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.
|
2026-01-02 08:57:29 +01:00
|
|
|
|
WP-25a: Implementierung der Mixture of Experts (MoE) Kaskaden-Steuerung.
|
|
|
|
|
|
VERSION: 3.5.2 (WP-25a: MoE & Fallback Cascade Support)
|
2025-12-23 15:55:06 +01:00
|
|
|
|
STATUS: Active
|
2025-12-26 10:33:40 +01:00
|
|
|
|
FIX:
|
2026-01-02 08:57:29 +01:00
|
|
|
|
- WP-25a: Implementierung der rekursiven Fallback-Kaskade via fallback_profile.
|
|
|
|
|
|
- WP-25a: Schutz gegen zirkuläre Profil-Referenzen (visited_profiles).
|
|
|
|
|
|
- WP-25a: Erweitertes Logging für Tracing der Experten-Entscheidungen.
|
|
|
|
|
|
- Erhalt der Ingest-Stability (WP-25) und des Rate-Limit-Managements.
|
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
|
2026-01-02 07:04:43 +01:00
|
|
|
|
from openai import AsyncOpenAI
|
2025-12-08 10:32:57 +01:00
|
|
|
|
from pathlib import Path
|
2025-12-12 19:00:35 +01:00
|
|
|
|
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
|
|
|
|
|
2026-01-02 07:04:43 +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-27 18:59:38 +01:00
|
|
|
|
|
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-12 19:00:35 +01:00
|
|
|
|
|
2025-12-08 10:32:57 +01:00
|
|
|
|
def __init__(self):
|
|
|
|
|
|
self.settings = get_settings()
|
|
|
|
|
|
self.prompts = self._load_prompts()
|
2026-01-01 07:52:41 +01:00
|
|
|
|
|
2026-01-02 07:04:43 +01:00
|
|
|
|
# WP-25a: Zentrale Experten-Profile laden
|
|
|
|
|
|
self.profiles = self._load_llm_profiles()
|
|
|
|
|
|
|
2026-01-01 07:52:41 +01:00
|
|
|
|
self._decision_engine = None
|
2025-12-24 07:47:45 +01:00
|
|
|
|
|
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-24 07:47:45 +01:00
|
|
|
|
|
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(
|
2025-12-24 07:47:45 +01:00
|
|
|
|
base_url=self.settings.OLLAMA_URL,
|
2025-12-23 15:09:47 +01:00
|
|
|
|
timeout=httpx.Timeout(self.settings.LLM_TIMEOUT)
|
2025-12-09 13:08:04 +01:00
|
|
|
|
)
|
2025-12-08 10:32:57 +01:00
|
|
|
|
|
2026-01-02 07:04:43 +01:00
|
|
|
|
# 2. Google GenAI Client
|
2025-12-23 15:09:47 +01:00
|
|
|
|
self.google_client = None
|
|
|
|
|
|
if self.settings.GOOGLE_API_KEY:
|
2025-12-25 13:03:42 +01:00
|
|
|
|
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",
|
2025-12-25 13:03:42 +01:00
|
|
|
|
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
|
|
|
|
|
2026-01-01 07:52:41 +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 {}
|
|
|
|
|
|
|
2026-01-02 07:04:43 +01:00
|
|
|
|
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}. System will use .env defaults.")
|
|
|
|
|
|
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 {}
|
|
|
|
|
|
|
2025-12-23 14:33:51 +01:00
|
|
|
|
def get_prompt(self, key: str, provider: str = None) -> str:
|
2025-12-23 15:09:47 +01:00
|
|
|
|
active_provider = provider or self.settings.MINDNET_LLM_PROVIDER
|
2025-12-23 14:33:51 +01:00
|
|
|
|
data = self.prompts.get(key, "")
|
|
|
|
|
|
if isinstance(data, dict):
|
2025-12-23 18:51:12 +01:00
|
|
|
|
val = data.get(active_provider, data.get("gemini", data.get("ollama", "")))
|
|
|
|
|
|
return str(val)
|
2025-12-23 14:33:51 +01:00
|
|
|
|
return str(data)
|
|
|
|
|
|
|
2025-12-12 13:53:54 +01:00
|
|
|
|
async def generate_raw_response(
|
2025-12-24 07:47:45 +01:00
|
|
|
|
self,
|
|
|
|
|
|
prompt: str,
|
|
|
|
|
|
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,
|
2025-12-24 07:47:45 +01:00
|
|
|
|
model_override: Optional[str] = None,
|
|
|
|
|
|
json_schema: Optional[Dict[str, Any]] = None,
|
|
|
|
|
|
json_schema_name: str = "mindnet_json",
|
2026-01-02 07:04:43 +01:00
|
|
|
|
strict_json_schema: bool = True,
|
2026-01-02 08:57:29 +01:00
|
|
|
|
profile_name: Optional[str] = None,
|
|
|
|
|
|
visited_profiles: Optional[list] = None
|
2025-12-12 13:53:54 +01:00
|
|
|
|
) -> str:
|
2025-12-24 07:47:45 +01:00
|
|
|
|
"""
|
2026-01-02 08:57:29 +01:00
|
|
|
|
Haupteinstiegspunkt für LLM-Anfragen mit rekursiver Kaskaden-Logik.
|
2025-12-24 07:47:45 +01:00
|
|
|
|
"""
|
2026-01-02 08:57:29 +01:00
|
|
|
|
visited_profiles = visited_profiles or []
|
2026-01-02 07:04:43 +01:00
|
|
|
|
target_provider = provider
|
|
|
|
|
|
target_model = model_override
|
|
|
|
|
|
target_temp = None
|
2026-01-02 08:57:29 +01:00
|
|
|
|
fallback_profile = None
|
2026-01-02 07:04:43 +01:00
|
|
|
|
|
2026-01-02 08:57:29 +01:00
|
|
|
|
# 1. Profil-Auflösung
|
2026-01-02 07:04:43 +01:00
|
|
|
|
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")
|
2026-01-02 08:57:29 +01:00
|
|
|
|
fallback_profile = profile.get("fallback_profile")
|
|
|
|
|
|
visited_profiles.append(profile_name)
|
2026-01-02 07:26:24 +01:00
|
|
|
|
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!")
|
2026-01-02 07:04:43 +01:00
|
|
|
|
|
|
|
|
|
|
# Fallback auf Standard-Provider falls nichts übergeben/definiert wurde
|
|
|
|
|
|
if not target_provider:
|
|
|
|
|
|
target_provider = self.settings.MINDNET_LLM_PROVIDER
|
2026-01-02 07:26:24 +01:00
|
|
|
|
logger.info(f"ℹ️ Kein Provider/Profil definiert. Nutze Default: {target_provider}")
|
2025-12-24 07:47:45 +01:00
|
|
|
|
|
2026-01-02 08:57:29 +01:00
|
|
|
|
# 2. Ausführung mit Fehler-Handling für Kaskade
|
|
|
|
|
|
try:
|
|
|
|
|
|
if priority == "background":
|
|
|
|
|
|
async with LLMService._background_semaphore:
|
|
|
|
|
|
res = await self._dispatch(
|
|
|
|
|
|
target_provider, prompt, system, force_json,
|
|
|
|
|
|
max_retries, base_delay, target_model,
|
|
|
|
|
|
json_schema, json_schema_name, strict_json_schema, target_temp
|
|
|
|
|
|
)
|
|
|
|
|
|
else:
|
2025-12-27 18:59:38 +01:00
|
|
|
|
res = await self._dispatch(
|
2025-12-25 13:03:42 +01:00
|
|
|
|
target_provider, prompt, system, force_json,
|
2026-01-02 07:04:43 +01:00
|
|
|
|
max_retries, base_delay, target_model,
|
|
|
|
|
|
json_schema, json_schema_name, strict_json_schema, target_temp
|
2025-12-24 07:47:45 +01:00
|
|
|
|
)
|
2026-01-01 07:52:41 +01:00
|
|
|
|
|
2026-01-02 08:57:29 +01:00
|
|
|
|
# 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 chain.")
|
|
|
|
|
|
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
|
|
|
|
|
2026-01-02 08:57:29 +01:00
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Error during execution of profile '{profile_name}' ({target_provider}): {e}")
|
|
|
|
|
|
|
|
|
|
|
|
# 3. Kaskaden-Logik: Nächstes Profil in der Kette versuchen
|
|
|
|
|
|
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, system=system, force_json=force_json,
|
|
|
|
|
|
max_retries=max_retries, base_delay=base_delay,
|
|
|
|
|
|
priority=priority, provider=provider, model_override=model_override,
|
|
|
|
|
|
json_schema=json_schema, json_schema_name=json_schema_name,
|
|
|
|
|
|
strict_json_schema=strict_json_schema,
|
|
|
|
|
|
profile_name=fallback_profile,
|
|
|
|
|
|
visited_profiles=visited_profiles
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# 4. 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(prompt, system, force_json, max_retries, base_delay)
|
|
|
|
|
|
return clean_llm_text(res) if not force_json else res
|
|
|
|
|
|
|
|
|
|
|
|
raise e
|
2025-12-24 07:47:45 +01:00
|
|
|
|
|
|
|
|
|
|
async def _dispatch(
|
|
|
|
|
|
self,
|
|
|
|
|
|
provider: str,
|
|
|
|
|
|
prompt: str,
|
|
|
|
|
|
system: Optional[str],
|
|
|
|
|
|
force_json: bool,
|
|
|
|
|
|
max_retries: int,
|
|
|
|
|
|
base_delay: float,
|
|
|
|
|
|
model_override: Optional[str],
|
|
|
|
|
|
json_schema: Optional[Dict[str, Any]],
|
|
|
|
|
|
json_schema_name: str,
|
2026-01-02 07:04:43 +01:00
|
|
|
|
strict_json_schema: bool,
|
2026-01-02 08:57:29 +01:00
|
|
|
|
temperature: Optional[float] = None
|
2025-12-24 07:47:45 +01:00
|
|
|
|
) -> str:
|
2026-01-02 08:57:29 +01:00
|
|
|
|
"""Routet die Anfrage an den spezifischen Provider-Executor."""
|
2025-12-25 13:03:42 +01:00
|
|
|
|
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))
|
2025-12-25 13:03:42 +01:00
|
|
|
|
wait_time = getattr(self.settings, "LLM_RATE_LIMIT_WAIT", 60.0)
|
2025-12-24 07:47:45 +01:00
|
|
|
|
|
2025-12-25 13:03:42 +01:00
|
|
|
|
while rate_limit_attempts <= max_rate_retries:
|
|
|
|
|
|
try:
|
|
|
|
|
|
if provider == "openrouter" and self.openrouter_client:
|
|
|
|
|
|
return await self._execute_openrouter(
|
2026-01-02 07:04:43 +01:00
|
|
|
|
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
|
2025-12-25 13:03:42 +01:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if provider == "gemini" and self.google_client:
|
2026-01-02 07:04:43 +01:00
|
|
|
|
return await self._execute_google(prompt, system, force_json, model_override, temperature)
|
2025-12-25 13:03:42 +01:00
|
|
|
|
|
2026-01-02 07:04:43 +01:00
|
|
|
|
return await self._execute_ollama(prompt, system, force_json, max_retries, base_delay, temperature)
|
2025-12-25 13:03:42 +01:00
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
err_str = str(e)
|
2026-01-02 08:57:29 +01:00
|
|
|
|
# Rate-Limit Handling (429)
|
2026-01-02 07:04:43 +01:00
|
|
|
|
if any(x in err_str for x in ["429", "RESOURCE_EXHAUSTED", "rate_limited"]):
|
2025-12-25 13:03:42 +01:00
|
|
|
|
rate_limit_attempts += 1
|
2026-01-02 07:04:43 +01:00
|
|
|
|
logger.warning(f"⏳ Rate Limit {provider}. Attempt {rate_limit_attempts}. Wait {wait_time}s.")
|
2025-12-25 13:03:42 +01:00
|
|
|
|
await asyncio.sleep(wait_time)
|
2025-12-26 10:33:40 +01:00
|
|
|
|
continue
|
2026-01-02 08:57:29 +01:00
|
|
|
|
# Andere Fehler werden an generate_raw_response für die Kaskade gereicht
|
2025-12-25 13:03:42 +01:00
|
|
|
|
raise e
|
2025-12-23 14:33:51 +01:00
|
|
|
|
|
2026-01-02 07:04:43 +01:00
|
|
|
|
async def _execute_google(self, prompt, system, force_json, model_override, temperature):
|
2025-12-23 15:09:47 +01:00
|
|
|
|
model = model_override or self.settings.GEMINI_MODEL
|
2025-12-25 13:03:42 +01:00
|
|
|
|
clean_model = model.replace("models/", "")
|
|
|
|
|
|
|
2026-01-02 07:04:43 +01:00
|
|
|
|
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)
|
|
|
|
|
|
|
2025-12-25 13:03:42 +01:00
|
|
|
|
response = await asyncio.wait_for(
|
|
|
|
|
|
asyncio.to_thread(
|
|
|
|
|
|
self.google_client.models.generate_content,
|
|
|
|
|
|
model=clean_model, contents=prompt, config=config
|
|
|
|
|
|
),
|
|
|
|
|
|
timeout=45.0
|
2025-12-23 14:33:51 +01:00
|
|
|
|
)
|
|
|
|
|
|
return response.text.strip()
|
2025-12-12 19:00:35 +01:00
|
|
|
|
|
2025-12-24 07:47:45 +01:00
|
|
|
|
async def _execute_openrouter(
|
2026-01-02 07:04:43 +01:00
|
|
|
|
self, prompt: str, system: Optional[str], force_json: bool,
|
|
|
|
|
|
model_override: Optional[str], json_schema: Optional[Dict[str, Any]] = None,
|
|
|
|
|
|
json_schema_name: str = "mindnet_json", strict_json_schema: bool = True,
|
|
|
|
|
|
temperature: Optional[float] = None
|
2025-12-24 07:47:45 +01:00
|
|
|
|
) -> str:
|
2025-12-23 18:51:12 +01:00
|
|
|
|
model = model_override or self.settings.OPENROUTER_MODEL
|
2026-01-02 07:26:24 +01:00
|
|
|
|
logger.info(f"🛰️ OpenRouter Call: Model='{model}' | Temp={temperature}")
|
2025-12-23 15:09:47 +01:00
|
|
|
|
messages = []
|
2026-01-02 07:04:43 +01:00
|
|
|
|
if system: messages.append({"role": "system", "content": system})
|
2025-12-23 15:09:47 +01:00
|
|
|
|
messages.append({"role": "user", "content": prompt})
|
2025-12-24 07:47:45 +01:00
|
|
|
|
|
|
|
|
|
|
kwargs: Dict[str, Any] = {}
|
2026-01-02 07:04:43 +01:00
|
|
|
|
if temperature is not None:
|
|
|
|
|
|
kwargs["temperature"] = temperature
|
|
|
|
|
|
|
2025-12-24 07:47:45 +01:00
|
|
|
|
if force_json:
|
|
|
|
|
|
if json_schema:
|
|
|
|
|
|
kwargs["response_format"] = {
|
|
|
|
|
|
"type": "json_schema",
|
2026-01-02 07:04:43 +01:00
|
|
|
|
"json_schema": {"name": json_schema_name, "strict": strict_json_schema, "schema": json_schema}
|
2025-12-24 07:47:45 +01:00
|
|
|
|
}
|
|
|
|
|
|
else:
|
|
|
|
|
|
kwargs["response_format"] = {"type": "json_object"}
|
|
|
|
|
|
|
2025-12-23 15:09:47 +01:00
|
|
|
|
response = await self.openrouter_client.chat.completions.create(
|
2026-01-02 07:04:43 +01:00
|
|
|
|
model=model, messages=messages, **kwargs
|
2025-12-23 15:09:47 +01:00
|
|
|
|
)
|
2026-01-01 08:31:15 +01:00
|
|
|
|
|
2026-01-02 07:04:43 +01:00
|
|
|
|
if not response.choices:
|
2026-01-01 08:31:15 +01:00
|
|
|
|
return ""
|
|
|
|
|
|
|
|
|
|
|
|
return response.choices[0].message.content.strip() if response.choices[0].message.content else ""
|
2025-12-23 15:09:47 +01:00
|
|
|
|
|
2026-01-02 07:04:43 +01:00
|
|
|
|
async def _execute_ollama(self, prompt, system, force_json, max_retries, base_delay, temperature=None):
|
2026-01-02 08:57:29 +01:00
|
|
|
|
# Nutzt Profil-Temperatur oder strikte Defaults für lokale Hardware-Schonung
|
2026-01-02 07:04:43 +01:00
|
|
|
|
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 = {
|
2025-12-23 15:55:06 +01:00
|
|
|
|
"model": self.settings.LLM_MODEL,
|
|
|
|
|
|
"prompt": prompt,
|
|
|
|
|
|
"stream": False,
|
2026-01-02 07:04:43 +01:00
|
|
|
|
"options": {"temperature": effective_temp, "num_ctx": 8192}
|
2025-12-09 13:08:04 +01:00
|
|
|
|
}
|
2026-01-01 08:31:15 +01:00
|
|
|
|
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
|
2026-01-02 08:57:29 +01:00
|
|
|
|
if attempt > max_retries:
|
|
|
|
|
|
logger.error(f"❌ Ollama final failure after {attempt} attempts: {e}")
|
|
|
|
|
|
raise e
|
2026-01-02 07:04:43 +01:00
|
|
|
|
await asyncio.sleep(base_delay * (2 ** (attempt - 1)))
|
2025-12-09 13:08:04 +01:00
|
|
|
|
|
2026-01-01 07:52:41 +01:00
|
|
|
|
async def generate_rag_response(self, query: str, context_str: Optional[str] = None) -> str:
|
2026-01-01 08:31:15 +01:00
|
|
|
|
"""WP-25: Orchestrierung via DecisionEngine."""
|
2026-01-01 07:52:41 +01:00
|
|
|
|
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:
|
2025-12-25 13:03:42 +01:00
|
|
|
|
await self.ollama_client.aclose()
|