Merge branch 'fix/bound-sync-lab-reads' into fix/review-meds-docs

This commit is contained in:
Marc-André Bombeck committed 2026-10-25 03:02:57 +01:00
commit 84064ea045
13 files changed
+170 -65

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");
@@ -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);
});
+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,
+2 -2
View File
@@ -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) };
+22 -4
View File
@@ -46,9 +46,27 @@ 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;
export function maxReadsFor(schedule: OcrSchedule): number {
return Math.max(
1,
Math.floor(schedule.maxModelCalls() / OCR_EXTRACT_MODEL_CALLS),
@@ -111,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);
@@ -167,7 +185,7 @@ export async function planVisionReads(
bytes: Buffer,
mime: OcrVisionMime,
pdfSupported: boolean,
schedule: OcrSchedule | undefined,
schedule: OcrSchedule,
): Promise<OcrVisionPlan> {
if (mime !== "application/pdf") {
return {
+85 -10
View File
@@ -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,
+7 -4
View File
@@ -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<AiRunOutcome<OcrExtractResponseDto>> {
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 } }
+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);
});