diff --git a/messages/de.json b/messages/de.json index b00f8c9fb..40d7ddbd8 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6637,7 +6637,44 @@ } }, "noteLoadFailed": "Notiz konnte nicht geladen werden; sie bleibt beim Speichern unverändert.", - "showingLatestOf": "Die neuesten {shown} von {total} Messwerten werden angezeigt" + "showingLatestOf": "Die neuesten {shown} von {total} Messwerten werden angezeigt", + "ocr": { + "scanButton": "Befund scannen", + "uploadHint": "Foto oder PDF eines Laborbefunds hochladen", + "acceptWithPdf": "JPEG, PNG, WebP oder PDF", + "acceptImageOnly": "JPEG, PNG oder WebP", + "extracting": "Befund wird gelesen…", + "reviewTitle": "Vor dem Speichern prüfen", + "reviewHint": "Prüfe jeden Wert und speichere die gewünschten Einträge.", + "foundCount": "{count} Werte gefunden", + "confirmRow": "Diesen Wert übernehmen", + "analyteLabel": "Bezeichnung", + "resultLabel": "Ergebnis", + "valueLabel": "Wert", + "unitLabel": "Einheit", + "dateLabel": "Datum", + "refLowLabel": "Referenz unten", + "refHighLabel": "Referenz oben", + "newBiomarker": "Neuer Biomarker — wird angelegt", + "linksExisting": "Verknüpft mit vorhandenem {name}", + "duplicateWarning": "Ein passender Wert existiert bereits", + "lowConfidence": "Bitte diesen Wert überprüfen", + "valueUnreadable": "Wert unleserlich — bitte manuell eingeben", + "saveSelected": "{count} ausgewählte speichern", + "discardAll": "Alle verwerfen", + "savedToast": "{count} Laborwerte gespeichert", + "noRows": "In diesem Befund konnten keine Werte gefunden werden.", + "nothingToSave": "Wähle mindestens einen Wert zum Speichern aus.", + "saveFailed": "Werte konnten nicht gespeichert werden. Bitte erneut versuchen.", + "providerUnsupported": "Dein KI-Anbieter kann keine Bilder lesen. Konfiguriere ein Modell mit Bildverständnis.", + "consentRequired": "Für das Scannen eines Befunds ist die Einwilligung zur KI-Verarbeitung erforderlich.", + "rateLimited": "Zu viele Scans. Bitte später erneut versuchen.", + "budgetExceeded": "Dein KI-Nutzungsbudget für heute ist erreicht.", + "fileTooLarge": "Datei ist zu groß (max. 12 MB).", + "fileType": "Lade ein JPEG, PNG, WebP oder PDF hoch.", + "extractFailed": "Der Befund konnte nicht gelesen werden. Versuche ein schärferes Foto.", + "pdfNeedsAnthropic": "PDF-Scannen benötigt einen Claude-Bildanbieter; lade stattdessen ein Foto hoch." + } }, "modules": { "cycle": { diff --git a/messages/en.json b/messages/en.json index c8691a6b4..54241ad76 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6637,7 +6637,44 @@ } }, "noteLoadFailed": "Note could not be loaded; it will be kept unchanged when you save.", - "showingLatestOf": "Showing the latest {shown} of {total} readings" + "showingLatestOf": "Showing the latest {shown} of {total} readings", + "ocr": { + "scanButton": "Scan a report", + "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…", + "reviewTitle": "Review before saving", + "reviewHint": "Check each reading, then save the ones you want to keep.", + "foundCount": "{count} readings found", + "confirmRow": "Include this reading", + "analyteLabel": "Test name", + "resultLabel": "Result", + "valueLabel": "Value", + "unitLabel": "Unit", + "dateLabel": "Date", + "refLowLabel": "Reference low", + "refHighLabel": "Reference high", + "newBiomarker": "New biomarker — will be created", + "linksExisting": "Links to existing {name}", + "duplicateWarning": "A matching reading already exists", + "lowConfidence": "Please double-check this value", + "valueUnreadable": "Value unreadable — enter it manually", + "saveSelected": "Save {count} selected", + "discardAll": "Discard all", + "savedToast": "{count} lab results saved", + "noRows": "No readings could be found in this report.", + "nothingToSave": "Select at least one reading to save.", + "saveFailed": "Couldn't save the readings. Please try again.", + "providerUnsupported": "Your AI provider can't read images. Configure a vision-capable model.", + "consentRequired": "AI processing consent is required to scan a report.", + "rateLimited": "Too many scans. Try again later.", + "budgetExceeded": "Your AI usage budget for today is reached.", + "fileTooLarge": "File is too large (max 12 MB).", + "fileType": "Upload a JPEG, PNG, WebP, or PDF.", + "extractFailed": "Couldn't read the report. Try a clearer photo.", + "pdfNeedsAnthropic": "PDF scanning needs a Claude vision provider; upload a photo instead." + } }, "modules": { "cycle": { diff --git a/messages/es.json b/messages/es.json index 1d3941800..4b8561ed3 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6637,7 +6637,44 @@ } }, "noteLoadFailed": "No se pudo cargar la nota; se mantendrá sin cambios al guardar.", - "showingLatestOf": "Mostrando los {shown} resultados más recientes de {total}" + "showingLatestOf": "Mostrando los {shown} resultados más recientes de {total}", + "ocr": { + "scanButton": "Escanear un informe", + "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…", + "reviewTitle": "Revisar antes de guardar", + "reviewHint": "Revisa cada valor y guarda los que quieras conservar.", + "foundCount": "{count} valores encontrados", + "confirmRow": "Incluir este valor", + "analyteLabel": "Nombre de la prueba", + "resultLabel": "Resultado", + "valueLabel": "Valor", + "unitLabel": "Unidad", + "dateLabel": "Fecha", + "refLowLabel": "Referencia inferior", + "refHighLabel": "Referencia superior", + "newBiomarker": "Nuevo biomarcador — se creará", + "linksExisting": "Se vincula a {name} existente", + "duplicateWarning": "Ya existe un valor coincidente", + "lowConfidence": "Verifica este valor", + "valueUnreadable": "Valor ilegible — introdúcelo manualmente", + "saveSelected": "Guardar {count} seleccionados", + "discardAll": "Descartar todo", + "savedToast": "{count} resultados de laboratorio guardados", + "noRows": "No se encontraron valores en este informe.", + "nothingToSave": "Selecciona al menos un valor para guardar.", + "saveFailed": "No se pudieron guardar los valores. Inténtalo de nuevo.", + "providerUnsupported": "Tu proveedor de IA no puede leer imágenes. Configura un modelo con visión.", + "consentRequired": "Se requiere consentimiento de procesamiento de IA para escanear un informe.", + "rateLimited": "Demasiados escaneos. Inténtalo más tarde.", + "budgetExceeded": "Has alcanzado tu presupuesto de uso de IA de hoy.", + "fileTooLarge": "El archivo es demasiado grande (máx. 12 MB).", + "fileType": "Sube un JPEG, PNG, WebP o PDF.", + "extractFailed": "No se pudo leer el informe. Prueba con una foto más nítida.", + "pdfNeedsAnthropic": "El escaneo de PDF necesita un proveedor de visión de Claude; sube una foto en su lugar." + } }, "modules": { "cycle": { diff --git a/messages/fr.json b/messages/fr.json index f68ca0281..71ec8f395 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6637,7 +6637,44 @@ } }, "noteLoadFailed": "La note n’a pas pu être chargée ; elle restera inchangée à l’enregistrement.", - "showingLatestOf": "Affichage des {shown} derniers résultats sur {total}" + "showingLatestOf": "Affichage des {shown} derniers résultats sur {total}", + "ocr": { + "scanButton": "Scanner un compte rendu", + "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…", + "reviewTitle": "Vérifier avant d'enregistrer", + "reviewHint": "Vérifiez chaque valeur, puis enregistrez celles à conserver.", + "foundCount": "{count} valeurs trouvées", + "confirmRow": "Inclure cette valeur", + "analyteLabel": "Nom de l'analyse", + "resultLabel": "Résultat", + "valueLabel": "Valeur", + "unitLabel": "Unité", + "dateLabel": "Date", + "refLowLabel": "Référence basse", + "refHighLabel": "Référence haute", + "newBiomarker": "Nouveau biomarqueur — sera créé", + "linksExisting": "Lié au {name} existant", + "duplicateWarning": "Une valeur correspondante existe déjà", + "lowConfidence": "Veuillez vérifier cette valeur", + "valueUnreadable": "Valeur illisible — saisissez-la manuellement", + "saveSelected": "Enregistrer {count} sélectionnées", + "discardAll": "Tout abandonner", + "savedToast": "{count} résultats de laboratoire enregistrés", + "noRows": "Aucune valeur n'a pu être trouvée dans ce compte rendu.", + "nothingToSave": "Sélectionnez au moins une valeur à enregistrer.", + "saveFailed": "Impossible d'enregistrer les valeurs. Veuillez réessayer.", + "providerUnsupported": "Votre fournisseur d'IA ne peut pas lire les images. Configurez un modèle capable de vision.", + "consentRequired": "Le consentement au traitement par IA est requis pour scanner un compte rendu.", + "rateLimited": "Trop de scans. Réessayez plus tard.", + "budgetExceeded": "Votre budget d'utilisation de l'IA pour aujourd'hui est atteint.", + "fileTooLarge": "Le fichier est trop volumineux (max. 12 Mo).", + "fileType": "Importez un JPEG, PNG, WebP ou PDF.", + "extractFailed": "Impossible de lire le compte rendu. Essayez une photo plus nette.", + "pdfNeedsAnthropic": "Le scan PDF nécessite un fournisseur de vision Claude ; importez plutôt une photo." + } }, "modules": { "cycle": { diff --git a/messages/it.json b/messages/it.json index b3eff405c..712b5c259 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6637,7 +6637,44 @@ } }, "noteLoadFailed": "Impossibile caricare la nota; resterà invariata al salvataggio.", - "showingLatestOf": "Visualizzazione degli ultimi {shown} risultati su {total}" + "showingLatestOf": "Visualizzazione degli ultimi {shown} risultati su {total}", + "ocr": { + "scanButton": "Scansiona un referto", + "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…", + "reviewTitle": "Controlla prima di salvare", + "reviewHint": "Controlla ogni valore, poi salva quelli da conservare.", + "foundCount": "{count} valori trovati", + "confirmRow": "Includi questo valore", + "analyteLabel": "Nome dell'esame", + "resultLabel": "Risultato", + "valueLabel": "Valore", + "unitLabel": "Unità", + "dateLabel": "Data", + "refLowLabel": "Riferimento inferiore", + "refHighLabel": "Riferimento superiore", + "newBiomarker": "Nuovo biomarcatore — verrà creato", + "linksExisting": "Collegato a {name} esistente", + "duplicateWarning": "Esiste già un valore corrispondente", + "lowConfidence": "Controlla questo valore", + "valueUnreadable": "Valore illeggibile — inseriscilo manualmente", + "saveSelected": "Salva {count} selezionati", + "discardAll": "Scarta tutto", + "savedToast": "{count} risultati di laboratorio salvati", + "noRows": "Nessun valore trovato in questo referto.", + "nothingToSave": "Seleziona almeno un valore da salvare.", + "saveFailed": "Impossibile salvare i valori. Riprova.", + "providerUnsupported": "Il tuo provider IA non può leggere le immagini. Configura un modello con visione.", + "consentRequired": "Per scansionare un referto è richiesto il consenso al trattamento tramite IA.", + "rateLimited": "Troppe scansioni. Riprova più tardi.", + "budgetExceeded": "Hai raggiunto il budget di utilizzo IA di oggi.", + "fileTooLarge": "Il file è troppo grande (max 12 MB).", + "fileType": "Carica un JPEG, PNG, WebP o PDF.", + "extractFailed": "Impossibile leggere il referto. Prova con una foto più nitida.", + "pdfNeedsAnthropic": "La scansione PDF richiede un provider di visione Claude; carica invece una foto." + } }, "modules": { "cycle": { diff --git a/messages/pl.json b/messages/pl.json index 81cdac560..832a4d5d2 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6637,7 +6637,44 @@ } }, "noteLoadFailed": "Nie udało się wczytać notatki; pozostanie niezmieniona po zapisaniu.", - "showingLatestOf": "Wyświetlanie najnowszych {shown} z {total} wyników" + "showingLatestOf": "Wyświetlanie najnowszych {shown} z {total} wyników", + "ocr": { + "scanButton": "Zeskanuj wynik", + "uploadHint": "Prześlij zdjęcie lub PDF wyniku laboratoryjnego", + "acceptWithPdf": "JPEG, PNG, WebP lub PDF", + "acceptImageOnly": "JPEG, PNG lub WebP", + "extracting": "Odczytywanie wyniku…", + "reviewTitle": "Sprawdź przed zapisaniem", + "reviewHint": "Sprawdź każdy wynik, a następnie zapisz te, które chcesz zachować.", + "foundCount": "Znaleziono {count} wyników", + "confirmRow": "Uwzględnij ten wynik", + "analyteLabel": "Nazwa badania", + "resultLabel": "Wynik", + "valueLabel": "Wartość", + "unitLabel": "Jednostka", + "dateLabel": "Data", + "refLowLabel": "Dolna granica", + "refHighLabel": "Górna granica", + "newBiomarker": "Nowy biomarker — zostanie utworzony", + "linksExisting": "Łączy z istniejącym {name}", + "duplicateWarning": "Pasujący wynik już istnieje", + "lowConfidence": "Sprawdź tę wartość", + "valueUnreadable": "Wartość nieczytelna — wprowadź ją ręcznie", + "saveSelected": "Zapisz {count} wybranych", + "discardAll": "Odrzuć wszystko", + "savedToast": "Zapisano {count} wyników laboratoryjnych", + "noRows": "W tym wyniku nie znaleziono żadnych wartości.", + "nothingToSave": "Wybierz co najmniej jeden wynik do zapisania.", + "saveFailed": "Nie udało się zapisać wyników. Spróbuj ponownie.", + "providerUnsupported": "Twój dostawca AI nie może odczytywać obrazów. Skonfiguruj model z obsługą obrazu.", + "consentRequired": "Do zeskanowania wyniku wymagana jest zgoda na przetwarzanie przez AI.", + "rateLimited": "Zbyt wiele skanów. Spróbuj ponownie później.", + "budgetExceeded": "Osiągnięto dzienny limit użycia AI.", + "fileTooLarge": "Plik jest za duży (maks. 12 MB).", + "fileType": "Prześlij plik JPEG, PNG, WebP lub PDF.", + "extractFailed": "Nie udało się odczytać wyniku. Spróbuj wyraźniejszego zdjęcia.", + "pdfNeedsAnthropic": "Skanowanie PDF wymaga dostawcy wizji Claude; prześlij zamiast tego zdjęcie." + } }, "modules": { "cycle": { diff --git a/src/app/labs/page.tsx b/src/app/labs/page.tsx index 6abc781fb..5dd27f3ac 100644 --- a/src/app/labs/page.tsx +++ b/src/app/labs/page.tsx @@ -4,10 +4,12 @@ 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, Wrench } from "lucide-react"; +import { Loader2, 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 { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; @@ -27,6 +29,10 @@ export default function LabsPage() { // Sticky-footer slot element for the add-result sheet (filled by the // ResponsiveSheet `footer` ref so the form portals its action row there). const [addFooterEl, setAddFooterEl] = useState(null); + // v1.18.9 — Lab-OCR "Scan a report" dialog. The scan affordance only shows + // when the user's configured AI provider can read images (capability probe). + const [scanOpen, setScanOpen] = useState(false); + const ocrCapability = useOcrCapability(isAuthenticated); const refreshVisible = useCallback( () => queryClient.invalidateQueries({ type: "active" }), @@ -67,6 +73,19 @@ export default function LabsPage() {

+ {/* 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 ? ( + + ) : 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 @@ -116,6 +135,22 @@ export default function LabsPage() { /> + {ocrCapability.data?.available ? ( + { + queryClient.invalidateQueries({ + queryKey: queryKeys.labResults(), + }); + queryClient.invalidateQueries({ + queryKey: queryKeys.biomarkers(), + }); + }} + /> + ) : null} + setDialogOpen(true)} />
); diff --git a/src/components/labs/ocr-review-dialog.tsx b/src/components/labs/ocr-review-dialog.tsx new file mode 100644 index 000000000..3edac70b7 --- /dev/null +++ b/src/components/labs/ocr-review-dialog.tsx @@ -0,0 +1,261 @@ +"use client"; + +/** + * v1.18.9 — the Lab-OCR "Scan a report" dialog. + * + * Flow: pick a photo / PDF → extract (vision provider) → MANDATORY human review + * (per-row confirm/edit/discard, duplicate + new-biomarker + low-confidence + * hints) → commit only the confirmed rows. Nothing writes until the user + * confirms. Reuses the labs design language via `ResponsiveSheet` + the labs + * row editor. + */ +import { useMemo, useRef, useState } from "react"; + +import { Loader2, ScanLine, Upload } from "lucide-react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; +import { ApiError } from "@/lib/api/api-fetch"; +import { useTranslations } from "@/lib/i18n/context"; + +import { OcrRowEditor } from "./ocr-row-editor"; +import { seedReviewRows, type OcrReviewRow } from "./ocr-review-types"; +import { + useOcrCommit, + useOcrExtract, + type OcrCommitRowInput, +} from "./use-ocr-extract"; + +type Stage = "pick" | "review"; + +const ACCEPT = "image/jpeg,image/png,image/webp,application/pdf"; + +/** Map a confirmed review row to the commit payload, or null when invalid. */ +function toCommitRow(row: OcrReviewRow): OcrCommitRowInput | null { + const analyte = row.analyte.trim(); + if (!analyte || !row.takenAt) return null; + // A calendar day → an ISO instant at noon UTC, avoiding a TZ day-shift. + const takenAt = new Date(`${row.takenAt}T12:00:00.000Z`); + if (Number.isNaN(takenAt.getTime())) return null; + + const isQualitative = row.valueText !== null && row.valueText !== undefined; + if (isQualitative) { + const valueText = (row.valueText ?? "").trim(); + if (!valueText) return null; + return { analyte, valueText, takenAt: takenAt.toISOString() }; + } + + if (row.value === null || !Number.isFinite(row.value)) return null; + const unit = (row.unit ?? "").trim(); + if (!unit) return null; + return { + analyte, + value: row.value, + unit, + takenAt: takenAt.toISOString(), + ...(row.referenceLow !== null ? { referenceLow: row.referenceLow } : {}), + ...(row.referenceHigh !== null ? { referenceHigh: row.referenceHigh } : {}), + }; +} + +/** Translate an extract failure into a friendly, error-code-aware message. */ +function extractErrorMessage(err: unknown, t: (key: string) => string): string { + if (err instanceof ApiError) { + const code = + typeof err.meta?.errorCode === "string" ? err.meta.errorCode : null; + switch (code) { + case "labs.ocr.providerUnsupported": + return t("labs.ocr.providerUnsupported"); + case "labs.ocr.rateLimited": + return t("labs.ocr.rateLimited"); + case "labs.ocr.budgetExceeded": + return t("labs.ocr.budgetExceeded"); + case "labs.ocr.fileTooLarge": + return t("labs.ocr.fileTooLarge"); + case "labs.ocr.fileType": + return t("labs.ocr.fileType"); + case "labs.ocr.pdfNeedsAnthropic": + return t("labs.ocr.pdfNeedsAnthropic"); + default: + break; + } + if (err.status === 403) return t("labs.ocr.consentRequired"); + } + return t("labs.ocr.extractFailed"); +} + +export function OcrReviewDialog({ + open, + onOpenChange, + pdfSupported, + onCommitted, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + pdfSupported: boolean; + onCommitted: () => void; +}) { + const { t } = useTranslations(); + const inputRef = useRef(null); + const [stage, setStage] = useState("pick"); + const [rows, setRows] = useState([]); + + const extract = useOcrExtract(); + const commit = useOcrCommit(); + + const confirmedCount = useMemo( + () => rows.filter((r) => r.confirmed).length, + [rows], + ); + + function reset() { + setStage("pick"); + setRows([]); + extract.reset(); + commit.reset(); + } + + function handleClose(next: boolean) { + if (!next) reset(); + onOpenChange(next); + } + + function onFilePicked(file: File) { + extract.mutate(file, { + onSuccess: (data) => { + const seeded = seedReviewRows(data.rows, data.reportDate); + if (seeded.length === 0) { + toast.error(t("labs.ocr.noRows")); + return; + } + setRows(seeded); + setStage("review"); + }, + onError: (err) => toast.error(extractErrorMessage(err, t)), + }); + } + + function onSave() { + const payload = rows + .filter((r) => r.confirmed) + .map(toCommitRow) + .filter((r): r is OcrCommitRowInput => r !== null); + if (payload.length === 0) { + toast.error(t("labs.ocr.nothingToSave")); + return; + } + commit.mutate(payload, { + onSuccess: (result) => { + toast.success( + t("labs.ocr.savedToast", { count: result.inserted.length }), + ); + onCommitted(); + handleClose(false); + }, + onError: () => toast.error(t("labs.ocr.saveFailed")), + }); + } + + const busy = extract.isPending || commit.isPending; + + return ( + + + + + ) : undefined + } + > + {stage === "pick" ? ( +
+ { + const file = e.target.files?.[0]; + // Reset so re-picking the same file fires `change` again. + e.target.value = ""; + if (file) onFilePicked(file); + }} + /> + +
+ ) : ( +
+

+ {t("labs.ocr.foundCount", { count: rows.length })} +

+ {rows.map((row) => ( + + setRows((prev) => + prev.map((r) => (r.key === next.key ? next : r)), + ) + } + /> + ))} +
+ )} +
+ ); +} diff --git a/src/components/labs/ocr-review-types.ts b/src/components/labs/ocr-review-types.ts new file mode 100644 index 000000000..1384cb0fd --- /dev/null +++ b/src/components/labs/ocr-review-types.ts @@ -0,0 +1,49 @@ +/** + * v1.18.9 — the client-side review-row shape for the Lab-OCR screen. + * + * A proposed extraction row plus the user's per-row edit + confirm state. The + * extract DTO seeds it; the review screen mutates it; only confirmed rows are + * mapped back to the commit payload. + */ +import type { OcrExtractedRowDto } from "@/lib/validations/labs-ocr"; + +export interface OcrReviewRow { + /** Stable client key for the list (the analyte+index at seed time). */ + key: string; + analyte: string; + value: number | null; + valueText: string | null; + unit: string | null; + referenceLow: number | null; + referenceHigh: number | null; + /** ISO calendar day (YYYY-MM-DD) or null. */ + takenAt: string | null; + confidence: OcrExtractedRowDto["confidence"]; + biomarkerMatch: "new" | "existing"; + /** Non-null when the server flagged a likely duplicate of a live reading. */ + duplicateOf: string | null; + /** Whether the row is selected for saving. Defaults false for duplicates. */ + confirmed: boolean; +} + +/** Seed the review rows from the extract response. */ +export function seedReviewRows( + rows: OcrExtractedRowDto[], + fallbackDate: string | null, +): OcrReviewRow[] { + return rows.map((row, index) => ({ + key: `${row.analyte}-${index}`, + analyte: row.analyte, + value: row.value, + valueText: row.valueText, + unit: row.unit, + referenceLow: row.referenceLow, + referenceHigh: row.referenceHigh, + takenAt: row.takenAt ?? fallbackDate, + confidence: row.confidence, + biomarkerMatch: row.biomarkerMatch, + duplicateOf: row.duplicateOf, + // A flagged duplicate starts unchecked; everything else starts confirmed. + confirmed: row.duplicateOf === null, + })); +} diff --git a/src/components/labs/ocr-row-editor.tsx b/src/components/labs/ocr-row-editor.tsx new file mode 100644 index 000000000..54563333f --- /dev/null +++ b/src/components/labs/ocr-row-editor.tsx @@ -0,0 +1,207 @@ +"use client"; + +/** + * v1.18.9 — a single proposed Lab-OCR row on the review screen. + * + * Mandatory human review: each row is shown for per-row confirm/edit/discard. + * The checkbox confirms; the inline fields edit analyte / value-or-valueText / + * unit / reference range / date. Server-computed hints surface as calm badges: + * a new-vs-existing-biomarker hint, a duplicate warning (the row defaults to + * unchecked when flagged), and a low-confidence flag per the model's self-score. + * + * The no-alarming-colour ethos holds: an out-of-range or duplicate row is not + * painted red — these are informative `secondary`/`outline` badges, same weight + * as the in-range state. + */ +import { useId } from "react"; + +import { AlertCircle, FilePlus2, Link2, TriangleAlert } from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { useTranslations } from "@/lib/i18n/context"; + +import type { OcrReviewRow } from "./ocr-review-types"; + +/** Below this per-field confidence the field is flagged for a second look. */ +const CONFIDENCE_THRESHOLD = 0.6; + +export function OcrRowEditor({ + row, + onChange, +}: { + row: OcrReviewRow; + onChange: (next: OcrReviewRow) => void; +}) { + const { t } = useTranslations(); + const fieldId = useId(); + + const isQualitative = row.valueText !== null && row.valueText !== undefined; + const lowValueConfidence = + !isQualitative && row.confidence.value < CONFIDENCE_THRESHOLD; + const valueUnreadable = !isQualitative && row.value === null; + + return ( +
+
+ + onChange({ ...row, confirmed: checked === true }) + } + className="mt-1 min-h-5 min-w-5" + aria-label={t("labs.ocr.confirmRow")} + /> +
+ onChange({ ...row, analyte: e.target.value })} + aria-label={t("labs.ocr.analyteLabel")} + className="font-medium" + /> +
+ {row.biomarkerMatch === "existing" ? ( + + + {t("labs.ocr.linksExisting", { name: row.analyte })} + + ) : ( + + + {t("labs.ocr.newBiomarker")} + + )} + {row.duplicateOf ? ( + + + {t("labs.ocr.duplicateWarning")} + + ) : null} +
+
+
+ +
+ {isQualitative ? ( +
+ + onChange({ ...row, valueText: e.target.value })} + /> +
+ ) : ( + <> +
+ + { + const raw = e.target.value.trim(); + const parsed = raw === "" ? null : Number(raw); + onChange({ + ...row, + value: + parsed !== null && Number.isFinite(parsed) + ? parsed + : null, + }); + }} + /> +
+
+ + onChange({ ...row, unit: e.target.value })} + /> +
+ + )} +
+ + + onChange({ ...row, takenAt: e.target.value || null }) + } + /> +
+
+ + {!isQualitative ? ( +
+
+ + { + const raw = e.target.value.trim(); + const parsed = raw === "" ? null : Number(raw); + onChange({ + ...row, + referenceLow: + parsed !== null && Number.isFinite(parsed) ? parsed : null, + }); + }} + /> +
+
+ + { + const raw = e.target.value.trim(); + const parsed = raw === "" ? null : Number(raw); + onChange({ + ...row, + referenceHigh: + parsed !== null && Number.isFinite(parsed) ? parsed : null, + }); + }} + /> +
+
+ ) : null} + + {valueUnreadable ? ( +

+ + {t("labs.ocr.valueUnreadable")} +

+ ) : lowValueConfidence ? ( +

+ + {t("labs.ocr.lowConfidence")} +

+ ) : null} +
+ ); +} diff --git a/src/components/labs/use-ocr-extract.ts b/src/components/labs/use-ocr-extract.ts new file mode 100644 index 000000000..8ed9307d4 --- /dev/null +++ b/src/components/labs/use-ocr-extract.ts @@ -0,0 +1,79 @@ +"use client"; + +/** + * v1.18.9 — client hooks for the Lab-OCR ingestion flow. + * + * - `useOcrCapability()` — the cheap capability probe that decides whether the + * "Scan a report" affordance shows. + * - `useOcrExtract()` — uploads the photo / PDF and returns the proposed rows. + * A vision call is slow, so it opts out of the default 15 s fetch timeout in + * favour of a generous 90 s window. + * - `useOcrCommit()` — writes the user-confirmed rows and invalidates the + * labs + biomarker query keys. + */ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { apiFetch, apiPost } from "@/lib/api/api-fetch"; +import { queryKeys } from "@/lib/query-keys"; +import type { + OcrCapabilityDto, + OcrCommitResponseDto, + OcrExtractResponseDto, +} from "@/lib/validations/labs-ocr"; + +import type { LabResultDto } from "./types"; + +/** Per-row payload the commit endpoint accepts (the human-confirmed shape). */ +export interface OcrCommitRowInput { + analyte: string; + panel?: string; + value?: number; + valueText?: string; + unit?: string; + referenceLow?: number; + referenceHigh?: number; + takenAt: string; +} + +export interface OcrCommitResult extends OcrCommitResponseDto { + inserted: LabResultDto[]; +} + +/** Capability probe — refetched when the scan dialog opens. */ +export function useOcrCapability(enabled: boolean) { + return useQuery({ + queryKey: queryKeys.ocrCapability(), + queryFn: () => apiFetch("/api/labs/ocr/capability"), + enabled, + staleTime: 60_000, + }); +} + +/** Upload + extract. Resolves with the proposed rows for the review screen. */ +export function useOcrExtract() { + return useMutation({ + mutationFn: (file: File) => { + const form = new FormData(); + form.append("file", file); + return apiFetch("/api/labs/ocr/extract", { + method: "POST", + body: form, + // Vision extraction is slow; a generous ceiling beats the 15 s default. + signal: AbortSignal.timeout(90_000), + }); + }, + }); +} + +/** Commit the confirmed rows and invalidate the dependent reads. */ +export function useOcrCommit() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (rows: OcrCommitRowInput[]) => + apiPost("/api/labs/ocr/commit", { rows }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.labResults() }); + queryClient.invalidateQueries({ queryKey: queryKeys.biomarkers() }); + }, + }); +} diff --git a/src/lib/query-keys/labs.ts b/src/lib/query-keys/labs.ts index 82095873e..09d823213 100644 --- a/src/lib/query-keys/labs.ts +++ b/src/lib/query-keys/labs.ts @@ -33,4 +33,8 @@ export const labKeys = { // the manager; both invalidate `biomarkers()` after a mutation. biomarkers: () => ["biomarkers"] as const, biomarkerDetail: (id: string) => ["biomarkers", "detail", id] as const, + + // v1.18.9 — Lab-OCR capability probe (drives whether the "Scan a report" + // affordance shows). Cheap GET; the dialog refetches it on open. + ocrCapability: () => ["lab-ocr", "capability"] as const, };