该适配器与主干 `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 份无编号冲突。
192 lines
12 KiB
Markdown
192 lines
12 KiB
Markdown
# 合并对策记录: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`
|