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

@@ -22,9 +22,11 @@ vi.mock("@/lib/api-handler", async () => {
};
});
vi.mock("@/lib/feature-flags", () => ({
requireAssistantSurface: vi.fn(async () => undefined),
AssistantDisabledError: class extends Error {},
vi.mock("@/lib/ai/capabilities/gate", () => ({
requireAiCapability: vi.fn(),
}));
vi.mock("@/lib/ai/capabilities/egress", () => ({
assertAiEgress: vi.fn(),
}));
vi.mock("@/lib/rate-limit", () => ({
@@ -53,14 +55,6 @@ vi.mock("@/lib/ai/provider", () => ({
resolveProviderChain: vi.fn(async () => []),
}));
// v1.12.1 — free-text medication extraction egresses PHI to the operator's
// server-managed key, so it now passes through the consent gate. Mock it to a
// no-op here; the gate's own fail-closed behaviour is covered by
// `consent-guard.test.ts`.
vi.mock("@/lib/ai/consent-guard", () => ({
assertConsentForChain: vi.fn(async () => undefined),
}));
vi.mock("@/lib/ai/provider-runner", async () => {
const actual = await vi.importActual<
typeof import("@/lib/ai/provider-runner")
@@ -93,7 +87,9 @@ import {
runRawCompletionWithFallback,
AllProvidersFailedError,
} from "@/lib/ai/provider-runner";
import { assertConsentForChain } from "@/lib/ai/consent-guard";
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
import { assertAiEgress } from "@/lib/ai/capabilities/egress";
import { AiUnavailableError } from "@/lib/ai/capabilities/refusal";
function postReq(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/medications/extract", {
@@ -110,6 +106,12 @@ const AUTH_OK = {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requireAiCapability).mockResolvedValue({
available: true,
reason: null,
onDeviceAllowed: true,
});
vi.mocked(assertAiEgress).mockResolvedValue(undefined);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
@@ -208,10 +210,15 @@ describe("POST /api/medications/extract — happy path", () => {
doseUnit: "mg",
cadenceKind: "everyNWeeks",
});
// The PHI free-text egress is gated on an active consent receipt for the
// coach surface before the provider chain runs.
expect(assertConsentForChain).toHaveBeenCalledWith(
expect.objectContaining({ surface: "coach" }),
// The PHI free-text egress is re-checked against the capability for the
// whole chain the runner may cascade through, before it runs.
expect(assertAiEgress).toHaveBeenCalledWith(
"medicationExtract",
"user-1",
expect.any(Array),
);
expect(vi.mocked(assertAiEgress).mock.invocationCallOrder[0]).toBeLessThan(
vi.mocked(runRawCompletionWithFallback).mock.invocationCallOrder[0]!,
);
});
@@ -300,6 +307,8 @@ describe("POST /api/medications/extract — daily budget", () => {
expect(res.status).toBe(429);
const body = await res.json();
expect(body.error).toMatch(/budget/i);
// The code a client branches on, the same string the body always carried.
expect(body.meta).toEqual({ errorCode: "coach.budget.exceeded" });
// The refusal happens BEFORE any upstream spend.
expect(runRawCompletionWithFallback).not.toHaveBeenCalled();
});
@@ -374,8 +383,19 @@ describe("POST /api/medications/extract — validation + provider errors", () =>
type: "none",
} as never);
const res = await POST(postReq({ text: "Mounjaro 5mg weekly" }) as never);
expect(res.status).toBe(503);
// The status stays 503 (a shipped client may branch on it) and gains the
// code that says what it means.
const error = await POST(
postReq({ text: "Mounjaro 5mg weekly" }) as never,
).catch((e) => e);
expect(error).toBeInstanceOf(AiUnavailableError);
expect(error.status).toBe(503);
expect(error.meta).toEqual({
errorCode: "ai.provider.none",
capability: "medicationExtract",
reason: "no_provider",
});
expect(runRawCompletionWithFallback).not.toHaveBeenCalled();
});
it("returns 502 when the provider returns unparseable JSON", async () => {
@@ -395,3 +415,39 @@ describe("POST /api/medications/extract — validation + provider errors", () =>
expect(res.status).toBe(502);
});
});
describe("POST /api/medications/extract — the medicationExtract capability", () => {
it("answers under medicationExtract, keeping 503 for no provider", async () => {
vi.mocked(requireAuth).mockResolvedValue(AUTH_OK as never);
await POST(postReq({ text: "5mg weekly" }) as never).catch(() => null);
expect(requireAiCapability).toHaveBeenCalledWith("medicationExtract", {
noProvider: { errorCode: "ai.provider.none", status: 503 },
});
});
it("refuses before the body is read when the operator turned it off", async () => {
vi.mocked(requireAuth).mockResolvedValue(AUTH_OK as never);
vi.mocked(requireAiCapability).mockRejectedValueOnce(
new AiUnavailableError("medicationExtract", "operator_disabled"),
);
const error = await POST(postReq({ text: "5mg weekly" }) as never).catch(
(e) => e,
);
expect(error.meta.errorCode).toBe("assistant.disabled.documentAi");
expect(checkRateLimit).not.toHaveBeenCalled();
expect(runRawCompletionWithFallback).not.toHaveBeenCalled();
});
it("stops at the wire without reserving budget or calling a provider", async () => {
vi.mocked(requireAuth).mockResolvedValue(AUTH_OK as never);
vi.mocked(assertAiEgress).mockRejectedValueOnce(
new AiUnavailableError("medicationExtract", "consent_required"),
);
const error = await POST(postReq({ text: "5mg weekly" }) as never).catch(
(e) => e,
);
expect(error.meta.errorCode).toBe("consent.ai.required");
expect(reserveBudget).not.toHaveBeenCalled();
expect(runRawCompletionWithFallback).not.toHaveBeenCalled();
});
});
+53 -14
View File
@@ -24,9 +24,16 @@
* Behaviour:
*
* 1. requireAuth() — cookie OR Bearer.
* 2. requireAssistantSurface("coach") — operator can disable.
* 2. requireAiCapability("medicationExtract") — the operator's "Reading
* documents" switch (and the master), provider-work authority for the
* record, a configured provider (503 `ai.provider.none` otherwise; the
* status predates the envelope and stays until no shipped client reads
* it), and a document-class consent receipt: free-text medication input
* is health data, and any provider that leaves the machine needs one.
* 3. checkRateLimit(...) — 10 requests / 5 min / user.
* 4. resolveProviderChain() — fall back through providers.
* 4. resolveProviderChain() — fall back through providers,
* then `assertAiEgress` re-checks the capability for exactly that chain
* immediately before anything is sent.
* 5. reserveBudget(resolveDailyCap(chain)) — atomic daily token ceiling,
* capped against the COST OWNER: a self-hoster on their own key or a
* local model is measured against the user-plan ceiling, not the
@@ -56,7 +63,13 @@ import {
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { assertAiEgress } from "@/lib/ai/capabilities/egress";
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
import {
AiUnavailableError,
type NoProviderRefusal,
} from "@/lib/ai/capabilities/refusal";
import { AI_PROVIDER_NONE_ERROR_CODE } from "@/lib/ai/capabilities/types";
import {
AllProvidersFailedError,
@@ -64,7 +77,6 @@ import {
} from "@/lib/ai/provider-runner";
import { resolveProvider, resolveProviderChain } from "@/lib/ai/provider";
import { singleUserTurn } from "@/lib/ai/types";
import { assertConsentForChain } from "@/lib/ai/consent-guard";
import {
buildDateKey,
@@ -80,6 +92,19 @@ import {
type MedicationExtractionResult,
} from "@/lib/ai/coach/medication-extract-prompt";
/**
* How the extractor refuses with no provider. 503 predates the capability
* envelope; it stays until no shipped client is known to branch on it, and the
* code says what it means either way.
*/
const MEDICATION_EXTRACT_NO_PROVIDER: NoProviderRefusal = {
errorCode: AI_PROVIDER_NONE_ERROR_CODE,
status: 503,
};
/** The budget refusal's code; the same string the body always carried. */
const MEDICATION_EXTRACT_BUDGET_CODE = "coach.budget.exceeded";
/** Hard cap on the free-text payload; mirrors `coachChatRequestSchema.message`. */
const MAX_TEXT_LENGTH = 2000;
@@ -128,10 +153,12 @@ function parseModelJson(content: string): unknown | null {
async function handleExtract(request: NextRequest): Promise<Response> {
const auth = await requireAuth();
// Same operator-disable gate the Coach SSE route honours — when
// "coach" is off, the overlay also disappears so the wizard simply
// falls back to the manual path.
await requireAssistantSurface("coach");
// Medication text extraction is its own capability under the operator's
// "Reading documents" switch. When it is closed the wizard keeps its
// manual path; nothing here is data the wizard needs.
await requireAiCapability("medicationExtract", {
noProvider: MEDICATION_EXTRACT_NO_PROVIDER,
});
const userId = auth.user.id;
@@ -178,15 +205,25 @@ async function handleExtract(request: NextRequest): Promise<Response> {
const legacy = await resolveProvider(userId);
if (legacy.type === "none") {
annotate({ action: { name: "medications.extract.no-provider" } });
return apiError("No AI provider configured", 503);
throw new AiUnavailableError(
"medicationExtract",
"no_provider",
null,
MEDICATION_EXTRACT_NO_PROVIDER,
);
}
chain.push({ providerType: "admin-openai", instance: legacy });
}
// Free-text medication input is PHI. If the resolved chain could egress
// via the operator's server-managed key, an active consent receipt is
// required first — same gate as the Coach (which shares this chain).
await assertConsentForChain({ userId, chain, surface: "coach" });
// The wire. The runner may cascade through every entry, so each one is
// named: any entry that leaves the machine needs a document-class consent
// receipt, and a switch turned off since the gate above answered stops the
// call here.
await assertAiEgress(
"medicationExtract",
userId,
chain.map((entry) => entry.providerType),
);
const today = todayOverride ?? buildDateKey();
const { systemPrompt, userPrompt } = buildMedicationExtractionPrompt({
@@ -219,7 +256,9 @@ async function handleExtract(request: NextRequest): Promise<Response> {
action: { name: "medications.extract.budget-exceeded" },
meta: { totalAfter: reservation.totalAfter },
});
return apiError("coach.budget.exceeded", 429);
return apiError(MEDICATION_EXTRACT_BUDGET_CODE, 429, {
errorCode: MEDICATION_EXTRACT_BUDGET_CODE,
});
}
let completion;