Files
Cibello-app/packages/recipe-engine/src/safety.ts
T
2026-08-05 19:21:11 +07:00

208 lines
6.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
}