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 = `keine Treffer
`; 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)