- Add attempt lifecycle with per-attempt answer visibility (#336) - Add atomic submission with updateMany CAS + $transaction (#337) - Add iOS attempt-based question loading + local answer cache (#338) - Freeze choice/fill/judge cross-platform contract with fill grading (#339) - Add per-question-type learning analysis accuracyByQuestionType (#340) - Add GATE E2E test suite with 17 scenarios (#341) - CLEANUP-01: Remove illegal type → choice silent degradation - CLEANUP-02: Integrate QuizQuestionType enum into iOS quiz views - CLEANUP-03: Test verification, git delivery, targeted gate review Co-Authored-By: Claude <noreply@anthropic.com>
134 lines
5.0 KiB
TypeScript
134 lines
5.0 KiB
TypeScript
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<string>();
|
||
|
||
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;
|
||
}
|
||
}
|
||
}
|
||
}
|