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 1/2] 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); }); From fd299cad61e102f0da8de7f473c63ccde2170051 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 25 Oct 2026 02:58:35 +0100 Subject: [PATCH 2/2] fix(labs): plan every report read against a schedule The lab scan runs only in the background worker, which hands it the run's deadline, but its body still took the schedule as optional and planned a missing one without a bound: a twenty-page report as forty model calls. Nothing calls it that way today; the signature allowed the next caller to, and so did every read planner beneath it. The scan and the planners (maxReadsFor, planTextReads, planVisionReads, planInboundTextReads, planInboundReads) now require a schedule. A run with room for every read says so with an unbounded one; a request uses SYNCHRONOUS_READ_SCHEDULE. No route, wire or response changes. --- .../documents/__tests__/extract-reads.test.ts | 15 +-- src/lib/documents/extract.ts | 4 +- src/lib/documents/ocr-plan.ts | 7 +- src/lib/labs/__tests__/ocr-run.test.ts | 95 +++++++++++++++++-- src/lib/labs/ocr-run.ts | 11 ++- 5 files changed, 105 insertions(+), 27 deletions(-) diff --git a/src/lib/documents/__tests__/extract-reads.test.ts b/src/lib/documents/__tests__/extract-reads.test.ts index 57fa54549..568f1eba1 100644 --- a/src/lib/documents/__tests__/extract-reads.test.ts +++ b/src/lib/documents/__tests__/extract-reads.test.ts @@ -201,16 +201,21 @@ describe("planInboundTextReads", () => { const line = (i: number) => `Analyt ${i} ${i},0 mg/dl 1 - 9`; const text = (lines: number) => Array.from({ length: lines }, (_, i) => line(i)).join("\n"); + const schedule = (calls: number) => ({ + maxModelCalls: () => calls, + arm: async () => undefined, + }); + const noDeadline = schedule(Number.POSITIVE_INFINITY); it("reads a short text as one read", () => { - const plan = planInboundTextReads(text(10), undefined); + const plan = planInboundTextReads(text(10), noDeadline); expect(plan.args.chunks).toHaveLength(1); expect(plan.partial).toBe(false); }); it("splits a long text at line boundaries into reads no longer than one read carries", () => { const whole = text(400); - const plan = planInboundTextReads(whole, undefined); + const plan = planInboundTextReads(whole, noDeadline); expect(plan.args.chunks.length).toBeGreaterThan(1); for (const chunk of plan.args.chunks) { expect(chunk.text!.length).toBeLessThanOrEqual(INBOUND_TEXT_READ_CHARS); @@ -221,10 +226,6 @@ describe("planInboundTextReads", () => { }); it("grows the parts when the deadline allows few reads, and reads from the start past twice the size", () => { - const schedule = (calls: number) => ({ - maxModelCalls: () => calls, - arm: async () => undefined, - }); // Two reads' worth of calls: the parts grow to hold the text. const grown = planInboundTextReads(text(200), schedule(4)); expect(grown.args.chunks).toHaveLength(2); @@ -239,7 +240,7 @@ describe("planInboundTextReads", () => { }); it("caps the reads of a run with no deadline", () => { - const plan = planInboundTextReads(text(4000), undefined); + const plan = planInboundTextReads(text(4000), noDeadline); expect(plan.args.chunks.length).toBe(OCR_MAX_PAGES); expect(plan.partial).toBe(true); }); diff --git a/src/lib/documents/extract.ts b/src/lib/documents/extract.ts index d48803484..361faa61a 100644 --- a/src/lib/documents/extract.ts +++ b/src/lib/documents/extract.ts @@ -462,7 +462,7 @@ export interface InboundReads { /** A document's text in parts at line boundaries, as many as the run's deadline allows. */ export function planInboundTextReads( text: string, - schedule: OcrSchedule | undefined, + schedule: OcrSchedule, ): InboundReads { const { chunks, unreadParts } = planTextReads( text, @@ -483,7 +483,7 @@ export function planInboundTextReads( */ export async function planInboundReads( source: InboundReadSource, - schedule: OcrSchedule | undefined, + schedule: OcrSchedule, ): Promise<({ ok: true } & InboundReads) | { ok: false }> { if (source.mode === "text") { return { ok: true, ...planInboundTextReads(source.text, schedule) }; diff --git a/src/lib/documents/ocr-plan.ts b/src/lib/documents/ocr-plan.ts index 3fec56366..f46242133 100644 --- a/src/lib/documents/ocr-plan.ts +++ b/src/lib/documents/ocr-plan.ts @@ -66,8 +66,7 @@ export const SYNCHRONOUS_READ_SCHEDULE: OcrSchedule = { }; /** How many reads the deadline leaves room for. */ -export function maxReadsFor(schedule: OcrSchedule | undefined): number { - if (!schedule) return Number.POSITIVE_INFINITY; +export function maxReadsFor(schedule: OcrSchedule): number { return Math.max( 1, Math.floor(schedule.maxModelCalls() / OCR_EXTRACT_MODEL_CALLS), @@ -130,7 +129,7 @@ export function splitOcrText( */ export function planTextReads( text: string, - schedule: OcrSchedule | undefined, + schedule: OcrSchedule, target: number, ): { chunks: OcrChunk[]; unreadParts: number } { const maxReads = Math.min(maxReadsFor(schedule), OCR_MAX_PAGES); @@ -186,7 +185,7 @@ export async function planVisionReads( bytes: Buffer, mime: OcrVisionMime, pdfSupported: boolean, - schedule: OcrSchedule | undefined, + schedule: OcrSchedule, ): Promise { if (mime !== "application/pdf") { return { diff --git a/src/lib/labs/__tests__/ocr-run.test.ts b/src/lib/labs/__tests__/ocr-run.test.ts index f26d226e7..2ca533791 100644 --- a/src/lib/labs/__tests__/ocr-run.test.ts +++ b/src/lib/labs/__tests__/ocr-run.test.ts @@ -94,6 +94,8 @@ const schedule = (maxModelCalls: number) => ({ maxModelCalls: () => maxModelCalls, arm: vi.fn().mockResolvedValue(undefined), }); +/** A run whose deadline leaves room for every read. */ +const noDeadline = () => schedule(Number.POSITIVE_INFINITY); beforeEach(() => { vi.clearAllMocks(); @@ -156,6 +158,18 @@ describe("planning the reads", () => { }); }); +describe("a scan is always planned against a schedule", () => { + it("cannot be started without one", () => { + // The scan only runs in the worker, which hands it the run's deadline. + // A caller with no schedule would read a twenty-page report as forty + // model calls, so the signature leaves no such call to write. + const start = () => + // @ts-expect-error -- `schedule` is required + executeOcrExtraction({ userId: "u1", input: text(), budget }); + expect(typeof start).toBe("function"); + }); +}); + describe("text mode", () => { it("charges what the provider reported to the provider that served", async () => { vi.mocked(runOcrExtraction).mockImplementation(extraction(2345)); @@ -163,6 +177,7 @@ describe("text mode", () => { userId: "u1", input: text(), budget, + schedule: noDeadline(), }); expect(out).toEqual({ ok: true, data: { rows: [] } }); expect(reconcileSpend).toHaveBeenCalledWith( @@ -177,7 +192,12 @@ describe("text mode", () => { it("charges the reservation when the provider reported nothing", async () => { vi.mocked(runOcrExtraction).mockImplementation(extraction(0)); - await executeOcrExtraction({ userId: "u1", input: text(), budget }); + await executeOcrExtraction({ + userId: "u1", + input: text(), + budget, + schedule: noDeadline(), + }); expect(reconcileSpend).toHaveBeenCalledWith( "u1", 1500, @@ -210,7 +230,12 @@ describe("text mode", () => { it("passes the cached share of what was spent to the ledger", async () => { vi.mocked(runOcrExtraction).mockImplementation(extraction(3000, 1200)); - await executeOcrExtraction({ userId: "u1", input: text(), budget }); + await executeOcrExtraction({ + userId: "u1", + input: text(), + budget, + schedule: noDeadline(), + }); expect(reconcileSpend).toHaveBeenCalledWith( "u1", 1500, @@ -231,7 +256,12 @@ describe("text mode", () => { expect(await args.reserveRead!()).toBe(true); return extraction(20_000)(args); }); - await executeOcrExtraction({ userId: "u1", input: text(), budget }); + await executeOcrExtraction({ + userId: "u1", + input: text(), + budget, + schedule: noDeadline(), + }); expect(reserveBudget).toHaveBeenCalledWith( "u1", 8000, @@ -265,6 +295,7 @@ describe("text mode", () => { userId: "u1", input: text(), budget: { ...budget, owner: "user" }, + schedule: noDeadline(), }); expect(vi.mocked(reserveBudget).mock.calls[0][3]).toBe(2_000_000); expect(vi.mocked(reserveBudget).mock.calls[0][4]).toBe("user"); @@ -293,7 +324,12 @@ describe("text mode", () => { answer = await args.reserveRead!(); return { data: { rows: [] } as never, spentTokens: 0, cachedTokens: 0 }; }); - await executeOcrExtraction({ userId: "u1", input: text(), budget }); + await executeOcrExtraction({ + userId: "u1", + input: text(), + budget, + schedule: noDeadline(), + }); // 18 500 caught up first (allowed, the day stood at 190 000); the next // read is then judged at 208 500 and refused. Booking only the 8 000 // ceiling, or both in one reservation, would have let it through. @@ -322,7 +358,12 @@ describe("text mode", () => { await args.reserveRead!(); return { data: { rows: [] } as never, spentTokens: 0, cachedTokens: 0 }; }); - await executeOcrExtraction({ userId: "u1", input: text(), budget }); + await executeOcrExtraction({ + userId: "u1", + input: text(), + budget, + schedule: noDeadline(), + }); // 20 000 spent, 4 000 of it cached: 16 000 net against 1 500 reserved. expect(vi.mocked(reserveBudget).mock.calls[0][1]).toBe(14_500); }); @@ -337,6 +378,7 @@ describe("text mode", () => { userId: "u1", input: text(), budget: { ...budget, owner: undefined as never }, + schedule: noDeadline(), }); expect(answer).toBe(false); expect(reserveBudget).not.toHaveBeenCalled(); @@ -347,7 +389,12 @@ describe("text mode", () => { new OcrExtractError("x", "budget"), ); expect( - await executeOcrExtraction({ userId: "u1", input: text(), budget }), + await executeOcrExtraction({ + userId: "u1", + input: text(), + budget, + schedule: noDeadline(), + }), ).toMatchObject({ status: 429, errorCode: "labs.ocr.budgetExceeded" }); vi.mocked(runOcrExtraction).mockRejectedValueOnce( @@ -358,14 +405,24 @@ describe("text mode", () => { ), ); expect( - await executeOcrExtraction({ userId: "u1", input: text(), budget }), + await executeOcrExtraction({ + userId: "u1", + input: text(), + budget, + schedule: noDeadline(), + }), ).toMatchObject({ ok: false, errorCode: "consent.ai.required" }); vi.mocked(runOcrExtraction).mockRejectedValueOnce( new OcrExtractError("x", "error"), ); expect( - await executeOcrExtraction({ userId: "u1", input: text(), budget }), + await executeOcrExtraction({ + userId: "u1", + input: text(), + budget, + schedule: noDeadline(), + }), ).toMatchObject({ status: 500, errorCode: "aiRuns.failed" }); }); @@ -379,7 +436,12 @@ describe("text mode", () => { answer = await args.reserveRead!(); return extraction(4000)(args); }); - await executeOcrExtraction({ userId: "u1", input: text(), budget }); + await executeOcrExtraction({ + userId: "u1", + input: text(), + budget, + schedule: noDeadline(), + }); expect(answer).toBe(false); // The refused reservation was handed back by reserveBudget itself; the // run settles against the first read's alone. @@ -399,6 +461,7 @@ describe("text mode", () => { userId: "u1", input: text("x".repeat(OCR_TEXT_READ_MAX_CHARS + 1)), budget, + schedule: noDeadline(), }); expect(out).toMatchObject({ ok: false, @@ -421,7 +484,12 @@ describe("text mode", () => { args.meter!.spent += 900; throw new OcrExtractError("x"); }); - await executeOcrExtraction({ userId: "u1", input: text(), budget }); + await executeOcrExtraction({ + userId: "u1", + input: text(), + budget, + schedule: noDeadline(), + }); expect(reconcileSpend).toHaveBeenCalledWith( "u1", 1500, @@ -438,6 +506,7 @@ describe("text mode", () => { userId: "u1", input: text(), budget, + schedule: noDeadline(), }); expect(out).toMatchObject({ ok: false, @@ -462,6 +531,7 @@ describe("text mode", () => { userId: "u1", input: text(), budget, + schedule: noDeadline(), }); expect(out).toMatchObject({ ok: false, @@ -482,6 +552,7 @@ describe("text mode", () => { userId: "u1", input: text(), budget, + schedule: noDeadline(), }); expect(out).toMatchObject({ ok: false, status: 502 }); expect(JSON.stringify(out)).toContain("configured AI provider"); @@ -563,6 +634,7 @@ describe("vision mode", () => { userId: "u1", input: pdf(anthropicPick), budget, + schedule: noDeadline(), }); const args = vi.mocked(runOcrExtraction).mock.calls[0][0]; expect(args.chunks.map((c) => c.pages)).toEqual([[1], [2], [3], [4]]); @@ -585,6 +657,7 @@ describe("vision mode", () => { userId: "u1", input: pdf(anthropicPick), budget, + schedule: noDeadline(), }); const args = vi.mocked(runOcrExtraction).mock.calls[i][0]; expect(args.chunks).toHaveLength(1); @@ -627,6 +700,7 @@ describe("vision mode", () => { userId: "u1", input: pdf(), budget, + schedule: noDeadline(), }); expect(out).toEqual({ ok: true, @@ -643,6 +717,7 @@ describe("vision mode", () => { userId: "u1", input: pdf(), budget, + schedule: noDeadline(), }); expect(out).toMatchObject({ ok: false, diff --git a/src/lib/labs/ocr-run.ts b/src/lib/labs/ocr-run.ts index 629151cc3..75b668be5 100644 --- a/src/lib/labs/ocr-run.ts +++ b/src/lib/labs/ocr-run.ts @@ -137,8 +137,11 @@ export async function executeOcrExtraction(args: { userId: string; input: OcrRunInput; budget: AiRunBudget | undefined; - /** The run's deadline; absent, the read is planned without one. */ - schedule?: OcrSchedule; + /** + * The run's deadline. Required: the scan only runs in the background + * worker, and a read planned without a bound could make forty model calls. + */ + schedule: OcrSchedule; }): Promise> { const { userId, input, budget, schedule } = args; const servedBy = input.pick.entry.providerType as ProviderChainType; @@ -205,7 +208,7 @@ export async function executeOcrExtraction(args: { errorCode: "labs.ocr.tooLarge", }); } - await schedule?.arm(parts.length * OCR_EXTRACT_MODEL_CALLS); + await schedule.arm(parts.length * OCR_EXTRACT_MODEL_CALLS); return read({ mode: "text", chunks: parts.map((text) => ({ text, pages: [] })), @@ -238,7 +241,7 @@ export async function executeOcrExtraction(args: { }); } - await schedule?.arm(chunks.length * OCR_EXTRACT_MODEL_CALLS); + await schedule.arm(chunks.length * OCR_EXTRACT_MODEL_CALLS); const outcome = await read({ mode: "vision", chunks, totalPages }); return outcome.ok && pageCoverage ? { ok: true, data: { ...outcome.data, pageCoverage } }