fix(W21): 智能度体检 → 3 类真实缺陷修复(C-8/C-9/C-10)+ 1 类待裁登记(C-11)

甲方反馈「客服 agent 还是不太智能」。本轮不新增出口、不改需求,
把"不智能"拆成可复现的实测条目:81 条真实口语问法 + 8 组多轮追问链
(真 HTTP:API → 队列 → Worker → 治理层),逐条定位根因并修复。

已修复(每条都有真机 HTML 证据,见 开发文档/D4.8 §3)

- C-8 账户盈亏问法落"误导性澄清"
  「我的基金赚了多少钱」→ E1 澄清,三个候选(万份收益/起投金额/收益率差别)
  全是公开知识,而客户问的是自己账户的盈亏金额。
  修法:P1_PATTERNS 补「第一人称 + 盈亏动词 + 金额疑问词」骨架,
  金额疑问词是必要条件 —— 「我买的基金亏了怎么办」照旧走知识检索(库里有解)。

- C-9 多轮指代断链,把"已经说过的"又问一遍
  「我想买个债基」→「它适合我吗」落 E2d「请告诉我具体的基金名称或代码」,
  而客户刚刚才被告知是那只产品;「赎回费怎么算」→「持有 8 个月呢」同因。
  两个根因:① _topic_of 只在答复前 4 行的固定形状里取主语,FAQ 型答复
  (首行「问:…」)反解为空;② 意图分类把短追问标成 suitability_check,
  绕开了知识出口里已做好的追问继承逻辑。
  修法:_answer_suitability 建成四级降级链(严格主语 → 形状反解
  _product_name_in_history → 有上文交回检索 → 首轮才澄清)+ 纯参数追问前置闸门。
  安全边界三条不放宽:不给访客"能不能买"结论;推测出的名字查不到风险等级时
  回落检索而非回"给不出结论";首轮指代保留澄清(金标 E-01 口径)。

- C-10 「你们投诉电话是多少」被强制转人工
  根因:P2_WRITE_DISPUTE_KEYWORDS 里的裸词「投诉」子串命中,把"问投诉渠道"
  和"提交投诉"撞在同一判据上;而答案就在库里(POL-SPM-036-01 / FAQ-0054)。
  修法:新增 _is_p2_contact_inquiry() 作为第二类 P2 豁免(渠道词 + 疑问词,
  且不带明确投诉意图)。「我要投诉,让你们经理来找我」照旧建单。

定位但未修(需甲方裁定合规口径)

- C-11 E4 证据约束生成的答复被"收益数值"闸门整条拦回 E5b
  「南方现金添利怎么样」(top1 1.0000) / 「买基金要手续费吗」(0.7328) /
  「债基和货基哪个收益高」(0.5457) 三条同因:生成稿出现 YIELD_METRIC_TERMS
  → hits_zero_tolerance → _exit_partial → E5b 兜底。
  试过"先净化再判合规",实测更差(drop_yield_claims 整行丢弃 + 生成稿常是
  单行长段 ⇒ 整条被删空),已回退并把原因写进代码注释留痕。
  建议方案:改 E4 提示词禁止输出收益数值(源头消除,不动红线代码)。

待判物

- _chunks_report.txt 判定可删:无代码引用 / 内容可从 knowledge/_chunks.jsonl
  复算 / 从未入库 / 统计口径已写进 D2.4 §6 与 D2.8 §2。已删 + 加 .gitignore。

验收

- 金标 46 条:M-1 46/46、M-4 46/46、M-6 5/46(白名单 F-05/G-01/G-03/G-04/G-05,
  零越界)、M-7/M-8/M-9/M-10 = 0、M-2 28/31、M-2b 15/18、M-3 4/4
  —— 与 W20 基线逐项一致,零回归。
- 全量回归 1985 passed / 3 skipped(W20 基线 1969 passed / 3 skipped)。
- 新增守卫单测 16 条(含 C-8 两条 / C-9 四条 / C-10 两条)。
- ruff 仅剩 4 条既有告警,未顺手改,避免混入无关 diff。

