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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-25 02:52:10 +01:00
1 parent 2b27b4b501
commit 474fc08649
9 files changed
+65 -38

No files matched your search

+7 -5
View File
@@ -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
@@ -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";
@@ -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");
+1 -18
View File
@@ -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<never> {
}
}
/**
* 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;
+2 -1
View File
@@ -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;
}
+8 -5
View File
@@ -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,
+19
View File
@@ -46,6 +46,25 @@ export interface OcrSchedule {
arm(modelCalls: number): Promise<void>;
}
/**
* 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;
+1 -1
View File
@@ -1399,7 +1399,7 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
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,
+2 -4
View File
@@ -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);
});