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

@@ -34,17 +34,13 @@ vi.mock("@/lib/documents/content-index", () => ({
vi.mock("@/lib/documents/provider-order", () => ({
resolveDocumentTextProvider: vi.fn(),
}));
vi.mock("@/lib/ai/consent-guard", () => ({
assertDocumentEgressConsent: vi.fn().mockResolvedValue(undefined),
ConsentRequiredError: class ConsentRequiredError extends Error {
errorCode = "consent.ai.required" as const;
surface: string;
constructor(surface: string) {
super("consent required");
this.surface = surface;
this.name = "ConsentRequiredError";
}
},
vi.mock("@/lib/ai/capabilities/gate", async () =>
(
await import("@/__tests__/helpers/provider-order-mock")
).openCapabilityGateMock(),
);
vi.mock("@/lib/ai/capabilities/egress", () => ({
assertAiEgress: vi.fn(),
}));
vi.mock("@/lib/ai/coach/budget", () => ({
buildDateKey: vi.fn(() => "2026-07-07"),
@@ -94,10 +90,9 @@ import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
import { loadDocumentChatText } from "@/lib/documents/content-index";
import { resolveDocumentTextProvider } from "@/lib/documents/provider-order";
import {
assertDocumentEgressConsent,
ConsentRequiredError,
} 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";
import { reserveBudget } from "@/lib/ai/coach/budget";
import { runStreamingRawCompletionWithFallback } from "@/lib/ai/provider-runner";
import {
@@ -162,7 +157,7 @@ beforeEach(() => {
allowed: true,
reserved: 600,
} as never);
vi.mocked(assertDocumentEgressConsent).mockResolvedValue(undefined);
vi.mocked(assertAiEgress).mockResolvedValue(undefined);
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue({
id: "doc-1",
@@ -413,9 +408,44 @@ describe("POST — numeric grounding", () => {
});
describe("POST — consent / budget gating", () => {
it("403s when document egress consent is missing", async () => {
vi.mocked(assertDocumentEgressConsent).mockRejectedValue(
new ConsentRequiredError("insights"),
it("403s when the pick needs a consent receipt the record does not hold", async () => {
vi.mocked(resolveDocumentTextProvider).mockResolvedValue({
chain: [],
pick: null,
withheld: new AiUnavailableError("documentAi", "consent_required"),
} as never);
const res = await POST(
req("doc-1", { message: "hi" }) as never,
ctx("doc-1") as never,
);
expect(res.status).toBe(403);
const body = await res.json();
expect(body.meta).toEqual({
errorCode: "consent.ai.required",
capability: "documentAi",
reason: "consent_required",
});
expect(runStreamingRawCompletionWithFallback).not.toHaveBeenCalled();
expect(appendMessage).not.toHaveBeenCalled();
});
it("answers under documentAi alone: the vault's chat asks nothing of the Coach", async () => {
await frames(
await POST(
req("doc-1", { message: "hi" }) as never,
ctx("doc-1") as never,
),
);
expect(requireAiCapability).toHaveBeenCalledTimes(1);
expect(requireAiCapability).toHaveBeenCalledWith("documentAi", {
pickDecides: true,
});
expect(assertAiEgress).not.toHaveBeenCalled();
});
it("refuses before loading anything when document reading is off", async () => {
vi.mocked(requireAiCapability).mockRejectedValueOnce(
new AiUnavailableError("documentAi", "operator_disabled"),
);
const res = await POST(
req("doc-1", { message: "hi" }) as never,
@@ -423,7 +453,8 @@ describe("POST — consent / budget gating", () => {
);
expect(res.status).toBe(403);
const body = await res.json();
expect(body.meta?.errorCode ?? body.errorCode).toBe("consent.ai.required");
expect(body.meta.errorCode).toBe("assistant.disabled.documentAi");
expect(loadDocumentChatText).not.toHaveBeenCalled();
expect(runStreamingRawCompletionWithFallback).not.toHaveBeenCalled();
});
@@ -23,6 +23,7 @@
*/
import { type NextRequest } from "next/server";
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
import {
apiError,
@@ -76,6 +77,11 @@ export const POST = apiHandler(
const gate = await requireModuleEnabled(userId, "inboundDocuments");
if (!gate.enabled) return gate.response;
// A turn sends the document's text to a model. The provider and the
// consent receipt are answered by the pick, for the provider actually used.
// Reading the history (GET) is the person's own data and stays open.
await requireAiCapability("documentAi", { pickDecides: true });
const { id } = await params;
const document = await loadOwnedDocument(userId, id);
if (!document) {
@@ -213,6 +219,7 @@ export const POST = apiHandler(
contractLocale,
locale,
signal: request.signal,
alsoRequires: [],
});
},
);
@@ -56,14 +56,14 @@ vi.mock("@/lib/labs/ocr-upload", () => ({
detectOcrMimeType: vi.fn(() => "image/png"),
}));
vi.mock("@/lib/documents/provider-order", () => ({
resolveDocumentVisionProvider: vi.fn(),
resolveDocumentTextProvider: vi.fn(),
}));
vi.mock("@/lib/ai/consent-guard", () => ({
assertDocumentEgressConsent: vi.fn().mockResolvedValue(undefined),
ConsentRequiredError: class ConsentRequiredError extends Error {},
}));
vi.mock("@/lib/documents/provider-order", async () =>
(await import("@/__tests__/helpers/provider-order-mock")).providerOrderMock(),
);
vi.mock("@/lib/ai/capabilities/gate", async () =>
(
await import("@/__tests__/helpers/provider-order-mock")
).openCapabilityGateMock(),
);
vi.mock("@/lib/ai/coach/budget", () => ({
buildDateKey: vi.fn(() => "2026-06-27"),
reserveBudget: vi.fn().mockResolvedValue({ allowed: true, reserved: 1 }),
@@ -117,6 +117,8 @@ import {
resolveDocumentTextProvider,
resolveDocumentVisionProvider,
} from "@/lib/documents/provider-order";
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
import { AiUnavailableError } from "@/lib/ai/capabilities/refusal";
const tx = (
prisma as unknown as {
@@ -323,3 +325,39 @@ describe("POST /api/documents/inbound/[id]/extract — stored mode", () => {
expect(runInboundExtraction).not.toHaveBeenCalled();
});
});
describe("POST /api/documents/inbound/[id]/extract — the documentAi capability", () => {
it("refuses with the operator's code before the document is loaded", async () => {
vi.mocked(requireAiCapability).mockRejectedValueOnce(
new AiUnavailableError("documentAi", "operator_disabled"),
);
const res = await POST(visionReq("doc-1") as never, ctx("doc-1") as never);
expect(res.status).toBe(403);
const body = await res.json();
expect(body.meta).toEqual({
errorCode: "assistant.disabled.documentAi",
capability: "documentAi",
reason: "operator_disabled",
});
expect(prisma.inboundDocument.findFirst).not.toHaveBeenCalled();
expect(resolveDocumentVisionProvider).not.toHaveBeenCalled();
expect(runInboundExtraction).not.toHaveBeenCalled();
});
it("stops at the wire when the picked provider needs a receipt, before any slot is charged", async () => {
vi.mocked(resolveDocumentTextProvider).mockResolvedValue({
chain: [],
pick: null,
withheld: new AiUnavailableError("documentAi", "consent_required"),
} as never);
vi.mocked(loadDocumentChatText).mockResolvedValue({
text: "Glucose 95 mg/dL",
} as never);
const res = await POST(storedReq("doc-1") as never, ctx("doc-1") as never);
expect(res.status).toBe(403);
const body = await res.json();
expect(body.meta.errorCode).toBe("consent.ai.required");
expect(checkRateLimit).not.toHaveBeenCalled();
expect(runInboundExtraction).not.toHaveBeenCalled();
});
});
@@ -4,8 +4,9 @@
* Extraction is no longer part of upload: a document is stored first
* (provider-free), and THIS route is the explicit, user-triggered enhancement.
* It carries the entire ingest gauntlet that upload used to run inline —
* resolve document provider (local-first, codex last) → assertDocumentEgressConsent
* → rate-limit → reserveBudget → runInboundExtraction → reconcileSpend → stage
* requireAiCapability("documentAi") → resolve document provider (local-first,
* codex last) and re-check the capability for that pick, consent receipt
* included → rate-limit → reserveBudget → runInboundExtraction → reconcileSpend → stage
* facts — but now against a row that already exists. Absent a provider this 422s
* the ENHANCEMENT only; the stored document is untouched and remains filed.
*
@@ -36,7 +37,7 @@ import {
sanitiseZodIssues,
} from "@/lib/api-response";
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard";
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
import {
buildDateKey,
reconcileSpend,
@@ -58,8 +59,8 @@ import {
serialiseDocumentDetail,
} from "@/lib/documents/store";
import {
resolveDocumentTextProvider,
resolveDocumentVisionProvider,
requireDocumentTextProvider,
requireDocumentVisionProvider,
} from "@/lib/documents/provider-order";
import {
checkDocumentAiRateLimit,
@@ -139,6 +140,10 @@ export const POST = apiHandler(
const gate = await requireModuleEnabled(user.id, "inboundDocuments");
if (!gate.enabled) return gate.response;
// Reading a document is model work. The provider and the consent receipt
// are answered by the pick below, for the provider actually used.
await requireAiCapability("documentAi", { pickDecides: true });
const { id } = await params;
const document = await prisma.inboundDocument.findFirst({
where: { id, userId: user.id, deletedAt: null },
@@ -220,18 +225,7 @@ async function handleTextExtract(
});
}
const { pick } = await resolveDocumentTextProvider(userId);
if (!pick) {
return apiError("No AI provider is configured", 422, {
errorCode: "documents.inbound.providerUnsupported",
});
}
await assertDocumentEgressConsent({
userId,
providerType: pick.providerType,
surface: "insights",
});
const pick = await requireDocumentTextProvider(userId);
const rl = await checkDocumentAiRateLimit(userId);
if (!rl.allowed) return documentAiRateLimited(rl);
@@ -337,18 +331,7 @@ async function handleStoredExtract(
});
}
const { pick } = await resolveDocumentTextProvider(userId);
if (!pick) {
return apiError("No AI provider is configured", 422, {
errorCode: "documents.inbound.providerUnsupported",
});
}
await assertDocumentEgressConsent({
userId,
providerType: pick.providerType,
surface: "insights",
});
const pick = await requireDocumentTextProvider(userId);
const rl = await checkDocumentAiRateLimit(userId);
if (!rl.allowed) return documentAiRateLimited(rl);
@@ -423,18 +406,7 @@ async function handleVisionExtract(
userId: string,
document: LoadedDocument,
): Promise<Response> {
const { pick } = await resolveDocumentVisionProvider(userId);
if (!pick) {
return apiError("No vision-capable AI provider is configured", 422, {
errorCode: "documents.inbound.providerUnsupported",
});
}
await assertDocumentEgressConsent({
userId,
providerType: pick.providerType,
surface: "insights",
});
const pick = await requireDocumentVisionProvider(userId);
const rl = await checkDocumentAiRateLimit(userId);
if (!rl.allowed) return documentAiRateLimited(rl);
@@ -35,18 +35,19 @@ vi.mock("@/lib/documents/describe", () => ({
vi.mock("@/lib/documents/content-index", () => ({
upsertContentIndex: vi.fn().mockResolvedValue({ tokenCount: 7 }),
}));
vi.mock("@/lib/documents/provider-order", () => ({
resolveDocumentVisionProvider: vi.fn(),
}));
vi.mock("@/lib/documents/provider-order", async () =>
(await import("@/__tests__/helpers/provider-order-mock")).providerOrderMock(),
);
vi.mock("@/lib/ai/capabilities/gate", async () =>
(
await import("@/__tests__/helpers/provider-order-mock")
).openCapabilityGateMock(),
);
vi.mock("@/lib/documents/auto-stage-labs", () => ({
maybeAutoStageLabFacts: vi
.fn()
.mockResolvedValue({ staged: false, reason: "not-lab" }),
}));
vi.mock("@/lib/ai/consent-guard", () => ({
assertDocumentEgressConsent: vi.fn().mockResolvedValue(undefined),
ConsentRequiredError: class ConsentRequiredError extends Error {},
}));
vi.mock("@/lib/ai/coach/budget", () => ({
buildDateKey: vi.fn(() => "2026-07-07"),
reserveBudget: vi.fn().mockResolvedValue({ allowed: true, reserved: 1 }),
@@ -32,7 +32,7 @@ import {
sanitiseZodIssues,
} from "@/lib/api-response";
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard";
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
import {
buildDateKey,
reconcileSpend,
@@ -57,7 +57,7 @@ import {
DocumentDescribeError,
transcribeDocument,
} from "@/lib/documents/describe";
import { resolveDocumentVisionProvider } from "@/lib/documents/provider-order";
import { requireDocumentVisionProvider } from "@/lib/documents/provider-order";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { prisma } from "@/lib/db";
@@ -183,17 +183,12 @@ async function handleVisionIndex(
userId: string,
document: LoadedDocument,
): Promise<Response> {
const { pick } = await resolveDocumentVisionProvider(userId);
if (!pick) {
return apiError("No vision-capable AI provider is configured", 422, {
errorCode: "documents.inbound.providerUnsupported",
});
}
await assertDocumentEgressConsent({
userId,
providerType: pick.providerType,
surface: "insights",
});
// Transcribing the stored original is model work; indexing text the browser
// already read (the text mode above) is not, and stays open with AI off so
// search keeps working. The provider and the consent receipt are answered
// by the pick, for the provider actually used.
await requireAiCapability("documentAi", { pickDecides: true });
const pick = await requireDocumentVisionProvider(userId);
const rl = await checkDocumentAiRateLimit(userId);
if (!rl.allowed) return documentAiRateLimited(rl);
@@ -27,14 +27,14 @@ vi.mock("@/lib/documents/assist", () => ({
runDocumentAssist: vi.fn(),
DocumentAssistError: class DocumentAssistError extends Error {},
}));
vi.mock("@/lib/documents/provider-order", () => ({
resolveDocumentVisionProvider: vi.fn(),
resolveDocumentTextProvider: vi.fn(),
}));
vi.mock("@/lib/ai/consent-guard", () => ({
assertDocumentEgressConsent: vi.fn().mockResolvedValue(undefined),
ConsentRequiredError: class ConsentRequiredError extends Error {},
}));
vi.mock("@/lib/documents/provider-order", async () =>
(await import("@/__tests__/helpers/provider-order-mock")).providerOrderMock(),
);
vi.mock("@/lib/ai/capabilities/gate", async () =>
(
await import("@/__tests__/helpers/provider-order-mock")
).openCapabilityGateMock(),
);
vi.mock("@/lib/ai/coach/budget", () => ({
buildDateKey: vi.fn(() => "2026-07-07"),
reserveBudget: vi.fn().mockResolvedValue({ allowed: true, reserved: 1 }),
@@ -24,7 +24,7 @@ import {
sanitiseZodIssues,
} from "@/lib/api-response";
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard";
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
import {
buildDateKey,
reconcileSpend,
@@ -48,8 +48,8 @@ import {
type LoadedDocument,
} from "@/lib/documents/ai-route-support";
import {
resolveDocumentTextProvider,
resolveDocumentVisionProvider,
requireDocumentTextProvider,
requireDocumentVisionProvider,
} from "@/lib/documents/provider-order";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
@@ -67,6 +67,10 @@ export const POST = apiHandler(
const gate = await requireModuleEnabled(user.id, "inboundDocuments");
if (!gate.enabled) return gate.response;
// Reading a document is model work. The provider and the consent receipt
// are answered by the pick below, for the provider actually used.
await requireAiCapability("documentAi", { pickDecides: true });
const { id } = await params;
const document = await loadOwnedDocument(user.id, id);
if (!document) {
@@ -125,18 +129,7 @@ async function handleTextSuggest(
});
}
const { pick } = await resolveDocumentTextProvider(userId);
if (!pick) {
return apiError("No AI provider is configured", 422, {
errorCode: "documents.inbound.providerUnsupported",
});
}
await assertDocumentEgressConsent({
userId,
providerType: pick.providerType,
surface: "insights",
});
const pick = await requireDocumentTextProvider(userId);
const rl = await checkDocumentAiRateLimit(userId);
if (!rl.allowed) return documentAiRateLimited(rl);
@@ -218,18 +211,7 @@ async function handleVisionSuggest(
userId: string,
document: LoadedDocument,
): Promise<Response> {
const { pick } = await resolveDocumentVisionProvider(userId);
if (!pick) {
return apiError("No vision-capable AI provider is configured", 422, {
errorCode: "documents.inbound.providerUnsupported",
});
}
await assertDocumentEgressConsent({
userId,
providerType: pick.providerType,
surface: "insights",
});
const pick = await requireDocumentVisionProvider(userId);
const rl = await checkDocumentAiRateLimit(userId);
if (!rl.allowed) return documentAiRateLimited(rl);
@@ -40,14 +40,14 @@ vi.mock("@/lib/documents/describe", () => ({
documentSummaryBlockedCopy: () => "The summary was withheld.",
DocumentDescribeError: class DocumentDescribeError extends Error {},
}));
vi.mock("@/lib/documents/provider-order", () => ({
resolveDocumentVisionProvider: vi.fn(),
resolveDocumentTextProvider: vi.fn(),
}));
vi.mock("@/lib/ai/consent-guard", () => ({
assertDocumentEgressConsent: vi.fn().mockResolvedValue(undefined),
ConsentRequiredError: class ConsentRequiredError extends Error {},
}));
vi.mock("@/lib/documents/provider-order", async () =>
(await import("@/__tests__/helpers/provider-order-mock")).providerOrderMock(),
);
vi.mock("@/lib/ai/capabilities/gate", async () =>
(
await import("@/__tests__/helpers/provider-order-mock")
).openCapabilityGateMock(),
);
vi.mock("@/lib/ai/coach/budget", () => ({
buildDateKey: vi.fn(() => "2026-07-07"),
reserveBudget: vi.fn().mockResolvedValue({ allowed: true, reserved: 1 }),
@@ -31,7 +31,7 @@ import {
sanitiseZodIssues,
} from "@/lib/api-response";
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard";
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
import {
buildDateKey,
reconcileSpend,
@@ -59,8 +59,8 @@ import {
import { encryptDocumentSummary } from "@/lib/documents/store";
import type { OutboundReason } from "@/lib/ai/safety/outbound-screen";
import {
resolveDocumentTextProvider,
resolveDocumentVisionProvider,
requireDocumentTextProvider,
requireDocumentVisionProvider,
} from "@/lib/documents/provider-order";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
@@ -203,6 +203,10 @@ export const POST = apiHandler(
const gate = await requireModuleEnabled(user.id, "inboundDocuments");
if (!gate.enabled) return gate.response;
// Reading a document is model work. The provider and the consent receipt
// are answered by the pick below, for the provider actually used.
await requireAiCapability("documentAi", { pickDecides: true });
const { id } = await params;
const document = await loadOwnedDocument(user.id, id);
if (!document) {
@@ -321,19 +325,9 @@ async function handleTextSummary(
return finishSummary(request, userId, document.id, "text", mode, result);
}
const { pick } = await resolveDocumentTextProvider(userId);
if (!pick) {
await refundDocumentAiSlot(userId);
return apiError("No AI provider is configured", 422, {
errorCode: "documents.inbound.providerUnsupported",
});
}
let pick: Awaited<ReturnType<typeof requireDocumentTextProvider>>;
try {
await assertDocumentEgressConsent({
userId,
providerType: pick.providerType,
surface: "insights",
});
pick = await requireDocumentTextProvider(userId);
} catch (err) {
// Refused before any dispatch — the slot goes back with the refusal.
await refundDocumentAiSlot(userId);
@@ -394,17 +388,7 @@ async function handleVisionSummary(
locale: Locale,
persist: SummaryPersistenceTarget | null,
): Promise<Response> {
const { pick } = await resolveDocumentVisionProvider(userId);
if (!pick) {
return apiError("No vision-capable AI provider is configured", 422, {
errorCode: "documents.inbound.providerUnsupported",
});
}
await assertDocumentEgressConsent({
userId,
providerType: pick.providerType,
surface: "insights",
});
const pick = await requireDocumentVisionProvider(userId);
const rl = await checkDocumentAiRateLimit(userId);
if (!rl.allowed) return documentAiRateLimited(rl);
@@ -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();
});
});
@@ -35,6 +35,7 @@ export const GET = apiHandler(async () => {
reason: capability.reason,
pdfSupported: capability.pdfSupported,
egress: capability.egress,
aiReason: capability.ai.reason,
},
});
@@ -20,11 +20,10 @@ vi.mock("@/lib/jobs/document-content-index-backfill", () => ({
enqueueContentIndexBackfill: vi.fn(),
}));
vi.mock("@/lib/documents/provider-order", () => ({
resolveDocumentVisionProvider: vi.fn(),
requireDocumentVisionProvider: vi.fn(),
}));
vi.mock("@/lib/ai/consent-guard", () => ({
assertDocumentEgressConsent: vi.fn().mockResolvedValue(undefined),
ConsentRequiredError: class ConsentRequiredError extends Error {},
vi.mock("@/lib/ai/capabilities/gate", () => ({
requireAiCapability: vi.fn(),
}));
vi.mock("@/lib/modules/gate", () => ({
requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }),
@@ -54,7 +53,9 @@ import { POST } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { enqueueContentIndexBackfill } from "@/lib/jobs/document-content-index-backfill";
import { resolveDocumentVisionProvider } from "@/lib/documents/provider-order";
import { requireDocumentVisionProvider } from "@/lib/documents/provider-order";
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
import { AiUnavailableError } from "@/lib/ai/capabilities/refusal";
const SESSION_OK = {
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -78,7 +79,14 @@ const req = () =>
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(resolveDocumentVisionProvider).mockResolvedValue(PICK as never);
vi.mocked(requireAiCapability).mockResolvedValue({
available: true,
reason: null,
onDeviceAllowed: true,
});
vi.mocked(requireDocumentVisionProvider).mockResolvedValue(
PICK.pick as never,
);
vi.mocked(enqueueContentIndexBackfill).mockResolvedValue({ enqueued: true });
// 7 live documents, 4 already indexed → 3 remain.
vi.mocked(prisma.inboundDocument.count).mockResolvedValue(7 as never);
@@ -114,3 +122,36 @@ describe("POST /api/documents/inbound/reindex", () => {
});
});
});
describe("POST /api/documents/inbound/reindex — the documentAi capability", () => {
it("answers the capability envelope and enqueues nothing when document reading is off", async () => {
vi.mocked(requireAiCapability).mockRejectedValue(
new AiUnavailableError("documentAi", "operator_disabled"),
);
const res = await POST(req() as never);
expect(res.status).toBe(403);
const body = await res.json();
expect(body.meta).toEqual({
errorCode: "assistant.disabled.documentAi",
capability: "documentAi",
reason: "operator_disabled",
});
expect(requireAiCapability).toHaveBeenCalledWith("documentAi", {
pickDecides: true,
});
expect(enqueueContentIndexBackfill).not.toHaveBeenCalled();
});
it("keeps the vault's own code when no provider can read documents", async () => {
vi.mocked(requireDocumentVisionProvider).mockRejectedValue(
new AiUnavailableError("documentAi", "no_provider", null, {
errorCode: "documents.inbound.providerUnsupported",
}),
);
const res = await POST(req() as never);
expect(res.status).toBe(422);
const body = await res.json();
expect(body.meta.errorCode).toBe("documents.inbound.providerUnsupported");
expect(enqueueContentIndexBackfill).not.toHaveBeenCalled();
});
});
+10 -15
View File
@@ -3,17 +3,17 @@
*
* Enqueues a per-user job that indexes the caller's not-yet-indexed documents
* (one provider transcription each, bounded + resumable). Gated on the module,
* a configured vision provider, and the EXISTING AI consent (the worker
* re-checks all three). The work runs off-request on pg-boss; this route only
* the `documentAi` capability, a configured vision provider, and a document
* consent receipt for that provider (the worker re-checks all of it). The work runs off-request on pg-boss; this route only
* enqueues and returns immediately so the vault stays responsive.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard";
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { enqueueContentIndexBackfill } from "@/lib/jobs/document-content-index-backfill";
import { resolveDocumentVisionProvider } from "@/lib/documents/provider-order";
import { requireDocumentVisionProvider } from "@/lib/documents/provider-order";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
@@ -30,6 +30,10 @@ export const POST = apiHandler(async (request) => {
const gate = await requireModuleEnabled(user.id, "inboundDocuments");
if (!gate.enabled) return gate.response;
// The backfill transcribes each document through a model. The provider and
// the consent receipt are answered by the pick below.
await requireAiCapability("documentAi", { pickDecides: true });
const rl = await checkRateLimit(
`documents-reindex:${user.id}`,
REINDEX_LIMIT_PER_HOUR,
@@ -47,17 +51,8 @@ export const POST = apiHandler(async (request) => {
// Fail fast when the precondition is not met so the UI gets immediate
// feedback rather than a silently no-op'd job.
const { pick } = await resolveDocumentVisionProvider(user.id);
if (!pick) {
return apiError("No vision-capable AI provider is configured", 422, {
errorCode: "documents.inbound.providerUnsupported",
});
}
await assertDocumentEgressConsent({
userId: user.id,
providerType: pick.providerType,
surface: "insights",
});
// The worker re-checks all of it per run.
await requireDocumentVisionProvider(user.id);
const { enqueued: jobCreated } = await enqueueContentIndexBackfill(user.id);
+14 -2
View File
@@ -40,6 +40,7 @@ import {
getClientIp,
sanitiseZodIssues,
} from "@/lib/api-response";
import { getAiCapability } from "@/lib/ai/capabilities/gate";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { hashQueryTokens } from "@/lib/documents/content-index";
@@ -446,10 +447,21 @@ async function processUpload(
// Summarise the freshly stored document in the background — but ONLY when the
// `documentsAutoAiRead` opt-in is ON (the job re-checks it and the egress
// consent, and no-ops otherwise). The persisted summary shows on the detail
// consent, and no-ops otherwise) and the `documentAi` capability is open for
// this record. The upload itself never depends on AI: the document is stored
// above whatever the capability says, and the index job still runs its
// provider-free text-layer path. The persisted summary shows on the detail
// view. Same fire-and-forget contract: the upload never blocks on or fails
// because of it. Only fresh inserts reach here (a duplicate returns early).
void enqueueDocumentSummary(user.id, document.id);
const documentAi = await getAiCapability("documentAi");
if (documentAi.available) {
void enqueueDocumentSummary(user.id, document.id);
} else {
annotate({
action: { name: "documents.summary.enqueueSkipped" },
meta: { documentId: document.id, reason: documentAi.reason },
});
}
const [links, visitLinks] = await Promise.all([
loadConditionLinks(user.id, [document.id]),
+5 -3
View File
@@ -14,7 +14,7 @@ import {
DOCUMENT_ACCEPTED_EXTENSIONS,
resolveDocumentLimits,
} from "@/lib/documents/upload-policy";
import { resolveOcrCapability } from "@/lib/labs/ocr-capability";
import { resolveDocumentAiCapability } from "@/lib/documents/provider-order";
import { listDistinctTargets } from "@/lib/links";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
@@ -58,8 +58,10 @@ export const GET = apiHandler(async () => {
}),
// Whether an AI action can run for this caller (assist + indexing share
// the same provider precondition). Honest availability so the UI never
// offers what the endpoint would 422.
resolveOcrCapability(user.id),
// offers what the endpoint would refuse: the document provider order and
// the `documentAi` capability for this record, the same answer the
// capability probe gives.
resolveDocumentAiCapability(user.id),
// Content-index coverage: how many live documents are indexed …
// Scope the count to LIVE documents: a soft-deleted document keeps its
// index row (cascade fires on hard purge only), so an unscoped count