Files
group_fqcd_jr/docs/37-记忆投影链路实现说明.md
T
lzf_0626 0c642133d4 修复长期记忆向量链路:投影入队 + 集合名三侧同源 + 召回按客户过滤
语义召回"恒空"的根因分四层,本提交修掉投递层与读取层(另两层——重试计数
门禁、episode 不投 rebuild 事件——已在前两个提交修复)。

R2 投递层:dispatch_profile_rebuild 只做图投影,没有任何 Milvus 投递,
  长期记忆的向量从未被写入过(实测客户 9001 在 memory_sync_outbox 里 0 行)。
  新增 _enqueue_memory_vector_projection(),在画像重建后投
  MemorySyncOutbox(target_store="milvus")。走事件而不是同步写,是为了拿
  Outbox 的重试/退避/死信,且不把 embedding 的网络等待拖进事务。
  ⚠️ payload 必须带 version(正整数):适配器 _coerce_profile_version 缺它
  直接抛 ValueError(实测踩到,事件立刻 failed)。

R4 读取层:写的集合与读的集合不是同一个 ——
  写(MilvusProfileProjection)用 "user_long_term_memory_v1",
  读(bootstrap.get_vector_memory_adapter)与删(projection_cleanup_service)
  却用 settings.milvus_collection = "jr_memory",而该集合从未被创建。
  ⇒ 召回:MilvusClient 构造不校验集合存在,适配器"构造成功"但每次 search
     抛异常被 VectorMemoryAdapter 吞成 degraded → 召回恒
     degraded_reasons=('milvus_unavailable',)、向量命中恒 0 条;
  ⇒ 清理:jr_memory 不在集合列表 → 走 vector_collection_absent 分支 →
     报清理成功但一个向量都没删,陈旧向量永久留存。
  修法:PROFILE_COLLECTION 成为唯一常量,读/删两侧直接引用它;
  并删除 Settings.milvus_collection 配置项、清掉 .env.example 的
  MILVUS_COLLECTION —— 写侧从来没读过它,一个只在契约一侧生效的配置项
  比没有配置项更危险(Settings 的 extra="ignore" 会让其他环境残留的该
  变量被安全忽略)。

顺带:
- 语义检索把客户过滤下推到 Milvus(filter="customer_id == N")。此前不带
  过滤,别家客户的命中会白占 limit 名额,稀释本客户的召回条数。
- upsert 在 sources 为空时先返回,不再无条件 load_collection ——
  "本来就没有可写内容"不该被记成投递失败(10001/10002 那两条事件即如此
  重试 5 次进死信)。

回归守卫:tests/unit/infrastructure/test_memory_vector_collection_consistency.py
  断言读侧与删侧用的都是 PROFILE_COLLECTION,且被删掉的配置项不得回归。
  这个缺陷能活下来,正是因为两侧单测全绿而接缝无人守。

验证(走生产装配、进程内调用,未重启你正在跑的 API 窗口):
  读侧集合打印 user_long_term_memory_v1(修前为 jr_memory);
  召回 degraded=False / reasons=() / sources 含 milvus,且排序随 query 语义
  变化(投资期限→horizon 0.288 > risk_level 0.201;风险偏好→risk_level 0.287);
  query="进取型" 双通道合并且 vector_score=0.9987;query=None 走 mysql 全量。
  pytest tests/unit tests/contract → 1432 passed, 2 skipped, 1 failed
  (唯一失败是同事正在改的投顾页面,与记忆链路无关)。

文档:docs/演示用/记忆召回恒空-根因与修复-2026-09-14.md(新增,四层根因+证据)、
  docs/37-记忆投影链路实现说明.md(补集合名三侧契约)。
2026-09-14 21:26:03 +08:00

