import { Injectable, Logger, NotFoundException, ForbiddenException } from '@nestjs/common'; import * as crypto from 'crypto'; import { PrismaService } from '../../infrastructure/database/prisma.service'; import { JobDefinitionRegistry } from './job-definition-registry'; /** * M-AI-05-02: Feynman Snapshot Builder * * 为统一 Job Engine 构建 Feynman 评估输入快照。 * * 契约依据:docs/architecture/m-ai-05-feynman-migration-contract.md §3 * * 职责: * 1. 加载 KnowledgeItem 并验证所有权(item.userId === userId) * 2. 加载关联知识库信息和参考材料摘要 * 3. 从 JobDefinitionRegistry 读取 prompt/model 配置(单一事实来源) * 4. 构建版本化、最小化、脱敏的快照 * 5. 计算 contentHash(SHA256 前 16 字符) * * 禁止: * - 存储 JWT / API Key / Cookie / DB 连接 / PII * - 存储完整用户画像 * - 硬编码 prompt/model 配置(应从 Definition 读取) * - 将每次生成时间加入 hash 输入 * * Snapshot Schema(feynman-evaluation-v1): * userId, knowledgeItemId, knowledgeItemTitle, knowledgeItemContent, * userExplanation, submissionId, knowledgeBaseId, * referenceMaterials (summary only), promptKey, promptVersion, * modelTier, inputSchemaVersion, outputSchemaVersion, createdAt */ const SNAPSHOT_SCHEMA_VERSION = 'feynman-evaluation-v1'; export interface FeynmanSnapshot { schemaVersion: string; snapshot: { userId: string; knowledgeItemId: string; knowledgeItemTitle: string; knowledgeItemContent: string; userExplanation: string; submissionId: string; knowledgeBaseId: string; referenceMaterials: Array<{ id: string; title: string; summary: string; }>; promptKey: string; promptVersion: string; modelTier: string; inputSchemaVersion: string; outputSchemaVersion: string; createdAt: string; // ISO8601 normalized to second }; } /** * Feynman Snapshot Build 输入参数。 * * knowledgeItemId 为必填 — 若当前请求体不含此字段, * 调用方(M-AI-05-05)需在路由层通过标题+内容匹配或要求客户端传入。 */ export interface FeynmanSnapshotInput { userId: string; knowledgeItemId: string; knowledgeItemTitle: string; knowledgeItemContent: string; userExplanation: string; submissionId: string; sessionId?: string; answerId?: string; } @Injectable() export class FeynmanSnapshotBuilder { private readonly logger = new Logger(FeynmanSnapshotBuilder.name); constructor( private readonly prisma: PrismaService, private readonly registry: JobDefinitionRegistry, ) {} /** * 构建 Feynman 评估输入快照。 * * prompt/model 配置从 JobDefinitionRegistry 读取(单一事实来源), * 避免与 feynman-job-definition.ts 重复硬编码。 * * @param input - Feynman 快照构建参数 * @returns 版本化、脱敏的快照对象 * * @throws NotFoundException KnowledgeItem 不存在 * @throws ForbiddenException KnowledgeItem 不属于当前用户 */ async build(input: FeynmanSnapshotInput): Promise { // 1. 从 Registry 读取配置(单一事实来源) const def = this.registry.get('feynman_evaluation'); // 2. 加载 KnowledgeItem 并验证所有权 const knowledgeItem = await this.prisma.knowledgeItem.findUnique({ where: { id: input.knowledgeItemId }, }); if (!knowledgeItem) { throw new NotFoundException( `KnowledgeItem ${input.knowledgeItemId} not found`, ); } if (knowledgeItem.userId !== input.userId) { throw new ForbiddenException( `KnowledgeItem ${input.knowledgeItemId} does not belong to user ${input.userId}`, ); } // 3. 加载参考材料摘要(同一知识库内最多 5 条关联知识点,仅取摘要不取全文) const referenceMaterials = await this.loadReferenceMaterials( knowledgeItem.knowledgeBaseId, ); // 4. 构建快照(仅包含模型调用所需最小字段) // prompt/model 值全部来自 Definition const now = new Date(); const snapshot: FeynmanSnapshot = { schemaVersion: SNAPSHOT_SCHEMA_VERSION, snapshot: { userId: input.userId, knowledgeItemId: input.knowledgeItemId, knowledgeItemTitle: input.knowledgeItemTitle, knowledgeItemContent: input.knowledgeItemContent, userExplanation: input.userExplanation, submissionId: input.submissionId, knowledgeBaseId: knowledgeItem.knowledgeBaseId, referenceMaterials, promptKey: def.prompt.promptKey, promptVersion: def.prompt.promptVersion, modelTier: def.model.modelTier, inputSchemaVersion: SNAPSHOT_SCHEMA_VERSION, outputSchemaVersion: def.output.schemaVersion, // 归一化到秒(截断毫秒以保证相同输入→相同hash) createdAt: now.toISOString().replace(/\.\d{3}Z$/, 'Z'), }, }; this.logger.log( `Built Feynman snapshot for knowledgeItem=${input.knowledgeItemId} ` + `userId=${input.userId} submissionId=${input.submissionId} ` + `promptKey=${def.prompt.promptKey}`, ); return snapshot; } /** * 计算快照的 contentHash(SHA256 前 16 字符)。 * * 相同输入 → 相同输出;用于幂等比较和审计追溯。 * 使用稳定序列化(JSON 紧凑格式,字段按字母序)。 */ computeHash(snapshot: FeynmanSnapshot): string { // 稳定序列化:只对 snapshot 内容做 hash,不包含外层 schemaVersion const serialized = JSON.stringify(snapshot.snapshot, Object.keys(snapshot.snapshot).sort()); return crypto .createHash('sha256') .update(serialized) .digest('hex') .substring(0, 16); } // ── Private Helpers ── /** * 加载参考材料:同一知识库内的活跃知识点摘要(排除当前项,最多 5 条)。 * * 只取 id/title/summary,不加载完整 content 以防止快照膨胀。 */ private async loadReferenceMaterials( knowledgeBaseId: string, ): Promise> { const items = await this.prisma.knowledgeItem.findMany({ where: { knowledgeBaseId, status: 'active', deletedAt: null, }, select: { id: true, title: true, summary: true, }, orderBy: { orderIndex: 'asc' }, take: 5, }); return items.map((item) => ({ id: item.id, title: item.title, summary: item.summary ?? '', })); } }