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

@@ -0,0 +1,67 @@
/**
* A `@/lib/documents/provider-order` mock for the vault route suites.
*
* The suites steer the document pick through `resolveDocumentVisionProvider` /
* `resolveDocumentTextProvider` (a `{ chain, pick }` per case). The routes call
* the `require*` forms, which throw instead of returning an empty pick; these
* are built on the two mocked resolvers so a case that returns no pick sees
* the route answer the vault's own `documents.inbound.providerUnsupported`
* 422, and a case that returns a `withheld` refusal sees it thrown.
*
* Use inside a factory, where only a dynamic import is allowed:
*
* vi.mock("@/lib/documents/provider-order", async () =>
* (await import("@/__tests__/helpers/provider-order-mock")).providerOrderMock(),
* );
*/
import { vi } from "vitest";
import { AiUnavailableError } from "@/lib/ai/capabilities/refusal";
interface MockPick {
pick: unknown;
withheld?: AiUnavailableError | null;
}
async function required(resolved: Promise<MockPick> | MockPick) {
const result = await resolved;
if (result?.withheld) throw result.withheld;
if (!result?.pick) {
throw new AiUnavailableError("documentAi", "no_provider", null, {
errorCode: "documents.inbound.providerUnsupported",
status: 422,
});
}
return result.pick;
}
export function providerOrderMock() {
const resolveDocumentVisionProvider = vi.fn();
const resolveDocumentTextProvider = vi.fn();
return {
resolveDocumentVisionProvider,
resolveDocumentTextProvider,
requireDocumentVisionProvider: vi.fn((userId: string) =>
required(resolveDocumentVisionProvider(userId)),
),
requireDocumentTextProvider: vi.fn((userId: string) =>
required(resolveDocumentTextProvider(userId)),
),
};
}
/** The capability gate, open. Pair with `vi.mock("@/lib/ai/capabilities/gate", …)`. */
export function openCapabilityGateMock() {
return {
requireAiCapability: vi.fn(async () => ({
available: true,
reason: null,
onDeviceAllowed: true,
})),
getAiCapability: vi.fn(async () => ({
available: true,
reason: null,
onDeviceAllowed: true,
})),
};
}