mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +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
@@ -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. */
|
||||
|
||||
Reference in new issue
Block a user