main.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526
  1. # -*- coding: utf-8 -*-
  2. """
  3. LLM-basiertes NLP Plugin für Trixy.
  4. Verantwortlich für:
  5. 1. Intent-Erkennung: speech_recognized → intent_received
  6. 2. Antwort-Generierung: create_output_text → output_text_created
  7. Event-Flow:
  8. speech_recognized
  9. ↓
  10. [NLP Plugin] Intent-Erkennung
  11. ↓
  12. intent_received
  13. ↓
  14. [Intent Dispatcher] Handler aufrufen
  15. ↓
  16. intent_handled
  17. ↓
  18. [NLP Plugin] Antwort generieren (falls nötig)
  19. ↓
  20. output_text_created
  21. ↓
  22. [TTS Plugin] Sprachausgabe
  23. """
  24. import asyncio
  25. import json
  26. import time
  27. import uuid
  28. from typing import Any
  29. from trixy_core.plugins.trixy_plugin import TrixyPlugin
  30. from trixy_core.plugins.extensions.points.conversation import NLPExtension
  31. from trixy_core.events.decorators import TrixyEvent
  32. from trixy_core.events.event_data.basic import (
  33. SpeechRecognized,
  34. IntentReceived,
  35. IntentHandled,
  36. CreateOutputText,
  37. OutputTextCreated,
  38. PluginLoaded,
  39. PluginUnloaded,
  40. )
  41. from trixy_core.nlp import (
  42. NLPConfig,
  43. NLPContext,
  44. NLPResult,
  45. IntentRegistry,
  46. get_system_intents_for_context,
  47. is_admin_intent,
  48. )
  49. from trixy_core.nlp.keyword_matcher import KeywordIntentMatcher
  50. from trixy_core.nlp.entities import EntityRegistry
  51. from trixy_core.utils.debug import pinfo, pdebug, perror, pwarn
  52. # Cache-Refresh-Intervall in Sekunden
  53. INTENT_CACHE_REFRESH_INTERVAL = 600 # 10 Minuten
  54. class LLMNLPExtension(NLPExtension):
  55. """NLP Extension für LLM-basierte Verarbeitung."""
  56. def __init__(self, plugin: "LLMNLPPlugin") -> None:
  57. super().__init__(
  58. extension_id="nlp_llm",
  59. name="LLM NLP",
  60. plugin_name=plugin.name,
  61. engine="llm",
  62. capabilities=["intent", "response", "entity"],
  63. offline=True,
  64. description="Lokales LLM für Intent-Erkennung und Antwortgenerierung",
  65. )
  66. self._plugin = plugin
  67. async def create_instance(self, config: dict[str, Any]):
  68. return self._plugin.provider
  69. class IntentCache:
  70. """Cache für verfügbare Intents."""
  71. def __init__(self) -> None:
  72. self._plugin_intents: list[dict[str, Any]] = []
  73. self._last_refresh: float = 0
  74. self._cached_prompt_intents: dict[str, str] = {}
  75. def refresh_plugin_intents(self) -> None:
  76. """Lädt Plugin-Intents aus der Registry neu."""
  77. registry = IntentRegistry.get_instance()
  78. self._plugin_intents = registry.get_all_as_dict()
  79. self._cached_prompt_intents.clear()
  80. self._last_refresh = time.time()
  81. pdebug(f"Intent-Cache aktualisiert: {len(self._plugin_intents)} Plugin-Intents")
  82. def needs_refresh(self) -> bool:
  83. return (time.time() - self._last_refresh) > INTENT_CACHE_REFRESH_INTERVAL
  84. def get_intents_for_context(
  85. self,
  86. wakeword_type: str = "custom",
  87. is_authenticated: bool = False,
  88. ) -> list[dict[str, Any]]:
  89. """Gibt alle verfügbaren Intents für den Kontext zurück."""
  90. system_intents = get_system_intents_for_context(
  91. wakeword_type=wakeword_type,
  92. is_authenticated=is_authenticated,
  93. )
  94. return system_intents + self._plugin_intents
  95. @property
  96. def plugin_intent_count(self) -> int:
  97. return len(self._plugin_intents)
  98. class LLMNLPPlugin(TrixyPlugin):
  99. """
  100. LLM-basiertes NLP Plugin.
  101. Aufgaben:
  102. 1. Intent-Erkennung aus speech_recognized
  103. 2. Antwort-Generierung für create_output_text
  104. """
  105. NAME = "nlp_llm"
  106. VERSION = "2.0.0"
  107. DESCRIPTION = "LLM-basierte NLP (Event-Driven)"
  108. AUTHOR = "Trixy"
  109. def __init__(self, application, plugin_path, config) -> None:
  110. super().__init__(application, plugin_path, config)
  111. self._provider = None
  112. self._extension = None
  113. self._intent_cache = IntentCache()
  114. self._keyword_matcher: KeywordIntentMatcher | None = None
  115. self._entity_registry: EntityRegistry | None = None
  116. self._refresh_task: asyncio.Task | None = None
  117. self._admin_sessions: dict[str, dict[str, Any]] = {}
  118. @property
  119. def provider(self):
  120. return self._provider
  121. async def on_load(self) -> None:
  122. """Initialisiert das Plugin."""
  123. pinfo(f"Lade {self.NAME} Plugin...")
  124. backend = self.get_config_value("backend", "llama_cpp")
  125. if backend == "ollama":
  126. from plugins.nlp_llm.backends.ollama import OllamaNLPBackend
  127. self._provider = OllamaNLPBackend()
  128. else:
  129. from plugins.nlp_llm.backends.llama_cpp import LlamaCppNLPBackend
  130. self._provider = LlamaCppNLPBackend()
  131. nlp_config = NLPConfig(
  132. backend=backend,
  133. model_name=self.get_config_value("model_name", "qwen2.5-1.5b-instruct"),
  134. model_path=self.get_config_value("model_path"),
  135. use_gpu=self.get_config_value("use_gpu", False),
  136. num_threads=self.get_config_value("num_threads", 4),
  137. temperature=self.get_config_value("temperature", 0.1),
  138. max_tokens=self.get_config_value("max_tokens", 256),
  139. context_window=self.get_config_value("context_window", 2048),
  140. extra={
  141. "ollama_host": self.get_config_value("ollama_host", "http://localhost:11434"),
  142. "response_language": self.get_config_value("response_language", "de"),
  143. }
  144. )
  145. success = await self._provider.initialize(nlp_config)
  146. if success:
  147. pinfo(f"[NLP] Provider initialisiert: {backend}, model={nlp_config.model_name}, ready={self._provider.is_ready}")
  148. else:
  149. perror(f"[NLP] Provider-Initialisierung fehlgeschlagen: {backend}")
  150. self._intent_cache.refresh_plugin_intents()
  151. # Entity-Registry initialisieren
  152. from pathlib import Path
  153. self._entity_registry = EntityRegistry()
  154. nlp_dir = Path(__file__).resolve().parent.parent.parent / "trixy_core" / "nlp"
  155. language = self.get_config_value("response_language",
  156. nlp_config.extra.get("response_language", "de"))
  157. self._entity_registry.load(nlp_dir, language)
  158. pinfo(f"[NLP] Entity-Registry geladen: {len(self._entity_registry.entity_types)} Typen")
  159. # Keyword-Matcher initialisieren (Pre-Filter vor LLM)
  160. matcher_config = self.get_config_value("keyword_matcher", {})
  161. if matcher_config.get("enabled", True):
  162. self._keyword_matcher = KeywordIntentMatcher(matcher_config)
  163. self._keyword_matcher.set_entity_registry(self._entity_registry)
  164. self._refresh_keyword_matcher()
  165. pinfo("[NLP] Keyword-Matcher aktiviert (mit Entity-Validierung)")
  166. else:
  167. self._keyword_matcher = None
  168. self._refresh_task = asyncio.create_task(self._periodic_cache_refresh())
  169. self._extension = LLMNLPExtension(self)
  170. # Extension nicht registrieren - das Plugin arbeitet direkt event-driven
  171. pinfo(f"{self.NAME} Plugin geladen (Event-Driven Mode)")
  172. async def on_unload(self) -> None:
  173. """Fährt das Plugin herunter."""
  174. if self._refresh_task:
  175. self._refresh_task.cancel()
  176. try:
  177. await self._refresh_task
  178. except asyncio.CancelledError:
  179. pass
  180. if self._provider:
  181. await self._provider.shutdown()
  182. self._provider = None
  183. self._extension = None
  184. def _refresh_keyword_matcher(self) -> None:
  185. """Aktualisiert den Keyword-Matcher mit allen verfuegbaren Intents."""
  186. if not self._keyword_matcher:
  187. return
  188. all_intents = self._intent_cache.get_intents_for_context()
  189. self._keyword_matcher.compile_intents(all_intents)
  190. async def _periodic_cache_refresh(self) -> None:
  191. """Periodischer Cache-Refresh."""
  192. while True:
  193. await asyncio.sleep(INTENT_CACHE_REFRESH_INTERVAL)
  194. self._intent_cache.refresh_plugin_intents()
  195. self._refresh_keyword_matcher()
  196. # =========================================================================
  197. # Event Handler: Plugin Changes
  198. # =========================================================================
  199. @TrixyEvent(["plugin_loaded", "plugin_unloaded"])
  200. async def on_plugin_change(self, event_name: str, event_data) -> None:
  201. """Aktualisiert Cache und Keyword-Matcher bei Plugin-Änderungen."""
  202. plugin_name = getattr(event_data, "plugin_name", "")
  203. if plugin_name != self.NAME:
  204. self._intent_cache.refresh_plugin_intents()
  205. self._refresh_keyword_matcher()
  206. # =========================================================================
  207. # Event Handler: Intent-Erkennung (speech_recognized → intent_received)
  208. # =========================================================================
  209. @TrixyEvent(["speech_recognized"])
  210. async def on_speech_recognized(self, event_name: str, event_data: SpeechRecognized) -> None:
  211. """
  212. Erkennt Intent aus Spracheingabe.
  213. Emittiert: intent_received
  214. """
  215. pinfo(f"[NLP] speech_recognized empfangen: '{getattr(event_data, 'text', '?')}'")
  216. if not self._provider:
  217. pwarn("[NLP] Kein Provider konfiguriert")
  218. return
  219. if not self._provider.is_ready:
  220. pwarn(f"[NLP] Provider nicht bereit (state={getattr(self._provider, '_state', '?')})")
  221. return
  222. text = event_data.text
  223. if not text or not text.strip():
  224. pdebug("[NLP] Leerer Text, überspringe")
  225. return
  226. # STT-Korrektur: Häufige Fehlerkennungen korrigieren
  227. text = self._correct_stt_text(text)
  228. pinfo(f"[NLP] Starte Intent-Erkennung für: '{text}'")
  229. # Session-Info holen
  230. session_info = self._get_session_info(event_data.satellite_id)
  231. wakeword_type = session_info.get("wakeword_type", "custom")
  232. is_authenticated = self._is_authenticated(event_data.satellite_id)
  233. # Cache aktualisieren falls nötig
  234. if self._intent_cache.needs_refresh():
  235. self._intent_cache.refresh_plugin_intents()
  236. available_intents = self._intent_cache.get_intents_for_context(
  237. wakeword_type=wakeword_type,
  238. is_authenticated=is_authenticated,
  239. )
  240. # Keyword-Matcher Pre-Filter (vor LLM)
  241. import time as _time
  242. metadata: dict[str, Any] = {}
  243. if self._keyword_matcher:
  244. t_kw_start = _time.monotonic()
  245. kw_match = self._keyword_matcher.match(text)
  246. t_kw_elapsed = (_time.monotonic() - t_kw_start) * 1000 # ms
  247. if kw_match:
  248. pinfo(
  249. f"[NLP] Keyword-Match: '{kw_match.intent}' "
  250. f"(confidence={kw_match.confidence:.2f}, "
  251. f"{kw_match.matched_by}, {t_kw_elapsed:.1f}ms)"
  252. )
  253. result = NLPResult(
  254. intent=kw_match.intent,
  255. confidence=kw_match.confidence,
  256. slots=kw_match.slots,
  257. success=True,
  258. processing_time=t_kw_elapsed / 1000,
  259. )
  260. if kw_match.sentiment:
  261. metadata["sentiment"] = kw_match.sentiment
  262. else:
  263. pdebug(f"[NLP] Kein Keyword-Match ({t_kw_elapsed:.1f}ms), Fallback auf LLM")
  264. kw_match = None
  265. else:
  266. kw_match = None
  267. # LLM-Fallback (nur wenn kein Keyword-Match)
  268. if kw_match is None:
  269. pinfo(f"[NLP] {len(available_intents)} Intents verfügbar, sende an LLM...")
  270. t_start = _time.monotonic()
  271. result = await self._recognize_intent(
  272. text=text,
  273. satellite_id=event_data.satellite_id,
  274. session_info=session_info,
  275. wakeword_type=wakeword_type,
  276. is_authenticated=is_authenticated,
  277. language=event_data.language or "de",
  278. )
  279. t_elapsed = _time.monotonic() - t_start
  280. if not result.success:
  281. perror(f"[NLP] Intent-Erkennung fehlgeschlagen nach {t_elapsed:.1f}s: {result.error}")
  282. return
  283. pinfo(f"[NLP] Intent erkannt in {t_elapsed:.1f}s: intent='{result.intent}', confidence={result.confidence:.2f}, slots={result.slots}")
  284. else:
  285. t_elapsed = result.processing_time
  286. # Sicherheitsprüfung
  287. if not self._check_authorization(result.intent, wakeword_type, is_authenticated):
  288. pwarn(f"[NLP] Autorisierung verweigert für Intent: {result.intent}")
  289. await self._emit_authorization_error(event_data.satellite_id, session_info)
  290. return
  291. # Konfidenz prüfen
  292. min_confidence = self.get_config_value("min_confidence", 0.3)
  293. intent = result.intent if result.confidence >= min_confidence else "unknown"
  294. if intent == "unknown" and result.intent != "unknown":
  295. pdebug(f"[NLP] Konfidenz zu niedrig ({result.confidence:.2f} < {min_confidence}), Intent -> 'unknown'")
  296. # intent_received emittieren
  297. intent_event = IntentReceived(
  298. satellite_id=event_data.satellite_id,
  299. intent=intent,
  300. confidence=result.confidence,
  301. slots=result.slots,
  302. original_text=text,
  303. session_id=session_info.get("session_id", ""),
  304. room_id=session_info.get("room_id", ""),
  305. wakeword_type=wakeword_type,
  306. is_authenticated=is_authenticated,
  307. language=event_data.language or "de",
  308. metadata=metadata,
  309. )
  310. pinfo(f"[NLP] Emittiere intent_received: '{intent}' (confidence={result.confidence:.2f})")
  311. await self._application.events.trigger("intent_received", intent_event)
  312. async def _recognize_intent(
  313. self,
  314. text: str,
  315. satellite_id: str,
  316. session_info: dict[str, Any],
  317. wakeword_type: str,
  318. is_authenticated: bool,
  319. language: str,
  320. ):
  321. """Führt Intent-Erkennung durch."""
  322. available_intents = self._intent_cache.get_intents_for_context(
  323. wakeword_type=wakeword_type,
  324. is_authenticated=is_authenticated,
  325. )
  326. context = NLPContext(
  327. text=text,
  328. satellite_id=satellite_id,
  329. room_id=session_info.get("room_id", ""),
  330. session_id=session_info.get("session_id", ""),
  331. available_intents=available_intents,
  332. language=language,
  333. )
  334. return await self._provider.process(context)
  335. # =========================================================================
  336. # Event Handler: Antwort-Generierung (create_output_text → output_text_created)
  337. # =========================================================================
  338. @TrixyEvent(["create_output_text"])
  339. async def on_create_output_text(self, event_name: str, event_data: CreateOutputText) -> None:
  340. """
  341. Generiert Antworttext aus Handler-Ergebnis.
  342. Emittiert: output_text_created
  343. """
  344. pinfo(f"[NLP] create_output_text empfangen: intent='{event_data.intent}', text='{event_data.original_text[:50]}'")
  345. if not self._provider or not self._provider.is_ready:
  346. pwarn("[NLP] Provider nicht bereit für Antwort-Generierung")
  347. return
  348. pinfo(f"[NLP] Generiere Antwort für Intent: '{event_data.intent}'...")
  349. # Antwort generieren
  350. response_text = await self._provider.generate_response(
  351. intent=event_data.intent,
  352. slots=event_data.slots,
  353. handler_result=event_data.handler_data,
  354. original_text=event_data.original_text,
  355. room_id=event_data.room_id,
  356. )
  357. if not response_text:
  358. # Fallback für unbekannte Intents
  359. response_text = await self._provider.generate_fallback_response(
  360. event_data.original_text
  361. )
  362. # output_text_created emittieren
  363. output_event = OutputTextCreated(
  364. satellite_id=event_data.satellite_id,
  365. session_id=event_data.session_id,
  366. room_id=event_data.room_id,
  367. text=response_text,
  368. intent=event_data.intent,
  369. is_followup=False,
  370. expects_response=False,
  371. )
  372. pdebug(f"Emittiere output_text_created: {response_text[:50]}...")
  373. await self._application.events.trigger("output_text_created", output_event)
  374. # =========================================================================
  375. # Hilfsmethoden
  376. # =========================================================================
  377. # STT-Korrekturen: Wort-Aliase für häufige Fehlerkennungen.
  378. # Nur angewendet wenn das Wort alleine steht (gesamter Text).
  379. _STT_WORD_ALIASES: dict[str, str] = {
  380. "shop": "stopp",
  381. "schop": "stopp",
  382. }
  383. def _correct_stt_text(self, text: str) -> str:
  384. """Korrigiert häufige STT-Fehlerkennungen."""
  385. stripped = text.strip().lower()
  386. # Einzelwort-Aliase (nur wenn das Wort alleine steht)
  387. if stripped in self._STT_WORD_ALIASES:
  388. corrected = self._STT_WORD_ALIASES[stripped]
  389. pdebug(f"[NLP] STT-Korrektur: '{text}' → '{corrected}'")
  390. return corrected
  391. return text
  392. def _get_session_info(self, satellite_id: str) -> dict[str, Any]:
  393. """Holt Session-Info."""
  394. info = {"session_id": "", "room_id": "", "wakeword_type": "custom"}
  395. if hasattr(self._application, "services"):
  396. try:
  397. conv_service = self._application.services.get_service("ConversationService")
  398. if conv_service:
  399. session = conv_service.get_active_session(satellite_id)
  400. if session:
  401. info["session_id"] = session.session_id
  402. info["room_id"] = getattr(session, "room_id", "")
  403. info["wakeword_type"] = getattr(session, "wakeword_type", "custom")
  404. except Exception:
  405. pass
  406. return info
  407. def _is_authenticated(self, satellite_id: str) -> bool:
  408. """Prüft Admin-Authentifizierung."""
  409. session = self._admin_sessions.get(satellite_id)
  410. if not session:
  411. return False
  412. if time.time() > session.get("expires", 0):
  413. del self._admin_sessions[satellite_id]
  414. return False
  415. return session.get("authenticated", False)
  416. def _check_authorization(
  417. self, intent: str, wakeword_type: str, is_authenticated: bool
  418. ) -> bool:
  419. """Prüft Intent-Autorisierung."""
  420. if not is_admin_intent(intent):
  421. return True
  422. if wakeword_type != "system_command":
  423. return False
  424. if intent == "system_login":
  425. return True
  426. return is_authenticated
  427. async def _emit_authorization_error(
  428. self, satellite_id: str, session_info: dict[str, Any]
  429. ) -> None:
  430. """Emittiert Autorisierungs-Fehler."""
  431. error_event = OutputTextCreated(
  432. satellite_id=satellite_id,
  433. session_id=session_info.get("session_id", ""),
  434. room_id=session_info.get("room_id", ""),
  435. text="Dieser Befehl erfordert Administrator-Rechte. "
  436. "Bitte verwende das System-Wakeword und melde dich an.",
  437. intent="authorization_error",
  438. )
  439. await self._application.events.trigger("output_text_created", error_event)