feat(documents): read documents and lab scans in the background

"Read with AI" and the lab scan used to run their model calls inside the
request. At the default response time a read of three calls outlasted the
100 s a proxy in front of the server commonly allows, and the person saw a
cut connection for a read the server was still doing.

Both now run as a background run (`DocumentAiRun`, migration 0366):

- `POST /api/documents/inbound/{id}/index` with `Prefer: respond-async`
  answers 202 with a run id once every quick refusal has passed. Without
  the header it answers exactly as before, which the iPhone app relies on.
  A second request while a read of the same document is queued or running
  attaches to that run.
- `POST /api/labs/ocr/extract` always answers 202. Only the web client
  calls it.
- `GET /api/ai-runs/{id}` serves the run: the same body the synchronous
  route answers with, or the failure code and status it would have given.
  Scoped to the caller; another account's run is a 404.

The worker (`document-ai-run`, a long queue with a one-hour expiry, one
job per run under `lockedPass`) claims the run, asks the capability again
for the record and re-checks the wire for the provider it picks, sets the
run's deadline from the person's AI response time and refuses a read that
cannot fit the job's expiry. Sync route and worker run one body per kind,
so the two paths cannot drift. Budget is reserved at enqueue and settled
once by whoever ends the run.

Input and result are sealed with labelled AES-256-GCM, registered for key
rotation; the input is dropped when the run ends. A five-minute reaper
fails a run no worker took within fifteen minutes (and hands its
reservation back), fails a run past its deadline, and deletes a finished
run after an hour. Runs are outside backups and inside the data wipe.

The web client polls through the query-key factory and says, calmly, that
the read continues in the background, and when no worker has picked it up.
This commit is contained in:
Marc-André Bombeck committed 2026-10-03 18:18:56 +02:00
1 parent 556bd6d6cd
commit 0acf3da575
49 files changed
+4533 -870

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":
+15
View File
@@ -82,6 +82,10 @@ export function extractErrorMessage(
return t("labs.ocr.consentRequired");
case "ai.provider.none":
return t("labs.ocr.providerUnsupported");
case "aiRuns.workerUnavailable":
return t("aiRuns.errorWorkerUnavailable");
case "aiRuns.timedOut":
return t("aiRuns.errorTimedOut");
default:
break;
}
@@ -332,6 +336,17 @@ export function OcrReviewDialog({
? t("labs.ocr.readingOnDevice")
: t("labs.ocr.extracting")}
</span>
{extract.runPhase !== "idle" ? (
<span
role="status"
data-slot="ocr-run-phase"
className="text-muted-foreground text-xs"
>
{extract.runPhase === "waitingForWorker"
? t("aiRuns.waitingForWorker")
: t("aiRuns.backgroundScan")}
</span>
) : null}
</>
) : (
<>
+55 -37
View File
@@ -6,17 +6,26 @@
* - `useOcrCapability()` — the cheap capability probe that decides whether the
* "Scan a report" affordance shows.
* - `useOcrExtract()` — uploads the photo / PDF and returns the proposed rows.
* A vision call is slow, so it opts out of the default 15 s fetch timeout in
* favour of a generous 90 s window.
* Since v1.40 the route only queues the read (202) and the rows come from
* the background run, so no proxy timeout can cut a slow model.
* - `useOcrCommit()` — writes the user-confirmed rows and invalidates the
* labs + biomarker query keys.
*/
import type { AiCapabilityState } from "@/lib/ai/capabilities/types";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
useMutation,
useQuery,
useQueryClient,
type QueryClient,
} from "@tanstack/react-query";
import { useAiProviderState } from "@/hooks/use-ai-capability";
import { clientAbortMs } from "@/lib/ai/effective-timeout";
import {
resolveAiRun,
useAiRunPhase,
type WaitForAiRunOptions,
} from "@/hooks/use-ai-run";
import { apiFetch, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
import type { AiRunAccepted } from "@/lib/documents/ai-runs/types";
import { ocrImageToText } from "@/lib/labs/local-ocr";
import {
aiInputDependentKeys,
@@ -94,49 +103,60 @@ 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;
/** How long the queuing request may take: the upload and the enqueue, no model. */
const OCR_QUEUE_REQUEST_TIMEOUT_MS = 60_000;
/**
* 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.
* POST one lab-report extraction and follow the background run to its rows.
* Vision mode uploads the file; text mode posts only the text the browser
* OCR'd. The request itself only queues the read, so it carries a short
* ceiling; the run's own deadline (the person's AI response time) ends a slow
* read on the server.
*/
export function postOcrExtract(
export async function postOcrExtract(
input: { file: File } | { text: string },
responseTimeoutMs: number,
queryClient: QueryClient,
progress: WaitForAiRunOptions & { onQueued?: () => void } = {},
): Promise<OcrExtractResponseDto> {
const signal = AbortSignal.timeout(
clientAbortMs(responseTimeoutMs, OCR_EXTRACT_MODEL_CALLS),
);
const signal = AbortSignal.timeout(OCR_QUEUE_REQUEST_TIMEOUT_MS);
let accepted: OcrExtractResponseDto | AiRunAccepted;
if ("text" in input) {
return apiPost<OcrExtractResponseDto>(
accepted = await apiPost<OcrExtractResponseDto | AiRunAccepted>(
"/api/labs/ocr/extract",
{ mode: "text", text: input.text },
{ signal },
);
} else {
const form = new FormData();
form.append("file", input.file);
accepted = await apiFetch<OcrExtractResponseDto | AiRunAccepted>(
"/api/labs/ocr/extract",
{ method: "POST", body: form, signal },
);
}
const form = new FormData();
form.append("file", input.file);
return apiFetch<OcrExtractResponseDto>("/api/labs/ocr/extract", {
method: "POST",
body: form,
signal,
return resolveAiRun<OcrExtractResponseDto>(queryClient, accepted, progress);
}
/**
* The extract mutation for one mode, plus the run phase the dialog shows while
* the read runs in the background.
*/
function useOcrExtractMutation(
read: (file: File) => Promise<{ file: File } | { text: string }>,
) {
const queryClient = useQueryClient();
const { phase, onQueued, onProgress, reset } = useAiRunPhase();
const mutation = useMutation<OcrExtractResponseDto, Error, File>({
mutationFn: async (file: File) =>
postOcrExtract(await read(file), queryClient, { onQueued, onProgress }),
onSettled: reset,
});
return { ...mutation, runPhase: mutation.isPending ? phase : "idle" };
}
/** Upload + extract (VISION mode). Resolves with the proposed review rows. */
export function useOcrExtract() {
const { responseTimeoutMs } = useAiProviderState();
return useMutation<OcrExtractResponseDto, Error, File>({
mutationFn: (file: File) => postOcrExtract({ file }, responseTimeoutMs),
});
return useOcrExtractMutation(async (file) => ({ file }));
}
/**
@@ -146,11 +166,9 @@ export function useOcrExtract() {
* is shared verbatim.
*/
export function useOcrTextExtract() {
const { responseTimeoutMs } = useAiProviderState();
return useMutation<OcrExtractResponseDto, Error, File>({
mutationFn: async (file: File) =>
postOcrExtract({ text: await ocrImageToText(file) }, responseTimeoutMs),
});
return useOcrExtractMutation(async (file) => ({
text: await ocrImageToText(file),
}));
}
/** The local-OCR opt-in preference (read + toggle). */