Ergebnisansicht: Sidebar + Header, Kategorie-Chip, manuelle Korrektur

- Layout: Grid sidebar|main. Linke Seitenleiste mit den Produktgruppen
  (Counts + unsicher-Badges, Klick springt zur Gruppe), sticky Header (Ort,
  Anzahl, Cache-Statistik, Modell, unsicher, Filter).
- Kategorie pro Angebot als Chip sichtbar; Chip ist zugleich Korrektur-Anker.
- POST /api/korrektur {titel,marke,gruppe,plz}: schreibt die manuelle Gruppe
  (modell='manuell') in den Produkt-Cache -- die hochwertigste Cache-Quelle;
  patcht den UI-Ergebnis-Cache der PLZ (Angebot wandert, unsicher-Flag weg).
  Kein LLM, kein Fetch; Whitelist erzwungen. Frontend hängt das Angebot
  client-seitig um, ohne neuen Lauf.
- +6 Tests (gültig/400/400, modell=manuell, manuelle Zuordnung -> Cache-Hit
  kein LLM, Ergebnis-Cache-Patch). 76 Tests grün.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jeuner 2026-06-03 19:42:17 +02:00
parent 077a877480
commit e1c7afef7e
3 changed files with 319 additions and 17 deletions

View file

@ -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.

View file

@ -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; }
}
</style>
</head>
<body>
@ -311,14 +348,25 @@
<!-- ============ ERGEBNIS ============ -->
<div id="result" hidden>
<div class="summary" id="summary"></div>
<div class="filters">
<div class="field"><label for="fhaendler">Händler</label><select id="fhaendler"><option value="">alle</option></select></div>
<div class="field"><label for="ftext">Angebot suchen</label><input id="ftext" type="text" placeholder="Titel…" /></div>
<div class="field check"><input id="fsicher" type="checkbox" /><label for="fsicher">nur sichere</label></div>
<div class="ergebnis-layout">
<aside class="sidebar">
<div class="sidebar-head">Produktgruppen</div>
<nav id="gruppennav"></nav>
</aside>
<div class="main">
<header class="ergebnis-header">
<div class="summary" id="summary"></div>
<div class="filters">
<div class="field"><label for="fhaendler">Händler</label><select id="fhaendler"><option value="">alle</option></select></div>
<div class="field"><label for="ftext">Angebot suchen</label><input id="ftext" type="text" placeholder="Titel…" /></div>
<div class="field check"><input id="fsicher" type="checkbox" /><label for="fsicher">nur sichere</label></div>
</div>
<div id="korrektur-feedback" class="kfeedback"></div>
</header>
<div id="gruppen"></div>
<footer class="note" id="footer"></footer>
</div>
</div>
<div id="gruppen"></div>
<footer class="note" id="footer"></footer>
</div>
</div>
@ -553,22 +601,30 @@ function render(){
const modellTxt = d.modell
? ` <span class="sep">·</span> kategorisiert mit <b>${esc(d.modell)}</b>${d.anbieter?` <span class="t-h">(${esc(d.anbieter)})</span>`:""}`
: "";
const cacheTxt = (d.aus_cache != null)
? ` <span class="sep">·</span> <b>${d.aus_cache}</b> aus Cache · <b>${d.neu}</b> neu` : "";
$("#summary").innerHTML =
`Ort <b>${esc(d.ort_name || d.ort_plz)}</b> (PLZ ${esc(d.ort_plz)}) <span class="sep">·</span> <b>${d.anzahl}</b> Angebote <span class="sep">·</span>
Quellen: ${d.quellen.map(esc).join(", ") || "—"} <span class="sep">·</span> <b>${d.unsicher}</b> unsicher${modellTxt}`;
Quellen: ${d.quellen.map(esc).join(", ") || "—"} <span class="sep">·</span> <b>${d.unsicher}</b> unsicher${cacheTxt}${modellTxt}`;
const fh = $("#fhaendler");
const aktuell = fh.value;
fh.innerHTML = "<option value=''>alle</option>" + d.haendler.map(h => `<option>${esc(h)}</option>`).join("");
fh.value = aktuell;
zeichneGruppen();
zeichneGruppen(); // baut Sektionen + ruft baueSidebar mit den gefilterten Counts
$("#footer").innerHTML =
`<span class="haendler"><b>Beobachtete Händler (belegt):</b> ${d.haendler.map(esc).join(", ")}.</span>` +
d.hinweise.map(h => `<span class="hinweis">${esc(h)}</span>`).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 = `<span>${esc(g.name)}</span><span class="cnt">${items.length}</span>`;
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 = `<div class="navitem" style="cursor:default;color:var(--muted)">keine Treffer</div>`; return; }
for (const g of navDaten){
const b = document.createElement("button");
b.className = "navitem"; b.dataset.grp = g.name;
const uns = g.uns ? `<span class="nav-uns">${g.uns}</span>` : "";
b.innerHTML = `<span>${esc(g.name)}</span><span><span class="nav-cnt">${g.count}</span>${uns}</span>`;
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 ? `<span class="marke">${esc(a.marke)}</span> ` : "";
@ -611,14 +690,64 @@ function zeile(a){
? ` <span class="sep">·</span> gültig ${a.gueltig_von||"?"}${a.gueltig_bis||"?"}` : "";
const badge = a.unsicher ? `<span class="badge">unsicher</span>` : "";
li.innerHTML =
`<div class="name">${marke}${esc(a.titel)}${badge}</div>` +
`<div class="name">${marke}${esc(a.titel)}${badge}<span class="gchip" title="Produktgruppe ändern">${esc(gruppe)}</span></div>` +
`<div class="preis">${preisFmt(a.preis)}</div>` +
`<div class="meta"><span class="haendler">${esc(a.haendler)}</span>${menge}${gueltig}
&nbsp;<span class="quelle" title="${esc(a.quelle)}">${esc((a.quelle||"").slice(0,42))}</span></div>` +
`<div class="grund">${a.grundpreis ? esc(a.grundpreis) : ""}</div>`;
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;

103
tests/test_korrektur.py Normal file
View file

@ -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)