mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
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:
1 parent
3d039d5220
commit
028b0d8685
12 files changed
+304
-29
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(() => {});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user