# M-AI-05 Feynman 与复习产物迁移契约

> 审计日期：2026-06-21
> 审计人：开发执行代理（只读审计，未修改代码）
> 基线：M-AI-04 GATE CONDITIONAL PASS (`a5ad0bc`)
> 输出：本文档冻结 Feynman 迁移的全部契约

---

## 1. 当前时序图（Legacy 链路）

```
Client
  │
  │  POST /api/ai-analysis/feynman
  │  Body: { knowledgeItemTitle, knowledgeItemContent, userExplanation, sessionId?, answerId? }
  │  Auth: JWT Bearer
  ▼
AiAnalysisController.evaluateFeynman()          [ai-analysis.controller.ts:32]
  │  @AiAnalysisRateLimit()
  │  body 无 DTO class — 直接解构 @Body()
  │
  ▼
AiAnalysisService.evaluateFeynman()             [ai-analysis.service.ts:33]
  │
  ├─► AiAnalysisRepository.createJob()           [ai-analysis.repository.ts:17]
  │     INSERT INTO AiAnalysisJob (
  │       userId, jobType='feynman-evaluation',
  │       status='pending', lifecycleStatus='queued',
  │       queueName='ai-interactive', inputSchemaVersion='legacy-v1',
  │       attemptCount=0, queuedAt=now()
  │     )
  │     RETURNING job.id
  │
  ├─► QueueService.add('ai-analysis', {          [queue.service.ts:47]
  │     jobId, userId,
  │     type: 'feynman-evaluation',
  │     knowledgeItemTitle,
  │     knowledgeItemContent,
  │     userExplanation
  │   })
  │   → BullMQ Queue: 'ai-analysis'
  │   → taskLog INSERT (status='enqueued')
  │
  └─► return { jobId: job.id, status: 'queued' }
```

```
BullMQ 'ai-analysis' Queue
  │  concurrency: 1, lockDuration: 30000ms
  │  attempts: 3, backoff: exponential 1s
  │  timeoutMs: 180_000
  ▼
AiAnalysisWorker.process()                       [ai-analysis.worker.ts:32]
  │
  ├─► repository.updateJobStatus(jobId, 'processing')   [line 48]
  │     status='processing', lifecycleStatus='running', startedAt=now()
  │
  ├─► [type === 'feynman-evaluation']
  │   FeynmanEvaluationWorkflow.execute()         [feynman-evaluation.workflow.ts:17]
  │   │
  │   │  构建 userMessage：
  │   │    【知识点标题】+ title
  │   │    【知识点原文】+ content
  │   │    【用户的费曼解释】+ userExplanation
  │   │    请评估以上费曼解释的质量，严格按照 JSON Schema 输出。
  │   │
  │   ├─► AiGatewayService.generate()             [ai-gateway.service.ts:40]
  │   │   │  feature: 'feynman-evaluation'
  │   │   │  tier: 'primary'
  │   │   │  promptKey: 'feynman-evaluation', version: '1.0.0'
  │   │   │  outputSchema: FeynmanEvaluationResultSchema
  │   │   │
  │   │   ├─► ModelRouter.resolve('primary')
  │   │   │   → preferred: deepseek, fallback: deepseek
  │   │   │   → model: deepseek-v4-pro, maxRetries: 3
  │   │   │
  │   │   ├─► PromptTemplateService.get('feynman-evaluation')
  │   │   │   → systemPrompt: FEYNMAN_EVALUATION_SYSTEM_PROMPT
  │   │   │   → schema description appended
  │   │   │
  │   │   ├─► Provider.generate()
  │   │   │   → HTTP POST to DeepSeek API
  │   │   │
  │   │   ├─► parseJson(rawText, FeynmanEvaluationResultSchema)
  │   │   │   → JSON Repair (可配置)
  │   │   │   → Zod validation
  │   │   │
  │   │   └─► usageLog.log() — cost/usage tracking
  │   │
  │   └─► return response.parsed as FeynmanEvaluationResult
  │
  ├─► repository.createResult(userId, jobId, result)     [line 67]
  │     INSERT INTO AiAnalysisResult (
  │       userId, jobId,
  │       summary=result.summary,
  │       masteryScore=result.score,
  │       strengths=result.strengths (JSON),
  │       weaknesses=result.weaknesses (JSON),
  │       suggestions=result.focusItems ?? result.suggestions (JSON),
  │       nextActions=result.reviewSuggestion ?? result.recommendations (JSON),
  │       rawResult=result (JSON)
  │     )
  │
  ├─► repository.updateJobStatus(jobId, 'completed')     [line 68]
  │     status='completed', lifecycleStatus='succeeded', finishedAt=now()
  │
  ├─► eventBus.publish(AIAnalysisCompleted)              [line 72]
  │   │  eventType: 'ai.analysis.completed'
  │   │  payload: { userId, jobId, sessionId, answerId, type, score, analysis, timestamp }
  │   │
  │   └─► [ASYNC SUBSCRIBER]
  │       ReviewCardSubscriber.handleAIAnalysisCompleted()  [review-card.subscriber.ts:12]
  │       │
  │       │  构造 title = summary.slice(0,80)
  │       │  构造 content = "摘要：...\n\n掌握点：...\n\n薄弱点：..."
  │       │  cardCount = min(3, max(1, weaknesses.length))
  │       │
  │       └─► ReviewService.generateCards()                 [review.service.ts:68]
  │           │
  │           └─► ReviewCardGenerationWorkflow.execute()    [review-card-generation.workflow.ts]
  │               │  ★ 二次 AI 调用 — feature: 'review-card-generation'
  │               │  ★ tier: 'cheap' (deepseek-v4-flash)
  │               │  outputSchema: ReviewCardGenerationSchema
  │               │
  │               └─► ReviewRepository.insertCard()  (× cardCount)
  │                     INSERT INTO ReviewCard (
  │                       userId, frontText, backText, difficulty, status='active',
  │                       intervalDays=1, easeFactor=2.5, repetitionCount=0,
  │                       lapseCount=0, scheduleState='new', nextReviewAt=now()
  │                     )
  │
  └─► FocusItemsService.create()                          [line 88]
      │  ★ for each weakness string in result.weaknesses
      │
      └─► FocusItemsRepository.create()             [focus-items.repository.ts:23]
            INSERT INTO FocusItem (
              userId, title=weaknessString,
              reason='', suggestion='', priority='normal',
              status='open', source='ai-analysis',
              knowledgeBaseId=result.knowledgeBaseId || 'unknown'
            )
            ★ result.knowledgeBaseId 在 Feynman Schema 中不存在 → 永远为 'unknown'
```

