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

@@ -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",
);
});
});
@@ -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" ? (
<DocumentReadingConsentPrompt />
) : null}
{canManageDocuments && documentAi.available && (
<DocumentAiSection
aiEnabled={aiEnabled}
autoReadEnabled={autoReadEnabled}
@@ -200,8 +200,19 @@ export function documentAiErrorKey(err: unknown): string {
return "documents.assist.errorProvider";
case "documents.inbound.notIndexed":
return "documents.assist.errorNotIndexed";
case "consent.ai.required":
return "documents.assist.errorConsent";
case "ai.provider.none":
return "documents.assist.errorProvider";
case "ai.record.notPermitted":
case "module.disabled":
return "documents.assist.errorUnavailable";
default:
return "documents.assist.errorGeneric";
// The operator switched reading off (`assistant.disabled.documentAi`,
// or the master `assistant.disabled.enabled`).
return code.startsWith("assistant.disabled.")
? "documents.assist.errorUnavailable"
: "documents.assist.errorGeneric";
}
}
return "documents.assist.errorGeneric";