# -*- coding: utf-8 -*- """ LLM NLP Provider Basisklasse. Gemeinsame Funktionalität für alle LLM-basierten NLP-Backends. Trennung von Intent-Erkennung und Antwort-Generierung. """ import json import re import time from abc import abstractmethod from typing import Any from trixy_core.nlp import ( NLPConfig, NLPContext, NLPProvider, NLPResult, NLPState, ) from trixy_core.utils.debug import pdebug, perror from plugins.nlp_llm.prompts.system import ( build_intent_recognition_prompt, build_response_generation_prompt, build_fallback_response_prompt, ) class LLMNLPProvider(NLPProvider): """ Abstrakte Basisklasse für LLM-basierte NLP-Provider. Implementiert zwei separate Phasen: 1. Intent-Erkennung: Text → Intent + Slots 2. Antwort-Generierung: Handler-Ergebnis → Natürliche Antwort Subklassen implementieren die eigentliche LLM-Kommunikation. """ def __init__(self) -> None: super().__init__() self._response_language = "de" async def initialize(self, config: NLPConfig) -> bool: """ Initialisiert den Provider. Args: config: NLP-Konfiguration Returns: True bei Erfolg """ self._config = config self._response_language = config.extra.get("response_language", "de") self._state = NLPState.INITIALIZING try: success = await self._initialize_backend(config) if success: self._state = NLPState.READY else: self._state = NLPState.ERROR return success except Exception as e: perror(f"Fehler bei Provider-Initialisierung: {e}") self._state = NLPState.ERROR return False @abstractmethod async def _initialize_backend(self, config: NLPConfig) -> bool: """ Initialisiert das spezifische LLM-Backend. Args: config: NLP-Konfiguration Returns: True bei Erfolg """ pass async def process(self, context: NLPContext) -> NLPResult: """ Phase 1: Intent-Erkennung. Verarbeitet Text und erkennt Intent + Slots. Generiert KEINE Antwort - das passiert in generate_response(). Args: context: NLP-Kontext Returns: NLP-Ergebnis mit Intent und Slots (OHNE response_text) """ if self._state != NLPState.READY: return NLPResult.failure("Provider nicht bereit") self._state = NLPState.PROCESSING start_time = time.time() try: # Intent-Erkennungs-Prompt erstellen # Token-Budget: Context-Window minus Platz fuer User-Text und Antwort max_intent_tokens = max(1000, (self._config.context_window - 1500) if self._config else 2500) system_prompt = build_intent_recognition_prompt( available_intents=context.available_intents, room_id=context.room_id, language=self._response_language, max_tokens=max_intent_tokens, ) # Nachrichtenliste erstellen messages = self._build_messages(system_prompt, context) # LLM-Anfrage für Intent-Erkennung raw_response = await self._generate(messages) # Intent-Antwort parsen (OHNE response_text) result = self._parse_intent_response(raw_response) result.processing_time = time.time() - start_time result.raw_output = raw_response self._state = NLPState.READY return result except Exception as e: self._state = NLPState.READY perror(f"Fehler bei Intent-Erkennung: {e}") return NLPResult.failure(str(e)) async def generate_response( self, intent: str, slots: dict[str, Any], handler_result: dict[str, Any], original_text: str, room_id: str = "", ) -> str: """ Phase 2: Antwort-Generierung. Wird NACH der Handler-Ausführung aufgerufen, um eine natürliche Antwort aus den Handler-Daten zu generieren. Args: intent: Erkannter Intent slots: Extrahierte Slots handler_result: Ergebnis des Intent-Handlers original_text: Ursprüngliche Benutzeranfrage room_id: Raum-ID Returns: Generierter Antworttext """ if self._state != NLPState.READY: return "Entschuldigung, ich kann gerade nicht antworten." self._state = NLPState.PROCESSING try: # Antwort-Generierungs-Prompt erstellen system_prompt = build_response_generation_prompt( intent=intent, slots=slots, handler_result=handler_result, original_text=original_text, room_id=room_id, language=self._response_language, ) messages = [ {"role": "system", "content": system_prompt}, {"role": "user", "content": "Generiere die Antwort."}, ] # LLM-Anfrage für Antwort-Generierung response = await self._generate(messages) self._state = NLPState.READY return response.strip() except Exception as e: self._state = NLPState.READY perror(f"Fehler bei Antwort-Generierung: {e}") return "Entschuldigung, bei der Verarbeitung ist ein Fehler aufgetreten." async def generate_fallback_response(self, original_text: str) -> str: """ Generiert eine Fallback-Antwort für unbekannte Intents. Args: original_text: Ursprüngliche Benutzeranfrage Returns: Generierter Fallback-Text """ if self._state != NLPState.READY: return "Entschuldigung, das habe ich nicht verstanden." self._state = NLPState.PROCESSING try: system_prompt = build_fallback_response_prompt( original_text=original_text, language=self._response_language, ) messages = [ {"role": "system", "content": system_prompt}, {"role": "user", "content": original_text}, ] response = await self._generate(messages) self._state = NLPState.READY return response.strip() except Exception as e: self._state = NLPState.READY perror(f"Fehler bei Fallback-Generierung: {e}") return "Entschuldigung, das habe ich leider nicht verstanden." def _build_messages( self, system_prompt: str, context: NLPContext ) -> list[dict[str, str]]: """ Erstellt die Nachrichtenliste für das LLM. Args: system_prompt: System-Prompt context: NLP-Kontext Returns: Liste von Nachrichten """ messages = [{"role": "system", "content": system_prompt}] # Konversationsverlauf hinzufügen (optional, für Follow-ups) history = context.get_conversation_history(max_turns=2) messages.extend(history) # Aktuelle Anfrage hinzufügen messages.append({"role": "user", "content": context.text}) return messages @abstractmethod async def _generate(self, messages: list[dict[str, str]]) -> str: """ Generiert eine Antwort vom LLM. Args: messages: Nachrichtenliste Returns: Rohtext-Antwort vom LLM """ pass def _parse_intent_response(self, raw_response: str) -> NLPResult: """ Parst die Intent-Erkennungs-Antwort. Erwartet JSON mit: intent, confidence, slots KEIN response-Feld! Args: raw_response: Rohtext-Antwort vom LLM Returns: NLPResult (ohne response_text) """ try: # JSON aus Antwort extrahieren json_data = self._extract_json(raw_response) if json_data is None: pdebug(f"Konnte kein JSON parsen: {raw_response[:200]}") return NLPResult( intent="unknown", confidence=0.3, success=True, ) return NLPResult( intent=json_data.get("intent", "unknown"), confidence=float(json_data.get("confidence", 0.5)), slots=json_data.get("slots", {}), response_text="", # Explizit leer - wird später generiert success=True, ) except Exception as e: pdebug(f"Fehler beim Parsen der Intent-Antwort: {e}") return NLPResult( intent="unknown", confidence=0.3, success=True, ) def _extract_json(self, text: str) -> dict[str, Any] | None: """ Extrahiert JSON aus Text. Args: text: Text mit möglichem JSON Returns: Geparstes JSON oder None """ # Versuche direktes JSON-Parsing try: return json.loads(text.strip()) except json.JSONDecodeError: pass # Suche nach JSON-Block in Markdown Code-Block code_block_pattern = r"```(?:json)?\s*(\{.*?\})\s*```" match = re.search(code_block_pattern, text, re.DOTALL) if match: try: return json.loads(match.group(1)) except json.JSONDecodeError: pass # Suche nach JSON-Objekt im Text json_pattern = r"\{[^{}]*\}" matches = re.findall(json_pattern, text, re.DOTALL) for match in matches: try: return json.loads(match) except json.JSONDecodeError: continue return None async def shutdown(self) -> None: """Fährt den Provider herunter.""" self._state = NLPState.SHUTDOWN await self._shutdown_backend() @abstractmethod async def _shutdown_backend(self) -> None: """Fährt das spezifische Backend herunter.""" pass def get_capabilities(self) -> list[str]: """Gibt die Provider-Fähigkeiten zurück.""" return ["intent", "response", "entity"] def supports_streaming(self) -> bool: """LLM-Provider könnten Streaming unterstützen.""" return False