fix(knowledge): 检索服务适配现库集合 schema、发布对齐的工具白名单、免责声明只由治理层注入

合并暴露的三个真机问题(单测全绿但线上必挂):

1. 检索服务字段名与现库集合不符 -> 静默零召回
   架构师那套按 load_knowledge_milvus.py 的 schema 读 doc_id/content/chapter/section/
   visibility,而现库三集合的真实字段是 knowledge_id/title/snippet/tags/version/intent。
   Milvus 对不存在的字段直接报错 -> 三集合全失败 -> degraded -> 客服一律转人工。
   改法:只改读取侧,用 _FIELD_ALIASES 映射;KnowledgeHit 对外形状不变(下游与测试不动)。
   代价已注明:没有 visibility 字段 -> 检索层内部资料硬隔离失效(现库 356 行均为对外知识)。

2. 发布白名单与代码上限不匹配 -> AGENT_PERMISSION_DENIED
   active 版本白名单是 query_knowledge,而合并后代码上限是 search_knowledge/check_suitability/
   query_customer_profile -> 交集为空 -> 所有知识问题 failed。
   改法:publish_customer_service_config.py 补 PROFILE_TOOL 进 faq 白名单,并让同 key 的
   继承项被本次定义覆盖(旧值原样继承会被子集校验 422 拒掉整次发布)。已激活版本 216。

3. 免责声明重复出现
   Agent 自己拼一句 + 治理层追加权威话术 -> 客户看到两条。改为只由治理层注入
   (话术属发布配置,改文案不该改代码)。风控等内部 Agent 不注入(结构化输出不被污染)。

