feat(W21-D1~D4): 四项待决按建议全部落地 + 1 条金标期望登记修订

按甲方 2026-09-21「按照你建议的改」批准 `D4.8` §6 四项,全部落地:

- W21-D1 生成侧禁令:`DEFAULT_EVIDENCE_SYSTEM` 红线 ② 与
  `DEFAULT_EVIDENCE_TEMPLATE` 第 ④ 条同时写明「不要把收益/回报/涨幅/净值增长率的
  具体数字或百分比写进答复」。红线门槛代码(`ZERO_TOLERANCE_WORDS` /
  `hits_zero_tolerance` / `YIELD_METRIC_TERMS` / `drop_yield_claims`)零改动;
  实测该 `prompt_code` 从未发布,改代码默认值即刻生效、无需发布流程。
  3 条问句 × 3 次 = 9/9 走 `E4` 真实作答(修复前 0/9)。
- W21-D2 指代依据:`_answer_suitability` 在主语由形状反解(`inferred=True`)时
  开场即「您上一轮提到的是「X」,我就按它帮您核对:」。
- W21-D3 答非所问闸门:新增产品名两种写法识别 + 前缀噪声剥离
  (`_strip_product_name_lead`) + `_names_other_product`,接入 `E5b` 相关性闸门。
- W21-D4 作答语气:`PARTIAL_TEMPLATE` 改「关于这一点,公开资料里的口径是:」;
  新增 `PARTIAL_FAQ_TEMPLATE`,命中块是 FAQ 问答对时直接以答案正文开场。

判据变更(知情项):`I-01`「南方科技是什么公司?」的金标期望出口由 [E5b, E3]
补入 `E4` —— 这正是提示词红线 ②(专为「名称查不到」设计)首次真正生效,
考点两条(禁忌字面不出现 / 关键事实命中)不变,见 `D4.8` §9.3。

新发现(未擅自执行,登记待决):`docs/43-场内基金产品手册(知识库入库版).md`
从未进入 `tools/build_knowledge_chunks.py` 的 `SOURCES`(702 块里「科创债」0 处),
这是「问在库的产品却答另一只」的根本原因;`W21-D3` 只是止损。

验收:金标 M-1 46/46、M-4 46/46、M-6 5/46、M-7/M-8/M-9/M-10=0、M-2 28/31、
M-2b 15/18、M-3 4/4;全量 1994 passed / 3 skipped;ruff 零新增;真 HTTP 11 条对照见 `D4.8` §9.4。

