feat(M-AI-07-04): quiz generation Projector + atomic idempotency
- 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:
parent
d368f1a97f
commit
a59446266a
@ -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: [
|
||||
|
||||
149
src/modules/ai-job/quiz-generation-projector.spec.ts
Normal file
149
src/modules/ai-job/quiz-generation-projector.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
150
src/modules/ai-job/quiz-generation-projector.ts
Normal file
150
src/modules/ai-job/quiz-generation-projector.ts
Normal 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 → 直接返回
|
||||
* - 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 : [];
|
||||
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;
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user