"""Filtrage des recettes : exclusion d'ingrédients (coco !) + scoring par préférences. Matching insensible à la casse ET aux accents : « Noix de Coco », « noix de coco rapée » et « creme de coco » matchent tous l'entrée « coco ». On compare des mots normalisés sur le nom des ingrédients, les allergènes, le nom et le titre de la recette. """ from __future__ import annotations import json import unicodedata from pathlib import Path from .auth import ROOT from .models import Recipe EXCLUDES_PATH = ROOT / "config" / "excludes.json" PREFS_PATH = ROOT / "config" / "prefs.json" def normalize(s: str) -> str: """Minuscule + suppression des accents (NFD → drop des diacritiques).""" s = unicodedata.normalize("NFD", s or "") s = "".join(c for c in s if unicodedata.category(c) != "Mn") return s.lower().strip() # --- gestion de la liste d'exclusion --------------------------------------- def load_excludes() -> list[str]: if not EXCLUDES_PATH.exists(): return [] data = json.loads(EXCLUDES_PATH.read_text(encoding="utf-8")) return list(data.get("exclude", [])) def save_excludes(terms: list[str]) -> None: existing = {} if EXCLUDES_PATH.exists(): existing = json.loads(EXCLUDES_PATH.read_text(encoding="utf-8")) existing["exclude"] = terms EXCLUDES_PATH.write_text(json.dumps(existing, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") def add_exclude(term: str) -> list[str]: terms = load_excludes() if normalize(term) not in {normalize(t) for t in terms}: terms.append(term) save_excludes(terms) return terms def remove_exclude(term: str) -> list[str]: nt = normalize(term) terms = [t for t in load_excludes() if normalize(t) != nt] save_excludes(terms) return terms def load_prefs() -> dict: if not PREFS_PATH.exists(): return {"liked": [], "disliked": [], "allow_premium": False} data = json.loads(PREFS_PATH.read_text(encoding="utf-8")) return { "liked": data.get("liked", []), "disliked": data.get("disliked", []), "allow_premium": bool(data.get("allow_premium", False)), } def save_prefs(liked: list[str], disliked: list[str]) -> dict: """Écrit liked/disliked en préservant le reste du fichier (commentaire, allow_premium).""" existing = {} if PREFS_PATH.exists(): existing = json.loads(PREFS_PATH.read_text(encoding="utf-8")) existing["liked"] = liked existing["disliked"] = disliked PREFS_PATH.write_text(json.dumps(existing, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") return {"liked": liked, "disliked": disliked} def load_allow_premium() -> bool: """Réglage : True si les recettes à supplément (premium) sont autorisées par défaut.""" return load_prefs()["allow_premium"] def save_allow_premium(value: bool) -> bool: """Persiste le réglage `allow_premium` en préservant le reste de prefs.json.""" existing = {} if PREFS_PATH.exists(): existing = json.loads(PREFS_PATH.read_text(encoding="utf-8")) existing["allow_premium"] = bool(value) PREFS_PATH.write_text(json.dumps(existing, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") return bool(value) # --- application aux recettes ---------------------------------------------- def _exclusion_haystack(recipe: Recipe) -> str: """Champs FAISANT FOI pour l'exclusion : ingrédients, allergènes, nom, accroche. On EXCLUT volontairement les `tags` : HelloFresh y pose des tags internes non affichés (ex. un tag `coconut` présent sur des dizaines de recettes sans coco), ce qui provoquait des faux positifs massifs. """ parts = [recipe.name, recipe.headline, *recipe.ingredients, *recipe.allergens] return normalize(" | ".join(p for p in parts if p)) def _pref_haystack(recipe: Recipe) -> str: """Pour le scoring de préférences, les tags sont utiles (catégories de cuisine…).""" parts = [recipe.name, recipe.headline, *recipe.ingredients, *recipe.tags] return normalize(" | ".join(p for p in parts if p)) def mark_excluded(recipe: Recipe, excludes: list[str] | None = None) -> Recipe: """Remplit `contains_excluded` et `matched_excludes` sur la recette.""" excludes = excludes if excludes is not None else load_excludes() hay = _exclusion_haystack(recipe) matched = [term for term in excludes if normalize(term) and normalize(term) in hay] recipe.matched_excludes = matched recipe.contains_excluded = bool(matched) return recipe def score(recipe: Recipe, prefs: dict | None = None) -> float: prefs = prefs or load_prefs() hay = _pref_haystack(recipe) s = 0.0 for kw in prefs.get("liked", []): if normalize(kw) and normalize(kw) in hay: s += 1.0 for kw in prefs.get("disliked", []): if normalize(kw) and normalize(kw) in hay: s -= 1.0 recipe.score = s return s def annotate(recipes: list[Recipe]) -> list[Recipe]: """Marque exclusions + score sur une liste de recettes (in place).""" excludes = load_excludes() prefs = load_prefs() for r in recipes: mark_excluded(r, excludes) score(r, prefs) return recipes def propose(recipes: list[Recipe], count: int | None = None, allow_premium: bool = False) -> list[Recipe]: """Retire les recettes exclues (coco…) et payantes, classe le reste par score décroissant. `allow_premium=True` conserve les recettes à supplément (hors abonnement) dans la liste. """ annotate(recipes) safe = [r for r in recipes if not r.contains_excluded and (allow_premium or not r.is_premium)] safe.sort(key=lambda r: r.score, reverse=True) return safe[:count] if count else safe