diff --git a/packages/recipe-generation/src/verification.ts b/packages/recipe-generation/src/verification.ts index 0ae2c98..60248a4 100644 --- a/packages/recipe-generation/src/verification.ts +++ b/packages/recipe-generation/src/verification.ts @@ -71,6 +71,55 @@ const DEFAULT_OPTIONS: Required = { requireProtein: false, }; +/** Ingredienser som kräver ett säkerhetssteg med genomstekning/temperatur (spec §61.3). */ +const RAW_PROTEIN_REQUIRING_SAFE_COOKING = new Set([ + // Fågel + "chicken_breast", + "chicken_thigh", + // Fläsk/nöt/köttfärs + "pork_loin", + "minced_beef", + "minced_mixed", + "meatball_pork_beef", + "falukorv", + // Ägg + "egg", + // Fisk/skaldjur + "cod", + "salmon", + "shrimp", + "anchovy_swedish", + "pickled_herring", +]); + +const SAFE_COOKING_KEYWORDS_SV = [ + /\btemperatur\b/i, + /\b°\s*c\b/i, + /\bgrader\b/i, + /\bgenomstekt\b/i, + /\bgenomkokt\b/i, + /\bgenomgrillad\b/i, + /\bgenomv\w+\b/i, + /\btill(?:s)? den är klar\b/i, + /\btill(?:s)? köttet släpper vätskan\b/i, + /\b72\s*c?\b/i, + /\b74\s*c?\b/i, + /\b75\s*c?\b/i, + /\b63\s*c?\b/i, + /\b65\s*c?\b/i, + /\b70\s*c?\b/i, +]; + +function requiresSafeCookingStep(ingredientIds: string[]): boolean { + return ingredientIds.some((id) => RAW_PROTEIN_REQUIRING_SAFE_COOKING.has(id)); +} + +function hasSafeCookingStep(steps: RecipeCandidate["steps"]): boolean { + return steps.some((s) => + SAFE_COOKING_KEYWORDS_SV.some((re) => re.test(s.instructionSv)), + ); +} + /** * Kör en kandidat genom verifieringspipelinen. * Returnerar alltid ett VerificationResult; status sätts beroende på om @@ -203,6 +252,13 @@ export async function verifyCandidate( } } + // ── 4b. Matsäkerhetslint: rå fågel/fläsk/ägg/fisk kräver genomstekningssteg ── + if (requiresSafeCookingStep(canonicalIds) && !hasSafeCookingStep(candidate.steps)) { + reasons.push( + "Receptet innehåller rått kött/fågel/ägg/fisk men saknar steg för genomstekning/temperatur.", + ); + } + // ── 5. Dedup mot befintliga recept ──────────────────────────────────────── if (similarity) { const isDup = await similarity.hasSimilarity(candidate.titleSv); diff --git a/packages/recipe-generation/test/verification.test.ts b/packages/recipe-generation/test/verification.test.ts index 2f3f6eb..93cb612 100644 --- a/packages/recipe-generation/test/verification.test.ts +++ b/packages/recipe-generation/test/verification.test.ts @@ -68,6 +68,27 @@ const mockIngredients: CanonicalIngredientLookup = { isBeef: false, isAlcohol: false, }, + chicken_breast: { + id: "chicken_breast", + nutritionPer100: { + basis: "per_100_g", + values: { + kcal: 165, proteinG: 31, carbsG: 0, fatG: 3.6, + saturatedFatG: 1, fiberG: 0, sugarG: 0, saltG: 0.1, + }, + }, + defaultUnit: "GRAM", + densityGPerMl: null, + gramsPerPiece: null, + allergens: [], + isVegan: false, + isVegetarian: false, + containsGluten: false, + containsLactose: false, + isPork: false, + isBeef: false, + isAlcohol: false, + }, }; return db[id] ?? undefined; }, @@ -211,4 +232,38 @@ describe("verifyCandidate", () => { expect(result.status).toBe("unverified"); expect(result.reasons.some((r) => r.includes("dubblett"))).toBe(true); }); + + it("kräver genomstekningssteg för rå fågel (positivt)", async () => { + const c = makeCandidate({ + ingredients: [ + { canonicalIngredientId: "chicken_breast", displayNameSv: "kycklingfilé", quantity: 500, unit: "GRAM", optional: false, note: null }, + { canonicalIngredientId: "ris", displayNameSv: "ris", quantity: 300, unit: "GRAM", optional: false, note: null }, + { canonicalIngredientId: "krossade_tomater", displayNameSv: "krossade tomater", quantity: 400, unit: "GRAM", optional: false, note: null }, + ], + steps: [ + { stepNumber: 1, instructionSv: "Stek kycklingen tills den är genomstekt och innertemperaturen är 72°C.", timerSeconds: null, temperatureC: null, tip: null }, + { stepNumber: 2, instructionSv: "Tillsätt ris och tomater, koka klart.", timerSeconds: null, temperatureC: null, tip: null }, + ], + }); + const result = await verifyCandidate(c, mockIngredients, noSimilarity); + expect(result.status).toBe("verified"); + expect(result.reasons).not.toContain("Receptet innehåller rått kött/fågel/ägg/fisk men saknar steg för genomstekning/temperatur."); + }); + + it("markerar unverified när rå fågel saknar genomstekningssteg (negativt)", async () => { + const c = makeCandidate({ + ingredients: [ + { canonicalIngredientId: "chicken_breast", displayNameSv: "kycklingfilé", quantity: 500, unit: "GRAM", optional: false, note: null }, + { canonicalIngredientId: "ris", displayNameSv: "ris", quantity: 300, unit: "GRAM", optional: false, note: null }, + { canonicalIngredientId: "krossade_tomater", displayNameSv: "krossade tomater", quantity: 400, unit: "GRAM", optional: false, note: null }, + ], + steps: [ + { stepNumber: 1, instructionSv: "Stek kycklingen i pannan.", timerSeconds: null, temperatureC: null, tip: null }, + { stepNumber: 2, instructionSv: "Tillsätt ris och tomater, koka klart.", timerSeconds: null, temperatureC: null, tip: null }, + ], + }); + const result = await verifyCandidate(c, mockIngredients, noSimilarity); + expect(result.status).toBe("unverified"); + expect(result.reasons).toContain("Receptet innehåller rått kött/fågel/ägg/fisk men saknar steg för genomstekning/temperatur."); + }); });