api-server/docs/architecture/m-ai-04-active-recall-migration-contract.md
wangdl 0ec58669dd
Some checks failed
Deploy API Server / build-and-unit (push) Successful in 37s
Deploy API Server / current-integration (push) Failing after 1m35s
Deploy API Server / backward-compat (push) Successful in 17s
Deploy API Server / deploy (push) Has been skipped
feat(M-AI-04): Active Recall 端到端迁移至统一 Job Engine
M-AI-04-01: 审计并冻结迁移契约 (文档)
M-AI-04-02: 注册 JobDefinition + SnapshotBuilder (28 tests)
M-AI-04-03: Executor + BusinessValidator + ReferenceValidator (31 tests)
M-AI-04-04: Projector + Artifact + 幂等写入 (19 tests)
M-AI-04-05: 入口集成 (Router + CreationService + Engine + Executor)
M-AI-04-06: 状态兼容 + 结构化日志 + 指标计数器 (255 tests)
M-AI-04-07: 真实业务 E2E + CI 触发 + FeatureFlag 受控切换 (11 tests)

P0 修复:
- 跨用户 question 所有权校验 (active-recall.service.ts)
- E2E infra 不可用时 HARD FAIL (fail() 替代静默 skip)

添加文件:
- docs/architecture/m-ai-04-active-recall-migration-contract.md
- 12 个 ai-job 模块文件 (Definition/Snapshot/Executor/Validator/Projector/Router/Observability)
- test/m-ai-04-active-recall.e2e-spec.ts + setup

修改文件:
- active-recall.service.ts, active-recall.module.ts
- ai-job-creation.service.ts, ai-job-execution-engine.ts, ai-job.module.ts
- .gitea/workflows/deploy.yml (CI 变更检测)
- test/jest-e2e.json (setupFiles + globals)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-21 14:55:33 +08:00

407 lines
23 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

