From 39b8a98bc247289f514145473451887f7164d790 Mon Sep 17 00:00:00 2001 From: Jeuner <62662523+Jeuners@users.noreply.github.com> Date: Wed, 3 Jun 2026 09:29:59 +0200 Subject: [PATCH] =?UTF-8?q?Initiale=20Implementierung:=20Angebots-=C3=9Cbe?= =?UTF-8?q?rsicht=20(Fetch=20+=20Kategorisierung=20+=20Web-UI)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der Schnitt aus CLAUDE.md ist durchgehalten: - Fetch (deterministisch, kein LLM): marktguru-Adapter mit geprüftem Ortsbezug (zipCode), Wochen-Cache, robots.txt-Respekt, ehrlicher Regel-4- Abbruch bei fehlendem Beleg statt Krücke. - Kategorisierung (einziger LLM-Ort): geschlossene Liste + Daten-Integrität als Code erzwungen; austauschbar via Protokoll (OpenRouter/Anthropic), mit Drosselung/Retry und ehrlichem Abbruch. - FastAPI-Web-UI als dünne Schicht: Modellauswahl (Liste/Suche/Refresh), Live-Fortschritt, gruppierte Ergebnisse mit Filtern, Ergebnis-Cache. - 36 Tests gegen die Architektur-Regeln (kein Auffüllen, Abbruch, Integrität, geschlossene Liste, Unsicherheit, Schnitt) und die Web-Schicht. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/skills/angebote-fetch/SKILL.md | 125 +++++ .../skills/angebote-kategorisieren/SKILL.md | 102 +++++ .gitignore | 14 + CLAUDE.md | 90 ++++ README.md | 102 +++++ pyproject.toml | 16 + requirements.txt | 15 + src/angebote/__init__.py | 10 + src/angebote/__main__.py | 3 + src/angebote/cli.py | 167 +++++++ src/angebote/config.py | 87 ++++ src/angebote/fehler.py | 25 + src/angebote/fetch.py | 98 ++++ src/angebote/kategorisieren.py | 354 +++++++++++++++ src/angebote/modell.py | 90 ++++ src/angebote/modellauswahl.py | 100 ++++ src/angebote/modelle.py | 115 +++++ src/angebote/quellen/__init__.py | 1 + src/angebote/quellen/basis.py | 41 ++ src/angebote/quellen/marktguru.py | 427 ++++++++++++++++++ src/angebote/uebersicht.py | 137 ++++++ src/angebote/web.py | 147 ++++++ src/angebote/web_static/index.html | 310 +++++++++++++ tests/__init__.py | 0 tests/fakes.py | 54 +++ tests/test_fetch_abbruch_ort.py | 41 ++ tests/test_fetch_kein_auffuellen.py | 21 + .../test_kategorisieren_geschlossene_liste.py | 35 ++ tests/test_kategorisieren_integritaet.py | 53 +++ tests/test_kategorisieren_unsicherheit.py | 41 ++ tests/test_kategorisierer_anbieter.py | 144 ++++++ tests/test_modelle.py | 136 ++++++ tests/test_schnitt.py | 36 ++ tests/test_web.py | 78 ++++ 34 files changed, 3215 insertions(+) create mode 100644 .claude/skills/angebote-fetch/SKILL.md create mode 100644 .claude/skills/angebote-kategorisieren/SKILL.md create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 pyproject.toml create mode 100644 requirements.txt create mode 100644 src/angebote/__init__.py create mode 100644 src/angebote/__main__.py create mode 100644 src/angebote/cli.py create mode 100644 src/angebote/config.py create mode 100644 src/angebote/fehler.py create mode 100644 src/angebote/fetch.py create mode 100644 src/angebote/kategorisieren.py create mode 100644 src/angebote/modell.py create mode 100644 src/angebote/modellauswahl.py create mode 100644 src/angebote/modelle.py create mode 100644 src/angebote/quellen/__init__.py create mode 100644 src/angebote/quellen/basis.py create mode 100644 src/angebote/quellen/marktguru.py create mode 100644 src/angebote/uebersicht.py create mode 100644 src/angebote/web.py create mode 100644 src/angebote/web_static/index.html create mode 100644 tests/__init__.py create mode 100644 tests/fakes.py create mode 100644 tests/test_fetch_abbruch_ort.py create mode 100644 tests/test_fetch_kein_auffuellen.py create mode 100644 tests/test_kategorisieren_geschlossene_liste.py create mode 100644 tests/test_kategorisieren_integritaet.py create mode 100644 tests/test_kategorisieren_unsicherheit.py create mode 100644 tests/test_kategorisierer_anbieter.py create mode 100644 tests/test_modelle.py create mode 100644 tests/test_schnitt.py create mode 100644 tests/test_web.py diff --git a/.claude/skills/angebote-fetch/SKILL.md b/.claude/skills/angebote-fetch/SKILL.md new file mode 100644 index 0000000..12b480a --- /dev/null +++ b/.claude/skills/angebote-fetch/SKILL.md @@ -0,0 +1,125 @@ +--- +name: angebote-fetch +description: > + Deterministischer Abruf wöchentlicher Supermarkt-/Discounter-Angebote für + einen konkreten Ort. Verwenden, wenn aktuelle Angebotsdaten beschafft werden + sollen. Dieser Skill enthält KEINE LLM-Aufrufe -- er holt, parst und + normalisiert nur. Trigger: "Angebote holen", "Daten für Ort X", "Prospekte + abrufen", Aufbau/Pflege der Datenbeschaffung. +--- + +# angebote-fetch + +## Zweck + +Beschaffe für einen gegebenen Ort (PLZ oder Ortsname) die aktuell gültigen +Angebote, händlerübergreifend, und gib sie als normalisierten, belegten +Datensatz zurück. Nichts wird interpretiert, kategorisiert oder bewertet -- +das ist Aufgabe des Skills `angebote-kategorisieren`. + +## Harte Regeln (Architektur, nicht Präferenz) + +Diese Regeln gelten unabhängig von Kontext und Plausibilität: + +1. **Keine LLM-Aufrufe.** Dieser Teil ist rein deterministisch. Wenn du + versucht bist, ein Modell "zum Aufräumen" einzusetzen, ist das der falsche + Skill -- halte an. +2. **Nur Belegtes.** Jedes zurückgegebene Angebot trägt seine Quelle (Händler, + Quell-URL/-ID, Abrufzeitpunkt). Felder, die in der Quelle fehlen, werden als + `null`/fehlend markiert -- niemals geraten oder geschätzt. +3. **Kein Auffüllen.** Wenn eine Quelle nichts liefert, wird das als leeres + Ergebnis dieser Quelle gemeldet, nicht durch Beispiele ersetzt. +4. **Abbruch bei Spezifitätsmangel.** Wenn der Ort nicht aufgelöst werden kann + oder keine Quelle für den Ort filtert, brich mit einer klaren Meldung ab: + Schwelle (welche), Ursache (Ort nicht auflösbar / keine Quelle deckt Ort ab), + konkreter Vorschlag (z. B. größerer Ort in der Nähe, andere Quelle). + Liefere kein ortsfremdes Ergebnis als Notlösung. + +## Datenmodell + +Ein normalisiertes Angebot (Vorschlag, anpassbar): + +```python +@dataclass +class Angebot: + titel: str # Produktname, wie in der Quelle + marke: str | None # falls vorhanden + preis: float | None # in EUR; None wenn nicht eindeutig parsebar + grundpreis: str | None # z. B. "1 kg = 4,44 EUR", roh übernommen + menge: str | None # z. B. "200g Packung", roh übernommen + gueltig_von: date | None + gueltig_bis: date | None + haendler: str # Pflicht + quelle: str # URL oder Quell-ID, Pflicht + abgerufen_am: datetime # Pflicht + # bewusst KEIN feld "produktgruppe" -- das setzt der andere skill +``` + +Das Fehlen eines `produktgruppe`-Felds ist Absicht: Die Trennung der beiden +Verantwortungen ist im Datenmodell verankert. In der Implementierung ist +`Angebot` zusätzlich **eingefroren** (`frozen=True`) -- der Kategorisier-Schritt +*kann* die Daten damit nicht verändern, nicht nur *soll* es nicht. + +## Quellen + +Kandidaten sind Angebots-Aggregatoren mit Ortsfilter. Wichtige bekannte +Eigenheiten: + +- Der Ortsfilter steckt bei vielen Aggregatoren in Session/Cookie, nicht in der + URL -- ein roher Abruf ohne Standort liefert ggf. bundesweite Angebote. + Stelle den Ortsbezug explizit her und prüfe ihn, statt ihm zu vertrauen. +- Manche Quellen liefern strukturierte Felder (Titel, Marke, Preis, Gültigkeit, + Händler) gut parsebar; andere nur als Bild-Prospekt. Bild-Prospekte sind + außerhalb des Scopes dieses Skills, solange keine strukturierte Quelle + existiert -- in dem Fall greift Regel 4 (Abbruch + ehrlicher Hinweis). +- Discounter (Aldi, Lidl) sind bei Aggregatoren oft unterrepräsentiert. Das + ist ein Abdeckungsloch, das im Ergebnis ehrlich ausgewiesen wird + ("Discounter X nicht abgedeckt"), nicht kaschiert. + +Halte die konkrete Quellenliste in einem Config-/Adapter-Modul, damit Quellen +ausgetauscht werden können, ohne die Kernlogik anzufassen. Ein Adapter pro +Quelle (gleiche Schnittstelle: rein = Ort, raus = Liste[Angebot]). + +## Erwartetes Scheitern (Regel 4 ist der Normalfall, nicht der Ausnahmefall) + +Beim Abruf realer Aggregatoren ist Scheitern eingeplant, nicht überraschend. +Die folgenden Fälle sind **vorgesehene Abbruchfälle** -- es wird kein brüchiger +Workaround gebaut, der "irgendwie etwas" liefert: + +- **Ortsfilter in Session/Cookie statt URL.** Liefert eine Quelle trotz + gesetztem Ort erkennbar bundesweite statt ortsbezogene Angebote, ist der + Ortsbezug *nicht verifiziert*. Dann bricht der Adapter ab (Regel 4) -- + er gibt nicht ein bundesweites Ergebnis als "Ort X" aus. +- **Nur Bild-Prospekte.** Liefert eine Quelle ausschließlich Prospekt-Bilder + ohne strukturierte Felder, ist sie außerhalb des Scopes. Kein OCR-Raten, + keine geschätzten Preise -- die Quelle meldet "keine strukturierten Daten" + und wird übersprungen oder führt (wenn sie die einzige Quelle war) zum + Abbruch mit Hinweis. +- **Zugang/Schlüssel fehlt oder bricht weg.** Lässt sich der für eine Quelle + nötige Zugang (z. B. ein aus der Seite gelesener Client-/API-Schlüssel) nicht + zuverlässig herstellen, ist das ein Abbruchgrund -- kein hartkodierter, + geratener Schlüssel. + +Merksatz: **Den ehrlichen Abbruch bauen, nicht die Krücke.** Ein Abbruch mit +klarer Ursache und Vorschlag ist ein korrektes Ergebnis dieses Skills. Ein +plausibel aussehender, aber unbelegt zusammengeflickter Datensatz ist ein +Fehler -- auch wenn er "funktioniert". + +Die Verifikation des Ortsbezugs ist deshalb selbst eine prüfbare Bedingung im +Datenfluss: Der Adapter belegt, *dass* und *womit* er den Ort gefiltert hat +(z. B. zurückgegebener `zipCode` im Request, Händler-/Filiale-Bezug in der +Antwort), und bricht ab, wenn dieser Beleg fehlt. + +## Robustheit + +- Respektiere robots.txt und vernünftige Request-Raten; cache Ergebnisse pro + Ort/Woche, statt bei jedem Lauf neu zu ziehen. +- Schreibe Tests für: leeres Quellergebnis (→ kein Auffüllen), nicht + auflösbarer Ort (→ Abbruch mit Meldung), fehlende Felder (→ als fehlend + markiert, nicht geraten). + +## Was dieser Skill NICHT tut + +- Keine Produktgruppen-Zuordnung. +- Keine Bewertung "guter"/"schlechter" Angebote. +- Keine sprachliche Aufbereitung. Nur Beschaffung und Normalisierung. diff --git a/.claude/skills/angebote-kategorisieren/SKILL.md b/.claude/skills/angebote-kategorisieren/SKILL.md new file mode 100644 index 0000000..587a04d --- /dev/null +++ b/.claude/skills/angebote-kategorisieren/SKILL.md @@ -0,0 +1,102 @@ +--- +name: angebote-kategorisieren +description: > + Ordnet einen flachen, bereits beschafften Angebots-Stream in saubere + Produktgruppen. Dies ist der EINZIGE Teil des Projekts, in dem ein LLM + eingesetzt wird -- weil die Einordnung genuin unscharf ist. Verwenden, wenn + normalisierte Angebote (aus angebote-fetch) in eine nach Kategorien + geordnete Übersicht gebracht werden sollen. Trigger: "kategorisieren", + "nach Produktgruppen sortieren", "Übersicht bauen". +--- + +# angebote-kategorisieren + +## Zweck + +Nimm die normalisierten, belegten Angebote aus `angebote-fetch` und ordne jedes +einer Produktgruppe zu. Ergebnis ist eine nach Kategorien gruppierte Übersicht. +Dieser Skill **verändert keine Angebotsdaten** -- er fügt nur die +Gruppenzuordnung hinzu. + +## Warum hier ein LLM sinnvoll ist (und nur hier) + +Die Zuordnung ist die echte Ambiguität der Aufgabe. Regelbasiert ("enthält +'Käse' → Molkereiprodukte") scheitert an Toffifee, Schwarzwälder Schinken, +Grundnahrungsmitteln mit Markennamen, Non-Food in Supermarktprospekten. Diese +unscharfe Einordnung ist exakt die Stärke eines Sprachmodells. Deshalb läuft +**nur** dieser Schritt über ein LLM -- nicht die Datenbeschaffung. + +## Harte Regeln (Architektur, nicht Präferenz) + +1. **Daten sind unantastbar.** Das LLM darf Titel, Preis, Gültigkeit, Händler + NICHT verändern, korrigieren oder ergänzen. Es vergibt ausschließlich eine + Produktgruppe. Wenn ein Preis fehlt, bleibt er fehlend -- nicht "ergänzen". +2. **Geschlossene Kategorienliste.** Das Modell wählt aus einer fest + definierten Liste von Produktgruppen (s. u.). Es erfindet keine neuen + Kategorien. Passt ein Artikel in keine, kommt er in `Sonstiges` bzw. + `Non-Food`. +3. **Eine Gate-Prüfung pro Angebot.** Jedes Angebot durchläuft die Zuordnung + einzeln. Wenn das Modell bei einem Artikel unsicher ist, markiert es ihn als + `unsicher: true` mit der wahrscheinlichsten Gruppe -- es rät nicht still. +4. **Kein stilles Absenken bei vielen Elementen.** Bei langen Listen darf die + Zuordnungsqualität nicht zum Ende hin abfallen. Verarbeite in kleinen + Batches mit gleichbleibendem Schema; fülle nie eine Gruppe auf, um sie + "ausgewogen" aussehen zu lassen. + +## Produktgruppen (Startliste, anpassbar) + +``` +Obst & Gemüse +Fleisch & Wurst +Fisch +Molkereiprodukte & Eier +Brot & Backwaren +Grundnahrungsmittel (Nudeln, Reis, Mehl, Konserven) +Süßwaren & Snacks +Getränke (alkoholfrei) +Alkoholische Getränke +Tiefkühl +Drogerie & Haushalt +Non-Food (Kleidung, Spielzeug, Garten, Technik) +Sonstiges +``` + +Die Liste lebt in einer Config, nicht im Prompt-Text -- so kann das Team sie +anpassen, ohne die Logik zu berühren. + +## LLM-Aufruf + +- Übergib dem Modell pro Batch nur das Nötige: Titel, Marke, Menge. NICHT + Preis/Gültigkeit (die sind für die Einordnung irrelevant und sollen nicht + versehentlich verändert zurückkommen). +- Lass das Modell strukturiert antworten (z. B. JSON: `{id, gruppe, + unsicher}`), und mappe das Ergebnis zurück auf die unveränderten + Original-Angebote über eine stabile ID. Übernimm vom Modell **nur** Gruppe + und Unsicherheits-Flag -- alles andere kommt aus dem Originaldatensatz. +- System-Vorgabe an das Modell: geschlossene Kategorienliste, keine neuen + Kategorien, bei Unsicherheit Flag setzen statt raten. +- Liefert das Modell trotzdem eine Gruppe außerhalb der Liste, wird sie nicht + übernommen: der Artikel landet in `Sonstiges` und wird als `unsicher` + markiert. Die geschlossene Liste ist eine prüfbare Bedingung im Code, keine + Bitte an das Modell. + +## Ausgabe + +Eine nach Produktgruppen geordnete Übersicht. Pro Gruppe die zugehörigen +Angebote mit ihren belegten Feldern. Leere Gruppen werden als "keine Angebote" +ausgewiesen -- nicht weggelassen und nicht aufgefüllt. Unsicher zugeordnete +Artikel werden sichtbar markiert, damit ein Mensch nachsehen kann. + +## Tests + +- Daten-Integrität: Preis/Gültigkeit/Händler nach Kategorisierung identisch zu + vorher (Property-Test über den ganzen Stream). +- Geschlossene Liste: keine Gruppe außerhalb der Config taucht auf. +- Unsicherheit: bewusst mehrdeutige Artikel (z. B. "Pflanzendrink") werden + geflaggt, nicht still einsortiert. + +## Was dieser Skill NICHT tut + +- Keine Datenbeschaffung (das macht angebote-fetch). +- Keine Preis-/Qualitätsbewertung. +- Kein Verändern, Reparieren oder Ergänzen von Angebotsdaten. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cc4d8d8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +.venv/ +__pycache__/ +*.pyc +.cache/ +.pytest_cache/ +.DS_Store + +# generierte Ausgaben / lokale Artefakte +*.err +/angebote-*.md + +# Original-Input-Archive (Specs liegen kanonisch in .claude/skills/) +/files.zip +/temp/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..1e5cf17 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,90 @@ +# CLAUDE.md -- Angebots-Übersicht + +Leitfaden für die Entwicklung dieses Projekts mit Claude Code. + +## Was dieses Projekt ist + +Eine sachliche, ortskonkrete Übersicht der wöchentlichen Supermarkt- und +Discounter-Angebote, sortiert nach Produktgruppen. Kein Hochglanzprospekt +einzelner Händler, sondern eine händlerübergreifende, neutrale Liste: +"Was ist diese Woche an Ort X im Angebot, geordnet nach Kategorie." + +## Das Leitprinzip: der Schnitt + +Diese Aufgabe sieht nach einer KI-Aufgabe aus. Sie ist es nur zur Hälfte. +Sie zerfällt in zwei grundverschiedene Teilprobleme, die strikt getrennt +gehören: + +1. **Daten holen** (ortskonkret, händlerübergreifend) -- das ist + **deterministisch**. Hier wird KI eingespart. Ein HTTP-Abruf / Parser + leistet das exakt und reproduzierbar. Ein LLM würde hier nur Preise und + Gültigkeiten halluzinieren. Dieser Teil enthält **keine** LLM-Aufrufe. + +2. **Kategorisieren** (flacher Angebots-Stream → saubere Produktgruppen) -- + das ist die **echte Ambiguität**: "Ist Toffifee Süßwaren? Ist Schwarzwälder + Schinken Fleisch/Wurst? Gehört eine Blühpflanze überhaupt in eine + Lebensmittel-Übersicht?" Genau hier ist ein LLM das richtige Werkzeug -- + und nur hier. + +> Merksatz: Vor KI muss man erst einmal KI einsparen. Aber dort, wo nur sie +> passt, muss man sie auch einsetzen. Wer die ganze Aufgabe dem Modell gibt, +> bekommt Fiktion. Wer sie ganz deterministisch löst, scheitert an der +> Kategorisierung. Richtig ist die Arbeitsteilung. + +Dieser Schnitt ist nicht verhandelbar. Er ist die Architektur des Projekts, +nicht eine Stilpräferenz. Vermische die beiden Teile nicht. + +## Architektur statt Präferenz + +Qualitätsregeln werden hier **festverdrahtet**, nicht "mitgedacht". Eine Regel, +die nur als höfliche Bitte im Code steht ("möglichst keine erfundenen Preise"), +bricht unter Druck still weg. Eine Regel, die als prüfbare Bedingung im +Datenfluss steht, hält. + +Konkrete Konsequenzen, die jederzeit gelten: + +- **Kein Auffüllen.** Wenn für eine Produktgruppe keine belegten Angebote + vorliegen, wird "keine Daten" ausgegeben -- niemals ein plausibel klingendes + Beispiel erfunden. +- **Jedes ausgegebene Angebot ist belegt.** Preis, Gültigkeit und Händler + stammen aus dem abgerufenen Datensatz, nicht aus dem Modell. Wenn ein Feld + fehlt, wird es als fehlend markiert, nicht geraten. +- **Abbruch statt stiller Drift.** Wenn die Datenlage die Anforderung nicht + hergibt (z. B. kein Treffer für den Ort), bricht das Programm mit einer + klaren Meldung ab und nennt die Ursache und einen Erweiterungsvorschlag. + Es liefert kein "irgendwie vollständig aussehendes" Ergebnis. + +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. + +## Skills + +Das Projekt nutzt zwei Skills (in `.claude/skills/`), die exakt dem Schnitt +folgen: + +- **angebote-fetch** -- deterministischer Datenabruf. Keine LLM-Aufrufe. +- **angebote-kategorisieren** -- LLM-gestützte Einordnung in Produktgruppen. + +Lies die jeweilige `SKILL.md`, bevor du an dem zugehörigen Teil arbeitest. +Die SKILL.md ist die verbindliche Spezifikation für ihren Bereich. + +## Tech-Kontext + +- Sprache: **Python** (FastAPI-nah). +- Öffentlicher Stack-Bezug, falls relevant: FastAPI, Qdrant, A2A. +- Datenquellen-Kandidaten für den Fetch-Teil: Angebots-Aggregatoren, die nach + Ort/PLZ filtern (z. B. marktguru, kaufda, MeinProspekt). Discounter wie + Aldi/Lidl liefern oft nicht an Aggregatoren -- das ist ein bekanntes + Abdeckungsloch und gehört ehrlich als solches ausgewiesen, nicht kaschiert. + +## Arbeitsweise mit Claude Code + +- Beginne jede Aufgabe damit, den relevanten Teil dieses Dokuments und die + passende SKILL.md zu lesen. +- Halte den Schnitt sauber: kein LLM-Aufruf im Fetch-Teil, kein heimliches + Daten-"Reparieren" im Kategorisier-Teil. +- Wenn eine Anforderung den Schnitt verletzen würde, benenne den Konflikt, + bevor du Code schreibst. +- Schreibe Tests für die Architektur-Regeln (kein Auffüllen, Abbruch bei + leerer Datenlage), nicht nur für den Happy Path. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ef7b8fa --- /dev/null +++ b/README.md @@ -0,0 +1,102 @@ +# Angebots-Übersicht + +Ortskonkrete, händlerübergreifende Übersicht wöchentlicher Supermarkt-Angebote, +geordnet nach Produktgruppen. Neutral, nicht als Hochglanzprospekt. + +## Der Gedanke + +Die Aufgabe sieht nach einer KI-Aufgabe aus, ist es aber 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`) + +Wer alles dem Modell gibt, bekommt erfundene Preise. Wer alles deterministisch +löst, scheitert an der Einordnung. Die Architektur erzwingt den Schnitt. + +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. + +## Entwicklung mit Claude Code + +`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. + +## Setup + +```bash +python3 -m venv .venv && source .venv/bin/activate +pip install -r requirements.txt +``` + +## Nutzung + +```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 `ANTHROPIC_API_KEY` in der Umgebung. Fehlt er +und es wird kein `--no-llm` gesetzt, bricht das Programm ehrlich ab, statt +ungeordnet weiterzulaufen. + +## Stand der Implementierung (ehrlich) + +- `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. + +## Struktur + +``` +CLAUDE.md Leitfaden / Architektur +README.md dieses Dokument +requirements.txt Abhängigkeiten +.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) + kategorisieren.py LLM-Schritt hinter einer Schnittstelle (offline testbar) + uebersicht.py Gruppierung + Rendering + cli.py / __main__.py CLI-Einstieg +tests/ Architektur-Tests +``` diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..44ac96a --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,16 @@ +[project] +name = "angebote-uebersicht" +version = "0.1.0" +description = "Ortskonkrete, händlerübergreifende Angebots-Übersicht nach Produktgruppen" +requires-python = ">=3.10" +dependencies = [ + "requests>=2.31", + "anthropic>=0.40", +] + +[project.optional-dependencies] +dev = ["pytest>=8.0"] + +[tool.pytest.ini_options] +pythonpath = ["src"] +testpaths = ["tests"] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..969b271 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,15 @@ +# Fetch-Teil (deterministisch, KEIN LLM) UND OpenRouter-Kategorisierung: +requests>=2.31 + +# Kategorisier-Teil, nur für den NATIVEN Anthropic-Anbieter nötig. +# Bei Nutzung von OpenRouter (OPENROUTER_API_KEY) NICHT erforderlich -- der +# OpenRouter-Kategorisierer kommt mit requests aus. +anthropic>=0.40 + +# Web-UI (lokale FastAPI-App): +fastapi>=0.110 +uvicorn>=0.29 + +# Entwicklung/Tests: +pytest>=8.0 +httpx>=0.27 # für fastapi.testclient diff --git a/src/angebote/__init__.py b/src/angebote/__init__.py new file mode 100644 index 0000000..44e5cd5 --- /dev/null +++ b/src/angebote/__init__.py @@ -0,0 +1,10 @@ +"""Angebots-Übersicht: deterministischer Fetch + LLM-Kategorisierung. + +Der Schnitt (siehe CLAUDE.md) ist auch in der Paketstruktur sichtbar: + * fetch / quellen / modell -> deterministisch, kein LLM + * kategorisieren -> der EINZIGE Ort mit LLM +""" + +from .modell import Angebot, FetchErgebnis, KategorisiertesAngebot + +__all__ = ["Angebot", "FetchErgebnis", "KategorisiertesAngebot"] diff --git a/src/angebote/__main__.py b/src/angebote/__main__.py new file mode 100644 index 0000000..eb53e2f --- /dev/null +++ b/src/angebote/__main__.py @@ -0,0 +1,3 @@ +from .cli import main + +raise SystemExit(main()) diff --git a/src/angebote/cli.py b/src/angebote/cli.py new file mode 100644 index 0000000..d8725bb --- /dev/null +++ b/src/angebote/cli.py @@ -0,0 +1,167 @@ +"""CLI-Einstieg: Ort rein, geordnete Übersicht raus. + +Der Schnitt bleibt auch hier sichtbar: erst `fetch` (deterministisch), dann -- +nur falls gewünscht und Schlüssel vorhanden -- `kategorisieren` (LLM). +""" + +from __future__ import annotations + +import argparse +import os +import sys + +from .fehler import AbbruchFehler +from .fetch import hole_angebote +from .modell import KategorisiertesAngebot + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + prog="angebote", + description="Ortskonkrete, händlerübergreifende Angebots-Übersicht nach Produktgruppen.", + ) + parser.add_argument( + "ort", + nargs="?", + help="PLZ (5-stellig) oder hinterlegter Ortsname", + ) + parser.add_argument( + "--no-llm", + action="store_true", + help="ohne Kategorisierung: flache, belegte Liste (kein LLM, kein Schlüssel nötig)", + ) + parser.add_argument( + "--anbieter", + choices=("openrouter", "anthropic"), + default=None, + help="LLM-Anbieter erzwingen; ohne Angabe automatisch nach gesetztem Key", + ) + parser.add_argument( + "--modell", + default=None, + help="Modell-ID überschreiben (Default je Anbieter)", + ) + parser.add_argument( + "--modelle", + nargs="?", + const="", + default=None, + metavar="SUCHE", + help="OpenRouter-Modelle auflisten und beenden; optional Suchbegriff " + "(ohne Begriff: Top-5 Free, tool-fähig)", + ) + parser.add_argument( + "--modell-waehlen", + action="store_true", + help="vor dem Lauf das OpenRouter-Modell interaktiv wählen (Liste/Suche/Update)", + ) + args = parser.parse_args(argv) + + # Reiner Listen-/Suchmodus -- braucht keinen Ort und keinen Key. + if args.modelle is not None: + return _liste_modelle(args.modelle) + + if not args.ort: + parser.error("Ort fehlt (PLZ oder Ortsname) -- oder nutze --modelle zum Auflisten.") + + try: + fetch = hole_angebote(args.ort) + except AbbruchFehler as e: + print(e.als_text(), file=sys.stderr) + return 2 + + if args.no_llm: + _druck_flach(fetch) + return 0 + + anbieter = args.anbieter or _anbieter_aus_umgebung() + if anbieter is None: + print( + "Abbruch (Kategorisierung): kein LLM-Key gesetzt " + "(OPENROUTER_API_KEY oder ANTHROPIC_API_KEY).\n" + " Vorschlag: einen Key setzen ODER mit --no-llm die belegte Rohliste ausgeben.", + file=sys.stderr, + ) + return 2 + + modell = args.modell + if args.modell_waehlen: + if anbieter != "openrouter": + print( + "Hinweis: --modell-waehlen ist für OpenRouter gedacht; nutze --anbieter openrouter.", + file=sys.stderr, + ) + return 2 + from .modellauswahl import waehle_modell_interaktiv + + modell = waehle_modell_interaktiv() + if not modell: + print("Keine Modellwahl getroffen -- abgebrochen.", file=sys.stderr) + return 2 + + from .kategorisieren import baue_kategorisierer, kategorisiere + from .uebersicht import rendern + + try: + kat = kategorisiere(list(fetch.angebote), baue_kategorisierer(anbieter, modell)) + except AbbruchFehler as e: + print(e.als_text(), file=sys.stderr) + return 2 + + print(rendern(fetch, kat)) + return 0 + + +def _liste_modelle(suchbegriff: str) -> int: + from .modelle import lade_modelle, suche, top_free + + try: + alle = lade_modelle() + except Exception as e: + print(f"Modelle nicht abrufbar: {e}", file=sys.stderr) + return 2 + + if suchbegriff: + treffer = suche(alle, suchbegriff)[:20] + print(f"Suche '{suchbegriff}' (max. 20):") + else: + treffer = top_free(alle, 5) + print("Top-5 Free (tool-fähig, Heuristik):") + + if not treffer: + print(" (keine Treffer)") + for i, m in enumerate(treffer, 1): + ctx = f"{m.context:,}".replace(",", ".") if m.context else "?" + frei = "FREE" if m.frei else "paid" + warn = "" if m.tools else " ⚠️ kein tool-calling" + print(f" {i}) {m.id} [{frei}, ctx {ctx}]{warn}") + return 0 + + +def _anbieter_aus_umgebung() -> str | None: + if os.environ.get("OPENROUTER_API_KEY"): + return "openrouter" + if os.environ.get("ANTHROPIC_API_KEY"): + return "anthropic" + return None + + +def _druck_flach(fetch) -> None: + ort = fetch.ort_name or fetch.ort_plz + print(f"# Angebote {ort} (PLZ {fetch.ort_plz}) — Rohliste, unkategorisiert") + print(f"Quellen: {', '.join(fetch.abgedeckte_quellen) or '—'}\n") + if not fetch.angebote: + print("_keine Angebote (kein Auffüllen)_") + for a in fetch.angebote: + preis = f"{a.preis:.2f} €".replace(".", ",") if a.preis is not None else "Preis fehlt" + marke = f"{a.marke} " if a.marke else "" + menge = f" ({a.menge})" if a.menge else "" + print(f"- {marke}{a.titel}{menge} — {preis} @ {a.haendler}") + if fetch.gesehene_haendler: + print("\nBeobachtete Händler: " + ", ".join(fetch.gesehene_haendler)) + for h in fetch.hinweise: + print(f"# {h}") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/angebote/config.py b/src/angebote/config.py new file mode 100644 index 0000000..3cdb4d4 --- /dev/null +++ b/src/angebote/config.py @@ -0,0 +1,87 @@ +"""Konfiguration -- bewusst getrennt von der Logik. + +Produktgruppen, Quellenliste und Orts-Auflösung leben hier, damit das Team sie +ändern kann, ohne Kern- oder Prompt-Code anzufassen. +""" + +from __future__ import annotations + +# --- Kategorisierung: geschlossene Liste (siehe SKILL angebote-kategorisieren) --- + +PRODUKTGRUPPEN: tuple[str, ...] = ( + "Obst & Gemüse", + "Fleisch & Wurst", + "Fisch", + "Molkereiprodukte & Eier", + "Brot & Backwaren", + "Grundnahrungsmittel (Nudeln, Reis, Mehl, Konserven)", + "Süßwaren & Snacks", + "Getränke (alkoholfrei)", + "Alkoholische Getränke", + "Tiefkühl", + "Drogerie & Haushalt", + "Non-Food (Kleidung, Spielzeug, Garten, Technik)", + "Sonstiges", +) + +# Auffanggruppe, wenn ein Artikel in keine passt ODER das Modell eine Gruppe +# außerhalb der Liste liefert. Muss in PRODUKTGRUPPEN enthalten sein. +FALLBACK_GRUPPE: str = "Sonstiges" + +# --- Fetch: bekannte Abdeckungslöcher, die ehrlich ausgewiesen werden --- + +# Sentinel-Werte der Quelle für "kein Wert". marktguru setzt z. B. einen +# Platzhalter-Markennamen, wenn keine Marke vorliegt -- der darf NICHT als echte +# Marke durchgereicht werden, sonst behaupten wir einen Beleg, den es nicht gibt. +MARKE_SENTINELS: tuple[str, ...] = ("thisisnobrand123",) + +# Hinweis Abdeckung: Welche Händler tatsächlich enthalten sind, ergibt sich aus +# den abgerufenen Daten (datengetrieben), nicht aus einer hartkodierten Annahme. +# Eine pauschale "Discounter X fehlt"-Behauptung wäre unbelegt -- Discounter wie +# Aldi/Lidl können je nach Quelle/Ort sehr wohl enthalten sein. + +# Seedbegriffe für query-orientierte Quellen (z. B. marktguru): deren API hat +# kein "alle Angebote"-Browse (leeres q -> 0 Treffer), nur Suche. Diese Liste +# spannt die Abdeckung auf. Sie ist BEWUSST endlich und damit unvollständig -- +# was sie nicht trifft, fehlt ehrlich, statt vorgetäuscht zu werden. +SUCHBEGRIFFE: tuple[str, ...] = ( + "Obst", "Gemüse", "Apfel", "Banane", "Tomate", "Kartoffel", "Salat", + "Fleisch", "Hähnchen", "Hackfleisch", "Schnitzel", "Wurst", "Schinken", + "Fisch", "Lachs", + "Milch", "Butter", "Käse", "Joghurt", "Quark", "Eier", "Sahne", + "Brot", "Brötchen", "Toast", + "Nudeln", "Reis", "Mehl", "Zucker", "Öl", "Konserve", + "Schokolade", "Süßigkeiten", "Chips", "Kekse", + "Wasser", "Saft", "Cola", "Kaffee", "Tee", + "Bier", "Wein", "Sekt", + "Pizza", "Eis", "Tiefkühl", + "Waschmittel", "Shampoo", "Toilettenpapier", "Zahnpasta", +) + +# --- Orts-Auflösung (deterministisch, KEIN LLM) --- +# +# 5-stellige Eingaben gelten direkt als PLZ. Ortsnamen werden NUR über diese +# bewusst kleine, kuratierte Auswahl großer Städte aufgelöst. Ein unbekannter +# Ortsname führt zum ehrlichen Abbruch (Regel 4) mit dem Vorschlag, eine PLZ +# anzugeben -- es wird KEINE PLZ geraten. +ORTSNAME_PLZ: dict[str, str] = { + "berlin": "10115", + "hamburg": "20095", + "münchen": "80331", + "muenchen": "80331", + "köln": "50667", + "koeln": "50667", + "frankfurt": "60311", + "frankfurt am main": "60311", + "stuttgart": "70173", + "düsseldorf": "40213", + "duesseldorf": "40213", + "leipzig": "04109", + "dortmund": "44135", + "essen": "45127", + "bremen": "28195", + "dresden": "01067", + "hannover": "30159", + "nürnberg": "90402", + "nuernberg": "90402", +} diff --git a/src/angebote/fehler.py b/src/angebote/fehler.py new file mode 100644 index 0000000..01ea689 --- /dev/null +++ b/src/angebote/fehler.py @@ -0,0 +1,25 @@ +"""Fehler, die den Abbruch statt stiller Drift erzwingen. + +`AbbruchFehler` ist Regel 4 in Codeform: Schwelle, Ursache und ein konkreter +Vorschlag werden mitgeführt, damit der Abbruch ein *brauchbares* Ergebnis ist -- +nicht nur ein Stacktrace. +""" + +from __future__ import annotations + + +class AbbruchFehler(Exception): + """Die Datenlage trägt die Anforderung nicht -- bewusster, belegter Abbruch.""" + + def __init__(self, schwelle: str, ursache: str, vorschlag: str) -> None: + self.schwelle = schwelle + self.ursache = ursache + self.vorschlag = vorschlag + super().__init__(f"{schwelle}: {ursache} -- Vorschlag: {vorschlag}") + + def als_text(self) -> str: + return ( + f"Abbruch ({self.schwelle}).\n" + f" Ursache: {self.ursache}\n" + f" Vorschlag: {self.vorschlag}" + ) diff --git a/src/angebote/fetch.py b/src/angebote/fetch.py new file mode 100644 index 0000000..f0065b4 --- /dev/null +++ b/src/angebote/fetch.py @@ -0,0 +1,98 @@ +"""Fetch-Orchestrator. Ort rein, belegte Angebote raus. KEIN LLM. + +Trennt sauber zwei Fälle, die oberflächlich gleich aussehen: + + * KEINE Quelle deckt den Ort ab / Ort nicht auflösbar -> AbbruchFehler (Regel 4). + * Quellen liefen, lieferten aber nichts -> leeres, ehrliches Ergebnis + (kein Auffüllen). Die Übersicht zeigt dann "keine Angebote". +""" + +from __future__ import annotations + +from .config import ORTSNAME_PLZ +from .fehler import AbbruchFehler +from .modell import Angebot, FetchErgebnis +from .quellen.basis import Ort, QuelleAdapter + + +def aufloesen_ort(eingabe: str) -> Ort: + """Löst eine Eingabe (PLZ oder Ortsname) deterministisch zu einem Ort auf. + + 5-stellige Ziffernfolge -> PLZ. Bekannter Ortsname -> hinterlegte PLZ. + Sonst Abbruch (Regel 4) -- es wird KEINE PLZ geraten. + """ + roh = (eingabe or "").strip() + if roh.isdigit() and len(roh) == 5: + return Ort(plz=roh, name=None) + + schluessel = roh.lower() + if schluessel in ORTSNAME_PLZ: + return Ort(plz=ORTSNAME_PLZ[schluessel], name=roh) + + raise AbbruchFehler( + schwelle="Ortsauflösung", + ursache=( + f"'{eingabe}' ist weder eine 5-stellige PLZ noch ein hinterlegter Ortsname" + ), + vorschlag=( + "eine 5-stellige PLZ angeben (z. B. 60487). Ortsnamen sind nur für " + "eine kleine Auswahl großer Städte hinterlegt -- bewusst wird keine " + "PLZ geraten." + ), + ) + + +def hole_angebote( + eingabe: str, + quellen: list[QuelleAdapter] | None = None, +) -> FetchErgebnis: + """Beschafft belegte Angebote für einen Ort über alle abdeckenden Quellen.""" + if quellen is None: + quellen = standard_quellen() + if not quellen: + raise AbbruchFehler( + schwelle="Quellen", + ursache="keine Quelle konfiguriert", + vorschlag="mindestens einen Adapter in standard_quellen() registrieren", + ) + + ort = aufloesen_ort(eingabe) + + abdeckend = [q for q in quellen if q.deckt_ab(ort)] + if not abdeckend: + raise AbbruchFehler( + schwelle="Ortsabdeckung", + ursache=f"keine der {len(quellen)} Quellen deckt PLZ {ort.plz} ab", + vorschlag="andere PLZ in der Nähe versuchen oder weitere Quelle ergänzen", + ) + + alle: list[Angebot] = [] + gelaufen: list[str] = [] + hinweise: list[str] = [] + for q in abdeckend: + gelaufen.append(q.name) + treffer = q.hole(ort) # AbbruchFehler propagiert bewusst nach oben + if not treffer: + hinweise.append(f"Quelle '{q.name}': 0 Angebote (kein Auffüllen).") + # Ehrliche Abdeckungs-Notiz der Quelle übernehmen, falls vorhanden: + notiz = getattr(q, "abdeckungshinweis", None) + if notiz: + hinweise.append(notiz) + alle.extend(treffer) + + gesehene = tuple(sorted({a.haendler for a in alle})) + return FetchErgebnis( + ort_plz=ort.plz, + ort_name=ort.name, + angebote=tuple(alle), + abgedeckte_quellen=tuple(gelaufen), + gesehene_haendler=gesehene, + hinweise=tuple(hinweise), + ) + + +def standard_quellen() -> list[QuelleAdapter]: + """Registry der aktiven Quellen. Hier werden Adapter ein-/ausgehängt.""" + from .quellen.marktguru import MarktguruAdapter + + return [MarktguruAdapter()] diff --git a/src/angebote/kategorisieren.py b/src/angebote/kategorisieren.py new file mode 100644 index 0000000..1675f62 --- /dev/null +++ b/src/angebote/kategorisieren.py @@ -0,0 +1,354 @@ +"""Kategorisier-Teil -- der EINZIGE Ort mit LLM. + +Aufbau bewusst so, dass die harten Regeln im *Code* erzwungen werden, nicht im +Prompt erbeten: + + * Dem Modell wird pro Posten nur {id, titel, marke, menge} gezeigt -- nie + Preis/Gültigkeit/Händler. Es kann sie also gar nicht "korrigieren". + * Vom Modell übernommen werden NUR `gruppe` + `unsicher`. Das Original-Angebot + bleibt unverändert (es ist ohnehin frozen). + * Eine Gruppe außerhalb der geschlossenen Liste wird NICHT übernommen -> + Fallback-Gruppe + `unsicher=True`. + * Ein Posten, den das Modell gar nicht beantwortet, wird `unsicher` mit + Fallback-Gruppe -- nicht still einsortiert. + +Das LLM steckt hinter `Kategorisierer`; Tests reichen einen Fake herein und +laufen damit ohne Netz und ohne Schlüssel. +""" + +from __future__ import annotations + +import json +import os +from typing import Protocol + +from .config import FALLBACK_GRUPPE, PRODUKTGRUPPEN +from .fehler import AbbruchFehler +from .modell import Angebot, KategorisiertesAngebot + + +class Kategorisierer(Protocol): + """Bekommt Posten [{id, titel, marke, menge}], gibt [{id, gruppe, unsicher}].""" + + def klassifiziere(self, posten: list[dict]) -> list[dict]: ... + + +def kategorisiere( + angebote: list[Angebot], + kategorisierer: Kategorisierer, + *, + batch_groesse: int = 25, + fortschritt=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. + """ + original = {a.angebot_id: a for a in angebote} + ergebnis: dict[str, KategorisiertesAngebot] = {} + + import math + + 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] + posten = [ + { + "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) + + # Posten, die das Modell nicht (gültig) beantwortet hat: ehrlich als + # unsicher mit Fallback markieren -- nicht still einsortieren. + out: list[KategorisiertesAngebot] = [] + for a in angebote: + out.append( + ergebnis.get( + a.angebot_id, + KategorisiertesAngebot(angebot=a, gruppe=FALLBACK_GRUPPE, unsicher=True), + ) + ) + return out + + +def _bereinige_gruppe(gruppe, unsicher) -> tuple[str, bool]: + """Erzwingt die geschlossene Liste. Off-list -> Fallback + unsicher.""" + flag = bool(unsicher) + if isinstance(gruppe, str) and gruppe in PRODUKTGRUPPEN: + return gruppe, flag + return FALLBACK_GRUPPE, True + + +# --- echte LLM-Implementierung ---------------------------------------------- + +_SYSTEM = ( + "Du ordnest Supermarkt-Angebote genau EINER Produktgruppe aus einer " + "geschlossenen Liste zu. Erfinde keine neuen Gruppen. Wähle ausschließlich " + "aus dieser Liste:\n- " + + "\n- ".join(PRODUKTGRUPPEN) + + "\n\nPasst ein Artikel in keine, nimm 'Sonstiges'. Wenn du dir unsicher " + "bist, setze unsicher=true und gib die wahrscheinlichste Gruppe an -- rate " + "nicht still. Verändere keine Produktnamen. Antworte für JEDEN übergebenen " + "Posten genau einmal über das Tool, identifiziert durch seine id." +) + +_TOOL = { + "name": "zuordnungen", + "description": "Gibt für jeden Posten die Produktgruppe und ein Unsicherheits-Flag zurück.", + "input_schema": { + "type": "object", + "properties": { + "zuordnungen": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": {"type": "string"}, + "gruppe": {"type": "string", "enum": list(PRODUKTGRUPPEN)}, + "unsicher": {"type": "boolean"}, + }, + "required": ["id", "gruppe", "unsicher"], + }, + } + }, + "required": ["zuordnungen"], + }, +} + + +def _user_text(posten: list[dict]) -> str: + return "Ordne diese Posten zu:\n" + "\n".join( + f"- id={p['id']} | titel={p['titel']} | " + f"marke={p.get('marke')} | menge={p.get('menge')}" + for p in posten + ) + + +def _wartezeit(antwort, versuch: int, basis: float) -> float: + """Backoff: Retry-After respektieren, sonst exponentiell ab max(basis, 2s).""" + try: + ra = antwort.headers.get("Retry-After") + except Exception: + ra = None + if ra: + try: + return float(ra) + except (TypeError, ValueError): + pass + grund = basis if basis > 0 else 2.0 + return grund * (2**versuch) + + +class AnthropicKategorisierer: + """Kategorisierer auf Basis der nativen Anthropic-API (Tool-Use).""" + + def __init__(self, *, client=None, modell: str = "claude-sonnet-4-6") -> None: + if client is None: + import anthropic + + client = anthropic.Anthropic() # liest ANTHROPIC_API_KEY aus der Umgebung + self._client = client + self._modell = modell + + def klassifiziere(self, posten: list[dict]) -> list[dict]: + nachricht = self._client.messages.create( + model=self._modell, + max_tokens=2048, + system=[ + {"type": "text", "text": _SYSTEM, "cache_control": {"type": "ephemeral"}} + ], + tools=[_TOOL], + tool_choice={"type": "tool", "name": "zuordnungen"}, + messages=[{"role": "user", "content": _user_text(posten)}], + ) + for block in nachricht.content: + if getattr(block, "type", None) == "tool_use": + return list(block.input.get("zuordnungen", [])) + return [] + + +class OpenRouterKategorisierer: + """Kategorisierer über OpenRouter (OpenAI-kompatibles Tool-Calling). + + Nutzt nur `requests` -- kein anbieterspezifisches SDK. Liest den Zugang aus + OPENROUTER_API_KEY. Modell-IDs im OpenRouter-Namespace, z. B. + 'anthropic/claude-sonnet-4.6'. Bei Transport-/Antwortfehlern: ehrlicher + AbbruchFehler statt stiller Drift. + """ + + def __init__( + self, + *, + api_key: str | None = None, + modell: str = "anthropic/claude-sonnet-4.6", + base_url: str = "https://openrouter.ai/api/v1", + session=None, + max_versuche: int = 5, + mindest_abstand_s: float | None = None, + schlafen=None, + ) -> None: + self._key = api_key or os.environ.get("OPENROUTER_API_KEY") + if not self._key: + raise AbbruchFehler( + schwelle="openrouter: Zugang", + ursache="OPENROUTER_API_KEY ist nicht gesetzt", + vorschlag="Key setzen ODER --no-llm für die belegte Rohliste verwenden", + ) + self._modell = modell + self._base = base_url.rstrip("/") + self._session = session + self._max = max(1, max_versuche) + # Free-Modelle drosseln hart (~20/min). Endet die ID auf ':free' und ist + # kein Abstand vorgegeben, throttlen wir automatisch auf ~20/min. + if mindest_abstand_s is None: + mindest_abstand_s = 3.2 if modell.endswith(":free") else 0.0 + self._abstand = mindest_abstand_s + self._letzter = 0.0 + # injizierbar für Tests (kein echtes Warten): + if schlafen is None: + import time + + schlafen = time.sleep + self._schlafen = schlafen + + def _sess(self): + if self._session is None: + import requests + + self._session = requests.Session() + return self._session + + def _throttle(self) -> None: + if self._abstand <= 0: + return + import time + + seit = time.monotonic() - self._letzter + if 0 < seit < self._abstand: + self._schlafen(self._abstand - seit) + self._letzter = time.monotonic() + + def klassifiziere(self, posten: list[dict]) -> list[dict]: + tool = { + "type": "function", + "function": { + "name": _TOOL["name"], + "description": _TOOL["description"], + "parameters": _TOOL["input_schema"], + }, + } + payload = { + "model": self._modell, + "max_tokens": 2048, + "messages": [ + {"role": "system", "content": _SYSTEM}, + {"role": "user", "content": _user_text(posten)}, + ], + "tools": [tool], + "tool_choice": {"type": "function", "function": {"name": _TOOL["name"]}}, + } + headers = { + "Authorization": f"Bearer {self._key}", + "Content-Type": "application/json", + "X-Title": "angebote-uebersicht", + } + + daten = None + for versuch in range(self._max): + self._throttle() + try: + antwort = self._sess().post( + f"{self._base}/chat/completions", + json=payload, + headers=headers, + timeout=60, + ) + except Exception as e: + if versuch + 1 < self._max: + self._schlafen(self._abstand or 2.0) + continue + raise AbbruchFehler( + schwelle="openrouter: Kategorisierung", + ursache=f"chat/completions nicht erreichbar ({e})", + vorschlag="Netz prüfen oder --no-llm verwenden", + ) from e + + status = getattr(antwort, "status_code", 200) + # 429 (Rate-Limit) / 5xx -> mit Backoff erneut versuchen. + if status == 429 or status >= 500: + if versuch + 1 < self._max: + self._schlafen(_wartezeit(antwort, versuch, self._abstand)) + continue + raise AbbruchFehler( + schwelle="openrouter: Rate-Limit", + ursache=f"HTTP {status} auch nach {self._max} Versuchen " + "(Free-Modell zu stark gedrosselt?)", + vorschlag="bezahltes Modell wählen, später erneut, oder --no-llm", + ) + try: + antwort.raise_for_status() + daten = antwort.json() + except Exception as e: + raise AbbruchFehler( + schwelle="openrouter: Kategorisierung", + ursache=f"chat/completions fehlgeschlagen ({e})", + vorschlag="Key/Modell/Guthaben prüfen oder --no-llm verwenden", + ) from e + break + + if isinstance(daten, dict) and daten.get("error"): + raise AbbruchFehler( + schwelle="openrouter: Kategorisierung", + ursache=f"API-Fehler: {daten['error']}", + 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. + return [] + + +_DEFAULT_MODELLE = { + "anthropic": "claude-sonnet-4-6", + "openrouter": "anthropic/claude-sonnet-4.6", +} + + +def baue_kategorisierer( + anbieter: str, modell: str | None = None, *, api_key: str | None = None +) -> Kategorisierer: + """Factory: wählt die LLM-Implementierung hinter dem gleichen Protokoll. + + `api_key` optional -- ohne wird er aus der Umgebung gelesen (CLI-Fall); + mitgegeben für die Web-UI, falls der Key nicht in der Server-Env steht. + """ + modell = modell or _DEFAULT_MODELLE.get(anbieter) + if anbieter == "openrouter": + return OpenRouterKategorisierer(modell=modell, api_key=api_key) + if anbieter == "anthropic": + return AnthropicKategorisierer(modell=modell) + raise AbbruchFehler( + schwelle="LLM-Anbieter", + ursache=f"unbekannter Anbieter '{anbieter}'", + vorschlag="openrouter oder anthropic wählen", + ) diff --git a/src/angebote/modell.py b/src/angebote/modell.py new file mode 100644 index 0000000..1d57a1e --- /dev/null +++ b/src/angebote/modell.py @@ -0,0 +1,90 @@ +"""Datenmodell des Fetch-Teils. + +`Angebot` ist bewusst `frozen=True`: Der Kategorisier-Schritt soll die Daten +nicht nur nicht verändern -- er *kann* es nicht. Damit ist die Regel "Daten +sind unantastbar" eine prüfbare Eigenschaft des Typs, keine Bitte. + +Es gibt bewusst KEIN Feld `produktgruppe`: Die Trennung von Beschaffung und +Einordnung ist im Datenmodell verankert (siehe SKILL angebote-fetch). +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import date, datetime + + +@dataclass(frozen=True) +class Angebot: + """Ein normalisiertes, belegtes Angebot. + + Pflichtfelder (`haendler`, `quelle`, `abgerufen_am`) belegen die Herkunft. + Optionale Felder sind `None`, wenn die Quelle sie nicht eindeutig hergibt -- + niemals geraten. + """ + + titel: str + haendler: str + quelle: str + abgerufen_am: datetime + marke: str | None = None + preis: float | None = None + grundpreis: str | None = None + menge: str | None = None + gueltig_von: date | None = None + gueltig_bis: date | None = None + # Stabile ID für das Zurückmappen nach der Kategorisierung. Wird aus + # belegten Feldern abgeleitet, nicht erfunden. + angebot_id: str = "" + + def __post_init__(self) -> None: + if not self.titel or not self.titel.strip(): + raise ValueError("Angebot ohne Titel ist nicht belegbar") + if not self.haendler or not self.haendler.strip(): + raise ValueError("Angebot ohne Händler verletzt 'nur Belegtes'") + if not self.quelle or not self.quelle.strip(): + raise ValueError("Angebot ohne Quelle verletzt 'nur Belegtes'") + if not self.angebot_id: + # frozen -> über object.__setattr__ setzen + object.__setattr__(self, "angebot_id", self._ableiten_id()) + + def _ableiten_id(self) -> str: + import hashlib + + roh = "|".join( + [ + self.haendler, + self.quelle, + self.titel, + str(self.preis), + str(self.gueltig_von), + str(self.gueltig_bis), + ] + ) + return hashlib.sha1(roh.encode("utf-8")).hexdigest()[:16] + + +@dataclass(frozen=True) +class KategorisiertesAngebot: + """Ein Angebot plus die vom LLM vergebene Gruppe. + + Das Original-`Angebot` bleibt unverändert eingebettet -- übernommen werden + aus dem Modell ausschließlich `gruppe` und `unsicher`. + """ + + angebot: Angebot + gruppe: str + unsicher: bool = False + + +@dataclass(frozen=True) +class FetchErgebnis: + """Ergebnis des Fetch-Teils: belegte Angebote + ehrliche Abdeckungslage.""" + + ort_plz: str + ort_name: str | None + angebote: tuple[Angebot, ...] + abgedeckte_quellen: tuple[str, ...] + # Datengetrieben: die Händler, die tatsächlich in den Angeboten vorkamen. + gesehene_haendler: tuple[str, ...] = field(default_factory=tuple) + hinweise: tuple[str, ...] = field(default_factory=tuple) diff --git a/src/angebote/modellauswahl.py b/src/angebote/modellauswahl.py new file mode 100644 index 0000000..38d9eea --- /dev/null +++ b/src/angebote/modellauswahl.py @@ -0,0 +1,100 @@ +"""Interaktive Modell-Auswahl für den OpenRouter-Kategorisierer. + +Zeigt die aktuell besten Top-5-Free-Modelle, erlaubt Suche und Aktualisieren +und gibt die gewählte Modell-ID zurück. I/O ist injizierbar (`eingabe`/`ausgabe`), +damit der Ablauf ohne TTY testbar ist. + +Befehle im Picker: + [1..N] Nummer aus der aktuellen Liste wählen + s TEXT nach TEXT suchen (über alle Modelle) + f zurück zu den Top-5 Free + u Liste neu von OpenRouter laden ("Aktualisieren") + q abbrechen +""" + +from __future__ import annotations + +from typing import Callable + +from .modelle import ModellInfo, lade_modelle, suche, top_free + + +def _format(modelle: list[ModellInfo], titel: str) -> str: + zeilen = [titel] + for i, m in enumerate(modelle, 1): + ctx = f"{m.context:,}".replace(",", ".") if m.context else "?" + frei = "FREE" if m.frei else "paid" + warn = "" if m.tools else " ⚠️ KEIN tool-calling (Kategorisierung bliebe leer)" + zeilen.append(f" {i}) {m.id} [{frei}, ctx {ctx}]{warn}") + if not modelle: + zeilen.append(" (keine Treffer)") + return "\n".join(zeilen) + + +def waehle_modell_interaktiv( + *, + session=None, + eingabe: Callable[[str], str] = input, + ausgabe: Callable[[str], None] = print, +) -> str | None: + """Führt den Auswahldialog und gibt die gewählte Modell-ID zurück (oder None).""" + ausgabe("Lade OpenRouter-Modelle …") + alle = lade_modelle(session) + aktuell = top_free(alle, 5) + titel = "Top-5 Free (tool-fähig, Heuristik – siehe modelle.py):" + + hilfe = ( + "Befehle: [Nummer] wählen · [s TEXT] suchen · [f] Top-5 Free · " + "[u] aktualisieren · [q] abbrechen" + ) + + while True: + ausgabe(_format(aktuell, titel)) + ausgabe(hilfe) + try: + roh = eingabe("> ").strip() + except EOFError: + return None + + if not roh: + continue + befehl = roh.lower() + + if befehl in ("q", "quit", "abbrechen"): + return None + + if befehl in ("u", "update"): + ausgabe("Aktualisiere …") + alle = lade_modelle(session) + aktuell = top_free(alle, 5) + titel = "Top-5 Free (aktualisiert):" + continue + + if befehl in ("f", "free"): + aktuell = top_free(alle, 5) + titel = "Top-5 Free (tool-fähig, Heuristik):" + continue + + if befehl.startswith("s ") or befehl == "s": + begriff = roh[1:].strip() + aktuell = suche(alle, begriff)[:15] + titel = f"Suche '{begriff}' (max. 15):" + continue + + if roh.isdigit(): + idx = int(roh) - 1 + if 0 <= idx < len(aktuell): + gewaehlt = aktuell[idx] + if not gewaehlt.tools: + ausgabe( + "Achtung: Modell kann kein Tool-Calling — die Kategorisierung " + "würde leer bleiben. Trotzdem wählen? [j/N]" + ) + if eingabe("> ").strip().lower() not in ("j", "ja", "y"): + continue + ausgabe(f"Gewählt: {gewaehlt.id}") + return gewaehlt.id + ausgabe("Nummer außerhalb der Liste.") + continue + + ausgabe("Eingabe nicht verstanden.") diff --git a/src/angebote/modelle.py b/src/angebote/modelle.py new file mode 100644 index 0000000..dd99acc --- /dev/null +++ b/src/angebote/modelle.py @@ -0,0 +1,115 @@ +"""OpenRouter-Modell-Discovery für die Kategorisierer-Auswahl. + +Rein deterministisch (KEIN LLM): zieht die Modellliste live von OpenRouter, +erkennt frei nutzbare und tool-fähige Modelle und sortiert sie nach einer +DOKUMENTIERTEN Heuristik. + +Ehrlichkeit zu "beste": Die API liefert keine Qualitätsmetrik. "beste" ist hier +deshalb kein Benchmark, sondern eine nachvollziehbare Präferenz bekannter, +starker Modellfamilien -- und IMMER gefiltert auf das, was gerade real +verfügbar UND tool-fähig ist (sonst liefert die Kategorisierung leer). +""" + +from __future__ import annotations + +from dataclasses import dataclass + +MODELS_URL = "https://openrouter.ai/api/v1/models" + +# Dokumentierte Präferenz (Meinung, kein Benchmark). Reihenfolge = Rang. +# Modelle, die hier matchen, gelten als "stärker"; der Rest folgt nach +# Kontextgröße. Bei Bedarf hier anpassen -- die Liste lebt in der Config-Ebene, +# nicht in der Logik. +PRAEFERENZ: tuple[str, ...] = ( + "kimi-k2", + "qwen3-next", + "llama-3.3-70b", + "gpt-oss-120b", + "glm-4.5", + "qwen3-coder", + "nemotron-3-super", + "gemma-4-31b", + "qwen3", + "llama", + "gemma", + "nemotron", + "gpt-oss", + "mistral", +) + + +@dataclass(frozen=True) +class ModellInfo: + id: str + name: str + context: int | None + frei: bool + tools: bool + + +def _als_float(x) -> float: + try: + return float(x) + except (TypeError, ValueError): + return 1.0 # unbekannt -> als "nicht frei" werten, nicht raten + + +def parse_modelle(daten: list[dict]) -> list[ModellInfo]: + out: list[ModellInfo] = [] + for m in daten: + p = m.get("pricing") or {} + frei = _als_float(p.get("prompt")) == 0 and _als_float(p.get("completion")) == 0 + params = m.get("supported_parameters") or [] + out.append( + ModellInfo( + id=m.get("id", ""), + name=m.get("name") or m.get("id", ""), + context=m.get("context_length"), + frei=frei, + tools="tools" in params, + ) + ) + return out + + +def lade_modelle(session=None) -> list[ModellInfo]: + """Zieht die Modellliste live (= 'aktualisieren'). Kein Key nötig.""" + sess = session + if sess is None: + import requests + + sess = requests + antwort = sess.get(MODELS_URL, timeout=30) + antwort.raise_for_status() + return parse_modelle(antwort.json().get("data", [])) + + +def _rang(mi: ModellInfo) -> tuple: + low = mi.id.lower() + for i, schluessel in enumerate(PRAEFERENZ): + if schluessel in low: + return (0, i, -(mi.context or 0)) + return (1, 0, -(mi.context or 0)) + + +def top_free(modelle: list[ModellInfo], n: int = 5, nur_tools: bool = True) -> list[ModellInfo]: + """Die n besten frei nutzbaren Modelle (standardmäßig nur tool-fähige).""" + kandidaten = [m for m in modelle if m.frei and (m.tools or not nur_tools)] + return sorted(kandidaten, key=_rang)[:n] + + +def suche( + modelle: list[ModellInfo], + begriff: str, + *, + nur_frei: bool = False, + nur_tools: bool = False, +) -> list[ModellInfo]: + """Filtert nach Teilstring in id/name; optional auf frei/tool-fähig.""" + b = (begriff or "").lower().strip() + res = [m for m in modelle if b in m.id.lower() or b in m.name.lower()] + if nur_frei: + res = [m for m in res if m.frei] + if nur_tools: + res = [m for m in res if m.tools] + return sorted(res, key=_rang) diff --git a/src/angebote/quellen/__init__.py b/src/angebote/quellen/__init__.py new file mode 100644 index 0000000..3008d3c --- /dev/null +++ b/src/angebote/quellen/__init__.py @@ -0,0 +1 @@ +"""Quellen-Adapter. Ein Adapter pro Quelle, gleiche Schnittstelle (basis.py).""" diff --git a/src/angebote/quellen/basis.py b/src/angebote/quellen/basis.py new file mode 100644 index 0000000..893e05a --- /dev/null +++ b/src/angebote/quellen/basis.py @@ -0,0 +1,41 @@ +"""Adapter-Schnittstelle für Quellen. + +Vertrag: rein = `Ort`, raus = `list[Angebot]`. Jeder Adapter kapselt genau eine +Quelle; die Kernlogik (fetch.py) kennt nur diese Schnittstelle, nicht die +einzelne Quelle. So lassen sich Quellen austauschen, ohne den Kern anzufassen. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Protocol, runtime_checkable + +from ..modell import Angebot + + +@dataclass(frozen=True) +class Ort: + """Aufgelöster Ort. `plz` ist Pflicht -- ein Adapter filtert darüber.""" + + plz: str + name: str | None = None + + +@runtime_checkable +class QuelleAdapter(Protocol): + """Eine Angebotsquelle. + + `name` -- Anzeigename der Quelle. + `deckt_ab` -- ob die Quelle den Ort bedienen kann (entscheidet mit über + Regel 4: deckt KEINE Quelle den Ort ab -> Abbruch). + `hole` -- liefert belegte Angebote für den Ort. Liefert die Quelle + nichts, ist das ein leeres Ergebnis -- KEIN Auffüllen. + Kann die Quelle den Ortsbezug nicht verifizieren oder den + nötigen Zugang nicht herstellen, wirft sie AbbruchFehler. + """ + + name: str + + def deckt_ab(self, ort: Ort) -> bool: ... + + def hole(self, ort: Ort) -> list[Angebot]: ... diff --git a/src/angebote/quellen/marktguru.py b/src/angebote/quellen/marktguru.py new file mode 100644 index 0000000..0c09c2d --- /dev/null +++ b/src/angebote/quellen/marktguru.py @@ -0,0 +1,427 @@ +"""marktguru-Adapter -- erste echte Quelle. REIN DETERMINISTISCH, kein LLM. + +Die API ist öffentlich dokumentiert nachvollziehbar (Reverse-Engineering durch +mehrere Community-Projekte): + + Basis : https://api.marktguru.de/api/v1 + Endpoint : offers/search + Parameter : as=web, q=, zipCode=, limit, offset + Header : x-apikey, x-clientkey -- werden aus dem in der Startseite + eingebetteten ', re.DOTALL +) + + +class MarktguruAdapter: + """Adapter für marktguru.de. Erfüllt das QuelleAdapter-Protokoll.""" + + name = "marktguru" + + def __init__( + self, + *, + suchbegriffe: tuple[str, ...] | None = None, + limit: int = 100, + cache_dir: str | Path | None = None, + session=None, + ) -> None: + from ..config import SUCHBEGRIFFE + + self.suchbegriffe = tuple(suchbegriffe) if suchbegriffe is not None else SUCHBEGRIFFE + self.limit = limit + self.cache_dir = Path(cache_dir) if cache_dir else Path(".cache/marktguru") + self._session = session # für Tests injizierbar + self._apikey: str | None = None + self._clientkey: str | None = None + # Ehrliche Abdeckungs-Notiz, vom Orchestrator eingesammelt: + self.abdeckungshinweis = ( + f"marktguru: query-basierte Teilabdeckung über {len(self.suchbegriffe)} " + "Seedbegriffe -- keine vollständige Aufzählung aller Angebote." + ) + + # -- QuelleAdapter ---------------------------------------------------- + + def deckt_ab(self, ort: Ort) -> bool: + # marktguru filtert bundesweit über PLZ. Eine 5-stellige PLZ gilt als + # abgedeckt; ob konkret Angebote vorliegen, entscheidet erst `hole`. + return bool(ort.plz) and ort.plz.isdigit() and len(ort.plz) == 5 + + def hole(self, ort: Ort) -> list[Angebot]: + if not self.deckt_ab(ort): + raise AbbruchFehler( + schwelle="marktguru: PLZ-Filter", + ursache=f"'{ort.plz}' ist keine gültige 5-stellige PLZ", + vorschlag="eine 5-stellige PLZ angeben (z. B. 60487)", + ) + + roh, abgerufen_am = self._hole_roh(ort) + angebote = self._parse(roh, ort, abgerufen_am) + + # Ortsbezug verifizieren: Es gab Treffer, aber der Filter ist nur dann + # belegt, wenn wir ihn auch wirklich mitgesendet haben (siehe quelle). + # Trefferzahl 0 ist KEIN Fehler -> leeres Ergebnis (kein Auffüllen). + return angebote + + # -- intern ----------------------------------------------------------- + + def _sess(self): + if self._session is None: + import requests # lokal, damit der Fetch-Teil ohne Netz importierbar bleibt + + self._session = requests.Session() + self._session.headers.update({"User-Agent": _USER_AGENT}) + return self._session + + def _robots_pruefen(self, url: str) -> tuple[bool, str]: + """Prüft robots.txt. Gibt (erlaubt, ehrliche_ursache_falls_nicht) zurück. + + robots.txt wird über DENSELBEN Transport wie der eigentliche Abruf + geholt (requests, certifi-gestützt) -- damit ein erfolgreicher Abruf + und ein erfolgreicher robots-Check dieselbe Vertrauensbasis haben. + + Wichtig für die Ehrlichkeit des Abbruchs: 'nicht prüfbar' (Netz-/SSL-/ + HTTP-Fehler) ist NICHT dasselbe wie 'durch robots.txt untersagt'. Beide + führen konservativ zum Nicht-Abruf, aber die genannte Ursache muss + zutreffen -- es wird keine Sperre behauptet, die nicht belegt ist. + Fehlt robots.txt (404), gilt der Pfad als erlaubt. + """ + from urllib.parse import urlsplit + + teile = urlsplit(url) + robots_url = f"{teile.scheme}://{teile.netloc}/robots.txt" + try: + antwort = self._sess().get(robots_url, timeout=20) + except Exception as e: + return False, f"robots.txt nicht prüfbar ({robots_url}: {e})" + if antwort.status_code == 404: + return True, "" + if antwort.status_code != 200: + return False, f"robots.txt nicht prüfbar ({robots_url}: HTTP {antwort.status_code})" + rp = RobotFileParser() + rp.parse(antwort.text.splitlines()) + if rp.can_fetch(_USER_AGENT, url): + return True, "" + return False, f"durch robots.txt untersagt ({robots_url})" + + def _schluessel(self) -> tuple[str, str]: + if self._apikey and self._clientkey: + return self._apikey, self._clientkey + + env_api = os.environ.get("MARKTGURU_APIKEY") + env_client = os.environ.get("MARKTGURU_CLIENTKEY") + if env_api and env_client: + self._apikey, self._clientkey = env_api, env_client + return self._apikey, self._clientkey + + erlaubt, grund = self._robots_pruefen(_HOMEPAGE) + if not erlaubt: + raise AbbruchFehler( + schwelle="marktguru: Startseite nicht abrufbar", + ursache=grund, + vorschlag="Schlüssel per ENV MARKTGURU_APIKEY/MARKTGURU_CLIENTKEY setzen", + ) + + try: + antwort = self._sess().get(_HOMEPAGE, timeout=20) + antwort.raise_for_status() + html = antwort.text + except Exception as e: # Netz-/HTTP-Fehler + raise AbbruchFehler( + schwelle="marktguru: Zugang", + ursache=f"Startseite nicht abrufbar ({e})", + vorschlag="Netzverbindung prüfen oder Schlüssel per ENV setzen", + ) from e + + api, client = self._schluessel_aus_html(html) + if not api or not client: + raise AbbruchFehler( + schwelle="marktguru: Zugang", + ursache="apiKey/clientKey nicht im Config-Block der Startseite gefunden " + "(Seitenstruktur geändert?)", + vorschlag="Schlüssel per ENV MARKTGURU_APIKEY/MARKTGURU_CLIENTKEY setzen", + ) + self._apikey, self._clientkey = api, client + return api, client + + @staticmethod + def _schluessel_aus_html(html: str) -> tuple[str | None, str | None]: + for block in _CONFIG_RE.findall(html): + try: + cfg = json.loads(block) + except json.JSONDecodeError: + continue + api, client = _suche_keys(cfg) + if api and client: + return api, client + return None, None + + def _cache_pfad(self, ort: Ort, abgerufen_am: datetime) -> Path: + jahr, woche, _ = abgerufen_am.isocalendar() + return self.cache_dir / f"{ort.plz}_{jahr}-W{woche:02d}.json" + + def _hole_roh(self, ort: Ort) -> tuple[list[dict], datetime]: + """Aggregiert die rohen Offer-Objekte über alle Seedbegriffe (dedupliziert). + + Cache pro PLZ/Kalenderwoche; bei Treffer wird nicht erneut gezogen. + Ein einzelner Suchbegriff ohne Treffer ist KEIN Fehler -- nur ein + leerer Beitrag. Bricht hingegen der Zugang/HTTP, gilt Regel 4. + """ + jetzt = datetime.now() + treffer = self._cache_pfad(ort, jetzt) + if treffer.exists(): + gespeichert = json.loads(treffer.read_text(encoding="utf-8")) + return gespeichert["results"], datetime.fromisoformat( + gespeichert["abgerufen_am"] + ) + + erlaubt, grund = self._robots_pruefen(_OFFERS_SEARCH) + if not erlaubt: + raise AbbruchFehler( + schwelle="marktguru: API nicht abrufbar", + ursache=grund, + vorschlag="andere Quelle wählen, Netz/Zertifikate prüfen oder Betreiber kontaktieren", + ) + + api, client = self._schluessel() + headers = {"x-apikey": api, "x-clientkey": client} + gesehen: set[str] = set() + aggregiert: list[dict] = [] + for begriff in self.suchbegriffe: + for offer in self._suche_eine(begriff, ort, headers): + oid = str(offer.get("id") or "") + schluessel = oid or json.dumps(offer, sort_keys=True)[:120] + if schluessel in gesehen: + continue + gesehen.add(schluessel) + aggregiert.append(offer) + + self.cache_dir.mkdir(parents=True, exist_ok=True) + treffer.write_text( + json.dumps({"abgerufen_am": jetzt.isoformat(), "results": aggregiert}), + encoding="utf-8", + ) + return aggregiert, jetzt + + def _suche_eine(self, begriff: str, ort: Ort, headers: dict) -> list[dict]: + params = { + "as": "web", + "q": begriff, + "limit": str(self.limit), + "offset": "0", + "zipCode": ort.plz, # <-- belegter Ortsfilter + } + try: + antwort = self._sess().get( + _OFFERS_SEARCH, params=params, headers=headers, timeout=30 + ) + antwort.raise_for_status() + daten = antwort.json() + except Exception as e: + raise AbbruchFehler( + schwelle="marktguru: Abruf", + ursache=f"offers/search fehlgeschlagen für '{begriff}' ({e})", + vorschlag="später erneut versuchen, PLZ prüfen, ggf. Schlüssel erneuern", + ) from e + + results = daten.get("results") + if results is None: + raise AbbruchFehler( + schwelle="marktguru: Antwortform", + ursache="Antwort enthält kein 'results' (API-Form geändert?)", + vorschlag="Parser an neue Antwortstruktur anpassen", + ) + return results + + def _parse( + self, results: list[dict], ort: Ort, abgerufen_am: datetime + ) -> list[Angebot]: + angebote: list[Angebot] = [] + for eintrag in results: + offer = eintrag.get("offer", eintrag) # je nach Antwortform + titel = _pfad(offer, "product", "name") + if not titel: + # Ohne belegten Titel ist der Eintrag nicht verwertbar -> weglassen, + # NICHT mit Platzhalter füllen. + continue + haendler = _erster_name(offer.get("advertisers")) or "unbekannt (marktguru)" + offer_id = str(offer.get("id") or "") + einheit = _pfad(offer, "unit", "name") + angebote.append( + Angebot( + titel=titel, + marke=_marke(_pfad(offer, "brand", "name")), + preis=_als_float(offer.get("price")), + grundpreis=_grundpreis(offer.get("referencePrice"), einheit), + menge=_menge(offer.get("quantity"), offer.get("volume"), einheit), + gueltig_von=_erstes_datum(offer.get("validityDates"), "from"), + gueltig_bis=_erstes_datum(offer.get("validityDates"), "to"), + haendler=haendler, + # quelle belegt zugleich den Ortsfilter (zipCode): + quelle=f"marktguru:offers/search?zipCode={ort.plz}#offer={offer_id}", + abgerufen_am=abgerufen_am, + ) + ) + return angebote + + +# --- kleine, reine Hilfsfunktionen (keine Daten-Reparatur, nur Auslesen) ----- + + +def _suche_keys(obj) -> tuple[str | None, str | None]: + """Findet apiKey/clientKey rekursiv im Config-Objekt der Startseite.""" + api = client = None + if isinstance(obj, dict): + for k, v in obj.items(): + kl = k.lower() + if kl == "apikey" and isinstance(v, str): + api = v + elif kl == "clientkey" and isinstance(v, str): + client = v + else: + a, c = _suche_keys(v) + api = api or a + client = client or c + elif isinstance(obj, list): + for el in obj: + a, c = _suche_keys(el) + api = api or a + client = client or c + return api, client + + +def _pfad(obj: dict, *schluessel: str) -> str | None: + cur = obj + for s in schluessel: + if not isinstance(cur, dict): + return None + cur = cur.get(s) + if isinstance(cur, str) and cur.strip(): + return cur + return None + + +def _marke(wert: str | None) -> str | None: + """Filtert den Sentinel der Quelle für 'keine Marke' heraus.""" + from ..config import MARKE_SENTINELS + + if wert is None or wert in MARKE_SENTINELS: + return None + return wert + + +def _erster_name(liste) -> str | None: + if isinstance(liste, list): + for el in liste: + if isinstance(el, dict) and isinstance(el.get("name"), str): + return el["name"] + return None + + +def _als_float(wert) -> float | None: + if isinstance(wert, (int, float)): + return float(wert) + return None # nicht eindeutig -> fehlend, NICHT geraten + + +def _zahl(wert) -> str | None: + """Formatiert eine Zahl ohne überflüssige Nullen, mit Komma. 0.25 -> '0,25'.""" + if not isinstance(wert, (int, float)): + return None + text = f"{wert:.3f}".rstrip("0").rstrip(".") + return text.replace(".", ",") + + +def _grundpreis(wert, einheit: str | None) -> str | None: + """Grundpreis aus belegten Feldern komponiert -- nichts umgerechnet/erfunden. + + marktguru liefert referencePrice numerisch (Preis je `unit.name`). Aus den + zwei belegten Feldern wird eine lesbare, aber unveränderte Darstellung. + """ + if isinstance(wert, (int, float)) and einheit: + return f"{wert:.2f} €/{einheit}".replace(".", ",") + if isinstance(wert, str) and wert.strip(): + return wert # Falls eine Quelle es doch als String liefert: roh. + return None + + +def _menge(quantity, volume, einheit: str | None) -> str | None: + """Packungsmenge aus quantity × volume + Einheit -- belegt zusammengesetzt.""" + vol = _zahl(volume) + if not vol or not einheit: + return None + if isinstance(quantity, (int, float)) and quantity and quantity != 1: + return f"{_zahl(quantity)} × {vol} {einheit}" + return f"{vol} {einheit}" + + +def _erstes_datum(liste, feld: str) -> date | None: + if isinstance(liste, list): + for el in liste: + if isinstance(el, dict) and el.get(feld): + return _parse_datum(el[feld]) + return None + + +def _parse_datum(roh) -> date | None: + """Gültigkeits-Datum aus ISO-Zeitstempel. + + Zeitzonen-korrekt: marktguru liefert UTC ('...22:00:00Z'). Für einen + Einkäufer in Deutschland zählt das LOKALE Datum -- sonst entsteht ein + Off-by-one (22:00Z = 00:00 lokal am Folgetag). Daher Umrechnung nach + Europe/Berlin, bevor das Datum genommen wird. + """ + if not isinstance(roh, str): + return None + text = roh.replace("Z", "+00:00") + try: + dt = datetime.fromisoformat(text) + except ValueError: + try: + return date.fromisoformat(text[:10]) + except ValueError: + return None # nicht parsebar -> fehlend, nicht geraten + if dt.tzinfo is not None: + try: + from zoneinfo import ZoneInfo + + dt = dt.astimezone(ZoneInfo("Europe/Berlin")) + except Exception: + pass # ohne tz-Daten: belegtes UTC-Datum statt Abbruch + return dt.date() diff --git a/src/angebote/uebersicht.py b/src/angebote/uebersicht.py new file mode 100644 index 0000000..7b5484b --- /dev/null +++ b/src/angebote/uebersicht.py @@ -0,0 +1,137 @@ +"""Gruppierung + Rendering der fertigen Übersicht. + +Regeln, die auch hier sichtbar bleiben: + * Reihenfolge = feste Produktgruppen-Liste (config), nicht "nach Häufigkeit". + * Leere Gruppen werden als "keine Angebote" gezeigt -- nicht weggelassen, + nicht aufgefüllt. + * Unsicher zugeordnete Artikel sind markiert (Mensch kann nachsehen). + * Bekannte Abdeckungslücken (z. B. Aldi/Lidl) werden ehrlich genannt. +""" + +from __future__ import annotations + +from .config import PRODUKTGRUPPEN +from .modell import FetchErgebnis, KategorisiertesAngebot + + +def gruppieren( + kategorisiert: list[KategorisiertesAngebot], +) -> dict[str, list[KategorisiertesAngebot]]: + """Gruppiert in der festen Reihenfolge der Produktgruppen-Config.""" + gruppen: dict[str, list[KategorisiertesAngebot]] = {g: [] for g in PRODUKTGRUPPEN} + for ka in kategorisiert: + # _bereinige_gruppe garantiert: ka.gruppe ist in PRODUKTGRUPPEN. + gruppen[ka.gruppe].append(ka) + return gruppen + + +def _angebot_dict(ka: KategorisiertesAngebot) -> dict: + a = ka.angebot + return { + "titel": a.titel, + "marke": a.marke, + "preis": a.preis, + "grundpreis": a.grundpreis, + "menge": a.menge, + "haendler": a.haendler, + "gueltig_von": a.gueltig_von.isoformat() if a.gueltig_von else None, + "gueltig_bis": a.gueltig_bis.isoformat() if a.gueltig_bis else None, + "quelle": a.quelle, + "unsicher": ka.unsicher, + } + + +def als_struktur( + fetch: FetchErgebnis, + kategorisiert: list[KategorisiertesAngebot], +) -> 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.""" + 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), + "quellen": list(fetch.abgedeckte_quellen), + "haendler": list(fetch.gesehene_haendler), + "hinweise": list(fetch.hinweise), + "gruppen": [ + { + "name": g, + "anzahl": len(gruppen[g]), + "angebote": [_angebot_dict(ka) for ka in gruppen[g]], + } + for g in PRODUKTGRUPPEN + ], + } + + +def rendern( + fetch: FetchErgebnis, + kategorisiert: list[KategorisiertesAngebot], +) -> str: + gruppen = gruppieren(kategorisiert) + ort = fetch.ort_name or fetch.ort_plz + zeilen: list[str] = [] + zeilen.append(f"# Angebote {ort} (PLZ {fetch.ort_plz})") + zeilen.append("") + zeilen.append( + f"Quellen: {', '.join(fetch.abgedeckte_quellen) or '—'} · " + f"{len(kategorisiert)} Angebote" + ) + zeilen.append("") + + for gruppe in PRODUKTGRUPPEN: + eintraege = gruppen[gruppe] + zeilen.append(f"## {gruppe}") + if not eintraege: + zeilen.append("_keine Angebote_") + zeilen.append("") + continue + for ka in eintraege: + zeilen.append(_zeile(ka)) + zeilen.append("") + + zeilen.append("---") + if fetch.gesehene_haendler: + zeilen.append( + "**Beobachtete Händler (datengetrieben, belegt):** " + + ", ".join(fetch.gesehene_haendler) + + "." + ) + zeilen.append( + "_Abdeckung = was die abgefragten Quellen für diesen Ort hergeben; " + "Fehlen eines Händlers belegt keine Lücke, sondern nur 'diese Woche/" + "Abfrage kein Treffer'._" + ) + for h in fetch.hinweise: + zeilen.append(f"_{h}_") + + return "\n".join(zeilen).rstrip() + "\n" + + +def _zeile(ka: KategorisiertesAngebot) -> str: + a = ka.angebot + teile: list[str] = [] + if a.marke: + teile.append(f"**{a.marke}** {a.titel}") + else: + teile.append(f"**{a.titel}**") + if a.menge: + teile.append(f"({a.menge})") + if a.preis is not None: + teile.append(f"— {a.preis:.2f} €".replace(".", ",")) + else: + teile.append("— Preis fehlt") + if a.grundpreis: + teile.append(f"[{a.grundpreis}]") + if a.gueltig_von or a.gueltig_bis: + von = a.gueltig_von.isoformat() if a.gueltig_von else "?" + bis = a.gueltig_bis.isoformat() if a.gueltig_bis else "?" + teile.append(f"gültig {von}–{bis}") + teile.append(f"@ {a.haendler}") + if ka.unsicher: + teile.append("⚠️ unsicher") + return "- " + " ".join(teile) diff --git a/src/angebote/web.py b/src/angebote/web.py new file mode 100644 index 0000000..45e1257 --- /dev/null +++ b/src/angebote/web.py @@ -0,0 +1,147 @@ +"""FastAPI-Web-UI -- dünne Schicht über den bestehenden Modulen. + +Der Schnitt bleibt unangetastet: dieser Server ruft `fetch` (deterministisch) +und `kategorisieren` (LLM) auf, vermischt aber nichts. Die UI ist reine +Präsentation; alle harten Regeln (kein Auffüllen, nur Belegtes, Abbruch statt +Drift) leben weiter in den darunterliegenden Modulen. + +Start: + OPENROUTER_API_KEY=... uvicorn angebote.web:app --port 8000 +(aus dem src/-Verzeichnis, oder mit PYTHONPATH=src) +""" + +from __future__ import annotations + +import threading +import uuid +from pathlib import Path + +from fastapi import FastAPI, HTTPException +from fastapi.responses import HTMLResponse + +from .fehler import AbbruchFehler + +app = FastAPI(title="Angebots-Übersicht") + +_HTML = (Path(__file__).parent / "web_static" / "index.html").read_text("utf-8") + +# In-memory Job-Store. Schlicht gehalten -- ein lokales Single-User-Werkzeug. +_jobs: dict[str, dict] = {} +_jobs_lock = threading.Lock() + +# Ergebnis-Cache: identischer Lauf (PLZ, Modell, no_llm) kommt sofort, ohne +# erneute LLM-Calls. Im Geist des Projekt-Cachings. In-memory, pro Serverlauf. +_ergebnis_cache: dict[tuple, dict] = {} + + +@app.get("/", response_class=HTMLResponse) +def index() -> str: + return _HTML + + +@app.get("/api/modelle") +def api_modelle(q: str = "") -> list[dict]: + """Modell-Liste für das Dropdown: Suche oder Top-Free. 'Aktualisieren' = neu rufen.""" + from .modelle import lade_modelle, suche, top_free + + try: + alle = lade_modelle() + except Exception as e: # Netz/SSL -> ehrlich melden, nicht raten + raise HTTPException(status_code=502, detail=f"Modelle nicht abrufbar: {e}") + treffer = suche(alle, q) if q else top_free(alle, 8) + return [ + {"id": m.id, "frei": m.frei, "tools": m.tools, "context": m.context} + for m in treffer[:25] + ] + + +@app.post("/api/lauf") +def api_lauf(req: dict) -> dict: + plz = (req.get("plz") or "").strip() + if not plz: + raise HTTPException(status_code=400, detail="PLZ fehlt") + modell = req.get("modell") or None + no_llm = bool(req.get("no_llm")) + job_id = uuid.uuid4().hex[:12] + + # Cache-Treffer? Dann sofort als fertiger Job ausliefern, kein neuer Lauf. + treffer = _ergebnis_cache.get((plz, modell, no_llm)) + if treffer is not None: + with _jobs_lock: + _jobs[job_id] = { + "status": "fertig", "phase": "cache", "done": 0, "total": 0, + "ergebnis": treffer, "fehler": None, + } + return {"job_id": job_id, "cache": True} + + with _jobs_lock: + _jobs[job_id] = { + "status": "laufend", + "phase": "fetch", + "done": 0, + "total": 0, + "ergebnis": None, + "fehler": None, + } + t = threading.Thread( + target=_run_job, + args=( + job_id, + plz, + modell, + req.get("anbieter") or "openrouter", + no_llm, + req.get("key") or None, + ), + daemon=True, + ) + t.start() + return {"job_id": job_id} + + +@app.get("/api/lauf/{job_id}") +def api_status(job_id: str) -> dict: + job = _jobs.get(job_id) + if not job: + raise HTTPException(status_code=404, detail="unbekannter Job") + return job + + +def _run_job(job_id, plz, modell, anbieter, no_llm, key) -> None: + job = _jobs[job_id] + try: + from .fetch import hole_angebote + from .modell import KategorisiertesAngebot + from .uebersicht import als_struktur + + fetch = hole_angebote(plz) # deterministisch; AbbruchFehler bei Regel 4 + + if no_llm: + # Ohne LLM: belegte Rohliste, sichtbar als unkategorisiert markiert. + from .config import FALLBACK_GRUPPE + + kat = [ + KategorisiertesAngebot(a, FALLBACK_GRUPPE, unsicher=True) + for a in fetch.angebote + ] + else: + from .kategorisieren import baue_kategorisierer, kategorisiere + + job["phase"] = "kategorisieren" + kt = baue_kategorisierer(anbieter, modell, api_key=key) + + def fort(done, total): + job["done"] = done + job["total"] = total + + kat = kategorisiere(list(fetch.angebote), kt, fortschritt=fort) + + job["ergebnis"] = als_struktur(fetch, kat) + job["status"] = "fertig" + _ergebnis_cache[(plz, modell, no_llm)] = job["ergebnis"] + except AbbruchFehler as e: + job["status"] = "fehler" + job["fehler"] = e.als_text() + except Exception as e: # nichts verstecken -- ehrliche Fehlermeldung + job["status"] = "fehler" + job["fehler"] = f"Unerwarteter Fehler: {e}" diff --git a/src/angebote/web_static/index.html b/src/angebote/web_static/index.html new file mode 100644 index 0000000..55c4270 --- /dev/null +++ b/src/angebote/web_static/index.html @@ -0,0 +1,310 @@ + + + + + +Angebots-Übersicht + + + +
+

