Claude Chat ‐ Verbesserung der Lösung - roger-infanger-weibel/cas-hzw-ai-innovation GitHub Wiki

Zuerst die Dokumentation, dann deine Fragen:

Dokumentation ist committet und gepusht (d30246a → origin/main). Jetzt zu deinen zwei Fragen.


1. Wie die Prognosen weiter besser werden

Die Rangfolge unten ist nach Wirkung pro Aufwand sortiert — die ersten drei würde ich zuerst machen.

# Massnahme Wirkung Aufwand
1 Bias-Korrektur pro Parkhaus mittel–hoch ½ Tag
2 Feiertage + Schulferien als Feature mittel–hoch 1 Tag
3 „Wird es voll?" statt nur Punktprognose hoch (für Nutzer) 1–2 Tage
4 Echte Events statt der erfundenen hoch mehrere Tage, Datenbeschaffung
5 Die 15 nicht zugeordneten Häuser mappen mittel (lokal hoch) 1 Tag
6 Trainingsfenster verlängern klein–mittel 10 Minuten
7 Datenqualitäts-Filter beim Training klein–mittel ½ Tag

1.1 Bias-Korrektur pro Parkhaus (der billigste echte Gewinn)

Du sammelst in ai_predictions bereits Prognose und Ist-Wert, und in ai_accuracy_daily steht der Bias pro Parkhaus und Horizont. Damit lässt sich das globale Modell nachträglich pro Haus geraderücken:

korrigiert = prognose − mittlerer_bias(parkhaus, horizont, letzte 14 Tage)

Ein globales Modell über 110 Häuser hat systematische Abweichungen bei einzelnen Häusern — genau die zeigt dein Genauigkeits-Dashboard schon an. Die Korrektur greift in predict.py direkt vor dem Schreiben, kostet kein Training und ist über den Skill-Score sofort messbar (nur bei mindestens ~50 Auswertungen anwenden, sonst rauscht es).

1.2 Feiertage und Schulferien

In features.py:24 gibt es Kalender-Features (Stunde, Wochentag, Monat, Wochenende), aber keine Feiertage und keine Ferien. Für Parkhäuser ist das ein grosser blinder Fleck: Der 1. August verhält sich wie ein Sonntag, die Herbstferien senken den Pendlerverkehr wochenlang. Drei neue Spalten reichen:

  • is_holiday (kantonal — Basel, Bern, Luzern, St. Gallen, Zürich haben unterschiedliche Feiertage)
  • is_school_holiday (kantonaler Ferienkalender)
  • days_to_holiday (Brückentage, Vorabende)

Die Daten sind statisch und lassen sich als kleine Tabelle oder JSON-Datei ablegen — kein externer Dienst nötig.

1.3 „Wird es voll?" — die Frage, die Nutzer wirklich haben

Heute sagt das Modell „noch 34 Plätze frei". Die Nutzerfrage lautet aber „finde ich noch einen Platz?". Zwei Ergänzungen, beide mit denselben Features:

a) Quantile statt Mittelwert. LightGBM mit objective='quantile', alpha=0.2 gibt eine pessimistische Untergrenze: „mit 80 % Wahrscheinlichkeit mindestens 12 Plätze frei". Das ist ehrlicher als ein Punktwert und bestraft Überoptimismus, der den Nutzer vergeblich hinfahren lässt.

b) Ein Klassifikator „voll" (free < 5). Kostet ein zusätzliches Modell pro Horizont, liefert aber eine Wahrscheinlichkeit statt einer Zahl — und ist genau dort informativ, wo die MAE-Metrik unbrauchbar wird (bei free ≈ 0).

Beides erklärt auch, warum der MAE bei vollen Häusern schlecht aussieht: Das Modell ist genau dann unsicher, wenn es am meisten zählt.

1.4 Events — die grösste bekannte Schwäche

Aus dem README, und es ist die ehrlichste Lücke im System: die Events in local_events sind erzeugt, nicht real — wiederkehrende Fr–So-Termine für Luzern und Zürich, für Basel, Bern und St. Gallen gar keine. Die Features event_active, event_soon, event_bonus tragen damit heute fast nichts bei. Echte Veranstaltungsdaten (Ticketportale, Stadt-Veranstaltungskalender, Hallen-Websites) wären der grösste Sprung — aber auch der einzige Punkt auf dieser Liste, der Datenbeschaffung statt Code bedeutet.

