5bf6013460
Progressive lesson streaming via onSegment callback (fixes SSE for non-English users — locale was shadowed in lesson-reader useEffect). Adds: BullMQ workers, Redis stream buffer, token budget enforcement, Langfuse tracing, golden-eval runner, Playwright e2e scaffolding, lesson depth/locale/preferences schema, mastery map UI, admin panel (blueprints/users/reports/quality/misconceptions), image queries, source citations, view transitions, reading animations, i18n (next-intl), PDF export, surprise endpoint, and 402 passing unit tests. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
121 lines
4.2 KiB
TypeScript
121 lines
4.2 KiB
TypeScript
/**
|
|
* 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<PromoteBlueprintJobData>(
|
|
'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, 'en');
|
|
|
|
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;
|