/** * Seed script: JS Closures corpus + blueprint structure. * * Idempotent: exits early if source_chunks for this topic already exist. * Use --force to re-seed (drops + re-inserts). * * Usage: * pnpm seed # idempotent * pnpm seed -- --force # drop and re-seed */ import 'dotenv/config'; import { like, eq } from 'drizzle-orm'; import { db, pool } from '../src/lib/db/index.js'; import { sourceChunks, blueprints, concepts, misconceptions, lessons, segments, checkpoints, } from '../src/lib/db/schema.js'; import { embedTexts } from '../src/lib/generation/embed.js'; import { JS_CLOSURES_CHUNKS, JS_CLOSURES_BLUEPRINT, JS_CLOSURES_CONCEPTS, JS_CLOSURES_MISCONCEPTIONS, JS_CLOSURES_SEGMENTS, } from '../src/lib/db/seed/js-closures.js'; const FORCE = process.argv.includes('--force'); async function seed() { console.log('Seeding JS Closures corpus...'); // Idempotency check if (!FORCE) { const existing = await db .select({ id: sourceChunks.id }) .from(sourceChunks) .where(like(sourceChunks.docRef, 'js-closures/%')) .limit(1); if (existing.length > 0) { console.log('Already seeded. Run with --force to re-seed.'); return; } } else { console.log('--force: removing existing js-closures data...'); await db.delete(sourceChunks).where(like(sourceChunks.docRef, 'js-closures/%')); const existingBlueprint = await db .select({ id: blueprints.id }) .from(blueprints) .where(eq(blueprints.intentKey, JS_CLOSURES_BLUEPRINT.intentKey)) .limit(1); if (existingBlueprint.length > 0) { const bpId = existingBlueprint[0].id; // Delete bottom-up to respect FK constraints: // checkpoints → segments → lessons, misconceptions → concepts → blueprint const lessonRows = await db.select({ id: lessons.id }).from(lessons).where(eq(lessons.blueprintId, bpId)); for (const lesson of lessonRows) { const segRows = await db.select({ id: segments.id }).from(segments).where(eq(segments.lessonId, lesson.id)); for (const seg of segRows) { await db.delete(checkpoints).where(eq(checkpoints.segmentId, seg.id)); } await db.delete(segments).where(eq(segments.lessonId, lesson.id)); } await db.delete(lessons).where(eq(lessons.blueprintId, bpId)); const conceptRows = await db.select({ id: concepts.id }).from(concepts).where(eq(concepts.blueprintId, bpId)); for (const concept of conceptRows) { await db.delete(misconceptions).where(eq(misconceptions.conceptId, concept.id)); } await db.delete(concepts).where(eq(concepts.blueprintId, bpId)); await db.delete(blueprints).where(eq(blueprints.id, bpId)); } } // 1. Embed all chunks + blueprint intent text in one batch // Lowercase matches normalizeIntent's query normalization (input.toLowerCase()) const intentText = JS_CLOSURES_BLUEPRINT.title.toLowerCase(); // "javascript closures" const allTexts = [intentText, ...JS_CLOSURES_CHUNKS.map((c) => c.text)]; console.log(`Embedding ${allTexts.length} texts (1 blueprint + ${JS_CLOSURES_CHUNKS.length} chunks)...`); const [blueprintEmbedding, ...chunkEmbeddings] = await embedTexts(allTexts); console.log('Embeddings done.'); // 2. Insert source_chunks const insertedChunks = await db .insert(sourceChunks) .values( JS_CLOSURES_CHUNKS.map((chunk, i) => ({ docRef: chunk.docRef, text: chunk.text, embedding: chunkEmbeddings[i], })), ) .returning({ id: sourceChunks.id, docRef: sourceChunks.docRef }); console.log(`Inserted ${insertedChunks.length} source chunks.`); // 3. Insert blueprint (with embedding so normalizeIntent similarity search finds it) const [blueprint] = await db .insert(blueprints) .values({ ...JS_CLOSURES_BLUEPRINT, embedding: blueprintEmbedding, status: 'published' }) .returning({ id: blueprints.id }); console.log(`Inserted blueprint: ${blueprint.id}`); // 4. Insert concepts const insertedConcepts = await db .insert(concepts) .values(JS_CLOSURES_CONCEPTS.map((c) => ({ ...c, blueprintId: blueprint.id }))) .returning({ id: concepts.id, name: concepts.name }); console.log(`Inserted ${insertedConcepts.length} concepts.`); // 5. Insert misconceptions (keyed by concept name) const conceptByName = Object.fromEntries(insertedConcepts.map((c) => [c.name, c.id])); const misconceptionRows = JS_CLOSURES_MISCONCEPTIONS.flatMap((m) => { const conceptId = conceptByName[m.conceptName]; if (!conceptId) { console.warn(`No concept found for misconception "${m.conceptName}" — skipping.`); return []; } return [{ conceptId, tag: m.tag, signature: m.signature, verifyStatus: 'pass' as const }]; }); if (misconceptionRows.length > 0) { await db.insert(misconceptions).values(misconceptionRows); console.log(`Inserted ${misconceptionRows.length} misconceptions.`); } // 6. Insert pre-built lesson (so app works without running generation workers) const chunkIdByDocRef = Object.fromEntries(insertedChunks.map((c) => [c.docRef, c.id])); const [lesson] = await db .insert(lessons) .values({ blueprintId: blueprint.id, ord: 0, estMinutes: 10 }) .returning({ id: lessons.id }); console.log(`Inserted lesson: ${lesson.id}`); for (const seg of JS_CLOSURES_SEGMENTS) { const sourceChunkIds = seg.sourceDocRefs .map((ref) => chunkIdByDocRef[ref]) .filter((id): id is string => !!id); const [insertedSegment] = await db .insert(segments) .values({ lessonId: lesson.id, ord: seg.ord, bodyJson: { text: seg.body }, sourceChunkIds, verifyStatus: 'pass', }) .returning({ id: segments.id }); await db.insert(checkpoints).values({ segmentId: insertedSegment.id, prompt: seg.checkpoint.prompt, kind: seg.checkpoint.kind, referenceAnswer: seg.checkpoint.referenceAnswer, rubricJson: seg.checkpoint.rubric, verifyStatus: 'pass', }); console.log(` Segment ${seg.ord}: ${seg.sourceDocRefs.join(', ')}`); } console.log('Seed complete.'); } seed() .catch((err) => { console.error('Seed failed:', err); process.exit(1); }) .finally(() => pool.end());