## 新入库(`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 个文件**,只抽样确认了改动性质与规模。若其中有需要复核的段落,
请指明文件,我逐处核对。
377 lines
26 KiB
Markdown
377 lines
26 KiB
Markdown
# 记忆分层与画像设计
|
||
|
||
> 目的:讲清短期/中期/长期/画像四层**各自存在哪里、谁写、什么条件下向上提升、是否进画像**。
|
||
> 全部基于底座已有的真实表,不是另起一套;每节末尾标注实现现状。
|
||
|
||
> **状态口径(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`,
|
||
不要误判成"投影代码坏了"**。
|