From c40a142da2d5cc8b6e7905da956a7c722d150506 Mon Sep 17 00:00:00 2001 From: Jakob Fritz Date: Thu, 30 Jul 2026 09:06:25 +0200 Subject: [PATCH] Introcuced a Free LLM for easier food input --- AGENTS.md | 6 ++ README.md | 31 +++++- backend/app/core/config.py | 3 + backend/app/llm.py | 95 +++++++++++++++++++ backend/app/routers/products.py | 46 ++++++++- backend/app/schemas.py | 19 +++- backend/requirements.txt | 1 + .../src/components/ProductNotFoundForm.jsx | 78 ++++++++++++++- 8 files changed, 272 insertions(+), 7 deletions(-) create mode 100644 backend/app/llm.py diff --git a/AGENTS.md b/AGENTS.md index 22df489..71d5238 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 ``-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 ``-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. diff --git a/README.md b/README.md index de76174..80c9442 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ KalorienTracker/ └── 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). ## Setup @@ -74,15 +74,38 @@ Läuft auf `http://localhost:5173` und spricht per CORS direkt mit dem Backend a 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. 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. 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). 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). +## 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 -- Backend: FastAPI, SQLModel, SQLite, JWT (python-jose), passlib/bcrypt, httpx -- Frontend: React, Vite, TailwindCSS, react-router-dom, axios, html5-qrcode +- Backend: FastAPI, SQLModel, SQLite, JWT (python-jose), passlib/bcrypt, httpx, openai (LLM-Client) +- Frontend: React, Vite, TailwindCSS, react-router-dom, axios, html5-qrcode, recharts - Deployment: Docker, nginx (Reverse Proxy + Static Hosting) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 1820573..c236f96 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -8,6 +8,9 @@ class Settings(BaseSettings): algorithm: str = "HS256" access_token_expire_minutes: int = 60 * 24 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") diff --git a/backend/app/llm.py b/backend/app/llm.py new file mode 100644 index 0000000..c2c92c4 --- /dev/null +++ b/backend/app/llm.py @@ -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 -Block voran. + if "" in text: + text = text.split("")[-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}") diff --git a/backend/app/routers/products.py b/backend/app/routers/products.py index eb0f8cf..abe5132 100644 --- a/backend/app/routers/products.py +++ b/backend/app/routers/products.py @@ -2,17 +2,36 @@ from uuid import uuid4 import httpx from fastapi import APIRouter, HTTPException, Query, status +from pydantic import ValidationError from sqlmodel import select +from app import llm from app.access import product_barcode_accessible from app.deps import CurrentUserDep, SessionDep 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"]) 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: calories = nutriments.get("energy-kcal_100g") @@ -71,6 +90,31 @@ def create_product(product_in: ProductCreate, current_user: CurrentUserDep, sess 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]) def search_products( current_user: CurrentUserDep, diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 530996f..d9fe15f 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -1,7 +1,7 @@ from datetime import date as date_type from datetime import datetime -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, Field from app.models import MealType @@ -66,6 +66,23 @@ class ProductSearchResult(ProductRead): 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): barcode: str | None = None name: str | None = None diff --git a/backend/requirements.txt b/backend/requirements.txt index 8b4cd44..cf2f5a2 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -7,3 +7,4 @@ bcrypt==4.0.1 python-multipart pydantic-settings httpx +openai diff --git a/frontend/src/components/ProductNotFoundForm.jsx b/frontend/src/components/ProductNotFoundForm.jsx index 78458fd..13354dd 100644 --- a/frontend/src/components/ProductNotFoundForm.jsx +++ b/frontend/src/components/ProductNotFoundForm.jsx @@ -29,11 +29,51 @@ export default function ProductNotFoundForm({ const [mealType, setMealType] = useState(initialMealType || defaultMealType()) const [error, setError] = useState('') 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) { 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) { e.preventDefault() setError('') @@ -72,7 +112,7 @@ export default function ProductNotFoundForm({ return (
-
+

{barcode ? 'Produkt nicht gefunden' : 'Neues Produkt anlegen'}

@@ -82,6 +122,42 @@ export default function ProductNotFoundForm({ : 'Bitte Nährwerte pro 100g eintragen'}

+
+ +
+ 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" + /> + +
+ {estimateError && ( +

{estimateError}

+ )} + {wasEstimated && !estimateError && ( +

+ Geschätzte Werte — bitte prüfen und bei Bedarf korrigieren. +

+ )} +
+