""" Pure utility functions and backend HTTP client for the RAG frontend. This module deliberately has no Gradio dependency so it can be unit-tested in isolation (and run on Python versions where Gradio's dep chain is broken). """ import os import json import requests from typing import Optional, Generator, Tuple # ---------- Configuration Constants ---------- BACKEND_URLS = { "query": "https://carsonbytes--query.modal.run/", "debug": "https://carsonbytes--debug.modal.run/", "upload": "https://carsonbytes--upload.modal.run/", "health": "https://carsonbytes--health.modal.run/", "list": "https://carsonbytes--list.modal.run/", "delete": "https://carsonbytes--delete.modal.run/", } WARMUP_TIMEOUT = 15 QUERY_TIMEOUT = 60 UPLOAD_TIMEOUT = 120 MAX_FILE_SIZE = 10 * 1024 * 1024 MAX_QUESTION_LENGTH = 1000 # ---------- Sample Data ---------- def load_sample_data() -> dict: """Load TechVision Corp sample annual reports from JSON.""" data_path = os.path.join(os.path.dirname(__file__), "data", "sample_reports.json") with open(data_path, "r", encoding="utf-8") as f: return json.load(f) # ---------- Pure utility functions ---------- def get_backend_url(endpoint: str) -> str: return BACKEND_URLS.get(endpoint, BACKEND_URLS["query"]) def format_file_size(size_bytes: int) -> str: if size_bytes < 1024: return f"{size_bytes} bytes" elif size_bytes < 1024 * 1024: return f"{size_bytes / 1024:.1f} KB" else: return f"{size_bytes / (1024 * 1024):.1f} MB" def clean_answer(answer: str) -> str: """Strip leaked prompt-template markers from LLM output.""" for marker in ['[/INST]', '[INST]', '<>', '<>', '<', '<|im_end|>', '<|im_start|>']: if marker in answer: answer = answer[:answer.index(marker)] return answer.strip() def update_index_display(docs: list) -> str: """Human-readable status for the index display markdown component.""" if not docs: return "📄 *No documents indexed yet*" count = len(docs) names = ", ".join(f"`{d}`" for d in docs[:3]) suffix = f" (and {count - 3} more)" if count > 3 else "" return f"📚 **{count} document{'s' if count != 1 else ''} indexed:** {names}{suffix}" # ---------- File extraction ---------- def _extract_pdf_text(path: str) -> str: from pypdf import PdfReader reader = PdfReader(path) parts = [] for page in reader.pages: try: parts.append(page.extract_text() or "") except Exception: continue return "\n\n".join(parts) def _extract_docx_text(path: str) -> str: import docx doc = docx.Document(path) paragraphs = [p.text for p in doc.paragraphs if p.text] for table in doc.tables: for row in table.rows: for cell in row.cells: if cell.text: paragraphs.append(cell.text) return "\n\n".join(paragraphs) def extract_text_from_file(path: str) -> Tuple[Optional[str], Optional[str]]: """Return (text, error). Supports .txt, .pdf, .docx.""" ext = os.path.splitext(path)[1].lower() try: if ext == ".txt": with open(path, "r", encoding="utf-8") as f: return f.read(), None if ext == ".pdf": return _extract_pdf_text(path), None if ext == ".docx": return _extract_docx_text(path), None return None, f"❌ Unsupported file type: {ext}. Use .txt, .pdf, or .docx." except UnicodeDecodeError: return None, "❌ Could not decode .txt file. Please use UTF-8 encoding." except Exception as e: return None, f"❌ Failed to read {ext} file: {e}" def resolve_file_path(file_obj) -> Optional[str]: """gr.File may return a str path (Gradio 6+) or an object with .name (older).""" if file_obj is None: return None if isinstance(file_obj, str): return file_obj return getattr(file_obj, "name", None) or getattr(file_obj, "path", None) # ---------- Backend HTTP client ---------- def check_health() -> Tuple[bool, list]: """GET /health. Returns (index_exists, docs_list).""" try: resp = requests.get(get_backend_url("health"), timeout=WARMUP_TIMEOUT) if resp.status_code == 200: data = resp.json() docs = data.get("indexed_docs", []) return data.get("index_exists", len(docs) > 0), docs return False, [] except Exception: return False, [] def fetch_indexed_docs() -> list: """GET /list. Returns list of indexed doc IDs (empty on error).""" try: resp = requests.get(get_backend_url("list"), timeout=WARMUP_TIMEOUT) if resp.status_code == 200: return resp.json().get("docs", []) return [] except Exception: return [] def upload_text_to_backend(text_content: str, filename: str) -> Tuple[bool, Optional[str], Optional[int], str]: """ POST /upload. Returns (success, filename, char_count, message). Returns the ORIGINAL filename (matches /list output) so the dropdown can select the freshly uploaded doc without a name mismatch. """ payload = {"text": text_content, "filename": filename} try: response = requests.post( get_backend_url("upload"), json=payload, timeout=UPLOAD_TIMEOUT, ) response.raise_for_status() data = response.json() if data.get("status") == "success": returned_name = data.get("filename") or filename char_count = data.get("char_count") return True, returned_name, char_count, f"✅ Indexed **{returned_name}**" return False, None, None, f"❌ {data.get('message', 'Upload failed')}" except requests.exceptions.Timeout: return False, None, None, "❌ Upload timed out. Please try a smaller file." except Exception as e: return False, None, None, f"❌ Upload failed: {str(e)}" def delete_document(filename: str) -> Tuple[bool, list, str]: """ POST /delete. Removes the named document from the index. Returns (success, remaining_docs, message). """ if not filename: return False, [], "⚠️ No document selected." try: response = requests.post( get_backend_url("delete"), json={"filename": filename}, timeout=UPLOAD_TIMEOUT, ) response.raise_for_status() data = response.json() if data.get("status") == "success": return True, data.get("indexed_docs", []), f"🗑️ Removed **{filename}**" if data.get("status") == "not_found": return False, data.get("indexed_docs", []), f"⚠️ '{filename}' not found." return False, [], f"❌ {data.get('message', 'Delete failed')}" except requests.exceptions.Timeout: return False, [], "❌ Delete timed out." except Exception as e: return False, [], f"❌ Delete failed: {str(e)}" def upload_pasted_text(text: str, filename: str = "pasted.txt") -> Tuple[bool, Optional[str], Optional[int], str]: """Validate and upload pasted text directly.""" if not text or not text.strip(): return False, None, None, "⚠️ Please paste some text first." filename = (filename or "pasted.txt").strip() or "pasted.txt" if not filename.endswith(".txt"): filename += ".txt" return upload_text_to_backend(text, filename) def upload_document(file_obj, max_file_size: int = MAX_FILE_SIZE) -> Tuple[bool, Optional[str], Optional[int], str]: """Parse a file object (path or Gradio file) and upload its extracted text.""" path = resolve_file_path(file_obj) if not path: return False, None, None, "❌ No file selected." if not os.path.exists(path): return False, None, None, f"❌ File not found: {path}" file_size = os.path.getsize(path) if file_size > max_file_size: return False, None, None, f"❌ File too large. Max {format_file_size(max_file_size)}." text_content, err = extract_text_from_file(path) if err: return False, None, None, err if not text_content or not text_content.strip(): return False, None, None, "❌ File is empty or no extractable text found." base = os.path.basename(path) stem, _ext = os.path.splitext(base) filename = f"{stem}.txt" return upload_text_to_backend(text_content, filename) def query_backend(message: str, doc_ids=None) -> Generator[str, None, None]: """ POST /query. Streams status, then yields the final answer. `doc_ids` may be: - None or [] → search across all indexed documents - a single filename string → filter to that one doc (legacy) - a list of filename strings → filter to any of those docs (OR) """ if not message or not message.strip(): return if len(message) > MAX_QUESTION_LENGTH: message = message[:MAX_QUESTION_LENGTH] yield "🤔 Thinking..." # Normalise doc_ids to a clean list of strings. if doc_ids is None: id_list = [] elif isinstance(doc_ids, str): id_list = [doc_ids] if doc_ids.strip() else [] else: id_list = [str(d).strip() for d in doc_ids if d and str(d).strip()] try: payload: dict = {"question": message} if id_list: payload["doc_ids"] = id_list response = requests.post( get_backend_url("query"), json=payload, timeout=QUERY_TIMEOUT, ) response.raise_for_status() data = response.json() answer = clean_answer(data.get("answer", "⚠️ Received an empty response.")) # Show which document(s) the answer came from. sources = data.get("sources") or [] if sources: src_chips = " ".join(f"`{s}`" for s in sources) answer += f"\n\n📎 Sources: {src_chips}" elapsed = data.get("elapsed") if elapsed is not None: answer += f"\n⏱️ {elapsed:.1f}s" yield answer except requests.exceptions.Timeout: yield "❌ Query timed out. Please try again." except requests.exceptions.ConnectionError: yield "❌ Cannot connect to backend. Please try again later." except Exception as e: yield f"❌ Error: {str(e)}" def load_index_state() -> Tuple[bool, list, Optional[str], str]: """Page-load helper. Returns (is_indexed, docs, active_doc, display_text).""" is_indexed, docs = check_health() active = docs[0] if docs else None return is_indexed, docs, active, update_index_display(docs)