mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
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:
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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user