文档

- 新增 开发文档/D4.8-客服Agent智能度体检与整改报告-W21-2026-09-21.md
- 客服agent/D2.1 升 v6.37;开发文档/D1.1 升 v1.13;D1.6 增 §9 上下文提取
This commit is contained in:
张胜宇
2026-09-21 10:55:16 +08:00
parent fc3aa67451
commit dd5e9f8435
17 changed files with 1385 additions and 33 deletions
@@ -596,3 +596,49 @@ def test_p2_write_and_dispute_requests_are_still_escalated(message: str) -> None
assert route is not None, message
assert route.priority == "P2", message
assert route.transfer_required is True, message
# ---------------------------------------------------------------------------
# `W21`:产品名里的单字动词不得被当成"改等级"的动词
# ---------------------------------------------------------------------------
def test_risk_level_change_rule_is_not_fooled_by_a_product_name() -> None:
"""产品名含「增」时,「X 的风险等级是啥」曾被判成**代办改等级**。
实测根因:`RISK_LEVEL_CHANGE_PATTERNS` 第 1 条(动词在前)的动词集合里有单字「增」,
而产品名「南方稳**健增**利债券 A」里正好有一个 —— 「增 + 利债券 A 的 + 风险等级」
满足 `增[^。;!?,,]{0,8}风险等级`。命中后的下场不是"答错",而是客户拿到一整段
「风险等级不可代办」的说教:一个**陈述式提问**被当成了**代办请求**。
修法是给单字动词加**词首**守卫(前面不能是汉字),多字动词不受影响。
"""
import re
from app.core.customer_service_rules import RISK_LEVEL_CHANGE_PATTERNS
blocked = [
"南方稳健增利债券 A 的风险等级是啥",
"南方稳健增利债券 A 的风险等级是多少?",
"南方稳健增利债券A的风险等级是R几",
]
for message in blocked:
assert rules.is_risk_level_change_request(message) is False, message
# 顺带钉死每一条模式本身也不命中:只断言总入口会让"换一条模式补回来"逃过守卫
assert not any(pattern.search(message) for pattern in RISK_LEVEL_CHANGE_PATTERNS), message
def test_risk_level_change_rule_still_catches_the_real_requests() -> None:
"""收紧之后**真请求一条都不能漏**(漏 = 红线 1 ④ 失守)。"""
caught = [
"帮我改一下我的风险等级",
"把我的风险等级提到 C4",
"风险等级能不能提上去",
"能不能帮我把风险等级调高",
"我想把风险评级改成 C5",
"把等级降下来",
"我的风险承受能力可以调整吗",
"帮我上调一下投资者等级",
"把风险等级调低一点",
"风险评级想调低",
]
for message in caught:
assert rules.is_risk_level_change_request(message) is True, message
@@ -1988,3 +1988,192 @@ def test_partial_exit_falls_back_to_empty_template_when_everything_is_yield() ->
assert "1.92%" not in result.text
assert "没找到" in result.text
assert result.transfer_required is False
# ---------------------------------------------------------------------------
# `W21`:智能度体检(55 条真实口语问法)实测出的四条短板的守卫
# ---------------------------------------------------------------------------
def test_topic_parser_rejects_a_narrative_lead() -> None:
"""`W21`:上一轮答复的首行「需要说明三点:」不是主语。
实测事故链:客户问「南方稳健增利债券 A 的风险等级是啥」→(旧实现被**改等级**红线
误拦)回答的首行是「您的风险等级(C1—C5)只能由…」、第二行是「需要说明三点:」——
旧 `_topic_in` 取到「需要说明三点」当主语,第二问「那费率呢」的检索词被污染成
「需要说明三点 那费率呢」,客户拿到的是「其他费用:专户业绩报酬」这种不相干的条款。
"""
agent = build_agent()
assert agent._topic_in("需要说明三点:") == ""
assert agent._topic_in("其他费用:专户业绩报酬") == ""
assert agent._topic_in("情况:如下") == ""
# 正常主语不受影响(整节块的标题行 / 行级子块的首段)
assert agent._topic_in("1.2 南方稳健增利债券 A〔示例〕") == "南方稳健增利债券 A〔示例〕"
assert agent._topic_in("南方稳健增利债券 A〔示例〕:风险等级 R2(中低风险)") == (
"南方稳健增利债券 A〔示例〕"
)
def test_search_query_falls_back_to_the_previous_customer_question() -> None:
"""`W21`:主语**取不到**时(上一轮是 `E4` 合并生成、不声明主语)退回上一位客户问句。
实测:「我想了解定投」(`E4`)→「最低多少钱」原先整句单独检索,候选是
「第九条 问卷内容及评分标准 / 第十六条 费率标准」—— 客户只是追问上一轮的金额门槛。
"""
agent = build_agent()
history = (
ConversationTurn(role="user", content="我想了解定投"),
ConversationTurn(role="assistant", content="关于定投,目前【证据】中只有…"),
)
assert agent._search_query(
build_request("最低多少钱", history=history)
) == "我想了解定投 最低多少钱"
def test_parameterized_followup_inherits_the_predicate_from_the_previous_question() -> None:
"""`W21`:「持有 8 个月呢」只有**参数**,谓词在上一轮客户问句里。"""
agent = build_agent()
history = (
ConversationTurn(role="user", content="赎回费怎么算"),
ConversationTurn(role="assistant", content="赎回费率按持有时间分档计算…"),
)
assert agent._search_query(
build_request("持有 8 个月呢", history=history)
) == "赎回费怎么算 持有 8 个月呢"
def test_parameterized_followup_does_not_hijack_a_self_contained_question() -> None:
"""判据必须窄:自己带谓词的问句(`D-03`)不得被改写成上一位问句。"""
agent = build_agent()
history = (ConversationTurn(role="user", content="持有 8 个月赎回要付费吗?"),)
assert agent._search_query(
build_request("持有 8 个月赎回要付费吗?", history=history)
) == "持有 8 个月赎回要付费吗?"
def test_general_knowledge_gate_only_opens_for_concept_and_timing_questions() -> None:
"""常识集合补位只对"概念 / 时效 / 品类风险"型问句开放(否则会抢答产品题)。"""
agent = build_agent()
for message in (
"基金和股票有啥区别", "基金分红是怎么回事", "什么是夏普比率", "最大回撤是啥",
"前端收费和后端收费的区别", "什么时候能卖", "周末能买吗", "货币基金会不会亏",
):
assert agent._is_general_knowledge_question(message) is True, message
for message in (
"南方稳健增利债券 A 的起投金额是多少?", "我是 C1,能买 R3 的产品吗?",
"买 10 万股票基金,申购费大概多少?", "南方稳健增利债券 A 的费率是多少?",
"基金申购和赎回有哪些费率?", "帮我挑一只收益最高的基金",
):
assert agent._is_general_knowledge_question(message) is False, message
def test_partial_exit_gate_drops_a_hit_with_no_shared_business_term() -> None:
"""`W21` E5b 相关性闸门:「什么时候能卖」不该把「第二十七条 生效日期」贴给客户。
判据看**标题 + 正文前 200 字**与问句的最长公共子串:连连续两个字都不重合,
说明这一块与问句没有任何共同业务词,拿它充数就是"答非所问"。
"""
agent = build_agent()
irrelevant = {
"title": "南方基金产品销售管理办法 · 第九章 附则 · 第二十七条 生效日期",
"content": "### 第二十七条 生效日期\n\n本办法自 **2026 年 9 月 1 日**起施行。",
}
relevant = {
"title": "南方基金产品销售管理办法 · 第三章 · 第三十四条 费用与费率管理",
"content": "其他费用:专户业绩报酬 按合同约定计提",
}
assert agent._hits_share_terms("什么时候能卖", irrelevant) is False
assert agent._hits_share_terms("费用怎么收", relevant) is True
# ---------------------------------------------------------------------------
# `W21` C-8 / C-9:账户盈亏问法的口径,以及多轮指代的**降级链**
# ---------------------------------------------------------------------------
def test_product_name_shape_extracts_the_product_from_a_previous_faq_answer() -> None:
"""`W21` C-9 第一级降级:上一轮是 FAQ 型答复(首行「问:…」)时仍要认出产品名。
实测:「我想买个债基」→「它适合我吗」——`_topic_of` 取不到主语(首行是「问:…」),
旧实现回一句「请告诉我具体的基金名称或代码」,而客户**刚刚**才被告知是那只产品。
把已经说过的话再问一遍,是"客服不智能"最直观的形态。
"""
agent = build_agent()
history = (
ConversationTurn(role="user", content="我想买个债基"),
ConversationTurn(
role="assistant",
content=(
"问:你们有没有债券型基金(债基)?\n"
"答:有。本公司旗下公募基金按类型覆盖货币市场基金、债券型基金…;"
"其中债券型基金的代表产品是南方稳健增利债券 A〔示例〕,风险等级 R2(中低风险)。"
),
),
)
assert agent._product_name_in_history(
build_request("它适合我吗", history=history)
) == "南方稳健增利债券 A"
def test_product_name_shape_does_not_mistake_the_company_for_a_product() -> None:
"""公司名不是产品名:判据要求「南方 + 名称 + **产品类型后缀**」。
没有后缀约束的话,「南方基金」「南方基金管理股份有限公司」都会被当产品,
而它们在语料里出现频率最高 —— 指代会被稳定地解析到错误对象上。
"""
agent = build_agent()
for answer in (
"南方基金管理股份有限公司成立于 1998 年,是经中国证监会批准设立的基金管理公司。",
"南方基金支持定投,最低每期 100 元起,可在 APP「定投管理」中设置。",
):
history = (
ConversationTurn(role="user", content="南方基金是什么"),
ConversationTurn(role="assistant", content=answer),
)
assert agent._product_name_in_history(
build_request("它适合我吗", history=history)
) == "", answer
async def test_suitability_exit_defers_a_pure_parameter_followup_to_knowledge() -> None:
"""`W21` C-9 前置闸门:纯参数追问**无条件**交回知识检索。
事故链:上一轮答复里提过「南方稳健增利债券 A」⇒ 形状法捞出产品名 ⇒
本出口给出一段"这只基适不适合你"的裁决,而客户问的是**持有 8 个月要交
多少赎回费**。从"答不上来"变成"答错题" —— 金融场景里后者更糟。
"""
agent = build_agent()
seen: list[str] = []
async def fake_knowledge(request, context, intent): # noqa: ANN001, ANN202
seen.append(intent)
return CoreResult(text="(知识检索答复)")
agent._answer_from_knowledge = fake_knowledge # type: ignore[method-assign]
history = (
ConversationTurn(role="user", content="赎回费怎么算"),
ConversationTurn(
role="assistant",
content="南方稳健增利债券 A〔示例〕为:持有<7 天 1.5%、7—30 天 0.75%。",
),
)
result = await agent._answer_suitability(
build_request("持有 8 个月呢", history=history), CUSTOMER
)
assert seen == [customer_service_module.INTENT_FAQ]
assert result.text == "(知识检索答复)"
async def test_suitability_exit_still_clarifies_on_a_first_turn_pronoun() -> None:
"""首轮「它费率多少?」没有指代对象 ⇒ 必须澄清,**不得**降级到知识检索。
金标 `E-01` 期望正是 `E1`:首轮指代是无解的,问清比乱猜好。
"""
agent = build_agent()
async def boom(*args, **kwargs): # noqa: ANN002, ANN003, ANN202
raise AssertionError("首轮指代没有指代对象,不该走知识检索")
agent._answer_from_knowledge = boom # type: ignore[method-assign]
result = await agent._answer_suitability(build_request("它费率多少?"), CUSTOMER)
assert result.clarification_required is True
assert result.transfer_required is False
@@ -74,6 +74,10 @@ def test_risk_level_change_detection_does_not_catch_rule_questions() -> None:
"风险测评的分数如何对应风险等级?",
"我的风险测评到期了会有什么影响?",
"什么是风险评估(KYC)?必须做吗?",
# `W21`:产品名里带一个"改等级"的单字动词(南方稳**健增**利)——
# 问这只产品的风险等级曾被判成"要改等级",客户拿到的是不可代办的说教。
"南方稳健增利债券 A 的风险等级是啥",
"南方稳健增利债券 A 的风险等级是多少?",
]
for message in rule_questions:
assert is_risk_level_change_request(message) is False, message
@@ -206,3 +210,69 @@ def test_delegation_requests_route_to_human_without_execution() -> None:
# 回复里不得出现可执行的交易要素(份额 / 金额 + 动作)
for phrase in ("已为您下单", "已替您交易"):
assert phrase not in route.reply
def test_account_profit_question_is_routed_to_the_account_guard() -> None:
"""`W21` C-8:「我的基金赚了多少钱」问的是**本人账户盈亏**,不是公开知识。
实测(2026-09-21):该问法落 `E1` 澄清,三个候选是「万份收益 / 起投金额 /
为什么收益率差别这么大」——三个都是**公开知识**,客户被引到答不出他问题的
地方去挑一个,挑完仍是答非所问。账户金额 Agent 读不到,正解是 `P1`
如实告知边界 + 给出自助入口。
"""
for message in (
"我的基金赚了多少钱", "我的基金赚了多少", "我亏了多少钱",
"我买的基金最近赚了多少", "帮我看下我赚了多少钱",
):
route = route_message(message)
assert route is not None, message
assert route.priority == "P1", message
assert route.transfer_required is False, message
def test_account_profit_guard_does_not_swallow_the_public_how_to_question() -> None:
"""判据必须窄:「我买的基金亏了怎么办」问的是**怎么办**,知识库有解。
它不含金额疑问词 ⇒ 不得被 C-8 拦成 `P1`。否则一条**能答**的问句会变成
"我读不到你的数据",等于自废能力 —— 这正是"客服只会兜底"的成因。
"""
for message in (
"我买的基金亏了怎么办", "我的基金亏了怎么办", "基金收益怎么算", "收益多少",
):
route = route_message(message)
assert route is None, message
# 「基金收益率是多少」可以被**合规**出口拦下(收益口径属禁承诺范围),
# 但**不能**被账户护栏拦下 —— 它问的是公开口径,不是本人金额。
compliance_route = route_message("基金收益率是多少")
assert compliance_route is None or compliance_route.priority != "P1"
def test_complaint_hotline_question_is_answered_from_knowledge_not_transferred() -> None:
"""`W21` C-10:问**投诉渠道**是公开信息,问**提交投诉**才是人工的事。
实测(2026-09-21):「你们的投诉电话是多少」被 `P2_WRITE_DISPUTE_KEYWORDS` 里的
裸词「投诉」整条拦成建单转人工,客户拿到"这件事需要人工为您办理"。而正确答案
就在库里(`POL-SPM-036-01` 第十八条 投诉渠道 / `FAQ-0054`)。知道投诉电话不需要
人工,受理投诉才需要 —— 混在一起是"客服只会转人工"最典型的成因。
"""
for message in (
"你们的投诉电话是多少", "投诉电话", "公司的投诉热线是多少",
"你们有哪些投诉渠道", "客服电话是多少",
):
assert route_message(message) is None, message
def test_complaint_intent_still_transfers_to_a_human() -> None:
"""豁免必须窄:**要提交投诉**照旧建单,能力与红线都不减。
`G-04`「我要投诉,让你们经理来找我」是显式诉求;带明确投诉意图词的问法
(「我要投诉你们客服电话打不通」)也不得因为句子里出现"电话"就被放行 ——
那才是真的需要人接收的事。
"""
for message in (
"我要投诉,让你们经理来找我", "我要投诉", "投诉你们客服",
"我要投诉你们客服电话打不通",
):
route = route_message(message)
assert route is not None, message
assert route.priority == "P2", message
assert route.transfer_required is True, message