manager.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578
  1. # -*- coding: utf-8 -*-
  2. """
  3. Conversation Manager - Verwaltet Dialog-Sessions.
  4. """
  5. from dataclasses import dataclass, field
  6. from datetime import datetime
  7. from typing import TYPE_CHECKING, Callable, Iterator
  8. import asyncio
  9. import threading
  10. from trixy_core.service import IService, ServicePriority, ServiceGroup
  11. from trixy_core.conversation.session import (
  12. ConversationSession,
  13. SessionConfig,
  14. SessionState,
  15. )
  16. from trixy_core.utils.debug import perror
  17. if TYPE_CHECKING:
  18. from trixy_core.application import BaseApplication
  19. @dataclass
  20. class ManagerConfig:
  21. """Konfiguration für den ConversationManager."""
  22. # Session-Defaults
  23. default_session_config: SessionConfig = field(default_factory=SessionConfig)
  24. # Limits
  25. max_active_sessions: int = 100
  26. max_sessions_per_satellite: int = 1
  27. # Cleanup
  28. cleanup_interval_seconds: float = 30.0
  29. keep_completed_seconds: float = 300.0 # 5 Minuten behalten
  30. # Timeouts
  31. arbitration_window_seconds: float = 1.0
  32. class ConversationManager(IService):
  33. """
  34. Verwaltet Conversation-Sessions.
  35. Verantwortlich für:
  36. - Session-Erstellung und -Verwaltung
  37. - Session-Lookup nach ID oder Satellite
  38. - Timeout-Handling und Cleanup
  39. - Session-Events
  40. """
  41. PRIORITY = ServicePriority.MANAGER
  42. GROUP = ServiceGroup.CONVERSATION
  43. DEPENDENCIES = ["event_manager"]
  44. def __init__(
  45. self,
  46. application: "BaseApplication",
  47. config: ManagerConfig | None = None,
  48. ):
  49. """
  50. Initialisiert den Manager.
  51. Args:
  52. application: Anwendungs-Instanz
  53. config: Manager-Konfiguration
  54. """
  55. super().__init__(application)
  56. self._config = config or ManagerConfig()
  57. self._lock = threading.RLock()
  58. # Sessions
  59. self._sessions: dict[str, ConversationSession] = {}
  60. self._sessions_by_satellite: dict[str, list[str]] = {}
  61. # Cleanup
  62. self._cleanup_task: asyncio.Task | None = None
  63. # Callbacks
  64. self._on_session_start: list[Callable[[ConversationSession], None]] = []
  65. self._on_session_complete: list[Callable[[ConversationSession], None]] = []
  66. self._on_follow_up: list[Callable[[ConversationSession, str], None]] = []
  67. def _emit_event(self, event_name: str, data: dict) -> None:
  68. """Emittiert Event async-safe aus sync-Kontext."""
  69. coro = self.application.event_manager.emit(event_name, data)
  70. try:
  71. loop = asyncio.get_running_loop()
  72. task = loop.create_task(coro)
  73. task.add_done_callback(
  74. lambda t: perror(f"Event-Fehler '{event_name}': {t.exception()}")
  75. if t.exception() else None
  76. )
  77. except RuntimeError:
  78. # Kein laufender Event-Loop
  79. pass
  80. @property
  81. def config(self) -> ManagerConfig:
  82. """Gibt Konfiguration zurück."""
  83. return self._config
  84. @property
  85. def session_count(self) -> int:
  86. """Anzahl aller Sessions."""
  87. with self._lock:
  88. return len(self._sessions)
  89. @property
  90. def active_session_count(self) -> int:
  91. """Anzahl aktiver Sessions."""
  92. with self._lock:
  93. return sum(1 for s in self._sessions.values() if s.is_active)
  94. async def on_start(self) -> None:
  95. """Service-Start."""
  96. # Starte Cleanup-Task
  97. self._cleanup_task = asyncio.create_task(self._cleanup_loop())
  98. self.logger.info("ConversationManager gestartet")
  99. async def on_stop(self) -> None:
  100. """Service-Stop."""
  101. # Stoppe Cleanup
  102. if self._cleanup_task:
  103. self._cleanup_task.cancel()
  104. try:
  105. await self._cleanup_task
  106. except asyncio.CancelledError:
  107. pass
  108. # Beende alle aktiven Sessions
  109. with self._lock:
  110. for session in self._sessions.values():
  111. if session.is_active:
  112. session.cancel("Manager wird gestoppt")
  113. # === Session-Erstellung ===
  114. def create_session(
  115. self,
  116. satellite_id: str | None = None,
  117. wakeword_type: str | None = None,
  118. session_id: str | None = None,
  119. config: SessionConfig | None = None,
  120. ) -> ConversationSession:
  121. """
  122. Erstellt eine neue Conversation-Session.
  123. Args:
  124. satellite_id: ID des auslösenden Satellites
  125. wakeword_type: Typ des erkannten Wakewords
  126. session_id: Session-ID (optional, wird generiert)
  127. config: Session-Konfiguration (optional)
  128. Returns:
  129. Neue Session
  130. Raises:
  131. RuntimeError: Bei Limit-Überschreitung
  132. """
  133. with self._lock:
  134. # Prüfe Limits
  135. if self.active_session_count >= self._config.max_active_sessions:
  136. raise RuntimeError("Maximale aktive Sessions erreicht")
  137. if satellite_id:
  138. satellite_sessions = self._sessions_by_satellite.get(satellite_id, [])
  139. active_count = sum(
  140. 1 for sid in satellite_sessions
  141. if sid in self._sessions and self._sessions[sid].is_active
  142. )
  143. if active_count >= self._config.max_sessions_per_satellite:
  144. raise RuntimeError(
  145. f"Satellite {satellite_id} hat bereits aktive Session"
  146. )
  147. # Erstelle Session
  148. session = ConversationSession(
  149. session_id=session_id,
  150. satellite_id=satellite_id,
  151. wakeword_type=wakeword_type,
  152. config=config or self._config.default_session_config,
  153. )
  154. # Registriere
  155. self._sessions[session.session_id] = session
  156. if satellite_id:
  157. if satellite_id not in self._sessions_by_satellite:
  158. self._sessions_by_satellite[satellite_id] = []
  159. self._sessions_by_satellite[satellite_id].append(session.session_id)
  160. # Callbacks
  161. for callback in self._on_session_start:
  162. try:
  163. callback(session)
  164. except Exception:
  165. pass
  166. # Event emittieren (async-safe aus sync-Kontext)
  167. self._emit_event("conversation_started", {
  168. "session_id": session.session_id,
  169. "satellite_id": satellite_id,
  170. "wakeword_type": wakeword_type,
  171. })
  172. self.logger.info(
  173. f"Session erstellt: {session.session_id} "
  174. f"(Satellite: {satellite_id}, Wakeword: {wakeword_type})"
  175. )
  176. return session
  177. # === Session-Lookup ===
  178. def get_session(self, session_id: str) -> ConversationSession | None:
  179. """
  180. Gibt Session nach ID zurück.
  181. Args:
  182. session_id: Session-ID
  183. Returns:
  184. Session oder None
  185. """
  186. with self._lock:
  187. return self._sessions.get(session_id)
  188. def get_active_session(self, satellite_id: str) -> ConversationSession | None:
  189. """
  190. Gibt aktive Session für Satellite zurück.
  191. Args:
  192. satellite_id: Satellite-ID
  193. Returns:
  194. Aktive Session oder None
  195. """
  196. with self._lock:
  197. session_ids = self._sessions_by_satellite.get(satellite_id, [])
  198. for sid in session_ids:
  199. session = self._sessions.get(sid)
  200. if session and session.is_active:
  201. return session
  202. return None
  203. def get_sessions(
  204. self,
  205. satellite_id: str | None = None,
  206. state: SessionState | None = None,
  207. active_only: bool = False,
  208. ) -> list[ConversationSession]:
  209. """
  210. Gibt gefilterte Sessions zurück.
  211. Args:
  212. satellite_id: Filter nach Satellite
  213. state: Filter nach Zustand
  214. active_only: Nur aktive Sessions
  215. Returns:
  216. Liste von Sessions
  217. """
  218. with self._lock:
  219. sessions = list(self._sessions.values())
  220. # Filter
  221. if satellite_id:
  222. sessions = [s for s in sessions if s.satellite_id == satellite_id]
  223. if state:
  224. sessions = [s for s in sessions if s.state == state]
  225. if active_only:
  226. sessions = [s for s in sessions if s.is_active]
  227. return sessions
  228. def iterate_active(self) -> Iterator[ConversationSession]:
  229. """Iteriert über aktive Sessions."""
  230. with self._lock:
  231. sessions = [s for s in self._sessions.values() if s.is_active]
  232. for session in sessions:
  233. yield session
  234. # === Session-Lifecycle ===
  235. def start_session(self, session_id: str) -> bool:
  236. """
  237. Startet eine Session.
  238. Args:
  239. session_id: Session-ID
  240. Returns:
  241. True wenn gestartet
  242. """
  243. session = self.get_session(session_id)
  244. if not session:
  245. return False
  246. session.start()
  247. return True
  248. def complete_session(
  249. self,
  250. session_id: str,
  251. reason: str | None = None,
  252. ) -> bool:
  253. """
  254. Schließt eine Session ab.
  255. Args:
  256. session_id: Session-ID
  257. reason: Abschlussgrund
  258. Returns:
  259. True wenn abgeschlossen
  260. """
  261. session = self.get_session(session_id)
  262. if not session:
  263. return False
  264. if session.is_completed:
  265. return False
  266. session.complete(reason)
  267. self._on_session_completed(session)
  268. return True
  269. def cancel_session(
  270. self,
  271. session_id: str,
  272. reason: str | None = None,
  273. ) -> bool:
  274. """
  275. Bricht eine Session ab.
  276. Args:
  277. session_id: Session-ID
  278. reason: Abbruchgrund
  279. Returns:
  280. True wenn abgebrochen
  281. """
  282. session = self.get_session(session_id)
  283. if not session:
  284. return False
  285. if session.is_completed:
  286. return False
  287. session.cancel(reason)
  288. self._on_session_completed(session)
  289. return True
  290. def _on_session_completed(self, session: ConversationSession) -> None:
  291. """Intern: Session wurde abgeschlossen."""
  292. # Callbacks
  293. for callback in self._on_session_complete:
  294. try:
  295. callback(session)
  296. except Exception:
  297. pass
  298. # Event emittieren (async-safe aus sync-Kontext)
  299. self._emit_event("conversation_ended", {
  300. "session_id": session.session_id,
  301. "satellite_id": session.satellite_id,
  302. "state": session.state.value,
  303. "duration_seconds": session.duration_seconds,
  304. "turn_count": session.turn_count,
  305. })
  306. self.logger.info(
  307. f"Session beendet: {session.session_id} "
  308. f"(State: {session.state.value}, Dauer: {session.duration_seconds:.1f}s)"
  309. )
  310. # === Follow-Up ===
  311. def request_follow_up(
  312. self,
  313. session_id: str,
  314. question: str,
  315. audio_data: bytes | None = None,
  316. ) -> bool:
  317. """
  318. Stellt eine Rückfrage in einer Session.
  319. Args:
  320. session_id: Session-ID
  321. question: Rückfrage-Text
  322. audio_data: TTS-Audio (optional)
  323. Returns:
  324. True wenn Rückfrage gestellt
  325. """
  326. session = self.get_session(session_id)
  327. if not session or not session.is_active:
  328. return False
  329. # Prüfe Limit
  330. if session.follow_up_count >= session.config.max_follow_ups:
  331. self.logger.warning(
  332. f"Max Follow-Ups erreicht für Session {session_id}"
  333. )
  334. return False
  335. # Füge Follow-Up Turn hinzu
  336. session.add_assistant_turn(
  337. content=question,
  338. audio_data=audio_data,
  339. is_follow_up=True,
  340. )
  341. # Callbacks
  342. for callback in self._on_follow_up:
  343. try:
  344. callback(session, question)
  345. except Exception:
  346. pass
  347. # Event emittieren (async-safe aus sync-Kontext)
  348. self._emit_event("conversation_follow_up", {
  349. "session_id": session_id,
  350. "satellite_id": session.satellite_id,
  351. "question": question,
  352. "follow_up_count": session.follow_up_count,
  353. })
  354. self.logger.info(
  355. f"Follow-Up #{session.follow_up_count} für Session {session_id}"
  356. )
  357. return True
  358. # === Cleanup ===
  359. async def _cleanup_loop(self) -> None:
  360. """Cleanup-Loop für Timeouts und alte Sessions."""
  361. while True:
  362. try:
  363. await asyncio.sleep(self._config.cleanup_interval_seconds)
  364. await self._do_cleanup()
  365. except asyncio.CancelledError:
  366. break
  367. except Exception as e:
  368. self.logger.error(f"Cleanup-Fehler: {e}")
  369. async def _do_cleanup(self) -> None:
  370. """Führt Cleanup durch."""
  371. now = datetime.now()
  372. to_remove = []
  373. with self._lock:
  374. for session_id, session in self._sessions.items():
  375. # Prüfe Timeout für aktive Sessions
  376. if session.is_active:
  377. session.check_timeout()
  378. if session.is_completed:
  379. self._on_session_completed(session)
  380. # Entferne alte abgeschlossene Sessions
  381. elif session.completed_at:
  382. age = (now - session.completed_at).total_seconds()
  383. if age > self._config.keep_completed_seconds:
  384. to_remove.append(session_id)
  385. # Entferne Sessions außerhalb des Locks
  386. for session_id in to_remove:
  387. await self.remove_session(session_id)
  388. if to_remove:
  389. self.logger.debug(f"Cleanup: {len(to_remove)} Sessions entfernt")
  390. async def remove_session(self, session_id: str) -> bool:
  391. """
  392. Entfernt eine Session.
  393. Args:
  394. session_id: Session-ID
  395. Returns:
  396. True wenn entfernt
  397. """
  398. with self._lock:
  399. session = self._sessions.pop(session_id, None)
  400. if not session:
  401. return False
  402. # Aus Satellite-Index entfernen
  403. if session.satellite_id:
  404. sat_sessions = self._sessions_by_satellite.get(session.satellite_id, [])
  405. if session_id in sat_sessions:
  406. sat_sessions.remove(session_id)
  407. return True
  408. # === Callbacks ===
  409. def on_session_start(
  410. self,
  411. callback: Callable[[ConversationSession], None],
  412. ) -> None:
  413. """Registriert Callback für Session-Start."""
  414. self._on_session_start.append(callback)
  415. def on_session_complete(
  416. self,
  417. callback: Callable[[ConversationSession], None],
  418. ) -> None:
  419. """Registriert Callback für Session-Abschluss."""
  420. self._on_session_complete.append(callback)
  421. def on_follow_up(
  422. self,
  423. callback: Callable[[ConversationSession, str], None],
  424. ) -> None:
  425. """Registriert Callback für Rückfragen."""
  426. self._on_follow_up.append(callback)
  427. # === Stats ===
  428. def get_stats(self) -> dict:
  429. """Gibt Statistiken zurück."""
  430. with self._lock:
  431. sessions = list(self._sessions.values())
  432. active = [s for s in sessions if s.is_active]
  433. completed = [s for s in sessions if s.is_completed]
  434. return {
  435. "total_sessions": len(sessions),
  436. "active_sessions": len(active),
  437. "completed_sessions": len(completed),
  438. "satellites_with_sessions": len(self._sessions_by_satellite),
  439. "states": {
  440. state.value: sum(1 for s in sessions if s.state == state)
  441. for state in SessionState
  442. },
  443. }
  444. def get_summary(self) -> dict:
  445. """Gibt Zusammenfassung zurück."""
  446. stats = self.get_stats()
  447. with self._lock:
  448. sessions = list(self._sessions.values())
  449. # Durchschnittliche Dauer
  450. completed = [s for s in sessions if s.is_completed]
  451. avg_duration = (
  452. sum(s.duration_seconds for s in completed) / len(completed)
  453. if completed else 0.0
  454. )
  455. # Durchschnittliche Turns
  456. avg_turns = (
  457. sum(s.turn_count for s in sessions) / len(sessions)
  458. if sessions else 0.0
  459. )
  460. return {
  461. **stats,
  462. "avg_duration_seconds": avg_duration,
  463. "avg_turns": avg_turns,
  464. }