feat(M-AI-07-03): quiz generation Executor + Validator + prompt/schema
Resolves A1/A2: bring quiz prompt + output schema in-house from Runtime. - quiz-generation.prompt.ts: system prompt with question design principles - quiz-generation.schema.ts: Zod schema (choice/fill/judge types) - Registered in PromptTemplateService with key 'quiz-generation' v1.0.0 - Executor: AiGateway call with knowledge items + generation params - Validator: per-type validation (choice options/index, judge true/false) - Engine dispatch for quiz_generation job type Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
9cac4781bd
commit
d368f1a97f
@ -12,6 +12,8 @@ import { FeynmanBusinessValidator, FeynmanReferenceValidator } from './feynman-v
|
|||||||
import { FeynmanObservabilityService } from './feynman-observability.service';
|
import { FeynmanObservabilityService } from './feynman-observability.service';
|
||||||
import { ReviewCardGenerationExecutor } from './review-card-generation-executor';
|
import { ReviewCardGenerationExecutor } from './review-card-generation-executor';
|
||||||
import { ReviewCardGenerationValidator } from './review-card-generation-validator';
|
import { ReviewCardGenerationValidator } from './review-card-generation-validator';
|
||||||
|
import { QuizGenerationExecutor } from './quiz-generation-executor';
|
||||||
|
import { QuizGenerationValidator } from './quiz-generation-validator';
|
||||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||||
import { JobLockConflictError, JobAlreadyTerminalError } from './ai-job.errors';
|
import { JobLockConflictError, JobAlreadyTerminalError } from './ai-job.errors';
|
||||||
|
|
||||||
@ -92,6 +94,8 @@ describe('AiJobExecutionEngineImpl', () => {
|
|||||||
{ provide: FeynmanReferenceValidator, useValue: { validate: jest.fn() } },
|
{ provide: FeynmanReferenceValidator, useValue: { validate: jest.fn() } },
|
||||||
{ provide: ReviewCardGenerationExecutor, useValue: { execute: jest.fn() } },
|
{ provide: ReviewCardGenerationExecutor, useValue: { execute: jest.fn() } },
|
||||||
{ provide: ReviewCardGenerationValidator, useValue: { validate: jest.fn() } },
|
{ provide: ReviewCardGenerationValidator, useValue: { validate: jest.fn() } },
|
||||||
|
{ provide: QuizGenerationExecutor, useValue: { execute: jest.fn() } },
|
||||||
|
{ provide: QuizGenerationValidator, useValue: { validate: jest.fn() } },
|
||||||
{ provide: ActiveRecallObservabilityService, useValue: {
|
{ provide: ActiveRecallObservabilityService, useValue: {
|
||||||
incrementUnifiedExecuteSuccess: jest.fn(),
|
incrementUnifiedExecuteSuccess: jest.fn(),
|
||||||
incrementUnifiedExecuteFailed: jest.fn(),
|
incrementUnifiedExecuteFailed: jest.fn(),
|
||||||
|
|||||||
@ -16,8 +16,12 @@ import { ReviewCardGenerationValidator } from './review-card-generation-validato
|
|||||||
import type { ActiveRecallSnapshot } from './active-recall-snapshot-builder';
|
import type { ActiveRecallSnapshot } from './active-recall-snapshot-builder';
|
||||||
import type { FeynmanSnapshot } from './feynman-snapshot-builder';
|
import type { FeynmanSnapshot } from './feynman-snapshot-builder';
|
||||||
import type { ReviewCardGenerationSnapshot } from './review-card-generation-snapshot-builder';
|
import type { ReviewCardGenerationSnapshot } from './review-card-generation-snapshot-builder';
|
||||||
|
import type { QuizGenerationSnapshot } from './quiz-generation-snapshot-builder';
|
||||||
import type { FeynmanEvaluationResult } from '../ai/prompts/schemas/feynman-evaluation.schema';
|
import type { FeynmanEvaluationResult } from '../ai/prompts/schemas/feynman-evaluation.schema';
|
||||||
import type { ReviewCardGenerationResult } from '../ai/prompts/schemas/review-card-generation.schema';
|
import type { ReviewCardGenerationResult } from '../ai/prompts/schemas/review-card-generation.schema';
|
||||||
|
import type { QuizGenerationResult } from '../ai/prompts/schemas/quiz-generation.schema';
|
||||||
|
import { QuizGenerationExecutor } from './quiz-generation-executor';
|
||||||
|
import { QuizGenerationValidator } from './quiz-generation-validator';
|
||||||
import {
|
import {
|
||||||
AiJobExecutionEngine,
|
AiJobExecutionEngine,
|
||||||
EngineJobContext,
|
EngineJobContext,
|
||||||
@ -94,6 +98,8 @@ export class AiJobExecutionEngineImpl implements AiJobExecutionEngine {
|
|||||||
private readonly feynmanReferenceValidator: FeynmanReferenceValidator,
|
private readonly feynmanReferenceValidator: FeynmanReferenceValidator,
|
||||||
private readonly reviewCardExecutor: ReviewCardGenerationExecutor,
|
private readonly reviewCardExecutor: ReviewCardGenerationExecutor,
|
||||||
private readonly reviewCardValidator: ReviewCardGenerationValidator,
|
private readonly reviewCardValidator: ReviewCardGenerationValidator,
|
||||||
|
private readonly quizExecutor: QuizGenerationExecutor,
|
||||||
|
private readonly quizValidator: QuizGenerationValidator,
|
||||||
private readonly observability: ActiveRecallObservabilityService,
|
private readonly observability: ActiveRecallObservabilityService,
|
||||||
private readonly feynmanObs: FeynmanObservabilityService,
|
private readonly feynmanObs: FeynmanObservabilityService,
|
||||||
) {}
|
) {}
|
||||||
@ -242,6 +248,26 @@ export class AiJobExecutionEngineImpl implements AiJobExecutionEngine {
|
|||||||
);
|
);
|
||||||
throw validationErr;
|
throw validationErr;
|
||||||
}
|
}
|
||||||
|
} else if (job.jobType === 'quiz_generation' && snapshot) {
|
||||||
|
// M-AI-07-03: Quiz Generation Executor
|
||||||
|
const quizSnapshot = snapshot as unknown as QuizGenerationSnapshot;
|
||||||
|
response = await this.quizExecutor.execute(quizSnapshot, timeoutMs);
|
||||||
|
parsedOutput = response.parsed;
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Quiz Executor completed: job=${aiJobId} ` +
|
||||||
|
`questions=${(parsedOutput as any)?.questions?.length}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── M-AI-07-03: 输出验证 ──
|
||||||
|
try {
|
||||||
|
this.quizValidator.validate(parsedOutput as QuizGenerationResult);
|
||||||
|
} catch (validationErr: any) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Quiz validation failed for job=${aiJobId}: ${validationErr.message}`,
|
||||||
|
);
|
||||||
|
throw validationErr;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// 默认路径:直接调用 AiGateway(synthetic_job 等)
|
// 默认路径:直接调用 AiGateway(synthetic_job 等)
|
||||||
response = await this.aiGateway.generate(
|
response = await this.aiGateway.generate(
|
||||||
|
|||||||
@ -36,6 +36,8 @@ import { ReviewCardGenerationValidator } from './review-card-generation-validato
|
|||||||
import { ReviewCardGenerationProjector } from './review-card-generation-projector';
|
import { ReviewCardGenerationProjector } from './review-card-generation-projector';
|
||||||
import { QuizGenerationRegistrationService } from './quiz-generation-registration.service';
|
import { QuizGenerationRegistrationService } from './quiz-generation-registration.service';
|
||||||
import { QuizGenerationSnapshotBuilder } from './quiz-generation-snapshot-builder';
|
import { QuizGenerationSnapshotBuilder } from './quiz-generation-snapshot-builder';
|
||||||
|
import { QuizGenerationExecutor } from './quiz-generation-executor';
|
||||||
|
import { QuizGenerationValidator } from './quiz-generation-validator';
|
||||||
import {
|
import {
|
||||||
FeynmanBusinessValidator,
|
FeynmanBusinessValidator,
|
||||||
FeynmanReferenceValidator,
|
FeynmanReferenceValidator,
|
||||||
@ -80,6 +82,8 @@ import { AppConfigModule } from '../config/config.module';
|
|||||||
ReviewCardGenerationProjector,
|
ReviewCardGenerationProjector,
|
||||||
QuizGenerationRegistrationService,
|
QuizGenerationRegistrationService,
|
||||||
QuizGenerationSnapshotBuilder,
|
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,
|
{ provide: RESULT_PROJECTORS, useFactory: (synthetic: SyntheticResultProjector, activeRecall: ActiveRecallProjector, feynman: FeynmanProjector, reviewCard: ReviewCardGenerationProjector) => [synthetic, activeRecall, feynman, reviewCard], inject: [SyntheticResultProjector, ActiveRecallProjector, FeynmanProjector, ReviewCardGenerationProjector] } as any,
|
||||||
{ provide: AI_JOB_EXECUTION_ENGINE, useExisting: AiJobExecutionEngineImpl },
|
{ provide: AI_JOB_EXECUTION_ENGINE, useExisting: AiJobExecutionEngineImpl },
|
||||||
],
|
],
|
||||||
|
|||||||
221
src/modules/ai-job/quiz-generation-executor.spec.ts
Normal file
221
src/modules/ai-job/quiz-generation-executor.spec.ts
Normal file
@ -0,0 +1,221 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { QuizGenerationExecutor } from './quiz-generation-executor';
|
||||||
|
import { QuizGenerationValidator } from './quiz-generation-validator';
|
||||||
|
import { AiGatewayService } from '../ai/gateway/ai-gateway.service';
|
||||||
|
import { BusinessValidationError } from './active-recall-validator';
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// Helpers
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function validSnapshot() {
|
||||||
|
return {
|
||||||
|
schemaVersion: 'quiz-generation-v1',
|
||||||
|
snapshot: {
|
||||||
|
userId: 'u-001',
|
||||||
|
targetType: 'knowledge_base',
|
||||||
|
targetId: 'kb-001',
|
||||||
|
submissionId: 'sub-001',
|
||||||
|
knowledgeBaseTitle: 'Test KB',
|
||||||
|
knowledgeBaseDescription: 'A test kb',
|
||||||
|
knowledgeItems: [
|
||||||
|
{ id: 'ki-1', title: 'Item 1', content: 'Content 1', summary: 'S1' },
|
||||||
|
{ id: 'ki-2', title: 'Item 2', content: 'Content 2', summary: 'S2' },
|
||||||
|
],
|
||||||
|
questionCount: 3,
|
||||||
|
questionTypes: ['choice', 'judge'],
|
||||||
|
difficultyLevel: 'medium',
|
||||||
|
knowledgePointIds: [],
|
||||||
|
promptKey: 'quiz-generation',
|
||||||
|
promptVersion: '1.0.0',
|
||||||
|
modelTier: 'primary',
|
||||||
|
inputSchemaVersion: 'quiz-generation-v1',
|
||||||
|
outputSchemaVersion: 'quiz-generation-v1',
|
||||||
|
createdAt: '2026-06-22T10:00:00Z',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function validOutput() {
|
||||||
|
return {
|
||||||
|
quizTitle: 'Test Quiz',
|
||||||
|
quizDescription: 'A test quiz',
|
||||||
|
questions: [
|
||||||
|
{ type: 'choice' as const, stem: 'Q1?', options: ['A', 'B', 'C', 'D'], answer: '0', explanation: 'Because A' },
|
||||||
|
{ type: 'judge' as const, stem: 'Q2?', answer: 'true', explanation: 'Correct' },
|
||||||
|
{ type: 'fill' as const, stem: 'Q3: ____ is the answer.', answer: '42', explanation: 'The answer is 42' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// QuizGenerationExecutor
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
describe('QuizGenerationExecutor', () => {
|
||||||
|
let executor: QuizGenerationExecutor;
|
||||||
|
let gateway: any;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
gateway = { generate: jest.fn() };
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
QuizGenerationExecutor,
|
||||||
|
{ provide: AiGatewayService, useValue: gateway },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
executor = module.get(QuizGenerationExecutor);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('execute', () => {
|
||||||
|
it('通过 AiGateway 调用并返回结构化输出', async () => {
|
||||||
|
gateway.generate.mockResolvedValue({ parsed: validOutput(), usage: { inputTokens: 500, outputTokens: 400 } });
|
||||||
|
const result = await executor.execute(validSnapshot(), 180000);
|
||||||
|
expect(result.parsed.questions).toHaveLength(3);
|
||||||
|
expect(result.parsed.quizTitle).toBe('Test Quiz');
|
||||||
|
expect(gateway.generate).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('使用 quiz-generation prompt + primary tier', async () => {
|
||||||
|
gateway.generate.mockResolvedValue({ parsed: validOutput(), usage: {} });
|
||||||
|
await executor.execute(validSnapshot(), 180000);
|
||||||
|
const call = gateway.generate.mock.calls[0][0];
|
||||||
|
expect(call.feature).toBe('quiz-generation');
|
||||||
|
expect(call.tier).toBe('primary');
|
||||||
|
expect(call.promptKey).toBe('quiz-generation');
|
||||||
|
expect(call.promptVersion).toBe('1.0.0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('user message 包含知识点内容', async () => {
|
||||||
|
gateway.generate.mockResolvedValue({ parsed: validOutput(), usage: {} });
|
||||||
|
await executor.execute(validSnapshot(), 180000);
|
||||||
|
const msg = gateway.generate.mock.calls[0][0].messages[0].content;
|
||||||
|
expect(msg).toContain('Test KB');
|
||||||
|
expect(msg).toContain('Item 1');
|
||||||
|
expect(msg).toContain('Content 1');
|
||||||
|
expect(msg).toContain('生成 3 道测验题目');
|
||||||
|
expect(msg).toContain('choice、judge');
|
||||||
|
expect(msg).toContain('medium');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('传递 maxTokens + timeoutMs', async () => {
|
||||||
|
gateway.generate.mockResolvedValue({ parsed: validOutput(), usage: {} });
|
||||||
|
await executor.execute(validSnapshot(), 120000);
|
||||||
|
expect(gateway.generate.mock.calls[0][0].maxTokens).toBe(4096);
|
||||||
|
expect(gateway.generate.mock.calls[1]?.[1]).toBeUndefined(); // timeout is second arg
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Provider 失败 → 错误传播', async () => {
|
||||||
|
gateway.generate.mockRejectedValue(new Error('AI timeout'));
|
||||||
|
await expect(executor.execute(validSnapshot(), 180000)).rejects.toThrow('AI timeout');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// QuizGenerationValidator
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
describe('QuizGenerationValidator', () => {
|
||||||
|
let validator: QuizGenerationValidator;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [QuizGenerationValidator],
|
||||||
|
}).compile();
|
||||||
|
validator = module.get(QuizGenerationValidator);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('正常输出', () => {
|
||||||
|
it('合法输出验证通过', () => {
|
||||||
|
expect(() => validator.validate(validOutput())).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('结构错误', () => {
|
||||||
|
it('空 questions → 拒绝', () => {
|
||||||
|
expect(() => validator.validate({ quizTitle: 'T', questions: [] }))
|
||||||
|
.toThrow(BusinessValidationError);
|
||||||
|
});
|
||||||
|
it('questions 非数组 → 拒绝', () => {
|
||||||
|
expect(() => validator.validate({ quizTitle: 'T', questions: null as any }))
|
||||||
|
.toThrow(BusinessValidationError);
|
||||||
|
});
|
||||||
|
it('quizTitle 为空 → 拒绝', () => {
|
||||||
|
expect(() => validator.validate({ quizTitle: '', questions: [validOutput().questions[0]] }))
|
||||||
|
.toThrow(BusinessValidationError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('题型验证', () => {
|
||||||
|
it('非法 type → 拒绝', () => {
|
||||||
|
const q = { type: 'essay' as any, stem: 'Q', answer: 'A', explanation: 'E' };
|
||||||
|
expect(() => validator.validate({ quizTitle: 'T', questions: [q] }))
|
||||||
|
.toThrow(BusinessValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('choice 无 options → 拒绝', () => {
|
||||||
|
const q = { type: 'choice' as const, stem: 'Q', answer: '0', explanation: 'E' };
|
||||||
|
expect(() => validator.validate({ quizTitle: 'T', questions: [q] }))
|
||||||
|
.toThrow(BusinessValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('choice options < 2 → 拒绝', () => {
|
||||||
|
const q = { type: 'choice' as const, stem: 'Q', options: ['A'], answer: '0', explanation: 'E' };
|
||||||
|
expect(() => validator.validate({ quizTitle: 'T', questions: [q] }))
|
||||||
|
.toThrow(BusinessValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('choice answer 不是有效索引 → 拒绝', () => {
|
||||||
|
const q = { type: 'choice' as const, stem: 'Q', options: ['A', 'B', 'C'], answer: '5', explanation: 'E' };
|
||||||
|
expect(() => validator.validate({ quizTitle: 'T', questions: [q] }))
|
||||||
|
.toThrow(BusinessValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('choice 重复选项 → 拒绝', () => {
|
||||||
|
const q = { type: 'choice' as const, stem: 'Q', options: ['A', 'A', 'B', 'C'], answer: '0', explanation: 'E' };
|
||||||
|
expect(() => validator.validate({ quizTitle: 'T', questions: [q] }))
|
||||||
|
.toThrow(BusinessValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('judge answer 非法 → 拒绝', () => {
|
||||||
|
const q = { type: 'judge' as const, stem: 'Q', answer: 'yes', explanation: 'E' };
|
||||||
|
expect(() => validator.validate({ quizTitle: 'T', questions: [q] }))
|
||||||
|
.toThrow(BusinessValidationError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('字段非空', () => {
|
||||||
|
it('stem 空 → 拒绝', () => {
|
||||||
|
const q = { type: 'fill' as const, stem: '', answer: 'A', explanation: 'E' };
|
||||||
|
expect(() => validator.validate({ quizTitle: 'T', questions: [q] }))
|
||||||
|
.toThrow(BusinessValidationError);
|
||||||
|
});
|
||||||
|
it('answer 空 → 拒绝', () => {
|
||||||
|
const q = { type: 'fill' as const, stem: 'Q', answer: '', explanation: 'E' };
|
||||||
|
expect(() => validator.validate({ quizTitle: 'T', questions: [q] }))
|
||||||
|
.toThrow(BusinessValidationError);
|
||||||
|
});
|
||||||
|
it('explanation 空 → 拒绝', () => {
|
||||||
|
const q = { type: 'fill' as const, stem: 'Q', answer: 'A', explanation: '' };
|
||||||
|
expect(() => validator.validate({ quizTitle: 'T', questions: [q] }))
|
||||||
|
.toThrow(BusinessValidationError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('重复题目', () => {
|
||||||
|
it('相同 stem → 拒绝', () => {
|
||||||
|
const q = { type: 'fill' as const, stem: 'Same Q', answer: 'A', explanation: 'E' };
|
||||||
|
expect(() => validator.validate({ quizTitle: 'T', questions: [q, q] }))
|
||||||
|
.toThrow(BusinessValidationError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('模型指令', () => {
|
||||||
|
it('stem 含代码块 → 拒绝', () => {
|
||||||
|
const q = { type: 'fill' as const, stem: '```json\n{}```', answer: 'A', explanation: 'E' };
|
||||||
|
expect(() => validator.validate({ quizTitle: 'T', questions: [q] }))
|
||||||
|
.toThrow(BusinessValidationError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
72
src/modules/ai-job/quiz-generation-executor.ts
Normal file
72
src/modules/ai-job/quiz-generation-executor.ts
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { AiGatewayService } from '../ai/gateway/ai-gateway.service';
|
||||||
|
import { QuizGenerationResultSchema } from '../ai/prompts/schemas/quiz-generation.schema';
|
||||||
|
import type { QuizGenerationSnapshot } from './quiz-generation-snapshot-builder';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* M-AI-07-03: Quiz Generation Executor
|
||||||
|
*
|
||||||
|
* 将 Quiz 生成输入快照适配到统一 Job Engine 的 EXECUTE 阶段。
|
||||||
|
*
|
||||||
|
* 职责:从 Snapshot 构造模型输入、通过 AiGateway 调用、返回结构化输出。
|
||||||
|
* 不负责:写数据库、写 Job 状态、重试、Artifact、Credential。
|
||||||
|
*/
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class QuizGenerationExecutor {
|
||||||
|
private readonly logger = new Logger(QuizGenerationExecutor.name);
|
||||||
|
|
||||||
|
constructor(private readonly aiGateway: AiGatewayService) {}
|
||||||
|
|
||||||
|
async execute(snapshot: QuizGenerationSnapshot, timeoutMs: number) {
|
||||||
|
const s = snapshot.snapshot;
|
||||||
|
|
||||||
|
// 构造知识点列表文本
|
||||||
|
const itemsText = s.knowledgeItems
|
||||||
|
.map((item, i) =>
|
||||||
|
`【知识点${i + 1}】${item.title}\n${item.content}`,
|
||||||
|
)
|
||||||
|
.join('\n\n');
|
||||||
|
|
||||||
|
const typeDesc = s.questionTypes.join('、');
|
||||||
|
const userMessage = [
|
||||||
|
`【知识库】${s.knowledgeBaseTitle}`,
|
||||||
|
s.knowledgeBaseDescription ? `描述:${s.knowledgeBaseDescription}` : '',
|
||||||
|
'',
|
||||||
|
itemsText,
|
||||||
|
'',
|
||||||
|
`请为以上知识点生成 ${s.questionCount} 道测验题目。`,
|
||||||
|
`题型:${typeDesc}`,
|
||||||
|
`难度:${s.difficultyLevel}`,
|
||||||
|
].filter(Boolean).join('\n');
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Quiz Generation Executor calling AI: userId=${s.userId} ` +
|
||||||
|
`kb=${s.targetId} items=${s.knowledgeItems.length} ` +
|
||||||
|
`questionCount=${s.questionCount} types=${s.questionTypes.join(',')} ` +
|
||||||
|
`difficulty=${s.difficultyLevel} modelTier=${s.modelTier} timeoutMs=${timeoutMs}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await this.aiGateway.generate(
|
||||||
|
{
|
||||||
|
feature: 'quiz-generation',
|
||||||
|
userId: s.userId,
|
||||||
|
tier: s.modelTier as any,
|
||||||
|
promptKey: s.promptKey,
|
||||||
|
promptVersion: s.promptVersion,
|
||||||
|
messages: [{ role: 'user' as const, content: userMessage }],
|
||||||
|
outputSchema: QuizGenerationResultSchema,
|
||||||
|
maxTokens: 4096,
|
||||||
|
},
|
||||||
|
timeoutMs,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Quiz Generation Executor completed: userId=${s.userId} ` +
|
||||||
|
`kb=${s.targetId} questions=${(response.parsed as any)?.questions?.length} ` +
|
||||||
|
`tokens=${response.usage.inputTokens}/${response.usage.outputTokens}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
}
|
||||||
133
src/modules/ai-job/quiz-generation-validator.ts
Normal file
133
src/modules/ai-job/quiz-generation-validator.ts
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import type { QuizGenerationResult } from '../ai/prompts/schemas/quiz-generation.schema';
|
||||||
|
import { BusinessValidationError, ReferenceValidationError } from './active-recall-validator';
|
||||||
|
|
||||||
|
export { BusinessValidationError, ReferenceValidationError };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* M-AI-07-03: Quiz Generation Business Validator
|
||||||
|
*
|
||||||
|
* 验证 AI 输出符合业务约束(契约 §6.2)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const VALID_TYPES = ['choice', 'fill', 'judge'] as const;
|
||||||
|
const CODE_BLOCK_PATTERN = /```(?:json|javascript|js|python)?[\s\S]*?```/;
|
||||||
|
const MODEL_INSTRUCTION_PATTERNS = [
|
||||||
|
/^here\s+(is|are)\s+(the|your)\s/i,
|
||||||
|
/^(certainly|sure|of course)[,;!\s]/i,
|
||||||
|
/^请根据/,
|
||||||
|
/^以下是/,
|
||||||
|
];
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class QuizGenerationValidator {
|
||||||
|
private readonly logger = new Logger(QuizGenerationValidator.name);
|
||||||
|
|
||||||
|
validate(output: QuizGenerationResult): void {
|
||||||
|
const violations: string[] = [];
|
||||||
|
|
||||||
|
// quizTitle
|
||||||
|
if (!output.quizTitle || typeof output.quizTitle !== 'string' || output.quizTitle.trim().length === 0) {
|
||||||
|
violations.push('quizTitle is required and must be non-empty');
|
||||||
|
} else if (output.quizTitle.length > 255) {
|
||||||
|
violations.push(`quizTitle length ${output.quizTitle.length} exceeds 255`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// questions array
|
||||||
|
if (!Array.isArray(output.questions)) {
|
||||||
|
violations.push('questions must be an array');
|
||||||
|
throw new BusinessValidationError('Business validation failed', violations);
|
||||||
|
}
|
||||||
|
if (output.questions.length === 0) {
|
||||||
|
violations.push('questions must not be empty');
|
||||||
|
}
|
||||||
|
if (output.questions.length > 50) {
|
||||||
|
violations.push(`questions count ${output.questions.length} exceeds max 50`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const seenStems = new Set<string>();
|
||||||
|
|
||||||
|
for (let i = 0; i < output.questions.length; i++) {
|
||||||
|
const q = output.questions[i];
|
||||||
|
|
||||||
|
// type
|
||||||
|
if (!q.type || !(VALID_TYPES as readonly string[]).includes(q.type)) {
|
||||||
|
violations.push(`questions[${i}].type "${q.type}" invalid, must be: ${VALID_TYPES.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// stem
|
||||||
|
if (!q.stem || typeof q.stem !== 'string' || q.stem.trim().length === 0) {
|
||||||
|
violations.push(`questions[${i}].stem is required`);
|
||||||
|
} else if (q.stem.length > 2000) {
|
||||||
|
violations.push(`questions[${i}].stem length ${q.stem.length} exceeds 2000`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// answer
|
||||||
|
if (!q.answer || typeof q.answer !== 'string' || q.answer.trim().length === 0) {
|
||||||
|
violations.push(`questions[${i}].answer is required`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// explanation
|
||||||
|
if (!q.explanation || typeof q.explanation !== 'string' || q.explanation.trim().length === 0) {
|
||||||
|
violations.push(`questions[${i}].explanation is required`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// per-type validation
|
||||||
|
if (q.type === 'choice') {
|
||||||
|
if (!Array.isArray(q.options) || q.options.length < 2) {
|
||||||
|
violations.push(`questions[${i}] choice requires >= 2 options`);
|
||||||
|
} else {
|
||||||
|
// answer must be a valid option index
|
||||||
|
const idx = parseInt(q.answer, 10);
|
||||||
|
if (isNaN(idx) || idx < 0 || idx >= q.options.length) {
|
||||||
|
violations.push(`questions[${i}] answer "${q.answer}" is not a valid option index (0-${q.options.length - 1})`);
|
||||||
|
}
|
||||||
|
// duplicate options
|
||||||
|
const uniqueOpts = new Set(q.options);
|
||||||
|
if (uniqueOpts.size !== q.options.length) {
|
||||||
|
violations.push(`questions[${i}] has duplicate options`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (q.type === 'judge') {
|
||||||
|
if (q.answer !== 'true' && q.answer !== 'false') {
|
||||||
|
violations.push(`questions[${i}] judge answer must be "true" or "false", got "${q.answer}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// duplicate stem
|
||||||
|
if (q.stem) {
|
||||||
|
const dedupeKey = q.stem.trim().toLowerCase();
|
||||||
|
if (seenStems.has(dedupeKey)) {
|
||||||
|
violations.push(`questions[${i}] stem is duplicate`);
|
||||||
|
}
|
||||||
|
seenStems.add(dedupeKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
// model artifacts
|
||||||
|
this.checkModelArtifacts(q.stem, `questions[${i}].stem`, violations);
|
||||||
|
this.checkModelArtifacts(q.explanation, `questions[${i}].explanation`, violations);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (violations.length > 0) {
|
||||||
|
this.logger.warn(`Quiz validation failed: ${violations.length} violation(s): ${violations.join('; ')}`);
|
||||||
|
throw new BusinessValidationError(`Business validation failed: ${violations.length} violation(s)`, violations);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`Quiz validation passed: ${output.questions.length} questions`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private checkModelArtifacts(text: string | undefined, fieldName: string, violations: string[]): void {
|
||||||
|
if (!text) return;
|
||||||
|
if (CODE_BLOCK_PATTERN.test(text)) {
|
||||||
|
violations.push(`${fieldName} contains code block`);
|
||||||
|
}
|
||||||
|
for (const pattern of MODEL_INSTRUCTION_PATTERNS) {
|
||||||
|
if (pattern.test(text.trim())) {
|
||||||
|
violations.push(`${fieldName} contains model instruction`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -11,6 +11,8 @@ import { LEARNING_TREND_SYSTEM_PROMPT } from './learning-trend.prompt';
|
|||||||
import { LEARNING_TREND_OUTPUT_SCHEMA_DESC } from './schemas/learning-trend.schema';
|
import { LEARNING_TREND_OUTPUT_SCHEMA_DESC } from './schemas/learning-trend.schema';
|
||||||
import { RAG_CHAT_SYSTEM_PROMPT } from './rag-chat.prompt';
|
import { RAG_CHAT_SYSTEM_PROMPT } from './rag-chat.prompt';
|
||||||
import { RAG_CHAT_OUTPUT_SCHEMA_DESC } from './schemas/rag-chat.schema';
|
import { RAG_CHAT_OUTPUT_SCHEMA_DESC } from './schemas/rag-chat.schema';
|
||||||
|
import { QUIZ_GENERATION_SYSTEM_PROMPT } from './quiz-generation.prompt';
|
||||||
|
import { QUIZ_OUTPUT_SCHEMA_DESC } from './schemas/quiz-generation.schema';
|
||||||
|
|
||||||
export interface PromptTemplate {
|
export interface PromptTemplate {
|
||||||
key: string;
|
key: string;
|
||||||
@ -60,6 +62,12 @@ export class PromptTemplateService {
|
|||||||
systemPrompt: RAG_CHAT_SYSTEM_PROMPT,
|
systemPrompt: RAG_CHAT_SYSTEM_PROMPT,
|
||||||
outputSchemaDesc: RAG_CHAT_OUTPUT_SCHEMA_DESC,
|
outputSchemaDesc: RAG_CHAT_OUTPUT_SCHEMA_DESC,
|
||||||
});
|
});
|
||||||
|
this.register({
|
||||||
|
key: 'quiz-generation',
|
||||||
|
version: '1.0.0',
|
||||||
|
systemPrompt: QUIZ_GENERATION_SYSTEM_PROMPT,
|
||||||
|
outputSchemaDesc: QUIZ_OUTPUT_SCHEMA_DESC,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
get(key: string, version?: string): PromptTemplate {
|
get(key: string, version?: string): PromptTemplate {
|
||||||
|
|||||||
30
src/modules/ai/prompts/quiz-generation.prompt.ts
Normal file
30
src/modules/ai/prompts/quiz-generation.prompt.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
export const QUIZ_GENERATION_SYSTEM_PROMPT = `你是一位教育测验设计专家,擅长根据知识点内容创建高质量的测验题目。
|
||||||
|
|
||||||
|
你的任务:根据提供的知识点内容,生成一套用于检验学习效果的测验题目。
|
||||||
|
|
||||||
|
题目设计原则:
|
||||||
|
1. 题型多样化:
|
||||||
|
- choice:单选题,提供4个选项,只有1个正确答案
|
||||||
|
- fill:填空题,要求填写关键概念或术语
|
||||||
|
- judge:判断题,判断陈述是否正确(答案只能是"true"或"false")
|
||||||
|
2. 题干清晰:问题表述明确,不产生歧义
|
||||||
|
3. 选项合理:错误选项应具有迷惑性但不误导
|
||||||
|
4. 解析详尽:每道题提供解析,说明正确答案的理由
|
||||||
|
5. 难度分级:
|
||||||
|
- easy:基础概念识别和简单回忆
|
||||||
|
- medium:需要理解原理和关联
|
||||||
|
- hard:需要综合分析和应用
|
||||||
|
6. 覆盖全面:题目应覆盖知识点的各个关键方面
|
||||||
|
7. 循序渐进:先基础后深入
|
||||||
|
|
||||||
|
输出要求:
|
||||||
|
- questions:题目数组(数量和类型由用户指定)
|
||||||
|
- quizTitle:测验标题(简洁明了,≤50字符)
|
||||||
|
- quizDescription:测验描述(可选,简要说明测验范围)
|
||||||
|
|
||||||
|
重要原则:
|
||||||
|
- 题目应检验理解而非记忆
|
||||||
|
- 选项之间应互斥且完整
|
||||||
|
- 判断题的陈述必须有明确的对错
|
||||||
|
- 填空题的答案应为简短的关键词或短语
|
||||||
|
- 不要重复考察同一个知识点`;
|
||||||
45
src/modules/ai/prompts/schemas/quiz-generation.schema.ts
Normal file
45
src/modules/ai/prompts/schemas/quiz-generation.schema.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const QuizQuestionSchema = z.object({
|
||||||
|
type: z.enum(['choice', 'fill', 'judge']),
|
||||||
|
stem: z.string().min(1).max(2000),
|
||||||
|
options: z.array(z.string().max(500)).min(2).max(6).optional(),
|
||||||
|
answer: z.string().min(1).max(500),
|
||||||
|
explanation: z.string().min(1).max(2000),
|
||||||
|
sourceBlockIds: z.array(z.string().max(100)).max(10).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const QuizGenerationResultSchema = z.object({
|
||||||
|
quizTitle: z.string().min(1).max(255),
|
||||||
|
quizDescription: z.string().max(1000).optional(),
|
||||||
|
questions: z.array(QuizQuestionSchema).min(1).max(50),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type QuizGenerationResult = z.infer<typeof QuizGenerationResultSchema>;
|
||||||
|
|
||||||
|
export const QUIZ_OUTPUT_SCHEMA_DESC = `{
|
||||||
|
"quizTitle": "光合作用基础测验",
|
||||||
|
"quizDescription": "检验对光合作用基本概念和过程的理解",
|
||||||
|
"questions": [
|
||||||
|
{
|
||||||
|
"type": "choice",
|
||||||
|
"stem": "光合作用中,光反应产生的气体是什么?",
|
||||||
|
"options": ["氧气", "二氧化碳", "氮气", "氢气"],
|
||||||
|
"answer": "0",
|
||||||
|
"explanation": "光反应中水分子分解产生氧气",
|
||||||
|
"sourceBlockIds": ["ki_001"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "fill",
|
||||||
|
"stem": "光合作用主要在植物细胞的____中进行。",
|
||||||
|
"answer": "叶绿体",
|
||||||
|
"explanation": "叶绿体是光合作用的场所"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "judge",
|
||||||
|
"stem": "光合作用只能在有光条件下进行。",
|
||||||
|
"answer": "true",
|
||||||
|
"explanation": "光合作用的光反应需要光能,暗反应虽不直接需光但依赖光反应产物"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`;
|
||||||
Loading…
x
Reference in New Issue
Block a user