1515import hashlib
1616import logging
1717import time
18+ from pathlib import Path
1819from typing import TYPE_CHECKING , Any
1920
2021import numpy as np
2122
2223from .base import BaseCache , CacheEntry , CacheStats
24+ from .storage import CacheStorage
2325
2426if TYPE_CHECKING :
2527 from sentence_transformers import SentenceTransformer
@@ -77,6 +79,7 @@ def __init__(
7779 similarity_threshold : float = 0.95 ,
7880 model_name : str = "all-MiniLM-L6-v2" ,
7981 device : str = "cpu" ,
82+ cache_dir : Path | None = None ,
8083 ):
8184 """Initialize hybrid cache.
8285
@@ -87,6 +90,7 @@ def __init__(
8790 similarity_threshold: Semantic similarity threshold (0.0-1.0, default: 0.95).
8891 model_name: Sentence transformer model (default: all-MiniLM-L6-v2).
8992 device: Device for embeddings ("cpu" or "cuda").
93+ cache_dir: Directory for persistent cache storage (default: ~/.empathy/cache/).
9094
9195 """
9296 super ().__init__ (max_size_mb , default_ttl )
@@ -106,9 +110,16 @@ def __init__(
106110 self ._model : SentenceTransformer | None = None
107111 self ._load_model ()
108112
113+ # Initialize persistent storage
114+ self ._storage = CacheStorage (cache_dir = cache_dir , max_disk_mb = max_size_mb )
115+
116+ # Load existing entries from storage into memory caches
117+ self ._load_from_storage ()
118+
109119 logger .info (
110120 f"HybridCache initialized (model: { model_name } , threshold: { similarity_threshold } , "
111- f"device: { device } , max_memory: { max_memory_mb } MB)"
121+ f"device: { device } , max_memory: { max_memory_mb } MB, "
122+ f"loaded: { len (self ._hash_cache )} entries from disk)"
112123 )
113124
114125 def _load_model (self ) -> None :
@@ -131,6 +142,34 @@ def _load_model(self) -> None:
131142 logger .warning ("Falling back to hash-only mode" )
132143 self ._model = None
133144
145+ def _load_from_storage (self ) -> None :
146+ """Load cached entries from persistent storage into memory caches."""
147+ try :
148+ # Get all non-expired entries from storage
149+ entries = self ._storage .get_all ()
150+
151+ if not entries :
152+ logger .debug ("No cached entries found in storage" )
153+ return
154+
155+ # Populate hash cache
156+ for entry in entries :
157+ self ._hash_cache [entry .key ] = entry
158+ self ._access_times [entry .key ] = entry .timestamp
159+
160+ logger .info (f"Loaded { len (entries )} entries from persistent storage into hash cache" )
161+
162+ # Populate semantic cache if model available
163+ if self ._model is not None :
164+ logger .debug ("Generating embeddings for cached prompts..." )
165+ # Note: We don't have the original prompts, so semantic cache
166+ # will be populated on-demand as cache hits occur
167+ # This is acceptable since semantic matching is secondary to hash matching
168+ logger .debug ("Semantic cache will be populated on-demand from hash hits" )
169+
170+ except Exception as e :
171+ logger .warning (f"Failed to load cache from storage: { e } , starting with empty cache" )
172+
134173 def get (
135174 self ,
136175 workflow : str ,
@@ -257,7 +296,7 @@ def put(
257296 response : Any ,
258297 ttl : int | None = None ,
259298 ) -> None :
260- """Store response in both hash and semantic caches.
299+ """Store response in both hash and semantic caches, and persist to disk .
261300
262301 Args:
263302 workflow: Workflow name.
@@ -295,22 +334,43 @@ def put(
295334 prompt_embedding = self ._model .encode (prompt , convert_to_numpy = True )
296335 self ._semantic_cache .append ((prompt_embedding , entry ))
297336
298- logger .debug (
299- f"Cache PUT (hybrid): { workflow } /{ stage } "
300- f"(hash_entries: { len (self ._hash_cache )} , "
301- f"semantic_entries: { len (self ._semantic_cache )} )"
302- )
337+ # Persist to disk storage
338+ try :
339+ self ._storage .put (entry )
340+ logger .debug (
341+ f"Cache PUT (hybrid): { workflow } /{ stage } "
342+ f"(hash_entries: { len (self ._hash_cache )} , "
343+ f"semantic_entries: { len (self ._semantic_cache )} , "
344+ f"persisted: True)"
345+ )
346+ except Exception as e :
347+ logger .warning (f"Failed to persist cache entry to disk: { e } " )
348+ logger .debug (
349+ f"Cache PUT (hybrid): { workflow } /{ stage } "
350+ f"(hash_entries: { len (self ._hash_cache )} , "
351+ f"semantic_entries: { len (self ._semantic_cache )} , "
352+ f"persisted: False)"
353+ )
303354
304355 def clear (self ) -> None :
305- """Clear all cached entries."""
356+ """Clear all cached entries from memory and disk ."""
306357 hash_count = len (self ._hash_cache )
307358 semantic_count = len (self ._semantic_cache )
308359
309360 self ._hash_cache .clear ()
310361 self ._access_times .clear ()
311362 self ._semantic_cache .clear ()
312363
313- logger .info (f"Cache cleared (hash: { hash_count } , semantic: { semantic_count } entries)" )
364+ # Clear persistent storage
365+ try :
366+ storage_count = self ._storage .clear ()
367+ logger .info (
368+ f"Cache cleared (hash: { hash_count } , semantic: { semantic_count } , "
369+ f"storage: { storage_count } entries)"
370+ )
371+ except Exception as e :
372+ logger .warning (f"Failed to clear persistent storage: { e } " )
373+ logger .info (f"Cache cleared (hash: { hash_count } , semantic: { semantic_count } entries)" )
314374
315375 def get_stats (self ) -> CacheStats :
316376 """Get cache statistics."""
0 commit comments