feat(ai): gate document, lab and medication extraction on their capabilities

Reading a stored document (suggest, summary, extract, index, chat,
reindex), scanning a lab report and turning medication text into a
schedule now answer under documentAi, labsOcr and medicationExtract. The
operator's master switch and the new reading-documents switch stop the
work and the egress; a module off for the record, a delegate inside
somebody else's record, and a missing receipt refuse with the capability
envelope. Existing no-provider codes keep their status.

The document pick and the labs pick re-check the capability for the
provider they picked, immediately before anything is sent, so a job
enqueued before a switch flipped finds no provider and falls back to the
provider-free text layer. Consent follows the document rule for all
three: any provider that leaves the machine needs an ai_extraction or
ai_full receipt, answered for the provider actually picked.

The fenced Coach chat and attachments answer under coach and documentAi.
Uploads are always stored and indexed; the background summary is only
enqueued while documentAi is open. The capability probes report the
capability beside the provider answer and keep a read offered when only
the receipt is missing. medications/extract keeps its 503 for no provider
and adds ai.provider.none, and its budget refusal gains the
coach.budget.exceeded code.
This commit is contained in:
Marc-André Bombeck committed 2026-09-24 08:44:57 +02:00
1 parent a19900c854
commit bd8ea9d86b
39 files changed
+1420 -376

No files matched your search

