intent_classifier.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377
  1. # -*- coding: utf-8 -*-
  2. """
  3. Intent-Classifier Runtime.
  4. Laedt das trainierte Intent-Modell (ONNX oder PyTorch)
  5. und klassifiziert Eingabesaetze in Intents + Slots.
  6. Inferenz auf Pi 4: <100ms
  7. Modellgroesse: ~30-50MB
  8. Beispiel:
  9. ```python
  10. classifier = IntentClassifier()
  11. await classifier.load("models/intent")
  12. result = await classifier.classify("Mach das Licht im Wohnzimmer an")
  13. # IntentPrediction(
  14. # intent="lightswitch",
  15. # confidence=0.97,
  16. # slots={"room": "Wohnzimmer", "status": "an"},
  17. # )
  18. ```
  19. """
  20. from __future__ import annotations
  21. import json
  22. import time
  23. from dataclasses import dataclass, field
  24. from pathlib import Path
  25. from typing import Any
  26. from trixy_core.utils.debug import pinfo, pdebug, perror
  27. @dataclass
  28. class IntentPrediction:
  29. """Ergebnis einer Intent-Klassifikation."""
  30. intent: str
  31. confidence: float
  32. slots: dict[str, str | list[str]] = field(default_factory=dict)
  33. tones: list[str] = field(default_factory=list) # Emotionaler Ton des Satzes
  34. inference_ms: float = 0.0
  35. # Top-N Alternativen
  36. alternatives: list[tuple[str, float]] = field(default_factory=list)
  37. @property
  38. def is_confident(self) -> bool:
  39. """True wenn Confidence ueber Schwelle (0.5)."""
  40. return self.confidence >= 0.5
  41. @property
  42. def is_friendly(self) -> bool:
  43. """True wenn Ton freundlich ist."""
  44. return any(t in self.tones for t in ("friendly", "polite", "nice", "grateful"))
  45. @property
  46. def is_rude(self) -> bool:
  47. """True wenn Ton unhoeflich/aggressiv ist."""
  48. return any(t in self.tones for t in ("rude", "aggressive"))
  49. @property
  50. def is_urgent(self) -> bool:
  51. """True wenn dringend."""
  52. return "urgent" in self.tones
  53. def to_dict(self) -> dict[str, Any]:
  54. from trixy_core.nlp.tone import TAG_TO_CATEGORY
  55. return {
  56. "intent": self.intent,
  57. "confidence": self.confidence,
  58. "slots": self.slots,
  59. "tones": [
  60. {"tag": t, "category": TAG_TO_CATEGORY.get(t, "unknown")}
  61. for t in self.tones
  62. ],
  63. "inference_ms": self.inference_ms,
  64. }
  65. class IntentClassifier:
  66. """
  67. Intent-Classifier fuer Trixy.
  68. Nutzt Sentence-Embeddings + trainiertes Klassifikationsmodell
  69. fuer schnelle Intent-Erkennung auf CPU.
  70. """
  71. def __init__(self) -> None:
  72. self._encoder = None # SentenceTransformer oder ONNX-Encoder
  73. self._tokenizer = None # HuggingFace Tokenizer (fuer ONNX-Encoder)
  74. self._classifier = None # ONNX InferenceSession oder PyTorch
  75. self._use_onnx = False
  76. self._use_onnx_encoder = False # True wenn ONNX-Encoder statt SentenceTransformer
  77. self._intent_names: list[str] = []
  78. self._slot_names: list[str] = []
  79. self._embedding_dim: int = 0
  80. self._loaded = False
  81. self._model_dir: Path | None = None
  82. @property
  83. def is_loaded(self) -> bool:
  84. """True wenn Modell geladen."""
  85. return self._loaded
  86. @property
  87. def intent_count(self) -> int:
  88. """Anzahl bekannter Intents."""
  89. return len(self._intent_names)
  90. @property
  91. def intent_names(self) -> list[str]:
  92. """Liste aller bekannten Intent-Namen."""
  93. return list(self._intent_names)
  94. async def load(self, model_dir: str | Path) -> bool:
  95. """
  96. Laedt das trainierte Modell.
  97. Args:
  98. model_dir: Verzeichnis mit metafile.json, classifier.onnx/pth
  99. Returns:
  100. True bei Erfolg
  101. """
  102. model_dir = Path(model_dir)
  103. self._model_dir = model_dir
  104. # Metadaten laden
  105. metafile = model_dir / "metafile.json"
  106. if not metafile.is_file():
  107. pdebug(f"IntentClassifier: Kein Modell in {model_dir}")
  108. return False
  109. try:
  110. with open(metafile) as f:
  111. meta = json.load(f)
  112. self._embedding_dim = meta["embedding_dim"]
  113. base_model = meta["base_model"]
  114. # Labels laden
  115. with open(model_dir / "intent_labels.json") as f:
  116. self._intent_names = json.load(f)
  117. self._slot_names = meta.get("slot_names", [])
  118. except (json.JSONDecodeError, KeyError, OSError) as e:
  119. perror(f"IntentClassifier: Metadaten-Fehler: {e}")
  120. return False
  121. # Encoder laden (ONNX bevorzugt, Fallback SentenceTransformer)
  122. encoder_dir = model_dir / "encoder_onnx"
  123. has_onnx_encoder = encoder_dir.is_dir() and any(
  124. f.suffix == ".onnx" for f in encoder_dir.iterdir() if f.is_file()
  125. )
  126. if has_onnx_encoder:
  127. # ONNX-Encoder (kein PyTorch noetig, ~112MB)
  128. try:
  129. from optimum.onnxruntime import ORTModelForFeatureExtraction
  130. from transformers import AutoTokenizer
  131. # Quantisiertes Modell bevorzugen
  132. onnx_files = [f.name for f in encoder_dir.iterdir() if f.suffix == ".onnx"]
  133. file_name = "model_quantized.onnx" if "model_quantized.onnx" in onnx_files else None
  134. self._encoder = ORTModelForFeatureExtraction.from_pretrained(
  135. str(encoder_dir), file_name=file_name,
  136. )
  137. self._tokenizer = AutoTokenizer.from_pretrained(str(encoder_dir))
  138. self._use_onnx_encoder = True
  139. pinfo(f"IntentClassifier: ONNX-Encoder geladen ({encoder_dir})")
  140. except ImportError:
  141. pdebug("optimum nicht verfuegbar, versuche SentenceTransformer")
  142. except Exception as e:
  143. pdebug(f"ONNX-Encoder Fehler: {e}, versuche SentenceTransformer")
  144. if not self._use_onnx_encoder:
  145. # Fallback: SentenceTransformer (braucht PyTorch, ~400MB)
  146. try:
  147. from sentence_transformers import SentenceTransformer
  148. self._encoder = SentenceTransformer(base_model)
  149. pdebug(f"IntentClassifier: SentenceTransformer geladen ({base_model})")
  150. except ImportError:
  151. perror("Weder optimum noch sentence-transformers installiert")
  152. return False
  153. # Classifier laden (ONNX bevorzugt, Fallback PyTorch)
  154. onnx_path = model_dir / "intent_classifier.onnx"
  155. pth_path = model_dir / "classifier.pth"
  156. if onnx_path.is_file():
  157. try:
  158. import onnxruntime as ort
  159. self._classifier = ort.InferenceSession(
  160. str(onnx_path),
  161. providers=["CPUExecutionProvider"],
  162. )
  163. self._use_onnx = True
  164. pdebug(f"IntentClassifier: ONNX Modell geladen")
  165. except ImportError:
  166. pdebug("onnxruntime nicht verfuegbar, versuche PyTorch")
  167. if not self._use_onnx and pth_path.is_file():
  168. try:
  169. import torch
  170. num_intents = len(self._intent_names)
  171. self._classifier = torch.nn.Sequential(
  172. torch.nn.Linear(self._embedding_dim, 256),
  173. torch.nn.ReLU(),
  174. torch.nn.Dropout(0.3),
  175. torch.nn.Linear(256, num_intents),
  176. )
  177. self._classifier.load_state_dict(
  178. torch.load(pth_path, map_location="cpu", weights_only=True)
  179. )
  180. self._classifier.eval()
  181. pdebug(f"IntentClassifier: PyTorch Modell geladen")
  182. except Exception as e:
  183. perror(f"IntentClassifier: PyTorch laden fehlgeschlagen: {e}")
  184. return False
  185. if self._classifier is None:
  186. perror(f"IntentClassifier: Kein Modell gefunden in {model_dir}")
  187. return False
  188. self._loaded = True
  189. pinfo(
  190. f"IntentClassifier geladen: {len(self._intent_names)} Intents"
  191. f"{', ONNX-Encoder' if self._use_onnx_encoder else ', SentenceTransformer'}"
  192. )
  193. return True
  194. async def classify(
  195. self,
  196. text: str,
  197. min_confidence: float = 0.3,
  198. top_n: int = 3,
  199. ) -> IntentPrediction:
  200. """
  201. Klassifiziert einen Text in Intent + Slots.
  202. Args:
  203. text: Eingabetext
  204. min_confidence: Minimale Confidence fuer gueltigen Intent
  205. top_n: Anzahl Alternativen zurueckgeben
  206. Returns:
  207. IntentPrediction
  208. """
  209. if not self._loaded:
  210. return IntentPrediction(intent="unknown", confidence=0.0)
  211. start = time.monotonic()
  212. # 1. Embedding berechnen
  213. import numpy as np
  214. if self._use_onnx_encoder:
  215. # ONNX-Encoder: Tokenize → Encoder → Mean-Pooling
  216. inputs = self._tokenizer(
  217. text, return_tensors="np",
  218. padding=True, truncation=True, max_length=128,
  219. )
  220. outputs = self._encoder(**inputs)
  221. # Mean-Pooling ueber Token-Embeddings (ohne Padding)
  222. token_embeddings = outputs.last_hidden_state[0] # (seq_len, dim)
  223. attention_mask = inputs["attention_mask"][0] # (seq_len,)
  224. mask = attention_mask.astype(np.float32)
  225. masked = token_embeddings * mask[:, np.newaxis]
  226. embedding = masked.sum(axis=0) / mask.sum()
  227. embedding = embedding.reshape(1, -1).astype(np.float32)
  228. else:
  229. # SentenceTransformer: Direkte Embedding-Berechnung
  230. embedding = self._encoder.encode([text], show_progress_bar=False)
  231. embedding = np.array(embedding, dtype=np.float32)
  232. # 2. Classifier ausfuehren
  233. if self._use_onnx:
  234. outputs = self._classifier.run(None, {"embedding": embedding})
  235. logits = outputs[0][0]
  236. else:
  237. import torch
  238. with torch.no_grad():
  239. logits = self._classifier(torch.FloatTensor(embedding))
  240. logits = logits[0].numpy()
  241. # 3. Softmax + Top-N
  242. exp_logits = np.exp(logits - np.max(logits))
  243. probabilities = exp_logits / exp_logits.sum()
  244. top_indices = np.argsort(probabilities)[::-1][:top_n]
  245. best_idx = top_indices[0]
  246. best_confidence = float(probabilities[best_idx])
  247. best_intent = self._intent_names[best_idx]
  248. # Negativ-Klasse pruefen
  249. if best_intent == "__negative__" or best_confidence < min_confidence:
  250. best_intent = "unknown"
  251. best_confidence = 0.0
  252. # Alternativen
  253. alternatives = [
  254. (self._intent_names[idx], float(probabilities[idx]))
  255. for idx in top_indices[1:]
  256. if self._intent_names[idx] != "__negative__"
  257. ]
  258. # 4. Slot-Extraktion (regelbasiert auf Basis der Slot-Listen)
  259. slots = self._extract_slots(text) if best_intent != "unknown" else {}
  260. # 5. Tone-Analyse (Schlagwort + Satzstruktur)
  261. from trixy_core.nlp.tone import analyze_tone
  262. tone_results = analyze_tone(text)
  263. tones = [r.tag for r in tone_results]
  264. elapsed = (time.monotonic() - start) * 1000
  265. return IntentPrediction(
  266. intent=best_intent,
  267. confidence=best_confidence,
  268. slots=slots,
  269. tones=tones,
  270. inference_ms=elapsed,
  271. alternatives=alternatives,
  272. )
  273. def _extract_slots(self, text: str) -> dict[str, str | list[str]]:
  274. """
  275. Extrahiert Slots aus dem Text via Pattern-Matching.
  276. Nutzt die Slot-Listen um bekannte Werte zu finden.
  277. """
  278. slots: dict[str, str | list[str]] = {}
  279. text_lower = text.lower()
  280. # Slot-Listen laden (gecacht aus Metafile)
  281. if not self._model_dir:
  282. return slots
  283. slot_file = self._model_dir / ".." / ".." / "config" / "slot_lists.json"
  284. if not slot_file.is_file():
  285. # Fallback: Default Slot-Registry
  286. from trixy_core.trainer.core.intent.slot_lists import SlotRegistry
  287. registry = SlotRegistry()
  288. for slot_name in self._slot_names:
  289. values = registry.get_values(slot_name)
  290. found = self._find_values_in_text(text_lower, values)
  291. if found:
  292. slots[slot_name] = found if len(found) > 1 else found[0]
  293. return slots
  294. try:
  295. with open(slot_file) as f:
  296. slot_data = json.load(f)
  297. for slot_name in self._slot_names:
  298. entry = slot_data.get(slot_name, {})
  299. values = entry.get("values", []) if isinstance(entry, dict) else entry
  300. found = self._find_values_in_text(text_lower, values)
  301. if found:
  302. slots[slot_name] = found if len(found) > 1 else found[0]
  303. except (json.JSONDecodeError, OSError):
  304. pass
  305. return slots
  306. @staticmethod
  307. def _find_values_in_text(text: str, values: list[str]) -> list[str]:
  308. """Findet Slot-Werte im Text (case-insensitive)."""
  309. found = []
  310. # Laengste Werte zuerst (damit "Wohnzimmer" vor "Wohn" matched)
  311. for value in sorted(values, key=len, reverse=True):
  312. if value.lower() in text:
  313. found.append(value)
  314. # Wert aus Text entfernen um Doppel-Matches zu vermeiden
  315. text = text.replace(value.lower(), "", 1)
  316. return found