session.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532
  1. # -*- coding: utf-8 -*-
  2. """
  3. Conversation Session - Einzelne Dialog-Session.
  4. """
  5. from dataclasses import dataclass, field
  6. from datetime import datetime, timedelta
  7. from enum import Enum
  8. from typing import Any
  9. import uuid
  10. class SessionState(Enum):
  11. """Zustand einer Conversation-Session."""
  12. CREATED = "created" # Session erstellt
  13. AWAITING_INPUT = "awaiting" # Warte auf User-Input
  14. PROCESSING = "processing" # Verarbeite Input
  15. RESPONDING = "responding" # Antwort wird generiert
  16. FOLLOW_UP = "follow_up" # Rückfrage gestellt
  17. COMPLETED = "completed" # Session abgeschlossen
  18. TIMEOUT = "timeout" # Session durch Timeout beendet
  19. CANCELLED = "cancelled" # Session abgebrochen
  20. class TurnType(Enum):
  21. """Typ eines Dialog-Turns."""
  22. USER_SPEECH = "user_speech" # User-Spracheingabe
  23. USER_TEXT = "user_text" # User-Texteingabe
  24. ASSISTANT_SPEECH = "assistant_speech" # Assistenten-Sprachausgabe
  25. ASSISTANT_TEXT = "assistant_text" # Assistenten-Text
  26. FOLLOW_UP = "follow_up" # Rückfrage
  27. SYSTEM = "system" # System-Nachricht
  28. @dataclass
  29. class SessionTurn:
  30. """Ein Turn (Gesprächsbeitrag) in der Session."""
  31. turn_id: str
  32. turn_type: TurnType
  33. timestamp: datetime
  34. content: str # Text-Inhalt (transkribiert oder generiert)
  35. # Audio-Daten (optional)
  36. audio_data: bytes | None = None
  37. audio_duration_seconds: float = 0.0
  38. # Metadaten
  39. metadata: dict[str, Any] = field(default_factory=dict)
  40. # Intent/Entity-Erkennung
  41. intent: str | None = None
  42. entities: dict[str, Any] = field(default_factory=dict)
  43. confidence: float = 0.0
  44. # Für Rückfragen
  45. requires_response: bool = False
  46. response_turn_id: str | None = None
  47. def to_dict(self) -> dict:
  48. """Konvertiert zu Dictionary."""
  49. return {
  50. "turn_id": self.turn_id,
  51. "turn_type": self.turn_type.value,
  52. "timestamp": self.timestamp.isoformat(),
  53. "content": self.content,
  54. "audio_duration": self.audio_duration_seconds,
  55. "intent": self.intent,
  56. "entities": self.entities,
  57. "confidence": self.confidence,
  58. "requires_response": self.requires_response,
  59. "metadata": self.metadata,
  60. }
  61. @dataclass
  62. class SessionConfig:
  63. """Konfiguration für Conversation-Sessions."""
  64. # Timeouts
  65. input_timeout_seconds: float = 60.0 # Timeout für User-Input
  66. follow_up_timeout_seconds: float = 60.0 # Timeout für Rückfrage-Antwort
  67. session_timeout_seconds: float = 300.0 # Gesamt-Session-Timeout
  68. # Limits
  69. max_turns: int = 20 # Maximale Turns pro Session
  70. max_follow_ups: int = 5 # Maximale Rückfragen
  71. # Verhalten
  72. auto_complete_on_timeout: bool = True # Bei Timeout automatisch abschließen
  73. class ConversationSession:
  74. """
  75. Eine Conversation-Session.
  76. Repräsentiert einen kompletten Dialog von Wakeword bis Abschluss.
  77. Trackt alle Turns (User-Eingaben, Assistenten-Antworten, Rückfragen).
  78. """
  79. def __init__(
  80. self,
  81. session_id: str | None = None,
  82. satellite_id: str | None = None,
  83. wakeword_type: str | None = None,
  84. config: SessionConfig | None = None,
  85. ):
  86. """
  87. Initialisiert die Session.
  88. Args:
  89. session_id: Session-ID (wird generiert wenn None)
  90. satellite_id: ID des auslösenden Satellites
  91. wakeword_type: Typ des erkannten Wakewords
  92. config: Session-Konfiguration
  93. """
  94. self._session_id = session_id or f"conv-{uuid.uuid4().hex[:12]}"
  95. self._satellite_id = satellite_id
  96. self._wakeword_type = wakeword_type
  97. self._config = config or SessionConfig()
  98. # Timestamps
  99. self._created_at = datetime.now()
  100. self._started_at: datetime | None = None
  101. self._completed_at: datetime | None = None
  102. self._last_activity = datetime.now()
  103. # State
  104. self._state = SessionState.CREATED
  105. self._turns: list[SessionTurn] = []
  106. self._follow_up_count = 0
  107. # Kontext für diese Session
  108. self._context: dict[str, Any] = {}
  109. # Pending Follow-Up
  110. self._pending_follow_up: SessionTurn | None = None
  111. # === Properties ===
  112. @property
  113. def session_id(self) -> str:
  114. """Session-ID."""
  115. return self._session_id
  116. @property
  117. def satellite_id(self) -> str | None:
  118. """Satellite-ID."""
  119. return self._satellite_id
  120. @property
  121. def wakeword_type(self) -> str | None:
  122. """Wakeword-Typ."""
  123. return self._wakeword_type
  124. @property
  125. def config(self) -> SessionConfig:
  126. """Konfiguration."""
  127. return self._config
  128. @property
  129. def state(self) -> SessionState:
  130. """Aktueller Zustand."""
  131. return self._state
  132. @property
  133. def is_active(self) -> bool:
  134. """Prüft ob Session aktiv ist."""
  135. return self._state in (
  136. SessionState.CREATED,
  137. SessionState.AWAITING_INPUT,
  138. SessionState.PROCESSING,
  139. SessionState.RESPONDING,
  140. SessionState.FOLLOW_UP,
  141. )
  142. @property
  143. def is_completed(self) -> bool:
  144. """Prüft ob Session abgeschlossen ist."""
  145. return self._state in (
  146. SessionState.COMPLETED,
  147. SessionState.TIMEOUT,
  148. SessionState.CANCELLED,
  149. )
  150. @property
  151. def created_at(self) -> datetime:
  152. """Erstellungszeit."""
  153. return self._created_at
  154. @property
  155. def started_at(self) -> datetime | None:
  156. """Startzeit (erster User-Turn)."""
  157. return self._started_at
  158. @property
  159. def completed_at(self) -> datetime | None:
  160. """Abschlusszeit."""
  161. return self._completed_at
  162. @property
  163. def last_activity(self) -> datetime:
  164. """Letzte Aktivität."""
  165. return self._last_activity
  166. @property
  167. def duration_seconds(self) -> float:
  168. """Session-Dauer in Sekunden."""
  169. end = self._completed_at or datetime.now()
  170. return (end - self._created_at).total_seconds()
  171. @property
  172. def turns(self) -> list[SessionTurn]:
  173. """Alle Turns."""
  174. return list(self._turns)
  175. @property
  176. def turn_count(self) -> int:
  177. """Anzahl Turns."""
  178. return len(self._turns)
  179. @property
  180. def user_turns(self) -> list[SessionTurn]:
  181. """Nur User-Turns."""
  182. return [t for t in self._turns if t.turn_type in (
  183. TurnType.USER_SPEECH, TurnType.USER_TEXT
  184. )]
  185. @property
  186. def assistant_turns(self) -> list[SessionTurn]:
  187. """Nur Assistenten-Turns."""
  188. return [t for t in self._turns if t.turn_type in (
  189. TurnType.ASSISTANT_SPEECH, TurnType.ASSISTANT_TEXT, TurnType.FOLLOW_UP
  190. )]
  191. @property
  192. def follow_up_count(self) -> int:
  193. """Anzahl Rückfragen."""
  194. return self._follow_up_count
  195. @property
  196. def has_pending_follow_up(self) -> bool:
  197. """Prüft ob Rückfrage aussteht."""
  198. return self._pending_follow_up is not None
  199. @property
  200. def context(self) -> dict[str, Any]:
  201. """Session-Kontext."""
  202. return self._context
  203. # === Turn Management ===
  204. def add_user_turn(
  205. self,
  206. content: str,
  207. audio_data: bytes | None = None,
  208. audio_duration: float = 0.0,
  209. intent: str | None = None,
  210. entities: dict | None = None,
  211. confidence: float = 0.0,
  212. metadata: dict | None = None,
  213. ) -> SessionTurn:
  214. """
  215. Fügt User-Turn hinzu.
  216. Args:
  217. content: Transkribierter Text
  218. audio_data: Original-Audio (optional)
  219. audio_duration: Audio-Dauer
  220. intent: Erkannter Intent
  221. entities: Extrahierte Entities
  222. confidence: Erkennungs-Confidence
  223. metadata: Zusätzliche Metadaten
  224. Returns:
  225. Erstellter Turn
  226. """
  227. self._check_can_add_turn()
  228. turn = SessionTurn(
  229. turn_id=self._generate_turn_id(),
  230. turn_type=TurnType.USER_SPEECH if audio_data else TurnType.USER_TEXT,
  231. timestamp=datetime.now(),
  232. content=content,
  233. audio_data=audio_data,
  234. audio_duration_seconds=audio_duration,
  235. intent=intent,
  236. entities=entities or {},
  237. confidence=confidence,
  238. metadata=metadata or {},
  239. )
  240. self._add_turn(turn)
  241. # Erster User-Turn startet die Session
  242. if self._started_at is None:
  243. self._started_at = turn.timestamp
  244. # Wenn Follow-Up beantwortet wurde
  245. if self._pending_follow_up:
  246. self._pending_follow_up.response_turn_id = turn.turn_id
  247. self._pending_follow_up = None
  248. self._state = SessionState.PROCESSING
  249. return turn
  250. def add_assistant_turn(
  251. self,
  252. content: str,
  253. audio_data: bytes | None = None,
  254. audio_duration: float = 0.0,
  255. is_follow_up: bool = False,
  256. metadata: dict | None = None,
  257. ) -> SessionTurn:
  258. """
  259. Fügt Assistenten-Turn hinzu.
  260. Args:
  261. content: Antwort-Text
  262. audio_data: Generiertes Audio (optional)
  263. audio_duration: Audio-Dauer
  264. is_follow_up: Ist dies eine Rückfrage?
  265. metadata: Zusätzliche Metadaten
  266. Returns:
  267. Erstellter Turn
  268. """
  269. turn_type = TurnType.FOLLOW_UP if is_follow_up else TurnType.ASSISTANT_SPEECH
  270. turn = SessionTurn(
  271. turn_id=self._generate_turn_id(),
  272. turn_type=turn_type,
  273. timestamp=datetime.now(),
  274. content=content,
  275. audio_data=audio_data,
  276. audio_duration_seconds=audio_duration,
  277. metadata=metadata or {},
  278. requires_response=is_follow_up,
  279. )
  280. self._add_turn(turn)
  281. if is_follow_up:
  282. self._follow_up_count += 1
  283. self._pending_follow_up = turn
  284. self._state = SessionState.FOLLOW_UP
  285. else:
  286. self._state = SessionState.RESPONDING
  287. return turn
  288. def add_system_turn(
  289. self,
  290. content: str,
  291. metadata: dict | None = None,
  292. ) -> SessionTurn:
  293. """
  294. Fügt System-Turn hinzu.
  295. Args:
  296. content: System-Nachricht
  297. metadata: Zusätzliche Metadaten
  298. Returns:
  299. Erstellter Turn
  300. """
  301. turn = SessionTurn(
  302. turn_id=self._generate_turn_id(),
  303. turn_type=TurnType.SYSTEM,
  304. timestamp=datetime.now(),
  305. content=content,
  306. metadata=metadata or {},
  307. )
  308. self._add_turn(turn)
  309. return turn
  310. def _add_turn(self, turn: SessionTurn) -> None:
  311. """Interne Methode zum Hinzufügen eines Turns."""
  312. self._turns.append(turn)
  313. self._last_activity = turn.timestamp
  314. def _check_can_add_turn(self) -> None:
  315. """Prüft ob Turn hinzugefügt werden kann."""
  316. if self.is_completed:
  317. raise RuntimeError(f"Session {self._session_id} ist bereits abgeschlossen")
  318. if len(self._turns) >= self._config.max_turns:
  319. raise RuntimeError(f"Maximale Turns ({self._config.max_turns}) erreicht")
  320. def _generate_turn_id(self) -> str:
  321. """Generiert Turn-ID."""
  322. return f"{self._session_id}-t{len(self._turns) + 1}"
  323. # === Lifecycle ===
  324. def start(self) -> None:
  325. """Startet die Session."""
  326. if self._state != SessionState.CREATED:
  327. return
  328. self._started_at = datetime.now()
  329. self._state = SessionState.AWAITING_INPUT
  330. def complete(self, reason: str | None = None) -> None:
  331. """
  332. Schließt die Session ab.
  333. Args:
  334. reason: Abschlussgrund (optional)
  335. """
  336. if self.is_completed:
  337. return
  338. self._state = SessionState.COMPLETED
  339. self._completed_at = datetime.now()
  340. if reason:
  341. self.add_system_turn(f"Session beendet: {reason}")
  342. def timeout(self) -> None:
  343. """Beendet Session wegen Timeout."""
  344. if self.is_completed:
  345. return
  346. self._state = SessionState.TIMEOUT
  347. self._completed_at = datetime.now()
  348. self.add_system_turn("Session-Timeout")
  349. def cancel(self, reason: str | None = None) -> None:
  350. """
  351. Bricht Session ab.
  352. Args:
  353. reason: Abbruchgrund
  354. """
  355. if self.is_completed:
  356. return
  357. self._state = SessionState.CANCELLED
  358. self._completed_at = datetime.now()
  359. if reason:
  360. self.add_system_turn(f"Session abgebrochen: {reason}")
  361. def check_timeout(self) -> bool:
  362. """
  363. Prüft ob Session getimeoutet ist.
  364. Returns:
  365. True wenn Timeout erreicht
  366. """
  367. now = datetime.now()
  368. # Gesamt-Timeout
  369. if self.duration_seconds >= self._config.session_timeout_seconds:
  370. if self._config.auto_complete_on_timeout:
  371. self.timeout()
  372. return True
  373. # Input-Timeout (wenn auf Input gewartet wird)
  374. if self._state == SessionState.AWAITING_INPUT:
  375. idle_seconds = (now - self._last_activity).total_seconds()
  376. if idle_seconds >= self._config.input_timeout_seconds:
  377. if self._config.auto_complete_on_timeout:
  378. self.timeout()
  379. return True
  380. # Follow-Up-Timeout
  381. if self._state == SessionState.FOLLOW_UP:
  382. idle_seconds = (now - self._last_activity).total_seconds()
  383. if idle_seconds >= self._config.follow_up_timeout_seconds:
  384. if self._config.auto_complete_on_timeout:
  385. self.timeout()
  386. return True
  387. return False
  388. # === Context ===
  389. def set_context(self, key: str, value: Any) -> None:
  390. """Setzt Kontext-Variable."""
  391. self._context[key] = value
  392. def get_context(self, key: str, default: Any = None) -> Any:
  393. """Gibt Kontext-Variable zurück."""
  394. return self._context.get(key, default)
  395. def clear_context(self) -> None:
  396. """Löscht Kontext."""
  397. self._context.clear()
  398. # === Serialization ===
  399. def to_dict(self) -> dict:
  400. """Konvertiert zu Dictionary."""
  401. return {
  402. "session_id": self._session_id,
  403. "satellite_id": self._satellite_id,
  404. "wakeword_type": self._wakeword_type,
  405. "state": self._state.value,
  406. "created_at": self._created_at.isoformat(),
  407. "started_at": self._started_at.isoformat() if self._started_at else None,
  408. "completed_at": self._completed_at.isoformat() if self._completed_at else None,
  409. "duration_seconds": self.duration_seconds,
  410. "turn_count": len(self._turns),
  411. "follow_up_count": self._follow_up_count,
  412. "turns": [t.to_dict() for t in self._turns],
  413. "context": self._context,
  414. }
  415. def get_transcript(self) -> str:
  416. """
  417. Gibt Transkript der Session zurück.
  418. Returns:
  419. Formatiertes Transkript
  420. """
  421. lines = []
  422. for turn in self._turns:
  423. if turn.turn_type == TurnType.SYSTEM:
  424. lines.append(f"[System] {turn.content}")
  425. elif turn.turn_type in (TurnType.USER_SPEECH, TurnType.USER_TEXT):
  426. lines.append(f"User: {turn.content}")
  427. elif turn.turn_type == TurnType.FOLLOW_UP:
  428. lines.append(f"Assistent (Rückfrage): {turn.content}")
  429. else:
  430. lines.append(f"Assistent: {turn.content}")
  431. return "\n".join(lines)