File size: 6,777 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
176
177
178
179
"""POST /extract/batch, GET /extract/batch/{job_id} β€” async batch extraction.

Use case
--------
An enterprise integration that wants to submit 100 invoices at once. Instead
of 100 sequential POST /extract calls (~10 minutes wall time), the caller
submits one batch request, gets a job_id, and polls for results while the
server processes them with bounded concurrency.

Semantics
---------
- POST /extract/batch is multipart with N files + doc_type + optional model.
  Returns 202 Accepted, {job_id, status: "pending"} β€” nothing has run yet.
  Actual extraction happens in a background task.
- GET /extract/batch/{job_id} returns the current snapshot: overall status,
  per-item status, and per-item results/errors as they land. Poll every 1-3s.
- Concurrency is capped globally at BATCH_CONCURRENCY (5) via an asyncio
  semaphore β€” sized to stay well under OpenAI rate limits even if multiple
  jobs run simultaneously.

Not implemented (deliberate)
----------------------------
- No persistent storage (Redis/DB). Jobs live in-process; restart wipes them.
- No auth, no per-user quotas.
- No webhook completion callbacks (the caller polls). Streaming completion
  updates would be the natural next step β€” but SSE for job progress is
  covered by the /extract/stream endpoint for the single-doc case.
"""
from __future__ import annotations

import asyncio
from pathlib import Path

from fastapi import APIRouter, BackgroundTasks, Depends, File, Form, UploadFile
from fastapi.responses import JSONResponse

from src.api.batch_store import JobStore, get_job_store
from src.api.deps import (
    ALLOWED_EXTENSIONS,
    ALLOWED_MIME_TYPES,
    MAX_UPLOAD_BYTES,
    get_extractor,
)
from src.api.errors import (
    FileTooLarge,
    UnsupportedDocType,
    UnsupportedFileType,
)
from src.extractors.extractor import DocumentExtractor
from src.schemas.registry import get_schema
from src.utils.logging import logger

router = APIRouter(prefix="/extract", tags=["extract-batch"])


# --- POST /extract/batch --------------------------------------------------

@router.post("/batch", status_code=202)
async def create_batch(
    background: BackgroundTasks,
    files:    list[UploadFile] = File(..., description="List of documents to extract."),
    doc_type: str    = Form(..., description="Registered doc type (invoice|receipt|filing)."),
    model:    str | None = Form(None, description="Optional model override."),
    extractor: DocumentExtractor = Depends(get_extractor),
    store:     JobStore = Depends(get_job_store),
) -> JSONResponse:
    # Reject unknown doc types up front β€” one 400 for the whole batch.
    try:
        get_schema(doc_type)
    except KeyError as e:
        raise UnsupportedDocType(str(e), details={"doc_type": doc_type}) from e

    if not files:
        # Return an empty done job rather than 400 β€” the semantics are clear.
        job = await store.create_job([], doc_type=doc_type, model=model)
        return JSONResponse(
            status_code=202,
            content={"job_id": job.id, "status": "done", "progress": job.progress},
        )

    # Read + validate each file *before* enqueueing so a bad file fails fast.
    items: list[tuple[str, bytes]] = []
    for uf in files:
        fn  = uf.filename or "upload"
        ext = Path(fn).suffix.lower()
        if ext not in ALLOWED_EXTENSIONS:
            raise UnsupportedFileType(
                f"Extension {ext!r} is not supported for {fn!r}.",
                details={"filename": fn, "extension": ext},
            )
        if uf.content_type and uf.content_type not in ALLOWED_MIME_TYPES:
            raise UnsupportedFileType(
                f"MIME type {uf.content_type!r} not accepted for {fn!r}.",
                details={"content_type": uf.content_type, "filename": fn},
            )
        payload = await uf.read()
        if len(payload) > MAX_UPLOAD_BYTES:
            raise FileTooLarge(
                f"{fn!r} is {len(payload)} bytes; max is {MAX_UPLOAD_BYTES}.",
                details={"filename": fn, "size_bytes": len(payload)},
            )
        items.append((fn, payload))

    job = await store.create_job(items, doc_type=doc_type, model=model)
    logger.info(f"[batch] created job {job.id} with {len(items)} items ({doc_type}, model={model})")

    # Fan out into background tasks. FastAPI runs BackgroundTasks after the
    # response is returned β€” so the caller gets an immediate 202.
    for i, (fn, payload) in enumerate(items):
        background.add_task(
            _run_item, store, extractor, job.id, i, fn, payload, doc_type, model
        )

    return JSONResponse(
        status_code=202,
        content={
            "job_id":   job.id,
            "status":   job.status,
            "progress": job.progress,
        },
    )


# --- GET /extract/batch/{job_id} -----------------------------------------

@router.get("/batch/{job_id}")
async def get_batch(
    job_id: str,
    store:  JobStore = Depends(get_job_store),
) -> dict:
    job = await store.get(job_id)
    if job is None:
        # 404 as a plain JSON envelope β€” this happens after a restart wipes memory.
        return JSONResponse(
            status_code=404,
            content={"error": {"code": "job_not_found", "message": f"No job with id {job_id!r}."}},
        )
    return job.snapshot()


# --- Worker --------------------------------------------------------------

async def _run_item(
    store: JobStore,
    extractor: DocumentExtractor,
    job_id: str,
    index: int,
    filename: str,
    payload: bytes,
    doc_type: str,
    model: str | None,
) -> None:
    """Run a single item under the global concurrency semaphore.

    The semaphore caps ALL concurrent extractions across every in-flight job.
    OpenAI rate limits are per-org, so job-scoped limits wouldn\'t protect us.
    """
    async with store.semaphore:
        await store.mark_running(job_id, index)
        try:
            # DocumentExtractor.extract() is sync + CPU/IO-bound (openai SDK is
            # synchronous under .parse). Push it to a thread so the event loop
            # keeps servicing status polls + other batches.
            result, metrics = await asyncio.to_thread(
                extractor.extract,
                payload,
                filename=filename,
                doc_type=doc_type,
                model_override=model,
            )
            await store.set_result(
                job_id, index,
                result=result.model_dump(mode="json"),
                metrics=metrics.to_dict(),
            )
        except Exception as e:  # noqa: BLE001
            logger.warning(f"[batch] job={job_id} item={index} failed: {e}")
            await store.set_error(job_id, index, f"{type(e).__name__}: {e}")