/** * Verifieringspipeline för AI-genererade receptkandidater (docs/32 §3). * * Obligatorisk grind — inget blir verified utan att passera alla steg: * 1. Ingrediensmappning mot canonical_ingredients (hård spärr). * 2. Allergener HÄRLEDDA ur ingredienserna (aldrig AI-påstådda). * 3. Näring BERÄKNAD via nutrition-engine (aldrig AI-siffra). * 4. Språk-/kvalitetskontroll (rimliga steg/tider). * 5. Dedup mot recipe_similarities. * * Först då verificationStatus=verified; annars unverified & osynlig. */ import { computeRecipeNutrition, type RecipeIngredientForCalc, type IngredientNutritionSource, } from "@app/nutrition-engine"; import { deriveRecipeAllergens, type IngredientSafetyInfo } from "@app/recipe-engine"; import type { NutritionValues, Allergen } from "@app/shared-types"; import type { RecipeCandidate, VerificationResult } from "./types.js"; export interface CanonicalIngredientLookup { getById(id: string): | { id: string; nutritionPer100: import("@app/shared-types").NutritionDeclaration; defaultUnit: import("@app/shared-types").Unit; densityGPerMl?: number | null; gramsPerPiece?: number | null; allergens: Allergen[]; isVegan: boolean; isVegetarian: boolean; containsGluten: boolean; containsLactose: boolean; isPork: boolean; isBeef: boolean; isAlcohol: boolean; } | undefined; } export interface SimilarityLookup { hasSimilarity(recipeTitle: string): Promise; } export interface VerificationOptions { /** Max tillåten förberedelsetid (minuter). */ maxPrepTimeMinutes?: number; /** Max tillåten koktid (minuter). */ maxCookTimeMinutes?: number; /** Max total tid (minuter). */ maxTotalTimeMinutes?: number; /** Minsta antal steg. */ minSteps?: number; /** Minsta antal ingredienser. */ minIngredients?: number; /** Om true, krävs att receptet har minst en icke-valfri protein-källa. */ requireProtein?: boolean; } const DEFAULT_OPTIONS: Required = { maxPrepTimeMinutes: 120, maxCookTimeMinutes: 180, maxTotalTimeMinutes: 240, minSteps: 2, minIngredients: 3, 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 = [ /\bgenomstekt\b/i, /\bgenomkokt\b/i, /\bgenomgrillad\b/i, /\bgenomv\w+\b/i, /\binte längre rosa\b/i, /\binte rosa\b/i, /\bflagnar\b/i, /\bgenomkokt\b/i, /\bkärntemperatur\b/i, /\binnertemperatur\b/i, /\btemperatur\b/i, /\b°\s*c\b/i, /\bgrader\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, ]; /** Fraser som INTE räcker som säkerhetsbevis – de beskriver bara utseende/tid. */ const UNSAFE_APPEARANCE_ONLY_SV = [ /\bgyllenbrun\b/i, /\bgyllene\b/i, /\bkrispig\b/i, /\bkrispiga\b/i, /\bfint färg\b/i, /\bfint färgade\b/i, /\bfärgad\b/i, /\bfräsch\b/i, /\bfräscha\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) => { const instruction = s.instructionSv; const hasPositive = SAFE_COOKING_KEYWORDS_SV.some((re) => re.test(instruction)); const onlyAppearance = UNSAFE_APPEARANCE_ONLY_SV.some((re) => re.test(instruction)) && !SAFE_COOKING_KEYWORDS_SV.some((re) => re.test(instruction)); return hasPositive && !onlyAppearance; }); } /** * Kör en kandidat genom verifieringspipelinen. * Returnerar alltid ett VerificationResult; status sätts beroende på om * alla grindar passerades. */ export async function verifyCandidate( candidate: RecipeCandidate, ingredients: CanonicalIngredientLookup, similarity: SimilarityLookup | null, options: VerificationOptions = {}, ): Promise { const opts = { ...DEFAULT_OPTIONS, ...options }; const reasons: string[] = []; // ── 0. Hård avvisning av inkompletta kandidater ────────────────────────── if (!candidate.titleSv || candidate.titleSv.trim().length === 0) { reasons.push("Receptet saknar titel."); } if (!Array.isArray(candidate.ingredients) || candidate.ingredients.length === 0) { reasons.push("Receptet saknar ingredienser."); } if (!Array.isArray(candidate.steps) || candidate.steps.length === 0) { reasons.push("Receptet saknar tillagningssteg."); } if (candidate.portions == null || candidate.portions <= 0) { reasons.push("Receptet saknar giltigt antal portioner."); } if ( candidate.prepTimeMinutes == null || candidate.cookTimeMinutes == null || candidate.totalTimeMinutes == null ) { reasons.push("Receptet saknar tidsangivelser."); } // ── 1. Ingrediensmappning (hård spärr) ─────────────────────────────────── const canonicalIds: string[] = []; const nutritionSources = new Map(); const safetyInfos = new Map(); for (const ing of candidate.ingredients) { const canonical = ingredients.getById(ing.canonicalIngredientId); if (!canonical) { reasons.push( `Ingrediens "${ing.canonicalIngredientId}" finns inte i canonical_ingredients — hård spärr.`, ); continue; } canonicalIds.push(ing.canonicalIngredientId); // Bygg nutrition source för steg 3 nutritionSources.set(ing.canonicalIngredientId, { densityGPerMl: canonical.densityGPerMl ?? undefined, gramsPerPiece: canonical.gramsPerPiece ?? undefined, nutritionPer100: canonical.nutritionPer100, }); // Bygg safety info för steg 2 safetyInfos.set(ing.canonicalIngredientId, { id: canonical.id, allergens: canonical.allergens, isVegan: canonical.isVegan, isVegetarian: canonical.isVegetarian, containsGluten: canonical.containsGluten, containsLactose: canonical.containsLactose, isPork: canonical.isPork, isBeef: canonical.isBeef, isAlcohol: canonical.isAlcohol, dataVerified: true, }); } // Om någon ingrediens saknas → avvisat direkt if (reasons.length > 0) { return { candidate, status: "rejected", reasons, nutritionPerPortion: null, allergens: [], canonicalIngredientIds: canonicalIds, }; } // ── 2. Allergener (härledda, aldrig AI) ────────────────────────────────── // AI:s eventuella påståenden om allergener ignoreras fullständigt. // Säkerheten härleds alltid deterministiskt ur canonical_ingredients. const aiClaims = candidate.aiClaimedAllergens ?? []; const derivedAllergens = deriveRecipeAllergens(canonicalIds, safetyInfos); const allergens = derivedAllergens; // ── 3. Näring (beräknad via nutrition-engine) ──────────────────────────── const calcIngredients: RecipeIngredientForCalc[] = candidate.ingredients.map((ing) => ({ canonicalIngredientId: ing.canonicalIngredientId, quantity: ing.quantity, unit: ing.unit as import("@app/shared-types").Unit, optional: ing.optional, })); const nutritionResult = computeRecipeNutrition( calcIngredients, candidate.portions, nutritionSources, ); if (nutritionResult.uncomputableIngredientIds.length > 0) { reasons.push( `Näring kunde inte beräknas för: ${nutritionResult.uncomputableIngredientIds.join(", ")}`, ); } // ── 4. Språk-/kvalitetskontroll ────────────────────────────────────────── if (candidate.prepTimeMinutes > opts.maxPrepTimeMinutes) { reasons.push( `Förberedelsetid ${candidate.prepTimeMinutes} min överstiger max ${opts.maxPrepTimeMinutes} min.`, ); } if (candidate.cookTimeMinutes > opts.maxCookTimeMinutes) { reasons.push( `Koktid ${candidate.cookTimeMinutes} min överstiger max ${opts.maxCookTimeMinutes} min.`, ); } const totalTime = candidate.prepTimeMinutes + candidate.cookTimeMinutes; if (totalTime > opts.maxTotalTimeMinutes) { reasons.push(`Total tid ${totalTime} min överstiger max ${opts.maxTotalTimeMinutes} min.`); } if (candidate.steps.length < opts.minSteps) { reasons.push(`Endast ${candidate.steps.length} steg — minst ${opts.minSteps} krävs.`); } if (candidate.ingredients.length < opts.minIngredients) { reasons.push( `Endast ${candidate.ingredients.length} ingredienser — minst ${opts.minIngredients} krävs.`, ); } if (candidate.totalTimeMinutes !== totalTime) { reasons.push( `totalTimeMinutes (${candidate.totalTimeMinutes}) matchar inte prep+cook (${totalTime}).`, ); } // Kontrollera att stegen är rimliga for (const step of candidate.steps) { if (!step.instructionSv || step.instructionSv.length < 10) { reasons.push(`Steg ${step.stepNumber} har för kort instruktion.`); } if (step.timerSeconds != null && step.timerSeconds < 0) { reasons.push(`Steg ${step.stepNumber} har negativ timer.`); } if (step.temperatureC != null && (step.temperatureC < 0 || step.temperatureC > 350)) { reasons.push(`Steg ${step.stepNumber} har orimlig temperatur (${step.temperatureC}°C).`); } } // ── 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); if (isDup) { reasons.push(`Titel "${candidate.titleSv}" flaggad som potentiell dubblett.`); } } // ── Resultat ───────────────────────────────────────────────────────────── const status: VerificationResult["status"] = reasons.length === 0 ? "verified" : "unverified"; return { candidate, status, reasons, nutritionPerPortion: nutritionResult.perPortion, allergens, canonicalIngredientIds: canonicalIds, }; } /** * Kör en batch kandidater genom pipelinen. */ export async function verifyBatch( candidates: RecipeCandidate[], ingredients: CanonicalIngredientLookup, similarity: SimilarityLookup | null, options?: VerificationOptions, ): Promise { return Promise.all(candidates.map((c) => verifyCandidate(c, ingredients, similarity, options))); }