feat(M-AI-07-02): quiz generation Job Definition + Snapshot Builder
- quiz_generation Definition: ai-interactive, primary tier, 180s timeout - Snapshot Builder: KB ownership validation, content truncation (2k chars) - Parameter normalization: questionCount [1,50], types, difficulty - Stable contentHash via sorted keys + SHA256 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
a17aba972b
commit
9cac4781bd
@ -34,6 +34,8 @@ import { ReviewCardGenerationSnapshotBuilder } from './review-card-generation-sn
|
||||
import { ReviewCardGenerationExecutor } from './review-card-generation-executor';
|
||||
import { ReviewCardGenerationValidator } from './review-card-generation-validator';
|
||||
import { ReviewCardGenerationProjector } from './review-card-generation-projector';
|
||||
import { QuizGenerationRegistrationService } from './quiz-generation-registration.service';
|
||||
import { QuizGenerationSnapshotBuilder } from './quiz-generation-snapshot-builder';
|
||||
import {
|
||||
FeynmanBusinessValidator,
|
||||
FeynmanReferenceValidator,
|
||||
@ -76,6 +78,8 @@ import { AppConfigModule } from '../config/config.module';
|
||||
ReviewCardGenerationExecutor,
|
||||
ReviewCardGenerationValidator,
|
||||
ReviewCardGenerationProjector,
|
||||
QuizGenerationRegistrationService,
|
||||
QuizGenerationSnapshotBuilder,
|
||||
{ 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 },
|
||||
],
|
||||
|
||||
76
src/modules/ai-job/quiz-generation-job-definition.ts
Normal file
76
src/modules/ai-job/quiz-generation-job-definition.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import type { JobDefinition } from './job-definition.types';
|
||||
|
||||
/**
|
||||
* M-AI-07-02: Quiz Generation Job Definition(冻结)
|
||||
*
|
||||
* 对应迁移契约 docs/architecture/m-ai-07-quiz-generation-migration-contract.md
|
||||
*
|
||||
* 字段取值来源:
|
||||
* - promptKey: quiz-generation(契约 §5.1 — 当前由外部 Runtime 管理,M-AI-07-03 内联)
|
||||
* - modelTier: primary(契约 A5 — 从硬编码 deepseek-chat 迁移到 ModelRouter)
|
||||
* - queueName: ai-interactive(Quiz 生成为用户交互请求)
|
||||
* - input.schemaVersion: quiz-generation-v1
|
||||
* - output.schemaVersion: quiz-generation-v1
|
||||
* - timeoutMs: 180000(3min,生成题目比评估更耗时)
|
||||
* - maxRetries: 3
|
||||
* - domain: generation
|
||||
*/
|
||||
|
||||
export const QUIZ_GENERATION_JOB_DEFINITION: JobDefinition = {
|
||||
jobType: 'quiz_generation',
|
||||
|
||||
metadata: {
|
||||
label: 'Quiz Generation',
|
||||
description:
|
||||
'Generate quiz questions from knowledge base content. ' +
|
||||
'Creates choice, fill-in-the-blank, and true/false questions ' +
|
||||
'with options, answers, and explanations.',
|
||||
domain: 'generation',
|
||||
version: '1.0.0',
|
||||
},
|
||||
|
||||
queue: {
|
||||
queueName: 'ai-interactive',
|
||||
defaultPriority: 0,
|
||||
},
|
||||
|
||||
execution: {
|
||||
timeoutMs: 180_000,
|
||||
maxRetries: 3,
|
||||
retryBackoff: { type: 'exponential', delay: 1000 },
|
||||
cancellable: true,
|
||||
abortStrategy: 'fail',
|
||||
},
|
||||
|
||||
input: {
|
||||
schemaVersion: 'quiz-generation-v1',
|
||||
},
|
||||
|
||||
output: {
|
||||
schemaVersion: 'quiz-generation-v1',
|
||||
},
|
||||
|
||||
prompt: {
|
||||
promptKey: 'quiz-generation',
|
||||
promptVersion: '1.0.0',
|
||||
},
|
||||
|
||||
model: {
|
||||
modelTier: 'primary',
|
||||
modelProvider: 'deepseek',
|
||||
modelName: 'deepseek-v4-pro', // ModelRouter primary tier (model-router.ts:25)
|
||||
maxTokens: 4096,
|
||||
},
|
||||
|
||||
credential: {
|
||||
allowedModes: ['platform_key'],
|
||||
defaultMode: 'platform_key',
|
||||
},
|
||||
|
||||
projectorKey: 'quiz_generation_projector',
|
||||
|
||||
security: {
|
||||
contentSafetyCheck: true,
|
||||
outputRedaction: false,
|
||||
},
|
||||
};
|
||||
294
src/modules/ai-job/quiz-generation-registration.service.spec.ts
Normal file
294
src/modules/ai-job/quiz-generation-registration.service.spec.ts
Normal file
@ -0,0 +1,294 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { NotFoundException, ForbiddenException, BadRequestException } from '@nestjs/common';
|
||||
import { JobDefinitionRegistry, DuplicateJobTypeError } from './job-definition-registry';
|
||||
import { QuizGenerationRegistrationService } from './quiz-generation-registration.service';
|
||||
import { QUIZ_GENERATION_JOB_DEFINITION } from './quiz-generation-job-definition';
|
||||
import { QuizGenerationSnapshotBuilder } from './quiz-generation-snapshot-builder';
|
||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// QuizGenerationRegistrationService
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('QuizGenerationRegistrationService', () => {
|
||||
let registry: JobDefinitionRegistry;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [JobDefinitionRegistry, QuizGenerationRegistrationService],
|
||||
}).compile();
|
||||
registry = module.get(JobDefinitionRegistry);
|
||||
});
|
||||
|
||||
describe('Definition 注册', () => {
|
||||
it('Registry 注册成功', async () => {
|
||||
const module = await Test.createTestingModule({
|
||||
providers: [JobDefinitionRegistry, QuizGenerationRegistrationService],
|
||||
}).compile();
|
||||
await module.init();
|
||||
|
||||
const reg = module.get(JobDefinitionRegistry);
|
||||
const def = reg.get('quiz_generation');
|
||||
expect(def).toBeDefined();
|
||||
expect(def.jobType).toBe('quiz_generation');
|
||||
expect(def.queue.queueName).toBe('ai-interactive');
|
||||
expect(def.metadata.domain).toBe('generation');
|
||||
});
|
||||
|
||||
it('重复注册失败', async () => {
|
||||
const module = await Test.createTestingModule({
|
||||
providers: [JobDefinitionRegistry, QuizGenerationRegistrationService],
|
||||
}).compile();
|
||||
await module.init();
|
||||
const reg = module.get(JobDefinitionRegistry);
|
||||
expect(() => reg.register(QUIZ_GENERATION_JOB_DEFINITION)).toThrow(DuplicateJobTypeError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Definition 字段验证', () => {
|
||||
it('jobType 格式合法', () => {
|
||||
expect(QUIZ_GENERATION_JOB_DEFINITION.jobType).toMatch(/^[a-z][a-z0-9_]{1,63}$/);
|
||||
});
|
||||
it('queueName = ai-interactive', () => {
|
||||
expect(QUIZ_GENERATION_JOB_DEFINITION.queue.queueName).toBe('ai-interactive');
|
||||
});
|
||||
it('modelTier = primary', () => {
|
||||
expect(QUIZ_GENERATION_JOB_DEFINITION.model.modelTier).toBe('primary');
|
||||
});
|
||||
it('promptKey = quiz-generation', () => {
|
||||
expect(QUIZ_GENERATION_JOB_DEFINITION.prompt.promptKey).toBe('quiz-generation');
|
||||
});
|
||||
it('timeoutMs in range', () => {
|
||||
expect(QUIZ_GENERATION_JOB_DEFINITION.execution.timeoutMs).toBeGreaterThanOrEqual(1000);
|
||||
expect(QUIZ_GENERATION_JOB_DEFINITION.execution.timeoutMs).toBeLessThanOrEqual(600000);
|
||||
});
|
||||
it('projectorKey set', () => {
|
||||
expect(QUIZ_GENERATION_JOB_DEFINITION.projectorKey).toBe('quiz_generation_projector');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// QuizGenerationSnapshotBuilder
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('QuizGenerationSnapshotBuilder', () => {
|
||||
let builder: QuizGenerationSnapshotBuilder;
|
||||
let prisma: any;
|
||||
let registry: any;
|
||||
|
||||
const mockKb = {
|
||||
id: 'kb-001',
|
||||
userId: 'u-001',
|
||||
title: 'Test KB',
|
||||
description: 'A test knowledge base',
|
||||
deletedAt: null,
|
||||
status: 'active',
|
||||
};
|
||||
|
||||
const mockItems = [
|
||||
{ id: 'ki-1', title: 'Item 1', content: 'Content of item 1', summary: 'Summary 1' },
|
||||
{ id: 'ki-2', title: 'Item 2', content: 'Content of item 2', summary: 'Summary 2' },
|
||||
{ id: 'ki-3', title: 'Item 3', content: 'C'.repeat(2500), summary: 'Summary 3' },
|
||||
];
|
||||
|
||||
beforeEach(async () => {
|
||||
prisma = {
|
||||
knowledgeBase: { findUnique: jest.fn() },
|
||||
knowledgeItem: { findMany: jest.fn() },
|
||||
};
|
||||
registry = { get: jest.fn().mockReturnValue(QUIZ_GENERATION_JOB_DEFINITION) };
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
QuizGenerationSnapshotBuilder,
|
||||
{ provide: PrismaService, useValue: prisma },
|
||||
{ provide: JobDefinitionRegistry, useValue: registry },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
builder = module.get(QuizGenerationSnapshotBuilder);
|
||||
jest.spyOn(require('@nestjs/common').Logger.prototype, 'log').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
describe('build', () => {
|
||||
it('构建有效快照', async () => {
|
||||
prisma.knowledgeBase.findUnique.mockResolvedValue(mockKb);
|
||||
prisma.knowledgeItem.findMany.mockResolvedValue(mockItems);
|
||||
|
||||
const snapshot = await builder.build({
|
||||
userId: 'u-001',
|
||||
targetType: 'knowledge_base',
|
||||
targetId: 'kb-001',
|
||||
submissionId: 'sub-001',
|
||||
questionCount: 3,
|
||||
questionTypes: ['choice', 'judge'],
|
||||
difficultyLevel: 'medium',
|
||||
});
|
||||
|
||||
expect(snapshot.schemaVersion).toBe('quiz-generation-v1');
|
||||
expect(snapshot.snapshot.userId).toBe('u-001');
|
||||
expect(snapshot.snapshot.questionCount).toBe(3);
|
||||
expect(snapshot.snapshot.questionTypes).toEqual(['choice', 'judge']);
|
||||
expect(snapshot.snapshot.difficultyLevel).toBe('medium');
|
||||
expect(snapshot.snapshot.knowledgeItems).toHaveLength(3);
|
||||
expect(snapshot.snapshot.promptKey).toBe('quiz-generation');
|
||||
expect(snapshot.snapshot.modelTier).toBe('primary');
|
||||
});
|
||||
|
||||
it('prompt/model 值来自 Definition(单一事实来源)', async () => {
|
||||
prisma.knowledgeBase.findUnique.mockResolvedValue(mockKb);
|
||||
prisma.knowledgeItem.findMany.mockResolvedValue([mockItems[0]]);
|
||||
registry.get.mockReturnValue({
|
||||
...QUIZ_GENERATION_JOB_DEFINITION,
|
||||
prompt: { promptKey: 'custom-quiz', promptVersion: '2.0' },
|
||||
});
|
||||
|
||||
const snapshot = await builder.build({
|
||||
userId: 'u-001',
|
||||
targetType: 'knowledge_base',
|
||||
targetId: 'kb-001',
|
||||
submissionId: 'sub-001',
|
||||
});
|
||||
|
||||
expect(snapshot.snapshot.promptKey).toBe('custom-quiz');
|
||||
expect(snapshot.snapshot.promptVersion).toBe('2.0');
|
||||
});
|
||||
|
||||
it('默认值:questionCount=5, types=全类型, difficulty=medium', async () => {
|
||||
prisma.knowledgeBase.findUnique.mockResolvedValue(mockKb);
|
||||
prisma.knowledgeItem.findMany.mockResolvedValue([mockItems[0]]);
|
||||
|
||||
const snapshot = await builder.build({
|
||||
userId: 'u-001',
|
||||
targetType: 'knowledge_base',
|
||||
targetId: 'kb-001',
|
||||
submissionId: 'sub-001',
|
||||
});
|
||||
|
||||
expect(snapshot.snapshot.questionCount).toBe(5);
|
||||
expect(snapshot.snapshot.questionTypes).toEqual(['choice', 'fill', 'judge']);
|
||||
expect(snapshot.snapshot.difficultyLevel).toBe('medium');
|
||||
});
|
||||
|
||||
it('content 截断到 2000 字符', async () => {
|
||||
prisma.knowledgeBase.findUnique.mockResolvedValue(mockKb);
|
||||
prisma.knowledgeItem.findMany.mockResolvedValue([mockItems[2]]); // 2500 chars
|
||||
|
||||
const snapshot = await builder.build({
|
||||
userId: 'u-001',
|
||||
targetType: 'knowledge_base',
|
||||
targetId: 'kb-001',
|
||||
submissionId: 'sub-001',
|
||||
});
|
||||
|
||||
expect(snapshot.snapshot.knowledgeItems[0].content.length).toBeLessThanOrEqual(2000);
|
||||
});
|
||||
|
||||
it('knowledgeBase 不存在 → NotFoundException', async () => {
|
||||
prisma.knowledgeBase.findUnique.mockResolvedValue(null);
|
||||
await expect(
|
||||
builder.build({ userId: 'u-001', targetType: 'knowledge_base', targetId: 'kb-missing', submissionId: 'sub-001' }),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('knowledgeBase 已删除 → NotFoundException', async () => {
|
||||
prisma.knowledgeBase.findUnique.mockResolvedValue({ ...mockKb, deletedAt: new Date() });
|
||||
await expect(
|
||||
builder.build({ userId: 'u-001', targetType: 'knowledge_base', targetId: 'kb-001', submissionId: 'sub-001' }),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('跨用户拒绝 → ForbiddenException', async () => {
|
||||
prisma.knowledgeBase.findUnique.mockResolvedValue({ ...mockKb, userId: 'u-other' });
|
||||
await expect(
|
||||
builder.build({ userId: 'u-001', targetType: 'knowledge_base', targetId: 'kb-001', submissionId: 'sub-001' }),
|
||||
).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it('非 knowledge_base targetType → BadRequestException', async () => {
|
||||
await expect(
|
||||
builder.build({ userId: 'u-001', targetType: 'knowledge_item', targetId: 'ki-1', submissionId: 'sub-001' }),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('非法 questionCount → BadRequestException', async () => {
|
||||
await expect(
|
||||
builder.build({ userId: 'u-001', targetType: 'knowledge_base', targetId: 'kb-001', submissionId: 'sub-001', questionCount: 0 }),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
await expect(
|
||||
builder.build({ userId: 'u-001', targetType: 'knowledge_base', targetId: 'kb-001', submissionId: 'sub-001', questionCount: 51 }),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('非法 questionTypes → BadRequestException', async () => {
|
||||
await expect(
|
||||
builder.build({ userId: 'u-001', targetType: 'knowledge_base', targetId: 'kb-001', submissionId: 'sub-001', questionTypes: ['essay' as any] }),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('非法 difficultyLevel → BadRequestException', async () => {
|
||||
await expect(
|
||||
builder.build({ userId: 'u-001', targetType: 'knowledge_base', targetId: 'kb-001', submissionId: 'sub-001', difficultyLevel: 'extreme' }),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('空知识库 → BadRequestException', async () => {
|
||||
prisma.knowledgeBase.findUnique.mockResolvedValue(mockKb);
|
||||
prisma.knowledgeItem.findMany.mockResolvedValue([]);
|
||||
await expect(
|
||||
builder.build({ userId: 'u-001', targetType: 'knowledge_base', targetId: 'kb-001', submissionId: 'sub-001' }),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('Snapshot 不含敏感字段', async () => {
|
||||
prisma.knowledgeBase.findUnique.mockResolvedValue(mockKb);
|
||||
prisma.knowledgeItem.findMany.mockResolvedValue([mockItems[0]]);
|
||||
|
||||
const snapshot = await builder.build({
|
||||
userId: 'u-001', targetType: 'knowledge_base', targetId: 'kb-001', submissionId: 'sub-001',
|
||||
});
|
||||
|
||||
const serialized = JSON.stringify(snapshot);
|
||||
expect(serialized).not.toContain('Authorization');
|
||||
expect(serialized).not.toContain('JWT');
|
||||
expect(serialized).not.toContain('apiKey');
|
||||
expect(serialized).not.toContain('api_key');
|
||||
expect(serialized).not.toContain('password');
|
||||
expect(serialized).not.toContain('DATABASE_URL');
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeHash', () => {
|
||||
it('相同输入 → 相同 hash', async () => {
|
||||
prisma.knowledgeBase.findUnique.mockResolvedValue(mockKb);
|
||||
prisma.knowledgeItem.findMany.mockResolvedValue([mockItems[0]]);
|
||||
|
||||
const s1 = await builder.build({ userId: 'u-001', targetType: 'knowledge_base', targetId: 'kb-001', submissionId: 'sub-001' });
|
||||
const s2 = await builder.build({ userId: 'u-001', targetType: 'knowledge_base', targetId: 'kb-001', submissionId: 'sub-001' });
|
||||
|
||||
expect(builder.computeHash(s1)).toBe(builder.computeHash(s2));
|
||||
});
|
||||
|
||||
it('不同输入 → 不同 hash', async () => {
|
||||
prisma.knowledgeBase.findUnique.mockResolvedValue(mockKb);
|
||||
prisma.knowledgeItem.findMany.mockResolvedValueOnce([mockItems[0]]);
|
||||
const s1 = await builder.build({ userId: 'u-001', targetType: 'knowledge_base', targetId: 'kb-001', submissionId: 'sub-001' });
|
||||
|
||||
prisma.knowledgeItem.findMany.mockResolvedValueOnce([mockItems[0], mockItems[1]]);
|
||||
const s2 = await builder.build({ userId: 'u-001', targetType: 'knowledge_base', targetId: 'kb-001', submissionId: 'sub-002' });
|
||||
|
||||
expect(builder.computeHash(s1)).not.toBe(builder.computeHash(s2));
|
||||
});
|
||||
|
||||
it('hash 长度 16,hex 格式', async () => {
|
||||
prisma.knowledgeBase.findUnique.mockResolvedValue(mockKb);
|
||||
prisma.knowledgeItem.findMany.mockResolvedValue([mockItems[0]]);
|
||||
const snapshot = await builder.build({ userId: 'u-001', targetType: 'knowledge_base', targetId: 'kb-001', submissionId: 'sub-001' });
|
||||
|
||||
const hash = builder.computeHash(snapshot);
|
||||
expect(hash).toHaveLength(16);
|
||||
expect(hash).toMatch(/^[0-9a-f]{16}$/);
|
||||
});
|
||||
});
|
||||
});
|
||||
28
src/modules/ai-job/quiz-generation-registration.service.ts
Normal file
28
src/modules/ai-job/quiz-generation-registration.service.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { JobDefinitionRegistry, DuplicateJobTypeError } from './job-definition-registry';
|
||||
import { QUIZ_GENERATION_JOB_DEFINITION } from './quiz-generation-job-definition';
|
||||
|
||||
@Injectable()
|
||||
export class QuizGenerationRegistrationService implements OnModuleInit {
|
||||
private readonly logger = new Logger(QuizGenerationRegistrationService.name);
|
||||
|
||||
constructor(private readonly registry: JobDefinitionRegistry) {}
|
||||
|
||||
onModuleInit(): void {
|
||||
try {
|
||||
this.registry.register(QUIZ_GENERATION_JOB_DEFINITION);
|
||||
this.logger.log(
|
||||
`Quiz Generation Job Definition registered: ` +
|
||||
`jobType="${QUIZ_GENERATION_JOB_DEFINITION.jobType}" ` +
|
||||
`queue="${QUIZ_GENERATION_JOB_DEFINITION.queue.queueName}" ` +
|
||||
`timeout=${QUIZ_GENERATION_JOB_DEFINITION.execution.timeoutMs}ms`,
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof DuplicateJobTypeError) {
|
||||
this.logger.log('Quiz Generation Definition already registered (dual process)');
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
267
src/modules/ai-job/quiz-generation-snapshot-builder.ts
Normal file
267
src/modules/ai-job/quiz-generation-snapshot-builder.ts
Normal file
@ -0,0 +1,267 @@
|
||||
import { Injectable, Logger, NotFoundException, ForbiddenException, BadRequestException } from '@nestjs/common';
|
||||
import * as crypto from 'crypto';
|
||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||
import { JobDefinitionRegistry } from './job-definition-registry';
|
||||
|
||||
/**
|
||||
* M-AI-07-02: Quiz Generation Snapshot Builder
|
||||
*
|
||||
* 为统一 Job Engine 构建 Quiz 生成输入快照。
|
||||
*
|
||||
* 契约依据:docs/architecture/m-ai-07-quiz-generation-migration-contract.md §5
|
||||
*
|
||||
* 职责:
|
||||
* 1. 校验 knowledgeBase 存在且属于当前用户
|
||||
* 2. 加载知识点列表(限制数量 + 截断内容长度)
|
||||
* 3. 规范化 questionCount / questionTypes / difficultyLevel
|
||||
* 4. 从 JobDefinitionRegistry 读取 prompt/model 配置
|
||||
* 5. 构建版本化、最小化、脱敏的快照
|
||||
* 6. 计算稳定的 contentHash
|
||||
*
|
||||
* 禁止:
|
||||
* - 存储 JWT / API Key / Cookie / PII
|
||||
* - 存储完整知识库未筛选内容
|
||||
* - 存储其他用户内容
|
||||
* - 将随机值或时间加入 hash
|
||||
*
|
||||
* Snapshot Schema(quiz-generation-v1):
|
||||
* userId, targetType, targetId, submissionId,
|
||||
* knowledgeBaseTitle/Description, knowledgeItems (truncated),
|
||||
* questionCount, questionTypes, difficultyLevel, knowledgePointIds,
|
||||
* promptKey, promptVersion, modelTier,
|
||||
* inputSchemaVersion, outputSchemaVersion, createdAt
|
||||
*/
|
||||
|
||||
const SNAPSHOT_SCHEMA_VERSION = 'quiz-generation-v1';
|
||||
|
||||
/** 允许的题型(契约 §4.1) */
|
||||
const VALID_QUESTION_TYPES = ['choice', 'fill', 'judge'] as const;
|
||||
|
||||
/** 允许的难度级别 */
|
||||
const VALID_DIFFICULTY_LEVELS = ['easy', 'medium', 'hard'] as const;
|
||||
|
||||
/** 题目数量范围 */
|
||||
const MIN_QUESTION_COUNT = 1;
|
||||
const MAX_QUESTION_COUNT = 50;
|
||||
|
||||
/** 知识点内容截断长度 */
|
||||
const MAX_ITEM_CONTENT_LENGTH = 2000;
|
||||
/** 知识点最大加载数量 */
|
||||
const MAX_KNOWLEDGE_ITEMS = 50;
|
||||
|
||||
export interface QuizGenerationSnapshot {
|
||||
schemaVersion: string;
|
||||
snapshot: {
|
||||
userId: string;
|
||||
targetType: string;
|
||||
targetId: string;
|
||||
submissionId: string;
|
||||
knowledgeBaseTitle: string;
|
||||
knowledgeBaseDescription: string | null;
|
||||
knowledgeItems: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
content: string; // truncated to MAX_ITEM_CONTENT_LENGTH
|
||||
summary: string;
|
||||
}>;
|
||||
questionCount: number;
|
||||
questionTypes: string[];
|
||||
difficultyLevel: string;
|
||||
knowledgePointIds: string[];
|
||||
promptKey: string;
|
||||
promptVersion: string;
|
||||
modelTier: string;
|
||||
inputSchemaVersion: string;
|
||||
outputSchemaVersion: string;
|
||||
createdAt: string; // ISO8601 normalized to second
|
||||
};
|
||||
}
|
||||
|
||||
export interface QuizGenerationSnapshotInput {
|
||||
userId: string;
|
||||
targetType: string;
|
||||
targetId: string;
|
||||
submissionId: string;
|
||||
questionCount?: number;
|
||||
questionTypes?: string[];
|
||||
difficultyLevel?: string;
|
||||
knowledgePointIds?: string[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class QuizGenerationSnapshotBuilder {
|
||||
private readonly logger = new Logger(QuizGenerationSnapshotBuilder.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly registry: JobDefinitionRegistry,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 构建 Quiz 生成输入快照。
|
||||
*
|
||||
* @param input - 快照构建参数
|
||||
* @returns 版本化、脱敏的快照对象
|
||||
*
|
||||
* @throws NotFoundException knowledgeBase 不存在
|
||||
* @throws ForbiddenException knowledgeBase 不属于当前用户
|
||||
* @throws BadRequestException 参数非法
|
||||
*/
|
||||
async build(input: QuizGenerationSnapshotInput): Promise<QuizGenerationSnapshot> {
|
||||
// 1. 从 Registry 读取配置(单一事实来源)
|
||||
const def = this.registry.get('quiz_generation');
|
||||
|
||||
// 2. 校验 targetType
|
||||
if (input.targetType !== 'knowledge_base') {
|
||||
throw new BadRequestException(
|
||||
`quiz_generation requires targetType="knowledge_base", got "${input.targetType}"`,
|
||||
);
|
||||
}
|
||||
|
||||
// 3. 规范化参数(在 DB 查询前,fail-fast)
|
||||
const questionCount = this.normalizeQuestionCount(input.questionCount);
|
||||
const questionTypes = this.normalizeQuestionTypes(input.questionTypes);
|
||||
const difficultyLevel = this.normalizeDifficultyLevel(input.difficultyLevel);
|
||||
const knowledgePointIds = input.knowledgePointIds ?? [];
|
||||
|
||||
// 4. 加载并校验 knowledgeBase
|
||||
const kb = await this.prisma.knowledgeBase.findUnique({
|
||||
where: { id: input.targetId },
|
||||
});
|
||||
if (!kb || kb.deletedAt) {
|
||||
throw new NotFoundException(`KnowledgeBase ${input.targetId} not found`);
|
||||
}
|
||||
if (kb.userId !== input.userId) {
|
||||
throw new ForbiddenException(
|
||||
`KnowledgeBase ${input.targetId} does not belong to user ${input.userId}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 5. 加载知识点(限制数量 + 截断内容)
|
||||
const where: any = {
|
||||
knowledgeBaseId: input.targetId,
|
||||
deletedAt: null,
|
||||
status: 'active',
|
||||
};
|
||||
if (knowledgePointIds.length > 0) {
|
||||
where.id = { in: knowledgePointIds };
|
||||
}
|
||||
|
||||
const items = await this.prisma.knowledgeItem.findMany({
|
||||
where,
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
content: true,
|
||||
summary: true,
|
||||
},
|
||||
orderBy: { orderIndex: 'asc' },
|
||||
take: MAX_KNOWLEDGE_ITEMS,
|
||||
});
|
||||
|
||||
if (items.length === 0) {
|
||||
throw new BadRequestException(
|
||||
'Knowledge base has no active knowledge items',
|
||||
);
|
||||
}
|
||||
|
||||
// 截断内容(最小化快照)
|
||||
const knowledgeItems = items.map((item) => ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
content: (item.content ?? '').slice(0, MAX_ITEM_CONTENT_LENGTH),
|
||||
summary: item.summary ?? '',
|
||||
}));
|
||||
|
||||
// 6. 构建快照
|
||||
const now = new Date();
|
||||
const snapshot: QuizGenerationSnapshot = {
|
||||
schemaVersion: SNAPSHOT_SCHEMA_VERSION,
|
||||
snapshot: {
|
||||
userId: input.userId,
|
||||
targetType: input.targetType,
|
||||
targetId: input.targetId,
|
||||
submissionId: input.submissionId,
|
||||
knowledgeBaseTitle: kb.title,
|
||||
knowledgeBaseDescription: kb.description ?? null,
|
||||
knowledgeItems,
|
||||
questionCount,
|
||||
questionTypes,
|
||||
difficultyLevel,
|
||||
knowledgePointIds,
|
||||
promptKey: def.prompt.promptKey,
|
||||
promptVersion: def.prompt.promptVersion,
|
||||
modelTier: def.model.modelTier,
|
||||
inputSchemaVersion: SNAPSHOT_SCHEMA_VERSION,
|
||||
outputSchemaVersion: def.output.schemaVersion,
|
||||
createdAt: now.toISOString().replace(/\.\d{3}Z$/, 'Z'),
|
||||
},
|
||||
};
|
||||
|
||||
this.logger.log(
|
||||
`Built Quiz Generation snapshot for kb=${input.targetId} ` +
|
||||
`userId=${input.userId} items=${knowledgeItems.length} ` +
|
||||
`questionCount=${questionCount} types=${questionTypes.join(',')} ` +
|
||||
`difficulty=${difficultyLevel}`,
|
||||
);
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算快照的 contentHash(SHA256 前 16 字符)。
|
||||
*/
|
||||
computeHash(snapshot: QuizGenerationSnapshot): string {
|
||||
const serialized = JSON.stringify(
|
||||
snapshot.snapshot,
|
||||
Object.keys(snapshot.snapshot).sort(),
|
||||
);
|
||||
return crypto
|
||||
.createHash('sha256')
|
||||
.update(serialized)
|
||||
.digest('hex')
|
||||
.substring(0, 16);
|
||||
}
|
||||
|
||||
// ── Private Helpers ──
|
||||
|
||||
private normalizeQuestionCount(raw?: number): number {
|
||||
if (raw == null) return 5; // default
|
||||
if (!Number.isInteger(raw) || raw < MIN_QUESTION_COUNT) {
|
||||
throw new BadRequestException(
|
||||
`questionCount must be >= ${MIN_QUESTION_COUNT}, got ${raw}`,
|
||||
);
|
||||
}
|
||||
if (raw > MAX_QUESTION_COUNT) {
|
||||
throw new BadRequestException(
|
||||
`questionCount must be <= ${MAX_QUESTION_COUNT}, got ${raw}`,
|
||||
);
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
private normalizeQuestionTypes(raw?: string[]): string[] {
|
||||
if (!raw || raw.length === 0) {
|
||||
return ['choice', 'fill', 'judge']; // default: all types
|
||||
}
|
||||
const invalid = raw.filter((t) => !(VALID_QUESTION_TYPES as readonly string[]).includes(t));
|
||||
if (invalid.length > 0) {
|
||||
throw new BadRequestException(
|
||||
`Invalid question types: ${invalid.join(', ')}. ` +
|
||||
`Allowed: ${VALID_QUESTION_TYPES.join(', ')}`,
|
||||
);
|
||||
}
|
||||
return [...new Set(raw)]; // dedup
|
||||
}
|
||||
|
||||
private normalizeDifficultyLevel(raw?: string): string {
|
||||
if (!raw) return 'medium';
|
||||
if (!(VALID_DIFFICULTY_LEVELS as readonly string[]).includes(raw)) {
|
||||
throw new BadRequestException(
|
||||
`Invalid difficultyLevel "${raw}". ` +
|
||||
`Allowed: ${VALID_DIFFICULTY_LEVELS.join(', ')}`,
|
||||
);
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user