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>
31 KiB
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。规范化:
- 字段按字母序排序
null与缺省字段等价(不写入 null 字段)- 时间字段归一化(截断到秒)
- 字符串首尾去空白(trim)
- 数组按业务 key 排序(如有),否则按原始顺序
- JSON 使用紧凑格式(无美化空格)
4. Output Schema(冻结)
4.1 当前 Feynman 输出 Schema
源文件:src/modules/ai/prompts/schemas/feynman-evaluation.schema.ts:3-14
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:必须是 booleanjargonUsage:必须在枚举值内
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 |
关键发现
- FocusItem 的
knowledgeBaseId永远为'unknown':result.knowledgeBaseId不在 Feynman Schema 中,而 worker 代码ai-analysis.worker.ts:89使用result.knowledgeBaseId || 'unknown',因此该字段始终为'unknown'。 - FocusItem 无
reason/suggestion/priority:Worker 只传title=weaknessString,其余字段为默认值(reason=''、suggestion=''、priority='normal')。 - ReviewCard 创建通过二次 AI 调用:不是从 Feynman 结果直接映射,而是调用独立的
review-card-generationworkflow(tier: 'cheap')。 - 无事务保证: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 由业务方传入或由以下字段组合派生:
userIdknowledgeItemIduserExplanation的前 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 机制
建议新增环境变量:
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。理由:
- Feynman Schema 无结构化 ReviewCard 字段,方案 B 需要改 Prompt/Schema — 这是"重新设计复习算法"的范畴(非目标)
- 将 ReviewCard 生成改为子 Job 是 Gitea 原始里程碑的内容,但本批明确非目标
- 保持与 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 模式(不变):
{
"jobId": "cuid...",
"status": "queued"
}
Unified 模式(兼容扩展):
{
"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 |