""" Volatile Cache service for Library Desk. Provides ephemeral data storage with TTL using Qdrant vectors: - Weather, news, financial data - Transit schedules, traffic conditions - System status, social notifications Data is stored as embedded vectors for semantic search retrieval. """ import hashlib import logging import time from datetime import datetime from typing import List, Optional, Dict, Any from src.clients.qdrant_client import QdrantClientWrapper from src.clients.ollama_client import OllamaClient from src.config import Settings from src.models.volatile import ( VolatileRecordResponse, VolatileNamespace, NAMESPACE_DEFAULT_TTL, ) logger = logging.getLogger(__name__) class VolatileCacheService: """ Service for volatile data with TTL stored in Qdrant. Stores ephemeral data as vectors for semantic search retrieval. Each user has an isolated volatile collection. """ COLLECTION_PREFIX = "volatile_" def __init__( self, qdrant_client: QdrantClientWrapper, ollama_client: OllamaClient, settings: Settings ): """ Initialize volatile cache service. Args: qdrant_client: Qdrant client for vector storage ollama_client: Ollama client for embeddings settings: Application settings """ self.qdrant = qdrant_client self.ollama = ollama_client self.settings = settings logger.info("Initialized VolatileCacheService (Qdrant backend)") def _collection_name(self, user: str) -> str: """ Get volatile collection name for user. The user id is sanitized (same rules as the document collections) so raw identifiers cannot alias or escape the per-tenant collection naming scheme. """ from src.core.multi_tenancy import sanitize_user_id return f"{self.COLLECTION_PREFIX}{sanitize_user_id(user)}" def _make_vector_id(self, namespace: str, key: str) -> str: """ Generate deterministic vector ID for namespace/key. Same namespace+key always produces same ID for upsert behavior. """ combined = f"{namespace}:{key}" return hashlib.md5(combined.encode()).hexdigest() def _get_default_ttl(self, namespace: str) -> int: """Get default TTL for a namespace.""" try: ns = VolatileNamespace(namespace) return NAMESPACE_DEFAULT_TTL.get(ns, self.settings.volatile_default_ttl) except ValueError: return self.settings.volatile_default_ttl def _current_timestamp_ms(self) -> int: """Get current timestamp in milliseconds.""" return int(time.time() * 1000) def _to_natural_language( self, namespace: str, key: str, data: Dict[str, Any] ) -> str: """ Convert structured data to natural language for embedding. This creates a text representation that embeds well semantically. """ # Template-based conversion for known namespaces if namespace == VolatileNamespace.WEATHER: temp = data.get("temperature", data.get("temp", "unknown")) conditions = data.get("conditions", data.get("weather", "")) humidity = data.get("humidity", "") text = f"Current weather in {key}: {temp}°C" if conditions: text += f", {conditions}" if humidity: text += f", humidity {humidity}%" return text elif namespace == VolatileNamespace.NEWS: title = data.get("title", data.get("headline", "")) summary = data.get("summary", data.get("description", "")) source = data.get("source", "") text = f"News: {title}" if summary: text += f". {summary}" if source: text += f" (Source: {source})" return text elif namespace == VolatileNamespace.FINANCIAL: symbol = data.get("symbol", key) price = data.get("price", "") change = data.get("change", data.get("change_percent", "")) text = f"Financial data for {symbol}" if price: text += f": price {price}" if change: text += f", change {change}%" return text elif namespace == VolatileNamespace.TRANSIT: route = data.get("route", data.get("line", key)) status = data.get("status", "") delay = data.get("delay", data.get("delay_minutes", "")) text = f"Transit {route}" if status: text += f": {status}" if delay: text += f", delay {delay} minutes" return text elif namespace == VolatileNamespace.TRAFFIC: location = data.get("location", key) duration = data.get("duration", data.get("travel_time", "")) congestion = data.get("congestion", "") text = f"Traffic for {location}" if duration: text += f": {duration} minutes" if congestion: text += f", congestion level {congestion}" return text elif namespace == VolatileNamespace.AIR_QUALITY: location = data.get("location", key) aqi = data.get("aqi", data.get("index", "")) quality = data.get("quality", "") text = f"Air quality in {location}" if aqi: text += f": AQI {aqi}" if quality: text += f" ({quality})" return text elif namespace == VolatileNamespace.SPORTS: event = data.get("event", data.get("match", key)) score = data.get("score", "") status = data.get("status", "") text = f"Sports: {event}" if score: text += f" - Score: {score}" if status: text += f" ({status})" return text elif namespace == VolatileNamespace.SYSTEM: service = data.get("service", key) status = data.get("status", "unknown") message = data.get("message", "") text = f"System status for {service}: {status}" if message: text += f". {message}" return text # Fallback: serialize key fields text_parts = [f"{namespace} data for {key}:"] for k, v in data.items(): if isinstance(v, (str, int, float, bool)): text_parts.append(f"{k}: {v}") return " ".join(text_parts) async def store( self, user: str, namespace: str, key: str, data: Dict[str, Any], source: Optional[str] = None, ttl: Optional[int] = None, refresh_schedule: Optional[str] = None ) -> VolatileRecordResponse: """ Store volatile data as an embedded vector. Args: user: User identifier namespace: Data namespace (from controlled list) key: Record key (normalized slug) data: Structured data to store source: Origin API/service ttl: TTL in seconds (uses namespace default if not set) refresh_schedule: Optional cron expression for refresh Returns: The stored record """ collection = self._collection_name(user) # Ensure collection exists await self.qdrant.ensure_collection(collection) # Calculate TTL and expiry effective_ttl = ttl if ttl is not None else self._get_default_ttl(namespace) now_ms = self._current_timestamp_ms() expiry_ms = now_ms + (effective_ttl * 1000) # Convert to natural language for embedding text = self._to_natural_language(namespace, key, data) # Generate embedding embedding = await self.ollama.embed(text) if not embedding: raise ValueError("Failed to generate embedding for volatile data") # Build payload now = datetime.utcnow() payload = { "doc_type": "volatile", "namespace": namespace, "key": key, "text": text, "raw_data": data, "source": source, "created_at": now.isoformat(), "updated_at": now.isoformat(), "ttl": effective_ttl, "ttl_expiry": expiry_ms, "refresh_schedule": refresh_schedule, "user": user, } # Upsert vector (same namespace+key = same ID = update) vector_id = self._make_vector_id(namespace, key) success = await self.qdrant.upsert_vector( collection_name=collection, vector_id=vector_id, vector=embedding, payload=payload ) if not success: raise ValueError("Failed to store volatile vector") logger.debug(f"Stored volatile {namespace}:{key} with TTL {effective_ttl}s") return VolatileRecordResponse( key=key, namespace=namespace, data=data, source=source, created_at=now, updated_at=now, ttl=effective_ttl, ttl_remaining=effective_ttl, refresh_schedule=refresh_schedule, user=user, ) async def search( self, user: str, query: str, limit: int = 5, score_threshold: float = 0.75 ) -> List[VolatileRecordResponse]: """ Semantic search across volatile data. Args: user: User identifier query: Search query limit: Maximum results score_threshold: Minimum similarity score (higher = stricter) Returns: List of matching volatile records """ collection = self._collection_name(user) # Check if collection exists if not await self.qdrant.collection_exists(collection): return [] # Generate query embedding query_embedding = await self.ollama.embed(query) if not query_embedding: logger.error("Failed to embed query for volatile search") return [] # Search with expiry filter now_ms = self._current_timestamp_ms() results = await self.qdrant.search_with_expiry_filter( collection_name=collection, query_vector=query_embedding, current_timestamp=now_ms, limit=limit, score_threshold=score_threshold ) # Convert to response models responses = [] for result in results: payload = result["payload"] ttl_expiry = payload.get("ttl_expiry", 0) ttl_remaining = max(0, (ttl_expiry - now_ms) // 1000) responses.append(VolatileRecordResponse( key=payload["key"], namespace=payload["namespace"], data=payload.get("raw_data", {}), source=payload.get("source"), created_at=datetime.fromisoformat(payload["created_at"]), updated_at=datetime.fromisoformat(payload["updated_at"]), ttl=payload.get("ttl", 0), ttl_remaining=ttl_remaining, refresh_schedule=payload.get("refresh_schedule"), user=payload["user"], )) return responses async def get( self, user: str, namespace: str, key: str ) -> Optional[VolatileRecordResponse]: """ Get a specific volatile record by namespace and key. Args: user: User identifier namespace: Data namespace key: Record key Returns: Record if found and not expired, None otherwise """ # Use search with high threshold to find exact match query = self._to_natural_language(namespace, key, {"key": key}) results = await self.search(user, query, limit=10, score_threshold=0.5) # Find exact namespace+key match for result in results: if result.namespace == namespace and result.key == key: return result return None async def delete( self, user: str, namespace: str, key: str ) -> bool: """ Delete a specific volatile record. Args: user: User identifier namespace: Data namespace key: Record key Returns: True if deleted, False if not found """ collection = self._collection_name(user) if not await self.qdrant.collection_exists(collection): return False vector_id = self._make_vector_id(namespace, key) try: deleted = await self.qdrant.delete_by_ids( collection_name=collection, point_ids=[vector_id] ) return deleted > 0 except Exception as e: logger.error(f"Failed to delete volatile {namespace}:{key}: {e}") return False async def get_scheduled( self, user: str ) -> List[VolatileRecordResponse]: """ Get all records with refresh schedules. Used by scheduler to determine what needs refreshing. Args: user: User identifier Returns: List of records with refresh_schedule set """ collection = self._collection_name(user) if not await self.qdrant.collection_exists(collection): return [] now_ms = self._current_timestamp_ms() scheduled = [] # Scroll through all non-expired records try: all_points = await self.qdrant.scroll_all_points( collection_name=collection, with_payload=True ) for point in all_points: payload = point.get("payload", {}) ttl_expiry = payload.get("ttl_expiry", 0) # Skip expired if ttl_expiry <= now_ms: continue # Only include if has refresh schedule if payload.get("refresh_schedule"): ttl_remaining = max(0, (ttl_expiry - now_ms) // 1000) scheduled.append(VolatileRecordResponse( key=payload["key"], namespace=payload["namespace"], data=payload.get("raw_data", {}), source=payload.get("source"), created_at=datetime.fromisoformat(payload["created_at"]), updated_at=datetime.fromisoformat(payload["updated_at"]), ttl=payload.get("ttl", 0), ttl_remaining=ttl_remaining, refresh_schedule=payload["refresh_schedule"], user=payload["user"], )) return scheduled except Exception as e: logger.error(f"Failed to get scheduled volatile records: {e}") return [] async def get_stats( self, user: str ) -> Dict[str, Any]: """ Get cache statistics for user. Args: user: User identifier Returns: Statistics dict """ collection = self._collection_name(user) if not await self.qdrant.collection_exists(collection): return { "total_records": 0, "by_namespace": {}, "scheduled_count": 0, "expired_count": 0, } now_ms = self._current_timestamp_ms() by_namespace: Dict[str, int] = {} total = 0 scheduled = 0 expired = 0 try: all_points = await self.qdrant.scroll_all_points( collection_name=collection, with_payload=True ) for point in all_points: payload = point.get("payload", {}) namespace = payload.get("namespace", "unknown") ttl_expiry = payload.get("ttl_expiry", 0) if ttl_expiry <= now_ms: expired += 1 else: total += 1 by_namespace[namespace] = by_namespace.get(namespace, 0) + 1 if payload.get("refresh_schedule"): scheduled += 1 return { "total_records": total, "by_namespace": by_namespace, "scheduled_count": scheduled, "expired_count": expired, } except Exception as e: logger.error(f"Failed to get volatile stats: {e}") return { "total_records": 0, "by_namespace": {}, "scheduled_count": 0, "expired_count": 0, } async def purge_expired( self, user: str ) -> int: """ Purge all expired volatile records for user. Args: user: User identifier Returns: Number of records purged """ collection = self._collection_name(user) if not await self.qdrant.collection_exists(collection): return 0 now_ms = self._current_timestamp_ms() return await self.qdrant.delete_expired_vectors(collection, now_ms) async def purge_all_expired(self) -> Dict[str, int]: """ Purge expired records from all volatile collections. Returns: Dict of collection -> purged count """ collections = await self.qdrant.get_volatile_collections() results = {} now_ms = self._current_timestamp_ms() for collection in collections: purged = await self.qdrant.delete_expired_vectors(collection, now_ms) if purged > 0: results[collection] = purged logger.info(f"Purged {purged} expired from {collection}") return results