/** * Day-3 seed runner: legal_sources, legal_chunks, route_rules. * * Idempotency strategy: * - legal_sources: upsert on (title) — delete existing chunks + source, then re-insert. * Simpler than per-row sha256 conflict since we control the data file. * - legal_chunks: inserted after source is inserted; no conflict clause needed as we * always delete-then-insert by source. * - route_rules: delete-then-insert by issueDomain (Phase 1 has exactly one rule/domain). * * Embedding: * Attempts to import createOpenAiCompatibleEmbedder from @courtmitra/adapters. * If the export does not exist (Agent A's embedder not yet merged), falls back gracefully * with embedding=null — FTS (tsvector GIN index) remains fully functional. * * Usage: * pnpm --filter @courtmitra/db seed * (or) set -a && . infra/.env && set +a && tsx packages/db/src/seeds/run.ts */ import { createHash } from "node:crypto"; import { readFileSync } from "node:fs"; import { resolve, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { eq, and } from "drizzle-orm"; // Load .env from infra/.env relative to repo root (two levels up from packages/db/src/seeds) const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const envPath = resolve(__dirname, "../../../../infra/.env"); function loadEnv(path: string): void { let raw: string; try { raw = readFileSync(path, "utf8"); } catch { // Already exported by shell; skip silently. return; } for (const line of raw.split("\n")) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith("#")) continue; const eqIdx = trimmed.indexOf("="); if (eqIdx === -1) continue; const key = trimmed.slice(0, eqIdx).trim(); const val = trimmed.slice(eqIdx + 1).trim(); if (!process.env[key]) { process.env[key] = val; } } } loadEnv(envPath); import { getDb, closeDb, legalSources, legalChunks, routeRules } from "../index.js"; import { ALL_LEGAL_SEED_ENTRIES } from "./legal-data.js"; import { ALL_ROUTE_RULES } from "./route-rules-data.js"; import type { LegalSeedEntry } from "./legal-data.js"; import type { RouteRuleSeedData } from "./route-rules-data.js"; // --------------------------------------------------------------------------- // Embedder — graceful degradation if Agent A's factory isn't merged yet // --------------------------------------------------------------------------- type EmbedFn = (texts: string[]) => Promise<(number[] | null)[]>; async function resolveEmbedder(): Promise { try { // Dynamically import so that a missing export doesn't crash at module load. const adaptersModule = await import("@courtmitra/adapters"); // eslint-disable-next-line @typescript-eslint/no-explicit-any const factory = (adaptersModule as any).createOpenAiCompatibleEmbedder; if (typeof factory !== "function") { console.warn( "[seed] WARNING: createOpenAiCompatibleEmbedder not found in @courtmitra/adapters. " + "Inserting chunks with embedding=null. FTS will still work.", ); return async (texts) => texts.map(() => null); } const embedder = factory(); console.log("[seed] Embedder resolved from @courtmitra/adapters."); return async (texts: string[]) => { try { return await embedder.embed(texts); } catch (err) { console.warn("[seed] WARNING: embedder.embed() failed:", (err as Error).message); console.warn("[seed] Inserting chunks with embedding=null."); return texts.map(() => null); } }; } catch (err) { console.warn( "[seed] WARNING: Could not import @courtmitra/adapters:", (err as Error).message, ); console.warn("[seed] Inserting chunks with embedding=null. FTS will still work."); return async (texts) => texts.map(() => null); } } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function sha256(text: string): string { return createHash("sha256").update(text, "utf8").digest("hex"); } function countTokensApprox(text: string): number { // Rough approximation: 1 token ≈ 4 chars for English legal text. return Math.ceil(text.length / 4); } // --------------------------------------------------------------------------- // Main seed logic // --------------------------------------------------------------------------- async function seedLegalData( db: ReturnType, embed: EmbedFn, ): Promise<{ sourcesInserted: number; chunksInserted: number }> { let sourcesInserted = 0; let chunksInserted = 0; for (const entry of ALL_LEGAL_SEED_ENTRIES) { const { source, chunks } = entry as LegalSeedEntry; // --- 1. Delete existing source (and cascading chunks) by title --- const existing = await db .select({ id: legalSources.id }) .from(legalSources) .where(eq(legalSources.title, source.title)); if (existing.length > 0) { const existingId = existing[0]!.id; // Delete chunks first (FK constraint) await db.delete(legalChunks).where(eq(legalChunks.sourceId, existingId)); await db.delete(legalSources).where(eq(legalSources.id, existingId)); console.log(`[seed] Replaced existing source: "${source.title}"`); } // --- 2. Compute sha256 for the source (based on title + version) --- const sourceHash = sha256(`${source.title}|${source.versionLabel}`); // --- 3. Insert source --- const [insertedSource] = await db .insert(legalSources) .values({ sourceType: source.sourceType, title: source.title, tier: source.tier, jurisdiction: source.jurisdiction, authority: source.authority, canonicalUrl: source.canonicalUrl, versionLabel: source.versionLabel, effectiveFrom: source.effectiveFrom, sha256: sourceHash, metadata: source.metadata, }) .returning({ id: legalSources.id }); if (!insertedSource) { console.error(`[seed] ERROR: Failed to insert source "${source.title}"`); continue; } sourcesInserted++; const sourceId = insertedSource.id; console.log(`[seed] Inserted source [${sourceId}]: "${source.title}"`); // --- 4. Embed all chunk texts for this source --- const chunkTexts = chunks.map((c) => c.text); const embeddings = await embed(chunkTexts); // --- 5. Insert chunks --- for (let i = 0; i < chunks.length; i++) { const chunk = chunks[i]!; const embedding = embeddings[i] ?? null; await db.insert(legalChunks).values({ sourceId, chunkNo: chunk.chunkNo, actName: chunk.actName, sectionNo: chunk.sectionNo, heading: chunk.heading, text: chunk.text, tokenCount: countTokensApprox(chunk.text), // textTsv is a GENERATED ALWAYS AS column — do not supply a value embedding: embedding as number[] | null, metadata: chunk.metadata, }); chunksInserted++; } console.log(`[seed] → ${chunks.length} chunks inserted for "${source.title}"`); } return { sourcesInserted, chunksInserted }; } async function seedRouteRules( db: ReturnType, ): Promise<{ rulesInserted: number }> { let rulesInserted = 0; for (const rule of ALL_ROUTE_RULES as RouteRuleSeedData[]) { // --- 1. Resolve sourceCitationsBySectionRef → legal_chunk ids --- const resolvedChunkIds: string[] = []; for (const ref of rule.sourceCitationsBySectionRef) { // Find the source by title (act name) const sourceRows = await db .select({ id: legalSources.id }) .from(legalSources) .where(eq(legalSources.title, ref.actName)); if (sourceRows.length === 0) { console.warn( `[seed] WARNING: Source not found for actName="${ref.actName}" — skipping citation`, ); continue; } const sourceId = sourceRows[0]!.id; // Find chunk by source_id + section_no const chunkRows = await db .select({ id: legalChunks.id }) .from(legalChunks) .where( and( eq(legalChunks.sourceId, sourceId), eq(legalChunks.sectionNo, ref.sectionNo), ), ); if (chunkRows.length === 0) { console.warn( `[seed] WARNING: Chunk not found for actName="${ref.actName}" sectionNo="${ref.sectionNo}" — skipping`, ); continue; } resolvedChunkIds.push(chunkRows[0]!.id); } // --- 2. Delete existing rule for this domain (idempotency) --- await db.delete(routeRules).where(eq(routeRules.issueDomain, rule.issueDomain)); // --- 3. Insert route rule --- const [inserted] = await db .insert(routeRules) .values({ issueDomain: rule.issueDomain, jurisdictionFilter: rule.jurisdictionFilter, triggerConditions: rule.triggerConditions, priority: rule.priority, routeGraph: rule.routeGraph, requiredEvidence: rule.requiredEvidence, deadlines: rule.deadlines, sourceCitations: resolvedChunkIds, socialTemplate: rule.socialTemplate, stopAndRefer: rule.stopAndRefer, active: rule.active, }) .returning({ id: routeRules.id }); if (!inserted) { console.error(`[seed] ERROR: Failed to insert route rule for domain "${rule.issueDomain}"`); continue; } rulesInserted++; console.log( `[seed] Route rule [${inserted.id}] inserted for domain="${rule.issueDomain}" ` + `with ${resolvedChunkIds.length} cited chunks`, ); } return { rulesInserted }; } // --------------------------------------------------------------------------- // Entry point // --------------------------------------------------------------------------- async function main(): Promise { console.log("[seed] Day-3 legal grounding seed starting..."); console.log(`[seed] DATABASE_URL: ${process.env["DATABASE_URL"] ? "set" : "NOT SET — will fail"}`); const db = getDb(); // Resolve embedder (graceful degradation if Agent A's export isn't merged) const embed = await resolveEmbedder(); // Seed legal data console.log("\n[seed] ── Seeding legal_sources + legal_chunks ──"); const { sourcesInserted, chunksInserted } = await seedLegalData(db, embed); // Seed route rules console.log("\n[seed] ── Seeding route_rules ──"); const { rulesInserted } = await seedRouteRules(db); console.log("\n[seed] ── Summary ──"); console.log(`[seed] legal_sources inserted : ${sourcesInserted}`); console.log(`[seed] legal_chunks inserted : ${chunksInserted}`); console.log(`[seed] route_rules inserted : ${rulesInserted}`); console.log("[seed] Done."); await closeDb(); } main().catch((err) => { console.error("[seed] FATAL:", err); process.exit(1); });