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 { QuizGenerationSnapshotBuilder } from './quiz-generation-snapshot-builder';
|
||||||
import { QuizGenerationExecutor } from './quiz-generation-executor';
|
import { QuizGenerationExecutor } from './quiz-generation-executor';
|
||||||
import { QuizGenerationValidator } from './quiz-generation-validator';
|
import { QuizGenerationValidator } from './quiz-generation-validator';
|
||||||
|
import { QuizGenerationProjector } from './quiz-generation-projector';
|
||||||
import {
|
import {
|
||||||
FeynmanBusinessValidator,
|
FeynmanBusinessValidator,
|
||||||
FeynmanReferenceValidator,
|
FeynmanReferenceValidator,
|
||||||
@ -84,7 +85,8 @@ import { AppConfigModule } from '../config/config.module';
|
|||||||
QuizGenerationSnapshotBuilder,
|
QuizGenerationSnapshotBuilder,
|
||||||
QuizGenerationExecutor,
|
QuizGenerationExecutor,
|
||||||
QuizGenerationValidator,
|
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 },
|
{ provide: AI_JOB_EXECUTION_ENGINE, useExisting: AiJobExecutionEngineImpl },
|
||||||
],
|
],
|
||||||
exports: [
|
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