import os import sys import uuid import asyncio import aiofiles import concurrent.futures import logging import json import re import time import random import hmac import shutil from datetime import datetime from typing import List, Tuple, Optional, Dict, Any from contextlib import asynccontextmanager from rapidfuzz import fuzz from fastapi import FastAPI, UploadFile, File, Form, BackgroundTasks, Depends, HTTPException, Request from fastapi.responses import JSONResponse, FileResponse from fastapi.middleware.cors import CORSMiddleware from starlette.middleware.wsgi import WSGIMiddleware from zipfile import ZipFile # Setup logging logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', handlers=[ logging.StreamHandler() ] ) logger = logging.getLogger(__name__) from langchain_core.prompts import PromptTemplate from langchain_core.documents import Document # 1) Imports for OpenAI (OpenAIEmbeddings, ChatOpenAI) from langchain_openai import ChatOpenAI, OpenAIEmbeddings # 1b) Imports for Gemini (Google Generative AI) try: from langchain_google_genai import ChatGoogleGenerativeAI, GoogleGenerativeAIEmbeddings except ImportError: ChatGoogleGenerativeAI = None GoogleGenerativeAIEmbeddings = None import requests as _requests try: from openai import OpenAI as OpenAIClient except ImportError: OpenAIClient = None from pdf_text_processing import ( extract_text_from_pdf, extract_and_split, get_pdf_title, entity_match ) from vectorstore import get_vectorstore, compute_file_hash, CHROMA_DB_BASE from system_chart_extraction import system_chart_extraction from system_chart_service import SystemChartService from hypotheses_service import HypothesesService from hypotheses_generation import PATTERN as CHART_PATTERN from prompts import create_hypotheses_generation_prompt, create_design_goals_analysis_prompt from conversation_storage import save_conversation, get_conversations, get_all_conversations, update_conversation from plotextract.app import app as plotextract_app # Metadata management functions def load_metadata(database: str) -> Dict[str, Dict[str, Any]]: """ Load metadata from the METADATA file in the database directory. Returns a dictionary with file_hash -> metadata mapping. """ category_upload_dir = os.path.join(UPLOAD_DIR, database) metadata_file = os.path.join(category_upload_dir, "METADATA") if not os.path.exists(metadata_file): logger.debug(f"📋 No METADATA file found for {database}") return {} try: with open(metadata_file, 'r', encoding='utf-8') as f: metadata = json.load(f) logger.debug(f"📋 Loaded metadata for {database}: {len(metadata)} entries") logger.debug(f"📋 Hash keys: {[h[:16] + '...' for h in metadata.keys()]}") return metadata except Exception as e: logger.error(f"Error loading metadata for {database}: {str(e)}") return {} async def embed_stream_into_vs( texts, metas, vectorstore, embedding_model, batch_size: int = 512, max_concurrency: int = 12, progress_key: str = None ): """ Embeds texts in parallel batches and writes each batch immediately to the vectorstore. Retries on transient errors with exponential backoff. Provides real-time progress tracking for large document uploads. """ assert len(texts) == len(metas) n = len(texts) if n == 0: return batches = [(i, min(i + batch_size, n)) for i in range(0, n, batch_size)] sem = asyncio.Semaphore(max_concurrency) # Thread-safe progress tracking progress_lock = asyncio.Lock() processed_count = 0 async def run_batch(batch_idx, bi, bj): nonlocal processed_count async with sem: delay = 1.0 for attempt in range(6): try: # Generate embeddings for this batch embeds = await embedding_model.aembed_documents(texts[bi:bj]) # Add to vectorstore vectorstore.add_texts( texts=texts[bi:bj], metadatas=metas[bi:bj], embeddings=embeds ) # Update progress atomically async with progress_lock: processed_count += (bj - bi) if progress_key and progress_key in EMBEDDING_PROGRESS: # Calculate progress (10% to 90% for embedding phase) progress_percent = min(90, int(10 + (processed_count / n * 80))) EMBEDDING_PROGRESS[progress_key].update({ "status": "embedding", "processed_chunks": processed_count, "progress": progress_percent, "message": f"Embedded {processed_count}/{n} chunks ({len(batches)} batches)" }) logger.debug(f"Batch {batch_idx + 1}/{len(batches)} completed: {processed_count}/{n} chunks ({progress_percent}%)") return except Exception as e: # Handle retryable errors with exponential backoff msg = str(e).lower() if any(keyword in msg for keyword in ["rate", "429", "timeout", "temporarily", "overloaded", "quota"]): if attempt < 5: # Don't sleep on last attempt jitter = random.uniform(0, 0.5) sleep_time = delay + jitter logger.warning(f"Retryable error in batch {batch_idx + 1}, attempt {attempt + 1}/6: {str(e)}. Sleeping {sleep_time:.2f}s") await asyncio.sleep(sleep_time) delay *= 1.5 # More conservative backoff continue # Log the error with context logger.error(f"Failed to embed batch {batch_idx + 1}/{len(batches)} (chunks {bi}-{bj}) after {attempt + 1} attempts: {str(e)}") raise # Run all batches in parallel try: await asyncio.gather(*[ run_batch(idx, bi, bj) for idx, (bi, bj) in enumerate(batches) ]) # Final progress update before persisting if progress_key and progress_key in EMBEDDING_PROGRESS: EMBEDDING_PROGRESS[progress_key].update({ "status": "saving", "progress": 95, "message": f"Saving {n} embeddings to vector database..." }) # Persist the vectorstore vectorstore.persist() logger.info(f"✅ Successfully embedded and saved {n} chunks in {len(batches)} parallel batches") except Exception as e: # Update progress on failure if progress_key and progress_key in EMBEDDING_PROGRESS: EMBEDDING_PROGRESS[progress_key].update({ "status": "error", "progress": EMBEDDING_PROGRESS[progress_key].get("progress", 0), "message": f"Embedding failed: {str(e)}" }) logger.error(f"❌ Embedding failed after processing {processed_count}/{n} chunks: {str(e)}") raise def upsert_metadata(database: str, new_entries: Dict[str, Dict[str, Any]]) -> None: category_upload_dir = os.path.join(UPLOAD_DIR, database) os.makedirs(category_upload_dir, exist_ok=True) metadata_file = os.path.join(category_upload_dir, "METADATA") try: existing = {} if os.path.exists(metadata_file): with open(metadata_file, "r", encoding="utf-8") as f: try: existing = json.load(f) except Exception: logger.warning("⚠️ METADATA unreadable; rebuilding.") existing = {} existing.update(new_entries) with open(metadata_file, "w", encoding="utf-8") as f: json.dump(existing, f, indent=2, ensure_ascii=False) logger.info(f"📋 METADATA updated; total entries: {len(existing)}") except Exception as e: logger.error(f"Error updating METADATA: {e}") def save_metadata(database: str, metadata: Dict[str, Dict[str, Any]]): """ Save metadata to the METADATA file in the database directory. PROTECTION: Will not overwrite existing METADATA files to prevent changes. """ category_upload_dir = os.path.join(UPLOAD_DIR, database) os.makedirs(category_upload_dir, exist_ok=True) metadata_file = os.path.join(category_upload_dir, "METADATA") # PROTECTION: Check if METADATA file already exists if os.path.exists(metadata_file): logger.warning(f"🔒 METADATA file already exists for {database}. Skipping save to prevent modification.") return try: with open(metadata_file, 'w', encoding='utf-8') as f: json.dump(metadata, f, indent=2, ensure_ascii=False) logger.info(f"📋 Created new METADATA file for {database}") except Exception as e: logger.error(f"Error saving metadata for {database}: {str(e)}") def extract_file_metadata(file_path: str, original_filename: str, file_type: str, file_hash: str) -> Dict[str, Any]: """ Extract metadata from a file including title, size, and other relevant information. """ metadata = { "original_filename": original_filename, "saved_filename": os.path.basename(file_path), "file_type": file_type, "file_hash": file_hash, "upload_timestamp": datetime.now().isoformat(), "file_size": os.path.getsize(file_path) if os.path.exists(file_path) else 0 } # Extract title based on file type if file_type == "pdf": # For PDFs, extract title from the PDF content try: from pdf_text_processing import get_pdf_title title = get_pdf_title(file_path) metadata["title"] = title if title else original_filename except Exception as e: logger.warning(f"Could not extract title from PDF {original_filename}: {str(e)}") metadata["title"] = original_filename elif file_type == "text": # For text files, try to extract title from the file content try: from pdf_text_processing import get_file_title title = get_file_title(file_path) metadata["title"] = title if title else original_filename except Exception as e: logger.warning(f"Could not extract title from text file {original_filename}: {str(e)}") metadata["title"] = original_filename else: # For other files (images), use original filename as title metadata["title"] = original_filename return metadata def get_file_metadata(database: str, file_hash: str) -> Dict[str, Any]: """ Get metadata for a specific file by its hash. """ metadata = load_metadata(database) file_metadata = metadata.get(file_hash, {}) # Debug logging if file_metadata: logger.debug(f"✅ Found metadata for hash {file_hash[:16]}... in database {database}") else: logger.debug(f"❌ No metadata found for hash {file_hash[:16]}... in database {database}") logger.debug(f"📋 Available metadata hashes: {[h[:16] + '...' for h in metadata.keys()]}") return file_metadata def get_file_title_from_metadata(database: str, file_hash: str) -> str: """ Get the title of a file from its metadata. """ metadata = get_file_metadata(database, file_hash) title = metadata.get("title", "Unknown") # Debug logging if title == "Unknown": logger.debug(f"🔍 No title found in metadata for hash {file_hash[:16]}... in database {database}") else: logger.debug(f"✅ Found title in metadata for hash {file_hash[:16]}...: {title}") return title def resource_path(relative_path: str) -> str: """ Get the absolute path to a resource, works for dev and for PyInstaller. """ if hasattr(sys, '_MEIPASS'): return os.path.join(sys._MEIPASS, relative_path) return os.path.join(os.path.dirname(__file__), relative_path) def get_base_directory() -> str: """ HF Spaces-safe data root: - Prefer explicit override via APP_DATA_DIR or PERSISTENT_STORAGE_PATH - Else use /data/ only if it exists and is writable - Else use $HOME/.cache/ - Else use /tmp/ """ override = (os.environ.get("APP_DATA_DIR") or os.environ.get("PERSISTENT_STORAGE_PATH") or "").strip() if override: os.makedirs(override, exist_ok=True) return override if os.path.isdir("/data") and os.access("/data", os.W_OK): base = "/data/pdf-rag" os.makedirs(base, exist_ok=True) return base home = os.environ.get("HOME") if home: base = os.path.join(home, ".cache", "pdf-rag") os.makedirs(base, exist_ok=True) return base base = "/tmp/pdf-rag" os.makedirs(base, exist_ok=True) return base # Define paths relative to the base directory for persistent storage APP_DATA_ROOT = get_base_directory() UPLOAD_DIR = os.path.join(APP_DATA_ROOT, "uploaded_files") os.makedirs(UPLOAD_DIR, exist_ok=True) logger.info(f"UPLOAD_DIR set to: {os.path.abspath(UPLOAD_DIR)}") @asynccontextmanager async def lifespan(app: FastAPI): """ FastAPI lifespan: 1) Load existing databases from previous sessions """ load_existing_databases() yield app = FastAPI(lifespan=lifespan) app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"] ) app.mount("/plotextract", WSGIMiddleware(plotextract_app)) # A registry of hash -> filename for each "database" category FILE_REGISTRY = {} # Progress tracking for embedding tasks EMBEDDING_PROGRESS = {} # Hypothesis generation context storage for continuous conversation HYPOTHESIS_GENERATION_CONTEXT = {} # Conversation storage for system chart extraction is now handled by conversation_storage module def load_existing_databases(): """ Scan the structured uploaded files directory and existing databases to populate FILE_REGISTRY with information about previously uploaded files. Now uses category subdirectories. Note: Only loads text and image files since PDFs are now converted to text during upload. Also handles METADATA files for preserving file information. """ global FILE_REGISTRY logger.info("🔍 Scanning for existing databases...") # Get all database directories from root databases database_dirs = [] chroma_db_base = CHROMA_DB_BASE if os.path.exists(chroma_db_base): for item in os.listdir(chroma_db_base): db_path = os.path.join(chroma_db_base, item) if os.path.isdir(db_path) and not item.startswith('.'): database_dirs.append(item) logger.info(f"Found database directories: {database_dirs}") # For each database directory, check if there's a corresponding uploaded files subdirectory for db_name in database_dirs: if db_name not in FILE_REGISTRY: FILE_REGISTRY[db_name] = {} # Check for corresponding uploaded files subdirectory category_upload_dir = os.path.join(UPLOAD_DIR, db_name) if os.path.exists(category_upload_dir) and os.path.isdir(category_upload_dir): logger.info(f"📁 Found uploaded files directory for {db_name}") # Load existing metadata existing_metadata = load_metadata(db_name) # Scan all files in this category directory files_found = 0 metadata_needs_migration = False migrated_metadata = {} for file in os.listdir(category_upload_dir): if not file.startswith('.') and file != "METADATA": file_path = os.path.join(category_upload_dir, file) if os.path.isfile(file_path): try: # Calculate file hash and add to registry with open(file_path, 'rb') as f: content = f.read() file_hash = compute_file_hash(content) FILE_REGISTRY[db_name][file_hash] = file files_found += 1 # Check if this file exists in metadata with the current hash if file_hash in existing_metadata: # Perfect match - use existing metadata migrated_metadata[file_hash] = existing_metadata[file_hash] else: # Hash mismatch - try to find by filename pattern for .txt files (migrated from PDF) found_metadata = None if file.endswith('.txt'): # Try to find metadata entry that matches this file's original filename pattern for old_hash, meta in existing_metadata.items(): if meta.get('saved_filename') == file: logger.info(f"🔄 Migrating metadata for {file}: {old_hash[:16]}... -> {file_hash[:16]}...") found_metadata = meta.copy() found_metadata['file_hash'] = file_hash # Update to new hash migrated_metadata[file_hash] = found_metadata metadata_needs_migration = True break # If still not found, try matching by original filename if not found_metadata: for old_hash, meta in existing_metadata.items(): # Extract the original filename without extension and compare original_name = os.path.splitext(meta.get('original_filename', ''))[0] file_name_part = file.split('_', 1)[1] if '_' in file else file # Remove UUID prefix file_name_part = os.path.splitext(file_name_part)[0] # Remove .txt extension if original_name and original_name == file_name_part: logger.info(f"🔄 Migrating metadata by filename match for {file}: {original_name}") found_metadata = meta.copy() found_metadata['file_hash'] = file_hash # Update to new hash found_metadata['saved_filename'] = file # Update saved filename migrated_metadata[file_hash] = found_metadata metadata_needs_migration = True break # If no metadata found, create new metadata if not found_metadata: logger.info(f"📋 Creating metadata for existing file: {file}") # Determine file type file_ext = os.path.splitext(file.lower())[1] if file_ext in ['.txt', '.text']: file_type = "text" # Could be from PDF or original text elif file_ext in ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.tiff', '.webp']: file_type = "image" else: file_type = "unknown" # Create metadata for the file file_metadata = extract_file_metadata(file_path, file, file_type, file_hash) migrated_metadata[file_hash] = file_metadata metadata_needs_migration = True except Exception as e: logger.warning(f"Error processing file {file}: {e}") # Update existing_metadata with migrated data existing_metadata = migrated_metadata # Log migration results if metadata_needs_migration: logger.info(f"🔄 Metadata migration completed for {db_name} database") # Save updated metadata (only if METADATA file doesn't exist yet) if existing_metadata: metadata_file = os.path.join(category_upload_dir, "METADATA") if not os.path.exists(metadata_file): save_metadata(db_name, existing_metadata) else: logger.info(f"🔒 METADATA file already exists for {db_name}. Skipping metadata updates.") logger.info(f"📁 Added {files_found} files to {db_name} database") else: logger.info(f"⚠️ No uploaded files directory found for {db_name} (expected: {category_upload_dir})") # Also check for any uploaded files subdirectories that don't have corresponding databases yet if os.path.exists(UPLOAD_DIR): for item in os.listdir(UPLOAD_DIR): item_path = os.path.join(UPLOAD_DIR, item) if os.path.isdir(item_path) and not item.startswith('.'): if item not in FILE_REGISTRY: # Found a files directory without a corresponding database logger.info(f"📂 Found uploaded files directory '{item}' without corresponding database") FILE_REGISTRY[item] = {} # Load existing metadata existing_metadata = load_metadata(item) # Add files from this directory files_found = 0 metadata_needs_migration = False migrated_metadata = {} for file in os.listdir(item_path): if not file.startswith('.') and file != "METADATA": file_path = os.path.join(item_path, file) if os.path.isfile(file_path): try: with open(file_path, 'rb') as f: content = f.read() file_hash = compute_file_hash(content) FILE_REGISTRY[item][file_hash] = file files_found += 1 # Check if this file exists in metadata with the current hash if file_hash in existing_metadata: # Perfect match - use existing metadata migrated_metadata[file_hash] = existing_metadata[file_hash] else: # Hash mismatch - try to find by filename pattern for .txt files (migrated from PDF) found_metadata = None if file.endswith('.txt'): # Try to find metadata entry that matches this file's original filename pattern for old_hash, meta in existing_metadata.items(): if meta.get('saved_filename') == file: logger.info(f"🔄 Migrating metadata for {file}: {old_hash[:16]}... -> {file_hash[:16]}...") found_metadata = meta.copy() found_metadata['file_hash'] = file_hash # Update to new hash migrated_metadata[file_hash] = found_metadata metadata_needs_migration = True break # If still not found, try matching by original filename if not found_metadata: for old_hash, meta in existing_metadata.items(): # Extract the original filename without extension and compare original_name = os.path.splitext(meta.get('original_filename', ''))[0] file_name_part = file.split('_', 1)[1] if '_' in file else file # Remove UUID prefix file_name_part = os.path.splitext(file_name_part)[0] # Remove .txt extension if original_name and original_name == file_name_part: logger.info(f"🔄 Migrating metadata by filename match for {file}: {original_name}") found_metadata = meta.copy() found_metadata['file_hash'] = file_hash # Update to new hash found_metadata['saved_filename'] = file # Update saved filename migrated_metadata[file_hash] = found_metadata metadata_needs_migration = True break # If no metadata found, create new metadata if not found_metadata: logger.info(f"📋 Creating metadata for existing file: {file}") # Determine file type file_ext = os.path.splitext(file.lower())[1] if file_ext in ['.txt', '.text']: file_type = "text" elif file_ext in ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.tiff', '.webp']: file_type = "image" else: file_type = "unknown" # Create metadata for the file file_metadata = extract_file_metadata(file_path, file, file_type, file_hash) migrated_metadata[file_hash] = file_metadata metadata_needs_migration = True except Exception as e: logger.warning(f"Error processing file {file}: {e}") # Update existing_metadata with migrated data existing_metadata = migrated_metadata # Log migration results if metadata_needs_migration: logger.info(f"🔄 Metadata migration completed for {item} database") # Save updated metadata (only if METADATA file doesn't exist yet) if existing_metadata: metadata_file = os.path.join(item_path, "METADATA") if not os.path.exists(metadata_file): save_metadata(item, existing_metadata) else: logger.info(f"🔒 METADATA file already exists for {item}. Skipping metadata updates.") logger.info(f"📁 Added {files_found} files to {item} (files-only database)") # Clean up empty databases empty_dbs = [db for db, files in FILE_REGISTRY.items() if not files] for db in empty_dbs: del FILE_REGISTRY[db] logger.info(f"🗑️ Removed empty database: {db}") logger.info(f"✅ Loaded {len(FILE_REGISTRY)} databases with files:") for db_name, files in FILE_REGISTRY.items(): logger.info(f" 📚 {db_name}: {len(files)} files") # Utility functions for file type detection def is_image_file(filename: str) -> bool: """Check if a file is an image based on its extension.""" image_extensions = ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.tiff', '.webp'] file_ext = os.path.splitext(filename.lower())[1] return file_ext in image_extensions def is_text_or_pdf_file(filename: str) -> bool: """Check if a file is a PDF or text file based on its extension.""" text_pdf_extensions = ['.pdf', '.txt', '.text'] file_ext = os.path.splitext(filename.lower())[1] return file_ext in text_pdf_extensions def get_file_type(filename: str) -> str: """Return the file type category: 'image', 'text_pdf', or 'unknown'.""" if is_image_file(filename): return 'image' elif is_text_or_pdf_file(filename): return 'text_pdf' else: return 'unknown' # Conversation management functions are now handled by conversation_storage module # -------------------- Per-browser workspaces -------------------- # # Each browser gets a random workspace id in a cookie. Every server-side key # (database, progress/extraction id, conversation, hypothesis run) is stored as # "-", so users only see their own data; the client only ever # sees the unprefixed names. API keys and models are held per workspace too. WORKSPACE_COOKIE = "madtool_workspace" WORKSPACE_MODELS: Dict[str, Dict[str, Any]] = {} # workspace -> {"embedding", "qa", "chart"} def is_workspace_id(value: Optional[str]) -> bool: return bool(value) and re.fullmatch(r"[0-9a-f]{32}", value) is not None def workspace(request: Request) -> str: ws = request.cookies.get(WORKSPACE_COOKIE) if not is_workspace_id(ws): raise HTTPException(status_code=401, detail="Your session has no workspace. Please reload the page.") return ws def scoped(ws: str, name: str) -> str: return f"{ws}-{name}" def unscoped(ws: str, key: str) -> Optional[str]: """The client-facing name of a key in workspace `ws`, or None if the key isn't theirs.""" prefix = f"{ws}-" return key[len(prefix):] if key.startswith(prefix) else None @app.get("/") def get_index(request: Request): response = FileResponse(resource_path("index.html")) if not is_workspace_id(request.cookies.get(WORKSPACE_COOKIE)): # Deployed, the site is always HTTPS (HF's proxy terminates TLS and doesn't say so), # and SameSite=None lets the cookie work inside the huggingface.co Space iframe. # Local http dev keeps a plain Lax cookie. secure = request.url.hostname not in ("localhost", "127.0.0.1") response.set_cookie( WORKSPACE_COOKIE, uuid.uuid4().hex, max_age=365 * 24 * 3600, httponly=True, secure=secure, samesite="none" if secure else "lax", ) return response # -------------------- Model / API related info -------------------- # OPENAI_MODELS = [ "gpt-5-nano", "gpt-5-mini", "gpt-5.2" ] GEMINI_MODELS = [ "gemini-3-flash-preview", "gemini-3-pro-preview", "gemini-2.5-flash-lite" ] AVAILABLE_MODELS = OPENAI_MODELS + GEMINI_MODELS DEFAULT_MODEL = "gpt-5.2" # Shared output-length cap for QA, system chart extraction, and hypotheses. # LangChain's max_tokens maps to provider-specific names: # OpenAI max_output_tokens / max_completion_tokens, Gemini maxOutputTokens. DEFAULT_MAX_OUTPUT_TOKENS = 8192 ADMIN_DELETE_CONFIRMATION_TEXT = "DELETE APP DATA" def get_model_provider(model: str) -> Optional[str]: if model in OPENAI_MODELS: return "openai" if model in GEMINI_MODELS: return "gemini" return None def detect_provider_from_key(api_key: str) -> Optional[str]: """Infer provider from an API key prefix. AIza... → Gemini, sk-... → OpenAI. Anthropic (sk-ant-...) keys are detected but unsupported — they must be checked *before* the generic sk- prefix so they are not misread as OpenAI; callers reject the "anthropic" value with a clear message. """ key = (api_key or "").strip() if not key: return None if key.startswith("sk-ant-"): return "anthropic" if key.startswith("AIza"): return "gemini" if key.startswith("sk-"): return "openai" return None UNSUPPORTED_ANTHROPIC_KEY_MESSAGE = ( "Anthropic (sk-ant-...) keys are not supported: Anthropic does not provide an " "embedding model, which this tool requires. Please use an OpenAI (sk-...) or " "Gemini (AIza...) key." ) def provider_display_name(provider: str) -> str: return {"openai": "OpenAI", "gemini": "Gemini"}.get(provider, provider or "Unknown") # Substrings of OpenAI model ids that are not text-chat models (same list # PlotExtract uses, plus completions-only "instruct" models). "search" also # covers the tool-only deep-research models. OPENAI_NON_CHAT_MARKERS = ( "audio", "dall-e", "embedding", "gpt-image", "instruct", "moderation", "realtime", "search", "transcribe", "tts", "whisper", ) def is_openai_chat_model(model_id: str) -> bool: mid = model_id.lower() is_family = "gpt" in mid or re.match(r"^o\d+(?:-|$)", mid) # GPT and o-series reasoning models return bool(is_family) and not any(marker in mid for marker in OPENAI_NON_CHAT_MARKERS) def list_openai_chat_models(api_key: str) -> List[Dict[str, str]]: if OpenAIClient is None: raise RuntimeError("openai SDK is not installed.") client = OpenAIClient(api_key=api_key) page = client.models.list() out: List[Dict[str, Any]] = [] seen = set() for item in getattr(page, "data", []) or []: model_id = getattr(item, "id", None) or (item.get("id") if isinstance(item, dict) else None) if not model_id or model_id in seen: continue if not is_openai_chat_model(model_id): continue seen.add(model_id) created = getattr(item, "created", None) or (item.get("created") if isinstance(item, dict) else None) out.append({"id": model_id, "display_name": model_id, "created": created or 0}) # Newest first by release timestamp out.sort(key=lambda m: (m["created"], m["id"]), reverse=True) for m in out: m.pop("created") return out def _fetch_gemini_model_list(api_key: str) -> List[Dict[str, Any]]: url = "https://generativelanguage.googleapis.com/v1beta/models" response = _requests.get(url, params={"key": api_key}, timeout=15) if response.status_code != 200: try: err = response.json().get("error", {}).get("message") or response.text except Exception: err = response.text raise RuntimeError(f"Gemini API returned {response.status_code}: {err}") payload = response.json() or {} return payload.get("models", []) or [] # Substrings of Gemini model ids that support generateContent but are not # text-chat models (speech, image generation, live audio, tool-only agents). GEMINI_NON_CHAT_MARKERS = ( "aqa", "audio", "computer-use", "embedding", "image", "live", "robotics", "tts", ) def gemini_sort_key(model_id: str) -> Tuple: """Newest-first ordering for Gemini ids, which carry no release date: the version number tracks release order, then pro > flash > flash-lite, then stable before preview/experimental. "-latest" aliases float to the top. """ mid = model_id.lower() version = re.search(r"-(\d+(?:\.\d+)?)(?:-|$)", mid) if "lite" in mid: tier = 1 elif "flash" in mid: tier = 2 elif "pro" in mid: tier = 3 else: tier = 0 return ( "latest" in mid, mid.startswith("gemini"), float(version.group(1)) if version else 0.0, tier, not any(tag in mid for tag in ("preview", "exp")), mid, ) def list_gemini_models(api_key: str) -> List[Dict[str, str]]: """List Gemini chat models via the public REST API to avoid pinning conflicts between google-generativeai and langchain-google-genai. """ out: List[Dict[str, str]] = [] seen = set() for item in _fetch_gemini_model_list(api_key): methods = item.get("supportedGenerationMethods") or [] if "generateContent" not in methods: continue raw_id = item.get("name", "") model_id = raw_id.split("/", 1)[-1] if raw_id else "" if not model_id or model_id in seen: continue if any(marker in model_id.lower() for marker in GEMINI_NON_CHAT_MARKERS): continue seen.add(model_id) display = item.get("displayName") or model_id out.append({"id": model_id, "display_name": str(display)}) out.sort(key=lambda m: gemini_sort_key(m["id"]), reverse=True) return out def pick_gemini_embedding_model(api_key: str) -> str: """Find a Gemini embedding model this key actually has access to. The v1beta endpoint exposes different embedding models depending on the key's tier and rollout — `embedding-001` and `text-embedding-004` are sometimes 404. We list models, filter to those supporting `embedContent`, and pick the newest one we recognise. """ try: embedding_models: List[str] = [] for item in _fetch_gemini_model_list(api_key): methods = item.get("supportedGenerationMethods") or [] if "embedContent" not in methods: continue name = item.get("name") or "" if name: embedding_models.append(name) for preferred_prefix in ( "models/gemini-embedding", "models/text-embedding", "models/embedding", ): for name in embedding_models: if name.startswith(preferred_prefix): return name if embedding_models: return embedding_models[0] except Exception as exc: logger.warning(f"Failed to pick Gemini embedding model dynamically, falling back: {exc}") return "models/gemini-embedding-001" def fetch_models_for_provider(provider: str, api_key: str) -> List[Dict[str, str]]: if provider == "openai": return list_openai_chat_models(api_key) if provider == "gemini": return list_gemini_models(api_key) return [] def choose_default_model(models: List[Dict[str, str]], provider: str) -> str: # The provider lists are already sorted newest-first, so the default is # simply the top entry. return models[0]["id"] if models else "" class AsyncEmbeddingAdapter: def __init__(self, embedder): self._embedder = embedder async def aembed_documents(self, texts): if hasattr(self._embedder, "aembed_documents"): return await self._embedder.aembed_documents(texts) return await asyncio.to_thread(self._embedder.embed_documents, texts) def __getattr__(self, name): return getattr(self._embedder, name) def ensure_async_embeddings(embedder): if hasattr(embedder, "aembed_documents"): return embedder return AsyncEmbeddingAdapter(embedder) def llm_content_to_text(content: Any) -> str: """ Normalize LLM content payloads (string/list/dict) into plain text. Some providers (e.g., Responses API) may return structured blocks. """ if content is None: return "" if isinstance(content, str): return content if isinstance(content, list): parts: List[str] = [] for item in content: text = llm_content_to_text(item) if text: parts.append(text) return "\n".join(parts) if isinstance(content, dict): # Common structured message shapes from provider SDKs. for key in ("text", "content", "output_text"): if key in content: return llm_content_to_text(content.get(key)) return json.dumps(content, ensure_ascii=False) return str(content) @app.post("/set_api_key") async def set_api_key( api_key_input: Optional[str] = Form(None), model: str = Form(DEFAULT_MODEL), ws: str = Depends(workspace), ): """ Sets up this workspace's LLM and embeddings for proprietary models. """ if not api_key_input: return JSONResponse( {"message": "Error: API key is required for proprietary models."}, status_code=400 ) api_key = api_key_input.strip() model_provider = detect_provider_from_key(api_key) if model_provider == "anthropic": return JSONResponse( content={"message": UNSUPPORTED_ANTHROPIC_KEY_MESSAGE}, status_code=400 ) if not model_provider: return JSONResponse( content={"message": "Unsupported API key format. Use an OpenAI (sk-...) or Gemini (AIza...) key."}, status_code=400 ) if not model: return JSONResponse( content={"message": "Please select a model."}, status_code=400 ) DEFAULT_MODEL_QA = model DEFAULT_MODEL_SYSTEM_CHART_EXTRACTION = model try: if not api_key: return JSONResponse( content={"message": "Error: API key is required for proprietary models."}, status_code=400 ) if model_provider == "openai": embedding_model = OpenAIEmbeddings(openai_api_key=api_key) embedding_model = ensure_async_embeddings(embedding_model) openai_common_kwargs: Dict[str, Any] = { "openai_api_key": api_key, "model": DEFAULT_MODEL_QA, "use_responses_api": True, "max_tokens": DEFAULT_MAX_OUTPUT_TOKENS, } openai_chart_kwargs: Dict[str, Any] = { "openai_api_key": api_key, "model": DEFAULT_MODEL_SYSTEM_CHART_EXTRACTION, "use_responses_api": True, "max_tokens": DEFAULT_MAX_OUTPUT_TOKENS, } llm_QA = ChatOpenAI( **openai_common_kwargs ) llm_system_chart_extraction = ChatOpenAI( **openai_chart_kwargs ) provider_label = "OpenAI" elif model_provider == "gemini": if ChatGoogleGenerativeAI is None or GoogleGenerativeAIEmbeddings is None: return JSONResponse( content={"message": "Gemini models require langchain-google-genai. Please install it to continue."}, status_code=500 ) gemini_embedding_model_name = pick_gemini_embedding_model(api_key) logger.info(f"Using Gemini embedding model: {gemini_embedding_model_name}") embedding_model = GoogleGenerativeAIEmbeddings( google_api_key=api_key, model=gemini_embedding_model_name ) embedding_model = ensure_async_embeddings(embedding_model) gemini_common_kwargs: Dict[str, Any] = { "google_api_key": api_key, "model": DEFAULT_MODEL_QA, "max_tokens": DEFAULT_MAX_OUTPUT_TOKENS, } gemini_chart_kwargs: Dict[str, Any] = { "google_api_key": api_key, "model": DEFAULT_MODEL_SYSTEM_CHART_EXTRACTION, "max_tokens": DEFAULT_MAX_OUTPUT_TOKENS, } llm_QA = ChatGoogleGenerativeAI( **gemini_common_kwargs ) llm_system_chart_extraction = ChatGoogleGenerativeAI( **gemini_chart_kwargs ) provider_label = "Gemini" else: return JSONResponse( content={"message": f"Unsupported provider: {model_provider}."}, status_code=400 ) WORKSPACE_MODELS[ws] = {"embedding": embedding_model, "qa": llm_QA, "chart": llm_system_chart_extraction} return JSONResponse( content={"message": f"{provider_label} model '{model}' set successfully!"}, status_code=200 ) except Exception as e: return JSONResponse( content={"message": f"Error setting up model: {str(e)}"}, status_code=500 ) @app.get("/available_models") async def get_available_models(): return JSONResponse(content={ "models": AVAILABLE_MODELS }) @app.post("/list_models") async def list_models_for_key(api_key_input: Optional[str] = Form(None)): """Return the models available for the provider that the supplied API key belongs to. Provider is inferred from the key prefix; the dropdown only ever shows models matching the detected provider. """ key = (api_key_input or "").strip() if not key: return JSONResponse({"message": "API key is required."}, status_code=400) provider = detect_provider_from_key(key) if provider == "anthropic": return JSONResponse( {"message": UNSUPPORTED_ANTHROPIC_KEY_MESSAGE}, status_code=400, ) if not provider: return JSONResponse( {"message": "Unsupported API key format. Use an OpenAI (sk-...) or Gemini (AIza...) key."}, status_code=400, ) try: models = fetch_models_for_provider(provider, key) except Exception as exc: return JSONResponse( {"message": f"Failed to fetch models from {provider_display_name(provider)}.", "details": str(exc)}, status_code=400, ) if not models: return JSONResponse( {"message": f"No models available for this {provider_display_name(provider)} key."}, status_code=400, ) return JSONResponse({ "provider": provider, "provider_label": provider_display_name(provider), "models": models, "default_model": choose_default_model(models, provider), }) # -------------------- Upload -------------------- # async def save_file(file: UploadFile, database: str) -> Tuple[str, str]: """ Saves text or image files to disk in a subdirectory for the database. For PDFs: extracts text and saves only the .txt file (not the original PDF). For text files: saves the text file as-is. For images: saves the image file as-is. Also extracts and saves metadata including paper titles to a METADATA file. Returns (file_path, duplicate_filename). If duplicate is detected, returns (None, original_filename). """ # Check if file type is supported file_ext = os.path.splitext(file.filename.lower())[1] supported_extensions = ['.pdf', '.txt', '.text', '.png', '.jpg', '.jpeg', '.gif', '.bmp', '.tiff', '.webp'] if file_ext not in supported_extensions: logger.warning(f"⚠️ Unsupported file type: {file.filename}") return None, f"Unsupported file type: {file.filename}" content = await file.read() # For non-PDF files, check duplicates immediately # For PDFs, duplicate check happens after text extraction (due to hash consistency) if file_ext != '.pdf': file_hash = compute_file_hash(content) if file_hash in FILE_REGISTRY[database]: logger.warning(f"⚠️ Duplicate detected: {file.filename}") return None, file.filename # Create category-specific directory category_upload_dir = os.path.join(UPLOAD_DIR, database) os.makedirs(category_upload_dir, exist_ok=True) # PROTECTION: Check if METADATA file already exists metadata_file = os.path.join(category_upload_dir, "METADATA") if os.path.exists(metadata_file): error_msg = f"🔒 Database '{database}' is locked. Cannot add files to databases with existing METADATA files." logger.warning(error_msg) return None, error_msg file_id = str(uuid.uuid4())[:8] image_extensions = ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.tiff', '.webp'] if file_ext == '.pdf': # For PDFs: Extract text AND preserve original PDF file logger.info(f"Processing PDF: {file.filename} (will save both text and original PDF)") # Create temporary file to extract text and metadata from import tempfile with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as temp_pdf: temp_pdf.write(content) temp_pdf_path = temp_pdf.name try: # Extract text from the temporary PDF docs = extract_text_from_pdf(temp_pdf_path) if not docs: logger.warning(f"⚠️ No text could be extracted from PDF: {file.filename}") os.unlink(temp_pdf_path) # Clean up temp file return None, f"No text could be extracted from: {file.filename}" # Save the extracted text file text_filename = f"{file_id}_{os.path.splitext(file.filename)[0]}.txt" text_path = os.path.join(category_upload_dir, text_filename) async with aiofiles.open(text_path, "w", encoding="utf-8") as tfile: await tfile.write(docs[0].page_content) # ALSO save the original PDF file pdf_filename = f"{file_id}_{file.filename}" pdf_path = os.path.join(category_upload_dir, pdf_filename) async with aiofiles.open(pdf_path, "wb") as pfile: await pfile.write(content) # CRITICAL FIX: Recompute hash from the saved .txt file content, not original PDF # This ensures hash consistency between upload and loading phases with open(text_path, "rb") as txt_file: txt_content = txt_file.read() file_hash = compute_file_hash(txt_content) # Use TXT hash instead of PDF hash # Check for duplicates with the new hash if file_hash in FILE_REGISTRY[database]: logger.warning(f"⚠️ Duplicate detected after text extraction: {file.filename}") os.unlink(temp_pdf_path) # Clean up temp file os.unlink(pdf_path) # Clean up saved PDF return None, file.filename # Extract and save metadata (including title from original PDF and PDF path) file_metadata = extract_file_metadata(temp_pdf_path, file.filename, "pdf", file_hash) file_metadata["pdf_path"] = pdf_filename # Store PDF filename upsert_metadata(database, {file_hash: file_metadata}) # Register the text file in FILE_REGISTRY (for text-based operations) FILE_REGISTRY[database][file_hash] = text_filename logger.info(f"📄 Extracted and saved text: {text_filename} in {database}") logger.info(f"📄 Also saved original PDF: {pdf_filename}") logger.info(f"📋 Saved metadata with title: {file_metadata.get('title', 'Unknown')}") logger.info(f"🔑 Using TXT file hash: {file_hash[:16]}... for consistency") os.unlink(temp_pdf_path) # Clean up temp file return text_path, None except Exception as e: logger.error(f"❌ Error processing PDF {file.filename}: {str(e)}") os.unlink(temp_pdf_path) # Clean up temp file return None, f"Error processing PDF: {str(e)}" elif file_ext in image_extensions: # For images: Save the image file as-is filename = f"{file_id}_{file.filename}" file_path = os.path.join(category_upload_dir, filename) logger.info(f"Saving image to: {os.path.abspath(file_path)}") async with aiofiles.open(file_path, "wb") as f: await f.write(content) # Extract and save metadata for image (file_hash already computed above) file_metadata = extract_file_metadata(file_path, file.filename, "image", file_hash) upsert_metadata(database, {file_hash: file_metadata}) FILE_REGISTRY[database][file_hash] = filename logger.info(f"📸 Saved image: {filename} in {database}") logger.info(f"📋 Saved metadata for image: {file.filename}") return file_path, None else: # For text files: Save as text with UTF-8 encoding filename = f"{file_id}_{file.filename}" file_path = os.path.join(category_upload_dir, filename) logger.info(f"Saving text file to: {os.path.abspath(file_path)}") async with aiofiles.open(file_path, "w", encoding="utf-8") as f: await f.write(content.decode('utf-8')) # Extract and save metadata for text file (file_hash already computed above) file_metadata = extract_file_metadata(file_path, file.filename, "text", file_hash) upsert_metadata(database, {file_hash: file_metadata}) FILE_REGISTRY[database][file_hash] = filename logger.info(f"📝 Saved text file: {filename} in {database}") logger.info(f"📋 Saved metadata with title: {file_metadata.get('title', 'Unknown')}") return file_path, None def save_file_from_bytes(content: bytes, original_filename: str, database: str) -> Tuple[str, str]: """ Save a single file provided as raw bytes with duplicate checks and metadata handling. Returns (file_path, duplicate_or_error_message). If duplicate or error, file_path is None. Mirrors logic in save_file() but for in-memory content (e.g., ZIP entries). """ # Check if file type is supported file_ext = os.path.splitext(original_filename.lower())[1] supported_extensions = ['.pdf', '.txt', '.text', '.png', '.jpg', '.jpeg', '.gif', '.bmp', '.tiff', '.webp'] if file_ext not in supported_extensions: logger.warning(f"⚠️ Unsupported file type in ZIP: {original_filename}") return None, f"Unsupported file type: {original_filename}" # For non-PDF files, check duplicates immediately if file_ext != '.pdf': file_hash = compute_file_hash(content) if file_hash in FILE_REGISTRY[database]: logger.warning(f"⚠️ Duplicate detected in ZIP: {original_filename}") return None, original_filename # Create category-specific directory category_upload_dir = os.path.join(UPLOAD_DIR, database) os.makedirs(category_upload_dir, exist_ok=True) file_id = str(uuid.uuid4())[:8] image_extensions = ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.tiff', '.webp'] if file_ext == '.pdf': # For PDFs: Extract text AND preserve original PDF file logger.info(f"Processing PDF from ZIP: {original_filename} (will save both text and original PDF)") import tempfile # Create temporary file to extract text and metadata from with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as temp_pdf: temp_pdf.write(content) temp_pdf_path = temp_pdf.name try: # Extract text from the temporary PDF docs = extract_text_from_pdf(temp_pdf_path) if not docs: logger.warning(f"⚠️ No text could be extracted from PDF (ZIP): {original_filename}") os.unlink(temp_pdf_path) return None, f"No text could be extracted from: {original_filename}" # Save the extracted text file text_filename = f"{file_id}_{os.path.splitext(original_filename)[0]}.txt" text_path = os.path.join(category_upload_dir, text_filename) with open(text_path, "w", encoding="utf-8") as tfile: tfile.write(docs[0].page_content) # ALSO save the original PDF file pdf_filename = f"{file_id}_{original_filename}" pdf_path = os.path.join(category_upload_dir, pdf_filename) with open(pdf_path, "wb") as pfile: pfile.write(content) # Recompute hash from the saved .txt file content with open(text_path, "rb") as txt_file: txt_content = txt_file.read() file_hash = compute_file_hash(txt_content) # Check for duplicates with the new hash if file_hash in FILE_REGISTRY[database]: logger.warning(f"⚠️ Duplicate detected after text extraction (ZIP): {original_filename}") os.unlink(temp_pdf_path) os.unlink(pdf_path) try: os.unlink(text_path) except Exception: pass return None, original_filename # Extract and save metadata (including title from original PDF and PDF path) file_metadata = extract_file_metadata(temp_pdf_path, original_filename, "pdf", file_hash) file_metadata["pdf_path"] = pdf_filename upsert_metadata(database, {file_hash: file_metadata}) # Register the text file in FILE_REGISTRY FILE_REGISTRY[database][file_hash] = text_filename logger.info(f"📄 Extracted and saved text (ZIP): {text_filename} in {database}") logger.info(f"📄 Also saved original PDF (ZIP): {pdf_filename}") os.unlink(temp_pdf_path) return text_path, None except Exception as e: logger.error(f"❌ Error processing PDF from ZIP {original_filename}: {str(e)}") try: os.unlink(temp_pdf_path) except Exception: pass return None, f"Error processing PDF: {str(e)}" elif file_ext in image_extensions: # For images: Save the image file as-is filename = f"{file_id}_{original_filename}" file_path = os.path.join(category_upload_dir, filename) logger.info(f"Saving image (ZIP) to: {os.path.abspath(file_path)}") with open(file_path, "wb") as f: f.write(content) # Extract and save metadata for image file_hash = compute_file_hash(content) file_metadata = extract_file_metadata(file_path, original_filename, "image", file_hash) upsert_metadata(database, {file_hash: file_metadata}) FILE_REGISTRY[database][file_hash] = filename logger.info(f"📸 Saved image (ZIP): {filename} in {database}") return file_path, None else: # For text files: Save as text with UTF-8 encoding filename = f"{file_id}_{original_filename}" file_path = os.path.join(category_upload_dir, filename) logger.info(f"Saving text file (ZIP) to: {os.path.abspath(file_path)}") with open(file_path, "w", encoding="utf-8") as f: f.write(content.decode('utf-8')) # Extract and save metadata for text file file_hash = compute_file_hash(content) file_metadata = extract_file_metadata(file_path, original_filename, "text", file_hash) upsert_metadata(database, {file_hash: file_metadata}) FILE_REGISTRY[database][file_hash] = filename logger.info(f"📝 Saved text file (ZIP): {filename} in {database}") return file_path, None def obtain_text_from_files(database: str): """ Returns a dict of title -> full file text for all text files in the given database. Now that PDFs are converted to .txt files during upload, we only need to read the text files. Uses metadata to get proper titles including original PDF titles. """ file_dict = {} if database not in FILE_REGISTRY or not FILE_REGISTRY[database]: logger.warning(f"Database '{database}' not found or empty; returning no papers.") return {} # Create path to category-specific directory category_upload_dir = os.path.join(UPLOAD_DIR, database) for file_hash, filename in FILE_REGISTRY[database].items(): file_path = os.path.join(category_upload_dir, filename) # Skip image files - we only want text content if is_image_file(filename): continue # Since PDFs are now converted to .txt files during upload, # we only need to read text files directly if filename.lower().endswith(('.txt', '.text')): try: # Get title from metadata (preserves original PDF titles) file_title = get_file_title_from_metadata(database, file_hash) if not file_title or file_title == "Unknown": # Fallback to extracting title from file content logger.info(f"🔍 Metadata lookup failed for {filename} (hash: {file_hash[:16]}...), using fallback title extraction") from pdf_text_processing import get_file_title temp_title = get_file_title(file_path) file_title = temp_title if temp_title else filename logger.info(f"📝 Fallback title for {filename}: {file_title}") else: logger.info(f"✅ Using title from metadata for {filename}: {file_title}") # Read the text file content with open(file_path, "r", encoding="utf-8") as f: file_text = f.read() file_dict[file_title] = file_text except Exception as e: logger.error(f"❌ Error reading file {filename}: {str(e)}") continue return file_dict def obtain_text_from_pdf(database: str): """ Legacy function name for backward compatibility. Now handles both PDF and text files. """ return obtain_text_from_files(database) def extract_all_files(file_paths: List[str]) -> List[Document]: """ Runs text extraction + chunk-splitting on each file (text files only, since PDFs are now converted to .txt during upload). """ all_docs = [] with concurrent.futures.ThreadPoolExecutor( max_workers=min(32, os.cpu_count()) ) as executor: futures = [executor.submit(extract_and_split, path) for path in file_paths] for future in concurrent.futures.as_completed(futures): result = future.result() if result: all_docs.extend(result) return all_docs def save_to_vectorstore(embeddings, texts, metadatas, vectorstore): """ Save a batch of (embeddings, texts, metadatas) to the vectorstore in chunks. """ try: batch_size = 4000 # tune as needed (e.g., 500-2000) n = len(texts) for i in range(0, n, batch_size): j = min(i + batch_size, n) vectorstore.add_texts( texts=texts[i:j], metadatas=metadatas[i:j], embeddings=embeddings[i:j], ) vectorstore.persist() logger.info(f"✅ Vectorstore saved: {n} chunks (in batches of {batch_size})") except Exception as e: logger.error(f"❌ Vectorstore save error: {e}") def extract_texts_and_metas(docs: List[Document]): texts = [doc.page_content for doc in docs] metas = [doc.metadata for doc in docs] return texts, metas # -------------------- Progress tracking endpoints -------------------- # @app.get("/embedding_progress/{database}") async def get_embedding_progress(database: str, ws: str = Depends(workspace)): """ Returns the current progress of embedding tasks for a specific database. """ key = scoped(ws, database) if key not in EMBEDDING_PROGRESS: print(f"❌ No task found for {database}") return JSONResponse(content={"status": "no_task", "progress": 0, "message": "No embedding task in progress"}) progress_data = EMBEDDING_PROGRESS[key].copy() # Remove zip_data from response to avoid sending large binary data if "zip_data" in progress_data: del progress_data["zip_data"] return JSONResponse(content=progress_data) @app.get("/download_extraction/{extraction_id}") async def download_extraction(extraction_id: str, ws: str = Depends(workspace)): """ Download the system chart extraction ZIP file directly from memory. """ progress_data = EMBEDDING_PROGRESS.get(scoped(ws, extraction_id)) if progress_data is None: return JSONResponse(content={"error": "Extraction not found"}, status_code=404) if progress_data.get("status") != "completed": return JSONResponse(content={"error": "Extraction not completed yet"}, status_code=400) zip_data = progress_data.get("zip_data") if not zip_data: return JSONResponse(content={"error": "Extraction data not available"}, status_code=404) filename = progress_data.get("filename", "system_chart_extraction.zip") # Return the ZIP file as a direct download from fastapi.responses import Response return Response( content=zip_data, media_type="application/zip", headers={ "Content-Disposition": f"attachment; filename={filename}", "Content-Length": str(len(zip_data)), "Content-Type": "application/zip", "Cache-Control": "no-cache, no-store, must-revalidate", "Pragma": "no-cache", "Expires": "0" } ) async def process_files_async_all(file_paths: List[str], database: str, embedding_model): """ Process all files with parallel embedding for improved performance. Uses the new embed_stream_into_vs function for concurrent batch processing. Note: PDFs are now converted to .txt files during upload, so we only process text files. """ global EMBEDDING_PROGRESS if embedding_model is None: logger.error("❌ Embedding model is None. Did you forget /set_api_key?") return total_files = len(file_paths) logger.info(f"🚀 Starting parallel processing of {total_files} files for database '{database}'") # Initialize progress tracking EMBEDDING_PROGRESS[database] = { "status": "extracting", "progress": 0, "total_files": total_files, "processed_files": 0, "message": f"Extracting text from {total_files} files..." } try: # Extract text from all files vectorstore = get_vectorstore(database, embedding_model) all_docs = extract_all_files(file_paths) if not all_docs: EMBEDDING_PROGRESS[database] = { "status": "completed", "progress": 100, "message": "No documents found to embed" } logger.warning(f"⚠️ No documents extracted from {total_files} files") return # Extract texts and metadata texts, metas = extract_texts_and_metas(all_docs) total_chunks = len(texts) logger.info(f"📄 Extracted {total_chunks} chunks from {total_files} files") # Update progress after extraction EMBEDDING_PROGRESS[database] = { "status": "embedding", "progress": 10, "total_chunks": total_chunks, "processed_chunks": 0, "message": f"Starting parallel embedding of {total_chunks} chunks..." } # Use parallel embedding with progress tracking await embed_stream_into_vs( texts, metas, vectorstore, embedding_model, batch_size=512, max_concurrency=12, progress_key=database ) # Final completion status EMBEDDING_PROGRESS[database] = { "status": "completed", "progress": 100, "message": f"Successfully embedded {total_chunks} chunks from {total_files} files using parallel processing" } logger.info(f"✅ Completed parallel processing: {total_chunks} chunks from {total_files} files") except asyncio.CancelledError: EMBEDDING_PROGRESS[database] = { "status": "error", "progress": EMBEDDING_PROGRESS[database].get("progress", 0), "message": "Embedding process was cancelled" } logger.warning(f"⚠️ Embedding cancelled for database '{database}'") raise except Exception as e: current_progress = EMBEDDING_PROGRESS[database].get("progress", 0) EMBEDDING_PROGRESS[database] = { "status": "error", "progress": current_progress, "message": f"Error during parallel embedding: {str(e)}" } logger.error(f"❌ Embedding failed for database '{database}': {str(e)}") raise # Removed per-file embedding mode; only all-at-once embedding is supported. async def process_system_chart_extraction_async(category: str, llm, extraction_id: str, enable_visualization: bool = False, run_system_chart_normalization: bool = False): """ Background task for system chart extraction with progress tracking. Uses the SystemChartService for business logic while handling API-specific concerns. """ global EMBEDDING_PROGRESS print(f"🚀 System Chart Background task started for extraction_id: {extraction_id}") print(f"📊 Visualization enabled: {enable_visualization}") # Initialize progress tracking EMBEDDING_PROGRESS[extraction_id] = { "status": "starting", "progress": 0, "message": "Initializing system chart extraction..." } try: # Create system chart service instance system_chart_service = SystemChartService() # Get PDF text dictionary pdf_dict = obtain_text_from_pdf(category) if not pdf_dict: EMBEDDING_PROGRESS[extraction_id] = { "status": "error", "progress": 0, "message": "No files found in the specified category." } return # Define progress callback for API-specific progress tracking def progress_callback(progress_data): print(f"📊 Progress update: {progress_data}") EMBEDDING_PROGRESS[extraction_id] = progress_data print(f"🔄 EMBEDDING_PROGRESS[{extraction_id}] updated to: {EMBEDDING_PROGRESS[extraction_id]}") print(f"📋 Current keys in EMBEDDING_PROGRESS: {list(EMBEDDING_PROGRESS.keys())}") # Perform system chart extraction using the service run_norm_effective = bool(enable_visualization or run_system_chart_normalization) result = await system_chart_service.extract_system_charts( category=category, llm=llm, pdf_dict=pdf_dict, enable_visualization=enable_visualization, run_system_chart_normalization=run_norm_effective, progress_callback=progress_callback, extraction_id=extraction_id # Pass extraction_id to save conversations ) # Handle the result if result["success"]: EMBEDDING_PROGRESS[extraction_id] = { "status": "completed", "progress": 100, "message": result["message"], "zip_data": result["zip_data"], "filename": result["filename"] } else: EMBEDDING_PROGRESS[extraction_id] = { "status": "error", "progress": 0, "message": result["message"] } except Exception as e: EMBEDDING_PROGRESS[extraction_id] = { "status": "error", "progress": 0, "message": f"Error during system chart extraction: {str(e)}" } logger.error(f"System chart extraction error: {str(e)}") async def process_multi_database_system_chart_extraction_async(ws: str, categories: List[str], llm, extraction_id: str, enable_visualization: bool = False, run_system_chart_normalization: bool = False): """ Background task for system chart extraction across multiple databases with progress tracking. Combines text from all databases and runs system chart extraction on the combined dataset. """ global EMBEDDING_PROGRESS print(f"🚀 Multi-Database System Chart Background task started for extraction_id: {extraction_id}") print(f"📊 Processing {len(categories)} databases: {categories}") print(f"📊 Visualization enabled: {enable_visualization}") # Initialize progress tracking EMBEDDING_PROGRESS[extraction_id] = { "status": "gathering_data", "progress": 5, "message": f"Gathering text from {len(categories)} databases...", "databases": categories, "current_database": None, "databases_processed": 0, "total_databases": len(categories) } try: # Create system chart service instance system_chart_service = SystemChartService() # Gather text from all databases combined_pdf_dict = {} databases_processed = 0 for i, category in enumerate(categories): EMBEDDING_PROGRESS[extraction_id].update({ "progress": 5 + (i * 20 // len(categories)), # 5-25% for data gathering "message": f"Gathering text from database: {category}", "current_database": category, "databases_processed": i }) print(f"📁 Processing database {i+1}/{len(categories)}: {category}") # Get PDF text dictionary for this database pdf_dict = obtain_text_from_pdf(scoped(ws, category)) if pdf_dict: # Add database prefix to avoid title conflicts for title, content in pdf_dict.items(): prefixed_title = f"[{category}] {title}" combined_pdf_dict[prefixed_title] = content print(f"✅ Added {len(pdf_dict)} documents from {category}") else: print(f"⚠️ No files found in database: {category}") databases_processed += 1 # Check if we have any documents to process if not combined_pdf_dict: EMBEDDING_PROGRESS[extraction_id] = { "status": "error", "progress": 0, "message": f"No files found in any of the selected databases: {', '.join(categories)}" } return print(f"📚 Combined {len(combined_pdf_dict)} documents from {len(categories)} databases") # Update progress for starting extraction EMBEDDING_PROGRESS[extraction_id].update({ "status": "extracting", "progress": 30, "message": f"Starting system chart extraction on {len(combined_pdf_dict)} documents...", "current_database": "combined", "databases_processed": len(categories), "total_documents": len(combined_pdf_dict) }) # Define progress callback for API-specific progress tracking def progress_callback(progress_data): # Adjust progress to account for the 30% already used for data gathering adjusted_progress = 30 + (progress_data.get("progress", 0) * 0.7) # 30-100% # Merge with our multi-database tracking data updated_data = { "status": progress_data.get("status", "processing"), "progress": int(adjusted_progress), "message": progress_data.get("message", "Processing..."), "databases": categories, "databases_processed": len(categories), "total_databases": len(categories), "total_documents": len(combined_pdf_dict) } # Add specific progress details if available for key in ["current_paper", "total_papers", "current_question", "total_questions", "completed_questions", "current_paper_type", "questions_in_paper"]: if key in progress_data: updated_data[key] = progress_data[key] print(f"📊 Multi-DB Progress update: {updated_data}") EMBEDDING_PROGRESS[extraction_id] = updated_data run_norm_effective = bool(enable_visualization or run_system_chart_normalization) # Perform system chart extraction using the service on combined data # Use a combined category name for the service combined_category_name = "_".join(categories) result = await system_chart_service.extract_system_charts( category=combined_category_name, llm=llm, pdf_dict=combined_pdf_dict, enable_visualization=enable_visualization, run_system_chart_normalization=run_norm_effective, progress_callback=progress_callback, extraction_id=extraction_id # Pass extraction_id to save conversations ) # Handle the result if result["success"]: EMBEDDING_PROGRESS[extraction_id] = { "status": "completed", "progress": 100, "message": f"System chart extraction completed for {len(categories)} databases ({len(combined_pdf_dict)} documents)", "zip_data": result["zip_data"], "filename": result["filename"], "databases": categories, "databases_processed": len(categories), "total_databases": len(categories), "total_documents": len(combined_pdf_dict) } else: EMBEDDING_PROGRESS[extraction_id] = { "status": "error", "progress": 0, "message": f"Error during extraction: {result['message']}", "databases": categories, "total_documents": len(combined_pdf_dict) } except Exception as e: EMBEDDING_PROGRESS[extraction_id] = { "status": "error", "progress": 0, "message": f"Error during multi-database system chart extraction: {str(e)}", "databases": categories } logger.error(f"Multi-database system chart extraction error: {str(e)}") async def process_hypotheses_generation_async(system_charts_content_list: List[str], llm, extraction_id: str, material_goal_1: str, material_goal_2: str, iterations_count: int = 1): """ Background task for synergistic hypotheses generation with progress tracking. Uses the HypothesesService for business logic while handling API-specific concerns. """ global EMBEDDING_PROGRESS, HYPOTHESIS_GENERATION_CONTEXT print(f"🚀 Hypotheses Generation Background task started for extraction_id: {extraction_id}") # Store context for continuous conversation HYPOTHESIS_GENERATION_CONTEXT[extraction_id] = { 'system_charts_content_list': system_charts_content_list, 'material_goal_1': material_goal_1, 'material_goal_2': material_goal_2, 'iterations_count': iterations_count } # Initialize progress tracking EMBEDDING_PROGRESS[extraction_id] = { "status": "starting", "progress": 0, "message": "Initializing hypothesis generation..." } try: # Create hypotheses service instance hypotheses_service = HypothesesService() # Validate the system charts content list first validation = HypothesesService.validate_system_charts_content(system_charts_content_list) if not validation["valid"]: EMBEDDING_PROGRESS[extraction_id] = { "status": "error", "progress": 0, "message": validation["message"] } return print(f"📊 Validated {validation['valid_charts']} system charts from {len(system_charts_content_list)} files for hypothesis generation") # Define progress callback for API-specific progress tracking def progress_callback(progress_data): EMBEDDING_PROGRESS[extraction_id] = progress_data # Perform hypotheses generation using the service result = await hypotheses_service.generate_hypotheses( system_charts_content_list=system_charts_content_list, # Pass the list llm=llm, material_goal_1=material_goal_1, material_goal_2=material_goal_2, iterations_count=iterations_count, progress_callback=progress_callback ) # Handle the result if result["success"]: HYPOTHESIS_GENERATION_CONTEXT[extraction_id]['hypotheses'] = result["hypotheses"] EMBEDDING_PROGRESS[extraction_id] = { "status": "completed", "progress": 100, "message": result["message"], "zip_data": result["zip_data"], "filename": result["filename"], "iterations_count": iterations_count, "hypotheses": result["hypotheses"] } else: EMBEDDING_PROGRESS[extraction_id] = { "status": "error", "progress": 0, "message": result["message"] } except Exception as e: EMBEDDING_PROGRESS[extraction_id] = { "status": "error", "progress": 0, "message": f"Error during hypothesis generation: {str(e)}" } logger.error(f"Hypothesis generation error: {str(e)}") # ------------ NEW: background QA with chunk-level progress ------------ # async def process_qa_async( question: str, categories: List[str], k: int, embedding_model, llm_QA, progress_id: str, ): """ End-to-end QA answer generation that updates EMBEDDING_PROGRESS after each chunk is summarised. """ try: # ---- gather relevant chunks ------------------------------------------------- all_docs = [] for cat in categories: vs = get_vectorstore(cat, embedding_model) q_emb = await embedding_model.aembed_documents([question]) k_cat = k # Use k chunks from each database (not divided across databases) res = vs._collection.query(query_embeddings=q_emb, n_results=k_cat) for doc_txt, meta in zip(res["documents"][0], res["metadatas"][0]): meta["database"] = cat all_docs.append(Document(page_content=doc_txt, metadata=meta)) total_chunks = len(all_docs) if not total_chunks: EMBEDDING_PROGRESS[progress_id] = { "status": "error", "progress": 0, "message": "No relevant chunks found." } return EMBEDDING_PROGRESS[progress_id] = { "status": "processing", "progress": 0, "total_chunks": total_chunks, "processed_chunks": 0, "answer": None } # ---- chunk-wise map --------------------------------------------------------- async def summarise(idx, doc): from langchain_core.messages import HumanMessage response = await llm_QA.ainvoke( [HumanMessage(content=f"Summarise this in relation to '{question}':\n{doc.page_content}")] ) summary = llm_content_to_text(getattr(response, "content", response)) # update progress *after* the chunk is done done = EMBEDDING_PROGRESS[progress_id]["processed_chunks"] + 1 EMBEDDING_PROGRESS[progress_id]["processed_chunks"] = done EMBEDDING_PROGRESS[progress_id]["progress"] = int(done / total_chunks * 90) # keep 10 % for reduce return summary summaries = await asyncio.gather( *[summarise(i, d) for i, d in enumerate(all_docs)] ) # ---- reduce ----------------------------------------------------------------- from langchain_core.messages import HumanMessage final_response = await llm_QA.ainvoke( [HumanMessage(content="Combine the following partial answers into one:\n" + "\n".join(summaries))] ) final = llm_content_to_text(getattr(final_response, "content", final_response)) EMBEDDING_PROGRESS[progress_id] = { "status": "completed", "progress": 100, "answer": final } except Exception as e: EMBEDDING_PROGRESS[progress_id] = { "status": "error", "progress": 0, "message": f"QA failed: {e}" } @app.post("/upload") async def upload_files( background_tasks: BackgroundTasks, category: str = Form(...), files: List[UploadFile] = File(...), ws: str = Depends(workspace), ): """ Upload PDF, text, and image files to the specified category ('database'). PDFs are converted to .txt files (original PDF is not saved to save storage space). Text files are saved as-is. Image files are saved as-is. Background embedding tasks are started for text files only. Supported file types: .pdf, .txt, .text, .png, .jpg, .jpeg, .gif, .bmp, .tiff, .webp """ global FILE_REGISTRY embedding_model = WORKSPACE_MODELS.get(ws, {}).get("embedding") # Basic check to ensure we have an embedding_model: if embedding_model is None: return JSONResponse( content={ "message": "No embedding model set. Call /set_api_key first." }, status_code=400 ) display_category, category = category, scoped(ws, category) if category not in FILE_REGISTRY: FILE_REGISTRY[category] = {} # PROTECTION: Check if database is locked before processing files category_upload_dir = os.path.join(UPLOAD_DIR, category) metadata_file = os.path.join(category_upload_dir, "METADATA") if os.path.exists(metadata_file): return JSONResponse( content={ "message": f"🔒 Database '{display_category}' is locked. Cannot add files to databases with existing METADATA files. Once a database is created and has a METADATA file, it becomes immutable to prevent data corruption." }, status_code=400 ) # Auto-detect single ZIP upload and route through ZIP logic if len(files) == 1 and files[0].filename.lower().endswith('.zip'): # Reuse the ZIP handler logic inline to keep response identical to /upload_zip import io import zipfile zip_bytes = await files[0].read() zf = zipfile.ZipFile(io.BytesIO(zip_bytes)) saved_file_paths, duplicates = [], [] uploaded_files_info = [] for info in zf.infolist(): if info.is_dir(): continue inner_name = info.filename base_name = os.path.basename(inner_name) if not base_name: continue try: data = zf.read(info) except Exception as e: logger.warning(f"⚠️ Skipping '{inner_name}' due to read error: {e}") continue path, dup = save_file_from_bytes(data, base_name, category) if dup: duplicates.append(dup) continue if path: saved_file_paths.append(path) original_ext = os.path.splitext(base_name.lower())[1] if original_ext == '.pdf' or original_ext in ['.txt', '.text']: file_type = "text_pdf" elif original_ext in ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.tiff', '.webp']: file_type = "image" else: file_type = "unknown" uploaded_files_info.append({ "path": path, "filename": os.path.basename(path), "original_filename": base_name, "type": file_type }) # Separate files by type text_pdf_files = [info["path"] for info in uploaded_files_info if info["type"] == "text_pdf"] image_files = [info["path"] for info in uploaded_files_info if info["type"] == "image"] # Kick off background tasks for embedding (only for PDF/text files) if text_pdf_files and embedding_model is not None: start_time = time.perf_counter() task = asyncio.create_task(process_files_async_all(text_pdf_files, category, embedding_model)) task.add_done_callback(lambda _, s = start_time: print(f"Embedding time in {(time.perf_counter() - s) * 1000:.2f} ms")) return JSONResponse(content={ "message": "✅ ZIP upload complete.", "uploaded": [info["filename"] for info in uploaded_files_info], "duplicates": duplicates, "file_types": { "text_pdf_count": len(text_pdf_files), "image_count": len(image_files), "has_images": len(image_files) > 0, "has_text_pdf": len(text_pdf_files) > 0 }, "embedding_started": len(text_pdf_files) > 0 and embedding_model is not None }) tasks = [(save_file(file, category), file.filename) for file in files] results = await asyncio.gather(*[task[0] for task in tasks]) saved_file_paths, duplicates = [], [] uploaded_files_info = [] for (path, dup), (_, original_filename) in zip(results, tasks): if dup: duplicates.append(dup) elif path: saved_file_paths.append(path) # Determine file type based on original filename, not the saved path # (since PDFs are now converted to .txt files) original_ext = os.path.splitext(original_filename.lower())[1] if original_ext == '.pdf' or original_ext in ['.txt', '.text']: file_type = "text_pdf" elif original_ext in ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.tiff', '.webp']: file_type = "image" else: file_type = "unknown" uploaded_files_info.append({ "path": path, "filename": os.path.basename(path), "original_filename": original_filename, "type": file_type }) # Separate files by type text_pdf_files = [info["path"] for info in uploaded_files_info if info["type"] == "text_pdf"] image_files = [info["path"] for info in uploaded_files_info if info["type"] == "image"] # Kick off background tasks for embedding (only for PDF/text files) if text_pdf_files and embedding_model is not None: start_time = time.perf_counter() task = asyncio.create_task(process_files_async_all(text_pdf_files, category, embedding_model)) task.add_done_callback(lambda _, s = start_time: print(f"Embedding time in {(time.perf_counter() - s) * 1000:.2f} ms")) # Prepare response with file type information return JSONResponse(content={ "message": "Upload complete.", "uploaded": [info["filename"] for info in uploaded_files_info], "duplicates": duplicates, "file_types": { "text_pdf_count": len(text_pdf_files), "image_count": len(image_files), "has_images": len(image_files) > 0, "has_text_pdf": len(text_pdf_files) > 0 }, "embedding_started": len(text_pdf_files) > 0 and embedding_model is not None, "note": "PDFs are converted to text files during upload to save storage space. Only the extracted text is saved." }) @app.post("/upload_zip") async def upload_zip( background_tasks: BackgroundTasks, category: str = Form(...), zip_file: UploadFile = File(...), ws: str = Depends(workspace), ): """ Upload a single ZIP file containing PDFs, text files, and/or images. Files are extracted server-side and saved using the same rules as /upload. Then, background embedding is kicked off for text/PDF-derived files. """ global FILE_REGISTRY embedding_model = WORKSPACE_MODELS.get(ws, {}).get("embedding") if embedding_model is None: return JSONResponse( content={"message": "No embedding model set. Please call /set_api_key first."}, status_code=400 ) display_category, category = category, scoped(ws, category) if category not in FILE_REGISTRY: FILE_REGISTRY[category] = {} # PROTECTION: Check if database is locked before processing files category_upload_dir = os.path.join(UPLOAD_DIR, category) metadata_file = os.path.join(category_upload_dir, "METADATA") if os.path.exists(metadata_file): return JSONResponse( content={ "message": f"🔒 Database '{display_category}' is locked. Cannot add files to databases with existing METADATA files. Once a database is created and has a METADATA file, it becomes immutable to prevent data corruption." }, status_code=400 ) import io import zipfile # Read ZIP into memory (could be optimized to temp file for very large zips) zip_bytes = await zip_file.read() zf = zipfile.ZipFile(io.BytesIO(zip_bytes)) saved_file_paths, duplicates = [], [] uploaded_files_info = [] for info in zf.infolist(): if info.is_dir(): continue inner_name = info.filename base_name = os.path.basename(inner_name) if not base_name: # skip entries without a filename continue try: data = zf.read(info) except Exception as e: logger.warning(f"⚠️ Skipping '{inner_name}' due to read error: {e}") continue path, dup = save_file_from_bytes(data, base_name, category) if dup: duplicates.append(dup) continue if path: saved_file_paths.append(path) original_ext = os.path.splitext(base_name.lower())[1] if original_ext == '.pdf' or original_ext in ['.txt', '.text']: file_type = "text_pdf" elif original_ext in ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.tiff', '.webp']: file_type = "image" else: file_type = "unknown" uploaded_files_info.append({ "path": path, "filename": os.path.basename(path), "original_filename": base_name, "type": file_type }) # Separate files by type text_pdf_files = [info["path"] for info in uploaded_files_info if info["type"] == "text_pdf"] image_files = [info["path"] for info in uploaded_files_info if info["type"] == "image"] # Kick off background tasks for embedding (only for PDF/text files) if text_pdf_files and embedding_model is not None: start_time = time.perf_counter() task = asyncio.create_task(process_files_async_all(text_pdf_files, category, embedding_model)) task.add_done_callback(lambda _, s = start_time: print(f"Embedding time in {(time.perf_counter() - s) * 1000:.2f} ms")) return JSONResponse(content={ "message": "✅ ZIP upload complete.", "uploaded": [info["filename"] for info in uploaded_files_info], "duplicates": duplicates, "file_types": { "text_pdf_count": len(text_pdf_files), "image_count": len(image_files), "has_images": len(image_files) > 0, "has_text_pdf": len(text_pdf_files) > 0 }, "embedding_started": len(text_pdf_files) > 0 and embedding_model is not None }) @app.get("/available_databases") async def get_available_databases(ws: str = Depends(workspace)): """ Returns this workspace's databases/categories with their file information. """ global FILE_REGISTRY databases = [] for category, files in FILE_REGISTRY.items(): name = unscoped(ws, category) if name is not None and files: # Only this workspace's categories that have files # Count file types in the category text_pdf_count = 0 image_count = 0 pdf_count = 0 # Load metadata to check original file types (especially for converted PDFs) metadata_dict = load_metadata(category) for file_hash, filename in files.items(): file_type = get_file_type(filename) if file_type == "text_pdf": text_pdf_count += 1 elif file_type == "image": image_count += 1 # Check for PDFs using metadata (for converted PDFs) or filename (for direct PDFs) if filename.lower().endswith('.pdf'): pdf_count += 1 elif file_hash in metadata_dict: original_filename = metadata_dict[file_hash].get('original_filename', '') if original_filename.lower().endswith('.pdf'): pdf_count += 1 # Determine available modes available_modes = [] if text_pdf_count > 0: available_modes.extend(["QA", "System Chart Extraction"]) databases.append({ "name": name, "file_count": len(files), "text_pdf_count": text_pdf_count, "image_count": image_count, "pdf_count": pdf_count, "available_modes": available_modes, "file_types": { "has_images": image_count > 0, "has_text_pdf": text_pdf_count > 0, "has_pdfs": pdf_count > 0 } }) return JSONResponse(content={"databases": databases}) # -------------------- Conversation Management Endpoints -------------------- # @app.get("/all_conversations") async def get_every_conversation(ws: str = Depends(workspace)): """ Get conversations across all of this workspace's extractions (and therefore all its databases), so the frontend dropdown is not limited to the most recent run. """ all_convs = get_all_conversations() conversation_list = [] for key, conversations in all_convs.items(): ext_id = unscoped(ws, key) if ext_id is None: continue for conv_key, conv_data in conversations.items(): conversation_list.append({ "extraction_id": ext_id, "key": conv_key, "title": conv_data["paper_title"], "paper_type": conv_data["paper_type"], "created_at": conv_data["created_at"], "last_updated": conv_data["last_updated"], "message_count": len(conv_data["conversation_history"]) }) conversation_list.sort(key=lambda x: x["created_at"], reverse=True) return JSONResponse(content={ "conversations": conversation_list, "total_count": len(conversation_list) }) @app.get("/conversations/{extraction_id}") async def get_extraction_conversations(extraction_id: str, ws: str = Depends(workspace)): """ Get all conversations for a given extraction ID. Returns a list of conversation metadata for frontend dropdown. """ conversations = get_conversations(scoped(ws, extraction_id)) if not conversations: return JSONResponse(content={ "conversations": [], "message": "No conversations found for this extraction ID" }) # Format conversations for frontend dropdown conversation_list = [] for conv_key, conv_data in conversations.items(): conversation_list.append({ "key": conv_key, "title": conv_data["paper_title"], "paper_type": conv_data["paper_type"], "created_at": conv_data["created_at"], "last_updated": conv_data["last_updated"], "message_count": len(conv_data["conversation_history"]) }) # Sort by creation time (newest first) conversation_list.sort(key=lambda x: x["created_at"], reverse=True) return JSONResponse(content={ "conversations": conversation_list, "total_count": len(conversation_list) }) @app.post("/continue_conversation/{extraction_id}/{conversation_key}") async def continue_conversation( extraction_id: str, conversation_key: str, question: str = Form(...), background_tasks: BackgroundTasks = None, ws: str = Depends(workspace), ): """ Continue a conversation from system chart extraction with a follow-up question. """ llm_system_chart_extraction = WORKSPACE_MODELS.get(ws, {}).get("chart") if llm_system_chart_extraction is None: return JSONResponse( content={"error": "No model configured. Please set up a model first."}, status_code=400 ) # Get the conversation extraction_id = scoped(ws, extraction_id) conversations = get_conversations(extraction_id) if conversation_key not in conversations: return JSONResponse( content={"error": "Conversation not found"}, status_code=404 ) conversation = conversations[conversation_key] try: # Import the required message classes from langchain_core.messages import HumanMessage, AIMessage # Get the current conversation history current_history = conversation["conversation_history"].copy() # Add the new question with scientific formatting instructions enhanced_question = f""" {question} Please provide a detailed, scientific answer. Follow these guidelines: 1. Provide quantitative details including exact values, percentages, and units when available 2. When referencing specific information, mention which paper or section it comes from 3. For compositional data, organize information clearly 4. Include relevant scientific context and mechanisms """ new_message = HumanMessage(content=enhanced_question) current_history.append(new_message) # Get response from the model response = await llm_system_chart_extraction.ainvoke(current_history) answer_text = llm_content_to_text(response.content) ai_response = AIMessage(content=answer_text) # Update the conversation update_conversation(extraction_id, conversation_key, new_message, ai_response) return JSONResponse(content={ "question": question, "answer": answer_text, "paper_title": conversation["paper_title"], "paper_type": conversation["paper_type"], "conversation_updated": True }) except Exception as e: logger.error(f"Error continuing conversation: {str(e)}") return JSONResponse( content={"error": f"Error processing question: {str(e)}"}, status_code=500 ) @app.get("/conversation_history/{extraction_id}/{conversation_key}") async def get_conversation_history(extraction_id: str, conversation_key: str, ws: str = Depends(workspace)): """ Get the full conversation history for a specific conversation. """ conversations = get_conversations(scoped(ws, extraction_id)) if conversation_key not in conversations: return JSONResponse( content={"error": "Conversation not found"}, status_code=404 ) conversation = conversations[conversation_key] # Format the conversation history for frontend display formatted_history = [] for i, message in enumerate(conversation["conversation_history"]): # Check if it's a human or AI message if hasattr(message, 'type'): message_type = message.type content = message.content else: # Fallback for different message formats message_type = "human" if i % 2 == 0 else "ai" content = str(message) formatted_history.append({ "type": message_type, "content": content, "index": i }) return JSONResponse(content={ "paper_title": conversation["paper_title"], "paper_type": conversation["paper_type"], "created_at": conversation["created_at"], "last_updated": conversation["last_updated"], "history": formatted_history }) # -------------------- Ask Endpoint -------------------- # @app.post("/ask") async def ask_question( background_tasks: BackgroundTasks, categories: str = Form(...), # Changed from category to categories (comma-separated) # Default to "" instead of required: newer FastAPI treats an empty form value as # missing, and the hidden question field submits "" in System Chart Extraction mode. question: str = Form(""), k: int = Form(10), goal: str = Form("QA"), enable_visualization: bool = Form(False), run_system_chart_normalization: bool = Form(False), ws: str = Depends(workspace), ): """ Ask a question with different 'goal' modes across multiple databases: - QA (Chunk-based Q&A across multiple databases) - System Chart Extraction (pulls pure text from PDFs, special method) """ models = WORKSPACE_MODELS.get(ws, {}) embedding_model, llm_QA, llm_system_chart_extraction = models.get("embedding"), models.get("qa"), models.get("chart") if embedding_model is None or llm_QA is None: return JSONResponse( content={"answer": "No model/embedding set. Please call /set_api_key first."}, status_code=400 ) # Parse categories (comma-separated string) category_list = [cat.strip() for cat in categories.split(",") if cat.strip()] if not category_list: return JSONResponse(content={ "answer": "No databases selected. Please select at least one database." }) # Validate that all categories exist and have files valid_categories = [] for category in category_list: if FILE_REGISTRY.get(scoped(ws, category)): valid_categories.append(category) if not valid_categories: return JSONResponse(content={ "answer": f"No files found in the selected databases: {', '.join(category_list)}" }) # System Chart Extraction can now work with multiple databases if goal == "System Chart Extraction": # Create a combined extraction ID that represents all databases database_names = "_".join(valid_categories) extraction_id = f"system_chart_multi_{uuid.uuid4().hex[:8]}" print(f"🎯 Created multi-database extraction_id: {extraction_id}") print(f"📊 Will process databases: {valid_categories}") EMBEDDING_PROGRESS[scoped(ws, extraction_id)] = { "status": "starting", "progress": 0, "message": f"Initializing system chart extraction for {len(valid_categories)} databases...", "databases": valid_categories, "total_databases": len(valid_categories) } # Start background task for multi-database system chart extraction background_tasks.add_task( process_multi_database_system_chart_extraction_async, ws, valid_categories, llm_system_chart_extraction, scoped(ws, extraction_id), enable_visualization, run_system_chart_normalization ) return JSONResponse(content={ "answer": f"System chart extraction started for {len(valid_categories)} databases: {', '.join(valid_categories)}", "extraction_id": extraction_id, "check_progress_url": f"/embedding_progress/{extraction_id}", "databases": valid_categories, "total_databases": len(valid_categories) }) # For QA goal, we can query multiple databases if goal == "QA": if not question.strip(): return JSONResponse( content={"answer": "Please enter a question before submitting."}, status_code=400 ) if llm_QA is None: return JSONResponse( content={"answer": "QA model not configured."}, status_code=400 ) qa_id = f"qa_{uuid.uuid4().hex[:8]}" EMBEDDING_PROGRESS[scoped(ws, qa_id)] = {"status": "starting", "progress": 0} # fire-and-forget background_tasks.add_task( process_qa_async, question, [scoped(ws, c) for c in valid_categories], k, embedding_model, llm_QA, scoped(ws, qa_id) ) return JSONResponse({ "answer": "Your question is being processed.", "extraction_id": qa_id, # same field name as other tasks "check_progress_url": f"/embedding_progress/{qa_id}", "databases_searched": valid_categories }) # If we get here, it's an unknown goal return JSONResponse(content={ "answer": f"Unknown goal: {goal}" }) # -------------------- Hypotheses Generation Endpoint -------------------- # @app.post("/generate_hypotheses") async def generate_hypotheses( background_tasks: BackgroundTasks, system_charts_files: List[UploadFile] = File(...), material_goal_1: str = Form("Property #1 for xxx material"), material_goal_2: str = Form("Property #2 for xxx material"), iterations_count: int = Form(1), ws: str = Depends(workspace), ): """ Generate synergistic scientific hypotheses from multiple uploaded system charts files. Accepts multiple system_charts.txt files and generates hypotheses targeting specific material design goals. """ llm_QA = WORKSPACE_MODELS.get(ws, {}).get("qa") # Check if models are configured if llm_QA is None: return JSONResponse( content={"answer": "No model/embedding set. Please call /set_api_key first."}, status_code=400 ) # Use the QA LLM for hypothesis generation selected_llm = llm_QA # Validate that we have at least one file if not system_charts_files or len(system_charts_files) == 0: return JSONResponse( content={"answer": "Please upload at least one .txt file containing system charts with citations."}, status_code=400 ) # Validate file types invalid_files = [f.filename for f in system_charts_files if not f.filename.endswith('.txt')] if invalid_files: return JSONResponse( content={"answer": f"All files must be .txt files. Invalid files: {', '.join(invalid_files)}"}, status_code=400 ) # Validate iterations count if iterations_count < 1 or iterations_count > 100: return JSONResponse( content={"answer": "Number of iterations must be between 1 and 100."}, status_code=400 ) try: # Read all uploaded file contents system_charts_content_list = [] for file in system_charts_files: file_content = await file.read() content = file_content.decode('utf-8') system_charts_content_list.append(content) # Validate that all contents are suitable for synergistic hypothesis generation validation = HypothesesService.validate_system_charts_content(system_charts_content_list) if not validation["valid"]: return JSONResponse( content={"answer": f"Content validation failed: {validation['message']}"}, status_code=400 ) # Initialize progress tracking for hypotheses generation extraction_id = f"hypotheses_{uuid.uuid4().hex[:8]}" run_key = scoped(ws, extraction_id) HYPOTHESIS_GENERATION_CONTEXT[run_key] = { 'system_charts_content_list': system_charts_content_list, 'material_goal_1': material_goal_1, 'material_goal_2': material_goal_2, 'iterations_count': iterations_count } print(f"🎯 Created hypotheses extraction_id: {extraction_id}") EMBEDDING_PROGRESS[run_key] = { "status": "starting", "progress": 0, "message": "Initializing hypothesis generation..." } print(f"📝 Initial hypotheses progress set for {extraction_id}: {EMBEDDING_PROGRESS[run_key]}") # Start background task for hypotheses generation print(f"🚀 Adding hypotheses background task for {extraction_id}") background_tasks.add_task( process_hypotheses_generation_async, system_charts_content_list, # Pass the list of contents selected_llm, run_key, material_goal_1, material_goal_2, iterations_count ) print(f"✅ Hypotheses background task added for {extraction_id}") file_names = [f.filename for f in system_charts_files] return JSONResponse(content={ "answer": f"Synergistic hypothesis generation started for {len(system_charts_files)} file{'s' if len(system_charts_files) > 1 else ''}: {', '.join(file_names)} targeting '{material_goal_1}' and '{material_goal_2}' with {iterations_count} iteration{'s' if iterations_count > 1 else ''}.", "extraction_id": extraction_id, "check_progress_url": f"/embedding_progress/{extraction_id}", "data_entries": validation.get('data_entries', 0), "method": "synergistic_analysis", "material_goals": [material_goal_1, material_goal_2], "charts_found": validation.get('chart_count', len(system_charts_files)), "files_processed": len(system_charts_files) }) except UnicodeDecodeError: return JSONResponse( content={"answer": "Error: Could not read the uploaded file. Please ensure it's a valid UTF-8 text file."}, status_code=400 ) except Exception as e: return JSONResponse( content={"answer": f"Error processing uploaded file: {str(e)}"}, status_code=500 ) @app.post("/analyze_design_goals") async def analyze_design_goals(description: str = Form(...), ws: str = Depends(workspace)): """ Analyze a free-form materials-design description and suggest the two target properties for hypothesis generation. Runs a single LLM call and returns the two properties so the frontend can pre-fill the editable goal fields. """ llm_QA = WORKSPACE_MODELS.get(ws, {}).get("qa") if llm_QA is None: return JSONResponse( content={"error": "No model set. Please call /set_api_key first."}, status_code=400 ) description = (description or "").strip() if not description: return JSONResponse( content={"error": "Please describe your materials design goals first."}, status_code=400 ) try: from langchain_core.messages import HumanMessage prompt = create_design_goals_analysis_prompt(description) response = await llm_QA.ainvoke([HumanMessage(content=prompt)]) raw = llm_content_to_text(response.content).strip() print(f"🎯 Design-goals analysis raw output: {raw[:300]}") # Parse the strict "Property 1: ... / Property 2: ..." format robustly. goal_1, goal_2 = _parse_design_goals(raw) if not goal_1 or not goal_2: return JSONResponse( content={ "error": "Could not confidently extract two design properties. Please refine your description or fill in the fields manually.", "raw": raw }, status_code=422 ) return JSONResponse(content={ "success": True, "goal_1": goal_1, "goal_2": goal_2 }) except Exception as e: print(f"❌ Error analyzing design goals: {str(e)}") return JSONResponse( content={"error": f"Error analyzing design goals: {str(e)}"}, status_code=500 ) def _parse_design_goals(raw: str): """Extract the two properties from the model output. Handles the expected 'Property 1: ...' / 'Property 2: ...' format and a few lenient fallbacks (numbered lists, bullet points). """ goal_1 = "" goal_2 = "" def _clean(value: str) -> str: return value.strip().strip('"').strip("'").strip("*").strip("-").strip().rstrip(".") m1 = re.search(r"Property\s*1\s*[:\-]\s*(.+)", raw, re.IGNORECASE) m2 = re.search(r"Property\s*2\s*[:\-]\s*(.+)", raw, re.IGNORECASE) if m1: goal_1 = _clean(m1.group(1).splitlines()[0]) if m2: goal_2 = _clean(m2.group(1).splitlines()[0]) # Fallback: take the first two non-empty lines if labels were not found. if not (goal_1 and goal_2): lines = [l.strip() for l in raw.splitlines() if l.strip()] cleaned = [] for line in lines: line = re.sub(r"^\s*(?:property\s*\d+|[0-9]+[.)]|[-*•])\s*[:\-]?\s*", "", line, flags=re.IGNORECASE) line = _clean(line) if line: cleaned.append(line) if not goal_1 and len(cleaned) >= 1: goal_1 = cleaned[0] if not goal_2 and len(cleaned) >= 2: goal_2 = cleaned[1] return goal_1, goal_2 # -------------------- Hypothesis Continuous Conversation Endpoints -------------------- # @app.post("/start_hypothesis_conversation") async def start_hypothesis_conversation( extraction_id: str = Form(...), hypothesis_number: int = Form(...), pasted_papers: str = Form("{}"), ws: str = Depends(workspace), ): """ Open a conversation about one generated hypothesis. Resolves the charts it cites and their source papers from every database in this workspace. `pasted_papers` is a JSON object {chart_number: paper_text} supplied by the user for papers not found. Replaces any previous conversation for this run. """ run = HYPOTHESIS_GENERATION_CONTEXT.get(scoped(ws, extraction_id)) if not run or 'hypotheses' not in run: return JSONResponse(content={"error": "Hypothesis run not found. Please generate hypotheses first."}, status_code=404) if WORKSPACE_MODELS.get(ws, {}).get("qa") is None: return JSONResponse(content={"error": "LLM not configured. Please set up the model first."}, status_code=400) try: pasted = {int(k): v for k, v in json.loads(pasted_papers).items() if str(v).strip()} context, sources = build_hypothesis_context(ws, run, hypothesis_number, pasted) except ValueError as e: return JSONResponse(content={"error": str(e)}, status_code=400) except Exception as e: return JSONResponse(content={"error": f"Error starting hypothesis conversation: {str(e)}"}, status_code=500) run.update({'conversation_context': context, 'conversation_history': []}) print(f"💬 Hypothesis #{hypothesis_number} conversation started for {extraction_id}: {len(context)} chars, sources={sources}") return JSONResponse(content={"success": True, "hypothesis_number": hypothesis_number, "sources": sources}) @app.post("/hypothesis_conversation_message") async def hypothesis_conversation_message( extraction_id: str = Form(...), message: str = Form(...), ws: str = Depends(workspace), ): """ Send a message in the hypothesis conversation and get an AI response with full context. """ run = HYPOTHESIS_GENERATION_CONTEXT.get(scoped(ws, extraction_id)) llm_QA = WORKSPACE_MODELS.get(ws, {}).get("qa") if not run or 'conversation_context' not in run: return JSONResponse(content={"error": "Hypothesis conversation not found. Please click Discuss on a hypothesis first."}, status_code=404) if llm_QA is None: return JSONResponse(content={"error": "LLM not configured. Please set up the model first."}, status_code=400) try: history = run['conversation_history'] prompt = run['conversation_context'] + "\nCONVERSATION HISTORY:\n" for entry in history[-10:]: prompt += f"Human: {entry['human']}\nAI: {entry['ai']}\n\n" prompt += f"""CURRENT QUESTION: Human: {message} Please provide a detailed, scientific answer based on the research context above. Follow these guidelines: 1. Include specific information from the source papers and system charts 2. Cite sources with the same number used in the hypothesis, e.g. "Paper [2]" or "Chart [2]" 3. For compositional data, organize information clearly 4. Provide quantitative details including exact values, percentages, and units when available 5. Explain the scientific significance of the data AI: """ print(f"🤖 Sending hypothesis prompt to LLM ({len(prompt)} chars)") from langchain_core.messages import HumanMessage response = await llm_QA.ainvoke([HumanMessage(content=prompt)]) ai_response = llm_content_to_text(response.content) history.append({'human': message, 'ai': ai_response}) return JSONResponse(content={"success": True, "response": ai_response, "conversation_length": len(history)}) except Exception as e: print(f"❌ Error processing hypothesis conversation message: {str(e)}") return JSONResponse(content={"error": f"Error processing message: {str(e)}"}, status_code=500) def get_admin_password() -> Optional[str]: password = os.environ.get("ADMIN_PASSWORD") if password: return password.strip() return None def get_admin_wipe_targets() -> List[str]: return [ os.path.abspath(UPLOAD_DIR), os.path.abspath(CHROMA_DB_BASE), ] def is_safe_target(path: str, data_root: str) -> bool: normalized_path = os.path.abspath(path) normalized_root = os.path.abspath(data_root) if normalized_path in {"/", ""}: return False try: return os.path.commonpath([normalized_root, normalized_path]) == normalized_root except ValueError: return False def delete_directory_contents(path: str) -> Dict[str, int]: deleted_files = 0 deleted_dirs = 0 if not os.path.isdir(path): return {"deleted_files": 0, "deleted_dirs": 0} for name in os.listdir(path): target = os.path.join(path, name) if os.path.isfile(target) or os.path.islink(target): os.unlink(target) deleted_files += 1 elif os.path.isdir(target): shutil.rmtree(target) deleted_dirs += 1 return {"deleted_files": deleted_files, "deleted_dirs": deleted_dirs} @app.post("/admin/verify_password") async def admin_verify_password(password: str = Form(...)): admin_password = get_admin_password() if not admin_password: return JSONResponse( content={"ok": False, "message": "ADMIN_PASSWORD is not configured on server."}, status_code=500 ) if not hmac.compare_digest(password or "", admin_password): return JSONResponse( content={"ok": False, "message": "Invalid admin password."}, status_code=403 ) return JSONResponse(content={"ok": True, "message": "Password verified."}, status_code=200) @app.post("/admin/wipe_data") async def admin_wipe_data( password: str = Form(...), confirmation_text: str = Form(...) ): admin_password = get_admin_password() if not admin_password: return JSONResponse( content={"ok": False, "message": "ADMIN_PASSWORD is not configured on server."}, status_code=500 ) if not hmac.compare_digest(password or "", admin_password): return JSONResponse( content={"ok": False, "message": "Invalid admin password."}, status_code=403 ) if (confirmation_text or "").strip() != ADMIN_DELETE_CONFIRMATION_TEXT: return JSONResponse( content={ "ok": False, "message": f"Invalid confirmation text. Type exactly: {ADMIN_DELETE_CONFIRMATION_TEXT}" }, status_code=400 ) targets = get_admin_wipe_targets() data_root = os.path.abspath(APP_DATA_ROOT) for target in targets: if not is_safe_target(target, data_root): return JSONResponse( content={ "ok": False, "message": f"Refusing to wipe unsafe target: {target}", "data_root": data_root, "targets": targets }, status_code=400 ) try: results = [] total_files = 0 total_dirs = 0 for target in targets: if not os.path.exists(target): results.append({"target": target, "status": "missing"}) continue result = delete_directory_contents(target) total_files += result["deleted_files"] total_dirs += result["deleted_dirs"] results.append({"target": target, "status": "wiped", **result}) FILE_REGISTRY.clear() EMBEDDING_PROGRESS.clear() HYPOTHESIS_GENERATION_CONTEXT.clear() logger.warning( "Admin data wipe executed for targets=%s (files=%s, dirs=%s)", targets, total_files, total_dirs ) return JSONResponse( content={ "ok": True, "message": "Wipe completed.", "data_root": data_root, "targets": results, "deleted_files": total_files, "deleted_dirs": total_dirs }, status_code=200 ) except Exception as e: logger.exception("Admin data wipe failed: %s", str(e)) return JSONResponse( content={"ok": False, "message": f"Failed to wipe data: {str(e)}"}, status_code=500 ) # -------------------- Hypothesis conversation helpers -------------------- # HYPOTHESIS_PAPER_CHAR_CAP = 60_000 # per source paper HYPOTHESIS_CONTEXT_CHAR_CAP = 250_000 # all source papers combined def load_all_papers(ws: str) -> List[tuple]: """The workspace's papers as (database, title, text), databases in alphabetical order.""" return [(unscoped(ws, key), title, text) for key in sorted(FILE_REGISTRY) if unscoped(ws, key) is not None for title, text in obtain_text_from_files(key).items()] def match_paper(title: str, papers: List[tuple]): """Find a paper by title: exact or substring first, then fuzzy (score >= 80). Returns (database, text).""" title = (title or "").strip() best = (0, None, None) for db, stored, text in papers: if title and (title == stored or title in stored or stored in title): return db, text score = fuzz.token_set_ratio(title, stored) if title else 0 if score > best[0]: best = (score, db, text) return (best[1], best[2]) if best[0] >= 80 else (None, None) def build_hypothesis_context(ws: str, run: dict, hypothesis_number: int, pasted: Dict[int, str]): """ Build the fixed conversation context for one hypothesis: the hypothesis text plus, for every chart it cites as [n], that chart and its source paper (full text, capped). Returns (context_text, sources) where sources describes how each citation resolved. """ hypothesis = next((h for h in run['hypotheses'] if h['number'] == hypothesis_number), None) if hypothesis is None: raise ValueError(f"Hypothesis #{hypothesis_number} not found in this run.") charts = [(m.group('paper').strip(), m.group('chart').strip()) for blob in run['system_charts_content_list'] for m in CHART_PATTERN.finditer(blob)] cited = sorted({int(n) for n in re.findall(r'\[(\d+)\]', hypothesis['text']) if 0 < int(n) <= len(charts)}) papers = load_all_papers(ws) if any(n not in pasted for n in cited) else [] context = f"=== HYPOTHESIS #{hypothesis_number} UNDER DISCUSSION ===\n{hypothesis['text']}\n\n" sources, budget = [], HYPOTHESIS_CONTEXT_CHAR_CAP for n in cited: title, chart = charts[n - 1] db, text = ("pasted", pasted[n]) if n in pasted else match_paper(title, papers) truncated = False if text: allowed = min(HYPOTHESIS_PAPER_CHAR_CAP, budget) truncated = len(text) > allowed text = text[:allowed] budget -= len(text) sources.append({'number': n, 'title': title, 'database': db, 'truncated': truncated}) context += (f"=== SOURCE [{n}]: {title} ===\n" f"CHART [{n}]:\n{chart}\n\n" f"PAPER [{n}]:\n{text or 'NOT AVAILABLE'}\n\n") context += "=== END OF CONTEXT ===\n\n" return context, sources if __name__ == "__main__": import uvicorn # Log important startup information for diagnostics logger.info("=" * 50) logger.info("Starting PDF-RAG application") logger.info(f"Running as executable: {hasattr(sys, '_MEIPASS')}") logger.info(f"Current working directory: {os.getcwd()}") if hasattr(sys, '_MEIPASS'): logger.info(f"PyInstaller _MEIPASS: {sys._MEIPASS}") logger.info(f"Executable path: {sys.executable}") logger.info(f"Base directory for storage: {get_base_directory()}") logger.info(f"UPLOAD_DIR: {os.path.abspath(UPLOAD_DIR)}") logger.info(f"CHROMA_DB_BASE: {os.path.abspath(CHROMA_DB_BASE)}") logger.info("=" * 50) print("Starting server...") uvicorn.run(app, host="127.0.0.1", port=8000)