api-server/src/modules/ai-job/quiz-generation-projector.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

175 lines
6.3 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 { 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 → 直接返回
* - ArtifactupsertArtifact + 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;
}
}