diff --git a/src/modules/ai-job/ai-job.module.ts b/src/modules/ai-job/ai-job.module.ts index c2cb447..10d5240 100644 --- a/src/modules/ai-job/ai-job.module.ts +++ b/src/modules/ai-job/ai-job.module.ts @@ -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: [ diff --git a/src/modules/ai-job/quiz-generation-projector.spec.ts b/src/modules/ai-job/quiz-generation-projector.spec.ts new file mode 100644 index 0000000..3d9ff8a --- /dev/null +++ b/src/modules/ai-job/quiz-generation-projector.spec.ts @@ -0,0 +1,149 @@ +import { QuizGenerationProjector } from './quiz-generation-projector'; +import type { ProjectionContext } from './result-projector.interface'; + +function makeTx(): any { + const store: Record = { + 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(); + }); +}); diff --git a/src/modules/ai-job/quiz-generation-projector.ts b/src/modules/ai-job/quiz-generation-projector.ts new file mode 100644 index 0000000..1ed58b1 --- /dev/null +++ b/src/modules/ai-job/quiz-generation-projector.ts @@ -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 { + 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 { + try { + await tx.aiJobArtifact.create({ + data: { jobId, artifactType, artifactId, ordinal }, + }); + } catch (err: any) { + if (err?.code === 'P2002') return; + throw err; + } +}