"""File-backed section-level cache keyed by SHA-256(template_id + bullets). Cache entries are JSON files stored in ``settings.cache_dir``. The cache prevents duplicate LLM calls for identical inputs, which is important for both cost control and determinism in concurrent requests. """ import hashlib import json import logging from pathlib import Path from typing import Any, cast from app.config import settings logger = logging.getLogger(__name__) def _cache_path(key: str) -> Path: return settings.cache_dir / f"{key}.json" def compute_cache_key( template_id: str, bullets: list[str], tenant_id: str, ai_level: int = 3, ai_percent: int | None = None, reference_document_ids: list[str] | None = None, draft_paragraph: str | None = None, rics_survey_level: int | None = None, interference_level: str | None = None, ) -> str: """Compute a stable SHA-256 cache key from tenant, template ID, bullets, and ai_level. Including ``tenant_id`` ensures cached results are never served to a different tenant. Including ``ai_level`` prevents a RAG-only result (level 1) being served to a request that asked for full AI adaptation (level 5) — the two produce materially different outputs. ``ai_percent`` (0–100) is the preferred control. When omitted, it is derived from ``ai_level`` in 25-point increments for backward compatibility. Reference document IDs and an optional draft paragraph are part of the key so cache hits do not ignore exemplar PDFs or a surveyor's style anchor. Args: template_id: RICS section code (e.g. ``"E4"`` for Main walls). bullets: Ordered list of fact bullets. tenant_id: Owning tenant identifier. ai_level: Legacy AI interference level 1–5 (default 3 = balanced). ai_percent: AI involvement intensity 0–100 (preferred; overrides ai_level). interference_level: Qualitative mode minimum | medium | maximum (part of cache key). reference_document_ids: Additional uploads used for layered RAG ordering. draft_paragraph: Optional style-anchor paragraph. Returns: 64-character hexadecimal SHA-256 digest. Example:: key = compute_cache_key("E4", ["solid brick 275mm", "DPC visible"], "tenant_abc", ai_level=3) """ refs = sorted(reference_document_ids or []) draft_key = (draft_paragraph or "").strip()[:8000] pct = None if ai_percent is not None: try: pct = int(ai_percent) except Exception: # noqa: BLE001 pct = None if pct is None: # Derive from 1–5 legacy scale lvl = max(1, min(5, int(ai_level))) pct = int((lvl - 1) * 25) pct = max(0, min(100, int(pct))) rics_lvl: int | None = None if rics_survey_level is not None: try: rics_lvl = max(1, min(3, int(rics_survey_level))) except Exception: # noqa: BLE001 rics_lvl = None tier_key: str | None = None if interference_level is not None: t = str(interference_level).strip().lower() if t == "minimal": t = "minimum" tier_key = t if t in ("minimum", "medium", "maximum") else None payload = json.dumps( { "tenant_id": tenant_id, "template_id": template_id, "bullets": bullets, "ai_level": int(ai_level), "ai_percent": int(pct), "interference_level": tier_key, "reference_document_ids": refs, "draft_paragraph": draft_key, "rics_survey_level": rics_lvl, }, sort_keys=True, ) return hashlib.sha256(payload.encode()).hexdigest() def get(key: str) -> dict[str, Any] | None: """Retrieve a cached section payload if it exists. Args: key: SHA-256 cache key from :func:`compute_cache_key`. Returns: Cached ``dict`` with keys ``text``, ``confidence``, ``provenance``, or ``None`` on a cache miss. Example:: data = get(key) if data: return data["text"] """ path = _cache_path(key) if not path.exists(): logger.debug("Cache miss: %s", key[:12]) return None try: with path.open() as f: data = json.load(f) logger.debug("Cache hit: %s", key[:12]) return cast(dict[str, Any], data) except (json.JSONDecodeError, OSError) as exc: logger.warning("Corrupt cache entry %s: %s", key[:12], exc) return None def set(key: str, payload: dict[str, Any]) -> None: # noqa: A001 """Write a section payload to the cache atomically. Uses a write-to-temp-then-rename strategy so a crash mid-write never leaves a corrupt cache file. ``Path.replace()`` is atomic on POSIX and best-effort atomic on Windows (same volume, no open readers). Args: key: SHA-256 cache key. payload: Dict to serialise, e.g. ``{"text": "…", "confidence": 0.9, "provenance": []}``. Example:: set(key, {"text": "The property…", "confidence": 0.85, "provenance": []}) """ settings.cache_dir.mkdir(parents=True, exist_ok=True) path = _cache_path(key) tmp = path.with_suffix(".tmp") try: with tmp.open("w") as f: json.dump(payload, f) tmp.replace(path) # atomic rename logger.debug("Cached section: %s", key[:12]) except OSError as exc: logger.error("Failed to write cache entry %s: %s", key[:12], exc) tmp.unlink(missing_ok=True) def invalidate(key: str) -> bool: """Delete a single cache entry. Args: key: SHA-256 cache key to remove. Returns: ``True`` if the entry existed and was removed, ``False`` otherwise. Example:: invalidate(compute_cache_key("E4", bullets)) """ path = _cache_path(key) if path.exists(): path.unlink() logger.debug("Invalidated cache: %s", key[:12]) return True return False def clear_all() -> int: """Remove all cache entries from ``settings.cache_dir``. Returns: Number of entries removed. Example:: removed = clear_all() """ if not settings.cache_dir.exists(): return 0 count = 0 for path in settings.cache_dir.glob("*.json"): path.unlink() count += 1 logger.info("Cleared %d cache entries", count) return count