Spaces:
Sleeping
Sleeping
| """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 | |