Direkt daran hängt Punkt 5: 15 von 110 Häusern haben keinen Stammdaten-Match und bekommen deshalb grundsätzlich keine Event-Features, egal wie gut die Eventdaten werden.

1.5 Kleinigkeiten mit sofortiger Wirkung

  • Trainingsfenster: TRAIN_DAYS steht auf 120 Tagen. Der neue Server hat genug RAM — mit 365 Tagen sieht das Modell einen kompletten Jahreszyklus (Sommerferien, Advent, Winterwetter). Einfach umstellen und den Skill-Score vergleichen.
  • Datenqualität: Scanner-Ausfälle erzeugen wiederholte identische Werte. Beim Training Zeilen ausschliessen, bei denen total sprunghaft wechselt oder der Messwert älter als 15 Minuten ist.
  • Wetter: aktuell nur Temperatur und Niederschlag. Wind und Schnee sind für die Verkehrsmittelwahl relevanter als die Temperatur allein.

Wichtig für alle Punkte: Du hast mit Skill-Score und Holdout-Vergleich bereits die Messinfrastruktur. Jede Änderung einzeln trainieren, den Skill-Score vorher/nachher vergleichen — nicht drei Dinge gleichzeitig ändern.


2. Wie der Chat-Assistent intelligenter wird

Der heutige Stand: acht Regex-Intents in intents.py:4, Entitäten per Keyword und difflib, Antworten aus Templates. Das ist schnell, gratis und vorhersehbar — aber es scheitert an allem, was nicht ins Muster passt.

Stufe 0: erst messen, dann bauen

ai_chat_log speichert bereits den erkannten Intent. Bevor du irgendetwas baust:

SELECT intent, COUNT(*) FROM ai_chat_log GROUP BY intent ORDER BY 2 DESC;

Ist der Fallback-Anteil klein, brauchst du keinen LLM — dann fehlen nur Synonyme. Ist er gross, siehst du in den Log-Texten genau, welche Fragen scheitern. Das ist eine Stunde Arbeit und entscheidet, ob sich der Rest lohnt.

Stufe 1: ohne LLM deutlich besser (1–2 Tage, keine laufenden Kosten)

  • Gesprächskontext: Heute wird pro Frage neu geraten. Merkt sich die Session die letzte Stadt und das letzte Parkhaus, funktionieren Anschlussfragen wie „und morgen?", „und in Basel?", „warum?".
  • Begründungen statt Zahlen: „In 2 Stunden noch ca. 40 Plätze — es regnet und im KKL beginnt um 19:30 eine Veranstaltung." Die Daten dafür liegen in den Handlern schon vor.
  • Unsicherheit nennen: „±25 Plätze" — die historische Genauigkeit pro Haus steht in ai_accuracy_daily.
  • Mehrere Intents pro Satz: „Wie ist das Wetter und wo parke ich am besten?" trifft heute nur den ersten Treffer der geordneten Regelliste.

Stufe 2: Claude API als Aufsatz (der eigentliche Sprung)

Die Architektur ist dafür vorbereitet — ChatEngine in engine.py ist die Fassade, die Handler liefern strukturierte Daten. Es gibt zwei Ausbaustufen:

Variante A — LLM nur fürs Verstehen. Claude klassifiziert Intent und extrahiert Entitäten (Stadt, Parkhaus, Zeitpunkt) als JSON, die Antworttexte bleiben deine Templates. Halluzinationen sind praktisch ausgeschlossen, weil das LLM keine Zahlen produziert. Günstig, weil Ausgabe = wenige Tokens.

Variante B — LLM mit Tools (empfohlen). Deine Handler werden zu Tools, Claude entscheidet selbst, welche es aufruft, und formuliert die Antwort. Damit funktionieren offene Fragen, die du nie als Regel schreiben würdest: „Ich muss am Freitag um 19 Uhr ins KKL, wo stelle ich das Auto hin und wie früh muss ich los?" oder „Ist die Prognose für das Bahnhofparking überhaupt zuverlässig?"

Skizze für Python (der Tool-Runner der offiziellen anthropic-Bibliothek übernimmt die Schleife):

from anthropic import Anthropic, beta_tool