---

## 2. 目标时序图（Unified 链路）

```
Client
  │
  │  POST /api/ai-analysis/feynman  (不变)
  │  Body: 不变
  │  Auth: JWT Bearer (不变)
  ▼
AiAnalysisController.evaluateFeynman()          [不变]
  │
  ▼
FeynmanExecutionRouter                         [新增]
  │
  ├─► FEYNMAN_ENGINE_MODE=legacy → 原 AiAnalysisService (不变)
  │
  └─► FEYNMAN_ENGINE_MODE=unified
      │
      ├─► 原有请求校验（权限、必填字段）
      ├─► 确定 submissionId → 构造 idempotencyKey = feynman:<submissionId>
      ├─► FeynmanSnapshotBuilder.build()
      │     → 加载知识点、用户解释、参考材料
      │     → 脱敏
      │     → 计算 contentHash
      │
      └─► AiJobCreationService.create({
            userId, jobType='feynman_evaluation',
            triggerType='user_api',
            targetType='knowledge_item', targetId=knowledgeItemId,
            idempotencyKey
          })
          │
          │  ★ 同一 Prisma Transaction:
          │    1. AiJob (lifecycleStatus='queued')
          │    2. AiJobSnapshot (snapshotContent, contentHash)
          │    3. OutboxEvent (eventType='ai.job.enqueue', payload={jobId})
          │
          └─► return { jobId, status: 'queued', engineMode: 'unified' }
```

```
Outbox Dispatcher
  │
  ▼
BullMQ Queue: 'ai-interactive'
  │  payload: { jobId }  ← 极简
  ▼
AiJobExecutionEngine
  │
  ├─► lockJob (CAS: queued → running)
  ├─► load Definition (feynman_evaluation)
  ├─► load Snapshot
  │
  ├─► FeynmanExecutor.execute(snapshot, signal)
  │   │
  │   ├─► 从 Snapshot + Definition 构造消息
  │   ├─► AiGatewayService.generate()
  │   │     feature, promptKey, promptVersion, modelTier → 全部来自 Definition
  │   │
  │   └─► return rawOutput
  │
  ├─► BusinessValidator.validate(rawOutput)
  │   ★ JSON Repair → Schema Validate → Business Rules
  │
  ├─► ReferenceValidator.validate(validatedOutput, snapshot)
  │
  └─► FeynmanProjector.project(tx, { job, snapshot, validatedOutput })
      │
      │  ★ 同一 Prisma Transaction:
      │    1. AiAnalysisResult (upsert by deterministic ID)
      │    2. FocusItem (按契约创建，不超过 N 个)
      │    3. ReviewCard (按契约创建，不二次调用 AI)
      │    4. AiJobArtifact (×3: analysis_result, focus_item, review_card)
      │    5. validatedOutput + outputHash
      │    6. Job → succeeded + finishedAt
      │
      └─► 任何步骤失败 → 全部回滚
```

