""" FastAPI Backend for NotebookPRO Handles RAG, LLM, file processing, and chat management """ import spaces from fastapi import FastAPI, File, UploadFile, HTTPException, Response from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import StreamingResponse, FileResponse from starlette.background import BackgroundTask from pydantic import BaseModel from typing import List, Optional, Dict, Any from pathlib import Path from datetime import datetime from io import BytesIO import torch import json import uuid import sys import warnings import logging import os import shutil import tempfile import zipfile import re import html import time import weasyprint import markdown import latex2mathml.converter import ziamath # Suppress warnings warnings.filterwarnings('ignore') os.environ['PYTHONWARNINGS'] = 'ignore' os.environ['TF_CPP_MIN_LOG_LEVEL'] = '3' os.environ['TOKENIZERS_PARALLELISM'] = 'false' os.environ.setdefault('OMP_NUM_THREADS', '2') os.environ.setdefault('MKL_NUM_THREADS', '2') os.environ.setdefault('OPENBLAS_NUM_THREADS', '2') os.environ.setdefault('NUMEXPR_NUM_THREADS', '2') logging.getLogger().setLevel(logging.ERROR) # Add project root to path sys.path.append(str(Path(__file__).parent.parent)) import config from utils.document_processor import DocumentProcessor from utils.vector_db import VectorDatabase from utils.hybrid_retriever import HybridRetriever from utils.llm_generator import LLMGenerator from utils.config_manager import ConfigManager from utils.spaces_manager import SpacesManager from utils.studio_manager import StudioManager from utils.studio_generator import StudioGenerator from models.studio_models import ( NotebookEntry, NotebookEntryCreate, NotebookEntryUpdate, Flashcard, FlashcardCreate, FlashcardUpdate, FlashcardReview, FlashcardGenerateRequest, MasteryLevel, Quiz, QuizCreate, QuizGenerateRequest, QuizSubmission, QuizResult, QuizHistory, ) # Initialize FastAPI app = FastAPI(title="NotebookPRO API", version="2.0.0") # CORS - Allow Flutter web to connect app.add_middleware( CORSMiddleware, allow_origins=["*"], # In production, specify your Flutter web URL allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # Global instances config_manager = ConfigManager() spaces_manager = SpacesManager() studio_manager = StudioManager() studio_generator = None # Will be initialized after LLM vector_db = None hybrid_retriever = None llm_generator = None current_space = None # ==================== Pydantic Models ==================== class ChatMessage(BaseModel): role: str content: str timestamp: str sources: Optional[List[Dict[str, Any]]] = None suggested_follow_ups: Optional[List[str]] = None class ChatRequest(BaseModel): query: str space_id: str chat_id: Optional[str] = None workflow: str = "chat" class ChatResponse(BaseModel): response: str sources: List[Dict[str, Any]] chat_id: str timestamp: str ui_notice: Optional[str] = None suggested_follow_ups: Optional[List[str]] = None class DeleteMessageRequest(BaseModel): timestamp: str role: str = "assistant" content: Optional[str] = None delete_related_user: bool = False class SpaceCreate(BaseModel): name: str class SpaceResponse(BaseModel): id: str name: str created_at: str file_count: int class ChatInfo(BaseModel): id: str title: str preview: str created_at: str updated_at: str message_count: int class ConfigResponse(BaseModel): gemini_api_key: Optional[str] nvidia_api_key: Optional[str] = None groq_api_key: Optional[str] = None class ConfigUpdate(BaseModel): gemini_api_key: Optional[str] = None nvidia_api_key: Optional[str] = None groq_api_key: Optional[str] = None class ChatToNotebookRequest(BaseModel): space_id: str question: str answer: str chat_id: Optional[str] = None assistant_timestamp: Optional[str] = None tags: List[str] = [] space_name: Optional[str] = None class ExportAnswerPdfRequest(BaseModel): space_id: str question: str answer: str title: Optional[str] = None class QAItem(BaseModel): question: str answer: str class ExportCombinedAnswersPdfRequest(BaseModel): space_id: str chat_title: Optional[str] = None items: List[QAItem] class ExportSelectedNotebookPdfRequest(BaseModel): space_id: str entry_ids: List[str] title: Optional[str] = None # ==================== Helper Functions ==================== def get_data_dir(): """Get data directory path""" return Path(config.DATA_DIR) def get_space_dir(space_id: str): """Get space-specific directory""" return get_data_dir() / "spaces" / space_id def load_chats_for_space(space_id: str) -> List[Dict]: """Load all chats for a space""" chats_file = get_space_dir(space_id) / "chats.json" if chats_file.exists(): with open(chats_file, 'r', encoding='utf-8') as f: return json.load(f) return [] def save_chats_for_space(space_id: str, chats: List[Dict]): """Save chats for a space""" chats_file = get_space_dir(space_id) / "chats.json" chats_file.parent.mkdir(parents=True, exist_ok=True) with open(chats_file, 'w', encoding='utf-8') as f: json.dump(chats, f, indent=2, ensure_ascii=False) def get_chat_title(messages: List[Dict]) -> str: """Generate chat title from first user message""" for msg in messages: if msg['role'] == 'user': content = msg['content'][:50] return content + "..." if len(msg['content']) > 50 else content return "New Chat" def _is_nlp_metrics_query(query: str) -> bool: """Detect metric-heavy NLP evaluation questions that need broader retrieval.""" q = (query or "").lower() triggers = ( "bleu", "rouge", "meteor", "bertscore", "sequence generation", "machine translation", "summarization", "n-gram", "brevity penalty", "evaluation metric", "nlp metric", "transformer", ) return any(t in q for t in triggers) def _extract_year_from_filename(filename: str) -> Optional[int]: """Infer publication year from filename (best-effort).""" if not filename: return None matches = re.findall(r'(?:19|20)\d{2}', filename) if not matches: return None try: years = [int(y) for y in matches] valid_years = [y for y in years if 1900 <= y <= 2100] if not valid_years: return None return max(valid_years) except Exception: return None def _resolve_publication_year(meta: Optional[Dict[str, Any]]) -> Optional[int]: """Resolve publication year from metadata; fallback to filename inference.""" if not meta: return None raw_year = meta.get('publication_year') if raw_year is not None: try: year = int(raw_year) if 1900 <= year <= 2100: return year except Exception: pass filename = str(meta.get('filename', '')) return _extract_year_from_filename(filename) @spaces.GPU def _build_retrieval_queries(query: str) -> List[str]: """Build retrieval query set; add targeted expansions for NLP metric topics.""" queries = [query] if not _is_nlp_metrics_query(query): return queries queries.extend([ f"{query} BLEU brevity penalty BP n-gram precision", f"{query} ROUGE-N ROUGE-L longest common subsequence LCS F1 recall precision", f"{query} METEOR stemming synonym WordNet exact-match limitation", f"{query} BERTScore contextual embeddings semantic similarity deep learning transformer", ]) return queries @spaces.GPU def _retrieve_documents_for_query( retriever: HybridRetriever, query: str, n_results: int, score_threshold: float, ): """Retrieve with optional query expansions and merge by max score.""" retrieval_queries = _build_retrieval_queries(query) per_query_n = max(12, n_results // 2) if len(retrieval_queries) > 1 else n_results per_query_threshold = max(0.0, score_threshold - 0.01) if len(retrieval_queries) > 1 else score_threshold merged = {} for q in retrieval_queries: docs, metas, scores = retriever.retrieve( query=q, n_results=per_query_n, score_threshold=per_query_threshold, ) for doc, meta, score in zip(docs, metas, scores): current = merged.get(doc) score_value = float(score) if current is None or score_value > current["score"]: merged[doc] = { "doc": doc, "meta": meta, "score": score_value, } ranked = sorted(merged.values(), key=lambda item: item["score"], reverse=True) selected = ranked[:n_results] return ( [item["doc"] for item in selected], [item["meta"] for item in selected], [item["score"] for item in selected], ) def _sanitize_retrieved_chunk(text: str) -> str: """Clean retrieval chunks to remove OCR/table-of-contents numeric noise.""" if not text: return "" cleaned_lines: List[str] = [] for raw_line in text.splitlines(): line = raw_line.strip() if not line: cleaned_lines.append("") continue if re.search(r'(?:\d+\.){8,}\d*', line): continue compact = re.sub(r'\s+', '', line) if len(compact) >= 40 and re.fullmatch(r'[\d\W_]+', compact): continue line = re.sub(r'(?:\d+\.){8,}\d*', ' ', line) line = re.sub(r'\s{2,}', ' ', line).strip() if line: cleaned_lines.append(line) cleaned = "\n".join(cleaned_lines) cleaned = re.sub(r'\n{3,}', '\n\n', cleaned).strip() return cleaned def _is_usable_context_chunk(text: str) -> bool: """Reject chunks that are mostly numeric garbage after cleanup.""" if not text or len(text) < 80: return False letters = len(re.findall(r'[A-Za-z]', text)) digits = len(re.findall(r'\d', text)) if letters < 35: return False if digits > 0 and letters < max(80, int(digits * 0.35)): return False return True def _format_sse_event(event: str, payload: Dict[str, Any]) -> str: """Format one Server-Sent Event frame.""" return f"event: {event}\ndata: {json.dumps(payload, ensure_ascii=False)}\n\n" def ensure_notebooks_for_existing_spaces() -> int: """Ensure every existing space has an associated notebook metadata record.""" created_count = 0 spaces = spaces_manager.get_all_spaces() for space in spaces: space_id = space.get('id') if not space_id: continue existing_notebook = studio_manager.get_space_notebook(space_id) if existing_notebook: continue studio_manager.ensure_space_notebook(space_id, space.get('name', space_id)) created_count += 1 return created_count @spaces.GPU def rebuild_space_index_if_missing(space_id: str, force: bool = False) -> int: """Rebuild a space index from uploaded files if the current index is empty.""" if not vector_db: return 0 if not force: try: existing_count = vector_db.get_collection_count() if existing_count > 0: print(f"[INDEX_REBUILD {space_id}] skip existing_vectors={existing_count}") return 0 except Exception: pass uploads_dir = get_space_dir(space_id) / "uploads" if not uploads_dir.exists(): return 0 files = [ p for p in uploads_dir.iterdir() if p.is_file() and p.suffix.lower() in {".pdf", ".docx", ".txt"} ] if not files: print(f"[INDEX_REBUILD {space_id}] skip no_supported_uploads") return 0 print(f"[INDEX_REBUILD {space_id}] start files={len(files)}") processor = DocumentProcessor() texts: List[str] = [] metadatas: List[Dict[str, Any]] = [] ids: List[str] = [] for file_path in files: try: print(f"[INDEX_REBUILD {space_id}] processing_file={file_path.name}") file_data = processor.process_file(file_path) chunk_filename = file_data.get('virtual_filename', file_path.name) chunks = processor.chunk_text( file_data['content'], chunk_size=config.CHUNK_SIZE, overlap=config.CHUNK_OVERLAP, semantic=True, source_filename=chunk_filename, ) total_chunks = len(chunks) print(f"[INDEX_REBUILD {space_id}] file_chunks={total_chunks} file={chunk_filename}") publication_year = file_data.get('publication_year', '0000') for idx, chunk in enumerate(chunks): texts.append(chunk) metadatas.append({ 'filename': chunk_filename, 'chunk_index': idx, 'total_chunks': total_chunks, 'source_type': file_data['format'], 'publication_year': publication_year, }) ids.append(f"{space_id}_rebuild_{len(ids)}_{uuid.uuid4().hex[:8]}") except Exception as e: print(f"Index rebuild skipped {file_path.name}: {e}") if not texts: print(f"[INDEX_REBUILD {space_id}] skip no_chunks_generated") return 0 batch_size = 5000 for i in range(0, len(texts), batch_size): end = min(i + batch_size, len(texts)) print(f"[INDEX_REBUILD {space_id}] batch_add start={i} end={end}") vector_db.add_documents( texts[i:i + batch_size], metadatas[i:i + batch_size], ids[i:i + batch_size], ) print(f"[INDEX_REBUILD {space_id}] done chunks={len(texts)}") return len(texts) def initialize_space(space_id: str): """Initialize vector DB and components for a space""" global vector_db, hybrid_retriever, llm_generator, studio_generator, current_space if current_space == space_id and vector_db is not None and llm_generator is not None: return switching_space = current_space is not None and current_space != space_id if switching_space: vector_db = None hybrid_retriever = None llm_generator = None studio_generator = None if config.SPACE_SWITCH_CLEAR_CUDA_CACHE: VectorDatabase.clear_runtime_caches( unload_embedding_model=config.SPACE_SWITCH_UNLOAD_EMBEDDING_MODEL, ) gemini_key = config_manager.get_api_key('gemini') nvidia_key = config_manager.get_api_key('nvidia') groq_key = config_manager.get_api_key('groq') if not gemini_key and not nvidia_key and not groq_key: raise HTTPException( status_code=400, detail="No API key configured. Please add a Gemini or NVIDIA API key.", ) space_dir = get_space_dir(space_id) vector_db = VectorDatabase( collection_name=f"space_{space_id}", persist_directory=str(space_dir / "vector_db"), ) collection_reset = vector_db.ensure_collection_embedding_compatibility() if collection_reset: print(f"[INDEX_REBUILD {space_id}] reset_due_to_embedding_dimension_change=1") hybrid_retriever = HybridRetriever(vector_db, alpha=0.6) rebuild_space_index_if_missing(space_id) llm_generator = LLMGenerator( provider="gemini", gemini_api_key=gemini_key, nvidia_api_key=nvidia_key, groq_api_key=groq_key, ) studio_generator = StudioGenerator(llm_generator, studio_manager) current_space = space_id @app.on_event("startup") async def startup_sync_notebooks(): """Auto-create missing notebooks for pre-existing spaces when backend starts and preload GPU models.""" try: from utils.vector_db import VectorDatabase print("Preloading embedding and reranker models to GPU...") VectorDatabase._get_or_create_embedding_model() VectorDatabase._get_or_create_reranker() print("Models successfully preloaded to GPU.") except Exception as e: print(f"Failed to preload models: {e}") try: created = ensure_notebooks_for_existing_spaces() if created > 0: print(f"Created {created} missing notebook(s) for existing spaces") except Exception as e: print(f"Notebook startup sync failed: {e}") # ==================== API Endpoints ==================== @app.get("/") async def root(): """Health check""" return {"status": "NotebookPRO API is running", "version": "2.0.0"} @app.get("/api/health") async def health_check(): """Fast health endpoint used by the Flutter app connection checks.""" return {"status": "ok"} @app.get("/api/config", response_model=ConfigResponse) async def get_config(): """Get current API keys (masked)""" gemini_key = config_manager.get_api_key('gemini') nvidia_key = config_manager.get_api_key('nvidia') groq_key = config_manager.get_api_key('groq') masked_gemini_key = "***" + gemini_key[-4:] if gemini_key else None masked_nvidia_key = "***" + nvidia_key[-4:] if nvidia_key else None masked_groq_key = "***" + groq_key[-4:] if groq_key else None return ConfigResponse( gemini_api_key=masked_gemini_key, nvidia_api_key=masked_nvidia_key, groq_api_key=masked_groq_key, ) @app.get("/api/system/export") async def export_data(): """Export the entire data directory as a ZIP file.""" try: tmp_dir = Path(tempfile.gettempdir()) zip_base_path = tmp_dir / f"notebookpro_backup_{int(time.time())}" shutil.make_archive( base_name=str(zip_base_path), format="zip", root_dir=str(get_data_dir()) ) zip_path = str(zip_base_path) + ".zip" return FileResponse( path=zip_path, filename="notebookpro_backup.zip", media_type="application/zip", background=BackgroundTask(lambda: os.remove(zip_path)) ) except Exception as e: raise HTTPException(status_code=500, detail=f"Export failed: {e}") @app.post("/api/system/import") async def import_data(file: UploadFile = File(...)): """Import a ZIP file and overwrite the data directory.""" if not file.filename.endswith('.zip'): raise HTTPException(status_code=400, detail="Must be a ZIP file") try: tmp_dir = Path(tempfile.gettempdir()) zip_path = tmp_dir / f"notebookpro_import_{int(time.time())}.zip" with open(zip_path, "wb") as buffer: shutil.copyfileobj(file.file, buffer) with zipfile.ZipFile(zip_path, 'r') as zip_ref: pass extract_dir = tmp_dir / f"notebookpro_import_extracted_{int(time.time())}" if extract_dir.exists(): shutil.rmtree(extract_dir) extract_dir.mkdir(parents=True) with zipfile.ZipFile(zip_path, 'r') as zip_ref: zip_ref.extractall(extract_dir) data_dir = get_data_dir() for item in extract_dir.iterdir(): target = data_dir / item.name if target.exists(): if target.is_dir(): shutil.rmtree(target) else: target.unlink() shutil.move(str(item), str(target)) os.remove(zip_path) shutil.rmtree(extract_dir) return {"status": "success", "message": "Data imported successfully. Please restart the backend."} except Exception as e: raise HTTPException(status_code=500, detail=f"Import failed: {e}") @app.post("/api/config") async def update_config(config_update: ConfigUpdate): """Update API keys""" if config_update.gemini_api_key: config_manager.set_api_key('gemini', config_update.gemini_api_key) if config_update.nvidia_api_key: config_manager.set_api_key('nvidia', config_update.nvidia_api_key) if config_update.groq_api_key: config_manager.set_api_key('groq', config_update.groq_api_key) return {"status": "success", "message": "Configuration updated"} @app.get("/api/spaces", response_model=List[SpaceResponse]) async def get_spaces(): """Get all spaces""" ensure_notebooks_for_existing_spaces() spaces = spaces_manager.get_all_spaces() result = [] for space in spaces: space_id = space['id'] space_dir = get_space_dir(space_id) processed_file = space_dir / "processed_files.json" file_count = 0 if processed_file.exists(): with open(processed_file, 'r', encoding='utf-8-sig') as f: file_count = len(json.load(f)) result.append(SpaceResponse( id=space_id, name=space['name'], created_at=space['created_at'], file_count=file_count )) return result @app.post("/api/spaces", response_model=SpaceResponse) async def create_space(space_data: SpaceCreate): """Create a new space""" try: space = spaces_manager.create_space(space_data.name) studio_manager.ensure_space_notebook(space['id'], space['name']) return SpaceResponse( id=space['id'], name=space['name'], created_at=space['created_at'], file_count=0 ) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) @app.delete("/api/spaces/{space_id}") async def delete_space(space_id: str): """Delete a space""" try: spaces_manager.delete_space(space_id) space_dir = get_space_dir(space_id) if space_dir.exists(): shutil.rmtree(space_dir) return {"status": "success", "message": f"Space {space_id} deleted"} except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except Exception as e: raise HTTPException(status_code=500, detail=f"Error deleting space: {str(e)}") @app.get("/api/spaces/{space_id}/chats", response_model=List[ChatInfo]) async def get_chats(space_id: str): """Get all chats for a space""" chats = load_chats_for_space(space_id) result = [] for chat in chats: messages = chat.get('messages', []) result.append(ChatInfo( id=chat['id'], title=get_chat_title(messages), preview=messages[0]['content'][:100] if messages else "", created_at=chat.get('created_at', ''), updated_at=chat.get('updated_at', ''), message_count=len(messages) )) return result @app.get("/api/spaces/{space_id}/chats/{chat_id}") async def get_chat(space_id: str, chat_id: str): """Get specific chat by ID""" chats = load_chats_for_space(space_id) for chat in chats: if chat['id'] == chat_id: return chat raise HTTPException(status_code=404, detail="Chat not found") @app.delete("/api/spaces/{space_id}/chats/{chat_id}") async def delete_chat(space_id: str, chat_id: str): """Delete a chat""" chats = load_chats_for_space(space_id) chats = [c for c in chats if c['id'] != chat_id] save_chats_for_space(space_id, chats) return {"status": "success", "message": f"Chat {chat_id} deleted"} @app.post("/api/spaces/{space_id}/chats/{chat_id}/messages/delete") async def delete_chat_message(space_id: str, chat_id: str, request: DeleteMessageRequest): """Delete a specific message from a chat.""" chats = load_chats_for_space(space_id) chat = next((c for c in chats if c.get('id') == chat_id), None) if chat is None: raise HTTPException(status_code=404, detail="Chat not found") messages = chat.get('messages', []) removed = False target_index = -1 for idx, msg in enumerate(messages): if msg.get('role') != request.role: continue if msg.get('timestamp') != request.timestamp: continue if request.content is not None and msg.get('content') != request.content: continue target_index = idx removed = True break if not removed and request.content is not None: for idx in range(len(messages) - 1, -1, -1): msg = messages[idx] if msg.get('role') == request.role and msg.get('content') == request.content: target_index = idx removed = True break if not removed: raise HTTPException(status_code=404, detail="Message not found") indexes_to_remove = {target_index} if request.delete_related_user and target_index > 0: for idx in range(target_index - 1, -1, -1): if messages[idx].get('role') == 'user': indexes_to_remove.add(idx) break for idx in sorted(indexes_to_remove, reverse=True): if 0 <= idx < len(messages): del messages[idx] chat['messages'] = messages chat['updated_at'] = datetime.now().isoformat() save_chats_for_space(space_id, chats) return { "status": "success", "message": "Message deleted", "remaining_messages": len(messages), } @app.post("/api/chat", response_model=ChatResponse) async def chat(request: ChatRequest): """Process a chat message with RAG""" try: trace_id = uuid.uuid4().hex[:8] t_start = time.perf_counter() t_init = time.perf_counter() initialize_space(request.space_id) global hybrid_retriever if hybrid_retriever is None: hybrid_retriever = HybridRetriever(vector_db, alpha=0.6) retrieval_n = int(os.getenv("CHAT_RETRIEVAL_RESULTS", "16")) retrieval_n = max(8, min(retrieval_n, 30)) retrieval_threshold = float(os.getenv("CHAT_RETRIEVAL_THRESHOLD", "0.01")) chat_max_tokens = int(os.getenv("CHAT_MAX_TOKENS", "1800")) chat_max_tokens = max(700, min(chat_max_tokens, 2600)) documents, metadatas, scores = _retrieve_documents_for_query( retriever=hybrid_retriever, query=request.query, n_results=retrieval_n, score_threshold=retrieval_threshold, ) context_parts = [] sources = [] filtered_metadatas = [] dropped_chunks = 0 seen_context_fingerprints = set() for doc, meta, score in zip(documents, metadatas, scores): cleaned_doc = _sanitize_retrieved_chunk(doc) if not _is_usable_context_chunk(cleaned_doc): dropped_chunks += 1 continue fingerprint = re.sub(r'\s+', ' ', cleaned_doc[:320]).lower() if fingerprint in seen_context_fingerprints: dropped_chunks += 1 continue seen_context_fingerprints.add(fingerprint) filename = meta.get('filename', 'Unknown') clean_name = filename.replace('.pdf', '').replace('.docx', '').replace('.txt', '') publication_year = _resolve_publication_year(meta) year_label = str(publication_year) if publication_year else "UNKNOWN" source_idx = len(context_parts) + 1 context_parts.append(f"Source [{source_idx}] [YEAR: {year_label}] ({clean_name}):\n{cleaned_doc}\n") filtered_metadatas.append(meta) sources.append({ "content": cleaned_doc[:200] + "..." if len(cleaned_doc) > 200 else cleaned_doc, "metadata": meta, "score": float(score) }) if not context_parts: return ChatResponse( response=( "I found related retrieval hits, but they were too noisy to trust for a grounded answer. " "Please re-upload a cleaner source file (or a text-based PDF) and try again." ), sources=[], chat_id=request.chat_id or str(uuid.uuid4()), timestamp=datetime.now().isoformat(), ui_notice="Context chunks were filtered due to low text quality.", suggested_follow_ups=[], ) context = "\n".join(context_parts) response = llm_generator.generate_response( prompt=request.query, context=context, use_case=request.workflow if request.workflow in ["summary", "explanation", "qa", "notes"] else "qa", metadatas=filtered_metadatas, temperature=0.35, max_tokens=chat_max_tokens, ) suggested_follow_ups = llm_generator.generate_follow_up_questions( prompt=request.query, context=context, answer=response, max_questions=3, ) ui_notice = getattr(llm_generator, 'last_notice', None) chat_id = request.chat_id or str(uuid.uuid4()) chats = load_chats_for_space(request.space_id) chat = next((c for c in chats if c['id'] == chat_id), None) if not chat: chat = { 'id': chat_id, 'messages': [], 'created_at': datetime.now().isoformat(), 'updated_at': datetime.now().isoformat() } chats.append(chat) timestamp = datetime.now().isoformat() chat['messages'].extend([ {'role': 'user', 'content': request.query, 'timestamp': timestamp}, { 'role': 'assistant', 'content': response, 'timestamp': timestamp, 'sources': sources, 'suggested_follow_ups': suggested_follow_ups, } ]) chat['updated_at'] = timestamp save_chats_for_space(request.space_id, chats) return ChatResponse( response=response, sources=sources, chat_id=chat_id, timestamp=timestamp, ui_notice=ui_notice, suggested_follow_ups=suggested_follow_ups, ) except HTTPException: raise except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @app.post("/api/chat/stream") async def chat_stream(request: ChatRequest): """SSE streaming compatibility endpoint for the Flutter client.""" trace_id = "unknown" t_stream = time.perf_counter() try: trace_id = uuid.uuid4().hex[:8] @space.GPU def event_stream(): try: initialize_space(request.space_id) global hybrid_retriever if hybrid_retriever is None: hybrid_retriever = HybridRetriever(vector_db, alpha=0.6) retrieval_n = int(os.getenv("CHAT_RETRIEVAL_RESULTS", "16")) retrieval_n = max(8, min(retrieval_n, 30)) retrieval_threshold = float(os.getenv("CHAT_RETRIEVAL_THRESHOLD", "0.01")) chat_max_tokens = int(os.getenv("CHAT_MAX_TOKENS", "1800")) chat_max_tokens = max(700, min(chat_max_tokens, 2600)) documents, metadatas, scores = _retrieve_documents_for_query( retriever=hybrid_retriever, query=request.query, n_results=retrieval_n, score_threshold=retrieval_threshold, ) context_parts = [] sources = [] filtered_metadatas = [] seen_context_fingerprints = set() for doc, meta, score in zip(documents, metadatas, scores): cleaned_doc = _sanitize_retrieved_chunk(doc) if not _is_usable_context_chunk(cleaned_doc): continue fingerprint = re.sub(r'\s+', ' ', cleaned_doc[:320]).lower() if fingerprint in seen_context_fingerprints: continue seen_context_fingerprints.add(fingerprint) filename = meta.get('filename', 'Unknown') clean_name = filename.replace('.pdf', '').replace('.docx', '').replace('.txt', '') publication_year = _resolve_publication_year(meta) year_label = str(publication_year) if publication_year else "UNKNOWN" source_idx = len(context_parts) + 1 context_parts.append(f"Source [{source_idx}] [YEAR: {year_label}] ({clean_name}):\n{cleaned_doc}\n") filtered_metadatas.append(meta) sources.append({ "content": cleaned_doc[:200] + "..." if len(cleaned_doc) > 200 else cleaned_doc, "metadata": meta, "score": float(score) }) chat_id = request.chat_id or str(uuid.uuid4()) timestamp = datetime.now().isoformat() if not context_parts: yield _format_sse_event("meta", { "chat_id": chat_id, "timestamp": timestamp, "sources": [], "ui_notice": "Context chunks were filtered due to low text quality.", }) msg = "I found related retrieval hits, but they were too noisy to trust for a grounded answer. Please re-upload a cleaner source file (or a text-based PDF) and try again." yield _format_sse_event("token", {"delta": msg}) yield _format_sse_event("done", { "chat_id": chat_id, "timestamp": timestamp, "response": msg, "sources": [], "ui_notice": "Context chunks were filtered due to low text quality.", "suggested_follow_ups": [], }) return context = "\n".join(context_parts) yield _format_sse_event("meta", { "chat_id": chat_id, "timestamp": timestamp, "sources": sources, "ui_notice": getattr(llm_generator, 'last_notice', None), }) full_response = "" generator = llm_generator.generate_stream( prompt=request.query, context=context, use_case=request.workflow if request.workflow in ["summary", "explanation", "qa", "notes"] else "qa", metadatas=filtered_metadatas, temperature=0.35, max_tokens=chat_max_tokens, ) for chunk in generator: if chunk: full_response += chunk yield _format_sse_event("token", {"delta": chunk}) suggested_follow_ups = llm_generator.generate_follow_up_questions( prompt=request.query, context=context, answer=full_response, max_questions=3, ) if suggested_follow_ups: yield _format_sse_event("followups", { "suggested_follow_ups": suggested_follow_ups, }) chats = load_chats_for_space(request.space_id) chat = next((c for c in chats if c['id'] == chat_id), None) if not chat: chat = { 'id': chat_id, 'messages': [], 'created_at': timestamp, 'updated_at': timestamp } chats.append(chat) chat['messages'].extend([ {'role': 'user', 'content': request.query, 'timestamp': timestamp}, { 'role': 'assistant', 'content': full_response, 'timestamp': timestamp, 'sources': sources, 'suggested_follow_ups': suggested_follow_ups, } ]) chat['updated_at'] = timestamp save_chats_for_space(request.space_id, chats) yield _format_sse_event("done", { "chat_id": chat_id, "timestamp": timestamp, "response": full_response, "sources": sources, "ui_notice": getattr(llm_generator, 'last_notice', None), "suggested_follow_ups": suggested_follow_ups, }) except Exception as e: print(f"[CHAT_STREAM ERROR] {trace_id}: {str(e)}") yield _format_sse_event("error", {"message": str(e)}) return StreamingResponse( event_stream(), media_type="text/event-stream", headers={ "Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no", }, ) except Exception as exc: error_message = str(exc) def error_stream(message: str = error_message): yield _format_sse_event("error", {"message": message}) return StreamingResponse( error_stream(), media_type="text/event-stream", status_code=500, ) @app.post("/api/spaces/{space_id}/upload") async def upload_files(space_id: str, files: List[UploadFile] = File(...)): """Upload and process files for a space""" try: global hybrid_retriever initialize_space(space_id) space_dir = get_space_dir(space_id) uploads_dir = space_dir / "uploads" uploads_dir.mkdir(parents=True, exist_ok=True) processor = DocumentProcessor() all_chunks = [] processed_files = [] skipped_files = [] processed_file_path = space_dir / "processed_files.json" existing_filenames = set() if processed_file_path.exists(): with open(processed_file_path, 'r', encoding='utf-8-sig') as f: try: for item in json.load(f): existing_filenames.add(item.get('original_filename', item.get('filename'))) except Exception: pass for file in files: filename = (file.filename or "unnamed_upload").strip() if filename in existing_filenames: skipped_files.append({"filename": filename, "reason": "File already exists in this space"}) continue try: await file.seek(0) except Exception: pass content = await file.read() content_size = len(content) if content else 0 if content_size == 0: reason = "Upload payload was empty (0 bytes)" skipped_files.append({"filename": filename, "reason": reason}) continue file_path = uploads_dir / filename with open(file_path, "wb") as f: f.write(content) f.flush() os.fsync(f.fileno()) try: if file_path.stat().st_size == 0: reason = "File became 0 bytes after write" skipped_files.append({"filename": filename, "reason": reason}) file_path.unlink(missing_ok=True) continue except Exception: pass try: file_data = processor.process_file(file_path) content = file_data['content'] chunk_filename = file_data.get('virtual_filename', filename) chunks = processor.chunk_text( content, chunk_size=config.CHUNK_SIZE, overlap=config.CHUNK_OVERLAP, semantic=True, source_filename=chunk_filename, ) formatted_chunks = [] publication_year = file_data.get('publication_year', '0000') for idx, chunk in enumerate(chunks): formatted_chunks.append({ 'content': chunk, 'metadata': { 'filename': chunk_filename, 'chunk_index': idx, 'total_chunks': len(chunks), 'source_type': file_data['format'], 'publication_year': publication_year, } }) all_chunks.extend(formatted_chunks) processed_files.append({ 'filename': chunk_filename, 'original_filename': filename, 'publication_year': publication_year, 'chunks': len(chunks), 'processed_at': datetime.now().isoformat() }) try: p_file = space_dir / "processed_files.json" ex = [] if p_file.exists(): with open(p_file, 'r', encoding='utf-8-sig') as f: ex = json.load(f) ex = [x for x in ex if x.get('original_filename') != filename and x.get('filename') != chunk_filename] ex.append(processed_files[-1]) with open(p_file, 'w', encoding='utf-8') as f: json.dump(ex, f, indent=2) except Exception as e: print(f"Error saving progressive processed_files.json: {e}") except Exception as e: err = str(e) skipped_files.append({"filename": filename, "reason": err}) continue if all_chunks: texts = [chunk['content'] for chunk in all_chunks] metadatas = [chunk['metadata'] for chunk in all_chunks] ids = [f"{space_id}_{idx}_{uuid.uuid4().hex[:8]}" for idx in range(len(all_chunks))] batch_size = 5000 for i in range(0, len(texts), batch_size): batch_texts = texts[i:i + batch_size] batch_metadatas = metadatas[i:i + batch_size] batch_ids = ids[i:i + batch_size] vector_db.add_documents(batch_texts, batch_metadatas, batch_ids) hybrid_retriever = HybridRetriever(vector_db, alpha=0.6) processed_file = space_dir / "processed_files.json" existing = [] if processed_file.exists(): try: with open(processed_file, 'r', encoding='utf-8-sig') as f: existing = json.load(f) except Exception: existing = [] new_originals = {p.get('original_filename') for p in processed_files} new_virtuals = {p.get('filename') for p in processed_files} existing = [x for x in existing if x.get('original_filename') not in new_originals and x.get('filename') not in new_virtuals] existing.extend(processed_files) tmp_file = processed_file.with_suffix('.tmp') with open(tmp_file, 'w', encoding='utf-8') as f: json.dump(existing, f, indent=2, ensure_ascii=False) tmp_file.replace(processed_file) return { "status": "success", "files_processed": len(processed_files), "total_chunks": len(all_chunks), "files_skipped": len(skipped_files), "skipped_files": skipped_files, } except HTTPException: raise except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @app.get("/api/spaces/{space_id}/files") async def get_files(space_id: str): """Get processed files for a space""" processed_file = get_space_dir(space_id) / "processed_files.json" if processed_file.exists(): with open(processed_file, 'r', encoding='utf-8-sig') as f: return json.load(f) return [] @app.delete("/api/spaces/{space_id}/files/{filename}") async def delete_file(space_id: str, filename: str): """Delete a specific file from a space""" try: original_filename = filename processed_file = get_space_dir(space_id) / "processed_files.json" if processed_file.exists(): with open(processed_file, 'r', encoding='utf-8-sig') as f: files_data = json.load(f) for item in files_data: if item.get('filename') == filename: original_filename = item.get('original_filename', filename) break if item.get('original_filename') == filename: original_filename = item.get('original_filename', filename) break files_data = [ f for f in files_data if f.get('filename') != filename and f.get('original_filename') != filename ] with open(processed_file, 'w', encoding='utf-8') as f: json.dump(files_data, f, indent=2, ensure_ascii=False) file_path = get_space_dir(space_id) / "uploads" / original_filename if file_path.exists(): file_path.unlink() try: ids_to_delete: List[str] = [] candidate_names = {filename, original_filename} try: for candidate in candidate_names: filtered = vector_db.collection.get(where={"filename": candidate}) if filtered and filtered.get('ids'): ids_to_delete.extend(filtered.get('ids', [])) except Exception: all_results = vector_db.collection.get() all_ids = all_results.get('ids', []) if all_results else [] all_metas = all_results.get('metadatas', []) if all_results else [] for doc_id, metadata in zip(all_ids, all_metas): if metadata and metadata.get('filename') in candidate_names: ids_to_delete.append(doc_id) if ids_to_delete: vector_db.collection.delete(ids=list(set(ids_to_delete))) vector_db.client.persist() except Exception as e: print(f"Vector cleanup warning for {filename}: {e}") return { "status": "success", "message": f"File {filename} deleted" } except HTTPException: raise except Exception as e: raise HTTPException(status_code=500, detail=str(e)) # ==================== Document Export Helpers ==================== def _render_latex_to_mathml(text: str) -> str: """Find math expressions in text and convert using ziamath.""" text = re.sub(r'\s*(?:\*\*)?\[\d+(?:,\s*\d+)*\](?:\*\*)?', '', text) def render_math(m, display_mode): try: math_str = m.group(1).strip() svg = ziamath.Math.fromlatex(math_str).svg() if display_mode == 'block': return f'