diff --git a/src/lib/ai/consent-guard.ts b/src/lib/ai/consent-guard.ts index 83f39c294..494042b32 100644 --- a/src/lib/ai/consent-guard.ts +++ b/src/lib/ai/consent-guard.ts @@ -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 = 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 { + if (!isExternalDocumentEgress(args.providerType)) return; + if (await hasActiveConsentForSurface(args.userId, args.surface)) return; + throw new ConsentRequiredError(args.surface); +} diff --git a/src/lib/documents/provider-order.ts b/src/lib/documents/provider-order.ts new file mode 100644 index 000000000..6299d4147 --- /dev/null +++ b/src/lib/documents/provider-order.ts @@ -0,0 +1,183 @@ +/** + * 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 type { ProviderChainResolved } from "@/lib/ai/provider-runner"; +import { isExternalDocumentEgress } from "@/lib/ai/consent-guard"; +import { + resolveTextProvider, + resolveVisionProvider, + type ChainReorder, + type VisionProviderPick, +} from "@/lib/labs/ocr-capability"; + +/** + * 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 { + 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, + }); +} + +/** Where a document read egresses, vendor-blind. */ +export type DocumentEgressClass = "local" | "external"; + +/** + * 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"; +} + +/** + * The document-scoped capability probe. Mirrors `resolveOcrCapability` but over + * the document provider order, and adds the `egress` class so the vault UI can + * show the "this leaves your machine to a third-party AI" notice BEFORE a read. + */ +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; +} + +/** + * 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 { + 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, + }; +} diff --git a/src/lib/labs/ocr-capability.ts b/src/lib/labs/ocr-capability.ts index 5991932c2..356290267 100644 --- a/src/lib/labs/ocr-capability.ts +++ b/src/lib/labs/ocr-capability.ts @@ -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 { 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 { - 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.