+4 -1
View File
@@ -4,7 +4,9 @@
* Cheap probe (no provider call) the Labs UI uses to decide whether to show
* the "Scan a report" affordance. Returns `{ available, reason, pdfSupported }`
* for the calling user's configured AI provider. The surface stays dark for
* users with no provider configured and no local-OCR opt-in.
* users with no provider configured and no local-OCR opt-in, and whenever the
* `labsOcr` capability is closed (operator switch, labs module, sharing grant);
* `ai` says which.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
@@ -25,6 +27,7 @@ export const GET = apiHandler(async () => {
mode: capability.mode,
reason: capability.reason,
pdfSupported: capability.pdfSupported,
aiReason: capability.ai.reason,
},
});
@@ -27,15 +27,14 @@ vi.mock("@/lib/db", () => ({
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/labs/ocr-capability", () => ({
resolveTextProvider: vi.fn(),
resolveVisionProvider: vi.fn(),
requireLabsOcrProvider: vi.fn(),
}));
vi.mock("@/lib/ai/capabilities/gate", () => ({
requireAiCapability: vi.fn(),
}));
vi.mock("@/lib/documents/rasterize-pdf", () => ({
rasterizePdf: vi.fn(),
}));
vi.mock("@/lib/ai/consent-guard", () => ({
assertConsentForChain: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
refundRateLimit: vi.fn().mockResolvedValue(undefined),
@@ -60,10 +59,9 @@ import { POST } from "../route";
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
import { requireAuth } from "@/lib/api-handler";
import { prisma } from "@/lib/db";
import {
resolveTextProvider,
resolveVisionProvider,
} from "@/lib/labs/ocr-capability";
import { requireLabsOcrProvider } from "@/lib/labs/ocr-capability";
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
import { AiUnavailableError } from "@/lib/ai/capabilities/refusal";
import { rasterizePdf } from "@/lib/documents/rasterize-pdf";
import { reserveBudget, reconcileSpend } from "@/lib/ai/coach/budget";
import { checkRateLimit, refundRateLimit } from "@/lib/rate-limit";
@@ -94,12 +92,14 @@ beforeEach(() => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
labsLocalOcrEnabled: true,
} as never);
vi.mocked(resolveTextProvider).mockResolvedValue({
chain: [{ providerType: "openai", instance: {} as never }],
pick: {
entry: { providerType: "openai", instance: {} as never },
providerType: "openai",
},
vi.mocked(requireAiCapability).mockResolvedValue({
available: true,
reason: null,
onDeviceAllowed: true,
});
vi.mocked(requireLabsOcrProvider).mockResolvedValue({
entry: { providerType: "openai", instance: {} as never },
providerType: "openai",
} as never);
vi.mocked(reserveBudget).mockResolvedValue({
allowed: true,
@@ -187,14 +187,10 @@ describe("POST /api/labs/ocr/extract — vision PDF rasterization", () => {
beforeEach(() => {
// A non-Anthropic vision provider (codex): no native PDF block, so the
// route must rasterize.
vi.mocked(resolveVisionProvider).mockResolvedValue({
chain: [{ providerType: "codex", instance: {} as never }],
localOcrEnabled: false,
pick: {
entry: { providerType: "codex", instance: {} as never },
providerType: "codex",
pdfSupported: false,
},
vi.mocked(requireLabsOcrProvider).mockResolvedValue({
entry: { providerType: "codex", instance: {} as never },
providerType: "codex",
pdfSupported: false,
} as never);
vi.mocked(reserveBudget).mockResolvedValue({
allowed: true,
@@ -334,3 +330,55 @@ describe("POST /api/labs/ocr/extract — the hourly scan bucket", () => {
expect(refundRateLimit).not.toHaveBeenCalled();
});
});
describe("POST /api/labs/ocr/extract — the labsOcr capability", () => {
it("refuses before anything is read when the operator turned document reading off", async () => {
vi.mocked(requireAiCapability).mockRejectedValue(
new AiUnavailableError("labsOcr", "operator_disabled"),
);
const error = await POST(textReq()).catch((e) => e);
expect(error).toBeInstanceOf(AiUnavailableError);
expect(error.meta.errorCode).toBe("assistant.disabled.documentAi");
expect(requireAiCapability).toHaveBeenCalledWith("labsOcr", {
pickDecides: true,
});
expect(requireLabsOcrProvider).not.toHaveBeenCalled();
expect(checkRateLimit).not.toHaveBeenCalled();
expect(runOcrExtraction).not.toHaveBeenCalled();
});
it("stops at the wire when the pick is refused, before any slot or budget is spent", async () => {
vi.mocked(requireLabsOcrProvider).mockRejectedValue(
new AiUnavailableError("labsOcr", "consent_required"),
);
const error = await POST(textReq()).catch((e) => e);
expect(error.meta).toEqual({
errorCode: "consent.ai.required",
capability: "labsOcr",
reason: "consent_required",
});
expect(requireLabsOcrProvider).toHaveBeenCalledWith("user-1", "text");
expect(checkRateLimit).not.toHaveBeenCalled();
expect(reserveBudget).not.toHaveBeenCalled();
expect(runOcrExtraction).not.toHaveBeenCalled();
});
it("asks for the vision pick on a multipart upload", async () => {
vi.mocked(requireLabsOcrProvider).mockRejectedValue(
new AiUnavailableError("labsOcr", "no_provider", null, {
errorCode: "labs.ocr.providerUnsupported",
}),
);
const form = new FormData();
form.append("file", new File([new Uint8Array([1])], "a.png"));
const error = await POST(
new Request("http://localhost/api/labs/ocr/extract", {
method: "POST",
body: form,
}),
).catch((e) => e);
expect(requireLabsOcrProvider).toHaveBeenCalledWith("user-1", "vision");
expect(error.status).toBe(422);
expect(error.meta.errorCode).toBe("labs.ocr.providerUnsupported");
});
});
+18 -35
View File
@@ -18,7 +18,10 @@
* `labsLocalOcrEnabled` preference.
*
* Guards mirror the Coach's discipline in both modes:
* requireAuth → resolve provider → assertConsentForChain → rate-limit
* requireAuth → requireAiCapability("labsOcr") → resolve provider and
* re-check the capability for that pick (`requireLabsOcrProvider`: the
* operator's switch, the labs module, and a document consent receipt for a
* pick that leaves the machine) → rate-limit
* (`LABS_OCR_LIMIT_PER_HOUR`, default 6/h) → reserveBudget → run extraction
* → reconcile budget. A slot is charged early so a 429 stays cheap, and it is
* handed back when the scan fails before the provider is called.
@@ -38,7 +41,7 @@ import {
sanitiseZodIssues,
} from "@/lib/api-response";
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
import { assertConsentForChain } from "@/lib/ai/consent-guard";
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
import {
buildDateKey,
reconcileSpend,
@@ -48,10 +51,7 @@ import {
} from "@/lib/ai/coach/budget";
import { prisma } from "@/lib/db";
import { rasterizePdf } from "@/lib/documents/rasterize-pdf";
import {
resolveTextProvider,
resolveVisionProvider,
} from "@/lib/labs/ocr-capability";
import { requireLabsOcrProvider } from "@/lib/labs/ocr-capability";
import { OcrExtractError, runOcrExtraction } from "@/lib/labs/ocr-extract";
import {
BodyTooLargeError,
@@ -94,6 +94,11 @@ function providerFailureMeta(error: unknown, mode?: "text") {
export const POST = apiHandler(async (request: Request) => {
const { user } = await requireAuth();
// Reading a lab report is model work in both modes: vision sends the image,
// text mode sends what the browser read off it. The provider and the consent
// receipt are answered by the pick below, for the provider actually used.
await requireAiCapability("labsOcr", { pickDecides: true });
// Dispatch on the body shape: a JSON body is the local-OCR text mode; a
// multipart body is the native-vision image/PDF mode.
const contentType = request.headers.get("content-type") ?? "";
@@ -124,21 +129,10 @@ async function handleTextExtract(
});
}
// Any configured provider can structure the text.
const { chain, pick } = await resolveTextProvider(userId);
if (!pick) {
annotate({
action: { name: "labs.ocr.providerUnsupported" },
meta: { chainLength: chain.length, mode: "text" },
});
return apiError("No AI provider is configured", 422, {
errorCode: "labs.ocr.providerUnsupported",
});
}
// Consent gate — only the server-managed key trips this; BYOK / local / codex
// egress is the user's own act (the toggle is the consent for local OCR).
await assertConsentForChain({ userId, chain, surface: "insights" });
// Any configured provider can structure the text. The pick is re-checked
// against `labsOcr` for exactly that provider; one that leaves the machine
// needs a document consent receipt.
const pick = await requireLabsOcrProvider(userId, "text");
const rl = await checkLabsOcrRateLimit(userId);
if (!rl.allowed) {
@@ -244,20 +238,9 @@ async function handleVisionExtract(
request: Request,
userId: string,
): Promise<Response> {
// 1. Resolve a vision-capable provider. 422 when none is configured.
const { chain, pick } = await resolveVisionProvider(userId);
if (!pick) {
annotate({
action: { name: "labs.ocr.providerUnsupported" },
meta: { chainLength: chain.length },
});
return apiError("No vision-capable AI provider is configured", 422, {
errorCode: "labs.ocr.providerUnsupported",
});
}
// 2. Consent gate — server-managed egress of health data requires a receipt.
await assertConsentForChain({ userId, chain, surface: "insights" });
// 1-2. Resolve a vision-capable provider (422 when none is configured) and
// re-check `labsOcr` for it, consent receipt included.
const pick = await requireLabsOcrProvider(userId, "vision");
// 3. Rate-limit (vision calls are costly).
const rl = await checkLabsOcrRateLimit(userId);