main.py 11 KB

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