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

@@ -64,7 +64,7 @@ const RESOLUTION_HELPERS = [
"resolveProviderForTest",
] as const;
/** The two sanctioned consent APIs. Either satisfies the guard. */
/** The sanctioned consent APIs in the consent guard. Any satisfies the guard. */
const CONSENT_HELPERS = [
// Chain-shaped: throw-form and predicate-form.
"assertConsentForChain",
@@ -108,9 +108,23 @@ function resolvesAProvider(source: string): boolean {
return RESOLUTION_HELPERS.some((h) => imported.includes(h));
}
/**
* The capability re-check at the wire. It answers the consent question for the
* providers a caller is about to send to, from the receipt kinds the capability
* table declares, so it is a consent helper in its own right.
*/
const CAPABILITY_EGRESS_HELPERS = [
"assertAiEgress",
"aiEgressRefusal",
] as const;
function importsAConsentHelper(source: string): boolean {
const imported = importedNamesFrom(source, "@/lib/ai/consent-guard");
return CONSENT_HELPERS.some((h) => imported.includes(h));
const egress = importedNamesFrom(source, "@/lib/ai/capabilities/egress");
return (
CONSENT_HELPERS.some((h) => imported.includes(h)) ||
CAPABILITY_EGRESS_HELPERS.some((h) => egress.includes(h))
);
}
/**
@@ -125,8 +139,6 @@ function importsAConsentHelper(source: string): boolean {
*/
const ALLOWLIST: Record<string, string> = {
// --- Capability / config probes: resolve a chain, never call it. ---
"lib/labs/ocr-capability.ts":
"Resolves the chain to REPORT which entry could do vision/text OCR (mode, reason, pdfSupported). Makes no completion call. Every consumer that then egresses a document is gated at its own call site with assertDocumentEgressConsent.",
"app/api/insights/provider-chain/route.ts":
"Lists the user's configured chain and which entry is active, for the settings UI. Constructs clients but never calls generateCompletion. No health data in the request or the response.",
"app/api/insights/comprehensive/route.ts":
@@ -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,
})),
};
}