feat(notion-search): add SHA256-keyed JSON file cache
Cache layer for rerank results. Key is SHA256 of query + sorted candidate IDs, so changing the candidate set automatically invalidates. 1-day TTL by default; corrupted cache files are silently dropped. 5 tests cover hit, miss, different-candidates, TTL expiry, corruption. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,69 @@
|
|||||||
|
"""Filesystem cache for rerank results, keyed by SHA256(query + candidate_ids)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
DEFAULT_CACHE_DIR = Path.home() / ".cache" / "notion-search"
|
||||||
|
DEFAULT_TTL_SECONDS = 86400 # 24 hours
|
||||||
|
|
||||||
|
|
||||||
|
def _cache_key(query: str, candidate_ids: List[str]) -> str:
|
||||||
|
payload = f"{query}|{','.join(sorted(candidate_ids))}"
|
||||||
|
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def cache_get(
|
||||||
|
query: str,
|
||||||
|
candidate_ids: List[str],
|
||||||
|
*,
|
||||||
|
cache_dir: Path = DEFAULT_CACHE_DIR,
|
||||||
|
ttl_seconds: int = DEFAULT_TTL_SECONDS,
|
||||||
|
) -> Optional[List]:
|
||||||
|
"""Return cached results if file exists and is fresh, else None.
|
||||||
|
|
||||||
|
Corrupted cache files are silently treated as misses (and removed).
|
||||||
|
"""
|
||||||
|
key = _cache_key(query, candidate_ids)
|
||||||
|
path = cache_dir / f"{key}.json"
|
||||||
|
if not path.exists():
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
try:
|
||||||
|
path.unlink()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
cached_at = data.get("cached_at_epoch", 0)
|
||||||
|
if time.time() - cached_at > ttl_seconds:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return data.get("results")
|
||||||
|
|
||||||
|
|
||||||
|
def cache_put(
|
||||||
|
query: str,
|
||||||
|
candidate_ids: List[str],
|
||||||
|
results: List,
|
||||||
|
*,
|
||||||
|
cache_dir: Path = DEFAULT_CACHE_DIR,
|
||||||
|
) -> None:
|
||||||
|
"""Write results to cache. Creates cache_dir if missing."""
|
||||||
|
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
key = _cache_key(query, candidate_ids)
|
||||||
|
path = cache_dir / f"{key}.json"
|
||||||
|
payload = {
|
||||||
|
"query": query,
|
||||||
|
"candidate_ids": list(candidate_ids),
|
||||||
|
"results": results,
|
||||||
|
"cached_at_epoch": time.time(),
|
||||||
|
}
|
||||||
|
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
@@ -58,11 +58,80 @@ def test_call_claude_raises_when_neither_available():
|
|||||||
"error mentions setup options")
|
"error mentions setup options")
|
||||||
|
|
||||||
|
|
||||||
|
def test_cache_miss_returns_none():
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
import _search_cache
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
result = _search_cache.cache_get("query", ["id1", "id2"], cache_dir=Path(tmpdir))
|
||||||
|
_assert(result is None, "cache miss returns None")
|
||||||
|
|
||||||
|
|
||||||
|
def test_cache_hit_returns_cached_results():
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
import _search_cache
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
cache_dir = Path(tmpdir)
|
||||||
|
results = [{"id": "abc", "title": "Test"}]
|
||||||
|
_search_cache.cache_put("query", ["id1", "id2"], results, cache_dir=cache_dir)
|
||||||
|
hit = _search_cache.cache_get("query", ["id1", "id2"], cache_dir=cache_dir)
|
||||||
|
_assert(hit == results, "cache hit returns the stored results")
|
||||||
|
|
||||||
|
|
||||||
|
def test_cache_miss_on_different_candidates():
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
import _search_cache
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
cache_dir = Path(tmpdir)
|
||||||
|
_search_cache.cache_put("query", ["id1", "id2"], [{"x": 1}], cache_dir=cache_dir)
|
||||||
|
miss = _search_cache.cache_get("query", ["id1", "id3"], cache_dir=cache_dir)
|
||||||
|
_assert(miss is None, "different candidate set hashes to different key")
|
||||||
|
|
||||||
|
|
||||||
|
def test_cache_miss_after_ttl_expires():
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
import _search_cache
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
cache_dir = Path(tmpdir)
|
||||||
|
_search_cache.cache_put("query", ["id1"], [{"x": 1}], cache_dir=cache_dir)
|
||||||
|
# TTL=0 means immediately expired
|
||||||
|
miss = _search_cache.cache_get("query", ["id1"], cache_dir=cache_dir, ttl_seconds=0)
|
||||||
|
_assert(miss is None, "expired cache returns None")
|
||||||
|
|
||||||
|
|
||||||
|
def test_cache_handles_corrupted_file():
|
||||||
|
import tempfile
|
||||||
|
import hashlib
|
||||||
|
from pathlib import Path
|
||||||
|
import _search_cache
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
cache_dir = Path(tmpdir)
|
||||||
|
cache_dir.mkdir(exist_ok=True)
|
||||||
|
# Write garbage to the expected key path
|
||||||
|
key = hashlib.sha256("query|id1".encode()).hexdigest()
|
||||||
|
(cache_dir / f"{key}.json").write_text("not json {{{")
|
||||||
|
result = _search_cache.cache_get("query", ["id1"], cache_dir=cache_dir)
|
||||||
|
_assert(result is None, "corrupted cache file returns None")
|
||||||
|
|
||||||
|
|
||||||
def run_all():
|
def run_all():
|
||||||
tests = [
|
tests = [
|
||||||
test_call_claude_dispatches_to_sdk_when_available,
|
test_call_claude_dispatches_to_sdk_when_available,
|
||||||
test_call_claude_falls_back_to_cli_when_no_sdk,
|
test_call_claude_falls_back_to_cli_when_no_sdk,
|
||||||
test_call_claude_raises_when_neither_available,
|
test_call_claude_raises_when_neither_available,
|
||||||
|
test_cache_miss_returns_none,
|
||||||
|
test_cache_hit_returns_cached_results,
|
||||||
|
test_cache_miss_on_different_candidates,
|
||||||
|
test_cache_miss_after_ttl_expires,
|
||||||
|
test_cache_handles_corrupted_file,
|
||||||
]
|
]
|
||||||
for t in tests:
|
for t in tests:
|
||||||
print(f"\n{t.__name__}")
|
print(f"\n{t.__name__}")
|
||||||
|
|||||||
Reference in New Issue
Block a user