Files
group_fqcd_jr/docs/39-主干合并对策记录.md
wangjianlong_0626 67ba1b8eee docs: 标明 neo4j_profile_projection 当前未被生产装配(方案 A 取舍)及启用前提
该适配器与主干 `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 份无编号冲突。
2026-09-12 13:23:43 +08:00

12 KiB
Raw Permalink Blame History

合并对策记录:NL_develop ← qyqy_develop(PR #7 后)

日期:2026-09-12|合并对象:origin/qyqy_develop @ 4d8edb4|共同祖先:bbf623a


0. 为什么需要这份记录

主干这次带来的 54 个提交里包含一项关键事实:架构师已把 ZSY 的整条投影实现合进主干(PR #7), 而本线此前的几个提交正好是移植并修正同一套代码。因此这次合并的冲突不是"文本冲突", 而是同一功能的两份实现并存——取舍错了会把已经修好的缺陷又带回来。

冲突文件 9 个、共同祖先到两边的改动面:本线 25 个文件 / 主干 118 个文件。


1. 逐文件取舍

文件 取舍 理由
app/infrastructure/milvus_profile_projection.py 取本线 主干是 ZSY 原版,含两处必炸点(见 §2);本线版是"原版 + 两处放宽 + 脱敏 + 日志"
app/worker/memory_sync_outbox_worker.py 取本线 代码逐行一致,仅注释/docstring 详略不同
app/core/conversation_privacy.py 取本线 语义完全一致(纯格式差异:docstring 详略、括号换行、空行)
app/model/risk_questionnaire.py 取本线 两边独立做了完全相同的修复(都改成 re-export app.model.profile),代码部分一字不差,仅说明文字中/英不同
tests/unit/infrastructure/test_milvus_profile_projection.py 取本线 本线是他那份的超集(他 4 例 / 本线 10 例,包含他全部 4 例)
tests/unit/worker/test_memory_sync_outbox_worker.py 取本线 同上(他 4 例 / 本线 5 例,包含他全部 4 例)
app/service/agent/implementations/customer_service.py 两边合并 见 §3:import 取并集;公司名取主干、热线取本线
app/worker/runtime.py 两边合并 __init__ 参数两边各加一个,都要
AGENTS.md 两边合并 表数/Agent 清单取主干、-X utf8 与两条 outbox 易错点取本线、测试基线按合并后实测重算

2. 主干上仍然存在的两处必炸点(本线修正的价值所在)

主干 milvus_profile_projection.py 是 ZSY 原版,未修:

代码 后果
if not isinstance(customer_id, int) or customer_id <= 0 本仓所有生产者都写 str(customer_id) ⇒ 每个事件必然 ValueError、重试 5 次进死信
raise ValueError("memory key is not projectable") 受控词表 13 个键有 7 个(constraint:*/profile:*)不满足前缀 ⇒ 一条 constraint: 记忆毒死该客户整批

本线版改为:接受纯数字字符串、不可投影键跳过并留痕。

另外主干 profile_generation_service.py 的取值仍是大写 MILVUS/NEO4J + 中文 待处理, 而消费端只领 {"pending","failed"}、按小写键分派 ⇒ 主干这条链同样是静默失效的。 本线已改为小写并加契约回归测试守着。


3. 需要人判断的三处取舍(本线已按"以架构师为主线 + 方案 A"决定)

3.1 COMPANY 取主干的「奶龙基金责任有限公司」

本线旧值是 "南方科技"(早期占位)。"奶龙"是本项目的实际品牌名,出现在主干多处 (customer_service_rules.py 的对外话术、风控配置、静态页等),故取主干值。

3.2 HOTLINE / SERVICE_HOURS 取本线的修复(不取主干)

主干仍是占位符 "400-XXX-XXXX"(本线修前的状态)。

本线的修复是把它们改为引用 customer_service_rules.CONTACT_PHONE(真号码 15936583816) 与 CONTACT_HOURS。这是本线 A1 缺陷的修复:常量各写一份必然漂移, 后果是同一个客服给客户两个不同的电话号码(安全路由出口给真号、兜底出口给假号), 客户按假号码永远打不通。有单测守着(HOTLINE is CONTACT_PHONE 的同一性断言)。

3.3 消费端只保留一套 —— 删掉 app/worker/__main__.py 里的重复接线

这是本次合并最重要的一处。合并后曾出现两套消费者读同一个 memory_sync_outbox:

位置 milvus handler neo4j handler
__main__.py(主干/PR #7) MilvusProfileProjection ZSY 的 Neo4jProfileProjection(按客户各建私有节点)
runtime.py(本线) MilvusProfileProjection + 兜底 主干的 ProfileGraphProjectionService(共享 tag 节点、只投影已确认事实)

两套都领同一个队列、neo4j 的 handler 却不同 ⇒ 同一事件被谁领到结果不定, 等于"同一事实在图里会有两种说法"——正是方案 A 要避免的状态。

取舍:只保留 runtime.consume_profile_projections() 那一套,删掉 __main__.py 的接线。理由:

  1. 它带 memory_sources 缺失兜底(投顾线两处生产者不发该字段,否则每次画像变更都死信);
  2. 它的 neo4j 复用主干 ProfileGraphProjectionService —— 落实方案 A(你已确认);
  3. 装配入口的职责仍留在 __main__.py(注入 relationships 与 projection_cleaner); Milvus 客户端由 bootstrap.get_milvus_profile_vector_client() 惰性构造、缺配置时显式降级 —— 与 runtime.py 自己写明的"由组装层注入、不在此兜底"口径一致。

随之失去生产引用的文件:app/infrastructure/neo4j_profile_projection.py(ZSY 那套)。

必须说清楚这不是"它本来就是死代码",时间线如下:

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

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

但更根本的原因是:它与方案 A 天然互斥。 该文件本身就是"第二套图投影", 只要落实方案 A,它就必然失去引用 —— 换哪种做法都一样 ("删 __main__.py 接线、保留 runtime 那套"与"保留 __main__.py 骨架、把它的 neo4j handler 换成主干服务"两种做法,结果相同)。所以这不是方案 A 的副作用, 而是"两套图投影本来就只能活一套"。

本线的处理:保留文件(实现本身完整:MERGE 幂等、按 profile_version 判重、 写入前脱敏),并在其文件头加 .. warning:: 写明"当前未被生产装配、为什么、 以及启用前必须先决定图的节点模型以谁为准";其单测继续跑,但保护的是模块自身契约, 不代表它已被装配。

待架构师决定:

  1. 删除该文件 + 其单测(它承载的是被否决的方案,留着可能被误读为"可用实现");
  2. 保留为参考实现(本线当前取此);
  3. 或反向 —— 若认为该用它而非主干服务,则"方案 A"需要重新讨论(这已超出本线能定的范围)。

4. 合并过程中一并修掉的 3 个继承缺陷(主干上同样存在)

这三处都是主干带进来的、集成测试能证明的缺陷(架构师说明过 tests/integration 在 PR #7 之后未整套复跑,所以没被发现):

4.1 tools/seed_test_rbac.py 少了 review_t 账号

tests/integration/test_rbac_read_mysql.py 断言 /api/v1/admin/users/9004/roles 返回 200 + username == "review_t" + roles == []("账号存在但无权限"应返回空权限集而非 404), test_auth_login_mysql.py 的 PLACEHOLDER_ACCOUNTS 也包含它 —— 但种子从未创建 9004。

(后者因"账号不存在时登录同样返回 401"而恰好蒙过,前者则一直红。)

修:USERS 加 (9004, "T-REVIEW", "review_t", "employee"),不绑角色(正是它要覆盖的场景)。

同时把用户↔角色绑定从 zip(user_ids, role_ids, strict=True) 改为显式配对表 USER_ROLES: 原写法隐含"USERS 与 ROLES 一一对应",一加不绑角色的账号就 ValueError, 整个种子跑不完(而 commit() 在最后,外部表现是"什么都没发生")。

4.2 customer_profile_candidate_service._write_profile_snapshot 漏写 current_customer_id

profile_snapshots 的 current_customer_id 不是生成列,而是普通可空列 + 唯一键 uk_profile_snapshot_current(app/model/profile.py 的模块 docstring 第 2 条明确说明)。 该处创建当前版本时只写了 is_current=True,没写 current_customer_id:

  • 唯一键形同虚设(多个 NULL 不冲突)⇒「每个客户最多一条当前快照」这条不变式失效;
  • 旧当前版本也没清空该列,一旦有人补上写入就会撞唯一键。

修:旧版本 current_customer_id = None、新版本显式写 current_customer_id=customer_id (与 ProfileGenerationService._SQL_CLEAR_CURRENT 的做法一致)。

4.3 tests/integration 的 13 个"假失败"

合并后首次整套跑 tests 时,13 个登录/RBAC 用例因 401「用户名或密码不正确」 而红 —— 不是代码问题,是集成测试的前置没做(测试账号不存在)。 跑 tools/seed_test_rbac.py + tools/set_user_password.py 后全部转绿。

已在 AGENTS.md 记录该前置,避免下一个人把它误判成代码缺陷。


5. 验证证据(合并后实测)

项 结果
全量 pytest tests 2 failed, 1396 passed, 2 skipped
其中 pytest tests/integration 102 passed, 1 skipped(修 §4.1/§4.2 后从 15 failed 归零)
mypy app Success: no issues found in 245 source files
tools/audit_schema.py 89 business tables, no missing or unexpected tables
tools/check_authoritative_docs.py checked 52 documents, no number collision
tools/check_rbac_seed_consistency.py 通过(种子 40 条权限、2 个 grant 脚本逐条一致)

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


6. 遗留 / 待架构师确认

  1. neo4j_profile_projection.py 失去生产引用(§3.3)—— 本质是它与方案 A 互斥, 不是它本来就没被装配。本线已在文件头加警告说明并保留文件, 待架构师在"删除 / 保留为参考 / 反过来改用它"三者间决定。
  2. 投顾线两处生产者的 payload 仍缺 memory_sources —— 本线的消费端已有兜底(不再死信), 但根治应由投顾线补上或明确"这两个来源是否也要投影长期记忆"。
  3. docs/00 §6.4.6 的取值栏与实现不一致 —— 按既定裁定未改基线文档, 实际口径记在 docs/37。
  4. 文档编号:主干已占用 29–36,本线两份文档让号至 docs/37(记忆投影链路实现说明)、 docs/38(架构对齐,决策依据)。
  5. 常驻 Worker 仍未运行 —— memory_unit / user_facts 仍为 0 行, 积压事件未消费(agent.run_requested 431 等)。⚠️ 启动会派发真实 agent 任务、产生模型调用费用。

执行人:NL(用户端)|合并前备份分支:NL-backup-before-trunkmerge-20260912 @ 57f56bb