api-server/src/modules/ai-analysis/feynman-execution-router.ts
wangdl a14f490526
All checks were successful
Deploy API Server / build-and-unit (push) Successful in 35s
Deploy API Server / current-integration (push) Successful in 3m2s
Deploy API Server / backward-compat (push) Successful in 17s
Deploy API Server / deploy (push) Successful in 1m2s
fix(M-AI-05): correct createJob return type — result.id not result.job.id
AiJobCreationService.createJob() returns AiJob entity directly,
not wrapped in { job: {...} }.

Build error: TS2339 Property 'job' does not exist on type AiJob.
Fixed: result.job.id → result.id (lines 135, 141).

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

194 lines
6.7 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, BadRequestException } from '@nestjs/common';
import * as crypto from 'crypto';
import { FeatureFlagService } from '../config/feature-flag.service';
import { FeynmanSnapshotBuilder } from '../ai-job/feynman-snapshot-builder';
import type { FeynmanSnapshotInput } from '../ai-job/feynman-snapshot-builder';
import { AiJobCreationService } from '../ai-job/ai-job-creation.service';
import { JobDefinitionRegistry } from '../ai-job/job-definition-registry';
import { AiAnalysisService } from './ai-analysis.service';
/**
* M-AI-05-05: Feynman Execution Router
*
* 根据 FEYNMAN_ENGINE_MODE Feature Flag 决定 Feynman 评估的执行分支:
* - 'legacy' → 原 AiAnalysisService.evaluateFeynman() 路径
* - 'unified' → FeynmanSnapshotBuilder → AiJobCreationService → Unified Job Engine
*
* 设计约束(契约 §10
* - 分支判断集中在 Router不散落在 Controller/Service/Worker
* - 支持用户白名单(通过 FeatureFlagService
* - 默认 legacyFeature Flag 不存在或 disabled 时)
* - Unified 失败不得自动调用 Legacy
* - 同一请求只能执行一个引擎
*/
const FLAG_NAME = 'FEYNMAN_ENGINE_MODE';
/** Feynman HTTP 请求体(与 AiAnalysisController 保持一致) */
export interface FeynmanEvaluateInput {
knowledgeItemTitle: string;
knowledgeItemContent: string;
userExplanation: string;
sessionId?: string;
answerId?: string;
}
/** Unified 模式扩展响应 */
export interface FeynmanUnifiedResponse {
jobId: string;
status: string;
engineMode: 'unified';
lifecycleStatus: string;
}
/** Legacy 兼容响应 */
export interface FeynmanLegacyResponse {
jobId: string;
status: string;
}
@Injectable()
export class FeynmanExecutionRouter {
private readonly logger = new Logger(FeynmanExecutionRouter.name);
constructor(
private readonly featureFlag: FeatureFlagService,
private readonly snapshotBuilder: FeynmanSnapshotBuilder,
private readonly creationService: AiJobCreationService,
private readonly registry: JobDefinitionRegistry,
private readonly legacyService: AiAnalysisService,
) {}
/**
* 路由 Feynman 评估请求。
*
* @param userId - 请求用户 ID
* @param input - 请求体knowledgeItemTitle/content/explanation + 可选的 sessionId/answerId
* @param knowledgeItemId - 知识点 ID由 Controller 从请求体获取或后续客户端传入)
* @returns Legacy 或 Unified 响应
*/
async evaluateFeynman(
userId: string,
input: FeynmanEvaluateInput,
knowledgeItemId?: string,
): Promise<FeynmanLegacyResponse | FeynmanUnifiedResponse> {
// 1. 基本参数校验(与 Legacy 一致)
if (!input.knowledgeItemTitle?.trim()) {
throw new BadRequestException('knowledgeItemTitle is required');
}
if (!input.knowledgeItemContent?.trim()) {
throw new BadRequestException('knowledgeItemContent is required');
}
if (!input.userExplanation?.trim()) {
throw new BadRequestException('userExplanation is required');
}
// 2. 检查 Feature Flag
const useUnified = await this.shouldUseUnified(userId);
if (!useUnified) {
// ── Legacy 路径 ──
return this.legacyService.evaluateFeynman(userId, input);
}
// ═════════════════════════════════════════════════════════
// ── Unified 路径 ──
// ═════════════════════════════════════════════════════════
// 3. 确定 knowledgeItemId
// 当前请求体不含此字段(契约 U-2使用传入值或占位符
// M-AI-05-07 及后续客户端升级后可传入真实 ID
const resolvedKnowledgeItemId = knowledgeItemId || 'unknown';
// 4. 确定稳定 submissionId幂等键来源
const submissionId = this.resolveSubmissionId(input);
// 5. 构造 idempotencyKey
const idempotencyKey = `feynman:${submissionId}`;
// 6. 构建 Snapshot
const snapshotInput: FeynmanSnapshotInput = {
userId,
knowledgeItemId: resolvedKnowledgeItemId,
knowledgeItemTitle: input.knowledgeItemTitle,
knowledgeItemContent: input.knowledgeItemContent,
userExplanation: input.userExplanation,
submissionId,
sessionId: input.sessionId,
answerId: input.answerId,
};
const snapshot = await this.snapshotBuilder.build(snapshotInput);
// 7. 通过 AiJobCreationService 创建 Job原子Job + Snapshot + Outbox
const result = await this.creationService.createJob({
userId,
jobType: 'feynman_evaluation',
triggerType: 'user_api',
targetType: 'knowledge_item',
targetId: resolvedKnowledgeItemId,
idempotencyKey,
retrySnapshotContent: snapshot as unknown as Record<string, unknown>,
});
this.logger.log(
`Feynman Unified: jobId=${result.id} userId=${userId} ` +
`submissionId=${submissionId} idempotencyKey=${idempotencyKey}`,
);
// 8. 返回兼容响应(不删除旧字段,新增可选字段)
return {
jobId: result.id,
status: 'queued',
engineMode: 'unified',
lifecycleStatus: 'queued',
};
}
// ── Private Helpers ──
/**
* 判断是否应使用 Unified 引擎。
*
* FeatureFlag 查询失败 → 安全回退到 legacy。
*/
private async shouldUseUnified(userId: string): Promise<boolean> {
try {
const enabled = await this.featureFlag.isEnabled(FLAG_NAME, userId);
this.logger.log(
`FEYNMAN_ENGINE_MODE=${enabled ? 'unified' : 'legacy'} for userId=${userId}`,
);
return enabled;
} catch (err: any) {
this.logger.warn(
`FeatureFlag query failed, falling back to legacy: ${err.message}`,
);
return false;
}
}
/**
* 从请求参数解析稳定 submissionId。
*
* 优先级:
* 1. sessionId + answerId 组合(如都存在)
* 2. sessionId如仅 sessionId 存在)
* 3. 基于 content 的 hash 回退(保证相同内容 → 相同 ID
*/
private resolveSubmissionId(input: FeynmanEvaluateInput): string {
if (input.sessionId && input.answerId) {
return `${input.sessionId}:${input.answerId}`;
}
if (input.sessionId) {
return input.sessionId;
}
// 回退:基于内容 hash 的 submissionId相同输入 → 相同 key
const contentKey = [
input.knowledgeItemTitle,
input.knowledgeItemContent,
input.userExplanation,
].join('|');
return crypto.createHash('sha256').update(contentKey).digest('hex').substring(0, 16);
}
}