feat(labs): unify the add action into a scan-or-manual choice

Replace the separate, capability-gated scan button with a single Add
menu: "Scan a document" (OCR) or "Add a value manually". The scan entry
appears only when the capability probe reports a usable mode, and routes
to native vision or local in-browser OCR (tesseract.js, lazy-loaded,
deu+eng, images only) per the resolved mode. Add the local-OCR opt-in
toggle to the Labs settings page with honest "less accurate — review
every value" copy.

Surface range confidence prominently on the OCR review screen for new
numeric biomarkers, where the extracted range becomes the catalog range:
flag low range-confidence or a missing range so the user verifies it.
This commit is contained in:
Marc-André Bombeck committed 2026-06-20 16:50:16 +02:00
1 parent 3d039d5220
commit 028b0d8685
12 files changed
+304 -29

No files matched your search

+10
View File
@@ -6489,6 +6489,10 @@
"deleteError": "Wert konnte nicht gelöscht werden",
"loadError": "Laborwerte konnten nicht geladen werden",
"customize": "Laborwerte anpassen",
"localOcr": {
"heading": "Scans auf diesem Gerät lesen",
"description": "Ermöglicht das Scannen eines Laborbefunds, auch wenn dein KI-Anbieter keine Bilder lesen kann: Das Foto wird auf deinem Gerät gelesen und nur der erkannte Text an deinen Anbieter gesendet. Weniger genau – prüfe jeden Wert vor dem Speichern."
},
"sort": {
"heading": "Sortierung",
"label": "Reihenfolge der Werte",
@@ -6640,10 +6644,16 @@
"showingLatestOf": "Die neuesten {shown} von {total} Messwerten werden angezeigt",
"ocr": {
"scanButton": "Befund scannen",
"addScan": "Dokument scannen",
"addManual": "Wert manuell hinzufügen",
"uploadHint": "Foto oder PDF eines Laborbefunds hochladen",
"acceptWithPdf": "JPEG, PNG, WebP oder PDF",
"acceptImageOnly": "JPEG, PNG oder WebP",
"extracting": "Befund wird gelesen…",
"readingOnDevice": "Wird auf deinem Gerät gelesen…",
"localModeHint": "Wird auf deinem Gerät gelesen und als Text gesendet. Weniger genau – prüfe jeden Wert.",
"rangeFromScan": "Referenzbereich aus dem Scan übernommen",
"rangeVerify": "Referenzbereich prüfen",
"reviewTitle": "Vor dem Speichern prüfen",
"reviewHint": "Prüfe jeden Wert und speichere die gewünschten Einträge.",
"foundCount": "{count} Werte gefunden",
+10
View File
@@ -6489,6 +6489,10 @@
"deleteError": "Could not delete the result",
"loadError": "Could not load lab results",
"customize": "Customize labs",
"localOcr": {
"heading": "Read scans on this device",
"description": "Lets you scan a lab report even when your AI provider can't read images: the photo is read on your device and only the extracted text is sent to your provider. Less accurate — review every value before saving."
},
"sort": {
"heading": "Sort order",
"label": "Order of values",
@@ -6640,10 +6644,16 @@
"showingLatestOf": "Showing the latest {shown} of {total} readings",
"ocr": {
"scanButton": "Scan a report",
"addScan": "Scan a document",
"addManual": "Add a value manually",
"uploadHint": "Upload a photo or PDF of a lab report",
"acceptWithPdf": "JPEG, PNG, WebP, or PDF",
"acceptImageOnly": "JPEG, PNG, or WebP",
"extracting": "Reading your report…",
"readingOnDevice": "Reading on your device…",
"localModeHint": "Read on your device, then sent as text. Less accurate — review every value.",
"rangeFromScan": "Reference range taken from the scan",
"rangeVerify": "Verify the reference range",
"reviewTitle": "Review before saving",
"reviewHint": "Check each reading, then save the ones you want to keep.",
"foundCount": "{count} readings found",
+10
View File
@@ -6489,6 +6489,10 @@
"deleteError": "No se pudo eliminar el resultado",
"loadError": "No se pudieron cargar los resultados",
"customize": "Personalizar laboratorio",
"localOcr": {
"heading": "Leer escaneos en este dispositivo",
"description": "Permite escanear un informe de laboratorio aunque tu proveedor de IA no pueda leer imágenes: la foto se lee en tu dispositivo y solo se envía el texto extraído a tu proveedor. Menos preciso: revisa cada valor antes de guardar."
},
"sort": {
"heading": "Orden",
"label": "Orden de los valores",
@@ -6640,10 +6644,16 @@
"showingLatestOf": "Mostrando los {shown} resultados más recientes de {total}",
"ocr": {
"scanButton": "Escanear un informe",
"addScan": "Escanear un documento",
"addManual": "Añadir un valor manualmente",
"uploadHint": "Sube una foto o un PDF de un informe de laboratorio",
"acceptWithPdf": "JPEG, PNG, WebP o PDF",
"acceptImageOnly": "JPEG, PNG o WebP",
"extracting": "Leyendo tu informe…",
"readingOnDevice": "Leyendo en tu dispositivo…",
"localModeHint": "Se lee en tu dispositivo y se envía como texto. Menos preciso: revisa cada valor.",
"rangeFromScan": "Intervalo de referencia tomado del escaneo",
"rangeVerify": "Verifica el intervalo de referencia",
"reviewTitle": "Revisar antes de guardar",
"reviewHint": "Revisa cada valor y guarda los que quieras conservar.",
"foundCount": "{count} valores encontrados",
+10
View File
@@ -6489,6 +6489,10 @@
"deleteError": "Impossible de supprimer le résultat",
"loadError": "Impossible de charger les résultats",
"customize": "Personnaliser les analyses",
"localOcr": {
"heading": "Lire les scans sur cet appareil",
"description": "Permet de scanner un compte rendu de laboratoire même si votre fournisseur d'IA ne peut pas lire les images : la photo est lue sur votre appareil et seul le texte extrait est envoyé à votre fournisseur. Moins précis — vérifiez chaque valeur avant d'enregistrer."
},
"sort": {
"heading": "Tri",
"label": "Ordre des valeurs",
@@ -6640,10 +6644,16 @@
"showingLatestOf": "Affichage des {shown} derniers résultats sur {total}",
"ocr": {
"scanButton": "Scanner un compte rendu",
"addScan": "Scanner un document",
"addManual": "Ajouter une valeur manuellement",
"uploadHint": "Importez une photo ou un PDF d'un compte rendu de laboratoire",
"acceptWithPdf": "JPEG, PNG, WebP ou PDF",
"acceptImageOnly": "JPEG, PNG ou WebP",
"extracting": "Lecture de votre compte rendu…",
"readingOnDevice": "Lecture sur votre appareil…",
"localModeHint": "Lu sur votre appareil, puis envoyé sous forme de texte. Moins précis — vérifiez chaque valeur.",
"rangeFromScan": "Valeurs de référence issues du scan",
"rangeVerify": "Vérifiez les valeurs de référence",
"reviewTitle": "Vérifier avant d'enregistrer",
"reviewHint": "Vérifiez chaque valeur, puis enregistrez celles à conserver.",
"foundCount": "{count} valeurs trouvées",
+10
View File
@@ -6489,6 +6489,10 @@
"deleteError": "Impossibile eliminare il risultato",
"loadError": "Impossibile caricare i risultati",
"customize": "Personalizza esami",
"localOcr": {
"heading": "Leggi le scansioni su questo dispositivo",
"description": "Consente di scansionare un referto di laboratorio anche se il tuo provider di IA non può leggere le immagini: la foto viene letta sul tuo dispositivo e al provider viene inviato solo il testo estratto. Meno preciso: controlla ogni valore prima di salvare."
},
"sort": {
"heading": "Ordinamento",
"label": "Ordine dei valori",
@@ -6640,10 +6644,16 @@
"showingLatestOf": "Visualizzazione degli ultimi {shown} risultati su {total}",
"ocr": {
"scanButton": "Scansiona un referto",
"addScan": "Scansiona un documento",
"addManual": "Aggiungi un valore manualmente",
"uploadHint": "Carica una foto o un PDF di un referto di laboratorio",
"acceptWithPdf": "JPEG, PNG, WebP o PDF",
"acceptImageOnly": "JPEG, PNG o WebP",
"extracting": "Lettura del referto…",
"readingOnDevice": "Lettura sul tuo dispositivo…",
"localModeHint": "Letto sul tuo dispositivo e inviato come testo. Meno preciso: controlla ogni valore.",
"rangeFromScan": "Intervallo di riferimento ricavato dalla scansione",
"rangeVerify": "Verifica l'intervallo di riferimento",
"reviewTitle": "Controlla prima di salvare",
"reviewHint": "Controlla ogni valore, poi salva quelli da conservare.",
"foundCount": "{count} valori trovati",
+10
View File
@@ -6489,6 +6489,10 @@
"deleteError": "Nie udało się usunąć wyniku",
"loadError": "Nie udało się wczytać wyników",
"customize": "Dostosuj wyniki badań",
"localOcr": {
"heading": "Odczytuj skany na tym urządzeniu",
"description": "Pozwala zeskanować wynik laboratoryjny nawet wtedy, gdy Twój dostawca AI nie potrafi odczytywać obrazów: zdjęcie jest odczytywane na Twoim urządzeniu, a do dostawcy wysyłany jest tylko wyodrębniony tekst. Mniej dokładne — sprawdź każdą wartość przed zapisaniem."
},
"sort": {
"heading": "Sortowanie",
"label": "Kolejność wartości",
@@ -6640,10 +6644,16 @@
"showingLatestOf": "Wyświetlanie najnowszych {shown} z {total} wyników",
"ocr": {
"scanButton": "Zeskanuj wynik",
"addScan": "Zeskanuj dokument",
"addManual": "Dodaj wartość ręcznie",
"uploadHint": "Prześlij zdjęcie lub PDF wyniku laboratoryjnego",
"acceptWithPdf": "JPEG, PNG, WebP lub PDF",
"acceptImageOnly": "JPEG, PNG lub WebP",
"extracting": "Odczytywanie wyniku…",
"readingOnDevice": "Odczytywanie na Twoim urządzeniu…",
"localModeHint": "Odczytywane na Twoim urządzeniu i wysyłane jako tekst. Mniej dokładne — sprawdź każdą wartość.",
"rangeFromScan": "Zakres referencyjny pobrany ze skanu",
"rangeVerify": "Zweryfikuj zakres referencyjny",
"reviewTitle": "Sprawdź przed zapisaniem",
"reviewHint": "Sprawdź każdy wynik, a następnie zapisz te, które chcesz zachować.",
"foundCount": "Znaleziono {count} wyników",
+43 -22
View File
@@ -4,13 +4,19 @@ import { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { Loader2, Plus, ScanLine, Wrench } from "lucide-react";
import { Loader2, Pencil, Plus, ScanLine, Wrench } from "lucide-react";
import { LabForm } from "@/components/labs/lab-form";
import { LabList } from "@/components/labs/lab-list";
import { OcrReviewDialog } from "@/components/labs/ocr-review-dialog";
import { useOcrCapability } from "@/components/labs/use-ocr-extract";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { useAuth } from "@/hooks/use-auth";
@@ -73,19 +79,6 @@ export default function LabsPage() {
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
{/* v1.18.9 — "Scan a report" Lab-OCR entry. Shown only when the
capability probe reports a vision-capable provider is configured,
so the surface stays dark for codex-only / text-only-model users. */}
{ocrCapability.data?.available ? (
<Button
variant="outline"
onClick={() => setScanOpen(true)}
className="min-h-11 sm:min-h-9"
>
<ScanLine className="h-4 w-4" />
{t("labs.ocr.scanButton")}
</Button>
) : null}
{/* v1.18.6 (MOD-01) — the wrench is the module's customize entry
point, left of the primary Add and linking to the Labs settings
page (view, sort order, biomarker CRUD + reorder). Mirrors the
@@ -104,13 +97,40 @@ export default function LabsPage() {
<Wrench className="h-4 w-4" aria-hidden="true" />
</Link>
</Button>
<Button
onClick={() => setDialogOpen(true)}
className="min-h-11 sm:min-h-9"
>
<Plus className="h-4 w-4" />
{t("common.add")}
</Button>
{/* v1.18.10 — the "Add" action is a CHOICE when scanning is
available: scan a document (OCR) or add a value by hand. The scan
option only appears when the capability probe reports a usable
mode (vision, or local OCR opted-in for text-only providers), so
the surface stays simple for everyone else. When scanning is
unavailable, Add opens the manual form directly. */}
{ocrCapability.data?.available ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="min-h-11 sm:min-h-9">
<Plus className="h-4 w-4" />
{t("common.add")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => setScanOpen(true)}>
<ScanLine className="h-4 w-4" aria-hidden="true" />
{t("labs.ocr.addScan")}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setDialogOpen(true)}>
<Pencil className="h-4 w-4" aria-hidden="true" />
{t("labs.ocr.addManual")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<Button
onClick={() => setDialogOpen(true)}
className="min-h-11 sm:min-h-9"
>
<Plus className="h-4 w-4" />
{t("common.add")}
</Button>
)}
</div>
</div>
@@ -135,10 +155,11 @@ export default function LabsPage() {
/>
</ResponsiveSheet>
{ocrCapability.data?.available ? (
{ocrCapability.data?.available && ocrCapability.data.mode ? (
<OcrReviewDialog
open={scanOpen}
onOpenChange={setScanOpen}
mode={ocrCapability.data.mode}
pdfSupported={ocrCapability.data.pdfSupported}
onCommitted={() => {
queryClient.invalidateQueries({
+26
View File
@@ -19,6 +19,7 @@ import {
FlaskConical,
LayoutGrid,
ListOrdered,
ScanText,
} from "lucide-react";
import { SettingsCard } from "@/components/settings/settings-card";
@@ -26,6 +27,7 @@ import { SettingsCardHeader } from "@/components/settings/_card-header";
import { NativeSelect } from "@/components/ui/native-select";
import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import { apiGet } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -42,11 +44,18 @@ import {
import { BiomarkerManager } from "./biomarker-manager";
import type { BiomarkerDto, BiomarkerListResponse } from "./types";
import { useLabsLocalOcr, useUpdateLabsLocalOcr } from "./use-ocr-extract";
export function LabsSettings() {
const { t } = useTranslations();
const { prefs, setView, setOrder, setSortDir } = useModuleListPrefs("labs");
// v1.18.10 — local (in-browser) OCR opt-in. Lets a user whose AI provider
// can't read images still scan a report: the image is OCR'd on-device and
// only the extracted text is sent to the text-only provider.
const localOcr = useLabsLocalOcr();
const updateLocalOcr = useUpdateLabsLocalOcr();
const { data, isLoading } = useQuery({
queryKey: queryKeys.biomarkers(),
queryFn: () => apiGet<BiomarkerListResponse>("/api/biomarkers"),
@@ -120,6 +129,23 @@ export function LabsSettings() {
</SettingsCard>
) : null}
{/* v1.18.10 — local (in-browser) OCR opt-in for lab scans. */}
<SettingsCard id="labs-local-ocr" className="scroll-mt-28 space-y-4">
<SettingsCardHeader
icon={ScanText}
title={t("labs.localOcr.heading")}
description={t("labs.localOcr.description")}
status={
<Switch
checked={localOcr.data?.labsLocalOcrEnabled ?? false}
disabled={localOcr.isLoading || updateLocalOcr.isPending}
onCheckedChange={(checked) => updateLocalOcr.mutate(checked)}
aria-label={t("labs.localOcr.heading")}
/>
}
/>
</SettingsCard>
{/* Biomarker catalog — define / edit / delete. */}
<SettingsCard id="labs-biomarkers" className="scroll-mt-28 space-y-4">
<SettingsCardHeader
+29 -4
View File
@@ -24,12 +24,17 @@ import { seedReviewRows, type OcrReviewRow } from "./ocr-review-types";
import {
useOcrCommit,
useOcrExtract,
useOcrTextExtract,
type OcrCommitRowInput,
} from "./use-ocr-extract";
type Stage = "pick" | "review";
/** How the scan runs: native vision vs in-browser (local) OCR. */
export type OcrMode = "vision" | "text";
const ACCEPT = "image/jpeg,image/png,image/webp,application/pdf";
const ACCEPT_IMAGE_ONLY = "image/jpeg,image/png,image/webp";
/** Map a confirmed review row to the commit payload, or null when invalid. */
function toCommitRow(row: OcrReviewRow): OcrCommitRowInput | null {
@@ -88,11 +93,14 @@ function extractErrorMessage(err: unknown, t: (key: string) => string): string {
export function OcrReviewDialog({
open,
onOpenChange,
mode,
pdfSupported,
onCommitted,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Native vision vs in-browser local OCR. Text mode is image-only. */
mode: OcrMode;
pdfSupported: boolean;
onCommitted: () => void;
}) {
@@ -101,8 +109,14 @@ export function OcrReviewDialog({
const [stage, setStage] = useState<Stage>("pick");
const [rows, setRows] = useState<OcrReviewRow[]>([]);
const extract = useOcrExtract();
// Text mode OCR's the image in the browser then POSTs the text; vision mode
// uploads the image. Both resolve with the same proposed-rows DTO.
const visionExtract = useOcrExtract();
const textExtract = useOcrTextExtract();
const extract = mode === "text" ? textExtract : visionExtract;
const commit = useOcrCommit();
// Text mode never accepts PDFs (tesseract.js can't read them).
const allowPdf = mode === "vision" && pdfSupported;
const confirmedCount = useMemo(
() => rows.filter((r) => r.confirmed).length,
@@ -197,7 +211,7 @@ export function OcrReviewDialog({
<input
ref={inputRef}
type="file"
accept={pdfSupported ? ACCEPT : "image/jpeg,image/png,image/webp"}
accept={allowPdf ? ACCEPT : ACCEPT_IMAGE_ONLY}
className="sr-only"
onChange={(e) => {
const file = e.target.files?.[0];
@@ -216,7 +230,12 @@ export function OcrReviewDialog({
<>
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
<span className="text-muted-foreground text-sm">
{t("labs.ocr.extracting")}
{/* Text mode runs OCR on-device first, then structures it —
the first run also downloads the OCR engine, so the copy
sets the "this is reading on your device" expectation. */}
{mode === "text"
? t("labs.ocr.readingOnDevice")
: t("labs.ocr.extracting")}
</span>
</>
) : (
@@ -230,10 +249,16 @@ export function OcrReviewDialog({
</span>
<span className="text-muted-foreground flex items-center gap-1.5 text-xs">
<Upload className="h-3.5 w-3.5" aria-hidden />
{pdfSupported
{allowPdf
? t("labs.ocr.acceptWithPdf")
: t("labs.ocr.acceptImageOnly")}
</span>
{/* Honest accuracy caveat for the local-OCR fallback. */}
{mode === "text" ? (
<span className="text-muted-foreground max-w-xs text-xs">
{t("labs.ocr.localModeHint")}
</span>
) : null}
</>
)}
</button>
+32 -1
View File
@@ -15,7 +15,13 @@
*/
import { useId } from "react";
import { AlertCircle, FilePlus2, Link2, TriangleAlert } from "lucide-react";
import {
AlertCircle,
FilePlus2,
Link2,
Ruler,
TriangleAlert,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
@@ -43,6 +49,16 @@ export function OcrRowEditor({
!isQualitative && row.confidence.value < CONFIDENCE_THRESHOLD;
const valueUnreadable = !isQualitative && row.value === null;
// v1.18.10 (#5) — a NEW numeric biomarker mints its catalog reference range
// from THIS extracted row. The range is therefore load-bearing and worth a
// second look. Surface the range confidence prominently for new numeric
// markers, and call it out explicitly when the model was unsure (or set no
// bounds at all) so the user verifies the range it will be judged against.
const isNewNumericMarker = !isQualitative && row.biomarkerMatch === "new";
const lowRangeConfidence = row.confidence.range < CONFIDENCE_THRESHOLD;
const hasBounds = row.referenceLow !== null || row.referenceHigh !== null;
const flagRange = isNewNumericMarker && (lowRangeConfidence || !hasBounds);
return (
<div className="space-y-3 rounded-lg border p-3">
<div className="flex items-start gap-3">
@@ -156,6 +172,21 @@ export function OcrRowEditor({
</div>
</div>
{/* v1.18.10 (#5) — for a NEW numeric biomarker the reference range below
becomes the marker's catalog range. Make that consequence visible, and
flag low range-confidence / a missing range so the user verifies it
before saving. A calm `secondary` badge, not an alarm. */}
{isNewNumericMarker ? (
<div className="flex flex-wrap items-center gap-1.5">
<Badge variant={flagRange ? "secondary" : "outline"}>
<Ruler aria-hidden />
{flagRange
? t("labs.ocr.rangeVerify")
: t("labs.ocr.rangeFromScan")}
</Badge>
</div>
) : null}
{!isQualitative ? (
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
+52 -2
View File
@@ -13,7 +13,8 @@
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiFetch, apiPost } from "@/lib/api/api-fetch";
import { apiFetch, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
import { ocrImageToText } from "@/lib/labs/local-ocr";
import { queryKeys } from "@/lib/query-keys";
import type {
OcrCapabilityDto,
@@ -49,7 +50,7 @@ export function useOcrCapability(enabled: boolean) {
});
}
/** Upload + extract. Resolves with the proposed rows for the review screen. */
/** Upload + extract (VISION mode). Resolves with the proposed review rows. */
export function useOcrExtract() {
return useMutation<OcrExtractResponseDto, Error, File>({
mutationFn: (file: File) => {
@@ -65,6 +66,55 @@ export function useOcrExtract() {
});
}
/**
* TEXT mode (v1.18.10) — OCR the image IN THE BROWSER (tesseract.js), then POST
* only the extracted text. The raw image never leaves the device. Resolves with
* the same proposed-rows DTO the vision path returns, so the review/commit flow
* is shared verbatim.
*/
export function useOcrTextExtract() {
return useMutation<OcrExtractResponseDto, Error, File>({
mutationFn: async (file: File) => {
const text = await ocrImageToText(file);
return apiPost<OcrExtractResponseDto>("/api/labs/ocr/extract", {
mode: "text",
text,
});
},
});
}
/** The local-OCR opt-in preference (read + toggle). */
export interface LabsLocalOcrPref {
labsLocalOcrEnabled: boolean;
}
/** Read the current local-OCR opt-in flag. */
export function useLabsLocalOcr(enabled = true) {
return useQuery<LabsLocalOcrPref>({
queryKey: queryKeys.labsLocalOcr(),
queryFn: () => apiGet<LabsLocalOcrPref>("/api/auth/me/labs-local-ocr"),
enabled,
staleTime: 60_000,
});
}
/** Toggle the local-OCR opt-in; invalidates the flag + the capability probe. */
export function useUpdateLabsLocalOcr() {
const queryClient = useQueryClient();
return useMutation<LabsLocalOcrPref, Error, boolean>({
mutationFn: (labsLocalOcrEnabled: boolean) =>
apiPatch<LabsLocalOcrPref>("/api/auth/me/labs-local-ocr", {
labsLocalOcrEnabled,
}),
onSuccess: (data) => {
queryClient.setQueryData(queryKeys.labsLocalOcr(), data);
// The toggle changes whether text-mode scanning is available.
queryClient.invalidateQueries({ queryKey: queryKeys.ocrCapability() });
},
});
}
/** Commit the confirmed rows and invalidate the dependent reads. */
export function useOcrCommit() {
const queryClient = useQueryClient();
+62
View File
@@ -0,0 +1,62 @@
/**
* v1.18.10 — in-browser (local) OCR for lab-report scans.
*
* Used by the TEXT-mode Lab-OCR path: a user whose AI provider cannot read
* images (ChatGPT-OAuth/Codex, a text-only model) OCR's the photo HERE, in the
* browser, and only the extracted TEXT is POSTed to `/api/labs/ocr/extract`.
* The raw image never leaves the device.
*
* tesseract.js (WASM) is LAZY-loaded via `await import(...)` so the multi-MB
* worker + WASM core + `deu+eng` traineddata land in the browser only when the
* user actually scans in text mode — never at app boot, and never for the
* higher-accuracy vision path. This mirrors the chart-defer discipline.
*
* Images only — tesseract.js cannot read PDFs, matching the vision path's
* image-only constraint for non-Anthropic providers.
*/
/** German + English: a German lab sheet needs `deu` for ä/ö/ü/ß; `eng` covers
* the many English analyte names and units that appear on the same sheet. */
const OCR_LANGS = "deu+eng";
export class LocalOcrError extends Error {
constructor(message: string) {
super(message);
this.name = "LocalOcrError";
}
}
/**
* OCR an image file entirely in the browser and return the extracted plain
* text. Throws `LocalOcrError` when the engine fails to load or recognise.
*
* A fresh worker is created and terminated per call. The traineddata is
* browser-cached after the first run, so the cost is paid once.
*/
export async function ocrImageToText(file: File): Promise<string> {
// Lazy import — keeps tesseract.js out of the main bundle.
let createWorker: typeof import("tesseract.js").createWorker;
try {
({ createWorker } = await import("tesseract.js"));
} catch {
throw new LocalOcrError("Failed to load the local OCR engine");
}
const worker = await createWorker(OCR_LANGS);
try {
const {
data: { text },
} = await worker.recognize(file);
const trimmed = text.trim();
if (!trimmed) {
throw new LocalOcrError("No text could be read from the image");
}
return trimmed;
} catch (err) {
if (err instanceof LocalOcrError) throw err;
throw new LocalOcrError("Local OCR failed to read the image");
} finally {
// Free the WASM worker; the traineddata stays browser-cached.
await worker.terminate().catch(() => {});
}
}