From bf7fb9c40132c88eed25e7aebd94c1c4f4bfb663 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 2 Oct 2026 23:01:41 +0200 Subject: [PATCH] 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 --- .../__tests__/document-ai-timeout.test.ts | 94 +++++++++++++++++++ .../documents/document-ai-transport.ts | 24 ++++- src/components/documents/use-content-index.ts | 5 +- .../documents/use-document-assist.ts | 7 ++ src/components/labs/use-ocr-extract.ts | 61 ++++++++---- 5 files changed, 169 insertions(+), 22 deletions(-) create mode 100644 src/components/documents/__tests__/document-ai-timeout.test.ts diff --git a/src/components/documents/__tests__/document-ai-timeout.test.ts b/src/components/documents/__tests__/document-ai-timeout.test.ts new file mode 100644 index 000000000..00ca00ae3 --- /dev/null +++ b/src/components/documents/__tests__/document-ai-timeout.test.ts @@ -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; + +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); + }); +}); diff --git a/src/components/documents/document-ai-transport.ts b/src/components/documents/document-ai-transport.ts index a698e0c2a..ae3a909af 100644 --- a/src/components/documents/document-ai-transport.ts +++ b/src/components/documents/document-ai-transport.ts @@ -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(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 { + const timeoutMs = clientAbortMs(opts.responseTimeoutMs, opts.modelCalls); if (opts.mode === "text") { if ( opts.target.servingClass !== "inline" || @@ -89,12 +102,12 @@ export async function runDocumentAi(opts: { return apiPost( 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(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 { return runDocumentAi({ path: `/api/documents/inbound/${opts.target.documentId}/index`, mode: opts.mode, target: opts.target, + responseTimeoutMs: opts.responseTimeoutMs, + modelCalls: 3, }); } diff --git a/src/components/documents/use-content-index.ts b/src/components/documents/use-content-index.ts index 28f164ba8..146bdf697 100644 --- a/src/components/documents/use-content-index.ts +++ b/src/components/documents/use-content-index.ts @@ -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()]); }, diff --git a/src/components/documents/use-document-assist.ts b/src/components/documents/use-document-assist.ts index 8ca83e7fe..a2e75fc15 100644 --- a/src/components/documents/use-document-assist.ts +++ b/src/components/documents/use-document-assist.ts @@ -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, }), }); } diff --git a/src/components/labs/use-ocr-extract.ts b/src/components/labs/use-ocr-extract.ts index 999e00f9a..e68e07eb0 100644 --- a/src/components/labs/use-ocr-extract.ts +++ b/src/components/labs/use-ocr-extract.ts @@ -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 { + const signal = AbortSignal.timeout( + clientAbortMs(responseTimeoutMs, OCR_EXTRACT_MODEL_CALLS), + ); + if ("text" in input) { + return apiPost( + "/api/labs/ocr/extract", + { mode: "text", text: input.text }, + { signal }, + ); + } + const form = new FormData(); + form.append("file", input.file); + return apiFetch("/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({ - mutationFn: (file: File) => { - const form = new FormData(); - form.append("file", file); - return apiFetch("/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({ - mutationFn: async (file: File) => { - const text = await ocrImageToText(file); - return apiPost("/api/labs/ocr/extract", { - mode: "text", - text, - }); - }, + mutationFn: async (file: File) => + postOcrExtract({ text: await ocrImageToText(file) }, responseTimeoutMs), }); }