feat(W20): 新增 E2c-my 出口 + 展示层净化 + P2 自助流程豁免(金标零回归)

用户口径:既智能又安全,不靠"答不上就转人工";按本人画像测评列出可购买的产品,
但不引导购买。

出口由五个扩为六个:
- 新增 E2c-my:按客户本人权威测评等级给出可购买范围 + 在售清单
  (只列代码/名称/类别/风险等级,按代码升序;出口后置引导词护栏,命中即降级为仅范围)
- 点名档位(「我可以买 R3 的产品吗」)先给一句直接裁决再列范围
  —— 修复前只给清单,"不"字始终没说出来
- 新工具 query_eligible_products(suitability:read)+ 发布 release 220

真实业务缺陷:
- P2_SELF_SERVICE_PATTERNS:问「怎么修改绑定的银行卡」不再建单转人工(FAQ 里就有答案);
  「帮我把绑定银行卡换一下」仍走 P2
- 展示层净化 render_plain / drop_yield_claims / prettify_title 接在 E3/E4/E5b/澄清四处
  (E4 模型会照抄证据包的 markdown 标记)
- 空结果护栏:语料里 15 个纯收益切片被净化清空时回退 E5b,不给空气泡

测试:定向 334 passed;全量回归 1969 passed / 3 skipped / 0 failed(基线 1946);
46 条金标 M-1 46/46=100%、M-4 100%、M-6 5/46=10.9%、M-7~M-10 全 0 —— 与
score_w11b 逐项一致(零回归)。

