main.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281
  1. # -*- coding: utf-8 -*-
  2. """
  3. NLP Classifier Plugin — Intent-Erkennung via trainiertem ONNX-Modell.
  4. 3-stufige Pipeline:
  5. 1. KeywordMatcher (< 5ms) — Pattern-basiert, hoechste Praezision
  6. 2. ONNX-Classifier (< 100ms) — Trainiertes Sentence-Embedding-Modell
  7. 3. LLM-Fallback — Nur wenn nlp_llm Plugin aktiv und Confidence zu niedrig
  8. Hoert auf speech_recognized mit HIGHEST Prioritaet.
  9. Bei ausreichender Confidence wird das Event gecancelt,
  10. damit das nlp_llm Plugin nicht zusaetzlich aufgerufen wird.
  11. Event-Flow:
  12. speech_recognized (HIGHEST Prioritaet)
  13. ↓
  14. [Classifier Plugin] KeywordMatcher → ONNX → Fallback
  15. ↓
  16. intent_received (mit confidence, slots, tones)
  17. ↓
  18. [Intent-Handler Plugin] verarbeitet Intent
  19. """
  20. from __future__ import annotations
  21. import time
  22. from pathlib import Path
  23. from typing import Any
  24. from trixy_core.events.decorators import TrixyEvent
  25. from trixy_core.events.enums import EventPriority
  26. from trixy_core.events.event_data.basic import IntentReceived, SpeechRecognized
  27. from trixy_core.nlp.intent_classifier import IntentClassifier
  28. from trixy_core.nlp.tone import get_analyzer, analyze_tone, emit_tone_events
  29. from trixy_core.plugins.trixy_plugin import TrixyPlugin
  30. from trixy_core.utils.debug import pdebug, perror, pinfo, pwarn
  31. class NLPClassifierPlugin(TrixyPlugin):
  32. """
  33. NLP-Backend via trainiertem Intent-Classifier.
  34. Nutzt ein ONNX-Modell fuer schnelle Intent-Erkennung (<100ms auf Pi).
  35. Faellt auf das LLM-Plugin zurueck wenn Confidence zu niedrig.
  36. """
  37. NAME = "nlp_classifier"
  38. VERSION = "1.0.0"
  39. DESCRIPTION = "Intent-Erkennung via trainiertem ONNX-Classifier"
  40. AUTHOR = "Trixy"
  41. def __init__(self, application, plugin_path, config=None) -> None:
  42. super().__init__(application, plugin_path, config)
  43. self._classifier: IntentClassifier | None = None
  44. self._keyword_matcher = None
  45. self._entity_registry = None
  46. async def on_load(self) -> None:
  47. """Plugin laden: Classifier, KeywordMatcher, ToneAnalyzer initialisieren."""
  48. pinfo(f"[Classifier] Lade Plugin v{self.VERSION}...")
  49. # 1. Intent-Classifier laden
  50. model_dir = self.get_config_value("model_dir", "models/intent")
  51. self._classifier = IntentClassifier()
  52. ok = await self._classifier.load(model_dir)
  53. if ok:
  54. pinfo(f"[Classifier] ONNX-Modell geladen: {self._classifier.intent_count} Intents")
  55. else:
  56. pwarn("[Classifier] ONNX-Modell nicht verfuegbar — nur KeywordMatcher aktiv")
  57. # 2. KeywordMatcher initialisieren (Pattern-basiert, schnellster Pfad)
  58. try:
  59. from trixy_core.nlp.keyword_matcher import KeywordIntentMatcher
  60. from trixy_core.nlp.entities import EntityRegistry
  61. from trixy_core.nlp.intent_registry import IntentRegistry
  62. matcher_config = {"enabled": True}
  63. self._keyword_matcher = KeywordIntentMatcher(matcher_config)
  64. # Entity-Registry laden
  65. nlp_dir = Path(__file__).resolve().parent.parent.parent / "trixy_core" / "nlp"
  66. self._entity_registry = EntityRegistry()
  67. self._entity_registry.load(nlp_dir, "de")
  68. self._keyword_matcher.set_entity_registry(self._entity_registry)
  69. # Intents aus der Registry laden und kompilieren
  70. registry = IntentRegistry.get_instance()
  71. all_intents = registry.get_all_as_dict()
  72. # System-Intents hinzufuegen
  73. try:
  74. from trixy_core.nlp.system_intents import get_system_intents_for_context
  75. system_intents = get_system_intents_for_context()
  76. all_intents = system_intents + all_intents
  77. except ImportError:
  78. pass
  79. self._keyword_matcher.compile_intents(all_intents)
  80. pinfo(f"[Classifier] KeywordMatcher: {len(all_intents)} Intents kompiliert")
  81. except Exception as e:
  82. pwarn(f"[Classifier] KeywordMatcher nicht verfuegbar: {e}")
  83. # 3. ToneAnalyzer laden
  84. tone_file = self.get_config_value("tone_keywords_file", "config/tone_keywords.json")
  85. if Path(tone_file).is_file():
  86. get_analyzer().load_keywords(tone_file)
  87. pdebug(f"[Classifier] ToneAnalyzer geladen")
  88. pinfo(f"[Classifier] Plugin geladen — Pipeline: KeywordMatcher → ONNX → LLM-Fallback")
  89. async def on_unload(self) -> None:
  90. """Plugin entladen."""
  91. self._classifier = None
  92. self._keyword_matcher = None
  93. self._entity_registry = None
  94. pdebug("[Classifier] Plugin entladen")
  95. # === KeywordMatcher aktualisieren bei Plugin-Aenderungen ===
  96. @TrixyEvent(["plugin_loaded", "plugin_unloaded"])
  97. async def on_plugin_change(self, event_name: str, event_data) -> None:
  98. """Aktualisiert den KeywordMatcher wenn Plugins sich aendern."""
  99. plugin_name = getattr(event_data, "plugin_name", "")
  100. if plugin_name == self.NAME:
  101. return
  102. if self._keyword_matcher:
  103. try:
  104. from trixy_core.nlp.intent_registry import IntentRegistry
  105. registry = IntentRegistry.get_instance()
  106. all_intents = registry.get_all_as_dict()
  107. self._keyword_matcher.compile_intents(all_intents)
  108. pdebug(f"[Classifier] KeywordMatcher aktualisiert: {len(all_intents)} Intents")
  109. except Exception:
  110. pass
  111. # === Intent-Erkennung ===
  112. @TrixyEvent(["speech_recognized"], priority=EventPriority.HIGHEST)
  113. async def on_speech_recognized(self, event_name: str, event_data: SpeechRecognized) -> None:
  114. """
  115. Erkennt Intent aus Spracheingabe.
  116. Prioritaet HIGHEST — laeuft VOR dem nlp_llm Plugin.
  117. Bei ausreichender Confidence wird das Event gecancelt.
  118. """
  119. text = getattr(event_data, "text", "")
  120. if not text or not text.strip():
  121. return
  122. satellite_id = getattr(event_data, "satellite_id", "")
  123. min_confidence = self.get_config_value("min_confidence", 0.7)
  124. log_predictions = self.get_config_value("log_predictions", True)
  125. t_start = time.monotonic()
  126. # --- Stufe 1: KeywordMatcher (< 5ms) ---
  127. intent = ""
  128. confidence = 0.0
  129. slots: dict[str, Any] = {}
  130. matched_by = ""
  131. if self._keyword_matcher:
  132. kw_match = self._keyword_matcher.match(text)
  133. if kw_match and kw_match.confidence >= min_confidence:
  134. intent = kw_match.intent
  135. confidence = kw_match.confidence
  136. slots = kw_match.slots
  137. matched_by = "keyword"
  138. if log_predictions:
  139. t_kw = (time.monotonic() - t_start) * 1000
  140. pinfo(
  141. f"[Classifier] KeywordMatch: '{intent}' "
  142. f"(conf={confidence:.2f}, {t_kw:.1f}ms)"
  143. )
  144. # --- Stufe 2: ONNX-Classifier (< 100ms) ---
  145. if not intent and self._classifier and self._classifier.is_loaded:
  146. prediction = await self._classifier.classify(text, min_confidence=min_confidence)
  147. if prediction.confidence >= min_confidence:
  148. intent = prediction.intent
  149. confidence = prediction.confidence
  150. slots = prediction.slots
  151. matched_by = "classifier"
  152. if log_predictions:
  153. pinfo(
  154. f"[Classifier] ONNX: '{intent}' "
  155. f"(conf={confidence:.2f}, {prediction.inference_ms:.1f}ms)"
  156. )
  157. # --- Stufe 3: LLM-Fallback ---
  158. if not intent:
  159. fallback = self.get_config_value("fallback_to_llm", True)
  160. if fallback:
  161. t_elapsed = (time.monotonic() - t_start) * 1000
  162. if log_predictions:
  163. pdebug(
  164. f"[Classifier] Keine Erkennung ({t_elapsed:.1f}ms) "
  165. f"— Fallback an LLM"
  166. )
  167. # Event NICHT canceln → nlp_llm Plugin uebernimmt
  168. return
  169. # Kein Fallback — unknown emittieren
  170. intent = "unknown"
  171. confidence = 0.0
  172. matched_by = "none"
  173. # --- Tone-Analyse ---
  174. tone_results = analyze_tone(text)
  175. tones = [r.tag for r in tone_results]
  176. # Tone-Events emittieren
  177. events = getattr(self._application, "events", None)
  178. if events and tone_results:
  179. await emit_tone_events(tone_results, text, events)
  180. # --- intent_received emittieren ---
  181. t_total = (time.monotonic() - t_start) * 1000
  182. # Session-Info sammeln
  183. session_info = self._get_session_info(satellite_id)
  184. intent_event = IntentReceived(
  185. satellite_id=satellite_id,
  186. intent=intent,
  187. confidence=confidence,
  188. slots=slots,
  189. original_text=text,
  190. session_id=session_info.get("session_id", ""),
  191. room_id=session_info.get("room_id", ""),
  192. wakeword_type=session_info.get("wakeword_type", "custom"),
  193. is_authenticated=self._is_authenticated(satellite_id),
  194. language=getattr(event_data, "language", "de") or "de",
  195. metadata={
  196. "matched_by": matched_by,
  197. "tones": [{"tag": r.tag, "category": r.category} for r in tone_results],
  198. "inference_ms": t_total,
  199. },
  200. )
  201. pinfo(
  202. f"[Classifier] Intent: '{intent}' (conf={confidence:.2f}, "
  203. f"by={matched_by}, {t_total:.1f}ms, "
  204. f"tones={tones[:3]})"
  205. )
  206. await self._application.events.trigger("intent_received", intent_event)
  207. # Event canceln — verhindert dass nlp_llm nochmal laeuft
  208. if hasattr(event_data, "cancel"):
  209. event_data.cancel()
  210. # === Hilfsmethoden ===
  211. def _get_session_info(self, satellite_id: str) -> dict[str, Any]:
  212. """Holt Session-Info fuer den Satellite."""
  213. try:
  214. if hasattr(self._application, "satellites"):
  215. satellite = self._application.satellites.get(satellite_id)
  216. if satellite:
  217. conv = getattr(satellite, "conversation", None)
  218. if conv:
  219. return {
  220. "session_id": getattr(conv, "session_id", ""),
  221. "room_id": getattr(satellite, "room", ""),
  222. "wakeword_type": getattr(conv, "wakeword_type", "custom"),
  223. }
  224. return {"room_id": getattr(satellite, "room", "")}
  225. except Exception:
  226. pass
  227. return {}
  228. def _is_authenticated(self, satellite_id: str) -> bool:
  229. """Prueft ob der Satellite authentifiziert ist (Admin-Befehle)."""
  230. # TODO: Admin-Session-Verwaltung (wie in nlp_llm)
  231. return False