From 123c273bc8b5a7cb39684d556118a68a24296e9b Mon Sep 17 00:00:00 2001 From: qyqy Date: Fri, 11 Sep 2026 20:47:48 +0800 Subject: [PATCH 01/14] =?UTF-8?q?merge:=20=E6=8A=8A=E4=B8=BB=E5=B9=B2?= =?UTF-8?q?=EF=BC=88=E6=9E=B6=E6=9E=84=E5=B8=88=E7=9A=84=2011=20=E4=B8=AA?= =?UTF-8?q?=E6=8F=90=E4=BA=A4=EF=BC=89=E6=8B=89=E8=BF=9B=20NL=5Fdevelop?= =?UTF-8?q?=EF=BC=8C=E5=B9=B6=E6=9B=B4=E6=96=B0=20AGENTS.md=20=E5=9F=BA?= =?UTF-8?q?=E7=BA=BF=E5=8F=A3=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 合并结果:**零冲突**(自动合并 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 均已声明且已安装。 --- AGENTS.md | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7dd6a40..2fb0b42 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -86,7 +86,24 @@ (`app/core/knowledge_schema.py`)——**不要在任何地方硬编码字段名**,那会把另一套环境打挂。 - ⚠️ **Docker Desktop 不会常驻**:它没运行时 Milvus 不可用(`docker` CLI 报连不上守护进程)。 跑真机验证前先确认 Docker Desktop 在运行。 -- 测试基线:`1 failed, 1034 passed, 2 skipped`(2026-09-11 实测);唯一失败是 `tests/unit/repository/test_fund_readonly_contract.py`(**底座既有缺陷,不要修也不要报**)。 -- mypy:本机 `mypy app` 报 181 个错,其中 170 个集中在 `app/model/` 的模型文件(**本机未安装 `sqlalchemy2-stubs`**, - SQLAlchemy 的 `BIGINT`/`DATETIME` 被判成未类型化函数);架构师环境报 0 错。 - **这个数字双方不可比**,不要拿它当结论;只需保证"不比自己改动前更多"。 +- 测试基线:`3 failed, 1219 passed, 2 skipped`(2026-09-11 合并主干后实测)。 + 三个失败**都不是代码缺陷**,接手时不要"修"它们: + ① `tests/unit/repository/test_fund_readonly_contract.py`(**底座既有缺陷,不要修也不要报**); + ② ③ `tests/unit/service/test_offsite_document_recognition_adapter.py` 的 2 个用例 —— **环境相关**: + 它们断言请求体里是中文原文,而 httpx 会把中文序列化成 `\uXXXX`,字节序列自然不匹配。 + 功能无影响;若要修,正确做法是断言 `json.loads(body)` 后的字段值(字节级断言不该用来测 JSON)。 +- **mypy:`mypy app` → `Success: no issues found in 180 source files`(0 错)。** + ⚠️ 曾在本机报 184 个错,**已查明是环境版本旧**,与代码质量无关 —— 复现矩阵: + + | SQLAlchemy | mypy | 报错数 | + |---|---|---| + | 2.0.34(本机旧) | 1.14.1 | **173** | + | 2.0.34 | 1.20.2 | 173 | + | 2.0.52 | 1.14.1 | **6** | + | 2.0.52 | 1.20.2 | **0**(当前) | + + ⇒ 主因是 **SQLAlchemy 的补丁版本**(旧补丁版类型标注不完整,`BIGINT`/`DATETIME` 被判成未类型化 + 函数,`app/model/*.py` 每个列定义报一条)。**不要装 `sqlalchemy2-stubs`** —— 那是给 + SQLAlchemy **1.4** 用的,装上会按 1.4 API 核对 2.0 代码、换一批新错。 + `pyproject.toml` 的 `sqlalchemy>=2.0,<3` 允许范围内补丁版差异会造成量级差异; + 若门禁数字要求稳定,需把 SQLAlchemy 钉到具体补丁版(属公共约定,改前先问)。 From a7e2d3eac0c2d0d03bf78af8d368a96658d06af3 Mon Sep 17 00:00:00 2001 From: qyqy Date: Fri, 11 Sep 2026 21:17:47 +0800 Subject: [PATCH 02/14] =?UTF-8?q?fix(customer-service):=20=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E5=AE=A2=E6=9C=8D=E7=83=AD=E7=BA=BF=E6=9D=A5=E6=BA=90?= =?UTF-8?q?=EF=BC=9B=E8=A1=A5=20transfer=5Frequired=20=E5=87=BA=E5=8F=82?= =?UTF-8?q?=EF=BC=88docs/05=20=C2=A76.3=20=E6=9C=AA=E5=85=91=E7=8E=B0?= =?UTF-8?q?=E7=9A=84=E4=B8=80=E5=8D=8A=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 两个都是"代码里存在但没接对"的缺陷,都不是新功能。 ## 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 错。 --- .../agent/implementations/customer_service.py | 13 +++- app/service/agent_persistence_service.py | 17 +++++- app/service/run_query_service.py | 18 +++++- .../unit/core/test_customer_service_rules.py | 20 ++++++ tests/unit/service/test_run_query_service.py | 61 +++++++++++++++++++ 5 files changed, 122 insertions(+), 7 deletions(-) diff --git a/app/service/agent/implementations/customer_service.py b/app/service/agent/implementations/customer_service.py index 5406efb..bdbf912 100644 --- a/app/service/agent/implementations/customer_service.py +++ b/app/service/agent/implementations/customer_service.py @@ -27,6 +27,7 @@ from app.core.contracts import ( RequestContext, SourceReference, ) +from app.core.customer_service_rules import CONTACT_HOURS, CONTACT_PHONE from app.core.errors import ForbiddenAgentError from app.service.agent.base import BaseAgent from app.service.model_gateway import DatabaseModelEndpointResolver @@ -151,9 +152,15 @@ MAX_ANSWER_CHARS = 1200 REFERENCE_LIMIT = 3 COMPANY = "南方科技" -# 客服热线:正式号码确定后改这里(或改为读配置项,避免改代码) -HOTLINE = "400-XXX-XXXX" -SERVICE_HOURS = "每日 7:00-22:00" +# 客服热线与工作时间:**唯一来源是 `app/core/customer_service_rules.py`**,这里只做转发。 +# +# 为什么必须转发而不是各写一份:这两处曾一度不一致 —— `customer_service_rules.CONTACT_PHONE` +# 是真号码 `15936583816`(安全路由出口在用),而本文件曾写占位符 `400-XXX-XXXX`(兜底出口在用)。 +# 后果是**同一个客服给客户两个不同的电话号码**:问"风险等级怎么划分"被安全路由处理时给真号码, +# 问一个知识库答不了的问题走兜底时给假号码 —— 客户按假号码永远打不通。 +# 常量各写一份就一定会漂移,所以这里直接引用,改号码只需改 `customer_service_rules` 一处。 +HOTLINE = CONTACT_PHONE +SERVICE_HOURS = CONTACT_HOURS FALLBACK_TEMPLATE = ( "抱歉,这个问题我暂时无法给出准确答复。为避免给您错误信息," diff --git a/app/service/agent_persistence_service.py b/app/service/agent_persistence_service.py index 9fe1cde..03bc905 100644 --- a/app/service/agent_persistence_service.py +++ b/app/service/agent_persistence_service.py @@ -67,8 +67,21 @@ class AgentPersistenceService: if result.result.intent else None), source_references=[ref.model_dump(mode="json") for ref in result.result.source_references], - tool_calls={"calls": [call.model_dump(mode="json") - for call in result.result.tool_calls]}, + # `tool_calls` 是**唯一能承载附加信息的现成 JSON 列**(`conversation_message` + # 没有 `transfer_required` 列,加列要迁移,而规则 4 禁止改既有字段定义)。 + # 因此把「转人工标记」作为 `calls` 的**兄弟键**放进来: + # {"calls": [...], "transfer_required": bool, "transfer_reason": str|None} + # 之所以必须落库:`docs/05` §6.3 规定 `GET /agent-runs/{run_id}` 的 + # `result` 里要有 `transfer_required` / `transfer_reason`,而它此前 + # **既没落库也没出参** —— 前端只能靠"回答里是否含兜底话术开头"来猜要不要转人工 + # (`docs/24` 自己把这称为权宜之计)。落库后读写两侧才有同一份真相。 + # 读侧允许 `calls` 是裸列表(历史行),见 `RunQueryService.get`。 + tool_calls={ + "calls": [call.model_dump(mode="json") + for call in result.result.tool_calls], + "transfer_required": bool(result.result.transfer_required), + "transfer_reason": result.result.transfer_reason, + }, ) self.session.add(message) await self.session.flush() diff --git a/app/service/run_query_service.py b/app/service/run_query_service.py index 5153a42..4ba8f1b 100644 --- a/app/service/run_query_service.py +++ b/app/service/run_query_service.py @@ -32,10 +32,24 @@ class RunQueryService: run, message = rows result = None if run.status == "succeeded" and message is not None: - result = {"content": message.content, "tool_calls": message.tool_calls, + # 「转人工标记」从 `tool_calls` 这个 JSON 列里取(与写入侧同一个位置)。 + # 兼容两种历史形状:dict 里带 `transfer_required`(新),或 `calls` 裸列表(旧行)—— + # 旧行取不到就按 False 处理,不猜、也不因为缺字段让整个响应失败。 + transfer_required = False + transfer_reason = None + stored_calls = message.tool_calls + if isinstance(stored_calls, dict): + transfer_required = bool(stored_calls.get("transfer_required", False)) + reason = stored_calls.get("transfer_reason") + transfer_reason = str(reason) if reason else None + result = {"content": message.content, "tool_calls": stored_calls, "intent": message.intent, "confidence": str(message.confidence) if message.confidence else None, - "source_references": message.source_references or []} + "source_references": message.source_references or [], + # `docs/05` §6.3 规定 `result` 必须含这两个字段,此前未兑现。 + # 前端据此判断"这轮要不要转人工",不必再去猜兜底话术的开头。 + "transfer_required": transfer_required, + "transfer_reason": transfer_reason} return RunSnapshot( run.run_id, run.trace_id, run.status, run.agent_type, run.session_id, result, run.error_code, run.created_at.isoformat() + "Z", diff --git a/tests/unit/core/test_customer_service_rules.py b/tests/unit/core/test_customer_service_rules.py index f4c29b7..7fe746f 100644 --- a/tests/unit/core/test_customer_service_rules.py +++ b/tests/unit/core/test_customer_service_rules.py @@ -13,6 +13,7 @@ from app.core import customer_service_rules as rules from app.core.customer_service_rules import ( COMPLIANCE_PRIORITY, COMPLIANCE_REPLY, + CONTACT_HOURS, CONTACT_PHONE, P0_REPLY, P1_REPLY, @@ -23,6 +24,25 @@ from app.core.customer_service_rules import ( route_message, ) + +def test_agent_fallback_uses_the_single_source_hotline() -> None: + """客服 Agent 的兜底话术必须与安全路由用**同一个**电话号码与工作时间。 + + 这条是回归守卫:这两处曾一度不一致 —— 安全路由出口给真号码 `15936583816`, + 而 Agent 的兜底出口给占位符 `400-XXX-XXXX`。后果是同一个客服对不同问题 + 给出**两个不同的客服电话**,客户按占位符那个永远打不通。 + + 断言方式刻意用"对象同一性"(`is`)而不是"值相等":值相等也能通过 + "两边各写一份恰好相同的字符串",而那正是漂移的开始 —— 必须是真的同一个来源。 + """ + from app.service.agent.implementations import customer_service as agent + + assert agent.HOTLINE is CONTACT_PHONE + assert agent.SERVICE_HOURS is CONTACT_HOURS + # 话术里不能再出现任何占位符形态 + assert "XXX" not in agent.FALLBACK_TEMPLATE + assert CONTACT_PHONE in agent.FALLBACK_TEMPLATE + #: 治理层 `review_output()` 的拦截口径(`app/service/agent/governance.py`): #: 输出只要命中任一 `agent_negative_word` 规则(客服 11 条,含 NEG-007「安全」) #: 或这 5 个硬编码字面,整条回复就会被替换成兜底话术。 diff --git a/tests/unit/service/test_run_query_service.py b/tests/unit/service/test_run_query_service.py index ad3ad6d..5c0ff28 100644 --- a/tests/unit/service/test_run_query_service.py +++ b/tests/unit/service/test_run_query_service.py @@ -123,6 +123,67 @@ async def test_failed_run_does_not_expose_result(monkeypatch: pytest.MonkeyPatch assert snapshot.error_code == "AGENT_INTERNAL_ERROR" +# --- `docs/05` §6.3 要求的 `transfer_required` / `transfer_reason` 出参 ----------------- + + +async def test_result_exposes_transfer_marker_when_transferred( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """兜底/转人工分支必须把标记**出参**给客户端。 + + 为什么这条重要:前端判断"这轮要不要转人工"此前只能靠**猜正文里有没有兜底话术开头** + (`docs/24` 自称权宜之计)。`docs/05` §6.3 一直规定 `result` 里有这两个字段, + 但此前既没落库也没出参 —— 这个用例把"契约已兑现"钉住。 + """ + message = FakeMessage( + content="抱歉,这个问题我暂时无法给出准确答复…", + tool_calls={"calls": [], "transfer_required": True, + "transfer_reason": "置信度不足:score=0.571 gap=0.004"}, + ) + patch_repository(monkeypatch, (FakeRun(status="succeeded", completed_at=NOW), message)) + + snapshot = await RunQueryService().get("run-1", CONTEXT) + + assert snapshot.result is not None + assert snapshot.result["transfer_required"] is True + assert snapshot.result["transfer_reason"] == "置信度不足:score=0.571 gap=0.004" + + +async def test_result_transfer_marker_defaults_to_false( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """正常回答:标记为 False、原因为 None(不能因为缺键就返回 None 让客户端混淆)。""" + message = FakeMessage(content="交易日 15:00 前提交…", + tool_calls={"calls": [], "transfer_required": False, + "transfer_reason": None}) + patch_repository(monkeypatch, (FakeRun(status="succeeded", completed_at=NOW), message)) + + snapshot = await RunQueryService().get("run-1", CONTEXT) + + assert snapshot.result is not None + assert snapshot.result["transfer_required"] is False + assert snapshot.result["transfer_reason"] is None + + +async def test_result_tolerates_legacy_tool_calls_shape( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """**兼容历史行**:本字段上线前落库的 `tool_calls` 里没有这两个键。 + + 那种行的 `tool_calls` 可能就是裸列表,甚至为 None。读取时必须按 False/None 处理, + **不得抛异常、也不得凭正文内容去猜**——猜错方向会让"不需要转人工"的答复被标成转人工。 + """ + for legacy in ({"calls": []}, [], None): + message = FakeMessage(content="稳健型", tool_calls=legacy) + patch_repository(monkeypatch, (FakeRun(status="succeeded", completed_at=NOW), message)) + + snapshot = await RunQueryService().get("run-1", CONTEXT) + + assert snapshot.result is not None, legacy + assert snapshot.result["transfer_required"] is False, legacy + assert snapshot.result["transfer_reason"] is None, legacy + + def terminal_snapshot(status: str = "succeeded") -> RunSnapshot: return RunSnapshot( run_id="run-1", From af6f689b3f38443b6a4c2ef6a0b6d6aaeec63d60 Mon Sep 17 00:00:00 2001 From: qyqy Date: Sat, 12 Sep 2026 09:36:27 +0800 Subject: [PATCH 03/14] =?UTF-8?q?docs(29):=20=E6=96=B0=E5=A2=9E=E3=80=8A?= =?UTF-8?q?=E6=9E=B6=E6=9E=84=E5=AF=B9=E9=BD=90=20=C2=B7=20=E8=AE=B0?= =?UTF-8?q?=E5=BF=86=E2=86=92=E7=94=BB=E5=83=8F=E2=86=92=E5=9B=BE=20?= =?UTF-8?q?=E8=BF=99=E6=9D=A1=E9=93=BE=E3=80=8B=EF=BC=88ZSY=5Fdevelop=20vs?= =?UTF-8?q?=20=E4=B8=BB=E5=B9=B2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 合并 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 明确不做的事(不整体合并、不改已验链路) 文档守卫通过;本文只做核对,未改任何代码。 --- docs/29-架构对齐-记忆与画像投影链路.md | 194 +++++++++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 docs/29-架构对齐-记忆与画像投影链路.md diff --git a/docs/29-架构对齐-记忆与画像投影链路.md b/docs/29-架构对齐-记忆与画像投影链路.md new file mode 100644 index 0000000..593d886 --- /dev/null +++ b/docs/29-架构对齐-记忆与画像投影链路.md @@ -0,0 +1,194 @@ +# 架构对齐 · 记忆→画像→图 这条链(ZSY_develop vs 主干) + +> **目的**:`ZSY_develop` 分支(张胜宇,最后更新 2026-09-11 20:45)包含一整套画像投影实现, +> 而主干 `qyqy_develop` 上也有同类实现。**在合并之前必须先做一次架构对齐**,否则两套会互相覆盖。 +> **本文只做核对与建议,不改任何代码**。 +> +> **核对时间**: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` 与行数实测。* From c82e9890e40c9a734d7b65e357b0b4058c5f6aac Mon Sep 17 00:00:00 2001 From: qyqy Date: Sat, 12 Sep 2026 09:37:00 +0800 Subject: [PATCH 04/14] =?UTF-8?q?docs:=20=E4=BF=AE=E6=8A=95=E9=A1=BE?= =?UTF-8?q?=E7=BA=BF=E5=B8=A6=E5=85=A5=E7=9A=84=E6=96=87=E6=A1=A3=E9=87=8D?= =?UTF-8?q?=E5=8F=B7=EF=BC=8821/22=20=E2=86=92=2030/31=EF=BC=89=EF=BC=9B?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=20docs/29=20=E6=9E=B6=E6=9E=84=E5=AF=B9?= =?UTF-8?q?=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 投顾线(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,两条平行不冲突,但需要先明确分工)。 --- docs/{21-投顾Agent迁移TODO.md => 30-投顾Agent迁移TODO.md} | 4 ++-- ...度与回滚操作手册.md => 31-投顾Agent灰度与回滚操作手册.md} | 0 2 files changed, 2 insertions(+), 2 deletions(-) rename docs/{21-投顾Agent迁移TODO.md => 30-投顾Agent迁移TODO.md} (99%) rename docs/{22-投顾Agent灰度与回滚操作手册.md => 31-投顾Agent灰度与回滚操作手册.md} (100%) diff --git a/docs/21-投顾Agent迁移TODO.md b/docs/30-投顾Agent迁移TODO.md similarity index 99% rename from docs/21-投顾Agent迁移TODO.md rename to docs/30-投顾Agent迁移TODO.md index cc297a9..287e29c 100644 --- a/docs/21-投顾Agent迁移TODO.md +++ b/docs/30-投顾Agent迁移TODO.md @@ -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`。生产/联调环境的 实际灰度与回滚演练仍待执行。 @@ -571,7 +571,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 重试/死信机制) diff --git a/docs/22-投顾Agent灰度与回滚操作手册.md b/docs/31-投顾Agent灰度与回滚操作手册.md similarity index 100% rename from docs/22-投顾Agent灰度与回滚操作手册.md rename to docs/31-投顾Agent灰度与回滚操作手册.md From 8a0cbab636dd661cdd8a534f8c6c863abb93d016 Mon Sep 17 00:00:00 2001 From: Windows <19353512109@163.com> Date: Sat, 12 Sep 2026 10:45:40 +0800 Subject: [PATCH 05/14] =?UTF-8?q?fix(memory-projection):=20=E8=AE=A2?= =?UTF-8?q?=E6=AD=A3=20outbox=20=E5=8F=96=E5=80=BC=E5=8F=A3=E5=BE=84?= =?UTF-8?q?=E5=B9=B6=E6=8E=A5=E9=80=9A=E7=94=BB=E5=83=8F=E6=8A=95=E5=BD=B1?= =?UTF-8?q?=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 背景: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,会被消费至死信,待架构师确认是否投影。 --- AGENTS.md | 37 ++- app/core/conversation_privacy.py | 32 +++ .../milvus_profile_projection.py | 191 +++++++++++++++ .../milvus_profile_vector_client.py | 65 +++++ app/repository/profile_repository.py | 18 ++ app/service/agent/bootstrap.py | 21 ++ app/service/profile_generation_service.py | 54 ++++- app/worker/memory_sync_outbox_worker.py | 100 ++++++++ app/worker/runtime.py | 101 ++++++++ docs/32-记忆投影链路实现说明.md | 229 ++++++++++++++++++ .../test_milvus_profile_projection.py | 201 +++++++++++++++ .../test_profile_generation_service.py | 68 +++++- .../worker/test_memory_sync_outbox_worker.py | 137 +++++++++++ tools/normalize_memory_sync_outbox.py | 91 +++++++ tools/setup_milvus_profile_collection.py | 144 +++++++++++ 15 files changed, 1477 insertions(+), 12 deletions(-) create mode 100644 app/core/conversation_privacy.py create mode 100644 app/infrastructure/milvus_profile_projection.py create mode 100644 app/infrastructure/milvus_profile_vector_client.py create mode 100644 app/worker/memory_sync_outbox_worker.py create mode 100644 docs/32-记忆投影链路实现说明.md create mode 100644 tests/unit/infrastructure/test_milvus_profile_projection.py create mode 100644 tests/unit/worker/test_memory_sync_outbox_worker.py create mode 100644 tools/normalize_memory_sync_outbox.py create mode 100644 tools/setup_milvus_profile_collection.py diff --git a/AGENTS.md b/AGENTS.md index 2fb0b42..1c14b00 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,6 +72,24 @@ - 解释器:本机用 **`.\.venv\Scripts\python.exe`**;架构师环境用 `D:\conda\envs\jr_py313\python.exe`。 两者等价,**各用本机可用的那个**(`.venv` 被 `.gitignore` 忽略、不进仓库,不存在"需要统一"的问题)。 +- ⚠️ **Windows 控制台跑测试请加 `-X utf8`,否则中文输出是乱码**: + + ```powershell + .\.venv\Scripts\python.exe -X utf8 -m pytest tests -q + ``` + + 原因:本机控制台是代码页 **936(GBK)**,Python 的 `sys.stdout.encoding` 随之为 `gbk`, + 而终端按 UTF-8 解码 ⇒ 中文(断言消息、`docs/` 中文文件名、测试内 `print`)全部乱码。 + **乱码只影响"显示",不影响测试结果**(`passed`/`failed` 计数是英文,照常可信)。 + + 为什么必须用 `-X utf8` 而不是别的办法: + - `chcp 65001` **单独无效**(Python 仍以 GBK 输出,终端按 UTF-8 解码,反而更乱); + - 在 `tests/conftest.py` 里 `sys.stdout.reconfigure(encoding="utf-8")` **只能修测试内部的 `print`**, + **修不了 pytest 自己的输出**(失败摘要、短测试摘要)—— pytest 的终端写入器在启动时就绑定了编码, + 早于 conftest 被加载。**实测两组对照:摘要照样乱码。** + - `-X utf8` 在解释器启动时生效,早于 pytest 的一切,且只影响当次进程、无副作用。 + - 等价替代:环境变量 `PYTHONUTF8=1` 或 `PYTHONIOENCODING=utf-8`(PyCharm 用户可写进 Run Configuration)。 + 同一原因,**跑 `tools/*.py` 等脚本时也要加 `-X utf8`**(例如 `python -X utf8 tools\check_authoritative_docs.py`)。 - 数据库现为 **69 张表**(含 `alembic_version`)= **68 张业务表** = **场内 51 + 场外/推广 17**。 后 17 张(`offsite_*` / `promotion_*`)**不进 `docs/00` 基线**(规则 8:场外基金运营流程独立), 逐表登记见 `docs/28-场外与推广域数据表登记.md`。核验命令:`python tools/audit_schema.py`。 @@ -86,13 +104,22 @@ (`app/core/knowledge_schema.py`)——**不要在任何地方硬编码字段名**,那会把另一套环境打挂。 - ⚠️ **Docker Desktop 不会常驻**:它没运行时 Milvus 不可用(`docker` CLI 报连不上守护进程)。 跑真机验证前先确认 Docker Desktop 在运行。 -- 测试基线:`3 failed, 1219 passed, 2 skipped`(2026-09-11 合并主干后实测)。 - 三个失败**都不是代码缺陷**,接手时不要"修"它们: - ① `tests/unit/repository/test_fund_readonly_contract.py`(**底座既有缺陷,不要修也不要报**); - ② ③ `tests/unit/service/test_offsite_document_recognition_adapter.py` 的 2 个用例 —— **环境相关**: +- ⚠️ **`memory_sync_outbox` 的取值必须是小写英文**(`milvus`/`neo4j`、`upsert`、 + `pending`/`failed`/`processed`/`dead`)。`docs/00` §6.4.6 那一栏曾写作大写 + `MILVUS`/`NEO4J`、`UPSERT` + 中文 `待处理`,**与全仓实现从未对齐,照它写会静默失效**: + 消费端按 `handlers.get(target_store)` 分派、且只领 `status in {"pending","failed"}`, + 大写 + 中文两个条件都不满足 ⇒ **事件任何消费者都领不到、永久滞留且不报错** + (唯一键 `(event_uuid, target_store)` 对大小写无约束,MySQL 也不报错)。 + 取值口径以**主干既有读取方**为准(`projection_reconciliation_service.py`、 + `graph_projection_worker.py`),不是文档。详见 `docs/32-记忆投影链路实现说明.md`。 +- 测试基线:`2 failed, 1307 passed, 2 skipped`(2026-09-12 记忆投影链路落地后实测; + 此前为 `3 failed, 1219 passed`——第 3 个失败 + `tests/unit/repository/test_fund_readonly_contract.py` 已由投顾线合入主干时修复,**不要再当既有缺陷引用**)。 + 剩下 2 个失败**都不是代码缺陷**,接手时不要"修"它们: + `tests/unit/service/test_offsite_document_recognition_adapter.py` 的 2 个用例 —— **环境相关**: 它们断言请求体里是中文原文,而 httpx 会把中文序列化成 `\uXXXX`,字节序列自然不匹配。 功能无影响;若要修,正确做法是断言 `json.loads(body)` 后的字段值(字节级断言不该用来测 JSON)。 -- **mypy:`mypy app` → `Success: no issues found in 180 source files`(0 错)。** +- **mypy:`mypy app` → `Success: no issues found in 227 source files`(0 错)。** ⚠️ 曾在本机报 184 个错,**已查明是环境版本旧**,与代码质量无关 —— 复现矩阵: | SQLAlchemy | mypy | 报错数 | diff --git a/app/core/conversation_privacy.py b/app/core/conversation_privacy.py new file mode 100644 index 0000000..abb97b8 --- /dev/null +++ b/app/core/conversation_privacy.py @@ -0,0 +1,32 @@ +"""客服会话落库前的敏感凭据最小化处理。 + +来源:同事 `ZSY_develop` 分支(`app/core/conversation_privacy.py`),整文件移植,未改语义。 +纯正则、无外部依赖,供记忆投影(`MilvusProfileProjection`)在写入向量前做最后一道脱敏。 + +为什么要单独抽一层:记忆内容最终会进入 Milvus 与 Neo4j,一旦写入就脱离了会话事务的 +管控范围。把脱敏放在**写入适配器内部**(而不是依赖调用方记得做),是为了让"未经脱敏的 +文本不得落外部存储"成为代码保证,而不是流程约定。 +""" + +import re + +# 替换顺序从带业务语义的凭据开始,避免通用数字规则先破坏上下文。 +_SENSITIVE_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = ( + ( + re.compile(r"(?i)((?:登录|交易)?密码)\s*(?:[::=]|是)\s*[^\s,。;,;]{1,64}"), + r"\1[已隐藏]", + ), + (re.compile(r"(?i)((?:登录|交易)?密码)\s*\d{4,32}"), r"\1[已隐藏]"), + (re.compile(r"(?i)(验证码|短信码|校验码)\s*(?:[::=]|是)?\s*\d{4,8}"), r"\1[已隐藏]"), + (re.compile(r"(? str: + """保留风险关键词,移除不应进入会话、Outbox 或后续 Redis 的凭据值。""" + sanitized = message + for pattern, replacement in _SENSITIVE_PATTERNS: + sanitized = pattern.sub(replacement, sanitized) + return sanitized diff --git a/app/infrastructure/milvus_profile_projection.py b/app/infrastructure/milvus_profile_projection.py new file mode 100644 index 0000000..fa00efb --- /dev/null +++ b/app/infrastructure/milvus_profile_projection.py @@ -0,0 +1,191 @@ +"""Milvus 长期记忆投影适配器。 + +只写入已经审核的 `memory_sources`,不接受画像快照整体冒充单条记忆。 + +来源:同事 `ZSY_develop` 分支(`app/infrastructure/milvus_profile_projection.py`), +本文件在其基础上做了**两处契约放宽**,均为"避免整批失败",不改变写入语义: + +1. **`customer_id` 接受 int 或数字字符串**。原实现要求 `isinstance(customer_id, int)`, + 而本仓**所有** outbox 生产者写的都是 `str(customer_id)` + (`profile_generation_service`、投顾线 `profile_governance_service` 与 + `risk_questionnaire_service` 三处皆然)。不放宽则每个事件必然失败、重试 5 次后进死信。 +2. **不可投影的 `memory_key` 跳过而非整批报错**。受控词表 + (`app/service/memory_taxonomy.py`)共 13 个键,其中 `constraint:*`(3 个)与 + `profile:*`(4 个)不以 `preference:`/`goal:` 开头。原实现对第一个不合规的键直接 + `raise`,一条 `constraint:` 记忆就会毒死该客户整批同步;现改为跳过并留痕, + 使"能投影的照常写入"。 +""" + +import logging +from collections.abc import Awaitable, Callable +from datetime import UTC, datetime +from typing import Any, Protocol +from uuid import UUID + +from app.core.conversation_privacy import sanitize_customer_service_message +from app.core.errors import RecoverableAgentError + +logger = logging.getLogger(__name__) + +PROFILE_COLLECTION = "user_long_term_memory_v1" +VECTOR_DIM = 1024 + +#: 可投影的键前缀。其余受控键(`constraint:*` / `profile:*`)属结构化字段, +#: 与向量召回不是同一用途,因此不进长期记忆向量集合。 +PROJECTABLE_KEY_PREFIXES: tuple[str, ...] = ("preference:", "goal:") + + +class MilvusProfileClient(Protocol): + async def query(self, **kwargs: Any) -> list[dict[str, Any]]: ... + + async def upsert(self, **kwargs: Any) -> Any: ... + + +EmbeddingProvider = Callable[[str], Awaitable[list[float]]] + + +class MilvusProfileProjection: + """按记忆 UUID 幂等写入长期记忆向量。""" + + def __init__( + self, + client: MilvusProfileClient, + embed: EmbeddingProvider, + *, + collection: str = PROFILE_COLLECTION, + ) -> None: + self._client = client + self._embed = embed + self._collection = collection + + async def upsert(self, payload: dict[str, Any]) -> None: + customer_id, profile_version, sources = self._normalize(payload) + load_collection = getattr(self._client, "load_collection", None) + if load_collection is not None: + await load_collection(collection_name=self._collection) + rows: list[dict[str, Any]] = [] + for source in sources: + vector = await self._embed(source["content"]) + if len(vector) != VECTOR_DIM: + raise RecoverableAgentError("画像向量维度不一致") + existing = await self._client.query( + collection_name=self._collection, + filter=f'memory_uuid == "{source["memory_uuid"]}"', + output_fields=["memory_uuid", "version", "customer_id"], + ) + # 幂等:库里已有更新版本时不回退覆盖。 + if existing and int(existing[0].get("version", 0)) > source["version"]: + continue + rows.append({ + "memory_uuid": source["memory_uuid"], + "customer_id": customer_id, + "content": source["content"], + "embedding": vector, + "memory_type": source["memory_type"], + "memory_key": source["memory_key"], + "confidence": source["confidence"], + "version": source["version"], + "status": "active", + "valid_until_ts": source["valid_until_ts"], + "updated_at_ts": source["updated_at_ts"], + }) + if rows: + await self._client.upsert(collection_name=self._collection, data=rows) + # 全部被跳过时也要留一条记录:否则"零写入"与"没跑"在日志上无法区分。 + logger.info( + "milvus profile projection: customer_id=%s profile_version=%s written=%s", + customer_id, + profile_version, + len(rows), + ) + + @staticmethod + def _coerce_customer_id(raw: Any) -> int: + """接受 int 或数字字符串。 + + 生产端统一写 `str(customer_id)`(本仓三处生产者皆然),若严格要求 int, + 所有事件都会失败。仅接受**纯数字**字符串,非数字一律拒绝, + 避免把 uuid 之类当成客户号写进向量库。 + """ + if isinstance(raw, bool): + raise ValueError("customer_id is invalid") + if isinstance(raw, int): + value = raw + elif isinstance(raw, str) and raw.strip().isdigit(): + value = int(raw.strip()) + else: + raise ValueError("customer_id is invalid") + if value <= 0: + raise ValueError("customer_id is invalid") + return value + + @staticmethod + def _coerce_profile_version(payload: dict[str, Any]) -> int: + """兼容两种键名:`profile_version`(本适配器契约)与 `version`(本仓生产端)。""" + raw = payload.get("profile_version", payload.get("version")) + if isinstance(raw, bool) or not isinstance(raw, int) or raw <= 0: + raise ValueError("profile_version is invalid") + return raw + + @classmethod + def _normalize( + cls, + payload: dict[str, Any], + ) -> tuple[int, int, list[dict[str, Any]]]: + customer_id = cls._coerce_customer_id(payload.get("customer_id")) + profile_version = cls._coerce_profile_version(payload) + sources = payload.get("memory_sources") + if not isinstance(sources, list): + raise ValueError("memory_sources is invalid") + normalized: list[dict[str, Any]] = [] + skipped: list[str] = [] + now = int(datetime.now(UTC).timestamp()) + for source in sources: + if not isinstance(source, dict): + raise ValueError("memory source is invalid") + required = [ + source.get(name) + for name in ("memory_uuid", "memory_key", "content", "memory_type") + ] + if not all(isinstance(value, str) and value.strip() for value in required): + raise ValueError("memory source fields are invalid") + try: + memory_uuid = str(UUID(str(source["memory_uuid"]))) + except ValueError as exc: + raise ValueError("memory_uuid is invalid") from exc + memory_key = str(source["memory_key"]).strip() + if not memory_key.startswith(PROJECTABLE_KEY_PREFIXES): + # 跳过而非整批失败:`constraint:*` / `profile:*` 是结构化事实, + # 不进向量召回。一条不可投影的键不得毒死同一客户其余记忆。 + skipped.append(memory_key) + continue + confidence = source.get("confidence") + version = source.get("version") + if not isinstance(confidence, (int, float)) or not 0 <= confidence <= 1: + raise ValueError("memory confidence is invalid") + if not isinstance(version, int) or version <= 0: + raise ValueError("memory version is invalid") + valid_until = source.get("valid_until") + valid_until_ts = None + if isinstance(valid_until, str) and valid_until: + try: + valid_until_ts = int(datetime.fromisoformat(valid_until).timestamp()) + except ValueError as exc: + raise ValueError("memory valid_until is invalid") from exc + normalized.append({ + "memory_uuid": memory_uuid, + "memory_key": memory_key, + "content": sanitize_customer_service_message(str(source["content"])).strip(), + "memory_type": str(source["memory_type"]).strip(), + "confidence": float(confidence), + "version": version, + "valid_until_ts": valid_until_ts, + "updated_at_ts": now, + }) + if skipped: + logger.info( + "milvus profile projection: skipped %s non-projectable memory keys %s", + len(skipped), + sorted(set(skipped)), + ) + return customer_id, profile_version, normalized diff --git a/app/infrastructure/milvus_profile_vector_client.py b/app/infrastructure/milvus_profile_vector_client.py new file mode 100644 index 0000000..941d756 --- /dev/null +++ b/app/infrastructure/milvus_profile_vector_client.py @@ -0,0 +1,65 @@ +"""长期记忆画像向量的 Milvus 客户端。 + +与 `MilvusKnowledgeWriter` 分开:那个适配器的 `upsert` 签名绑定知识集合的 +`knowledge_id` 主键与字段表,而画像投影的主键是 `memory_uuid`、且需要先 `query` +按版本判重。两者共用一套连接口径(惰性连接 + 失败一律 `RecoverableAgentError`), +但不硬把两种 schema 塞进同一个类。 + +与召回侧的客户端也分开:写路径不与检索进程共用连接(读写物理隔离,向量库故障 +不能从写路径传染到问答主链路),与 `get_milvus_knowledge_writer` 的取向一致。 +""" + +from typing import Any + +from app.core.errors import RecoverableAgentError + + +class MilvusProfileVectorClient: + """满足 `MilvusProfileProjection` 所需的 `query` / `upsert` / `load_collection`。""" + + def __init__(self, uri: str, token: str = "") -> None: + self._uri = uri + self._token = token + self._client: Any = None + + async def _ensure(self) -> Any: + if self._client is None: + try: + from pymilvus import AsyncMilvusClient # type: ignore[import-untyped] + except ImportError as exc: # pragma: no cover - 依赖已声明,缺装是环境问题 + raise RecoverableAgentError("pymilvus 未安装,无法写入画像向量") from exc + try: + self._client = AsyncMilvusClient(uri=self._uri, token=self._token or None) + except Exception as exc: + raise RecoverableAgentError("Milvus 画像写客户端初始化失败") from exc + return self._client + + async def load_collection(self, *, collection_name: str) -> None: + """把集合载入内存后再查/写。 + + 集合不存在时抛 `RecoverableAgentError`:由 outbox 退避重试并最终判死信, + 而不是静默跳过——"集合没建"是装配问题,必须可见。 + """ + client = await self._ensure() + try: + await client.load_collection(collection_name=collection_name) + except Exception as exc: + raise RecoverableAgentError("画像向量集合不可用") from exc + + async def query(self, **kwargs: Any) -> list[dict[str, Any]]: + """按 filter 查询;返回空列表表示无匹配(不是错误)。""" + client = await self._ensure() + try: + raw = await client.query(**kwargs) + except Exception as exc: + raise RecoverableAgentError("画像向量查询失败") from exc + return list(raw or []) + + async def upsert(self, **kwargs: Any) -> Any: + client = await self._ensure() + try: + return await client.upsert(**kwargs) + except RecoverableAgentError: + raise + except Exception as exc: + raise RecoverableAgentError("画像向量写入失败") from exc diff --git a/app/repository/profile_repository.py b/app/repository/profile_repository.py index 04488d8..f5666f8 100644 --- a/app/repository/profile_repository.py +++ b/app/repository/profile_repository.py @@ -50,6 +50,17 @@ _SQL_NEXT_VERSION = text(""" FROM profile_snapshots WHERE customer_id = :customer_id """) +#: 该客户**有效**的长期记忆(投影到 Milvus 长期记忆向量集合的数据源)。 +#: +#: 只取 `status='active'`:失效/被取代的记忆不应再进入向量召回,否则投顾会召回 +#: 已过期偏好。字段与 `MilvusProfileProjection` 的 `memory_sources` 契约一一对应。 +_SQL_ACTIVE_MEMORIES = text(""" + SELECT memory_uuid, memory_key, content, memory_type, confidence, version, valid_until + FROM memory_unit + WHERE customer_id = :customer_id AND status = 'active' + ORDER BY id +""") + #: 旧画像置非当前(`current_customer_id` 有唯一键,必须先置 0 才能插新的当前版本)。 _SQL_CLEAR_CURRENT = text(""" UPDATE profile_snapshots @@ -88,6 +99,13 @@ class ProfileRepository: row = result.mappings().first() return dict(row) if row is not None else None + async def active_memories(self, customer_id: int) -> list[dict[str, Any]]: + """该客户所有 `status='active'` 的长期记忆,按 id 稳定排序。""" + result = await self._session.execute( + _SQL_ACTIVE_MEMORIES, {"customer_id": customer_id} + ) + return [dict(row) for row in result.mappings().all()] + async def next_version(self, customer_id: int) -> int: return int(await self._session.scalar(_SQL_NEXT_VERSION, {"customer_id": customer_id})) diff --git a/app/service/agent/bootstrap.py b/app/service/agent/bootstrap.py index b71c2c0..85fe555 100644 --- a/app/service/agent/bootstrap.py +++ b/app/service/agent/bootstrap.py @@ -19,6 +19,7 @@ from app.infrastructure.fund_quote_cache import FundQuoteCache from app.infrastructure.graph import build_graph_driver from app.infrastructure.memory_cache import MemoryCacheAdapter from app.infrastructure.milvus_knowledge_writer import MilvusKnowledgeWriter +from app.infrastructure.milvus_profile_vector_client import MilvusProfileVectorClient from app.infrastructure.vector_memory import VectorMemoryAdapter from app.service.agent.factory import AgentFactory from app.service.agent.governance import PlatformGovernance @@ -159,6 +160,26 @@ def get_milvus_knowledge_writer() -> MilvusKnowledgeWriter | None: return MilvusKnowledgeWriter(uri, settings.milvus_token or "") +@lru_cache(maxsize=1) +def get_milvus_profile_vector_client() -> MilvusProfileVectorClient | None: + """长期记忆画像向量**写**适配器(装配入口);`milvus_uri` 缺失时返回 None。 + + 与 `get_milvus_knowledge_writer` 同一取向:读写隔离、构造惰性、缺配置显式降级。 + 返回 None 的语义是**显式降级**——`WorkerRuntime` 会因此不注册画像投影 handler, + `memory_sync_outbox` 的事件在库里保持 pending(可观测、可重放),并留一条 warning, + 绝不静默,也绝不伪造同步成功。 + """ + settings = get_settings() + uri = (settings.milvus_uri or "").strip() + if not uri: + logging.getLogger(__name__).warning( + "milvus_uri not configured; profile vector projection disabled and " + "memory_sync_outbox events will stay pending" + ) + return None + return MilvusProfileVectorClient(uri, settings.milvus_token or "") + + async def _embed_text(text: str) -> list[float]: """把文本向量化;端点来自发布配置(task_type=embedding),无端点时失败关闭。""" endpoints = await DatabaseModelEndpointResolver().resolve( diff --git a/app/service/profile_generation_service.py b/app/service/profile_generation_service.py index 17cf61a..6705d3b 100644 --- a/app/service/profile_generation_service.py +++ b/app/service/profile_generation_service.py @@ -44,14 +44,24 @@ from app.core.errors import ValidationAgentError from app.core.profile_projection import PROFILE_FIELD_POLICY from app.repository.profile_repository import ProfileRepository -#: `memory_sync_outbox` 的两个目标存储(`docs/00` §6.4.6 的 `target_store` 取值)。 -TARGET_MILVUS = "MILVUS" -TARGET_NEO4J = "NEO4J" +#: `memory_sync_outbox` 的两个目标存储。 +#: +#: ⚠️ **一律小写**:消费端按 `target_store` 的值分派 handler +#: (`MemorySyncOutboxWorker.handlers.get(event.target_store)`),且 +#: `graph_projection_worker` / `projection_reconciliation_service` 的领取与重放 +#: 都以 `status in {"pending","failed"}` + `"processed"` 为准。全仓(包含投顾线两处生产者) +#: 统一使用小写 `milvus`/`neo4j`、`upsert`、`pending`。 +#: +#: 历史说明:`docs/00` §6.4.6 该栏曾写作大写 `MILVUS`/`NEO4J`、`UPSERT` 与中文 `待处理`, +#: 与上述实现从未对齐;本模块原先照文档写,是**全仓唯一的异类**,导致自己写的事件 +#: 任何消费者都领不到。现统一为小写,并已把历史 2 行就地改齐(主键/唯一键不变)。 +TARGET_MILVUS = "milvus" +TARGET_NEO4J = "neo4j" SYNC_TARGETS: tuple[str, ...] = (TARGET_MILVUS, TARGET_NEO4J) -#: 同步操作与状态取值(与 `memory_sync_outbox` DDL 的语义一致)。 -SYNC_OPERATION_UPSERT = "UPSERT" -SYNC_STATUS_PENDING = "待处理" +#: 同步操作与状态取值。 +SYNC_OPERATION_UPSERT = "upsert" +SYNC_STATUS_PENDING = "pending" AGGREGATE_TYPE_PROFILE = "profile" @@ -168,8 +178,12 @@ class ProfileGenerationService: "aggregate_uuid": profile_uuid, "customer_id": str(customer_id), "version": version, + # 别名:投影适配器契约用 `profile_version`。两个键都写,避免消费端 + # 因生产者用词不同而取不到值(本仓三种 payload 形状的历史遗留)。 + "profile_version": version, "snapshot_hash": snapshot_hash, "snapshot": snapshot, + "memory_sources": await self._memory_sources(customer_id), } for target in SYNC_TARGETS: self._repo.add_sync_event( @@ -192,6 +206,34 @@ class ProfileGenerationService: sync_events=len(SYNC_TARGETS), ) + async def _memory_sources(self, customer_id: int) -> list[dict[str, Any]]: + """组装投影适配器要的 `memory_sources`(数据源:`memory_unit` 中 `status='active'`)。 + + 为什么由生产端组装而不是消费端回查:消费端到时那条记忆可能已改版本, + 让它自己去查会把"投递的是哪一版"变成不确定。事件里带上当时的确定快照, + 投递语义才与 `aggregate_version` 一致(幂等判据也才有意义)。 + + 为什么是 `memory_unit` 而不是 `user_facts`:两者用途不同——`user_facts` 是 + **已确认的结构化事实**,喂画像与图投影;`memory_unit` 是**长期记忆条目**, + 正是长期记忆向量集合要存的东西。这不与"图投影只读 user_facts"的不变式冲突, + 因为 Milvus 存的就是记忆本身的向量,不是画像事实的另一种说法。 + """ + rows = await self._repo.active_memories(customer_id) + return [ + { + "memory_uuid": str(row["memory_uuid"]), + "memory_key": str(row["memory_key"]), + "content": str(row["content"]), + "memory_type": str(row["memory_type"]), + "confidence": float(row["confidence"]), + "version": int(row["version"]), + "valid_until": ( + row["valid_until"].isoformat() if row["valid_until"] else None + ), + } + for row in rows + ] + @staticmethod def _generation_basis(assessment_row: Mapping[str, Any] | None) -> dict[str, object]: """记录这次画像**依据了什么**(`docs/00`:使用的测评版本、交易窗口和记忆版本列表)。 diff --git a/app/worker/memory_sync_outbox_worker.py b/app/worker/memory_sync_outbox_worker.py new file mode 100644 index 0000000..461fddd --- /dev/null +++ b/app/worker/memory_sync_outbox_worker.py @@ -0,0 +1,100 @@ +"""画像投影 Outbox 消费器。 + +外部存储通过 handler 注入;本模块只负责领取、重试、死信和 MySQL 状态更新。 + +来源:同事 `ZSY_develop` 分支(`app/worker/memory_sync_outbox_worker.py`)。 +本文件保留其**领取/重试/退避/死信骨架原样未改**——这部分(`skip_locked` 并发领取、 +指数退避、5 次转死信)是正确的,也是这条链上唯一把"投递可靠性"做对的地方。 + +分派口径:`runtime.py` 是本消费器的装配方,它会把 handler 键注册为**全仓统一的小写** +`milvus` / `neo4j`(与生产端写入的 `target_store` 取值一致)。 +""" + +from collections.abc import Awaitable, Callable +from datetime import UTC, datetime, timedelta +from typing import Any + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.infrastructure.db import SessionFactory +from app.model.memory import MemorySyncOutbox + +ProjectionHandler = Callable[[dict[str, Any]], Awaitable[Any]] +MAX_RETRY_COUNT = 5 + + +class MemorySyncOutboxWorker: + """按目标存储独立消费画像投影事件。""" + + def __init__( + self, + handlers: dict[str, ProjectionHandler], + *, + session_factory: Callable[[], AsyncSession] = SessionFactory, + ) -> None: + self.handlers = handlers + self.session_factory = session_factory + + async def run_once( + self, *, target_store: str | None = None, event_uuid: str | None = None + ) -> bool: + """领取并处理一条到期事件;没有可处理事件时返回 False。""" + if not self.handlers: + return False + async with self.session_factory() as session: + now = datetime.now(UTC).replace(tzinfo=None) + conditions: list[Any] = [ + MemorySyncOutbox.status.in_({"pending", "failed"}), + MemorySyncOutbox.next_retry_at.is_(None) + | (MemorySyncOutbox.next_retry_at <= now), + ] + if target_store is not None: + conditions.append(MemorySyncOutbox.target_store == target_store) + if event_uuid is not None: + conditions.append(MemorySyncOutbox.event_uuid == event_uuid) + event = await session.scalar( + select(MemorySyncOutbox) + .where(*conditions) + .order_by(MemorySyncOutbox.id) + .limit(1) + .with_for_update(skip_locked=True) + ) + if event is None: + await session.rollback() + return False + handler = self.handlers.get(event.target_store) + if handler is None: + # 没有对应 handler 时判死信(不留 pending 空转),并写明原因。 + self._fail(event, "target_handler_not_configured", now, dead=True) + await session.commit() + return True + try: + await handler(event.payload) + except Exception as exc: + self._fail(event, type(exc).__name__, now) + else: + event.status = "processed" + event.processed_at = datetime.now(UTC).replace(tzinfo=None) + event.last_error = None + event.next_retry_at = None + await session.commit() + return True + + @staticmethod + def _fail( + event: MemorySyncOutbox, + reason: str, + now: datetime, + *, + dead: bool = False, + ) -> None: + """写入可重试失败或死信状态,不吞掉失败事实。""" + event.retry_count = int(event.retry_count) + 1 + event.last_error = reason[:500] + event.status = "dead" if dead or event.retry_count >= MAX_RETRY_COUNT else "failed" + event.next_retry_at = ( + None + if event.status == "dead" + else now + timedelta(seconds=min(300, 2 ** int(event.retry_count))) + ) diff --git a/app/worker/runtime.py b/app/worker/runtime.py index c125ed3..abb8394 100644 --- a/app/worker/runtime.py +++ b/app/worker/runtime.py @@ -22,6 +22,7 @@ from app.service.agent.bootstrap import ( get_memory_cache_adapter, get_memory_embedding_service, get_milvus_knowledge_writer, + get_milvus_profile_vector_client, get_model_service, ) from app.service.agent.executor import AgentExecutor @@ -91,6 +92,7 @@ class WorkerRuntime: knowledge_writer: Any = _UNSET, knowledge_embedder: Any = _UNSET, knowledge_endpoint_resolver: Any = _UNSET, + profile_vector_client: Any = _UNSET, ) -> None: self.factory = factory if factory is not None else get_agent_factory() self.settings = settings or get_settings() @@ -138,6 +140,18 @@ class WorkerRuntime: ) # 降级告警只打一次:dispatch 是轮询热路径,每轮一条 warning 会把日志淹掉。 self._knowledge_degraded_logged = False + # 画像投影(`memory_sync_outbox`)消费装配。 + # + # 与 `knowledge_writer` 同一取向:客户端构造**惰性**(不连 Milvus), + # `milvus_uri` 未配置时显式降级为不注册 handler(事件留 pending、可观测、可重放), + # 绝不伪造同步成功。 + self.profile_vector_client = ( + get_milvus_profile_vector_client() + if profile_vector_client is _UNSET + else profile_vector_client + ) + self.profile_endpoint_resolver = self.knowledge_endpoint_resolver + self._profile_degraded_logged = False # episode 聚合是低频批处理,按轮次节流而不是每轮都查。 self._episode_rounds = 0 @@ -408,6 +422,14 @@ class WorkerRuntime: async def run_once(self) -> bool: dispatched = await self.dispatch_batch() > 0 + # 画像投影消费:与领域事件同一轮次内处理。失败只告警,不影响 run 的处理与 + # 轮询节奏——事件仍在库里,下一轮照常重试(退避由 worker 自己记在 next_retry_at)。 + try: + projected = await self.consume_profile_projections() > 0 + except Exception: + logger.warning("profile projection consumption failed", exc_info=True) + projected = False + dispatched = dispatched or projected self._episode_rounds += 1 if self._episode_rounds % EPISODE_INTERVAL_ROUNDS == 0: # 会话片段聚合:内部幂等(content_hash 唯一键),失败只告警, @@ -431,6 +453,85 @@ class WorkerRuntime: return dispatched return await self.execute(run_id) or dispatched + async def consume_profile_projections(self, *, limit: int = 20) -> int: + """消费 `memory_sync_outbox` 的画像投影事件,返回本次处理条数。 + + 两个目标存储的分工(**方案 A**:以架构师主干为主线,不引入第二套 Neo4j 投影): + + - `milvus` → 写入长期记忆向量集合 `user_long_term_memory_v1` + (此前**完全没有消费者**,事件永久滞留); + - `neo4j` → 复用主干 `ProfileGraphProjectionService`。主干已由 + `profile.rebuild_requested` 事件驱动同一条链,图投影是 `MERGE` 幂等的, + 因此这里再投一次不产生重复节点/关系,只用于把 outbox 行的投递状态收敛掉。 + + 为什么不让 handler 自己 commit:事务边界与 `dispatch_batch` 一致, + 由本方法按条提交;单条失败由 `MemorySyncOutboxWorker` 内部转成 + `failed`+退避或死信,不冒泡打断本轮其余事件。 + """ + if self.profile_vector_client is None: + # 显式降级:不注册 handler 就交给 worker 判死信是**错的**(那是把配置缺失 + # 伪装成投递失败)。这里直接不消费,事件保持 pending,由启动日志提示。 + if not self._profile_degraded_logged: + self._profile_degraded_logged = True + logger.warning( + "profile projection disabled: milvus profile vector client unavailable; " + "memory_sync_outbox events stay pending" + ) + return 0 + # 收窄到局部变量:闭包内访问 self 属性时 mypy 无法保留上面的 None 判定。 + vector_client = self.profile_vector_client + + from app.infrastructure.milvus_profile_projection import MilvusProfileProjection + from app.worker.memory_sync_outbox_worker import MemorySyncOutboxWorker + + async def project_milvus(payload: dict[str, Any]) -> None: + projection = MilvusProfileProjection(vector_client, self._profile_embed) + await projection.upsert(payload) + + async def project_neo4j(payload: dict[str, Any]) -> None: + raw_customer_id = payload.get("customer_id") + # 显式 isinstance 而不是 `in (None, "")`:后者不做类型收窄,mypy 无法确认 + # int() 的入参类型;同时也把"客户号必须是数字"这一契约写在类型检查里。 + if not isinstance(raw_customer_id, (int, str)) or raw_customer_id == "": + raise RecoverableAgentError("profile projection payload has no customer_id") + customer_id = int(raw_customer_id) + # 延迟导入:与 dispatch_profile_rebuild 同一理由,避免模块级循环依赖。 + from app.service.profile_graph_projection_service import ( + ProfileGraphProjectionService, + ) + + async with SessionFactory() as session: + outcome = await ProfileGraphProjectionService( + session, self.relationships + ).project_customer(customer_id) + if outcome.degraded: + # 图库不可用:如实抛出,让 worker 走失败/退避,而不是记成已投递。 + raise RecoverableAgentError(f"graph projection degraded: {outcome.reason}") + + worker = MemorySyncOutboxWorker( + {"milvus": project_milvus, "neo4j": project_neo4j} + ) + handled = 0 + for _ in range(max(1, limit)): + if not await worker.run_once(): + break + handled += 1 + return handled + + async def _profile_embed(self, text: str) -> list[float]: + """向量化一条记忆正文;端点走与知识向量化同一套已批准端点解析。 + + 不复用 `bootstrap._embed_text`:那是模块私有函数,跨模块引用私有名会把 + 两处的耦合藏起来。这里用同一组公开装配(端点解析器 + embedding 服务)。 + """ + endpoints = await self.profile_endpoint_resolver.resolve( + agent_type="memory_recall", task_type="embedding" + ) + if not endpoints: + raise RecoverableAgentError("没有可用的 embedding 端点,无法投影长期记忆") + execution = await self.knowledge_embedder.embed(endpoints, text) + return list(execution.vector) + async def aggregate_episodes(self, *, customer_limit: int = 50) -> int: """把已静默的会话片段聚合为 episode,返回新写入的片段数。 diff --git a/docs/32-记忆投影链路实现说明.md b/docs/32-记忆投影链路实现说明.md new file mode 100644 index 0000000..9356ac8 --- /dev/null +++ b/docs/32-记忆投影链路实现说明.md @@ -0,0 +1,229 @@ +# 记忆投影链路(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"`)。 + +--- + +## 7. 真机验证证据(2026-09-12) + +| 验证项 | 结果 | +|---|---| +| 集合创建幂等 | 首次 `created`,复跑 `exists` | +| 真实 embedding 维度 | **1024**(与集合定义一致) | +| 真实写入 + 回读 | 2 条可投影键写入成功并可回读(内容/版本正确) | +| 不可投影键 | `constraint:liquidity` **未写入**(跳过生效,未毒死整批) | +| 测试数据清理 | 已按 `customer_id=999999` 删除,集合残留 **0** 条 | +| 单元测试 | 新增 17 个(适配器 10 + worker 5 + 生产端 2),全过 | +| 全量回归 | `2 failed, 1307 passed, 2 skipped` —— 与基线一致,**无新增失败** | +| mypy | `Success: no issues found in 227 source files` | + +> 全量的 2 个失败是既有环境相关项(`test_offsite_document_recognition_adapter.py` +> 断言请求体里的中文原文,而 httpx 序列化成 `\uXXXX`),与本次改动无关。 + +> 真机注意:Milvus 写入后**短时间内可能查不到**(索引尚未可见), +> 验证脚本按重试处理;同理删除后立即查询可能仍返回旧行,需重查确认。 + +--- + +## 8. 尚未完成 / 依赖他人 + +1. **常驻 Worker 未运行**:核对时数据库里 `memory_sync_outbox` 2 行、 + `domain_event_outbox` 积压 + (`agent.run_requested` 428、`profile.rebuild_requested` 216、 + `agent.run_completed` 61、`memory.extraction_requested` 58)。 + **`memory_unit` 与 `user_facts` 目前都是 0 行** —— 代码链路是通的, + 但没有 Worker 在跑,所以记忆永远不会被抽取出来。起 + `python -m app.worker --once`(或常驻)即会开始消费。 +2. **投顾线两处生产者的取值**:`profile_governance_service.py` 与 + `risk_questionnaire_service.py` 已写小写 `milvus`/`neo4j` + `pending`(与本文口径一致), + 但它们的 `payload` 是 `{customer_id, profile_uuid, version, profile}`, + **没有 `memory_sources`** → 投影时会因 `memory_sources is invalid` 失败重试至死信。 + 需投顾线补 `memory_sources` 或明确这两个来源是否也要投影长期记忆。 + (属架构师线的改动,本线未动。) +3. `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` +- `tests/unit/infrastructure/test_milvus_profile_projection.py` +- `tests/unit/worker/test_memory_sync_outbox_worker.py` + +**修改** +- `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()` + `run_once` 接线) +- `tests/unit/service/test_profile_generation_service.py`(+2 用例、断言改引用常量) diff --git a/tests/unit/infrastructure/test_milvus_profile_projection.py b/tests/unit/infrastructure/test_milvus_profile_projection.py new file mode 100644 index 0000000..836e46e --- /dev/null +++ b/tests/unit/infrastructure/test_milvus_profile_projection.py @@ -0,0 +1,201 @@ +"""`MilvusProfileProjection` 的定向测试。 + +前 4 个用例移植自同事 `ZSY_develop` 的 +`tests/unit/infrastructure/test_milvus_profile_projection.py`; +后 4 个覆盖本仓对其做的**两处契约放宽**(`customer_id` 兼容字符串、 +不可投影键跳过而非整批失败)与脱敏,这些是移植时必须钉住的差异点。 +""" + +from uuid import uuid4 + +import pytest + +from app.core.errors import RecoverableAgentError +from app.infrastructure.milvus_profile_projection import MilvusProfileProjection + + +class FakeMilvus: + def __init__(self, existing: list[dict[str, object]] | None = None) -> None: + self.existing = existing or [] + self.queries: list[dict[str, object]] = [] + self.upserts: list[dict[str, object]] = [] + + async def query(self, **kwargs: object) -> list[dict[str, object]]: + self.queries.append(kwargs) + return self.existing + + async def upsert(self, **kwargs: object) -> None: + self.upserts.append(kwargs) + + +def payload() -> dict[str, object]: + return { + "customer_id": 7, + "profile_version": 1, + "memory_sources": [{ + "memory_uuid": str(uuid4()), + "memory_key": "preference:risk_level", + "content": "稳健型", + "memory_type": "preference", + "confidence": 0.9, + "version": 2, + "valid_until": None, + }], + } + + +def _source(data: dict[str, object]) -> dict[str, object]: + sources = data["memory_sources"] + assert isinstance(sources, list) + source = sources[0] + assert isinstance(source, dict) + return source + + +@pytest.mark.asyncio +async def test_upsert_writes_schema_fields_and_vector() -> None: + client = FakeMilvus() + projection = MilvusProfileProjection(client, _embed) + + await projection.upsert(payload()) + + assert len(client.upserts) == 1 + row = client.upserts[0]["data"][0] + assert row["customer_id"] == 7 + assert row["status"] == "active" + assert len(row["embedding"]) == 1024 + + +@pytest.mark.asyncio +async def test_lower_memory_version_is_not_overwritten() -> None: + data = payload() + memory_uuid = _source(data)["memory_uuid"] + client = FakeMilvus(existing=[{ + "memory_uuid": memory_uuid, "customer_id": 7, "version": 3, + }]) + + await MilvusProfileProjection(client, _embed).upsert(data) + + assert client.upserts == [] + + +@pytest.mark.asyncio +async def test_embedding_dimension_is_enforced() -> None: + with pytest.raises(RecoverableAgentError, match="维度"): + await MilvusProfileProjection(client=FakeMilvus(), embed=_embed_short).upsert( + payload() + ) + + +@pytest.mark.asyncio +async def test_non_uuid_memory_id_is_rejected() -> None: + data = payload() + _source(data)["memory_uuid"] = "unsafe\" or true" + + with pytest.raises(ValueError, match="memory_uuid"): + await MilvusProfileProjection(FakeMilvus(), _embed).upsert(data) + + +# --- 本仓放宽的契约(移植差异点) ------------------------------------------- + + +@pytest.mark.asyncio +async def test_string_customer_id_is_accepted() -> None: + """本仓三处生产者写的都是 `str(customer_id)`;不接受字符串则事件必然全部失败。""" + data = payload() + data["customer_id"] = "9102" + + client = FakeMilvus() + await MilvusProfileProjection(client, _embed).upsert(data) + + assert client.upserts[0]["data"][0]["customer_id"] == 9102 + + +@pytest.mark.asyncio +async def test_non_numeric_customer_id_is_rejected() -> None: + """放宽不等于不校验:uuid 之类的非数字串必须拒绝,不能当成客户号写进向量库。""" + data = payload() + data["customer_id"] = "957c0552-7fa2-4f2a-924d-d2d2e133b245" + + with pytest.raises(ValueError, match="customer_id"): + await MilvusProfileProjection(FakeMilvus(), _embed).upsert(data) + + +@pytest.mark.asyncio +async def test_version_key_fallback_is_supported() -> None: + """本仓生产端 payload 用 `version`;适配器契约用 `profile_version`。两个都要认。""" + data = payload() + del data["profile_version"] + data["version"] = 5 + + client = FakeMilvus() + await MilvusProfileProjection(client, _embed).upsert(data) + + assert len(client.upserts) == 1 + + +@pytest.mark.asyncio +async def test_non_projectable_memory_key_is_skipped_not_fatal() -> None: + """`constraint:*` / `profile:*` 不在可投影前缀内。 + + 关键:一条不可投影的键**不得**毒死同一客户其余可投影记忆。 + """ + data = payload() + good = _source(data) + data["memory_sources"] = [ + { + "memory_uuid": str(uuid4()), + "memory_key": "constraint:liquidity", + "content": "半年内需要流动性", + "memory_type": "constraint", + "confidence": 0.8, + "version": 1, + "valid_until": None, + }, + good, + ] + + client = FakeMilvus() + await MilvusProfileProjection(client, _embed).upsert(data) + + assert len(client.upserts) == 1 + rows = client.upserts[0]["data"] + assert [row["memory_key"] for row in rows] == ["preference:risk_level"] + + +@pytest.mark.asyncio +async def test_all_keys_non_projectable_writes_nothing() -> None: + """全部不可投影时不写 Milvus,但也不报错(不是失败,是无需投影)。""" + data = payload() + _source(data)["memory_key"] = "profile:occupation" + + client = FakeMilvus() + await MilvusProfileProjection(client, _embed).upsert(data) + + assert client.upserts == [] + + +@pytest.mark.asyncio +async def test_sensitive_credentials_are_sanitized_before_write() -> None: + """落外部存储前必须脱敏:手机号不得原样写进向量库。""" + data = payload() + _source(data)["content"] = "我的手机号是 15936583816,稳健型" + + client = FakeMilvus() + await MilvusProfileProjection(client, _embed).upsert(data) + + content = client.upserts[0]["data"][0]["content"] + assert "15936583816" not in content + assert "[手机号已隐藏]" in content + + +def _vector(size: int = 1024) -> list[float]: + return [0.0] * size + + +async def _embed(_: str) -> list[float]: + return _vector() + + +async def _embed_short(_: str) -> list[float]: + return _vector(3) diff --git a/tests/unit/service/test_profile_generation_service.py b/tests/unit/service/test_profile_generation_service.py index 8c04a63..65fab79 100644 --- a/tests/unit/service/test_profile_generation_service.py +++ b/tests/unit/service/test_profile_generation_service.py @@ -21,6 +21,8 @@ from app.core.errors import ValidationAgentError from app.core.profile_projection import project_profile from app.service.profile_generation_service import ( REQUIRED_SNAPSHOT_FIELDS, + SYNC_OPERATION_UPSERT, + SYNC_STATUS_PENDING, SYNC_TARGETS, ProfileGenerationService, build_snapshot, @@ -45,11 +47,13 @@ class FakeRepository: assessment: dict[str, Any] | None = None, current: dict[str, Any] | None = None, next_version: int = 2, + memories: list[dict[str, Any]] | None = None, ) -> None: self._profile = profile self._assessment = assessment self._current = current self._next_version = next_version + self._memories = memories or [] self.executed: list[str] = [] self.inserted_snapshot: dict[str, Any] | None = None self.sync_events: list[dict[str, Any]] = [] @@ -66,6 +70,10 @@ class FakeRepository: self.executed.append("current_snapshot") return self._current + async def active_memories(self, _cid: int) -> list[dict[str, Any]]: + self.executed.append("active_memories") + return self._memories + async def next_version(self, _cid: int) -> int: self.executed.append("next_version") return self._next_version @@ -179,7 +187,13 @@ async def test_first_generation_writes_two_events_and_clears_old_current( # `aggregate_type='profile'` 由仓储层固定写入(不在 kwargs 里),此处断言服务传入的实体标识 assert len({e["aggregate_uuid"] for e in repo.sync_events}) == 1 assert all(e["aggregate_uuid"] == result.profile_uuid for e in repo.sync_events) - assert all(e["operation"] == "UPSERT" for e in repo.sync_events) + # 断言取值本身,且**与消费端领取条件对齐**:outbox worker 只领 `pending`/`failed`, + # 写成别的取值事件就永远没人消费。这里不再硬编码字面量(硬编码正是当初跑偏的原因)。 + assert all(e["operation"] == SYNC_OPERATION_UPSERT for e in repo.sync_events) + assert all(e["status"] == SYNC_STATUS_PENDING for e in repo.sync_events) + assert SYNC_OPERATION_UPSERT == "upsert" + assert SYNC_STATUS_PENDING == "pending" + assert set(SYNC_TARGETS) == {"milvus", "neo4j"} # 顺序:清旧当前标记必须在插新版本之前(否则撞唯一键) assert repo.executed.index("clear_current") < repo.executed.index("insert_snapshot") # 新版本标为当前 @@ -187,6 +201,58 @@ async def test_first_generation_writes_two_events_and_clears_old_current( assert repo.inserted_snapshot["version"] == 2 +@pytest.mark.asyncio +async def test_payload_carries_memory_sources_for_projection( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """payload 必须带 `memory_sources` 与 `profile_version`。 + + 这是 Milvus 长期记忆投影的输入契约:适配器要 `memory_sources` 才知道往向量库 + 写什么,要 `profile_version`(或 `version`)才认得出这一批属于哪个画像版本。 + 缺了它,事件能被领取、却什么也投影不出来——属于"静默空转",必须由测试挡住。 + """ + memories = [{ + "memory_uuid": "11111111-2222-3333-4444-555555555555", + "memory_key": "preference:risk_level", + "content": "稳健型", + "memory_type": "preference", + "confidence": 0.9, + "version": 1, + "valid_until": None, + }] + repo = FakeRepository( + profile=profile_row(), assessment=assessment_row(), current=None, memories=memories + ) + + result = await service(monkeypatch, repo).generate(9102, now=NOW) + + for sync_event in repo.sync_events: + payload = sync_event["payload"] + assert payload["profile_version"] == result.version + assert payload["version"] == result.version + sources = payload["memory_sources"] + assert len(sources) == 1 + assert sources[0]["memory_uuid"] == memories[0]["memory_uuid"] + assert sources[0]["memory_key"] == "preference:risk_level" + + +@pytest.mark.asyncio +async def test_payload_memory_sources_is_empty_without_active_memories( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """没有有效记忆时给**空列表**(而不是省略该键)。 + + 省略键会让适配器的 `memory_sources is invalid` 报错、事件反复重试直至死信; + 空列表是"确实没有要投影的记忆",语义不同。这里把这个区别钉住。 + """ + repo = FakeRepository(profile=profile_row(), assessment=assessment_row(), current=None) + + await service(monkeypatch, repo).generate(9102, now=NOW) + + for sync_event in repo.sync_events: + assert sync_event["payload"]["memory_sources"] == [] + + @pytest.mark.asyncio async def test_each_version_gets_a_fresh_profile_uuid(monkeypatch: pytest.MonkeyPatch) -> None: """`profile_uuid` 有唯一键,**每个版本必须用新 uuid**(实测撞过 Duplicate entry)。""" diff --git a/tests/unit/worker/test_memory_sync_outbox_worker.py b/tests/unit/worker/test_memory_sync_outbox_worker.py new file mode 100644 index 0000000..40c18fa --- /dev/null +++ b/tests/unit/worker/test_memory_sync_outbox_worker.py @@ -0,0 +1,137 @@ +"""`MemorySyncOutboxWorker` 的定向测试。 + +4 个用例移植自同事 `ZSY_develop` 的 +`tests/unit/worker/test_memory_sync_outbox_worker.py`;最后一个用例是本仓新增的 +**契约回归测试**——它是这次整条链故障的根因所在,必须有人守着。 +""" + +from datetime import datetime + +import pytest + +from app.model.memory import MemorySyncOutbox +from app.worker.memory_sync_outbox_worker import MemorySyncOutboxWorker + + +class FakeSession: + def __init__(self, event: MemorySyncOutbox | None) -> None: + self.event = event + self.commits = 0 + self.rollbacks = 0 + + async def __aenter__(self) -> "FakeSession": + return self + + async def __aexit__(self, *args: object) -> None: + return None + + async def scalar(self, statement: object) -> MemorySyncOutbox | None: + del statement + return self.event + + async def commit(self) -> None: + self.commits += 1 + + async def rollback(self) -> None: + self.rollbacks += 1 + + +def event(*, target: str = "neo4j", retry_count: int = 0) -> MemorySyncOutbox: + return MemorySyncOutbox( + id=1, event_uuid="event-1", aggregate_type="profile", + aggregate_uuid="profile-1", aggregate_version=1, target_store=target, + operation="upsert", payload={"customer_id": 7}, status="pending", + retry_count=retry_count, next_retry_at=None, last_error=None, + created_at=datetime(2026, 1, 1), processed_at=None, + ) + + +@pytest.mark.asyncio +async def test_success_marks_event_processed() -> None: + item = event() + session = FakeSession(item) + seen: list[dict[str, object]] = [] + + async def handler(payload: dict[str, object]) -> None: + seen.append(payload) + + worker = MemorySyncOutboxWorker({"neo4j": handler}, session_factory=lambda: session) + assert await worker.run_once() is True + assert seen == [{"customer_id": 7}] + assert item.status == "processed" + assert item.processed_at is not None + assert session.commits == 1 + + +@pytest.mark.asyncio +async def test_handler_failure_uses_backoff_and_keeps_event() -> None: + item = event() + session = FakeSession(item) + + async def handler(payload: dict[str, object]) -> None: + del payload + raise TimeoutError + + worker = MemorySyncOutboxWorker({"neo4j": handler}, session_factory=lambda: session) + assert await worker.run_once() is True + assert item.status == "failed" + assert item.retry_count == 1 + assert item.next_retry_at is not None + assert item.last_error == "TimeoutError" + + +@pytest.mark.asyncio +async def test_fifth_failure_enters_dead_state() -> None: + item = event(retry_count=4) + session = FakeSession(item) + + async def handler(payload: dict[str, object]) -> None: + del payload + raise RuntimeError + + worker = MemorySyncOutboxWorker({"neo4j": handler}, session_factory=lambda: session) + await worker.run_once() + assert item.status == "dead" + assert item.retry_count == 5 + assert item.next_retry_at is None + + +@pytest.mark.asyncio +async def test_missing_handler_enters_dead_state_without_external_call() -> None: + item = event(target="milvus") + session = FakeSession(item) + worker = MemorySyncOutboxWorker({"neo4j": lambda _: None}, session_factory=lambda: session) + + assert await worker.run_once() is True + assert item.status == "dead" + assert item.last_error == "target_handler_not_configured" + + +# --- 本仓新增:契约回归 ------------------------------------------------------ + + +@pytest.mark.asyncio +async def test_no_handler_is_registered_for_uppercase_target_store() -> None: + """契约回归:`target_store` 只能是小写 `milvus`/`neo4j`。 + + 背景(这条链真实故障的根因):`profile_generation_service` 曾照 `docs/00` §6.4.6 + 写作大写 `MILVUS`/`NEO4J` + 中文状态 `待处理`,而本 worker 按 `target_store` 的**值** + 分派 handler、且只领 `{"pending","failed"}`。结果该事件两个条件都不满足, + **任何消费者都领不到,永久滞留且不报错**(唯一键 `(event_uuid, target_store)` + 对大小写没有约束,所以静默)。 + + 本用例把"大写分派不到"这一事实钉住:将来谁把取值改回大写,这里会立刻红。 + """ + item = event(target="MILVUS", retry_count=0) + session = FakeSession(item) + called: list[object] = [] + + async def handler(payload: dict[str, object]) -> None: + called.append(payload) + + worker = MemorySyncOutboxWorker({"milvus": handler}, session_factory=lambda: session) + assert await worker.run_once() is True + + assert called == [] # 大写键分派不到 milvus handler + assert item.status == "dead" + assert item.last_error == "target_handler_not_configured" diff --git a/tools/normalize_memory_sync_outbox.py b/tools/normalize_memory_sync_outbox.py new file mode 100644 index 0000000..94cd8e1 --- /dev/null +++ b/tools/normalize_memory_sync_outbox.py @@ -0,0 +1,91 @@ +"""数据订正:把 `memory_sync_outbox` 的历史取值改齐为全仓小写口径。 + +**默认 dry-run**(只报告将影响几行),加 `--apply` 才真正提交。幂等,可重复运行。 + +### 背景 + +`docs/00` §6.4.6 的取值栏曾写作大写 `MILVUS`/`NEO4J`、`UPSERT` 与中文 `待处理`, +与全仓实现从未对齐。按那份文档写入的事件会**静默失效**: + +- 消费端 `MemorySyncOutboxWorker` 按 `handlers.get(event.target_store)` 分派 handler; +- 领取条件是 `status.in_({"pending","failed"})`。 + +大写 + 中文两个条件都不满足 ⇒ 事件任何消费者都领不到、永久滞留且不报错 +(唯一键 `(event_uuid, target_store)` 对大小写没有约束,MySQL 也不会报错)。 + +因此凡是在本仓写入过 `memory_sync_outbox` 的环境,都可能有这类脏行。 + +### 安全口径 + +- 只改 `target_store` / `operation` / `status` 的**值**; +- **不触碰**主键、唯一键 `uk_memory_sync_event (event_uuid, target_store)`、 + `payload`、时间戳、`retry_count`; +- 就地改值不会新增行(两组取值大小写不同,本会各自成行,这里把它们并到正确的一组); +- 中文字符不受 `LOWER()` 影响(非 ASCII 字节不变),故 `LOWER()` 是安全的。 + +用法: + .\\.venv\\Scripts\\python.exe tools\\normalize_memory_sync_outbox.py # 先看 + .\\.venv\\Scripts\\python.exe tools\\normalize_memory_sync_outbox.py --apply # 再改 +""" + +import asyncio +import sys + +from sqlalchemy import text + +from app.infrastructure.db import engine + +_STATUS_CASE = """ + CASE status + WHEN '待处理' THEN 'pending' + WHEN '处理中' THEN 'processing' + WHEN '已完成' THEN 'processed' + WHEN '失败' THEN 'failed' + ELSE LOWER(status) + END +""" + +_UPDATE = text(f""" + UPDATE memory_sync_outbox + SET target_store = LOWER(target_store), + operation = LOWER(operation), + status = {_STATUS_CASE} + WHERE target_store <> LOWER(target_store) + OR operation <> LOWER(operation) + OR status <> {_STATUS_CASE} +""") + +_GROUP = text( + "SELECT target_store, operation, status, COUNT(*) n " + "FROM memory_sync_outbox GROUP BY target_store, operation, status" +) + +_DETAIL = text( + "SELECT id, event_uuid, target_store, operation, status, retry_count " + "FROM memory_sync_outbox ORDER BY id" +) + + +async def main(apply: bool) -> int: + async with engine.connect() as conn: + before = (await conn.execute(_GROUP)).mappings().all() + print("改前:", [dict(row) for row in before] or "(空表)") + + affected = (await conn.execute(_UPDATE)).rowcount + print("将订正行数:", affected) + + if not apply: + print("dry-run:未提交。确认无误后加 --apply 再运行。") + await conn.rollback() + return 0 + + await conn.commit() + after = (await conn.execute(_GROUP)).mappings().all() + print("改后:", [dict(row) for row in after] or "(空表)") + for row in (await conn.execute(_DETAIL)).mappings().all(): + print(" ", dict(row)) + return 0 + + +if __name__ == "__main__": + sys.exit(asyncio.run(main("--apply" in sys.argv))) diff --git a/tools/setup_milvus_profile_collection.py b/tools/setup_milvus_profile_collection.py new file mode 100644 index 0000000..c18eba7 --- /dev/null +++ b/tools/setup_milvus_profile_collection.py @@ -0,0 +1,144 @@ +"""幂等创建长期记忆画像向量集合 `user_long_term_memory_v1`。 + +写入侧是 `app/infrastructure/milvus_profile_projection.py`,字段必须与之一致。 + +用法: + .\\.venv\\Scripts\\python.exe tools\\setup_milvus_profile_collection.py + +安全口径(与 `setup_milvus_knowledge_collections.py` 相同:这是**共享** Milvus 实例, +里面还有别的项目在用的集合): +- 只碰 `user_long_term_memory_v1` 这一个集合,绝不 list 后批量删除; +- 集合已存在时**只做结构比对并报告**,不覆盖、不删重建; +- 结构不一致时明确报错退出,由人决定怎么处理,避免静默丢数据。 +""" + +import asyncio +import sys +from typing import Any + +COLLECTION = "user_long_term_memory_v1" +VECTOR_FIELD = "embedding" +VECTOR_DIM = 1024 +PRIMARY_FIELD = "memory_uuid" + +#: (字段名, 最大长度)。`memory_uuid` 是主键(UUID 字符串)。 +VARCHAR_FIELDS: tuple[tuple[str, int], ...] = ( + ("memory_uuid", 64), + ("content", 2048), + ("memory_type", 32), + ("memory_key", 64), + ("status", 16), +) +#: INT64 字段;可空,因为 `valid_until_ts` 对永久记忆为空。 +INT64_FIELDS: tuple[str, ...] = ( + "customer_id", + "version", + "valid_until_ts", + "updated_at_ts", +) +FLOAT_FIELDS: tuple[str, ...] = ("confidence",) + + +def _build_schema() -> Any: + from pymilvus import DataType, MilvusClient # type: ignore[import-untyped] + + schema = MilvusClient.create_schema(auto_id=False, enable_dynamic_field=False) + for name, max_length in VARCHAR_FIELDS: + schema.add_field( + field_name=name, + datatype=DataType.VARCHAR, + max_length=max_length, + is_primary=(name == PRIMARY_FIELD), + nullable=False, + ) + for name in INT64_FIELDS: + # 可空:`valid_until_ts` 对永久记忆必须能不写。 + schema.add_field( + field_name=name, datatype=DataType.INT64, nullable=name != "customer_id" + ) + for name in FLOAT_FIELDS: + schema.add_field(field_name=name, datatype=DataType.DOUBLE, nullable=False) + schema.add_field( + field_name=VECTOR_FIELD, datatype=DataType.FLOAT_VECTOR, dim=VECTOR_DIM + ) + return schema + + +def _build_index_params() -> Any: + from pymilvus import MilvusClient # type: ignore[import-untyped] + + index_params = MilvusClient.prepare_index_params() + index_params.add_index( + field_name=VECTOR_FIELD, + index_name="profile_vector_index", + index_type="AUTOINDEX", + metric_type="COSINE", + ) + return index_params + + +def describe_mismatch(described: dict[str, Any]) -> list[str]: + """比对已存在集合与期望结构,返回差异列表(一致时为空)。""" + problems: list[str] = [] + actual = {field["name"]: field for field in described.get("fields", [])} + expected_names = ( + [name for name, _ in VARCHAR_FIELDS] + list(INT64_FIELDS) + + list(FLOAT_FIELDS) + [VECTOR_FIELD] + ) + for name in expected_names: + if name not in actual: + problems.append(f"缺少字段 {name}") + for name, _ in VARCHAR_FIELDS: + if name in actual and not actual[name].get("is_primary") and name == PRIMARY_FIELD: + problems.append(f"{name} 不是主键") + vector = actual.get(VECTOR_FIELD) + if vector is not None: + params = vector.get("params") or {} + dim = params.get("dim") + if dim is not None and int(dim) != VECTOR_DIM: + problems.append(f"{VECTOR_FIELD} 维度是 {dim},期望 {VECTOR_DIM}") + return problems + + +async def ensure_collection(uri: str, token: str = "") -> str: + """返回 `created` / `exists` / `conflict:<原因>`。""" + from pymilvus import AsyncMilvusClient # type: ignore[import-untyped] + + client = AsyncMilvusClient(uri=uri, token=token or None) + try: + if await client.has_collection(COLLECTION): + described = await client.describe_collection(COLLECTION) + problems = describe_mismatch(described) + if problems: + return "conflict:" + "; ".join(problems) + return "exists" + await client.create_collection( + collection_name=COLLECTION, + schema=_build_schema(), + index_params=_build_index_params(), + ) + return "created" + finally: + close = getattr(client, "close", None) + if close is not None: + await close() + + +async def main() -> int: + from app.core.config import get_settings + + settings = get_settings() + uri = settings.milvus_uri + if not uri: + print("未配置 milvus_uri,无法创建集合") + return 1 + outcome = await ensure_collection(uri, settings.milvus_token or "") + print(f"{COLLECTION}: {outcome}") + if outcome.startswith("conflict:"): + print("结构不一致,未做任何修改。请人工确认后再处理。") + return 2 + return 0 + + +if __name__ == "__main__": + sys.exit(asyncio.run(main())) From cf70ac49e9482c0ea655abbc3f054d360485a049 Mon Sep 17 00:00:00 2001 From: Windows <19353512109@163.com> Date: Sat, 12 Sep 2026 10:52:25 +0800 Subject: [PATCH 06/14] =?UTF-8?q?docs:=20docs/29=20=E8=AE=A9=E5=8F=B7?= =?UTF-8?q?=E7=BB=99=E6=9E=B6=E6=9E=84=E5=B8=88=E7=BA=BF=EF=BC=88=E6=94=B9?= =?UTF-8?q?=E5=8F=B7=2033=EF=BC=89=EF=BC=9B=E8=A1=A5=E5=AE=9E=E6=96=BD?= =?UTF-8?q?=E5=90=8E=E7=BB=AD=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 架构师线 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,合并后该重号自动消除,无需额外处理。 --- ...画像投影链路.md => 33-架构对齐-记忆与画像投影链路.md} | 8 ++++++++ 1 file changed, 8 insertions(+) rename docs/{29-架构对齐-记忆与画像投影链路.md => 33-架构对齐-记忆与画像投影链路.md} (94%) diff --git a/docs/29-架构对齐-记忆与画像投影链路.md b/docs/33-架构对齐-记忆与画像投影链路.md similarity index 94% rename from docs/29-架构对齐-记忆与画像投影链路.md rename to docs/33-架构对齐-记忆与画像投影链路.md index 593d886..3ccaaa9 100644 --- a/docs/29-架构对齐-记忆与画像投影链路.md +++ b/docs/33-架构对齐-记忆与画像投影链路.md @@ -4,6 +4,14 @@ > 而主干 `qyqy_develop` 上也有同类实现。**在合并之前必须先做一次架构对齐**,否则两套会互相覆盖。 > **本文只做核对与建议,不改任何代码**。 > +> **⚠️ 后续(2026-09-12)**:本文的结论**已落地实施**,实施说明与验证证据见 +> `docs/32-记忆投影链路实现说明.md`。本文保留作为**决策依据** —— 它记录了 +> "为什么选方案 A(复用主干图投影、不引入第二套)"、"为什么由生产端组装 `memory_sources`" +> 这两个决定的原始核对过程与判据,这部分推演在 `docs/32` 里没有重复。 +> +> **编号说明**:本文原为 `docs/29`;2026-09-12 让号给架构师线的 +> `docs/29-Agent组员登录接口使用说明.md`(该编号被后推的主干占用),改号为 `docs/33`。 +> > **核对时间**:2026-09-11 晚 **主干**:`qyqy_develop` @ `bbf623a` **ZSY**:`cbcf7c7` --- From 57f56bbcbe22dcc0f7593485d81a7241cbc5a7bc Mon Sep 17 00:00:00 2001 From: Windows <19353512109@163.com> Date: Sat, 12 Sep 2026 11:24:50 +0800 Subject: [PATCH 07/14] =?UTF-8?q?fix(profile):=20=E4=BF=AE=20profile=5Fsna?= =?UTF-8?q?pshots=20=E9=87=8D=E5=A4=8D=E5=AE=9A=E4=B9=89=EF=BC=88=E4=BC=9A?= =?UTF-8?q?=E6=89=93=E6=8C=82=20Worker=EF=BC=89=EF=BC=9B=E6=B6=88=E8=B4=B9?= =?UTF-8?q?=E7=AB=AF=E5=85=9C=E5=BA=95=20memory=5Fsources?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 属环境不可用、非代码缺陷。 --- AGENTS.md | 8 +- app/model/risk_questionnaire.py | 57 ++++---- app/worker/runtime.py | 63 ++++++++- docs/32-记忆投影链路实现说明.md | 114 ++++++++++++--- .../worker/test_runtime_profile_projection.py | 130 ++++++++++++++++++ 5 files changed, 329 insertions(+), 43 deletions(-) create mode 100644 tests/unit/worker/test_runtime_profile_projection.py diff --git a/AGENTS.md b/AGENTS.md index 1c14b00..fcda30b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -112,7 +112,13 @@ (唯一键 `(event_uuid, target_store)` 对大小写无约束,MySQL 也不报错)。 取值口径以**主干既有读取方**为准(`projection_reconciliation_service.py`、 `graph_projection_worker.py`),不是文档。详见 `docs/32-记忆投影链路实现说明.md`。 -- 测试基线:`2 failed, 1307 passed, 2 skipped`(2026-09-12 记忆投影链路落地后实测; +- ⚠️ **一张表只能有一个 ORM 类**:`app/model/` 下曾出现**两个类都映射 `profile_snapshots`** + (`profile.py` 与 `risk_questionnaire.py`),各自单独导入都没事,**同时导入即抛** + `InvalidRequestError: Table 'profile_snapshots' is already defined for this MetaData instance` + —— Worker 既要重建画像又要处理投顾问卷,因此**真的被打挂过**(库里 `memory_sync_outbox` + 留下 `last_error='InvalidRequestError'` 的行)。2026-09-12 已修为 re-export,见 `docs/32` §6.2。 + **新增模型前先搜一遍 `__tablename__` 有没有被占用。** +- 测试基线:`2 failed, 1312 passed, 2 skipped`(2026-09-12 记忆投影链路落地后实测; 此前为 `3 failed, 1219 passed`——第 3 个失败 `tests/unit/repository/test_fund_readonly_contract.py` 已由投顾线合入主干时修复,**不要再当既有缺陷引用**)。 剩下 2 个失败**都不是代码缺陷**,接手时不要"修"它们: diff --git a/app/model/risk_questionnaire.py b/app/model/risk_questionnaire.py index 6bd96bb..def63e7 100644 --- a/app/model/risk_questionnaire.py +++ b/app/model/risk_questionnaire.py @@ -1,30 +1,39 @@ -"""Additive projection model for opening-risk questionnaire profiles.""" +"""开户风险问卷画像的模型出口(re-export)。 -from datetime import datetime -from typing import Any +⚠️ 2026-09-12 修复的**重复定义缺陷** +=================================== -from sqlalchemy import JSON, BigInteger, DateTime, String -from sqlalchemy.orm import Mapped, mapped_column +本模块原先**自己定义**了一个 `ProfileSnapshot` 类,映射的却是主干 +`app/model/profile.py` 里已有的同名表 `profile_snapshots`。 + +SQLAlchemy 不允许两个类映射同一张表,因此**任何同时导入本模块与 +`app.model.profile` 的进程都会抛**: + + InvalidRequestError: Table 'profile_snapshots' is already defined + for this MetaData instance. + +**实测影响(不是理论风险)**: + +- 单独导入 `app.main` / `app.worker.runtime` 都正常;但 + `profile_assembly_service` 与 `risk_questionnaire_service` **同时**导入即崩。 +- Worker 在同一个进程里既要处理 `profile.rebuild_requested`(走 `app.model.profile`), + 又要处理投顾风险问卷(走本模块)——因此这是**会打挂 Worker 的缺陷**。 +- 库里已留痕:`memory_sync_outbox` 中 `target_store='neo4j'` 的行 + `last_error='InvalidRequestError'` 就是这个原因,不是图库故障。 + +**修法**:本模块不再重复定义,改为从 `app.model.profile` 转出(re-export)。 +因此 `from app.model.risk_questionnaire import ProfileSnapshot` 的既有调用点 +**无需改动**(4 处:`risk_questionnaire_repository`、`profile_governance_service`、 +`risk_questionnaire_service` 与对应单测)。 + +**字段等价性核查**:原定义比 `app.model.profile` 多映射了一个 `current_customer_id`。 +经全仓核查**无人使用**该属性(投顾线只用到 `id` / `customer_id` / `version` / `is_current`), +且 `app.model.profile` 明确注明该列由数据库维护、**故意不映射**,故不保留。 +`app.model.profile` 的列类型(`CHAR(36)` / `CHAR(64)` / `Boolean`)与库中实际 DDL 一致, +比原定义的 `String(36)` / `String(64)` 更准确。 +""" -from app.model.base import Base from app.model.fund import FundRiskAssessment as RiskAssessment - - -class ProfileSnapshot(Base): - __tablename__ = "profile_snapshots" - - id: Mapped[int] = mapped_column(BigInteger, primary_key=True) - profile_uuid: Mapped[str | None] = mapped_column(String(36), unique=True) - customer_id: Mapped[int] = mapped_column(BigInteger, nullable=False) - version: Mapped[int] = mapped_column(BigInteger, nullable=False) - snapshot: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False) - generation_basis: Mapped[dict[str, Any] | None] = mapped_column(JSON) - snapshot_hash: Mapped[str | None] = mapped_column(String(64)) - is_current: Mapped[bool] = mapped_column(nullable=False, default=False) - current_customer_id: Mapped[int | None] = mapped_column(BigInteger) - generated_at: Mapped[datetime | None] = mapped_column(DateTime) - created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) - updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) - +from app.model.profile import ProfileSnapshot __all__ = ["ProfileSnapshot", "RiskAssessment"] diff --git a/app/worker/runtime.py b/app/worker/runtime.py index abb8394..9d968b8 100644 --- a/app/worker/runtime.py +++ b/app/worker/runtime.py @@ -485,8 +485,9 @@ class WorkerRuntime: from app.worker.memory_sync_outbox_worker import MemorySyncOutboxWorker async def project_milvus(payload: dict[str, Any]) -> None: + effective = await self._with_memory_sources(payload) projection = MilvusProfileProjection(vector_client, self._profile_embed) - await projection.upsert(payload) + await projection.upsert(effective) async def project_neo4j(payload: dict[str, Any]) -> None: raw_customer_id = payload.get("customer_id") @@ -518,6 +519,66 @@ class WorkerRuntime: handled += 1 return handled + async def _with_memory_sources(self, payload: dict[str, Any]) -> dict[str, Any]: + """保证 payload 带 `memory_sources`;缺失时回退为查询当前有效记忆。 + + 为什么需要这个兜底:`memory_sources` 是本仓新增的投影入参,而**投顾线两处 + 生产者**(`profile_governance_service` / `risk_questionnaire_service`)发的 payload + 是 `{customer_id, profile_uuid, version, profile}`,**没有**这个键。若不兜底, + 它们每次画像变更都会因 `memory_sources is invalid` 失败重试直至死信 + (本仓 `memory_sync_outbox` 已有这种 `ValueError` 行留痕)。 + + 为什么不是"缺失就报错":缺失与"格式错"性质不同——缺失表示该生产者不知道要提供, + 属契约演进期的正常情况;格式错(不是列表、字段不合法)仍由适配器**失败关闭**, + 不会被这里掩盖。 + + 回退查的是 `memory_unit` 中 `status='active'` 的行,即"该客户当前有效的长期记忆"。 + 这在语义上成立:长期记忆是**客户级**的,不是画像版本级的;且每条记忆自带 + `version`,适配器按 `memory_uuid + version` 做幂等,所以"用的是哪一版"仍然确定。 + + 每次兜底都记一条 warning,使"谁没提供 memory_sources"保持可见,而不是静默兼容。 + """ + sources = payload.get("memory_sources") + # 只对"**键不存在或为 None**"兜底。若键存在但格式不对(例如字符串), + # 原样放行交给适配器报错——那是真错误,兜底会把它悄悄修好、线上永远看不见。 + # (用 isinstance 判断会把这两种情况混为一谈,故用键存在性判断。) + if sources is not None: + return payload + + raw_customer_id = payload.get("customer_id") + if not isinstance(raw_customer_id, (int, str)) or raw_customer_id == "": + # 没有客户号就无法兜底;交给适配器按原 payload 失败关闭。 + return payload + customer_id = int(raw_customer_id) + + from app.repository.profile_repository import ProfileRepository + + async with SessionFactory() as session: + rows = await ProfileRepository(session).active_memories(customer_id) + logger.warning( + "profile projection payload has no memory_sources (customer_id=%s); " + "fell back to %s active memories from memory_unit", + customer_id, + len(rows), + ) + return { + **payload, + "memory_sources": [ + { + "memory_uuid": str(row["memory_uuid"]), + "memory_key": str(row["memory_key"]), + "content": str(row["content"]), + "memory_type": str(row["memory_type"]), + "confidence": float(row["confidence"]), + "version": int(row["version"]), + "valid_until": ( + row["valid_until"].isoformat() if row["valid_until"] else None + ), + } + for row in rows + ], + } + async def _profile_embed(self, text: str) -> list[float]: """向量化一条记忆正文;端点走与知识向量化同一套已批准端点解析。 diff --git a/docs/32-记忆投影链路实现说明.md b/docs/32-记忆投影链路实现说明.md index 9356ac8..66e4145 100644 --- a/docs/32-记忆投影链路实现说明.md +++ b/docs/32-记忆投影链路实现说明.md @@ -167,6 +167,51 @@ 向量化复用与知识向量同一套已批准端点解析(`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` +**无需改动**。原定义多映射的 `current_customer_id` 经全仓核查**无人使用**,故不保留 +(`app.model.profile` 明确注明该列由数据库维护、故意不映射)。 + --- ## 7. 真机验证证据(2026-09-12) @@ -178,8 +223,9 @@ | 真实写入 + 回读 | 2 条可投影键写入成功并可回读(内容/版本正确) | | 不可投影键 | `constraint:liquidity` **未写入**(跳过生效,未毒死整批) | | 测试数据清理 | 已按 `customer_id=999999` 删除,集合残留 **0** 条 | -| 单元测试 | 新增 17 个(适配器 10 + worker 5 + 生产端 2),全过 | -| 全量回归 | `2 failed, 1307 passed, 2 skipped` —— 与基线一致,**无新增失败** | +| **消费端全路径**(补验) | 见下方 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` @@ -188,24 +234,54 @@ > 真机注意: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_sync_outbox` 2 行、 - `domain_event_outbox` 积压 - (`agent.run_requested` 428、`profile.rebuild_requested` 216、 - `agent.run_completed` 61、`memory.extraction_requested` 58)。 - **`memory_unit` 与 `user_facts` 目前都是 0 行** —— 代码链路是通的, - 但没有 Worker 在跑,所以记忆永远不会被抽取出来。起 - `python -m app.worker --once`(或常驻)即会开始消费。 -2. **投顾线两处生产者的取值**:`profile_governance_service.py` 与 - `risk_questionnaire_service.py` 已写小写 `milvus`/`neo4j` + `pending`(与本文口径一致), - 但它们的 `payload` 是 `{customer_id, profile_uuid, version, profile}`, - **没有 `memory_sources`** → 投影时会因 `memory_sources is invalid` 失败重试至死信。 - 需投顾线补 `memory_sources` 或明确这两个来源是否也要投影长期记忆。 - (属架构师线的改动,本线未动。) -3. `docs/00` §6.4.6 的取值栏与实现不一致:按评审要求**未改基线文档**, +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 节为准。 --- @@ -218,12 +294,16 @@ - `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()` + `run_once` 接线) +- `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 数字) diff --git a/tests/unit/worker/test_runtime_profile_projection.py b/tests/unit/worker/test_runtime_profile_projection.py new file mode 100644 index 0000000..df28268 --- /dev/null +++ b/tests/unit/worker/test_runtime_profile_projection.py @@ -0,0 +1,130 @@ +"""画像投影入参的契约与兜底:`WorkerRuntime._with_memory_sources`。 + +背景(本测试要拦住的真实故障):`memory_sources` 是本仓新增的投影入参,而投顾线两处 +生产者(`profile_governance_service` / `risk_questionnaire_service`)发的 payload 是 +`{customer_id, profile_uuid, version, profile}`,**没有**这个键。若不兜底,它们每次画像 +变更都会因 `memory_sources is invalid` 失败重试直至死信(库里已留 `ValueError` 行痕)。 + +同时要钉住"兜底不得掩盖格式错":已提供但格式不合法时,仍由适配器失败关闭。 +""" + +from typing import Any + +import pytest + +from app.repository.profile_repository import ProfileRepository +from app.worker.runtime import WorkerRuntime + + +class FakeSession: + def __init__(self) -> None: + self.closed = False + + async def __aenter__(self) -> "FakeSession": + return self + + async def __aexit__(self, *args: object) -> None: + self.closed = True + + +def memory_row() -> dict[str, Any]: + return { + "memory_uuid": "11111111-2222-3333-4444-555555555555", + "memory_key": "preference:risk_level", + "content": "稳健型", + "memory_type": "preference", + "confidence": 0.9, + "version": 3, + "valid_until": None, + } + + +@pytest.mark.asyncio +async def test_provided_memory_sources_is_passed_through_unchanged() -> None: + """生产端已提供时必须**原样**使用事件里的确定快照,不回查数据库。""" + runtime = WorkerRuntime() + sources = [{"memory_uuid": "u-1", "memory_key": "preference:horizon"}] + payload = {"customer_id": 9102, "profile_version": 2, "memory_sources": sources} + + result = await runtime._with_memory_sources(payload) + + assert result is payload + assert result["memory_sources"] is sources + + +@pytest.mark.asyncio +async def test_missing_memory_sources_falls_back_to_active_memories( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """缺失时回退为查询当前有效记忆,并把它组装成适配器认得的形状。""" + runtime = WorkerRuntime() + calls: list[int] = [] + + async def fake_active_memories(self: Any, customer_id: int) -> list[dict[str, Any]]: + calls.append(customer_id) + return [memory_row()] + + monkeypatch.setattr(ProfileRepository, "active_memories", fake_active_memories) + monkeypatch.setattr("app.worker.runtime.SessionFactory", FakeSession) + + payload = {"customer_id": "9102", "profile_version": 2} # 字符串客户号,复现生产端形态 + result = await runtime._with_memory_sources(payload) + + assert calls == [9102] + sources = result["memory_sources"] + assert len(sources) == 1 + assert sources[0]["memory_key"] == "preference:risk_level" + assert sources[0]["memory_uuid"] == memory_row()["memory_uuid"] + assert sources[0]["version"] == 3 + # 原 payload 的其余键必须保留(适配器还要用 profile_version 等) + assert result["profile_version"] == 2 + + +@pytest.mark.asyncio +async def test_empty_active_memories_yields_empty_list_not_missing_key( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """没有有效记忆时补**空列表**(不是删掉键)。 + + 空列表是"确实没有要投影的记忆",适配器接受并写 0 行;缺键则会 `memory_sources is + invalid` 报错、退避重试到死信。两者语义不同,必须区分。 + """ + runtime = WorkerRuntime() + + async def none_active(self: Any, customer_id: int) -> list[dict[str, Any]]: + return [] + + monkeypatch.setattr(ProfileRepository, "active_memories", none_active) + monkeypatch.setattr("app.worker.runtime.SessionFactory", FakeSession) + + result = await runtime._with_memory_sources({"customer_id": 9102}) + + assert result["memory_sources"] == [] + + +@pytest.mark.asyncio +async def test_payload_without_customer_id_is_left_alone() -> None: + """没有客户号时无法兜底,原样返回、交给适配器失败关闭(不在此处静默造数据)。""" + runtime = WorkerRuntime() + payload = {"profile_version": 1} + + result = await runtime._with_memory_sources(payload) + + assert result is payload + assert "memory_sources" not in result + + +@pytest.mark.asyncio +async def test_non_list_memory_sources_is_not_silently_replaced() -> None: + """已提供但格式错(不是列表)时不兜底——那是真错误,必须由适配器报出来。 + + 这条守住"兜底不得掩盖格式错":否则生产者写错字段类型会被悄悄修好, + 线上永远看不到问题。 + """ + runtime = WorkerRuntime() + payload = {"customer_id": 9102, "memory_sources": "not-a-list"} + + result = await runtime._with_memory_sources(payload) + + assert result is payload + assert result["memory_sources"] == "not-a-list" From 3e081addf0c06ba2fc27e0710a00fa1495fc502c Mon Sep 17 00:00:00 2001 From: Windows <19353512109@163.com> Date: Sat, 12 Sep 2026 13:00:30 +0800 Subject: [PATCH 08/14] =?UTF-8?q?docs:=20docs/32=E3=80=81docs/33=20?= =?UTF-8?q?=E8=AE=A9=E5=8F=B7=E8=87=B3=2037=E3=80=8138=EF=BC=88=E4=B8=BB?= =?UTF-8?q?=E5=B9=B2=E7=BB=AD=E5=8D=A0=2032=E2=80=9336=EF=BC=89=EF=BC=9B?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=E5=BC=95=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主干 `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 --- AGENTS.md | 4 ++-- ...-记忆投影链路实现说明.md => 37-记忆投影链路实现说明.md} | 0 ...与画像投影链路.md => 38-架构对齐-记忆与画像投影链路.md} | 7 ++++--- 3 files changed, 6 insertions(+), 5 deletions(-) rename docs/{32-记忆投影链路实现说明.md => 37-记忆投影链路实现说明.md} (100%) rename docs/{33-架构对齐-记忆与画像投影链路.md => 38-架构对齐-记忆与画像投影链路.md} (97%) diff --git a/AGENTS.md b/AGENTS.md index fcda30b..b006930 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -111,12 +111,12 @@ 大写 + 中文两个条件都不满足 ⇒ **事件任何消费者都领不到、永久滞留且不报错** (唯一键 `(event_uuid, target_store)` 对大小写无约束,MySQL 也不报错)。 取值口径以**主干既有读取方**为准(`projection_reconciliation_service.py`、 - `graph_projection_worker.py`),不是文档。详见 `docs/32-记忆投影链路实现说明.md`。 + `graph_projection_worker.py`),不是文档。详见 `docs/37-记忆投影链路实现说明.md`。 - ⚠️ **一张表只能有一个 ORM 类**:`app/model/` 下曾出现**两个类都映射 `profile_snapshots`** (`profile.py` 与 `risk_questionnaire.py`),各自单独导入都没事,**同时导入即抛** `InvalidRequestError: Table 'profile_snapshots' is already defined for this MetaData instance` —— Worker 既要重建画像又要处理投顾问卷,因此**真的被打挂过**(库里 `memory_sync_outbox` - 留下 `last_error='InvalidRequestError'` 的行)。2026-09-12 已修为 re-export,见 `docs/32` §6.2。 + 留下 `last_error='InvalidRequestError'` 的行)。2026-09-12 已修为 re-export,见 `docs/37` §6.2。 **新增模型前先搜一遍 `__tablename__` 有没有被占用。** - 测试基线:`2 failed, 1312 passed, 2 skipped`(2026-09-12 记忆投影链路落地后实测; 此前为 `3 failed, 1219 passed`——第 3 个失败 diff --git a/docs/32-记忆投影链路实现说明.md b/docs/37-记忆投影链路实现说明.md similarity index 100% rename from docs/32-记忆投影链路实现说明.md rename to docs/37-记忆投影链路实现说明.md diff --git a/docs/33-架构对齐-记忆与画像投影链路.md b/docs/38-架构对齐-记忆与画像投影链路.md similarity index 97% rename from docs/33-架构对齐-记忆与画像投影链路.md rename to docs/38-架构对齐-记忆与画像投影链路.md index 3ccaaa9..601a8a1 100644 --- a/docs/33-架构对齐-记忆与画像投影链路.md +++ b/docs/38-架构对齐-记忆与画像投影链路.md @@ -5,12 +5,13 @@ > **本文只做核对与建议,不改任何代码**。 > > **⚠️ 后续(2026-09-12)**:本文的结论**已落地实施**,实施说明与验证证据见 -> `docs/32-记忆投影链路实现说明.md`。本文保留作为**决策依据** —— 它记录了 +> `docs/37-记忆投影链路实现说明.md`。本文保留作为**决策依据** —— 它记录了 > "为什么选方案 A(复用主干图投影、不引入第二套)"、"为什么由生产端组装 `memory_sources`" -> 这两个决定的原始核对过程与判据,这部分推演在 `docs/32` 里没有重复。 +> 这两个决定的原始核对过程与判据,这部分推演在 `docs/37` 里没有重复。 > > **编号说明**:本文原为 `docs/29`;2026-09-12 让号给架构师线的 -> `docs/29-Agent组员登录接口使用说明.md`(该编号被后推的主干占用),改号为 `docs/33`。 +> `docs/29-Agent组员登录接口使用说明.md`,先改号 `docs/33`,后又因主干续占 32–36 +> 再让号至 `docs/38`(本文的实际编号以文件名与 `AGENTS.md` 为准)。 > > **核对时间**:2026-09-11 晚 **主干**:`qyqy_develop` @ `bbf623a` **ZSY**:`cbcf7c7` From 91f1efcada05bc8566215354eba8493babcc8c68 Mon Sep 17 00:00:00 2001 From: Windows <19353512109@163.com> Date: Sat, 12 Sep 2026 13:18:16 +0800 Subject: [PATCH 09/14] =?UTF-8?q?chore:=20=E5=90=8C=E6=AD=A5=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E5=9F=BA=E7=BA=BF=E5=88=B0=E5=90=88=E5=B9=B6=E4=B8=BB?= =?UTF-8?q?=E5=B9=B2=E5=90=8E=E7=9A=84=E5=AE=9E=E6=B5=8B=E5=80=BC=EF=BC=88?= =?UTF-8?q?1414=20passed=20/=20mypy=20245=20=E6=96=87=E4=BB=B6=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 88a6f3e..bcfa7d1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -117,7 +117,7 @@ **新增模型前先搜一遍 `__tablename__` 有没有被占用。** - 测试基线(**2026-09-12 合并主干 PR #7 + 本线记忆投影链路之后实测**): `mypy app` → **245 个文件 0 错**; - `pytest tests`(全量)→ `2 failed, 1396 passed, 2 skipped`; + `pytest tests`(全量)→ `2 failed, 1414 passed, 2 skipped`; `pytest tests/integration` → `102 passed, 1 skipped`。 ⚠️ **用例数会随开发增减,判断健康看"0 failed"而不是看绝对值**。 剩下那 2 个失败**都不是代码缺陷**,接手时不要"修"它们: From 67ba1b8eee5a13109d7e33f12844b030d1854faf Mon Sep 17 00:00:00 2001 From: Windows <19353512109@163.com> Date: Sat, 12 Sep 2026 13:23:43 +0800 Subject: [PATCH 10/14] =?UTF-8?q?docs:=20=E6=A0=87=E6=98=8E=20neo4j=5Fprof?= =?UTF-8?q?ile=5Fprojection=20=E5=BD=93=E5=89=8D=E6=9C=AA=E8=A2=AB?= =?UTF-8?q?=E7=94=9F=E4=BA=A7=E8=A3=85=E9=85=8D=EF=BC=88=E6=96=B9=E6=A1=88?= =?UTF-8?q?=20A=20=E5=8F=96=E8=88=8D=EF=BC=89=E5=8F=8A=E5=90=AF=E7=94=A8?= =?UTF-8?q?=E5=89=8D=E6=8F=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 该适配器与主干 `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 份无编号冲突。 --- .../neo4j_profile_projection.py | 27 ++++++++++++++ docs/39-主干合并对策记录.md | 36 ++++++++++++++++--- 2 files changed, 59 insertions(+), 4 deletions(-) diff --git a/app/infrastructure/neo4j_profile_projection.py b/app/infrastructure/neo4j_profile_projection.py index 4dcba13..1df4fac 100644 --- a/app/infrastructure/neo4j_profile_projection.py +++ b/app/infrastructure/neo4j_profile_projection.py @@ -1,6 +1,33 @@ """Neo4j 客户画像最小投影适配器。 该模块只接受已审核画像快照的结构化来源,不接受模型生成的 Cypher 或关系名称。 + +.. warning:: + + **本模块当前未被生产代码装配**(2026-09-12 起)。 + + 它与主干 `app/service/profile_graph_projection_service.py` 是**同一件事的两套实现**, + 而两者对图的建模不同: + + - **本模块**:`MERGE (c:Customer)-[:PREFERS/HAS_GOAL]->(p:Preference/goal)`, + 按客户各建**私有**节点,数据源是 `memory_unit`(原始记忆); + - **主干服务**:`MERGE (a:Customer)-[:PREFERS]->(b:tag)`,写**共享** tag 节点, + 数据源是 `user_facts`(**已确认**事实),并显式承诺 + "只投影已确认的事实……否则同一件事在画像和图里会有两种说法"。 + + 两套同时上线 ⇒ 同一事实在图中两种表示。2026-09-12 合并主干 PR #7 时据此取舍为 + **方案 A:只保留主干服务**,`memory_sync_outbox` 的 `neo4j` 分支改由 + `WorkerRuntime.consume_profile_projections()` 调用 `ProfileGraphProjectionService`; + 原先在 `app/worker/__main__.py` 里对本模块的装配(`memory_sync_handlers["neo4j"]`) + 已删除。取舍的完整理由见 `docs/39-主干合并对策记录.md` §3.3。 + + **保留本文件**是因为实现本身是完整的(`MERGE` 幂等、按 `profile_version` 判重不被旧版本覆盖、 + 写入前经 `sanitize_customer_service_message` 脱敏),对后续讨论仍有参考价值; + 其单测 `tests/unit/infrastructure/test_neo4j_profile_projection.py` 仍在跑, + 保护的是模块自身的契约,**不代表它已被装配**。 + + **若要启用**:不要只加回 `__main__.py` 的装配 —— 那会重新变成两套图投影并存。 + 正确顺序是先决定"图的节点模型以谁为准",再改主干服务或本模块使二者一致。 """ from dataclasses import dataclass diff --git a/docs/39-主干合并对策记录.md b/docs/39-主干合并对策记录.md index afa0498..0f1ce7e 100644 --- a/docs/39-主干合并对策记录.md +++ b/docs/39-主干合并对策记录.md @@ -83,9 +83,35 @@ Milvus 客户端由 `bootstrap.get_milvus_profile_vector_client()` 惰性构造、缺配置时显式降级 —— 与 `runtime.py` 自己写明的"由组装层注入、不在此兜底"口径一致。 -**副作用**:`app/infrastructure/neo4j_profile_projection.py`(ZSY 那套)**不再被生产代码引用**, -只剩它自己的单测在跑 ⇒ 成为**死代码**。本线**未删**(属架构师线,且其单测仍在), -**请架构师决定**:是删掉,还是明确"两套图投影各服务什么场景"。 +**随之失去生产引用的文件**:`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"需要重新讨论(这已超出本线能定的范围)。 --- @@ -148,7 +174,9 @@ ## 6. 遗留 / 待架构师确认 -1. **`neo4j_profile_projection.py` 成为死代码**(§3.3)—— 删或明确分工。 +1. **`neo4j_profile_projection.py` 失去生产引用**(§3.3)—— 本质是它与方案 A 互斥, + 不是它本来就没被装配。本线已在文件头加警告说明并保留文件, + 待架构师在"删除 / 保留为参考 / 反过来改用它"三者间决定。 2. **投顾线两处生产者的 payload 仍缺 `memory_sources`** —— 本线的消费端已有兜底(不再死信), 但根治应由投顾线补上或明确"这两个来源是否也要投影长期记忆"。 3. **`docs/00` §6.4.6 的取值栏与实现不一致** —— 按既定裁定未改基线文档, From be970aa46a0c88e1666a3aaec9c71d84f5fbde1b Mon Sep 17 00:00:00 2001 From: Windows <19353512109@163.com> Date: Sat, 12 Sep 2026 14:08:50 +0800 Subject: [PATCH 11/14] =?UTF-8?q?chore:=20=E5=90=8C=E6=AD=A5=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E5=9F=BA=E7=BA=BF=EF=BC=881415=20passed=EF=BC=89?= =?UTF-8?q?=EF=BC=9B=E5=90=88=E5=B9=B6=E4=B8=BB=E5=B9=B2=E8=A2=81=E8=81=AA?= =?UTF-8?q?=E5=9C=BA=E5=A4=96=E7=BA=BF=204=20=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index bcfa7d1..cb906f4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -117,7 +117,7 @@ **新增模型前先搜一遍 `__tablename__` 有没有被占用。** - 测试基线(**2026-09-12 合并主干 PR #7 + 本线记忆投影链路之后实测**): `mypy app` → **245 个文件 0 错**; - `pytest tests`(全量)→ `2 failed, 1414 passed, 2 skipped`; + `pytest tests`(全量)→ `2 failed, 1415 passed, 2 skipped`; `pytest tests/integration` → `102 passed, 1 skipped`。 ⚠️ **用例数会随开发增减,判断健康看"0 failed"而不是看绝对值**。 剩下那 2 个失败**都不是代码缺陷**,接手时不要"修"它们: From 58c28ef4a045cd64fe02797efe6e0796be8d17eb Mon Sep 17 00:00:00 2001 From: Windows <19353512109@163.com> Date: Sat, 12 Sep 2026 14:11:42 +0800 Subject: [PATCH 12/14] =?UTF-8?q?fix(memory):=20=E8=AE=B0=E5=BF=86?= =?UTF-8?q?=E6=8A=BD=E5=8F=96=E5=AE=B9=E5=BF=8D"=E5=8D=95=E5=85=83?= =?UTF-8?q?=E7=B4=A0=E6=95=B0=E7=BB=84"=E5=BD=A2=E7=8A=B6=EF=BC=8C?= =?UTF-8?q?=E7=A9=BA=E7=BB=93=E6=9E=9C=E4=B8=8D=E5=86=8D=E8=A2=AB=E5=88=A4?= =?UTF-8?q?=E4=B8=BA=E6=97=A0=E6=95=88=20JSON?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 现象(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` 属主干线代码。此处是从**实际运行日志**里发现的 > 健壮性缺陷,改动限定在输出形状归一化,不改变抽取契约与校验规则。 --- app/service/memory_extraction_service.py | 16 +++++++-- .../service/test_memory_extraction_service.py | 33 +++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/app/service/memory_extraction_service.py b/app/service/memory_extraction_service.py index 0286f3d..31a9fef 100644 --- a/app/service/memory_extraction_service.py +++ b/app/service/memory_extraction_service.py @@ -163,8 +163,20 @@ class MemoryExtractionService: candidate = candidate.removeprefix("```") candidate = candidate.removeprefix("json").removesuffix("```").strip() try: - return _ExtractionPayload.model_validate(json.loads(candidate)) - except (json.JSONDecodeError, ValidationError, TypeError) as exc: + data = json.loads(candidate) + except (json.JSONDecodeError, TypeError) as exc: + raise RecoverableAgentError("模型记忆抽取输出不是有效 JSON") from exc + # 模型有时把结果包在**单元素数组**里,而契约是**对象**。实测出现于 episode 抽取路径: + # [{"memory_key": null, "value": null, "memory_type": null, "confidence": 0}] + # 该形状此前直接进 pydantic 校验、报 `Input should be a valid dictionary`, + # 被记成"输出不是有效 JSON"并反复重试直到 episode 判失败 —— 而它其实是**合法的空结果** + # (`_validate` 已能把"三字段为 null 且 confidence=0"识别为"无持久事实")。 + # 只归一化"恰好一个对象的数组";多元素或元素非对象时**不猜**,仍交给校验失败关闭。 + if isinstance(data, list) and len(data) == 1 and isinstance(data[0], dict): + data = data[0] + try: + return _ExtractionPayload.model_validate(data) + except (ValidationError, TypeError) as exc: raise RecoverableAgentError("模型记忆抽取输出不是有效 JSON") from exc def _validate( diff --git a/tests/unit/service/test_memory_extraction_service.py b/tests/unit/service/test_memory_extraction_service.py index c06a57a..ac93368 100644 --- a/tests/unit/service/test_memory_extraction_service.py +++ b/tests/unit/service/test_memory_extraction_service.py @@ -161,6 +161,39 @@ async def test_declared_empty_extraction_returns_none() -> None: await extract_with(json.dumps({**payload, "confidence": 0.5})) +@pytest.mark.asyncio +async def test_single_element_array_is_normalized() -> None: + """模型把结果包在**单元素数组**里时归一化为对象,而不是当成"无效 JSON"。 + + 背景(2026-09-12 实测):契约是对象,但模型在 episode 抽取路径会返回 + `[{"memory_key": null, "value": null, "memory_type": null, "confidence": 0}]`。 + 该形状此前直接进 pydantic 校验,报 + `Input should be a valid dictionary ... input_type=list`,被记成"输出不是有效 JSON", + 于是反复重试到 episode 判失败 —— 而它其实是**合法的空结果**。 + """ + empty = {"memory_key": None, "value": None, "memory_type": None, "confidence": 0} + # 数组包着的空结果 → 归一化后按"无持久事实"处理,返回 None(不是失败)。 + assert await extract_with(json.dumps([empty])) is None + # 数组包着的**有事实**结果 → 同样归一化,照常解析出来。 + extracted = await extract_with(json.dumps([VALID_PAYLOAD])) + assert extracted is not None + assert extracted.memory_key == VALID_PAYLOAD["memory_key"] + + +@pytest.mark.asyncio +async def test_multi_element_or_non_dict_array_still_fails_closed() -> None: + """只归一化"恰好一个对象的数组";其余形状**不猜**,仍失败关闭。 + + 多元素时无法判断哪一个是答案,猜错会把错误记忆写进库 —— 比失败更糟。 + """ + with pytest.raises(RecoverableAgentError): + await extract_with(json.dumps([VALID_PAYLOAD, VALID_PAYLOAD])) + with pytest.raises(RecoverableAgentError): + await extract_with(json.dumps(["not-a-dict"])) + with pytest.raises(RecoverableAgentError): + await extract_with(json.dumps([])) + + @pytest.mark.asyncio async def test_missing_endpoint_fails_closed_without_calling_model() -> None: service, model = build_service(json.dumps(VALID_PAYLOAD), resolver=StubResolver([])) From ffbcc229c2e65f8746912973bbd299bf15480a05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=BF=E4=BA=91=E7=A7=8B=E6=9C=88?= <15273589815@163.com> Date: Sat, 12 Sep 2026 14:17:42 +0800 Subject: [PATCH 13/14] =?UTF-8?q?fix:=20=E5=88=A0=E6=8E=89=20NL=20?= =?UTF-8?q?=E5=90=88=E5=B9=B6=E5=90=8E=E6=AE=8B=E7=95=99=E7=9A=84=E6=9C=AA?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=E5=8F=98=E9=87=8F=20role=5Fids=EF=BC=88ruff?= =?UTF-8?q?=20F841=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tools/seed_test_rbac.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tools/seed_test_rbac.py b/tools/seed_test_rbac.py index 9fbdf10..f308e62 100644 --- a/tools/seed_test_rbac.py +++ b/tools/seed_test_rbac.py @@ -154,7 +154,6 @@ async def seed() -> None: # "尚未生效",于是刚种好的账号一个权限都拿不到(表现为 roles=() 而非报错)。 # 往前留 5 秒,彻底避开这个舍入窗口。 effective_at = now - timedelta(seconds=5) - role_ids = tuple(role[0] for role in ROLES) async with SessionFactory() as session: await session.execute( text("DELETE FROM sys_role_permission WHERE role_id IN (9001,9002,9003)") From c200a61cd54ad36e1353b03274fcb268e6657960 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=BF=E4=BA=91=E7=A7=8B=E6=9C=88?= <15273589815@163.com> Date: Sat, 12 Sep 2026 14:22:34 +0800 Subject: [PATCH 14/14] =?UTF-8?q?docs:=20docs/32=20=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E5=88=B0=202026-09-12=20=E7=8A=B6=E6=80=81=EF=BC=8C=E8=A1=A5?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E6=80=A7=E6=B5=8B=E8=AF=95=E7=9A=84=E8=A7=A6?= =?UTF-8?q?=E5=8F=91=E6=9D=A1=E4=BB=B6=E4=B8=8E=E5=A4=96=E9=83=A8=E4=BE=9D?= =?UTF-8?q?=E8=B5=96=E5=89=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/32-平台侧交接与联调准备.md | 65 +++++++++++++++++++++++++++------ 1 file changed, 54 insertions(+), 11 deletions(-) diff --git a/docs/32-平台侧交接与联调准备.md b/docs/32-平台侧交接与联调准备.md index c15489b..77727c4 100644 --- a/docs/32-平台侧交接与联调准备.md +++ b/docs/32-平台侧交接与联调准备.md @@ -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