fix(memory-projection): 订正 outbox 取值口径并接通画像投影链路

背景:memory_sync_outbox 这条链此前**完全没有消费者**,且生产端照 docs/00 §6.4.6
写成大写 MILVUS/NEO4J + 中文「待处理」,而消费端按 target_store 的**值**分派 handler、
且只领 status in {pending, failed} —— 两个条件都不满足,事件任何消费者都领不到、
永久滞留且不报错(唯一键 (event_uuid, target_store) 对大小写无约束,MySQL 也不报错)。
根因是代码与测试都硬编码字面量,所以测试跟着一起错、谁也没拦住。

订正
- profile_generation_service:取值改为全仓一致的小写(milvus/neo4j/upsert/pending)
- 测试改为引用常量并断言消费端契约,不再硬编码(硬编码是本次跑偏的直接原因)
- 新增契约回归测试:断言大写值分派不到 handler、会进死信,谁改回大写立刻红
- 新增 tools/normalize_memory_sync_outbox.py:订正历史脏行(默认 dry-run、幂等)

接通投影链路(此前零消费者)
- 新增 Milvus 集合 user_long_term_memory_v1 及建集合工具(幂等、不覆盖已有集合)
- 新增 MilvusProfileProjection / MilvusProfileVectorClient,并修掉移植带来的两处必炸点:
  customer_id 由「必须 int」放宽为接受数字字符串(本仓所有生产者都写 str,
  不放宽则每个事件必然失败);不可投影的 memory_key 由「整批 raise」改为跳过留痕
  (否则一条 constraint: 记忆毒死该客户整批,而受控词表 13 个键里有 7 个不满足前缀)
- 新增 MemorySyncOutboxWorker(领取/指数退避/死信骨架保留原样)并接入 WorkerRuntime
- milvus → 向量投影;neo4j → 复用主干 ProfileGraphProjectionService(方案 A,
  不引入第二套投影,避免同一事实在图中两种说法、违反主干既有的只投影已确认事实的不变式)
- 生产端从 memory_unit(status=active) 组装 memory_sources,随事件带上确定快照
- 前置移植 conversation_privacy:写外部存储前脱敏手机号/证件号/银行卡等

验证
- 新增 17 个单测;全量 2 failed, 1307 passed, 2 skipped
  (2 个失败为既有环境项:断言请求体中文原文而 httpx 序列化成 \uXXXX,非本次引入)
- mypy app → 0 错(227 文件);audit_schema → 89 张业务表无缺失/意外,未改动表结构
- 真机:真实 embedding(1024 维) + 真实 Milvus 写入并回读通过
- 整合链路(测试记忆 → 生产端组装 → outbox → 消费端投递 → Milvus 回读)通过,
  且 MySQL 已回滚、Milvus 无残留

文档
- 新增 docs/32-记忆投影链路实现说明.md:真实口径、根因、契约与验证证据(供接手)
- AGENTS.md:新增该易错点;新增 Windows 中文输出乱码的正确命令(-X utf8);
  校正测试基线与 mypy 文件数

未做:未改 docs/00 基线、未动数据库迁移、未改投顾线代码、未启动常驻 Worker。
遗留:投顾线两处生产者的 payload 缺 memory_sources,会被消费至死信,待架构师确认是否投影。
This commit is contained in:
2026-09-12 10:45:40 +08:00
parent c82e9890e4
commit 8a0cbab636
15 changed files with 1477 additions and 12 deletions
+48 -6
View File
@@ -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`:使用的测评版本、交易窗口和记忆版本列表)。