api-server/src/modules/ai-job/review-card-generation-snapshot-builder.ts
wangdl f1e529b99e
Some checks failed
Deploy API Server / build-and-unit (push) Failing after 26s
Deploy API Server / current-integration (push) Has been skipped
Deploy API Server / backward-compat (push) Has been skipped
Deploy API Server / deploy (push) Has been skipped
feat: M-AI-06 Feynman ReviewCard Child Job 与可靠生成
完成 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>
2026-06-22 19:27:14 +08:00

218 lines
7.6 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 } 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 Schemareview-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 评估结果 IDfe_<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;
}
/**
* 计算快照的 contentHashSHA256 前 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}`;
}
}