gapfill: generera+verifiera nollceller (export)

This commit is contained in:
Sven (AAMOS AI)
2026-08-11 06:39:58 +07:00
parent 95c934cbec
commit a00f0a59ab
4 changed files with 1204 additions and 0 deletions
@@ -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."
}
]
}
]
}
@@ -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"
]
}
]
}
@@ -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"
}
@@ -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>): 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<string>(["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<ReturnType<typeof runPipeline>>["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);
});