Files
group_fqcd_jr/docs/23-记忆分层与画像设计.md
T
lzf_0626 36c7a9d8d2 文档:审查报告入库 + 全量校对补注
## 新入库(`docs/演示用/`)

- `代码库全面审查报告-2026-09-14.md`
- `代码修改方案-2026-09-14.md`
- `记忆系统排查报告-2026-09-14.md`
- `记忆系统修复文档-2026-09-14.md`
- `文档一致性审计报告-2026-09-14.md`
- `多Worker接入方案-2026-09-14.md`

## 全量校对(32 个既有文档 + `AGENTS.md`)

跨 39 个文件、**1125 insertions / 148 deletions**。

⚠️ **这批改动同样不是本次会话写的**。我抽样核对过性质:是**实质内容补充**而不是
格式/换行转换。例如 `docs/44-演示流程.md` 新增两条"2026-09-14 补注":

- `启动金融Agent平台.bat` 只在**桌面**上,仓库里只有 `启动平台.bat` 这一份
  (两份由同一个 `tools/make_launcher_bat.py` 产出,改完 `start.ps1` 重跑它一起更新);
- `advisor_t`(9020) 与 `offsite_t`(9006) **不在 `tools/seed_test_rbac.py` 的演示用户里**
  (那里只有 `cust_t`/`risk_t`/`admin_t`/`review_t` 四个),由 `grant_*.py` 系列创建,
  **重跑种子不会重建它们** —— 换机器时这两个账号登录失败,要先查 `sys_user` 有没有这两行,
  而不是查密码。

这两条都是对的地方,与我这一路踩到的现象一致(我确实用到了 `advisor_t`/`offsite_t`)。

**我没有逐字审阅全部 39 个文件**,只抽样确认了改动性质与规模。若其中有需要复核的段落,
请指明文件,我逐处核对。
2026-09-14 20:36:00 +08:00

