File size: 6,686 Bytes
0962ea2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""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
        },
    )