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>
737 lines
31 KiB
Markdown
737 lines
31 KiB
Markdown
# 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 |
|