Merge remote-tracking branch 'origin/qyqy_develop' into lzl_qyqy_integration
This commit is contained in:
@@ -195,7 +195,7 @@ Redis 不可用时实测按设计降级放行;生产装配模式因本机 Milv
|
||||
本次继续完成灰度闸门和回滚手册:新增 `AdvisorRolloutService`,由环境变量控制投顾灰度,
|
||||
开启后管理员放行、客户按白名单放行,未命中返回 `403 AGENT_PERMISSION_DENIED` 并写入
|
||||
`advisor.rollout_denied` 审计;已接入投顾业务路由和 `advisor` Agent 运行入口。新增操作手册
|
||||
`docs/22-投顾Agent灰度与回滚操作手册.md`。专项测试 `6 passed`,全量单元测试 `505 passed,
|
||||
`docs/31-投顾Agent灰度与回滚操作手册.md`。专项测试 `6 passed`,全量单元测试 `505 passed,
|
||||
3 warnings`,Ruff 和 MyPy(146 个源文件)通过。实现提交:`f5dd5b8`。生产/联调环境的
|
||||
实际灰度与回滚演练仍待执行。
|
||||
|
||||
@@ -663,7 +663,7 @@ python tools/audit_constraints.py
|
||||
- [x] 保存每个模块的测试结果。(已回填阶段记录)
|
||||
- [x] 保存迁移后的结构审计结果。(独立迁移库 72 张业务表)
|
||||
- [x] 准备关闭新投顾入口的配置开关。(`ADVISOR_ROLLOUT_ENABLED=false`)
|
||||
- [x] 准备应用代码按提交回滚方案。(见 `docs/22-投顾Agent灰度与回滚操作手册.md`)
|
||||
- [x] 准备应用代码按提交回滚方案。(见 `docs/31-投顾Agent灰度与回滚操作手册.md`)
|
||||
- [x] 确认数据库不执行破坏性 downgrade。(见回滚手册)
|
||||
- [x] 确认新增表保留,不自动删除。(见回滚手册)
|
||||
- [x] 确认失败 Outbox 可以重试或人工处理。(沿用公共 Outbox 重试/死信机制)
|
||||
|
||||
+54
-11
@@ -1,26 +1,30 @@
|
||||
# 平台侧交接与联调准备
|
||||
|
||||
> **读者**:接手平台侧的人,以及联调前要确认状态的人
|
||||
> **时点**:2026-09-11
|
||||
> **一句话**:三条组员线(袁聪的场外/推广、NL 的客服画像与知识管理、投顾)已并入
|
||||
> **时点**:2026-09-11 建立,2026-09-12 更新
|
||||
> **一句话**:四条线(袁聪的场外/推广、NL 的客服画像与知识、投顾、ZSY 的客服接入)已并入
|
||||
> `qyqy_develop`,库已跟上(89 张业务表),登录与 RBAC 只读接口已补齐;
|
||||
> 等组员继续推送后按本文 §5 的清单联调。
|
||||
> **但截至 2026-09-12 14:20 仍有线在推**,功能性测试/联调按 §5 的触发条件启动。
|
||||
|
||||
---
|
||||
|
||||
## 1. 当前状态(2026-09-11 实测)
|
||||
## 1. 当前状态(2026-09-12 实测,**非终态**)
|
||||
|
||||
> ⚠️ **这不是最终快照**:`lzl_qyqy_integration` 那条线仍在持续推送 —— 本轮我推第一次时
|
||||
> 就被它抢先(远端在两次 `fetch` 之间前进了 4 个提交)。**做功能性测试前请先按 §5.0
|
||||
> 确认各线都已停止推送**,然后重跑 §5.3 的命令并把数字更新到新的交接文档里。
|
||||
|
||||
| 项 | 值 |
|
||||
|---|---|
|
||||
| 分支 | `qyqy_develop`(本地 ahead 若干,待推送) |
|
||||
| 工作区 | 干净 |
|
||||
| 分支 | `qyqy_develop` = `c8cdc06`(与 `origin` 同步,待推送 0,工作区干净) |
|
||||
| 数据库 | **89 张业务表**,`alembic current` = head = `20260911_merge_adv_risk_heads` |
|
||||
| `ruff check app tests tools` | 干净 |
|
||||
| `mypy app` | **228 个文件 0 错** |
|
||||
| `pytest tests/unit tests/contract` | **1207 passed, 2 skipped, 0 failed** |
|
||||
| `pytest tests/integration` | **99 passed** |
|
||||
| `tools/check_authoritative_docs.py` | **40 份文档,无编号冲突** |
|
||||
| 后台进程 | 无(登录测试台已关闭,端口已释放) |
|
||||
| `mypy app` | **245 个文件 0 错** |
|
||||
| `pytest tests/unit tests/contract` | **1317 passed, 2 skipped, 0 failed** |
|
||||
| `pytest tests/integration` | **104 passed** |
|
||||
| 文档守卫 / 端点编号守卫 | 53 份文档无编号冲突;§19 **62 个端点 / 6 个号段**无重复 |
|
||||
| RBAC 号段自检 | 一致(种子 40 条权限,各 `grant_*.py` 与种子逐条一致) |
|
||||
| 后台进程 | 无 |
|
||||
|
||||
> `mypy` 与测试数在本项目**必须带环境**读:架构师环境用
|
||||
> `D:\conda\envs\jr_py313\python.exe`,NL 那边用本机 `.venv`。此前出现过
|
||||
@@ -122,6 +126,45 @@ D:\conda\envs\jr_py313\python.exe -m pytest -q
|
||||
|
||||
## 5. 等组员推完之后的联调清单
|
||||
|
||||
### 5.0 触发条件:先确认"没人还在推"
|
||||
|
||||
**判据**:下面这段输出**为空**,才算各线都合完了。都搞完之前不要开始功能性测试 ——
|
||||
否则测的是一个还会变的树,结论没有意义。
|
||||
|
||||
```powershell
|
||||
git fetch --all --prune
|
||||
foreach ($b in (git branch -r --format='%(refname:short)' | Where-Object { $_ -notmatch 'HEAD' })) {
|
||||
$n = git rev-list --count "origin/qyqy_develop..$b" 2>$null
|
||||
if ($n -gt 0) { "$b 独有 $n 个提交未合" }
|
||||
}
|
||||
```
|
||||
|
||||
> 2026-09-12 的实测:`NL_develop` 曾独有 16 个(已合)、`lzl_qyqy_integration` 正在持续推送。
|
||||
> 注意**别把"落后很多的老分支"当成待合分支**(如 `lzl_develop` 落后 206 个提交,
|
||||
> 它的产出走的是新建的 `lzl_qyqy_integration`),但也**别反过来把活跃分支当废弃分支**——
|
||||
> 先看它的最新提交时间。
|
||||
|
||||
### 5.1 外部依赖前置(不满足会产生**假失败**,别当代码缺陷)
|
||||
|
||||
| 依赖 | 检查方式 | 不满足的后果 |
|
||||
|---|---|---|
|
||||
| **MySQL + RBAC 种子** | `python tools/seed_test_rbac.py` → `python tools/set_user_password.py` | **不跑这两步,`tests/integration` 会有 13 个登录/RBAC 用例因 401 而红**。口令脚本**非幂等**(重复执行等于重设密码) |
|
||||
| Docker Desktop(Milvus) | `docker ps` 能连上 | 检索、知识链路不可用;`tools/setup_milvus_profile_collection.py` 建不了集合 |
|
||||
| Redis | 健康检查 | 登录限流、客服短期会话记忆链路不可用 |
|
||||
| Neo4j | `127.0.0.1:7687` 可连 | 关系/图投影链路不可用 |
|
||||
| SMTP / IMAP | `.env` 里的开关 | 场外通知与邮件识别只能走离线数据集 |
|
||||
|
||||
### 5.2 已知的"看起来像缺陷但不是"
|
||||
|
||||
- `tests/unit/service/test_offsite_document_recognition_adapter.py` 有 2 个用例被记为**环境相关失败**
|
||||
(断言请求体里是中文原文,而 httpx 会把中文序列化成 `\uXXXX`,字节序列自然不匹配)。
|
||||
**主干的架构师环境复现不了**(2026-09-12 全量为 0 failed)。不要为了"让它绿"去动实现;
|
||||
若要修,正确做法是断言 `json.loads(body)` 后的字段值 —— 字节级断言不该用来测 JSON。
|
||||
- `mypy app` 的数字**先对版本再对代码**:SQLAlchemy 补丁版不同会差出上百个错,
|
||||
复现矩阵见 `AGENTS.md` 的"环境与命令口径"一节。
|
||||
|
||||
### 5.3 合并与验证命令
|
||||
|
||||
```powershell
|
||||
git fetch origin --prune
|
||||
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
# 记忆投影链路(Outbox → Milvus 长期记忆 / Neo4j)实现说明
|
||||
|
||||
**适用分支**:`NL_develop`(用户端线)|**日期**:2026-09-12|**状态**:已实现并通过真机验证
|
||||
|
||||
---
|
||||
|
||||
## 1. 这条链路是干什么的
|
||||
|
||||
记忆(对话里被抽取出来的长期事实)要能被后续召回,必须从 MySQL 同步到两处外部存储:
|
||||
|
||||
```
|
||||
对话消息
|
||||
└─→ memory.extraction_requested(domain_event_outbox)
|
||||
└─→ MemoryExtractionWorker → MemoryService.upsert() → memory_unit(MySQL,唯一真相)
|
||||
└─→ profile.rebuild_requested(domain_event_outbox)
|
||||
├─→ ProfileAssemblyService.rebuild() → profile_snapshots
|
||||
├─→ ProfileGraphProjectionService → Neo4j(图)
|
||||
└─→ ProfileGenerationService.generate() → memory_sync_outbox
|
||||
├─ milvus → MilvusProfileProjection → Milvus 长期记忆向量
|
||||
└─ neo4j → ProfileGraphProjectionService(幂等复投)
|
||||
```
|
||||
|
||||
`memory_sync_outbox` 是"画像版本 → 外部存储"的投递队列,唯一键
|
||||
`uk_memory_sync_event (event_uuid, target_store)`:**同一 `event_uuid` 对两个目标库各写一条**。
|
||||
|
||||
---
|
||||
|
||||
## 2. ⚠️ 最容易踩的坑:枚举取值必须全仓统一(小写 + 英文)
|
||||
|
||||
这条链曾**完全失效但不报错**,根因就是取值口径不统一。
|
||||
|
||||
### 唯一正确口径
|
||||
|
||||
| 字段 | 取值 | 大小写 |
|
||||
|---|---|---|
|
||||
| `target_store` | `milvus` / `neo4j` | **小写** |
|
||||
| `operation` | `upsert` / `archive` / `delete` | **小写** |
|
||||
| `status` | `pending` / `failed` / `processed` / `dead` | **小写英文** |
|
||||
|
||||
(`aggregate_type` 用 `memory` / `profile` / `relationship` / `deletion`。)
|
||||
|
||||
### 为什么照 `docs/00` §6.4.6 写会坏
|
||||
|
||||
`docs/00` §6.4.6 那一栏曾写作大写 `MILVUS`/`NEO4J`、`UPSERT` 与中文 `待处理`,
|
||||
与**全仓实现从未对齐**。按那份文档写会造成:
|
||||
|
||||
1. `MemorySyncOutboxWorker` 按 `handlers.get(event.target_store)` 分派 handler
|
||||
—— 大写值找不到 handler;
|
||||
2. 领取条件是 `status.in_({"pending","failed"})` —— 中文 `待处理` 不满足。
|
||||
|
||||
⇒ **两个条件都不满足,事件任何消费者都领不到,永久滞留且不报错。**
|
||||
唯一键 `(event_uuid, target_store)` 对大小写没有约束,MySQL 也不会报错,所以是**静默失效**。
|
||||
|
||||
判断依据应以**主干既有读取方**为准,不是文档:
|
||||
`projection_reconciliation_service.py:20`(`{"pending","failed"}`)、
|
||||
`graph_projection_worker.py`(`pending`/`processed`/`dead`)。
|
||||
|
||||
### 现状
|
||||
|
||||
- 生产端取值集中在 `app/service/profile_generation_service.py` 的常量
|
||||
(`TARGET_MILVUS`/`TARGET_NEO4J`/`SYNC_OPERATION_UPSERT`/`SYNC_STATUS_PENDING`);
|
||||
- **测试不再硬编码字面量**,并断言 `SYNC_STATUS_PENDING == "pending"`、
|
||||
`set(SYNC_TARGETS) == {"milvus","neo4j"}`
|
||||
(硬编码正是当初跑偏的直接原因);
|
||||
- `tests/unit/worker/test_memory_sync_outbox_worker.py` 有一条契约回归测试,
|
||||
断言大写 `MILVUS` 分派不到 handler、会进死信 —— 谁改回大写,测试立刻红;
|
||||
- 历史 2 行已就地把取值改齐(只改值,主键/唯一键/payload 未动),
|
||||
正式订正工具 `tools/normalize_memory_sync_outbox.py`
|
||||
(**默认 dry-run,加 `--apply` 才写库**,幂等可复跑;换环境若也有同批旧值可直接用)。
|
||||
|
||||
---
|
||||
|
||||
## 3. Milvus 长期记忆向量集合
|
||||
|
||||
| 项 | 值 |
|
||||
|---|---|
|
||||
| 集合名 | `user_long_term_memory_v1` |
|
||||
| 主键 | `memory_uuid`(VARCHAR 64,UUID 字符串,**按 UUID 幂等 upsert**) |
|
||||
| 向量 | `embedding`,`FLOAT_VECTOR` dim **1024**,索引 `AUTOINDEX` + `COSINE` |
|
||||
| 其他字段 | `customer_id`(INT64) / `version`(INT64,可空) / `valid_until_ts`(INT64,可空) / `updated_at_ts`(INT64,可空) / `confidence`(DOUBLE) / `content`(VARCHAR 2048) / `memory_type`(VARCHAR 32) / `memory_key`(VARCHAR 64) / `status`(VARCHAR 16) |
|
||||
|
||||
建集合:`python tools/setup_milvus_profile_collection.py`
|
||||
——**幂等**,集合已存在时只做结构比对报告、不覆盖不删重建(共享 Milvus 实例里还有别的项目的集合)。
|
||||
|
||||
### `memory_sources` 契约(适配器的输入)
|
||||
|
||||
```json
|
||||
{
|
||||
"customer_id": 9102,
|
||||
"profile_version": 2,
|
||||
"memory_sources": [{
|
||||
"memory_uuid": "uuid 字符串",
|
||||
"memory_key": "preference:risk_level",
|
||||
"content": "正文",
|
||||
"memory_type": "preference",
|
||||
"confidence": 0.9,
|
||||
"version": 1,
|
||||
"valid_until": null
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
数据源:`memory_unit` 中 `status='active'` 的行
|
||||
(`ProfileRepository.active_memories()`)。**由生产端组装**而不是消费端回查:
|
||||
消费端到时记忆可能已改版本,事件里带确定快照才能与 `aggregate_version` 语义一致。
|
||||
|
||||
> 为什么用 `memory_unit` 而不是 `user_facts`:两者用途不同 —— `user_facts` 是
|
||||
> **已确认的结构化事实**(喂画像与图投影),`memory_unit` 是**长期记忆条目**
|
||||
> (正是长期记忆向量集合要存的东西)。这不与"图投影只读 `user_facts`"的不变式冲突。
|
||||
|
||||
---
|
||||
|
||||
## 4. 移植自 `ZSY_develop` 的改动(含两处契约放宽)
|
||||
|
||||
来源:同事 `ZSY_develop` 分支。整文件移植、骨架未改的部分:
|
||||
`app/core/conversation_privacy.py`、`app/worker/memory_sync_outbox_worker.py`
|
||||
(领取/`skip_locked`/指数退避/5 次转死信的可靠性骨架原样保留)、
|
||||
`app/infrastructure/milvus_profile_projection.py`。
|
||||
|
||||
对适配器做了**两处契约放宽**,都是"避免整批失败",不改变写入语义:
|
||||
|
||||
1. **`customer_id` 接受 int 或数字字符串**
|
||||
原实现要求 `isinstance(customer_id, int)`,而本仓**所有**生产者写的都是
|
||||
`str(customer_id)`。不放宽则**每个事件必然失败**、重试 5 次后进死信。
|
||||
放宽仅限**纯数字**串,非数字(如 uuid)仍拒绝。
|
||||
2. **不可投影的 `memory_key` 跳过而非整批报错**
|
||||
受控词表 `app/service/memory_taxonomy.py` 共 13 个键,其中 `constraint:*`(3 个)与
|
||||
`profile:*`(4 个)不以 `preference:`/`goal:` 开头。原实现对第一个不合规的键直接
|
||||
`raise` —— 一条 `constraint:` 记忆就会毒死该客户整批同步。现改为**跳过并留痕**。
|
||||
|
||||
**收窄而非扩容的理由**:`preference:*`/`goal:*` 是偏好与目标,适合语义召回;
|
||||
`constraint:*`/`profile:*` 是结构化约束与属性,应由结构化通道查询(`user_facts` → 图),
|
||||
放进向量集合只会造成召回噪声。若日后要给它们做语义召回,应扩容而非靠现在的跳过。
|
||||
|
||||
---
|
||||
|
||||
## 5. Neo4j 分支:方案 A(不引入第二套投影)
|
||||
|
||||
同事分支另有一套 `neo4j_profile_projection.py`(按客户各建私有
|
||||
`Preference`/`Goal` 节点、数据源 `memory_unit`)。**未采用**,理由:
|
||||
|
||||
主干 `ProfileGraphProjectionService` 已由 `profile.rebuild_requested` 驱动同一条链,
|
||||
其数据源是 `user_facts`(**已确认事实**)、`MERGE` **共享 tag 节点**,且文件头写明不变式:
|
||||
|
||||
> "只投影'已确认'的事实……保证图里的偏好标签与画像口径一致;
|
||||
> 否则同一件事在画像和图里会有两种说法。"
|
||||
|
||||
两套并存 = 同一事实在图中两种表示,正好违反这条不变式。
|
||||
因此 `memory_sync_outbox` 的 `neo4j` 分支**复用主干服务**:
|
||||
图投影是 `MERGE` 幂等的,再投一次不产生重复节点/关系,只用于收敛 outbox 的投递状态。
|
||||
|
||||
---
|
||||
|
||||
## 6. 消费端装配
|
||||
|
||||
`WorkerRuntime.consume_profile_projections()`(`app/worker/runtime.py`),在
|
||||
`run_once()` 每轮执行,按条提交、单条失败不冒泡。
|
||||
|
||||
- `milvus` handler → `MilvusProfileProjection`(注入 `MilvusProfileVectorClient` + 向量化);
|
||||
- `neo4j` handler → `ProfileGraphProjectionService`;其 `degraded` 会**如实抛错**
|
||||
走失败/退避,不记成已投递;
|
||||
- `milvus_uri` 未配置时**显式降级**:不消费、事件留 `pending`(可观测、可重放),
|
||||
启动路径留一条 warning,绝不伪造同步成功(与 `knowledge_writer` 同一取向);
|
||||
- 目标存储没有对应 handler 时判**死信**并记 `target_handler_not_configured`
|
||||
—— 这正是当初大写值事件的下场(有回归测试守着)。
|
||||
|
||||
向量化复用与知识向量同一套已批准端点解析(`agent_type="memory_recall"`,
|
||||
`task_type="embedding"`)。
|
||||
|
||||
### 6.1 `memory_sources` 缺失时的兜底(`_with_memory_sources`)
|
||||
|
||||
`memory_sources` 是本线新增的投影入参,而**投顾线两处生产者**
|
||||
(`profile_governance_service` / `risk_questionnaire_service`)发的 payload 是
|
||||
`{customer_id, profile_uuid, version, profile}`,**没有**这个键。若不处理,它们每次画像
|
||||
变更都会因 `memory_sources is invalid` 失败重试直至死信。
|
||||
|
||||
消费端因此做了兜底:**键缺失或为 `None`** 时,回退为查询该客户 `memory_unit` 中
|
||||
`status='active'` 的记忆,并**记一条 warning**(使"谁没提供"保持可见)。
|
||||
|
||||
为什么允许兜底:缺失表示生产者不知道要提供,属契约演进期的正常情况,且**语义成立**
|
||||
——长期记忆是**客户级**的、不是画像版本级的,每条记忆自带 `version`,适配器按
|
||||
`memory_uuid + version` 幂等,"用的是哪一版"仍然确定。
|
||||
|
||||
**兜底不掩盖真错误**(有测试守着):键**存在但格式不对**(例如是字符串)时**不兜底**,
|
||||
原样放行交给适配器失败关闭。实现上用**键存在性**判断而不是 `isinstance`——后者会把
|
||||
"缺失"与"格式错"混为一谈,那正是本模块初版实现里的一个真 bug,被测试抓出来后修正。
|
||||
|
||||
---
|
||||
|
||||
## 6.2 顺带修掉的独立缺陷:`profile_snapshots` 被重复定义
|
||||
|
||||
**发现路径**:验证 `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`
|
||||
**无需改动**。
|
||||
|
||||
> ⚠️ **订正(2026-09-12 合并主干后复核)**:本条初稿曾写"`app.model.profile` 不映射
|
||||
> `current_customer_id`、该属性无人使用",**这个说法已过时**。实际情况:
|
||||
> `app/model/profile.py` **已经映射** `current_customer_id`(普通可空列 + 唯一键
|
||||
> `uk_profile_snapshot_current`,**不是**生成列 —— 模块 docstring 第 2 条写明了原因:
|
||||
> 声明成生成列会让 SQLAlchemy 把它从 INSERT 排除,反而永远写不进去),
|
||||
> 且**确有人使用**(集成测试按该列查当前快照)。
|
||||
> 因此本次合并顺带修了一个真实缺陷:`CustomerProfileCandidateService._write_profile_snapshot`
|
||||
> 创建当前版本时**没写**该列、也没清旧值 —— 唯一键形同虚设,且一旦补写就会与旧值撞键。
|
||||
> 详见 `docs/39-主干合并对策记录.md` §4.2。
|
||||
> 两个模块的 `ProfileSnapshot` 现在**都**映射该列,这是 re-export 成立的前提。
|
||||
|
||||
---
|
||||
|
||||
## 7. 真机验证证据(2026-09-12)
|
||||
|
||||
| 验证项 | 结果 |
|
||||
|---|---|
|
||||
| 集合创建幂等 | 首次 `created`,复跑 `exists` |
|
||||
| 真实 embedding 维度 | **1024**(与集合定义一致) |
|
||||
| 真实写入 + 回读 | 2 条可投影键写入成功并可回读(内容/版本正确) |
|
||||
| 不可投影键 | `constraint:liquidity` **未写入**(跳过生效,未毒死整批) |
|
||||
| 测试数据清理 | 已按 `customer_id=999999` 删除,集合残留 **0** 条 |
|
||||
| **消费端全路径**(补验) | 见下方 7.1 |
|
||||
| 单元测试 | 新增 **22** 个(适配器 10 + worker 5 + 生产端 2 + 消费端兜底 5),全过 |
|
||||
| 全量回归 | `2 failed, 1312 passed, 2 skipped` —— 与基线一致,**无新增失败** |
|
||||
| mypy | `Success: no issues found in 227 source files` |
|
||||
|
||||
> 全量的 2 个失败是既有环境相关项(`test_offsite_document_recognition_adapter.py`
|
||||
> 断言请求体里的中文原文,而 httpx 序列化成 `\uXXXX`),与本次改动无关。
|
||||
|
||||
> 真机注意:Milvus 写入后**短时间内可能查不到**(索引尚未可见),
|
||||
> 验证脚本按重试处理;同理删除后立即查询可能仍返回旧行,需重查确认。
|
||||
|
||||
### 7.1 消费端全路径验证(2026-09-12 补做)
|
||||
|
||||
此前"整合验证"是**直接调适配器**,跳过了 outbox 的领取→分派→状态更新。
|
||||
后补做了两轮,覆盖失败分支与成功分支:
|
||||
|
||||
**失败分支**(Milvus 断开时实测):
|
||||
|
||||
| id | `last_error` | 说明 |
|
||||
|---|---|---|
|
||||
| 5 | `ValueError` | payload 缺 `memory_sources`(兜底上线前的旧行) |
|
||||
| 6 | `InvalidRequestError` | 模型重复定义缺陷(见 §6.2),**修复后此错误消失** |
|
||||
| 9 | `RecoverableAgentError` | Milvus 不可达——如实失败,不伪造成功 |
|
||||
|
||||
这证明全路径都工作:行被领取 ✓、按 `target_store` 分派 handler ✓、
|
||||
handler 异常被捕获 ✓、`status`/`retry_count`/`last_error`/`next_retry_at` 正确落库 ✓。
|
||||
|
||||
**成功分支**(注入替身向量客户端,不依赖真实 Milvus):
|
||||
|
||||
- outbox 行 → `status=processed`、`processed_at` 已写、`last_error` 清空 ✓
|
||||
- 不可投影的 `constraint:liquidity` **被跳过**(只写 1 行而非 2 行)✓
|
||||
- 向量维度 1024 ✓;字符串客户号 `"999996"` → int ✓
|
||||
- **手机号脱敏生效**:`稳健型投资者,手机号 [手机号已隐藏] 请勿外泄` ✓
|
||||
|
||||
**兜底的实证**:历史行 `id=5`(payload 无 `memory_sources`)经兜底回退查询后
|
||||
成功投递为 `processed`,日志留
|
||||
`profile projection payload has no memory_sources (customer_id=9102); fell back to 0 active memories`。
|
||||
|
||||
> 补验时的环境限制:Docker Desktop 中途崩溃(`milvus-standalone` 内嵌 etcd panic、
|
||||
> Neo4j `Exited(1)`),因此 `id=6`(neo4j 分支)停在 `failed`/`RecoverableAgentError`。
|
||||
> **那是环境不可用,不是代码缺陷**——图库不可用时如实失败、不伪造成功正是设计口径。
|
||||
|
||||
---
|
||||
|
||||
## 8. 尚未完成 / 依赖他人
|
||||
|
||||
1. **常驻 Worker 未运行**:`memory_unit`、`user_facts`、`episodes` 目前都是 **0 行**。
|
||||
代码链路是通的(§7.1 已用真实 outbox 行验证消费端),但没有 Worker 在跑,
|
||||
所以记忆永远不会被抽取出来。积压量(2026-09-12 实测):
|
||||
`agent.run_requested` **431**、`profile.rebuild_requested` **220**、
|
||||
`agent.run_completed` **61**、`memory.extraction_requested` **58**。
|
||||
起 `python -X utf8 -m app.worker --once`(或常驻)即开始消费。
|
||||
⚠️ 会派发真实 agent 任务、产生模型调用费用,故未擅自启动。
|
||||
2. **投顾线两处生产者的 `memory_sources`**:其 payload 仍**没有**这个键。
|
||||
本线已在消费端加了兜底(§6.1),因此**不再会死信**;但根治仍应由投顾线补上
|
||||
(或明确这两个来源是否也要投影长期记忆)。属架构师线,本线未改其生产者代码。
|
||||
3. **`profile_snapshots` 重复定义缺陷**(§6.2):本线已修,但它源自投顾线的模型文件,
|
||||
需让架构师知晓,以免在别处再引入同名定义。
|
||||
4. `docs/00` §6.4.6 的取值栏与实现不一致:按评审要求**未改基线文档**,
|
||||
实际口径以本文第 2 节为准。
|
||||
|
||||
---
|
||||
|
||||
## 9. 相关文件
|
||||
|
||||
**新增**
|
||||
- `app/core/conversation_privacy.py`(移植)
|
||||
- `app/infrastructure/milvus_profile_projection.py`(移植 + 2 处放宽)
|
||||
- `app/infrastructure/milvus_profile_vector_client.py`
|
||||
- `app/worker/memory_sync_outbox_worker.py`(移植)
|
||||
- `tools/setup_milvus_profile_collection.py`
|
||||
- `tools/normalize_memory_sync_outbox.py`(历史取值订正,默认 dry-run、幂等)
|
||||
- `tests/unit/infrastructure/test_milvus_profile_projection.py`
|
||||
- `tests/unit/worker/test_memory_sync_outbox_worker.py`
|
||||
- `tests/unit/worker/test_runtime_profile_projection.py`(消费端兜底 5 用例)
|
||||
|
||||
**修改**
|
||||
- `app/service/profile_generation_service.py`(取值改小写、payload 加 `memory_sources` 与 `profile_version`)
|
||||
- `app/repository/profile_repository.py`(`active_memories()`)
|
||||
- `app/service/agent/bootstrap.py`(`get_milvus_profile_vector_client()`)
|
||||
- `app/worker/runtime.py`(`consume_profile_projections()`、`_with_memory_sources()` 兜底、`run_once` 接线)
|
||||
- `app/model/risk_questionnaire.py`(**修重复定义**:改为 re-export `app.model.profile` 的 `ProfileSnapshot`,见 §6.2)
|
||||
- `tests/unit/service/test_profile_generation_service.py`(+2 用例、断言改引用常量)
|
||||
- `AGENTS.md`(新增 `memory_sync_outbox` 取值口径与 Windows 中文输出两条易错点;校正测试基线/mypy 数字)
|
||||
@@ -0,0 +1,203 @@
|
||||
# 架构对齐 · 记忆→画像→图 这条链(ZSY_develop vs 主干)
|
||||
|
||||
> **目的**:`ZSY_develop` 分支(张胜宇,最后更新 2026-09-11 20:45)包含一整套画像投影实现,
|
||||
> 而主干 `qyqy_develop` 上也有同类实现。**在合并之前必须先做一次架构对齐**,否则两套会互相覆盖。
|
||||
> **本文只做核对与建议,不改任何代码**。
|
||||
>
|
||||
> **⚠️ 后续(2026-09-12)**:本文的结论**已落地实施**,实施说明与验证证据见
|
||||
> `docs/37-记忆投影链路实现说明.md`。本文保留作为**决策依据** —— 它记录了
|
||||
> "为什么选方案 A(复用主干图投影、不引入第二套)"、"为什么由生产端组装 `memory_sources`"
|
||||
> 这两个决定的原始核对过程与判据,这部分推演在 `docs/37` 里没有重复。
|
||||
>
|
||||
> **编号说明**:本文原为 `docs/29`;2026-09-12 让号给架构师线的
|
||||
> `docs/29-Agent组员登录接口使用说明.md`,先改号 `docs/33`,后又因主干续占 32–36
|
||||
> 再让号至 `docs/38`(本文的实际编号以文件名与 `AGENTS.md` 为准)。
|
||||
>
|
||||
> **核对时间**:2026-09-11 晚 **主干**:`qyqy_develop` @ `bbf623a` **ZSY**:`cbcf7c7`
|
||||
|
||||
---
|
||||
|
||||
## 一、最重要的发现:主干那条链**已经接好了**(走的是另一条 outbox)
|
||||
|
||||
之前(包括架构师的评审意见里)的说法是"**组件写好了、线没接**"。**核对后这个说法不准确** ——
|
||||
主干上有一条**完整且已在装配层接线**的链路,只是它走的不是 `memory_sync_outbox`:
|
||||
|
||||
```
|
||||
agent.run_completed
|
||||
↓ (app/worker/runtime.py 的 handler 字典,已注册)
|
||||
memory.extraction_requested
|
||||
↓ dispatch_memory_extraction
|
||||
MemoryExtractionService → 提升成 user_facts
|
||||
↓
|
||||
profile.rebuild_requested
|
||||
↓ dispatch_profile_rebuild(runtime.py L175-197)
|
||||
ProfileAssemblyService.rebuild() → 重建画像快照
|
||||
ProfileGraphProjectionService.project_customer() → 投影到 Neo4j
|
||||
```
|
||||
|
||||
**这条链的引入者与时间**:
|
||||
|
||||
```
|
||||
d7f6ef7 09-10 21:52 卿云秋月(架构师) feat: 记忆→画像→图全自动触发
|
||||
```
|
||||
|
||||
⇒ **架构师 09-10 就做完了"全自动触发"**,并且是**直接接在 `runtime.py` 的 handler 字典里**的。
|
||||
`app/worker/runtime.py` 的 handler 白名单**包含** `"profile.rebuild_requested"` —— 这就是"线接上了"的证据。
|
||||
|
||||
### 1.1 与 ZSY 那条链的关系:**两条平行的 outbox**
|
||||
|
||||
| | 主干(架构师的链) | ZSY(张胜宇的链) |
|
||||
|---|---|---|
|
||||
触发事件 | `profile.rebuild_requested` | `memory_sync_outbox` 表里的行 |
|
||||
存储 | **`domain_event_outbox`**(领域事件表) | **`memory_sync_outbox`**(记忆同步表) |
|
||||
消费方式 | `WorkerRuntime` 的 handler 字典(**已注册**) | 独立的 `MemorySyncOutboxWorker` |
|
||||
消费者装配 | `app/worker/__main__.py` → `WorkerRuntime(...)` | `app/worker/__main__.py` → `MemorySyncOutboxWorker({...})` |
|
||||
投影目标 | **Neo4j**(`ProfileGraphProjectionService` + `relationships`) | **Milvus + Neo4j**(两个 adapter) |
|
||||
画像生成 | `ProfileAssemblyService`(从 `user_facts` 组装) | `CustomerProfileCandidateService`(候选 → 复核 → 已批准快照) |
|
||||
|
||||
**⇒ 两条链做的是同一件事,但走不同的 outbox、不同的画像生成方式、不同的消费装配。**
|
||||
|
||||
### 1.2 现场数据(我这边实测)
|
||||
|
||||
| 表 | 行数 | 说明 |
|
||||
|---|---|---|
|
||||
`user_facts` | **0** | 事实层为空 ⇒ 主干那条链**从没跑过**(或跑过但被清理) |
|
||||
`memory_unit` | **0** | 记忆单元为空 |
|
||||
`profile_snapshots` | **5** | 我的 `seed_profile_demo.py` 种的演示数据 |
|
||||
`memory_sync_outbox` | **2** | **我的 `profile_generation_service` 写的**,`status='待处理'`,无人消费 |
|
||||
|
||||
**⇒ 关键因果链**:`user_facts` 为空 → 主干链没产出过东西;`memory_sync_outbox` 的 2 行是**我的**生产者写的。
|
||||
|
||||
### 1.3 `graph_projection_worker.py` 的真相
|
||||
|
||||
```
|
||||
app/worker/graph_projection_worker.py → GraphProjectionWorker 类
|
||||
git grep "GraphProjectionWorker(" → 零处实例化
|
||||
```
|
||||
|
||||
**它是死代码**(或备用路径)。架构师说的"`GraphProjectionWorker` 没有实例化点"**是对的**;
|
||||
但由此推论"整条链没接"**不对** —— 接的是 `runtime.py` 的 handler,不是这个类。
|
||||
|
||||
---
|
||||
|
||||
## 二、ZSY 分支带来的**真正增量**(主干确实没有的)
|
||||
|
||||
逐文件核对后,ZSY 分支**独有且主干没有**的只有这些:
|
||||
|
||||
| 文件 | 作用 | 主干有没有等价物 |
|
||||
|---|---|---|
|
||||
`app/worker/memory_sync_outbox_worker.py` | `memory_sync_outbox` 的消费者(handler 注入 + 重试 + 死信) | **无**(主干消费的是另一个 outbox) |
|
||||
`app/infrastructure/milvus_profile_projection.py` | 画像投影到 **Milvus** | **无**(主干只投影到 Neo4j) |
|
||||
`app/infrastructure/neo4j_profile_projection.py` | 画像投影到 Neo4j(另一个实现) | 🟡 主干有 `ProfileGraphProjectionService` |
|
||||
`app/service/knowledge_publication_service.py` | 知识**发布**状态机(草稿→审核→发布) | 🟡 主干有 `knowledge_ingest_service` + 审核字段 |
|
||||
`app/service/knowledge_authority.py` / `knowledge_config.py` | 知识权威来源与运行期配置 | **无** |
|
||||
`app/service/customer_service_session_memory_service.py` | 客服**多轮会话记忆** | 🟡 主干有 Redis 短期记忆 + `docs/24` 说多轮已跑通 |
|
||||
`app/service/customer_service_handover_*.py` | 转人工上下文与后台 | 🟡 主干有 `handover-requests` 端点 + 工单表 |
|
||||
`app/service/customer_profile_candidate_service.py` | 画像**候选 → 复核 → 批准**工作流 | **无**(我的 `profile_generation_service` 是直接生成快照) |
|
||||
`app/worker/customer_profile_candidate_worker.py` | 候选流程的 worker | **无** |
|
||||
`app/service/agent/customer_service_agent.py` | 他自己的客服 Agent | 🔴 **主干已有**(`implementations/customer_service.py`,老师验收第 4/5/6 条靠它) |
|
||||
`app/service/agent/customer_service_routing.py` | 他自己的路由 | 🔴 主干已有 `core/customer_service_rules.py` |
|
||||
`app/service/knowledge_tool_service.py` | 知识工具(另一份) | 🔴 主干已有 `knowledge_tool.py` |
|
||||
|
||||
---
|
||||
|
||||
## 三、四个必须对齐的架构分歧
|
||||
|
||||
### 分歧 1:用哪条 outbox 承载"记忆→画像→图"?
|
||||
|
||||
- **主干**:`domain_event_outbox` + `profile.rebuild_requested`(**已在 handler 白名单里**,架构师 09-10 接的)
|
||||
- **ZSY**:`memory_sync_outbox` + `MemorySyncOutboxWorker`
|
||||
|
||||
**我的判断**:`memory_sync_outbox` 是 `docs/00` 基线里定义的表(**存在即有其设计意图**),
|
||||
而主干那条链并没有消费它 —— 所以**两套有各自的合法位置,不该二选一,而该明确分工**:
|
||||
|
||||
| 场景 | 应该走哪条 |
|
||||
|---|---|
|
||||
记忆内容变化 → 画像重建 → 图投影 | **主干那条**(`profile.rebuild_requested`,已接线、已验) |
|
||||
画像快照 → **同步到 Milvus 向量库**(用于语义召回) | **需要一个消费者**,这正是 ZSY 的 `milvus_profile_projection` 补的 |
|
||||
|
||||
⇒ **建议**:主干链保留;ZSY 的 **Milvus 投影适配器**接进主干链的尾部(或接成 `memory_sync_outbox` 的消费者,
|
||||
但那样要明确"这两个 outbox 各自负责什么",否则就是两套并存)。
|
||||
|
||||
### 分歧 2:画像生成"直接快照"还是"候选→复核→批准"?
|
||||
|
||||
- **我的**:`profile_generation_service.py` —— 直接生成快照(符合 `docs/00` §6.4.6 的事务口径)
|
||||
- **ZSY 的**:`customer_profile_candidate_service.py` —— 先生成候选、人工/规则复核后才成为正式快照
|
||||
|
||||
**我的判断**:这是**业务裁决**,不是技术裁决。候选复核流程更严(金融场景可能更合适),
|
||||
但**成本更高**(需要复核人、需要审核界面)。**建议由架构师或业务方定**,不要由合并动作决定。
|
||||
|
||||
### 分歧 3:两套客服 Agent
|
||||
|
||||
- **主干**:`implementations/customer_service.py`(**已进主干**,老师验收第 4/5/6 条靠它)
|
||||
- **ZSY**:`agent/customer_service_agent.py`
|
||||
|
||||
**我的判断**:**这是最危险的一条**。主干那套已通过验收(产品咨询 5/5、政策 3/3、多轮 3 轮),
|
||||
ZSY 那套是从旧基线写的、**落后主干 144 个提交**。**不能因为合并把它顶掉。**
|
||||
|
||||
### 分歧 4:ZSY 落后主干 144 个提交 —— 他的改动里有大量"过期内容"
|
||||
|
||||
ZSY 的分叉点是 `c2178a9`(09-11 09:49),而它改过 68 个与主干重叠的文件,其中包含:
|
||||
|
||||
```
|
||||
app/service/tool_executor.py、app/service/agent/base.py、app/service/agent/governance.py、
|
||||
app/worker/runtime.py、app/service/public_platform_service.py、app/main.py、app/core/contracts.py
|
||||
```
|
||||
|
||||
这些文件在主干上**已经过多轮修改**(风控 P3、投顾线、我的画像线)。**整体合并会把他的旧版本顶回主干。**
|
||||
|
||||
---
|
||||
|
||||
## 四、建议的收口方案(分三步,风险递增)
|
||||
|
||||
### 第 1 步:**只移植两个投影适配器**(低风险、有明确收益)
|
||||
|
||||
| 移植什么 | 从哪来 | 落到哪 |
|
||||
|---|---|---|
|
||||
Milvus 画像投影适配器 | `app/infrastructure/milvus_profile_projection.py` | 同一路径(主干没有同名文件) |
|
||||
(可选)Neo4j 投影改进 | `app/infrastructure/neo4j_profile_projection.py` | 与主干 `ProfileGraphProjectionService` 比对后再定 |
|
||||
测试 | `tests/unit/infrastructure/test_milvus_profile_projection.py` | 同路径 |
|
||||
|
||||
**为什么安全**:这两个文件在主干**不存在** ⇒ 零冲突;它们是纯适配器(无业务改动)。
|
||||
|
||||
### 第 2 步:**`memory_sync_outbox` 的消费者**(中风险,需要先定分工)
|
||||
|
||||
两种做法,**必须选一种**:
|
||||
|
||||
| 做法 | 说明 |
|
||||
|---|---|
|
||||
**2a** | 把 ZSY 的 `MemorySyncOutboxWorker` 接进 `app/worker/__main__.py`(他那套本来就是这么设计的)—— 直接能用,但要明确它与主干链的分工 |
|
||||
**2b** | 不引入新 worker,而是把主干链的尾部补上"同时写 Milvus"(改 `dispatch_profile_rebuild`)—— 单一链路更简单,但要改主干的已验代码 |
|
||||
|
||||
我倾向 **2a**,理由:`memory_sync_outbox` 是基线表,让它有自己的消费者更符合原本设计;
|
||||
且不用改主干已验的那条链。
|
||||
|
||||
### 第 3 步:**其余部分对齐后再谈**(需要架构师/业务裁决)
|
||||
|
||||
- 画像**候选复核流程**要不要采纳(分歧 2)
|
||||
- ZSY 的**客服 Agent / 知识工具 / 路由**是否全部放弃(分歧 3)—— 我建议放弃
|
||||
- ZSY 的**知识发布状态机**与主干的知识入库/审核是否合并
|
||||
|
||||
---
|
||||
|
||||
## 五、给架构师/张胜宇的四个问题
|
||||
|
||||
1. **`memory_sync_outbox` 与 `profile.rebuild_requested` 的分工是什么?**
|
||||
(两个 outbox 都在用,但谁负责什么没写下来。我在 `AGENTS.md` 里已经记了"两个 outbox 不能混",
|
||||
但现在**两条链各用一个**,需要明确边界。)
|
||||
2. **画像生成要"直接快照"还是"候选复核"?** 这是业务裁决。
|
||||
3. **ZSY 的客服 Agent 要不要保留?** 主干那套已通过老师验收第 4/5/6 条,我建议以主干为准。
|
||||
4. **ZSY 分支怎么收尾?** 它落后主干 144 个提交 —— 建议**不整体合并**,改为"按需移植 2-3 个文件"
|
||||
(投影适配器 + outbox worker + 对应测试),其余明确标记为"已被主干实现取代"。
|
||||
|
||||
---
|
||||
|
||||
## 六、我这边确认不做的事
|
||||
|
||||
- **不改任何代码**(本文只是对齐)
|
||||
- **不整体合并 ZSY 分支**(会让 144 个提交的旧改动回退主干)
|
||||
- **不动主线那条已验链路**(`profile.rebuild_requested` 全自动触发)
|
||||
|
||||
---
|
||||
|
||||
*核对依据:`git ls-tree` 逐文件比对 + `git grep` 引用追踪 + 现库 `information_schema` 与行数实测。*
|
||||
@@ -0,0 +1,191 @@
|
||||
# 合并对策记录: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`
|
||||
Reference in New Issue
Block a user