- 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>
268 lines
8.2 KiB
TypeScript
268 lines
8.2 KiB
TypeScript
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;
|
||
}
|
||
}
|