diff --git a/src/app/labs/page.tsx b/src/app/labs/page.tsx
index 65824629e..1899669fd 100644
--- a/src/app/labs/page.tsx
+++ b/src/app/labs/page.tsx
@@ -16,6 +16,7 @@ import {
import { LabForm } from "@/components/labs/lab-form";
import { LabList } from "@/components/labs/lab-list";
+import { useAiCapability } from "@/hooks/use-ai-capability";
import { OcrReviewDialog } from "@/components/labs/ocr-review-dialog";
import {
shouldProbeOcrCapability,
@@ -64,6 +65,10 @@ export default function LabsPage() {
// explicit `false`. Every `/api/labs/*` route also enforces the gate
// server-side, so this is a UX redirect, not the security boundary.
const enabled = user?.modules?.labs !== false;
+ // Scanning a lab report follows the `labsOcr` capability on
+ // `/api/auth/me`; with only the document-reading consent missing, the scan
+ // stays offered and the dialog asks for it.
+ const labsOcr = useAiCapability("labsOcr");
const ocrCapability = useOcrCapability(
shouldProbeOcrCapability({
isAuthenticated,
@@ -71,6 +76,7 @@ export default function LabsPage() {
labsEnabled: enabled,
mounted,
ownRecord: !inSharedRecord,
+ labsOcr,
}),
);
@@ -216,6 +222,7 @@ export default function LabsPage() {
onOpenChange={setScanOpen}
mode={ocrCapability.data.mode}
pdfSupported={ocrCapability.data.pdfSupported}
+ consentRequired={labsOcr.reason === "consent_required"}
onCommitted={() => {
queryClient.invalidateQueries({
queryKey: queryKeys.labResults(),
diff --git a/src/components/ai/document-reading-consent-prompt.tsx b/src/components/ai/document-reading-consent-prompt.tsx
new file mode 100644
index 000000000..5cca04d21
--- /dev/null
+++ b/src/components/ai/document-reading-consent-prompt.tsx
@@ -0,0 +1,55 @@
+"use client";
+
+import { ShieldCheck } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import { useGrantDocumentReadingConsent } from "@/hooks/use-grant-document-reading-consent";
+import { useTranslations } from "@/lib/i18n/context";
+
+/**
+ * The consent question, asked where a document is about to be read.
+ *
+ * Shown in place of a document-reading action while its capability reports
+ * `consent_required`: the chain would send the document to a service outside
+ * this server and no receipt covers that yet. One sentence says what would
+ * happen, one button grants exactly that (`ai_extraction`), and the action
+ * appears once `/api/auth/me` confirms it. Withdrawing stays in Settings → AI.
+ */
+export function DocumentReadingConsentPrompt({
+ className,
+}: {
+ className?: string;
+}) {
+ const { t } = useTranslations();
+ const grant = useGrantDocumentReadingConsent();
+ return (
+
+
+
+ {t("documents.readingConsent.body")}
+
+
+ {grant.isError ? (
+
+ {t("documents.readingConsent.error")}
+
+ ) : null}
+
+ );
+}
diff --git a/src/components/documents/__tests__/document-ai-error-key.test.ts b/src/components/documents/__tests__/document-ai-error-key.test.ts
new file mode 100644
index 000000000..190dbd7ae
--- /dev/null
+++ b/src/components/documents/__tests__/document-ai-error-key.test.ts
@@ -0,0 +1,43 @@
+/**
+ * A refused document read names its reason. Before the capability refusals
+ * had codes of their own, a missing consent, the operator's switch and a
+ * missing provider all fell through to the generic "couldn't read" line, and
+ * the one thing the reader could fix (the consent) was never said.
+ */
+import { describe, expect, it } from "vitest";
+
+import { ApiError } from "@/lib/api/api-fetch";
+
+import { documentAiErrorKey } from "../use-document-assist";
+
+const refusal = (errorCode: string, status = 403) =>
+ new ApiError("refused", status, { errorCode });
+
+describe("documentAiErrorKey", () => {
+ it("maps each capability refusal to its own sentence", () => {
+ expect(documentAiErrorKey(refusal("consent.ai.required"))).toBe(
+ "documents.assist.errorConsent",
+ );
+ expect(documentAiErrorKey(refusal("assistant.disabled.documentAi"))).toBe(
+ "documents.assist.errorUnavailable",
+ );
+ expect(documentAiErrorKey(refusal("assistant.disabled.enabled"))).toBe(
+ "documents.assist.errorUnavailable",
+ );
+ expect(documentAiErrorKey(refusal("ai.record.notPermitted"))).toBe(
+ "documents.assist.errorUnavailable",
+ );
+ expect(documentAiErrorKey(refusal("ai.provider.none", 422))).toBe(
+ "documents.assist.errorProvider",
+ );
+ });
+
+ it("keeps the existing route codes", () => {
+ expect(
+ documentAiErrorKey(refusal("documents.inbound.budgetExceeded", 429)),
+ ).toBe("documents.assist.errorBudget");
+ expect(documentAiErrorKey(refusal("something.else", 500))).toBe(
+ "documents.assist.errorGeneric",
+ );
+ });
+});
diff --git a/src/components/documents/document-detail-sheet.tsx b/src/components/documents/document-detail-sheet.tsx
index 1ee941f67..c1b216982 100644
--- a/src/components/documents/document-detail-sheet.tsx
+++ b/src/components/documents/document-detail-sheet.tsx
@@ -62,6 +62,8 @@ import {
apiPost,
} from "@/lib/api/api-fetch";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
+import { useAiCapability } from "@/hooks/use-ai-capability";
+import { DocumentReadingConsentPrompt } from "@/components/ai/document-reading-consent-prompt";
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
import {
@@ -389,8 +391,15 @@ export function DocumentDetailSheet({
// not, and both of these routes stay on the caller's own authentication. A
// delegate would fire two queries that must 403 in order to compute an
// affordance every branch below already withholds on `canManageDocuments`.
+ // Reading a document with AI follows the `documentAi` capability on
+ // `/api/auth/me` (the operator's "Reading documents" switch, the provider,
+ // the document-reading consent). Unavailable, the actions are simply not
+ // offered and the probe never fires; a missing consent is asked for in
+ // place instead.
+ const documentAi = useAiCapability("documentAi");
+ const coach = useAiCapability("coach");
const capability = useDocumentAiCapability(
- canManageDocuments && open && documentId !== null,
+ canManageDocuments && open && documentId !== null && documentAi.available,
);
// When auto-read is ON, reading happens automatically on upload — the manual
// per-document AI action row is redundant and collapses away.
@@ -558,9 +567,12 @@ export function DocumentDetailSheet({
// The affordance gate falls back to the capability probe when the sheet is
// rendered without the usage-derived props (deep link before usage loads).
- const aiEnabled = assistAvailable ?? capability.data?.available ?? false;
+ const aiEnabled =
+ documentAi.available &&
+ (assistAvailable ?? capability.data?.available ?? false);
const indexEnabled =
- contentIndexEnabled ?? capability.data?.available ?? false;
+ documentAi.available &&
+ (contentIndexEnabled ?? capability.data?.available ?? false);
const autoReadEnabled = autoRead.data?.documentsAutoAiRead ?? false;
const aiMode = capability.data?.mode === "text" ? "text" : "vision";
const aiTarget: DocumentAiTarget | null = doc
@@ -742,7 +754,7 @@ export function DocumentDetailSheet({
inside somebody else's record (the shell mounts no drawer
there), and `/documents` is a shared-record destination, so
this is the Coach button a delegate actually meets. */}
- {aiEnabled && launch ? (
+ {aiEnabled && coach.available && launch ? (
// v1.28.52 (Documents R3) — "Ask the Coach" opens the REAL
// coach conversation scoped to this document in the SIDE
// DRAWER (the maximize control there expands it to the full
@@ -856,7 +868,11 @@ export function DocumentDetailSheet({
onGenerate={generateStoredSummary}
/>
- {canManageDocuments && (
+ {canManageDocuments && documentAi.reason === "consent_required" ? (
+
+ ) : null}
+
+ {canManageDocuments && documentAi.available && (
({
+ 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";
diff --git a/src/components/labs/__tests__/ocr-capability-gate.test.ts b/src/components/labs/__tests__/ocr-capability-gate.test.ts
index 6d94d478f..2bf8e62e1 100644
--- a/src/components/labs/__tests__/ocr-capability-gate.test.ts
+++ b/src/components/labs/__tests__/ocr-capability-gate.test.ts
@@ -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[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);
+ }
+ });
});
diff --git a/src/components/labs/__tests__/ocr-review-dialog.test.tsx b/src/components/labs/__tests__/ocr-review-dialog.test.tsx
index 29a3f5337..1ef88b1c9 100644
--- a/src/components/labs/__tests__/ocr-review-dialog.test.tsx
+++ b/src/components/labs/__tests__/ocr-review-dialog.test.tsx
@@ -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(
@@ -59,6 +77,7 @@ function render(
onOpenChange={() => {}}
mode={options.mode ?? "vision"}
pdfSupported={options.pdfSupported ?? true}
+ consentRequired={options.consentRequired}
onCommitted={() => {}}
/>
,
@@ -138,3 +157,47 @@ describe(" file picker", () => {
expect(input.value).toBe("");
});
});
+
+describe(" 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(/]*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(/]*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",
+ );
+ });
+});
diff --git a/src/components/labs/ocr-review-dialog.tsx b/src/components/labs/ocr-review-dialog.tsx
index e64c33577..b17162702 100644
--- a/src/components/labs/ocr-review-dialog.tsx
+++ b/src/components/labs/ocr-review-dialog.tsx
@@ -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 ? (
+
+ ) : stage === "pick" ? (
({
+ data: null,
+ error,
+ ...(errorCode ? { meta: { errorCode } } : {}),
+});
+
+describe("classifyExtractFailure", () => {
+ it("reads the code before the status", () => {
+ expect(
+ classifyExtractFailure(429, envelope("coach.budget.exceeded")).kind,
+ ).toBe("budget");
+ expect(classifyExtractFailure(429, envelope()).kind).toBe("rateLimit");
+ });
+
+ it("names a missing provider, a missing consent and a switched-off reader", () => {
+ expect(classifyExtractFailure(503, envelope("ai.provider.none")).kind).toBe(
+ "noProvider",
+ );
+ expect(
+ classifyExtractFailure(403, envelope("consent.ai.required")).kind,
+ ).toBe("consent");
+ expect(
+ classifyExtractFailure(403, envelope("assistant.disabled.documentAi"))
+ .kind,
+ ).toBe("unavailable");
+ expect(
+ classifyExtractFailure(403, envelope("ai.record.notPermitted")).kind,
+ ).toBe("unavailable");
+ });
+
+ it("keeps a bare 503 as no provider and anything else as a network failure", () => {
+ expect(classifyExtractFailure(503, null).kind).toBe("noProvider");
+ expect(classifyExtractFailure(500, envelope()).kind).toBe("network");
+ });
+});
diff --git a/src/components/medications/scheduling/natural-language-extractor.tsx b/src/components/medications/scheduling/natural-language-extractor.tsx
index 8aad94571..0824067a5 100644
--- a/src/components/medications/scheduling/natural-language-extractor.tsx
+++ b/src/components/medications/scheduling/natural-language-extractor.tsx
@@ -46,6 +46,7 @@ import { Loader2, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
+import { DocumentReadingConsentPrompt } from "@/components/ai/document-reading-consent-prompt";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { useTranslations } from "@/lib/i18n/context";
@@ -77,6 +78,12 @@ export interface NaturalLanguageExtractorProps {
* point at a mock server. Defaults to the production route.
*/
endpoint?: string;
+ /**
+ * The `medicationExtract` capability is missing only the document-reading
+ * consent. The sheet asks for it above the text field and holds the submit
+ * until `/api/auth/me` reports it granted.
+ */
+ consentRequired?: boolean;
}
type DialogError =
@@ -85,8 +92,43 @@ type DialogError =
| { kind: "rateLimit" }
| { kind: "noProvider" }
| { kind: "budget" }
+ | { kind: "consent" }
+ | { kind: "unavailable" }
| { kind: "network"; message?: string };
+interface ExtractFailureBody {
+ error?: string | null;
+ meta?: { errorCode?: unknown } | null;
+}
+
+/**
+ * Classify a failed extract response. The refusal's `meta.errorCode` decides
+ * first, because a status is shared by different reasons: a 429 is either
+ * the per-minute limit or the spent daily budget, and a 403 is a missing
+ * consent, the operator's switch or somebody else's record.
+ */
+export function classifyExtractFailure(
+ status: number,
+ body: ExtractFailureBody | null,
+): DialogError {
+ const code =
+ typeof body?.meta?.errorCode === "string" ? body.meta.errorCode : null;
+ if (code === "coach.budget.exceeded") return { kind: "budget" };
+ if (code === "ai.provider.none") return { kind: "noProvider" };
+ if (code === "consent.ai.required") return { kind: "consent" };
+ if (
+ code !== null &&
+ (code.startsWith("assistant.disabled.") ||
+ code === "ai.record.notPermitted" ||
+ code === "module.disabled")
+ ) {
+ return { kind: "unavailable" };
+ }
+ if (status === 429) return { kind: "rateLimit" };
+ if (status === 503) return { kind: "noProvider" };
+ return { kind: "network", message: body?.error ?? undefined };
+}
+
const MAX_TEXT_LENGTH = 2000;
export function NaturalLanguageExtractor({
@@ -95,6 +137,7 @@ export function NaturalLanguageExtractor({
onPrefill,
locale,
endpoint = "/api/medications/extract",
+ consentRequired = false,
}: NaturalLanguageExtractorProps) {
const { t } = useTranslations();
const textareaId = useId();
@@ -143,23 +186,11 @@ export function NaturalLanguageExtractor({
signal: controller.signal,
});
- if (res.status === 429) {
- setError({ kind: "rateLimit" });
- return;
- }
- if (res.status === 503) {
- setError({ kind: "noProvider" });
- return;
- }
if (!res.ok) {
- const body = (await res.json().catch(() => null)) as {
- error?: string;
- } | null;
- if (body?.error && /budget/i.test(body.error)) {
- setError({ kind: "budget" });
- return;
- }
- setError({ kind: "network", message: body?.error });
+ const body = (await res
+ .json()
+ .catch(() => null)) as ExtractFailureBody | null;
+ setError(classifyExtractFailure(res.status, body));
return;
}
@@ -228,7 +259,7 @@ export function NaturalLanguageExtractor({