#!/usr/bin/env tsx /** * PILOTBATCH – Receptkatalog Fas A (docs/32). * * Genererar ~20–30 recept över en skiva av matrisen: * middag × mejeri/kyckling/köttfärs × allätare+veg * * Använder Gemini i staging/dev (AAMOS_MODE=gemini) eller mock (AAMOS_MODE=mock). * Ingen påfyllning i prod. Resultatet skrivs till stdout som JSON + markdown. * * Körning: * AAMOS_MODE=mock pnpm tsx packages/recipe-generation/scripts/pilot-batch.ts * AAMOS_MODE=gemini GEMINI_API_KEY=... pnpm tsx packages/recipe-generation/scripts/pilot-batch.ts */ import { createAamosClient } from "@app/ai-contracts"; import { SEED_INGREDIENTS } from "@app/database/seed"; import { runPipeline, type PipelineTarget, type PipelineIngredient } from "@app/recipe-generation"; import type { CanonicalIngredientLookup, SimilarityLookup } from "@app/recipe-generation"; // ── 1. Bygg katalog från seed-data ───────────────────────────────────────── 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, })); // ── 2. Bygg lookup för verifiering ───────────────────────────────────────── 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. Dedup-lookup (enkel, baserad på titel) ────────────────────────────── const knownTitles = new Set(); const similarityLookup: SimilarityLookup = { async hasSimilarity(title: string) { if (knownTitles.has(title.toLowerCase())) return true; knownTitles.add(title.toLowerCase()); return false; }, }; // ── 4. Definiera pilot-matris ────────────────────────────────────────────── const targets: PipelineTarget[] = [ // Mejeri × allätare { mealType: "dinner", mainIngredientId: "milk_3", dietVariant: "standard", count: 3 }, { mealType: "dinner", mainIngredientId: "cream", dietVariant: "standard", count: 2 }, // Kyckling × allätare { mealType: "dinner", mainIngredientId: "chicken_breast", dietVariant: "standard", count: 4 }, { mealType: "dinner", mainIngredientId: "chicken_thigh", dietVariant: "standard", count: 2 }, // Köttfärs × allätare { mealType: "dinner", mainIngredientId: "minced_beef", dietVariant: "standard", count: 3 }, { mealType: "dinner", mainIngredientId: "minced_mixed", dietVariant: "standard", count: 2 }, // Veg-varianter { mealType: "dinner", mainIngredientId: "tofu", dietVariant: "vegan", count: 3 }, { mealType: "dinner", mainIngredientId: "red_lentils", dietVariant: "vegetarian", count: 2 }, { mealType: "dinner", mainIngredientId: "chickpeas_canned", dietVariant: "vegan", count: 2 }, ]; // ── 5. Kör pipelinen ─────────────────────────────────────────────────────── async function main() { const client = createAamosClient(process.env); console.error("[pilot-batch] Startar generering..."); console.error(`[pilot-batch] Katalog: ${catalog.length} ingredienser`); console.error(`[pilot-batch] Mål: ${targets.length} matris-celler, ~${targets.reduce((s, t) => s + t.count, 0)} recept`); const result = await runPipeline( client, targets, catalog, ingredientLookup, similarityLookup, { maxPrepTimeMinutes: 30, maxCookTimeMinutes: 45, portions: 4 }, ); // ── 6. Rapportera ──────────────────────────────────────────────────────── const report = { batchId: `pilot-${Date.now()}`, generatedAt: new Date().toISOString(), targetMatrix: targets, geminiStatus: result.geminiResult.status, geminiCostUsd: result.geminiResult.costUsd ?? 0, candidatesGenerated: result.candidates.length, verifiedCount: result.verifiedCount, unverifiedCount: result.unverifiedCount, rejectedCount: result.rejectedCount, candidates: result.verificationResults.map((r) => ({ title: r.candidate.titleSv, status: r.status, reasons: r.reasons, allergens: r.allergens, nutritionPerPortion: r.nutritionPerPortion, ingredients: r.candidate.ingredients.map((i) => i.canonicalIngredientId), })), }; console.log(JSON.stringify(report, null, 2)); // Markdown-sammanfattning till stderr console.error("\n# Pilotbatch-sammanfattning\n"); console.error(`- Genererade kandidater: ${result.candidates.length}`); console.error(`- Verified: ${result.verifiedCount}`); console.error(`- Unverified: ${result.unverifiedCount}`); console.error(`- Rejected: ${result.rejectedCount}`); console.error(`- Gemini-kostnad: $${(result.geminiResult.costUsd ?? 0).toFixed(4)}`); if (result.geminiResult.error) { console.error(`\n**Fel:** ${result.geminiResult.error}`); } if (result.candidates.length === 0 && result.geminiResult.status === "ok") { console.error("\n**Notering:** Gemini returnerade ok men inga kandidater. " + "Mock-klienten stödjer inte GENERATE_RECIPE_CANDIDATES ännu — " + "kör med AAMOS_MODE=gemini för live-generering."); } } main().catch((err) => { console.error("[pilot-batch] Fatal:", err); process.exit(1); });