Files
AntiCoco/hellofresh/webui.py
jerem 61ee7f02a4 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)
2026-06-18 18:07:12 +02:00

276 lines
9.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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>
"""