Web-UI: zweistufiger Flow (Rohdaten holen+speichern / Kategorisieren)

- Stufe 1 (/api/rohdaten): deterministischer Fetch + Persistenz pro PLZ/Woche
  in data/roh/, ohne LLM/Key. speicher.py serialisiert belegte Angebote
  verlustfrei (fehlende Felder bleiben null).
- OpenRouter-Konfig als separates Panel (gilt für Stufe 2).
- Stufe 2 (/api/kategorisieren): LLM-Schritt auf den GESPEICHERTEN Rohdaten,
  gesperrt solange keine vorliegen (400). Fetcht nicht erneut.
- Funktionales Premium-Redesign: zwei nummerierte Stufen-Karten mit Status-
  Flags, erzwungene Reihenfolge, belegte Rohliste, ehrlicher Footer.
- 47 Tests (+11: speicher Round-Trip, Endpoint-Sperre, Rohdaten offline).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jeuner 2026-06-03 09:44:14 +02:00
parent d6d9b07a99
commit 11f1444599
6 changed files with 901 additions and 190 deletions

4
.gitignore vendored
View file

@ -12,3 +12,7 @@ __pycache__/
# Original-Input-Archive (Specs liegen kanonisch in .claude/skills/)
/files.zip
/temp/
# gespeicherte Rohdaten (Stufe 1, pro PLZ/Woche generiert)
/src/data/
/data/

181
src/angebote/speicher.py Normal file
View file

@ -0,0 +1,181 @@
"""Persistenz der belegten Rohdaten (Stufe 1) -- deterministisch, KEIN LLM.
Trennt die zwei Stufen des Workflows auch auf der Platte:
* Stufe 1 (Fetch) schreibt die belegten, normalisierten Angebote pro PLZ und
Kalenderwoche hierher. Nichts wird interpretiert oder kategorisiert.
* Stufe 2 (Kategorisieren) liest sie von hier -- sie fetcht NICHT erneut.
Bewusst rohes JSON der belegten Felder: Was die Quelle nicht hergibt, bleibt
`null` (kein Auffüllen). Die Datei belegt zusätzlich Herkunft (Quellen, geholt
am, gesehene Händler), damit der gespeicherte Stand selbst auditierbar ist.
Der Round-Trip ist verlustfrei für die belegten Felder: `lade_rohdaten`
rekonstruiert echte `Angebot`-Objekte (frozen), sodass Stufe 2 exakt mit dem
arbeitet, was Stufe 1 belegt hat.
"""
from __future__ import annotations
from datetime import date, datetime
from pathlib import Path
from .modell import Angebot, FetchErgebnis
# Standard-Ablageort. Relativ zum Arbeitsverzeichnis des Servers (src/), damit
# der Pfad neben dem bestehenden Fetch-Cache (.cache/) liegt und nicht ins
# Paket eingreift. Über `basis_dir` injizierbar (Tests).
STANDARD_BASIS = Path("data/roh")
def _iso(d) -> str | None:
return d.isoformat() if d is not None else None
def _dat(s) -> date | None:
return date.fromisoformat(s) if s else None
def _angebot_dict(a: Angebot) -> dict:
"""Serialisiert ein belegtes Angebot vollständig -- inkl. stabiler ID."""
return {
"titel": a.titel,
"haendler": a.haendler,
"quelle": a.quelle,
"abgerufen_am": a.abgerufen_am.isoformat(),
"marke": a.marke,
"preis": a.preis,
"grundpreis": a.grundpreis,
"menge": a.menge,
"gueltig_von": _iso(a.gueltig_von),
"gueltig_bis": _iso(a.gueltig_bis),
"angebot_id": a.angebot_id,
}
def _angebot_aus_dict(d: dict) -> Angebot:
"""Rekonstruiert ein belegtes Angebot. Fehlende Felder bleiben fehlend."""
return Angebot(
titel=d["titel"],
haendler=d["haendler"],
quelle=d["quelle"],
abgerufen_am=datetime.fromisoformat(d["abgerufen_am"]),
marke=d.get("marke"),
preis=d.get("preis"),
grundpreis=d.get("grundpreis"),
menge=d.get("menge"),
gueltig_von=_dat(d.get("gueltig_von")),
gueltig_bis=_dat(d.get("gueltig_bis")),
angebot_id=d.get("angebot_id") or "",
)
def pfad_fuer(plz: str, *, basis_dir: Path | str | None = None, jetzt: datetime | None = None) -> Path:
"""Dateipfad pro PLZ/Kalenderwoche: data/roh/{plz}_{jahr}-W{woche}.json.
Gleiche Wochen-Logik wie der marktguru-Cache -- so gehört der Roh-Stand
erkennbar zur selben Woche wie die zugrundeliegende Quelle.
"""
basis = Path(basis_dir) if basis_dir else STANDARD_BASIS
jetzt = jetzt or datetime.now()
jahr, woche, _ = jetzt.isocalendar()
return basis / f"{plz}_{jahr}-W{woche:02d}.json"
def speichere_rohdaten(
fetch: FetchErgebnis, *, basis_dir: Path | str | None = None, jetzt: datetime | None = None
) -> Path:
"""Persistiert das belegte Fetch-Ergebnis. Gibt den Schreibpfad zurück.
KEIN LLM, kein Auffüllen: es wird exakt das geschrieben, was der Fetch belegt
hat. `abgerufen_am` der Meta ist der Schreibzeitpunkt; die einzelnen Angebote
tragen ihren eigenen, von der Quelle belegten Abrufzeitpunkt.
"""
import json
jetzt = jetzt or datetime.now()
pfad = pfad_fuer(fetch.ort_plz, basis_dir=basis_dir, jetzt=jetzt)
pfad.parent.mkdir(parents=True, exist_ok=True)
inhalt = {
"ort_plz": fetch.ort_plz,
"ort_name": fetch.ort_name,
"abgerufen_am": jetzt.isoformat(),
"abgedeckte_quellen": list(fetch.abgedeckte_quellen),
"gesehene_haendler": list(fetch.gesehene_haendler),
"hinweise": list(fetch.hinweise),
"angebote": [_angebot_dict(a) for a in fetch.angebote],
}
pfad.write_text(json.dumps(inhalt, ensure_ascii=False, indent=2), encoding="utf-8")
return pfad
def lade_rohdaten(
plz: str, *, basis_dir: Path | str | None = None, jetzt: datetime | None = None
) -> FetchErgebnis | None:
"""Lädt den gespeicherten Roh-Stand der aktuellen Woche -- oder None.
None bedeutet: für diese PLZ/Woche liegen keine Rohdaten vor (Stufe 2 ist
dann gesperrt). Es wird NICHT gefetcht und nichts geraten.
"""
import json
pfad = pfad_fuer(plz, basis_dir=basis_dir, jetzt=jetzt)
if not pfad.exists():
return None
d = json.loads(pfad.read_text(encoding="utf-8"))
angebote = tuple(_angebot_aus_dict(a) for a in d.get("angebote", []))
return FetchErgebnis(
ort_plz=d["ort_plz"],
ort_name=d.get("ort_name"),
angebote=angebote,
abgedeckte_quellen=tuple(d.get("abgedeckte_quellen", ())),
gesehene_haendler=tuple(d.get("gesehene_haendler", ())),
hinweise=tuple(d.get("hinweise", ())),
)
def meta_fuer(
plz: str, *, basis_dir: Path | str | None = None, jetzt: datetime | None = None
) -> dict | None:
"""Kurz-Zusammenfassung des Roh-Stands (für die UI), ohne die volle Liste.
Gibt None zurück, wenn keine Rohdaten vorliegen.
"""
import json
pfad = pfad_fuer(plz, basis_dir=basis_dir, jetzt=jetzt)
if not pfad.exists():
return None
d = json.loads(pfad.read_text(encoding="utf-8"))
return {
"ort_plz": d["ort_plz"],
"ort_name": d.get("ort_name"),
"abgerufen_am": d.get("abgerufen_am"),
"anzahl": len(d.get("angebote", [])),
"haendler": list(d.get("gesehene_haendler", [])),
"quellen": list(d.get("abgedeckte_quellen", [])),
"hinweise": list(d.get("hinweise", [])),
}
def rohliste_dicts(fetch: FetchErgebnis) -> list[dict]:
"""Belegte Rohliste als JSON-fähige dicts -- für die UI-Anzeige von Stufe 1.
Bewusst OHNE Produktgruppe: Stufe 1 kategorisiert nicht.
"""
out: list[dict] = []
for a in fetch.angebote:
out.append(
{
"titel": a.titel,
"marke": a.marke,
"preis": a.preis,
"grundpreis": a.grundpreis,
"menge": a.menge,
"haendler": a.haendler,
"gueltig_von": _iso(a.gueltig_von),
"gueltig_bis": _iso(a.gueltig_bis),
"quelle": a.quelle,
}
)
return out

