Files
group_fqcd_jr/docs/23-记忆分层与画像设计.md
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

115 lines
8.3 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 记忆分层与画像设计
> 目的:讲清短期/中期/长期/画像四层**各自存在哪里、谁写、什么条件下向上提升、是否进画像**。
> 全部基于底座已有的真实表,不是另起一套;每节末尾标注实现现状。
## 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) |
| 中期 → 长期(证据累积与门槛) | ❌ 待实现 |
| 长期 → 画像(组装 + 版本快照) | ❌ 待实现 |
| 问卷 → 画像 | ❌ 待实现(注册问卷流程未做) |
| 行为 → 画像 | ❌ 待实现(交易模块未做) |
| 短期会话上下文 | ❌ 待实现(影响多轮指代) |
| 画像 → 投顾 / 风控 | ❌ 待实现 |
**建议顺序**:先做「中期 → 长期 → 画像」这一段(现在就有记忆数据可验证),再接问卷与行为两路,最后接下游消费者。