mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
Merge branch 'fix/docai-governance-rebased' into release/v1.27.31
This commit is contained in:
commit
7b1f077888
31 files changed
+786
-100
No files matched your search
@@ -28,12 +28,13 @@ vi.mock("@/lib/ai/coach/bytes-codec", () => ({
|
||||
decryptFromBytes: vi.fn(() => "{}"),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/labs/ocr-capability", () => ({
|
||||
resolveVisionProvider: vi.fn(),
|
||||
resolveTextProvider: vi.fn(),
|
||||
vi.mock("@/lib/documents/provider-order", () => ({
|
||||
resolveDocumentVisionProvider: vi.fn(),
|
||||
resolveDocumentTextProvider: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/consent-guard", () => ({
|
||||
assertConsentForChain: vi.fn().mockResolvedValue(undefined),
|
||||
assertDocumentEgressConsent: vi.fn().mockResolvedValue(undefined),
|
||||
ConsentRequiredError: class ConsentRequiredError extends Error {},
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/budget", () => ({
|
||||
buildDateKey: vi.fn(() => "2026-06-27"),
|
||||
@@ -76,7 +77,7 @@ import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { resolveVisionProvider } from "@/lib/labs/ocr-capability";
|
||||
import { resolveDocumentVisionProvider } from "@/lib/documents/provider-order";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
@@ -109,7 +110,7 @@ beforeEach(() => {
|
||||
|
||||
describe("POST /api/documents/inbound/[id]/extract", () => {
|
||||
it("422s without a vision provider and leaves the stored row intact", async () => {
|
||||
vi.mocked(resolveVisionProvider).mockResolvedValue({
|
||||
vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({
|
||||
chain: [],
|
||||
pick: null,
|
||||
} as never);
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
* 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 provider → assertConsentForChain → 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.
|
||||
* resolve document provider (local-first, codex last) → assertDocumentEgressConsent
|
||||
* → 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.
|
||||
*
|
||||
* Two modes, dispatched on content-type (mirroring the original upload):
|
||||
* - VISION (no JSON body): decrypt the stored original, re-derive its MIME,
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
|
||||
import { assertConsentForChain } from "@/lib/ai/consent-guard";
|
||||
import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard";
|
||||
import {
|
||||
buildDateKey,
|
||||
reconcileSpend,
|
||||
@@ -52,9 +52,9 @@ import {
|
||||
serialiseDocumentDetail,
|
||||
} from "@/lib/documents/store";
|
||||
import {
|
||||
resolveTextProvider,
|
||||
resolveVisionProvider,
|
||||
} from "@/lib/labs/ocr-capability";
|
||||
resolveDocumentTextProvider,
|
||||
resolveDocumentVisionProvider,
|
||||
} from "@/lib/documents/provider-order";
|
||||
import { detectOcrMimeType } from "@/lib/labs/ocr-upload";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
@@ -200,14 +200,18 @@ async function handleTextExtract(
|
||||
});
|
||||
}
|
||||
|
||||
const { chain, pick } = await resolveTextProvider(userId);
|
||||
const { pick } = await resolveDocumentTextProvider(userId);
|
||||
if (!pick) {
|
||||
return apiError("No AI provider is configured", 422, {
|
||||
errorCode: "documents.inbound.providerUnsupported",
|
||||
});
|
||||
}
|
||||
|
||||
await assertConsentForChain({ userId, chain, surface: "insights" });
|
||||
await assertDocumentEgressConsent({
|
||||
userId,
|
||||
providerType: pick.providerType,
|
||||
surface: "insights",
|
||||
});
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`documents-inbound:${userId}`,
|
||||
@@ -298,14 +302,18 @@ async function handleVisionExtract(
|
||||
userId: string,
|
||||
document: LoadedDocument,
|
||||
): Promise<Response> {
|
||||
const { chain, pick } = await resolveVisionProvider(userId);
|
||||
const { pick } = await resolveDocumentVisionProvider(userId);
|
||||
if (!pick) {
|
||||
return apiError("No vision-capable AI provider is configured", 422, {
|
||||
errorCode: "documents.inbound.providerUnsupported",
|
||||
});
|
||||
}
|
||||
|
||||
await assertConsentForChain({ userId, chain, surface: "insights" });
|
||||
await assertDocumentEgressConsent({
|
||||
userId,
|
||||
providerType: pick.providerType,
|
||||
surface: "insights",
|
||||
});
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`documents-inbound:${userId}`,
|
||||
|
||||
@@ -29,11 +29,12 @@ vi.mock("@/lib/documents/describe", () => ({
|
||||
vi.mock("@/lib/documents/content-index", () => ({
|
||||
upsertContentIndex: vi.fn().mockResolvedValue({ tokenCount: 7 }),
|
||||
}));
|
||||
vi.mock("@/lib/labs/ocr-capability", () => ({
|
||||
resolveVisionProvider: vi.fn(),
|
||||
vi.mock("@/lib/documents/provider-order", () => ({
|
||||
resolveDocumentVisionProvider: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/consent-guard", () => ({
|
||||
assertConsentForChain: vi.fn().mockResolvedValue(undefined),
|
||||
assertDocumentEgressConsent: vi.fn().mockResolvedValue(undefined),
|
||||
ConsentRequiredError: class ConsentRequiredError extends Error {},
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/budget", () => ({
|
||||
buildDateKey: vi.fn(() => "2026-07-07"),
|
||||
@@ -68,7 +69,7 @@ vi.mock("next/headers", () => ({
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { resolveVisionProvider } from "@/lib/labs/ocr-capability";
|
||||
import { resolveDocumentVisionProvider } from "@/lib/documents/provider-order";
|
||||
import { transcribeDocument } from "@/lib/documents/describe";
|
||||
import { upsertContentIndex } from "@/lib/documents/content-index";
|
||||
|
||||
@@ -109,7 +110,7 @@ beforeEach(() => {
|
||||
|
||||
describe("POST /api/documents/inbound/[id]/index", () => {
|
||||
it("vision: transcribes then upserts the index, never facts", async () => {
|
||||
vi.mocked(resolveVisionProvider).mockResolvedValue({
|
||||
vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({
|
||||
chain: [{ providerType: "anthropic", instance: {} }],
|
||||
pick: {
|
||||
entry: { providerType: "anthropic", instance: {} },
|
||||
@@ -137,7 +138,7 @@ describe("POST /api/documents/inbound/[id]/index", () => {
|
||||
});
|
||||
|
||||
it("vision: 422 without a provider", async () => {
|
||||
vi.mocked(resolveVisionProvider).mockResolvedValue({
|
||||
vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({
|
||||
chain: [],
|
||||
pick: null,
|
||||
} as never);
|
||||
@@ -160,7 +161,7 @@ describe("POST /api/documents/inbound/[id]/index", () => {
|
||||
expect.objectContaining({ source: "text-ocr", providerType: null }),
|
||||
);
|
||||
// No provider was resolved on the text path.
|
||||
expect(resolveVisionProvider).not.toHaveBeenCalled();
|
||||
expect(resolveDocumentVisionProvider).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("text: 422 when local OCR is not enabled", async () => {
|
||||
|
||||
@@ -14,8 +14,9 @@
|
||||
* Decision (maintainer, 2026-07-07): content indexing is gated on the EXISTING
|
||||
* AI consent / provider gate — there is NO separate `documentsContentIndexEnabled`
|
||||
* toggle (the plan's P2-D8 opt-in was refused to avoid toggle sprawl). The vision
|
||||
* path runs `assertConsentForChain`; the text path rides the local-OCR opt-in the
|
||||
* lab / extract text mode already uses.
|
||||
* path runs `assertDocumentEgressConsent` (any external provider needs an active
|
||||
* receipt; a local pick stays ungated); the text path rides the local-OCR opt-in
|
||||
* the lab / extract text mode already uses — no provider egress at all.
|
||||
*
|
||||
* Persists ONLY AES-256-GCM ciphertext text + opaque HMAC token hashes (A4).
|
||||
*/
|
||||
@@ -29,7 +30,7 @@ import {
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
|
||||
import { assertConsentForChain } from "@/lib/ai/consent-guard";
|
||||
import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard";
|
||||
import {
|
||||
buildDateKey,
|
||||
reconcileSpend,
|
||||
@@ -51,7 +52,7 @@ import {
|
||||
DocumentDescribeError,
|
||||
transcribeDocument,
|
||||
} from "@/lib/documents/describe";
|
||||
import { resolveVisionProvider } from "@/lib/labs/ocr-capability";
|
||||
import { resolveDocumentVisionProvider } from "@/lib/documents/provider-order";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { prisma } from "@/lib/db";
|
||||
@@ -164,13 +165,17 @@ async function handleVisionIndex(
|
||||
userId: string,
|
||||
document: LoadedDocument,
|
||||
): Promise<Response> {
|
||||
const { chain, pick } = await resolveVisionProvider(userId);
|
||||
const { pick } = await resolveDocumentVisionProvider(userId);
|
||||
if (!pick) {
|
||||
return apiError("No vision-capable AI provider is configured", 422, {
|
||||
errorCode: "documents.inbound.providerUnsupported",
|
||||
});
|
||||
}
|
||||
await assertConsentForChain({ userId, chain, surface: "insights" });
|
||||
await assertDocumentEgressConsent({
|
||||
userId,
|
||||
providerType: pick.providerType,
|
||||
surface: "insights",
|
||||
});
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`${DOCUMENT_AI_BUCKET}:${userId}`,
|
||||
|
||||
@@ -27,12 +27,13 @@ vi.mock("@/lib/documents/assist", () => ({
|
||||
runDocumentAssist: vi.fn(),
|
||||
DocumentAssistError: class DocumentAssistError extends Error {},
|
||||
}));
|
||||
vi.mock("@/lib/labs/ocr-capability", () => ({
|
||||
resolveVisionProvider: vi.fn(),
|
||||
resolveTextProvider: vi.fn(),
|
||||
vi.mock("@/lib/documents/provider-order", () => ({
|
||||
resolveDocumentVisionProvider: vi.fn(),
|
||||
resolveDocumentTextProvider: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/consent-guard", () => ({
|
||||
assertConsentForChain: vi.fn().mockResolvedValue(undefined),
|
||||
assertDocumentEgressConsent: vi.fn().mockResolvedValue(undefined),
|
||||
ConsentRequiredError: class ConsentRequiredError extends Error {},
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/budget", () => ({
|
||||
buildDateKey: vi.fn(() => "2026-07-07"),
|
||||
@@ -67,7 +68,7 @@ vi.mock("next/headers", () => ({
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { resolveVisionProvider } from "@/lib/labs/ocr-capability";
|
||||
import { resolveDocumentVisionProvider } from "@/lib/documents/provider-order";
|
||||
import { runDocumentAssist } from "@/lib/documents/assist";
|
||||
|
||||
const SESSION_OK = {
|
||||
@@ -98,7 +99,7 @@ beforeEach(() => {
|
||||
|
||||
describe("POST /api/documents/inbound/[id]/suggest", () => {
|
||||
it("422s without a vision provider and writes nothing", async () => {
|
||||
vi.mocked(resolveVisionProvider).mockResolvedValue({
|
||||
vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({
|
||||
chain: [],
|
||||
pick: null,
|
||||
} as never);
|
||||
@@ -113,7 +114,7 @@ describe("POST /api/documents/inbound/[id]/suggest", () => {
|
||||
});
|
||||
|
||||
it("returns drafts and never persists them", async () => {
|
||||
vi.mocked(resolveVisionProvider).mockResolvedValue({
|
||||
vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({
|
||||
chain: [{ providerType: "anthropic", instance: {} }],
|
||||
pick: {
|
||||
entry: { providerType: "anthropic", instance: {} },
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
|
||||
import { assertConsentForChain } from "@/lib/ai/consent-guard";
|
||||
import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard";
|
||||
import {
|
||||
buildDateKey,
|
||||
reconcileSpend,
|
||||
@@ -45,9 +45,9 @@ import {
|
||||
type LoadedDocument,
|
||||
} from "@/lib/documents/ai-route-support";
|
||||
import {
|
||||
resolveTextProvider,
|
||||
resolveVisionProvider,
|
||||
} from "@/lib/labs/ocr-capability";
|
||||
resolveDocumentTextProvider,
|
||||
resolveDocumentVisionProvider,
|
||||
} from "@/lib/documents/provider-order";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { prisma } from "@/lib/db";
|
||||
@@ -133,14 +133,18 @@ async function handleTextSuggest(
|
||||
});
|
||||
}
|
||||
|
||||
const { chain, pick } = await resolveTextProvider(userId);
|
||||
const { pick } = await resolveDocumentTextProvider(userId);
|
||||
if (!pick) {
|
||||
return apiError("No AI provider is configured", 422, {
|
||||
errorCode: "documents.inbound.providerUnsupported",
|
||||
});
|
||||
}
|
||||
|
||||
await assertConsentForChain({ userId, chain, surface: "insights" });
|
||||
await assertDocumentEgressConsent({
|
||||
userId,
|
||||
providerType: pick.providerType,
|
||||
surface: "insights",
|
||||
});
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`${DOCUMENT_AI_BUCKET}:${userId}`,
|
||||
@@ -209,14 +213,18 @@ async function handleVisionSuggest(
|
||||
userId: string,
|
||||
document: LoadedDocument,
|
||||
): Promise<Response> {
|
||||
const { chain, pick } = await resolveVisionProvider(userId);
|
||||
const { pick } = await resolveDocumentVisionProvider(userId);
|
||||
if (!pick) {
|
||||
return apiError("No vision-capable AI provider is configured", 422, {
|
||||
errorCode: "documents.inbound.providerUnsupported",
|
||||
});
|
||||
}
|
||||
|
||||
await assertConsentForChain({ userId, chain, surface: "insights" });
|
||||
await assertDocumentEgressConsent({
|
||||
userId,
|
||||
providerType: pick.providerType,
|
||||
surface: "insights",
|
||||
});
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`${DOCUMENT_AI_BUCKET}:${userId}`,
|
||||
|
||||
@@ -27,12 +27,13 @@ vi.mock("@/lib/documents/describe", () => ({
|
||||
transcribeDocument: vi.fn(),
|
||||
DocumentDescribeError: class DocumentDescribeError extends Error {},
|
||||
}));
|
||||
vi.mock("@/lib/labs/ocr-capability", () => ({
|
||||
resolveVisionProvider: vi.fn(),
|
||||
resolveTextProvider: vi.fn(),
|
||||
vi.mock("@/lib/documents/provider-order", () => ({
|
||||
resolveDocumentVisionProvider: vi.fn(),
|
||||
resolveDocumentTextProvider: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/consent-guard", () => ({
|
||||
assertConsentForChain: vi.fn().mockResolvedValue(undefined),
|
||||
assertDocumentEgressConsent: vi.fn().mockResolvedValue(undefined),
|
||||
ConsentRequiredError: class ConsentRequiredError extends Error {},
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/budget", () => ({
|
||||
buildDateKey: vi.fn(() => "2026-07-07"),
|
||||
@@ -67,7 +68,7 @@ vi.mock("next/headers", () => ({
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { resolveVisionProvider } from "@/lib/labs/ocr-capability";
|
||||
import { resolveDocumentVisionProvider } from "@/lib/documents/provider-order";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
runDocumentSummary,
|
||||
@@ -105,7 +106,7 @@ beforeEach(() => {
|
||||
mimeType: "image/png",
|
||||
status: "STORED",
|
||||
} as never);
|
||||
vi.mocked(resolveVisionProvider).mockResolvedValue({
|
||||
vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({
|
||||
chain: [{ providerType: "anthropic", instance: {} }],
|
||||
pick: {
|
||||
entry: { providerType: "anthropic", instance: {} },
|
||||
@@ -151,7 +152,7 @@ describe("POST /api/documents/inbound/[id]/summary", () => {
|
||||
});
|
||||
|
||||
it("422s without a provider", async () => {
|
||||
vi.mocked(resolveVisionProvider).mockResolvedValue({
|
||||
vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({
|
||||
chain: [],
|
||||
pick: null,
|
||||
} as never);
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
|
||||
import { assertConsentForChain } from "@/lib/ai/consent-guard";
|
||||
import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard";
|
||||
import {
|
||||
buildDateKey,
|
||||
reconcileSpend,
|
||||
@@ -47,9 +47,9 @@ import {
|
||||
type DescribeInput,
|
||||
} from "@/lib/documents/describe";
|
||||
import {
|
||||
resolveTextProvider,
|
||||
resolveVisionProvider,
|
||||
} from "@/lib/labs/ocr-capability";
|
||||
resolveDocumentTextProvider,
|
||||
resolveDocumentVisionProvider,
|
||||
} from "@/lib/documents/provider-order";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { prisma } from "@/lib/db";
|
||||
@@ -188,13 +188,17 @@ async function handleTextSummary(
|
||||
return finishSummary(request, userId, document.id, "text", mode, result);
|
||||
}
|
||||
|
||||
const { chain, pick } = await resolveTextProvider(userId);
|
||||
const { pick } = await resolveDocumentTextProvider(userId);
|
||||
if (!pick) {
|
||||
return apiError("No AI provider is configured", 422, {
|
||||
errorCode: "documents.inbound.providerUnsupported",
|
||||
});
|
||||
}
|
||||
await assertConsentForChain({ userId, chain, surface: "insights" });
|
||||
await assertDocumentEgressConsent({
|
||||
userId,
|
||||
providerType: pick.providerType,
|
||||
surface: "insights",
|
||||
});
|
||||
|
||||
const dateKey = buildDateKey();
|
||||
const reservation = await reserveBudget(
|
||||
@@ -235,13 +239,17 @@ async function handleVisionSummary(
|
||||
document: LoadedDocument,
|
||||
mode: DocumentSummaryMode,
|
||||
): Promise<Response> {
|
||||
const { chain, pick } = await resolveVisionProvider(userId);
|
||||
const { pick } = await resolveDocumentVisionProvider(userId);
|
||||
if (!pick) {
|
||||
return apiError("No vision-capable AI provider is configured", 422, {
|
||||
errorCode: "documents.inbound.providerUnsupported",
|
||||
});
|
||||
}
|
||||
await assertConsentForChain({ userId, chain, surface: "insights" });
|
||||
await assertDocumentEgressConsent({
|
||||
userId,
|
||||
providerType: pick.providerType,
|
||||
surface: "insights",
|
||||
});
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`${DOCUMENT_AI_BUCKET}:${userId}`,
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* GET /api/documents/inbound/capability
|
||||
*
|
||||
* Document-scoped AI capability probe (no provider call). Mirrors the labs
|
||||
* `/api/labs/ocr/capability` probe but resolves over the DOCUMENT provider
|
||||
* order (local-first, codex last — governance fix, oauth-investigation
|
||||
* SYNTHESIS §1) and adds the vendor-blind `egress` class so the vault UI can
|
||||
* show the "this leaves your machine to a third-party AI" notice BEFORE a read.
|
||||
*
|
||||
* The `mode` / `pdfSupported` / `egress` here reflect exactly what the document
|
||||
* routes (suggest / summary / extract / index) will do, so the affordance the
|
||||
* UI offers never diverges from what the endpoint accepts.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { resolveDocumentAiCapability } from "@/lib/documents/provider-order";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const gate = await requireModuleEnabled(user.id, "inboundDocuments");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const capability = await resolveDocumentAiCapability(user.id);
|
||||
|
||||
annotate({
|
||||
action: { name: "documents.ai.capability" },
|
||||
meta: {
|
||||
available: capability.available,
|
||||
mode: capability.mode,
|
||||
reason: capability.reason,
|
||||
pdfSupported: capability.pdfSupported,
|
||||
egress: capability.egress,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess(capability);
|
||||
});
|
||||
@@ -9,10 +9,10 @@
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { assertConsentForChain } from "@/lib/ai/consent-guard";
|
||||
import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { enqueueContentIndexBackfill } from "@/lib/jobs/document-content-index-backfill";
|
||||
import { resolveVisionProvider } from "@/lib/labs/ocr-capability";
|
||||
import { resolveDocumentVisionProvider } from "@/lib/documents/provider-order";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
@@ -46,13 +46,17 @@ 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 { chain, pick } = await resolveVisionProvider(user.id);
|
||||
const { pick } = await resolveDocumentVisionProvider(user.id);
|
||||
if (!pick) {
|
||||
return apiError("No vision-capable AI provider is configured", 422, {
|
||||
errorCode: "documents.inbound.providerUnsupported",
|
||||
});
|
||||
}
|
||||
await assertConsentForChain({ userId: user.id, chain, surface: "insights" });
|
||||
await assertDocumentEgressConsent({
|
||||
userId: user.id,
|
||||
providerType: pick.providerType,
|
||||
surface: "insights",
|
||||
});
|
||||
|
||||
const { enqueued } = await enqueueContentIndexBackfill(user.id);
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ function base(
|
||||
aiEnabled: true,
|
||||
indexEnabled: true,
|
||||
unavailableReason: null,
|
||||
egressExternal: false,
|
||||
actionsDisabled: false,
|
||||
onSuggest: noop,
|
||||
suggestPending: false,
|
||||
@@ -142,6 +143,22 @@ describe("<DocumentAiSection>", () => {
|
||||
expect(html).toContain("Not saved");
|
||||
});
|
||||
|
||||
it("shows the vendor-blind egress notice before an external document read", () => {
|
||||
const html = render(base({ aiEnabled: true, egressExternal: true }));
|
||||
expect(html).toContain('data-slot="document-ai-egress-notice"');
|
||||
expect(html).toContain("third-party AI service");
|
||||
// Vendor-blind: never names a specific AI vendor in the document surface.
|
||||
expect(html).not.toContain("OpenAI");
|
||||
expect(html).not.toContain("ChatGPT");
|
||||
// The actions are still offered — the notice informs, it does not block.
|
||||
expect(html).toContain('data-slot="assist-suggest"');
|
||||
});
|
||||
|
||||
it("omits the egress notice when the read stays on the local machine", () => {
|
||||
const html = render(base({ aiEnabled: true, egressExternal: false }));
|
||||
expect(html).not.toContain('data-slot="document-ai-egress-notice"');
|
||||
});
|
||||
|
||||
it("hides the Read-with-AI action when indexing is unavailable but keeps suggest", () => {
|
||||
const html = render(base({ aiEnabled: true, indexEnabled: false }));
|
||||
expect(html).toContain('data-slot="assist-suggest"');
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
* status pill stays, honestly reflecting any local auto-index, and the manual
|
||||
* form below stays fully usable.
|
||||
*/
|
||||
import { FileText, ScanText, WandSparkles } from "lucide-react";
|
||||
import { FileText, ScanText, ShieldAlert, WandSparkles } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
@@ -46,6 +46,7 @@ export function DocumentAiSection({
|
||||
aiEnabled,
|
||||
indexEnabled,
|
||||
unavailableReason,
|
||||
egressExternal,
|
||||
actionsDisabled,
|
||||
onSuggest,
|
||||
suggestPending,
|
||||
@@ -72,6 +73,12 @@ export function DocumentAiSection({
|
||||
aiEnabled: boolean;
|
||||
indexEnabled: boolean;
|
||||
unavailableReason: "no-provider" | "enable-local-ocr" | null;
|
||||
/**
|
||||
* True when a document read will egress to a third-party AI service (any
|
||||
* provider other than a self-hosted local model). Drives the vendor-blind
|
||||
* "this leaves your machine" notice shown before the AI actions.
|
||||
*/
|
||||
egressExternal: boolean;
|
||||
/** Capability still resolving — the transport mode isn't known yet. */
|
||||
actionsDisabled: boolean;
|
||||
onSuggest: () => void;
|
||||
@@ -126,6 +133,17 @@ export function DocumentAiSection({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{egressExternal ? (
|
||||
<div
|
||||
data-slot="document-ai-egress-notice"
|
||||
role="note"
|
||||
className="border-border text-muted-foreground flex items-start gap-2 rounded-lg border border-dashed px-3 py-2.5 text-xs"
|
||||
>
|
||||
<ShieldAlert className="mt-0.5 size-3.5 shrink-0" aria-hidden />
|
||||
<p className="min-w-0">{t("documents.ai.egressNotice")}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{aiEnabled ? (
|
||||
<>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
|
||||
@@ -505,6 +505,7 @@ export function DocumentDetailSheet({
|
||||
aiEnabled={aiEnabled}
|
||||
indexEnabled={indexEnabled}
|
||||
unavailableReason={capability.data?.reason ?? null}
|
||||
egressExternal={capability.data?.egress === "external"}
|
||||
actionsDisabled={capability.isPending}
|
||||
onSuggest={runSuggest}
|
||||
suggestPending={suggest.isPending}
|
||||
|
||||
@@ -17,8 +17,8 @@ import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { apiGet, ApiError } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { OcrCapabilityDto } from "@/lib/validations/labs-ocr";
|
||||
import type {
|
||||
DocumentAiCapabilityDto,
|
||||
DocumentSuggestionDto,
|
||||
DocumentSummaryMode,
|
||||
} from "@/lib/validations/inbound-documents";
|
||||
@@ -34,16 +34,18 @@ import {
|
||||
export type DocumentDescribeResult = { summary: string } | { text: string };
|
||||
|
||||
/**
|
||||
* The shared OCR capability probe (`/api/labs/ocr/capability`) reused verbatim:
|
||||
* the same server resolution both the lab-OCR and the document AI routes gate
|
||||
* on, so the client picks the transport the endpoint accepts. Availability of
|
||||
* the affordance itself is gated on `usage.assistAvailable`; this read only
|
||||
* resolves the transport `mode` + PDF support.
|
||||
* The document-scoped AI capability probe (`/api/documents/inbound/capability`).
|
||||
* Resolved over the DOCUMENT provider order (local-first, codex last), so the
|
||||
* transport `mode` + PDF support match what the document routes do, and it
|
||||
* carries the vendor-blind `egress` class the detail sheet uses to warn before a
|
||||
* read leaves the machine. Availability of the affordance itself is still gated
|
||||
* on `usage.assistAvailable`.
|
||||
*/
|
||||
export function useDocumentAiCapability(enabled: boolean) {
|
||||
return useQuery<OcrCapabilityDto>({
|
||||
queryKey: queryKeys.ocrCapability(),
|
||||
queryFn: () => apiGet<OcrCapabilityDto>("/api/labs/ocr/capability"),
|
||||
return useQuery<DocumentAiCapabilityDto>({
|
||||
queryKey: queryKeys.inboundDocumentAiCapability(),
|
||||
queryFn: () =>
|
||||
apiGet<DocumentAiCapabilityDto>("/api/documents/inbound/capability"),
|
||||
enabled,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
@@ -7,8 +7,10 @@ vi.mock("@/lib/consent/receipts", () => ({
|
||||
import {
|
||||
ConsentRequiredError,
|
||||
assertConsentForChain,
|
||||
assertDocumentEgressConsent,
|
||||
chainRequiresServerManagedConsent,
|
||||
hasActiveConsentForSurface,
|
||||
isExternalDocumentEgress,
|
||||
} from "../consent-guard";
|
||||
import { latestActiveReceipt } from "@/lib/consent/receipts";
|
||||
import type { ProviderChainResolved } from "../provider-runner";
|
||||
@@ -168,3 +170,67 @@ describe("assertConsentForChain", () => {
|
||||
).rejects.toBeInstanceOf(ConsentRequiredError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isExternalDocumentEgress", () => {
|
||||
it("treats only the self-hosted local provider as non-egress", () => {
|
||||
expect(isExternalDocumentEgress("local")).toBe(false);
|
||||
});
|
||||
|
||||
it("treats every external provider as document egress", () => {
|
||||
for (const p of ["codex", "openai", "anthropic", "admin-openai"]) {
|
||||
expect(isExternalDocumentEgress(p)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertDocumentEgressConsent", () => {
|
||||
beforeEach(() => mockedLatest.mockReset());
|
||||
|
||||
it("never reads consent and never throws for a LOCAL document pick", async () => {
|
||||
grant([]);
|
||||
await expect(
|
||||
assertDocumentEgressConsent({
|
||||
userId: "u1",
|
||||
providerType: "local",
|
||||
surface: "insights",
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
expect(mockedLatest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// The live gap the governance fix closes: codex was ungated for documents.
|
||||
it("REQUIRES a receipt to send a document to codex (the closed gap)", async () => {
|
||||
grant([]);
|
||||
await expect(
|
||||
assertDocumentEgressConsent({
|
||||
userId: "u1",
|
||||
providerType: "codex",
|
||||
surface: "insights",
|
||||
}),
|
||||
).rejects.toBeInstanceOf(ConsentRequiredError);
|
||||
});
|
||||
|
||||
it("requires a receipt for BYOK document egress too (openai / anthropic)", async () => {
|
||||
grant([]);
|
||||
for (const p of ["openai", "anthropic", "admin-openai"]) {
|
||||
await expect(
|
||||
assertDocumentEgressConsent({
|
||||
userId: "u1",
|
||||
providerType: p,
|
||||
surface: "insights",
|
||||
}),
|
||||
).rejects.toBeInstanceOf(ConsentRequiredError);
|
||||
}
|
||||
});
|
||||
|
||||
it("proceeds for codex WITH an active document-class receipt", async () => {
|
||||
grant(["ai_insights_only"]);
|
||||
await expect(
|
||||
assertDocumentEgressConsent({
|
||||
userId: "u1",
|
||||
providerType: "codex",
|
||||
surface: "insights",
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -118,3 +118,50 @@ export async function assertConsentForChain(args: {
|
||||
if (await hasActiveConsentForSurface(args.userId, args.surface)) return;
|
||||
throw new ConsentRequiredError(args.surface);
|
||||
}
|
||||
|
||||
/**
|
||||
* The DOCUMENT-class consent gate (governance fix, oauth-investigation
|
||||
* SYNTHESIS §2).
|
||||
*
|
||||
* The self-snapshot gate above leaves BYOK / codex / local UNGATED on the
|
||||
* theory that "configuring the provider was the consent act." That theory holds
|
||||
* for a metrics snapshot the account owner sends about themselves. It is too
|
||||
* thin for an uploaded medical DOCUMENT: sending a scanned discharge letter to
|
||||
* ANY third-party AI is the egress data-protection law cares about, and the
|
||||
* codex (ChatGPT-subscription) backend trains on consumer content by default.
|
||||
*
|
||||
* So for the document surfaces the gate is stricter and PICK-based: the vault
|
||||
* AI routes call the single resolved provider directly (no runner cascade), so
|
||||
* the exact egress is the picked provider. A `local` pick never leaves the
|
||||
* machine and stays ungated; EVERY external pick (codex, BYOK openai/anthropic,
|
||||
* the operator's admin key) requires an active document-class consent receipt.
|
||||
*/
|
||||
const LOCAL_ONLY_PROVIDER_TYPES: ReadonlySet<string> = new Set(["local"]);
|
||||
|
||||
/**
|
||||
* True when reading a document through this provider egresses it OFF the machine
|
||||
* to a third-party AI service. Only the self-hosted `local` provider keeps the
|
||||
* document on the operator's own infrastructure.
|
||||
*/
|
||||
export function isExternalDocumentEgress(providerType: string): boolean {
|
||||
return !LOCAL_ONLY_PROVIDER_TYPES.has(providerType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce the document-class consent precondition for the provider that will
|
||||
* actually receive the document. No-op for a `local` pick (nothing leaves the
|
||||
* machine); for any external pick, throw `ConsentRequiredError` unless an active
|
||||
* receipt of the surface's mapped kind (or `ai_full`) is on file.
|
||||
*
|
||||
* Call this AFTER the document provider is picked and BEFORE the first
|
||||
* `generateCompletion` on it.
|
||||
*/
|
||||
export async function assertDocumentEgressConsent(args: {
|
||||
userId: string;
|
||||
providerType: string;
|
||||
surface: ConsentSurface;
|
||||
}): Promise<void> {
|
||||
if (!isExternalDocumentEgress(args.providerType)) return;
|
||||
if (await hasActiveConsentForSurface(args.userId, args.surface)) return;
|
||||
throw new ConsentRequiredError(args.surface);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
/**
|
||||
* Document-class provider order (governance fix, oauth-investigation
|
||||
* SYNTHESIS §1). Pins: for a DOCUMENT read the chain is reprioritised
|
||||
* local-first with codex (ChatGPT-subscription OAuth) LAST, and the egress
|
||||
* class the vault notice reads is vendor-blind local/external. Coach / insights
|
||||
* do not use these helpers, so their app-wide order is untouched.
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/labs/ocr-capability", () => ({
|
||||
resolveVisionProvider: vi.fn(),
|
||||
resolveTextProvider: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
documentEgressClass,
|
||||
reorderChainForDocumentClass,
|
||||
resolveDocumentVisionProvider,
|
||||
} from "../provider-order";
|
||||
import {
|
||||
resolveVisionProvider,
|
||||
type VisionProviderPick,
|
||||
} from "@/lib/labs/ocr-capability";
|
||||
import type { ProviderChainResolved } from "@/lib/ai/provider-runner";
|
||||
|
||||
function entry(providerType: string): ProviderChainResolved {
|
||||
return {
|
||||
providerType: providerType as ProviderChainResolved["providerType"],
|
||||
instance: {
|
||||
type: providerType,
|
||||
generateCompletion: vi.fn(),
|
||||
} as unknown as ProviderChainResolved["instance"],
|
||||
};
|
||||
}
|
||||
|
||||
const order = (chain: ProviderChainResolved[]) =>
|
||||
reorderChainForDocumentClass(chain).map((e) => e.providerType);
|
||||
|
||||
describe("reorderChainForDocumentClass", () => {
|
||||
it("demotes codex last and lifts local first for the default chain", () => {
|
||||
// The persisted default chain the audit flagged: codex at priority 1.
|
||||
const chain = [
|
||||
entry("codex"),
|
||||
entry("openai"),
|
||||
entry("anthropic"),
|
||||
entry("local"),
|
||||
entry("admin-openai"),
|
||||
];
|
||||
expect(order(chain)).toEqual([
|
||||
"local",
|
||||
"openai",
|
||||
"anthropic",
|
||||
"admin-openai",
|
||||
"codex",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps codex behind local even when codex is the only cheap option", () => {
|
||||
expect(order([entry("codex"), entry("local")])).toEqual(["local", "codex"]);
|
||||
});
|
||||
|
||||
it("is stable within a rank tier (preserves user order for BYOK keys)", () => {
|
||||
expect(order([entry("anthropic"), entry("openai")])).toEqual([
|
||||
"anthropic",
|
||||
"openai",
|
||||
]);
|
||||
expect(order([entry("openai"), entry("anthropic")])).toEqual([
|
||||
"openai",
|
||||
"anthropic",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not mutate the input array", () => {
|
||||
const chain = [entry("codex"), entry("local")];
|
||||
reorderChainForDocumentClass(chain);
|
||||
expect(chain.map((e) => e.providerType)).toEqual(["codex", "local"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("documentEgressClass", () => {
|
||||
it("classifies local as on-machine and everything else as external", () => {
|
||||
expect(documentEgressClass("local")).toBe("local");
|
||||
expect(documentEgressClass("codex")).toBe("external");
|
||||
expect(documentEgressClass("openai")).toBe("external");
|
||||
expect(documentEgressClass("anthropic")).toBe("external");
|
||||
expect(documentEgressClass("admin-openai")).toBe("external");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveDocumentVisionProvider", () => {
|
||||
beforeEach(() => vi.mocked(resolveVisionProvider).mockReset());
|
||||
|
||||
it("invokes the shared resolver with the document reorder that demotes codex", async () => {
|
||||
vi.mocked(resolveVisionProvider).mockResolvedValue({
|
||||
chain: [],
|
||||
localOcrEnabled: false,
|
||||
pick: null,
|
||||
} as VisionProviderPick);
|
||||
|
||||
await resolveDocumentVisionProvider("u1");
|
||||
|
||||
const call = vi.mocked(resolveVisionProvider).mock.calls[0]!;
|
||||
expect(call[0]).toBe("u1");
|
||||
const reorder = call[1]!.reorder!;
|
||||
// The reorder the document resolver hands down puts a codex-first chain
|
||||
// local-first — proving documents never default to the subscription path.
|
||||
expect(
|
||||
reorder([entry("codex"), entry("local")]).map((e) => e.providerType),
|
||||
).toEqual(["local", "codex"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Document-class AI provider resolution (governance fix, oauth-investigation
|
||||
* SYNTHESIS §1).
|
||||
*
|
||||
* A three-audit investigation found that for uploaded medical DOCUMENTS the
|
||||
* app-wide provider chain is the wrong default: `codex` (the
|
||||
* ChatGPT-subscription OAuth backend) sits at chain priority 1, ahead of BYOK
|
||||
* and local, and OpenAI's consumer policy allows training on that content by
|
||||
* default. HealthLog is privacy-first, so a scanned discharge letter must NOT
|
||||
* default to the train-by-default backend.
|
||||
*
|
||||
* The fix is scoped to the DOCUMENT class only — Coach / insights keep the
|
||||
* cost-first app-wide order untouched. This module reprioritises the resolved
|
||||
* provider chain for the vault's AI surfaces (suggest / summary / extract /
|
||||
* index / reindex / backfill):
|
||||
*
|
||||
* local (no egress) → BYOK no-train API (openai / anthropic) → operator's
|
||||
* admin key → codex (ChatGPT-subscription OAuth) LAST, as an explicit,
|
||||
* 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.
|
||||
*/
|
||||
import { isExternalDocumentEgress } from "@/lib/ai/consent-guard";
|
||||
import {
|
||||
resolveTextProvider,
|
||||
resolveVisionProvider,
|
||||
type ChainReorder,
|
||||
type VisionProviderPick,
|
||||
} from "@/lib/labs/ocr-capability";
|
||||
import type {
|
||||
DocumentAiCapabilityDto,
|
||||
DocumentEgressClass,
|
||||
} from "@/lib/validations/inbound-documents";
|
||||
|
||||
/**
|
||||
* Preference rank for a provider when the payload is a DOCUMENT. Lower wins.
|
||||
* Local keeps the document on the machine (rank 0); BYOK no-train API keys are
|
||||
* next (rank 1); the operator's shared no-train key follows (rank 2); the
|
||||
* ChatGPT-subscription OAuth path (`codex`) is LAST (rank 3) because it trains
|
||||
* on consumer content by default and cannot be verified opted-out from here.
|
||||
*/
|
||||
function documentProviderRank(providerType: string): number {
|
||||
switch (providerType) {
|
||||
case "local":
|
||||
return 0;
|
||||
case "openai":
|
||||
case "anthropic":
|
||||
case "admin-key":
|
||||
return 1;
|
||||
case "admin-openai":
|
||||
return 2;
|
||||
case "codex":
|
||||
return 3;
|
||||
default:
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder a resolved chain for the document class: stable sort by
|
||||
* `documentProviderRank`, so within a rank tier the user's own chain order is
|
||||
* preserved (a stable sort keeps insertion order on ties). Pure — returns a new
|
||||
* array, never mutates the input.
|
||||
*/
|
||||
export const reorderChainForDocumentClass: ChainReorder = (chain) => {
|
||||
return [...chain].sort(
|
||||
(a, b) =>
|
||||
documentProviderRank(a.providerType) -
|
||||
documentProviderRank(b.providerType),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function resolveDocumentVisionProvider(
|
||||
userId: string,
|
||||
): Promise<VisionProviderPick> {
|
||||
return resolveVisionProvider(userId, {
|
||||
reorder: reorderChainForDocumentClass,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function resolveDocumentTextProvider(userId: string) {
|
||||
return resolveTextProvider(userId, {
|
||||
reorder: reorderChainForDocumentClass,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a picked provider's egress for the per-egress UI notice. Vendor-blind
|
||||
* by design — "local" (stays on the machine) vs "external" (a third-party AI
|
||||
* service); the copy never names a vendor.
|
||||
*/
|
||||
export function documentEgressClass(providerType: string): DocumentEgressClass {
|
||||
return isExternalDocumentEgress(providerType) ? "external" : "local";
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export async function resolveDocumentAiCapability(
|
||||
userId: string,
|
||||
): Promise<DocumentAiCapabilityDto> {
|
||||
const { chain, pick, localOcrEnabled } =
|
||||
await resolveDocumentVisionProvider(userId);
|
||||
|
||||
// A vision-capable provider is available — the read runs directly over the
|
||||
// stored original. Egress follows the picked provider.
|
||||
if (pick) {
|
||||
return {
|
||||
available: true,
|
||||
mode: "vision",
|
||||
reason: null,
|
||||
pdfSupported: pick.pdfSupported,
|
||||
egress: documentEgressClass(pick.providerType),
|
||||
};
|
||||
}
|
||||
|
||||
// Nothing configured at all — no read, regardless of the local-OCR toggle.
|
||||
if (chain.length === 0) {
|
||||
return {
|
||||
available: false,
|
||||
mode: null,
|
||||
reason: "no-provider",
|
||||
pdfSupported: false,
|
||||
egress: null,
|
||||
};
|
||||
}
|
||||
|
||||
// A text-only provider is configured. Local OCR runs in the browser and only
|
||||
// the extracted text is structured by the first provider in document order.
|
||||
if (localOcrEnabled) {
|
||||
return {
|
||||
available: true,
|
||||
mode: "text",
|
||||
reason: null,
|
||||
pdfSupported: false,
|
||||
egress: documentEgressClass(chain[0]!.providerType),
|
||||
};
|
||||
}
|
||||
|
||||
// A text-only provider is configured but local OCR is not enabled.
|
||||
return {
|
||||
available: false,
|
||||
mode: null,
|
||||
reason: "enable-local-ocr",
|
||||
pdfSupported: false,
|
||||
egress: null,
|
||||
};
|
||||
}
|
||||
@@ -25,11 +25,11 @@ vi.mock("@/lib/documents/describe", () => ({
|
||||
vi.mock("@/lib/documents/content-index", () => ({
|
||||
upsertContentIndex: vi.fn().mockResolvedValue({ tokenCount: 3 }),
|
||||
}));
|
||||
vi.mock("@/lib/labs/ocr-capability", () => ({
|
||||
resolveVisionProvider: vi.fn(),
|
||||
vi.mock("@/lib/documents/provider-order", () => ({
|
||||
resolveDocumentVisionProvider: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/consent-guard", () => ({
|
||||
assertConsentForChain: vi.fn().mockResolvedValue(undefined),
|
||||
assertDocumentEgressConsent: vi.fn().mockResolvedValue(undefined),
|
||||
ConsentRequiredError: class ConsentRequiredError extends Error {},
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/budget", () => ({
|
||||
@@ -42,9 +42,9 @@ vi.mock("@/lib/jobs/boss-instance", () => ({ getGlobalBoss: vi.fn() }));
|
||||
|
||||
import { runContentIndexBackfillForUser } from "../document-content-index-backfill";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { resolveVisionProvider } from "@/lib/labs/ocr-capability";
|
||||
import { resolveDocumentVisionProvider } from "@/lib/documents/provider-order";
|
||||
import {
|
||||
assertConsentForChain,
|
||||
assertDocumentEgressConsent,
|
||||
ConsentRequiredError,
|
||||
} from "@/lib/ai/consent-guard";
|
||||
import { reserveBudget } from "@/lib/ai/coach/budget";
|
||||
@@ -81,7 +81,7 @@ beforeEach(() => {
|
||||
|
||||
describe("runContentIndexBackfillForUser", () => {
|
||||
it("no-ops with no provider", async () => {
|
||||
vi.mocked(resolveVisionProvider).mockResolvedValue({
|
||||
vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({
|
||||
chain: [],
|
||||
pick: null,
|
||||
} as never);
|
||||
@@ -91,8 +91,8 @@ describe("runContentIndexBackfillForUser", () => {
|
||||
});
|
||||
|
||||
it("no-ops when consent is missing", async () => {
|
||||
vi.mocked(resolveVisionProvider).mockResolvedValue(PICK as never);
|
||||
vi.mocked(assertConsentForChain).mockRejectedValueOnce(
|
||||
vi.mocked(resolveDocumentVisionProvider).mockResolvedValue(PICK as never);
|
||||
vi.mocked(assertDocumentEgressConsent).mockRejectedValueOnce(
|
||||
new ConsentRequiredError("insights" as never),
|
||||
);
|
||||
const result = await runContentIndexBackfillForUser("user-1");
|
||||
@@ -101,7 +101,7 @@ describe("runContentIndexBackfillForUser", () => {
|
||||
});
|
||||
|
||||
it("indexes the not-yet-indexed documents", async () => {
|
||||
vi.mocked(resolveVisionProvider).mockResolvedValue(PICK as never);
|
||||
vi.mocked(resolveDocumentVisionProvider).mockResolvedValue(PICK as never);
|
||||
// One short page (< PAGE_SIZE) — the walk breaks after it, so a single
|
||||
// findMany return is enough (a trailing once would leak to the next test).
|
||||
vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([
|
||||
@@ -115,7 +115,7 @@ describe("runContentIndexBackfillForUser", () => {
|
||||
});
|
||||
|
||||
it("stops when the daily budget is reached (resumable)", async () => {
|
||||
vi.mocked(resolveVisionProvider).mockResolvedValue(PICK as never);
|
||||
vi.mocked(resolveDocumentVisionProvider).mockResolvedValue(PICK as never);
|
||||
vi.mocked(prisma.inboundDocument.findMany).mockResolvedValueOnce([
|
||||
{ id: "d1" },
|
||||
{ id: "d2" },
|
||||
|
||||
@@ -8,9 +8,10 @@
|
||||
* indexing egresses to a provider under the user's key + budget, so it must be
|
||||
* a deliberate, consented act, exactly like the per-document index route.
|
||||
*
|
||||
* Consent: gated on the EXISTING AI consent (`assertConsentForChain`), the same
|
||||
* gate the extract / index routes use — there is no separate per-user toggle
|
||||
* (maintainer decision, 2026-07-07).
|
||||
* Consent: gated on the document-egress consent (`assertDocumentEgressConsent`),
|
||||
* the same gate the extract / index routes use — any external provider needs an
|
||||
* active receipt; a local pick stays ungated. There is no separate per-user
|
||||
* toggle (maintainer decision, 2026-07-07).
|
||||
*
|
||||
* Bounded + resumable: an id-cursor forward walk over the not-yet-indexed set,
|
||||
* capped at `MAX_DOCS_PER_RUN` provider calls per job and stopped the moment the
|
||||
@@ -24,7 +25,7 @@
|
||||
*/
|
||||
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
|
||||
import {
|
||||
assertConsentForChain,
|
||||
assertDocumentEgressConsent,
|
||||
ConsentRequiredError,
|
||||
} from "@/lib/ai/consent-guard";
|
||||
import {
|
||||
@@ -41,7 +42,7 @@ import {
|
||||
import { upsertContentIndex } from "@/lib/documents/content-index";
|
||||
import { transcribeDocument } from "@/lib/documents/describe";
|
||||
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
|
||||
import { resolveVisionProvider } from "@/lib/labs/ocr-capability";
|
||||
import { resolveDocumentVisionProvider } from "@/lib/documents/provider-order";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
export const CONTENT_INDEX_BACKFILL_QUEUE = "document-content-index-backfill";
|
||||
@@ -76,10 +77,14 @@ export interface ContentIndexBackfillSummary {
|
||||
export async function runContentIndexBackfillForUser(
|
||||
userId: string,
|
||||
): Promise<ContentIndexBackfillSummary> {
|
||||
const { chain, pick } = await resolveVisionProvider(userId);
|
||||
const { pick } = await resolveDocumentVisionProvider(userId);
|
||||
if (!pick) return { indexed: 0, reason: "no-provider" };
|
||||
try {
|
||||
await assertConsentForChain({ userId, chain, surface: "insights" });
|
||||
await assertDocumentEgressConsent({
|
||||
userId,
|
||||
providerType: pick.providerType,
|
||||
surface: "insights",
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof ConsentRequiredError) {
|
||||
return { indexed: 0, reason: "no-consent" };
|
||||
|
||||
@@ -39,6 +39,21 @@ function modelForEntry(
|
||||
return providerType === "admin-openai" ? ctx.adminModel : ctx.userModel;
|
||||
}
|
||||
|
||||
/**
|
||||
* A transform applied to the resolved provider chain before the vision/text
|
||||
* pick is made. Identity by default (labs behaviour unchanged); the document
|
||||
* class passes a reorder that demotes external/train-by-default providers below
|
||||
* local (see `@/lib/documents/provider-order`).
|
||||
*/
|
||||
export type ChainReorder = (
|
||||
chain: ProviderChainResolved[],
|
||||
) => ProviderChainResolved[];
|
||||
|
||||
export interface ResolveProviderOptions {
|
||||
/** Reorder the resolved chain before picking. Defaults to identity. */
|
||||
reorder?: ChainReorder;
|
||||
}
|
||||
|
||||
/** A vision-capable provider pick: its instance, logical tag, and model. */
|
||||
export interface VisionProviderPick {
|
||||
/** The full resolved chain (for `assertConsentForChain`). */
|
||||
@@ -61,6 +76,7 @@ export interface VisionProviderPick {
|
||||
*/
|
||||
export async function resolveVisionProvider(
|
||||
userId: string,
|
||||
options?: ResolveProviderOptions,
|
||||
): Promise<VisionProviderPick> {
|
||||
const userRow = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
@@ -84,16 +100,22 @@ export async function resolveVisionProvider(
|
||||
adminModel: settings?.adminAiModel ?? null,
|
||||
};
|
||||
|
||||
const chain = ocr.chain;
|
||||
if (chain.length === 0) {
|
||||
const resolvedChain = ocr.chain;
|
||||
if (resolvedChain.length === 0) {
|
||||
// Mirror the Coach: fall back to the legacy single-provider resolution and
|
||||
// tag it as the admin-managed entry the consent gate recognises.
|
||||
const legacy = await resolveProvider(userId);
|
||||
if (legacy.type !== "none") {
|
||||
chain.push({ providerType: "admin-openai", instance: legacy });
|
||||
resolvedChain.push({ providerType: "admin-openai", instance: legacy });
|
||||
}
|
||||
}
|
||||
|
||||
// Apply the caller's reorder (document class demotes external providers below
|
||||
// local) before picking; labs passes no reorder → identity.
|
||||
const chain = options?.reorder
|
||||
? options.reorder(resolvedChain)
|
||||
: resolvedChain;
|
||||
|
||||
for (const entry of chain) {
|
||||
const providerType = entry.providerType as VisionProviderType;
|
||||
const model = modelForEntry(entry.providerType, ctx);
|
||||
@@ -119,19 +141,25 @@ export async function resolveVisionProvider(
|
||||
* so this returns the whole chain plus the first entry to drive the structuring
|
||||
* call. Returns `null` when nothing is configured.
|
||||
*/
|
||||
export async function resolveTextProvider(userId: string): Promise<{
|
||||
export async function resolveTextProvider(
|
||||
userId: string,
|
||||
options?: ResolveProviderOptions,
|
||||
): Promise<{
|
||||
chain: ProviderChainResolved[];
|
||||
pick: { entry: ProviderChainResolved; providerType: string } | null;
|
||||
}> {
|
||||
// v1.22 (#90) — use the dedicated document-scan provider when enabled, else
|
||||
// the main chain (the text-mode structuring pass needs no vision).
|
||||
const chain = (await resolveOcrProviderChain(userId)).chain;
|
||||
if (chain.length === 0) {
|
||||
const resolvedChain = (await resolveOcrProviderChain(userId)).chain;
|
||||
if (resolvedChain.length === 0) {
|
||||
const legacy = await resolveProvider(userId);
|
||||
if (legacy.type !== "none") {
|
||||
chain.push({ providerType: "admin-openai", instance: legacy });
|
||||
resolvedChain.push({ providerType: "admin-openai", instance: legacy });
|
||||
}
|
||||
}
|
||||
const chain = options?.reorder
|
||||
? options.reorder(resolvedChain)
|
||||
: resolvedChain;
|
||||
const entry = chain[0];
|
||||
return {
|
||||
chain,
|
||||
@@ -147,8 +175,12 @@ export async function resolveTextProvider(userId: string): Promise<{
|
||||
*/
|
||||
export async function resolveOcrCapability(
|
||||
userId: string,
|
||||
options?: ResolveProviderOptions,
|
||||
): Promise<OcrCapabilityDto> {
|
||||
const { chain, pick, localOcrEnabled } = await resolveVisionProvider(userId);
|
||||
const { chain, pick, localOcrEnabled } = await resolveVisionProvider(
|
||||
userId,
|
||||
options,
|
||||
);
|
||||
|
||||
// Prefer the native vision path whenever it is available — it is more
|
||||
// accurate and the client does not download the OCR WASM.
|
||||
|
||||
@@ -334,6 +334,38 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/documents/inbound/capability": {
|
||||
get: {
|
||||
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.',
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Document-AI capability flags + egress class.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z
|
||||
.object({
|
||||
available: z.boolean(),
|
||||
mode: z.enum(["vision", "text"]).nullable(),
|
||||
reason: z
|
||||
.enum(["no-provider", "enable-local-ocr"])
|
||||
.nullable(),
|
||||
pdfSupported: z.boolean(),
|
||||
egress: z.enum(["local", "external"]).nullable(),
|
||||
})
|
||||
.meta({ id: "DocumentAiCapability" }),
|
||||
"DocumentAiCapabilityEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/documents/inbound/usage": {
|
||||
get: {
|
||||
tags: ["Documents"],
|
||||
|
||||
@@ -58,4 +58,12 @@ export const documentKeys = {
|
||||
* delete so the quota bar tracks reality.
|
||||
*/
|
||||
inboundDocumentUsage: () => ["documents", "inbound", "usage"] as const,
|
||||
/**
|
||||
* Document-scoped AI capability probe
|
||||
* (`GET /api/documents/inbound/capability`). Distinct from the labs
|
||||
* `ocrCapability` key because it resolves over the DOCUMENT provider order
|
||||
* (local-first) and carries the per-egress class the vault notice reads.
|
||||
*/
|
||||
inboundDocumentAiCapability: () =>
|
||||
["documents", "inbound", "ai-capability"] as const,
|
||||
};
|
||||
@@ -594,6 +594,30 @@ export interface DocumentUsageDto {
|
||||
};
|
||||
}
|
||||
|
||||
/** Where a document read egresses, vendor-blind. */
|
||||
export type DocumentEgressClass = "local" | "external";
|
||||
|
||||
/**
|
||||
* The document-scoped AI capability probe response
|
||||
* (`GET /api/documents/inbound/capability`). Resolved over the DOCUMENT
|
||||
* provider order (local-first, codex last), so `mode` / `pdfSupported` /
|
||||
* `egress` match exactly what the document AI routes will do. `egress` drives
|
||||
* the vault's per-egress "this leaves your machine to a third-party AI" notice.
|
||||
*/
|
||||
export interface DocumentAiCapabilityDto {
|
||||
available: boolean;
|
||||
mode: "vision" | "text" | null;
|
||||
reason: "no-provider" | "enable-local-ocr" | null;
|
||||
pdfSupported: boolean;
|
||||
/**
|
||||
* Where a document read will egress with the current provider order:
|
||||
* - "local": stays on the operator's machine (self-hosted model).
|
||||
* - "external": leaves the machine to a third-party AI service.
|
||||
* - null: no read is available (see `reason`).
|
||||
*/
|
||||
egress: DocumentEgressClass | null;
|
||||
}
|
||||
|
||||
// ─── AI assist / summary / index (Document vault P2) ────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user