完成 M-AI-06 全部 7 个 Issue: - 01: 审计冻结契约 (docs/architecture/m-ai-06-review-card-child-job-contract.md) - 02: AiJobCreationService.createInTransaction(tx, input) - 03: ReviewCard Generation Job Definition + Snapshot Builder - 04: ReviewCard Generation Executor + 输出验证 Validator - 05: ReviewCard Generation Projector + 卡片幂等 + Engine 接入 - 06: FeynmanProjector 接入 FEYNMAN_REVIEW_CARD_MODE Feature Flag - 07: E2E 测试 (test/m-ai-06-review-card-child.e2e-spec.ts) 核心变更: - 新增 10 个文件, 修改 7 个文件 - 新增 createInTransaction() 外部事务支持 - 新增 review_card_generation Job Type (ai-background / cheap tier) - FeynmanProjector 根据 Feature Flag 路由 child_job / legacy_event - Projector 入口幂等 + P2002 回退双重保障 - 测试: 单元 482 passed, E2E 待 CI 执行 Co-Authored-By: Claude <noreply@anthropic.com>
677 lines
33 KiB
Markdown
677 lines
33 KiB
Markdown
# M-AI-06 ReviewCard Child Job 迁移契约
|
||
|
||
> 审计日期:2026-06-21
|
||
> 契约状态:冻结(待 M-AI-06-01 验收)
|
||
> 对应里程碑:M-AI-06 Feynman ReviewCard 子 Job 与可靠生成
|
||
|
||
---
|
||
|
||
## 目录
|
||
|
||
1. [当前链路审计](#1-当前链路审计)
|
||
2. [目标链路](#2-目标链路)
|
||
3. [Parent/Child 状态机](#3-parentchild-状态机)
|
||
4. [Child Snapshot Schema](#4-child-snapshot-schema)
|
||
5. [Child Output Schema](#5-child-output-schema)
|
||
6. [幂等契约](#6-幂等契约)
|
||
7. [Feature Flag](#7-feature-flag)
|
||
8. [失败隔离](#8-失败隔离)
|
||
9. [回滚流程](#9-回滚流程)
|
||
10. [实现约束与不确定项](#10-实现约束与不确定项)
|
||
|
||
---
|
||
|
||
## 1. 当前链路审计
|
||
|
||
### 1.1 当前时序(Legacy EventBus 路径)
|
||
|
||
```text
|
||
┌─ AiAnalysisWorker ─────────────────────────────────────────────────┐
|
||
│ process(job) │
|
||
│ ├─ feynmanWorkflow.execute() ← 调用模型(Feynman 评估) │
|
||
│ │ `src/workers/ai-analysis.worker.ts:52-57` │
|
||
│ ├─ repository.createResult(userId, jobId, result) │
|
||
│ │ `ai-analysis.worker.ts:67` │
|
||
│ ├─ repository.updateJobStatus(jobId, 'completed') │
|
||
│ │ `ai-analysis.worker.ts:68` │
|
||
│ └─ eventBus?.publish(AIAnalysisCompleted({...})) ← 进程内发射 │
|
||
│ `ai-analysis.worker.ts:72-81` │
|
||
│ │
|
||
│ ★ 异常处理:publish 包在 try/catch 中,异常被吞(line 82) │
|
||
│ ★ eventBus 是 @Optional(),缺失时静默跳过(line 26) │
|
||
└──────────────────────┬──────────────────────────────────────────────┘
|
||
│ EventEmitter2.emit('ai.analysis.completed')
|
||
│ `src/common/event-bus/event-bus.service.ts:22`
|
||
▼
|
||
┌─ ReviewCardSubscriber ──────────────────────────────────────────────┐
|
||
│ @OnEvent('ai.analysis.completed') │
|
||
│ handleAIAnalysisCompleted(payload) │
|
||
│ `src/modules/review/review-card.subscriber.ts:12` │
|
||
│ ├─ 提取 weaknesses / strengths / summary │
|
||
│ ├─ cardCount = Math.min(3, Math.max(1, weaknesses.length || 1)) │
|
||
│ │ `review-card.subscriber.ts:42` │
|
||
│ └─ reviewService.generateCards(userId, {title, content, cardCount})│
|
||
│ `review-card.subscriber.ts:39-43` │
|
||
│ │
|
||
│ ★ 异常被 catch 吞掉,不向上传播(line 48-49) │
|
||
│ ★ 无幂等检查,每次事件都生成卡片 │
|
||
└──────────────────────┬──────────────────────────────────────────────┘
|
||
│
|
||
▼
|
||
┌─ ReviewService.generateCards ───────────────────────────────────────┐
|
||
│ `src/modules/review/review.service.ts:68` │
|
||
│ ├─ cardGenerationWorkflow.execute(input) ← 第二次 AI 调用 │
|
||
│ │ `review.service.ts:73-78` │
|
||
│ └─ for each card: reviewRepository.insertCard({...}) │
|
||
│ `review.service.ts:82-95` │
|
||
│ ★ 每次 insertCard 是独立的 Prisma create()
|
||
│ ★ 无事务包裹
|
||
│ ★ 部分插入失败 → 部分卡片残留
|
||
└──────────────────────┬──────────────────────────────────────────────┘
|
||
│
|
||
▼
|
||
┌─ ReviewCardGenerationWorkflow.execute ──────────────────────────────┐
|
||
│ `src/modules/ai/workflows/review-card-generation.workflow.ts:17` │
|
||
│ ├─ 构造 userMessage(知识点标题 + 内容 + cardCount) │
|
||
│ ├─ gateway.generate({ │
|
||
│ │ feature: 'review-card-generation', │
|
||
│ │ tier: 'cheap', │
|
||
│ │ promptKey: 'review-card-generation', │
|
||
│ │ promptVersion: '1.0.0', │
|
||
│ │ outputSchema: ReviewCardGenerationResultSchema, │
|
||
│ │ }) │
|
||
│ │ `review-card-generation.workflow.ts:30-40` │
|
||
│ └─ return response.parsed │
|
||
└──────────────────────┬──────────────────────────────────────────────┘
|
||
│
|
||
▼
|
||
┌─ ReviewRepository.insertCard ───────────────────────────────────────┐
|
||
│ `src/modules/review/review.repository.ts:24-37` │
|
||
│ prisma.reviewCard.create({ data }) │
|
||
│ ★ ID 由 Prisma @default(cuid()) 自动生成,不可显式赋值 │
|
||
│ ★ 无 jobId / sourceId / dedupeKey 字段 │
|
||
│ ★ 无唯一约束(除 PK 外) │
|
||
└─────────────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
### 1.2 EventBus 行为确认
|
||
|
||
| 属性 | 结论 | 证据 |
|
||
|------|------|------|
|
||
| 进程内/持久化 | **进程内**(EventEmitter2,非持久化) | `src/common/event-bus/event-bus.service.ts:19-23` — 调用 `this.eventEmitter.emit()` |
|
||
| publish 是否 await | **同步 fire-and-forget** | `event-bus.service.ts:19` — 返回 `void`,非 Promise |
|
||
| Subscriber 是否同步 | **同步**(同一 EventEmitter 实例) | `@OnEvent` 装饰器由 NestJS EventEmitterModule 同步调用 |
|
||
| 异常是否传播 | **不传播** — Worker 层 catch 吞掉 | `ai-analysis.worker.ts:82` — `} catch {}` 空块 |
|
||
| Subscriber 异常 | **被吞掉** | `review-card.subscriber.ts:48-49` — `catch (err) { logger.error(...) }` |
|
||
| 进程崩溃是否丢失 | **是** — 事件仅存于内存 | 无持久化机制(对比 `publishAsync` 使用 BullMQ) |
|
||
| 重复 publish 去重 | **无去重** | Subscriber 无幂等检查 |
|
||
| Worker 重启后重放 | **否** — 事件不持久化 | BullMQ 只负责触发 Worker,Worker 内部事件发射是临时的 |
|
||
|
||
### 1.3 ReviewCard 输入字段分类
|
||
|
||
| 字段 | 来源 | 分类 |
|
||
|------|------|------|
|
||
| `userId` | `payload.userId` | **进入 Child Snapshot** |
|
||
| `knowledgeItemTitle` | `payload.analysis.summary.slice(0, 80)` 或 `'AI 分析结果'` | **进入 Child Snapshot** |
|
||
| `knowledgeItemContent` | 拼接 `summary + strengths + weaknesses` | **进入 Child Snapshot** |
|
||
| `cardCount` | `Math.min(3, Math.max(1, weaknesses.length || 1))` | **进入 Child Snapshot** |
|
||
| `weaknesses` | `payload.analysis.weaknesses` | **进入 Child Snapshot**(用于 cardCount) |
|
||
| `strengths` | `payload.analysis.strengths` | **进入 Child Snapshot**(用于 content) |
|
||
| `summary` | `payload.analysis.summary` | **进入 Child Snapshot**(用于 title + content) |
|
||
| `promptKey` | 硬编码 `'review-card-generation'` | **进入 Child Snapshot** |
|
||
| `promptVersion` | 硬编码 `'1.0.0'` | **进入 Child Snapshot** |
|
||
| `modelTier` | 硬编码 `'cheap'` | **进入 Child Snapshot** |
|
||
| `outputSchema` | `ReviewCardGenerationResultSchema` | **进入 Child Snapshot**(schema 版本号) |
|
||
| `jobId` | `payload.jobId` | **不进入 Snapshot**(通过 parentJobId 关联) |
|
||
| `sessionId` | `payload.sessionId` | **不进入 Snapshot**(与卡片生成无关) |
|
||
| `answerId` | `payload.answerId` | **不进入 Snapshot**(与卡片生成无关) |
|
||
| JWT / API Key | — | **禁止进入 Snapshot** |
|
||
|
||
### 1.4 卡片数量规则
|
||
|
||
**代码证据**:`src/modules/review/review-card.subscriber.ts:42`
|
||
|
||
```ts
|
||
cardCount: Math.min(3, Math.max(1, (a.weaknesses?.length || 1)))
|
||
```
|
||
|
||
- 无 weaknesses:1 张
|
||
- 1 个 weakness:1 张
|
||
- 2 个 weaknesses:2 张
|
||
- 3+ weaknesses:3 张
|
||
- **范围:[1, 3]**
|
||
|
||
与需求文档中的 `min(3, max(1, weaknesses.length))` **一致**(`|| 1` 处理空数组/undefined)。
|
||
|
||
### 1.5 ReviewCard 写入字段
|
||
|
||
| 字段 | 写入值 | 来源 |
|
||
|------|--------|------|
|
||
| `id` | `cuid()` 自动生成 | Prisma `@default(cuid())` — `schema.prisma:732` |
|
||
| `userId` | `input.userId` | `review.service.ts:83` |
|
||
| `knowledgeItemId` | `undefined` | **未设置**(`insertCard` 调用未传此字段 — `review.service.ts:82-94`) |
|
||
| `frontText` | `card.frontText` | AI 输出 — `review.service.ts:84` |
|
||
| `backText` | `card.backText` | AI 输出 — `review.service.ts:85` |
|
||
| `difficulty` | `card.difficulty` | AI 输出 — `review.service.ts:86` |
|
||
| `status` | `'active'` | 硬编码 — `review.service.ts:87` |
|
||
| `intervalDays` | `1` | 硬编码 — `review.service.ts:88` |
|
||
| `easeFactor` | `2.5` | 硬编码 — `review.service.ts:89` |
|
||
| `repetitionCount` | `0` | 硬编码 — `review.service.ts:90` |
|
||
| `lapseCount` | `0` | 硬编码 — `review.service.ts:91` |
|
||
| `scheduleState` | `'new'` | 硬编码 — `review.service.ts:92` |
|
||
| `nextReviewAt` | `new Date()` | 硬编码 — `review.service.ts:93` |
|
||
| `focusItemId` | — | **未设置** |
|
||
| `createdAt` | `now()` | Prisma `@default(now())` |
|
||
| `updatedAt` | `now()` | Prisma `@updatedAt` |
|
||
|
||
**★ ReviewCard 不支持显式 ID 赋值**:`schema.prisma:732` — `id String @id @default(cuid())`,无法通过 `create({ id: ... })` 显式指定。
|
||
|
||
### 1.6 幂等能力现状
|
||
|
||
| 检查项 | 结论 |
|
||
|--------|------|
|
||
| ReviewCard 表有 `jobId`? | **否** |
|
||
| ReviewCard 表有 `sourceId`? | **否** |
|
||
| ReviewCard 表有 `externalId`? | **否** |
|
||
| ReviewCard 表有 `dedupeKey`? | **否** |
|
||
| 允许 deterministic ID? | **否** — CUID auto-generated |
|
||
| 有可利用的唯一约束? | **否** — 仅 PK `id` |
|
||
| 重复 EventBus 当前生成重复卡片? | **是** — 无任何去重机制 |
|
||
|
||
### 1.7 Parent/Child 数据模型
|
||
|
||
**AiJob 表已支持**:
|
||
|
||
| 字段 | 类型 | 用途 | 证据 |
|
||
|------|------|------|------|
|
||
| `parentJobId` | `String? @db.VarChar(255)` | 指向父 Job | `schema.prisma:580` |
|
||
| `idempotencyKey` | `String? @db.VarChar(255)` | 幂等去重键 | `schema.prisma:582` |
|
||
| `children` | `AiJob[]` | 子 Job 列表(自引用) | `schema.prisma:619` |
|
||
| `@@unique([userId, jobType, idempotencyKey])` | | 幂等约束 | `schema.prisma:636` |
|
||
| `@@index([parentJobId])` | | 子 Job 查询索引 | `schema.prisma:627` |
|
||
|
||
**Snapshot 关联**:
|
||
- `AiJobSnapshot.jobId` 是 `@unique`,与 AiJob 一对一(`schema.prisma:648`)
|
||
- AiJob 侧无 `snapshotId` 字段,通过 `AiJobSnapshot.jobId` 反向查询
|
||
|
||
**删除策略**:
|
||
- `AiJobArtifact`:`onDelete: Cascade`(`schema.prisma:672`)
|
||
- `AiJobSnapshot`:`onDelete: Cascade`(`schema.prisma:658`)
|
||
- `OutboxEvent`:无 Cascade(独立生命周期)
|
||
|
||
### 1.8 事务创建能力
|
||
|
||
| 接口 | 支持外部 tx? | 证据 |
|
||
|------|--------------|------|
|
||
| `AiJobCreationService.createJob()` | **否** — 内部 `$transaction` | `ai-job-creation.service.ts:158` — `this.prisma.$transaction(createFn)` |
|
||
| `AiJobLifecycleRepository.createJob()` | **是** — 要求 `tx` 参数 | `ai-job-lifecycle.repository.ts:80` — `tx: Prisma.TransactionClient` |
|
||
| `AiJobLifecycleRepository.markSucceeded()` | **是** — 可选 `tx` 参数 | `ai-job-lifecycle.repository.ts:228` |
|
||
| `OutboxRepository.createInTransaction()` | **是** — 要求 `tx` 参数 | `outbox.repository.ts:37` |
|
||
| `AiJobSnapshot`(直接 Prisma) | **是** — 由 `AiJobCreationService` 直接调用 `tx.aiJobSnapshot.create()` | `ai-job-creation.service.ts:132` |
|
||
|
||
**关键结论**:`AiJobCreationService` 需要新增 `createInTransaction(tx, input)` 方法,复用内部逻辑但不自行开启事务。`AiJobLifecycleRepository.createJob()` 和 `OutboxRepository.createInTransaction()` 已具备外部事务能力。
|
||
|
||
### 1.9 失败语义(当前状态)
|
||
|
||
| 场景 | 旧 AiAnalysis Job 状态 | ReviewCard |
|
||
|------|----------------------|------------|
|
||
| Worker 崩溃前 publish 成功 | `completed` | 已生成 ✅ |
|
||
| Worker 崩溃前 publish 未执行 | `completed`(结果已写入) | **丢失** ❌ |
|
||
| EventBus.emit 执行中崩溃 | `completed` | **部分生成** ❌ |
|
||
| Subscriber 异常 | `completed` | **丢失** ❌ |
|
||
| 模型调用失败 | `completed` | **丢失** ❌ |
|
||
| 部分 insertCard 失败 | `completed` | **部分残留** ❌ |
|
||
| 重复 Worker 执行 | 旧 job 保持 completed | **重复卡片** ❌ |
|
||
|
||
---
|
||
|
||
## 2. 目标链路
|
||
|
||
### 2.1 目标时序(Child Job 路径)
|
||
|
||
```text
|
||
┌─ POST /api/ai-analysis/feynman ─────────────────────────────────────┐
|
||
│ FeynmanExecutionRouter.evaluateFeynman(userId, input) │
|
||
│ `src/modules/ai-analysis/feynman-execution-router.ts:70` │
|
||
│ ├─ FeatureFlag: FEYNMAN_REVIEW_CARD_MODE === 'child_job' ? │
|
||
│ ├─ [legacy_event] → AiAnalysisService.evaluateFeynman() │
|
||
│ └─ [child_job] → Unified 路径 ↓ │
|
||
└──────────────────────────┬──────────────────────────────────────────┘
|
||
│
|
||
▼
|
||
┌─ FeynmanExecutionRouter (Unified 路径) ─────────────────────────────┐
|
||
│ ├─ FeynmanSnapshotBuilder.build(input) ← 构建 Parent Snapshot │
|
||
│ └─ AiJobCreationService.createJob({...}) │
|
||
│ ★ 触发 Parent Job │
|
||
└──────────────────────────┬──────────────────────────────────────────┘
|
||
│ ai-interactive → Outbox Dispatcher → BullMQ
|
||
▼
|
||
┌─ Worker: AiJobExecutionEngine ──────────────────────────────────────┐
|
||
│ ├─ RESOLVE: FeynmanExecutor.execute(snapshot) ← AI 调用 │
|
||
│ ├─ VALIDATE: FeynmanBusinessValidator │
|
||
│ └─ PROJECT: FeynmanProjector.project(tx, ctx) │
|
||
│ ─── 同一 Prisma 事务内 ─── │
|
||
│ ├─ AiAnalysisResult (deterministic ID: fe_<jobId>) │
|
||
│ ├─ FocusItem (每 weakness 一个) │
|
||
│ ├─ Parent AiJobArtifact │
|
||
│ ├─ ★ ReviewCard Child Job(createInTransaction) │
|
||
│ ├─ ★ Child Snapshot │
|
||
│ ├─ ★ Child OutboxEvent │
|
||
│ └─ Parent Job → succeeded │
|
||
│ ─── 事务提交 ─── │
|
||
└──────────────────────────┬──────────────────────────────────────────┘
|
||
│ Outbox Dispatcher → BullMQ
|
||
│ Queue: ai-background
|
||
▼
|
||
┌─ Worker: AiJobExecutionEngine (Child Job) ──────────────────────────┐
|
||
│ ├─ RESOLVE: ReviewCardGenerationExecutor.execute(childSnapshot) │
|
||
│ │ ★ 通过 AiGateway,保持 cheap tier + review-card-generation prompt│
|
||
│ ├─ VALIDATE: ReviewCardGenerationValidator │
|
||
│ └─ PROJECT: ReviewCardGenerationProjector.project(tx, ctx) │
|
||
│ ─── 同一 Prisma 事务内 ─── │
|
||
│ ├─ ReviewCard × N (deterministic ID 或 DB 级幂等) │
|
||
│ ├─ AiJobArtifact × N │
|
||
│ └─ Child Job → succeeded │
|
||
│ ─── 事务提交 ─── │
|
||
└─────────────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
### 2.2 关键差异对比
|
||
|
||
| 维度 | Legacy EventBus | Child Job |
|
||
|------|----------------|-----------|
|
||
| 触发方式 | `EventEmitter2.emit()`(进程内) | OutboxEvent → BullMQ(持久化) |
|
||
| 事务保证 | 无(事件在事务外) | 与 Parent Projector 同一事务 |
|
||
| 失败隔离 | Subscriber 异常吞掉 | Child Job 独立 failed 状态 |
|
||
| 重试机制 | 无 | BullMQ retry + Admin 手动重试 |
|
||
| 卡片幂等 | 无 | deterministic ID 或 DB 级唯一约束 |
|
||
| 可观测性 | 仅日志 | AiJob 状态机 + Admin 查询 |
|
||
| 双执行防护 | 无 | Feature Flag 互斥 |
|
||
|
||
---
|
||
|
||
## 3. Parent/Child 状态机
|
||
|
||
### 3.1 Parent Job(Feynman)状态
|
||
|
||
```
|
||
pending → queued → running → succeeded
|
||
↓ (Child Job 通过 Child Outbox 异步执行)
|
||
★ Parent 不等待 Child 完成
|
||
★ Parent 成功与 Child 状态无关
|
||
```
|
||
|
||
### 3.2 Child Job(ReviewCard Generation)状态
|
||
|
||
```
|
||
queued → running → succeeded ← Child 正常完成
|
||
→ failed ← Child 异常(可独立重试)
|
||
→ cancelled ← Admin 取消
|
||
```
|
||
|
||
### 3.3 失败语义冻结
|
||
|
||
| 场景 | Parent 状态 | Child 状态 | ReviewCard | 说明 |
|
||
|------|------------|-----------|------------|------|
|
||
| Parent Projector 失败 | `failed` | 不存在 | 不存在 | Child 创建属于 Parent 事务,整体回滚 |
|
||
| Child 创建失败(事务内) | `failed`(回滚) | 不存在 | 不存在 | P2002 冲突或 DB 错误导致 Parent 事务失败 |
|
||
| Child Executor 失败 | `succeeded` | `failed` | 不存在 | **Parent 不回滚** |
|
||
| Child Projector 失败 | `succeeded` | `failed` | 不存在 | Child Projector 事务回滚,无部分卡片 |
|
||
| Child 重试成功 | `succeeded` | `succeeded` | 已生成 | Admin 手动重试或自动重试 |
|
||
| Child 永久失败 | `succeeded` | `failed` | 不存在 | 合法状态,需运维介入 |
|
||
|
||
**核心原则:Parent succeeded + Child failed 是合法最终状态。禁止因附属 ReviewCard 失败回滚已成功的 Feynman 评估。**
|
||
|
||
### 3.4 状态转换约束
|
||
|
||
- Child Job **不得**修改 Parent 的 `lifecycleStatus`、`finishedAt`
|
||
- Child Job **不得**修改 Parent 的 `validatedOutput`
|
||
- Parent Job 取消时,**应**级联取消未完成的 Child Job
|
||
- Child Job 重试**不得**重新创建 Parent 的 Result/FocusItem
|
||
|
||
---
|
||
|
||
## 4. Child Snapshot Schema
|
||
|
||
### 4.1 必须包含
|
||
|
||
```typescript
|
||
interface ReviewCardGenerationSnapshot {
|
||
/** 用户 ID(从 Parent Job 继承) */
|
||
userId: string;
|
||
/** 父 Job ID */
|
||
parentJobId: string;
|
||
/** 来源 Feynman 评估结果 ID(fe_<parentJobId>) */
|
||
sourceResultId: string;
|
||
/** 知识点 ID(从 Parent Snapshot 读取) */
|
||
knowledgeItemId: string;
|
||
/** 知识库 ID(从 Parent Snapshot 读取) */
|
||
knowledgeBaseId: string;
|
||
|
||
/** 评估摘要(来自 Feynman validatedOutput.summary,截断前 80 字符用于 title) */
|
||
title: string;
|
||
/** 拼接摘要 text(summary + strengths + weaknesses) */
|
||
summary: string;
|
||
/** 薄弱项列表 */
|
||
strengths: string[];
|
||
/** 掌握项列表 */
|
||
weaknesses: string[];
|
||
|
||
/** 目标卡片数量(规则:Math.min(3, Math.max(1, weaknesses.length || 1))) */
|
||
cardCount: number;
|
||
|
||
/** 冻结的 Prompt 配置 */
|
||
promptKey: 'review-card-generation';
|
||
promptVersion: '1.0.0';
|
||
|
||
/** 模型层级(冻结为 cheap) */
|
||
modelTier: 'cheap';
|
||
|
||
/** Schema 版本 */
|
||
inputSchemaVersion: string;
|
||
outputSchemaVersion: string;
|
||
}
|
||
```
|
||
|
||
### 4.2 禁止进入 Snapshot
|
||
|
||
- JWT / Authorization Header
|
||
- 模型 API Key / Provider Credential
|
||
- 数据库连接信息
|
||
- 完整用户画像(除 userId 外的 PII)
|
||
- 完整 Parent Job 数据库实体(仅引用 parentJobId)
|
||
- 原始 Parent 模型响应(仅引用 sourceResultId)
|
||
- 内部错误堆栈信息
|
||
- `sessionId`、`answerId`(与卡片生成无关)
|
||
|
||
### 4.3 稳定性要求
|
||
|
||
- 相同 `parentJobId` → 相同 `contentHash`
|
||
- `cardCount` 必须基于 `weaknesses.length` 确定计算,非随机
|
||
- `idempotencyKey` 必须稳定(不包含时间戳或随机值)
|
||
- `contentHash` = `sha256(JSON.stringify(snapshotContent)).substring(0, 16)` — 与现有 `AiJobCreationService.computeHash()` 一致
|
||
|
||
---
|
||
|
||
## 5. Child Output Schema
|
||
|
||
### 5.1 AI 输出 Schema(冻结)
|
||
|
||
```typescript
|
||
// 与现有 ReviewCardGenerationResultSchema 完全一致
|
||
// src/modules/ai/prompts/schemas/review-card-generation.schema.ts
|
||
|
||
{
|
||
cards: Array<{
|
||
frontText: string; // 1-500 字符
|
||
backText: string; // 1-1000 字符
|
||
difficulty: 'easy' | 'medium' | 'hard';
|
||
tags: string[]; // 每标签 max 50 字符,最多 5 个
|
||
}>;
|
||
totalCount: number; // 1-20
|
||
}
|
||
```
|
||
|
||
### 5.2 验证规则
|
||
|
||
| 规则 | 严重程度 | 违规处理 |
|
||
|------|---------|---------|
|
||
| `cards.length > 0` | P0 | 拒绝,Job failed |
|
||
| `cards.length <= 20` | P0 | 拒绝,Job failed |
|
||
| `cards.length` 与 `totalCount` 一致 | P1 | 拒绝,Job failed |
|
||
| `cards.length` 与 `cardCount` 一致或在 `[cardCount-1, cardCount+1]` 允许范围 | P2 | 超出范围 → 裁剪或拒绝 |
|
||
| `frontText` 非空且 ≤ 500 | P0 | 拒绝该卡片 |
|
||
| `backText` 非空且 ≤ 1000 | P0 | 拒绝该卡片 |
|
||
| `difficulty` ∈ `{easy, medium, hard}` | P1 | 默认 `medium` |
|
||
| 禁止空卡片(frontText 和 backText 都为空) | P0 | 拒绝 |
|
||
| 禁止完全重复卡片(frontText + backText 相同) | P1 | 去重 |
|
||
| 禁止模型指令文本(如"请分析…"、"根据上文…") | P0 | 拒绝 |
|
||
| 禁止 Markdown 代码块标记(如 "```json") | P0 | 拒绝 |
|
||
| 禁止超大文本(frontText > 2000 或 backText > 5000) | P0 | 拒绝 |
|
||
|
||
### 5.3 Business Validator
|
||
|
||
新增 `ReviewCardGenerationValidator`:
|
||
- 实现 `BusinessValidator` 接口
|
||
- 不得重新设计卡片质量算法
|
||
- 仅执行上述规则的结构化验证
|
||
|
||
---
|
||
|
||
## 6. 幂等契约
|
||
|
||
### 6.1 Child Job 幂等
|
||
|
||
```text
|
||
idempotencyKey = "review-card:feynman:<parentJobId>"
|
||
```
|
||
|
||
- 唯一约束:`AiJob.@@unique([userId, jobType, idempotencyKey])`
|
||
- 一个 Parent Job 最多一个 ReviewCard Child Job
|
||
- 重复执行 Parent Projector 时:P2002 catch → 返回已有 Child Job
|
||
- 不创建第二个 Child Snapshot
|
||
- 不创建第二个 Child Outbox
|
||
|
||
### 6.2 ReviewCard 幂等
|
||
|
||
**关键约束**:ReviewCard 的 `id` 使用 `@default(cuid())` 不允许显式赋值。
|
||
|
||
**方案 A(推荐)**:在 `ReviewCard` 表上新增 `@@unique([childJobId, ordinal])`。
|
||
|
||
需要 M-AI-06-02 中通过 Prisma Migration 新增:
|
||
```prisma
|
||
model ReviewCard {
|
||
// ... existing fields ...
|
||
childJobId String? @db.VarChar(255) // 新增
|
||
ordinal Int @default(0) // 新增
|
||
|
||
@@unique([childJobId, ordinal]) // 新增
|
||
}
|
||
```
|
||
|
||
Child Projector 写入时:
|
||
```ts
|
||
for (const [idx, card] of result.cards.entries()) {
|
||
try {
|
||
await tx.reviewCard.create({
|
||
data: {
|
||
childJobId: job.id,
|
||
ordinal: idx,
|
||
// ... other fields
|
||
},
|
||
});
|
||
} catch (err) {
|
||
if (err?.code === 'P2002') continue; // 幂等跳过
|
||
throw err;
|
||
}
|
||
}
|
||
```
|
||
|
||
**方案 B(备选)**:使用 `AiJobArtifact` 唯一约束间接保证。
|
||
|
||
利用现有 `@@unique([jobId, artifactType, artifactId])`:
|
||
```ts
|
||
await upsertArtifact(tx, childJobId, 'ReviewCard', reviewCardId, ordinal);
|
||
```
|
||
但 `reviewCardId` 无法预测(CUID 自动生成),无法作为稳定的 `artifactId`。
|
||
|
||
**★ 最终选择需在 M-AI-06-05 实现时确定,取决于是否允许新增 Migration。**
|
||
|
||
### 6.3 禁止的幂等方案
|
||
|
||
- ❌ `findFirst({ frontText }) → create` — 不同卡片可能相同题面,并发不安全
|
||
- ❌ 全局 frontText 去重 — 违反业务语义(同一用户可有多张同题面卡片)
|
||
- ❌ 仅在内存中去重 — 并发不安全
|
||
- ❌ 依赖 `insertCard` 返回的 CUID 做幂等 — 每次调用生成不同 ID
|
||
|
||
---
|
||
|
||
## 7. Feature Flag
|
||
|
||
### 7.1 定义
|
||
|
||
```text
|
||
Flag Name: FEYNMAN_REVIEW_CARD_MODE
|
||
Values: legacy_event | child_job
|
||
Default: legacy_event
|
||
```
|
||
|
||
### 7.2 行为
|
||
|
||
| 模式 | Feynman Parent 完成后 | ReviewCard 生成 |
|
||
|------|----------------------|----------------|
|
||
| `legacy_event` | `eventBus.publish('ai.analysis.completed')` | `ReviewCardSubscriber` → 二次 AI 调用 |
|
||
| `child_job` | 事务内创建 `review_card_generation` Child Job | Child Job Worker → 二次 AI 调用 |
|
||
|
||
### 7.3 互斥要求
|
||
|
||
- `child_job` 模式下:**不发布** `ai.analysis.completed`(Legacy 事件),或事件携带 `reviewCardMode: 'child_job'` 标识使 `ReviewCardSubscriber` 忽略
|
||
- 同一个 Parent Job 只能选择一种 ReviewCard 生成方式
|
||
- 切回 `legacy_event`:新 Parent 使用旧事件路径;已创建的 Child Job 继续运行;不删除已生成 ReviewCard
|
||
- `legacy_event` 模式用于**回滚**,确保可快速切回旧行为
|
||
|
||
---
|
||
|
||
## 8. 失败隔离
|
||
|
||
### 8.1 隔离边界
|
||
|
||
```text
|
||
┌── Parent Transaction ──────────────────────┐
|
||
│ FeynmanResult + FocusItem + ParentArtifact │
|
||
│ + Child Job + Child Snapshot + Child Outbox│
|
||
│ → COMMIT │
|
||
│ 失败 → 全部 ROLLBACK │
|
||
└─────────────────────────────────────────────┘
|
||
│
|
||
│ Outbox → BullMQ → Worker
|
||
▼
|
||
┌── Child Transaction ───────────────────────┐
|
||
│ ReviewCard × N + Artifact × N │
|
||
│ → COMMIT │
|
||
│ 失败 → 全部 ROLLBACK(Parent 不受影响) │
|
||
└─────────────────────────────────────────────┘
|
||
```
|
||
|
||
### 8.2 恢复操作
|
||
|
||
| 场景 | 操作 |
|
||
|------|------|
|
||
| Child Job `failed` | Admin 重试 Child Job(`AiJobService.retryJob`) |
|
||
| Child Job 永久失败 | 运维介入,手动检查日志 |
|
||
| Parent Projector 重新执行 | 返回已有 Child Job(幂等),不重复创建 |
|
||
| Child Worker 崩溃 | BullMQ 自动重试(根据 Definition.maxRetries) |
|
||
|
||
---
|
||
|
||
## 9. 回滚流程
|
||
|
||
### 9.1 child_job → legacy_event
|
||
|
||
```text
|
||
1. 修改 FeatureFlag: FEYNMAN_REVIEW_CARD_MODE = legacy_event
|
||
2. 生效后:
|
||
- 新 Feynman 请求走 Legacy EventBus
|
||
- ReviewCardSubscriber 正常处理 ai.analysis.completed
|
||
- 已创建的 Child Job 继续完成
|
||
- 已生成 ReviewCard 保留
|
||
3. 无需数据库回滚
|
||
4. 无需删除 Child Job
|
||
```
|
||
|
||
### 9.2 legacy_event → child_job(灰度上线)
|
||
|
||
```text
|
||
1. 部署包含 Child Job 支持的代码
|
||
2. 对测试用户/白名单开启 FEYNMAN_REVIEW_CARD_MODE = child_job
|
||
3. 监控 Child Job 成功率、卡片生成量
|
||
4. 逐步扩大白名单
|
||
5. 全量切换为 child_job
|
||
```
|
||
|
||
---
|
||
|
||
## 10. 实现约束与不确定项
|
||
|
||
### 10.1 确定项
|
||
|
||
| 项 | 结论 |
|
||
|----|------|
|
||
| EventBus 类型 | 进程内 EventEmitter2,不做持久化 |
|
||
| promptKey | `review-card-generation` |
|
||
| promptVersion | `1.0.0` |
|
||
| modelTier | `cheap` |
|
||
| queueName | `ai-background` |
|
||
| triggerType | `child_job` |
|
||
| cardCount 规则 | `Math.min(3, Math.max(1, weaknesses.length \|\| 1))` |
|
||
| ReviewCard 默认 SM-2 值 | status=active, interval=1, easeFactor=2.5, rep=0, lapse=0, state=new, next=now |
|
||
| Parent Projector 不创建 ReviewCard | 确认(`feynman-projector.ts:178-180`) |
|
||
| Child Executor 不写数据库 | 与现有 Executor 模式一致 |
|
||
| Child Projector 在事务内 | 与现有 Projector 模式一致 |
|
||
|
||
### 10.2 不确定项(需 M-AI-06-02 ~ M-AI-06-05 实现时解决)
|
||
|
||
| 项 | 问题 | 建议 |
|
||
|----|------|------|
|
||
| ReviewCard 幂等方案 | CUID 不支持显式 ID,需选择方案 A 或 B | 优先方案 A(新增 `@@unique([childJobId, ordinal])`),需评估 Migration 风险 |
|
||
| `createInTransaction` 实现 | `AiJobCreationService` 需新增事务方法 | 复用现有 `createJob` 内部逻辑,提取可注入 `tx` 的核心方法 |
|
||
| P2002 后安全读取 | Prisma/MySQL 事务内 P2002 后能否安全读取已存在的 Job | 需要集成测试验证(MySQL InnoDB 行为) |
|
||
| Legacy Subscriber 忽略 child_job | 是否修改 `ReviewCardSubscriber` 检查 Flag | 建议 Subscriber 也检查 FeatureFlag,双保险 |
|
||
| `knowledgeItemId` 在 Child Snapshot | 当前 Legacy 路径未设置 `knowledgeItemId` | 从 Parent Snapshot 读取(FeynmanProjector 已有此逻辑) |
|
||
|
||
### 10.3 非目标(明确不做)
|
||
|
||
- ❌ Quiz 生成 / 迁移
|
||
- ❌ Active Recall 的 ReviewCard 重构
|
||
- ❌ 复习算法(SM-2)升级
|
||
- ❌ EventBus 平台整体重构
|
||
- ❌ 通用消息总线建设
|
||
- ❌ ReviewCard 表大规模重构(除幂等所需最小字段)
|
||
- ❌ 删除 `ReviewCardSubscriber` / `AiAnalysisWorker` / 旧 `ReviewCardGenerationWorkflow`
|
||
- ❌ Heavy Runtime 清理
|
||
- ❌ 压力测试
|
||
- ❌ Worker SIGKILL 全场景硬化
|
||
|
||
---
|
||
|
||
## 附录 A:关键文件索引
|
||
|
||
| 用途 | 绝对路径 | 关键行号 |
|
||
|------|---------|---------|
|
||
| EventBus 服务 | `src/common/event-bus/event-bus.service.ts` | `:19` publish, `:26` publishAsync |
|
||
| EventBus 模块 | `src/common/event-bus/event-bus.module.ts` | `:1` @Global() |
|
||
| AiAnalysisWorker | `src/workers/ai-analysis.worker.ts` | `:72` publish, `:52-57` feynman execute |
|
||
| ReviewCardSubscriber | `src/modules/review/review-card.subscriber.ts` | `:12` handle, `:42` cardCount |
|
||
| ReviewService | `src/modules/review/review.service.ts` | `:68` generateCards, `:82-94` insertCard |
|
||
| ReviewRepository | `src/modules/review/review.repository.ts` | `:24-37` insertCard |
|
||
| ReviewCardGenerationWorkflow | `src/modules/ai/workflows/review-card-generation.workflow.ts` | `:17` execute, `:30-40` gateway call |
|
||
| ReviewCard Prompt | `src/modules/ai/prompts/review-card-generation.prompt.ts` | `:1` system prompt |
|
||
| ReviewCard Output Schema | `src/modules/ai/prompts/schemas/review-card-generation.schema.ts` | `:3-13` schema |
|
||
| Prompt Template Service | `src/modules/ai/prompts/prompt-template.service.ts` | `:46-49` review-card registration |
|
||
| FeynmanProjector | `src/modules/ai-job/feynman-projector.ts` | `:38` project, `:178-180` ReviewCard NOT in projector |
|
||
| FeynmanExecutor | `src/modules/ai-job/feynman-executor.ts` | `:44` execute |
|
||
| Feynman Definition | `src/modules/ai-job/feynman-job-definition.ts` | `:18` Definition, `:59` modelTier=primary |
|
||
| Feynman Execution Router | `src/modules/ai-analysis/feynman-execution-router.ts` | `:70` evaluateFeynman, `:124` createJob |
|
||
| AiJobCreationService | `src/modules/ai-job/ai-job-creation.service.ts` | `:67` createJob, `:158` $transaction, `:164-183` P2002 |
|
||
| AiJobLifecycleRepository | `src/modules/ai-job/ai-job-lifecycle.repository.ts` | `:80` createJob, `:228` markSucceeded |
|
||
| OutboxRepository | `src/infrastructure/outbox/outbox.repository.ts` | `:37` createInTransaction |
|
||
| ActiveRecallProjector | `src/modules/ai-job/active-recall-projector.ts` | `:148-192` ReviewCard creation pattern |
|
||
| ReviewCard Prisma Model | `prisma/schema.prisma` | `:731-757` |
|
||
| AiJob Prisma Model | `prisma/schema.prisma` | `:568-639` |
|
||
| AiJobSnapshot Model | `prisma/schema.prisma` | `:643-659` |
|
||
| AiJobArtifact Model | `prisma/schema.prisma` | `:663-677` |
|
||
| OutboxEvent Model | `prisma/schema.prisma` | `:2323-2344` |
|
||
|
||
## 附录 B:Prompt 兼容性确认
|
||
|
||
| 属性 | 旧 Workflow 值 | Child Executor 要求 |
|
||
|------|---------------|-------------------|
|
||
| promptKey | `'review-card-generation'` | **相同** |
|
||
| promptVersion | `'1.0.0'` | **相同** |
|
||
| systemPrompt | `REVIEW_CARD_GENERATION_SYSTEM_PROMPT` | **相同**(来自 PromptTemplateService) |
|
||
| userMessage 格式 | 知识点标题 + 内容 + cardCount 指示 | **相同** |
|
||
| model tier | `'cheap'` | **相同** |
|
||
| outputSchema | `ReviewCardGenerationResultSchema` | **相同** |
|
||
| temperature | 默认(AiGateway 内部) | **相同** |
|
||
| maxTokens | 默认(AiGateway 内部) | **相同** |
|