| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452 |
- # -*- coding: utf-8 -*-
- """
- Intent-Classifier Runtime.
- Laedt das trainierte Intent-Modell (ONNX oder PyTorch)
- und klassifiziert Eingabesaetze in Intents + Slots.
- Inferenz auf Pi 4: <100ms
- Modellgroesse: ~30-50MB
- Beispiel:
- ```python
- classifier = IntentClassifier()
- await classifier.load("models/intent")
- result = await classifier.classify("Mach das Licht im Wohnzimmer an")
- # IntentPrediction(
- # intent="lightswitch",
- # confidence=0.97,
- # slots={"room": "Wohnzimmer", "status": "an"},
- # )
- ```
- """
- from __future__ import annotations
- import json
- import time
- from dataclasses import dataclass, field
- from pathlib import Path
- from typing import Any
- from trixy_core.utils.debug import pinfo, pdebug, perror
- @dataclass
- class IntentPrediction:
- """Ergebnis einer Intent-Klassifikation."""
- intent: str
- confidence: float
- slots: dict[str, str | list[str]] = field(default_factory=dict)
- tones: list[str] = field(default_factory=list) # Emotionaler Ton des Satzes
- inference_ms: float = 0.0
- # Top-N Alternativen
- alternatives: list[tuple[str, float]] = field(default_factory=list)
- @property
- def is_confident(self) -> bool:
- """True wenn Confidence ueber Schwelle (0.5)."""
- return self.confidence >= 0.5
- @property
- def is_friendly(self) -> bool:
- """True wenn Ton freundlich ist."""
- return any(t in self.tones for t in ("friendly", "polite", "nice", "grateful"))
- @property
- def is_rude(self) -> bool:
- """True wenn Ton unhoeflich/aggressiv ist."""
- return any(t in self.tones for t in ("rude", "aggressive"))
- @property
- def is_urgent(self) -> bool:
- """True wenn dringend."""
- return "urgent" in self.tones
- def to_dict(self) -> dict[str, Any]:
- from trixy_core.nlp.tone import TAG_TO_CATEGORY
- return {
- "intent": self.intent,
- "confidence": self.confidence,
- "slots": self.slots,
- "tones": [
- {"tag": t, "category": TAG_TO_CATEGORY.get(t, "unknown")}
- for t in self.tones
- ],
- "inference_ms": self.inference_ms,
- }
- class IntentClassifier:
- """
- Intent-Classifier fuer Trixy.
- Nutzt Sentence-Embeddings + trainiertes Klassifikationsmodell
- fuer schnelle Intent-Erkennung auf CPU.
- """
- def __init__(self) -> None:
- self._encoder = None # SentenceTransformer oder ONNX-Encoder
- self._tokenizer = None # HuggingFace Tokenizer (fuer ONNX-Encoder)
- self._classifier = None # ONNX InferenceSession oder PyTorch
- self._slot_tagger = None # ONNX InferenceSession fuer BIO-Tagger
- self._bio_tagset = None # BIOTagSet fuer Tag-Decoding
- self._use_onnx = False
- self._use_onnx_encoder = False
- self._has_bio_tagger = False
- self._intent_names: list[str] = []
- self._slot_names: list[str] = []
- self._embedding_dim: int = 0
- self._loaded = False
- self._model_dir: Path | None = None
- @property
- def is_loaded(self) -> bool:
- """True wenn Modell geladen."""
- return self._loaded
- @property
- def intent_count(self) -> int:
- """Anzahl bekannter Intents."""
- return len(self._intent_names)
- @property
- def intent_names(self) -> list[str]:
- """Liste aller bekannten Intent-Namen."""
- return list(self._intent_names)
- async def load(self, model_dir: str | Path) -> bool:
- """
- Laedt das trainierte Modell.
- Args:
- model_dir: Verzeichnis mit metafile.json, classifier.onnx/pth
- Returns:
- True bei Erfolg
- """
- model_dir = Path(model_dir)
- self._model_dir = model_dir
- # Metadaten laden
- metafile = model_dir / "metafile.json"
- if not metafile.is_file():
- pdebug(f"IntentClassifier: Kein Modell in {model_dir}")
- return False
- try:
- with open(metafile) as f:
- meta = json.load(f)
- self._embedding_dim = meta["embedding_dim"]
- base_model = meta["base_model"]
- # Labels laden
- with open(model_dir / "intent_labels.json") as f:
- self._intent_names = json.load(f)
- self._slot_names = meta.get("slot_names", [])
- except (json.JSONDecodeError, KeyError, OSError) as e:
- perror(f"IntentClassifier: Metadaten-Fehler: {e}")
- return False
- # Encoder laden (ONNX bevorzugt, Fallback SentenceTransformer)
- encoder_dir = model_dir / "encoder_onnx"
- has_onnx_encoder = encoder_dir.is_dir() and any(
- f.suffix == ".onnx" for f in encoder_dir.iterdir() if f.is_file()
- )
- if has_onnx_encoder:
- # ONNX-Encoder (kein PyTorch noetig, ~112MB)
- try:
- from optimum.onnxruntime import ORTModelForFeatureExtraction
- from transformers import AutoTokenizer
- # Quantisiertes Modell bevorzugen
- onnx_files = [f.name for f in encoder_dir.iterdir() if f.suffix == ".onnx"]
- file_name = "model_quantized.onnx" if "model_quantized.onnx" in onnx_files else None
- self._encoder = ORTModelForFeatureExtraction.from_pretrained(
- str(encoder_dir), file_name=file_name,
- )
- self._tokenizer = AutoTokenizer.from_pretrained(str(encoder_dir))
- self._use_onnx_encoder = True
- pinfo(f"IntentClassifier: ONNX-Encoder geladen ({encoder_dir})")
- except ImportError:
- pdebug("optimum nicht verfuegbar, versuche SentenceTransformer")
- except Exception as e:
- pdebug(f"ONNX-Encoder Fehler: {e}, versuche SentenceTransformer")
- if not self._use_onnx_encoder:
- # Fallback: SentenceTransformer (braucht PyTorch, ~400MB)
- try:
- from sentence_transformers import SentenceTransformer
- self._encoder = SentenceTransformer(base_model)
- pdebug(f"IntentClassifier: SentenceTransformer geladen ({base_model})")
- except ImportError:
- perror("Weder optimum noch sentence-transformers installiert")
- return False
- # Classifier laden (ONNX bevorzugt, Fallback PyTorch)
- onnx_path = model_dir / "intent_classifier.onnx"
- pth_path = model_dir / "classifier.pth"
- if onnx_path.is_file():
- try:
- import onnxruntime as ort
- self._classifier = ort.InferenceSession(
- str(onnx_path),
- providers=["CPUExecutionProvider"],
- )
- self._use_onnx = True
- pdebug(f"IntentClassifier: ONNX Modell geladen")
- except ImportError:
- pdebug("onnxruntime nicht verfuegbar, versuche PyTorch")
- if not self._use_onnx and pth_path.is_file():
- try:
- import torch
- num_intents = len(self._intent_names)
- self._classifier = torch.nn.Sequential(
- torch.nn.Linear(self._embedding_dim, 256),
- torch.nn.ReLU(),
- torch.nn.Dropout(0.3),
- torch.nn.Linear(256, num_intents),
- )
- self._classifier.load_state_dict(
- torch.load(pth_path, map_location="cpu", weights_only=True)
- )
- self._classifier.eval()
- pdebug(f"IntentClassifier: PyTorch Modell geladen")
- except Exception as e:
- perror(f"IntentClassifier: PyTorch laden fehlgeschlagen: {e}")
- return False
- if self._classifier is None:
- perror(f"IntentClassifier: Kein Modell gefunden in {model_dir}")
- return False
- # BIO Slot-Tagger laden (optional)
- bio_onnx = model_dir / "slot_tagger.onnx"
- bio_labels = model_dir / "bio_labels.json"
- if bio_onnx.is_file() and bio_labels.is_file():
- try:
- import onnxruntime as ort
- self._slot_tagger = ort.InferenceSession(
- str(bio_onnx), providers=["CPUExecutionProvider"],
- )
- with open(bio_labels) as f:
- from trixy_core.trainer.core.intent.bio_tagger import BIOTagSet
- self._bio_tagset = BIOTagSet.from_dict(json.load(f))
- self._has_bio_tagger = True
- pinfo(f"IntentClassifier: BIO Slot-Tagger geladen ({self._bio_tagset.num_tags} Tags)")
- except Exception as e:
- pdebug(f"BIO Slot-Tagger nicht geladen: {e}")
- self._loaded = True
- pinfo(
- f"IntentClassifier geladen: {len(self._intent_names)} Intents"
- f"{', ONNX-Encoder' if self._use_onnx_encoder else ', SentenceTransformer'}"
- f"{', BIO-Slots' if self._has_bio_tagger else ''}"
- )
- return True
- async def classify(
- self,
- text: str,
- min_confidence: float = 0.3,
- top_n: int = 3,
- ) -> IntentPrediction:
- """
- Klassifiziert einen Text in Intent + Slots.
- Args:
- text: Eingabetext
- min_confidence: Minimale Confidence fuer gueltigen Intent
- top_n: Anzahl Alternativen zurueckgeben
- Returns:
- IntentPrediction
- """
- if not self._loaded:
- return IntentPrediction(intent="unknown", confidence=0.0)
- start = time.monotonic()
- # 1. Embedding berechnen
- import numpy as np
- if self._use_onnx_encoder:
- # ONNX-Encoder: Tokenize → Encoder → Mean-Pooling
- inputs = self._tokenizer(
- text, return_tensors="np",
- padding=True, truncation=True, max_length=128,
- )
- outputs = self._encoder(**inputs)
- # Mean-Pooling ueber Token-Embeddings (ohne Padding)
- token_embeddings = outputs.last_hidden_state[0] # (seq_len, dim)
- attention_mask = inputs["attention_mask"][0] # (seq_len,)
- mask = attention_mask.astype(np.float32)
- masked = token_embeddings * mask[:, np.newaxis]
- embedding = masked.sum(axis=0) / mask.sum()
- embedding = embedding.reshape(1, -1).astype(np.float32)
- else:
- # SentenceTransformer: Direkte Embedding-Berechnung
- embedding = self._encoder.encode([text], show_progress_bar=False)
- embedding = np.array(embedding, dtype=np.float32)
- # 2. Classifier ausfuehren
- if self._use_onnx:
- outputs = self._classifier.run(None, {"embedding": embedding})
- logits = outputs[0][0]
- else:
- import torch
- with torch.no_grad():
- logits = self._classifier(torch.FloatTensor(embedding))
- logits = logits[0].numpy()
- # 3. Softmax + Top-N
- exp_logits = np.exp(logits - np.max(logits))
- probabilities = exp_logits / exp_logits.sum()
- top_indices = np.argsort(probabilities)[::-1][:top_n]
- best_idx = top_indices[0]
- best_confidence = float(probabilities[best_idx])
- best_intent = self._intent_names[best_idx]
- # Negativ-Klasse pruefen
- if best_intent == "__negative__" or best_confidence < min_confidence:
- best_intent = "unknown"
- best_confidence = 0.0
- # Alternativen
- alternatives = [
- (self._intent_names[idx], float(probabilities[idx]))
- for idx in top_indices[1:]
- if self._intent_names[idx] != "__negative__"
- ]
- # 4. Slot-Extraktion
- slots: dict[str, Any] = {}
- if best_intent != "unknown":
- # BIO-Tagger hat Vorrang (neuronale Slot-Extraktion)
- if self._has_bio_tagger and self._use_onnx_encoder:
- slots = self._extract_slots_bio(token_embeddings, inputs)
- # Fallback: Regelbasiert
- if not slots:
- slots = self._extract_slots(text)
- # 5. Tone-Analyse (Schlagwort + Satzstruktur)
- from trixy_core.nlp.tone import analyze_tone
- tone_results = analyze_tone(text)
- tones = [r.tag for r in tone_results]
- elapsed = (time.monotonic() - start) * 1000
- return IntentPrediction(
- intent=best_intent,
- confidence=best_confidence,
- slots=slots,
- tones=tones,
- inference_ms=elapsed,
- alternatives=alternatives,
- )
- def _extract_slots_bio(
- self, token_embeddings: "np.ndarray", inputs: dict,
- ) -> dict[str, Any]:
- """
- Extrahiert Slots via BIO-Tagger auf Token-Embeddings.
- Args:
- token_embeddings: (seq_len, 384) vom Encoder
- inputs: Tokenizer-Output mit input_ids
- Returns:
- Dict mit extrahierten Slots
- """
- import numpy as np
- if not self._slot_tagger or not self._bio_tagset:
- return {}
- try:
- # BIO-Logits berechnen: (1, seq_len, num_tags)
- token_emb_batch = token_embeddings.reshape(1, *token_embeddings.shape).astype(np.float32)
- bio_outputs = self._slot_tagger.run(
- None, {"token_embeddings": token_emb_batch},
- )
- bio_logits = bio_outputs[0][0] # (seq_len, num_tags)
- # Argmax → Tag-Indizes
- tag_indices = bio_logits.argmax(axis=1).tolist() # (seq_len,)
- # Token-IDs fuer Decoding
- token_ids = inputs["input_ids"][0].tolist() if hasattr(inputs["input_ids"], "tolist") else list(inputs["input_ids"][0])
- # BIO-Tags decodieren → Slots
- from trixy_core.trainer.core.intent.bio_tagger import decode_bio_tags
- slots = decode_bio_tags(
- tag_indices, token_ids, self._bio_tagset, self._tokenizer,
- )
- if slots:
- pdebug(f"IntentClassifier BIO-Slots: {slots}")
- return slots
- except Exception as e:
- pdebug(f"BIO Slot-Extraktion Fehler: {e}")
- return {}
- def _extract_slots(self, text: str) -> dict[str, str | list[str]]:
- """
- Extrahiert Slots aus dem Text via Pattern-Matching.
- Nutzt die Slot-Listen um bekannte Werte zu finden.
- """
- slots: dict[str, str | list[str]] = {}
- text_lower = text.lower()
- # Slot-Listen laden (gecacht aus Metafile)
- if not self._model_dir:
- return slots
- slot_file = self._model_dir / ".." / ".." / "config" / "slot_lists.json"
- if not slot_file.is_file():
- # Fallback: Default Slot-Registry
- from trixy_core.trainer.core.intent.slot_lists import SlotRegistry
- registry = SlotRegistry()
- for slot_name in self._slot_names:
- values = registry.get_values(slot_name)
- found = self._find_values_in_text(text_lower, values)
- if found:
- slots[slot_name] = found if len(found) > 1 else found[0]
- return slots
- try:
- with open(slot_file) as f:
- slot_data = json.load(f)
- for slot_name in self._slot_names:
- entry = slot_data.get(slot_name, {})
- values = entry.get("values", []) if isinstance(entry, dict) else entry
- found = self._find_values_in_text(text_lower, values)
- if found:
- slots[slot_name] = found if len(found) > 1 else found[0]
- except (json.JSONDecodeError, OSError):
- pass
- return slots
- @staticmethod
- def _find_values_in_text(text: str, values: list[str]) -> list[str]:
- """Findet Slot-Werte im Text (case-insensitive)."""
- found = []
- # Laengste Werte zuerst (damit "Wohnzimmer" vor "Wohn" matched)
- for value in sorted(values, key=len, reverse=True):
- if value.lower() in text:
- found.append(value)
- # Wert aus Text entfernen um Doppel-Matches zu vermeiden
- text = text.replace(value.lower(), "", 1)
- return found
|