fix(documents): bound an extraction that runs inside the request

Without Prefer: respond-async the extract route ran the read with no
schedule, so a twenty-page report read a page at a time, with a retry
each, could make forty model calls in one HTTP request. The iPhone app
sends no Prefer header and reads the document detail from the answer,
so the request keeps its 200 and its body.

An extraction without a run deadline now plans at most three reads (six
calls): a longer document has its pages or text shared out between
them, and what does not fit is reported as extractionIncomplete on the
document. The background run keeps reading it a page at a time.

Refs #1257
This commit is contained in:
Marc-André Bombeck committed 2026-10-25 01:01:13 +02:00
1 parent 958d230b9f
commit c9481b672c
5 files changed
+100 -18

No files matched your search

+14 -11
View File
@@ -19767,17 +19767,20 @@ paths:
\"stored\" }` structures the document's own stored extracted text (its content index) — the manual recovery for
a skipped/failed automatic staging run, 422 `documents.inbound.notIndexed` when no stored text exists. A long
document is read a page or a part at a time under the same per-read limits as the lab scan (`LABS_OCR_EXTRACT`),
each read after the first reserved against the day's budget on its own; an extraction in which no read produced
a complete fact fails with 422 `documents.inbound.outputLimit` when the reads stopped at the provider's output
limit, 429 `documents.inbound.budgetExceeded` when the budget refused the reads, and 422
`documents.inbound.extractFailed` only when the provider's answers were not the schema. Extraction reproduces
what the document states — it never interprets. Nothing reaches the structured stores here; the confirm route is
the only write path. With `Prefer: respond-async` (v1.40) the extraction runs in the background: after the same
quick refusals the route answers 202 with a run id, a second request while an extraction of this document from
the same input is queued or running answers with that run, and `GET /api/ai-runs/{id}` serves
`DocumentExtractRunResult` (`{ documentId, factsStaged, status }`, plus `incomplete` when part of the document
may be missing from the staged facts; not the document detail) as the run's `result`. Staging re-checks the
document under a row lock, so a review finished while the read ran fails the run with
each read after the first reserved against the day's budget on its own. Without `Prefer: respond-async` the
extraction runs inside the request and makes at most three reads (six model calls): a longer document has its
pages or text shared out between them, and what does not fit is left out and reported as `extractionIncomplete:
true` on the answered document; the background run reads it a page at a time within its own deadline; an
extraction in which no read produced a complete fact fails with 422 `documents.inbound.outputLimit` when the
reads stopped at the provider's output limit, 429 `documents.inbound.budgetExceeded` when the budget refused the
reads, and 422 `documents.inbound.extractFailed` only when the provider's answers were not the schema.
Extraction reproduces what the document states — it never interprets. Nothing reaches the structured stores
here; the confirm route is the only write path. With `Prefer: respond-async` (v1.40) the extraction runs in the
background: after the same quick refusals the route answers 202 with a run id, a second request while an
extraction of this document from the same input is queued or running answers with that run, and `GET
/api/ai-runs/{id}` serves `DocumentExtractRunResult` (`{ documentId, factsStaged, status }`, plus `incomplete`
when part of the document may be missing from the staged facts; not the document detail) as the run's `result`.
Staging re-checks the document under a row lock, so a review finished while the read ran fails the run with
`documents.inbound.alreadyPartlyConfirmed` (409) or `documents.inbound.alreadyConfirmed` (422) and leaves the
review untouched; the synchronous form answers the same in that race."
parameters: