From a17aba972b2f51116cd4282b2127a298d8df8bc0 Mon Sep 17 00:00:00 2001 From: wangdl Date: Mon, 22 Jun 2026 20:29:57 +0800 Subject: [PATCH] docs: M-AI-07-01 quiz generation migration contract Audit complete: 2 quiz systems (procedural + AI Runtime), 4 data models, 3 question types (choice/fill/judge), external Runtime polling architecture. Identified 7 anomalies including answer leak (P0) and missing prompt/schema. Co-Authored-By: Claude --- ...i-07-quiz-generation-migration-contract.md | 585 ++++++++++++++++++ 1 file changed, 585 insertions(+) create mode 100644 docs/architecture/m-ai-07-quiz-generation-migration-contract.md diff --git a/docs/architecture/m-ai-07-quiz-generation-migration-contract.md b/docs/architecture/m-ai-07-quiz-generation-migration-contract.md new file mode 100644 index 0000000..bd25ed3 --- /dev/null +++ b/docs/architecture/m-ai-07-quiz-generation-migration-contract.md @@ -0,0 +1,585 @@ +# 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` 优先使用客户端传入的 `idempotencyKey` +- 回退:`::` +- Db 级唯一约束:`AiJob.@@unique([userId, jobType, idempotencyKey])` + +### 9.2 投影级幂等 + +```text +Quiz: deterministic ID = "qz_" +Question: deterministic ID = "qq__" +``` + +如 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 |