NL_develop
qyqy_develop
user_long_term_memory_v1
MilvusProfileProjection
MilvusProfileVectorClient
MemorySyncOutboxWorker
WorkerRuntime.consume_profile_projections()
run_once()
milvus
neo4j
ProfileGraphProjectionService
memory_unit
memory_sources
conversation_privacy
memory_sync_outbox
原写法照 docs/00 §6.4.6 用大写 MILVUS/NEO4J + 中文 待处理,而消费端按 target_store 的值分派 handler、且只领 status in {"pending","failed"} —— 两个条件都不满足 ⇒ 事件任何消费者都领不到、永久滞留且不报错(唯一键 (event_uuid, target_store) 对大小写无约束,MySQL 也不报错)。
docs/00
MILVUS
NEO4J
待处理
target_store
status in {"pending","failed"}
(event_uuid, target_store)
已改为全仓一致的小写英文,并加契约回归测试(断言大写值分派不到 handler、会进死信)。
if not isinstance(customer_id, int)
str(customer_id)
memory_key
raise
constraint:*
profile:*
constraint:
现改为:接受纯数字字符串;不可投影键跳过并留痕。
投顾线两处生产者的 payload 没有该字段,原本每次画像变更都会死信。现缺失时回退查询该客户 memory_unit 有效记忆并记 warning(使"谁没提供"保持可见)。兜底不掩盖格式错:键存在但类型不对时不兜底,仍失败关闭。
(PR #7 之后 tests/integration 未整套复跑,故均未被发现)
tests/integration
tools/seed_test_rbac.py
review_t
zip(user_ids, role_ids, strict=True)
ValueError
commit()
CustomerProfileCandidateService._write_profile_snapshot
current_customer_id
uk_profile_snapshot_current
AGENTS.md
profile_snapshots
app/model/profile.py 与 app/model/risk_questionnaire.py 各有一个 ProfileSnapshot 映射同一张表。单独导入各自都没事,同时导入即抛 InvalidRequestError —— Worker 既要重建画像又要处理投顾问卷,真的会被打挂(库里留下 last_error='InvalidRequestError' 的行)。已修为 re-export。
app/model/profile.py
app/model/risk_questionnaire.py
ProfileSnapshot
InvalidRequestError
last_error='InvalidRequestError'
完整推演见 docs/39-主干合并对策记录.md。
docs/39-主干合并对策记录.md
risk_questionnaire.py
seed_test_rbac.py
_write_profile_snapshot
customer_service.py
COMPANY
HOTLINE
SERVICE_HOURS
400-XXX-XXXX
app/worker/__main__.py
memory_sync_handlers
Neo4jProfileProjection
副作用:app/infrastructure/neo4j_profile_projection.py 因此失去生产引用(它与方案 A 天然互斥,换哪种做法都一样)。已在文件头加 .. warning:: 写明未装配、原因、启用前提 —— 去留待决定。
app/infrastructure/neo4j_profile_projection.py
.. warning::
pytest tests
2 failed, 1415 passed, 2 skipped
pytest tests/integration
102 passed, 1 skipped
mypy app
Success: no issues found in 245 source files
tools/audit_schema.py
tools/check_authoritative_docs.py
tools/check_docs_endpoint_ids.py
tools/check_rbac_seed_consistency.py
那 2 个失败是既有环境项(test_offsite_document_recognition_adapter.py 断言请求体里的中文原文,而 httpx 序列化成 \uXXXX),非本次引入。
test_offsite_document_recognition_adapter.py
\uXXXX
真机验证:真实 embedding(1024 维) + 真实 Milvus 写入并回读通过;消费端失败分支与成功分支(含跳过不可投影键、手机号脱敏)均实测通过。
docs/39
neo4j_profile_projection.py
docs/37
docs/37-记忆投影链路实现说明.md
docs/38-架构对齐-记忆与画像投影链路.md
-X utf8
合并结果:**零冲突**(自动合并 21 文件 / +1430 行)。合并后 HEAD = origin/qyqy_develop = 76e87a3, 两边完全一致(rev-list 双向均为 0)。 架构师这轮做的事(我这边此前没有): - f09ea9e 把我的 NL_develop 并进主干(**第二父就是我的 58849ad,我这条线全部提交已在主干里**) - 3029d0c 补发画像工具白名单完成(release 254 active)+ release-state 证据 - 76e87a3 AGENTS.md 表数口径 51 → 68(并指向我写的 docs/28) - f60915b 补声明 aiosqlite(同事那 6 个用例在干净环境会 ModuleNotFoundError) - b5b0680 修掉我留下的 3 个 mypy 错(全在同事的场外邮件模块,各一行、不动逻辑) - 6c09cde/8268646 新增补发画像白名单脚本(dry-run + 合并前防呆),并发现两个既有发布脚本会丢提示词 - 新增文档:他的评审意见与答复(docs/NL_develop交付说明-评审意见.md 等)、 docs/evidence/knowledge-collections.json、tools/probe_* 两个探针 **他抓到了一个我漏声明的依赖**:`python-docx` —— 我的 `document_parser.py` 用它解析 .docx, 但 pyproject.toml / requirements.txt 里没有,换干净环境跑知识入库会直接 `ModuleNotFoundError: No module named 'docx'`。已随合并进来(本机原本恰好装了,所以本地没暴露)。 本次我只改 AGENTS.md 的基线口径(合并把过期的 mypy/测试数字带回来了): - 测试基线 1034 → **1219 passed / 3 failed**,并逐条说明那 3 个失败都不是代码缺陷 (1 个既有空集缺陷 + 2 个 httpx 中文序列化的环境相关) - mypy 从"181 个错、双方不可比、未装 sqlalchemy2-stubs"改为 **`Success: no issues found in 180 source files`(0 错)**,并附四组复现矩阵说明真因是 SQLAlchemy 补丁版旧(**明确写上"不要装 sqlalchemy2-stubs"**,那是 1.4 的包) 门禁全绿:测试 1219 passed / 文档守卫 37 份无重号 / 结构审计 68 张表 / mypy 0 错 / 迁移 head 一致 / aiosqlite·python-docx·python-pptx 均已声明且已安装。
f09ea9e
3029d0c
76e87a3
f60915b
b5b0680
两个都是"代码里存在但没接对"的缺陷,都不是新功能。 ## A1 客服热线在代码里有两个值(一个出口给假号码) - `app/core/customer_service_rules.py:35` `CONTACT_PHONE = "15936583816"` ← 真号码,安全路由 6 处在用 - `app/service/agent/implementations/customer_service.py:155` `HOTLINE = "400-XXX-XXXX"` ← 占位符,兜底出口在用 后果:**同一个客服给客户两个不同的电话号码**。问"风险等级怎么划分"被安全路由处理时给真号码; 问一个知识库答不了的问题走兜底时给 `400-XXX-XXXX` —— 客户按这个号码永远打不通。 修法:`HOTLINE` / `SERVICE_HOURS` 改为**转发** `customer_service_rules` 的两个常量 (不是"改成相同的值",而是引用同一对象,避免日后再次漂移);工作时间也随之从 "每日 7:00-22:00" 统一为 "工作日 09:00-18:00"(与安全路由出口一致)。 新增守卫测试用 `is` 断言对象同一性 —— 值相等挡不住"两边各写一份恰好相同"的漂移。 ## A2 `transfer_required` 既没落库也没出参 `docs/05` §6.3 一直规定 `GET /agent-runs/{run_id}` 的 `result` 里有 `transfer_required` / `transfer_reason`,但实现里两个都没有:前端判断"这轮要不要转人工" 只能靠**猜正文里有没有兜底话术的开头**(`docs/24` 自己把这称为权宜之计)。 - 写入侧:`conversation_message` **没有** `transfer_required` 列,加列要迁移且规则 4 禁止改既有 字段定义 ⇒ 放进 `tool_calls` 这个现成 JSON 列,作为 `calls` 的兄弟键 (`{"calls": [...], "transfer_required": bool, "transfer_reason": str|None}`) - 读取侧:`RunQueryService.get` 取出来放进 `result`;**兼容历史行**(`calls` 裸列表 / None → 按 False/None 处理,不抛异常、也不凭正文猜) 刻意**没做**的一半:`docs/05` §6.3 的 `result` 里还有 `degraded` / `degradation_reason`, 但 `CoreResult` 里根本没有这两个字段(降级信息目前只在工具出参里)—— 补它要改 `CoreResult` 并让各 Agent 传递降级状态,属另一个改动范围。**已在交付说明里注明这一半仍缺。** ## 真机验证 | 问题 | transfer_required | transfer_reason | 正文电话 | |---|---|---|---| 「请介绍一下量子纠缠在基金估值中的应用」 | **True** | 置信度不足:score=0.571 gap=0.004 | 15936583816 ✅ | 「请帮我计算一下三体问题的数值解」 | **True** | 置信度不足:score=0.499 gap=0.011 | 15936583816 ✅ | 「基金申购后多久确认」(正常知识直返) | False | — | 无(正确) | 「你们公司明天会下雪吗」(闲聊出口) | False | — | 无(正确) | (第一次我用"下雪"当兜底用例,结果它被闲聊出口正确接住了 —— 是我的期望值写错,不是代码问题。) 门禁:测试 1223 passed(新增 4 个用例)/ 3 failed(均为已知非代码缺陷)/ mypy 0 错。
合并 ZSY 分支前必须先做的对齐。核对结论与"线没接"的旧说法**不一致**: **主干那条链其实已经接好了**——架构师在 09-10 21:52 的 d7f6ef7 就做完了"记忆→画像→图全自动触发", 走的是 `domain_event_outbox` + `profile.rebuild_requested`,**已注册进 runtime.py 的 handler 白名单**: agent.run_completed → memory.extraction_requested → MemoryExtractionService(提升 user_facts) → profile.rebuild_requested → ProfileAssemblyService 重建画像 + ProfileGraphProjectionService 投影 Neo4j 而 ZSY 走的是**另一条 outbox**(`memory_sync_outbox` + 自建 MemorySyncOutboxWorker)。 ⇒ 两条平行链做同一件事,但 outbox、画像生成方式、消费装配都不同。 顺带修正一处旧结论:`graph_projection_worker.py` 确实**零实例化**(架构师说的对), 但由此推论"整条链没接"不对——接的是 handler 字典,不是那个类;那个类是死代码。 文档内容: - §1 主干链的完整链路与引入者、现场数据(user_facts 0 行 / memory_sync_outbox 2 行是**我的**生产者写的) - §2 ZSY 的**真正增量**逐文件列表(只有 3 个是主干完全没有的:Milvus 画像投影适配器、 memory_sync_outbox worker、画像候选复核流程) - §3 四个必须对齐的分歧(两条 outbox 的分工 / 直接快照 vs 候选复核 / 两套客服 Agent / ZSY 落后 144 提交) - §4 三步收口方案(先只移植投影适配器,再定 outbox 分工,其余等裁决) - §5 给架构师与张胜宇的四个问题 - §6 明确不做的事(不整体合并、不改已验链路) 文档守卫通过;本文只做核对,未改任何代码。
d7f6ef7
投顾线(bbf623a)带进两份文档,与既有的撞号: 21-投顾Agent迁移TODO.md → 30-(原 21 已被《风控业务第二版迁移清单》占用) 22-投顾Agent灰度与回滚操作手册.md → 31-(原 22 已被《四大Agent流程实现拆解》占用) 处理原则与 docs/27 一致:**让新来的那份让号**(既有那份被更多地方引用)。 同时修了 30 号文档里两处指向旧号的自引用。守卫恢复通过(40 份无重号)。 另含 docs/29-架构对齐-记忆与画像投影链路.md(合并 ZSY 分支前必须做的对齐, 结论见该文档:主干那条链**其实已经接好**,走的是 domain_event_outbox + profile.rebuild_requested; ZSY 走的是另一条 memory_sync_outbox,两条平行不冲突,但需要先明确分工)。
背景:memory_sync_outbox 这条链此前**完全没有消费者**,且生产端照 docs/00 §6.4.6 写成大写 MILVUS/NEO4J + 中文「待处理」,而消费端按 target_store 的**值**分派 handler、 且只领 status in {pending, failed} —— 两个条件都不满足,事件任何消费者都领不到、 永久滞留且不报错(唯一键 (event_uuid, target_store) 对大小写无约束,MySQL 也不报错)。 根因是代码与测试都硬编码字面量,所以测试跟着一起错、谁也没拦住。 订正 - profile_generation_service:取值改为全仓一致的小写(milvus/neo4j/upsert/pending) - 测试改为引用常量并断言消费端契约,不再硬编码(硬编码是本次跑偏的直接原因) - 新增契约回归测试:断言大写值分派不到 handler、会进死信,谁改回大写立刻红 - 新增 tools/normalize_memory_sync_outbox.py:订正历史脏行(默认 dry-run、幂等) 接通投影链路(此前零消费者) - 新增 Milvus 集合 user_long_term_memory_v1 及建集合工具(幂等、不覆盖已有集合) - 新增 MilvusProfileProjection / MilvusProfileVectorClient,并修掉移植带来的两处必炸点: customer_id 由「必须 int」放宽为接受数字字符串(本仓所有生产者都写 str, 不放宽则每个事件必然失败);不可投影的 memory_key 由「整批 raise」改为跳过留痕 (否则一条 constraint: 记忆毒死该客户整批,而受控词表 13 个键里有 7 个不满足前缀) - 新增 MemorySyncOutboxWorker(领取/指数退避/死信骨架保留原样)并接入 WorkerRuntime - milvus → 向量投影;neo4j → 复用主干 ProfileGraphProjectionService(方案 A, 不引入第二套投影,避免同一事实在图中两种说法、违反主干既有的只投影已确认事实的不变式) - 生产端从 memory_unit(status=active) 组装 memory_sources,随事件带上确定快照 - 前置移植 conversation_privacy:写外部存储前脱敏手机号/证件号/银行卡等 验证 - 新增 17 个单测;全量 2 failed, 1307 passed, 2 skipped (2 个失败为既有环境项:断言请求体中文原文而 httpx 序列化成 \uXXXX,非本次引入) - mypy app → 0 错(227 文件);audit_schema → 89 张业务表无缺失/意外,未改动表结构 - 真机:真实 embedding(1024 维) + 真实 Milvus 写入并回读通过 - 整合链路(测试记忆 → 生产端组装 → outbox → 消费端投递 → Milvus 回读)通过, 且 MySQL 已回滚、Milvus 无残留 文档 - 新增 docs/32-记忆投影链路实现说明.md:真实口径、根因、契约与验证证据(供接手) - AGENTS.md:新增该易错点;新增 Windows 中文输出乱码的正确命令(-X utf8); 校正测试基线与 mypy 文件数 未做:未改 docs/00 基线、未动数据库迁移、未改投顾线代码、未启动常驻 Worker。 遗留:投顾线两处生产者的 payload 缺 memory_sources,会被消费至死信,待架构师确认是否投影。
架构师线 2026-09-12 推送的 `docs/29-Agent组员登录接口使用说明.md` 占用了 29, 与本线的《架构对齐 · 记忆→画像→图》重号。按"以架构师为主线"的口径,本线让号。 - `git mv docs/29-架构对齐-记忆与画像投影链路.md docs/33-架构对齐-记忆与画像投影链路.md` (用 mv 而非增删,保留文件历史) - 该文档开头补"⚠️ 后续(2026-09-12)"提示:结论**已落地实施**,指向 `docs/32-记忆投影链路实现说明.md`;避免接手人按原文的"本文只做核对与建议, 不改任何代码"误判为尚未实施。同时记录让号原因,避免编号来历不明 - 文档内部无自引用编号、全仓无 `docs/29` 引用(已全量搜索确认),故无引用需改 验证 - `tools/check_authoritative_docs.py` → checked 41 documents, no number collision - docs 编号现为 30(投顾迁移TODO) / 31(投顾灰度) / 32(记忆投影链路) / 33(架构对齐) 说明:架构师分支上 `docs/21`、`docs/22` 各自有两份(投顾线带入的重号), 本线已在 c82e989 改号为 30/31,合并后该重号自动消除,无需额外处理。
c82e989
## 1. 独立缺陷:`profile_snapshots` 被两个 ORM 类重复映射 排查 `memory_sync_outbox` 中 `target_store='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` 无需改动。原定义多映射的 `current_customer_id` 经全仓核查无人使用,故不保留 (`app.model.profile` 明确注明该列由数据库维护、故意不映射)。 ## 2. 消费端兜底 `memory_sources` `memory_sources` 是本线新增的投影入参,而投顾线两处生产者的 payload (`{customer_id, profile_uuid, version, profile}`)没有这个键,原样会导致它们每次画像 变更都 `memory_sources is invalid` → 重试至死信。 新增 `WorkerRuntime._with_memory_sources()`:**键缺失或为 None** 时回退查询该客户 `memory_unit` 中 `status='active'` 的记忆,并记 warning(使"谁没提供"保持可见)。 语义成立:长期记忆是**客户级**而非画像版本级的,每条记忆自带 `version`, 适配器按 `memory_uuid + version` 幂等,故"用的是哪一版"仍确定。 **兜底不掩盖真错误**:键**存在但格式不对**时**不兜底**,原样交给适配器失败关闭。 实现上用键存在性判断而非 `isinstance`——后者会把"缺失"与"格式错"混为一谈, 那是初版实现里的一个真 bug,被新测试抓出后修正。 ## 3. 补上此前欠缺的消费端全路径验证 此前"整合验证"是直接调适配器,跳过了 outbox 的领取→分派→状态更新。 - **失败分支**(Milvus 断开时实测):行被领取、按 target_store 分派、异常被捕获、 `status`/`retry_count`/`last_error`/`next_retry_at` 正确落库。 - **成功分支**(注入替身向量客户端):outbox 行 → `processed`、`processed_at` 已写; 不可投影的 `constraint:` 被跳过(只写 1 行);维度 1024;字符串客户号转 int; **手机号脱敏生效**(`稳健型投资者,手机号 [手机号已隐藏] 请勿外泄`)。 - **兜底实证**:历史行 `id=5`(payload 无 `memory_sources`)经兜底后成功投递为 `processed`。 - **修复实证**:`id=6` 的 `last_error` 从 `InvalidRequestError` 变为 `RecoverableAgentError`(图库不可用)——证明重复定义缺陷确已消除,剩下的是环境问题。 ## 4. 测试与验证 - 新增 `tests/unit/worker/test_runtime_profile_projection.py`(5 用例:已提供原样透传、 缺失兜底、空记忆给空列表而非删键、无客户号不兜底、格式错不兜底) - 全量:`2 failed, 1312 passed, 2 skipped`(2 个失败为既有环境项,非本次引入) - mypy:`Success: no issues found in 227 source files` - 表结构审计:89 张业务表无缺失/意外(未改动任何表结构) - 文档守卫:41 份文档无编号冲突 ## 5. 文档 `docs/32-记忆投影链路实现说明.md` 增补 §6.1(兜底)、§6.2(重复定义缺陷)、 §7.1(消费端全路径验证)并更新验证表与文件清单; `AGENTS.md` 新增"一张表只能有一个 ORM 类"易错点、校正测试基线数字。 ## 未做 未改 `docs/00` 基线、未动数据库迁移、未改投顾线生产者代码、未启动常驻 Worker。 遗留:投顾线两处生产者的 payload 仍缺 `memory_sources`(已有兜底,不再死信, 但根治应由投顾线确认);Milvus/Neo4j 容器本轮不可用(Docker Desktop 崩溃), `id=6` 停在 failed 属环境不可用、非代码缺陷。
主干 `qyqy_develop` 已占用 29–36(29 登录接口 / 30 投顾迁移TODO / 31 投顾灰度 / 32 平台侧交接与联调准备 / 33–35 ZSY 底座扩展确认往来 / 36 PR7 合并记录), 本线原用的 32、33 与之重号。按"以架构师为主线"继续让号。 - `docs/32-记忆投影链路实现说明.md` → `docs/37-记忆投影链路实现说明.md` - `docs/33-架构对齐-记忆与画像投影链路.md` → `docs/38-架构对齐-记忆与画像投影链路.md` (两次 `git mv`,保留文件历史) - 同步更新 `AGENTS.md` 内 2 处引用、`docs/38` 内 2 处交叉引用 - 文档内部无自引用编号;全仓已无其它 `docs/32`、`docs/33` 引用(全量搜索确认) 验证:`tools/check_authoritative_docs.py` → checked 41 documents, no number collision
共同祖先 bbf623a;主干 54 个提交、118 个文件;本线 25 个文件;9 个冲突文件。 主干这次把 **ZSY 的整条投影实现合进来了(PR #7)**,而本线此前的提交正是 移植并修正同一套代码 —— 因此冲突的本质是"同一功能两份实现并存",取舍错了会把 已修好的缺陷又带回来。逐项取舍与理由见 `docs/39-主干合并对策记录.md`。 ## 取舍(9 个冲突) 取本线: - `app/infrastructure/milvus_profile_projection.py` —— 主干是 ZSY 原版,含两处必炸点: ① `customer_id` 要求 int 而本仓所有生产者都写 `str` ⇒ 每个事件必然失败; ② 不可投影的 `memory_key` 直接 raise ⇒ 一条 `constraint:` 记忆毒死整客户整批。 本线版已放宽为「接受纯数字字符串」与「跳过并留痕」。 - `memory_sync_outbox_worker.py` / `conversation_privacy.py` / `risk_questionnaire.py` —— 代码逐行一致,仅注释与说明文字详略不同(`risk_questionnaire.py` 两边**独立做了 完全相同的修复**,都改成 re-export `app.model.profile`)。 - 两个投影测试文件 —— 本线是他那份的**超集**(4→10、4→5 例,包含他全部用例)。 两边合并: - `app/worker/runtime.py`:`__init__` 两边各加一个参数,都要。 - `app/service/agent/implementations/customer_service.py`:import 取并集; `COMPANY` 取主干的「奶龙基金责任有限公司」("奶龙"是本项目实际品牌名,主干多处出现), `HOTLINE`/`SERVICE_HOURS` **取本线的修复**(主干仍是占位符 `400-XXX-XXXX`, 本线已改为引用 `customer_service_rules` 的唯一来源 —— 这是 A1 缺陷修复, 否则同一客服给客户两个不同号码)。 - `AGENTS.md`:表数/Agent 清单取主干(90/89、7 个 Agent),本线的 `-X utf8` 与两条 outbox 易错点保留,测试基线按合并后实测重算。 ## 消费端只保留一套(本次最重要的一处) 合并后曾出现**两套消费者读同一个 `memory_sync_outbox`**:`__main__.py`(PR #7) 与 `runtime.consume_profile_projections()`(本线),而**两者的 neo4j handler 不同** —— 前者用 ZSY 的 `Neo4jProfileProjection`(按客户各建私有节点), 后者用主干 `ProfileGraphProjectionService`(共享 tag 节点、只投影已确认事实)。 同一事件被谁领到结果不定,等于"同一事实在图里有两种说法",正是**方案 A 要避免的状态**。 现只保留 runtime 那一套(带 `memory_sources` 兜底、neo4j 复用主干服务), 删除 `__main__.py` 的重复接线;装配入口职责仍在该文件(注入 `relationships` / `projection_cleaner`),Milvus 客户端由 `bootstrap` 工厂惰性构造、缺配置时显式降级。 副作用:`app/infrastructure/neo4j_profile_projection.py` 不再被生产代码引用,成为 **死代码**(本线未删,属架构师线,其单测仍在)—— 待架构师决定删或明确分工。 ## 顺带修掉的 3 个继承缺陷(主干同样存在,PR #7 后未整套复跑故未发现) 1. `tools/seed_test_rbac.py` **少建 `review_t`(9004)账号** —— 两个集成测试都依赖它 ("账号存在但无权限应返回 200 空集而非 404"、`PLACEHOLDER_ACCOUNTS`)。 同时把用户↔角色绑定从 `zip(..., strict=True)` 改为**显式配对表**:原写法隐含 "USERS 与 ROLES 一一对应",一加不绑角色的账号就 ValueError、整个种子跑不完 (commit 在最后,外部表现是"什么都没发生")。 2. `CustomerProfileCandidateService._write_profile_snapshot` **漏写 `current_customer_id`** —— 该列不是生成列而是普通可空列 + 唯一键 `uk_profile_snapshot_current`, 不写则唯一键形同虚设(多个 NULL 不冲突),且旧当前版本也没清该列、补写就会撞键。 现旧值置 None、新值显式写入(与 `ProfileGenerationService._clear_current` 一致)。 3. 集成测试前置未记录 —— 13 个登录/RBAC 用例因 401 而红,实为"测试账号不存在", 跑 `seed_test_rbac.py` + `set_user_password.py` 后转绿;已在 `AGENTS.md` 记明, 避免被误判成代码缺陷。 ## 文档 - 新增 `docs/39-主干合并对策记录.md`(逐文件取舍 + 理由 + 遗留) - `docs/37` 订正一处过时说法:曾写 `current_customer_id` 无人使用且故意不映射, 实际 `app/model/profile.py` 已映射且有人使用(详见该文档 §6.2 的订正块) - 文档编号:主干已占 29–36,本线两份文档让号至 `docs/37`、`docs/38` ## 验证(合并后实测) - `pytest tests`(全量)→ `2 failed, 1396 passed, 2 skipped` - `pytest tests/integration` → `102 passed, 1 skipped`(修上述 1、2 后从 15 failed 归零) - `mypy app` → `Success: no issues found in 245 source files` - `tools/audit_schema.py` → 89 张业务表无缺失/意外(未改动任何表结构) - `tools/check_authoritative_docs.py` → 52 份文档无编号冲突 - `tools/check_rbac_seed_consistency.py` → 通过 那 2 个失败是既有环境项(`test_offsite_document_recognition_adapter.py` 断言请求体 中文原文而 httpx 序列化成 `\uXXXX`),与本次合并无关。
该适配器与主干 `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 跑真实 Worker 时发现) `episode_worker` 反复报 `模型记忆抽取输出不是有效 JSON` 并重试到失败: ``` pydantic_core.ValidationError: Input should be a valid dictionary or instance of _ExtractionPayload input_value=[{'memory_key': None, 'value': None, 'memory_type': None, 'confidence': 0}] input_type=list ``` ## 根因 契约是**对象** `{...}`,而模型在 episode 抽取路径会返回**单元素数组** `[{...}]`。 `_parse` 直接 `json.loads` 后交给 pydantic,数组自然过不了 `model_validate`, 于是被归入"输出不是有效 JSON"这一条 —— 但它其实是**合法的空结果** (`_validate` 已能把"三字段为 null 且 confidence=0"正确识别为"无持久事实",返回 None)。 后果:这类 episode 白跑一遍模型调用、重试到 `retry_count` 上限后判失败, **该片段的记忆永远抽不出来**。 ## 修法 `_parse` 里只对"**恰好一个对象**的数组"做归一化: - `[{...}]` → 取 `{...}`(空结果照常返回 None;有事实照常解析) - 多元素数组、元素非对象、空数组 → **不猜**,仍交给校验失败关闭 (多元素时无法判断哪个是答案,猜错会把错误记忆写进库,比失败更糟) ## 测试 `tests/unit/service/test_memory_extraction_service.py` 追加 2 个用例: - `test_single_element_array_is_normalized`:数组包空结果 → None;数组包有事实 → 正常解析 - `test_multi_element_or_non_dict_array_still_fails_closed`:多元素 / 非对象元素 / 空数组 → 仍失败 ## 验证 - `pytest tests/unit/service/test_memory_extraction_service.py tests/unit/worker/test_memory_extraction_worker.py` → 28 passed - `mypy app` → 0 错 / 245 文件 > 说明:`memory_extraction_service.py` 属主干线代码。此处是从**实际运行日志**里发现的 > 健壮性缺陷,改动限定在输出形状归一化,不改变抽取契约与校验规则。
## 现象(2026-09-12 端到端跑通后查数据时发现) 客户 9102 的画像快照状态**彼此矛盾**: | version | is_current | current_customer_id | |---|---|---| | 2 | `0` | `9102` ← 清旧时没清空 | | 3 | `1` | `NULL` ← 建新时没写入 | ## 根因 `ProfileAssemblyService._write_snapshot` 把 `current_customer_id` **当成了生成列**: - 方法 docstring 原文写着"唯一键 `uk_profile_snapshot_current` 建立在**生成列** `current_customer_id` 上" - 因此两处都不赋值(以为 DB 会自动填) **但该列不是生成列** —— `alembic/baseline_generated.sql` 与真实库都是**普通可空列 + 唯一键**, `app/model/profile.py` 的模块 docstring 第 2 条已明确:"当前版本必须由写入方**显式写入**客户 ID (历史版本写 NULL),才能保证「每个客户最多一条当前快照」"。 后果:唯一键**形同虚设**(多个 NULL 不冲突)⇒ 不变式失效;且旧版本残留的值 一旦与新版本补上的值相同,就会**直接撞唯一键**。 > 这与本线先前修的 `CustomerProfileCandidateService._write_profile_snapshot` 是**同一个缺陷的另一处** > —— 当时只找到一处,这次是靠真实链路跑出数据后核对才暴露出来。 ## 改动 `app/service/profile_assembly_service.py`: - 旧版本:`is_current = False` 的同时 `current_customer_id = None` - 新版本:`is_current=True` 的同时 `current_customer_id=customer_id` - 订正方法 docstring 的错误认知("生成列"→ 普通可空列 + 唯一键),并写明后果 ## 已有数据订正 新增 `tools/fix_profile_snapshot_current.py`(**默认 dry-run**、幂等、`--apply` 才提交): 1. 先清空 `is_current=0` 却残留值的行 2. 再补写 `is_current=1` 却是 NULL 的行 3. **顺序要紧**:反过来的话第 2 步会与残留值撞唯一键 本机实测:清空 1 行、补写 1 行,复核两类异常均归零。 ## 验证 - `mypy app` → 0 错 / 245 文件 - `pytest tests`(全量)→ `2 failed, 1418 passed, 1 skipped` (2 个失败为既有环境项:httpx 把中文序列化成 `\uXXXX`,非本次引入) - 端到端:真实对话 → 记忆抽取 → `memory_unit` 落库已实测通过(客户 9102 `preference:risk_level = "低风险"`,候选态)
## 1. 补回被我丢失的门禁条目 主干 `AGENTS.md` 原本有一条 `ruff` 干净,但我在解决 `AGENTS.md` 冲突时**只保留了自己的 mypy/pytest 基线,把这条丢了** —— 合并时丢信息,和代码冲突一样是缺陷。 本次补回,并把命令写准(这点很重要): - `ruff check app tests tools alembic` → **`All checks passed`(0 错)** - 直接 `ruff check .`(全仓)会报 **40 个错,全部来自仓库根目录的 `hq.py` / `nl2sql_yc.py`** (袁聪线的演示脚本,不属本项目包结构) 所以"ruff 干净"**必须带范围**,否则会和别人的脚本混在一起、把一个健康状态误报成 40 个错。 ## 2. 我的一个真实疏漏(已由架构师修正,本次合并带入) 架构师提交 `ffbcc22`:**`fix: 删掉 NL 合并后残留的未使用变量 role_ids(ruff F841)`** 该疏漏是我引入的:改 `tools/seed_test_rbac.py` 时把 `zip(user_ids, role_ids, strict=True)` 换成显式配对表 `USER_ROLES`,删掉了 `user_ids` 却**没删 `role_ids`**。根因是**我全程没跑过 ruff** —— 项目门禁里有它, 只跑 mypy 和 pytest 是不够的。 已在本机复跑 `ruff check app tests tools alembic` 确认:我改过的文件全部干净 (`role_ids` 已随主干修正进来)。 ## 3. 合并主干 6 提交 `fedbf5a..c8cdc06`,含上条修正与投顾线的行情双源、验收归档等,**无冲突**。 ## 验证 - `ruff check app tests tools alembic` → `All checks passed` - `mypy app` → 0 错 / 245 文件 - `pytest tests`(全量)→ `2 failed, 1421 passed, 1 skipped` (2 个失败为既有环境项:httpx 把中文序列化成 `\uXXXX`,非本次引入)
最终验收在你机器上跑,所以把"环境数据"的部分单独列出来 —— 这些不随代码合并, 缺了会产生看起来像代码缺陷的失败(正如你在 docs/32 §5.1/§5.2 反复强调的那类)。
docs/32
python -X utf8 tools/setup_milvus_profile_collection.py
load_collection
RecoverableAgentError("画像向量集合不可用")
docker ps
milvus-standalone
MILVUS_LOCAL_URI
task_type=embedding
_profile_embed
--requirepass 123456
.env
REDIS_URL
AuthenticationError
config_release
第 4 条我本机已修(REDIS_URL=redis://:123456@127.0.0.1:6379/0),但 .env 被 .gitignore, 不进仓库 —— 你那边如果也是同一个容器启动方式,需要同样补一下。 这一条你 docs/32 §5.1 已经列为前置了,我这里只是给出具体的失配点。
REDIS_URL=redis://:123456@127.0.0.1:6379/0
.gitignore
pytest tests/unit tests/contract
2 failed, 1421 passed, 1 skipped
ruff
ruff check app tests tools
ruff check app tests tools alembic
那 2 个 failed 就是你在 docs/32 §5.2 记为环境相关的 test_offsite_document_recognition_adapter.py(httpx 把中文序列化成 \uXXXX)—— 你已经写明"主干架构师环境复现不了、不要为了让它绿去动实现",我这里确认同一个现象,没有动实现。
另:全仓 ruff check . 会报 40 个错,全部来自根目录 hq.py / nl2sql_yc.py(袁聪线脚本), 我已在 AGENTS.md 里把门禁命令写清为 ruff check app tests tools alembic,避免下一个人把它读成"项目 40 个错"。
ruff check .
hq.py
nl2sql_yc.py
之前本线的证据都停在组件级(我用替身客户端验证)。这次起了真实常驻 Worker,跑出:
episodes: 0 → 50 (其中 49 个"已完成") domain_event_outbox 积压: 845 → 0(published 1136) memory_unit: 0 → 1 ← 真实对话 → 抽取 → 落库
那 1 条来自一次真实对话(客户 9102,含明确偏好陈述): preference:risk_level = "低风险"、status=candidate(候选态,按设计需审核后才提升为 user_facts)。
preference:risk_level = "低风险"
status=candidate
user_facts
顺带在这次真实运行里发现并修掉 3 个缺陷(都在本 PR 内):
memory_extraction_service._parse
[{...}]
ProfileAssemblyService._write_snapshot
tools/fix_profile_snapshot_current.py
role_ids
F841
ffbcc22
合并就绪(已追平主干,2026-09-12 更新):
c200a61..5634fdc
check_rbac_seed_consistency.py
app/service/profile_assembly_service.py
合并前请再 git fetch 一次确认(这条线今天动得勤)。上面那条长评论是环境前置与数字差异。
git fetch
主干 `c200a61..5634fdc` 3 个提交(advisor 演示环境引导、风控登录权限与模型能力配置), 与本线 5 个提交**无文件重叠**,合并**无冲突**(`tools/seed_test_rbac.py` 双方都改过, Git 自动合上;合并后 `check_rbac_seed_consistency.py` 仍通过)。 追平后本线相对主干恢复 **BEHIND 0**,PR #9 可重新 fast-forward 合并。 ## 验证(合并后重跑) - `ruff check app tests tools alembic` → `All checks passed` - `mypy app` → 0 错 / 245 文件 - `pytest tests`(全量)→ `2 failed, 1424 passed, 1 skipped` (2 个失败为既有环境项:httpx 把中文序列化成 `\uXXXX`,非本次引入) - `check_authoritative_docs.py` → 53 份文档无编号冲突 - `check_rbac_seed_consistency.py` → 通过(种子 id 唯一、各 grant 脚本与种子逐条一致) - `check_docs_endpoint_ids.py` → §19 端点编号无重复 - `audit_schema.py` → 89 张业务表无缺失/意外
## 1. 新增客户权益(用户端) `GET /api/v1/users/me/entitlements`(T010,权限 `benefit:read:self`): - **层级**由 `fin_customer_profile.total_asset` **实时判定** (门槛来自 `knowledge/product/高净值客户服务规范.md`: 金卡 50 万 / 白金 200 万 / 钻石 600 万 / 私行 1000 万;低于 50 万为普通客户); - **权益按层级累积展开**(文档原文"含全部下级权益,新增以下"): 金卡 9 条 / 白金 20 / 钻石 33 / 私行 54,各档已逐档实测; - 返回**升级提示**(`next_tier`:下一层级与门槛),前端可直接渲染"再投 X 元升级"。 ### 新增表 `fin_customer_benefit`(1 张) 层级 → 权益目录,54 条种子数据(`tools/seed_customer_benefits.py`,按 `benefit_code` 幂等)。 **基线合规证明**(规则 1/3/4):只新增这一张表;**未**重命名/删除任何已有表; **未**重命名/删除/复用任何已有字段,**未**改任何已有字段的类型、可空性或业务含义; 未改 `docs/00`。 复核:`tools/audit_schema.py` → `90 business tables, no missing or unexpected tables`。 ### 两条设计取舍 1. **不落"某客户享有哪些权益"**:层级可算,权益由层级推出,两者都不落库。 与 `docs/00` L159(不保留 `net_worth_flag`,因为可算)同一取向。 2. **权益只存各层新增条目**,累积由服务层 `tier_chain()` 展开 —— 否则改一条权益要改四处,漏一处就出现"白金没有金卡权益"。 ### 数据来源与一处刻意省略 逐条照抄知识文档,不新增文档里没有的权益。**私行那条 「7×24小时私人银行专线:400-XXX-XXXX 转 8」不写号码** —— 文档里是占位符,而对客号码的唯一来源是 `customer_service_rules.CONTACT_PHONE` (本线此前修过"同一客服给客户两个不同号码"的缺陷)。把占位符抄进库等于再造一份假号码。 ## 2. 修投顾迁移契约里写死的断言 `tests/unit/test_advisor_migration_contract.py` 原先断言 ```python assert script.get_heads()[0] == "20260911_merge_adv_risk_heads" ``` 那是"投顾迁移刚加完那一刻"的快照 —— 本 PR 一新增迁移(`20260912_customer_benefit`) 它就变红,**而红的原因与投顾链的对错无关**:断言测到的是时间,不是契约。 原意是"投顾链接在这条主链上、没另起分支"。改为断言**投顾链尾是当前 head 的祖先** (链尾从 `ADVISOR_FILES[-1]` 派生,不写死),既保住原意又不受后续迁移影响。 `len(script.get_heads()) == 1`(链不分叉)与"投顾文件首尾相接"两条原样保留。 ## 3. 顺带发现的既有缺口(**不在本次改动范围**) `app/api/controllers/trading.py` 的 **T001–T009 未调用 `AuthorizationService.require`**: `docs/05` §19 为它们登记了权限码(`account:read:self` / `trade:order:*` / `holding:read:self`), 但代码只做认证 + 开户测评门槛,**没有执行 RBAC 权限检查**。 对照:仓库里 **26 个 service** 都调了 `require`,`trade_service` 不在其中。 本线的 T010 **按正确做法实现**:`CustomerBenefitService.entitlements_for` 先鉴权再读数据, 且**鉴权在读取客户资产之前**(有测试断言"拒绝时未查库")。 T001–T009 如何补,需架构师定口径后另行处理。 ## 4. 文档 - 新增 `docs/41-客户权益功能说明.md`:表登记 + 基线合规证明 + 分层口径 + 累积规则 + 数据来源 + 权限 + 与仪表盘的关系 + 上述缺口 - `docs/05` §19 登记 T010,并**单独注明它引入了新表**(避免被误读为 "T 段数据库零变更"的一部分) - `AGENTS.md` 表数 89 → **90** 张业务表 ## 验证 - `pytest tests/unit/service/test_customer_benefit_service.py` → **20 passed** (含边界:499999.99 不是金卡、500000 整是金卡、1000 万整是私行;累积条数;升级提示; 鉴权先于读数据) - 全量 `pytest tests` → `2 failed, 1469 passed, 1 skipped` (2 个失败为既有环境项:httpx 把中文序列化成 `\uXXXX`,非本次引入) - `ruff check app tests tools alembic` → `All checks passed` - `mypy app` → **0 错 / 252 文件** - 真机:`GET /users/me/entitlements` → `200`;各档分层与累积条数逐档实测通过 - `audit_schema.py` → 90 张业务表无缺失/意外;文档守卫 55 份无编号冲突; 端点编号无重复;RBAC 种子一致性通过
真机实测到的缺陷:`tools/seed_sim_account_demo.py` 的 `_upsert_market_price()` 在"当天已有行"时直接 `return`,于是同一天重跑种子**不刷新 `source_updated_at`**。 行情是时效数据,过期后 `FundQuoteService` 返回 `503 FUND_QUOTE_UNAVAILABLE:产品 510300 行情已过期`, 连带 `T001` 仪表盘与 `T006` 持仓一起不可用(`T010` 权益不查行情,仍 200)。 表现极具误导性:**刚灌完种子能用,过十几分钟就 503** —— 看起来像行情适配器或缓存故障,实际根因在种子脚本。归因过程: `memory_sync` / Redis / Milvus 全部正常,`fin_market_price` 里当天那行 `source_updated_at` 停在首次灌入时刻。 修法:把列值抽成共享 `values` 字典,当天已有行时 `update` 刷新全部行情列 (含 `source_updated_at`),不存在才 `insert`。 **幂等的正确含义是"不产生重复行"(`(product_id, trade_date)` 唯一), 不是"不更新值"。** 账户/持仓的"已存在则跳过"保持不变 —— 那是业务数据,不该被种子覆盖。 验证(本机,测试客户 9001 `cust_t`): - `python -X utf8 -m tools.seed_sim_account_demo --customer-id 9001` 正常 - `GET /api/v1/users/me/account/dashboard` → 200(修前 503) - `GET /api/v1/users/me/holdings` → 200 - `GET /api/v1/users/me/entitlements` → 200 门禁:ruff `app tests tools alembic` 全过;`mypy app` 0 错(252 文件); `audit_schema.py` 90 张业务表无差异;文档编号/端点编号/RBAC 种子一致性全过。
tools/seed_sim_account_demo.py
1f62aca
真机验收时 T001 仪表盘返回 503 FUND_QUOTE_UNAVAILABLE:产品 510300 行情已过期, T006 持仓同样 503,而 T010 权益 200。排查结论在种子脚本里:
T001
503 FUND_QUOTE_UNAVAILABLE:产品 510300 行情已过期
T006
T010
_upsert_market_price() 当天已有行时直接 return,所以同一天重跑种子不刷新 source_updated_at。 行情是时效数据(FundQuoteService 按 source_updated_at 判新鲜度),于是表现为 「刚灌完种子能用、过十几分钟就 503」—— 看着像行情适配器或缓存故障,实际与它们无关。 fin_market_price 里当天那行的时间戳停在首次灌入时刻,是判定依据。
_upsert_market_price()
return
source_updated_at
FundQuoteService
fin_market_price
修法:列值抽成共享 values,已有行就 update 刷新(含 source_updated_at),无行才 insert; 账户/持仓的「已存在则跳过」不动(业务数据不该被种子覆盖)。 幂等 = 不产生重复行((product_id, trade_date) 唯一),不是「不更新值」。
values
update
insert
(product_id, trade_date)
改后验证(本机 9001 cust_t):dashboard 200 / holdings 200 / entitlements 200; ruff check app tests tools alembic 全过、mypy app 0 错(252 文件)、pytest tests 与本线基线一致 (2 failed, 1469 passed, 1 skipped,2 个仍是 test_offsite_document_recognition_adapter 的中文转义环境差异,按 docs/32 §5.2 不改实现)。
cust_t
dashboard
holdings
entitlements
2 failed, 1469 passed, 1 skipped
test_offsite_document_recognition_adapter
⚠️ 顺便提醒:这条坑会让「灌完种子立刻验收」和「过一会儿再验收」结论不同, 建议 docs/32 或验收清单里注明「灌完种子立刻跑,或验收前先重跑一次种子」。
另,两个路径口径(前端接入时容易猜错,已用 app.routes 实测列出):
app.routes
GET /api/v1/users/me/entitlements
/account/
/benefit/
GET /api/v1/users/me/holdings
/users/me/
/users/me/account/holdings
用户端现有 13 个 GET/POST 端点(memory-profile、memory-candidates 及其决策、 account/dashboard、orders 及其详情/撤单、holdings、transactions 及其详情、cash-ledger、entitlements)。 经确认产品(列表/详情/行情)在用户端没有任何 HTTP 端点,且 fin_product 除演示种子外为空 —— 如果前端需要「产品列表/详情/行情」这几屏,需要新增端点,目前只能靠 Agent 工具兜。
GET/POST
fin_product
No dependencies set.
The note is not visible to the blocked user.
交付内容
1. 接通画像投影链路(此前零消费者)
user_long_term_memory_v1+MilvusProfileProjection+MilvusProfileVectorClient+MemorySyncOutboxWorker+ 建集合工具WorkerRuntime.consume_profile_projections()接入run_once():milvus→ 向量投影;neo4j→ 复用主干ProfileGraphProjectionService(方案 A,不引入第二套图投影)memory_unit组装memory_sources;写入外部存储前经conversation_privacy脱敏2. 订正
memory_sync_outbox取值口径(此前静默失效)原写法照
docs/00§6.4.6 用大写MILVUS/NEO4J+ 中文待处理,而消费端按target_store的值分派 handler、且只领status in {"pending","failed"}—— 两个条件都不满足 ⇒ 事件任何消费者都领不到、永久滞留且不报错(唯一键(event_uuid, target_store)对大小写无约束,MySQL 也不报错)。已改为全仓一致的小写英文,并加契约回归测试(断言大写值分派不到 handler、会进死信)。
3. 修掉移植带来的两处必炸点
if not isinstance(customer_id, int)str(customer_id)⇒ 每个事件必然失败、重试 5 次进死信memory_key直接raiseconstraint:*/profile:*)不满足前缀 ⇒ 一条constraint:记忆毒死该客户整批现改为:接受纯数字字符串;不可投影键跳过并留痕。
4. 消费端
memory_sources缺失兜底投顾线两处生产者的 payload 没有该字段,原本每次画像变更都会死信。现缺失时回退查询该客户
memory_unit有效记忆并记 warning(使"谁没提供"保持可见)。兜底不掩盖格式错:键存在但类型不对时不兜底,仍失败关闭。5. 修掉 3 个主干同样存在的继承缺陷
(PR #7 之后
tests/integration未整套复跑,故均未被发现)tools/seed_test_rbac.py少建review_t(9004) —— 两个集成测试都依赖它。且用户↔角色绑定用zip(user_ids, role_ids, strict=True)按位置配对,隐含"USERS 与 ROLES 一一对应";一加不绑角色的账号就ValueError、整个种子跑不完(commit()在最后 ⇒ 外部表现是"什么都没发生")。已改为显式配对表。CustomerProfileCandidateService._write_profile_snapshot漏写current_customer_id—— 该列不是生成列而是普通可空列 + 唯一键uk_profile_snapshot_current,不写则唯一键形同虚设(多个 NULL 不冲突);旧当前版本也没清该列、补写就会撞键。AGENTS.md记明。6. 独立缺陷:
profile_snapshots被两个 ORM 类重复映射app/model/profile.py与app/model/risk_questionnaire.py各有一个ProfileSnapshot映射同一张表。单独导入各自都没事,同时导入即抛InvalidRequestError—— Worker 既要重建画像又要处理投顾问卷,真的会被打挂(库里留下last_error='InvalidRequestError'的行)。已修为 re-export。需要评审的 5 处取舍
完整推演见
docs/39-主干合并对策记录.md。risk_questionnaire.py去重复定义 → re-export(与主干另一份提交内容完全一致,两边独立做了同一修复)seed_test_rbac.py加review_t+ 改显式配对_write_profile_snapshot补current_customer_idcustomer_service.py:COMPANY取主干的「奶龙基金责任有限公司」(本项目实际品牌名);HOTLINE/SERVICE_HOURS取本线修复 —— 主干仍是占位符400-XXX-XXXX,不修则同一客服给客户两个不同号码(安全路由出口给真号、兜底出口给假号)app/worker/__main__.py删除memory_sync_handlers接线 —— 否则两套消费者读同一个memory_sync_outbox,而两者的neo4jhandler 不同(一套用 ZSY 的Neo4jProfileProjection按客户各建私有节点,一套用主干服务写共享 tag 节点)⇒ 同一事件被谁领到结果不定,等于同一事实在图里两种说法,正是方案 A 要避免的状态验证(合并主干后实测)
pytest tests(全量)2 failed, 1415 passed, 2 skippedpytest tests/integration102 passed, 1 skippedmypy appSuccess: no issues found in 245 source filestools/audit_schema.pytools/check_authoritative_docs.pytools/check_docs_endpoint_ids.pytools/check_rbac_seed_consistency.py那 2 个失败是既有环境项(
test_offsite_document_recognition_adapter.py断言请求体里的中文原文,而 httpx 序列化成\uXXXX),非本次引入。真机验证:真实 embedding(1024 维) + 真实 Milvus 写入并回读通过;消费端失败分支与成功分支(含跳过不可投影键、手机号脱敏)均实测通过。
遗留(详见
docs/39§6)memory_sources—— 本线已有消费端兜底(不再死信),根治待投顾线补或明确"这两个来源是否也要投影长期记忆"neo4j_profile_projection.py去留待定docs/00§6.4.6 取值栏与实现不一致 —— 按既定裁定未改基线文档,实际口径记在docs/37文档
docs/37-记忆投影链路实现说明.md(实现说明 + 根因 + 契约 + 验证证据)docs/38-架构对齐-记忆与画像投影链路.md(方案 A 等决定的决策依据)docs/39-主干合并对策记录.md(本次合并逐文件取舍)AGENTS.md新增三条易错点:memory_sync_outbox取值口径、一张表只能有一个 ORM 类、Windows 中文输出乱码的正确命令(-X utf8)两个都是"代码里存在但没接对"的缺陷,都不是新功能。 ## A1 客服热线在代码里有两个值(一个出口给假号码) - `app/core/customer_service_rules.py:35` `CONTACT_PHONE = "15936583816"` ← 真号码,安全路由 6 处在用 - `app/service/agent/implementations/customer_service.py:155` `HOTLINE = "400-XXX-XXXX"` ← 占位符,兜底出口在用 后果:**同一个客服给客户两个不同的电话号码**。问"风险等级怎么划分"被安全路由处理时给真号码; 问一个知识库答不了的问题走兜底时给 `400-XXX-XXXX` —— 客户按这个号码永远打不通。 修法:`HOTLINE` / `SERVICE_HOURS` 改为**转发** `customer_service_rules` 的两个常量 (不是"改成相同的值",而是引用同一对象,避免日后再次漂移);工作时间也随之从 "每日 7:00-22:00" 统一为 "工作日 09:00-18:00"(与安全路由出口一致)。 新增守卫测试用 `is` 断言对象同一性 —— 值相等挡不住"两边各写一份恰好相同"的漂移。 ## A2 `transfer_required` 既没落库也没出参 `docs/05` §6.3 一直规定 `GET /agent-runs/{run_id}` 的 `result` 里有 `transfer_required` / `transfer_reason`,但实现里两个都没有:前端判断"这轮要不要转人工" 只能靠**猜正文里有没有兜底话术的开头**(`docs/24` 自己把这称为权宜之计)。 - 写入侧:`conversation_message` **没有** `transfer_required` 列,加列要迁移且规则 4 禁止改既有 字段定义 ⇒ 放进 `tool_calls` 这个现成 JSON 列,作为 `calls` 的兄弟键 (`{"calls": [...], "transfer_required": bool, "transfer_reason": str|None}`) - 读取侧:`RunQueryService.get` 取出来放进 `result`;**兼容历史行**(`calls` 裸列表 / None → 按 False/None 处理,不抛异常、也不凭正文猜) 刻意**没做**的一半:`docs/05` §6.3 的 `result` 里还有 `degraded` / `degradation_reason`, 但 `CoreResult` 里根本没有这两个字段(降级信息目前只在工具出参里)—— 补它要改 `CoreResult` 并让各 Agent 传递降级状态,属另一个改动范围。**已在交付说明里注明这一半仍缺。** ## 真机验证 | 问题 | transfer_required | transfer_reason | 正文电话 | |---|---|---|---| 「请介绍一下量子纠缠在基金估值中的应用」 | **True** | 置信度不足:score=0.571 gap=0.004 | 15936583816 ✅ | 「请帮我计算一下三体问题的数值解」 | **True** | 置信度不足:score=0.499 gap=0.011 | 15936583816 ✅ | 「基金申购后多久确认」(正常知识直返) | False | — | 无(正确) | 「你们公司明天会下雪吗」(闲聊出口) | False | — | 无(正确) | (第一次我用"下雪"当兜底用例,结果它被闲聊出口正确接住了 —— 是我的期望值写错,不是代码问题。) 门禁:测试 1223 passed(新增 4 个用例)/ 3 failed(均为已知非代码缺陷)/ mypy 0 错。背景:memory_sync_outbox 这条链此前**完全没有消费者**,且生产端照 docs/00 §6.4.6 写成大写 MILVUS/NEO4J + 中文「待处理」,而消费端按 target_store 的**值**分派 handler、 且只领 status in {pending, failed} —— 两个条件都不满足,事件任何消费者都领不到、 永久滞留且不报错(唯一键 (event_uuid, target_store) 对大小写无约束,MySQL 也不报错)。 根因是代码与测试都硬编码字面量,所以测试跟着一起错、谁也没拦住。 订正 - profile_generation_service:取值改为全仓一致的小写(milvus/neo4j/upsert/pending) - 测试改为引用常量并断言消费端契约,不再硬编码(硬编码是本次跑偏的直接原因) - 新增契约回归测试:断言大写值分派不到 handler、会进死信,谁改回大写立刻红 - 新增 tools/normalize_memory_sync_outbox.py:订正历史脏行(默认 dry-run、幂等) 接通投影链路(此前零消费者) - 新增 Milvus 集合 user_long_term_memory_v1 及建集合工具(幂等、不覆盖已有集合) - 新增 MilvusProfileProjection / MilvusProfileVectorClient,并修掉移植带来的两处必炸点: customer_id 由「必须 int」放宽为接受数字字符串(本仓所有生产者都写 str, 不放宽则每个事件必然失败);不可投影的 memory_key 由「整批 raise」改为跳过留痕 (否则一条 constraint: 记忆毒死该客户整批,而受控词表 13 个键里有 7 个不满足前缀) - 新增 MemorySyncOutboxWorker(领取/指数退避/死信骨架保留原样)并接入 WorkerRuntime - milvus → 向量投影;neo4j → 复用主干 ProfileGraphProjectionService(方案 A, 不引入第二套投影,避免同一事实在图中两种说法、违反主干既有的只投影已确认事实的不变式) - 生产端从 memory_unit(status=active) 组装 memory_sources,随事件带上确定快照 - 前置移植 conversation_privacy:写外部存储前脱敏手机号/证件号/银行卡等 验证 - 新增 17 个单测;全量 2 failed, 1307 passed, 2 skipped (2 个失败为既有环境项:断言请求体中文原文而 httpx 序列化成 \uXXXX,非本次引入) - mypy app → 0 错(227 文件);audit_schema → 89 张业务表无缺失/意外,未改动表结构 - 真机:真实 embedding(1024 维) + 真实 Milvus 写入并回读通过 - 整合链路(测试记忆 → 生产端组装 → outbox → 消费端投递 → Milvus 回读)通过, 且 MySQL 已回滚、Milvus 无残留 文档 - 新增 docs/32-记忆投影链路实现说明.md:真实口径、根因、契约与验证证据(供接手) - AGENTS.md:新增该易错点;新增 Windows 中文输出乱码的正确命令(-X utf8); 校正测试基线与 mypy 文件数 未做:未改 docs/00 基线、未动数据库迁移、未改投顾线代码、未启动常驻 Worker。 遗留:投顾线两处生产者的 payload 缺 memory_sources,会被消费至死信,待架构师确认是否投影。## 1. 独立缺陷:`profile_snapshots` 被两个 ORM 类重复映射 排查 `memory_sync_outbox` 中 `target_store='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` 无需改动。原定义多映射的 `current_customer_id` 经全仓核查无人使用,故不保留 (`app.model.profile` 明确注明该列由数据库维护、故意不映射)。 ## 2. 消费端兜底 `memory_sources` `memory_sources` 是本线新增的投影入参,而投顾线两处生产者的 payload (`{customer_id, profile_uuid, version, profile}`)没有这个键,原样会导致它们每次画像 变更都 `memory_sources is invalid` → 重试至死信。 新增 `WorkerRuntime._with_memory_sources()`:**键缺失或为 None** 时回退查询该客户 `memory_unit` 中 `status='active'` 的记忆,并记 warning(使"谁没提供"保持可见)。 语义成立:长期记忆是**客户级**而非画像版本级的,每条记忆自带 `version`, 适配器按 `memory_uuid + version` 幂等,故"用的是哪一版"仍确定。 **兜底不掩盖真错误**:键**存在但格式不对**时**不兜底**,原样交给适配器失败关闭。 实现上用键存在性判断而非 `isinstance`——后者会把"缺失"与"格式错"混为一谈, 那是初版实现里的一个真 bug,被新测试抓出后修正。 ## 3. 补上此前欠缺的消费端全路径验证 此前"整合验证"是直接调适配器,跳过了 outbox 的领取→分派→状态更新。 - **失败分支**(Milvus 断开时实测):行被领取、按 target_store 分派、异常被捕获、 `status`/`retry_count`/`last_error`/`next_retry_at` 正确落库。 - **成功分支**(注入替身向量客户端):outbox 行 → `processed`、`processed_at` 已写; 不可投影的 `constraint:` 被跳过(只写 1 行);维度 1024;字符串客户号转 int; **手机号脱敏生效**(`稳健型投资者,手机号 [手机号已隐藏] 请勿外泄`)。 - **兜底实证**:历史行 `id=5`(payload 无 `memory_sources`)经兜底后成功投递为 `processed`。 - **修复实证**:`id=6` 的 `last_error` 从 `InvalidRequestError` 变为 `RecoverableAgentError`(图库不可用)——证明重复定义缺陷确已消除,剩下的是环境问题。 ## 4. 测试与验证 - 新增 `tests/unit/worker/test_runtime_profile_projection.py`(5 用例:已提供原样透传、 缺失兜底、空记忆给空列表而非删键、无客户号不兜底、格式错不兜底) - 全量:`2 failed, 1312 passed, 2 skipped`(2 个失败为既有环境项,非本次引入) - mypy:`Success: no issues found in 227 source files` - 表结构审计:89 张业务表无缺失/意外(未改动任何表结构) - 文档守卫:41 份文档无编号冲突 ## 5. 文档 `docs/32-记忆投影链路实现说明.md` 增补 §6.1(兜底)、§6.2(重复定义缺陷)、 §7.1(消费端全路径验证)并更新验证表与文件清单; `AGENTS.md` 新增"一张表只能有一个 ORM 类"易错点、校正测试基线数字。 ## 未做 未改 `docs/00` 基线、未动数据库迁移、未改投顾线生产者代码、未启动常驻 Worker。 遗留:投顾线两处生产者的 payload 仍缺 `memory_sources`(已有兜底,不再死信, 但根治应由投顾线确认);Milvus/Neo4j 容器本轮不可用(Docker Desktop 崩溃), `id=6` 停在 failed 属环境不可用、非代码缺陷。## 现象(2026-09-12 跑真实 Worker 时发现) `episode_worker` 反复报 `模型记忆抽取输出不是有效 JSON` 并重试到失败: ``` pydantic_core.ValidationError: Input should be a valid dictionary or instance of _ExtractionPayload input_value=[{'memory_key': None, 'value': None, 'memory_type': None, 'confidence': 0}] input_type=list ``` ## 根因 契约是**对象** `{...}`,而模型在 episode 抽取路径会返回**单元素数组** `[{...}]`。 `_parse` 直接 `json.loads` 后交给 pydantic,数组自然过不了 `model_validate`, 于是被归入"输出不是有效 JSON"这一条 —— 但它其实是**合法的空结果** (`_validate` 已能把"三字段为 null 且 confidence=0"正确识别为"无持久事实",返回 None)。 后果:这类 episode 白跑一遍模型调用、重试到 `retry_count` 上限后判失败, **该片段的记忆永远抽不出来**。 ## 修法 `_parse` 里只对"**恰好一个对象**的数组"做归一化: - `[{...}]` → 取 `{...}`(空结果照常返回 None;有事实照常解析) - 多元素数组、元素非对象、空数组 → **不猜**,仍交给校验失败关闭 (多元素时无法判断哪个是答案,猜错会把错误记忆写进库,比失败更糟) ## 测试 `tests/unit/service/test_memory_extraction_service.py` 追加 2 个用例: - `test_single_element_array_is_normalized`:数组包空结果 → None;数组包有事实 → 正常解析 - `test_multi_element_or_non_dict_array_still_fails_closed`:多元素 / 非对象元素 / 空数组 → 仍失败 ## 验证 - `pytest tests/unit/service/test_memory_extraction_service.py tests/unit/worker/test_memory_extraction_worker.py` → 28 passed - `mypy app` → 0 错 / 245 文件 > 说明:`memory_extraction_service.py` 属主干线代码。此处是从**实际运行日志**里发现的 > 健壮性缺陷,改动限定在输出形状归一化,不改变抽取契约与校验规则。换到你的环境跑之前,有 4 条前置(第 1 条是我这边新引入的)
最终验收在你机器上跑,所以把"环境数据"的部分单独列出来 —— 这些不随代码合并,
缺了会产生看起来像代码缺陷的失败(正如你在
docs/32§5.1/§5.2 反复强调的那类)。python -X utf8 tools/setup_milvus_profile_collection.pyload_collection抛RecoverableAgentError("画像向量集合不可用")⇒memory_sync_outbox的milvus行失败重试到死信。是环境问题,不是代码缺陷docker ps见milvus-standalonehealthy;确认MILVUS_LOCAL_URI为空MILVUS_LOCAL_URI被设过会指向本地 Lite 文件、查的是另一个库(你已在AGENTS.md记过)task_type=embedding的端点_profile_embed抛"没有可用的 embedding 端点",事件同样重试到死信--requirepass 123456,而.env的REDIS_URL原本没带密码AuthenticationError,表现为整片功能静默降级(只打 WARNING):短期会话记忆、记忆召回热缓存、config_release缓存失效、登录限流我这边的环境差异(供你比数字时参考,不是缺陷)
pytest tests/unit tests/contractpytest tests(全量)2 failed, 1421 passed, 1 skippedpytest tests/integration102 passed, 1 skippedmypy appruffruff check app tests tools干净ruff check app tests tools alembic干净(我多含 alembic)那 2 个 failed 就是你在
docs/32§5.2 记为环境相关的test_offsite_document_recognition_adapter.py(httpx 把中文序列化成\uXXXX)——你已经写明"主干架构师环境复现不了、不要为了让它绿去动实现",我这里确认同一个现象,没有动实现。
另:全仓
ruff check .会报 40 个错,全部来自根目录hq.py/nl2sql_yc.py(袁聪线脚本),我已在
AGENTS.md里把门禁命令写清为ruff check app tests tools alembic,避免下一个人把它读成"项目 40 个错"。另外:我在你环境之外跑通了端到端,可作为你验收的对照基线
之前本线的证据都停在组件级(我用替身客户端验证)。这次起了真实常驻 Worker,跑出:
那 1 条来自一次真实对话(客户 9102,含明确偏好陈述):
preference:risk_level = "低风险"、status=candidate(候选态,按设计需审核后才提升为user_facts)。顺带在这次真实运行里发现并修掉 3 个缺陷(都在本 PR 内):
memory_extraction_service._parse不接受单元素数组[{...}]—— 模型在 episode 路径就是这么返回的,被记成"不是有效 JSON"并重试到失败,而它其实是合法空结果
ProfileAssemblyService._write_snapshot把current_customer_id当成生成列(连 docstring都那么写),导致清旧不清、建新不写 ⇒ 唯一键
uk_profile_snapshot_current完全失效(实测数据是"旧版本留着值、新版本是 NULL")。附数据订正工具
tools/fix_profile_snapshot_current.pyseed_test_rbac.py时漏删role_ids(ruffF841)—— 已由你的ffbcc22修掉并合入,我这里致谢;根因是我只跑了 mypy + pytest、没跑 ruff我已知、且已记录的遗留(不阻塞本 PR)
memory_sources(本线已在消费端加兜底,不再死信;根治待投顾线)app/infrastructure/neo4j_profile_projection.py因方案 A 失去生产引用,已在文件头加.. warning::说明"未装配/原因/启用前提",去留待你定docs/00§6.4.6 的取值栏与实现不一致(按既定裁定未改基线文档,实际口径记在docs/37)合并就绪(已追平主干,2026-09-12 更新):
NL_develop相对qyqy_develop:BEHIND 0、AHEAD 7NL_develop历史里)⇒ 无冲突、无需解冲突c200a61..5634fdc那 3 个提交(advisor 演示引导 / 风控登录权限与模型能力配置),与本线无文件重叠、合并无冲突;
tools/seed_test_rbac.py双方都改过,Git 自动合上,合并后
check_rbac_seed_consistency.py仍通过app/service/profile_assembly_service.pycurrent_customer_id漏写修复 —— 这条请重点看,它是画像写入主路径,我把该列从"当生成列"改成"显式写入/清空"tools/fix_profile_snapshot_current.pyAGENTS.mddocs/37-记忆投影链路实现说明.md合并前请再
git fetch一次确认(这条线今天动得勤)。上面那条长评论是环境前置与数字差异。补一个修:
tools/seed_sim_account_demo.py的当日行情不刷新(已推 NL_develop1f62aca)真机验收时
T001仪表盘返回503 FUND_QUOTE_UNAVAILABLE:产品 510300 行情已过期,T006持仓同样 503,而T010权益 200。排查结论在种子脚本里:_upsert_market_price()当天已有行时直接return,所以同一天重跑种子不刷新source_updated_at。行情是时效数据(
FundQuoteService按source_updated_at判新鲜度),于是表现为「刚灌完种子能用、过十几分钟就 503」—— 看着像行情适配器或缓存故障,实际与它们无关。
fin_market_price里当天那行的时间戳停在首次灌入时刻,是判定依据。修法:列值抽成共享
values,已有行就update刷新(含source_updated_at),无行才insert;账户/持仓的「已存在则跳过」不动(业务数据不该被种子覆盖)。
幂等 = 不产生重复行(
(product_id, trade_date)唯一),不是「不更新值」。改后验证(本机 9001
cust_t):dashboard200 /holdings200 /entitlements200;ruff check app tests tools alembic全过、mypy app0 错(252 文件)、pytest tests与本线基线一致(
2 failed, 1469 passed, 1 skipped,2 个仍是test_offsite_document_recognition_adapter的中文转义环境差异,按
docs/32§5.2 不改实现)。⚠️ 顺便提醒:这条坑会让「灌完种子立刻验收」和「过一会儿再验收」结论不同,
建议
docs/32或验收清单里注明「灌完种子立刻跑,或验收前先重跑一次种子」。另,两个路径口径(前端接入时容易猜错,已用
app.routes实测列出):GET /api/v1/users/me/entitlements—— 权益(T010),不在/account/下、也不带/benefit/前缀GET /api/v1/users/me/holdings—— 持仓,是/users/me/直属,不是/users/me/account/holdings用户端现有 13 个
GET/POST端点(memory-profile、memory-candidates 及其决策、account/dashboard、orders 及其详情/撤单、holdings、transactions 及其详情、cash-ledger、entitlements)。
经确认产品(列表/详情/行情)在用户端没有任何 HTTP 端点,且
fin_product除演示种子外为空 ——如果前端需要「产品列表/详情/行情」这几屏,需要新增端点,目前只能靠 Agent 工具兜。
View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.