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