From 32a1c9d538cddcdff7f057815d9ef63421503d0e Mon Sep 17 00:00:00 2001 From: Andrew Yim Date: Tue, 28 Apr 2026 07:42:48 +0900 Subject: [PATCH] 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) --- .../code/scripts/_search_cache.py | 69 +++++++++++++++++++ .../code/scripts/test_notion_search.py | 69 +++++++++++++++++++ 2 files changed, 138 insertions(+) create mode 100644 custom-skills/31-notion-organizer/code/scripts/_search_cache.py diff --git a/custom-skills/31-notion-organizer/code/scripts/_search_cache.py b/custom-skills/31-notion-organizer/code/scripts/_search_cache.py new file mode 100644 index 0000000..4290703 --- /dev/null +++ b/custom-skills/31-notion-organizer/code/scripts/_search_cache.py @@ -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") diff --git a/custom-skills/31-notion-organizer/code/scripts/test_notion_search.py b/custom-skills/31-notion-organizer/code/scripts/test_notion_search.py index 1a2d109..1f0f2b5 100644 --- a/custom-skills/31-notion-organizer/code/scripts/test_notion_search.py +++ b/custom-skills/31-notion-organizer/code/scripts/test_notion_search.py @@ -58,11 +58,80 @@ def test_call_claude_raises_when_neither_available(): "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(): tests = [ test_call_claude_dispatches_to_sdk_when_available, test_call_claude_falls_back_to_cli_when_no_sdk, 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: print(f"\n{t.__name__}")