api-server/src/modules/ai-job/quiz-generation-validator.ts
wangdl f1eb99b6fa
Some checks failed
Deploy API Server / build-and-unit (push) Failing after 42s
Deploy API Server / current-integration (push) Has been skipped
Deploy API Server / backward-compat (push) Has been skipped
Deploy API Server / deploy (push) Has been skipped
fix(quiz): complete cross-platform attempt and question type contract
- 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>
2026-06-24 19:55:05 +08:00

134 lines
5.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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];
// typeM-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;
}
}
}
}