conversation_session.py 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927
  1. """
  2. Conversation Session Management
  3. This module implements individual conversation session tracking for the Trixy
  4. voice assistant system. Each ConversationSession represents a single multi-turn
  5. conversation between a user and the system, maintaining conversation history,
  6. state, and metadata.
  7. Key Features:
  8. - Individual conversation session tracking
  9. - Turn-based conversation history
  10. - Speaker and satellite information tracking
  11. - Conversation timing and metrics
  12. - Thread-safe operations
  13. - Conversation state transitions
  14. - Context preservation across turns
  15. - Plugin interaction support
  16. Classes:
  17. - ConversationSession: Main session class
  18. - ConversationTurn: Individual conversation turn
  19. - ConversationRole: Speaker role enumeration
  20. - ConversationSessionState: Session state enumeration
  21. """
  22. import json
  23. import threading
  24. import time
  25. import uuid
  26. from datetime import datetime, timezone, timedelta
  27. from dataclasses import dataclass, field
  28. from enum import Enum
  29. from typing import Any, Dict, List, Optional, Tuple, Union, Callable
  30. from pathlib import Path
  31. import weakref
  32. from ..events.event_data import SatelliteInfo, SpeakerInfo
  33. def pprint(message: str) -> None:
  34. """Conversation session logging function."""
  35. print(f"[CONVERSATION_SESSION] {message}")
  36. class ConversationRole(Enum):
  37. """Roles in a conversation."""
  38. USER = "user"
  39. ASSISTANT = "assistant"
  40. SYSTEM = "system"
  41. PLUGIN = "plugin"
  42. class ConversationSessionState(Enum):
  43. """States of a conversation session."""
  44. INITIALIZING = "initializing"
  45. ACTIVE = "active"
  46. WAITING_FOR_INPUT = "waiting_for_input"
  47. PROCESSING = "processing"
  48. WAITING_FOR_PLUGIN = "waiting_for_plugin"
  49. COMPLETED = "completed"
  50. TIMED_OUT = "timed_out"
  51. ERROR = "error"
  52. CANCELLED = "cancelled"
  53. @dataclass
  54. class ConversationTurn:
  55. """
  56. Represents a single turn in a conversation.
  57. A turn consists of input from a user and optional response from the system.
  58. This tracks the complete interaction cycle for analytics and context.
  59. """
  60. turn_id: str = field(default_factory=lambda: str(uuid.uuid4()))
  61. turn_number: int = 0
  62. role: ConversationRole = ConversationRole.USER
  63. timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
  64. # Input data
  65. input_type: str = "" # "audio", "text", "intent", "wakeword"
  66. input_text: Optional[str] = None
  67. input_audio_data: Optional[bytes] = None
  68. input_confidence: float = 0.0
  69. # Processing data
  70. intent: Optional[str] = None
  71. entities: Dict[str, Any] = field(default_factory=dict)
  72. processing_time_ms: float = 0.0
  73. # Response data
  74. response_text: Optional[str] = None
  75. response_audio_data: Optional[bytes] = None
  76. response_voice_settings: Dict[str, Any] = field(default_factory=dict)
  77. # Metadata
  78. speaker_info: Optional[SpeakerInfo] = None
  79. satellite_info: Optional[SatelliteInfo] = None
  80. plugin_name: Optional[str] = None
  81. error_message: Optional[str] = None
  82. metadata: Dict[str, Any] = field(default_factory=dict)
  83. def to_dict(self) -> Dict[str, Any]:
  84. """Convert turn to dictionary representation."""
  85. return {
  86. "turn_id": self.turn_id,
  87. "turn_number": self.turn_number,
  88. "role": self.role.value,
  89. "timestamp": self.timestamp.isoformat(),
  90. "input_type": self.input_type,
  91. "input_text": self.input_text,
  92. "input_confidence": self.input_confidence,
  93. "intent": self.intent,
  94. "entities": self.entities,
  95. "processing_time_ms": self.processing_time_ms,
  96. "response_text": self.response_text,
  97. "response_voice_settings": self.response_voice_settings,
  98. "speaker_info": self.speaker_info.to_dict() if self.speaker_info else None,
  99. "satellite_info": self.satellite_info.to_dict() if self.satellite_info else None,
  100. "plugin_name": self.plugin_name,
  101. "error_message": self.error_message,
  102. "metadata": self.metadata
  103. }
  104. @classmethod
  105. def from_dict(cls, data: Dict[str, Any]) -> 'ConversationTurn':
  106. """Create ConversationTurn from dictionary."""
  107. # Parse datetime
  108. timestamp = datetime.fromisoformat(data.get("timestamp", datetime.now(timezone.utc).isoformat()))
  109. # Parse speaker info
  110. speaker_info = None
  111. if data.get("speaker_info"):
  112. speaker_info = SpeakerInfo(**data["speaker_info"])
  113. # Parse satellite info
  114. satellite_info = None
  115. if data.get("satellite_info"):
  116. satellite_info = SatelliteInfo(**data["satellite_info"])
  117. return cls(
  118. turn_id=data.get("turn_id", str(uuid.uuid4())),
  119. turn_number=data.get("turn_number", 0),
  120. role=ConversationRole(data.get("role", "user")),
  121. timestamp=timestamp,
  122. input_type=data.get("input_type", ""),
  123. input_text=data.get("input_text"),
  124. input_confidence=data.get("input_confidence", 0.0),
  125. intent=data.get("intent"),
  126. entities=data.get("entities", {}),
  127. processing_time_ms=data.get("processing_time_ms", 0.0),
  128. response_text=data.get("response_text"),
  129. response_voice_settings=data.get("response_voice_settings", {}),
  130. speaker_info=speaker_info,
  131. satellite_info=satellite_info,
  132. plugin_name=data.get("plugin_name"),
  133. error_message=data.get("error_message"),
  134. metadata=data.get("metadata", {})
  135. )
  136. class ConversationSessionError(Exception):
  137. """Base exception for conversation session errors."""
  138. pass
  139. class ConversationSession:
  140. """
  141. Represents an individual conversation session between a user and the Trixy system.
  142. A conversation session tracks:
  143. - Conversation metadata (ID, participants, timing)
  144. - Turn-by-turn conversation history
  145. - Current conversation state and context
  146. - Plugin interactions and multi-turn workflows
  147. - Performance metrics and analytics
  148. The session supports the conversation workflow specified in CLAUDE.md:
  149. 1. Wakeword detection starts conversation session
  150. 2. Raw audio input with conversation_id
  151. 3. Text processing with same conversation_id
  152. 4. Intent handling with conversation tracking
  153. 5. Multi-turn conversations where plugins ask questions and wait for responses
  154. Thread-safe for multi-satellite environments.
  155. """
  156. def __init__(
  157. self,
  158. conversation_id: str,
  159. satellite_info: SatelliteInfo,
  160. speaker_info: Optional[SpeakerInfo] = None,
  161. trigger_event: str = "wakeword_received",
  162. timeout_seconds: float = 300.0, # 5 minutes default
  163. max_turns: int = 50,
  164. application_ref: Optional[weakref.ref] = None
  165. ):
  166. """
  167. Initialize a conversation session.
  168. Args:
  169. conversation_id: Unique conversation identifier
  170. satellite_info: Information about the satellite device
  171. speaker_info: Information about the detected speaker
  172. trigger_event: Event that triggered the conversation
  173. timeout_seconds: Conversation timeout in seconds
  174. max_turns: Maximum number of conversation turns
  175. application_ref: Weak reference to application container
  176. """
  177. # Core identifiers
  178. self._conversation_id = conversation_id
  179. self._trigger_event = trigger_event
  180. # Participant information
  181. self._satellite_info = satellite_info
  182. self._speaker_info = speaker_info
  183. # Timing and limits
  184. self._start_time = datetime.now(timezone.utc)
  185. self._last_activity = self._start_time
  186. self._end_time: Optional[datetime] = None
  187. self._timeout_seconds = timeout_seconds
  188. self._max_turns = max_turns
  189. # State management
  190. self._state = ConversationSessionState.INITIALIZING
  191. self._state_lock = threading.RLock()
  192. # Conversation history
  193. self._turns: List[ConversationTurn] = []
  194. self._turns_lock = threading.RLock()
  195. self._current_turn_number = 0
  196. # Context and metadata
  197. self._context_data: Dict[str, Any] = {}
  198. self._metadata: Dict[str, Any] = {
  199. "created_at": self._start_time.isoformat(),
  200. "trigger_event": trigger_event,
  201. "version": "1.0.0"
  202. }
  203. # Plugin interaction
  204. self._waiting_for_plugin: Optional[str] = None
  205. self._plugin_question: Optional[str] = None
  206. self._plugin_callback: Optional[Callable] = None
  207. self._plugin_timeout: Optional[datetime] = None
  208. # Application integration
  209. self._application_ref = application_ref
  210. # Analytics
  211. self._total_processing_time = 0.0
  212. self._error_count = 0
  213. self._successful_turns = 0
  214. pprint(f"Conversation session created: {conversation_id}")
  215. self._set_state(ConversationSessionState.ACTIVE)
  216. # Properties
  217. @property
  218. def conversation_id(self) -> str:
  219. """Get the conversation ID."""
  220. return self._conversation_id
  221. @property
  222. def state(self) -> ConversationSessionState:
  223. """Get the current conversation state."""
  224. with self._state_lock:
  225. return self._state
  226. @property
  227. def satellite_info(self) -> SatelliteInfo:
  228. """Get satellite information."""
  229. return self._satellite_info
  230. @property
  231. def speaker_info(self) -> Optional[SpeakerInfo]:
  232. """Get speaker information."""
  233. return self._speaker_info
  234. @property
  235. def start_time(self) -> datetime:
  236. """Get conversation start time."""
  237. return self._start_time
  238. @property
  239. def last_activity(self) -> datetime:
  240. """Get last activity time."""
  241. return self._last_activity
  242. @property
  243. def duration_seconds(self) -> float:
  244. """Get conversation duration in seconds."""
  245. end_time = self._end_time or datetime.now(timezone.utc)
  246. return (end_time - self._start_time).total_seconds()
  247. @property
  248. def turn_count(self) -> int:
  249. """Get number of conversation turns."""
  250. with self._turns_lock:
  251. return len(self._turns)
  252. @property
  253. def is_active(self) -> bool:
  254. """Check if conversation is active."""
  255. with self._state_lock:
  256. return self._state in [
  257. ConversationSessionState.ACTIVE,
  258. ConversationSessionState.WAITING_FOR_INPUT,
  259. ConversationSessionState.PROCESSING,
  260. ConversationSessionState.WAITING_FOR_PLUGIN
  261. ]
  262. @property
  263. def is_timed_out(self) -> bool:
  264. """Check if conversation has timed out."""
  265. if not self.is_active:
  266. return False
  267. now = datetime.now(timezone.utc)
  268. # Check overall timeout
  269. if (now - self._start_time).total_seconds() > self._timeout_seconds:
  270. return True
  271. # Check plugin timeout
  272. if (self._plugin_timeout and
  273. self._state == ConversationSessionState.WAITING_FOR_PLUGIN and
  274. now > self._plugin_timeout):
  275. return True
  276. return False
  277. # State Management
  278. def _set_state(self, new_state: ConversationSessionState) -> None:
  279. """Set conversation state thread-safely."""
  280. with self._state_lock:
  281. old_state = self._state
  282. self._state = new_state
  283. self._last_activity = datetime.now(timezone.utc)
  284. if old_state != new_state:
  285. pprint(f"Conversation {self._conversation_id} state: {old_state.value} -> {new_state.value}")
  286. # Handle state transitions
  287. if new_state in [ConversationSessionState.COMPLETED,
  288. ConversationSessionState.TIMED_OUT,
  289. ConversationSessionState.ERROR,
  290. ConversationSessionState.CANCELLED]:
  291. self._end_time = self._last_activity
  292. def _update_activity(self) -> None:
  293. """Update last activity timestamp."""
  294. self._last_activity = datetime.now(timezone.utc)
  295. # Turn Management
  296. def add_turn(
  297. self,
  298. role: ConversationRole,
  299. input_type: str = "",
  300. input_text: Optional[str] = None,
  301. input_audio_data: Optional[bytes] = None,
  302. input_confidence: float = 0.0,
  303. speaker_info: Optional[SpeakerInfo] = None,
  304. satellite_info: Optional[SatelliteInfo] = None,
  305. metadata: Optional[Dict[str, Any]] = None
  306. ) -> ConversationTurn:
  307. """
  308. Add a new turn to the conversation.
  309. Args:
  310. role: Role of the speaker (user, assistant, plugin)
  311. input_type: Type of input (audio, text, intent, wakeword)
  312. input_text: Text content of the input
  313. input_audio_data: Audio data if applicable
  314. input_confidence: Confidence score for the input
  315. speaker_info: Speaker information (uses session default if None)
  316. satellite_info: Satellite information (uses session default if None)
  317. metadata: Additional metadata for the turn
  318. Returns:
  319. ConversationTurn: The created turn
  320. Raises:
  321. ConversationSessionError: If session is not in valid state or limits exceeded
  322. """
  323. if not self.is_active:
  324. raise ConversationSessionError(f"Cannot add turn to conversation in state: {self._state.value}")
  325. with self._turns_lock:
  326. if len(self._turns) >= self._max_turns:
  327. raise ConversationSessionError(f"Maximum turns ({self._max_turns}) exceeded")
  328. # Increment turn number
  329. self._current_turn_number += 1
  330. # Create turn
  331. turn = ConversationTurn(
  332. turn_number=self._current_turn_number,
  333. role=role,
  334. input_type=input_type,
  335. input_text=input_text,
  336. input_audio_data=input_audio_data,
  337. input_confidence=input_confidence,
  338. speaker_info=speaker_info or self._speaker_info,
  339. satellite_info=satellite_info or self._satellite_info,
  340. metadata=metadata or {}
  341. )
  342. # Add to history
  343. self._turns.append(turn)
  344. self._update_activity()
  345. pprint(f"Added turn {self._current_turn_number} to conversation {self._conversation_id}")
  346. return turn
  347. def get_current_turn(self) -> Optional[ConversationTurn]:
  348. """Get the current (most recent) turn."""
  349. with self._turns_lock:
  350. return self._turns[-1] if self._turns else None
  351. def get_turn_by_number(self, turn_number: int) -> Optional[ConversationTurn]:
  352. """Get a turn by its number."""
  353. with self._turns_lock:
  354. for turn in self._turns:
  355. if turn.turn_number == turn_number:
  356. return turn
  357. return None
  358. def get_turns(self, role: Optional[ConversationRole] = None) -> List[ConversationTurn]:
  359. """
  360. Get conversation turns, optionally filtered by role.
  361. Args:
  362. role: Optional role filter
  363. Returns:
  364. List of conversation turns
  365. """
  366. with self._turns_lock:
  367. if role is None:
  368. return self._turns.copy()
  369. return [turn for turn in self._turns if turn.role == role]
  370. def update_turn_response(
  371. self,
  372. turn_id: str,
  373. response_text: Optional[str] = None,
  374. response_audio_data: Optional[bytes] = None,
  375. response_voice_settings: Optional[Dict[str, Any]] = None,
  376. processing_time_ms: float = 0.0,
  377. plugin_name: Optional[str] = None
  378. ) -> bool:
  379. """
  380. Update a turn with response information.
  381. Args:
  382. turn_id: ID of the turn to update
  383. response_text: Response text
  384. response_audio_data: Response audio data
  385. response_voice_settings: Voice settings used
  386. processing_time_ms: Processing time in milliseconds
  387. plugin_name: Name of plugin that generated response
  388. Returns:
  389. bool: True if turn was found and updated
  390. """
  391. with self._turns_lock:
  392. for turn in self._turns:
  393. if turn.turn_id == turn_id:
  394. if response_text is not None:
  395. turn.response_text = response_text
  396. if response_audio_data is not None:
  397. turn.response_audio_data = response_audio_data
  398. if response_voice_settings is not None:
  399. turn.response_voice_settings = response_voice_settings
  400. if processing_time_ms > 0:
  401. turn.processing_time_ms = processing_time_ms
  402. self._total_processing_time += processing_time_ms
  403. if plugin_name is not None:
  404. turn.plugin_name = plugin_name
  405. self._update_activity()
  406. return True
  407. return False
  408. def update_turn_intent(
  409. self,
  410. turn_id: str,
  411. intent: str,
  412. entities: Optional[Dict[str, Any]] = None,
  413. confidence: float = 0.0
  414. ) -> bool:
  415. """
  416. Update a turn with intent information.
  417. Args:
  418. turn_id: ID of the turn to update
  419. intent: Detected intent
  420. entities: Extracted entities
  421. confidence: Intent confidence score
  422. Returns:
  423. bool: True if turn was found and updated
  424. """
  425. with self._turns_lock:
  426. for turn in self._turns:
  427. if turn.turn_id == turn_id:
  428. turn.intent = intent
  429. turn.entities = entities or {}
  430. turn.input_confidence = max(turn.input_confidence, confidence)
  431. self._update_activity()
  432. return True
  433. return False
  434. def mark_turn_error(self, turn_id: str, error_message: str) -> bool:
  435. """
  436. Mark a turn as having an error.
  437. Args:
  438. turn_id: ID of the turn to update
  439. error_message: Error description
  440. Returns:
  441. bool: True if turn was found and updated
  442. """
  443. with self._turns_lock:
  444. for turn in self._turns:
  445. if turn.turn_id == turn_id:
  446. turn.error_message = error_message
  447. self._error_count += 1
  448. self._update_activity()
  449. return True
  450. return False
  451. # Plugin Interaction
  452. def ask_question(
  453. self,
  454. question: str,
  455. plugin_name: str,
  456. callback: Optional[Callable] = None,
  457. timeout_seconds: float = 30.0,
  458. voice_settings: Optional[Dict[str, Any]] = None
  459. ) -> ConversationTurn:
  460. """
  461. Ask a question and wait for user response (plugin interaction).
  462. Args:
  463. question: Question to ask the user
  464. plugin_name: Name of the plugin asking the question
  465. callback: Optional callback function for the response
  466. timeout_seconds: Timeout for the response
  467. voice_settings: Voice settings for TTS
  468. Returns:
  469. ConversationTurn: The question turn
  470. Raises:
  471. ConversationSessionError: If session is not in valid state
  472. """
  473. if not self.is_active:
  474. raise ConversationSessionError(f"Cannot ask question in state: {self._state.value}")
  475. # Add question turn
  476. turn = self.add_turn(
  477. role=ConversationRole.PLUGIN,
  478. input_type="question",
  479. input_text=question,
  480. metadata={
  481. "plugin_name": plugin_name,
  482. "is_question": True,
  483. "timeout_seconds": timeout_seconds
  484. }
  485. )
  486. # Update response info
  487. self.update_turn_response(
  488. turn.turn_id,
  489. response_text=question,
  490. response_voice_settings=voice_settings or {},
  491. plugin_name=plugin_name
  492. )
  493. # Set waiting state
  494. self._waiting_for_plugin = plugin_name
  495. self._plugin_question = question
  496. self._plugin_callback = callback
  497. self._plugin_timeout = datetime.now(timezone.utc) + timedelta(seconds=timeout_seconds)
  498. self._set_state(ConversationSessionState.WAITING_FOR_PLUGIN)
  499. pprint(f"Plugin {plugin_name} asked question in conversation {self._conversation_id}: {question}")
  500. return turn
  501. def answer_question(
  502. self,
  503. answer: str,
  504. confidence: float = 1.0,
  505. speaker_info: Optional[SpeakerInfo] = None
  506. ) -> Optional[ConversationTurn]:
  507. """
  508. Provide an answer to a plugin question.
  509. Args:
  510. answer: User's answer
  511. confidence: Confidence in the answer
  512. speaker_info: Speaker information
  513. Returns:
  514. ConversationTurn: The answer turn, or None if not waiting for answer
  515. """
  516. if self._state != ConversationSessionState.WAITING_FOR_PLUGIN:
  517. pprint(f"Not waiting for plugin response in conversation {self._conversation_id}")
  518. return None
  519. # Add answer turn
  520. turn = self.add_turn(
  521. role=ConversationRole.USER,
  522. input_type="answer",
  523. input_text=answer,
  524. input_confidence=confidence,
  525. speaker_info=speaker_info,
  526. metadata={
  527. "answering_plugin": self._waiting_for_plugin,
  528. "original_question": self._plugin_question,
  529. "is_answer": True
  530. }
  531. )
  532. # Call plugin callback if available
  533. if self._plugin_callback:
  534. try:
  535. self._plugin_callback(self._conversation_id, answer, turn)
  536. except Exception as e:
  537. pprint(f"Error in plugin callback: {e}")
  538. self.mark_turn_error(turn.turn_id, f"Plugin callback error: {e}")
  539. # Clear plugin state
  540. self._waiting_for_plugin = None
  541. self._plugin_question = None
  542. self._plugin_callback = None
  543. self._plugin_timeout = None
  544. self._set_state(ConversationSessionState.ACTIVE)
  545. pprint(f"Received answer in conversation {self._conversation_id}: {answer}")
  546. return turn
  547. def cancel_plugin_question(self, reason: str = "timeout") -> None:
  548. """
  549. Cancel a pending plugin question.
  550. Args:
  551. reason: Reason for cancellation
  552. """
  553. if self._state == ConversationSessionState.WAITING_FOR_PLUGIN:
  554. pprint(f"Cancelling plugin question in conversation {self._conversation_id}: {reason}")
  555. # Clear plugin state
  556. self._waiting_for_plugin = None
  557. self._plugin_question = None
  558. self._plugin_callback = None
  559. self._plugin_timeout = None
  560. if reason == "timeout":
  561. self._set_state(ConversationSessionState.TIMED_OUT)
  562. else:
  563. self._set_state(ConversationSessionState.ACTIVE)
  564. # Context Management
  565. def set_context(self, key: str, value: Any) -> None:
  566. """Set a context value."""
  567. self._context_data[key] = value
  568. self._update_activity()
  569. def get_context(self, key: str, default: Any = None) -> Any:
  570. """Get a context value."""
  571. return self._context_data.get(key, default)
  572. def update_context(self, context_data: Dict[str, Any]) -> None:
  573. """Update multiple context values."""
  574. self._context_data.update(context_data)
  575. self._update_activity()
  576. def clear_context(self) -> None:
  577. """Clear all context data."""
  578. self._context_data.clear()
  579. self._update_activity()
  580. def get_all_context(self) -> Dict[str, Any]:
  581. """Get all context data."""
  582. return self._context_data.copy()
  583. # Session Control
  584. def complete(self, final_response: Optional[str] = None) -> None:
  585. """
  586. Complete the conversation session.
  587. Args:
  588. final_response: Optional final response to the user
  589. """
  590. if final_response:
  591. self.add_turn(
  592. role=ConversationRole.ASSISTANT,
  593. input_type="completion",
  594. input_text=final_response,
  595. metadata={"is_final_response": True}
  596. )
  597. self._successful_turns = len([t for t in self._turns if not t.error_message])
  598. self._set_state(ConversationSessionState.COMPLETED)
  599. pprint(f"Conversation {self._conversation_id} completed with {self.turn_count} turns")
  600. def cancel(self, reason: str = "user_cancelled") -> None:
  601. """
  602. Cancel the conversation session.
  603. Args:
  604. reason: Reason for cancellation
  605. """
  606. self._metadata["cancellation_reason"] = reason
  607. self._set_state(ConversationSessionState.CANCELLED)
  608. pprint(f"Conversation {self._conversation_id} cancelled: {reason}")
  609. def timeout(self) -> None:
  610. """Mark conversation as timed out."""
  611. self._set_state(ConversationSessionState.TIMED_OUT)
  612. pprint(f"Conversation {self._conversation_id} timed out after {self.duration_seconds:.1f}s")
  613. def error(self, error_message: str) -> None:
  614. """
  615. Mark conversation as having an error.
  616. Args:
  617. error_message: Error description
  618. """
  619. self._metadata["error_message"] = error_message
  620. self._error_count += 1
  621. self._set_state(ConversationSessionState.ERROR)
  622. pprint(f"Conversation {self._conversation_id} error: {error_message}")
  623. # Analytics and Export
  624. def get_analytics(self) -> Dict[str, Any]:
  625. """
  626. Get conversation analytics and metrics.
  627. Returns:
  628. Dictionary containing analytics data
  629. """
  630. return {
  631. "conversation_id": self._conversation_id,
  632. "duration_seconds": self.duration_seconds,
  633. "turn_count": self.turn_count,
  634. "successful_turns": self._successful_turns,
  635. "error_count": self._error_count,
  636. "total_processing_time_ms": self._total_processing_time,
  637. "average_processing_time_ms": (
  638. self._total_processing_time / max(1, self.turn_count)
  639. ),
  640. "state": self._state.value,
  641. "trigger_event": self._trigger_event,
  642. "speaker_id": self._speaker_info.speaker_id if self._speaker_info else None,
  643. "satellite_id": self._satellite_info.satellite_id,
  644. "satellite_room": self._satellite_info.room_id,
  645. "start_time": self._start_time.isoformat(),
  646. "end_time": self._end_time.isoformat() if self._end_time else None,
  647. "last_activity": self._last_activity.isoformat(),
  648. "context_keys": list(self._context_data.keys()),
  649. "metadata": self._metadata.copy()
  650. }
  651. def to_dict(self) -> Dict[str, Any]:
  652. """
  653. Convert conversation session to dictionary representation.
  654. Returns:
  655. Dictionary containing complete session data
  656. """
  657. with self._turns_lock:
  658. return {
  659. "conversation_id": self._conversation_id,
  660. "state": self._state.value,
  661. "trigger_event": self._trigger_event,
  662. "start_time": self._start_time.isoformat(),
  663. "end_time": self._end_time.isoformat() if self._end_time else None,
  664. "last_activity": self._last_activity.isoformat(),
  665. "timeout_seconds": self._timeout_seconds,
  666. "max_turns": self._max_turns,
  667. "speaker_info": self._speaker_info.to_dict() if self._speaker_info else None,
  668. "satellite_info": self._satellite_info.to_dict(),
  669. "turns": [turn.to_dict() for turn in self._turns],
  670. "context_data": self._context_data.copy(),
  671. "metadata": self._metadata.copy(),
  672. "analytics": self.get_analytics()
  673. }
  674. def save_to_file(self, file_path: Path) -> None:
  675. """
  676. Save conversation session to JSON file.
  677. Args:
  678. file_path: Path to save the file
  679. """
  680. try:
  681. with open(file_path, 'w', encoding='utf-8') as f:
  682. json.dump(self.to_dict(), f, indent=2, ensure_ascii=False)
  683. pprint(f"Conversation {self._conversation_id} saved to {file_path}")
  684. except Exception as e:
  685. raise ConversationSessionError(f"Failed to save conversation: {e}")
  686. @classmethod
  687. def load_from_file(cls, file_path: Path, application_ref: Optional[weakref.ref] = None) -> 'ConversationSession':
  688. """
  689. Load conversation session from JSON file.
  690. Args:
  691. file_path: Path to load from
  692. application_ref: Application reference
  693. Returns:
  694. ConversationSession: Loaded session
  695. """
  696. try:
  697. with open(file_path, 'r', encoding='utf-8') as f:
  698. data = json.load(f)
  699. # Parse satellite info
  700. satellite_info = SatelliteInfo(**data["satellite_info"])
  701. # Parse speaker info
  702. speaker_info = None
  703. if data.get("speaker_info"):
  704. speaker_info = SpeakerInfo(**data["speaker_info"])
  705. # Create session
  706. session = cls(
  707. conversation_id=data["conversation_id"],
  708. satellite_info=satellite_info,
  709. speaker_info=speaker_info,
  710. trigger_event=data.get("trigger_event", "wakeword_received"),
  711. timeout_seconds=data.get("timeout_seconds", 300.0),
  712. max_turns=data.get("max_turns", 50),
  713. application_ref=application_ref
  714. )
  715. # Restore state
  716. session._state = ConversationSessionState(data["state"])
  717. session._start_time = datetime.fromisoformat(data["start_time"])
  718. if data.get("end_time"):
  719. session._end_time = datetime.fromisoformat(data["end_time"])
  720. session._last_activity = datetime.fromisoformat(data["last_activity"])
  721. session._context_data = data.get("context_data", {})
  722. session._metadata = data.get("metadata", {})
  723. # Restore turns
  724. for turn_data in data.get("turns", []):
  725. turn = ConversationTurn.from_dict(turn_data)
  726. session._turns.append(turn)
  727. session._current_turn_number = max(session._current_turn_number, turn.turn_number)
  728. pprint(f"Conversation {session.conversation_id} loaded from {file_path}")
  729. return session
  730. except Exception as e:
  731. raise ConversationSessionError(f"Failed to load conversation: {e}")
  732. def __str__(self) -> str:
  733. """String representation of the conversation session."""
  734. return (f"ConversationSession(id={self._conversation_id}, "
  735. f"state={self._state.value}, turns={self.turn_count}, "
  736. f"duration={self.duration_seconds:.1f}s)")
  737. def __repr__(self) -> str:
  738. """Detailed representation of the conversation session."""
  739. return (f"ConversationSession(conversation_id='{self._conversation_id}', "
  740. f"state={self._state.value}, turn_count={self.turn_count}, "
  741. f"speaker='{self._speaker_info.speaker_name if self._speaker_info else None}', "
  742. f"satellite='{self._satellite_info.alias}', "
  743. f"duration={self.duration_seconds:.1f}s)")
  744. # Factory function
  745. def create_conversation_session(
  746. conversation_id: str,
  747. satellite_info: SatelliteInfo,
  748. speaker_info: Optional[SpeakerInfo] = None,
  749. **kwargs
  750. ) -> ConversationSession:
  751. """
  752. Create a ConversationSession with default configuration.
  753. Args:
  754. conversation_id: Unique conversation identifier
  755. satellite_info: Satellite device information
  756. speaker_info: Speaker information
  757. **kwargs: Additional session parameters
  758. Returns:
  759. ConversationSession: Configured session
  760. """
  761. return ConversationSession(
  762. conversation_id=conversation_id,
  763. satellite_info=satellite_info,
  764. speaker_info=speaker_info,
  765. **kwargs
  766. )
  767. # Module exports
  768. __all__ = [
  769. "ConversationSession",
  770. "ConversationSessionError",
  771. "ConversationSessionState",
  772. "ConversationTurn",
  773. "ConversationRole",
  774. "create_conversation_session"
  775. ]