#!/usr/bin/env tsx /** * STEG 2 – Skala receptkatalogen till ~200 verified AI-recept. * * - Kör batchvis (~25–30 recept per batch). * - Dedup mot hela växande banken (recipes + recipe_similarities). * - Seed verified kandidater i staging DB. * - Dra ~15% slumpat stickprov per batch till C:\Users\Public. * - Rapporterar yield, täckningsmatris, dedup-statistik, kostnad. * * Körning: * GEMINI_API_KEY=$(aws ssm get-parameter --name "/cibello/prod/gemini-api-key" --with-decryption --query Parameter.Value --output text) \ * DATABASE_URL=... \ * pnpm tsx packages/recipe-generation/scripts/scale-batch.ts */ import { createAamosClient } from "@app/ai-contracts"; import { SEED_INGREDIENTS } from "@app/database/seed"; import { createDatabase, closeDatabase, schema } from "@app/database"; import { eq, sql, inArray } from "drizzle-orm"; import { runPipeline, type PipelineTarget, type PipelineIngredient } from "@app/recipe-generation"; import type { CanonicalIngredientLookup, SimilarityLookup } from "@app/recipe-generation"; import type { RecipeCandidate } from "@app/recipe-generation"; import { BRAND } from "@app/shared-types"; import { randomUUID } from "node:crypto"; import * as fs from "node:fs/promises"; import * as path from "node:path"; // ── 1. Bygg katalog och lookups ──────────────────────────────────────────── 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, }; }, }; // ── 2. Täckningsmatris (mål ~220 rå-kandidater → ~200 verified) ───────────── const BASE_TARGETS: PipelineTarget[] = [ // Mejeri { mealType: "dinner", mainIngredientId: "milk_3", dietVariant: "standard", count: 3 }, { mealType: "dinner", mainIngredientId: "cream", dietVariant: "standard", count: 3 }, { mealType: "dinner", mainIngredientId: "creme_fraiche", dietVariant: "standard", count: 3 }, { mealType: "dinner", mainIngredientId: "milk_3", dietVariant: "vegetarian", count: 2 }, { mealType: "dinner", mainIngredientId: "cream", dietVariant: "vegetarian", count: 2 }, { mealType: "dinner", mainIngredientId: "creme_fraiche", dietVariant: "gluten_free", count: 2 }, { mealType: "dinner", mainIngredientId: "milk_3", dietVariant: "lactose_free", count: 2 }, // Kyckling { mealType: "dinner", mainIngredientId: "chicken_breast", dietVariant: "standard", count: 5 }, { mealType: "dinner", mainIngredientId: "chicken_thigh", dietVariant: "standard", count: 4 }, { mealType: "dinner", mainIngredientId: "chicken_breast", dietVariant: "gluten_free", count: 3 }, { mealType: "dinner", mainIngredientId: "chicken_breast", dietVariant: "lactose_free", count: 3 }, // Köttfärs { mealType: "dinner", mainIngredientId: "minced_beef", dietVariant: "standard", count: 5 }, { mealType: "dinner", mainIngredientId: "minced_mixed", dietVariant: "standard", count: 4 }, { mealType: "dinner", mainIngredientId: "minced_beef", dietVariant: "gluten_free", count: 3 }, { mealType: "dinner", mainIngredientId: "minced_beef", dietVariant: "lactose_free", count: 3 }, // Fisk { mealType: "dinner", mainIngredientId: "salmon", dietVariant: "standard", count: 5 }, { mealType: "dinner", mainIngredientId: "cod", dietVariant: "standard", count: 4 }, { mealType: "dinner", mainIngredientId: "shrimp", dietVariant: "standard", count: 4 }, { mealType: "dinner", mainIngredientId: "salmon", dietVariant: "gluten_free", count: 3 }, { mealType: "dinner", mainIngredientId: "salmon", dietVariant: "lactose_free", count: 3 }, // Pasta/ris { mealType: "dinner", mainIngredientId: "pasta_dry", dietVariant: "standard", count: 5 }, { mealType: "dinner", mainIngredientId: "rice_white", dietVariant: "standard", count: 5 }, { mealType: "dinner", mainIngredientId: "pasta_dry", dietVariant: "vegetarian", count: 3 }, { mealType: "dinner", mainIngredientId: "rice_white", dietVariant: "vegan", count: 3 }, { mealType: "dinner", mainIngredientId: "rice_white", dietVariant: "gluten_free", count: 3 }, // Potatis { mealType: "dinner", mainIngredientId: "potato", dietVariant: "standard", count: 5 }, { mealType: "dinner", mainIngredientId: "potato", dietVariant: "vegetarian", count: 3 }, { mealType: "dinner", mainIngredientId: "potato", dietVariant: "vegan", count: 3 }, { mealType: "dinner", mainIngredientId: "potato", dietVariant: "gluten_free", count: 3 }, // Baljväxter { mealType: "dinner", mainIngredientId: "tofu", dietVariant: "vegan", count: 4 }, { mealType: "dinner", mainIngredientId: "tofu", dietVariant: "vegetarian", count: 3 }, { mealType: "dinner", mainIngredientId: "red_lentils", dietVariant: "vegan", count: 4 }, { mealType: "dinner", mainIngredientId: "red_lentils", dietVariant: "vegetarian", count: 3 }, { mealType: "dinner", mainIngredientId: "chickpeas_canned", dietVariant: "vegan", count: 4 }, { mealType: "dinner", mainIngredientId: "black_beans_canned", dietVariant: "vegan", count: 3 }, // Veckans grönsaker { mealType: "dinner", mainIngredientId: "tomato", dietVariant: "standard", count: 3 }, { mealType: "dinner", mainIngredientId: "tomato", dietVariant: "vegetarian", count: 2 }, { mealType: "dinner", mainIngredientId: "tomato", dietVariant: "vegan", count: 2 }, { mealType: "dinner", mainIngredientId: "zucchini", dietVariant: "standard", count: 3 }, { mealType: "dinner", mainIngredientId: "zucchini", dietVariant: "vegan", count: 2 }, { mealType: "dinner", mainIngredientId: "paprika", dietVariant: "standard", count: 3 }, { mealType: "dinner", mainIngredientId: "paprika", dietVariant: "vegan", count: 2 }, { mealType: "dinner", mainIngredientId: "carrot", dietVariant: "standard", count: 3 }, { mealType: "dinner", mainIngredientId: "carrot", dietVariant: "vegetarian", count: 2 }, { mealType: "dinner", mainIngredientId: "spinach", dietVariant: "standard", count: 3 }, { mealType: "dinner", mainIngredientId: "spinach", dietVariant: "vegan", count: 2 }, ]; const GROUP_MAIN_IDS: Record = { "1": ["milk_3", "cream", "creme_fraiche", "chicken_breast", "chicken_thigh"], "2": ["minced_beef", "minced_mixed", "salmon", "cod", "shrimp"], "3": [ "pasta_dry", "rice_white", "potato", "tofu", "red_lentils", "chickpeas_canned", "black_beans_canned", "tomato", "zucchini", "paprika", "carrot", "spinach", ], }; // ── 2b. RUNDA 2: gap-fokuserade targets (~80 nya mål) ────────────────────── const RUNDA_2_TARGETS: PipelineTarget[] = [ // Paprika-gap (0 st) – variera kök { mealType: "dinner", mainIngredientId: "paprika", dietVariant: "standard", count: 4 }, { mealType: "dinner", mainIngredientId: "paprika", dietVariant: "vegetarian", count: 3 }, { mealType: "dinner", mainIngredientId: "paprika", dietVariant: "vegan", count: 3 }, { mealType: "dinner", mainIngredientId: "paprika", dietVariant: "gluten_free", count: 2 }, // Blandfärs-gap (2 st) { mealType: "dinner", mainIngredientId: "minced_mixed", dietVariant: "standard", count: 5 }, { mealType: "dinner", mainIngredientId: "minced_mixed", dietVariant: "gluten_free", count: 3 }, { mealType: "dinner", mainIngredientId: "minced_mixed", dietVariant: "lactose_free", count: 3 }, // Räkor-gap (3 st) { mealType: "dinner", mainIngredientId: "shrimp", dietVariant: "standard", count: 5 }, { mealType: "dinner", mainIngredientId: "shrimp", dietVariant: "gluten_free", count: 3 }, { mealType: "dinner", mainIngredientId: "shrimp", dietVariant: "lactose_free", count: 3 }, // Torsk-gap (4 st) { mealType: "dinner", mainIngredientId: "cod", dietVariant: "standard", count: 5 }, { mealType: "dinner", mainIngredientId: "cod", dietVariant: "gluten_free", count: 3 }, { mealType: "dinner", mainIngredientId: "cod", dietVariant: "lactose_free", count: 3 }, // Svarta bönor-gap (4 st) { mealType: "dinner", mainIngredientId: "black_beans_canned", dietVariant: "vegan", count: 4 }, { mealType: "dinner", mainIngredientId: "black_beans_canned", dietVariant: "vegetarian", count: 3, }, { mealType: "dinner", mainIngredientId: "black_beans_canned", dietVariant: "gluten_free", count: 2, }, // Kycklinglår (6 st) – fler varianter { mealType: "dinner", mainIngredientId: "chicken_thigh", dietVariant: "standard", count: 4 }, { mealType: "dinner", mainIngredientId: "chicken_thigh", dietVariant: "gluten_free", count: 3 }, { mealType: "dinner", mainIngredientId: "chicken_thigh", dietVariant: "lactose_free", count: 3 }, // Tomat/zucchini (6 st vardera) – fler veg-varianter { mealType: "dinner", mainIngredientId: "tomato", dietVariant: "vegan", count: 4 }, { mealType: "dinner", mainIngredientId: "tomato", dietVariant: "vegetarian", count: 3 }, { mealType: "dinner", mainIngredientId: "tomato", dietVariant: "standard", count: 3 }, { mealType: "dinner", mainIngredientId: "zucchini", dietVariant: "vegan", count: 4 }, { mealType: "dinner", mainIngredientId: "zucchini", dietVariant: "vegetarian", count: 3 }, { mealType: "dinner", mainIngredientId: "zucchini", dietVariant: "standard", count: 3 }, // Tofu (8 st) – fler asiatiska varianter { mealType: "dinner", mainIngredientId: "tofu", dietVariant: "vegan", count: 4 }, { mealType: "dinner", mainIngredientId: "tofu", dietVariant: "vegetarian", count: 3 }, { mealType: "dinner", mainIngredientId: "tofu", dietVariant: "gluten_free", count: 2 }, // Morot/spenat – lågt täckta { mealType: "dinner", mainIngredientId: "carrot", dietVariant: "vegetarian", count: 3 }, { mealType: "dinner", mainIngredientId: "carrot", dietVariant: "vegan", count: 3 }, { mealType: "dinner", mainIngredientId: "spinach", dietVariant: "standard", count: 3 }, { mealType: "dinner", mainIngredientId: "spinach", dietVariant: "vegetarian", count: 3 }, { mealType: "dinner", mainIngredientId: "spinach", dietVariant: "vegan", count: 2 }, ]; const targetGroup = process.env.TARGET_GROUP; const isRunda2 = process.env.RUNDA === "2"; const SOURCE_TARGETS = isRunda2 ? RUNDA_2_TARGETS : BASE_TARGETS; const ACTIVE_TARGETS = targetGroup && GROUP_MAIN_IDS[targetGroup] ? SOURCE_TARGETS.filter((t) => GROUP_MAIN_IDS[targetGroup]!.includes(t.mainIngredientId)) : SOURCE_TARGETS; const TOTAL_TARGETS = ACTIVE_TARGETS.reduce((s, t) => s + t.count, 0); const BATCH_SIZE = 5; // ── 3. Hjälpfunktioner ───────────────────────────────────────────────────── const VALID_CUISINES = new Set([ "swedish", "nordic", "italian", "french", "spanish", "greek", "thai", "chinese", "japanese", "korean", "vietnamese", "indian", "mexican", "american", "turkish", "lebanese", "moroccan", "middle_eastern", "international", ]); function normalizeCuisine(raw: string | undefined): string { const c = (raw ?? "swedish").toLowerCase().replace(/[^a-z]/g, "_"); if (VALID_CUISINES.has(c)) return c; if (c === "asian") return "international"; if (c.includes("swedish") || c.includes("nordic")) return "swedish"; if (c.includes("italian")) return "italian"; if (c.includes("asian")) return "international"; if (c.includes("japan")) return "japanese"; if (c.includes("china")) return "chinese"; if (c.includes("thai")) return "thai"; if (c.includes("indian")) return "indian"; if (c.includes("mexican")) return "mexican"; if (c.includes("american")) return "american"; if (c.includes("mediterranean")) return "greek"; return "international"; } function toSlug(title: string): string { return title .toLowerCase() .normalize("NFD") .replace(/[\u0300-\u036f]/g, "") .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, ""); } function normalizeTitle(title: string): string { return title.toLowerCase().replace(/[^a-z0-9åäö]/g, " "); } function shuffle(arr: T[]): T[] { const a = [...arr]; for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; } function formatRecipeMarkdown(r: SeededRecipe, idx: number): string { const lines: string[] = []; lines.push(`## ${idx + 1}. ${r.titleSv}`); lines.push(`**Status:** ${r.status} | **Kök:** ${r.cuisine} | **Portioner:** ${r.portions}`); lines.push( `**Tid:** ${r.prepTimeMinutes} min prep + ${r.cookTimeMinutes} min kok = ${r.totalTimeMinutes} min`, ); lines.push(`**Allergener:** ${r.allergens.join(", ") || "inget"}`); lines.push(`**Näring/portion:** ${r.nutritionText}`); lines.push(""); lines.push(r.descriptionSv); lines.push(""); lines.push("### Ingredienser"); for (const ing of r.ingredients) { lines.push( `- ${ing.displayNameSv}: ${ing.quantity} ${ing.unit}${ing.optional ? " (valfri)" : ""}`, ); } lines.push(""); lines.push("### Steg"); for (const s of r.steps) { lines.push( `${s.stepNumber}. ${s.instructionSv}${s.temperatureC ? ` (${s.temperatureC}°C)` : ""}`, ); } if (r.flagReasons.length) { lines.push(""); lines.push(`**Flaggat:** ${r.flagReasons.join("; ")}`); } lines.push(""); return lines.join("\n"); } interface SeededRecipe { id: string; titleSv: string; slug: string; descriptionSv: string; cuisine: string; status: string; portions: number; prepTimeMinutes: number; cookTimeMinutes: number; totalTimeMinutes: number; allergens: string[]; nutritionText: string; ingredients: { displayNameSv: string; quantity: number; unit: string; optional: boolean; canonicalIngredientId: string; }[]; steps: { stepNumber: number; instructionSv: string; temperatureC: number | null }[]; flagReasons: string[]; } interface BatchResult { batchId: string; targets: PipelineTarget[]; generated: number; verified: number; unverified: number; rejected: number; duplicate: number; seeded: number; costUsd: number; sample: SeededRecipe[]; } // ── 4. Huvudflöde ────────────────────────────────────────────────────────── async function main() { const dbUrl = process.env.DATABASE_URL; if (!dbUrl) { console.error("[scale-batch] DATABASE_URL saknas"); process.exit(1); } const { db, pool } = createDatabase(dbUrl); const client = createAamosClient(process.env); console.error(`[scale-batch] Startar STEG 2: ${TOTAL_TARGETS} mål, batchar om ~${BATCH_SIZE}`); console.error(`[scale-batch] Katalog: ${catalog.length} ingredienser`); // Ladda befintliga titlar för dedup const existingRecipes = await db.query.recipes.findMany({ columns: { id: true, titleSv: true }, }); const knownTitles = new Set(existingRecipes.map((r) => normalizeTitle(r.titleSv))); const knownSlugs = new Set(existingRecipes.map((r) => r.slug)); console.error(`[scale-batch] Befintliga recept i DB: ${existingRecipes.length}`); // Ladda befintliga similarities (vi kommer bara skriva nya, men bra att ha om vi vill kolla) // För närvarande räcker titel-dedup för STEG 2. const similarityLookup: SimilarityLookup = { async hasSimilarity(title: string) { return knownTitles.has(normalizeTitle(title)); }, }; // Dela upp targets i batchar const batches: PipelineTarget[][] = []; let currentBatch: PipelineTarget[] = []; let currentCount = 0; for (const t of ACTIVE_TARGETS) { if (currentCount + t.count > BATCH_SIZE && currentBatch.length > 0) { batches.push(currentBatch); currentBatch = []; currentCount = 0; } currentBatch.push(t); currentCount += t.count; } if (currentBatch.length) batches.push(currentBatch); console.error(`[scale-batch] ${batches.length} batchar planerade`); const batchResults: BatchResult[] = []; const allSeeded: SeededRecipe[] = []; let totalCost = 0; let totalGenerated = 0; let totalVerified = 0; let totalUnverified = 0; let totalRejected = 0; let totalDuplicate = 0; let totalSeeded = 0; for (let batchIdx = 0; batchIdx < batches.length; batchIdx++) { const batchTargets = batches[batchIdx]!; const batchId = `steg2-batch-${batchIdx + 1}-${Date.now()}`; console.error( `\n[scale-batch] Batch ${batchIdx + 1}/${batches.length} (${batchTargets.reduce((s, t) => s + t.count, 0)} recept)`, ); const result = await runPipeline( client, batchTargets, catalog, ingredientLookup, similarityLookup, { maxPrepTimeMinutes: 30, maxCookTimeMinutes: 45, portions: 4 }, ); totalGenerated += result.candidates.length; totalVerified += result.verifiedCount; totalUnverified += result.unverifiedCount; totalRejected += result.rejectedCount; totalCost += result.geminiResult.costUsd ?? 0; if (result.geminiResult.status !== "ok" || result.candidates.length === 0) { console.error( `[scale-batch] Batch ${batchIdx + 1} gav inga kandidater: ${result.geminiResult.error ?? "ok utan output"}`, ); batchResults.push({ batchId, targets: batchTargets, generated: 0, verified: 0, unverified: 0, rejected: 0, duplicate: 0, seeded: 0, costUsd: result.geminiResult.costUsd ?? 0, sample: [], }); continue; } // Seed verified kandidater som inte är dubbletter let batchDuplicate = 0; let batchSeeded = 0; const batchSeededRecipes: SeededRecipe[] = []; for (let i = 0; i < result.candidates.length; i++) { const candidate = result.candidates[i]!; const v = result.verificationResults[i]!; if (v.status !== "verified") continue; const titleNorm = normalizeTitle(candidate.titleSv); if (knownTitles.has(titleNorm)) { batchDuplicate++; continue; } let slug = toSlug(candidate.titleSv); if (knownSlugs.has(slug)) { slug = `${slug}-${randomUUID().slice(0, 8)}`; } knownSlugs.add(slug); knownTitles.add(titleNorm); const recipeId = randomUUID(); const nutrition = v.nutritionPerPortion ?? { kcal: 0, proteinG: 0, carbsG: 0, fatG: 0, saturatedFatG: 0, fiberG: 0, sugarG: 0, saltG: 0, }; try { await db.insert(schema.recipes).values({ id: recipeId, slug, titleSv: candidate.titleSv, descriptionSv: candidate.descriptionSv, cuisine: normalizeCuisine(candidate.cuisine), mealTypes: candidate.mealTypes, tags: [], methods: [], equipment: [], prepTimeMinutes: candidate.prepTimeMinutes, cookTimeMinutes: candidate.cookTimeMinutes, totalTimeMinutes: candidate.totalTimeMinutes, portions: candidate.portions, nutritionPerPortion: nutrition, allergens: v.allergens, spiceLevel: candidate.spiceLevel, storageGuidanceSv: candidate.storageGuidanceSv ?? null, mealPrepFriendly: candidate.mealPrepFriendly, freezerFriendly: candidate.freezerFriendly, dna: { cuisine: normalizeCuisine(candidate.cuisine) as "swedish", protein: candidate.ingredients.find((i) => { const ing = ingredientLookup.getById(i.canonicalIngredientId); return ( ing && (ing.isBeef || ing.isPork || [ "chicken_breast", "chicken_thigh", "salmon", "cod", "shrimp", "tofu", "red_lentils", "chickpeas_canned", "black_beans_canned", "minced_beef", "minced_mixed", ].includes(i.canonicalIngredientId)) ); })?.canonicalIngredientId, carbohydrate: candidate.ingredients.find((i) => ["rice_white", "pasta_dry", "potato"].includes(i.canonicalIngredientId), )?.canonicalIngredientId, vegetables: candidate.ingredients .filter((i) => { const ing = ingredientLookup.getById(i.canonicalIngredientId); return ( ing?.category === "gronsaker" || [ "tomato", "zucchini", "paprika", "carrot", "spinach", "onion", "garlic", ].includes(i.canonicalIngredientId) ); }) .map((i) => i.canonicalIngredientId), flavorProfile: inferDietTags(candidate), spiceLevel: candidate.spiceLevel, method: "stovetop", timeMinutes: candidate.totalTimeMinutes, calories: Math.round(nutrition.kcal), proteinGrams: Math.round(nutrition.proteinG), }, variantType: "standard", status: "draft", verificationStatus: "verified", sourceType: "ai_assisted_reviewed", creatorDisplayName: `${BRAND.name} AI`, }); } catch (err: any) { if (err?.message?.includes("recipes_slug_unique") || err?.code === "23505") { batchDuplicate++; continue; } throw err; } for (let ingIdx = 0; ingIdx < candidate.ingredients.length; ingIdx++) { const ing = candidate.ingredients[ingIdx]!; await db.insert(schema.recipeIngredients).values({ recipeId, canonicalIngredientId: ing.canonicalIngredientId, displayNameSv: ing.displayNameSv, quantity: ing.quantity, unit: ing.unit, note: ing.note ?? null, optional: ing.optional, sortOrder: ingIdx, }); } for (let stepIdx = 0; stepIdx < candidate.steps.length; stepIdx++) { const step = candidate.steps[stepIdx]!; await db.insert(schema.recipeSteps).values({ recipeId, stepNumber: step.stepNumber, instructionSv: step.instructionSv, timerSeconds: step.timerSeconds, temperatureC: step.temperatureC, tip: step.tip ?? null, }); } const flagReasons = flagRecipe(candidate, nutrition); const seededRecipe: SeededRecipe = { id: recipeId, titleSv: candidate.titleSv, slug, descriptionSv: candidate.descriptionSv, cuisine: normalizeCuisine(candidate.cuisine), status: "verified", portions: candidate.portions, prepTimeMinutes: candidate.prepTimeMinutes, cookTimeMinutes: candidate.cookTimeMinutes, totalTimeMinutes: candidate.totalTimeMinutes, allergens: v.allergens, nutritionText: `${Math.round(nutrition.kcal)} kcal, P ${nutrition.proteinG.toFixed(1)}g, K ${nutrition.carbsG.toFixed(1)}g, F ${nutrition.fatG.toFixed(1)}g`, ingredients: candidate.ingredients.map((ing) => ({ displayNameSv: ing.displayNameSv, quantity: ing.quantity, unit: ing.unit, optional: ing.optional, canonicalIngredientId: ing.canonicalIngredientId, })), steps: candidate.steps.map((s) => ({ stepNumber: s.stepNumber, instructionSv: s.instructionSv, temperatureC: s.temperatureC, })), flagReasons, }; batchSeededRecipes.push(seededRecipe); allSeeded.push(seededRecipe); batchSeeded++; } totalDuplicate += batchDuplicate; totalSeeded += batchSeeded; // Stickprov: ~15% slumpat av denna batchs seeded recept const sampleSize = Math.max(1, Math.round(batchSeededRecipes.length * 0.15)); const sample = shuffle(batchSeededRecipes).slice(0, sampleSize); batchResults.push({ batchId, targets: batchTargets, generated: result.candidates.length, verified: result.verifiedCount, unverified: result.unverifiedCount, rejected: result.rejectedCount, duplicate: batchDuplicate, seeded: batchSeeded, costUsd: result.geminiResult.costUsd ?? 0, sample, }); // Logga varför unverified recept misslyckades (för debugging / prompt-förbättring) for (let i = 0; i < result.candidates.length; i++) { const v = result.verificationResults[i]!; if (v.status === "unverified" && v.reasons.length > 0) { console.error( `[scale-batch] unverified: "${result.candidates[i]!.titleSv}" => ${v.reasons.join("; ")}`, ); } } console.error( `[scale-batch] Batch ${batchIdx + 1} klar: gen=${result.candidates.length}, ver=${result.verifiedCount}, unv=${result.unverifiedCount}, rej=${result.rejectedCount}, dup=${batchDuplicate}, seeded=${batchSeeded}, cost=$${(result.geminiResult.costUsd ?? 0).toFixed(4)}`, ); } // ── 5. Spara stickprov ──────────────────────────────────────────────────── const publicDir = "/mnt/c/Users/Public"; const sampleRecipes = shuffle(allSeeded).slice( 0, Math.max(1, Math.round(allSeeded.length * 0.15)), ); const sampleMarkdown = [ `# ${BRAND.name} STEG 2 – Stickprov för smakkoll`, `Genererad: ${new Date().toISOString()}`, `Totalt seedade: ${allSeeded.length}`, `Stickprov: ${sampleRecipes.length}`, "", ...sampleRecipes.map((r, i) => formatRecipeMarkdown(r, i)), ].join("\n"); await fs.writeFile( path.join(publicDir, `${BRAND.slug}-steg2-sample.md`), sampleMarkdown, "utf-8", ); // ── 6. Slutrapport ──────────────────────────────────────────────────────── const report = { runId: `steg2-${Date.now()}`, generatedAt: new Date().toISOString(), totalTargets: TOTAL_TARGETS, batches: batchResults.length, totalGenerated, totalVerified, totalUnverified, totalRejected, totalDuplicate, totalSeeded, totalCostUsd: totalCost, batchSummary: batchResults.map((b) => ({ batchId: b.batchId, targets: b.targets.reduce((s, t) => s + t.count, 0), generated: b.generated, verified: b.verified, unverified: b.unverified, rejected: b.rejected, duplicate: b.duplicate, seeded: b.seeded, costUsd: b.costUsd, sampleSize: b.sample.length, })), coverageMatrix: buildCoverageMatrix(allSeeded), }; await fs.writeFile( path.join(publicDir, "cibello-steg2-report.json"), JSON.stringify(report, null, 2), "utf-8", ); // Skriv också till workspace för enkel åtkomst await fs.writeFile( "/home/dator_ubuntujpb/.openclaw/workspace/cibello-steg2-report.json", JSON.stringify(report, null, 2), "utf-8", ); console.error("\n# STEG 2 – Slutrapport\n"); console.error(`- Batchar: ${batchResults.length}`); console.error(`- Genererade kandidater: ${totalGenerated}`); console.error(`- Verified: ${totalVerified}`); console.error(`- Unverified: ${totalUnverified}`); console.error(`- Rejected: ${totalRejected}`); console.error(`- Dubbletter: ${totalDuplicate}`); console.error(`- Seedade i DB: ${totalSeeded}`); console.error(`- Total Gemini-kostnad: $${totalCost.toFixed(4)}`); console.error( `- Stickprov: ${sampleRecipes.length} recept → ${publicDir}\\cibello-steg2-sample.md`, ); console.error(`- Rapport: ${publicDir}\\cibello-steg2-report.json`); await closeDatabase(); } function inferDietTags(candidate: RecipeCandidate): string[] { const tags: string[] = []; const ingIds = new Set(candidate.ingredients.map((i) => i.canonicalIngredientId)); const ings = candidate.ingredients .map((i) => ingredientLookup.getById(i.canonicalIngredientId)) .filter(Boolean); const allVegan = ings.every((i) => i?.isVegan); const allVegetarian = ings.every((i) => i?.isVegetarian); const noGluten = ings.every((i) => !i?.containsGluten); const noLactose = ings.every((i) => !i?.containsLactose); if (allVegan) tags.push("vegan"); else if (allVegetarian) tags.push("vegetarian"); if (noGluten) tags.push("gluten_free"); if (noLactose) tags.push("lactose_free"); return tags; } function flagRecipe(candidate: RecipeCandidate, nutrition: { kcal: number }): string[] { const reasons: string[] = []; if (nutrition.kcal > 1000) reasons.push("hög kalorihalt"); if (nutrition.kcal < 150) reasons.push("låg kalorihalt"); if (candidate.ingredients.length < 4) reasons.push("få ingredienser"); const riskyAllergens = [ "peanuts", "tree_nuts", "shellfish", "crustaceans", "fish", "milk", "gluten", "eggs", ]; const ingIds = new Set(candidate.ingredients.map((i) => i.canonicalIngredientId)); for (const id of ingIds) { const ing = ingredientLookup.getById(id); if (ing?.allergens.some((a) => riskyAllergens.includes(a))) { reasons.push("kant-allergener"); break; } } return reasons; } function buildCoverageMatrix(seeded: SeededRecipe[]) { const mainIngredientIds = new Set(ACTIVE_TARGETS.map((t) => t.mainIngredientId)); const matrix: Record> = {}; for (const r of seeded) { // Hitta den mest sannolika huvudingrediensen: första ingrediensen som är en target-huvudingrediens const mainId = r.ingredients.find((i) => mainIngredientIds.has(i.canonicalIngredientId), )?.canonicalIngredientId; if (!mainId) continue; // Härled dietvariant från ingredienserna const ings = r.ingredients .map((i) => ingredientLookup.getById(i.canonicalIngredientId)) .filter(Boolean); const isVegan = ings.every((i) => i?.isVegan); const isVegetarian = ings.every((i) => i?.isVegetarian); const isGlutenFree = ings.every((i) => !i?.containsGluten); const isLactoseFree = ings.every((i) => !i?.containsLactose); let diet = "standard"; if (isVegan) diet = "vegan"; else if (isVegetarian) diet = "vegetarian"; if (isGlutenFree && !isVegan && !isVegetarian) diet = "gluten_free"; if (isLactoseFree && !isVegan && !isVegetarian && !isGlutenFree) diet = "lactose_free"; matrix[mainId] ??= {}; matrix[mainId]![diet] = (matrix[mainId]![diet] ?? 0) + 1; } return matrix; } main().catch((err) => { console.error("[scale-batch] Fatal:", err); process.exit(1); });