api-server/src/modules/ai-job/ai-job-execution-engine.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

491 lines
19 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 { AiGatewayService } from '../ai/gateway/ai-gateway.service';
import { AiJobLifecycleRepository } from './ai-job-lifecycle.repository';
import { JobDefinitionRegistry } from './job-definition-registry';
import { AiJobStateMachine } from './ai-job-state-machine';
import { PrismaService } from '../../infrastructure/database/prisma.service';
import { ProjectionExecutor } from './projection-executor.service';
import { ActiveRecallExecutor } from './active-recall-executor';
import { ActiveRecallObservabilityService } from './active-recall-observability.service';
import { FeynmanExecutor } from './feynman-executor';
import { FeynmanObservabilityService } from './feynman-observability.service';
import { FeynmanBusinessValidator, FeynmanReferenceValidator } from './feynman-validator';
import type { ActiveRecallSnapshot } from './active-recall-snapshot-builder';
import type { FeynmanSnapshot } from './feynman-snapshot-builder';
import type { FeynmanEvaluationResult } from '../ai/prompts/schemas/feynman-evaluation.schema';
import {
AiJobExecutionEngine,
EngineJobContext,
} from './ai-job-execution-engine.interface';
import {
JobLockConflictError,
JobAlreadyTerminalError,
} from './ai-job.errors';
// ═══════════════════════════════════════════════════════════
// 错误分类ADR-003 §5.2
// ═══════════════════════════════════════════════════════════
interface ClassifiedError {
errorCode: string;
publicMessage: string;
retryable: boolean;
}
function classifyError(err: any): ClassifiedError {
const msg = err?.message || '';
if (err?.code === 'JOB_CANCELLED' || msg.includes('cancelled')) {
return { errorCode: 'cancelled', publicMessage: 'Job was cancelled', retryable: false };
}
if (msg.includes('429') || msg.includes('rate') || msg.includes('Rate')) {
return { errorCode: 'provider_rate_limited', publicMessage: 'AI 服务繁忙,请稍后重试', retryable: true };
}
if (msg.includes('timeout') || msg.includes('ETIMEDOUT') || msg.includes('AbortError')) {
return { errorCode: 'provider_timeout', publicMessage: 'AI 服务响应超时,请重试', retryable: true };
}
if (msg.includes('5xx') || msg.includes('503') || msg.includes('502') || msg.includes('unavailable')) {
return { errorCode: 'provider_unavailable', publicMessage: 'AI 服务暂不可用', retryable: true };
}
if (msg.includes('schema') || msg.includes('validation') || msg.includes('invalid')) {
return { errorCode: 'schema_validation_failed', publicMessage: 'AI 输出格式异常', retryable: false };
}
if (msg.includes('business') || msg.includes('Business')) {
return { errorCode: 'business_validation_failed', publicMessage: 'AI 输出不符合业务规则', retryable: false };
}
if (msg.includes('reference') || msg.includes('Reference')) {
return { errorCode: 'reference_validation_failed', publicMessage: 'AI 输出引用无效', retryable: false };
}
if (err instanceof JobLockConflictError || err instanceof JobAlreadyTerminalError) {
return { errorCode: 'internal_error', publicMessage: err.message, retryable: false };
}
return { errorCode: 'internal_error', publicMessage: '内部错误', retryable: true };
}
/**
* M-AI-03-08: AiJobExecutionEngine
*
* 统一执行管线ADR-003 §5.1
* PREPARE → RESOLVE → EXECUTE → PROJECT → COMPLETE
*
* 供 AiInteractiveJobWorker / AiBackgroundJobWorker 调用。
* 替代 #288 中的 no-op 占位。
*/
@Injectable()
export class AiJobExecutionEngineImpl implements AiJobExecutionEngine {
private readonly logger = new Logger(AiJobExecutionEngineImpl.name);
constructor(
private readonly prisma: PrismaService,
private readonly lifecycleRepo: AiJobLifecycleRepository,
private readonly registry: JobDefinitionRegistry,
private readonly stateMachine: AiJobStateMachine,
private readonly aiGateway: AiGatewayService,
private readonly projectionExecutor: ProjectionExecutor,
private readonly activeRecallExecutor: ActiveRecallExecutor,
private readonly feynmanExecutor: FeynmanExecutor,
private readonly feynmanBusinessValidator: FeynmanBusinessValidator,
private readonly feynmanReferenceValidator: FeynmanReferenceValidator,
private readonly observability: ActiveRecallObservabilityService,
private readonly feynmanObs: FeynmanObservabilityService,
) {}
async execute(aiJobId: string, context: EngineJobContext): Promise<void> {
// ── PREPARE ──
// 1. 读取 AiJob
const job = await this.prisma.aiJob.findUnique({ where: { id: aiJobId } });
if (!job) {
this.logger.error(`Job ${aiJobId} not found`);
return;
}
// 2. 检查是否终态
const currentStatus = this.stateMachine.parse(job.lifecycleStatus);
if (this.stateMachine.isTerminal(currentStatus)) {
this.logger.warn(`Job ${aiJobId} already in terminal status "${currentStatus}" — skipping`);
return;
}
// 3. 检查取消请求queued 状态直接取消running 状态设置信号)
if (job.cancelRequestedAt) {
await this.lifecycleRepo.markCancelled(aiJobId);
return;
}
// 4. Registry 获取 Definition
const def = this.registry.get(job.jobType);
// 5. CAS 抢锁 (queued → running),递增 attemptCount
// lockJob 内部:如果 AttemptCount > maxAttempts 或已经是终态,会抛错
let lockedJob: any;
try {
lockedJob = await this.lifecycleRepo.lockJob(aiJobId, context.jobId || 'engine');
} catch (err: any) {
// 已被其他 Worker 抢走 → 静默退出,不重试
if (err instanceof JobLockConflictError) {
this.logger.warn(`Job ${aiJobId} already locked by another worker — skipping`);
return;
}
if (err instanceof JobAlreadyTerminalError) {
this.logger.warn(`Job ${aiJobId} already terminal — skipping`);
return;
}
throw err;
}
await context.updateProgress(10);
// ── RESOLVE ──
let snapshot: any;
try {
// 6. 加载 Snapshot
const snap = await this.prisma.aiJobSnapshot.findUnique({
where: { jobId: aiJobId },
});
if (snap) {
// 验证 schemaVersion 兼容性
if (snap.schemaVersion !== def.input.schemaVersion) {
await this.lifecycleRepo.markFailed(aiJobId, {
errorCode: 'schema_validation_failed',
publicErrorMessage: 'Snapshot schema version mismatch',
internalErrorMessage: `Expected ${def.input.schemaVersion}, got ${snap.schemaVersion}`,
});
return;
}
snapshot = snap.content;
}
// 7. 取消检查Provider 调用前)
const currentJob = await this.prisma.aiJob.findUnique({
where: { id: aiJobId },
select: { cancelRequestedAt: true },
});
if (currentJob?.cancelRequestedAt) {
await this.lifecycleRepo.markCancelled(aiJobId);
return;
}
await context.updateProgress(30);
// ── EXECUTE ──
// 按 jobType 分派执行策略active_recall / feynman_evaluation → Executor, 其他 → AiGateway
const timeoutMs = def.execution.timeoutMs || 30000;
try {
let parsedOutput: Record<string, any>;
let response: any;
if (job.jobType === 'active_recall' && snapshot) {
// M-AI-04-05: ActiveRecall Executor 处理消息构造 + AiGateway 调用
const activeRecallSnapshot = snapshot as unknown as ActiveRecallSnapshot;
response = await this.activeRecallExecutor.execute(
activeRecallSnapshot,
timeoutMs,
);
parsedOutput = response.parsed;
this.logger.log(
`ActiveRecall Executor completed: job=${aiJobId} ` +
`score=${(parsedOutput as any)?.score}`,
);
} else if (job.jobType === 'feynman_evaluation' && snapshot) {
// M-AI-05-03: Feynman Executor 处理消息构造 + AiGateway 调用
const feynmanSnapshot = snapshot as unknown as FeynmanSnapshot;
response = await this.feynmanExecutor.execute(
feynmanSnapshot,
timeoutMs,
);
parsedOutput = response.parsed;
this.logger.log(
`Feynman Executor completed: job=${aiJobId} ` +
`score=${(parsedOutput as any)?.score}`,
);
// ── M-AI-05-03: 结构化输出验证 ──
try {
this.feynmanBusinessValidator.validate(parsedOutput as FeynmanEvaluationResult);
this.feynmanReferenceValidator.validate(parsedOutput as FeynmanEvaluationResult);
} catch (validationErr: any) {
this.logger.warn(
`Feynman validation failed for job=${aiJobId}: ${validationErr.message}`,
);
throw validationErr; // classifyError → markFailed
}
} else {
// 默认路径:直接调用 AiGatewaysynthetic_job 等)
response = await this.aiGateway.generate(
{
userId: job.userId,
feature: job.jobType,
tier: def.model.modelTier as any,
promptKey: def.prompt.promptKey,
promptVersion: def.prompt.promptVersion,
messages: [],
maxTokens: def.model.maxTokens,
outputSchema: undefined,
},
timeoutMs,
);
parsedOutput = response.parsed;
}
await context.updateProgress(70);
// 取消检查Projector 前)
const jobAfterExec = await this.prisma.aiJob.findUnique({
where: { id: aiJobId },
select: { cancelRequestedAt: true },
});
if (jobAfterExec?.cancelRequestedAt) {
await this.lifecycleRepo.markCancelled(aiJobId);
return;
}
// ── PROJECT ──
// 调用 ProjectionExecutor事务内Projector + Artifact + markSucceeded
// active_recall → ActiveRecallProjector (key: 'active_recall_projector')
// synthetic_job → SyntheticResultProjector (key: 'synthetic_projector')
const outputHash = this.computeHash(JSON.stringify(parsedOutput));
await this.prisma.aiJob.update({
where: { id: aiJobId },
data: { validatedOutput: parsedOutput as any, outputHash },
});
let artifacts: any[];
try {
artifacts = await this.projectionExecutor.execute(
def.projectorKey,
{
job: {
id: job.id,
userId: job.userId,
jobType: job.jobType,
targetType: job.targetType,
targetId: job.targetId,
snapshotId: snap?.id || null,
promptVersion: def.prompt.promptVersion,
outputSchemaVersion: def.output.schemaVersion,
},
snapshot,
validatedOutput: parsedOutput,
},
);
} catch (projectorErr: any) {
// M-AI-04-GATE-FIX-02: Projector 失败独立观测
if (job.jobType === 'active_recall') {
this.observability.incrementProjectorFailed();
this.logger.error(
`[ActiveRecall] Projector failed: jobId=${aiJobId} ` +
`projectorKey=${def.projectorKey} error=${projectorErr.message}`,
);
}
// M-AI-05-06: Feynman Projector 失败观测
if (job.jobType === 'feynman_evaluation') {
this.feynmanObs.incrementProjectorFailed();
this.logger.error(
`[Feynman] Projector failed: jobId=${aiJobId} ` +
`projectorKey=${def.projectorKey} error=${projectorErr.message}`,
);
}
throw projectorErr; // 传播到外层 catch → classifyError + markFailed
}
await context.updateProgress(90);
// ── COMPLETE ──
// Usage logging
await this.writeUsageLog(job.userId, aiJobId, def, response, lockedJob.attemptCount || 0).catch((err) => {
this.logger.error(`Usage log failed for job=${aiJobId}: ${err.message}`);
});
this.logger.log(
`Job ${aiJobId} (${job.jobType}) completed: ${artifacts.length} artifact(s), hash=${outputHash}`,
);
await context.updateProgress(100);
this.logger.log(`Job ${aiJobId} (${job.jobType}) completed successfully`);
// M-AI-04-06: ActiveRecall 执行成功观测
if (job.jobType === 'active_recall') {
const durationMs = Date.now() - new Date(job.startedAt || job.queuedAt || Date.now()).getTime();
this.observability.incrementUnifiedExecuteSuccess(durationMs);
this.observability.logExecutionCompleted({
requestId: 'engine',
jobId: aiJobId,
activeRecallId: job.targetId || '',
userId: job.userId,
engineMode: 'unified',
jobType: job.jobType,
queueName: def.queue.queueName,
durationMs,
lifecycleStatus: 'succeeded',
attemptCount: lockedJob.attemptCount,
});
}
// M-AI-05-06: Feynman 执行成功观测
if (job.jobType === 'feynman_evaluation') {
const durationMs = Date.now() - new Date(job.startedAt || job.queuedAt || Date.now()).getTime();
const focusItemCount = artifacts.filter((a: any) => a.artifactType === 'FocusItem').length;
const reviewCardCount = artifacts.filter((a: any) => a.artifactType === 'ReviewCard').length;
this.feynmanObs.incrementUnifiedExecuteSuccess(durationMs);
this.feynmanObs.addFocusItemCreated(focusItemCount);
this.feynmanObs.addReviewCardCreated(reviewCardCount);
this.feynmanObs.logExecutionCompleted({
requestId: 'engine',
jobId: aiJobId,
knowledgeItemId: job.targetId || '',
userId: job.userId,
engineMode: 'unified',
jobType: job.jobType,
queueName: def.queue.queueName,
durationMs,
lifecycleStatus: 'succeeded',
attemptCount: lockedJob.attemptCount,
focusItemCount,
reviewCardCount,
});
}
} catch (execErr: any) {
// 取消检查
if (execErr?.message?.includes('cancelled')) {
await this.lifecycleRepo.markCancelled(aiJobId);
return;
}
// 错误分类与处理
const classified = classifyError(execErr);
this.logger.error(
`Job ${aiJobId} execution error: ${classified.errorCode} ` +
`retryable=${classified.retryable} msg=${execErr.message}`,
);
// M-AI-04-06: ActiveRecall 执行失败 + 重试观测
if (job.jobType === 'active_recall') {
if (classified.retryable) {
this.observability.incrementUnifiedRetry();
} else {
this.observability.incrementUnifiedExecuteFailed();
}
this.observability.logExecutionFailed(
{
requestId: 'engine',
jobId: aiJobId,
activeRecallId: job.targetId || '',
userId: job.userId,
engineMode: 'unified',
jobType: job.jobType,
queueName: def.queue.queueName,
errorCode: classified.errorCode,
},
execErr.message,
);
}
// M-AI-05-06: Feynman 执行失败 + 重试观测
if (job.jobType === 'feynman_evaluation') {
if (classified.retryable) {
this.feynmanObs.incrementUnifiedRetry();
} else {
this.feynmanObs.incrementUnifiedExecuteFailed();
}
this.feynmanObs.logExecutionFailed(
{
requestId: 'engine',
jobId: aiJobId,
knowledgeItemId: job.targetId || '',
userId: job.userId,
engineMode: 'unified',
jobType: job.jobType,
queueName: def.queue.queueName,
errorCode: classified.errorCode,
},
execErr.message,
);
}
if (classified.retryable) {
// 重试:先解锁回 queuedBullMQ retry → lockJob 可再次抢锁),然后抛给 BullMQ
await this.unlockForRetry(aiJobId);
throw execErr;
}
// 永久错误:无论 attemptCount 多少,直接 markFailed不重试
await this.lifecycleRepo.markFailed(aiJobId, {
errorCode: classified.errorCode,
publicErrorMessage: classified.publicMessage,
internalErrorMessage: execErr.message?.slice(0, 500),
});
// markFailed 后不抛错 → BullMQ 认为 job 完成(不再重试)
return;
}
} catch (outerErr: any) {
// 捕获 RESOLVE 阶段的错误
const classified = classifyError(outerErr);
this.logger.error(
`Job ${aiJobId} resolve error: ${classified.errorCode}${outerErr.message}`,
);
if (!classified.retryable) {
await this.lifecycleRepo.markFailed(aiJobId, {
errorCode: classified.errorCode,
publicErrorMessage: classified.publicMessage,
internalErrorMessage: outerErr.message?.slice(0, 500),
}).catch(() => {});
}
throw outerErr;
}
}
// ── helpers ──
/**
* 解锁 Job 回 queued 状态,供 BullMQ 重试。
* 保持 attemptCount已在 lockJob 中原子递增)。
*/
private async unlockForRetry(jobId: string): Promise<void> {
// 仅持有锁的 Worker 调用 — 无并发竞争update 即可。
await this.prisma.aiJob.update({
where: { id: jobId },
data: {
lifecycleStatus: 'queued',
status: 'pending',
lockedBy: null,
lockedAt: null,
lockUntil: null,
},
});
}
private computeHash(content: string): string {
return crypto.createHash('sha256').update(content).digest('hex').substring(0, 16);
}
private async writeUsageLog(
userId: string,
jobId: string,
definition: any,
response: any,
attemptNo: number,
): Promise<void> {
await this.prisma.aiUsageLog.create({
data: {
userId,
jobId,
provider: response?.usage?.provider || 'deepseek',
model: response?.usage?.model || 'deepseek-chat',
tier: 'primary',
promptKey: definition?.prompt?.promptKey || 'unknown',
promptVersion: definition?.prompt?.promptVersion || 'unknown',
inputTokens: response?.usage?.inputTokens || 0,
outputTokens: response?.usage?.outputTokens || 0,
estimatedCost: response?.usage?.estimatedCost || 0,
latencyMs: response?.usage?.latencyMs || 0,
success: true,
attemptNo,
credentialMode: 'platform_key',
} as any,
});
}
}