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
+3
View File
@@ -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")
+95
View File
@@ -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}")
+45 -1
View File
@@ -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,
+18 -1
View File
@@ -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