diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 62579ce7d..ec4f71ddb 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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: diff --git a/src/app/api/documents/inbound/[id]/extract/route.ts b/src/app/api/documents/inbound/[id]/extract/route.ts index 0eeaca9f8..9ec8e00e5 100644 --- a/src/app/api/documents/inbound/[id]/extract/route.ts +++ b/src/app/api/documents/inbound/[id]/extract/route.ts @@ -30,9 +30,13 @@ * answered here, then the route answers 202 with a run id. The run's result is * `{ documentId, factsStaged, status }` rather than the whole detail (the * facts are staged on the document; the client reads them from there). - * Without the header the route behaves exactly as before; the iPhone app - * relies on that. Both paths run one body (`executeDocumentExtract`), which - * re-checks the document under a row lock before it stages anything. + * Without the header the route answers the document detail as before; the + * iPhone app relies on that. Both paths run one body (`executeDocumentExtract`), + * which re-checks the document under a row lock before it stages anything. + * Inside the request the reads are bounded (`SYNC_EXTRACT_MAX_READS`): a + * twenty-page report read a page at a time with a retry each was forty model + * calls in one HTTP request. What does not fit is reported on the document as + * `extractionIncomplete`, and the background run reads it in full. */ import { Buffer } from "node:buffer"; diff --git a/src/lib/documents/ai-runs/extract-run.ts b/src/lib/documents/ai-runs/extract-run.ts index c3c961814..77a56c785 100644 --- a/src/lib/documents/ai-runs/extract-run.ts +++ b/src/lib/documents/ai-runs/extract-run.ts @@ -267,16 +267,39 @@ function extractionFailure(err: OcrExtractError): AiRunOutcome { } } +/** + * The most reads an extraction makes inside one HTTP request (the extract + * route without `Prefer: respond-async`, which the iPhone app sends). Each + * read is up to `OCR_EXTRACT_MODEL_CALLS` model calls, so this bounds the + * request at six calls where an unplanned read of a twenty-page report made + * forty. A longer document is read in as many reads by sharing its pages or + * text out between them, and what does not fit is reported as + * `extractionIncomplete` on the document; the background run, which has a + * deadline of its own, reads it a page at a time. + */ +export const SYNC_EXTRACT_MAX_READS = 3; + +/** The read plan of a request: a fixed number of calls, nothing to arm. */ +export const SYNCHRONOUS_READ_SCHEDULE: OcrSchedule = { + maxModelCalls: () => SYNC_EXTRACT_MAX_READS * OCR_EXTRACT_MODEL_CALLS, + arm: async () => undefined, +}; + /** Extract and stage facts from one owned document. The caller authorised and charged it. */ export async function executeDocumentExtract(args: { userId: string; document: LoadedDocument; input: DocumentExtractInput; origin: RunOrigin; - /** The run's deadline; absent, the reads are planned without one. */ + /** + * The run's deadline. Absent, the reads are planned for one request + * ({@link SYNCHRONOUS_READ_SCHEDULE}): no extraction plans its reads + * without a bound. + */ schedule?: OcrSchedule; }): Promise> { - const { userId, document, input, origin, schedule } = args; + const { userId, document, input, origin } = args; + const schedule = args.schedule ?? SYNCHRONOUS_READ_SCHEDULE; const { pick, budget } = input; const servedBy = pick.entry.providerType as ProviderChainType; const { meter, reserveRead, settle } = meteredReadBudget( @@ -301,7 +324,7 @@ export async function executeDocumentExtract(args: { } const plan = await planInboundReads(source, schedule); if (!plan.ok) return refused(pdfUnreadable()); - await schedule?.arm(plan.args.chunks.length * OCR_EXTRACT_MODEL_CALLS); + await schedule.arm(plan.args.chunks.length * OCR_EXTRACT_MODEL_CALLS); let result: InboundExtractionResult; try { diff --git a/src/lib/openapi/routes/documents.ts b/src/lib/openapi/routes/documents.ts index 946e80b20..0c02c76f7 100644 --- a/src/lib/openapi/routes/documents.ts +++ b/src/lib/openapi/routes/documents.ts @@ -1233,7 +1233,7 @@ export const inboundDocumentPaths: NonNullable = { tags: ["Documents"], summary: "Extract facts from a stored document", description: - "Optional AI enhancement on an already-stored document. Runs the dedicated OCR/vision provider over the stored original and stages STRUCTURED FACTS for review. Answers under the `documentAi` capability (403 with the capability envelope when it is closed), then rate / budget gated. With no provider configured this returns 422 (`documents.inbound.providerUnsupported`, with `meta.capability` and `meta.reason = \"no_provider\"`) — the stored document is untouched, only the enhancement fails. Three modes: VISION (empty body) decrypts and scans the stored original (a PDF is rendered and read a page at a time; one that cannot be rendered needs a provider that reads PDFs natively, else 422 `documents.inbound.pdfNeedsAnthropic`); TEXT (`application/json`, opt-in local OCR) `{ mode: \"text\", text }` structures browser-OCR'd text; STORED (`application/json`) `{ mode: \"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 `documents.inbound.alreadyPartlyConfirmed` (409) or `documents.inbound.alreadyConfirmed` (422) and leaves the review untouched; the synchronous form answers the same in that race.", + "Optional AI enhancement on an already-stored document. Runs the dedicated OCR/vision provider over the stored original and stages STRUCTURED FACTS for review. Answers under the `documentAi` capability (403 with the capability envelope when it is closed), then rate / budget gated. With no provider configured this returns 422 (`documents.inbound.providerUnsupported`, with `meta.capability` and `meta.reason = \"no_provider\"`) — the stored document is untouched, only the enhancement fails. Three modes: VISION (empty body) decrypts and scans the stored original (a PDF is rendered and read a page at a time; one that cannot be rendered needs a provider that reads PDFs natively, else 422 `documents.inbound.pdfNeedsAnthropic`); TEXT (`application/json`, opt-in local OCR) `{ mode: \"text\", text }` structures browser-OCR'd text; STORED (`application/json`) `{ mode: \"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. 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: [ { name: "id", diff --git a/tests/integration/document-ai-runs.test.ts b/tests/integration/document-ai-runs.test.ts index 2e66347f2..8fd407ad0 100644 --- a/tests/integration/document-ai-runs.test.ts +++ b/tests/integration/document-ai-runs.test.ts @@ -924,3 +924,55 @@ describe("an extraction that did not read the whole document", () => { }); }); }); + +describe("a synchronous extraction stays inside one request", () => { + /** A stored text of many reads' worth: forty lab lines per part. */ + async function longIndexedDocument(userId: string) { + const document = await storeDocument(userId); + const { upsertContentIndex } = + await import("@/lib/documents/content-index"); + const lines = Array.from( + { length: 1200 }, + (_, i) => `Analyte ${i} ${(i % 90) + 10}.0 mg/dL reference 10-100`, + ); + await upsertContentIndex({ + userId, + documentId: document.id, + text: lines.join("\n"), + source: "text-ocr", + providerType: null, + }); + return document; + } + + it("reads a long document in a few reads and says what it left out", async () => { + const user = await makeUser(); + await grantConsent(user.id); + const document = await longIndexedDocument(user.id); + await signIn(user.id); + + const res = await postStoredExtract(document.id, false); + expect(res.status).toBe(200); + const { SYNC_EXTRACT_MAX_READS } = + await import("@/lib/documents/ai-runs/extract-run"); + const args = vi.mocked(runInboundExtraction).mock.calls[0][0]; + expect(args.chunks.length).toBeGreaterThan(0); + expect(args.chunks.length).toBeLessThanOrEqual(SYNC_EXTRACT_MAX_READS); + expect(res.body.data).toMatchObject({ extractionIncomplete: true }); + }); + + it("reads it in full in the background", async () => { + const user = await makeUser(); + await grantConsent(user.id); + const document = await longIndexedDocument(user.id); + await signIn(user.id); + + const queued = await postStoredExtract(document.id, true); + const { runId } = queued.body.data as { runId: string }; + expect(await work(runId)).toBe("succeeded"); + const { SYNC_EXTRACT_MAX_READS } = + await import("@/lib/documents/ai-runs/extract-run"); + const args = vi.mocked(runInboundExtraction).mock.calls[0][0]; + expect(args.chunks.length).toBeGreaterThan(SYNC_EXTRACT_MAX_READS); + }); +});