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>
194 lines
6.7 KiB
TypeScript
194 lines
6.7 KiB
TypeScript
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)
|
||
* - 默认 legacy(Feature 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);
|
||
}
|
||
}
|