- Refus des recettes payantes (chargeSetting) à la sélection, override allow_premium - Recipe.surcharge_cents/is_premium exposés dans summary(); propose() les exclut - hellofresh/webui.py : page d'admin + API JSON montées sur FastMCP (/, /api/*) édition à chaud des excludes et préférences (liked/disliked)
140 lines
5.0 KiB
Python
140 lines
5.0 KiB
Python
"""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": []}
|
|
data = json.loads(PREFS_PATH.read_text(encoding="utf-8"))
|
|
return {"liked": data.get("liked", []), "disliked": data.get("disliked", [])}
|
|
|
|
|
|
def save_prefs(liked: list[str], disliked: list[str]) -> dict:
|
|
"""Écrit liked/disliked en préservant le commentaire éventuel du fichier."""
|
|
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}
|
|
|
|
|
|
# --- 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
|