mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
48d97ef70d
commit
4449b34ac7
15 files changed
+466
-40
No files matched your search
@@ -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(),
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
data-slot="document-reading-consent"
|
||||
className={
|
||||
className ??
|
||||
"border-border flex flex-col items-start gap-2 rounded-lg border border-dashed px-3 py-2.5"
|
||||
}
|
||||
>
|
||||
<p className="text-foreground flex items-start gap-2 text-sm">
|
||||
<ShieldCheck className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
|
||||
<span>{t("documents.readingConsent.body")}</span>
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
disabled={grant.isPending}
|
||||
onClick={() => grant.mutate()}
|
||||
data-slot="document-reading-consent-grant"
|
||||
>
|
||||
{t("documents.readingConsent.grant")}
|
||||
</Button>
|
||||
{grant.isError ? (
|
||||
<p role="alert" className="text-destructive text-xs">
|
||||
{t("documents.readingConsent.error")}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* The medication text reader says why it could not read, from the refusal's
|
||||
* code. Before, a spent daily budget (429 `coach.budget.exceeded`) showed the
|
||||
* rate-limit sentence, and a switched-off reader or a missing consent showed
|
||||
* "check your connection".
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { classifyExtractFailure } from "../natural-language-extractor";
|
||||
|
||||
const envelope = (errorCode?: string, error = "refused") => ({
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={busy || overLimit}
|
||||
disabled={busy || overLimit || consentRequired}
|
||||
aria-busy={busy || undefined}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
>
|
||||
@@ -248,6 +279,7 @@ export function NaturalLanguageExtractor({
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-3" aria-busy={busy || undefined}>
|
||||
{consentRequired ? <DocumentReadingConsentPrompt /> : null}
|
||||
<div>
|
||||
<Label htmlFor={textareaId} className="sr-only">
|
||||
{t("medications.scheduling.naturalLanguage.title")}
|
||||
@@ -348,6 +380,10 @@ function resolveErrorMessage(
|
||||
return t("medications.scheduling.naturalLanguage.error.noProvider");
|
||||
case "budget":
|
||||
return t("medications.scheduling.naturalLanguage.error.budget");
|
||||
case "consent":
|
||||
return t("medications.scheduling.naturalLanguage.error.consent");
|
||||
case "unavailable":
|
||||
return t("medications.scheduling.naturalLanguage.error.unavailable");
|
||||
case "network":
|
||||
default:
|
||||
return t("medications.scheduling.naturalLanguage.error.network");
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAiCapability } from "@/hooks/use-ai-capability";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Bell,
|
||||
@@ -127,6 +128,14 @@ function WizardDialogShell({
|
||||
navigateOnCreate = true,
|
||||
}: MedicationWizardDialogProps) {
|
||||
const { t, locale } = useTranslations();
|
||||
// The "describe it in words" reader follows the `medicationExtract`
|
||||
// capability: offered while available, and while only the document-reading
|
||||
// consent is missing (the sheet asks for it). Otherwise the button is not
|
||||
// there at all, and the manual steps are the whole wizard.
|
||||
const medicationExtract = useAiCapability("medicationExtract");
|
||||
const extractOffered =
|
||||
medicationExtract.available ||
|
||||
medicationExtract.reason === "consent_required";
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -489,7 +498,7 @@ function WizardDialogShell({
|
||||
lastLabel={t("medications.wizard.nav.jumpLast")}
|
||||
srLabel={stepOf}
|
||||
/>
|
||||
{step === 1 && mode === "create" && (
|
||||
{step === 1 && mode === "create" && extractOffered && (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
@@ -612,7 +621,7 @@ function WizardDialogShell({
|
||||
{/* Natural-language extractor — same dialog the v1.5.3 page mounted.
|
||||
Only available on the create path; edit flows already know
|
||||
what the medication looks like. */}
|
||||
{mode === "create" && (
|
||||
{mode === "create" && extractOffered && (
|
||||
<NaturalLanguageExtractor
|
||||
open={nlOpen}
|
||||
onClose={() => setNlOpen(false)}
|
||||
@@ -620,6 +629,7 @@ function WizardDialogShell({
|
||||
applyPartial(extractorToWizardPartial(partial));
|
||||
}}
|
||||
locale={locale}
|
||||
consentRequired={medicationExtract.reason === "consent_required"}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { apiPost } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
/**
|
||||
* Grant the consent for reading documents (`ai_extraction`) from the place
|
||||
* that needs it.
|
||||
*
|
||||
* Sending a document, a lab report scan or a typed medication description to
|
||||
* an AI service outside this server needs its own receipt. The narrow kind
|
||||
* covers exactly that and never the Coach or AI analysis, so asking for it
|
||||
* where a document is being read asks for no more than the reader is about to
|
||||
* do. The tap on the prompt IS the consent act; the receipt records it with
|
||||
* its source, the same way the web settings record a full grant.
|
||||
*
|
||||
* On success `/api/auth/me` is refetched, because the capabilities it
|
||||
* publishes (`documentAi`, `labsOcr`, `medicationExtract`) are what every
|
||||
* reading surface renders from.
|
||||
*/
|
||||
export function useGrantDocumentReadingConsent() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationKey: queryKeys.aiExtractionConsentGrant(),
|
||||
mutationFn: async () => {
|
||||
const signedAt = new Date().toISOString();
|
||||
return apiPost("/api/consent/ai", {
|
||||
kind: "ai_extraction",
|
||||
signedAt,
|
||||
artefact: JSON.stringify({
|
||||
source: "web",
|
||||
kind: "ai_extraction",
|
||||
grantedAt: signedAt,
|
||||
note: "In-app affirmative consent to reading documents, granted via the web client.",
|
||||
}),
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.aiConsentReceipt("ai_extraction"),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -62,8 +62,6 @@ export const settingsKeys = {
|
||||
/** v1.11.0 — owner's clinician share links (Settings → Sharing). */
|
||||
shareLinks: () => ["share-links"] as const,
|
||||
|
||||
featureFlags: () => ["feature-flags"] as const,
|
||||
|
||||
/**
|
||||
* The account's standing AI consent receipt (`GET /api/consent/ai/latest`).
|
||||
* Keyed by kind because the endpoint answers per kind and a shared key
|
||||
@@ -71,4 +69,12 @@ export const settingsKeys = {
|
||||
*/
|
||||
aiConsentReceipt: (kind: string) =>
|
||||
["consent", "ai", "latest", kind] as const,
|
||||
|
||||
/**
|
||||
* Mutation key for granting the document-reading consent (`ai_extraction`)
|
||||
* from the place that asks for it: a document read, a lab scan, the
|
||||
* medication text reader.
|
||||
*/
|
||||
aiExtractionConsentGrant: () =>
|
||||
["consent", "ai", "grant", "ai_extraction"] as const,
|
||||
};
|
||||
Reference in new issue
Block a user