mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
Reading a stored document (suggest, summary, extract, index, chat, reindex), scanning a lab report and turning medication text into a schedule now answer under documentAi, labsOcr and medicationExtract. The operator's master switch and the new reading-documents switch stop the work and the egress; a module off for the record, a delegate inside somebody else's record, and a missing receipt refuse with the capability envelope. Existing no-provider codes keep their status. The document pick and the labs pick re-check the capability for the provider they picked, immediately before anything is sent, so a job enqueued before a switch flipped finds no provider and falls back to the provider-free text layer. Consent follows the document rule for all three: any provider that leaves the machine needs an ai_extraction or ai_full receipt, answered for the provider actually picked. The fenced Coach chat and attachments answer under coach and documentAi. Uploads are always stored and indexed; the background summary is only enqueued while documentAi is open. The capability probes report the capability beside the provider answer and keep a read offered when only the receipt is missing. medications/extract keeps its 503 for no provider and adds ai.provider.none, and its budget refusal gains the coach.budget.exceeded code.
259 lines
8.9 KiB
TypeScript
259 lines
8.9 KiB
TypeScript
/**
|
|
* 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. 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 {
|
|
resolveTextProvider,
|
|
resolveVisionProvider,
|
|
type ChainReorder,
|
|
type VisionProviderPick,
|
|
} from "@/lib/labs/ocr-capability";
|
|
import type {
|
|
DocumentAiCapabilityDto,
|
|
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
|
|
* 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),
|
|
);
|
|
};
|
|
|
|
/**
|
|
* 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 type DocumentProviderPick<T> = T & {
|
|
withheld: AiUnavailableError | null;
|
|
};
|
|
|
|
async function atTheWire<T extends { pick: { providerType: string } | null }>(
|
|
userId: string,
|
|
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 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 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));
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
* `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 }, 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
|
|
// whenever the picked provider natively supports them (Anthropic) OR the
|
|
// server-side rasterizer is available (every other vision provider reads a
|
|
// PDF via rasterized page images), so the UI offers a PDF read on codex too.
|
|
if (pick) {
|
|
return {
|
|
available: true,
|
|
mode: "vision",
|
|
reason: null,
|
|
pdfSupported: pick.pdfSupported || RASTERIZATION_AVAILABLE,
|
|
egress: documentEgressClass(pick.providerType),
|
|
ai,
|
|
};
|
|
}
|
|
|
|
// 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,
|
|
ai,
|
|
};
|
|
}
|
|
|
|
// 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),
|
|
ai,
|
|
};
|
|
}
|
|
|
|
// 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,
|
|
ai,
|
|
};
|
|
}
|