client = Anthropic()   # ANTHROPIC_API_KEY aus der .env

@beta_tool
def freie_plaetze(stadt: str, parkhaus: str = "") -> str:
    """Aktuelle Belegung. Args: stadt: Stadtname. parkhaus: optional."""
    return json.dumps(handlers.current(stadt, parkhaus))

@beta_tool
def prognose(stadt: str, parkhaus: str, stunden_voraus: int) -> str:
    """Belegungsprognose für 1, 2, 4 oder 8 Stunden voraus."""
    return json.dumps(handlers.forecast(stadt, parkhaus, stunden_voraus))

runner = client.beta.messages.tool_runner(
    model="claude-opus-5",
    max_tokens=2000,
    thinking={"type": "adaptive"},
    system="Du bist ein Parkhaus-Assistent für Schweizer Städte. "
           "Antworte kurz und auf Deutsch. Nenne nur Zahlen aus den Tools, "
           "erfinde nie welche. Sag es, wenn Daten fehlen.",
    tools=[freie_plaetze, prognose, bestes_parkhaus, wetter, events, genauigkeit],
    messages=verlauf,
)
antwort = runner.until_done()

Kosten: Claude Opus 5 kostet $5 pro Million Eingabe-Tokens und $25 pro Million Ausgabe-Tokens. Eine typische Frage mit Tool-Ergebnissen liegt bei rund 3'000 Eingabe- und 300 Ausgabe-Tokens — also etwa 2 Rappen pro Frage. Mit Prompt-Caching für System-Prompt und Tool-Schemas sinkt der Eingabeteil auf etwa ein Zehntel. Wenn dir das für einen öffentlichen Chat zu viel ist, gibt es günstigere Modelle (Sonnet 5 zu $3/$15, Haiku 4.5 zu $1/$5) — das ist aber deine Entscheidung, nicht meine; qualitativ empfehle ich Opus 5.

Wichtig für den Betrieb: LLMEngine als zweite Implementierung neben der regelbasierten anlegen, per .env-Schalter umstellbar, und bei fehlendem API-Key oder API-Fehler automatisch auf die Regeln zurückfallen. Dann bleibt der Chat auch funktionsfähig, wenn die API nicht erreichbar ist oder das Budget aufgebraucht ist. Ein Zähler pro Session verhindert Kostenüberraschungen.


Meine Empfehlung, wenn du nur eine Sache machst: Bei der Prognose die Bias-Korrektur pro Parkhaus (½ Tag, sofort messbar). Beim Chat zuerst eine Stunde ai_chat_log auswerten — das sagt dir, ob sich der LLM überhaupt lohnt.

Plan: 4 Prognose-Verbesserungen umsetzen

Context

Die ML-Prognose hat vier bekannte Schwächen: (1) systematische Abweichungen pro Parkhaus, (2) fehlende Feiertags-/Ferien-Features (Code existiert in kalender.py, wird aber nie aufgerufen), (3) nur Punktprognosen statt Voll-Wahrscheinlichkeit, (4) erfundene statt echte Events. Der User will alle vier umsetzen, mit Feiertagen/Ferien in der Datenbank statt in Dateien.

1. Bias-Korrektur pro Parkhaus

Was: Nach der ML-Prognose den mittleren Bias der letzten 14 Tage abziehen.

Dateien:

  • core/data_access.py — neue Funktion bias_lookup(env, model_type="ml") die ai_accuracy_daily abfragt (Spalte bias_free existiert schon), gefiltert auf letzte 14 Tage, min. 50 Auswertungen
  • forecast/predict.py — nach Zeile ~124 (wo free berechnet wird): free = free - bias falls Bias-Daten vorhanden

Logik:

# In predict.py, vor dem Append der insert_rows:
bias = bias_lookup.get((row["city"], row["pls_id"], h), 0)
free = max(0, min(total, free - round(bias)))

2. Feiertage/Schulferien als DB-Features

Was: Die bestehende kalender.py nutzt hardcodierte Daten und JSON-Dateien. Der User will alles in der DB. Zwei neue Tabellen + kalender.py anpassen, build_horizon_frame um den Aufruf ergänzen.

