背景(用户提问触发):
§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 五项自检全过。
80 lines
3.5 KiB
Python
80 lines
3.5 KiB
Python
"""画像快照的**字段策略投影**(HTTP 端点与 Agent 工具共用的唯一实现)。
|
|
|
|
## 为什么单独成模块
|
|
|
|
投影逻辑原先写在 `app/service/public_platform_service.py`,而该模块会
|
|
`from app.service.agent.bootstrap import get_agent_factory` ——
|
|
一旦 `bootstrap` 反过来 import 使用投影的画像工具,就形成**循环导入**。
|
|
把纯粹的数据投影抽到零依赖的模块,两个入口(HTTP M001/M002 与 `query_customer_profile`
|
|
工具)共用同一份白名单,既解开环,也避免"两个入口返回不同字段"的漂移。
|
|
|
|
## 字段策略(`docs/05` §8.1 要求"返回经过字段策略过滤的当前画像")
|
|
|
|
**白名单**而非黑名单:上游快照是 JSON,新增字段若用黑名单会**自动对外可见**。
|
|
白名单只放行**画像属性**,**不放行任何 PII** ——
|
|
`real_name` / `birth_date` / `mobile_masked` / `trade_account` 及持仓明细都不在列内。
|
|
|
|
|
|
## 本白名单 ≠ 客服对话可直接陈述的字段集(`W15` 登记)
|
|
|
|
本表是**投影层**白名单,HTTP 画像端点与 `query_customer_profile` 工具共用;
|
|
客服**对话**能直接讲给客户的字段由
|
|
`app/service/agent/implementations/customer_service.py::render_profile` 的**渲染层**决定,
|
|
且刻意更窄:`total_asset` / `behavior_score`(账户与资产维度)与 `risk_tags`(内部风控标签)
|
|
**在投影层放行、在渲染层不陈述**。
|
|
|
|
原因:账户/资产维度正是 `D2.2` `FR-CS-023` 的 `P1` 裁定对象(Agent 无权限读取),
|
|
在对话里渲染它等于用画像工具**绕过 `P1`**。⇒ 「白名单 ⊇ 渲染集」是设计意图,不是漏渲染。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime
|
|
from typing import Any
|
|
|
|
#: 对外可投影的画像字段白名单。
|
|
PROFILE_FIELD_POLICY: tuple[str, ...] = (
|
|
"investor_type",
|
|
"investment_horizon",
|
|
"trading_frequency",
|
|
"preferred_asset_class",
|
|
"risk_tags",
|
|
"customer_tier",
|
|
"behavior_score",
|
|
"total_asset",
|
|
"assessment_valid_until",
|
|
"assessment_expired",
|
|
)
|
|
|
|
|
|
def parse_instant(value: Any) -> datetime | None:
|
|
"""把快照里的时间值解析成带时区的 `datetime`(不合法返回 `None`)。"""
|
|
if isinstance(value, datetime):
|
|
return value if value.tzinfo is not None else value.replace(tzinfo=UTC)
|
|
if not isinstance(value, str) or not value.strip():
|
|
return None
|
|
try:
|
|
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
except ValueError:
|
|
return None
|
|
return parsed if parsed.tzinfo is not None else parsed.replace(tzinfo=UTC)
|
|
|
|
|
|
def project_profile(snapshot: Any, *, now: datetime | None = None) -> dict[str, Any]:
|
|
"""把画像快照投影成对外的 `profile` 字段(白名单 + 测评有效期**实时**判定)。
|
|
|
|
`assessment_expired` 按**当前时间**重算,不直接信快照里的布尔值 ——
|
|
快照是历史的,而"测评是否仍有效"必须按当前时间算,否则过期测评会被当成有效
|
|
(与 `SuitabilityService` 的 `ASSESSMENT_EXPIRED` 失败关闭口径一致)。
|
|
"""
|
|
if not isinstance(snapshot, dict):
|
|
return {}
|
|
current = now or datetime.now(UTC)
|
|
projected: dict[str, Any] = {k: snapshot[k] for k in PROFILE_FIELD_POLICY if k in snapshot}
|
|
|
|
valid_until = parse_instant(projected.get("assessment_valid_until"))
|
|
if valid_until is not None:
|
|
projected["assessment_valid_until"] = valid_until.isoformat()
|
|
projected["assessment_expired"] = valid_until <= current
|
|
return projected
|