mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -268,9 +268,12 @@ const EXEMPT_ROUTES: ReadonlyArray<string> = [
|
||||
// per-medication importer below: it writes intake rows and reads its own job.
|
||||
"src/app/api/medications/intake/dose-history-import/route.ts",
|
||||
"src/app/api/medications/intake/dose-history-import/[jobId]/status/route.ts",
|
||||
// NB: `medications/extract` is NOT exempt — it gates on
|
||||
// `requireAssistantSurface("coach")` (the NL-extraction is an assistant
|
||||
// surface), so the inventory already counts it as a delegated gate.
|
||||
// `medications/extract` is model work, not data: it answers under the
|
||||
// `medicationExtract` AI capability (`requireAiCapability`), which no
|
||||
// module owns, because medications is a core domain for extraction. The
|
||||
// capability carries the operator switch, the record's authority and the
|
||||
// consent rule; there is no module gate for it to carry.
|
||||
"src/app/api/medications/extract/route.ts",
|
||||
"src/app/api/medications/[id]/route.ts",
|
||||
"src/app/api/medications/[id]/api-endpoint/route.ts",
|
||||
"src/app/api/medications/[id]/cadence/route.ts",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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]),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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"],
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,9 @@
|
||||
* Cheap probe (no provider call) the Labs UI uses to decide whether to show
|
||||
* the "Scan a report" affordance. Returns `{ available, reason, pdfSupported }`
|
||||
* for the calling user's configured AI provider. The surface stays dark for
|
||||
* users with no provider configured and no local-OCR opt-in.
|
||||
* users with no provider configured and no local-OCR opt-in, and whenever the
|
||||
* `labsOcr` capability is closed (operator switch, labs module, sharing grant);
|
||||
* `ai` says which.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
@@ -25,6 +27,7 @@ export const GET = apiHandler(async () => {
|
||||
mode: capability.mode,
|
||||
reason: capability.reason,
|
||||
pdfSupported: capability.pdfSupported,
|
||||
aiReason: capability.ai.reason,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -27,15 +27,14 @@ vi.mock("@/lib/db", () => ({
|
||||
}));
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
vi.mock("@/lib/labs/ocr-capability", () => ({
|
||||
resolveTextProvider: vi.fn(),
|
||||
resolveVisionProvider: vi.fn(),
|
||||
requireLabsOcrProvider: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/capabilities/gate", () => ({
|
||||
requireAiCapability: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/documents/rasterize-pdf", () => ({
|
||||
rasterizePdf: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/consent-guard", () => ({
|
||||
assertConsentForChain: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
refundRateLimit: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -60,10 +59,9 @@ import { POST } from "../route";
|
||||
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
|
||||
import { requireAuth } from "@/lib/api-handler";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
resolveTextProvider,
|
||||
resolveVisionProvider,
|
||||
} from "@/lib/labs/ocr-capability";
|
||||
import { requireLabsOcrProvider } from "@/lib/labs/ocr-capability";
|
||||
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
|
||||
import { AiUnavailableError } from "@/lib/ai/capabilities/refusal";
|
||||
import { rasterizePdf } from "@/lib/documents/rasterize-pdf";
|
||||
import { reserveBudget, reconcileSpend } from "@/lib/ai/coach/budget";
|
||||
import { checkRateLimit, refundRateLimit } from "@/lib/rate-limit";
|
||||
@@ -94,12 +92,14 @@ beforeEach(() => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
labsLocalOcrEnabled: true,
|
||||
} as never);
|
||||
vi.mocked(resolveTextProvider).mockResolvedValue({
|
||||
chain: [{ providerType: "openai", instance: {} as never }],
|
||||
pick: {
|
||||
entry: { providerType: "openai", instance: {} as never },
|
||||
providerType: "openai",
|
||||
},
|
||||
vi.mocked(requireAiCapability).mockResolvedValue({
|
||||
available: true,
|
||||
reason: null,
|
||||
onDeviceAllowed: true,
|
||||
});
|
||||
vi.mocked(requireLabsOcrProvider).mockResolvedValue({
|
||||
entry: { providerType: "openai", instance: {} as never },
|
||||
providerType: "openai",
|
||||
} as never);
|
||||
vi.mocked(reserveBudget).mockResolvedValue({
|
||||
allowed: true,
|
||||
@@ -187,14 +187,10 @@ describe("POST /api/labs/ocr/extract — vision PDF rasterization", () => {
|
||||
beforeEach(() => {
|
||||
// A non-Anthropic vision provider (codex): no native PDF block, so the
|
||||
// route must rasterize.
|
||||
vi.mocked(resolveVisionProvider).mockResolvedValue({
|
||||
chain: [{ providerType: "codex", instance: {} as never }],
|
||||
localOcrEnabled: false,
|
||||
pick: {
|
||||
entry: { providerType: "codex", instance: {} as never },
|
||||
providerType: "codex",
|
||||
pdfSupported: false,
|
||||
},
|
||||
vi.mocked(requireLabsOcrProvider).mockResolvedValue({
|
||||
entry: { providerType: "codex", instance: {} as never },
|
||||
providerType: "codex",
|
||||
pdfSupported: false,
|
||||
} as never);
|
||||
vi.mocked(reserveBudget).mockResolvedValue({
|
||||
allowed: true,
|
||||
@@ -334,3 +330,55 @@ describe("POST /api/labs/ocr/extract — the hourly scan bucket", () => {
|
||||
expect(refundRateLimit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/labs/ocr/extract — the labsOcr capability", () => {
|
||||
it("refuses before anything is read when the operator turned document reading off", async () => {
|
||||
vi.mocked(requireAiCapability).mockRejectedValue(
|
||||
new AiUnavailableError("labsOcr", "operator_disabled"),
|
||||
);
|
||||
const error = await POST(textReq()).catch((e) => e);
|
||||
expect(error).toBeInstanceOf(AiUnavailableError);
|
||||
expect(error.meta.errorCode).toBe("assistant.disabled.documentAi");
|
||||
expect(requireAiCapability).toHaveBeenCalledWith("labsOcr", {
|
||||
pickDecides: true,
|
||||
});
|
||||
expect(requireLabsOcrProvider).not.toHaveBeenCalled();
|
||||
expect(checkRateLimit).not.toHaveBeenCalled();
|
||||
expect(runOcrExtraction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stops at the wire when the pick is refused, before any slot or budget is spent", async () => {
|
||||
vi.mocked(requireLabsOcrProvider).mockRejectedValue(
|
||||
new AiUnavailableError("labsOcr", "consent_required"),
|
||||
);
|
||||
const error = await POST(textReq()).catch((e) => e);
|
||||
expect(error.meta).toEqual({
|
||||
errorCode: "consent.ai.required",
|
||||
capability: "labsOcr",
|
||||
reason: "consent_required",
|
||||
});
|
||||
expect(requireLabsOcrProvider).toHaveBeenCalledWith("user-1", "text");
|
||||
expect(checkRateLimit).not.toHaveBeenCalled();
|
||||
expect(reserveBudget).not.toHaveBeenCalled();
|
||||
expect(runOcrExtraction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("asks for the vision pick on a multipart upload", async () => {
|
||||
vi.mocked(requireLabsOcrProvider).mockRejectedValue(
|
||||
new AiUnavailableError("labsOcr", "no_provider", null, {
|
||||
errorCode: "labs.ocr.providerUnsupported",
|
||||
}),
|
||||
);
|
||||
const form = new FormData();
|
||||
form.append("file", new File([new Uint8Array([1])], "a.png"));
|
||||
const error = await POST(
|
||||
new Request("http://localhost/api/labs/ocr/extract", {
|
||||
method: "POST",
|
||||
body: form,
|
||||
}),
|
||||
).catch((e) => e);
|
||||
expect(requireLabsOcrProvider).toHaveBeenCalledWith("user-1", "vision");
|
||||
expect(error.status).toBe(422);
|
||||
expect(error.meta.errorCode).toBe("labs.ocr.providerUnsupported");
|
||||
});
|
||||
});
|
||||
@@ -18,7 +18,10 @@
|
||||
* `labsLocalOcrEnabled` preference.
|
||||
*
|
||||
* Guards mirror the Coach's discipline in both modes:
|
||||
* requireAuth → resolve provider → assertConsentForChain → rate-limit
|
||||
* requireAuth → requireAiCapability("labsOcr") → resolve provider and
|
||||
* re-check the capability for that pick (`requireLabsOcrProvider`: the
|
||||
* operator's switch, the labs module, and a document consent receipt for a
|
||||
* pick that leaves the machine) → rate-limit
|
||||
* (`LABS_OCR_LIMIT_PER_HOUR`, default 6/h) → reserveBudget → run extraction
|
||||
* → reconcile budget. A slot is charged early so a 429 stays cheap, and it is
|
||||
* handed back when the scan fails before the provider is called.
|
||||
@@ -38,7 +41,7 @@ import {
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
|
||||
import { assertConsentForChain } from "@/lib/ai/consent-guard";
|
||||
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
|
||||
import {
|
||||
buildDateKey,
|
||||
reconcileSpend,
|
||||
@@ -48,10 +51,7 @@ import {
|
||||
} from "@/lib/ai/coach/budget";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { rasterizePdf } from "@/lib/documents/rasterize-pdf";
|
||||
import {
|
||||
resolveTextProvider,
|
||||
resolveVisionProvider,
|
||||
} from "@/lib/labs/ocr-capability";
|
||||
import { requireLabsOcrProvider } from "@/lib/labs/ocr-capability";
|
||||
import { OcrExtractError, runOcrExtraction } from "@/lib/labs/ocr-extract";
|
||||
import {
|
||||
BodyTooLargeError,
|
||||
@@ -94,6 +94,11 @@ function providerFailureMeta(error: unknown, mode?: "text") {
|
||||
export const POST = apiHandler(async (request: Request) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
// Reading a lab report is model work in both modes: vision sends the image,
|
||||
// text mode sends what the browser read off it. The provider and the consent
|
||||
// receipt are answered by the pick below, for the provider actually used.
|
||||
await requireAiCapability("labsOcr", { pickDecides: true });
|
||||
|
||||
// Dispatch on the body shape: a JSON body is the local-OCR text mode; a
|
||||
// multipart body is the native-vision image/PDF mode.
|
||||
const contentType = request.headers.get("content-type") ?? "";
|
||||
@@ -124,21 +129,10 @@ async function handleTextExtract(
|
||||
});
|
||||
}
|
||||
|
||||
// Any configured provider can structure the text.
|
||||
const { chain, pick } = await resolveTextProvider(userId);
|
||||
if (!pick) {
|
||||
annotate({
|
||||
action: { name: "labs.ocr.providerUnsupported" },
|
||||
meta: { chainLength: chain.length, mode: "text" },
|
||||
});
|
||||
return apiError("No AI provider is configured", 422, {
|
||||
errorCode: "labs.ocr.providerUnsupported",
|
||||
});
|
||||
}
|
||||
|
||||
// Consent gate — only the server-managed key trips this; BYOK / local / codex
|
||||
// egress is the user's own act (the toggle is the consent for local OCR).
|
||||
await assertConsentForChain({ userId, chain, surface: "insights" });
|
||||
// Any configured provider can structure the text. The pick is re-checked
|
||||
// against `labsOcr` for exactly that provider; one that leaves the machine
|
||||
// needs a document consent receipt.
|
||||
const pick = await requireLabsOcrProvider(userId, "text");
|
||||
|
||||
const rl = await checkLabsOcrRateLimit(userId);
|
||||
if (!rl.allowed) {
|
||||
@@ -244,20 +238,9 @@ async function handleVisionExtract(
|
||||
request: Request,
|
||||
userId: string,
|
||||
): Promise<Response> {
|
||||
// 1. Resolve a vision-capable provider. 422 when none is configured.
|
||||
const { chain, pick } = await resolveVisionProvider(userId);
|
||||
if (!pick) {
|
||||
annotate({
|
||||
action: { name: "labs.ocr.providerUnsupported" },
|
||||
meta: { chainLength: chain.length },
|
||||
});
|
||||
return apiError("No vision-capable AI provider is configured", 422, {
|
||||
errorCode: "labs.ocr.providerUnsupported",
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Consent gate — server-managed egress of health data requires a receipt.
|
||||
await assertConsentForChain({ userId, chain, surface: "insights" });
|
||||
// 1-2. Resolve a vision-capable provider (422 when none is configured) and
|
||||
// re-check `labsOcr` for it, consent receipt included.
|
||||
const pick = await requireLabsOcrProvider(userId, "vision");
|
||||
|
||||
// 3. Rate-limit (vision calls are costly).
|
||||
const rl = await checkLabsOcrRateLimit(userId);
|
||||
|
||||
@@ -22,9 +22,11 @@ vi.mock("@/lib/api-handler", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/feature-flags", () => ({
|
||||
requireAssistantSurface: vi.fn(async () => undefined),
|
||||
AssistantDisabledError: class extends Error {},
|
||||
vi.mock("@/lib/ai/capabilities/gate", () => ({
|
||||
requireAiCapability: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/capabilities/egress", () => ({
|
||||
assertAiEgress: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
@@ -53,14 +55,6 @@ vi.mock("@/lib/ai/provider", () => ({
|
||||
resolveProviderChain: vi.fn(async () => []),
|
||||
}));
|
||||
|
||||
// v1.12.1 — free-text medication extraction egresses PHI to the operator's
|
||||
// server-managed key, so it now passes through the consent gate. Mock it to a
|
||||
// no-op here; the gate's own fail-closed behaviour is covered by
|
||||
// `consent-guard.test.ts`.
|
||||
vi.mock("@/lib/ai/consent-guard", () => ({
|
||||
assertConsentForChain: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/ai/provider-runner", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("@/lib/ai/provider-runner")
|
||||
@@ -93,7 +87,9 @@ import {
|
||||
runRawCompletionWithFallback,
|
||||
AllProvidersFailedError,
|
||||
} from "@/lib/ai/provider-runner";
|
||||
import { assertConsentForChain } 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";
|
||||
|
||||
function postReq(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/medications/extract", {
|
||||
@@ -110,6 +106,12 @@ const AUTH_OK = {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(requireAiCapability).mockResolvedValue({
|
||||
available: true,
|
||||
reason: null,
|
||||
onDeviceAllowed: true,
|
||||
});
|
||||
vi.mocked(assertAiEgress).mockResolvedValue(undefined);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
limit: 60,
|
||||
@@ -208,10 +210,15 @@ describe("POST /api/medications/extract — happy path", () => {
|
||||
doseUnit: "mg",
|
||||
cadenceKind: "everyNWeeks",
|
||||
});
|
||||
// The PHI free-text egress is gated on an active consent receipt for the
|
||||
// coach surface before the provider chain runs.
|
||||
expect(assertConsentForChain).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ surface: "coach" }),
|
||||
// The PHI free-text egress is re-checked against the capability for the
|
||||
// whole chain the runner may cascade through, before it runs.
|
||||
expect(assertAiEgress).toHaveBeenCalledWith(
|
||||
"medicationExtract",
|
||||
"user-1",
|
||||
expect.any(Array),
|
||||
);
|
||||
expect(vi.mocked(assertAiEgress).mock.invocationCallOrder[0]).toBeLessThan(
|
||||
vi.mocked(runRawCompletionWithFallback).mock.invocationCallOrder[0]!,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -300,6 +307,8 @@ describe("POST /api/medications/extract — daily budget", () => {
|
||||
expect(res.status).toBe(429);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/budget/i);
|
||||
// The code a client branches on, the same string the body always carried.
|
||||
expect(body.meta).toEqual({ errorCode: "coach.budget.exceeded" });
|
||||
// The refusal happens BEFORE any upstream spend.
|
||||
expect(runRawCompletionWithFallback).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -374,8 +383,19 @@ describe("POST /api/medications/extract — validation + provider errors", () =>
|
||||
type: "none",
|
||||
} as never);
|
||||
|
||||
const res = await POST(postReq({ text: "Mounjaro 5mg weekly" }) as never);
|
||||
expect(res.status).toBe(503);
|
||||
// The status stays 503 (a shipped client may branch on it) and gains the
|
||||
// code that says what it means.
|
||||
const error = await POST(
|
||||
postReq({ text: "Mounjaro 5mg weekly" }) as never,
|
||||
).catch((e) => e);
|
||||
expect(error).toBeInstanceOf(AiUnavailableError);
|
||||
expect(error.status).toBe(503);
|
||||
expect(error.meta).toEqual({
|
||||
errorCode: "ai.provider.none",
|
||||
capability: "medicationExtract",
|
||||
reason: "no_provider",
|
||||
});
|
||||
expect(runRawCompletionWithFallback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 502 when the provider returns unparseable JSON", async () => {
|
||||
@@ -395,3 +415,39 @@ describe("POST /api/medications/extract — validation + provider errors", () =>
|
||||
expect(res.status).toBe(502);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/medications/extract — the medicationExtract capability", () => {
|
||||
it("answers under medicationExtract, keeping 503 for no provider", async () => {
|
||||
vi.mocked(requireAuth).mockResolvedValue(AUTH_OK as never);
|
||||
await POST(postReq({ text: "5mg weekly" }) as never).catch(() => null);
|
||||
expect(requireAiCapability).toHaveBeenCalledWith("medicationExtract", {
|
||||
noProvider: { errorCode: "ai.provider.none", status: 503 },
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses before the body is read when the operator turned it off", async () => {
|
||||
vi.mocked(requireAuth).mockResolvedValue(AUTH_OK as never);
|
||||
vi.mocked(requireAiCapability).mockRejectedValueOnce(
|
||||
new AiUnavailableError("medicationExtract", "operator_disabled"),
|
||||
);
|
||||
const error = await POST(postReq({ text: "5mg weekly" }) as never).catch(
|
||||
(e) => e,
|
||||
);
|
||||
expect(error.meta.errorCode).toBe("assistant.disabled.documentAi");
|
||||
expect(checkRateLimit).not.toHaveBeenCalled();
|
||||
expect(runRawCompletionWithFallback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stops at the wire without reserving budget or calling a provider", async () => {
|
||||
vi.mocked(requireAuth).mockResolvedValue(AUTH_OK as never);
|
||||
vi.mocked(assertAiEgress).mockRejectedValueOnce(
|
||||
new AiUnavailableError("medicationExtract", "consent_required"),
|
||||
);
|
||||
const error = await POST(postReq({ text: "5mg weekly" }) as never).catch(
|
||||
(e) => e,
|
||||
);
|
||||
expect(error.meta.errorCode).toBe("consent.ai.required");
|
||||
expect(reserveBudget).not.toHaveBeenCalled();
|
||||
expect(runRawCompletionWithFallback).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -24,9 +24,16 @@
|
||||
* Behaviour:
|
||||
*
|
||||
* 1. requireAuth() — cookie OR Bearer.
|
||||
* 2. requireAssistantSurface("coach") — operator can disable.
|
||||
* 2. requireAiCapability("medicationExtract") — the operator's "Reading
|
||||
* documents" switch (and the master), provider-work authority for the
|
||||
* record, a configured provider (503 `ai.provider.none` otherwise; the
|
||||
* status predates the envelope and stays until no shipped client reads
|
||||
* it), and a document-class consent receipt: free-text medication input
|
||||
* is health data, and any provider that leaves the machine needs one.
|
||||
* 3. checkRateLimit(...) — 10 requests / 5 min / user.
|
||||
* 4. resolveProviderChain() — fall back through providers.
|
||||
* 4. resolveProviderChain() — fall back through providers,
|
||||
* then `assertAiEgress` re-checks the capability for exactly that chain
|
||||
* immediately before anything is sent.
|
||||
* 5. reserveBudget(resolveDailyCap(chain)) — atomic daily token ceiling,
|
||||
* capped against the COST OWNER: a self-hoster on their own key or a
|
||||
* local model is measured against the user-plan ceiling, not the
|
||||
@@ -56,7 +63,13 @@ import {
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
import { assertAiEgress } from "@/lib/ai/capabilities/egress";
|
||||
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
|
||||
import {
|
||||
AiUnavailableError,
|
||||
type NoProviderRefusal,
|
||||
} from "@/lib/ai/capabilities/refusal";
|
||||
import { AI_PROVIDER_NONE_ERROR_CODE } from "@/lib/ai/capabilities/types";
|
||||
|
||||
import {
|
||||
AllProvidersFailedError,
|
||||
@@ -64,7 +77,6 @@ import {
|
||||
} from "@/lib/ai/provider-runner";
|
||||
import { resolveProvider, resolveProviderChain } from "@/lib/ai/provider";
|
||||
import { singleUserTurn } from "@/lib/ai/types";
|
||||
import { assertConsentForChain } from "@/lib/ai/consent-guard";
|
||||
|
||||
import {
|
||||
buildDateKey,
|
||||
@@ -80,6 +92,19 @@ import {
|
||||
type MedicationExtractionResult,
|
||||
} from "@/lib/ai/coach/medication-extract-prompt";
|
||||
|
||||
/**
|
||||
* How the extractor refuses with no provider. 503 predates the capability
|
||||
* envelope; it stays until no shipped client is known to branch on it, and the
|
||||
* code says what it means either way.
|
||||
*/
|
||||
const MEDICATION_EXTRACT_NO_PROVIDER: NoProviderRefusal = {
|
||||
errorCode: AI_PROVIDER_NONE_ERROR_CODE,
|
||||
status: 503,
|
||||
};
|
||||
|
||||
/** The budget refusal's code; the same string the body always carried. */
|
||||
const MEDICATION_EXTRACT_BUDGET_CODE = "coach.budget.exceeded";
|
||||
|
||||
/** Hard cap on the free-text payload; mirrors `coachChatRequestSchema.message`. */
|
||||
const MAX_TEXT_LENGTH = 2000;
|
||||
|
||||
@@ -128,10 +153,12 @@ function parseModelJson(content: string): unknown | null {
|
||||
|
||||
async function handleExtract(request: NextRequest): Promise<Response> {
|
||||
const auth = await requireAuth();
|
||||
// Same operator-disable gate the Coach SSE route honours — when
|
||||
// "coach" is off, the overlay also disappears so the wizard simply
|
||||
// falls back to the manual path.
|
||||
await requireAssistantSurface("coach");
|
||||
// Medication text extraction is its own capability under the operator's
|
||||
// "Reading documents" switch. When it is closed the wizard keeps its
|
||||
// manual path; nothing here is data the wizard needs.
|
||||
await requireAiCapability("medicationExtract", {
|
||||
noProvider: MEDICATION_EXTRACT_NO_PROVIDER,
|
||||
});
|
||||
|
||||
const userId = auth.user.id;
|
||||
|
||||
@@ -178,15 +205,25 @@ async function handleExtract(request: NextRequest): Promise<Response> {
|
||||
const legacy = await resolveProvider(userId);
|
||||
if (legacy.type === "none") {
|
||||
annotate({ action: { name: "medications.extract.no-provider" } });
|
||||
return apiError("No AI provider configured", 503);
|
||||
throw new AiUnavailableError(
|
||||
"medicationExtract",
|
||||
"no_provider",
|
||||
null,
|
||||
MEDICATION_EXTRACT_NO_PROVIDER,
|
||||
);
|
||||
}
|
||||
chain.push({ providerType: "admin-openai", instance: legacy });
|
||||
}
|
||||
|
||||
// Free-text medication input is PHI. If the resolved chain could egress
|
||||
// via the operator's server-managed key, an active consent receipt is
|
||||
// required first — same gate as the Coach (which shares this chain).
|
||||
await assertConsentForChain({ userId, chain, surface: "coach" });
|
||||
// The wire. The runner may cascade through every entry, so each one is
|
||||
// named: any entry that leaves the machine needs a document-class consent
|
||||
// receipt, and a switch turned off since the gate above answered stops the
|
||||
// call here.
|
||||
await assertAiEgress(
|
||||
"medicationExtract",
|
||||
userId,
|
||||
chain.map((entry) => entry.providerType),
|
||||
);
|
||||
|
||||
const today = todayOverride ?? buildDateKey();
|
||||
const { systemPrompt, userPrompt } = buildMedicationExtractionPrompt({
|
||||
@@ -219,7 +256,9 @@ async function handleExtract(request: NextRequest): Promise<Response> {
|
||||
action: { name: "medications.extract.budget-exceeded" },
|
||||
meta: { totalAfter: reservation.totalAfter },
|
||||
});
|
||||
return apiError("coach.budget.exceeded", 429);
|
||||
return apiError(MEDICATION_EXTRACT_BUDGET_CODE, 429, {
|
||||
errorCode: MEDICATION_EXTRACT_BUDGET_CODE,
|
||||
});
|
||||
}
|
||||
|
||||
let completion;
|
||||
|
||||
@@ -2,9 +2,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
/**
|
||||
* v1.29.x (S7) — the shared fenced turn pipeline (design §3, adversarial tests
|
||||
* 12, 14, 16, 17, 18, 19). Pins: per-document egress consent fan-out (ALL clear
|
||||
* or refuse, zero egress + no user turn persisted on partial consent), the SINGLE
|
||||
* consent-checked provider (no cascade), numeric grounding over the LIVE
|
||||
* 12, 14, 16, 17, 18, 19). Pins: the capability re-check at the wire (the
|
||||
* document pick answers `documentAi`, each extra capability the entry point
|
||||
* names is asked for the same provider; any refusal means zero egress and no
|
||||
* user turn persisted), the SINGLE checked provider (no cascade), numeric grounding over the LIVE
|
||||
* attachment UNION, and the owner-scoped context loader (a corrupted/foreign join
|
||||
* row yields null → the turn refuses, never foreign text).
|
||||
*/
|
||||
@@ -21,8 +22,8 @@ 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),
|
||||
vi.mock("@/lib/ai/capabilities/egress", () => ({
|
||||
assertAiEgress: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/budget", () => ({
|
||||
buildDateKey: vi.fn(() => "2026-07-16"),
|
||||
@@ -49,7 +50,8 @@ import {
|
||||
} from "../fenced-chat";
|
||||
import { loadDocumentChatText } from "@/lib/documents/content-index";
|
||||
import { resolveDocumentTextProvider } from "@/lib/documents/provider-order";
|
||||
import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard";
|
||||
import { assertAiEgress } from "@/lib/ai/capabilities/egress";
|
||||
import { AiUnavailableError } from "@/lib/ai/capabilities/refusal";
|
||||
import { runStreamingRawCompletionWithFallback } from "@/lib/ai/provider-runner";
|
||||
import { appendMessage } from "@/lib/ai/coach/persistence";
|
||||
|
||||
@@ -90,12 +92,13 @@ const baseArgs = {
|
||||
contractLocale: "en" as const,
|
||||
locale: "en" as const,
|
||||
signal: new AbortController().signal,
|
||||
alsoRequires: [],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(resolveDocumentTextProvider).mockResolvedValue(PICK as never);
|
||||
vi.mocked(assertDocumentEgressConsent).mockResolvedValue(undefined);
|
||||
vi.mocked(assertAiEgress).mockResolvedValue(undefined);
|
||||
vi.mocked(appendMessage).mockImplementation(
|
||||
async (p) => ({ id: p.role === "assistant" ? "a" : "u", ...p }) as never,
|
||||
);
|
||||
@@ -106,36 +109,67 @@ beforeEach(() => {
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("streamFencedReply — consent fan-out", () => {
|
||||
it("checks egress consent ONCE PER attached document", async () => {
|
||||
await streamFencedReply({
|
||||
...baseArgs,
|
||||
docs: [doc("a", "x"), doc("b", "y"), doc("c", "z")],
|
||||
});
|
||||
expect(assertDocumentEgressConsent).toHaveBeenCalledTimes(3);
|
||||
expect(assertDocumentEgressConsent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
providerType: "anthropic",
|
||||
surface: "insights",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses (throws) with ZERO egress + no user turn persisted when ANY document's consent fails", async () => {
|
||||
vi.mocked(assertDocumentEgressConsent)
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockRejectedValueOnce(new Error("ConsentRequiredError"));
|
||||
describe("streamFencedReply — the capability at the wire", () => {
|
||||
it("throws the document pick's refusal with ZERO egress and no user turn persisted", async () => {
|
||||
const refusal = new AiUnavailableError("documentAi", "consent_required");
|
||||
vi.mocked(resolveDocumentTextProvider).mockResolvedValue({
|
||||
chain: [],
|
||||
pick: null,
|
||||
withheld: refusal,
|
||||
} as never);
|
||||
await expect(
|
||||
streamFencedReply({ ...baseArgs, docs: [doc("a", "x"), doc("b", "y")] }),
|
||||
).rejects.toThrow();
|
||||
).rejects.toBe(refusal);
|
||||
expect(runStreamingRawCompletionWithFallback).not.toHaveBeenCalled();
|
||||
expect(appendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("asks every extra capability about the picked provider", async () => {
|
||||
await drain(
|
||||
await streamFencedReply({
|
||||
...baseArgs,
|
||||
alsoRequires: ["coach"],
|
||||
docs: [doc("a", "x"), doc("b", "y")],
|
||||
}),
|
||||
);
|
||||
expect(assertAiEgress).toHaveBeenCalledTimes(1);
|
||||
expect(assertAiEgress).toHaveBeenCalledWith("coach", "user-1", [
|
||||
"anthropic",
|
||||
]);
|
||||
});
|
||||
|
||||
it("refuses with ZERO egress when an extra capability closed", async () => {
|
||||
vi.mocked(assertAiEgress).mockRejectedValueOnce(
|
||||
new AiUnavailableError("coach", "operator_disabled"),
|
||||
);
|
||||
await expect(
|
||||
streamFencedReply({
|
||||
...baseArgs,
|
||||
alsoRequires: ["coach"],
|
||||
docs: [doc("a", "x")],
|
||||
}),
|
||||
).rejects.toBeInstanceOf(AiUnavailableError);
|
||||
expect(runStreamingRawCompletionWithFallback).not.toHaveBeenCalled();
|
||||
expect(appendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps a missing provider an SSE frame, not a refusal", async () => {
|
||||
vi.mocked(resolveDocumentTextProvider).mockResolvedValue({
|
||||
chain: [],
|
||||
pick: null,
|
||||
withheld: null,
|
||||
} as never);
|
||||
const events = await drain(
|
||||
await streamFencedReply({ ...baseArgs, docs: [doc("a", "x")] }),
|
||||
);
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({ code: "documents.chat.provider.none" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("streamFencedReply — single provider, no cascade", () => {
|
||||
it("passes exactly the ONE consent-checked provider entry to the runner", async () => {
|
||||
it("passes exactly the ONE checked provider entry to the runner", async () => {
|
||||
await drain(
|
||||
await streamFencedReply({ ...baseArgs, docs: [doc("a", "x")] }),
|
||||
);
|
||||
|
||||
@@ -53,6 +53,7 @@ vi.mock("@/lib/ai/ai-budgets", () => ({
|
||||
}));
|
||||
|
||||
import { indexDocumentContent } from "../index-document";
|
||||
import { AiUnavailableError } from "@/lib/ai/capabilities/refusal";
|
||||
import {
|
||||
loadOwnedDocument,
|
||||
prepareVisionInput,
|
||||
@@ -259,6 +260,26 @@ describe("indexDocumentContent — local fallback path", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("indexDocumentContent — the documentAi capability", () => {
|
||||
it("indexes through the provider-free text layer when the pick was withheld", async () => {
|
||||
// The operator turned document reading off after the upload enqueued this
|
||||
// job: the pick comes back empty with the refusal beside it, no model is
|
||||
// called, and the text layer still makes the document searchable.
|
||||
vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({
|
||||
chain: [],
|
||||
pick: null,
|
||||
withheld: new AiUnavailableError("documentAi", "operator_disabled"),
|
||||
} as never);
|
||||
|
||||
const outcome = await indexDocumentContent("user-1", "doc-1");
|
||||
|
||||
expect(transcribeDocument).not.toHaveBeenCalled();
|
||||
expect(reserveBudget).not.toHaveBeenCalled();
|
||||
expect(localExtractText).toHaveBeenCalledTimes(1);
|
||||
expect(outcome).toMatchObject({ indexed: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("indexDocumentContent — fail-closed", () => {
|
||||
it("returns not-found for an unknown / non-owned document", async () => {
|
||||
vi.mocked(loadOwnedDocument).mockResolvedValue(null as never);
|
||||
|
||||
@@ -12,13 +12,27 @@ vi.mock("@/lib/labs/ocr-capability", () => ({
|
||||
resolveVisionProvider: vi.fn(),
|
||||
resolveTextProvider: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/capabilities/egress", () => ({
|
||||
aiEgressRefusal: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/capabilities/gate", () => ({
|
||||
getAiCapability: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
documentEgressClass,
|
||||
reorderChainForDocumentClass,
|
||||
requireDocumentTextProvider,
|
||||
requireDocumentVisionProvider,
|
||||
resolveDocumentAiCapability,
|
||||
resolveDocumentTextProvider,
|
||||
resolveDocumentVisionProvider,
|
||||
} from "../provider-order";
|
||||
import { aiEgressRefusal } from "@/lib/ai/capabilities/egress";
|
||||
import { getAiCapability } from "@/lib/ai/capabilities/gate";
|
||||
import { AiUnavailableError } from "@/lib/ai/capabilities/refusal";
|
||||
import {
|
||||
resolveTextProvider,
|
||||
resolveVisionProvider,
|
||||
type VisionProviderPick,
|
||||
} from "@/lib/labs/ocr-capability";
|
||||
@@ -103,7 +117,11 @@ describe("documentEgressClass", () => {
|
||||
});
|
||||
|
||||
describe("resolveDocumentVisionProvider", () => {
|
||||
beforeEach(() => vi.mocked(resolveVisionProvider).mockReset());
|
||||
beforeEach(() => {
|
||||
vi.mocked(resolveVisionProvider).mockReset();
|
||||
vi.mocked(aiEgressRefusal).mockReset();
|
||||
vi.mocked(aiEgressRefusal).mockResolvedValue(null);
|
||||
});
|
||||
|
||||
it("invokes the shared resolver with the document reorder that demotes codex", async () => {
|
||||
vi.mocked(resolveVisionProvider).mockResolvedValue({
|
||||
@@ -124,3 +142,152 @@ describe("resolveDocumentVisionProvider", () => {
|
||||
).toEqual(["local", "codex"]);
|
||||
});
|
||||
});
|
||||
|
||||
function visionPick(providerType: string): VisionProviderPick {
|
||||
return {
|
||||
chain: [entry(providerType)],
|
||||
localOcrEnabled: false,
|
||||
pick: {
|
||||
entry: entry(providerType),
|
||||
providerType: providerType as never,
|
||||
pdfSupported: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("the document pick re-checks documentAi at the wire", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(resolveVisionProvider).mockReset();
|
||||
vi.mocked(resolveTextProvider).mockReset();
|
||||
vi.mocked(aiEgressRefusal).mockReset();
|
||||
vi.mocked(aiEgressRefusal).mockResolvedValue(null);
|
||||
});
|
||||
|
||||
it("asks about the picked provider, under documentAi", async () => {
|
||||
vi.mocked(resolveVisionProvider).mockResolvedValue(visionPick("openai"));
|
||||
const result = await resolveDocumentVisionProvider("u1");
|
||||
expect(aiEgressRefusal).toHaveBeenCalledWith("documentAi", "u1", [
|
||||
"openai",
|
||||
]);
|
||||
expect(result.pick?.providerType).toBe("openai");
|
||||
expect(result.withheld).toBeNull();
|
||||
});
|
||||
|
||||
it("withholds the pick when the capability closed after the route answered", async () => {
|
||||
// A job enqueued before the operator turned the switch off: the pick is
|
||||
// resolved, and the wire says no. The caller sees no provider to call.
|
||||
const refusal = new AiUnavailableError("documentAi", "operator_disabled");
|
||||
vi.mocked(resolveVisionProvider).mockResolvedValue(visionPick("local"));
|
||||
vi.mocked(aiEgressRefusal).mockResolvedValue(refusal);
|
||||
const result = await resolveDocumentVisionProvider("u1");
|
||||
expect(result.pick).toBeNull();
|
||||
expect(result.withheld).toBe(refusal);
|
||||
});
|
||||
|
||||
it("withholds a text-mode pick the same way", async () => {
|
||||
const refusal = new AiUnavailableError("documentAi", "consent_required");
|
||||
vi.mocked(resolveTextProvider).mockResolvedValue({
|
||||
chain: [entry("codex")],
|
||||
pick: { entry: entry("codex"), providerType: "codex" },
|
||||
});
|
||||
vi.mocked(aiEgressRefusal).mockResolvedValue(refusal);
|
||||
const result = await resolveDocumentTextProvider("u1");
|
||||
expect(aiEgressRefusal).toHaveBeenCalledWith("documentAi", "u1", ["codex"]);
|
||||
expect(result.pick).toBeNull();
|
||||
expect(result.withheld).toBe(refusal);
|
||||
});
|
||||
|
||||
it("does not ask when nothing was picked", async () => {
|
||||
vi.mocked(resolveVisionProvider).mockResolvedValue({
|
||||
chain: [],
|
||||
localOcrEnabled: false,
|
||||
pick: null,
|
||||
});
|
||||
const result = await resolveDocumentVisionProvider("u1");
|
||||
expect(aiEgressRefusal).not.toHaveBeenCalled();
|
||||
expect(result.withheld).toBeNull();
|
||||
});
|
||||
|
||||
it("the route form throws the refusal", async () => {
|
||||
const refusal = new AiUnavailableError("documentAi", "operator_disabled");
|
||||
vi.mocked(resolveVisionProvider).mockResolvedValue(visionPick("local"));
|
||||
vi.mocked(aiEgressRefusal).mockResolvedValue(refusal);
|
||||
await expect(requireDocumentVisionProvider("u1")).rejects.toBe(refusal);
|
||||
});
|
||||
|
||||
it("the route form refuses a missing provider with the vault's own code", async () => {
|
||||
vi.mocked(resolveTextProvider).mockResolvedValue({ chain: [], pick: null });
|
||||
const error = await requireDocumentTextProvider("u1").catch((e) => e);
|
||||
expect(error).toBeInstanceOf(AiUnavailableError);
|
||||
expect(error.status).toBe(422);
|
||||
expect(error.meta).toEqual({
|
||||
errorCode: "documents.inbound.providerUnsupported",
|
||||
capability: "documentAi",
|
||||
reason: "no_provider",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveDocumentAiCapability", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(resolveVisionProvider).mockReset();
|
||||
vi.mocked(aiEgressRefusal).mockReset();
|
||||
vi.mocked(getAiCapability).mockReset();
|
||||
});
|
||||
|
||||
it("reports the capability beside the provider answer", async () => {
|
||||
vi.mocked(resolveVisionProvider).mockResolvedValue(visionPick("local"));
|
||||
vi.mocked(getAiCapability).mockResolvedValue({
|
||||
available: true,
|
||||
reason: null,
|
||||
onDeviceAllowed: true,
|
||||
});
|
||||
const dto = await resolveDocumentAiCapability("u1");
|
||||
expect(dto).toMatchObject({
|
||||
available: true,
|
||||
mode: "vision",
|
||||
egress: "local",
|
||||
ai: { available: true, reason: null },
|
||||
});
|
||||
// A probe never runs the wire re-check: it sends nothing.
|
||||
expect(aiEgressRefusal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is unavailable when the operator turned document reading off", async () => {
|
||||
vi.mocked(resolveVisionProvider).mockResolvedValue(visionPick("local"));
|
||||
vi.mocked(getAiCapability).mockResolvedValue({
|
||||
available: false,
|
||||
reason: "operator_disabled",
|
||||
onDeviceAllowed: false,
|
||||
});
|
||||
const dto = await resolveDocumentAiCapability("u1");
|
||||
expect(dto).toEqual({
|
||||
available: false,
|
||||
mode: null,
|
||||
reason: null,
|
||||
pdfSupported: false,
|
||||
egress: null,
|
||||
ai: {
|
||||
available: false,
|
||||
reason: "operator_disabled",
|
||||
onDeviceAllowed: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("stays available when only a consent receipt is missing, so the read can ask for it", async () => {
|
||||
vi.mocked(resolveVisionProvider).mockResolvedValue(visionPick("openai"));
|
||||
vi.mocked(getAiCapability).mockResolvedValue({
|
||||
available: false,
|
||||
reason: "consent_required",
|
||||
onDeviceAllowed: true,
|
||||
});
|
||||
const dto = await resolveDocumentAiCapability("u1");
|
||||
expect(dto).toMatchObject({
|
||||
available: true,
|
||||
mode: "vision",
|
||||
egress: "external",
|
||||
ai: { reason: "consent_required" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -19,9 +19,12 @@
|
||||
* - NO TOOLS, NO SNAPSHOT — one completion per turn.
|
||||
* - PROMPT-INJECTION FENCING — every document fenced as DATA (`fenceDocument`),
|
||||
* per-doc header fields marker-scrubbed (`buildFencedChatSystemPrompt`).
|
||||
* - PER-DOCUMENT EGRESS CONSENT — the single picked provider (no cascade) is
|
||||
* consent-checked once per attached document; ALL must clear or the turn
|
||||
* refuses (403) BEFORE any egress and BEFORE the user turn is persisted.
|
||||
* - CAPABILITY AT THE WIRE — the single picked provider (no cascade) is
|
||||
* re-checked against `documentAi` (the operator's switch, the module, the
|
||||
* sharing grant, and a document consent receipt for a pick that leaves the
|
||||
* machine) and against every other capability the entry point names (the
|
||||
* Coach, for a turn inside a Coach conversation). Any refusal is a 403
|
||||
* BEFORE any egress and BEFORE the user turn is persisted.
|
||||
* - NUMERIC GROUNDING over the LIVE attachment union only — recomputed per turn
|
||||
* from the join rows, never history / snapshot.
|
||||
* - OWNER-SCOPED TEXT LOADER as a second ownership layer — a corrupted/foreign
|
||||
@@ -33,7 +36,8 @@ import { prisma } from "@/lib/db";
|
||||
import { createSseStream } from "@/lib/sse/create-stream";
|
||||
|
||||
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
|
||||
import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard";
|
||||
import { assertAiEgress } from "@/lib/ai/capabilities/egress";
|
||||
import type { AiCapabilityKey } from "@/lib/ai/capabilities/types";
|
||||
import {
|
||||
AllProvidersFailedError,
|
||||
runStreamingRawCompletionWithFallback,
|
||||
@@ -260,12 +264,19 @@ export interface StreamFencedReplyArgs {
|
||||
/** i18n locale for outbound-guard fallbacks. */
|
||||
locale: Locale;
|
||||
signal: AbortSignal;
|
||||
/**
|
||||
* The capabilities this turn is answered under, beside `documentAi`, which
|
||||
* every fenced turn needs because it reads documents. The Coach's fenced
|
||||
* chat adds `coach`; the vault's single-document chat adds nothing.
|
||||
*/
|
||||
alsoRequires: readonly AiCapabilityKey[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Run ONE fenced turn against a resolved conversation and its live documents, and
|
||||
* return the SSE response. May throw `ConsentRequiredError` (403, rendered as
|
||||
* JSON by the api-handler) BEFORE any egress — the consent fan-out runs first.
|
||||
* return the SSE response. May throw `AiUnavailableError` (403, rendered as
|
||||
* JSON by the api-handler) BEFORE any egress — the capability re-check runs
|
||||
* first.
|
||||
*/
|
||||
export async function streamFencedReply(
|
||||
args: StreamFencedReplyArgs,
|
||||
@@ -274,29 +285,28 @@ export async function streamFencedReply(
|
||||
args;
|
||||
|
||||
// ── Provider resolution: document order (local-first, codex last), SINGLE
|
||||
// pick, NO cascade — so the exact egress equals the consent-checked target. ──
|
||||
const { pick } = await resolveDocumentTextProvider(userId);
|
||||
// pick, NO cascade — so the exact egress equals the checked target. The pick
|
||||
// re-checks `documentAi` for that provider, consent receipt included; a
|
||||
// refusal is thrown (403) before any egress. ──
|
||||
const { pick, withheld } = await resolveDocumentTextProvider(userId);
|
||||
if (withheld) throw withheld;
|
||||
if (!pick) {
|
||||
annotate({ action: { name: "documents.chat.noProvider" } });
|
||||
return streamFencedError("documents.chat.provider.none");
|
||||
}
|
||||
|
||||
// ── Per-document egress consent fan-out. Structured as a loop over the live
|
||||
// attachments (annotated per doc id) so a future per-document consent
|
||||
// dimension slots into this seam. ALL must clear or the first throws
|
||||
// ConsentRequiredError (403) — zero egress on partial consent, BEFORE budget
|
||||
// and BEFORE the user turn is persisted. ──
|
||||
for (const doc of docs) {
|
||||
annotate({
|
||||
action: { name: "documents.chat.consent_check" },
|
||||
meta: { documentId: doc.documentId, provider: pick.providerType },
|
||||
});
|
||||
await assertDocumentEgressConsent({
|
||||
userId,
|
||||
providerType: pick.providerType,
|
||||
surface: "insights",
|
||||
});
|
||||
// ── Every other capability the entry point answers under, for the same
|
||||
// provider, BEFORE budget and BEFORE the user turn is persisted. ──
|
||||
for (const key of args.alsoRequires) {
|
||||
await assertAiEgress(key, userId, [pick.providerType]);
|
||||
}
|
||||
annotate({
|
||||
action: { name: "documents.chat.consent_check" },
|
||||
meta: {
|
||||
documentIds: docs.map((doc) => doc.documentId),
|
||||
provider: pick.providerType,
|
||||
},
|
||||
});
|
||||
|
||||
// Persist the user's turn first so it's on disk regardless of the outcome.
|
||||
await appendMessage({ conversationId, role: "user", content: message });
|
||||
|
||||
@@ -3,8 +3,13 @@
|
||||
* both the per-document auto-index-on-upload job and the corpus backfill.
|
||||
*
|
||||
* Provider resolution follows the DOCUMENT order (local-first, codex last —
|
||||
* `resolveDocumentVisionProvider`), NOT the cost-first app-wide chain, and the
|
||||
* external egress is governed by the per-user `documentsAutoAiRead` opt-in:
|
||||
* `resolveDocumentVisionProvider`), NOT the cost-first app-wide chain. That
|
||||
* pick re-checks the `documentAi` capability for the provider it picked: with
|
||||
* the operator's switch off, the module off, or (for a pick that leaves the
|
||||
* machine) no extraction consent receipt, there is no pick and the tree goes
|
||||
* straight to the provider-free text-layer path below, so search keeps working
|
||||
* with AI off. The external egress is further governed by the per-user
|
||||
* `documentsAutoAiRead` opt-in:
|
||||
*
|
||||
* 1. LOCAL PROVIDER (a self-hosted vision model) — never egresses, so it runs
|
||||
* whenever it is the document-order pick, toggle-independent.
|
||||
@@ -149,7 +154,15 @@ export interface ResolvedIndexProvider {
|
||||
export async function resolveIndexProvider(
|
||||
userId: string,
|
||||
): Promise<ResolvedIndexProvider> {
|
||||
const { chain, pick } = await resolveDocumentVisionProvider(userId);
|
||||
// A pick the `documentAi` capability refused comes back null, which lands on
|
||||
// the local text-layer path exactly like having no provider at all.
|
||||
const { chain, pick, withheld } = await resolveDocumentVisionProvider(userId);
|
||||
if (withheld) {
|
||||
annotate({
|
||||
action: { name: "documents.contentIndex.providerWithheld" },
|
||||
meta: { reason: withheld.reason },
|
||||
});
|
||||
}
|
||||
let consentOk = false;
|
||||
if (pick) {
|
||||
if (!isExternalDocumentEgress(pick.providerType)) {
|
||||
|
||||
@@ -19,12 +19,28 @@
|
||||
* consented opt-in.
|
||||
*
|
||||
* The reorder only decides WHICH configured provider is preferred; it never
|
||||
* invents a provider. Egress consent (`assertDocumentEgressConsent`) and the
|
||||
* per-egress vendor-blind UI notice sit on top of this order — the reorder
|
||||
* keeps codex from being the silent default, the consent gate keeps any
|
||||
* external egress from happening without an active receipt.
|
||||
* invents a provider. The per-egress vendor-blind UI notice sits on top of
|
||||
* this order — the reorder keeps codex from being the silent default.
|
||||
*
|
||||
* The pick is also the document class's wire: every read of a stored document
|
||||
* resolves its provider here, the vault routes and the background jobs alike,
|
||||
* so this is where the `documentAi` capability is asked again, about the
|
||||
* provider actually picked, immediately before anything is sent. A switch the
|
||||
* operator turned off after a job was enqueued stops the job here, and an
|
||||
* external pick without an extraction consent receipt never reaches its
|
||||
* provider. The document class is the `documentAi` capability; there is no
|
||||
* other key a caller could pass, so the check is built in rather than taken
|
||||
* as an argument.
|
||||
*/
|
||||
import { isExternalDocumentEgress } from "@/lib/ai/consent-guard";
|
||||
import { aiEgressRefusal } from "@/lib/ai/capabilities/egress";
|
||||
import { getAiCapability } from "@/lib/ai/capabilities/gate";
|
||||
import { PICK_DECIDED_REASONS } from "@/lib/ai/capabilities/types";
|
||||
import {
|
||||
AiUnavailableError,
|
||||
type NoProviderRefusal,
|
||||
} from "@/lib/ai/capabilities/refusal";
|
||||
import type { ProviderChainResolved } from "@/lib/ai/provider-runner";
|
||||
import { documentProviderRank } from "@/lib/documents/provider-rank";
|
||||
import { RASTERIZATION_AVAILABLE } from "@/lib/documents/rasterize-pdf";
|
||||
import {
|
||||
@@ -38,6 +54,15 @@ import type {
|
||||
DocumentEgressClass,
|
||||
} from "@/lib/validations/inbound-documents";
|
||||
|
||||
/**
|
||||
* How the vault refuses a read with no provider that can serve it. The code
|
||||
* predates the capability envelope and a client already branches on it.
|
||||
*/
|
||||
export const DOCUMENT_NO_PROVIDER: NoProviderRefusal = {
|
||||
errorCode: "documents.inbound.providerUnsupported",
|
||||
status: 422,
|
||||
};
|
||||
|
||||
/**
|
||||
* Reorder a resolved chain for the document class: stable sort by
|
||||
* `documentProviderRank`, so within a rank tier the user's own chain order is
|
||||
@@ -53,27 +78,92 @@ export const reorderChainForDocumentClass: ChainReorder = (chain) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve the vision provider for a DOCUMENT read — local-first, codex last.
|
||||
* Same shape as `resolveVisionProvider`; the returned `chain` is already in
|
||||
* document order and the `pick` is the first vision-capable entry in it.
|
||||
* A document pick after the wire re-check. `pick` is null when nothing can
|
||||
* serve the read OR when the capability refused it; `withheld` says which.
|
||||
* A job reads `pick` and skips; a route throws `withheld` so the person sees
|
||||
* the real reason.
|
||||
*/
|
||||
export function resolveDocumentVisionProvider(
|
||||
export type DocumentProviderPick<T> = T & {
|
||||
withheld: AiUnavailableError | null;
|
||||
};
|
||||
|
||||
async function atTheWire<T extends { pick: { providerType: string } | null }>(
|
||||
userId: string,
|
||||
): Promise<VisionProviderPick> {
|
||||
return resolveVisionProvider(userId, {
|
||||
reorder: reorderChainForDocumentClass,
|
||||
});
|
||||
resolved: T,
|
||||
): Promise<DocumentProviderPick<T>> {
|
||||
if (!resolved.pick) return { ...resolved, withheld: null };
|
||||
const withheld = await aiEgressRefusal("documentAi", userId, [
|
||||
resolved.pick.providerType,
|
||||
]);
|
||||
return withheld
|
||||
? { ...resolved, pick: null, withheld }
|
||||
: { ...resolved, withheld: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the text-mode (browser-OCR) structuring provider for a DOCUMENT —
|
||||
* local-first, codex last. The `pick` is the first entry of the reordered
|
||||
* chain.
|
||||
* Resolve the vision provider for a DOCUMENT read — local-first, codex last.
|
||||
* Same shape as `resolveVisionProvider`; the returned `chain` is already in
|
||||
* document order and the `pick` is the first vision-capable entry in it, or
|
||||
* null when `documentAi` refuses the wire.
|
||||
*/
|
||||
export function resolveDocumentTextProvider(userId: string) {
|
||||
return resolveTextProvider(userId, {
|
||||
reorder: reorderChainForDocumentClass,
|
||||
});
|
||||
export async function resolveDocumentVisionProvider(
|
||||
userId: string,
|
||||
): Promise<DocumentProviderPick<VisionProviderPick>> {
|
||||
return atTheWire(
|
||||
userId,
|
||||
await resolveVisionProvider(userId, {
|
||||
reorder: reorderChainForDocumentClass,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the text-mode structuring provider for a DOCUMENT — local-first,
|
||||
* codex last. The `pick` is the first entry of the reordered chain, or null
|
||||
* when `documentAi` refuses the wire.
|
||||
*/
|
||||
export async function resolveDocumentTextProvider(userId: string): Promise<
|
||||
DocumentProviderPick<{
|
||||
chain: ProviderChainResolved[];
|
||||
pick: { entry: ProviderChainResolved; providerType: string } | null;
|
||||
}>
|
||||
> {
|
||||
return atTheWire(
|
||||
userId,
|
||||
await resolveTextProvider(userId, {
|
||||
reorder: reorderChainForDocumentClass,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function required<T>(result: {
|
||||
pick: T | null;
|
||||
withheld: AiUnavailableError | null;
|
||||
}): T {
|
||||
if (result.withheld) throw result.withheld;
|
||||
if (!result.pick) {
|
||||
throw new AiUnavailableError(
|
||||
"documentAi",
|
||||
"no_provider",
|
||||
null,
|
||||
DOCUMENT_NO_PROVIDER,
|
||||
);
|
||||
}
|
||||
return result.pick;
|
||||
}
|
||||
|
||||
/**
|
||||
* The route form of {@link resolveDocumentVisionProvider}: the pick, or the
|
||||
* refusal thrown for `apiHandler` to render (`documents.inbound.providerUnsupported`
|
||||
* when nothing can read the document, the capability envelope otherwise).
|
||||
*/
|
||||
export async function requireDocumentVisionProvider(userId: string) {
|
||||
return required(await resolveDocumentVisionProvider(userId));
|
||||
}
|
||||
|
||||
/** The route form of {@link resolveDocumentTextProvider}. */
|
||||
export async function requireDocumentTextProvider(userId: string) {
|
||||
return required(await resolveDocumentTextProvider(userId));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -89,12 +179,31 @@ export function documentEgressClass(providerType: string): DocumentEgressClass {
|
||||
* Resolve the document AI capability for the vault UI. The `mode` /
|
||||
* `pdfSupported` / `egress` reflect the DOCUMENT provider order (local-first),
|
||||
* so the affordance the UI offers matches exactly what the document routes do.
|
||||
*
|
||||
* `ai` is the `documentAi` capability for the request's record. A read the
|
||||
* operator, the record's modules or the sharing grant close is not offered
|
||||
* (`available: false`, no mode). A missing consent receipt keeps the read
|
||||
* offered: the read itself is where the person is asked for the receipt, and
|
||||
* hiding the button would leave them no way to give it.
|
||||
*/
|
||||
export async function resolveDocumentAiCapability(
|
||||
userId: string,
|
||||
): Promise<DocumentAiCapabilityDto> {
|
||||
const { chain, pick, localOcrEnabled } =
|
||||
await resolveDocumentVisionProvider(userId);
|
||||
const [{ chain, pick, localOcrEnabled }, ai] = await Promise.all([
|
||||
resolveVisionProvider(userId, { reorder: reorderChainForDocumentClass }),
|
||||
getAiCapability("documentAi"),
|
||||
]);
|
||||
|
||||
if (ai.reason !== null && !PICK_DECIDED_REASONS.has(ai.reason)) {
|
||||
return {
|
||||
available: false,
|
||||
mode: null,
|
||||
reason: null,
|
||||
pdfSupported: false,
|
||||
egress: null,
|
||||
ai,
|
||||
};
|
||||
}
|
||||
|
||||
// A vision-capable provider is available — the read runs directly over the
|
||||
// stored original. Egress follows the picked provider. PDFs are readable
|
||||
@@ -108,6 +217,7 @@ export async function resolveDocumentAiCapability(
|
||||
reason: null,
|
||||
pdfSupported: pick.pdfSupported || RASTERIZATION_AVAILABLE,
|
||||
egress: documentEgressClass(pick.providerType),
|
||||
ai,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -119,6 +229,7 @@ export async function resolveDocumentAiCapability(
|
||||
reason: "no-provider",
|
||||
pdfSupported: false,
|
||||
egress: null,
|
||||
ai,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -131,6 +242,7 @@ export async function resolveDocumentAiCapability(
|
||||
reason: null,
|
||||
pdfSupported: false,
|
||||
egress: documentEgressClass(chain[0]!.providerType),
|
||||
ai,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -141,5 +253,6 @@ export async function resolveDocumentAiCapability(
|
||||
reason: "enable-local-ocr",
|
||||
pdfSupported: false,
|
||||
egress: null,
|
||||
ai,
|
||||
};
|
||||
}
|
||||
@@ -31,9 +31,10 @@
|
||||
* matcher that stopped matching cannot agree with an empty world.
|
||||
*
|
||||
* Mutation checks: adding `await requireAssistantSurface("coach")` to any
|
||||
* route outside the frozen list turns guard 3 red by file name; removing the
|
||||
* `documentAi` entry from `AWAITING_READER` turns guard 2 red; putting
|
||||
* `assistantCorrelationsEnabled` back on the schema turns guard 1 red.
|
||||
* route outside the frozen list turns guard 3 red by file name; deleting the
|
||||
* `requireAiCapability("documentAi"` calls from the document routes (the only
|
||||
* reader of that switch's capabilities in the route tree) turns guard 2 red;
|
||||
* putting `assistantCorrelationsEnabled` back on the schema turns guard 1 red.
|
||||
*/
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
@@ -99,13 +100,7 @@ const PLUMBING = [
|
||||
* Switches with no reader yet, and why that is correct for now. An entry is
|
||||
* a claim with an expiry: the test below fails as soon as a reader appears.
|
||||
*/
|
||||
const AWAITING_READER: Record<string, string> = {
|
||||
documentAi:
|
||||
"The capabilities it covers (documentAi, labsOcr, medicationExtract) are " +
|
||||
"resolved and published, but the document, lab-scan and medication " +
|
||||
"extraction routes move onto requireAiCapability in the same release; " +
|
||||
"until then the master switch is what stops them.",
|
||||
};
|
||||
const AWAITING_READER: Record<string, string> = {};
|
||||
|
||||
/**
|
||||
* Every file that still calls the retired gate. Only ever shrinks.
|
||||
@@ -132,7 +127,6 @@ const RETIRED_GATE_CALLERS = [
|
||||
"src/app/api/insights/pregenerate/route.ts",
|
||||
"src/app/api/insights/pulse-status/route.ts",
|
||||
"src/app/api/insights/weight-status/route.ts",
|
||||
"src/app/api/medications/extract/route.ts",
|
||||
"src/app/coach/page.tsx",
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* The labs scan's wire and its probe.
|
||||
*
|
||||
* `requireLabsOcrProvider` is where a lab report meets a provider: it picks
|
||||
* the provider for the scan mode and asks the `labsOcr` capability again about
|
||||
* exactly that provider. `resolveOcrCapability` is the probe the Labs page
|
||||
* reads to decide whether to offer a scan at all.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findUnique: vi.fn() },
|
||||
appSettings: { findUnique: vi.fn() },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/ai/provider", () => ({
|
||||
resolveProviderChain: vi.fn(),
|
||||
resolveProvider: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/codex-client", () => ({
|
||||
resolveCodexVisionSlug: vi.fn(() => "gpt-5"),
|
||||
}));
|
||||
vi.mock("@/lib/ai/capabilities/egress", () => ({
|
||||
aiEgressRefusal: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/capabilities/gate", () => ({
|
||||
getAiCapability: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
vi.mock("@/lib/documents/rasterize-pdf", () => ({
|
||||
RASTERIZATION_AVAILABLE: true,
|
||||
}));
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { resolveProvider, resolveProviderChain } from "@/lib/ai/provider";
|
||||
import { aiEgressRefusal } from "@/lib/ai/capabilities/egress";
|
||||
import { getAiCapability } from "@/lib/ai/capabilities/gate";
|
||||
import { AiUnavailableError } from "@/lib/ai/capabilities/refusal";
|
||||
|
||||
import {
|
||||
requireLabsOcrProvider,
|
||||
resolveOcrCapability,
|
||||
} from "../ocr-capability";
|
||||
|
||||
function entry(providerType: string) {
|
||||
return { providerType, instance: { type: providerType } } as never;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
aiModel: "claude-sonnet-4-5",
|
||||
labsLocalOcrEnabled: true,
|
||||
} as never);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
adminAiModel: null,
|
||||
} as never);
|
||||
vi.mocked(resolveProvider).mockResolvedValue({ type: "none" } as never);
|
||||
vi.mocked(aiEgressRefusal).mockResolvedValue(null);
|
||||
vi.mocked(getAiCapability).mockResolvedValue({
|
||||
available: true,
|
||||
reason: null,
|
||||
onDeviceAllowed: true,
|
||||
});
|
||||
});
|
||||
|
||||
describe("requireLabsOcrProvider", () => {
|
||||
it("re-checks labsOcr for the vision pick", async () => {
|
||||
vi.mocked(resolveProviderChain).mockResolvedValue([entry("anthropic")]);
|
||||
const pick = await requireLabsOcrProvider("u1", "vision");
|
||||
expect(pick.providerType).toBe("anthropic");
|
||||
expect(aiEgressRefusal).toHaveBeenCalledWith("labsOcr", "u1", [
|
||||
"anthropic",
|
||||
]);
|
||||
});
|
||||
|
||||
it("re-checks labsOcr for the text-mode chain head, not the first vision entry", async () => {
|
||||
vi.mocked(resolveProviderChain).mockResolvedValue([
|
||||
entry("codex"),
|
||||
entry("local"),
|
||||
]);
|
||||
const pick = await requireLabsOcrProvider("u1", "text");
|
||||
expect(pick.providerType).toBe("codex");
|
||||
expect(aiEgressRefusal).toHaveBeenCalledWith("labsOcr", "u1", ["codex"]);
|
||||
});
|
||||
|
||||
it("throws the wire's refusal and hands back no provider", async () => {
|
||||
vi.mocked(resolveProviderChain).mockResolvedValue([entry("anthropic")]);
|
||||
const refusal = new AiUnavailableError("labsOcr", "operator_disabled");
|
||||
vi.mocked(aiEgressRefusal).mockResolvedValue(refusal);
|
||||
await expect(requireLabsOcrProvider("u1", "vision")).rejects.toBe(refusal);
|
||||
});
|
||||
|
||||
it("keeps the scan's own code when nothing can read the report", async () => {
|
||||
vi.mocked(resolveProviderChain).mockResolvedValue([]);
|
||||
const error = await requireLabsOcrProvider("u1", "vision").catch((e) => e);
|
||||
expect(error).toBeInstanceOf(AiUnavailableError);
|
||||
expect(error.status).toBe(422);
|
||||
expect(error.meta).toEqual({
|
||||
errorCode: "labs.ocr.providerUnsupported",
|
||||
capability: "labsOcr",
|
||||
reason: "no_provider",
|
||||
});
|
||||
expect(aiEgressRefusal).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveOcrCapability", () => {
|
||||
it("offers the scan and reports the capability beside it", async () => {
|
||||
vi.mocked(resolveProviderChain).mockResolvedValue([entry("anthropic")]);
|
||||
const dto = await resolveOcrCapability("u1");
|
||||
expect(dto).toMatchObject({
|
||||
available: true,
|
||||
mode: "vision",
|
||||
ai: { available: true },
|
||||
});
|
||||
expect(getAiCapability).toHaveBeenCalledWith("labsOcr");
|
||||
});
|
||||
|
||||
it("offers nothing when the operator turned document reading off", async () => {
|
||||
vi.mocked(resolveProviderChain).mockResolvedValue([entry("anthropic")]);
|
||||
vi.mocked(getAiCapability).mockResolvedValue({
|
||||
available: false,
|
||||
reason: "operator_disabled",
|
||||
onDeviceAllowed: false,
|
||||
});
|
||||
await expect(resolveOcrCapability("u1")).resolves.toEqual({
|
||||
available: false,
|
||||
mode: null,
|
||||
reason: null,
|
||||
pdfSupported: false,
|
||||
ai: {
|
||||
available: false,
|
||||
reason: "operator_disabled",
|
||||
onDeviceAllowed: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("offers nothing with the labs module off", async () => {
|
||||
vi.mocked(resolveProviderChain).mockResolvedValue([entry("anthropic")]);
|
||||
vi.mocked(getAiCapability).mockResolvedValue({
|
||||
available: false,
|
||||
reason: "module_disabled",
|
||||
onDeviceAllowed: false,
|
||||
});
|
||||
const dto = await resolveOcrCapability("u1");
|
||||
expect(dto.available).toBe(false);
|
||||
expect(dto.mode).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the scan offered when only a consent receipt is missing", async () => {
|
||||
vi.mocked(resolveProviderChain).mockResolvedValue([entry("anthropic")]);
|
||||
vi.mocked(getAiCapability).mockResolvedValue({
|
||||
available: false,
|
||||
reason: "consent_required",
|
||||
onDeviceAllowed: true,
|
||||
});
|
||||
const dto = await resolveOcrCapability("u1");
|
||||
expect(dto).toMatchObject({ available: true, mode: "vision" });
|
||||
});
|
||||
});
|
||||
@@ -9,11 +9,19 @@
|
||||
* one place) and pair each surviving entry with the model that drives its
|
||||
* vision allowlist.
|
||||
*/
|
||||
import { aiEgressRefusal } from "@/lib/ai/capabilities/egress";
|
||||
import { getAiCapability } from "@/lib/ai/capabilities/gate";
|
||||
import {
|
||||
AiUnavailableError,
|
||||
type NoProviderRefusal,
|
||||
} from "@/lib/ai/capabilities/refusal";
|
||||
import { PICK_DECIDED_REASONS } from "@/lib/ai/capabilities/types";
|
||||
import type { ProviderChainResolved } from "@/lib/ai/provider-runner";
|
||||
import { resolveProvider, resolveProviderChain } from "@/lib/ai/provider";
|
||||
import { resolveCodexVisionSlug } from "@/lib/ai/codex-client";
|
||||
import { RASTERIZATION_AVAILABLE } from "@/lib/documents/rasterize-pdf";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
supportsPdfForProvider,
|
||||
supportsVisionForConfig,
|
||||
@@ -167,20 +175,91 @@ export async function resolveTextProvider(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* How a lab scan refuses with no provider that can serve it. The code predates
|
||||
* the capability envelope and a client already branches on it.
|
||||
*/
|
||||
export const LABS_OCR_NO_PROVIDER: NoProviderRefusal = {
|
||||
errorCode: "labs.ocr.providerUnsupported",
|
||||
status: 422,
|
||||
};
|
||||
|
||||
/**
|
||||
* The labs scan's wire. Resolve the provider for one scan mode and ask the
|
||||
* `labsOcr` capability again about exactly that provider, immediately before
|
||||
* the report is sent: the operator's switch, the labs module, the sharing
|
||||
* grant, and a document-class consent receipt for any pick that leaves the
|
||||
* machine (a lab report is a document). Throws the refusal for `apiHandler`
|
||||
* to render; a missing provider keeps its `labs.ocr.providerUnsupported` code.
|
||||
*
|
||||
* Vision mode picks the first vision-capable entry in chain order; text mode
|
||||
* (the person's in-browser OCR) takes the chain head, which is why the pick,
|
||||
* not the published capability, answers the consent question.
|
||||
*/
|
||||
export async function requireLabsOcrProvider(
|
||||
userId: string,
|
||||
mode: "vision",
|
||||
): Promise<NonNullable<VisionProviderPick["pick"]>>;
|
||||
export async function requireLabsOcrProvider(
|
||||
userId: string,
|
||||
mode: "text",
|
||||
): Promise<{ entry: ProviderChainResolved; providerType: string }>;
|
||||
export async function requireLabsOcrProvider(
|
||||
userId: string,
|
||||
mode: "vision" | "text",
|
||||
) {
|
||||
const { chain, pick } =
|
||||
mode === "vision"
|
||||
? await resolveVisionProvider(userId)
|
||||
: await resolveTextProvider(userId);
|
||||
if (!pick) {
|
||||
annotate({
|
||||
action: { name: "labs.ocr.providerUnsupported" },
|
||||
meta: {
|
||||
chainLength: chain.length,
|
||||
...(mode === "text" ? { mode } : {}),
|
||||
},
|
||||
});
|
||||
throw new AiUnavailableError(
|
||||
"labsOcr",
|
||||
"no_provider",
|
||||
null,
|
||||
LABS_OCR_NO_PROVIDER,
|
||||
);
|
||||
}
|
||||
const refusal = await aiEgressRefusal("labsOcr", userId, [pick.providerType]);
|
||||
if (refusal) throw refusal;
|
||||
return pick;
|
||||
}
|
||||
|
||||
/**
|
||||
* The cheap capability DTO for the probe endpoint: whether scanning is
|
||||
* available + why not + whether PDFs are accepted. Built from the same
|
||||
* resolution the extract route uses, so the UI never shows an entry the
|
||||
* extract route would 422.
|
||||
*
|
||||
* `ai` is the `labsOcr` capability for the request's record. A scan the
|
||||
* operator, the labs module or the sharing grant close is not offered
|
||||
* (`available: false`, no mode). A missing consent receipt keeps the scan
|
||||
* offered: the scan is where the person is asked for the receipt.
|
||||
*/
|
||||
export async function resolveOcrCapability(
|
||||
userId: string,
|
||||
options?: ResolveProviderOptions,
|
||||
): Promise<OcrCapabilityDto> {
|
||||
const { chain, pick, localOcrEnabled } = await resolveVisionProvider(
|
||||
userId,
|
||||
options,
|
||||
);
|
||||
const [{ chain, pick, localOcrEnabled }, ai] = await Promise.all([
|
||||
resolveVisionProvider(userId),
|
||||
getAiCapability("labsOcr"),
|
||||
]);
|
||||
|
||||
if (ai.reason !== null && !PICK_DECIDED_REASONS.has(ai.reason)) {
|
||||
return {
|
||||
available: false,
|
||||
mode: null,
|
||||
reason: null,
|
||||
pdfSupported: false,
|
||||
ai,
|
||||
};
|
||||
}
|
||||
|
||||
// Prefer the native vision path whenever it is available — it is more
|
||||
// accurate and the client does not download the OCR WASM. PDFs are readable
|
||||
@@ -195,6 +274,7 @@ export async function resolveOcrCapability(
|
||||
mode: "vision",
|
||||
reason: null,
|
||||
pdfSupported: pick.pdfSupported || RASTERIZATION_AVAILABLE,
|
||||
ai,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -206,6 +286,7 @@ export async function resolveOcrCapability(
|
||||
mode: null,
|
||||
reason: "no-provider",
|
||||
pdfSupported: false,
|
||||
ai,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -219,6 +300,7 @@ export async function resolveOcrCapability(
|
||||
mode: "text",
|
||||
reason: null,
|
||||
pdfSupported: false,
|
||||
ai,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -229,5 +311,6 @@ export async function resolveOcrCapability(
|
||||
mode: null,
|
||||
reason: "enable-local-ocr",
|
||||
pdfSupported: false,
|
||||
ai,
|
||||
};
|
||||
}
|
||||
@@ -26,6 +26,8 @@
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { AiCapabilityState } from "@/lib/ai/capabilities/types";
|
||||
|
||||
/** Max facts a single document may stage / confirm. A dense letter is ~40. */
|
||||
export const INBOUND_MAX_FACTS = 120;
|
||||
|
||||
@@ -840,6 +842,12 @@ export interface DocumentAiCapabilityDto {
|
||||
* - null: no read is available (see `reason`).
|
||||
*/
|
||||
egress: DocumentEgressClass | null;
|
||||
/**
|
||||
* The `documentAi` capability for this record. When it is closed by
|
||||
* anything other than a missing provider or a missing consent receipt,
|
||||
* `available` is false with a null `reason`, and this says why.
|
||||
*/
|
||||
ai: AiCapabilityState;
|
||||
}
|
||||
|
||||
// ─── AI assist / summary / index (Document vault P2) ────────────────────────
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
* The DTO types the routes return + the UI consumes are derived here so the
|
||||
* three layers agree on one shape.
|
||||
*/
|
||||
import type { AiCapabilityState } from "@/lib/ai/capabilities/types";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { WrittenOutcome } from "@/lib/outcome/written-outcome";
|
||||
@@ -258,6 +259,12 @@ export interface OcrCapabilityDto {
|
||||
reason: "no-provider" | "enable-local-ocr" | null;
|
||||
/** Whether PDF uploads are accepted (Anthropic vision provider only). */
|
||||
pdfSupported: boolean;
|
||||
/**
|
||||
* The `labsOcr` capability for this record. When it is closed by anything
|
||||
* other than a missing provider or a missing consent receipt, `available`
|
||||
* is false with a null `reason`, and this says why.
|
||||
*/
|
||||
ai: AiCapabilityState;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user