api-server/src/modules/ai-job/feynman-snapshot-builder.ts
wangdl 8987598eb8 feat(M-AI-05): track Feynman unified engine migration implementation
23 files (+4676/-10):
- Contract: m-ai-05-feynman-migration-contract.md (737 lines)
- Gate audit: m-ai-05-gate-audit.md (318 lines)
- Job Definition + Snapshot Builder + Registration
- Executor + BusinessValidator + ReferenceValidator
- Projector (atomic transaction + 3-layer idempotency)
- ExecutionRouter (FeatureFlag + idempotencyKey)
- ObservabilityService (structured logging + counters)
- Engine: feynman_evaluation execution branch
- AiJobCreationService: feynman_evaluation safety branch
- Controller/Module: Router injection
- CI: path detection for m-ai-05
- E2E: 8 HTTP-layer scenarios (14 total)
- Unit tests: 104 new tests (5 spec files)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-21 17:44:58 +08:00

202 lines
6.5 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 } 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. 计算 contentHashSHA256 前 16 字符)
*
* 禁止:
* - 存储 JWT / API Key / Cookie / DB 连接 / PII
* - 存储完整用户画像
* - 硬编码 prompt/model 配置(应从 Definition 读取)
* - 将每次生成时间加入 hash 输入
*
* Snapshot Schemafeynman-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<FeynmanSnapshot> {
// 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;
}
/**
* 计算快照的 contentHashSHA256 前 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<Array<{ id: string; title: string; summary: string }>> {
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 ?? '',
}));
}
}