mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
Merge remote-tracking branch 'origin/main' into feat/v140-fixes
# Conflicts: # docs/api/openapi.yaml
This commit is contained in:
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,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":
|
||||
|
||||
Reference in new issue
Block a user