该适配器与主干 `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 份无编号冲突。
12 KiB
合并对策记录: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 的接线。理由:
- 它带
memory_sources缺失兜底(投顾线两处生产者不发该字段,否则每次画像变更都死信); - 它的
neo4j复用主干ProfileGraphProjectionService—— 落实方案 A(你已确认); - 装配入口的职责仍留在
__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:: 写明"当前未被生产装配、为什么、
以及启用前必须先决定图的节点模型以谁为准";其单测继续跑,但保护的是模块自身契约,
不代表它已被装配。
待架构师决定:
- 删除该文件 + 其单测(它承载的是被否决的方案,留着可能被误读为"可用实现");
- 保留为参考实现(本线当前取此);
- 或反向 —— 若认为该用它而非主干服务,则"方案 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. 遗留 / 待架构师确认
neo4j_profile_projection.py失去生产引用(§3.3)—— 本质是它与方案 A 互斥, 不是它本来就没被装配。本线已在文件头加警告说明并保留文件, 待架构师在"删除 / 保留为参考 / 反过来改用它"三者间决定。- 投顾线两处生产者的 payload 仍缺
memory_sources—— 本线的消费端已有兜底(不再死信), 但根治应由投顾线补上或明确"这两个来源是否也要投影长期记忆"。 docs/00§6.4.6 的取值栏与实现不一致 —— 按既定裁定未改基线文档, 实际口径记在docs/37。- 文档编号:主干已占用 29–36,本线两份文档让号至
docs/37(记忆投影链路实现说明)、docs/38(架构对齐,决策依据)。 - 常驻 Worker 仍未运行 ——
memory_unit/user_facts仍为 0 行, 积压事件未消费(agent.run_requested431 等)。⚠️ 启动会派发真实 agent 任务、产生模型调用费用。
执行人:NL(用户端)|合并前备份分支:NL-backup-before-trunkmerge-20260912 @ 57f56bb