feat(M-AI-07-04): quiz generation Projector + atomic idempotency
Some checks failed
Deploy API Server / build-and-unit (push) Successful in 36s
Deploy API Server / backward-compat (push) Has been cancelled
Deploy API Server / deploy (push) Has been cancelled
Deploy API Server / current-integration (push) Has been cancelled

- QuizGenerationProjector: Quiz + QuizQuestion x N + Artifact in one tx
- Entry idempotency via Artifact check + P2002 fallback
- Quiz fields: sourceType=ai, sourceId=job.id, status=ready
- Invalid questions filtered (missing stem/answer)
- Registered in RESULT_PROJECTORS factory

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
wangdl 2026-06-22 20:43:55 +08:00
parent d368f1a97f
commit a59446266a
3 changed files with 302 additions and 1 deletions

View File

@ -38,6 +38,7 @@ import { QuizGenerationRegistrationService } from './quiz-generation-registratio
import { QuizGenerationSnapshotBuilder } from './quiz-generation-snapshot-builder';
import { QuizGenerationExecutor } from './quiz-generation-executor';
import { QuizGenerationValidator } from './quiz-generation-validator';
import { QuizGenerationProjector } from './quiz-generation-projector';
import {
FeynmanBusinessValidator,
FeynmanReferenceValidator,
@ -84,7 +85,8 @@ import { AppConfigModule } from '../config/config.module';
QuizGenerationSnapshotBuilder,
QuizGenerationExecutor,
QuizGenerationValidator,
{ provide: RESULT_PROJECTORS, useFactory: (synthetic: SyntheticResultProjector, activeRecall: ActiveRecallProjector, feynman: FeynmanProjector, reviewCard: ReviewCardGenerationProjector) => [synthetic, activeRecall, feynman, reviewCard], inject: [SyntheticResultProjector, ActiveRecallProjector, FeynmanProjector, ReviewCardGenerationProjector] } as any,
QuizGenerationProjector,
{ provide: RESULT_PROJECTORS, useFactory: (synthetic: SyntheticResultProjector, activeRecall: ActiveRecallProjector, feynman: FeynmanProjector, reviewCard: ReviewCardGenerationProjector, quiz: QuizGenerationProjector) => [synthetic, activeRecall, feynman, reviewCard, quiz], inject: [SyntheticResultProjector, ActiveRecallProjector, FeynmanProjector, ReviewCardGenerationProjector, QuizGenerationProjector] } as any,
{ provide: AI_JOB_EXECUTION_ENGINE, useExisting: AiJobExecutionEngineImpl },
],
exports: [

View File

@ -0,0 +1,149 @@
import { QuizGenerationProjector } from './quiz-generation-projector';
import type { ProjectionContext } from './result-projector.interface';
function makeTx(): any {
const store: Record<string, any[]> = {
aiJobArtifact: [],
quiz: [],
quizQuestion: [],
};
return {
aiJobArtifact: {
findMany: jest.fn(async (args: any) =>
store.aiJobArtifact.filter(a => a.jobId === args.where.jobId)),
create: jest.fn(async (args: any) => {
const dup = store.aiJobArtifact.find(
a => a.jobId === args.data.jobId && a.artifactType === args.data.artifactType && a.artifactId === args.data.artifactId,
);
if (dup) { const e: any = new Error('P2002'); e.code = 'P2002'; throw e; }
store.aiJobArtifact.push(args.data);
return args.data;
}),
},
quiz: {
create: jest.fn(async (args: any) => {
const q = { id: `qz_${store.quiz.length + 1}`, ...args.data };
store.quiz.push(q);
return q;
}),
},
quizQuestion: {
create: jest.fn(async (args: any) => {
const qq = { id: `qq_${store.quizQuestion.length + 1}`, ...args.data };
store.quizQuestion.push(qq);
return qq;
}),
},
};
}
function makeCtx(overrides?: any): ProjectionContext {
return {
job: { id: 'job-001', userId: 'u-001', jobType: 'quiz_generation', targetType: 'knowledge_base', targetId: 'kb-001', snapshotId: 'snap-001', promptVersion: '1.0.0', outputSchemaVersion: 'quiz-generation-v1' },
snapshot: { snapshot: { knowledgeBaseId: 'kb-001' } },
validatedOutput: {
quizTitle: 'Test Quiz',
quizDescription: 'A test',
questions: [
{ type: 'choice', stem: 'Q1?', options: ['A', 'B', 'C', 'D'], answer: '0', explanation: 'Because A', sourceBlockIds: ['ki-1'] },
{ type: 'judge', stem: 'Q2?', answer: 'true', explanation: 'Correct' },
{ type: 'fill', stem: 'Q3: fill the blank', answer: '42', explanation: 'Answer is 42' },
],
},
...overrides,
};
}
describe('QuizGenerationProjector', () => {
let projector: QuizGenerationProjector;
beforeEach(() => { projector = new QuizGenerationProjector(); });
it('key = quiz_generation_projector', () => {
expect(projector.key).toBe('quiz_generation_projector');
});
it('创建 Quiz + Questions + Artifact', async () => {
const tx = makeTx();
const artifacts = await projector.project(tx, makeCtx());
expect(artifacts).toHaveLength(1); // 1 Quiz artifact
expect(artifacts[0].artifactType).toBe('Quiz');
expect(tx.quiz.create).toHaveBeenCalledTimes(1);
expect(tx.quizQuestion.create).toHaveBeenCalledTimes(3);
// Quiz fields
const quizCall = tx.quiz.create.mock.calls[0][0].data;
expect(quizCall.userId).toBe('u-001');
expect(quizCall.knowledgeBaseId).toBe('kb-001');
expect(quizCall.sourceType).toBe('ai');
expect(quizCall.sourceId).toBe('job-001');
expect(quizCall.status).toBe('ready');
expect(quizCall.questionCount).toBe(3);
// Question fields
const qCall = tx.quizQuestion.create.mock.calls[0][0].data;
expect(qCall.type).toBe('choice');
expect(qCall.stem).toBe('Q1?');
expect(qCall.options).toEqual(['A', 'B', 'C', 'D']);
expect(qCall.answer).toBe('0');
expect(qCall.explanation).toBe('Because A');
expect(qCall.orderIndex).toBe(0);
});
it('入口幂等:已有 Artifact → 直接返回', async () => {
const tx = makeTx();
await projector.project(tx, makeCtx());
const artifacts = await projector.project(tx, makeCtx());
expect(artifacts).toHaveLength(1);
// No additional creates
expect(tx.quiz.create).toHaveBeenCalledTimes(1);
expect(tx.quizQuestion.create).toHaveBeenCalledTimes(3);
});
it('P2002 → 静默跳过', async () => {
const tx = makeTx();
tx.aiJobArtifact.create = jest.fn(async () => { const e: any = new Error('P2002'); e.code = 'P2002'; throw e; });
tx.aiJobArtifact.findMany = jest.fn().mockResolvedValue([]);
const artifacts = await projector.project(tx, makeCtx());
expect(artifacts).toHaveLength(1); // Quiz still created, artifact ref returned
expect(tx.quiz.create).toHaveBeenCalledTimes(1);
});
it('空 questions → 不创建 Quiz', async () => {
const tx = makeTx();
const artifacts = await projector.project(tx, makeCtx({ validatedOutput: { quizTitle: 'T', questions: [] } }));
expect(artifacts).toHaveLength(0);
expect(tx.quiz.create).not.toHaveBeenCalled();
});
it('questions 中无效项被过滤(缺少 stem/answer', async () => {
const tx = makeTx();
const ctx = makeCtx({
validatedOutput: {
quizTitle: 'T',
questions: [
{ stem: 'Valid', answer: 'A', type: 'fill', explanation: 'E' },
{ stem: '', answer: 'B', type: 'fill', explanation: 'E' }, // no stem
{ answer: 'C', type: 'fill', explanation: 'E' }, // no stem
],
},
});
const artifacts = await projector.project(tx, ctx);
expect(artifacts).toHaveLength(1);
expect(tx.quizQuestion.create).toHaveBeenCalledTimes(1);
});
it('fill 题型options 为 undefined', async () => {
const tx = makeTx();
const ctx = makeCtx({
validatedOutput: {
quizTitle: 'T',
questions: [{ type: 'fill', stem: 'Q', answer: 'A', explanation: 'E' }],
},
});
await projector.project(tx, ctx);
const qCall = tx.quizQuestion.create.mock.calls[0][0].data;
expect(qCall.options).toBeUndefined();
});
});

View File

@ -0,0 +1,150 @@
import { Injectable, Logger } from '@nestjs/common';
import type { Prisma } from '@prisma/client';
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 : [];
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,
type: q.type ?? 'choice',
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;
}
}