# M-AI-04 Active Recall 迁移契约
> 状态:✅ 已审计冻结M-AI-04-01 完成)
> 审计日期2026-06-21
> 基线M-AI-03 GATE PASScommit `5108a9a`
> 对应 Issue[#296](https://git.admin.longde.cloud/wangdl/api-server/issues/296)
> ⚠️ 行号引用以 commit `5108a9a` 为准。后续代码变更可能导致行号漂移Review 时请对照该 commit 验证。
---
## 1. 当前链路(已审计确认)
```
POST /api/active-recalls/:id/submit
→ Controller: ActiveRecallController.submit()
active-recall.controller.ts:21
@Body() body: any // ⚠️ 无 DTO 校验,期望 { answerText: string }
→ ActiveRecallService.submit(userId, questionId, body)
active-recall.service.ts:19-39
├── ⚠️ userId 来自 @CurrentUser() → user?.id || 'anonymous'
│ JwtAuthGuard 作为全局 APP_GUARD (app.module.ts:184) 生效,
│ 但代码中的 || 'anonymous' fallback 暗示防御性编程,
│ 实际上 JWT 校验失败时 Guard 直接返回 401不会到达 Controller。
├── ActiveRecallRepository.findById(questionId)
│ active-recall.repository.ts:19-21
│ → prisma.activeRecallQuestion.findUnique({ where: { id } })
│ → 不存在时 throw NotFoundException('问题不存在')
│ → ⚠️ P0 安全缺陷:不校验 question.userId === userId
│ 用户 A 可以提交用户 B 的 questionId导致分析结果错配到 A 名下
├── ActiveRecallRepository.createAnswer(userId, questionId, body)
│ active-recall.repository.ts:41-50
│ → prisma.activeRecallAnswer.create({ userId, questionId, answerText, submittedAt })
│ → 表: ActiveRecallAnswer (id, userId, questionId, sessionId, answerType, answerText, audioFileId, submittedAt)
│ → answerType 默认 "text"
└── AiAnalysisService.analyze(userId, { questionText, knowledgeItemContent, userAnswer, answerId })
ai-analysis.service.ts:12-31
├── AiAnalysisRepository.createJob(userId, 'active-recall', sessionId, answerId)
│ ai-analysis.repository.ts:17-33
│ → prisma.aiJob.create({
│ userId, jobType: 'active-recall', sessionId, answerId,
│ status: 'pending',
│ lifecycleStatus: 'queued', // M-AI-02-10 Shadow Write
│ queueName: 'ai-interactive', // ⚠️ 写入 'ai-interactive' 但实际入队 'ai-analysis'
│ inputSchemaVersion: 'legacy-v1',
│ attemptCount: 0,
│ queuedAt: new Date()
│ })
│ → 表: AiJob (物理表名 AiAnalysisJob)
└── QueueService.add('ai-analysis', { jobId, userId, type: 'active-recall', questionText, knowledgeItemContent, userAnswer })
queue.service.ts:47-64
→ BullMQ queue: 'ai-analysis' (QUEUE_AI_ANALYSIS)
→ queue.constants.ts:1
→ 默认 JobOptions (queue-definitions.ts:61-66):
{ attempts: 3, backoff: { type: 'exponential', delay: 1000 },
removeOnComplete: { count: 1000, age: 24*3600 }, // 保留最近 1000 条24h
removeOnFail: { count: 5000, age: 7*24*3600 } } // 保留最近 5000 条7d
→ 写入 TaskLog 表queueName + jobId + payload + status: 'enqueued'
→ 发布 task.enqueued 事件
⚠️ analyze() 错误被 catch 并 log不 re-throw → 答案返回不受 AI 入队结果影响
Worker: AiAnalysisWorker (@Processor('ai-analysis'))
workers/ai-analysis.worker.ts:18-106
├── AiAnalysisRepository.updateJobStatus(jobId, 'processing')
│ ai-analysis.repository.ts:35-46
│ → 设置 status='processing', lifecycleStatus='running', startedAt=now
├── [type='active-recall'] → ActiveRecallAnalysisWorkflow.execute(input)
│ modules/ai/workflows/active-recall-analysis.workflow.ts:17-41
│ └── AiGatewayService.generate({
│ feature: 'active-recall-analysis',
│ userId, tier: 'primary',
│ promptKey: 'active-recall-analysis',
│ promptVersion: '1.0.0',
│ messages: [{ role: 'user', content: userMessage }],
│ outputSchema: ActiveRecallAnalysisResultSchema (Zod)
│ })
│ modules/ai/gateway/ai-gateway.service.ts:40-170
│ ├── ModelRouter.resolve('primary')
│ │ model-router.ts:70-72
│ │ → { tier: 'primary', preferred: { provider:'deepseek', model:'deepseek-v4-pro' },
│ │ fallback: { provider:'deepseek', model:'deepseek-v4-pro' }, maxRetries: 3 }
│ │ ⚠️ preferred 和 fallback 相同 → 无实际 fallback 效果
│ │
│ ├── PromptTemplateService.get('active-recall-analysis', '1.0.0')
│ │ prompt-template.service.ts:65-73
│ │ → 硬编码 TypeScript 常量(非 DB
│ │ → systemPrompt: ACTIVE_RECALL_ANALYSIS_SYSTEM_PROMPT
│ │ → outputSchemaDesc: ACTIVE_RECALL_OUTPUT_SCHEMA_DESC
│ │
│ ├── DeepSeekProvider.generate({ model, messages, temperature: 0.3, maxTokens: 4096, responseFormat: 'json_object' })
│ │ → HTTP POST to DeepSeek API
│ │
│ ├── ContentSafetyService.check(output.rawText, { contentType: 'ai_output' })
│ │ → 不安全时 throw Error('AI output blocked by content safety')
│ │
│ ├── parseJson() - 3 层 JSON 解析:
│ │ 1. 直接 JSON.parse → Zod schema.parse
│ │ 2. 提取 markdown ```json``` fence
│ │ 3. 提取第一个 {…} 对象
│ │
│ ├── AiUsageLogService.log({ userId, feature, provider, model, tier, promptKey, promptVersion, inputTokens, outputTokens, estimatedCost, latencyMs, success })
│ │ → 表: AiUsageLog (usage-log.service.ts)
│ │
│ └── EventBusService.publish(AIUsageRecorded event)
├── AiAnalysisRepository.createResult(userId, jobId, result)
│ ai-analysis.repository.ts:55-69
│ → prisma.aiAnalysisResult.create({
│ userId, jobId,
│ summary: result.summary,
│ masteryScore: result.score,
│ strengths: result.strengths (Json),
│ weaknesses: result.weaknesses (Json),
│ suggestions: result.focusItems (Json),
│ nextActions: result.reviewSuggestion (Json),
│ rawResult: result (Json)
│ })
│ → 表: AiAnalysisResult
├── AiAnalysisRepository.updateJobStatus(jobId, 'completed')
│ → 设置 status='completed', lifecycleStatus='succeeded', finishedAt=now
├── EventBusService.publish(AIAnalysisCompleted event)
│ → eventType: 'ai.analysis.completed'
│ → payload: { userId, jobId, sessionId, answerId, type, score, analysis, timestamp }
│ └── 消费方: ReviewCardSubscriber.handleAIAnalysisCompleted()
│ modules/review/review-card.subscriber.ts:11-51
│ → ReviewService.generateCards(userId, { knowledgeItemTitle, knowledgeItemContent, cardCount })
│ modules/review/review.service.ts:68-99
│ → ReviewCardGenerationWorkflow.execute() → AiGatewayService.generate()
│ → 创建 1-3 条 ReviewCard (SM-2: intervalDays=1, easeFactor=2.5, scheduleState='new')
│ → 表: ReviewCard
└── FocusItemsService.create(userId, { title: w, source: 'ai-analysis', status: 'open' })
→ 为每个 result.weaknesses 元素创建一条 FocusItem
→ knowledgeBaseId: result.knowledgeBaseId || 'unknown'
→ ⚠️ 数据完整性问题ActiveRecallAnalysisResultSchema 不含 knowledgeBaseId 字段
(active-recall-analysis.schema.ts:17-28),因此 result.knowledgeBaseId 恒为 undefined
所有 FocusItem 的 knowledgeBaseId 恒为 'unknown',无法关联到具体知识库
→ 表: FocusItem
错误处理:
Worker catch → updateJobStatus(jobId, 'failed', err.message) → lifecycleStatus='failed'
→ throw err (触发 BullMQ 重试,默认 3 次指数退避)
```
### 关键发现
| # | 发现 | 严重度 | 文件:行 |
|---|------|--------|---------|
| 1 | `queueName` 写入 `'ai-interactive'`,但实际 BullMQ 入队 `'ai-analysis'` | **P0** | `ai-analysis.repository.ts:28` vs `ai-analysis.service.ts:21` |
| 2 | `ActiveRecallService.submit()` 不校验 `question.userId === userId`,用户 A 可提交用户 B 的问题 | **P0** | `active-recall.service.ts:20-21` |
| 3 | 所有 FocusItem 的 `knowledgeBaseId` 恒为 `'unknown'`,因 AI 输出 Schema 不含此字段 | **P1** | `active-recall-analysis.schema.ts:17-28``ai-analysis.worker.ts:89` |
| 4 | POST body 类型为 `any`,无 DTO 校验 | **P1** | `active-recall.controller.ts:21` |
| 5 | `knowledgeItemContent` 硬编码为空字符串 `''`。此外该行注释 `// worker picks up content from the analysis workflow` 是**虚假注释**Worker 不查询 DB 获取知识点内容,直接使用 Job data 中的 `knowledgeItemContent`即空字符串。AI 模型仅收到 `【知识点原文】\n\n` 而无实际内容,分析质量严重受损 | **HIGH** | `active-recall.service.ts:29``ai-analysis.service.ts:26``active-recall-analysis.workflow.ts:18-21` |
| 6 | `removeOnComplete: { count: 1000, age: 24h }` / `removeOnFail: { count: 5000, age: 7d }` — completed Job 保留 24h非立即删除failed Job 保留 7d。影响故障排查窗口和存储容量估算Unified 链路需匹配此行为 | **INFO** | `queue-definitions.ts:64-65` |
| 7 | ModelRouter `primary``strong` tier 的 preferred/fallback 完全相同(均为 `deepseek-v4-pro``maxRetries: 3`。这意味着 4 次尝试全部打到同一个模型fallback 机制形同虚设。生产环境中若 deepseek-v4-pro 故障或限流,重试只会重复失败,不会自动切换备用模型 | **HIGH** | `model-router.ts:24-29` |
| 8 | Prompt 硬编码在 TypeScript 常量中,非 DB 管理 | INFO | `active-recall-analysis.prompt.ts` |
| 9 | AI 分析入队失败不阻止答案返回catch + log | INFO | `active-recall.service.ts:34-36` |
| 10 | `ActiveRecallAnswer` 包含 `audioFileId``answerType` 字段(`schema.prisma:554-556`),但当前 `submit()` 仅接受 `{ answerText }`。非文本答案(音频)在 Unified 链路中的处理方式未定义 | **P2** | `active-recall.controller.ts:21``schema.prisma:549-566` |
| 11 | Worker stall 恢复(`maxStalledCount: 1``queue-definitions.ts:58`)可能导致重复 `AiAnalysisResult`AI 调用成功后 Worker 崩溃 → BullMQ 重新投递 → 再次执行 → `createResult()` 无幂等保护(`AiAnalysisResult``@@unique([jobId])`)→ 同一 jobId 产生两条 Result | **HIGH** | `ai-analysis.worker.ts:67``ai-analysis.repository.ts:55``schema.prisma:679-700``queue-definitions.ts:58` |
---
## 2. 目标链路
```
POST /api/active-recalls/:id/submit
→ ActiveRecallService
→ ActiveRecallExecutionRouter (NEW)
├─ legacy → 原有 AiAnalysisService 路径(不改动)
└─ unified → AiJobCreationService
→ AiJob + AiJobSnapshot + OutboxEvent
→ Outbox Dispatcher
→ Queue: ai-interactive
→ AiJobExecutionEngine
→ ActiveRecallExecutor
→ Validation (Business + Reference)
→ ActiveRecallProjector
→ 业务结果 + AiJobArtifact
```
---
## 3. 输入 Snapshot Schema已冻结
```json
{
"schemaVersion": "active-recall-v1",
"snapshot": {
"userId": "<string, 必填>",
"activeRecallId": "<string, 问题 ID必填>",
"knowledgeItemId": "<string | null, 问题关联的知识点 ID>",
"questionText": "<string, 问题原文,必填>",
"userAnswer": "<string, 用户回答文本,必填>",
"referenceAnswer": "<string | null, 参考答案/标准内容>",
"answerId": "<string, ActiveRecallAnswer.id必填>",
"submittedAt": "<ISO8601 timestamp, 答案提交时间>",
"promptKey": "active-recall-analysis",
"promptVersion": "1.0.0",
"modelTier": "primary",
"modelProvider": "deepseek",
"modelName": "deepseek-v4-pro",
"maxTokens": 4096,
"temperature": 0.3
}
}
```
### 禁止字段
- JWT / Authorization Header
- 模型 API Key
- Cookie
- 数据库连接信息
- 无关用户画像
- 未脱敏 Credential
- 用户邮箱、手机号等 PII
### 区分原则
| 数据 | 归属 | 理由 |
|------|------|------|
| userId, activeRecallId, questionText, userAnswer, answerId | Snapshot冻结 | 重放 AI 调用所需 |
| knowledgeItemContent | 执行时重新查询 | 知识点内容可能更新 |
| knowledgeItemId | Snapshot冻结 | 关联追溯 |
| referenceAnswer | 执行时重新查询 | 从 KnowledgeItem 获取 |
| promptKey, promptVersion, modelTier | Snapshot冻结 | 重放一致性 |
| 用户 email/phone/displayName | 禁止 | PII |
---
## 4. 输出 Schema已冻结
```json
{
"score": "<number, 0-100, 必填>",
"masteryLevel": "'excellent' | 'good' | 'partial' | 'weak' | 'none', 必填",
"summary": "<string, 1-2000 字符, 必填>",
"strengths": ["<string[], 最多 10 项>"],
"weaknesses": ["<string[], 最多 10 项>"],
"missingKeyPoints": ["<string[], 最多 20 项>"],
"misconceptions": ["<string[], 最多 10 项>"],
"weaknessTypes": ["<string[], missing_detail|missing_application|misconception|vague_expression|incomplete_structure|wrong_emphasis>"],
"focusItems": [
{
"title": "<string, 1-255>",
"reason": "<string, 1-1000>",
"suggestion": "<string, optional>",
"priority": "'high' | 'normal' | 'low'"
}
],
"reviewSuggestion": {
"shouldReview": "<boolean>",
"intervalDays": "<number, 1-365>",
"cardFront": "<string, optional>",
"cardBack": "<string, optional>"
}
}
```
### 验证规则
| 字段 | 规则 | 来源 |
|------|------|------|
| score | 0 ≤ score ≤ 100, 整数 | `active-recall-analysis.schema.ts:18` |
| masteryLevel | enum: excellent/good/partial/weak/none | `active-recall-analysis.schema.ts:19` |
| summary | 1-2000 字符 | `active-recall-analysis.schema.ts:20` |
| strengths | 最多 10 项, 每项 ≤500 字符 | `active-recall-analysis.schema.ts:21` |
| weaknesses | 最多 10 项, 每项 ≤500 字符 | `active-recall-analysis.schema.ts:22` |
| focusItems | 最多 10 项 | `active-recall-analysis.schema.ts:26` |
| reviewSuggestion | 必填, intervalDays 1-365 | `active-recall-analysis.schema.ts:27` |
---
## 5. 副作用矩阵(已审计)
| 操作 | 表/实体 | 触发条件 | 写入方 | 文件:行 |
|------|---------|----------|--------|---------|
| 创建答案记录 | `ActiveRecallAnswer` | 每次提交 | `ActiveRecallRepository.createAnswer()` | `active-recall.repository.ts:41` |
| 创建 Job | `AiJob` (AiAnalysisJob) | 每次提交 | `AiAnalysisRepository.createJob()` | `ai-analysis.repository.ts:17` |
| 创建 TaskLog | `TaskLog` | 每次入队 | `QueueService.add()` | `queue.service.ts:59` |
| 更新 Job → processing | `AiJob` | Worker 开始执行 | `AiAnalysisRepository.updateJobStatus()` | `ai-analysis.worker.ts:48` |
| 调用 AI 模型 | DeepSeek API | Worker 执行 | `AiGatewayService.generate()` | `ai-gateway.service.ts:58` |
| 记录 UsageLog | `AiUsageLog` | AI 调用完成 | `AiUsageLogService.log()` | `ai-gateway.service.ts:78` |
| 发布 AIUsageRecorded | EventBus | AI 调用完成 | `AiGatewayService` | `ai-gateway.service.ts:94` |
| 安全审核 | ContentSafety | AI 输出后 | `ContentSafetyService.check()` | `ai-gateway.service.ts:67` |
| 创建 FallbackEvent | `FallbackEvent` | 首次调用失败切备用 | `AiGatewayService` | `ai-gateway.service.ts:127` |
| 创建分析结果 | `AiAnalysisResult` | Worker 成功后 | `AiAnalysisRepository.createResult()` | `ai-analysis.worker.ts:67` |
| 更新 Job → completed | `AiJob` | Worker 成功后 | `AiAnalysisRepository.updateJobStatus()` | `ai-analysis.worker.ts:68` |
| 发布 AIAnalysisCompleted | EventBus | Worker 成功后 | `AiAnalysisWorker` | `ai-analysis.worker.ts:72` |
| 生成复习卡片 | `ReviewCard` | 收到 AIAnalysisCompleted 事件 | `ReviewCardSubscriber``ReviewService.generateCards()` | `review-card.subscriber.ts:39` |
| 创建薄弱项 | `FocusItem` | result.weaknesses.length > 0 | `FocusItemsService.create()` | `ai-analysis.worker.ts:88` | ⚠️ knowledgeBaseId 恒为 'unknown' |
| 更新 Job → failed | `AiJob` | Worker 失败 | `AiAnalysisRepository.updateJobStatus()` | `ai-analysis.worker.ts:102` |
---
## 6. 状态映射(已冻结)
| 业务阶段 | 旧 Job 状态 (`status`) | 新 `lifecycleStatus` | Active Recall 业务状态 | 客户端可见 |
|----------|----------------------|---------------------|----------------------|-----------|
| 提交答案 | `pending` | `queued` | 答案已提交,等待 AI 分析 | 答案已提交 |
| AI 分析执行中 | `processing` | `running` | AI 正在分析回答 | 分析中 |
| 分析完成 | `completed` | `succeeded` | 分析完成,结果可用 | 分析完成(可查看结果) |
| 分析失败 | `failed` | `failed` | 分析失败(自动重试后仍失败) | 分析失败 |
| 取消 | N/A旧链路不支持 | `cancelled` | 分析已取消 | 分析取消 |
### Shadow Write 映射ai-analysis.repository.ts:10-15
```
pending → queued
processing → running
completed → succeeded
failed → failed
```
---
## 7. 幂等键
```
active-recall:<answerId>
```
- **稳定业务标识**`answerId``ActiveRecallAnswer.id`,每次提交生成唯一 ID
- **唯一约束**`AiJob.@@unique([userId, jobType, idempotencyKey])` (`schema.prisma:636`)
- **冲突处理**P2002 时返回已有 Job`AiJobCreationService` 实现)
- **格式**`active-recall:<answerId>`
---
## 8. Feature Flag
| 属性 | 值 |
|------|-----|
| 配置名 | `ACTIVE_RECALL_ENGINE_MODE` |
| 值 | `legacy` \| `unified` |
| 默认 | `legacy`(切换前) |
| 白名单 | 支持用户 ID 白名单(通过 `FeatureFlagService` |
| 存储 | `FeatureFlag` 表 + Redis 缓存30s TTL |
| 分支点 | `ActiveRecallExecutionRouter`待实现M-AI-04-05 |
| 切换方式 | 修改 FeatureFlag 值,无需重启 |
### 配置机制(现有基础设施)
- **FeatureFlagService**: `FeatureFlag` 表 (`name`, `enabled`, `whitelist`, `rolloutPct`)Redis 缓存 30s
- `config/feature-flag.service.ts`
- **AppConfigService**: `AppConfig` 表 (key-value)Redis 缓存 60s
- `config/config.service.ts`
---
## 9. 回滚流程
```
unified → legacy:
1. 修改 ACTIVE_RECALL_ENGINE_MODE=legacy通过 Admin 或 FeatureFlag API
2. 新请求立即走 Legacy 路径ActiveRecallExecutionRouter 读取 Flag
3. 已创建的新 Job 继续完成或取消(不强制中断)
4. 不重新送入 Legacy避免重复分析
5. 客户端仍可查询已有新 JobGET /api/ai/jobs/:jobId
6. 已写入的 AiAnalysisResult / FocusItem / ReviewCard 不删除
```
### 禁止事项
- 禁止 Legacy 和 Unified 双链路同时执行同一 answerId通过幂等键保证
- 禁止在无运行证据时直接全量切换
- 禁止自动 Legacy fallback必须通过 Flag 显式切换)
---
## 10. 不确定项
- [x] 确认 `knowledgeItemContent` 来源:当前硬编码 `''`Worker 不查 DB。迁移后 Snapshot 仍可为空Executor 可从 knowledgeItemId 查询。
- [x] 确认 `queueName` 不一致:当前 DB 写入 `ai-interactive` 但 BullMQ 路由 `ai-analysis`。迁移后 Unified 路径统一使用 `ai-interactive`
- [x] 确认 ReviewCard 生成是否需要保留:是,`AIAnalysisCompleted``ReviewCardSubscriber` 链路由 EventBus 驱动,与 Job 系统解耦。
- [x] 确认认证/权限缺陷JwtAuthGuard 为全局 Guard`app.module.ts:184`),认证层面安全;但 `submit()` 不校验跨用户所有权P0需在 M-AI-04-05 修复。
- [x] 确认 FocusItem knowledgeBaseId 恒为 `'unknown'`P1`ActiveRecallAnalysisResultSchema` 不含 `knowledgeBaseId` 字段,需在 M-AI-04-03 输出 Schema 中增加该字段。
- [x] 确认 Job 保留策略:`removeOnComplete: { count: 1000, age: 24h }` / `removeOnFail: { count: 5000, age: 7d }``queue-definitions.ts:64-65`Unified 链路 `ai-interactive` 队列使用相同默认值。
- [ ] 待 M-AI-04-02Snapshot 是否包含 `knowledgeItemContent`(查询时获取 vs 快照冻结)— 建议不包含Executor 执行时实时查询。
- [ ] 待 M-AI-04-03Executor 是否复用现有 `ActiveRecallAnalysisWorkflow` 还是新建。
- [ ] 待 M-AI-04-05`ActiveRecallExecutionRouter` 的分支粒度per-request vs per-user vs per-session
- [ ] 待 M-AI-04-03/04非文本答案`audioFileId`)在 Unified 链路的处理方式。当前 `submit()` 仅处理 `{ answerText }`,但 `ActiveRecallAnswer` 模型包含 `audioFileId``answerType` 字段。若支持音频答案,需语音转文本步骤或独立的音频分析 Executor。
- [ ] 待 M-AI-04-04Worker stall 恢复的重复 `AiAnalysisResult` 风险。`maxStalledCount: 1` + `AiAnalysisResult``@@unique([jobId])` 约束 → 崩溃重试可能产生重复结果。Unified 链路的 `ActiveRecallProjector` 必须在 Projector 层提供幂等保证。
---
## 关联 Issue
| Issue | 标题 | Gitea |
|-------|------|-------|
| M-AI-04-01 | 审计并冻结迁移契约 | [#296](https://git.admin.longde.cloud/wangdl/api-server/issues/296) ✅ 本 Issue |
| M-AI-04-02 | 注册 Definition 与 Snapshot | [#297](https://git.admin.longde.cloud/wangdl/api-server/issues/297) |
| M-AI-04-03 | Executor 与输出验证 | [#298](https://git.admin.longde.cloud/wangdl/api-server/issues/298) |
| M-AI-04-04 | Projector、Artifact 与幂等写入 | [#299](https://git.admin.longde.cloud/wangdl/api-server/issues/299) |
| M-AI-04-05 | 入口接入 CreationService | [#300](https://git.admin.longde.cloud/wangdl/api-server/issues/300) |
| M-AI-04-06 | 状态兼容、可观测性与回滚 | [#301](https://git.admin.longde.cloud/wangdl/api-server/issues/301) |
| M-AI-04-07 | 真实业务 E2E 与受控切换 | [#302](https://git.admin.longde.cloud/wangdl/api-server/issues/302) |
| M-AI-04-08-GATE | 最终验收与切换 | [#303](https://git.admin.longde.cloud/wangdl/api-server/issues/303) |