feat: 切换Agent自主知识工具检索链路

This commit is contained in:
2026-07-11 19:14:17 +08:00
parent d2d43de6d3
commit 6cadca8c8e
14 changed files with 863 additions and 62 deletions

View File

@@ -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:

View File

@@ -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() + ""

View File

@@ -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)

View File

@@ -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

View File

@@ -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))

View File

@@ -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,
)

View File

@@ -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)