keyword_matcher.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618
  1. # -*- coding: utf-8 -*-
  2. """
  3. Keyword Intent Matcher — Snips-aehnlicher Pre-Filter vor dem LLM.
  4. Erkennt eindeutige Befehle in <5ms ueber Pattern-Matching und
  5. Fuzzy-Token-Vergleich. Nur bei niedrigem Confidence-Score wird
  6. das LLM als Fallback aufgerufen.
  7. Pattern-Syntax:
  8. wort — Pflicht-Keyword
  9. [wort] — Optionales Wort (0 oder 1)
  10. [a|b|c] — Optionale Alternativen
  11. (a|b) — Pflicht-Alternativen (genau 1)
  12. {slot_name} — Slot-Extraktion (Rest-Wort)
  13. {a|b|} — Inline-Alternativen (letztes leer = optional)
  14. """
  15. from __future__ import annotations
  16. import re
  17. from dataclasses import dataclass, field
  18. from difflib import SequenceMatcher
  19. from typing import Any
  20. from trixy_core.utils.debug import pdebug
  21. # Optionaler Import — Entity-System ist nicht zwingend erforderlich
  22. try:
  23. from trixy_core.nlp.entities import EntityRegistry, EntityResolver, ResolvedEntity
  24. _HAS_ENTITIES = True
  25. except ImportError:
  26. _HAS_ENTITIES = False
  27. # ============================================================================
  28. # Datenklassen
  29. # ============================================================================
  30. @dataclass
  31. class PatternToken:
  32. """Ein kompiliertes Pattern-Element."""
  33. kind: str # "literal", "optional", "alternatives", "slot"
  34. values: list[str] = field(default_factory=list)
  35. slot_name: str = ""
  36. entity_type: str = "" # Entity-Typ fuer Validierung (z.B. "datum", "stadt")
  37. required: bool = True
  38. @dataclass
  39. class CompiledPattern:
  40. """Ein kompiliertes Pattern mit Quell-String."""
  41. tokens: list[PatternToken] = field(default_factory=list)
  42. source: str = ""
  43. @dataclass
  44. class MatchResult:
  45. """Ergebnis eines erfolgreichen Matches."""
  46. intent: str = ""
  47. confidence: float = 0.0
  48. slots: dict[str, Any] = field(default_factory=dict)
  49. sentiment: dict[str, Any] = field(default_factory=dict)
  50. matched_by: str = "" # "pattern" oder "example"
  51. # ============================================================================
  52. # Sentiment-Woerter
  53. # ============================================================================
  54. POLITE_WORDS: set[str] = {
  55. "bitte", "danke", "freundlicherweise", "koenntest", "wuerdest",
  56. "koennten", "wuerden", "gerne", "bitteschoen",
  57. }
  58. RUDE_WORDS: set[str] = {
  59. "verdammt", "scheisse", "mist", "verflucht", "bloed", "dumm",
  60. "idiot", "doof", "kacke", "scheiße",
  61. }
  62. URGENT_WORDS: set[str] = {
  63. "sofort", "schnell", "jetzt", "dringend", "eilig", "hurtig",
  64. }
  65. # Woerter die beim Matching ignoriert werden (Fuellwoerter)
  66. _FILLER_WORDS: set[str] = {
  67. "mal", "mir", "du", "kannst", "koenntest", "wuerdest",
  68. "eigentlich", "doch", "auch", "noch",
  69. }
  70. # Pattern-Syntax: Regex zum Erkennen der Elemente
  71. _PATTERN_RE = re.compile(
  72. r"""
  73. \( ([^)]+) \) | # (a|b) — Pflicht-Alternativen
  74. \[ ([^\]]+) \] | # [a|b] — Optionale Alternativen / optionales Wort
  75. \{ ([^}]+) \} | # {slot} oder {a|b|} — Slot / Inline-Alternativen
  76. (\S+) # Pflicht-Wort
  77. """,
  78. re.VERBOSE,
  79. )
  80. class KeywordIntentMatcher:
  81. """
  82. Snips-aehnlicher Keyword-Matcher als LLM-Pre-Filter.
  83. Kompiliert Intent-Patterns einmal beim Laden und matcht
  84. eingehenden Text in <5ms gegen alle Patterns.
  85. """
  86. def __init__(self, config: dict[str, Any] | None = None) -> None:
  87. config = config or {}
  88. self._confidence_threshold: float = config.get("confidence_threshold", 0.82)
  89. self._fuzzy_threshold: float = config.get("fuzzy_threshold", 0.8)
  90. self._compiled: dict[str, list[CompiledPattern]] = {}
  91. self._examples: dict[str, list[list[str]]] = {} # intent → tokenized examples
  92. self._entity_registry: Any | None = None # EntityRegistry (optional)
  93. self._entity_resolver: Any | None = None # EntityResolver (optional)
  94. # ========================================================================
  95. # Kompilierung
  96. # ========================================================================
  97. def set_entity_registry(self, registry: Any) -> None:
  98. """
  99. Setzt die EntityRegistry fuer Entity-Typ-Validierung.
  100. Args:
  101. registry: EntityRegistry-Instanz
  102. """
  103. self._entity_registry = registry
  104. if _HAS_ENTITIES and registry is not None:
  105. self._entity_resolver = EntityResolver(registry)
  106. else:
  107. self._entity_resolver = None
  108. def compile_intents(self, intents: list[dict[str, Any]]) -> None:
  109. """
  110. Kompiliert Intent-Patterns und Examples aus den available_intents.
  111. Args:
  112. intents: Liste von Intent-Dicts (aus Registry.get_all_as_dict())
  113. """
  114. self._compiled.clear()
  115. self._examples.clear()
  116. for intent_dict in intents:
  117. name = intent_dict.get("name", "")
  118. if not name:
  119. continue
  120. # Patterns kompilieren
  121. patterns = intent_dict.get("patterns", [])
  122. if patterns:
  123. compiled = []
  124. for pattern_str in patterns:
  125. parsed = self._parse_pattern(pattern_str)
  126. if parsed:
  127. compiled.append(CompiledPattern(tokens=parsed, source=pattern_str))
  128. if compiled:
  129. self._compiled[name] = compiled
  130. # Examples tokenisieren
  131. examples = intent_dict.get("examples", [])
  132. if examples:
  133. self._examples[name] = [
  134. self._tokenize(ex) for ex in examples
  135. ]
  136. total_patterns = sum(len(v) for v in self._compiled.values())
  137. total_examples = sum(len(v) for v in self._examples.values())
  138. pdebug(
  139. f"[KeywordMatcher] Kompiliert: {len(self._compiled)} Intents "
  140. f"mit {total_patterns} Patterns, {total_examples} Examples"
  141. )
  142. # ========================================================================
  143. # Matching
  144. # ========================================================================
  145. def match(self, text: str) -> MatchResult | None:
  146. """
  147. Matcht Text gegen alle kompilierten Patterns und Examples.
  148. Returns:
  149. MatchResult wenn Confidence >= Schwelle, sonst None
  150. """
  151. if not text or not text.strip():
  152. return None
  153. # Sentiment extrahieren und Tokens bereinigen
  154. all_tokens = self._tokenize(text)
  155. sentiment = self._extract_sentiment(all_tokens)
  156. clean_tokens = self._remove_sentiment_words(all_tokens)
  157. best: MatchResult | None = None
  158. # 1. Pattern-Match (hoehere Praezision)
  159. for intent_name, patterns in self._compiled.items():
  160. for pattern in patterns:
  161. result = self._match_pattern(clean_tokens, pattern)
  162. if result is not None:
  163. confidence = result["confidence"]
  164. if confidence >= self._confidence_threshold:
  165. if best is None or confidence > best.confidence:
  166. best = MatchResult(
  167. intent=intent_name,
  168. confidence=confidence,
  169. slots=result.get("slots", {}),
  170. sentiment=sentiment,
  171. matched_by="pattern",
  172. )
  173. # 2. Example-Fuzzy-Match (Fallback)
  174. for intent_name, example_lists in self._examples.items():
  175. for ex_tokens in example_lists:
  176. score = self._score_example_match(clean_tokens, ex_tokens)
  177. if score >= self._confidence_threshold:
  178. if best is None or score > best.confidence:
  179. best = MatchResult(
  180. intent=intent_name,
  181. confidence=score,
  182. slots={},
  183. sentiment=sentiment,
  184. matched_by="example",
  185. )
  186. if best is not None:
  187. best.sentiment = sentiment
  188. return best
  189. # ========================================================================
  190. # Tokenisierung
  191. # ========================================================================
  192. def _tokenize(self, text: str) -> list[str]:
  193. """Tokenisiert Text in Kleinbuchstaben-Woerter."""
  194. # Interpunktion entfernen, lowercase, splitten
  195. cleaned = re.sub(r"[^\w\süöäß]", "", text.lower())
  196. return cleaned.split()
  197. # ========================================================================
  198. # Pattern-Parsing
  199. # ========================================================================
  200. def _parse_pattern(self, pattern: str) -> list[PatternToken]:
  201. """
  202. Parst einen Pattern-String in eine Liste von PatternTokens.
  203. Beispiel:
  204. "(wie ist|wie wird) [denn] wetter [in] {city}"
  205. → [alternatives(required), optional, literal, optional, slot]
  206. """
  207. tokens: list[PatternToken] = []
  208. for m in _PATTERN_RE.finditer(pattern):
  209. alternatives_group = m.group(1) # (a|b)
  210. optional_group = m.group(2) # [a|b]
  211. slot_group = m.group(3) # {slot} oder {a|b|}
  212. literal_group = m.group(4) # wort
  213. if alternatives_group is not None:
  214. # (a|b) — Pflicht-Alternativen (koennen Mehrwort sein)
  215. values = [v.strip().lower() for v in alternatives_group.split("|")]
  216. tokens.append(PatternToken(
  217. kind="alternatives",
  218. values=values,
  219. required=True,
  220. ))
  221. elif optional_group is not None:
  222. # [wort] oder [a|b|c]
  223. if "|" in optional_group:
  224. values = [v.strip().lower() for v in optional_group.split("|")]
  225. else:
  226. values = [optional_group.strip().lower()]
  227. tokens.append(PatternToken(
  228. kind="optional",
  229. values=values,
  230. required=False,
  231. ))
  232. elif slot_group is not None:
  233. # {slot_name} oder {slot_name:entity_type} oder {a|b|} (Inline-Alternativen)
  234. if "|" in slot_group:
  235. # Inline-Alternativen: {das|} → letztes leer = optional
  236. values = [v.strip().lower() for v in slot_group.split("|")]
  237. has_empty = "" in values
  238. values = [v for v in values if v] # leere entfernen
  239. tokens.append(PatternToken(
  240. kind="optional" if has_empty else "alternatives",
  241. values=values,
  242. required=not has_empty,
  243. ))
  244. elif ":" in slot_group:
  245. # Slot mit Entity-Typ: {date:datum}
  246. slot_name, entity_type = slot_group.split(":", 1)
  247. tokens.append(PatternToken(
  248. kind="slot",
  249. slot_name=slot_name.strip(),
  250. entity_type=entity_type.strip(),
  251. required=False,
  252. ))
  253. else:
  254. # Slot-Extraktion ohne Entity-Typ
  255. tokens.append(PatternToken(
  256. kind="slot",
  257. slot_name=slot_group.strip(),
  258. required=False,
  259. ))
  260. elif literal_group is not None:
  261. # Pflicht-Keyword
  262. tokens.append(PatternToken(
  263. kind="literal",
  264. values=[literal_group.strip().lower()],
  265. required=True,
  266. ))
  267. return tokens
  268. # ========================================================================
  269. # Pattern-Matching
  270. # ========================================================================
  271. def _match_pattern(
  272. self, tokens: list[str], pattern: CompiledPattern
  273. ) -> dict[str, Any] | None:
  274. """
  275. Matcht eine Token-Liste gegen ein kompiliertes Pattern.
  276. Returns:
  277. Dict mit "confidence" und "slots", oder None bei Nicht-Match
  278. """
  279. input_tokens = list(tokens) # Kopie
  280. pos = 0
  281. matched_count = 0
  282. total_required = 0
  283. slots: dict[str, Any] = {}
  284. for pt in pattern.tokens:
  285. if pt.kind == "literal":
  286. total_required += 1
  287. if pos < len(input_tokens):
  288. sim = self._fuzzy_compare(input_tokens[pos], pt.values[0])
  289. if sim >= self._fuzzy_threshold:
  290. matched_count += 1
  291. pos += 1
  292. else:
  293. return None # Pflicht-Keyword fehlt
  294. else:
  295. return None
  296. elif pt.kind == "alternatives":
  297. if pt.required:
  298. total_required += 1
  299. found = False
  300. for value in pt.values:
  301. # Mehrwort-Alternativen (z.B. "wie ist")
  302. alt_tokens = value.split()
  303. if pos + len(alt_tokens) <= len(input_tokens):
  304. all_match = True
  305. for i, alt_tok in enumerate(alt_tokens):
  306. sim = self._fuzzy_compare(input_tokens[pos + i], alt_tok)
  307. if sim < self._fuzzy_threshold:
  308. all_match = False
  309. break
  310. if all_match:
  311. matched_count += 1
  312. pos += len(alt_tokens)
  313. found = True
  314. break
  315. if not found:
  316. if pt.required:
  317. return None
  318. elif pt.kind == "optional":
  319. # Optional: matchen falls vorhanden, sonst ueberspringen
  320. if pos < len(input_tokens):
  321. for value in pt.values:
  322. alt_tokens = value.split()
  323. if pos + len(alt_tokens) <= len(input_tokens):
  324. all_match = True
  325. for i, alt_tok in enumerate(alt_tokens):
  326. sim = self._fuzzy_compare(
  327. input_tokens[pos + i], alt_tok
  328. )
  329. if sim < self._fuzzy_threshold:
  330. all_match = False
  331. break
  332. if all_match:
  333. matched_count += 1
  334. pos += len(alt_tokens)
  335. break
  336. elif pt.kind == "slot":
  337. # Slot: Restliche Tokens bis zum naechsten Pattern-Element aufnehmen
  338. # Oder ein Token wenn noch Pattern-Elemente folgen
  339. after_this = pattern.tokens[pattern.tokens.index(pt) + 1:]
  340. remaining_pattern = [
  341. p for p in after_this
  342. if p.required or p.kind == "slot"
  343. ]
  344. has_entity = bool(pt.entity_type and self._entity_registry)
  345. if remaining_pattern:
  346. if pos < len(input_tokens):
  347. # Bei Entity-Typ: Mehrwort-Match versuchen
  348. if has_entity:
  349. mw_result = self._entity_registry.match_multi_word(
  350. input_tokens, pos, pt.entity_type
  351. )
  352. if mw_result is not None:
  353. mw_value, new_pos = mw_result
  354. slot_val = self._resolve_slot(mw_value, pt.entity_type)
  355. slots[pt.slot_name] = slot_val
  356. matched_count += 1
  357. pos = new_pos
  358. elif self._entity_registry.matches(input_tokens[pos], pt.entity_type):
  359. slot_val = self._resolve_slot(input_tokens[pos], pt.entity_type)
  360. slots[pt.slot_name] = slot_val
  361. matched_count += 1
  362. pos += 1
  363. else:
  364. # Strikte Entity: Wert passt nicht → kein Match
  365. entity_def = self._entity_registry.get(pt.entity_type)
  366. if entity_def is not None and not entity_def.open:
  367. return None
  368. # Open Entity: Wert akzeptieren
  369. slot_val = self._resolve_slot(input_tokens[pos], pt.entity_type)
  370. slots[pt.slot_name] = slot_val
  371. matched_count += 1
  372. pos += 1
  373. else:
  374. # Kein Entity-Typ: wie bisher
  375. slots[pt.slot_name] = input_tokens[pos]
  376. matched_count += 1
  377. pos += 1
  378. else:
  379. # Rest als Slot
  380. if pos < len(input_tokens):
  381. slot_value = " ".join(input_tokens[pos:])
  382. if has_entity:
  383. # Mehrwort-Match ab pos versuchen
  384. mw_result = self._entity_registry.match_multi_word(
  385. input_tokens, pos, pt.entity_type
  386. )
  387. if mw_result is not None:
  388. mw_value, new_pos = mw_result
  389. slot_val = self._resolve_slot(mw_value, pt.entity_type)
  390. slots[pt.slot_name] = slot_val
  391. matched_count += 1
  392. pos = new_pos
  393. elif self._entity_registry.matches(slot_value, pt.entity_type):
  394. slot_val = self._resolve_slot(slot_value, pt.entity_type)
  395. slots[pt.slot_name] = slot_val
  396. matched_count += 1
  397. pos = len(input_tokens)
  398. else:
  399. entity_def = self._entity_registry.get(pt.entity_type)
  400. if entity_def is not None and not entity_def.open:
  401. return None
  402. slot_val = self._resolve_slot(slot_value, pt.entity_type)
  403. slots[pt.slot_name] = slot_val
  404. matched_count += 1
  405. pos = len(input_tokens)
  406. else:
  407. slots[pt.slot_name] = slot_value
  408. matched_count += 1
  409. pos = len(input_tokens)
  410. # Confidence berechnen
  411. if total_required == 0:
  412. return None
  413. # Basis-Confidence: Anteil gematchter Tokens
  414. token_coverage = pos / max(len(input_tokens), 1)
  415. required_coverage = min(matched_count / max(total_required, 1), 1.0)
  416. # Gewichtete Confidence
  417. confidence = 0.6 * required_coverage + 0.4 * token_coverage
  418. # Bonus fuer vollstaendigen Match (alle Tokens konsumiert)
  419. if pos >= len(input_tokens):
  420. confidence = min(confidence + 0.05, 0.98)
  421. # Penalty fuer unkonsumierte Tokens
  422. unconsumed = len(input_tokens) - pos
  423. if unconsumed >= 2:
  424. confidence *= 0.7
  425. elif unconsumed == 1:
  426. confidence *= 0.85
  427. # Bonus fuer Slots die gegen strikte (nicht-offene) Entity-Typen
  428. # gematcht wurden — bevorzugt spezifischere Matches
  429. if self._entity_registry is not None:
  430. for pt in pattern.tokens:
  431. if pt.kind == "slot" and pt.entity_type and pt.slot_name in slots:
  432. entity_def = self._entity_registry.get(pt.entity_type)
  433. if entity_def is not None and not entity_def.open:
  434. confidence += 0.03
  435. if confidence < self._confidence_threshold:
  436. return None
  437. return {"confidence": round(confidence, 3), "slots": slots}
  438. # ========================================================================
  439. # Fuzzy-Vergleich
  440. # ========================================================================
  441. def _fuzzy_compare(self, a: str, b: str) -> float:
  442. """
  443. Vergleicht zwei Tokens mit SequenceMatcher.
  444. Exakte Treffer werden direkt zurueckgegeben (Performance).
  445. """
  446. if a == b:
  447. return 1.0
  448. return SequenceMatcher(None, a, b).ratio()
  449. # ========================================================================
  450. # Example-Matching
  451. # ========================================================================
  452. def _score_example_match(
  453. self, tokens: list[str], example_tokens: list[str]
  454. ) -> float:
  455. """
  456. Bewertet die Aehnlichkeit zwischen Input-Tokens und Example-Tokens.
  457. Nutzt Fuzzy-Token-Matching fuer STT-Fehlertoleranz.
  458. """
  459. if not tokens or not example_tokens:
  460. return 0.0
  461. # Exakter Match (Sonderfall fuer Einwort-Befehle)
  462. if tokens == example_tokens:
  463. return 0.98
  464. # Token-fuer-Token Fuzzy-Matching
  465. matched = 0
  466. used_indices: set[int] = set()
  467. for input_tok in tokens:
  468. # Besten Match in den Example-Tokens finden
  469. best_score = 0.0
  470. best_idx = -1
  471. for idx, ex_tok in enumerate(example_tokens):
  472. if idx in used_indices:
  473. continue
  474. score = self._fuzzy_compare(input_tok, ex_tok)
  475. if score > best_score:
  476. best_score = score
  477. best_idx = idx
  478. if best_score >= self._fuzzy_threshold and best_idx >= 0:
  479. matched += 1
  480. used_indices.add(best_idx)
  481. # Score: Anteil gematchter Tokens am laengeren der beiden
  482. max_len = max(len(tokens), len(example_tokens))
  483. score = matched / max_len
  484. # Bonus fuer gleiche Laenge (exakterer Match)
  485. if len(tokens) == len(example_tokens):
  486. score = min(score + 0.03, 0.98)
  487. return round(score, 3)
  488. # ========================================================================
  489. # Sentiment-Analyse
  490. # ========================================================================
  491. def _extract_sentiment(self, tokens: list[str]) -> dict[str, Any]:
  492. """
  493. Extrahiert Sentiment-Informationen aus den Tokens.
  494. Returns:
  495. Dict mit tone, is_urgent, markers
  496. """
  497. markers: list[str] = []
  498. tone = "neutral"
  499. is_urgent = False
  500. for tok in tokens:
  501. if tok in POLITE_WORDS:
  502. markers.append(tok)
  503. if tone != "rude":
  504. tone = "polite"
  505. elif tok in RUDE_WORDS:
  506. markers.append(tok)
  507. tone = "rude"
  508. elif tok in URGENT_WORDS:
  509. markers.append(tok)
  510. is_urgent = True
  511. return {
  512. "tone": tone,
  513. "is_urgent": is_urgent,
  514. "markers": markers,
  515. }
  516. def _remove_sentiment_words(self, tokens: list[str]) -> list[str]:
  517. """Entfernt Sentiment- und Fuellwoerter aus den Tokens fuer saubereres Matching."""
  518. all_sentiment = POLITE_WORDS | RUDE_WORDS | URGENT_WORDS | _FILLER_WORDS
  519. cleaned = [t for t in tokens if t not in all_sentiment]
  520. # Falls alles entfernt wurde, Original zurueckgeben
  521. return cleaned if cleaned else tokens
  522. def _resolve_slot(self, raw_value: str, entity_type: str) -> Any:
  523. """Loest einen Slot-Wert ueber den EntityResolver auf, falls verfuegbar."""
  524. if self._entity_resolver is not None:
  525. return self._entity_resolver.resolve(raw_value, entity_type)
  526. return raw_value