api-server/src/modules/ai-job/ai-job-execution-engine.ts
wangdl f1eb99b6fa
Some checks failed
Deploy API Server / build-and-unit (push) Failing after 42s
Deploy API Server / current-integration (push) Has been skipped
Deploy API Server / backward-compat (push) Has been skipped
Deploy API Server / deploy (push) Has been skipped
fix(quiz): complete cross-platform attempt and question type contract
- Add attempt lifecycle with per-attempt answer visibility (#336)
- Add atomic submission with updateMany CAS + $transaction (#337)
- Add iOS attempt-based question loading + local answer cache (#338)
- Freeze choice/fill/judge cross-platform contract with fill grading (#339)
- Add per-question-type learning analysis accuracyByQuestionType (#340)
- Add GATE E2E test suite with 17 scenarios (#341)
- CLEANUP-01: Remove illegal type → choice silent degradation
- CLEANUP-02: Integrate QuizQuestionType enum into iOS quiz views
- CLEANUP-03: Test verification, git delivery, targeted gate review

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-24 19:55:05 +08:00

579 lines
24 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 { ReviewCardGenerationExecutor } from './review-card-generation-executor';
import { ReviewCardGenerationValidator } from './review-card-generation-validator';
import type { ActiveRecallSnapshot } from './active-recall-snapshot-builder';
import type { FeynmanSnapshot } from './feynman-snapshot-builder';
import type { ReviewCardGenerationSnapshot } from './review-card-generation-snapshot-builder';
import type { QuizGenerationSnapshot } from './quiz-generation-snapshot-builder';
import type { FeynmanEvaluationResult } from '../ai/prompts/schemas/feynman-evaluation.schema';
import type { ReviewCardGenerationResult } from '../ai/prompts/schemas/review-card-generation.schema';
import type { QuizGenerationResult } from '../ai/prompts/schemas/quiz-generation.schema';
import type { LearningAnalysisResult } from '../ai/prompts/schemas/learning-analysis.schema';
import { QuizGenerationExecutor } from './quiz-generation-executor';
import { QuizGenerationValidator } from './quiz-generation-validator';
import { LearningAnalysisExecutor } from './learning-analysis-executor';
import { LearningAnalysisValidator } from './learning-analysis-validator';
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 reviewCardExecutor: ReviewCardGenerationExecutor,
private readonly reviewCardValidator: ReviewCardGenerationValidator,
private readonly quizExecutor: QuizGenerationExecutor,
private readonly quizValidator: QuizGenerationValidator,
private readonly learningAnalysisExecutor: LearningAnalysisExecutor,
private readonly learningAnalysisValidator: LearningAnalysisValidator,
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 if (job.jobType === 'review_card_generation' && snapshot) {
// M-AI-06-04: ReviewCard Generation Executor — Child Job 二次 AI 调用
const reviewCardSnapshot = snapshot as unknown as ReviewCardGenerationSnapshot;
response = await this.reviewCardExecutor.execute(
reviewCardSnapshot,
timeoutMs,
);
parsedOutput = response.parsed;
this.logger.log(
`ReviewCard Executor completed: job=${aiJobId} ` +
`parentJobId=${reviewCardSnapshot.snapshot.parentJobId} ` +
`cards=${(parsedOutput as any)?.cards?.length}`,
);
// ── M-AI-06-04: 输出验证 ──
try {
this.reviewCardValidator.validate(parsedOutput as ReviewCardGenerationResult);
} catch (validationErr: any) {
this.logger.warn(
`ReviewCard validation failed for job=${aiJobId}: ${validationErr.message}`,
);
throw validationErr;
}
} else if (job.jobType === 'quiz_generation' && snapshot) {
// M-AI-07-03: Quiz Generation Executor
const quizSnapshot = snapshot as unknown as QuizGenerationSnapshot;
response = await this.quizExecutor.execute(quizSnapshot, timeoutMs);
parsedOutput = response.parsed;
this.logger.log(
`Quiz Executor completed: job=${aiJobId} ` +
`questions=${(parsedOutput as any)?.questions?.length}`,
);
// ── M-AI-07-03: 输出验证 ──
try {
this.quizValidator.validate(parsedOutput as QuizGenerationResult);
} catch (validationErr: any) {
this.logger.warn(`Quiz validation failed for job=${aiJobId}: ${validationErr.message}`);
throw validationErr;
}
} else if (job.jobType === 'learning_analysis' && snapshot) {
// M-AI-08-03: Learning Analysis Executor
response = await this.learningAnalysisExecutor.execute(snapshot as any, timeoutMs);
parsedOutput = response.parsed;
this.logger.log(
`Learning Analysis Executor completed: job=${aiJobId} ` +
`confidence=${(parsedOutput as any)?.confidence}`,
);
// M-AI-08-04: Output validation
try {
this.learningAnalysisValidator.validate(parsedOutput as LearningAnalysisResult, snapshot as any);
} catch (validationErr: any) {
this.logger.warn(`Learning analysis validation failed for job=${aiJobId}: ${validationErr.message}`);
throw validationErr;
}
} 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}`,
);
}
// M-AI-06-05: ReviewCard Projector 失败观测
if (job.jobType === 'review_card_generation') {
this.logger.error(
`[ReviewCard] Projector failed: jobId=${aiJobId} ` +
`projectorKey=${def.projectorKey} error=${projectorErr.message}`,
);
}
// M-QUIZ-01-CLEANUP-01: Quiz Generation Projector 失败观测(非法 type 等)
if (job.jobType === 'quiz_generation') {
this.logger.error(
`[QuizGeneration] 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,
});
}
}