From a00f0a59ab1d5c22dbfae3d74312f56dd96fedeb Mon Sep 17 00:00:00 2001 From: "Sven (AAMOS AI)" Date: Tue, 11 Aug 2026 06:39:58 +0700 Subject: [PATCH] gapfill: generera+verifiera nollceller (export) --- .../output/gapfill-nullcells-export.json | 828 ++++++++++++++++++ .../output/gapfill-nullcells-rejects.json | 58 ++ .../output/gapfill-nullcells-summary.json | 12 + .../scripts/gapfill-nullcells.ts | 306 +++++++ 4 files changed, 1204 insertions(+) create mode 100644 packages/recipe-generation/output/gapfill-nullcells-export.json create mode 100644 packages/recipe-generation/output/gapfill-nullcells-rejects.json create mode 100644 packages/recipe-generation/output/gapfill-nullcells-summary.json create mode 100644 packages/recipe-generation/scripts/gapfill-nullcells.ts diff --git a/packages/recipe-generation/output/gapfill-nullcells-export.json b/packages/recipe-generation/output/gapfill-nullcells-export.json new file mode 100644 index 0000000..46712d7 --- /dev/null +++ b/packages/recipe-generation/output/gapfill-nullcells-export.json @@ -0,0 +1,828 @@ +{ + "batchId": "gapfill-nullcells-1786405187103", + "generatedAt": "2026-08-10T23:39:47.103Z", + "brand": "Cibello", + "targetCount": 12, + "verifiedCount": 8, + "unverifiedCount": 4, + "rejectedCount": 0, + "geminiStatus": "ok", + "geminiCostUsd": 0.0033332249999999996, + "recipes": [ + { + "slug": "kramig-flaskytterfile-med-svamp-och-potatis", + "titleSv": "Krämig fläskytterfilé med svamp och potatis", + "descriptionSv": "En enkel och smakrik vardagsrätt med mör fläskytterfilé i en krämig svampsås, serverad med kokt potatis. Perfekt när tiden är knapp men du vill äta gott.", + "cuisine": "swedish", + "mealTypes": [ + "dinner" + ], + "tags": [ + "quick", + "high_protein" + ], + "methods": [ + "stovetop" + ], + "equipment": [ + "stove" + ], + "difficulty": "beginner", + "prepMin": 15, + "cookMin": 30, + "portions": 4, + "spiceLevel": 1, + "mealPrepFriendly": true, + "freezerFriendly": false, + "peakSeasons": [], + "holidayTags": [], + "variantType": "standard", + "dnaProtein": "pork_loin", + "dnaCarb": "potato", + "dnaVegetables": [ + "onion", + "garlic" + ], + "dnaFlavor": [ + "fresh", + "balanced" + ], + "verificationStatus": "verified", + "storageGuidanceSv": "Förvara eventuella rester i en lufttät behållare i kylskåp i upp till 2 dagar.", + "ingredients": [ + { + "ing": "pork_loin", + "nameSv": "fläskytterfilé", + "qty": 500, + "unit": "GRAM", + "optional": false + }, + { + "ing": "potato", + "nameSv": "potatis", + "qty": 8, + "unit": "COUNT", + "note": "medelstora", + "optional": false + }, + { + "ing": "mushroom", + "nameSv": "champinjoner", + "qty": 250, + "unit": "GRAM", + "optional": false + }, + { + "ing": "onion", + "nameSv": "gul lök", + "qty": 1, + "unit": "COUNT", + "optional": false + }, + { + "ing": "garlic", + "nameSv": "vitlök", + "qty": 2, + "unit": "CLOVE", + "optional": false + }, + { + "ing": "cooking_cream", + "nameSv": "matlagningsgrädde", + "qty": 2.5, + "unit": "DECILITER", + "optional": false + }, + { + "ing": "rapeseed_oil", + "nameSv": "rapsolja", + "qty": 1, + "unit": "TABLESPOON", + "note": "till stekning", + "optional": false + }, + { + "ing": "salt", + "nameSv": "salt", + "qty": 1, + "unit": "TEASPOON", + "optional": false + }, + { + "ing": "black_pepper", + "nameSv": "svartpeppar", + "qty": 1, + "unit": "MILLILITER", + "note": "nymald", + "optional": false + } + ], + "steps": [ + { + "text": "Skala och koka potatisen i saltat vatten tills den är mjuk, cirka 20-25 minuter.", + "timerSeconds": 1500 + }, + { + "text": "Putsa fläskytterfilén och skär den i cirka 1,5 cm tjocka skivor. Salta och peppra skivorna." + }, + { + "text": "Skala och finhacka lök och vitlök. Skiva champinjonerna." + }, + { + "text": "Hetta upp rapsolja i en stekpanna. Stek fläskytterfiléskivorna på medelhög värme tills de är genomstekta och har en innertemperatur på minst 68°C, cirka 3-4 minuter per sida. Ta upp köttet och lägg åt sidan.", + "timerSeconds": 480, + "tip": "Stek i omgångar om pannan blir för full för att få en fin stekyta." + }, + { + "text": "Tillsätt mer olja om det behövs i samma panna. Fräs lök, vitlök och champinjoner tills löken är mjuk och champinjonerna fått fin färg." + }, + { + "text": "Häll i matlagningsgrädden och låt såsen sjuda i några minuter. Smaka av med salt och peppar.", + "timerSeconds": 180 + }, + { + "text": "Lägg tillbaka fläskytterfilén i såsen och låt den bli varm. Servera genast med den kokta potatisen." + } + ] + }, + { + "slug": "flaskytterfile-med-apple-och-timjan", + "titleSv": "Fläskytterfilé med äpple och timjan", + "descriptionSv": "En härlig kombination av sött äpple och aromatisk timjan som lyfter den mjälla fläskytterfilén. Serveras med kokt ris för en komplett måltid.", + "cuisine": "swedish", + "mealTypes": [ + "dinner" + ], + "tags": [ + "quick", + "high_protein" + ], + "methods": [ + "stovetop" + ], + "equipment": [ + "stove" + ], + "difficulty": "beginner", + "prepMin": 15, + "cookMin": 30, + "portions": 4, + "spiceLevel": 1, + "mealPrepFriendly": true, + "freezerFriendly": false, + "peakSeasons": [], + "holidayTags": [], + "variantType": "standard", + "dnaProtein": "pork_loin", + "dnaCarb": "rice_white", + "dnaVegetables": [ + "onion" + ], + "dnaFlavor": [ + "fresh", + "balanced" + ], + "verificationStatus": "verified", + "storageGuidanceSv": "Rester kan förvaras i kylskåp i en lufttät behållare i upp till 2 dagar.", + "ingredients": [ + { + "ing": "pork_loin", + "nameSv": "fläskytterfilé", + "qty": 500, + "unit": "GRAM", + "optional": false + }, + { + "ing": "rice_white", + "nameSv": "ris (jasmin/basmati)", + "qty": 300, + "unit": "GRAM", + "note": "torrt", + "optional": false + }, + { + "ing": "apple", + "nameSv": "äpple", + "qty": 2, + "unit": "COUNT", + "optional": false + }, + { + "ing": "onion", + "nameSv": "gul lök", + "qty": 1, + "unit": "COUNT", + "optional": false + }, + { + "ing": "cooking_cream", + "nameSv": "matlagningsgrädde", + "qty": 2.5, + "unit": "DECILITER", + "optional": false + }, + { + "ing": "thyme_dried", + "nameSv": "torkad timjan", + "qty": 1, + "unit": "TEASPOON", + "optional": false + }, + { + "ing": "rapeseed_oil", + "nameSv": "rapsolja", + "qty": 1, + "unit": "TABLESPOON", + "note": "till stekning", + "optional": false + }, + { + "ing": "salt", + "nameSv": "salt", + "qty": 1, + "unit": "TEASPOON", + "optional": false + }, + { + "ing": "black_pepper", + "nameSv": "svartpeppar", + "qty": 1, + "unit": "MILLILITER", + "note": "nymald", + "optional": false + } + ], + "steps": [ + { + "text": "Koka riset enligt anvisningarna på förpackningen." + }, + { + "text": "Putsa fläskytterfilén och skär den i cirka 1,5 cm tjocka skivor. Salta och peppra skivorna." + }, + { + "text": "Skala och hacka löken. Kärna ur och skär äpplena i klyftor eller tjocka skivor." + }, + { + "text": "Hetta upp rapsolja i en stekpanna. Stek fläskytterfiléskivorna på medelhög värme tills de är genomstekta och har en innertemperatur på minst 68°C, cirka 3-4 minuter per sida. Ta upp köttet och lägg åt sidan.", + "timerSeconds": 480, + "tip": "Stek i omgångar om pannan blir för full för att få en fin stekyta." + }, + { + "text": "Tillsätt mer olja om det behövs i samma panna. Fräs lök och äppelklyftor tills löken är mjuk och äpplena börjar mjukna." + }, + { + "text": "Häll i matlagningsgrädden och tillsätt timjan. Låt såsen sjuda i några minuter. Smaka av med salt och peppar.", + "timerSeconds": 180 + }, + { + "text": "Lägg tillbaka fläskytterfilén i såsen och låt den bli varm. Servera genast med det kokta riset." + } + ] + }, + { + "slug": "flaskytterfile-med-senapssas-och-broccoli", + "titleSv": "Fläskytterfilé med senapssås och broccoli", + "descriptionSv": "En klassisk kombination av fläskytterfilé och en krämig senapssås. Serveras med ångad broccoli och kokt potatis för en balanserad måltid.", + "cuisine": "swedish", + "mealTypes": [ + "dinner" + ], + "tags": [ + "quick", + "high_protein" + ], + "methods": [ + "stovetop" + ], + "equipment": [ + "stove" + ], + "difficulty": "beginner", + "prepMin": 15, + "cookMin": 30, + "portions": 4, + "spiceLevel": 2, + "mealPrepFriendly": true, + "freezerFriendly": false, + "peakSeasons": [], + "holidayTags": [], + "variantType": "standard", + "dnaProtein": "pork_loin", + "dnaCarb": "potato", + "dnaVegetables": [ + "onion" + ], + "dnaFlavor": [ + "fresh", + "balanced" + ], + "verificationStatus": "verified", + "storageGuidanceSv": "Förvara eventuella rester i en lufttät behållare i kylskåp i upp till 2 dagar.", + "ingredients": [ + { + "ing": "pork_loin", + "nameSv": "fläskytterfilé", + "qty": 500, + "unit": "GRAM", + "optional": false + }, + { + "ing": "potato", + "nameSv": "potatis", + "qty": 8, + "unit": "COUNT", + "note": "medelstora", + "optional": false + }, + { + "ing": "broccoli", + "nameSv": "broccoli", + "qty": 1, + "unit": "COUNT", + "note": "ca 300g", + "optional": false + }, + { + "ing": "onion", + "nameSv": "gul lök", + "qty": 1, + "unit": "COUNT", + "optional": false + }, + { + "ing": "mustard", + "nameSv": "senap (dijon eller grovkornig)", + "qty": 2, + "unit": "TABLESPOON", + "optional": false + }, + { + "ing": "cooking_cream", + "nameSv": "matlagningsgrädde", + "qty": 2.5, + "unit": "DECILITER", + "optional": false + }, + { + "ing": "rapeseed_oil", + "nameSv": "rapsolja", + "qty": 1, + "unit": "TABLESPOON", + "note": "till stekning", + "optional": false + }, + { + "ing": "salt", + "nameSv": "salt", + "qty": 1, + "unit": "TEASPOON", + "optional": false + }, + { + "ing": "black_pepper", + "nameSv": "svartpeppar", + "qty": 1, + "unit": "MILLILITER", + "note": "nymald", + "optional": false + } + ], + "steps": [ + { + "text": "Skala och koka potatisen i saltat vatten tills den är mjuk, cirka 20-25 minuter.", + "timerSeconds": 1500 + }, + { + "text": "Dela broccolin i mindre buketter och koka eller ånga den tills den är mjuk men fortfarande har lite tuggmotstånd, cirka 5-7 minuter.", + "timerSeconds": 420 + }, + { + "text": "Putsa fläskytterfilén och skär den i cirka 1,5 cm tjocka skivor. Salta och peppra skivorna." + }, + { + "text": "Skala och finhacka löken." + }, + { + "text": "Hetta upp rapsolja i en stekpanna. Stek fläskytterfiléskivorna på medelhög värme tills de är genomstekta och har en innertemperatur på minst 68°C, cirka 3-4 minuter per sida. Ta upp köttet och lägg åt sidan.", + "timerSeconds": 480, + "tip": "Stek i omgångar om pannan blir för full för att få en fin stekyta." + }, + { + "text": "Tillsätt mer olja om det behövs i samma panna. Fräs löken tills den är mjuk och glansig." + }, + { + "text": "Rör ner senap och matlagningsgrädde. Låt såsen sjuda i några minuter. Smaka av med salt och peppar.", + "timerSeconds": 180 + }, + { + "text": "Lägg tillbaka fläskytterfilén i såsen och låt den bli varm. Servera genast med den kokta potatisen och broccolin." + } + ] + }, + { + "slug": "snabb-banansmoothie", + "titleSv": "Snabb banansmoothie", + "descriptionSv": "En energigivande och mättande smoothie med banan, yoghurt och havredryck. Perfekt när du behöver en snabb frukost eller ett mellanmål.", + "cuisine": "swedish", + "mealTypes": [ + "breakfast" + ], + "tags": [ + "quick", + "kid_friendly" + ], + "methods": [ + "no_cook" + ], + "equipment": [], + "difficulty": "beginner", + "prepMin": 5, + "cookMin": 0, + "portions": 4, + "spiceLevel": 0, + "mealPrepFriendly": false, + "freezerFriendly": false, + "peakSeasons": [], + "holidayTags": [], + "variantType": "standard", + "dnaCarb": "oats", + "dnaVegetables": [], + "dnaFlavor": [ + "fresh", + "balanced" + ], + "verificationStatus": "verified", + "storageGuidanceSv": "Smoothien bör drickas direkt för bästa smak och konsistens. Kan förvaras i kylskåp i max 1 dygn, men kan då skikta sig.", + "ingredients": [ + { + "ing": "banana", + "nameSv": "banan", + "qty": 2, + "unit": "COUNT", + "note": "mogna", + "optional": false + }, + { + "ing": "yoghurt_natural", + "nameSv": "naturell yoghurt 3 %", + "qty": 4, + "unit": "DECILITER", + "optional": false + }, + { + "ing": "oat_drink", + "nameSv": "havredryck", + "qty": 2, + "unit": "DECILITER", + "optional": false + }, + { + "ing": "oats", + "nameSv": "havregryn", + "qty": 0.5, + "unit": "DECILITER", + "note": "för extra mättnad", + "optional": true + } + ], + "steps": [ + { + "text": "Skala bananerna och bryt dem i mindre bitar." + }, + { + "text": "Lägg bananbitarna, naturell yoghurt, havredryck och eventuellt havregryn i en mixer." + }, + { + "text": "Mixa tills smoothien är slät och krämig. Tillsätt mer havredryck om du vill ha en tunnare konsistens." + }, + { + "text": "Häll upp i glas och servera direkt." + } + ] + }, + { + "slug": "havregrynsgrot-med-banan-och-kanel", + "titleSv": "Havregrynsgröt med banan och kanel", + "descriptionSv": "En klassisk och värmande frukost som är både enkel att göra och mättande. Toppas med skivad banan och en nypa kanel.", + "cuisine": "swedish", + "mealTypes": [ + "breakfast" + ], + "tags": [ + "quick", + "high_protein" + ], + "methods": [ + "stovetop" + ], + "equipment": [ + "stove" + ], + "difficulty": "beginner", + "prepMin": 5, + "cookMin": 10, + "portions": 4, + "spiceLevel": 0, + "mealPrepFriendly": false, + "freezerFriendly": false, + "peakSeasons": [], + "holidayTags": [], + "variantType": "standard", + "dnaCarb": "oats", + "dnaVegetables": [], + "dnaFlavor": [ + "fresh", + "balanced" + ], + "verificationStatus": "verified", + "storageGuidanceSv": "Gröten smakar bäst nygjord. Eventuella rester kan förvaras i kylskåp i en lufttät behållare i upp till 1 dag och värmas upp med lite extra mjölk.", + "ingredients": [ + { + "ing": "oats", + "nameSv": "havregryn", + "qty": 4, + "unit": "DECILITER", + "optional": false + }, + { + "ing": "milk_1_5", + "nameSv": "mellanmjölk 1,5 %", + "qty": 8, + "unit": "DECILITER", + "optional": false + }, + { + "ing": "salt", + "nameSv": "salt", + "qty": 0.5, + "unit": "TEASPOON", + "optional": false + }, + { + "ing": "banana", + "nameSv": "banan", + "qty": 2, + "unit": "COUNT", + "optional": false + }, + { + "ing": "cinnamon", + "nameSv": "kanel", + "qty": 1, + "unit": "TEASPOON", + "optional": false + } + ], + "steps": [ + { + "text": "Blanda havregryn, mjölk och salt i en kastrull." + }, + { + "text": "Koka upp under omrörning och låt gröten sjuda på svag värme i cirka 3-5 minuter, eller tills den har önskad konsistens. Rör om då och då.", + "timerSeconds": 300, + "tip": "Tillsätt mer mjölk om du vill ha en lösare gröt." + }, + { + "text": "Skala och skiva bananerna." + }, + { + "text": "Fördela gröten i skålar och toppa med skivad banan och kanel. Servera genast." + } + ] + }, + { + "slug": "yoghurt-med-jordgubbar-och-havregryn", + "titleSv": "Yoghurt med jordgubbar och havregryn", + "descriptionSv": "En fräsch och enkel frukost eller mellanmål med krämig yoghurt, söta jordgubbar och knapriga havregryn.", + "cuisine": "swedish", + "mealTypes": [ + "breakfast" + ], + "tags": [ + "quick", + "kid_friendly" + ], + "methods": [ + "no_cook" + ], + "equipment": [], + "difficulty": "beginner", + "prepMin": 5, + "cookMin": 0, + "portions": 4, + "spiceLevel": 0, + "mealPrepFriendly": false, + "freezerFriendly": false, + "peakSeasons": [], + "holidayTags": [], + "variantType": "standard", + "dnaCarb": "oats", + "dnaVegetables": [], + "dnaFlavor": [ + "fresh", + "balanced" + ], + "verificationStatus": "verified", + "storageGuidanceSv": "Bör ätas direkt för bästa upplevelse. Förvara ingredienserna separat och montera vid servering.", + "ingredients": [ + { + "ing": "yoghurt_natural", + "nameSv": "naturell yoghurt 3 %", + "qty": 8, + "unit": "DECILITER", + "optional": false + }, + { + "ing": "strawberry", + "nameSv": "jordgubbar (färska eller frysta)", + "qty": 200, + "unit": "GRAM", + "optional": false + }, + { + "ing": "oats", + "nameSv": "havregryn", + "qty": 1, + "unit": "DECILITER", + "optional": false + }, + { + "ing": "honey", + "nameSv": "honung", + "qty": 20, + "unit": "GRAM", + "note": "för sötma", + "optional": true + } + ], + "steps": [ + { + "text": "Skölj och skiva jordgubbarna om de är färska. Om de är frysta, tina dem lätt." + }, + { + "text": "Fördela yoghurten i fyra skålar." + }, + { + "text": "Toppa yoghurten med skivade jordgubbar och havregryn." + }, + { + "text": "Ringla över lite honung om så önskas. Servera genast." + } + ] + }, + { + "slug": "yoghurt-med-apple-banan-och-kanel", + "titleSv": "Yoghurt med äpple, banan och kanel", + "descriptionSv": "En enkel och näringsrik frukost eller mellanmål med naturell yoghurt, färsk frukt och en touch av kanel.", + "cuisine": "swedish", + "mealTypes": [ + "breakfast" + ], + "tags": [ + "quick", + "kid_friendly" + ], + "methods": [ + "no_cook" + ], + "equipment": [], + "difficulty": "beginner", + "prepMin": 5, + "cookMin": 0, + "portions": 4, + "spiceLevel": 0, + "mealPrepFriendly": false, + "freezerFriendly": false, + "peakSeasons": [], + "holidayTags": [], + "variantType": "standard", + "dnaVegetables": [], + "dnaFlavor": [ + "fresh", + "balanced" + ], + "verificationStatus": "verified", + "storageGuidanceSv": "Bör ätas direkt för bästa upplevelse. Förvara ingredienserna separat och montera vid servering.", + "ingredients": [ + { + "ing": "yoghurt_natural", + "nameSv": "naturell yoghurt 3 %", + "qty": 8, + "unit": "DECILITER", + "optional": false + }, + { + "ing": "apple", + "nameSv": "äpple", + "qty": 1, + "unit": "COUNT", + "optional": false + }, + { + "ing": "banana", + "nameSv": "banan", + "qty": 1, + "unit": "COUNT", + "optional": false + }, + { + "ing": "cinnamon", + "nameSv": "kanel", + "qty": 0.5, + "unit": "TEASPOON", + "optional": false + }, + { + "ing": "honey", + "nameSv": "honung", + "qty": 10, + "unit": "GRAM", + "note": "för sötma", + "optional": true + } + ], + "steps": [ + { + "text": "Skölj äpplet, kärna ur och tärna det i små bitar. Skala bananen och skiva den." + }, + { + "text": "Fördela yoghurten i fyra skålar." + }, + { + "text": "Toppa yoghurten med tärnat äpple och skivad banan." + }, + { + "text": "Strö över kanel och ringla eventuellt över lite honung. Servera genast." + } + ] + }, + { + "slug": "yoghurt-med-honung-och-en-nypa-kanel", + "titleSv": "Yoghurt med honung och en nypa kanel", + "descriptionSv": "En enkel och klassisk frukost eller mellanmål som är snabb att svänga ihop. Naturell yoghurt med sötma från honung och värmande kanel.", + "cuisine": "swedish", + "mealTypes": [ + "breakfast" + ], + "tags": [ + "quick", + "kid_friendly" + ], + "methods": [ + "no_cook" + ], + "equipment": [], + "difficulty": "beginner", + "prepMin": 2, + "cookMin": 0, + "portions": 4, + "spiceLevel": 0, + "mealPrepFriendly": false, + "freezerFriendly": false, + "peakSeasons": [], + "holidayTags": [], + "variantType": "standard", + "dnaVegetables": [], + "dnaFlavor": [ + "fresh", + "balanced" + ], + "verificationStatus": "verified", + "storageGuidanceSv": "Bör ätas direkt för bästa upplevelse. Förvara ingredienserna separat och montera vid servering.", + "ingredients": [ + { + "ing": "yoghurt_natural", + "nameSv": "naturell yoghurt 3 %", + "qty": 8, + "unit": "DECILITER", + "optional": false + }, + { + "ing": "honey", + "nameSv": "honung", + "qty": 40, + "unit": "GRAM", + "optional": false + }, + { + "ing": "cinnamon", + "nameSv": "kanel", + "qty": 0.5, + "unit": "TEASPOON", + "optional": false + } + ], + "steps": [ + { + "text": "Fördela yoghurten i fyra skålar." + }, + { + "text": "Ringla generöst med honung över yoghurten i varje skål." + }, + { + "text": "Strö över en nypa kanel på varje portion. Servera genast." + } + ] + } + ] +} \ No newline at end of file diff --git a/packages/recipe-generation/output/gapfill-nullcells-rejects.json b/packages/recipe-generation/output/gapfill-nullcells-rejects.json new file mode 100644 index 0000000..9e15869 --- /dev/null +++ b/packages/recipe-generation/output/gapfill-nullcells-rejects.json @@ -0,0 +1,58 @@ +{ + "batchId": "gapfill-nullcells-1786405187104", + "generatedAt": "2026-08-10T23:39:47.104Z", + "rejects": [ + { + "target": { + "mealType": "breakfast", + "mainIngredientId": "banana", + "dietVariant": "standard", + "count": 3 + }, + "title": "Bananpannkakor med havregryn", + "status": "unverified", + "reasons": [ + "Receptet innehåller rått kött/fågel/ägg/fisk men saknar steg för genomstekning/temperatur." + ] + }, + { + "target": { + "mealType": "dinner", + "mainIngredientId": "pork_loin", + "dietVariant": "standard", + "count": 3 + }, + "title": "Äppelskivor med jordnötssmör", + "status": "unverified", + "reasons": [ + "Endast 2 ingredienser — minst 3 krävs." + ] + }, + { + "target": { + "mealType": "breakfast", + "mainIngredientId": "banana", + "dietVariant": "standard", + "count": 3 + }, + "title": "Banan med jordnötssmör", + "status": "unverified", + "reasons": [ + "Endast 2 ingredienser — minst 3 krävs." + ] + }, + { + "target": { + "mealType": "dinner", + "mainIngredientId": "pork_loin", + "dietVariant": "standard", + "count": 3 + }, + "title": "Jordnötssmörsröra på surdegsbröd", + "status": "unverified", + "reasons": [ + "Näring kunde inte beräknas för: bread_sourdough" + ] + } + ] +} \ No newline at end of file diff --git a/packages/recipe-generation/output/gapfill-nullcells-summary.json b/packages/recipe-generation/output/gapfill-nullcells-summary.json new file mode 100644 index 0000000..480a07c --- /dev/null +++ b/packages/recipe-generation/output/gapfill-nullcells-summary.json @@ -0,0 +1,12 @@ +{ + "batchId": "gapfill-nullcells-1786405187104", + "generatedAt": "2026-08-10T23:39:47.104Z", + "geminiStatus": "ok", + "geminiCostUsd": 0.0033332249999999996, + "candidatesGenerated": 12, + "verified": 8, + "unverified": 4, + "rejected": 0, + "exportPath": "/home/dator_ubuntujpb/.openclaw/workspace/cibello-work/cibello/packages/recipe-generation/output/gapfill-nullcells-export.json", + "rejectsPath": "/home/dator_ubuntujpb/.openclaw/workspace/cibello-work/cibello/packages/recipe-generation/output/gapfill-nullcells-rejects.json" +} \ No newline at end of file diff --git a/packages/recipe-generation/scripts/gapfill-nullcells.ts b/packages/recipe-generation/scripts/gapfill-nullcells.ts new file mode 100644 index 0000000..b07187d --- /dev/null +++ b/packages/recipe-generation/scripts/gapfill-nullcells.ts @@ -0,0 +1,306 @@ +#!/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); +});