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)。
This commit is contained in:
2026-09-10 21:36:23 +08:00
parent 20a3a2f249
commit 962a0a116f
4 changed files with 509 additions and 0 deletions
+114
View File
@@ -0,0 +1,114 @@
# 记忆分层与画像设计
> 目的:讲清短期/中期/长期/画像四层**各自存在哪里、谁写、什么条件下向上提升、是否进画像**。
> 全部基于底座已有的真实表,不是另起一套;每节末尾标注实现现状。
## 0. 一句话
```
短期(会话上下文)→ 中期(候选事实)→ 长期(稳定事实)→ 画像(决策依据)
```
**逐层提高门槛**,是为了让"随口一说"永远变不成画像结论;
**三条路径(问卷/行为/对话)在画像层汇合**,但按字段划分所有权,不互相覆盖。
## 1. 总览
| 层 | 存在哪里 | 装什么 | 谁写 | 提升门槛 | 进画像 |
| --- | --- | --- | --- | --- | --- |
| **短期** | Redis 会话列表(设计);`conversation_message` 表(现状只有存档) | 当前会话最近的对话轮次 | 每次 run 追加 | — | ❌ 不进 |
| **中期** | `memory_unit` + `memory_evidence` | 从对话抽出的候选事实,带证据链 | Worker 记忆抽取 | `evidence_count ≥ 2` 或 `confidence ≥ 0.90` | ✅ 过门槛后提升 |
| **长期** | `user_facts`(`is_critical` 标关键事实) | 已确认的稳定事实 | 由中期提升 | 由 `user_facts` 判定 | ✅ 唯一用途就是喂画像 |
| **画像** | `fin_customer_profile`(当前)+ `profile_snapshots`(版本化,带 `generation_basis`) | 整合三路来源的客户视图 | 画像组装服务 | — | 它就是画像 |
## 2. 三条路径在画像汇合
这是整套设计的核心结构。**只有对话路径需要累积证据**,另外两条是权威/客观数据,直接写:
```
对话消息 ──→ [短期] 会话上下文 ──→ Agent 用它理解指代("它""那个")
│
└─(run 完成,发 memory.extraction_requested 事件)
↓
[中期] memory_unit + memory_evidence
│ 证据累积过门槛
↓
[长期] user_facts
│
│ ┌── 问卷测评(权威)────┐
└──组装──────┤ ├──→ [画像]
└── 行为/流水(客观)──┘
│
投顾 / 风控 / 客服
```
**为什么问卷和行为不需要走前三层**:它们是**权威**(问卷由客户答题、系统评分)与**客观**(交易记录无法自称)数据,不需要"多次出现才可信"这层保护;对话自述才有"记错、修饰、被引导"的风险。
## 3. 各层细节
### 3.1 短期:会话上下文
- **装什么**:当前会话最近若干轮对话,用于解析指代与保持连贯。
- **怎么工作**:run 开始时按 `session_id` 读出历史 → 与当前消息一起交给意图分类与回答生成;run 结束时把本轮追加进去。
- **生命周期**:会话结束或长时间无活动即失效(方案 §2.2 给的是 TTL 30min、最长 24h、超 4096 token 截断旧消息)。
- **为什么不进画像**:会话上下文是"他刚才说了什么",不是"他是什么样的人"。把临时对话当画像会造成画像抖动。
- **实现现状**:❌ **未实现**。`conversation_message` 表存了全部消息、`svc_conversation_session.message_count` 只在计数,但**没有任何"加载最近 N 条"的代码**,run 时只拿到当前这一条消息。后果是多轮指代无法解析(问"那它风险高吗"无法知道"它"指谁)。
### 3.2 中期:候选事实
- **装什么**:从对话里抽出的事实(风险偏好自述、投资期限、家庭情况、资产类别偏好等),每条都带证据。
- **怎么工作**:
- 写入:run 完成 → 发布 `memory.extraction_requested` → **Worker 消费事件** → 调模型抽取 → 写 `memory_unit`,同时写一条 `memory_evidence`(含原文摘录,可回溯"这句话从哪来")。
- 读取:run 开始时 `recall_memory` 自动召回,`recall_count` 累加。
- 演进:同一事实再次出现 → `evidence_count` 增加;出现相反证据 → `conflict_count` 增加(**不直接覆盖**,留冲突待判)。
- **生命周期**:`valid_from` / `valid_until`;过期或长期无新证据即降级/失效(`memory_lifecycle_service` 负责级联失效)。
- **进画像的条件**:`evidence_count ≥ 2` **或** `confidence ≥ 0.90` —— 这条门槛就是"一次性说法不该成为画像结论"的落地方式。
- **实现现状**:✅ **已实现并可验证**。实测:客户说"我的风险偏好是稳健型,平时只买债券基金" → 抽出 `preference:risk_level = 稳健型`(置信 0.95)+ 1 条证据。
- **⚠️ 运维前提**:抽取依赖**常驻 Worker** 消费 Outbox 事件。只起 API 服务不起 Worker,事件会一直堆在 `pending`,记忆永远不产生。
### 3.3 长期:稳定事实
- **装什么**:经过证据累积确认的稳定事实,`is_critical` 标记其中对决策关键的那些(如风险偏好、投资期限)。
- **怎么工作**:只由中期提升而来(不直接从对话写);`source_portal` 记录事实来自哪个入口,`source_episode_id` 记录来自哪个会话片段。
- **为什么不直接从对话写**:长期层是画像的输入,必须保证"这条结论经过验证",否则画像会被一次性说法污染。
- **进画像**:✅ 它的存在意义就是喂画像。
- **实现现状**:❌ **表在(9 列),无生成代码**,当前 0 行。
### 3.4 画像:决策依据
- **装什么**:整合三路来源后的客户视图,是投顾、风控、客服共同读取的**唯一**客户数据入口。
- **怎么工作**:画像组装服务按字段所有权写入 → `fin_customer_profile` 保存当前值 → 每次重建写一条 `profile_snapshots`(带 `version`、`generation_basis`、`snapshot_hash`、`is_current`),从而**每次变化都可追溯**。
- **字段所有权(关键:按字段切开,避免互相覆盖)**:
| 字段 | 唯一写入方 | 性质 |
| --- | --- | --- |
| `investor_type`(C1–C5) | **只有问卷测评** | 权威、合规硬约束 |
| `total_asset`、`trading_frequency`、`behavior_score` | 交易/流水侧 | 客观行为 |
| `preferred_asset_class`、`investment_horizon`、`risk_tags` | 记忆系统(经长期层) | 软信息、需累积 |
| `real_name`、`birth_date`、`occupation`、`mobile_masked`、`opened_at` | 注册/账户流程 | 身份 |
- **客户自述风险偏好放哪**:放 `risk_tags` 并标注"客户自述",**不得写入 `investor_type`**。
保留它的价值在于:当出现「问卷 C4 / 自述稳健 / 行为买 R4」三方不一致时,**这种矛盾本身就是风控信号**。
- **实现现状**:❌ **两张表都在,无组装代码**,当前 0 行。
## 4. 红线(必须由代码保证,不能只靠约定)
1. **适当性判定只认问卷**。`investor_type` 只能由问卷写入,记忆与对话在任何情况下都不得修改它。客户在对话里说"我是激进型"不能让他买到 R5。
2. **风控与投顾只读画像**,不直接读 `memory_unit`。否则同一条记忆会被多处按各自口径解释。
3. **每条画像字段都要能回答"凭什么"**。写入时通过 `generation_basis` 记录依据来源。
4. **画像变更留版本**,不原地覆盖 —— 风控复盘时需要"当时看到的是什么"。
5. **内部资料不进入面向客户的知识库**(已按此处理:反洗钱手册 `visibility=internal`)。
## 5. 现状与缺口一览
| 环节 | 状态 |
| --- | --- |
| 对话 → 中期记忆(抽取 + 证据 + 可回溯) | ✅ 已实现(依赖常驻 Worker) |
| 中期 → 长期(证据累积与门槛) | ❌ 待实现 |
| 长期 → 画像(组装 + 版本快照) | ❌ 待实现 |
| 问卷 → 画像 | ❌ 待实现(注册问卷流程未做) |
| 行为 → 画像 | ❌ 待实现(交易模块未做) |
| 短期会话上下文 | ❌ 待实现(影响多轮指代) |
| 画像 → 投顾 / 风控 | ❌ 待实现 |
**建议顺序**:先做「中期 → 长期 → 画像」这一段(现在就有记忆数据可验证),再接问卷与行为两路,最后接下游消费者。