# 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 |