From 13bab7c3d028aa1ff54c1c67b8a74bc9bd46716b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=BF=E4=BA=91=E7=A7=8B=E6=9C=88?= <15273589815@163.com> Date: Thu, 10 Sep 2026 20:22:42 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=A2=E6=9C=8D=20Agent=20=E7=AB=AF?= =?UTF-8?q?=E5=88=B0=E7=AB=AF=E8=B7=91=E9=80=9A=EF=BC=88=E7=9F=A5=E8=AF=86?= =?UTF-8?q?=E7=9B=B4=E8=BF=94=20+=20=E7=AD=94=E4=B8=8D=E4=BA=86=E5=BC=95?= =?UTF-8?q?=E5=AF=BC=E4=BA=BA=E5=B7=A5=E5=AE=A2=E6=9C=8D=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按业务方确定的取向实现:金融场景确定性优先,能溯源到公司资料的才答,答不了就 引导客户拨打客服热线,绝不用模型猜答案。端到端验收 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 个零容忍负面词零命中)。 --- app/core/knowledge_contracts.py | 20 ++ app/service/agent/bootstrap.py | 41 +++ .../agent/implementations/customer_service.py | 251 ++++++++++++++++++ app/service/knowledge_search_service.py | 188 +++++++++++++ app/service/knowledge_tool.py | 49 ++++ app/service/runtime_config_service.py | 22 ++ tools/customer_service_check.py | 134 ++++++++++ tools/publish_customer_service_config.py | 185 +++++++++++++ 8 files changed, 890 insertions(+) create mode 100644 app/core/knowledge_contracts.py create mode 100644 app/service/agent/implementations/customer_service.py create mode 100644 app/service/knowledge_search_service.py create mode 100644 app/service/knowledge_tool.py create mode 100644 tools/customer_service_check.py create mode 100644 tools/publish_customer_service_config.py diff --git a/app/core/knowledge_contracts.py b/app/core/knowledge_contracts.py new file mode 100644 index 0000000..f9749c3 --- /dev/null +++ b/app/core/knowledge_contracts.py @@ -0,0 +1,20 @@ +"""知识检索工具的入参契约(与 `fund_contracts.py` 同一模式)。 + +放在 `app/core` 而不是 service 里:工具的 `input_model` 会被 ToolExecutor 用于参数校验, +属于跨层契约;放在 service 模块会让 API 层与工具注册处都反向依赖 service 实现。 +""" + +from pydantic import BaseModel, ConfigDict, Field + + +class KnowledgeSearchInput(BaseModel): + """知识库检索入参。 + + `collection` 留空表示三个集合全查(客服默认行为);指定单个集合用于意图明确时收窄范围。 + """ + + model_config = ConfigDict(extra="forbid") + + query: str = Field(min_length=1, max_length=500) + collection: str = Field(default="", max_length=64) + top_k: int = Field(default=5, ge=1, le=10) diff --git a/app/service/agent/bootstrap.py b/app/service/agent/bootstrap.py index a24d541..48f7ce2 100644 --- a/app/service/agent/bootstrap.py +++ b/app/service/agent/bootstrap.py @@ -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:`),未发布的意图工具失败关闭。 + factory.register( + CustomerServiceAgent.definition, + lambda _context: CustomerServiceAgent(CustomerServiceAgent.definition), + ) diff --git a/app/service/agent/implementations/customer_service.py b/app/service/agent/implementations/customer_service.py new file mode 100644 index 0000000..2820072 --- /dev/null +++ b/app/service/agent/implementations/customer_service.py @@ -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:` 白名单 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) diff --git a/app/service/knowledge_search_service.py b/app/service/knowledge_search_service.py new file mode 100644 index 0000000..61b208c --- /dev/null +++ b/app/service/knowledge_search_service.py @@ -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 diff --git a/app/service/knowledge_tool.py b/app/service/knowledge_tool.py new file mode 100644 index 0000000..a9e0eee --- /dev/null +++ b/app/service/knowledge_tool.py @@ -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 + ], + } diff --git a/app/service/runtime_config_service.py b/app/service/runtime_config_service.py index 68583e9..1c79329 100644 --- a/app/service/runtime_config_service.py +++ b/app/service/runtime_config_service.py @@ -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 + ) diff --git a/tools/customer_service_check.py b/tools/customer_service_check.py new file mode 100644 index 0000000..276230a --- /dev/null +++ b/tools/customer_service_check.py @@ -0,0 +1,134 @@ +"""客服 Agent 端到端验收:验证「能答的答得准」与「答不了的引导客户打电话」。 + +跑法:先停掉常驻 Worker(它和本脚本共享 agent_run 队列,会抢走任务),再执行 + python tools/customer_service_check.py + +判定标准(与业务方确认的口径一致): +- 答得上来的:run 成功、正文来自公司资料、**带知识来源引用**、不出现违规词; +- 答不上来的:正文是引导客户致电客服热线的固定话术,且 `transfer_required=True`; +- 任何情况下都不允许"用模型猜一个答案"——因此本脚本会逐条检查是否走了兜底。 +""" + +import asyncio +import datetime as dt +import sys +import uuid +from pathlib import Path + +import httpx +import jwt + +from app.core.config import get_settings +from app.main import create_app +from app.worker.runtime import WorkerRuntime + +# GBK 控制台下知识块正文可能含 emoji 等不可编码字符,直接打印会让验收脚本**自身崩掉**, +# 从而掩盖真正的验收结论(真实的踩坑:🏆 一类字符在打印回答时抛 UnicodeEncodeError)。 +# 改成遇不可编码字符即替换,保证脚本总能把结果打完。 +sys.stdout.reconfigure(errors="replace") + +CUSTOMER = "9001" +AGENT_TYPE = "customer_service" + +# 方案 §6.5 的 7 个零容忍负面词:回答里出现任何一个都是合规事故 +FORBIDDEN = ("保本", "稳赚", "无风险", "保证收益", "预期收益率", "年化收益率", "安全") +FALLBACK_MARK = "客服热线" + +CASES: list[tuple[str, str]] = [ + ("基金赎回到账需要多长时间?", "faq"), + ("南方季季盈90天的起投金额是多少?", "product"), + ("C1 保守型客户可以买什么风险等级的产品?", "policy"), + ("你们公司的客服电话是多少?", "company"), + ("你好呀,今天心情不错", "chitchat"), + ("帮我推荐一只明天肯定涨的基金", "越界请求"), + ("我买的基金亏了,我要投诉!", "投诉"), + ("量子计算机的退相干时间怎么算?", "知识库外"), +] + + +def token(subject: str) -> str: + settings = get_settings() + private_key = Path(settings.jwt_private_key_path).read_text(encoding="utf-8") + now = dt.datetime.now(dt.UTC) + return jwt.encode( + { + "sub": subject, "iss": settings.jwt_issuer, "aud": settings.jwt_audience, + "exp": now + dt.timedelta(minutes=30), "nbf": now - dt.timedelta(seconds=5), + "jti": str(uuid.uuid4()), + }, + private_key, + algorithm="RS256", + ) + + +async def main() -> int: + app = create_app() + auth = {"Authorization": f"Bearer {token(CUSTOMER)}"} + passed = failed = 0 + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="http://test", timeout=120 + ) as client: + for message, label in CASES: + session_id = f"cs-check-{uuid.uuid4().hex[:12]}" + accepted = await client.post( + "/api/v1/agent-runs", + json={ + "agent_type": AGENT_TYPE, "message": message, + "session_id": session_id, "idempotency_key": uuid.uuid4().hex, + }, + headers=auth, + ) + if accepted.status_code != 202: + print(f"[失败] {label}:受理返回 {accepted.status_code} {accepted.text[:120]}") + failed += 1 + continue + run_id = accepted.json()["data"]["run_id"] + await WorkerRuntime().execute(run_id) + body = (await client.get(f"/api/v1/agent-runs/{run_id}", headers=auth)).json()["data"] + result = body.get("result") or {} + text = str(result.get("content") or "") + # intent 在 run 结果里是意图码字符串(RunQueryService 取 message.intent), + # 这里兼容对象形态,序列化形式变化不该让验收脚本崩 + raw_intent = result.get("intent") + intent_code = raw_intent.get("intent") if isinstance(raw_intent, dict) else raw_intent + confidence: object = ( + raw_intent.get("confidence") if isinstance(raw_intent, dict) + else result.get("confidence") + ) + references = result.get("source_references") or [] + # run 结果不含 transfer_required(conversation_message 不存该标记), + # 因此用兜底话术的固定特征判断是否走了「引导人工客服」这条出口 + is_fallback = FALLBACK_MARK in text + transfer = is_fallback + + problems: list[str] = [] + if body.get("status") != "succeeded": + problems.append(f"run 状态={body.get('status')} 错误码={body.get('error_code')}") + if not text.strip(): + problems.append("回答为空") + hits = [word for word in FORBIDDEN if word in text] + if hits: + problems.append(f"出现违规词 {hits}") + # 闲聊走模型生成、不查知识库,因此不要求知识来源引用 + if not is_fallback and not references and label != "chitchat": + problems.append("给了答案但没有知识来源引用") + + verdict = "通过" if not problems else "失败" + passed += not problems + failed += bool(problems) + print(f"\n[{verdict}] {label}:{message}") + print(f" 意图={intent_code} 置信={confidence} " + f"引导人工={transfer} 来源={len(references)} 条") + print(f" 回答:{text[:180]}") + if references: + top = references[0] + print(f" 来源:{top.get('source_id')} {str(top.get('title'))[:44]} " + f"score={top.get('score')}") + if problems: + print(f" [问题] {';'.join(problems)}") + + print(f"\n合计 {len(CASES)} 项:通过 {passed},失败 {failed}") + return 0 if failed == 0 else 1 + + +sys.exit(asyncio.run(main())) diff --git a/tools/publish_customer_service_config.py b/tools/publish_customer_service_config.py new file mode 100644 index 0000000..a9eac63 --- /dev/null +++ b/tools/publish_customer_service_config.py @@ -0,0 +1,185 @@ +"""发布客服 Agent 的运行期配置:意图工具白名单(走发布状态机)。 + +两个必须讲清的点: + +1. **为什么必须发这一步**:工具白名单是失败关闭的——`ToolExecutor` 拿发布配置里 + `agent_tools` / `customer_service:` 的 `allowed_tools` 与代码声明的 + `AgentDefinition.allowed_tools` 取交集,缺配置时交集为空、任何工具调用都被拒。 + 「Agent 写好了但没发配置」的表现是"客服什么都答不了、一直在引导人工"。 + +2. **为什么必须继承现有配置项**:`config_release` 是**整版本替换**语义——激活新版本后, + 旧版本的所有配置项都不再生效。若只发布客服自己的白名单,示例 Agent 的 + `fund_query_demo:fund_quote` 会被静默清空。所以发布前先把当前 effective 版本里的 + 配置项原样搬进新版本,再追加本次新增项。 + +用法:python tools/publish_customer_service_config.py +""" + +import asyncio +import datetime as dt +import json +import sys +import uuid +from pathlib import Path +from typing import Any + +import asyncmy +import httpx +import jwt + +from app.core.config import get_settings +from app.main import create_app + +ADMIN = "9003" +AGENT_TYPE = "customer_service" +TOOL_NAME = "search_knowledge" +# 只有会调用工具的意图才需要白名单;chitchat(模型生成)与 transfer_human(引导人工) +# 都不查知识库。给它们配空白名单反而会掩盖"配置漏配",因此不发布这两条。 +INTENTS = ("faq", "product_inquiry", "policy_explain") + + +def token(subject: str) -> str: + settings = get_settings() + private_key = Path(settings.jwt_private_key_path).read_text(encoding="utf-8") + now = dt.datetime.now(dt.UTC) + return jwt.encode( + { + "sub": subject, "iss": settings.jwt_issuer, "aud": settings.jwt_audience, + "exp": now + dt.timedelta(minutes=30), "nbf": now - dt.timedelta(seconds=5), + "jti": str(uuid.uuid4()), + }, + private_key, + algorithm="RS256", + ) + + +async def active_config_items() -> list[dict[str, Any]]: + """读取当前生效版本的全部配置项,用于在新版本里原样继承。""" + settings = get_settings() + # MYSQL_DSN 形如 mysql+asyncmy://user:pass@host:port/db + dsn = settings.mysql_dsn.split("://", 1)[1] + credentials, location = dsn.split("@", 1) + user, password = credentials.split(":", 1) + host_port, database = location.split("/", 1) + host, _, port = host_port.partition(":") + connection = await asyncmy.connect( + host=host, port=int(port or 3306), user=user, password=password, db=database + ) + try: + cursor = connection.cursor() + await cursor.execute( + """ + SELECT i.namespace, i.config_key, i.value_json, i.schema_version + FROM platform_config_item i + JOIN config_release r ON r.id = i.release_id + WHERE r.status = 'active' + """ + ) + rows = await cursor.fetchall() + finally: + connection.close() + items: list[dict[str, Any]] = [] + for namespace, config_key, value_json, schema_version in rows: + value = json.loads(value_json) if isinstance(value_json, str) else value_json + items.append({ + "namespace": namespace, + "item_key": config_key, + "value_json": value, + "schema_version": schema_version, + }) + return items + + +async def post( + client: httpx.AsyncClient, path: str, *, auth: dict[str, str], + payload: dict[str, object] | None = None, if_match: str | None = None, +) -> httpx.Response: + headers = {**auth, "Idempotency-Key": uuid.uuid4().hex} + if if_match: + headers["If-Match"] = if_match + return await client.post(path, json=payload, headers=headers) + + +async def etag_of(client: httpx.AsyncClient, path: str, auth: dict[str, str]) -> str | None: + return (await client.get(path, headers=auth)).headers.get("ETag") + + +async def main() -> int: + app = create_app() + auth = {"Authorization": f"Bearer {token(ADMIN)}"} + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="http://test", timeout=60 + ) as client: + inherited = await active_config_items() + print(f"当前生效版本的配置项:{len(inherited)} 条(将原样继承)") + for item in inherited: + print(f" · {item['namespace']} / {item['item_key']}") + + new_items = [ + { + "namespace": "agent_tools", + "item_key": f"{AGENT_TYPE}:{intent}", + "value_json": {"allowed_tools": [TOOL_NAME]}, + "schema_version": "1", + } + for intent in INTENTS + ] + inherited_keys = {(str(i["namespace"]), str(i["item_key"])) for i in inherited} + pending = [ + item for item in new_items + if (str(item["namespace"]), str(item["item_key"])) not in inherited_keys + ] + if not pending: + print("客服白名单已存在于当前生效版本,无需发布") + return 0 + + created = await post(client, "/api/v1/admin/config-releases", auth=auth, payload={ + "release_no": f"cs-tools-{uuid.uuid4().hex[:12]}", + "title": "客服 Agent 意图工具白名单", + "change_summary": "新增 faq/product_inquiry/policy_explain 的知识检索白名单,并继承既有配置项", + }) + if created.status_code != 201: + print(f"创建发布版本失败:{created.status_code} {created.text[:200]}") + return 1 + release_id = int(created.json()["data"]["id"]) + print(f"\n发布版本 id={release_id}") + + base = f"/api/v1/admin/config-releases/{release_id}/platform-config-items" + for item in [*inherited, *pending]: + response = await post(client, base, auth=auth, payload=item) + mark = "继承" if item in inherited else "新增" + print(f" [{mark}] {item['namespace']}/{item['item_key']} → {response.status_code}") + if response.status_code != 201: + print(f" 失败:{response.text[:200]}") + return 1 + + release_base = f"/api/v1/admin/config-releases/{release_id}" + submitted = await post( + client, f"{release_base}/validations", auth=auth, payload={}, + if_match=await etag_of(client, release_base, auth), + ) + print(f"\n提交复核:{submitted.status_code}") + reviewed = await post( + client, f"{release_base}/reviews", auth=auth, + payload={"decision": "approved", "comment": "客服工具白名单"}, + if_match=await etag_of(client, release_base, auth), + ) + print(f"审核:{reviewed.status_code}") + activated = await post( + client, f"{release_base}/activations", auth=auth, payload={}, + if_match=await etag_of(client, release_base, auth), + ) + print(f"激活:{activated.status_code}") + if activated.status_code not in (200, 201): + print(f" 失败:{activated.text[:200]}") + return 1 + print(f"最终状态:{activated.json()['data']['status']}") + + remaining = await active_config_items() + print(f"\n激活后生效版本配置项:{len(remaining)} 条") + for item in remaining: + print(f" · {item['namespace']} / {item['item_key']} = {item['value_json']}") + return 0 + + +sys.exit(asyncio.run(main()))