From 61ee7f02a4a79eabd2694b8c07f1f6f951457f25 Mon Sep 17 00:00:00 2001 From: jerem Date: Thu, 18 Jun 2026 18:07:12 +0200 Subject: [PATCH] =?UTF-8?q?UI=20web=20d'admin=20+=20garde-fou=20recettes?= =?UTF-8?q?=20premium=20(suppl=C3=A9ment=20hors=20abonnement)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- README.md | 20 +++- hellofresh/api.py | 8 ++ hellofresh/filter.py | 24 +++- hellofresh/models.py | 11 ++ hellofresh/webui.py | 275 +++++++++++++++++++++++++++++++++++++++++++ server.py | 30 ++++- 6 files changed, 358 insertions(+), 10 deletions(-) create mode 100644 hellofresh/webui.py diff --git a/README.md b/README.md index 651968b..9453471 100644 --- a/README.md +++ b/README.md @@ -35,9 +35,11 @@ intervention ni re-sync. Le navigateur headless ne sert plus que de filet de sec ``` Hermes ──HTTP──▶ server.py (FastMCP, :9200/mcp) + navigateur ──▶ server.py (UI admin, :9200/) ├─ hellofresh/auth.py session storage_state + refresh HTTP /gw/refresh ├─ hellofresh/api.py httpx : menu, détails, deliveries, PUT cart ├─ hellofresh/filter.py exclusion (coco !) + scoring préférences + ├─ hellofresh/webui.py page d'admin + API JSON (édition à chaud) └─ config/ excludes.json · prefs.json · endpoints.json ``` @@ -129,10 +131,24 @@ Enregistrer AntiCoco dans la config MCP de Hermes (côté homelab), URL | `hf_next_delivery()` | **prochaine box réellement sélectionnée** (≈4 recettes) + date/cutoff, images servables — prêt Telegram | | `hf_favorites()` | recettes **favorites** du compte (images servables) | | `hf_get_menu(week)` | toutes les recettes, avec `contains_excluded` et `is_favorite` | -| `hf_propose(week, count=0)` | shortlist **sans coco**, classée par préférences | -| `hf_confirm_selection(week, recipe_ids)` | **écrit** la sélection (refuse la coco) | +| `hf_propose(week, count=0)` | shortlist **sans coco ni premium**, classée par préférences | +| `hf_confirm_selection(week, recipe_ids, allow_premium=False)` | **écrit** la sélection (refuse coco **et** recettes payantes hors abonnement) | | `hf_get_excludes()` / `hf_add_exclude(term)` / `hf_remove_exclude(term)` | gérer la liste d'exclusion | +Les recettes premium (supplément, `chargeSetting` côté HelloFresh) portent `is_premium` / +`surcharge_eur` dans chaque réponse. `hf_confirm_selection` les refuse par défaut ; +`allow_premium=True` accepte sciemment le surcoût. + +## Interface web d'admin + +Sur le **même port que MCP** : `http://127.0.0.1:9200/` (MCP reste sur `/mcp`). +Édition à chaud (prise en compte au prochain appel, sans redémarrage) de : +- la **liste d'exclusion** (coco & co) ; +- les **préférences** de scoring `liked` / `disliked`. + +Le port est bindé sur `127.0.0.1` du homelab : y accéder via tunnel SSH +(`ssh -L 9200:127.0.0.1:9200 homelab`) puis ouvrir `http://localhost:9200/`. + ## Configuration - `config/excludes.json` — ingrédients bannis (matching insensible casse/accents). Coco déjà listée. diff --git a/hellofresh/api.py b/hellofresh/api.py index aff72e0..188121a 100644 --- a/hellofresh/api.py +++ b/hellofresh/api.py @@ -228,15 +228,23 @@ class HelloFreshClient: if not courses: return [] id_index: dict[str, int] = {} + charge_by_id: dict[str, dict] = {} ids: list[str] = [] for c in courses: rid = (c.get("recipe") or {}).get("id") if rid and rid not in id_index: id_index[rid] = c.get("index") + cs = c.get("chargeSetting") + if isinstance(cs, dict): + charge_by_id[rid] = cs ids.append(rid) recipes = self._fetch_details(ids) for r in recipes: r.course_index = id_index.get(r.id) + cs = charge_by_id.get(r.id) + if cs: + r.surcharge_cents = int(cs.get("amount") or 0) + r.surcharge_reason = str(cs.get("reason") or "") return _dedupe_by_name(recipes) def _fetch_details(self, ids: list[str]) -> list[Recipe]: diff --git a/hellofresh/filter.py b/hellofresh/filter.py index c93e0c1..f3da5c0 100644 --- a/hellofresh/filter.py +++ b/hellofresh/filter.py @@ -38,7 +38,7 @@ def save_excludes(terms: list[str]) -> None: 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), encoding="utf-8") + EXCLUDES_PATH.write_text(json.dumps(existing, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") def add_exclude(term: str) -> list[str]: @@ -63,6 +63,17 @@ def load_prefs() -> dict: 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. @@ -115,9 +126,14 @@ def annotate(recipes: list[Recipe]) -> list[Recipe]: return recipes -def propose(recipes: list[Recipe], count: int | None = None) -> list[Recipe]: - """Retire les recettes exclues (coco…) et classe le reste par score décroissant.""" +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] + 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 diff --git a/hellofresh/models.py b/hellofresh/models.py index fb59d7d..db4d724 100644 --- a/hellofresh/models.py +++ b/hellofresh/models.py @@ -52,12 +52,20 @@ class Recipe: image_url: str = "" prep_time: str = "" course_index: int | None = None # index du course dans le menu (sert à l'écriture) + # Supplément hors abonnement (rempli depuis le menu) : 0 = inclus, >0 = surcoût en centimes + surcharge_cents: int = 0 + surcharge_reason: str = "" # ex. "premium" (motif renvoyé par chargeSetting) # Champs calculés par le filtre (remplis plus tard) contains_excluded: bool = False matched_excludes: list[str] = field(default_factory=list) score: float = 0.0 is_favorite: bool = False # rempli par api (best-effort) depuis le service favoris + @property + def is_premium(self) -> bool: + """True si la recette coûte un supplément (non incluse dans l'abonnement).""" + return self.surcharge_cents > 0 + @classmethod def from_api(cls, raw: dict[str, Any]) -> "Recipe": """Construit une Recipe depuis un objet recette brut de l'API gateway. @@ -123,6 +131,9 @@ class Recipe: "image_url": fix_image_url(self.image_url), "prep_time": self.prep_time, "prep_minutes": _iso_duration_to_minutes(self.prep_time), + "is_premium": self.is_premium, + "surcharge_eur": round(self.surcharge_cents / 100, 2) if self.surcharge_cents else 0, + "surcharge_reason": self.surcharge_reason, "allergens": self.allergens, "contains_excluded": self.contains_excluded, "matched_excludes": self.matched_excludes, diff --git a/hellofresh/webui.py b/hellofresh/webui.py new file mode 100644 index 0000000..5a5fcc2 --- /dev/null +++ b/hellofresh/webui.py @@ -0,0 +1,275 @@ +"""Petite interface web d'administration, greffée sur le serveur FastMCP (même port). + +Permet d'éditer à la main les paramètres « métier » sans rebuild ni édition JSON : + - la liste des ingrédients à exclure (coco & co) ; + - les préférences de scoring (liked / disliked). + +Les écritures passent par `hellofresh.filter` (mêmes fichiers que le serveur MCP), +donc le serveur prend en compte les changements au prochain appel — pas de redémarrage. +Routes enregistrées via `register(mcp)` depuis server.py : + GET / -> page HTML + GET /api/config -> {excludes, prefs} + PUT /api/excludes-> sauve la liste d'exclusion + PUT /api/prefs -> sauve liked/disliked +""" + +from __future__ import annotations + +from starlette.requests import Request +from starlette.responses import HTMLResponse, JSONResponse, Response + +from . import filter as hf_filter + + +def _clean_list(value) -> list[str]: + """Normalise une entrée en liste de chaînes non vides, dédupliquées (ordre conservé).""" + if not isinstance(value, list): + return [] + out: list[str] = [] + seen: set[str] = set() + for v in value: + s = str(v).strip() + key = hf_filter.normalize(s) + if s and key not in seen: + seen.add(key) + out.append(s) + return out + + +async def _config(request: Request) -> Response: + return JSONResponse({ + "excludes": hf_filter.load_excludes(), + "prefs": hf_filter.load_prefs(), + }) + + +async def _put_excludes(request: Request) -> Response: + try: + body = await request.json() + except Exception: + return JSONResponse({"error": "JSON invalide"}, status_code=400) + terms = _clean_list(body.get("exclude")) + hf_filter.save_excludes(terms) + return JSONResponse({"exclude": terms}) + + +async def _put_prefs(request: Request) -> Response: + try: + body = await request.json() + except Exception: + return JSONResponse({"error": "JSON invalide"}, status_code=400) + liked = _clean_list(body.get("liked")) + disliked = _clean_list(body.get("disliked")) + saved = hf_filter.save_prefs(liked, disliked) + return JSONResponse(saved) + + +async def _index(request: Request) -> Response: + return HTMLResponse(_PAGE) + + +def register(mcp) -> None: + """Enregistre les routes web sur le serveur FastMCP.""" + mcp.custom_route("/", methods=["GET"], include_in_schema=False)(_index) + mcp.custom_route("/api/config", methods=["GET"], include_in_schema=False)(_config) + mcp.custom_route("/api/excludes", methods=["PUT"], include_in_schema=False)(_put_excludes) + mcp.custom_route("/api/prefs", methods=["PUT"], include_in_schema=False)(_put_prefs) + + +_PAGE = """ + + + + +AntiCoco — réglages + + + +
+
+

