Compare commits
17 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75b4c3b97d | ||
|
|
4b833f3785 | ||
|
|
aed664dc16 | ||
|
|
4ba5f65f39 | ||
|
|
20012a7d46 | ||
|
|
b63dad74a0 | ||
|
|
25bb790517 | ||
|
|
e1c7afef7e | ||
|
|
077a877480 | ||
|
|
2029eb9fcf | ||
|
|
aa60331f7f | ||
|
|
62f5af533e | ||
|
|
2ffb89a6d2 | ||
|
|
2e80f0d826 | ||
|
|
59d7d916ef | ||
|
|
1ded067928 | ||
|
|
11f1444599 |
27 changed files with 2766 additions and 313 deletions
30
.github/workflows/tests.yml
vendored
Normal file
30
.github/workflows/tests.yml
vendored
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
name: tests
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Python 3.12 einrichten
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: pip
|
||||
|
||||
- name: Abhängigkeiten installieren
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
|
||||
- name: Playwright-Browser (für den E2E-Test)
|
||||
run: python -m playwright install --with-deps chromium
|
||||
|
||||
- name: Testsuite (Architektur-Regeln, Cache, Web, E2E)
|
||||
env:
|
||||
PYTHONPATH: src
|
||||
run: python -m pytest -q
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -12,3 +12,7 @@ __pycache__/
|
|||
# Original-Input-Archive (Specs liegen kanonisch in .claude/skills/)
|
||||
/files.zip
|
||||
/temp/
|
||||
|
||||
# gespeicherte Rohdaten (Stufe 1, pro PLZ/Woche generiert)
|
||||
/src/data/
|
||||
/data/
|
||||
|
|
|
|||
16
CLAUDE.md
16
CLAUDE.md
|
|
@ -58,6 +58,22 @@ Diese drei Punkte sind der Grund, warum "die KI soll sagen, wenn sie scheitert"
|
|||
hier funktioniert: nicht weil das Modell Einsicht hätte, sondern weil eine
|
||||
externe, prüfbare Bedingung es erzwingt.
|
||||
|
||||
## Sichtbarkeit der LLM-Arbeit (Grundregel)
|
||||
|
||||
So wie das System **sichtbar abbricht**, wenn es scheitert, muss es auch
|
||||
**sichtbar zeigen**, wenn es arbeitet. Jede LLM-Aktion (die Kategorisierung)
|
||||
wird in der Oberfläche als *laufende Aktivität* dargestellt: erkennbarer
|
||||
Fortschritt (z. B. „LLM kategorisiert … Batch X/Y"), ein animierter Indikator,
|
||||
und das auslösende Bedien-Element im Lade-Zustand. Eine LLM-Aktion darf **nie**
|
||||
wie ein eingefrorenes, totes UI aussehen.
|
||||
|
||||
Der Grund ist derselbe wie beim Abbruch: Transparenz. Der Nutzer muss jederzeit
|
||||
zwischen „arbeitet gerade" und „hängt / ist fertig" unterscheiden können -- sonst
|
||||
wirkt selbst ein korrekt laufender Prozess wie ein Defekt. Das gilt für die
|
||||
Web-UI (animierter Status + Fortschrittsbalken) ebenso wie für die CLI (laufende
|
||||
Status-/Batch-Zeile). Deterministische Schritte (Fetch) dürfen still sein; die
|
||||
LLM-Schritte nicht.
|
||||
|
||||
## Skills
|
||||
|
||||
Das Projekt nutzt zwei Skills (in `.claude/skills/`), die exakt dem Schnitt
|
||||
|
|
|
|||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2026 Jeuners
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
228
README.md
228
README.md
|
|
@ -1,32 +1,116 @@
|
|||
# Angebots-Übersicht
|
||||
# MABOTO — Marktbeobachtungstool
|
||||
|
||||
[](https://github.com/Jeuners/timopro/actions/workflows/tests.yml)
|
||||
|
||||
Ortskonkrete, händlerübergreifende Übersicht wöchentlicher Supermarkt-Angebote,
|
||||
geordnet nach Produktgruppen. Neutral, nicht als Hochglanzprospekt.
|
||||
|
||||
## Der Gedanke
|
||||
**Live-Demo:** <https://dev.dillenberg.net/angebote/>
|
||||
|
||||
Die Aufgabe sieht nach einer KI-Aufgabe aus, ist es aber nur zur Hälfte. Sie
|
||||
---
|
||||
|
||||
## Worum es hier eigentlich geht
|
||||
|
||||
Dieses Repo ist mehr als ein Angebots-Tool. Es ist eine **Referenz dafür, wie
|
||||
man KI *richtig* einsetzt** -- nicht, indem man die ganze Aufgabe einem Modell
|
||||
übergibt, sondern indem man genau trennt, was deterministisch gehört und was
|
||||
nur ein LLM kann. Wer die fünf Punkte unten versteht, hat das Wesentliche.
|
||||
|
||||
### 1. Der Schnitt: Vor KI muss man erst KI einsparen
|
||||
|
||||
Die Aufgabe *sieht* nach einer KI-Aufgabe aus. Sie ist es nur zur Hälfte. Sie
|
||||
zerfällt in zwei strikt getrennte Teile:
|
||||
|
||||
1. **Daten holen** -- deterministisch, ohne LLM. (Skill `angebote-fetch`)
|
||||
2. **Kategorisieren** -- die echte Ambiguität, hier gehört das LLM hin.
|
||||
(Skill `angebote-kategorisieren`)
|
||||
| Teil | Wesen | Werkzeug |
|
||||
|---|---|---|
|
||||
| **Daten holen** (Preise, Gültigkeiten, Händler an Ort X) | reproduzierbar, exakt | **kein LLM** -- HTTP + Parser |
|
||||
| **Kategorisieren** (ist Toffifee Süßware? gehört eine Blühpflanze in eine Lebensmittelliste?) | echte Ambiguität | **LLM** -- und nur hier |
|
||||
|
||||
Wer alles dem Modell gibt, bekommt erfundene Preise. Wer alles deterministisch
|
||||
löst, scheitert an der Einordnung. Die Architektur erzwingt den Schnitt.
|
||||
> Wer alles dem Modell gibt, bekommt erfundene Preise. Wer alles deterministisch
|
||||
> löst, scheitert an der Einordnung. Richtig ist die Arbeitsteilung.
|
||||
|
||||
Der zweite Punkt, der dieses Projekt trägt: Qualitätsregeln stehen nicht als
|
||||
gut gemeinte Bitten im Code, sondern als prüfbare Bedingungen -- kein
|
||||
Auffüllen, nur Belegtes, Abbruch statt stiller Drift. Genau dadurch "sagt das
|
||||
System, wenn es scheitert": nicht aus Einsicht des Modells, sondern weil eine
|
||||
externe Bedingung es erzwingt.
|
||||
Im Code ist dieser Schnitt nicht verhandelbar: Der Fetch-Teil enthält **keinen
|
||||
einzigen LLM-Aufruf** -- ein Test (`tests/`) prüft genau das.
|
||||
|
||||
## Entwicklung mit Claude Code
|
||||
### 2. Architektur statt Präferenz: Qualität als prüfbare Bedingung
|
||||
|
||||
`CLAUDE.md` ist der verbindliche Leitfaden. Die beiden `SKILL.md` in
|
||||
`.claude/skills/` sind die Spezifikationen der zwei Teile. Claude Code liest
|
||||
sie automatisch -- beim Arbeiten am jeweiligen Teil zuerst die passende
|
||||
SKILL.md lesen.
|
||||
Eine Regel, die nur als höfliche Bitte im Prompt steht („möglichst keine
|
||||
erfundenen Preise"), bricht unter Druck still weg. Eine Regel, die als
|
||||
**prüfbare Bedingung im Datenfluss** steht, hält. Drei solcher Regeln sind hier
|
||||
festverdrahtet -- und durch Tests abgesichert:
|
||||
|
||||
- **Kein Auffüllen.** Keine belegten Angebote für eine Gruppe → „keine Daten",
|
||||
niemals ein plausibel klingendes erfundenes Beispiel.
|
||||
- **Nur Belegtes.** Preis, Gültigkeit, Händler stammen aus dem Datensatz, nicht
|
||||
aus dem Modell. Fehlendes Feld = als fehlend markiert, nicht geraten.
|
||||
- **Abbruch statt stiller Drift.** Gibt die Datenlage die Anforderung nicht her
|
||||
(kein Treffer für den Ort), bricht das Programm mit klarer Ursache + Vorschlag
|
||||
ab -- statt ein „irgendwie vollständig aussehendes" Ergebnis zu liefern.
|
||||
|
||||
Deshalb „sagt das System, wenn es scheitert" -- nicht aus Einsicht des Modells,
|
||||
sondern weil eine externe Bedingung es erzwingt.
|
||||
|
||||
### 3. Der Cache macht ein Mini-Model möglich
|
||||
|
||||
Jedes vom LLM (oder per Hand) eingeordnete Produkt landet in einem schnellen
|
||||
SQLite-Cache (`titel+marke → gruppe`, modell-agnostischer Schlüssel). Beim
|
||||
nächsten Lauf überspringen bekannte Produkte das LLM komplett. In der Praxis
|
||||
sinkt die LLM-Last über die Wochen drastisch -- bis ein kleines, billiges
|
||||
Modell (oder gar keins) für die wenigen Neuzugänge reicht. **KI sparsam machen,
|
||||
indem man sie ihre eigene Arbeit zwischenspeichern lässt.**
|
||||
|
||||
### 4. Ehrlich über Grenzen
|
||||
|
||||
- Lokale Modelle (3-9 B über Ollama) liefern für diese Batch-Tool-Calling-Aufgabe
|
||||
oft *kein* zuverlässiges Ergebnis. Das System rät dann nicht -- es markiert
|
||||
alles als „Sonstiges/unsicher". Der Mangel ist sichtbar, nicht kaschiert.
|
||||
- Discounter-Abdeckung wird **datengetrieben** ausgewiesen (beobachtete Händler),
|
||||
nicht behauptet. Die pauschale Annahme „Aldi/Lidl fehlen bei Aggregatoren"
|
||||
wurde von den echten Daten widerlegt -- also steht sie auch nicht im Code.
|
||||
|
||||
### 5. Mensch im Loop + sichtbare KI-Arbeit
|
||||
|
||||
- Unsichere (oder falsch eingeordnete) Angebote sind per Klick korrigierbar; die
|
||||
Korrektur fließt als **stärkstes Cache-Signal** (`modell="manuell"`) zurück --
|
||||
das Produkt ist danach nie wieder unsicher.
|
||||
- Jede LLM-Aktion zeigt **sichtbar Aktivität** (animierter Indikator, Fortschritt,
|
||||
Modellname, Button-Ladezustand). Das ist als Grundregel in `CLAUDE.md`
|
||||
verankert: deterministische Schritte dürfen still laufen, LLM-Schritte nicht.
|
||||
|
||||
---
|
||||
|
||||
## So sieht es aus
|
||||
|
||||
Zweistufige UI -- Rohdaten holen (deterministisch), dann kategorisieren (LLM).
|
||||
Heller Modus (Start-Screen):
|
||||
|
||||

|
||||
|
||||
Die nach Produktgruppen gruppierte Ergebnisansicht nach Stufe 2 -- hier im
|
||||
Dunkelmodus (die UI folgt automatisch dem System-Theme):
|
||||
|
||||

|
||||
|
||||
**Barrierefrei & adaptiv.** Hell/Dunkel adaptiv (`prefers-color-scheme`),
|
||||
volle Tastaturbedienung mit sichtbarem Fokus, `aria-live` für den
|
||||
LLM-Fortschritt. Die Farbpaletten beider Themes sind auf **WCAG-AAA-Kontrast
|
||||
(≥7:1)** ausgelegt -- automatisiert mit `axe-core` geprüft: 0 Verletzungen,
|
||||
inklusive der verschärften AAA-Regel `color-contrast-enhanced`.
|
||||
|
||||
---
|
||||
|
||||
## Die Datenquelle (ehrlich)
|
||||
|
||||
Der erste echte Adapter spricht die öffentliche Angebots-API von **marktguru**
|
||||
an. Das ist ein **Bildungs-/Recherche-Projekt**, kein Produkt: Der Abruf
|
||||
respektiert `robots.txt`, drosselt sich und bricht bei fehlender Erlaubnis
|
||||
sauber ab (Regel 4), statt blind weiterzulaufen. Die Quellen-Schicht ist hinter
|
||||
einer Adapter-Schnittstelle (`quellen/basis.py`) gekapselt -- ein anderer
|
||||
Anbieter mit ausdrücklicher API-Erlaubnis lässt sich ohne Eingriff in den Rest
|
||||
ergänzen. Wer das Projekt produktiv nutzen will, klärt die Nutzungsbedingungen
|
||||
der jeweiligen Quelle selbst.
|
||||
|
||||
---
|
||||
|
||||
## Setup
|
||||
|
||||
|
|
@ -35,7 +119,7 @@ python3 -m venv .venv && source .venv/bin/activate
|
|||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
## Nutzung
|
||||
## Nutzung (CLI)
|
||||
|
||||
```bash
|
||||
# PLZ direkt (immer verlässlich):
|
||||
|
|
@ -49,35 +133,67 @@ python -m angebote "Frankfurt"
|
|||
python -m angebote 60487 --no-llm
|
||||
```
|
||||
|
||||
Der Kategorisier-Schritt braucht `ANTHROPIC_API_KEY` in der Umgebung. Fehlt er
|
||||
und es wird kein `--no-llm` gesetzt, bricht das Programm ehrlich ab, statt
|
||||
ungeordnet weiterzulaufen.
|
||||
Der Kategorisier-Schritt braucht einen LLM-Zugang in der Umgebung --
|
||||
`OPENROUTER_API_KEY` (empfohlen, viele Modelle) **oder** `ANTHROPIC_API_KEY`.
|
||||
Fehlt beides und es wird kein `--no-llm` gesetzt, bricht das Programm ehrlich
|
||||
ab. Modell überschreiben mit `--modell`, Anbieter erzwingen mit
|
||||
`--anbieter openrouter|anthropic`. Modelle auflisten:
|
||||
`python -m angebote --modelle [suchbegriff]`.
|
||||
|
||||
## Stand der Implementierung (ehrlich)
|
||||
## Web-UI
|
||||
|
||||
- `requirements.txt` -- **vorhanden**.
|
||||
- `src/angebote/` -- **vorhanden**: Datenmodell, Adapter-Schnittstelle,
|
||||
Fetch-Orchestrator, Kategorisier-Schritt, Übersicht-Renderer, CLI.
|
||||
- `tests/` -- **vorhanden**: Architektur-Tests (kein Auffüllen, Abbruch bei
|
||||
leerem/unauflösbarem Ort, Daten-Integrität nach Kategorisierung,
|
||||
geschlossene Kategorienliste, Unsicherheits-Flag, Schnitt-Test "kein LLM im
|
||||
Fetch-Teil"). Laufen offline, ohne Netz und ohne LLM.
|
||||
- **Live bestätigt:** der marktguru-Adapter wurde gegen die echte API getestet
|
||||
(PLZ 60487) und liefert reale, belegte Angebote (u. a. ALDI SÜD, PENNY, Lidl,
|
||||
REWE, Kaufland, nahkauf). Erkenntnisse aus dem echten Lauf, die direkt in den
|
||||
Code geflossen sind:
|
||||
- `offers/search` ist query-orientiert -- leeres `q` liefert 0 Treffer. Der
|
||||
Adapter aggregiert daher über Kategorie-Seedbegriffe (config) und weist
|
||||
diese **Teilabdeckung** ehrlich aus, statt Vollständigkeit zu behaupten.
|
||||
- Die pauschale Annahme "Aldi/Lidl fehlen bei Aggregatoren" wurde von den
|
||||
Daten **widerlegt** (beide sind enthalten). Die Abdeckung wird deshalb
|
||||
**datengetrieben** ausgewiesen (beobachtete Händler), nicht hartkodiert.
|
||||
- marktgurus Marken-Sentinel `thisisnobrand123` wird als "keine Marke"
|
||||
behandelt, nicht als Beleg durchgereicht.
|
||||
- **Voraussetzungen für den Live-Lauf:** installiertes `requests` (certifi für
|
||||
TLS), erreichbares Netz, von der Seite lesbare API-Schlüssel. Fehlt eines
|
||||
davon, ist das der vorgesehene Abbruchfall (Regel 4) mit *zutreffend*
|
||||
benannter Ursache -- keine Krücke.
|
||||
Lokale FastAPI-App -- dünne Schicht über denselben Modulen, der Schnitt bleibt
|
||||
gewahrt. Sie macht den **zweistufigen Ablauf** sichtbar und erzwingt seine
|
||||
Reihenfolge:
|
||||
|
||||
1. **Stufe 1 -- Rohdaten holen & speichern** (deterministisch, kein LLM, kein
|
||||
Key): Abruf für eine PLZ, Persistenz pro PLZ/Woche unter `data/roh/`.
|
||||
2. **LLM-Konfiguration** -- Panel mit **Anbieter-Umschalter**:
|
||||
- **OpenRouter** (Cloud): Key + Modellauswahl (Liste/Suche/Aktualisieren).
|
||||
Default `deepseek/deepseek-v4-flash` -- günstig (~1-2 Cent/Lauf), verlässlich.
|
||||
- **Ollama** (lokal): zeigt die lokal installierten Modelle (kein Key, kein
|
||||
Netz) -- mit ehrlichem Hinweis zur Tool-Calling-Grenze kleiner Modelle.
|
||||
|
||||
Anbieter + Modell werden gemerkt (`localStorage`) und überleben einen Reload;
|
||||
das gewählte Modell ist dauerhaft sichtbar.
|
||||
3. **Stufe 2 -- Kategorisieren** (LLM): läuft **nur auf den gespeicherten
|
||||
Rohdaten** und ist gesperrt, solange keine vorliegen. Ergebnis ist die nach
|
||||
Produktgruppen gruppierte Übersicht mit Filtern, Unsicherheits-Markierung,
|
||||
Korrektur-Button und belegter Quelle je Angebot.
|
||||
|
||||
Starten:
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt # enthält fastapi + uvicorn
|
||||
cd src
|
||||
OPENROUTER_API_KEY=… PYTHONPATH=. uvicorn angebote.web:app --port 8077
|
||||
# Browser: http://127.0.0.1:8077/
|
||||
```
|
||||
|
||||
Die App ist als **Single-User-Werkzeug für localhost** gedacht -- nicht mit
|
||||
`--host 0.0.0.0` ungeschützt ins Netz stellen (keine Auth auf den Endpoints).
|
||||
Die öffentliche Demo läuft hinter einem Reverse-Proxy als Schaufenster.
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
python -m pytest -q
|
||||
```
|
||||
|
||||
Die Suite prüft **die Architektur-Regeln**, nicht nur den Happy Path:
|
||||
kein Auffüllen, Abbruch bei leerem/unauflösbarem Ort, Daten-Integrität nach der
|
||||
Kategorisierung, geschlossene Kategorienliste, Unsicherheits-Flag, der
|
||||
Schnitt-Test „kein LLM im Fetch-Teil", Cache-Verhalten (bekannte Produkte
|
||||
überspringen das LLM), die Korrektur-zu-Cache-Schleife, Modell-Discovery
|
||||
(OpenRouter + Ollama), Anbieter-/Retry-Logik und die Web-Endpoints. Alles läuft
|
||||
offline (Fakes); ein E2E-Test (Playwright) prüft die Konfig-Persistenz im Browser.
|
||||
CI führt die volle Suite bei jedem Push aus (Badge oben).
|
||||
|
||||
## Entwicklung mit Claude Code
|
||||
|
||||
`CLAUDE.md` ist der verbindliche Leitfaden. Die beiden `SKILL.md` in
|
||||
`.claude/skills/` sind die Spezifikationen der zwei Teile -- beim Arbeiten am
|
||||
jeweiligen Teil zuerst die passende SKILL.md lesen, den Schnitt nie vermischen.
|
||||
|
||||
## Struktur
|
||||
|
||||
|
|
@ -85,6 +201,8 @@ ungeordnet weiterzulaufen.
|
|||
CLAUDE.md Leitfaden / Architektur
|
||||
README.md dieses Dokument
|
||||
requirements.txt Abhängigkeiten
|
||||
.github/workflows/tests.yml CI: volle Testsuite bei jedem Push
|
||||
docs/ Screenshots für dieses Dokument
|
||||
.claude/skills/angebote-fetch/ Spec: deterministischer Datenabruf
|
||||
.claude/skills/angebote-kategorisieren/ Spec: LLM-gestützte Einordnung
|
||||
src/angebote/ Implementierung
|
||||
|
|
@ -95,8 +213,20 @@ src/angebote/ Implementierung
|
|||
basis.py Adapter-Schnittstelle + Ort
|
||||
marktguru.py erster echter Adapter, geprüfter Ortsbezug
|
||||
fetch.py Orchestrator (Ort rein, belegte Angebote raus)
|
||||
kategorisieren.py LLM-Schritt hinter einer Schnittstelle (offline testbar)
|
||||
uebersicht.py Gruppierung + Rendering
|
||||
speicher.py Persistenz der belegten Rohdaten (Stufe 1), kein LLM
|
||||
kategorisieren.py LLM-Schritt hinter Protokoll (OpenRouter/Anthropic), testbar
|
||||
produktcache.py schneller SQLite-Cache (titel+marke -> gruppe), kein LLM
|
||||
modelle.py OpenRouter-Modell-Discovery (Liste/Suche/Top-Free)
|
||||
modellauswahl.py interaktive Modellauswahl (CLI)
|
||||
uebersicht.py Gruppierung + Rendering (Markdown + JSON-Struktur)
|
||||
web.py FastAPI-Web-UI (Stufe-1-/Stufe-2-/Korrektur-Endpoints)
|
||||
web_static/ Frontend (index.html)
|
||||
cli.py / __main__.py CLI-Einstieg
|
||||
tests/ Architektur-Tests
|
||||
tests/ Architektur-/Cache-/Web-/E2E-Tests
|
||||
data/roh/ generierte Rohdaten (ge-ignored)
|
||||
data/kategorie_cache.sqlite Produkt->Kategorie-Cache (ge-ignored)
|
||||
```
|
||||
|
||||
## Lizenz
|
||||
|
||||
[MIT](LICENSE).
|
||||
|
|
|
|||
BIN
docs/ui-ergebnis.png
Normal file
BIN
docs/ui-ergebnis.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 410 KiB |
BIN
docs/ui-stufen.png
Normal file
BIN
docs/ui-stufen.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 324 KiB |
|
|
@ -12,4 +12,5 @@ uvicorn>=0.29
|
|||
|
||||
# Entwicklung/Tests:
|
||||
pytest>=8.0
|
||||
httpx>=0.27 # für fastapi.testclient
|
||||
httpx>=0.27 # für fastapi.testclient
|
||||
pytest-playwright>=0.5 # E2E-UI-Tests (danach: `playwright install chromium`)
|
||||
|
|
|
|||
|
|
@ -55,6 +55,11 @@ def main(argv: list[str] | None = None) -> int:
|
|||
action="store_true",
|
||||
help="vor dem Lauf das OpenRouter-Modell interaktiv wählen (Liste/Suche/Update)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-cache",
|
||||
action="store_true",
|
||||
help="den Produkt->Kategorie-Cache nicht nutzen (alles neu kategorisieren)",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
# Reiner Listen-/Suchmodus -- braucht keinen Ort und keinen Key.
|
||||
|
|
@ -102,12 +107,28 @@ def main(argv: list[str] | None = None) -> int:
|
|||
from .kategorisieren import baue_kategorisierer, kategorisiere
|
||||
from .uebersicht import rendern
|
||||
|
||||
cache = None
|
||||
if not args.no_cache:
|
||||
from .produktcache import ProduktCache
|
||||
|
||||
cache = ProduktCache()
|
||||
stat: dict = {}
|
||||
try:
|
||||
kat = kategorisiere(list(fetch.angebote), baue_kategorisierer(anbieter, modell))
|
||||
kat = kategorisiere(
|
||||
list(fetch.angebote),
|
||||
baue_kategorisierer(anbieter, modell),
|
||||
cache=cache,
|
||||
statistik=stat,
|
||||
)
|
||||
except AbbruchFehler as e:
|
||||
print(e.als_text(), file=sys.stderr)
|
||||
return 2
|
||||
|
||||
if cache is not None:
|
||||
print(
|
||||
f"({stat.get('aus_cache', 0)} aus Cache · {stat.get('neu', 0)} neu kategorisiert)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(rendern(fetch, kat))
|
||||
return 0
|
||||
|
||||
|
|
|
|||
|
|
@ -39,40 +39,116 @@ def kategorisiere(
|
|||
*,
|
||||
batch_groesse: int = 25,
|
||||
fortschritt=None,
|
||||
parallel: int = 8,
|
||||
cache=None,
|
||||
statistik: dict | None = None,
|
||||
) -> list[KategorisiertesAngebot]:
|
||||
"""Ordnet jedes Angebot einer Produktgruppe zu, ohne Daten zu verändern.
|
||||
|
||||
`fortschritt`: optionaler Callback (erledigte_batches, gesamt_batches) --
|
||||
für Live-Anzeigen (z. B. die Web-UI). Ändert die Logik nicht.
|
||||
`fortschritt`: optionaler Callback (erledigte_batches, gesamt_batches).
|
||||
`parallel`: bis zu so viele Batches gleichzeitig (LLM-Calls sind I/O-bound).
|
||||
`cache`: optionaler ProduktCache. Bekannte Produkte werden per Lookup direkt
|
||||
übernommen (kein LLM); nur unbekannte gehen ans Modell, dedupliziert
|
||||
(ein Produkt = ein Posten). Neue SICHERE Zuordnungen werden zurück-
|
||||
geschrieben. Default None -> heutiges Verhalten, unverändert.
|
||||
`statistik`: optionales dict, wird mit {aus_cache, neu} befüllt.
|
||||
Die Zuordnung ist id-basiert, also reihenfolge- (und parallel-)unabhängig.
|
||||
"""
|
||||
import threading
|
||||
|
||||
original = {a.angebot_id: a for a in angebote}
|
||||
ergebnis: dict[str, KategorisiertesAngebot] = {}
|
||||
lock = threading.Lock()
|
||||
|
||||
import math
|
||||
# --- Cache-Phase: bekannte Produkte übernehmen, Rest deduplizieren -------
|
||||
aids_von_schluessel: dict[str, list[str]] = {}
|
||||
schluessel_von_repr: dict[str, str] = {} # Repräsentant-id -> Produkt-Schlüssel
|
||||
neu_fuer_cache: dict[str, str] = {}
|
||||
aus_cache = 0
|
||||
modell_name = getattr(kategorisierer, "_modell", None)
|
||||
|
||||
gesamt_batches = max(1, math.ceil(len(angebote) / batch_groesse))
|
||||
for nr, start in enumerate(range(0, len(angebote), batch_groesse), 1):
|
||||
batch = angebote[start : start + batch_groesse]
|
||||
if cache is not None:
|
||||
from .produktcache import produkt_schluessel
|
||||
|
||||
repraesentant: dict[str, Angebot] = {}
|
||||
for a in angebote:
|
||||
s = produkt_schluessel(a.titel, a.marke)
|
||||
gruppe = cache.hole(s)
|
||||
if gruppe is not None:
|
||||
ergebnis[a.angebot_id] = KategorisiertesAngebot(
|
||||
angebot=a, gruppe=gruppe, unsicher=False
|
||||
)
|
||||
aus_cache += 1
|
||||
else:
|
||||
aids_von_schluessel.setdefault(s, []).append(a.angebot_id)
|
||||
if s not in repraesentant:
|
||||
repraesentant[s] = a
|
||||
schluessel_von_repr[a.angebot_id] = s
|
||||
zu_kategorisieren = list(repraesentant.values())
|
||||
else:
|
||||
zu_kategorisieren = angebote
|
||||
|
||||
batches = [
|
||||
zu_kategorisieren[start : start + batch_groesse]
|
||||
for start in range(0, len(zu_kategorisieren), batch_groesse)
|
||||
]
|
||||
gesamt_batches = max(1, len(batches))
|
||||
erledigt = [0]
|
||||
|
||||
def verarbeite(batch):
|
||||
posten = [
|
||||
{
|
||||
"id": a.angebot_id,
|
||||
"titel": a.titel,
|
||||
"marke": a.marke,
|
||||
"menge": a.menge,
|
||||
}
|
||||
{"id": a.angebot_id, "titel": a.titel, "marke": a.marke, "menge": a.menge}
|
||||
for a in batch
|
||||
]
|
||||
antworten = kategorisierer.klassifiziere(posten)
|
||||
for ant in antworten:
|
||||
aid = ant.get("id")
|
||||
if aid not in original or aid in ergebnis:
|
||||
continue # fremde/duplizierte ID ignorieren
|
||||
gruppe, unsicher = _bereinige_gruppe(ant.get("gruppe"), ant.get("unsicher"))
|
||||
ergebnis[aid] = KategorisiertesAngebot(
|
||||
angebot=original[aid], gruppe=gruppe, unsicher=unsicher
|
||||
)
|
||||
if fortschritt is not None:
|
||||
fortschritt(nr, gesamt_batches)
|
||||
return kategorisierer.klassifiziere(posten)
|
||||
|
||||
def setze(aid, gruppe, unsicher):
|
||||
if aid not in original or aid in ergebnis:
|
||||
return
|
||||
ergebnis[aid] = KategorisiertesAngebot(
|
||||
angebot=original[aid], gruppe=gruppe, unsicher=unsicher
|
||||
)
|
||||
|
||||
def uebernehmen(antworten):
|
||||
with lock:
|
||||
for ant in antworten:
|
||||
aid = ant.get("id")
|
||||
gruppe, unsicher = _bereinige_gruppe(
|
||||
ant.get("gruppe"), ant.get("unsicher")
|
||||
)
|
||||
schluessel = schluessel_von_repr.get(aid)
|
||||
if schluessel is not None:
|
||||
# Repräsentant -> Ergebnis auf ALLE Angebote desselben Produkts
|
||||
for ziel in aids_von_schluessel[schluessel]:
|
||||
setze(ziel, gruppe, unsicher)
|
||||
if not unsicher:
|
||||
neu_fuer_cache[schluessel] = gruppe
|
||||
else:
|
||||
setze(aid, gruppe, unsicher)
|
||||
erledigt[0] += 1
|
||||
if fortschritt is not None:
|
||||
fortschritt(erledigt[0], gesamt_batches)
|
||||
|
||||
if parallel > 1 and len(batches) > 1:
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
with ThreadPoolExecutor(max_workers=parallel) as ex:
|
||||
futures = [ex.submit(verarbeite, b) for b in batches]
|
||||
for fut in as_completed(futures):
|
||||
uebernehmen(fut.result()) # AbbruchFehler propagiert bewusst
|
||||
else:
|
||||
for batch in batches:
|
||||
uebernehmen(verarbeite(batch))
|
||||
|
||||
# Write-Back: neue, SICHERE Zuordnungen in den Cache (ein executemany).
|
||||
if cache is not None and neu_fuer_cache:
|
||||
cache.schreibe_viele(
|
||||
[(s, g, modell_name) for s, g in neu_fuer_cache.items()]
|
||||
)
|
||||
|
||||
if statistik is not None:
|
||||
statistik["aus_cache"] = aus_cache
|
||||
statistik["neu"] = len(zu_kategorisieren)
|
||||
|
||||
# Posten, die das Modell nicht (gültig) beantwortet hat: ehrlich als
|
||||
# unsicher mit Fallback markieren -- nicht still einsortieren.
|
||||
|
|
@ -140,6 +216,45 @@ def _user_text(posten: list[dict]) -> str:
|
|||
)
|
||||
|
||||
|
||||
def _finde_zuordnungen(obj):
|
||||
"""Sucht rekursiv ein 'zuordnungen'-Array in einem geparsten JSON-Objekt."""
|
||||
if isinstance(obj, dict):
|
||||
if isinstance(obj.get("zuordnungen"), list):
|
||||
return obj["zuordnungen"]
|
||||
for v in obj.values():
|
||||
treffer = _finde_zuordnungen(v)
|
||||
if treffer:
|
||||
return treffer
|
||||
elif isinstance(obj, list):
|
||||
for v in obj:
|
||||
treffer = _finde_zuordnungen(v)
|
||||
if treffer:
|
||||
return treffer
|
||||
return None
|
||||
|
||||
|
||||
def _zuordnungen_aus_content(content) -> list | None:
|
||||
"""Extrahiert ein 'zuordnungen'-Array aus einem content-String (Fallback).
|
||||
|
||||
Manche lokale Modelle verpacken die Tool-Antwort als (ggf. in ```json
|
||||
eingefasstes) JSON im Text statt als echte tool_calls. Wir lesen das defensiv
|
||||
aus -- finden wir nichts Verwertbares, geben wir None zurück (kein Raten).
|
||||
"""
|
||||
if not isinstance(content, str) or "{" not in content:
|
||||
return None
|
||||
import re
|
||||
|
||||
for roh in re.findall(r"\{.*\}", content, re.DOTALL):
|
||||
try:
|
||||
obj = json.loads(roh)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
treffer = _finde_zuordnungen(obj)
|
||||
if treffer:
|
||||
return treffer
|
||||
return None
|
||||
|
||||
|
||||
def _wartezeit(antwort, versuch: int, basis: float) -> float:
|
||||
"""Backoff: Retry-After respektieren, sonst exponentiell ab max(basis, 2s)."""
|
||||
try:
|
||||
|
|
@ -319,18 +434,58 @@ class OpenRouterKategorisierer:
|
|||
vorschlag="Key/Modell/Guthaben prüfen oder --no-llm verwenden",
|
||||
)
|
||||
try:
|
||||
aufrufe = daten["choices"][0]["message"]["tool_calls"]
|
||||
args = json.loads(aufrufe[0]["function"]["arguments"])
|
||||
return list(args.get("zuordnungen", []))
|
||||
except (KeyError, IndexError, TypeError, json.JSONDecodeError):
|
||||
# Modell hat das Tool nicht (sauber) aufgerufen -> leer; die
|
||||
# kategorisiere()-Logik markiert die Posten dann als unsicher.
|
||||
nachricht = daten["choices"][0]["message"]
|
||||
except (KeyError, IndexError, TypeError):
|
||||
return []
|
||||
# 1) Sauberer Tool-Call (OpenRouter / gute Modelle).
|
||||
try:
|
||||
args = json.loads(nachricht["tool_calls"][0]["function"]["arguments"])
|
||||
z = args.get("zuordnungen")
|
||||
if z is not None:
|
||||
return list(z)
|
||||
except (KeyError, IndexError, TypeError, json.JSONDecodeError):
|
||||
pass
|
||||
# 2) Fallback: manche (lokale Ollama-) Modelle schreiben die Tool-Antwort
|
||||
# als content-JSON statt als tool_calls. Defensiv auslesen, nicht raten.
|
||||
z = _zuordnungen_aus_content(nachricht.get("content"))
|
||||
return z if z else []
|
||||
|
||||
|
||||
class OllamaKategorisierer(OpenRouterKategorisierer):
|
||||
"""Lokaler LLM über Ollama (OpenAI-kompatibler Endpoint /v1/chat/completions).
|
||||
|
||||
Erbt die komplette Tool-Calling-Logik vom OpenRouter-Kategorisierer -- Ollama
|
||||
spricht dasselbe OpenAI-Schema. Kein Key (der Dummy-Bearer wird ignoriert),
|
||||
kein Netz, keine Drosselung. Nur tool-fähige lokale Modelle (z. B. qwen3.5,
|
||||
qwen2.5-coder, gemma4) eignen sich; andere liefern keine sauberen tool_calls.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
modell: str,
|
||||
base_url: str = "http://localhost:11434/v1",
|
||||
session=None,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
api_key="ollama", # Dummy -- der lokale Server ignoriert den Bearer.
|
||||
modell=modell,
|
||||
base_url=base_url,
|
||||
session=session,
|
||||
mindest_abstand_s=0.0, # lokal: keine Rate-Limits
|
||||
)
|
||||
|
||||
|
||||
# Default je Anbieter. Für OpenRouter ein günstiges, aber verlässliches paid-
|
||||
# Modell: deepseek-v4-flash macht sauberes Tool-Calling, ist über mehrere
|
||||
# Batches konsistent und kostet nur ~1-2 Cent pro vollem Lauf (1903 Angebote).
|
||||
# Free-Modelle sind bei OpenRouter oft hart gedrosselt. Für Ollama ein gängiges
|
||||
# tool-fähiges lokales Modell als Fallback. Mit `--modell` (CLI) bzw. der
|
||||
# Modellauswahl in der Web-UI lässt sich jederzeit ein anderes wählen.
|
||||
_DEFAULT_MODELLE = {
|
||||
"anthropic": "claude-sonnet-4-6",
|
||||
"openrouter": "anthropic/claude-sonnet-4.6",
|
||||
"openrouter": "deepseek/deepseek-v4-flash",
|
||||
"ollama": "qwen3.5:latest",
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -345,6 +500,8 @@ def baue_kategorisierer(
|
|||
modell = modell or _DEFAULT_MODELLE.get(anbieter)
|
||||
if anbieter == "openrouter":
|
||||
return OpenRouterKategorisierer(modell=modell, api_key=api_key)
|
||||
if anbieter == "ollama":
|
||||
return OllamaKategorisierer(modell=modell)
|
||||
if anbieter == "anthropic":
|
||||
return AnthropicKategorisierer(modell=modell)
|
||||
raise AbbruchFehler(
|
||||
|
|
|
|||
|
|
@ -113,3 +113,55 @@ def suche(
|
|||
if nur_tools:
|
||||
res = [m for m in res if m.tools]
|
||||
return sorted(res, key=_rang)
|
||||
|
||||
|
||||
# --- Ollama (lokale LLMs) ----------------------------------------------------
|
||||
|
||||
OLLAMA_BASIS = "http://localhost:11434"
|
||||
|
||||
|
||||
def _ollama_kann_tools(sess, name: str) -> bool:
|
||||
"""Liest die Capabilities eines lokalen Modells (/api/show) -- 'tools'?"""
|
||||
try:
|
||||
r = sess.post(OLLAMA_BASIS + "/api/show", json={"model": name}, timeout=8)
|
||||
r.raise_for_status()
|
||||
return "tools" in (r.json().get("capabilities") or [])
|
||||
except Exception:
|
||||
return False # nicht belegbar -> konservativ als nicht tool-fähig
|
||||
|
||||
|
||||
def lade_ollama_modelle(session=None) -> list[ModellInfo]:
|
||||
"""Lokal installierte Ollama-Modelle (/api/tags), mit Tool-Fähigkeit.
|
||||
|
||||
`frei=True` (lokal = ohne Kosten). `tools` aus /api/show -- nur tool-fähige
|
||||
Modelle taugen für die Kategorisierung. Läuft Ollama nicht, kommt eine LEERE
|
||||
Liste zurück (kein Fehler -- der Anbieter ist optional), nicht geraten.
|
||||
"""
|
||||
sess = session
|
||||
if sess is None:
|
||||
import requests
|
||||
|
||||
sess = requests
|
||||
try:
|
||||
r = sess.get(OLLAMA_BASIS + "/api/tags", timeout=5)
|
||||
r.raise_for_status()
|
||||
eintraege = r.json().get("models", [])
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
out: list[ModellInfo] = []
|
||||
for m in eintraege:
|
||||
name = m.get("name") or m.get("model") or ""
|
||||
if not name:
|
||||
continue
|
||||
out.append(
|
||||
ModellInfo(
|
||||
id=name,
|
||||
name=name,
|
||||
context=None,
|
||||
frei=True,
|
||||
tools=_ollama_kann_tools(sess, name),
|
||||
)
|
||||
)
|
||||
# tool-fähige zuerst, dann alphabetisch -- die brauchbaren oben.
|
||||
return sorted(out, key=lambda mi: (not mi.tools, mi.id))
|
||||
|
|
|
|||
129
src/angebote/produktcache.py
Normal file
129
src/angebote/produktcache.py
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
"""Produkt→Kategorie-Cache -- Teil des Kategorisier-Teils (Stufe 2).
|
||||
|
||||
Speichert pro PRODUKT-Identität (Titel + Marke, NICHT pro Angebot/Preis) die
|
||||
einmal vom LLM ermittelte Produktgruppe. So muss ein bekanntes Produkt nicht
|
||||
erneut eingeordnet werden -- über Zeit sinkt die LLM-Last drastisch (nur noch
|
||||
*neue* Produkte gehen ans Modell), und ein günstiges Mini-Model genügt.
|
||||
|
||||
Schnitt-konform:
|
||||
* Es werden ausschließlich Werte gespeichert, die vom LLM stammen (Gruppe) --
|
||||
NIE Angebotsdaten (Preis/Händler/Gültigkeit). Der Cache erinnert nur an eine
|
||||
bereits getroffene Einordnung; er repariert keine Daten.
|
||||
* Er importiert KEIN LLM-Modul und keinen Kategorisier-Code (nur `config`).
|
||||
* Geschlossene Liste: Gelesen UND geschrieben werden nur Gruppen aus
|
||||
PRODUKTGRUPPEN. Eine off-list-Zeile (z. B. manipulierte DB oder geänderte
|
||||
Liste) wird beim Lesen verworfen -- selbstheilend, kein TTL nötig.
|
||||
* Nur SICHERE Zuordnungen werden abgelegt (siehe kategorisieren.py) -- so ist
|
||||
jeder Cache-Treffer per Konstruktion sicher.
|
||||
|
||||
Der Cache ist global (ort-/wochenübergreifend): Die Kategorie eines Produkts ist
|
||||
eine objektive Eigenschaft, unabhängig von PLZ, Woche oder Modell.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from .config import PRODUKTGRUPPEN
|
||||
|
||||
# Globaler Ablageort, neben dem Roh-Cache. `/data/` ist bereits in .gitignore.
|
||||
STANDARD_DB = Path("data/kategorie_cache.sqlite")
|
||||
|
||||
_WS = re.compile(r"\s+")
|
||||
|
||||
|
||||
def _norm(text: str | None) -> str:
|
||||
return _WS.sub(" ", (text or "").strip().lower())
|
||||
|
||||
|
||||
def produkt_schluessel(titel: str, marke: str | None) -> str:
|
||||
"""Stabile Produkt-Identität aus Titel + Marke (mengen-invariant).
|
||||
|
||||
Menge bleibt bewusst draußen: 'Butter 250 g' und 'Butter 500 g' sind dieselbe
|
||||
Produktgruppe -- das maximiert die Trefferquote.
|
||||
"""
|
||||
roh = _norm(titel) + "|" + _norm(marke)
|
||||
return hashlib.sha1(roh.encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CacheEintrag:
|
||||
schluessel: str
|
||||
gruppe: str
|
||||
modell: str | None
|
||||
gesehen_am: str # ISO
|
||||
|
||||
|
||||
class ProduktCache:
|
||||
"""Schneller, persistenter Produkt→Gruppe-Cache (SQLite + In-Memory-dict).
|
||||
|
||||
Beim ersten Zugriff wird die ganze (kleine) Tabelle einmalig in ein dict
|
||||
geladen -> Lookups sind O(1) im RAM, kein SQL pro Posten. Geschrieben wird
|
||||
gebündelt (`schreibe_viele`) in einer Transaktion.
|
||||
"""
|
||||
|
||||
def __init__(self, *, db_pfad: Path | str | None = None) -> None:
|
||||
self._pfad = Path(db_pfad) if db_pfad else STANDARD_DB
|
||||
self._mem: dict[str, str] | None = None # lazy
|
||||
self._init_db()
|
||||
|
||||
# -- intern -----------------------------------------------------------
|
||||
|
||||
def _verbinde(self):
|
||||
import sqlite3
|
||||
|
||||
self._pfad.parent.mkdir(parents=True, exist_ok=True)
|
||||
return sqlite3.connect(str(self._pfad))
|
||||
|
||||
def _init_db(self) -> None:
|
||||
with self._verbinde() as con:
|
||||
con.execute(
|
||||
"CREATE TABLE IF NOT EXISTS produkt_kategorie ("
|
||||
" schluessel TEXT PRIMARY KEY,"
|
||||
" gruppe TEXT NOT NULL,"
|
||||
" modell TEXT,"
|
||||
" gesehen_am TEXT"
|
||||
")"
|
||||
)
|
||||
|
||||
def _lade(self) -> dict[str, str]:
|
||||
if self._mem is None:
|
||||
with self._verbinde() as con:
|
||||
rows = con.execute(
|
||||
"SELECT schluessel, gruppe FROM produkt_kategorie"
|
||||
).fetchall()
|
||||
# Whitelist auch beim Laden: nur gültige Gruppen in den Speicher.
|
||||
self._mem = {k: g for k, g in rows if g in PRODUKTGRUPPEN}
|
||||
return self._mem
|
||||
|
||||
# -- öffentliche API --------------------------------------------------
|
||||
|
||||
def hole(self, schluessel: str) -> str | None:
|
||||
"""Gruppe für ein Produkt -- nur wenn sie in der geschlossenen Liste ist."""
|
||||
gruppe = self._lade().get(schluessel)
|
||||
return gruppe if gruppe in PRODUKTGRUPPEN else None
|
||||
|
||||
def schreibe_viele(self, eintraege: list[tuple[str, str, str | None]]) -> int:
|
||||
"""Speichert (schluessel, gruppe, modell)-Tupel. Nur Gruppen aus der
|
||||
geschlossenen Liste werden übernommen. Gibt die Zahl der Schreibungen."""
|
||||
gueltig = [(s, g, m) for (s, g, m) in eintraege if g in PRODUKTGRUPPEN and s]
|
||||
if not gueltig:
|
||||
return 0
|
||||
jetzt = datetime.now().isoformat()
|
||||
with self._verbinde() as con:
|
||||
con.executemany(
|
||||
"INSERT OR REPLACE INTO produkt_kategorie "
|
||||
"(schluessel, gruppe, modell, gesehen_am) VALUES (?, ?, ?, ?)",
|
||||
[(s, g, m, jetzt) for (s, g, m) in gueltig],
|
||||
)
|
||||
mem = self._lade()
|
||||
for s, g, _ in gueltig:
|
||||
mem[s] = g
|
||||
return len(gueltig)
|
||||
|
||||
def groesse(self) -> int:
|
||||
return len(self._lade())
|
||||
181
src/angebote/speicher.py
Normal file
181
src/angebote/speicher.py
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
"""Persistenz der belegten Rohdaten (Stufe 1) -- deterministisch, KEIN LLM.
|
||||
|
||||
Trennt die zwei Stufen des Workflows auch auf der Platte:
|
||||
|
||||
* Stufe 1 (Fetch) schreibt die belegten, normalisierten Angebote pro PLZ und
|
||||
Kalenderwoche hierher. Nichts wird interpretiert oder kategorisiert.
|
||||
* Stufe 2 (Kategorisieren) liest sie von hier -- sie fetcht NICHT erneut.
|
||||
|
||||
Bewusst rohes JSON der belegten Felder: Was die Quelle nicht hergibt, bleibt
|
||||
`null` (kein Auffüllen). Die Datei belegt zusätzlich Herkunft (Quellen, geholt
|
||||
am, gesehene Händler), damit der gespeicherte Stand selbst auditierbar ist.
|
||||
|
||||
Der Round-Trip ist verlustfrei für die belegten Felder: `lade_rohdaten`
|
||||
rekonstruiert echte `Angebot`-Objekte (frozen), sodass Stufe 2 exakt mit dem
|
||||
arbeitet, was Stufe 1 belegt hat.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from .modell import Angebot, FetchErgebnis
|
||||
|
||||
# Standard-Ablageort. Relativ zum Arbeitsverzeichnis des Servers (src/), damit
|
||||
# der Pfad neben dem bestehenden Fetch-Cache (.cache/) liegt und nicht ins
|
||||
# Paket eingreift. Über `basis_dir` injizierbar (Tests).
|
||||
STANDARD_BASIS = Path("data/roh")
|
||||
|
||||
|
||||
def _iso(d) -> str | None:
|
||||
return d.isoformat() if d is not None else None
|
||||
|
||||
|
||||
def _dat(s) -> date | None:
|
||||
return date.fromisoformat(s) if s else None
|
||||
|
||||
|
||||
def _angebot_dict(a: Angebot) -> dict:
|
||||
"""Serialisiert ein belegtes Angebot vollständig -- inkl. stabiler ID."""
|
||||
return {
|
||||
"titel": a.titel,
|
||||
"haendler": a.haendler,
|
||||
"quelle": a.quelle,
|
||||
"abgerufen_am": a.abgerufen_am.isoformat(),
|
||||
"marke": a.marke,
|
||||
"preis": a.preis,
|
||||
"grundpreis": a.grundpreis,
|
||||
"menge": a.menge,
|
||||
"gueltig_von": _iso(a.gueltig_von),
|
||||
"gueltig_bis": _iso(a.gueltig_bis),
|
||||
"angebot_id": a.angebot_id,
|
||||
}
|
||||
|
||||
|
||||
def _angebot_aus_dict(d: dict) -> Angebot:
|
||||
"""Rekonstruiert ein belegtes Angebot. Fehlende Felder bleiben fehlend."""
|
||||
return Angebot(
|
||||
titel=d["titel"],
|
||||
haendler=d["haendler"],
|
||||
quelle=d["quelle"],
|
||||
abgerufen_am=datetime.fromisoformat(d["abgerufen_am"]),
|
||||
marke=d.get("marke"),
|
||||
preis=d.get("preis"),
|
||||
grundpreis=d.get("grundpreis"),
|
||||
menge=d.get("menge"),
|
||||
gueltig_von=_dat(d.get("gueltig_von")),
|
||||
gueltig_bis=_dat(d.get("gueltig_bis")),
|
||||
angebot_id=d.get("angebot_id") or "",
|
||||
)
|
||||
|
||||
|
||||
def pfad_fuer(plz: str, *, basis_dir: Path | str | None = None, jetzt: datetime | None = None) -> Path:
|
||||
"""Dateipfad pro PLZ/Kalenderwoche: data/roh/{plz}_{jahr}-W{woche}.json.
|
||||
|
||||
Gleiche Wochen-Logik wie der marktguru-Cache -- so gehört der Roh-Stand
|
||||
erkennbar zur selben Woche wie die zugrundeliegende Quelle.
|
||||
"""
|
||||
basis = Path(basis_dir) if basis_dir else STANDARD_BASIS
|
||||
jetzt = jetzt or datetime.now()
|
||||
jahr, woche, _ = jetzt.isocalendar()
|
||||
return basis / f"{plz}_{jahr}-W{woche:02d}.json"
|
||||
|
||||
|
||||
def speichere_rohdaten(
|
||||
fetch: FetchErgebnis, *, basis_dir: Path | str | None = None, jetzt: datetime | None = None
|
||||
) -> Path:
|
||||
"""Persistiert das belegte Fetch-Ergebnis. Gibt den Schreibpfad zurück.
|
||||
|
||||
KEIN LLM, kein Auffüllen: es wird exakt das geschrieben, was der Fetch belegt
|
||||
hat. `abgerufen_am` der Meta ist der Schreibzeitpunkt; die einzelnen Angebote
|
||||
tragen ihren eigenen, von der Quelle belegten Abrufzeitpunkt.
|
||||
"""
|
||||
import json
|
||||
|
||||
jetzt = jetzt or datetime.now()
|
||||
pfad = pfad_fuer(fetch.ort_plz, basis_dir=basis_dir, jetzt=jetzt)
|
||||
pfad.parent.mkdir(parents=True, exist_ok=True)
|
||||
inhalt = {
|
||||
"ort_plz": fetch.ort_plz,
|
||||
"ort_name": fetch.ort_name,
|
||||
"abgerufen_am": jetzt.isoformat(),
|
||||
"abgedeckte_quellen": list(fetch.abgedeckte_quellen),
|
||||
"gesehene_haendler": list(fetch.gesehene_haendler),
|
||||
"hinweise": list(fetch.hinweise),
|
||||
"angebote": [_angebot_dict(a) for a in fetch.angebote],
|
||||
}
|
||||
pfad.write_text(json.dumps(inhalt, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
return pfad
|
||||
|
||||
|
||||
def lade_rohdaten(
|
||||
plz: str, *, basis_dir: Path | str | None = None, jetzt: datetime | None = None
|
||||
) -> FetchErgebnis | None:
|
||||
"""Lädt den gespeicherten Roh-Stand der aktuellen Woche -- oder None.
|
||||
|
||||
None bedeutet: für diese PLZ/Woche liegen keine Rohdaten vor (Stufe 2 ist
|
||||
dann gesperrt). Es wird NICHT gefetcht und nichts geraten.
|
||||
"""
|
||||
import json
|
||||
|
||||
pfad = pfad_fuer(plz, basis_dir=basis_dir, jetzt=jetzt)
|
||||
if not pfad.exists():
|
||||
return None
|
||||
d = json.loads(pfad.read_text(encoding="utf-8"))
|
||||
angebote = tuple(_angebot_aus_dict(a) for a in d.get("angebote", []))
|
||||
return FetchErgebnis(
|
||||
ort_plz=d["ort_plz"],
|
||||
ort_name=d.get("ort_name"),
|
||||
angebote=angebote,
|
||||
abgedeckte_quellen=tuple(d.get("abgedeckte_quellen", ())),
|
||||
gesehene_haendler=tuple(d.get("gesehene_haendler", ())),
|
||||
hinweise=tuple(d.get("hinweise", ())),
|
||||
)
|
||||
|
||||
|
||||
def meta_fuer(
|
||||
plz: str, *, basis_dir: Path | str | None = None, jetzt: datetime | None = None
|
||||
) -> dict | None:
|
||||
"""Kurz-Zusammenfassung des Roh-Stands (für die UI), ohne die volle Liste.
|
||||
|
||||
Gibt None zurück, wenn keine Rohdaten vorliegen.
|
||||
"""
|
||||
import json
|
||||
|
||||
pfad = pfad_fuer(plz, basis_dir=basis_dir, jetzt=jetzt)
|
||||
if not pfad.exists():
|
||||
return None
|
||||
d = json.loads(pfad.read_text(encoding="utf-8"))
|
||||
return {
|
||||
"ort_plz": d["ort_plz"],
|
||||
"ort_name": d.get("ort_name"),
|
||||
"abgerufen_am": d.get("abgerufen_am"),
|
||||
"anzahl": len(d.get("angebote", [])),
|
||||
"haendler": list(d.get("gesehene_haendler", [])),
|
||||
"quellen": list(d.get("abgedeckte_quellen", [])),
|
||||
"hinweise": list(d.get("hinweise", [])),
|
||||
}
|
||||
|
||||
|
||||
def rohliste_dicts(fetch: FetchErgebnis) -> list[dict]:
|
||||
"""Belegte Rohliste als JSON-fähige dicts -- für die UI-Anzeige von Stufe 1.
|
||||
|
||||
Bewusst OHNE Produktgruppe: Stufe 1 kategorisiert nicht.
|
||||
"""
|
||||
out: list[dict] = []
|
||||
for a in fetch.angebote:
|
||||
out.append(
|
||||
{
|
||||
"titel": a.titel,
|
||||
"marke": a.marke,
|
||||
"preis": a.preis,
|
||||
"grundpreis": a.grundpreis,
|
||||
"menge": a.menge,
|
||||
"haendler": a.haendler,
|
||||
"gueltig_von": _iso(a.gueltig_von),
|
||||
"gueltig_bis": _iso(a.gueltig_bis),
|
||||
"quelle": a.quelle,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
|
@ -44,16 +44,28 @@ def _angebot_dict(ka: KategorisiertesAngebot) -> dict:
|
|||
def als_struktur(
|
||||
fetch: FetchErgebnis,
|
||||
kategorisiert: list[KategorisiertesAngebot],
|
||||
*,
|
||||
modell: str | None = None,
|
||||
anbieter: str | None = None,
|
||||
aus_cache: int | None = None,
|
||||
neu: int | None = None,
|
||||
) -> dict:
|
||||
"""Strukturierte Ausgabe für die Web-UI -- dieselben belegten Felder wie der
|
||||
Markdown-Renderer, nur als JSON-fähiges dict. Leere Gruppen bleiben enthalten
|
||||
(als leere Liste) -- kein Weglassen, kein Auffüllen."""
|
||||
(als leere Liste) -- kein Weglassen, kein Auffüllen.
|
||||
|
||||
`modell`/`anbieter` belegen, WOMIT kategorisiert wurde -- damit in der UI
|
||||
nachvollziehbar bleibt, welches LLM die Einordnung vorgenommen hat."""
|
||||
gruppen = gruppieren(kategorisiert)
|
||||
return {
|
||||
"ort_plz": fetch.ort_plz,
|
||||
"ort_name": fetch.ort_name,
|
||||
"anzahl": len(kategorisiert),
|
||||
"unsicher": sum(1 for k in kategorisiert if k.unsicher),
|
||||
"modell": modell,
|
||||
"anbieter": anbieter,
|
||||
"aus_cache": aus_cache,
|
||||
"neu": neu,
|
||||
"quellen": list(fetch.abgedeckte_quellen),
|
||||
"haendler": list(fetch.gesehene_haendler),
|
||||
"hinweise": list(fetch.hinweise),
|
||||
|
|
|
|||
|
|
@ -1,9 +1,15 @@
|
|||
"""FastAPI-Web-UI -- dünne Schicht über den bestehenden Modulen.
|
||||
|
||||
Der Schnitt bleibt unangetastet: dieser Server ruft `fetch` (deterministisch)
|
||||
und `kategorisieren` (LLM) auf, vermischt aber nichts. Die UI ist reine
|
||||
Präsentation; alle harten Regeln (kein Auffüllen, nur Belegtes, Abbruch statt
|
||||
Drift) leben weiter in den darunterliegenden Modulen.
|
||||
Der Schnitt bleibt unangetastet und ist hier sogar im Endpoint-Schnitt sichtbar:
|
||||
|
||||
* Stufe 1 -- /api/rohdaten -- ruft NUR den deterministischen Fetch und
|
||||
persistiert die belegten Rohdaten. KEIN Key, KEIN LLM.
|
||||
* Stufe 2 -- /api/kategorisieren -- liest die gespeicherten Rohdaten und
|
||||
führt ausschließlich darauf die LLM-Kategorisierung aus. Sie fetcht NICHT
|
||||
erneut und ist gesperrt, solange keine Rohdaten vorliegen.
|
||||
|
||||
Die UI ist reine Präsentation; alle harten Regeln (kein Auffüllen, nur Belegtes,
|
||||
Abbruch statt Drift) leben weiter in den darunterliegenden Modulen.
|
||||
|
||||
Start:
|
||||
OPENROUTER_API_KEY=... uvicorn angebote.web:app --port 8000
|
||||
|
|
@ -17,19 +23,21 @@ import uuid
|
|||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.responses import HTMLResponse, Response
|
||||
|
||||
from .fehler import AbbruchFehler
|
||||
|
||||
app = FastAPI(title="Angebots-Übersicht")
|
||||
app = FastAPI(title="MABOTO — Marktbeobachtungstool")
|
||||
|
||||
_HTML = (Path(__file__).parent / "web_static" / "index.html").read_text("utf-8")
|
||||
_FAVICON = (Path(__file__).parent / "web_static" / "favicon.svg").read_text("utf-8")
|
||||
|
||||
# In-memory Job-Store. Schlicht gehalten -- ein lokales Single-User-Werkzeug.
|
||||
# In-memory Job-Store für Stufe 2 (LLM, läuft im Thread). Schlicht gehalten --
|
||||
# ein lokales Single-User-Werkzeug.
|
||||
_jobs: dict[str, dict] = {}
|
||||
_jobs_lock = threading.Lock()
|
||||
|
||||
# Ergebnis-Cache: identischer Lauf (PLZ, Modell, no_llm) kommt sofort, ohne
|
||||
# Ergebnis-Cache: identische Kategorisierung (PLZ, Modell) kommt sofort, ohne
|
||||
# erneute LLM-Calls. Im Geist des Projekt-Cachings. In-memory, pro Serverlauf.
|
||||
_ergebnis_cache: dict[tuple, dict] = {}
|
||||
|
||||
|
|
@ -39,33 +47,157 @@ def index() -> str:
|
|||
return _HTML
|
||||
|
||||
|
||||
@app.get("/favicon.svg")
|
||||
def favicon() -> Response:
|
||||
return Response(content=_FAVICON, media_type="image/svg+xml",
|
||||
headers={"Cache-Control": "public, max-age=86400"})
|
||||
|
||||
|
||||
@app.get("/api/modelle")
|
||||
def api_modelle(q: str = "") -> list[dict]:
|
||||
"""Modell-Liste für das Dropdown: Suche oder Top-Free. 'Aktualisieren' = neu rufen."""
|
||||
"""Modell-Liste fürs Dropdown. Ohne Suche: das EMPFOHLENE Default-Modell
|
||||
(günstig, nicht gedrosselt) zuerst, dann die Top-Free.
|
||||
|
||||
Hintergrund: Free-Modelle sind bei OpenRouter hart gedrosselt (429). Würde
|
||||
die UI eines davon vorwählen, läuft der erste Lauf ins Rate-Limit. Deshalb
|
||||
steht der Default (deepseek-v4-flash) vorne und wird vorgewählt.
|
||||
"""
|
||||
from .kategorisieren import _DEFAULT_MODELLE
|
||||
from .modelle import lade_modelle, suche, top_free
|
||||
|
||||
try:
|
||||
alle = lade_modelle()
|
||||
except Exception as e: # Netz/SSL -> ehrlich melden, nicht raten
|
||||
raise HTTPException(status_code=502, detail=f"Modelle nicht abrufbar: {e}")
|
||||
treffer = suche(alle, q) if q else top_free(alle, 8)
|
||||
|
||||
default_id = _DEFAULT_MODELLE.get("openrouter")
|
||||
if q:
|
||||
treffer = suche(alle, q)
|
||||
else:
|
||||
default_mi = next((m for m in alle if m.id == default_id), None)
|
||||
top = [m for m in top_free(alle, 8) if m.id != default_id]
|
||||
treffer = ([default_mi] if default_mi else []) + top
|
||||
return [
|
||||
{"id": m.id, "frei": m.frei, "tools": m.tools, "context": m.context}
|
||||
{
|
||||
"id": m.id, "frei": m.frei, "tools": m.tools, "context": m.context,
|
||||
"empfohlen": m.id == default_id,
|
||||
}
|
||||
for m in treffer[:25]
|
||||
]
|
||||
|
||||
|
||||
@app.post("/api/lauf")
|
||||
def api_lauf(req: dict) -> dict:
|
||||
@app.get("/api/ollama-modelle")
|
||||
def api_ollama_modelle() -> list[dict]:
|
||||
"""Lokal installierte Ollama-Modelle. Leere Liste, wenn Ollama nicht läuft."""
|
||||
from .modelle import lade_ollama_modelle
|
||||
|
||||
return [
|
||||
{"id": m.id, "frei": m.frei, "tools": m.tools, "context": m.context}
|
||||
for m in lade_ollama_modelle()
|
||||
]
|
||||
|
||||
|
||||
# === Stufe 1: Rohdaten holen & speichern (deterministisch, ohne Key) =========
|
||||
|
||||
|
||||
@app.post("/api/rohdaten")
|
||||
def api_rohdaten_holen(req: dict) -> dict:
|
||||
"""Holt belegte Rohdaten (Fetch) und persistiert sie pro PLZ/Woche.
|
||||
|
||||
KEIN LLM, KEIN Key. Bei Spezifitätsmangel / Datenlage-Bruch wird der
|
||||
AbbruchFehler ehrlich als 422 mit Ursache/Vorschlag weitergereicht --
|
||||
es wird nichts aufgefüllt.
|
||||
"""
|
||||
plz = (req.get("plz") or "").strip()
|
||||
if not plz:
|
||||
raise HTTPException(status_code=400, detail="PLZ fehlt")
|
||||
|
||||
from .fetch import hole_angebote
|
||||
from .speicher import meta_fuer, rohliste_dicts, speichere_rohdaten
|
||||
|
||||
try:
|
||||
fetch = hole_angebote(plz) # deterministisch; AbbruchFehler bei Regel 4
|
||||
except AbbruchFehler as e:
|
||||
raise HTTPException(status_code=422, detail=e.als_text())
|
||||
except Exception as e: # nichts verstecken -- ehrliche Fehlermeldung
|
||||
raise HTTPException(status_code=502, detail=f"Unerwarteter Fehler: {e}")
|
||||
|
||||
speichere_rohdaten(fetch)
|
||||
# Frisch kategorisierte Ergebnisse dieser PLZ verwerfen -- Roh-Stand neu.
|
||||
for schluessel in [k for k in _ergebnis_cache if k[0] == plz]:
|
||||
_ergebnis_cache.pop(schluessel, None)
|
||||
|
||||
meta = meta_fuer(plz) or {}
|
||||
return {
|
||||
"plz": plz,
|
||||
"ort_name": meta.get("ort_name"),
|
||||
"anzahl": meta.get("anzahl", len(fetch.angebote)),
|
||||
"haendler": meta.get("haendler", list(fetch.gesehene_haendler)),
|
||||
"quellen": meta.get("quellen", list(fetch.abgedeckte_quellen)),
|
||||
"abgerufen_am": meta.get("abgerufen_am"),
|
||||
"hinweise": meta.get("hinweise", list(fetch.hinweise)),
|
||||
"angebote": rohliste_dicts(fetch),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/rohdaten/{plz}")
|
||||
def api_rohdaten_laden(plz: str) -> dict:
|
||||
"""Liefert den gespeicherten Roh-Stand der aktuellen Woche oder 404."""
|
||||
from .speicher import lade_rohdaten, meta_fuer, rohliste_dicts
|
||||
|
||||
meta = meta_fuer(plz)
|
||||
if meta is None:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"keine gespeicherten Rohdaten für PLZ {plz}"
|
||||
)
|
||||
fetch = lade_rohdaten(plz)
|
||||
return {
|
||||
"plz": plz,
|
||||
"ort_name": meta.get("ort_name"),
|
||||
"anzahl": meta.get("anzahl", 0),
|
||||
"haendler": meta.get("haendler", []),
|
||||
"quellen": meta.get("quellen", []),
|
||||
"abgerufen_am": meta.get("abgerufen_am"),
|
||||
"hinweise": meta.get("hinweise", []),
|
||||
"angebote": rohliste_dicts(fetch) if fetch else [],
|
||||
}
|
||||
|
||||
|
||||
# === Stufe 2: Kategorisieren (LLM) -- erst NACH vorhandenen Rohdaten =========
|
||||
|
||||
|
||||
@app.post("/api/kategorisieren")
|
||||
def api_kategorisieren(req: dict) -> dict:
|
||||
"""Startet die LLM-Kategorisierung auf den GESPEICHERTEN Rohdaten.
|
||||
|
||||
Gesperrt (400), solange keine Rohdaten zur PLZ vorliegen. Liest sie aus der
|
||||
Persistenz -- fetcht NICHT erneut. Status-Polling über /api/lauf/{job_id}.
|
||||
"""
|
||||
plz = (req.get("plz") or "").strip()
|
||||
if not plz:
|
||||
raise HTTPException(status_code=400, detail="PLZ fehlt")
|
||||
|
||||
from .speicher import lade_rohdaten
|
||||
|
||||
fetch = lade_rohdaten(plz)
|
||||
if fetch is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
f"Keine Rohdaten für PLZ {plz}. Zuerst Stufe 1 'Rohdaten holen' "
|
||||
"ausführen -- die Kategorisierung arbeitet nur auf belegten Daten."
|
||||
),
|
||||
)
|
||||
|
||||
modell = req.get("modell") or None
|
||||
no_llm = bool(req.get("no_llm"))
|
||||
anbieter = req.get("anbieter") or "openrouter"
|
||||
key = req.get("key") or None
|
||||
job_id = uuid.uuid4().hex[:12]
|
||||
|
||||
# Cache-Treffer? Dann sofort als fertiger Job ausliefern, kein neuer Lauf.
|
||||
treffer = _ergebnis_cache.get((plz, modell, no_llm))
|
||||
# Anbieter ist Teil des Schlüssels -- dasselbe Modell-Kürzel kann je Anbieter
|
||||
# etwas anderes bedeuten.
|
||||
treffer = _ergebnis_cache.get((plz, anbieter, modell))
|
||||
if treffer is not None:
|
||||
with _jobs_lock:
|
||||
_jobs[job_id] = {
|
||||
|
|
@ -77,22 +209,15 @@ def api_lauf(req: dict) -> dict:
|
|||
with _jobs_lock:
|
||||
_jobs[job_id] = {
|
||||
"status": "laufend",
|
||||
"phase": "fetch",
|
||||
"phase": "kategorisieren",
|
||||
"done": 0,
|
||||
"total": 0,
|
||||
"ergebnis": None,
|
||||
"fehler": None,
|
||||
}
|
||||
t = threading.Thread(
|
||||
target=_run_job,
|
||||
args=(
|
||||
job_id,
|
||||
plz,
|
||||
modell,
|
||||
req.get("anbieter") or "openrouter",
|
||||
no_llm,
|
||||
req.get("key") or None,
|
||||
),
|
||||
target=_run_kategorisieren,
|
||||
args=(job_id, plz, fetch, modell, anbieter, key),
|
||||
daemon=True,
|
||||
)
|
||||
t.start()
|
||||
|
|
@ -107,41 +232,125 @@ def api_status(job_id: str) -> dict:
|
|||
return job
|
||||
|
||||
|
||||
def _run_job(job_id, plz, modell, anbieter, no_llm, key) -> None:
|
||||
# === Manuelle Korrektur -> Produkt-Cache (die hochwertigste Cache-Quelle) =====
|
||||
|
||||
|
||||
@app.post("/api/korrektur")
|
||||
def api_korrektur(req: dict) -> dict:
|
||||
"""Setzt die Produktgruppe eines Produkts manuell und dauerhaft.
|
||||
|
||||
Schnitt-konform: betrifft NUR die Kategorie (Cache-Seite), nie Angebotsdaten;
|
||||
ruft KEIN LLM und fetcht nicht. Geschlossene Liste wird erzwungen. Die
|
||||
Zuordnung (modell='manuell') überschreibt im Produkt-Cache jeden früheren
|
||||
LLM-Wert -- dieses Produkt ist damit künftig sicher und LLM-frei.
|
||||
"""
|
||||
from .config import PRODUKTGRUPPEN
|
||||
from .produktcache import ProduktCache, produkt_schluessel
|
||||
|
||||
titel = (req.get("titel") or "").strip()
|
||||
marke = req.get("marke") # darf None/"" sein -- Teil der Produkt-Identität
|
||||
gruppe = (req.get("gruppe") or "").strip()
|
||||
|
||||
if not titel:
|
||||
raise HTTPException(status_code=400, detail="Titel fehlt")
|
||||
if gruppe not in PRODUKTGRUPPEN:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Unbekannte Produktgruppe '{gruppe}' -- nur die geschlossene "
|
||||
"Liste ist erlaubt.",
|
||||
)
|
||||
|
||||
schluessel = produkt_schluessel(titel, marke)
|
||||
geschrieben = ProduktCache().schreibe_viele([(schluessel, gruppe, "manuell")])
|
||||
|
||||
plz = (req.get("plz") or "").strip()
|
||||
if plz:
|
||||
_patche_ergebnis_cache(plz, schluessel, gruppe)
|
||||
|
||||
return {"schluessel": schluessel, "gruppe": gruppe, "gespeichert": bool(geschrieben)}
|
||||
|
||||
|
||||
def _patche_ergebnis_cache(plz: str, schluessel: str, ziel_gruppe: str) -> None:
|
||||
"""Hält die zwischengespeicherte UI-Struktur konsistent mit der Korrektur:
|
||||
hängt alle Angebote mit passendem Produkt-Schlüssel in die Zielgruppe um,
|
||||
setzt unsicher=False und aktualisiert Counts. Verschiebt nur die Kategorie --
|
||||
keine Neukategorisierung, kein Fetch."""
|
||||
from .produktcache import produkt_schluessel
|
||||
|
||||
for key, erg in _ergebnis_cache.items():
|
||||
if key[0] != plz:
|
||||
continue
|
||||
gruppen = erg.get("gruppen", [])
|
||||
zielblock = next((g for g in gruppen if g["name"] == ziel_gruppe), None)
|
||||
if zielblock is None:
|
||||
continue
|
||||
bewegt = []
|
||||
for block in gruppen:
|
||||
bleibt = []
|
||||
for a in block["angebote"]:
|
||||
if produkt_schluessel(a["titel"], a.get("marke")) == schluessel:
|
||||
a["unsicher"] = False
|
||||
bewegt.append(a)
|
||||
else:
|
||||
bleibt.append(a)
|
||||
block["angebote"] = bleibt
|
||||
zielblock["angebote"].extend(bewegt)
|
||||
for g in gruppen:
|
||||
g["anzahl"] = len(g["angebote"])
|
||||
erg["unsicher"] = sum(
|
||||
1 for g in gruppen for a in g["angebote"] if a.get("unsicher")
|
||||
)
|
||||
|
||||
|
||||
def _run_kategorisieren(job_id, plz, fetch, modell, anbieter, key) -> None:
|
||||
"""LLM-Schritt im Hintergrund. Arbeitet auf den geladenen Rohdaten.
|
||||
|
||||
Verändert die Angebotsdaten nicht (sie sind frozen); übernimmt vom Modell
|
||||
nur Gruppe + Unsicherheits-Flag.
|
||||
"""
|
||||
job = _jobs[job_id]
|
||||
try:
|
||||
from .fetch import hole_angebote
|
||||
from .modell import KategorisiertesAngebot
|
||||
from .kategorisieren import baue_kategorisierer, kategorisiere
|
||||
from .produktcache import ProduktCache
|
||||
from .uebersicht import als_struktur
|
||||
|
||||
fetch = hole_angebote(plz) # deterministisch; AbbruchFehler bei Regel 4
|
||||
kt = baue_kategorisierer(anbieter, modell, api_key=key)
|
||||
# Tatsächlich genutztes Modell (Default je Anbieter aufgelöst) -- für die
|
||||
# sichtbare Herkunft im Ergebnis.
|
||||
modell_genutzt = getattr(kt, "_modell", modell)
|
||||
print(
|
||||
f"[Stufe 2] start · PLZ {plz} · {anbieter}/{modell_genutzt} · "
|
||||
f"{len(fetch.angebote)} Angebote",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
if no_llm:
|
||||
# Ohne LLM: belegte Rohliste, sichtbar als unkategorisiert markiert.
|
||||
from .config import FALLBACK_GRUPPE
|
||||
def fort(done, total):
|
||||
job["done"] = done
|
||||
job["total"] = total
|
||||
if done == total or done % 5 == 0: # nicht jede Batch -> Log lesbar
|
||||
print(f"[Stufe 2] PLZ {plz} · Batch {done}/{total}", flush=True)
|
||||
|
||||
kat = [
|
||||
KategorisiertesAngebot(a, FALLBACK_GRUPPE, unsicher=True)
|
||||
for a in fetch.angebote
|
||||
]
|
||||
else:
|
||||
from .kategorisieren import baue_kategorisierer, kategorisiere
|
||||
cache = ProduktCache() # Produkt->Kategorie-Cache: bekannte Produkte ohne LLM
|
||||
stat: dict = {}
|
||||
kat = kategorisiere(
|
||||
list(fetch.angebote), kt, fortschritt=fort, cache=cache, statistik=stat
|
||||
)
|
||||
|
||||
job["phase"] = "kategorisieren"
|
||||
kt = baue_kategorisierer(anbieter, modell, api_key=key)
|
||||
|
||||
def fort(done, total):
|
||||
job["done"] = done
|
||||
job["total"] = total
|
||||
|
||||
kat = kategorisiere(list(fetch.angebote), kt, fortschritt=fort)
|
||||
|
||||
job["ergebnis"] = als_struktur(fetch, kat)
|
||||
job["ergebnis"] = als_struktur(
|
||||
fetch, kat, modell=modell_genutzt, anbieter=anbieter,
|
||||
aus_cache=stat.get("aus_cache"), neu=stat.get("neu"),
|
||||
)
|
||||
job["status"] = "fertig"
|
||||
_ergebnis_cache[(plz, modell, no_llm)] = job["ergebnis"]
|
||||
_ergebnis_cache[(plz, anbieter, modell)] = job["ergebnis"]
|
||||
print(
|
||||
f"[Stufe 2] fertig · PLZ {plz} · {stat.get('aus_cache', 0)} aus Cache · "
|
||||
f"{stat.get('neu', 0)} neu · {job['ergebnis']['unsicher']} unsicher",
|
||||
flush=True,
|
||||
)
|
||||
except AbbruchFehler as e:
|
||||
job["status"] = "fehler"
|
||||
job["fehler"] = e.als_text()
|
||||
print(f"[Stufe 2] ABBRUCH · PLZ {plz} · {e.schwelle}: {e.ursache}", flush=True)
|
||||
except Exception as e: # nichts verstecken -- ehrliche Fehlermeldung
|
||||
job["status"] = "fehler"
|
||||
job["fehler"] = f"Unerwarteter Fehler: {e}"
|
||||
|
|
|
|||
18
src/angebote/web_static/favicon.svg
Normal file
18
src/angebote/web_static/favicon.svg
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<svg viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="MABOTO">
|
||||
<defs>
|
||||
<linearGradient id="mb" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#22936b"/>
|
||||
<stop offset="1" stop-color="#0c5238"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="32" height="32" rx="8" fill="url(#mb)"/>
|
||||
<g transform="translate(3.4,3.4)">
|
||||
<!-- Preis-Balken (Marktdaten) im Sichtfeld -->
|
||||
<rect x="7.0" y="9.6" width="1.8" height="3.8" rx="0.6" fill="#fff"/>
|
||||
<rect x="9.1" y="7.7" width="1.8" height="5.7" rx="0.6" fill="#fff"/>
|
||||
<rect x="11.2" y="5.8" width="1.8" height="7.6" rx="0.6" fill="#fff"/>
|
||||
<!-- Lupe (Beobachtung) -->
|
||||
<circle cx="10" cy="10" r="6.4" fill="none" stroke="#fff" stroke-width="2.0"/>
|
||||
<path d="M14.8 14.8 L20.4 20.4" stroke="#fff" stroke-width="2.7" stroke-linecap="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 875 B |
File diff suppressed because it is too large
Load diff
|
|
@ -37,6 +37,24 @@ class FakeKategorisierer:
|
|||
return self._fn(posten)
|
||||
|
||||
|
||||
class CountingFakeKategorisierer:
|
||||
"""Zählt die ans LLM gegebenen Posten -- für Cache-Tests (Hit/Dedup)."""
|
||||
|
||||
def __init__(self, gruppe: str = "Sonstiges", unsicher: bool = False):
|
||||
self.gesehen = 0
|
||||
self.titel: list[str] = []
|
||||
self._gruppe = gruppe
|
||||
self._unsicher = unsicher
|
||||
|
||||
def klassifiziere(self, posten: list[dict]) -> list[dict]:
|
||||
self.gesehen += len(posten)
|
||||
self.titel.extend(p["titel"] for p in posten)
|
||||
return [
|
||||
{"id": p["id"], "gruppe": self._gruppe, "unsicher": self._unsicher}
|
||||
for p in posten
|
||||
]
|
||||
|
||||
|
||||
def beispiel_angebot(titel="Butter", **kw) -> Angebot:
|
||||
"""Belegtes Angebot mit Default-Pflichtfeldern; einzeln überschreibbar."""
|
||||
daten = dict(
|
||||
|
|
|
|||
64
tests/test_kategorisieren_cache.py
Normal file
64
tests/test_kategorisieren_cache.py
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
"""Cache-Integration in kategorisiere() -- offline, eigene DB pro Test."""
|
||||
|
||||
from angebote.kategorisieren import kategorisiere
|
||||
from angebote.produktcache import ProduktCache
|
||||
from tests.fakes import CountingFakeKategorisierer, beispiel_angebot
|
||||
|
||||
|
||||
def _cache(tmp_path):
|
||||
return ProduktCache(db_pfad=tmp_path / "c.sqlite")
|
||||
|
||||
|
||||
def test_zweiter_lauf_komplett_aus_cache(tmp_path):
|
||||
cache = _cache(tmp_path)
|
||||
angebote = [
|
||||
beispiel_angebot("Butter", marke="Meggle"),
|
||||
beispiel_angebot("Apfel", marke=None),
|
||||
]
|
||||
# 1. Lauf: alles neu ans LLM, wird gecacht
|
||||
fake1 = CountingFakeKategorisierer("Sonstiges", unsicher=False)
|
||||
stat1 = {}
|
||||
kategorisiere(angebote, fake1, cache=cache, statistik=stat1)
|
||||
assert fake1.gesehen == 2
|
||||
assert stat1 == {"aus_cache": 0, "neu": 2}
|
||||
|
||||
# 2. Lauf: nichts mehr ans LLM (alles aus Cache)
|
||||
fake2 = CountingFakeKategorisierer("Sonstiges")
|
||||
stat2 = {}
|
||||
erg = kategorisiere(angebote, fake2, cache=cache, statistik=stat2)
|
||||
assert fake2.gesehen == 0
|
||||
assert stat2 == {"aus_cache": 2, "neu": 0}
|
||||
assert all(k.gruppe == "Sonstiges" and not k.unsicher for k in erg)
|
||||
|
||||
|
||||
def test_dedup_ein_produkt_nur_ein_posten(tmp_path):
|
||||
cache = _cache(tmp_path)
|
||||
# zwei Angebote DESSELBEN Produkts (Titel+Marke), aber versch. Preis/Händler
|
||||
a1 = beispiel_angebot("Butter", marke="Meggle", preis=1.49, haendler="REWE")
|
||||
a2 = beispiel_angebot("Butter", marke="Meggle", preis=1.99, haendler="EDEKA")
|
||||
assert a1.angebot_id != a2.angebot_id
|
||||
fake = CountingFakeKategorisierer("Molkereiprodukte & Eier")
|
||||
erg = kategorisiere([a1, a2], fake, cache=cache)
|
||||
assert fake.gesehen == 1 # nur EIN Posten ans LLM
|
||||
assert len(erg) == 2
|
||||
assert all(k.gruppe == "Molkereiprodukte & Eier" for k in erg)
|
||||
|
||||
|
||||
def test_unsichere_werden_nicht_gecacht(tmp_path):
|
||||
cache = _cache(tmp_path)
|
||||
a = beispiel_angebot("Hafer-Pflanzendrink", marke=None)
|
||||
fake = CountingFakeKategorisierer("Molkereiprodukte & Eier", unsicher=True)
|
||||
kategorisiere([a], fake, cache=cache)
|
||||
assert cache.groesse() == 0 # unsicher -> nicht gespeichert
|
||||
# Folge-Lauf fragt erneut (keine Propagation des Zweifels)
|
||||
fake2 = CountingFakeKategorisierer("Sonstiges")
|
||||
kategorisiere([a], fake2, cache=cache)
|
||||
assert fake2.gesehen == 1
|
||||
|
||||
|
||||
def test_ohne_cache_geht_alles_ans_llm(tmp_path):
|
||||
angebote = [beispiel_angebot("Butter"), beispiel_angebot("Apfel", marke=None)]
|
||||
fake = CountingFakeKategorisierer("Sonstiges")
|
||||
erg = kategorisiere(angebote, fake) # cache=None
|
||||
assert fake.gesehen == 2
|
||||
assert len(erg) == 2
|
||||
|
|
@ -35,6 +35,23 @@ def test_originaldaten_unveraendert_property():
|
|||
assert nachher is vorher
|
||||
|
||||
|
||||
def test_parallel_ordnet_alle_batches_vollstaendig_zu():
|
||||
# 60 Angebote -> 3 Batches -> parallel verarbeitet; nichts darf verloren gehen.
|
||||
angebote = [beispiel_angebot(f"Artikel {i}", preis=float(i)) for i in range(60)]
|
||||
fake = FakeKategorisierer(
|
||||
lambda posten: [
|
||||
{"id": p["id"], "gruppe": "Sonstiges", "unsicher": False} for p in posten
|
||||
]
|
||||
)
|
||||
ergebnis = kategorisiere(angebote, fake, batch_groesse=25, parallel=4)
|
||||
assert len(ergebnis) == 60
|
||||
assert all(k.gruppe == "Sonstiges" and not k.unsicher for k in ergebnis)
|
||||
# jedes Original-Angebot bleibt unverändert erhalten (id-basiert gemappt)
|
||||
ids_in = {a.angebot_id for a in angebote}
|
||||
ids_out = {k.angebot.angebot_id for k in ergebnis}
|
||||
assert ids_in == ids_out
|
||||
|
||||
|
||||
def test_fehlender_preis_bleibt_fehlend():
|
||||
angebot = beispiel_angebot("Hähnchen", preis=None)
|
||||
ergebnis = kategorisiere([angebot], _gib_gruppe("Fleisch & Wurst"))
|
||||
|
|
|
|||
|
|
@ -141,4 +141,63 @@ def test_openrouter_429_erschoepft_bricht_als_ratelimit_ab():
|
|||
with pytest.raises(AbbruchFehler) as exc:
|
||||
kat.klassifiziere([{"id": "x", "titel": "Apfel"}])
|
||||
assert "Rate-Limit" in exc.value.schwelle
|
||||
assert seq.calls == 3
|
||||
assert seq.calls == 3
|
||||
|
||||
|
||||
def test_baue_kategorisierer_ollama_ohne_key():
|
||||
from angebote.kategorisieren import OllamaKategorisierer, baue_kategorisierer
|
||||
|
||||
# Ollama braucht KEINEN Key -- darf also nicht abbrechen.
|
||||
kt = baue_kategorisierer("ollama", "qwen3.5:latest")
|
||||
assert isinstance(kt, OllamaKategorisierer)
|
||||
|
||||
|
||||
def test_ollama_kategorisierer_parst_tool_calls_lokal():
|
||||
from angebote.kategorisieren import OllamaKategorisierer
|
||||
|
||||
a = beispiel_angebot("Apfel")
|
||||
payload = _openrouter_payload(
|
||||
[{"id": a.angebot_id, "gruppe": "Obst & Gemüse", "unsicher": False}]
|
||||
)
|
||||
sess = _FakeSession(payload)
|
||||
kt = OllamaKategorisierer(modell="qwen3.5:latest", session=sess)
|
||||
ergebnis = kategorisiere([a], kt)
|
||||
assert ergebnis[0].gruppe == "Obst & Gemüse"
|
||||
# OpenAI-kompatibel an den lokalen Endpoint adressiert:
|
||||
assert sess.calls[0]["url"].endswith("/chat/completions")
|
||||
assert "localhost:11434" in sess.calls[0]["url"]
|
||||
|
||||
|
||||
def test_content_fallback_extrahiert_zuordnungen():
|
||||
from angebote.kategorisieren import _zuordnungen_aus_content
|
||||
|
||||
# Manche (lokale) Modelle verpacken die Tool-Antwort als content-JSON.
|
||||
c = (
|
||||
'```json\n{"name":"zuordnungen","arguments":{"zuordnungen":'
|
||||
'[{"id":"x","gruppe":"Obst & Gemüse","unsicher":false}]}}\n```'
|
||||
)
|
||||
z = _zuordnungen_aus_content(c)
|
||||
assert z == [{"id": "x", "gruppe": "Obst & Gemüse", "unsicher": False}]
|
||||
|
||||
|
||||
def test_content_fallback_ohne_json_gibt_none():
|
||||
from angebote.kategorisieren import _zuordnungen_aus_content
|
||||
|
||||
assert _zuordnungen_aus_content("nur Text, kein JSON") is None
|
||||
assert _zuordnungen_aus_content(None) is None
|
||||
|
||||
|
||||
def test_kategorisierer_nutzt_content_fallback_ohne_tool_calls():
|
||||
# Antwort OHNE tool_calls, aber mit content-JSON -> Fallback greift.
|
||||
from angebote.kategorisieren import OllamaKategorisierer
|
||||
|
||||
a = beispiel_angebot("Apfel")
|
||||
inhalt = (
|
||||
'{"zuordnungen":[{"id":"' + a.angebot_id
|
||||
+ '","gruppe":"Obst & Gemüse","unsicher":false}]}'
|
||||
)
|
||||
payload = {"choices": [{"message": {"content": inhalt}}]}
|
||||
sess = _FakeSession(payload)
|
||||
kt = OllamaKategorisierer(modell="x", session=sess)
|
||||
ergebnis = kategorisiere([a], kt)
|
||||
assert ergebnis[0].gruppe == "Obst & Gemüse"
|
||||
103
tests/test_korrektur.py
Normal file
103
tests/test_korrektur.py
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
"""Tests für die manuelle Korrektur (POST /api/korrektur) + Cache-Wirkung."""
|
||||
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("fastapi")
|
||||
from fastapi.testclient import TestClient # noqa: E402
|
||||
|
||||
import angebote.web as web # noqa: E402
|
||||
from angebote.produktcache import ProduktCache, produkt_schluessel # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db(tmp_path, monkeypatch):
|
||||
"""Isolierte Cache-DB; der Endpoint nutzt ProduktCache() ohne Pfad -> STANDARD_DB."""
|
||||
p = tmp_path / "kat.sqlite"
|
||||
monkeypatch.setattr("angebote.produktcache.STANDARD_DB", p)
|
||||
return p
|
||||
|
||||
|
||||
def test_korrektur_gueltig_schreibt_cache(db):
|
||||
client = TestClient(web.app)
|
||||
r = client.post(
|
||||
"/api/korrektur",
|
||||
json={"titel": "Hafer-Drink", "marke": "Oatly", "gruppe": "Getränke (alkoholfrei)"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["gespeichert"] is True
|
||||
cache = ProduktCache(db_pfad=db)
|
||||
assert cache.hole(produkt_schluessel("Hafer-Drink", "Oatly")) == "Getränke (alkoholfrei)"
|
||||
|
||||
|
||||
def test_korrektur_off_list_ist_400(db):
|
||||
client = TestClient(web.app)
|
||||
r = client.post("/api/korrektur", json={"titel": "X", "gruppe": "Weltraumzeug"})
|
||||
assert r.status_code == 400
|
||||
assert ProduktCache(db_pfad=db).groesse() == 0
|
||||
|
||||
|
||||
def test_korrektur_ohne_titel_ist_400(db):
|
||||
client = TestClient(web.app)
|
||||
r = client.post("/api/korrektur", json={"titel": " ", "gruppe": "Fisch"})
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_korrektur_modell_ist_manuell(db):
|
||||
client = TestClient(web.app)
|
||||
client.post("/api/korrektur", json={"titel": "Lachs", "marke": None, "gruppe": "Fisch"})
|
||||
with sqlite3.connect(str(db)) as con:
|
||||
row = con.execute("SELECT modell FROM produkt_kategorie").fetchone()
|
||||
assert row[0] == "manuell"
|
||||
|
||||
|
||||
def test_manuelle_zuordnung_ist_cache_hit_kein_llm(db):
|
||||
from angebote.kategorisieren import kategorisiere
|
||||
from tests.fakes import CountingFakeKategorisierer, beispiel_angebot
|
||||
|
||||
cache = ProduktCache(db_pfad=db)
|
||||
cache.schreibe_viele(
|
||||
[(produkt_schluessel("Hafer-Drink", None), "Getränke (alkoholfrei)", "manuell")]
|
||||
)
|
||||
a = beispiel_angebot("Hafer-Drink", marke=None)
|
||||
fake = CountingFakeKategorisierer("Sonstiges") # würde falsch raten
|
||||
stat = {}
|
||||
erg = kategorisiere([a], fake, cache=cache, statistik=stat)
|
||||
assert fake.gesehen == 0 # kein LLM-Posten -- die manuelle Zuordnung gewinnt
|
||||
assert stat == {"aus_cache": 1, "neu": 0}
|
||||
assert erg[0].gruppe == "Getränke (alkoholfrei)" and not erg[0].unsicher
|
||||
|
||||
|
||||
def test_korrektur_patcht_ergebnis_cache(db):
|
||||
client = TestClient(web.app)
|
||||
schluessel = produkt_schluessel("Toffifee", "Storck")
|
||||
key = ("99999", "openrouter", "x")
|
||||
web._ergebnis_cache[key] = {
|
||||
"gruppen": [
|
||||
{
|
||||
"name": "Sonstiges",
|
||||
"anzahl": 1,
|
||||
"angebote": [{"titel": "Toffifee", "marke": "Storck", "unsicher": True}],
|
||||
},
|
||||
{"name": "Süßwaren & Snacks", "anzahl": 0, "angebote": []},
|
||||
],
|
||||
"unsicher": 1,
|
||||
}
|
||||
try:
|
||||
r = client.post(
|
||||
"/api/korrektur",
|
||||
json={
|
||||
"titel": "Toffifee", "marke": "Storck",
|
||||
"gruppe": "Süßwaren & Snacks", "plz": "99999",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
erg = web._ergebnis_cache[key]
|
||||
suess = next(g for g in erg["gruppen"] if g["name"] == "Süßwaren & Snacks")
|
||||
sonst = next(g for g in erg["gruppen"] if g["name"] == "Sonstiges")
|
||||
assert len(suess["angebote"]) == 1 and not suess["angebote"][0]["unsicher"]
|
||||
assert len(sonst["angebote"]) == 0
|
||||
assert erg["unsicher"] == 0
|
||||
finally:
|
||||
web._ergebnis_cache.pop(key, None)
|
||||
|
|
@ -134,3 +134,48 @@ def test_picker_quit_gibt_none():
|
|||
ausgabe=lambda s: None,
|
||||
)
|
||||
assert gewaehlt is None
|
||||
|
||||
|
||||
# --- Ollama (lokale Modelle) -------------------------------------------------
|
||||
|
||||
|
||||
class _OllamaSession:
|
||||
"""Fake für /api/tags (Liste) + /api/show (Capabilities)."""
|
||||
|
||||
def __init__(self, namen, caps):
|
||||
self._namen = namen
|
||||
self._caps = caps
|
||||
|
||||
def get(self, url, timeout=None):
|
||||
return _Resp({"models": [{"name": n} for n in self._namen]})
|
||||
|
||||
def post(self, url, json=None, timeout=None):
|
||||
name = (json or {}).get("model")
|
||||
return _Resp({"capabilities": self._caps.get(name, [])})
|
||||
|
||||
|
||||
def test_lade_ollama_modelle_tool_faehige_zuerst():
|
||||
from angebote.modelle import lade_ollama_modelle
|
||||
|
||||
sess = _OllamaSession(
|
||||
["gemma3:latest", "qwen3.5:latest", "nomic-embed:latest"],
|
||||
{
|
||||
"qwen3.5:latest": ["completion", "tools"],
|
||||
"gemma3:latest": ["completion", "vision"],
|
||||
"nomic-embed:latest": ["embedding"],
|
||||
},
|
||||
)
|
||||
modelle = lade_ollama_modelle(session=sess)
|
||||
assert all(m.frei for m in modelle) # lokal = frei
|
||||
assert modelle[0].id == "qwen3.5:latest" and modelle[0].tools is True
|
||||
assert any(m.id == "gemma3:latest" and not m.tools for m in modelle)
|
||||
|
||||
|
||||
def test_lade_ollama_modelle_leer_wenn_server_aus():
|
||||
from angebote.modelle import lade_ollama_modelle
|
||||
|
||||
class _Down:
|
||||
def get(self, *a, **k):
|
||||
raise OSError("connection refused")
|
||||
|
||||
assert lade_ollama_modelle(session=_Down()) == []
|
||||
|
|
|
|||
101
tests/test_produktcache.py
Normal file
101
tests/test_produktcache.py
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
"""Tests für den Produkt→Kategorie-Cache -- offline, eigene DB pro Test."""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from angebote.produktcache import ProduktCache, produkt_schluessel
|
||||
|
||||
SRC = Path(__file__).resolve().parents[1] / "src"
|
||||
|
||||
|
||||
def _cache(tmp_path) -> ProduktCache:
|
||||
return ProduktCache(db_pfad=tmp_path / "cache.sqlite")
|
||||
|
||||
|
||||
# -- Schlüssel ----------------------------------------------------------------
|
||||
|
||||
|
||||
def test_schluessel_ist_mengen_invariant():
|
||||
# Titel + Marke bestimmen den Schlüssel; Menge spielt keine Rolle.
|
||||
assert produkt_schluessel("Butter", "Meggle") == produkt_schluessel(
|
||||
" butter ", "MEGGLE"
|
||||
)
|
||||
|
||||
|
||||
def test_schluessel_unterscheidet_marke():
|
||||
assert produkt_schluessel("Butter", "Meggle") != produkt_schluessel(
|
||||
"Butter", "Kerrygold"
|
||||
)
|
||||
|
||||
|
||||
# -- Round-Trip / Persistenz --------------------------------------------------
|
||||
|
||||
|
||||
def test_round_trip_ueber_instanzgrenzen(tmp_path):
|
||||
db = tmp_path / "c.sqlite"
|
||||
c1 = ProduktCache(db_pfad=db)
|
||||
s = produkt_schluessel("Toffifee", "Storck")
|
||||
c1.schreibe_viele([(s, "Süßwaren & Snacks", "deepseek")])
|
||||
# frische Instanz auf derselben DB
|
||||
c2 = ProduktCache(db_pfad=db)
|
||||
assert c2.hole(s) == "Süßwaren & Snacks"
|
||||
assert c2.groesse() == 1
|
||||
|
||||
|
||||
def test_unbekannter_schluessel_gibt_none(tmp_path):
|
||||
c = _cache(tmp_path)
|
||||
assert c.hole("gibtsnicht") is None
|
||||
|
||||
|
||||
# -- Geschlossene Liste (Whitelist) ------------------------------------------
|
||||
|
||||
|
||||
def test_off_list_gruppe_wird_nicht_geschrieben(tmp_path):
|
||||
c = _cache(tmp_path)
|
||||
n = c.schreibe_viele([("k1", "Weltraumzeug", None)])
|
||||
assert n == 0
|
||||
assert c.hole("k1") is None
|
||||
|
||||
|
||||
def test_off_list_zeile_in_db_wird_beim_lesen_verworfen(tmp_path):
|
||||
import sqlite3
|
||||
|
||||
db = tmp_path / "c.sqlite"
|
||||
ProduktCache(db_pfad=db) # legt Tabelle an
|
||||
# manipulierte Zeile direkt in die DB schreiben
|
||||
with sqlite3.connect(str(db)) as con:
|
||||
con.execute(
|
||||
"INSERT INTO produkt_kategorie VALUES (?,?,?,?)",
|
||||
("k1", "Quatschgruppe", None, "2026-01-01"),
|
||||
)
|
||||
c = ProduktCache(db_pfad=db)
|
||||
assert c.hole("k1") is None # Whitelist filtert sie heraus
|
||||
assert c.groesse() == 0
|
||||
|
||||
|
||||
# -- Schnitt: kein LLM im Cache ----------------------------------------------
|
||||
|
||||
|
||||
def test_cache_laedt_kein_anthropic():
|
||||
code = (
|
||||
"import sys; import angebote.produktcache; "
|
||||
"assert 'anthropic' not in sys.modules, 'Cache hat anthropic geladen'; "
|
||||
"print('ok')"
|
||||
)
|
||||
proc = subprocess.run(
|
||||
[sys.executable, "-c", code], cwd=str(SRC), capture_output=True, text=True
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
assert "ok" in proc.stdout
|
||||
|
||||
|
||||
def test_cache_importiert_kein_llm():
|
||||
quelltext = (SRC / "angebote" / "produktcache.py").read_text("utf-8")
|
||||
import_zeilen = "\n".join(
|
||||
z for z in quelltext.splitlines()
|
||||
if z.strip().startswith(("import ", "from "))
|
||||
).lower()
|
||||
assert "anthropic" not in import_zeilen
|
||||
assert "openai" not in import_zeilen
|
||||
assert "kategorisieren" not in import_zeilen
|
||||
115
tests/test_speicher.py
Normal file
115
tests/test_speicher.py
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
"""Persistenz der Rohdaten -- offline, deterministisch (kein Netz, kein LLM).
|
||||
|
||||
Geprüft wird der Architektur-Vertrag, nicht nur der Happy Path:
|
||||
* Round-Trip ist verlustfrei für belegte Felder (inkl. fehlender Felder).
|
||||
* Fehlende Felder bleiben None -- werden NICHT aufgefüllt.
|
||||
* Kein Stand -> None (Stufe 2 ist dann gesperrt), es wird nichts geraten.
|
||||
* Pfad liegt pro PLZ/Kalenderwoche.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
|
||||
from angebote.modell import FetchErgebnis
|
||||
from angebote.speicher import (
|
||||
lade_rohdaten,
|
||||
meta_fuer,
|
||||
pfad_fuer,
|
||||
rohliste_dicts,
|
||||
speichere_rohdaten,
|
||||
)
|
||||
from tests.fakes import beispiel_angebot
|
||||
|
||||
|
||||
def _fetch(angebote, plz="60487"):
|
||||
return FetchErgebnis(
|
||||
ort_plz=plz,
|
||||
ort_name=None,
|
||||
angebote=tuple(angebote),
|
||||
abgedeckte_quellen=("marktguru",),
|
||||
gesehene_haendler=tuple(sorted({a.haendler for a in angebote})),
|
||||
hinweise=("marktguru: Teilabdeckung",),
|
||||
)
|
||||
|
||||
|
||||
def test_round_trip_behaelt_belegte_felder(tmp_path):
|
||||
a = beispiel_angebot(
|
||||
"Bio-Banane",
|
||||
haendler="ALDI SÜD",
|
||||
preis=1.29,
|
||||
marke="Bio Smiley",
|
||||
menge="1 kg",
|
||||
gueltig_von=date(2026, 6, 1),
|
||||
gueltig_bis=date(2026, 6, 7),
|
||||
)
|
||||
fetch = _fetch([a])
|
||||
pfad = speichere_rohdaten(fetch, basis_dir=tmp_path)
|
||||
assert pfad.exists()
|
||||
|
||||
geladen = lade_rohdaten("60487", basis_dir=tmp_path)
|
||||
assert geladen is not None
|
||||
assert len(geladen.angebote) == 1
|
||||
g = geladen.angebote[0]
|
||||
assert g.titel == "Bio-Banane"
|
||||
assert g.haendler == "ALDI SÜD"
|
||||
assert g.preis == 1.29
|
||||
assert g.marke == "Bio Smiley"
|
||||
assert g.gueltig_von == date(2026, 6, 1)
|
||||
assert g.gueltig_bis == date(2026, 6, 7)
|
||||
# stabile ID bleibt über den Round-Trip identisch (für Stufe-2-Mapping):
|
||||
assert g.angebot_id == a.angebot_id
|
||||
# Herkunft bleibt belegt:
|
||||
assert geladen.abgedeckte_quellen == ("marktguru",)
|
||||
assert geladen.gesehene_haendler == ("ALDI SÜD",)
|
||||
|
||||
|
||||
def test_fehlende_felder_bleiben_none_kein_auffuellen(tmp_path):
|
||||
# Pflichtfelder gesetzt, optionale bewusst leer -> dürfen NICHT geraten werden.
|
||||
a = beispiel_angebot(
|
||||
"No-Name-Artikel",
|
||||
haendler="REWE",
|
||||
preis=None,
|
||||
marke=None,
|
||||
menge=None,
|
||||
grundpreis=None,
|
||||
gueltig_von=None,
|
||||
gueltig_bis=None,
|
||||
)
|
||||
speichere_rohdaten(_fetch([a]), basis_dir=tmp_path)
|
||||
g = lade_rohdaten("60487", basis_dir=tmp_path).angebote[0]
|
||||
assert g.preis is None
|
||||
assert g.marke is None
|
||||
assert g.menge is None
|
||||
assert g.grundpreis is None
|
||||
assert g.gueltig_von is None
|
||||
assert g.gueltig_bis is None
|
||||
|
||||
|
||||
def test_kein_stand_liefert_none(tmp_path):
|
||||
assert lade_rohdaten("12345", basis_dir=tmp_path) is None
|
||||
assert meta_fuer("12345", basis_dir=tmp_path) is None
|
||||
|
||||
|
||||
def test_meta_fasst_zusammen(tmp_path):
|
||||
a = beispiel_angebot("Butter", haendler="REWE")
|
||||
b = beispiel_angebot("Käse", haendler="EDEKA")
|
||||
speichere_rohdaten(_fetch([a, b]), basis_dir=tmp_path)
|
||||
meta = meta_fuer("60487", basis_dir=tmp_path)
|
||||
assert meta["anzahl"] == 2
|
||||
assert set(meta["haendler"]) == {"REWE", "EDEKA"}
|
||||
assert meta["abgerufen_am"]
|
||||
|
||||
|
||||
def test_pfad_pro_plz_und_woche():
|
||||
jetzt = datetime(2026, 6, 2, 9, 0, 0) # ISO-Woche 23/2026
|
||||
pfad = pfad_fuer("60487", basis_dir="/tmp/roh", jetzt=jetzt)
|
||||
assert pfad.name == "60487_2026-W23.json"
|
||||
|
||||
|
||||
def test_rohliste_ohne_produktgruppe(tmp_path):
|
||||
"""Stufe 1 kategorisiert nicht -- die Rohliste trägt keine Produktgruppe."""
|
||||
a = beispiel_angebot("Butter")
|
||||
dicts = rohliste_dicts(_fetch([a]))
|
||||
assert dicts and "produktgruppe" not in dicts[0]
|
||||
assert "gruppe" not in dicts[0]
|
||||
94
tests/test_ui_persistenz.py
Normal file
94
tests/test_ui_persistenz.py
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
"""E2E-Test: die LLM-Konfiguration (Anbieter + Modell) überlebt einen Reload.
|
||||
|
||||
Echter Browser (Playwright) gegen einen frisch gestarteten Server. Prüft das
|
||||
Verhalten, das im UI vorher fehlte: nach Umschalten auf Ollama und Neuladen darf
|
||||
die UI NICHT auf den OpenRouter-Default zurückspringen.
|
||||
|
||||
Wird übersprungen, wenn Playwright/Chromium nicht installiert ist.
|
||||
"""
|
||||
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sync_api = pytest.importorskip("playwright.sync_api")
|
||||
from playwright.sync_api import sync_playwright # noqa: E402
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
SRC = REPO / "src"
|
||||
|
||||
|
||||
def _freier_port() -> int:
|
||||
s = socket.socket()
|
||||
s.bind(("127.0.0.1", 0))
|
||||
port = s.getsockname()[1]
|
||||
s.close()
|
||||
return port
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def server():
|
||||
port = _freier_port()
|
||||
env = {**os.environ, "PYTHONPATH": str(SRC)}
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "-m", "uvicorn", "angebote.web:app",
|
||||
"--port", str(port), "--log-level", "warning"],
|
||||
cwd=str(SRC), env=env,
|
||||
)
|
||||
base = f"http://127.0.0.1:{port}"
|
||||
try:
|
||||
for _ in range(60):
|
||||
try:
|
||||
urllib.request.urlopen(base + "/", timeout=1)
|
||||
break
|
||||
except Exception:
|
||||
time.sleep(0.25)
|
||||
else:
|
||||
raise RuntimeError("Server kam nicht hoch")
|
||||
yield base
|
||||
finally:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=10)
|
||||
except Exception:
|
||||
proc.kill()
|
||||
|
||||
|
||||
def _chromium_da() -> bool:
|
||||
try:
|
||||
with sync_playwright() as p:
|
||||
b = p.chromium.launch()
|
||||
b.close()
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _chromium_da(), reason="Chromium für Playwright nicht installiert")
|
||||
def test_anbieter_persistiert_ueber_reload(server):
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch()
|
||||
page = browser.new_page()
|
||||
page.goto(server)
|
||||
page.wait_for_selector("#anbieter", state="attached")
|
||||
|
||||
# Konfig-Panel aufklappen (der Anbieter-Select liegt darin), dann
|
||||
# auf Ollama umschalten -> löst change-Event + merkeWahl() aus.
|
||||
page.evaluate("document.getElementById('config').open = true")
|
||||
page.select_option("#anbieter", "ollama")
|
||||
page.wait_for_timeout(1000)
|
||||
|
||||
# Reload -> init() muss die gemerkte Wahl wiederherstellen.
|
||||
page.reload()
|
||||
page.wait_for_selector("#anbieter", state="attached")
|
||||
page.wait_for_timeout(1000)
|
||||
|
||||
wert = page.eval_on_selector("#anbieter", "el => el.value")
|
||||
assert wert == "ollama", f"Anbieter nach Reload = {wert!r}, erwartet 'ollama'"
|
||||
browser.close()
|
||||
|
|
@ -1,15 +1,18 @@
|
|||
"""Web-Schicht -- offline. Struktur-Funktion + Endpoints (ohne Netz/Key/LLM).
|
||||
|
||||
Der Schnitt gilt auch hier: die Web-Schicht fügt keine Logik hinzu, sie ruft
|
||||
die getesteten Module auf. Geprüft wird, dass sie das belegt-und-ehrlich
|
||||
weiterreicht.
|
||||
Der Schnitt gilt auch hier und ist im Endpoint-Schnitt sichtbar:
|
||||
* Stufe 1 (/api/rohdaten) holt + speichert deterministisch -- ohne Key.
|
||||
* Stufe 2 (/api/kategorisieren) ist gesperrt, solange keine Rohdaten vorliegen.
|
||||
|
||||
Geprüft wird, dass die Web-Schicht das belegt-und-ehrlich weiterreicht und die
|
||||
zwei Stufen sauber trennt -- ohne dabei selbst zu fetchen oder ein LLM zu rufen.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from angebote.modell import FetchErgebnis, KategorisiertesAngebot
|
||||
from angebote.uebersicht import als_struktur
|
||||
from tests.fakes import beispiel_angebot
|
||||
from tests.fakes import FakeQuelle, beispiel_angebot
|
||||
|
||||
fastapi = pytest.importorskip("fastapi")
|
||||
from fastapi.testclient import TestClient # noqa: E402
|
||||
|
|
@ -48,7 +51,15 @@ def test_index_liefert_html():
|
|||
client = TestClient(web.app)
|
||||
r = client.get("/")
|
||||
assert r.status_code == 200
|
||||
assert "Angebots-Übersicht" in r.text
|
||||
assert "MABOTO" in r.text
|
||||
|
||||
|
||||
def test_favicon_liefert_svg():
|
||||
client = TestClient(web.app)
|
||||
r = client.get("/favicon.svg")
|
||||
assert r.status_code == 200
|
||||
assert r.headers["content-type"].startswith("image/svg+xml")
|
||||
assert "<svg" in r.text
|
||||
|
||||
|
||||
def test_api_modelle_gibt_top_free(monkeypatch):
|
||||
|
|
@ -66,13 +77,103 @@ def test_api_modelle_gibt_top_free(monkeypatch):
|
|||
assert daten[0]["frei"] is True
|
||||
|
||||
|
||||
def test_api_lauf_ohne_plz_ist_400():
|
||||
client = TestClient(web.app)
|
||||
r = client.post("/api/lauf", json={"plz": ""})
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_api_status_unbekannt_ist_404():
|
||||
client = TestClient(web.app)
|
||||
r = client.get("/api/lauf/gibtsnicht")
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_als_struktur_fuehrt_modell_und_anbieter():
|
||||
a = beispiel_angebot("Butter")
|
||||
kat = [KategorisiertesAngebot(a, "Molkereiprodukte & Eier", unsicher=False)]
|
||||
s = als_struktur(_fetch_mit([a]), kat, modell="qwen3.5:latest", anbieter="ollama")
|
||||
assert s["modell"] == "qwen3.5:latest"
|
||||
assert s["anbieter"] == "ollama"
|
||||
|
||||
|
||||
def test_api_ollama_modelle(monkeypatch):
|
||||
fake = [ModellInfo("qwen3.5:latest", "qwen3.5:latest", None, True, True)]
|
||||
monkeypatch.setattr(
|
||||
"angebote.modelle.lade_ollama_modelle", lambda session=None: fake
|
||||
)
|
||||
client = TestClient(web.app)
|
||||
r = client.get("/api/ollama-modelle")
|
||||
assert r.status_code == 200
|
||||
daten = r.json()
|
||||
assert daten[0]["id"] == "qwen3.5:latest" and daten[0]["tools"] is True
|
||||
|
||||
|
||||
# === Stufe 1: Rohdaten holen & speichern (deterministisch, ohne Key) =========
|
||||
|
||||
|
||||
def test_api_rohdaten_ohne_plz_ist_400():
|
||||
client = TestClient(web.app)
|
||||
r = client.post("/api/rohdaten", json={"plz": ""})
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_api_rohdaten_holen_speichert_und_laedt(tmp_path, monkeypatch):
|
||||
"""Stufe 1 fetcht (über Fake-Quelle, kein Netz), speichert, und GET liefert es."""
|
||||
# Persistenz in tmp lenken -- der Default-Pfad bleibt unangetastet.
|
||||
monkeypatch.setattr("angebote.speicher.STANDARD_BASIS", tmp_path / "roh")
|
||||
# Fetch über eine Fake-Quelle: kein Netz, kein Schlüssel, deterministisch.
|
||||
a = beispiel_angebot("Bio-Banane", haendler="ALDI SÜD", preis=1.29)
|
||||
quelle = FakeQuelle("fake", [a])
|
||||
monkeypatch.setattr("angebote.fetch.standard_quellen", lambda: [quelle])
|
||||
|
||||
client = TestClient(web.app)
|
||||
r = client.post("/api/rohdaten", json={"plz": "60487"})
|
||||
assert r.status_code == 200
|
||||
d = r.json()
|
||||
assert d["plz"] == "60487"
|
||||
assert d["anzahl"] == 1
|
||||
assert "ALDI SÜD" in d["haendler"]
|
||||
assert d["angebote"][0]["titel"] == "Bio-Banane"
|
||||
assert d["abgerufen_am"] # belegt
|
||||
|
||||
# Datei wurde geschrieben (data/roh-Äquivalent im tmp)
|
||||
geschrieben = list((tmp_path / "roh").glob("60487_*.json"))
|
||||
assert geschrieben, "Rohdaten-Datei wurde nicht persistiert"
|
||||
|
||||
# GET liefert den gespeicherten Stand zurück (kein erneutes Fetchen nötig).
|
||||
g = client.get("/api/rohdaten/60487")
|
||||
assert g.status_code == 200
|
||||
assert g.json()["angebote"][0]["titel"] == "Bio-Banane"
|
||||
|
||||
|
||||
def test_api_rohdaten_holen_abbruch_ist_422(monkeypatch, tmp_path):
|
||||
"""Ehrlicher Abbruch (Regel 4) wird als 422 mit Ursache/Vorschlag gemeldet."""
|
||||
monkeypatch.setattr("angebote.speicher.STANDARD_BASIS", tmp_path / "roh")
|
||||
# Keine Quelle deckt den Ort ab -> AbbruchFehler.
|
||||
quelle = FakeQuelle("fake", [], deckt=False)
|
||||
monkeypatch.setattr("angebote.fetch.standard_quellen", lambda: [quelle])
|
||||
|
||||
client = TestClient(web.app)
|
||||
r = client.post("/api/rohdaten", json={"plz": "60487"})
|
||||
assert r.status_code == 422
|
||||
assert "Abbruch" in r.json()["detail"]
|
||||
|
||||
|
||||
def test_api_rohdaten_laden_ohne_stand_ist_404(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr("angebote.speicher.STANDARD_BASIS", tmp_path / "roh")
|
||||
client = TestClient(web.app)
|
||||
r = client.get("/api/rohdaten/99999")
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
# === Stufe 2: Kategorisieren -- gesperrt ohne Rohdaten =======================
|
||||
|
||||
|
||||
def test_api_kategorisieren_ohne_rohdaten_ist_400(monkeypatch, tmp_path):
|
||||
"""Stufe 2 ist hart gesperrt, solange keine Rohdaten gespeichert sind."""
|
||||
monkeypatch.setattr("angebote.speicher.STANDARD_BASIS", tmp_path / "roh")
|
||||
client = TestClient(web.app)
|
||||
r = client.post("/api/kategorisieren", json={"plz": "60487"})
|
||||
assert r.status_code == 400
|
||||
assert "Rohdaten" in r.json()["detail"]
|
||||
|
||||
|
||||
def test_api_kategorisieren_ohne_plz_ist_400():
|
||||
client = TestClient(web.app)
|
||||
r = client.post("/api/kategorisieren", json={"plz": ""})
|
||||
assert r.status_code == 400
|
||||
|
|
|
|||
Loading…
Reference in a new issue