File size: 3,631 Bytes
f5628ad
2868f80
f5628ad
 
3a34cb6
f5628ad
3a34cb6
f5628ad
 
43e87a7
1e5c62a
43e87a7
f5628ad
 
25ed685
f5628ad
3a34cb6
38ea650
3a34cb6
2868f80
f5628ad
 
 
 
 
 
7dac988
f5628ad
 
 
 
25ed685
34ca534
f5628ad
43e87a7
 
 
1e5c62a
 
 
 
f5628ad
 
 
 
 
25ed685
7dac988
f5628ad
 
 
7dac988
f5628ad
 
 
2322e90
f5628ad
 
 
 
2868f80
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f5628ad
1e5c62a
f5628ad
 
 
 
1e5c62a
f5628ad
8446976
 
 
7dac988
8446976
3a34cb6
 
 
 
 
 
10860d7
3a34cb6
 
 
 
 
 
f5628ad
 
 
 
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
from contextlib import asynccontextmanager
import time
from pathlib import Path

from fastapi import FastAPI, HTTPException, Request, Response
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles

from server.retriever import get_retriever, get_vectorstore, has_documents
from server.bm25_index import build_from_vectorstore, DEFAULT_WORKSPACE as BM25_DEFAULT
from server.reranker import load_reranker
from server.memory import create_memory
from server.chain import build_qa_chain
from server.utils import configure_logging, setup_logger, log_memory_mb

# Module-level constant so tests can monkeypatch it
UPLOAD_DIR = Path(__file__).resolve().parent.parent / "data" / "raw"

configure_logging()
logger = setup_logger(__name__)


@asynccontextmanager
async def lifespan(app: FastAPI):
    """Initialize chain, memory, retriever on startup."""
    logger.info("Starting Prism server...")
    app.state.memory = create_memory()
    app.state.retriever = None
    app.state.chain = None
    app.state.eval_log = []
    app.state.is_contextualizing = False  # True while background contextual refresh runs
    app.state.upload_jobs = {}  # job_id -> {status, message, briefing, error, workspace}

    # Pre-load reranker to avoid cold-start latency on first query
    load_reranker()

    if has_documents(BM25_DEFAULT):
        vectorstore = get_vectorstore(BM25_DEFAULT)
        build_from_vectorstore(vectorstore, workspace_id=BM25_DEFAULT)
        app.state.retriever = get_retriever(BM25_DEFAULT)
        app.state.chain = build_qa_chain(app.state.retriever, app.state.memory)
        logger.info("Chain initialized with existing documents")
    else:
        logger.info("No documents found - chain will be built after first upload")

    log_memory_mb(logger, "startup")
    logger.info("Prism server ready")
    yield


app = FastAPI(title="Prism API", lifespan=lifespan)

app.add_middleware(
    CORSMiddleware,
    allow_origin_regex=r"https://.*\.vercel\.app|http://localhost:\d+",
    allow_methods=["*"],
    allow_headers=["*"],
)


@app.middleware("http")
async def log_requests(request: Request, call_next) -> Response:
    start = time.perf_counter()
    response = await call_next(request)
    ms = (time.perf_counter() - start) * 1000
    logger.info(
        "%s %s  %d  %.0fms",
        request.method,
        request.url.path,
        response.status_code,
        ms,
    )
    return response

# Import and include route modules
from server.routes import chat, eval, upload, workspaces  # noqa: E402

app.include_router(chat.router, prefix="/api")
app.include_router(eval.router, prefix="/api")
app.include_router(upload.router, prefix="/api")
app.include_router(workspaces.router, prefix="/api")


@app.get("/health")
async def health():
    return {"status": "ok", "version": "3.0.0"}


@app.get("/api/files/{filename}")
async def serve_file(filename: str) -> FileResponse:
    """Serve uploaded files from data/raw/ for citation PDF links."""
    upload_root = UPLOAD_DIR.resolve()
    target = (UPLOAD_DIR / filename).resolve()
    if not target.is_relative_to(upload_root):
        raise HTTPException(status_code=400, detail="Invalid filename")
    if not target.exists():
        raise HTTPException(status_code=404, detail="File not found")
    return FileResponse(str(target))


# Serve React frontend build if it exists
frontend_dist = Path(__file__).resolve().parent.parent / "frontend" / "dist"
if frontend_dist.exists():
    app.mount("/", StaticFiles(directory=str(frontend_dist), html=True), name="frontend")