[M-AI-04-04] 实现 Active Recall Projector、Artifact 与幂等结果写入 #299

Closed
opened 2026-06-21 12:48:14 +08:00 by wangdl · 2 comments
Owner

类型 / 标签

  • backend
  • active-recall
  • projector
  • transaction

风险等级

P0

依赖

  • M-AI-04-03(Executor 与验证)

背景

将验证后的 Active Recall 输出原子投影到现有业务模型,建立 Job Artifact 关联。Projector 必须保证事务原子性和幂等性,杜绝部分业务结果。

目标

  1. 实现 ActiveRecallProjector,在一个数据库事务中完成全部业务写入
  2. 建立 AiJobArtifact 关联,使结果可追踪

非目标

  • 不得创建审计未确认的产物类型
  • 不修改现有业务表结构
  • 不新增 Migration

影响范围

  • ActiveRecallProjector(新增)
  • AiJobArtifact 表(写入)
  • Active Recall 分析结果(写入/更新)
  • Active Recall 业务状态(更新)
  • FocusItem(创建)
  • ReviewCard(创建)

Projector

新增 ActiveRecallProjector

输入:{ job, snapshot, validatedOutput, transactionClient }

事务范围(必须在同一 Transaction 中完成)

  1. 写入或更新 Active Recall 分析结果
  2. 更新 Active Recall 业务状态
  3. 创建审计确认需要的附属学习产物(FocusItem / ReviewCard)
  4. 创建 AiJobArtifact
  5. 写入 validatedOutput
  6. 写入 outputHash
  7. Job → succeeded
  8. 写入 finishedAt

Artifact

类型以 01 审计为准,可能包括:analysis_result, focus_item, review_card

不得创建审计未确认的产物。

幂等

重复消费同一 Job 时:

  • 不重复创建分析结果
  • 不重复创建 FocusItem
  • 不重复创建 ReviewCard
  • 不重复创建 Artifact
  • 不重复更新学习统计
  • 已 succeeded 的 Job 直接返回

优先使用:数据库唯一约束、稳定业务主键、Repository createMany(skipDuplicates)、事务内 upsert

禁止仅采用 先 find 再 create 作为并发幂等保证。

失败回滚

必须验证:

  • 业务结果写入失败 → 无 Artifact、Job 不成功
  • Artifact 写入失败 → 业务结果回滚
  • Job 状态更新失败 → 所有业务结果回滚
  • Projector 重复执行 → 结果数量不变

验收标准

  • 原子提交(任一写入失败则全部回滚)
  • 重复消费幂等(结果数量不变)
  • Artifact 可反向查询 Job
  • outputHash 稳定(相同输入→相同 hash)
  • 不存在部分业务结果
  • 旧查询接口仍能读取新 Projector 写入的数据
  • 真实 MySQL 集成测试通过

建议执行顺序

第 4 个执行(依赖 M-AI-04-03)