文档:`D4.8` v1.0→v1.1(新增 §9)、`D3.7` v1.0→v1.1(I-01 注)、
`D1.6` v1.0→v1.1(新增 §10 本轮对话上下文)、`D2.1` v6.37→v6.38、
`D2.9` v1.1→v1.2、`D1.1` v1.13→v1.14。
This commit is contained in:
张胜宇
2026-09-21 11:23:31 +08:00
parent dd5e9f8435
commit 36d9ba9b9f
8 changed files with 511 additions and 37 deletions
@@ -488,8 +488,20 @@ def prettify_title(title: str, limit: int = 34) -> str:
#: 检索降级、画像查不到都属"这次没查到",不是"必须人工办的事"。原实现把这四类一律渲染成
#: 同一句兜底话术并建单,是转人工率偏高的主因。
PARTIAL_TEMPLATE = (
"我先帮您把找到的公开资料放上来:\n\n{content}\n\n"
"如果这还不是您想问的那一条,把具体产品名或条款名告诉我,我接着帮您查;"
"关于这一点,公开资料里的口径是:\n\n{content}\n\n"
"如果您问的不是这一条,把具体产品名或条款名告诉我,我接着帮您查;"
f"也可以拨打客服热线 {HOTLINE}({SERVICE_HOURS})咨询。"
)
#: 🆕 `W21-D4`:命中块**本身就是一条 FAQ 问答对**(正文以「问:」起头)时的说法。
#
#: 为什么值得单开一个模板:资料本身就是答案,再套一层"我把找到的资料放上来"等于
#: 自己给自己降级。实测「债基和货基哪个收益高」命中的 `FAQ-0068` 内容**完全正确**
#: (就是标准答案),却因为开场白像兜底而显得"客服不会答" ——
#: 同样的内容换个开场,客户对"这个客服会不会答"的判断完全不同。
PARTIAL_FAQ_TEMPLATE = (
"{content}\n\n"
"还想核对别的,把具体产品名或条款名告诉我即可;"
f"也可以拨打客服热线 {HOTLINE}({SERVICE_HOURS})咨询。"
)
@@ -781,7 +793,9 @@ DEFAULT_EVIDENCE_SYSTEM = (
f"你是{COMPANY}的智能客服助手。你只能依据给出的【证据】回答用户问题,"
"并且只输出一个 JSON 对象。四条红线:① 只使用【证据】里出现过的事实与数字,"
"不得引入证据之外的任何信息、数字、产品名称或推测;② 不得给出投资建议、"
"不得承诺收益、不得代替客户办理任何业务;③ 证据不足以完整回答时不要勉强作答;"
"不得承诺收益、不得代替客户办理任何业务,"
"**也不要把收益 / 回报 / 涨幅 / 净值增长率的具体数字或百分比写进答复**"
"(费率、期限、金额、产品代码不受此限);③ 证据不足以完整回答时不要勉强作答;"
"④ 「我司不经营某项业务」这类**否定事实**只要有证据就是可答的,不得当作证据不足。"
)
DEFAULT_EVIDENCE_TEMPLATE = (
@@ -809,6 +823,15 @@ DEFAULT_EVIDENCE_TEMPLATE = (
"「收益并非确定」「可能亏损」)。这条是给「要不要写这几个字」把关,"
"**不是**在放宽上面任何一条红线:输出侧有逐字校验,写出来会被整条拦回 `E5b`,"
"客户只会拿到一段没有被回答的资料。"
"④ 答复里**不要出现任何收益数值**:不要把【证据】里的收益 / 回报 / 涨幅 /"
"净值增长率之类的**具体数字或百分比**写进 answer(例如「近一年收益 3.20%」"
"「七日年化 1.85%」)。证据里只有这类数字时,改成**定性**说法"
"(如「收益会随市场波动,可能产生亏损」);若整段证据只写了这类数值、"
"改不出定性说法,就**不要围绕它作答**,"
"也**不要**写「数值已隐去」「按合规要求省略」这种字眼"
"(那反而在提示原处有一个数字)。这条同样是**硬校验**:"
"answer 里出现这类数值会被**整条拦回**,客户只会拿到一段没有被回答的资料 ——"
"宁可少写一个数字,也不要让整条答复作废。"
)
@@ -1096,12 +1119,21 @@ class CustomerServiceAgent(BaseAgent):
# 文件生效时间的说明。金融场景下"答非所问"比"如实说没找到"更伤人。
# 判据只看**标题与正文前 200 字**与问句的**最长公共子串 ≥ 2 字**:
# 连续两个字都不重合,说明这块与问句连一个业务词都没共享,不该拿它充数。
if not self._hits_share_terms(request.message, hits[0]):
logger.info(
"E5b 相关性闸门:命中与问句无共同业务词 doc_id=%s",
hits[0].get("doc_id"),
#
# 🆕 `W21-D3` 第二道闸门:**词面沾边也可能答非所问**。实测
# `科创债ETF南方怎么样` 的 top1 是 `南方稳健增利债券 A` 的产品卡 ——
# 「南方」两个字重合,第一道闸门放行,客户拿到的却是**另一只产品**的资料。
# 因此再加一条:问句点名了产品 X,而 top-K 里**一块都没提到 X**、
# 命中块讲的是别的产品 ⇒ 同样判"没找到"。宁可诚实说没有,也不拿相近产品替答。
off_subject = self._names_other_product(request.message, hits)
if not self._hits_share_terms(request.message, hits[0]) or off_subject:
note = (
"问句点名的产品与命中块讲的产品不是同一只"
if off_subject
else "命中与问句无共同业务词"
)
return self._exit_partial([], note="命中与问句无共同业务词")
logger.info("E5b 相关性闸门:%s doc_id=%s", note, hits[0].get("doc_id"))
return self._exit_partial([], note=note)
return self._exit_partial(
hits, note=f"置信度不足:score={score:.3f} gap={gap:.3f}"
)
@@ -1767,6 +1799,11 @@ class CustomerServiceAgent(BaseAgent):
f"您提到自己是 {claimed}。适当性判断以您在公司留存的、在有效期内的"
f"风险测评结果为准,不以本次自述为准。\n{text}"
)
if inferred:
# `W21-D2`:主语是**反解**出来的(客户自己没说产品名),必须把指代依据摆出来 ——
# 「我按上一轮提到的 X 帮您核对」让客户看得见推论链,认错了能立刻纠正。
# 这比反问"您指的是哪一只"更省客户一步,也更像"客服在听你说话"。
text = f"您上一轮提到的是「{product}」,我就按它帮您核对:\n{text}"
return CoreResult(
text=text,
intent=self._classified_intent,
@@ -1809,6 +1846,101 @@ class CustomerServiceAgent(BaseAgent):
r"(?:货币市场基金|定开债券[ABC]?|债券\s?[ABC]?|优选混合|价值股票|科技股票|全球精选)"
)
#: 🆕 `W21-D3`:产品名的**两种真实写法**,用于「问句点名的产品 vs 命中块讲的产品」比对。
#:
#: 为什么需要这个判据:`E5b` 的模板是"把找到的公开资料放上来",可命中的块讲的是
#: **另一只产品**时,等于替客户确认了一件不相干的事 —— 金融场景下"答非所问"比
#: "如实说没找到"更伤人(实测 `科创债ETF南方怎么样` 的 top1 是
#: `南方稳健增利债券 A` 的产品卡,0.6696)。
#:
#: 两种形状都收:①「南方 + 名称 + 产品类型后缀」(与 C-9 的 `_PRODUCT_NAME_SHAPE`
#: 同源);②「名称 + ETF南方」—— 场内基金手册里 10 只 ETF 全是这个名字在前、
#: 品牌在后的写法(`科创债ETF南方` / `公司债ETF南方` / `货币ETF南方`),
#: 只用 ① 会把这一整类漏掉,而它们恰恰是最容易被近邻产品"抢答"的问句。
#: ETF 的写法:名称在前、品牌在后(`科创债ETF南方`)。单独提成常量,
#: 是为了让"前缀噪声剥离"知道最少要留几个字(见 `_strip_product_name_lead`)。
_ETF_NAME_SUFFIX = "ETF南方"
_PRODUCT_NAME_PATTERNS = (
_PRODUCT_NAME_SHAPE,
re.compile(r"[\u4e00-\u9fa5A-Za-z0-9]{2,10}?" + _ETF_NAME_SUFFIX),
)
#: 产品名前可能粘上的**动词 / 连接词 / 语气词**。
#:
#: 正则没有左边界:`我想买科创债ETF南方怎么样` 会从最早的起点起匹配,
#: 把「我想买」一起吞进产品名;`货币ETF南方和公司债ETF南方` 的第二个匹配会变成
#: 「和公司债ETF南方」。吞进来之后,"命中块讲的是不是这只"就会被判成否定 ——
#: 这不是理论风险,是**会造出假"没找到"**的一类缺陷。
#: 覆盖范围刻意收窄到"在本项目产品名里从不作首字"的功能字。
_PRODUCT_NAME_LEAD_NOISE = frozenset(
"和与跟的买卖想要查看问请帮我了有是这那在对给把将打算了解"
)
#: `E5b` 一致性闸门在 top-K 里找产品名的窗口。取 5 不取全量:`_exit_partial`
#: 展示的是**最高分**那一块,第 6 名往后已不在候选范围,用它们来否认"答非所问"
#: 会把明显答错的场景又放回来。
_PRODUCT_MISMATCH_LOOKAHEAD = 5
@classmethod
def _strip_product_name_lead(cls, name: str) -> str:
"""把产品名前粘上的功能字剥掉,只留名字本身。
两条形状各有各的锚点,**都不能用同一种办法**:
- `南方…` 形状自带锚点 —— 从第一个「南方」起算一定不会多吞,也不必担心
「南方稳健增利债券 A」这种合法首字被误剥;
- `…ETF南方` 形状内部没有第二个锚点,只能按 :attr:`_PRODUCT_NAME_LEAD_NOISE`
逐字剥离,并**至少留 2 个字 + `ETF南方`**(最短的合法名是「货币ETF南方」)。
"""
core = re.sub(r"\s+", "", name or "")
if core.endswith(cls._ETF_NAME_SUFFIX):
# ⚠️ 这条分支**必须排在"找第一个南方"之前**:`科创债ETF南方` 里也有「南方」,
# 先按锚点截会把名字截成「南方」两个字 —— 实测踩到的正是这个坑。
floor = 2 + len(cls._ETF_NAME_SUFFIX)
while len(core) > floor and core[0] in cls._PRODUCT_NAME_LEAD_NOISE:
core = core[1:]
return core
anchor = core.find("南方")
return core[anchor:] if anchor > 0 else core
@classmethod
def _product_names(cls, text: str) -> set[str]:
"""文本里出现的**产品名**(去空白 + 去前缀噪声后比较)。
`南方稳健增利债券 A` 与 `南方稳健增利债券A` 是同一只 —— 语料里带不带空格都有,
不归一化会把"命中块讲的就是这只"错判成"讲的是另一只",反而制造假无命中。
"""
names: set[str] = set()
for pattern in cls._PRODUCT_NAME_PATTERNS:
for match in pattern.finditer(text or ""):
name = cls._strip_product_name_lead(match.group(0))
if name:
names.add(name)
return names
@classmethod
def _names_other_product(cls, message: str, hits: Sequence[object]) -> bool:
"""问句点名的产品**一条命中都没提到**、而命中块讲的是别的产品 ⇒ 答非所问。
判据宁严勿宽,三条**刻意的不启用**条件(每条都对应一类真会答对的场景):
- 问句里没有形状完整的产品名(「我想买个债基」)→ 不启用:客户问的是类目,
库里的品类条款就是答案;
- 命中块一个产品名都没提(讲通用条款)→ 不启用:通用条款本来就该照答;
- 命中块提到了问句里那只(哪怕只是第 5 块)→ 不启用:说明确实命中了。
"""
asked = cls._product_names(message)
if not asked:
return False
talked: set[str] = set()
for hit in list(hits)[: cls._PRODUCT_MISMATCH_LOOKAHEAD]:
if not isinstance(hit, dict):
continue
talked |= cls._product_names(
f"{hit.get('title') or ''}\n{str(hit.get('content') or '')[:400]}"
)
if not talked:
return False
return not (asked & talked)
@classmethod
def _product_name_in_history(cls, request: AgentRequest) -> str:
"""`W21` C-9:在**上一轮答复**里按产品名形状找主语;找不到返回空串。
@@ -2081,8 +2213,13 @@ class CustomerServiceAgent(BaseAgent):
# 这类 `YIELD_METRIC_TERMS`。**没有改成"先净化再判定"**,因为
# `drop_yield_claims` 是**整行丢弃**、而生成稿常是单行长段 —— 实测换成
# 先净化后,三条里有两条变成"整条被删空",客户体验反而更差。
# 真正的解法在**生成侧**(提示词禁止输出收益数值),属合规口径决策,
# 见 `D1.6` 的待决项,不在代码里单方面放宽。
# `W21-D1`(2026-09-21 甲方批准):真正的解法在**生成侧** ——
# `DEFAULT_EVIDENCE_TEMPLATE` 的 ④ 已明确禁止生成稿出现任何收益数值。
# 本节顺序**保持不变**(仍先判合规、后净化):④ 是在源头消除,
# 不是把判定放宽;`drop_yield_claims` 继续只承担展示层兜底。
# ⚠️ 提示词自身不经过 `hits_zero_tolerance`(唯一调用点在本方法下方,
# 作用对象是 `answer`),所以在提示词里**引用**指标名是安全的;
# 换成"先净化再判合规"已被实测证伪(整行丢弃会把单行长段整条删空)。
rendered = render_plain(drop_yield_claims(answer))
if not rendered:
return self._exit_partial(evidence, note="证据约束生成内容全部为收益数值")
@@ -2656,8 +2793,15 @@ class CustomerServiceAgent(BaseAgent):
content = ""
if best is not None and best_score >= PARTIAL_FLOOR:
content = self._clamp_answer(best_text)
if not content:
text = PARTIAL_EMPTY_TEMPLATE
elif best_text.lstrip().startswith("问:"):
# `W21-D4`:命中块是 FAQ 问答对 ⇒ 以答案正文开场,不加兜底式前言。
text = PARTIAL_FAQ_TEMPLATE.format(content=content)
else:
text = PARTIAL_TEMPLATE.format(content=content)
return CoreResult(
text=PARTIAL_TEMPLATE.format(content=content) if content else PARTIAL_EMPTY_TEMPLATE,
text=text,
intent=self._classified_intent,
topic=self._declared_topic(content) if content else "",
)