208 lines
6.3 KiB
TypeScript
208 lines
6.3 KiB
TypeScript
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<string, IngredientSafetyInfo>,
|
||
): 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<string, IngredientSafetyInfo>,
|
||
): Allergen[] {
|
||
const set = new Set<Allergen>();
|
||
for (const id of ingredientIds) {
|
||
const info = ingredientInfo.get(id);
|
||
for (const a of info?.allergens ?? []) set.add(a);
|
||
}
|
||
return [...set].sort();
|
||
}
|