| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357 |
- # -*- 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
|