api-server/docs/architecture/m-ai-05-feynman-migration-contract.md
wangdl 8987598eb8 feat(M-AI-05): track Feynman unified engine migration implementation
23 files (+4676/-10):
- Contract: m-ai-05-feynman-migration-contract.md (737 lines)
- Gate audit: m-ai-05-gate-audit.md (318 lines)
- Job Definition + Snapshot Builder + Registration
- Executor + BusinessValidator + ReferenceValidator
- Projector (atomic transaction + 3-layer idempotency)
- ExecutionRouter (FeatureFlag + idempotencyKey)
- ObservabilityService (structured logging + counters)
- Engine: feynman_evaluation execution branch
- AiJobCreationService: feynman_evaluation safety branch
- Controller/Module: Router injection
- CI: path detection for m-ai-05
- E2E: 8 HTTP-layer scenarios (14 total)
- Unit tests: 104 new tests (5 spec files)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-21 17:44:58 +08:00

737 lines
31 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 |