---

## 3. Snapshot Schema（冻结）

### 3.1 进入 Snapshot 的字段

| 字段 | 来源 | 说明 |
|------|------|------|
| `userId` | JWT sub | 评估者标识 |
| `knowledgeItemId` | 请求体 / 路由参数 | 知识点 ID |
| `knowledgeItemTitle` | 请求体 `knowledgeItemTitle` | 知识点标题 |
| `knowledgeItemContent` | 请求体 `knowledgeItemContent` | 知识点原文 |
| `userExplanation` | 请求体 `userExplanation` | 用户费曼解释 |
| `referenceMaterials` | 从 DB 加载 | 关联参考材料摘要（非全文） |
| `knowledgeBaseId` | 从 knowledgeItem 推导 | 知识库归属 |
| `submissionId` | 请求体或生成 | 稳定业务标识（幂等） |
| `promptKey` | Definition | `feynman-evaluation` |
| `promptVersion` | Definition | `1.0.0` |
| `modelTier` | Definition | `primary` |
| `inputSchemaVersion` | Definition | `1.0.0` |
| `outputSchemaVersion` | Definition | `1.0.0` |
| `createdAt` | 创建时间（归一化） | ISO 8601，截断到秒 |

### 3.2 执行时查询的字段

| 字段 | 说明 |
|------|------|
| 系统 Prompt 全文 | 从 PromptTemplateService 实时获取 |
| 模型凭据 | 从 CredentialService 实时解密 |
| Provider 配置 | 从 ModelRouter 实时解析 |

### 3.3 禁止进入 Snapshot 的字段

| 字段 | 原因 |
|------|------|
| JWT / Authorization Header | 敏感凭据 |
| Cookie | 敏感凭据 |
| 明文模型 API Key | 敏感凭据 |
| DATABASE_URL | 基础设施密钥 |
| REDIS_URL | 基础设施密钥 |
| 完整用户画像 | 不必要 |
| 整个知识库序列化 | 不必要，应只取必要字段 |
| 每次生成时间戳 | 破坏 contentHash 稳定性 |

### 3.4 contentHash 规范化规则

相同业务输入 → 相同 contentHash。规范化：

1. 字段按字母序排序
2. `null` 与缺省字段等价（不写入 null 字段）
3. 时间字段归一化（截断到秒）
4. 字符串首尾去空白（trim）
5. 数组按业务 key 排序（如有），否则按原始顺序
6. JSON 使用紧凑格式（无美化空格）

---

## 4. Output Schema（冻结）

### 4.1 当前 Feynman 输出 Schema

源文件：`src/modules/ai/prompts/schemas/feynman-evaluation.schema.ts:3-14`

```typescript
FeynmanEvaluationResultSchema = z.object({
  score:              z.number().int().min(0).max(100),
  clarityLevel:       z.enum(['crystal_clear','clear','mostly_clear','confusing','very_confusing']),
  summary:            z.string().min(1).max(2000),
  strengths:          z.array(z.string().max(500)).max(10).default([]),
  weaknesses:         z.array(z.string().max(500)).max(10).default([]),
  blindSpots:         z.array(z.string().max(500)).max(10).default([]),
  suggestions:        z.array(z.string().max(500)).max(10).default([]),
  isBeginnerFriendly: z.boolean(),
  analogyQuality:     z.enum(['excellent','good','acceptable','poor','none']).optional(),
  jargonUsage:        z.enum(['none','minimal','moderate','heavy']),
})
```

### 4.2 已确认被业务消费的字段

