main.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531
  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. project_root = Path(__file__).resolve().parent.parent.parent
  155. entities_dir = project_root / "entities"
  156. plugins_dir = project_root / "plugins"
  157. language = self.get_config_value("response_language",
  158. nlp_config.extra.get("response_language", "de"))
  159. # Globale Entities laden (entities/{lang}/*.yml|*.json)
  160. self._entity_registry.load(entities_dir, language)
  161. # Plugin-Entities laden (plugins/*/entities/{lang}/*.yml|*.json)
  162. self._entity_registry.load_plugin_entities(plugins_dir, language)
  163. pinfo(f"[NLP] Entity-Registry geladen: {len(self._entity_registry.entity_types)} Typen")
  164. # Keyword-Matcher initialisieren (Pre-Filter vor LLM)
  165. matcher_config = self.get_config_value("keyword_matcher", {})
  166. if matcher_config.get("enabled", True):
  167. self._keyword_matcher = KeywordIntentMatcher(matcher_config)
  168. self._keyword_matcher.set_entity_registry(self._entity_registry)
  169. self._refresh_keyword_matcher()
  170. pinfo("[NLP] Keyword-Matcher aktiviert (mit Entity-Validierung)")
  171. else:
  172. self._keyword_matcher = None
  173. self._refresh_task = asyncio.create_task(self._periodic_cache_refresh())
  174. self._extension = LLMNLPExtension(self)
  175. # Extension nicht registrieren - das Plugin arbeitet direkt event-driven
  176. pinfo(f"{self.NAME} Plugin geladen (Event-Driven Mode)")
  177. async def on_unload(self) -> None:
  178. """Fährt das Plugin herunter."""
  179. if self._refresh_task:
  180. self._refresh_task.cancel()
  181. try:
  182. await self._refresh_task
  183. except asyncio.CancelledError:
  184. pass
  185. if self._provider:
  186. await self._provider.shutdown()
  187. self._provider = None
  188. self._extension = None
  189. def _refresh_keyword_matcher(self) -> None:
  190. """Aktualisiert den Keyword-Matcher mit allen verfuegbaren Intents."""
  191. if not self._keyword_matcher:
  192. return
  193. all_intents = self._intent_cache.get_intents_for_context()
  194. self._keyword_matcher.compile_intents(all_intents)
  195. async def _periodic_cache_refresh(self) -> None:
  196. """Periodischer Cache-Refresh."""
  197. while True:
  198. await asyncio.sleep(INTENT_CACHE_REFRESH_INTERVAL)
  199. self._intent_cache.refresh_plugin_intents()
  200. self._refresh_keyword_matcher()
  201. # =========================================================================
  202. # Event Handler: Plugin Changes
  203. # =========================================================================
  204. @TrixyEvent(["plugin_loaded", "plugin_unloaded"])
  205. async def on_plugin_change(self, event_name: str, event_data) -> None:
  206. """Aktualisiert Cache und Keyword-Matcher bei Plugin-Änderungen."""
  207. plugin_name = getattr(event_data, "plugin_name", "")
  208. if plugin_name != self.NAME:
  209. self._intent_cache.refresh_plugin_intents()
  210. self._refresh_keyword_matcher()
  211. # =========================================================================
  212. # Event Handler: Intent-Erkennung (speech_recognized → intent_received)
  213. # =========================================================================
  214. @TrixyEvent(["speech_recognized"])
  215. async def on_speech_recognized(self, event_name: str, event_data: SpeechRecognized) -> None:
  216. """
  217. Erkennt Intent aus Spracheingabe.
  218. Emittiert: intent_received
  219. """
  220. pinfo(f"[NLP] speech_recognized empfangen: '{getattr(event_data, 'text', '?')}'")
  221. if not self._provider:
  222. pwarn("[NLP] Kein Provider konfiguriert")
  223. return
  224. if not self._provider.is_ready:
  225. pwarn(f"[NLP] Provider nicht bereit (state={getattr(self._provider, '_state', '?')})")
  226. return
  227. text = event_data.text
  228. if not text or not text.strip():
  229. pdebug("[NLP] Leerer Text, überspringe")
  230. return
  231. # STT-Korrektur: Häufige Fehlerkennungen korrigieren
  232. text = self._correct_stt_text(text)
  233. pinfo(f"[NLP] Starte Intent-Erkennung für: '{text}'")
  234. # Session-Info holen
  235. session_info = self._get_session_info(event_data.satellite_id)
  236. wakeword_type = session_info.get("wakeword_type", "custom")
  237. is_authenticated = self._is_authenticated(event_data.satellite_id)
  238. # Cache aktualisieren falls nötig
  239. if self._intent_cache.needs_refresh():
  240. self._intent_cache.refresh_plugin_intents()
  241. available_intents = self._intent_cache.get_intents_for_context(
  242. wakeword_type=wakeword_type,
  243. is_authenticated=is_authenticated,
  244. )
  245. # Keyword-Matcher Pre-Filter (vor LLM)
  246. import time as _time
  247. metadata: dict[str, Any] = {}
  248. if self._keyword_matcher:
  249. t_kw_start = _time.monotonic()
  250. kw_match = self._keyword_matcher.match(text)
  251. t_kw_elapsed = (_time.monotonic() - t_kw_start) * 1000 # ms
  252. if kw_match:
  253. pinfo(
  254. f"[NLP] Keyword-Match: '{kw_match.intent}' "
  255. f"(confidence={kw_match.confidence:.2f}, "
  256. f"{kw_match.matched_by}, {t_kw_elapsed:.1f}ms)"
  257. )
  258. result = NLPResult(
  259. intent=kw_match.intent,
  260. confidence=kw_match.confidence,
  261. slots=kw_match.slots,
  262. success=True,
  263. processing_time=t_kw_elapsed / 1000,
  264. )
  265. if kw_match.sentiment:
  266. metadata["sentiment"] = kw_match.sentiment
  267. else:
  268. pdebug(f"[NLP] Kein Keyword-Match ({t_kw_elapsed:.1f}ms), Fallback auf LLM")
  269. kw_match = None
  270. else:
  271. kw_match = None
  272. # LLM-Fallback (nur wenn kein Keyword-Match)
  273. if kw_match is None:
  274. pinfo(f"[NLP] {len(available_intents)} Intents verfügbar, sende an LLM...")
  275. t_start = _time.monotonic()
  276. result = await self._recognize_intent(
  277. text=text,
  278. satellite_id=event_data.satellite_id,
  279. session_info=session_info,
  280. wakeword_type=wakeword_type,
  281. is_authenticated=is_authenticated,
  282. language=event_data.language or "de",
  283. )
  284. t_elapsed = _time.monotonic() - t_start
  285. if not result.success:
  286. perror(f"[NLP] Intent-Erkennung fehlgeschlagen nach {t_elapsed:.1f}s: {result.error}")
  287. return
  288. pinfo(f"[NLP] Intent erkannt in {t_elapsed:.1f}s: intent='{result.intent}', confidence={result.confidence:.2f}, slots={result.slots}")
  289. else:
  290. t_elapsed = result.processing_time
  291. # Sicherheitsprüfung
  292. if not self._check_authorization(result.intent, wakeword_type, is_authenticated):
  293. pwarn(f"[NLP] Autorisierung verweigert für Intent: {result.intent}")
  294. await self._emit_authorization_error(event_data.satellite_id, session_info)
  295. return
  296. # Konfidenz prüfen
  297. min_confidence = self.get_config_value("min_confidence", 0.3)
  298. intent = result.intent if result.confidence >= min_confidence else "unknown"
  299. if intent == "unknown" and result.intent != "unknown":
  300. pdebug(f"[NLP] Konfidenz zu niedrig ({result.confidence:.2f} < {min_confidence}), Intent -> 'unknown'")
  301. # intent_received emittieren
  302. intent_event = IntentReceived(
  303. satellite_id=event_data.satellite_id,
  304. intent=intent,
  305. confidence=result.confidence,
  306. slots=result.slots,
  307. original_text=text,
  308. session_id=session_info.get("session_id", ""),
  309. room_id=session_info.get("room_id", ""),
  310. wakeword_type=wakeword_type,
  311. is_authenticated=is_authenticated,
  312. language=event_data.language or "de",
  313. metadata=metadata,
  314. )
  315. pinfo(f"[NLP] Emittiere intent_received: '{intent}' (confidence={result.confidence:.2f})")
  316. await self._application.events.trigger("intent_received", intent_event)
  317. async def _recognize_intent(
  318. self,
  319. text: str,
  320. satellite_id: str,
  321. session_info: dict[str, Any],
  322. wakeword_type: str,
  323. is_authenticated: bool,
  324. language: str,
  325. ):
  326. """Führt Intent-Erkennung durch."""
  327. available_intents = self._intent_cache.get_intents_for_context(
  328. wakeword_type=wakeword_type,
  329. is_authenticated=is_authenticated,
  330. )
  331. context = NLPContext(
  332. text=text,
  333. satellite_id=satellite_id,
  334. room_id=session_info.get("room_id", ""),
  335. session_id=session_info.get("session_id", ""),
  336. available_intents=available_intents,
  337. language=language,
  338. )
  339. return await self._provider.process(context)
  340. # =========================================================================
  341. # Event Handler: Antwort-Generierung (create_output_text → output_text_created)
  342. # =========================================================================
  343. @TrixyEvent(["create_output_text"])
  344. async def on_create_output_text(self, event_name: str, event_data: CreateOutputText) -> None:
  345. """
  346. Generiert Antworttext aus Handler-Ergebnis.
  347. Emittiert: output_text_created
  348. """
  349. pinfo(f"[NLP] create_output_text empfangen: intent='{event_data.intent}', text='{event_data.original_text[:50]}'")
  350. if not self._provider or not self._provider.is_ready:
  351. pwarn("[NLP] Provider nicht bereit für Antwort-Generierung")
  352. return
  353. pinfo(f"[NLP] Generiere Antwort für Intent: '{event_data.intent}'...")
  354. # Antwort generieren
  355. response_text = await self._provider.generate_response(
  356. intent=event_data.intent,
  357. slots=event_data.slots,
  358. handler_result=event_data.handler_data,
  359. original_text=event_data.original_text,
  360. room_id=event_data.room_id,
  361. )
  362. if not response_text:
  363. # Fallback für unbekannte Intents
  364. response_text = await self._provider.generate_fallback_response(
  365. event_data.original_text
  366. )
  367. # output_text_created emittieren
  368. output_event = OutputTextCreated(
  369. satellite_id=event_data.satellite_id,
  370. session_id=event_data.session_id,
  371. room_id=event_data.room_id,
  372. text=response_text,
  373. intent=event_data.intent,
  374. is_followup=False,
  375. expects_response=False,
  376. )
  377. pdebug(f"Emittiere output_text_created: {response_text[:50]}...")
  378. await self._application.events.trigger("output_text_created", output_event)
  379. # =========================================================================
  380. # Hilfsmethoden
  381. # =========================================================================
  382. # STT-Korrekturen: Wort-Aliase für häufige Fehlerkennungen.
  383. # Nur angewendet wenn das Wort alleine steht (gesamter Text).
  384. _STT_WORD_ALIASES: dict[str, str] = {
  385. "shop": "stopp",
  386. "schop": "stopp",
  387. }
  388. def _correct_stt_text(self, text: str) -> str:
  389. """Korrigiert häufige STT-Fehlerkennungen."""
  390. stripped = text.strip().lower()
  391. # Einzelwort-Aliase (nur wenn das Wort alleine steht)
  392. if stripped in self._STT_WORD_ALIASES:
  393. corrected = self._STT_WORD_ALIASES[stripped]
  394. pdebug(f"[NLP] STT-Korrektur: '{text}' → '{corrected}'")
  395. return corrected
  396. return text
  397. def _get_session_info(self, satellite_id: str) -> dict[str, Any]:
  398. """Holt Session-Info."""
  399. info = {"session_id": "", "room_id": "", "wakeword_type": "custom"}
  400. if hasattr(self._application, "services"):
  401. try:
  402. conv_service = self._application.services.get_service("ConversationService")
  403. if conv_service:
  404. session = conv_service.get_active_session(satellite_id)
  405. if session:
  406. info["session_id"] = session.session_id
  407. info["room_id"] = getattr(session, "room_id", "")
  408. info["wakeword_type"] = getattr(session, "wakeword_type", "custom")
  409. except Exception:
  410. pass
  411. return info
  412. def _is_authenticated(self, satellite_id: str) -> bool:
  413. """Prüft Admin-Authentifizierung."""
  414. session = self._admin_sessions.get(satellite_id)
  415. if not session:
  416. return False
  417. if time.time() > session.get("expires", 0):
  418. del self._admin_sessions[satellite_id]
  419. return False
  420. return session.get("authenticated", False)
  421. def _check_authorization(
  422. self, intent: str, wakeword_type: str, is_authenticated: bool
  423. ) -> bool:
  424. """Prüft Intent-Autorisierung."""
  425. if not is_admin_intent(intent):
  426. return True
  427. if wakeword_type != "system_command":
  428. return False
  429. if intent == "system_login":
  430. return True
  431. return is_authenticated
  432. async def _emit_authorization_error(
  433. self, satellite_id: str, session_info: dict[str, Any]
  434. ) -> None:
  435. """Emittiert Autorisierungs-Fehler."""
  436. error_event = OutputTextCreated(
  437. satellite_id=satellite_id,
  438. session_id=session_info.get("session_id", ""),
  439. room_id=session_info.get("room_id", ""),
  440. text="Dieser Befehl erfordert Administrator-Rechte. "
  441. "Bitte verwende das System-Wakeword und melde dich an.",
  442. intent="authorization_error",
  443. )
  444. await self._application.events.trigger("output_text_created", error_event)