test_keyword_matcher.py 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839
  1. # -*- coding: utf-8 -*-
  2. """
  3. Tests fuer den KeywordIntentMatcher.
  4. Testet Pattern-Parsing, Pattern-Matching, Fuzzy-Matching,
  5. Sentiment-Erkennung, Slot-Extraktion, Example-Matching
  6. und Confidence-Berechnung.
  7. """
  8. import pytest
  9. from trixy_core.nlp.keyword_matcher import (
  10. KeywordIntentMatcher,
  11. MatchResult,
  12. PatternToken,
  13. CompiledPattern,
  14. POLITE_WORDS,
  15. RUDE_WORDS,
  16. URGENT_WORDS,
  17. )
  18. # ============================================================================
  19. # Fixtures
  20. # ============================================================================
  21. @pytest.fixture
  22. def matcher():
  23. """Erstellt einen KeywordIntentMatcher mit Standard-Config."""
  24. return KeywordIntentMatcher()
  25. @pytest.fixture
  26. def matcher_low_threshold():
  27. """Matcher mit niedrigerer Schwelle fuer Grenzfall-Tests."""
  28. return KeywordIntentMatcher({"confidence_threshold": 0.5, "fuzzy_threshold": 0.7})
  29. @pytest.fixture
  30. def weather_intents():
  31. """Wetter-Intent-Definitionen fuer Tests."""
  32. return [
  33. {
  34. "name": "current_weather",
  35. "patterns": [
  36. "(wie ist|wie wird) [denn] [das] wetter [draussen|gerade|heute]",
  37. "wetter [in] {city}",
  38. "(wie warm|wie kalt) ist [es] [draussen|gerade]",
  39. "temperatur [draussen]",
  40. ],
  41. "examples": [
  42. "Wie ist das Wetter?",
  43. "Wie ist das Wetter in Berlin?",
  44. "Wie warm ist es?",
  45. "Wetter in Hamburg",
  46. ],
  47. },
  48. {
  49. "name": "weather_forecast",
  50. "patterns": [
  51. "wie wird [das] wetter {date}",
  52. "vorhersage [fuer] {date}",
  53. "brauche ich {date} [einen] regenschirm",
  54. ],
  55. "examples": [
  56. "Wie wird das Wetter morgen?",
  57. "Brauche ich morgen einen Regenschirm?",
  58. ],
  59. },
  60. ]
  61. @pytest.fixture
  62. def system_intents():
  63. """System-Intent-Definitionen fuer Tests."""
  64. return [
  65. {
  66. "name": "stop",
  67. "patterns": ["stopp", "halt", "alles stoppen", "sei still", "ruhe"],
  68. "examples": ["Stopp", "Halt", "Alles stoppen"],
  69. },
  70. {
  71. "name": "pause",
  72. "patterns": ["pause", "pausieren"],
  73. "examples": ["Pause", "Pausieren"],
  74. },
  75. {
  76. "name": "resume",
  77. "patterns": ["weiterspielen", "fortsetzen", "weiter [machen]", "play"],
  78. "examples": ["Weiterspielen", "Fortsetzen", "Play"],
  79. },
  80. {
  81. "name": "volume_set",
  82. "patterns": [
  83. "lautstaerke [auf] {level}",
  84. "(setze|stelle) [die] lautstaerke [auf] {level}",
  85. ],
  86. "examples": ["Lautstärke auf 50"],
  87. },
  88. {
  89. "name": "help",
  90. "patterns": ["hilfe", "was kannst [du]", "befehle"],
  91. "examples": ["Hilfe", "Was kannst du?"],
  92. },
  93. ]
  94. @pytest.fixture
  95. def timer_intents():
  96. """Timer-Intent-Definitionen fuer Tests."""
  97. return [
  98. {
  99. "name": "set_timer",
  100. "patterns": [
  101. "(setze|stelle|erstelle|starte) [einen|den] timer [auf|fuer] {duration}",
  102. "timer {duration}",
  103. "weck [mich] [in] {duration}",
  104. ],
  105. "examples": [
  106. "Setze Timer auf 5 Minuten",
  107. "Timer 30 Sekunden",
  108. ],
  109. },
  110. ]
  111. # ============================================================================
  112. # Tokenisierung
  113. # ============================================================================
  114. class TestTokenize:
  115. """Tests fuer die Tokenisierung."""
  116. def test_einfacher_text(self, matcher):
  117. """Einfacher Text sollte in Kleinbuchstaben tokenisiert werden."""
  118. tokens = matcher._tokenize("Wie ist das Wetter")
  119. assert tokens == ["wie", "ist", "das", "wetter"]
  120. def test_interpunktion_entfernt(self, matcher):
  121. """Interpunktion sollte entfernt werden."""
  122. tokens = matcher._tokenize("Wie spät ist es?")
  123. assert "?" not in " ".join(tokens)
  124. assert tokens[0] == "wie"
  125. def test_umlaute_erhalten(self, matcher):
  126. """Umlaute sollten erhalten bleiben."""
  127. tokens = matcher._tokenize("Wie spät ist es draußen?")
  128. assert "spät" in tokens or "spaet" in tokens or "spät" in tokens
  129. def test_leerer_text(self, matcher):
  130. """Leerer Text sollte leere Liste ergeben."""
  131. tokens = matcher._tokenize("")
  132. assert tokens == []
  133. def test_nur_leerzeichen(self, matcher):
  134. """Nur Leerzeichen sollte leere Liste ergeben."""
  135. tokens = matcher._tokenize(" ")
  136. assert tokens == []
  137. # ============================================================================
  138. # Pattern-Parsing
  139. # ============================================================================
  140. class TestParsePattern:
  141. """Tests fuer das Pattern-Parsing."""
  142. def test_literal_keyword(self, matcher):
  143. """Einfaches Wort sollte als Literal geparst werden."""
  144. tokens = matcher._parse_pattern("wetter")
  145. assert len(tokens) == 1
  146. assert tokens[0].kind == "literal"
  147. assert tokens[0].values == ["wetter"]
  148. assert tokens[0].required is True
  149. def test_pflicht_alternativen(self, matcher):
  150. """(a|b) sollte als Pflicht-Alternativen geparst werden."""
  151. tokens = matcher._parse_pattern("(wie ist|wie wird)")
  152. assert len(tokens) == 1
  153. assert tokens[0].kind == "alternatives"
  154. assert "wie ist" in tokens[0].values
  155. assert "wie wird" in tokens[0].values
  156. assert tokens[0].required is True
  157. def test_optionales_wort(self, matcher):
  158. """[wort] sollte als optional geparst werden."""
  159. tokens = matcher._parse_pattern("[denn]")
  160. assert len(tokens) == 1
  161. assert tokens[0].kind == "optional"
  162. assert tokens[0].values == ["denn"]
  163. assert tokens[0].required is False
  164. def test_optionale_alternativen(self, matcher):
  165. """[a|b|c] sollte als optionale Alternativen geparst werden."""
  166. tokens = matcher._parse_pattern("[draussen|gerade|heute]")
  167. assert len(tokens) == 1
  168. assert tokens[0].kind == "optional"
  169. assert "draussen" in tokens[0].values
  170. assert "gerade" in tokens[0].values
  171. assert "heute" in tokens[0].values
  172. assert tokens[0].required is False
  173. def test_slot(self, matcher):
  174. """{{slot_name}} sollte als Slot geparst werden."""
  175. tokens = matcher._parse_pattern("{city}")
  176. assert len(tokens) == 1
  177. assert tokens[0].kind == "slot"
  178. assert tokens[0].slot_name == "city"
  179. assert tokens[0].entity_type == ""
  180. assert tokens[0].required is False
  181. def test_slot_mit_entity_typ(self, matcher):
  182. """{{name:typ}} sollte Slot mit Entity-Typ ergeben."""
  183. tokens = matcher._parse_pattern("{date:datum}")
  184. assert len(tokens) == 1
  185. assert tokens[0].kind == "slot"
  186. assert tokens[0].slot_name == "date"
  187. assert tokens[0].entity_type == "datum"
  188. def test_slot_mit_entity_typ_komplex(self, matcher):
  189. """Komplexes Pattern mit Entity-Typ-Slots."""
  190. tokens = matcher._parse_pattern("wetter [am] {date:datum} [in] {city:stadt}")
  191. assert len(tokens) == 5
  192. # {date:datum}
  193. assert tokens[2].kind == "slot"
  194. assert tokens[2].slot_name == "date"
  195. assert tokens[2].entity_type == "datum"
  196. # {city:stadt}
  197. assert tokens[4].kind == "slot"
  198. assert tokens[4].slot_name == "city"
  199. assert tokens[4].entity_type == "stadt"
  200. def test_inline_alternativen_mit_leer(self, matcher):
  201. """{a|b|} sollte als optional geparst werden (letztes leer)."""
  202. tokens = matcher._parse_pattern("{das|}")
  203. assert len(tokens) == 1
  204. assert tokens[0].kind == "optional"
  205. assert "das" in tokens[0].values
  206. assert tokens[0].required is False
  207. def test_inline_alternativen_ohne_leer(self, matcher):
  208. """{a|b} sollte als Pflicht-Alternativen geparst werden."""
  209. tokens = matcher._parse_pattern("{warm|kalt}")
  210. assert len(tokens) == 1
  211. assert tokens[0].kind == "alternatives"
  212. assert "warm" in tokens[0].values
  213. assert "kalt" in tokens[0].values
  214. assert tokens[0].required is True
  215. def test_komplexes_pattern(self, matcher):
  216. """Komplexes Pattern mit mehreren Elementen."""
  217. tokens = matcher._parse_pattern("(wie ist|wie wird) [denn] wetter [in] {city}")
  218. assert len(tokens) == 5
  219. # (wie ist|wie wird)
  220. assert tokens[0].kind == "alternatives"
  221. assert tokens[0].required is True
  222. # [denn]
  223. assert tokens[1].kind == "optional"
  224. # wetter
  225. assert tokens[2].kind == "literal"
  226. assert tokens[2].required is True
  227. # [in]
  228. assert tokens[3].kind == "optional"
  229. # {city}
  230. assert tokens[4].kind == "slot"
  231. assert tokens[4].slot_name == "city"
  232. def test_leeres_pattern(self, matcher):
  233. """Leeres Pattern sollte leere Liste ergeben."""
  234. tokens = matcher._parse_pattern("")
  235. assert tokens == []
  236. # ============================================================================
  237. # Pattern-Matching
  238. # ============================================================================
  239. class TestPatternMatching:
  240. """Tests fuer das Pattern-Matching."""
  241. def test_exakter_match(self, matcher, system_intents):
  242. """Exakter Befehl sollte matchen."""
  243. matcher.compile_intents(system_intents)
  244. result = matcher.match("Stopp")
  245. assert result is not None
  246. assert result.intent == "stop"
  247. assert result.confidence >= 0.82
  248. def test_einwort_befehle(self, matcher, system_intents):
  249. """Einwort-Befehle sollten korrekt matchen."""
  250. matcher.compile_intents(system_intents)
  251. for text, expected_intent in [
  252. ("Stopp", "stop"),
  253. ("Halt", "stop"),
  254. ("Pause", "pause"),
  255. ("Pausieren", "pause"),
  256. ("Hilfe", "help"),
  257. ("Play", "resume"),
  258. ]:
  259. result = matcher.match(text)
  260. assert result is not None, f"'{text}' sollte matchen"
  261. assert result.intent == expected_intent, (
  262. f"'{text}' sollte '{expected_intent}' sein, war '{result.intent}'"
  263. )
  264. def test_mehrwort_befehl(self, matcher, system_intents):
  265. """Mehrwort-Befehle sollten matchen."""
  266. matcher.compile_intents(system_intents)
  267. result = matcher.match("Alles stoppen")
  268. assert result is not None
  269. assert result.intent == "stop"
  270. def test_optionale_woerter(self, matcher, system_intents):
  271. """Optionale Woerter sollten korrekt behandelt werden."""
  272. matcher.compile_intents(system_intents)
  273. # "weiter [machen]" — ohne optionales Wort
  274. result = matcher.match("Weiter")
  275. assert result is not None
  276. assert result.intent == "resume"
  277. # "weiter [machen]" — mit optionalem Wort
  278. result = matcher.match("Weiter machen")
  279. assert result is not None
  280. assert result.intent == "resume"
  281. def test_pflicht_alternativen(self, matcher, weather_intents):
  282. """Pflicht-Alternativen sollten alle matchen."""
  283. matcher.compile_intents(weather_intents)
  284. result1 = matcher.match("Wie ist das Wetter")
  285. assert result1 is not None
  286. assert result1.intent == "current_weather"
  287. result2 = matcher.match("Wie wird das Wetter")
  288. assert result2 is not None
  289. def test_slot_extraktion(self, matcher, weather_intents):
  290. """Slots sollten korrekt extrahiert werden."""
  291. matcher.compile_intents(weather_intents)
  292. result = matcher.match("Wetter in Berlin")
  293. assert result is not None
  294. assert result.intent == "current_weather"
  295. assert "city" in result.slots
  296. assert result.slots["city"] == "berlin"
  297. def test_slot_mehrwort(self, matcher, weather_intents):
  298. """Slots sollten Mehrwort-Werte aufnehmen koennen."""
  299. matcher.compile_intents(weather_intents)
  300. result = matcher.match("Wetter in New York")
  301. assert result is not None
  302. assert "city" in result.slots
  303. assert "new york" in result.slots["city"]
  304. def test_slot_am_ende(self, matcher, timer_intents):
  305. """Slot am Ende sollte restliche Tokens aufnehmen."""
  306. matcher.compile_intents(timer_intents)
  307. result = matcher.match("Timer 5 Minuten")
  308. assert result is not None
  309. assert result.intent == "set_timer"
  310. assert "duration" in result.slots
  311. assert "5 minuten" in result.slots["duration"]
  312. def test_kein_match(self, matcher, system_intents):
  313. """Unbekannter Text sollte nicht matchen."""
  314. matcher.compile_intents(system_intents)
  315. result = matcher.match("Wer war Beethoven?")
  316. assert result is None
  317. def test_kein_match_leerer_text(self, matcher, system_intents):
  318. """Leerer Text sollte nicht matchen."""
  319. matcher.compile_intents(system_intents)
  320. assert matcher.match("") is None
  321. assert matcher.match(" ") is None
  322. assert matcher.match(None) is None # type: ignore
  323. def test_confidence_ueber_schwelle(self, matcher, system_intents):
  324. """Confidence sollte ueber der konfigurierten Schwelle liegen."""
  325. matcher.compile_intents(system_intents)
  326. result = matcher.match("Stopp")
  327. assert result is not None
  328. assert result.confidence >= 0.82
  329. def test_matched_by_pattern(self, matcher, system_intents):
  330. """matched_by sollte 'pattern' fuer Pattern-Matches sein."""
  331. matcher.compile_intents(system_intents)
  332. result = matcher.match("Stopp")
  333. assert result is not None
  334. assert result.matched_by == "pattern"
  335. # ============================================================================
  336. # Fuzzy-Matching
  337. # ============================================================================
  338. class TestFuzzyMatching:
  339. """Tests fuer das Fuzzy-Matching (STT-Fehlertoleranz)."""
  340. def test_fuzzy_compare_exakt(self, matcher):
  341. """Exakte Matches sollten 1.0 ergeben."""
  342. assert matcher._fuzzy_compare("wetter", "wetter") == 1.0
  343. assert matcher._fuzzy_compare("stopp", "stopp") == 1.0
  344. def test_fuzzy_compare_aehnlich(self, matcher):
  345. """Aehnliche Woerter sollten hohen Score ergeben."""
  346. score = matcher._fuzzy_compare("weter", "wetter")
  347. assert score >= 0.8
  348. def test_fuzzy_compare_verschieden(self, matcher):
  349. """Verschiedene Woerter sollten niedrigen Score ergeben."""
  350. score = matcher._fuzzy_compare("hund", "katze")
  351. assert score < 0.5
  352. def test_fuzzy_pattern_match(self, matcher, weather_intents):
  353. """Leichte STT-Fehler sollten toleriert werden."""
  354. matcher.compile_intents(weather_intents)
  355. # "weter" statt "wetter" — ein Buchstabe fehlt
  356. result = matcher.match("weter in Berlin")
  357. assert result is not None
  358. assert result.intent == "current_weather"
  359. def test_fuzzy_example_match(self, matcher, system_intents):
  360. """Fuzzy sollte auch bei Example-Matching funktionieren."""
  361. matcher.compile_intents(system_intents)
  362. # "Hilf" statt "Hilfe" — aehnlich genug
  363. result = matcher.match("Hilf")
  364. # Kann per Pattern oder Example matchen
  365. if result is not None:
  366. assert result.intent == "help"
  367. # ============================================================================
  368. # Example-Matching
  369. # ============================================================================
  370. class TestExampleMatching:
  371. """Tests fuer das Example-basierte Matching."""
  372. def test_exakter_example_match(self, matcher):
  373. """Exakte Uebereinstimmung mit Example sollte hohen Score ergeben."""
  374. score = matcher._score_example_match(
  375. ["wie", "ist", "das", "wetter"],
  376. ["wie", "ist", "das", "wetter"],
  377. )
  378. assert score == 0.98 # Exakter Match
  379. def test_teilweise_uebereinstimmung(self, matcher):
  380. """Teilweise Uebereinstimmung sollte Score proportional ergeben."""
  381. score = matcher._score_example_match(
  382. ["wie", "ist", "wetter"],
  383. ["wie", "ist", "das", "wetter"],
  384. )
  385. assert score > 0.5
  386. def test_leerer_input(self, matcher):
  387. """Leerer Input sollte 0.0 ergeben."""
  388. assert matcher._score_example_match([], ["test"]) == 0.0
  389. assert matcher._score_example_match(["test"], []) == 0.0
  390. assert matcher._score_example_match([], []) == 0.0
  391. def test_example_nur_intents(self, matcher):
  392. """Intents mit nur Examples (keine Patterns) sollten matchen."""
  393. intents = [{
  394. "name": "greet",
  395. "examples": ["Hallo", "Guten Tag", "Hi"],
  396. }]
  397. matcher.compile_intents(intents)
  398. result = matcher.match("Hallo")
  399. assert result is not None
  400. assert result.intent == "greet"
  401. assert result.matched_by == "example"
  402. def test_example_match_confidence(self, matcher):
  403. """Example-Matches sollten realistische Confidence haben."""
  404. intents = [{
  405. "name": "greet",
  406. "examples": ["Hallo wie geht es dir"],
  407. }]
  408. matcher.compile_intents(intents)
  409. result = matcher.match("Hallo wie geht es dir")
  410. assert result is not None
  411. assert result.confidence >= 0.82
  412. # ============================================================================
  413. # Sentiment-Erkennung
  414. # ============================================================================
  415. class TestSentiment:
  416. """Tests fuer die Sentiment-Erkennung."""
  417. def test_neutral(self, matcher):
  418. """Text ohne Floskel sollte neutral sein."""
  419. sentiment = matcher._extract_sentiment(["wie", "ist", "das", "wetter"])
  420. assert sentiment["tone"] == "neutral"
  421. assert sentiment["is_urgent"] is False
  422. assert sentiment["markers"] == []
  423. def test_polite(self, matcher):
  424. """'bitte' sollte als hoeflich erkannt werden."""
  425. sentiment = matcher._extract_sentiment(["bitte", "wie", "ist", "das", "wetter"])
  426. assert sentiment["tone"] == "polite"
  427. assert "bitte" in sentiment["markers"]
  428. def test_rude(self, matcher):
  429. """Schimpfwoerter sollten als unhöflich erkannt werden."""
  430. sentiment = matcher._extract_sentiment(["verdammt", "wie", "ist", "das", "wetter"])
  431. assert sentiment["tone"] == "rude"
  432. assert "verdammt" in sentiment["markers"]
  433. def test_rude_ueberwiegt_polite(self, matcher):
  434. """Rude sollte polite ueberschreiben."""
  435. sentiment = matcher._extract_sentiment(["bitte", "verdammt", "stopp"])
  436. assert sentiment["tone"] == "rude"
  437. def test_urgent(self, matcher):
  438. """Dringlichkeitswoerter sollten erkannt werden."""
  439. sentiment = matcher._extract_sentiment(["sofort", "stopp"])
  440. assert sentiment["is_urgent"] is True
  441. assert "sofort" in sentiment["markers"]
  442. def test_polite_und_urgent(self, matcher):
  443. """Hoeflich und dringend gleichzeitig."""
  444. sentiment = matcher._extract_sentiment(["bitte", "schnell", "stopp"])
  445. assert sentiment["tone"] == "polite"
  446. assert sentiment["is_urgent"] is True
  447. def test_sentiment_in_match_result(self, matcher, system_intents):
  448. """Sentiment sollte im Match-Ergebnis enthalten sein."""
  449. matcher.compile_intents(system_intents)
  450. result = matcher.match("bitte stopp")
  451. assert result is not None
  452. assert result.sentiment["tone"] == "polite"
  453. def test_sentiment_woerter_entfernt_beim_matching(self, matcher, weather_intents):
  454. """Sentiment-Woerter sollten das Matching nicht stoeren."""
  455. matcher.compile_intents(weather_intents)
  456. # "bitte wie ist das wetter" — "bitte" soll entfernt werden
  457. result = matcher.match("bitte wie ist das Wetter")
  458. assert result is not None
  459. assert result.intent == "current_weather"
  460. assert result.sentiment["tone"] == "polite"
  461. # ============================================================================
  462. # Compile-Intents
  463. # ============================================================================
  464. class TestCompileIntents:
  465. """Tests fuer das Kompilieren von Intents."""
  466. def test_compile_mit_patterns(self, matcher):
  467. """Intents mit Patterns sollten kompiliert werden."""
  468. intents = [{
  469. "name": "test_intent",
  470. "patterns": ["test (eins|zwei)"],
  471. "examples": ["Test eins"],
  472. }]
  473. matcher.compile_intents(intents)
  474. assert "test_intent" in matcher._compiled
  475. assert len(matcher._compiled["test_intent"]) == 1
  476. def test_compile_nur_examples(self, matcher):
  477. """Intents mit nur Examples sollten in _examples landen."""
  478. intents = [{
  479. "name": "test_intent",
  480. "examples": ["Hallo", "Hi"],
  481. }]
  482. matcher.compile_intents(intents)
  483. assert "test_intent" not in matcher._compiled
  484. assert "test_intent" in matcher._examples
  485. assert len(matcher._examples["test_intent"]) == 2
  486. def test_compile_ohne_name(self, matcher):
  487. """Intents ohne Name sollten uebersprungen werden."""
  488. intents = [{"patterns": ["test"]}]
  489. matcher.compile_intents(intents)
  490. assert len(matcher._compiled) == 0
  491. assert len(matcher._examples) == 0
  492. def test_compile_leere_liste(self, matcher):
  493. """Leere Intent-Liste sollte keine Fehler verursachen."""
  494. matcher.compile_intents([])
  495. assert len(matcher._compiled) == 0
  496. assert len(matcher._examples) == 0
  497. def test_compile_loescht_vorherige(self, matcher):
  498. """Erneutes Kompilieren sollte vorherige Daten loeschen."""
  499. matcher.compile_intents([{
  500. "name": "old",
  501. "patterns": ["alt"],
  502. }])
  503. assert "old" in matcher._compiled
  504. matcher.compile_intents([{
  505. "name": "new",
  506. "patterns": ["neu"],
  507. }])
  508. assert "old" not in matcher._compiled
  509. assert "new" in matcher._compiled
  510. def test_compile_mehrere_patterns_pro_intent(self, matcher):
  511. """Mehrere Patterns pro Intent sollten kompiliert werden."""
  512. intents = [{
  513. "name": "test",
  514. "patterns": ["muster eins", "(a|b) zwei", "drei [optional]"],
  515. }]
  516. matcher.compile_intents(intents)
  517. assert len(matcher._compiled["test"]) == 3
  518. # ============================================================================
  519. # Konfiguration
  520. # ============================================================================
  521. class TestKonfiguration:
  522. """Tests fuer die Konfiguration des Matchers."""
  523. def test_default_config(self):
  524. """Standard-Konfiguration sollte gesetzt sein."""
  525. matcher = KeywordIntentMatcher()
  526. assert matcher._confidence_threshold == 0.82
  527. assert matcher._fuzzy_threshold == 0.8
  528. def test_custom_config(self):
  529. """Benutzerdefinierte Konfiguration sollte uebernommen werden."""
  530. matcher = KeywordIntentMatcher({
  531. "confidence_threshold": 0.9,
  532. "fuzzy_threshold": 0.85,
  533. })
  534. assert matcher._confidence_threshold == 0.9
  535. assert matcher._fuzzy_threshold == 0.85
  536. def test_none_config(self):
  537. """None-Config sollte Standard-Werte verwenden."""
  538. matcher = KeywordIntentMatcher(None)
  539. assert matcher._confidence_threshold == 0.82
  540. def test_leere_config(self):
  541. """Leere Config sollte Standard-Werte verwenden."""
  542. matcher = KeywordIntentMatcher({})
  543. assert matcher._confidence_threshold == 0.82
  544. # ============================================================================
  545. # Datenklassen
  546. # ============================================================================
  547. class TestDatenklassen:
  548. """Tests fuer die Datenklassen."""
  549. def test_pattern_token_defaults(self):
  550. """PatternToken sollte korrekte Defaults haben."""
  551. pt = PatternToken(kind="literal")
  552. assert pt.kind == "literal"
  553. assert pt.values == []
  554. assert pt.slot_name == ""
  555. assert pt.required is True
  556. def test_compiled_pattern_defaults(self):
  557. """CompiledPattern sollte korrekte Defaults haben."""
  558. cp = CompiledPattern()
  559. assert cp.tokens == []
  560. assert cp.source == ""
  561. def test_match_result_defaults(self):
  562. """MatchResult sollte korrekte Defaults haben."""
  563. mr = MatchResult()
  564. assert mr.intent == ""
  565. assert mr.confidence == 0.0
  566. assert mr.slots == {}
  567. assert mr.sentiment == {}
  568. assert mr.matched_by == ""
  569. def test_match_result_mit_werten(self):
  570. """MatchResult sollte Werte korrekt speichern."""
  571. mr = MatchResult(
  572. intent="test",
  573. confidence=0.95,
  574. slots={"city": "berlin"},
  575. sentiment={"tone": "polite"},
  576. matched_by="pattern",
  577. )
  578. assert mr.intent == "test"
  579. assert mr.confidence == 0.95
  580. assert mr.slots["city"] == "berlin"
  581. assert mr.sentiment["tone"] == "polite"
  582. # ============================================================================
  583. # Sentiment-Wort-Entfernung
  584. # ============================================================================
  585. class TestRemoveSentimentWords:
  586. """Tests fuer das Entfernen von Sentiment-Woertern."""
  587. def test_entfernt_bitte(self, matcher):
  588. """'bitte' sollte entfernt werden."""
  589. result = matcher._remove_sentiment_words(["bitte", "stopp"])
  590. assert "bitte" not in result
  591. assert "stopp" in result
  592. def test_entfernt_fuellwoerter(self, matcher):
  593. """Fuellwoerter sollten entfernt werden."""
  594. result = matcher._remove_sentiment_words(["kannst", "du", "mal", "stopp"])
  595. assert "stopp" in result
  596. assert "kannst" not in result
  597. assert "du" not in result
  598. assert "mal" not in result
  599. def test_behaelt_original_bei_komplettem_entfernen(self, matcher):
  600. """Wenn alles entfernt wuerde, Original zurueckgeben."""
  601. result = matcher._remove_sentiment_words(["bitte", "danke"])
  602. # Sollte Original zurueckgeben, da sonst leere Liste
  603. assert len(result) > 0
  604. # ============================================================================
  605. # Integrationstests
  606. # ============================================================================
  607. class TestIntegration:
  608. """Integrationstests mit realistischen Szenarien."""
  609. @pytest.fixture
  610. def full_matcher(self, weather_intents, system_intents, timer_intents):
  611. """Matcher mit allen Intents geladen."""
  612. m = KeywordIntentMatcher()
  613. all_intents = weather_intents + system_intents + timer_intents
  614. m.compile_intents(all_intents)
  615. return m
  616. def test_wetter_abfrage(self, full_matcher):
  617. """Wetter-Abfrage sollte korrekt matchen."""
  618. result = full_matcher.match("Wie ist das Wetter?")
  619. assert result is not None
  620. assert result.intent == "current_weather"
  621. def test_wetter_mit_stadt(self, full_matcher):
  622. """Wetter-Abfrage mit Stadt sollte Slot extrahieren."""
  623. result = full_matcher.match("Wetter in München")
  624. assert result is not None
  625. assert result.intent == "current_weather"
  626. assert "city" in result.slots
  627. def test_stopp_befehl(self, full_matcher):
  628. """Stopp-Befehl sollte matchen."""
  629. result = full_matcher.match("Stopp")
  630. assert result is not None
  631. assert result.intent == "stop"
  632. def test_timer_setzen(self, full_matcher):
  633. """Timer setzen sollte matchen."""
  634. result = full_matcher.match("Setze Timer auf 5 Minuten")
  635. assert result is not None
  636. assert result.intent == "set_timer"
  637. assert "duration" in result.slots
  638. def test_unbekannter_befehl(self, full_matcher):
  639. """Unbekannter Befehl sollte nicht matchen."""
  640. result = full_matcher.match("Wer war Beethoven?")
  641. assert result is None
  642. def test_hoefliche_wetter_abfrage(self, full_matcher):
  643. """Hoefliche Anfrage sollte Sentiment erkennen."""
  644. result = full_matcher.match("Bitte wie ist das Wetter?")
  645. assert result is not None
  646. assert result.intent == "current_weather"
  647. assert result.sentiment["tone"] == "polite"
  648. def test_dringende_stopp_anfrage(self, full_matcher):
  649. """Dringende Anfrage sollte Urgency erkennen."""
  650. result = full_matcher.match("Sofort stopp")
  651. assert result is not None
  652. assert result.intent == "stop"
  653. assert result.sentiment["is_urgent"] is True
  654. def test_lautstaerke_mit_slot(self, full_matcher):
  655. """Lautstaerke setzen sollte Level-Slot extrahieren."""
  656. result = full_matcher.match("Lautstärke auf 50")
  657. assert result is not None
  658. assert result.intent == "volume_set"
  659. assert "level" in result.slots
  660. def test_wie_warm_ist_es(self, full_matcher):
  661. """'Wie warm ist es' sollte als Wetter matchen."""
  662. result = full_matcher.match("Wie warm ist es")
  663. assert result is not None
  664. assert result.intent == "current_weather"
  665. def test_pattern_match_vor_example(self, full_matcher):
  666. """Bei gleicher Confidence sollte der bessere Match gewinnen."""
  667. result = full_matcher.match("Pause")
  668. assert result is not None
  669. assert result.intent == "pause"
  670. # Pattern-Match sollte bevorzugt werden (hoehere Confidence)
  671. # oder zumindest korrekt matchen
  672. def test_performance_einfach(self, full_matcher):
  673. """Einfacher Match sollte schnell sein (<10ms)."""
  674. import time
  675. start = time.perf_counter()
  676. for _ in range(100):
  677. full_matcher.match("Stopp")
  678. elapsed = (time.perf_counter() - start) / 100 * 1000 # ms
  679. # Sollte weit unter 10ms pro Match sein
  680. assert elapsed < 10, f"Match dauerte {elapsed:.2f}ms (sollte <10ms sein)"
  681. def test_performance_komplex(self, full_matcher):
  682. """Komplexer Match sollte schnell sein (<10ms)."""
  683. import time
  684. start = time.perf_counter()
  685. for _ in range(100):
  686. full_matcher.match("Bitte wie ist das Wetter in Berlin?")
  687. elapsed = (time.perf_counter() - start) / 100 * 1000 # ms
  688. assert elapsed < 10, f"Match dauerte {elapsed:.2f}ms (sollte <10ms sein)"