| 字段 | 消费位置 | 用途 |
|------|---------|------|
| `score` | `ai-analysis.repository.ts:61` | → `masteryScore` |
| `summary` | `ai-analysis.repository.ts:60` | → `summary`；同时被 ReviewCardSubscriber 用于卡片标题 |
| `strengths` | `ai-analysis.repository.ts:62` | → `strengths` (JSON)；被 ReviewCardSubscriber 拼入卡片内容 |
| `weaknesses` | `ai-analysis.worker.ts:85-96` | 每个字符串创建一个 FocusItem (title=w)；被 ReviewCardSubscriber 拼入卡片内容 |
| `suggestions` | `ai-analysis.repository.ts:64` | → `suggestions` (JSON)，路径为 `result.focusItems ?? result.suggestions` |
| `blindSpots` | — | Schema 中有，但未在业务代码中找到消费位置 |
| `clarityLevel` | — | Schema 中有，但未在业务代码中找到消费位置 |
| `isBeginnerFriendly` | — | Schema 中有，但未在业务代码中找到消费位置 |
| `analogyQuality` | — | Schema 中有，但未在业务代码中找到消费位置 |
| `jargonUsage` | — | Schema 中有，但未在业务代码中找到消费位置 |

### 4.3 验证规则

#### Schema 验证（Zod 层）

- `score`：0-100 整数，越界拒绝
- `clarityLevel`：必须在枚举值内
- `summary`：1-2000 字符，空字符串拒绝
- `strengths/weaknesses/blindSpots/suggestions`：每项 ≤500 字符，数组 ≤10 项
- `isBeginnerFriendly`：必须是 boolean
- `jargonUsage`：必须在枚举值内

#### Business Validator（新增）

