# 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: ├─► 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_` | | `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` 由业务方传入或由以下字段组合派生: - `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 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 |