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}")
|