From 474fc086492f3f2451ac9807a1b79ee5d3a67975 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 25 Oct 2026 02:52:10 +0100 Subject: [PATCH] fix(documents): bound the lab staging an index request continues into The index route without Prefer: respond-async, which the iPhone app calls, continues into the lab staging with no run schedule. The staging then planned a long report's text as up to twenty reads with a retry each: forty model calls inside one HTTP request, the same class the extract route was bounded against. The staging now plans a missing schedule as one request does (SYNCHRONOUS_READ_SCHEDULE, at most three reads): a longer report has its text shared out between them, and what does not fit is left out and reported as extractionIncomplete on the document. The request bound moves into ocr-plan beside the schedule it bounds, so the extract run and the staging share one definition. Background runs and the automatic index job keep planning against their own deadline. --- docs/api/openapi.yaml | 12 ++++++---- .../api/documents/inbound/[id]/index/route.ts | 6 +++-- .../__tests__/auto-stage-labs.test.ts | 23 +++++++++++++++++-- src/lib/documents/ai-runs/extract-run.ts | 19 +-------------- src/lib/documents/ai-runs/index-run.ts | 3 ++- src/lib/documents/auto-stage-labs.ts | 13 +++++++---- src/lib/documents/ocr-plan.ts | 19 +++++++++++++++ src/lib/openapi/routes/documents.ts | 2 +- tests/integration/document-ai-runs.test.ts | 6 ++--- 9 files changed, 65 insertions(+), 38 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index efb542eeb..9255e81e3 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -19980,11 +19980,13 @@ paths: — no plaintext body, no plaintext token. Idempotent; re-indexing overwrites in place. A successful index continues into the same lab auto-staging the background worker performs (still-STORED lab-looking document with no facts, both modules on, provider + consent eligible); `labFactsStaged` reports how many facts were staged - PENDING for review — confirmation stays the only write into Labs. With `Prefer: respond-async` (v1.40) the read - runs in the background: after the same quick refusals the route answers 202 with a run id, a second request - while a read of this document is queued or running answers with that run, and `GET /api/ai-runs/{id}` serves - this body as the run's `result`. Text mode honours the preference too, because the lab staging it continues into - is model work." + PENDING for review — confirmation stays the only write into Labs. Without `Prefer: respond-async` that staging + runs inside the request and makes at most three reads (six model calls), like the extract route: what does not + fit is left out and reported as `extractionIncomplete: true` on the document. With `Prefer: respond-async` + (v1.40) the read runs in the background: after the same quick refusals the route answers 202 with a run id, a + second request while a read of this document is queued or running answers with that run, and `GET + /api/ai-runs/{id}` serves this body as the run's `result`. Text mode honours the preference too, because the lab + staging it continues into is model work." parameters: - name: id in: path diff --git a/src/app/api/documents/inbound/[id]/index/route.ts b/src/app/api/documents/inbound/[id]/index/route.ts index 514c266ba..aa0b5318a 100644 --- a/src/app/api/documents/inbound/[id]/index/route.ts +++ b/src/app/api/documents/inbound/[id]/index/route.ts @@ -24,8 +24,10 @@ * worker: every refusal that can be answered quickly is still answered here, * then the route answers 202 with a run id and `GET /api/ai-runs/{id}` serves * the same body this route answers with synchronously. Without the header the - * route behaves exactly as before; the iPhone app relies on that. Both paths - * run one body (`executeDocumentIndex`). + * route answers the same body as before; the iPhone app relies on that. Both + * paths run one body (`executeDocumentIndex`). Inside the request the lab + * staging is bounded like the extract route (`SYNC_EXTRACT_MAX_READS`): what + * does not fit is reported on the document as `extractionIncomplete`. */ import { Buffer } from "node:buffer"; diff --git a/src/lib/documents/__tests__/auto-stage-labs.test.ts b/src/lib/documents/__tests__/auto-stage-labs.test.ts index 42f2778fa..adf98befa 100644 --- a/src/lib/documents/__tests__/auto-stage-labs.test.ts +++ b/src/lib/documents/__tests__/auto-stage-labs.test.ts @@ -63,6 +63,7 @@ import { resolveIndexProvider } from "@/lib/documents/index-document"; import { loadDocumentChatText } from "@/lib/documents/content-index"; import { runInboundExtraction } from "@/lib/documents/extract"; import { reserveBudget } from "@/lib/ai/coach/budget"; +import { SYNC_EXTRACT_MAX_READS } from "@/lib/documents/ocr-plan"; const mockModule = vi.mocked(isModuleEnabled); const mockProvider = vi.mocked(resolveIndexProvider); @@ -195,8 +196,8 @@ describe("maybeAutoStageLabFacts", () => { mockText.mockResolvedValue({ text: longReport, source: "verbatim" }); await maybeAutoStageLabFacts("u1", "d1"); - const unbounded = mockExtract.mock.calls[0]![0].chunks.length; - expect(unbounded).toBeGreaterThan(1); + const inRequest = mockExtract.mock.calls[0]![0].chunks.length; + expect(inRequest).toBeGreaterThan(1); const arm = vi.fn(async () => {}); await maybeAutoStageLabFacts("u1", "d1", { @@ -207,6 +208,24 @@ describe("maybeAutoStageLabFacts", () => { expect(arm).toHaveBeenCalledWith(2); }); + it("stays inside one request without a run deadline, and says what it left out", async () => { + // The index route without Prefer: respond-async continues into this + // staging with no schedule. Twelve hundred lines is many reads of text. + const longReport = Array.from( + { length: 1200 }, + (_, i) => `Hämoglobin ${i} g/dl Referenzbereich 13-17 LDL 120 mg/dl`, + ).join("\n"); + mockText.mockResolvedValue({ text: longReport, source: "verbatim" }); + + await maybeAutoStageLabFacts("u1", "d1"); + const reads = mockExtract.mock.calls[0]![0].chunks.length; + expect(reads).toBeGreaterThan(0); + expect(reads).toBeLessThanOrEqual(SYNC_EXTRACT_MAX_READS); + expect(updateMany.mock.calls[0]![0]).toMatchObject({ + data: { extractionIncomplete: true }, + }); + }); + it("skips (manual path only) when a module is off", async () => { mockModule.mockImplementation(async (_u, key) => key !== "labs"); const res = await maybeAutoStageLabFacts("u1", "d1"); diff --git a/src/lib/documents/ai-runs/extract-run.ts b/src/lib/documents/ai-runs/extract-run.ts index 77a56c785..c968e0d54 100644 --- a/src/lib/documents/ai-runs/extract-run.ts +++ b/src/lib/documents/ai-runs/extract-run.ts @@ -36,6 +36,7 @@ import { } from "@/lib/documents/extract"; import { OCR_EXTRACT_MODEL_CALLS, + SYNCHRONOUS_READ_SCHEDULE, type OcrSchedule, } from "@/lib/documents/ocr-plan"; import { OcrExtractError } from "@/lib/documents/ocr-read"; @@ -267,24 +268,6 @@ 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; diff --git a/src/lib/documents/ai-runs/index-run.ts b/src/lib/documents/ai-runs/index-run.ts index 99f67b1fa..64cf2251c 100644 --- a/src/lib/documents/ai-runs/index-run.ts +++ b/src/lib/documents/ai-runs/index-run.ts @@ -59,7 +59,8 @@ export interface IndexOrigin { worker: boolean; /** * A background run's deadline, which the lab staging after the index plans - * its reads against. A request has none: it reads as before. + * its reads against. A request has none: the staging then makes at most + * `SYNC_EXTRACT_MAX_READS` reads and reports what it left out. */ schedule?: OcrSchedule; } diff --git a/src/lib/documents/auto-stage-labs.ts b/src/lib/documents/auto-stage-labs.ts index dfa361350..7fb63eede 100644 --- a/src/lib/documents/auto-stage-labs.ts +++ b/src/lib/documents/auto-stage-labs.ts @@ -43,6 +43,7 @@ import { } from "@/lib/documents/extract"; import { OCR_EXTRACT_MODEL_CALLS, + SYNCHRONOUS_READ_SCHEDULE, type OcrSchedule, } from "@/lib/documents/ocr-plan"; import { OcrExtractError } from "@/lib/documents/ocr-read"; @@ -261,7 +262,10 @@ async function stageFactsIfStored( * `schedule` is the run's deadline when a job reads (the auto-index job, the * document run): the report's text is then split into as many reads as fit * before it, instead of up to `OCR_MAX_PAGES` reads of two calls each that a - * slow model could stretch past the job's expiry (#1274). + * slow model could stretch past the job's expiry (#1274). Absent, the staging + * runs inside a request (the index route without `Prefer: respond-async`) + * and is planned as one ({@link SYNCHRONOUS_READ_SCHEDULE}); what does not + * fit is reported as `extractionIncomplete` on the document. */ export async function maybeAutoStageLabFacts( userId: string, @@ -333,14 +337,13 @@ export async function maybeAutoStageLabFacts( { cap, surface: "job" }, ); - const plan = planInboundTextReads(chat.text, options.schedule); + const schedule = options.schedule ?? SYNCHRONOUS_READ_SCHEDULE; + const plan = planInboundTextReads(chat.text, schedule); let result: InboundExtractionResult; try { // A run that cannot fit even one read before its deadline refuses here; // the reservation is settled below like any other failed read. - await options.schedule?.arm( - plan.args.chunks.length * OCR_EXTRACT_MODEL_CALLS, - ); + await schedule.arm(plan.args.chunks.length * OCR_EXTRACT_MODEL_CALLS); result = await runInboundExtraction({ userId, provider: provider.pick.entry.instance, diff --git a/src/lib/documents/ocr-plan.ts b/src/lib/documents/ocr-plan.ts index 9c50caecd..3fec56366 100644 --- a/src/lib/documents/ocr-plan.ts +++ b/src/lib/documents/ocr-plan.ts @@ -46,6 +46,25 @@ export interface OcrSchedule { arm(modelCalls: number): Promise; } +/** + * The most reads one HTTP request makes: the extract route, and the lab + * staging the index route continues into, both without `Prefer: + * respond-async` (the iPhone app sends neither). 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; a 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, +}; + /** How many reads the deadline leaves room for. */ export function maxReadsFor(schedule: OcrSchedule | undefined): number { if (!schedule) return Number.POSITIVE_INFINITY; diff --git a/src/lib/openapi/routes/documents.ts b/src/lib/openapi/routes/documents.ts index 0c02c76f7..807c7e7f1 100644 --- a/src/lib/openapi/routes/documents.ts +++ b/src/lib/openapi/routes/documents.ts @@ -1399,7 +1399,7 @@ export const inboundDocumentPaths: NonNullable = { tags: ["Documents"], summary: "Build / refresh the content-search index", description: - "Populates or refreshes one document's content-search index so search matches INSIDE its body. VISION (empty body) decrypts the stored original and runs one provider transcription (answers under the `documentAi` capability — 403 with the capability envelope when it is closed, including a missing `ai_extraction` / `ai_full` receipt for a provider that leaves the machine — then rate / budget gated); TEXT (`application/json` `{ mode: \"text\", text }`, opt-in local OCR) indexes browser-OCR'd text with no provider egress and stays available with AI off, so search keeps working. Persists ONLY AES-256-GCM ciphertext of the text plus opaque HMAC token hashes — no plaintext body, no plaintext token. Idempotent; re-indexing overwrites in place. A successful index continues into the same lab auto-staging the background worker performs (still-STORED lab-looking document with no facts, both modules on, provider + consent eligible); `labFactsStaged` reports how many facts were staged PENDING for review — confirmation stays the only write into Labs. With `Prefer: respond-async` (v1.40) the read runs in the background: after the same quick refusals the route answers 202 with a run id, a second request while a read of this document is queued or running answers with that run, and `GET /api/ai-runs/{id}` serves this body as the run's `result`. Text mode honours the preference too, because the lab staging it continues into is model work.", + "Populates or refreshes one document's content-search index so search matches INSIDE its body. VISION (empty body) decrypts the stored original and runs one provider transcription (answers under the `documentAi` capability — 403 with the capability envelope when it is closed, including a missing `ai_extraction` / `ai_full` receipt for a provider that leaves the machine — then rate / budget gated); TEXT (`application/json` `{ mode: \"text\", text }`, opt-in local OCR) indexes browser-OCR'd text with no provider egress and stays available with AI off, so search keeps working. Persists ONLY AES-256-GCM ciphertext of the text plus opaque HMAC token hashes — no plaintext body, no plaintext token. Idempotent; re-indexing overwrites in place. A successful index continues into the same lab auto-staging the background worker performs (still-STORED lab-looking document with no facts, both modules on, provider + consent eligible); `labFactsStaged` reports how many facts were staged PENDING for review — confirmation stays the only write into Labs. Without `Prefer: respond-async` that staging runs inside the request and makes at most three reads (six model calls), like the extract route: what does not fit is left out and reported as `extractionIncomplete: true` on the document. With `Prefer: respond-async` (v1.40) the read runs in the background: after the same quick refusals the route answers 202 with a run id, a second request while a read of this document is queued or running answers with that run, and `GET /api/ai-runs/{id}` serves this body as the run's `result`. Text mode honours the preference too, because the lab staging it continues into is model work.", parameters: [ { name: "id", in: "path", required: true, schema: { type: "string" } }, preferRespondAsyncParameter, diff --git a/tests/integration/document-ai-runs.test.ts b/tests/integration/document-ai-runs.test.ts index 8fd407ad0..68a3a922c 100644 --- a/tests/integration/document-ai-runs.test.ts +++ b/tests/integration/document-ai-runs.test.ts @@ -953,8 +953,7 @@ describe("a synchronous extraction stays inside one request", () => { 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 { SYNC_EXTRACT_MAX_READS } = await import("@/lib/documents/ocr-plan"); const args = vi.mocked(runInboundExtraction).mock.calls[0][0]; expect(args.chunks.length).toBeGreaterThan(0); expect(args.chunks.length).toBeLessThanOrEqual(SYNC_EXTRACT_MAX_READS); @@ -970,8 +969,7 @@ describe("a synchronous extraction stays inside one request", () => { 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 { SYNC_EXTRACT_MAX_READS } = await import("@/lib/documents/ocr-plan"); const args = vi.mocked(runInboundExtraction).mock.calls[0][0]; expect(args.chunks.length).toBeGreaterThan(SYNC_EXTRACT_MAX_READS); });