import { Injectable, Logger } from '@nestjs/common'; import type { Prisma } from '@prisma/client'; import { isValidQuestionType } from '../quiz/quiz-type.contract'; import { ResultProjector, ProjectionContext, ArtifactReference, } from './result-projector.interface'; /** * M-AI-07-04: Quiz Generation Result Projector * * 将验证后的 Quiz AI 输出原子投影到现有业务模型。 * * 契约依据:docs/architecture/m-ai-07-quiz-generation-migration-contract.md §7, §8 * * 在 Prisma Transaction 内与 AiJobArtifact + markSucceeded 共享事务: * 1. Quiz — 测验记录(sourceType='ai', status='ready') * 2. QuizQuestion × N — 题目记录(type/stem/options/answer/explanation) * 3. AiJobArtifact — Quiz 引用 * * 幂等策略: * - 入口幂等:检查已有 Artifact → 直接返回 * - Artifact:upsertArtifact + P2002 catch * - Engine CAS 保证无并发写入同一 jobId */ @Injectable() export class QuizGenerationProjector implements ResultProjector { readonly key = 'quiz_generation_projector'; private readonly logger = new Logger(QuizGenerationProjector.name); async project( tx: Prisma.TransactionClient, context: ProjectionContext, ): Promise { const { job, validatedOutput } = context; let ordinal = 0; // ── 入口幂等:已有 Artifact → 直接返回 ── const existingArtifacts = await tx.aiJobArtifact.findMany({ where: { jobId: job.id }, orderBy: { ordinal: 'asc' }, }); if (existingArtifacts.length > 0) { this.logger.log( `Quiz Projector: returning ${existingArtifacts.length} existing artifact(s) for job=${job.id}`, ); return existingArtifacts.map((a) => ({ artifactType: a.artifactType, artifactId: a.artifactId, ordinal: a.ordinal, })); } const artifacts: ArtifactReference[] = []; const output = validatedOutput as any; // ═════════════════════════════════════════════════════════ // 1. Quiz // ═════════════════════════════════════════════════════════ const questions = Array.isArray(output.questions) ? output.questions : []; // M-QUIZ-01-CLEANUP-01: 写数据库前全量校验题型合法性 // 任一非法 → 拒绝整个 Quiz(原子回滚,Job 不得 succeeded) const illegalTypes: Array<{ index: number; type: unknown }> = []; for (let i = 0; i < questions.length; i++) { if (!isValidQuestionType(questions[i].type)) { illegalTypes.push({ index: i, type: questions[i].type }); } } if (illegalTypes.length > 0) { const details = illegalTypes.map(t => `questions[${t.index}].type="${t.type}"`).join(', '); this.logger.error( `Quiz Projector: rejecting entire quiz for job=${job.id} — ` + `${illegalTypes.length} illegal type(s): ${details}` + `promptVersion=${job.promptVersion} outputSchemaVersion=${(job as any).outputSchemaVersion}`, ); throw new Error( `QUIZ_INVALID_QUESTION_TYPE: ${illegalTypes.length} question(s) have invalid type. ` + `Allowed: choice, fill, judge. Got: ${details}`, ); } const validQuestions = questions.filter( (q: any) => q.stem && q.answer, ); if (validQuestions.length === 0) { this.logger.warn(`Quiz Projector: no valid questions for job=${job.id}`); return artifacts; // empty — no Quiz created } const quiz = await tx.quiz.create({ data: { userId: job.userId, knowledgeBaseId: job.targetId ?? 'unknown', title: output.quizTitle ?? 'AI Generated Quiz', description: output.quizDescription ?? null, questionCount: validQuestions.length, sourceType: 'ai', sourceId: job.id, status: 'ready', }, }); // Quiz Artifact await upsertArtifact(tx, job.id, 'Quiz', quiz.id, ordinal); artifacts.push({ artifactType: 'Quiz', artifactId: quiz.id, ordinal: ordinal++, }); this.logger.log( `Quiz Projector: Quiz ${quiz.id} created for job=${job.id} with ${validQuestions.length} questions`, ); // ═════════════════════════════════════════════════════════ // 2. QuizQuestion × N // ═════════════════════════════════════════════════════════ for (let i = 0; i < validQuestions.length; i++) { const q = validQuestions[i]; await tx.quizQuestion.create({ data: { quizId: quiz.id, // M-QUIZ-01-CLEANUP-01: type 已在全量预检中验证,此处必为合法值 type: q.type, stem: q.stem, options: Array.isArray(q.options) ? q.options : undefined, answer: q.answer, explanation: q.explanation ?? null, sourceBlockIds: Array.isArray(q.sourceBlockIds) ? q.sourceBlockIds : undefined, orderIndex: i, }, }); } this.logger.log( `Quiz Projector: ${validQuestions.length} Question(s) written for quiz=${quiz.id}`, ); // ═════════════════════════════════════════════════════════ // 完成 // ═════════════════════════════════════════════════════════ this.logger.log( `Quiz Projector: ${artifacts.length} artifact(s) total for job=${job.id}`, ); return artifacts; } } // ── Helpers ── async function upsertArtifact( tx: Prisma.TransactionClient, jobId: string, artifactType: string, artifactId: string, ordinal: number, ): Promise { try { await tx.aiJobArtifact.create({ data: { jobId, artifactType, artifactId, ordinal }, }); } catch (err: any) { if (err?.code === 'P2002') return; throw err; } }