maboto/src/angebote/web.py
Jeuner 39b8a98bc2 Initiale Implementierung: Angebots-Übersicht (Fetch + Kategorisierung + Web-UI)
Der Schnitt aus CLAUDE.md ist durchgehalten:
- Fetch (deterministisch, kein LLM): marktguru-Adapter mit geprüftem
  Ortsbezug (zipCode), Wochen-Cache, robots.txt-Respekt, ehrlicher Regel-4-
  Abbruch bei fehlendem Beleg statt Krücke.
- Kategorisierung (einziger LLM-Ort): geschlossene Liste + Daten-Integrität
  als Code erzwungen; austauschbar via Protokoll (OpenRouter/Anthropic),
  mit Drosselung/Retry und ehrlichem Abbruch.
- FastAPI-Web-UI als dünne Schicht: Modellauswahl (Liste/Suche/Refresh),
  Live-Fortschritt, gruppierte Ergebnisse mit Filtern, Ergebnis-Cache.
- 36 Tests gegen die Architektur-Regeln (kein Auffüllen, Abbruch, Integrität,
  geschlossene Liste, Unsicherheit, Schnitt) und die Web-Schicht.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 09:29:59 +02:00

147 lines
4.6 KiB
Python

"""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.
Start:
OPENROUTER_API_KEY=... uvicorn angebote.web:app --port 8000
(aus dem src/-Verzeichnis, oder mit PYTHONPATH=src)
"""
from __future__ import annotations
import threading
import uuid
from pathlib import Path
from fastapi import FastAPI, HTTPException
from fastapi.responses import HTMLResponse
from .fehler import AbbruchFehler
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.
_jobs: dict[str, dict] = {}
_jobs_lock = threading.Lock()
# Ergebnis-Cache: identischer Lauf (PLZ, Modell, no_llm) kommt sofort, ohne
# erneute LLM-Calls. Im Geist des Projekt-Cachings. In-memory, pro Serverlauf.
_ergebnis_cache: dict[tuple, dict] = {}
@app.get("/", response_class=HTMLResponse)
def index() -> str:
return _HTML
@app.get("/api/modelle")
def api_modelle(q: str = "") -> list[dict]:
"""Modell-Liste für das Dropdown: Suche oder Top-Free. 'Aktualisieren' = neu rufen."""
from .modelle import lade_modelle, suche, top_free
try:
alle = lade_modelle()
except Exception as e: # Netz/SSL -> ehrlich melden, nicht raten
raise HTTPException(status_code=502, detail=f"Modelle nicht abrufbar: {e}")
treffer = suche(alle, q) if q else top_free(alle, 8)
return [
{"id": m.id, "frei": m.frei, "tools": m.tools, "context": m.context}
for m in treffer[:25]
]
@app.post("/api/lauf")
def api_lauf(req: dict) -> dict:
plz = (req.get("plz") or "").strip()
if not plz:
raise HTTPException(status_code=400, detail="PLZ fehlt")
modell = req.get("modell") or None
no_llm = bool(req.get("no_llm"))
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))
if treffer is not None:
with _jobs_lock:
_jobs[job_id] = {
"status": "fertig", "phase": "cache", "done": 0, "total": 0,
"ergebnis": treffer, "fehler": None,
}
return {"job_id": job_id, "cache": True}
with _jobs_lock:
_jobs[job_id] = {
"status": "laufend",
"phase": "fetch",
"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,
),
daemon=True,
)
t.start()
return {"job_id": job_id}
@app.get("/api/lauf/{job_id}")
def api_status(job_id: str) -> dict:
job = _jobs.get(job_id)
if not job:
raise HTTPException(status_code=404, detail="unbekannter Job")
return job
def _run_job(job_id, plz, modell, anbieter, no_llm, key) -> None:
job = _jobs[job_id]
try:
from .fetch import hole_angebote
from .modell import KategorisiertesAngebot
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):
job["done"] = done
job["total"] = total
kat = kategorisiere(list(fetch.angebote), kt, fortschritt=fort)
job["ergebnis"] = als_struktur(fetch, kat)
job["status"] = "fertig"
_ergebnis_cache[(plz, modell, no_llm)] = job["ergebnis"]
except AbbruchFehler as e:
job["status"] = "fehler"
job["fehler"] = e.als_text()
except Exception as e: # nichts verstecken -- ehrliche Fehlermeldung
job["status"] = "fehler"
job["fehler"] = f"Unerwarteter Fehler: {e}"