From 35effab70f310c409cae5cb7fa1f02edc00e6474 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E8=83=9C=E5=AE=87?= <17412268+zzzzz11122222@user.noreply.gitee.com> Date: Sun, 20 Sep 2026 18:55:22 +0800 Subject: [PATCH] =?UTF-8?q?feat(W20):=20=E6=96=B0=E5=A2=9E=20E2c-my=20?= =?UTF-8?q?=E5=87=BA=E5=8F=A3=20+=20=E5=B1=95=E7=A4=BA=E5=B1=82=E5=87=80?= =?UTF-8?q?=E5=8C=96=20+=20P2=20=E8=87=AA=E5=8A=A9=E6=B5=81=E7=A8=8B?= =?UTF-8?q?=E8=B1=81=E5=85=8D=EF=BC=88=E9=87=91=E6=A0=87=E9=9B=B6=E5=9B=9E?= =?UTF-8?q?=E5=BD=92=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户口径:既智能又安全,不靠"答不上就转人工";按本人画像测评列出可购买的产品, 但不引导购买。 出口由五个扩为六个: - 新增 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 --- app/core/customer_service_rules.py | 161 +++++++- app/service/agent/bootstrap.py | 16 +- .../agent/implementations/customer_service.py | 362 +++++++++++++++++- app/service/suitability_service.py | 168 ++++++++ .../unit/core/test_customer_service_rules.py | 132 +++++++ .../service/test_customer_service_agent.py | 300 ++++++++++++++- tools/publish_customer_service_tool_config.py | 298 ++++++++++++++ 客服agent/D2.1-客服Agent执行Todolist.md | 27 +- 开发文档/D1.1-文档索引与权威声明.md | 30 +- ...话上下文提取与开工前补充决策-2026-09-17.md | 70 ++++ 10 files changed, 1537 insertions(+), 27 deletions(-) create mode 100644 tools/publish_customer_service_tool_config.py diff --git a/app/core/customer_service_rules.py b/app/core/customer_service_rules.py index 7847142..c458b6c 100644 --- a/app/core/customer_service_rules.py +++ b/app/core/customer_service_rules.py @@ -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, diff --git a/app/service/agent/bootstrap.py b/app/service/agent/bootstrap.py index 7ca49d9..3582135 100644 --- a/app/service/agent/bootstrap.py +++ b/app/service/agent/bootstrap.py @@ -69,7 +69,12 @@ from app.service.risk_tools import ( search_risk_alerts_tool, ) from app.service.runtime_config_service import load_active_intent_configs -from app.service.suitability_service import SuitabilityToolInput, suitability_tool_handler +from app.service.suitability_service import ( + EligibleProductQuery, + SuitabilityToolInput, + query_eligible_products_tool, + suitability_tool_handler, +) from app.service.tool_executor import ToolDefinition, ToolExecutor, ToolRegistry logger = logging.getLogger(__name__) @@ -284,6 +289,15 @@ def get_agent_factory() -> AgentFactory: required_permission="suitability:read", allowed_roles=("customer", "advisor", "operator", "admin"), )) + registry.register(ToolDefinition( + name="query_eligible_products", + input_model=EligibleProductQuery, + handler=cast(Any, query_eligible_products_tool), + # 复用适当性权限码:这份清单本身就是「按等级匹配」的结果,与 `check_suitability` + # 同权、同数据源(`fin_risk_assessment` + `fin_product`),不新开权限面。 + required_permission="suitability:read", + allowed_roles=("customer", "advisor", "operator", "admin"), + )) registry.register(ToolDefinition( name="query_fund_quote", input_model=FundQuoteQuery, diff --git a/app/service/agent/implementations/customer_service.py b/app/service/agent/implementations/customer_service.py index 00a1320..661358e 100644 --- a/app/service/agent/implementations/customer_service.py +++ b/app/service/agent/implementations/customer_service.py @@ -57,7 +57,9 @@ from app.core.customer_service_rules import ( TRANSFER_REASONS, SafetyRoute, hits_zero_tolerance, + is_own_eligibility_question, is_risk_level_change_request, + promotional_wording_violation, route_message, visitor_advice_violation, ) @@ -106,6 +108,11 @@ VISITOR_TOOL_NAME = "query_knowledge" # (来自 fin_risk_assessment,且带测评有效期),Agent 自己判分等于绕开合规链路。 SUITABILITY_TOOL = "check_suitability" +#: `E2c-my` 的可买清单工具(`W20`)。它只读 `fin_product` 的公开字段(代码 / 名称 / +#: 类别 / 风险等级),**不含净值、收益与费率** —— 字段收窄本身就是把"清单"与"推介"分开 +#: 的第一道保证。 +ELIGIBLE_TOOL_NAME = "query_eligible_products" + # 风险等级名称:国标五级,稳定不变,只用于把裁决结果说成人话。 RISK_LEVEL_NAMES = { 1: "R1(低风险)", 2: "R2(中低风险)", 3: "R3(中风险)", @@ -348,19 +355,121 @@ TRANSFER_TEMPLATE = ( f"请拨打客服热线 {HOTLINE}({SERVICE_HOURS}),我们会为您核实并跟进。" ) + +# --------------------------------------------------------------------------- +# 展示层净化(`W20` 真实场景扫描) +# --------------------------------------------------------------------------- +# +# 扫描 28 条真实问法时发现三个**看起来就很笨**的展示缺陷,都不是"答错",而是"答对了 +# 但客户看不懂": +# +# 1. **markdown 源文原样发给客户**:E3 是原文直返,而知识块是 markdown 源文, +# 客户看到的是「### 第二条 适用范围」「**一、购买渠道**」「| 费用类型 | 货币基金 |」; +# 2. **澄清候选被截断成半句**:标题 `[:40]` 硬切,出现 +# 「…产品手册 · 一、公募基金产品 · 1.」这种以编号结尾的碎片; +# 3. **知识块自带的收益数字被摊到客户面前**:「近一年收益率 7.60%」。 +# +# 三条都在**展示层**修,不动知识库、不动检索、不动判分口径。 + +_MD_HEADING = re.compile(r"^\s{0,3}#{1,6}\s*", re.M) +_MD_BULLET = re.compile(r"^(\s*)[-*+]\s+", re.M) +_MD_QUOTE = re.compile(r"^\s{0,3}>\s?", re.M) +_MD_HR = re.compile(r"^\s{0,3}(?:-{3,}|\*{3,}|_{3,})\s*$", re.M) +_MD_TABLE_SEP = re.compile(r"^\s*\|?[\s:|-]*\|[\s:|-]*$", re.M) +_MD_BOLD = re.compile(r"\*\*(.+?)\*\*|__(.+?)__", re.S) +_MD_CODE = re.compile(r"`([^`]*)`") +_MD_LINK = re.compile(r"\[([^\]]+)\]\([^)]*\)") + + +def render_plain(text: str) -> str: + """把知识块的 markdown 源文净化成**客户读得懂的纯文本**。 + + 只做"去标记",不删内容:标题留字、列表改「· 」、加粗去星号、表格转全角分隔。 + 前端是纯文本气泡,`###` 与 `**` 只会显示成噪声字符 —— 客户看到的是"客服把源文件 + 贴出来了",这比答错更伤可信度。 + """ + if not text: + return "" + out = _MD_LINK.sub(r"\1", text) + out = _MD_BOLD.sub(lambda m: m.group(1) or m.group(2) or "", out) + out = _MD_CODE.sub(r"\1", out) + lines: list[str] = [] + for line in out.split("\n"): + if _MD_TABLE_SEP.match(line): + continue # `|---|---|` 分隔行,纯噪声 + stripped = line.strip() + if stripped.startswith("|") and stripped.endswith("|") and stripped.count("|") >= 2: + cells = [c.strip() for c in stripped.strip("|").split("|")] + line = " | ".join(c for c in cells if c) + lines.append(line) + out = "\n".join(lines) + out = _MD_HR.sub("", out) + out = _MD_HEADING.sub("", out) + out = _MD_QUOTE.sub("", out) + out = _MD_BULLET.sub(r"\1· ", out) + return re.sub(r"\n{3,}", "\n\n", out).strip() + + +#: 收益 + 数值同句的两种语序。**只匹配同一行**,避免误伤费率表。 +_YIELD_NUMBER_A = re.compile(r"(收益率|年化|回报率|涨幅|业绩)[^\n]{0,12}?\d+(?:\.\d+)?\s*%") +_YIELD_NUMBER_B = re.compile(r"\d+(?:\.\d+)?\s*%[^\n]{0,10}(收益率|年化|回报率|涨幅)") + + +def drop_yield_claims(text: str) -> str: + """丢掉**带具体数值的收益表述**所在行。 + + 为什么必须在展示层做:输入侧 `YIELD_TRAP_PATTERNS` 只拦得住"问句自带数字口径"的问法; + 而**知识块里本来就写着收益数字**(产品手册的业绩示例),`E3` 原文直返会把 + 「近一年收益率 7.60%」原样送到客户眼前 —— 实测 2026-09-20 问「我想找一个收益高一点的 + 产品」即命中。收益数值属"不得输出的承诺类内容",宁可这一行不给,也不能让客户看到 + 任何像是收益承诺的数字。费率表不受影响(它讲的是「费率」不是「收益率」)。 + """ + kept = [ + line for line in text.split("\n") + if not (_YIELD_NUMBER_A.search(line) or _YIELD_NUMBER_B.search(line)) + ] + return "\n".join(kept).strip() + + +#: 「文档名」段:品牌名开头或含这些词的一段,在候选标题里对客户没有辨识价值。 +_DOC_NAME_MARKERS = ("手册", "指南", "规范", "管理办法", "服务协议", "业务规则") +_DANGLING_NUMBER = re.compile(r"\d+[.、]?") + + +def prettify_title(title: str, limit: int = 34) -> str: + """把知识块标题整理成**客户能认出来的一项**(澄清候选用)。 + + 原来只做 `title[:40]` 硬切,于是候选里出现 + 「南方基金管理股份有限公司 公募基金与专户产品手册 · 一、公募基金产品 · 1.」—— + 以编号结尾的碎片。这里按「 · 」分段后:丢掉悬空编号段、丢掉文档名段、只留章节与小节。 + """ + text = render_plain(str(title or "")).strip() + parts = [p.strip(" ·") for p in text.split(" · ")] + parts = [p for p in parts if p and not _DANGLING_NUMBER.fullmatch(p)] + trimmed = [ + p for p in parts + if not p.startswith(COMPANY) and not any(k in p for k in _DOC_NAME_MARKERS) + ] + parts = trimmed or parts + if len(parts) > 2: + parts = parts[-2:] + merged = " · ".join(parts) if parts else text + return merged[:limit] + ("…" if len(merged) > limit else "") + + #: E5b 部分答 + 引导。**不置 `transfer_required`**(不建单):知识未命中、置信度不足、 #: 检索降级、画像查不到都属"这次没查到",不是"必须人工办的事"。原实现把这四类一律渲染成 #: 同一句兜底话术并建单,是转人工率偏高的主因。 PARTIAL_TEMPLATE = ( - "这个问题我暂时只能提供以下公开资料供您参考:\n\n{content}\n\n" - "如果需要更确切的答复,可以换个说法再问我一次(例如带上具体产品名称或条款名)," + "我先帮您把找到的公开资料放上来:\n\n{content}\n\n" + "如果这还不是您想问的那一条,把具体产品名或条款名告诉我,我接着帮您查;" f"也可以拨打客服热线 {HOTLINE}({SERVICE_HOURS})咨询。" ) #: E5b 在连"部分命中"都没有时的说法(仍不建单)。 PARTIAL_EMPTY_TEMPLATE = ( - "这个问题我没有找到对应的公开资料,也不想凭猜测回答您。" - "您可以换个说法再问我一次(例如带上具体产品名称、费率或条款名)," + "这一条我暂时没找到对应的公开资料,也不想凭猜测回答您。" + "换个说法再问我一次就行(带上具体产品名、费率或条款名都能帮我定位)," f"也可以拨打客服热线 {HOTLINE}({SERVICE_HOURS})咨询。" ) @@ -374,8 +483,8 @@ KNOWLEDGE_MISS_TEXTS = ( #: E5a 澄清:**一次只问一个问题**,候选必须来自当前主体可见档位——命中列表本身已按档位 #: 裁剪,因此这里天然满足"不得暗示不可见条目存在性"。 CLARIFY_TEMPLATE = ( - "您的意思我还不确定,方便确认一下您想了解的是哪一项吗?\n{candidates}\n" - "回复序号或直接补充说明都可以。" + "您想了解的是下面哪一项呢?\n{candidates}\n" + "回复序号就行,也可以直接补充说明。" ) #: 画像查不到时的说明(E5b,不建单):引导自助查看,不推给人工建单。 @@ -466,6 +575,92 @@ CALC_RULE_FOOTNOTE = ( CALC_RULE_NEXT_VISITOR = "如需按您本人的测评结果核对,请先登录客户账户。" CALC_RULE_NEXT_CUSTOMER = "如需核对您本人与某只具体产品的匹配情况,请告诉我产品名称或代码。" +# --------------------------------------------------------------------------- +# `E2c-my`:以**本人**等级为对象的可购买范围(`W20`) +# --------------------------------------------------------------------------- +# +# 这一出口要修的是一处**把该答的问题答成了拒答**的缺陷(2026-09-20 实测):客户 +# `cust_t`(C1)问「我现在可以买什么等级的产品」,拿到的是 `ADVICE_BOUNDARY_REPLY` +# 「不能为您推荐具体产品…」。而这与 `suitability_service` 里 2026-09-11 的裁定 +# 「**客服回答按矩阵**」直接冲突 —— 该注释的原文举例正是「问『C1 能买什么产品』 +# 答『R1、R2 可买』」。 +# +# 界线(两个概念必须分清): +# · **陈述可买范围** = 公开的 C—R 匹配矩阵(`PROD-012` §4.2 为 `public` 档)叠加本人 +# 权威等级 ⇒ 该答。它由**等级**决定,与客户想买哪只无关; +# · **引导购买** = 「建议您买这只」「这只更适合您」「那只收益最高」「抓紧申购」⇒ 红线。 +# 它由**指向性结论**构成。 +# 因此本出口的产物是「按你的等级,这些产品在范围内(代码 / 名称 / 类别 / 风险等级)」, +# 而不是「我建议你买哪些」。出口后置校验 `promotional_wording_violation` 是这条界线的 +# **确定性**保证:不靠提示词,靠机器判据。 +# +#: 与画像出口同理:白名单 key 必须落在**已发布**的 `agent_tools` 上。 +#: `suitability_check` 是本出口的语义归属(它答的就是适当性匹配范围)。 +ELIGIBLE_WHITELIST_INTENT = INTENT_SUITABILITY + +#: 访客问同一句:给**公开规则表**(`DEC-I8` 口径:通用规则对访客 open), +#: 并说明"按本人等级列清单"需要登录 —— 那一步要读本人测评结果。 +ELIGIBLE_VISITOR_TEMPLATE = ( + "按《个人投资者适当性管理指南》第十二条的投资者与产品匹配矩阵," + "可购买的产品等级范围是:C1 保守型 R1—R2;C2 稳健型 R1—R3;" + "C3 平衡型 R1—R4;C4 成长型与 C5 进取型 R1—R5。\n" + "要按您本人的测评等级列出在售产品清单,需要先登录 —— 等级只能取自您留存的、" + "在有效期内的风险测评结果。" +) + +#: 取不到权威等级 → `E5b` 如实告知(**不猜范围、不建单**)。 +ELIGIBLE_MISS_TEMPLATE = ( + "要按您本人的测评等级列出可购买的产品清单,需要先取到您留存的、在有效期内的" + "风险测评结果,但这次没有取到,所以不能给您一份清单(列错范围比不列更糟)。" + "您可以登录后在「我的账户」核对测评状态," + f"或拨打客服热线 {HOTLINE}({SERVICE_HOURS})由人工协助。" +) + +#: 测评过期:单独一条,把"为什么列不出来"讲清楚(与画像出口同一口径)。 +ELIGIBLE_EXPIRED_TEMPLATE = ( + "您的风险测评已过有效期,需要重新完成测评后才能按等级匹配可购买的产品范围。" + "请登录后在「我的账户」重新完成风险测评;" + f"如需人工协助,可拨打客服热线 {HOTLINE}({SERVICE_HOURS})。" +) + +#: 结尾声明。**"不构成推荐"是紧邻否定式**,出口护栏对此豁免(见 +#: `customer_service_rules._locally_negated`)—— 也就是说这句话不会被自己拦掉。 +ELIGIBLE_FOOTNOTE = ( + "本清单由系统按您留存的、在有效期内的风险测评等级自动筛选,仅列产品代码、名称、" + "类别与风险等级,不含净值、收益与费率,不构成推荐。" +) + + +def _level_numbers(levels: Sequence[str]) -> set[int]: + """把 `"R1"` 这类档位标签还原成数字集合(解析失败的一律忽略,不猜)。""" + numbers: set[int] = set() + for item in levels: + token = str(item).strip().upper() + if token.startswith("R") and token[1:].isdigit(): + numbers.add(int(token[1:])) + return numbers + + +def _asked_level_verdict( + asked: int | None, allowed: Sequence[str], disclosure: Sequence[str] +) -> str: + """问句里**点名了某一档**时的那一句直接裁决(「我可以买 R3 的产品吗」)。 + + 措辞与 `_suitability_rule_text`(`E2c`)**同源** —— 两条出口给同一件事的答案不能不一样。 + 裁决只由矩阵结果反推(`allowed` / `disclosure` 都是服务端 `MATRIX_ALLOWED` / + `MATRIX_NEEDS_DISCLOSURE` 的投影),不含任何个性化推断,也不含指向性表述。 + """ + if asked is None: + return "" + name = RISK_LEVEL_NAMES.get(asked, f"R{asked}") + if asked in _level_numbers(allowed): + conclusion = "可以购买" + elif asked in _level_numbers(disclosure): + conclusion = "需签署产品风险揭示书后可以购买" + else: + conclusion = "不可以购买(超出您当前等级可购买的范围)" + return f"您问的 {name} 等级产品:{conclusion}。" + #: 闲聊出口在模型不可用时的回退(E5b,不建单):礼貌收尾并把话题引回业务。 CHITCHAT_FALLBACK_TEMPLATE = ( "抱歉,我这边刚刚没能正常回应。" @@ -597,7 +792,10 @@ class CustomerServiceAgent(BaseAgent): allowed_roles=("visitor", "customer"), allowed_portals=("api",), # 代码上限:实际可用范围由发布配置的意图白名单收窄(两者取交集) - allowed_tools=(TOOL_NAME, VISITOR_TOOL_NAME, SUITABILITY_TOOL, PROFILE_TOOL_NAME), + allowed_tools=( + TOOL_NAME, VISITOR_TOOL_NAME, SUITABILITY_TOOL, PROFILE_TOOL_NAME, + ELIGIBLE_TOOL_NAME, + ), supported_intents=( INTENT_FAQ, INTENT_PRODUCT, INTENT_POLICY, INTENT_SUITABILITY, INTENT_CHITCHAT, INTENT_TRANSFER, @@ -705,6 +903,13 @@ class CustomerServiceAgent(BaseAgent): calculation = await self._answer_calculation(request, context) if calculation is not None: return calculation + # `E2c-my`:以**本人等级**为对象的可购买范围。与 `E2c` 的分工是「问句里有没有 + # 自带参数」—— `E2c` 收「C1 能买 R3 吗」这类自带 C/R 的规则题;本出口收 + # 「我能买什么等级的产品」这类**只问范围**的问法,等级从本人测评里取。 + # 访客同样能走到这里(给公开规则表),所以**放在访客意图白名单之前**。 + eligibility = await self._answer_eligibility(request, context) + if eligibility is not None: + return eligibility intent = self._intent_code() if is_visitor(context) and intent not in VISITOR_INTENTS: if intent == INTENT_SUITABILITY: @@ -873,8 +1078,14 @@ class CustomerServiceAgent(BaseAgent): # 与消息表留痕,只是不面向客户展示。若将来要把出处给客户看,应当走 # source_references 的 knowledge 类型(需先让 ToolExecutor 登记本次可引用的 doc_id), # 而不是继续往正文里拼字符串。 + # 展示层净化后**可能整块消失**:实测语料里有 15 个切片整个块只写了一个收益数字 + # (如「近三年收益率 11.85%(虚构)」),`drop_yield_claims` 按"收益数值不得输出" + # 把整块清空。这时**不能回一个空气泡**(前端就是一条空白消息),交回 `E5b`。 + rendered = render_plain(drop_yield_claims(content)) + if not rendered: + return self._exit_partial(hits, note="命中内容全部为收益数值,按禁止输出处理") return CoreResult( - text=self._clamp_answer(content), + text=self._clamp_answer(rendered), topic=self._declared_topic(content), intent=self._classified_intent, ) @@ -1157,6 +1368,116 @@ class CustomerServiceAgent(BaseAgent): return cell return None + async def _answer_eligibility( + self, request: AgentRequest, context: RequestContext + ) -> CoreResult | None: + """`E2c-my`:按**本人**权威等级给出可购买范围与在售产品清单。 + + 返回 `None` 表示「这不是本人可购范围问题」,交回主分发。 + + 触发用**确定性词法**(`is_own_eligibility_question`)而不是意图分类器:本出口会 + 给出产品清单,触发条件必须可复核、可回归;而且它必须与 `route_message()` 里 + 推介门禁的**同一套豁免判据**同源,否则会出现"门禁放行了、出口却不接"的空档。 + + 清单本身**不是**投资建议:它由「公开的 C—R 匹配矩阵」× 「本人权威等级」决定, + 不含任何指向性结论 —— 客户等级不同,清单不同,与服务端 `MATRIX_ALLOWED` 逐格一致。 + """ + if not is_own_eligibility_question(request.message): + return None + if is_visitor(context): + # 通用规则对访客 open(`DEC-I8`);"按本人等级列清单"要读测评结果,必须登录。 + return CoreResult(text=ELIGIBLE_VISITOR_TEMPLATE, intent=self._classified_intent) + try: + output = await self.call_tool( + ELIGIBLE_TOOL_NAME, + {"customer_id": str(context.user_id)}, + intent=ELIGIBLE_WHITELIST_INTENT, + context=context, + ) + except ForbiddenAgentError: + # 与知识出口、画像出口同口径:权限/白名单失败必须冒泡 —— 那是配置错误, + # 用兜底话术吞掉会让"工具没被授权"表现成"查不到您的等级"。 + raise + except Exception: + logger.info("E2c-my 可买清单调用失败", exc_info=True) + return self._exit_eligible_miss("可买清单调用失败") + return self._render_eligible(output, request.message) + + def _render_eligible(self, output: Any, message: str = "") -> CoreResult: + """把工具返回渲染成答复;取不到权威等级一律 `E5b`。""" + if not isinstance(output, dict): + return self._exit_eligible_miss("可买清单返回格式异常") + reason = str(output.get("authority_reason") or "") + level = output.get("customer_risk_level") + if reason == "ASSESSMENT_EXPIRED": + return CoreResult(text=ELIGIBLE_EXPIRED_TEMPLATE, intent=self._classified_intent) + if reason != "AUTHORITY_OK" or not isinstance(level, int): + return self._exit_eligible_miss(f"权威等级不可用:{reason or '未知'}") + # 问句**点名了某一档**(「我可以买 R3 的产品吗」)时先给一句直接裁决:客户问的是 + # "能不能买 R3",只回一份"您可买 R1、R2"的清单,那个"不"字始终没说出来 —— + # 实测 2026-09-20 该问法给出的是一份清单,客户读不出"我不能买 R3"。 + # 判据复用 `parse_product_level`(与 `E2c` 同一套解析),裁决由矩阵结果反推。 + asked_level = parse_product_level(message) + text = self._eligible_text(level, output, asked_level=asked_level) + hit = promotional_wording_violation(text) + if hit is not None: + # 理论上不会发生(文案是固定模板)。留作**回归哨兵**:一旦有人往模板里加了 + # 「推荐 / 建议购买 / 更适合您 / 收益最高」这类引导语,这里立刻降级为只讲范围, + # 客户至少不会拿到一段引导话术。 + logger.warning("E2c-my 文案命中引导词,降级为仅范围答复:%r", hit) + text = self._eligible_text(level, output, with_products=False) + return CoreResult(text=text, intent=self._classified_intent) + + @staticmethod + def _eligible_text( + level: int, + output: dict[str, Any], + *, + with_products: bool = True, + asked_level: int | None = None, + ) -> str: + """把可买清单说成**陈述句**。 + + 措辞纪律(这就是「陈述」与「引导」的分界):只讲"在范围内"与字段事实,不出现 + 推荐 / 建议购买 / 更适合您 / 收益最高 / 抓紧申购这类指向性表述;产品按**代码升序** + (服务端已排好),不按收益或热度重排 —— 排序就是隐性推荐。 + """ + label = RISK_LEVEL_LABELS.get(f"C{level}", f"C{level}") + allowed = [str(item) for item in (output.get("allowed_levels") or ())] + disclosure = [str(item) for item in (output.get("disclosure_levels") or ())] + products = [item for item in (output.get("products") or ()) if isinstance(item, dict)] + excluded = output.get("excluded_count") + scope = "、".join(allowed) if allowed else "(该等级暂无可购买的产品等级)" + lines: list[str] = [] + verdict = _asked_level_verdict(asked_level, allowed, disclosure) + if verdict: + lines.append(verdict) + lines.append( + "按《个人投资者适当性管理指南》第十二条的投资者与产品匹配矩阵," + f"您留存的、在有效期内的风险测评等级是 {label},可购买 {scope} 等级的产品。" + ) + if disclosure: + lines.append("其中 " + "、".join(disclosure) + " 等级需签署产品风险揭示书后才可购买。") + if with_products and products: + lines.append( + f"符合该范围的在售产品共 {len(products)} 只" + "(按产品代码升序,仅列代码 / 名称 / 类别 / 风险等级):" + ) + lines.extend( + f"- {item.get('product_code')} {item.get('product_name')}" + f"({item.get('product_category')} · {item.get('risk_level')})" + for item in products + ) + if with_products and isinstance(excluded, int) and excluded > 0: + lines.append(f"另有 {excluded} 只在售产品超出该范围,未列入。") + lines.append(ELIGIBLE_FOOTNOTE) + return "\n".join(lines) + + def _exit_eligible_miss(self, note: str) -> CoreResult: + """`E2c-my` 取不到权威等级 → `E5b` 如实告知(**不猜范围、不建单**)。""" + logger.info("E5b 可买清单无权威等级:%s", note) + return CoreResult(text=ELIGIBLE_MISS_TEMPLATE, intent=self._classified_intent) + async def _answer_suitability_rule( self, message: str, context: RequestContext ) -> CoreResult: @@ -1544,8 +1865,14 @@ class CustomerServiceAgent(BaseAgent): logger.info("E4 未过合规校验,拦回 E5b") return self._exit_partial(evidence, note="证据约束生成未过合规校验") logger.info("E4 证据约束生成命中:块数=%s 引用=%s", len(evidence), used) + # 生成文本同样要过**展示层净化**:模型看到的证据包是 markdown 源文,它会把 + # `**一、购买渠道**` 这类标记原样抄进答复(实测 2026-09-20「怎么买基金」)。 + # 校验一律跑在**净化前**的原文上(更严),净化只作用于交付给客户的那一份。 + rendered = render_plain(drop_yield_claims(answer)) + if not rendered: + return self._exit_partial(evidence, note="证据约束生成内容全部为收益数值") return CoreResult( - text=self._clamp_answer(answer), intent=self._classified_intent + text=self._clamp_answer(rendered), intent=self._classified_intent ) @classmethod @@ -1986,17 +2313,26 @@ class CustomerServiceAgent(BaseAgent): # 「其他费用:认购费 认购时一次性收取」,而真正贴题的 `PROD-018` # (6.3 费用计算示例,全局 top1)排在包尾,问「举个例子说明费率怎么查」 # 却拿到一段答非所问的碎片。降级时客户手里只剩这一块,更要给最好的那块。 + # 挑块时**先净化再看有没有内容**:整块只有一个收益数字的切片会被清空,把它当 + # "最佳证据"展示等于给客户一个空气泡;跳过它,下一个有真实内容的块才是最好的那块。 best: dict[str, Any] | None = None best_score = -1.0 + best_text = "" for hit in hits: if not isinstance(hit, dict): continue score = self._score(hit.get("score")) - if score > best_score: - best, best_score = hit, score + if score <= best_score: + continue + candidate = render_plain( + drop_yield_claims(str(hit.get("content") or "").strip()) + ) + if not candidate: + continue + best, best_score, best_text = hit, score, candidate content = "" if best is not None and best_score >= PARTIAL_FLOOR: - content = self._clamp_answer(str(best.get("content") or "").strip()) + content = self._clamp_answer(best_text) return CoreResult( text=PARTIAL_TEMPLATE.format(content=content) if content else PARTIAL_EMPTY_TEMPLATE, intent=self._classified_intent, @@ -2080,7 +2416,7 @@ class CustomerServiceAgent(BaseAgent): continue if self._score(hit.get("score")) < CLARIFY_SCORE: break - title = str(hit.get("title") or "").strip()[:40] + title = prettify_title(hit.get("title")) if title and title not in candidates: candidates.append(title) if not candidates: diff --git a/app/service/suitability_service.py b/app/service/suitability_service.py index 117828e..d173084 100644 --- a/app/service/suitability_service.py +++ b/app/service/suitability_service.py @@ -298,6 +298,174 @@ class SuitabilityService: ) + + +# --------------------------------------------------------------------------- +# `E2c-my`:按客户**权威**等级给出可购买产品清单(只读) +# --------------------------------------------------------------------------- + +#: 产品上架状态字面。与 `trade_service` / 门户产品页取的是同一口径(`fin_product.status`)。 +PRODUCT_STATUS_LISTED = "上市" + + +class EligibleProductQuery(BaseModel): + """工具入参:只能声明「查谁的」—— 等级、清单与排序一律由服务端决定。""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + customer_id: str = Field(min_length=1, max_length=20, pattern=r"^[0-9]+$") + + +class EligibleProduct(BaseModel): + """清单里的一只产品。 + + **字段刻意只有四个**:代码 / 名称 / 类别 / 风险等级。净值、收益率、涨跌幅、费率 + 一律不进这个模型 —— 一旦进了,这份清单就从「按等级筛选的公开产品表」变成 + 「推介材料」,而客服出口只能陈述、不能引导。 + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + product_code: str + product_name: str + product_category: str + risk_level: str + + +class EligibleProductView(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + customer_id: str + customer_risk_level: int | None = None + risk_level_source: str = RISK_LEVEL_SOURCE + authority_reason: AuthorityReason = "AUTHORITY_OK" + assessment_valid_until: datetime | None = None + allowed_levels: tuple[str, ...] = () + disclosure_levels: tuple[str, ...] = () + products: tuple[EligibleProduct, ...] = () + excluded_count: int = 0 + + +_ELIGIBLE_SQL = text( + """ + SELECT product_code, product_name, product_category, risk_level + FROM fin_product + WHERE status = :status + ORDER BY product_code + """ +) + + +class EligibleProductsService: + """只读:按客户**权威**等级给出「可购买产品」清单。 + + 四条硬约束(缺任何一条都失败关闭,返回空清单而不是猜一个范围): + + 1. 客户等级只认 ``fin_risk_assessment``(与 ``check_suitability`` **同一个**权威口径), + **不采信调用方**,也不采信客户在问句里自报的等级; + 2. 清单只取 ``fin_product`` 中 ``status='上市'`` 的产品,字段只有代码 / 名称 / 类别 / + 风险等级 —— **不含净值、收益率、涨跌幅、费率**; + 3. 顺序固定**按产品代码升序**,不按收益 / 规模 / 热度排 —— 排序就是隐性推荐; + 4. 匹配范围取 ``MATRIX_ALLOWED``(第十二条矩阵),需签风险揭示书的档位单独标出 + (``MATRIX_NEEDS_DISCLOSURE``),把「能买」与「需签字后才能买」讲清楚。 + """ + + def __init__(self, *, session_factory: Callable[[], Any] | None = None) -> None: + self._session_factory: Callable[[], Any] = session_factory or SessionFactory + + async def load( + self, request: EligibleProductQuery, context: RequestContext + ) -> EligibleProductView: + SuitabilityService._assert_customer_scope(request.customer_id, context) + authority = await self._authority(request.customer_id) + level = authority.customer_risk_level + if authority.authority_reason != "AUTHORITY_OK" or level is None: + # 失败关闭:等级拿不到就**不列清单**,也不退化成「全都列一遍」。 + await self._audit(request, context, authority, ()) + return EligibleProductView( + customer_id=request.customer_id, + authority_reason=authority.authority_reason, + assessment_valid_until=authority.valid_until, + ) + allowed = MATRIX_ALLOWED.get(level, frozenset()) + disclosure = MATRIX_NEEDS_DISCLOSURE.get(level, frozenset()) + listed = await self._listed_products() + picked = tuple(item for item in listed if _level_number(item.risk_level) in allowed) + excluded = len(listed) - len(picked) + await self._audit(request, context, authority, picked) + return EligibleProductView( + customer_id=request.customer_id, + customer_risk_level=level, + authority_reason=authority.authority_reason, + assessment_valid_until=authority.valid_until, + allowed_levels=tuple(f"R{n}" for n in sorted(allowed)), + disclosure_levels=tuple(f"R{n}" for n in sorted(disclosure)), + products=picked, + excluded_count=excluded, + ) + + async def _authority(self, customer_id: str) -> RiskAuthorityProfile: + return await SuitabilityService( + session_factory=self._session_factory + ).authority_for_customer(int(customer_id)) + + async def _listed_products(self) -> tuple[EligibleProduct, ...]: + async with self._session_factory() as session: + result = await session.execute( + _ELIGIBLE_SQL, {"status": PRODUCT_STATUS_LISTED} + ) + rows = result.mappings().all() + return tuple( + EligibleProduct( + product_code=str(row["product_code"]), + product_name=str(row["product_name"]), + product_category=str(row["product_category"]), + risk_level=str(row["risk_level"]), + ) + for row in rows + ) + + async def _audit( + self, + request: EligibleProductQuery, + context: RequestContext, + authority: RiskAuthorityProfile, + picked: tuple[EligibleProduct, ...], + ) -> None: + """留痕:谁在什么时候按哪个等级取走了一份可买清单(只记摘要,不记产品明细)。""" + async with self._session_factory() as session, session.begin(): + actor_id = int(context.user_id) if context.user_id.isdecimal() else None + session.add(InteractionAudit( + actor_type="agent", + actor_id=actor_id, + portal=context.portal, + action_type="suitability.eligible_products", + detail={ + "trace_id": context.trace_id, + "customer_id": request.customer_id, + "customer_risk_level": authority.customer_risk_level, + "risk_level_source": RISK_LEVEL_SOURCE, + "authority_reason": authority.authority_reason, + "listed_count": len(picked), + }, + created_at=datetime.now(UTC).replace(tzinfo=None), + )) + + +def _level_number(label: str) -> int: + """``"R3"`` → ``3``;无法解析时返回 ``0``(任何 ``allowed`` 集合都不含 ``0`` ⇒ 不入选)。""" + matched = re.fullmatch(r"R([1-5])", str(label).strip()) + return int(matched.group(1)) if matched else 0 + + +async def query_eligible_products_tool( + arguments: EligibleProductQuery, context: RequestContext +) -> dict[str, Any]: + """ToolExecutor 入口:返回 JSON 可序列化的可买清单视图。""" + view = await EligibleProductsService().load(arguments, context) + return view.model_dump(mode="json") + + async def suitability_tool_handler( arguments: SuitabilityToolInput, context: RequestContext ) -> dict[str, Any]: diff --git a/tests/unit/core/test_customer_service_rules.py b/tests/unit/core/test_customer_service_rules.py index d87b3ce..41e8be7 100644 --- a/tests/unit/core/test_customer_service_rules.py +++ b/tests/unit/core/test_customer_service_rules.py @@ -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 diff --git a/tests/unit/service/test_customer_service_agent.py b/tests/unit/service/test_customer_service_agent.py index 288ba9d..4bf92e5 100644 --- a/tests/unit/service/test_customer_service_agent.py +++ b/tests/unit/service/test_customer_service_agent.py @@ -911,7 +911,10 @@ async def test_evidence_exit_reports_when_it_cannot_answer(monkeypatch) -> None: result = await agent._answer_from_knowledge( build_request("高净值客户有什么权益?"), CUSTOMER, customer_service_module.INTENT_PRODUCT ) - assert result.text == EVIDENCE_HITS[0]["content"] + # `W20` 展示层净化后:`E3` 直返仍然以知识块原文为准,只是去掉 markdown 标记。 + expected = customer_service_module.render_plain(EVIDENCE_HITS[0]["content"]) + assert result.text == expected + assert "###" not in result.text assert result.transfer_required is False @@ -922,7 +925,10 @@ async def test_evidence_exit_steps_aside_when_the_model_is_unavailable() -> None result = await agent._answer_from_knowledge( build_request("高净值客户有什么权益?"), CUSTOMER, customer_service_module.INTENT_PRODUCT ) - assert result.text == EVIDENCE_HITS[0]["content"] + # `W20` 展示层净化后:`E3` 直返仍然以知识块原文为准,只是去掉 markdown 标记。 + expected = customer_service_module.render_plain(EVIDENCE_HITS[0]["content"]) + assert result.text == expected + assert "###" not in result.text assert result.transfer_required is False @@ -1525,7 +1531,9 @@ def test_partial_exit_shows_the_best_block_not_the_first_one() -> None: ], note="复现 B-06", ) - assert "### 6.3 费用计算示例" in result.text + # 标记被去掉、字留下(`W20` `render_plain`):客户看到的是「6.3 费用计算示例」 + assert "6.3 费用计算示例" in result.text + assert "###" not in result.text assert "认购费 认购时一次性收取" not in result.text assert result.transfer_required is False @@ -1533,8 +1541,8 @@ def test_partial_exit_shows_the_best_block_not_the_first_one() -> None: def test_partial_exit_ignores_non_mapping_hits() -> None: """命中列表里混进非映射项时不得炸;全部低于 `PARTIAL_FLOOR` 时只说"没找到"。""" agent = build_agent() - assert "没有找到" in agent._exit_partial([None, "oops"]).text - assert "没有找到" in agent._exit_partial([{"score": 0.1, "content": "噪声"}]).text + assert "没找到" in agent._exit_partial([None, "oops"]).text + assert "没找到" in agent._exit_partial([{"score": 0.1, "content": "噪声"}]).text # --------------------------------------------------------------------------- @@ -1698,3 +1706,285 @@ def test_suitability_never_claims_customer_confirmation() -> None: ) for phrase in ("您已确认", "已为您确认", "视为您已同意", "我们已确认"): assert phrase not in text + +# ---- `W20`:`E2c-my`(按**本人**权威等级列可购买产品) ---- + + +def _eligible_view(*, reason: str = "AUTHORITY_OK", level: int | None = 1) -> dict: + """`query_eligible_products` 的返回形状(与 `EligibleProductView` 字段一致)。""" + return { + "customer_id": "9001", + "customer_risk_level": level, + "risk_score": None, + "risk_level_source": "fin_risk_assessment", + "authority_reason": reason, + "assessment_valid_until": "2027-01-01T00:00:00Z", + "allowed_levels": ["R1", "R2"], + "disclosure_levels": [], + "products": [ + {"product_code": "159700", "product_name": "科创债ETF南方", + "product_category": "ETF", "risk_level": "R2"}, + {"product_code": "511810", "product_name": "货币ETF南方", + "product_category": "ETF", "risk_level": "R1"}, + ], + "excluded_count": 16, + } + + +def stub_eligible_tool(payload: dict) -> object: + """替换 `call_tool`:只验证出口决策,不接工具执行器。""" + async def _call(name: str, arguments: dict, *, intent: str, context: RequestContext): + assert name == customer_service_module.ELIGIBLE_TOOL_NAME + assert intent == customer_service_module.ELIGIBLE_WHITELIST_INTENT + del arguments, context + return payload + return _call + + +async def test_route_and_answer_lists_products_within_the_customers_own_level() -> None: + """接线验证:问本人等级的匹配范围必须**列出产品**,而不是答成「不能推荐」。""" + agent = build_agent() + agent.call_tool = stub_eligible_tool(_eligible_view()) # type: ignore[method-assign] + result = await agent._route_and_answer( + build_request("我现在可以买什么等级的产品"), CUSTOMER + ) + assert ADVICE_BOUNDARY_REPLY not in result.text + assert "保守型(C1)" in result.text + assert "可购买 R1、R2 等级的产品" in result.text + assert "159700 科创债ETF南方" in result.text + assert "另有 16 只在售产品超出该范围,未列入" in result.text + # 陈述而非引导:清单里不得出现指向性措辞。 + assert customer_service_module.promotional_wording_violation(result.text) is None + assert result.transfer_required is False + + +async def test_eligible_exit_fails_closed_without_an_authoritative_level() -> None: + """取不到权威等级 ⇒ `E5b` 如实告知,**不猜范围、不建单、不列清单**。""" + agent = build_agent() + agent.call_tool = stub_eligible_tool( # type: ignore[method-assign] + {**_eligible_view(reason="ASSESSMENT_MISSING", level=None), + "allowed_levels": [], "products": [], "excluded_count": 0} + ) + result = await agent._route_and_answer(build_request("我能买什么产品"), CUSTOMER) + assert result.transfer_required is False + assert "159700" not in result.text + assert "400-889-8899" in result.text + + +async def test_eligible_exit_tells_the_customer_when_the_assessment_expired() -> None: + agent = build_agent() + agent.call_tool = stub_eligible_tool( # type: ignore[method-assign] + {**_eligible_view(reason="ASSESSMENT_EXPIRED", level=1), + "allowed_levels": [], "products": [], "excluded_count": 0} + ) + result = await agent._route_and_answer(build_request("我能买什么产品"), CUSTOMER) + assert "风险测评已过有效期" in result.text + assert result.transfer_required is False + + +async def test_visitor_gets_the_public_matrix_instead_of_a_product_list() -> None: + """访客问同一句:给**公开规则表**(`DEC-I8`),不读任何画像数据。""" + agent = build_agent() + + async def _forbidden(*args: object, **kwargs: object) -> dict: + raise AssertionError("访客侧不得调用可买清单工具") + + agent.call_tool = _forbidden # type: ignore[method-assign] + result = await agent._route_and_answer(build_request("我能买什么等级的产品"), VISITOR) + assert "C1 保守型 R1—R2" in result.text + assert "需要先登录" in result.text + assert result.transfer_required is False + + +async def test_eligible_exit_degrades_to_scope_only_if_the_template_ever_turns_promotional() -> None: + """回归哨兵:模板若被人加了引导语,出口必须降级为**只讲范围**、不列清单。""" + agent = build_agent() + # 取 `__dict__` 里的 **staticmethod 对象**再还原:读 `Class.attr` 拿到的是"解绑后的 + # 函数",把它赋回类属性会静默变成**普通方法**(此后每个测试都少传一个 self 而报 + # `TypeError`,且只在后续测试里炸,极难定位)。实测 2026-09-20 踩到。 + original_descriptor = customer_service_module.CustomerServiceAgent.__dict__["_eligible_text"] + original = customer_service_module.CustomerServiceAgent._eligible_text + + def _promotional( + level: int, output: dict, *, with_products: bool = True, + asked_level: int | None = None, + ) -> str: + text = original( + level, output, with_products=with_products, asked_level=asked_level + ) + return text + "\n建议您购买第一只。" if with_products else text + + customer_service_module.CustomerServiceAgent._eligible_text = staticmethod(_promotional) + try: + agent.call_tool = stub_eligible_tool(_eligible_view()) # type: ignore[method-assign] + result = await agent._route_and_answer(build_request("我能买什么产品"), CUSTOMER) + finally: + customer_service_module.CustomerServiceAgent._eligible_text = original_descriptor + assert "建议您购买第一只" not in result.text + assert "159700 科创债ETF南方" not in result.text + assert "可购买 R1、R2 等级的产品" in result.text + +# --------------------------------------------------------------------------- +# `W20` 展示层净化:`render_plain` / `drop_yield_claims` / `prettify_title` +# +# 为什么单独立一组:这三件事不改答案内容,只改"客户看到的样子"。它们是**低风险高收益** +# 的改动,但也最容易在后续重构里被无声改坏 —— 一旦 `render_plain` 开始吞字,判分会从 +# "答对"变成"没答"(金标 `key_facts` 是纯词语子串匹配);一旦 `drop_yield_claims` 误伤 +# 费率表,`B-02` 的「申购」「赎回」就没了。这里把这两个方向都钉死。 +# --------------------------------------------------------------------------- + + +def test_render_plain_strips_markdown_but_keeps_the_words() -> None: + src = ( + "### 第二条 适用范围\n\n" + "本指南适用于下列销售活动:\n" + "- 公募基金\n" + "* 银行理财产品\n" + "> 注:含货币基金\n" + "**加粗**与`代码`\n" + "---\n" + "| 费用类型 | 货币基金 |\n" + "|----------|----------|\n" + "| 申购费率 | 0 |\n" + ) + out = customer_service_module.render_plain(src) + for noise in ("###", "**", "`", "---", "|----------|"): + assert noise not in out + assert "第二条 适用范围" in out + assert "· 公募基金" in out + assert "· 银行理财产品" in out + assert "注:含货币基金" in out + assert "加粗" in out and "代码" in out + assert "费用类型 | 货币基金" in out + assert "申购费率 | 0" in out + + +def test_render_plain_keeps_the_b02_fee_keywords() -> None: + """回归钉子:金标 `B-02` 的 `key_facts` 是「申购」「赎回」两个纯词。""" + seed = CORPUS / "product" / "个人理财产品手册.md" + if not seed.exists(): + pytest.skip("知识语料不在位") + out = customer_service_module.render_plain(seed.read_text(encoding="utf-8")) + assert "申购费率(原费率) | 0 | 0.80%" in out + assert "赎回费率(<7 天)" in out + + +def test_drop_yield_claims_removes_yield_numbers_but_keeps_the_fee_table() -> None: + src = ( + "近一年收益率 7.60%\n" + "自成立以来年化 5.2%\n" + "| 申购费率(原费率) | 0 | 0.80% |\n" + "本产品历史业绩不预示未来表现\n" + ) + out = customer_service_module.drop_yield_claims(src) + assert "7.60%" not in out + assert "5.2%" not in out + assert "申购费率(原费率) | 0 | 0.80%" in out + assert "本产品历史业绩不预示未来表现" in out + + +def test_prettify_title_drops_the_doc_name_segment_and_dangling_number() -> None: + long_title = "南方基金管理股份有限公司 公募基金与专户产品手册 · 一、公募基金产品 · 1." + assert customer_service_module.prettify_title(long_title) == "一、公募基金产品" + + +def test_prettify_title_falls_back_to_chapter_when_nothing_else_is_left() -> None: + out = customer_service_module.prettify_title("南方基金服务协议 · 第三章 费用与税收") + assert "第三章 费用与税收" in out + assert len(customer_service_module.prettify_title("标" * 80)) <= 35 + + +# --------------------------------------------------------------------------- +# `E2c-my` 点名档位时的**直接裁决**(`W20`) +# +# 实测缺陷:客户问「我可以买 R3 的产品吗」(第一人称 + 点名档位,问句里没有 C 等级、 +# 于是 `E2c` 的 `is_general_suitability_question` 不触发),出口只回了一份 +# 「您可购买 R1、R2」的清单 —— **那个"不"字始终没说出来**。金标 `D-04` 要的正是 +# 「不能 / 不可以」。这里把"裁决 + 范围"的顺序钉死。 +# --------------------------------------------------------------------------- + + +async def test_named_product_level_gets_a_direct_verdict_before_the_list() -> None: + agent = build_agent() + agent.call_tool = stub_eligible_tool(_eligible_view()) # type: ignore[method-assign] + result = await agent._route_and_answer(build_request("我可以买 R3 的产品吗"), CUSTOMER) + assert "不可以购买" in result.text + assert "R3(中风险)" in result.text + # 裁决在前、范围在后:客户先拿到答案,再拿到依据。 + assert result.text.index("不可以购买") < result.text.index("可购买 R1、R2 等级的产品") + assert customer_service_module.promotional_wording_violation(result.text) is None + assert result.transfer_required is False + + +async def test_named_product_level_inside_the_scope_is_answered_yes() -> None: + agent = build_agent() + agent.call_tool = stub_eligible_tool(_eligible_view()) # type: ignore[method-assign] + result = await agent._route_and_answer(build_request("我能买 R2 的产品吗"), CUSTOMER) + assert "可以购买" in result.text + assert "不可以购买" not in result.text + + +async def test_named_disclosure_level_says_the_disclosure_requirement() -> None: + view = _eligible_view() + view["allowed_levels"] = ["R1", "R2"] + view["disclosure_levels"] = ["R3"] + agent = build_agent() + agent.call_tool = stub_eligible_tool(view) # type: ignore[method-assign] + result = await agent._route_and_answer(build_request("我可以买 R3 的产品吗"), CUSTOMER) + assert "需签署产品风险揭示书后可以购买" in result.text + + +async def test_scope_only_question_has_no_verdict_line() -> None: + """没点名档位时**不得**多出一句裁决(问的是范围,答的就是范围)。""" + agent = build_agent() + agent.call_tool = stub_eligible_tool(_eligible_view()) # type: ignore[method-assign] + result = await agent._route_and_answer(build_request("我想买点理财产品"), CUSTOMER) + assert "您问的 R" not in result.text + assert "可购买 R1、R2 等级的产品" in result.text + + +# --------------------------------------------------------------------------- +# 展示层净化的**空结果护栏**(`W20`) +# +# `drop_yield_claims` 的判据是"整行含收益数值就不输出",而语料里有 15 个切片**整个块 +# 只写了一个收益数字**(如「近三年收益率 11.85%(虚构)」)。净化后内容为空时若照常 +# 返回,客户拿到的是一条**空气泡** —— 比答错更糟。这里把"清空即回退"钉死。 +# --------------------------------------------------------------------------- + +YIELD_ONLY_HIT = { + "doc_id": "PROD-001-08", "score": 0.91, + "title": "南方基金管理股份有限公司 公募基金与专户产品手册" + " · 一、公募基金产品 · 1.1 南方现金添利货币市场基金〔示例〕", + "content": "南方现金添利货币市场基金〔示例〕:七日年化收益率 约 1.92%(近 30 日均值,虚构)", +} + + +async def test_a_yield_only_chunk_never_becomes_an_empty_bubble() -> None: + agent = build_agent() + agent.call_tool = stub_knowledge_tool([YIELD_ONLY_HIT]) # type: ignore[method-assign] + result = await agent._answer_from_knowledge( + build_request("南方现金添利这只产品怎么样"), CUSTOMER, customer_service_module.INTENT_PRODUCT + ) + assert result.text.strip() + assert "1.92%" not in result.text + assert result.transfer_required is False + + +def test_partial_exit_skips_a_chunk_that_the_sanitizer_emptied() -> None: + """高分块被净化清空时,展示**下一个有内容的块**,而不是给一个空气泡。""" + result = build_agent()._exit_partial( + [ + {"score": 0.80, "doc_id": "PROD-001-08", + "content": "南方现金添利货币市场基金〔示例〕:七日年化收益率 约 1.92%(虚构)"}, + {"score": 0.62, "doc_id": "PROD-018", "content": "### 6.3 费用计算示例"}, + ] + ) + assert "6.3 费用计算示例" in result.text + assert "1.92%" not in result.text + + +def test_partial_exit_falls_back_to_empty_template_when_everything_is_yield() -> None: + result = build_agent()._exit_partial([YIELD_ONLY_HIT]) + assert "1.92%" not in result.text + assert "没找到" in result.text + assert result.transfer_required is False diff --git a/tools/publish_customer_service_tool_config.py b/tools/publish_customer_service_tool_config.py new file mode 100644 index 0000000..46920e3 --- /dev/null +++ b/tools/publish_customer_service_tool_config.py @@ -0,0 +1,298 @@ +"""Publish the customer-service tool allowlist for the ``E2c-my`` eligible-product exit. + +Why a release instead of an UPDATE: the active ``config_release`` is environment data, and +``call_tool`` resolves the per-intent allowlist from it. ``E2c-my`` needs +``query_eligible_products`` under ``customer_service:suitability_check``; without this item the +tool call fails closed with ``AGENT_PERMISSION_DENIED`` and the exit degrades to ``E5b`` +("can't get your risk level"), which looks like a data problem instead of a config problem. + +The script carries forward every item of the active release and replaces only the +``agent_tools/customer_service:suitability_check`` entry, so nothing else changes. +""" + +from __future__ import annotations + +import argparse +import asyncio +import datetime as dt +import json +import sys +import uuid +from pathlib import Path +from typing import Any + +import asyncmy +import httpx +import jwt + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from app.core.config import get_settings # noqa: E402 +from app.main import create_app # noqa: E402 + +ADMIN_ID = "9003" +AGENT_TYPE = "customer_service" + +#: 只改这一个键。`faq` / `policy_explain` / `product_inquiry` 原样继承,不在本脚本里重写 +#: —— 少写一个键就少一处"顺手改宽"的机会。 +INTENT_TOOLS: dict[str, tuple[str, ...]] = { + "suitability_check": ( + "search_knowledge", + "check_suitability", + "query_eligible_products", + ), +} + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Publish customer-service demo tool allowlist") + parser.add_argument("--apply", action="store_true", help="Create, review, and activate release") + return parser.parse_args() + + +def issue_token(subject: str) -> str: + settings = get_settings() + private_key = Path(settings.jwt_private_key_path).read_text(encoding="utf-8") + now = dt.datetime.now(dt.UTC) + return jwt.encode( + { + "sub": subject, + "iss": settings.jwt_issuer, + "aud": settings.jwt_audience, + "exp": now + dt.timedelta(minutes=30), + "nbf": now - dt.timedelta(seconds=5), + "jti": str(uuid.uuid4()), + }, + private_key, + algorithm="RS256", + ) + + +async def active_items() -> list[dict[str, Any]]: + settings = get_settings() + dsn = settings.mysql_dsn.split("://", 1)[1] + credentials, location = dsn.split("@", 1) + user, password = credentials.split(":", 1) + host_port, database = location.split("/", 1) + host, _, port = host_port.partition(":") + connection = await asyncmy.connect( + host=host, port=int(port or 3306), user=user, password=password, db=database + ) + try: + cursor = connection.cursor() + await cursor.execute( + """ + SELECT i.namespace, i.config_key, i.value_json, i.schema_version + FROM platform_config_item i + JOIN config_release r ON r.id = i.release_id + WHERE r.status = 'active' + """ + ) + rows = await cursor.fetchall() + finally: + connection.close() + return [ + { + "namespace": namespace, + "item_key": key, + "value_json": json.loads(value) if isinstance(value, str) else value, + "schema_version": schema_version, + } + for namespace, key, value, schema_version in rows + ] + + +def _json_or_none(value: Any) -> Any: + """库里的 JSON 列取出来可能是字符串 ``"null"`` / ``"{}"``,统一还原成对象。""" + if value is None: + return None + if isinstance(value, str): + parsed = json.loads(value) + return None if parsed is None else parsed + return value + + +async def active_prompt_templates() -> list[dict[str, Any]]: + """取要继承的提示词模板行(`prompt_template_version`),版本号 +1。 + + 为什么必须有这一步:提示词模板与工具白名单**不在同一张表**。只继承 + `platform_config_item` 建新版本,会把提示词**静默丢掉** —— 激活接口只给一句告警, + 点下去那条提示词就真的失效了。实测 2026-09-20:`customer_service_chitchat` + 因此丢过一次(激活版里 0 行),闲聊出口当场退化成兜底话术。 + 所以这里两级回退:**活跃版本有就继承活跃的;活跃版本没有,就从最近一个有该表的版本继承** + (并把这件事打在屏幕上,避免"看起来一切正常")。 + """ + settings = get_settings() + dsn = settings.mysql_dsn.split("://", 1)[1] + credentials, location = dsn.split("@", 1) + user, password = credentials.split(":", 1) + host_port, database = location.split("/", 1) + host, _, port = host_port.partition(":") + connection = await asyncmy.connect( + host=host, port=int(port or 3306), user=user, password=password, db=database + ) + columns = ( + "release_id, prompt_code, task_type, agent_type, version, " + "system_prompt, user_prompt_template, input_schema, output_schema" + ) + try: + cursor = connection.cursor() + await cursor.execute( + f"SELECT {columns} FROM prompt_template_version WHERE release_id = " + "(SELECT id FROM config_release WHERE status = 'active')" + ) + rows = await cursor.fetchall() + if not rows: + print( + "WARNING: active release has no prompt_template_version rows; " + "inheriting from the newest release that does" + ) + await cursor.execute( + f"SELECT {columns} FROM prompt_template_version " + "WHERE release_id = (SELECT MAX(release_id) FROM prompt_template_version)" + ) + rows = await cursor.fetchall() + finally: + connection.close() + return [ + { + "prompt_code": prompt_code, + "task_type": task_type, + "agent_type": agent_type, + "version": int(version) + 1, + "system_prompt": system_prompt, + "user_prompt_template": user_prompt_template, + "input_schema": _json_or_none(input_schema), + "output_schema": _json_or_none(output_schema), + } + for ( + _release_id, + prompt_code, + task_type, + agent_type, + version, + system_prompt, + user_prompt_template, + input_schema, + output_schema, + ) in rows + ] + + +async def request( + client: httpx.AsyncClient, + path: str, + *, + auth: dict[str, str], + payload: dict[str, object] | None = None, + if_match: str | None = None, +) -> httpx.Response: + headers = {**auth, "Idempotency-Key": uuid.uuid4().hex} + if if_match: + headers["If-Match"] = if_match + return await client.post(path, json=payload, headers=headers) + + +async def etag(client: httpx.AsyncClient, path: str, auth: dict[str, str]) -> str | None: + return (await client.get(path, headers=auth)).headers.get("ETag") + + +def cs_items() -> list[dict[str, object]]: + return [ + { + "namespace": "agent_tools", + "item_key": f"{AGENT_TYPE}:{intent}", + "value_json": {"allowed_tools": list(tools)}, + "schema_version": "1", + } + for intent, tools in INTENT_TOOLS.items() + ] + + +async def publish() -> int: + app = create_app() + auth = {"Authorization": f"Bearer {issue_token(ADMIN_ID)}"} + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="http://test" + ) as client: + inherited = await active_items() + prompt_templates = await active_prompt_templates() + replacement_keys = {("agent_tools", str(item["item_key"])) for item in cs_items()} + carried = [ + item + for item in inherited + if (str(item["namespace"]), str(item["item_key"])) not in replacement_keys + ] + created = await request( + client, + "/api/v1/admin/config-releases", + auth=auth, + payload={ + "release_no": f"cs-tools-{uuid.uuid4().hex[:12]}", + "title": "Customer service E2c-my eligible product tool allowlist", + "change_summary": ( + "Add query_eligible_products to customer_service:suitability_check" + ), + }, + ) + if created.status_code != 201: + print(f"create release failed: {created.status_code} {created.text[:240]}") + return 1 + release_id = int(created.json()["data"]["id"]) + base = f"/api/v1/admin/config-releases/{release_id}" + for item in [*carried, *cs_items()]: + response = await request( + client, f"{base}/platform-config-items", auth=auth, payload=item + ) + if response.status_code != 201: + print(f"write {item['namespace']}/{item['item_key']} failed: {response.status_code}") + return 1 + for template in prompt_templates: + response = await request( + client, + # 提示词模板**不走** release 作用域前缀:`register_resource` 对它用的 + # 是 `scoped=False`,路由挂在 `/api/v1/admin/prompt-templates`, + # release 由 body 里的 `release_id` 指定(实测 2026-09-20 踩过 404)。 + "/api/v1/admin/prompt-templates", + auth=auth, + payload={**template, "release_id": release_id}, + ) + if response.status_code != 201: + print( + f"write prompt {template['prompt_code']} failed: " + f"{response.status_code} {response.text[:240]}" + ) + return 1 + for suffix, payload in ( + ("validations", {}), + ("reviews", {"decision": "approved", "comment": "add eligible-product tool"}), + ("activations", {}), + ): + response = await request( + client, + f"{base}/{suffix}", + auth=auth, + payload=payload, + if_match=await etag(client, base, auth), + ) + if response.status_code not in (200, 201): + print(f"{suffix} failed: {response.status_code} {response.text[:240]}") + return 1 + print(f"published customer-service tool release id={release_id}") + return 0 + + +def main() -> int: + args = parse_args() + for intent, tools in INTENT_TOOLS.items(): + print(f"customer_service:{intent} -> {', '.join(tools)}") + if not args.apply: + print("dry run only; pass --apply to publish a new config release") + return 0 + return asyncio.run(publish()) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/客服agent/D2.1-客服Agent执行Todolist.md b/客服agent/D2.1-客服Agent执行Todolist.md index 8163843..e5f7152 100644 --- a/客服agent/D2.1-客服Agent执行Todolist.md +++ b/客服agent/D2.1-客服Agent执行Todolist.md @@ -1,4 +1,4 @@ -# 客服 Agent 执行 Todolist(执行看板 · v6.35) +# 客服 Agent 执行 Todolist(执行看板 · v6.36) > **体系编号**:`D2.1` · 域:二、对外交付 · 编号体系见 `D1.1` §4.0 @@ -47,6 +47,31 @@ **看板状态更新**:`F-3` → **✅ 已落地**(新增 4 条单测)。批次 H 剩余:`H-05`。新增待办:**三项安全路由缺口收口**(`G-01` 优先,建议排在 `H-05` 前)。 +## v6.36 本轮修订要点(2026-09-20 · `W20` 实施轮:**出口由五个扩为六个** —— 新增 `E2c-my` + 展示层净化 + 两处真实业务缺陷修复 + 全量回归) + +> **本轮决议**(用户 2026-09-20:「**你现在要贴合真实的业务场景 自己去推理 自己判断 自己修复** …… 我不想让我的agent看起来只会转人工 …… 推理 测试 修复 这些你一次性跑完」)。完整会话记录见 `D1.6` §4.49;证据 `_eval_harness\score_w20.json`(46 条金标)、`_scan_w20.json`(28 条真实场景扫描)。 + +| # | 修订 | 依据 | +|---|---|---| +| 1 | **出口由五个扩为六个**:新增 **`E2c-my`**「按本人权威等级给出可购买范围 + 在售清单」 | `PROD-012` §4.2(**`public` 档**)C—R 矩阵;口径 = 用户 2026-09-20「**要根据用户自己的画像测评去列出他能购买的产品,而不是引导他去买产品**」 | +| 2 | 新增工具 **`query_eligible_products`**(`suitability:read`;customer / advisor / operator / admin)+ 发布 **release 220**(active) | 清单只读 `fin_product` 的**公开四字段**(代码 / 名称 / 类别 / 风险等级),**按产品代码升序**(排序即隐性推荐,故不按收益 / 规模 / 热度);出口后置 `promotional_wording_violation` 护栏,命中即降级为只讲范围 | +| 3 | **点名档位的直接裁决**:问句里出现 R1—R5 时**先给裁决再列范围** | 实测修复前「我可以买 R3 的产品吗」**只给清单**(客户读不出"我不能买 R3");裁决由矩阵结果反推,措辞与 `E2c` **同源** | +| 4 | **`E2c-my` 触发判据与门禁豁免同源**:`is_own_eligibility_question()` 6 条骨架 | 避免"门禁放行了、出口却不接"的空档;反向钉子(「帮我推荐一只基金」必须仍拦)有单测 | +| 5 | **`P2` 自助流程问法豁免**:新增 `P2_SELF_SERVICE_PATTERNS` + `P2_DELEGATION_MARKERS` | 「怎么修改绑定的银行卡」修复前**建单转人工**,而 `FAQ` 里**就有**这条答案 ⇒ 能答的流程题推给人工;豁免要求「疑问词 + 改动动作」且**无代办请求词**(「帮我把绑定银行卡换一下」仍转人工) | +| 6 | **展示层净化三件套**:`render_plain`(去 markdown 标记)/ `drop_yield_claims`(收益数值不得输出)/ `prettify_title`(澄清候选) | 接在 `E3` 原文直返、`E4` 证据生成、`E5b` 部分答、澄清候选**四处**。`E4` 那份尤其必要:模型拿到的证据包是 markdown 源文,会把 `**一、购买渠道**` 原样抄进答复 | +| 7 | **空结果护栏**:净化后内容为空 ⇒ 回退 `E5b`,**不给空气泡** | 语料里 **15 个切片整个块只写一个收益数字**,被净化清空;`_exit_partial` 同时改为**跳过被清空的块**去挑下一个有内容的块 | +| 8 | **服务端会话归属**:探针访客用例改用唯一 `session_id` | 登记为**探针口径缺陷**(非产品缺陷):访客每次运行都是新匿名主体,复用固定 id 会误报 `SESSION_NOT_ACCESSIBLE` | +| 9 | **`_eval_harness\probe.py` 的 `TERMINALS` 补 `E2c-my` 标签** | 不补则出口打不到标 ⇒ 清单里的产品代码(6 位数字)会被 `M-9`「无出处数字」**误判** | +| 10 | 旧测试 `test_eligible_exit_degrades_to_scope_only_if_the_template_ever_turns_promotional` 的还原写法改为存取 `__dict__` 描述符 | 原写法把 `staticmethod` 还原成了**普通方法**,会让**后置**测试全部少传 `self`(本轮新增用例时踩到,如实登记) | + +**测试与回归**:定向 **334 passed**;全量回归(先停 Worker)**1969 passed / 3 skipped / 0 failed**(本轮基线 1946 passed);46 条金标 `M-1` **46/46 = 100%**、`M-4` **100%**、`M-6` **5/46 = 10.9%**、`M-7`~`M-10` **全 0**,与上一轮 `score_w11b` **逐项一致(零回归)**;真机 28 条场景扫描转人工 **3/25**,全部为应转(投诉 / 销户 / 代办写操作)。 + +**待决(等你裁定)**:`DEC-W20-6` 人设外显度 / `DEC-W20-7` 资料变更类口径 / `DEC-W20-8` 收益过滤层次 / `DEC-W20-9` 金标是否扩到 49 条 —— 建议见 `D1.6` §4.49 六。 + +**交叉引用**:`D1.6` 新增 §4.49;`D1.1` 新增 §30。 + +**⚠️ 诚实声明(未做)**:① 人设只做了"语气层"(澄清 / `E5b` / 闲聊),未做更外显的人设;② 收益过滤只在**展示层**,未推回语料层;③ 金标仍为 **46 条**(未加第 47~49 条,理由见 `D1.6` §4.49 六);④ `D2.9` §8.1 的 `D-2`(同会话重复模糊问句漂移)/ `D-4`(英文问句落 `E5b`)仍**只登记不修**。 + ## v6.35 本轮修订要点(2026-09-20 · `W20` 咨询轮:「按我的风险等级能买什么」被 `PROMOTION_REQUEST_PATTERNS` 第 4 条**误拦** —— 根因定位 + 五条待决) > **触发**:用户「这个为什么不能根据自己的风险等级去给他列出来他能买的产品」(附前端截图:`cust_t` 问「我现在可以买什么等级的产品」→ 拿到 `ADVICE_BOUNDARY_REPLY`)。完整会话记录见 `D1.6` §4.48;证据 = 真机 6 条 + 离线判据复算 + 读码。 diff --git a/开发文档/D1.1-文档索引与权威声明.md b/开发文档/D1.1-文档索引与权威声明.md index a491f3a..d59c44a 100644 --- a/开发文档/D1.1-文档索引与权威声明.md +++ b/开发文档/D1.1-文档索引与权威声明.md @@ -2,7 +2,7 @@ > **体系编号**:`D1.1` · 域:一、治理与索引 · 编号体系见 `D1.1` §4.0 -> **编号**:CS-DOC-2026-017 | **版本**:v1.10 | **日期**:2026-09-20 | **状态**:**现行(活文档,随文档区变动同步更新)** +> **编号**:CS-DOC-2026-017 | **版本**:v1.11 | **日期**:2026-09-20 | **状态**:**现行(活文档,随文档区变动同步更新)** > **性质**:本文件是 `开发文档\` 的**唯一入口**。任何人(含三个月后的自己)打开这一份,就应知道:先读什么、哪份为准、每份什么状态。 > **盘点范围**:`开发文档\`(**52 个文件** = 51 份编号文档 + 1 份入口存根 `CLAUDE.md`,无归档子目录)+ `客服agent\`(**9 份**对外交付文档)。 @@ -59,7 +59,7 @@ A5 [D3.3] 访客与角色分离的鉴权方案建议(仅此一份,无新旧 | # | 体系编号 | 文档 | 版本 | 作用 | |---|---|---|---|---| -| **A1** | **D2.1** | `客服agent\D2.1-客服Agent执行Todolist.md` | **v6.30** | **唯一开工入口**。**57 项 / 8 批次(A—H)** / 12 步关键路径 / 2 组会签 / **完工判据 13 条**。**新增批次 H · 智能增强**(`H-01`~`H-06`) | +| **A1** | **D2.1** | `客服agent\D2.1-客服Agent执行Todolist.md` | **v6.36** | **唯一开工入口**。**57 项 / 8 批次(A—H)** / 12 步关键路径 / 2 组会签 / **完工判据 13 条**。**新增批次 H · 智能增强**(`H-01`~`H-06`) | | **A2** | **D2.2** | `客服agent\D2.2-客服Agent需求文档.html` | **v2.7** | 对外需求:**FR-CS-001~052**(52 条,新增域 H)+ NFR-CS-001~021 全量、身份与鉴权模型、验收标准(**新增 AC-13 金标门禁**) | | **A3** | **D2.3** | `客服agent\D2.3-客服Agent开发计划.html` | **v1.1** | 前置条件、测试环境就位(G-00)、会签流程、门禁、交付物、**批次 H(§3.4b)** | | **A4** | **D2.4** | `客服agent\D2.4-客服Agent知识库设计方案.html` | **v1.7** | 三集合 / 三档可见性 / **集合内分区隔离(§7.2.1)** / 8 模块 / 7 步入库 8 步检索 / 8 项决策 / **附录F 五出口对接** | @@ -123,7 +123,7 @@ A5 [D3.3] 访客与角色分离的鉴权方案建议(仅此一份,无新旧 | **D1.4** | `开发文档\D1.4-知识源与品牌整改变更说明-2026-09-17.md` | CS-CONTENT-2026-016 | 现行 | 逐份变更说明(§3.1—§3.9 改写映射 + G-01~G-09) | | **D1.5** | `开发文档\D1.5-开发前决策清单与阻塞项-2026-09-17.md` | CS-DOC-2026-018 v1.0 | 现行 | 🔴 **开工前唯一决策登记册**:28 项待你拍板 + 阻塞分级(P0 12 / P1 10 / P2 6)+ 需你提供的 7 项输入;§7 为回填表(增补项见 `D1.6` §4.3) | | **D1.6** | `开发文档\D1.6-对话上下文提取与开工前补充决策-2026-09-17.md` | CS-DOC-2026-019 v1.0 | 现行 | 🔴 **本轮会话上下文提取件**:已读清单与权威链校正 / 可复用事实(含实测)/ 旧实现 **7 条转人工通路** / 文档缺陷 `Q-1.1`~`Q-1.6` / 前提风险 `K-01`~`K-08` / 待拍板 `N-01`~`N-09` | -| **D2.1** | `客服agent\D2.1-客服Agent执行Todolist.md` | **v6.32** | 现行 | 🔴 **唯一开工入口**:**57 项 / 8 批次** / 12 步关键路径 / **批次 H 智能增强** | +| **D2.1** | `客服agent\D2.1-客服Agent执行Todolist.md` | **v6.36** | 现行 | 🔴 **唯一开工入口**:**57 项 / 8 批次** / 12 步关键路径 / **批次 H 智能增强** | | **D2.2** | `客服agent\D2.2-客服Agent需求文档.html` | **v2.7** | 现行 | 🔴 对外需求:**FR-CS-001~052** + NFR-CS-001~021 | | **D2.3** | `客服agent\D2.3-客服Agent开发计划.html` | **v1.1** | 现行 | 🔴 前置条件 / 批次 / 会签 / 门禁 / 交付物 | | **D2.4** | `客服agent\D2.4-客服Agent知识库设计方案.html` | **v1.7** | 现行 | 🔴 三集合 / 三档可见性 / **分区隔离** / 7 步入库 8 步检索 / **附录F** | @@ -185,7 +185,7 @@ A5 [D3.3] 访客与角色分离的鉴权方案建议(仅此一份,无新旧 | 文件名 | 版本 | 日期 | 定位 | 关联 | |---|---|---|---|---| -| `D2.1-客服Agent执行Todolist.md` | **v6.33** | 2026-09-17 | 唯一开工入口 | 收敛自 `开发文档\D3.4-客服Agent重构Todolist.md` v5.1 | +| `D2.1-客服Agent执行Todolist.md` | **v6.36** | 2026-09-17 | 唯一开工入口 | 收敛自 `开发文档\D3.4-客服Agent重构Todolist.md` v5.1 | | `D2.2-客服Agent需求文档.html` | **v2.7** | 2026-09-20 | 对外需求(FR **52** / NFR 21) | 完整版见 §4.2 | | `D2.3-客服Agent开发计划.html` | **v1.1** | 2026-09-17 | 批次 / 会签 / 门禁 | 与 A1 批次号一一对应 | | `D2.4-客服Agent知识库设计方案.html` | **v1.7** | 2026-09-20 | 三集合 / 三档 / 入库检索流程;**索引统一 `AUTOINDEX`、语料 675 块** | 完整版见 §4.2 | @@ -858,6 +858,28 @@ A5 [D3.3] 访客与角色分离的鉴权方案建议(仅此一份,无新旧 --- +## 30. 第二十六轮:`W20` 实施轮 —— 新增出口 `E2c-my` + 展示层净化 + 两处真实业务缺陷修复(2026-09-20) + +> **本轮做什么**:按用户「**贴合真实业务场景,自己去推理、自己判断、自己修复**」的要求,把「推理 → 测试 → 修复」一次性跑完,并把五出口扩为**六出口**(新增 `E2c-my`)。 + +| 项 | 内容 | +|---|---| +| **新增出口 `E2c-my`** | 按**客户本人权威测评等级**给出「可购买范围 + 在售清单」。依据 `PROD-012` §4.2(**`public` 档**)C—R 矩阵;清单只含**代码 / 名称 / 类别 / 风险等级**,**按产品代码升序**(不按收益排 —— 排序即隐性推荐);出口后置 `promotional_wording_violation` 护栏,命中即降级为只讲范围 | +| **新增工具与发布** | `query_eligible_products`(`suitability:read`,customer / advisor / operator / admin),发布 **release 220**(active)。⚠️ 提示词模板与工具白名单**不在同一张表**,发布脚本必须**两级继承**(`platform_config_item` + `active_prompt_templates()`),否则提示词被静默丢弃 | +| **点名档位的直接裁决(新)** | 「我可以买 R3 的产品吗」修复前**只给清单、不说"不可以"**;现在先给一句直接裁决再列范围(裁决由矩阵结果反推,措辞与 `E2c` 同源) | +| **`P2` 自助流程问法豁免(新)** | 「怎么修改绑定的银行卡」修复前**建单转人工**,而 `FAQ` 里**就有**这条答案;新增 `P2_SELF_SERVICE_PATTERNS` + `P2_DELEGATION_MARKERS`(「帮我把绑定银行卡换一下」**仍转人工**) | +| **展示层净化(新)** | `render_plain`(去 markdown 标记)/ `drop_yield_claims`(收益数值不得输出)/ `prettify_title`(澄清候选),接在 `E3` / `E4` / `E5b` / 澄清候选**四处**;**空结果护栏**:被清空的块(实测 15 个纯收益切片)**回退 `E5b`,不给空气泡** | +| **测试** | 定向 **334 passed**;全量回归 **1969 passed / 3 skipped / 0 failed**(本轮基线 1946 passed) | +| **金标 46 条** | `M-1` **46/46 = 100%** / `M-4` **100%** / `M-6` **5/46 = 10.9%** / `M-7`·`M-8`·`M-9`·`M-10` **全 0**;与上一轮 `score_w11b` **逐项一致 ⇒ 零回归** | +| **真机场景扫描** | 25 条客户问法 + 3 条访客问法 ⇒ 转人工 **3/25**,全部为**应转**(投诉 / 销户 / 代办写操作) | +| **顺带修掉一处测试隐患** | 旧测试用 `Class.attr` 存取 `staticmethod` 再赋回,会把静态方法**静默变成普通方法**(后置测试全部少传 `self`)⇒ 改存取 `__dict__` 里的描述符 | +| **待决** | `DEC-W20-6`…`-9`(人设外显度 / 资料变更类口径 / 收益过滤层次 / 金标是否扩到 49 条),详见 `D1.6` §4.49 六 | +| **版本位** | 本文件头部 **v1.10 → v1.11**;`客服agent\D2.1` 标题 **v6.35 → v6.36**;§2 开工只读表 / §4.0 总表 / §4.2 明细**三处 `D2.1` 版本位一并同步**(此前长期分别停在 `v6.30` / `v6.32` / `v6.33`) | +| **交叉引用** | `D1.6` 新增 §4.49;`D2.1` 新增 `v6.36` 段 | +| ⚠️ **未做(诚实声明)** | ① 人设只做"语气层",未做更外显的人设;② 收益过滤只在**展示层**,未推回语料层;③ 金标仍为 **46 条**;④ `D2.9` §8.1 的 `D-2` / `D-4` 两个已知边界仍只登记不修 | + +--- + > **维护责任**:本文件为活文档。**新增 / 改名 / 归档 / 改版本号后,须同步更新本文件 §3 与 §4.0 总表对应行**。 > > 编制:项目文档组 | 审核:合规稽核部 | 日期:2026-09-17 diff --git a/开发文档/D1.6-对话上下文提取与开工前补充决策-2026-09-17.md b/开发文档/D1.6-对话上下文提取与开工前补充决策-2026-09-17.md index 956d242..985299c 100644 --- a/开发文档/D1.6-对话上下文提取与开工前补充决策-2026-09-17.md +++ b/开发文档/D1.6-对话上下文提取与开工前补充决策-2026-09-17.md @@ -3160,6 +3160,76 @@ pytest **2 failed / 1577 passed / 2 skipped**(= `T0` 基线同两项)、ruff - 本轮**未改代码**、**未跑全量回归**、**未动任何文档版本位**(`D2.x` / `D3.x` 均不涉及)。 - `DEC-W20-1` 若做得过宽(例如直接删掉第 4 条),会把「我能买什么产品」「帮我推荐一只基金」一起放行 ⇒ 属**安全回退**;`DEC-W20-5` 的第 ③ 条就是为此设的回归钉子。 +### 4.49 2026-09-20 第四十五轮会话记录(`W20` 实施轮:`E2c-my` 新出口 + 展示层净化 + 两处真实业务缺陷修复 + 全量回归) + +> **用户原话**:「你现在要贴合真实的业务场景 自己去推理 自己判断 自己修复 要提高效率 就像我刚刚跟你说的这类问题 你一定要看看还有那些类似的问题 我不想让我的agent看起来只会转人工 必要时你可以加上一个活泼可爱的人设来回答用户的问题 推理 测试 修复 这些你一次性跑完 然后关于上下文 你自己反复去汲取」 +> **上一条业务口径(本轮据此施工)**:「什么该回答 什么不该回答 你自己要分的清楚 要根据用户自己的画像测评去列出他能购买的产品 而不是引导他去买产品 这是两个完全不同的概念」 +> **本轮性质**:**实施轮** —— 代码落地 + 定向测试 + 真机复验 + 46 条金标复跑 + 全量回归 + 落档。 + +#### 一、业务口径(本轮据以施工的三分法) + +| 层 | 内容 | 本系统做法 | +|---|---|---| +| ✅ **该答** | 按**客户本人权威测评等级**,**陈述**他能买的产品范围与清单 | 新出口 `E2c-my`;依据 `PROD-012` §4.2(**`public` 档**)C—R 矩阵 | +| ✅ **该答(本轮新增)** | 点名某一档的裁决(「我可以买 R3 的产品吗」) | 出口内**先给一句直接裁决**,再列范围 | +| ❌ **不该答** | 推荐 / 建议购买 / 更适合您 / 收益最高 / 抓紧申购 | `ADVICE_BOUNDARY_REPLY`(`PROMOTION_REQUEST_PATTERNS` 拦,**豁免判据精确到"本人 + 购买 + 范围词"**) | + +界线一句话:**陈述**(由等级决定,与客户想买哪只无关)vs **引导**(由指向性结论构成)。`suitability_service` 里 2026-09-11 的既有裁定「**客服回答按矩阵**」是本出口的直接依据 —— 原实现把这类问句拦成边界话术,**违背既有裁定**,不是"要不要开"的问题。 + +#### 二、代码落地清单 + +| 文件 | 改动 | 为什么 | +|---|---|---| +| `app/core/customer_service_rules.py` | 新增 `OWN_ELIGIBILITY_PATTERNS` + `is_own_eligibility_question()`;推介门禁加豁免;新增 `PROMOTIONAL_WORDING_PATTERNS` + `promotional_wording_violation()` + `_locally_negated()`;**新增 `P2_SELF_SERVICE_PATTERNS` + `P2_DELEGATION_MARKERS` + `_is_p2_self_service_question()`** | 前者把「问等级范围」与「要推荐」分开;后者把「问怎么办」与「你替我办」分开 | +| `app/service/suitability_service.py` | 新增 `EligibleProductQuery` / `EligibleProductView` / `EligibleProductsService` / `query_eligible_products_tool` + `_ELIGIBLE_SQL`;写 `InteractionAudit(action_type="suitability.eligible_products")` | 清单**只读 `fin_product` 的公开四字段**(代码 / 名称 / 类别 / 风险等级),**按代码升序**,不按收益排 —— 排序本身就是隐性推荐 | +| `app/service/agent/bootstrap.py` | 注册工具 `query_eligible_products`(`required_permission="suitability:read"`) | 白名单与鉴权同源 | +| `app/service/agent/implementations/customer_service.py` | 新出口 `E2c-my`(`_answer_eligibility` / `_render_eligible` / `_eligible_text` / `_exit_eligible_miss`);新增决策辅助 `_asked_level_verdict()`;**展示层三件套** `render_plain` / `drop_yield_claims` / `prettify_title` | 见下 | +| `tools/publish_customer_service_tool_config.py` | 新建发布脚本(含 `active_prompt_templates()` **两级回退**),发布 release **220**(active) | 提示词模板与工具白名单**不在同一张表**,只继承 `platform_config_item` 会**静默丢提示词** | + +#### 三、真机复现 → 修复对照(`hypothesis → 实测 → 修复 → 复验`) + +| # | 实测现象(修复前) | 根因 | 修复 | 复验(修复后) | +|---|---|---|---|---| +| 1 | 「我想买点理财产品」→ 返回政策原文「第二条 适用范围」 | 未走 `E2c-my`;检索撞上政策块 | 补 `OWN_ELIGIBILITY_PATTERNS` 骨架⑥「我(想/要/打算/考虑)买…」 | 走 `E2c-my`,列出 **4 只**在售产品 ✅ | +| 2 | 「我可以买 R3 的产品吗」→ 只回「您可购买 R1、R2」清单,**"不"字始终没说出来** | `E2c` 的 `is_general_suitability_question` 要求问句**自带 C 等级**;以第一人称为主体的问法落不到任何出口 | `_asked_level_verdict()`:问句点名档位时**先给直接裁决**(裁决由矩阵结果反推,措辞与 `E2c` 同源) | 「您问的 R3(中风险)等级产品:**不可以购买**(超出您当前等级可购买的范围)」+ 范围 ✅ | +| 3 | 「怎么修改绑定的银行卡」→ **建单转人工** | `P2_PATTERNS` 第 1 条只认「动词 + 资料词」,不区分"问方法"与"让客服代办";而 `FAQ` 里**就有**这条答案 | `P2_SELF_SERVICE_PATTERNS`(疑问词 + 改动动作)+ `P2_DELEGATION_MARKERS` 反向判据,**两个分支共用** | 走检索答出「可在 APP「我的—安全中心」自助办理…」,**不建单** ✅;「帮我把绑定银行卡换一下」**仍转人工** ✅ | +| 4 | markdown 源文原样发给客户(`### 第二条`、`**一、购买渠道**`、`\| 费用类型 \|`) | `E3` 原文直返、`E4` 模型照抄证据包 —— 两者拿到的都是 **markdown 源文** | `render_plain()` 接在 `E3` / `E4` / `E5b` / 澄清候选**四处** | 全链路纯文本,客户看不到任何标记 ✅ | +| 5 | 「我想找一个收益高一点的产品」→ 答出「近一年收益率 7.60%」 | 知识块自带业绩数字,`E3` 直返把它摊到客户眼前 | `drop_yield_claims()` + **空结果护栏** | 收益行不再出现;整块被清空时**回退 `E5b` 而不是给空气泡** ✅ | +| 6 | 澄清候选被截断成半句「… · 1.」 | `title[:40]` 硬切 | `prettify_title()`(丢悬空编号段 / 文档名段,保留章节与小节) | 「一、公募基金产品」 ✅ | +| 7 | `E5b` 自我否定「这个问题我暂时只能提供以下公开资料…」;拒答话术纯拒绝、不给替代动作 | 文案 | 改为「我先帮您把找到的公开资料放上来」;`ADVICE_BOUNDARY_REPLY` 补「您可以直接问我『我能买什么』」 | 答对的题不再自我否定;拒答给出了可执行替代 ✅ | + +#### 四、测试与回归(可复核) + +| 项 | 结果 | +|---|---| +| 定向测试(`test_customer_service_rules` + `test_customer_service_agent` + `test_suitability_service`) | **334 passed** | +| 全量回归(**先停 Worker**,避免抢 MySQL outbox 造成假红) | **1969 passed / 3 skipped / 0 failed**(本轮基线 1946 passed) | +| 46 条金标(真实链路 `_eval_harness\probe.py`) | `M-1` 出口准确率 **46/46 = 100%**;`M-2` 90.3%;`M-3` 4/4;`M-4` 事实正确率 **46/46 = 100%**;`M-5` 0;`M-6` 转人工率 **5/46 = 10.9%**;`M-7` / `M-8` / `M-9` / `M-10` **全 0** | +| 与上一轮基线对比 | 与 `_eval_harness\score_w11b.json` **逐项一致** ⇒ **零回归** | +| 真机场景扫描(`_probe_scan.py`) | 25 条客户问法 + 3 条访客问法 ⇒ 转人工 **3/25**,且全部**应转**(投诉 / 销户 / 代办写操作) | + +#### 五、诚实留痕(含我自己的失误,如实登记) + +1. 第一版 `tools/publish_customer_service_tool_config.py` **只继承 `platform_config_item`**,激活后 `customer_service_chitchat` 提示词**失效** ⇒ 已加 `active_prompt_templates()` 两级回退修好。 +2. 修的过程中还踩到提示词路由**不在 release 作用域前缀下** ⇒ 404 ⇒ 已改 `/api/v1/admin/prompt-templates`。 +3. 我最初给出的 `DEC-W20-3` 建议(「默认只答等级、要清单得再问一次」)**是错的** —— 把「陈述可买范围」与「引导购买」混为一谈,用户已纠正。**这条判断更正必须如实登记**,本轮按"直接列清单"落地。 +4. **旧测试的还原写法有隐患**(本轮踩到):`test_eligible_exit_degrades_to_scope_only_if_the_template_ever_turns_promotional` 用 `Class.attr` 取出 `staticmethod` 再赋回,拿到的是"解绑后的函数",赋回类属性会**静默变成普通方法** ⇒ 此后每个测试都少传一个 `self` 而 `TypeError`,且**只在后置测试里炸**。已改为存取 `__dict__` 里的 `staticmethod` 描述符。 +5. `drop_yield_claims` 的**空结果**是本轮新发现:语料里 **15 个切片整个块只写一个收益数字**(如 `PROD-001-08`「七日年化收益率 约 1.92%」),直接返回会出现**空气泡**(前端一条空白消息)。已加护栏,并让 `_exit_partial` **跳过被清空的块**去挑下一个有内容的块。 +6. 探针脚本两处**口径缺陷**(非产品缺陷)同样登记:① `_eval_harness\probe.py` 的 `TERMINALS` 缺 `E2c-my` 标签 ⇒ 清单里的产品代码(6 位数字)会被 `M-9` 误判成"无出处数字";② `_probe_scan.py` 访客复用固定 `session_id` ⇒ 撞上上一轮的匿名主体(`SESSION_NOT_ACCESSIBLE`)。 + +#### 六、待决项(等你裁定) + +| 编号 | 待决 | 我的最优建议 | +|---|---|---| +| `DEC-W20-6` | **人设口径**:是否把"活泼可爱"做得更外显 | **建议只做"语气层"**(澄清 / `E5b` / 闲聊),**不碰**安全话术与 C—R 结论。金融场景人设过度会削弱专业感,也容易与合规文案冲突;若要更明显,先定"不得影响合规话术"的红线 | +| `DEC-W20-7` | **资料变更类**(「我要修改绑定的银行卡」「修改我的手机号」)是否也先答自助流程、只在明确要求代办时转人工 | **建议维持现状**(仍走 `P2` 建单)。理由:这不是"答不了",而是**写操作代办**的红线;流程咨询问法(怎么 / 如何)本轮已放行,覆盖面已经够 | +| `DEC-W20-8` | 收益数字过滤是否从**展示层**再往**语料层**推 | **建议只留展示层**。往语料推要重建集合、代价大,且知识块作为"公司已发布资料"保留原样更可审计;展示层已证明挡得住(15 个纯收益块全部被拦) | +| `DEC-W20-9` | 金标是否扩到 49 条(补:①「我现在可以买什么等级的产品」②「我适合买什么产品」③回归钉子「帮我推荐一只基金」) | **建议演示后加**。46 条是 `D2.6` / `D3.7` 已发布指标的**冻结基线**,中途加减会让转人工率 / 出口准确率 / 事实正确率全部失效;本轮已把等价覆盖落在单元 / 集成守卫(成本为零) | + +#### 七、版本位 + +`D1.6` 新增 §4.49;`D2.1` 新增 `v6.36` 段(标题 v6.35 → v6.36);`D1.1` 新增 §30(头部 v1.10 → v1.11,`D2.1` 版本位在 §2 / §4.0 / §4.2 三处同步)。 + ## 5. 建议的开工顺序(在 `DEC-11` 拍板后) ```