Compare commits

...

17 commits
v0.1 ... main

Author SHA1 Message Date
Jeuner
75b4c3b97d UI: Hell/Dunkel-Schalter + bessere Filter-UX
- Manueller Farbmodus-Button in der App-Bar: Auto -> Hell -> Dunkel,
  in localStorage gemerkt, ohne Aufblitzen (Inline-Head-Script),
  color-scheme + Adressleisten-Farbe ziehen mit. CSS auf data-theme-
  Override umgestellt (dark-Tokens für [data-theme=dark] UND System-
  Automatik, außer manuell „hell"). AAA in beiden Modi erhalten.
- Filter: leere Gruppen werden beim Filtern ausgeblendet -> Treffer
  stehen sofort oben, kein Suchen mehr zwischen leeren Boxen. Sichtbares
  Feedback (Treffer-Zähler im Header + sanfte Einblende-Animation,
  reduced-motion-safe), klarer Leer-Zustand bei 0 Treffern + Reset.
- QC: axe-core 0 Verletzungen hell+dunkel (inkl. AAA), 77 Tests grün,
  Toggle/Persistenz/Filter live verifiziert, keine JS-Fehler.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 23:20:37 +02:00
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
Jeuner
aed664dc16 UI-Redesign: App-Anmutung + adaptiv hell/dunkel + WCAG-AAA
- App-Shell: sticky App-Bar mit Wortmarke, Orts-Chip (spiegelt PLZ),
  ⚙-Einstieg in die LLM-Konfig; Karten mit Tiefe statt Hairline-Doku-Look
- Adaptives Theme via prefers-color-scheme (hell + dunkel), color-scheme-
  Meta + theme-color je Theme
- AAA-Kontrast: beide Paletten auf >=7:1 ausgelegt und mit axe-core
  verifiziert (0 Verletzungen, inkl. color-contrast-enhanced, 30 Regeln ok)
- A11y-Semantik: Kategorie-Chip & Gruppen-Header sind echte <button>
  (Tastatur), aria-expanded/aria-current, aria-live auf Status + LLM-
  Fortschritt, sichtbare :focus-visible-Ringe, Skip-Link
- Schnitt-Transparenz bleibt sichtbar, nur schöner: Quelle als ruhiges
  „🔗 marktguru"-Badge statt Roh-URL; deterministisch/LLM-Badges erhalten
- LLM-arbeitet-Indikator bleibt prominent (Grundregel)
- README: Screenshots neu (hell/dunkel) + Barrierefrei-&-adaptiv-Abschnitt

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 22:38:02 +02:00
Jeuner
4ba5f65f39 README als Lern-Referenz, MIT-Lizenz, CI-Workflow
- README führt jetzt mit der Lehre (der Schnitt, Architektur als
  prüfbare Regeln, Cache->Mini-Model, ehrliche Grenzen, KI-Sichtbarkeit)
  + Live-Demo-Link + ehrlicher Datenquellen-Hinweis (Bildungskontext,
  robots.txt, gekapselter Adapter)
- LICENSE (MIT)
- .github/workflows/tests.yml: volle Suite (inkl. Playwright-E2E)
  bei jedem Push -> Badge im README

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 21:33:36 +02:00
Jeuner
20012a7d46 LLM-Arbeit sichtbar machen + als Grundregel verankern
Grundregel (CLAUDE.md): So wie das System sichtbar abbricht, muss es auch
sichtbar zeigen, wenn es arbeitet. Jede LLM-Aktion = laufende Aktivität mit
Fortschritt + animiertem Indikator; nie wie ein eingefrorenes UI.

UI: prominenter LLM-Indikator beim Kategorisieren -- rotierender Spinner,
'🤖 LLM kategorisiert … Batch X/Y', Modellname, Fortschrittsbalken (bestimmt
oder Shimmer bei unbekanntem Total), Button im Lade-Zustand (' LLM arbeitet').
prefers-reduced-motion respektiert.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 21:06:36 +02:00
Jeuner
b63dad74a0 Frontend: API-Pfade pfad-agnostisch (BASE-Prefix) für Reverse-Proxy
Alle fetch('/api/...') laufen über api(p)=BASE+p; BASE aus location.pathname
(lokal '/', deployed z.B. '/angebote/'). Ermöglicht Betrieb hinter einem
nginx-Reverse-Proxy mit Unterpfad ohne 404. Lokal unverändert (E2E grün).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 20:52:24 +02:00
Jeuner
25bb790517 Ergebnis-Navigation: Sidebar -> horizontales sticky Gruppen-Band
Die linke Sidebar verursachte einen Layout-Sprung (einspaltige Stufen oben,
zweispaltiges Ergebnis darunter) und kostete viel Fläche. Ersetzt durch eine
sticky Chip-Leiste im Header: Gruppen als Pills mit Count + unsicher-Badge,
horizontal scrollbar, Klick springt zur Gruppe. Volle Breite für die Angebote,
konsistentes einspaltiges Layout. Kategorie-Chip pro Angebot + Korrektur bleiben.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 20:41:57 +02:00
Jeuner
e1c7afef7e Ergebnisansicht: Sidebar + Header, Kategorie-Chip, manuelle Korrektur
- Layout: Grid sidebar|main. Linke Seitenleiste mit den Produktgruppen
  (Counts + unsicher-Badges, Klick springt zur Gruppe), sticky Header (Ort,
  Anzahl, Cache-Statistik, Modell, unsicher, Filter).
- Kategorie pro Angebot als Chip sichtbar; Chip ist zugleich Korrektur-Anker.
- POST /api/korrektur {titel,marke,gruppe,plz}: schreibt die manuelle Gruppe
  (modell='manuell') in den Produkt-Cache -- die hochwertigste Cache-Quelle;
  patcht den UI-Ergebnis-Cache der PLZ (Angebot wandert, unsicher-Flag weg).
  Kein LLM, kein Fetch; Whitelist erzwungen. Frontend hängt das Angebot
  client-seitig um, ohne neuen Lauf.
- +6 Tests (gültig/400/400, modell=manuell, manuelle Zuordnung -> Cache-Hit
  kein LLM, Ergebnis-Cache-Patch). 76 Tests grün.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 19:42:17 +02:00
Jeuner
077a877480 Produkt->Kategorie-Cache: bekannte Produkte ohne LLM (SQLite, modellübergreifend)
Neuer produktcache.py (Stufe 2): speichert pro Produkt (Titel+Marke, mengen-
invariant) die einmal ermittelte Gruppe in SQLite, bulk-load ins dict -> O(1).
Schnitt gewahrt: kein LLM-Import, nur Gruppe (nie Angebotsdaten), Whitelist
beim Lesen+Schreiben, nur SICHERE Zuordnungen gecacht.

kategorisiere(cache=, statistik=): Lookup vor dem LLM, Dedup im Lauf (ein
Produkt = ein Posten), Write-Back danach. Parallel-/id-Logik unverändert.
als_struktur/web/cli verdrahtet (Statistik 'X aus Cache · Y neu', --no-cache).

Live verifiziert (1903 Angebote PLZ 60487): Lauf 1 (gemini) 1551 neu; Lauf 2
(deepseek, anderes Modell) nur 110 neu, 1765 aus Cache -> ~93% weniger LLM-Calls,
modellübergreifend. +12 Tests (Round-Trip, Whitelist, Hit-vermeidet-Call, Dedup,
nur-sichere, Schnitt). 70 Tests grün.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 18:37:12 +02:00
Jeuner
2029eb9fcf Kategorisierung parallelisieren (bis zu 8 Batches gleichzeitig)
Die 77 LLM-Calls liefen bisher sequenziell -> bei langsamer Modell-Latenz
minutenlang. Jetzt ThreadPoolExecutor (parallel=8); id-basiertes Mapping ist
reihenfolge-unabhängig, Logik unverändert. Voller deepseek-Lauf: 162s statt
sequenziell ~20min bei der heutigen Latenz (~16s/Call). Schnelle Modelle
(gemini-flash) entsprechend ~15-20s. +1 Test (parallel ordnet alle Batches
vollständig zu). 58 Tests grün.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 18:15:14 +02:00
Jeuner
aa60331f7f Fix: UI wählt empfohlenes deepseek-Default vor statt gedrosseltem Free-Modell
Ursache des 429-'es hängt': Die UI wählte beim Laden das erste Top-Free-Modell
vor; OpenRouter-Free-Modelle sind hart gedrosselt -> Lauf lief in 5x Retry +
Abbruch. Jetzt:
- /api/modelle stellt den Default (deepseek-v4-flash) als 'empfohlen' voran.
- UI wählt das empfohlene Modell vor, markiert Free als 'oft gedrosselt' und
  stellt ein gemerktes Free-Modell NICHT automatisch wieder her.
- Server-seitiges Fortschritts-Logging ([Stufe 2] Batch X/Y) fürs Live-Log.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 17:59:03 +02:00
Jeuner
62f5af533e README: Ollama-Anbieter, deepseek-Default, Modell-Sichtbarkeit, Persistenz
LLM-Konfig-Sektion um Anbieter-Umschalter (OpenRouter/Ollama) erweitert,
ehrlicher Ollama-Modell-Hinweis, gemerkte Auswahl + sichtbares Modell.
Test-Zahl auf 57 (inkl. E2E-Persistenz) aktualisiert.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 17:50:58 +02:00
Jeuner
2ffb89a6d2 Ollama-Konfig: Persistenz (localStorage) + E2E-Test, ehrliche Modell-Grenze
Goal 'Ollama-Konfig bleibt bestehen & klappt', mit Tests:
- Persistenz-Fix: Anbieter + Modell in localStorage gemerkt, init() stellt sie
  wieder her (URL-Param > gemerkt > Default). Behebt das Zurückspringen auf
  OpenRouter nach Reload.
- E2E-Test (Playwright): Anbieter überlebt echten Reload. content-JSON-Fallback
  mit 3 Tests abgesichert. 57 Tests grün.
- Ehrlich dokumentiert (Code-Untersuchung + UI-Hinweis): kleine lokale Modelle
  (qwen2.5-coder, gemma4, qwen3.5, llama3.2) liefern kein brauchbares Batch-
  Tool-Calling -> Ergebnis 'Sonstiges/unsicher' (markiert, nicht geraten).
  Brauchbare lokale Kategorisierung braucht ein starkes tool-Modell; Cloud
  (deepseek) bleibt die verlässliche Wahl.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 17:34:34 +02:00
Jeuner
2e80f0d826 Ollama als lokaler Anbieter + gewähltes LLM dauerhaft sichtbar
- OllamaKategorisierer (lokaler OpenAI-kompatibler Endpoint, kein Key/Netz),
  baue_kategorisierer('ollama'), Default-Ollama-Modell.
- modelle.lade_ollama_modelle: /api/tags + /api/show (Tool-Fähigkeit), nur
  tool-fähige taugen; leere Liste wenn Ollama aus.
- web: /api/ollama-modelle, Anbieter im Kategorisier-Flow + Cache-Key,
  Modell+Anbieter im Ergebnis (als_struktur).
- UI: Anbieter-Umschalter (OpenRouter/Ollama), gewähltes Modell als Chip im
  Konfig-Kopf (auch zugeklappt) + 'kategorisiert mit … (anbieter)' im Ergebnis,
  bookmarkbarer ?modell/?anbieter/?auto-Start.
- content-JSON-Fallback fürs Tool-Parsing (manche lokale Modelle liefern die
  Antwort als Text-JSON). +6 Tests (53 gesamt).

Ehrlich: lokal installierte Modelle (qwen2.5-coder/gemma4/qwen3.5) liefern kein
brauchbares Tool-Calling -> Ergebnis dort 'Sonstiges/unsicher' (ehrlich markiert,
nicht geraten). Cloud-Default deepseek-v4-flash voll verifiziert (1903 Angebote,
modellstabil).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 14:54:32 +02:00
Jeuner
59d7d916ef Default-OpenRouter-Modell: deepseek-v4-flash (günstig + verlässlich)
Verifiziert gegen Alternativen: sauberes Tool-Calling, über mehrere Batches
konsistent, ~1,8 Cent pro vollem Lauf (5x günstiger als gemini-flash-lite).
glm-4.7-flash/seed-1.6-flash riefen das Tool nicht sauber auf, free-Modelle
sind hart gedrosselt. Per --modell / Web-UI weiter frei wählbar.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 14:13:51 +02:00
Jeuner
1ded067928 README: Web-UI + zweistufiger Flow dokumentiert, mit Screenshots
- Neue Web-UI-Sektion: Stufe 1 (Rohdaten holen+speichern), separate
  OpenRouter-Konfig, Stufe 2 (Kategorisieren, gesperrt bis Rohdaten da).
- Zwei Screenshots unter docs/ (Stufen-Ansicht + gruppiertes Ergebnis).
- Nutzung um OpenRouter/--anbieter/--modelle ergänzt, Struktur und Test-
  zahl (47) nachgezogen, localhost-Hinweis.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 14:08:31 +02:00
Jeuner
11f1444599 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>
2026-06-03 09:44:14 +02:00
27 changed files with 2766 additions and 313 deletions

30
.github/workflows/tests.yml vendored Normal file
View file

@ -0,0 +1,30 @@
name: tests
on:
push:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Python 3.12 einrichten
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- name: Abhängigkeiten installieren
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Playwright-Browser (für den E2E-Test)
run: python -m playwright install --with-deps chromium
- name: Testsuite (Architektur-Regeln, Cache, Web, E2E)
env:
PYTHONPATH: src
run: python -m pytest -q

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/

View file

@ -58,6 +58,22 @@ Diese drei Punkte sind der Grund, warum "die KI soll sagen, wenn sie scheitert"
hier funktioniert: nicht weil das Modell Einsicht hätte, sondern weil eine
externe, prüfbare Bedingung es erzwingt.
## Sichtbarkeit der LLM-Arbeit (Grundregel)
So wie das System **sichtbar abbricht**, wenn es scheitert, muss es auch
**sichtbar zeigen**, wenn es arbeitet. Jede LLM-Aktion (die Kategorisierung)
wird in der Oberfläche als *laufende Aktivität* dargestellt: erkennbarer
Fortschritt (z. B. „LLM kategorisiert … Batch X/Y"), ein animierter Indikator,
und das auslösende Bedien-Element im Lade-Zustand. Eine LLM-Aktion darf **nie**
wie ein eingefrorenes, totes UI aussehen.
Der Grund ist derselbe wie beim Abbruch: Transparenz. Der Nutzer muss jederzeit
zwischen „arbeitet gerade" und „hängt / ist fertig" unterscheiden können -- sonst
wirkt selbst ein korrekt laufender Prozess wie ein Defekt. Das gilt für die
Web-UI (animierter Status + Fortschrittsbalken) ebenso wie für die CLI (laufende
Status-/Batch-Zeile). Deterministische Schritte (Fetch) dürfen still sein; die
LLM-Schritte nicht.
## Skills
Das Projekt nutzt zwei Skills (in `.claude/skills/`), die exakt dem Schnitt

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Jeuners
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

228
README.md
View file

@ -1,32 +1,116 @@
# Angebots-Übersicht
# 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.
## Der Gedanke
**Live-Demo:** <https://dev.dillenberg.net/angebote/>
Die Aufgabe sieht nach einer KI-Aufgabe aus, ist es aber nur zur Hälfte. Sie
---
## 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:
1. **Daten holen** -- deterministisch, ohne LLM. (Skill `angebote-fetch`)
2. **Kategorisieren** -- die echte Ambiguität, hier gehört das LLM hin.
(Skill `angebote-kategorisieren`)
| 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. Die Architektur erzwingt den Schnitt.
> Wer alles dem Modell gibt, bekommt erfundene Preise. Wer alles deterministisch
> löst, scheitert an der Einordnung. Richtig ist die Arbeitsteilung.
Der zweite Punkt, der dieses Projekt trägt: Qualitätsregeln stehen nicht als
gut gemeinte Bitten im Code, sondern als prüfbare Bedingungen -- kein
Auffüllen, nur Belegtes, Abbruch statt stiller Drift. Genau dadurch "sagt das
System, wenn es scheitert": nicht aus Einsicht des Modells, sondern weil eine
externe Bedingung es erzwingt.
Im Code ist dieser Schnitt nicht verhandelbar: Der Fetch-Teil enthält **keinen
einzigen LLM-Aufruf** -- ein Test (`tests/`) prüft genau das.
## Entwicklung mit Claude Code
### 2. Architektur statt Präferenz: Qualität als prüfbare Bedingung
`CLAUDE.md` ist der verbindliche Leitfaden. Die beiden `SKILL.md` in
`.claude/skills/` sind die Spezifikationen der zwei Teile. Claude Code liest
sie automatisch -- beim Arbeiten am jeweiligen Teil zuerst die passende
SKILL.md lesen.
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
@ -35,7 +119,7 @@ python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
```
## Nutzung
## Nutzung (CLI)
```bash
# PLZ direkt (immer verlässlich):
@ -49,35 +133,67 @@ python -m angebote "Frankfurt"
python -m angebote 60487 --no-llm
```
Der Kategorisier-Schritt braucht `ANTHROPIC_API_KEY` in der Umgebung. Fehlt er
und es wird kein `--no-llm` gesetzt, bricht das Programm ehrlich ab, statt
ungeordnet weiterzulaufen.
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]`.
## Stand der Implementierung (ehrlich)
## Web-UI
- `requirements.txt` -- **vorhanden**.
- `src/angebote/` -- **vorhanden**: Datenmodell, Adapter-Schnittstelle,
Fetch-Orchestrator, Kategorisier-Schritt, Übersicht-Renderer, CLI.
- `tests/` -- **vorhanden**: Architektur-Tests (kein Auffüllen, Abbruch bei
leerem/unauflösbarem Ort, Daten-Integrität nach Kategorisierung,
geschlossene Kategorienliste, Unsicherheits-Flag, Schnitt-Test "kein LLM im
Fetch-Teil"). Laufen offline, ohne Netz und ohne LLM.
- **Live bestätigt:** der marktguru-Adapter wurde gegen die echte API getestet
(PLZ 60487) und liefert reale, belegte Angebote (u. a. ALDI SÜD, PENNY, Lidl,
REWE, Kaufland, nahkauf). Erkenntnisse aus dem echten Lauf, die direkt in den
Code geflossen sind:
- `offers/search` ist query-orientiert -- leeres `q` liefert 0 Treffer. Der
Adapter aggregiert daher über Kategorie-Seedbegriffe (config) und weist
diese **Teilabdeckung** ehrlich aus, statt Vollständigkeit zu behaupten.
- Die pauschale Annahme "Aldi/Lidl fehlen bei Aggregatoren" wurde von den
Daten **widerlegt** (beide sind enthalten). Die Abdeckung wird deshalb
**datengetrieben** ausgewiesen (beobachtete Händler), nicht hartkodiert.
- marktgurus Marken-Sentinel `thisisnobrand123` wird als "keine Marke"
behandelt, nicht als Beleg durchgereicht.
- **Voraussetzungen für den Live-Lauf:** installiertes `requests` (certifi für
TLS), erreichbares Netz, von der Seite lesbare API-Schlüssel. Fehlt eines
davon, ist das der vorgesehene Abbruchfall (Regel 4) mit *zutreffend*
benannter Ursache -- keine Krücke.
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
@ -85,6 +201,8 @@ ungeordnet weiterzulaufen.
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
@ -95,8 +213,20 @@ src/angebote/ Implementierung
basis.py Adapter-Schnittstelle + Ort
marktguru.py erster echter Adapter, geprüfter Ortsbezug
fetch.py Orchestrator (Ort rein, belegte Angebote raus)
kategorisieren.py LLM-Schritt hinter einer Schnittstelle (offline testbar)
uebersicht.py Gruppierung + Rendering
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-Tests
tests/ Architektur-/Cache-/Web-/E2E-Tests
data/roh/ generierte Rohdaten (ge-ignored)
data/kategorie_cache.sqlite Produkt->Kategorie-Cache (ge-ignored)
```
## Lizenz
[MIT](LICENSE).

BIN
docs/ui-ergebnis.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 410 KiB

BIN
docs/ui-stufen.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 324 KiB

View file

@ -12,4 +12,5 @@ uvicorn>=0.29
# Entwicklung/Tests:
pytest>=8.0
httpx>=0.27 # für fastapi.testclient
httpx>=0.27 # für fastapi.testclient
pytest-playwright>=0.5 # E2E-UI-Tests (danach: `playwright install chromium`)

View file

@ -55,6 +55,11 @@ def main(argv: list[str] | None = None) -> int:
action="store_true",
help="vor dem Lauf das OpenRouter-Modell interaktiv wählen (Liste/Suche/Update)",
)
parser.add_argument(
"--no-cache",
action="store_true",
help="den Produkt->Kategorie-Cache nicht nutzen (alles neu kategorisieren)",
)
args = parser.parse_args(argv)
# Reiner Listen-/Suchmodus -- braucht keinen Ort und keinen Key.
@ -102,12 +107,28 @@ def main(argv: list[str] | None = None) -> int:
from .kategorisieren import baue_kategorisierer, kategorisiere
from .uebersicht import rendern
cache = None
if not args.no_cache:
from .produktcache import ProduktCache
cache = ProduktCache()
stat: dict = {}
try:
kat = kategorisiere(list(fetch.angebote), baue_kategorisierer(anbieter, modell))
kat = kategorisiere(
list(fetch.angebote),
baue_kategorisierer(anbieter, modell),
cache=cache,
statistik=stat,
)
except AbbruchFehler as e:
print(e.als_text(), file=sys.stderr)
return 2
if cache is not None:
print(
f"({stat.get('aus_cache', 0)} aus Cache · {stat.get('neu', 0)} neu kategorisiert)",
file=sys.stderr,
)
print(rendern(fetch, kat))
return 0

View file

@ -39,40 +39,116 @@ def kategorisiere(
*,
batch_groesse: int = 25,
fortschritt=None,
parallel: int = 8,
cache=None,
statistik: dict | None = None,
) -> list[KategorisiertesAngebot]:
"""Ordnet jedes Angebot einer Produktgruppe zu, ohne Daten zu verändern.
`fortschritt`: optionaler Callback (erledigte_batches, gesamt_batches) --
für Live-Anzeigen (z. B. die Web-UI). Ändert die Logik nicht.
`fortschritt`: optionaler Callback (erledigte_batches, gesamt_batches).
`parallel`: bis zu so viele Batches gleichzeitig (LLM-Calls sind I/O-bound).
`cache`: optionaler ProduktCache. Bekannte Produkte werden per Lookup direkt
übernommen (kein LLM); nur unbekannte gehen ans Modell, dedupliziert
(ein Produkt = ein Posten). Neue SICHERE Zuordnungen werden zurück-
geschrieben. Default None -> heutiges Verhalten, unverändert.
`statistik`: optionales dict, wird mit {aus_cache, neu} befüllt.
Die Zuordnung ist id-basiert, also reihenfolge- (und parallel-)unabhängig.
"""
import threading
original = {a.angebot_id: a for a in angebote}
ergebnis: dict[str, KategorisiertesAngebot] = {}
lock = threading.Lock()
import math
# --- Cache-Phase: bekannte Produkte übernehmen, Rest deduplizieren -------
aids_von_schluessel: dict[str, list[str]] = {}
schluessel_von_repr: dict[str, str] = {} # Repräsentant-id -> Produkt-Schlüssel
neu_fuer_cache: dict[str, str] = {}
aus_cache = 0
modell_name = getattr(kategorisierer, "_modell", None)
gesamt_batches = max(1, math.ceil(len(angebote) / batch_groesse))
for nr, start in enumerate(range(0, len(angebote), batch_groesse), 1):
batch = angebote[start : start + batch_groesse]
if cache is not None:
from .produktcache import produkt_schluessel
repraesentant: dict[str, Angebot] = {}
for a in angebote:
s = produkt_schluessel(a.titel, a.marke)
gruppe = cache.hole(s)
if gruppe is not None:
ergebnis[a.angebot_id] = KategorisiertesAngebot(
angebot=a, gruppe=gruppe, unsicher=False
)
aus_cache += 1
else:
aids_von_schluessel.setdefault(s, []).append(a.angebot_id)
if s not in repraesentant:
repraesentant[s] = a
schluessel_von_repr[a.angebot_id] = s
zu_kategorisieren = list(repraesentant.values())
else:
zu_kategorisieren = angebote
batches = [
zu_kategorisieren[start : start + batch_groesse]
for start in range(0, len(zu_kategorisieren), batch_groesse)
]
gesamt_batches = max(1, len(batches))
erledigt = [0]
def verarbeite(batch):
posten = [
{
"id": a.angebot_id,
"titel": a.titel,
"marke": a.marke,
"menge": a.menge,
}
{"id": a.angebot_id, "titel": a.titel, "marke": a.marke, "menge": a.menge}
for a in batch
]
antworten = kategorisierer.klassifiziere(posten)
for ant in antworten:
aid = ant.get("id")
if aid not in original or aid in ergebnis:
continue # fremde/duplizierte ID ignorieren
gruppe, unsicher = _bereinige_gruppe(ant.get("gruppe"), ant.get("unsicher"))
ergebnis[aid] = KategorisiertesAngebot(
angebot=original[aid], gruppe=gruppe, unsicher=unsicher
)
if fortschritt is not None:
fortschritt(nr, gesamt_batches)
return kategorisierer.klassifiziere(posten)
def setze(aid, gruppe, unsicher):
if aid not in original or aid in ergebnis:
return
ergebnis[aid] = KategorisiertesAngebot(
angebot=original[aid], gruppe=gruppe, unsicher=unsicher
)
def uebernehmen(antworten):
with lock:
for ant in antworten:
aid = ant.get("id")
gruppe, unsicher = _bereinige_gruppe(
ant.get("gruppe"), ant.get("unsicher")
)
schluessel = schluessel_von_repr.get(aid)
if schluessel is not None:
# Repräsentant -> Ergebnis auf ALLE Angebote desselben Produkts
for ziel in aids_von_schluessel[schluessel]:
setze(ziel, gruppe, unsicher)
if not unsicher:
neu_fuer_cache[schluessel] = gruppe
else:
setze(aid, gruppe, unsicher)
erledigt[0] += 1
if fortschritt is not None:
fortschritt(erledigt[0], gesamt_batches)
if parallel > 1 and len(batches) > 1:
from concurrent.futures import ThreadPoolExecutor, as_completed
with ThreadPoolExecutor(max_workers=parallel) as ex:
futures = [ex.submit(verarbeite, b) for b in batches]
for fut in as_completed(futures):
uebernehmen(fut.result()) # AbbruchFehler propagiert bewusst
else:
for batch in batches:
uebernehmen(verarbeite(batch))
# Write-Back: neue, SICHERE Zuordnungen in den Cache (ein executemany).
if cache is not None and neu_fuer_cache:
cache.schreibe_viele(
[(s, g, modell_name) for s, g in neu_fuer_cache.items()]
)
if statistik is not None:
statistik["aus_cache"] = aus_cache
statistik["neu"] = len(zu_kategorisieren)
# Posten, die das Modell nicht (gültig) beantwortet hat: ehrlich als
# unsicher mit Fallback markieren -- nicht still einsortieren.
@ -140,6 +216,45 @@ def _user_text(posten: list[dict]) -> str:
)
def _finde_zuordnungen(obj):
"""Sucht rekursiv ein 'zuordnungen'-Array in einem geparsten JSON-Objekt."""
if isinstance(obj, dict):
if isinstance(obj.get("zuordnungen"), list):
return obj["zuordnungen"]
for v in obj.values():
treffer = _finde_zuordnungen(v)
if treffer:
return treffer
elif isinstance(obj, list):
for v in obj:
treffer = _finde_zuordnungen(v)
if treffer:
return treffer
return None
def _zuordnungen_aus_content(content) -> list | None:
"""Extrahiert ein 'zuordnungen'-Array aus einem content-String (Fallback).
Manche lokale Modelle verpacken die Tool-Antwort als (ggf. in ```json
eingefasstes) JSON im Text statt als echte tool_calls. Wir lesen das defensiv
aus -- finden wir nichts Verwertbares, geben wir None zurück (kein Raten).
"""
if not isinstance(content, str) or "{" not in content:
return None
import re
for roh in re.findall(r"\{.*\}", content, re.DOTALL):
try:
obj = json.loads(roh)
except json.JSONDecodeError:
continue
treffer = _finde_zuordnungen(obj)
if treffer:
return treffer
return None
def _wartezeit(antwort, versuch: int, basis: float) -> float:
"""Backoff: Retry-After respektieren, sonst exponentiell ab max(basis, 2s)."""
try:
@ -319,18 +434,58 @@ class OpenRouterKategorisierer:
vorschlag="Key/Modell/Guthaben prüfen oder --no-llm verwenden",
)
try:
aufrufe = daten["choices"][0]["message"]["tool_calls"]
args = json.loads(aufrufe[0]["function"]["arguments"])
return list(args.get("zuordnungen", []))
except (KeyError, IndexError, TypeError, json.JSONDecodeError):
# Modell hat das Tool nicht (sauber) aufgerufen -> leer; die
# kategorisiere()-Logik markiert die Posten dann als unsicher.
nachricht = daten["choices"][0]["message"]
except (KeyError, IndexError, TypeError):
return []
# 1) Sauberer Tool-Call (OpenRouter / gute Modelle).
try:
args = json.loads(nachricht["tool_calls"][0]["function"]["arguments"])
z = args.get("zuordnungen")
if z is not None:
return list(z)
except (KeyError, IndexError, TypeError, json.JSONDecodeError):
pass
# 2) Fallback: manche (lokale Ollama-) Modelle schreiben die Tool-Antwort
# als content-JSON statt als tool_calls. Defensiv auslesen, nicht raten.
z = _zuordnungen_aus_content(nachricht.get("content"))
return z if z else []
class OllamaKategorisierer(OpenRouterKategorisierer):
"""Lokaler LLM über Ollama (OpenAI-kompatibler Endpoint /v1/chat/completions).
Erbt die komplette Tool-Calling-Logik vom OpenRouter-Kategorisierer -- Ollama
spricht dasselbe OpenAI-Schema. Kein Key (der Dummy-Bearer wird ignoriert),
kein Netz, keine Drosselung. Nur tool-fähige lokale Modelle (z. B. qwen3.5,
qwen2.5-coder, gemma4) eignen sich; andere liefern keine sauberen tool_calls.
"""
def __init__(
self,
*,
modell: str,
base_url: str = "http://localhost:11434/v1",
session=None,
) -> None:
super().__init__(
api_key="ollama", # Dummy -- der lokale Server ignoriert den Bearer.
modell=modell,
base_url=base_url,
session=session,
mindest_abstand_s=0.0, # lokal: keine Rate-Limits
)
# Default je Anbieter. Für OpenRouter ein günstiges, aber verlässliches paid-
# Modell: deepseek-v4-flash macht sauberes Tool-Calling, ist über mehrere
# Batches konsistent und kostet nur ~1-2 Cent pro vollem Lauf (1903 Angebote).
# Free-Modelle sind bei OpenRouter oft hart gedrosselt. Für Ollama ein gängiges
# tool-fähiges lokales Modell als Fallback. Mit `--modell` (CLI) bzw. der
# Modellauswahl in der Web-UI lässt sich jederzeit ein anderes wählen.
_DEFAULT_MODELLE = {
"anthropic": "claude-sonnet-4-6",
"openrouter": "anthropic/claude-sonnet-4.6",
"openrouter": "deepseek/deepseek-v4-flash",
"ollama": "qwen3.5:latest",
}
@ -345,6 +500,8 @@ def baue_kategorisierer(
modell = modell or _DEFAULT_MODELLE.get(anbieter)
if anbieter == "openrouter":
return OpenRouterKategorisierer(modell=modell, api_key=api_key)
if anbieter == "ollama":
return OllamaKategorisierer(modell=modell)
if anbieter == "anthropic":
return AnthropicKategorisierer(modell=modell)
raise AbbruchFehler(

View file

@ -113,3 +113,55 @@ def suche(
if nur_tools:
res = [m for m in res if m.tools]
return sorted(res, key=_rang)
# --- Ollama (lokale LLMs) ----------------------------------------------------
OLLAMA_BASIS = "http://localhost:11434"
def _ollama_kann_tools(sess, name: str) -> bool:
"""Liest die Capabilities eines lokalen Modells (/api/show) -- 'tools'?"""
try:
r = sess.post(OLLAMA_BASIS + "/api/show", json={"model": name}, timeout=8)
r.raise_for_status()
return "tools" in (r.json().get("capabilities") or [])
except Exception:
return False # nicht belegbar -> konservativ als nicht tool-fähig
def lade_ollama_modelle(session=None) -> list[ModellInfo]:
"""Lokal installierte Ollama-Modelle (/api/tags), mit Tool-Fähigkeit.
`frei=True` (lokal = ohne Kosten). `tools` aus /api/show -- nur tool-fähige
Modelle taugen für die Kategorisierung. Läuft Ollama nicht, kommt eine LEERE
Liste zurück (kein Fehler -- der Anbieter ist optional), nicht geraten.
"""
sess = session
if sess is None:
import requests
sess = requests
try:
r = sess.get(OLLAMA_BASIS + "/api/tags", timeout=5)
r.raise_for_status()
eintraege = r.json().get("models", [])
except Exception:
return []
out: list[ModellInfo] = []
for m in eintraege:
name = m.get("name") or m.get("model") or ""
if not name:
continue
out.append(
ModellInfo(
id=name,
name=name,
context=None,
frei=True,
tools=_ollama_kann_tools(sess, name),
)
)
# tool-fähige zuerst, dann alphabetisch -- die brauchbaren oben.
return sorted(out, key=lambda mi: (not mi.tools, mi.id))

View file

@ -0,0 +1,129 @@
"""Produkt→Kategorie-Cache -- Teil des Kategorisier-Teils (Stufe 2).
Speichert pro PRODUKT-Identität (Titel + Marke, NICHT pro Angebot/Preis) die
einmal vom LLM ermittelte Produktgruppe. So muss ein bekanntes Produkt nicht
erneut eingeordnet werden -- über Zeit sinkt die LLM-Last drastisch (nur noch
*neue* Produkte gehen ans Modell), und ein günstiges Mini-Model genügt.
Schnitt-konform:
* Es werden ausschließlich Werte gespeichert, die vom LLM stammen (Gruppe) --
NIE Angebotsdaten (Preis/Händler/Gültigkeit). Der Cache erinnert nur an eine
bereits getroffene Einordnung; er repariert keine Daten.
* Er importiert KEIN LLM-Modul und keinen Kategorisier-Code (nur `config`).
* Geschlossene Liste: Gelesen UND geschrieben werden nur Gruppen aus
PRODUKTGRUPPEN. Eine off-list-Zeile (z. B. manipulierte DB oder geänderte
Liste) wird beim Lesen verworfen -- selbstheilend, kein TTL nötig.
* Nur SICHERE Zuordnungen werden abgelegt (siehe kategorisieren.py) -- so ist
jeder Cache-Treffer per Konstruktion sicher.
Der Cache ist global (ort-/wochenübergreifend): Die Kategorie eines Produkts ist
eine objektive Eigenschaft, unabhängig von PLZ, Woche oder Modell.
"""
from __future__ import annotations
import hashlib
import re
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from .config import PRODUKTGRUPPEN
# Globaler Ablageort, neben dem Roh-Cache. `/data/` ist bereits in .gitignore.
STANDARD_DB = Path("data/kategorie_cache.sqlite")
_WS = re.compile(r"\s+")
def _norm(text: str | None) -> str:
return _WS.sub(" ", (text or "").strip().lower())
def produkt_schluessel(titel: str, marke: str | None) -> str:
"""Stabile Produkt-Identität aus Titel + Marke (mengen-invariant).
Menge bleibt bewusst draußen: 'Butter 250 g' und 'Butter 500 g' sind dieselbe
Produktgruppe -- das maximiert die Trefferquote.
"""
roh = _norm(titel) + "|" + _norm(marke)
return hashlib.sha1(roh.encode("utf-8")).hexdigest()[:16]
@dataclass(frozen=True)
class CacheEintrag:
schluessel: str
gruppe: str
modell: str | None
gesehen_am: str # ISO
class ProduktCache:
"""Schneller, persistenter Produkt→Gruppe-Cache (SQLite + In-Memory-dict).
Beim ersten Zugriff wird die ganze (kleine) Tabelle einmalig in ein dict
geladen -> Lookups sind O(1) im RAM, kein SQL pro Posten. Geschrieben wird
gebündelt (`schreibe_viele`) in einer Transaktion.
"""
def __init__(self, *, db_pfad: Path | str | None = None) -> None:
self._pfad = Path(db_pfad) if db_pfad else STANDARD_DB
self._mem: dict[str, str] | None = None # lazy
self._init_db()
# -- intern -----------------------------------------------------------
def _verbinde(self):
import sqlite3
self._pfad.parent.mkdir(parents=True, exist_ok=True)
return sqlite3.connect(str(self._pfad))
def _init_db(self) -> None:
with self._verbinde() as con:
con.execute(
"CREATE TABLE IF NOT EXISTS produkt_kategorie ("
" schluessel TEXT PRIMARY KEY,"
" gruppe TEXT NOT NULL,"
" modell TEXT,"
" gesehen_am TEXT"
")"
)
def _lade(self) -> dict[str, str]:
if self._mem is None:
with self._verbinde() as con:
rows = con.execute(
"SELECT schluessel, gruppe FROM produkt_kategorie"
).fetchall()
# Whitelist auch beim Laden: nur gültige Gruppen in den Speicher.
self._mem = {k: g for k, g in rows if g in PRODUKTGRUPPEN}
return self._mem
# -- öffentliche API --------------------------------------------------
def hole(self, schluessel: str) -> str | None:
"""Gruppe für ein Produkt -- nur wenn sie in der geschlossenen Liste ist."""
gruppe = self._lade().get(schluessel)
return gruppe if gruppe in PRODUKTGRUPPEN else None
def schreibe_viele(self, eintraege: list[tuple[str, str, str | None]]) -> int:
"""Speichert (schluessel, gruppe, modell)-Tupel. Nur Gruppen aus der
geschlossenen Liste werden übernommen. Gibt die Zahl der Schreibungen."""
gueltig = [(s, g, m) for (s, g, m) in eintraege if g in PRODUKTGRUPPEN and s]
if not gueltig:
return 0
jetzt = datetime.now().isoformat()
with self._verbinde() as con:
con.executemany(
"INSERT OR REPLACE INTO produkt_kategorie "
"(schluessel, gruppe, modell, gesehen_am) VALUES (?, ?, ?, ?)",
[(s, g, m, jetzt) for (s, g, m) in gueltig],
)
mem = self._lade()
for s, g, _ in gueltig:
mem[s] = g
return len(gueltig)
def groesse(self) -> int:
return len(self._lade())

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

@ -44,16 +44,28 @@ def _angebot_dict(ka: KategorisiertesAngebot) -> dict:
def als_struktur(
fetch: FetchErgebnis,
kategorisiert: list[KategorisiertesAngebot],
*,
modell: str | None = None,
anbieter: str | None = None,
aus_cache: int | None = None,
neu: int | None = None,
) -> dict:
"""Strukturierte Ausgabe für die Web-UI -- dieselben belegten Felder wie der
Markdown-Renderer, nur als JSON-fähiges dict. Leere Gruppen bleiben enthalten
(als leere Liste) -- kein Weglassen, kein Auffüllen."""
(als leere Liste) -- kein Weglassen, kein Auffüllen.
`modell`/`anbieter` belegen, WOMIT kategorisiert wurde -- damit in der UI
nachvollziehbar bleibt, welches LLM die Einordnung vorgenommen hat."""
gruppen = gruppieren(kategorisiert)
return {
"ort_plz": fetch.ort_plz,
"ort_name": fetch.ort_name,
"anzahl": len(kategorisiert),
"unsicher": sum(1 for k in kategorisiert if k.unsicher),
"modell": modell,
"anbieter": anbieter,
"aus_cache": aus_cache,
"neu": neu,
"quellen": list(fetch.abgedeckte_quellen),
"haendler": list(fetch.gesehene_haendler),
"hinweise": list(fetch.hinweise),

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
@ -17,19 +23,21 @@ import uuid
from pathlib import Path
from fastapi import FastAPI, HTTPException
from fastapi.responses import HTMLResponse
from fastapi.responses import HTMLResponse, Response
from .fehler import AbbruchFehler
app = FastAPI(title="Angebots-Übersicht")
app = FastAPI(title="MABOTO — Marktbeobachtungstool")
_HTML = (Path(__file__).parent / "web_static" / "index.html").read_text("utf-8")
_FAVICON = (Path(__file__).parent / "web_static" / "favicon.svg").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] = {}
@ -39,33 +47,157 @@ def index() -> str:
return _HTML
@app.get("/favicon.svg")
def favicon() -> Response:
return Response(content=_FAVICON, media_type="image/svg+xml",
headers={"Cache-Control": "public, max-age=86400"})
@app.get("/api/modelle")
def api_modelle(q: str = "") -> list[dict]:
"""Modell-Liste für das Dropdown: Suche oder Top-Free. 'Aktualisieren' = neu rufen."""
"""Modell-Liste fürs Dropdown. Ohne Suche: das EMPFOHLENE Default-Modell
(günstig, nicht gedrosselt) zuerst, dann die Top-Free.
Hintergrund: Free-Modelle sind bei OpenRouter hart gedrosselt (429). Würde
die UI eines davon vorwählen, läuft der erste Lauf ins Rate-Limit. Deshalb
steht der Default (deepseek-v4-flash) vorne und wird vorgewählt.
"""
from .kategorisieren import _DEFAULT_MODELLE
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)
default_id = _DEFAULT_MODELLE.get("openrouter")
if q:
treffer = suche(alle, q)
else:
default_mi = next((m for m in alle if m.id == default_id), None)
top = [m for m in top_free(alle, 8) if m.id != default_id]
treffer = ([default_mi] if default_mi else []) + top
return [
{"id": m.id, "frei": m.frei, "tools": m.tools, "context": m.context}
{
"id": m.id, "frei": m.frei, "tools": m.tools, "context": m.context,
"empfohlen": m.id == default_id,
}
for m in treffer[:25]
]
@app.post("/api/lauf")
def api_lauf(req: dict) -> dict:
@app.get("/api/ollama-modelle")
def api_ollama_modelle() -> list[dict]:
"""Lokal installierte Ollama-Modelle. Leere Liste, wenn Ollama nicht läuft."""
from .modelle import lade_ollama_modelle
return [
{"id": m.id, "frei": m.frei, "tools": m.tools, "context": m.context}
for m in lade_ollama_modelle()
]
# === 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))
# Anbieter ist Teil des Schlüssels -- dasselbe Modell-Kürzel kann je Anbieter
# etwas anderes bedeuten.
treffer = _ergebnis_cache.get((plz, anbieter, modell))
if treffer is not None:
with _jobs_lock:
_jobs[job_id] = {
@ -77,22 +209,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,41 +232,125 @@ def api_status(job_id: str) -> dict:
return job
def _run_job(job_id, plz, modell, anbieter, no_llm, key) -> None:
# === 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.
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 .produktcache import ProduktCache
from .uebersicht import als_struktur
fetch = hole_angebote(plz) # deterministisch; AbbruchFehler bei Regel 4
kt = baue_kategorisierer(anbieter, modell, api_key=key)
# Tatsächlich genutztes Modell (Default je Anbieter aufgelöst) -- für die
# sichtbare Herkunft im Ergebnis.
modell_genutzt = getattr(kt, "_modell", modell)
print(
f"[Stufe 2] start · PLZ {plz} · {anbieter}/{modell_genutzt} · "
f"{len(fetch.angebote)} Angebote",
flush=True,
)
if no_llm:
# Ohne LLM: belegte Rohliste, sichtbar als unkategorisiert markiert.
from .config import FALLBACK_GRUPPE
def fort(done, total):
job["done"] = done
job["total"] = total
if done == total or done % 5 == 0: # nicht jede Batch -> Log lesbar
print(f"[Stufe 2] PLZ {plz} · Batch {done}/{total}", flush=True)
kat = [
KategorisiertesAngebot(a, FALLBACK_GRUPPE, unsicher=True)
for a in fetch.angebote
]
else:
from .kategorisieren import baue_kategorisierer, kategorisiere
cache = ProduktCache() # Produkt->Kategorie-Cache: bekannte Produkte ohne LLM
stat: dict = {}
kat = kategorisiere(
list(fetch.angebote), kt, fortschritt=fort, cache=cache, statistik=stat
)
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["ergebnis"] = als_struktur(
fetch, kat, modell=modell_genutzt, anbieter=anbieter,
aus_cache=stat.get("aus_cache"), neu=stat.get("neu"),
)
job["status"] = "fertig"
_ergebnis_cache[(plz, modell, no_llm)] = job["ergebnis"]
_ergebnis_cache[(plz, anbieter, modell)] = job["ergebnis"]
print(
f"[Stufe 2] fertig · PLZ {plz} · {stat.get('aus_cache', 0)} aus Cache · "
f"{stat.get('neu', 0)} neu · {job['ergebnis']['unsicher']} unsicher",
flush=True,
)
except AbbruchFehler as e:
job["status"] = "fehler"
job["fehler"] = e.als_text()
print(f"[Stufe 2] ABBRUCH · PLZ {plz} · {e.schwelle}: {e.ursache}", flush=True)
except Exception as e: # nichts verstecken -- ehrliche Fehlermeldung
job["status"] = "fehler"
job["fehler"] = f"Unerwarteter Fehler: {e}"

View file

@ -0,0 +1,18 @@
<svg viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="MABOTO">
<defs>
<linearGradient id="mb" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#22936b"/>
<stop offset="1" stop-color="#0c5238"/>
</linearGradient>
</defs>
<rect width="32" height="32" rx="8" fill="url(#mb)"/>
<g transform="translate(3.4,3.4)">
<!-- Preis-Balken (Marktdaten) im Sichtfeld -->
<rect x="7.0" y="9.6" width="1.8" height="3.8" rx="0.6" fill="#fff"/>
<rect x="9.1" y="7.7" width="1.8" height="5.7" rx="0.6" fill="#fff"/>
<rect x="11.2" y="5.8" width="1.8" height="7.6" rx="0.6" fill="#fff"/>
<!-- Lupe (Beobachtung) -->
<circle cx="10" cy="10" r="6.4" fill="none" stroke="#fff" stroke-width="2.0"/>
<path d="M14.8 14.8 L20.4 20.4" stroke="#fff" stroke-width="2.7" stroke-linecap="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 875 B

File diff suppressed because it is too large Load diff

View file

@ -37,6 +37,24 @@ class FakeKategorisierer:
return self._fn(posten)
class CountingFakeKategorisierer:
"""Zählt die ans LLM gegebenen Posten -- für Cache-Tests (Hit/Dedup)."""
def __init__(self, gruppe: str = "Sonstiges", unsicher: bool = False):
self.gesehen = 0
self.titel: list[str] = []
self._gruppe = gruppe
self._unsicher = unsicher
def klassifiziere(self, posten: list[dict]) -> list[dict]:
self.gesehen += len(posten)
self.titel.extend(p["titel"] for p in posten)
return [
{"id": p["id"], "gruppe": self._gruppe, "unsicher": self._unsicher}
for p in posten
]
def beispiel_angebot(titel="Butter", **kw) -> Angebot:
"""Belegtes Angebot mit Default-Pflichtfeldern; einzeln überschreibbar."""
daten = dict(

View file

@ -0,0 +1,64 @@
"""Cache-Integration in kategorisiere() -- offline, eigene DB pro Test."""
from angebote.kategorisieren import kategorisiere
from angebote.produktcache import ProduktCache
from tests.fakes import CountingFakeKategorisierer, beispiel_angebot
def _cache(tmp_path):
return ProduktCache(db_pfad=tmp_path / "c.sqlite")
def test_zweiter_lauf_komplett_aus_cache(tmp_path):
cache = _cache(tmp_path)
angebote = [
beispiel_angebot("Butter", marke="Meggle"),
beispiel_angebot("Apfel", marke=None),
]
# 1. Lauf: alles neu ans LLM, wird gecacht
fake1 = CountingFakeKategorisierer("Sonstiges", unsicher=False)
stat1 = {}
kategorisiere(angebote, fake1, cache=cache, statistik=stat1)
assert fake1.gesehen == 2
assert stat1 == {"aus_cache": 0, "neu": 2}
# 2. Lauf: nichts mehr ans LLM (alles aus Cache)
fake2 = CountingFakeKategorisierer("Sonstiges")
stat2 = {}
erg = kategorisiere(angebote, fake2, cache=cache, statistik=stat2)
assert fake2.gesehen == 0
assert stat2 == {"aus_cache": 2, "neu": 0}
assert all(k.gruppe == "Sonstiges" and not k.unsicher for k in erg)
def test_dedup_ein_produkt_nur_ein_posten(tmp_path):
cache = _cache(tmp_path)
# zwei Angebote DESSELBEN Produkts (Titel+Marke), aber versch. Preis/Händler
a1 = beispiel_angebot("Butter", marke="Meggle", preis=1.49, haendler="REWE")
a2 = beispiel_angebot("Butter", marke="Meggle", preis=1.99, haendler="EDEKA")
assert a1.angebot_id != a2.angebot_id
fake = CountingFakeKategorisierer("Molkereiprodukte & Eier")
erg = kategorisiere([a1, a2], fake, cache=cache)
assert fake.gesehen == 1 # nur EIN Posten ans LLM
assert len(erg) == 2
assert all(k.gruppe == "Molkereiprodukte & Eier" for k in erg)
def test_unsichere_werden_nicht_gecacht(tmp_path):
cache = _cache(tmp_path)
a = beispiel_angebot("Hafer-Pflanzendrink", marke=None)
fake = CountingFakeKategorisierer("Molkereiprodukte & Eier", unsicher=True)
kategorisiere([a], fake, cache=cache)
assert cache.groesse() == 0 # unsicher -> nicht gespeichert
# Folge-Lauf fragt erneut (keine Propagation des Zweifels)
fake2 = CountingFakeKategorisierer("Sonstiges")
kategorisiere([a], fake2, cache=cache)
assert fake2.gesehen == 1
def test_ohne_cache_geht_alles_ans_llm(tmp_path):
angebote = [beispiel_angebot("Butter"), beispiel_angebot("Apfel", marke=None)]
fake = CountingFakeKategorisierer("Sonstiges")
erg = kategorisiere(angebote, fake) # cache=None
assert fake.gesehen == 2
assert len(erg) == 2

View file

@ -35,6 +35,23 @@ def test_originaldaten_unveraendert_property():
assert nachher is vorher
def test_parallel_ordnet_alle_batches_vollstaendig_zu():
# 60 Angebote -> 3 Batches -> parallel verarbeitet; nichts darf verloren gehen.
angebote = [beispiel_angebot(f"Artikel {i}", preis=float(i)) for i in range(60)]
fake = FakeKategorisierer(
lambda posten: [
{"id": p["id"], "gruppe": "Sonstiges", "unsicher": False} for p in posten
]
)
ergebnis = kategorisiere(angebote, fake, batch_groesse=25, parallel=4)
assert len(ergebnis) == 60
assert all(k.gruppe == "Sonstiges" and not k.unsicher for k in ergebnis)
# jedes Original-Angebot bleibt unverändert erhalten (id-basiert gemappt)
ids_in = {a.angebot_id for a in angebote}
ids_out = {k.angebot.angebot_id for k in ergebnis}
assert ids_in == ids_out
def test_fehlender_preis_bleibt_fehlend():
angebot = beispiel_angebot("Hähnchen", preis=None)
ergebnis = kategorisiere([angebot], _gib_gruppe("Fleisch & Wurst"))

View file

@ -141,4 +141,63 @@ def test_openrouter_429_erschoepft_bricht_als_ratelimit_ab():
with pytest.raises(AbbruchFehler) as exc:
kat.klassifiziere([{"id": "x", "titel": "Apfel"}])
assert "Rate-Limit" in exc.value.schwelle
assert seq.calls == 3
assert seq.calls == 3
def test_baue_kategorisierer_ollama_ohne_key():
from angebote.kategorisieren import OllamaKategorisierer, baue_kategorisierer
# Ollama braucht KEINEN Key -- darf also nicht abbrechen.
kt = baue_kategorisierer("ollama", "qwen3.5:latest")
assert isinstance(kt, OllamaKategorisierer)
def test_ollama_kategorisierer_parst_tool_calls_lokal():
from angebote.kategorisieren import OllamaKategorisierer
a = beispiel_angebot("Apfel")
payload = _openrouter_payload(
[{"id": a.angebot_id, "gruppe": "Obst & Gemüse", "unsicher": False}]
)
sess = _FakeSession(payload)
kt = OllamaKategorisierer(modell="qwen3.5:latest", session=sess)
ergebnis = kategorisiere([a], kt)
assert ergebnis[0].gruppe == "Obst & Gemüse"
# OpenAI-kompatibel an den lokalen Endpoint adressiert:
assert sess.calls[0]["url"].endswith("/chat/completions")
assert "localhost:11434" in sess.calls[0]["url"]
def test_content_fallback_extrahiert_zuordnungen():
from angebote.kategorisieren import _zuordnungen_aus_content
# Manche (lokale) Modelle verpacken die Tool-Antwort als content-JSON.
c = (
'```json\n{"name":"zuordnungen","arguments":{"zuordnungen":'
'[{"id":"x","gruppe":"Obst & Gemüse","unsicher":false}]}}\n```'
)
z = _zuordnungen_aus_content(c)
assert z == [{"id": "x", "gruppe": "Obst & Gemüse", "unsicher": False}]
def test_content_fallback_ohne_json_gibt_none():
from angebote.kategorisieren import _zuordnungen_aus_content
assert _zuordnungen_aus_content("nur Text, kein JSON") is None
assert _zuordnungen_aus_content(None) is None
def test_kategorisierer_nutzt_content_fallback_ohne_tool_calls():
# Antwort OHNE tool_calls, aber mit content-JSON -> Fallback greift.
from angebote.kategorisieren import OllamaKategorisierer
a = beispiel_angebot("Apfel")
inhalt = (
'{"zuordnungen":[{"id":"' + a.angebot_id
+ '","gruppe":"Obst & Gemüse","unsicher":false}]}'
)
payload = {"choices": [{"message": {"content": inhalt}}]}
sess = _FakeSession(payload)
kt = OllamaKategorisierer(modell="x", session=sess)
ergebnis = kategorisiere([a], kt)
assert ergebnis[0].gruppe == "Obst & Gemüse"

103
tests/test_korrektur.py Normal file
View file

@ -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)

View file

@ -134,3 +134,48 @@ def test_picker_quit_gibt_none():
ausgabe=lambda s: None,
)
assert gewaehlt is None
# --- Ollama (lokale Modelle) -------------------------------------------------
class _OllamaSession:
"""Fake für /api/tags (Liste) + /api/show (Capabilities)."""
def __init__(self, namen, caps):
self._namen = namen
self._caps = caps
def get(self, url, timeout=None):
return _Resp({"models": [{"name": n} for n in self._namen]})
def post(self, url, json=None, timeout=None):
name = (json or {}).get("model")
return _Resp({"capabilities": self._caps.get(name, [])})
def test_lade_ollama_modelle_tool_faehige_zuerst():
from angebote.modelle import lade_ollama_modelle
sess = _OllamaSession(
["gemma3:latest", "qwen3.5:latest", "nomic-embed:latest"],
{
"qwen3.5:latest": ["completion", "tools"],
"gemma3:latest": ["completion", "vision"],
"nomic-embed:latest": ["embedding"],
},
)
modelle = lade_ollama_modelle(session=sess)
assert all(m.frei for m in modelle) # lokal = frei
assert modelle[0].id == "qwen3.5:latest" and modelle[0].tools is True
assert any(m.id == "gemma3:latest" and not m.tools for m in modelle)
def test_lade_ollama_modelle_leer_wenn_server_aus():
from angebote.modelle import lade_ollama_modelle
class _Down:
def get(self, *a, **k):
raise OSError("connection refused")
assert lade_ollama_modelle(session=_Down()) == []

101
tests/test_produktcache.py Normal file
View file

@ -0,0 +1,101 @@
"""Tests für den Produkt→Kategorie-Cache -- offline, eigene DB pro Test."""
import subprocess
import sys
from pathlib import Path
from angebote.produktcache import ProduktCache, produkt_schluessel
SRC = Path(__file__).resolve().parents[1] / "src"
def _cache(tmp_path) -> ProduktCache:
return ProduktCache(db_pfad=tmp_path / "cache.sqlite")
# -- Schlüssel ----------------------------------------------------------------
def test_schluessel_ist_mengen_invariant():
# Titel + Marke bestimmen den Schlüssel; Menge spielt keine Rolle.
assert produkt_schluessel("Butter", "Meggle") == produkt_schluessel(
" butter ", "MEGGLE"
)
def test_schluessel_unterscheidet_marke():
assert produkt_schluessel("Butter", "Meggle") != produkt_schluessel(
"Butter", "Kerrygold"
)
# -- Round-Trip / Persistenz --------------------------------------------------
def test_round_trip_ueber_instanzgrenzen(tmp_path):
db = tmp_path / "c.sqlite"
c1 = ProduktCache(db_pfad=db)
s = produkt_schluessel("Toffifee", "Storck")
c1.schreibe_viele([(s, "Süßwaren & Snacks", "deepseek")])
# frische Instanz auf derselben DB
c2 = ProduktCache(db_pfad=db)
assert c2.hole(s) == "Süßwaren & Snacks"
assert c2.groesse() == 1
def test_unbekannter_schluessel_gibt_none(tmp_path):
c = _cache(tmp_path)
assert c.hole("gibtsnicht") is None
# -- Geschlossene Liste (Whitelist) ------------------------------------------
def test_off_list_gruppe_wird_nicht_geschrieben(tmp_path):
c = _cache(tmp_path)
n = c.schreibe_viele([("k1", "Weltraumzeug", None)])
assert n == 0
assert c.hole("k1") is None
def test_off_list_zeile_in_db_wird_beim_lesen_verworfen(tmp_path):
import sqlite3
db = tmp_path / "c.sqlite"
ProduktCache(db_pfad=db) # legt Tabelle an
# manipulierte Zeile direkt in die DB schreiben
with sqlite3.connect(str(db)) as con:
con.execute(
"INSERT INTO produkt_kategorie VALUES (?,?,?,?)",
("k1", "Quatschgruppe", None, "2026-01-01"),
)
c = ProduktCache(db_pfad=db)
assert c.hole("k1") is None # Whitelist filtert sie heraus
assert c.groesse() == 0
# -- Schnitt: kein LLM im Cache ----------------------------------------------
def test_cache_laedt_kein_anthropic():
code = (
"import sys; import angebote.produktcache; "
"assert 'anthropic' not in sys.modules, 'Cache hat anthropic geladen'; "
"print('ok')"
)
proc = subprocess.run(
[sys.executable, "-c", code], cwd=str(SRC), capture_output=True, text=True
)
assert proc.returncode == 0, proc.stderr
assert "ok" in proc.stdout
def test_cache_importiert_kein_llm():
quelltext = (SRC / "angebote" / "produktcache.py").read_text("utf-8")
import_zeilen = "\n".join(
z for z in quelltext.splitlines()
if z.strip().startswith(("import ", "from "))
).lower()
assert "anthropic" not in import_zeilen
assert "openai" not in import_zeilen
assert "kategorisieren" not in import_zeilen

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

@ -0,0 +1,94 @@
"""E2E-Test: die LLM-Konfiguration (Anbieter + Modell) überlebt einen Reload.
Echter Browser (Playwright) gegen einen frisch gestarteten Server. Prüft das
Verhalten, das im UI vorher fehlte: nach Umschalten auf Ollama und Neuladen darf
die UI NICHT auf den OpenRouter-Default zurückspringen.
Wird übersprungen, wenn Playwright/Chromium nicht installiert ist.
"""
import os
import socket
import subprocess
import sys
import time
import urllib.request
from pathlib import Path
import pytest
sync_api = pytest.importorskip("playwright.sync_api")
from playwright.sync_api import sync_playwright # noqa: E402
REPO = Path(__file__).resolve().parents[1]
SRC = REPO / "src"
def _freier_port() -> int:
s = socket.socket()
s.bind(("127.0.0.1", 0))
port = s.getsockname()[1]
s.close()
return port
@pytest.fixture(scope="module")
def server():
port = _freier_port()
env = {**os.environ, "PYTHONPATH": str(SRC)}
proc = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "angebote.web:app",
"--port", str(port), "--log-level", "warning"],
cwd=str(SRC), env=env,
)
base = f"http://127.0.0.1:{port}"
try:
for _ in range(60):
try:
urllib.request.urlopen(base + "/", timeout=1)
break
except Exception:
time.sleep(0.25)
else:
raise RuntimeError("Server kam nicht hoch")
yield base
finally:
proc.terminate()
try:
proc.wait(timeout=10)
except Exception:
proc.kill()
def _chromium_da() -> bool:
try:
with sync_playwright() as p:
b = p.chromium.launch()
b.close()
return True
except Exception:
return False
@pytest.mark.skipif(not _chromium_da(), reason="Chromium für Playwright nicht installiert")
def test_anbieter_persistiert_ueber_reload(server):
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
page.goto(server)
page.wait_for_selector("#anbieter", state="attached")
# Konfig-Panel aufklappen (der Anbieter-Select liegt darin), dann
# auf Ollama umschalten -> löst change-Event + merkeWahl() aus.
page.evaluate("document.getElementById('config').open = true")
page.select_option("#anbieter", "ollama")
page.wait_for_timeout(1000)
# Reload -> init() muss die gemerkte Wahl wiederherstellen.
page.reload()
page.wait_for_selector("#anbieter", state="attached")
page.wait_for_timeout(1000)
wert = page.eval_on_selector("#anbieter", "el => el.value")
assert wert == "ollama", f"Anbieter nach Reload = {wert!r}, erwartet 'ollama'"
browser.close()

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
@ -48,7 +51,15 @@ def test_index_liefert_html():
client = TestClient(web.app)
r = client.get("/")
assert r.status_code == 200
assert "Angebots-Übersicht" in r.text
assert "MABOTO" in r.text
def test_favicon_liefert_svg():
client = TestClient(web.app)
r = client.get("/favicon.svg")
assert r.status_code == 200
assert r.headers["content-type"].startswith("image/svg+xml")
assert "<svg" in r.text
def test_api_modelle_gibt_top_free(monkeypatch):
@ -66,13 +77,103 @@ 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
def test_als_struktur_fuehrt_modell_und_anbieter():
a = beispiel_angebot("Butter")
kat = [KategorisiertesAngebot(a, "Molkereiprodukte & Eier", unsicher=False)]
s = als_struktur(_fetch_mit([a]), kat, modell="qwen3.5:latest", anbieter="ollama")
assert s["modell"] == "qwen3.5:latest"
assert s["anbieter"] == "ollama"
def test_api_ollama_modelle(monkeypatch):
fake = [ModellInfo("qwen3.5:latest", "qwen3.5:latest", None, True, True)]
monkeypatch.setattr(
"angebote.modelle.lade_ollama_modelle", lambda session=None: fake
)
client = TestClient(web.app)
r = client.get("/api/ollama-modelle")
assert r.status_code == 200
daten = r.json()
assert daten[0]["id"] == "qwen3.5:latest" and daten[0]["tools"] is True
# === 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