Neue DB-Tabellen (in schema.sql):

  • ai_feiertage — (datum DATE, kanton VARCHAR(4), name VARCHAR(100), PRIMARY KEY (datum, kanton))
  • ai_schulferien — (kanton VARCHAR(4), jahr SMALLINT, von DATE, bis DATE, bezeichnung VARCHAR(64), PRIMARY KEY (kanton, jahr, von))

Dateien:

  • schema.sql — zwei CREATE TABLE Statements
  • core/kalender.py — feiertage() und schulferien() lesen aus DB (Fallback auf bisherige Berechnung falls DB leer). Neue Funktion sync_kalender_to_db(env) die die berechneten Werte in die DB schreibt (idempotent, einmalig aufrufbar).
  • forecast/features.py — in build_horizon_frame() nach den Kalender-Features (Zeile ~163) den Aufruf kalender.kalender_spalten(df["city"], df["target_slot"]) einfügen und die 3 Spalten zuweisen

Wichtig: kalender_spalten() existiert bereits und liefert genau die 3 Spalten is_holiday, is_bridge_day, is_school_holiday. Es fehlt nur der Aufruf in build_horizon_frame.

3. "Wird es voll?" — Quantil-Modell + Voll-Klassifikator

Was: Zusätzlich zum Punktmodell (a) ein Quantil-Modell (α=0.2, pessimistisch) und (b) einen Binär-Klassifikator "voll" (free < 5).

Dateien:

  • forecast/ml_model.py — neue Klasse QuantileModel (LGBMRegressor mit objective='quantile', alpha=0.2) und FullClassifier (LGBMClassifier mit objective='binary', target = free < 5)
  • forecast/train.py — nach dem normalen ML-Training: Quantil-Modell und Klassifikator trainieren, als eigene model_type ('ml_q20', 'ml_full') in ai_model_runs speichern
  • forecast/predict.py — Quantil- und Klassifikator-Prognosen schreiben. Braucht neue Spalten in ai_predictions
  • schema.sql — ai_predictions erweitern: predicted_free_q20 INT NULL, predicted_full_prob DECIMAL(5,4) NULL
  • api/ Routen — die neuen Werte im API-Response mitgeben

4. Echte Events statt erfundene

Was: Die local_events-Tabelle enthält heute erfundene Fr–So-Termine. Ersetzen durch echte Eventdaten. Das ist primär Datenbeschaffung — der Code-Teil ist:

Dateien:

  • Neues Script scanner/fetch_events.py — Scraper/API-Client für echte Veranstaltungskalender (Stadt-Websites, Ticketportale). Schreibt in local_events im bestehenden Schema.
  • core/data_access.py — events_range() funktioniert bereits korrekt mit echten Daten, keine Änderung nötig
  • Bestehende event_parkhaus-Zuordnungen müssen für echte Events neu gemacht werden (manuell oder regelbasiert über Venue → nächstes Parkhaus)

Datenquellen: Offizielle Stadt-Veranstaltungskalender scrapen:

  • Basel: basel.ch/veranstaltungen
  • Bern: bern.ch/veranstaltungen
  • Luzern: luzern.ch/veranstaltungen
  • St.Gallen: stadt.sg.ch/veranstaltungen
  • Zürich: zuerich.com/veranstaltungen

Zuerst die Seiten untersuchen (JSON-API? iCal? HTML?), dann pro Stadt einen Scraper schreiben. Relevante Felder: Titel, Venue/Ort, Start/Ende, Kategorie. Venue → Parkhaus-Zuordnung über Geodaten oder manuelle Tabelle.

Reihenfolge der Umsetzung

  1. Punkt 2 zuerst — Kalender-Features aktivieren (einfachster Fix: kalender_spalten-Aufruf in build_horizon_frame + DB-Tabellen)
  2. Punkt 1 — Bias-Korrektur (unabhängig, sofort messbar)
  3. Punkt 3 — Quantil + Klassifikator (braucht Schema-Änderung + neues Training)
  4. Punkt 4 — Events (Datenbeschaffung, längste Laufzeit)

Verifizierung

  • Nach Punkt 1+2: Neu trainieren (python -m forecast.train --env test), Skill-Score vorher/nachher vergleichen
  • Nach Punkt 3: Prüfen dass ai_predictions die neuen Spalten befüllt
  • Nach Punkt 4: SELECT * FROM local_events WHERE ... zeigt echte Events
  • Alle Punkte einzeln testen, nicht gleichzeitig ändern