gapfill: generera+verifiera nollceller (export)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user