api-server/src/modules/ai-job/quiz-generation-snapshot-builder.ts
wangdl 9cac4781bd
All checks were successful
Deploy API Server / build-and-unit (push) Successful in 35s
Deploy API Server / current-integration (push) Successful in 3m3s
Deploy API Server / backward-compat (push) Successful in 0s
Deploy API Server / deploy (push) Successful in 1m4s
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>
2026-06-22 20:34:33 +08:00

268 lines
8.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 Schemaquiz-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;
}
/**
* 计算快照的 contentHashSHA256 前 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;
}
}