画像投影链路接通 + 合并主干(PR #7)并对齐两套投影实现 #9

Open
wangjianlong_0626 wants to merge 10 commits from NL_develop into qyqy_develop
Owner

交付内容

1. 接通画像投影链路(此前零消费者)

  • 新建 Milvus 集合 user_long_term_memory_v1 + MilvusProfileProjection + MilvusProfileVectorClient + MemorySyncOutboxWorker + 建集合工具
  • WorkerRuntime.consume_profile_projections() 接入 run_once():milvus → 向量投影;neo4j → 复用主干 ProfileGraphProjectionService(方案 A,不引入第二套图投影)
  • 生产端从 memory_unit 组装 memory_sources;写入外部存储前经 conversation_privacy 脱敏

2. 订正 memory_sync_outbox 取值口径(此前静默失效)

原写法照 docs/00 §6.4.6 用大写 MILVUS/NEO4J + 中文 待处理,而消费端按 target_store 的值分派 handler、且只领 status in {"pending","failed"} —— 两个条件都不满足 ⇒ 事件任何消费者都领不到、永久滞留且不报错(唯一键 (event_uuid, target_store) 对大小写无约束,MySQL 也不报错)。

已改为全仓一致的小写英文,并加契约回归测试(断言大写值分派不到 handler、会进死信)。

3. 修掉移植带来的两处必炸点

原实现 后果
if not isinstance(customer_id, int) 本仓所有生产者都写 str(customer_id) ⇒ 每个事件必然失败、重试 5 次进死信
不可投影的 memory_key 直接 raise 受控词表 13 个键有 7 个(constraint:*/profile:*)不满足前缀 ⇒ 一条 constraint: 记忆毒死该客户整批

现改为:接受纯数字字符串;不可投影键跳过并留痕。

4. 消费端 memory_sources 缺失兜底

投顾线两处生产者的 payload 没有该字段,原本每次画像变更都会死信。现缺失时回退查询该客户 memory_unit 有效记忆并记 warning(使"谁没提供"保持可见)。兜底不掩盖格式错:键存在但类型不对时不兜底,仍失败关闭。

5. 修掉 3 个主干同样存在的继承缺陷

(PR #7 之后 tests/integration 未整套复跑,故均未被发现)

  1. tools/seed_test_rbac.py 少建 review_t(9004) —— 两个集成测试都依赖它。且用户↔角色绑定用 zip(user_ids, role_ids, strict=True) 按位置配对,隐含"USERS 与 ROLES 一一对应";一加不绑角色的账号就 ValueError、整个种子跑不完(commit() 在最后 ⇒ 外部表现是"什么都没发生")。已改为显式配对表。
  2. CustomerProfileCandidateService._write_profile_snapshot 漏写 current_customer_id —— 该列不是生成列而是普通可空列 + 唯一键 uk_profile_snapshot_current,不写则唯一键形同虚设(多个 NULL 不冲突);旧当前版本也没清该列、补写就会撞键。
  3. 集成测试前置未记录 —— 13 个登录/RBAC 用例因 401 而红,实为"测试账号不存在"。已在 AGENTS.md 记明。

6. 独立缺陷:profile_snapshots 被两个 ORM 类重复映射

app/model/profile.py 与 app/model/risk_questionnaire.py 各有一个 ProfileSnapshot 映射同一张表。单独导入各自都没事,同时导入即抛 InvalidRequestError —— Worker 既要重建画像又要处理投顾问卷,真的会被打挂(库里留下 last_error='InvalidRequestError' 的行)。已修为 re-export。


需要评审的 5 处取舍

完整推演见 docs/39-主干合并对策记录.md。

  1. risk_questionnaire.py 去重复定义 → re-export(与主干另一份提交内容完全一致,两边独立做了同一修复)
  2. seed_test_rbac.py 加 review_t + 改显式配对
  3. _write_profile_snapshot 补 current_customer_id
  4. customer_service.py:COMPANY 取主干的「奶龙基金责任有限公司」(本项目实际品牌名);HOTLINE/SERVICE_HOURS 取本线修复 —— 主干仍是占位符 400-XXX-XXXX,不修则同一客服给客户两个不同号码(安全路由出口给真号、兜底出口给假号)
  5. app/worker/__main__.py 删除 memory_sync_handlers 接线 —— 否则两套消费者读同一个 memory_sync_outbox,而两者的 neo4j handler 不同(一套用 ZSY 的 Neo4jProfileProjection 按客户各建私有节点,一套用主干服务写共享 tag 节点)⇒ 同一事件被谁领到结果不定,等于同一事实在图里两种说法,正是方案 A 要避免的状态

副作用:app/infrastructure/neo4j_profile_projection.py 因此失去生产引用(它与方案 A 天然互斥,换哪种做法都一样)。已在文件头加 .. warning:: 写明未装配、原因、启用前提 —— 去留待决定。


验证(合并主干后实测)

项 结果
pytest tests(全量) 2 failed, 1415 passed, 2 skipped
pytest tests/integration 102 passed, 1 skipped
mypy app Success: no issues found in 245 source files
tools/audit_schema.py 89 张业务表无缺失/意外(未改动任何表结构)
tools/check_authoritative_docs.py 53 份文档无编号冲突
tools/check_docs_endpoint_ids.py 62 个端点编号无重复
tools/check_rbac_seed_consistency.py 通过

那 2 个失败是既有环境项(test_offsite_document_recognition_adapter.py 断言请求体里的中文原文,而 httpx 序列化成 \uXXXX),非本次引入。

真机验证:真实 embedding(1024 维) + 真实 Milvus 写入并回读通过;消费端失败分支与成功分支(含跳过不可投影键、手机号脱敏)均实测通过。


遗留(详见 docs/39 §6)

  1. 投顾线两处生产者仍缺 memory_sources —— 本线已有消费端兜底(不再死信),根治待投顾线补或明确"这两个来源是否也要投影长期记忆"
  2. neo4j_profile_projection.py 去留待定
  3. docs/00 §6.4.6 取值栏与实现不一致 —— 按既定裁定未改基线文档,实际口径记在 docs/37

文档

  • 新增 docs/37-记忆投影链路实现说明.md(实现说明 + 根因 + 契约 + 验证证据)
  • 新增 docs/38-架构对齐-记忆与画像投影链路.md(方案 A 等决定的决策依据)
  • 新增 docs/39-主干合并对策记录.md(本次合并逐文件取舍)
  • AGENTS.md 新增三条易错点:memory_sync_outbox 取值口径、一张表只能有一个 ORM 类、Windows 中文输出乱码的正确命令(-X utf8)
  • 文档编号:主干已占 29–36,本线两份文档让号至 37、38
## 交付内容 ### 1. 接通画像投影链路(此前**零消费者**) - 新建 Milvus 集合 `user_long_term_memory_v1` + `MilvusProfileProjection` + `MilvusProfileVectorClient` + `MemorySyncOutboxWorker` + 建集合工具 - `WorkerRuntime.consume_profile_projections()` 接入 `run_once()`:`milvus` → 向量投影;`neo4j` → **复用主干 `ProfileGraphProjectionService`**(**方案 A**,不引入第二套图投影) - 生产端从 `memory_unit` 组装 `memory_sources`;写入外部存储前经 `conversation_privacy` 脱敏 ### 2. 订正 `memory_sync_outbox` 取值口径(此前**静默失效**) 原写法照 `docs/00` §6.4.6 用大写 `MILVUS`/`NEO4J` + 中文 `待处理`,而消费端**按 `target_store` 的值分派 handler**、且**只领 `status in {"pending","failed"}`** —— 两个条件都不满足 ⇒ 事件任何消费者都领不到、**永久滞留且不报错**(唯一键 `(event_uuid, target_store)` 对大小写无约束,MySQL 也不报错)。 已改为全仓一致的小写英文,并加**契约回归测试**(断言大写值分派不到 handler、会进死信)。 ### 3. 修掉移植带来的两处必炸点 | 原实现 | 后果 | |---|---| | `if not isinstance(customer_id, int)` | 本仓**所有**生产者都写 `str(customer_id)` ⇒ 每个事件必然失败、重试 5 次进死信 | | 不可投影的 `memory_key` 直接 `raise` | 受控词表 13 个键有 7 个(`constraint:*`/`profile:*`)不满足前缀 ⇒ 一条 `constraint:` 记忆**毒死该客户整批** | 现改为:接受**纯数字字符串**;不可投影键**跳过并留痕**。 ### 4. 消费端 `memory_sources` 缺失兜底 投顾线两处生产者的 payload 没有该字段,原本每次画像变更都会死信。现缺失时回退查询该客户 `memory_unit` 有效记忆并**记 warning**(使"谁没提供"保持可见)。**兜底不掩盖格式错**:键存在但类型不对时不兜底,仍失败关闭。 ### 5. 修掉 3 个**主干同样存在**的继承缺陷 (PR #7 之后 `tests/integration` 未整套复跑,故均未被发现) 1. `tools/seed_test_rbac.py` 少建 `review_t`(9004) —— 两个集成测试都依赖它。**且**用户↔角色绑定用 `zip(user_ids, role_ids, strict=True)` 按位置配对,隐含"USERS 与 ROLES 一一对应";一加不绑角色的账号就 `ValueError`、**整个种子跑不完**(`commit()` 在最后 ⇒ 外部表现是"什么都没发生")。已改为显式配对表。 2. `CustomerProfileCandidateService._write_profile_snapshot` 漏写 `current_customer_id` —— 该列**不是生成列**而是普通可空列 + 唯一键 `uk_profile_snapshot_current`,不写则唯一键形同虚设(多个 NULL 不冲突);旧当前版本也没清该列、补写就会撞键。 3. 集成测试前置未记录 —— 13 个登录/RBAC 用例因 401 而红,实为"测试账号不存在"。已在 `AGENTS.md` 记明。 ### 6. 独立缺陷:`profile_snapshots` 被两个 ORM 类重复映射 `app/model/profile.py` 与 `app/model/risk_questionnaire.py` 各有一个 `ProfileSnapshot` 映射同一张表。单独导入各自都没事,**同时导入即抛** `InvalidRequestError` —— Worker 既要重建画像又要处理投顾问卷,**真的会被打挂**(库里留下 `last_error='InvalidRequestError'` 的行)。已修为 re-export。 --- ## 需要评审的 5 处取舍 完整推演见 `docs/39-主干合并对策记录.md`。 1. `risk_questionnaire.py` 去重复定义 → re-export(**与主干另一份提交内容完全一致**,两边独立做了同一修复) 2. `seed_test_rbac.py` 加 `review_t` + 改显式配对 3. `_write_profile_snapshot` 补 `current_customer_id` 4. `customer_service.py`:`COMPANY` 取主干的「奶龙基金责任有限公司」(本项目实际品牌名);**`HOTLINE`/`SERVICE_HOURS` 取本线修复** —— 主干仍是占位符 `400-XXX-XXXX`,不修则**同一客服给客户两个不同号码**(安全路由出口给真号、兜底出口给假号) 5. `app/worker/__main__.py` **删除** `memory_sync_handlers` 接线 —— 否则**两套消费者读同一个 `memory_sync_outbox`**,而两者的 `neo4j` handler 不同(一套用 ZSY 的 `Neo4jProfileProjection` 按客户各建私有节点,一套用主干服务写共享 tag 节点)⇒ 同一事件被谁领到结果不定,等于**同一事实在图里两种说法**,正是方案 A 要避免的状态 > **副作用**:`app/infrastructure/neo4j_profile_projection.py` 因此失去生产引用(它与方案 A 天然互斥,换哪种做法都一样)。已在文件头加 `.. warning::` 写明未装配、原因、启用前提 —— 去留待决定。 --- ## 验证(合并主干后实测) | 项 | 结果 | |---|---| | `pytest tests`(全量) | `2 failed, 1415 passed, 2 skipped` | | `pytest tests/integration` | `102 passed, 1 skipped` | | `mypy app` | `Success: no issues found in 245 source files` | | `tools/audit_schema.py` | 89 张业务表无缺失/意外(**未改动任何表结构**) | | `tools/check_authoritative_docs.py` | 53 份文档无编号冲突 | | `tools/check_docs_endpoint_ids.py` | 62 个端点编号无重复 | | `tools/check_rbac_seed_consistency.py` | 通过 | 那 2 个失败是**既有环境项**(`test_offsite_document_recognition_adapter.py` 断言请求体里的中文原文,而 httpx 序列化成 `\uXXXX`),**非本次引入**。 **真机验证**:真实 embedding(1024 维) + 真实 Milvus 写入并回读通过;消费端失败分支与成功分支(含跳过不可投影键、手机号脱敏)均实测通过。 --- ## 遗留(详见 `docs/39` §6) 1. 投顾线两处生产者仍缺 `memory_sources` —— 本线已有消费端兜底(不再死信),根治待投顾线补或明确"这两个来源是否也要投影长期记忆" 2. `neo4j_profile_projection.py` 去留待定 3. `docs/00` §6.4.6 取值栏与实现不一致 —— 按既定裁定未改基线文档,实际口径记在 `docs/37` --- ## 文档 - 新增 `docs/37-记忆投影链路实现说明.md`(实现说明 + 根因 + 契约 + 验证证据) - 新增 `docs/38-架构对齐-记忆与画像投影链路.md`(方案 A 等决定的决策依据) - 新增 `docs/39-主干合并对策记录.md`(本次合并逐文件取舍) - `AGENTS.md` 新增三条易错点:`memory_sync_outbox` 取值口径、一张表只能有一个 ORM 类、Windows 中文输出乱码的正确命令(`-X utf8`) - 文档编号:主干已占 29–36,本线两份文档让号至 37、38
wangjianlong_0626 added 15 commits 2026-09-12 14:09:10 +08:00
合并结果:**零冲突**(自动合并 21 文件 / +1430 行)。合并后 HEAD = origin/qyqy_develop = 76e87a3,
两边完全一致(rev-list 双向均为 0)。

架构师这轮做的事(我这边此前没有):
- f09ea9e 把我的 NL_develop 并进主干(**第二父就是我的 58849ad,我这条线全部提交已在主干里**)
- 3029d0c 补发画像工具白名单完成(release 254 active)+ release-state 证据
- 76e87a3 AGENTS.md 表数口径 51 → 68(并指向我写的 docs/28)
- f60915b 补声明 aiosqlite(同事那 6 个用例在干净环境会 ModuleNotFoundError)
- b5b0680 修掉我留下的 3 个 mypy 错(全在同事的场外邮件模块,各一行、不动逻辑)
- 6c09cde/8268646 新增补发画像白名单脚本(dry-run + 合并前防呆),并发现两个既有发布脚本会丢提示词
- 新增文档:他的评审意见与答复(docs/NL_develop交付说明-评审意见.md 等)、
  docs/evidence/knowledge-collections.json、tools/probe_* 两个探针

**他抓到了一个我漏声明的依赖**:`python-docx` —— 我的 `document_parser.py` 用它解析 .docx,
但 pyproject.toml / requirements.txt 里没有,换干净环境跑知识入库会直接
`ModuleNotFoundError: No module named 'docx'`。已随合并进来(本机原本恰好装了,所以本地没暴露)。

本次我只改 AGENTS.md 的基线口径(合并把过期的 mypy/测试数字带回来了):
- 测试基线 1034 → **1219 passed / 3 failed**,并逐条说明那 3 个失败都不是代码缺陷
  (1 个既有空集缺陷 + 2 个 httpx 中文序列化的环境相关)
- mypy 从"181 个错、双方不可比、未装 sqlalchemy2-stubs"改为
  **`Success: no issues found in 180 source files`(0 错)**,并附四组复现矩阵说明真因是
  SQLAlchemy 补丁版旧(**明确写上"不要装 sqlalchemy2-stubs"**,那是 1.4 的包)

门禁全绿:测试 1219 passed / 文档守卫 37 份无重号 / 结构审计 68 张表 / mypy 0 错 /
迁移 head 一致 / aiosqlite·python-docx·python-pptx 均已声明且已安装。
两个都是"代码里存在但没接对"的缺陷,都不是新功能。

## A1 客服热线在代码里有两个值(一个出口给假号码)

- `app/core/customer_service_rules.py:35` `CONTACT_PHONE = "15936583816"` ← 真号码,安全路由 6 处在用
- `app/service/agent/implementations/customer_service.py:155` `HOTLINE = "400-XXX-XXXX"` ← 占位符,兜底出口在用

后果:**同一个客服给客户两个不同的电话号码**。问"风险等级怎么划分"被安全路由处理时给真号码;
问一个知识库答不了的问题走兜底时给 `400-XXX-XXXX` —— 客户按这个号码永远打不通。

修法:`HOTLINE` / `SERVICE_HOURS` 改为**转发** `customer_service_rules` 的两个常量
(不是"改成相同的值",而是引用同一对象,避免日后再次漂移);工作时间也随之从
"每日 7:00-22:00" 统一为 "工作日 09:00-18:00"(与安全路由出口一致)。
新增守卫测试用 `is` 断言对象同一性 —— 值相等挡不住"两边各写一份恰好相同"的漂移。

## A2 `transfer_required` 既没落库也没出参

`docs/05` §6.3 一直规定 `GET /agent-runs/{run_id}` 的 `result` 里有
`transfer_required` / `transfer_reason`,但实现里两个都没有:前端判断"这轮要不要转人工"
只能靠**猜正文里有没有兜底话术的开头**(`docs/24` 自己把这称为权宜之计)。

- 写入侧:`conversation_message` **没有** `transfer_required` 列,加列要迁移且规则 4 禁止改既有
  字段定义 ⇒ 放进 `tool_calls` 这个现成 JSON 列,作为 `calls` 的兄弟键
  (`{"calls": [...], "transfer_required": bool, "transfer_reason": str|None}`)
- 读取侧:`RunQueryService.get` 取出来放进 `result`;**兼容历史行**(`calls` 裸列表 / None →
  按 False/None 处理,不抛异常、也不凭正文猜)

刻意**没做**的一半:`docs/05` §6.3 的 `result` 里还有 `degraded` / `degradation_reason`,
但 `CoreResult` 里根本没有这两个字段(降级信息目前只在工具出参里)—— 补它要改
`CoreResult` 并让各 Agent 传递降级状态,属另一个改动范围。**已在交付说明里注明这一半仍缺。**

## 真机验证

| 问题 | transfer_required | transfer_reason | 正文电话 |
|---|---|---|---|
「请介绍一下量子纠缠在基金估值中的应用」 | **True** | 置信度不足:score=0.571 gap=0.004 | 15936583816 ✅ |
「请帮我计算一下三体问题的数值解」 | **True** | 置信度不足:score=0.499 gap=0.011 | 15936583816 ✅ |
「基金申购后多久确认」(正常知识直返) | False | — | 无(正确) |
「你们公司明天会下雪吗」(闲聊出口) | False | — | 无(正确) |

(第一次我用"下雪"当兜底用例,结果它被闲聊出口正确接住了 —— 是我的期望值写错,不是代码问题。)

门禁:测试 1223 passed(新增 4 个用例)/ 3 failed(均为已知非代码缺陷)/ mypy 0 错。
合并 ZSY 分支前必须先做的对齐。核对结论与"线没接"的旧说法**不一致**:

**主干那条链其实已经接好了**——架构师在 09-10 21:52 的 d7f6ef7 就做完了"记忆→画像→图全自动触发",
走的是 `domain_event_outbox` + `profile.rebuild_requested`,**已注册进 runtime.py 的 handler 白名单**:
  agent.run_completed → memory.extraction_requested → MemoryExtractionService(提升 user_facts)
  → profile.rebuild_requested → ProfileAssemblyService 重建画像 + ProfileGraphProjectionService 投影 Neo4j

而 ZSY 走的是**另一条 outbox**(`memory_sync_outbox` + 自建 MemorySyncOutboxWorker)。
⇒ 两条平行链做同一件事,但 outbox、画像生成方式、消费装配都不同。

顺带修正一处旧结论:`graph_projection_worker.py` 确实**零实例化**(架构师说的对),
但由此推论"整条链没接"不对——接的是 handler 字典,不是那个类;那个类是死代码。

文档内容:
- §1 主干链的完整链路与引入者、现场数据(user_facts 0 行 / memory_sync_outbox 2 行是**我的**生产者写的)
- §2 ZSY 的**真正增量**逐文件列表(只有 3 个是主干完全没有的:Milvus 画像投影适配器、
  memory_sync_outbox worker、画像候选复核流程)
- §3 四个必须对齐的分歧(两条 outbox 的分工 / 直接快照 vs 候选复核 / 两套客服 Agent / ZSY 落后 144 提交)
- §4 三步收口方案(先只移植投影适配器,再定 outbox 分工,其余等裁决)
- §5 给架构师与张胜宇的四个问题
- §6 明确不做的事(不整体合并、不改已验链路)

文档守卫通过;本文只做核对,未改任何代码。
投顾线(bbf623a)带进两份文档,与既有的撞号:
  21-投顾Agent迁移TODO.md          → 30-(原 21 已被《风控业务第二版迁移清单》占用)
  22-投顾Agent灰度与回滚操作手册.md  → 31-(原 22 已被《四大Agent流程实现拆解》占用)
处理原则与 docs/27 一致:**让新来的那份让号**(既有那份被更多地方引用)。
同时修了 30 号文档里两处指向旧号的自引用。守卫恢复通过(40 份无重号)。

另含 docs/29-架构对齐-记忆与画像投影链路.md(合并 ZSY 分支前必须做的对齐,
结论见该文档:主干那条链**其实已经接好**,走的是 domain_event_outbox + profile.rebuild_requested;
ZSY 走的是另一条 memory_sync_outbox,两条平行不冲突,但需要先明确分工)。
背景:memory_sync_outbox 这条链此前**完全没有消费者**,且生产端照 docs/00 §6.4.6
写成大写 MILVUS/NEO4J + 中文「待处理」,而消费端按 target_store 的**值**分派 handler、
且只领 status in {pending, failed} —— 两个条件都不满足,事件任何消费者都领不到、
永久滞留且不报错(唯一键 (event_uuid, target_store) 对大小写无约束,MySQL 也不报错)。
根因是代码与测试都硬编码字面量,所以测试跟着一起错、谁也没拦住。

订正
- profile_generation_service:取值改为全仓一致的小写(milvus/neo4j/upsert/pending)
- 测试改为引用常量并断言消费端契约,不再硬编码(硬编码是本次跑偏的直接原因)
- 新增契约回归测试:断言大写值分派不到 handler、会进死信,谁改回大写立刻红
- 新增 tools/normalize_memory_sync_outbox.py:订正历史脏行(默认 dry-run、幂等)

接通投影链路(此前零消费者)
- 新增 Milvus 集合 user_long_term_memory_v1 及建集合工具(幂等、不覆盖已有集合)
- 新增 MilvusProfileProjection / MilvusProfileVectorClient,并修掉移植带来的两处必炸点:
  customer_id 由「必须 int」放宽为接受数字字符串(本仓所有生产者都写 str,
  不放宽则每个事件必然失败);不可投影的 memory_key 由「整批 raise」改为跳过留痕
  (否则一条 constraint: 记忆毒死该客户整批,而受控词表 13 个键里有 7 个不满足前缀)
- 新增 MemorySyncOutboxWorker(领取/指数退避/死信骨架保留原样)并接入 WorkerRuntime
- milvus → 向量投影;neo4j → 复用主干 ProfileGraphProjectionService(方案 A,
  不引入第二套投影,避免同一事实在图中两种说法、违反主干既有的只投影已确认事实的不变式)
- 生产端从 memory_unit(status=active) 组装 memory_sources,随事件带上确定快照
- 前置移植 conversation_privacy:写外部存储前脱敏手机号/证件号/银行卡等

验证
- 新增 17 个单测;全量 2 failed, 1307 passed, 2 skipped
  (2 个失败为既有环境项:断言请求体中文原文而 httpx 序列化成 \uXXXX,非本次引入)
- mypy app → 0 错(227 文件);audit_schema → 89 张业务表无缺失/意外,未改动表结构
- 真机:真实 embedding(1024 维) + 真实 Milvus 写入并回读通过
- 整合链路(测试记忆 → 生产端组装 → outbox → 消费端投递 → Milvus 回读)通过,
  且 MySQL 已回滚、Milvus 无残留

文档
- 新增 docs/32-记忆投影链路实现说明.md:真实口径、根因、契约与验证证据(供接手)
- AGENTS.md:新增该易错点;新增 Windows 中文输出乱码的正确命令(-X utf8);
  校正测试基线与 mypy 文件数

未做:未改 docs/00 基线、未动数据库迁移、未改投顾线代码、未启动常驻 Worker。
遗留:投顾线两处生产者的 payload 缺 memory_sources,会被消费至死信,待架构师确认是否投影。
架构师线 2026-09-12 推送的 `docs/29-Agent组员登录接口使用说明.md` 占用了 29,
与本线的《架构对齐 · 记忆→画像→图》重号。按"以架构师为主线"的口径,本线让号。

- `git mv docs/29-架构对齐-记忆与画像投影链路.md docs/33-架构对齐-记忆与画像投影链路.md`
  (用 mv 而非增删,保留文件历史)
- 该文档开头补"⚠️ 后续(2026-09-12)"提示:结论**已落地实施**,指向
  `docs/32-记忆投影链路实现说明.md`;避免接手人按原文的"本文只做核对与建议,
  不改任何代码"误判为尚未实施。同时记录让号原因,避免编号来历不明
- 文档内部无自引用编号、全仓无 `docs/29` 引用(已全量搜索确认),故无引用需改

验证
- `tools/check_authoritative_docs.py` → checked 41 documents, no number collision
- docs 编号现为 30(投顾迁移TODO) / 31(投顾灰度) / 32(记忆投影链路) / 33(架构对齐)

说明:架构师分支上 `docs/21`、`docs/22` 各自有两份(投顾线带入的重号),
本线已在 c82e989 改号为 30/31,合并后该重号自动消除,无需额外处理。
## 1. 独立缺陷:`profile_snapshots` 被两个 ORM 类重复映射

排查 `memory_sync_outbox` 中 `target_store='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`
无需改动。原定义多映射的 `current_customer_id` 经全仓核查无人使用,故不保留
(`app.model.profile` 明确注明该列由数据库维护、故意不映射)。

## 2. 消费端兜底 `memory_sources`

`memory_sources` 是本线新增的投影入参,而投顾线两处生产者的 payload
(`{customer_id, profile_uuid, version, profile}`)没有这个键,原样会导致它们每次画像
变更都 `memory_sources is invalid` → 重试至死信。

新增 `WorkerRuntime._with_memory_sources()`:**键缺失或为 None** 时回退查询该客户
`memory_unit` 中 `status='active'` 的记忆,并记 warning(使"谁没提供"保持可见)。
语义成立:长期记忆是**客户级**而非画像版本级的,每条记忆自带 `version`,
适配器按 `memory_uuid + version` 幂等,故"用的是哪一版"仍确定。

**兜底不掩盖真错误**:键**存在但格式不对**时**不兜底**,原样交给适配器失败关闭。
实现上用键存在性判断而非 `isinstance`——后者会把"缺失"与"格式错"混为一谈,
那是初版实现里的一个真 bug,被新测试抓出后修正。

## 3. 补上此前欠缺的消费端全路径验证

此前"整合验证"是直接调适配器,跳过了 outbox 的领取→分派→状态更新。

- **失败分支**(Milvus 断开时实测):行被领取、按 target_store 分派、异常被捕获、
  `status`/`retry_count`/`last_error`/`next_retry_at` 正确落库。
- **成功分支**(注入替身向量客户端):outbox 行 → `processed`、`processed_at` 已写;
  不可投影的 `constraint:` 被跳过(只写 1 行);维度 1024;字符串客户号转 int;
  **手机号脱敏生效**(`稳健型投资者,手机号 [手机号已隐藏] 请勿外泄`)。
- **兜底实证**:历史行 `id=5`(payload 无 `memory_sources`)经兜底后成功投递为 `processed`。
- **修复实证**:`id=6` 的 `last_error` 从 `InvalidRequestError` 变为
  `RecoverableAgentError`(图库不可用)——证明重复定义缺陷确已消除,剩下的是环境问题。

## 4. 测试与验证

- 新增 `tests/unit/worker/test_runtime_profile_projection.py`(5 用例:已提供原样透传、
  缺失兜底、空记忆给空列表而非删键、无客户号不兜底、格式错不兜底)
- 全量:`2 failed, 1312 passed, 2 skipped`(2 个失败为既有环境项,非本次引入)
- mypy:`Success: no issues found in 227 source files`
- 表结构审计:89 张业务表无缺失/意外(未改动任何表结构)
- 文档守卫:41 份文档无编号冲突

## 5. 文档

`docs/32-记忆投影链路实现说明.md` 增补 §6.1(兜底)、§6.2(重复定义缺陷)、
§7.1(消费端全路径验证)并更新验证表与文件清单;
`AGENTS.md` 新增"一张表只能有一个 ORM 类"易错点、校正测试基线数字。

## 未做

未改 `docs/00` 基线、未动数据库迁移、未改投顾线生产者代码、未启动常驻 Worker。
遗留:投顾线两处生产者的 payload 仍缺 `memory_sources`(已有兜底,不再死信,
但根治应由投顾线确认);Milvus/Neo4j 容器本轮不可用(Docker Desktop 崩溃),
`id=6` 停在 failed 属环境不可用、非代码缺陷。
主干 `qyqy_develop` 已占用 29–36(29 登录接口 / 30 投顾迁移TODO / 31 投顾灰度 /
32 平台侧交接与联调准备 / 33–35 ZSY 底座扩展确认往来 / 36 PR7 合并记录),
本线原用的 32、33 与之重号。按"以架构师为主线"继续让号。

- `docs/32-记忆投影链路实现说明.md`       → `docs/37-记忆投影链路实现说明.md`
- `docs/33-架构对齐-记忆与画像投影链路.md` → `docs/38-架构对齐-记忆与画像投影链路.md`
  (两次 `git mv`,保留文件历史)
- 同步更新 `AGENTS.md` 内 2 处引用、`docs/38` 内 2 处交叉引用
- 文档内部无自引用编号;全仓已无其它 `docs/32`、`docs/33` 引用(全量搜索确认)

验证:`tools/check_authoritative_docs.py` → checked 41 documents, no number collision
共同祖先 bbf623a;主干 54 个提交、118 个文件;本线 25 个文件;9 个冲突文件。
主干这次把 **ZSY 的整条投影实现合进来了(PR #7)**,而本线此前的提交正是
移植并修正同一套代码 —— 因此冲突的本质是"同一功能两份实现并存",取舍错了会把
已修好的缺陷又带回来。逐项取舍与理由见 `docs/39-主干合并对策记录.md`。

## 取舍(9 个冲突)

取本线:
- `app/infrastructure/milvus_profile_projection.py` —— 主干是 ZSY 原版,含两处必炸点:
  ① `customer_id` 要求 int 而本仓所有生产者都写 `str` ⇒ 每个事件必然失败;
  ② 不可投影的 `memory_key` 直接 raise ⇒ 一条 `constraint:` 记忆毒死整客户整批。
  本线版已放宽为「接受纯数字字符串」与「跳过并留痕」。
- `memory_sync_outbox_worker.py` / `conversation_privacy.py` / `risk_questionnaire.py`
  —— 代码逐行一致,仅注释与说明文字详略不同(`risk_questionnaire.py` 两边**独立做了
  完全相同的修复**,都改成 re-export `app.model.profile`)。
- 两个投影测试文件 —— 本线是他那份的**超集**(4→10、4→5 例,包含他全部用例)。

两边合并:
- `app/worker/runtime.py`:`__init__` 两边各加一个参数,都要。
- `app/service/agent/implementations/customer_service.py`:import 取并集;
  `COMPANY` 取主干的「奶龙基金责任有限公司」("奶龙"是本项目实际品牌名,主干多处出现),
  `HOTLINE`/`SERVICE_HOURS` **取本线的修复**(主干仍是占位符 `400-XXX-XXXX`,
  本线已改为引用 `customer_service_rules` 的唯一来源 —— 这是 A1 缺陷修复,
  否则同一客服给客户两个不同号码)。
- `AGENTS.md`:表数/Agent 清单取主干(90/89、7 个 Agent),本线的
  `-X utf8` 与两条 outbox 易错点保留,测试基线按合并后实测重算。

## 消费端只保留一套(本次最重要的一处)

合并后曾出现**两套消费者读同一个 `memory_sync_outbox`**:`__main__.py`(PR #7)
与 `runtime.consume_profile_projections()`(本线),而**两者的 neo4j handler 不同**
—— 前者用 ZSY 的 `Neo4jProfileProjection`(按客户各建私有节点),
后者用主干 `ProfileGraphProjectionService`(共享 tag 节点、只投影已确认事实)。
同一事件被谁领到结果不定,等于"同一事实在图里有两种说法",正是**方案 A 要避免的状态**。

现只保留 runtime 那一套(带 `memory_sources` 兜底、neo4j 复用主干服务),
删除 `__main__.py` 的重复接线;装配入口职责仍在该文件(注入 `relationships` /
`projection_cleaner`),Milvus 客户端由 `bootstrap` 工厂惰性构造、缺配置时显式降级。

副作用:`app/infrastructure/neo4j_profile_projection.py` 不再被生产代码引用,成为
**死代码**(本线未删,属架构师线,其单测仍在)—— 待架构师决定删或明确分工。

## 顺带修掉的 3 个继承缺陷(主干同样存在,PR #7 后未整套复跑故未发现)

1. `tools/seed_test_rbac.py` **少建 `review_t`(9004)账号** —— 两个集成测试都依赖它
   ("账号存在但无权限应返回 200 空集而非 404"、`PLACEHOLDER_ACCOUNTS`)。
   同时把用户↔角色绑定从 `zip(..., strict=True)` 改为**显式配对表**:原写法隐含
   "USERS 与 ROLES 一一对应",一加不绑角色的账号就 ValueError、整个种子跑不完
   (commit 在最后,外部表现是"什么都没发生")。
2. `CustomerProfileCandidateService._write_profile_snapshot` **漏写 `current_customer_id`**
   —— 该列不是生成列而是普通可空列 + 唯一键 `uk_profile_snapshot_current`,
   不写则唯一键形同虚设(多个 NULL 不冲突),且旧当前版本也没清该列、补写就会撞键。
   现旧值置 None、新值显式写入(与 `ProfileGenerationService._clear_current` 一致)。
3. 集成测试前置未记录 —— 13 个登录/RBAC 用例因 401 而红,实为"测试账号不存在",
   跑 `seed_test_rbac.py` + `set_user_password.py` 后转绿;已在 `AGENTS.md` 记明,
   避免被误判成代码缺陷。

## 文档

- 新增 `docs/39-主干合并对策记录.md`(逐文件取舍 + 理由 + 遗留)
- `docs/37` 订正一处过时说法:曾写 `current_customer_id` 无人使用且故意不映射,
  实际 `app/model/profile.py` 已映射且有人使用(详见该文档 §6.2 的订正块)
- 文档编号:主干已占 29–36,本线两份文档让号至 `docs/37`、`docs/38`

## 验证(合并后实测)

- `pytest tests`(全量)→ `2 failed, 1396 passed, 2 skipped`
- `pytest tests/integration` → `102 passed, 1 skipped`(修上述 1、2 后从 15 failed 归零)
- `mypy app` → `Success: no issues found in 245 source files`
- `tools/audit_schema.py` → 89 张业务表无缺失/意外(未改动任何表结构)
- `tools/check_authoritative_docs.py` → 52 份文档无编号冲突
- `tools/check_rbac_seed_consistency.py` → 通过

那 2 个失败是既有环境项(`test_offsite_document_recognition_adapter.py` 断言请求体
中文原文而 httpx 序列化成 `\uXXXX`),与本次合并无关。
该适配器与主干 `ProfileGraphProjectionService` 是同一件事的两套实现,对图的建模不同:
本模块按客户各建**私有** `Preference`/`goal` 节点、数据源是 `memory_unit`;
主干服务写**共享** tag 节点、数据源是 `user_facts` 且只投影已确认事实。

## 它不是"本来就没被装配"

| 提交 | 事件 |
|---|---|
| `f167390`(ZSY) | 新建该适配器 |
| `5e848f5`(ZSY) | `feat: wire neo4j projection into worker` —— 在 `__main__.py` 装配,此后一直是**活的** |
| `4d8edb4`(主干) | PR #7 合并后接线仍在,**仍是活的** |
| `57677f6`(本次合并) | 主动摘掉那段装配 ⇒ 失去生产引用 |

`__main__.py` 在本次合并中并没有冲突(git 自动取的是带接线的主干版本),
是本线解决完冲突后**主动手工删除**的。

## 但根本原因是它与方案 A 互斥

只要落实方案 A,它就必然失去引用 —— "删 `__main__.py` 接线、保留 runtime 那套"与
"保留 `__main__.py` 骨架、把它的 neo4j handler 换成主干服务"两种做法结果相同。
所以这不是方案 A 的副作用,而是"两套图投影本来就只能活一套"。

## 改动

- `app/infrastructure/neo4j_profile_projection.py` 文件头加 `.. warning::`:
  写明当前未被生产装配、为什么、其单测保护的是**模块自身契约**而非"已装配",
  以及**启用前提** —— 必须先决定"图的节点模型以谁为准",只加回 `__main__.py` 装配
  会重新变成两套图投影并存。
- `docs/39-主干合并对策记录.md` §3.3 补完整时间线与上述论证;§6 第 1 条改为准确表述。
- **保留文件**(实现本身完整:`MERGE` 幂等、按 `profile_version` 判重不被旧版本覆盖、
  写入前经 `sanitize_customer_service_message` 脱敏),去留待架构师定:
  删除 / 保留为参考实现(当前取此)/ 反过来改用它(则方案 A 需重议)。

验证:`mypy app` → 245 文件 0 错;该模块 4 个单测通过;文档守卫 53 份无编号冲突。
wangjianlong_0626 added 1 commit 2026-09-12 14:11:43 +08:00
## 现象(2026-09-12 跑真实 Worker 时发现)

`episode_worker` 反复报 `模型记忆抽取输出不是有效 JSON` 并重试到失败:

```
pydantic_core.ValidationError: Input should be a valid dictionary or instance of _ExtractionPayload
  input_value=[{'memory_key': None, 'value': None, 'memory_type': None, 'confidence': 0}]
  input_type=list
```

## 根因

契约是**对象** `{...}`,而模型在 episode 抽取路径会返回**单元素数组** `[{...}]`。
`_parse` 直接 `json.loads` 后交给 pydantic,数组自然过不了 `model_validate`,
于是被归入"输出不是有效 JSON"这一条 —— 但它其实是**合法的空结果**
(`_validate` 已能把"三字段为 null 且 confidence=0"正确识别为"无持久事实",返回 None)。

后果:这类 episode 白跑一遍模型调用、重试到 `retry_count` 上限后判失败,
**该片段的记忆永远抽不出来**。

## 修法

`_parse` 里只对"**恰好一个对象**的数组"做归一化:

- `[{...}]` → 取 `{...}`(空结果照常返回 None;有事实照常解析)
- 多元素数组、元素非对象、空数组 → **不猜**,仍交给校验失败关闭
  (多元素时无法判断哪个是答案,猜错会把错误记忆写进库,比失败更糟)

## 测试

`tests/unit/service/test_memory_extraction_service.py` 追加 2 个用例:

- `test_single_element_array_is_normalized`:数组包空结果 → None;数组包有事实 → 正常解析
- `test_multi_element_or_non_dict_array_still_fails_closed`:多元素 / 非对象元素 / 空数组 → 仍失败

## 验证

- `pytest tests/unit/service/test_memory_extraction_service.py tests/unit/worker/test_memory_extraction_worker.py` → 28 passed
- `mypy app` → 0 错 / 245 文件

> 说明:`memory_extraction_service.py` 属主干线代码。此处是从**实际运行日志**里发现的
> 健壮性缺陷,改动限定在输出形状归一化,不改变抽取契约与校验规则。
wangjianlong_0626 added 1 commit 2026-09-12 14:20:53 +08:00
## 现象(2026-09-12 端到端跑通后查数据时发现)

客户 9102 的画像快照状态**彼此矛盾**:

| version | is_current | current_customer_id |
|---|---|---|
| 2 | `0` | `9102` ← 清旧时没清空 |
| 3 | `1` | `NULL` ← 建新时没写入 |

## 根因

`ProfileAssemblyService._write_snapshot` 把 `current_customer_id` **当成了生成列**:

- 方法 docstring 原文写着"唯一键 `uk_profile_snapshot_current` 建立在**生成列** `current_customer_id` 上"
- 因此两处都不赋值(以为 DB 会自动填)

**但该列不是生成列** —— `alembic/baseline_generated.sql` 与真实库都是**普通可空列 + 唯一键**,
`app/model/profile.py` 的模块 docstring 第 2 条已明确:"当前版本必须由写入方**显式写入**客户 ID
(历史版本写 NULL),才能保证「每个客户最多一条当前快照」"。

后果:唯一键**形同虚设**(多个 NULL 不冲突)⇒ 不变式失效;且旧版本残留的值
一旦与新版本补上的值相同,就会**直接撞唯一键**。

> 这与本线先前修的 `CustomerProfileCandidateService._write_profile_snapshot` 是**同一个缺陷的另一处**
> —— 当时只找到一处,这次是靠真实链路跑出数据后核对才暴露出来。

## 改动

`app/service/profile_assembly_service.py`:

- 旧版本:`is_current = False` 的同时 `current_customer_id = None`
- 新版本:`is_current=True` 的同时 `current_customer_id=customer_id`
- 订正方法 docstring 的错误认知("生成列"→ 普通可空列 + 唯一键),并写明后果

## 已有数据订正

新增 `tools/fix_profile_snapshot_current.py`(**默认 dry-run**、幂等、`--apply` 才提交):

1. 先清空 `is_current=0` 却残留值的行
2. 再补写 `is_current=1` 却是 NULL 的行
3. **顺序要紧**:反过来的话第 2 步会与残留值撞唯一键

本机实测:清空 1 行、补写 1 行,复核两类异常均归零。

## 验证

- `mypy app` → 0 错 / 245 文件
- `pytest tests`(全量)→ `2 failed, 1418 passed, 1 skipped`
  (2 个失败为既有环境项:httpx 把中文序列化成 `\uXXXX`,非本次引入)
- 端到端:真实对话 → 记忆抽取 → `memory_unit` 落库已实测通过(客户 9102
  `preference:risk_level = "低风险"`,候选态)
wangjianlong_0626 added 2 commits 2026-09-12 14:26:47 +08:00
## 1. 补回被我丢失的门禁条目

主干 `AGENTS.md` 原本有一条 `ruff` 干净,但我在解决 `AGENTS.md` 冲突时**只保留了自己的
mypy/pytest 基线,把这条丢了** —— 合并时丢信息,和代码冲突一样是缺陷。

本次补回,并把命令写准(这点很重要):

- `ruff check app tests tools alembic` → **`All checks passed`(0 错)**
- 直接 `ruff check .`(全仓)会报 **40 个错,全部来自仓库根目录的 `hq.py` / `nl2sql_yc.py`**
  (袁聪线的演示脚本,不属本项目包结构)

所以"ruff 干净"**必须带范围**,否则会和别人的脚本混在一起、把一个健康状态误报成 40 个错。

## 2. 我的一个真实疏漏(已由架构师修正,本次合并带入)

架构师提交 `ffbcc22`:**`fix: 删掉 NL 合并后残留的未使用变量 role_ids(ruff F841)`**

该疏漏是我引入的:改 `tools/seed_test_rbac.py` 时把
`zip(user_ids, role_ids, strict=True)` 换成显式配对表 `USER_ROLES`,删掉了 `user_ids`
却**没删 `role_ids`**。根因是**我全程没跑过 ruff** —— 项目门禁里有它,
只跑 mypy 和 pytest 是不够的。

已在本机复跑 `ruff check app tests tools alembic` 确认:我改过的文件全部干净
(`role_ids` 已随主干修正进来)。

## 3. 合并主干 6 提交

`fedbf5a..c8cdc06`,含上条修正与投顾线的行情双源、验收归档等,**无冲突**。

## 验证

- `ruff check app tests tools alembic` → `All checks passed`
- `mypy app` → 0 错 / 245 文件
- `pytest tests`(全量)→ `2 failed, 1421 passed, 1 skipped`
  (2 个失败为既有环境项:httpx 把中文序列化成 `\uXXXX`,非本次引入)
wangjianlong_0626 added 1 commit 2026-09-12 14:29:13 +08:00
wangjianlong_0626 added 1 commit 2026-09-12 14:35:52 +08:00
Author
Owner

换到你的环境跑之前,有 4 条前置(第 1 条是我这边新引入的)

最终验收在你机器上跑,所以把"环境数据"的部分单独列出来 —— 这些不随代码合并,
缺了会产生看起来像代码缺陷的失败(正如你在 docs/32 §5.1/§5.2 反复强调的那类)。

# 前置 命令 / 检查 缺了的后果
1 建画像向量集合(本 PR 新增,你环境一定没有) python -X utf8 tools/setup_milvus_profile_collection.py load_collection 抛 RecoverableAgentError("画像向量集合不可用") ⇒ memory_sync_outbox 的 milvus 行失败重试到死信。是环境问题,不是代码缺陷
2 Milvus 可达 docker ps 见 milvus-standalone healthy;确认 MILVUS_LOCAL_URI 为空 同 1;MILVUS_LOCAL_URI 被设过会指向本地 Lite 文件、查的是另一个库(你已在 AGENTS.md 记过)
3 embedding 端点 发布配置里有 task_type=embedding 的端点 _profile_embed 抛"没有可用的 embedding 端点",事件同样重试到死信
4 Redis 密码与容器一致 本机容器是 --requirepass 123456,而 .env 的 REDIS_URL 原本没带密码 每次连接抛 AuthenticationError,表现为整片功能静默降级(只打 WARNING):短期会话记忆、记忆召回热缓存、config_release 缓存失效、登录限流

第 4 条我本机已修(REDIS_URL=redis://:123456@127.0.0.1:6379/0),但 .env 被 .gitignore,
不进仓库
—— 你那边如果也是同一个容器启动方式,需要同样补一下。
这一条你 docs/32 §5.1 已经列为前置了,我这里只是给出具体的失配点。


我这边的环境差异(供你比数字时参考,不是缺陷)

项 你的环境 我这边
pytest tests/unit tests/contract 1317 passed / 0 failed —
pytest tests(全量) — 2 failed, 1421 passed, 1 skipped
pytest tests/integration 104 passed 102 passed, 1 skipped
mypy app 245 文件 0 错 一致
ruff ruff check app tests tools 干净 ruff check app tests tools alembic 干净(我多含 alembic)

那 2 个 failed 就是你在 docs/32 §5.2 记为环境相关的
test_offsite_document_recognition_adapter.py(httpx 把中文序列化成 \uXXXX)——
你已经写明"主干架构师环境复现不了、不要为了让它绿去动实现",我这里确认同一个现象,没有动实现。

另:全仓 ruff check . 会报 40 个错,全部来自根目录 hq.py / nl2sql_yc.py(袁聪线脚本),
我已在 AGENTS.md 里把门禁命令写清为 ruff check app tests tools alembic,避免下一个人把它读成"项目 40 个错"。


另外:我在你环境之外跑通了端到端,可作为你验收的对照基线

之前本线的证据都停在组件级(我用替身客户端验证)。这次起了真实常驻 Worker,跑出:

episodes: 0 → 50          (其中 49 个"已完成")
domain_event_outbox 积压: 845 → 0(published 1136)
memory_unit: 0 → 1        ← 真实对话 → 抽取 → 落库

那 1 条来自一次真实对话(客户 9102,含明确偏好陈述):
preference:risk_level = "低风险"、status=candidate(候选态,按设计需审核后才提升为 user_facts)。

顺带在这次真实运行里发现并修掉 3 个缺陷(都在本 PR 内):

  1. memory_extraction_service._parse 不接受单元素数组 [{...}] —— 模型在 episode 路径
    就是这么返回的,被记成"不是有效 JSON"并重试到失败,而它其实是合法空结果
  2. ProfileAssemblyService._write_snapshot 把 current_customer_id 当成生成列(连 docstring
    都那么写),导致清旧不清、建新不写 ⇒ 唯一键 uk_profile_snapshot_current 完全失效
    (实测数据是"旧版本留着值、新版本是 NULL")。附数据订正工具 tools/fix_profile_snapshot_current.py
  3. 我自己的一处疏漏:改 seed_test_rbac.py 时漏删 role_ids(ruff F841)—— 已由你的
    ffbcc22 修掉并合入,我这里致谢;根因是我只跑了 mypy + pytest、没跑 ruff

我已知、且已记录的遗留(不阻塞本 PR)

  1. 投顾线两处生产者的 payload 仍缺 memory_sources(本线已在消费端加兜底,不再死信;根治待投顾线)
  2. app/infrastructure/neo4j_profile_projection.py 因方案 A 失去生产引用,已在文件头加
    .. warning:: 说明"未装配/原因/启用前提",去留待你定
  3. docs/00 §6.4.6 的取值栏与实现不一致(按既定裁定未改基线文档,实际口径记在 docs/37)
## 换到你的环境跑之前,有 4 条前置(第 1 条是我这边新引入的) 最终验收在你机器上跑,所以把"环境数据"的部分单独列出来 —— 这些**不随代码合并**, 缺了会产生**看起来像代码缺陷**的失败(正如你在 `docs/32` §5.1/§5.2 反复强调的那类)。 | # | 前置 | 命令 / 检查 | 缺了的后果 | |---|---|---|---| | **1** | **建画像向量集合**(本 PR 新增,你环境一定没有) | `python -X utf8 tools/setup_milvus_profile_collection.py` | `load_collection` 抛 `RecoverableAgentError("画像向量集合不可用")` ⇒ `memory_sync_outbox` 的 `milvus` 行**失败重试到死信**。**是环境问题,不是代码缺陷** | | 2 | Milvus 可达 | `docker ps` 见 `milvus-standalone` healthy;确认 `MILVUS_LOCAL_URI` **为空** | 同 1;`MILVUS_LOCAL_URI` 被设过会指向本地 Lite 文件、查的是另一个库(你已在 `AGENTS.md` 记过) | | 3 | embedding 端点 | 发布配置里有 `task_type=embedding` 的端点 | `_profile_embed` 抛"没有可用的 embedding 端点",事件同样重试到死信 | | 4 | **Redis 密码与容器一致** | 本机容器是 `--requirepass 123456`,而 `.env` 的 `REDIS_URL` **原本没带密码** | 每次连接抛 `AuthenticationError`,表现为**整片功能静默降级**(只打 WARNING):短期会话记忆、记忆召回热缓存、`config_release` 缓存失效、登录限流 | > 第 4 条我本机已修(`REDIS_URL=redis://:123456@127.0.0.1:6379/0`),但 **`.env` 被 `.gitignore`, > 不进仓库** —— 你那边如果也是同一个容器启动方式,需要同样补一下。 > 这一条你 `docs/32` §5.1 已经列为前置了,我这里只是给出具体的失配点。 --- ## 我这边的环境差异(供你比数字时参考,不是缺陷) | 项 | 你的环境 | 我这边 | |---|---|---| | `pytest tests/unit tests/contract` | 1317 passed / 0 failed | — | | `pytest tests`(全量) | — | `2 failed, 1421 passed, 1 skipped` | | `pytest tests/integration` | 104 passed | `102 passed, 1 skipped` | | `mypy app` | 245 文件 0 错 | 一致 | | `ruff` | `ruff check app tests tools` 干净 | `ruff check app tests tools alembic` 干净(我多含 alembic) | 那 2 个 failed 就是你在 `docs/32` §5.2 记为**环境相关**的 `test_offsite_document_recognition_adapter.py`(httpx 把中文序列化成 `\uXXXX`)—— 你已经写明"主干架构师环境复现不了、不要为了让它绿去动实现",**我这里确认同一个现象,没有动实现**。 另:全仓 `ruff check .` 会报 40 个错,**全部来自根目录 `hq.py` / `nl2sql_yc.py`**(袁聪线脚本), 我已在 `AGENTS.md` 里把门禁命令写清为 `ruff check app tests tools alembic`,避免下一个人把它读成"项目 40 个错"。 --- ## 另外:我在你环境之外**跑通了端到端**,可作为你验收的对照基线 之前本线的证据都停在组件级(我用替身客户端验证)。这次起了真实常驻 Worker,跑出: ``` episodes: 0 → 50 (其中 49 个"已完成") domain_event_outbox 积压: 845 → 0(published 1136) memory_unit: 0 → 1 ← 真实对话 → 抽取 → 落库 ``` 那 1 条来自一次**真实对话**(客户 9102,含明确偏好陈述): `preference:risk_level = "低风险"`、`status=candidate`(候选态,按设计需审核后才提升为 `user_facts`)。 顺带在这次真实运行里发现并修掉 3 个缺陷(都在本 PR 内): 1. `memory_extraction_service._parse` 不接受**单元素数组** `[{...}]` —— 模型在 episode 路径 就是这么返回的,被记成"不是有效 JSON"并重试到失败,而它其实是**合法空结果** 2. `ProfileAssemblyService._write_snapshot` 把 `current_customer_id` **当成生成列**(连 docstring 都那么写),导致清旧不清、建新不写 ⇒ **唯一键 `uk_profile_snapshot_current` 完全失效** (实测数据是"旧版本留着值、新版本是 NULL")。附数据订正工具 `tools/fix_profile_snapshot_current.py` 3. 我自己的一处疏漏:改 `seed_test_rbac.py` 时漏删 `role_ids`(ruff `F841`)—— 已由你的 `ffbcc22` 修掉并合入,我这里致谢;根因是我**只跑了 mypy + pytest、没跑 ruff** --- ## 我已知、且已记录的遗留(不阻塞本 PR) 1. 投顾线两处生产者的 payload 仍缺 `memory_sources`(本线已在消费端加兜底,不再死信;根治待投顾线) 2. `app/infrastructure/neo4j_profile_projection.py` 因方案 A 失去生产引用,已在文件头加 `.. warning::` 说明"未装配/原因/启用前提",去留待你定 3. `docs/00` §6.4.6 的取值栏与实现不一致(按既定裁定未改基线文档,实际口径记在 `docs/37`)
Author
Owner

合并就绪(已追平主干,2026-09-12 更新):

  • NL_develop 相对 qyqy_develop:BEHIND 0、AHEAD 7
  • 干净 fast-forward(主干已在 NL_develop 历史里)⇒ 无冲突、无需解冲突
  • 刚追平你 c200a61..5634fdc 那 3 个提交(advisor 演示引导 / 风控登录权限与模型能力配置),
    与本线无文件重叠、合并无冲突;tools/seed_test_rbac.py 双方都改过,Git 自动合上,
    合并后 check_rbac_seed_consistency.py 仍通过
  • 本 PR 对主干的净变化只有 4 个文件(其余改动早已在主干上,不重复):
文件 说明
app/service/profile_assembly_service.py current_customer_id 漏写修复 —— 这条请重点看,它是画像写入主路径,我把该列从"当生成列"改成"显式写入/清空"
tools/fix_profile_snapshot_current.py 新增数据订正工具(默认 dry-run、幂等)
AGENTS.md 补回 ruff 基线(含正确的检查范围)
docs/37-记忆投影链路实现说明.md 新环境前置清单

合并前请再 git fetch 一次确认(这条线今天动得勤)。上面那条长评论是环境前置与数字差异。

**合并就绪**(已追平主干,2026-09-12 更新): - `NL_develop` 相对 `qyqy_develop`:**BEHIND 0**、AHEAD 7 - **干净 fast-forward**(主干已在 `NL_develop` 历史里)⇒ **无冲突、无需解冲突** - 刚追平你 `c200a61..5634fdc` 那 3 个提交(advisor 演示引导 / 风控登录权限与模型能力配置), 与本线**无文件重叠**、合并无冲突;`tools/seed_test_rbac.py` 双方都改过,Git 自动合上, 合并后 `check_rbac_seed_consistency.py` 仍通过 - 本 PR 对主干的**净变化只有 4 个文件**(其余改动早已在主干上,不重复): | 文件 | 说明 | |---|---| | `app/service/profile_assembly_service.py` | **`current_customer_id` 漏写修复** —— 这条**请重点看**,它是画像写入主路径,我把该列从"当生成列"改成"显式写入/清空" | | `tools/fix_profile_snapshot_current.py` | 新增数据订正工具(默认 dry-run、幂等) | | `AGENTS.md` | 补回 ruff 基线(含正确的检查范围) | | `docs/37-记忆投影链路实现说明.md` | 新环境前置清单 | 合并前请再 `git fetch` 一次确认(这条线今天动得勤)。上面那条长评论是环境前置与数字差异。
wangjianlong_0626 added 2 commits 2026-09-12 14:50:10 +08:00
主干 `c200a61..5634fdc` 3 个提交(advisor 演示环境引导、风控登录权限与模型能力配置),
与本线 5 个提交**无文件重叠**,合并**无冲突**(`tools/seed_test_rbac.py` 双方都改过,
Git 自动合上;合并后 `check_rbac_seed_consistency.py` 仍通过)。

追平后本线相对主干恢复 **BEHIND 0**,PR #9 可重新 fast-forward 合并。

## 验证(合并后重跑)

- `ruff check app tests tools alembic` → `All checks passed`
- `mypy app` → 0 错 / 245 文件
- `pytest tests`(全量)→ `2 failed, 1424 passed, 1 skipped`
  (2 个失败为既有环境项:httpx 把中文序列化成 `\uXXXX`,非本次引入)
- `check_authoritative_docs.py` → 53 份文档无编号冲突
- `check_rbac_seed_consistency.py` → 通过(种子 id 唯一、各 grant 脚本与种子逐条一致)
- `check_docs_endpoint_ids.py` → §19 端点编号无重复
- `audit_schema.py` → 89 张业务表无缺失/意外
wangjianlong_0626 added 1 commit 2026-09-12 16:40:45 +08:00
wangjianlong_0626 added 1 commit 2026-09-12 17:24:42 +08:00
## 1. 新增客户权益(用户端)

`GET /api/v1/users/me/entitlements`(T010,权限 `benefit:read:self`):

- **层级**由 `fin_customer_profile.total_asset` **实时判定**
  (门槛来自 `knowledge/product/高净值客户服务规范.md`:
  金卡 50 万 / 白金 200 万 / 钻石 600 万 / 私行 1000 万;低于 50 万为普通客户);
- **权益按层级累积展开**(文档原文"含全部下级权益,新增以下"):
  金卡 9 条 / 白金 20 / 钻石 33 / 私行 54,各档已逐档实测;
- 返回**升级提示**(`next_tier`:下一层级与门槛),前端可直接渲染"再投 X 元升级"。

### 新增表 `fin_customer_benefit`(1 张)

层级 → 权益目录,54 条种子数据(`tools/seed_customer_benefits.py`,按 `benefit_code` 幂等)。

**基线合规证明**(规则 1/3/4):只新增这一张表;**未**重命名/删除任何已有表;
**未**重命名/删除/复用任何已有字段,**未**改任何已有字段的类型、可空性或业务含义;
未改 `docs/00`。
复核:`tools/audit_schema.py` → `90 business tables, no missing or unexpected tables`。

### 两条设计取舍

1. **不落"某客户享有哪些权益"**:层级可算,权益由层级推出,两者都不落库。
   与 `docs/00` L159(不保留 `net_worth_flag`,因为可算)同一取向。
2. **权益只存各层新增条目**,累积由服务层 `tier_chain()` 展开 ——
   否则改一条权益要改四处,漏一处就出现"白金没有金卡权益"。

### 数据来源与一处刻意省略

逐条照抄知识文档,不新增文档里没有的权益。**私行那条
「7×24小时私人银行专线:400-XXX-XXXX 转 8」不写号码** ——
文档里是占位符,而对客号码的唯一来源是 `customer_service_rules.CONTACT_PHONE`
(本线此前修过"同一客服给客户两个不同号码"的缺陷)。把占位符抄进库等于再造一份假号码。

## 2. 修投顾迁移契约里写死的断言

`tests/unit/test_advisor_migration_contract.py` 原先断言

```python
assert script.get_heads()[0] == "20260911_merge_adv_risk_heads"
```

那是"投顾迁移刚加完那一刻"的快照 —— 本 PR 一新增迁移(`20260912_customer_benefit`)
它就变红,**而红的原因与投顾链的对错无关**:断言测到的是时间,不是契约。

原意是"投顾链接在这条主链上、没另起分支"。改为断言**投顾链尾是当前 head 的祖先**
(链尾从 `ADVISOR_FILES[-1]` 派生,不写死),既保住原意又不受后续迁移影响。
`len(script.get_heads()) == 1`(链不分叉)与"投顾文件首尾相接"两条原样保留。

## 3. 顺带发现的既有缺口(**不在本次改动范围**)

`app/api/controllers/trading.py` 的 **T001–T009 未调用 `AuthorizationService.require`**:
`docs/05` §19 为它们登记了权限码(`account:read:self` / `trade:order:*` / `holding:read:self`),
但代码只做认证 + 开户测评门槛,**没有执行 RBAC 权限检查**。
对照:仓库里 **26 个 service** 都调了 `require`,`trade_service` 不在其中。

本线的 T010 **按正确做法实现**:`CustomerBenefitService.entitlements_for` 先鉴权再读数据,
且**鉴权在读取客户资产之前**(有测试断言"拒绝时未查库")。
T001–T009 如何补,需架构师定口径后另行处理。

## 4. 文档

- 新增 `docs/41-客户权益功能说明.md`:表登记 + 基线合规证明 + 分层口径 + 累积规则 +
  数据来源 + 权限 + 与仪表盘的关系 + 上述缺口
- `docs/05` §19 登记 T010,并**单独注明它引入了新表**(避免被误读为
  "T 段数据库零变更"的一部分)
- `AGENTS.md` 表数 89 → **90** 张业务表

## 验证

- `pytest tests/unit/service/test_customer_benefit_service.py` → **20 passed**
  (含边界:499999.99 不是金卡、500000 整是金卡、1000 万整是私行;累积条数;升级提示;
  鉴权先于读数据)
- 全量 `pytest tests` → `2 failed, 1469 passed, 1 skipped`
  (2 个失败为既有环境项:httpx 把中文序列化成 `\uXXXX`,非本次引入)
- `ruff check app tests tools alembic` → `All checks passed`
- `mypy app` → **0 错 / 252 文件**
- 真机:`GET /users/me/entitlements` → `200`;各档分层与累积条数逐档实测通过
- `audit_schema.py` → 90 张业务表无缺失/意外;文档守卫 55 份无编号冲突;
  端点编号无重复;RBAC 种子一致性通过
wangjianlong_0626 added 1 commit 2026-09-12 17:32:30 +08:00
真机实测到的缺陷:`tools/seed_sim_account_demo.py` 的 `_upsert_market_price()`
在"当天已有行"时直接 `return`,于是同一天重跑种子**不刷新 `source_updated_at`**。
行情是时效数据,过期后 `FundQuoteService` 返回
`503 FUND_QUOTE_UNAVAILABLE:产品 510300 行情已过期`,
连带 `T001` 仪表盘与 `T006` 持仓一起不可用(`T010` 权益不查行情,仍 200)。

表现极具误导性:**刚灌完种子能用,过十几分钟就 503** ——
看起来像行情适配器或缓存故障,实际根因在种子脚本。归因过程:
`memory_sync` / Redis / Milvus 全部正常,`fin_market_price` 里当天那行
`source_updated_at` 停在首次灌入时刻。

修法:把列值抽成共享 `values` 字典,当天已有行时 `update` 刷新全部行情列
(含 `source_updated_at`),不存在才 `insert`。
**幂等的正确含义是"不产生重复行"(`(product_id, trade_date)` 唯一),
不是"不更新值"。** 账户/持仓的"已存在则跳过"保持不变 ——
那是业务数据,不该被种子覆盖。

验证(本机,测试客户 9001 `cust_t`):
- `python -X utf8 -m tools.seed_sim_account_demo --customer-id 9001` 正常
- `GET /api/v1/users/me/account/dashboard` → 200(修前 503)
- `GET /api/v1/users/me/holdings` → 200
- `GET /api/v1/users/me/entitlements` → 200

门禁:ruff `app tests tools alembic` 全过;`mypy app` 0 错(252 文件);
`audit_schema.py` 90 张业务表无差异;文档编号/端点编号/RBAC 种子一致性全过。
Author
Owner

补一个修:tools/seed_sim_account_demo.py 的当日行情不刷新(已推 NL_develop 1f62aca)

真机验收时 T001 仪表盘返回 503 FUND_QUOTE_UNAVAILABLE:产品 510300 行情已过期,
T006 持仓同样 503,而 T010 权益 200。排查结论在种子脚本里:

_upsert_market_price() 当天已有行时直接 return,所以同一天重跑种子不刷新 source_updated_at。
行情是时效数据(FundQuoteService 按 source_updated_at 判新鲜度),于是表现为
「刚灌完种子能用、过十几分钟就 503」—— 看着像行情适配器或缓存故障,实际与它们无关。
fin_market_price 里当天那行的时间戳停在首次灌入时刻,是判定依据。

修法:列值抽成共享 values,已有行就 update 刷新(含 source_updated_at),无行才 insert;
账户/持仓的「已存在则跳过」不动(业务数据不该被种子覆盖)。
幂等 = 不产生重复行((product_id, trade_date) 唯一),不是「不更新值」。

改后验证(本机 9001 cust_t):dashboard 200 / holdings 200 / entitlements 200;
ruff check app tests tools alembic 全过、mypy app 0 错(252 文件)、pytest tests 与本线基线一致
(2 failed, 1469 passed, 1 skipped,2 个仍是 test_offsite_document_recognition_adapter
的中文转义环境差异,按 docs/32 §5.2 不改实现)。

⚠️ 顺便提醒:这条坑会让「灌完种子立刻验收」和「过一会儿再验收」结论不同,
建议 docs/32 或验收清单里注明「灌完种子立刻跑,或验收前先重跑一次种子」。


另,两个路径口径(前端接入时容易猜错,已用 app.routes 实测列出):

  • GET /api/v1/users/me/entitlements —— 权益(T010),不在 /account/ 下、也不带 /benefit/ 前缀
  • GET /api/v1/users/me/holdings —— 持仓,是 /users/me/ 直属,不是 /users/me/account/holdings

用户端现有 13 个 GET/POST 端点(memory-profile、memory-candidates 及其决策、
account/dashboard、orders 及其详情/撤单、holdings、transactions 及其详情、cash-ledger、entitlements)。
经确认产品(列表/详情/行情)在用户端没有任何 HTTP 端点,且 fin_product 除演示种子外为空 ——
如果前端需要「产品列表/详情/行情」这几屏,需要新增端点,目前只能靠 Agent 工具兜。

### 补一个修:`tools/seed_sim_account_demo.py` 的当日行情不刷新(已推 NL_develop `1f62aca`) 真机验收时 `T001` 仪表盘返回 `503 FUND_QUOTE_UNAVAILABLE:产品 510300 行情已过期`, `T006` 持仓同样 503,而 `T010` 权益 200。排查结论在**种子脚本**里: `_upsert_market_price()` 当天已有行时直接 `return`,所以**同一天重跑种子不刷新 `source_updated_at`**。 行情是时效数据(`FundQuoteService` 按 `source_updated_at` 判新鲜度),于是表现为 「刚灌完种子能用、过十几分钟就 503」—— 看着像行情适配器或缓存故障,实际与它们无关。 `fin_market_price` 里当天那行的时间戳停在首次灌入时刻,是判定依据。 修法:列值抽成共享 `values`,已有行就 `update` 刷新(含 `source_updated_at`),无行才 `insert`; 账户/持仓的「已存在则跳过」不动(业务数据不该被种子覆盖)。 **幂等 = 不产生重复行(`(product_id, trade_date)` 唯一),不是「不更新值」。** 改后验证(本机 9001 `cust_t`):`dashboard` 200 / `holdings` 200 / `entitlements` 200; `ruff check app tests tools alembic` 全过、`mypy app` 0 错(252 文件)、`pytest tests` 与本线基线一致 (`2 failed, 1469 passed, 1 skipped`,2 个仍是 `test_offsite_document_recognition_adapter` 的中文转义环境差异,按 `docs/32` §5.2 不改实现)。 ⚠️ 顺便提醒:**这条坑会让「灌完种子立刻验收」和「过一会儿再验收」结论不同**, 建议 `docs/32` 或验收清单里注明「灌完种子立刻跑,或验收前先重跑一次种子」。 --- 另,两个路径口径(前端接入时容易猜错,已用 `app.routes` 实测列出): - `GET /api/v1/users/me/entitlements` —— 权益(T010),**不在 `/account/` 下、也不带 `/benefit/` 前缀** - `GET /api/v1/users/me/holdings` —— 持仓,是 `/users/me/` 直属,**不是** `/users/me/account/holdings` 用户端现有 13 个 `GET/POST` 端点(memory-profile、memory-candidates 及其决策、 account/dashboard、orders 及其详情/撤单、holdings、transactions 及其详情、cash-ledger、entitlements)。 经确认**产品(列表/详情/行情)在用户端没有任何 HTTP 端点**,且 `fin_product` 除演示种子外为空 —— 如果前端需要「产品列表/详情/行情」这几屏,需要新增端点,目前只能靠 Agent 工具兜。
This pull request has changes conflicting with the target branch.
  • AGENTS.md
  • app/main.py
  • app/service/profile_assembly_service.py
  • docs/05-接口文档.md
  • docs/37-记忆投影链路实现说明.md
  • tests/unit/test_advisor_migration_contract.py
  • tools/seed_sim_account_demo.py
  • tools/seed_test_rbac.py
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin NL_develop:NL_develop
git checkout NL_develop
Sign in to join this conversation.