## 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 属环境不可用、非代码缺陷。
40 lines
2.0 KiB
Python
40 lines
2.0 KiB
Python
"""开户风险问卷画像的模型出口(re-export)。
|
||
|
||
⚠️ 2026-09-12 修复的**重复定义缺陷**
|
||
===================================
|
||
|
||
本模块原先**自己定义**了一个 `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.fund import FundRiskAssessment as RiskAssessment
|
||
from app.model.profile import ProfileSnapshot
|
||
|
||
__all__ = ["ProfileSnapshot", "RiskAssessment"]
|