mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
Merge remote-tracking branch 'origin/main' into feat/key-backup-and-transport
# Conflicts: # docs/api/openapi.yaml
This commit is contained in:
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,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/);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -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). */
|
||||
|
||||
Reference in new issue
Block a user