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
+158 -3
View File
@@ -294,6 +294,110 @@ PROMOTION_REQUEST_PATTERNS = (
r"(基金|产品|理财|一只|几支)"),
)
#: 「本人可购买范围」问法(`E2c-my`)。**它不是推介请求** —— 这是本项目最容易混淆的一处,
#: 两个问句在字面上只差一个限定词,性质却完全相反:
#:
#: - 「我能买**什么等级**的产品」= 问**本人等级的匹配范围**。依据是公开的 C—R 匹配规则
#: (`PROD-012` §4.2 为 `public` 档)叠加本人权威等级 ⇒ **属该答**;
#: - 「我买什么理财产品**好**」= 要一个**指向性结论** ⇒ 投资建议,**属红线**。
#:
#: 判据刻意**要求「能力 / 适当性标记」**(能 / 可以 / 够 / 适合 / 有资格 / 允许)**或「范围限定词」**
#: (等级 / 档位 / 级别 / 范围):有标记 ⇒ 问的是「我够不够得上哪些」,没有 ⇒ 问的是「哪个更好」。
#: 这样「我买什么理财产品好」不会被误放行,`PROMOTION_REQUEST_PATTERNS` 的拦截力不受影响。
_OWN_SUBJECT = r"(我|本人|自己)"
_ABILITY = r"(能|可以|可|能够|够|适合|有资格|允许)"
_RANGE_WORD = r"(等级|风险等级|级别|档位|档|范围)"
_MARKET_WORD = r"(产品|基金|理财|标的|品种)"
_WHICH = r"(什么|哪些|哪几|哪[只支个种些])"
OWN_ELIGIBILITY_PATTERNS: tuple[re.Pattern[str], ...] = (
# ① 「我(能/适合)买(什么/哪些)…(产品|等级|范围)」
re.compile(rf"{_OWN_SUBJECT}[^。;!?,,]{{0,6}}{_ABILITY}[^。;!?,,]{{0,4}}"
rf"(买|购买|申购|投)[^。;!?,,]{{0,6}}{_WHICH}[^。;!?,,]{{0,8}}"
rf"({_MARKET_WORD}|{_RANGE_WORD})"),
# ② 「我(的等级)能买哪些」——「哪些」直接收尾(宾语省略)
re.compile(rf"{_OWN_SUBJECT}[^。;!?,,]{{0,8}}{_WHICH}[^。;!?,,]{{0,6}}"
rf"{_ABILITY}[^。;!?,,]{{0,4}}(买|购买|申购|投)"),
# ③ 「我这个等级能买 R几 / 几档 / 哪一级」
re.compile(rf"{_OWN_SUBJECT}[^。;!?,,]{{0,8}}(买|购买|申购|投)[^。;!?,,]{{0,6}}"
rf"(R[1-5]|R几|R多少|几级|几档|几等|哪一档|哪一级|哪个等级)"),
# ⑤ 「(有)哪些/什么…适合我的…(产品|基金…)」—— 与 ① 同义,只是不出现「买」字。
# 实测 2026-09-20:「有哪些适合我的产品」与「我适合买什么产品」是**同一件事**,
# 前者却落 `E1` 澄清("还没看出您指的是哪只产品")⇒ 补上这条骨架。
# 判据仍要求「适合**我的**」这种**明确指向本人**的限定语:「有哪些好产品」不含它,
# 依旧走推介边界,不受影响。
re.compile(rf"{_WHICH}[^。;!?,,]{{0,6}}(适合我的|我能买的|我可以买的)"
rf"[^。;!?,,]{{0,4}}({_MARKET_WORD})"),
# ⑥ 「我(想|要|打算)买…(理财产品|基金)」—— 表达**购买意愿**,不是要推荐。
# 实测 2026-09-20:「我想买点理财产品」原先落一段「第二条 适用范围」政策原文,
# 答非所问。走本出口后回答变成"按您的等级,这些产品在范围内" —— 仍是**陈述**。
re.compile(rf"{_OWN_SUBJECT}[^。;!?,,]{{0,4}}(想|要|打算|准备|考虑)"
rf"[^。;!?,,]{{0,6}}(买|购买|申购|投资)[^。;!?,,]{{0,6}}({_MARKET_WORD})"),
# ④ 「我够哪一档 / 我属于哪一级」
re.compile(rf"{_OWN_SUBJECT}[^。;!?,,]{{0,4}}(够|到|达到|属于|算)"
rf"[^。;!?,,]{{0,6}}({_RANGE_WORD})"),
)
def is_own_eligibility_question(text: str) -> bool:
"""问的是「以我本人的等级,我能买哪些」—— **陈述可买范围**,不是「帮我挑一只」。
这条判据是 `PROMOTION_REQUEST_PATTERNS` 的**豁免项**:它命中的问句问的是**规则与范围**
(公开的 C—R 匹配矩阵 + 本人权威等级),不是要求一个指向性结论。豁免必须**精确**
—— 只认带能力 / 适当性标记或范围限定词的说法,避免把真正的推介请求一起放行。
"""
return _hit_patterns(text, OWN_ELIGIBILITY_PATTERNS)
#: **输出侧**「引导购买」禁用措辞(`E2c-my` 的可买清单答复用)。
#:
#: 与 `VISITOR_ADVICE_PATTERNS` 管的**对象不同**:那一条守访客侧「不得出现投资建议」,
#: 这一条守客户侧「**列清单可以,引导不行**」—— 客户该拿到的是「按你的等级能买这些」,
#: 而不是「建议你买这些 / 这些更适合你 / 那只收益最高 / 抓紧申购」。
#: 复用同一套语境豁免(否定 / 询问 / 引用):话术里的「不构成推荐」属**否定**,不触发。
PROMOTIONAL_WORDING_PATTERNS: tuple[re.Pattern[str], ...] = (
re.compile(r"(推荐|推介|优选|精选|首选|力荐)"),
re.compile(r"建议[^。;!?,,]{0,4}(购买|买入|申购|赎回|投资|配置|入手|加仓|建仓)"),
re.compile(r"(更|最)适合(您|你)"),
re.compile(r"(值得|不妨|可以考虑)[^。;!?,,]{0,3}(买|购买|申购|入手|投资|关注)"),
re.compile(r"(收益|回报|涨幅|业绩)[^。;!?,,]{0,4}(最高|最好|最优|第一|排名|更高)"),
re.compile(r"(抓紧|尽快|赶快|趁早|别错过|赶紧)[^。;!?,,]{0,6}(买|申购|入手|上车|布局)"),
re.compile(r"现在[^。;!?,,]{0,3}(正是|就是)[^。;!?,,]{0,4}(布局|入手|上车|买入|申购)"),
)
#: **紧邻**否定前缀。为什么不用底座 ``NEGATION_CUES`` 就够:它的线索是**同句窗口**匹配,
#: 而「不构成」不在其中;若把「不构成」加进底座,就会连带豁免同句后文的真实引导
#: (反例:「本产品不构成风险,建议您买入」——「建议您买入」必须仍被拦)。
#: 因此这里用**严格紧邻**判据:线索必须**紧贴在命中词之前**。
_LOCAL_NEGATION_PREFIXES: tuple[str, ...] = (
"不构成", "不视为", "不属于", "不作为", "不提供", "不做", "不含", "并非", "不是",
)
def _locally_negated(text: str, index: int) -> bool:
"""命中词之前**紧邻**着否定前缀(如「不构成推荐」)⇒ 该次命中豁免。"""
return any(
index - len(cue) >= 0 and text[index - len(cue):index] == cue
for cue in _LOCAL_NEGATION_PREFIXES
)
def promotional_wording_violation(text: str) -> str | None:
"""返回第一条**未处于否定 / 询问 / 引用语境**的引导性措辞(无则 `None`)。
这是「不引导购买」的**确定性**保证:不靠提示词,靠出口后置校验。
豁免口径 = 底座语境豁免 **或** 紧邻否定前缀(后者专为「不构成推荐」这类标准免责句)。
"""
for pattern in PROMOTIONAL_WORDING_PATTERNS:
for match in pattern.finditer(text):
if not is_prohibited_context(text, match.start(), match.group(0)) and not _locally_negated(
text, match.start()
):
return match.group(0)
return None
#: 收益承诺陷阱的补充正则。**存在的理由是一个真实矛盾**:用户会问
#: 「有什么年化5%以上的理财」——这句话**不含**「年化收益率」这个字面,朴素子串匹配抓不到,
#: 若直接放行就会当成普通产品咨询去检索、再由模型自由作答。
@@ -519,6 +623,34 @@ P2_PATTERNS = (
r"(钱|资金|钱款|款项)[^。;!?,,]{0,6}(转|划|汇)"),
)
#: P2 的**自助流程问法**豁免(`W20`,2026-09-20 实测)。
#:
#: 实测「怎么修改绑定的银行卡」被上面第 1 条(动词在前语序)整条拦成**建单转人工**,
#: 而 `knowledge/faq/高频问答对.txt` 里就有这条 FAQ
#: (「怎么修改绑定的银行卡或手机号?→ 可在 APP「我的—安全中心」自助办理,需人脸识别…」)。
#: **模板里有答案的流程题被推给人工**,正是「客服看起来只会转人工」的典型形态。
#:
#: 判据刻意收得很窄 —— 必须同时满足两件事:
#: ① 句子里有**疑问词**(怎么 / 如何 / 怎样 / 在哪…)⇒ 问的是"怎么办",不是"你替我办";
#: ② 没有**代办请求词**(帮我 / 替我 / 麻烦你…)。
#: 于是「我要修改绑定的银行卡」「帮我把绑定银行卡换一下」(`G-03`)**照旧建单** ——
#: 那两句说的是"你来办",能力与红线都不减。
#: 与 `CREDENTIAL_HELP_PATTERNS` 对「怎么修改密码」的处理同源:问方法 ≠ 报事故。
P2_SELF_SERVICE_PATTERNS = (
re.compile(r"(怎么|如何|怎样|咋|在哪|哪里)[^。;!?,,]{0,8}"
r"(修改|更改|变更|更换|换|改)[^。;!?,,]{0,6}"
r"(手机号|手机号码|银行卡|密码|邮箱|资料|绑定)"),
re.compile(r"(修改|更改|变更|更换)[^。;!?,,]{0,6}"
r"(手机号|手机号码|银行卡|密码|邮箱|资料)"
r"[^。;!?,,]{0,8}(怎么|如何|怎样|在哪|哪里)"),
)
#: 委托他人(含客服)代办的动作词。出现它,问题就从"流程咨询"变成"代办请求"。
P2_DELEGATION_MARKERS = (
"帮我", "替我", "给我", "给咱", "麻烦你", "麻烦您", "请帮", "帮我办",
"代我", "代为", "你来", "你们来", "由你们",
)
# ---------------------------------------------------------------------------
# 闲聊判定与连续闲聊计数(恢复自一期留痕 `_CHITCHAT_MESSAGES` / `_CHITCHAT_PHRASES`)
# ---------------------------------------------------------------------------
@@ -583,7 +715,9 @@ ADVICE_BOUNDARY_REPLY = (
f"{COMPANY}智能客服不能为您推荐具体产品、不能做排序比较,也不能判断某只产品是否适合您 ——"
"这类属于投资建议,需要结合您的风险测评结果,由具备资质的专业人员提供。"
"我可以为您介绍产品的公开资料:风险等级、费率、申赎规则、开放时间与产品要素;"
"如果您想了解自己的风险承受能力,也可以先完成风险测评。"
"如果您已经做过风险测评,也可以直接问我「我能买什么」,"
"我按您留存的、在有效期内的测评等级,列一份在售产品中落在您可购买范围内的清单"
"(只列代码、名称、类别与风险等级,不构成推荐)。"
f"如需人工协助,请拨打官方客服电话 {CONTACT_PHONE}({CONTACT_HOURS})。"
)
@@ -693,6 +827,13 @@ def hits_zero_tolerance(text: str) -> bool:
return _hit_patterns(text, YIELD_TRAP_PATTERNS) or _hit_patterns(text, RANKING_REQUEST_PATTERNS)
def _is_p2_self_service_question(text: str) -> bool:
"""问「怎么做」而不是「替我办」→ 不按 P2 建单。判据见 `P2_SELF_SERVICE_PATTERNS`。"""
if not _hit_patterns(text, P2_SELF_SERVICE_PATTERNS):
return False
return not _hit_keywords(text, P2_DELEGATION_MARKERS)
def route_message(message: str) -> SafetyRoute | None:
"""按 P0硬级 → P0凭据级 → 注入 → 合规 → 推介边界 → P1 → P2 判定;``None`` = P3(走检索)。
@@ -746,7 +887,15 @@ def route_message(message: str) -> SafetyRoute | None:
priority=COMPLIANCE_PRIORITY, intent="product_inquiry", needs_clarification=False,
reply=COMPLIANCE_REPLY, transfer_required=False, transfer_reason=None,
)
if _hit_patterns(text, PROMOTION_REQUEST_PATTERNS):
if _hit_patterns(text, PROMOTION_REQUEST_PATTERNS) and not is_own_eligibility_question(text):
# 为什么要有 `and not is_own_eligibility_question(text)` 这一半:
# 「我能买什么**等级**的产品」与「有什么好产品推荐」在句式上只差一个限定词,
# 但前者问的是**本人等级的匹配范围**(公开 C—R 矩阵 + 本人权威等级 ⇒ 该答),
# 不是要一个指向性结论。不放这半边,客服就会把一句**该答的规则题**答成
# 「不能为您推荐具体产品」(实测 2026-09-20:客户 `cust_t` 问「我现在可以买
# 什么等级的产品」被本分支拦成边界话术,与 `suitability_service` 里
# 「客服回答按矩阵」的裁定直接冲突)。豁免判据是**精确的**:见
# `OWN_ELIGIBILITY_PATTERNS` 的四条骨架,普通推介请求仍照旧拦下。
# 推介请求:不提供投资建议,但**也不建单** —— 它不是资金风险、不是争议,
# 也不是"必须人来办的事";边界话术里已经给出可答范围与自助入口。
return SafetyRoute(
@@ -762,7 +911,13 @@ def route_message(message: str) -> SafetyRoute | None:
priority="P1", intent="transfer_human", needs_clarification=False,
reply=P1_REPLY, transfer_required=False, transfer_reason=None,
)
if _hit_keywords(text, P2_WRITE_DISPUTE_KEYWORDS) or _hit_patterns(text, P2_PATTERNS):
# 两个分支共用同一个豁免:`P2_WRITE_DISPUTE_KEYWORDS` 里有「改手机号」这类**短语**,
# 「怎么改手机号」走的是关键词分支而不是正则分支(实测踩到过),豁免必须覆盖两者,
# 否则同一件事换个写法就变成转人工,回归钉不住。
if (
_hit_keywords(text, P2_WRITE_DISPUTE_KEYWORDS)
or _hit_patterns(text, P2_PATTERNS)
) and not _is_p2_self_service_question(text):
return SafetyRoute(
priority="P2", intent="transfer_human", needs_clarification=False,
reply=P2_REPLY, transfer_required=True,