#!/usr/bin/env tsx /** * Gap-fill för fyra nollceller (docs/32 Spår B): * fläskytterfilé, banan, naturell yoghurt, jordnötssmör * * - Max 3 recept per huvudingrediens (~12 totalt). * - Använder befintlig runPipeline + verifyCandidate. * - Dedup mot befintliga 246 recept (titel + slug). * - Skriver endast sammanfattning till stdout; full export + rejects till filer. */ import { createAamosClient } from "@app/ai-contracts"; import { SEED_INGREDIENTS, SEED_RECIPES } from "@app/database/seed"; import { runPipeline, type PipelineTarget, type PipelineIngredient } from "@app/recipe-generation"; import type { CanonicalIngredientLookup, SimilarityLookup } from "@app/recipe-generation"; import type { SeedRecipe } from "@app/database/seed/data/recipes.js"; import { BRAND } from "@app/shared-types"; import * as fs from "node:fs/promises"; import * as path from "node:path"; const OUTPUT_DIR = path.resolve( import.meta.dirname ?? "..", "..", "output", ); const NULLCELL_TARGETS: PipelineTarget[] = [ { mealType: "dinner", mainIngredientId: "pork_loin", dietVariant: "standard", count: 3 }, { mealType: "breakfast", mainIngredientId: "banana", dietVariant: "standard", count: 3 }, { mealType: "breakfast", mainIngredientId: "yoghurt_natural", dietVariant: "standard", count: 3 }, { mealType: "snack", mainIngredientId: "peanut_butter", dietVariant: "standard", count: 3 }, ]; function normalizeForSlug(title: string): string { return title .toLowerCase() .replace(/[åä]/g, "a") .replace(/ö/g, "o") .replace(/é/g, "e") .replace(/[^a-z0-9]+/g, "-") .replace(/^-|-$/g, "") .replace(/-+/g, "-"); } function makeUniqueSlug(title: string, existingSlugs: Set): string { let slug = normalizeForSlug(title); if (!slug) slug = "recept"; let candidate = slug; let suffix = 2; while (existingSlugs.has(candidate)) { candidate = `${slug}-${suffix}`; suffix++; } return candidate; } function deriveTags(mealType: string, mainId: string): string[] { const tags = new Set(["quick"]); if (mealType === "breakfast" || mealType === "snack") tags.add("kid_friendly"); if (mainId === "pork_loin") tags.add("high_protein"); if (mainId === "peanut_butter") tags.add("high_protein"); return Array.from(tags); } function deriveMethods(mainId: string): string[] { if (mainId === "banana" || mainId === "yoghurt_natural") return ["no_cook"]; if (mainId === "peanut_butter") return ["no_cook"]; return ["stovetop"]; } function deriveEquipment(mainId: string): string[] { if (mainId === "banana" || mainId === "yoghurt_natural" || mainId === "peanut_butter") { return []; } return ["stove"]; } function guessDna(mainId: string, ingredientIds: string[]) { const vegetables = ingredientIds.filter((id) => ["tomato", "spinach", "zucchini", "paprika", "carrot", "cucumber", "lettuce", "corn", "onion", "garlic"].includes(id), ); const carbs = ingredientIds.filter((id) => ["rice_white", "pasta_dry", "potato", "bread", "tortilla", "oats", "quinoa"].includes(id), ); const proteins = ingredientIds.filter((id) => ["pork_loin", "chicken_breast", "chicken_thigh", "minced_beef", "salmon", "tofu", "egg", "peanut_butter"].includes(id), ); return { protein: proteins[0] ?? null, carb: carbs[0] ?? null, vegetables: vegetables.slice(0, 4), flavor: ["fresh", "balanced"], }; } function toSeedRecipe( candidate: Awaited>["verificationResults"][number], target: PipelineTarget, slug: string, ): SeedRecipe { const c = candidate.candidate; const dna = guessDna(target.mainIngredientId, candidate.canonicalIngredientIds); return { slug, titleSv: c.titleSv, descriptionSv: c.descriptionSv, cuisine: (c.cuisine as SeedRecipe["cuisine"]) ?? "international", mealTypes: (c.mealTypes as SeedRecipe["mealTypes"]) ?? [target.mealType as SeedRecipe["mealTypes"][number]], tags: deriveTags(target.mealType, target.mainIngredientId) as SeedRecipe["tags"], methods: deriveMethods(target.mainIngredientId) as SeedRecipe["methods"], equipment: deriveEquipment(target.mainIngredientId) as SeedRecipe["equipment"], difficulty: "beginner", prepMin: c.prepTimeMinutes, cookMin: c.cookTimeMinutes, portions: c.portions, spiceLevel: c.spiceLevel, mealPrepFriendly: c.mealPrepFriendly, freezerFriendly: c.freezerFriendly, peakSeasons: [], holidayTags: [], variantType: target.dietVariant as SeedRecipe["variantType"], dnaProtein: dna.protein ?? undefined, dnaCarb: dna.carb ?? undefined, dnaVegetables: dna.vegetables, dnaFlavor: dna.flavor, verificationStatus: "verified", storageGuidanceSv: c.storageGuidanceSv ?? undefined, ingredients: c.ingredients.map((i) => ({ ing: i.canonicalIngredientId, nameSv: i.displayNameSv, qty: i.quantity, unit: i.unit as SeedRecipe["ingredients"][number]["unit"], note: i.note ?? undefined, optional: i.optional, })), steps: c.steps.map((s) => ({ text: s.instructionSv, timerSeconds: s.timerSeconds ?? undefined, temperatureC: s.temperatureC ?? undefined, tip: s.tip ?? undefined, })), }; } async function main() { const client = createAamosClient(process.env); 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, }; }, }; const existingSlugs = new Set(SEED_RECIPES.map((r) => r.slug)); const existingTitles = new Set(SEED_RECIPES.map((r) => r.titleSv.toLowerCase())); const usedSlugs = new Set(existingSlugs); const usedTitles = new Set(existingTitles); const similarityLookup: SimilarityLookup = { async hasSimilarity(title: string) { const lower = title.toLowerCase(); if (usedTitles.has(lower)) return true; const slug = normalizeForSlug(title); if (usedSlugs.has(slug)) return true; return false; }, }; console.error(`[gapfill] ${NULLCELL_TARGETS.length} nollceller, max ${NULLCELL_TARGETS.reduce((s, t) => s + t.count, 0)} recept`); const result = await runPipeline( client, NULLCELL_TARGETS, catalog, ingredientLookup, similarityLookup, { maxPrepTimeMinutes: 30, maxCookTimeMinutes: 45, portions: 4 }, ); const verifiedExports: Array<{ target: PipelineTarget; slug: string; recipe: SeedRecipe }> = []; const rejects: Array<{ target: PipelineTarget; title: string; status: string; reasons: string[] }> = []; for (let i = 0; i < result.verificationResults.length; i++) { const vr = result.verificationResults[i]; const target = NULLCELL_TARGETS.find((t) => t.mainIngredientId === vr.canonicalIngredientIds[0]) ?? NULLCELL_TARGETS[0]; if (vr.status === "verified") { const slug = makeUniqueSlug(vr.candidate.titleSv, usedSlugs); usedSlugs.add(slug); usedTitles.add(vr.candidate.titleSv.toLowerCase()); verifiedExports.push({ target, slug, recipe: toSeedRecipe(vr, target, slug) }); } else { rejects.push({ target, title: vr.candidate.titleSv, status: vr.status, reasons: vr.reasons, }); } } await fs.mkdir(OUTPUT_DIR, { recursive: true }); const exportPath = path.join(OUTPUT_DIR, "gapfill-nullcells-export.json"); const rejectsPath = path.join(OUTPUT_DIR, "gapfill-nullcells-rejects.json"); const summaryPath = path.join(OUTPUT_DIR, "gapfill-nullcells-summary.json"); await fs.writeFile( exportPath, JSON.stringify( { batchId: `gapfill-nullcells-${Date.now()}`, generatedAt: new Date().toISOString(), brand: BRAND.name, targetCount: NULLCELL_TARGETS.reduce((s, t) => s + t.count, 0), verifiedCount: result.verifiedCount, unverifiedCount: result.unverifiedCount, rejectedCount: result.rejectedCount, geminiStatus: result.geminiResult.status, geminiCostUsd: result.geminiResult.costUsd ?? 0, recipes: verifiedExports.map((e) => e.recipe), }, null, 2, ), "utf-8", ); await fs.writeFile( rejectsPath, JSON.stringify( { batchId: `gapfill-nullcells-${Date.now()}`, generatedAt: new Date().toISOString(), rejects: rejects, }, null, 2, ), "utf-8", ); const summary = { batchId: `gapfill-nullcells-${Date.now()}`, generatedAt: new Date().toISOString(), geminiStatus: result.geminiResult.status, geminiCostUsd: result.geminiResult.costUsd ?? 0, candidatesGenerated: result.candidates.length, verified: result.verifiedCount, unverified: result.unverifiedCount, rejected: result.rejectedCount, exportPath, rejectsPath, }; await fs.writeFile(summaryPath, JSON.stringify(summary, null, 2), "utf-8"); console.error("\n# Gap-fill nollceller — sammanfattning\n"); console.error(`- Genererade kandidater: ${result.candidates.length}`); console.error(`- Verified/godkända: ${result.verifiedCount}`); console.error(`- Unverified: ${result.unverifiedCount}`); console.error(`- Rejected: ${result.rejectedCount}`); console.error(`- Gemini-kostnad: $${(result.geminiResult.costUsd ?? 0).toFixed(4)}`); console.error(`- Export: ${exportPath}`); console.error(`- Rejects: ${rejectsPath}`); console.error(`- Summary: ${summaryPath}`); if (result.geminiResult.error) { console.error(`\n**Fel:** ${result.geminiResult.error}`); } } main().catch((err) => { console.error("[gapfill] Fatal:", err); process.exit(1); });