api-server/docs/architecture/m-ai-07-quiz-generation-migration-contract.md
wangdl a17aba972b
All checks were successful
Deploy API Server / build-and-unit (push) Successful in 36s
Deploy API Server / current-integration (push) Successful in 30s
Deploy API Server / backward-compat (push) Successful in 0s
Deploy API Server / deploy (push) Successful in 1m5s
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 <noreply@anthropic.com>
2026-06-22 20:29:57 +08:00

25 KiB
Raw Blame History

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

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


目录

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

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 调用 (程序化生成) 外部 RuntimeAPI 不管理 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 中包含 answerexplanation 字段。任何已认证用户可在未开始答题前通过此接口获取正确答案。

Procedural quizquiz.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.tsquiz-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 状态 readypublishactive 两阶段 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/judgeValidator 限制)
选项 其他 item 内容截断 AI 生成 AI 生成
答案 正确选项索引/关键词 AI 生成 AI 生成
解析 item.content 截断 AI 生成 AI 生成
状态 直接可用 ready→publish→active 同 Legacy 或直接 active