File size: 9,138 Bytes
6bcf4a2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
"""FastAPI service.

Design notes worth stating, because they are the parts that would otherwise
look arbitrary:

- The engine is built once in the lifespan handler and warmed before the server
  accepts traffic. Lazy loading would push a 5 second model initialisation onto
  whichever user arrives first.
- Retrieval and embedding are synchronous CPU work. Running them directly in
  the event loop would stall every other in-flight request, so the blocking
  path is dispatched to a worker thread.
- Errors are translated in one exception handler from the typed hierarchy in
  core.errors, so no route constructs a status code by hand.
"""

from __future__ import annotations

import asyncio
import contextlib
import json
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any

from fastapi import APIRouter, FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse, StreamingResponse

from secrag import __version__
from secrag.core.config import get_settings
from secrag.core.errors import SecRagError
from secrag.core.logging import configure_logging, get_logger
from secrag.core.types import QueryRequest, QueryResponse
from secrag.engine import QueryEngine
from secrag.observability.metrics import REGISTRY

log = get_logger(__name__)

router = APIRouter()

UI_DIR = Path(__file__).resolve().parents[3] / "ui"


@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
    settings = get_settings()
    configure_logging(settings.log_level, json_output=settings.log_json)

    engine = QueryEngine(settings)
    app.state.engine = engine
    app.state.ready = False

    # Warmup is blocking model initialisation, so it runs in a thread to keep
    # the health endpoint responsive while it happens.
    async def warm() -> None:
        try:
            await asyncio.to_thread(engine.warmup)
            app.state.ready = True
            REGISTRY.gauge("secrag_corpus_chunks", engine.retriever.corpus_size)
            log.info("service_ready", version=__version__)
        except Exception as exc:
            log.warning("warmup_failed", error=str(exc))
            app.state.ready = True

    task = asyncio.create_task(warm())
    try:
        yield
    finally:
        # Wait for warmup rather than cancelling it. Warmup runs inside
        # asyncio.to_thread, and cancelling the awaiting coroutine does not
        # stop the thread: it carries on and reopens the vector store after
        # shutdown has closed it. The index lock is then held by a thread
        # nobody is waiting on, and the next startup fails with
        # "already accessed by another instance".
        with contextlib.suppress(TimeoutError, asyncio.CancelledError, Exception):
            await asyncio.wait_for(task, timeout=60.0)
        await engine.aclose()


def create_app() -> FastAPI:
    """Build the application.

    A factory rather than a bare module-level object so configuration is read
    when the app is constructed instead of when the module is first imported.
    Import-time configuration is invisible to tests and to anything that sets
    environment variables after import.
    """
    application = FastAPI(
        title="sec-rag-platform",
        version=__version__,
        description=(
            "Evaluation-driven retrieval-augmented generation over SEC filings. "
            "Hybrid retrieval, cross-encoder reranking, XBRL-verified figures, "
            "and groundedness verification on every answer."
        ),
        lifespan=lifespan,
    )
    application.add_middleware(
        CORSMiddleware,
        allow_origins=get_settings().cors_origin_list,
        allow_credentials=False,
        allow_methods=["GET", "POST"],
        allow_headers=["*"],
    )
    application.include_router(router)
    application.add_exception_handler(SecRagError, handle_secrag_error)  # type: ignore[arg-type]
    return application


def engine_of(request: Request) -> QueryEngine:
    return request.app.state.engine  # type: ignore[no-any-return]


async def handle_secrag_error(_: Request, exc: SecRagError) -> JSONResponse:
    REGISTRY.increment("secrag_errors_total", code=exc.code)
    log.warning("request_failed", code=exc.code, message=exc.message)
    return JSONResponse(status_code=exc.status_code, content=exc.to_dict())


# ---------------------------------------------------------------------------
# Health and introspection
# ---------------------------------------------------------------------------


@router.get("/health", tags=["ops"])
async def health() -> dict[str, str]:
    """Liveness. Answers even while models are still loading."""
    return {"status": "ok", "version": __version__}


