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
@@ -2177,3 +2177,168 @@ async def test_suitability_exit_still_clarifies_on_a_first_turn_pronoun() -> Non
result = await agent._answer_suitability(build_request("它费率多少?"), CUSTOMER)
assert result.clarification_required is True
assert result.transfer_required is False
# ---------------------------------------------------------------------------
# `W21-D1`~`D4`:甲方四项待决的落地守卫
# ---------------------------------------------------------------------------
def test_evidence_prompt_bans_yield_numbers_in_the_generated_answer() -> None:
"""`W21-D1`(`C-11`):根因在**生成侧**,所以修法是把禁令写进提示词。
三条实测问句(`南方现金添利怎么样` / `买基金要手续费吗` / `债基和货基哪个收益高`)
检索**完全正确**,生成稿里却出现收益数值,被 `hits_zero_tolerance` **整条**拦回 `E5b`。
改闸门(先净化再判合规)已被实测证伪(整行丢弃会把单行长段整条删空),
因此必须在源头说清楚"不要写数字"。
"""
template = customer_service_module.DEFAULT_EVIDENCE_TEMPLATE
assert "不要出现任何收益数值" in template
# 模板必须仍能被 `_answer_from_evidence` 正常 format:裸花括号会让运行期回落内置模板,
# 于是这段禁令**静默失效**——比不写更糟。
rendered = template.format(evidence="【证据】", message="测试问句")
assert "【证据】" in rendered
assert "测试问句" in rendered
def test_output_side_yield_check_is_not_relaxed_by_d1() -> None:
"""`W21-D1` 只改**生成侧话术**,输出侧红线一条没松(安全边界不退让)。"""
from app.core.customer_service_rules import hits_zero_tolerance
assert hits_zero_tolerance("该产品近一年收益率 11.85%。") is True
# 「问含义」照旧放行(`A-05`/`F-04` 要求「什么叫七日年化」必须能答)。
assert hits_zero_tolerance("什么叫七日年化?") is False
def test_partial_exit_uses_an_answering_tone() -> None:
"""`W21-D4`:`E5b` 开场白从"我把找到的资料放上来"改成"作答"语气。
内容一字未改,只换开场 —— 客户对"这个客服会不会答"的判断完全不同。
"""
result = build_agent()._exit_partial(
[{"score": 0.6, "content": "基金申购费率按金额分档。"}], note="置信度不足"
)
assert "关于这一点,公开资料里的口径是:" in result.text
assert "我先帮您把找到的公开资料放上来" not in result.text
assert result.transfer_required is False
def test_partial_exit_answers_a_faq_pair_directly() -> None:
"""`W21-D4`:命中块本身就是一条 FAQ 问答对时,**以答案正文开场**,不套前言。
实测「债基和货基哪个收益高」命中的 `FAQ-0068` 正文就是标准答案,
却因为兜底式开场显得"客服不会答"。
"""
content = (
"问:债券型基金和货币市场基金哪个收益高?\n"
"答:两者是不同风险收益特征的品类,本公司不提供这类比较结论。"
)
result = build_agent()._exit_partial(
[{"score": 0.7, "content": content}], note="置信度不足"
)
assert result.text.startswith("问:债券型基金和货币市场基金哪个收益高?")
assert "关于这一点,公开资料里的口径是" not in result.text
assert "400-889-8899" in result.text
def test_knowledge_miss_judgement_survives_the_template_change() -> None:
"""`W21-D4` 的连带风险:`F-2`/`G-05` 的转人工判据锚在 `KNOWLEDGE_MISS_TEXTS` 上。
改开场白必须让判据**同源移动**,否则会出现「模板改了、判据静默失效」——
该转人工的不转、或不该转的乱转。
"""
agent = build_agent()
for text in customer_service_module.KNOWLEDGE_MISS_TEXTS:
assert agent._knowledge_missed(CoreResult(text=text)) is True
assert agent._knowledge_missed(CoreResult(text="这是一段有实质内容的答复")) is False
def test_product_names_covers_both_corpus_shapes() -> None:
"""`W21-D3`:产品名在语料里有**两种真实写法**,只认一种会漏掉整类 ETF。"""
agent = build_agent()
assert agent._product_names("科创债ETF南方怎么样") == {"科创债ETF南方"}
assert agent._product_names("货币ETF南方和公司债ETF南方哪个好") == {
"货币ETF南方",
"公司债ETF南方",
}
# 归一化:语料里带不带空格都有,同一个名字必须判成同一只。
assert agent._product_names("南方稳健增利债券 A 的费率") == {"南方稳健增利债券A"}
# 类目词不是产品名;公司名也不是(后缀约束在起作用)。
assert agent._product_names("我想买个债基") == set()
assert agent._product_names("南方基金是什么公司") == set()
def test_product_names_survive_leading_noise_in_the_question() -> None:
"""`W21-D3`:正则没有左边界,**动词 / 连接词会被吞进产品名**,必须剥干净。
吞进来的后果不是"少判一次",而是**造出假「没找到」**:`asked` 变成了
「我想买科创债ETF南方」,命中块里当然找不到这个名字,于是客户明明问在库的产品
却被回一句"没有公开资料"。这条是有针对性的回归守卫。
"""
agent = build_agent()
assert agent._product_names("我想买科创债ETF南方怎么样") == {"科创债ETF南方"}
assert agent._product_names("货币ETF南方和公司债ETF南方哪个好") == {
"货币ETF南方",
"公司债ETF南方",
}
assert agent._product_names("申购南方平衡优选混合要多少钱") == {"南方平衡优选混合"}
# 最短的 ETF 名(2 字 + 后缀)不得被剥到失真。
assert agent._product_names("买货币ETF南方") == {"货币ETF南方"}
def test_product_mismatch_gate_catches_answering_with_another_product() -> None:
"""`W21-D3`:问 A 却拿 B 的产品卡 ⇒ 判"答非所问",`E5b` 宁可说没找到。"""
agent = build_agent()
hits = [
{
"title": "南方基金管理股份有限公司 公募基金与专户产品手册 · 一、公募基金产品 · 1.",
"content": "1.2 南方稳健增利债券 A〔示例〕\n风险等级 R2(中低风险)",
},
{"title": "… · 四、产品对比与适当性匹配", "content": "C1 可购买 R1、R2。"},
]
# 命中块讲的是**别的**产品 ⇒ 启用闸门。
assert agent._names_other_product("科创债ETF南方怎么样", hits) is True
# 命中块讲的就是问句里那只(哪怕带空格差异)⇒ 不判答非所问。
assert agent._names_other_product("南方稳健增利债券 A 怎么样", hits) is False
# 问句没点名具体产品(问的是类目)⇒ 闸门不启用。
assert agent._names_other_product("我想买个债基", hits) is False
# 命中块讲的是通用条款、一个产品名都没提 ⇒ 闸门不启用(通用条款本来就该照答)。
generic = [{"title": "第五章 费用与费率管理", "content": "费用按金额分档收取。"}]
assert agent._names_other_product("科创债ETF南方怎么样", generic) is False
async def test_suitability_exit_states_the_inferred_referent() -> None:
"""`W21-D2`:主语是**反解**出来的时必须摆出指代依据,客户认错要能立刻纠正。"""
agent = build_agent()
async def fake_risk_level(product: str, context: RequestContext) -> int:
return 2
async def fake_call_tool(name, arguments, *, intent, context): # noqa: ANN001, ANN202
return {
"allowed": True,
"reason_code": "SUITABLE",
"required_disclosure": False,
"requires_confirmation": False,
"requires_recording": False,
"customer_risk_level": 3,
"assessment_valid_until": None,
}
agent._product_risk_level = fake_risk_level # type: ignore[method-assign]
agent.call_tool = fake_call_tool # type: ignore[method-assign]
history = (
ConversationTurn(role="user", content="我想买个债基"),
ConversationTurn(
role="assistant",
content=(
"问:你们有没有债券型基金(债基)?\n"
"答:有。代表产品是南方稳健增利债券 A〔示例〕,风险等级 R2(中低风险)。"
),
),
)
result = await agent._answer_suitability(
build_request("它适合我吗", history=history), CUSTOMER
)
assert "您上一轮提到的是「南方稳健增利债券 A」" in result.text
assert "可以购买" in result.text