- 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)
276 lines
9.7 KiB
Python
276 lines
9.7 KiB
Python
"""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>
|
||
"""
|