provider.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357
  1. # -*- coding: utf-8 -*-
  2. """
  3. LLM NLP Provider Basisklasse.
  4. Gemeinsame Funktionalität für alle LLM-basierten NLP-Backends.
  5. Trennung von Intent-Erkennung und Antwort-Generierung.
  6. """
  7. import json
  8. import re
  9. import time
  10. from abc import abstractmethod
  11. from typing import Any
  12. from trixy_core.nlp import (
  13. NLPConfig,
  14. NLPContext,
  15. NLPProvider,
  16. NLPResult,
  17. NLPState,
  18. )
  19. from trixy_core.utils.debug import pdebug, perror
  20. from plugins.nlp_llm.prompts.system import (
  21. build_intent_recognition_prompt,
  22. build_response_generation_prompt,
  23. build_fallback_response_prompt,
  24. )
  25. class LLMNLPProvider(NLPProvider):
  26. """
  27. Abstrakte Basisklasse für LLM-basierte NLP-Provider.
  28. Implementiert zwei separate Phasen:
  29. 1. Intent-Erkennung: Text → Intent + Slots
  30. 2. Antwort-Generierung: Handler-Ergebnis → Natürliche Antwort
  31. Subklassen implementieren die eigentliche LLM-Kommunikation.
  32. """
  33. def __init__(self) -> None:
  34. super().__init__()
  35. self._response_language = "de"
  36. async def initialize(self, config: NLPConfig) -> bool:
  37. """
  38. Initialisiert den Provider.
  39. Args:
  40. config: NLP-Konfiguration
  41. Returns:
  42. True bei Erfolg
  43. """
  44. self._config = config
  45. self._response_language = config.extra.get("response_language", "de")
  46. self._state = NLPState.INITIALIZING
  47. try:
  48. success = await self._initialize_backend(config)
  49. if success:
  50. self._state = NLPState.READY
  51. else:
  52. self._state = NLPState.ERROR
  53. return success
  54. except Exception as e:
  55. perror(f"Fehler bei Provider-Initialisierung: {e}")
  56. self._state = NLPState.ERROR
  57. return False
  58. @abstractmethod
  59. async def _initialize_backend(self, config: NLPConfig) -> bool:
  60. """
  61. Initialisiert das spezifische LLM-Backend.
  62. Args:
  63. config: NLP-Konfiguration
  64. Returns:
  65. True bei Erfolg
  66. """
  67. pass
  68. async def process(self, context: NLPContext) -> NLPResult:
  69. """
  70. Phase 1: Intent-Erkennung.
  71. Verarbeitet Text und erkennt Intent + Slots.
  72. Generiert KEINE Antwort - das passiert in generate_response().
  73. Args:
  74. context: NLP-Kontext
  75. Returns:
  76. NLP-Ergebnis mit Intent und Slots (OHNE response_text)
  77. """
  78. if self._state != NLPState.READY:
  79. return NLPResult.failure("Provider nicht bereit")
  80. self._state = NLPState.PROCESSING
  81. start_time = time.time()
  82. try:
  83. # Intent-Erkennungs-Prompt erstellen
  84. # Token-Budget: Context-Window minus Platz fuer User-Text und Antwort
  85. max_intent_tokens = max(1000, (self._config.context_window - 1500) if self._config else 2500)
  86. system_prompt = build_intent_recognition_prompt(
  87. available_intents=context.available_intents,
  88. room_id=context.room_id,
  89. language=self._response_language,
  90. max_tokens=max_intent_tokens,
  91. )
  92. # Nachrichtenliste erstellen
  93. messages = self._build_messages(system_prompt, context)
  94. # LLM-Anfrage für Intent-Erkennung
  95. raw_response = await self._generate(messages)
  96. # Intent-Antwort parsen (OHNE response_text)
  97. result = self._parse_intent_response(raw_response)
  98. result.processing_time = time.time() - start_time
  99. result.raw_output = raw_response
  100. self._state = NLPState.READY
  101. return result
  102. except Exception as e:
  103. self._state = NLPState.READY
  104. perror(f"Fehler bei Intent-Erkennung: {e}")
  105. return NLPResult.failure(str(e))
  106. async def generate_response(
  107. self,
  108. intent: str,
  109. slots: dict[str, Any],
  110. handler_result: dict[str, Any],
  111. original_text: str,
  112. room_id: str = "",
  113. ) -> str:
  114. """
  115. Phase 2: Antwort-Generierung.
  116. Wird NACH der Handler-Ausführung aufgerufen, um eine
  117. natürliche Antwort aus den Handler-Daten zu generieren.
  118. Args:
  119. intent: Erkannter Intent
  120. slots: Extrahierte Slots
  121. handler_result: Ergebnis des Intent-Handlers
  122. original_text: Ursprüngliche Benutzeranfrage
  123. room_id: Raum-ID
  124. Returns:
  125. Generierter Antworttext
  126. """
  127. if self._state != NLPState.READY:
  128. return "Entschuldigung, ich kann gerade nicht antworten."
  129. self._state = NLPState.PROCESSING
  130. try:
  131. # Antwort-Generierungs-Prompt erstellen
  132. system_prompt = build_response_generation_prompt(
  133. intent=intent,
  134. slots=slots,
  135. handler_result=handler_result,
  136. original_text=original_text,
  137. room_id=room_id,
  138. language=self._response_language,
  139. )
  140. messages = [
  141. {"role": "system", "content": system_prompt},
  142. {"role": "user", "content": "Generiere die Antwort."},
  143. ]
  144. # LLM-Anfrage für Antwort-Generierung
  145. response = await self._generate(messages)
  146. self._state = NLPState.READY
  147. return response.strip()
  148. except Exception as e:
  149. self._state = NLPState.READY
  150. perror(f"Fehler bei Antwort-Generierung: {e}")
  151. return "Entschuldigung, bei der Verarbeitung ist ein Fehler aufgetreten."
  152. async def generate_fallback_response(self, original_text: str) -> str:
  153. """
  154. Generiert eine Fallback-Antwort für unbekannte Intents.
  155. Args:
  156. original_text: Ursprüngliche Benutzeranfrage
  157. Returns:
  158. Generierter Fallback-Text
  159. """
  160. if self._state != NLPState.READY:
  161. return "Entschuldigung, das habe ich nicht verstanden."
  162. self._state = NLPState.PROCESSING
  163. try:
  164. system_prompt = build_fallback_response_prompt(
  165. original_text=original_text,
  166. language=self._response_language,
  167. )
  168. messages = [
  169. {"role": "system", "content": system_prompt},
  170. {"role": "user", "content": original_text},
  171. ]
  172. response = await self._generate(messages)
  173. self._state = NLPState.READY
  174. return response.strip()
  175. except Exception as e:
  176. self._state = NLPState.READY
  177. perror(f"Fehler bei Fallback-Generierung: {e}")
  178. return "Entschuldigung, das habe ich leider nicht verstanden."
  179. def _build_messages(
  180. self, system_prompt: str, context: NLPContext
  181. ) -> list[dict[str, str]]:
  182. """
  183. Erstellt die Nachrichtenliste für das LLM.
  184. Args:
  185. system_prompt: System-Prompt
  186. context: NLP-Kontext
  187. Returns:
  188. Liste von Nachrichten
  189. """
  190. messages = [{"role": "system", "content": system_prompt}]
  191. # Konversationsverlauf hinzufügen (optional, für Follow-ups)
  192. history = context.get_conversation_history(max_turns=2)
  193. messages.extend(history)
  194. # Aktuelle Anfrage hinzufügen
  195. messages.append({"role": "user", "content": context.text})
  196. return messages
  197. @abstractmethod
  198. async def _generate(self, messages: list[dict[str, str]]) -> str:
  199. """
  200. Generiert eine Antwort vom LLM.
  201. Args:
  202. messages: Nachrichtenliste
  203. Returns:
  204. Rohtext-Antwort vom LLM
  205. """
  206. pass
  207. def _parse_intent_response(self, raw_response: str) -> NLPResult:
  208. """
  209. Parst die Intent-Erkennungs-Antwort.
  210. Erwartet JSON mit: intent, confidence, slots
  211. KEIN response-Feld!
  212. Args:
  213. raw_response: Rohtext-Antwort vom LLM
  214. Returns:
  215. NLPResult (ohne response_text)
  216. """
  217. try:
  218. # JSON aus Antwort extrahieren
  219. json_data = self._extract_json(raw_response)
  220. if json_data is None:
  221. pdebug(f"Konnte kein JSON parsen: {raw_response[:200]}")
  222. return NLPResult(
  223. intent="unknown",
  224. confidence=0.3,
  225. success=True,
  226. )
  227. return NLPResult(
  228. intent=json_data.get("intent", "unknown"),
  229. confidence=float(json_data.get("confidence", 0.5)),
  230. slots=json_data.get("slots", {}),
  231. response_text="", # Explizit leer - wird später generiert
  232. success=True,
  233. )
  234. except Exception as e:
  235. pdebug(f"Fehler beim Parsen der Intent-Antwort: {e}")
  236. return NLPResult(
  237. intent="unknown",
  238. confidence=0.3,
  239. success=True,
  240. )
  241. def _extract_json(self, text: str) -> dict[str, Any] | None:
  242. """
  243. Extrahiert JSON aus Text.
  244. Args:
  245. text: Text mit möglichem JSON
  246. Returns:
  247. Geparstes JSON oder None
  248. """
  249. # Versuche direktes JSON-Parsing
  250. try:
  251. return json.loads(text.strip())
  252. except json.JSONDecodeError:
  253. pass
  254. # Suche nach JSON-Block in Markdown Code-Block
  255. code_block_pattern = r"```(?:json)?\s*(\{.*?\})\s*```"
  256. match = re.search(code_block_pattern, text, re.DOTALL)
  257. if match:
  258. try:
  259. return json.loads(match.group(1))
  260. except json.JSONDecodeError:
  261. pass
  262. # Suche nach JSON-Objekt im Text
  263. json_pattern = r"\{[^{}]*\}"
  264. matches = re.findall(json_pattern, text, re.DOTALL)
  265. for match in matches:
  266. try:
  267. return json.loads(match)
  268. except json.JSONDecodeError:
  269. continue
  270. return None
  271. async def shutdown(self) -> None:
  272. """Fährt den Provider herunter."""
  273. self._state = NLPState.SHUTDOWN
  274. await self._shutdown_backend()
  275. @abstractmethod
  276. async def _shutdown_backend(self) -> None:
  277. """Fährt das spezifische Backend herunter."""
  278. pass
  279. def get_capabilities(self) -> list[str]:
  280. """Gibt die Provider-Fähigkeiten zurück."""
  281. return ["intent", "response", "entity"]
  282. def supports_streaming(self) -> bool:
  283. """LLM-Provider könnten Streaming unterstützen."""
  284. return False