aditya0103's picture
v3: streaming SSE + async batch API (12 new tests, 138 total)
0962ea2
Raw
History Blame Contribute Delete
6.69 kB
"""POST /extract/stream — Server-Sent Events for progress-aware extraction.
Why this shape (progress events, not partial JSON)
--------------------------------------------------
Streaming a strict-mode structured-output response as *partial validated
Pydantic objects* is currently not supported by openai-python. `.parse()`
with `stream=True` yields raw token deltas — you\'d have to run your own
streaming JSON parser (ijson / json-stream) that can tolerate mid-value
boundaries, then re-validate the accumulated object against the envelope
schema. That gets fragile, particularly on nested arrays of line items or
risk factors.
Instead we ship the honest thing: a progress stream. Each server-sent
event carries a JSON payload with a `stage` and a `message`. The UI shows
"loading document", "calling model", "validating schema", and the final
`result` event carries the full validated ExtractionResult. The user gets
the same UX (something is happening, then the answer arrives) without the
JSON-stream headaches.
If OpenAI ships object-level streaming for structured outputs later, we can
extend this endpoint to emit field-level `partial` events on top of the
progress events — the SSE format is fine with both.
Event schema (SSE)
------------------
event: progress
data: {"stage": "loading", "elapsed_ms": 12}
event: progress
data: {"stage": "model_call", "elapsed_ms": 4820}
event: result
data: {"result": <ExtractionResult>, "metrics": <ExtractionMetrics>}
event: done
data: {}
event: error
data: {"code": "extraction_failed", "message": "..."}
Errors mid-stream come back as an SSE `event: error` rather than an HTTP
error code — the HTTP 200 has already been sent by then. Clients that
speak SSE handle this naturally.
"""
from __future__ import annotations
import asyncio
import json
import time
from collections.abc import AsyncIterator
from pathlib import Path
from fastapi import APIRouter, Depends, File, Form, UploadFile
from fastapi.responses import StreamingResponse
from src.api.deps import (
ALLOWED_EXTENSIONS,
ALLOWED_MIME_TYPES,
MAX_UPLOAD_BYTES,
get_extractor,
)
from src.api.errors import (
EmptyDocument,
FileTooLarge,
UnsupportedDocType,
UnsupportedFileType,
)
from src.extractors.extractor import DocumentExtractor
from src.schemas.registry import get_schema
router = APIRouter(tags=["extract-stream"])
def _sse(event: str, data: dict) -> bytes:
"""Format one SSE frame — event name + JSON data + terminator blank line."""
payload = json.dumps(data, separators=(",", ":"))
return f"event: {event}\ndata: {payload}\n\n".encode()
@router.post("/extract/stream")
async def extract_stream(
file: UploadFile = File(..., description="PDF or image."),
doc_type: str = Form(..., description="invoice|receipt|filing"),
model: str | None = Form(None, description="Optional model override."),
extractor: DocumentExtractor = Depends(get_extractor),
) -> StreamingResponse:
# --- Validate up front so the HTTP status code carries a real error. ---
try:
get_schema(doc_type)
except KeyError as e:
raise UnsupportedDocType(str(e), details={"doc_type": doc_type}) from e
filename = file.filename or "upload"
ext = Path(filename).suffix.lower()
if ext not in ALLOWED_EXTENSIONS:
raise UnsupportedFileType(
f"Extension {ext!r} not supported. Allowed: {sorted(ALLOWED_EXTENSIONS)}",
details={"filename": filename, "extension": ext},
)
if file.content_type and file.content_type not in ALLOWED_MIME_TYPES:
raise UnsupportedFileType(
f"MIME type {file.content_type!r} not accepted for {filename!r}.",
details={"content_type": file.content_type},
)
payload = await file.read()
if not payload:
raise EmptyDocument("Uploaded file is empty.")
if len(payload) > MAX_UPLOAD_BYTES:
raise FileTooLarge(
f"File is {len(payload)} bytes; max allowed is {MAX_UPLOAD_BYTES}.",
details={"size_bytes": len(payload), "max_bytes": MAX_UPLOAD_BYTES},
)
async def event_stream() -> AsyncIterator[bytes]:
t0 = time.perf_counter()
def elapsed_ms() -> int:
return int((time.perf_counter() - t0) * 1000)
# Emit an immediate "starting" so the client sees the connection open.
yield _sse("progress", {"stage": "starting", "elapsed_ms": elapsed_ms(),
"filename": filename, "doc_type": doc_type})
# Loader phase — cheap. Emit before running so the UI can render even
# if loading takes non-trivial time (large multi-page PDFs).
yield _sse("progress", {"stage": "loading", "elapsed_ms": elapsed_ms()})
# Force a tiny sleep so the frame ships before the CPU-bound work starts.
await asyncio.sleep(0)
# Model call phase.
yield _sse("progress", {"stage": "model_call", "elapsed_ms": elapsed_ms()})
try:
# The extractor runs synchronously (openai-python .parse is sync);
# push it off the event loop so we can keep serving SSE frames.
result, metrics = await asyncio.to_thread(
extractor.extract,
payload,
filename=filename,
doc_type=doc_type,
model_override=model,
)
except ValueError as e:
# Loader reports "could not load" for unknown/corrupt formats.
yield _sse("error", {"code": "empty_document", "message": str(e)})
yield _sse("done", {})
return
except Exception as e: # noqa: BLE001
yield _sse("error", {
"code": "extraction_failed",
"message": f"{type(e).__name__}: {e}",
})
yield _sse("done", {})
return
# Emit validation success + full payload.
yield _sse("progress", {"stage": "validated", "elapsed_ms": elapsed_ms()})
yield _sse("result", {
"result": result.model_dump(mode="json"),
"metrics": metrics.to_dict(),
})
yield _sse("done", {"elapsed_ms": elapsed_ms()})
# Note: text/event-stream is the SSE content-type. Cache-Control: no-cache
# is critical — otherwise nginx or a browser cache buffers the whole stream.
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no", # nginx: disable proxy buffering
},
)