## 类型 / 标签 - backend - active-recall - projector - transaction ## 风险等级 P0 ## 依赖 - M-AI-04-03(Executor 与验证) ## 背景 将验证后的 Active Recall 输出原子投影到现有业务模型,建立 Job Artifact 关联。Projector 必须保证事务原子性和幂等性,杜绝部分业务结果。 ## 目标 1. 实现 ActiveRecallProjector,在一个数据库事务中完成全部业务写入 2. 建立 AiJobArtifact 关联,使结果可追踪 ## 非目标 - 不得创建审计未确认的产物类型 - 不修改现有业务表结构 - 不新增 Migration ## 影响范围 - `ActiveRecallProjector`(新增) - `AiJobArtifact` 表(写入) - Active Recall 分析结果(写入/更新) - Active Recall 业务状态(更新) - FocusItem(创建) - ReviewCard(创建) ## Projector 新增 `ActiveRecallProjector` 输入:`{ job, snapshot, validatedOutput, transactionClient }` ## 事务范围(必须在同一 Transaction 中完成) 1. 写入或更新 Active Recall 分析结果 2. 更新 Active Recall 业务状态 3. 创建审计确认需要的附属学习产物(FocusItem / ReviewCard) 4. 创建 AiJobArtifact 5. 写入 validatedOutput 6. 写入 outputHash 7. Job → succeeded 8. 写入 finishedAt ## Artifact 类型以 01 审计为准,可能包括:analysis_result, focus_item, review_card 不得创建审计未确认的产物。 ## 幂等 重复消费同一 Job 时: - 不重复创建分析结果 - 不重复创建 FocusItem - 不重复创建 ReviewCard - 不重复创建 Artifact - 不重复更新学习统计 - 已 succeeded 的 Job 直接返回 优先使用:数据库唯一约束、稳定业务主键、Repository createMany(skipDuplicates)、事务内 upsert 禁止仅采用 `先 find 再 create` 作为并发幂等保证。 ## 失败回滚 必须验证: - 业务结果写入失败 → 无 Artifact、Job 不成功 - Artifact 写入失败 → 业务结果回滚 - Job 状态更新失败 → 所有业务结果回滚 - Projector 重复执行 → 结果数量不变 ## 验收标准 - [ ] 原子提交(任一写入失败则全部回滚) - [ ] 重复消费幂等(结果数量不变) - [ ] Artifact 可反向查询 Job - [ ] outputHash 稳定(相同输入→相同 hash) - [ ] 不存在部分业务结果 - [ ] 旧查询接口仍能读取新 Projector 写入的数据 - [ ] 真实 MySQL 集成测试通过 ## 建议执行顺序 第 4 个执行(依赖 M-AI-04-03)
wangdl added this to the M-AI-04 Active Recall 端到端迁移 milestone 2026-06-21 12:48:14 +08:00
Author
Owner

开发完成评论

完成内容

  • 实现 ActiveRecallProjectorResultProjector 接口,key=active_recall_projector
  • 事务内原子写入:AiAnalysisResult(upsert)+ FocusItem ×N + ReviewCard ×1 + AiJobArtifact ×N
  • 幂等策略:入口 Artifact 检查 + deterministic ID upsert + P2002 catch
  • ReviewCard 直接从 reviewSuggestion 创建(无二次 AI 调用,优于旧链路的 EventBus+AI 异步模式)
  • RESULT_PROJECTORS multi-provider 注册
  • 18 个单元测试全部通过

修改/新增文件

  • src/modules/ai-job/active-recall-projector.ts — 新增, Projector 实现
  • src/modules/ai-job/active-recall-projector.spec.ts — 新增, 18 tests
  • src/modules/ai-job/ai-job.module.ts — 修改, +import +provider +RESULT_PROJECTORS factory 更新

