"""FastAPI application: upload documents, list/manage them, and chat over them.""" from __future__ import annotations import uuid from pathlib import Path from fastapi import FastAPI, File, HTTPException, UploadFile from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles from . import db, vector_store from .config import get_settings from .embeddings import embed_texts from .ingestion import process_document from .models import ChatRequest, ChatResponse, DocumentSummary, IngestResponse app = FastAPI( title="Document Intelligence & Chat Assistant", description="RAG over your uploaded documents: OCR + MongoDB + Qdrant + Claude.", version="1.0.0", ) STATIC_DIR = Path(__file__).resolve().parent.parent / "static" @app.on_event("startup") def _startup() -> None: # Best-effort: create the Qdrant collection up front. Never block startup — # if Qdrant is briefly unreachable the collection is created lazily on the # first upload instead, so the server still binds and the UI loads. try: vector_store.ensure_collection() except Exception as exc: # noqa: BLE001 print(f"[startup] Qdrant not ready yet, will retry on first upload: {exc}") def _to_summary(doc: dict) -> DocumentSummary: return DocumentSummary( id=doc["_id"], filename=doc["filename"], content_type=doc["content_type"], num_chunks=doc["num_chunks"], num_chars=doc["num_chars"], ocr_used=doc["ocr_used"], created_at=doc["created_at"], ) @app.get("/health") def health() -> dict: status = {"status": "ok"} try: db.ping() status["mongodb"] = "ok" except Exception as exc: # noqa: BLE001 status["mongodb"] = f"error: {exc}" try: vector_store.get_client().get_collections() status["qdrant"] = "ok" except Exception as exc: # noqa: BLE001 status["qdrant"] = f"error: {exc}" status["llm_key_configured"] = bool(get_settings().openrouter_api_key) return status @app.post("/documents/upload", response_model=IngestResponse) async def upload_document(file: UploadFile = File(...)) -> IngestResponse: settings = get_settings() data = await file.read() if not data: raise HTTPException(status_code=400, detail="Uploaded file is empty.") chunks, ocr_used, num_chars = process_document( file.filename, file.content_type or "", data, settings.chunk_size, settings.chunk_overlap, ) if not chunks: raise HTTPException( status_code=422, detail="No text could be extracted from this document.", ) document_id = str(uuid.uuid4()) vectors = embed_texts(chunks) vector_store.ensure_collection() # lazy create if startup couldn't reach Qdrant vector_store.upsert_chunks(document_id, file.filename, chunks, vectors) doc = db.save_document( document_id=document_id, filename=file.filename, content_type=file.content_type or "", num_chunks=len(chunks), num_chars=num_chars, ocr_used=ocr_used, ) return IngestResponse( document=_to_summary(doc), message=f"Indexed {len(chunks)} chunks" + (" (OCR used)" if ocr_used else ""), ) @app.get("/documents", response_model=list[DocumentSummary]) def list_documents() -> list[DocumentSummary]: return [_to_summary(d) for d in db.list_documents()] @app.get("/documents/{document_id}", response_model=DocumentSummary) def get_document(document_id: str) -> DocumentSummary: doc = db.get_document(document_id) if not doc: raise HTTPException(status_code=404, detail="Document not found.") return _to_summary(doc) @app.delete("/documents/{document_id}") def delete_document(document_id: str) -> dict: if not db.get_document(document_id): raise HTTPException(status_code=404, detail="Document not found.") vector_store.delete_document(document_id) db.delete_document(document_id) return {"deleted": document_id} @app.post("/chat", response_model=ChatResponse) def chat(request: ChatRequest) -> ChatResponse: from . import rag try: return rag.answer_question( question=request.question, document_ids=request.document_ids, top_k=request.top_k, ) except RuntimeError as exc: raise HTTPException(status_code=503, detail=str(exc)) except Exception as exc: # noqa: BLE001 — always return JSON so the UI can show it import traceback traceback.print_exc() raise HTTPException(status_code=502, detail=f"{type(exc).__name__}: {exc}") # --- Minimal web UI (served at /) --- if STATIC_DIR.exists(): app.mount("/ui", StaticFiles(directory=str(STATIC_DIR), html=True), name="ui") @app.get("/") def root() -> FileResponse: return FileResponse(str(STATIC_DIR / "index.html"))