main.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376
  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: KeywordMatcher gegen den erkannten Intent matchen
  149. slots = self._extract_slots_for_intent(text, intent)
  150. if log_predictions:
  151. pinfo(
  152. f"[Classifier] ONNX: '{intent}' "
  153. f"(conf={confidence:.2f}, {prediction.inference_ms:.1f}ms, "
  154. f"slots={slots})"
  155. )
  156. # --- Stufe 3: LLM-Fallback ---
  157. if not intent:
  158. fallback = self.get_config_value("fallback_to_llm", True)
  159. if fallback:
  160. t_elapsed = (time.monotonic() - t_start) * 1000
  161. if log_predictions:
  162. pdebug(
  163. f"[Classifier] Keine Erkennung ({t_elapsed:.1f}ms) "
  164. f"— Fallback an LLM"
  165. )
  166. # Event NICHT canceln → nlp_llm Plugin uebernimmt
  167. return
  168. # Kein Fallback — unknown emittieren
  169. intent = "unknown"
  170. confidence = 0.0
  171. matched_by = "none"
  172. # --- Tone-Analyse ---
  173. tone_results = analyze_tone(text)
  174. tones = [r.tag for r in tone_results]
  175. # Tone-Events emittieren
  176. events = getattr(self._application, "events", None)
  177. if events and tone_results:
  178. await emit_tone_events(tone_results, text, events)
  179. # --- intent_received emittieren ---
  180. t_total = (time.monotonic() - t_start) * 1000
  181. # Session-Info sammeln
  182. session_info = self._get_session_info(satellite_id)
  183. intent_event = IntentReceived(
  184. satellite_id=satellite_id,
  185. intent=intent,
  186. confidence=confidence,
  187. slots=slots,
  188. original_text=text,
  189. session_id=session_info.get("session_id", ""),
  190. room_id=session_info.get("room_id", ""),
  191. wakeword_type=session_info.get("wakeword_type", "custom"),
  192. is_authenticated=self._is_authenticated(satellite_id),
  193. language=getattr(event_data, "language", "de") or "de",
  194. metadata={
  195. "matched_by": matched_by,
  196. "tones": [{"tag": r.tag, "category": r.category} for r in tone_results],
  197. "inference_ms": t_total,
  198. },
  199. )
  200. pinfo(
  201. f"[Classifier] Intent: '{intent}' (conf={confidence:.2f}, "
  202. f"by={matched_by}, {t_total:.1f}ms, "
  203. f"tones={tones[:3]})"
  204. )
  205. await self._application.events.trigger("intent_received", intent_event)
  206. # Event canceln — verhindert dass nlp_llm nochmal laeuft
  207. if hasattr(event_data, "cancel"):
  208. event_data.cancel()
  209. # === Hilfsmethoden ===
  210. def _extract_slots_for_intent(self, text: str, intent_name: str) -> dict[str, Any]:
  211. """
  212. Extrahiert Slots fuer einen bestimmten Intent.
  213. Nutzt den KeywordMatcher um die Patterns des erkannten Intents
  214. gezielt gegen den Text zu matchen. Fallback auf einfache Heuristik.
  215. Args:
  216. text: Eingabetext
  217. intent_name: Vom Classifier erkannter Intent
  218. Returns:
  219. Dict mit extrahierten Slots
  220. """
  221. if not self._keyword_matcher:
  222. return {}
  223. # Strategie 1: KeywordMatcher Patterns fuer diesen Intent direkt matchen
  224. compiled_patterns = self._keyword_matcher._compiled.get(intent_name, [])
  225. if compiled_patterns:
  226. text_lower = text.lower()
  227. # Tokenisieren (wie der KeywordMatcher es macht)
  228. import re
  229. tokens = re.findall(r"[\w]+", text_lower)
  230. best_slots: dict[str, Any] = {}
  231. best_conf = 0.0
  232. for pattern in compiled_patterns:
  233. result = self._keyword_matcher._match_pattern(tokens, pattern)
  234. if result and result.get("confidence", 0) > best_conf:
  235. best_conf = result["confidence"]
  236. best_slots = result.get("slots", {})
  237. if best_slots:
  238. pdebug(f"[Classifier] Slots extrahiert: {best_slots}")
  239. return best_slots
  240. # Strategie 2: Generischen Match versuchen (beliebiger Intent)
  241. kw_result = self._keyword_matcher.match(text)
  242. if kw_result and kw_result.slots:
  243. pdebug(f"[Classifier] Slots via generischem Match ({kw_result.intent}): {kw_result.slots}")
  244. return kw_result.slots
  245. # Strategie 3: Heuristik — Stoppwoerter entfernen, Rest als Query
  246. # Fuer Intents mit freiem {query} Slot (play_music, search_notes, etc.)
  247. query_intents = {
  248. "play_music", "search_notes", "search_appointment",
  249. }
  250. if intent_name in query_intents:
  251. query = self._extract_query_heuristic(text)
  252. if query:
  253. pdebug(f"[Classifier] Query via Heuristik: '{query}'")
  254. return {"query": query}
  255. return {}
  256. @staticmethod
  257. def _extract_query_heuristic(text: str) -> str:
  258. """
  259. Extrahiert einen Query aus dem Text durch Entfernen von Stoppwoertern.
  260. "koenntest du bitte musik von rammstein spielen"
  261. → entferne: koenntest, du, bitte, musik, von, spielen
  262. → uebrig: "rammstein"
  263. """
  264. stop_words = {
  265. # Hoeflichkeit
  266. "koenntest", "kannst", "wuerdest", "waerst", "bitte", "mal",
  267. "du", "mir", "doch", "gerne", "gern",
  268. # Musik-spezifisch
  269. "musik", "spiel", "spiele", "spielen", "abspielen", "abspiel",
  270. "mach", "mache", "hoeren", "hoer", "an", "von", "etwas",
  271. "lied", "song", "track",
  272. # Allgemein
  273. "ich", "moechte", "will", "haette", "lass", "wir",
  274. "den", "die", "das", "der", "dem", "ein", "eine", "einen",
  275. "und", "oder", "fuer", "mit", "auf", "in", "im",
  276. "sei", "so", "lieb", "nett", "es",
  277. # Suche
  278. "suche", "such", "suchen", "finde", "finden", "nach",
  279. }
  280. words = text.lower().split()
  281. # Stoppwoerter entfernen
  282. query_words = [w for w in words if w not in stop_words]
  283. # Satzzeichen entfernen
  284. query = " ".join(query_words).strip("?!.,")
  285. return query if query else ""
  286. def _get_session_info(self, satellite_id: str) -> dict[str, Any]:
  287. """Holt Session-Info fuer den Satellite."""
  288. try:
  289. if hasattr(self._application, "satellites"):
  290. satellite = self._application.satellites.get(satellite_id)
  291. if satellite:
  292. conv = getattr(satellite, "conversation", None)
  293. if conv:
  294. return {
  295. "session_id": getattr(conv, "session_id", ""),
  296. "room_id": getattr(satellite, "room", ""),
  297. "wakeword_type": getattr(conv, "wakeword_type", "custom"),
  298. }
  299. return {"room_id": getattr(satellite, "room", "")}
  300. except Exception:
  301. pass
  302. return {}
  303. def _is_authenticated(self, satellite_id: str) -> bool:
  304. """Prueft ob der Satellite authentifiziert ist (Admin-Befehle)."""
  305. # TODO: Admin-Session-Verwaltung (wie in nlp_llm)
  306. return False