370 lines
20 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.
# 记忆投影链路(Outbox → Milvus 长期记忆 / Neo4j)实现说明
**适用分支**:`NL_develop`(用户端线)|**日期**:2026-09-12|**状态**:已实现并通过真机验证
---
## 1. 这条链路是干什么的
记忆(对话里被抽取出来的长期事实)要能被后续召回,必须从 MySQL 同步到两处外部存储:
```
对话消息
└─→ memory.extraction_requested(domain_event_outbox)
└─→ MemoryExtractionWorker → MemoryService.upsert() → memory_unit(MySQL,唯一真相)
└─→ profile.rebuild_requested(domain_event_outbox)
├─→ ProfileAssemblyService.rebuild() → profile_snapshots
├─→ ProfileGraphProjectionService → Neo4j(图)
└─→ ProfileGenerationService.generate() → memory_sync_outbox
├─ milvus → MilvusProfileProjection → Milvus 长期记忆向量
└─ neo4j → ProfileGraphProjectionService(幂等复投)
```
`memory_sync_outbox` 是"画像版本 → 外部存储"的投递队列,唯一键
`uk_memory_sync_event (event_uuid, target_store)`:**同一 `event_uuid` 对两个目标库各写一条**。
---
## 2. ⚠️ 最容易踩的坑:枚举取值必须全仓统一(小写 + 英文)
这条链曾**完全失效但不报错**,根因就是取值口径不统一。
### 唯一正确口径
| 字段 | 取值 | 大小写 |
|---|---|---|
| `target_store` | `milvus` / `neo4j` | **小写** |
| `operation` | `upsert` / `archive` / `delete` | **小写** |
| `status` | `pending` / `failed` / `processed` / `dead` | **小写英文** |
(`aggregate_type` 用 `memory` / `profile` / `relationship` / `deletion`。)
### 为什么照 `docs/00` §6.4.6 写会坏
`docs/00` §6.4.6 那一栏曾写作大写 `MILVUS`/`NEO4J`、`UPSERT` 与中文 `待处理`,
与**全仓实现从未对齐**。按那份文档写会造成:
1. `MemorySyncOutboxWorker` 按 `handlers.get(event.target_store)` 分派 handler
—— 大写值找不到 handler;
2. 领取条件是 `status.in_({"pending","failed"})` —— 中文 `待处理` 不满足。
⇒ **两个条件都不满足,事件任何消费者都领不到,永久滞留且不报错。**
唯一键 `(event_uuid, target_store)` 对大小写没有约束,MySQL 也不会报错,所以是**静默失效**。
判断依据应以**主干既有读取方**为准,不是文档:
`projection_reconciliation_service.py:20`(`{"pending","failed"}`)、
`graph_projection_worker.py`(`pending`/`processed`/`dead`)。
### 现状
- 生产端取值集中在 `app/service/profile_generation_service.py` 的常量
(`TARGET_MILVUS`/`TARGET_NEO4J`/`SYNC_OPERATION_UPSERT`/`SYNC_STATUS_PENDING`);
- **测试不再硬编码字面量**,并断言 `SYNC_STATUS_PENDING == "pending"`、
`set(SYNC_TARGETS) == {"milvus","neo4j"}`
(硬编码正是当初跑偏的直接原因);
- `tests/unit/worker/test_memory_sync_outbox_worker.py` 有一条契约回归测试,
断言大写 `MILVUS` 分派不到 handler、会进死信 —— 谁改回大写,测试立刻红;
- 历史 2 行已就地把取值改齐(只改值,主键/唯一键/payload 未动),
正式订正工具 `tools/normalize_memory_sync_outbox.py`
(**默认 dry-run,加 `--apply` 才写库**,幂等可复跑;换环境若也有同批旧值可直接用)。
---
## 3. Milvus 长期记忆向量集合
| 项 | 值 |
|---|---|
| 集合名 | `user_long_term_memory_v1` |
| 主键 | `memory_uuid`(VARCHAR 64,UUID 字符串,**按 UUID 幂等 upsert**) |
| 向量 | `embedding`,`FLOAT_VECTOR` dim **1024**,索引 `AUTOINDEX` + `COSINE` |
| 其他字段 | `customer_id`(INT64) / `version`(INT64,可空) / `valid_until_ts`(INT64,可空) / `updated_at_ts`(INT64,可空) / `confidence`(DOUBLE) / `content`(VARCHAR 2048) / `memory_type`(VARCHAR 32) / `memory_key`(VARCHAR 64) / `status`(VARCHAR 16) |
建集合:`python tools/setup_milvus_profile_collection.py`
——**幂等**,集合已存在时只做结构比对报告、不覆盖不删重建(共享 Milvus 实例里还有别的项目的集合)。
### ⚠️ 集合名是"写、读、删"**三侧共用**的代码级契约(2026-09-14 修)
集合名 `user_long_term_memory_v1` **不做成配置项**,唯一真相是
`app/infrastructure/milvus_profile_projection.py` 的 `PROFILE_COLLECTION` 常量。
**踩过的坑(本次语义召回"恒空"的 4 个根因里的第 4 个,也是最隐蔽的一个)**:
三侧各写各的集合名 ——
| 侧 | 位置 | 用的名字 |
|---|---|---|
| **写** | `MilvusProfileProjection`(`PROFILE_COLLECTION`) | `user_long_term_memory_v1` ✅ |
| **读** | `bootstrap.get_vector_memory_adapter()` | `settings.milvus_collection` = **`jr_memory`** ❌ |
| **删** | `ProjectionCleanupService._cleanup_vector()` | 同上 ❌ |
`.env` 里 `MILVUS_COLLECTION=jr_memory`,而 **`jr_memory` 这个集合在本仓从未被创建过**
(实测 `list_collections()` 只有 `fin_faq_collection` / `fin_policy_collection` /
`fin_product_collection` / `user_long_term_memory_v1`)。于是:
- **读**:适配器构造成功(`MilvusClient` 不校验集合存在),每次 `search` 抛异常并被
`VectorMemoryAdapter.search` 吞掉 → `degraded=True` → 召回结果恒
`degraded_reasons=('milvus_unavailable',)`、**向量通道条数恒为 0**。
写侧的投影日志一切正常,所以"写入成功、召回没数据"看似矛盾,实际是**查错了集合**。
- **删**:`jr_memory not in list_collections()` 为真 → 走 `vector_collection_absent`
分支 → **报"清理成功",但一个向量都没删**,陈旧向量永久留存。
两侧各自的单测都是绿的 —— 因为**没人守"两侧必须是同一个名字"这条接缝**。
**修法(三条一起改)**:
1. `app/infrastructure/milvus_profile_projection.py`:`PROFILE_COLLECTION` 成为唯一常量,
并注明"这里改就三侧一起改"。
2. `app/service/agent/bootstrap.py` 与 `app/service/projection_cleanup_service.py`:
改为直接引用该常量。
3. **删掉 `Settings.milvus_collection` 配置项**(`app/core/config.py`)并清掉
`.env` / `.env.example` 里的 `MILVUS_COLLECTION`:写侧从来没读过它,
一个**只在契约一侧生效**的配置项比没有配置项更危险。`Settings` 的 `extra="ignore"`
保证其他环境残留的 `MILVUS_COLLECTION` 被安全忽略(不会报错、也不会再生效)。
回归守卫:`tests/unit/infrastructure/test_memory_vector_collection_consistency.py`
(断言读侧适配器与删侧用的都是 `PROFILE_COLLECTION`,且被删掉的配置项不得回归)。
> 另注:同批次还给召回加了**客户过滤下推** —— `VectorMemoryAdapter.search(..., customer_id=N)`
> 现在会带 `filter="customer_id == N"` 去查。此前不带过滤,别家客户的命中会白占 `limit`
> 名额,本客户能拿到的条数被稀释(回表时虽按 customer_id 再筛一次,但名额已经没了)。
### `memory_sources` 契约(适配器的输入)
```json
{
"customer_id": 9102,
"profile_version": 2,
"memory_sources": [{
"memory_uuid": "uuid 字符串",
"memory_key": "preference:risk_level",
"content": "正文",
"memory_type": "preference",
"confidence": 0.9,
"version": 1,
"valid_until": null
}]
}
```
数据源:`memory_unit` 中 `status='active'` 的行
(`ProfileRepository.active_memories()`)。**由生产端组装**而不是消费端回查:
消费端到时记忆可能已改版本,事件里带确定快照才能与 `aggregate_version` 语义一致。
> 为什么用 `memory_unit` 而不是 `user_facts`:两者用途不同 —— `user_facts` 是
> **已确认的结构化事实**(喂画像与图投影),`memory_unit` 是**长期记忆条目**
> (正是长期记忆向量集合要存的东西)。这不与"图投影只读 `user_facts`"的不变式冲突。
---
## 4. 移植自 `ZSY_develop` 的改动(含两处契约放宽)
来源:同事 `ZSY_develop` 分支。整文件移植、骨架未改的部分:
`app/core/conversation_privacy.py`、`app/worker/memory_sync_outbox_worker.py`
(领取/`skip_locked`/指数退避/5 次转死信的可靠性骨架原样保留)、
`app/infrastructure/milvus_profile_projection.py`。
对适配器做了**两处契约放宽**,都是"避免整批失败",不改变写入语义:
1. **`customer_id` 接受 int 或数字字符串**
原实现要求 `isinstance(customer_id, int)`,而本仓**所有**生产者写的都是
`str(customer_id)`。不放宽则**每个事件必然失败**、重试 5 次后进死信。
放宽仅限**纯数字**串,非数字(如 uuid)仍拒绝。
2. **不可投影的 `memory_key` 跳过而非整批报错**
受控词表 `app/service/memory_taxonomy.py` 共 13 个键,其中 `constraint:*`(3 个)与
`profile:*`(4 个)不以 `preference:`/`goal:` 开头。原实现对第一个不合规的键直接
`raise` —— 一条 `constraint:` 记忆就会毒死该客户整批同步。现改为**跳过并留痕**。
**收窄而非扩容的理由**:`preference:*`/`goal:*` 是偏好与目标,适合语义召回;
`constraint:*`/`profile:*` 是结构化约束与属性,应由结构化通道查询(`user_facts` → 图),
放进向量集合只会造成召回噪声。若日后要给它们做语义召回,应扩容而非靠现在的跳过。
---
## 5. Neo4j 分支:方案 A(不引入第二套投影)
同事分支另有一套 `neo4j_profile_projection.py`(按客户各建私有
`Preference`/`Goal` 节点、数据源 `memory_unit`)。**未采用**,理由:
主干 `ProfileGraphProjectionService` 已由 `profile.rebuild_requested` 驱动同一条链,
其数据源是 `user_facts`(**已确认事实**)、`MERGE` **共享 tag 节点**,且文件头写明不变式:
> "只投影'已确认'的事实……保证图里的偏好标签与画像口径一致;
> 否则同一件事在画像和图里会有两种说法。"
两套并存 = 同一事实在图中两种表示,正好违反这条不变式。
因此 `memory_sync_outbox` 的 `neo4j` 分支**复用主干服务**:
图投影是 `MERGE` 幂等的,再投一次不产生重复节点/关系,只用于收敛 outbox 的投递状态。
---
## 6. 消费端装配
`WorkerRuntime.consume_profile_projections()`(`app/worker/runtime.py`),在
`run_once()` 每轮执行,按条提交、单条失败不冒泡。
- `milvus` handler → `MilvusProfileProjection`(注入 `MilvusProfileVectorClient` + 向量化);
- `neo4j` handler → `ProfileGraphProjectionService`;其 `degraded` 会**如实抛错**
走失败/退避,不记成已投递;
- `milvus_uri` 未配置时**显式降级**:不消费、事件留 `pending`(可观测、可重放),
启动路径留一条 warning,绝不伪造同步成功(与 `knowledge_writer` 同一取向);
- 目标存储没有对应 handler 时判**死信**并记 `target_handler_not_configured`
—— 这正是当初大写值事件的下场(有回归测试守着)。
向量化复用与知识向量同一套已批准端点解析(`agent_type="memory_recall"`,
`task_type="embedding"`)。
### 6.1 `memory_sources` 缺失时的兜底(`_with_memory_sources`)
`memory_sources` 是本线新增的投影入参,而**投顾线两处生产者**
(`profile_governance_service` / `risk_questionnaire_service`)发的 payload 是
`{customer_id, profile_uuid, version, profile}`,**没有**这个键。若不处理,它们每次画像
变更都会因 `memory_sources is invalid` 失败重试直至死信。
消费端因此做了兜底:**键缺失或为 `None`** 时,回退为查询该客户 `memory_unit` 中
`status='active'` 的记忆,并**记一条 warning**(使"谁没提供"保持可见)。
为什么允许兜底:缺失表示生产者不知道要提供,属契约演进期的正常情况,且**语义成立**
——长期记忆是**客户级**的、不是画像版本级的,每条记忆自带 `version`,适配器按
`memory_uuid + version` 幂等,"用的是哪一版"仍然确定。
**兜底不掩盖真错误**(有测试守着):键**存在但格式不对**(例如是字符串)时**不兜底**,
原样放行交给适配器失败关闭。实现上用**键存在性**判断而不是 `isinstance`——后者会把
"缺失"与"格式错"混为一谈,那正是本模块初版实现里的一个真 bug,被测试抓出来后修正。
---
## 6.2 顺带修掉的独立缺陷:`profile_snapshots` 被重复定义
**发现路径**:验证 `neo4j` 分支时,库里那行 `last_error='InvalidRequestError'`。
原以为是图库故障,追下去发现是**模型层缺陷**:
- `app/model/profile.py` → `ProfileSnapshot` 映射 `profile_snapshots`
- `app/model/risk_questionnaire.py` → **另一个** `ProfileSnapshot` 也映射 `profile_snapshots`
SQLAlchemy 不允许两个类映射同一张表。实测:
| 场景 | 结果 |
|---|---|
| 单独导入 `app.main` / `app.worker.runtime` | 正常 |
| 单独导入 `profile_assembly_service` / `risk_questionnaire_service` | 正常 |
| **两者同时导入** | `InvalidRequestError: Table 'profile_snapshots' is already defined` |
**影响**:Worker 在同一个进程里既要处理 `profile.rebuild_requested`(走 `app.model.profile`),
又要处理投顾风险问卷(走 `risk_questionnaire.py`)——所以这是**会打挂 Worker 的缺陷**,
不是理论风险。
**修法**:`app/model/risk_questionnaire.py` 不再重复定义,改为从 `app.model.profile`
转出(re-export),既有 4 处 `from app.model.risk_questionnaire import ProfileSnapshot`
**无需改动**。
> ⚠️ **订正(2026-09-12 合并主干后复核)**:本条初稿曾写"`app.model.profile` 不映射
> `current_customer_id`、该属性无人使用",**这个说法已过时**。实际情况:
> `app/model/profile.py` **已经映射** `current_customer_id`(普通可空列 + 唯一键
> `uk_profile_snapshot_current`,**不是**生成列 —— 模块 docstring 第 2 条写明了原因:
> 声明成生成列会让 SQLAlchemy 把它从 INSERT 排除,反而永远写不进去),
> 且**确有人使用**(集成测试按该列查当前快照)。
> 因此本次合并顺带修了一个真实缺陷:`CustomerProfileCandidateService._write_profile_snapshot`
> 创建当前版本时**没写**该列、也没清旧值 —— 唯一键形同虚设,且一旦补写就会与旧值撞键。
> 详见 `docs/39-主干合并对策记录.md` §4.2。
> 两个模块的 `ProfileSnapshot` 现在**都**映射该列,这是 re-export 成立的前提。
---
## 7. 真机验证证据(2026-09-12)
| 验证项 | 结果 |
|---|---|
| 集合创建幂等 | 首次 `created`,复跑 `exists` |
| 真实 embedding 维度 | **1024**(与集合定义一致) |
| 真实写入 + 回读 | 2 条可投影键写入成功并可回读(内容/版本正确) |
| 不可投影键 | `constraint:liquidity` **未写入**(跳过生效,未毒死整批) |
| 测试数据清理 | 已按 `customer_id=999999` 删除,集合残留 **0** 条 |
| **消费端全路径**(补验) | 见下方 7.1 |
| 单元测试 | 新增 **22** 个(适配器 10 + worker 5 + 生产端 2 + 消费端兜底 5),全过 |
| 全量回归 | `2 failed, 1312 passed, 2 skipped` —— 与基线一致,**无新增失败** |
| mypy | `Success: no issues found in 227 source files` |
> 全量的 2 个失败是既有环境相关项(`test_offsite_document_recognition_adapter.py`
> 断言请求体里的中文原文,而 httpx 序列化成 `\uXXXX`),与本次改动无关。
> 真机注意:Milvus 写入后**短时间内可能查不到**(索引尚未可见),
> 验证脚本按重试处理;同理删除后立即查询可能仍返回旧行,需重查确认。
### 7.1 消费端全路径验证(2026-09-12 补做)
此前"整合验证"是**直接调适配器**,跳过了 outbox 的领取→分派→状态更新。
后补做了两轮,覆盖失败分支与成功分支:
**失败分支**(Milvus 断开时实测):
| id | `last_error` | 说明 |
|---|---|---|
| 5 | `ValueError` | payload 缺 `memory_sources`(兜底上线前的旧行) |
| 6 | `InvalidRequestError` | 模型重复定义缺陷(见 §6.2),**修复后此错误消失** |
| 9 | `RecoverableAgentError` | Milvus 不可达——如实失败,不伪造成功 |
这证明全路径都工作:行被领取 ✓、按 `target_store` 分派 handler ✓、
handler 异常被捕获 ✓、`status`/`retry_count`/`last_error`/`next_retry_at` 正确落库 ✓。
**成功分支**(注入替身向量客户端,不依赖真实 Milvus):
- outbox 行 → `status=processed`、`processed_at` 已写、`last_error` 清空 ✓
- 不可投影的 `constraint:liquidity` **被跳过**(只写 1 行而非 2 行)✓
- 向量维度 1024 ✓;字符串客户号 `"999996"` → int ✓
- **手机号脱敏生效**:`稳健型投资者,手机号 [手机号已隐藏] 请勿外泄` ✓
**兜底的实证**:历史行 `id=5`(payload 无 `memory_sources`)经兜底回退查询后
成功投递为 `processed`,日志留
`profile projection payload has no memory_sources (customer_id=9102); fell back to 0 active memories`。
> 补验时的环境限制:Docker Desktop 中途崩溃(`milvus-standalone` 内嵌 etcd panic、
> Neo4j `Exited(1)`),因此 `id=6`(neo4j 分支)停在 `failed`/`RecoverableAgentError`。
> **那是环境不可用,不是代码缺陷**——图库不可用时如实失败、不伪造成功正是设计口径。
---
## 8. 尚未完成 / 依赖他人
1. **常驻 Worker 未运行**:`memory_unit`、`user_facts`、`episodes` 目前都是 **0 行**。
代码链路是通的(§7.1 已用真实 outbox 行验证消费端),但没有 Worker 在跑,
所以记忆永远不会被抽取出来。积压量(2026-09-12 实测):
`agent.run_requested` **431**、`profile.rebuild_requested` **220**、
`agent.run_completed` **61**、`memory.extraction_requested` **58**。
起 `python -X utf8 -m app.worker --once`(或常驻)即开始消费。
⚠️ 会派发真实 agent 任务、产生模型调用费用,故未擅自启动。
2. **投顾线两处生产者的 `memory_sources`**:其 payload 仍**没有**这个键。
本线已在消费端加了兜底(§6.1),因此**不再会死信**;但根治仍应由投顾线补上
(或明确这两个来源是否也要投影长期记忆)。属架构师线,本线未改其生产者代码。
3. **`profile_snapshots` 重复定义缺陷**(§6.2):本线已修,但它源自投顾线的模型文件,
需让架构师知晓,以免在别处再引入同名定义。
4. `docs/00` §6.4.6 的取值栏与实现不一致:按评审要求**未改基线文档**,
实际口径以本文第 2 节为准。
---
## 9. 相关文件
**新增**
- `app/core/conversation_privacy.py`(移植)
- `app/infrastructure/milvus_profile_projection.py`(移植 + 2 处放宽)
- `app/infrastructure/milvus_profile_vector_client.py`
- `app/worker/memory_sync_outbox_worker.py`(移植)
- `tools/setup_milvus_profile_collection.py`
- `tools/normalize_memory_sync_outbox.py`(历史取值订正,默认 dry-run、幂等)
- `tests/unit/infrastructure/test_milvus_profile_projection.py`
- `tests/unit/infrastructure/test_memory_vector_collection_consistency.py`(集合名三侧同源守卫)
- `tests/unit/worker/test_memory_sync_outbox_worker.py`
- `tests/unit/worker/test_runtime_profile_projection.py`(消费端兜底 5 用例)
**修改**
- `app/core/config.py`(**删除 `milvus_collection` 配置项**,见 §3 的集合名契约)
- `app/infrastructure/vector_memory.py`(`search()` 支持按 `customer_id` 下推过滤)
- `app/service/memory_recall_service.py`(语义检索传 `customer_id`)
- `app/service/projection_cleanup_service.py`(清理改引用 `PROFILE_COLLECTION`)
- `app/service/profile_generation_service.py`(取值改小写、payload 加 `memory_sources` 与 `profile_version`)
- `app/repository/profile_repository.py`(`active_memories()`)
- `app/service/agent/bootstrap.py`(`get_milvus_profile_vector_client()`)
- `app/worker/runtime.py`(`consume_profile_projections()`、`_with_memory_sources()` 兜底、`run_once` 接线)
- `app/model/risk_questionnaire.py`(**修重复定义**:改为 re-export `app.model.profile` 的 `ProfileSnapshot`,见 §6.2)
- `tests/unit/service/test_profile_generation_service.py`(+2 用例、断言改引用常量)
- `AGENTS.md`(新增 `memory_sync_outbox` 取值口径与 Windows 中文输出两条易错点;校正测试基线/mypy 数字)