feat(labs-ocr): read PDFs on the configured provider, retire the separate scan provider

Lab-report OCR now rasterizes a PDF to page images for any non-Anthropic
vision provider, exactly as the document vault already does — Anthropic keeps
its native PDF block, every other vision provider (codex/OAuth, OpenAI, local)
reads the rendered pages. The capability probe reports `pdfSupported` whenever
the picked provider reads PDFs natively or the server-side rasterizer is
available, so the "scan a PDF" affordance shows for a codex provider too.

Drop the dedicated document-scan (Lab-OCR) provider: labs OCR always uses the
main configured provider chain. Removes the settings card, the `aiOcr*`
resolution branch, the GET/PATCH handler fields, the OpenAPI schema fields, the
i18n keys across all locales, and the five backing `User` columns (migration
0234). The on-device tesseract fallback is untouched.

Dashboard hero: stack the overdue-dose action directly beneath the verdict
text (tight gap, left-aligned) on every viewport instead of pushing it to the
far container edge on desktop.
This commit is contained in:
Marc-André Bombeck committed 2026-07-09 23:29:06 +02:00
1 parent c8e67042a8
commit 46f8c71ee5
25 files changed
+191 -775

No files matched your search

@@ -399,17 +399,18 @@ export function DashboardHero({
>
{welcomeText}
</p>
{/* Text left, action right, one row — the PageHeader/TileHeader
inline-action pattern. The sentence takes the available width and
wraps internally (`min-w-0 flex-1`); the button never wraps to its
own line (`shrink-0`), so the action stays beside the text. */}
<div className="flex items-start justify-between gap-3">
{/* Sentence then action, stacked — the overdue-dose button sits
DIRECTLY beneath the verdict text (tight `gap-1.5`), left-aligned
and intrinsic width (`self-start w-auto`), on every viewport. An
earlier `justify-between` shoved the button to the far container
edge on desktop, stranding it from the sentence it acts on. */}
<div className="flex flex-col items-start gap-1.5">
{sentenceHref ? (
<Link
href={sentenceHref}
data-slot="dashboard-hero-verdict"
data-verdict-variant={verdict.variant}
className="text-foreground/90 hover:text-foreground min-w-0 flex-1 text-base font-medium transition-colors"
className="text-foreground/90 hover:text-foreground min-w-0 text-base font-medium transition-colors"
>
{sentence}
</Link>
@@ -417,7 +418,7 @@ export function DashboardHero({
<p
data-slot="dashboard-hero-verdict"
data-verdict-variant={verdict.variant}
className="text-foreground/90 min-w-0 flex-1 text-base font-medium"
className="text-foreground/90 min-w-0 text-base font-medium"
>
{sentence}
</p>
@@ -428,7 +429,7 @@ export function DashboardHero({
asChild
size="default"
variant="outline"
className="min-h-11 shrink-0 sm:min-h-9"
className="min-h-11 w-auto self-start sm:min-h-9"
data-slot="dashboard-hero-cta"
>
<Link href={cta.href}>{t(ctaLabelKey)}</Link>
@@ -436,7 +437,7 @@ export function DashboardHero({
) : (
<Button
size="default"
className="min-h-11 shrink-0 sm:min-h-9"
className="min-h-11 w-auto self-start sm:min-h-9"
data-slot="dashboard-hero-cta"
onClick={() => onQuickEntry(cta.target)}
>
@@ -33,7 +33,6 @@ import { AdminOpenAIProviderForm } from "./admin-openai-provider-form";
import { AnthropicProviderForm } from "./anthropic-provider-form";
import { AutoReadCard } from "./auto-read-card";
import { CodexProviderForm } from "./codex-provider-form";
import { DocumentScanCard } from "./document-scan-card";
import { FallbackChainCard } from "./fallback-chain-card";
import { LocalProviderForm } from "./local-provider-form";
import { OpenAIProviderForm } from "./openai-provider-form";
@@ -179,9 +178,6 @@ export function AiInsightsCard({
local/self-hosted backends). */}
<ResponseTimeoutCard userProvider={userProvider} />
{/* v1.22 (#90) — dedicated document-scan (Lab-OCR) provider. */}
<DocumentScanCard userProvider={userProvider} />
{/* Read uploaded vault documents automatically with AI (opt-in). */}
<AutoReadCard />
@@ -1,212 +0,0 @@
"use client";
/* ────────────────────────────────────────────────────────────────
* v1.22 (#90) — Document scanning (Lab reports).
*
* Opt-in dedicated provider for the Lab-OCR ingestion path. OFF by default:
* scanning uses the main provider chain. When enabled, document/lab-report
* processing routes to THIS provider/model/key instead — so a user can keep a
* cheap text Coach on one provider and send expensive vision OCR to a
* vision-capable one. Surfaces a hint when the chosen model can't read images.
* ──────────────────────────────────────────────────────────────── */
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Loader2, Save } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import { PasswordInput } from "@/components/ui/password-input";
import { Switch } from "@/components/ui/switch";
import { apiPatch } from "@/lib/api/api-fetch";
import {
supportsVisionForConfig,
type VisionProviderType,
} from "@/lib/ai/vision-capability";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { OCR_PROVIDER_TYPES, type UserAIProvider } from "./shared";
/** Map the OCR provider enum to the vision-capability tag. */
function toVisionType(provider: string): VisionProviderType {
switch (provider) {
case "ANTHROPIC":
return "anthropic";
case "OPENAI":
return "openai";
case "LOCAL":
return "local";
default:
return "none";
}
}
export function DocumentScanCard({
userProvider,
}: {
userProvider: UserAIProvider | null | undefined;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [enabled, setEnabled] = useState(false);
const [provider, setProvider] = useState<string>("OPENAI");
const [model, setModel] = useState("");
const [baseUrl, setBaseUrl] = useState("");
const [apiKey, setApiKey] = useState("");
const [msg, setMsg] = useState<string | null>(null);
const [ok, setOk] = useState(false);
const seededKey =
userProvider != null
? `${userProvider.ocrEnabled}|${userProvider.ocrProvider ?? ""}|${userProvider.ocrModel ?? ""}|${userProvider.ocrBaseUrl ?? ""}`
: null;
const [previousSeed, setPreviousSeed] = useState<string | null>(null);
if (seededKey != null && seededKey !== previousSeed) {
setPreviousSeed(seededKey);
setEnabled(userProvider?.ocrEnabled ?? false);
setProvider(userProvider?.ocrProvider ?? "OPENAI");
setModel(userProvider?.ocrModel ?? "");
setBaseUrl(userProvider?.ocrBaseUrl ?? "");
}
// Vision-capability hint: warn when a model is chosen that can't read images
// (local is trust-by-default; the warning surfaces only for cloud providers).
const visionOk =
!enabled ||
model.trim() === "" ||
supportsVisionForConfig(toVisionType(provider), model.trim());
const saveMutation = useMutation({
mutationFn: async () => {
const body: Record<string, unknown> = {
ocrEnabled: enabled,
ocrProvider: provider,
ocrModel: model.trim() || null,
ocrBaseUrl: provider === "LOCAL" ? baseUrl.trim() || null : null,
};
if (apiKey.trim()) body.ocrKey = apiKey.trim();
await apiPatch("/api/user/ai-provider", body);
},
onSuccess: () => {
setOk(true);
setMsg(t("settings.ai.saved"));
setApiKey("");
queryClient.invalidateQueries({ queryKey: queryKeys.userAiProvider() });
},
onError: (e) => {
setOk(false);
setMsg(e instanceof Error ? e.message : t("settings.ai.errorGeneric"));
},
});
return (
<div className="bg-muted/50 space-y-4 rounded-lg p-4">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-medium">{t("settings.ai.ocrHeading")}</p>
<p className="text-muted-foreground text-xs">
{t("settings.ai.ocrBody")}
</p>
</div>
<Switch
checked={enabled}
onCheckedChange={setEnabled}
aria-label={t("settings.ai.ocrEnableLabel")}
data-testid="ai-ocr-enable"
/>
</div>
{enabled && (
<div className="space-y-4">
<div>
<Label htmlFor="ai-ocr-provider">
{t("settings.ai.ocrProviderLabel")}
</Label>
<NativeSelect
id="ai-ocr-provider"
value={provider}
onChange={(e) => setProvider(e.target.value)}
className="mt-1 sm:max-w-md"
>
{OCR_PROVIDER_TYPES.map((p) => (
<option key={p} value={p}>
{t(`settings.ai.ocrProviderOption.${p}` as const)}
</option>
))}
</NativeSelect>
</div>
<div>
<Label htmlFor="ai-ocr-model">{t("settings.ai.modelLabel")}</Label>
<Input
id="ai-ocr-model"
value={model}
onChange={(e) => setModel(e.target.value)}
placeholder={t("settings.ai.ocrModelPlaceholder")}
className="mt-1"
/>
{!visionOk && (
<p className="text-warning mt-1 text-xs">
{t("settings.ai.ocrVisionHint")}
</p>
)}
</div>
{provider === "LOCAL" && (
<div>
<Label htmlFor="ai-ocr-base-url">
{t("settings.ai.baseUrlLabel")}
</Label>
<Input
id="ai-ocr-base-url"
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
placeholder="http://localhost:11434/v1"
className="mt-1"
/>
</div>
)}
<div>
<Label htmlFor="ai-ocr-key">{t("settings.ai.ocrKeyLabel")}</Label>
<PasswordInput
id="ai-ocr-key"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={
userProvider?.hasOcrKey ? t("settings.ai.savedShort") : ""
}
className="mt-1"
/>
</div>
</div>
)}
<div>
<Button
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => saveMutation.mutate()}
disabled={saveMutation.isPending}
>
{saveMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Save className="h-4 w-4" />
)}
{t("settings.ai.saveCta")}
</Button>
</div>
{msg && (
<p className={`text-xs ${ok ? "text-success" : "text-destructive"}`}>
{msg}
</p>
)}
</div>
);
}
-10
View File
@@ -29,18 +29,8 @@ export interface UserAIProvider {
openaiKeyPreview: string | null;
// v1.22 (#89) — per-user response timeout, in seconds (null = default).
responseTimeoutSeconds: number | null;
// v1.22 (#90) — dedicated document-scan (Lab-OCR) provider config.
ocrEnabled: boolean;
ocrProvider: string | null;
ocrModel: string | null;
ocrBaseUrl: string | null;
hasOcrKey: boolean;
ocrKeyPreview: string | null;
}
/** v1.22 (#90) — providers the dedicated document-scan path accepts. */
export const OCR_PROVIDER_TYPES = ["OPENAI", "ANTHROPIC", "LOCAL"] as const;
/**
* v1.4.16 phase B2 — provider tags exposed to the UI. Mirrors
* `PROVIDER_CHAIN_TYPES` server-side; kept as a const-array here to