conversation_context.py 35 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007
  1. """
  2. Conversation Context Management
  3. This module implements context management and preservation for conversations
  4. in the Trixy voice assistant system. It provides sophisticated context tracking,
  5. scope management, and retention policies to maintain conversation continuity
  6. across multiple turns and sessions.
  7. Key Features:
  8. - Multi-scope context management (session, global, plugin-specific)
  9. - Context entry versioning and history
  10. - Retention policies for context cleanup
  11. - Thread-safe context operations
  12. - Context persistence and restoration
  13. - Integration with conversation sessions
  14. - Performance optimization for large contexts
  15. Classes:
  16. - ConversationContext: Main context management class
  17. - ContextEntry: Individual context data entry
  18. - ContextScope: Context scope enumeration
  19. - ContextRetentionPolicy: Context cleanup policies
  20. """
  21. import json
  22. import threading
  23. import time
  24. from datetime import datetime, timezone, timedelta
  25. from dataclasses import dataclass, field
  26. from enum import Enum
  27. from typing import Any, Dict, List, Optional, Set, Union, Callable, Iterator
  28. from pathlib import Path
  29. import weakref
  30. import pickle
  31. import gzip
  32. from collections import defaultdict, OrderedDict
  33. def pprint(message: str) -> None:
  34. """Conversation context logging function."""
  35. print(f"[CONVERSATION_CONTEXT] {message}")
  36. class ContextScope(Enum):
  37. """Scopes for context data."""
  38. SESSION = "session" # Scoped to current conversation session
  39. SPEAKER = "speaker" # Scoped to specific speaker across sessions
  40. SATELLITE = "satellite" # Scoped to specific satellite device
  41. ROOM = "room" # Scoped to room/location
  42. GLOBAL = "global" # Global context across all conversations
  43. PLUGIN = "plugin" # Plugin-specific context
  44. TEMPORARY = "temporary" # Temporary context with short lifetime
  45. class ContextRetentionPolicy(Enum):
  46. """Retention policies for context cleanup."""
  47. IMMEDIATE = "immediate" # Delete immediately when scope ends
  48. SESSION_END = "session_end" # Keep until session ends
  49. TIME_BASED = "time_based" # Keep for specified time duration
  50. TURN_BASED = "turn_based" # Keep for specified number of turns
  51. MANUAL = "manual" # Manual cleanup only
  52. PERSISTENT = "persistent" # Keep indefinitely
  53. @dataclass
  54. class ContextEntry:
  55. """
  56. Represents a single context entry with metadata.
  57. Context entries store data with versioning, timestamps, and retention policies
  58. to enable sophisticated context management.
  59. """
  60. key: str
  61. value: Any
  62. scope: ContextScope
  63. created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
  64. updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
  65. accessed_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
  66. # Retention and lifecycle
  67. retention_policy: ContextRetentionPolicy = ContextRetentionPolicy.SESSION_END
  68. expires_at: Optional[datetime] = None
  69. max_turns: Optional[int] = None
  70. turn_count: int = 0
  71. # Versioning
  72. version: int = 1
  73. previous_values: List[Any] = field(default_factory=list)
  74. # Metadata
  75. source: str = "system" # Who created this context
  76. tags: Set[str] = field(default_factory=set)
  77. priority: int = 0 # Higher priority entries are preserved longer
  78. metadata: Dict[str, Any] = field(default_factory=dict)
  79. def is_expired(self) -> bool:
  80. """Check if the context entry has expired."""
  81. now = datetime.now(timezone.utc)
  82. # Check time-based expiration
  83. if self.expires_at and now > self.expires_at:
  84. return True
  85. # Check turn-based expiration
  86. if self.max_turns and self.turn_count >= self.max_turns:
  87. return True
  88. return False
  89. def update_value(self, new_value: Any, source: str = "system") -> None:
  90. """Update the context value with versioning."""
  91. # Store previous value
  92. if len(self.previous_values) >= 10: # Keep last 10 versions
  93. self.previous_values.pop(0)
  94. self.previous_values.append(self.value)
  95. # Update current value
  96. self.value = new_value
  97. self.updated_at = datetime.now(timezone.utc)
  98. self.version += 1
  99. self.source = source
  100. def access(self) -> Any:
  101. """Access the value and update access timestamp."""
  102. self.accessed_at = datetime.now(timezone.utc)
  103. return self.value
  104. def increment_turn(self) -> None:
  105. """Increment the turn counter."""
  106. self.turn_count += 1
  107. def add_tag(self, tag: str) -> None:
  108. """Add a tag to the context entry."""
  109. self.tags.add(tag)
  110. def remove_tag(self, tag: str) -> None:
  111. """Remove a tag from the context entry."""
  112. self.tags.discard(tag)
  113. def has_tag(self, tag: str) -> bool:
  114. """Check if context entry has a specific tag."""
  115. return tag in self.tags
  116. def to_dict(self) -> Dict[str, Any]:
  117. """Convert context entry to dictionary representation."""
  118. return {
  119. "key": self.key,
  120. "value": self.value,
  121. "scope": self.scope.value,
  122. "created_at": self.created_at.isoformat(),
  123. "updated_at": self.updated_at.isoformat(),
  124. "accessed_at": self.accessed_at.isoformat(),
  125. "retention_policy": self.retention_policy.value,
  126. "expires_at": self.expires_at.isoformat() if self.expires_at else None,
  127. "max_turns": self.max_turns,
  128. "turn_count": self.turn_count,
  129. "version": self.version,
  130. "previous_values": self.previous_values,
  131. "source": self.source,
  132. "tags": list(self.tags),
  133. "priority": self.priority,
  134. "metadata": self.metadata
  135. }
  136. @classmethod
  137. def from_dict(cls, data: Dict[str, Any]) -> 'ContextEntry':
  138. """Create ContextEntry from dictionary."""
  139. expires_at = None
  140. if data.get("expires_at"):
  141. expires_at = datetime.fromisoformat(data["expires_at"])
  142. return cls(
  143. key=data["key"],
  144. value=data["value"],
  145. scope=ContextScope(data["scope"]),
  146. created_at=datetime.fromisoformat(data["created_at"]),
  147. updated_at=datetime.fromisoformat(data["updated_at"]),
  148. accessed_at=datetime.fromisoformat(data["accessed_at"]),
  149. retention_policy=ContextRetentionPolicy(data.get("retention_policy", "session_end")),
  150. expires_at=expires_at,
  151. max_turns=data.get("max_turns"),
  152. turn_count=data.get("turn_count", 0),
  153. version=data.get("version", 1),
  154. previous_values=data.get("previous_values", []),
  155. source=data.get("source", "system"),
  156. tags=set(data.get("tags", [])),
  157. priority=data.get("priority", 0),
  158. metadata=data.get("metadata", {})
  159. )
  160. class ConversationContextError(Exception):
  161. """Base exception for conversation context errors."""
  162. pass
  163. class ConversationContext:
  164. """
  165. Manages conversation context with multi-scope support and retention policies.
  166. This class provides sophisticated context management for conversations,
  167. supporting different scopes (session, speaker, global, etc.) and retention
  168. policies to automatically clean up old context data.
  169. Features:
  170. - Multi-scope context management
  171. - Automatic context cleanup based on retention policies
  172. - Context versioning and history
  173. - Thread-safe operations
  174. - Context persistence and restoration
  175. - Performance optimization for large contexts
  176. - Integration with conversation sessions
  177. The context system supports the CLAUDE.md workflow by maintaining state
  178. across conversation turns and enabling plugins to preserve context
  179. for multi-turn interactions.
  180. """
  181. def __init__(
  182. self,
  183. conversation_id: str,
  184. speaker_id: Optional[str] = None,
  185. satellite_id: Optional[str] = None,
  186. room_id: Optional[str] = None,
  187. max_entries_per_scope: int = 1000,
  188. cleanup_interval_seconds: float = 60.0,
  189. enable_persistence: bool = False,
  190. persistence_path: Optional[Path] = None
  191. ):
  192. """
  193. Initialize conversation context manager.
  194. Args:
  195. conversation_id: Current conversation ID
  196. speaker_id: Speaker identifier for speaker-scoped context
  197. satellite_id: Satellite identifier for satellite-scoped context
  198. room_id: Room identifier for room-scoped context
  199. max_entries_per_scope: Maximum entries per scope (for memory management)
  200. cleanup_interval_seconds: Interval for automatic cleanup
  201. enable_persistence: Enable context persistence to disk
  202. persistence_path: Path for persisting context data
  203. """
  204. # Core identifiers
  205. self._conversation_id = conversation_id
  206. self._speaker_id = speaker_id
  207. self._satellite_id = satellite_id
  208. self._room_id = room_id
  209. # Configuration
  210. self._max_entries_per_scope = max_entries_per_scope
  211. self._cleanup_interval = cleanup_interval_seconds
  212. self._enable_persistence = enable_persistence
  213. self._persistence_path = persistence_path or Path(f"./context/{conversation_id}")
  214. # Context storage organized by scope
  215. self._contexts: Dict[ContextScope, Dict[str, ContextEntry]] = {
  216. scope: OrderedDict() for scope in ContextScope
  217. }
  218. # Thread safety
  219. self._lock = threading.RLock()
  220. # Cleanup management
  221. self._last_cleanup = time.time()
  222. self._cleanup_stats = defaultdict(int)
  223. # Performance tracking
  224. self._access_count = 0
  225. self._hit_count = 0
  226. self._miss_count = 0
  227. # Session tracking
  228. self._session_start = datetime.now(timezone.utc)
  229. self._turn_number = 0
  230. pprint(f"Context manager initialized for conversation {conversation_id}")
  231. # Load persisted context if enabled
  232. if self._enable_persistence:
  233. self._load_persistent_context()
  234. # Core Context Operations
  235. def set(
  236. self,
  237. key: str,
  238. value: Any,
  239. scope: ContextScope = ContextScope.SESSION,
  240. retention_policy: ContextRetentionPolicy = ContextRetentionPolicy.SESSION_END,
  241. expires_in_seconds: Optional[float] = None,
  242. max_turns: Optional[int] = None,
  243. source: str = "system",
  244. tags: Optional[Set[str]] = None,
  245. priority: int = 0,
  246. metadata: Optional[Dict[str, Any]] = None
  247. ) -> None:
  248. """
  249. Set a context value with specified scope and retention policy.
  250. Args:
  251. key: Context key
  252. value: Context value
  253. scope: Context scope
  254. retention_policy: How long to retain the context
  255. expires_in_seconds: Expiration time in seconds
  256. max_turns: Maximum turns to keep context
  257. source: Source that created the context
  258. tags: Optional tags for the context entry
  259. priority: Priority level (higher = more important)
  260. metadata: Additional metadata
  261. """
  262. with self._lock:
  263. # Calculate expiration time
  264. expires_at = None
  265. if expires_in_seconds:
  266. expires_at = datetime.now(timezone.utc) + timedelta(seconds=expires_in_seconds)
  267. elif retention_policy == ContextRetentionPolicy.TIME_BASED:
  268. # Default to 1 hour for time-based retention
  269. expires_at = datetime.now(timezone.utc) + timedelta(hours=1)
  270. # Get or create context entry
  271. context_dict = self._contexts[scope]
  272. if key in context_dict:
  273. # Update existing entry
  274. entry = context_dict[key]
  275. entry.update_value(value, source)
  276. if expires_at:
  277. entry.expires_at = expires_at
  278. if max_turns:
  279. entry.max_turns = max_turns
  280. if tags:
  281. entry.tags.update(tags)
  282. if metadata:
  283. entry.metadata.update(metadata)
  284. else:
  285. # Create new entry
  286. entry = ContextEntry(
  287. key=key,
  288. value=value,
  289. scope=scope,
  290. retention_policy=retention_policy,
  291. expires_at=expires_at,
  292. max_turns=max_turns,
  293. source=source,
  294. tags=tags or set(),
  295. priority=priority,
  296. metadata=metadata or {}
  297. )
  298. # Add to context
  299. context_dict[key] = entry
  300. # Move to end (LRU)
  301. context_dict.move_to_end(key)
  302. # Check if scope is over capacity
  303. if len(context_dict) > self._max_entries_per_scope:
  304. self._cleanup_scope_capacity(scope)
  305. # Persist if enabled
  306. if self._enable_persistence and scope in [ContextScope.SPEAKER, ContextScope.GLOBAL, ContextScope.PLUGIN]:
  307. self._persist_context_entry(entry)
  308. def get(
  309. self,
  310. key: str,
  311. scope: ContextScope = ContextScope.SESSION,
  312. default: Any = None,
  313. mark_accessed: bool = True
  314. ) -> Any:
  315. """
  316. Get a context value from the specified scope.
  317. Args:
  318. key: Context key
  319. scope: Context scope to search
  320. default: Default value if not found
  321. mark_accessed: Whether to mark as accessed (for LRU)
  322. Returns:
  323. Context value or default
  324. """
  325. with self._lock:
  326. self._access_count += 1
  327. context_dict = self._contexts[scope]
  328. if key in context_dict:
  329. entry = context_dict[key]
  330. # Check if expired
  331. if entry.is_expired():
  332. del context_dict[key]
  333. self._miss_count += 1
  334. return default
  335. # Mark as accessed
  336. if mark_accessed:
  337. value = entry.access()
  338. context_dict.move_to_end(key) # LRU
  339. else:
  340. value = entry.value
  341. self._hit_count += 1
  342. return value
  343. self._miss_count += 1
  344. return default
  345. def get_multi_scope(
  346. self,
  347. key: str,
  348. scopes: List[ContextScope],
  349. default: Any = None,
  350. mark_accessed: bool = True
  351. ) -> Any:
  352. """
  353. Get a context value searching multiple scopes in order.
  354. Args:
  355. key: Context key
  356. scopes: List of scopes to search in order
  357. default: Default value if not found
  358. mark_accessed: Whether to mark as accessed
  359. Returns:
  360. First found context value or default
  361. """
  362. for scope in scopes:
  363. value = self.get(key, scope, None, mark_accessed)
  364. if value is not None:
  365. return value
  366. return default
  367. def has(self, key: str, scope: ContextScope = ContextScope.SESSION) -> bool:
  368. """
  369. Check if a context key exists in the specified scope.
  370. Args:
  371. key: Context key
  372. scope: Context scope
  373. Returns:
  374. True if key exists and is not expired
  375. """
  376. with self._lock:
  377. context_dict = self._contexts[scope]
  378. if key not in context_dict:
  379. return False
  380. entry = context_dict[key]
  381. if entry.is_expired():
  382. del context_dict[key]
  383. return False
  384. return True
  385. def delete(self, key: str, scope: ContextScope = ContextScope.SESSION) -> bool:
  386. """
  387. Delete a context entry.
  388. Args:
  389. key: Context key
  390. scope: Context scope
  391. Returns:
  392. True if entry was deleted
  393. """
  394. with self._lock:
  395. context_dict = self._contexts[scope]
  396. if key in context_dict:
  397. del context_dict[key]
  398. # Remove from persistence if enabled
  399. if self._enable_persistence and scope in [ContextScope.SPEAKER, ContextScope.GLOBAL, ContextScope.PLUGIN]:
  400. self._remove_persistent_entry(key, scope)
  401. return True
  402. return False
  403. def update(
  404. self,
  405. data: Dict[str, Any],
  406. scope: ContextScope = ContextScope.SESSION,
  407. source: str = "system"
  408. ) -> None:
  409. """
  410. Update multiple context values at once.
  411. Args:
  412. data: Dictionary of key-value pairs
  413. scope: Context scope
  414. source: Source identifier
  415. """
  416. for key, value in data.items():
  417. self.set(key, value, scope=scope, source=source)
  418. def clear_scope(self, scope: ContextScope) -> int:
  419. """
  420. Clear all entries from a specific scope.
  421. Args:
  422. scope: Scope to clear
  423. Returns:
  424. Number of entries cleared
  425. """
  426. with self._lock:
  427. context_dict = self._contexts[scope]
  428. count = len(context_dict)
  429. context_dict.clear()
  430. pprint(f"Cleared {count} entries from scope {scope.value}")
  431. return count
  432. def clear_all(self) -> int:
  433. """
  434. Clear all context entries.
  435. Returns:
  436. Total number of entries cleared
  437. """
  438. total_cleared = 0
  439. for scope in ContextScope:
  440. total_cleared += self.clear_scope(scope)
  441. pprint(f"Cleared all context entries: {total_cleared} total")
  442. return total_cleared
  443. # Advanced Query Operations
  444. def search_by_tag(
  445. self,
  446. tag: str,
  447. scope: Optional[ContextScope] = None
  448. ) -> List[ContextEntry]:
  449. """
  450. Search for context entries by tag.
  451. Args:
  452. tag: Tag to search for
  453. scope: Optional scope to limit search
  454. Returns:
  455. List of matching context entries
  456. """
  457. with self._lock:
  458. results = []
  459. scopes_to_search = [scope] if scope else list(ContextScope)
  460. for search_scope in scopes_to_search:
  461. context_dict = self._contexts[search_scope]
  462. for entry in context_dict.values():
  463. if not entry.is_expired() and entry.has_tag(tag):
  464. results.append(entry)
  465. return results
  466. def search_by_source(
  467. self,
  468. source: str,
  469. scope: Optional[ContextScope] = None
  470. ) -> List[ContextEntry]:
  471. """
  472. Search for context entries by source.
  473. Args:
  474. source: Source identifier
  475. scope: Optional scope to limit search
  476. Returns:
  477. List of matching context entries
  478. """
  479. with self._lock:
  480. results = []
  481. scopes_to_search = [scope] if scope else list(ContextScope)
  482. for search_scope in scopes_to_search:
  483. context_dict = self._contexts[search_scope]
  484. for entry in context_dict.values():
  485. if not entry.is_expired() and entry.source == source:
  486. results.append(entry)
  487. return results
  488. def get_entries_by_scope(self, scope: ContextScope) -> List[ContextEntry]:
  489. """
  490. Get all entries from a specific scope.
  491. Args:
  492. scope: Context scope
  493. Returns:
  494. List of context entries
  495. """
  496. with self._lock:
  497. context_dict = self._contexts[scope]
  498. return [entry for entry in context_dict.values() if not entry.is_expired()]
  499. def get_all_entries(self) -> Dict[ContextScope, List[ContextEntry]]:
  500. """
  501. Get all context entries organized by scope.
  502. Returns:
  503. Dictionary mapping scopes to entry lists
  504. """
  505. with self._lock:
  506. result = {}
  507. for scope in ContextScope:
  508. result[scope] = self.get_entries_by_scope(scope)
  509. return result
  510. # Session and Turn Management
  511. def new_turn(self) -> None:
  512. """Mark the start of a new conversation turn."""
  513. with self._lock:
  514. self._turn_number += 1
  515. # Increment turn counter for all entries
  516. for scope_dict in self._contexts.values():
  517. for entry in scope_dict.values():
  518. entry.increment_turn()
  519. # Trigger cleanup if needed
  520. self._maybe_cleanup()
  521. def end_session(self) -> None:
  522. """Mark the end of the conversation session."""
  523. with self._lock:
  524. # Clean up session-scoped context
  525. self.clear_scope(ContextScope.SESSION)
  526. # Clean up temporary context
  527. self.clear_scope(ContextScope.TEMPORARY)
  528. # Clean up entries with SESSION_END retention policy
  529. for scope_dict in self._contexts.values():
  530. expired_keys = []
  531. for key, entry in scope_dict.items():
  532. if entry.retention_policy == ContextRetentionPolicy.SESSION_END:
  533. expired_keys.append(key)
  534. for key in expired_keys:
  535. del scope_dict[key]
  536. pprint(f"Session ended for conversation {self._conversation_id}")
  537. # Cleanup and Maintenance
  538. def _maybe_cleanup(self) -> None:
  539. """Perform cleanup if interval has passed."""
  540. now = time.time()
  541. if now - self._last_cleanup > self._cleanup_interval:
  542. self.cleanup_expired()
  543. self._last_cleanup = now
  544. def cleanup_expired(self) -> int:
  545. """
  546. Clean up expired context entries.
  547. Returns:
  548. Number of entries cleaned up
  549. """
  550. with self._lock:
  551. total_cleaned = 0
  552. for scope, context_dict in self._contexts.items():
  553. expired_keys = []
  554. for key, entry in context_dict.items():
  555. if entry.is_expired():
  556. expired_keys.append(key)
  557. for key in expired_keys:
  558. del context_dict[key]
  559. total_cleaned += 1
  560. self._cleanup_stats[scope.value] += len(expired_keys)
  561. if total_cleaned > 0:
  562. pprint(f"Cleaned up {total_cleaned} expired context entries")
  563. return total_cleaned
  564. def _cleanup_scope_capacity(self, scope: ContextScope) -> None:
  565. """Clean up a scope that's over capacity using LRU."""
  566. context_dict = self._contexts[scope]
  567. # Remove oldest entries (LRU) until under capacity
  568. while len(context_dict) > self._max_entries_per_scope:
  569. # Remove from the beginning (oldest)
  570. oldest_key = next(iter(context_dict))
  571. del context_dict[oldest_key]
  572. self._cleanup_stats[f"{scope.value}_capacity"] += 1
  573. def force_cleanup(
  574. self,
  575. max_age_hours: float = 24.0,
  576. max_entries_per_scope: Optional[int] = None
  577. ) -> int:
  578. """
  579. Force cleanup of old context entries.
  580. Args:
  581. max_age_hours: Maximum age in hours
  582. max_entries_per_scope: Override max entries per scope
  583. Returns:
  584. Number of entries cleaned up
  585. """
  586. with self._lock:
  587. cutoff_time = datetime.now(timezone.utc) - timedelta(hours=max_age_hours)
  588. max_entries = max_entries_per_scope or self._max_entries_per_scope
  589. total_cleaned = 0
  590. for scope, context_dict in self._contexts.items():
  591. # Remove old entries
  592. old_keys = []
  593. for key, entry in context_dict.items():
  594. if (entry.created_at < cutoff_time and
  595. entry.retention_policy != ContextRetentionPolicy.PERSISTENT):
  596. old_keys.append(key)
  597. for key in old_keys:
  598. del context_dict[key]
  599. total_cleaned += 1
  600. # Remove excess entries (LRU)
  601. while len(context_dict) > max_entries:
  602. oldest_key = next(iter(context_dict))
  603. del context_dict[oldest_key]
  604. total_cleaned += 1
  605. pprint(f"Force cleanup removed {total_cleaned} context entries")
  606. return total_cleaned
  607. # Persistence Operations
  608. def _persist_context_entry(self, entry: ContextEntry) -> None:
  609. """Persist a single context entry to disk."""
  610. if not self._enable_persistence:
  611. return
  612. try:
  613. # Create persistence directory
  614. self._persistence_path.mkdir(parents=True, exist_ok=True)
  615. # Create scope-specific file
  616. scope_file = self._persistence_path / f"{entry.scope.value}.json"
  617. # Load existing data
  618. data = {}
  619. if scope_file.exists():
  620. with open(scope_file, 'r', encoding='utf-8') as f:
  621. data = json.load(f)
  622. # Update with new entry
  623. data[entry.key] = entry.to_dict()
  624. # Save back to file
  625. with open(scope_file, 'w', encoding='utf-8') as f:
  626. json.dump(data, f, indent=2, ensure_ascii=False)
  627. except Exception as e:
  628. pprint(f"Error persisting context entry: {e}")
  629. def _load_persistent_context(self) -> None:
  630. """Load persistent context from disk."""
  631. if not self._enable_persistence or not self._persistence_path.exists():
  632. return
  633. try:
  634. loaded_count = 0
  635. for scope_file in self._persistence_path.glob("*.json"):
  636. scope_name = scope_file.stem
  637. try:
  638. scope = ContextScope(scope_name)
  639. except ValueError:
  640. continue # Skip unknown scopes
  641. with open(scope_file, 'r', encoding='utf-8') as f:
  642. data = json.load(f)
  643. for key, entry_data in data.items():
  644. try:
  645. entry = ContextEntry.from_dict(entry_data)
  646. # Only load non-expired entries
  647. if not entry.is_expired():
  648. self._contexts[scope][key] = entry
  649. loaded_count += 1
  650. except Exception as e:
  651. pprint(f"Error loading context entry {key}: {e}")
  652. if loaded_count > 0:
  653. pprint(f"Loaded {loaded_count} persistent context entries")
  654. except Exception as e:
  655. pprint(f"Error loading persistent context: {e}")
  656. def _remove_persistent_entry(self, key: str, scope: ContextScope) -> None:
  657. """Remove a persistent context entry."""
  658. if not self._enable_persistence:
  659. return
  660. try:
  661. scope_file = self._persistence_path / f"{scope.value}.json"
  662. if scope_file.exists():
  663. with open(scope_file, 'r', encoding='utf-8') as f:
  664. data = json.load(f)
  665. if key in data:
  666. del data[key]
  667. with open(scope_file, 'w', encoding='utf-8') as f:
  668. json.dump(data, f, indent=2, ensure_ascii=False)
  669. except Exception as e:
  670. pprint(f"Error removing persistent context entry: {e}")
  671. def save_to_file(self, file_path: Path) -> None:
  672. """
  673. Save all context data to a file.
  674. Args:
  675. file_path: Path to save the context data
  676. """
  677. try:
  678. data = {
  679. "conversation_id": self._conversation_id,
  680. "speaker_id": self._speaker_id,
  681. "satellite_id": self._satellite_id,
  682. "room_id": self._room_id,
  683. "session_start": self._session_start.isoformat(),
  684. "turn_number": self._turn_number,
  685. "contexts": {
  686. scope.value: {
  687. key: entry.to_dict()
  688. for key, entry in context_dict.items()
  689. if not entry.is_expired()
  690. }
  691. for scope, context_dict in self._contexts.items()
  692. }
  693. }
  694. # Compress if file is large
  695. if json.dumps(data).encode('utf-8').__len__() > 1024 * 1024: # 1MB
  696. with gzip.open(f"{file_path}.gz", 'wt', encoding='utf-8') as f:
  697. json.dump(data, f, indent=2, ensure_ascii=False)
  698. else:
  699. with open(file_path, 'w', encoding='utf-8') as f:
  700. json.dump(data, f, indent=2, ensure_ascii=False)
  701. pprint(f"Context saved to {file_path}")
  702. except Exception as e:
  703. raise ConversationContextError(f"Failed to save context: {e}")
  704. @classmethod
  705. def load_from_file(cls, file_path: Path) -> 'ConversationContext':
  706. """
  707. Load context data from a file.
  708. Args:
  709. file_path: Path to load from
  710. Returns:
  711. ConversationContext: Loaded context manager
  712. """
  713. try:
  714. # Try compressed format first
  715. if file_path.with_suffix('.gz').exists():
  716. with gzip.open(file_path.with_suffix('.gz'), 'rt', encoding='utf-8') as f:
  717. data = json.load(f)
  718. else:
  719. with open(file_path, 'r', encoding='utf-8') as f:
  720. data = json.load(f)
  721. # Create context manager
  722. context = cls(
  723. conversation_id=data["conversation_id"],
  724. speaker_id=data.get("speaker_id"),
  725. satellite_id=data.get("satellite_id"),
  726. room_id=data.get("room_id")
  727. )
  728. # Restore state
  729. context._session_start = datetime.fromisoformat(data["session_start"])
  730. context._turn_number = data.get("turn_number", 0)
  731. # Restore context entries
  732. for scope_name, scope_data in data.get("contexts", {}).items():
  733. try:
  734. scope = ContextScope(scope_name)
  735. for key, entry_data in scope_data.items():
  736. entry = ContextEntry.from_dict(entry_data)
  737. if not entry.is_expired():
  738. context._contexts[scope][key] = entry
  739. except ValueError:
  740. continue # Skip unknown scopes
  741. pprint(f"Context loaded from {file_path}")
  742. return context
  743. except Exception as e:
  744. raise ConversationContextError(f"Failed to load context: {e}")
  745. # Analytics and Monitoring
  746. def get_stats(self) -> Dict[str, Any]:
  747. """
  748. Get context statistics and performance metrics.
  749. Returns:
  750. Dictionary containing context statistics
  751. """
  752. with self._lock:
  753. total_entries = sum(len(context_dict) for context_dict in self._contexts.values())
  754. scope_stats = {}
  755. for scope, context_dict in self._contexts.items():
  756. scope_stats[scope.value] = {
  757. "count": len(context_dict),
  758. "oldest": min((e.created_at for e in context_dict.values()), default=None),
  759. "newest": max((e.created_at for e in context_dict.values()), default=None),
  760. "total_versions": sum(e.version for e in context_dict.values()),
  761. "expired_count": sum(1 for e in context_dict.values() if e.is_expired())
  762. }
  763. hit_rate = (self._hit_count / max(1, self._access_count)) * 100
  764. return {
  765. "conversation_id": self._conversation_id,
  766. "total_entries": total_entries,
  767. "total_accesses": self._access_count,
  768. "cache_hits": self._hit_count,
  769. "cache_misses": self._miss_count,
  770. "hit_rate_percent": round(hit_rate, 2),
  771. "session_duration_seconds": (datetime.now(timezone.utc) - self._session_start).total_seconds(),
  772. "turn_number": self._turn_number,
  773. "scope_stats": scope_stats,
  774. "cleanup_stats": dict(self._cleanup_stats),
  775. "last_cleanup": self._last_cleanup
  776. }
  777. def __str__(self) -> str:
  778. """String representation of the context manager."""
  779. total_entries = sum(len(context_dict) for context_dict in self._contexts.values())
  780. return f"ConversationContext(conversation={self._conversation_id}, entries={total_entries})"
  781. def __repr__(self) -> str:
  782. """Detailed representation of the context manager."""
  783. return (f"ConversationContext(conversation_id='{self._conversation_id}', "
  784. f"speaker_id='{self._speaker_id}', satellite_id='{self._satellite_id}', "
  785. f"room_id='{self._room_id}', turn_number={self._turn_number})")
  786. # Factory function
  787. def create_conversation_context(
  788. conversation_id: str,
  789. speaker_id: Optional[str] = None,
  790. satellite_id: Optional[str] = None,
  791. room_id: Optional[str] = None,
  792. **kwargs
  793. ) -> ConversationContext:
  794. """
  795. Create a ConversationContext with default configuration.
  796. Args:
  797. conversation_id: Conversation identifier
  798. speaker_id: Speaker identifier
  799. satellite_id: Satellite identifier
  800. room_id: Room identifier
  801. **kwargs: Additional context parameters
  802. Returns:
  803. ConversationContext: Configured context manager
  804. """
  805. return ConversationContext(
  806. conversation_id=conversation_id,
  807. speaker_id=speaker_id,
  808. satellite_id=satellite_id,
  809. room_id=room_id,
  810. **kwargs
  811. )
  812. # Module exports
  813. __all__ = [
  814. "ConversationContext",
  815. "ConversationContextError",
  816. "ContextScope",
  817. "ContextEntry",
  818. "ContextRetentionPolicy",
  819. "create_conversation_context"
  820. ]