@router.get("/ready", tags=["ops"])
async def ready(request: Request) -> JSONResponse:
    """Readiness. Only true once models are warm and the corpus is loaded."""
    is_ready = bool(getattr(request.app.state, "ready", False))
    engine = engine_of(request)
    return JSONResponse(
        status_code=200 if is_ready else 503,
        content={
            "ready": is_ready,
            "corpus_chunks": engine.retriever.corpus_size,
            "bm25_documents": engine.retriever.bm25.size,
        },
    )


@router.get("/v1/stats", tags=["ops"])
async def stats(request: Request) -> dict[str, Any]:
    """Everything the service knows about its own configuration and corpus."""
    return engine_of(request).stats()


@router.get("/metrics", response_class=PlainTextResponse, tags=["ops"])
async def metrics() -> str:
    """Prometheus text exposition."""
    return REGISTRY.render_prometheus()


@router.get("/v1/metrics", tags=["ops"])
async def metrics_json() -> dict[str, object]:
    return REGISTRY.snapshot()


# ---------------------------------------------------------------------------
# Query
# ---------------------------------------------------------------------------


@router.post("/v1/query", response_model=QueryResponse, tags=["query"])
async def query(request: Request, payload: QueryRequest) -> QueryResponse:
    """Answer a question with citations, verified figures, and a groundedness score."""
    engine = engine_of(request)
    REGISTRY.increment("secrag_queries_total")

    response = await engine.answer(payload)

    REGISTRY.observe("secrag_query_latency_ms", response.latency_ms)
    REGISTRY.increment("secrag_answers_total", status=response.answer.status.value)
    if response.cached:
        REGISTRY.increment("secrag_cache_hits_total")
    if response.route:
        REGISTRY.increment("secrag_routes_total", intent=response.route.intent.value)
    return response


@router.post("/v1/query/stream", tags=["query"])
async def query_stream(request: Request, payload: QueryRequest) -> StreamingResponse:
    """Stream an answer as server-sent events.

    Sources and the routing decision are emitted before the first token, so the
    client can render provenance while the answer is still being written.
    """
    engine = engine_of(request)
    REGISTRY.increment("secrag_queries_total")
    REGISTRY.increment("secrag_stream_requests_total")

    async def event_source() -> AsyncIterator[str]:
        try:
            async for event in engine.stream(payload):
                yield f"event: {event['event']}\ndata: {json.dumps(event['data'])}\n\n"
        except SecRagError as exc:
            yield f"event: error\ndata: {json.dumps(exc.to_dict())}\n\n"
        except Exception as exc:
            log.warning("stream_failed", error=str(exc))
            payload_error = {"code": "stream_error", "message": str(exc)}
            yield f"event: error\ndata: {json.dumps(payload_error)}\n\n"

    return StreamingResponse(
        event_source(),
        media_type="text/event-stream",
        headers={
            "Cache-Control": "no-cache",
            "Connection": "keep-alive",
            # Without this, nginx-style proxies buffer the whole response and
            # the stream arrives as one lump, defeating the point.
            "X-Accel-Buffering": "no",
        },
    )


# ---------------------------------------------------------------------------
# UI
# ---------------------------------------------------------------------------


@router.get("/", response_class=HTMLResponse, include_in_schema=False)
async def index() -> HTMLResponse:
    path = UI_DIR / "index.html"
    if not path.exists():
        return HTMLResponse(
            "<h1>sec-rag-platform</h1><p>API is running. See <a href='/docs'>/docs</a>.</p>"
        )
    return HTMLResponse(path.read_text(encoding="utf-8"))


# ---------------------------------------------------------------------------
# Module-level instance for `uvicorn secrag.api.app:app`.
#
# Constructed at the bottom of the module, after every handler it references
# has been defined. Building it immediately after the factory raises NameError
# on the exception handler declared further down.
# ---------------------------------------------------------------------------

app = create_app()