mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
feat(documents): document-scoped provider order and egress consent
Add a document-class provider resolution that demotes external/train-by-default providers below local (local -> BYOK no-train API -> operator key -> codex last) and a pick-based document-egress consent gate that requires an active receipt for any external provider. Coach/insights keep the app-wide cost-first order. Co-Authored-By: Marc-André Bombeck
This commit is contained in:
1 parent
6ad2dc858a
commit
f7ad2339ae
3 files changed
+270
-8
No files matched your search
@@ -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,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<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,
|
||||
});
|
||||
}
|
||||
|
||||
/** 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<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,
|
||||
};
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user