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