feat: integrate leaderboard features with new MyRankCard and RankingPodium components. Enhance sidebar navigation to include leaderboard links and update routing to support new setup-avatar and leaderboard routes. Refactor job management logic to utilize useGenerationJobs hook for improved job tracking and error handling.
85cb661 | import { Queue, Worker, type Job } from "bullmq"; | |
| import IORedis from "ioredis"; | |
| import { env } from "@labas/env/server"; | |
| import { | |
| generateQuestionsQuick, | |
| generateQuestionsAgentic, | |
| GenerationError, | |
| type GenerationInput, | |
| type GenerationResult, | |
| } from "@labas/ai"; | |
| import { db } from "@labas/db"; | |
| import { | |
| generationJob, | |
| question, | |
| testPackage, | |
| packageSection, | |
| sectionQuestion, | |
| } from "@labas/db"; | |
| import { and, eq, notInArray } from "drizzle-orm"; | |
| 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 = 6; | |
| const FAST_SHARD_CONCURRENCY = 3; | |
| const QUALITY_SECTION_CONCURRENCY = 2; | |
| const MAX_SHARD_RETRIES = 2; | |
| 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"; | |
| } | |
| } | |
| 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), | |
| })); | |
| } | |
| 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(); | |
| }, | |
| }; | |
| } | |
| async function runWithConcurrency<T, R>( | |
| items: T[], | |
| concurrency: number, | |
| worker: (item: T, index: number) => Promise<R>, | |
| ): Promise<R[]> { | |
| 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; | |
| } | |
| 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) { | |
| await db.insert(sectionQuestion).values( | |
| sectionQuestions | |
| .map((q, idx) => ({ | |
| sectionId: sec.id, | |
| questionId: savedQuestionIds[q._globalIndex], | |
| orderIndex: idx, | |
| })) | |
| .filter((q) => q.questionId != null) 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 }; | |
| } | |
| async function completeJobWithResult(params: { | |
| jobId: string; | |
| input: GenerationInput; | |
| statusMessage?: string; | |
| allQuestions: PersistableQuestion[]; | |
| sectionSplits: SectionSplit[]; | |
| totalTokens: number; | |
| durationMs: number; | |
| metrics?: Record<string, unknown>; | |
| }) { | |
| 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: "completed", | |
| progress: 100, | |
| progressMessage: params.statusMessage ?? "Completed", | |
| resultJson: { | |
| ...result, | |
| savedQuestionIds, | |
| generatedPackageId, | |
| sectionSplits: params.sectionSplits, | |
| qualityPhase: "final", | |
| metrics: params.metrics, | |
| } as any, | |
| tokensUsed: params.totalTokens, | |
| durationMs: params.durationMs, | |
| completedAt: new Date(), | |
| }) | |
| .where(eq(generationJob.id, params.jobId)); | |
| } | |
| export type CancelGenerationJobResult = | |
| | { ok: true } | |
| | { ok: false; reason: "not_found" | "forbidden" | "not_cancellable" }; | |
| export const generationQueue = new Queue<FastJobData>(FAST_QUEUE_NAME, { | |
| connection: new IORedis(env.REDIS_URL, connectionOptions), | |
| }); | |
| export const generationQualityQueue = new Queue<QualityJobData>(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<CancelGenerationJobResult> { | |
| 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 === "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 { | |
| // Likely active; worker exits cooperatively. | |
| } | |
| } | |
| } catch { | |
| // Ignore queue hiccups. | |
| } | |
| } | |
| return { ok: true }; | |
| } | |
| export const generationWorker = new Worker<FastJobData>( | |
| FAST_QUEUE_NAME, | |
| async (job: Job<FastJobData>) => { | |
| 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" | |
| ) { | |
| return; | |
| } | |
| const claimed = await db | |
| .update(generationJob) | |
| .set({ status: "running_fast" }) | |
| .where( | |
| and( | |
| eq(generationJob.id, jobId), | |
| notInArray(generationJob.status, ["cancelled", "completed"]), | |
| ), | |
| ) | |
| .returning({ id: generationJob.id }); | |
| if (!claimed.length) return; | |
| const cancelPoll = createCancellationPoller(jobId); | |
| let heartbeatInterval: ReturnType<typeof setInterval> | 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)); | |
| }; | |
| const updateProgress = async ( | |
| progress: number, | |
| progressMessage: string, | |
| status?: string, | |
| resultJson?: unknown, | |
| ) => { | |
| cancelPoll.check(); | |
| await job.updateProgress(progress); | |
| await db | |
| .update(generationJob) | |
| .set({ | |
| progress, | |
| progressMessage, | |
| ...(status ? { status } : {}), | |
| ...(resultJson !== undefined ? { resultJson: resultJson as any } : {}), | |
| }) | |
| .where(eq(generationJob.id, jobId)); | |
| }; | |
| 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 shardResults: Array<{ | |
| shard: ShardPlan; | |
| questions: PersistableQuestion[]; | |
| }> = []; | |
| const runShard = async (shard: ShardPlan): Promise<void> => { | |
| 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; | |
| try { | |
| if (selectedMode === "agentic") { | |
| sectionResult = await generateQuestionsAgentic( | |
| subInput, | |
| undefined, | |
| tokenCounter, | |
| { strategy: "lean", maxRegenerateAttempts: 1 }, | |
| ); | |
| } else { | |
| sectionResult = await generateQuestionsQuick(subInput, { | |
| onToken: tokenCounter, | |
| }); | |
| } | |
| } catch (quickErr: any) { | |
| const quickErrorMessage = quickErr?.message ?? String(quickErr); | |
| const shouldFallbackToAgentic = | |
| 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" }, | |
| undefined, | |
| 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[] = []; | |
| await runWithConcurrency(shards, FAST_SHARD_CONCURRENCY, async (shard) => { | |
| try { | |
| await runShard(shard); | |
| } catch { | |
| failedShards.push(shard); | |
| } | |
| }); | |
| if (failedShards.length > 0) { | |
| await pushLog( | |
| "retry_budget", | |
| `Retrying ${failedShards.length} failed shard(s), budget=${MAX_SHARD_RETRIES}`, | |
| "running", | |
| ); | |
| } | |
| for (const failedShard of failedShards) { | |
| let success = false; | |
| for (let attempt = 1; attempt <= MAX_SHARD_RETRIES; attempt++) { | |
| try { | |
| await runShard(failedShard); | |
| success = true; | |
| break; | |
| } catch (retryErr: any) { | |
| await pushLog( | |
| "retry_budget", | |
| `[${failedShard.section}] shard retry ${attempt}/${MAX_SHARD_RETRIES} failed: ${retryErr?.message ?? String(retryErr)}`, | |
| "error", | |
| ); | |
| } | |
| } | |
| if (!success) { | |
| 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); | |
| if (allQuestions.length === 0) { | |
| throw new Error("No questions generated in fast phase"); | |
| } | |
| const fastResult: GenerationResult = { | |
| questions: allQuestions as any, | |
| meta: { | |
| model: input.apiKeyConfig.model, | |
| tokensUsed: totalTokens || approxTokens, | |
| durationMs: totalDurationMs || Date.now() - start, | |
| mode: input.mode, | |
| }, | |
| }; | |
| if (selectedMode === "agentic") { | |
| await updateProgress(85, "Fast phase done, enqueuing quality phase...", "running_quality", { | |
| ...fastResult, | |
| sectionSplits, | |
| qualityPhase: "fast", | |
| isPartial: true, | |
| metrics: { | |
| timeToFirstValidQuestionMs: timeToFirstValidQuestionMs ?? Date.now() - start, | |
| shardCount: shards.length, | |
| shardRetryBudget: MAX_SHARD_RETRIES, | |
| }, | |
| }); | |
| await pushLog( | |
| "quality_queue", | |
| "Partial result ready, lanjut quality upgrade di background", | |
| "done", | |
| ); | |
| await generationQualityQueue.add( | |
| "quality-upgrade", | |
| { | |
| input, | |
| jobId, | |
| sectionSplits, | |
| fastQuestions: allQuestions, | |
| fastMeta: { | |
| tokensUsed: totalTokens, | |
| durationMs: totalDurationMs || Date.now() - start, | |
| approxTokens, | |
| }, | |
| }, | |
| { | |
| jobId, | |
| removeOnComplete: { count: 100 }, | |
| removeOnFail: { count: 100 }, | |
| attempts: 2, | |
| backoff: { type: "exponential", delay: 4000 }, | |
| }, | |
| ); | |
| return; | |
| } | |
| await pushLog("save", "Saving quick result...", "running"); | |
| await completeJobWithResult({ | |
| jobId, | |
| input, | |
| allQuestions, | |
| sectionSplits, | |
| totalTokens: totalTokens || approxTokens, | |
| durationMs: Date.now() - start, | |
| statusMessage: "Completed", | |
| metrics: { | |
| timeToFirstValidQuestionMs: timeToFirstValidQuestionMs ?? Date.now() - start, | |
| shardCount: shards.length, | |
| shardRetryBudget: MAX_SHARD_RETRIES, | |
| shardFailures: failedShards.length, | |
| }, | |
| }); | |
| await pushLog("save", `Saved ${allQuestions.length} questions`, "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)); | |
| throw err; | |
| } finally { | |
| stopHeartbeat(); | |
| cancelPoll.stop(); | |
| } | |
| }, | |
| { | |
| connection: new IORedis(env.REDIS_URL, connectionOptions), | |
| concurrency: 5, | |
| }, | |
| ); | |
| export const generationQualityWorker = new Worker<QualityJobData>( | |
| QUALITY_QUEUE_NAME, | |
| async (job: Job<QualityJobData>) => { | |
| const { input, jobId, sectionSplits, fastQuestions, fastMeta } = job.data; | |
| const qualityStart = Date.now(); | |
| const cancelPoll = createCancellationPoller(jobId); | |
| let approxTokens = 0; | |
| const tokenCounter = (token: string) => { | |
| cancelPoll.check(); | |
| approxTokens += Math.ceil(token.length / 4); | |
| }; | |
| const updateProgress = async (progress: number, progressMessage: string) => { | |
| cancelPoll.check(); | |
| await job.updateProgress(progress); | |
| await db | |
| .update(generationJob) | |
| .set({ | |
| status: "running_quality", | |
| progress, | |
| progressMessage, | |
| }) | |
| .where(eq(generationJob.id, jobId)); | |
| }; | |
| 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<Record<string, unknown>>; | |
| 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 result = await generateQuestionsAgentic( | |
| sectionInput, | |
| undefined, | |
| tokenCounter, | |
| { strategy: "full", maxRegenerateAttempts: 2 }, | |
| ); | |
| return { | |
| section: split.section, | |
| questions: normalizeQuestions( | |
| split.section, | |
| input.apiKeyConfig.model, | |
| result, | |
| ), | |
| tokensUsed: result.meta.tokensUsed ?? 0, | |
| }; | |
| } catch { | |
| 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)); | |
| throw err; | |
| } finally { | |
| cancelPoll.stop(); | |
| } | |
| }, | |
| { | |
| connection: new IORedis(env.REDIS_URL, connectionOptions), | |
| concurrency: 1, | |
| }, | |
| ); | |
| export async function enqueueGeneration( | |
| userId: string, | |
| input: GenerationInput, | |
| ): Promise<string> { | |
| 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: input 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: 3, | |
| backoff: { | |
| type: "exponential", | |
| delay: 5000, | |
| }, | |
| }, | |
| ); | |
| return jobRecord.id; | |
| } | |