feat: 客服 Agent 端到端跑通(知识直返 + 答不了引导人工客服)
按业务方确定的取向实现:金融场景确定性优先,能溯源到公司资料的才答,答不了就 引导客户拨打客服热线,绝不用模型猜答案。端到端验收 8/8 通过。 新增: - app/service/knowledge_search_service.py:知识检索。未复用记忆的 VectorMemoryAdapter 是因为它只返回 (memory_uuid, score),会丢掉知识块的标题与正文,而客服回答必须能把 原文与出处一起交付。检索失败一律返回 degraded 而不抛异常,由 Agent 走兜底。 - app/service/knowledge_tool.py + app/core/knowledge_contracts.py:只读工具 search_knowledge。 走 ToolExecutor 而不是让 Agent 直接持有检索服务,是为了让白名单、权限、审计、超时 都归基座统一管理;工具只读也符合 ToolRegistry 的硬约束。复用既有权限码 knowledge:reference:read(customer 角色已具备),不新增权限点。 - app/service/agent/implementations/customer_service.py:Agent 本体,刻意保持薄—— 意图分发 + 四条出口(faq/产品/政策直返、闲聊走模型、其余与异常引导人工)。 直接返回知识原文而不经模型改写,答案的字面内容全部来自公司已发布资料。 - tools/publish_customer_service_config.py:发布意图工具白名单。 - tools/customer_service_check.py:端到端验收(8 个用例,含越界请求与知识库外问题)。 装配: - bootstrap 新增 get_knowledge_search_service 工厂,注册 search_knowledge 工具与 customer_service Agent。 - runtime_config_service 新增 load_active_prompt:提示词绑定 release_id,按当前生效 版本读取,未发布时回落代码默认值。闲聊话术因此可审核、可回滚,不必改代码发版。 过程中发现并处理的三个问题: 1. 自造 source_references 被基座合规闸门拒绝。governance.review_output 只接受 「本次召回的记忆」与「本次成功调用的工具」两类引用(用于防止伪造来源), knowledge 类型会被判非法并使整个 run 失败。处理方式是**不放开那道校验**, 而把知识出处(文件标题与内部编号)写进正文,source_references 交给基座自动附加。 2. 发布配置是整版本替换语义:新版本会清空旧版本的全部配置项。若只发客服白名单, 示例 Agent 的 fund_query_demo:fund_quote 会被静默清空。故发布脚本先读取当前生效 版本的全部配置项并原样继承,再追加新增项。 3. 验收脚本自身两处自伤:打印 emoji 触发 GBK UnicodeEncodeError、以及读错结果字段 (RunQueryService 返回的答案键是 content 不是 text)。 已知缺口(未修,已记录): - CoreResult.transfer_required 未持久化:conversation_message 不存该标记, API 读不到"本次是否引导了人工"。当前靠正文里的固定话术判断。 - 知识块引用(source_type=knowledge)尚未启用,需先让 ToolExecutor 把工具返回的 doc_id 登记为本次可引用来源。 验证:ruff 通过、mypy 107 文件无错、unit+contract 447 passed; tools/customer_service_check.py 8/8 通过(含越界请求、投诉、知识库外问题三类 必须引导人工的场景,以及 7 个零容忍负面词零命中)。
This commit is contained in:
@@ -7,14 +7,18 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.core.config import get_settings
|
||||
from app.core.errors import RecoverableAgentError
|
||||
from app.core.fund_contracts import FundQuoteQuery
|
||||
from app.core.knowledge_contracts import KnowledgeSearchInput
|
||||
from app.infrastructure.fund_quote_cache import FundQuoteCache
|
||||
from app.infrastructure.memory_cache import MemoryCacheAdapter
|
||||
from app.infrastructure.vector_memory import VectorMemoryAdapter
|
||||
from app.service.agent.factory import AgentFactory
|
||||
from app.service.agent.governance import PlatformGovernance
|
||||
from app.service.agent.implementations.customer_service import CustomerServiceAgent
|
||||
from app.service.agent.implementations.fund_query_demo import FundQueryDemoAgent
|
||||
from app.service.fund_quote_service import query_fund_quote_tool
|
||||
from app.service.intent_classifier import IntentClassifier
|
||||
from app.service.knowledge_search_service import KnowledgeSearchService
|
||||
from app.service.knowledge_tool import knowledge_search_tool
|
||||
from app.service.memory_recall_service import MemoryRecallService
|
||||
from app.service.model_gateway import (
|
||||
DatabaseModelEndpointResolver,
|
||||
@@ -103,6 +107,25 @@ async def _embed_text(text: str) -> list[float]:
|
||||
return execution.vector
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_knowledge_search_service() -> KnowledgeSearchService:
|
||||
"""客服知识检索装配:Milvus 客户端 + 向量化入口。
|
||||
|
||||
与记忆的语义通道同一取向:Milvus 不可达或缺少 embedding 端点时**不抛异常**,
|
||||
而是返回 `available=False` 的实例,检索结果标记 `degraded`,由客服 Agent 走
|
||||
「引导客户致电人工客服」。基础设施故障不该表现成客户可见的错误。
|
||||
"""
|
||||
client = None
|
||||
try:
|
||||
from pymilvus import MilvusClient
|
||||
|
||||
settings = get_settings()
|
||||
client = MilvusClient(uri=settings.milvus_uri, token=settings.milvus_token or None)
|
||||
except Exception:
|
||||
logger.warning("knowledge vector client unavailable; search degrades", exc_info=True)
|
||||
return KnowledgeSearchService(client, _embed_text)
|
||||
|
||||
|
||||
def build_memory_recall_service(session: AsyncSession) -> MemoryRecallService:
|
||||
"""记忆召回组装:结构化召回始终可用,Redis 缓存与语义通道可用时叠加。
|
||||
|
||||
@@ -143,6 +166,17 @@ def get_agent_factory() -> AgentFactory:
|
||||
# (12s)与重试预算,多代码查询必然先撞工具超时。
|
||||
timeout_seconds=15,
|
||||
))
|
||||
registry.register(ToolDefinition(
|
||||
name="search_knowledge",
|
||||
input_model=KnowledgeSearchInput,
|
||||
handler=cast(Any, knowledge_search_tool),
|
||||
# 复用既有权限码(customer 角色已具备),不新增权限点
|
||||
required_permission="knowledge:reference:read",
|
||||
allowed_roles=("customer", "advisor", "operator", "admin"),
|
||||
# 检索含一次 embedding 调用 + 三次集合检索;embedding 实测 0.42s,
|
||||
# 10s 覆盖冷启动与 Milvus 抖动,又不至于让客户等太久
|
||||
timeout_seconds=10,
|
||||
))
|
||||
model_service = get_model_service()
|
||||
endpoint_resolver = DatabaseModelEndpointResolver()
|
||||
factory = AgentFactory(
|
||||
@@ -172,3 +206,10 @@ def register_business_agents(factory: AgentFactory) -> None:
|
||||
FundQueryDemoAgent.definition,
|
||||
lambda _context: FundQueryDemoAgent(FundQueryDemoAgent.definition),
|
||||
)
|
||||
# 客服 Agent:只回答能溯源到公司资料的问题,答不了引导客户致电人工客服。
|
||||
# 它声明了 5 个意图,但真正能调用 search_knowledge 的范围由发布配置逐意图收窄
|
||||
# (`agent_tools` 里 `customer_service:<intent>`),未发布的意图工具失败关闭。
|
||||
factory.register(
|
||||
CustomerServiceAgent.definition,
|
||||
lambda _context: CustomerServiceAgent(CustomerServiceAgent.definition),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
"""客服 Agent:只回答能溯源到公司资料的问题,答不了就引导客户致电人工客服。
|
||||
|
||||
设计取向(金融场景,由业务方确定):
|
||||
|
||||
- **确定性优先**:命中知识块后**直接返回原文**,不经模型改写。答案的字面内容来自公司
|
||||
已发布的资料,模型不参与事实生成,因此不存在"编一个看起来合理的答案"的通路。
|
||||
- **答不了就引导**:检索降级、未命中、置信度不足、意图未覆盖、模型异常——一律返回
|
||||
引导客户拨打客服热线的固定话术,并置 `transfer_required=True` 留痕;
|
||||
绝不用模型猜测答案。
|
||||
- **本体保持薄**:只有意图分发与四条出口,不做多轮推理、不自主决策。这是业务方明确
|
||||
要求的取向——"客服 Agent 本身不会很多内容,不会的就转人工"。
|
||||
|
||||
四条出口:
|
||||
`faq` → 检索直返(不经模型)|`product_inquiry` / `policy_explain` → 检索直返 + 来源引用
|
||||
|`chitchat` → 模型生成(提示词走发布配置)|其余与异常 → 引导人工客服
|
||||
"""
|
||||
|
||||
from app.core.contracts import (
|
||||
AgentDefinition,
|
||||
AgentRequest,
|
||||
CoreResult,
|
||||
RequestContext,
|
||||
SourceReference,
|
||||
)
|
||||
from app.core.errors import ForbiddenAgentError
|
||||
from app.service.agent.base import BaseAgent
|
||||
from app.service.model_gateway import DatabaseModelEndpointResolver
|
||||
from app.service.runtime_config_service import load_active_prompt
|
||||
|
||||
AGENT_TYPE = "customer_service"
|
||||
|
||||
# 意图码必须三处对齐:AgentDefinition.supported_intents、agent_intent_config 的
|
||||
# (agent_type, intent_code)、以及发布版 config_release 里 agent_tools 的
|
||||
# `customer_service:<intent>` 白名单 key。缺任一处即失败关闭,这是底座的有意设计。
|
||||
INTENT_FAQ = "faq"
|
||||
INTENT_PRODUCT = "product_inquiry"
|
||||
INTENT_POLICY = "policy_explain"
|
||||
INTENT_CHITCHAT = "chitchat"
|
||||
INTENT_TRANSFER = "transfer_human"
|
||||
BUSINESS_INTENTS = (INTENT_FAQ, INTENT_PRODUCT, INTENT_POLICY)
|
||||
|
||||
TOOL_NAME = "search_knowledge"
|
||||
|
||||
# 三档置信阈值(方案 §2.3,业务方已确认先按此跑通、后续用语料校准)。
|
||||
# 分数为 COSINE 相似度,落在 [0, 1]。
|
||||
HIGH_SCORE = 0.75 # ≥ 直接答
|
||||
MID_SCORE = 0.60 # ≥ 且 < 高置信:答 + 提示可能不完整;< 该值:不硬答,引导人工
|
||||
|
||||
TOP_K = 5
|
||||
MAX_ANSWER_CHARS = 1200
|
||||
REFERENCE_LIMIT = 3
|
||||
|
||||
COMPANY = "南方科技"
|
||||
# 客服热线:正式号码确定后改这里(或改为读配置项,避免改代码)
|
||||
HOTLINE = "400-XXX-XXXX"
|
||||
SERVICE_HOURS = "每日 7:00-22:00"
|
||||
|
||||
FALLBACK_TEMPLATE = (
|
||||
"抱歉,这个问题我暂时无法给出准确答复。为避免给您错误信息,"
|
||||
f"建议您拨打客服热线 {HOTLINE}({SERVICE_HOURS})转人工客服咨询。"
|
||||
)
|
||||
INCOMPLETE_NOTICE = "(以上信息可能不完整,具体以产品说明书与公司制度为准)"
|
||||
DISCLAIMER = "(以上内容由智能客服依据公司公开资料整理,不构成投资建议)"
|
||||
|
||||
CHITCHAT_PROMPT_CODE = "customer_service_chitchat"
|
||||
CHITCHAT_TASK_TYPE = "chat"
|
||||
DEFAULT_CHITCHAT_SYSTEM = (
|
||||
f"你是{COMPANY}的智能客服助手。回应要简短、礼貌,并自然引导用户提出与基金、理财、"
|
||||
"账户相关的问题。禁止承诺收益,禁止出现「保本」「稳赚」「无风险」「保证收益」"
|
||||
"「预期收益率」「年化收益率」「安全」等表述。"
|
||||
)
|
||||
DEFAULT_CHITCHAT_TEMPLATE = "用户说:{message}\n请用不超过 40 字回应,并把话题引导到业务上。"
|
||||
|
||||
|
||||
class CustomerServiceAgent(BaseAgent):
|
||||
definition = AgentDefinition(
|
||||
agent_type=AGENT_TYPE,
|
||||
version="1.0.0",
|
||||
allowed_roles=("customer",),
|
||||
allowed_portals=("api",),
|
||||
# 代码上限:实际可用范围由发布配置的意图白名单收窄(两者取交集)
|
||||
allowed_tools=(TOOL_NAME,),
|
||||
supported_intents=(
|
||||
INTENT_FAQ, INTENT_PRODUCT, INTENT_POLICY, INTENT_CHITCHAT, INTENT_TRANSFER,
|
||||
),
|
||||
)
|
||||
|
||||
async def handle(self, request: AgentRequest, context: RequestContext) -> CoreResult:
|
||||
intent = self._intent_code()
|
||||
if intent == INTENT_CHITCHAT:
|
||||
return await self._chitchat(request)
|
||||
if intent == INTENT_TRANSFER:
|
||||
return self._guide_to_human("客户主动要求转人工")
|
||||
if intent not in BUSINESS_INTENTS:
|
||||
# 分类失败或意图未覆盖:不猜,直接引导人工
|
||||
return self._guide_to_human(f"意图未覆盖:{intent or '未识别'}")
|
||||
return await self._answer_from_knowledge(request, context, intent)
|
||||
|
||||
# ---- 出口一:知识直返(faq / 产品 / 政策) ----
|
||||
|
||||
async def _answer_from_knowledge(
|
||||
self, request: AgentRequest, context: RequestContext, intent: str
|
||||
) -> CoreResult:
|
||||
try:
|
||||
output = await self.call_tool(
|
||||
TOOL_NAME,
|
||||
{"query": request.message[:500], "top_k": TOP_K},
|
||||
intent=intent,
|
||||
context=context,
|
||||
)
|
||||
except ForbiddenAgentError:
|
||||
# 白名单/权限类失败必须冒泡:那是配置错误,若被兜底话术吞掉,
|
||||
# 运维会看到"客服一直引导人工"却查不出原因。
|
||||
raise
|
||||
except Exception:
|
||||
return self._guide_to_human("知识检索调用失败")
|
||||
|
||||
if not isinstance(output, dict):
|
||||
return self._guide_to_human("知识检索返回格式异常")
|
||||
if output.get("degraded"):
|
||||
reason = str(output.get("reason") or "未知")
|
||||
return self._guide_to_human(f"知识检索降级:{reason}")
|
||||
hits = output.get("hits")
|
||||
if not isinstance(hits, list) or not hits:
|
||||
return self._guide_to_human("知识库未命中")
|
||||
|
||||
best = hits[0]
|
||||
if not isinstance(best, dict):
|
||||
return self._guide_to_human("命中内容格式异常")
|
||||
score = self._score(best.get("score"))
|
||||
if score < MID_SCORE:
|
||||
return self._guide_to_human(f"置信度不足:{score:.3f}")
|
||||
|
||||
content = str(best.get("content") or "").strip()
|
||||
if not content:
|
||||
return self._guide_to_human("命中内容为空")
|
||||
|
||||
answer = content[:MAX_ANSWER_CHARS]
|
||||
if score < HIGH_SCORE:
|
||||
answer = f"{answer}\n{INCOMPLETE_NOTICE}"
|
||||
# 知识出处写进正文,而不是塞进 source_references:
|
||||
# `governance.review_output` 只接受「本次召回的记忆」与「本次成功调用的工具」两类引用
|
||||
# (用于防止 Agent 伪造来源),知识块的 doc_id 不属于这两类,会被判为非法引用。
|
||||
# 把文件标题与内部文件编号写进正文,客户与人工同样能核对,且不必放开那道校验。
|
||||
source_note = self._source_note(best)
|
||||
text = (
|
||||
f"{answer}\n{source_note}\n{DISCLAIMER}"
|
||||
if source_note
|
||||
else f"{answer}\n{DISCLAIMER}"
|
||||
)
|
||||
return CoreResult(text=text, intent=self._classified_intent)
|
||||
|
||||
# ---- 出口二:闲聊(提示词走发布配置) ----
|
||||
|
||||
async def _chitchat(self, request: AgentRequest) -> CoreResult:
|
||||
system, template = await self._chitchat_prompt()
|
||||
message = request.message[:500]
|
||||
try:
|
||||
prompt = template.format(message=message, company=COMPANY)
|
||||
except (KeyError, IndexError, ValueError):
|
||||
# 发布配置里的占位符与代码不一致时回落默认模板:配置写错不应该让运行期崩
|
||||
system = DEFAULT_CHITCHAT_SYSTEM
|
||||
prompt = DEFAULT_CHITCHAT_TEMPLATE.format(message=message, company=COMPANY)
|
||||
full_prompt = f"{system}\n\n{prompt}" if system else prompt
|
||||
try:
|
||||
endpoints = await DatabaseModelEndpointResolver().resolve(
|
||||
agent_type=AGENT_TYPE, task_type="text_generation"
|
||||
)
|
||||
# 显式转成 list[object]:generate_with_model 形参是 list[object],
|
||||
# 而 list 不变型(invariant),直接传 list[ModelEndpointConfig] 过不了类型检查
|
||||
endpoint_list: list[object] = list(endpoints)
|
||||
execution = await self.generate_with_model(endpoint_list, full_prompt)
|
||||
except Exception:
|
||||
return self._guide_to_human("模型不可用")
|
||||
text = (execution.text or "").strip()
|
||||
if not text:
|
||||
return self._guide_to_human("模型返回为空")
|
||||
return CoreResult(
|
||||
text=f"{text[:MAX_ANSWER_CHARS]}\n{DISCLAIMER}",
|
||||
intent=self._classified_intent,
|
||||
)
|
||||
|
||||
async def _chitchat_prompt(self) -> tuple[str, str]:
|
||||
"""读取当前发布版本的闲聊提示词;未发布或读取失败时回落代码内置默认值。"""
|
||||
try:
|
||||
row = await load_active_prompt(CHITCHAT_PROMPT_CODE, CHITCHAT_TASK_TYPE, AGENT_TYPE)
|
||||
except Exception:
|
||||
row = None
|
||||
if row is None:
|
||||
return DEFAULT_CHITCHAT_SYSTEM, DEFAULT_CHITCHAT_TEMPLATE
|
||||
return (
|
||||
row.system_prompt or DEFAULT_CHITCHAT_SYSTEM,
|
||||
row.user_prompt_template or DEFAULT_CHITCHAT_TEMPLATE,
|
||||
)
|
||||
|
||||
# ---- 出口三:引导人工客服(不做工单,只回话并留痕) ----
|
||||
|
||||
def _guide_to_human(self, reason: str) -> CoreResult:
|
||||
return CoreResult(
|
||||
text=FALLBACK_TEMPLATE,
|
||||
intent=self._classified_intent,
|
||||
transfer_required=True,
|
||||
transfer_reason=reason[:200],
|
||||
)
|
||||
|
||||
# ---- 辅助 ----
|
||||
|
||||
def _intent_code(self) -> str:
|
||||
classified = self._classified_intent
|
||||
return classified.intent if classified is not None else ""
|
||||
|
||||
@staticmethod
|
||||
def _score(value: object) -> float:
|
||||
"""把命中分数夹到 [0,1]:SourceReference.score 有 ge=0/le=1 约束。"""
|
||||
try:
|
||||
number = float(value) # type: ignore[arg-type]
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
return min(1.0, max(0.0, number))
|
||||
|
||||
@staticmethod
|
||||
def _source_note(hit: dict[str, object]) -> str:
|
||||
"""把知识出处写成一行正文(文件标题 + 内部文件编号),便于客户与人工核对。"""
|
||||
title = str(hit.get("title") or "").strip()
|
||||
doc_no = str(hit.get("doc_no") or "").strip()
|
||||
if not title:
|
||||
return ""
|
||||
return f"(依据:{title}{',' + doc_no if doc_no else ''})"
|
||||
|
||||
def _references(self, hits: list[object]) -> tuple[SourceReference, ...]:
|
||||
"""保留待用:等基座支持 knowledge 类型引用后再启用。
|
||||
|
||||
目前 `governance.review_output` 只认可 memory / tool 两类来源,直接返回
|
||||
knowledge 引用会被判为「引用未来自本次已授权召回结果」而让整个 run 失败。
|
||||
启用前提是让 ToolExecutor 把工具返回的知识 doc_id 登记为本次可引用来源。
|
||||
"""
|
||||
references: list[SourceReference] = []
|
||||
for hit in hits[:REFERENCE_LIMIT]:
|
||||
if not isinstance(hit, dict):
|
||||
continue
|
||||
doc_id = str(hit.get("doc_id") or "").strip()
|
||||
if not doc_id:
|
||||
continue # 没有标识的命中无法回溯,丢弃而不是造一个假来源
|
||||
title = str(hit.get("title") or "").strip()
|
||||
references.append(SourceReference(
|
||||
source_type="knowledge",
|
||||
source_id=doc_id,
|
||||
title=title[:200] or None,
|
||||
score=self._score(hit.get("score")),
|
||||
))
|
||||
return tuple(references)
|
||||
@@ -0,0 +1,188 @@
|
||||
"""知识库检索:把客户问题向量化后在三个知识集合里检索(只读)。
|
||||
|
||||
为什么不复用记忆那套 `VectorMemoryAdapter`:它只把命中折叠成 `(memory_uuid, score)`,
|
||||
会把知识块的标题与正文丢掉。而客服回答必须能把**原文与来源**一起交给客户——金融场景
|
||||
里「答案出自哪份文件的哪一条」本身就是交付物的一部分,丢了正文等于没法给来源引用。
|
||||
|
||||
失败语义与基座一致:**任何一步失败都不抛异常给主链路**,而是返回 `degraded=True`
|
||||
的空结果,由调用方(客服 Agent)据此走「引导客户致电人工客服」的兜底路径。
|
||||
金融场景下"答不了"是可接受的结果,"答错"不是。
|
||||
"""
|
||||
|
||||
from collections.abc import Awaitable, Callable, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Protocol
|
||||
|
||||
# 三个知识集合(方案 §2.4.4 / §4.1)
|
||||
FAQ_COLLECTION = "fin_faq_collection"
|
||||
PRODUCT_COLLECTION = "fin_product_collection"
|
||||
POLICY_COLLECTION = "fin_policy_collection"
|
||||
DEFAULT_COLLECTIONS: tuple[str, ...] = (FAQ_COLLECTION, PRODUCT_COLLECTION, POLICY_COLLECTION)
|
||||
|
||||
# 检索输出字段:与灌库脚本写入的 schema 对齐
|
||||
OUTPUT_FIELDS = (
|
||||
"doc_id", "title", "content", "chapter", "section",
|
||||
"tags", "doc_no", "version", "source_file", "visibility",
|
||||
)
|
||||
|
||||
|
||||
class VectorSearcher(Protocol):
|
||||
"""只依赖用到的两个方法,便于测试替身注入。"""
|
||||
|
||||
def search(self, **kwargs: Any) -> Any: ...
|
||||
|
||||
|
||||
Embedder = Callable[[str], Awaitable[list[float]]]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class KnowledgeHit:
|
||||
"""一条知识命中;`score` 为 COSINE 相似度(越大越相似)。"""
|
||||
|
||||
doc_id: str
|
||||
title: str
|
||||
content: str
|
||||
score: float
|
||||
source_file: str = ""
|
||||
visibility: str = "public"
|
||||
doc_no: str = ""
|
||||
version: str = ""
|
||||
chapter: str = ""
|
||||
|
||||
@property
|
||||
def reference_title(self) -> str:
|
||||
"""给客户看的来源标题:优先带内部文件编号,便于人工核对。"""
|
||||
return f"{self.title}({self.doc_no})" if self.doc_no else self.title
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class KnowledgeSearchOutcome:
|
||||
"""检索结果;`degraded=True` 表示检索链路故障,调用方必须走兜底而非当'没找到'。"""
|
||||
|
||||
hits: tuple[KnowledgeHit, ...] = ()
|
||||
degraded: bool = False
|
||||
reason: str = ""
|
||||
searched_collections: tuple[str, ...] = field(default_factory=tuple)
|
||||
|
||||
@property
|
||||
def best(self) -> KnowledgeHit | None:
|
||||
return self.hits[0] if self.hits else None
|
||||
|
||||
@property
|
||||
def top_score(self) -> float:
|
||||
return self.hits[0].score if self.hits else 0.0
|
||||
|
||||
|
||||
class KnowledgeSearchService:
|
||||
def __init__(
|
||||
self,
|
||||
client: VectorSearcher | None,
|
||||
embedder: Embedder | None,
|
||||
*,
|
||||
collections: Sequence[str] = DEFAULT_COLLECTIONS,
|
||||
) -> None:
|
||||
self._client = client
|
||||
self._embedder = embedder
|
||||
self._collections = tuple(collections)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""向量库与向量化能力是否都在位;缺任一项都不做检索,直接走兜底。"""
|
||||
return self._client is not None and self._embedder is not None
|
||||
|
||||
async def search(
|
||||
self,
|
||||
query: str,
|
||||
*,
|
||||
collections: Sequence[str] | None = None,
|
||||
top_k: int = 5,
|
||||
include_internal: bool = False,
|
||||
) -> KnowledgeSearchOutcome:
|
||||
"""检索知识库。
|
||||
|
||||
`include_internal=False`(默认)时在 Milvus 侧就过滤掉 `visibility=internal` 的块,
|
||||
内部资料不进入面向客户的答案——这是检索层的硬隔离,不依赖提示词约束。
|
||||
"""
|
||||
text = query.strip()
|
||||
if not text:
|
||||
return KnowledgeSearchOutcome(reason="empty_query")
|
||||
if not self.available:
|
||||
return KnowledgeSearchOutcome(degraded=True, reason="vector_backend_unavailable")
|
||||
|
||||
client, embedder = self._client, self._embedder
|
||||
if client is None or embedder is None:
|
||||
# 与 available 重复,但这里需要类型收窄(mypy 不跨属性判断 Optional)
|
||||
return KnowledgeSearchOutcome(degraded=True, reason="vector_backend_unavailable")
|
||||
try:
|
||||
vector = await embedder(text)
|
||||
except Exception:
|
||||
return KnowledgeSearchOutcome(degraded=True, reason="embedding_failed")
|
||||
if not vector:
|
||||
return KnowledgeSearchOutcome(degraded=True, reason="embedding_empty")
|
||||
|
||||
targets = tuple(collections or self._collections)
|
||||
expression = None if include_internal else 'visibility == "public"'
|
||||
collected: list[KnowledgeHit] = []
|
||||
failures = 0
|
||||
for collection in targets:
|
||||
try:
|
||||
raw = client.search(
|
||||
collection_name=collection,
|
||||
data=[vector],
|
||||
limit=max(1, min(top_k, 20)),
|
||||
output_fields=list(OUTPUT_FIELDS),
|
||||
filter=expression,
|
||||
)
|
||||
except Exception:
|
||||
failures += 1
|
||||
continue
|
||||
collected.extend(self._parse(raw, collection))
|
||||
|
||||
if not collected and failures == len(targets) and targets:
|
||||
# 三个集合全查失败:是链路故障,不是"知识库里没有"
|
||||
return KnowledgeSearchOutcome(
|
||||
degraded=True, reason="search_failed", searched_collections=targets
|
||||
)
|
||||
|
||||
collected.sort(key=lambda hit: hit.score, reverse=True)
|
||||
# 同一内容可能同时存在于产品手册与问答对里,按 doc_id 去重保留最高分
|
||||
deduped: list[KnowledgeHit] = []
|
||||
seen: set[str] = set()
|
||||
for hit in collected:
|
||||
if hit.doc_id in seen:
|
||||
continue
|
||||
seen.add(hit.doc_id)
|
||||
deduped.append(hit)
|
||||
return KnowledgeSearchOutcome(
|
||||
hits=tuple(deduped[: max(1, top_k)]),
|
||||
degraded=failures > 0,
|
||||
reason="partial_collection_failure" if failures else "",
|
||||
searched_collections=targets,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _parse(raw: Any, collection: str) -> list[KnowledgeHit]:
|
||||
"""把 pymilvus 的 `[[{id, distance, entity}]]` 折叠成命中列表(纯函数,不抛异常)。"""
|
||||
hits: list[KnowledgeHit] = []
|
||||
groups = raw if isinstance(raw, (list, tuple)) else [raw]
|
||||
for group in groups:
|
||||
rows = group if isinstance(group, (list, tuple)) else [group]
|
||||
for row in rows:
|
||||
entity = row.get("entity") if isinstance(row, dict) else None
|
||||
if not isinstance(entity, dict):
|
||||
continue
|
||||
content = str(entity.get("content") or "")
|
||||
if not content:
|
||||
continue # 没有正文的命中无法作为答案来源,直接丢弃而不是猜造
|
||||
hits.append(KnowledgeHit(
|
||||
doc_id=str(entity.get("doc_id") or ""),
|
||||
title=str(entity.get("title") or ""),
|
||||
content=content,
|
||||
score=float(row.get("distance") or 0.0),
|
||||
source_file=str(entity.get("source_file") or ""),
|
||||
visibility=str(entity.get("visibility") or "public"),
|
||||
doc_no=str(entity.get("doc_no") or ""),
|
||||
version=str(entity.get("version") or ""),
|
||||
chapter=str(entity.get("chapter") or ""),
|
||||
))
|
||||
return hits
|
||||
@@ -0,0 +1,49 @@
|
||||
"""知识检索工具:注册给 Agent 的**只读**公共工具。
|
||||
|
||||
为什么把知识检索做成工具,而不是让 Agent 直接持有检索服务:走 `ToolExecutor` 就同时
|
||||
得到四件由基座保证的事——工具白名单(发布配置可收窄、缺配置即失败关闭)、权限校验、
|
||||
调用审计(`agent.tool_executed`)、超时保护。Agent 拿到的 `source_references` 也由基座
|
||||
统一附加,业务代码不能伪造来源引用。
|
||||
|
||||
工具只读是硬约束(`ToolRegistry.register` 会拒绝 `read_only=False`),本工具确实只查库。
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.core.contracts import RequestContext
|
||||
from app.core.knowledge_contracts import KnowledgeSearchInput
|
||||
|
||||
|
||||
async def knowledge_search_tool(
|
||||
arguments: KnowledgeSearchInput, context: RequestContext
|
||||
) -> dict[str, Any]:
|
||||
"""检索三个知识集合,返回命中原文与来源信息。
|
||||
|
||||
检索链路(向量化 / Milvus)任一环节失败都**不抛异常**,而是以 `degraded=True` 返回:
|
||||
客服 Agent 据此走「引导客户致电人工客服」,而不是把基础设施故障暴露成客户可见的错误。
|
||||
"""
|
||||
del context # 检索本身不区分身份;权限与白名单已在 ToolExecutor 中校验
|
||||
# 延迟导入:bootstrap 会导入本模块完成工具注册,模块级导入会形成循环依赖。
|
||||
from app.service.agent.bootstrap import get_knowledge_search_service
|
||||
|
||||
outcome = await get_knowledge_search_service().search(
|
||||
arguments.query,
|
||||
collections=(arguments.collection,) if arguments.collection else None,
|
||||
top_k=arguments.top_k,
|
||||
)
|
||||
return {
|
||||
"degraded": outcome.degraded,
|
||||
"reason": outcome.reason,
|
||||
"hits": [
|
||||
{
|
||||
"doc_id": hit.doc_id,
|
||||
"title": hit.title,
|
||||
"content": hit.content,
|
||||
"score": round(hit.score, 4),
|
||||
"source_file": hit.source_file,
|
||||
"doc_no": hit.doc_no,
|
||||
"visibility": hit.visibility,
|
||||
}
|
||||
for hit in outcome.hits
|
||||
],
|
||||
}
|
||||
@@ -125,3 +125,25 @@ async def load_active_intent_configs(agent_type: str) -> tuple[IntentConfigEntry
|
||||
"""
|
||||
async with SessionFactory() as session:
|
||||
return await RuntimeConfigService(session).active_intents(agent_type)
|
||||
|
||||
|
||||
async def load_active_prompt(
|
||||
prompt_code: str, task_type: str, agent_type: str
|
||||
) -> PromptTemplateVersion | None:
|
||||
"""读取当前生效版本里的提示词;没有 active 版本或没有该提示词时返回 None。
|
||||
|
||||
`prompt_template_version` 绑定 `release_id`,所以必须先定位 active 的 `config_release`
|
||||
——这也正是「提示词变更要经过审核与发布」的落地方式:改话术走发布流程,而不是改代码。
|
||||
|
||||
返回 None 是**正常路径**而非异常:调用方(Agent)据此回落到代码内置的默认提示词,
|
||||
保证即使配置中心还没发布过这条提示词,功能也能工作。
|
||||
"""
|
||||
async with SessionFactory() as session:
|
||||
release = await session.scalar(
|
||||
select(ConfigRelease).where(ConfigRelease.status == "active")
|
||||
)
|
||||
if release is None:
|
||||
return None
|
||||
return await RuntimeConfigService(session).prompt(
|
||||
release.id, prompt_code, task_type, agent_type
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user