maboto/README.md
Jeuner 4b833f3785 Branding: App heißt MABOTO (Marktbeobachtungstool) + Icon/Favicon
- Eigenes Icon: Lupe über aufsteigenden Preis-Balken (Beobachtung + Markt)
  in Markengrün, als skalierbares SVG. Favicon via /favicon.svg-Route
  (image/svg+xml, gecached) + Inline-Motiv im App-Bar-Logo.
- App-Bar-Wortmarke „MABOTO / Marktbeobachtung", Seitentitel, Intro-Kopf
  und FastAPI-App-Titel umbenannt; README-H1 + Doku-Screenshots neu.
- Tests: Index prüft jetzt auf MABOTO, neuer favicon.svg-Test (77 grün).

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

232 lines
10 KiB
Markdown

# MABOTO — Marktbeobachtungstool
[![tests](https://github.com/Jeuners/timopro/actions/workflows/tests.yml/badge.svg)](https://github.com/Jeuners/timopro/actions/workflows/tests.yml)
Ortskonkrete, händlerübergreifende Übersicht wöchentlicher Supermarkt-Angebote,
geordnet nach Produktgruppen. Neutral, nicht als Hochglanzprospekt.
**Live-Demo:** <https://dev.dillenberg.net/angebote/>
---
## Worum es hier eigentlich geht
Dieses Repo ist mehr als ein Angebots-Tool. Es ist eine **Referenz dafür, wie
man KI *richtig* einsetzt** -- nicht, indem man die ganze Aufgabe einem Modell
übergibt, sondern indem man genau trennt, was deterministisch gehört und was
nur ein LLM kann. Wer die fünf Punkte unten versteht, hat das Wesentliche.
### 1. Der Schnitt: Vor KI muss man erst KI einsparen
Die Aufgabe *sieht* nach einer KI-Aufgabe aus. Sie ist es nur zur Hälfte. Sie
zerfällt in zwei strikt getrennte Teile:
| Teil | Wesen | Werkzeug |
|---|---|---|
| **Daten holen** (Preise, Gültigkeiten, Händler an Ort X) | reproduzierbar, exakt | **kein LLM** -- HTTP + Parser |
| **Kategorisieren** (ist Toffifee Süßware? gehört eine Blühpflanze in eine Lebensmittelliste?) | echte Ambiguität | **LLM** -- und nur hier |
> Wer alles dem Modell gibt, bekommt erfundene Preise. Wer alles deterministisch
> löst, scheitert an der Einordnung. Richtig ist die Arbeitsteilung.
Im Code ist dieser Schnitt nicht verhandelbar: Der Fetch-Teil enthält **keinen
einzigen LLM-Aufruf** -- ein Test (`tests/`) prüft genau das.
### 2. Architektur statt Präferenz: Qualität als prüfbare Bedingung
Eine Regel, die nur als höfliche Bitte im Prompt steht („möglichst keine
erfundenen Preise"), bricht unter Druck still weg. Eine Regel, die als
**prüfbare Bedingung im Datenfluss** steht, hält. Drei solcher Regeln sind hier
festverdrahtet -- und durch Tests abgesichert:
- **Kein Auffüllen.** Keine belegten Angebote für eine Gruppe → „keine Daten",
niemals ein plausibel klingendes erfundenes Beispiel.
- **Nur Belegtes.** Preis, Gültigkeit, Händler stammen aus dem Datensatz, nicht
aus dem Modell. Fehlendes Feld = als fehlend markiert, nicht geraten.
- **Abbruch statt stiller Drift.** Gibt die Datenlage die Anforderung nicht her
(kein Treffer für den Ort), bricht das Programm mit klarer Ursache + Vorschlag
ab -- statt ein „irgendwie vollständig aussehendes" Ergebnis zu liefern.
Deshalb „sagt das System, wenn es scheitert" -- nicht aus Einsicht des Modells,
sondern weil eine externe Bedingung es erzwingt.
### 3. Der Cache macht ein Mini-Model möglich
Jedes vom LLM (oder per Hand) eingeordnete Produkt landet in einem schnellen
SQLite-Cache (`titel+marke → gruppe`, modell-agnostischer Schlüssel). Beim
nächsten Lauf überspringen bekannte Produkte das LLM komplett. In der Praxis
sinkt die LLM-Last über die Wochen drastisch -- bis ein kleines, billiges
Modell (oder gar keins) für die wenigen Neuzugänge reicht. **KI sparsam machen,
indem man sie ihre eigene Arbeit zwischenspeichern lässt.**
### 4. Ehrlich über Grenzen
- Lokale Modelle (3-9 B über Ollama) liefern für diese Batch-Tool-Calling-Aufgabe
oft *kein* zuverlässiges Ergebnis. Das System rät dann nicht -- es markiert
alles als „Sonstiges/unsicher". Der Mangel ist sichtbar, nicht kaschiert.
- Discounter-Abdeckung wird **datengetrieben** ausgewiesen (beobachtete Händler),
nicht behauptet. Die pauschale Annahme „Aldi/Lidl fehlen bei Aggregatoren"
wurde von den echten Daten widerlegt -- also steht sie auch nicht im Code.
### 5. Mensch im Loop + sichtbare KI-Arbeit
- Unsichere (oder falsch eingeordnete) Angebote sind per Klick korrigierbar; die
Korrektur fließt als **stärkstes Cache-Signal** (`modell="manuell"`) zurück --
das Produkt ist danach nie wieder unsicher.
- Jede LLM-Aktion zeigt **sichtbar Aktivität** (animierter Indikator, Fortschritt,
Modellname, Button-Ladezustand). Das ist als Grundregel in `CLAUDE.md`
verankert: deterministische Schritte dürfen still laufen, LLM-Schritte nicht.
---
## So sieht es aus
Zweistufige UI -- Rohdaten holen (deterministisch), dann kategorisieren (LLM).
Heller Modus (Start-Screen):
![Zweistufige UI: Rohdaten holen, LLM-Konfig, Kategorisieren](docs/ui-stufen.png)
Die nach Produktgruppen gruppierte Ergebnisansicht nach Stufe 2 -- hier im
Dunkelmodus (die UI folgt automatisch dem System-Theme):
![Gruppierte Übersicht mit Preisen, Händlern, Kategorie-Chips, Quelle-Beleg](docs/ui-ergebnis.png)
**Barrierefrei & adaptiv.** Hell/Dunkel adaptiv (`prefers-color-scheme`),
volle Tastaturbedienung mit sichtbarem Fokus, `aria-live` für den
LLM-Fortschritt. Die Farbpaletten beider Themes sind auf **WCAG-AAA-Kontrast
(≥7:1)** ausgelegt -- automatisiert mit `axe-core` geprüft: 0 Verletzungen,
inklusive der verschärften AAA-Regel `color-contrast-enhanced`.
---
## Die Datenquelle (ehrlich)
Der erste echte Adapter spricht die öffentliche Angebots-API von **marktguru**
an. Das ist ein **Bildungs-/Recherche-Projekt**, kein Produkt: Der Abruf
respektiert `robots.txt`, drosselt sich und bricht bei fehlender Erlaubnis
sauber ab (Regel 4), statt blind weiterzulaufen. Die Quellen-Schicht ist hinter
einer Adapter-Schnittstelle (`quellen/basis.py`) gekapselt -- ein anderer
Anbieter mit ausdrücklicher API-Erlaubnis lässt sich ohne Eingriff in den Rest
ergänzen. Wer das Projekt produktiv nutzen will, klärt die Nutzungsbedingungen
der jeweiligen Quelle selbst.
---
## Setup
```bash
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
```
## Nutzung (CLI)
```bash
# PLZ direkt (immer verlässlich):
python -m angebote 60487
# Ortsname (nur für die in config.py hinterlegte Auswahl großer Städte;
# unbekannter Ort -> ehrlicher Abbruch mit Vorschlag, keine Notlösung):
python -m angebote "Frankfurt"
# Ohne Kategorisierung (kein LLM, flache belegte Liste):
python -m angebote 60487 --no-llm
```
Der Kategorisier-Schritt braucht einen LLM-Zugang in der Umgebung --
`OPENROUTER_API_KEY` (empfohlen, viele Modelle) **oder** `ANTHROPIC_API_KEY`.
Fehlt beides und es wird kein `--no-llm` gesetzt, bricht das Programm ehrlich
ab. Modell überschreiben mit `--modell`, Anbieter erzwingen mit
`--anbieter openrouter|anthropic`. Modelle auflisten:
`python -m angebote --modelle [suchbegriff]`.
## Web-UI
Lokale FastAPI-App -- dünne Schicht über denselben Modulen, der Schnitt bleibt
gewahrt. Sie macht den **zweistufigen Ablauf** sichtbar und erzwingt seine
Reihenfolge:
1. **Stufe 1 -- Rohdaten holen & speichern** (deterministisch, kein LLM, kein
Key): Abruf für eine PLZ, Persistenz pro PLZ/Woche unter `data/roh/`.
2. **LLM-Konfiguration** -- Panel mit **Anbieter-Umschalter**:
- **OpenRouter** (Cloud): Key + Modellauswahl (Liste/Suche/Aktualisieren).
Default `deepseek/deepseek-v4-flash` -- günstig (~1-2 Cent/Lauf), verlässlich.
- **Ollama** (lokal): zeigt die lokal installierten Modelle (kein Key, kein
Netz) -- mit ehrlichem Hinweis zur Tool-Calling-Grenze kleiner Modelle.
Anbieter + Modell werden gemerkt (`localStorage`) und überleben einen Reload;
das gewählte Modell ist dauerhaft sichtbar.
3. **Stufe 2 -- Kategorisieren** (LLM): läuft **nur auf den gespeicherten
Rohdaten** und ist gesperrt, solange keine vorliegen. Ergebnis ist die nach
Produktgruppen gruppierte Übersicht mit Filtern, Unsicherheits-Markierung,
Korrektur-Button und belegter Quelle je Angebot.
Starten:
```bash
pip install -r requirements.txt # enthält fastapi + uvicorn
cd src
OPENROUTER_API_KEY=PYTHONPATH=. uvicorn angebote.web:app --port 8077
# Browser: http://127.0.0.1:8077/
```
Die App ist als **Single-User-Werkzeug für localhost** gedacht -- nicht mit
`--host 0.0.0.0` ungeschützt ins Netz stellen (keine Auth auf den Endpoints).
Die öffentliche Demo läuft hinter einem Reverse-Proxy als Schaufenster.
## Tests
```bash
python -m pytest -q
```
Die Suite prüft **die Architektur-Regeln**, nicht nur den Happy Path:
kein Auffüllen, Abbruch bei leerem/unauflösbarem Ort, Daten-Integrität nach der
Kategorisierung, geschlossene Kategorienliste, Unsicherheits-Flag, der
Schnitt-Test „kein LLM im Fetch-Teil", Cache-Verhalten (bekannte Produkte
überspringen das LLM), die Korrektur-zu-Cache-Schleife, Modell-Discovery
(OpenRouter + Ollama), Anbieter-/Retry-Logik und die Web-Endpoints. Alles läuft
offline (Fakes); ein E2E-Test (Playwright) prüft die Konfig-Persistenz im Browser.
CI führt die volle Suite bei jedem Push aus (Badge oben).
## Entwicklung mit Claude Code
`CLAUDE.md` ist der verbindliche Leitfaden. Die beiden `SKILL.md` in
`.claude/skills/` sind die Spezifikationen der zwei Teile -- beim Arbeiten am
jeweiligen Teil zuerst die passende SKILL.md lesen, den Schnitt nie vermischen.
## Struktur
```
CLAUDE.md Leitfaden / Architektur
README.md dieses Dokument
requirements.txt Abhängigkeiten
.github/workflows/tests.yml CI: volle Testsuite bei jedem Push
docs/ Screenshots für dieses Dokument
.claude/skills/angebote-fetch/ Spec: deterministischer Datenabruf
.claude/skills/angebote-kategorisieren/ Spec: LLM-gestützte Einordnung
src/angebote/ Implementierung
modell.py eingefrorenes Angebot-Datenmodell
fehler.py AbbruchFehler (Regel 4)
config.py Quellenliste, Produktgruppen, Orts-Auflösung
quellen/ ein Adapter pro Quelle (rein = Ort, raus = [Angebot])
basis.py Adapter-Schnittstelle + Ort
marktguru.py erster echter Adapter, geprüfter Ortsbezug
fetch.py Orchestrator (Ort rein, belegte Angebote raus)
speicher.py Persistenz der belegten Rohdaten (Stufe 1), kein LLM
kategorisieren.py LLM-Schritt hinter Protokoll (OpenRouter/Anthropic), testbar
produktcache.py schneller SQLite-Cache (titel+marke -> gruppe), kein LLM
modelle.py OpenRouter-Modell-Discovery (Liste/Suche/Top-Free)
modellauswahl.py interaktive Modellauswahl (CLI)
uebersicht.py Gruppierung + Rendering (Markdown + JSON-Struktur)
web.py FastAPI-Web-UI (Stufe-1-/Stufe-2-/Korrektur-Endpoints)
web_static/ Frontend (index.html)
cli.py / __main__.py CLI-Einstieg
tests/ Architektur-/Cache-/Web-/E2E-Tests
data/roh/ generierte Rohdaten (ge-ignored)
data/kategorie_cache.sqlite Produkt->Kategorie-Cache (ge-ignored)
```
## Lizenz
[MIT](LICENSE).