File size: 4,942 Bytes
1e8bb26
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0151e19
 
 
 
1e8bb26
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
"""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"))