import type { Allergen, DietPattern, ReligiousRule } from "@app/shared-types"; /** * DETERMINISTISK säkerhets- och kostfiltrering (spec §13, §61.2). * Ingen AI får någonsin avgöra allergisäkerhet. Denna modul är enda vägen. * * Försiktighetsprincip: om en ingrediens saknar data ("unknown") behandlas * receptet som EJ säkert för berörda filter och flaggas för granskning. */ export interface IngredientSafetyInfo { id: string; allergens: Allergen[]; mayContainAllergens?: Allergen[]; isVegan: boolean; isVegetarian: boolean; containsGluten: boolean; containsLactose: boolean; isPork: boolean; isBeef: boolean; isAlcohol: boolean; /** true när datan är verifierad; ovverifierad data ger varning i stället för tyst OK. */ dataVerified?: boolean; } export interface DietaryConstraints { allergens: Allergen[]; intolerances?: string[]; dietPattern?: DietPattern; religiousRule?: ReligiousRule; avoidIngredientIds?: string[]; spiceLevelMax?: number; /** Behandla "kan innehålla spår av" som blockerande (default true vid allergi). */ blockMayContain?: boolean; } export type ViolationSeverity = "blocker" | "warning"; export interface SafetyViolation { severity: ViolationSeverity; code: | "allergen" | "allergen_may_contain" | "diet_pattern" | "religious_rule" | "avoided_ingredient" | "spice_level" | "unverified_data"; ingredientId?: string; allergen?: Allergen; messageSv: string; } export interface RecipeSafetyInput { ingredients: Array<{ canonicalIngredientId: string; optional: boolean }>; spiceLevel: number; } /** * Returnerar ALLA överträdelser (tom lista = säkert enligt tillgänglig data). * Valfria ingredienser ger varning i stället för blocker (kan uteslutas). */ export function checkRecipeSafety( recipe: RecipeSafetyInput, constraints: DietaryConstraints, ingredientInfo: Map, ): SafetyViolation[] { const violations: SafetyViolation[] = []; const userAllergens = new Set(constraints.allergens); const avoid = new Set(constraints.avoidIngredientIds ?? []); const blockMayContain = constraints.blockMayContain ?? userAllergens.size > 0; for (const ing of recipe.ingredients) { const info = ingredientInfo.get(ing.canonicalIngredientId); const severity: ViolationSeverity = ing.optional ? "warning" : "blocker"; if (!info) { violations.push({ severity: "warning", code: "unverified_data", ingredientId: ing.canonicalIngredientId, messageSv: `Ingrediensen ${ing.canonicalIngredientId} saknar säkerhetsdata – kontrollera manuellt.`, }); continue; } for (const allergen of info.allergens) { if (userAllergens.has(allergen)) { violations.push({ severity, code: "allergen", ingredientId: info.id, allergen, messageSv: `Innehåller ${allergen} (${info.id}).`, }); } } if (blockMayContain) { for (const allergen of info.mayContainAllergens ?? []) { if (userAllergens.has(allergen)) { violations.push({ severity: "warning", code: "allergen_may_contain", ingredientId: info.id, allergen, messageSv: `Kan innehålla spår av ${allergen} (${info.id}).`, }); } } } const diet = constraints.dietPattern; if (diet === "vegan" && !info.isVegan) { violations.push({ severity, code: "diet_pattern", ingredientId: info.id, messageSv: `${info.id} är inte veganskt.`, }); } else if ((diet === "vegetarian" || diet === "pescatarian") && !info.isVegetarian) { const isFish = info.allergens.includes("fish") || info.allergens.includes("crustaceans"); const allowed = diet === "pescatarian" && isFish; if (!allowed) { violations.push({ severity, code: "diet_pattern", ingredientId: info.id, messageSv: `${info.id} är inte ${diet === "vegetarian" ? "vegetariskt" : "pescetarianskt"}.`, }); } } const rule = constraints.religiousRule; if (rule === "halal" && (info.isPork || info.isAlcohol)) { violations.push({ severity, code: "religious_rule", ingredientId: info.id, messageSv: `${info.id} är inte förenligt med halal (${info.isPork ? "fläsk" : "alkohol"}).`, }); } if ( rule === "kosher" && (info.isPork || info.allergens.includes("crustaceans") || info.allergens.includes("molluscs")) ) { violations.push({ severity, code: "religious_rule", ingredientId: info.id, messageSv: `${info.id} är inte förenligt med kosher.`, }); } if (rule === "hindu_no_beef" && info.isBeef) { violations.push({ severity, code: "religious_rule", ingredientId: info.id, messageSv: `${info.id} innehåller nötkött.`, }); } if (rule === "buddhist_vegetarian" && !info.isVegetarian) { violations.push({ severity, code: "religious_rule", ingredientId: info.id, messageSv: `${info.id} är inte vegetariskt.`, }); } if (avoid.has(info.id)) { violations.push({ severity, code: "avoided_ingredient", ingredientId: info.id, messageSv: `${info.id} finns på din undviklista.`, }); } } if (constraints.spiceLevelMax != null && recipe.spiceLevel > constraints.spiceLevelMax) { violations.push({ severity: "warning", code: "spice_level", messageSv: `Styrka ${recipe.spiceLevel} överstiger din maxnivå ${constraints.spiceLevelMax}.`, }); } return violations; } /** true om receptet är helt fritt från blockers. */ export function isRecipeSafe(violations: SafetyViolation[]): boolean { return !violations.some((v) => v.severity === "blocker"); } /** Härled ett recepts allergener deterministiskt ur ingredienserna (spec §61.2). */ export function deriveRecipeAllergens( ingredientIds: string[], ingredientInfo: Map, ): Allergen[] { const set = new Set(); for (const id of ingredientIds) { const info = ingredientInfo.get(id); for (const a of info?.allergens ?? []) set.add(a); } return [...set].sort(); }