mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Merge branch 'feat/ai-extraction-routes' into feat/ai-jobs-stored-text
Brings in the extraction routes and the capability re-check at the wire (`aiEgressRefusal`), so the jobs and stored-text work can share it. Conflicts in the document routes, the fenced chat, the auto-read toggle and its contract text resolve to the extraction branch: those files no longer call the per-pick document consent helper.
This commit is contained in:
commit
f158f01f18
57 files changed
+3158
-676
No files matched your search
+391
-256
File diff suppressed because it is too large.
Load diff
@@ -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",
|
||||
|
||||
@@ -43,8 +43,19 @@ vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/feature-flags", () => ({
|
||||
loadAssistantSwitches: vi.fn(async () => ({
|
||||
enabled: true,
|
||||
coach: true,
|
||||
briefing: true,
|
||||
insightStatus: true,
|
||||
documentAi: true,
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { resolveProviderForTest } from "@/lib/ai/provider";
|
||||
import { loadAssistantSwitches } from "@/lib/feature-flags";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
@@ -367,6 +378,8 @@ describe("POST /api/ai/test — daily ceiling + ledger", () => {
|
||||
|
||||
expect(response.status).toBe(429);
|
||||
expect(generateCompletion).not.toHaveBeenCalled();
|
||||
const body = (await response.json()) as { meta?: { errorCode?: string } };
|
||||
expect(body.meta?.errorCode).toBe("ai.budget.exceeded");
|
||||
});
|
||||
|
||||
it("records a successful probe on the ledger", async () => {
|
||||
@@ -385,3 +398,48 @@ describe("POST /api/ai/test — daily ceiling + ledger", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/ai/test — the operator's master switch and typed refusals", () => {
|
||||
it("refuses with the master's code when AI is off on the server, before any provider resolves", async () => {
|
||||
vi.mocked(loadAssistantSwitches).mockResolvedValueOnce({
|
||||
enabled: false,
|
||||
coach: false,
|
||||
briefing: false,
|
||||
insightStatus: false,
|
||||
documentAi: false,
|
||||
});
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(response.status).toBe(403);
|
||||
const body = (await response.json()) as { meta: Record<string, unknown> };
|
||||
expect(body.meta).toEqual({
|
||||
errorCode: "assistant.disabled.enabled",
|
||||
reason: "operator_disabled",
|
||||
});
|
||||
expect(resolveProviderForTest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails closed, without a 5xx, when the switches cannot be read", async () => {
|
||||
vi.mocked(loadAssistantSwitches).mockResolvedValueOnce(null);
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(response.status).toBeLessThan(500);
|
||||
const body = (await response.json()) as { meta: Record<string, unknown> };
|
||||
expect(body.meta).toEqual({
|
||||
errorCode: "ai.unavailable",
|
||||
reason: "check_failed",
|
||||
});
|
||||
expect(resolveProviderForTest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("names a missing provider", async () => {
|
||||
vi.mocked(resolveProviderForTest).mockResolvedValue({
|
||||
type: "none",
|
||||
} as never);
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(response.status).toBe(422);
|
||||
const body = (await response.json()) as { meta: Record<string, unknown> };
|
||||
expect(body.meta).toEqual({
|
||||
errorCode: "ai.provider.none",
|
||||
reason: "no_provider",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,23 @@ import {
|
||||
resolveDailyCap,
|
||||
} from "@/lib/ai/coach/budget";
|
||||
import { aiTestOverrideSchema } from "@/lib/validations/ai-provider";
|
||||
import {
|
||||
AI_PROVIDER_NONE_ERROR_CODE,
|
||||
AI_UNAVAILABLE_ERROR_CODE,
|
||||
} from "@/lib/ai/capabilities/types";
|
||||
import { loadAssistantSwitches } from "@/lib/feature-flags";
|
||||
|
||||
/**
|
||||
* The master switch's refusal code. The connection test sends a fixed prompt
|
||||
* and no health data, so it belongs to no capability; it answers to the
|
||||
* operator's master switch alone, under the `assistant.disabled.<switch>`
|
||||
* template with the master's own name (`enabled`) as the last segment.
|
||||
*/
|
||||
const MASTER_SWITCH = "enabled";
|
||||
const AI_TEST_MASTER_DISABLED_CODE = `assistant.disabled.${MASTER_SWITCH}`;
|
||||
|
||||
/** The daily AI budget refusal's code. */
|
||||
const AI_TEST_BUDGET_CODE = "ai.budget.exceeded";
|
||||
|
||||
/**
|
||||
* Output ceiling of the probe below. Mirrors the `maxTokens` on the completion
|
||||
@@ -50,6 +67,27 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "ai.test" } });
|
||||
|
||||
// With the operator's master switch off no AI call leaves this server, and
|
||||
// the connection test is an AI call. A switch set that could not be read
|
||||
// fails closed, like every capability does.
|
||||
// (403, not the usual 503 for `ai.unavailable`: this route never answers
|
||||
// 5xx, because a proxy rewrites a 5xx into an HTML page the settings form
|
||||
// cannot parse. See the catch below.)
|
||||
const switches = await loadAssistantSwitches();
|
||||
if (switches === null) {
|
||||
return apiError("AI is unavailable right now", 403, {
|
||||
errorCode: AI_UNAVAILABLE_ERROR_CODE,
|
||||
reason: "check_failed",
|
||||
});
|
||||
}
|
||||
if (!switches.enabled) {
|
||||
annotate({ action: { name: "ai.test.operator_disabled" } });
|
||||
return apiError("AI is turned off on this server", 403, {
|
||||
errorCode: AI_TEST_MASTER_DISABLED_CODE,
|
||||
reason: "operator_disabled",
|
||||
});
|
||||
}
|
||||
|
||||
const rl = await checkRateLimit(`ai-test:${user.id}`, 5, 60_000);
|
||||
if (!rl.allowed) return apiError("Too many test requests", 429);
|
||||
|
||||
@@ -96,7 +134,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
|
||||
if (provider.type === "none") {
|
||||
return apiError("No AI provider configured", 422);
|
||||
return apiError("No AI provider configured", 422, {
|
||||
errorCode: AI_PROVIDER_NONE_ERROR_CODE,
|
||||
reason: "no_provider",
|
||||
});
|
||||
}
|
||||
|
||||
// Meter the probe on the same daily ledger every other AI surface writes to,
|
||||
@@ -123,7 +164,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
);
|
||||
if (!reservation.allowed) {
|
||||
annotate({ action: { name: "ai.test.budget_exceeded" } });
|
||||
return apiError("Daily AI budget exhausted", 429);
|
||||
return apiError("Daily AI budget exhausted", 429, {
|
||||
errorCode: AI_TEST_BUDGET_CODE,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -11,7 +11,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { user: { findUnique: vi.fn(), update: vi.fn() } },
|
||||
prisma: {
|
||||
user: { findUnique: vi.fn(), update: vi.fn() },
|
||||
consentReceipt: { findFirst: vi.fn(), create: vi.fn() },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
@@ -21,8 +24,8 @@ vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/consent/web-grant", () => ({
|
||||
ensureExtractionConsentReceipt: vi.fn().mockResolvedValue(undefined),
|
||||
vi.mock("@/lib/ai/capabilities/gate", () => ({
|
||||
getAiCapability: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/jobs/document-summary-catchup", () => ({
|
||||
enqueueSummaryCatchUp: vi.fn().mockResolvedValue({ enqueued: true }),
|
||||
@@ -47,7 +50,7 @@ vi.mock("next/headers", () => ({
|
||||
import { PATCH } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { ensureExtractionConsentReceipt } from "@/lib/consent/web-grant";
|
||||
import { getAiCapability } from "@/lib/ai/capabilities/gate";
|
||||
import { enqueueSummaryCatchUp } from "@/lib/jobs/document-summary-catchup";
|
||||
|
||||
const SESSION_OK = {
|
||||
@@ -74,6 +77,13 @@ beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(null);
|
||||
vi.mocked(prisma.consentReceipt.create).mockResolvedValue({} as never);
|
||||
vi.mocked(getAiCapability).mockResolvedValue({
|
||||
available: true,
|
||||
reason: null,
|
||||
onDeviceAllowed: true,
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /api/auth/me/documents-auto-ai-read — catch-up scheduling", () => {
|
||||
@@ -103,20 +113,75 @@ describe("PATCH /api/auth/me/documents-auto-ai-read — catch-up scheduling", ()
|
||||
await PATCH(mkPatch(false));
|
||||
|
||||
expect(enqueueSummaryCatchUp).not.toHaveBeenCalled();
|
||||
expect(ensureExtractionConsentReceipt).not.toHaveBeenCalled();
|
||||
expect(prisma.consentReceipt.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still mints the consent receipt on the flip that schedules the pass", async () => {
|
||||
// The catch-up rides the same act of consent, never around it. The mint
|
||||
// is the extraction kind (documents, lab scans, medication text) and
|
||||
// nothing wider: switching document reading on does not consent to the
|
||||
// Coach or to the analysis. It is the person's own affirmative act, so
|
||||
// it may lift an earlier withdrawal of the same kind.
|
||||
it("mints the extraction receipt on the flip that schedules the pass", async () => {
|
||||
// The catch-up rides the same act of consent, never around it. The toggle
|
||||
// grants document reading only: `ai_extraction`, not the master `ai_full`
|
||||
// that would also open the Coach and the AI analysis.
|
||||
withPrevious(false);
|
||||
|
||||
await PATCH(mkPatch(true));
|
||||
|
||||
expect(ensureExtractionConsentReceipt).toHaveBeenCalledWith("user-1");
|
||||
expect(prisma.consentReceipt.create).toHaveBeenCalledTimes(1);
|
||||
const data = vi.mocked(prisma.consentReceipt.create).mock.calls[0]![0]
|
||||
.data as { userId: string; kind: string };
|
||||
expect(data.userId).toBe("user-1");
|
||||
expect(data.kind).toBe("ai_extraction");
|
||||
// The capability is read after the mint, so the fresh receipt counts.
|
||||
const mintOrder = vi.mocked(prisma.consentReceipt.create).mock
|
||||
.invocationCallOrder[0]!;
|
||||
const readOrder = vi.mocked(getAiCapability).mock.invocationCallOrder[0]!;
|
||||
expect(mintOrder).toBeLessThan(readOrder);
|
||||
});
|
||||
|
||||
it("does not mint a second receipt when one that covers document reads is active", async () => {
|
||||
withPrevious(false);
|
||||
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue({
|
||||
id: "r1",
|
||||
} as never);
|
||||
|
||||
await PATCH(mkPatch(true));
|
||||
|
||||
expect(prisma.consentReceipt.findFirst).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
userId: "user-1",
|
||||
revokedAt: null,
|
||||
kind: { in: ["ai_extraction", "ai_full"] },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(prisma.consentReceipt.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("treats a concurrent mint as success", async () => {
|
||||
withPrevious(false);
|
||||
vi.mocked(prisma.consentReceipt.create).mockRejectedValue({
|
||||
code: "P2002",
|
||||
});
|
||||
|
||||
const res = await PATCH(mkPatch(true));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("saves the preference but schedules no catch-up while document reading is closed", async () => {
|
||||
// The operator turned document reading off: every summary the pass would
|
||||
// enqueue could only be refused, so it is not enqueued at all.
|
||||
withPrevious(false);
|
||||
vi.mocked(getAiCapability).mockResolvedValue({
|
||||
available: false,
|
||||
reason: "operator_disabled",
|
||||
onDeviceAllowed: false,
|
||||
});
|
||||
|
||||
const res = await PATCH(mkPatch(true));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.user.update).toHaveBeenCalled();
|
||||
expect(enqueueSummaryCatchUp).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("persists the flag field-by-field", async () => {
|
||||
|
||||
@@ -10,12 +10,17 @@
|
||||
* through the user's configured external provider with no per-document tap.
|
||||
*
|
||||
* Flipping it ON is itself the standing consent act, so the write also mints an
|
||||
* append-only `ai_full` consent receipt (`ensureWebAiConsentReceipt`) — the
|
||||
* durable audit record that sits alongside the runtime short-circuit the
|
||||
* document consent gate reads. An OFF→ON flip additionally schedules a bounded
|
||||
* catch-up over the documents already stored (`enqueueSummaryCatchUp`), because
|
||||
* the summary job is enqueued at upload time and would otherwise never revisit
|
||||
* a vault filled before the opt-in. Mirrors `auth/me/labs-local-ocr`: 60/min rate
|
||||
* append-only `ai_extraction` consent receipt — the durable record the document
|
||||
* capability reads. It covers reading documents, lab reports and medication
|
||||
* text and nothing else: the Coach and the AI analysis keep asking for their
|
||||
* own consent. (Before this release the toggle minted `ai_full`, which also
|
||||
* opened those; receipts minted that way stay valid, because nothing tells
|
||||
* them apart from a deliberate master grant.) An OFF→ON flip additionally
|
||||
* schedules a bounded catch-up over the documents already stored
|
||||
* (`enqueueSummaryCatchUp`), because the summary job is enqueued at upload time
|
||||
* and would otherwise never revisit a vault filled before the opt-in; it is
|
||||
* skipped when the `documentAi` capability is closed for the record, since
|
||||
* every summary it would enqueue could only be refused. Mirrors `auth/me/labs-local-ocr`: 60/min rate
|
||||
* limit, Zod `safeParse` → 422 via `returnAllZodIssues`, audit-log row,
|
||||
* field-by-field write (no mass assignment). Idempotent — always returns the
|
||||
* resolved next state so the client can hard-set the optimistic update.
|
||||
@@ -30,8 +35,10 @@ import {
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { ensureExtractionConsentReceipt } from "@/lib/consent/web-grant";
|
||||
import { getAiCapability } from "@/lib/ai/capabilities/gate";
|
||||
import { AI_CAPABILITIES } from "@/lib/ai/capabilities/types";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { isP2002 } from "@/lib/prisma-errors";
|
||||
import { enqueueSummaryCatchUp } from "@/lib/jobs/document-summary-catchup";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { documentsAutoAiReadPatchSchema } from "@/lib/validations/user-prefs";
|
||||
@@ -45,6 +52,55 @@ type DocumentsAutoAiReadResponse = {
|
||||
documentsAutoAiRead: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* The receipt kind the toggle mints: the extraction grant, narrower than the
|
||||
* master `ai_full`. The capability table lists it (with `ai_full`) as what
|
||||
* satisfies the document consent rule.
|
||||
*/
|
||||
const AUTO_READ_CONSENT_KIND = "ai_extraction";
|
||||
|
||||
/**
|
||||
* Mint the extraction receipt unless one that satisfies document reads is
|
||||
* already active. The toggle is an affirmative act, so an earlier revocation
|
||||
* does not block it, exactly as a first grant would not be blocked. The
|
||||
* partial unique index on active receipts is the backstop for two concurrent
|
||||
* flips; losing that race means the other flip already minted, which is the
|
||||
* outcome wanted. Returns whether a receipt was written.
|
||||
*/
|
||||
async function ensureExtractionReceipt(
|
||||
userId: string,
|
||||
now: Date = new Date(),
|
||||
): Promise<boolean> {
|
||||
const active = await prisma.consentReceipt.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
revokedAt: null,
|
||||
kind: { in: [...AI_CAPABILITIES.documentAi.consent.kinds] },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (active) return false;
|
||||
try {
|
||||
await prisma.consentReceipt.create({
|
||||
data: {
|
||||
userId,
|
||||
kind: AUTO_READ_CONSENT_KIND,
|
||||
artefact: JSON.stringify({
|
||||
source: "web",
|
||||
kind: AUTO_READ_CONSENT_KIND,
|
||||
grantedAt: now.toISOString(),
|
||||
note: "Documents read automatically with AI, turned on in the app.",
|
||||
}),
|
||||
signedAt: now,
|
||||
},
|
||||
});
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (isP2002(err)) return false;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "auth.me.documentsAutoAiRead.get" } });
|
||||
@@ -102,13 +158,10 @@ export const PATCH = apiHandler(async (req: Request) => {
|
||||
data: { documentsAutoAiRead: next },
|
||||
});
|
||||
|
||||
// Turning the toggle ON is the consent act for reading documents — mint an
|
||||
// append-only `ai_extraction` receipt (idempotent). The document consent
|
||||
// gate reads that receipt, not the toggle, so a later revocation wins over
|
||||
// a toggle left on. It does not grant the Coach or the analysis.
|
||||
if (next) {
|
||||
await ensureExtractionConsentReceipt(user.id);
|
||||
}
|
||||
// Turning the toggle ON is the standing consent act — mint an append-only
|
||||
// extraction receipt (idempotent) so the durable audit trail records it and
|
||||
// the document capability can read it.
|
||||
const consentMinted = next ? await ensureExtractionReceipt(user.id) : false;
|
||||
|
||||
// A genuine OFF→ON flip schedules a catch-up over the documents already in
|
||||
// the vault. Without it the switch only ever applied to FUTURE uploads: the
|
||||
@@ -118,7 +171,16 @@ export const PATCH = apiHandler(async (req: Request) => {
|
||||
// budget gate still runs per document inside the summary job itself.
|
||||
const wasEnabled = previous?.documentsAutoAiRead ?? false;
|
||||
if (next && !wasEnabled) {
|
||||
void enqueueSummaryCatchUp(user.id);
|
||||
// Read after the mint, so a receipt this request just wrote counts.
|
||||
const documentAi = await getAiCapability("documentAi");
|
||||
if (documentAi.available) {
|
||||
void enqueueSummaryCatchUp(user.id);
|
||||
} else {
|
||||
annotate({
|
||||
action: { name: "auth.me.documentsAutoAiRead.catchUpSkipped" },
|
||||
meta: { reason: documentAi.reason },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await auditLog("user.documentsAutoAiRead.update", {
|
||||
@@ -127,6 +189,7 @@ export const PATCH = apiHandler(async (req: Request) => {
|
||||
details: {
|
||||
previous: previous?.documentsAutoAiRead ?? false,
|
||||
next,
|
||||
consentMinted,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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,17 +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,
|
||||
});
|
||||
const pick = await requireDocumentTextProvider(userId);
|
||||
|
||||
const rl = await checkDocumentAiRateLimit(userId);
|
||||
if (!rl.allowed) return documentAiRateLimited(rl);
|
||||
@@ -336,17 +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,
|
||||
});
|
||||
const pick = await requireDocumentTextProvider(userId);
|
||||
|
||||
const rl = await checkDocumentAiRateLimit(userId);
|
||||
if (!rl.allowed) return documentAiRateLimited(rl);
|
||||
@@ -421,17 +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,
|
||||
});
|
||||
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,16 +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,
|
||||
});
|
||||
// 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,17 +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,
|
||||
});
|
||||
const pick = await requireDocumentTextProvider(userId);
|
||||
|
||||
const rl = await checkDocumentAiRateLimit(userId);
|
||||
if (!rl.allowed) return documentAiRateLimited(rl);
|
||||
@@ -217,17 +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,
|
||||
});
|
||||
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,18 +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,
|
||||
});
|
||||
pick = await requireDocumentTextProvider(userId);
|
||||
} catch (err) {
|
||||
// Refused before any dispatch — the slot goes back with the refusal.
|
||||
await refundDocumentAiSlot(userId);
|
||||
@@ -393,16 +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,
|
||||
});
|
||||
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,16 +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,
|
||||
});
|
||||
// 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;
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* The capability re-check at the wire, and the gate option that leaves the
|
||||
* provider questions to the route's own pick.
|
||||
*
|
||||
* The loader is replaced so each case hands the gate exactly the inputs it
|
||||
* needs; the receipt read is replaced so the exact-pick consent answer can be
|
||||
* steered independently of the presence-based one.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
let auth: Record<string, unknown> | undefined;
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
getEvent: () => ({ getAuth: () => auth, addWarning: vi.fn() }),
|
||||
}));
|
||||
vi.mock("@/lib/sharing/grants", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/sharing/grants")>()),
|
||||
findActiveGrant: vi.fn(),
|
||||
}));
|
||||
vi.mock("../load", () => ({ loadAiCapabilityInputs: vi.fn() }));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { consentReceipt: { findFirst: vi.fn() } },
|
||||
}));
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { MODULE_KEYS, type ModuleKey } from "@/lib/modules/registry";
|
||||
import type { ModuleAccessState } from "@/lib/sharing/module-disclosure";
|
||||
|
||||
import { aiEgressRefusal, assertAiEgress } from "../egress";
|
||||
import { requireAiCapability } from "../gate";
|
||||
import { loadAiCapabilityInputs } from "../load";
|
||||
import { AiUnavailableError } from "../refusal";
|
||||
import type { AiCapabilityInputs } from "../resolve";
|
||||
|
||||
function inputs(
|
||||
overrides: Partial<AiCapabilityInputs> = {},
|
||||
): AiCapabilityInputs {
|
||||
const moduleAccess = {} as Record<ModuleKey, ModuleAccessState>;
|
||||
for (const key of MODULE_KEYS) moduleAccess[key] = "enabled";
|
||||
return {
|
||||
switches: {
|
||||
enabled: true,
|
||||
coach: true,
|
||||
briefing: true,
|
||||
insightStatus: true,
|
||||
documentAi: true,
|
||||
},
|
||||
moduleAccess,
|
||||
providerWorkAdmitted: true,
|
||||
provider: {
|
||||
entries: [{ providerType: "local", vision: true }],
|
||||
localOcrEnabled: false,
|
||||
managedBy: "local",
|
||||
},
|
||||
activeConsentKinds: new Set(),
|
||||
recordKind: "self",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const LOAD = vi.mocked(loadAiCapabilityInputs);
|
||||
const RECEIPT = vi.mocked(prisma.consentReceipt.findFirst);
|
||||
|
||||
beforeEach(() => {
|
||||
LOAD.mockReset();
|
||||
RECEIPT.mockReset();
|
||||
RECEIPT.mockResolvedValue(null);
|
||||
auth = { user_id: "user-1" };
|
||||
});
|
||||
|
||||
describe("requireAiCapability with pickDecides", () => {
|
||||
it("leaves no_provider to the route's pick", async () => {
|
||||
LOAD.mockResolvedValue(
|
||||
inputs({
|
||||
provider: { entries: [], localOcrEnabled: false, managedBy: null },
|
||||
}),
|
||||
);
|
||||
await expect(
|
||||
requireAiCapability("documentAi", { pickDecides: true }),
|
||||
).resolves.toMatchObject({ available: true });
|
||||
});
|
||||
|
||||
it("leaves consent_required to the route's pick", async () => {
|
||||
LOAD.mockResolvedValue(
|
||||
inputs({
|
||||
provider: {
|
||||
entries: [{ providerType: "openai", vision: true }],
|
||||
localOcrEnabled: false,
|
||||
managedBy: "user",
|
||||
},
|
||||
}),
|
||||
);
|
||||
await expect(
|
||||
requireAiCapability("documentAi", { pickDecides: true }),
|
||||
).resolves.toMatchObject({ available: true });
|
||||
});
|
||||
|
||||
it("still refuses a layer the pick cannot answer", async () => {
|
||||
LOAD.mockResolvedValue(
|
||||
inputs({ switches: { ...inputs().switches, documentAi: false } }),
|
||||
);
|
||||
const error = await requireAiCapability("documentAi", {
|
||||
pickDecides: true,
|
||||
}).catch((e) => e);
|
||||
expect(error).toBeInstanceOf(AiUnavailableError);
|
||||
expect(error.meta.errorCode).toBe("assistant.disabled.documentAi");
|
||||
});
|
||||
});
|
||||
|
||||
describe("aiEgressRefusal inside a request", () => {
|
||||
it("refuses when the operator switch is off, whatever the pick", async () => {
|
||||
LOAD.mockResolvedValue(
|
||||
inputs({ switches: { ...inputs().switches, documentAi: false } }),
|
||||
);
|
||||
const refusal = await aiEgressRefusal("documentAi", "user-1", ["local"]);
|
||||
expect(refusal).toBeInstanceOf(AiUnavailableError);
|
||||
expect(refusal?.meta).toEqual({
|
||||
errorCode: "assistant.disabled.documentAi",
|
||||
capability: "documentAi",
|
||||
reason: "operator_disabled",
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses the master switch through every extraction capability", async () => {
|
||||
LOAD.mockResolvedValue(
|
||||
inputs({ switches: { ...inputs().switches, enabled: false } }),
|
||||
);
|
||||
for (const key of ["documentAi", "labsOcr", "medicationExtract"] as const) {
|
||||
const refusal = await aiEgressRefusal(key, "user-1", ["local"]);
|
||||
expect(refusal?.reason, key).toBe("operator_disabled");
|
||||
}
|
||||
});
|
||||
|
||||
it("asks for a receipt when the actual pick leaves the machine", async () => {
|
||||
// Presence says a local model would read it; the route picked an external
|
||||
// provider anyway (text mode reads the chain head). The pick decides.
|
||||
LOAD.mockResolvedValue(inputs());
|
||||
const refusal = await aiEgressRefusal("documentAi", "user-1", ["openai"]);
|
||||
expect(refusal?.meta).toEqual({
|
||||
errorCode: "consent.ai.required",
|
||||
capability: "documentAi",
|
||||
reason: "consent_required",
|
||||
});
|
||||
expect(RECEIPT).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
userId: "user-1",
|
||||
revokedAt: null,
|
||||
kind: { in: ["ai_extraction", "ai_full"] },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("admits an external pick with an extraction receipt", async () => {
|
||||
LOAD.mockResolvedValue(inputs());
|
||||
RECEIPT.mockResolvedValue({ id: "r1" } as never);
|
||||
await expect(
|
||||
aiEgressRefusal("documentAi", "user-1", ["openai"]),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("needs no receipt for a local pick, even when presence says one is needed", async () => {
|
||||
LOAD.mockResolvedValue(
|
||||
inputs({
|
||||
provider: {
|
||||
entries: [{ providerType: "openai", vision: true }],
|
||||
localOcrEnabled: false,
|
||||
managedBy: "user",
|
||||
},
|
||||
}),
|
||||
);
|
||||
await expect(
|
||||
aiEgressRefusal("documentAi", "user-1", ["local"]),
|
||||
).resolves.toBeNull();
|
||||
expect(RECEIPT).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("checks every entry of a cascading chain under the document rule", async () => {
|
||||
LOAD.mockResolvedValue(inputs());
|
||||
const refusal = await aiEgressRefusal("medicationExtract", "user-1", [
|
||||
"local",
|
||||
"admin-openai",
|
||||
]);
|
||||
expect(refusal?.reason).toBe("consent_required");
|
||||
await expect(
|
||||
aiEgressRefusal("medicationExtract", "user-1", ["local"]),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("uses the self-snapshot rule for the Coach: a person's own key needs nothing", async () => {
|
||||
LOAD.mockResolvedValue(inputs());
|
||||
await expect(
|
||||
aiEgressRefusal("coach", "user-1", ["openai"]),
|
||||
).resolves.toBeNull();
|
||||
const refusal = await aiEgressRefusal("coach", "user-1", ["admin-openai"]);
|
||||
expect(refusal?.reason).toBe("consent_required");
|
||||
expect(RECEIPT).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
kind: { in: ["ai_coach", "ai_full"] },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("fails closed when the inputs cannot be loaded", async () => {
|
||||
LOAD.mockResolvedValue(null);
|
||||
const refusal = await aiEgressRefusal("labsOcr", "user-1", ["local"]);
|
||||
expect(refusal?.reason).toBe("check_failed");
|
||||
expect(refusal?.status).toBe(503);
|
||||
});
|
||||
});
|
||||
|
||||
describe("aiEgressRefusal outside a request (a job)", () => {
|
||||
beforeEach(() => {
|
||||
auth = undefined;
|
||||
});
|
||||
|
||||
it("resolves for the record the job names", async () => {
|
||||
LOAD.mockResolvedValue(
|
||||
inputs({ switches: { ...inputs().switches, documentAi: false } }),
|
||||
);
|
||||
const refusal = await aiEgressRefusal("documentAi", "user-9", ["local"]);
|
||||
expect(refusal?.reason).toBe("operator_disabled");
|
||||
expect(LOAD).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ recordId: "user-9" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("admits the wire when every layer is open", async () => {
|
||||
LOAD.mockResolvedValue(inputs());
|
||||
await expect(
|
||||
aiEgressRefusal("documentAi", "user-9", ["local"]),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertAiEgress", () => {
|
||||
it("throws the refusal", async () => {
|
||||
LOAD.mockResolvedValue(
|
||||
inputs({ switches: { ...inputs().switches, documentAi: false } }),
|
||||
);
|
||||
await expect(
|
||||
assertAiEgress("medicationExtract", "user-1", ["local"]),
|
||||
).rejects.toBeInstanceOf(AiUnavailableError);
|
||||
});
|
||||
|
||||
it("returns quietly when the wire is open", async () => {
|
||||
LOAD.mockResolvedValue(inputs());
|
||||
await expect(
|
||||
assertAiEgress("labsOcr", "user-1", ["local"]),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* The capability re-check at the wire.
|
||||
*
|
||||
* `requireAiCapability` answers at the top of a route, before anything is
|
||||
* picked. That is the refusal a client sees, but it is not the last word: a
|
||||
* route may pick its provider in its own order, and a job enqueued before the
|
||||
* operator turned a switch off runs long after any route answered. So every
|
||||
* place that hands input to a model (the document provider pick, the labs
|
||||
* scan, the medication extractor, the fenced document chat) asks again here,
|
||||
* with the provider it is about to send to, immediately before it sends.
|
||||
*
|
||||
* Two questions, both about the actual wire:
|
||||
*
|
||||
* 1. Is the capability still open for this record? Inside a request this is
|
||||
* the request's own resolution (memoised per request, so the answer
|
||||
* matches the one the route gave); outside a request it is the job's
|
||||
* resolution for the record it names.
|
||||
* 2. Does sending to exactly these providers need a consent receipt, and is
|
||||
* one active? Presence cannot answer this for a route that reads the
|
||||
* chain in its own order, so the pick answers it here, from the same
|
||||
* rule and the same receipt kinds the capability table declares.
|
||||
*
|
||||
* `no_provider` is never answered here: a caller that has a pick has a
|
||||
* provider, and one without a pick refuses with its own typed code.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
|
||||
import { aiCapabilityForJob, requireAiCapability } from "./gate";
|
||||
import { AiUnavailableError } from "./refusal";
|
||||
import { consentNeededForProviders } from "./resolve";
|
||||
import {
|
||||
AI_CAPABILITIES,
|
||||
PICK_DECIDED_REASONS,
|
||||
type AiCapabilityKey,
|
||||
} from "./types";
|
||||
|
||||
/** Whether the record holds an active receipt of one of these kinds. */
|
||||
async function hasActiveReceipt(
|
||||
recordId: string,
|
||||
kinds: readonly string[],
|
||||
): Promise<boolean> {
|
||||
const row = await prisma.consentReceipt.findFirst({
|
||||
where: { userId: recordId, revokedAt: null, kind: { in: [...kinds] } },
|
||||
select: { id: true },
|
||||
});
|
||||
return row !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Why input may not leave for these providers right now, or `null` when it
|
||||
* may. Never throws for a refusal; a database error propagates, which is the
|
||||
* closed direction for a caller about to send.
|
||||
*
|
||||
* @param recordId The record the input belongs to. Inside a request the
|
||||
* request's own record is used, which is the record the route acts on.
|
||||
* @param providerTypes The providers the input is about to reach: the one
|
||||
* picked provider for a single-pick read, the whole chain for a cascade.
|
||||
*/
|
||||
export async function aiEgressRefusal(
|
||||
key: AiCapabilityKey,
|
||||
recordId: string,
|
||||
providerTypes: readonly string[],
|
||||
): Promise<AiUnavailableError | null> {
|
||||
if (getEvent()?.getAuth()?.user_id) {
|
||||
try {
|
||||
await requireAiCapability(key, { pickDecides: true });
|
||||
} catch (error) {
|
||||
if (error instanceof AiUnavailableError) return error;
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
const state = await aiCapabilityForJob(recordId, key);
|
||||
if (state.reason !== null && !PICK_DECIDED_REASONS.has(state.reason)) {
|
||||
return new AiUnavailableError(key, state.reason);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
consentNeededForProviders(key, providerTypes) &&
|
||||
!(await hasActiveReceipt(recordId, AI_CAPABILITIES[key].consent.kinds))
|
||||
) {
|
||||
return new AiUnavailableError(key, "consent_required");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** {@link aiEgressRefusal}, thrown. For routes, where `apiHandler` renders it. */
|
||||
export async function assertAiEgress(
|
||||
key: AiCapabilityKey,
|
||||
recordId: string,
|
||||
providerTypes: readonly string[],
|
||||
): Promise<void> {
|
||||
const refusal = await aiEgressRefusal(key, recordId, providerTypes);
|
||||
if (refusal) throw refusal;
|
||||
}
|
||||
@@ -40,7 +40,11 @@ import { providerWorkAuthorityForRecord } from "@/lib/sharing/provider-work-auth
|
||||
import { loadAiCapabilityInputs, type AiCapabilityScope } from "./load";
|
||||
import { explainAiCapability, resolveAiCapability } from "./resolve";
|
||||
import { AiUnavailableError, type NoProviderRefusal } from "./refusal";
|
||||
import type { AiCapabilityKey, AiCapabilityState } from "./types";
|
||||
import {
|
||||
PICK_DECIDED_REASONS,
|
||||
type AiCapabilityKey,
|
||||
type AiCapabilityState,
|
||||
} from "./types";
|
||||
|
||||
export interface AiGateOptions {
|
||||
/**
|
||||
@@ -50,6 +54,15 @@ export interface AiGateOptions {
|
||||
recordId?: string;
|
||||
/** The route's own `no_provider` refusal, where a client already reads it. */
|
||||
noProvider?: NoProviderRefusal;
|
||||
/**
|
||||
* Leave `no_provider` and `consent_required` to the provider the route
|
||||
* actually picks. The resolver answers both from presence, which is right
|
||||
* for the published payload but can differ from a route that reads the
|
||||
* chain in its own order (a text-mode document read takes the chain head,
|
||||
* not the first vision entry). A route that sets this must run the pick
|
||||
* through `assertAiEgress` / `aiEgressRefusal`, which answers both exactly.
|
||||
*/
|
||||
pickDecides?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -118,7 +131,10 @@ export async function requireAiCapability(
|
||||
} catch {
|
||||
finding = { reason: "check_failed", module: null };
|
||||
}
|
||||
if (finding.reason !== null) {
|
||||
if (
|
||||
finding.reason !== null &&
|
||||
!(options.pickDecides && PICK_DECIDED_REASONS.has(finding.reason))
|
||||
) {
|
||||
throw new AiUnavailableError(
|
||||
key,
|
||||
finding.reason,
|
||||
|
||||
@@ -223,6 +223,29 @@ function consentNeeded(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether sending to exactly these providers needs a receipt under the
|
||||
* capability's consent rule. The egress re-check asks it about the providers a
|
||||
* route actually picked, so the answer at the wire comes from the same rule the
|
||||
* published capability does. Pass the one picked provider for a single-pick
|
||||
* read, or the whole chain for a cascade.
|
||||
*/
|
||||
export function consentNeededForProviders(
|
||||
key: AiCapabilityKey,
|
||||
providerTypes: readonly string[],
|
||||
): boolean {
|
||||
return consentNeeded(AI_CAPABILITIES[key], {
|
||||
// Every entry named here is one the route will send to, so each counts as
|
||||
// able to read the input.
|
||||
entries: providerTypes.map((providerType) => ({
|
||||
providerType,
|
||||
vision: true,
|
||||
})),
|
||||
localOcrEnabled: false,
|
||||
managedBy: null,
|
||||
});
|
||||
}
|
||||
|
||||
function consentReason(
|
||||
def: AiCapabilityDefinition,
|
||||
provider: ProviderPresence,
|
||||
|
||||
@@ -84,6 +84,15 @@ export interface AiCapabilityState {
|
||||
export const ON_DEVICE_ALLOWED_REASONS: ReadonlySet<AiUnavailableReason> =
|
||||
new Set<AiUnavailableReason>(["no_provider", "consent_required"]);
|
||||
|
||||
/**
|
||||
* The reasons a route's own provider pick answers exactly: whether there is a
|
||||
* provider, and whether sending to it needs a receipt. The resolver answers
|
||||
* both from presence, which is right for the published payload; a route that
|
||||
* reads the chain in its own order asks again about the provider it picked.
|
||||
*/
|
||||
export const PICK_DECIDED_REASONS: ReadonlySet<AiUnavailableReason> =
|
||||
new Set<AiUnavailableReason>(["no_provider", "consent_required"]);
|
||||
|
||||
/**
|
||||
* The operator's assistant switches below the master. Each maps to a cost or
|
||||
* egress profile an operator can reason about: the Coach, the daily briefing
|
||||
|
||||
@@ -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,35 +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);
|
||||
// The document rule is the extraction receipt for the picked provider;
|
||||
// no chat surface rides along to widen it.
|
||||
expect(assertDocumentEgressConsent).toHaveBeenCalledWith({
|
||||
userId: "user-1",
|
||||
providerType: "anthropic",
|
||||
});
|
||||
});
|
||||
|
||||
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,28 +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,
|
||||
});
|
||||
// ── 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,10 +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
|
||||
* `aiCapabilityForJob(userId, "documentAi")` calls from the document jobs
|
||||
* turns guard 2 red for `documentAi`; 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";
|
||||
@@ -100,9 +100,6 @@ 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.
|
||||
*/
|
||||
// Empty since the document jobs resolve `documentAi` (the summary job, its
|
||||
// catch-up pass and the index backfill). A new switch that ships before its
|
||||
// reader is listed here with the reason.
|
||||
const AWAITING_READER: Record<string, string> = {};
|
||||
|
||||
/**
|
||||
@@ -130,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,
|
||||
};
|
||||
}
|
||||
@@ -38,7 +38,12 @@
|
||||
*/
|
||||
export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
|
||||
{
|
||||
ai: ["ai.provider.none", "ai.record.notPermitted", "ai.unavailable"],
|
||||
ai: [
|
||||
"ai.budget.exceeded",
|
||||
"ai.provider.none",
|
||||
"ai.record.notPermitted",
|
||||
"ai.unavailable",
|
||||
],
|
||||
ai_provider: ["ai_provider.invalid", "ai_provider.no_fields"],
|
||||
allergy: ["allergy.invalid"],
|
||||
analytics: ["analytics.invalid_query"],
|
||||
@@ -62,6 +67,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
|
||||
],
|
||||
backup: ["backup.payload.undecryptable", "backup.section.missing"],
|
||||
coach: [
|
||||
"coach.budget.exceeded",
|
||||
"coach.conversation.invalidTitle",
|
||||
"coach.conversation.notFound",
|
||||
"coach.fenced.attachmentConflict",
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* The refusals the extraction group answers with: reading a stored document,
|
||||
* scanning a lab report, and turning typed medication text into a schedule.
|
||||
*
|
||||
* Three capabilities under one operator switch ("Reading documents",
|
||||
* `documentAi`), one consent rule (any provider that leaves the machine needs
|
||||
* an `ai_extraction` or `ai_full` receipt), and one envelope. Written once so
|
||||
* the vault, the labs scan and the medication extractor describe the same
|
||||
* refusal in the same words.
|
||||
*/
|
||||
import type { AiCapabilityKey } from "@/lib/ai/capabilities/types";
|
||||
|
||||
import { errorEnvelope } from "./shared";
|
||||
|
||||
const MODULE_BY_CAPABILITY: Partial<Record<AiCapabilityKey, string>> = {
|
||||
documentAi: "inboundDocuments",
|
||||
labsOcr: "labs",
|
||||
};
|
||||
|
||||
/**
|
||||
* The 403 sentence for one extraction capability (plus any capability the
|
||||
* route also answers under, such as `coach` for a fenced Coach turn).
|
||||
*/
|
||||
export function aiExtractionRefusalDescription(
|
||||
capability: AiCapabilityKey,
|
||||
...alsoUnder: AiCapabilityKey[]
|
||||
): string {
|
||||
const owningModule = MODULE_BY_CAPABILITY[capability];
|
||||
const also =
|
||||
alsoUnder.length === 0
|
||||
? ""
|
||||
: ` The route also answers under ${alsoUnder.map((key) => `\`${key}\``).join(", ")}, with the same envelope and that capability's own codes (\`assistant.disabled.coach\`, \`module.disabled\` with \`meta.module = "coach"\` when the Coach is off for the record).`;
|
||||
return (
|
||||
`AI refusal: the \`${capability}\` capability is unavailable for this record. The envelope carries \`meta.errorCode\`, \`meta.capability\` and \`meta.reason\` (one of \`AiUnavailableReason\`). ` +
|
||||
"`assistant.disabled.documentAi` — the operator turned off reading documents, or every AI feature (the master switch). " +
|
||||
(owningModule
|
||||
? `\`module.disabled\` with \`meta.module = "${owningModule}"\` — the module is off for this record. `
|
||||
: "") +
|
||||
"`ai.record.notPermitted` — AI work is not admitted inside this record (a delegate acting in somebody else's record). " +
|
||||
"`consent.ai.required` — the provider this request would reach leaves the machine and the record holds no active `ai_extraction` or `ai_full` receipt; only a self-hosted model is exempt. The question is answered for the provider actually picked, so it can differ from the published capability when a text-mode read takes a different provider than a vision read would." +
|
||||
also
|
||||
);
|
||||
}
|
||||
|
||||
/** The 503 an extraction route answers when the capability could not be resolved. */
|
||||
export const AI_EXTRACTION_UNAVAILABLE_DESCRIPTION =
|
||||
"`ai.unavailable` — the inputs behind the AI capability could not be read, and the answer failed closed. Nothing was sent. Retry later.";
|
||||
|
||||
type AiExtractionRefusals = {
|
||||
"403": {
|
||||
description: string;
|
||||
content: { "application/json": { schema: typeof errorEnvelope } };
|
||||
};
|
||||
"503": {
|
||||
description: string;
|
||||
content: { "application/json": { schema: typeof errorEnvelope } };
|
||||
};
|
||||
};
|
||||
|
||||
// Memoised so identical responses are one object: the YAML emitter aliases by
|
||||
// identity, and a fresh object per operation would print the paragraph again.
|
||||
const cache = new Map<string, AiExtractionRefusals>();
|
||||
|
||||
/** `{ "403", "503" }` for an extraction route. Spread before `stdResponses`. */
|
||||
export function aiExtractionRefusals(
|
||||
capability: AiCapabilityKey,
|
||||
...alsoUnder: AiCapabilityKey[]
|
||||
): AiExtractionRefusals {
|
||||
const key = [capability, ...alsoUnder].join(",");
|
||||
const cached = cache.get(key);
|
||||
if (cached) return cached;
|
||||
const refusals: AiExtractionRefusals = {
|
||||
"403": {
|
||||
description: aiExtractionRefusalDescription(capability, ...alsoUnder),
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"503": {
|
||||
description: AI_EXTRACTION_UNAVAILABLE_DESCRIPTION,
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
};
|
||||
cache.set(key, refusals);
|
||||
return refusals;
|
||||
}
|
||||
@@ -58,6 +58,11 @@ import {
|
||||
recordRefusal,
|
||||
stdResponses,
|
||||
} from "./shared";
|
||||
import {
|
||||
AI_EXTRACTION_UNAVAILABLE_DESCRIPTION,
|
||||
aiExtractionRefusalDescription,
|
||||
aiExtractionRefusals,
|
||||
} from "./ai-extraction-refusals";
|
||||
|
||||
// The assigning form, deliberately: `schema.meta({...})` as a bare statement
|
||||
// returns a clone and registers nothing, so the component id would never
|
||||
@@ -936,7 +941,7 @@ export const coachPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Insights"],
|
||||
summary: "Send a FENCED multi-document coach turn (streaming reply)",
|
||||
description:
|
||||
"v1.29.x (S7) — sends a user turn and streams a grounded prose reply about the documents attached to a coach conversation, as Server-Sent Events (`text/event-stream`: one `data: <json>\\n\\n` frame per event; `token` / `done` / `error`, HTTP 200 even for a provider/refusal outcome). FENCED by construction: NO tools, NO health snapshot; every attached document is fenced as untrusted DATA (per-document header fields marker-scrubbed); the single picked provider is egress-consent-checked once per attached document (403 `consent.ai.required` if any fails); the reply is numerically grounded against the LIVE attachments' figures only. `conversationId` continues an existing fenced thread; `attachmentIds` (first-turn ONLY, min 1, max 5) creates a fresh fenced thread — supplying BOTH is a 422. The body is `.strict()`: `scope` / `guidedQuestion` / `prefill` / `userId` are rejected. A plain tool conversation 404s here. Module-gated on `inboundDocuments`; rate-limited (shared `document-chat` bucket). Renders as plain text (no markdown). Auth via cookie or Bearer.",
|
||||
"v1.29.x (S7) — sends a user turn and streams a grounded prose reply about the documents attached to a coach conversation, as Server-Sent Events (`text/event-stream`: one `data: <json>\\n\\n` frame per event; `token` / `done` / `error`, HTTP 200 even for a provider/refusal outcome). FENCED by construction: NO tools, NO health snapshot; every attached document is fenced as untrusted DATA (per-document header fields marker-scrubbed); answers under the `coach` and `documentAi` capabilities, both re-checked for the single picked provider immediately before anything is sent (403 with the capability envelope when either is closed; `consent.ai.required` when that provider leaves the machine without an `ai_extraction` / `ai_full` receipt; no provider is the `documents.chat.provider.none` error frame); the reply is numerically grounded against the LIVE attachments' figures only. `conversationId` continues an existing fenced thread; `attachmentIds` (first-turn ONLY, min 1, max 5) creates a fresh fenced thread — supplying BOTH is a 422. The body is `.strict()`: `scope` / `guidedQuestion` / `prefill` / `userId` are rejected. A plain tool conversation 404s here. Module-gated on `inboundDocuments`; rate-limited (shared `document-chat` bucket). Renders as plain text (no markdown). Auth via cookie or Bearer.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
@@ -959,7 +964,12 @@ export const coachPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
"403": {
|
||||
description:
|
||||
"AI consent required for an external provider (`errorCode: consent.ai.required`). A 422 (`stdResponses`) covers an un-indexed / unavailable attachment (`coach.fenced.attachmentUnavailable`), the attachment cap (`coach.fenced.attachmentLimit`), or `attachmentIds` sent with a `conversationId` (`coach.fenced.attachmentConflict`).",
|
||||
aiExtractionRefusalDescription("documentAi", "coach") +
|
||||
" A 422 (`stdResponses`) covers an un-indexed / unavailable attachment (`coach.fenced.attachmentUnavailable`), the attachment cap (`coach.fenced.attachmentLimit`), or `attachmentIds` sent with a `conversationId` (`coach.fenced.attachmentConflict`).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"503": {
|
||||
description: AI_EXTRACTION_UNAVAILABLE_DESCRIPTION,
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
@@ -971,7 +981,7 @@ export const coachPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Insights"],
|
||||
summary: "Attach a document to a coach conversation",
|
||||
description:
|
||||
"v1.29.x (S7) — attaches one already-stored, content-indexed document to an existing conversation and sets its sticky `documentScoped` flag TRUE (the one legal, privilege-reducing tool→fenced transition; audit-logged when a flip occurs). Validates the document is owned + live + indexed + within the 5-document cap. Idempotent: attaching an already-attached document is a 200. A foreign / unknown conversation or document maps to 404. Module-gated on `inboundDocuments`; rate-limited. Auth via cookie or Bearer.",
|
||||
"v1.29.x (S7) — attaches one already-stored, content-indexed document to an existing conversation and sets its sticky `documentScoped` flag TRUE (the one legal, privilege-reducing tool→fenced transition; audit-logged when a flip occurs). Validates the document is owned + live + indexed + within the 5-document cap. Idempotent: attaching an already-attached document is a 200. A foreign / unknown conversation or document maps to 404. Module-gated on `inboundDocuments`; answers under the `coach` and `documentAi` capabilities (nothing is sent to a provider here, so a missing provider or consent receipt is left to the next turn); rate-limited. Detaching (DELETE) is never refused for AI reasons: it removes the person\'s own data. Auth via cookie or Bearer.",
|
||||
parameters: [
|
||||
{
|
||||
name: "id",
|
||||
@@ -1003,6 +1013,7 @@ export const coachPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
description: "Conversation or document not found / not owned.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...aiExtractionRefusals("documentAi", "coach"),
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -28,6 +28,8 @@ import {
|
||||
INBOUND_DOCUMENT_STATUSES,
|
||||
} from "@/lib/validations/inbound-documents";
|
||||
|
||||
import { aiExtractionRefusals } from "./ai-extraction-refusals";
|
||||
import { aiCapabilityState } from "./profile";
|
||||
import {
|
||||
dataEnvelope,
|
||||
errorEnvelope,
|
||||
@@ -208,7 +210,7 @@ const inboundDocumentDetail = inboundDocument
|
||||
.meta({
|
||||
id: "InboundDocumentDetail",
|
||||
description:
|
||||
'A stored document plus its staged facts. `summary` is a short (3-4 sentence) plain-language description of WHAT the document is, generated once and then served from storage; it is descriptive only, never a diagnosis. `summaryGeneratedAt` is when it was generated (null until then). `summaryState` says what became of it, because a null `summary` on its own is ambiguous: NONE = never attempted (the `documentsAutoAiRead` opt-in was off at upload, or the document predates it — no backfill reaches these), PENDING = a job is enqueued or running, READY = stored and returned in `summary`, WITHHELD = generated but blocked by the outbound safety screen and therefore never returned as text, UNAVAILABLE = attempted and could not produce one (no vision provider, spent budget, withdrawn consent, unreadable file, provider error). Only PENDING may be presented as "being generated"; WITHHELD and UNAVAILABLE are both re-attemptable via POST `/api/documents/inbound/{id}/summary`. Treat an unknown value as UNAVAILABLE when decoding.',
|
||||
'A stored document plus its staged facts. `summary` is a short (3-4 sentence) plain-language description of WHAT the document is, generated once and then served from storage; it is descriptive only, never a diagnosis. `summaryGeneratedAt` is when it was generated (null until then). `summaryState` says what became of it, because a null `summary` on its own is ambiguous: NONE = never attempted (the `documentsAutoAiRead` opt-in was off at upload, reading documents with AI was closed for the record at upload, or the document predates it — no backfill reaches these), PENDING = a job is enqueued or running, READY = stored and returned in `summary`, WITHHELD = generated but blocked by the outbound safety screen and therefore never returned as text, UNAVAILABLE = attempted and could not produce one (no vision provider, spent budget, withdrawn consent, unreadable file, provider error). Only PENDING may be presented as "being generated"; WITHHELD and UNAVAILABLE are both re-attemptable via POST `/api/documents/inbound/{id}/summary`. Treat an unknown value as UNAVAILABLE when decoding.',
|
||||
});
|
||||
|
||||
const listResponse = z
|
||||
@@ -440,7 +442,7 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Documents"],
|
||||
summary: "Probe document-AI availability + egress",
|
||||
description:
|
||||
'Cheap probe (no provider call) the vault uses to decide the AI transport and whether to warn before a read leaves the machine. Resolved over the DOCUMENT provider order (local-first, ChatGPT-subscription OAuth last), so `mode` / `pdfSupported` / `egress` match exactly what the document AI routes do. `egress` is vendor-blind: `"local"` (a self-hosted model — the document never leaves the operator\'s machine) or `"external"` (a third-party AI service). The vault shows a per-egress notice before any external document read; sending a document to any external provider also requires an active AI-consent receipt.',
|
||||
'Cheap probe (no provider call) the vault uses to decide the AI transport and whether to warn before a read leaves the machine. Resolved over the DOCUMENT provider order (local-first, ChatGPT-subscription OAuth last), so `mode` / `pdfSupported` / `egress` match exactly what the document AI routes do. `egress` is vendor-blind: `"local"` (a self-hosted model — the document never leaves the operator\'s machine) or `"external"` (a third-party AI service). The vault shows a per-egress notice before any external document read; sending a document to any external provider also requires an active `ai_extraction` or `ai_full` consent receipt. `ai` is the `documentAi` capability for the record: when the operator turned reading documents off, the module is off, or the record is somebody else\'s, `available` is false with a null `reason` and `ai.reason` says why. A missing consent receipt leaves the read offered (`ai.reason = "consent_required"`), because the read is where the person is asked for it.',
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Document-AI capability flags + egress class.",
|
||||
@@ -456,6 +458,7 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
.nullable(),
|
||||
pdfSupported: z.boolean(),
|
||||
egress: z.enum(["local", "external"]).nullable(),
|
||||
ai: aiCapabilityState,
|
||||
})
|
||||
.meta({ id: "DocumentAiCapability" }),
|
||||
"DocumentAiCapabilityEnvelope",
|
||||
@@ -492,7 +495,11 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
name: z.string(),
|
||||
}),
|
||||
),
|
||||
assistAvailable: z.boolean(),
|
||||
assistAvailable: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"Whether a document read can run for this record now: a provider can serve it and the `documentAi` capability is not closed by the operator, the module or the sharing grant. The same answer as `available` on GET /api/documents/inbound/capability.",
|
||||
),
|
||||
contentIndex: z.object({
|
||||
enabled: z.boolean(),
|
||||
indexedCount: z.number(),
|
||||
@@ -739,7 +746,7 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Documents"],
|
||||
summary: "Extract facts from a stored document",
|
||||
description:
|
||||
'Optional AI enhancement on an already-stored document. Runs the dedicated OCR/vision provider over the stored original and stages STRUCTURED FACTS for review. AI-consent / rate / budget gated. With no provider configured this returns 422 (`documents.inbound.providerUnsupported`) — the stored document is untouched, only the enhancement fails. Three modes: VISION (empty body) decrypts and scans the stored original (PDF needs an Anthropic vision provider); TEXT (`application/json`, opt-in local OCR) `{ mode: "text", text }` structures browser-OCR\'d text; STORED (`application/json`) `{ mode: "stored" }` structures the document\'s own stored extracted text (its content index) — the manual recovery for a skipped/failed automatic staging run, 422 `documents.inbound.notIndexed` when no stored text exists. Extraction reproduces what the document states — it never interprets. Nothing reaches the structured stores here; the confirm route is the only write path.',
|
||||
'Optional AI enhancement on an already-stored document. Runs the dedicated OCR/vision provider over the stored original and stages STRUCTURED FACTS for review. Answers under the `documentAi` capability (403 with the capability envelope when it is closed), then rate / budget gated. With no provider configured this returns 422 (`documents.inbound.providerUnsupported`, with `meta.capability` and `meta.reason = "no_provider"`) — the stored document is untouched, only the enhancement fails. Three modes: VISION (empty body) decrypts and scans the stored original (PDF needs an Anthropic vision provider); TEXT (`application/json`, opt-in local OCR) `{ mode: "text", text }` structures browser-OCR\'d text; STORED (`application/json`) `{ mode: "stored" }` structures the document\'s own stored extracted text (its content index) — the manual recovery for a skipped/failed automatic staging run, 422 `documents.inbound.notIndexed` when no stored text exists. Extraction reproduces what the document states — it never interprets. Nothing reaches the structured stores here; the confirm route is the only write path.',
|
||||
parameters: [
|
||||
{
|
||||
name: "id",
|
||||
@@ -784,6 +791,7 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"Re-extraction refused: at least one fact on this document is already APPROVED. `meta.errorCode` = `documents.inbound.alreadyPartlyConfirmed`. Re-extracting would sever committed-record provenance and duplicate committed records, so the user must finish reviewing or discard the document first.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...aiExtractionRefusals("documentAi"),
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
@@ -793,7 +801,7 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Documents"],
|
||||
summary: "Suggest filing metadata (drafts only)",
|
||||
description:
|
||||
'Optional AI assist on an already-stored document. Runs ONE provider call over the stored original (VISION, empty body) or browser-OCR\'d text (TEXT, `application/json` `{ mode: "text", text }`, opt-in local OCR) and returns a `{ title, kind, documentDate }` DRAFT for the edit form. AI-consent / rate / budget gated (shares the per-user document-AI bucket with extract/summary/index — default 6/hour, operator-tunable via `DOCUMENT_AI_LIMIT_PER_HOUR`; a slot is only consumed when the request reaches the provider, and the 429 carries `Retry-After`, the `X-RateLimit-Limit` / `-Remaining` / `-Reset` triple, and `meta.retryAt`). WRITES NOTHING — never stages facts, never flips status; the user reviews and saves. 422 (`documents.inbound.providerUnsupported`) with no provider configured. Never interprets or diagnoses; the title is a neutral filing label.',
|
||||
'Optional AI assist on an already-stored document. Runs ONE provider call over the stored original (VISION, empty body) or browser-OCR\'d text (TEXT, `application/json` `{ mode: "text", text }`, opt-in local OCR) and returns a `{ title, kind, documentDate }` DRAFT for the edit form. Answers under the `documentAi` capability (403 with the capability envelope when it is closed), then rate / budget gated (shares the per-user document-AI bucket with extract/summary/index — default 6/hour, operator-tunable via `DOCUMENT_AI_LIMIT_PER_HOUR`; a slot is only consumed when the request reaches the provider, and the 429 carries `Retry-After`, the `X-RateLimit-Limit` / `-Remaining` / `-Reset` triple, and `meta.retryAt`). WRITES NOTHING — never stages facts, never flips status; the user reviews and saves. 422 (`documents.inbound.providerUnsupported`) with no provider configured. Never interprets or diagnoses; the title is a neutral filing label.',
|
||||
parameters: [
|
||||
{ name: "id", in: "path", required: true, schema: { type: "string" } },
|
||||
],
|
||||
@@ -829,6 +837,7 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
...aiExtractionRefusals("documentAi"),
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
@@ -838,7 +847,7 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Documents"],
|
||||
summary: "Summarise or transcribe a document",
|
||||
description:
|
||||
'On-demand description of a stored document. `?mode=summary` (default) returns a short plain-language summary of WHAT the document is; it remains transient unless `persist=true` fills the empty stored-summary slot, while `persist=true&replace=true` explicitly replaces an existing summary. `?mode=text` returns transient raw transcribed text. Neither result reaches coach memory, snapshots, structured stores, or the search index. The summary is descriptive only and never a diagnosis. Same VISION (empty body) / TEXT (`application/json` `{ mode: "text", text }`, opt-in local OCR) dispatch as extract. AI-consent / rate / budget gated. 422 with no provider.',
|
||||
'On-demand description of a stored document. `?mode=summary` (default) returns a short plain-language summary of WHAT the document is; it remains transient unless `persist=true` fills the empty stored-summary slot, while `persist=true&replace=true` explicitly replaces an existing summary. `?mode=text` returns transient raw transcribed text. Neither result reaches coach memory, snapshots, structured stores, or the search index. The summary is descriptive only and never a diagnosis. Same VISION (empty body) / TEXT (`application/json` `{ mode: "text", text }`, opt-in local OCR) dispatch as extract. Answers under the `documentAi` capability (403 with the capability envelope when it is closed), then rate / budget gated. 422 `documents.inbound.providerUnsupported` with no provider.',
|
||||
parameters: [
|
||||
{ name: "id", in: "path", required: true, schema: { type: "string" } },
|
||||
{
|
||||
@@ -903,6 +912,7 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
...aiExtractionRefusals("documentAi"),
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
@@ -912,7 +922,7 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Documents"],
|
||||
summary: "Build / refresh the content-search index",
|
||||
description:
|
||||
"Populates or refreshes one document's content-search index so search matches INSIDE its body. VISION (empty body) decrypts the stored original and runs one provider transcription (AI-consent / rate / budget gated); TEXT (`application/json` `{ mode: \"text\", text }`, opt-in local OCR) indexes browser-OCR'd text with no provider egress. Persists ONLY AES-256-GCM ciphertext of the text plus opaque HMAC token hashes — no plaintext body, no plaintext token. Gated on the existing AI consent (no separate per-user toggle). Idempotent; re-indexing overwrites in place. A successful index continues into the same lab auto-staging the background worker performs (still-STORED lab-looking document with no facts, both modules on, provider + consent eligible); `labFactsStaged` reports how many facts were staged PENDING for review — confirmation stays the only write into Labs.",
|
||||
"Populates or refreshes one document's content-search index so search matches INSIDE its body. VISION (empty body) decrypts the stored original and runs one provider transcription (answers under the `documentAi` capability — 403 with the capability envelope when it is closed, including a missing `ai_extraction` / `ai_full` receipt for a provider that leaves the machine — then rate / budget gated); TEXT (`application/json` `{ mode: \"text\", text }`, opt-in local OCR) indexes browser-OCR'd text with no provider egress and stays available with AI off, so search keeps working. Persists ONLY AES-256-GCM ciphertext of the text plus opaque HMAC token hashes — no plaintext body, no plaintext token. Idempotent; re-indexing overwrites in place. A successful index continues into the same lab auto-staging the background worker performs (still-STORED lab-looking document with no facts, both modules on, provider + consent eligible); `labFactsStaged` reports how many facts were staged PENDING for review — confirmation stays the only write into Labs.",
|
||||
parameters: [
|
||||
{ name: "id", in: "path", required: true, schema: { type: "string" } },
|
||||
],
|
||||
@@ -951,6 +961,7 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
...aiExtractionRefusals("documentAi"),
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
@@ -960,7 +971,7 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Documents"],
|
||||
summary: "Chat about a document (streaming reply)",
|
||||
description:
|
||||
"v1.27.33 — sends a user turn and streams a grounded prose reply about ONE stored document as Server-Sent Events (`text/event-stream`, not JSON: one `data: <json>\\n\\n` frame per event). Frame `type` is `token` (a chunk of reply text), `done` (`{ type, conversationId, messageId, usage? }`), or `error` (`{ type, code, message }`); HTTP status is 200 even for a provider/refusal outcome (dispatch on the `error` frame). The reply is grounded ONLY in the document's indexed text — NO health snapshot, NO tools, NO other document. The document text is fenced as untrusted DATA (prompt-injection defence); the inbound message + every prior turn are injection-screened and the reply is dose/risk-screened + numerically grounded against the document's own figures. Available only for a content-indexed document (422 `documents.inbound.notIndexed` otherwise). AI-consent-gated (403 `consent.ai.required` for an external provider), budget- and rate-limited. Omitting `conversationId` starts a new thread. Renders as plain text on the client (no markdown). Auth via cookie or Bearer.",
|
||||
"v1.27.33 — sends a user turn and streams a grounded prose reply about ONE stored document as Server-Sent Events (`text/event-stream`, not JSON: one `data: <json>\\n\\n` frame per event). Frame `type` is `token` (a chunk of reply text), `done` (`{ type, conversationId, messageId, usage? }`), or `error` (`{ type, code, message }`); HTTP status is 200 even for a provider/refusal outcome (dispatch on the `error` frame). The reply is grounded ONLY in the document's indexed text — NO health snapshot, NO tools, NO other document. The document text is fenced as untrusted DATA (prompt-injection defence); the inbound message + every prior turn are injection-screened and the reply is dose/risk-screened + numerically grounded against the document's own figures. Available only for a content-indexed document (422 `documents.inbound.notIndexed` otherwise). Answers under the `documentAi` capability (403 with the capability envelope when it is closed; `consent.ai.required` for a provider that leaves the machine without an `ai_extraction` / `ai_full` receipt), budget- and rate-limited; no provider is the `documents.chat.provider.none` error frame. Reading the history (GET) stays available whatever the capability says. Omitting `conversationId` starts a new thread. Renders as plain text on the client (no markdown). Auth via cookie or Bearer.",
|
||||
parameters: [
|
||||
{ name: "id", in: "path", required: true, schema: { type: "string" } },
|
||||
],
|
||||
@@ -984,11 +995,7 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"403": {
|
||||
description:
|
||||
"AI consent required for an external provider (`errorCode: consent.ai.required`).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...aiExtractionRefusals("documentAi"),
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
@@ -1044,7 +1051,7 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Documents"],
|
||||
summary: "Index all documents for content search",
|
||||
description:
|
||||
"Enqueues a background job that content-indexes the caller's not-yet-indexed documents (one provider transcription each, bounded + resumable). Gated on the module, a configured vision provider (422 `documents.inbound.providerUnsupported` otherwise), and the existing AI consent (403 otherwise). Returns immediately; the work runs off-request on the queue.",
|
||||
"Enqueues a background job that content-indexes the caller's not-yet-indexed documents (one provider transcription each, bounded + resumable). Gated on the module, the `documentAi` capability (403 with the capability envelope), a configured vision provider (422 `documents.inbound.providerUnsupported` otherwise), and an `ai_extraction` / `ai_full` consent receipt when that provider leaves the machine (403 `consent.ai.required`). Returns immediately; the work runs off-request on the queue, which re-checks the capability before every read.",
|
||||
responses: {
|
||||
"200": {
|
||||
description:
|
||||
@@ -1060,6 +1067,7 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
...aiExtractionRefusals("documentAi"),
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
import type { ZodOpenApiObject } from "zod-openapi";
|
||||
|
||||
import { aiExtractionRefusalDescription } from "../ai-extraction-refusals";
|
||||
import {
|
||||
createMedicationSchema,
|
||||
updateMedicationSchema,
|
||||
@@ -1336,7 +1338,7 @@ export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
summary:
|
||||
"Extract scheduling fields from a free-text medication description",
|
||||
description:
|
||||
"Runs the user's free-text description through the Coach provider chain and returns a citation-guarded partial payload the wizard merges onto whatever the user already typed. `name` and `dose` are dropped when not substring-matched in the original text so the wizard cannot land a hallucinated brand or dose. `cadenceKind` / `doseUnit` / `weekdays` are closed enums; numeric fields are clamped. Rate-limited 10 requests / 5 minutes / user, gated against the daily Coach token budget.",
|
||||
"Runs the user's free-text description through the Coach provider chain and returns a citation-guarded partial payload the wizard merges onto whatever the user already typed. `name` and `dose` are dropped when not substring-matched in the original text so the wizard cannot land a hallucinated brand or dose. `cadenceKind` / `doseUnit` / `weekdays` are closed enums; numeric fields are clamped. Answers under the `medicationExtract` capability (the operator's reading-documents switch; free-text medication input is health data, so any provider in the chain that leaves the machine needs an active `ai_extraction` or `ai_full` consent receipt). Rate-limited 10 requests / 5 minutes / user, gated against the daily Coach token budget (429 `coach.budget.exceeded`).",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
@@ -1360,9 +1362,13 @@ export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"Upstream provider returned an empty, unparseable, or off-schema reply.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"403": {
|
||||
description: aiExtractionRefusalDescription("medicationExtract"),
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"503": {
|
||||
description:
|
||||
"No AI provider configured for the calling user (or operator).",
|
||||
'No answer could be produced. `meta.errorCode = "ai.provider.none"` (with `meta.capability` and `meta.reason = "no_provider"`): no AI provider is configured for the calling user or the operator — the status predates the capability envelope and is kept for clients that branch on it. `ai.unavailable`: the capability could not be resolved and failed closed. No code: every provider in the chain failed.',
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
|
||||
@@ -17,6 +17,8 @@ import { z } from "zod/v4";
|
||||
|
||||
import { ocrCommitSchema } from "@/lib/validations/labs-ocr";
|
||||
|
||||
import { aiExtractionRefusals } from "./ai-extraction-refusals";
|
||||
import { aiCapabilityState } from "./profile";
|
||||
import {
|
||||
dataEnvelope,
|
||||
idempotencyKeyParameter,
|
||||
@@ -40,11 +42,12 @@ const capabilityResponse = z
|
||||
mode: z.enum(["vision", "text"]).nullable(),
|
||||
reason: z.enum(["no-provider", "enable-local-ocr"]).nullable(),
|
||||
pdfSupported: z.boolean(),
|
||||
ai: aiCapabilityState,
|
||||
})
|
||||
.meta({
|
||||
id: "OcrCapabilityResponse",
|
||||
description:
|
||||
"Whether the caller's configured AI provider can ingest a lab report (drives the UI's scan affordance). `mode` is `vision` when the provider reads the image directly, `text` when the image is OCR'd in-browser and only the extracted text is sent (opt-in local OCR for text-only providers), or null when unavailable. `reason` explains an unavailable state; `pdfSupported` is true when the provider reads PDFs natively (Anthropic) or the server-side rasterizer can render the pages for any other vision provider. No provider call is made.",
|
||||
"Whether the caller's configured AI provider can ingest a lab report (drives the UI's scan affordance). `mode` is `vision` when the provider reads the image directly, `text` when the image is OCR'd in-browser and only the extracted text is sent (opt-in local OCR for text-only providers), or null when unavailable. `reason` explains an unavailable state; `pdfSupported` is true when the provider reads PDFs natively (Anthropic) or the server-side rasterizer can render the pages for any other vision provider. `ai` is the `labsOcr` capability for the record: when the operator turned reading documents off, the labs module is off, or the record is somebody else\'s, `available` is false with a null `reason` and `ai.reason` says why. A missing consent receipt leaves the scan offered (`ai.reason = \"consent_required\"`), because the scan is where the person is asked for it. No provider call is made.",
|
||||
});
|
||||
|
||||
const extractConfidence = z.object({
|
||||
@@ -165,7 +168,7 @@ export const ocrPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Labs"],
|
||||
summary: "Extract lab readings from a photo, PDF, or OCR'd text",
|
||||
description:
|
||||
"Read-only (NOT idempotent) extraction. Two modes by content-type. VISION (`multipart/form-data`): a `file` (JPEG/PNG/WebP, or PDF — read natively on Anthropic, else rasterized to page images for any other vision provider; ≤ 12 MiB) is run through the user's vision-capable provider; the upload lives in memory only and is never persisted or logged. TEXT (`application/json`, opt-in local OCR): the browser OCR's the image (tesseract.js) and POSTs `{ mode: \"text\", text }` — only the extracted text reaches the server, so a text-only provider (e.g. ChatGPT-OAuth) reaches the same review/commit flow. Both modes are gated by AI consent, a per-user hourly rate bucket (default 6, operator-tunable via `LABS_OCR_LIMIT_PER_HOUR`; a slot is only consumed when the scan reaches the provider, and the 429 carries `Retry-After`, the `X-RateLimit-*` triple and `meta.retryAt`), and the per-day token budget, and return proposed rows for the mandatory human review screen — nothing is written. Extracted content is treated as untrusted (prompt-injection); the review step is the safety boundary.",
|
||||
"Read-only (NOT idempotent) extraction. Two modes by content-type. VISION (`multipart/form-data`): a `file` (JPEG/PNG/WebP, or PDF — read natively on Anthropic, else rasterized to page images for any other vision provider; ≤ 12 MiB) is run through the user's vision-capable provider; the upload lives in memory only and is never persisted or logged. TEXT (`application/json`, opt-in local OCR): the browser OCR's the image (tesseract.js) and POSTs `{ mode: \"text\", text }` — only the extracted text reaches the server, so a text-only provider (e.g. ChatGPT-OAuth) reaches the same review/commit flow. Both modes are model work and answer under the `labsOcr` capability (the operator's reading-documents switch, the labs module, and — because a lab report is a document — an active `ai_extraction` or `ai_full` consent receipt for any provider that leaves the machine, checked for the provider actually picked: the first vision-capable entry in vision mode, the chain head in text mode). No provider is 422 `labs.ocr.providerUnsupported`; text mode without the local-OCR opt-in is 422 `labs.ocr.localOcrDisabled`. Then both pass a per-user hourly rate bucket (default 6, operator-tunable via `LABS_OCR_LIMIT_PER_HOUR`; a slot is only consumed when the scan reaches the provider, and the 429 carries `Retry-After`, the `X-RateLimit-*` triple and `meta.retryAt`) and the per-day token budget, and return proposed rows for the mandatory human review screen — nothing is written. Extracted content is treated as untrusted (prompt-injection); the review step is the safety boundary.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
@@ -200,6 +203,7 @@ export const ocrPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
...aiExtractionRefusals("labsOcr"),
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -709,7 +709,7 @@ const glucoseUnitResponse = z
|
||||
const documentsAutoAiReadPatchRequest = documentsAutoAiReadPatchSchema.meta({
|
||||
id: "DocumentsAutoAiReadPatchRequest",
|
||||
description:
|
||||
"Turn automatic AI reading of newly uploaded documents on or off. Turning it on also mints an `ai_extraction` consent receipt and schedules a catch-up over the existing vault.",
|
||||
"Turn automatic AI reading of newly uploaded documents on or off. Turning it on also mints an `ai_full` consent receipt and schedules a catch-up over the existing vault.",
|
||||
});
|
||||
|
||||
const documentsAutoAiReadResponse = z
|
||||
@@ -1044,7 +1044,7 @@ export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Auth"],
|
||||
summary: "Probe the AI provider connection",
|
||||
description:
|
||||
"Sends a tiny fixed prompt to the resolved provider and reports whether it answered. The body is OPTIONAL: with no body the saved configuration is tested; with one, the unsaved selection is tested without touching the user row, so the settings surface can verify a credential before persisting it. Plaintext keys sent this way are never stored.\n\n**This route never returns 5xx, and that is deliberate.** A 5xx from the origin is rewritten to an HTML error page by a reverse proxy or CDN, and the client's `res.json()` then dies on `<!DOCTYPE`. So a provider failure comes back as **200 with `ok: false`** plus a categorised, secret-free `reasonCode`. Branch on `ok`, not on the status. The provider's own error text and body excerpt are logged for the operator and never put on the wire.\n\nThe probe is metered on the same daily AI budget every other AI surface writes to, because with an empty body it can resolve the OPERATOR's shared key — unmetered, that would be invisible spend on a surface that exists to answer 'are my settings right?'. A probe that fails is refunded.\n\nTwo ceilings: 5 per minute and 50 per day, per user.",
|
||||
"Sends a tiny fixed prompt to the resolved provider and reports whether it answered. The body is OPTIONAL: with no body the saved configuration is tested; with one, the unsaved selection is tested without touching the user row, so the settings surface can verify a credential before persisting it. Plaintext keys sent this way are never stored.\n\n**This route never returns 5xx, and that is deliberate.** A 5xx from the origin is rewritten to an HTML error page by a reverse proxy or CDN, and the client's `res.json()` then dies on `<!DOCTYPE`. So a provider failure comes back as **200 with `ok: false`** plus a categorised, secret-free `reasonCode`. Branch on `ok`, not on the status. The provider's own error text and body excerpt are logged for the operator and never put on the wire.\n\nThe probe is metered on the same daily AI budget every other AI surface writes to, because with an empty body it can resolve the OPERATOR's shared key — unmetered, that would be invisible spend on a surface that exists to answer 'are my settings right?'. A probe that fails is refunded.\n\nTwo ceilings: 5 per minute and 50 per day, per user.\n\nWith the operator\'s master AI switch off the probe is refused (403 `assistant.disabled.enabled`, `meta.reason = \"operator_disabled\"`): no AI call leaves the server then, and this is an AI call. When the switches cannot be read the answer fails closed with 403 `ai.unavailable` (`meta.reason = \"check_failed\"`) rather than the usual 503, for the no-5xx reason above.",
|
||||
requestBody: {
|
||||
required: false,
|
||||
content: { "application/json": { schema: aiTestRequest } },
|
||||
@@ -1059,6 +1059,11 @@ export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"403": {
|
||||
description:
|
||||
'The operator\'s master AI switch is off (`meta.errorCode = "assistant.disabled.enabled"`), or the switches could not be read (`ai.unavailable`). Nothing was sent.',
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"413": {
|
||||
description: "Body exceeds 64 KiB.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
@@ -1071,12 +1076,12 @@ export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
...stdResponses,
|
||||
"422": {
|
||||
description:
|
||||
"Nothing could be probed. Either the override body failed validation, or the resolved configuration is not usable — no provider selected, a required key or base URL missing, a model name missing for the gateway, ChatGPT OAuth not connected, or a base URL pointing at an internal host. The message names which.",
|
||||
'Nothing could be probed. Either the override body failed validation, or the resolved configuration is not usable — no provider selected, a required key or base URL missing, a model name missing for the gateway, ChatGPT OAuth not connected, or a base URL pointing at an internal host. The message names which; a resolved configuration with no provider at all carries `meta.errorCode = "ai.provider.none"`.',
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"429": {
|
||||
description:
|
||||
"One of three ceilings: 5 probes per minute, 50 per day, or the account's daily AI token budget is exhausted. The message distinguishes them.",
|
||||
'One of three ceilings: 5 probes per minute, 50 per day, or the account\'s daily AI token budget is exhausted. The message distinguishes them; the budget refusal also carries `meta.errorCode = "ai.budget.exceeded"`.',
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
},
|
||||
@@ -1820,7 +1825,7 @@ export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
summary: "Set the automatic document-reading opt-in",
|
||||
description:
|
||||
"Hard-set, idempotent, audit-logged. Rate-limited 60 / min per user.\n\n" +
|
||||
"Turning it ON does two further things a caller should expect. It is the consent act for reading documents, so the write appends an `ai_extraction` `ConsentReceipt`: the narrow kind that covers documents, lab report scans and typed medication text and nothing else (not the Coach, not the analysis). DELETE /api/consent/ai/latest revokes it without touching this flag, and the revocation wins: document reading stops even while the flag stays on. And a genuine OFF→ON flip schedules a bounded catch-up over the documents already in the vault, because the summary job is enqueued at upload time and would otherwise only ever apply to future uploads. The catch-up is fire-and-forget and re-runs every consent and budget gate per document.\n\n" +
|
||||
"Turning it ON does two further things a caller should expect. It is itself the standing consent act, so the write appends an `ai_extraction` `ConsentReceipt` unless one that covers document reads (`ai_extraction` or `ai_full`) is already active. The receipt covers reading documents, lab reports and medication text and nothing else: the Coach and the AI analysis keep asking for their own consent (earlier releases minted `ai_full` here, and those receipts stay valid). DELETE /api/consent/ai/latest?kind=ai_extraction revokes it without touching this flag. And a genuine OFF→ON flip schedules a bounded catch-up over the documents already in the vault, because the summary job is enqueued at upload time and would otherwise only ever apply to future uploads; it is skipped while the `documentAi` capability is closed for the record. The catch-up is fire-and-forget and re-runs every consent and budget gate per document.\n\n" +
|
||||
"The body cap here is 1 KB, far tighter than the 64 KB its siblings allow — a payload above it is refused with 413 rather than parsed.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,539 @@
|
||||
/**
|
||||
* The extraction group's capabilities, through the real routes and the real
|
||||
* document pick against real Postgres, with a spy standing in for every model
|
||||
* call.
|
||||
*
|
||||
* The unit suites prove each gate is asked. What only this file can prove is
|
||||
* that the answer comes off the rows it should: the operator's switches on
|
||||
* `AppSettings`, the record's module preferences, the receipts on
|
||||
* `ConsentReceipt`, and the provider built from the person's own credential.
|
||||
* Every state is checked twice: the response a client sees, and the spy that
|
||||
* says whether anything reached a model. Each file-level state has an all-open
|
||||
* twin in which the spy IS called, so a spy that could not see a call would
|
||||
* turn those red.
|
||||
*
|
||||
* The two background paths at the end run the job handlers directly. They
|
||||
* prove the wire re-check in the document pick: a job enqueued before the
|
||||
* operator turned document reading off finds no provider when it runs, so
|
||||
* nothing is sent even though the job itself asks nothing of the capability.
|
||||
*
|
||||
* Mutation checks, run against this file: replacing the `atTheWire` re-check
|
||||
* in `provider-order.ts` with "nothing withheld" turns four cases red (both
|
||||
* receipt cases on the vault route and both background cases). Deleting the
|
||||
* `requireAiCapability("documentAi"` line from the suggest route leaves all
|
||||
* seventeen green, and that is the design rather than a blind spot: the pick
|
||||
* re-checks the same capability, so the switch still answers 403 with the
|
||||
* same envelope. The route gate's own presence and order are pinned by the
|
||||
* route's unit suite instead.
|
||||
*/
|
||||
import { Buffer } from "node:buffer";
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
process.env.ENCRYPTION_KEY ??=
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => {
|
||||
cookieJar.set(name, value);
|
||||
},
|
||||
delete: (name: string) => {
|
||||
cookieJar.delete(name);
|
||||
},
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// ── The spies: every place a model would be called ─────────────────────────
|
||||
|
||||
vi.mock("@/lib/documents/assist", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/documents/assist")>()),
|
||||
runDocumentAssist: vi.fn(async () => ({
|
||||
title: "Letter",
|
||||
kind: null,
|
||||
documentDate: null,
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/lib/labs/ocr-extract", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/labs/ocr-extract")>()),
|
||||
runOcrExtraction: vi.fn(async () => ({ rows: [], reportDate: null })),
|
||||
}));
|
||||
vi.mock("@/lib/documents/describe", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/documents/describe")>()),
|
||||
transcribeDocument: vi.fn(async () => ({ text: "Glucose 95 mg/dL" })),
|
||||
runDocumentSummary: vi.fn(async () => ({
|
||||
summary: "A laboratory report.",
|
||||
blocked: false,
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/lib/ai/provider-runner", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/ai/provider-runner")>()),
|
||||
runRawCompletionWithFallback: vi.fn(async () => ({
|
||||
result: {
|
||||
content: JSON.stringify({ name: "Metformin" }),
|
||||
tokensUsed: 20,
|
||||
model: "m",
|
||||
},
|
||||
workingProvider: { providerType: "anthropic" },
|
||||
fallbackHops: [],
|
||||
})),
|
||||
}));
|
||||
|
||||
import { runDocumentAssist } from "@/lib/documents/assist";
|
||||
import { runOcrExtraction } from "@/lib/labs/ocr-extract";
|
||||
import {
|
||||
runDocumentSummary,
|
||||
transcribeDocument,
|
||||
} from "@/lib/documents/describe";
|
||||
import { runRawCompletionWithFallback } from "@/lib/ai/provider-runner";
|
||||
|
||||
const SPIES = [
|
||||
runDocumentAssist,
|
||||
runOcrExtraction,
|
||||
transcribeDocument,
|
||||
runDocumentSummary,
|
||||
runRawCompletionWithFallback,
|
||||
] as const;
|
||||
|
||||
/** A 1x1 PNG, so the vision path has a real image to prepare. */
|
||||
const PNG = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGBgAAAABQABXvMqOgAAAABJRU5ErkJggg==",
|
||||
"base64",
|
||||
);
|
||||
|
||||
let counter = 0;
|
||||
|
||||
/** A person on their own Anthropic key: every extraction leaves the machine. */
|
||||
async function makeUser(overrides: Record<string, unknown> = {}) {
|
||||
const suffix = `x-${counter++}`;
|
||||
return getPrismaClient().user.create({
|
||||
data: {
|
||||
username: `extract-${suffix}`,
|
||||
email: `extract-${suffix}@example.test`,
|
||||
role: "USER",
|
||||
timezone: "UTC",
|
||||
locale: "en",
|
||||
onboardingCompletedAt: new Date(),
|
||||
aiProvider: "ANTHROPIC",
|
||||
aiModel: "claude-sonnet-4-6",
|
||||
aiAnthropicKeyEncrypted: encrypt("sk-ant-integration-test"),
|
||||
labsLocalOcrEnabled: true,
|
||||
...overrides,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function grantConsent(userId: string, kind: string) {
|
||||
await getPrismaClient().consentReceipt.create({
|
||||
data: { userId, kind, artefact: "test", signedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
async function setSwitches(values: Record<string, unknown>) {
|
||||
await getPrismaClient().appSettings.upsert({
|
||||
where: { id: "singleton" },
|
||||
create: { id: "singleton", ...values },
|
||||
update: values,
|
||||
});
|
||||
}
|
||||
|
||||
async function signIn(userId: string) {
|
||||
const session = await getPrismaClient().session.create({
|
||||
data: { userId, expiresAt: new Date(Date.now() + 60_000) },
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
}
|
||||
|
||||
async function storeDocument(userId: string) {
|
||||
const { encryptDocumentContent } = await import("@/lib/documents/store");
|
||||
const { content, codec } = encryptDocumentContent(PNG);
|
||||
return getPrismaClient().inboundDocument.create({
|
||||
data: {
|
||||
userId,
|
||||
kind: "OTHER",
|
||||
filename: "letter.png",
|
||||
mimeType: "image/png",
|
||||
byteSize: PNG.byteLength,
|
||||
status: "STORED",
|
||||
contentEncrypted: content,
|
||||
contentCodec: codec,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
interface Envelope {
|
||||
data: unknown;
|
||||
error: string | null;
|
||||
meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
async function suggestText(documentId: string) {
|
||||
const { POST } =
|
||||
await import("@/app/api/documents/inbound/[id]/suggest/route");
|
||||
const { NextRequest } = await import("next/server");
|
||||
const res = await POST(
|
||||
new NextRequest(
|
||||
`http://localhost/api/documents/inbound/${documentId}/suggest`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ mode: "text", text: "Discharge letter" }),
|
||||
},
|
||||
) as never,
|
||||
{ params: Promise.resolve({ id: documentId }) } as never,
|
||||
);
|
||||
return { status: res.status, body: (await res.json()) as Envelope };
|
||||
}
|
||||
|
||||
async function labsScanText() {
|
||||
const { POST } = await import("@/app/api/labs/ocr/extract/route");
|
||||
const res = await POST(
|
||||
new Request("http://localhost/api/labs/ocr/extract", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ mode: "text", text: "Glucose 95 mg/dL" }),
|
||||
}) as never,
|
||||
);
|
||||
return { status: res.status, body: (await res.json()) as Envelope };
|
||||
}
|
||||
|
||||
async function extractMedication() {
|
||||
const { POST } = await import("@/app/api/medications/extract/route");
|
||||
const { NextRequest } = await import("next/server");
|
||||
const res = await POST(
|
||||
new NextRequest("http://localhost/api/medications/extract", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ text: "Metformin 500 mg twice daily" }),
|
||||
}) as never,
|
||||
);
|
||||
return { status: res.status, body: (await res.json()) as Envelope };
|
||||
}
|
||||
|
||||
function expectNoModelCall() {
|
||||
for (const spy of SPIES) {
|
||||
expect(vi.mocked(spy)).not.toHaveBeenCalled();
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
counter = 0;
|
||||
for (const spy of SPIES) vi.mocked(spy).mockClear();
|
||||
});
|
||||
|
||||
describe("reading a stored document — POST /api/documents/inbound/[id]/suggest", () => {
|
||||
it("reads the document when every layer is open (the spy can see a call)", async () => {
|
||||
const user = await makeUser();
|
||||
await grantConsent(user.id, "ai_extraction");
|
||||
const document = await storeDocument(user.id);
|
||||
await signIn(user.id);
|
||||
|
||||
const { status } = await suggestText(document.id);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(runDocumentAssist).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("refuses with the reading-documents code and sends nothing when the operator turned it off", async () => {
|
||||
const user = await makeUser();
|
||||
await grantConsent(user.id, "ai_full");
|
||||
await setSwitches({ assistantDocumentAiEnabled: false });
|
||||
const document = await storeDocument(user.id);
|
||||
await signIn(user.id);
|
||||
|
||||
const { status, body } = await suggestText(document.id);
|
||||
|
||||
expect(status).toBe(403);
|
||||
expect(body.meta).toEqual({
|
||||
errorCode: "assistant.disabled.documentAi",
|
||||
capability: "documentAi",
|
||||
reason: "operator_disabled",
|
||||
});
|
||||
expectNoModelCall();
|
||||
});
|
||||
|
||||
it("refuses under the master switch too", async () => {
|
||||
const user = await makeUser();
|
||||
await grantConsent(user.id, "ai_full");
|
||||
await setSwitches({ assistantEnabled: false });
|
||||
const document = await storeDocument(user.id);
|
||||
await signIn(user.id);
|
||||
|
||||
const { status, body } = await suggestText(document.id);
|
||||
|
||||
expect(status).toBe(403);
|
||||
expect(body.meta?.reason).toBe("operator_disabled");
|
||||
expectNoModelCall();
|
||||
});
|
||||
|
||||
it("asks for an extraction receipt before the document leaves for the person's own key", async () => {
|
||||
const user = await makeUser();
|
||||
const document = await storeDocument(user.id);
|
||||
await signIn(user.id);
|
||||
|
||||
const { status, body } = await suggestText(document.id);
|
||||
|
||||
expect(status).toBe(403);
|
||||
expect(body.meta).toEqual({
|
||||
errorCode: "consent.ai.required",
|
||||
capability: "documentAi",
|
||||
reason: "consent_required",
|
||||
});
|
||||
expectNoModelCall();
|
||||
});
|
||||
|
||||
it("does not take the AI-analysis receipt as consent to read documents", async () => {
|
||||
const user = await makeUser();
|
||||
await grantConsent(user.id, "ai_insights_only");
|
||||
const document = await storeDocument(user.id);
|
||||
await signIn(user.id);
|
||||
|
||||
const { status, body } = await suggestText(document.id);
|
||||
|
||||
expect(status).toBe(403);
|
||||
expect(body.meta?.errorCode).toBe("consent.ai.required");
|
||||
expectNoModelCall();
|
||||
});
|
||||
|
||||
it("keeps the vault's own code when no provider is configured", async () => {
|
||||
const user = await makeUser({
|
||||
aiProvider: null,
|
||||
aiModel: null,
|
||||
aiAnthropicKeyEncrypted: null,
|
||||
});
|
||||
const document = await storeDocument(user.id);
|
||||
await signIn(user.id);
|
||||
|
||||
const { status, body } = await suggestText(document.id);
|
||||
|
||||
expect(status).toBe(422);
|
||||
expect(body.meta).toEqual({
|
||||
errorCode: "documents.inbound.providerUnsupported",
|
||||
capability: "documentAi",
|
||||
reason: "no_provider",
|
||||
});
|
||||
expectNoModelCall();
|
||||
});
|
||||
});
|
||||
|
||||
describe("scanning a lab report — POST /api/labs/ocr/extract", () => {
|
||||
it("scans when every layer is open (the spy can see a call)", async () => {
|
||||
const user = await makeUser();
|
||||
await grantConsent(user.id, "ai_extraction");
|
||||
await signIn(user.id);
|
||||
|
||||
const { status } = await labsScanText();
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(runOcrExtraction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("stops under the reading-documents switch", async () => {
|
||||
const user = await makeUser();
|
||||
await grantConsent(user.id, "ai_full");
|
||||
await setSwitches({ assistantDocumentAiEnabled: false });
|
||||
await signIn(user.id);
|
||||
|
||||
const { status, body } = await labsScanText();
|
||||
|
||||
expect(status).toBe(403);
|
||||
expect(body.meta).toMatchObject({
|
||||
errorCode: "assistant.disabled.documentAi",
|
||||
capability: "labsOcr",
|
||||
});
|
||||
expectNoModelCall();
|
||||
});
|
||||
|
||||
it("names the labs module when it is off for the record", async () => {
|
||||
const user = await makeUser({
|
||||
modulePreferencesJson: { labs: false },
|
||||
});
|
||||
await grantConsent(user.id, "ai_full");
|
||||
await signIn(user.id);
|
||||
|
||||
const { status, body } = await labsScanText();
|
||||
|
||||
expect(status).toBe(403);
|
||||
expect(body.meta).toEqual({
|
||||
errorCode: "module.disabled",
|
||||
capability: "labsOcr",
|
||||
reason: "module_disabled",
|
||||
module: "labs",
|
||||
});
|
||||
expectNoModelCall();
|
||||
});
|
||||
|
||||
it("puts a lab report under the document consent rule", async () => {
|
||||
const user = await makeUser();
|
||||
// The snapshot-rule receipt that used to cover a scan no longer does: a
|
||||
// lab report is a document.
|
||||
await grantConsent(user.id, "ai_insights_only");
|
||||
await signIn(user.id);
|
||||
|
||||
const { status, body } = await labsScanText();
|
||||
|
||||
expect(status).toBe(403);
|
||||
expect(body.meta?.errorCode).toBe("consent.ai.required");
|
||||
expectNoModelCall();
|
||||
});
|
||||
});
|
||||
|
||||
describe("turning medication text into a schedule — POST /api/medications/extract", () => {
|
||||
it("extracts when every layer is open (the spy can see a call)", async () => {
|
||||
const user = await makeUser();
|
||||
await grantConsent(user.id, "ai_extraction");
|
||||
await signIn(user.id);
|
||||
|
||||
const { status } = await extractMedication();
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(runRawCompletionWithFallback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("stops under the reading-documents switch, whatever the Coach switch says", async () => {
|
||||
const user = await makeUser();
|
||||
await grantConsent(user.id, "ai_full");
|
||||
await setSwitches({
|
||||
assistantDocumentAiEnabled: false,
|
||||
assistantCoachEnabled: true,
|
||||
});
|
||||
await signIn(user.id);
|
||||
|
||||
const { status, body } = await extractMedication();
|
||||
|
||||
expect(status).toBe(403);
|
||||
expect(body.meta).toEqual({
|
||||
errorCode: "assistant.disabled.documentAi",
|
||||
capability: "medicationExtract",
|
||||
reason: "operator_disabled",
|
||||
});
|
||||
expectNoModelCall();
|
||||
});
|
||||
|
||||
it("keeps 503 for no provider and names it", async () => {
|
||||
const user = await makeUser({
|
||||
aiProvider: null,
|
||||
aiModel: null,
|
||||
aiAnthropicKeyEncrypted: null,
|
||||
});
|
||||
await signIn(user.id);
|
||||
|
||||
const { status, body } = await extractMedication();
|
||||
|
||||
expect(status).toBe(503);
|
||||
expect(body.meta).toEqual({
|
||||
errorCode: "ai.provider.none",
|
||||
capability: "medicationExtract",
|
||||
reason: "no_provider",
|
||||
});
|
||||
expectNoModelCall();
|
||||
});
|
||||
});
|
||||
|
||||
describe("document auto-read mints the extraction receipt", () => {
|
||||
it("writes ai_extraction, not ai_full", async () => {
|
||||
const user = await makeUser();
|
||||
await signIn(user.id);
|
||||
const { PATCH } =
|
||||
await import("@/app/api/auth/me/documents-auto-ai-read/route");
|
||||
|
||||
const res = await PATCH(
|
||||
new Request("http://localhost/api/auth/me/documents-auto-ai-read", {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ documentsAutoAiRead: true }),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const receipts = await getPrismaClient().consentReceipt.findMany({
|
||||
where: { userId: user.id, revokedAt: null },
|
||||
select: { kind: true },
|
||||
});
|
||||
expect(receipts.map((r) => r.kind)).toEqual(["ai_extraction"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("background reads stop at the wire", () => {
|
||||
it("indexes through the text layer, not a model, once document reading is off", async () => {
|
||||
const user = await makeUser({ documentsAutoAiRead: true });
|
||||
await grantConsent(user.id, "ai_extraction");
|
||||
const document = await storeDocument(user.id);
|
||||
const { indexDocumentContent } =
|
||||
await import("@/lib/documents/index-document");
|
||||
|
||||
// Open: the provider path is taken (the spy can see a call).
|
||||
await indexDocumentContent(user.id, document.id);
|
||||
expect(transcribeDocument).toHaveBeenCalledTimes(1);
|
||||
|
||||
// The operator turns document reading off; the same job runs again.
|
||||
vi.mocked(transcribeDocument).mockClear();
|
||||
await getPrismaClient().documentContentIndex.deleteMany({
|
||||
where: { documentId: document.id },
|
||||
});
|
||||
await setSwitches({ assistantDocumentAiEnabled: false });
|
||||
await indexDocumentContent(user.id, document.id);
|
||||
|
||||
expectNoModelCall();
|
||||
});
|
||||
|
||||
it("leaves a summary job enqueued before the switch flipped with no provider to call", async () => {
|
||||
const user = await makeUser({ documentsAutoAiRead: true });
|
||||
await grantConsent(user.id, "ai_extraction");
|
||||
const document = await storeDocument(user.id);
|
||||
await setSwitches({ assistantDocumentAiEnabled: false });
|
||||
const { runDocumentSummaryJob } =
|
||||
await import("@/lib/jobs/document-summary");
|
||||
|
||||
await runDocumentSummaryJob({
|
||||
userId: user.id,
|
||||
documentId: document.id,
|
||||
enqueuedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
expectNoModelCall();
|
||||
const row = await getPrismaClient().inboundDocument.findUniqueOrThrow({
|
||||
where: { id: document.id },
|
||||
select: { summaryEncrypted: true },
|
||||
});
|
||||
expect(row.summaryEncrypted).toBeNull();
|
||||
});
|
||||
|
||||
it("summarises the same document with the switch on (the spy can see a call)", async () => {
|
||||
const user = await makeUser({ documentsAutoAiRead: true });
|
||||
await grantConsent(user.id, "ai_extraction");
|
||||
const document = await storeDocument(user.id);
|
||||
const { runDocumentSummaryJob } =
|
||||
await import("@/lib/jobs/document-summary");
|
||||
|
||||
await runDocumentSummaryJob({
|
||||
userId: user.id,
|
||||
documentId: document.id,
|
||||
enqueuedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
expect(runDocumentSummary).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user