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

@@ -8,6 +8,7 @@ import { NextRequest } from "next/server";
vi.mock("@/lib/modules/gate", () => ({
requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }),
MODULE_DISABLED_ERROR_CODE: "module.disabled",
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({ allowed: true }),
@@ -28,6 +29,11 @@ vi.mock("@/lib/auth/audit", () => ({
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db", () => ({ prisma: {} }));
vi.mock("@/lib/ai/capabilities/gate", async () =>
(
await import("@/__tests__/helpers/provider-order-mock")
).openCapabilityGateMock(),
);
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
@@ -43,6 +49,8 @@ vi.mock("next/headers", () => ({
import { POST } from "../route";
import { DELETE } from "../[documentId]/route";
import { getSession } from "@/lib/auth/session";
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
import { AiUnavailableError } from "@/lib/ai/capabilities/refusal";
import {
attachDocument,
detachDocument,
@@ -211,3 +219,50 @@ describe("DELETE detach", () => {
expect(body.data.fenced).toBe(true);
});
});
describe("attachments — the coach and documentAi capabilities", () => {
it("refuses an attach when the Coach is off for the record", async () => {
vi.mocked(requireAiCapability).mockRejectedValueOnce(
new AiUnavailableError("coach", "user_disabled", "coach"),
);
const res = await POST(
postReq({ documentId: "doc-a" }) as never,
postCtx() as never,
);
expect(res.status).toBe(403);
expect(await code(res)).toBe("module.disabled");
expect(attachDocument).not.toHaveBeenCalled();
});
it("checks both capabilities on attach", async () => {
vi.mocked(fetchConversationAttachmentState).mockResolvedValue(null);
await POST(postReq({ documentId: "doc-a" }) as never, postCtx() as never);
expect(requireAiCapability).toHaveBeenCalledWith("coach", {
pickDecides: true,
});
expect(requireAiCapability).toHaveBeenCalledWith("documentAi", {
pickDecides: true,
});
});
it("lets a detach through with every capability closed: it removes the person's own data", async () => {
vi.mocked(requireAiCapability).mockRejectedValue(
new AiUnavailableError("coach", "operator_disabled"),
);
vi.mocked(fetchConversationAttachmentState).mockResolvedValue({
id: "conv-1",
documentScoped: true,
messageCount: 0,
attachmentIds: ["doc-a"],
});
const res = await DELETE(
new NextRequest(
new URL("http://localhost/api/insights/chat/conv-1/attachments/doc-a"),
{ method: "DELETE" },
) as never,
delCtx() as never,
);
expect(res.status).not.toBe(403);
expect(requireAiCapability).not.toHaveBeenCalled();
});
});
@@ -9,6 +9,7 @@
*/
import { type NextRequest } from "next/server";
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
@@ -48,6 +49,12 @@ export const POST = apiHandler(
const gate = await requireModuleEnabled(userId, "inboundDocuments");
if (!gate.enabled) return gate.response;
// Attaching prepares a fenced turn, which is Coach work over documents.
// Nothing is sent here, so the provider and the consent receipt wait for
// the turn. Detaching (DELETE) removes the person's own data and stays open.
await requireAiCapability("coach", { pickDecides: true });
await requireAiCapability("documentAi", { pickDecides: true });
const { id: conversationId } = await params;
const rl = await checkRateLimit(
@@ -10,6 +10,11 @@ import { NextRequest } from "next/server";
*/
vi.mock("@/lib/db", () => ({ prisma: {} }));
vi.mock("@/lib/ai/capabilities/gate", async () =>
(
await import("@/__tests__/helpers/provider-order-mock")
).openCapabilityGateMock(),
);
vi.mock("@/lib/modules/gate", () => ({
requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }),
}));
@@ -54,6 +59,8 @@ vi.mock("next/headers", () => ({
import { POST } from "../route";
import { getSession } from "@/lib/auth/session";
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
import { AiUnavailableError } from "@/lib/ai/capabilities/refusal";
import {
createConversation,
fetchConversationWithMessages,
@@ -260,3 +267,50 @@ describe("fenced route — refusal", () => {
expect(streamFencedReply).not.toHaveBeenCalled();
});
});
describe("fenced route — the coach and documentAi capabilities", () => {
it("answers under both, leaving provider and consent to the pick", async () => {
await POST(req({ message: "hi", attachmentIds: ["doc-a"] }) as never);
expect(requireAiCapability).toHaveBeenCalledWith("coach", {
pickDecides: true,
});
expect(requireAiCapability).toHaveBeenCalledWith("documentAi", {
pickDecides: true,
});
// The turn re-checks the Coach for the picked provider too.
expect(streamFencedReply).toHaveBeenCalledWith(
expect.objectContaining({ alsoRequires: ["coach"] }),
);
});
it("refuses with the Coach's code before any conversation is created", async () => {
vi.mocked(requireAiCapability).mockRejectedValueOnce(
new AiUnavailableError("coach", "operator_disabled"),
);
const res = await POST(
req({ message: "hi", attachmentIds: ["doc-a"] }) as never,
);
expect(res.status).toBe(403);
expect(await errorCode(res)).toBe("assistant.disabled.coach");
expect(createConversation).not.toHaveBeenCalled();
expect(streamFencedReply).not.toHaveBeenCalled();
});
it("refuses when document reading is off even with the Coach on", async () => {
vi.mocked(requireAiCapability)
.mockResolvedValueOnce({
available: true,
reason: null,
onDeviceAllowed: true,
})
.mockRejectedValueOnce(
new AiUnavailableError("documentAi", "operator_disabled"),
);
const res = await POST(
req({ message: "hi", attachmentIds: ["doc-a"] }) as never,
);
expect(res.status).toBe(403);
expect(await errorCode(res)).toBe("assistant.disabled.documentAi");
expect(streamFencedReply).not.toHaveBeenCalled();
});
});
+10 -1
View File
@@ -7,13 +7,15 @@
* route via `@/lib/documents/fenced-chat` so the two cannot drift.
*
* THE FENCE (see `fenced-chat.ts` for the full posture): NO tools, NO health
* snapshot; every document fenced as untrusted DATA; per-document egress consent;
* snapshot; every document fenced as untrusted DATA; the `coach` and
* `documentAi` capabilities, re-checked for the picked provider;
* numeric grounding over the LIVE attachment union only. A plain tool
* conversation 404s here (`documentScoped: true` in the fetch), so a fenced turn
* can never be appended into a tool thread's history.
*/
import { type NextRequest } from "next/server";
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
import {
apiError,
@@ -56,6 +58,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
const gate = await requireModuleEnabled(userId, "inboundDocuments");
if (!gate.enabled) return gate.response;
// A fenced turn is a Coach turn that reads documents: both capabilities must
// be open. The provider and the consent receipt are answered by the pick,
// for the provider actually used (a missing provider stays an SSE frame).
await requireAiCapability("coach", { pickDecides: true });
await requireAiCapability("documentAi", { pickDecides: true });
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
@@ -224,5 +232,6 @@ export const POST = apiHandler(async (request: NextRequest) => {
contractLocale,
locale,
signal: request.signal,
alsoRequires: ["coach"],
});
});