feat: initial commit — Curio mastery tutor
Full Next.js App Router application with: - AI-graded lesson checkpoints (BKT/Elo mastery tracking) - Auth.js v5: credentials, Google, GitHub, generic OIDC - Anonymous-session-first with migrate-on-signin - Admin panel: users, blueprints, reports, site settings - Password reset + email verification (nodemailer/SMTP) - Site config: require_auth + signups_enabled flags - server-only guards on all DB/generation/verification modules - PostgreSQL 16 + pgvector, Redis cache, Drizzle ORM - 271 unit tests (Vitest), golden-eval harness, Playwright e2e stubs Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* BullMQ worker: generate lesson content for a blueprint.
|
||||
*
|
||||
* Idempotent: checks `job` ledger before generating.
|
||||
* On success: writes lesson to Redis cache and updates job status.
|
||||
* On failure: increments attempt count; sets status to 'failed' after processing.
|
||||
*/
|
||||
import { Worker } from 'bullmq';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db } from '../src/lib/db';
|
||||
import { jobs, blueprints, concepts, lessons } from '../src/lib/db/schema';
|
||||
import { generateLesson } from '../src/lib/generation/generate-lesson';
|
||||
import { verifyLesson } from '../src/lib/verification/verify-content';
|
||||
import { getLessonResponse } from '../src/lib/db/queries';
|
||||
import { setCachedLesson } from '../src/lib/cache/lesson';
|
||||
import { enqueuePromoteBlueprint } from '../src/lib/jobs/queue';
|
||||
import type { GenerateLessonJobData } from '../src/lib/jobs/queue';
|
||||
|
||||
const connection = {
|
||||
url: process.env.REDIS_URL ?? 'redis://localhost:6379',
|
||||
};
|
||||
|
||||
const worker = new Worker<GenerateLessonJobData>(
|
||||
'generate-lesson',
|
||||
async (job) => {
|
||||
const { intentKey, blueprintId, idempotencyKey } = job.data;
|
||||
|
||||
// 1. Idempotency check — find the job row by idempotency key
|
||||
const [jobRow] = await db
|
||||
.select({ id: jobs.id, status: jobs.status, attempts: jobs.attempts })
|
||||
.from(jobs)
|
||||
.where(eq(jobs.id, idempotencyKey))
|
||||
.limit(1);
|
||||
|
||||
if (jobRow?.status === 'done') {
|
||||
// Already completed — warm cache if needed and return
|
||||
const cached = await getLessonResponse(intentKey);
|
||||
if (cached?.state === 'ready') {
|
||||
await setCachedLesson(intentKey, cached);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark running
|
||||
await db
|
||||
.update(jobs)
|
||||
.set({ status: 'running', attempts: (jobRow?.attempts ?? 0) + 1 })
|
||||
.where(eq(jobs.id, idempotencyKey));
|
||||
|
||||
// 2. Check lesson doesn't already exist
|
||||
const existing = await getLessonResponse(intentKey);
|
||||
if (existing?.state === 'ready') {
|
||||
await setCachedLesson(intentKey, existing);
|
||||
await db.update(jobs).set({ status: 'done' }).where(eq(jobs.id, idempotencyKey));
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Fetch blueprint + concepts
|
||||
const [blueprint] = await db
|
||||
.select()
|
||||
.from(blueprints)
|
||||
.where(eq(blueprints.id, blueprintId))
|
||||
.limit(1);
|
||||
|
||||
if (!blueprint) throw new Error(`Blueprint not found: ${blueprintId}`);
|
||||
|
||||
const conceptRows = await db
|
||||
.select({ id: concepts.id, name: concepts.name, ord: concepts.ord, retrievalCtxRef: concepts.retrievalCtxRef })
|
||||
.from(concepts)
|
||||
.where(eq(concepts.blueprintId, blueprintId));
|
||||
|
||||
if (conceptRows.length === 0) throw new Error(`No concepts for blueprint: ${blueprintId}`);
|
||||
|
||||
// 4. Generate lesson
|
||||
const { lessonId } = await generateLesson({
|
||||
blueprintId,
|
||||
lessonOrd: 0,
|
||||
topicTitle: blueprint.title,
|
||||
conceptsToGenerate: conceptRows.map((c) => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
retrievalCtxRef: c.retrievalCtxRef,
|
||||
})),
|
||||
});
|
||||
|
||||
// 5. T1 verification (non-blocking for serve — T2 + promotion handled by promote-blueprint job)
|
||||
await verifyLesson({ lessonId, runT2: false });
|
||||
|
||||
// 6. Write to Redis cache
|
||||
const lesson = await getLessonResponse(intentKey);
|
||||
if (lesson?.state === 'ready') {
|
||||
await setCachedLesson(intentKey, lesson);
|
||||
}
|
||||
|
||||
// 7. Update job ledger
|
||||
await db.update(jobs).set({ status: 'done' }).where(eq(jobs.id, idempotencyKey));
|
||||
|
||||
// 8. Enqueue blueprint promotion (T2 verify + misconceptions)
|
||||
const promoteKey = `promote-blueprint:${blueprintId}:v1`;
|
||||
const [existingPromote] = await db
|
||||
.select({ id: jobs.id })
|
||||
.from(jobs)
|
||||
.where(eq(jobs.idempotencyKey, promoteKey))
|
||||
.limit(1);
|
||||
|
||||
if (!existingPromote) {
|
||||
const [promoteJobRow] = await db
|
||||
.insert(jobs)
|
||||
.values({
|
||||
type: 'promote-blueprint',
|
||||
idempotencyKey: promoteKey,
|
||||
status: 'pending',
|
||||
payloadJson: { blueprintId, intentKey, lessonId },
|
||||
})
|
||||
.returning({ id: jobs.id });
|
||||
|
||||
await enqueuePromoteBlueprint({
|
||||
blueprintId,
|
||||
intentKey,
|
||||
idempotencyKey: promoteJobRow.id,
|
||||
});
|
||||
}
|
||||
},
|
||||
{ connection, concurrency: 2 },
|
||||
);
|
||||
|
||||
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('[generate-lesson] job failed:', err);
|
||||
});
|
||||
|
||||
export default worker;
|
||||
@@ -0,0 +1,17 @@
|
||||
import generateLessonWorker from './generate-lesson-job';
|
||||
import promoteBlueprintWorker from './promote-blueprint-job';
|
||||
|
||||
console.log('[workers] Running: generate-lesson, promote-blueprint');
|
||||
|
||||
async function shutdown(signal: string) {
|
||||
console.log(`[workers] ${signal} received — draining and closing workers...`);
|
||||
await Promise.all([
|
||||
generateLessonWorker.close(),
|
||||
promoteBlueprintWorker.close(),
|
||||
]);
|
||||
console.log('[workers] Shutdown complete.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on('SIGTERM', () => void shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => void shutdown('SIGINT'));
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* 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);
|
||||
|
||||
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;
|
||||
Reference in New Issue
Block a user