mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
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:
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,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user