import { Injectable, Logger, NotFoundException, ForbiddenException, BadRequestException } from '@nestjs/common'; import * as crypto from 'crypto'; import { PrismaService } from '../../infrastructure/database/prisma.service'; import { JobDefinitionRegistry } from './job-definition-registry'; /** * M-AI-07-02: Quiz Generation Snapshot Builder * * 为统一 Job Engine 构建 Quiz 生成输入快照。 * * 契约依据:docs/architecture/m-ai-07-quiz-generation-migration-contract.md §5 * * 职责: * 1. 校验 knowledgeBase 存在且属于当前用户 * 2. 加载知识点列表(限制数量 + 截断内容长度) * 3. 规范化 questionCount / questionTypes / difficultyLevel * 4. 从 JobDefinitionRegistry 读取 prompt/model 配置 * 5. 构建版本化、最小化、脱敏的快照 * 6. 计算稳定的 contentHash * * 禁止: * - 存储 JWT / API Key / Cookie / PII * - 存储完整知识库未筛选内容 * - 存储其他用户内容 * - 将随机值或时间加入 hash * * Snapshot Schema(quiz-generation-v1): * userId, targetType, targetId, submissionId, * knowledgeBaseTitle/Description, knowledgeItems (truncated), * questionCount, questionTypes, difficultyLevel, knowledgePointIds, * promptKey, promptVersion, modelTier, * inputSchemaVersion, outputSchemaVersion, createdAt */ const SNAPSHOT_SCHEMA_VERSION = 'quiz-generation-v1'; /** 允许的题型(契约 §4.1) */ const VALID_QUESTION_TYPES = ['choice', 'fill', 'judge'] as const; /** 允许的难度级别 */ const VALID_DIFFICULTY_LEVELS = ['easy', 'medium', 'hard'] as const; /** 题目数量范围 */ const MIN_QUESTION_COUNT = 1; const MAX_QUESTION_COUNT = 50; /** 知识点内容截断长度 */ const MAX_ITEM_CONTENT_LENGTH = 2000; /** 知识点最大加载数量 */ const MAX_KNOWLEDGE_ITEMS = 50; export interface QuizGenerationSnapshot { schemaVersion: string; snapshot: { userId: string; targetType: string; targetId: string; submissionId: string; knowledgeBaseTitle: string; knowledgeBaseDescription: string | null; knowledgeItems: Array<{ id: string; title: string; content: string; // truncated to MAX_ITEM_CONTENT_LENGTH summary: string; }>; questionCount: number; questionTypes: string[]; difficultyLevel: string; knowledgePointIds: string[]; promptKey: string; promptVersion: string; modelTier: string; inputSchemaVersion: string; outputSchemaVersion: string; createdAt: string; // ISO8601 normalized to second }; } export interface QuizGenerationSnapshotInput { userId: string; targetType: string; targetId: string; submissionId: string; questionCount?: number; questionTypes?: string[]; difficultyLevel?: string; knowledgePointIds?: string[]; } @Injectable() export class QuizGenerationSnapshotBuilder { private readonly logger = new Logger(QuizGenerationSnapshotBuilder.name); constructor( private readonly prisma: PrismaService, private readonly registry: JobDefinitionRegistry, ) {} /** * 构建 Quiz 生成输入快照。 * * @param input - 快照构建参数 * @returns 版本化、脱敏的快照对象 * * @throws NotFoundException knowledgeBase 不存在 * @throws ForbiddenException knowledgeBase 不属于当前用户 * @throws BadRequestException 参数非法 */ async build(input: QuizGenerationSnapshotInput): Promise { // 1. 从 Registry 读取配置(单一事实来源) const def = this.registry.get('quiz_generation'); // 2. 校验 targetType if (input.targetType !== 'knowledge_base') { throw new BadRequestException( `quiz_generation requires targetType="knowledge_base", got "${input.targetType}"`, ); } // 3. 规范化参数(在 DB 查询前,fail-fast) const questionCount = this.normalizeQuestionCount(input.questionCount); const questionTypes = this.normalizeQuestionTypes(input.questionTypes); const difficultyLevel = this.normalizeDifficultyLevel(input.difficultyLevel); const knowledgePointIds = input.knowledgePointIds ?? []; // 4. 加载并校验 knowledgeBase const kb = await this.prisma.knowledgeBase.findUnique({ where: { id: input.targetId }, }); if (!kb || kb.deletedAt) { throw new NotFoundException(`KnowledgeBase ${input.targetId} not found`); } if (kb.userId !== input.userId) { throw new ForbiddenException( `KnowledgeBase ${input.targetId} does not belong to user ${input.userId}`, ); } // 5. 加载知识点(限制数量 + 截断内容) const where: any = { knowledgeBaseId: input.targetId, deletedAt: null, status: 'active', }; if (knowledgePointIds.length > 0) { where.id = { in: knowledgePointIds }; } const items = await this.prisma.knowledgeItem.findMany({ where, select: { id: true, title: true, content: true, summary: true, }, orderBy: { orderIndex: 'asc' }, take: MAX_KNOWLEDGE_ITEMS, }); if (items.length === 0) { throw new BadRequestException( 'Knowledge base has no active knowledge items', ); } // 截断内容(最小化快照) const knowledgeItems = items.map((item) => ({ id: item.id, title: item.title, content: (item.content ?? '').slice(0, MAX_ITEM_CONTENT_LENGTH), summary: item.summary ?? '', })); // 6. 构建快照 const now = new Date(); const snapshot: QuizGenerationSnapshot = { schemaVersion: SNAPSHOT_SCHEMA_VERSION, snapshot: { userId: input.userId, targetType: input.targetType, targetId: input.targetId, submissionId: input.submissionId, knowledgeBaseTitle: kb.title, knowledgeBaseDescription: kb.description ?? null, knowledgeItems, questionCount, questionTypes, difficultyLevel, knowledgePointIds, promptKey: def.prompt.promptKey, promptVersion: def.prompt.promptVersion, modelTier: def.model.modelTier, inputSchemaVersion: SNAPSHOT_SCHEMA_VERSION, outputSchemaVersion: def.output.schemaVersion, createdAt: now.toISOString().replace(/\.\d{3}Z$/, 'Z'), }, }; this.logger.log( `Built Quiz Generation snapshot for kb=${input.targetId} ` + `userId=${input.userId} items=${knowledgeItems.length} ` + `questionCount=${questionCount} types=${questionTypes.join(',')} ` + `difficulty=${difficultyLevel}`, ); return snapshot; } /** * 计算快照的 contentHash(SHA256 前 16 字符)。 */ computeHash(snapshot: QuizGenerationSnapshot): string { const serialized = JSON.stringify( snapshot.snapshot, Object.keys(snapshot.snapshot).sort(), ); return crypto .createHash('sha256') .update(serialized) .digest('hex') .substring(0, 16); } // ── Private Helpers ── private normalizeQuestionCount(raw?: number): number { if (raw == null) return 5; // default if (!Number.isInteger(raw) || raw < MIN_QUESTION_COUNT) { throw new BadRequestException( `questionCount must be >= ${MIN_QUESTION_COUNT}, got ${raw}`, ); } if (raw > MAX_QUESTION_COUNT) { throw new BadRequestException( `questionCount must be <= ${MAX_QUESTION_COUNT}, got ${raw}`, ); } return raw; } private normalizeQuestionTypes(raw?: string[]): string[] { if (!raw || raw.length === 0) { return ['choice', 'fill', 'judge']; // default: all types } const invalid = raw.filter((t) => !(VALID_QUESTION_TYPES as readonly string[]).includes(t)); if (invalid.length > 0) { throw new BadRequestException( `Invalid question types: ${invalid.join(', ')}. ` + `Allowed: ${VALID_QUESTION_TYPES.join(', ')}`, ); } return [...new Set(raw)]; // dedup } private normalizeDifficultyLevel(raw?: string): string { if (!raw) return 'medium'; if (!(VALID_DIFFICULTY_LEVELS as readonly string[]).includes(raw)) { throw new BadRequestException( `Invalid difficultyLevel "${raw}". ` + `Allowed: ${VALID_DIFFICULTY_LEVELS.join(', ')}`, ); } return raw; } }