Introcuced a Free LLM for easier food input

This commit is contained in:
2026-07-30 09:06:25 +02:00
parent 5196ff0194
commit c40a142da2
8 changed files with 272 additions and 7 deletions
+6
View File
@@ -19,6 +19,7 @@ backend/app/
├── security.py Passwort-Hashing, JWT
├── deps.py get_session, get_current_user
├── access.py product_barcode_accessible / user_can_access_dish (Zugriffsprüfung für private Dish-Produkte)
├── llm.py OpenAI-kompatibler Chat-Client (complete_json) für die KI-Nährwertschätzung
├── core/config.py Settings (pydantic-settings, liest .env)
└── routers/ auth.py, users.py (inkl. /me/goals, GET / für Sharing-Picker), products.py (inkl. /search), logs.py (inkl. /today, /history, /day/{day}, PUT+DELETE /{log_id}), dishes.py (Kochbuch-CRUD + /shares)
@@ -44,6 +45,11 @@ frontend/src/
**Mehrbenutzer-Absicherung** (`app/access.py`, genutzt von `products.py`, `logs.py`, `dishes.py`): `Product` hat kein `user_id` und ist absichtlich global — alle Nutzer sehen/loggen dieselben Lebensmittel. Die einzige Ausnahme sind Gerichte (`Product.barcode` beginnt mit `dish-`): die sind über `models.DishShare` (dish_id, shared_with_user_id) an ihren Besitzer gebunden. `access.product_barcode_accessible(session, barcode, user_id)` ist die zentrale Prüfung — gibt für normale Barcodes immer `True` zurück, für `dish-*`-Barcodes nur wenn `user_id` Besitzer ist oder ein `DishShare`-Eintrag existiert. Wird an drei Stellen aufgerufen: `GET /api/products/{barcode}` und `GET /api/products/search` (private Gerichte anderer werden wie "nicht gefunden" behandelt, keine Existenz-Leaks), sowie `POST /api/logs` (verhindert Loggen fremder privater Gerichte auch wenn der Barcode bekannt ist). `dishes.py` unterscheidet `_get_owned_dish` (strikt Besitzer, für Bearbeiten/Löschen/Sharing-Verwaltung) von `_get_accessible_dish` (Besitzer ODER Freigabe-Empfänger, für die Detailansicht). `DishRead.is_owner`/`owner_username` sagen dem Frontend, ob Bearbeiten/Löschen/Teilen-UI angezeigt werden darf — das Backend ist die eigentliche Durchsetzung, das Frontend blendet nur aus. `GET /api/users` (nur Username, keine persönlichen Daten) existiert einzig für den Sharing-Picker im Frontend.
**KI-Nährwertschätzung** (`app/llm.py`, `POST /api/products/estimate`, Frontend in `ProductNotFoundForm.jsx`): Freitext („Currywurst mit Pommes") → LLM schätzt alle acht Nährwerte pro 100 g plus `portion_g` und füllt damit das bestehende Anlege-Formular vor. Bewusst **nur ein Vorschlag**: die Werte landen in den normalen Eingabefeldern, der Nutzer speichert erst nach Prüfung (Hinweistext im UI), und es wird nichts automatisch geloggt. Konfiguration komplett über `.env` (`LLM_API_URL`/`LLM_API_KEY`/`LLM_MODEL`), Client ist OpenAI-kompatibel — Gateway/Modell also austauschbar ohne Codeänderung. Ohne `LLM_API_KEY` antwortet der Endpunkt sauber mit `503`, der Rest der App bleibt unberührt (getestet). Die LLM-Antwort wird zusätzlich per Pydantic (`NutritionEstimateResponse`, mit `ge`/`le`-Grenzen z. B. `calories <= 900`) validiert, damit offensichtlich unsinnige Halluzinationen nicht ins Formular gelangen; scheitert das, gibt es `502` statt kaputter Werte.
- **Modellwahl ist nicht beliebig — gemessen, nicht geraten**: Von den freien `kit.*`-Modellen der KIT-Toolbox lieferte im direkten Vergleich (gleicher Prompt, je 4–6 Anfragen) nur `kit.mistral-small-4-119b-a8b` durchgängig valides JSON, bei ~4 s pro Anfrage. `kit.qwen3.5-397b-A17b` gab **gar keinen** `content` zurück (0/3, vermutlich Reasoning-only-Ausgabe), `kit.minimax-m2.7-229b` schrieb `<think>`-Blöcke vor das JSON, `kit.gemma4-31b-it` brauchte 14–17 s, `kit.gpt-oss-120b` war unzuverlässiger. Deshalb ist mistral-small der Default. `response_format={"type": "json_object"}` verbessert die Trefferquote spürbar und wird immer mitgeschickt; `_extract_json()` räumt trotzdem defensiv `<think>`-Blöcke und Markdown-Fences ab, damit ein Modellwechsel per `.env` nicht sofort alles bricht.
- **Die KIT-Toolbox meldet transiente Fehler als HTTP 400**: sporadisch kommen `{"detail": "Model not found"}`, `{"detail": "Function not found: token_usage_display"}` oder sogar durchgereichte `psycopg.OperationalError`-Meldungen zurück — bei einem unveränderten Request, der Sekunden später funktioniert. Das sind Gateway-Aussetzer, keine Client-Fehler. `complete_json()` wiederholt deshalb **jeden** Fehler bis zu `MAX_ATTEMPTS` (3) mal, obwohl man einen 400er normalerweise nie wiederholen würde — ohne das schlägt gefühlt jede vierte Anfrage grundlos fehl. `max_retries=0` am OpenAI-Client ist Absicht, damit die Wiederholungslogik an einer Stelle liegt.
**Kein `window.confirm()`**: Lösch-Bestätigungen laufen über die eigene `ConfirmDialog`-Komponente, nicht über natives `window.confirm()`. Grund: native Dialoge blockieren/verhalten sich inkonsistent in automatisierten Browsern (z. B. Preview-Tooling in dieser Umgebung) und passen optisch nicht zum Rest der App. Neue Lösch-Aktionen sollten `ConfirmDialog` wiederverwenden.
**`ProductNotFoundForm` hat zwei Modi**: `mode="log"` (Default) erstellt ein Produkt UND loggt es sofort (zeigt Mahlzeit-Auswahl + Mengenfeld, Button "Anlegen & Loggen") — genutzt vom Scan-/Such-Flow. `mode="createOnly"` legt nur das Produkt an und ruft `onCreated(product)` auf, ohne zu loggen (kein Mahlzeit-/Mengenfeld, Button "Anlegen") — genutzt von `IngredientPickerModal.jsx` beim Zutaten-Anlegen im Kochbuch, wo ein frisch angelegtes Produkt nur der Zutatenliste hinzugefügt werden soll, nicht dem Tages-Log. `IngredientPickerModal` bündelt für den Zutaten-Picker dieselben drei Wege wie `AddMealModal` (Suche, Barcode-Scan, manuelles Anlegen) — bei 404 nach einem Scan wird automatisch `ProductNotFoundForm` im `createOnly`-Modus mit dem gescannten Barcode vorausgefüllt geöffnet.