RICS / app /cache /section_cache.py
StormShadow308's picture
feat: AI interference levels (minimum|medium|maximum) UI, API, prompts, generation
879e4e0
Raw
History Blame Contribute Delete
6.44 kB
"""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