Spaces:
Runtime error
Runtime error
| """Duplicate detection using RapidFuzz pre-filter and sentence embeddings. | |
| PERFORMANCE NOTE (fixes the #1 bottleneck under concurrent load): | |
| The original implementation re-fetched the *entire* submissions table and | |
| re-embedded every candidate on every single /check-submission call. With | |
| 100+ participants hammering "Check & save" concurrently near the end of an | |
| event, that's O(table size) network + CPU work per request, on a 2 vCPU | |
| Hugging Face Space -- it queues up fast and starts hitting the frontend's | |
| 30s timeout. | |
| Instead we keep an in-process cache of (id -> text, id -> embedding) that is: | |
| - built once, lazily, on first use (or eagerly at startup via warmup_model) | |
| - updated incrementally in O(1) whenever this process inserts a new row | |
| (see add_to_cache, called from routers/submission.py right after insert) | |
| - refreshed from the DB on a cheap cadence (row-count check) so it also | |
| picks up rows inserted by another process/replica, without doing a full | |
| re-embed unless the count actually changed | |
| A threading.Semaphore bounds how many encode() calls can run at once, so a | |
| burst of concurrent checks doesn't oversubscribe the box's CPUs and starve | |
| other requests (login/submit) sharing the same thread pool. | |
| """ | |
| import logging | |
| import threading | |
| import time | |
| from typing import Optional, TypedDict | |
| import numpy as np | |
| from rapidfuzz import fuzz, process | |
| from sentence_transformers import SentenceTransformer | |
| from config import ( | |
| EMBEDDER_API_KEY, | |
| FUZZ_PREFILTER_THRESHOLD, | |
| FUZZ_TOP_K, | |
| MODEL_NAME, | |
| SIMILARITY_THRESHOLD, | |
| ) | |
| from database import count_all_submissions, fetch_all_submissions | |
| from services.embedder_client import encode_remote, is_remote_configured | |
| logger = logging.getLogger(__name__) | |
| SNIPPET_MAX_LEN = 100 | |
| _model: Optional[SentenceTransformer] = None | |
| # Cap how many encode() calls can run concurrently. Keep this <= vCPU count | |
| # on the host (HF free CPU-Basic = 2) so duplicate checks can't starve out | |
| # /login and /submit, which share the same uvicorn thread pool. | |
| _ENCODE_CONCURRENCY = 2 | |
| _encode_semaphore = threading.Semaphore(_ENCODE_CONCURRENCY) | |
| # Only re-poll the DB row count this often, so a burst of concurrent checks | |
| # doesn't turn into a burst of COUNT queries too. | |
| _CACHE_REFRESH_INTERVAL_SECONDS = 15 | |
| class _CorpusCache: | |
| def __init__(self) -> None: | |
| self._lock = threading.Lock() | |
| self._ready = False # ids/texts loaded, regardless of embedding availability | |
| self._ids: list[str] = [] | |
| self._texts: list[str] = [] | |
| self._embeddings: Optional[np.ndarray] = None # None => embeddings unavailable (degraded mode) | |
| self._known_ids: set[str] = set() | |
| self._last_count_check = 0.0 | |
| self._last_known_count: Optional[int] = None | |
| def _encode_texts(self, texts: list[str]) -> Optional[np.ndarray]: | |
| """Tiered fallback: remote embedder Space -> local in-process model -> | |
| None (embeddings unavailable; caller degrades to fuzzy-only). Never | |
| raises -- a dead embedder must never be able to break a check.""" | |
| if not texts: | |
| return np.zeros((0, 1)) | |
| remote = encode_remote(texts, api_key=EMBEDDER_API_KEY) | |
| if remote is not None: | |
| return remote | |
| if is_model_loaded(): | |
| with _encode_semaphore: | |
| return np.asarray(get_model().encode(texts, normalize_embeddings=True)) | |
| return None | |
| def _full_reload_locked(self) -> None: | |
| rows = fetch_all_submissions("id, text") | |
| ids: list[str] = [] | |
| texts: list[str] = [] | |
| seen: set[str] = set() | |
| for row in rows: | |
| row_id = row.get("id") | |
| row_text = (row.get("text") or "").strip() | |
| if row_id and row_text and row_id not in seen: | |
| seen.add(row_id) | |
| ids.append(row_id) | |
| texts.append(row_text) | |
| embeddings = self._encode_texts(texts) | |
| self._ids = ids | |
| self._texts = texts | |
| self._embeddings = embeddings | |
| self._known_ids = seen | |
| self._last_known_count = len(ids) | |
| self._ready = True | |
| logger.info( | |
| "Duplicate cache: full reload, %d rows, embeddings=%s", | |
| len(ids), | |
| "available" if embeddings is not None else "UNAVAILABLE (fuzzy-only fallback)", | |
| ) | |
| def ensure_fresh(self) -> None: | |
| """Cheap freshness check: only hit the DB for a row COUNT at most | |
| once per _CACHE_REFRESH_INTERVAL_SECONDS, and only pay for a full | |
| reload if that count actually changed (e.g. another process/replica | |
| inserted rows this one doesn't know about yet).""" | |
| now = time.monotonic() | |
| with self._lock: | |
| if not self._ready: | |
| try: | |
| self._full_reload_locked() | |
| except Exception as exc: | |
| # Supabase unreachable on the very first load (e.g. down | |
| # at startup, or the first /check-submission ever). This | |
| # must NOT propagate as an error on every check -- there's | |
| # simply nothing to compare against yet, which is a | |
| # legitimate (if degraded) state, not a failure. Mark the | |
| # cache ready with an empty corpus so callers get a normal | |
| # "no duplicates found" result instead of a 503, and so we | |
| # don't retry-and-fail this exact path on every single | |
| # request. The periodic count-check below will pick up | |
| # real data automatically once Supabase recovers. | |
| logger.warning( | |
| "Duplicate cache: initial load failed (Supabase unreachable?) -- " | |
| "treating as empty corpus until it recovers: %s", | |
| exc, | |
| ) | |
| self._ids, self._texts, self._embeddings = [], [], None | |
| self._known_ids = set() | |
| self._last_known_count = None | |
| self._ready = True | |
| self._last_count_check = now | |
| return | |
| if now - self._last_count_check < _CACHE_REFRESH_INTERVAL_SECONDS: | |
| return | |
| self._last_count_check = now | |
| try: | |
| current_count = count_all_submissions() | |
| except Exception as exc: | |
| logger.warning("Duplicate cache: could not check row count: %s", exc) | |
| return | |
| with self._lock: | |
| if current_count != self._last_known_count: | |
| self._full_reload_locked() | |
| elif self._embeddings is None: | |
| # Row count unchanged but we're in degraded (no-embeddings) | |
| # mode -- retry embedding periodically in case the remote | |
| # embedder's circuit breaker has since closed again. | |
| self._full_reload_locked() | |
| def add(self, row_id: str, text: str) -> None: | |
| """O(1)-ish incremental update after this process inserts a row -- | |
| no DB round-trip, just one embedding + one array append (or a plain | |
| text-only append if embeddings are currently unavailable).""" | |
| text = (text or "").strip() | |
| if not row_id or not text: | |
| return | |
| with self._lock: | |
| if row_id in self._known_ids or not self._ready: | |
| return | |
| self._ids.append(row_id) | |
| self._texts.append(text) | |
| self._known_ids.add(row_id) | |
| self._last_known_count = (self._last_known_count or 0) + 1 | |
| if self._embeddings is None: | |
| return # degraded mode; nothing to append embedding-wise | |
| embedding = self._encode_texts([text]) | |
| if embedding is None: | |
| # Embedder just went down between reload and now -- drop to | |
| # degraded mode for everyone until the next reload retries it. | |
| self._embeddings = None | |
| return | |
| self._embeddings = ( | |
| embedding if self._embeddings.shape[0] == 0 else np.vstack([self._embeddings, embedding]) | |
| ) | |
| def encode_texts(self, texts: list[str]) -> Optional[np.ndarray]: | |
| """Public wrapper so callers (e.g. check_duplicate for the query | |
| text itself) can reuse the same tiered fallback without duplicating it.""" | |
| return self._encode_texts(texts) | |
| def snapshot(self) -> tuple[list[str], list[str], Optional[np.ndarray]]: | |
| with self._lock: | |
| return ( | |
| list(self._ids), | |
| list(self._texts), | |
| self._embeddings.copy() if self._embeddings is not None else None, | |
| ) | |
| _corpus_cache = _CorpusCache() | |
| def add_to_cache(row_id: str, text: str) -> None: | |
| """Called right after a successful /submit insert so the new row is | |
| immediately visible to future duplicate checks without a DB round-trip.""" | |
| try: | |
| _corpus_cache.add(row_id, text) | |
| except Exception as exc: | |
| logger.warning("Duplicate cache: failed to add row %s: %s", row_id, exc) | |
| class DuplicateResult(TypedDict): | |
| flagged: bool | |
| similarity: float | |
| closest_match_snippet: str | |
| def warmup_model() -> None: | |
| """Load the embedding model once at startup -- unless a separate embedder | |
| Space is configured (EMBEDDER_URL), in which case we deliberately skip | |
| loading a model here at all. That's the whole point of splitting the | |
| embedder out: this backend should carry zero ML compute/memory when a | |
| remote embedder is available, and fall back to fuzzy-only matching (not | |
| a local model) if that remote Space is ever unreachable -- keeping this | |
| backend's resource footprint small and predictable under load.""" | |
| global _model | |
| if is_remote_configured(): | |
| logger.info( | |
| "EMBEDDER_URL is set — skipping local model load; using the " | |
| "remote embedder Space, with RapidFuzz-only fallback if it's unreachable." | |
| ) | |
| elif _model is None: | |
| logger.info("Loading embedding model: %s", MODEL_NAME) | |
| _model = SentenceTransformer(MODEL_NAME) | |
| logger.info("Embedding model loaded successfully") | |
| try: | |
| _corpus_cache.ensure_fresh() | |
| except Exception as exc: | |
| # Don't block startup on this -- ensure_fresh() will retry lazily on | |
| # the first real /check-submission call if the initial load fails | |
| # (e.g. Supabase not reachable yet during a cold boot race). | |
| logger.warning("Duplicate cache: initial warmup load failed, will retry lazily: %s", exc) | |
| def get_model() -> SentenceTransformer: | |
| if _model is None: | |
| raise RuntimeError("Embedding model is not loaded. Call warmup_model() at startup.") | |
| return _model | |
| def is_model_loaded() -> bool: | |
| return _model is not None | |
| def is_embedding_available() -> bool: | |
| """True if EITHER the remote embedder or a local model can currently | |
| serve encode() calls. Used by /check-submission to decide whether to | |
| 503 (nothing is even configured/loaded) vs. proceed in fuzzy-only mode | |
| (something is configured but temporarily unreachable -- that should | |
| never block a check, per this tool's own 'warnings never block' design).""" | |
| return is_remote_configured() or is_model_loaded() | |
| def _truncate_snippet(text: str, max_len: int = SNIPPET_MAX_LEN) -> str: | |
| cleaned = text.strip() | |
| if len(cleaned) <= max_len: | |
| return cleaned | |
| return cleaned[: max_len - 1].rstrip() + "…" | |
| def _empty_result() -> DuplicateResult: | |
| return { | |
| "flagged": False, | |
| "similarity": 0.0, | |
| "closest_match_snippet": "", | |
| } | |
| def _cosine_similarity(query_emb: np.ndarray, candidate_embs: np.ndarray) -> np.ndarray: | |
| return np.dot(candidate_embs, query_emb) | |
| def check_duplicate(text: str) -> DuplicateResult: | |
| """ | |
| Compare text against existing submissions. | |
| Uses RapidFuzz to narrow candidates, then embedding cosine similarity | |
| on the top fuzzy matches only. | |
| """ | |
| normalized = text.strip() | |
| if not normalized: | |
| return _empty_result() | |
| # Uses the in-process cache (see _CorpusCache above) instead of fetching | |
| # and re-embedding the whole table on every call -- this is the change | |
| # that makes /check-submission viable under 100+ concurrent participants. | |
| _corpus_cache.ensure_fresh() | |
| id_list, text_list, embeddings = _corpus_cache.snapshot() | |
| if not id_list: | |
| logger.info("Duplicate check: empty database, no candidates") | |
| return _empty_result() | |
| fuzzy_hits = process.extract( | |
| normalized, | |
| text_list, | |
| scorer=fuzz.ratio, | |
| limit=min(FUZZ_TOP_K, len(text_list)), | |
| ) | |
| candidate_indices: list[int] = [ | |
| index for _match_text, score, index in fuzzy_hits if score >= FUZZ_PREFILTER_THRESHOLD | |
| ] | |
| if not candidate_indices: | |
| logger.info("Duplicate check: no fuzzy candidates above threshold") | |
| return _empty_result() | |
| if embeddings is None: | |
| # Degraded mode: neither the remote embedder nor a local model is | |
| # currently available. Fall back to the fuzzy-ratio score itself as | |
| # the similarity signal rather than failing the check outright -- | |
| # this catches near-exact duplicates (typos, punctuation) even | |
| # without semantic embeddings, which is strictly better than no | |
| # duplicate protection at all while the embedder recovers. | |
| best_match = max( | |
| (fuzzy_hits[i] for i in range(len(fuzzy_hits)) if fuzzy_hits[i][2] in candidate_indices), | |
| key=lambda hit: hit[1], | |
| ) | |
| best_text, fuzzy_score, _ = best_match | |
| best_similarity = fuzzy_score / 100.0 | |
| flagged = best_similarity >= SIMILARITY_THRESHOLD | |
| logger.warning( | |
| "Duplicate check running in DEGRADED (fuzzy-only) mode — " | |
| "similarity=%.4f flagged=%s candidates=%d", | |
| best_similarity, | |
| flagged, | |
| len(candidate_indices), | |
| ) | |
| return { | |
| "flagged": flagged, | |
| "similarity": round(best_similarity, 4), | |
| "closest_match_snippet": _truncate_snippet(best_text), | |
| } | |
| query_emb = _corpus_cache.encode_texts([normalized]) | |
| if query_emb is None: | |
| # Embedder went down between the snapshot above and now -- treat as | |
| # degraded rather than crash; the cache will self-heal on next poll. | |
| return _empty_result() | |
| candidate_embs = embeddings[candidate_indices] | |
| candidate_texts = [text_list[i] for i in candidate_indices] | |
| similarities = _cosine_similarity(query_emb[0], candidate_embs) | |
| best_idx = int(np.argmax(similarities)) | |
| best_similarity = float(similarities[best_idx]) | |
| best_text = candidate_texts[best_idx] | |
| flagged = best_similarity >= SIMILARITY_THRESHOLD | |
| logger.info( | |
| "Duplicate check: similarity=%.4f flagged=%s candidates=%d", | |
| best_similarity, | |
| flagged, | |
| len(candidate_indices), | |
| ) | |
| return { | |
| "flagged": flagged, | |
| "similarity": round(best_similarity, 4), | |
| "closest_match_snippet": _truncate_snippet(best_text), | |
| } | |
| _QA_BATCH_CHUNK_SIZE = 64 # keep in sync with embedder/app.py's MAX_BATCH_SIZE | |
| def _encode_batched(texts: list[str], chunk_size: int = _QA_BATCH_CHUNK_SIZE) -> Optional[np.ndarray]: | |
| """Encode a (possibly large) list of texts in chunks, via the same | |
| tiered fallback as everything else. Returns None if embeddings are | |
| unavailable at all -- callers should degrade gracefully, not crash.""" | |
| if not texts: | |
| return np.zeros((0, 1)) | |
| chunks: list[np.ndarray] = [] | |
| for start in range(0, len(texts), chunk_size): | |
| chunk = _corpus_cache.encode_texts(texts[start : start + chunk_size]) | |
| if chunk is None: | |
| return None | |
| chunks.append(chunk) | |
| return np.vstack(chunks) | |
| def pairwise_duplicates( | |
| rows: list[dict], threshold: Optional[float] = None | |
| ) -> list[dict]: | |
| """Exhaustive duplicate detection for organizer QA batch.""" | |
| if threshold is None: | |
| threshold = SIMILARITY_THRESHOLD | |
| valid = [row for row in rows if (row.get("text") or "").strip()] | |
| flagged: list[dict] = [] | |
| if len(valid) < 2: | |
| return flagged | |
| texts = [row["text"] for row in valid] | |
| embeddings = _encode_batched(texts) | |
| if embeddings is None: | |
| logger.warning( | |
| "QA batch: embeddings unavailable (remote embedder unreachable and no " | |
| "local model loaded) -- skipping semantic duplicate detection for this run." | |
| ) | |
| return flagged | |
| similarities = embeddings @ embeddings.T | |
| for j in range(1, len(valid)): | |
| row_sims = similarities[j][:j] | |
| best_i = int(np.argmax(row_sims)) | |
| best_score = float(row_sims[best_i]) | |
| if best_score >= threshold: | |
| flagged.append( | |
| { | |
| "id": valid[j]["id"], | |
| "duplicate_of": valid[best_i]["id"], | |
| "similarity": round(best_score, 4), | |
| } | |
| ) | |
| return flagged | |