import { Injectable, Logger } from '@nestjs/common'; import type { QuizGenerationResult } from '../ai/prompts/schemas/quiz-generation.schema'; import { BusinessValidationError, ReferenceValidationError } from './active-recall-validator'; import { VALID_QUESTION_TYPES, isValidJudgeValue } from '../quiz/quiz-type.contract'; export { BusinessValidationError, ReferenceValidationError }; /** * M-AI-07-03 / M-QUIZ-01-04: Quiz Generation Business Validator * * 验证 AI 输出符合业务约束(使用共享契约 quiz-type.contract)。 */ const CODE_BLOCK_PATTERN = /```(?:json|javascript|js|python)?[\s\S]*?```/; const MODEL_INSTRUCTION_PATTERNS = [ /^here\s+(is|are)\s+(the|your)\s/i, /^(certainly|sure|of course)[,;!\s]/i, /^请根据/, /^以下是/, ]; @Injectable() export class QuizGenerationValidator { private readonly logger = new Logger(QuizGenerationValidator.name); validate(output: QuizGenerationResult): void { const violations: string[] = []; // quizTitle if (!output.quizTitle || typeof output.quizTitle !== 'string' || output.quizTitle.trim().length === 0) { violations.push('quizTitle is required and must be non-empty'); } else if (output.quizTitle.length > 255) { violations.push(`quizTitle length ${output.quizTitle.length} exceeds 255`); } // questions array if (!Array.isArray(output.questions)) { violations.push('questions must be an array'); throw new BusinessValidationError('Business validation failed', violations); } if (output.questions.length === 0) { violations.push('questions must not be empty'); } if (output.questions.length > 50) { violations.push(`questions count ${output.questions.length} exceeds max 50`); } const seenStems = new Set(); for (let i = 0; i < output.questions.length; i++) { const q = output.questions[i]; // type(M-QUIZ-01-04: 使用共享契约枚举) if (!q.type || !VALID_QUESTION_TYPES.includes(q.type as any)) { violations.push(`questions[${i}].type "${q.type}" invalid, must be: ${VALID_QUESTION_TYPES.join(', ')}`); } // stem if (!q.stem || typeof q.stem !== 'string' || q.stem.trim().length === 0) { violations.push(`questions[${i}].stem is required`); } else if (q.stem.length > 2000) { violations.push(`questions[${i}].stem length ${q.stem.length} exceeds 2000`); } // answer if (!q.answer || typeof q.answer !== 'string' || q.answer.trim().length === 0) { violations.push(`questions[${i}].answer is required`); } // explanation if (!q.explanation || typeof q.explanation !== 'string' || q.explanation.trim().length === 0) { violations.push(`questions[${i}].explanation is required`); } // per-type validation if (q.type === 'choice') { if (!Array.isArray(q.options) || q.options.length < 2) { violations.push(`questions[${i}] choice requires >= 2 options`); } else { // answer must be a valid option index const idx = parseInt(q.answer, 10); if (isNaN(idx) || idx < 0 || idx >= q.options.length) { violations.push(`questions[${i}] answer "${q.answer}" is not a valid option index (0-${q.options.length - 1})`); } // duplicate options const uniqueOpts = new Set(q.options); if (uniqueOpts.size !== q.options.length) { violations.push(`questions[${i}] has duplicate options`); } } } if (q.type === 'judge') { // M-QUIZ-01-04: 使用共享契约校验 judge 值 if (!isValidJudgeValue(q.answer)) { violations.push(`questions[${i}] judge answer must be "true" or "false" (string), got "${q.answer}" (${typeof q.answer})`); } } // duplicate stem if (q.stem) { const dedupeKey = q.stem.trim().toLowerCase(); if (seenStems.has(dedupeKey)) { violations.push(`questions[${i}] stem is duplicate`); } seenStems.add(dedupeKey); } // model artifacts this.checkModelArtifacts(q.stem, `questions[${i}].stem`, violations); this.checkModelArtifacts(q.explanation, `questions[${i}].explanation`, violations); } if (violations.length > 0) { this.logger.warn(`Quiz validation failed: ${violations.length} violation(s): ${violations.join('; ')}`); throw new BusinessValidationError(`Business validation failed: ${violations.length} violation(s)`, violations); } this.logger.log(`Quiz validation passed: ${output.questions.length} questions`); } private checkModelArtifacts(text: string | undefined, fieldName: string, violations: string[]): void { if (!text) return; if (CODE_BLOCK_PATTERN.test(text)) { violations.push(`${fieldName} contains code block`); } for (const pattern of MODEL_INSTRUCTION_PATTERNS) { if (pattern.test(text.trim())) { violations.push(`${fieldName} contains model instruction`); break; } } } }