feat(M-AI-07-02): quiz generation Job Definition + Snapshot Builder
All checks were successful
Deploy API Server / build-and-unit (push) Successful in 35s
Deploy API Server / current-integration (push) Successful in 3m3s
Deploy API Server / backward-compat (push) Successful in 0s
Deploy API Server / deploy (push) Successful in 1m4s

- quiz_generation Definition: ai-interactive, primary tier, 180s timeout
- Snapshot Builder: KB ownership validation, content truncation (2k chars)
- Parameter normalization: questionCount [1,50], types, difficulty
- Stable contentHash via sorted keys + SHA256

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
wangdl 2026-06-22 20:34:33 +08:00
parent a17aba972b
commit 9cac4781bd
5 changed files with 669 additions and 0 deletions

View File

@ -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 },
],

View File

@ -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-interactiveQuiz
* - input.schemaVersion: quiz-generation-v1
* - output.schemaVersion: quiz-generation-v1
* - timeoutMs: 1800003min
* - 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,
},
};

View File

@ -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 长度 16hex 格式', 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}$/);
});
});
});

View File

@ -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;
}
}
}
}

View File

@ -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 Schemaquiz-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<QuizGenerationSnapshot> {
// 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;
}
/**
* contentHashSHA256 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;
}
}