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

31 KiB
Raw Blame History

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

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 层)

  • score0-100 整数,越界拒绝
  • clarityLevel:必须在枚举值内
  • summary1-2000 字符,空字符串拒绝
  • strengths/weaknesses/blindSpots/suggestions:每项 ≤500 字符,数组 ≤10 项
  • isBeginnerFriendly:必须是 boolean
  • jargonUsage:必须在枚举值内

Business Validator新增

  • score 在 0-100 范围内
  • summary 非空且非纯空格
  • strengthsweaknesses 不能同时为空
  • 禁止空对象 {} 冒充成功
  • 禁止异常大文本(单项 > 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/priorityWorker 只传 title=weaknessString其余字段为默认值reason=''、suggestion=''、priority='normal')。
  3. ReviewCard 创建通过二次 AI 调用:不是从 Feynman 结果直接映射,而是调用独立的 review-card-generation workflowtier: '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

建议优先使用客户端传入的稳定标识(如 sessionIdanswerId 组合)。

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 分支不变
AiJobjobType '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(默认) 所有请求走原 AiAnalysisServiceai-analysis 队列
unified 所有请求走 FeynmanExecutionRouterAiJobCreationService
白名单模式 支持特定 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 行为(目标)

  • 触发条件:同 Legacyresult.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-51review.service.ts:68-98

  • 触发条件EventBus 收到 ai.analysis.completed 事件 + strengthsweaknesses 非空
  • 二次 AI 调用ReviewCardGenerationWorkflow.execute() — 独立 Provider 调用tier: 'cheap'
  • 数量min(3, max(1, weaknesses.length))
  • 内容来源
    • frontText / backTextAI 生成的卡片内容
    • difficultyAI 判断schema 默认 'normal'
  • 无去重:每次事件都生成新卡片
  • 失败策略:整个 subscriber 方法 catch 吞掉,不影响主链路
  • SM-2 参数intervalDays=1, easeFactor=2.5, repetitionCount=0, lapseCount=0, scheduleState='new', nextReviewAt=now()
  • 不关联 JobReviewCard 表无 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 模式(不变):

{
  "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