- `score` 在 0-100 范围内
- `summary` 非空且非纯空格
- `strengths` 和 `weaknesses` 不能同时为空
- 禁止空对象 `{}` 冒充成功
- 禁止异常大文本（单项 > 500 字符）
- 禁止模型指令或代码块进入结构化字段
- 禁止 JSON 中包含 ````json` 等 markdown 包装

#### Reference Validator（新增）

- 当前 Feynman 输出不包含引用字段 → 无需实现 Reference Validator
- 如果后续 Schema 增加了 `sourceReferences`，则必须验证

---

## 5. 副作用矩阵（冻结）

| 副作用 | 创建条件 | 数量 | 唯一性 | 失败策略 | 当前实现位置 |
|--------|---------|------|--------|---------|-------------|
| AiAnalysisResult | 每次 Feynman 评估 | 1 | jobId 唯一（1:1） | 抛错 → Worker catch → mark failed | `ai-analysis.worker.ts:67` |
| FocusItem | `result.weaknesses.length > 0` | 每个 weakness 字符串 1 个（最多 10） | 无去重 — 每次创建新的 | 单个失败被 catch 吞掉 | `ai-analysis.worker.ts:88` |
| ReviewCard | EventBus 触发 + strengths/weaknesses 非空 | `min(3, max(1, weaknesses.length))` 张 | 无去重 — 每次创建新的 | 整个 subscriber catch 吞掉 | `review-card.subscriber.ts:39` |
| UsageLog | Provider 每次调用 | 1（加 retry） | — | AiGateway 内部处理 | `ai-gateway.service.ts` |
| ReviewLog | 用户提交复习 | 1 per submission | — | 不在 Feynman 链路内 | `review.service.ts:57` |
| 学习统计 | 间接（通过 FocusItem/ReviewCard） | 不确定 | — | 不在 Feynman 链路内 | — |
| 通知 | 无 | 0 | — | — | — |
| EventBus | 每次 AI 分析完成 | 1 个 `ai.analysis.completed` 事件 | — | catch 吞掉 | `ai-analysis.worker.ts:72` |

### 关键发现

1. **FocusItem 的 `knowledgeBaseId` 永远为 `'unknown'`**：`result.knowledgeBaseId` 不在 Feynman Schema 中，而 worker 代码 `ai-analysis.worker.ts:89` 使用 `result.knowledgeBaseId || 'unknown'`，因此该字段始终为 `'unknown'`。
2. **FocusItem 无 `reason`/`suggestion`/`priority`**：Worker 只传 `title=weaknessString`，其余字段为默认值（reason=''、suggestion=''、priority='normal'）。
3. **ReviewCard 创建通过二次 AI 调用**：不是从 Feynman 结果直接映射，而是调用独立的 `review-card-generation` workflow（`tier: 'cheap'`）。
4. **无事务保证**：result、FocusItem、ReviewCard 分别在独立操作中写入，无原子性。

---

## 6. Artifact 矩阵（冻结）

| Artifact Type | 对应实体 | 创建时机 | 数量 | ID 格式 |
|---------------|---------|---------|------|---------|
| `AiAnalysisResult` | AiAnalysisResult (analysis_result) | Projector — Result 写入后 | 1 | `ar_<jobId前24字符>` |
| `FocusItem` | FocusItem (focus_item) | Projector — 每个 FocusItem 创建后 | 0-N | 数据库自增 cuid |
| `ReviewCard` | ReviewCard (review_card) | Projector — ReviewCard 创建后 | 0-1 | 数据库自增 cuid |

注：旧链路不创建 Artifact。这是 Unified 链路的产物。

---

## 7. 幂等契约（冻结）

### 7.1 幂等键

```
格式：feynman:<submissionId>
```

其中 `submissionId` 由业务方传入或由以下字段组合派生：
- `userId`
- `knowledgeItemId`
- `userExplanation` 的前 N 个字符（hash）

建议优先使用客户端传入的稳定标识（如 `sessionId`、`answerId` 组合）。

### 7.2 幂等语义

| 场景 | 预期行为 |
|------|---------|
| 相同 submissionId 重复请求 | 返回同一个 Job（不创建新 Job/Snapshot/Outbox） |
| 用户重新提交新解释 | 新 submissionId → 新 Job（不覆盖旧 Job） |
| 相同 Job 重复消费（Worker crash 重试） | Projector 幂等 — 结果不重复 |
| 并发提交相同 submissionId | DB 唯一约束保证只有一个成功 |

### 7.3 禁止作为幂等键

- 时间戳（每次不同）
- 随机值（每次不同）
- JWT（过期后变化）
- 用户解释全文（Hash 可以，全文不行 — 太大）

---

## 8. 状态映射（冻结）

### 8.1 Legacy 状态

旧链路使用的状态字符串（来源于 `ai-analysis.repository.ts:10-15`）：

| status (旧) | lifecycleStatus (新 Shadow Write) | 说明 |
|-------------|----------------------------------|------|
| `pending` | `queued` | 已入队，等待 Worker 拾取 |
| `processing` | `running` | Worker 正在处理 |
| `completed` | `succeeded` | 成功完成 |
| `failed` | `failed` | 失败（含 errorMessage） |

### 8.2 Unified 状态

| lifecycleStatus | 旧 status (兼容) | 说明 |
|-----------------|-----------------|------|
| `queued` | `pending` | Outbox 已创建，等待 Dispatcher |
| `running` | `processing` | Engine 已拾取并开始执行 |
| `succeeded` | `completed` | Projector 成功 |
| `failed` | `failed` | Executor/Validator/Projector 失败 |
| `cancelled` | `failed` | Admin/用户取消了 Job |

### 8.3 公开状态查询

旧接口 `GET /api/ai-analysis/:id/status` 返回 `status` 字段。Unified Job 必须映射后返回，不得直接返回 `lifecycleStatus`。

---

## 9. Job Type 映射（冻结）

| 位置 | 当前值 | 新 Registry Key | 兼容方式 |
|------|--------|----------------|---------|
| `AiAnalysisService.evaluateFeynman()` (ai-analysis.service.ts:40) | `'feynman-evaluation'` | `'feynman_evaluation'` | 新 Definition 使用 `feynman_evaluation`；数据库历史记录保留 `feynman-evaluation`；查询时两者都匹配 |
| `AiAnalysisWorker.process()` (ai-analysis.worker.ts:51) | `'feynman-evaluation'` | — | Legacy 分支不变 |
| `AiJob` 表 `jobType` 列 | `'feynman-evaluation'` | `'feynman_evaluation'` | 历史数据不修改；Unified 新 Job 使用新值 |

**决定**：Registry Key 使用 `feynman_evaluation`（下划线），与 `active_recall` 风格一致。不修改数据库历史记录。

---

## 10. Feature Flag（冻结）

### 10.1 机制

建议新增环境变量：

```bash
FEYNMAN_ENGINE_MODE=legacy   # 默认值
FEYNMAN_ENGINE_MODE=unified  # 切换后走新引擎
```

或复用 `FeatureFlagService`（如项目已有）。

### 10.2 行为契约

| 配置 | 行为 |
|------|------|
| `legacy`（默认） | 所有请求走原 `AiAnalysisService` → `ai-analysis` 队列 |
| `unified` | 所有请求走 `FeynmanExecutionRouter` → `AiJobCreationService` |
| 白名单模式 | 支持特定 userId 走 Unified，其余走 Legacy |

### 10.3 约束

- 同一请求只能执行一个引擎（禁止双跑）
- Unified 失败不得自动调用 Legacy
- 可随时从 `unified` 切回 `legacy`
- 已创建的 Unified Job 继续完成，不重新送入旧链路
- 切回 Legacy 不需要数据库回滚

---

## 11. FocusItem 创建契约（冻结）

### 11.1 当前行为（Legacy）

源：`ai-analysis.worker.ts:85-96`

- **触发条件**：`result.weaknesses.length > 0`
- **每个 weakness 创建 1 个 FocusItem**，字段值：
  - `title` = weakness 字符串（如 "缺少生活化类比"）
  - `reason` = `''`（空）
  - `suggestion` = `''`（空）
  - `priority` = `'normal'`（默认）
  - `status` = `'open'`
  - `source` = `'ai-analysis'`
  - `knowledgeBaseId` = `'unknown'`（永远）
- **无去重**：每次 AI 分析都创建新的 FocusItem
- **无上限**：理论上最多 10 个（Schema 限制 `weaknesses.max(10)`）
- **失败策略**：单个 FocusItem 创建失败被 catch 吞掉，不影响其他

### 11.2 Unified 行为（目标）

- **触发条件**：同 Legacy（`result.weaknesses.length > 0`）
- **数量**：每个 weakness 字符串 1 个，最多 10 个
- **字段映射**：
  - `title` = weakness 字符串
  - `reason` = `''`（Feynman Schema 无结构化 weakness，保持 Legacy 兼容）
  - `suggestion` = `''`（同上）
  - `priority` = `'normal'`
  - `status` = `'open'`
  - `source` = `'ai-analysis'`
  - `knowledgeBaseId` = 从 Snapshot 读取真实值（修复 Legacy bug）
  - `knowledgeItemId` = 从 Snapshot 读取（新增，Legacy 未设置）
- **幂等**：相同 `userId + title + source` 不重复创建（使用 findFirst + create 或 upsert）
- **原子性**：Projector 事务内完成
- **不重新设计**：不改为结构化 weakness、不增加 reason/suggestion 推导逻辑

---

## 12. ReviewCard 创建契约（冻结）

### 12.1 当前行为（Legacy）

源：`review-card.subscriber.ts:12-51` → `review.service.ts:68-98`

- **触发条件**：EventBus 收到 `ai.analysis.completed` 事件 + `strengths` 或 `weaknesses` 非空
- **二次 AI 调用**：`ReviewCardGenerationWorkflow.execute()` — 独立 Provider 调用（tier: 'cheap'）
- **数量**：`min(3, max(1, weaknesses.length))` 张
- **内容来源**：
  - `frontText` / `backText`：AI 生成的卡片内容
  - `difficulty`：AI 判断（schema 默认 'normal'）
- **无去重**：每次事件都生成新卡片
- **失败策略**：整个 subscriber 方法 catch 吞掉，不影响主链路
- **SM-2 参数**：`intervalDays=1, easeFactor=2.5, repetitionCount=0, lapseCount=0, scheduleState='new', nextReviewAt=now()`
- **不关联 Job**：ReviewCard 表无 `jobId` 字段

### 12.2 Unified 行为（目标）

**方案选择**：保持 Legacy 兼容 — 在同一 Projector 事务内基于 EventBus 逻辑创建 ReviewCard。

由于 Feynman Schema 没有 `reviewSuggestion` 结构化字段（ActiveRecall 有），无法像 Active Recall Projector 那样直接从输出创建 ReviewCard。需要二次 AI 调用。

**但二次 AI 调用不能放在 Projector 事务内**（事务内不应有外部 HTTP 调用）。

**两种实现方案**：

**方案 A（保守）**：Projector 只创建 Result + FocusItem + Artifact。ReviewCard 仍通过 EventBus 异步生成。
- 优点：事务简单，不引入新的复杂度
- 缺点：ReviewCard 生成仍非原子

**方案 B（推荐）**：在 Executor 阶段并行调用 Feynman 评估 + ReviewCard 生成，两者的结果一起传入 Projector。
- 优点：Projector 事务内原子写入全部产物
- 缺点：Executor 复杂度增加

**本契约冻结：方案 A**。理由：
1. Feynman Schema 无结构化 ReviewCard 字段，方案 B 需要改 Prompt/Schema — 这是"重新设计复习算法"的范畴（非目标）
2. 将 ReviewCard 生成改为子 Job 是 Gitea 原始里程碑的内容，但本批明确非目标
3. 保持与 Legacy 行为最大兼容

**Unified 链路下 ReviewCard 仍通过 EventBus 异步生成**，但 EventBus 发布从 `AiAnalysisWorker` 移至 `Projector` 完成后。

如果发现 EventBus 丢失导致 ReviewCard 不生成，那是 M-AI-06 的可靠性改进范畴。

### 12.3 字段映射

| ReviewCard 字段 | 值 | 来源 |
|-----------------|----|------|
| `frontText` | AI 生成 | ReviewCardGenerationWorkflow |
| `backText` | AI 生成 | ReviewCardGenerationWorkflow |
| `difficulty` | AI 生成或 `'normal'` | ReviewCardGenerationWorkflow |
| `status` | `'active'` | 硬编码 |
| `intervalDays` | `1` | SM-2 初始值 |
| `easeFactor` | `2.5` | SM-2 默认 |
| `repetitionCount` | `0` | SM-2 初始值 |
| `lapseCount` | `0` | SM-2 初始值 |
| `scheduleState` | `'new'` | 新卡片 |
| `nextReviewAt` | `now()` | 立即可复习 |
| `knowledgeItemId` | null | Legacy 未设置，Unified 可补充 |

---

## 13. Projector 原子性契约（冻结）

### 13.1 事务边界

同一 `$transaction` 内：

```
1. AiAnalysisResult    — upsert (deterministic ID)
2. FocusItem           — findFirst + create (per weakness) 或 skipDuplicates
3. AiJobArtifact       — create (×2: analysis_result, focus_item)
   ★ ReviewCard 不在事务内（方案 A）
