wangjianlong_0626
|
451aa4e915
|
fix(profile): ProfileAssemblyService 漏写 current_customer_id(唯一键失效的另一处)
## 现象(2026-09-12 端到端跑通后查数据时发现)
客户 9102 的画像快照状态**彼此矛盾**:
| version | is_current | current_customer_id |
|---|---|---|
| 2 | `0` | `9102` ← 清旧时没清空 |
| 3 | `1` | `NULL` ← 建新时没写入 |
## 根因
`ProfileAssemblyService._write_snapshot` 把 `current_customer_id` **当成了生成列**:
- 方法 docstring 原文写着"唯一键 `uk_profile_snapshot_current` 建立在**生成列** `current_customer_id` 上"
- 因此两处都不赋值(以为 DB 会自动填)
**但该列不是生成列** —— `alembic/baseline_generated.sql` 与真实库都是**普通可空列 + 唯一键**,
`app/model/profile.py` 的模块 docstring 第 2 条已明确:"当前版本必须由写入方**显式写入**客户 ID
(历史版本写 NULL),才能保证「每个客户最多一条当前快照」"。
后果:唯一键**形同虚设**(多个 NULL 不冲突)⇒ 不变式失效;且旧版本残留的值
一旦与新版本补上的值相同,就会**直接撞唯一键**。
> 这与本线先前修的 `CustomerProfileCandidateService._write_profile_snapshot` 是**同一个缺陷的另一处**
> —— 当时只找到一处,这次是靠真实链路跑出数据后核对才暴露出来。
## 改动
`app/service/profile_assembly_service.py`:
- 旧版本:`is_current = False` 的同时 `current_customer_id = None`
- 新版本:`is_current=True` 的同时 `current_customer_id=customer_id`
- 订正方法 docstring 的错误认知("生成列"→ 普通可空列 + 唯一键),并写明后果
## 已有数据订正
新增 `tools/fix_profile_snapshot_current.py`(**默认 dry-run**、幂等、`--apply` 才提交):
1. 先清空 `is_current=0` 却残留值的行
2. 再补写 `is_current=1` 却是 NULL 的行
3. **顺序要紧**:反过来的话第 2 步会与残留值撞唯一键
本机实测:清空 1 行、补写 1 行,复核两类异常均归零。
## 验证
- `mypy app` → 0 错 / 245 文件
- `pytest tests`(全量)→ `2 failed, 1418 passed, 1 skipped`
(2 个失败为既有环境项:httpx 把中文序列化成 `\uXXXX`,非本次引入)
- 端到端:真实对话 → 记忆抽取 → `memory_unit` 落库已实测通过(客户 9102
`preference:risk_level = "低风险"`,候选态)
|
2026-09-12 14:20:38 +08:00 |
|
lzf_0626
|
d4836ed4df
|
feat: 装配投影删除客户端(记忆失效/销户清理图与向量),并修复画像字段残留
一、装配 projection_cleaner
新增 app/service/projection_cleanup_service.py,并在**组装层**(app/worker/__main__.py)
注入。此前该客户端一直未装配,清理链路只记录降级(skipped_no_client),实际后果是
**销户后图里仍留着偏好关系**——投顾仍能通过关系网络看到这个人。
清理策略是"以画像为准"而不是按标识直接删边:先删掉该记忆对应的长期事实,再重建画像,
最后用对账修复让图跟着画像收敛。这样即使一条记忆影响多条派生边也能删干净,不依赖
"记得它当初投影成了什么"。Milvus 侧按 memory_uuid 删除;集合不存在(语义召回未启用)
时视为无需清理,客户端不可用则如实报告未清理,绝不伪造成功。
放在组装层而不是 runtime 内部兜底:组件内部给默认实现会把"尚未装配"这一事实悄悄盖住,
而"未注入即显式降级并留痕"是 runtime 刻意保留的语义。最初的改法写成内部兜底,被 5 个
既有单测拦下——那些测试是对的,因此改为在入口处注入。
二、顺带修复:事实消失后画像字段残留
实测:让 preference:horizon 记忆失效并清理后,user_facts 与图边都正确清除,但
fin_customer_profile.investment_horizon 仍是"长期(5年以上)"。根因是 rebuild_profile
只写"本轮有新值"的字段;事实被删后该字段没有新值,旧值就留在画像里——于是记忆已经作废,
投顾还能看到一个客户从未授权继续生效的投资期限。
改为:本轮没有对应事实时**清空**该字段;investor_type 例外——问卷是唯一权威,本轮没有
问卷记录时保持原值(该列 NOT NULL,且清空会在重测空档抹掉开户时的等级)。
三、实测
· 让 preference:horizon 失效后清理:cleaned=True,
detail=profile_rebuilt; graph_cleaned; vector_collection_absent;
· user_facts 2→1;图边由 ['HAS_GOAL','PREFERS'] 变为 ['PREFERS'];
· 画像 investment_horizon 由"长期(5年以上)"变为 None;investor_type 保持 C2、
risk_tags 不受影响(与 horizon 无关);
· 恢复记忆状态后重建,画像与图边均正确复原;
· ruff 通过、mypy 113 文件无错、unit+contract 447 passed、integration 29 passed。
|
2026-09-10 21:58:42 +08:00 |
|
lzf_0626
|
962a0a116f
|
feat: 记忆→画像打通(事实提升 + 画像组装 + 版本快照)
补齐"记忆系统为画像服务"的断链,按 docs/23 的分层设计实现后三层。
1. 新增 app/model/profile.py:user_facts 与 profile_snapshots 的 ORM 映射。此前这两张表
只有结构、没有 Model,实际没有任何代码在用。两处表结构特例在 docstring 里显式标注,
避免后续有人按直觉写入踩坑:
· user_facts.id 无 auto_increment,主键必须由应用提供(本实现用微秒时间戳,单调递增);
· profile_snapshots.current_customer_id 是生成列(IF(is_current=1, customer_id, NULL)),
故意不映射——映射了反而会在写入时与之冲突。
2. 新增 app/service/profile_assembly_service.py,三段职责:
· 事实提升(中期→长期):evidence_count ≥ 2 或 confidence ≥ 0.90 才从 memory_unit
提炼进 user_facts —— 这条门槛就是"客户随口一说不能变成画像结论"的落地方式;
· 画像组装(长期→画像):按白名单映射进 fin_customer_profile,未列入白名单的事实
(如 profile:family)只进 user_facts,保证画像的信噪比;
· 版本留痕:每次重建写一条 profile_snapshots,generation_basis 逐字段记录来源,
用于回答"当时凭什么这么判断"。
3. 新增 tools/rebuild_profile.py:手工触发入口(单客户或 --all)。画像暂无自动触发,
这是目前唯一的重建方式,也便于排查"画像为什么没更新"。
红线由代码保证而非约定:investor_type 只从 fin_risk_assessment 最新一条读取,实现中
不存在任何记忆路径能写它。实测——客户 9001 问卷为 C2、对话自述"稳健型",重建后
investor_type 仍为 C2,自述信息进入 risk_tags 并标注"自述:"前缀。三方不一致保持可见,
但等级判定只认问卷,客户无法靠对话改变自己的可购范围。
另一处由实测修正的设计:fin_customer_profile 的 trade_account/real_name/total_asset/
behavior_score 均为 NOT NULL,说明画像行由开户流程创建(也印证了"注册时填问卷"是开户
前置条件)。原先"首次重建时创建画像行"的做法是错的——会写出一条假的开户记录,而画像
恰恰是风控要读的数据。已改为只更新已存在的画像,未开户时返回 reason=profile_row_not_opened
并如实报告,而不是静默成功。
同时新增 docs/23-记忆分层与画像设计.md:短期/中期/长期/画像四层各自存在哪里、谁写、
提升门槛、是否进画像,以及三条路径(问卷/行为/对话)在画像层汇合的设计。
验证:ruff 通过、mypy 109 文件无错;tools/rebuild_profile.py 对客户 9001 连续两次重建
产生 version=1/2 两条快照且 is_current 正确轮转(旧版本置 0)。
|
2026-09-10 21:36:23 +08:00 |
|