Merge pull request #2 from JakobFritzz/history-charts
Introcuced a Free LLM for easier food input
This commit is contained in:
@@ -19,6 +19,7 @@ backend/app/
|
|||||||
├── security.py Passwort-Hashing, JWT
|
├── security.py Passwort-Hashing, JWT
|
||||||
├── deps.py get_session, get_current_user
|
├── deps.py get_session, get_current_user
|
||||||
├── access.py product_barcode_accessible / user_can_access_dish (Zugriffsprüfung für private Dish-Produkte)
|
├── 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)
|
├── 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)
|
└── 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.
|
**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.
|
**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.
|
**`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.
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ KalorienTracker/
|
|||||||
└── docker-compose.yml
|
└── docker-compose.yml
|
||||||
```
|
```
|
||||||
|
|
||||||
- **Backend** (`backend/`): FastAPI-API mit JWT-Login, Produktsuche (lokale DB → Fallback auf Open Food Facts, Ergebnis wird lokal gecacht), Logging von Mahlzeiten inkl. Tagesübersicht, Historie (Tage/Wochen/Monate) und selbst gesetzten Tageszielen. Mehrbenutzerfähig: jeder registrierte Nutzer sieht nur seine eigenen Tagebuch-Einträge, Ziele und privaten Rezepte, während alle Lebensmittel (Barcode-Produkte) in einer gemeinsamen Datenbank für alle Nutzer verfügbar sind. Jeder Endpunkt außer Registrierung/Login verlangt ein gültiges JWT.
|
- **Backend** (`backend/`): FastAPI-API mit JWT-Login, Produktsuche (lokale DB → Fallback auf Open Food Facts, Ergebnis wird lokal gecacht), Logging von Mahlzeiten inkl. Tagesübersicht, Historie (Tage/Wochen/Monate) und selbst gesetzten Tageszielen. Optional eine KI-Nährwertschätzung aus Freitext über ein OpenAI-kompatibles LLM-Gateway. Mehrbenutzerfähig: jeder registrierte Nutzer sieht nur seine eigenen Tagebuch-Einträge, Ziele und privaten Rezepte, während alle Lebensmittel (Barcode-Produkte) in einer gemeinsamen Datenbank für alle Nutzer verfügbar sind. Jeder Endpunkt außer Registrierung/Login verlangt ein gültiges JWT.
|
||||||
- **Frontend** (`frontend/`): React-SPA mit Kamera-Barcode-Scanner (`html5-qrcode`), Dashboard mit Fortschrittsbalken für Kalorien/Protein/Carbs/Fett, Formular für manuell angelegte Produkte, Historie-Seite mit Trend-Grafiken (Recharts), Kalenderwochen- und Monatsdurchschnitt, Settings-Seite für eigene Tagesziele, umschaltbarer Dark Mode (folgt standardmäßig der Systemeinstellung, per Klick auf das Sonne/Mond-Icon umschaltbar, Wahl bleibt gespeichert).
|
- **Frontend** (`frontend/`): React-SPA mit Kamera-Barcode-Scanner (`html5-qrcode`), Dashboard mit Fortschrittsbalken für Kalorien/Protein/Carbs/Fett, Formular für manuell angelegte Produkte, Historie-Seite mit Trend-Grafiken (Recharts), Kalenderwochen- und Monatsdurchschnitt, Settings-Seite für eigene Tagesziele, umschaltbarer Dark Mode (folgt standardmäßig der Systemeinstellung, per Klick auf das Sonne/Mond-Icon umschaltbar, Wahl bleibt gespeichert).
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
@@ -74,15 +74,38 @@ Läuft auf `http://localhost:5173` und spricht per CORS direkt mit dem Backend a
|
|||||||
1. Registrieren / einloggen.
|
1. Registrieren / einloggen.
|
||||||
2. Auf „Barcode scannen“ tippen, Kamera auf den Barcode halten. Falls die Live-Erkennung hakt (z. B. schlechtes Licht oder Fokus), per „Stattdessen Foto aufnehmen“ ein einzelnes scharfes Foto aufnehmen — das Bild wird einmalig ausgewertet und danach verworfen, nie gespeichert oder hochgeladen.
|
2. Auf „Barcode scannen“ tippen, Kamera auf den Barcode halten. Falls die Live-Erkennung hakt (z. B. schlechtes Licht oder Fokus), per „Stattdessen Foto aufnehmen“ ein einzelnes scharfes Foto aufnehmen — das Bild wird einmalig ausgewertet und danach verworfen, nie gespeichert oder hochgeladen.
|
||||||
3. Produkt gefunden → Mahlzeit (Frühstück/Mittagessen/Abendessen/Snacks, per Uhrzeit vorausgewählt) + Menge in Gramm eingeben → „Hinzufügen“. Angezeigt werden Kalorien, Carbs, Protein, Fett sowie Zucker, Ballaststoffe, gesättigte Fettsäuren und Salz pro 100g.
|
3. Produkt gefunden → Mahlzeit (Frühstück/Mittagessen/Abendessen/Snacks, per Uhrzeit vorausgewählt) + Menge in Gramm eingeben → „Hinzufügen“. Angezeigt werden Kalorien, Carbs, Protein, Fett sowie Zucker, Ballaststoffe, gesättigte Fettsäuren und Salz pro 100g.
|
||||||
4. Produkt nicht gefunden (404) → Name + Nährwerte pro 100g manuell eintragen (die vier Zusatz-Nährwerte sind optional) → wird angelegt und direkt geloggt.
|
4. Produkt nicht gefunden (404) → Name + Nährwerte pro 100g manuell eintragen (die vier Zusatz-Nährwerte sind optional) → wird angelegt und direkt geloggt. Alternativ oben im Formular kurz beschreiben, was es war (z. B. „Currywurst mit Pommes“) und auf „Schätzen“ tippen — dann füllt eine KI alle Nährwerte und die übliche Portionsgröße als **Vorschlag** aus, den man vor dem Speichern prüfen und korrigieren kann.
|
||||||
5. Alternativ „Mahlzeit hinzufügen“ tippen, um ohne Scan nach einem Produkt zu suchen (z. B. „Döner“ vom Essen gehen) oder ein neues anzulegen. Die Suche schlägt zuerst die zuletzt selbst verwendeten Produkte vor (inkl. der zuletzt genutzten Menge) — auch wenn sie ursprünglich per Barcode gescannt wurden.
|
5. Alternativ „Mahlzeit hinzufügen“ tippen, um ohne Scan nach einem Produkt zu suchen (z. B. „Döner“ vom Essen gehen) oder ein neues anzulegen. Die Suche schlägt zuerst die zuletzt selbst verwendeten Produkte vor (inkl. der zuletzt genutzten Menge) — auch wenn sie ursprünglich per Barcode gescannt wurden.
|
||||||
6. Dashboard zeigt die Tagessumme aus Kalorien, Protein, Carbs und Fett als Fortschrittsbalken gegen die eigenen Ziele, plus eine Zeile mit den restlichen Nährwerten (Zucker/Ballaststoffe/ges. Fett/Salz, ohne eigenes Ziel), sowie die Einträge gruppiert nach Frühstück/Mittagessen/Abendessen/Snacks. Jeder Eintrag lässt sich über die Stift-/Papierkorb-Icons nachträglich in Menge/Mahlzeit bearbeiten oder löschen.
|
6. Dashboard zeigt die Tagessumme aus Kalorien, Protein, Carbs und Fett als Fortschrittsbalken gegen die eigenen Ziele, plus eine Zeile mit den restlichen Nährwerten (Zucker/Ballaststoffe/ges. Fett/Salz, ohne eigenes Ziel), sowie die Einträge gruppiert nach Frühstück/Mittagessen/Abendessen/Snacks. Jeder Eintrag lässt sich über die Stift-/Papierkorb-Icons nachträglich in Menge/Mahlzeit bearbeiten oder löschen.
|
||||||
7. Unter „Ziele“ lassen sich die eigenen Tagesziele für Kalorien, Protein, Carbs und Fett festlegen (Default: 2000 kcal / 100 g Protein / 250 g Carbs / 70 g Fett).
|
7. Unter „Ziele“ lassen sich die eigenen Tagesziele für Kalorien, Protein, Carbs und Fett festlegen (Default: 2000 kcal / 100 g Protein / 250 g Carbs / 70 g Fett).
|
||||||
8. Unter „Historie“ gibt es einen Kalorien-Trend und einen Makro-Trend (Carbs/Protein/Fett) als Grafik über die letzten 30 Tage, eine Wochenübersicht mit echten Kalenderwochen-Durchschnitten (Montag–Sonntag) sowie den Monatsdurchschnitt (jeweils nur über Tage mit Einträgen gemittelt) und eine Liste der letzten 30 Tage — ein Tag antippen zeigt die geloggten Einträge dieses Tages, ebenfalls nach Mahlzeit gruppiert.
|
8. Unter „Historie“ gibt es einen Kalorien-Trend und einen Makro-Trend (Carbs/Protein/Fett) als Grafik über die letzten 30 Tage, eine Wochenübersicht mit echten Kalenderwochen-Durchschnitten (Montag–Sonntag) sowie den Monatsdurchschnitt (jeweils nur über Tage mit Einträgen gemittelt) und eine Liste der letzten 30 Tage — ein Tag antippen zeigt die geloggten Einträge dieses Tages, ebenfalls nach Mahlzeit gruppiert.
|
||||||
9. Unter „Kochbuch“ lassen sich eigene Gerichte aus bereits bekannten Produkten zusammenstellen (Name, Zubereitung, Zutaten mit Menge, bei Bedarf direkt per Scan oder manuellem Formular neu angelegt) — die Nährwerte pro 100g werden automatisch aus den Zutaten berechnet. Ein Gericht taucht danach wie ein normales Produkt in der Suche auf (mit „Gericht“-Badge) und lässt sich genauso mengenbasiert loggen, bearbeiten oder löschen — praktisch für Rezepte, die man öfter kocht, oder als Inspiration. Rezepte sind privat und nur für den Ersteller sichtbar, lassen sich aber über „Teilen“ auf der Detailseite gezielt mit anderen registrierten Nutzern teilen (diese können das Rezept dann ansehen und loggen, aber nicht bearbeiten oder löschen).
|
9. Unter „Kochbuch“ lassen sich eigene Gerichte aus bereits bekannten Produkten zusammenstellen (Name, Zubereitung, Zutaten mit Menge, bei Bedarf direkt per Scan oder manuellem Formular neu angelegt) — die Nährwerte pro 100g werden automatisch aus den Zutaten berechnet. Ein Gericht taucht danach wie ein normales Produkt in der Suche auf (mit „Gericht“-Badge) und lässt sich genauso mengenbasiert loggen, bearbeiten oder löschen — praktisch für Rezepte, die man öfter kocht, oder als Inspiration. Rezepte sind privat und nur für den Ersteller sichtbar, lassen sich aber über „Teilen“ auf der Detailseite gezielt mit anderen registrierten Nutzern teilen (diese können das Rezept dann ansehen und loggen, aber nicht bearbeiten oder löschen).
|
||||||
|
|
||||||
|
## KI-Nährwertschätzung (optional)
|
||||||
|
|
||||||
|
Die Schätzfunktion spricht ein **OpenAI-kompatibles** Chat-Gateway an und wird rein über
|
||||||
|
`backend/.env` konfiguriert:
|
||||||
|
|
||||||
|
```
|
||||||
|
LLM_API_URL=https://ki-toolbox.scc.kit.edu/api/v1
|
||||||
|
LLM_API_KEY=dein-key
|
||||||
|
LLM_MODEL=kit.mistral-small-4-119b-a8b
|
||||||
|
```
|
||||||
|
|
||||||
|
Ohne gesetzten `LLM_API_KEY` ist das Feature einfach deaktiviert (der Endpunkt antwortet mit
|
||||||
|
`503`) — die restliche App funktioniert unverändert, Nährwerte lassen sich weiterhin manuell
|
||||||
|
eintragen. Weil die Konfiguration rein OpenAI-kompatibel ist, funktioniert genauso jedes andere
|
||||||
|
Gateway (OpenAI selbst, ein lokaler Ollama-Server mit OpenAI-API, …) durch bloßes Ändern von
|
||||||
|
`LLM_API_URL`/`LLM_MODEL`.
|
||||||
|
|
||||||
|
Als Default ist `kit.mistral-small-4-119b-a8b` gesetzt: In einem Vergleich der frei verfügbaren
|
||||||
|
`kit.*`-Modelle lieferte es als einziges durchgängig valides JSON **und** war mit ~4 s pro
|
||||||
|
Anfrage etwa drei- bis viermal schneller als die Alternativen (`kit.gpt-oss-120b` und
|
||||||
|
`kit.gemma4-31b-it` waren langsamer bzw. unzuverlässiger, `kit.qwen3.5-397b-A17b` lieferte
|
||||||
|
gar keinen verwertbaren Inhalt).
|
||||||
|
|
||||||
## Tech-Stack
|
## Tech-Stack
|
||||||
|
|
||||||
- Backend: FastAPI, SQLModel, SQLite, JWT (python-jose), passlib/bcrypt, httpx
|
- Backend: FastAPI, SQLModel, SQLite, JWT (python-jose), passlib/bcrypt, httpx, openai (LLM-Client)
|
||||||
- Frontend: React, Vite, TailwindCSS, react-router-dom, axios, html5-qrcode
|
- Frontend: React, Vite, TailwindCSS, react-router-dom, axios, html5-qrcode, recharts
|
||||||
- Deployment: Docker, nginx (Reverse Proxy + Static Hosting)
|
- Deployment: Docker, nginx (Reverse Proxy + Static Hosting)
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ class Settings(BaseSettings):
|
|||||||
algorithm: str = "HS256"
|
algorithm: str = "HS256"
|
||||||
access_token_expire_minutes: int = 60 * 24
|
access_token_expire_minutes: int = 60 * 24
|
||||||
database_url: str = "sqlite:///./kalorientracker.db"
|
database_url: str = "sqlite:///./kalorientracker.db"
|
||||||
|
llm_api_url: str = "https://ki-toolbox.scc.kit.edu/api/v1"
|
||||||
|
llm_api_key: str = ""
|
||||||
|
llm_model: str = "kit.mistral-small-4-119b-a8b"
|
||||||
|
|
||||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
|
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from openai import OpenAI
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
MAX_ATTEMPTS = 3
|
||||||
|
|
||||||
|
_client: OpenAI | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class LLMUnavailableError(RuntimeError):
|
||||||
|
"""Kein LLM konfiguriert oder alle Versuche fehlgeschlagen."""
|
||||||
|
|
||||||
|
|
||||||
|
def is_configured() -> bool:
|
||||||
|
return bool(settings.llm_api_key)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_client() -> OpenAI:
|
||||||
|
global _client
|
||||||
|
if _client is None:
|
||||||
|
# max_retries=0: Wiederholungen macht complete_json selbst, weil die
|
||||||
|
# KIT-Toolbox transiente Fehler als 400 meldet (siehe _request_json).
|
||||||
|
_client = OpenAI(
|
||||||
|
api_key=settings.llm_api_key,
|
||||||
|
base_url=settings.llm_api_url,
|
||||||
|
timeout=60.0,
|
||||||
|
max_retries=0,
|
||||||
|
)
|
||||||
|
return _client
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_json(content: str | None) -> dict:
|
||||||
|
if content is None:
|
||||||
|
raise ValueError("Antwort ohne Inhalt")
|
||||||
|
|
||||||
|
text = content.strip()
|
||||||
|
# Reasoning-Modelle stellen der Antwort teils einen <think>-Block voran.
|
||||||
|
if "</think>" in text:
|
||||||
|
text = text.split("</think>")[-1].strip()
|
||||||
|
if text.startswith("```"):
|
||||||
|
parts = text.split("```")
|
||||||
|
if len(parts) > 1:
|
||||||
|
text = parts[1]
|
||||||
|
if text.startswith("json"):
|
||||||
|
text = text[4:]
|
||||||
|
text = text.strip()
|
||||||
|
|
||||||
|
start, end = text.find("{"), text.rfind("}")
|
||||||
|
if start == -1 or end == -1:
|
||||||
|
raise ValueError("Kein JSON-Objekt in der Antwort")
|
||||||
|
return json.loads(text[start : end + 1])
|
||||||
|
|
||||||
|
|
||||||
|
def complete_json(system_prompt: str, user_prompt: str, max_tokens: int = 500) -> dict:
|
||||||
|
"""Fragt das LLM und gibt die geparste JSON-Antwort zurück.
|
||||||
|
|
||||||
|
Wiederholt bei JEDEM Fehler bis zu MAX_ATTEMPTS mal — auch bei HTTP 400.
|
||||||
|
Das ist normalerweise falsch (400 = Client-Fehler), hier aber nötig: die
|
||||||
|
KIT-Toolbox liefert sporadisch 400er mit gateway-internen Meldungen wie
|
||||||
|
"Model not found", "Function not found: token_usage_display" oder
|
||||||
|
psycopg-Verbindungsfehlern, die beim nächsten identischen Request weg sind.
|
||||||
|
"""
|
||||||
|
if not is_configured():
|
||||||
|
raise LLMUnavailableError("Kein LLM-API-Key konfiguriert")
|
||||||
|
|
||||||
|
last_error: Exception | None = None
|
||||||
|
for attempt in range(MAX_ATTEMPTS):
|
||||||
|
try:
|
||||||
|
response = _get_client().chat.completions.create(
|
||||||
|
model=settings.llm_model,
|
||||||
|
messages=[
|
||||||
|
{"role": "system", "content": system_prompt},
|
||||||
|
{"role": "user", "content": user_prompt},
|
||||||
|
],
|
||||||
|
temperature=0.2,
|
||||||
|
max_tokens=max_tokens,
|
||||||
|
response_format={"type": "json_object"},
|
||||||
|
)
|
||||||
|
return _extract_json(response.choices[0].message.content)
|
||||||
|
except Exception as error:
|
||||||
|
last_error = error
|
||||||
|
logger.warning(
|
||||||
|
"LLM-Anfrage fehlgeschlagen (Versuch %d/%d): %s",
|
||||||
|
attempt + 1,
|
||||||
|
MAX_ATTEMPTS,
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
|
||||||
|
raise LLMUnavailableError(f"LLM nach {MAX_ATTEMPTS} Versuchen nicht erreichbar: {last_error}")
|
||||||
@@ -2,17 +2,36 @@ from uuid import uuid4
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, HTTPException, Query, status
|
from fastapi import APIRouter, HTTPException, Query, status
|
||||||
|
from pydantic import ValidationError
|
||||||
from sqlmodel import select
|
from sqlmodel import select
|
||||||
|
|
||||||
|
from app import llm
|
||||||
from app.access import product_barcode_accessible
|
from app.access import product_barcode_accessible
|
||||||
from app.deps import CurrentUserDep, SessionDep
|
from app.deps import CurrentUserDep, SessionDep
|
||||||
from app.models import FoodLog, Product
|
from app.models import FoodLog, Product
|
||||||
from app.schemas import ProductCreate, ProductRead, ProductSearchResult
|
from app.schemas import (
|
||||||
|
NutritionEstimateRequest,
|
||||||
|
NutritionEstimateResponse,
|
||||||
|
ProductCreate,
|
||||||
|
ProductRead,
|
||||||
|
ProductSearchResult,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/products", tags=["products"])
|
router = APIRouter(prefix="/api/products", tags=["products"])
|
||||||
|
|
||||||
OFF_API_URL = "https://world.openfoodfacts.org/api/v2/product/{barcode}.json"
|
OFF_API_URL = "https://world.openfoodfacts.org/api/v2/product/{barcode}.json"
|
||||||
|
|
||||||
|
ESTIMATE_SYSTEM_PROMPT = """Du bist ein Ernährungsexperte. Schätze die Nährwerte des vom Nutzer beschriebenen Lebensmittels oder Gerichts.
|
||||||
|
|
||||||
|
Antworte AUSSCHLIESSLICH mit einem JSON-Objekt in genau diesem Format:
|
||||||
|
{"name": "kurzer Produktname", "calories": 250.0, "carbs": 30.0, "protein": 12.0, "fat": 9.0, "sugar": 3.0, "fiber": 2.0, "saturated_fat": 3.5, "salt": 1.2, "portion_g": 350.0}
|
||||||
|
|
||||||
|
Regeln:
|
||||||
|
- Alle Nährwerte beziehen sich auf 100 g (calories in kcal, alles andere in Gramm).
|
||||||
|
- portion_g ist die geschätzte übliche Portionsgröße der Beschreibung in Gramm.
|
||||||
|
- name ist ein kurzer, gut lesbarer deutscher Produktname ohne Mengenangabe.
|
||||||
|
- Nutze realistische Durchschnittswerte. Keine Erklärungen, kein Text außerhalb des JSON."""
|
||||||
|
|
||||||
|
|
||||||
def _extract_macros(nutriments: dict) -> dict:
|
def _extract_macros(nutriments: dict) -> dict:
|
||||||
calories = nutriments.get("energy-kcal_100g")
|
calories = nutriments.get("energy-kcal_100g")
|
||||||
@@ -71,6 +90,31 @@ def create_product(product_in: ProductCreate, current_user: CurrentUserDep, sess
|
|||||||
return product
|
return product
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/estimate", response_model=NutritionEstimateResponse)
|
||||||
|
def estimate_nutrition(estimate_in: NutritionEstimateRequest, current_user: CurrentUserDep):
|
||||||
|
if not llm.is_configured():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail="KI-Schätzung ist auf diesem Server nicht konfiguriert",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = llm.complete_json(ESTIMATE_SYSTEM_PROMPT, estimate_in.description)
|
||||||
|
except llm.LLMUnavailableError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail="KI-Schätzung gerade nicht erreichbar. Bitte später erneut versuchen.",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return NutritionEstimateResponse(**data)
|
||||||
|
except (ValidationError, TypeError):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail="KI-Antwort war unbrauchbar. Bitte erneut versuchen oder Werte manuell eintragen.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/search", response_model=list[ProductSearchResult])
|
@router.get("/search", response_model=list[ProductSearchResult])
|
||||||
def search_products(
|
def search_products(
|
||||||
current_user: CurrentUserDep,
|
current_user: CurrentUserDep,
|
||||||
|
|||||||
+18
-1
@@ -1,7 +1,7 @@
|
|||||||
from datetime import date as date_type
|
from datetime import date as date_type
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
from app.models import MealType
|
from app.models import MealType
|
||||||
|
|
||||||
@@ -66,6 +66,23 @@ class ProductSearchResult(ProductRead):
|
|||||||
is_dish: bool = False
|
is_dish: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class NutritionEstimateRequest(BaseModel):
|
||||||
|
description: str = Field(min_length=2, max_length=300)
|
||||||
|
|
||||||
|
|
||||||
|
class NutritionEstimateResponse(BaseModel):
|
||||||
|
name: str
|
||||||
|
calories: float = Field(ge=0, le=900)
|
||||||
|
carbs: float = Field(ge=0, le=100)
|
||||||
|
protein: float = Field(ge=0, le=100)
|
||||||
|
fat: float = Field(ge=0, le=100)
|
||||||
|
sugar: float = Field(default=0.0, ge=0, le=100)
|
||||||
|
fiber: float = Field(default=0.0, ge=0, le=100)
|
||||||
|
saturated_fat: float = Field(default=0.0, ge=0, le=100)
|
||||||
|
salt: float = Field(default=0.0, ge=0, le=100)
|
||||||
|
portion_g: float = Field(ge=1, le=5000)
|
||||||
|
|
||||||
|
|
||||||
class LogCreate(BaseModel):
|
class LogCreate(BaseModel):
|
||||||
barcode: str | None = None
|
barcode: str | None = None
|
||||||
name: str | None = None
|
name: str | None = None
|
||||||
|
|||||||
@@ -7,3 +7,4 @@ bcrypt==4.0.1
|
|||||||
python-multipart
|
python-multipart
|
||||||
pydantic-settings
|
pydantic-settings
|
||||||
httpx
|
httpx
|
||||||
|
openai
|
||||||
|
|||||||
@@ -29,11 +29,51 @@ export default function ProductNotFoundForm({
|
|||||||
const [mealType, setMealType] = useState(initialMealType || defaultMealType())
|
const [mealType, setMealType] = useState(initialMealType || defaultMealType())
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
const [description, setDescription] = useState('')
|
||||||
|
const [isEstimating, setIsEstimating] = useState(false)
|
||||||
|
const [estimateError, setEstimateError] = useState('')
|
||||||
|
const [wasEstimated, setWasEstimated] = useState(false)
|
||||||
|
|
||||||
function optionalDecimal(value) {
|
function optionalDecimal(value) {
|
||||||
return value === '' ? undefined : parseDecimal(value)
|
return value === '' ? undefined : parseDecimal(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatEstimate(value) {
|
||||||
|
return String(Math.round(value * 10) / 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleEstimate() {
|
||||||
|
if (description.trim().length < 2) return
|
||||||
|
setEstimateError('')
|
||||||
|
setIsEstimating(true)
|
||||||
|
try {
|
||||||
|
const { data } = await apiClient.post('/api/products/estimate', {
|
||||||
|
description: description.trim(),
|
||||||
|
})
|
||||||
|
setName(data.name)
|
||||||
|
setCalories(formatEstimate(data.calories))
|
||||||
|
setCarbs(formatEstimate(data.carbs))
|
||||||
|
setProtein(formatEstimate(data.protein))
|
||||||
|
setFat(formatEstimate(data.fat))
|
||||||
|
setSugar(formatEstimate(data.sugar))
|
||||||
|
setFiber(formatEstimate(data.fiber))
|
||||||
|
setSaturatedFat(formatEstimate(data.saturated_fat))
|
||||||
|
setSalt(formatEstimate(data.salt))
|
||||||
|
if (mode === 'log') {
|
||||||
|
setAmountG(formatEstimate(data.portion_g))
|
||||||
|
}
|
||||||
|
setWasEstimated(true)
|
||||||
|
} catch (err) {
|
||||||
|
setEstimateError(
|
||||||
|
err.response?.status === 503
|
||||||
|
? 'KI-Schätzung gerade nicht verfügbar.'
|
||||||
|
: 'Schätzung fehlgeschlagen. Bitte erneut versuchen.',
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setIsEstimating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSubmit(e) {
|
async function handleSubmit(e) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setError('')
|
setError('')
|
||||||
@@ -72,7 +112,7 @@ export default function ProductNotFoundForm({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/50 sm:items-center">
|
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/50 sm:items-center">
|
||||||
<div className="w-full max-w-sm rounded-t-2xl bg-white p-6 sm:rounded-2xl dark:bg-gray-800">
|
<div className="max-h-[90vh] w-full max-w-sm overflow-y-auto rounded-t-2xl bg-white p-6 sm:rounded-2xl dark:bg-gray-800">
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-50">
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-50">
|
||||||
{barcode ? 'Produkt nicht gefunden' : 'Neues Produkt anlegen'}
|
{barcode ? 'Produkt nicht gefunden' : 'Neues Produkt anlegen'}
|
||||||
</h2>
|
</h2>
|
||||||
@@ -82,6 +122,42 @@ export default function ProductNotFoundForm({
|
|||||||
: 'Bitte Nährwerte pro 100g eintragen'}
|
: 'Bitte Nährwerte pro 100g eintragen'}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<div className="mb-4 rounded-lg border border-dashed border-gray-300 p-3 dark:border-gray-600">
|
||||||
|
<label className={labelClass}>Nährwerte per KI schätzen (optional)</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
handleEstimate()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="z. B. Döner mit allem"
|
||||||
|
disabled={isEstimating}
|
||||||
|
className="min-w-0 flex-1 rounded-lg border border-gray-300 px-3 py-2 text-base text-gray-900 focus:border-emerald-500 focus:outline-none disabled:opacity-60 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-50"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleEstimate}
|
||||||
|
disabled={isEstimating || description.trim().length < 2}
|
||||||
|
className="shrink-0 rounded-lg bg-emerald-600 px-3 py-2 text-sm font-medium text-white active:bg-emerald-700 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{isEstimating ? '…' : 'Schätzen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{estimateError && (
|
||||||
|
<p className="mt-2 text-sm text-red-600 dark:text-red-400">{estimateError}</p>
|
||||||
|
)}
|
||||||
|
{wasEstimated && !estimateError && (
|
||||||
|
<p className="mt-2 text-xs text-amber-700 dark:text-amber-400">
|
||||||
|
Geschätzte Werte — bitte prüfen und bei Bedarf korrigieren.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-3">
|
<form onSubmit={handleSubmit} className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<label className={labelClass}>Name</label>
|
<label className={labelClass}>Name</label>
|
||||||
|
|||||||
Reference in New Issue
Block a user