fix(documents): wait as long as the server may on document reads and lab OCR

The browser gave up on its own: 120 s for the document routes, 90 s for
a lab scan, and the 15 s default on the lab text path, while a slow
self-hosted model with a raised response timeout was still allowed to
answer. A five-page report read by a local model failed on the client
side with the server still working.

Each request now sizes its abort from ai.provider.responseTimeoutMs on
the account payload, times the model calls the route can make, plus
30 s: one call for suggest and summary, three for Read with AI (the
transcription, then the lab staging read and its corrective retry), two
for a lab scan (the read and its retry). With the setting unset that is
90 s, 210 s and 150 s.

Refs #1090
This commit is contained in:
Marc-André Bombeck committed 2026-10-02 23:01:41 +02:00
1 parent 5ee1b3e28f
commit bf7fb9c401
5 files changed
+169 -22

No files matched your search

@@ -0,0 +1,94 @@
/**
* The browser waits as long as the server may.
*
* Document reads and lab OCR used to abort on fixed windows (120 s, 90 s, and
* the 15 s default on the lab text path) while the server, for a slow local
* model, was allowed far longer. The windows now derive from
* `ai.provider.responseTimeoutMs` on the account payload: the per-call value
* times the model calls one request can make, plus a 30 s margin.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const apiPost = vi.fn();
const apiFetch = vi.fn();
vi.mock("@/lib/api/api-fetch", () => ({
apiPost: (...a: unknown[]) => apiPost(...a),
apiFetch: (...a: unknown[]) => apiFetch(...a),
apiFetchRaw: vi.fn(),
apiGet: vi.fn(),
apiPatch: vi.fn(),
}));
vi.mock("@/lib/labs/local-ocr", () => ({
ocrImageToText: vi.fn(),
LocalOcrError: class extends Error {},
}));
import {
runDocumentAi,
runDocumentIndex,
} from "@/components/documents/document-ai-transport";
import { postOcrExtract } from "@/components/labs/use-ocr-extract";
const target = {
documentId: "d1",
mimeType: "application/pdf",
filename: "report.pdf",
servingClass: "attachment" as const,
};
let timeout: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
apiPost.mockReset().mockResolvedValue({});
apiFetch.mockReset().mockResolvedValue({});
timeout = vi.spyOn(AbortSignal, "timeout");
});
afterEach(() => {
timeout.mockRestore();
});
describe("document AI requests", () => {
it("give Read with AI three model calls under the published setting", async () => {
await runDocumentIndex({
mode: "vision",
target,
responseTimeoutMs: 300_000,
});
expect(timeout).toHaveBeenCalledWith(3 * 300_000 + 30_000);
});
it("give a one-call route one call plus the margin", async () => {
await runDocumentAi({
path: "/api/documents/inbound/d1/summary?mode=summary",
mode: "vision",
target,
responseTimeoutMs: 300_000,
modelCalls: 1,
});
expect(timeout).toHaveBeenCalledWith(330_000);
});
it("follow the server default when nothing is set", async () => {
await runDocumentIndex({
mode: "vision",
target,
responseTimeoutMs: 60_000,
});
expect(timeout).toHaveBeenCalledWith(210_000);
});
});
describe("lab OCR requests", () => {
it("vision upload waits for the read and its retry", async () => {
await postOcrExtract({ file: new File(["x"], "r.png") }, 300_000);
expect(timeout).toHaveBeenCalledWith(630_000);
expect(apiFetch.mock.calls[0][1].signal).toBeInstanceOf(AbortSignal);
});
it("text mode no longer rides the 15 s default", async () => {
await postOcrExtract({ text: "Hb 14" }, 300_000);
expect(timeout).toHaveBeenCalledWith(630_000);
expect(apiPost.mock.calls[0][2].signal).toBeInstanceOf(AbortSignal);
});
});
@@ -10,10 +10,14 @@
* loaded) and posts ONLY the text. Images only — a PDF/attachment in text
* mode is refused client-side before any download/OCR work.
*
* A provider call is slow, so requests opt out of the default 15 s fetch window
* in favour of a generous ceiling.
* A provider call is slow, so requests opt out of the default 15 s fetch window.
* The ceiling is derived from the `ai.provider.responseTimeoutMs` the account
* payload publishes (the person's response-timeout setting, resolved on the
* server) times the model calls the route can make, plus a margin, so the
* browser never abandons a read the server is still allowed to finish.
*/
import { apiFetchRaw, apiPost } from "@/lib/api/api-fetch";
import { clientAbortMs } from "@/lib/ai/effective-timeout";
import { ocrImageToText, LocalOcrError } from "@/lib/labs/local-ocr";
/** The transport an AI call uses, resolved from the OCR capability probe. */
@@ -68,7 +72,16 @@ export async function runDocumentAi<T>(opts: {
path: string;
mode: DocumentAiMode;
target: DocumentAiTarget;
/** `ai.provider.responseTimeoutMs` from the account payload. */
responseTimeoutMs: number;
/**
* The most sequential model calls the route makes on one request: one for
* suggest and summary; three for index (the transcription, then the lab
* staging read with its one corrective retry).
*/
modelCalls: number;
}): Promise<T> {
const timeoutMs = clientAbortMs(opts.responseTimeoutMs, opts.modelCalls);
if (opts.mode === "text") {
if (
opts.target.servingClass !== "inline" ||
@@ -89,12 +102,12 @@ export async function runDocumentAi<T>(opts: {
return apiPost<T>(
opts.path,
{ mode: "text", text },
{ signal: AbortSignal.timeout(120_000) },
{ signal: AbortSignal.timeout(timeoutMs) },
);
}
// Vision: empty body → the route dispatches to its vision handler.
return apiPost<T>(opts.path, undefined, {
signal: AbortSignal.timeout(120_000),
signal: AbortSignal.timeout(timeoutMs),
});
}
@@ -114,10 +127,13 @@ export interface DocumentIndexResult {
export function runDocumentIndex(opts: {
mode: DocumentAiMode;
target: DocumentAiTarget;
responseTimeoutMs: number;
}): Promise<DocumentIndexResult> {
return runDocumentAi<DocumentIndexResult>({
path: `/api/documents/inbound/${opts.target.documentId}/index`,
mode: opts.mode,
target: opts.target,
responseTimeoutMs: opts.responseTimeoutMs,
modelCalls: 3,
});
}
@@ -16,6 +16,7 @@
*/
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useAiProviderState } from "@/hooks/use-ai-capability";
import { apiPost } from "@/lib/api/api-fetch";
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
@@ -29,12 +30,14 @@ import {
/** Index / re-index one document for content search. */
export function useIndexDocument() {
const queryClient = useQueryClient();
const { responseTimeoutMs } = useAiProviderState();
return useMutation<
DocumentIndexResult,
Error,
{ mode: DocumentAiMode; target: DocumentAiTarget }
>({
mutationFn: ({ mode, target }) => runDocumentIndex({ mode, target }),
mutationFn: ({ mode, target }) =>
runDocumentIndex({ mode, target, responseTimeoutMs }),
onSuccess: () => {
void invalidateKeys(queryClient, [queryKeys.documents()]);
},
@@ -17,6 +17,7 @@ import { useCallback } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useAiProviderState } from "@/hooks/use-ai-capability";
import { apiGet, ApiError } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -86,6 +87,7 @@ export function useDocumentsAutoAiRead(enabled: boolean) {
* nothing.
*/
export function useSuggestDetails() {
const { responseTimeoutMs } = useAiProviderState();
return useMutation<
DocumentSuggestionDto,
Error,
@@ -96,6 +98,8 @@ export function useSuggestDetails() {
path: `/api/documents/inbound/${target.documentId}/suggest`,
mode,
target,
responseTimeoutMs,
modelCalls: 1,
});
return data.suggestions;
},
@@ -107,6 +111,7 @@ export function useSuggestDetails() {
* summary block explicitly requests persistence or replacement.
*/
export function useDocumentSummary() {
const { responseTimeoutMs } = useAiProviderState();
return useMutation<
DocumentDescribeResult,
Error,
@@ -123,6 +128,8 @@ export function useDocumentSummary() {
path: `/api/documents/inbound/${target.documentId}/summary?mode=${output}${persist ? "&persist=true" : ""}${replace ? "&replace=true" : ""}`,
mode,
target,
responseTimeoutMs,
modelCalls: 1,
}),
});
}
+44 -17
View File
@@ -14,6 +14,8 @@
import type { AiCapabilityState } from "@/lib/ai/capabilities/types";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useAiProviderState } from "@/hooks/use-ai-capability";
import { clientAbortMs } from "@/lib/ai/effective-timeout";
import { apiFetch, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
import { ocrImageToText } from "@/lib/labs/local-ocr";
import {
@@ -92,19 +94,48 @@ export function useOcrCapability(enabled: boolean) {
});
}
/**
* The most sequential model calls one extract request makes: the read, and one
* corrective retry when the reply does not parse.
*/
const OCR_EXTRACT_MODEL_CALLS = 2;
/**
* POST one lab-report extraction. Vision mode uploads the file; text mode posts
* only the text the browser OCR'd. Either way a model reads it, so the request
* waits as long as the server may: the person's response-timeout setting as
* the account payload publishes it (`ai.provider.responseTimeoutMs`), times
* the calls one request can make, plus a margin. The 15 s default used to cut
* the text path off long before any model answered.
*/
export function postOcrExtract(
input: { file: File } | { text: string },
responseTimeoutMs: number,
): Promise<OcrExtractResponseDto> {
const signal = AbortSignal.timeout(
clientAbortMs(responseTimeoutMs, OCR_EXTRACT_MODEL_CALLS),
);
if ("text" in input) {
return apiPost<OcrExtractResponseDto>(
"/api/labs/ocr/extract",
{ mode: "text", text: input.text },
{ signal },
);
}
const form = new FormData();
form.append("file", input.file);
return apiFetch<OcrExtractResponseDto>("/api/labs/ocr/extract", {
method: "POST",
body: form,
signal,
});
}
/** Upload + extract (VISION mode). Resolves with the proposed review rows. */
export function useOcrExtract() {
const { responseTimeoutMs } = useAiProviderState();
return useMutation<OcrExtractResponseDto, Error, File>({
mutationFn: (file: File) => {
const form = new FormData();
form.append("file", file);
return apiFetch<OcrExtractResponseDto>("/api/labs/ocr/extract", {
method: "POST",
body: form,
// Vision extraction is slow; a generous ceiling beats the 15 s default.
signal: AbortSignal.timeout(90_000),
});
},
mutationFn: (file: File) => postOcrExtract({ file }, responseTimeoutMs),
});
}
@@ -115,14 +146,10 @@ export function useOcrExtract() {
* is shared verbatim.
*/
export function useOcrTextExtract() {
const { responseTimeoutMs } = useAiProviderState();
return useMutation<OcrExtractResponseDto, Error, File>({
mutationFn: async (file: File) => {
const text = await ocrImageToText(file);
return apiPost<OcrExtractResponseDto>("/api/labs/ocr/extract", {
mode: "text",
text,
});
},
mutationFn: async (file: File) =>
postOcrExtract({ text: await ocrImageToText(file) }, responseTimeoutMs),
});
}