mindnet/app/services/llm_service.py

133 lines
5.2 KiB
Python

"""
app/services/llm_service.py — LLM Client (Ollama)
Version:
0.2.0 (WP-06 Hybrid Router Support)
"""
import httpx
import yaml
import logging
import os
from pathlib import Path
from app.config import get_settings
logger = logging.getLogger(__name__)
class LLMService:
def __init__(self):
self.settings = get_settings()
self.prompts = self._load_prompts()
# Timeout aus Config nutzen (Default 120s)
self.client = httpx.AsyncClient(
base_url=self.settings.OLLAMA_URL,
timeout=self.settings.LLM_TIMEOUT
)
def _load_prompts(self) -> dict:
"""Lädt Prompts aus der konfigurierten YAML-Datei."""
path = Path(self.settings.PROMPTS_PATH)
if not path.exists():
logger.warning(f"Prompt config not found at {path}, using defaults.")
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) -> str:
"""
Führt einen direkten LLM Call ohne RAG-Template aus.
Ideal für Classification/Routing.
"""
payload = {
"model": self.settings.LLM_MODEL,
"prompt": prompt,
"stream": False,
"options": {
"temperature": 0.0, # Deterministisch für Routing!
"num_ctx": 512 # Kleines Fenster reicht für Classification
}
}
try:
response = await self.client.post("/api/generate", json=payload)
if response.status_code != 200:
logger.error(f"Ollama Error ({response.status_code}): {response.text}")
return "FACT" # Fallback bei Fehler
data = response.json()
return data.get("response", "").strip()
except Exception as e:
logger.error(f"LLM Raw Gen Error: {e}")
return "FACT"
async def generate_rag_response(self, query: str, context_str: str) -> str:
"""
Generiert eine Antwort basierend auf Query und Kontext (RAG).
"""
# Template hier ist nur Fallback, falls im Router nichts übergeben wird.
# Im Normalfall formatiert der Router den context_str bereits vor oder übergibt das Template.
# Hier nutzen wir simple substitution, da der Prompt meist schon vom Router aufbereitet ist
# oder wir nutzen das Standard-Template aus der YAML.
# HINWEIS: In der neuen Architektur (chat.py) wird das Template bereits VOR diesem Aufruf
# geladen und formatiert, und als 'prompt' übergeben?
# Nein, chat.py ruft generate_rag_response(query, context_str) auf.
# Wir müssen sicherstellen, dass wir das *richtige* Template nutzen.
# Da generate_rag_response aktuell KEINEN 'template_key' Parameter hat,
# gehen wir davon aus, dass 'context_str' bereits Instruktionen enthält ODER
# dass chat.py den Prompt komplett baut.
# Um die API sauber zu halten: chat.py übergibt jetzt den FERTIGEN Prompt als context_str?
# Nein, chat.py baut den Prompt mit replace().
# Wir ändern diese Methode leicht ab, um flexibler zu sein:
# Wir erwarten, dass der Aufrufer (chat.py) die volle Kontrolle hat.
# Damit es 100% zusammenpasst mit dem chat.py unten:
# chat.py baut den finalen Prompt selbst zusammen!
# Wir nutzen daher generate_raw_response eigentlich auch für RAG,
# ODER wir passen generate_rag_response an, dass es "dumm" ist.
# Legacy Support für generate_rag_response:
# Wir bauen den Prompt zusammen, falls noch nicht geschehen.
# ABER: chat.py in meiner Version unten macht das Template-Handling.
# Daher ist der sauberste Weg: chat.py ruft generate_raw_response auf für die Antwort!
# FIX für Kompatibilität: Wir leiten rag_response intern auf raw um,
# bauen aber vorher den Prompt, falls context_str übergeben wird.
# Da wir chat.py kontrollieren (siehe unten), ändern wir chat.py so,
# dass es generate_raw_response nutzt! Das ist viel sauberer.
# Diese Methode bleibt für Backward Compatibility.
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)
payload = {
"model": self.settings.LLM_MODEL,
"system": system_prompt,
"prompt": final_prompt,
"stream": False,
"options": {
"temperature": 0.7,
"num_ctx": 2048
}
}
try:
response = await self.client.post("/api/generate", json=payload)
if response.status_code != 200:
return f"Error: {response.text}"
return response.json().get("response", "")
except Exception as e:
return f"Error: {str(e)}"
async def close(self):
await self.client.aclose()