feat(recipe-generation): matsäkerhetslint för rå fågel/fläsk/ägg/fisk
This commit is contained in:
@@ -71,6 +71,55 @@ const DEFAULT_OPTIONS: Required<VerificationOptions> = {
|
|||||||
requireProtein: false,
|
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.
|
* Kör en kandidat genom verifieringspipelinen.
|
||||||
* Returnerar alltid ett VerificationResult; status sätts beroende på om
|
* 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 ────────────────────────────────────────
|
// ── 5. Dedup mot befintliga recept ────────────────────────────────────────
|
||||||
if (similarity) {
|
if (similarity) {
|
||||||
const isDup = await similarity.hasSimilarity(candidate.titleSv);
|
const isDup = await similarity.hasSimilarity(candidate.titleSv);
|
||||||
|
|||||||
@@ -68,6 +68,27 @@ const mockIngredients: CanonicalIngredientLookup = {
|
|||||||
isBeef: false,
|
isBeef: false,
|
||||||
isAlcohol: 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;
|
return db[id] ?? undefined;
|
||||||
},
|
},
|
||||||
@@ -211,4 +232,38 @@ describe("verifyCandidate", () => {
|
|||||||
expect(result.status).toBe("unverified");
|
expect(result.status).toBe("unverified");
|
||||||
expect(result.reasons.some((r) => r.includes("dubblett"))).toBe(true);
|
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.");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user