Skip to content

Commit 1bad1e5

Browse files
feat: Add cache persistence and fix dashboard config
Cache Persistence: - Integrate CacheStorage into HybridCache for persistent disk storage - Load existing entries from ~/.empathy/cache/responses.json on startup - Persist new entries to disk after each put operation - All 56 cache unit tests pass Dashboard Config Fix: - Add extra="ignore" to Pydantic Settings to allow extra env vars - Fixes ValidationError preventing test collection Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 731ec86 commit 1bad1e5

3 files changed

Lines changed: 79 additions & 15 deletions

File tree

dashboard/backend/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ class Config:
9494
env_file = ".env"
9595
env_file_encoding = "utf-8"
9696
case_sensitive = False
97+
extra = "ignore" # Ignore extra environment variables (like API keys)
9798

9899
def model_post_init(self, __context) -> None:
99100
"""Post-initialization validation and setup."""

src/empathy_os/cache/hybrid.py

Lines changed: 69 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,13 @@
1515
import hashlib
1616
import logging
1717
import time
18+
from pathlib import Path
1819
from typing import TYPE_CHECKING, Any
1920

2021
import numpy as np
2122

2223
from .base import BaseCache, CacheEntry, CacheStats
24+
from .storage import CacheStorage
2325

2426
if 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."""

tests/unit/cache/test_hybrid_cache.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ def encode_side_effect(text, **kwargs):
8282
assert cache.stats.hits == 1
8383

8484
@patch("sentence_transformers.SentenceTransformer")
85-
def test_semantic_cache_miss_below_threshold(self, mock_st):
85+
def test_semantic_cache_miss_below_threshold(self, mock_st, tmp_path):
8686
"""Test semantic cache miss when similarity below threshold."""
8787
mock_model = Mock()
8888
mock_st.return_value = mock_model
@@ -100,7 +100,8 @@ def encode_side_effect(text, **kwargs):
100100

101101
mock_model.encode.side_effect = encode_side_effect
102102

103-
cache = HybridCache(similarity_threshold=0.95)
103+
# Use isolated cache directory
104+
cache = HybridCache(similarity_threshold=0.95, cache_dir=tmp_path / "cache")
104105

105106
# Store
106107
cache.put("code-review", "scan", "prompt1", "sonnet", {"id": 1})
@@ -139,7 +140,7 @@ def test_semantic_only_matches_same_workflow_stage_model(self, mock_st):
139140
assert result is None
140141

141142
@patch("sentence_transformers.SentenceTransformer")
142-
def test_semantic_hit_adds_to_hash_cache(self, mock_st):
143+
def test_semantic_hit_adds_to_hash_cache(self, mock_st, tmp_path):
143144
"""Test that semantic hits are added to hash cache for future fast lookups."""
144145
mock_model = Mock()
145146
mock_st.return_value = mock_model
@@ -148,7 +149,8 @@ def test_semantic_hit_adds_to_hash_cache(self, mock_st):
148149
embedding = np.array([1.0, 0.0, 0.0])
149150
mock_model.encode.return_value = embedding
150151

151-
cache = HybridCache(similarity_threshold=0.90)
152+
# Use isolated cache directory
153+
cache = HybridCache(similarity_threshold=0.90, cache_dir=tmp_path / "cache")
152154

153155
# Store original
154156
cache.put("code-review", "scan", "prompt1", "sonnet", {"id": 1})
@@ -221,13 +223,14 @@ def test_lru_eviction_both_caches(self, mock_st):
221223
assert len(cache._semantic_cache) < 200
222224

223225
@patch("sentence_transformers.SentenceTransformer")
224-
def test_size_info(self, mock_st):
226+
def test_size_info(self, mock_st, tmp_path):
225227
"""Test cache size information."""
226228
mock_model = Mock()
227229
mock_st.return_value = mock_model
228230
mock_model.encode.return_value = np.array([1.0, 0.0, 0.0])
229231

230-
cache = HybridCache(similarity_threshold=0.95)
232+
# Use isolated cache directory to avoid loading existing entries
233+
cache = HybridCache(similarity_threshold=0.95, cache_dir=tmp_path / "cache")
231234

232235
# Add entries
233236
for i in range(10):

0 commit comments

Comments
 (0)