Files
AntiCoco/hellofresh/filter.py
Jerem 29ac984113 UI web : statut de connexion HelloFresh + checkbox recettes premium
- Carte « Connexion HelloFresh » (pastille + bouton Rafraîchir) via un nouvel
  endpoint GET /api/auth-status (auth.auth_status, vérifié contre l'API, déporté
  dans un thread pour ne pas figer la boucle asyncio).
- Checkbox « Recettes premium » : réglage persistant allow_premium dans
  config/prefs.json (load/save_allow_premium dans filter.py), exposé par
  /api/config et piloté par PUT /api/allow-premium.
- Le réglage devient le défaut côté MCP : hf_propose inclut/écarte les premium
  selon la case (le signale dans allow_premium/note), hf_confirm_selection
  reprend ce défaut quand allow_premium n'est pas passé explicitement.
- .dockerignore ajouté.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 20:23:44 +02:00

159 lines
5.7 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": [], "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