View file

@ -1,9 +1,15 @@
"""FastAPI-Web-UI -- dünne Schicht über den bestehenden Modulen.
Der Schnitt bleibt unangetastet: dieser Server ruft `fetch` (deterministisch)
und `kategorisieren` (LLM) auf, vermischt aber nichts. Die UI ist reine
Präsentation; alle harten Regeln (kein Auffüllen, nur Belegtes, Abbruch statt
Drift) leben weiter in den darunterliegenden Modulen.
Der Schnitt bleibt unangetastet und ist hier sogar im Endpoint-Schnitt sichtbar:
* Stufe 1 -- /api/rohdaten -- ruft NUR den deterministischen Fetch und
persistiert die belegten Rohdaten. KEIN Key, KEIN LLM.
* Stufe 2 -- /api/kategorisieren -- liest die gespeicherten Rohdaten und
führt ausschließlich darauf die LLM-Kategorisierung aus. Sie fetcht NICHT
erneut und ist gesperrt, solange keine Rohdaten vorliegen.
Die UI ist reine Präsentation; alle harten Regeln (kein Auffüllen, nur Belegtes,
Abbruch statt Drift) leben weiter in den darunterliegenden Modulen.
Start:
OPENROUTER_API_KEY=... uvicorn angebote.web:app --port 8000
@ -25,11 +31,12 @@ app = FastAPI(title="Angebots-Übersicht")
_HTML = (Path(__file__).parent / "web_static" / "index.html").read_text("utf-8")
# In-memory Job-Store. Schlicht gehalten -- ein lokales Single-User-Werkzeug.
# In-memory Job-Store für Stufe 2 (LLM, läuft im Thread). Schlicht gehalten --
# ein lokales Single-User-Werkzeug.
_jobs: dict[str, dict] = {}
_jobs_lock = threading.Lock()
# Ergebnis-Cache: identischer Lauf (PLZ, Modell, no_llm) kommt sofort, ohne
# Ergebnis-Cache: identische Kategorisierung (PLZ, Modell) kommt sofort, ohne
# erneute LLM-Calls. Im Geist des Projekt-Cachings. In-memory, pro Serverlauf.
_ergebnis_cache: dict[tuple, dict] = {}
@ -55,17 +62,105 @@ def api_modelle(q: str = "") -> list[dict]:
]
@app.post("/api/lauf")
def api_lauf(req: dict) -> dict:
# === Stufe 1: Rohdaten holen & speichern (deterministisch, ohne Key) =========
@app.post("/api/rohdaten")
def api_rohdaten_holen(req: dict) -> dict:
"""Holt belegte Rohdaten (Fetch) und persistiert sie pro PLZ/Woche.
KEIN LLM, KEIN Key. Bei Spezifitätsmangel / Datenlage-Bruch wird der
AbbruchFehler ehrlich als 422 mit Ursache/Vorschlag weitergereicht --
es wird nichts aufgefüllt.
"""
plz = (req.get("plz") or "").strip()
if not plz:
raise HTTPException(status_code=400, detail="PLZ fehlt")
from .fetch import hole_angebote
from .speicher import meta_fuer, rohliste_dicts, speichere_rohdaten
try:
fetch = hole_angebote(plz) # deterministisch; AbbruchFehler bei Regel 4
except AbbruchFehler as e:
raise HTTPException(status_code=422, detail=e.als_text())
except Exception as e: # nichts verstecken -- ehrliche Fehlermeldung
raise HTTPException(status_code=502, detail=f"Unerwarteter Fehler: {e}")
speichere_rohdaten(fetch)
# Frisch kategorisierte Ergebnisse dieser PLZ verwerfen -- Roh-Stand neu.
for schluessel in [k for k in _ergebnis_cache if k[0] == plz]:
_ergebnis_cache.pop(schluessel, None)
meta = meta_fuer(plz) or {}
return {
"plz": plz,
"ort_name": meta.get("ort_name"),
"anzahl": meta.get("anzahl", len(fetch.angebote)),
"haendler": meta.get("haendler", list(fetch.gesehene_haendler)),
"quellen": meta.get("quellen", list(fetch.abgedeckte_quellen)),
"abgerufen_am": meta.get("abgerufen_am"),
"hinweise": meta.get("hinweise", list(fetch.hinweise)),
"angebote": rohliste_dicts(fetch),
}
@app.get("/api/rohdaten/{plz}")
def api_rohdaten_laden(plz: str) -> dict:
"""Liefert den gespeicherten Roh-Stand der aktuellen Woche oder 404."""
from .speicher import lade_rohdaten, meta_fuer, rohliste_dicts
meta = meta_fuer(plz)
if meta is None:
raise HTTPException(
status_code=404, detail=f"keine gespeicherten Rohdaten für PLZ {plz}"
)
fetch = lade_rohdaten(plz)
return {
"plz": plz,
"ort_name": meta.get("ort_name"),
"anzahl": meta.get("anzahl", 0),
"haendler": meta.get("haendler", []),
"quellen": meta.get("quellen", []),
"abgerufen_am": meta.get("abgerufen_am"),
"hinweise": meta.get("hinweise", []),
"angebote": rohliste_dicts(fetch) if fetch else [],
}
# === Stufe 2: Kategorisieren (LLM) -- erst NACH vorhandenen Rohdaten =========
@app.post("/api/kategorisieren")
def api_kategorisieren(req: dict) -> dict:
"""Startet die LLM-Kategorisierung auf den GESPEICHERTEN Rohdaten.
Gesperrt (400), solange keine Rohdaten zur PLZ vorliegen. Liest sie aus der
Persistenz -- fetcht NICHT erneut. Status-Polling über /api/lauf/{job_id}.
"""
plz = (req.get("plz") or "").strip()
if not plz:
raise HTTPException(status_code=400, detail="PLZ fehlt")
from .speicher import lade_rohdaten
fetch = lade_rohdaten(plz)
if fetch is None:
raise HTTPException(
status_code=400,
detail=(
f"Keine Rohdaten für PLZ {plz}. Zuerst Stufe 1 'Rohdaten holen' "
"ausführen -- die Kategorisierung arbeitet nur auf belegten Daten."
),
)
modell = req.get("modell") or None
no_llm = bool(req.get("no_llm"))
anbieter = req.get("anbieter") or "openrouter"
key = req.get("key") or None
job_id = uuid.uuid4().hex[:12]
# Cache-Treffer? Dann sofort als fertiger Job ausliefern, kein neuer Lauf.
treffer = _ergebnis_cache.get((plz, modell, no_llm))
treffer = _ergebnis_cache.get((plz, modell))
if treffer is not None:
with _jobs_lock:
_jobs[job_id] = {
@ -77,22 +172,15 @@ def api_lauf(req: dict) -> dict:
with _jobs_lock:
_jobs[job_id] = {
"status": "laufend",
"phase": "fetch",
"phase": "kategorisieren",
"done": 0,
"total": 0,
"ergebnis": None,
"fehler": None,
}
t = threading.Thread(
target=_run_job,
args=(
job_id,
plz,
modell,
req.get("anbieter") or "openrouter",
no_llm,
req.get("key") or None,
),
target=_run_kategorisieren,
args=(job_id, plz, fetch, modell, anbieter, key),
daemon=True,
)
t.start()
@ -107,27 +195,17 @@ def api_status(job_id: str) -> dict:
return job
def _run_job(job_id, plz, modell, anbieter, no_llm, key) -> None:
def _run_kategorisieren(job_id, plz, fetch, modell, anbieter, key) -> None:
"""LLM-Schritt im Hintergrund. Arbeitet auf den geladenen Rohdaten.
Verändert die Angebotsdaten nicht (sie sind frozen); übernimmt vom Modell
nur Gruppe + Unsicherheits-Flag.
"""
job = _jobs[job_id]
try:
from .fetch import hole_angebote
from .modell import KategorisiertesAngebot
from .kategorisieren import baue_kategorisierer, kategorisiere
from .uebersicht import als_struktur
fetch = hole_angebote(plz) # deterministisch; AbbruchFehler bei Regel 4
if no_llm:
# Ohne LLM: belegte Rohliste, sichtbar als unkategorisiert markiert.
from .config import FALLBACK_GRUPPE
kat = [
KategorisiertesAngebot(a, FALLBACK_GRUPPE, unsicher=True)
for a in fetch.angebote
]
else:
from .kategorisieren import baue_kategorisierer, kategorisiere
job["phase"] = "kategorisieren"
kt = baue_kategorisierer(anbieter, modell, api_key=key)
def fort(done, total):
@ -138,7 +216,7 @@ def _run_job(job_id, plz, modell, anbieter, no_llm, key) -> None:
job["ergebnis"] = als_struktur(fetch, kat)
job["status"] = "fertig"
_ergebnis_cache[(plz, modell, no_llm)] = job["ergebnis"]
_ergebnis_cache[(plz, modell)] = job["ergebnis"]
except AbbruchFehler as e:
job["status"] = "fehler"
job["fehler"] = e.als_text()

View file

@ -6,113 +6,297 @@
<title>Angebots-Übersicht</title>
<style>
:root {
--bg:#f6f5f2; --panel:#ffffff; --ink:#1c1b19; --muted:#6b6864;
--line:#e2ded7; --accent:#1f6f53; --accent-weak:#e7f1ec;
--warn:#9a6a00; --warn-bg:#fdf3df; --price:#11221b;
--radius:10px; --mono:ui-monospace,"SF Mono",Menlo,Consolas,monospace;
/* warme Neutral-Skala (kein reines Grau) */
--bg:#f4f2ee; --panel:#ffffff; --panel-2:#faf9f6;
--ink:#1b1a17; --ink-2:#3c3a35; --muted:#76726b; --faint:#a39e95;
--line:#e5e0d8; --line-2:#efebe3;
--brand:#1f6f53; --brand-d:#185b44; --brand-weak:#e7f1ec;
--accent:#9a6a00; --warn:#9a6a00; --warn-bg:#fbf2dd; --warn-line:#ecd49a;
--err:#9c2a23; --err-bg:#fbeeec; --err-line:#e6b8b2;
--price:#11221b;
/* Spacing-Skala 4/8/12/16/24/32/48 */
--s1:4px; --s2:8px; --s3:12px; --s4:16px; --s6:24px; --s8:32px; --s12:48px;
--radius:14px; --radius-sm:9px;
--mono:ui-monospace,"SF Mono",Menlo,Consolas,monospace;
--shadow-sm:0 1px 2px rgba(30,26,20,.05);
--shadow-md:0 6px 18px -6px rgba(30,26,20,.12);
}
* { box-sizing:border-box; }
body {
margin:0; background:var(--bg); color:var(--ink);
font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
font:16px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
-webkit-font-smoothing:antialiased; letter-spacing:-.003em;
}
@media (prefers-reduced-motion: reduce) { * { transition-duration:.01ms !important; } }
header.top {
padding:22px 26px 16px; border-bottom:1px solid var(--line); background:var(--panel);
padding:var(--s8) var(--s6) var(--s6); border-bottom:1px solid var(--line);
background:var(--panel);
}
header.top h1 { margin:0; font-size:20px; letter-spacing:-.01em; }
header.top p { margin:4px 0 0; color:var(--muted); font-size:13px; max-width:60ch; }
.wrap { max-width:1040px; margin:0 auto; padding:20px 26px 80px; }
.top-inner { max-width:1180px; margin:0 auto; }
header.top .eyebrow {
font-size:12px; text-transform:uppercase; letter-spacing:.16em;
color:var(--brand); font-weight:700; margin:0 0 var(--s2);
}
header.top h1 {
margin:0; font-size:clamp(1.7rem,2.6vw,2.3rem); line-height:1.08;
letter-spacing:-.025em; font-weight:700;
}
header.top p { margin:var(--s3) 0 0; color:var(--muted); font-size:15px; max-width:68ch; text-wrap:pretty; }
.controls {
display:flex; flex-wrap:wrap; gap:10px 14px; align-items:end;
.wrap { max-width:1180px; margin:0 auto; padding:var(--s8) var(--s6) var(--s12); }
/* Stufen-Anordnung */
.stages { display:grid; grid-template-columns:1fr; gap:var(--s6); }
.stage {
background:var(--panel); border:1px solid var(--line); border-radius:var(--radius);
padding:16px; margin-bottom:18px;
box-shadow:var(--shadow-sm); overflow:hidden;
}
.field { display:flex; flex-direction:column; gap:4px; }
.field label { font-size:11px; text-transform:uppercase; letter-spacing:.05em; color:var(--muted); }
.stage.locked { opacity:.72; }
.stage-head {
display:flex; align-items:center; gap:var(--s4);
padding:var(--s4) var(--s6); border-bottom:1px solid var(--line-2);
background:linear-gradient(var(--panel),var(--panel-2));
}
.stage-num {
flex:none; width:30px; height:30px; border-radius:50%;
display:grid; place-items:center; font-weight:700; font-size:14px;
background:var(--brand); color:#fff; font-variant-numeric:tabular-nums;
}
.stage.locked .stage-num { background:var(--faint); }
.stage-titles { flex:1; min-width:0; }
.stage-titles h2 { margin:0; font-size:16px; font-weight:700; letter-spacing:-.01em; }
.stage-titles .sub { margin:2px 0 0; font-size:13px; color:var(--muted); }
.stage-flag {
flex:none; font-size:11px; font-weight:600; letter-spacing:.04em;
text-transform:uppercase; padding:3px 9px; border-radius:999px;
background:var(--brand-weak); color:var(--brand-d); border:1px solid #cfe3d9;
}
.stage-flag.llm { background:#f0ecf7; color:#5a4a87; border-color:#ddd3ee; }
.stage-body { padding:var(--s6); }
.row { display:flex; flex-wrap:wrap; gap:var(--s4) var(--s6); align-items:end; }
.field { display:flex; flex-direction:column; gap:var(--s1); }
.field label { font-size:11px; text-transform:uppercase; letter-spacing:.06em; color:var(--muted); font-weight:600; }
input, select, button { font:inherit; }
input[type=text], select {
padding:8px 10px; border:1px solid var(--line); border-radius:7px; background:#fff; color:var(--ink);
input[type=text], input[type=password], select {
padding:10px 12px; border:1px solid var(--line); border-radius:var(--radius-sm);
background:#fff; color:var(--ink); transition:border-color .15s, box-shadow .15s;
}
input#plz { width:90px; font-variant-numeric:tabular-nums; }
select#modell { min-width:230px; }
input#modellsuche { width:130px; }
.check { flex-direction:row; align-items:center; gap:7px; }
.check label { text-transform:none; letter-spacing:0; font-size:13px; color:var(--ink); }
input:focus, select:focus { outline:none; border-color:var(--brand); box-shadow:0 0 0 3px var(--brand-weak); }
input#plz { width:118px; font-variant-numeric:tabular-nums; font-size:18px; font-weight:600; letter-spacing:.04em; }
select#modell { min-width:280px; }
input#modellsuche { width:150px; }
button {
padding:9px 16px; border:1px solid var(--accent); border-radius:7px;
background:var(--accent); color:#fff; cursor:pointer; font-weight:600;
padding:11px 20px; border:1px solid var(--brand); border-radius:var(--radius-sm);
background:var(--brand); color:#fff; cursor:pointer; font-weight:600; min-height:44px;
transition:transform .15s ease, background .15s, box-shadow .15s; white-space:nowrap;
}
button.ghost { background:#fff; color:var(--accent); }
button:disabled { opacity:.5; cursor:default; }
button.icon { padding:8px 11px; }
button:hover:not(:disabled) { background:var(--brand-d); transform:translateY(-1px); box-shadow:var(--shadow-md); }
button.ghost { background:#fff; color:var(--brand); }
button.ghost:hover:not(:disabled) { background:var(--brand-weak); color:var(--brand-d); }
button:disabled { opacity:.45; cursor:not-allowed; }
button.icon { padding:10px 13px; }
.keyrow { width:100%; display:none; gap:8px; align-items:center; margin-top:4px; }
.keyrow.show { display:flex; }
.keyrow input { flex:1; }
.keytoggle { background:none; border:none; color:var(--muted); cursor:pointer; font-size:12px; text-decoration:underline; padding:0; }
.hint { font-size:12.5px; color:var(--muted); margin-top:var(--s3); }
.hint.lock { color:var(--accent); display:flex; align-items:center; gap:6px; }
#status { margin:14px 0; min-height:22px; font-size:14px; }
.bar { height:8px; background:var(--line); border-radius:5px; overflow:hidden; margin-top:8px; }
.bar > i { display:block; height:100%; width:0; background:var(--accent); transition:width .25s; }
.err { background:var(--warn-bg); border:1px solid #eccf8a; color:#5a3d00; padding:12px 14px; border-radius:8px; white-space:pre-wrap; font-family:var(--mono); font-size:13px; }
/* Roh-Stand-Anzeige */
.rohstand {
margin-top:var(--s4); padding:var(--s4); border:1px solid var(--line-2);
border-radius:var(--radius-sm); background:var(--panel-2);
}
.rohstand.ok { border-color:#cfe3d9; background:#f3f8f5; }
.rohstand .line1 { display:flex; flex-wrap:wrap; gap:6px 16px; align-items:baseline; }
.rohstand .ok-dot { color:var(--brand); font-weight:700; }
.rohstand b { color:var(--ink); }
.rohstand .meta { color:var(--muted); font-size:13px; }
.rohstand .haendler-tags { margin-top:var(--s3); display:flex; flex-wrap:wrap; gap:6px; }
.tag { font-size:12px; background:var(--brand-weak); color:var(--brand-d); padding:2px 9px; border-radius:999px; border:1px solid #d7e7df; }
.summary { display:flex; flex-wrap:wrap; gap:10px 18px; align-items:center; color:var(--muted); font-size:13px; margin:6px 0 14px; }
details.rohliste { margin-top:var(--s3); }
details.rohliste > summary { cursor:pointer; font-size:13px; color:var(--brand-d); font-weight:600; user-select:none; padding:var(--s2) 0; }
.rohtable { width:100%; border-collapse:collapse; margin-top:var(--s2); font-size:13.5px; }
.rohtable th { text-align:left; font-weight:600; color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:.05em; padding:6px 10px; border-bottom:1px solid var(--line); }
.rohtable td { padding:7px 10px; border-bottom:1px solid var(--line-2); vertical-align:top; }
.rohtable tr:last-child td { border-bottom:none; }
.rohtable .t-preis { text-align:right; font-variant-numeric:tabular-nums; font-weight:600; white-space:nowrap; }
.rohtable .t-h { color:var(--muted); }
.rohtable .marke { color:var(--brand-d); font-weight:600; }
/* OpenRouter-Konfig (separater Bereich) */
.config {
border:1px dashed var(--line); border-radius:var(--radius); background:var(--panel-2);
margin:var(--s6) 0; overflow:hidden;
}
.config > summary {
cursor:pointer; user-select:none; list-style:none;
display:flex; align-items:center; gap:var(--s3);
padding:var(--s4) var(--s6); font-weight:700; font-size:15px;
}
.config > summary::-webkit-details-marker { display:none; }
.config > summary .chev { transition:transform .2s; color:var(--muted); }
.config[open] > summary .chev { transform:rotate(90deg); }
.config > summary .ico { font-size:14px; }
.config > summary .gilt { margin-left:auto; font-weight:500; font-size:12px; color:var(--muted); }
.config-body { padding:0 var(--s6) var(--s6); border-top:1px solid var(--line-2); padding-top:var(--s4); }
.keyrow { width:100%; max-width:560px; }
.keyrow input { width:100%; }
/* Status / Fortschritt */
#status { margin:var(--s4) 0 0; min-height:0; font-size:14.5px; }
#status:empty { margin:0; }
.bar { height:8px; background:var(--line); border-radius:5px; overflow:hidden; margin-top:var(--s2); max-width:420px; }
.bar > i { display:block; height:100%; width:0; background:var(--brand); transition:width .25s; }
.err {
background:var(--err-bg); border:1px solid var(--err-line); color:#6e201b;
padding:var(--s4); border-radius:var(--radius-sm); white-space:pre-wrap;
font-family:var(--mono); font-size:13px; line-height:1.5;
}
.pending { color:var(--muted); }
/* Ergebnis (Stufe 2) */
#result { margin-top:var(--s8); }
.summary { display:flex; flex-wrap:wrap; gap:var(--s2) var(--s6); align-items:baseline; color:var(--muted); font-size:14px; margin-bottom:var(--s4); }
.summary b { color:var(--ink); }
.filters { display:flex; flex-wrap:wrap; gap:10px 14px; align-items:end; margin-bottom:18px; }
section.grp { background:var(--panel); border:1px solid var(--line); border-radius:var(--radius); margin-bottom:12px; overflow:hidden; }
section.grp > h2 {
margin:0; padding:11px 16px; font-size:14px; display:flex; justify-content:space-between;
align-items:center; cursor:pointer; background:linear-gradient(#fff,#fbfaf7); border-bottom:1px solid var(--line);
.filters {
display:flex; flex-wrap:wrap; gap:var(--s4) var(--s6); align-items:end;
padding:var(--s4) var(--s6); background:var(--panel); border:1px solid var(--line);
border-radius:var(--radius); margin-bottom:var(--s6);
}
section.grp > h2 .cnt { color:var(--muted); font-weight:500; font-variant-numeric:tabular-nums; }
.check { flex-direction:row; align-items:center; gap:8px; }
.check label { text-transform:none; letter-spacing:0; font-size:14px; color:var(--ink); font-weight:400; }
section.grp { background:var(--panel); border:1px solid var(--line); border-radius:var(--radius); margin-bottom:var(--s3); overflow:hidden; box-shadow:var(--shadow-sm); }
section.grp > h3 {
margin:0; padding:var(--s3) var(--s6); font-size:15px; font-weight:700; display:flex; justify-content:space-between;
align-items:center; cursor:pointer; background:linear-gradient(var(--panel),var(--panel-2)); border-bottom:1px solid var(--line-2);
letter-spacing:-.01em;
}
section.grp > h3 .cnt { color:var(--muted); font-weight:600; font-variant-numeric:tabular-nums; font-size:13px; }
section.grp.zu ul { display:none; }
section.grp ul { list-style:none; margin:0; padding:4px 0; }
li.ang { display:grid; grid-template-columns:1fr auto; gap:2px 14px; padding:9px 16px; border-bottom:1px solid #f1edea; align-items:baseline; }
section.grp.zu > h3 { border-bottom:none; }
section.grp ul { list-style:none; margin:0; padding:var(--s1) 0; }
li.ang { display:grid; grid-template-columns:1fr auto; gap:2px var(--s4); padding:11px var(--s6); border-bottom:1px solid var(--line-2); align-items:baseline; }
li.ang:last-child { border-bottom:none; }
.ang .name { font-weight:600; }
.ang .name .marke { color:var(--accent); }
.ang .meta { grid-column:1; color:var(--muted); font-size:12.5px; }
.ang .meta .haendler { color:var(--ink); background:var(--accent-weak); padding:1px 6px; border-radius:5px; }
.ang .name .marke { color:var(--brand-d); }
.ang .meta { grid-column:1; color:var(--muted); font-size:13px; }
.ang .meta .haendler { color:var(--ink-2); background:var(--brand-weak); padding:1px 7px; border-radius:5px; font-weight:500; }
.ang .preis { grid-column:2; grid-row:1; text-align:right; font-weight:700; color:var(--price); font-variant-numeric:tabular-nums; white-space:nowrap; }
.ang .grund { grid-column:2; grid-row:2; text-align:right; color:var(--muted); font-size:12px; white-space:nowrap; }
.ang.unsicher { background:var(--warn-bg); }
.badge { display:inline-block; font-size:11px; background:var(--warn-bg); color:var(--warn); border:1px solid #eccf8a; border-radius:5px; padding:0 6px; margin-left:6px; }
.leer { padding:12px 16px; color:var(--muted); font-style:italic; }
.quelle { font-family:var(--mono); font-size:11px; color:#9a968f; }
.badge { display:inline-block; font-size:11px; background:var(--warn-bg); color:var(--warn); border:1px solid var(--warn-line); border-radius:5px; padding:0 7px; margin-left:7px; font-weight:600; }
.leer { padding:var(--s3) var(--s6); color:var(--muted); font-style:italic; font-size:14px; }
.quelle { font-family:var(--mono); font-size:11px; color:var(--faint); }
footer.note { margin-top:22px; padding-top:16px; border-top:1px solid var(--line); color:var(--muted); font-size:12.5px; }
footer.note .haendler { color:var(--ink); }
footer.note { margin-top:var(--s6); padding-top:var(--s4); border-top:1px solid var(--line); color:var(--muted); font-size:13px; }
footer.note .haendler { color:var(--ink-2); }
footer.note .hinweis { display:block; margin-top:6px; font-style:italic; }
.sep { color:var(--faint); }
</style>
</head>
<body>
<header class="top">
<div class="top-inner">
<p class="eyebrow">Ortskonkret · händlerübergreifend · belegt</p>
<h1>Angebots-Übersicht</h1>
<p>Ortskonkret, händlerübergreifend, nach Produktgruppen. Daten deterministisch aus marktguru,
Einordnung per LLM. Jedes Angebot ist belegt — kein Auffüllen, Unsicheres ist markiert.</p>
<p>Zwei strikt getrennte Stufen: zuerst die Rohdaten <b>deterministisch</b> holen
und speichern (kein LLM, kein Key), danach erst per LLM in Produktgruppen
einordnen. Jedes Angebot ist belegt — kein Auffüllen, Unsicheres ist markiert.</p>
</div>
</header>
<div class="wrap">
<div class="controls">
<div class="field"><label for="plz">PLZ</label><input id="plz" type="text" value="60487" inputmode="numeric" /></div>
<div class="stages">
<!-- ============ STUFE 1 ============ -->
<section class="stage" id="stage1">
<div class="stage-head">
<div class="stage-num">1</div>
<div class="stage-titles">
<h2>Rohdaten holen &amp; speichern</h2>
<p class="sub">Deterministischer Abruf für eine PLZ. Wird pro PLZ/Woche auf Platte gespeichert.</p>
</div>
<div class="stage-flag">deterministisch · kein LLM</div>
</div>
<div class="stage-body">
<div class="row">
<div class="field">
<label for="plz">PLZ</label>
<input id="plz" type="text" value="60487" inputmode="numeric" maxlength="5" />
</div>
<div class="field">
<label>&nbsp;</label>
<button id="rohholen">Rohdaten holen ▶</button>
</div>
</div>
<div id="rohstatus"></div>
<div id="rohstand"></div>
</div>
</section>
<!-- ============ OPENROUTER-KONFIG (separat) ============ -->
<details class="config" id="config">
<summary>
<span class="chev"></span>
<span class="ico"></span>
<span>OpenRouter-Konfiguration</span>
<span class="gilt">gilt für Stufe&nbsp;2</span>
</summary>
<div class="config-body">
<div class="row">
<div class="field">
<label for="modell">Modell</label>
<select id="modell"><option>lädt…</option></select>
</div>
<div class="field"><label for="modellsuche">Modell suchen</label><input id="modellsuche" type="text" placeholder="z.B. qwen" /></div>
<div class="field"><label>&nbsp;</label><button id="refresh" class="ghost icon" title="Modell-Liste aktualisieren"></button></div>
<div class="field check"><input id="nollm" type="checkbox" /><label for="nollm">ohne LLM (Rohliste)</label></div>
<div class="field"><label>&nbsp;</label><button id="holen">Holen ▶</button></div>
<button type="button" class="keytoggle" id="keytoggle">API-Key setzen (optional)</button>
<div class="keyrow" id="keyrow">
<input id="key" type="password" placeholder="OPENROUTER_API_KEY (bleibt lokal, nur an deinen Server)" />
<div class="field">
<label for="modellsuche">Modell suchen</label>
<input id="modellsuche" type="text" placeholder="z. B. qwen" />
</div>
<div class="field">
<label>&nbsp;</label>
<button id="refresh" class="ghost icon" title="Modell-Liste aktualisieren">↻ aktualisieren</button>
</div>
</div>
<div class="row" style="margin-top:var(--s4)">
<div class="field keyrow">
<label for="key">API-Key (bleibt lokal, nur an deinen Server)</label>
<input id="key" type="password" placeholder="OPENROUTER_API_KEY — optional, falls nicht in der Server-Umgebung" autocomplete="off" />
</div>
</div>
<p class="hint">Der Key wird ausschließlich für Stufe&nbsp;2 verwendet und nie in Stufe&nbsp;1 (Datenabruf) eingesetzt.</p>
</div>
</details>
<!-- ============ STUFE 2 ============ -->
<section class="stage locked" id="stage2">
<div class="stage-head">
<div class="stage-num">2</div>
<div class="stage-titles">
<h2>Kategorisieren</h2>
<p class="sub">Ordnet die gespeicherten Rohdaten per LLM in Produktgruppen. Verändert keine Angebotsdaten.</p>
</div>
<div class="stage-flag llm">LLM-Schritt</div>
</div>
<div class="stage-body">
<div class="row">
<div class="field">
<label>&nbsp;</label>
<button id="kategorisieren" disabled>Kategorisieren ▶</button>
</div>
</div>
<div class="hint lock" id="lockhint">🔒 Erst aktiv, sobald für die PLZ Rohdaten gespeichert sind (Stufe&nbsp;1).</div>
<div id="status"></div>
</div>
</section>
</div>
<!-- ============ ERGEBNIS ============ -->
<div id="result" hidden>
<div class="summary" id="summary"></div>
<div class="filters">
@ -127,8 +311,15 @@
<script>
const $ = s => document.querySelector(s);
let DATEN = null;
let DATEN = null; // kategorisiertes Ergebnis (Stufe 2)
let ROHDA = false; // ob für die aktuelle PLZ Rohdaten vorliegen
function esc(s){ return (s==null?"":String(s)).replace(/[&<>"]/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c])); }
function preisFmt(p){ return p!=null ? p.toLocaleString("de-DE",{minimumFractionDigits:2,maximumFractionDigits:2})+" €" : "Preis fehlt"; }
function setRohStatus(html){ $("#rohstatus").innerHTML = html; }
function setStatus(html){ $("#status").innerHTML = html; }
// ----- Modelle (OpenRouter-Konfig) ------------------------------------------
async function ladeModelle(q=""){
const sel = $("#modell");
try {
@ -148,33 +339,114 @@ async function ladeModelle(q="") {
}
} catch (e){
sel.innerHTML = `<option value=''>Modelle nicht abrufbar</option>`;
setStatus(`<div class="err">Modelle nicht abrufbar: ${e.message}</div>`);
}
}
function setStatus(html) { $("#status").innerHTML = html; }
// ----- Stufe 1: Rohdaten holen / prüfen -------------------------------------
function lockStage2(grund){
ROHDA = false;
$("#kategorisieren").disabled = true;
$("#stage2").classList.add("locked");
$("#lockhint").style.display = "flex";
if (grund) $("#lockhint").innerHTML = "🔒 " + grund;
}
function unlockStage2(){
ROHDA = true;
$("#kategorisieren").disabled = false;
$("#stage2").classList.remove("locked");
$("#lockhint").style.display = "none";
}
async function starteLauf() {
function zeigeRohstand(d, frisch){
const dt = d.abgerufen_am ? new Date(d.abgerufen_am).toLocaleString("de-DE") : "—";
const tags = (d.haendler||[]).map(h => `<span class="tag">${esc(h)}</span>`).join("");
const zeilen = (d.angebote||[]).map(a => `
<tr>
<td>${a.marke ? `<span class="marke">${esc(a.marke)}</span> ` : ""}${esc(a.titel)}${a.menge?` <span class="t-h">· ${esc(a.menge)}</span>`:""}</td>
<td class="t-h">${esc(a.haendler)}</td>
<td class="t-preis">${preisFmt(a.preis)}</td>
</tr>`).join("");
$("#rohstand").innerHTML = `
<div class="rohstand ok">
<div class="line1">
<span class="ok-dot">✓ Rohdaten vorhanden</span>
<span>für PLZ <b>${esc(d.plz)}</b>${d.ort_name?` (${esc(d.ort_name)})`:""}</span>
<span class="meta"><b>${d.anzahl}</b> Angebote · <b>${(d.haendler||[]).length}</b> Händler · geholt am ${esc(dt)}${frisch?" (gerade aktualisiert)":""}</span>
</div>
<div class="haendler-tags">${tags}</div>
${(d.angebote||[]).length ? `
<details class="rohliste">
<summary>Belegte Rohliste anzeigen (${d.angebote.length}) — ungruppiert</summary>
<table class="rohtable">
<thead><tr><th>Artikel</th><th>Händler</th><th style="text-align:right">Preis</th></tr></thead>
<tbody>${zeilen}</tbody>
</table>
</details>` : `<div class="hint">Quellen liefen, lieferten aber 0 Angebote — kein Auffüllen.</div>`}
</div>`;
unlockStage2();
}
async function pruefeRohstand(plz){
$("#rohstand").innerHTML = "";
lockStage2();
if (!/^\d{5}$/.test(plz)) return;
try {
const r = await fetch("/api/rohdaten/" + encodeURIComponent(plz));
// Stale-Guard: zwischenzeitlich getippte/andere PLZ -> Antwort verwerfen.
if ($("#plz").value.trim() !== plz) return;
if (r.status === 404) { lockStage2("Noch keine Rohdaten für diese PLZ — Stufe 1 ausführen."); return; }
if (!r.ok) return;
const d = await r.json();
if ($("#plz").value.trim() !== plz) return;
zeigeRohstand(d, false);
} catch {}
}
async function holeRohdaten(){
const plz = $("#plz").value.trim();
if (!plz) { setStatus(`<div class="err">Bitte eine PLZ eingeben.</div>`); return; }
$("#holen").disabled = true;
if (!/^\d{5}$/.test(plz)) { setRohStatus(`<div class="err">Bitte eine 5-stellige PLZ eingeben.</div>`); return; }
$("#rohholen").disabled = true;
$("#result").hidden = true; DATEN = null;
setRohStatus(`<div class="pending">Hole Angebote für <b>${esc(plz)}</b> … (deterministisch, ohne LLM)</div>`);
try {
const r = await fetch("/api/rohdaten", {
method:"POST", headers:{"Content-Type":"application/json"},
body: JSON.stringify({ plz })
});
const d = await r.json();
if (!r.ok) throw new Error(d.detail || r.status);
setRohStatus("");
zeigeRohstand(d, true);
} catch (e){
setRohStatus(`<div class="err">${esc(e.message)}</div>`);
lockStage2();
} finally {
$("#rohholen").disabled = false;
}
}
// ----- Stufe 2: Kategorisieren ----------------------------------------------
async function kategorisiere(){
if (!ROHDA) return;
const plz = $("#plz").value.trim();
$("#kategorisieren").disabled = true;
$("#result").hidden = true;
const noLlm = $("#nollm").checked;
setStatus(`<div>Hole Angebote für <b>${plz}</b></div>`);
setStatus(`<div class="pending">Starte Kategorisierung …</div>`);
let job;
try {
const r = await fetch("/api/lauf", {
const r = await fetch("/api/kategorisieren", {
method:"POST", headers:{"Content-Type":"application/json"},
body: JSON.stringify({
plz, modell: $("#modell").value, no_llm: noLlm,
anbieter: "openrouter", key: $("#key").value || undefined
plz, modell: $("#modell").value, anbieter:"openrouter",
key: $("#key").value || undefined
})
});
if (!r.ok) throw new Error((await r.json()).detail || r.status);
job = (await r.json()).job_id;
const d = await r.json();
if (!r.ok) throw new Error(d.detail || r.status);
job = d.job_id;
} catch (e){
setStatus(`<div class="err">${e.message}</div>`); $("#holen").disabled = false; return;
setStatus(`<div class="err">${esc(e.message)}</div>`); $("#kategorisieren").disabled = false; return;
}
const poll = setInterval(async () => {
@ -182,35 +454,36 @@ async function starteLauf() {
try { s = await (await fetch("/api/lauf/" + job)).json(); }
catch { return; }
if (s.status === "laufend"){
if (s.phase === "kategorisieren" && s.total) {
if (s.total){
const pct = Math.round(100 * s.done / s.total);
setStatus(`<div>Kategorisiere … Batch <b>${s.done}/${s.total}</b>
<div class="bar"><i style="width:${pct}%"></i></div></div>`);
} else {
setStatus(`<div>${s.phase === "kategorisieren" ? "Kategorisiere …" : "Hole Angebote …"}</div>`);
setStatus(`<div class="pending">Kategorisiere …</div>`);
}
return;
}
clearInterval(poll);
$("#holen").disabled = false;
if (s.status === "fehler") { setStatus(`<div class="err">${s.fehler}</div>`); return; }
$("#kategorisieren").disabled = false;
if (s.status === "fehler"){ setStatus(`<div class="err">${esc(s.fehler)}</div>`); return; }
setStatus("");
DATEN = s.ergebnis;
render();
$("#result").scrollIntoView({behavior:"smooth", block:"start"});
}, 1000);
}
// ----- Ergebnis-Rendering ----------------------------------------------------
function render(){
const d = DATEN;
$("#result").hidden = false;
$("#summary").innerHTML =
`Ort <b>${d.ort_name || d.ort_plz}</b> (PLZ ${d.ort_plz}) · <b>${d.anzahl}</b> Angebote ·
Quellen: ${d.quellen.join(", ") || "—"} · <b>${d.unsicher}</b> unsicher`;
`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`;
const fh = $("#fhaendler");
const aktuell = fh.value;
fh.innerHTML = "<option value=''>alle</option>" +
d.haendler.map(h => `<option>${esc(h)}</option>`).join("");
fh.innerHTML = "<option value=''>alle</option>" + d.haendler.map(h => `<option>${esc(h)}</option>`).join("");
fh.value = aktuell;
zeichneGruppen();
@ -236,7 +509,7 @@ function zeichneGruppen() {
);
const sec = document.createElement("section");
sec.className = "grp";
const h = document.createElement("h2");
const h = document.createElement("h3");
h.innerHTML = `<span>${esc(g.name)}</span><span class="cnt">${items.length}</span>`;
h.onclick = () => sec.classList.toggle("zu");
sec.appendChild(h);
@ -257,54 +530,41 @@ function zeile(a) {
const li = document.createElement("li");
li.className = "ang" + (a.unsicher ? " unsicher" : "");
const marke = a.marke ? `<span class="marke">${esc(a.marke)}</span> ` : "";
const menge = a.menge ? ` · ${esc(a.menge)}` : "";
const menge = a.menge ? ` <span class="sep">·</span> ${esc(a.menge)}` : "";
const gueltig = (a.gueltig_von || a.gueltig_bis)
? ` · gültig ${a.gueltig_von||"?"}${a.gueltig_bis||"?"}` : "";
const preis = a.preis != null
? a.preis.toLocaleString("de-DE",{minimumFractionDigits:2,maximumFractionDigits:2}) + " €"
: "Preis fehlt";
? ` <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="preis">${preis}</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>`;
return li;
}
function esc(s){ return (s==null?"":String(s)).replace(/[&<>"]/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c])); }
// Events
$("#holen").onclick = starteLauf;
// ----- Events ----------------------------------------------------------------
$("#rohholen").onclick = holeRohdaten;
$("#kategorisieren").onclick = kategorisiere;
$("#refresh").onclick = () => ladeModelle($("#modellsuche").value.trim());
$("#modellsuche").addEventListener("keydown", e => { if (e.key==="Enter") ladeModelle(e.target.value.trim()); });
$("#keytoggle").onclick = () => $("#keyrow").classList.toggle("show");
$("#plz").addEventListener("keydown", e => { if (e.key==="Enter") holeRohdaten(); });
let _plzTimer;
$("#plz").addEventListener("input", () => {
clearTimeout(_plzTimer);
const plz = $("#plz").value.trim();
_plzTimer = setTimeout(() => pruefeRohstand(plz), 350);
});
for (const id of ["#fhaendler","#ftext","#fsicher"])
$(id).addEventListener("input", () => DATEN && zeichneGruppen());
$("#plz").addEventListener("keydown", e => { if (e.key==="Enter") starteLauf(); });
// Auto-Start per URL-Param: ?plz=60487[&no_llm=1][&modell=...]
const _p = new URLSearchParams(location.search);
if (_p.get("plz")) {
$("#plz").value = _p.get("plz");
if (_p.get("no_llm")) $("#nollm").checked = true;
ladeModelle().then(() => {
const m = _p.get("modell");
if (m) {
const sel = $("#modell");
if (![...sel.options].some(o => o.value === m)) {
const o = document.createElement("option");
o.value = o.textContent = m;
sel.insertBefore(o, sel.firstChild);
}
sel.value = m;
}
starteLauf();
});
} else {
// ----- Init ------------------------------------------------------------------
ladeModelle();
}
// Auto: ?plz=… vorbelegen (überschreibt den Default), dann GENAU EINMAL prüfen.
const _p = new URLSearchParams(location.search);
if (_p.get("plz")) $("#plz").value = _p.get("plz").trim();
pruefeRohstand($("#plz").value.trim());
</script>
</body>
</html>

115
tests/test_speicher.py Normal file
View file

@ -0,0 +1,115 @@
"""Persistenz der Rohdaten -- offline, deterministisch (kein Netz, kein LLM).
Geprüft wird der Architektur-Vertrag, nicht nur der Happy Path:
* Round-Trip ist verlustfrei für belegte Felder (inkl. fehlender Felder).
* Fehlende Felder bleiben None -- werden NICHT aufgefüllt.
* Kein Stand -> None (Stufe 2 ist dann gesperrt), es wird nichts geraten.
* Pfad liegt pro PLZ/Kalenderwoche.
"""
from __future__ import annotations
from datetime import date, datetime
from angebote.modell import FetchErgebnis
from angebote.speicher import (
lade_rohdaten,
meta_fuer,
pfad_fuer,
rohliste_dicts,
speichere_rohdaten,
)
from tests.fakes import beispiel_angebot
def _fetch(angebote, plz="60487"):
return FetchErgebnis(
ort_plz=plz,
ort_name=None,
angebote=tuple(angebote),
abgedeckte_quellen=("marktguru",),
gesehene_haendler=tuple(sorted({a.haendler for a in angebote})),
hinweise=("marktguru: Teilabdeckung",),
)
def test_round_trip_behaelt_belegte_felder(tmp_path):
a = beispiel_angebot(
"Bio-Banane",
haendler="ALDI SÜD",
preis=1.29,
marke="Bio Smiley",
menge="1 kg",
gueltig_von=date(2026, 6, 1),
gueltig_bis=date(2026, 6, 7),
)
fetch = _fetch([a])
pfad = speichere_rohdaten(fetch, basis_dir=tmp_path)
assert pfad.exists()
geladen = lade_rohdaten("60487", basis_dir=tmp_path)
assert geladen is not None
assert len(geladen.angebote) == 1
g = geladen.angebote[0]
assert g.titel == "Bio-Banane"
assert g.haendler == "ALDI SÜD"
assert g.preis == 1.29
assert g.marke == "Bio Smiley"
assert g.gueltig_von == date(2026, 6, 1)
assert g.gueltig_bis == date(2026, 6, 7)
# stabile ID bleibt über den Round-Trip identisch (für Stufe-2-Mapping):
assert g.angebot_id == a.angebot_id
# Herkunft bleibt belegt:
assert geladen.abgedeckte_quellen == ("marktguru",)
assert geladen.gesehene_haendler == ("ALDI SÜD",)
def test_fehlende_felder_bleiben_none_kein_auffuellen(tmp_path):
# Pflichtfelder gesetzt, optionale bewusst leer -> dürfen NICHT geraten werden.
a = beispiel_angebot(
"No-Name-Artikel",
haendler="REWE",
preis=None,
marke=None,
menge=None,
grundpreis=None,
gueltig_von=None,
gueltig_bis=None,
)
speichere_rohdaten(_fetch([a]), basis_dir=tmp_path)
g = lade_rohdaten("60487", basis_dir=tmp_path).angebote[0]
assert g.preis is None
assert g.marke is None
assert g.menge is None
assert g.grundpreis is None
assert g.gueltig_von is None
assert g.gueltig_bis is None
def test_kein_stand_liefert_none(tmp_path):
assert lade_rohdaten("12345", basis_dir=tmp_path) is None
assert meta_fuer("12345", basis_dir=tmp_path) is None
def test_meta_fasst_zusammen(tmp_path):
a = beispiel_angebot("Butter", haendler="REWE")
b = beispiel_angebot("Käse", haendler="EDEKA")
speichere_rohdaten(_fetch([a, b]), basis_dir=tmp_path)
meta = meta_fuer("60487", basis_dir=tmp_path)
assert meta["anzahl"] == 2
assert set(meta["haendler"]) == {"REWE", "EDEKA"}
assert meta["abgerufen_am"]
def test_pfad_pro_plz_und_woche():
jetzt = datetime(2026, 6, 2, 9, 0, 0) # ISO-Woche 23/2026
pfad = pfad_fuer("60487", basis_dir="/tmp/roh", jetzt=jetzt)
assert pfad.name == "60487_2026-W23.json"
def test_rohliste_ohne_produktgruppe(tmp_path):
"""Stufe 1 kategorisiert nicht -- die Rohliste trägt keine Produktgruppe."""
a = beispiel_angebot("Butter")
dicts = rohliste_dicts(_fetch([a]))
assert dicts and "produktgruppe" not in dicts[0]
assert "gruppe" not in dicts[0]

View file

@ -1,15 +1,18 @@
"""Web-Schicht -- offline. Struktur-Funktion + Endpoints (ohne Netz/Key/LLM).
Der Schnitt gilt auch hier: die Web-Schicht fügt keine Logik hinzu, sie ruft
die getesteten Module auf. Geprüft wird, dass sie das belegt-und-ehrlich
weiterreicht.
Der Schnitt gilt auch hier und ist im Endpoint-Schnitt sichtbar:
* Stufe 1 (/api/rohdaten) holt + speichert deterministisch -- ohne Key.
* Stufe 2 (/api/kategorisieren) ist gesperrt, solange keine Rohdaten vorliegen.
Geprüft wird, dass die Web-Schicht das belegt-und-ehrlich weiterreicht und die
zwei Stufen sauber trennt -- ohne dabei selbst zu fetchen oder ein LLM zu rufen.
"""
import pytest
from angebote.modell import FetchErgebnis, KategorisiertesAngebot
from angebote.uebersicht import als_struktur
from tests.fakes import beispiel_angebot
from tests.fakes import FakeQuelle, beispiel_angebot
fastapi = pytest.importorskip("fastapi")
from fastapi.testclient import TestClient # noqa: E402
@ -66,13 +69,83 @@ def test_api_modelle_gibt_top_free(monkeypatch):
assert daten[0]["frei"] is True
def test_api_lauf_ohne_plz_ist_400():
client = TestClient(web.app)
r = client.post("/api/lauf", json={"plz": ""})
assert r.status_code == 400
def test_api_status_unbekannt_ist_404():
client = TestClient(web.app)
r = client.get("/api/lauf/gibtsnicht")
assert r.status_code == 404
# === Stufe 1: Rohdaten holen & speichern (deterministisch, ohne Key) =========
def test_api_rohdaten_ohne_plz_ist_400():
client = TestClient(web.app)
r = client.post("/api/rohdaten", json={"plz": ""})
assert r.status_code == 400
def test_api_rohdaten_holen_speichert_und_laedt(tmp_path, monkeypatch):
"""Stufe 1 fetcht (über Fake-Quelle, kein Netz), speichert, und GET liefert es."""
# Persistenz in tmp lenken -- der Default-Pfad bleibt unangetastet.
monkeypatch.setattr("angebote.speicher.STANDARD_BASIS", tmp_path / "roh")
# Fetch über eine Fake-Quelle: kein Netz, kein Schlüssel, deterministisch.
a = beispiel_angebot("Bio-Banane", haendler="ALDI SÜD", preis=1.29)
quelle = FakeQuelle("fake", [a])
monkeypatch.setattr("angebote.fetch.standard_quellen", lambda: [quelle])
client = TestClient(web.app)
r = client.post("/api/rohdaten", json={"plz": "60487"})
assert r.status_code == 200
d = r.json()
assert d["plz"] == "60487"
assert d["anzahl"] == 1
assert "ALDI SÜD" in d["haendler"]
assert d["angebote"][0]["titel"] == "Bio-Banane"
assert d["abgerufen_am"] # belegt
# Datei wurde geschrieben (data/roh-Äquivalent im tmp)
geschrieben = list((tmp_path / "roh").glob("60487_*.json"))
assert geschrieben, "Rohdaten-Datei wurde nicht persistiert"
# GET liefert den gespeicherten Stand zurück (kein erneutes Fetchen nötig).
g = client.get("/api/rohdaten/60487")
assert g.status_code == 200
assert g.json()["angebote"][0]["titel"] == "Bio-Banane"
def test_api_rohdaten_holen_abbruch_ist_422(monkeypatch, tmp_path):
"""Ehrlicher Abbruch (Regel 4) wird als 422 mit Ursache/Vorschlag gemeldet."""
monkeypatch.setattr("angebote.speicher.STANDARD_BASIS", tmp_path / "roh")
# Keine Quelle deckt den Ort ab -> AbbruchFehler.
quelle = FakeQuelle("fake", [], deckt=False)
monkeypatch.setattr("angebote.fetch.standard_quellen", lambda: [quelle])
client = TestClient(web.app)
r = client.post("/api/rohdaten", json={"plz": "60487"})
assert r.status_code == 422
assert "Abbruch" in r.json()["detail"]
def test_api_rohdaten_laden_ohne_stand_ist_404(monkeypatch, tmp_path):
monkeypatch.setattr("angebote.speicher.STANDARD_BASIS", tmp_path / "roh")
client = TestClient(web.app)
r = client.get("/api/rohdaten/99999")
assert r.status_code == 404
# === Stufe 2: Kategorisieren -- gesperrt ohne Rohdaten =======================
def test_api_kategorisieren_ohne_rohdaten_ist_400(monkeypatch, tmp_path):
"""Stufe 2 ist hart gesperrt, solange keine Rohdaten gespeichert sind."""
monkeypatch.setattr("angebote.speicher.STANDARD_BASIS", tmp_path / "roh")
client = TestClient(web.app)
r = client.post("/api/kategorisieren", json={"plz": "60487"})
assert r.status_code == 400
assert "Rohdaten" in r.json()["detail"]
def test_api_kategorisieren_ohne_plz_ist_400():
client = TestClient(web.app)
r = client.post("/api/kategorisieren", json={"plz": ""})
assert r.status_code == 400