diff --git a/src/angebote/web.py b/src/angebote/web.py index 50138e0..5ead334 100644 --- a/src/angebote/web.py +++ b/src/angebote/web.py @@ -225,6 +225,76 @@ def api_status(job_id: str) -> dict: return job +# === Manuelle Korrektur -> Produkt-Cache (die hochwertigste Cache-Quelle) ===== + + +@app.post("/api/korrektur") +def api_korrektur(req: dict) -> dict: + """Setzt die Produktgruppe eines Produkts manuell und dauerhaft. + + Schnitt-konform: betrifft NUR die Kategorie (Cache-Seite), nie Angebotsdaten; + ruft KEIN LLM und fetcht nicht. Geschlossene Liste wird erzwungen. Die + Zuordnung (modell='manuell') überschreibt im Produkt-Cache jeden früheren + LLM-Wert -- dieses Produkt ist damit künftig sicher und LLM-frei. + """ + from .config import PRODUKTGRUPPEN + from .produktcache import ProduktCache, produkt_schluessel + + titel = (req.get("titel") or "").strip() + marke = req.get("marke") # darf None/"" sein -- Teil der Produkt-Identität + gruppe = (req.get("gruppe") or "").strip() + + if not titel: + raise HTTPException(status_code=400, detail="Titel fehlt") + if gruppe not in PRODUKTGRUPPEN: + raise HTTPException( + status_code=400, + detail=f"Unbekannte Produktgruppe '{gruppe}' -- nur die geschlossene " + "Liste ist erlaubt.", + ) + + schluessel = produkt_schluessel(titel, marke) + geschrieben = ProduktCache().schreibe_viele([(schluessel, gruppe, "manuell")]) + + plz = (req.get("plz") or "").strip() + if plz: + _patche_ergebnis_cache(plz, schluessel, gruppe) + + return {"schluessel": schluessel, "gruppe": gruppe, "gespeichert": bool(geschrieben)} + + +def _patche_ergebnis_cache(plz: str, schluessel: str, ziel_gruppe: str) -> None: + """Hält die zwischengespeicherte UI-Struktur konsistent mit der Korrektur: + hängt alle Angebote mit passendem Produkt-Schlüssel in die Zielgruppe um, + setzt unsicher=False und aktualisiert Counts. Verschiebt nur die Kategorie -- + keine Neukategorisierung, kein Fetch.""" + from .produktcache import produkt_schluessel + + for key, erg in _ergebnis_cache.items(): + if key[0] != plz: + continue + gruppen = erg.get("gruppen", []) + zielblock = next((g for g in gruppen if g["name"] == ziel_gruppe), None) + if zielblock is None: + continue + bewegt = [] + for block in gruppen: + bleibt = [] + for a in block["angebote"]: + if produkt_schluessel(a["titel"], a.get("marke")) == schluessel: + a["unsicher"] = False + bewegt.append(a) + else: + bleibt.append(a) + block["angebote"] = bleibt + zielblock["angebote"].extend(bewegt) + for g in gruppen: + g["anzahl"] = len(g["angebote"]) + erg["unsicher"] = sum( + 1 for g in gruppen for a in g["angebote"] if a.get("unsicher") + ) + + def _run_kategorisieren(job_id, plz, fetch, modell, anbieter, key) -> None: """LLM-Schritt im Hintergrund. Arbeitet auf den geladenen Rohdaten. diff --git a/src/angebote/web_static/index.html b/src/angebote/web_static/index.html index d763e8d..ee1c1e3 100644 --- a/src/angebote/web_static/index.html +++ b/src/angebote/web_static/index.html @@ -202,6 +202,43 @@ footer.note .hinweis { display:block; margin-top:6px; font-style:italic; } .sep { color:var(--faint); } + + /* --- Ergebnis-Layout: Sidebar + sticky Header --- */ + .ergebnis-layout { display:grid; grid-template-columns:248px minmax(0,1fr); gap:var(--s6); align-items:start; } + aside.sidebar { + position:sticky; top:var(--s4); align-self:start; max-height:calc(100vh - var(--s8)); overflow:auto; + background:var(--panel); border:1px solid var(--line); border-radius:var(--radius); box-shadow:var(--shadow-sm); padding:var(--s3); + } + .sidebar-head { font-size:11px; text-transform:uppercase; letter-spacing:.06em; color:var(--muted); font-weight:700; padding:var(--s2) var(--s3) var(--s3); } + nav#gruppennav { display:flex; flex-direction:column; gap:2px; } + .navitem { + display:flex; justify-content:space-between; align-items:center; gap:var(--s3); + width:100%; text-align:left; padding:8px var(--s3); border-radius:var(--radius-sm); + background:transparent; color:var(--ink-2); border:1px solid transparent; font-weight:500; font-size:14px; min-height:0; cursor:pointer; + transition:background .15s, color .15s; + } + .navitem:hover { background:var(--panel-2); transform:none; box-shadow:none; color:var(--ink); } + .navitem.aktiv { background:var(--brand-weak); color:var(--brand-d); border-color:#cfe3d9; font-weight:600; } + .navitem .nav-cnt { font-variant-numeric:tabular-nums; color:var(--muted); font-size:13px; } + .navitem .nav-uns { font-size:11px; color:var(--warn); border:1px solid var(--warn-line); background:var(--warn-bg); border-radius:5px; padding:0 6px; margin-left:6px; } + header.ergebnis-header { position:sticky; top:0; z-index:5; background:var(--bg); padding-top:var(--s2); padding-bottom:var(--s3); margin-bottom:var(--s4); border-bottom:1px solid var(--line); } + header.ergebnis-header .summary { margin-bottom:var(--s3); } + header.ergebnis-header .filters { margin-bottom:0; } + .kfeedback { font-size:13px; color:var(--brand-d); font-weight:600; height:0; overflow:hidden; transition:height .2s; } + .kfeedback.an { height:22px; margin-top:var(--s2); } + + /* Kategorie-Chip pro Angebot (zugleich Korrektur-Anker) */ + .ang .gchip { display:inline-block; font-size:11px; color:var(--muted); background:var(--panel-2); border:1px solid var(--line); border-radius:5px; padding:0 7px; margin-left:7px; cursor:pointer; vertical-align:middle; } + .ang .gchip:hover { border-color:var(--brand); color:var(--brand-d); } + .ang.unsicher .gchip { border-color:var(--warn-line); background:var(--warn-bg); color:var(--warn); } + .ang .ksel { font-size:12px; padding:2px 6px; border:1px solid var(--brand); border-radius:6px; margin-left:7px; vertical-align:middle; } + + @media (max-width:860px){ + .ergebnis-layout { grid-template-columns:1fr; } + aside.sidebar { position:static; max-height:none; order:-1; } + nav#gruppennav { flex-direction:row; flex-wrap:wrap; } + .navitem { width:auto; } + } @@ -311,14 +348,25 @@ @@ -553,22 +601,30 @@ function render(){ const modellTxt = d.modell ? ` · kategorisiert mit ${esc(d.modell)}${d.anbieter?` (${esc(d.anbieter)})`:""}` : ""; + const cacheTxt = (d.aus_cache != null) + ? ` · ${d.aus_cache} aus Cache · ${d.neu} neu` : ""; $("#summary").innerHTML = `Ort ${esc(d.ort_name || d.ort_plz)} (PLZ ${esc(d.ort_plz)}) · ${d.anzahl} Angebote · - Quellen: ${d.quellen.map(esc).join(", ") || "—"} · ${d.unsicher} unsicher${modellTxt}`; + Quellen: ${d.quellen.map(esc).join(", ") || "—"} · ${d.unsicher} unsicher${cacheTxt}${modellTxt}`; const fh = $("#fhaendler"); const aktuell = fh.value; fh.innerHTML = "" + d.haendler.map(h => ``).join(""); fh.value = aktuell; - zeichneGruppen(); + zeichneGruppen(); // baut Sektionen + ruft baueSidebar mit den gefilterten Counts $("#footer").innerHTML = `Beobachtete Händler (belegt): ${d.haendler.map(esc).join(", ")}.` + d.hinweise.map(h => `${esc(h)}`).join(""); } +function passt(a, fH, fT, nurSicher){ + return (!fH || a.haendler === fH) && + (!nurSicher || !a.unsicher) && + (!fT || (a.titel + " " + (a.marke||"")).toLowerCase().includes(fT)); +} + function zeichneGruppen(){ const d = DATEN; const fH = $("#fhaendler").value; @@ -576,15 +632,13 @@ function zeichneGruppen(){ const nurSicher = $("#fsicher").checked; const box = $("#gruppen"); box.innerHTML = ""; + const navDaten = []; for (const g of d.gruppen){ - const items = g.angebote.filter(a => - (!fH || a.haendler === fH) && - (!nurSicher || !a.unsicher) && - (!fT || (a.titel + " " + (a.marke||"")).toLowerCase().includes(fT)) - ); + const items = g.angebote.filter(a => passt(a, fH, fT, nurSicher)); const sec = document.createElement("section"); sec.className = "grp"; + sec.dataset.grp = g.name; const h = document.createElement("h3"); h.innerHTML = `${esc(g.name)}${items.length}`; h.onclick = () => sec.classList.toggle("zu"); @@ -595,14 +649,39 @@ function zeichneGruppen(){ sec.appendChild(p); } else { const ul = document.createElement("ul"); - for (const a of items) ul.appendChild(zeile(a)); + for (const a of items) ul.appendChild(zeile(a, g.name)); sec.appendChild(ul); + navDaten.push({ name:g.name, count:items.length, uns:items.filter(a=>a.unsicher).length }); } box.appendChild(sec); } + baueSidebar(navDaten); } -function zeile(a){ +function baueSidebar(navDaten){ + const nav = $("#gruppennav"); + nav.innerHTML = ""; + if (!navDaten.length){ nav.innerHTML = ``; return; } + for (const g of navDaten){ + const b = document.createElement("button"); + b.className = "navitem"; b.dataset.grp = g.name; + const uns = g.uns ? `${g.uns}` : ""; + b.innerHTML = `${esc(g.name)}${g.count}${uns}`; + b.onclick = () => springeZu(g.name); + nav.appendChild(b); + } +} + +function springeZu(name){ + let ziel = null; + for (const sec of document.querySelectorAll("#gruppen section.grp")){ + if (sec.dataset.grp === name){ ziel = sec; break; } + } + if (ziel){ ziel.classList.remove("zu"); ziel.scrollIntoView({behavior:"smooth", block:"start"}); } + for (const b of document.querySelectorAll(".navitem")) b.classList.toggle("aktiv", b.dataset.grp === name); +} + +function zeile(a, gruppe){ const li = document.createElement("li"); li.className = "ang" + (a.unsicher ? " unsicher" : ""); const marke = a.marke ? `${esc(a.marke)} ` : ""; @@ -611,14 +690,64 @@ function zeile(a){ ? ` · gültig ${a.gueltig_von||"?"}–${a.gueltig_bis||"?"}` : ""; const badge = a.unsicher ? `unsicher` : ""; li.innerHTML = - `
${marke}${esc(a.titel)}${badge}
` + + `
${marke}${esc(a.titel)}${badge}${esc(gruppe)}
` + `
${preisFmt(a.preis)}
` + `
${esc(a.haendler)}${menge}${gueltig}  ${esc((a.quelle||"").slice(0,42))}
` + `
${a.grundpreis ? esc(a.grundpreis) : ""}
`; + const chip = li.querySelector(".gchip"); + chip.onclick = () => oeffneKorrektur(chip, a, gruppe); return li; } +// ----- Korrektur: Produktgruppe manuell setzen -> Produkt-Cache -------------- +function oeffneKorrektur(chip, a, vonGruppe){ + const sel = document.createElement("select"); + sel.className = "ksel"; + for (const g of DATEN.gruppen.map(x => x.name)){ + const o = document.createElement("option"); + o.value = g; o.textContent = g; + if (g === vonGruppe) o.selected = true; + sel.appendChild(o); + } + chip.replaceWith(sel); + sel.focus(); + let fertig = false; + sel.onchange = () => { fertig = true; korrigiere(a, vonGruppe, sel.value); }; + sel.onblur = () => { setTimeout(() => { if (!fertig && DATEN) render(); }, 150); }; +} + +async function korrigiere(a, vonGruppe, zielGruppe){ + if (zielGruppe === vonGruppe){ render(); return; } + try { + const r = await fetch("/api/korrektur", { + method:"POST", headers:{"Content-Type":"application/json"}, + body: JSON.stringify({ titel:a.titel, marke:a.marke, gruppe:zielGruppe, plz:$("#plz").value.trim() }) + }); + if (!r.ok){ throw new Error((await r.json()).detail || r.status); } + const von = DATEN.gruppen.find(g => g.name === vonGruppe); + const ziel = DATEN.gruppen.find(g => g.name === zielGruppe); + if (von){ von.angebote = von.angebote.filter(x => x !== a); von.anzahl = von.angebote.length; } + a.unsicher = false; + if (ziel){ ziel.angebote.push(a); ziel.anzahl = ziel.angebote.length; } + DATEN.unsicher = DATEN.gruppen.reduce((s,g) => s + g.angebote.filter(x=>x.unsicher).length, 0); + render(); + zeigeFeedback(`✓ „${a.titel}" → ${zielGruppe} gemerkt (auch im Cache)`); + } catch(e){ + render(); + zeigeFeedback(`Korrektur fehlgeschlagen: ${e.message}`, true); + } +} + +function zeigeFeedback(txt, fehler){ + const el = $("#korrektur-feedback"); + el.textContent = txt; + el.style.color = fehler ? "var(--err)" : "var(--brand-d)"; + el.classList.add("an"); + clearTimeout(zeigeFeedback._t); + zeigeFeedback._t = setTimeout(() => el.classList.remove("an"), 2800); +} + // ----- Events ---------------------------------------------------------------- $("#rohholen").onclick = holeRohdaten; $("#kategorisieren").onclick = kategorisiere; diff --git a/tests/test_korrektur.py b/tests/test_korrektur.py new file mode 100644 index 0000000..f7efb04 --- /dev/null +++ b/tests/test_korrektur.py @@ -0,0 +1,103 @@ +"""Tests für die manuelle Korrektur (POST /api/korrektur) + Cache-Wirkung.""" + +import sqlite3 + +import pytest + +pytest.importorskip("fastapi") +from fastapi.testclient import TestClient # noqa: E402 + +import angebote.web as web # noqa: E402 +from angebote.produktcache import ProduktCache, produkt_schluessel # noqa: E402 + + +@pytest.fixture +def db(tmp_path, monkeypatch): + """Isolierte Cache-DB; der Endpoint nutzt ProduktCache() ohne Pfad -> STANDARD_DB.""" + p = tmp_path / "kat.sqlite" + monkeypatch.setattr("angebote.produktcache.STANDARD_DB", p) + return p + + +def test_korrektur_gueltig_schreibt_cache(db): + client = TestClient(web.app) + r = client.post( + "/api/korrektur", + json={"titel": "Hafer-Drink", "marke": "Oatly", "gruppe": "Getränke (alkoholfrei)"}, + ) + assert r.status_code == 200 + assert r.json()["gespeichert"] is True + cache = ProduktCache(db_pfad=db) + assert cache.hole(produkt_schluessel("Hafer-Drink", "Oatly")) == "Getränke (alkoholfrei)" + + +def test_korrektur_off_list_ist_400(db): + client = TestClient(web.app) + r = client.post("/api/korrektur", json={"titel": "X", "gruppe": "Weltraumzeug"}) + assert r.status_code == 400 + assert ProduktCache(db_pfad=db).groesse() == 0 + + +def test_korrektur_ohne_titel_ist_400(db): + client = TestClient(web.app) + r = client.post("/api/korrektur", json={"titel": " ", "gruppe": "Fisch"}) + assert r.status_code == 400 + + +def test_korrektur_modell_ist_manuell(db): + client = TestClient(web.app) + client.post("/api/korrektur", json={"titel": "Lachs", "marke": None, "gruppe": "Fisch"}) + with sqlite3.connect(str(db)) as con: + row = con.execute("SELECT modell FROM produkt_kategorie").fetchone() + assert row[0] == "manuell" + + +def test_manuelle_zuordnung_ist_cache_hit_kein_llm(db): + from angebote.kategorisieren import kategorisiere + from tests.fakes import CountingFakeKategorisierer, beispiel_angebot + + cache = ProduktCache(db_pfad=db) + cache.schreibe_viele( + [(produkt_schluessel("Hafer-Drink", None), "Getränke (alkoholfrei)", "manuell")] + ) + a = beispiel_angebot("Hafer-Drink", marke=None) + fake = CountingFakeKategorisierer("Sonstiges") # würde falsch raten + stat = {} + erg = kategorisiere([a], fake, cache=cache, statistik=stat) + assert fake.gesehen == 0 # kein LLM-Posten -- die manuelle Zuordnung gewinnt + assert stat == {"aus_cache": 1, "neu": 0} + assert erg[0].gruppe == "Getränke (alkoholfrei)" and not erg[0].unsicher + + +def test_korrektur_patcht_ergebnis_cache(db): + client = TestClient(web.app) + schluessel = produkt_schluessel("Toffifee", "Storck") + key = ("99999", "openrouter", "x") + web._ergebnis_cache[key] = { + "gruppen": [ + { + "name": "Sonstiges", + "anzahl": 1, + "angebote": [{"titel": "Toffifee", "marke": "Storck", "unsicher": True}], + }, + {"name": "Süßwaren & Snacks", "anzahl": 0, "angebote": []}, + ], + "unsicher": 1, + } + try: + r = client.post( + "/api/korrektur", + json={ + "titel": "Toffifee", "marke": "Storck", + "gruppe": "Süßwaren & Snacks", "plz": "99999", + }, + ) + assert r.status_code == 200 + erg = web._ergebnis_cache[key] + suess = next(g for g in erg["gruppen"] if g["name"] == "Süßwaren & Snacks") + sonst = next(g for g in erg["gruppen"] if g["name"] == "Sonstiges") + assert len(suess["angebote"]) == 1 and not suess["angebote"][0]["unsicher"] + assert len(sonst["angebote"]) == 0 + assert erg["unsicher"] == 0 + finally: + web._ergebnis_cache.pop(key, None)