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:
张胜宇
2026-09-20 16:22:02 +08:00
parent 56cb15342b
commit c3d57fbf3f
12 changed files with 493 additions and 26 deletions
@@ -230,6 +230,23 @@ def render_profile(profile: dict[str, object]) -> str:
**只陈述该客户自己的字段**,不推断、不承诺收益;测评过期时**必须明说**并引导重新测评
(与适当性服务的 `ASSESSMENT_EXPIRED` 失败关闭口径一致)。
## 为什么只渲染这 6 类,而白名单里还有 `total_asset` / `behavior_score`
`profile_projection.PROFILE_FIELD_POLICY` 是**投影层**白名单(HTTP 画像端点与
`query_customer_profile` 工具共用),它决定"哪些字段可以出库";
本函数是**客服对话的渲染层**,它决定"哪些字段可以直接讲给客户听"。两者刻意不等:
- `total_asset`(资产规模)与 `behavior_score`(行为评分)属**账户/资产维度**,
正是 `D2.2` §1.4.5 `FR-CS-023` 的 `P1` 裁定的对象(账户数据 Agent 无权限读取)。
若在这里渲染,等于用画像工具**绕过 `P1`** —— 同一份数据换个入口就能读到,
`P1` 就不再是一条可验证的边界。
- `risk_tags` 是内部风控标签,措辞未经客户沟通话术审核,不对外陈述。
- `assessment_valid_until` 只用于**推导** `assessment_expired`(按当前时间重算),
对客户讲"有效期到某日"价值低、且易与适当性服务的失败关闭口径产生歧义。
⇒ 结论:**白名单 ⊃ 渲染集**是设计意图,不是漏渲染。要放开某个字段,
必须先改 `FR-CS-023` 的 `P1` 口径,并在 `P1` 的反向守卫用例中登记。
"""
lines: list[str] = []
investor_type = str(profile.get("investor_type") or "").strip().upper()