377 lines
26 KiB
Markdown
Raw 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.
# 记忆分层与画像设计
> 目的:讲清短期/中期/长期/画像四层**各自存在哪里、谁写、什么条件下向上提升、是否进画像**。
> 全部基于底座已有的真实表,不是另起一套;每节末尾标注实现现状。
> **状态口径(2026-09-14 修订)**:本文原 §5「现状与缺口一览」把 5 个环节标为 ❌,
> 但代码侧已完成,该表**已作废并重写**。判定依据是**源码实际装配**,不是文档承诺:
> `app/service/profile_assembly_service.py`、`app/worker/runtime.py`、
> `app/service/relationship_service.py`、`app/service/memory_lifecycle_service.py`。
> 下文凡标 ✅ 者均注明承载文件,可逐条复核;标 ❌ 者为**确认仍缺**的部分。
## 0. 一句话
```
短期(会话上下文)→ 中期(候选事实)→ 长期(稳定事实)→ 画像(决策依据)
```
**逐层提高门槛**,是为了让"随口一说"永远变不成画像结论;
**三条路径(问卷/行为/对话)在画像层汇合**,但按字段划分所有权,不互相覆盖。
## 1. 总览
| 层 | 存在哪里 | 装什么 | 谁写 | 提升门槛 | 进画像 |
| --- | --- | --- | --- | --- | --- |
| **短期** | `conversation_message` 表(**唯一来源**,不引入 Redis 双写) | 当前会话最近 10 轮对话 | run 受理时落库;读取只读不写 | — | ❌ 不进 |
| **中期** | `memory_unit` + `memory_evidence` | 从对话抽出的候选事实,带证据链 | Worker 记忆抽取 | `evidence_count ≥ 2` 或 `confidence ≥ 0.90` | ✅ 过门槛后提升 |
| **长期** | `user_facts`(`is_critical` 标关键事实) | 已确认的稳定事实 | 由中期提升(`promote_facts`) | 由 `user_facts` 判定 | ✅ 唯一用途就是喂画像 |
| **画像** | `fin_customer_profile`(当前)+ `profile_snapshots`(版本化,带 `generation_basis`);**关系侧另有 Neo4j 派生投影** | 整合三路来源的客户视图 | 画像组装服务 | — | 它就是画像 |
> **两库分工**(原文档未写):画像的**值**在 MySQL(唯一真相),画像的**关系**在 Neo4j
> (单向、幂等、可重建的派生视图)。见 §3.5。
## 2. 三条路径在画像汇合
这是整套设计的核心结构。**只有对话路径需要累积证据**,另外两条是权威/客观数据,直接写:
```
对话消息 ──→ [短期] 会话上下文(conversation_message 最近 10 轮)
│ └─ 以 history= 参数送入 AgentRequest
│
└─(run 完成,发 memory.extraction_requested 事件)
↓
[中期] memory_unit + memory_evidence
│ ↑
│ └─ 每轮 run 开始由 recall_memory() 召回
│ (Redis 热缓存 → MySQL 结构化 + Milvus 向量,双通道合并)
│ 证据累积过门槛(evidence ≥ 2 或 confidence ≥ 0.90)
↓
[长期] user_facts
│
│ ┌── 问卷测评(权威)────┐
└──组装──────┤ ├──→ [画像] fin_customer_profile
└── 行为/流水(客观)──┘ + profile_snapshots(版本)
│
┌─────────┴─────────┐
↓ ↓
[投影] Neo4j 关系图 投顾 / 风控 / 客服
(单向、幂等、可降级;
读侧走 RelationshipService)
```
**为什么问卷和行为不需要走前三层**:它们是**权威**(问卷由客户答题、系统评分)与**客观**(交易记录无法自称)数据,不需要"多次出现才可信"这层保护;对话自述才有"记错、修饰、被引导"的风险。
**图的定位**:它不存画像的值,只存**画像之上的关系**(客户偏好的标签、持有的产品)。
存在理由是投顾要靠"买过 A 的客户还买过什么"这类多跳关系做组合推荐、风控要靠关系网络
发现关联账户——这些查询用关系库写既别扭又慢。详见 §3.5。
## 3. 各层细节
### 3.1 短期:会话上下文
- **装什么**:当前会话最近 **10 轮**对话,旧 → 新排列,用于解析指代与保持连贯。
- **怎么工作**:run 执行时由 `WorkerRuntime._conversation_history()` 读出历史 →
组装成 `ConversationTurn` 元组 → 作为 `history=` 一并交给 `AgentRequest`。
- **存储选择**:以 `conversation_message` 表为**唯一来源**,**刻意不做 Redis 双写**。
| 方案 | 代价 |
| --- | --- |
| MySQL 单读(现状) | 一次索引查询 |
| MySQL + Redis 双写 | 换来省下这一次查询,付出**不一致风险 + TTL 管理成本** |
消息在受理时已经落库,再同步一份到 Redis 的收益抵不上代价。方案 §2.2 设想的是 Redis
列表,实现取**等价语义**(同样"最近若干轮、超出即截断")而不复制存储。
- **两个边界细节**:
- `before_message_id` **排除本轮请求消息本身**。它刚写入库,若也算进历史,模型会在
上下文里看到自己的问题被重复一遍。
- 截断按**条数**而非 token。这里没有与模型一致的分词器,按 token 截断只能靠估算、
边界会随实现漂移;按条数是确定性的——**宁可少给几轮,也不给一个不稳定的边界**。
- **为什么不进画像**:会话上下文是"他刚才说了什么",不是"他是什么样的人"。
把临时对话当画像会造成画像抖动。
- **实现现状**:✅ **已实现**,承载于 `app/worker/runtime.py::_conversation_history()`。
(原文档记为"❌ 未实现"——已过期。)
- **⚠️ 与公共召回的区别**:`BaseAgent.recall_memory()` 里有明确注释——
> 公共召回是长期/画像记忆,**不是客服二期的会话短期上下文**;定义未授权时不得读取。
即两层走的是**两个不同入口**:短期走 `history=` 参数,中期走 `recall_memory()`。
且定义未开 `recalls_customer_memory`、或角色含 `visitor` 时,短期层直接不读。
### 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` —— 这条门槛就是"一次性说法不该成为画像结论"的落地方式。
- **什么情况下才触发抽取**(原文档未写,但直接影响"为什么我说话没被记住"):
`WorkerRuntime.should_request_memory_extraction()` 先做**前置过滤**,任一不满足即整条链不启动:
| 条件 | 说明 |
| --- | --- |
| `agent_type != "customer_service"` | 客服 Agent **不写长期记忆**(它走画像候选那条独立路径) |
| `"visitor" not in context.roles` | 访客不写 |
| `should_extract_memory(...)` 为真 | 见下 |
而 `should_extract_memory()` 的三个触发条件是**任一命中**即可:
- 命中受控信号词(`detect_memory_signals()`)
- 本轮有成功的工具调用(`tool_result=True`)
- 本轮产生了业务事件(`event_type ∈ BUSINESS_EVENT_TYPES`)
**注意:句长不是门槛**。"只买货币基金"(3 字,明确)必须触发;正常的长问答不应触发。
- **实现现状**:✅ **已实现并可验证**。实测:客户说"我的风险偏好是稳健型,平时只买债券基金" → 抽出 `preference:risk_level = 稳健型`(置信 0.95)+ 1 条证据。
- **⚠️ 运维前提**:抽取依赖**常驻 Worker** 消费 Outbox 事件。只起 API 服务不起 Worker,事件会一直堆在 `pending`,记忆永远不产生。
- **⚠️ 幂等边界**:证据的幂等键是 `{event_type}:{event_id}`;重复消费返回 False 且**不产生第二条证据**。
### 3.3 长期:稳定事实
- **装什么**:经过证据累积确认的稳定事实,`is_critical` 标记其中对决策关键的那些(如风险偏好、投资期限)。
- **怎么工作**:只由中期提升而来(不直接从对话写);`source_portal` 记录事实来自哪个入口,`source_episode_id` 记录来自哪个会话片段。
- **为什么不直接从对话写**:长期层是画像的输入,必须保证"这条结论经过验证",否则画像会被一次性说法污染。
- **进画像**:✅ 它的存在意义就是喂画像。
- **提升实现细节**(`ProfileAssemblyService.promote_facts()`):
- 只取 `status='active'` 且未过 `valid_until` 的记忆;
- 值优先取 `structured_value`(结构化值),回退到正文 `content`;
- `is_critical` 由 `CRITICAL_FACTS` 决定,当前是 `{preference:risk_level, preference:horizon, preference:asset_class}` 三个。
- **两个必须知道的表约束**(否则容易写出线上故障):
1. **`user_facts.id` 没有 `auto_increment`**,主键由应用侧 `_fact_id()` 生成
(微秒时间戳:单调递增、无需额外序列)。
2. **该表没有 `(customer_id, fact_key)` 唯一键**(只有两个普通索引),
所以"每个键一条事实"必须靠服务层**先查后写**保证,**不能指望数据库约束**。
这也是 `promote_facts()` 里先 `select` 再决定 insert/update 的原因。
- **实现现状**:✅ **已实现**。承载于 `app/service/profile_assembly_service.py::promote_facts()`。
(原文档记为"❌ 表在,无生成代码,当前 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」三方不一致时,**这种矛盾本身就是风控信号**。
实现上由 `SELF_REPORTED_PREFIXES = ("preference:risk_level", "preference:", "profile:")` 判定,
统一渲染成 `自述:{key}={value}`、以「;」连接写入 `risk_tags`。
- **本服务只拥有 4 个字段**:`PROFILE_OWNED_FIELDS = (investor_type, preferred_asset_class, investment_horizon, risk_tags)`。
其余字段归交易/注册侧所有,本服务**不写**——注释写明"避免两个模块抢写同一列"。
- **事实进画像有白名单**:`FACT_TO_PROFILE_FIELD` 只有 **2 条**:
| 事实键 | 画像字段 |
| --- | --- |
| `preference:asset_class` | `preferred_asset_class` |
| `preference:horizon` | `investment_horizon` |
其余事实(例如 `profile:family`)**只进 `user_facts`,不进画像字段**——
画像要保持"能直接支撑决策"的信噪比。
- **两条不对称的更新规则**(`rebuild_profile()`,容易看漏):`investor_type` 本轮**没查到问卷就保持原值**
(既不写也不清)——否则重测前的空档会把开户时的等级抹掉,而该列是 `NOT NULL`;其余 3 个自有字段
**本轮没有对应事实就清空**——否则记忆失效后画像会留着一个已作废的投资期限,投顾据此给建议,
而客户从未授权这条信息继续生效(**实测踩到过**)。
- **画像行不存在时不代建**:返回 `{"profile": None, "reason": "profile_row_not_opened"}`。
`trade_account` 等身份字段是 `NOT NULL` 且属注册/账户侧所有,代建会写出一条**假的开户记录**——
注释的原话是"画像恰恰是风控要读的东西,**假数据比没有数据更危险**"。事实已提升进 `user_facts`,
开户后再重建即可。
- **快照写入顺序不能反**:唯一键 `uk_profile_snapshot_current` 建在 `current_customer_id` 上,
保证"每客户最多一条 current",所以必须**先 flush 旧版本 `is_current=False`,再写新版本**。
版本号取 `COALESCE(MAX(version),0)+1`,`snapshot_hash` 是 payload(`sort_keys=True`)的 sha256。
- **实现现状**:✅ **已实现**。承载于 `app/service/profile_assembly_service.py::rebuild_profile()`。
(原文档记为"❌ 两张表都在,无组装代码,当前 0 行"——已过期。)
### 3.5 画像的第二存储:Neo4j 关系投影
⚠️ **原文档完全漏了这一层**。画像不是只存 MySQL:**值在 MySQL,关系在 Neo4j**。
两者的地位**不是并列的**:
| | MySQL | Neo4j |
| --- | --- | --- |
| 存什么 | **字段的值**(`investor_type='C4'`) | **客户与外部实体的关系**(偏好标签、持仓产品) |
| 地位 | **唯一真相** | **派生视图**(derived projection) |
| 丢了会怎样 | 灾难 | **可直接重建**(拿同一份画像重投影) |
| 与 MySQL 事务一致 | — | **不需要,也刻意不做** |
**节点(5 种,`graph_model.py::NODE_SPECS`)**:
| 类型名 | 标签 | 主属性 |
| --- | --- | --- |
| `customer` | `Customer` | `customer_id`(int) |
| `product` | `Product` | `product_code` |
| `tag` | `Tag` | `tag_key` |
| `industry` | `Industry` | `category` |
| `event` | `Event` | `event_id` |
未登记的类型调 `node_spec()` 直接抛 `ValueError`——注释:"宁可失败也不拼出任意标签"。
**关系(8 种,`RELATION_SEMANTICS` 记录"谁指向谁")**:
```
PREFERS 客户 → 标签
HAS_GOAL 客户 → 标签
INTERESTED_IN 客户 → 产品 | 行业
TRADED 客户 → 产品
HOLDS 客户 → 产品
TRIGGERED_RISK 客户 → 事件
BELONGS_TO_CATEGORY 产品 → 标签
EXPOSED_TO_INDUSTRY 产品|客户 → 行业
```
底座本只有关系名白名单,这张表补的是**语义约束**(拒绝把 `TRADED` 写成客户→标签这类无意义的边)。
**实际会被投影的内容**——`FACT_PROJECTION` 只有 4 条,13 个受控记忆键里**只有这 4 个进图**:
```
preference:risk_level → PREFERS
preference:asset_class → PREFERS
preference:horizon → HAS_GOAL
profile:family → PREFERS
```
外加持仓 `HOLDS`(来自 `fin_holding JOIN fin_product`,上限 50 条)。
`constraint:*` 与其余 `profile:*` **不进图**——它们是结构化约束与属性,该走结构化通道查,
放进图只增加召回噪音。整客上限 `MAX_EDGES_PER_CUSTOMER = 200`。
**四条设计约束**(`profile_graph_projection_service.py` docstring):
1. **单向投影** —— 图可随时重建,不要求与 MySQL 事务一致。
2. **幂等** —— 全部 `MERGE`,重复投影不产生重复节点/关系。
3. **降级** —— 图库不可用时返回 `degraded`,**绝不把异常抛给画像重建主链路**。
原文:"投顾推荐可以暂时没有图,但不能因为图挂了就让画像更新失败。"
4. **只投影"已确认"的事实** —— 数据源是 `user_facts`,**不直接读 `memory_unit`**。
原文:"保证图里的偏好标签与画像口径一致;否则同一件事在画像和图里会有两种说法。"
这条不是洁癖:仓库里**曾真的有两套图投影并存**(同事分支按客户各建私有
`Preference`/`Goal` 节点、数据源是 `memory_unit`;主干写共享 `Tag` 节点、数据源是
`user_facts`)。2026-09-12 合并时取舍为**方案 A:只保留主干服务**,
`app/infrastructure/neo4j_profile_projection.py` 现已**未被生产装配**(文件头有显式 warning)。
**触发链**(与画像重建同源):
```
memory_unit 落库(同事务写 profile.rebuild_requested)
→ Worker 消费
├─ ProfileAssemblyService.rebuild() → user_facts + 画像 + 快照
└─ ProfileGraphProjectionService → Neo4j 图
另有 memory_sync_outbox 的 neo4j 分支 ⤴ 幂等复投,仅用于收敛投递状态
```
**为什么必须绕事件而不能直接调**(`runtime.py::dispatch_profile_rebuild` 注释):
> 抽取时那条记忆还在**未提交**的事务里,另开 session 去重建画像看不到它
> (实测:快照加了、事实没进、图里也没多出关系)。事件只可能在本事务提交之后被消费,
> 届时数据一定可见。
**一致性:两层对账,职责不重叠**
| 层 | 服务 | 回答的问题 |
| --- | --- | --- |
| **事件层** | `ProjectionReconciliationService` | 哪些投影事件还没投递、需要重放 |
| **数据层** | `ProfileGraphProjectionService.reconcile_customer()` | 投递完成后,**图里的内容对不对** |
数据层把差异分两类:`missing`(画像有/图里没有)与 `orphaned`(图里有/画像已无),
两者都为空才算 `consistent`。加 `repair=True` 自动修,**差异一律以画像为准**
(`missing` 补写、`orphaned` 删除,**绝不反过来改画像**),修完还会**重新读一遍确认**
——注释:"不凭'操作没报错'就宣布修好了"。手工工具:
```powershell
python tools/reconcile_graph.py 9001 # 只检查
python tools/reconcile_graph.py 9001 --repair # 检查并修复
python tools/reconcile_graph.py --all # 所有有记忆数据的客户
```
**删除的两种粒度**:`delete_customer()` 用 `DETACH DELETE` 连节点带关系一起清
(这是此前**缺失的投影删除客户端**——缺了它,销户后图里留着旧关系,投顾会拿过期偏好做推荐);
`_delete_edge()` **只删边、保留节点**(节点是共享的,同一个 `Tag` 可能被上百客户指向)。
后者有个安全细节:关系名取自图中既有边,属外部数据,拼进 Cypher 前**必须过
`ALLOWED_RELATIONSHIPS` 白名单**。
**读侧走受控边界 `RelationshipService`**(docstring:*"Controlled Neo4j boundary;
callers cannot submit arbitrary labels or Cypher."*):
```python
neighbors(customer_id, relationship, *, limit=50) # limit 夹到 1..100
paths(customer_id, *, max_hops=2, limit=50) # hops 硬上限 2
portfolio_industry_context(customer_id) # LIMIT 20
```
实际读图的两处都在投顾侧:`portfolio_analysis_service.py`(产出 `graph_context`)与
`product_recommendation_service.py`(产出 `graph_status`)。`portfolio_industry_context`
的 docstring 有一条红线:**"Return only relationship enrichment; no position value comes
from Neo4j."** —— 图只提供关系上的补充信息,**任何持仓金额之类的数值一律不从图里取**。
调用侧还二次过滤 `product_count >= 2`、只取前 5 条,图挂了返回 `{"degraded": True}` 不影响主分析。
- **实现现状**:✅ **已实现**(写侧 `profile_graph_projection_service.py`、
读侧 `relationship_service.py`、驱动 `app/infrastructure/graph.py`)。
**⚠️ 环境前提**:`build_graph_driver()` 在 `neo4j_uri`/密码缺失或驱动未安装时**返回 None**
(图能力整体关闭,不让应用起不来)。`docs/37` 记录本机验证时图库是 `Exited(1)`,
`neo4j` 分支停在 `failed` —— 文档明确判断**那是环境不可用,不是代码缺陷**,
因为"图库不可用时如实失败、不伪造成功"正是设计口径。
## 4. 红线(必须由代码保证,不能只靠约定)
1. **适当性判定只认问卷**。`investor_type` 只能由问卷写入,记忆与对话在任何情况下都不得修改它。客户在对话里说"我是激进型"不能让他买到 R5。
*落地*:`rebuild_profile()` 里 `investor_type` 只从 `fin_risk_assessment` 查询取数,记忆路径碰不到它。
2. **风控与投顾只读画像**,不直接读 `memory_unit`。否则同一条记忆会被多处按各自口径解释。
*落地*:`risk_repository.py` 走 `FundCustomerProfile`;投顾三个服务走 `enforce_profile_governance=True`。
3. **每条画像字段都要能回答"凭什么"**。写入时通过 `generation_basis` 记录依据来源。
*落地*:`basis[field] = {source, fact_key, confidence}`,随 `profile_snapshots` 一并落库。
4. **画像变更留版本**,不原地覆盖 —— 风控复盘时需要"当时看到的是什么"。
*落地*:`_write_snapshot()` 先清旧 `is_current` 再写新版本;版本号 `MAX(version)+1`。
5. **内部资料不进入面向客户的知识库**(已按此处理:反洗钱手册 `visibility=internal`)。
6. **数值不从图里取**。Neo4j 只做关系补充,任何持仓金额/市值必须来自权威库。
*落地*:`portfolio_industry_context()` docstring——*"Return only relationship enrichment;
no position value comes from Neo4j."*
7. **投影与权威源不一致时,一律以画像为准**。图是派生数据,不能反过来改画像。
*落地*:`reconcile_customer(repair=True)` 只补写 `missing`、删 `orphaned`,修完重新读一遍确认。
8. **投影失败不得拖垮画像更新**。图库不可用返回 `degraded`,主链路照常提交。
*落地*:`ProjectionOutcome(degraded=True, reason=...)`;`dispatch_profile_rebuild` 只记 warning。
## 5. 现状一览(2026-09-14 重写)
> 原表把 5 个环节标为 ❌,但代码侧已完成,故**整表作废重写**。
> 判定依据是源码实际装配;每条给出承载文件,便于逐条复核。
| 环节 | 状态 | 承载文件 |
| --- | --- | --- |
| 对话 → 中期记忆(抽取 + 证据 + 可回溯) | ✅ 已实现(依赖常驻 Worker) | `worker/memory_extraction_worker.py`、`service/memory_extraction_service.py` |
| 中期 → 长期(证据累积与门槛) | ✅ 已实现 | `service/profile_assembly_service.py::promote_facts()` |
| 长期 → 画像(组装 + 版本快照) | ✅ 已实现 | `service/profile_assembly_service.py::rebuild_profile()` |
| 问卷 → 画像 | ✅ 已实现(`rebuild_profile` 内查 `fin_risk_assessment` 最新一条) | 同上 |
| 短期会话上下文 | ✅ 已实现(最近 10 轮;不影响多轮指代) | `worker/runtime.py::_conversation_history()` |
| 画像 → 图投影(Neo4j 关系) | ✅ 已实现(**依赖图库可用**) | `service/profile_graph_projection_service.py`、`service/graph_model.py` |
| 画像 → 投顾 / 风控 | ✅ 已实现 | 读:`risk_repository.py`(8+ 处 join)、`portfolio_analysis_service.py`、`product_recommendation_service.py`;闸门:`profile_governance_service.py` |
| 中期记忆召回(双通道合并 + 降级) | ✅ 已实现 | `service/memory_recall_service.py` |
| 记忆更新与遗忘(冲突留痕、失效、客户级级联) | ✅ 已实现 | `service/memory_service.py`、`service/memory_lifecycle_service.py` |
| **行为 → 画像** | ❌ **仍未实现** | 交易侧字段(`total_asset`/`trading_frequency`/`behavior_score`)本服务**刻意不写**,留给交易模块 |
| **画像候选的人工确认闭环** | ⚠️ 接口与候选服务已在 | `customer_profile_candidate_service.py`;`GET/POST /users/me/memory-candidates*`、`POST /admin/customer-profile-candidates/{id}/reviews` |
### 5.1 三条需要注意的"看着像,其实另一回事"
1. **画像有第二个落点,别只找 MySQL**。关系在 Neo4j,见 §3.5。混起来会得出
"画像只在 MySQL"或"两个库地位相同"这两种错误结论。
2. **投顾侧还有一条独立的标签证据链**:`advisor_profile_tag`(每 `tag_key` 只有一条
`status='active'`,靠唯一列 `active_customer_tag` 保证)+ `advisor_profile_drift_review`
(标签漂移时把候选画像**挂起待复核**)。它与 `risk_tags` 不是同一套——前者是投顾内部
带证据血统的标签,后者是画像里给风控看的三方不一致信号。
3. **`risk_tags` 是 JSON 列但写入的是字符串**(`";".join(tags)`)。读取侧不要假定它是数组。
### 5.2 剩余待办(原 §5 的建议已基本生效)
原表末尾的"建议顺序:先做中期→长期→画像,再接问卷与行为两路,最后接下游消费者"
—— 除**行为一路**外均已落地。剩余待办集中在两处:
- **行为 → 画像**:等交易模块产出 `total_asset`/`trading_frequency`/`behavior_score`;
本服务的口径是"不抢写",交易侧就绪后无需改动即可衔接。
- **图库可用性**:`neo4j_uri`/密码缺失或驱动未装时 `build_graph_driver()` 返回 None,
投顾侧走 `degraded` 分支但不报错;排查时**先确认返回体里的 `graph_status`/`graph_context`,
不要误判成"投影代码坏了"**。