Merge remote-tracking branch 'origin/main' into feat/v140-fixes

# Conflicts:
#	docs/api/openapi.yaml
This commit is contained in:
Marc-André Bombeck committed 2026-10-03 19:22:56 +02:00
commit 816eabb3c3
59 files changed
+4736 -905

No files matched your search

@@ -1,21 +1,28 @@
/**
* The browser waits as long as the server may.
* The browser waits as long as the server may, and no longer has to wait at
* all for a read that runs in the background.
*
* 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.
* model, was allowed far longer. A synchronous route still derives its window
* from `ai.provider.responseTimeoutMs` (per call, times the calls, plus a
* 30 s margin). Since v1.40 "Read with AI" and the lab scan only queue the
* read: the request gets a short ceiling for the upload and the enqueue, and
* the result comes from the run.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClient } from "@tanstack/react-query";
const apiPost = vi.fn();
const apiFetch = vi.fn();
vi.mock("@/lib/api/api-fetch", () => ({
const apiGet = vi.fn();
vi.mock("@/lib/api/api-fetch", async (importOriginal) => ({
ApiError: (await importOriginal<typeof import("@/lib/api/api-fetch")>())
.ApiError,
apiPost: (...a: unknown[]) => apiPost(...a),
apiFetch: (...a: unknown[]) => apiFetch(...a),
apiFetchRaw: vi.fn(),
apiGet: vi.fn(),
apiGet: (...a: unknown[]) => apiGet(...a),
apiPatch: vi.fn(),
}));
vi.mock("@/lib/labs/local-ocr", () => ({
@@ -36,29 +43,55 @@ const target = {
servingClass: "attachment" as const,
};
const accepted = { runId: "run1", status: "QUEUED", pollAfterMs: 0 };
const indexResult = {
documentId: "d1",
indexed: true,
tokenCount: 12,
labFactsStaged: 0,
};
let timeout: ReturnType<typeof vi.spyOn>;
let queryClient: QueryClient;
beforeEach(() => {
apiPost.mockReset().mockResolvedValue({});
apiFetch.mockReset().mockResolvedValue({});
apiPost.mockReset().mockResolvedValue(accepted);
apiFetch.mockReset().mockResolvedValue(accepted);
apiGet.mockReset().mockResolvedValue({
id: "run1",
status: "SUCCEEDED",
result: indexResult,
error: null,
retryAfterMs: null,
});
timeout = vi.spyOn(AbortSignal, "timeout");
queryClient = new QueryClient();
vi.useFakeTimers({ shouldAdvanceTime: true });
});
afterEach(() => {
timeout.mockRestore();
vi.useRealTimers();
});
describe("document AI requests", () => {
it("give Read with AI three model calls under the published setting", async () => {
await runDocumentIndex({
it("queue Read with AI and resolve the run's result", async () => {
const result = await runDocumentIndex({
mode: "vision",
target,
responseTimeoutMs: 300_000,
background: { queryClient },
});
expect(timeout).toHaveBeenCalledWith(3 * 300_000 + 30_000);
expect(result).toEqual(indexResult);
expect(timeout).toHaveBeenCalledWith(60_000);
expect(timeout).not.toHaveBeenCalledWith(3 * 300_000 + 30_000);
const init = apiPost.mock.calls[0][2] as RequestInit;
expect(new Headers(init.headers).get("Prefer")).toBe("respond-async");
expect(apiGet).toHaveBeenCalledWith("/api/ai-runs/run1");
});
it("give a one-call route one call plus the margin", async () => {
it("give a one-call synchronous route one call plus the margin", async () => {
apiPost.mockResolvedValue({ title: "x" });
await runDocumentAi({
path: "/api/documents/inbound/d1/summary?mode=summary",
mode: "vision",
@@ -67,28 +100,54 @@ describe("document AI requests", () => {
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);
const init = apiPost.mock.calls[0][2] as RequestInit;
expect(new Headers(init.headers ?? {}).get("Prefer")).toBeNull();
});
});
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);
it("vision upload queues the read and polls the rows", async () => {
const rows = { reportDate: null, providerType: "anthropic", rows: [] };
apiGet.mockResolvedValue({
id: "run1",
status: "SUCCEEDED",
result: rows,
error: null,
retryAfterMs: null,
});
const result = await postOcrExtract(
{ file: new File(["x"], "r.png") },
queryClient,
);
expect(result).toEqual(rows);
expect(timeout).toHaveBeenCalledWith(60_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);
await postOcrExtract({ text: "Hb 14" }, queryClient);
expect(timeout).toHaveBeenCalledWith(60_000);
expect(apiPost.mock.calls[0][2].signal).toBeInstanceOf(AbortSignal);
});
it("a failed run throws what the synchronous route would have", async () => {
apiGet.mockResolvedValue({
id: "run1",
status: "FAILED",
result: null,
error: {
code: "labs.ocr.extractFailed",
message: "Couldn't read the report.",
status: 422,
},
retryAfterMs: null,
});
await expect(
postOcrExtract({ text: "Hb 14" }, queryClient),
).rejects.toMatchObject({
name: "ApiError",
status: 422,
meta: { errorCode: "labs.ocr.extractFailed" },
});
});
});
@@ -33,6 +33,7 @@
import { FileText, ScanText, WandSparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import type { AiRunPhase } from "@/hooks/use-ai-run";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import {
@@ -77,6 +78,7 @@ export function DocumentAiSection({
contentIndexSource,
lastIndexOutcome = null,
indexPending,
indexRunPhase = "idle",
onIndex,
}: {
aiEnabled: boolean;
@@ -120,6 +122,8 @@ export function DocumentAiSection({
*/
lastIndexOutcome?: DocumentIndexOutcomeValue | null;
indexPending: boolean;
/** v1.40 — where a background read stands, for the calm line under it. */
indexRunPhase?: AiRunPhase;
onIndex: () => void;
}) {
const { t } = useTranslations();
@@ -248,6 +252,18 @@ export function DocumentAiSection({
</div>
) : null}
{indexRunPhase !== "idle" ? (
<p
role="status"
data-slot="document-index-run-phase"
className="text-muted-foreground text-xs"
>
{indexRunPhase === "waitingForWorker"
? t("aiRuns.waitingForWorker")
: t("aiRuns.backgroundDocument")}
</p>
) : null}
{indexOutcomeText ? (
<p
data-slot="document-index-outcome"
@@ -15,9 +15,20 @@
* 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.
*
* v1.40 — a route that offers the background path is called with
* `Prefer: respond-async` (`background`). The request then only queues the
* read and answers 202 quickly, and the result is polled from the run, so no
* proxy in front of the server can cut a long read. Such a call carries no
* model-time ceiling at all; the run's own deadline (the same setting) ends
* it on the server.
*/
import type { QueryClient } from "@tanstack/react-query";
import { resolveAiRun, type WaitForAiRunOptions } from "@/hooks/use-ai-run";
import { apiFetchRaw, apiPost } from "@/lib/api/api-fetch";
import { clientAbortMs } from "@/lib/ai/effective-timeout";
import type { AiRunAccepted } from "@/lib/documents/ai-runs/types";
import { ocrImageToText, LocalOcrError } from "@/lib/labs/local-ocr";
/** The transport an AI call uses, resolved from the OCR capability probe. */
@@ -67,6 +78,15 @@ async function fetchOriginalAsFile(target: DocumentAiTarget): Promise<File> {
});
}
/** How long a queuing request may take: the upload and the enqueue, no model. */
const QUEUE_REQUEST_TIMEOUT_MS = 60_000;
/** The background path of a document AI call. */
export interface DocumentAiBackground extends WaitForAiRunOptions {
queryClient: QueryClient;
onQueued?: () => void;
}
/** Run one document AI call over the chosen transport. */
export async function runDocumentAi<T>(opts: {
path: string;
@@ -80,8 +100,20 @@ export async function runDocumentAi<T>(opts: {
* staging read with its one corrective retry).
*/
modelCalls: number;
/** Queue the read and poll it (the route must offer `Prefer: respond-async`). */
background?: DocumentAiBackground;
}): Promise<T> {
const timeoutMs = clientAbortMs(opts.responseTimeoutMs, opts.modelCalls);
const background = opts.background;
const timeoutMs = background
? QUEUE_REQUEST_TIMEOUT_MS
: clientAbortMs(opts.responseTimeoutMs, opts.modelCalls);
const headers: HeadersInit | undefined = background
? { Prefer: "respond-async" }
: undefined;
const settle = async (data: T | AiRunAccepted): Promise<T> =>
background
? resolveAiRun<T>(background.queryClient, data, background)
: (data as T);
if (opts.mode === "text") {
if (
opts.target.servingClass !== "inline" ||
@@ -99,16 +131,21 @@ export async function runDocumentAi<T>(opts: {
? new DocumentAssistClientError("ocr")
: err;
}
return apiPost<T>(
opts.path,
{ mode: "text", text },
{ signal: AbortSignal.timeout(timeoutMs) },
return settle(
await apiPost<T | AiRunAccepted>(
opts.path,
{ mode: "text", text },
{ signal: AbortSignal.timeout(timeoutMs), headers },
),
);
}
// Vision: empty body → the route dispatches to its vision handler.
return apiPost<T>(opts.path, undefined, {
signal: AbortSignal.timeout(timeoutMs),
});
return settle(
await apiPost<T | AiRunAccepted>(opts.path, undefined, {
signal: AbortSignal.timeout(timeoutMs),
headers,
}),
);
}
/** The index route's result, including the lab-staging continuation. */
@@ -123,11 +160,16 @@ export interface DocumentIndexResult {
labFactsStaged: number;
}
/** Populate / refresh one document's content index over the chosen transport. */
/**
* Populate / refresh one document's content index over the chosen transport.
* Always in the background: the read and the lab staging after it are up to
* three model calls, which no proxy timeout should be able to cut.
*/
export function runDocumentIndex(opts: {
mode: DocumentAiMode;
target: DocumentAiTarget;
responseTimeoutMs: number;
background: DocumentAiBackground;
}): Promise<DocumentIndexResult> {
return runDocumentAi<DocumentIndexResult>({
path: `/api/documents/inbound/${opts.target.documentId}/index`,
@@ -135,5 +177,6 @@ export function runDocumentIndex(opts: {
target: opts.target,
responseTimeoutMs: opts.responseTimeoutMs,
modelCalls: 3,
background: opts.background,
});
}
@@ -937,6 +937,7 @@ export function DocumentDetailSheet({
contentIndexSource={doc.contentIndexSource}
lastIndexOutcome={doc.lastIndexOutcome}
indexPending={indexDoc.isPending}
indexRunPhase={indexDoc.runPhase}
onIndex={runIndex}
/>
)}
+17 -3
View File
@@ -17,6 +17,7 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useAiProviderState } from "@/hooks/use-ai-capability";
import { useAiRunPhase } from "@/hooks/use-ai-run";
import { apiPost } from "@/lib/api/api-fetch";
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
@@ -27,21 +28,34 @@ import {
type DocumentIndexResult,
} from "./document-ai-transport";
/** Index / re-index one document for content search. */
/**
* Index / re-index one document for content search. The read runs in the
* background (v1.40); `runPhase` says so while it does, so the screen can tell
* the person they may leave: the index and any staged lab values land on the
* document whether or not anyone is still watching.
*/
export function useIndexDocument() {
const queryClient = useQueryClient();
const { responseTimeoutMs } = useAiProviderState();
return useMutation<
const { phase, onQueued, onProgress, reset } = useAiRunPhase();
const mutation = useMutation<
DocumentIndexResult,
Error,
{ mode: DocumentAiMode; target: DocumentAiTarget }
>({
mutationFn: ({ mode, target }) =>
runDocumentIndex({ mode, target, responseTimeoutMs }),
runDocumentIndex({
mode,
target,
responseTimeoutMs,
background: { queryClient, onQueued, onProgress },
}),
onSettled: reset,
onSuccess: () => {
void invalidateKeys(queryClient, [queryKeys.documents()]);
},
});
return { ...mutation, runPhase: mutation.isPending ? phase : "idle" };
}
/**
@@ -207,6 +207,10 @@ export function documentAiErrorKey(err: unknown): string {
return "documents.assist.errorProvider";
case "documents.inbound.notIndexed":
return "documents.assist.errorNotIndexed";
case "aiRuns.workerUnavailable":
return "aiRuns.errorWorkerUnavailable";
case "aiRuns.timedOut":
return "aiRuns.errorTimedOut";
case "consent.ai.required":
return "documents.assist.errorConsent";
case "ai.provider.none":