# M-AI-07 Quiz 生成与题目产物迁移契约

> 审计日期：2026-06-22
> 契约状态：冻结（待 M-AI-07-01 验收）
> 对应里程碑：M-AI-07 Quiz 生成与题目产物统一引擎迁移

---

## 目录

1. [当前链路审计](#1-当前链路审计)
2. [目标链路](#2-目标链路)
3. [数据模型](#3-数据模型)
4. [题型规则](#4-题型规则)
5. [Snapshot Schema](#5-snapshot-schema)
6. [Output Schema](#6-output-schema)
7. [副作用矩阵](#7-副作用矩阵)
8. [Artifact 矩阵](#8-artifact-矩阵)
9. [幂等契约](#9-幂等契约)
10. [权限模型](#10-权限模型)
11. [答案隐藏契约](#11-答案隐藏契约)
12. [Feature Flag](#12-feature-flag)
13. [回滚流程](#13-回滚流程)
14. [架构异常与不确定项](#14-架构异常与不确定项)

---

## 1. 当前链路审计

### 1.1 当前时序（Legacy Runtime 轮询路径）

```text
Client                           API Server                         External Runtime
  |                                 |                                      |
  | POST /api/ai/jobs               |                                      |
  | { jobType:"quiz_generation",    |                                      |
  |   targetType:"knowledge_base",  |                                      |
  |   targetId:"kb-1",              |                                      |
  |   questionCount:10,             |                                      |
  |   questionTypes:["choice"],     |                                      |
  |   difficultyLevel:"medium",     |                                      |
  |   idempotencyKey? }             |                                      |
  |-------------------------------->|                                      |
  |                                 | UserAiService.createAnalysisJob()    |
  |                                 | `src/modules/ai-runtime/user-ai.service.ts:199` |
  |                                 |  1. Settings check (:201)            |
  |                                 |  2. Validate jobType (:210-214)       |
  |                                 |  3. Validate targetType=knowledge_base (:216-222) |
  |                                 |  4. Idempotency check (:225-233)     |
  |                                 |  5. Resolve credential (:237-249)    |
  |                                 |  6. Quota check (:252)               |
  |                                 |  7. Platform budget check (:255-267) |
  |                                 |  8. Build snapshot (:270)            |
  |                                 |  9. Resolve prompt/schema versions   |
  |                                 |     → quiz_gen_v1 / quiz_output_v1   |
  |                                 |     `user-ai.service.ts:15-17`       |
  |                                 |  10. Compute priority (:279)         |
  |                                 |  11. Create aiRuntimeJob (pending)   |
  |                                 |      modelProvider=deepseek           |
  |                                 |      modelName=deepseek-chat (:294)  |
  |                                 |      `user-ai.service.ts:282-299`    |
  |                                 |  12. Create QuestionGenerationPlan   |
  |                                 |      `user-ai.service.ts:303-318`    |
  |                                 |  13. Increment quota (:338)          |
  |                                 |                                      |
  | { jobId, status:"pending",      |                                      |
  |   planId }                      |                                      |
  |<--------------------------------|                                      |
  |                                 |                                      |
  |                                 |    POST /internal/runtime/jobs/poll  |
  |                                 |    (InternalAuthGuard)               |
  |                                 |    `runtime-internal.controller.ts:22`|
  |                                 |<-------------------------------------|
  |                                 |    RuntimeInternalService.pollJobs()  |
  |                                 |    `runtime-internal.service.ts:22`   |
  |                                 |    → CAS lockJob (:85)              |
  |                                 |    → getSnapshot (:153)              |
  |                                 |    → resolveCredential (:201)        |
  |                                 |                                      |
  |                                 |    ★ Runtime makes AI call directly  |
  |                                 |    ★ prompt/schema managed externally |
  |                                 |    ★ API server has NO quiz prompt    |
  |                                 |    ★ API server has NO quiz schema    |
  |                                 |                                      |
  |                                 |    POST /internal/runtime/jobs/:id/result
  |                                 |    { validatedOutput: {              |
  |                                 |        quizTitle, quizDescription,   |
  |                                 |        questions: [{                 |
  |                                 |          type, stem/question,         |
  |                                 |          options, answer/correctAnswer,|
  |                                 |          explanation, sourceBlockIds  |
  |                                 |        }] } }                       |
  |                                 |<-------------------------------------|
  |                                 |                                      |
  |                                 | submitResult() → persistResult()     |
  |                                 | `runtime-internal.service.ts:220`    |
  |                                 |   → quiz_generation (:367-381)       |
  |                                 |     update Plan→completed            |
  |                                 |     → convertQuizCandidates()        |
  |                                 |       `runtime-internal.service.ts:435`|
  |                                 |       Quiz.create (status=ready)     |
  |                                 |       QuizQuestion.create × N        |
  |                                 |       dedup by stem (:463-472)       |
  |                                 |                                      |
  | POST /api/ai/quizzes/:id/publish |                                      |
  |   → status: ready→active         |                                      |
```

### 1.2 双系统并存

| 特性 | Procedural Quiz | AI Quiz Generation |
|------|----------------|-------------------|
| 入口 | `POST /quizzes` | `POST /ai/jobs` (jobType=quiz_generation) |
| Controller | `quiz.controller.ts` | `user-ai.controller.ts` |
| Service | `quiz.service.ts` | `user-ai.service.ts` / `runtime-internal.service.ts` |
| AI 调用 | **无**（程序化生成） | **外部 Runtime**（API 不管理 prompt/schema） |
| 题目类型 | choice, fill, judge | Runtime 决定，默认 choice |
| Job 系统 | 无 | `aiRuntimeJob`（非统一 AiJob） |
| 队列 | 无 | Runtime 轮询（非 BullMQ） |
| 幂等 | 无 | `idempotencyKey` on aiRuntimeJob |
| 数据模型 | Quiz + QuizQuestion | 相同 |
| 状态 | 直接 active | ready → publish → active |
| 计费 | 无 | quota.incrementJobCount |

**本里程碑只迁移 AI Quiz Generation 路径。Procedural Quiz 保持不变。**

### 1.3 Quiz 数据模型

**文件**: `prisma/schema.prisma:1833-1904`

```text
Quiz
├─ id              String @id @default(cuid())
├─ userId          String
├─ knowledgeBaseId String
├─ title           String @db.VarChar(255)
├─ description     String? @db.Text
├─ questionCount   Int @default(0)
├─ sourceType      String @default("kb") @db.VarChar(16)   // "kb" | "ai"
├─ sourceId        String? @db.VarChar(100)                // aiRuntimeJob.id
├─ status          String @default("ready") @db.VarChar(16) // ready|active|archived
├─ createdAt       DateTime @default(now())
└─ updatedAt       DateTime @updatedAt
   @@index([userId]), @@index([knowledgeBaseId])

QuizQuestion
├─ id              String @id @default(cuid())
├─ quizId          String
├─ type            String @db.VarChar(16)   // "choice"|"fill"|"judge"
├─ stem            String @db.Text          // 题干
├─ options         Json?                    // 选项数组 (choice 题型)
├─ answer          String @db.VarChar(500)  // 正确答案
├─ explanation     String? @db.Text         // 解析
├─ sourceBlockIds  Json?                    // 来源文本块引用
├─ orderIndex      Int @default(0)
└─ createdAt       DateTime @default(now())
   @@index([quizId])

QuizAttempt
├─ id              String @id @default(cuid())
├─ quizId          String
├─ userId          String
├─ totalQuestions  Int @default(0)
├─ correctCount    Int @default(0)
├─ score           Int @default(0)         // 0-100
├─ startedAt       DateTime @default(now())
└─ finishedAt      DateTime?
   @@index([quizId]), @@index([userId])

QuizAnswer
├─ id              String @id @default(cuid())
├─ attemptId       String
├─ questionId      String
├─ userAnswer      String @db.VarChar(500)
├─ isCorrect       Boolean @default(false)
└─ answeredAt      DateTime @default(now())
   @@index([attemptId]), @@index([questionId])

QuestionGenerationPlan (Legacy — 由 Unified 的 AiJob + Snapshot 替代)
├─ id              String @id @default(cuid())
├─ userId          String
├─ jobId           String                  // → aiRuntimeJob.id
├─ snapshotId      String?
├─ targetType      String? @db.VarChar(32)
├─ targetId        String? @db.VarChar(255)
├─ knowledgePointIds Json?
├─ questionTypes   Json?
├─ difficultyLevel String? @db.VarChar(32)  // easy|medium|hard
├─ count           Int @default(5)
├─ reason          String? @db.Text
├─ status          String @default("pending")
├─ createdAt       DateTime
└─ updatedAt       DateTime
   @@index([userId])
```

### 1.4 当前题型

| 题型 | 代码值 | 选项结构 | 答案结构 | 来源 |
|------|--------|---------|---------|------|
| 选择题 | `choice` | `Json` 数组（选项字符串） | 正确选项的索引（字符串） | `quiz.service.ts:43-51` |
| 填空题 | `fill` | 无 | 缺失的关键词 | `quiz.service.ts:52-56` |
| 判断题 | `judge` | 无 | `"true"` 或 `"false"`（字符串） | `quiz.service.ts:57-60` |

**AI 生成的题目默认 type=`'choice'`**（`runtime-internal.service.ts:486`），但可包含其他类型（由 Runtime 决定）。

### 1.5 输入字段分类

| 字段 | 来源 | 分类 |
|------|------|------|
| `userId` | JWT | **进入 Snapshot** |
| `targetType` | DTO | **进入 Snapshot** |
| `targetId` (knowledgeBaseId) | DTO | **进入 Snapshot** |
| `questionCount` | DTO (1-50) | **进入 Snapshot** |
| `questionTypes` | DTO (string[]) | **进入 Snapshot** |
| `difficultyLevel` | DTO | **进入 Snapshot** |
| `knowledgePointIds` | DTO | **进入 Snapshot** |
| `idempotencyKey` | DTO | **进入 Snapshot** |
| `promptKey` | 配置 `quiz_gen_v1` | **进入 Snapshot** |
| `promptVersion` | 配置 | **进入 Snapshot** |
| `modelTier` | 配置 | **进入 Snapshot** |
| 知识库内容 | DB 查询 | **进入 Snapshot（限制长度）** |
| 知识点列表 | DB 查询 | **进入 Snapshot（限制数量+内容长度）** |
| JWT / Authorization | Request Header | **禁止进入 Snapshot** |
| API Key | DB | **禁止进入 Snapshot** |
| 用户学习画像（完整） | DB | **禁止进入 Snapshot**（仅取必要配置） |
| 完整知识库内容（未筛选） | DB | **禁止进入 Snapshot** |

### 1.6 副作用矩阵

| 副作用 | 当前行为 | Unified 行为 |
|--------|---------|-------------|
| 配额消耗 | `quota.incrementJobCount` (:338) | 保持（通过 CreationService 或 Router） |
| 平台预算检查 | `budget.checkPlatformBudget` (:257) | 保持 |
| QuestionGenerationPlan | 创建 plan 记录 (:303-318) | **废弃** — 由 AiJob + Snapshot 替代 |
| 使用日志 | Runtime 记录 | API 侧通过 Engine 的 usage log |
| 学习记录 | 不创建 | 不创建 |
| 通知 | 不创建 | 不创建 |

### 1.7 答案泄漏风险（审计发现）

**P0 风险**：`user-ai.service.ts:546-556` `getQuizQuestions()` 在 SELECT 中包含 `answer` 和 `explanation` 字段。任何已认证用户可在未开始答题前通过此接口获取正确答案。

**Procedural quiz**：`quiz.service.ts:83-88` `findOne()` include `questions` 包含完整 `answer` 字段。同样泄漏。

**建议**：M-AI-07-06 必须在 Unified 路径修复此问题。Legacy 路径暂不修改（避免影响现有客户端）。

---

## 2. 目标链路

### 2.1 目标时序（Unified 路径）

```text
POST /api/ai/jobs
  { jobType: "quiz_generation", targetType: "knowledge_base", targetId, ... }
→ QuizExecutionRouter
  ├─ [legacy] → UserAiService.createAnalysisJob()  ← 原路径
  └─ [unified] →
       QuizGenerationSnapshotBuilder.build()
       → AiJobCreationService.createJob()
       → AiJob + Snapshot + Outbox
       → BullMQ (ai-interactive)
       → Worker → AiJobExecutionEngine
         EXECUTE: QuizGenerationExecutor (AiGateway)
         VALIDATE: QuizGenerationValidator
         PROJECT: QuizGenerationProjector
           → Quiz + QuizQuestion × N + Artifact
           → markSucceeded
```

### 2.2 关键差异

| 维度 | Legacy (Runtime) | Unified |
|------|-----------------|---------|
| AI 调用方 | 外部 Runtime 直接调用 Provider | API 内 Executor → AiGateway |
| Prompt 管理 | Runtime 外部管理 | API 内 PromptTemplateService |
| Schema 管理 | Runtime 外部管理 | API 内 Zod Schema |
| Job 系统 | aiRuntimeJob | 统一 AiJob |
| 队列 | Runtime 轮询 | BullMQ (ai-interactive) |
| 结果持久化 | runtime-internal.service | QuizGenerationProjector |
| 事务 | 非原子（Quiz + Questions 分步） | 原子（同一 Prisma 事务） |

---

## 3. 数据模型（迁移相关约束）

### 3.1 保持不变的字段

Quiz 和 QuizQuestion 的所有字段保持不变。Unified Projector 写入相同的表和列。

### 3.2 ID 策略

| 实体 | 当前 | Unified |
|------|------|---------|
| Quiz.id | `@default(cuid())` | 如允许显式赋值 → `deterministic(jobId)`；否则 CUID + Artifact 幂等 |
| QuizQuestion.id | `@default(cuid())` | 如允许显式赋值 → `deterministic(jobId + ordinal)`；否则 CUID + Artifact 幂等 |
| QuizQuestion.options | `Json?` | 选择题：`string[]`；填空/判断：`null` |
| QuizQuestion.answer | `String` | **按当前格式保存**（choice=索引字符串，fill=关键词，judge="true"/"false"） |

### 3.3 QuestionGenerationPlan

Unified 路径不再创建 `QuestionGenerationPlan`。由 AiJob + AiJobSnapshot 替代其功能。

---

## 4. 题型规则

### 4.1 支持题型（冻结）

```text
choice   — 单选题（1 个正确答案）
fill     — 填空题（缺失关键词）
judge    — 判断题（true/false）
```

不新增题型。如果 AI 输出其他类型，Validator 拒绝。

### 4.2 题型约束

| 题型 | options | answer | 验证规则 |
|------|---------|--------|---------|
| `choice` | `string[]`（≥2 项） | 正确选项索引（字符串） | answer 必须是 options 的有效索引 |
| `fill` | 不需要 | 缺失关键词 | answer 非空 |
| `judge` | 不需要 | `"true"` 或 `"false"` | 必须是这两者之一 |

---

## 5. Snapshot Schema

### 5.1 必须包含

```typescript
interface QuizGenerationSnapshot {
  schemaVersion: string;          // "quiz-generation-v1"
  snapshot: {
    userId: string;
    targetType: string;           // "knowledge_base"
    targetId: string;             // knowledgeBaseId
    submissionId: string;         // 稳定幂等标识
    
    // 知识内容（限制长度）
    knowledgeBaseTitle: string;
    knowledgeBaseDescription: string;
    knowledgeItems: Array<{       // 限制数量 ≤ 50
      id: string;
      title: string;
      content: string;            // 截断 ≤ 2000 字符
      summary: string;
    }>;
    
    // 生成参数
    questionCount: number;        // 1-50
    questionTypes: string[];      // ["choice","fill","judge"]
    difficultyLevel: string;      // "easy"|"medium"|"hard"
    knowledgePointIds: string[];  // 空数组表示全部知识点
    
    // Prompt/模型策略（从 Definition 读取）
    promptKey: string;
    promptVersion: string;
    modelTier: string;
    inputSchemaVersion: string;
    outputSchemaVersion: string;
    
    createdAt: string;            // ISO8601 归一化到秒
  };
}
```

### 5.2 禁止进入 Snapshot

- JWT / Authorization Header
- API Key / Provider Credential
- 完整知识库（未筛选+未截断）
- 完整用户学习画像
- 其他用户内容
- 数据库连接信息

---

## 6. Output Schema

### 6.1 AI 输出结构（冻结）

```typescript
interface QuizGenerationOutput {
  quizTitle: string;              // 测验标题（非空，≤255）
  quizDescription?: string;       // 测验描述
  questions: Array<{
    type: 'choice' | 'fill' | 'judge';
    stem: string;                 // 题干（非空）
    options?: string[];           // 选项（choice 题型必填，≥2）
    answer: string;               // 正确答案
    explanation: string;          // 解析（非空）
    sourceBlockIds?: string[];    // 来源引用
  }>;
}
```

### 6.2 验证规则

| 规则 | 严重程度 |
|------|---------|
| questions 数组非空，≤ 50 | P0 |
| quizTitle 非空，≤ 255 | P0 |
| stem 非空 | P0 |
| type ∈ {choice, fill, judge} | P0 |
| choice → options 数组 ≥ 2 项 | P0 |
| choice → answer 是 options 的有效索引 | P0 |
| fill → answer 非空 | P0 |
| judge → answer ∈ {"true", "false"} | P0 |
| explanation 非空 | P1 |
| 题目不重复（相同 stem） | P1 |
| 选项不重复 | P1 |
| 无 Markdown 代码围栏 | P0 |
| 无模型指令文本 | P0 |

---

## 7. 副作用矩阵

| 副作用 | Legacy | Unified |
|--------|--------|---------|
| AiJob 创建 | aiRuntimeJob | 统一 AiJob |
| Snapshot 创建 | snapshot_builder | AiJobSnapshot |
| 队列入队 | Runtime 轮询 | OutboxEvent → BullMQ |
| 配额消耗 | quota.incrementJobCount | 保持（Router 层） |
| 平台预算检查 | budget.checkPlatformBudget | 保持（Router 层） |
| QuestionGenerationPlan | 创建 | **不创建**（由 AiJob+Snapshot 替代） |
| 使用日志 | Runtime 记录 | Engine 记录 |
| 学习记录 | 无 | 无 |
| 通知 | 无 | 无 |

---

## 8. Artifact 矩阵

| 实体 | artifactType | artifactId | ordinal |
|------|-------------|-----------|---------|
| Quiz | `"quiz"` | Quiz.id | 0 |
| (可选) QuizQuestion | metadata 记录 | — | — |

不为每个 QuizQuestion 创建独立 Artifact（避免 Artifact 膨胀）。

---

## 9. 幂等契约

### 9.1 请求级幂等

```text
idempotencyKey = "quiz-generation:<submissionId>"
```

- `submissionId` 优先使用客户端传入的 `idempotencyKey`
- 回退：`<targetType>:<targetId>:<contentHash>`
- Db 级唯一约束：`AiJob.@@unique([userId, jobType, idempotencyKey])`

### 9.2 投影级幂等

```text
Quiz: deterministic ID = "qz_<jobId>"
Question: deterministic ID = "qq_<jobId>_<ordinal>"
```

如 Prisma `@default(cuid())` 不允许显式 ID 赋值，使用入口 Artifact 检查 + P2002 catch 作为替代（与 M-AI-06 ReviewCard 一致）。

### 9.3 禁止的幂等方式

- ❌ `findFirst({ stem }) → create` — 不同 Quiz 可能有相同题干
- ❌ 全局 stem 去重 — 当前 `convertQuizCandidates` 使用此方式 (`runtime-internal.service.ts:463-472`)，迁移后应改为确定性 ID

---

## 10. 权限模型

| 操作 | 权限 |
|------|------|
| 生成 Quiz | 用户只能为自己的 knowledgeBase 生成 |
| 查询 Quiz | 只能查询自己的 Quiz (`userId` 过滤) |
| 查询 Question | 只能查询自己 Quiz 的 Question |
| 发布 Quiz | 只能发布自己的 Quiz |
| Admin 查询 | Admin 可查看所有（按现有 Admin 权限） |

---

## 11. 答案隐藏契约

**当前缺陷**：`getQuizQuestions()` 直接返回 `answer` + `explanation` 字段（`user-ai.service.ts:553`）。`findOne()` include questions 同样泄漏（`quiz.service.ts:85`）。

**Unified 要求**：
- Quiz 列表 DTO：不返回 questions 数组（或不含 answer/explanation）
- Quiz 详情 DTO（未作答前）：不返回 answer/explanation
- 开始答题后：不通过查询接口返回答案（答案仅在 submit 时服务端比对）
- 提交后：QuizAttempt.getResults 可以返回 question.answer（当前行为保留）
- Admin：可以查看答案

**M-AI-07-06 必须修复此问题。Legacy 路径暂不修改。**

---

## 12. Feature Flag

```text
Flag Name: QUIZ_GENERATION_ENGINE_MODE
Values:    legacy | unified
Default:   legacy
```

| 模式 | 行为 |
|------|------|
| `legacy` | UserAiService.createAnalysisJob() → aiRuntimeJob → Runtime 轮询 |
| `unified` | QuizGenerationSnapshotBuilder → AiJobCreationService → BullMQ → Engine |

---

## 13. 回滚流程

```text
unified → legacy:
  1. 修改 FeatureFlag: QUIZ_GENERATION_ENGINE_MODE = legacy
  2. 新请求立即走 Legacy 路径
  3. 已创建的 Unified Job 继续完成
  4. 相同 submission 不重新进入 Legacy
  5. 已生成 Quiz 保留
  6. 无需数据库回滚
```

---

## 14. 架构异常与不确定项

### 14.1 架构异常

| # | 异常 | 严重程度 | 处理方式 |
|---|------|---------|---------|
| A1 | API 服务器无 Quiz Prompt 模板 | **阻塞** | M-AI-07-03 需新增 `quiz-generation.prompt.ts` 和 `quiz-generation.schema.ts`（从 Runtime 获取或重新设计） |
| A2 | API 服务器无 Quiz Output Schema | **阻塞** | 同上 |
| A3 | `getQuizQuestions()` 泄漏答案 | P0 | M-AI-07-06 修复 |
| A4 | `findOne()` include questions 泄漏答案 | P0 | M-AI-07-06 修复 |
| A5 | `deepseek-chat` 硬编码（非 ModelRouter） | P2 | Unified 使用 Definition + ModelRouter |
| A6 | 当前题目去重依赖 `stem` 全局匹配 | P2 | Unified 使用确定性 ID |
| A7 | Quiz 状态 `ready` → `publish` → `active` 两阶段 | P2 | Unified 可简化为直接 `active`，或保留两阶段 |

### 14.2 不确定项

| 项 | 问题 | 建议 |
|----|------|------|
| Prompt 来源 | 当前 prompt 由外部 Runtime 管理 | 从 Runtime 团队获取 `quiz_gen_v1` 的 system prompt 和 output schema，或基于业务需求重新设计 |
| 模型 Tier | 当前硬编码 `deepseek-chat`，不经过 ModelRouter | Unified Definition 使用 `modelTier`（建议 `primary`），通过 ModelRouter 解析 |
| questionTypes 扩展 | 当前仅 choice/fill/judge，是否需支持 multi_choice/short_answer | 本批不扩展，严格按现有题型 |
| QuestionGenerationPlan | 是否保留此表供旧数据查询 | Unified 不创建新记录，旧记录保留 |

---

## 附录 A：关键文件索引

| 用途 | 绝对路径 | 关键行号 |
|------|---------|---------|
| AI Quiz 入口 Controller | `src/modules/ai-runtime/user-ai.controller.ts` | `:63` createAnalysisJob, `:85` publishQuiz, `:134` getQuiz, `:139` getQuizQuestions |
| AI Quiz 入口 Service | `src/modules/ai-runtime/user-ai.service.ts` | `:199` createAnalysisJob, `:374` publishQuiz, `:533` getQuiz, `:546` getQuizQuestions |
| AI Job DTO | `src/modules/ai-runtime/user-ai.dto.ts` | `:67` VALID_JOB_TYPES, `:74` CreateAnalysisJobDto |
| Runtime Internal Controller | `src/modules/ai-runtime/internal/runtime-internal.controller.ts` | `:22` pollJobs, `:58` submitResult |
| Runtime Internal Service | `src/modules/ai-runtime/internal/runtime-internal.service.ts` | `:22` pollJobs, `:220` submitResult, `:367` quiz persisting, `:435` convertQuizCandidates |
| Job Config | `src/modules/ai-runtime/user-ai.service.ts` | `:15-17` JOB_TYPE_CONFIG (quiz_gen_v1/quiz_output_v1) |
| Procedural Quiz Controller | `src/modules/quiz/quiz.controller.ts` | `:12` create, `:24` findOne |
| Procedural Quiz Service | `src/modules/quiz/quiz.service.ts` | `:8` create, `:38` 题型 logic |
| Quiz Prisma Model | `prisma/schema.prisma` | `:1833-1853` Quiz, `:1855-1871` QuizQuestion |
| QuizAttempt Model | `prisma/schema.prisma` | `:1873-1889` |
| QuizAnswer Model | `prisma/schema.prisma` | `:1891-1904` |
| QuestionGenerationPlan | `prisma/schema.prisma` | `:2243-2260` |
| Snapshot Builder | `src/modules/ai-runtime/snapshot-builder.service.ts` | `:379` quizAttempt data |
| Model Router | `src/modules/ai/model-router.ts` | `:18-22` cheap tier, `:24-28` primary tier |
| Prompt Template Service | `src/modules/ai/prompts/prompt-template.service.ts` | 无 quiz prompt（需新增） |

## 附录 B：题型对比

| 维度 | Procedural Quiz | AI Quiz (Legacy) | AI Quiz (Unified) |
|------|----------------|-----------------|-------------------|
| 生成方式 | 随机抽取+交替类型 | Runtime AI 调用 | AiGateway AI 调用 |
| 题型 | choice/fill/judge | Runtime 决定 | choice/fill/judge（Validator 限制） |
| 选项 | 其他 item 内容截断 | AI 生成 | AI 生成 |
| 答案 | 正确选项索引/关键词 | AI 生成 | AI 生成 |
| 解析 | item.content 截断 | AI 生成 | AI 生成 |
| 状态 | 直接可用 | ready→publish→active | 同 Legacy 或直接 active |
