- 分支内容对齐 qyqy_develop b6ec3aa,树完全一致(同步后 git diff 为空) - 覆盖本轮全部交付:客服 Agent 重构(安全路由 / 五出口 / 记忆与画像 / RAG 全链路) + 开发文档 62 份编号体系(D1.1 v1.17 索引) + 新增 D2.10-客服Agent端到端答辩文档-2026-09-21.html - 基线:e239eb7(2026-09-17 品牌口径统一快照),本提交为其直接后继
77 lines
3.7 KiB
Python
77 lines
3.7 KiB
Python
"""画像快照**字段完整性**的真机回归(守 `ProfileAssemblyService` 不得写残片快照)。
|
||
|
||
## 守的是什么
|
||
|
||
`profile_snapshots` 的当前版本有多个写入方,其中两个会把它置为 `is_current=1`:
|
||
|
||
1. `ProfileGenerationService` —— 走 `build_snapshot()`,覆盖读取侧白名单全部字段;
|
||
2. `ProfileAssemblyService.rebuild_profile()` —— 记忆 → 画像重建。
|
||
|
||
第 2 个此前**就地拼一个只含 `PROFILE_OWNED_FIELDS + generated_at` 的四字段残片**。
|
||
后果(2026-09-19 连库实测,客户 9001 的当前快照停在 `{investor_type, risk_tags,
|
||
generated_at, ...}`):
|
||
|
||
- 种子里明明写了 `customer_tier: gold`,被下一次重建覆盖后**分层整个消失**
|
||
⇒ 客服「我够哪一档?」只能拿风险等级顶包;
|
||
- `assessment_valid_until` / `assessment_expired` 一起消失 ⇒ 画像读取侧再也判断不出
|
||
「测评是否过期」,与 `SuitabilityService` 的 `ASSESSMENT_EXPIRED` 失败关闭口径**分叉**;
|
||
- `total_asset` / `behavior_score` 消失,画像与「账户看板」无法对照。
|
||
|
||
所以用例按真实路径重建一次,断言当前快照覆盖 `REQUIRED_SNAPSHOT_FIELDS` 全量。
|
||
`9001` 有有效期内的测评行,因此「测评有效期 / 是否过期」两个字段也应齐全。
|
||
"""
|
||
|
||
import asyncio
|
||
|
||
import pytest
|
||
from sqlalchemy import select
|
||
|
||
from app.core.profile_projection import project_profile
|
||
from app.infrastructure.db import SessionFactory
|
||
from app.model.profile import ProfileSnapshot
|
||
from app.service.profile_assembly_service import ProfileAssemblyService
|
||
from app.service.profile_generation_service import REQUIRED_SNAPSHOT_FIELDS
|
||
|
||
CUSTOMER_ID = 9001
|
||
|
||
|
||
async def _rebuild_and_read_current() -> dict[str, object]:
|
||
async with SessionFactory() as session, session.begin():
|
||
await ProfileAssemblyService(session).rebuild(CUSTOMER_ID)
|
||
async with SessionFactory() as session:
|
||
row = await session.scalar(
|
||
select(ProfileSnapshot).where(
|
||
ProfileSnapshot.customer_id == CUSTOMER_ID,
|
||
ProfileSnapshot.is_current.is_(True),
|
||
)
|
||
)
|
||
assert row is not None, "重建后必须存在一条当前版本快照"
|
||
snapshot = row.snapshot
|
||
if isinstance(snapshot, (bytes, bytearray)):
|
||
snapshot = snapshot.decode("utf-8")
|
||
return snapshot if isinstance(snapshot, dict) else {}
|
||
|
||
|
||
@pytest.mark.integration
|
||
def test_rebuild_writes_a_complete_snapshot_not_a_partial_fragment() -> None:
|
||
"""重建画像后,当前快照必须覆盖读取侧白名单的全部字段(不得是残片)。"""
|
||
snapshot = asyncio.run(_rebuild_and_read_current())
|
||
|
||
missing = [field for field in REQUIRED_SNAPSHOT_FIELDS if field not in snapshot]
|
||
assert not missing, (
|
||
f"重建写出的快照缺少读取侧字段:{missing}(实际快照={snapshot})"
|
||
)
|
||
|
||
projected = project_profile(snapshot)
|
||
assert projected.get("investor_type"), "重建后画像必须仍可投影出风险等级"
|
||
assert "total_asset" in projected, "交易侧客观字段不能被记忆重建抹掉"
|
||
assert "assessment_expired" in projected, "测评有效期判据必须保留在画像里"
|
||
|
||
# JSON 列字段不得被二次编码:正确形状是 `["money_fund"]`,不是 `['"[\\"money_fund\\"]"']`。
|
||
# 后者会被读取侧 `_localized` 当成未知值静默丢弃,等价于「偏好资产类别」整条消失。
|
||
for field in ("preferred_asset_class", "risk_tags"):
|
||
value = snapshot.get(field)
|
||
assert isinstance(value, list), f"{field} 必须是数组,实得 {value!r}"
|
||
nested = [item for item in value if isinstance(item, str) and item.strip().startswith("[")]
|
||
assert not nested, f"{field} 出现二次编码的元素:{nested}"
|