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:
2026-06-21 22:25:43 +02:00
commit 4e38b5a791
194 changed files with 30789 additions and 0 deletions
+185
View File
@@ -0,0 +1,185 @@
/**
* 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());