落档:D1.6 §4.49 / D2.1 v6.36 / D1.1 §30
This commit is contained in:
张胜宇
2026-09-20 18:55:22 +08:00
parent dc83f4f4a7
commit 35effab70f
10 changed files with 1537 additions and 27 deletions
@@ -464,3 +464,135 @@ def test_zero_loss_commitment_is_a_compliance_red_line(message: str) -> None:
assert route.priority == "COMPLIANCE"
# 合规拒答**不建单**:它是"不能承诺",不是"必须人工办的事"
assert route.transfer_required is False
# ---------------------------------------------------------------------------
# `W20`:本人可购范围(`E2c-my`)的判据与"不引导"护栏
# ---------------------------------------------------------------------------
#
# 守两条不可回退的界线:
# ① 「我能买什么**等级**的产品」这类**问本人匹配范围**的问法,必须从
# `PROMOTION_REQUEST_PATTERNS` 的拦截里**豁免**出去 —— 它问的是公开的 C—R 匹配
# 矩阵叠加本人权威等级,属该答;2026-09-20 实测它曾被误拦成边界话术,与
# `suitability_service` 里「客服回答按矩阵」的裁定直接冲突。
# ② 同一张表的**推介请求**必须仍被拦下 —— 豁免一旦放宽,Agent 就会开始"帮客户挑产品"。
# 「我买什么理财产品**好**」是这条界线的**回归钉子**。
#: 该答:问本人等级的匹配范围(豁免 `PROMOTION_REQUEST_PATTERNS`)。
OWN_ELIGIBILITY_QUESTIONS = (
"我现在可以买什么等级的产品",
"我能买什么风险等级的产品?",
"我适合买什么产品",
"我这个等级能买 R几?",
"我够哪一档?",
"有哪些适合我的产品",
"我都能买哪些基金",
)
#: 回归钉子:要一个**指向性结论**,仍必须走推介边界。
STILL_PROMOTION_REQUESTS = (
"帮我推荐一只基金",
"我买什么理财产品好",
"有什么好产品值得买",
"给我挑一只收益最高的基金",
"哪只基金收益最高",
)
@pytest.mark.parametrize("message", OWN_ELIGIBILITY_QUESTIONS)
def test_own_eligibility_questions_are_not_treated_as_promotion_requests(message: str) -> None:
assert rules.is_own_eligibility_question(message) is True, message
route = rules.route_message(message)
assert route is None or route.priority != rules.ADVICE_BOUNDARY_PRIORITY, message
@pytest.mark.parametrize("message", STILL_PROMOTION_REQUESTS)
def test_promotion_requests_are_still_blocked(message: str) -> None:
"""豁免之后,要指向性结论的问法**一道都不能漏**。
允许落在两个闸门之一:`ADVICE_BOUNDARY`(推介边界)或 `COMPLIANCE`(排序 / 最优请求
更早被合规拦下)—— 两者都是拦,**唯一不允许的是 `None`(放行去检索)**。
"""
route = rules.route_message(message)
assert route is not None, message
assert route.priority in {rules.ADVICE_BOUNDARY_PRIORITY, rules.COMPLIANCE_PRIORITY}, message
@pytest.mark.parametrize(
"message", ("帮我推荐一只基金", "我买什么理财产品好", "有什么好产品值得买")
)
def test_plain_promotion_requests_land_on_the_advice_boundary(message: str) -> None:
route = rules.route_message(message)
assert route is not None, message
assert route.priority == rules.ADVICE_BOUNDARY_PRIORITY, message
assert route.reply == rules.ADVICE_BOUNDARY_REPLY, message
def test_promotion_gate_exemption_is_exact_not_a_blanket_removal() -> None:
"""豁免必须**精确**:问句里出现「等级」不足以放行,仍要有"本人 + 购买"的骨架。"""
assert rules.is_own_eligibility_question("R3 的产品有哪些") is False
assert rules.route_message("给我推荐几个 R2 等级的产品") is not None
#: 「不引导购买」护栏:陈述可买范围允许,引导购买不允许。
PROMOTIONAL_WORDING = (
"建议您购买南方金利定开债券A",
"这只产品更适合您",
"抓紧申购以免错过这波行情",
"这几只里收益最高的是 510300",
"推荐您关注科创债ETF南方",
)
#: 免责句里的「推荐」是否定式,**不得**被自己的护栏拦掉(否则每次答复都被降级)。
NON_PROMOTIONAL_WORDING = (
"以下 4 只产品在您的可购买范围内(按产品代码升序)。",
"本清单由系统按您的风险测评等级自动筛选,不构成推荐。",
"本清单不构成推荐或投资建议。",
)
@pytest.mark.parametrize("text", PROMOTIONAL_WORDING)
def test_promotional_wording_is_detected(text: str) -> None:
assert rules.promotional_wording_violation(text) is not None, text
@pytest.mark.parametrize("text", NON_PROMOTIONAL_WORDING)
def test_promotional_wording_exempts_disclaimers(text: str) -> None:
assert rules.promotional_wording_violation(text) is None, text
# ---------------------------------------------------------------------------
# `P2` 自助流程问法豁免(`W20`):问「怎么办」不等于「你替我办」
# ---------------------------------------------------------------------------
# 实测发现:知识库里**有**「怎么修改绑定的银行卡或手机号?」这条 FAQ,而该问法
# 被 `P2_PATTERNS` 第 1 条拦成建单转人工 —— 能答的流程题推给人工,正是"客服看起来
# 只会转人工"的形态。豁免收窄到「疑问词 + 改动动作 且 无代办请求词」。
P2_SELF_SERVICE_QUESTIONS = (
"怎么修改绑定的银行卡",
"如何更换绑定的银行卡",
"怎么改手机号",
"怎样修改登录密码",
"银行卡怎么修改绑定",
)
#: 这几句说的是"你替我办"或"我要办",**照旧**是 P2(建单转人工),一行都不能松。
STILL_P2_REQUESTS = (
"帮我把绑定银行卡换一下",
"帮我改手机号",
"我要修改绑定的银行卡",
"替我更换绑定的银行卡",
"我要销户",
"我要投诉你们的服务",
)
@pytest.mark.parametrize("message", P2_SELF_SERVICE_QUESTIONS)
def test_p2_how_to_questions_are_not_treated_as_write_requests(message: str) -> None:
route = rules.route_message(message)
assert route is None or route.priority != "P2", message
@pytest.mark.parametrize("message", STILL_P2_REQUESTS)
def test_p2_write_and_dispute_requests_are_still_escalated(message: str) -> None:
route = rules.route_message(message)
assert route is not None, message
assert route.priority == "P2", message
assert route.transfer_required is True, message