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

192 lines
12 KiB
Markdown
Raw Permalink 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.
# 合并对策记录: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`