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>
202 lines
6.5 KiB
TypeScript
202 lines
6.5 KiB
TypeScript
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<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;
|
||
}
|
||
|
||
/**
|
||
* 计算快照的 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<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 ?? '',
|
||
}));
|
||
}
|
||
}
|