Spaces:
Paused
Paused
| """ | |
| FastAPI backend for the Multimodal RAG system. | |
| Exposes endpoints for document management and querying. | |
| """ | |
| import os | |
| # On local dev (no SPACE_ID env var) default the embedding model to CPU. | |
| # MPS (Apple Silicon GPU) can segfault after a previous crash leaves the Metal | |
| # driver in a bad state. CPU is fast enough for local dev. | |
| if not os.environ.get("SPACE_ID"): | |
| os.environ.setdefault("TORCH_DEVICE", "cpu") | |
| import asyncio | |
| import logging | |
| import re | |
| import shutil | |
| from pathlib import Path | |
| from typing import List, Optional | |
| import threading | |
| from fastapi import FastAPI, UploadFile, File, HTTPException, Form, BackgroundTasks | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from fastapi.responses import JSONResponse | |
| from pydantic import BaseModel | |
| from utils.document_processor import process_document_chunked, SUPPORTED_EXTENSIONS, extract_dataframes, extract_images | |
| from utils.vector_store import VectorStoreManager | |
| from utils.rag_engine import RAGEngine, BACKEND, RELEVANCE_THRESHOLD | |
| from utils.memory import ConversationMemory, estimate_tokens | |
| from utils.device import device_info | |
| from utils.table_store import TableStore | |
| from utils.image_store import ImageStore | |
| # ─── Configuration ──────────────────────────────────────────────────────────── | |
| DATA_DIR = os.environ.get("DATA_DIR", "./data") | |
| VECTORSTORE_DIR = os.environ.get("VECTORSTORE_DIR", "./vectorstore") | |
| OLLAMA_MODEL = os.environ.get("OLLAMA_MODEL", "llama3.2") | |
| # HF Hub Dataset used for persistent user-uploaded file storage. | |
| # Set HF_DATASET_REPO (e.g. "yourname/MyApp-data") and HF_TOKEN as Space secrets. | |
| # Files uploaded via the app are pushed here and re-downloaded on every cold start, | |
| # so they survive container restarts and redeployments. | |
| HF_DATASET_REPO = os.environ.get("MultiModalRag_dataset", "") | |
| HF_TOKEN = os.environ.get("MultiModalRag_Token", "") | |
| os.makedirs(DATA_DIR, exist_ok=True) | |
| os.makedirs(VECTORSTORE_DIR, exist_ok=True) | |
| # ─── HF Hub Persistent Storage Helpers ─────────────────────────────────────── | |
| def _hf_api(): | |
| """Return a configured HfApi instance, or None if not set up.""" | |
| if HF_DATASET_REPO and HF_TOKEN: | |
| from huggingface_hub import HfApi | |
| return HfApi(token=HF_TOKEN) | |
| return None | |
| def sync_from_hf_hub(): | |
| """Download user-uploaded files from HF Hub dataset to data dir on startup. | |
| Only downloads files that don't already exist locally (committed files win). | |
| """ | |
| api = _hf_api() | |
| if not api: | |
| print("[STARTUP] sync_data: SKIPPED — HF_DATASET_REPO or HF_TOKEN not set", flush=True) | |
| return | |
| try: | |
| import huggingface_hub | |
| files = list(api.list_repo_files(HF_DATASET_REPO, repo_type="dataset")) | |
| data_files = [f for f in files if f.startswith("data/") and | |
| Path(f).suffix.lower() in SUPPORTED_EXTENSIONS and Path(f).name] | |
| print(f"[STARTUP] sync_data: {len(data_files)} supported file(s) in HF Hub", flush=True) | |
| downloaded_count = 0 | |
| for path_in_repo in data_files: | |
| basename = Path(path_in_repo).name | |
| local_path = Path(DATA_DIR) / basename | |
| if local_path.exists(): | |
| continue | |
| dl = huggingface_hub.hf_hub_download( | |
| repo_id=HF_DATASET_REPO, | |
| filename=path_in_repo, | |
| repo_type="dataset", | |
| token=HF_TOKEN, | |
| ) | |
| shutil.copy2(dl, str(local_path)) | |
| downloaded_count += 1 | |
| print(f"[STARTUP] sync_data: downloaded '{basename}'", flush=True) | |
| print(f"[STARTUP] sync_data: {downloaded_count} new file(s) downloaded", flush=True) | |
| except Exception as e: | |
| print(f"[STARTUP] sync_data: FAILED — {e}", flush=True) | |
| logger.warning(f"HF Hub sync (download) failed: {e}") | |
| def push_to_hf_hub(filename: str): | |
| """Push a single file from data dir to the HF Hub dataset repo.""" | |
| if not (HF_DATASET_REPO and HF_TOKEN): | |
| return | |
| api = _hf_api() | |
| if not api: | |
| return | |
| try: | |
| api.upload_file( | |
| path_or_fileobj=str(Path(DATA_DIR) / filename), | |
| path_in_repo=f"data/{filename}", | |
| repo_id=HF_DATASET_REPO, | |
| repo_type="dataset", | |
| commit_message=f"Upload {filename}", | |
| ) | |
| logger.info(f"HF Hub: pushed '{filename}'") | |
| except Exception as e: | |
| logger.warning(f"HF Hub push failed for '{filename}': {e}") | |
| def delete_from_hf_hub(filename: str): | |
| """Delete a single file from the HF Hub dataset repo.""" | |
| api = _hf_api() | |
| if not api: | |
| return | |
| try: | |
| api.delete_file( | |
| path_in_repo=f"data/{filename}", | |
| repo_id=HF_DATASET_REPO, | |
| repo_type="dataset", | |
| commit_message=f"Delete {filename}", | |
| ) | |
| logger.info(f"HF Hub: deleted '{filename}'") | |
| except Exception as e: | |
| logger.warning(f"HF Hub delete failed for '{filename}': {e}") | |
| def sync_vectorstore_from_hf_hub(): | |
| """Download persisted vectorstore from HF Hub dataset. | |
| Must be called BEFORE VectorStoreManager is initialized so ChromaDB can | |
| load existing embeddings and avoid re-indexing on cold start. | |
| """ | |
| if not (HF_DATASET_REPO and HF_TOKEN): | |
| print("[STARTUP] sync_vectorstore: SKIPPED — HF_DATASET_REPO or HF_TOKEN not set", flush=True) | |
| return | |
| try: | |
| import huggingface_hub | |
| from huggingface_hub import HfApi | |
| api = HfApi(token=HF_TOKEN) | |
| files = list(api.list_repo_files(HF_DATASET_REPO, repo_type="dataset")) | |
| vs_files = [f for f in files if f.startswith("vectorstore/")] | |
| if not vs_files: | |
| print("[STARTUP] sync_vectorstore: no vectorstore in HF Hub — will build from scratch", flush=True) | |
| return | |
| print(f"[STARTUP] sync_vectorstore: downloading {len(vs_files)} file(s)...", flush=True) | |
| for path_in_repo in vs_files: | |
| rel = path_in_repo[len("vectorstore/"):] | |
| if not rel: | |
| continue | |
| local = Path(VECTORSTORE_DIR) / rel | |
| local.parent.mkdir(parents=True, exist_ok=True) | |
| dl = huggingface_hub.hf_hub_download( | |
| repo_id=HF_DATASET_REPO, | |
| filename=path_in_repo, | |
| repo_type="dataset", | |
| token=HF_TOKEN, | |
| ) | |
| shutil.copy2(dl, str(local)) | |
| print(f"[STARTUP] sync_vectorstore: restored {len(vs_files)} file(s) OK", flush=True) | |
| except Exception as e: | |
| print(f"[STARTUP] sync_vectorstore: FAILED — {e}", flush=True) | |
| logger.warning(f"HF Hub vectorstore sync failed: {e}") | |
| def push_vectorstore_to_hf_hub(): | |
| """Push the entire vectorstore directory to HF Hub dataset. | |
| Called after every index or delete operation so embeddings survive restarts. | |
| """ | |
| if not (HF_DATASET_REPO and HF_TOKEN): | |
| return | |
| api = _hf_api() | |
| if not api: | |
| return | |
| try: | |
| # Compact SQLite before uploading to keep file sizes small. | |
| # ChromaDB accumulates free pages over time; VACUUM reclaims them. | |
| _sqlite_path = Path(VECTORSTORE_DIR) / "chroma.sqlite3" | |
| if _sqlite_path.exists(): | |
| try: | |
| import sqlite3 as _sqlite3 | |
| _conn = _sqlite3.connect(str(_sqlite_path), timeout=5) | |
| _conn.execute("VACUUM") | |
| _conn.close() | |
| logger.info("Vectorstore SQLite compacted before push") | |
| except Exception as _ve: | |
| logger.warning(f"SQLite VACUUM skipped: {_ve}") | |
| api.upload_folder( | |
| folder_path=VECTORSTORE_DIR, | |
| path_in_repo="vectorstore", | |
| repo_id=HF_DATASET_REPO, | |
| repo_type="dataset", | |
| commit_message="Update vectorstore", | |
| ignore_patterns=["*.lock", ".DS_Store", "*.wal", "*.shm"], | |
| ) | |
| logger.info("HF Hub: pushed vectorstore") | |
| except Exception as e: | |
| logger.warning(f"HF Hub vectorstore push failed: {e}") | |
| def push_tables_to_hf_hub(): | |
| if not (HF_DATASET_REPO and HF_TOKEN): | |
| return | |
| api = _hf_api() | |
| if not api: | |
| return | |
| try: | |
| api.upload_folder( | |
| folder_path=str(Path(DATA_DIR) / "tables"), | |
| path_in_repo="tables", | |
| repo_id=HF_DATASET_REPO, | |
| repo_type="dataset", | |
| commit_message="Update tables", | |
| ignore_patterns=["*.lock", ".DS_Store"], | |
| ) | |
| logger.info("HF Hub: pushed tables") | |
| except Exception as e: | |
| logger.warning(f"HF Hub tables push failed: {e}") | |
| def sync_tables_from_hf_hub(): | |
| if not (HF_DATASET_REPO and HF_TOKEN): | |
| print("[STARTUP] sync_tables: SKIPPED — HF_DATASET_REPO or HF_TOKEN not set", flush=True) | |
| return | |
| try: | |
| import huggingface_hub | |
| from huggingface_hub import HfApi | |
| api = HfApi(token=HF_TOKEN) | |
| files = list(api.list_repo_files(HF_DATASET_REPO, repo_type="dataset")) | |
| table_files = [f for f in files if f.startswith("tables/")] | |
| if not table_files: | |
| print("[STARTUP] sync_tables: no tables found on HF Hub — will rely on on-demand extraction", flush=True) | |
| return | |
| print(f"[STARTUP] sync_tables: downloading {len(table_files)} file(s)...", flush=True) | |
| tables_dir = Path(DATA_DIR) / "tables" | |
| tables_dir.mkdir(parents=True, exist_ok=True) | |
| for path_in_repo in table_files: | |
| rel = path_in_repo[len("tables/"):] | |
| if not rel: | |
| continue | |
| local = tables_dir / rel | |
| dl = huggingface_hub.hf_hub_download( | |
| repo_id=HF_DATASET_REPO, | |
| filename=path_in_repo, | |
| repo_type="dataset", | |
| token=HF_TOKEN, | |
| ) | |
| shutil.copy2(dl, str(local)) | |
| logger.info(f"HF Hub tables: restored '{rel}'") | |
| print(f"[STARTUP] sync_tables: restored {len(table_files)} file(s) OK", flush=True) | |
| except Exception as e: | |
| print(f"[STARTUP] sync_tables: FAILED — {e}", flush=True) | |
| logger.warning(f"HF Hub tables sync failed: {e}") | |
| def push_images_to_hf_hub(): | |
| if not (HF_DATASET_REPO and HF_TOKEN): | |
| return | |
| api = _hf_api() | |
| if not api: | |
| return | |
| try: | |
| api.upload_folder( | |
| folder_path=str(Path(DATA_DIR) / "images"), | |
| path_in_repo="images", | |
| repo_id=HF_DATASET_REPO, | |
| repo_type="dataset", | |
| commit_message="Update images", | |
| ignore_patterns=["*.lock", ".DS_Store"], | |
| ) | |
| logger.info("HF Hub: pushed images") | |
| except Exception as e: | |
| logger.warning(f"HF Hub images push failed: {e}") | |
| def sync_images_from_hf_hub(): | |
| if not (HF_DATASET_REPO and HF_TOKEN): | |
| return | |
| try: | |
| import huggingface_hub | |
| from huggingface_hub import HfApi | |
| api = HfApi(token=HF_TOKEN) | |
| files = list(api.list_repo_files(HF_DATASET_REPO, repo_type="dataset")) | |
| image_files = [f for f in files if f.startswith("images/")] | |
| if not image_files: | |
| return | |
| images_dir = Path(DATA_DIR) / "images" | |
| for path_in_repo in image_files: | |
| rel = path_in_repo[len("images/"):] | |
| if not rel: | |
| continue | |
| local = images_dir / rel | |
| local.parent.mkdir(parents=True, exist_ok=True) | |
| dl = huggingface_hub.hf_hub_download( | |
| repo_id=HF_DATASET_REPO, | |
| filename=path_in_repo, | |
| repo_type="dataset", | |
| token=HF_TOKEN, | |
| ) | |
| shutil.copy2(dl, str(local)) | |
| logger.info(f"HF Hub images: restored '{rel}'") | |
| except Exception as e: | |
| logger.warning(f"HF Hub images sync failed: {e}") | |
| def _copy_committed_files(): | |
| """Copy baseline PDFs committed to the Space repo under _secrets/data/ into DATA_DIR. | |
| These are always available in the Space even without HF Hub Dataset configured. | |
| """ | |
| secrets_data = Path("_secrets/data") | |
| if not secrets_data.exists(): | |
| return | |
| for fp in secrets_data.iterdir(): | |
| if fp.suffix.lower() in SUPPORTED_EXTENSIONS: | |
| dest = Path(DATA_DIR) / fp.name | |
| if not dest.exists(): | |
| shutil.copy2(str(fp), str(dest)) | |
| logger.info(f"Copied committed file '{fp.name}' → data/") | |
| logging.basicConfig(level=logging.INFO, format="%(levelname)s | %(name)s | %(message)s") | |
| logger = logging.getLogger(__name__) | |
| # ─── Pre-init: restore persisted data so ChromaDB loads existing embeddings ── | |
| # This runs BEFORE VectorStoreManager is created. On HF Spaces (SPACE_ID is set) | |
| # it downloads the latest data + vectorstore from HF Hub. Locally, files are | |
| # already on disk so we skip the network calls for a fast startup. | |
| _IS_HF_SPACE = bool(os.environ.get("SPACE_ID")) | |
| print( | |
| f"[STARTUP] IS_HF_SPACE={_IS_HF_SPACE} | " | |
| f"HF_DATASET_REPO={'SET' if HF_DATASET_REPO else 'NOT SET'} | " | |
| f"HF_TOKEN={'SET' if HF_TOKEN else 'NOT SET'}", | |
| flush=True, | |
| ) | |
| _copy_committed_files() | |
| if _IS_HF_SPACE: | |
| sync_vectorstore_from_hf_hub() | |
| sync_from_hf_hub() | |
| sync_tables_from_hf_hub() | |
| sync_images_from_hf_hub() | |
| else: | |
| logger.info("Pre-init: local run — skipping HF Hub sync (files already on disk).") | |
| # ─── Singletons ─────────────────────────────────────────────────────────────── | |
| vs = VectorStoreManager(persist_dir=VECTORSTORE_DIR) | |
| _vs_chunks = vs.total_chunks() | |
| _vs_sources = vs.list_sources() | |
| _data_files = [f.name for f in Path(DATA_DIR).iterdir() if f.suffix.lower() in SUPPORTED_EXTENSIONS] | |
| print( | |
| f"[STARTUP] VS loaded: {_vs_chunks} chunks, {len(_vs_sources)} source(s): {_vs_sources}", | |
| flush=True, | |
| ) | |
| print(f"[STARTUP] DATA_DIR files: {_data_files}", flush=True) | |
| rag = RAGEngine(vector_store=vs, model=OLLAMA_MODEL) | |
| memory = ConversationMemory() | |
| ts = TableStore() | |
| img_store = ImageStore() | |
| # ─── App ────────────────────────────────────────────────────────────────────── | |
| app = FastAPI(title="Multimodal RAG API", version="1.0.0") | |
| # CORS is a browser security mechanism that blocks web pages from making requests to a different domain than the one that served the page. For example, if your Gradio frontend runs on localhost:7860 and tries to call your FastAPI backend on localhost:8000, the browser would normally block that request. | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=["*"], | |
| allow_methods=["*"], | |
| allow_headers=["*"], | |
| ) | |
| # ─── Models ─────────────────────────────────────────────────────────────────── | |
| class QueryRequest(BaseModel): | |
| question: str | |
| n_results: int = 8 | |
| temperature: float = 0.0 | |
| source_filter: List[str] = [] | |
| class QueryResponse(BaseModel): | |
| answer: str | |
| sources: List[str] | |
| tokens_user: int = 0 | |
| tokens_assistant: int = 0 | |
| chunks_used: int = 0 | |
| sql_query: str = "" | |
| answer_method: str = "rag" # "rag" | "table_query" | |
| class StatusResponse(BaseModel): | |
| documents: List[str] | |
| total_chunks: int | |
| data_dir_files: List[str] | |
| model: str | |
| device: str | |
| class URLIndexRequest(BaseModel): | |
| url: str | |
| max_depth: int = 2 | |
| max_pages: int = 50 | |
| # ─── Helper ─────────────────────────────────────────────────────────────────── | |
| def index_file(filepath: str) -> int: | |
| """Process and index a file into the vector store, and extract tables into TableStore.""" | |
| chunks = process_document_chunked(filepath) | |
| source_name = Path(filepath).name | |
| vs.remove_document(source_name) | |
| n = vs.add_documents(chunks, source_name) | |
| try: | |
| dfs = _extract_tables(filepath) | |
| if dfs: | |
| ts.save(source_name, dfs) | |
| except Exception as e: | |
| logger.warning(f"Table extraction failed for '{source_name}': {e}") | |
| return n | |
| def index_all_data_dir(): | |
| """Index all supported files in DATA_DIR on startup.""" | |
| indexed_sources = set(vs.list_sources()) | |
| for fp in Path(DATA_DIR).iterdir(): | |
| if fp.suffix.lower() in SUPPORTED_EXTENSIONS and fp.name not in indexed_sources: | |
| try: | |
| n = index_file(str(fp)) | |
| logger.info(f"Indexed '{fp.name}': {n} chunks") | |
| except Exception as e: | |
| logger.error(f"Failed to index '{fp.name}': {e}") | |
| # ─── Startup ────────────────────────────────────────────────────────────────── | |
| async def startup_event(): | |
| # ChromaDB PersistentClient already restores the vectorstore from disk on init. | |
| # Do NOT auto-index data/ here — that would re-index files the user deleted | |
| # from the index, making them reappear after every restart. | |
| # On HF Spaces, sync_from_hf_hub() + sync_vectorstore_from_hf_hub() run at | |
| # module load (before this), so the vectorstore is already fully restored. | |
| def _backfill_tables(): | |
| for fp in Path(DATA_DIR).iterdir(): | |
| if fp.suffix.lower() not in SUPPORTED_EXTENSIONS: | |
| continue | |
| src = fp.name | |
| if ts.was_attempted(src): | |
| continue | |
| try: | |
| dfs = _extract_tables(str(fp)) | |
| ts.save(src, dfs) | |
| if dfs: | |
| logger.info(f"Startup backfill: {len(dfs)} table(s) for '{src}'") | |
| except Exception as e: | |
| logger.warning(f"Startup backfill failed for '{src}': {e}") | |
| ts.save(src, []) | |
| def _backfill_images(): | |
| for fp in Path(DATA_DIR).iterdir(): | |
| if fp.suffix.lower() not in SUPPORTED_EXTENSIONS: | |
| continue | |
| src = fp.name | |
| if img_store.was_attempted(src): | |
| continue | |
| try: | |
| images = extract_images(str(fp)) | |
| img_store.save(src, images) | |
| if images: | |
| logger.info(f"Startup backfill: {len(images)} image(s) for '{src}'") | |
| except Exception as e: | |
| logger.warning(f"Startup image backfill failed for '{src}': {e}") | |
| img_store.save(src, []) | |
| def _index_missing_files(): | |
| """Index any file in data/ that is not yet in the vectorstore. | |
| Runs on every startup to self-heal after a stale or failed vectorstore restore. | |
| Safe to run because deleted files are now removed from disk too. | |
| """ | |
| indexed = set(vs.list_sources()) | |
| missing = [ | |
| fp for fp in Path(DATA_DIR).iterdir() | |
| if fp.suffix.lower() in SUPPORTED_EXTENSIONS and fp.name not in indexed | |
| ] | |
| if not missing: | |
| return | |
| logger.warning( | |
| "%d file(s) in data/ not in vectorstore — indexing: %s", | |
| len(missing), [f.name for f in missing], | |
| ) | |
| failed = [] | |
| for fp in missing: | |
| try: | |
| n = index_file(str(fp)) | |
| logger.warning(f"Startup index OK: '{fp.name}' — {n} chunks") | |
| except Exception as e: | |
| logger.error(f"Startup index FAILED: '{fp.name}': {e}", exc_info=True) | |
| failed.append(fp.name) | |
| logger.warning( | |
| "Startup index complete: %d OK, %d failed. VS now has %d chunks.", | |
| len(missing) - len(failed), len(failed), vs.total_chunks(), | |
| ) | |
| if _IS_HF_SPACE: | |
| if failed: | |
| logger.warning(f"Skipping vectorstore push — {len(failed)} file(s) failed: {failed}") | |
| else: | |
| push_vectorstore_to_hf_hub() | |
| loop = asyncio.get_event_loop() | |
| loop.run_in_executor(None, _index_missing_files) | |
| loop.run_in_executor(None, _backfill_tables) | |
| loop.run_in_executor(None, _backfill_images) | |
| logger.info("HTTP server ready.") | |
| # ─── Endpoints ──────────────────────────────────────────────────────────────── | |
| async def get_status(): | |
| data_files = [ | |
| f.name for f in Path(DATA_DIR).iterdir() | |
| if f.suffix.lower() in SUPPORTED_EXTENSIONS | |
| ] | |
| return StatusResponse( | |
| documents=vs.list_sources(), | |
| total_chunks=vs.total_chunks(), | |
| data_dir_files=data_files, | |
| model=rag.model, | |
| device=device_info()["label"], | |
| ) | |
| async def get_debug(): | |
| """Diagnostic endpoint — shows startup config and current VS/data state.""" | |
| data_files = [ | |
| f.name for f in Path(DATA_DIR).iterdir() | |
| if f.suffix.lower() in SUPPORTED_EXTENSIONS | |
| ] | |
| vs_files = [] | |
| try: | |
| vs_files = list(Path(VECTORSTORE_DIR).rglob("*")) | |
| vs_files = [str(p.relative_to(VECTORSTORE_DIR)) for p in vs_files if p.is_file()] | |
| except Exception: | |
| pass | |
| return { | |
| "is_hf_space": _IS_HF_SPACE, | |
| "hf_dataset_repo_set": bool(HF_DATASET_REPO), | |
| "hf_token_set": bool(HF_TOKEN), | |
| "vs_chunks": vs.total_chunks(), | |
| "vs_sources": vs.list_sources(), | |
| "data_dir_files": data_files, | |
| "vectorstore_files": vs_files, | |
| } | |
| async def upload_document(background_tasks: BackgroundTasks, file: UploadFile = File(...)): | |
| """Save a document to disk and start background indexing. Returns immediately.""" | |
| suffix = Path(file.filename).suffix.lower() | |
| if suffix not in SUPPORTED_EXTENSIONS: | |
| raise HTTPException(400, f"Unsupported file type: {suffix}. Supported: {SUPPORTED_EXTENSIONS}") | |
| save_path = Path(DATA_DIR) / file.filename | |
| content = await file.read() | |
| with open(save_path, "wb") as f: | |
| f.write(content) | |
| with _upload_lock: | |
| _upload_jobs[file.filename] = {"status": "processing"} | |
| background_tasks.add_task(_index_background, file.filename, str(save_path)) | |
| return { | |
| "message": f"⏳ Indexing started for '{file.filename}' — polling for status.", | |
| "status": "processing", | |
| "filename": file.filename, | |
| } | |
| async def upload_status(filename: str): | |
| """Poll the indexing status of a background file upload.""" | |
| with _upload_lock: | |
| job = _upload_jobs.get(filename) | |
| if job is None: | |
| raise HTTPException(404, f"No upload job found for '{filename}'") | |
| return job | |
| async def delete_document(filename: str): | |
| """Remove a document's embeddings and delete the file from disk and HF Hub.""" | |
| removed_chunks = vs.remove_document(filename) | |
| ts.remove(filename) | |
| img_store.remove(filename) | |
| file_path = Path(DATA_DIR) / filename | |
| file_path.unlink(missing_ok=True) | |
| loop = asyncio.get_running_loop() | |
| await loop.run_in_executor(None, delete_from_hf_hub, filename) | |
| await loop.run_in_executor(None, push_vectorstore_to_hf_hub) | |
| await loop.run_in_executor(None, push_tables_to_hf_hub) | |
| await loop.run_in_executor(None, push_images_to_hf_hub) | |
| if removed_chunks > 0: | |
| return {"message": f"Removed '{filename}' ({removed_chunks} chunks)."} | |
| else: | |
| raise HTTPException(404, f"No indexed chunks found for '{filename}'.") | |
| async def reextract_all(): | |
| """Re-run table and image extraction on all files in DATA_DIR. No re-embedding.""" | |
| files = [f for f in Path(DATA_DIR).iterdir() if f.suffix.lower() in SUPPORTED_EXTENSIONS] | |
| results = {} | |
| for f in files: | |
| source_name = f.name | |
| tables_saved = 0 | |
| images_saved = 0 | |
| try: | |
| dfs = _extract_tables(str(f)) | |
| ts.save(source_name, dfs) | |
| tables_saved = len(dfs) | |
| except Exception as e: | |
| logger.warning(f"Table reextract failed for '{source_name}': {e}") | |
| try: | |
| images = extract_images(str(f)) | |
| img_store.save(source_name, images) | |
| images_saved = len(images) | |
| except Exception as e: | |
| logger.warning(f"Image reextract failed for '{source_name}': {e}") | |
| results[source_name] = {"tables": tables_saved, "images": images_saved} | |
| loop = asyncio.get_event_loop() | |
| loop.run_in_executor(None, push_tables_to_hf_hub) | |
| loop.run_in_executor(None, push_images_to_hf_hub) | |
| return {"results": results} | |
| async def delete_all_documents(): | |
| """Remove ALL embeddings and delete all user files from disk and HF Hub.""" | |
| filenames = [f.name for f in Path(DATA_DIR).iterdir() if f.suffix.lower() in SUPPORTED_EXTENSIONS] | |
| removed = vs.clear_all() | |
| ts.clear_all() | |
| img_store.clear_all() | |
| for name in filenames: | |
| (Path(DATA_DIR) / name).unlink(missing_ok=True) | |
| loop = asyncio.get_running_loop() | |
| for name in filenames: | |
| await loop.run_in_executor(None, delete_from_hf_hub, name) | |
| await loop.run_in_executor(None, push_vectorstore_to_hf_hub) | |
| await loop.run_in_executor(None, push_tables_to_hf_hub) | |
| await loop.run_in_executor(None, push_images_to_hf_hub) | |
| return {"message": f"Removed {removed} indexed chunks and {len(filenames)} file(s).", "chunks_removed": removed} | |
| # ─── Upload job tracker ────────────────────────────────────────────────────── | |
| _upload_jobs: dict = {} # filename → {"status": "processing"|"done"|"error", ...} | |
| _upload_lock = threading.Lock() | |
| def _index_background(filename: str, save_path: str): | |
| """Runs in a background thread: index file, persist to HF Hub, update job status.""" | |
| def _set_phase(msg: str): | |
| with _upload_lock: | |
| _upload_jobs[filename]["phase"] = msg | |
| try: | |
| _set_phase("parsing document…") | |
| chunks = process_document_chunked(save_path) | |
| total = len(chunks) | |
| source_name = Path(save_path).name | |
| vs.remove_document(source_name) | |
| # Embed and upsert in batches of 150 so we can report live progress and | |
| # avoid one massive blocking encode() call (critical on HF Space CPU). | |
| EMBED_BATCH = 150 | |
| done = 0 | |
| for i in range(0, max(total, 1), EMBED_BATCH): | |
| batch = chunks[i : i + EMBED_BATCH] | |
| end = min(i + EMBED_BATCH, total) | |
| _set_phase(f"embedding chunks {i + 1}–{end} of {total}…") | |
| vs.add_documents(batch, source_name, chunk_offset=i) | |
| done += len(batch) | |
| n_chunks = done | |
| _set_phase("extracting tables…") | |
| try: | |
| dfs = _extract_tables(save_path) | |
| ts.save(source_name, dfs) | |
| if dfs: | |
| logger.info(f"TableStore: saved {len(dfs)} table(s) for '{source_name}'") | |
| except Exception as e: | |
| logger.warning(f"Table extraction failed for '{source_name}': {e}") | |
| _set_phase("extracting images…") | |
| try: | |
| images = extract_images(save_path) | |
| img_store.save(source_name, images) | |
| if images: | |
| logger.info(f"ImageStore: saved {len(images)} image(s) for '{source_name}'") | |
| except Exception as e: | |
| logger.warning(f"Image extraction failed for '{source_name}': {e}") | |
| # Mark done IMMEDIATELY so the frontend poll resolves without waiting for | |
| # the (potentially slow) HF Hub push that follows. | |
| with _upload_lock: | |
| _upload_jobs[filename] = { | |
| "status": "done", | |
| "message": f"Uploaded and indexed '{filename}' ({n_chunks} chunks).", | |
| "chunks": n_chunks, | |
| } | |
| logger.info(f"Background index done: '{filename}' — {n_chunks} chunks") | |
| # Push to HF Hub after marking done so a slow upload doesn't block status. | |
| push_to_hf_hub(filename) | |
| push_vectorstore_to_hf_hub() | |
| push_tables_to_hf_hub() | |
| push_images_to_hf_hub() | |
| except Exception as e: | |
| logger.error(f"Background index failed for '{filename}': {e}", exc_info=True) | |
| # File is kept on disk even if indexing fails — user can retry | |
| with _upload_lock: | |
| _upload_jobs[filename] = {"status": "error", "message": str(e)} | |
| # ─── URL crawl job tracker ──────────────────────────────────────────────────── | |
| _crawl_jobs: dict = {} # url → {"status": "crawling"|"done"|"error", ...} | |
| _crawl_lock = threading.Lock() | |
| def _crawl_background(url: str, max_depth: int, max_pages: int): | |
| """Runs in a background thread: crawl + index, then update job status.""" | |
| from utils.url_processor import crawl_url | |
| try: | |
| vs.remove_document(url) | |
| chunks, crawled_urls = crawl_url(url, max_depth=max_depth, max_pages=max_pages) | |
| if not chunks: | |
| with _crawl_lock: | |
| _crawl_jobs[url] = {"status": "error", "message": "No content extracted."} | |
| return | |
| n_chunks = vs.add_documents(chunks, url) | |
| with _crawl_lock: | |
| _crawl_jobs[url] = { | |
| "status": "done", | |
| "message": ( | |
| f"Indexed {len(crawled_urls)} page(s) and file(s) " | |
| f"({n_chunks} chunks) from {url}" | |
| ), | |
| "pages": len(crawled_urls), | |
| "chunks": n_chunks, | |
| } | |
| logger.info(f"Crawl done: {url} — {len(crawled_urls)} pages, {n_chunks} chunks") | |
| except Exception as e: | |
| logger.error(f"Crawl failed for {url}: {e}", exc_info=True) | |
| with _crawl_lock: | |
| _crawl_jobs[url] = {"status": "error", "message": str(e)} | |
| async def index_url(req: URLIndexRequest, background_tasks: BackgroundTasks): | |
| """Start a background crawl of a URL (2 levels deep). Returns immediately.""" | |
| url = req.url.strip() | |
| if not url.startswith(("http://", "https://")): | |
| raise HTTPException(400, "URL must start with http:// or https://") | |
| with _crawl_lock: | |
| _crawl_jobs[url] = {"status": "crawling"} | |
| background_tasks.add_task(_crawl_background, url, req.max_depth, req.max_pages) | |
| return { | |
| "message": f"⏳ Crawling started for {url} — refresh the document list in ~30 s.", | |
| "status": "crawling", | |
| "url": url, | |
| } | |
| async def url_crawl_status(url: str): | |
| """Poll the status of a background URL crawl.""" | |
| with _crawl_lock: | |
| job = _crawl_jobs.get(url) | |
| if job is None: | |
| raise HTTPException(404, f"No crawl job found for {url}") | |
| return job | |
| async def reindex_all(): | |
| """Force re-index all documents in data dir.""" | |
| for fp in Path(DATA_DIR).iterdir(): | |
| if fp.suffix.lower() in SUPPORTED_EXTENSIONS: | |
| try: | |
| index_file(str(fp)) | |
| except Exception as e: | |
| logger.error(f"Reindex failed for {fp.name}: {e}") | |
| return {"message": "Reindexed all documents.", "total_chunks": vs.total_chunks()} | |
| _GREETING_RESPONSES = { | |
| "hi": "Hi! Ask me anything about your uploaded documents.", | |
| "hello": "Hello! Ask me anything about your uploaded documents.", | |
| "hey": "Hey! Ask me anything about your uploaded documents.", | |
| "hiya": "Hi there! Ask me anything about your uploaded documents.", | |
| "howdy": "Howdy! Ask me anything about your uploaded documents.", | |
| "greetings": "Greetings! Ask me anything about your uploaded documents.", | |
| "sup": "Hey! Ask me anything about your uploaded documents.", | |
| "yo": "Hey! Ask me anything about your uploaded documents.", | |
| "good morning": "Good morning! Ask me anything about your uploaded documents.", | |
| "good afternoon": "Good afternoon! Ask me anything about your uploaded documents.", | |
| "good evening": "Good evening! Ask me anything about your uploaded documents.", | |
| "good day": "Good day! Ask me anything about your uploaded documents.", | |
| "how are you": "I'm doing well, thank you! Ask me anything about your uploaded documents.", | |
| "how are you doing": "I'm doing well, thank you! Ask me anything about your uploaded documents.", | |
| "how do you do": "I'm doing well, thank you! How can I help with your documents?", | |
| "what's up": "Not much! Ready to answer questions about your documents.", | |
| "whats up": "Not much! Ready to answer questions about your documents.", | |
| "what is up": "Not much! Ready to answer questions about your documents.", | |
| "thanks": "You're welcome! Let me know if you have more questions.", | |
| "thank you": "You're welcome! Let me know if you have more questions.", | |
| "thx": "You're welcome! Let me know if you have more questions.", | |
| "ty": "You're welcome! Let me know if you have more questions.", | |
| "bye": "Goodbye! Feel free to come back anytime.", | |
| "goodbye": "Goodbye! Feel free to come back anytime.", | |
| "see you": "See you! Feel free to come back anytime.", | |
| "cya": "See you later! Feel free to come back anytime.", | |
| "ok": "Let me know if you have any questions about your documents.", | |
| "okay": "Let me know if you have any questions about your documents.", | |
| "cool": "Glad to help! Let me know if you have more questions.", | |
| "great": "Glad to help! Let me know if you have more questions.", | |
| "nice": "Thanks! Let me know if you have more questions.", | |
| } | |
| _META_PATTERNS = [ | |
| "how can you help", "how you can help", "what can you do", | |
| "what do you do", "who are you", "what are you", | |
| "help me", "how does this work", "how do you work", | |
| ] | |
| _ALL_DOCS_SUMMARY_PATTERNS = [ | |
| "summarize each doc", "summarize all doc", "summarize every doc", | |
| "summarize each file", "summarize all file", "summarize every file", | |
| "summary of each", "summary of all doc", "summary of every doc", | |
| "overview of each", "overview of all doc", | |
| "describe each doc", "describe all doc", "describe every doc", | |
| "bullet point each", "bullet points for each", | |
| "summarize the doc", "summarize the file", | |
| "summarize all the doc", "summarize all the file", | |
| "give me a summary of each", "give me a summary of all", | |
| ] | |
| _DOCS_LIST_PATTERNS = [ | |
| "how many doc", "how many file", "list doc", "list file", | |
| "what doc", "what file", "which doc", "which file", | |
| "show doc", "show file", "what are the doc", "what are the file", | |
| "what is indexed", "what is uploaded", "what have you indexed", | |
| "what have you uploaded", "what documents do you have", | |
| "what files do you have", "tell me the doc", "tell me the file", | |
| "name the doc", "name the file", | |
| # file-type specific | |
| "how many docx", "how many xlsx", "how many csv", "how many pdf", | |
| "how many image", "how many txt", "how many png", "how many jpg", | |
| "list docx", "list xlsx", "list csv", "list pdf", "list image", "list txt", | |
| "show docx", "show xlsx", "show csv", "show pdf", "show image", "show txt", | |
| "what docx", "what xlsx", "what csv", "what pdf", | |
| "which docx", "which xlsx", "which csv", "which pdf", | |
| "any docx", "any xlsx", "any csv", "any pdf", "any image", | |
| "excel file", "word file", "spreadsheet", "word document", | |
| ] | |
| def _docs_list_response() -> str | None: | |
| """Return a formatted list of indexed documents, or None if none are indexed.""" | |
| sources = vs.list_sources() | |
| if not sources: | |
| return None | |
| lines = "\n".join(f"- {s}" for s in sources) | |
| # type breakdown | |
| from collections import Counter | |
| ext_counts = Counter(Path(s).suffix.lower() for s in sources) | |
| breakdown = ", ".join(f"{cnt} {ext}" for ext, cnt in sorted(ext_counts.items())) | |
| return ( | |
| f"There are **{len(sources)}** indexed document(s) ({breakdown}):\n\n" | |
| f"{lines}" | |
| ) | |
| def _is_all_docs_summary(text: str) -> bool: | |
| normalized = text.strip().lower().rstrip("!?.,") | |
| return any(p in normalized for p in _ALL_DOCS_SUMMARY_PATTERNS) | |
| _META_ANSWER = ( | |
| "I'm your document assistant. Here's how I can help:\n\n" | |
| "1. **Upload documents** (PDF, Word, Excel, CSV, TXT, images) or **add URLs** in the Documents tab\n" | |
| "2. **Ask questions** about your uploaded documents and I'll answer based on their content\n" | |
| "3. I can handle text, tables, charts, and scanned images\n" | |
| "4. Use the **Read** button to hear answers aloud\n\n" | |
| "Upload some documents and start asking questions!" | |
| ) | |
| def _chitchat_response(text: str) -> str | None: | |
| """Return a context-appropriate response for chitchat, or None if not chitchat.""" | |
| normalized = text.strip().lower().rstrip("!?.,") | |
| if normalized in _GREETING_RESPONSES: | |
| return _GREETING_RESPONSES[normalized] | |
| for pattern in _META_PATTERNS: | |
| if pattern in normalized: | |
| return _META_ANSWER | |
| for pattern in _DOCS_LIST_PATTERNS: | |
| if pattern in normalized: | |
| return _docs_list_response() | |
| return None | |
| def _llm_extract_dataframes(filepath: str) -> list: | |
| """LLM-based table extraction fallback for images and PDFs where the heuristic fails.""" | |
| import io as _io | |
| import pandas as _pd | |
| from pathlib import Path as _Path | |
| ext = _Path(filepath).suffix.lower() | |
| ocr_text = "" | |
| try: | |
| if ext in {".png", ".jpg", ".jpeg", ".tiff", ".bmp", ".gif"}: | |
| from utils.document_processor import ocr_image | |
| from PIL import Image | |
| ocr_text = ocr_image(Image.open(filepath).convert("RGB")) | |
| elif ext == ".pdf": | |
| from pypdf import PdfReader | |
| reader = PdfReader(filepath) | |
| ocr_text = "\n".join(p.extract_text() or "" for p in reader.pages) | |
| except Exception as e: | |
| logger.warning(f"LLM table fallback OCR failed for '{filepath}': {e}") | |
| return [] | |
| if not ocr_text.strip(): | |
| return [] | |
| prompt = ( | |
| "The following is text extracted from a document. " | |
| "If it contains a table, output ONLY a valid CSV representation — " | |
| "no explanation, no markdown fences, just raw CSV rows. " | |
| "Use clean column names in the header row. Strip leading row/line numbers. " | |
| "If no table is present, output exactly: NO_TABLE\n\n" | |
| f"{ocr_text}" | |
| ) | |
| try: | |
| csv_text = _call_llm([{"role": "user", "content": prompt}]).strip() | |
| if not csv_text or csv_text.startswith("NO_TABLE"): | |
| return [] | |
| if csv_text.startswith("```"): | |
| csv_text = "\n".join(l for l in csv_text.splitlines() if not l.startswith("```")) | |
| df = _pd.read_csv(_io.StringIO(csv_text.strip())) | |
| return [df] if not df.empty else [] | |
| except Exception as e: | |
| logger.warning(f"LLM table fallback parse failed for '{filepath}': {e}") | |
| return [] | |
| def _extract_tables(filepath: str) -> list: | |
| """Extract DataFrames from a file, falling back to LLM parsing for images/PDFs.""" | |
| dfs = extract_dataframes(filepath) | |
| if not dfs: | |
| ext = Path(filepath).suffix.lower() | |
| if ext in {".png", ".jpg", ".jpeg", ".tiff", ".bmp", ".gif", ".pdf"}: | |
| dfs = _llm_extract_dataframes(filepath) | |
| return dfs | |
| def _call_llm(messages) -> str: | |
| """Direct LLM call with no RAG context. Falls back to HF Inference on Groq rate-limit.""" | |
| if BACKEND == "groq": | |
| try: | |
| resp = rag._client.chat.completions.create( | |
| model=rag.model, messages=messages, temperature=0.0, | |
| ) | |
| return resp.choices[0].message.content | |
| except Exception as e: | |
| msg = str(e).lower() | |
| if ("429" in msg or "rate_limit" in msg or "rate limit" in msg) and HF_TOKEN: | |
| logger.warning("Groq rate limit in _call_llm — falling back to HF Inference") | |
| try: | |
| from huggingface_hub import InferenceClient | |
| from utils.rag_engine import DEFAULT_HF_MODEL | |
| client = InferenceClient(token=HF_TOKEN) | |
| resp = client.chat_completion( | |
| model=os.environ.get("HF_MODEL", DEFAULT_HF_MODEL), | |
| messages=messages, temperature=0.01, max_tokens=512, | |
| ) | |
| return resp.choices[0].message.content | |
| except Exception as hf_err: | |
| logger.warning(f"HF Inference fallback also failed in _call_llm: {hf_err}") | |
| raise e | |
| raise | |
| elif BACKEND == "hf": | |
| resp = rag._client.chat_completion( | |
| model=rag.model, messages=messages, temperature=0.01, max_tokens=2048, | |
| ) | |
| return resp.choices[0].message.content | |
| else: | |
| response = rag._client.chat( | |
| model=rag.model, messages=messages, options={"temperature": 0.0}, | |
| ) | |
| return response["message"]["content"] | |
| def _strip_sql_fences(raw: str) -> str: | |
| raw = raw.strip() | |
| if raw.startswith("```"): | |
| lines = raw.split("\n") | |
| raw = "\n".join(lines[1:]) | |
| if "```" in raw: | |
| raw = raw[: raw.rfind("```")] | |
| return raw.strip() | |
| def _select_relevant_tables(question: str, schema_info: list) -> list: | |
| """Return the most relevant table(s) based on keyword overlap with column names and sample data.""" | |
| import re as _re | |
| if len(schema_info) <= 1: | |
| return schema_info | |
| q_words = set(_re.sub(r"[^a-z0-9]", " ", question.lower()).split()) | |
| scores = [] | |
| for s in schema_info: | |
| col_words = set(_re.sub(r"[^a-z0-9]", " ", " ".join(s["numeric_cols"] + s["text_cols"]).lower()).split()) | |
| sample_words = set(_re.sub(r"[^a-z0-9]", " ", s["sample"].lower()).split()) | |
| score = len(q_words & col_words) + 0.3 * len(q_words & sample_words) | |
| scores.append(score) | |
| max_score = max(scores) | |
| if max_score == 0: | |
| return schema_info | |
| sorted_scores = sorted(scores, reverse=True) | |
| # Narrow to best table only when it's clearly dominant | |
| if len(sorted_scores) >= 2 and sorted_scores[0] >= 2 * sorted_scores[1] + 0.01: | |
| return [schema_info[scores.index(max_score)]] | |
| return [s for s, sc in zip(schema_info, scores) if sc >= max_score * 0.5] | |
| def _question_to_sql(question: str, schema_info: list, conn) -> tuple[str, list, list] | None: | |
| """Convert a natural language question to SQL, execute it, and return (sql, rows, col_names). | |
| Returns None if SQL generation or execution fails after retries. | |
| Uses the table schema to build a structured prompt that prevents column confusion. | |
| """ | |
| import pandas as pd | |
| # Narrow to the most relevant table(s) to prevent cross-table column confusion | |
| relevant = _select_relevant_tables(question, schema_info) | |
| schema_text = "\n\n".join( | |
| f"Table `{s['table_name']}` (source: {s['source']}, {s['nrows']} rows)\n" | |
| f" Numeric columns (REAL — safe for SUM/AVG/MIN/MAX): {', '.join(s['numeric_cols']) or 'none'}\n" | |
| f" Text columns (strings only — NEVER use in SUM/AVG/MIN/MAX): {', '.join(s['text_cols'])}\n" | |
| f" Sample rows:\n{s['sample']}" | |
| for s in relevant | |
| ) | |
| table_col_blocks = "\n".join( | |
| f" `{s['table_name']}` valid columns: " | |
| + ", ".join(f"`{c}`" for c in s["numeric_cols"] + s["text_cols"]) | |
| for s in relevant | |
| ) | |
| # Build dynamic warnings for columns that look like OCR-garbage text columns | |
| text_col_warnings = [] | |
| for s in relevant: | |
| bad_cols = [c for c in s["text_cols"] if c.lower() in ("balance", "total", "amount", "price")] | |
| if bad_cols: | |
| text_col_warnings.append( | |
| f"WARNING: {', '.join(bad_cols)} in `{s['table_name']}` are TEXT (OCR output with symbols like '$', '€', ',') — " | |
| f"they CANNOT be summed. Use {', '.join(s['numeric_cols']) or 'numeric columns'} instead." | |
| ) | |
| warnings_block = ("\n".join(text_col_warnings) + "\n\n") if text_col_warnings else "" | |
| sql_prompt = ( | |
| "SQLite database tables:\n\n" | |
| + schema_text | |
| + "\n\nCOLUMN CONSTRAINTS (only these column names are valid — no others exist):\n" | |
| + table_col_blocks | |
| + "\n\n" | |
| + warnings_block | |
| + "RULES:\n" | |
| " 1. NEVER use text columns in SUM/AVG/MIN/MAX — they contain strings, not numbers.\n" | |
| " 2. For date comparisons ALWAYS wrap with DATE(): WHERE DATE(Date)='2022-01-02'\n" | |
| " WRONG: WHERE Date='2022-01-02' CORRECT: WHERE DATE(Date)='2022-01-02'\n" | |
| " 3. For spending/expense queries on a bank table: filter Credit < 0 (negative = debit/spending)\n" | |
| " 4. ONLY filter on columns explicitly mentioned in the question. Do NOT infer extra filters from sample data.\n" | |
| " WRONG (question says 'all items'): WHERE LOWER(Item)='apple' AND LOWER(Sales_Rep)='william'\n" | |
| " CORRECT: WHERE LOWER(Sales_Rep)='william'\n" | |
| " 5. Always use LOWER() for text column comparisons.\n" | |
| " For exact names: WHERE LOWER(Sales_Rep)='william'\n" | |
| " For categories/keywords: WHERE LOWER(Description) LIKE '%grocery%'\n" | |
| " NEVER use bare equality for text without LOWER().\n" | |
| "\n" | |
| "Query patterns:\n" | |
| " Specific day: WHERE DATE(Date)='2022-01-02'\n" | |
| " Month filter: WHERE strftime('%Y-%m', Date)='2026-03'\n" | |
| " Exact text filter: WHERE LOWER(Sales_Rep)='william'\n" | |
| " Category/keyword: WHERE LOWER(Description) LIKE '%grocery%'\n" | |
| " All items for person: SELECT SUM(Sales) FROM tbl WHERE LOWER(Sales_Rep)='william'\n" | |
| " Numeric aggregation: SELECT SUM(Sales) FROM tbl WHERE ...\n" | |
| " Spending total: SELECT SUM(Credit) FROM tbl WHERE strftime('%Y-%m', Date)='2026-03' AND Credit < 0\n" | |
| " Spending by category: SELECT SUM(Credit) FROM tbl WHERE strftime('%Y-%m', Date)='2026-03' AND LOWER(Description) LIKE '%grocery%' AND Credit < 0\n" | |
| " Spending list: SELECT Date, Description, Credit FROM tbl WHERE Credit < 0 ORDER BY Credit\n" | |
| "\n" | |
| f"Question: {question}\n\n" | |
| "Write one SQLite SELECT statement. OUTPUT ONLY THE SQL — no markdown, no explanation.\n" | |
| ) | |
| sql_messages = [ | |
| {"role": "system", "content": "Output only a valid SQLite SELECT statement. No markdown. No explanation."}, | |
| {"role": "user", "content": sql_prompt}, | |
| ] | |
| for attempt in range(2): | |
| try: | |
| llm_out = _call_llm(sql_messages) | |
| except Exception as e: | |
| logger.warning(f"LLM SQL generation failed: {e}") | |
| return None | |
| if not llm_out: | |
| return None | |
| sql = _strip_sql_fences(llm_out.strip()) | |
| try: | |
| cursor = conn.execute(sql) | |
| rows = cursor.fetchall() | |
| col_names = [d[0] for d in cursor.description] if cursor.description else [] | |
| return sql, rows, col_names | |
| except Exception as e: | |
| logger.warning(f"SQL exec failed (attempt {attempt + 1}): {e}\nSQL: {sql}") | |
| if attempt == 0: | |
| sql_messages = sql_messages + [ | |
| {"role": "assistant", "content": sql}, | |
| {"role": "user", "content": f"Error: {e}\nReturn only the corrected SQL query."}, | |
| ] | |
| return None | |
| _TABLE_INTENT_RE = re.compile( | |
| r"\b(sum|total|average|avg|mean|maximum|minimum|count|how much|how many|" | |
| r"calculate|tallest|largest|smallest|highest|lowest|most|least|" | |
| r"(?:max|min)(?!\s+\d)|" | |
| r"per (month|year|day|week|item|person|category)|" | |
| r"sell|sells|sold|selling|" | |
| r"(sales|revenue|profit|cost|price|amount|balance|credit|debit|spendings?|paid|owe)\b.*\b(of|for|by|in|per)\b|" | |
| r"\b(of|for|by|in)\b.*\b(sales|revenue|profit|cost|price|amount|balance|credit|debit|spendings?))\b", | |
| re.IGNORECASE, | |
| ) | |
| def _is_table_question(question: str) -> bool: | |
| """Return True only if the question is asking for quantitative/analytical data from tables.""" | |
| return bool(_TABLE_INTENT_RE.search(question)) | |
| def _run_table_query(question: str, source_filter=None) -> tuple[str, str] | None: | |
| """Answer a question using stored tables via text-to-SQL + LLM synthesis. Returns (answer, sql) or None.""" | |
| import pandas as pd | |
| if source_filter: | |
| sources = source_filter | |
| else: | |
| # Use all files in DATA_DIR, not just embedded ones | |
| sources = [f.name for f in Path(DATA_DIR).iterdir() if f.suffix.lower() in SUPPORTED_EXTENSIONS] | |
| # On-demand extraction for sources not yet attempted | |
| for src in sources: | |
| if not ts.was_attempted(src): | |
| fp = Path(DATA_DIR) / src | |
| if fp.exists(): | |
| try: | |
| extracted = _extract_tables(str(fp)) | |
| ts.save(src, extracted) | |
| except Exception as e: | |
| logger.warning(f"On-demand table extraction failed for '{src}': {e}") | |
| ts.save(src, []) | |
| conn, schema_info = ts.load_into_memory(sources) | |
| if not schema_info: | |
| conn.close() | |
| return None | |
| result = _question_to_sql(question, schema_info, conn) | |
| if result is None: | |
| conn.close() | |
| return None | |
| sql, rows, col_names = result | |
| if not rows: | |
| conn.close() | |
| return "No matching data found in the tables.", sql | |
| result_df = pd.DataFrame(rows, columns=col_names) if col_names else pd.DataFrame(rows) | |
| result_str = result_df.to_string(index=False) | |
| # When result is a single aggregate value, fetch the underlying detail rows | |
| detail_str = "" | |
| if len(rows) == 1 and len(col_names) == 1: | |
| import re as _re | |
| m = _re.search(r"(FROM\s+\S+(?:\s+WHERE\s+.+)?)", sql, _re.IGNORECASE | _re.DOTALL) | |
| if m: | |
| detail_sql = f"SELECT * {m.group(1).rstrip(';')}" | |
| try: | |
| dcursor = conn.execute(detail_sql) | |
| drows = dcursor.fetchall() | |
| dcols = [d[0] for d in dcursor.description] | |
| if drows: | |
| ddf = pd.DataFrame(drows, columns=dcols) | |
| detail_str = f"\n\nUnderlying rows:\n{ddf.to_string(index=False)}" | |
| except Exception: | |
| pass | |
| conn.close() | |
| # For a single aggregate value: return it directly — no synthesis LLM to avoid misinterpretation | |
| if len(rows) == 1 and len(col_names) == 1: | |
| raw_val = rows[0][0] | |
| col_label = col_names[0] | |
| answer = f"**{col_label}:** {raw_val}" | |
| if detail_str: | |
| answer += detail_str | |
| return answer, sql | |
| answer_messages = [ | |
| {"role": "system", "content": "You are a helpful data analyst. Answer concisely based on the query results. NEVER recalculate or modify the numbers — report them exactly as returned by SQL."}, | |
| { | |
| "role": "user", | |
| "content": ( | |
| f"Question: {question}\n\n" | |
| f"SQL: {sql}\n\n" | |
| f"Results:\n{result_str}" | |
| f"{detail_str}\n\n" | |
| "Report the exact values from the results. Do not round, negate, or recalculate any numbers." | |
| ), | |
| }, | |
| ] | |
| answer = _call_llm(answer_messages).strip() | |
| return (answer if answer else result_str), sql | |
| async def query_documents(req: QueryRequest): | |
| """Query the RAG system.""" | |
| try: | |
| # Short-circuit chitchat / greetings — don't pollute with RAG results | |
| chitchat_answer = _chitchat_response(req.question) | |
| if chitchat_answer is not None: | |
| return QueryResponse(answer=chitchat_answer, sources=[]) | |
| # Doc-list question but no docs indexed yet | |
| normalized_q = req.question.strip().lower().rstrip("!?.,") | |
| if any(p in normalized_q for p in _DOCS_LIST_PATTERNS): | |
| return QueryResponse(answer="No documents are indexed yet. Please upload some documents first.", sources=[]) | |
| if vs.total_chunks() == 0: | |
| return QueryResponse(answer="No documents are indexed yet. Please upload some documents first.", sources=[]) | |
| # Run all blocking work (embedding + LLM) in a thread executor | |
| def _run_query(): | |
| sf = req.source_filter or None | |
| table_result = _run_table_query(req.question, sf) if _is_table_question(req.question) else None | |
| if table_result: | |
| table_answer, table_sql = table_result | |
| sources = sf if sf else vs.list_sources() | |
| memory.add("user", req.question) | |
| memory.add("assistant", f"{table_answer}\n\n[SQL used: {table_sql}]") | |
| return table_answer, table_sql, "table_query", list(sources), estimate_tokens(req.question), estimate_tokens(table_answer), 0 | |
| # "Summarize each/all doc" — fetch chunks from every source so no doc is missed | |
| if _is_all_docs_summary(req.question) and not sf: | |
| results = vs.query_per_source(req.question, n_per_source=2) | |
| else: | |
| results = vs.query(req.question, n_results=req.n_results, source_filter=sf) | |
| relevant = [r for r in results if r.get("distance", 2.0) <= RELEVANCE_THRESHOLD] | |
| chunks_used = len(relevant) | |
| if relevant: | |
| best_dist = min(r.get("distance", 2.0) for r in relevant) | |
| source_chunks = [r for r in relevant if r.get("distance", 2.0) <= best_dist + 0.3] | |
| else: | |
| source_chunks = results if _is_all_docs_summary(req.question) else [] | |
| sources = list({r["metadata"].get("source", "") for r in source_chunks}) | |
| parts = [] | |
| for token in rag.query(req.question, memory, n_results=req.n_results, temperature=req.temperature, stream=False, source_filter=sf, pre_fetched_results=results): | |
| parts.append(token) | |
| answer = "".join(parts) | |
| tokens_user = estimate_tokens(req.question) | |
| tokens_assistant = estimate_tokens(answer) | |
| return answer, "", "rag", sources, tokens_user, tokens_assistant, chunks_used | |
| loop = asyncio.get_running_loop() | |
| answer, sql_query, answer_method, sources, tokens_user, tokens_assistant, chunks_used = await loop.run_in_executor(None, _run_query) | |
| return QueryResponse( | |
| answer=answer, | |
| sources=sources, | |
| tokens_user=tokens_user, | |
| tokens_assistant=tokens_assistant, | |
| chunks_used=chunks_used, | |
| sql_query=sql_query, | |
| answer_method=answer_method, | |
| ) | |
| except Exception as e: | |
| logger.error(f"Query endpoint error: {e}", exc_info=True) | |
| raise HTTPException(status_code=500, detail=str(e)) | |
| async def clear_memory(): | |
| memory.clear() | |
| return {"message": "Conversation memory cleared."} | |
| async def memory_stats(): | |
| from utils.memory import estimate_tokens | |
| total_tokens = sum(estimate_tokens(m.content) for m in memory.messages) | |
| summary_tokens = estimate_tokens(memory.summary) if memory.summary else 0 | |
| return { | |
| "message_count": len(memory.messages), | |
| "total_tokens": total_tokens + summary_tokens, | |
| "has_summary": memory.summary is not None, | |
| "max_tokens": memory.max_tokens, | |
| } | |
| async def list_models(): | |
| return {"models": rag.list_available_models(), "current": OLLAMA_MODEL} | |