Files
CalTracker/backend/app/llm.py
T

96 lines
3.0 KiB
Python

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}")