feat: 切换Agent自主知识工具检索链路
This commit is contained in:
@@ -11,10 +11,12 @@ from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.chat import ChatMessage
|
||||
from app.models.knowledge import KnowledgeRetrievalLog
|
||||
from app.models.user import User
|
||||
from app.services.ai_request_log_service import AiRequestLogService
|
||||
from app.services.chat_service import ChatService, _title_from_question
|
||||
from app.services.external_errors import ExternalServiceError
|
||||
from app.services.human_attention_service import HumanAttentionService
|
||||
from app.services.model_stream_service import ModelStreamService
|
||||
from app.services.rag_async_service import AsyncRagService
|
||||
from app.services.rag_service import RagService
|
||||
@@ -83,6 +85,7 @@ class ChatStreamService:
|
||||
error_message=str(exc),
|
||||
retrieved_chunks=rag_result.chunks if rag_result is not None else None,
|
||||
)
|
||||
_mark_retrieval_failed(db, rag_result, str(exc), cost_ms)
|
||||
db.commit()
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
|
||||
|
||||
@@ -102,6 +105,7 @@ class ChatStreamService:
|
||||
error_message="模型未返回有效内容",
|
||||
retrieved_chunks=rag_result.chunks if rag_result is not None else None,
|
||||
)
|
||||
_mark_retrieval_failed(db, rag_result, "模型未返回有效内容", cost_ms)
|
||||
db.commit()
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="模型未返回有效内容")
|
||||
|
||||
@@ -188,6 +192,7 @@ class ChatStreamService:
|
||||
normalized_question,
|
||||
history=history,
|
||||
session_summary=getattr(session, "summary", None),
|
||||
session_id=session.id,
|
||||
)
|
||||
model_response = ModelStreamService.stream_async(db, rag_result)
|
||||
async for chunk in model_response.chunks:
|
||||
@@ -304,9 +309,52 @@ def _write_success(
|
||||
cost_ms=cost_ms,
|
||||
retrieved_chunks=rag_result.chunks if rag_result is not None else None,
|
||||
)
|
||||
if rag_result is not None and rag_result.retrieval_log_id:
|
||||
retrieval_log = db.get(KnowledgeRetrievalLog, rag_result.retrieval_log_id)
|
||||
if retrieval_log:
|
||||
attention = HumanAttentionService.create_if_needed(
|
||||
db,
|
||||
session_id=session.id,
|
||||
message_id=user_message_id_for_attention(db, session.id, assistant_message.id),
|
||||
user_id=user.id,
|
||||
question=question,
|
||||
answer=answer,
|
||||
knowledge_missing=not rag_result.allow_general_knowledge and not rag_result.is_hit,
|
||||
)
|
||||
retrieval_log.message_id = assistant_message.id
|
||||
retrieval_log.final_answer = answer
|
||||
retrieval_log.status = "success"
|
||||
retrieval_log.total_cost_ms = cost_ms
|
||||
retrieval_log.attention_created = 1 if attention else 0
|
||||
db.add(retrieval_log)
|
||||
db.commit()
|
||||
|
||||
|
||||
def user_message_id_for_attention(db: Session, session_id: int, assistant_message_id: int) -> int:
|
||||
message_id = db.scalar(
|
||||
select(ChatMessage.id)
|
||||
.where(
|
||||
ChatMessage.session_id == session_id,
|
||||
ChatMessage.role == "user",
|
||||
ChatMessage.id < assistant_message_id,
|
||||
)
|
||||
.order_by(ChatMessage.id.desc())
|
||||
.limit(1)
|
||||
)
|
||||
return int(message_id or assistant_message_id)
|
||||
|
||||
|
||||
def _mark_retrieval_failed(db: Session, rag_result, error_message: str, cost_ms: int) -> None:
|
||||
if rag_result is None or not rag_result.retrieval_log_id:
|
||||
return
|
||||
retrieval_log = db.get(KnowledgeRetrievalLog, rag_result.retrieval_log_id)
|
||||
if retrieval_log:
|
||||
retrieval_log.status = "failed"
|
||||
retrieval_log.error_message = error_message
|
||||
retrieval_log.total_cost_ms = cost_ms
|
||||
db.add(retrieval_log)
|
||||
|
||||
|
||||
def _build_rag_result(db: Session, user: User, question: str, history: list[ChatMessage], summary: str | None):
|
||||
parameters = signature(RagService.build_result).parameters
|
||||
if "history" in parameters:
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.knowledge import HumanAttentionHistory, HumanAttentionRecord
|
||||
|
||||
URGENT_TERMS = ("自杀", "不想活", "自伤", "伤害别人", "杀人", "现实危险")
|
||||
IMPORTANT_TERMS = ("绝望", "撑不住", "崩溃", "非常痛苦", "反复失败", "没有办法")
|
||||
CONTACT_TERMS = ("联系老师", "找老师", "人工帮助", "人工客服")
|
||||
|
||||
|
||||
class HumanAttentionService:
|
||||
@staticmethod
|
||||
def create_if_needed(
|
||||
db: Session,
|
||||
*,
|
||||
session_id: int,
|
||||
message_id: int,
|
||||
user_id: int,
|
||||
question: str,
|
||||
answer: str,
|
||||
knowledge_missing: bool,
|
||||
) -> HumanAttentionRecord | None:
|
||||
priority = None
|
||||
reason = None
|
||||
if any(term in question for term in URGENT_TERMS):
|
||||
priority, reason = "urgent", "检测到现实危险或自伤伤人风险"
|
||||
elif any(term in question for term in IMPORTANT_TERMS):
|
||||
priority, reason = "important", "用户表达持续或强烈痛苦"
|
||||
elif any(term in question for term in CONTACT_TERMS):
|
||||
priority, reason = "normal", "用户主动要求联系老师或人工"
|
||||
elif knowledge_missing:
|
||||
priority, reason = "normal", "课程或业务问题缺少可靠正式知识"
|
||||
if priority is None:
|
||||
return None
|
||||
record = HumanAttentionRecord(
|
||||
session_id=session_id,
|
||||
message_id=message_id,
|
||||
user_id=user_id,
|
||||
trigger_message=question,
|
||||
problem_summary=_summary(question),
|
||||
trigger_reason=reason,
|
||||
priority=priority,
|
||||
status="pending",
|
||||
)
|
||||
db.add(record)
|
||||
db.flush()
|
||||
db.add(
|
||||
HumanAttentionHistory(
|
||||
attention_id=record.id,
|
||||
from_status=None,
|
||||
to_status="pending",
|
||||
note=f"系统自动创建;回答摘要:{_summary(answer, 200)}",
|
||||
operated_by=0,
|
||||
)
|
||||
)
|
||||
return record
|
||||
|
||||
|
||||
def _summary(text: str, limit: int = 120) -> str:
|
||||
value = " ".join(text.split())
|
||||
return value if len(value) <= limit else value[:limit].rstrip() + "…"
|
||||
@@ -0,0 +1,293 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from time import perf_counter
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.models.ai_config import ModelConfig
|
||||
from app.models.knowledge import (
|
||||
Knowledge,
|
||||
KnowledgeChunk,
|
||||
KnowledgeManifest,
|
||||
KnowledgeRetrievalCandidate,
|
||||
KnowledgeRetrievalLog,
|
||||
KnowledgeSection,
|
||||
KnowledgeVersion,
|
||||
)
|
||||
from app.services.knowledge_pipeline_service import expand_synonyms, extract_terms
|
||||
from app.services.knowledge_service import KnowledgeScope
|
||||
from app.services.model_service import _call_configured_model, _system_config_bool
|
||||
from app.services.rag_service import PromptService, RagResult, RetrievedChunk
|
||||
|
||||
SAFETY_RULE_VERSION = "minimum-safety-v1"
|
||||
MAX_LEXICAL_CANDIDATES = 12
|
||||
MAX_SELECTED_SECTIONS = 4
|
||||
BUSINESS_MARKERS = {"课程", "大本营", "训练营", "老师", "卢慧", "功课", "学员", "课堂", "练习", "觉察", "内在"}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Candidate:
|
||||
chunk: KnowledgeChunk
|
||||
section: KnowledgeSection
|
||||
knowledge: Knowledge
|
||||
version: KnowledgeVersion
|
||||
lexical_score: float
|
||||
rerank_score: float | None = None
|
||||
selected: bool = False
|
||||
discard_reason: str | None = None
|
||||
|
||||
|
||||
class KnowledgeAgentService:
|
||||
@classmethod
|
||||
async def build_result(
|
||||
cls,
|
||||
db: Session,
|
||||
*,
|
||||
question: str,
|
||||
history=None,
|
||||
session_summary: str | None = None,
|
||||
session_id: int | None = None,
|
||||
user_id: int | None = None,
|
||||
version_overrides: dict[int, int] | None = None,
|
||||
preview_knowledge_ids: list[int] | None = None,
|
||||
) -> RagResult:
|
||||
started = perf_counter()
|
||||
catalog = cls.get_knowledge_catalog(
|
||||
db, version_overrides=version_overrides, preview_knowledge_ids=preview_knowledge_ids
|
||||
)
|
||||
catalog_json = json.dumps(catalog, ensure_ascii=False, sort_keys=True, default=str)
|
||||
log = KnowledgeRetrievalLog(
|
||||
session_id=session_id,
|
||||
user_id=user_id,
|
||||
question=question.strip(),
|
||||
catalog_snapshot=catalog_json,
|
||||
catalog_hash=hashlib.sha256(catalog_json.encode()).hexdigest(),
|
||||
safety_rule_version=SAFETY_RULE_VERSION,
|
||||
status="running",
|
||||
)
|
||||
db.add(log)
|
||||
db.flush()
|
||||
scopes = [KnowledgeScope(id=item["knowledgeId"], name=item["name"], feishu_space_id="", feishu_node_id="") for item in catalog]
|
||||
trace: list[dict] = [{"tool": "get_knowledge_catalog", "count": len(catalog)}]
|
||||
need_knowledge, selected_ids, reason = cls._decide(question, catalog)
|
||||
trace.append({"tool": "agent_decision", "needKnowledge": need_knowledge, "selectedKnowledgeIds": selected_ids, "reason": reason})
|
||||
chunks: list[RetrievedChunk] = []
|
||||
try:
|
||||
if need_knowledge and selected_ids:
|
||||
terms = cls._query_terms(question)
|
||||
candidates = cls.search_knowledge(db, terms, selected_ids, catalog)
|
||||
trace.append({"tool": "search_knowledge", "queryTerms": terms, "candidateCount": len(candidates)})
|
||||
await cls._rerank(db, question, candidates, trace)
|
||||
selected = cls._select_sections(candidates)
|
||||
chunks = [
|
||||
RetrievedChunk(
|
||||
knowledge_id=item.knowledge.id,
|
||||
knowledge_name=item.knowledge.name,
|
||||
title=item.section.title,
|
||||
content=cls._protect_content(item.section.content),
|
||||
version_id=item.version.id,
|
||||
section_id=item.section.id,
|
||||
chunk_id=item.chunk.id,
|
||||
score=item.rerank_score if item.rerank_score is not None else item.lexical_score,
|
||||
)
|
||||
for item in selected
|
||||
]
|
||||
trace.append({"tool": "read_knowledge_sections", "sectionIds": [item.section.id for item in selected]})
|
||||
cls._persist_candidates(db, log.id, candidates)
|
||||
log.knowledge_called = 1
|
||||
log.selected_knowledge_ids = ",".join(str(item) for item in selected_ids)
|
||||
log.query_terms = json.dumps(terms, ensure_ascii=False)
|
||||
log.final_section_ids = ",".join(str(item.section.id) for item in selected)
|
||||
log.tool_trace = json.dumps(trace, ensure_ascii=False)
|
||||
log.tool_cost_ms = int((perf_counter() - started) * 1000)
|
||||
log.status = "prepared"
|
||||
db.add(log)
|
||||
return RagResult(
|
||||
question=question,
|
||||
knowledge_scopes=scopes,
|
||||
chunks=chunks,
|
||||
prompt=PromptService.build_prompt(db, question, chunks, history, session_summary),
|
||||
allow_general_knowledge=not need_knowledge,
|
||||
retrieval_log_id=log.id,
|
||||
tool_trace=trace,
|
||||
)
|
||||
except Exception as exc:
|
||||
log.status = "failed"
|
||||
log.error_message = str(exc)
|
||||
log.tool_trace = json.dumps(trace, ensure_ascii=False)
|
||||
log.tool_cost_ms = int((perf_counter() - started) * 1000)
|
||||
db.add(log)
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def get_knowledge_catalog(
|
||||
db: Session,
|
||||
*,
|
||||
version_overrides: dict[int, int] | None = None,
|
||||
preview_knowledge_ids: list[int] | None = None,
|
||||
) -> list[dict]:
|
||||
query = select(Knowledge).where(Knowledge.lifecycle_status == "active")
|
||||
if preview_knowledge_ids is None:
|
||||
query = query.where(
|
||||
Knowledge.status == 1,
|
||||
Knowledge.current_version_id.is_not(None),
|
||||
Knowledge.manifest_confirmed == 1,
|
||||
Knowledge.source_status == "normal",
|
||||
)
|
||||
elif preview_knowledge_ids:
|
||||
query = query.where(Knowledge.id.in_(preview_knowledge_ids))
|
||||
rows = db.scalars(query.order_by(Knowledge.id)).all()
|
||||
catalog: list[dict] = []
|
||||
for knowledge in rows:
|
||||
version_id = (version_overrides or {}).get(knowledge.id) or knowledge.current_version_id or knowledge.pending_version_id
|
||||
if not version_id:
|
||||
continue
|
||||
version = db.get(KnowledgeVersion, version_id)
|
||||
manifest = db.scalar(select(KnowledgeManifest).where(KnowledgeManifest.version_id == version_id))
|
||||
if version is None or manifest is None:
|
||||
continue
|
||||
catalog.append(
|
||||
{
|
||||
"knowledgeId": knowledge.id,
|
||||
"name": knowledge.name,
|
||||
"type": knowledge.knowledge_type,
|
||||
"purpose": manifest.purpose,
|
||||
"applicableQuestions": manifest.applicable_questions,
|
||||
"inapplicableQuestions": manifest.inapplicable_questions,
|
||||
"coreTopics": manifest.core_topics,
|
||||
"boundaries": manifest.boundaries,
|
||||
"versionId": version.id,
|
||||
"versionNo": version.version_no,
|
||||
"publishedAt": version.published_at,
|
||||
}
|
||||
)
|
||||
return catalog
|
||||
|
||||
@classmethod
|
||||
def search_knowledge(cls, db: Session, terms: list[str], selected_ids: list[int], catalog: list[dict]) -> list[Candidate]:
|
||||
versions_by_kb = {item["knowledgeId"]: item["versionId"] for item in catalog}
|
||||
version_ids = [versions_by_kb[item] for item in selected_ids if item in versions_by_kb]
|
||||
if not version_ids:
|
||||
return []
|
||||
chunks = db.scalars(select(KnowledgeChunk).where(KnowledgeChunk.version_id.in_(version_ids))).all()
|
||||
sections = {item.id: item for item in db.scalars(select(KnowledgeSection).where(KnowledgeSection.version_id.in_(version_ids))).all()}
|
||||
knowledge_rows = {item.id: item for item in db.scalars(select(Knowledge).where(Knowledge.id.in_(selected_ids))).all()}
|
||||
versions = {item.id: item for item in db.scalars(select(KnowledgeVersion).where(KnowledgeVersion.id.in_(version_ids))).all()}
|
||||
frequencies = {term: sum(1 for chunk in chunks if term in f"{chunk.title}{chunk.normalized_text}") for term in terms}
|
||||
candidates: list[Candidate] = []
|
||||
for chunk in chunks:
|
||||
section, knowledge, version = sections.get(chunk.section_id), knowledge_rows.get(chunk.knowledge_id), versions.get(chunk.version_id)
|
||||
if not section or not knowledge or not version:
|
||||
continue
|
||||
haystack = f"{chunk.title} {chunk.normalized_text} {chunk.content}".lower()
|
||||
score = 0.0
|
||||
for term in terms:
|
||||
count = haystack.count(term)
|
||||
if count:
|
||||
idf = math.log((len(chunks) + 1) / (frequencies.get(term, 0) + 1)) + 1
|
||||
score += (count / (count + 1.2)) * idf * (2.4 if term in chunk.title.lower() else 1.0)
|
||||
if score > 0:
|
||||
candidates.append(Candidate(chunk, section, knowledge, version, score))
|
||||
candidates.sort(key=lambda item: (item.lexical_score, item.version.published_at or item.version.created_at), reverse=True)
|
||||
return candidates[:MAX_LEXICAL_CANDIDATES]
|
||||
|
||||
@classmethod
|
||||
async def _rerank(cls, db: Session, question: str, candidates: list[Candidate], trace: list[dict]) -> None:
|
||||
if not candidates:
|
||||
return
|
||||
model = db.scalar(select(ModelConfig).where(ModelConfig.enabled == 1).order_by(ModelConfig.id.desc()).limit(1))
|
||||
if model is None or _system_config_bool(db, "mock_model_enabled", get_settings().mock_model_enabled):
|
||||
trace.append({"tool": "model_rerank", "status": "lexical_fallback", "reason": "模型未启用或处于 mock"})
|
||||
return
|
||||
prompt = (
|
||||
"你是知识检索重排器。根据用户问题给候选打0到100相关分,只返回JSON:"
|
||||
'{"scores":[{"index":1,"score":90}]}。不要回答问题。\n'
|
||||
f"用户问题:{question}\n候选:\n"
|
||||
+ "\n".join(f"[{index}] {item.knowledge.name}/{item.chunk.title}: {item.chunk.content[:350]}" for index, item in enumerate(candidates, 1))
|
||||
)
|
||||
rag = RagResult(question=question, knowledge_scopes=[], chunks=[], prompt=prompt, allow_general_knowledge=True)
|
||||
try:
|
||||
raw = await asyncio.to_thread(_call_configured_model, model, rag, allow_no_hit=True)
|
||||
payload = json.loads(_extract_json(raw))
|
||||
score_map = {int(item["index"]): max(0.0, min(100.0, float(item["score"]))) for item in payload.get("scores", [])}
|
||||
for index, item in enumerate(candidates, 1):
|
||||
item.rerank_score = score_map.get(index)
|
||||
candidates.sort(key=lambda item: item.rerank_score if item.rerank_score is not None else item.lexical_score, reverse=True)
|
||||
trace.append({"tool": "model_rerank", "status": "success", "candidateCount": len(candidates)})
|
||||
except Exception as exc:
|
||||
trace.append({"tool": "model_rerank", "status": "lexical_fallback", "reason": str(exc)[:300]})
|
||||
|
||||
@staticmethod
|
||||
def _select_sections(candidates: list[Candidate]) -> list[Candidate]:
|
||||
selected: list[Candidate] = []
|
||||
seen: set[int] = set()
|
||||
for item in candidates:
|
||||
score = item.rerank_score if item.rerank_score is not None else item.lexical_score * 10
|
||||
if score < 8:
|
||||
item.discard_reason = "相关性不足"
|
||||
elif item.section.id in seen:
|
||||
item.discard_reason = "同一父章节已有更高分候选"
|
||||
elif len(selected) >= MAX_SELECTED_SECTIONS:
|
||||
item.discard_reason = "超过本轮章节数量限制"
|
||||
else:
|
||||
item.selected = True
|
||||
selected.append(item)
|
||||
seen.add(item.section.id)
|
||||
return selected
|
||||
|
||||
@staticmethod
|
||||
def _persist_candidates(db: Session, log_id: int, candidates: list[Candidate]) -> None:
|
||||
for item in candidates:
|
||||
db.add(KnowledgeRetrievalCandidate(
|
||||
retrieval_log_id=log_id,
|
||||
knowledge_id=item.knowledge.id,
|
||||
version_id=item.version.id,
|
||||
chunk_id=item.chunk.id,
|
||||
section_id=item.section.id,
|
||||
lexical_score=f"{item.lexical_score:.6f}",
|
||||
rerank_score=f"{item.rerank_score:.4f}" if item.rerank_score is not None else None,
|
||||
selected=1 if item.selected else 0,
|
||||
discard_reason=item.discard_reason,
|
||||
))
|
||||
|
||||
@staticmethod
|
||||
def _decide(question: str, catalog: list[dict]) -> tuple[bool, list[int], str]:
|
||||
business = any(marker in question for marker in BUSINESS_MARKERS)
|
||||
if not catalog:
|
||||
return business, [], "当前没有可用正式知识库"
|
||||
terms = set(extract_terms(question))
|
||||
ranked: list[tuple[float, int]] = []
|
||||
for item in catalog:
|
||||
manifest_terms = set(extract_terms(" ".join(str(item.get(key, "")) for key in ("name", "purpose", "applicableQuestions", "coreTopics"))))
|
||||
overlap = len(terms & manifest_terms) / max(1, min(len(terms), 18))
|
||||
if overlap > 0 or business:
|
||||
ranked.append((overlap, int(item["knowledgeId"])))
|
||||
ranked.sort(reverse=True)
|
||||
selected = [item_id for score, item_id in ranked if score >= 0.04][:4]
|
||||
if business and not selected:
|
||||
selected = [item_id for _, item_id in ranked[:3]]
|
||||
return business or bool(selected), selected, "涉及课程/老师/业务知识" if business else "问题与知识目录主题相关"
|
||||
|
||||
@staticmethod
|
||||
def _query_terms(question: str) -> list[str]:
|
||||
base = extract_terms(question)
|
||||
return list(dict.fromkeys(base + expand_synonyms(base)))[:40]
|
||||
|
||||
@staticmethod
|
||||
def _protect_content(content: str) -> str:
|
||||
return content if len(content) <= 3200 else content[:3200].rstrip() + "\n[章节内容已按保护规则截断]"
|
||||
|
||||
|
||||
def _extract_json(value: str) -> str:
|
||||
match = re.search(r"\{[\s\S]*\}", value)
|
||||
if not match:
|
||||
raise ValueError("重排模型未返回 JSON")
|
||||
return match.group(0)
|
||||
@@ -37,7 +37,7 @@ class ModelClientService:
|
||||
if model is None:
|
||||
raise ExternalServiceError("未启用可用模型,请先在模型管理中启用一个模型。", provider="model")
|
||||
model_name = model.model_name
|
||||
answer = _call_configured_model(model, rag_result)
|
||||
answer = _call_configured_model(model, rag_result, allow_no_hit=rag_result.allow_general_knowledge)
|
||||
return ModelCompletion(
|
||||
answer=answer,
|
||||
model_id=model.id if model is not None else None,
|
||||
@@ -185,7 +185,7 @@ def _call_openai_compatible_model(model: ModelConfig, rag_result: RagResult) ->
|
||||
payload = {
|
||||
"model": model.model_name,
|
||||
"messages": [
|
||||
{"role": "system", "content": "你是大本营答疑助手,只能基于已提供的知识片段回答。"},
|
||||
{"role": "system", "content": _agent_system_prompt()},
|
||||
{"role": "user", "content": rag_result.prompt},
|
||||
],
|
||||
"stream": False,
|
||||
@@ -218,7 +218,7 @@ def _call_anthropic_messages(model: ModelConfig, rag_result: RagResult) -> str:
|
||||
payload = {
|
||||
"model": model.model_name,
|
||||
"max_tokens": model.max_token or 1024,
|
||||
"system": "你是大本营答疑助手,只能基于已提供的知识片段回答。",
|
||||
"system": _agent_system_prompt(),
|
||||
"messages": [{"role": "user", "content": rag_result.prompt}],
|
||||
"stream": False,
|
||||
}
|
||||
@@ -252,7 +252,7 @@ def _call_gemini_generate_content(model: ModelConfig, rag_result: RagResult) ->
|
||||
|
||||
payload = {
|
||||
"systemInstruction": {
|
||||
"parts": [{"text": "你是大本营答疑助手,只能基于已提供的知识片段回答。"}]
|
||||
"parts": [{"text": _agent_system_prompt()}]
|
||||
},
|
||||
"contents": [{"role": "user", "parts": [{"text": rag_result.prompt}]}],
|
||||
}
|
||||
@@ -337,7 +337,7 @@ def _call_minimax(model: ModelConfig, rag_result: RagResult) -> str:
|
||||
"bot_setting": [
|
||||
{
|
||||
"bot_name": "大本营答疑助手",
|
||||
"content": "你是大本营答疑助手,只能基于已提供的知识片段回答。",
|
||||
"content": _agent_system_prompt(),
|
||||
}
|
||||
],
|
||||
}
|
||||
@@ -435,6 +435,13 @@ def _model_api_key(model: ModelConfig) -> str:
|
||||
return SecretService.decrypt(model.api_key)
|
||||
|
||||
|
||||
def _agent_system_prompt() -> str:
|
||||
return (
|
||||
"你是大本营答疑助手。必须遵守最低安全规则。涉及课程、老师观点和公司业务时只能依据提供的正式知识,"
|
||||
"没有可靠知识必须明确依据不足;普通常识可以谨慎回答。不得编造老师观点,不得输出整篇课程资料或大段连续原文。"
|
||||
)
|
||||
|
||||
|
||||
def _put_if_not_none(target: dict[str, Any], key: str, value: Any) -> None:
|
||||
if value is not None:
|
||||
target[key] = value
|
||||
|
||||
@@ -15,6 +15,7 @@ from app.models.ai_config import ModelConfig
|
||||
from app.services.external_errors import ExternalServiceError
|
||||
from app.services.model_service import (
|
||||
_anthropic_headers,
|
||||
_agent_system_prompt,
|
||||
_auth_headers,
|
||||
_call_configured_model,
|
||||
_decimal_to_float,
|
||||
@@ -60,7 +61,7 @@ class ModelStreamService:
|
||||
chunks=_chunk_text(_mock_answer(rag_result)),
|
||||
)
|
||||
|
||||
if not rag_result.is_hit:
|
||||
if not rag_result.is_hit and not rag_result.allow_general_knowledge:
|
||||
return StreamingModelResponse(
|
||||
model_id=model.id if model is not None else None,
|
||||
model_name=model.model_name if model is not None else "no-hit",
|
||||
@@ -94,7 +95,7 @@ class ModelStreamService:
|
||||
chunks=_async_chunk_text(_mock_answer(rag_result)),
|
||||
)
|
||||
|
||||
if not rag_result.is_hit:
|
||||
if not rag_result.is_hit and not rag_result.allow_general_knowledge:
|
||||
return AsyncStreamingModelResponse(
|
||||
model_id=model.id if model is not None else None,
|
||||
model_name=model.model_name if model is not None else "no-hit",
|
||||
@@ -127,18 +128,20 @@ def _get_enabled_model(db: Session) -> ModelConfig | None:
|
||||
def _stream_configured_model(model: ModelConfig, rag_result: RagResult) -> Iterator[str]:
|
||||
api_type = model.api_type or "openai_compatible"
|
||||
if model.stream_enabled != 1:
|
||||
return _chunk_text(_call_configured_model(model, rag_result))
|
||||
return _chunk_text(_call_configured_model(model, rag_result, allow_no_hit=rag_result.allow_general_knowledge))
|
||||
if api_type == "anthropic_messages":
|
||||
return _stream_anthropic_messages(model, rag_result)
|
||||
if api_type == "openai_compatible":
|
||||
return _stream_openai_compatible_model(model, rag_result)
|
||||
return _chunk_text(_call_configured_model(model, rag_result))
|
||||
return _chunk_text(_call_configured_model(model, rag_result, allow_no_hit=rag_result.allow_general_knowledge))
|
||||
|
||||
|
||||
async def _stream_configured_model_async(model: ModelConfig, rag_result: RagResult) -> AsyncIterator[str]:
|
||||
api_type = model.api_type or "openai_compatible"
|
||||
if model.stream_enabled != 1:
|
||||
answer = await asyncio.to_thread(_call_configured_model, model, rag_result)
|
||||
answer = await asyncio.to_thread(
|
||||
_call_configured_model, model, rag_result, allow_no_hit=rag_result.allow_general_knowledge
|
||||
)
|
||||
async for chunk in _async_chunk_text(answer):
|
||||
yield chunk
|
||||
return
|
||||
@@ -153,7 +156,9 @@ async def _stream_configured_model_async(model: ModelConfig, rag_result: RagResu
|
||||
yield chunk
|
||||
return
|
||||
|
||||
answer = await asyncio.to_thread(_call_configured_model, model, rag_result)
|
||||
answer = await asyncio.to_thread(
|
||||
_call_configured_model, model, rag_result, allow_no_hit=rag_result.allow_general_knowledge
|
||||
)
|
||||
async for chunk in _async_chunk_text(answer):
|
||||
yield chunk
|
||||
|
||||
@@ -162,7 +167,7 @@ def _stream_openai_compatible_model(model: ModelConfig, rag_result: RagResult) -
|
||||
payload: dict[str, Any] = {
|
||||
"model": model.model_name,
|
||||
"messages": [
|
||||
{"role": "system", "content": "你是大本营答疑助手,只能基于已提供的知识片段回答。"},
|
||||
{"role": "system", "content": _agent_system_prompt()},
|
||||
{"role": "user", "content": rag_result.prompt},
|
||||
],
|
||||
"max_tokens": model.max_token or 1024,
|
||||
@@ -211,7 +216,7 @@ def _openai_stream_payload(model: ModelConfig, rag_result: RagResult) -> dict[st
|
||||
payload: dict[str, Any] = {
|
||||
"model": model.model_name,
|
||||
"messages": [
|
||||
{"role": "system", "content": "你是大本营答疑助手,只能基于已提供的知识片段回答。"},
|
||||
{"role": "system", "content": _agent_system_prompt()},
|
||||
{"role": "user", "content": rag_result.prompt},
|
||||
],
|
||||
"max_tokens": model.max_token or 1024,
|
||||
@@ -317,7 +322,7 @@ def _stream_anthropic_messages(model: ModelConfig, rag_result: RagResult) -> Ite
|
||||
payload: dict[str, Any] = {
|
||||
"model": model.model_name,
|
||||
"max_tokens": model.max_token or 1024,
|
||||
"system": "你是大本营答疑助手,只能基于已提供的知识片段回答。",
|
||||
"system": _agent_system_prompt(),
|
||||
"messages": [{"role": "user", "content": rag_result.prompt}],
|
||||
}
|
||||
_put_if_not_none(payload, "temperature", _decimal_to_float(model.temperature))
|
||||
@@ -361,7 +366,7 @@ def _anthropic_stream_payload(model: ModelConfig, rag_result: RagResult) -> dict
|
||||
payload: dict[str, Any] = {
|
||||
"model": model.model_name,
|
||||
"max_tokens": model.max_token or 1024,
|
||||
"system": "你是大本营答疑助手,只能基于已提供的知识片段回答。",
|
||||
"system": _agent_system_prompt(),
|
||||
"messages": [{"role": "user", "content": rag_result.prompt}],
|
||||
}
|
||||
_put_if_not_none(payload, "temperature", _decimal_to_float(model.temperature))
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from inspect import signature
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.chat import ChatMessage
|
||||
from app.models.user import User
|
||||
from app.services.feishu_async_service import AsyncFeishuKnowledgeService
|
||||
from app.services.knowledge_service import KnowledgeAccessService
|
||||
from app.services.rag_service import PromptService, RagResult
|
||||
from app.services.knowledge_agent_service import KnowledgeAgentService
|
||||
from app.services.rag_service import RagResult
|
||||
|
||||
|
||||
class AsyncRagService:
|
||||
@@ -19,21 +16,13 @@ class AsyncRagService:
|
||||
question: str,
|
||||
history: list[ChatMessage] | None = None,
|
||||
session_summary: str | None = None,
|
||||
session_id: int | None = None,
|
||||
) -> RagResult:
|
||||
scopes = KnowledgeAccessService.get_allowed_knowledge(db, user)
|
||||
chunks = await AsyncFeishuKnowledgeService.retrieve(question, scopes, db)
|
||||
prompt = _build_prompt(db, question, chunks, history, session_summary)
|
||||
return RagResult(question=question, knowledge_scopes=scopes, chunks=chunks, prompt=prompt)
|
||||
|
||||
|
||||
def _build_prompt(
|
||||
db: Session,
|
||||
question: str,
|
||||
chunks,
|
||||
history: list[ChatMessage] | None,
|
||||
session_summary: str | None,
|
||||
) -> str:
|
||||
parameters = signature(PromptService.build_prompt).parameters
|
||||
if "history" in parameters:
|
||||
return PromptService.build_prompt(db, question, chunks, history, session_summary)
|
||||
return PromptService.build_prompt(db, question, chunks)
|
||||
return await KnowledgeAgentService.build_result(
|
||||
db,
|
||||
question=question,
|
||||
history=history,
|
||||
session_summary=session_summary,
|
||||
session_id=session_id,
|
||||
user_id=user.id,
|
||||
)
|
||||
|
||||
@@ -26,6 +26,10 @@ class RetrievedChunk:
|
||||
title: str
|
||||
content: str
|
||||
source_url: str | None = None
|
||||
version_id: int | None = None
|
||||
section_id: int | None = None
|
||||
chunk_id: int | None = None
|
||||
score: float | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -34,6 +38,9 @@ class RagResult:
|
||||
knowledge_scopes: list[KnowledgeScope]
|
||||
chunks: list[RetrievedChunk]
|
||||
prompt: str
|
||||
allow_general_knowledge: bool = False
|
||||
retrieval_log_id: int | None = None
|
||||
tool_trace: list[dict] | None = None
|
||||
|
||||
@property
|
||||
def is_hit(self) -> bool:
|
||||
@@ -41,7 +48,8 @@ class RagResult:
|
||||
|
||||
@property
|
||||
def knowledge_ids(self) -> str:
|
||||
return ",".join(str(scope.id) for scope in self.knowledge_scopes)
|
||||
ids = list(dict.fromkeys(chunk.knowledge_id for chunk in self.chunks))
|
||||
return ",".join(str(item) for item in ids)
|
||||
|
||||
|
||||
class RagService:
|
||||
@@ -61,8 +69,7 @@ class RagService:
|
||||
|
||||
class PromptService:
|
||||
_default_prompt = (
|
||||
"你是大本营答疑助手。你只能基于提供的知识片段回答,不能编造。"
|
||||
"如果知识片段之间有冲突,需要说明不同来源的观点。"
|
||||
"你是大本营答疑助手。必须遵守系统安全边界,涉及课程、老师观点和公司业务时只能依据可靠知识,不能编造。"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@@ -78,13 +85,18 @@ class PromptService:
|
||||
|
||||
# 知识库上下文
|
||||
context = "\n\n".join(
|
||||
f"[知识片段 {index}] 来源:{chunk.knowledge_name} / {chunk.title}\n{chunk.content}"
|
||||
f"[已回读完整章节 {index}] {chunk.title}\n{chunk.content}"
|
||||
for index, chunk in enumerate(chunks, start=1)
|
||||
)
|
||||
if not context:
|
||||
context = "未检索到相关知识片段。"
|
||||
context = "本轮没有可靠的正式知识章节。对于一般常识可以谨慎回答;涉及课程、老师观点或公司业务时必须说明依据不足,不得编造。"
|
||||
|
||||
parts = [prompt]
|
||||
parts.append(
|
||||
"[不可关闭的最低安全规则 v1]\n"
|
||||
"现实危险、自伤伤人风险应优先建议立即寻求线下专业帮助;医疗、法律、财务问题不得给出替代专业意见的结论;"
|
||||
"不得伪造老师观点或课程内容;不得输出整篇课程文章、大段连续原文,也不得通过多轮拼接还原完整资料。"
|
||||
)
|
||||
|
||||
# 对话历史:摘要 + 最近 N 轮原文
|
||||
history_part = cls._build_history_section(history, session_summary)
|
||||
|
||||
Reference in New Issue
Block a user