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

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