mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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.
183 lines
6.1 KiB
TypeScript
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,
|
|
};
|
|
}
|