mindnet/app/services/llm_service.py

139 lines
4.7 KiB
Python

"""
app/services/llm_service.py — LLM Client (Ollama)
Version: 0.5.1 (Full: Retry Strategy + Chat Support + JSON Mode)
"""
import httpx
import yaml
import logging
import os
import asyncio
from pathlib import Path
from typing import Optional, Dict, Any
logger = logging.getLogger(__name__)
class Settings:
OLLAMA_URL = os.getenv("MINDNET_OLLAMA_URL", "http://127.0.0.1:11434")
# Timeout für den einzelnen Request (nicht für den gesamten Retry-Zyklus)
LLM_TIMEOUT = float(os.getenv("MINDNET_LLM_TIMEOUT", 300.0))
LLM_MODEL = os.getenv("MINDNET_LLM_MODEL", "phi3:mini")
PROMPTS_PATH = os.getenv("MINDNET_PROMPTS_PATH", "./config/prompts.yaml")
def get_settings():
return Settings()
class LLMService:
def __init__(self):
self.settings = get_settings()
self.prompts = self._load_prompts()
# Connection Limits erhöhen für Parallelität im Import
limits = httpx.Limits(max_keepalive_connections=5, max_connections=10)
self.client = httpx.AsyncClient(
base_url=self.settings.OLLAMA_URL,
timeout=self.settings.LLM_TIMEOUT,
limits=limits
)
def _load_prompts(self) -> dict:
path = Path(self.settings.PROMPTS_PATH)
if not path.exists():
return {}
try:
with open(path, "r", encoding="utf-8") as f:
return yaml.safe_load(f)
except Exception as e:
logger.error(f"Failed to load prompts: {e}")
return {}
async def generate_raw_response(
self,
prompt: str,
system: str = None,
force_json: bool = False,
max_retries: int = 0, # Standard: 0 (Chat failt sofort, Import nutzt >0)
base_delay: float = 5.0 # Start-Wartezeit für Backoff
) -> str:
"""
Führt einen LLM Call aus.
Features:
- JSON Mode (für Semantic Analyzer)
- System Prompt (für Persona)
- Aggressive Retry (für robusten Import bei Überlast)
"""
payload: Dict[str, Any] = {
"model": self.settings.LLM_MODEL,
"prompt": prompt,
"stream": False,
"options": {
# JSON braucht niedrige Temperature für valide Syntax
"temperature": 0.1 if force_json else 0.7,
"num_ctx": 4096
}
}
if force_json:
payload["format"] = "json"
if system:
payload["system"] = system
attempt = 0
# RETRY LOOP
while True:
try:
response = await self.client.post("/api/generate", json=payload)
if response.status_code == 200:
data = response.json()
return data.get("response", "").strip()
else:
# HTTP Fehler simulieren, um in den except-Block zu springen
response.raise_for_status()
except Exception as e:
# CATCH-ALL: Wir fangen Timeouts, Connection Errors UND Protokollfehler
attempt += 1
# Check: Haben wir noch Versuche?
if attempt > max_retries:
# Finaler Fehler (wird im Chat oder Log angezeigt)
logger.error(f"LLM Final Error (Versuch {attempt}): {e}")
return "Interner LLM Fehler."
# Backoff berechnen (5s, 10s, 20s, 40s...)
wait_time = base_delay * (2 ** (attempt - 1))
error_msg = str(e) if str(e) else repr(e)
logger.warning(
f"⚠️ LLM Fehler ({attempt}/{max_retries}). "
f"Warte {wait_time}s zur Abkühlung... Grund: {error_msg}"
)
# Warten und Loop wiederholen
await asyncio.sleep(wait_time)
async def generate_rag_response(self, query: str, context_str: str) -> str:
"""
WICHTIG FÜR CHAT:
Generiert eine Antwort basierend auf RAG-Kontext.
Nutzt KEINE Retries (User will nicht warten), KEIN JSON.
"""
system_prompt = self.prompts.get("system_prompt", "")
rag_template = self.prompts.get("rag_template", "{context_str}\n\n{query}")
final_prompt = rag_template.format(context_str=context_str, query=query)
# Chat-Call: force_json=False, max_retries=0
return await self.generate_raw_response(
final_prompt,
system=system_prompt,
max_retries=0
)
async def close(self):
if self.client:
await self.client.aclose()