完成 M-AI-06 全部 7 个 Issue: - 01: 审计冻结契约 (docs/architecture/m-ai-06-review-card-child-job-contract.md) - 02: AiJobCreationService.createInTransaction(tx, input) - 03: ReviewCard Generation Job Definition + Snapshot Builder - 04: ReviewCard Generation Executor + 输出验证 Validator - 05: ReviewCard Generation Projector + 卡片幂等 + Engine 接入 - 06: FeynmanProjector 接入 FEYNMAN_REVIEW_CARD_MODE Feature Flag - 07: E2E 测试 (test/m-ai-06-review-card-child.e2e-spec.ts) 核心变更: - 新增 10 个文件, 修改 7 个文件 - 新增 createInTransaction() 外部事务支持 - 新增 review_card_generation Job Type (ai-background / cheap tier) - FeynmanProjector 根据 Feature Flag 路由 child_job / legacy_event - Projector 入口幂等 + P2002 回退双重保障 - 测试: 单元 482 passed, E2E 待 CI 执行 Co-Authored-By: Claude <noreply@anthropic.com>
218 lines
7.6 KiB
TypeScript
218 lines
7.6 KiB
TypeScript
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_<parentJobId>) */
|
||
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}`;
|
||
}
|
||
}
|