4. Job.validatedOutput — update
5. Job.outputHash      — update
6. Job.lifecycleStatus — update → 'succeeded'
7. Job.finishedAt      — update
```

### 13.2 失败回滚

| 失败步骤 | 预期结果 |
|---------|---------|
| Result upsert 失败 | 事务回滚 — 无任何产物 |
| FocusItem 创建失败 | 事务回滚 — Result 不保留 |
| Artifact 创建失败 | 事务回滚 — Result + FocusItem 不保留 |
| Job update 失败 | 事务回滚 — 全部业务产物不保留 |
| 重复执行 Projector | 入口幂等检查 — 已有 Artifact → 直接返回已有引用 |
| ReviewCard 生成失败 | 不影响主链路（异步 EventBus） |

---

## 14. 入口兼容契约（冻结）

### 14.1 请求

```
POST /api/ai-analysis/feynman
Content-Type: application/json
Authorization: Bearer <JWT>

Body (不变):
{
  "knowledgeItemTitle": "string",    // 必填
  "knowledgeItemContent": "string",  // 必填
  "userExplanation": "string",       // 必填
  "sessionId?": "string",            // 可选
  "answerId?": "string"              // 可选
}
```

### 14.2 响应

Legacy 模式（不变）：
```json
{
  "jobId": "cuid...",
  "status": "queued"
}
```

Unified 模式（兼容扩展）：
```json
{
  "jobId": "cuid...",
  "status": "queued",
  "engineMode": "unified",       // 新增可选字段
  "lifecycleStatus": "queued"    // 新增可选字段
}
```

### 14.3 状态码

| 场景 | HTTP 状态 | 响应 |
|------|----------|------|
| 正常提交 | 201 | `{ jobId, status }` |
| 参数缺失 | 400 | `{ message, error }` |
| 未认证 | 401 | `{ message, error }` |
| 重复提交 | 200 | 返回已有 Job 信息 |
| Unified 创建失败 | 500 | `{ message, errorCode }` — 不自动 fallback Legacy |

---

## 15. 回滚流程（冻结）

```
unified → legacy 切换步骤：

