feat(documents): gate document, lab and medication reading on their capabilities

Reading a document, scanning a lab report and turning a medication
description into a schedule follow documentAi, labsOcr and
medicationExtract. Unavailable, the action is not offered. With only the
document-reading consent missing, the place that reads asks for it and
grants exactly that (ai_extraction), nothing wider.

Refusals are read by meta.errorCode: a 403 is no longer always called a
missing consent, and a spent daily budget no longer shows the rate-limit
sentence.
This commit is contained in:
Marc-André Bombeck committed 2026-09-24 11:05:49 +02:00
1 parent 48d97ef70d
commit 4449b34ac7
15 files changed
+466 -40

No files matched your search

@@ -17,6 +17,15 @@ import type {
* the strip, the chart placeholder, and the assessment card all paint.
*/
// AI surfaces paint only while their capability is available; these tests
// cover the painted surface, so every capability reads available here.
vi.mock("@/hooks/use-ai-capability", () => ({
useAiCapability: () => ({
available: true,
reason: null,
onDeviceAllowed: true,
}),
}));
vi.mock("@/lib/api/api-fetch", () => ({
apiGet: () => new Promise(() => {}),
apiDelete: vi.fn(),
@@ -31,10 +40,6 @@ vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ isAuthenticated: true, user: { id: "u1" } }),
}));
vi.mock("@/hooks/use-feature-flags", () => ({
useFeatureFlags: () => ({ insightStatus: true }),
}));
import { LabBiomarkerDetail } from "../lab-biomarker-detail";
const BIOMARKER_ID = "bm-1";
@@ -10,7 +10,8 @@ describe("shouldProbeOcrCapability", () => {
labsEnabled: true,
mounted: true,
ownRecord: true,
};
labsOcr: { available: true, reason: null, onDeviceAllowed: true },
} as const;
expect(shouldProbeOcrCapability(ownerControl)).toBe(true);
expect(
@@ -26,4 +27,36 @@ describe("shouldProbeOcrCapability", () => {
false,
);
});
it("follows the labsOcr capability: probes when available or when only consent is missing", () => {
const base = {
isAuthenticated: true,
isLoading: false,
labsEnabled: true,
mounted: true,
ownRecord: true,
};
const state = (reason: string | null) =>
({
available: reason === null,
reason,
onDeviceAllowed: false,
}) as Parameters<typeof shouldProbeOcrCapability>[0]["labsOcr"];
// Consent is asked for inside the scan dialog, so the scan stays offered.
expect(
shouldProbeOcrCapability({ ...base, labsOcr: state("consent_required") }),
).toBe(true);
for (const reason of [
"operator_disabled",
"no_provider",
"module_disabled",
"not_permitted_for_record",
"check_failed",
]) {
expect(
shouldProbeOcrCapability({ ...base, labsOcr: state(reason) }),
).toBe(false);
}
});
});
@@ -5,7 +5,13 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { I18nProvider } from "@/lib/i18n/context";
import { handleFilePickerChange, OcrReviewDialog } from "../ocr-review-dialog";
import { ApiError } from "@/lib/api/api-fetch";
import {
extractErrorMessage,
handleFilePickerChange,
OcrReviewDialog,
} from "../ocr-review-dialog";
const hookState = vi.hoisted(() => ({ extractPending: false }));
@@ -44,13 +50,25 @@ vi.mock("../use-ocr-extract", () => ({
}),
}));
vi.mock("@/hooks/use-grant-document-reading-consent", () => ({
useGrantDocumentReadingConsent: () => ({
isPending: false,
isError: false,
mutate: vi.fn(),
}),
}));
vi.mock("sonner", () => ({
toast: { success: vi.fn(), error: vi.fn() },
}));
function render(
locale: "en" | "de" = "en",
options: { mode?: "vision" | "text"; pdfSupported?: boolean } = {},
options: {
mode?: "vision" | "text";
pdfSupported?: boolean;
consentRequired?: boolean;
} = {},
): string {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
@@ -59,6 +77,7 @@ function render(
onOpenChange={() => {}}
mode={options.mode ?? "vision"}
pdfSupported={options.pdfSupported ?? true}
consentRequired={options.consentRequired}
onCommitted={() => {}}
/>
</I18nProvider>,
@@ -138,3 +157,47 @@ describe("<OcrReviewDialog> file picker", () => {
expect(input.value).toBe("");
});
});
describe("<OcrReviewDialog> document-reading consent", () => {
it("asks for the reading consent in place of the picker while it is missing", () => {
const html = render("en", { consentRequired: true });
expect(html).toContain('data-slot="document-reading-consent"');
expect(html).not.toMatch(/<input[^>]*type="file"/);
});
it("shows the picker once consent is in place", () => {
const html = render("en", { consentRequired: false });
expect(html).not.toContain('data-slot="document-reading-consent"');
expect(html).toMatch(/<input[^>]*type="file"/);
});
});
describe("extractErrorMessage", () => {
const t = (key: string) => key;
const refusal = (errorCode: string, status = 403) =>
new ApiError("refused", status, { errorCode });
it("reads the refusal by its code, not by the status", () => {
expect(extractErrorMessage(refusal("consent.ai.required"), t)).toBe(
"labs.ocr.consentRequired",
);
expect(
extractErrorMessage(refusal("assistant.disabled.documentAi"), t),
).toBe("labs.ocr.aiUnavailable");
expect(extractErrorMessage(refusal("ai.record.notPermitted"), t)).toBe(
"labs.ocr.aiUnavailable",
);
expect(extractErrorMessage(refusal("module.disabled"), t)).toBe(
"labs.ocr.aiUnavailable",
);
expect(extractErrorMessage(refusal("ai.provider.none", 422), t)).toBe(
"labs.ocr.providerUnsupported",
);
});
it("never calls an unrelated 403 a missing consent", () => {
expect(extractErrorMessage(new ApiError("forbidden", 403), t)).toBe(
"labs.ocr.extractFailed",
);
});
});
+34 -4
View File
@@ -9,6 +9,7 @@
* confirms. Reuses the labs design language via `ResponsiveSheet` + the labs
* row editor.
*/
import { DocumentReadingConsentPrompt } from "@/components/ai/document-reading-consent-prompt";
import { useId, useMemo, useState } from "react";
import { Loader2, ScanLine, Upload } from "lucide-react";
@@ -81,8 +82,15 @@ function toCommitRow(row: OcrReviewRow): OcrCommitRowInput | null {
};
}
/** Translate an extract failure into a friendly, error-code-aware message. */
function extractErrorMessage(err: unknown, t: (key: string) => string): string {
/**
* Translate an extract failure into a friendly, error-code-aware message.
* Refusals are read by `meta.errorCode`: a 403 is a missing consent only when
* the code says so, never by its status alone.
*/
export function extractErrorMessage(
err: unknown,
t: (key: string) => string,
): string {
if (err instanceof ApiError) {
const code =
typeof err.meta?.errorCode === "string" ? err.meta.errorCode : null;
@@ -99,10 +107,23 @@ function extractErrorMessage(err: unknown, t: (key: string) => string): string {
return t("labs.ocr.fileType");
case "labs.ocr.pdfNeedsAnthropic":
return t("labs.ocr.pdfNeedsAnthropic");
case "consent.ai.required":
return t("labs.ocr.consentRequired");
case "ai.provider.none":
return t("labs.ocr.providerUnsupported");
default:
break;
}
if (err.status === 403) return t("labs.ocr.consentRequired");
// The operator's switch, the module, somebody else's record: scanning is
// not available here right now, whatever the exact layer.
if (
code !== null &&
(code.startsWith("assistant.disabled.") ||
code === "ai.record.notPermitted" ||
code === "module.disabled")
) {
return t("labs.ocr.aiUnavailable");
}
}
return t("labs.ocr.extractFailed");
}
@@ -112,6 +133,7 @@ export function OcrReviewDialog({
onOpenChange,
mode,
pdfSupported,
consentRequired = false,
onCommitted,
}: {
open: boolean;
@@ -119,6 +141,12 @@ export function OcrReviewDialog({
/** Native vision vs in-browser local OCR. Text mode is image-only. */
mode: OcrMode;
pdfSupported: boolean;
/**
* The `labsOcr` capability is missing only the document-reading consent.
* The pick step asks for it instead of offering the file picker; once it is
* granted `/api/auth/me` refreshes and the picker appears.
*/
consentRequired?: boolean;
onCommitted: () => void;
}) {
const { t } = useTranslations();
@@ -270,7 +298,9 @@ export function OcrReviewDialog({
) : undefined
}
>
{stage === "pick" ? (
{stage === "pick" && consentRequired ? (
<DocumentReadingConsentPrompt />
) : stage === "pick" ? (
<div className="relative py-2">
<input
id={pickerId}
+21 -2
View File
@@ -11,6 +11,7 @@
* - `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 { apiFetch, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
@@ -41,13 +42,22 @@ export interface OcrCommitResult extends OcrCommitResponseDto {
inserted: LabResultDto[];
}
/** The capability route resolves the caller's own provider configuration. */
/**
* The capability route resolves the caller's own provider configuration.
*
* It only runs while the `labsOcr` capability on `/api/auth/me` could let a
* scan happen: available, or missing nothing but the document-reading
* consent, which the scan dialog asks for in place. Any other reason (the
* operator's switch, no provider, the module, somebody else's record) means
* no scan is offered, so there is nothing to probe.
*/
export function shouldProbeOcrCapability({
isAuthenticated,
isLoading,
labsEnabled,
mounted,
ownRecord,
labsOcr,
}: {
isAuthenticated: boolean;
isLoading: boolean;
@@ -55,8 +65,17 @@ export function shouldProbeOcrCapability({
mounted: boolean;
/** In the caller's own record; the probe and the commit resolve the caller, never a grant. */
ownRecord: boolean;
labsOcr: AiCapabilityState;
}): boolean {
return isAuthenticated && !isLoading && labsEnabled && mounted && ownRecord;
const aiOffered = labsOcr.available || labsOcr.reason === "consent_required";
return (
isAuthenticated &&
!isLoading &&
labsEnabled &&
mounted &&
ownRecord &&
aiOffered
);
}
/** Capability probe — refetched when the scan dialog opens. */