#!/usr/bin/env tsx /** * SCALEBATCH – Receptkatalog Fas A Steg 2 (docs/32). * * Genererar ~200 verified AI-recept i batchar om ~25–30. * Budgetguard, DB-dedup, stickprov, seed i staging. * * Körning per batch: * AAMOS_MODE=gemini GEMINI_API_KEY=... pnpm tsx packages/recipe-generation/scripts/scale-batch.ts --batch=0 * * Kör alla batchar: * AAMOS_MODE=gemini GEMINI_API_KEY=... pnpm tsx packages/recipe-generation/scripts/scale-batch.ts --all */ import { config as loadDotenv } from "dotenv"; import { existsSync as fsExistsSync } from "fs"; import { resolve } from "path"; for (const candidate of [".env", "../.env", "../../.env"]) { const p = resolve(process.cwd(), candidate); if (fsExistsSync(p)) { loadDotenv({ path: p }); break; } } import { createAamosClient } from "@app/ai-contracts"; import { SEED_INGREDIENTS } from "@app/database/seed"; import { createDatabase, schema } from "@app/database"; import { runPipeline, type PipelineTarget, type PipelineIngredient } from "@app/recipe-generation"; import type { CanonicalIngredientLookup, SimilarityLookup, VerificationResult } from "@app/recipe-generation"; import { sql, eq } from "drizzle-orm"; import { writeFileSync, mkdirSync, existsSync, readFileSync } from "fs"; import { join } from "path"; import type { RecipeDNA, Allergen } from "@app/shared-types"; // ── 0. ARGS ──────────────────────────────────────────────────────────────── const args = process.argv.slice(2); const batchArg = args.find((a) => a.startsWith("--batch=")); const batchIndex = batchArg ? parseInt(batchArg.split("=")[1], 10) : null; const runAll = args.includes("--all"); const dryRun = args.includes("--dry-run"); if (batchIndex === null && !runAll) { console.error("Användning: pnpm tsx scale-batch.ts --batch=N | --all [--dry-run]"); console.error("Batchar 0–7 finns. --all kör alla sekventiellt med budgetcheck."); process.exit(1); } // ── 1. Konfiguration ─────────────────────────────────────────────────────── const REPORT_DIR = "/mnt/c/Users/Public/cibello-scale-batch"; const BUDGET_FILE = join(REPORT_DIR, "budget-state.json"); const BUDGET_USD = Number(process.env.GEMINI_DAILY_BUDGET_USD ?? 10); const PAUSE_THRESHOLD_USD = BUDGET_USD * 0.9; // Pausa vid 90 % mkdirSync(REPORT_DIR, { recursive: true }); function getBudgetState(): { spentUsd: number; lastReset: string } { if (!existsSync(BUDGET_FILE)) return { spentUsd: 0, lastReset: new Date().toISOString() }; try { return JSON.parse(readFileSync(BUDGET_FILE, "utf-8")); } catch { return { spentUsd: 0, lastReset: new Date().toISOString() }; } } function saveBudgetState(state: { spentUsd: number; lastReset: string }) { writeFileSync(BUDGET_FILE, JSON.stringify(state, null, 2)); } // ── 2. Katalog från seed ─────────────────────────────────────────────────── const catalog: PipelineIngredient[] = SEED_INGREDIENTS.map((i) => ({ id: i.id, nameSv: i.nameSv, category: i.category, defaultUnit: i.defaultUnit, isVegan: i.isVegan, isVegetarian: i.isVegetarian, containsGluten: i.containsGluten, containsLactose: i.containsLactose, allergens: i.allergens, })); const ingredientLookup: CanonicalIngredientLookup = { getById(id: string) { const ing = SEED_INGREDIENTS.find((i) => i.id === id); if (!ing) return undefined; return { id: ing.id, nutritionPer100: ing.nutritionPer100, defaultUnit: ing.defaultUnit, densityGPerMl: ing.densityGPerMl ?? null, gramsPerPiece: ing.gramsPerPiece ?? null, allergens: ing.allergens, isVegan: ing.isVegan, isVegetarian: ing.isVegetarian, containsGluten: ing.containsGluten, containsLactose: ing.containsLactose, isPork: ing.isPork, isBeef: ing.isBeef, isAlcohol: ing.isAlcohol, }; }, }; // ── 3. DB-klient ─────────────────────────────────────────────────────────── const { db, pool } = createDatabase(); // ── 4. Batch-matriser (8 batchar × ~27 recept = ~216 mål) ───────────────── const BATCHES: PipelineTarget[][] = [ // Batch 0: Mejeri [ { mealType: "dinner", mainIngredientId: "milk_3", dietVariant: "standard", count: 5 }, { mealType: "dinner", mainIngredientId: "cream", dietVariant: "standard", count: 5 }, { mealType: "dinner", mainIngredientId: "creme_fraiche", dietVariant: "standard", count: 5 }, { mealType: "dinner", mainIngredientId: "cooking_cream", dietVariant: "standard", count: 5 }, { mealType: "dinner", mainIngredientId: "milk_3", dietVariant: "lactose_free", count: 3 }, { mealType: "dinner", mainIngredientId: "cream", dietVariant: "lactose_free", count: 4 }, ], // Batch 1: Kyckling [ { mealType: "dinner", mainIngredientId: "chicken_breast", dietVariant: "standard", count: 7 }, { mealType: "dinner", mainIngredientId: "chicken_thigh", dietVariant: "standard", count: 7 }, { mealType: "dinner", mainIngredientId: "chicken_breast", dietVariant: "gluten_free", count: 7 }, { mealType: "dinner", mainIngredientId: "chicken_thigh", dietVariant: "gluten_free", count: 6 }, ], // Batch 2: Kött [ { mealType: "dinner", mainIngredientId: "minced_beef", dietVariant: "standard", count: 7 }, { mealType: "dinner", mainIngredientId: "minced_mixed", dietVariant: "standard", count: 7 }, { mealType: "dinner", mainIngredientId: "minced_beef", dietVariant: "gluten_free", count: 7 }, { mealType: "dinner", mainIngredientId: "pork_loin", dietVariant: "standard", count: 6 }, ], // Batch 3: Fisk [ { mealType: "dinner", mainIngredientId: "salmon", dietVariant: "standard", count: 7 }, { mealType: "dinner", mainIngredientId: "cod", dietVariant: "standard", count: 7 }, { mealType: "dinner", mainIngredientId: "shrimp", dietVariant: "standard", count: 7 }, { mealType: "dinner", mainIngredientId: "salmon", dietVariant: "gluten_free", count: 6 }, ], // Batch 4: Pasta/ris/potatis [ { mealType: "dinner", mainIngredientId: "pasta_dry", dietVariant: "standard", count: 7 }, { mealType: "dinner", mainIngredientId: "rice_white", dietVariant: "standard", count: 7 }, { mealType: "dinner", mainIngredientId: "potato", dietVariant: "standard", count: 7 }, { mealType: "dinner", mainIngredientId: "pasta_dry", dietVariant: "vegan", count: 6 }, ], // Batch 5: Baljväxter [ { mealType: "dinner", mainIngredientId: "tofu", dietVariant: "vegan", count: 7 }, { mealType: "dinner", mainIngredientId: "red_lentils", dietVariant: "vegetarian", count: 7 }, { mealType: "dinner", mainIngredientId: "chickpeas_canned", dietVariant: "vegan", count: 7 }, { mealType: "dinner", mainIngredientId: "black_beans_canned", dietVariant: "vegan", count: 6 }, ], // Batch 6: Grönsaker [ { mealType: "dinner", mainIngredientId: "tomato", dietVariant: "standard", count: 7 }, { mealType: "dinner", mainIngredientId: "zucchini", dietVariant: "standard", count: 7 }, { mealType: "dinner", mainIngredientId: "bell_pepper", dietVariant: "standard", count: 7 }, { mealType: "dinner", mainIngredientId: "carrot", dietVariant: "standard", count: 6 }, ], // Batch 7: Mixed dietvarianter [ { mealType: "dinner", mainIngredientId: "rice_white", dietVariant: "vegan", count: 7 }, { mealType: "dinner", mainIngredientId: "tofu", dietVariant: "standard", count: 7 }, { mealType: "dinner", mainIngredientId: "cod", dietVariant: "gluten_free", count: 7 }, { mealType: "dinner", mainIngredientId: "shrimp", dietVariant: "gluten_free", count: 6 }, ], ]; // ── 5. Hjälpfunktioner ───────────────────────────────────────────────────── function slugify(title: string): string { return title .toLowerCase() .replace(/[åä]/g, "a") .replace(/[ö]/g, "o") .replace(/[^a-z0-9\s-]/g, "") .trim() .replace(/\s+/g, "-") .substring(0, 80); } async function fetchExistingTitles(): Promise> { const rows = await db.select({ titleSv: schema.recipes.titleSv }).from(schema.recipes); return new Set(rows.map((r) => r.titleSv.toLowerCase().trim())); } function inferDna(vr: VerificationResult): RecipeDNA { const c = vr.candidate; const ings = c.ingredients.map((i) => i.canonicalIngredientId); // Protein const proteinId = ings.find( (id) => id.includes("chicken") || id.includes("beef") || id.includes("pork") || id.includes("salmon") || id.includes("cod") || id.includes("shrimp") || id.includes("tofu") || id.includes("lentil") || id.includes("chickpea") || id.includes("bean") || id.includes("egg") || id.includes("meatball"), ); // Kolhydrat const carbId = ings.find( (id) => id.includes("pasta") || id.includes("rice") || id.includes("potato") || id.includes("noodle") || id.includes("bread") || id.includes("tortilla") || id.includes("bun"), ); // Grönsaker const vegIds = ings.filter( (id) => id.includes("tomato") || id.includes("zucchini") || id.includes("pepper") || id.includes("carrot") || id.includes("onion") || id.includes("spinach") || id.includes("broccoli") || id.includes("leek") || id.includes("mushroom") || id.includes("cucumber") || id.includes("lettuce") || id.includes("garlic") || id.includes("dill") || id.includes("parsley") || id.includes("basil") || id.includes("ginger") || id.includes("peas") || id.includes("corn") || id.includes("olives") || id.includes("avocado") || id.includes("cabbage") || id.includes("cauliflower") || id.includes("celeriac") || id.includes("beetroot") || id.includes("parsnip") || id.includes("swede") || id.includes("kale") || id.includes("asparagus") || id.includes("green_bean") || id.includes("sugar_snap") || id.includes("radish") || id.includes("fennel") || id.includes("eggplant") || id.includes("pumpkin") || id.includes("squash") || id.includes("sweet_potato"), ); // Smakprofil const flavorTokens: string[] = []; if (ings.some((id) => id.includes("curry"))) flavorTokens.push("curry"); if (ings.some((id) => id.includes("tomato") || id.includes("paste"))) flavorTokens.push("tomato"); if (ings.some((id) => id.includes("garlic") || id.includes("onion"))) flavorTokens.push("aromatic"); if (ings.some((id) => id.includes("lemon") || id.includes("lime"))) flavorTokens.push("citrus"); if (ings.some((id) => id.includes("dill") || id.includes("parsley") || id.includes("basil"))) flavorTokens.push("herb"); if (ings.some((id) => id.includes("ginger") || id.includes("soy"))) flavorTokens.push("asian"); if (ings.some((id) => id.includes("coconut"))) flavorTokens.push("coconut"); if (ings.some((id) => id.includes("cheese") || id.includes("feta") || id.includes("halloumi"))) flavorTokens.push("cheese"); if (flavorTokens.length === 0) flavorTokens.push("savory"); // Metod const firstStep = c.steps[0]?.instructionSv.toLowerCase() ?? ""; let method = "stovetop"; if (firstStep.includes("ugn") || firstStep.includes("baka") || firstStep.includes("gratin")) method = "oven"; else if (firstStep.includes("wok") || firstStep.includes("woka")) method = "wok"; else if (firstStep.includes("grill")) method = "grill"; else if (firstStep.includes("koka") && firstStep.includes("soppa")) method = "simmer"; else if (firstStep.includes("ånga")) method = "steam"; return { cuisine: c.cuisine ?? "swedish", ...(proteinId ? { protein: proteinId.replace(/_/g, " ") } : {}), ...(carbId ? { carbohydrate: carbId.replace(/_/g, " ") } : {}), vegetables: vegIds.map((id) => id.replace(/_/g, " ")), flavorProfile: flavorTokens.join(", "), spiceLevel: c.spiceLevel, method, timeMinutes: c.prepTimeMinutes + c.cookTimeMinutes, calories: vr.nutritionPerPortion?.kcal ?? 0, proteinGrams: Math.round(vr.nutritionPerPortion?.proteinG ?? 0), }; } function estimateCostUsd(targets: PipelineTarget[]): number { const total = targets.reduce((s, t) => s + t.count, 0); return total * 0.003; // pessimistisk uppskattning per kandidat } // ── 6. DB-dedup-lookup ───────────────────────────────────────────────────── class DbSimilarityLookup implements SimilarityLookup { private knownTitles: Set; private sessionTitles: Set = new Set(); constructor(existingTitles: Set) { this.knownTitles = existingTitles; } async hasSimilarity(title: string): Promise { const key = title.toLowerCase().trim(); if (this.knownTitles.has(key)) return true; if (this.sessionTitles.has(key)) return true; this.sessionTitles.add(key); return false; } } // ── 7. Seed-verified-recept till DB ──────────────────────────────────────── async function seedVerifiedRecipes(results: VerificationResult[]): Promise { let seeded = 0; // Hämta eller skapa source registry för AI const [registry] = await db .insert(schema.recipeSourceRegistry) .values({ sourceName: "Cibello AI", license: "proprietary", rightToStore: true, rightToModify: true, rightToDisplay: true, attributionRequired: false, commercialUse: true, notes: "AI-genererade recept via Gemini. Genomgår verifieringspipeline.", }) .onConflictDoUpdate({ target: schema.recipeSourceRegistry.sourceName, set: { updatedAt: new Date() }, }) .returning(); const sourceRegistryId = registry?.id ?? null; for (const vr of results) { if (vr.status !== "verified" || !vr.nutritionPerPortion) continue; const c = vr.candidate; const slug = slugify(c.titleSv); // Kolla om slug redan finns const existing = await db .select({ id: schema.recipes.id }) .from(schema.recipes) .where(eq(schema.recipes.slug, slug)) .limit(1); if (existing.length > 0) { console.error(`[seed] SKIP: slug ${slug} finns redan i DB`); continue; } const dna = inferDna(vr); const [row] = await db .insert(schema.recipes) .values({ slug, titleSv: c.titleSv, descriptionSv: c.descriptionSv, cuisine: (c.cuisine as any) ?? "swedish", mealTypes: c.mealTypes as any, tags: [], methods: [dna.method], equipment: [], difficulty: "easy", prepTimeMinutes: c.prepTimeMinutes, cookTimeMinutes: c.cookTimeMinutes, totalTimeMinutes: c.prepTimeMinutes + c.cookTimeMinutes, portions: c.portions, nutritionPerPortion: vr.nutritionPerPortion, allergens: vr.allergens as Allergen[], spiceLevel: c.spiceLevel, storageGuidanceSv: c.storageGuidanceSv, mealPrepFriendly: c.mealPrepFriendly, freezerFriendly: c.freezerFriendly, dna, variantType: "standard", status: "draft", verificationStatus: "verified", sourceType: "ai_generated" as any, sourceRegistryId, creatorDisplayName: "Cibello AI", imageUrls: [], }) .returning(); const recipeId = row!.id; await db.insert(schema.recipeIngredients).values( c.ingredients.map((ing, idx) => ({ recipeId, canonicalIngredientId: ing.canonicalIngredientId, displayNameSv: ing.displayNameSv, quantity: ing.quantity, unit: ing.unit as any, note: ing.note, optional: ing.optional, sortOrder: idx, })), ); await db.insert(schema.recipeSteps).values( c.steps.map((step, idx) => ({ recipeId, stepNumber: idx + 1, instructionSv: step.instructionSv, timerSeconds: step.timerSeconds, temperatureC: step.temperatureC, tip: step.tip, })), ); seeded++; } return seeded; } // ── 8. Stickprov ─────────────────────────────────────────────────────────── function saveSamples(results: VerificationResult[], batchIdx: number) { const verified = results.filter((r) => r.status === "verified"); const sampleCount = Math.max(1, Math.round(verified.length * 0.15)); const shuffled = [...verified].sort(() => Math.random() - 0.5); const samples = shuffled.slice(0, sampleCount); const flagged: Array<{ title: string; reason: string }> = []; const markdown = samples .map((vr, idx) => { const c = vr.candidate; const nut = vr.nutritionPerPortion; const flags: string[] = []; if (nut && nut.kcal > 900) flags.push("MYCKET HÖG kcal (>900)"); if (nut && nut.kcal < 150) flags.push("MYCKET LÅG kcal (<150)"); if (c.ingredients.length < 5) flags.push("FÅ ingredienser (<5)"); const edgeAllergens = vr.allergens.filter( (a) => ["peanuts", "tree_nuts", "crustaceans", "molluscs", "sesame", "lupin"].includes(a), ); if (edgeAllergens.length > 0) flags.push(`Kant-allergener: ${edgeAllergens.join(", ")}`); if (flags.length > 0) { flagged.push({ title: c.titleSv, reason: flags.join("; ") }); } return [ `## ${idx + 1}. ${c.titleSv}`, `**Beskrivning:** ${c.descriptionSv}`, `**Tid:** ${c.prepTimeMinutes} min prep + ${c.cookTimeMinutes} min kok = ${c.totalTimeMinutes} min`, `**Portioner:** ${c.portions} | **Krydda:** ${c.spiceLevel}/3`, `**Näring/portion:** ${nut ? `${nut.kcal} kcal, P ${Math.round(nut.proteinG)}g, K ${Math.round(nut.carbsG)}g, F ${Math.round(nut.fatG)}g` : "OKÄND"}`, `**Allergener:** ${vr.allergens.join(", ") || "-"}`, `**Flaggor:** ${flags.join("; ") || "-"}`, `**Ingredienser:**`, ...c.ingredients.map((i) => `- ${i.displayNameSv}: ${i.quantity} ${i.unit}${i.optional ? " (valfri)" : ""}`), `**Steg:**`, ...c.steps.map((s) => `${s.stepNumber}. ${s.instructionSv}${s.temperatureC ? ` (${s.temperatureC}°C)` : ""}${s.timerSeconds ? ` [${Math.round(s.timerSeconds / 60)} min]` : ""}`), `---`, ].join("\n"); }) .join("\n\n"); const header = `# Stickprov – Batch ${batchIdx}\n\n*${new Date().toISOString()}* | ${samples.length} av ${verified.length} verifierade recept\n\n`; const flaggedSection = flagged.length > 0 ? `## ⚠️ Flagga-recept\n\n${flagged.map((f) => `- **${f.title}:** ${f.reason}`).join("\n")}\n\n` : "## Inga avvikelser flaggade\n\n"; writeFileSync(join(REPORT_DIR, `stickprov-batch-${batchIdx}.md`), header + flaggedSection + markdown); return { sampleCount, flaggedCount: flagged.length }; } // ── 9. Kör en batch ──────────────────────────────────────────────────────── async function runBatch(batchIdx: number): Promise<{ batchId: string; candidates: number; verified: number; unverified: number; rejected: number; deduped: number; seeded: number; costUsd: number; }> { const targets = BATCHES[batchIdx]; if (!targets) throw new Error(`Batch ${batchIdx} finns inte (0–${BATCHES.length - 1})`); const budgetState = getBudgetState(); const estimatedCost = estimateCostUsd(targets); console.error(`[scale-batch] === BATCH ${batchIdx} ===`); console.error(`[scale-batch] Mål: ${targets.length} celler, ~${targets.reduce((s, t) => s + t.count, 0)} recept`); console.error(`[scale-batch] Uppskattad kostnad: $${estimatedCost.toFixed(4)}`); console.error(`[scale-batch] Budget kvar: $${(BUDGET_USD - budgetState.spentUsd).toFixed(4)} / $${BUDGET_USD}`); if (budgetState.spentUsd + estimatedCost > PAUSE_THRESHOLD_USD) { console.error(`[scale-batch] BUDGET-PAUS: skulle överskrida 90 % av dagsbudget.`); console.error(`[scale-batch] Avbryter. Återställ budget imorgon eller höj GEMINI_DAILY_BUDGET_USD.`); process.exit(0); } const client = createAamosClient(process.env); const existingTitles = await fetchExistingTitles(); const similarity = new DbSimilarityLookup(existingTitles); console.error(`[scale-batch] Kända titlar i DB: ${existingTitles.size}`); const result = await runPipeline( client, targets, catalog, ingredientLookup, similarity, { maxPrepTimeMinutes: 30, maxCookTimeMinutes: 45, portions: 4 }, ); const costUsd = result.geminiResult.costUsd ?? 0; budgetState.spentUsd += costUsd; saveBudgetState(budgetState); // Räkna dedup const deduped = result.verificationResults.filter((r) => r.reasons.some((reason) => reason.includes("dubblett")), ).length; const verified = result.verificationResults.filter((r) => r.status === "verified"); console.error(`[scale-batch] Gemini: ${result.geminiResult.status}, kostnad: $${costUsd.toFixed(4)}`); console.error(`[scale-batch] Kandidater: ${result.candidates.length}, Verified: ${result.verifiedCount}, Unverified: ${result.unverifiedCount}, Rejected: ${result.rejectedCount}, Deduped: ${deduped}`); if (result.geminiResult.error) { console.error(`[scale-batch] Gemini-fel: ${result.geminiResult.error}`); } // Seed verified till DB let seeded = 0; if (!dryRun && verified.length > 0) { seeded = await seedVerifiedRecipes(verified); console.error(`[scale-batch] Seedade ${seeded} recept till DB`); } else if (dryRun) { console.error(`[scale-batch] DRY RUN – inget seedas`); } // Stickprov const sampleInfo = saveSamples(result.verificationResults, batchIdx); console.error(`[scale-batch] Stickprov: ${sampleInfo.sampleCount} recept, ${sampleInfo.flaggedCount} flaggade`); // Spara batch-rapport const batchReport = { batchId: `scale-${Date.now()}-b${batchIdx}`, batchIndex: batchIdx, generatedAt: new Date().toISOString(), dryRun, targets, geminiStatus: result.geminiResult.status, geminiCostUsd: costUsd, candidatesGenerated: result.candidates.length, verifiedCount: result.verifiedCount, unverifiedCount: result.unverifiedCount, rejectedCount: result.rejectedCount, dedupedCount: deduped, seededCount: seeded, sampleCount: sampleInfo.sampleCount, flaggedCount: sampleInfo.flaggedCount, candidates: result.verificationResults.map((r) => ({ title: r.candidate.titleSv, status: r.status, reasons: r.reasons, allergens: r.allergens, nutritionPerPortion: r.nutritionPerPortion, })), }; writeFileSync( join(REPORT_DIR, `batch-${batchIdx}-report.json`), JSON.stringify(batchReport, null, 2), ); return { batchId: batchReport.batchId, candidates: result.candidates.length, verified: result.verifiedCount, unverified: result.unverifiedCount, rejected: result.rejectedCount, deduped, seeded, costUsd, }; } // ── 10. Huvudloop ────────────────────────────────────────────────────────── async function main() { const indicesToRun = runAll ? BATCHES.map((_, i) => i) : [batchIndex!]; const summary: Array<{ batch: number; candidates: number; verified: number; unverified: number; rejected: number; deduped: number; seeded: number; costUsd: number; }> = []; for (const idx of indicesToRun) { try { const report = await runBatch(idx); summary.push({ batch: idx, ...report }); } catch (err) { console.error(`[scale-batch] Batch ${idx} misslyckades:`, err); summary.push({ batch: idx, candidates: 0, verified: 0, unverified: 0, rejected: 0, deduped: 0, seeded: 0, costUsd: 0, }); } } // Spara slutlig sammanfattning const totalCost = summary.reduce((s, r) => s + r.costUsd, 0); const totalCandidates = summary.reduce((s, r) => s + r.candidates, 0); const totalVerified = summary.reduce((s, r) => s + r.verified, 0); const totalUnverified = summary.reduce((s, r) => s + r.unverified, 0); const totalRejected = summary.reduce((s, r) => s + r.rejected, 0); const totalDeduped = summary.reduce((s, r) => s + r.deduped, 0); const totalSeeded = summary.reduce((s, r) => s + r.seeded, 0); const finalReport = { runId: `scale-run-${Date.now()}`, generatedAt: new Date().toISOString(), dryRun, budgetLimitUsd: BUDGET_USD, totalCostUsd: totalCost, summary: { batchesRun: summary.length, totalCandidates, totalVerified, totalUnverified, totalRejected, totalDeduped, totalSeeded, }, perBatch: summary, }; writeFileSync(join(REPORT_DIR, "final-report.json"), JSON.stringify(finalReport, null, 2)); console.log(JSON.stringify(finalReport, null, 2)); console.error("\n# Scale-batch sammanfattning\n"); console.error(`- Batchar körda: ${summary.length}`); console.error(`- Kandidater genererade: ${totalCandidates}`); console.error(`- Verified: ${totalVerified}`); console.error(`- Unverified: ${totalUnverified}`); console.error(`- Rejected: ${totalRejected}`); console.error(`- Deduped: ${totalDeduped}`); console.error(`- Seedade till DB: ${totalSeeded}`); console.error(`- Total Gemini-kostnad: $${totalCost.toFixed(4)}`); console.error(`- Budget kvar: $${(BUDGET_USD - totalCost).toFixed(4)} / $${BUDGET_USD}`); await pool.end(); } main().catch((err) => { console.error("[scale-batch] Fatal:", err); pool.end().catch(() => {}); process.exit(1); });