| """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"]) |
|
|
|
|
| |
|
|
| @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: |
| |
| try: |
| get_schema(doc_type) |
| except KeyError as e: |
| raise UnsupportedDocType(str(e), details={"doc_type": doc_type}) from e |
|
|
| if not files: |
| |
| 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}, |
| ) |
|
|
| |
| 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})") |
|
|
| |
| |
| 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, |
| }, |
| ) |
|
|
|
|
| |
|
|
| @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: |
| |
| return JSONResponse( |
| status_code=404, |
| content={"error": {"code": "job_not_found", "message": f"No job with id {job_id!r}."}}, |
| ) |
| return job.snapshot() |
|
|
|
|
| |
|
|
| 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: |
| |
| |
| |
| 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: |
| logger.warning(f"[batch] job={job_id} item={index} failed: {e}") |
| await store.set_error(job_id, index, f"{type(e).__name__}: {e}") |
|
|