import { Queue, Worker, type Job } from "bullmq"; import IORedis from "ioredis"; import { env } from "@pram/env/server"; async function log(level: "debug" | "warn", message: string, meta?: Record) { const mod = await import("@pram/api/logger"); (mod.logger as any)[level](message, meta); } import { generateQuestionsQuick, generateQuestionsAgentic, generatePassageForInput, generateQuestionsAgenticFromPassage, GenerationError, type AgenticProgress, type GenerationInput, type GenerationResult, } from "@pram/ai"; import { db } from "@pram/db"; import { generationJob, question, testPackage, packageSection, sectionQuestion, } from "@pram/db"; import { and, eq, notInArray } from "drizzle-orm"; import { encryptApiKey, decryptApiKey } from "./lib/encryption"; import { collectShardFailureCause, isNonRetryableProviderError, resolveGenerationJobOutcome, sectionsWithNoQuestions, } from "./lib/generation-outcome"; const connectionOptions = { maxRetriesPerRequest: null }; const FAST_QUEUE_NAME = "generation-fast"; const QUALITY_QUEUE_NAME = "generation-quality"; const CANCEL_POLL_MS = 500; const HEARTBEAT_MS = 10_000; const MAX_QUESTIONS_PER_SHARD = 8; const FAST_SHARD_CONCURRENCY = 3; const QUALITY_SECTION_CONCURRENCY = 2; const QUICK_PARSE_FALLBACK_TO_AGENTIC = false; export const MAX_SHARD_RETRIES = 1; interface SectionSplit { section: string; count: number; } interface ShardPlan extends SectionSplit { sectionIndex: number; shardIndex: number; shardCount: number; } interface PersistableQuestion { section: string; format: string; passageText: string; questionText: string; options: unknown; correctAnswer: string; explanation: string; difficulty: number; skillTags: string[]; aiModel: string; } interface FastJobData { input: GenerationInput; jobId: string; } interface QualityJobData { input: GenerationInput; jobId: string; sectionSplits: SectionSplit[]; fastQuestions: PersistableQuestion[]; fastMeta: { tokensUsed: number; durationMs: number; approxTokens: number; }; } /** Thrown when the job was cancelled (DB status or cooperative poll). */ export class GenerationJobCancelledError extends Error { constructor() { super("JOB_CANCELLED"); this.name = "GenerationJobCancelledError"; } } const AGENTIC_STEP_FRACTIONS = [0.12, 0.28, 0.62, 0.92] as const; const AGENTIC_STEP_LABELS: Record = { generate_passage: "Menulis bacaan...", validate_passage: "Memvalidasi bacaan...", generate_questions: "Membuat soal...", self_validate: "Validasi kualitas...", }; function mapAgenticProgressInShard( agentic: AgenticProgress, completedShards: number, totalShards: number, ): { progress: number; message: string } { const shardSpan = 65 / Math.max(totalShards, 1); const shardBase = 15 + completedShards * shardSpan; const stepFrac = AGENTIC_STEP_FRACTIONS[agentic.currentStep] ?? 0.5; const progress = Math.min(Math.round(shardBase + shardSpan * stepFrac), 84); const step = agentic.steps[agentic.currentStep]; const message = step?.message ?? AGENTIC_STEP_LABELS[step?.step ?? ""] ?? "Agentic generation..."; return { progress, message }; } function mapAgenticProgressInQuality(agentic: AgenticProgress): { progress: number; message: string } { const stepFrac = AGENTIC_STEP_FRACTIONS[agentic.currentStep] ?? 0.5; const progress = Math.min(86 + Math.round(stepFrac * 10), 96); const step = agentic.steps[agentic.currentStep]; const message = step?.message ?? AGENTIC_STEP_LABELS[step?.step ?? ""] ?? "Quality validation..."; return { progress, message }; } function createAgenticProgressHandler( updateProgress: (progress: number, progressMessage: string) => Promise, pushLog: ( step: string, message: string, status: "running" | "done" | "error", details?: string, ) => Promise, mapProgress: (agentic: AgenticProgress) => { progress: number; message: string }, ) { const seenSteps = new Set(); return (agentic: AgenticProgress) => { const { progress, message } = mapProgress(agentic); void updateProgress(progress, message).catch(() => {}); for (const step of agentic.steps) { if (!step || step.status === "pending") continue; const key = `${step.step}:${step.status}`; if (seenSteps.has(key)) continue; seenSteps.add(key); void pushLog( step.step, step.message ?? AGENTIC_STEP_LABELS[step.step] ?? step.step, step.status as "running" | "done" | "error", step.output, ).catch(() => {}); } }; } export function computeSectionSplit( selectedSections: string[], count: number, ): SectionSplit[] { const sections = selectedSections.length > 0 ? selectedSections : ["READING"]; if (count < 20 || sections.length <= 1) { return [{ section: sections[0]!, count }]; } const base = Math.floor(count / sections.length); const remainder = count % sections.length; return sections.map((section, i) => ({ section, count: base + (i < remainder ? 1 : 0), })); } export function splitIntoShards(sectionSplits: SectionSplit[]): ShardPlan[] { const shards: ShardPlan[] = []; for (let sectionIndex = 0; sectionIndex < sectionSplits.length; sectionIndex++) { const split = sectionSplits[sectionIndex]!; const shardCount = Math.ceil(split.count / MAX_QUESTIONS_PER_SHARD); let remaining = split.count; for (let shardIndex = 0; shardIndex < shardCount; shardIndex++) { const count = Math.min(MAX_QUESTIONS_PER_SHARD, remaining); remaining -= count; shards.push({ section: split.section, count, sectionIndex, shardIndex, shardCount, }); } } return shards; } function createCancellationPoller(jobId: string) { let cancelled = false; const interval = setInterval(() => { db .select({ status: generationJob.status }) .from(generationJob) .where(eq(generationJob.id, jobId)) .limit(1) .then(([row]) => { if (row?.status === "cancelled") cancelled = true; }) .catch(() => {}); }, CANCEL_POLL_MS); return { stop: () => clearInterval(interval), check: () => { if (cancelled) throw new GenerationJobCancelledError(); }, }; } export async function runWithConcurrency( items: T[], concurrency: number, worker: (item: T, index: number) => Promise, ): Promise { const results: R[] = new Array(items.length); let nextIndex = 0; const runners = Array.from({ length: Math.max(1, concurrency) }, async () => { while (true) { const index = nextIndex++; if (index >= items.length) return; results[index] = await worker(items[index]!, index); } }); await Promise.all(runners); return results; } export function normalizeQuestions( section: string, model: string, result: GenerationResult, ): PersistableQuestion[] { return result.questions.map((q) => ({ section, format: q.format, passageText: q.passageText, questionText: q.questionText, options: (q as any).options ?? null, correctAnswer: q.correctAnswer, explanation: q.explanation, difficulty: q.difficulty, skillTags: q.skillTags, aiModel: model, })); } async function saveGeneratedArtifacts( jobId: string, input: GenerationInput, allQuestions: PersistableQuestion[], sectionSplits: SectionSplit[], ): Promise<{ savedQuestionIds: string[]; generatedPackageId: string | null }> { const [existingJob] = await db .select({ resultJson: generationJob.resultJson }) .from(generationJob) .where(eq(generationJob.id, jobId)) .limit(1); const alreadySaved = existingJob?.resultJson && typeof existingJob.resultJson === "object" && "savedQuestionIds" in (existingJob.resultJson as any) && Array.isArray((existingJob.resultJson as any).savedQuestionIds) && (existingJob.resultJson as any).savedQuestionIds.length > 0; if (alreadySaved) { const savedQuestionIds = (existingJob!.resultJson as any).savedQuestionIds as string[]; return { savedQuestionIds, generatedPackageId: ((existingJob!.resultJson as any).generatedPackageId ?? null) as string | null, }; } const [jobRow] = await db .select({ userId: generationJob.userId }) .from(generationJob) .where(eq(generationJob.id, jobId)) .limit(1); const userId = jobRow?.userId; if (!userId) return { savedQuestionIds: [], generatedPackageId: null }; const inserted = await db .insert(question) .values( allQuestions.map((q) => ({ examTypeId: input.examType, sectionTypeId: q.section, format: q.format, passageText: q.passageText, questionText: q.questionText, options: q.options, correctAnswer: q.correctAnswer, explanation: q.explanation, difficulty: q.difficulty, skillTags: q.skillTags, source: "ai" as const, aiModel: q.aiModel, creatorUserId: userId, isPublic: false, })) as any, ) .returning({ id: question.id }); const savedQuestionIds = inserted.map((r) => r.id); if (!savedQuestionIds.length) { return { savedQuestionIds, generatedPackageId: null }; } let generatedPackageId: string | null = null; try { const dateStr = new Date().toLocaleDateString("id-ID", { day: "numeric", month: "short", year: "numeric", }); const sectionLabel = sectionSplits.length > 1 ? `${sectionSplits.length} sections` : input.section; const pkgTitle = `AI Generated - ${input.examType} ${sectionLabel} - ${dateStr}`; const [pkg] = await db .insert(testPackage) .values({ title: pkgTitle, description: `Paket latihan AI-generated dengan ${savedQuestionIds.length} soal ${input.examType}.`, examTypeId: input.examType, creatorUserId: userId, isPublic: false, totalQuestions: savedQuestionIds.length, totalSections: sectionSplits.length, estimatedDurationMin: Math.ceil(savedQuestionIds.length * 1.5), }) .returning(); if (!pkg) return { savedQuestionIds, generatedPackageId }; generatedPackageId = pkg.id; for (let i = 0; i < sectionSplits.length; i++) { const split = sectionSplits[i]!; const sectionQuestions = allQuestions .map((q, idx) => ({ ...q, _globalIndex: idx })) .filter((q) => q.section === split.section); const [sec] = await db .insert(packageSection) .values({ packageId: pkg.id, sectionTypeId: split.section, title: `${split.section} Section`, orderIndex: i, }) .returning(); if (sec) { const sectionQuestionRows = sectionQuestions .map((q, idx) => ({ sectionId: sec.id, questionId: savedQuestionIds[q._globalIndex], orderIndex: idx, })) .filter((q) => q.questionId != null); if (sectionQuestionRows.length > 0) { await db.insert(sectionQuestion).values(sectionQuestionRows as any); } } } } catch (packageErr: any) { // eslint-disable-next-line no-console console.warn("[GENERATION] Failed to auto-create package, but questions were saved.", { error: packageErr?.message ?? String(packageErr), jobId, examType: input.examType, section: input.section, }); } return { savedQuestionIds, generatedPackageId }; } function shardKey(shard: ShardPlan): string { return `${shard.sectionIndex}:${shard.shardIndex}`; } async function failGenerationJob(params: { jobId: string; errorMessage: string; tokensUsed?: number; durationMs: number; }) { await db .update(generationJob) .set({ status: "failed", progress: 100, progressMessage: "Generation failed", errorMessage: params.errorMessage, tokensUsed: params.tokensUsed, durationMs: params.durationMs, completedAt: new Date(), }) .where(eq(generationJob.id, params.jobId)); } async function completeJobWithResult(params: { jobId: string; input: GenerationInput; status?: "completed" | "completed_partial"; statusMessage?: string; errorMessage?: string | null; allQuestions: PersistableQuestion[]; sectionSplits: SectionSplit[]; totalTokens: number; durationMs: number; metrics?: Record; }) { const [currentJob] = await db .select({ status: generationJob.status }) .from(generationJob) .where(eq(generationJob.id, params.jobId)) .limit(1); if (currentJob?.status === "cancelled") return; const { savedQuestionIds, generatedPackageId } = await saveGeneratedArtifacts( params.jobId, params.input, params.allQuestions, params.sectionSplits, ); const result: GenerationResult = { questions: params.allQuestions as any, meta: { model: params.input.apiKeyConfig.model, tokensUsed: params.totalTokens, durationMs: params.durationMs, mode: params.input.mode, }, }; await db .update(generationJob) .set({ status: params.status ?? "completed", progress: 100, progressMessage: params.statusMessage ?? "Completed", errorMessage: params.errorMessage ?? null, resultJson: { ...result, savedQuestionIds, generatedPackageId, sectionSplits: params.sectionSplits, qualityPhase: "final", metrics: params.metrics, isPartial: params.status === "completed_partial", requestedQuestionCount: params.input.questionCount, } as any, tokensUsed: params.totalTokens, durationMs: params.durationMs, completedAt: new Date(), }) .where( and( eq(generationJob.id, params.jobId), notInArray(generationJob.status, ["cancelled"]), ), ); // Deduct from user credit if using platform generation const inputAny = params.input as any; if (inputAny._isPlatformGeneration && params.totalTokens > 0) { const [jobRow] = await db .select({ userId: generationJob.userId }) .from(generationJob) .where(eq(generationJob.id, params.jobId)) .limit(1); if (jobRow) { const { deductCredit } = await import("@pram/api/lib/credit"); await deductCredit(jobRow.userId, params.totalTokens).catch(() => {}); } } } export type CancelGenerationJobResult = | { ok: true } | { ok: false; reason: "not_found" | "forbidden" | "not_cancellable" }; export const generationQueue = new Queue(FAST_QUEUE_NAME, { connection: new IORedis(env.REDIS_URL, connectionOptions), }); export const generationQualityQueue = new Queue(QUALITY_QUEUE_NAME, { connection: new IORedis(env.REDIS_URL, connectionOptions), }); /** * Marks the job cancelled in Postgres, then removes queued jobs. * Active jobs are stopped cooperatively by the worker (cancellation poller). */ export async function cancelGenerationJob( userId: string, jobId: string, ): Promise { const [row] = await db .select({ id: generationJob.id, userId: generationJob.userId, status: generationJob.status, }) .from(generationJob) .where(eq(generationJob.id, jobId)) .limit(1); if (!row) return { ok: false, reason: "not_found" }; if (row.userId !== userId) return { ok: false, reason: "forbidden" }; if (row.status === "completed" || row.status === "completed_partial" || row.status === "failed" || row.status === "cancelled") { return { ok: false, reason: "not_cancellable" }; } await db .update(generationJob) .set({ status: "cancelled", errorMessage: "Dibatalkan pengguna", completedAt: new Date(), }) .where(eq(generationJob.id, jobId)); for (const queue of [generationQueue, generationQualityQueue]) { try { const bullJob = await queue.getJob(jobId); if (bullJob) { try { await bullJob.remove(); } catch { log("debug", "[QUEUE] Could not remove active job.", { jobId }); } } } catch { log("warn", "[QUEUE] Failed to access queue during cancellation.", { jobId }); } } return { ok: true }; } export const generationWorker = new Worker( FAST_QUEUE_NAME, async (job: Job) => { const { input, jobId } = job.data; const start = Date.now(); const [initialRow] = await db .select({ status: generationJob.status }) .from(generationJob) .where(eq(generationJob.id, jobId)) .limit(1); if ( !initialRow || initialRow.status === "cancelled" || initialRow.status === "completed" || initialRow.status === "completed_partial" ) { return; } const claimed = await db .update(generationJob) .set({ status: "running_fast" }) .where( and( eq(generationJob.id, jobId), notInArray(generationJob.status, ["cancelled", "completed", "completed_partial"]), ), ) .returning({ id: generationJob.id }); if (!claimed.length) return; const cancelPoll = createCancellationPoller(jobId); let heartbeatInterval: ReturnType | null = null; const startHeartbeat = () => { if (heartbeatInterval) clearInterval(heartbeatInterval); heartbeatInterval = setInterval(() => { db .update(generationJob) .set({ updatedAt: new Date() }) .where(eq(generationJob.id, jobId)) .catch(() => {}); }, HEARTBEAT_MS); }; const stopHeartbeat = () => { if (heartbeatInterval) { clearInterval(heartbeatInterval); heartbeatInterval = null; } }; const pushLog = async ( step: string, message: string, status: "running" | "done" | "error", details?: string, ) => { const entry = { step, message, status, timestamp: new Date().toISOString(), details, }; const [row] = await db .select({ logs: generationJob.logs }) .from(generationJob) .where(eq(generationJob.id, jobId)) .limit(1); const existing = (row?.logs ?? []) as Array<{ step: string; message: string; status: string; timestamp: string; details?: string; }>; await db .update(generationJob) .set({ logs: [...existing, entry] }) .where(eq(generationJob.id, jobId)); }; let maxProgress = 0; const updateProgress = async ( progress: number, progressMessage: string, status?: string, resultJson?: unknown, ) => { cancelPoll.check(); const next = Math.max(maxProgress, progress); maxProgress = next; await job.updateProgress(next); const [updated] = await db .update(generationJob) .set({ progress: next, progressMessage, ...(status ? { status } : {}), ...(resultJson !== undefined ? { resultJson: resultJson as any } : {}), }) .where( and( eq(generationJob.id, jobId), notInArray(generationJob.status, ["cancelled"]), ), ) .returning({ id: generationJob.id }); if (!updated) throw new GenerationJobCancelledError(); }; let approxTokens = 0; const tokenCounter = (token: string) => { cancelPoll.check(); approxTokens += Math.ceil(token.length / 4); }; try { startHeartbeat(); const selectedMode = input.mode; const activeSections = input.selectedSections ?? [input.section]; const sectionSplits = computeSectionSplit(activeSections, input.questionCount); const shards = splitIntoShards(sectionSplits); const minPartialThreshold = Math.min(3, input.questionCount); await updateProgress( 5, `Planning ${sectionSplits.length} section(s), ${shards.length} shard(s)...`, ); await pushLog( "plan", `Section plan: ${sectionSplits .map((s) => `${s.section}(${s.count})`) .join(", ")} | shards=${shards.length}`, "done", ); let totalTokens = 0; let totalDurationMs = 0; let completedShards = 0; let partialPublished = false; let timeToFirstValidQuestionMs: number | null = null; const sectionPassages = new Map(); if (selectedMode === "agentic") { for (const split of sectionSplits) { if (sectionPassages.has(split.section)) continue; await updateProgress(8, `Menulis bacaan ${split.section}...`); const passageInput: GenerationInput = { ...input, section: split.section as GenerationInput["section"], questionCount: split.count, }; const passageResult = await generatePassageForInput(passageInput, tokenCounter); totalTokens += passageResult.tokensUsed ?? 0; sectionPassages.set(split.section, passageResult.passage); await pushLog( "generate_passage", `Bacaan siap: ${passageResult.title}`, "done", passageResult.passage.slice(0, 400), ); } } const shardResults: Array<{ shard: ShardPlan; questions: PersistableQuestion[]; }> = []; const runShard = async (shard: ShardPlan): Promise => { cancelPoll.check(); await pushLog( "generate_shard", `[${shard.section}] shard ${shard.shardIndex + 1}/${shard.shardCount} generating ${shard.count} question(s)`, "running", ); const subInput: GenerationInput = { ...input, section: shard.section as any, questionCount: shard.count, }; let sectionResult: GenerationResult; const onAgenticProgress = createAgenticProgressHandler( (progress, progressMessage) => updateProgress(progress, progressMessage), pushLog, (agentic) => mapAgenticProgressInShard(agentic, completedShards, shards.length), ); try { if (selectedMode === "agentic") { const sharedPassage = sectionPassages.get(shard.section); if (sharedPassage) { sectionResult = await generateQuestionsAgenticFromPassage( subInput, sharedPassage, onAgenticProgress, tokenCounter, { strategy: "lean", maxRegenerateAttempts: 1 }, ); } else { sectionResult = await generateQuestionsAgentic( subInput, onAgenticProgress, tokenCounter, { strategy: "lean", maxRegenerateAttempts: 1 }, ); } } else { sectionResult = await generateQuestionsQuick(subInput, { onToken: tokenCounter, }); } } catch (quickErr: any) { const quickErrorMessage = quickErr?.message ?? String(quickErr); const shouldFallbackToAgentic = QUICK_PARSE_FALLBACK_TO_AGENTIC && selectedMode === "quick" && (/Failed to parse AI response as JSON/i.test(quickErrorMessage) || /Unterminated string/i.test(quickErrorMessage) || /Missing 'questions' array/i.test(quickErrorMessage)); if (!shouldFallbackToAgentic) { throw quickErr; } sectionResult = await generateQuestionsAgentic( { ...subInput, mode: "agentic" }, onAgenticProgress, tokenCounter, { strategy: "lean", maxRegenerateAttempts: 1 }, ); } const normalized = normalizeQuestions( shard.section, input.apiKeyConfig.model, sectionResult, ); shardResults.push({ shard, questions: normalized }); totalTokens += sectionResult.meta.tokensUsed ?? 0; totalDurationMs += sectionResult.meta.durationMs; completedShards += 1; const progress = Math.min( 15 + Math.round((completedShards / shards.length) * 65), 80, ); const generatedCount = shardResults.reduce( (acc, item) => acc + item.questions.length, 0, ); if (generatedCount > 0 && timeToFirstValidQuestionMs == null) { timeToFirstValidQuestionMs = Date.now() - start; } await updateProgress( progress, `Fast generation: ${generatedCount}/${input.questionCount} soal`, ); await pushLog( "generate_shard", `[${shard.section}] shard ${shard.shardIndex + 1}/${shard.shardCount} completed`, "done", ); if (!partialPublished && generatedCount >= minPartialThreshold) { partialPublished = true; const partialQuestions = shardResults .sort((a, b) => { if (a.shard.sectionIndex !== b.shard.sectionIndex) { return a.shard.sectionIndex - b.shard.sectionIndex; } return a.shard.shardIndex - b.shard.shardIndex; }) .flatMap((r) => r.questions) .slice(0, input.questionCount); const partialResult: GenerationResult = { questions: partialQuestions as any, meta: { model: input.apiKeyConfig.model, tokensUsed: totalTokens || approxTokens, durationMs: Date.now() - start, mode: input.mode, }, }; await updateProgress( 70, `Partial ready: ${partialQuestions.length} soal`, "partial_ready", { ...partialResult, sectionSplits, qualityPhase: "fast", isPartial: true, metrics: { timeToFirstValidQuestionMs: timeToFirstValidQuestionMs ?? Date.now() - start, shardCount: shards.length, shardRetryBudget: MAX_SHARD_RETRIES, }, }, ); } }; const failedShards: ShardPlan[] = []; const shardFailureInfo = new Map(); await runWithConcurrency(shards, FAST_SHARD_CONCURRENCY, async (shard) => { try { await runShard(shard); } catch (err: any) { const message = err?.message ?? String(err); shardFailureInfo.set(shardKey(shard), { error: message, nonRetryable: isNonRetryableProviderError(err), }); log("warn", "[GENERATION] Fast shard failed.", { jobId, section: shard.section, nonRetryable: isNonRetryableProviderError(err), }); failedShards.push(shard); } }); if (failedShards.length > 0) { const retryableCount = failedShards.filter( (shard) => !shardFailureInfo.get(shardKey(shard))?.nonRetryable, ).length; if (retryableCount > 0) { await pushLog( "retry_budget", `Retrying ${retryableCount} failed shard(s), budget=${MAX_SHARD_RETRIES}`, "running", ); } } for (const failedShard of failedShards) { const failure = shardFailureInfo.get(shardKey(failedShard)); if (failure?.nonRetryable) { await pushLog( "retry_budget", `[${failedShard.section}] provider error — skipping retries`, "error", failure.error.slice(0, 300), ); continue; } let success = false; for (let attempt = 1; attempt <= MAX_SHARD_RETRIES; attempt++) { try { await runShard(failedShard); success = true; break; } catch (retryErr: any) { const retryMessage = retryErr?.message ?? String(retryErr); if (isNonRetryableProviderError(retryErr)) { await pushLog( "retry_budget", `[${failedShard.section}] provider error — stopping retries`, "error", retryMessage.slice(0, 300), ); break; } await pushLog( "retry_budget", `[${failedShard.section}] shard retry ${attempt}/${MAX_SHARD_RETRIES} failed: ${retryMessage}`, "error", ); } } if (!success && !failure?.nonRetryable) { await pushLog( "retry_budget", `[${failedShard.section}] exhausted retry budget`, "error", ); } } const allQuestions = shardResults .sort((a, b) => { if (a.shard.sectionIndex !== b.shard.sectionIndex) { return a.shard.sectionIndex - b.shard.sectionIndex; } return a.shard.shardIndex - b.shard.shardIndex; }) .flatMap((r) => r.questions) .slice(0, input.questionCount); const outcome = resolveGenerationJobOutcome({ requestedCount: input.questionCount, generatedCount: allQuestions.length, failedSections: sectionsWithNoQuestions(sectionSplits, allQuestions), cause: collectShardFailureCause( failedShards.map((shard) => shardFailureInfo.get(shardKey(shard))), ), }); if (outcome.status === "failed") { await pushLog("save", outcome.errorMessage ?? "Generation failed", "error"); await failGenerationJob({ jobId, errorMessage: outcome.errorMessage ?? "Generation failed", tokensUsed: totalTokens || approxTokens || undefined, durationMs: Date.now() - start, }); return; } const finalizeParams = { jobId, input, allQuestions, sectionSplits, totalTokens: totalTokens || approxTokens, durationMs: Date.now() - start, status: outcome.status, statusMessage: outcome.progressMessage, errorMessage: outcome.errorMessage, metrics: { timeToFirstValidQuestionMs: timeToFirstValidQuestionMs ?? Date.now() - start, shardCount: shards.length, shardRetryBudget: MAX_SHARD_RETRIES, shardFailures: failedShards.length, requestedQuestionCount: input.questionCount, generatedQuestionCount: allQuestions.length, ...(selectedMode === "agentic" ? { singlePassAgentic: true } : {}), }, }; if (selectedMode === "agentic") { await pushLog("save", "Saving agentic result...", "running"); await completeJobWithResult(finalizeParams); await pushLog( "save", outcome.status === "completed_partial" ? `Saved ${allQuestions.length}/${input.questionCount} questions (partial)` : `Saved ${allQuestions.length} questions`, outcome.status === "completed_partial" ? "error" : "done", ); return; } await pushLog("save", "Saving quick result...", "running"); await completeJobWithResult(finalizeParams); await pushLog( "save", outcome.status === "completed_partial" ? `Saved ${allQuestions.length}/${input.questionCount} questions (partial)` : `Saved ${allQuestions.length} questions`, outcome.status === "completed_partial" ? "error" : "done", ); } catch (err: any) { if ( err instanceof GenerationJobCancelledError || err?.name === "GenerationJobCancelledError" ) { await db .update(generationJob) .set({ status: "cancelled", errorMessage: "Dibatalkan pengguna", durationMs: Date.now() - start, completedAt: new Date(), }) .where(eq(generationJob.id, jobId)); return; } let failedTokens: number | undefined; if (err instanceof GenerationError) failedTokens = err.tokensUsed ?? undefined; if (failedTokens == null && approxTokens > 0) failedTokens = approxTokens; await db .update(generationJob) .set({ status: "failed", errorMessage: err?.message ?? String(err), tokensUsed: failedTokens, durationMs: Date.now() - start, completedAt: new Date(), }) .where(eq(generationJob.id, jobId)); return; } finally { stopHeartbeat(); cancelPoll.stop(); } }, { connection: new IORedis(env.REDIS_URL, connectionOptions), concurrency: 5, }, ); export const generationQualityWorker = new Worker( QUALITY_QUEUE_NAME, async (job: Job) => { const { input, jobId, sectionSplits, fastQuestions, fastMeta } = job.data; const qualityStart = Date.now(); const [initialRow] = await db .select({ status: generationJob.status }) .from(generationJob) .where(eq(generationJob.id, jobId)) .limit(1); if ( !initialRow || initialRow.status === "cancelled" || initialRow.status === "completed" || initialRow.status === "completed_partial" ) { return; } const cancelPoll = createCancellationPoller(jobId); let approxTokens = 0; let maxProgress = 0; const tokenCounter = (token: string) => { cancelPoll.check(); approxTokens += Math.ceil(token.length / 4); }; const updateProgress = async (progress: number, progressMessage: string) => { cancelPoll.check(); const next = Math.max(maxProgress, progress); maxProgress = next; await job.updateProgress(next); const [updated] = await db .update(generationJob) .set({ status: "running_quality", progress: next, progressMessage, }) .where( and( eq(generationJob.id, jobId), notInArray(generationJob.status, ["cancelled"]), ), ) .returning({ id: generationJob.id }); if (!updated) throw new GenerationJobCancelledError(); }; const pushLog = async ( step: string, message: string, status: "running" | "done" | "error", ) => { const entry = { step, message, status, timestamp: new Date().toISOString() }; const [row] = await db .select({ logs: generationJob.logs }) .from(generationJob) .where(eq(generationJob.id, jobId)) .limit(1); const existing = (row?.logs ?? []) as Array>; await db .update(generationJob) .set({ logs: [...existing, entry] as any }) .where(eq(generationJob.id, jobId)); }; try { await updateProgress(86, "Quality phase started..."); await pushLog("quality_start", "Running deep quality validation...", "running"); let completedSections = 0; const sectionResults = await runWithConcurrency( sectionSplits, QUALITY_SECTION_CONCURRENCY, async (split) => { try { const sectionInput: GenerationInput = { ...input, section: split.section as any, questionCount: split.count, mode: "agentic", }; const onQualityProgress = createAgenticProgressHandler( updateProgress, pushLog, mapAgenticProgressInQuality, ); const result = await generateQuestionsAgentic( sectionInput, onQualityProgress, tokenCounter, { strategy: "full", maxRegenerateAttempts: 2 }, ); return { section: split.section, questions: normalizeQuestions( split.section, input.apiKeyConfig.model, result, ), tokensUsed: result.meta.tokensUsed ?? 0, }; } catch { log("warn", "[GENERATION] Quality phase failed, using fast questions.", { jobId, section: split.section }); const fallbackQuestions = fastQuestions .filter((q) => q.section === split.section) .slice(0, split.count); return { section: split.section, questions: fallbackQuestions, tokensUsed: 0, usedFallback: true, }; } finally { completedSections += 1; const progress = Math.min( 86 + Math.round((completedSections / sectionSplits.length) * 10), 96, ); await updateProgress( progress, `Quality phase ${completedSections}/${sectionSplits.length}`, ); } }, ); const mergedQuestions = sectionSplits .flatMap((split) => { const sectionEntry = sectionResults.find((r) => r.section === split.section); return (sectionEntry?.questions ?? []).slice(0, split.count); }) .slice(0, input.questionCount); if (mergedQuestions.length === 0) { throw new Error("Quality phase produced no questions"); } const qualityTokens = sectionResults.reduce((acc, cur) => acc + (cur.tokensUsed ?? 0), 0) + (approxTokens > 0 ? approxTokens : 0); const totalTokens = (fastMeta.tokensUsed || fastMeta.approxTokens || 0) + qualityTokens; await completeJobWithResult({ jobId, input, allQuestions: mergedQuestions, sectionSplits, totalTokens, durationMs: Date.now() - qualityStart + fastMeta.durationMs, statusMessage: "Completed (quality upgraded)", metrics: { qualityUpgraded: true, qualitySections: sectionSplits.length, qualityApproxTokens: approxTokens, }, }); await pushLog("quality_done", "Quality upgrade completed", "done"); } catch (err: any) { if ( err instanceof GenerationJobCancelledError || err?.name === "GenerationJobCancelledError" ) { await db .update(generationJob) .set({ status: "cancelled", errorMessage: "Dibatalkan pengguna", completedAt: new Date(), }) .where(eq(generationJob.id, jobId)); return; } // Fallback safety: complete using fast result if quality pass fails. if (fastQuestions.length > 0) { await completeJobWithResult({ jobId, input, allQuestions: fastQuestions.slice(0, input.questionCount), sectionSplits, totalTokens: fastMeta.tokensUsed || fastMeta.approxTokens || 0, durationMs: fastMeta.durationMs, statusMessage: "Completed (fast result, quality fallback)", metrics: { qualityUpgraded: false, qualityFallback: true, }, }); return; } await db .update(generationJob) .set({ status: "failed", errorMessage: err?.message ?? String(err), durationMs: Date.now() - qualityStart, completedAt: new Date(), }) .where(eq(generationJob.id, jobId)); return; } finally { cancelPoll.stop(); } }, { connection: new IORedis(env.REDIS_URL, connectionOptions), concurrency: 1, }, ); export async function enqueueGeneration( userId: string, input: GenerationInput, ): Promise { const encryptedInput = { ...input, apiKeyConfig: { ...input.apiKeyConfig, apiKey: encryptApiKey(input.apiKeyConfig.apiKey), }, }; const [jobRecord] = await db .insert(generationJob) .values({ userId, mode: input.mode, examTypeId: input.examType, sectionTypeId: input.section, questionCount: input.questionCount, status: "pending", progress: 0, inputJson: encryptedInput as any, }) .returning(); if (!jobRecord) throw new Error("Failed to create generation job"); await generationQueue.add( "generate-fast", { input, jobId: jobRecord.id }, { jobId: jobRecord.id, removeOnComplete: { count: 100 }, removeOnFail: { count: 100 }, attempts: 1, }, ); return jobRecord.id; } export function decryptInputFromDb(inputJson: unknown): GenerationInput { const data = inputJson as Record; const apiKeyConfig = data.apiKeyConfig as Record; if (apiKeyConfig?.apiKey && typeof apiKeyConfig.apiKey === "string") { try { return { ...(data as unknown as GenerationInput), apiKeyConfig: { ...(apiKeyConfig as unknown as GenerationInput["apiKeyConfig"]), apiKey: decryptApiKey(apiKeyConfig.apiKey), }, }; } catch { log("debug", "[QUEUE] API key decryption failed (legacy data)."); return data as unknown as GenerationInput; } } return data as unknown as GenerationInput; }