diff --git a/packages/database/package.json b/packages/database/package.json index 03de2e7..5fccb13 100644 --- a/packages/database/package.json +++ b/packages/database/package.json @@ -6,6 +6,7 @@ "description": "Drizzle-schema, migrationer, seed och databasklient (separat databas, spec §52)", "exports": { ".": "./src/index.ts", + "./client": "./src/client.ts", "./schema": "./src/schema/index.ts", "./seed": "./src/seed/index.ts" }, diff --git a/packages/recipe-generation/package.json b/packages/recipe-generation/package.json index ca676bf..e66eef9 100644 --- a/packages/recipe-generation/package.json +++ b/packages/recipe-generation/package.json @@ -21,6 +21,7 @@ "@app/nutrition-engine": "workspace:*", "@app/recipe-engine": "workspace:*", "@app/shared-types": "workspace:*", + "dotenv": "^16.4.0", "drizzle-orm": "^0.41.0", "zod": "^4.4.0" }, diff --git a/packages/recipe-generation/scripts/scale-batch.ts b/packages/recipe-generation/scripts/scale-batch.ts new file mode 100644 index 0000000..d59cc90 --- /dev/null +++ b/packages/recipe-generation/scripts/scale-batch.ts @@ -0,0 +1,682 @@ +#!/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); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2349cd0..e101827 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -426,6 +426,9 @@ importers: '@app/shared-types': specifier: workspace:* version: link:../shared-types + dotenv: + specifier: ^16.4.0 + version: 16.6.1 drizzle-orm: specifier: ^0.41.0 version: 0.41.0(@types/pg@8.20.3)(pg@8.22.0) diff --git a/scripts/scale-batch.sh b/scripts/scale-batch.sh new file mode 100755 index 0000000..22243d4 --- /dev/null +++ b/scripts/scale-batch.sh @@ -0,0 +1,10 @@ +#!/bin/bash +# scale-batch.sh – wrapper som sätter IPv4-preferens (workaround för Node fetch-häng på WSL+IPv6) +set -e +cd "$(dirname "$0")/.." +export NODE_OPTIONS="${NODE_OPTIONS:---dns-result-order=ipv4first}" +export AAMOS_MODE=gemini +export GEMINI_MODEL=gemini-2.5-flash +export GEMINI_TIMEOUT_MS=180000 +# GEMINI_API_KEY och GEMINI_DAILY_BUDGET_USD måste sättas utanför +pnpm tsx packages/recipe-generation/scripts/scale-batch.ts "$@"