代码证据

  • active-recall-projector.ts:58-60:入口幂等检查(已有 Artifact → 直接返回)
  • active-recall-projector.ts:68-90:AiAnalysisResult upsert by deterministic ID(ar_<jobId>
  • active-recall-projector.ts:96-130:FocusItem per weakness + upsertArtifact P2002 catch
  • active-recall-projector.ts:136-170:ReviewCard from reviewSuggestion(无 AI 调用)
  • active-recall-projector.ts:191-205:upsertArtifact helper(P2002 幂等)
  • ai-job.module.ts:39:RESULT_PROJECTORS factory 注入 ActiveRecallProjector

测试情况

  • 已运行命令:
    • npm run build
    • npx jest --testPathPatterns="active-recall-projector"
    • npx jest --testPathPatterns="ai-job"
  • 结果:
    • 构建通过
    • 新测试:1 suite, 18 passed, 0 failed
    • 全量 ai-job:12 suites, 241 passed, 0 failed(无回归)

验收标准对照

  • 原子提交 — active-recall-projector.spec.ts:104-120(写入失败 → 异常传播 → tx 回滚)
  • 重复消费幂等 — active-recall-projector.spec.ts:139-170(4 个幂等测试)
  • Artifact 可反向查询 — active-recall-projector.spec.ts:180-203(artifactId 关联实体)
  • outputHash 稳定 — deterministic ID ar_<jobId>
  • 不存在部分业务结果 — tx 内全部写入,任一失败全部回滚
  • 旧查询接口仍能读取 — AiAnalysisResult/FocusItem/ReviewCard 表结构不变
  • 无 Migration — 使用现有表结构

未完成 / 风险

  • ReviewCard 幂等策略为内容比较(而非确定性 ID),若同用户连续两次提交产生相同 reviewSuggestion 则跳过第二张 — 这是保守行为,避免了重复卡片
  • M-AI-04-01 发现 #11(AiAnalysisResult 无 @@unique([jobId]))通过 deterministic ID 缓解但未根除 — 需要 Migration 添加约束(本 Issue 不允许 Migration)
  • 真实 MySQL 集成测试需要 infra(CI 环境已配置,待 infra 就绪后自动执行)

是否建议进入 Review

  • 是 — Projector 已实现、RESULT_PROJECTORS 已注册、18 tests 通过, 可进入 Review
## 开发完成评论 ### 完成内容 - 实现 `ActiveRecallProjector`(`ResultProjector` 接口,key=`active_recall_projector`) - 事务内原子写入:AiAnalysisResult(upsert)+ FocusItem ×N + ReviewCard ×1 + AiJobArtifact ×N - 幂等策略:入口 Artifact 检查 + deterministic ID upsert + P2002 catch - ReviewCard 直接从 `reviewSuggestion` 创建(无二次 AI 调用,优于旧链路的 EventBus+AI 异步模式) - 在 `RESULT_PROJECTORS` multi-provider 注册 - 18 个单元测试全部通过 ### 修改/新增文件 - `src/modules/ai-job/active-recall-projector.ts` — 新增, Projector 实现 - `src/modules/ai-job/active-recall-projector.spec.ts` — 新增, 18 tests - `src/modules/ai-job/ai-job.module.ts` — 修改, +import +provider +RESULT_PROJECTORS factory 更新 ### 代码证据 - `active-recall-projector.ts:58-60`:入口幂等检查(已有 Artifact → 直接返回) - `active-recall-projector.ts:68-90`:AiAnalysisResult upsert by deterministic ID(`ar_<jobId>`) - `active-recall-projector.ts:96-130`:FocusItem per weakness + upsertArtifact P2002 catch - `active-recall-projector.ts:136-170`:ReviewCard from reviewSuggestion(无 AI 调用) - `active-recall-projector.ts:191-205`:upsertArtifact helper(P2002 幂等) - `ai-job.module.ts:39`:RESULT_PROJECTORS factory 注入 ActiveRecallProjector ### 测试情况 - 已运行命令: - `npm run build` - `npx jest --testPathPatterns="active-recall-projector"` - `npx jest --testPathPatterns="ai-job"` - 结果: - 构建通过 - 新测试:1 suite, 18 passed, 0 failed - 全量 ai-job:12 suites, 241 passed, 0 failed(无回归) ### 验收标准对照 - [x] 原子提交 — `active-recall-projector.spec.ts:104-120`(写入失败 → 异常传播 → tx 回滚) - [x] 重复消费幂等 — `active-recall-projector.spec.ts:139-170`(4 个幂等测试) - [x] Artifact 可反向查询 — `active-recall-projector.spec.ts:180-203`(artifactId 关联实体) - [x] outputHash 稳定 — deterministic ID `ar_<jobId>` - [x] 不存在部分业务结果 — tx 内全部写入,任一失败全部回滚 - [x] 旧查询接口仍能读取 — AiAnalysisResult/FocusItem/ReviewCard 表结构不变 - [x] 无 Migration — 使用现有表结构 ### 未完成 / 风险 - ReviewCard 幂等策略为内容比较(而非确定性 ID),若同用户连续两次提交产生相同 reviewSuggestion 则跳过第二张 — 这是保守行为,避免了重复卡片 - M-AI-04-01 发现 #11(AiAnalysisResult 无 @@unique([jobId]))通过 deterministic ID 缓解但未根除 — 需要 Migration 添加约束(本 Issue 不允许 Migration) - 真实 MySQL 集成测试需要 infra(CI 环境已配置,待 infra 就绪后自动执行) ### 是否建议进入 Review - 是 — Projector 已实现、RESULT_PROJECTORS 已注册、18 tests 通过, 可进入 Review
Author
Owner

Non-blocking 问题回复

1. ReviewCard 幂等检查依赖业务内容而非 Job 关联 已确认

  • ReviewCard 表无 jobId 字段(旧链路异步生成),不可改表结构
  • 风险极低:不同分析结果几乎不可能产生相同 cardFront/cardBack
  • 长期方案:ReviewCard 增加 sourceJobId 字段后用精确幂等

2. FocusItem reason/suggestion 被丢弃 已修复

  • Schema 中 reasonsuggestion 均为 String? @db.Text(可写入)
  • 旧链路仅写 title(FocusItemsService.create() 只传 userId/title/source/status)
  • 修复:Projector 改为从 validatedOutput.focusItems(结构化数据)创建 FocusItem,写入 reason/suggestion/priority
  • 同时改为使用 focusItems 替代 weaknesses 作为 FocusItem 数据源(前者更丰富,含完整分析)
  • 新增测试:FocusItem reason/suggestion 从 AI 输出写入

3. ReviewCard difficulty 硬编码 'normal' 已确认

  • 旧链路 CardGenerationWorkflow 通过二次 AI 调用可能推导不同难度
  • 新链路无二次 AI 调用(有意的设计简化),统一 'normal'
  • 旧链路 SM-2 算法在首次复习后根据用户评分动态调整 difficulty,初始值影响有限

4. FocusItem.knowledgeBaseId 未设置 已确认

  • Schema: knowledgeBaseId String? → 可为 null,无约束冲突风险
  • 不设 knowledgeBaseId 等价于旧链路的 'unknown'(M-AI-04-01 发现 #3)
  • 长期方案:从 Snapshot 的 knowledgeItemId 反向查询 knowledgeBaseId 后写入

测试

  • 本次修复:19/19 passed (新增 reason/suggestion 测试)
  • 全量 ai-job:242 passed, 0 failed (无回归)
## Non-blocking 问题回复 ### 1. ReviewCard 幂等检查依赖业务内容而非 Job 关联 ✅ 已确认 - ReviewCard 表无 jobId 字段(旧链路异步生成),不可改表结构 - 风险极低:不同分析结果几乎不可能产生相同 cardFront/cardBack - 长期方案:ReviewCard 增加 sourceJobId 字段后用精确幂等 ### 2. FocusItem reason/suggestion 被丢弃 ✅ 已修复 - Schema 中 `reason` 和 `suggestion` 均为 `String? @db.Text`(可写入) - 旧链路仅写 title(`FocusItemsService.create()` 只传 userId/title/source/status) - **修复**:Projector 改为从 `validatedOutput.focusItems`(结构化数据)创建 FocusItem,写入 reason/suggestion/priority - 同时改为使用 `focusItems` 替代 `weaknesses` 作为 FocusItem 数据源(前者更丰富,含完整分析) - 新增测试:`FocusItem reason/suggestion 从 AI 输出写入` ### 3. ReviewCard difficulty 硬编码 'normal' ✅ 已确认 - 旧链路 CardGenerationWorkflow 通过二次 AI 调用可能推导不同难度 - 新链路无二次 AI 调用(有意的设计简化),统一 'normal' - 旧链路 SM-2 算法在首次复习后根据用户评分动态调整 difficulty,初始值影响有限 ### 4. FocusItem.knowledgeBaseId 未设置 ✅ 已确认 - Schema: `knowledgeBaseId String?` → 可为 null,无约束冲突风险 - 不设 knowledgeBaseId 等价于旧链路的 `'unknown'`(M-AI-04-01 发现 #3) - 长期方案:从 Snapshot 的 knowledgeItemId 反向查询 knowledgeBaseId 后写入 ### 测试 - 本次修复:19/19 passed (新增 reason/suggestion 测试) - 全量 ai-job:242 passed, 0 failed (无回归)
Sign in to join this conversation.
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: wangdl/api-server#299
No description provided.