| """Caching. In-process LRU cache for diagnosis results keyed by image and metadata. |
| |
| For privacy, we hash the image bytes; the image itself is never persisted |
| in the cache. Identical scans produce identical hashes, giving us a simple |
| content-addressed cache. Metadata is hashed into the key so the same scan can |
| be diagnosed again when the user corrects film stock, storage, or confidence. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import hashlib |
| import json |
| import logging |
| import time |
| from collections import OrderedDict |
| from threading import Lock |
| from typing import Any |
|
|
| from config import get_app_config |
|
|
| logger = logging.getLogger(__name__) |
|
|
|
|
| class DiagnosisCache: |
| """Thread-safe LRU cache for diagnosis results.""" |
|
|
| def __init__(self, max_size: int = 64, ttl_seconds: int = 3600) -> None: |
| self._max_size = max_size |
| self._ttl = ttl_seconds |
| self._store: OrderedDict[str, tuple[float, dict]] = OrderedDict() |
| self._lock = Lock() |
| self._hits = 0 |
| self._misses = 0 |
|
|
| @staticmethod |
| def hash_image(image_bytes: bytes) -> str: |
| return hashlib.sha256(image_bytes).hexdigest() |
|
|
| @classmethod |
| def hash_key(cls, image_bytes: bytes, metadata: dict | None = None) -> str: |
| image_hash = cls.hash_image(image_bytes) |
| if not metadata: |
| return image_hash |
| metadata_json = json.dumps(metadata, sort_keys=True, separators=(",", ":")) |
| metadata_hash = hashlib.sha256(metadata_json.encode("utf-8")).hexdigest() |
| return f"{image_hash}:{metadata_hash}" |
|
|
| def get(self, image_bytes: bytes, metadata: dict | None = None) -> dict | None: |
| key = self.hash_key(image_bytes, metadata) |
| now = time.time() |
| with self._lock: |
| entry = self._store.get(key) |
| if entry is None: |
| self._misses += 1 |
| return None |
| ts, value = entry |
| if now - ts > self._ttl: |
| del self._store[key] |
| self._misses += 1 |
| return None |
| self._store.move_to_end(key) |
| self._hits += 1 |
| logger.info("Cache hit for %s", key[:25]) |
| return value |
|
|
| def put(self, image_bytes: bytes, value: dict, metadata: dict | None = None) -> None: |
| key = self.hash_key(image_bytes, metadata) |
| now = time.time() |
| with self._lock: |
| self._store[key] = (now, value) |
| self._store.move_to_end(key) |
| while len(self._store) > self._max_size: |
| self._store.popitem(last=False) |
|
|
| def stats(self) -> dict: |
| with self._lock: |
| return { |
| "size": len(self._store), |
| "max_size": self._max_size, |
| "hits": self._hits, |
| "misses": self._misses, |
| } |
|
|
| def clear(self) -> None: |
| with self._lock: |
| self._store.clear() |
| self._hits = 0 |
| self._misses = 0 |
|
|
|
|
| _default_cache: DiagnosisCache | None = None |
|
|
|
|
| def get_cache() -> DiagnosisCache: |
| global _default_cache |
| if _default_cache is None: |
| cfg = get_app_config() |
| _default_cache = DiagnosisCache( |
| max_size=cfg.cache_size, |
| ttl_seconds=cfg.cache_ttl_seconds, |
| ) |
| return _default_cache |
|
|
|
|
| __all__ = ["DiagnosisCache", "get_cache"] |
|
|