import { Injectable, Logger } from '@nestjs/common'; import * as crypto from 'crypto'; import { JobDefinitionRegistry } from './job-definition-registry'; /** * M-AI-06-03: ReviewCard Generation Snapshot Builder * * 为 ReviewCard Generation Child Job 构建不可变输入快照。 * * 契约依据:docs/architecture/m-ai-06-review-card-child-job-contract.md §4 * * 职责: * 1. 接收已验证的 Feynman 输出(非模型原始响应) * 2. 从 JobDefinitionRegistry 读取 prompt/model 配置(单一事实来源) * 3. 构建版本化、最小化、脱敏的快照 * 4. 计算稳定的 contentHash(相同输入→相同 hash) * * 禁止: * - 再次读取数据库(所有数据由调用方传入) * - 再次读取未经验证的模型原始响应 * - 存储 JWT / API Key / Cookie / DB 连接 / PII * - 将随机值或当前毫秒时间加入 hash * - 硬编码 prompt/model 配置(应从 Definition 读取) * * Snapshot Schema(review-card-generation-v1): * userId, parentJobId, sourceResultId, knowledgeItemId, knowledgeBaseId, * title, summary, strengths, weaknesses, cardCount, * promptKey, promptVersion, modelTier, * inputSchemaVersion, outputSchemaVersion, createdAt */ const SNAPSHOT_SCHEMA_VERSION = 'review-card-generation-v1'; /** * ReviewCard Generation 快照(版本化包装) */ export interface ReviewCardGenerationSnapshot { /** Snapshot Schema 版本 */ schemaVersion: string; /** 快照内容(不可变,全部进入 AiJobSnapshot.content) */ snapshot: { /** 用户 ID(从 Parent Job 继承,不得在 Child Worker 中重新决定) */ userId: string; /** 父 Feynman Job ID */ parentJobId: string; /** 来源 Feynman 评估结果 ID(fe_) */ sourceResultId: string; /** 知识点 ID(从 Parent Snapshot 读取) */ knowledgeItemId: string; /** 知识库 ID(从 Parent Snapshot 读取) */ knowledgeBaseId: string; /** 评估摘要截断(用作卡片生成标题,max 80 字符) */ title: string; /** 拼接摘要文本(summary + strengths + weaknesses) */ summary: string; /** 掌握项(从 Feynman validatedOutput) */ strengths: string[]; /** 薄弱项(从 Feynman validatedOutput,用于决定 cardCount) */ weaknesses: string[]; /** 目标卡片数量(冻结规则:min(3, max(1, weaknesses.length || 1))) */ cardCount: number; /** 冻结的 Prompt Key(从 Definition) */ promptKey: string; /** 冻结的 Prompt Version(从 Definition) */ promptVersion: string; /** 模型层级(从 Definition,冻结为 cheap) */ modelTier: string; /** 输入 Schema 版本(与 Definition.input.schemaVersion 一致) */ inputSchemaVersion: string; /** 输出 Schema 版本(与 Definition.output.schemaVersion 一致) */ outputSchemaVersion: string; /** 快照创建时间(ISO8601 归一化到秒,保证稳定 hash) */ createdAt: string; }; } /** * ReviewCard Generation Snapshot Build 输入参数。 * * 所有字段来源于已验证的 Feynman 输出,非模型原始响应。 */ export interface ReviewCardGenerationSnapshotInput { /** 用户 ID */ userId: string; /** 父 Feynman Job ID */ parentJobId: string; /** 来源 Feynman 评估结果 ID */ sourceResultId: string; /** 知识点 ID */ knowledgeItemId: string; /** 知识库 ID */ knowledgeBaseId: string; /** 评估摘要(用于 title 和 summary 拼接) */ summary: string; /** 掌握项(字符串数组) */ strengths: string[]; /** 薄弱项(字符串数组) */ weaknesses: string[]; } @Injectable() export class ReviewCardGenerationSnapshotBuilder { private readonly logger = new Logger(ReviewCardGenerationSnapshotBuilder.name); constructor(private readonly registry: JobDefinitionRegistry) {} /** * 构建 ReviewCard Generation 输入快照。 * * prompt/model 配置从 JobDefinitionRegistry 读取(单一事实来源), * 避免与 review-card-generation-job-definition.ts 重复硬编码。 * * 稳定性保证: * - createdAt 归一化到秒(截断毫秒) * - cardCount 确定性计算(无随机性) * - 不包含时间戳或随机值 * * @param input - 快照构建参数(来源于已验证的 Feynman 输出) * @returns 版本化、脱敏的快照对象 */ build(input: ReviewCardGenerationSnapshotInput): ReviewCardGenerationSnapshot { // 1. 从 Registry 读取配置(单一事实来源) const def = this.registry.get('review_card_generation'); // 2. 确定性计算 cardCount(冻结规则:min(3, max(1, weaknesses.length || 1))) const cardCount = this.computeCardCount(input.weaknesses); // 3. 构建 title(摘要截断 80 字符)和 summary(拼接) const title = input.summary ? input.summary.slice(0, 80) : 'AI 分析结果'; const summary = this.buildSummaryText(input.summary, input.strengths, input.weaknesses); // 4. 构建快照(仅包含模型调用所需最小字段) // prompt/model 值全部来自 Definition const now = new Date(); const snapshot: ReviewCardGenerationSnapshot = { schemaVersion: SNAPSHOT_SCHEMA_VERSION, snapshot: { userId: input.userId, parentJobId: input.parentJobId, sourceResultId: input.sourceResultId, knowledgeItemId: input.knowledgeItemId, knowledgeBaseId: input.knowledgeBaseId, title, summary, strengths: input.strengths ?? [], weaknesses: input.weaknesses ?? [], cardCount, 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 ReviewCard Generation snapshot for parentJobId=${input.parentJobId} ` + `userId=${input.userId} cardCount=${cardCount} ` + `promptKey=${def.prompt.promptKey}`, ); return snapshot; } /** * 计算快照的 contentHash(SHA256 前 16 字符)。 * * 相同输入 → 相同输出;用于幂等比较和审计追溯。 * 使用稳定序列化(JSON 紧凑格式,字段按字母序排序)。 */ computeHash(snapshot: ReviewCardGenerationSnapshot): 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 ── /** * 确定性计算卡片数量。 * * 规则(契约 §1.4): * Math.min(3, Math.max(1, weaknesses.length || 1)) * * 范围 [1, 3]。无 weaknesses 时生成 1 张卡片。 */ private computeCardCount(weaknesses: string[]): number { const count = weaknesses?.length || 0; return Math.min(3, Math.max(1, count)); } /** * 拼接 summary 文本(用于模型 user message)。 * * 格式:"摘要:{summary}\n\n掌握点:{strengths}\n\n薄弱点:{weaknesses}" * 与 Legacy ReviewCardSubscriber 的拼接方式一致(review-card.subscriber.ts:36-37)。 */ private buildSummaryText( summary: string, strengths: string[], weaknesses: string[], ): string { const s = (strengths ?? []).join(';'); const w = (weaknesses ?? []).join(';'); return `摘要:${summary || ''}\n\n掌握点:${s}\n\n薄弱点:${w}`; } }