intent_dispatcher.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691
  1. # -*- coding: utf-8 -*-
  2. """
  3. Intent Dispatcher Service.
  4. Zentrale Komponente die Intent-Events verarbeitet und
  5. den Handler-Flow koordiniert.
  6. Event-Flow:
  7. intent_received
  8. ↓
  9. [Intent Dispatcher]
  10. ├── Handler finden und aufrufen
  11. ├── System-Intents direkt behandeln
  12. ↓
  13. intent_handled
  14. ↓
  15. [Falls keine response_text und nicht suppress_response]
  16. ↓
  17. create_output_text
  18. """
  19. import time
  20. from typing import Any, TYPE_CHECKING
  21. from trixy_core.service.iservice import IService
  22. from trixy_core.service.enums import ServicePriority, ServiceGroup
  23. from trixy_core.events.decorators import TrixyEvent
  24. from trixy_core.events.event_data.basic import (
  25. IntentReceived,
  26. IntentHandled,
  27. CreateOutputText,
  28. OutputTextCreated,
  29. FollowUpExpected,
  30. )
  31. from trixy_core.nlp.intent_registry import IntentRegistry
  32. from trixy_core.nlp.system_intents import is_system_intent, get_system_intent_info, is_admin_intent
  33. from trixy_core.nlp.handler import IntentReceivedData
  34. from trixy_core.nlp.decorators import INTENT_METADATA_ATTR
  35. from trixy_core.utils.debug import pinfo, pdebug, perror
  36. import logging
  37. from pathlib import Path
  38. # Separater Logger fuer Admin-Audit-Log
  39. _admin_logger: logging.Logger | None = None
  40. def _get_admin_logger() -> logging.Logger:
  41. """Erstellt/gibt den Admin-Audit-Logger zurueck."""
  42. global _admin_logger
  43. if _admin_logger is None:
  44. _admin_logger = logging.getLogger("trixy.admin_audit")
  45. _admin_logger.setLevel(logging.INFO)
  46. _admin_logger.propagate = False
  47. log_dir = Path("logs")
  48. log_dir.mkdir(exist_ok=True)
  49. handler = logging.FileHandler(log_dir / "admin_audit.log", encoding="utf-8")
  50. handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s"))
  51. _admin_logger.addHandler(handler)
  52. return _admin_logger
  53. if TYPE_CHECKING:
  54. from trixy_core.application import IApplication
  55. class IntentDispatcherService(IService):
  56. """
  57. Service der Intent-Events verarbeitet.
  58. Verantwortlich für:
  59. 1. Handler-Aufruf für intent_received
  60. 2. Emission von intent_handled
  61. 3. Emission von create_output_text (falls nötig)
  62. 4. System-Intent Behandlung
  63. 5. TTS-Request für output_text_created
  64. """
  65. PRIORITY = ServicePriority.MANAGER
  66. GROUP = ServiceGroup.CONVERSATION
  67. DEPENDENCIES: list[str] = []
  68. NAME = "IntentDispatcher"
  69. def __init__(self, application: "IApplication") -> None:
  70. super().__init__(application)
  71. self._admin_sessions: dict[str, dict[str, Any]] = {}
  72. # Tracking für ausstehende Follow-ups: request_id → session_info
  73. self._pending_followups: dict[str, dict[str, Any]] = {}
  74. # Aktive Follow-Up-Kontexte: satellite_id → Follow-Up-Info
  75. self._active_followups: dict[str, dict[str, Any]] = {}
  76. async def start(self) -> None:
  77. """Startet den Service."""
  78. pinfo("IntentDispatcher gestartet")
  79. async def stop(self) -> None:
  80. """Stoppt den Service."""
  81. pdebug("IntentDispatcher gestoppt")
  82. # =========================================================================
  83. # Event: intent_received → intent_handled
  84. # =========================================================================
  85. @TrixyEvent(["intent_received"])
  86. async def on_intent_received(self, event_name: str, event_data: IntentReceived) -> None:
  87. """
  88. Verarbeitet erkannte Intents.
  89. 1. Findet passenden Handler
  90. 2. Ruft Handler auf
  91. 3. Emittiert intent_handled
  92. 4. Emittiert ggf. create_output_text
  93. """
  94. pinfo(f"[DISPATCHER] intent_received: '{event_data.intent}' (confidence={event_data.confidence:.2f}, text='{event_data.original_text[:50]}')")
  95. # System-Intents direkt behandeln
  96. if is_system_intent(event_data.intent):
  97. pinfo(f"[DISPATCHER] System-Intent erkannt: '{event_data.intent}'")
  98. # Admin-System-Intents loggen
  99. if is_admin_intent(event_data.intent):
  100. _get_admin_logger().info(
  101. "SYSTEM intent='%s' satellite='%s' room='%s' "
  102. "wakeword='%s' text='%s'",
  103. event_data.intent, event_data.satellite_id,
  104. event_data.room_id, event_data.wakeword_type,
  105. event_data.original_text,
  106. )
  107. await self._handle_system_intent(event_data)
  108. return
  109. # Follow-Up-Validierung: Ist eine Rueckfrage aktiv?
  110. followup_ctx = self._active_followups.get(event_data.satellite_id)
  111. if followup_ctx:
  112. validated = self._validate_followup_response(
  113. event_data, followup_ctx,
  114. )
  115. if validated is not None:
  116. # Antwort validiert — Intent und Slots anpassen
  117. event_data.intent = followup_ctx["follow_up_intent"]
  118. event_data.slots = {**followup_ctx.get("data", {}), **validated}
  119. # Follow-Up-Kontext loeschen
  120. del self._active_followups[event_data.satellite_id]
  121. pdebug(f"[DISPATCHER] Follow-Up validiert: {event_data.intent}")
  122. elif validated is None and followup_ctx.get("valid_responses"):
  123. # Ungueltige Antwort — Retry
  124. retry_text = followup_ctx.get("retry_text", "")
  125. if retry_text:
  126. pinfo(f"[DISPATCHER] Follow-Up: Ungueltige Antwort, Retry")
  127. output_event = OutputTextCreated(
  128. satellite_id=event_data.satellite_id,
  129. session_id=event_data.session_id,
  130. room_id=event_data.room_id,
  131. text=retry_text,
  132. intent=followup_ctx.get("original_intent", ""),
  133. is_followup=True,
  134. expects_response=True,
  135. )
  136. await self._application.events.trigger("output_text_created", output_event)
  137. return
  138. # Plugin-Handler suchen (mit Suffix-Fallback fuer LLM-verkuerzte Namen)
  139. registry = IntentRegistry.get_instance()
  140. resolved = registry.resolve_intent(event_data.intent)
  141. handler = resolved.handler if resolved else None
  142. # Falls Suffix-Match, den aufgeloesten Namen verwenden
  143. if resolved and resolved.name != event_data.intent:
  144. pinfo(f"[DISPATCHER] Intent aufgeloest: '{event_data.intent}' → '{resolved.name}'")
  145. event_data.intent = resolved.name
  146. pinfo(f"[DISPATCHER] Handler-Suche für '{event_data.intent}': {'gefunden' if handler else 'NICHT gefunden'}")
  147. response_text = ""
  148. handler_data: dict[str, Any] = {}
  149. success = True
  150. error = ""
  151. needs_followup = False
  152. followup_prompt = ""
  153. suppress_response = False
  154. if handler is not None:
  155. # Admin-Only Pruefung: Intent nur bei system_command Wakeword erlauben
  156. handler_meta = getattr(handler, INTENT_METADATA_ATTR, {})
  157. is_admin = handler_meta.get("admin_only", False)
  158. if is_admin:
  159. ww_type = getattr(event_data, "wakeword_type", "")
  160. ww_model = getattr(event_data, "wakeword_model", ww_type)
  161. admin_wakewords = self._get_admin_wakewords()
  162. audit = _get_admin_logger()
  163. if ww_model not in admin_wakewords and ww_type not in admin_wakewords:
  164. audit.warning(
  165. "ABGELEHNT intent='%s' satellite='%s' room='%s' "
  166. "wakeword='%s' text='%s'",
  167. event_data.intent, event_data.satellite_id,
  168. event_data.room_id, ww_type, event_data.original_text,
  169. )
  170. pinfo(f"[DISPATCHER] Admin-Intent '{event_data.intent}' abgelehnt "
  171. f"(Wakeword: '{ww_type}', erfordert: 'system_command')")
  172. response_text = "Dieser Befehl erfordert das System-Wakeword."
  173. success = False
  174. handled_event = IntentHandled(
  175. satellite_id=event_data.satellite_id,
  176. session_id=event_data.session_id,
  177. room_id=event_data.room_id,
  178. intent=event_data.intent,
  179. original_text=event_data.original_text,
  180. slots=event_data.slots,
  181. success=False,
  182. response_text=response_text,
  183. )
  184. await self._application.events.trigger("intent_handled", handled_event)
  185. output_event = OutputTextCreated(
  186. satellite_id=event_data.satellite_id,
  187. session_id=event_data.session_id,
  188. room_id=event_data.room_id,
  189. text=response_text,
  190. intent=event_data.intent,
  191. )
  192. await self._application.events.trigger("output_text_created", output_event)
  193. return
  194. else:
  195. audit.info(
  196. "ERLAUBT intent='%s' satellite='%s' room='%s' "
  197. "wakeword='%s' text='%s'",
  198. event_data.intent, event_data.satellite_id,
  199. event_data.room_id, ww_type, event_data.original_text,
  200. )
  201. # Handler-Input erstellen
  202. handler_input = IntentReceivedData(
  203. intent=event_data.intent,
  204. confidence=event_data.confidence,
  205. slots=event_data.slots,
  206. original_text=event_data.original_text,
  207. satellite_id=event_data.satellite_id,
  208. room_id=event_data.room_id,
  209. session_id=event_data.session_id,
  210. wakeword_type=event_data.wakeword_type,
  211. is_authenticated=event_data.is_authenticated,
  212. )
  213. try:
  214. result = await handler(handler_input)
  215. if result:
  216. success = result.success
  217. error = result.error
  218. handler_data = result.data or {}
  219. suppress_response = result.suppress_tts
  220. if result.has_response():
  221. response_text = result.response_text
  222. if result.needs_follow_up():
  223. needs_followup = True
  224. followup_prompt = result.follow_up_intent
  225. # Follow-Up-Kontext mit Validierung speichern
  226. self._active_followups[event_data.satellite_id] = {
  227. "follow_up_intent": result.follow_up_intent,
  228. "valid_responses": result.follow_up_valid_responses,
  229. "retry_text": result.follow_up_retry_text,
  230. "data": result.data or {},
  231. "original_intent": event_data.intent,
  232. }
  233. except Exception as e:
  234. perror(f"Handler-Fehler für '{event_data.intent}': {e}")
  235. success = False
  236. error = str(e)
  237. else:
  238. pdebug(f"Kein Handler für Intent: {event_data.intent}")
  239. handler_data = {"note": "Kein Handler registriert"}
  240. # intent_handled emittieren
  241. handled_event = IntentHandled(
  242. satellite_id=event_data.satellite_id,
  243. session_id=event_data.session_id,
  244. room_id=event_data.room_id,
  245. intent=event_data.intent,
  246. original_text=event_data.original_text,
  247. slots=event_data.slots,
  248. success=success,
  249. response_text=response_text,
  250. data=handler_data,
  251. error=error,
  252. needs_followup=needs_followup,
  253. followup_prompt=followup_prompt,
  254. suppress_response=suppress_response,
  255. )
  256. await self._application.events.trigger("intent_handled", handled_event)
  257. # Wenn Handler Antwort hat → direkt output_text_created
  258. if response_text:
  259. pinfo(f"[DISPATCHER] Handler hat Antwort: '{response_text[:80]}' → output_text_created")
  260. output_event = OutputTextCreated(
  261. satellite_id=event_data.satellite_id,
  262. session_id=event_data.session_id,
  263. room_id=event_data.room_id,
  264. text=response_text,
  265. intent=event_data.intent,
  266. is_followup=needs_followup,
  267. expects_response=needs_followup,
  268. )
  269. await self._application.events.trigger("output_text_created", output_event)
  270. # Wenn keine Antwort und nicht unterdrückt → create_output_text (LLM generiert Antwort)
  271. elif not suppress_response:
  272. pinfo(f"[DISPATCHER] Keine Handler-Antwort → create_output_text (LLM generiert)")
  273. create_event = CreateOutputText(
  274. satellite_id=event_data.satellite_id,
  275. session_id=event_data.session_id,
  276. room_id=event_data.room_id,
  277. intent=event_data.intent,
  278. original_text=event_data.original_text,
  279. slots=event_data.slots,
  280. handler_data=handler_data,
  281. handler_success=success,
  282. handler_error=error,
  283. language=event_data.language,
  284. )
  285. await self._application.events.trigger("create_output_text", create_event)
  286. # =========================================================================
  287. # Event: output_text_created → tts_request
  288. # =========================================================================
  289. @TrixyEvent(["output_text_created"])
  290. async def on_output_text_created(self, event_name: str, event_data: OutputTextCreated) -> None:
  291. """
  292. Leitet Antworttext an TTS weiter.
  293. Emittiert: tts_request
  294. Bei Follow-up: Speichert Info für followup_expected nach TTS
  295. """
  296. if not event_data.text:
  297. return
  298. import uuid
  299. from trixy_core.utils.template_formatter import format_template
  300. # Template-Platzhalter aufloesen (Plugins koennen {date.*} etc. nutzen)
  301. resolved_text = format_template(event_data.text, application=self._application)
  302. request_id = str(uuid.uuid4())
  303. tts_request = {
  304. "request_id": request_id,
  305. "satellite_id": event_data.satellite_id,
  306. "text": resolved_text,
  307. }
  308. # Bei Follow-up: Request-ID merken für späteren followup_expected
  309. if event_data.expects_response:
  310. self._pending_followups[request_id] = {
  311. "satellite_id": event_data.satellite_id,
  312. "session_id": event_data.session_id,
  313. "room_id": event_data.room_id,
  314. "intent": event_data.intent,
  315. }
  316. pdebug(f"Follow-up registriert für TTS-Request: {request_id}")
  317. pdebug(f"TTS-Request: {resolved_text[:50]}...")
  318. await self._application.events.emit("tts_request", tts_request)
  319. # =========================================================================
  320. # Event: tts_completed → followup_expected (bei Rückfragen)
  321. # =========================================================================
  322. @TrixyEvent(["tts_completed"])
  323. async def on_tts_completed(self, event_name: str, event_data) -> None:
  324. """
  325. Verarbeitet TTS-Ergebnis:
  326. 1. Audio an Satellite senden
  327. 2. Bei Follow-up: followup_expected emittieren
  328. """
  329. # tts_completed kommt als generisches EventData (via emit() mit dict)
  330. satellite_id = event_data.get("satellite_id", "")
  331. audio_data_hex = event_data.get("audio_data", "")
  332. request_id = event_data.get("request_id", "")
  333. # Audio an Satellite senden (falls satellite_id vorhanden)
  334. if satellite_id and audio_data_hex:
  335. await self._send_tts_to_satellite(satellite_id, audio_data_hex)
  336. # Follow-up prüfen
  337. if not request_id:
  338. return
  339. followup_info = self._pending_followups.pop(request_id, None)
  340. if not followup_info:
  341. return
  342. success = event_data.get("success", True)
  343. if not success:
  344. perror(f"TTS fehlgeschlagen für Follow-up Request: {request_id}")
  345. return
  346. # followup_expected emittieren - Client wechselt in Hör-Modus
  347. followup_event = FollowUpExpected(
  348. satellite_id=followup_info["satellite_id"],
  349. session_id=followup_info["session_id"],
  350. room_id=followup_info["room_id"],
  351. timeout_seconds=30.0,
  352. followup_context={
  353. "previous_intent": followup_info["intent"],
  354. },
  355. )
  356. pinfo(f"Follow-up erwartet für Satellite: {followup_info['satellite_id']}")
  357. await self._application.events.trigger("followup_expected", followup_event)
  358. async def _send_tts_to_satellite(self, satellite_id: str, audio_data_hex: str) -> None:
  359. """Sendet TTS-Audio an den Satellite."""
  360. try:
  361. audio_bytes = bytes.fromhex(audio_data_hex)
  362. except (ValueError, AttributeError) as e:
  363. perror(f"TTS-Audio Dekodierung fehlgeschlagen: {e}")
  364. return
  365. satellites = getattr(self._application, "satellites", None)
  366. if not satellites:
  367. return
  368. satellite = satellites.get(satellite_id)
  369. if not satellite or not satellite.is_connected:
  370. pdebug(f"Satellite nicht verfügbar für TTS: {satellite_id}")
  371. return
  372. pinfo(f"[DISPATCHER] Sende TTS-Audio ({len(audio_bytes)} bytes) an {satellite.alias}")
  373. success = await satellite.say(audio_bytes)
  374. if success:
  375. # TTSStop senden damit Client weiß dass Stream beendet ist
  376. from trixy_core.network.cmd import TTSStop
  377. network = self._application.services.get_service("NetworkService")
  378. if network and hasattr(network, "send_to_satellite"):
  379. await network.send_to_satellite(satellite_id, TTSStop())
  380. pdebug(f"TTSStop gesendet an {satellite.alias}")
  381. else:
  382. perror(f"TTS-Audio senden fehlgeschlagen an {satellite_id}")
  383. # =========================================================================
  384. # System-Intent Behandlung
  385. # =========================================================================
  386. async def _handle_system_intent(self, event_data: IntentReceived) -> None:
  387. """Behandelt System-Intents direkt."""
  388. intent = event_data.intent
  389. slots = event_data.slots
  390. satellite_id = event_data.satellite_id
  391. response_text = ""
  392. handler_data: dict[str, Any] = {}
  393. success = True
  394. needs_followup = False
  395. # Zeit-Abfragen
  396. if intent == "get_time":
  397. from datetime import datetime
  398. now = datetime.now()
  399. response_text = f"Es ist {now.strftime('%H:%M')} Uhr."
  400. elif intent == "get_date":
  401. from datetime import datetime
  402. weekdays = ["Montag", "Dienstag", "Mittwoch", "Donnerstag",
  403. "Freitag", "Samstag", "Sonntag"]
  404. now = datetime.now()
  405. response_text = f"Heute ist {weekdays[now.weekday()]}, der {now.strftime('%d.%m.%Y')}."
  406. elif intent == "health_check":
  407. response_text = await self._generate_health_response()
  408. elif intent == "help":
  409. response_text = "Ich kann Geräte steuern, Fragen beantworten und vieles mehr. Sag einfach was du brauchst."
  410. elif intent == "cancel":
  411. response_text = "Alles klar, abgebrochen."
  412. # Medien-Steuerung
  413. elif intent == "stop":
  414. await self._application.events.emit("media_stop_all", {
  415. "satellite_id": satellite_id,
  416. })
  417. response_text = "Gestoppt."
  418. elif intent == "pause":
  419. await self._application.events.emit("music_paused", {
  420. "satellite_id": satellite_id,
  421. })
  422. response_text = "Pausiert."
  423. elif intent == "resume":
  424. await self._application.events.emit("music_resumed", {
  425. "satellite_id": satellite_id,
  426. })
  427. response_text = "Wird fortgesetzt."
  428. # Lautstärke
  429. elif intent in ("volume_up", "volume_down", "volume_set", "mute", "unmute"):
  430. if intent == "volume_up":
  431. await self._application.events.emit("music_volume_change", {
  432. "direction": "up", "amount": slots.get("amount", 10),
  433. })
  434. response_text = "Lauter."
  435. elif intent == "volume_down":
  436. await self._application.events.emit("music_volume_change", {
  437. "direction": "down", "amount": slots.get("amount", 10),
  438. })
  439. response_text = "Leiser."
  440. elif intent == "volume_set":
  441. level = slots.get("level", 50)
  442. await self._application.events.emit("music_volume_change", {
  443. "direction": "set", "level": level,
  444. })
  445. response_text = f"Lautstärke auf {level} Prozent."
  446. elif intent == "mute":
  447. await self._application.events.emit("music_volume_change", {
  448. "direction": "mute",
  449. })
  450. response_text = "Stumm."
  451. elif intent == "unmute":
  452. await self._application.events.emit("music_volume_change", {
  453. "direction": "unmute",
  454. })
  455. response_text = "Ton an."
  456. # Admin-Authentifizierung
  457. elif intent == "system_login":
  458. password = slots.get("password", "")
  459. if self._authenticate(satellite_id, password):
  460. response_text = "Administrator-Anmeldung erfolgreich."
  461. else:
  462. response_text = "Falsches Passwort."
  463. success = False
  464. elif intent == "system_logout":
  465. self._logout(satellite_id)
  466. response_text = "Administrator-Sitzung beendet."
  467. # Admin-Befehle
  468. elif intent == "system_shutdown":
  469. response_text = "System wird heruntergefahren."
  470. from trixy_core.events.event_data.basic import SystemShutdown
  471. await self._application.events.trigger(
  472. "system_shutdown", SystemShutdown(reason="Admin-Befehl")
  473. )
  474. elif intent == "system_reboot":
  475. response_text = "System wird neu gestartet."
  476. elif intent == "system_status":
  477. response_text = await self._generate_status_response()
  478. # Unknown system intent
  479. else:
  480. response_text = f"System-Befehl '{intent}' ist nicht implementiert."
  481. success = False
  482. # intent_handled emittieren
  483. handled_event = IntentHandled(
  484. satellite_id=satellite_id,
  485. session_id=event_data.session_id,
  486. room_id=event_data.room_id,
  487. intent=intent,
  488. original_text=event_data.original_text,
  489. slots=slots,
  490. success=success,
  491. response_text=response_text,
  492. data=handler_data,
  493. needs_followup=needs_followup,
  494. )
  495. await self._application.events.trigger("intent_handled", handled_event)
  496. # output_text_created emittieren
  497. if response_text:
  498. output_event = OutputTextCreated(
  499. satellite_id=satellite_id,
  500. session_id=event_data.session_id,
  501. room_id=event_data.room_id,
  502. text=response_text,
  503. intent=intent,
  504. expects_response=needs_followup,
  505. )
  506. await self._application.events.trigger("output_text_created", output_event)
  507. async def _generate_health_response(self) -> str:
  508. """Generiert Health-Check Antwort."""
  509. try:
  510. import psutil
  511. cpu = psutil.cpu_percent()
  512. mem = psutil.virtual_memory().percent
  513. return f"Mir geht es gut! CPU: {cpu}%, Speicher: {mem}%."
  514. except ImportError:
  515. return "Mir geht es gut! Alle Systeme laufen normal."
  516. async def _generate_status_response(self) -> str:
  517. """Generiert detaillierten Status."""
  518. try:
  519. import psutil
  520. from datetime import datetime
  521. cpu = psutil.cpu_percent()
  522. mem = psutil.virtual_memory()
  523. disk = psutil.disk_usage('/')
  524. return (f"Systemstatus: CPU {cpu}%, "
  525. f"RAM {mem.percent}% ({mem.used // (1024**3)}GB), "
  526. f"Disk {disk.percent}%.")
  527. except ImportError:
  528. return "Detaillierter Status nicht verfügbar."
  529. def _get_admin_wakewords(self) -> set[str]:
  530. """Liest die Admin-Wakewords aus der Server-Config."""
  531. config_manager = getattr(self._application, "config_manager", None)
  532. if config_manager:
  533. ww_cfg = config_manager.get("wakeword", {})
  534. if isinstance(ww_cfg, dict):
  535. return set(ww_cfg.get("admin_wakewords", ["system_command"]))
  536. elif hasattr(ww_cfg, "admin_wakewords"):
  537. return set(ww_cfg.admin_wakewords)
  538. return {"system_command"}
  539. def _authenticate(self, satellite_id: str, password: str) -> bool:
  540. """Admin-Authentifizierung."""
  541. # TODO: Passwort aus Config holen
  542. admin_password = "admin" # Placeholder
  543. if password == admin_password:
  544. self._admin_sessions[satellite_id] = {
  545. "authenticated": True,
  546. "expires": time.time() + 1800,
  547. }
  548. return True
  549. return False
  550. def _logout(self, satellite_id: str) -> None:
  551. """Admin-Abmeldung."""
  552. self._admin_sessions.pop(satellite_id, None)
  553. def _validate_followup_response(
  554. self,
  555. event_data: IntentReceived,
  556. followup_ctx: dict[str, Any],
  557. ) -> dict[str, Any] | None:
  558. """
  559. Validiert eine Antwort im Follow-Up-Kontext.
  560. Prueft ob die Antwort zu den erlaubten Antworten passt.
  561. Bei offener Validierung (keine valid_responses) wird
  562. der Text als Slot-Wert uebernommen.
  563. Args:
  564. event_data: Das eingehende Intent-Event
  565. followup_ctx: Der gespeicherte Follow-Up-Kontext
  566. Returns:
  567. Dict mit extrahierten Slot-Werten oder None bei ungueltiger Antwort.
  568. Leeres Dict wenn keine Validierung definiert (alles akzeptiert).
  569. """
  570. valid_responses = followup_ctx.get("valid_responses", [])
  571. text = event_data.original_text.strip().lower()
  572. # Keine Validierung definiert — alles akzeptieren
  573. if not valid_responses:
  574. return {"response_text": event_data.original_text}
  575. # Gegen gueltige Antworten pruefen (fuzzy, case-insensitive)
  576. for valid in valid_responses:
  577. valid_lower = valid.lower()
  578. # Exakter Match
  579. if valid_lower == text or valid_lower in text:
  580. return {"response_text": valid, "matched_value": valid}
  581. # Mehrere Werte in einem Satz suchen (z.B. "Salami und Pilze")
  582. found_values = []
  583. for valid in valid_responses:
  584. if valid.lower() in text:
  585. found_values.append(valid)
  586. if found_values:
  587. return {"response_text": ", ".join(found_values), "matched_values": found_values}
  588. # Kein Match — ungueltige Antwort
  589. pdebug(
  590. f"[DISPATCHER] Follow-Up: '{text}' nicht in "
  591. f"{[v[:15] for v in valid_responses[:5]]}..."
  592. )
  593. return None