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), }); }