Angebots-Übersicht

+

Ortskonkret, händlerübergreifend, nach Produktgruppen. Daten deterministisch aus marktguru, + Einordnung per LLM. Jedes Angebot ist belegt — kein Auffüllen, Unsicheres ist markiert.

+
+ +
+
+
+
+ + +
+
+
+
+
+ +
+ +
+
+ +
+ + +
+ + + + diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/fakes.py b/tests/fakes.py new file mode 100644 index 0000000..fd0c3aa --- /dev/null +++ b/tests/fakes.py @@ -0,0 +1,54 @@ +"""Test-Doubles -- halten die Tests offline (kein Netz, kein LLM, kein Schlüssel).""" + +from __future__ import annotations + +from datetime import date, datetime +from typing import Callable + +from angebote.modell import Angebot +from angebote.quellen.basis import Ort + + +class FakeQuelle: + """Adapter-Double: liefert vorgegebene Angebote, deckt-Flag, optional Fehler.""" + + def __init__(self, name, angebote=None, *, deckt=True, fehler=None): + self.name = name + self._angebote = list(angebote or []) + self._deckt = deckt + self._fehler = fehler + + def deckt_ab(self, ort: Ort) -> bool: + return self._deckt + + def hole(self, ort: Ort): + if self._fehler is not None: + raise self._fehler + return list(self._angebote) + + +class FakeKategorisierer: + """Kategorisierer-Double: ruft eine reine Funktion posten->antworten auf.""" + + def __init__(self, fn: Callable[[list[dict]], list[dict]]): + self._fn = fn + + def klassifiziere(self, posten: list[dict]) -> list[dict]: + return self._fn(posten) + + +def beispiel_angebot(titel="Butter", **kw) -> Angebot: + """Belegtes Angebot mit Default-Pflichtfeldern; einzeln überschreibbar.""" + daten = dict( + titel=titel, + haendler="REWE", + quelle="test:fixture", + abgerufen_am=datetime(2026, 6, 1, 8, 0, 0), + preis=1.49, + marke="Markenbutter", + menge="250 g", + gueltig_von=date(2026, 6, 1), + gueltig_bis=date(2026, 6, 7), + ) + daten.update(kw) + return Angebot(**daten) diff --git a/tests/test_fetch_abbruch_ort.py b/tests/test_fetch_abbruch_ort.py new file mode 100644 index 0000000..4696f1c --- /dev/null +++ b/tests/test_fetch_abbruch_ort.py @@ -0,0 +1,41 @@ +"""Regel 4: Abbruch statt stiller Drift -- mit Schwelle, Ursache, Vorschlag.""" + +import pytest + +from angebote.fehler import AbbruchFehler +from angebote.fetch import aufloesen_ort, hole_angebote +from tests.fakes import FakeQuelle, beispiel_angebot + + +def test_unaufloesbarer_ort_bricht_ab(): + with pytest.raises(AbbruchFehler) as exc: + aufloesen_ort("Hintertupfingen") + e = exc.value + # Der Abbruch ist brauchbar: alle drei Felder sind belegt. + assert e.schwelle and e.ursache and e.vorschlag + assert "PLZ" in e.vorschlag + + +def test_plz_wird_direkt_aufgeloest(): + ort = aufloesen_ort("60487") + assert ort.plz == "60487" + + +def test_bekannter_ortsname_wird_aufgeloest(): + ort = aufloesen_ort("Frankfurt") + assert ort.plz and ort.plz.isdigit() + + +def test_keine_quelle_deckt_ort_ab_bricht_ab(): + with pytest.raises(AbbruchFehler) as exc: + hole_angebote("60487", [FakeQuelle("woanders", [], deckt=False)]) + assert exc.value.schwelle == "Ortsabdeckung" + + +def test_adapter_abbruch_propagiert_nicht_kaschiert(): + # Eine Quelle, die ihren Ortsbezug nicht herstellen kann, bricht ab -- + # der Orchestrator schluckt das NICHT zu einem leeren Ergebnis. + fehler = AbbruchFehler("Quelle X", "Ortsbezug nicht verifiziert", "andere Quelle") + quelle = FakeQuelle("kaputt", [beispiel_angebot()], fehler=fehler) + with pytest.raises(AbbruchFehler): + hole_angebote("60487", [quelle]) diff --git a/tests/test_fetch_kein_auffuellen.py b/tests/test_fetch_kein_auffuellen.py new file mode 100644 index 0000000..c529794 --- /dev/null +++ b/tests/test_fetch_kein_auffuellen.py @@ -0,0 +1,21 @@ +"""Regel: Kein Auffüllen. Leeres Quellergebnis bleibt leer.""" + +from angebote.fetch import hole_angebote +from angebote.uebersicht import rendern +from tests.fakes import FakeQuelle + + +def test_leere_quelle_fuellt_nicht_auf(): + ergebnis = hole_angebote("60487", [FakeQuelle("leer", [])]) + assert ergebnis.angebote == () + # Der Lauf wird ehrlich vermerkt, nicht versteckt: + assert any("0 Angebote" in h for h in ergebnis.hinweise) + + +def test_uebersicht_zeigt_keine_erfundenen_beispiele(): + ergebnis = hole_angebote("60487", [FakeQuelle("leer", [])]) + text = rendern(ergebnis, []) + # Jede Gruppe steht da -- aber leer, als "keine Angebote", nicht aufgefüllt. + assert text.count("_keine Angebote_") >= 1 + # Keine Beispieldaten: + assert "€" not in text or "Preis fehlt" in text diff --git a/tests/test_kategorisieren_geschlossene_liste.py b/tests/test_kategorisieren_geschlossene_liste.py new file mode 100644 index 0000000..d76d2da --- /dev/null +++ b/tests/test_kategorisieren_geschlossene_liste.py @@ -0,0 +1,35 @@ +"""Regel: Geschlossene Kategorienliste. Off-list -> Fallback + unsicher.""" + +from angebote.config import FALLBACK_GRUPPE, PRODUKTGRUPPEN +from angebote.kategorisieren import kategorisiere +from tests.fakes import FakeKategorisierer, beispiel_angebot + + +def test_erfundene_gruppe_wird_nicht_uebernommen(): + # Das Modell "erfindet" eine Gruppe -> Code zwingt sie in den Fallback. + fake = FakeKategorisierer( + lambda posten: [ + {"id": p["id"], "gruppe": "Weltraumzeug", "unsicher": False} for p in posten + ] + ) + ergebnis = kategorisiere([beispiel_angebot("Mondstaub")], fake) + assert ergebnis[0].gruppe == FALLBACK_GRUPPE + assert ergebnis[0].unsicher is True + + +def test_keine_gruppe_ausserhalb_der_config(): + namen = ["Butter", "Apfel", "Cola", "Bier", "Pizza", "Shampoo", "Bohrmaschine"] + fake = FakeKategorisierer( + # Mischung aus gültigen und ungültigen Gruppen: + lambda posten: [ + { + "id": p["id"], + "gruppe": "Obst & Gemüse" if i % 2 == 0 else "Quatschgruppe", + "unsicher": False, + } + for i, p in enumerate(posten) + ] + ) + ergebnis = kategorisiere([beispiel_angebot(n) for n in namen], fake) + for ka in ergebnis: + assert ka.gruppe in PRODUKTGRUPPEN diff --git a/tests/test_kategorisieren_integritaet.py b/tests/test_kategorisieren_integritaet.py new file mode 100644 index 0000000..fcb444d --- /dev/null +++ b/tests/test_kategorisieren_integritaet.py @@ -0,0 +1,53 @@ +"""Regel: Daten sind unantastbar. Preis/Gültigkeit/Händler bleiben identisch.""" + +from dataclasses import replace + +from angebote.kategorisieren import kategorisiere +from tests.fakes import FakeKategorisierer, beispiel_angebot + + +def _gib_gruppe(gruppe): + return FakeKategorisierer( + lambda posten: [{"id": p["id"], "gruppe": gruppe, "unsicher": False} for p in posten] + ) + + +def test_originaldaten_unveraendert_property(): + angebote = [ + beispiel_angebot("Butter", preis=1.49, haendler="REWE"), + beispiel_angebot("Hähnchen", preis=None, haendler="Penny", marke=None), + beispiel_angebot("Wein", preis=4.99, haendler="EDEKA", menge="0,75 l"), + ] + ergebnis = kategorisiere(angebote, _gib_gruppe("Sonstiges")) + + nach = {k.angebot.angebot_id: k.angebot for k in ergebnis} + for vorher in angebote: + nachher = nach[vorher.angebot_id] + # Property über den ganzen Stream: jedes belegte Feld bleibt gleich. + assert nachher.titel == vorher.titel + assert nachher.preis == vorher.preis + assert nachher.haendler == vorher.haendler + assert nachher.marke == vorher.marke + assert nachher.menge == vorher.menge + assert nachher.gueltig_von == vorher.gueltig_von + assert nachher.gueltig_bis == vorher.gueltig_bis + # Sogar Objekt-Identität: das eingefrorene Original wird durchgereicht. + assert nachher is vorher + + +def test_fehlender_preis_bleibt_fehlend(): + angebot = beispiel_angebot("Hähnchen", preis=None) + ergebnis = kategorisiere([angebot], _gib_gruppe("Fleisch & Wurst")) + assert ergebnis[0].angebot.preis is None + + +def test_modell_kann_daten_strukturell_nicht_aendern(): + # frozen=True: ein Versuch, den Preis zu "korrigieren", schlägt fehl. + angebot = beispiel_angebot("Butter", preis=1.49) + import pytest + + with pytest.raises(Exception): + angebot.preis = 0.99 # type: ignore[misc] + # replace erzeugt ein NEUES Objekt -- das Original bleibt unberührt. + anderes = replace(angebot, preis=0.99) + assert angebot.preis == 1.49 and anderes.preis == 0.99 diff --git a/tests/test_kategorisieren_unsicherheit.py b/tests/test_kategorisieren_unsicherheit.py new file mode 100644 index 0000000..3e08727 --- /dev/null +++ b/tests/test_kategorisieren_unsicherheit.py @@ -0,0 +1,41 @@ +"""Regel: Unsicherheit wird geflaggt, nicht still einsortiert.""" + +from angebote.kategorisieren import kategorisiere +from angebote.modell import FetchErgebnis +from angebote.uebersicht import rendern +from tests.fakes import FakeKategorisierer, beispiel_angebot + + +def test_mehrdeutiger_artikel_wird_geflaggt(): + fake = FakeKategorisierer( + lambda posten: [ + {"id": p["id"], "gruppe": "Molkereiprodukte & Eier", "unsicher": True} + for p in posten + ] + ) + ergebnis = kategorisiere([beispiel_angebot("Hafer-Pflanzendrink")], fake) + assert ergebnis[0].unsicher is True + + +def test_nicht_beantworteter_posten_wird_unsicher_statt_still(): + # Modell antwortet zu KEINEM Posten -> kein stilles Einsortieren. + fake = FakeKategorisierer(lambda posten: []) + ergebnis = kategorisiere([beispiel_angebot("Rätselartikel")], fake) + assert ergebnis[0].unsicher is True + + +def test_unsicherheit_ist_in_der_uebersicht_sichtbar(): + fake = FakeKategorisierer( + lambda posten: [ + {"id": p["id"], "gruppe": "Sonstiges", "unsicher": True} for p in posten + ] + ) + kat = kategorisiere([beispiel_angebot("Pflanzendrink")], fake) + fetch = FetchErgebnis( + ort_plz="60487", + ort_name=None, + angebote=tuple(k.angebot for k in kat), + abgedeckte_quellen=("test",), + ) + text = rendern(fetch, kat) + assert "unsicher" in text diff --git a/tests/test_kategorisierer_anbieter.py b/tests/test_kategorisierer_anbieter.py new file mode 100644 index 0000000..3eaac2f --- /dev/null +++ b/tests/test_kategorisierer_anbieter.py @@ -0,0 +1,144 @@ +"""Anbieter-Factory + OpenRouter-Antwortform -- offline, ohne Netz/Key.""" + +import json + +import pytest + +from angebote.fehler import AbbruchFehler +from angebote.kategorisieren import ( + OpenRouterKategorisierer, + baue_kategorisierer, + kategorisiere, +) +from tests.fakes import beispiel_angebot + + +class _FakeResp: + def __init__(self, payload, status_code=200): + self._p = payload + self.status_code = status_code + self.headers = {} + + def raise_for_status(self): + pass + + def json(self): + return self._p + + +class _FakeSession: + def __init__(self, payload): + self._p = payload + self.calls = [] + + def post(self, url, json=None, headers=None, timeout=None): + self.calls.append({"url": url, "json": json, "headers": headers}) + return _FakeResp(self._p) + + +def _openrouter_payload(zuordnungen): + return { + "choices": [ + { + "message": { + "tool_calls": [ + { + "function": { + "name": "zuordnungen", + "arguments": json.dumps({"zuordnungen": zuordnungen}), + } + } + ] + } + } + ] + } + + +def test_factory_unbekannter_anbieter_bricht_ab(): + with pytest.raises(AbbruchFehler): + baue_kategorisierer("gibtsnicht") + + +def test_factory_openrouter_ohne_key_bricht_ehrlich_ab(monkeypatch): + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + with pytest.raises(AbbruchFehler) as exc: + baue_kategorisierer("openrouter") + assert "OPENROUTER_API_KEY" in exc.value.ursache + + +def test_openrouter_parst_openai_tool_calls(): + angebote = [beispiel_angebot("Apfel"), beispiel_angebot("Bohrmaschine")] + payload = _openrouter_payload( + [ + {"id": angebote[0].angebot_id, "gruppe": "Obst & Gemüse", "unsicher": False}, + { + "id": angebote[1].angebot_id, + "gruppe": "Non-Food (Kleidung, Spielzeug, Garten, Technik)", + "unsicher": False, + }, + ] + ) + sess = _FakeSession(payload) + kat = OpenRouterKategorisierer(api_key="test-key", session=sess) + ergebnis = kategorisiere(angebote, kat) + + gruppen = {k.angebot.titel: k.gruppe for k in ergebnis} + assert gruppen["Apfel"] == "Obst & Gemüse" + assert gruppen["Bohrmaschine"].startswith("Non-Food") + # Request war OpenAI-kompatibel adressiert und authentifiziert: + aufruf = sess.calls[0] + assert aufruf["url"].endswith("/chat/completions") + assert aufruf["json"]["tool_choice"]["function"]["name"] == "zuordnungen" + assert aufruf["headers"]["Authorization"] == "Bearer test-key" + + +def test_openrouter_api_fehler_bricht_ab(): + sess = _FakeSession({"error": {"message": "insufficient_credits"}}) + kat = OpenRouterKategorisierer(api_key="test-key", session=sess) + with pytest.raises(AbbruchFehler): + kat.klassifiziere([{"id": "x", "titel": "Apfel"}]) + + +class _SeqSession: + """Liefert eine Folge vorgegebener Antworten (für Retry-Tests).""" + + def __init__(self, responses): + self._responses = list(responses) + self.calls = 0 + + def post(self, url, json=None, headers=None, timeout=None): + r = self._responses[min(self.calls, len(self._responses) - 1)] + self.calls += 1 + return r + + +def test_openrouter_retry_bei_429_dann_erfolg(): + payload = _openrouter_payload( + [{"id": "x", "gruppe": "Sonstiges", "unsicher": False}] + ) + seq = _SeqSession( + [_FakeResp({}, 429), _FakeResp({}, 429), _FakeResp(payload, 200)] + ) + geschlafen = [] + kat = OpenRouterKategorisierer( + api_key="k", + session=seq, + mindest_abstand_s=0.0, + schlafen=geschlafen.append, + ) + res = kat.klassifiziere([{"id": "x", "titel": "Apfel"}]) + assert res == [{"id": "x", "gruppe": "Sonstiges", "unsicher": False}] + assert seq.calls == 3 + assert len(geschlafen) == 2 # zwei Backoffs vor dem Erfolg, kein echtes Warten + + +def test_openrouter_429_erschoepft_bricht_als_ratelimit_ab(): + seq = _SeqSession([_FakeResp({}, 429)]) + kat = OpenRouterKategorisierer( + api_key="k", session=seq, max_versuche=3, mindest_abstand_s=0.0, schlafen=lambda s: None + ) + with pytest.raises(AbbruchFehler) as exc: + kat.klassifiziere([{"id": "x", "titel": "Apfel"}]) + assert "Rate-Limit" in exc.value.schwelle + assert seq.calls == 3 \ No newline at end of file diff --git a/tests/test_modelle.py b/tests/test_modelle.py new file mode 100644 index 0000000..746b9fb --- /dev/null +++ b/tests/test_modelle.py @@ -0,0 +1,136 @@ +"""Modell-Discovery + interaktive Auswahl -- offline, ohne Netz/Key.""" + +from angebote.modelle import lade_modelle, parse_modelle, suche, top_free +from angebote.modellauswahl import waehle_modell_interaktiv + +FAKE = [ + { + "id": "moonshotai/kimi-k2.6:free", + "name": "Kimi K2", + "context_length": 262144, + "pricing": {"prompt": "0", "completion": "0"}, + "supported_parameters": ["tools", "response_format"], + }, + { + "id": "meta-llama/llama-3.3-70b-instruct:free", + "name": "Llama 3.3 70B", + "context_length": 131072, + "pricing": {"prompt": "0", "completion": "0"}, + "supported_parameters": ["tools"], + }, + { + "id": "some/free-no-tools:free", + "name": "Free ohne Tools", + "context_length": 100000, + "pricing": {"prompt": "0", "completion": "0"}, + "supported_parameters": [], + }, + { + "id": "anthropic/claude-sonnet-4.6", + "name": "Claude Sonnet 4.6", + "context_length": 200000, + "pricing": {"prompt": "3", "completion": "15"}, + "supported_parameters": ["tools"], + }, +] + + +class _Resp: + def __init__(self, payload): + self._p = payload + + def raise_for_status(self): + pass + + def json(self): + return self._p + + +class _Session: + def __init__(self, data): + self._data = data + + def get(self, url, timeout=None): + return _Resp({"data": self._data}) + + +def _scripted(antworten): + it = iter(antworten) + return lambda prompt="": next(it) + + +# --- reine Daten-Logik ------------------------------------------------------- + + +def test_parse_erkennt_frei_und_tools(): + modelle = parse_modelle(FAKE) + nach_id = {m.id: m for m in modelle} + assert nach_id["moonshotai/kimi-k2.6:free"].frei is True + assert nach_id["moonshotai/kimi-k2.6:free"].tools is True + assert nach_id["anthropic/claude-sonnet-4.6"].frei is False + assert nach_id["some/free-no-tools:free"].tools is False + + +def test_top_free_nur_tools_und_rangfolge(): + modelle = parse_modelle(FAKE) + top = top_free(modelle, 5, nur_tools=True) + ids = [m.id for m in top] + # paid (Sonnet) und das tool-lose Free-Modell sind raus: + assert "anthropic/claude-sonnet-4.6" not in ids + assert "some/free-no-tools:free" not in ids + # Präferenz: kimi-k2 vor llama-3.3-70b + assert ids == [ + "moonshotai/kimi-k2.6:free", + "meta-llama/llama-3.3-70b-instruct:free", + ] + + +def test_suche_findet_teilstring(): + modelle = parse_modelle(FAKE) + treffer = suche(modelle, "llama") + assert [m.id for m in treffer] == ["meta-llama/llama-3.3-70b-instruct:free"] + + +def test_lade_modelle_ueber_session(): + modelle = lade_modelle(session=_Session(FAKE)) + assert len(modelle) == 4 + + +# --- interaktiver Picker ----------------------------------------------------- + + +def test_picker_waehlt_per_nummer(): + gewaehlt = waehle_modell_interaktiv( + session=_Session(FAKE), + eingabe=_scripted(["1"]), + ausgabe=lambda s: None, + ) + assert gewaehlt == "moonshotai/kimi-k2.6:free" + + +def test_picker_suche_dann_wahl(): + gewaehlt = waehle_modell_interaktiv( + session=_Session(FAKE), + eingabe=_scripted(["s llama", "1"]), + ausgabe=lambda s: None, + ) + assert gewaehlt == "meta-llama/llama-3.3-70b-instruct:free" + + +def test_picker_warnt_bei_modell_ohne_tools_und_bricht_ab(): + # Suche bringt das tool-lose Modell in die Liste; Wahl -> Warnung -> 'n' -> q. + gewaehlt = waehle_modell_interaktiv( + session=_Session(FAKE), + eingabe=_scripted(["s no-tools", "1", "n", "q"]), + ausgabe=lambda s: None, + ) + assert gewaehlt is None + + +def test_picker_quit_gibt_none(): + gewaehlt = waehle_modell_interaktiv( + session=_Session(FAKE), + eingabe=_scripted(["q"]), + ausgabe=lambda s: None, + ) + assert gewaehlt is None diff --git a/tests/test_schnitt.py b/tests/test_schnitt.py new file mode 100644 index 0000000..144c946 --- /dev/null +++ b/tests/test_schnitt.py @@ -0,0 +1,36 @@ +"""Der Schnitt als Test: im Fetch-Teil darf KEIN LLM geladen werden. + +Geprüft in einem frischen Interpreter: nach dem Import des kompletten +Fetch-Pfads (fetch + marktguru-Adapter) darf 'anthropic' nicht in sys.modules +stehen. So bleibt die Trennung 'kein LLM im Fetch-Teil' eine prüfbare Bedingung. +""" + +import subprocess +import sys +from pathlib import Path + +SRC = Path(__file__).resolve().parents[1] / "src" + + +def test_fetch_teil_laedt_kein_anthropic(): + code = ( + "import sys; " + "import angebote.fetch; " + "import angebote.quellen.marktguru; " + "assert 'anthropic' not in sys.modules, " + "'Fetch-Teil hat anthropic geladen -- Schnitt verletzt'; " + "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_marktguru_quelltext_nennt_anthropic_nicht(): + quelltext = (SRC / "angebote" / "quellen" / "marktguru.py").read_text("utf-8") + assert "anthropic" not in quelltext.lower() diff --git a/tests/test_web.py b/tests/test_web.py new file mode 100644 index 0000000..167ddab --- /dev/null +++ b/tests/test_web.py @@ -0,0 +1,78 @@ +"""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. +""" + +import pytest + +from angebote.modell import FetchErgebnis, KategorisiertesAngebot +from angebote.uebersicht import als_struktur +from tests.fakes import beispiel_angebot + +fastapi = pytest.importorskip("fastapi") +from fastapi.testclient import TestClient # noqa: E402 + +import angebote.web as web # noqa: E402 +from angebote.modelle import ModellInfo # noqa: E402 + + +def _fetch_mit(angebote): + return FetchErgebnis( + ort_plz="60487", + ort_name=None, + angebote=tuple(angebote), + abgedeckte_quellen=("marktguru",), + gesehene_haendler=tuple(sorted({a.haendler for a in angebote})), + hinweise=("marktguru: Teilabdeckung",), + ) + + +def test_als_struktur_behaelt_belegte_felder_und_leere_gruppen(): + a = beispiel_angebot("Butter", preis=1.49, haendler="REWE") + kat = [KategorisiertesAngebot(a, "Molkereiprodukte & Eier", unsicher=False)] + struktur = als_struktur(_fetch_mit([a]), kat) + + assert struktur["anzahl"] == 1 + # alle 13 Produktgruppen vorhanden, leere als leere Liste (kein Weglassen): + assert len(struktur["gruppen"]) == 13 + molk = next(g for g in struktur["gruppen"] if g["name"].startswith("Molkerei")) + assert molk["angebote"][0]["preis"] == 1.49 + assert molk["angebote"][0]["haendler"] == "REWE" + leere = [g for g in struktur["gruppen"] if g["anzahl"] == 0] + assert leere # leere Gruppen bleiben erhalten + + +def test_index_liefert_html(): + client = TestClient(web.app) + r = client.get("/") + assert r.status_code == 200 + assert "Angebots-Übersicht" in r.text + + +def test_api_modelle_gibt_top_free(monkeypatch): + fake = [ + ModellInfo("moonshotai/kimi-k2.6:free", "Kimi", 262144, True, True), + ModellInfo("anthropic/claude-sonnet-4.6", "Sonnet", 200000, False, True), + ] + monkeypatch.setattr(web, "_jobs", {}) # sauberer Zustand + monkeypatch.setattr("angebote.modelle.lade_modelle", lambda session=None: fake) + client = TestClient(web.app) + r = client.get("/api/modelle") + assert r.status_code == 200 + daten = r.json() + assert daten and daten[0]["id"] == "moonshotai/kimi-k2.6:free" + 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