1. 修改 FEYNMAN_ENGINE_MODE=legacy（或 Feature Flag 切回）
2. 重启 API Process（或热加载 Feature Flag）
3. 验证：
   a. 新请求走 Legacy（检查日志）
   b. 已创建的 Unified Job 继续完成（Worker 日志不中断）
   c. 同一 submission 不重新进入 Legacy
   d. 客户端查询旧/新 Job 均正常
4. 不需要：
   a. 数据库回滚
   b. 删除 Unified 产物
   c. 清理 Outbox 事件
   d. 重启 Worker
```

---

## 16. 不确定项

| 编号 | 不确定项 | 影响 | 建议 |
|------|---------|------|------|
| U-1 | `submissionId` 来源 — 客户端是否传入？是否用 `sessionId + answerId` 组合？ | 幂等键设计 | 优先使用 `sessionId + answerId`（如都存在）；否则服务端生成 cuid 作为 submissionId |
| U-2 | `knowledgeItemId` 来源 — 请求体当前不包含此字段，需要从 `knowledgeItemTitle + knowledgeItemContent` 匹配？还是客户端传入？ | Snapshot 完整性 | 需要客户端新增 `knowledgeItemId` 字段，或在服务端通过标题+内容匹配 |
| U-3 | `blindSpots` 字段当前未被消费 — 是否需要保留？ | Schema 冻结 | 保留（不删除已有 Schema 字段），但不为其创建 FocusItem |
| U-4 | Feature Flag 机制 — 项目是否已有 `FeatureFlagService`？还是使用环境变量？ | 入口路由实现 | 检查 M0-03 是否已实现；优先复用已有机制 |
| U-5 | Legacy Feynman 的 BullMQ Job 重试次数是 3 — Unified 是否保持一致？ | Job 可靠性 | 在 Definition 中设置 `attempts: 3` |
| U-6 | ReviewCard 生成的 `tier: 'cheap'` 在 Unified 链路的 EventBus 中是否保持一致？ | 成本 | 保持 `tier: 'cheap'` |
| U-7 | FocusItem 的 `knowledgeBaseId` 修复（从 'unknown' 改为真实值）是否会影响现有业务查询？ | 数据兼容 | 影响极小（当前值始终为 'unknown'）；Fix 后 UI 可按 knowledgeBaseId 筛选 |

---

## 17. 附录：相关文件索引

| 文件路径 | 关键类/函数 | 行号 |
|---------|-----------|------|
| `src/modules/ai-analysis/ai-analysis.controller.ts` | `AiAnalysisController.evaluateFeynman()` | 29-43 |
| `src/modules/ai-analysis/ai-analysis.service.ts` | `AiAnalysisService.evaluateFeynman()` | 33-52 |
| `src/modules/ai-analysis/ai-analysis.repository.ts` | `AiAnalysisRepository.createJob()` | 17-33 |
| `src/modules/ai-analysis/ai-analysis.repository.ts` | `AiAnalysisRepository.updateJobStatus()` | 35-46 |
| `src/modules/ai-analysis/ai-analysis.repository.ts` | `AiAnalysisRepository.createResult()` | 55-69 |
| `src/modules/ai-analysis/ai-analysis.repository.ts` | `STATUS_TO_LIFECYCLE` 映射表 | 10-15 |
| `src/workers/ai-analysis.worker.ts` | `AiAnalysisWorker.process()` | 32-105 |
| `src/workers/ai-analysis.worker.ts` | FocusItem 创建循环 | 85-96 |
| `src/workers/ai-analysis.worker.ts` | `AIAnalysisCompleted` 事件发布 | 72-81 |
| `src/modules/ai/workflows/feynman-evaluation.workflow.ts` | `FeynmanEvaluationWorkflow.execute()` | 17-44 |
| `src/modules/ai/prompts/feynman-evaluation.prompt.ts` | `FEYNMAN_EVALUATION_SYSTEM_PROMPT` | 1-31 |
| `src/modules/ai/prompts/schemas/feynman-evaluation.schema.ts` | `FeynmanEvaluationResultSchema` | 3-14 |
| `src/modules/ai/prompts/prompt-template.service.ts` | Feynman prompt 注册 | 33-38 |
| `src/modules/ai/gateway/ai-gateway.service.ts` | `AiGatewayService.generate()` | 40-110 |
| `src/modules/ai/model-router.ts` | `ModelRouter.resolve()` | 70+ |
| `src/modules/review/review-card.subscriber.ts` | `ReviewCardSubscriber.handleAIAnalysisCompleted()` | 12-51 |
| `src/modules/review/review.service.ts` | `ReviewService.generateCards()` | 68-98 |
| `src/modules/review/review.repository.ts` | `ReviewRepository.insertCard()` | 24-39 |
| `src/modules/focus-items/focus-items.service.ts` | `FocusItemsService.create()` | 12 |
| `src/modules/focus-items/focus-items.repository.ts` | `FocusItemsRepository.create()` | 23-47 |
| `src/modules/ai-job/active-recall-projector.ts` | `ActiveRecallProjector.project()` (参考) | 37-202 |
| `src/modules/ai-job/ai-job-creation.service.ts` | `AiJobCreationService.create()` (参考) | 50+ |
| `src/infrastructure/queue/queue.service.ts` | `QueueService.add()` | 47+ |
| `src/infrastructure/queue/queue.constants.ts` | `QUEUE_AI_ANALYSIS = 'ai-analysis'` | 1 |
| `src/infrastructure/queue/queue-definitions.ts` | Queue 配置 | 97+ |
| `prisma/schema.prisma` | AiJob (AiAnalysisJob) | 568-639 |
| `prisma/schema.prisma` | AiAnalysisResult | 679-701 |
| `prisma/schema.prisma` | FocusItem | 703-729 |
| `prisma/schema.prisma` | ReviewCard | 731-757 |
| `prisma/schema.prisma` | AiJobArtifact | 663-677 |
