fix(profile): 修 profile_snapshots 重复定义(会打挂 Worker);消费端兜底 memory_sources

## 1. 独立缺陷:`profile_snapshots` 被两个 ORM 类重复映射

排查 `memory_sync_outbox` 中 `target_store='neo4j'` 那行 `last_error='InvalidRequestError'`
时发现,根因不在图库,而在模型层:

- `app/model/profile.py` → `ProfileSnapshot` 映射 `profile_snapshots`
- `app/model/risk_questionnaire.py` → **另一个** `ProfileSnapshot` 也映射 `profile_snapshots`

SQLAlchemy 不允许两个类映射同一张表。实测:

| 场景 | 结果 |
|---|---|
| 单独导入 `app.main` / `app.worker.runtime` | 正常 |
| 单独导入 `profile_assembly_service` / `risk_questionnaire_service` | 正常 |
| **两者同时导入** | `InvalidRequestError: Table 'profile_snapshots' is already defined` |

Worker 在同一进程里既要处理 `profile.rebuild_requested`(走 `app.model.profile`),
又要处理投顾风险问卷(走 `risk_questionnaire.py`)——所以这是**会打挂 Worker 的缺陷**,
不是理论风险。

修法:`app/model/risk_questionnaire.py` 不再重复定义,改为从 `app.model.profile`
转出(re-export),既有 4 处 `from app.model.risk_questionnaire import ProfileSnapshot`
无需改动。原定义多映射的 `current_customer_id` 经全仓核查无人使用,故不保留
(`app.model.profile` 明确注明该列由数据库维护、故意不映射)。

## 2. 消费端兜底 `memory_sources`

`memory_sources` 是本线新增的投影入参,而投顾线两处生产者的 payload
(`{customer_id, profile_uuid, version, profile}`)没有这个键,原样会导致它们每次画像
变更都 `memory_sources is invalid` → 重试至死信。

新增 `WorkerRuntime._with_memory_sources()`:**键缺失或为 None** 时回退查询该客户
`memory_unit` 中 `status='active'` 的记忆,并记 warning(使"谁没提供"保持可见)。
语义成立:长期记忆是**客户级**而非画像版本级的,每条记忆自带 `version`,
适配器按 `memory_uuid + version` 幂等,故"用的是哪一版"仍确定。

**兜底不掩盖真错误**:键**存在但格式不对**时**不兜底**,原样交给适配器失败关闭。
实现上用键存在性判断而非 `isinstance`——后者会把"缺失"与"格式错"混为一谈,
那是初版实现里的一个真 bug,被新测试抓出后修正。

## 3. 补上此前欠缺的消费端全路径验证

此前"整合验证"是直接调适配器,跳过了 outbox 的领取→分派→状态更新。

- **失败分支**(Milvus 断开时实测):行被领取、按 target_store 分派、异常被捕获、
  `status`/`retry_count`/`last_error`/`next_retry_at` 正确落库。
- **成功分支**(注入替身向量客户端):outbox 行 → `processed`、`processed_at` 已写;
  不可投影的 `constraint:` 被跳过(只写 1 行);维度 1024;字符串客户号转 int;
  **手机号脱敏生效**(`稳健型投资者,手机号 [手机号已隐藏] 请勿外泄`)。
- **兜底实证**:历史行 `id=5`(payload 无 `memory_sources`)经兜底后成功投递为 `processed`。
- **修复实证**:`id=6` 的 `last_error` 从 `InvalidRequestError` 变为
  `RecoverableAgentError`(图库不可用)——证明重复定义缺陷确已消除,剩下的是环境问题。

## 4. 测试与验证

- 新增 `tests/unit/worker/test_runtime_profile_projection.py`(5 用例:已提供原样透传、
  缺失兜底、空记忆给空列表而非删键、无客户号不兜底、格式错不兜底)
- 全量:`2 failed, 1312 passed, 2 skipped`(2 个失败为既有环境项,非本次引入)
- mypy:`Success: no issues found in 227 source files`
- 表结构审计:89 张业务表无缺失/意外(未改动任何表结构)
- 文档守卫:41 份文档无编号冲突

## 5. 文档

`docs/32-记忆投影链路实现说明.md` 增补 §6.1(兜底)、§6.2(重复定义缺陷)、
§7.1(消费端全路径验证)并更新验证表与文件清单;
`AGENTS.md` 新增"一张表只能有一个 ORM 类"易错点、校正测试基线数字。

## 未做

未改 `docs/00` 基线、未动数据库迁移、未改投顾线生产者代码、未启动常驻 Worker。
遗留:投顾线两处生产者的 payload 仍缺 `memory_sources`(已有兜底,不再死信,
但根治应由投顾线确认);Milvus/Neo4j 容器本轮不可用(Docker Desktop 崩溃),
`id=6` 停在 failed 属环境不可用、非代码缺陷。
This commit is contained in:
2026-09-12 11:24:50 +08:00
parent cf70ac49e9
commit 57f56bbcbe
5 changed files with 329 additions and 43 deletions
@@ -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"