""" Redis-backed memory cache for session context. Provides short-term memory storage with TTL: - Session context (24h TTL) - Recent entities mentioned in conversation - User-scoped with conversation isolation Uses Redis DB 2 (separate from benchmarks in DB 1). """ import json from typing import Any import redis.asyncio as redis from .config import config from .logging_config import get_logger from .multi_tenancy import get_session_key, get_entities_key logger = get_logger(__name__) class MemoryCache: """ Redis-backed cache for session memory. Stores ephemeral context that doesn't need vector search: - Session context (recent topics, user state) - Recent entities (people, places, things mentioned) - Conversation metadata All data expires after REDIS_MEMORY_TTL_HOURS (default 24h). Usage: cache = MemoryCache() await cache.set_session_context( user="jpmschweitzer", conversation_id="conv_123", context={"topic": "docker", "mood": "curious"} ) context = await cache.get_session_context("jpmschweitzer", "conv_123") """ def __init__( self, redis_url: str | None = None, ttl_hours: int | None = None, ): """ Initialize memory cache. Args: redis_url: Redis connection URL (defaults to config.redis_memory_url) ttl_hours: TTL for cached data (defaults to config.REDIS_MEMORY_TTL_HOURS) """ self._redis_url = redis_url or config.redis_memory_url self._ttl_seconds = (ttl_hours or config.REDIS_MEMORY_TTL_HOURS) * 3600 self._client: redis.Redis | None = None logger.info( "memory_cache_initialized", redis_url=self._redis_url, ttl_hours=ttl_hours or config.REDIS_MEMORY_TTL_HOURS, ) async def _get_client(self) -> redis.Redis: """Get or create Redis client.""" if self._client is None: self._client = redis.from_url( self._redis_url, encoding="utf-8", decode_responses=True, socket_timeout=config.REDIS_TIMEOUT, socket_connect_timeout=config.REDIS_TIMEOUT, ) return self._client async def close(self) -> None: """Close Redis connection.""" if self._client is not None: await self._client.aclose() self._client = None # ========================================================================= # Session Context # ========================================================================= async def get_session_context( self, user: str, conversation_id: str, ) -> dict[str, Any] | None: """ Get session context for a conversation. Args: user: User identifier conversation_id: Conversation identifier Returns: Session context dict or None if not found Example: >>> context = await cache.get_session_context("jpmschweitzer", "conv_123") >>> context {"topic": "docker", "mood": "curious", "last_tool": "librarian"} """ try: client = await self._get_client() key = get_session_key(user, conversation_id) data = await client.get(key) if data is None: return None return json.loads(data) except Exception as e: logger.warning( "memory_cache_get_session_failed", user=user, conversation_id=conversation_id, error=str(e), ) return None async def set_session_context( self, user: str, conversation_id: str, context: dict[str, Any], ) -> bool: """ Set session context for a conversation. Args: user: User identifier conversation_id: Conversation identifier context: Context data to store Returns: True if successful, False otherwise Example: >>> await cache.set_session_context( ... "jpmschweitzer", ... "conv_123", ... {"topic": "docker", "mood": "curious"} ... ) True """ try: client = await self._get_client() key = get_session_key(user, conversation_id) await client.setex( key, self._ttl_seconds, json.dumps(context), ) logger.debug( "memory_cache_set_session", user=user, conversation_id=conversation_id, context_keys=list(context.keys()), ) return True except Exception as e: logger.warning( "memory_cache_set_session_failed", user=user, conversation_id=conversation_id, error=str(e), ) return False async def update_session_context( self, user: str, conversation_id: str, updates: dict[str, Any], ) -> bool: """ Update session context (merge with existing). Args: user: User identifier conversation_id: Conversation identifier updates: Fields to update/add Returns: True if successful, False otherwise """ existing = await self.get_session_context(user, conversation_id) or {} existing.update(updates) return await self.set_session_context(user, conversation_id, existing) async def delete_session_context( self, user: str, conversation_id: str, ) -> bool: """ Delete session context for a conversation. Args: user: User identifier conversation_id: Conversation identifier Returns: True if deleted, False otherwise """ try: client = await self._get_client() key = get_session_key(user, conversation_id) await client.delete(key) return True except Exception as e: logger.warning( "memory_cache_delete_session_failed", user=user, conversation_id=conversation_id, error=str(e), ) return False # ========================================================================= # Recent Entities # ========================================================================= async def get_recent_entities( self, user: str, conversation_id: str, ) -> list[str]: """ Get recently mentioned entities in a conversation. Args: user: User identifier conversation_id: Conversation identifier Returns: List of entity names/identifiers Example: >>> entities = await cache.get_recent_entities("jpmschweitzer", "conv_123") >>> entities ["Docker", "Kubernetes", "nginx"] """ try: client = await self._get_client() key = get_entities_key(user, conversation_id) # Get all members of the set entities = await client.smembers(key) return list(entities) except Exception as e: logger.warning( "memory_cache_get_entities_failed", user=user, conversation_id=conversation_id, error=str(e), ) return [] async def add_recent_entities( self, user: str, conversation_id: str, entities: list[str], ) -> bool: """ Add entities to the recent entities set. Args: user: User identifier conversation_id: Conversation identifier entities: Entity names to add Returns: True if successful, False otherwise Example: >>> await cache.add_recent_entities( ... "jpmschweitzer", ... "conv_123", ... ["Docker", "Kubernetes"] ... ) True """ if not entities: return True try: client = await self._get_client() key = get_entities_key(user, conversation_id) # Add to set await client.sadd(key, *entities) # Refresh TTL await client.expire(key, self._ttl_seconds) logger.debug( "memory_cache_add_entities", user=user, conversation_id=conversation_id, entities=entities, ) return True except Exception as e: logger.warning( "memory_cache_add_entities_failed", user=user, conversation_id=conversation_id, error=str(e), ) return False async def clear_recent_entities( self, user: str, conversation_id: str, ) -> bool: """ Clear all recent entities for a conversation. Args: user: User identifier conversation_id: Conversation identifier Returns: True if cleared, False otherwise """ try: client = await self._get_client() key = get_entities_key(user, conversation_id) await client.delete(key) return True except Exception as e: logger.warning( "memory_cache_clear_entities_failed", user=user, conversation_id=conversation_id, error=str(e), ) return False # ========================================================================= # Health Check # ========================================================================= async def health_check(self) -> bool: """ Check if Redis is reachable. Returns: True if healthy, False otherwise """ try: client = await self._get_client() await client.ping() return True except Exception as e: logger.error("memory_cache_health_check_failed", error=str(e)) return False # Global cache instance (lazy initialization) _memory_cache: MemoryCache | None = None def get_memory_cache() -> MemoryCache: """ Get global memory cache instance. Returns: MemoryCache instance """ global _memory_cache if _memory_cache is None: _memory_cache = MemoryCache() return _memory_cache