Merge remote-tracking branch 'origin/main' into feat/key-backup-and-transport

# Conflicts:
#	docs/api/openapi.yaml
This commit is contained in:
Marc-André Bombeck committed 2026-10-03 19:22:01 +02:00
commit 37793298a8
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":
@@ -0,0 +1,109 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type { LabResultDto, LabResultListResponse } from "../types";
/**
* Every row of the compact list is five grid cells from `lg` up. The range
* badge renders nothing for a reading without a reference range, so without
* its own cell the range bar and the trend slid one column to the left on
* exactly those rows.
*/
vi.mock("@/lib/api/api-fetch", () => ({
apiGet: () => new Promise(() => {}),
apiDelete: vi.fn(),
}));
import { LabList } from "../lab-list";
function reading(over: Partial<LabResultDto>): LabResultDto {
return {
id: "r1",
biomarkerId: "b1",
panel: null,
analyte: "Ferritin",
value: 80,
valueText: null,
unit: "ng/mL",
referenceLow: 30,
referenceHigh: 400,
catalogReferenceLow: 30,
catalogReferenceHigh: 400,
sourceReferenceLow: null,
sourceReferenceHigh: null,
sourceReferenceText: null,
referenceOrigin: "catalog",
referenceDivergesFromCatalog: false,
takenAt: "2026-09-01T12:00:00.000Z",
source: "MANUAL",
hasNote: false,
rangeStatus: "in_range",
createdAt: "2026-09-01T12:00:00.000Z",
updatedAt: "2026-09-01T12:00:00.000Z",
...over,
} as LabResultDto;
}
function renderList(results: LabResultDto[]) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
});
const data: LabResultListResponse = {
results,
meta: { total: results.length, limit: 500, offset: 0 },
};
queryClient.setQueryData(
queryKeys.labResultsList({
biomarkerId: undefined,
analyte: undefined,
panel: undefined,
from: undefined,
to: undefined,
page: 0,
sortDir: "desc",
}),
data,
);
return renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">
<LabList />
</I18nProvider>
</QueryClientProvider>,
);
}
function count(html: string, needle: string): number {
return html.split(needle).length - 1;
}
describe("<LabList> columns", () => {
it("gives the badge its own cell even when a reading has no reference range", () => {
const html = renderList([
reading({ id: "a", biomarkerId: "b1", analyte: "Ferritin" }),
reading({
id: "b",
biomarkerId: "b2",
analyte: "Lipoprotein (a)",
referenceLow: null,
referenceHigh: null,
catalogReferenceLow: null,
catalogReferenceHigh: null,
rangeStatus: "unknown",
}),
]);
expect(count(html, 'href="/labs/')).toBe(2);
// One cell per row, rendered even where the badge itself renders nothing.
expect(count(html, 'data-slot="lab-list-badge-cell"')).toBe(2);
expect(html).toMatch(/<div data-slot="lab-list-badge-cell"[^>]*><\/div>/);
});
it("hides the chevron from lg up and keeps it on a phone", () => {
const html = renderList([reading({})]);
expect(html).toMatch(/class="[^"]*lucide-chevron-right[^"]*lg:invisible/);
});
});
+18 -11
View File
@@ -78,6 +78,19 @@ function groupReadings(results: LabResultDto[]): MarkerGroup[] {
);
}
/**
* The badge's grid cell. The badge renders nothing for a reading without a
* reference range, and a missing cell would pull the range bar and the trend
* one column to the left; the wrapper keeps every row at five cells.
*/
function RangeBadgeCell({ status }: { status: LabResultDto["rangeStatus"] }) {
return (
<div data-slot="lab-list-badge-cell" className={MOBILE_CELL.badge}>
<ReferenceRangeBadge status={status} compact className="lg:w-full" />
</div>
);
}
function LabRangeBarSlot({ reading }: { reading: LabResultDto }) {
return (
<div className={cn("w-full lg:w-48", MOBILE_CELL.rangeBar)}>
@@ -274,11 +287,7 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
)}
>
{inner}
<ReferenceRangeBadge
status={group.latest.rangeStatus}
compact
className={cn("lg:w-full", MOBILE_CELL.badge)}
/>
<RangeBadgeCell status={group.latest.rangeStatus} />
<LabRangeBarSlot reading={group.latest} />
<div className={cn("w-[72px]", MOBILE_CELL.trend)}>
<LabTrendSparkline
@@ -287,9 +296,11 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
referenceHigh={group.latest.referenceHigh}
/>
</div>
{/* With a pointer the row's hover already says it opens; a
phone has no hover, so the chevron stays there. */}
<ChevronRight
className={cn(
"text-muted-foreground h-4 w-4 shrink-0",
"text-muted-foreground h-4 w-4 shrink-0 lg:invisible",
MOBILE_CELL.end,
)}
/>
@@ -300,11 +311,7 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
// link next to its clickable neighbours.
<div key={group.key} className={ROW_CLASS}>
{inner}
<ReferenceRangeBadge
status={group.latest.rangeStatus}
compact
className={cn("lg:w-full", MOBILE_CELL.badge)}
/>
<RangeBadgeCell status={group.latest.rangeStatus} />
<LabRangeBarSlot reading={group.latest} />
<div className={cn("w-[72px]", MOBILE_CELL.trend)}>
<LabTrendSparkline
+15
View File
@@ -102,6 +102,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;
}
@@ -365,6 +369,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). */