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

@@ -92,6 +92,14 @@ vi.mock("@/lib/jobs/document-index", () => ({
enqueueDocumentIndex: vi.fn().mockResolvedValue({ enqueued: true }),
}));
vi.mock("@/lib/jobs/document-summary", () => ({
enqueueDocumentSummary: vi.fn().mockResolvedValue({ enqueued: true }),
}));
vi.mock("@/lib/ai/capabilities/gate", async () =>
(
await import("@/__tests__/helpers/provider-order-mock")
).openCapabilityGateMock(),
);
vi.mock("@/lib/jobs/document-thumbnail", () => ({
enqueueDocumentThumbnail: vi.fn().mockResolvedValue({ enqueued: true }),
}));
@@ -129,6 +137,8 @@ import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { enqueueDocumentIndex } from "@/lib/jobs/document-index";
import { enqueueDocumentSummary } from "@/lib/jobs/document-summary";
import { getAiCapability } from "@/lib/ai/capabilities/gate";
import {
acquireDocumentUploadSlot,
DOCUMENT_UPLOAD_GLOBAL_CONCURRENCY,
@@ -547,3 +557,27 @@ describe("GET /api/documents/inbound (list)", () => {
expect(res.status).toBe(422);
});
});
describe("POST /api/documents/inbound — AI work on upload follows documentAi", () => {
it("enqueues the background summary when document reading is open", async () => {
const res = await post(mkUpload());
expect(res.status).toBe(201);
expect(getAiCapability).toHaveBeenCalledWith("documentAi");
expect(enqueueDocumentSummary).toHaveBeenCalledWith("user-1", "doc-1");
});
it("stores the document and indexes it, but enqueues no summary, with document reading closed", async () => {
vi.mocked(getAiCapability).mockResolvedValueOnce({
available: false,
reason: "operator_disabled",
onDeviceAllowed: false,
});
const res = await post(mkUpload());
// The upload is data: accepted whatever AI says.
expect(res.status).toBe(201);
expect(txCreate).toHaveBeenCalled();
// The index job still runs its provider-free text-layer path.
expect(enqueueDocumentIndex).toHaveBeenCalledWith("user-1", "doc-1");
expect(enqueueDocumentSummary).not.toHaveBeenCalled();
});
});