intent_dispatcher.py 31 KB

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