"""画像快照的**字段策略投影**(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