🥥🚫 AntiCoco

+ réglages — modifiés à chaud, pris en compte au prochain appel MCP +
+ +
+

Ingrédients à exclure

+

Toute recette contenant l'un de ces termes (nom d'ingrédient ou allergène, sans accent ni casse) est refusée à la sélection.

+
+
+ + +
+
+ +
+

Préférences de scoring

+

Mots-clés cherchés dans le nom, le titre et les ingrédients. « Aimé » remonte la recette dans les propositions, « pas aimé » la fait descendre (sans l'exclure).

+
+
+ 👍 Aimé +
+
+ + +
+
+
+ 👎 Pas aimé +
+
+ + +
+
+
+
+
+
+ + + + +""" diff --git a/server.py b/server.py index 681827a..745b839 100644 --- a/server.py +++ b/server.py @@ -20,11 +20,15 @@ from mcp.server.fastmcp import FastMCP load_dotenv() -from hellofresh import api, auth, filter as hf_filter # noqa: E402 +from hellofresh import api, auth, filter as hf_filter, webui # noqa: E402 PORT = int(os.environ.get("ANTICOCO_PORT", "9200")) mcp = FastMCP("AntiCoco", host="0.0.0.0", port=PORT) +# Interface web d'admin (édition à chaud des excludes/préférences) sur le même port. +# MCP reste servi sur /mcp ; l'UI est sur / (cf. hellofresh/webui.py). +webui.register(mcp) + @mcp.tool() async def hf_auth_status() -> dict: @@ -159,21 +163,26 @@ async def hf_propose(week: str = "", count: int = 0) -> dict: recipes = client.get_menu(w) safe = hf_filter.propose(recipes, count=count or None) excluded = [r.summary() for r in recipes if r.contains_excluded] + premium = [r.summary() for r in recipes if r.is_premium and not r.contains_excluded] return { "week": w, "proposed": [r.summary() for r in safe], "excluded_for_coco_etc": excluded, - "note": "Aucune écriture effectuée. Confirme avec hf_confirm_selection(week, recipe_ids).", + "premium_extra_cost": premium, + "note": "Aucune écriture effectuée. Les recettes premium (supplément) sont exclues " + "de la proposition. Confirme avec hf_confirm_selection(week, recipe_ids).", } return await anyio.to_thread.run_sync(_impl) @mcp.tool() -async def hf_confirm_selection(week: str, recipe_ids: list[str], dry_run: bool = False) -> dict: +async def hf_confirm_selection(week: str, recipe_ids: list[str], dry_run: bool = False, + allow_premium: bool = False) -> dict: """ÉCRIT la sélection de recettes dans la box de la semaine (après confirmation). - Garde-fou : refuse toute recette contenant un ingrédient exclu (coco !). + Garde-fous : refuse toute recette contenant un ingrédient exclu (coco !) ET toute + recette payante hors abonnement (premium, supplément) sauf si `allow_premium=True`. `dry_run=True` : construit et renvoie la requête sans l'envoyer (vérification). """ def _impl() -> dict: @@ -188,6 +197,19 @@ async def hf_confirm_selection(week: str, recipe_ids: list[str], dry_run: bool = "error": "Sélection refusée : recette(s) avec ingrédient exclu (coco ?).", "offending_ids": bad, } + premium = [rid for rid in recipe_ids + if rid in by_id and by_id[rid].is_premium] + if premium and not allow_premium: + return { + "ok": False, + "error": "Sélection refusée : recette(s) payante(s) hors abonnement (supplément). " + "Repasse allow_premium=True pour accepter le surcoût.", + "premium_recipes": [ + {"id": rid, "name": by_id[rid].name, + "surcharge_eur": round(by_id[rid].surcharge_cents / 100, 2)} + for rid in premium + ], + } unknown = [rid for rid in recipe_ids if rid not in by_id] if unknown: return {"ok": False, "error": "Recette(s) inconnue(s) pour cette semaine.",