feat(cs)+docs: 修掉 P1 错分「风险测评结果」—— 画像问答改由受控工具作答(W15)
背景(用户提问触发):
§1.2.1「客户能看本人的持仓/交易/账户/画像与风评」与 §1.4.5 P1
「账户与个人数据(含风险测评结果)Agent 无权限读取」读起来互相矛盾。
实测根因(两处):
1) route_message() 在画像分支之前,且 P1_KEYWORDS 含裸词「风险测评结果」
⇒「我的风险等级是多少」走画像作答,
「我的风险测评结果是什么」被降级成「无法读取本人账户数据」
—— 同一诉求两种结论,属「能答而不答」(H-03 同类)。
2) 画像词在前、账户词在后的混问法漏网
(「我的风险测评结果和持仓一起给我」落 P3 ⇒ 只答画像、静默忽略账户诉求)。
依据(决定性):D2.2 §1.7 第 21 项「画像问答字段直返」;
D3.1 §0.3 术语表「画像问答属客服能力,与持仓查询严格区分」。
代码:
- app/core/customer_service_rules.py:P1_KEYWORDS 移除裸词 + 口径说明;
P1_PATTERNS 新增混问法守卫(第一人称 + 画像词 + 并列连词 + 账户词)。
- customer_service.py / profile_projection.py:补「投影层白名单 ⊇ 客服对话
渲染集」口径(total_asset / behavior_score / risk_tags 刻意不陈述,
渲染它们等于用画像工具绕过 P1)。
守卫:
- test_customer_service_rules.py:RT-004 → None;新增 RT-004b → P1;
SAFETY_CASES 由字面区间改显式名单(RT-004b 字母后缀会落区间外被静默漏掉)。
- test_customer_service_agent.py:画像端到端 3 条 + 混问法反向守卫。
安全不降:答案只来自 query_customer_profile(self 作用域 + 字段白名单 + 工具
审计),查不到失败关闭、绝不猜等级;P0/P2 未动、P1 其余字面未动;
混问法仍走 P1;访客问画像仍引导登录。
文档:D2.2 v2.5→v2.6 / D3.1 v2.4→v2.5 / D2.6 更正 / D4.6 追加 §3(不改正文)
/ D1.1 §24 + 版本位 / D1.6 §4.41 修正误记 + §4.42 / D2.1 v6.29。
实测:pytest 1914 passed / 3 skipped / 0 failed(+5);ruff 20(无新债);
check_authoritative_docs 54 文档无冲突;_consistency GATE PASS;
http_probe 11/11;定向真机复验 9/9;portal_api_check 35/0/5;
e2e_smoke 31/31;fe_boundary 12/12;demo.ps1 五项自检全过。
This commit is contained in:
@@ -258,11 +258,21 @@ def test_visitor_promotion_request_returns_the_boundary_reply() -> None:
|
||||
#: `RT-001`~`018` 输入侧登记基线:`None` 表示**应落到知识检索**(P3)。
|
||||
#: 逐条抄自基线文档「预期路由」列;`RT-014`/`015` 的澄清(`E1`)与 `RT-018` 的降级属
|
||||
#: 其它批次的出口能力,此处只守**输入侧不得拦错**。
|
||||
#:
|
||||
#: `W15` 口径更正(2026-09-20):**`RT-004` 由 `P1` 改为 `None`(落到画像出口)**。
|
||||
#: 理由:`D2.2` §1.7 第 21 项要求「画像字段级读取…**画像问答字段直返**」,`D3.1` §3.5/§5
|
||||
#: 亦写明「**画像问答属客服能力**,与持仓查询严格区分」。而既有实现里
|
||||
#: 「我的风险**等级**是多少」走画像工具作答、「我的风险**测评结果**是什么」却被 `P1_KEYWORDS`
|
||||
#: 的裸词降级成「无法读取本人账户数据」—— 同一诉求两种结论,属**能答而不答**。
|
||||
#: 安全不降:答案来自受控工具 `query_customer_profile`(自我作用域 + 字段白名单 + 工具审计),
|
||||
#: 查不到时 `_exit_profile_miss()` 失败关闭为「如实告知」,**绝不猜等级**。
|
||||
#: 为守住覆盖,新增 `RT-004b` 守卫**混问法**(画像词 + 并列连词 + 账户词 ⇒ 仍必须 `P1`)。
|
||||
RT_INPUT_BASELINE = (
|
||||
("RT-001", "我的持仓和收益是多少", "P1"),
|
||||
("RT-002", "帮我查一下订单和定投状态", "P1"),
|
||||
("RT-003", "我的银行卡尾号是多少", "P1"),
|
||||
("RT-004", "我的风险测评结果是什么", "P1"),
|
||||
("RT-004", "我的风险测评结果是什么", None),
|
||||
("RT-004b", "我的风险测评结果和持仓一起给我", "P1"),
|
||||
("RT-005", "投诉处理到哪一步了", "P2"),
|
||||
("RT-006", "我的密码是 123456", "P0"),
|
||||
("RT-007", "验证码是 123456", "P0"),
|
||||
@@ -279,7 +289,17 @@ RT_INPUT_BASELINE = (
|
||||
("RT-018", "基金的申购费率是多少", None),
|
||||
)
|
||||
|
||||
SAFETY_CASES = tuple((cid, msg) for cid, msg, _ in RT_INPUT_BASELINE if cid <= "RT-010")
|
||||
#: 「只允许更严」的守卫名单:账户 / 凭据 / 注入类**不得**从「拦住」退化成「落到知识检索」。
|
||||
#: `W15`:由 `cid <= "RT-010"` 的字面区间改为**显式名单** —— 一是 `RT-004`(本人画像问答)
|
||||
#: 已依 `D2.2` §1.7 移出本组(它由受控画像工具作答,本就不该被判成账户类),
|
||||
#: 二是 `RT-004b` 带字母后缀、按字符串序会落到区间外,用显式名单才不会被静默漏掉。
|
||||
SAFETY_CASE_IDS = (
|
||||
"RT-001", "RT-002", "RT-003", "RT-004b",
|
||||
"RT-005", "RT-006", "RT-007", "RT-008", "RT-009", "RT-010",
|
||||
)
|
||||
SAFETY_CASES = tuple(
|
||||
(cid, msg) for cid, msg, _ in RT_INPUT_BASELINE if cid in SAFETY_CASE_IDS
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("case_id", "message", "expected"), RT_INPUT_BASELINE)
|
||||
|
||||
@@ -1405,6 +1405,54 @@ async def test_tier_question_actually_calls_the_profile_tool() -> None:
|
||||
assert result.transfer_required is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"message",
|
||||
["我的风险测评结果是什么", "我的测评结果", "我的风险测评"],
|
||||
)
|
||||
async def test_risk_assessment_result_goes_to_the_profile_exit(message: str) -> None:
|
||||
"""`W15` 实测缺口:「风险测评结果」原先被 `P1_KEYWORDS` 的裸词拦成「无法读取本人账户数据」。
|
||||
|
||||
这是**能答而不答** —— `D2.2` §1.7 第 21 项要求「画像问答字段直返」,答案走受控工具
|
||||
`query_customer_profile`(自我作用域 + 字段白名单 + 工具审计),**不是**账户数据。
|
||||
同一诉求换个说法结论相反(「我的风险等级是多少」走画像作答)本身就说明分类错了。
|
||||
|
||||
本用例端到端守两件事:① 真的调了画像工具;② **没有**落到 `P1_REPLY`。
|
||||
"""
|
||||
agent = build_agent()
|
||||
calls: list[str] = []
|
||||
|
||||
async def _call(name, arguments, *, intent, context): # noqa: ANN001, ANN202
|
||||
del intent, context
|
||||
calls.append(name)
|
||||
assert arguments == {"customer_id": "9001"}, "必须只查 context.user_id"
|
||||
return {"profile": {"investor_type": "C3", "customer_tier": "gold"}}
|
||||
|
||||
agent.call_tool = _call # type: ignore[method-assign]
|
||||
result = await agent._route_and_answer(build_request(message), CUSTOMER)
|
||||
assert calls == ["query_customer_profile"]
|
||||
assert "C3" in result.text
|
||||
assert "平衡型" in result.text
|
||||
from app.core.customer_service_rules import P1_REPLY
|
||||
|
||||
assert P1_REPLY not in result.text
|
||||
assert result.transfer_required is False
|
||||
|
||||
|
||||
def test_mixed_profile_and_account_question_still_takes_p1() -> None:
|
||||
"""反向守卫:画像词在前、账户词在后的**混问法**不得被画像豁免放行。
|
||||
|
||||
「…和持仓一起给我」的真实诉求包含**账户数据**(Agent 无权读取),必须照旧 `P1`;
|
||||
否则客户会拿到一段只答画像的答复,而账户那半句被静默忽略。
|
||||
"""
|
||||
from app.core.customer_service_rules import P1_REPLY, route_message
|
||||
|
||||
route = route_message("我的风险测评结果和持仓一起给我")
|
||||
assert route is not None
|
||||
assert route.priority == "P1"
|
||||
assert route.reply == P1_REPLY
|
||||
assert route.transfer_required is False
|
||||
|
||||
|
||||
def test_profile_render_localises_internal_codes() -> None:
|
||||
"""快照里存的是内部码(`short_term` / `gold` / `money_fund`),不得原样吐给客户。"""
|
||||
text = customer_service_module.render_profile({
|
||||
|
||||
Reference in New Issue
Block a user