test_nlp.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774
  1. # -*- coding: utf-8 -*-
  2. """
  3. Tests für das NLP-System.
  4. Testet IntentRegistry, @intent Decorator, NLPProvider Interface und Event-Flow.
  5. """
  6. import asyncio
  7. import pytest
  8. from dataclasses import dataclass
  9. from typing import Any
  10. from unittest.mock import AsyncMock, MagicMock, patch
  11. from trixy_core.nlp import (
  12. IntentDefinition,
  13. IntentRegistry,
  14. IntentSlot,
  15. IntentReceivedData,
  16. IntentResult,
  17. NLPConfig,
  18. NLPContext,
  19. NLPProvider,
  20. NLPResult,
  21. NLPState,
  22. intent,
  23. discover_intent_handlers,
  24. get_intent_metadata,
  25. is_intent_handler,
  26. )
  27. # ============================================================================
  28. # IntentRegistry Tests
  29. # ============================================================================
  30. class TestIntentRegistry:
  31. """Tests für die IntentRegistry."""
  32. @pytest.fixture(autouse=True)
  33. def setup_registry(self):
  34. """Setzt die Registry vor jedem Test zurück."""
  35. registry = IntentRegistry.get_instance()
  36. registry.clear()
  37. yield registry
  38. registry.clear()
  39. def test_singleton_pattern(self):
  40. """Registry sollte Singleton sein."""
  41. registry1 = IntentRegistry.get_instance()
  42. registry2 = IntentRegistry.get_instance()
  43. assert registry1 is registry2
  44. def test_register_intent(self, setup_registry):
  45. """Intent sollte registriert werden können."""
  46. registry = setup_registry
  47. def handler():
  48. pass
  49. intent_def = IntentDefinition(
  50. name="test_intent",
  51. handler=handler,
  52. plugin_name="test_plugin",
  53. description="Test Intent",
  54. )
  55. result = registry.register(intent_def)
  56. assert result is True
  57. assert "test_intent" in registry
  58. assert registry.count() == 1
  59. def test_register_duplicate_intent_same_plugin(self, setup_registry):
  60. """Doppelter Intent vom gleichen Plugin sollte ersetzt werden."""
  61. registry = setup_registry
  62. def handler1():
  63. pass
  64. def handler2():
  65. pass
  66. intent1 = IntentDefinition(
  67. name="test_intent",
  68. handler=handler1,
  69. plugin_name="test_plugin",
  70. )
  71. intent2 = IntentDefinition(
  72. name="test_intent",
  73. handler=handler2,
  74. plugin_name="test_plugin",
  75. )
  76. registry.register(intent1)
  77. result = registry.register(intent2)
  78. assert result is True
  79. assert registry.get_handler("test_intent") is handler2
  80. def test_register_duplicate_intent_higher_priority(self, setup_registry):
  81. """Intent mit höherer Priorität sollte überschreiben."""
  82. registry = setup_registry
  83. def handler1():
  84. pass
  85. def handler2():
  86. pass
  87. intent1 = IntentDefinition(
  88. name="test_intent",
  89. handler=handler1,
  90. plugin_name="plugin1",
  91. priority=0,
  92. )
  93. intent2 = IntentDefinition(
  94. name="test_intent",
  95. handler=handler2,
  96. plugin_name="plugin2",
  97. priority=10,
  98. )
  99. registry.register(intent1)
  100. result = registry.register(intent2)
  101. assert result is True
  102. assert registry.get_handler("test_intent") is handler2
  103. def test_register_duplicate_intent_lower_priority(self, setup_registry):
  104. """Intent mit niedrigerer Priorität sollte abgelehnt werden."""
  105. registry = setup_registry
  106. def handler1():
  107. pass
  108. def handler2():
  109. pass
  110. intent1 = IntentDefinition(
  111. name="test_intent",
  112. handler=handler1,
  113. plugin_name="plugin1",
  114. priority=10,
  115. )
  116. intent2 = IntentDefinition(
  117. name="test_intent",
  118. handler=handler2,
  119. plugin_name="plugin2",
  120. priority=0,
  121. )
  122. registry.register(intent1)
  123. result = registry.register(intent2)
  124. assert result is False
  125. assert registry.get_handler("test_intent") is handler1
  126. def test_unregister_intent(self, setup_registry):
  127. """Intent sollte entfernt werden können."""
  128. registry = setup_registry
  129. def handler():
  130. pass
  131. intent_def = IntentDefinition(
  132. name="test_intent",
  133. handler=handler,
  134. )
  135. registry.register(intent_def)
  136. result = registry.unregister("test_intent")
  137. assert result is True
  138. assert "test_intent" not in registry
  139. assert registry.count() == 0
  140. def test_unregister_nonexistent(self, setup_registry):
  141. """Entfernen eines nicht existierenden Intents sollte False zurückgeben."""
  142. registry = setup_registry
  143. result = registry.unregister("nonexistent")
  144. assert result is False
  145. def test_unregister_plugin(self, setup_registry):
  146. """Alle Intents eines Plugins sollten entfernt werden können."""
  147. registry = setup_registry
  148. def handler():
  149. pass
  150. for i in range(3):
  151. registry.register(IntentDefinition(
  152. name=f"intent_{i}",
  153. handler=handler,
  154. plugin_name="test_plugin",
  155. ))
  156. registry.register(IntentDefinition(
  157. name="other_intent",
  158. handler=handler,
  159. plugin_name="other_plugin",
  160. ))
  161. count = registry.unregister_plugin("test_plugin")
  162. assert count == 3
  163. assert registry.count() == 1
  164. assert "other_intent" in registry
  165. def test_get_handler(self, setup_registry):
  166. """Handler sollte zurückgegeben werden."""
  167. registry = setup_registry
  168. def handler():
  169. return "result"
  170. registry.register(IntentDefinition(
  171. name="test_intent",
  172. handler=handler,
  173. ))
  174. result = registry.get_handler("test_intent")
  175. assert result is handler
  176. def test_get_handler_disabled(self, setup_registry):
  177. """Handler für deaktivierten Intent sollte None sein."""
  178. registry = setup_registry
  179. def handler():
  180. pass
  181. registry.register(IntentDefinition(
  182. name="test_intent",
  183. handler=handler,
  184. enabled=False,
  185. ))
  186. result = registry.get_handler("test_intent")
  187. assert result is None
  188. def test_get_all_as_dict(self, setup_registry):
  189. """Intents sollten als Dictionary-Liste zurückgegeben werden."""
  190. registry = setup_registry
  191. def handler():
  192. pass
  193. registry.register(IntentDefinition(
  194. name="intent1",
  195. handler=handler,
  196. plugin_name="plugin1",
  197. description="First intent",
  198. examples=["example1", "example2"],
  199. slots={
  200. "param": IntentSlot(name="param", slot_type=str),
  201. },
  202. ))
  203. result = registry.get_all_as_dict()
  204. assert len(result) == 1
  205. assert result[0]["name"] == "intent1"
  206. assert result[0]["description"] == "First intent"
  207. assert result[0]["examples"] == ["example1", "example2"]
  208. assert "param" in result[0]["slots"]
  209. def test_enable_disable(self, setup_registry):
  210. """Intents sollten aktiviert/deaktiviert werden können."""
  211. registry = setup_registry
  212. def handler():
  213. pass
  214. registry.register(IntentDefinition(
  215. name="test_intent",
  216. handler=handler,
  217. ))
  218. assert registry.get_handler("test_intent") is handler
  219. registry.disable("test_intent")
  220. assert registry.get_handler("test_intent") is None
  221. registry.enable("test_intent")
  222. assert registry.get_handler("test_intent") is handler
  223. # ============================================================================
  224. # IntentSlot Tests
  225. # ============================================================================
  226. class TestIntentSlot:
  227. """Tests für IntentSlot."""
  228. def test_basic_slot(self):
  229. """Slot sollte mit Standardwerten erstellt werden."""
  230. slot = IntentSlot(name="device")
  231. assert slot.name == "device"
  232. assert slot.slot_type is str
  233. assert slot.required is False
  234. assert slot.description == ""
  235. def test_slot_with_all_params(self):
  236. """Slot sollte alle Parameter akzeptieren."""
  237. slot = IntentSlot(
  238. name="temperature",
  239. slot_type=float,
  240. required=True,
  241. description="Zieltemperatur",
  242. examples=["20", "22.5"],
  243. )
  244. assert slot.name == "temperature"
  245. assert slot.slot_type is float
  246. assert slot.required is True
  247. assert slot.description == "Zieltemperatur"
  248. assert slot.examples == ["20", "22.5"]
  249. def test_slot_to_dict(self):
  250. """Slot sollte zu Dictionary konvertiert werden."""
  251. slot = IntentSlot(
  252. name="room",
  253. slot_type=str,
  254. required=True,
  255. description="Raumname",
  256. )
  257. result = slot.to_dict()
  258. assert result["name"] == "room"
  259. assert result["type"] == "str"
  260. assert result["required"] is True
  261. assert result["description"] == "Raumname"
  262. # ============================================================================
  263. # @intent Decorator Tests
  264. # ============================================================================
  265. class TestIntentDecorator:
  266. """Tests für den @intent Decorator."""
  267. def test_decorator_basic(self):
  268. """Decorator sollte Metadaten hinzufügen."""
  269. @intent("test_intent")
  270. async def handler(data):
  271. pass
  272. assert is_intent_handler(handler)
  273. metadata = get_intent_metadata(handler)
  274. assert metadata is not None
  275. assert metadata["name"] == "test_intent"
  276. def test_decorator_with_slots(self):
  277. """Decorator sollte Slots normalisieren."""
  278. @intent(
  279. "turn_on",
  280. slots={"device": str, "room": str},
  281. )
  282. async def handler(data):
  283. pass
  284. metadata = get_intent_metadata(handler)
  285. assert "device" in metadata["slots"]
  286. assert "room" in metadata["slots"]
  287. assert isinstance(metadata["slots"]["device"], IntentSlot)
  288. def test_decorator_with_intent_slot(self):
  289. """Decorator sollte IntentSlot direkt akzeptieren."""
  290. @intent(
  291. "set_temp",
  292. slots={"temp": IntentSlot("temp", float, required=True)},
  293. )
  294. async def handler(data):
  295. pass
  296. metadata = get_intent_metadata(handler)
  297. assert metadata["slots"]["temp"].slot_type is float
  298. assert metadata["slots"]["temp"].required is True
  299. def test_decorator_with_examples(self):
  300. """Decorator sollte Beispiele speichern."""
  301. @intent(
  302. "greeting",
  303. examples=["Hallo", "Guten Tag", "Hi"],
  304. )
  305. async def handler(data):
  306. pass
  307. metadata = get_intent_metadata(handler)
  308. assert len(metadata["examples"]) == 3
  309. assert "Hallo" in metadata["examples"]
  310. def test_decorator_preserves_function(self):
  311. """Decorator sollte Funktion nicht verändern."""
  312. @intent("test")
  313. async def handler(data):
  314. return "result"
  315. # Async-Funktion bleibt async
  316. assert asyncio.iscoroutinefunction(handler)
  317. def test_discover_handlers(self):
  318. """discover_intent_handlers sollte alle Handler finden."""
  319. class TestPlugin:
  320. @intent("intent1")
  321. async def handler1(self, data):
  322. pass
  323. @intent("intent2", slots={"param": str})
  324. async def handler2(self, data):
  325. pass
  326. def normal_method(self):
  327. pass
  328. plugin = TestPlugin()
  329. handlers = discover_intent_handlers(plugin)
  330. assert len(handlers) == 2
  331. names = [h[0] for h in handlers]
  332. assert "intent1" in names
  333. assert "intent2" in names
  334. # ============================================================================
  335. # Handler Data Classes Tests
  336. # ============================================================================
  337. class TestIntentReceivedData:
  338. """Tests für IntentReceivedData."""
  339. def test_get_slot(self):
  340. """Slot-Werte sollten abgerufen werden können."""
  341. data = IntentReceivedData(
  342. intent="test",
  343. slots={"device": "Licht", "room": "Wohnzimmer"},
  344. )
  345. assert data.get_slot("device") == "Licht"
  346. assert data.get_slot("room") == "Wohnzimmer"
  347. assert data.get_slot("unknown") is None
  348. assert data.get_slot("unknown", "default") == "default"
  349. def test_has_slot(self):
  350. """has_slot sollte korrekt prüfen."""
  351. data = IntentReceivedData(
  352. intent="test",
  353. slots={"device": "Licht", "empty": None},
  354. )
  355. assert data.has_slot("device") is True
  356. assert data.has_slot("empty") is False
  357. assert data.has_slot("unknown") is False
  358. def test_get_required_slots(self):
  359. """Erforderliche Slots sollten geprüft werden."""
  360. data = IntentReceivedData(
  361. intent="test",
  362. slots={"device": "Licht"},
  363. )
  364. all_present, missing = data.get_required_slots("device")
  365. assert all_present is True
  366. assert missing == []
  367. all_present, missing = data.get_required_slots("device", "room")
  368. assert all_present is False
  369. assert "room" in missing
  370. class TestIntentResult:
  371. """Tests für IntentResult."""
  372. def test_success_with_response(self):
  373. """Erfolgreiche Antwort sollte erstellt werden."""
  374. result = IntentResult.success_with_response(
  375. "Licht wurde eingeschaltet.",
  376. device="Licht",
  377. )
  378. assert result.success is True
  379. assert result.response_text == "Licht wurde eingeschaltet."
  380. assert result.data["device"] == "Licht"
  381. def test_failure(self):
  382. """Fehler-Ergebnis sollte erstellt werden."""
  383. result = IntentResult.failure(
  384. "Gerät nicht gefunden",
  385. "Das Gerät konnte nicht gefunden werden.",
  386. )
  387. assert result.success is False
  388. assert result.error == "Gerät nicht gefunden"
  389. assert result.response_text == "Das Gerät konnte nicht gefunden werden."
  390. def test_silent_success(self):
  391. """Stilles Erfolgs-Ergebnis sollte erstellt werden."""
  392. result = IntentResult.silent_success(action="completed")
  393. assert result.success is True
  394. assert result.suppress_tts is True
  395. assert result.has_response() is False
  396. # ============================================================================
  397. # NLP Provider Tests
  398. # ============================================================================
  399. class TestNLPResult:
  400. """Tests für NLPResult."""
  401. def test_failure_result(self):
  402. """Fehler-Ergebnis sollte erstellt werden."""
  403. result = NLPResult.failure("Model error")
  404. assert result.success is False
  405. assert result.error == "Model error"
  406. def test_has_response(self):
  407. """has_response sollte korrekt prüfen."""
  408. result_with = NLPResult(intent="test", response_text="Antwort")
  409. result_without = NLPResult(intent="test")
  410. assert result_with.has_response() is True
  411. assert result_without.has_response() is False
  412. class TestNLPContext:
  413. """Tests für NLPContext."""
  414. def test_basic_context(self):
  415. """Kontext sollte erstellt werden."""
  416. context = NLPContext(
  417. text="Licht an",
  418. satellite_id="sat1",
  419. room_id="wohnzimmer",
  420. )
  421. assert context.text == "Licht an"
  422. assert context.satellite_id == "sat1"
  423. assert context.room_id == "wohnzimmer"
  424. assert context.language == "de"
  425. def test_get_conversation_history_empty(self):
  426. """Leerer Verlauf ohne Session."""
  427. context = NLPContext(text="test")
  428. history = context.get_conversation_history()
  429. assert history == []
  430. class TestNLPConfig:
  431. """Tests für NLPConfig."""
  432. def test_default_config(self):
  433. """Standardkonfiguration sollte gesetzt sein."""
  434. config = NLPConfig()
  435. assert config.backend == "llama_cpp"
  436. assert config.temperature == 0.1
  437. assert config.num_threads == 4
  438. assert config.use_gpu is False
  439. def test_custom_config(self):
  440. """Benutzerdefinierte Konfiguration sollte akzeptiert werden."""
  441. config = NLPConfig(
  442. backend="ollama",
  443. model_name="llama3",
  444. use_gpu=True,
  445. extra={"host": "localhost"},
  446. )
  447. assert config.backend == "ollama"
  448. assert config.model_name == "llama3"
  449. assert config.use_gpu is True
  450. assert config.extra["host"] == "localhost"
  451. # ============================================================================
  452. # Mock NLP Provider Tests
  453. # ============================================================================
  454. class MockNLPProvider(NLPProvider):
  455. """Mock-Provider für Tests."""
  456. def __init__(self):
  457. super().__init__()
  458. self._initialized = False
  459. async def initialize(self, config: NLPConfig) -> bool:
  460. self._config = config
  461. self._state = NLPState.READY
  462. self._initialized = True
  463. return True
  464. async def process(self, context: NLPContext) -> NLPResult:
  465. if not self._initialized:
  466. return NLPResult.failure("Not initialized")
  467. # Einfaches Mocking basierend auf Text
  468. if "licht" in context.text.lower():
  469. return NLPResult(
  470. intent="turn_on_device",
  471. confidence=0.9,
  472. slots={"device": "Licht"},
  473. response_text="Licht wird eingeschaltet.",
  474. )
  475. return NLPResult(
  476. intent="unknown",
  477. confidence=0.3,
  478. )
  479. async def shutdown(self) -> None:
  480. self._state = NLPState.SHUTDOWN
  481. self._initialized = False
  482. class TestNLPProviderInterface:
  483. """Tests für das NLP Provider Interface."""
  484. @pytest.fixture
  485. def provider(self):
  486. """Erstellt einen Mock-Provider."""
  487. return MockNLPProvider()
  488. @pytest.mark.asyncio
  489. async def test_provider_lifecycle(self, provider):
  490. """Provider-Lebenszyklus sollte funktionieren."""
  491. assert provider.state == NLPState.UNINITIALIZED
  492. config = NLPConfig(model_name="test")
  493. await provider.initialize(config)
  494. assert provider.state == NLPState.READY
  495. assert provider.is_ready is True
  496. await provider.shutdown()
  497. assert provider.state == NLPState.SHUTDOWN
  498. @pytest.mark.asyncio
  499. async def test_provider_process(self, provider):
  500. """Provider sollte Text verarbeiten."""
  501. await provider.initialize(NLPConfig())
  502. context = NLPContext(text="Schalte das Licht ein")
  503. result = await provider.process(context)
  504. assert result.success is True
  505. assert result.intent == "turn_on_device"
  506. assert result.confidence >= 0.9
  507. assert "Licht" in result.slots.values()
  508. @pytest.mark.asyncio
  509. async def test_provider_unknown_intent(self, provider):
  510. """Unbekannter Intent sollte erkannt werden."""
  511. await provider.initialize(NLPConfig())
  512. context = NLPContext(text="xyz abc")
  513. result = await provider.process(context)
  514. assert result.intent == "unknown"
  515. assert result.confidence < 0.5
  516. # ============================================================================
  517. # Integration Tests
  518. # ============================================================================
  519. class TestIntegration:
  520. """Integrationstests für das NLP-System."""
  521. @pytest.fixture(autouse=True)
  522. def setup(self):
  523. """Setup und Teardown."""
  524. registry = IntentRegistry.get_instance()
  525. registry.clear()
  526. yield
  527. registry.clear()
  528. @pytest.mark.asyncio
  529. async def test_full_flow(self):
  530. """Kompletter Flow von Intent-Registrierung bis Handler-Aufruf."""
  531. registry = IntentRegistry.get_instance()
  532. # Handler definieren
  533. handler_called = False
  534. handler_data = None
  535. @intent(
  536. "turn_on_device",
  537. slots={"device": str},
  538. examples=["Licht an"],
  539. )
  540. async def handle_turn_on(data: IntentReceivedData) -> IntentResult:
  541. nonlocal handler_called, handler_data
  542. handler_called = True
  543. handler_data = data
  544. return IntentResult.success_with_response(
  545. f"{data.get_slot('device')} wurde eingeschaltet."
  546. )
  547. # Plugin-Objekt simulieren
  548. class TestPlugin:
  549. pass
  550. plugin = TestPlugin()
  551. plugin.handle_turn_on = handle_turn_on
  552. # Handler entdecken und registrieren
  553. handlers = discover_intent_handlers(plugin)
  554. for name, handler, metadata in handlers:
  555. from trixy_core.nlp.intent_registry import IntentDefinition
  556. registry.register(IntentDefinition(
  557. name=name,
  558. handler=handler,
  559. slots=metadata.get("slots", {}),
  560. examples=metadata.get("examples", []),
  561. ))
  562. # Intent-Daten erstellen
  563. data = IntentReceivedData(
  564. intent="turn_on_device",
  565. slots={"device": "Licht"},
  566. original_text="Schalte das Licht ein",
  567. )
  568. # Handler aufrufen
  569. handler = registry.get_handler("turn_on_device")
  570. assert handler is not None
  571. result = await handler(data)
  572. assert handler_called is True
  573. assert handler_data.intent == "turn_on_device"
  574. assert result.success is True
  575. assert "Licht" in result.response_text
  576. @pytest.mark.asyncio
  577. async def test_provider_with_registry(self):
  578. """Provider sollte Intents aus Registry verwenden."""
  579. registry = IntentRegistry.get_instance()
  580. # Intents registrieren
  581. def dummy_handler():
  582. pass
  583. registry.register(IntentDefinition(
  584. name="turn_on_device",
  585. handler=dummy_handler,
  586. description="Schaltet ein Gerät ein",
  587. examples=["Licht an", "Lampe einschalten"],
  588. slots={"device": IntentSlot("device", str)},
  589. ))
  590. # Provider mit Intents initialisieren
  591. provider = MockNLPProvider()
  592. await provider.initialize(NLPConfig())
  593. # Kontext mit verfügbaren Intents
  594. intents = registry.get_all_as_dict()
  595. context = NLPContext(
  596. text="Schalte das Licht ein",
  597. available_intents=intents,
  598. )
  599. result = await provider.process(context)
  600. assert result.intent == "turn_on_device"