File size: 6,138 Bytes
e1ced8e | 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 | """In-session crop cache β avoids redundant Gemini API calls for identical crops.
Stored in ``st.session_state`` so it persists across questions within a single
Streamlit session, but is discarded when the session ends.
Matching strategy:
- **Exact match** on ``(page_num, crop_instruction)`` is the primary lookup.
- **Fuzzy match** with a simple normalized overlap score handles cases where
the planner rephrases slightly (e.g., "Crop the gymnasium area" vs
"Crop gymnasium area showing diffusers"). Only matches above a high
threshold (0.85) are considered hits to avoid false positives.
"""
from __future__ import annotations
import logging
import re
from dataclasses import dataclass, field
from state import ImageRef
logger = logging.getLogger(__name__)
@dataclass
class CachedCrop:
"""A cached crop entry with its original instruction and result."""
page_num: int
crop_instruction: str
label: str
image_ref: ImageRef
# Normalised token set for fuzzy matching (computed once at insert time)
_tokens: frozenset[str] = field(default_factory=frozenset, repr=False)
def _normalise_tokens(text: str) -> frozenset[str]:
"""Lowercase, strip punctuation, split into a token set."""
cleaned = re.sub(r"[^a-z0-9\s]", "", text.lower())
return frozenset(cleaned.split())
def _token_overlap(a: frozenset[str], b: frozenset[str]) -> float:
"""Jaccard-style overlap: |intersection| / |union|."""
if not a or not b:
return 0.0
return len(a & b) / len(a | b)
class CropCache:
"""Session-scoped cache mapping (page, instruction) β ImageRef.
Thread-safe for concurrent reads (dict lookups under CPython's GIL) but
writes are serialised via the single-threaded Streamlit main thread.
"""
# Minimum token-overlap score to accept a fuzzy match.
# Tuned so that minor rephrasing (dropping "the", "all") still matches
# (~0.78 overlap) while genuinely different instructions miss (~0.06-0.42).
FUZZY_THRESHOLD = 0.70
def __init__(self) -> None:
# Primary index: exact (page_num, instruction) β CachedCrop
self._exact: dict[tuple[int, str], CachedCrop] = {}
# Secondary list for fuzzy scanning (same objects as _exact values)
self._entries: list[CachedCrop] = []
self._hit_count = 0
self._miss_count = 0
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def lookup(self, page_num: int, crop_instruction: str) -> ImageRef | None:
"""Return a cached ImageRef if a matching crop exists, else None.
Tries exact match first, then falls back to fuzzy token overlap
restricted to the same page.
"""
key = (page_num, crop_instruction)
# 1. Exact match
if key in self._exact:
self._hit_count += 1
entry = self._exact[key]
logger.info(
"CropCache HIT (exact) page=%d instruction='%s' β %s",
page_num, crop_instruction[:60], entry.image_ref["id"],
)
return entry.image_ref
# 2. Fuzzy match β only among entries on the same page
query_tokens = _normalise_tokens(crop_instruction)
best_score = 0.0
best_entry: CachedCrop | None = None
for entry in self._entries:
if entry.page_num != page_num:
continue
score = _token_overlap(query_tokens, entry._tokens)
if score > best_score:
best_score = score
best_entry = entry
if best_entry is not None and best_score >= self.FUZZY_THRESHOLD:
self._hit_count += 1
logger.info(
"CropCache HIT (fuzzy %.2f) page=%d instruction='%s' β %s",
best_score, page_num, crop_instruction[:60],
best_entry.image_ref["id"],
)
return best_entry.image_ref
self._miss_count += 1
return None
def register(
self,
page_num: int,
crop_instruction: str,
label: str,
image_ref: ImageRef,
*,
is_fallback: bool = False,
) -> None:
"""Register a successful crop in the cache.
Parameters
----------
is_fallback
If True, the crop is a full-page fallback (Gemini failed to crop).
These are NOT cached because they don't represent a useful targeted crop.
"""
if is_fallback:
logger.debug(
"CropCache SKIP (fallback) page=%d instruction='%s'",
page_num, crop_instruction[:60],
)
return
key = (page_num, crop_instruction)
if key in self._exact:
return # already cached
entry = CachedCrop(
page_num=page_num,
crop_instruction=crop_instruction,
label=label,
image_ref=image_ref,
_tokens=_normalise_tokens(crop_instruction),
)
self._exact[key] = entry
self._entries.append(entry)
logger.info(
"CropCache REGISTER page=%d instruction='%s' β %s",
page_num, crop_instruction[:60], image_ref["id"],
)
@property
def size(self) -> int:
return len(self._entries)
@property
def stats(self) -> str:
total = self._hit_count + self._miss_count
rate = (self._hit_count / total * 100) if total > 0 else 0
return (
f"CropCache: {self.size} entries, "
f"{self._hit_count} hits / {self._miss_count} misses "
f"({rate:.0f}% hit rate)"
)
def clear(self) -> None:
"""Reset the cache (e.g., when a new PDF is loaded)."""
self._exact.clear()
self._entries.clear()
self._hit_count = 0
self._miss_count = 0
|