测试:934 passed / 1 failed(test_fund_readonly_contract 既有空集缺陷,与本线无关)
真机:知识问答两问 succeeded 且只带一条声明;画像问答 succeeded;知识库三端点全绿
This commit is contained in:
qyqy
2026-09-11 15:27:16 +08:00
parent 57c4add7d8
commit 395bad25b7
5 changed files with 142 additions and 71 deletions
@@ -159,9 +159,16 @@ FALLBACK_TEMPLATE = (
"抱歉,这个问题我暂时无法给出准确答复。为避免给您错误信息,"
f"建议您拨打客服热线 {HOTLINE}({SERVICE_HOURS})转人工客服咨询。"
)
# 客户侧只展示这一句固定话术(业务方确定)。原中置信的"信息可能不完整"提示已按此移除,
# 即中置信回答不再对客户标注不确定性——这是调整时知情的取舍,不是遗漏。
DISCLAIMER = "(以上内容由智能客服依据公司公开资料整理,不构成投资建议)"
# 免责声明**只由治理层注入**(`PlatformGovernance.review` → `review_output`,话术取自
# `agent_reply_template` 的 `TPL_DISCLAIMER`,取不到时退回 `FALLBACK_DISCLAIMER`)。
#
# 这里曾自行拼一句 `DISCLAIMER`,合并后与治理层的话术**同时出现**,客户会看到两条
# 意思重复的声明的(实测复现)。更重要的是分工问题:话术是合规文案,属于**发布配置**,
# 改文案不该改代码;Agent 自己拼等于把可配置的合规文案硬编码进业务逻辑,
# 而且治理层无法判断"业务是不是已经加过了"(它只认自己追加过的那个形状)。
# 因此本文件不再定义、也不再引用任何免责声明常量。
# 客服热线:正式号码确定后改这里(或改为读配置项,避免改代码)
CHITCHAT_PROMPT_CODE = "customer_service_chitchat"
CHITCHAT_TASK_TYPE = "chat"
@@ -291,15 +298,15 @@ class CustomerServiceAgent(BaseAgent):
if not content:
return self._guide_to_human("命中内容为空")
# 正文只保留「答案 + 固定免责声明」:业务方要求客户侧只看到这一句固定话术,
# 因此不确定性提示与出处行都不再出现在正文里。
# 正文只保留答案本身:固定免责声明由**治理层**统一追加(见文件头 `DISCLAIMER` 说明),
# 业务代码不再拼字符串——否则会出现两条重复声明,且合规文案变成不可配置的硬编码。
#
# 可追溯性不受影响:本次命中哪个知识块仍由审计(agent.tool_executed 的工具调用记录)
# 与消息表留痕,只是不面向客户展示。若将来要把出处给客户看,应当走
# source_references 的 knowledge 类型(需先让 ToolExecutor 登记本次可引用的 doc_id),
# 而不是继续往正文里拼字符串。
return CoreResult(
text=f"{content[:MAX_ANSWER_CHARS]}\n{DISCLAIMER}",
text=content[:MAX_ANSWER_CHARS],
intent=self._classified_intent,
)
@@ -383,7 +390,7 @@ class CustomerServiceAgent(BaseAgent):
f"风险测评结果为准,不以本次自述为准。\n{text}"
)
return CoreResult(
text=f"{text}\n{DISCLAIMER}",
text=text,
intent=self._classified_intent,
)
@@ -505,7 +512,7 @@ class CustomerServiceAgent(BaseAgent):
if not text:
return self._guide_to_human("模型返回为空")
return CoreResult(
text=f"{text[:MAX_ANSWER_CHARS]}\n{DISCLAIMER}",
text=text[:MAX_ANSWER_CHARS],
intent=self._classified_intent,
)
+79 -47
View File
@@ -19,11 +19,45 @@ 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",
)
# 检索输出字段:**按现库集合的真实 schema**(2026-09-11 实测 describe_collection)。
#
# ⚠️ 这里与灌库脚本 `tools/load_knowledge_milvus.py` 的设计字段**不一致**,是刻意的:
# 那个脚本自述"临时脚本,跑完即删",所需的 `knowledge/_chunks.jsonl` 不在仓库里,
# 它那套 schema(`doc_id` 主键 + `chapter`/`section`/`doc_no`/`visibility`)在本环境
# **从未建起来过**。现库三个集合的真实字段是:
# knowledge_id / title / snippet / tags / version / intent / embedding
# 字段名不匹配的代价是**静默零召回**:Milvus 会对不存在的字段直接报错,
# 三个集合全失败 → `degraded=True` → 客服一律"引导人工",知识问答整体失效(实测复现)。
#
# 对齐策略:**只改读取侧**,不改检索逻辑与 `KnowledgeHit` 的对外形状——
# · `doc_id` ← `knowledge_id`(`KnowledgeHit.doc_id` 的名字保持不动,
# 下游 Agent 与 `test_knowledge_keyword_recall` 都依赖它)
# · `content` ← `snippet`(现库正文存在 snippet 字段)
# · `title` / `version` 同名直取
# · `chapter`/`section`/`doc_no`/`source_file`/`visibility` 现库没有 → 留空/默认,
# 依赖它们的增强逻辑(父子块选择、来源编号)自然退化为"不启用"而不会报错。
#
# 若将来按灌库脚本的 schema 重建集合并回填,把下面的 `_FIELD_ALIASES` 改回
# `{"doc_id": "doc_id", "content": "content", ...}` 并把 `_HAS_VISIBILITY` 置 True 即可,
# 检索逻辑一行都不用动。
_FIELD_ALIASES: dict[str, str] = {
"doc_id": "knowledge_id",
"title": "title",
"content": "snippet",
"tags": "tags",
"version": "version",
"intent": "intent",
# 现库未落这些字段 → 显式不请求,读取处按缺省值处理
}
#: 现库集合是否有 `visibility` 字段。没有时**不能**再拼 `visibility == "public"`:
#: 该表达式会让 Milvus 报 "field visibility not exist",整次检索失败。
_HAS_VISIBILITY = False
#: 现库集合是否有 `source_file` 字段(没有时来源标题只能靠 `doc_no`,通常为空)。
_HAS_SOURCE_FILE = False
OUTPUT_FIELDS = tuple(_FIELD_ALIASES.values())
# 关键字精确召回:客户问到产品名这类**专有名词**时,字面匹配比相似度更确定。
#
@@ -149,7 +183,13 @@ class KnowledgeSearchService:
return KnowledgeSearchOutcome(degraded=True, reason="embedding_empty")
targets = tuple(collections or self._collections)
expression = None if include_internal else 'visibility == "public"'
# 可见性过滤只在集合真有该字段时拼:现库没有 `visibility`,拼上去会让 Milvus
# 报 "field visibility not exist",三个集合全失败 → 一律走兜底(实测复现)。
# 代价必须说清楚:`_HAS_VISIBILITY=False` 时**没有**检索层的内部资料硬隔离,
# 只能靠入库侧只放对外知识来保证(现库 356 行均为对外知识,已核对)。
expression = (
None if (include_internal or not _HAS_VISIBILITY) else 'visibility == "public"'
)
collected: list[KnowledgeHit] = []
failures = 0
for collection in targets:
@@ -263,14 +303,14 @@ class KnowledgeSearchService:
rows = lookup(
collection_name=PRODUCT_COLLECTION,
filter=expression,
output_fields=["doc_id", "title"],
output_fields=[_FIELD_ALIASES["doc_id"], _FIELD_ALIASES["title"]],
limit=KEYWORD_SCAN_LIMIT,
)
except Exception:
return []
matched_ids = [
str(row.get("doc_id") or "")
str(row.get(_FIELD_ALIASES["doc_id"]) or "")
for row in (rows if isinstance(rows, list) else [])
if isinstance(row, dict)
and self._overlap_length(query, str(row.get("title") or "")) >= MIN_KEYWORD_OVERLAP
@@ -283,7 +323,7 @@ class KnowledgeSearchService:
try:
details = lookup(
collection_name=PRODUCT_COLLECTION,
filter=f"doc_id in [{quoted}]",
filter=f"{_FIELD_ALIASES['doc_id']} in [{quoted}]",
output_fields=list(OUTPUT_FIELDS),
limit=len(matched_ids),
)
@@ -294,20 +334,10 @@ class KnowledgeSearchService:
for row in details if isinstance(details, list) else []:
if not isinstance(row, dict):
continue
content = str(row.get("content") or "")
content = str(row.get(_FIELD_ALIASES["content"]) or "")
if not content:
continue
hits.append(KnowledgeHit(
doc_id=str(row.get("doc_id") or ""),
title=str(row.get("title") or ""),
content=content,
score=KEYWORD_MATCH_SCORE,
source_file=str(row.get("source_file") or ""),
visibility=str(row.get("visibility") or "public"),
doc_no=str(row.get("doc_no") or ""),
version=str(row.get("version") or ""),
chapter=str(row.get("chapter") or ""),
))
hits.append(self._hit_from_row(row, score=KEYWORD_MATCH_SCORE))
# 一个产品名可能命中多个块(产品概览、费率表各一块):全都保留,
# 是不是"只有一个明确候选"交给上层的 gap 判定,这里不替它做选择。
return hits
@@ -347,7 +377,8 @@ class KnowledgeSearchService:
try:
rows = lookup(
collection_name=collection,
filter=self._anded(expression, f"doc_id in [{quoted}]"),
filter=self._anded(expression,
f"{_FIELD_ALIASES['doc_id']} in [{quoted}]"),
output_fields=list(OUTPUT_FIELDS),
limit=KEYWORD_SCAN_LIMIT,
)
@@ -356,21 +387,11 @@ class KnowledgeSearchService:
for row in rows if isinstance(rows, list) else []:
if not isinstance(row, dict):
continue
doc_id = str(row.get("doc_id") or "")
content = str(row.get("content") or "")
doc_id = str(row.get(_FIELD_ALIASES["doc_id"]) or "")
content = str(row.get(_FIELD_ALIASES["content"]) or "")
if not content or doc_id not in parent_scores:
continue
found.append(KnowledgeHit(
doc_id=doc_id,
title=str(row.get("title") or ""),
content=content,
score=parent_scores[doc_id],
source_file=str(row.get("source_file") or ""),
visibility=str(row.get("visibility") or "public"),
doc_no=str(row.get("doc_no") or ""),
version=str(row.get("version") or ""),
chapter=str(row.get("chapter") or ""),
))
found.append(self._hit_from_row(row, score=parent_scores[doc_id]))
return found
@staticmethod
@@ -390,6 +411,26 @@ class KnowledgeSearchService:
"""把可见性过滤与 doc_id 过滤合成一个 Milvus 表达式。"""
return f"({expression}) and ({extra})" if expression else extra
@staticmethod
def _hit_from_row(row: Any, *, score: float) -> KnowledgeHit:
"""把一行 Milvus 标量查询结果折成 `KnowledgeHit`。
字段名经 `_FIELD_ALIASES` 映射(现库是 `knowledge_id`/`snippet`);现库没有的字段
(`source_file`/`doc_no`/`chapter`/`visibility`)按缺省值处理——留空字符串而不是
编造内容,`reference_title` 会因此退回纯标题,来源引用退化但不失真。
"""
return KnowledgeHit(
doc_id=str(row.get(_FIELD_ALIASES["doc_id"]) or ""),
title=str(row.get(_FIELD_ALIASES["title"]) or ""),
content=str(row.get(_FIELD_ALIASES["content"]) or ""),
score=score,
source_file=str(row.get("source_file") or "") if _HAS_SOURCE_FILE else "",
visibility="public",
doc_no="",
version=str(row.get(_FIELD_ALIASES["version"]) or ""),
chapter="",
)
@staticmethod
def _parse(raw: Any, collection: str) -> list[KnowledgeHit]:
"""把 pymilvus 的 `[[{id, distance, entity}]]` 折叠成命中列表(纯函数,不抛异常)。"""
@@ -401,18 +442,9 @@ class KnowledgeSearchService:
entity = row.get("entity") if isinstance(row, dict) else None
if not isinstance(entity, dict):
continue
content = str(entity.get("content") or "")
content = str(entity.get(_FIELD_ALIASES["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 ""),
))
hits.append(KnowledgeSearchService._hit_from_row(
entity, score=float(row.get("distance") or 0.0)))
return hits
@@ -13,14 +13,19 @@
from typing import Any
from app.service.agent.governance import FALLBACK_DISCLAIMER
from app.service.agent.implementations.customer_service import (
DISCLAIMER,
FALLBACK_TEMPLATE,
CustomerServiceAgent,
)
PRODUCT = "南方季季盈90天"
#: 固定免责声明由**治理层**注入(`PlatformGovernance.review` → `review_output`),
#: 业务 Agent 不再自己拼——否则客户会看到两条重复声明(合并时实测复现)。
#: 这里取治理层的代码兜底文案,只用于还原"治理后"的正文形状。
DISCLAIMER = FALLBACK_DISCLAIMER
def _decision(**overrides: Any) -> dict[str, Any]:
base: dict[str, Any] = {
@@ -32,8 +37,8 @@ def _decision(**overrides: Any) -> dict[str, Any]:
def _replied(body: str) -> str:
"""出口交给客户的正文形状:`{正文}\\n{DISCLAIMER}`(四个出口都这样组装)。"""
return f"{body}\n{DISCLAIMER}"
"""出口交给客户的正文形状:`{正文}\\n\\n{免责声明}`(治理层统一追加)。"""
return f"{body}\n\n{DISCLAIMER}"
# ---- 出口一:知识直返(正文 = 知识块 content + DISCLAIMER) ----
@@ -29,13 +29,17 @@ async def _embed(text: str) -> list[float]:
def _row(doc_id: str, title: str, score: float, content: str = "正文") -> dict[str, Any]:
"""构造 pymilvus 的 `{distance, entity}` 行(`search()` 的返回形状)。"""
"""构造 pymilvus 的 `{distance, entity}` 行(`search()` 的返回形状)。
字段名按**现库集合的真实 schema**(`knowledge_id` / `snippet`),不是灌库脚本里那套
(`doc_id` / `content`)——后者在本环境的集合从未建起来过,按它写替身会让测试假绿:
业务代码读取 `snippet` 拿到空串,命中被静默丢弃,而断言又恰好只查 `doc_id`。
"""
return {
"distance": score,
"entity": {
"doc_id": doc_id, "title": title, "content": content,
"source_file": "x.md", "visibility": "public", "doc_no": "",
"version": "", "chapter": "",
"knowledge_id": doc_id, "title": title, "snippet": content,
"tags": "", "version": "", "intent": "",
},
}
@@ -48,9 +52,8 @@ def _flat(doc_id: str, title: str, content: str = "正文") -> dict[str, Any]:
测试红了一次——是测试写错,不是业务代码有问题(业务代码"没有正文就不作答"是对的)。
"""
return {
"doc_id": doc_id, "title": title, "content": content,
"source_file": "x.md", "visibility": "public", "doc_no": "",
"version": "", "chapter": "",
"knowledge_id": doc_id, "title": title, "snippet": content,
"tags": "", "version": "", "intent": "",
}
@@ -73,8 +76,11 @@ class FakeClient:
def query(self, **kwargs: Any) -> Any:
self.query_calls += 1
if "content" not in kwargs.get("output_fields", []):
return [{"doc_id": d, "title": t} for d, t in self._product_titles]
# 业务代码请求的字段名取自 `_FIELD_ALIASES`(现库是 `knowledge_id`/`snippet`):
# 这里按"有没有要正文"区分两轮查询,而不是写死某一套字段名。
output_fields = kwargs.get("output_fields", [])
if "snippet" not in output_fields and "content" not in output_fields:
return [{"knowledge_id": d, "title": t} for d, t in self._product_titles]
pattern = str(kwargs.get("filter") or "")
return [row for doc_id, row in self._product_rows.items() if doc_id in pattern]
+25 -4
View File
@@ -34,10 +34,15 @@ ADMIN = "9003"
AGENT_TYPE = "customer_service"
TOOL_NAME = "search_knowledge"
SUITABILITY_TOOL = "check_suitability"
# 画像只读工具:客服的"出口零"(本人风险等级/投资偏好/测评是否过期)走它取权威字段。
# 那个出口复用的是 `faq` 意图 key(见 `customer_service.PROFILE_WHITELIST_INTENT`),
# 所以**必须**把它加进 `faq` 的白名单里;漏了的表现是"问画像一律转人工",
# 而画像出口本身是对的——这是纯配置缺口(实测复现过)。
PROFILE_TOOL = "query_customer_profile"
# 只有会调用工具的意图才需要白名单;chitchat(模型生成)与 transfer_human(引导人工)
# 都不查知识库。给它们配空白名单反而会掩盖"配置漏配",因此不发布这两条。
INTENT_TOOLS: dict[str, tuple[str, ...]] = {
"faq": (TOOL_NAME,),
"faq": (TOOL_NAME, PROFILE_TOOL),
"product_inquiry": (TOOL_NAME,),
"policy_explain": (TOOL_NAME,),
# 适当性裁决要两步:先从知识库拿到产品的风险等级,再由底座按档案里的客户等级裁决
@@ -208,9 +213,25 @@ async def main() -> int:
for intent, tools in INTENT_TOOLS.items()
]
inherited_keys = {(str(i["namespace"]), str(i["item_key"])) for i in inherited}
# **同 key 的继承项必须被本次新定义覆盖**,不能原样搬过去。
# 血泪教训(实测):上一版发布的是 `faq = ["query_knowledge", ...]`,而那个工具
# 已随"客服检索改为 search_knowledge"从代码上限移除;原样继承会让 admin 服务的
# 子集校验(白名单 ⊆ 代码限定的 allowed_tools)直接 422 拒绝整次发布,
# 报错是"配置超出 Agent 工具上限",看不出是继承造成的。
inherited_only = [
item for item in inherited
if (str(item["namespace"]), str(item["item_key"])) not in {
("agent_tools", f"{AGENT_TYPE}:{intent}") for intent in INTENT_TOOLS
}
]
dropped = [item for item in inherited if item not in inherited_only]
for item in dropped:
print(f" [覆盖] {item['namespace']}/{item['item_key']} 将由本次定义替换"
f"(原值 {item['value_json']})")
pending = [
item for item in new_items
if (str(item["namespace"]), str(item["item_key"])) not in inherited_keys
if (str(item["namespace"]), str(item["item_key"])) not in
{(str(i["namespace"]), str(i["item_key"])) for i in inherited_only}
]
if not pending:
print("客服白名单已存在于当前生效版本,无需发布")
@@ -228,9 +249,9 @@ async def main() -> int:
print(f"\n发布版本 id={release_id}")
base = f"/api/v1/admin/config-releases/{release_id}/platform-config-items"
for item in [*inherited, *pending]:
for item in [*inherited_only, *pending]:
response = await post(client, base, auth=auth, payload=item)
mark = "继承" if item in inherited else "新增"
mark = "继承" if item in inherited_only else "新增"
print(f" [{mark}] {item['namespace']}/{item['item_key']} → {response.status_code}")
if response.status_code != 201:
print(f" 失败:{response.text[:200]}")