Spaces:
Sleeping
Sleeping
File size: 6,435 Bytes
dc1b199 faa8fb3 dc1b199 3c31a2a b76f199 879e4e0 3c31a2a dc1b199 b76f199 dc1b199 3c31a2a dc1b199 7d37f11 b76f199 879e4e0 b76f199 dc1b199 3c31a2a dc1b199 b76f199 879e4e0 7d37f11 3c31a2a b76f199 879e4e0 b76f199 3c31a2a 7d37f11 dc1b199 faa8fb3 dc1b199 3c31a2a dc1b199 3c31a2a dc1b199 3c31a2a dc1b199 3c31a2a dc1b199 3c31a2a dc1b199 3c31a2a dc1b199 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 | """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
|