/** * BullMQ worker: T2 content verification + misconception generation + blueprint promotion. * * After T2 passes, generates difficulty level 2 and 3 variants (§8.1, §13) so the serve * path can select the right level per learner without synchronous generation. * * Idempotent: checks job ledger before running, skips variant generation if segments * at that level already exist. */ import { Worker } from 'bullmq'; import { eq, and } from 'drizzle-orm'; import { db } from '../src/lib/db'; import { jobs, concepts, lessons, segments, blueprints } from '../src/lib/db/schema'; import { verifyLesson } from '../src/lib/verification/verify-content'; import { generateMisconceptions } from '../src/lib/generation/generate-misconceptions'; import { verifyMisconceptions } from '../src/lib/verification/verify-misconceptions'; import { generateLesson } from '../src/lib/generation/generate-lesson'; import { invalidateCachedLesson } from '../src/lib/cache/lesson'; import type { PromoteBlueprintJobData } from '../src/lib/jobs/queue'; const connection = { url: process.env.REDIS_URL ?? 'redis://localhost:6379', }; const worker = new Worker( 'promote-blueprint', async (job) => { const { blueprintId, intentKey, idempotencyKey } = job.data; // 1. Idempotency check const [jobRow] = await db .select({ id: jobs.id, status: jobs.status, payloadJson: jobs.payloadJson }) .from(jobs) .where(eq(jobs.id, idempotencyKey)) .limit(1); if (jobRow?.status === 'done') return; await db .update(jobs) .set({ status: 'running', attempts: (jobRow as { attempts?: number })?.attempts ?? 1 }) .where(eq(jobs.id, idempotencyKey)); // 2. Find lesson for this blueprint const [lesson] = await db .select({ id: lessons.id }) .from(lessons) .where(eq(lessons.blueprintId, blueprintId)) .limit(1); if (!lesson) throw new Error(`No lesson for blueprint: ${blueprintId}`); // 3. T2 verification (strong model — promotes blueprint to 'published' if all pass) await verifyLesson({ lessonId: lesson.id, runT2: true }); // 4. Generate misconceptions for each concept const conceptRows = await db .select({ id: concepts.id }) .from(concepts) .where(eq(concepts.blueprintId, blueprintId)); for (const concept of conceptRows) { await generateMisconceptions({ conceptId: concept.id }); await verifyMisconceptions({ conceptId: concept.id }); } // 5. Generate difficulty variants 2 and 3 (§8.1, §13) // Each variant is a new set of segments at a higher explanation level. // Skip if segments at that level already exist (idempotency). const [blueprint] = await db .select({ title: blueprints.title }) .from(blueprints) .where(eq(blueprints.id, blueprintId)) .limit(1); const conceptRowsFull = await db .select({ id: concepts.id, name: concepts.name, retrievalCtxRef: concepts.retrievalCtxRef, ord: concepts.ord }) .from(concepts) .where(eq(concepts.blueprintId, blueprintId)); if (blueprint && conceptRowsFull.length > 0) { const lessonOrd = 0; for (const level of [2, 3] as const) { const existing = await db .select({ id: segments.id }) .from(segments) .where(and(eq(segments.lessonId, lesson.id), eq(segments.difficultyLevel, level))) .limit(1); if (existing.length === 0) { await generateLesson({ blueprintId, lessonOrd, topicTitle: blueprint.title, conceptsToGenerate: conceptRowsFull, difficultyLevel: level, }); } } } // 6. Invalidate cache so next request fetches fresh lesson with updated verify status await invalidateCachedLesson(intentKey); await db.update(jobs).set({ status: 'done' }).where(eq(jobs.id, idempotencyKey)); }, { connection, concurrency: 1 }, ); worker.on('failed', async (job, err) => { if (job?.data.idempotencyKey) { await db .update(jobs) .set({ status: 'failed' }) .where(eq(jobs.id, job.data.idempotencyKey)); } console.error('[promote-blueprint] job failed:', err); }); export default worker;