mindnet/app/services/llm_service.py

324 lines
14 KiB
Python
Raw Normal View History

2025-12-08 10:32:57 +01:00
"""
2025-12-15 15:40:39 +01:00
FILE: app/services/llm_service.py
2025-12-23 15:55:06 +01:00
DESCRIPTION: Hybrid-Client für Ollama, Google GenAI (Gemini) und OpenRouter.
WP-25b: Implementierung der Lazy-Prompt-Orchestration (Modell-spezifisch).
VERSION: 3.5.5 (WP-25b: Prompt Orchestration & Full Resilience)
2025-12-23 15:55:06 +01:00
STATUS: Active
2025-12-26 10:33:40 +01:00
FIX:
- WP-25b: get_prompt() unterstützt Hierarchie: Model-ID -> Provider -> Default.
- WP-25b: generate_raw_response() unterstützt prompt_key + variables für Lazy-Formatting.
- WP-25a: Voller Erhalt der rekursiven Fallback-Kaskade und visited_profiles Schutz.
- WP-20: Restaurierung des internen Ollama-Retry-Loops für Hardware-Stabilität.
2025-12-08 10:32:57 +01:00
"""
import httpx
import yaml
import logging
2025-12-12 13:53:54 +01:00
import asyncio
2025-12-23 14:33:51 +01:00
import json
2025-12-23 15:09:47 +01:00
from google import genai
from google.genai import types
from openai import AsyncOpenAI
2025-12-08 10:32:57 +01:00
from pathlib import Path
from typing import Optional, Dict, Any, Literal
2025-12-23 14:33:51 +01:00
from app.config import get_settings
2025-12-08 10:32:57 +01:00
# Import der neutralen Bereinigungs-Logik
2025-12-27 19:12:14 +01:00
from app.core.registry import clean_llm_text
2025-12-08 10:32:57 +01:00
logger = logging.getLogger(__name__)
class LLMService:
2025-12-12 19:36:33 +01:00
_background_semaphore = None
2025-12-08 10:32:57 +01:00
def __init__(self):
self.settings = get_settings()
self.prompts = self._load_prompts()
self.profiles = self._load_llm_profiles()
self._decision_engine = None
2025-12-12 19:36:33 +01:00
if LLMService._background_semaphore is None:
2025-12-23 15:55:06 +01:00
limit = getattr(self.settings, "BACKGROUND_LIMIT", 2)
logger.info(f"🚦 LLMService: Initializing Background Semaphore with limit: {limit}")
2025-12-12 19:36:33 +01:00
LLMService._background_semaphore = asyncio.Semaphore(limit)
2025-12-23 15:09:47 +01:00
# 1. Lokaler Ollama Client
2025-12-23 14:33:51 +01:00
self.ollama_client = httpx.AsyncClient(
base_url=self.settings.OLLAMA_URL,
2025-12-23 15:09:47 +01:00
timeout=httpx.Timeout(self.settings.LLM_TIMEOUT)
)
2025-12-08 10:32:57 +01:00
# 2. Google GenAI Client
2025-12-23 15:09:47 +01:00
self.google_client = None
if self.settings.GOOGLE_API_KEY:
self.google_client = genai.Client(
api_key=self.settings.GOOGLE_API_KEY,
http_options={'api_version': 'v1'}
)
2025-12-23 15:09:47 +01:00
logger.info("✨ LLMService: Google GenAI (Gemini) active.")
# 3. OpenRouter Client
self.openrouter_client = None
if self.settings.OPENROUTER_API_KEY:
self.openrouter_client = AsyncOpenAI(
base_url="https://openrouter.ai/api/v1",
api_key=self.settings.OPENROUTER_API_KEY,
timeout=45.0
2025-12-23 15:09:47 +01:00
)
logger.info("🛰️ LLMService: OpenRouter Integration active.")
2025-12-23 14:33:51 +01:00
@property
def decision_engine(self):
if self._decision_engine is None:
from app.core.retrieval.decision_engine import DecisionEngine
self._decision_engine = DecisionEngine()
return self._decision_engine
2025-12-08 10:32:57 +01:00
def _load_prompts(self) -> dict:
path = Path(self.settings.PROMPTS_PATH)
2025-12-23 18:51:12 +01:00
if not path.exists():
return {}
2025-12-08 10:32:57 +01:00
try:
2025-12-23 18:51:12 +01:00
with open(path, "r", encoding="utf-8") as f:
return yaml.safe_load(f) or {}
2025-12-08 10:32:57 +01:00
except Exception as e:
2025-12-23 18:51:12 +01:00
logger.error(f"❌ Failed to load prompts: {e}")
2025-12-08 10:32:57 +01:00
return {}
def _load_llm_profiles(self) -> dict:
"""WP-25a: Lädt die zentralen MoE-Profile aus der llm_profiles.yaml."""
path_str = getattr(self.settings, "LLM_PROFILES_PATH", "config/llm_profiles.yaml")
path = Path(path_str)
if not path.exists():
logger.warning(f"⚠️ LLM Profiles file not found at {path}.")
return {}
try:
with open(path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
return data.get("profiles", {})
except Exception as e:
logger.error(f"❌ Failed to load llm_profiles.yaml: {e}")
return {}
def get_prompt(self, key: str, model_id: str = None, provider: str = None) -> str:
"""
WP-25b: Hochpräziser Prompt-Lookup.
Hierarchie: Exakte Modell-ID -> Provider-Name -> Globaler Default.
"""
2025-12-23 14:33:51 +01:00
data = self.prompts.get(key, "")
if not isinstance(data, dict):
return str(data)
# 1. Spezifischstes Match: Exakte Modell-ID (z.B. 'meta-llama/llama-3.3-70b-instruct:free')
if model_id and model_id in data:
return str(data[model_id])
# 2. Mittlere Ebene: Provider (z.B. 'ollama' oder 'openrouter')
if provider and provider in data:
return str(data[provider])
# 3. Fallback: Bekannte Keys oder Default aus prompts.yaml
return str(data.get("default", data.get("gemini", data.get("ollama", ""))))
2025-12-23 14:33:51 +01:00
2025-12-12 13:53:54 +01:00
async def generate_raw_response(
self,
prompt: str = None,
prompt_key: str = None, # WP-25b: Lazy Loading Key
variables: dict = None, # WP-25b: Daten für Formatierung
system: str = None,
force_json: bool = False,
max_retries: int = 2,
base_delay: float = 2.0,
2025-12-23 14:33:51 +01:00
priority: Literal["realtime", "background"] = "realtime",
2025-12-23 15:09:47 +01:00
provider: Optional[str] = None,
model_override: Optional[str] = None,
json_schema: Optional[Dict[str, Any]] = None,
json_schema_name: str = "mindnet_json",
strict_json_schema: bool = True,
profile_name: Optional[str] = None,
visited_profiles: Optional[list] = None
2025-12-12 13:53:54 +01:00
) -> str:
"""Haupteinstiegspunkt für LLM-Anfragen mit Lazy-Prompt Orchestrierung."""
visited_profiles = visited_profiles or []
target_provider = provider
target_model = model_override
target_temp = None
fallback_profile = None
# 1. Profil-Auflösung (Mixture of Experts)
if profile_name and self.profiles:
profile = self.profiles.get(profile_name)
if profile:
target_provider = profile.get("provider", target_provider)
target_model = profile.get("model", target_model)
target_temp = profile.get("temperature")
fallback_profile = profile.get("fallback_profile")
visited_profiles.append(profile_name)
logger.info(f"🎭 MoE Dispatch: Profil='{profile_name}' -> Provider='{target_provider}' | Model='{target_model}'")
else:
logger.warning(f"⚠️ Profil '{profile_name}' nicht in llm_profiles.yaml gefunden!")
if not target_provider:
target_provider = self.settings.MINDNET_LLM_PROVIDER
# 2. WP-25b: Lazy Prompt Resolving
# Wir laden den Prompt erst JETZT, basierend auf dem gerade aktiven Modell.
current_prompt = prompt
if prompt_key:
template = self.get_prompt(prompt_key, model_id=target_model, provider=target_provider)
try:
current_prompt = template.format(**(variables or {}))
except Exception as e:
logger.error(f"❌ Prompt formatting failed for key '{prompt_key}': {e}")
current_prompt = template # Sicherheits-Fallback
# 3. Ausführung mit Fehler-Handling für Kaskade
try:
if priority == "background":
async with LLMService._background_semaphore:
res = await self._dispatch(
target_provider, current_prompt, system, force_json,
max_retries, base_delay, target_model,
json_schema, json_schema_name, strict_json_schema, target_temp
)
else:
res = await self._dispatch(
target_provider, current_prompt, system, force_json,
max_retries, base_delay, target_model,
json_schema, json_schema_name, strict_json_schema, target_temp
)
# Check auf leere Cloud-Antworten (WP-25 Stability)
if not res and target_provider != "ollama":
logger.warning(f"⚠️ Empty response from {target_provider}. Triggering fallback.")
raise ValueError(f"Empty response from {target_provider}")
return clean_llm_text(res) if not force_json else res
2025-12-23 14:33:51 +01:00
except Exception as e:
logger.error(f"❌ Error during execution of profile '{profile_name}' ({target_provider}): {e}")
# 4. WP-25b Kaskaden-Logik (Rekursiv mit Modell-spezifischem Re-Loading)
if fallback_profile and fallback_profile not in visited_profiles:
logger.info(f"🔄 Switching to fallback profile: '{fallback_profile}'")
return await self.generate_raw_response(
prompt=prompt,
prompt_key=prompt_key,
variables=variables, # Ermöglicht neues Formatting für Fallback-Modell
system=system, force_json=force_json,
max_retries=max_retries, base_delay=base_delay,
priority=priority, provider=None, model_override=None,
json_schema=json_schema, json_schema_name=json_schema_name,
strict_json_schema=strict_json_schema,
profile_name=fallback_profile,
visited_profiles=visited_profiles
)
# 5. Ultimativer Notanker: Falls alles fehlschlägt, direkt zu Ollama
if target_provider != "ollama" and self.settings.LLM_FALLBACK_ENABLED:
logger.warning(f"🚨 Kaskade erschöpft. Nutze finalen Ollama-Notanker.")
res = await self._execute_ollama(current_prompt, system, force_json, max_retries, base_delay, target_temp, target_model)
return clean_llm_text(res) if not force_json else res
raise e
async def _dispatch(
self, provider, prompt, system, force_json, max_retries, base_delay,
model_override, json_schema, json_schema_name, strict_json_schema, temperature
) -> str:
"""Routet die Anfrage an den spezifischen Provider-Executor."""
rate_limit_attempts = 0
2025-12-26 10:33:40 +01:00
max_rate_retries = min(max_retries, getattr(self.settings, "LLM_RATE_LIMIT_RETRIES", 3))
wait_time = getattr(self.settings, "LLM_RATE_LIMIT_WAIT", 60.0)
while rate_limit_attempts <= max_rate_retries:
try:
if provider == "openrouter" and self.openrouter_client:
return await self._execute_openrouter(
prompt=prompt, system=system, force_json=force_json,
model_override=model_override, json_schema=json_schema,
json_schema_name=json_schema_name, strict_json_schema=strict_json_schema,
temperature=temperature
)
if provider == "gemini" and self.google_client:
return await self._execute_google(prompt, system, force_json, model_override, temperature)
return await self._execute_ollama(prompt, system, force_json, max_retries, base_delay, temperature, model_override)
except Exception as e:
err_str = str(e)
if any(x in err_str for x in ["429", "RESOURCE_EXHAUSTED", "rate_limited"]):
rate_limit_attempts += 1
logger.warning(f"⏳ Rate Limit {provider}. Attempt {rate_limit_attempts}. Wait {wait_time}s.")
await asyncio.sleep(wait_time)
2025-12-26 10:33:40 +01:00
continue
raise e
2025-12-23 14:33:51 +01:00
async def _execute_google(self, prompt, system, force_json, model_override, temperature):
model = (model_override or self.settings.GEMINI_MODEL).replace("models/", "")
config_kwargs = {
"system_instruction": system,
"response_mime_type": "application/json" if force_json else "text/plain"
}
if temperature is not None:
config_kwargs["temperature"] = temperature
config = types.GenerateContentConfig(**config_kwargs)
response = await asyncio.wait_for(
asyncio.to_thread(self.google_client.models.generate_content, model=model, contents=prompt, config=config),
timeout=45.0
2025-12-23 14:33:51 +01:00
)
return response.text.strip()
async def _execute_openrouter(self, prompt, system, force_json, model_override, json_schema, json_schema_name, strict_json_schema, temperature) -> str:
2025-12-23 18:51:12 +01:00
model = model_override or self.settings.OPENROUTER_MODEL
logger.info(f"🛰️ OpenRouter Call: Model='{model}' | Temp={temperature}")
2025-12-23 15:09:47 +01:00
messages = []
if system: messages.append({"role": "system", "content": system})
2025-12-23 15:09:47 +01:00
messages.append({"role": "user", "content": prompt})
kwargs: Dict[str, Any] = {}
if temperature is not None: kwargs["temperature"] = temperature
if force_json:
if json_schema:
kwargs["response_format"] = {"type": "json_schema", "json_schema": {"name": json_schema_name, "strict": strict_json_schema, "schema": json_schema}}
else:
kwargs["response_format"] = {"type": "json_object"}
response = await self.openrouter_client.chat.completions.create(model=model, messages=messages, **kwargs)
if not response.choices: return ""
return response.choices[0].message.content.strip() if response.choices[0].message.content else ""
2025-12-23 15:09:47 +01:00
async def _execute_ollama(self, prompt, system, force_json, max_retries, base_delay, temperature=None, model_override=None):
# WP-20: Restaurierter Retry-Loop für lokale Hardware-Resilienz
effective_model = model_override or self.settings.LLM_MODEL
effective_temp = temperature if temperature is not None else (0.1 if force_json else 0.7)
2025-12-23 15:09:47 +01:00
payload = {
"model": effective_model,
"prompt": prompt, "stream": False,
"options": {"temperature": effective_temp, "num_ctx": 8192}
}
if force_json: payload["format"] = "json"
if system: payload["system"] = system
2025-12-09 13:37:26 +01:00
2025-12-12 13:53:54 +01:00
attempt = 0
while True:
try:
2025-12-23 15:09:47 +01:00
res = await self.ollama_client.post("/api/generate", json=payload)
res.raise_for_status()
return res.json().get("response", "").strip()
2025-12-12 13:53:54 +01:00
except Exception as e:
attempt += 1
if attempt > max_retries:
logger.error(f"❌ Ollama failure after {attempt} attempts: {e}")
raise e
await asyncio.sleep(base_delay * (2 ** (attempt - 1)))
async def generate_rag_response(self, query: str, context_str: Optional[str] = None) -> str:
return await self.decision_engine.ask(query)
2025-12-08 10:32:57 +01:00
async def close(self):
2025-12-23 15:55:06 +01:00
if self.ollama_client:
await self.ollama_client.aclose()