diff --git a/src/modules/ai-job/ai-job.module.ts b/src/modules/ai-job/ai-job.module.ts index 6653a16..08949ff 100644 --- a/src/modules/ai-job/ai-job.module.ts +++ b/src/modules/ai-job/ai-job.module.ts @@ -34,6 +34,8 @@ import { ReviewCardGenerationSnapshotBuilder } from './review-card-generation-sn import { ReviewCardGenerationExecutor } from './review-card-generation-executor'; import { ReviewCardGenerationValidator } from './review-card-generation-validator'; import { ReviewCardGenerationProjector } from './review-card-generation-projector'; +import { QuizGenerationRegistrationService } from './quiz-generation-registration.service'; +import { QuizGenerationSnapshotBuilder } from './quiz-generation-snapshot-builder'; import { FeynmanBusinessValidator, FeynmanReferenceValidator, @@ -76,6 +78,8 @@ import { AppConfigModule } from '../config/config.module'; ReviewCardGenerationExecutor, ReviewCardGenerationValidator, ReviewCardGenerationProjector, + QuizGenerationRegistrationService, + QuizGenerationSnapshotBuilder, { provide: RESULT_PROJECTORS, useFactory: (synthetic: SyntheticResultProjector, activeRecall: ActiveRecallProjector, feynman: FeynmanProjector, reviewCard: ReviewCardGenerationProjector) => [synthetic, activeRecall, feynman, reviewCard], inject: [SyntheticResultProjector, ActiveRecallProjector, FeynmanProjector, ReviewCardGenerationProjector] } as any, { provide: AI_JOB_EXECUTION_ENGINE, useExisting: AiJobExecutionEngineImpl }, ], diff --git a/src/modules/ai-job/quiz-generation-job-definition.ts b/src/modules/ai-job/quiz-generation-job-definition.ts new file mode 100644 index 0000000..71e7e1c --- /dev/null +++ b/src/modules/ai-job/quiz-generation-job-definition.ts @@ -0,0 +1,76 @@ +import type { JobDefinition } from './job-definition.types'; + +/** + * M-AI-07-02: Quiz Generation Job Definition(冻结) + * + * 对应迁移契约 docs/architecture/m-ai-07-quiz-generation-migration-contract.md + * + * 字段取值来源: + * - promptKey: quiz-generation(契约 §5.1 — 当前由外部 Runtime 管理,M-AI-07-03 内联) + * - modelTier: primary(契约 A5 — 从硬编码 deepseek-chat 迁移到 ModelRouter) + * - queueName: ai-interactive(Quiz 生成为用户交互请求) + * - input.schemaVersion: quiz-generation-v1 + * - output.schemaVersion: quiz-generation-v1 + * - timeoutMs: 180000(3min,生成题目比评估更耗时) + * - maxRetries: 3 + * - domain: generation + */ + +export const QUIZ_GENERATION_JOB_DEFINITION: JobDefinition = { + jobType: 'quiz_generation', + + metadata: { + label: 'Quiz Generation', + description: + 'Generate quiz questions from knowledge base content. ' + + 'Creates choice, fill-in-the-blank, and true/false questions ' + + 'with options, answers, and explanations.', + domain: 'generation', + version: '1.0.0', + }, + + queue: { + queueName: 'ai-interactive', + defaultPriority: 0, + }, + + execution: { + timeoutMs: 180_000, + maxRetries: 3, + retryBackoff: { type: 'exponential', delay: 1000 }, + cancellable: true, + abortStrategy: 'fail', + }, + + input: { + schemaVersion: 'quiz-generation-v1', + }, + + output: { + schemaVersion: 'quiz-generation-v1', + }, + + prompt: { + promptKey: 'quiz-generation', + promptVersion: '1.0.0', + }, + + model: { + modelTier: 'primary', + modelProvider: 'deepseek', + modelName: 'deepseek-v4-pro', // ModelRouter primary tier (model-router.ts:25) + maxTokens: 4096, + }, + + credential: { + allowedModes: ['platform_key'], + defaultMode: 'platform_key', + }, + + projectorKey: 'quiz_generation_projector', + + security: { + contentSafetyCheck: true, + outputRedaction: false, + }, +}; diff --git a/src/modules/ai-job/quiz-generation-registration.service.spec.ts b/src/modules/ai-job/quiz-generation-registration.service.spec.ts new file mode 100644 index 0000000..b25b117 --- /dev/null +++ b/src/modules/ai-job/quiz-generation-registration.service.spec.ts @@ -0,0 +1,294 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { NotFoundException, ForbiddenException, BadRequestException } from '@nestjs/common'; +import { JobDefinitionRegistry, DuplicateJobTypeError } from './job-definition-registry'; +import { QuizGenerationRegistrationService } from './quiz-generation-registration.service'; +import { QUIZ_GENERATION_JOB_DEFINITION } from './quiz-generation-job-definition'; +import { QuizGenerationSnapshotBuilder } from './quiz-generation-snapshot-builder'; +import { PrismaService } from '../../infrastructure/database/prisma.service'; + +// ═══════════════════════════════════════════════════════════════════════════ +// QuizGenerationRegistrationService +// ═══════════════════════════════════════════════════════════════════════════ + +describe('QuizGenerationRegistrationService', () => { + let registry: JobDefinitionRegistry; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [JobDefinitionRegistry, QuizGenerationRegistrationService], + }).compile(); + registry = module.get(JobDefinitionRegistry); + }); + + describe('Definition 注册', () => { + it('Registry 注册成功', async () => { + const module = await Test.createTestingModule({ + providers: [JobDefinitionRegistry, QuizGenerationRegistrationService], + }).compile(); + await module.init(); + + const reg = module.get(JobDefinitionRegistry); + const def = reg.get('quiz_generation'); + expect(def).toBeDefined(); + expect(def.jobType).toBe('quiz_generation'); + expect(def.queue.queueName).toBe('ai-interactive'); + expect(def.metadata.domain).toBe('generation'); + }); + + it('重复注册失败', async () => { + const module = await Test.createTestingModule({ + providers: [JobDefinitionRegistry, QuizGenerationRegistrationService], + }).compile(); + await module.init(); + const reg = module.get(JobDefinitionRegistry); + expect(() => reg.register(QUIZ_GENERATION_JOB_DEFINITION)).toThrow(DuplicateJobTypeError); + }); + }); + + describe('Definition 字段验证', () => { + it('jobType 格式合法', () => { + expect(QUIZ_GENERATION_JOB_DEFINITION.jobType).toMatch(/^[a-z][a-z0-9_]{1,63}$/); + }); + it('queueName = ai-interactive', () => { + expect(QUIZ_GENERATION_JOB_DEFINITION.queue.queueName).toBe('ai-interactive'); + }); + it('modelTier = primary', () => { + expect(QUIZ_GENERATION_JOB_DEFINITION.model.modelTier).toBe('primary'); + }); + it('promptKey = quiz-generation', () => { + expect(QUIZ_GENERATION_JOB_DEFINITION.prompt.promptKey).toBe('quiz-generation'); + }); + it('timeoutMs in range', () => { + expect(QUIZ_GENERATION_JOB_DEFINITION.execution.timeoutMs).toBeGreaterThanOrEqual(1000); + expect(QUIZ_GENERATION_JOB_DEFINITION.execution.timeoutMs).toBeLessThanOrEqual(600000); + }); + it('projectorKey set', () => { + expect(QUIZ_GENERATION_JOB_DEFINITION.projectorKey).toBe('quiz_generation_projector'); + }); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// QuizGenerationSnapshotBuilder +// ═══════════════════════════════════════════════════════════════════════════ + +describe('QuizGenerationSnapshotBuilder', () => { + let builder: QuizGenerationSnapshotBuilder; + let prisma: any; + let registry: any; + + const mockKb = { + id: 'kb-001', + userId: 'u-001', + title: 'Test KB', + description: 'A test knowledge base', + deletedAt: null, + status: 'active', + }; + + const mockItems = [ + { id: 'ki-1', title: 'Item 1', content: 'Content of item 1', summary: 'Summary 1' }, + { id: 'ki-2', title: 'Item 2', content: 'Content of item 2', summary: 'Summary 2' }, + { id: 'ki-3', title: 'Item 3', content: 'C'.repeat(2500), summary: 'Summary 3' }, + ]; + + beforeEach(async () => { + prisma = { + knowledgeBase: { findUnique: jest.fn() }, + knowledgeItem: { findMany: jest.fn() }, + }; + registry = { get: jest.fn().mockReturnValue(QUIZ_GENERATION_JOB_DEFINITION) }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + QuizGenerationSnapshotBuilder, + { provide: PrismaService, useValue: prisma }, + { provide: JobDefinitionRegistry, useValue: registry }, + ], + }).compile(); + + builder = module.get(QuizGenerationSnapshotBuilder); + jest.spyOn(require('@nestjs/common').Logger.prototype, 'log').mockImplementation(() => {}); + }); + + describe('build', () => { + it('构建有效快照', async () => { + prisma.knowledgeBase.findUnique.mockResolvedValue(mockKb); + prisma.knowledgeItem.findMany.mockResolvedValue(mockItems); + + const snapshot = await builder.build({ + userId: 'u-001', + targetType: 'knowledge_base', + targetId: 'kb-001', + submissionId: 'sub-001', + questionCount: 3, + questionTypes: ['choice', 'judge'], + difficultyLevel: 'medium', + }); + + expect(snapshot.schemaVersion).toBe('quiz-generation-v1'); + expect(snapshot.snapshot.userId).toBe('u-001'); + expect(snapshot.snapshot.questionCount).toBe(3); + expect(snapshot.snapshot.questionTypes).toEqual(['choice', 'judge']); + expect(snapshot.snapshot.difficultyLevel).toBe('medium'); + expect(snapshot.snapshot.knowledgeItems).toHaveLength(3); + expect(snapshot.snapshot.promptKey).toBe('quiz-generation'); + expect(snapshot.snapshot.modelTier).toBe('primary'); + }); + + it('prompt/model 值来自 Definition(单一事实来源)', async () => { + prisma.knowledgeBase.findUnique.mockResolvedValue(mockKb); + prisma.knowledgeItem.findMany.mockResolvedValue([mockItems[0]]); + registry.get.mockReturnValue({ + ...QUIZ_GENERATION_JOB_DEFINITION, + prompt: { promptKey: 'custom-quiz', promptVersion: '2.0' }, + }); + + const snapshot = await builder.build({ + userId: 'u-001', + targetType: 'knowledge_base', + targetId: 'kb-001', + submissionId: 'sub-001', + }); + + expect(snapshot.snapshot.promptKey).toBe('custom-quiz'); + expect(snapshot.snapshot.promptVersion).toBe('2.0'); + }); + + it('默认值:questionCount=5, types=全类型, difficulty=medium', async () => { + prisma.knowledgeBase.findUnique.mockResolvedValue(mockKb); + prisma.knowledgeItem.findMany.mockResolvedValue([mockItems[0]]); + + const snapshot = await builder.build({ + userId: 'u-001', + targetType: 'knowledge_base', + targetId: 'kb-001', + submissionId: 'sub-001', + }); + + expect(snapshot.snapshot.questionCount).toBe(5); + expect(snapshot.snapshot.questionTypes).toEqual(['choice', 'fill', 'judge']); + expect(snapshot.snapshot.difficultyLevel).toBe('medium'); + }); + + it('content 截断到 2000 字符', async () => { + prisma.knowledgeBase.findUnique.mockResolvedValue(mockKb); + prisma.knowledgeItem.findMany.mockResolvedValue([mockItems[2]]); // 2500 chars + + const snapshot = await builder.build({ + userId: 'u-001', + targetType: 'knowledge_base', + targetId: 'kb-001', + submissionId: 'sub-001', + }); + + expect(snapshot.snapshot.knowledgeItems[0].content.length).toBeLessThanOrEqual(2000); + }); + + it('knowledgeBase 不存在 → NotFoundException', async () => { + prisma.knowledgeBase.findUnique.mockResolvedValue(null); + await expect( + builder.build({ userId: 'u-001', targetType: 'knowledge_base', targetId: 'kb-missing', submissionId: 'sub-001' }), + ).rejects.toThrow(NotFoundException); + }); + + it('knowledgeBase 已删除 → NotFoundException', async () => { + prisma.knowledgeBase.findUnique.mockResolvedValue({ ...mockKb, deletedAt: new Date() }); + await expect( + builder.build({ userId: 'u-001', targetType: 'knowledge_base', targetId: 'kb-001', submissionId: 'sub-001' }), + ).rejects.toThrow(NotFoundException); + }); + + it('跨用户拒绝 → ForbiddenException', async () => { + prisma.knowledgeBase.findUnique.mockResolvedValue({ ...mockKb, userId: 'u-other' }); + await expect( + builder.build({ userId: 'u-001', targetType: 'knowledge_base', targetId: 'kb-001', submissionId: 'sub-001' }), + ).rejects.toThrow(ForbiddenException); + }); + + it('非 knowledge_base targetType → BadRequestException', async () => { + await expect( + builder.build({ userId: 'u-001', targetType: 'knowledge_item', targetId: 'ki-1', submissionId: 'sub-001' }), + ).rejects.toThrow(BadRequestException); + }); + + it('非法 questionCount → BadRequestException', async () => { + await expect( + builder.build({ userId: 'u-001', targetType: 'knowledge_base', targetId: 'kb-001', submissionId: 'sub-001', questionCount: 0 }), + ).rejects.toThrow(BadRequestException); + await expect( + builder.build({ userId: 'u-001', targetType: 'knowledge_base', targetId: 'kb-001', submissionId: 'sub-001', questionCount: 51 }), + ).rejects.toThrow(BadRequestException); + }); + + it('非法 questionTypes → BadRequestException', async () => { + await expect( + builder.build({ userId: 'u-001', targetType: 'knowledge_base', targetId: 'kb-001', submissionId: 'sub-001', questionTypes: ['essay' as any] }), + ).rejects.toThrow(BadRequestException); + }); + + it('非法 difficultyLevel → BadRequestException', async () => { + await expect( + builder.build({ userId: 'u-001', targetType: 'knowledge_base', targetId: 'kb-001', submissionId: 'sub-001', difficultyLevel: 'extreme' }), + ).rejects.toThrow(BadRequestException); + }); + + it('空知识库 → BadRequestException', async () => { + prisma.knowledgeBase.findUnique.mockResolvedValue(mockKb); + prisma.knowledgeItem.findMany.mockResolvedValue([]); + await expect( + builder.build({ userId: 'u-001', targetType: 'knowledge_base', targetId: 'kb-001', submissionId: 'sub-001' }), + ).rejects.toThrow(BadRequestException); + }); + + it('Snapshot 不含敏感字段', async () => { + prisma.knowledgeBase.findUnique.mockResolvedValue(mockKb); + prisma.knowledgeItem.findMany.mockResolvedValue([mockItems[0]]); + + const snapshot = await builder.build({ + userId: 'u-001', targetType: 'knowledge_base', targetId: 'kb-001', submissionId: 'sub-001', + }); + + const serialized = JSON.stringify(snapshot); + expect(serialized).not.toContain('Authorization'); + expect(serialized).not.toContain('JWT'); + expect(serialized).not.toContain('apiKey'); + expect(serialized).not.toContain('api_key'); + expect(serialized).not.toContain('password'); + expect(serialized).not.toContain('DATABASE_URL'); + }); + }); + + describe('computeHash', () => { + it('相同输入 → 相同 hash', async () => { + prisma.knowledgeBase.findUnique.mockResolvedValue(mockKb); + prisma.knowledgeItem.findMany.mockResolvedValue([mockItems[0]]); + + const s1 = await builder.build({ userId: 'u-001', targetType: 'knowledge_base', targetId: 'kb-001', submissionId: 'sub-001' }); + const s2 = await builder.build({ userId: 'u-001', targetType: 'knowledge_base', targetId: 'kb-001', submissionId: 'sub-001' }); + + expect(builder.computeHash(s1)).toBe(builder.computeHash(s2)); + }); + + it('不同输入 → 不同 hash', async () => { + prisma.knowledgeBase.findUnique.mockResolvedValue(mockKb); + prisma.knowledgeItem.findMany.mockResolvedValueOnce([mockItems[0]]); + const s1 = await builder.build({ userId: 'u-001', targetType: 'knowledge_base', targetId: 'kb-001', submissionId: 'sub-001' }); + + prisma.knowledgeItem.findMany.mockResolvedValueOnce([mockItems[0], mockItems[1]]); + const s2 = await builder.build({ userId: 'u-001', targetType: 'knowledge_base', targetId: 'kb-001', submissionId: 'sub-002' }); + + expect(builder.computeHash(s1)).not.toBe(builder.computeHash(s2)); + }); + + it('hash 长度 16,hex 格式', async () => { + prisma.knowledgeBase.findUnique.mockResolvedValue(mockKb); + prisma.knowledgeItem.findMany.mockResolvedValue([mockItems[0]]); + const snapshot = await builder.build({ userId: 'u-001', targetType: 'knowledge_base', targetId: 'kb-001', submissionId: 'sub-001' }); + + const hash = builder.computeHash(snapshot); + expect(hash).toHaveLength(16); + expect(hash).toMatch(/^[0-9a-f]{16}$/); + }); + }); +}); diff --git a/src/modules/ai-job/quiz-generation-registration.service.ts b/src/modules/ai-job/quiz-generation-registration.service.ts new file mode 100644 index 0000000..8e9f4f8 --- /dev/null +++ b/src/modules/ai-job/quiz-generation-registration.service.ts @@ -0,0 +1,28 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { JobDefinitionRegistry, DuplicateJobTypeError } from './job-definition-registry'; +import { QUIZ_GENERATION_JOB_DEFINITION } from './quiz-generation-job-definition'; + +@Injectable() +export class QuizGenerationRegistrationService implements OnModuleInit { + private readonly logger = new Logger(QuizGenerationRegistrationService.name); + + constructor(private readonly registry: JobDefinitionRegistry) {} + + onModuleInit(): void { + try { + this.registry.register(QUIZ_GENERATION_JOB_DEFINITION); + this.logger.log( + `Quiz Generation Job Definition registered: ` + + `jobType="${QUIZ_GENERATION_JOB_DEFINITION.jobType}" ` + + `queue="${QUIZ_GENERATION_JOB_DEFINITION.queue.queueName}" ` + + `timeout=${QUIZ_GENERATION_JOB_DEFINITION.execution.timeoutMs}ms`, + ); + } catch (err: unknown) { + if (err instanceof DuplicateJobTypeError) { + this.logger.log('Quiz Generation Definition already registered (dual process)'); + } else { + throw err; + } + } + } +} diff --git a/src/modules/ai-job/quiz-generation-snapshot-builder.ts b/src/modules/ai-job/quiz-generation-snapshot-builder.ts new file mode 100644 index 0000000..fd3357d --- /dev/null +++ b/src/modules/ai-job/quiz-generation-snapshot-builder.ts @@ -0,0 +1,267 @@ +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; + } +}