Introcuced a Free LLM for easier food input
This commit is contained in:
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user