M-AI-07 Quiz 生成与题目产物迁移契约
审计日期:2026-06-22
契约状态:冻结(待 M-AI-07-01 验收)
对应里程碑:M-AI-07 Quiz 生成与题目产物统一引擎迁移
目录
- 当前链路审计
- 目标链路
- 数据模型
- 题型规则
- Snapshot Schema
- Output Schema
- 副作用矩阵
- Artifact 矩阵
- 幂等契约
- 权限模型
- 答案隐藏契约
- Feature Flag
- 回滚流程
- 架构异常与不确定项
1. 当前链路审计
1.1 当前时序(Legacy Runtime 轮询路径)
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
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 路径)
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 支持题型(冻结)
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 必须包含
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 输出结构(冻结)
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 请求级幂等
idempotencyKey = "quiz-generation:<submissionId>"
submissionId 优先使用客户端传入的 idempotencyKey
- 回退:
<targetType>:<targetId>:<contentHash>
- Db 级唯一约束:
AiJob.@@unique([userId, jobType, idempotencyKey])
9.2 投影级幂等
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
Flag Name: QUIZ_GENERATION_ENGINE_MODE
Values: legacy | unified
Default: legacy
| 模式 |
行为 |
legacy |
UserAiService.createAnalysisJob() → aiRuntimeJob → Runtime 轮询 |
unified |
QuizGenerationSnapshotBuilder → AiJobCreationService → BullMQ → Engine |
13. 回滚流程
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 |