UI web d'admin + garde-fou recettes premium (supplément hors abonnement)

- 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)
This commit is contained in:
jerem
2026-06-18 18:07:12 +02:00
parent a14ce4664b
commit 61ee7f02a4
6 changed files with 358 additions and 10 deletions

View File

@@ -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) Hermes ──HTTP──▶ server.py (FastMCP, :9200/mcp)
navigateur ──▶ server.py (UI admin, :9200/)
├─ hellofresh/auth.py session storage_state + refresh HTTP /gw/refresh ├─ hellofresh/auth.py session storage_state + refresh HTTP /gw/refresh
├─ hellofresh/api.py httpx : menu, détails, deliveries, PUT cart ├─ hellofresh/api.py httpx : menu, détails, deliveries, PUT cart
├─ hellofresh/filter.py exclusion (coco !) + scoring préférences ├─ 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 └─ 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_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_favorites()` | recettes **favorites** du compte (images servables) |
| `hf_get_menu(week)` | toutes les recettes, avec `contains_excluded` et `is_favorite` | | `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_propose(week, count=0)` | shortlist **sans coco ni premium**, classée par préférences |
| `hf_confirm_selection(week, recipe_ids)` | **écrit** la sélection (refuse la coco) | | `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 | | `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 ## Configuration
- `config/excludes.json` — ingrédients bannis (matching insensible casse/accents). Coco déjà listée. - `config/excludes.json` — ingrédients bannis (matching insensible casse/accents). Coco déjà listée.

View File

@@ -228,15 +228,23 @@ class HelloFreshClient:
if not courses: if not courses:
return [] return []
id_index: dict[str, int] = {} id_index: dict[str, int] = {}
charge_by_id: dict[str, dict] = {}
ids: list[str] = [] ids: list[str] = []
for c in courses: for c in courses:
rid = (c.get("recipe") or {}).get("id") rid = (c.get("recipe") or {}).get("id")
if rid and rid not in id_index: if rid and rid not in id_index:
id_index[rid] = c.get("index") id_index[rid] = c.get("index")
cs = c.get("chargeSetting")
if isinstance(cs, dict):
charge_by_id[rid] = cs
ids.append(rid) ids.append(rid)
recipes = self._fetch_details(ids) recipes = self._fetch_details(ids)
for r in recipes: for r in recipes:
r.course_index = id_index.get(r.id) 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) return _dedupe_by_name(recipes)
def _fetch_details(self, ids: list[str]) -> list[Recipe]: def _fetch_details(self, ids: list[str]) -> list[Recipe]:

View File

@@ -38,7 +38,7 @@ def save_excludes(terms: list[str]) -> None:
if EXCLUDES_PATH.exists(): if EXCLUDES_PATH.exists():
existing = json.loads(EXCLUDES_PATH.read_text(encoding="utf-8")) existing = json.loads(EXCLUDES_PATH.read_text(encoding="utf-8"))
existing["exclude"] = terms 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]: def add_exclude(term: str) -> list[str]:
@@ -63,6 +63,17 @@ def load_prefs() -> dict:
return {"liked": data.get("liked", []), "disliked": data.get("disliked", [])} 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 ---------------------------------------------- # --- application aux recettes ----------------------------------------------
def _exclusion_haystack(recipe: Recipe) -> str: def _exclusion_haystack(recipe: Recipe) -> str:
"""Champs FAISANT FOI pour l'exclusion : ingrédients, allergènes, nom, accroche. """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 return recipes
def propose(recipes: list[Recipe], count: int | None = None) -> list[Recipe]: def propose(recipes: list[Recipe], count: int | None = None,
"""Retire les recettes exclues (coco…) et classe le reste par score décroissant.""" 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) 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) safe.sort(key=lambda r: r.score, reverse=True)
return safe[:count] if count else safe return safe[:count] if count else safe

View File

@@ -52,12 +52,20 @@ class Recipe:
image_url: str = "" image_url: str = ""
prep_time: str = "" prep_time: str = ""
course_index: int | None = None # index du course dans le menu (sert à l'écriture) 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) # Champs calculés par le filtre (remplis plus tard)
contains_excluded: bool = False contains_excluded: bool = False
matched_excludes: list[str] = field(default_factory=list) matched_excludes: list[str] = field(default_factory=list)
score: float = 0.0 score: float = 0.0
is_favorite: bool = False # rempli par api (best-effort) depuis le service favoris 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 @classmethod
def from_api(cls, raw: dict[str, Any]) -> "Recipe": def from_api(cls, raw: dict[str, Any]) -> "Recipe":
"""Construit une Recipe depuis un objet recette brut de l'API gateway. """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), "image_url": fix_image_url(self.image_url),
"prep_time": self.prep_time, "prep_time": self.prep_time,
"prep_minutes": _iso_duration_to_minutes(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, "allergens": self.allergens,
"contains_excluded": self.contains_excluded, "contains_excluded": self.contains_excluded,
"matched_excludes": self.matched_excludes, "matched_excludes": self.matched_excludes,

275
hellofresh/webui.py Normal file
View File

@@ -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 = """<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>AntiCoco — réglages</title>
<style>
:root {
--bg: #0f1115; --card: #181b22; --line: #272b34; --fg: #e6e8ec;
--muted: #8b909c; --accent: #4ade80; --danger: #f87171; --chip: #232732;
}
* { box-sizing: border-box; }
body {
margin: 0; font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: var(--bg); color: var(--fg); padding: 32px 16px;
}
.wrap { max-width: 760px; margin: 0 auto; }
header { display: flex; align-items: baseline; gap: 12px; margin-bottom: 24px; }
h1 { font-size: 22px; margin: 0; letter-spacing: -.3px; }
header span { color: var(--muted); font-size: 13px; }
.card {
background: var(--card); border: 1px solid var(--line); border-radius: 14px;
padding: 20px 22px; margin-bottom: 18px;
}
.card h2 { font-size: 15px; margin: 0 0 4px; }
.card p { color: var(--muted); font-size: 13px; margin: 0 0 14px; }
.chips { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; }
.chip {
display: inline-flex; align-items: center; gap: 6px; background: var(--chip);
border: 1px solid var(--line); border-radius: 999px; padding: 5px 6px 5px 12px;
font-size: 13px;
}
.chip button {
border: 0; background: transparent; color: var(--muted); cursor: pointer;
font-size: 15px; line-height: 1; padding: 0 4px; border-radius: 50%;
}
.chip button:hover { color: var(--danger); }
.empty { color: var(--muted); font-size: 13px; font-style: italic; }
.addrow { display: flex; gap: 8px; }
input[type=text] {
flex: 1; background: var(--bg); border: 1px solid var(--line); color: var(--fg);
border-radius: 9px; padding: 9px 12px; font-size: 14px; outline: none;
}
input[type=text]:focus { border-color: var(--accent); }
.addrow button {
background: var(--accent); color: #06210f; border: 0; border-radius: 9px;
padding: 0 16px; font-weight: 600; cursor: pointer; font-size: 14px;
}
.cols { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
@media (max-width: 560px) { .cols { grid-template-columns: 1fr; } }
.toast {
position: fixed; bottom: 22px; left: 50%; transform: translateX(-50%) translateY(20px);
background: var(--card); border: 1px solid var(--line); color: var(--fg);
padding: 10px 18px; border-radius: 10px; font-size: 14px; opacity: 0;
transition: all .25s; pointer-events: none;
}
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
.toast.err { border-color: var(--danger); color: var(--danger); }
</style>
</head>
<body>
<div class="wrap">
<header>
<h1>🥥🚫 AntiCoco</h1>
<span>réglages — modifiés à chaud, pris en compte au prochain appel MCP</span>
</header>
<div class="card">
<h2>Ingrédients à exclure</h2>
<p>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.</p>
<div class="chips" id="excludes-chips"></div>
<div class="addrow">
<input type="text" id="excludes-input" placeholder="ex. coco, arachide…" autocomplete="off">
<button onclick="addItem('excludes')">Ajouter</button>
</div>
</div>
<div class="card">
<h2>Préférences de scoring</h2>
<p>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).</p>
<div class="cols">
<div>
<strong style="font-size:13px;color:var(--accent)">👍 Aimé</strong>
<div class="chips" id="liked-chips" style="margin-top:10px"></div>
<div class="addrow">
<input type="text" id="liked-input" placeholder="ex. poulet…" autocomplete="off">
<button onclick="addItem('liked')">+</button>
</div>
</div>
<div>
<strong style="font-size:13px;color:var(--danger)">👎 Pas aimé</strong>
<div class="chips" id="disliked-chips" style="margin-top:10px"></div>
<div class="addrow">
<input type="text" id="disliked-input" placeholder="ex. betterave…" autocomplete="off">
<button onclick="addItem('disliked')">+</button>
</div>
</div>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
const state = { excludes: [], liked: [], disliked: [] };
function toast(msg, isErr) {
const t = document.getElementById('toast');
t.textContent = msg;
t.className = 'toast show' + (isErr ? ' err' : '');
clearTimeout(t._tm);
t._tm = setTimeout(() => { t.className = 'toast'; }, 1800);
}
function render(key) {
const box = document.getElementById(key + '-chips');
const list = state[key];
box.innerHTML = '';
if (!list.length) {
box.innerHTML = '<span class="empty">aucun</span>';
return;
}
for (const term of list) {
const chip = document.createElement('span');
chip.className = 'chip';
chip.append(document.createTextNode(term));
const btn = document.createElement('button');
btn.textContent = '×';
btn.title = 'Retirer';
btn.onclick = () => removeItem(key, term);
chip.append(btn);
box.append(chip);
}
}
async function save(key) {
let url, payload;
if (key === 'excludes') {
url = '/api/excludes'; payload = { exclude: state.excludes };
} else {
url = '/api/prefs'; payload = { liked: state.liked, disliked: state.disliked };
}
try {
const r = await fetch(url, {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!r.ok) throw new Error(await r.text());
const data = await r.json();
if (key === 'excludes') { state.excludes = data.exclude; render('excludes'); }
else { state.liked = data.liked; state.disliked = data.disliked; render('liked'); render('disliked'); }
toast('Enregistré ✓');
} catch (e) {
toast('Erreur : ' + e.message, true);
}
}
function addItem(key) {
const inputId = (key === 'excludes') ? 'excludes-input' : key + '-input';
const input = document.getElementById(inputId);
const val = input.value.trim();
if (!val) return;
if (!state[key].some(x => x.toLowerCase() === val.toLowerCase())) {
state[key].push(val);
}
input.value = '';
save(key === 'excludes' ? 'excludes' : 'prefs');
}
function removeItem(key, term) {
state[key] = state[key].filter(x => x !== term);
save(key === 'excludes' ? 'excludes' : 'prefs');
}
// Entrée = ajouter
for (const id of ['excludes-input', 'liked-input', 'disliked-input']) {
document.getElementById(id).addEventListener('keydown', e => {
if (e.key === 'Enter') { e.preventDefault(); addItem(id.replace('-input', '')); }
});
}
async function load() {
try {
const r = await fetch('/api/config');
const cfg = await r.json();
state.excludes = cfg.excludes || [];
state.liked = (cfg.prefs && cfg.prefs.liked) || [];
state.disliked = (cfg.prefs && cfg.prefs.disliked) || [];
render('excludes'); render('liked'); render('disliked');
} catch (e) {
toast('Chargement impossible : ' + e.message, true);
}
}
load();
</script>
</body>
</html>
"""

View File

@@ -20,11 +20,15 @@ from mcp.server.fastmcp import FastMCP
load_dotenv() 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")) PORT = int(os.environ.get("ANTICOCO_PORT", "9200"))
mcp = FastMCP("AntiCoco", host="0.0.0.0", port=PORT) 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() @mcp.tool()
async def hf_auth_status() -> dict: 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) recipes = client.get_menu(w)
safe = hf_filter.propose(recipes, count=count or None) safe = hf_filter.propose(recipes, count=count or None)
excluded = [r.summary() for r in recipes if r.contains_excluded] 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 { return {
"week": w, "week": w,
"proposed": [r.summary() for r in safe], "proposed": [r.summary() for r in safe],
"excluded_for_coco_etc": excluded, "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) return await anyio.to_thread.run_sync(_impl)
@mcp.tool() @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). """É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). `dry_run=True` : construit et renvoie la requête sans l'envoyer (vérification).
""" """
def _impl() -> dict: 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 ?).", "error": "Sélection refusée : recette(s) avec ingrédient exclu (coco ?).",
"offending_ids": bad, "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] unknown = [rid for rid in recipe_ids if rid not in by_id]
if unknown: if unknown:
return {"ok": False, "error": "Recette(s) inconnue(s) pour cette semaine.", return {"ok": False, "error": "Recette(s) inconnue(s) pour cette semaine.",