Files
HealthLog/src/lib/labs/ocr-capability.ts
T
Marc-André Bombeck 3d039d5220 feat(labs): add local-OCR text mode for image-blind AI providers
Add an opt-in in-browser OCR path so a user whose provider cannot read
images (ChatGPT-OAuth/Codex, a text-only model) can still scan a lab
report. The capability probe gains a `mode` field: `vision` when the
provider reads the image directly, `text` when local OCR is enabled and
any provider is configured, or null. The extract route grows a text
branch that accepts the in-browser-OCR'd text as JSON and structures it
through the existing schema + review/commit flow; the raw image never
reaches the server in text mode. New `labsLocalOcrEnabled` user column
(migration 0184, default off) gates it, with a dedicated PATCH endpoint.
2026-06-20 16:50:06 +02:00

183 lines
6.1 KiB
TypeScript

/**
* v1.18.9 — resolve whether a user's configured AI provider can read images,
* and (for the extract route) pick the first vision-capable provider instance.
*
* Capability is a function of (providerType, model). The user's `aiModel`
* applies to a per-user provider entry; the operator's `adminAiModel` applies
* to the `admin-openai` fallback entry. We resolve the chain instances via
* `resolveProviderChain` (so credentials + Codex-token refresh are handled in
* one place) and pair each surviving entry with the model that drives its
* vision allowlist.
*/
import type { ProviderChainResolved } from "@/lib/ai/provider-runner";
import { resolveProvider, resolveProviderChain } from "@/lib/ai/provider";
import { prisma } from "@/lib/db";
import {
supportsPdfForProvider,
supportsVisionForConfig,
type VisionProviderType,
} from "@/lib/ai/vision-capability";
import type { OcrCapabilityDto } from "@/lib/validations/labs-ocr";
interface ModelContext {
/** The user's selected model (per-user provider entries). */
userModel: string | null;
/** The operator's admin-key model (the `admin-openai` chain entry). */
adminModel: string | null;
}
/** The model that drives a chain entry's vision allowlist. */
function modelForEntry(
providerType: ProviderChainResolved["providerType"],
ctx: ModelContext,
): string | null {
return providerType === "admin-openai" ? ctx.adminModel : ctx.userModel;
}
/** A vision-capable provider pick: its instance, logical tag, and model. */
export interface VisionProviderPick {
/** The full resolved chain (for `assertConsentForChain`). */
chain: ProviderChainResolved[];
/** The first vision-capable entry, or null when none can read images. */
pick: {
entry: ProviderChainResolved;
providerType: VisionProviderType;
pdfSupported: boolean;
} | null;
/** Whether the user opted into local (in-browser) OCR for text-only providers. */
localOcrEnabled: boolean;
}
/**
* Resolve the user's provider chain and find the first entry that can read
* images. Returns the whole chain (the consent gate needs it) plus the chosen
* vision entry (or null). Mirrors the Coach's chain-then-legacy fallback so a
* user served only by the operator's admin key is still covered.
*/
export async function resolveVisionProvider(
userId: string,
): Promise<VisionProviderPick> {
const userRow = await prisma.user.findUnique({
where: { id: userId },
select: { aiModel: true, labsLocalOcrEnabled: true },
});
const settings = await prisma.appSettings.findUnique({
where: { id: "singleton" },
select: { adminAiModel: true },
});
const ctx: ModelContext = {
userModel: userRow?.aiModel ?? null,
adminModel: settings?.adminAiModel ?? null,
};
const localOcrEnabled = userRow?.labsLocalOcrEnabled ?? false;
const chain = await resolveProviderChain(userId);
if (chain.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 });
}
}
for (const entry of chain) {
const providerType = entry.providerType as VisionProviderType;
const model = modelForEntry(entry.providerType, ctx);
if (supportsVisionForConfig(providerType, model)) {
return {
chain,
localOcrEnabled,
pick: {
entry,
providerType,
pdfSupported: supportsPdfForProvider(providerType),
},
};
}
}
return { chain, localOcrEnabled, pick: null };
}
/**
* Resolve the provider chain for a TEXT-mode (local-OCR) extract. There is no
* vision requirement here — ANY configured provider can structure OCR'd text —
* 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<{
chain: ProviderChainResolved[];
pick: { entry: ProviderChainResolved; providerType: string } | null;
}> {
const chain = await resolveProviderChain(userId);
if (chain.length === 0) {
const legacy = await resolveProvider(userId);
if (legacy.type !== "none") {
chain.push({ providerType: "admin-openai", instance: legacy });
}
}
const entry = chain[0];
return {
chain,
pick: entry ? { entry, providerType: entry.providerType } : null,
};
}
/**
* The cheap capability DTO for the probe endpoint: whether scanning is
* available + why not + whether PDFs are accepted. Built from the same
* resolution the extract route uses, so the UI never shows an entry the
* extract route would 422.
*/
export async function resolveOcrCapability(
userId: string,
): Promise<OcrCapabilityDto> {
const { chain, pick, localOcrEnabled } = await resolveVisionProvider(userId);
// Prefer the native vision path whenever it is available — it is more
// accurate and the client does not download the OCR WASM.
if (pick) {
return {
available: true,
mode: "vision",
reason: null,
pdfSupported: pick.pdfSupported,
};
}
// No vision pick. Nothing configured at all → no-provider, regardless of the
// toggle (there is no provider to structure the OCR text).
if (chain.length === 0) {
return {
available: false,
mode: null,
reason: "no-provider",
pdfSupported: false,
};
}
// A provider IS configured but it can't read images. Local OCR is the
// text-only fallback: in-browser OCR → text → the configured provider. It is
// available only when the user opted in.
if (localOcrEnabled) {
// Text mode is image-only (tesseract.js can't read PDFs).
return {
available: true,
mode: "text",
reason: null,
pdfSupported: false,
};
}
// A text-only provider is configured but the user has not enabled local OCR —
// surface the actionable reason so the UI can point at the toggle.
return {
available: false,
mode: null,
reason: "enable-local-ocr",
pdfSupported: false,
};
}