- 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>
175 lines
6.3 KiB
TypeScript
175 lines
6.3 KiB
TypeScript
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<ArtifactReference[]> {
|
||
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<void> {
|
||
try {
|
||
await tx.aiJobArtifact.create({
|
||
data: { jobId, artifactType, artifactId, ordinal },
|
||
});
|
||
} catch (err: any) {
|
||
if (err?.code === 'P2002') return;
|
||
throw err;
|
||
}
|
||
}
|