# Dependencies (add to requirements.txt on Spaces) # gradio pypdf pypdfium2 langchain-text-splitters sentence-transformers faiss-cpu transformers requests import base64 import hmac import hashlib import io import json import os import pickle import re import textwrap from collections import Counter from datetime import datetime, timezone from pathlib import Path from threading import Lock from typing import Any, Dict, List, Optional, Tuple from urllib.parse import parse_qs, urlparse from uuid import uuid4 import faiss import gradio as gr import numpy as np import pypdfium2 as pdfium import requests try: from gradio_rangeslider import RangeSlider except ImportError: RangeSlider = None from langchain_text_splitters import RecursiveCharacterTextSplitter from pypdf import PdfReader from sentence_transformers import SentenceTransformer from starlette.middleware import Middleware # --------------------------------------------------------------------------- # Persistent storage paths # --------------------------------------------------------------------------- def _can_write_to_dir(path: Path) -> bool: try: path.mkdir(parents=True, exist_ok=True) probe = path / ".write_probe" with probe.open("w", encoding="utf-8") as fh: fh.write("") probe.unlink(missing_ok=True) return True except OSError: return False def resolve_data_dir() -> Path: configured_dir = os.getenv("PERSIST_DIR", "").strip() candidates: List[Path] = [] if configured_dir: candidates.append(Path(configured_dir)) candidates.extend( [ Path("/data"), Path.cwd() / ".data", Path("/tmp/rp-chatbot-data"), ] ) seen_paths = set() for candidate in candidates: candidate_key = str(candidate) if candidate_key in seen_paths: continue seen_paths.add(candidate_key) if _can_write_to_dir(candidate): return candidate raise RuntimeError( "No writable data directory found. Set PERSIST_DIR to a writable path." ) DATA_DIR = resolve_data_dir() UPLOAD_DIR = DATA_DIR / "uploads" INDEX_PATH_TEMPLATE = "docs_{strategy}.faiss" META_PATH_TEMPLATE = "meta_{strategy}.pkl" LEGACY_INDEX_PATH = DATA_DIR / "docs.faiss" LEGACY_META_PATH = DATA_DIR / "meta.pkl" REGISTRY_PATH = DATA_DIR / "registry.json" FEEDBACK_PATH = DATA_DIR / "feedback.json" UPLOAD_DIR.mkdir(parents=True, exist_ok=True) # Thread-safety when running on Spaces INDEX_LOCK = Lock() REGISTRY_LOCK = Lock() FEEDBACK_LOCK = Lock() EMBEDDER_LOCK = Lock() MIGRATION_LOCK = Lock() MIGRATION_DONE = False # Embedding strategies (cosine similarity via normalized embeddings) EMBEDDING_STRATEGIES: Dict[str, Dict[str, str]] = { "fast_minilm_l6": { "label": "Fast MiniLM-L6", "model": os.getenv( "EMBED_MODEL_FAST", "sentence-transformers/all-MiniLM-L6-v2" ), }, "quality_mpnet": { "label": "Quality MPNet", "model": os.getenv("EMBED_MODEL_QUALITY", "sentence-transformers/all-mpnet-base-v2"), }, "qa_multilingual": { "label": "QA Multilingual", "model": os.getenv( "EMBED_MODEL_QA", "sentence-transformers/multi-qa-mpnet-base-dot-v1" ), }, } EMBEDDING_OPTIONS = [cfg["label"] for cfg in EMBEDDING_STRATEGIES.values()] EMBEDDING_LABEL_TO_KEY = { cfg["label"]: key for key, cfg in EMBEDDING_STRATEGIES.items() } DEFAULT_EMBEDDING_KEY = next(iter(EMBEDDING_STRATEGIES.keys())) DEFAULT_EMBEDDING_LABEL = EMBEDDING_STRATEGIES[DEFAULT_EMBEDDING_KEY]["label"] EMBEDDERS: Dict[str, SentenceTransformer] = {} # Text splitter config (tweak via env if needed) CHUNK_SIZE = int(os.getenv("CHUNK_SIZE", "800")) CHUNK_OVERLAP = int(os.getenv("CHUNK_OVERLAP", "120")) MIN_CHUNK_TEXT_LENGTH = int(os.getenv("MIN_CHUNK_TEXT_LENGTH", "80")) ACCESS_QUERY_PARAM = os.getenv("APP_ACCESS_QUERY_PARAM", "token") URL_ACCESS_TOKEN = os.getenv("APP_URL_TOKEN", "").strip() ACCESS_COOKIE_NAME = os.getenv("APP_ACCESS_COOKIE_NAME", "rag_access") ALLOW_LOOPBACK_BYPASS = os.getenv("APP_ALLOW_LOOPBACK_BYPASS", "1").strip().lower() in { "1", "true", "yes", "on", } IS_HF_SPACE = bool(os.getenv("SPACE_ID") or os.getenv("HF_SPACE_ID")) ENABLE_TOKEN_GATE_ON_SPACES = os.getenv( "APP_ENABLE_TOKEN_GATE_ON_SPACES", "0" ).strip().lower() in {"1", "true", "yes", "on"} TOKEN_GATE_ENABLED = bool(URL_ACCESS_TOKEN) and ( not IS_HF_SPACE or ENABLE_TOKEN_GATE_ON_SPACES ) def _get_env_float(name: str, default: float) -> float: try: return float(os.getenv(name, default)) except (TypeError, ValueError): return default RAG_MIN_SCORE = _get_env_float("RAG_MIN_SCORE", 0.15) def utc_now() -> datetime: return datetime.now(timezone.utc) def utc_year() -> int: return utc_now().year def utc_now_iso_z() -> str: return utc_now().isoformat(timespec="seconds").replace("+00:00", "Z") DEFAULT_CHAT_MODEL = os.getenv( "DEEPINFRA_RAG_MODEL", "meta-llama/Meta-Llama-3.1-8B-Instruct" ) REASONING_PRESETS = { "Fast (8B)": { "model": os.getenv( "DEEPINFRA_FAST_MODEL", "meta-llama/Meta-Llama-3.1-8B-Instruct" ), "temperature": _get_env_float("DEEPINFRA_FAST_TEMP", 0.1), "extra": {}, }, "Balanced (70B)": { "model": os.getenv( "DEEPINFRA_BALANCED_MODEL", "meta-llama/Meta-Llama-3.1-70B-Instruct" ), "temperature": _get_env_float("DEEPINFRA_BALANCED_TEMP", 0.15), "extra": {}, }, "Deep Reasoning (R1 Distill)": { "model": os.getenv( "DEEPINFRA_REASONING_MODEL", "deepseek-ai/DeepSeek-R1-Distill-Llama-70B" ), "temperature": _get_env_float("DEEPINFRA_REASONING_TEMP", 0.2), "extra": {"reasoning": {"effort": "medium"}}, }, } # --------------------------------------------------------------------------- # Registry helpers # --------------------------------------------------------------------------- def _load_registry() -> Dict: if not REGISTRY_PATH.exists(): return {"documents": {}} with REGISTRY_PATH.open("r", encoding="utf-8") as fh: return json.load(fh) def _save_registry(data: Dict) -> None: with REGISTRY_PATH.open("w", encoding="utf-8") as fh: json.dump(data, fh, indent=2, ensure_ascii=True) def load_registry_threadsafe() -> Dict: with REGISTRY_LOCK: return _load_registry() def update_registry(doc_id: str, payload: Dict) -> None: with REGISTRY_LOCK: registry = _load_registry() registry.setdefault("documents", {}) registry["documents"][doc_id] = payload _save_registry(registry) def remove_from_registry(doc_id: str) -> Optional[Dict]: with REGISTRY_LOCK: registry = _load_registry() documents = registry.get("documents", {}) document = documents.pop(doc_id, None) registry["documents"] = documents _save_registry(registry) return document def _load_feedback_registry() -> Dict: if not FEEDBACK_PATH.exists(): return {"feedback": {}} with FEEDBACK_PATH.open("r", encoding="utf-8") as fh: data = json.load(fh) if not isinstance(data, dict): return {"feedback": {}} feedback = data.get("feedback") if not isinstance(feedback, dict): data["feedback"] = {} return data def _save_feedback_registry(data: Dict) -> None: with FEEDBACK_PATH.open("w", encoding="utf-8") as fh: json.dump(data, fh, indent=2, ensure_ascii=True) def load_feedback_registry_threadsafe() -> Dict: with FEEDBACK_LOCK: return _load_feedback_registry() def create_feedback_entry(payload: Dict) -> Dict: with FEEDBACK_LOCK: store = _load_feedback_registry() feedback_map = store.setdefault("feedback", {}) feedback_id = payload.get("feedback_id") if not feedback_id: feedback_id = f"fb_{utc_now().strftime('%Y%m%d%H%M%S')}_{uuid4().hex[:8]}" payload["feedback_id"] = feedback_id feedback_map[feedback_id] = payload _save_feedback_registry(store) return payload def get_feedback_entry(feedback_id: str) -> Optional[Dict]: feedback_id = (feedback_id or "").strip() if not feedback_id: return None store = load_feedback_registry_threadsafe() feedback_map = store.get("feedback", {}) entry = feedback_map.get(feedback_id) return dict(entry) if isinstance(entry, dict) else None def delete_feedback_entry(feedback_id: str) -> Dict: feedback_id = (feedback_id or "").strip() if not feedback_id: return {"status": "error", "message": "Feedback ID is missing."} with FEEDBACK_LOCK: store = _load_feedback_registry() feedback_map = store.get("feedback", {}) removed = feedback_map.pop(feedback_id, None) store["feedback"] = feedback_map _save_feedback_registry(store) if not removed: return {"status": "error", "message": f"Feedback {feedback_id} not found."} return {"status": "deleted", "message": f"Deleted feedback {feedback_id}."} def mark_feedback_resolved(feedback_id: str) -> Dict: feedback_id = (feedback_id or "").strip() if not feedback_id: return {"status": "error", "message": "Feedback ID is missing."} with FEEDBACK_LOCK: store = _load_feedback_registry() feedback_map = store.get("feedback", {}) entry = feedback_map.get(feedback_id) if not isinstance(entry, dict): return {"status": "error", "message": f"Feedback {feedback_id} not found."} if entry.get("status") == "resolved": return {"status": "noop", "message": f"Feedback {feedback_id} is already resolved."} entry["status"] = "resolved" entry["resolved_at"] = utc_now_iso_z() feedback_map[feedback_id] = entry store["feedback"] = feedback_map _save_feedback_registry(store) return {"status": "resolved", "message": f"Marked feedback {feedback_id} as resolved."} def compute_sha256(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as fh: for block in iter(lambda: fh.read(8192), b""): digest.update(block) return digest.hexdigest() # --------------------------------------------------------------------------- # Vector index helpers # --------------------------------------------------------------------------- def strategy_paths(strategy_key: str) -> Tuple[Path, Path]: index_path = DATA_DIR / INDEX_PATH_TEMPLATE.format(strategy=strategy_key) meta_path = DATA_DIR / META_PATH_TEMPLATE.format(strategy=strategy_key) return index_path, meta_path def load_embedder(strategy_key: str) -> SentenceTransformer: with EMBEDDER_LOCK: existing = EMBEDDERS.get(strategy_key) if existing is not None: return existing strategy = EMBEDDING_STRATEGIES.get(strategy_key) if not strategy: raise ValueError(f"Unknown embedding strategy: {strategy_key}") model_id = strategy["model"] embedder = SentenceTransformer(model_id) EMBEDDERS[strategy_key] = embedder return embedder def encode_texts(texts: List[str], strategy_key: str) -> np.ndarray: embedder = load_embedder(strategy_key) vectors = embedder.encode(texts, normalize_embeddings=True) return np.asarray(vectors, dtype="float32") def load_index(strategy_key: str) -> Tuple[Optional[faiss.Index], List[Dict]]: index_path, meta_path = strategy_paths(strategy_key) if not index_path.exists() or not meta_path.exists(): return None, [] index = faiss.read_index(str(index_path)) with meta_path.open("rb") as fh: metadata = pickle.load(fh) return index, metadata def save_index(strategy_key: str, index: faiss.Index, metadata: List[Dict]) -> None: index_path, meta_path = strategy_paths(strategy_key) faiss.write_index(index, str(index_path)) with meta_path.open("wb") as fh: pickle.dump(metadata, fh) def clear_legacy_index_files() -> None: for path in (LEGACY_INDEX_PATH, LEGACY_META_PATH): try: path.unlink() except FileNotFoundError: continue def get_registry_doc_ids() -> set[str]: registry = load_registry_threadsafe() documents = registry.get("documents", {}) return set(documents.keys()) def ensure_strategy_indexes_initialized() -> None: """ Migrates legacy single-index storage into all configured embedding strategies. """ global MIGRATION_DONE with MIGRATION_LOCK: if MIGRATION_DONE: return MIGRATION_DONE = True if not (LEGACY_INDEX_PATH.exists() and LEGACY_META_PATH.exists()): return has_new_layout = False for strategy_key in EMBEDDING_STRATEGIES: index_path, meta_path = strategy_paths(strategy_key) if index_path.exists() and meta_path.exists(): has_new_layout = True break if has_new_layout: return try: with LEGACY_META_PATH.open("rb") as fh: legacy_metadata = pickle.load(fh) except Exception: return if not isinstance(legacy_metadata, list) or not legacy_metadata: clear_legacy_index_files() return active_doc_ids = get_registry_doc_ids() if active_doc_ids: legacy_metadata = [ item for item in legacy_metadata if item.get("doc_id") in active_doc_ids ] else: legacy_metadata = [] if not legacy_metadata: clear_index_files() clear_legacy_index_files() return for item in legacy_metadata: try: int(item.get("year")) except (TypeError, ValueError): item["year"] = get_registry_doc_year(item.get("doc_id")) or utc_year() for strategy_key in EMBEDDING_STRATEGIES: strategy_metadata = [dict(item) for item in legacy_metadata] rebuild_index_from_metadata(strategy_key, strategy_metadata) clear_legacy_index_files() # --------------------------------------------------------------------------- # OCR client for DeepInfra DeepSeek OCR # --------------------------------------------------------------------------- class DeepInfraOCR: def __init__(self) -> None: self.api_token = os.getenv("DEEPINFRA") self.model_id = os.getenv("DEEPINFRA_OCR_MODEL", "deepseek-ai/deepseek-ocr") base_url = os.getenv("DEEPINFRA_API_URL", "https://api.deepinfra.com/v1/inference") self.endpoint = base_url.rstrip("/") + f"/{self.model_id}" self.timeout = int(os.getenv("DEEPINFRA_TIMEOUT", "120")) def available(self) -> bool: return bool(self.api_token) def infer(self, image) -> str: """ Runs OCR on a single PIL image. Expects DeepInfra API to respond with JSON containing the recognized text. """ if not self.available(): raise RuntimeError("DeepInfra token not configured (DEEPINFRA).") buffer = io.BytesIO() image.save(buffer, format="PNG") image_base64 = base64.b64encode(buffer.getvalue()).decode("utf-8") payload = {"input": {"image": image_base64}} headers = { "Authorization": f"Bearer {self.api_token}", "Content-Type": "application/json", } response = requests.post( self.endpoint, headers=headers, json=payload, timeout=self.timeout, ) response.raise_for_status() data = response.json() text = self._extract_text(data) if not text: raise ValueError(f"DeepInfra OCR returned an empty result: {data}") return text.strip() @staticmethod def _extract_text(data: Dict) -> Optional[str]: """ DeepInfra responses may vary; try a few common response shapes. """ if data is None: return None if isinstance(data, str): return data for key in ("text", "output", "result"): value = data.get(key) if isinstance(value, str): return value if isinstance(value, dict): nested = ( value.get("text") or value.get("output") or value.get("result") or value.get("content") ) if isinstance(nested, str): return nested results = data.get("results") or data.get("outputs") or data.get("choices") if isinstance(results, list) and results: first = results[0] if isinstance(first, str): return first if isinstance(first, dict): return ( first.get("text") or first.get("output") or first.get("result") or first.get("content") or first.get("message", {}).get("content") ) return None OCR_CLIENT = DeepInfraOCR() # --------------------------------------------------------------------------- # PDF text extraction & chunking # --------------------------------------------------------------------------- def extract_pdf_text(pdf_path: Path) -> List[Dict]: """ Returns a list of {"page": int, "text": str} dictionaries. Falls back to OCR for pages where text extraction fails. """ reader = PdfReader(str(pdf_path)) pages: List[Dict] = [] ocr_candidates: List[int] = [] for idx, page in enumerate(reader.pages): raw_text = (page.extract_text() or "").strip() if len(raw_text) > 30: pages.append({"page": idx + 1, "text": raw_text}) else: pages.append({"page": idx + 1, "text": ""}) ocr_candidates.append(idx) if ocr_candidates and not OCR_CLIENT.available(): # Leave blank text; user will be notified about missing OCR capability. return pages if ocr_candidates: pdf = pdfium.PdfDocument(str(pdf_path)) for page_index in ocr_candidates: page = pdf.get_page(page_index) bitmap = page.render(scale=2.0, rotation=0) image = bitmap.to_pil() try: text = OCR_CLIENT.infer(image) except Exception as exc: pages[page_index]["text"] = "" pages[page_index]["error"] = str(exc) else: pages[page_index]["text"] = text return pages def extract_candidate_years(text: str) -> List[int]: current_year = utc_year() max_year = current_year + 1 years: List[int] = [] for match in re.findall(r"\b(19\d{2}|20\d{2}|21\d{2})\b", text or ""): year = int(match) if 1900 <= year <= max_year: years.append(year) return years def infer_publication_year(pdf_path: Path, doc_name: str, pages: List[Dict]) -> Tuple[int, str]: """ Extract publication year from metadata and front matter first. If unclear, use the most recent year mentioned in the document as a proxy. """ current_year = utc_year() metadata_years: List[int] = [] try: reader = PdfReader(str(pdf_path)) metadata = reader.metadata or {} for key, value in metadata.items(): if value is None: continue combined = f"{key} {value}" metadata_years.extend(extract_candidate_years(str(combined))) except Exception: pass filename_years = extract_candidate_years(doc_name) cover_text = "\n".join((p.get("text") or "") for p in pages[:3]) front_matter_years = extract_candidate_years(cover_text) explicit_years = metadata_years + filename_years + front_matter_years if explicit_years: year_counts = Counter(explicit_years) highest_count = max(year_counts.values()) tied_years = [year for year, count in year_counts.items() if count == highest_count] return max(tied_years), "explicit" full_text = "\n".join((p.get("text") or "") for p in pages) proxy_years = extract_candidate_years(full_text) if proxy_years: return max(proxy_years), "proxy_latest_mentioned" return current_year, "proxy_current_year" def chunk_pages( pages: List[Dict], doc_id: str, doc_name: str, doc_year: int ) -> List[Dict]: splitter = RecursiveCharacterTextSplitter( chunk_size=CHUNK_SIZE, chunk_overlap=CHUNK_OVERLAP ) chunks: List[Dict] = [] for page in pages: text = page.get("text", "").strip() if not text: continue page_chunks = splitter.split_text(text) for idx, chunk_text in enumerate(page_chunks, start=1): normalized = " ".join(chunk_text.split()) if len(normalized) < MIN_CHUNK_TEXT_LENGTH: continue chunks.append( { "text": normalized, "metadata": { "doc_id": doc_id, "doc_name": doc_name, "year": doc_year, "page": page["page"], "chunk_number": idx, }, } ) return chunks def add_chunks_to_indices(chunks: List[Dict]) -> Tuple[int, Dict[str, int]]: if not chunks: return 0, {} texts = [chunk["text"] for chunk in chunks] vectors_by_strategy: Dict[str, np.ndarray] = {} for strategy_key in EMBEDDING_STRATEGIES: vectors_by_strategy[strategy_key] = encode_texts(texts, strategy_key) with INDEX_LOCK: end_ids: Dict[str, int] = {} for strategy_key, vectors in vectors_by_strategy.items(): index, metadata = load_index(strategy_key) dim = vectors.shape[1] if index is None: index = faiss.IndexFlatIP(dim) metadata = [] elif index.d != dim: existing_texts = [item["text"] for item in metadata] index = faiss.IndexFlatIP(dim) if existing_texts: reencoded = encode_texts(existing_texts, strategy_key) index.add(reencoded) for row_idx, item in enumerate(metadata): item["vector_id"] = row_idx start_idx = len(metadata) index.add(vectors) for offset, chunk in enumerate(chunks): chunk_meta = { "vector_id": start_idx + offset, "text": chunk["text"], } chunk_meta.update(chunk["metadata"]) metadata.append(chunk_meta) save_index(strategy_key, index, metadata) end_ids[strategy_key] = start_idx + len(chunks) added = len(chunks) return added, end_ids # --------------------------------------------------------------------------- # Ingestion workflow # --------------------------------------------------------------------------- def ingest_pdf(path_str: str) -> Dict: ensure_strategy_indexes_initialized() pdf_path = Path(path_str) if not pdf_path.exists(): return {"status": "error", "message": f"File not found: {pdf_path}"} sha256 = compute_sha256(pdf_path) doc_id = sha256[:12] original_name = pdf_path.name registry = load_registry_threadsafe() existing = registry.get("documents", {}).get(doc_id) if existing: return { "status": "skipped", "doc_id": doc_id, "message": f"Already indexed ({existing['title']})", } persisted_path = UPLOAD_DIR / f"{doc_id}.pdf" if not persisted_path.exists(): persisted_path.write_bytes(pdf_path.read_bytes()) pages = extract_pdf_text(persisted_path) blank_pages = [p["page"] for p in pages if not p.get("text")] publication_year, year_source = infer_publication_year( persisted_path, original_name, pages ) chunks = chunk_pages( pages, doc_id=doc_id, doc_name=original_name, doc_year=publication_year ) added_chunks, end_ids = add_chunks_to_indices(chunks) document_record = { "doc_id": doc_id, "sha256": sha256, "title": original_name, "stored_path": str(persisted_path.relative_to(DATA_DIR)), "year": publication_year, "year_source": year_source, "pages": len(pages), "blank_pages": blank_pages, "chunks": added_chunks, "first_vector_ids": { key: (end_ids[key] - added_chunks) for key in end_ids }, "ingested_at": utc_now_iso_z(), } update_registry(doc_id, document_record) if blank_pages and OCR_CLIENT.available(): message = ( f"Indexed {original_name} ({publication_year}, {added_chunks} chunks). " f"OCR ran on pages: {blank_pages}" ) elif blank_pages: message = ( f"Indexed {original_name} ({publication_year}, {added_chunks} chunks) but " f"{len(blank_pages)} pages lacked text (set the DEEPINFRA token to enable OCR)." ) else: message = f"Indexed {original_name} ({publication_year}, {added_chunks} chunks)." return {"status": "indexed", "doc_id": doc_id, "message": message} def clear_index_files(strategy_key: Optional[str] = None) -> None: strategy_keys = [strategy_key] if strategy_key else list(EMBEDDING_STRATEGIES.keys()) for key in strategy_keys: index_path, meta_path = strategy_paths(key) for path in (index_path, meta_path): try: path.unlink() except FileNotFoundError: continue def rebuild_index_from_metadata(strategy_key: str, metadata: List[Dict]) -> None: if not metadata: clear_index_files(strategy_key=strategy_key) return texts = [m["text"] for m in metadata] vectors = encode_texts(texts, strategy_key) dim = vectors.shape[1] index = faiss.IndexFlatIP(dim) index.add(vectors) for idx, meta in enumerate(metadata): meta["vector_id"] = idx save_index(strategy_key, index, metadata) def rebuild_index_after_delete( strategy_key: str, index: faiss.Index, metadata: List[Dict], removed_doc_id: str ) -> None: if index is None or not metadata: clear_index_files(strategy_key=strategy_key) return total_vectors = int(getattr(index, "ntotal", 0)) usable_count = min(len(metadata), total_vectors) if usable_count <= 0: clear_index_files(strategy_key=strategy_key) return remaining_metadata: List[Dict] = [] remaining_vectors: List[np.ndarray] = [] for vector_idx in range(usable_count): item = metadata[vector_idx] if item.get("doc_id") == removed_doc_id: continue remaining_metadata.append(dict(item)) vector = index.reconstruct(vector_idx) remaining_vectors.append(np.asarray(vector, dtype="float32")) if not remaining_metadata: clear_index_files(strategy_key=strategy_key) return matrix = np.vstack(remaining_vectors).astype("float32") dim = matrix.shape[1] rebuilt_index = faiss.IndexFlatIP(dim) rebuilt_index.add(matrix) for new_idx, item in enumerate(remaining_metadata): item["vector_id"] = new_idx save_index(strategy_key, rebuilt_index, remaining_metadata) def delete_document(doc_id: str) -> Dict: ensure_strategy_indexes_initialized() document = remove_from_registry(doc_id) if not document: return {"status": "error", "doc_id": doc_id, "message": "Document not found."} title = document.get("title", doc_id) try: with INDEX_LOCK: for strategy_key in EMBEDDING_STRATEGIES: index, metadata = load_index(strategy_key) if not metadata or index is None: clear_index_files(strategy_key=strategy_key) continue rebuild_index_after_delete( strategy_key=strategy_key, index=index, metadata=metadata, removed_doc_id=doc_id, ) if not get_registry_doc_ids(): clear_index_files() clear_legacy_index_files() except Exception as exc: # Keep registry and index state consistent when deletion fails. update_registry(doc_id, document) return { "status": "error", "doc_id": doc_id, "message": f"Failed to remove {title}: {exc}", } stored_path = document.get("stored_path") if stored_path: stored_file = DATA_DIR / stored_path if stored_file.exists(): try: stored_file.unlink() except Exception: pass message = f"Removed {title}." return {"status": "deleted", "doc_id": doc_id, "message": message} def get_registry_doc_year(doc_id: Optional[str]) -> Optional[int]: if not doc_id: return None registry = load_registry_threadsafe() doc = registry.get("documents", {}).get(doc_id, {}) year_value = doc.get("year") try: return int(year_value) except (TypeError, ValueError): return None def resolve_hit_year(hit: Dict) -> int: try: return int(hit.get("year")) except (TypeError, ValueError): pass registry_year = get_registry_doc_year(hit.get("doc_id")) if registry_year is not None: hit["year"] = registry_year return registry_year fallback = utc_year() hit["year"] = fallback return fallback def select_diverse_hits(hits: List[Dict], top_k: int) -> List[Dict]: if len(hits) <= top_k: return hits per_doc_cap = max(2, top_k // 2) selected: List[Dict] = [] selected_ids = set() per_doc_counts: Dict[str, int] = {} for hit in hits: hit_id = ( hit.get("doc_id"), hit.get("page"), hit.get("chunk_number"), hit.get("vector_id"), ) if hit_id in selected_ids: continue doc_id = hit.get("doc_id", "") if per_doc_counts.get(doc_id, 0) >= per_doc_cap: continue selected.append(hit) selected_ids.add(hit_id) per_doc_counts[doc_id] = per_doc_counts.get(doc_id, 0) + 1 if len(selected) >= top_k: return selected for hit in hits: if len(selected) >= top_k: break hit_id = ( hit.get("doc_id"), hit.get("page"), hit.get("chunk_number"), hit.get("vector_id"), ) if hit_id in selected_ids: continue selected.append(hit) selected_ids.add(hit_id) return selected def _parse_year_range(year_from: Optional[int], year_to: Optional[int]) -> Tuple[int, int]: current_year = utc_year() from_year = int(year_from) if year_from is not None else 1900 to_year = int(year_to) if year_to is not None else current_year + 1 if from_year > to_year: from_year, to_year = to_year, from_year return from_year, to_year def parse_year_range_input(year_range: Any) -> Tuple[int, int]: year_from: Optional[int] = None year_to: Optional[int] = None if isinstance(year_range, (list, tuple)): if len(year_range) >= 1: try: year_from = int(year_range[0]) except (TypeError, ValueError): year_from = None if len(year_range) >= 2: try: year_to = int(year_range[1]) except (TypeError, ValueError): year_to = None elif year_from is not None: year_to = year_from elif year_range is not None: try: year_from = int(year_range) year_to = year_from except (TypeError, ValueError): year_from, year_to = None, None return _parse_year_range(year_from, year_to) # --------------------------------------------------------------------------- # Retrieval & LLM answer generation # --------------------------------------------------------------------------- def resolve_embedding_selection(selected_label: Optional[str]) -> Tuple[str, str]: strategy_key = EMBEDDING_LABEL_TO_KEY.get( (selected_label or "").strip(), DEFAULT_EMBEDDING_KEY ) strategy_label = EMBEDDING_STRATEGIES[strategy_key]["label"] return strategy_key, strategy_label def search_index( query: str, top_k: int = 5, strategy_key: str = DEFAULT_EMBEDDING_KEY, year_from: Optional[int] = None, year_to: Optional[int] = None, ) -> List[Dict]: ensure_strategy_indexes_initialized() active_doc_ids = get_registry_doc_ids() if not active_doc_ids: return [] from_year, to_year = _parse_year_range(year_from, year_to) with INDEX_LOCK: index, metadata = load_index(strategy_key) if index is None or not metadata: return [] query_vector = encode_texts([query], strategy_key) fetch_k = min(len(metadata), max(top_k * 8, 100)) if fetch_k < top_k: fetch_k = min(len(metadata), top_k) scores, ids = index.search(query_vector, fetch_k) candidates: List[Dict] = [] for score, idx in zip(scores[0], ids[0]): if idx == -1: continue meta = metadata[idx] doc_id = meta.get("doc_id") if doc_id not in active_doc_ids: continue hit = {**meta, "score": float(score)} year_value = resolve_hit_year(hit) if from_year <= year_value <= to_year: candidates.append(hit) if not candidates: return [] ranked = sorted(candidates, key=lambda item: item.get("score", 0.0), reverse=True) high_confidence = [item for item in ranked if item.get("score", 0.0) >= RAG_MIN_SCORE] retrieval_pool = high_confidence if high_confidence else ranked return select_diverse_hits(retrieval_pool, top_k)[:top_k] class DeepInfraChat: def __init__(self) -> None: self.api_token = os.getenv("DEEPINFRA") self.model_id = DEFAULT_CHAT_MODEL self.endpoint = os.getenv( "DEEPINFRA_CHAT_URL", "https://api.deepinfra.com/v1/openai/chat/completions" ) self.timeout = int(os.getenv("DEEPINFRA_TIMEOUT", "120")) def available(self) -> bool: return bool(self.api_token) def generate( self, messages: List[Dict], temperature: float = 0.1, model_override: Optional[str] = None, extra_payload: Optional[Dict] = None, ) -> str: if not self.available(): raise RuntimeError("Configure DEEPINFRA to enable answering.") payload = { "model": model_override or self.model_id, "messages": messages, "temperature": temperature, } if extra_payload: payload.update(extra_payload) headers = { "Authorization": f"Bearer {self.api_token}", "Content-Type": "application/json", } response = requests.post( self.endpoint, headers=headers, json=payload, timeout=self.timeout ) response.raise_for_status() data = response.json() choices = data.get("choices") if not choices: raise ValueError(f"DeepInfra chat returned no choices: {data}") content = choices[0].get("message", {}).get("content") if not content: raise ValueError(f"No content in response: {data}") return content.strip() CHAT_CLIENT = DeepInfraChat() def _token_matches(candidate: str) -> bool: if not URL_ACCESS_TOKEN: return False if not candidate: return False return hmac.compare_digest(candidate.strip(), URL_ACCESS_TOKEN) def _query_tokens_from_raw_query(raw_query: str) -> List[str]: tokens: List[str] = [] parsed = parse_qs(raw_query, keep_blank_values=True) tokens.extend(token for token in parsed.get(ACCESS_QUERY_PARAM, []) if token) # Keep raw values too so '+' and similar characters do not break matching. prefix = f"{ACCESS_QUERY_PARAM}=" for pair in raw_query.split("&"): if pair.startswith(prefix): raw_value = pair[len(prefix) :].strip() if raw_value: tokens.append(raw_value) return tokens def _query_tokens_from_scope(scope: Dict[str, Any]) -> List[str]: raw_query = (scope.get("query_string") or b"").decode("utf-8", errors="ignore") if not raw_query: return [] return _query_tokens_from_raw_query(raw_query) def _referer_query_tokens(scope: Dict[str, Any]) -> List[str]: for key, value in scope.get("headers", []): if key == b"referer": referer = value.decode("latin-1") parsed = urlparse(referer) return _query_tokens_from_raw_query(parsed.query) return [] def _cookies_from_scope(scope: Dict[str, Any]) -> Dict[str, str]: cookies: Dict[str, str] = {} for key, value in scope.get("headers", []): if key == b"cookie": header = value.decode("latin-1") for chunk in header.split(";"): if "=" not in chunk: continue cookie_key, cookie_value = chunk.strip().split("=", 1) cookies[cookie_key] = cookie_value return cookies def _decode_cookie_token(cookie_value: str) -> List[str]: if not cookie_value: return [] tokens = [cookie_value] if not cookie_value.startswith("b64."): return tokens encoded = cookie_value[4:] padded = encoded + ("=" * (-len(encoded) % 4)) try: decoded = base64.urlsafe_b64decode(padded.encode("ascii")).decode("utf-8") except Exception: return tokens if decoded: tokens.append(decoded) return tokens def _is_loopback_client(scope: Dict[str, Any]) -> bool: client = scope.get("client") if isinstance(client, (tuple, list)) and client: host = str(client[0]).lower() if host in {"127.0.0.1", "::1", "localhost"}: return True for key, value in scope.get("headers", []): if key == b"host": host_header = value.decode("latin-1").lower() if host_header.startswith("localhost:") or host_header.startswith("127.0.0.1:"): return True return False class QueryTokenGateMiddleware: def __init__(self, app) -> None: self.app = app async def __call__(self, scope, receive, send): scope_type = scope.get("type") if scope_type not in {"http", "websocket"}: await self.app(scope, receive, send) return if ALLOW_LOOPBACK_BYPASS and _is_loopback_client(scope): await self.app(scope, receive, send) return if not URL_ACCESS_TOKEN: await self.app(scope, receive, send) return # Gate only the landing page to avoid blocking internal Gradio assets/API # requests on Spaces, which can lead to a blank screen. path = str(scope.get("path") or "/") if path not in {"", "/"}: await self.app(scope, receive, send) return query_tokens = _query_tokens_from_scope(scope) referer_tokens = _referer_query_tokens(scope) cookies = _cookies_from_scope(scope) cookie_tokens = _decode_cookie_token(cookies.get(ACCESS_COOKIE_NAME, "")) token_candidates = query_tokens + referer_tokens + cookie_tokens token_valid = any(_token_matches(candidate) for candidate in token_candidates) if not token_valid: if scope_type == "websocket": await send({"type": "websocket.close", "code": 1008}) return body = f"""
Open this Space with a valid token in the URL query:
?{ACCESS_QUERY_PARAM}=YOUR_TOKEN