feat(labs): keep the reference range a report prints beside a value

A lab runs its own method on its own device, so the window a physician
evaluates a value against is the one printed next to it on that report,
not a catalog band averaged over every lab. Reading a report pulled the
value and dropped that range: the extraction asked for it, the commit
handed it to the biomarker resolver, and the resolver only uses it when
it mints a NEW marker. For every analyte the user already had — the
normal case after a first panel — the printed range was read and then
thrown away.

Add `sourceReferenceLow` / `sourceReferenceHigh` / `sourceReferenceText`
to `LabResult` and carry the range from the report to the reading. The
text is stored whether or not numbers can be read out of it, because
"bis 5,0" and "negativ" state a window too; a range that could not be
parsed is kept verbatim, never replaced with invented bounds.

`resolveEffectiveReferenceRange` is the one place the precedence lives:
the source window wins for that single reading, and the catalog stays
the net for readings without one. A printed string with no derivable
bound does not displace the catalog. Every surface that judges a
reading routes through it — the API verdict, the doctor report, the
insights blocks and per-marker assessment, the coach snapshot, the MCP
read and the FHIR `referenceRange` — so a value cannot read in range on
one surface and out on another.

The API keeps `referenceLow` / `referenceHigh` as the window in force,
so a client that reads only those two renders the right range and the
right verdict with no change of its own; the catalog band, the source
window, its origin and a divergence flag ride alongside for clients
that want to show both.

Read side: the chart draws the source window as a second, visually
distinct band with the reading it came from named beneath it, the
reading rows name the printed range and the catalog band where the two
disagree, and the doctor-report PDF prints the report's own string with
a footnote naming the saved range. Entry side: one optional field on
the lab form, folded away until asked for, and the printed range is
editable on the scan review screen.

The range parser covers the notations German and English reports
actually print — two-sided, one-sided, comma or dot decimals, thousands
separators, trailing units — reads units without ever converting them,
and refuses to guess on anything ambiguous.
This commit is contained in:
Marc-André Bombeck committed 2026-08-07 21:40:41 +02:00
1 parent 3778e70ff5
commit fd2bd67ff3
57 files changed
+2281 -175

No files matched your search

+172 -9
View File
@@ -9147,6 +9147,16 @@ paths:
type: number
referenceHigh:
type: number
sourceReferenceLow:
type: number
sourceReferenceHigh:
type: number
sourceReferenceText:
anyOf:
- type: string
maxLength: 120
- type: string
const: ""
takenAt:
type: string
format: date-time
@@ -9254,6 +9264,22 @@ paths:
anyOf:
- type: number
- type: "null"
sourceReferenceLow:
anyOf:
- type: number
- type: "null"
sourceReferenceHigh:
anyOf:
- type: number
- type: "null"
sourceReferenceText:
anyOf:
- anyOf:
- type: string
maxLength: 120
- type: string
const: ""
- type: "null"
takenAt:
type: string
format: date-time
@@ -9430,6 +9456,12 @@ paths:
type: number
referenceHigh:
type: number
referenceText:
anyOf:
- type: string
maxLength: 120
- type: string
const: ""
takenAt:
type: string
format: date-time
@@ -11616,6 +11648,11 @@ paths:
anyOf:
- type: number
- type: "null"
referenceText:
anyOf:
- type: string
maxLength: 120
- type: "null"
effectiveDate:
anyOf:
- type: string
@@ -32970,6 +33007,30 @@ components:
anyOf:
- type: number
- type: "null"
catalogReferenceLow:
anyOf:
- type: number
- type: "null"
catalogReferenceHigh:
anyOf:
- type: number
- type: "null"
sourceReferenceLow:
anyOf:
- type: number
- type: "null"
sourceReferenceHigh:
anyOf:
- type: number
- type: "null"
sourceReferenceText:
anyOf:
- type: string
- type: "null"
referenceOrigin:
$ref: "#/components/schemas/LabReferenceRangeOrigin"
referenceDivergesFromCatalog:
type: boolean
takenAt:
type: string
source:
@@ -32992,6 +33053,13 @@ components:
- unit
- referenceLow
- referenceHigh
- catalogReferenceLow
- catalogReferenceHigh
- sourceReferenceLow
- sourceReferenceHigh
- sourceReferenceText
- referenceOrigin
- referenceDivergesFromCatalog
- takenAt
- source
- hasNote
@@ -32999,12 +33067,31 @@ components:
- createdAt
- updatedAt
additionalProperties: false
description: A stored lab result. Exactly one of `value` (numeric) or `valueText` (qualitative, e.g. "negativ") is
non-null; `rangeStatus` is always `unknown` for a qualitative row (no number to classify). `biomarkerId` links
the user-scoped catalog marker (null for legacy free-text rows); when linked, `analyte` / `unit` /
`referenceLow` / `referenceHigh` are RESOLVED server-side from the Biomarker — the client renders them and never
recomputes the range. The encrypted note is never echoed in list rows; `hasNote` flags its presence and the
single-resource GET returns the decrypted `note`.
description: >-
A stored lab result. Exactly one of `value` (numeric) or `valueText` (qualitative, e.g. "negativ") is non-null;
`rangeStatus` is always `unknown` for a qualitative row (no number to classify). `biomarkerId` links the
user-scoped catalog marker (null for legacy free-text rows); when linked, `analyte` / `unit` are RESOLVED
server-side from the Biomarker.
Three reference windows ride on the row and the server says which one applies. `referenceLow` / `referenceHigh`
are the window IN FORCE and the one `rangeStatus` was computed from — read these to render the range and you are
correct without doing any resolution of your own. `sourceReferenceLow` / `sourceReferenceHigh` /
`sourceReferenceText` are the range this reading's own report printed (the text verbatim, kept even when no
numbers could be read out of it). `catalogReferenceLow` / `catalogReferenceHigh` are the Biomarker band.
`referenceOrigin` names the winner and `referenceDivergesFromCatalog` is true when the report's window and the
catalog band state different limits — a surface showing only one of the two is showing a partial answer. The
client renders these values and never recomputes the range. The encrypted note is never echoed in list rows;
`hasNote` flags its presence and the single-resource GET returns the decrypted `note`.
LabReferenceRangeOrigin:
type: string
enum:
- source
- catalog
- none
description: Which window `referenceLow` / `referenceHigh` came from. `source` = the range printed on this reading's own
report, which wins for that reading because it is the window the physician evaluated the value against.
`catalog` = the user's Biomarker band, the net for readings with no printed range. `none` = no window on file.
LabReferenceRangeStatus:
type: string
enum:
@@ -33012,9 +33099,9 @@ components:
- below
- above
- unknown
description: "Server-computed, NEUTRAL reference-range verdict. `unknown` when the lab reported no usable bounds.
Inclusive bounds: a value on the limit reads in-range. The badge that renders this must stay calm and
informative — not an alarming red."
description: "Server-computed, NEUTRAL reference-range verdict, always against `referenceLow` / `referenceHigh` (the
window in force for that reading). `unknown` when no usable bounds are on file. Inclusive bounds: a value on the
limit reads in-range. The badge that renders this must stay calm and informative — not an alarming red."
CreateLabResultResponse:
type: object
properties:
@@ -33082,6 +33169,30 @@ components:
anyOf:
- type: number
- type: "null"
catalogReferenceLow:
anyOf:
- type: number
- type: "null"
catalogReferenceHigh:
anyOf:
- type: number
- type: "null"
sourceReferenceLow:
anyOf:
- type: number
- type: "null"
sourceReferenceHigh:
anyOf:
- type: number
- type: "null"
sourceReferenceText:
anyOf:
- type: string
- type: "null"
referenceOrigin:
$ref: "#/components/schemas/LabReferenceRangeOrigin"
referenceDivergesFromCatalog:
type: boolean
takenAt:
type: string
source:
@@ -33106,6 +33217,13 @@ components:
- unit
- referenceLow
- referenceHigh
- catalogReferenceLow
- catalogReferenceHigh
- sourceReferenceLow
- sourceReferenceHigh
- sourceReferenceText
- referenceOrigin
- referenceDivergesFromCatalog
- takenAt
- source
- rangeStatus
@@ -33288,6 +33406,14 @@ components:
anyOf:
- type: number
- type: "null"
referenceText:
anyOf:
- type: string
- type: "null"
description: The reference range EXACTLY as printed on the report, verbatim ("3,5 - 5,0", "< 116", "bis 5,0",
"negativ"). `referenceLow` / `referenceHigh` carry the same window reduced to numbers WHEN it reduces to
numbers; this field carries it either way, so a window stated in words is not lost between the report and
the reading.
takenAt:
anyOf:
- type: string
@@ -33325,6 +33451,7 @@ components:
- unit
- referenceLow
- referenceHigh
- referenceText
- takenAt
- confidence
- biomarkerMatch
@@ -33422,6 +33549,34 @@ components:
anyOf:
- type: number
- type: "null"
catalogReferenceLow:
anyOf:
- type: number
- type: "null"
catalogReferenceHigh:
anyOf:
- type: number
- type: "null"
sourceReferenceLow:
anyOf:
- type: number
- type: "null"
sourceReferenceHigh:
anyOf:
- type: number
- type: "null"
sourceReferenceText:
anyOf:
- type: string
- type: "null"
referenceOrigin:
type: string
enum:
- source
- catalog
- none
referenceDivergesFromCatalog:
type: boolean
takenAt:
type: string
source:
@@ -33449,6 +33604,13 @@ components:
- unit
- referenceLow
- referenceHigh
- catalogReferenceLow
- catalogReferenceHigh
- sourceReferenceLow
- sourceReferenceHigh
- sourceReferenceText
- referenceOrigin
- referenceDivergesFromCatalog
- takenAt
- source
- hasNote
@@ -33456,6 +33618,7 @@ components:
- createdAt
- updatedAt
additionalProperties: false
description: A written reading. Its reference window resolves exactly as it does on `/api/labs` — see `LabResult`.
ListBiomarkersEnvelope:
type: object
properties:
+21 -5
View File
@@ -281,7 +281,9 @@
"familyHistoryTitle": "Familienanamnese",
"familyHistoryColRelationship": "Verwandtschaft",
"familyHistoryColCondition": "Erkrankung",
"familyHistoryColAgeAtOnset": "Alter bei Beginn"
"familyHistoryColAgeAtOnset": "Alter bei Beginn",
"labsSourceRangeNote": "* Der Referenzbereich stammt aus dem Befund und weicht vom gespeicherten Bereich ab:",
"labsCatalogRangeLabel": "gespeicherter Bereich"
},
"errorBoundary": {
"title": "Da ist etwas schiefgelaufen",
@@ -8037,7 +8039,12 @@
"qualitativePlaceholder": "z. B. negativ",
"qualNegative": "negativ",
"qualPositive": "positiv",
"qualBorderline": "grenzwertig"
"qualBorderline": "grenzwertig",
"sourceRange": "Bereich laut Befund",
"sourceRangePlaceholder": "z. B. 3,5 - 5,0 oder < 5",
"sourceRangeAdd": "+ Bereich aus dem Befund ergänzen",
"sourceRangeHint": "Optional. Gilt nur für diesen Wert; dein gespeicherter Bereich bleibt unverändert.",
"sourceRangeUnparsed": "Wird so übernommen — daraus ließ sich kein Zahlenbereich lesen."
},
"backToLabs": "Zurück zu den Laborwerten",
"hasNote": "Notiz",
@@ -8068,7 +8075,10 @@
"90": "90 T",
"365": "1 J",
"all": "Alle"
}
},
"sourceRangeLabel": "Befundbereich",
"sourceBandLegend": "Zweites Band: der Bereich aus dem Befund vom {date}.",
"sourceBandMixed": "Zweites Band: der Bereich aus dem neuesten Befund. Frühere Befunde im Zeitraum nennen andere Bereiche."
},
"biomarker": {
"manageTitle": "Biomarker",
@@ -8243,11 +8253,17 @@
"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."
"pdfNeedsAnthropic": "PDF-Scannen benötigt einen Claude-Bildanbieter; lade stattdessen ein Foto hoch.",
"refTextLabel": "Bereich wie gedruckt",
"refTextPlaceholder": "z. B. 3,5 - 5,0"
},
"loadMoreReadings": "Weitere Werte laden",
"documentsLink": "Befund-Dokumente",
"notLinkedYet": "Noch keinem Biomarker zugeordnet"
"notLinkedYet": "Noch keinem Biomarker zugeordnet",
"sourceRange": {
"fromReport": "Befundbereich {range}",
"catalogDiffers": "dein Bereich {range}"
}
},
"modules": {
"cycle": {
+21 -5
View File
@@ -281,7 +281,9 @@
"familyHistoryTitle": "Family history",
"familyHistoryColRelationship": "Relative",
"familyHistoryColCondition": "Condition",
"familyHistoryColAgeAtOnset": "Age at onset"
"familyHistoryColAgeAtOnset": "Age at onset",
"labsSourceRangeNote": "* The reference range comes from the report itself and differs from the saved range:",
"labsCatalogRangeLabel": "saved range"
},
"errorBoundary": {
"title": "Something went wrong",
@@ -8037,7 +8039,12 @@
"qualitativePlaceholder": "e.g. negative",
"qualNegative": "negative",
"qualPositive": "positive",
"qualBorderline": "borderline"
"qualBorderline": "borderline",
"sourceRange": "Range printed on the report",
"sourceRangePlaceholder": "e.g. 3.5 - 5.0 or < 5",
"sourceRangeAdd": "+ Add the range from the report",
"sourceRangeHint": "Optional. Applies to this reading only; your saved range stays as it is.",
"sourceRangeUnparsed": "Kept as written — no numeric range read from it."
},
"backToLabs": "Back to lab results",
"hasNote": "Note",
@@ -8068,7 +8075,10 @@
"90": "90d",
"365": "1y",
"all": "All"
}
},
"sourceRangeLabel": "Report range",
"sourceBandLegend": "Second band: the range printed on the report of {date}.",
"sourceBandMixed": "Second band: the range printed on the most recent report. Earlier reports in view state different ranges."
},
"biomarker": {
"manageTitle": "Biomarkers",
@@ -8243,11 +8253,17 @@
"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."
"pdfNeedsAnthropic": "PDF scanning needs a Claude vision provider; upload a photo instead.",
"refTextLabel": "Range as printed",
"refTextPlaceholder": "e.g. 3.5 - 5.0"
},
"loadMoreReadings": "Load more readings",
"documentsLink": "Lab report documents",
"notLinkedYet": "Not linked to a biomarker yet"
"notLinkedYet": "Not linked to a biomarker yet",
"sourceRange": {
"fromReport": "Report range {range}",
"catalogDiffers": "your range {range}"
}
},
"modules": {
"cycle": {
+21 -5
View File
@@ -281,7 +281,9 @@
"familyHistoryTitle": "Antecedentes familiares",
"familyHistoryColRelationship": "Parentesco",
"familyHistoryColCondition": "Afección",
"familyHistoryColAgeAtOnset": "Edad de inicio"
"familyHistoryColAgeAtOnset": "Edad de inicio",
"labsSourceRangeNote": "* El rango de referencia procede del propio informe y difiere del rango guardado:",
"labsCatalogRangeLabel": "rango guardado"
},
"errorBoundary": {
"title": "Algo ha salido mal",
@@ -8037,7 +8039,12 @@
"qualitativePlaceholder": "p. ej. negativo",
"qualNegative": "negativo",
"qualPositive": "positivo",
"qualBorderline": "límite"
"qualBorderline": "límite",
"sourceRange": "Rango impreso en el informe",
"sourceRangePlaceholder": "p. ej. 3,5 - 5,0 o < 5",
"sourceRangeAdd": "+ Añadir el rango del informe",
"sourceRangeHint": "Opcional. Se aplica solo a esta lectura; tu rango guardado no cambia.",
"sourceRangeUnparsed": "Se guarda tal cual: no se pudo leer un rango numérico."
},
"backToLabs": "Volver a los resultados",
"hasNote": "Nota",
@@ -8068,7 +8075,10 @@
"90": "90 d",
"365": "1 a",
"all": "Todo"
}
},
"sourceRangeLabel": "Rango del informe",
"sourceBandLegend": "Segunda banda: el rango impreso en el informe del {date}.",
"sourceBandMixed": "Segunda banda: el rango impreso en el informe más reciente. Informes anteriores del periodo indican rangos distintos."
},
"biomarker": {
"manageTitle": "Biomarcadores",
@@ -8243,11 +8253,17 @@
"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."
"pdfNeedsAnthropic": "El escaneo de PDF necesita un proveedor de visión de Claude; sube una foto en su lugar.",
"refTextLabel": "Rango tal como aparece",
"refTextPlaceholder": "p. ej. 3,5 - 5,0"
},
"loadMoreReadings": "Cargar más mediciones",
"documentsLink": "Documentos de resultados",
"notLinkedYet": "Aún no vinculado a un biomarcador"
"notLinkedYet": "Aún no vinculado a un biomarcador",
"sourceRange": {
"fromReport": "Rango del informe {range}",
"catalogDiffers": "tu rango {range}"
}
},
"modules": {
"cycle": {
+21 -5
View File
@@ -281,7 +281,9 @@
"familyHistoryTitle": "Antécédents familiaux",
"familyHistoryColRelationship": "Lien de parenté",
"familyHistoryColCondition": "Affection",
"familyHistoryColAgeAtOnset": "Âge au début"
"familyHistoryColAgeAtOnset": "Âge au début",
"labsSourceRangeNote": "* La plage de référence provient du compte rendu lui-même et diffère de la plage enregistrée :",
"labsCatalogRangeLabel": "plage enregistrée"
},
"errorBoundary": {
"title": "Une erreur est survenue",
@@ -8037,7 +8039,12 @@
"qualitativePlaceholder": "p. ex. négatif",
"qualNegative": "négatif",
"qualPositive": "positif",
"qualBorderline": "limite"
"qualBorderline": "limite",
"sourceRange": "Plage imprimée sur le compte rendu",
"sourceRangePlaceholder": "p. ex. 3,5 - 5,0 ou < 5",
"sourceRangeAdd": "+ Ajouter la plage du compte rendu",
"sourceRangeHint": "Facultatif. Ne vaut que pour cette mesure ; votre plage enregistrée reste inchangée.",
"sourceRangeUnparsed": "Conservé tel quel : aucune plage numérique n'a pu en être lue."
},
"backToLabs": "Retour aux résultats",
"hasNote": "Note",
@@ -8068,7 +8075,10 @@
"90": "90 j",
"365": "1 a",
"all": "Tout"
}
},
"sourceRangeLabel": "Plage du compte rendu",
"sourceBandLegend": "Deuxième bande : la plage imprimée sur le compte rendu du {date}.",
"sourceBandMixed": "Deuxième bande : la plage imprimée sur le compte rendu le plus récent. Des comptes rendus antérieurs de la période indiquent d'autres plages."
},
"biomarker": {
"manageTitle": "Biomarqueurs",
@@ -8243,11 +8253,17 @@
"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."
"pdfNeedsAnthropic": "Le scan PDF nécessite un fournisseur de vision Claude ; importez plutôt une photo.",
"refTextLabel": "Plage telle qu'imprimée",
"refTextPlaceholder": "p. ex. 3,5 - 5,0"
},
"loadMoreReadings": "Charger plus de mesures",
"documentsLink": "Documents de résultats",
"notLinkedYet": "Pas encore lié à un biomarqueur"
"notLinkedYet": "Pas encore lié à un biomarqueur",
"sourceRange": {
"fromReport": "Plage du compte rendu {range}",
"catalogDiffers": "votre plage {range}"
}
},
"modules": {
"cycle": {
+21 -5
View File
@@ -281,7 +281,9 @@
"familyHistoryTitle": "Anamnesi familiare",
"familyHistoryColRelationship": "Parentela",
"familyHistoryColCondition": "Condizione",
"familyHistoryColAgeAtOnset": "Età di esordio"
"familyHistoryColAgeAtOnset": "Età di esordio",
"labsSourceRangeNote": "* L'intervallo di riferimento proviene dal referto stesso e differisce da quello salvato:",
"labsCatalogRangeLabel": "intervallo salvato"
},
"errorBoundary": {
"title": "Qualcosa è andato storto",
@@ -8037,7 +8039,12 @@
"qualitativePlaceholder": "es. negativo",
"qualNegative": "negativo",
"qualPositive": "positivo",
"qualBorderline": "borderline"
"qualBorderline": "borderline",
"sourceRange": "Intervallo stampato sul referto",
"sourceRangePlaceholder": "es. 3,5 - 5,0 oppure < 5",
"sourceRangeAdd": "+ Aggiungi l'intervallo dal referto",
"sourceRangeHint": "Facoltativo. Vale solo per questa misura; l'intervallo salvato resta invariato.",
"sourceRangeUnparsed": "Conservato così com'è: non è stato letto alcun intervallo numerico."
},
"backToLabs": "Torna ai risultati",
"hasNote": "Nota",
@@ -8068,7 +8075,10 @@
"90": "90 g",
"365": "1 a",
"all": "Tutto"
}
},
"sourceRangeLabel": "Intervallo del referto",
"sourceBandLegend": "Seconda banda: l'intervallo stampato sul referto del {date}.",
"sourceBandMixed": "Seconda banda: l'intervallo stampato sul referto più recente. Referti precedenti nel periodo indicano intervalli diversi."
},
"biomarker": {
"manageTitle": "Biomarcatori",
@@ -8243,11 +8253,17 @@
"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."
"pdfNeedsAnthropic": "La scansione PDF richiede un provider di visione Claude; carica invece una foto.",
"refTextLabel": "Intervallo come stampato",
"refTextPlaceholder": "es. 3,5 - 5,0"
},
"loadMoreReadings": "Carica altre misurazioni",
"documentsLink": "Documenti dei referti",
"notLinkedYet": "Non ancora collegato a un biomarcatore"
"notLinkedYet": "Non ancora collegato a un biomarcatore",
"sourceRange": {
"fromReport": "Intervallo del referto {range}",
"catalogDiffers": "il tuo intervallo {range}"
}
},
"modules": {
"cycle": {
+21 -5
View File
@@ -281,7 +281,9 @@
"familyHistoryTitle": "Wywiad rodzinny",
"familyHistoryColRelationship": "Pokrewieństwo",
"familyHistoryColCondition": "Schorzenie",
"familyHistoryColAgeAtOnset": "Wiek zachorowania"
"familyHistoryColAgeAtOnset": "Wiek zachorowania",
"labsSourceRangeNote": "* Zakres referencyjny pochodzi z samego wyniku i różni się od zapisanego zakresu:",
"labsCatalogRangeLabel": "zapisany zakres"
},
"errorBoundary": {
"title": "Coś poszło nie tak",
@@ -8037,7 +8039,12 @@
"qualitativePlaceholder": "np. ujemny",
"qualNegative": "ujemny",
"qualPositive": "dodatni",
"qualBorderline": "graniczny"
"qualBorderline": "graniczny",
"sourceRange": "Zakres wydrukowany na wyniku",
"sourceRangePlaceholder": "np. 3,5 - 5,0 lub < 5",
"sourceRangeAdd": "+ Dodaj zakres z wyniku",
"sourceRangeHint": "Opcjonalne. Dotyczy tylko tego pomiaru; zapisany zakres pozostaje bez zmian.",
"sourceRangeUnparsed": "Zapisane bez zmian — nie odczytano z tego zakresu liczbowego."
},
"backToLabs": "Wróć do wyników",
"hasNote": "Notatka",
@@ -8068,7 +8075,10 @@
"90": "90 d",
"365": "1 r",
"all": "Wszystko"
}
},
"sourceRangeLabel": "Zakres z wyniku",
"sourceBandLegend": "Drugie pasmo: zakres wydrukowany na wyniku z {date}.",
"sourceBandMixed": "Drugie pasmo: zakres z najnowszego wyniku. Wcześniejsze wyniki w tym okresie podają inne zakresy."
},
"biomarker": {
"manageTitle": "Biomarkery",
@@ -8243,11 +8253,17 @@
"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."
"pdfNeedsAnthropic": "Skanowanie PDF wymaga dostawcy wizji Claude; prześlij zamiast tego zdjęcie.",
"refTextLabel": "Zakres tak jak wydrukowano",
"refTextPlaceholder": "np. 3,5 - 5,0"
},
"loadMoreReadings": "Wczytaj więcej pomiarów",
"documentsLink": "Dokumenty wyników badań",
"notLinkedYet": "Jeszcze niepowiązane z biomarkerem"
"notLinkedYet": "Jeszcze niepowiązane z biomarkerem",
"sourceRange": {
"fromReport": "Zakres z wyniku {range}",
"catalogDiffers": "twój zakres {range}"
}
},
"modules": {
"cycle": {
@@ -0,0 +1,14 @@
-- Per-reading source reference range on a lab result.
--
-- A lab report prints the window its own method and device produce, right
-- beside the value, and that window is what a physician evaluates against.
-- Until now the document reading pulled the value and dropped the range.
-- These three columns carry it: the derived numeric bounds where the printed
-- string yields them, plus the report's verbatim string, which is stored
-- whether or not it parsed. Nothing is backfilled — an existing row genuinely
-- has no source range on file and stays NULL.
ALTER TABLE "lab_results"
ADD COLUMN "source_reference_low" DOUBLE PRECISION,
ADD COLUMN "source_reference_high" DOUBLE PRECISION,
ADD COLUMN "source_reference_text" TEXT;
+33 -1
View File
@@ -5005,9 +5005,41 @@ model LabResult {
/// reference bounds + range verdict are meaningless for a qualitative row.
valueText String? @map("value_text")
unit String
/// Optional reference-range bounds as reported by the lab.
/// Reference-range bounds stamped onto the row at write time. Despite the
/// name these are NOT the lab's printed range: since v1.18.1 every write
/// path copies the linked `Biomarker`'s `lowerBound` / `upperBound` here as
/// historical truth, so a later catalog edit does not silently rewrite a
/// past reading. The range the LAB printed lives in `sourceReference*`.
referenceLow Float? @map("reference_low")
referenceHigh Float? @map("reference_high")
/// The reference range as PRINTED ON THE SOURCE REPORT for this one reading.
///
/// Labs run different methods on different devices, so the window a
/// physician evaluates against is the one printed beside the value on that
/// report — not a catalog band that averages every lab. These three columns
/// carry it: the parsed numeric bounds where a bound could be derived, and
/// `sourceReferenceText` as the report's own verbatim string ("< 5",
/// "bis 5,0", "negativ", "siehe Befund"). The text is kept whenever the
/// source printed one, parseable or not — absence reads as absence, and a
/// window that could not be derived is never invented.
///
/// Precedence: for a single reading the source bounds win over the catalog
/// band; the catalog stays the net for readings that carry no source range.
/// `resolveEffectiveReferenceRange` in `src/lib/labs/reference-range.ts` is
/// the one place that rule is expressed, and every surface that judges a
/// reading routes through it.
///
/// Plaintext, not `*Encrypted`: that suffix marks user prose (`noteEncrypted`
/// here, `Biomarker.contextEncrypted` next door). This is a structured
/// lab-report field of exactly the class of the plaintext `analyte` / `unit`
/// / `value` / `valueText` columns beside it — `valueText` already carries
/// the actual clinical RESULT ("positiv") in the clear, so encrypting the
/// window that result is judged against would protect strictly less while
/// making the column unreadable to the SQL the read paths run.
sourceReferenceLow Float? @map("source_reference_low")
sourceReferenceHigh Float? @map("source_reference_high")
sourceReferenceText String? @map("source_reference_text")
/// When the sample was taken (UTC instant; wall-clock anchoring is the
/// reader's concern per the schema's UTC-only convention).
takenAt DateTime @map("taken_at")
@@ -724,6 +724,13 @@ const OCR_EXCEPTIONS: Readonly<Record<string, string>> = {
"inserted.unit": OCR_ROW_EXCEPTION,
"inserted.referenceLow": OCR_ROW_EXCEPTION,
"inserted.referenceHigh": OCR_ROW_EXCEPTION,
"inserted.catalogReferenceLow": OCR_ROW_EXCEPTION,
"inserted.catalogReferenceHigh": OCR_ROW_EXCEPTION,
"inserted.sourceReferenceLow": OCR_ROW_EXCEPTION,
"inserted.sourceReferenceHigh": OCR_ROW_EXCEPTION,
"inserted.sourceReferenceText": OCR_ROW_EXCEPTION,
"inserted.referenceOrigin": OCR_ROW_EXCEPTION,
"inserted.referenceDivergesFromCatalog": OCR_ROW_EXCEPTION,
"inserted.takenAt": OCR_ROW_EXCEPTION,
"inserted.source": OCR_ROW_EXCEPTION,
"inserted.hasNote": OCR_ROW_EXCEPTION,
@@ -894,6 +894,9 @@ const handler = apiHandler(
unit: lab.unit,
referenceLow: lab.referenceLow ?? null,
referenceHigh: lab.referenceHigh ?? null,
sourceReferenceLow: lab.sourceReferenceLow ?? null,
sourceReferenceHigh: lab.sourceReferenceHigh ?? null,
sourceReferenceText: lab.sourceReferenceText ?? null,
takenAt: new Date(lab.takenAt),
source: lab.source,
noteEncrypted:
@@ -57,6 +57,7 @@ function editToFactData(edit: InboundFactEdit): FactData {
unit: edit.unit ?? null,
referenceLow: edit.referenceLow ?? null,
referenceHigh: edit.referenceHigh ?? null,
referenceText: edit.referenceText ?? null,
effectiveDate: edit.effectiveDate ?? null,
};
return data;
@@ -46,6 +46,9 @@ export const GET = apiHandler(async () => {
value: true,
referenceLow: true,
referenceHigh: true,
sourceReferenceLow: true,
sourceReferenceHigh: true,
sourceReferenceText: true,
takenAt: true,
},
orderBy: { takenAt: "desc" },
@@ -58,6 +61,9 @@ export const GET = apiHandler(async () => {
value: r.value as number,
referenceLow: r.referenceLow,
referenceHigh: r.referenceHigh,
sourceReferenceLow: r.sourceReferenceLow,
sourceReferenceHigh: r.sourceReferenceHigh,
sourceReferenceText: r.sourceReferenceText,
takenAt: r.takenAt,
})),
);
+35
View File
@@ -143,6 +143,12 @@ export const PUT = apiHandler(
// historical columns the serialiser ignores. Reject the attempt with a
// 422 rather than silently no-op, so the client never believes an edit
// landed. Edit such fields on the Biomarker itself instead.
//
// `sourceReference*` is deliberately NOT on that list. The catalog band is
// a property of the marker; the source window is a property of THIS
// reading — it is what one report printed beside one value — so it is
// editable on a linked row and there is nothing on the biomarker to edit
// it on instead.
const isLinked = existing.biomarkerId !== null;
if (
isLinked &&
@@ -194,6 +200,15 @@ export const PUT = apiHandler(
if (d.unit !== undefined) data.unit = d.unit;
if (d.referenceLow !== undefined) data.referenceLow = d.referenceLow;
if (d.referenceHigh !== undefined) data.referenceHigh = d.referenceHigh;
if (d.sourceReferenceLow !== undefined) {
data.sourceReferenceLow = d.sourceReferenceLow;
}
if (d.sourceReferenceHigh !== undefined) {
data.sourceReferenceHigh = d.sourceReferenceHigh;
}
if (d.sourceReferenceText !== undefined) {
data.sourceReferenceText = d.sourceReferenceText;
}
if (d.takenAt !== undefined) data.takenAt = d.takenAt;
// Inverted-range guard for a PARTIAL bound update. The schema-level
@@ -212,6 +227,20 @@ export const PUT = apiHandler(
errorCode: "labs.update.referenceRangeInvalid",
});
}
// The same partial-update hole exists on the source window, and closes the
// same way.
if (
isInvertedRange(
effectiveBound(d.sourceReferenceLow, existing.sourceReferenceLow),
effectiveBound(d.sourceReferenceHigh, existing.sourceReferenceHigh),
)
) {
return apiError(
"sourceReferenceLow must not exceed sourceReferenceHigh",
422,
{ errorCode: "labs.update.sourceReferenceRangeInvalid" },
);
}
if (d.note !== undefined) {
data.noteEncrypted = d.note ? encryptNoteToBytes(d.note) : null;
}
@@ -239,6 +268,9 @@ export const PUT = apiHandler(
panel: existing.panel,
referenceLow: existing.referenceLow,
referenceHigh: existing.referenceHigh,
sourceReferenceLow: existing.sourceReferenceLow,
sourceReferenceHigh: existing.sourceReferenceHigh,
sourceReferenceText: existing.sourceReferenceText,
takenAt: existing.takenAt,
},
after: {
@@ -249,6 +281,9 @@ export const PUT = apiHandler(
panel: updated.panel,
referenceLow: updated.referenceLow,
referenceHigh: updated.referenceHigh,
sourceReferenceLow: updated.sourceReferenceLow,
sourceReferenceHigh: updated.sourceReferenceHigh,
sourceReferenceText: updated.sourceReferenceText,
takenAt: updated.takenAt,
},
redacted: d.note !== undefined ? ["note"] : [],
+13 -2
View File
@@ -33,6 +33,7 @@ import { withIdempotency } from "@/lib/idempotency";
import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy";
import { emitDataArrival } from "@/lib/arrivals/emit-shared";
import { resolveOrMintBiomarker } from "@/lib/labs/biomarker-store";
import { parseReferenceRange } from "@/lib/labs/parse-reference-range";
import { serialiseLabResult } from "@/lib/labs/serialise";
import {
linkOcrLabsToVaultDocument,
@@ -151,12 +152,19 @@ async function commitOcrRows(request: NextRequest) {
}
const isQualitative = row.valueText !== undefined;
// The window the scan read off the report. The confirmed numeric bounds
// win over a re-parse of the string (the human may have corrected them on
// the review screen); the string rides along either way.
const printed = parseReferenceRange(row.referenceText, row.unit);
const sourceLow = row.referenceLow ?? printed?.low ?? null;
const sourceHigh = row.referenceHigh ?? printed?.high ?? null;
const sourceText = printed?.text ?? null;
const biomarker = await resolveOrMintBiomarker(user.id, {
analyte: row.analyte,
// A qualitative reading has no numeric unit / range.
unit: isQualitative ? (row.unit ?? "") : (row.unit as string),
referenceLow: isQualitative ? null : (row.referenceLow ?? null),
referenceHigh: isQualitative ? null : (row.referenceHigh ?? null),
referenceLow: isQualitative ? null : sourceLow,
referenceHigh: isQualitative ? null : sourceHigh,
panel: row.panel ?? null,
});
@@ -173,6 +181,9 @@ async function commitOcrRows(request: NextRequest) {
unit: biomarker.unit,
referenceLow: biomarker.lowerBound,
referenceHigh: biomarker.upperBound,
sourceReferenceLow: isQualitative ? null : sourceLow,
sourceReferenceHigh: isQualitative ? null : sourceHigh,
sourceReferenceText: isQualitative ? null : sourceText,
takenAt: row.takenAt,
source: "OCR",
noteEncrypted: null,
+15 -1
View File
@@ -184,6 +184,9 @@ async function postLabResult(request: NextRequest) {
unit,
referenceLow,
referenceHigh,
sourceReferenceLow,
sourceReferenceHigh,
sourceReferenceText,
takenAt,
note,
source,
@@ -248,6 +251,13 @@ async function postLabResult(request: NextRequest) {
unit: biomarker.unit,
referenceLow: biomarker.lowerBound,
referenceHigh: biomarker.upperBound,
// The window this reading's own report printed, kept beside the catalog
// stamp rather than folded into it: it governs THIS reading's verdict,
// and the catalog band stays the net for every reading without one. A
// qualitative reading has no window to record.
sourceReferenceLow: isQualitative ? null : (sourceReferenceLow ?? null),
sourceReferenceHigh: isQualitative ? null : (sourceReferenceHigh ?? null),
sourceReferenceText: isQualitative ? null : (sourceReferenceText ?? null),
takenAt,
// v1.25 (iOS #36) — provenance: the on-device-OCR path posts
// `source: "OCR"`; an omitted field stays "MANUAL" so the legacy
@@ -258,6 +268,7 @@ async function postLabResult(request: NextRequest) {
},
});
invalidateUserHealthScore(user.id);
const dto = serialiseLabResult(created, biomarker);
await auditLog("labResult.create", {
userId: user.id,
@@ -275,6 +286,9 @@ async function postLabResult(request: NextRequest) {
structured: biomarkerId !== undefined,
// v1.25 (iOS #36) — provenance of the reading ("MANUAL" | "OCR").
source: source ?? "MANUAL",
// Which window governs this reading's verdict, so a dashboard can see
// how often a report's own range displaces the catalog band.
referenceOrigin: dto.referenceOrigin,
},
});
@@ -298,5 +312,5 @@ async function postLabResult(request: NextRequest) {
source: "manual",
}).catch(() => {});
return apiSuccess(serialiseLabResult(created, biomarker), 201);
return apiSuccess(dto, 201);
}
@@ -64,6 +64,13 @@ function reading(id: string, value: number, takenAt: string): LabResultDto {
unit: "mg/dL",
referenceLow: 0,
referenceHigh: 100,
catalogReferenceLow: 0,
catalogReferenceHigh: 100,
sourceReferenceLow: null,
sourceReferenceHigh: null,
sourceReferenceText: null,
referenceOrigin: "catalog",
referenceDivergesFromCatalog: false,
takenAt,
source: "MANUAL",
hasNote: false,
+128 -1
View File
@@ -17,6 +17,7 @@ import { Button } from "@/components/ui/button";
import { CHART_HEIGHT_PX } from "@/lib/charts/constants";
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
import { formatLabValue } from "@/lib/labs/format-value";
import { formatReferenceRange } from "@/lib/labs/reference-range";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { RichChartTooltip, type RichTooltipRow } from "../charts/chart-tooltip";
@@ -32,6 +33,15 @@ import type { LabResultDto } from "./types";
* MUTED primary fill — never a danger colour — honouring the no-alarming-
* colour rule. Every reading carries the same y-axis unit because a chart is
* one biomarker only.
*
* A SECOND band draws the window a source report printed, when the readings in
* view carry one. It is visually distinct from the catalog band — an outlined
* band in the chart's secondary hue against the catalog's filled primary — and
* both stay muted; the in/out verdict is the badge's job, never the chart's
* colour. The source window is a per-reading fact, so the band is drawn from
* the NEWEST reading in view that carries one and the legend dates it. When
* the readings in view carry more than one distinct printed window the legend
* says so rather than implying one window covered the whole series.
*/
const RANGE_DAYS = [
@@ -46,6 +56,26 @@ type RangeKey = (typeof RANGE_DAYS)[number]["key"];
interface ChartPoint {
timestamp: number;
value: number;
/** The window this reading was judged against, for the tooltip. */
referenceLow: number | null;
referenceHigh: number | null;
referenceOrigin: LabResultDto["referenceOrigin"];
sourceReferenceText: string | null;
}
/** The source window drawn as the second band, resolved from the readings in view. */
interface SourceBand {
low: number | null;
high: number | null;
/** The reading the band was taken from. */
takenAt: number;
/** True when the readings in view printed more than one distinct window. */
mixed: boolean;
}
/** Identity of a printed window, for counting distinct ones. */
function bandKey(low: number | null, high: number | null): string {
return `${low ?? ""}|${high ?? ""}`;
}
export function LabBiomarkerChart({
@@ -87,9 +117,37 @@ export function LabBiomarkerChart({
return filtered.map((r) => ({
timestamp: new Date(r.takenAt).getTime(),
value: r.value,
referenceLow: r.referenceLow,
referenceHigh: r.referenceHigh,
referenceOrigin: r.referenceOrigin,
sourceReferenceText: r.sourceReferenceText,
}));
}, [readings, range, nowMs]);
// The source band: the newest in-view reading whose report printed a window
// with usable bounds. Readings without one contribute nothing — an absent
// window is absent, not a reason to widen or narrow the band.
const sourceBand = useMemo<SourceBand | null>(() => {
const withWindow = points.filter(
(p) =>
p.referenceOrigin === "source" &&
(p.referenceLow !== null || p.referenceHigh !== null),
);
if (withWindow.length === 0) return null;
const newest = withWindow.reduce((a, b) =>
b.timestamp > a.timestamp ? b : a,
);
const distinct = new Set(
withWindow.map((p) => bandKey(p.referenceLow, p.referenceHigh)),
);
return {
low: newest.referenceLow,
high: newest.referenceHigh,
takenAt: newest.timestamp,
mixed: distinct.size > 1,
};
}, [points]);
// Y domain wraps both the data and the reference window so the band is
// always visible even when every reading sits inside it.
const yDomain = useMemo<[number, number]>(() => {
@@ -97,15 +155,23 @@ export function LabBiomarkerChart({
const candidates = [...values];
if (lowerBound != null) candidates.push(lowerBound);
if (upperBound != null) candidates.push(upperBound);
// The source band has to fit too, or the second band is drawn off-canvas
// and the chart silently shows one window while claiming two.
if (sourceBand?.low != null) candidates.push(sourceBand.low);
if (sourceBand?.high != null) candidates.push(sourceBand.high);
if (candidates.length === 0) return [0, 1];
const min = Math.min(...candidates);
const max = Math.max(...candidates);
const pad = (max - min || Math.abs(max) || 1) * 0.1;
return [min - pad, max + pad];
}, [points, lowerBound, upperBound]);
}, [points, lowerBound, upperBound, sourceBand]);
const animate = !prefersReducedMotion();
const primary = "var(--primary)";
// A second muted hue from the chart palette. Distinct from the primary band
// by hue AND by treatment (outline vs fill), so the two are told apart
// without relying on colour alone.
const secondary = "var(--chart-2)";
return (
<div className="space-y-3">
@@ -185,6 +251,42 @@ export function LabBiomarkerChart({
strokeDasharray="4 4"
/>
) : null}
{/* The source window — outlined, not filled, so it reads as a
second band over the catalog one rather than a second fill
competing with it. Same no-alarm-colour rule. */}
{sourceBand &&
sourceBand.low != null &&
sourceBand.high != null ? (
<ReferenceArea
y1={sourceBand.low}
y2={sourceBand.high}
fill={secondary}
fillOpacity={0.06}
stroke={secondary}
strokeOpacity={0.5}
strokeDasharray="2 3"
/>
) : null}
{sourceBand &&
sourceBand.low != null &&
sourceBand.high == null ? (
<ReferenceLine
y={sourceBand.low}
stroke={secondary}
strokeOpacity={0.6}
strokeDasharray="2 3"
/>
) : null}
{sourceBand &&
sourceBand.high != null &&
sourceBand.low == null ? (
<ReferenceLine
y={sourceBand.high}
stroke={secondary}
strokeOpacity={0.6}
strokeDasharray="2 3"
/>
) : null}
<Tooltip
content={(props) => {
const active = props.active ?? false;
@@ -201,6 +303,22 @@ export function LabBiomarkerChart({
color: primary,
},
];
// Name the window THIS reading was judged against. Two
// readings on one chart can carry different printed windows,
// so the band alone cannot answer it per point.
if (point.referenceOrigin === "source") {
rows.push({
name: t("labs.chart.sourceRangeLabel"),
value:
point.sourceReferenceText ??
`${formatReferenceRange(
point.referenceLow,
point.referenceHigh,
formatLabValue,
)} ${unit}`.trim(),
color: secondary,
});
}
return (
<RichChartTooltip
active
@@ -221,6 +339,15 @@ export function LabBiomarkerChart({
/>
</ComposedChart>
</ResponsiveContainer>
{sourceBand ? (
<p className="text-muted-foreground mt-2 text-xs">
{sourceBand.mixed
? t("labs.chart.sourceBandMixed")
: t("labs.chart.sourceBandLegend", {
date: fmt.dateShortSmart(new Date(sourceBand.takenAt)),
})}
</p>
) : null}
</div>
)}
</div>
+73
View File
@@ -22,6 +22,7 @@ import {
import { Textarea } from "@/components/ui/textarea";
import { apiGet, apiPost } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { parseReferenceRange } from "@/lib/labs/parse-reference-range";
import { formatReferenceRange } from "@/lib/labs/reference-range";
import { formatLabValue } from "@/lib/labs/format-value";
import { useTranslations } from "@/lib/i18n/context";
@@ -118,6 +119,13 @@ export function LabForm({
const [valueText, setValueText] = useState("");
const [takenAt, setTakenAt] = useState(defaultTakenAtValue);
const [note, setNote] = useState("");
// The window the paper report printed for THIS reading. Optional and folded
// away by default: most entries are typed against a catalog marker and never
// need it, and a field nobody fills is a field in the way. One text input,
// because that is how a report states it — "3,5 - 5,0", "< 5", "bis 5,0".
// The bounds are derived from what is typed; what is typed is kept verbatim.
const [sourceRangeOpen, setSourceRangeOpen] = useState(false);
const [sourceRange, setSourceRange] = useState("");
// v1.30.1 H3 — which submit button is in flight, so the OTHER one doesn't
// also show a spinner. `!== null` replaces the old plain `submitting`
// boolean for every disabled check below.
@@ -193,11 +201,27 @@ export function LabForm({
setPendingAction(keepOpen ? "saveAndAddAnother" : "save");
try {
// Parse client-side so the user sees the derived window before saving,
// and send BOTH: the bounds where they could be read, the text always.
const printed = isQualitative
? null
: parseReferenceRange(sourceRange, selected?.unit);
const created = await apiPost<LabResultDto>("/api/labs", {
biomarkerId,
...(isQualitative
? { valueText: qualitativeValue }
: { value: numericValue }),
...(printed
? {
...(printed.low !== null
? { sourceReferenceLow: printed.low }
: {}),
...(printed.high !== null
? { sourceReferenceHigh: printed.high }
: {}),
sourceReferenceText: printed.text,
}
: {}),
takenAt: new Date(takenAt).toISOString(),
...(note.trim() ? { note: note.trim() } : {}),
});
@@ -221,6 +245,8 @@ export function LabForm({
setValue("");
setValueText("");
setNote("");
// The next analyte off the same report has its own printed window.
setSourceRange("");
setError(null);
onSavedKeepOpen?.(created);
// Return focus to wherever the next entry starts so the flow
@@ -252,6 +278,20 @@ export function LabForm({
)
: "";
// What the typed string resolved to, shown back so the user can see whether
// a window was read out of it before the reading is saved. When none was,
// the hint says the text is kept as written rather than staying silent.
const parsedSourceRange = parseReferenceRange(sourceRange, selected?.unit);
const sourceRangeHint = parsedSourceRange
? parsedSourceRange.low === null && parsedSourceRange.high === null
? t("labs.form.sourceRangeUnparsed")
: `${formatReferenceRange(
parsedSourceRange.low,
parsedSourceRange.high,
formatLabValue,
)} ${selected?.unit ?? ""}`.trim()
: "";
const footerNode = (
<>
{onCancel ? (
@@ -415,6 +455,39 @@ export function LabForm({
</p>
) : null}
{resultType === "numeric" ? (
<div className="space-y-1.5">
{sourceRangeOpen ? (
<>
<Label htmlFor="lab-sourceRange">
{t("labs.form.sourceRange")}
</Label>
<Input
id="lab-sourceRange"
value={sourceRange}
onChange={(e) => setSourceRange(e.target.value)}
placeholder={t("labs.form.sourceRangePlaceholder")}
maxLength={120}
autoFocus
/>
<p className="text-muted-foreground text-xs">
{sourceRangeHint || t("labs.form.sourceRangeHint")}
</p>
</>
) : (
<Button
type="button"
variant="ghost"
size="sm"
className="text-muted-foreground h-auto px-0 text-xs"
onClick={() => setSourceRangeOpen(true)}
>
{t("labs.form.sourceRangeAdd")}
</Button>
)}
</div>
) : null}
<div className="space-y-1.5">
<Label htmlFor="lab-note">{t("labs.form.note")}</Label>
<Textarea
+8 -1
View File
@@ -35,6 +35,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { ReferenceRangeBadge } from "./reference-range-badge";
import { SourceRangeNote } from "./source-range-note";
import type { LabResultDetailDto, LabResultDto } from "./types";
const NOTE_MAX_LENGTH = 2000;
@@ -356,7 +357,13 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
{formatDateShort(r.takenAt, true)}
</TableCell>
<TableCell>
<ReferenceRangeBadge status={r.rangeStatus} />
<div className="flex flex-col items-start gap-1">
<ReferenceRangeBadge status={r.rangeStatus} />
<SourceRangeNote
reading={r}
className="text-muted-foreground text-xs"
/>
</div>
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{r.hasNote ? t("labs.hasNote") : ""}
+5
View File
@@ -23,6 +23,7 @@ import { applyOrder, useModuleListPrefs } from "@/lib/module-list-prefs";
import { LabTrendSparkline } from "./lab-trend-sparkline";
import { ReferenceRangeBadge } from "./reference-range-badge";
import { SourceRangeNote } from "./source-range-note";
import type { LabResultDto, LabResultListResponse } from "./types";
interface MarkerGroup {
@@ -316,6 +317,10 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
)}
</span>
) : null}
<SourceRangeNote
reading={group.latest}
className="text-xs"
/>
<span className="text-xs">
{formatDate(group.latest.takenAt)}
</span>
@@ -76,6 +76,7 @@ function toCommitRow(row: OcrReviewRow): OcrCommitRowInput | null {
takenAt: takenAt.toISOString(),
...(row.referenceLow !== null ? { referenceLow: row.referenceLow } : {}),
...(row.referenceHigh !== null ? { referenceHigh: row.referenceHigh } : {}),
...(row.referenceText ? { referenceText: row.referenceText } : {}),
};
}
+3
View File
@@ -16,6 +16,8 @@ export interface OcrReviewRow {
unit: string | null;
referenceLow: number | null;
referenceHigh: number | null;
/** The reference range as printed on the scanned report, verbatim. */
referenceText: string | null;
/** ISO calendar day (YYYY-MM-DD) or null. */
takenAt: string | null;
confidence: OcrExtractedRowDto["confidence"];
@@ -39,6 +41,7 @@ export function seedReviewRows(
unit: row.unit,
referenceLow: row.referenceLow,
referenceHigh: row.referenceHigh,
referenceText: row.referenceText,
takenAt: row.takenAt ?? fallbackDate,
confidence: row.confidence,
biomarkerMatch: row.biomarkerMatch,
+26
View File
@@ -247,6 +247,32 @@ export function OcrRowEditor({
</div>
) : null}
{/* The window as the report printed it. Editable, because the human is
reviewing a transcription: the two bounds above are the derived
reading of this string, and correcting the string is how a range the
parser could not read ("bis 5,0", "negativ") still reaches the row
instead of being dropped. */}
{!isQualitative ? (
<div className="space-y-1">
<Label htmlFor={`${fieldId}-refText`} className="text-xs">
{t("labs.ocr.refTextLabel")}
</Label>
<Input
id={`${fieldId}-refText`}
value={row.referenceText ?? ""}
placeholder={t("labs.ocr.refTextPlaceholder")}
maxLength={120}
onChange={(e) => {
const raw = e.target.value;
onChange({
...row,
referenceText: raw.trim() === "" ? null : raw,
});
}}
/>
</div>
) : null}
{valueUnreadable ? (
<p className="text-muted-foreground flex items-center gap-1.5 text-xs">
<AlertCircle aria-hidden className="h-3.5 w-3.5" />
+69
View File
@@ -0,0 +1,69 @@
"use client";
import { formatLabValue } from "@/lib/labs/format-value";
import { formatReferenceRange } from "@/lib/labs/reference-range";
import { useTranslations } from "@/lib/i18n/context";
import type { LabResultDto } from "./types";
/**
* The reference window a reading's own report printed, shown wherever that
* reading is shown.
*
* Renders nothing for a reading with no printed window — most of them — so it
* costs no space on the common row. When the report DID state a window it
* names it, verbatim where the report gave words; and when that window and the
* catalog band disagree it names the catalog band too. A reader told only
* "3,9–5,4" and a reader told only "3,5–5,0" draw different conclusions from
* the same number, so a surface that shows one silently is showing a partial
* answer.
*
* Neutral by construction: no tint, no icon, muted text. The in/out verdict is
* the badge's job — this element states where the window came from, nothing
* about whether the value is good news.
*/
export function SourceRangeNote({
reading,
className,
}: {
reading: Pick<
LabResultDto,
| "unit"
| "referenceLow"
| "referenceHigh"
| "catalogReferenceLow"
| "catalogReferenceHigh"
| "sourceReferenceText"
| "referenceOrigin"
| "referenceDivergesFromCatalog"
>;
className?: string;
}) {
const { t } = useTranslations();
if (reading.referenceOrigin !== "source") return null;
const printed =
reading.sourceReferenceText ??
`${formatReferenceRange(
reading.referenceLow,
reading.referenceHigh,
formatLabValue,
)} ${reading.unit}`.trim();
const catalog = formatReferenceRange(
reading.catalogReferenceLow,
reading.catalogReferenceHigh,
formatLabValue,
);
return (
<span className={className}>
{t("labs.sourceRange.fromReport", { range: printed })}
{reading.referenceDivergesFromCatalog && catalog
? ` · ${t("labs.sourceRange.catalogDiffers", {
range: `${catalog} ${reading.unit}`.trim(),
})}`
: null}
</span>
);
}
+21 -1
View File
@@ -1,4 +1,7 @@
import type { ReferenceRangeStatus } from "@/lib/validations/labs";
import type {
ReferenceRangeOrigin,
ReferenceRangeStatus,
} from "@/lib/validations/labs";
/** A lab-result row as the list / create endpoints serialise it. */
export interface LabResultDto {
@@ -12,8 +15,25 @@ export interface LabResultDto {
/** v1.18.9 — qualitative result text ("negativ" / …); null for a numeric row. */
valueText: string | null;
unit: string;
/**
* The window this reading is judged against and the one `rangeStatus` was
* computed from: the range its own report printed when it carries one, the
* catalog band otherwise. Render these and the range shown always matches
* the verdict shown.
*/
referenceLow: number | null;
referenceHigh: number | null;
/** The catalog marker's band, so both windows can be drawn. */
catalogReferenceLow: number | null;
catalogReferenceHigh: number | null;
/** The window the source report printed; the text verbatim, bounds when derivable. */
sourceReferenceLow: number | null;
sourceReferenceHigh: number | null;
sourceReferenceText: string | null;
/** Which window is in force. */
referenceOrigin: ReferenceRangeOrigin;
/** True when the report's window and the catalog band state different limits. */
referenceDivergesFromCatalog: boolean;
takenAt: string;
source: string;
hasNote: boolean;
+1
View File
@@ -33,6 +33,7 @@ export interface OcrCommitRowInput {
unit?: string;
referenceLow?: number;
referenceHigh?: number;
referenceText?: string;
takenAt: string;
}
+11 -10
View File
@@ -37,7 +37,7 @@
* is always owner-scoped and safe to read), so the block is `null` only when the
* account has no recent readings.
*/
import { classifyReferenceRange } from "@/lib/labs/reference-range";
import { classifyAgainstEffectiveRange } from "@/lib/labs/reference-range";
import { resolveLabFields } from "@/lib/labs/serialise";
import { sanitizeForPrompt } from "@/lib/insights/sanitize";
import { prisma } from "@/lib/db";
@@ -131,6 +131,9 @@ export async function buildLabsSnapshotBlock(
unit: true,
referenceLow: true,
referenceHigh: true,
sourceReferenceLow: true,
sourceReferenceHigh: true,
sourceReferenceText: true,
takenAt: true,
biomarkerId: true,
biomarker: {
@@ -181,15 +184,13 @@ export async function buildLabsSnapshotBlock(
referenceLow: resolved.referenceLow,
referenceHigh: resolved.referenceHigh,
// A qualitative row has nothing to compare against the bounds → neutral
// "unknown", never a fabricated verdict. A numeric row classifies.
rangeStatus:
row.value === null
? "unknown"
: classifyReferenceRange(
row.value,
resolved.referenceLow,
resolved.referenceHigh,
),
// "unknown", never a fabricated verdict. A numeric row classifies against
// the window in force for it — the report's own range when it carries
// one, the catalog band otherwise. Same rule as every other lab surface.
rangeStatus: classifyAgainstEffectiveRange(
row.value,
resolved.effectiveRange,
),
takenAt: row.takenAt.toISOString(),
});
@@ -20,10 +20,18 @@ export function buildClinicalRecordsNotesSection(
// v1.17.1 — structured lab-results section. Populated when the `labs`
// toggle is ON (default) and the user recorded at least one result in the
// window. One row per analyte (latest reading), with the lab's reference
// range and a NEUTRAL in/out-of-range marker — informative, never an
// alarming red. The marker is a quiet glyph (↓ / ↑ / —), not a colour, so
// the clinical PDF stays calm.
// window. One row per analyte (latest reading), with the reference range and
// a NEUTRAL in/out-of-range marker — informative, never an alarming red. The
// marker is a quiet glyph (↓ / ↑ / —), not a colour, so the clinical PDF
// stays calm.
//
// The reference column prints the window the reading was JUDGED against, and
// when that window came off the source report it prints the report's own
// string verbatim — a clinician comparing this table against the original
// report should read the same characters. A reading whose report window
// differs from the catalog band carries a footnote marker, and the catalog
// band is named beneath the table, because "3,9–5,4" and "3,5–5,0" disagree
// about the same number and a table that shows only one is a partial answer.
if (data.labResults && data.labResults.length > 0) {
y = ensureSpace(y, 6 + 18);
doc.setFontSize(14);
@@ -54,6 +62,23 @@ export function buildClinicalRecordsNotesSection(
}
};
// The reference cell: the report's own string when the report stated the
// window, else the formatted catalog band. A divergence gets a "*" the
// note under the table explains.
const referenceCell = (
lr: NonNullable<typeof data.labResults>[number],
): string => {
const printed =
lr.referenceOrigin === "source" && lr.sourceReferenceText
? lr.sourceReferenceText
: rangeText(lr.referenceLow, lr.referenceHigh);
return lr.referenceDivergesFromCatalog ? `${printed} *` : printed;
};
const diverging = data.labResults.filter(
(lr) => lr.referenceDivergesFromCatalog,
);
const labRows = data.labResults.map((lr) => {
// v1.18.9 — a qualitative reading (`value === null`) prints its result
// text in the value column and has no numeric range / status glyph.
@@ -63,7 +88,7 @@ export function buildClinicalRecordsNotesSection(
isQualitative
? (lr.valueText ?? "")
: `${num(lr.value as number)} ${lr.unit}`.trim(),
isQualitative ? "—" : rangeText(lr.referenceLow, lr.referenceHigh),
isQualitative ? "—" : referenceCell(lr),
isQualitative
? ""
: statusGlyph(lr.value as number, lr.referenceLow, lr.referenceHigh),
@@ -107,6 +132,30 @@ export function buildClinicalRecordsNotesSection(
y =
(doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable
.finalY + 8;
// Name every diverging marker's catalog band, so the reader can see both
// windows rather than being told only that they differ.
if (diverging.length > 0) {
y = ensureSpace(y, 4 + diverging.length * 4);
doc.setFontSize(8);
doc.setFont("helvetica", "normal");
doc.setTextColor(110, 110, 110);
doc.text(t("doctorReport.labsSourceRangeNote"), margin, y);
y += 4;
for (const lr of diverging) {
doc.text(
`* ${lr.analyte}: ${t("doctorReport.labsCatalogRangeLabel")} ${rangeText(
lr.catalogReferenceLow,
lr.catalogReferenceHigh,
)} ${lr.unit}`.trim(),
margin,
y,
);
y += 4;
}
doc.setTextColor(30, 30, 30);
y += 4;
}
}
// v1.18.1 P4 — illness / condition episodes overlapping the window. Present
+15
View File
@@ -241,8 +241,23 @@ export interface DoctorReportData {
*/
valueText: string | null;
unit: string;
/**
* The reference window this reading is judged against: the range printed
* on its own report when it carries one, the catalog band otherwise.
* Resolved server-side through `resolveEffectiveReferenceRange` so the PDF
* marker, the FHIR `referenceRange`, and the API verdict agree.
*/
referenceLow: number | null;
referenceHigh: number | null;
/** The catalog band, kept so the PDF can name a divergence. */
catalogReferenceLow: number | null;
catalogReferenceHigh: number | null;
/** The window the source report printed, verbatim; null when it printed none. */
sourceReferenceText: string | null;
/** Which of the two windows `referenceLow` / `referenceHigh` came from. */
referenceOrigin: "source" | "catalog" | "none";
/** True when the report's window and the catalog band state different limits. */
referenceDivergesFromCatalog: boolean;
/** ISO timestamp of the latest reading. */
takenAt: string;
/** Count of readings for this analyte in the window (≥ 1). */
+20 -2
View File
@@ -11,6 +11,7 @@
* Split out of the aggregator with the selection rework.
*/
import { prisma } from "@/lib/db";
import { resolveEffectiveReferenceRange } from "@/lib/labs/reference-range";
import { getEvent } from "@/lib/logging/context";
import { decryptAllergyReaction } from "@/lib/doctor-report-helpers";
import type { DoctorReportData } from "@/lib/doctor-report-types";
@@ -42,6 +43,9 @@ export async function loadLabResults(
unit: true,
referenceLow: true,
referenceHigh: true,
sourceReferenceLow: true,
sourceReferenceHigh: true,
sourceReferenceText: true,
takenAt: true,
},
});
@@ -52,14 +56,28 @@ export async function loadLabResults(
for (const r of rows) {
const key = r.analyte.toLowerCase();
const prev = byAnalyte.get(key);
// The row already stamps the catalog band it was written under; the
// reading's own report window, when present, is what the physician read
// the value against, so it wins here exactly as it does on every other
// lab surface.
const range = resolveEffectiveReferenceRange(
r.referenceLow,
r.referenceHigh,
r,
);
byAnalyte.set(key, {
panel: r.panel,
analyte: r.analyte,
value: r.value,
valueText: r.valueText,
unit: r.unit,
referenceLow: r.referenceLow,
referenceHigh: r.referenceHigh,
referenceLow: range.low,
referenceHigh: range.high,
catalogReferenceLow: range.catalogLow,
catalogReferenceHigh: range.catalogHigh,
sourceReferenceText: range.sourceText,
referenceOrigin: range.origin,
referenceDivergesFromCatalog: range.divergesFromCatalog,
takenAt: r.takenAt.toISOString(),
count: (prev?.count ?? 0) + 1,
});
+24 -2
View File
@@ -15,6 +15,12 @@
* as-needed medication with notifications off — a record of what the patient
* takes, never a prescription action. An Observation never carries a
* range-flag; the reference bounds ride along only as the document stated them.
*
* The window the report printed beside a value is carried onto the reading, not
* dropped. Until now it only seeded a freshly minted catalog marker, which meant
* that for every analyte the user already had, the range printed on the report
* was read and then thrown away. It now lands on the row's `sourceReference*`
* columns and governs that reading's verdict.
*/
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { prisma } from "@/lib/db";
@@ -22,6 +28,7 @@ import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
import { emitDataArrival } from "@/lib/arrivals/emit-shared";
import { decryptFactData } from "@/lib/documents/store";
import { resolveOrMintBiomarker } from "@/lib/labs/biomarker-store";
import { parseReferenceRange } from "@/lib/labs/parse-reference-range";
import type { ExtractedFact } from "@/generated/prisma/client";
import type {
ConditionFactData,
@@ -66,11 +73,21 @@ async function commitObservation(
"A numeric value needs a unit before it can be saved",
);
}
// The printed window, read once. `referenceText` is what the report wrote;
// the parser derives bounds from it where it can, and where it cannot the
// string still survives onto the row. A document staged before the field
// existed has no text, and the numeric bounds the model reported are then
// the whole of what it stated.
const printed = parseReferenceRange(data.referenceText, data.unit);
const sourceLow = printed?.low ?? data.referenceLow;
const sourceHigh = printed?.high ?? data.referenceHigh;
const sourceText = printed?.text ?? null;
const biomarker = await resolveOrMintBiomarker(userId, {
analyte: data.label,
unit: isQualitative ? (data.unit ?? "") : (data.unit as string),
referenceLow: isQualitative ? null : data.referenceLow,
referenceHigh: isQualitative ? null : data.referenceHigh,
referenceLow: isQualitative ? null : sourceLow,
referenceHigh: isQualitative ? null : sourceHigh,
panel: null,
});
// Fail closed on a unit mismatch against an EXISTING marker. A freshly
@@ -101,6 +118,11 @@ async function commitObservation(
unit: biomarker.unit,
referenceLow: biomarker.lowerBound,
referenceHigh: biomarker.upperBound,
// The report's own window for THIS reading. A qualitative result has no
// window to record.
sourceReferenceLow: isQualitative ? null : sourceLow,
sourceReferenceHigh: isQualitative ? null : sourceHigh,
sourceReferenceText: isQualitative ? null : sourceText,
takenAt: statedDateOrNow(data.effectiveDate),
source: "DOCUMENT",
noteEncrypted: null,
+12 -2
View File
@@ -74,7 +74,8 @@ Extract these fact types:
- code + codeSystem ("LOINC"): ONLY if a code is printed, else null.
- value: the numeric result OR null. valueText: the qualitative result text (e.g. "negative") OR null. Set EXACTLY ONE.
- unit: the printed unit, or null. NEVER convert or normalise a unit.
- referenceLow / referenceHigh: the printed reference bounds as numbers, or null. Do NOT flag the value against them.
- referenceText: the reference range EXACTLY as printed beside the value, verbatim and complete, including its operator and unit if written (for example "3,5 - 5,0", "< 5", "bis 5,0", "\u2265 3,5", "150.000 - 400.000 /\u00b5l", "negativ"). Copy the characters; do NOT reformat, convert, or normalise them. Null only when no range is printed for that value.
- referenceLow / referenceHigh: the SAME range reduced to two numbers, when it reduces to two numbers, else null. Do NOT flag the value against them.
- effectiveDate: the stated measurement date (YYYY-MM-DD), else null.
3. MEDICATION_STATEMENT — a medication the patient is/was taking, as recorded. Capture:
- label: the drug name, verbatim.
@@ -91,7 +92,7 @@ For EVERY fact also capture:
Do not emit a fact you cannot read — omit it rather than invent one. Also capture reportDate: the document's stated report/collection date (YYYY-MM-DD), or null, and kind: "DOCTOR_REPORT", "DISCHARGE_LETTER", or "OTHER".
Respond ONLY with a JSON object of this exact shape:
{ "reportDate": string|null, "kind": "DOCTOR_REPORT"|"DISCHARGE_LETTER"|"OTHER"|null, "facts": [ { "type": "CONDITION"|"OBSERVATION"|"MEDICATION_STATEMENT", "label": string, "code": string|null, "codeSystem": "SNOMED"|"ICD10"|"LOINC"|"RXNORM"|"ATC"|null, "clinicalStatus": string|null, "verificationStatus": string|null, "value": number|null, "valueText": string|null, "unit": string|null, "referenceLow": number|null, "referenceHigh": number|null, "dose": string|null, "medicationStatus": string|null, "effectiveDate": string|null, "sourceText": string, "page": number|null, "confidence": number } ] }`;
{ "reportDate": string|null, "kind": "DOCTOR_REPORT"|"DISCHARGE_LETTER"|"OTHER"|null, "facts": [ { "type": "CONDITION"|"OBSERVATION"|"MEDICATION_STATEMENT", "label": string, "code": string|null, "codeSystem": "SNOMED"|"ICD10"|"LOINC"|"RXNORM"|"ATC"|null, "clinicalStatus": string|null, "verificationStatus": string|null, "value": number|null, "valueText": string|null, "unit": string|null, "referenceText": string|null, "referenceLow": number|null, "referenceHigh": number|null, "dose": string|null, "medicationStatus": string|null, "effectiveDate": string|null, "sourceText": string, "page": number|null, "confidence": number } ] }`;
const USER_PROMPT = `Transcribe the clinical document in the attached file into the JSON schema described in the system prompt. Reproduce only what is written — do not interpret. Return only the JSON object.`;
@@ -172,6 +173,7 @@ function mapFactData(raw: ExtractedFactRaw): FactData | null {
unit: raw.unit,
referenceLow: raw.referenceLow,
referenceHigh: raw.referenceHigh,
referenceText: raw.referenceText,
effectiveDate,
};
return data;
@@ -309,6 +311,14 @@ export async function runInboundExtraction(
unanchored,
conditions: facts.filter((f) => f.factType === "CONDITION").length,
observations: facts.filter((f) => f.factType === "OBSERVATION").length,
// How many transcribed observations arrived with the window the report
// printed beside them. A collapse here is the extraction quietly going
// back to dropping the range.
observationsWithRange: facts.filter(
(f) =>
f.factType === "OBSERVATION" &&
((f.data as ObservationFactData).referenceText ?? null) !== null,
).length,
medications: facts.filter((f) => f.factType === "MEDICATION_STATEMENT")
.length,
needsReview: facts.filter((f) => f.needsReview).length,
+7
View File
@@ -54,6 +54,10 @@ export interface LabResultBackupEntry {
unit: string;
referenceLow: number | null;
referenceHigh: number | null;
/** The window the reading's own source report printed, verbatim + derived. */
sourceReferenceLow: number | null;
sourceReferenceHigh: number | null;
sourceReferenceText: string | null;
takenAt: string;
source: string;
/** Human-readable cross-reference into `biomarkers` below, not an id. */
@@ -336,6 +340,9 @@ export async function buildRecordsBackupSection(
unit: r.unit,
referenceLow: r.referenceLow,
referenceHigh: r.referenceHigh,
sourceReferenceLow: r.sourceReferenceLow,
sourceReferenceHigh: r.sourceReferenceHigh,
sourceReferenceText: r.sourceReferenceText,
takenAt: r.takenAt.toISOString(),
source: r.source,
biomarkerName: r.biomarker?.name ?? null,
@@ -54,6 +54,11 @@ describe("lab-result FHIR Observations", () => {
unit: "mg/dL",
referenceLow: null,
referenceHigh: 116,
catalogReferenceLow: null,
catalogReferenceHigh: null,
sourceReferenceText: null,
referenceOrigin: "catalog" as const,
referenceDivergesFromCatalog: false,
takenAt: "2026-04-20T08:00:00.000Z",
count: 2,
},
@@ -65,6 +70,11 @@ describe("lab-result FHIR Observations", () => {
unit: "%",
referenceLow: 4,
referenceHigh: 5.6,
catalogReferenceLow: null,
catalogReferenceHigh: null,
sourceReferenceText: null,
referenceOrigin: "catalog" as const,
referenceDivergesFromCatalog: false,
takenAt: "2026-04-21T08:00:00.000Z",
count: 1,
},
@@ -137,6 +147,11 @@ describe("lab-result FHIR Observations", () => {
unit: "mg/L",
referenceLow: null,
referenceHigh: null,
catalogReferenceLow: null,
catalogReferenceHigh: null,
sourceReferenceText: null,
referenceOrigin: "catalog" as const,
referenceDivergesFromCatalog: false,
takenAt: "2026-04-22T08:00:00.000Z",
count: 1,
},
@@ -178,6 +193,11 @@ describe("lab-result LOINC + canonical UCUM coding (v1.18.8)", () => {
unit,
referenceLow: null,
referenceHigh: null,
catalogReferenceLow: null,
catalogReferenceHigh: null,
sourceReferenceText: null,
referenceOrigin: "catalog" as const,
referenceDivergesFromCatalog: false,
takenAt: "2026-04-20T08:00:00.000Z",
count: 1,
},
@@ -264,6 +284,11 @@ describe("qualitative lab-result FHIR Observations (v1.18.9)", () => {
unit: "",
referenceLow: null,
referenceHigh: null,
catalogReferenceLow: null,
catalogReferenceHigh: null,
sourceReferenceText: null,
referenceOrigin: "catalog" as const,
referenceDivergesFromCatalog: false,
takenAt: "2026-04-20T08:00:00.000Z",
count: 1,
},
+14 -3
View File
@@ -7,8 +7,15 @@
* `code.text` for the common biomarkers, and stamps the canonical UCUM `code`
* on the value WHEN the recorded unit matches the mapped canonical symbol. An
* analyte that does NOT resolve keeps the honest text-only `code` +
* display-only `unit` (no fabricated terminology). The lab's reference bounds
* map onto R4 `referenceRange`.
* display-only `unit` (no fabricated terminology).
*
* The reference bounds map onto R4 `referenceRange`, and they are the SAME
* bounds the app's own verdict uses: the window the reading's source report
* printed when it carries one, the catalog band otherwise, already resolved
* upstream in `loadLabResults`. When the source report stated its window in
* words the bounds could not be read from ("negativ", "siehe Befund"), that
* string rides on `referenceRange.text` so a receiver still gets what the lab
* wrote rather than a silently empty element.
*/
import type { DoctorReportData } from "@/lib/doctor-report-data";
import { LOINC_SYSTEM, UCUM_SYSTEM } from "@/lib/fhir/loinc-map";
@@ -82,8 +89,11 @@ export function labObservations(
// The bounds are stated in the SAME unit as the value, so they carry the
// SAME coding — a bare unit beside a coded value leaves a receiver unable
// to compare the two.
const sourceText =
lab.referenceOrigin === "source" ? lab.sourceReferenceText : null;
const hasBounds = lab.referenceLow !== null || lab.referenceHigh !== null;
const referenceRange: FhirObservationReferenceRange[] | undefined =
lab.referenceLow !== null || lab.referenceHigh !== null
hasBounds || sourceText
? [
{
...(lab.referenceLow !== null
@@ -92,6 +102,7 @@ export function labObservations(
...(lab.referenceHigh !== null
? { high: quantity(lab.referenceHigh) }
: {}),
...(sourceText ? { text: sourceText } : {}),
},
]
: undefined;
+7
View File
@@ -117,6 +117,13 @@ export interface FhirObservation {
export interface FhirObservationReferenceRange {
low?: FhirQuantity;
high?: FhirQuantity;
/**
* R4 `referenceRange.text`. Carries the window the source report printed,
* verbatim, when the reading has one. A receiver that cannot use the bounds
* (because the report stated a qualitative window like "negativ", or a
* one-sided one) still reads what the lab wrote.
*/
text?: string;
}
export interface FhirDosage {
@@ -19,7 +19,14 @@ function row(
analyte: string,
value: number,
day: string,
opts: { unit?: string; low?: number | null; high?: number | null } = {},
opts: {
unit?: string;
low?: number | null;
high?: number | null;
sourceLow?: number | null;
sourceHigh?: number | null;
sourceText?: string | null;
} = {},
): LabChangeRow {
return {
analyte,
@@ -27,6 +34,9 @@ function row(
value,
referenceLow: opts.low ?? null,
referenceHigh: opts.high ?? null,
sourceReferenceLow: opts.sourceLow ?? null,
sourceReferenceHigh: opts.sourceHigh ?? null,
sourceReferenceText: opts.sourceText ?? null,
takenAt: new Date(`${day}T08:00:00.000Z`),
};
}
+27 -10
View File
@@ -29,7 +29,10 @@ import {
} from "@/lib/ai/prompts/biomarker";
import { openerArchetypeHint } from "@/lib/ai/prompts/opener-archetype";
import type { Locale } from "@/lib/i18n/config";
import { classifyReferenceRange } from "@/lib/labs/reference-range";
import {
classifyAgainstEffectiveRange,
resolveEffectiveReferenceRange,
} from "@/lib/labs/reference-range";
import { getNoKeyBiomarkerStatusText } from "@/lib/insights/no-key-fallbacks";
import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash";
import { runStatusCompletion } from "@/lib/insights/status-provider";
@@ -158,7 +161,17 @@ export async function generateBiomarkerStatus(args: {
},
orderBy: { takenAt: "desc" },
take: SNAPSHOT_READING_CAP,
select: { id: true, value: true, takenAt: true },
select: {
id: true,
value: true,
takenAt: true,
// The window this reading's own report printed, when it carries one.
// The verdict below is the reading's, not the marker's, so it has to
// read the reading's window — see `resolveEffectiveReferenceRange`.
sourceReferenceLow: true,
sourceReferenceHigh: true,
sourceReferenceText: true,
},
});
if (rows.length === 0) {
annotate({
@@ -174,6 +187,14 @@ export async function generateBiomarkerStatus(args: {
takenAt: r.takenAt,
}));
const latest = readings[0];
// The window the latest reading is judged against: its own report's range
// when that reading carries one, the catalog band otherwise. One rule, the
// same one the API DTO, the doctor report and the coach snapshot apply.
const latestRange = resolveEffectiveReferenceRange(
marker.lowerBound,
marker.upperBound,
rows[0],
);
const totalCount = await prisma.labResult.count({
where: {
userId: args.userId,
@@ -226,7 +247,7 @@ export async function generateBiomarkerStatus(args: {
locale,
promptVersion: PROMPT_VERSION,
count: totalCount,
bounds: { lower: marker.lowerBound, upper: marker.upperBound },
bounds: { lower: latestRange.low, upper: latestRange.high },
latest: {
id: latest.id,
takenAt: latest.takenAt.toISOString(),
@@ -252,11 +273,7 @@ export async function generateBiomarkerStatus(args: {
// Ascending series (oldest → newest) for the trend summary the prompt reads.
const ascending = [...readings].reverse();
const summary = summarizeSeries(ascending.map((r) => ({ value: r.value })));
const latestStatus = classifyReferenceRange(
latest.value,
marker.lowerBound,
marker.upperBound,
);
const latestStatus = classifyAgainstEffectiveRange(latest.value, latestRange);
const snapshot = {
locale,
@@ -266,8 +283,8 @@ export async function generateBiomarkerStatus(args: {
name: marker.name,
unit: marker.unit,
referenceRange:
marker.lowerBound != null || marker.upperBound != null
? { lower: marker.lowerBound, upper: marker.upperBound }
latestRange.low != null || latestRange.high != null
? { lower: latestRange.low, upper: latestRange.high }
: null,
},
dataCoverage: {
@@ -590,6 +590,9 @@ export async function fetchLabDraws(
unit: true,
referenceLow: true,
referenceHigh: true,
sourceReferenceLow: true,
sourceReferenceHigh: true,
sourceReferenceText: true,
panel: true,
value: true,
takenAt: true,
+8 -9
View File
@@ -10,7 +10,7 @@
* point (`extractFeatures`) stays in the hub.
*/
import { prisma } from "@/lib/db";
import { classifyReferenceRange } from "@/lib/labs/reference-range";
import { classifyAgainstEffectiveRange } from "@/lib/labs/reference-range";
import { calendarDaysUntil } from "@/lib/measurement-reminders/due-day";
import { resolveLabFields } from "@/lib/labs/serialise";
import { readRollupBuckets } from "@/lib/rollups/measurement-rollups";
@@ -164,6 +164,9 @@ export async function readLabsBriefingBlock(
valueText: true,
referenceLow: true,
referenceHigh: true,
sourceReferenceLow: true,
sourceReferenceHigh: true,
sourceReferenceText: true,
takenAt: true,
biomarkerId: true,
biomarker: {
@@ -198,14 +201,10 @@ export async function readLabsBriefingBlock(
for (const list of byMarker.values()) {
const latest = list[0];
const resolved = resolveLabFields(latest, latest.biomarker);
const rangeStatus =
latest.value === null
? ("unknown" as const)
: classifyReferenceRange(
latest.value,
resolved.referenceLow,
resolved.referenceHigh,
);
const rangeStatus = classifyAgainstEffectiveRange(
latest.value,
resolved.effectiveRange,
);
// Trend = latest numeric reading vs the immediately prior numeric reading.
let trend: "rising" | "falling" | "flat" | null = null;
+16 -9
View File
@@ -4,9 +4,11 @@
* Groups the user's numeric lab results by panel DATE (the calendar day of
* `takenAt`, UTC), finds the two most-recent dates, and for every analyte that
* appears in BOTH the latest and the previous panel reports the signed delta,
* the direction, and where the latest value sits against its reference band
* (`classifyReferenceRange`). Qualitative (valueText-only) results carry no
* numeric value, so they are skipped — a delta is meaningless for them.
* the direction, and where the latest value sits against the reference window
* in force for it — the range that reading's own report printed when it has
* one, the catalog band otherwise, resolved through the shared
* `resolveEffectiveReferenceRange`. Qualitative (valueText-only) results carry
* no numeric value, so they are skipped — a delta is meaningless for them.
*
* Pure + Prisma-free so the present / absent states are unit-testable: it is
* absent when there are fewer than two panel dates, no analyte is shared, or
@@ -14,12 +16,14 @@
* only — a delta is not a diagnosis.
*/
import {
classifyReferenceRange,
classifyAgainstEffectiveRange,
resolveEffectiveReferenceRange,
type ReferenceRangeStatus,
type SourceReferenceRange,
} from "@/lib/labs/reference-range";
/** A single numeric lab reading. */
export interface LabChangeRow {
/** A single numeric lab reading, with both windows it could be judged against. */
export interface LabChangeRow extends SourceReferenceRange {
analyte: string;
unit: string;
value: number;
@@ -150,10 +154,13 @@ export function summariseLabChanges(
previous: previousRow.value,
delta,
direction,
status: classifyReferenceRange(
status: classifyAgainstEffectiveRange(
latestRow.value,
latestRow.referenceLow,
latestRow.referenceHigh,
resolveEffectiveReferenceRange(
latestRow.referenceLow,
latestRow.referenceHigh,
latestRow,
),
),
});
}
@@ -0,0 +1,128 @@
import { describe, expect, it } from "vitest";
import {
classifyAgainstEffectiveRange,
resolveEffectiveReferenceRange,
} from "@/lib/labs/reference-range";
const NO_SOURCE = {
sourceReferenceLow: null,
sourceReferenceHigh: null,
sourceReferenceText: null,
};
describe("resolveEffectiveReferenceRange", () => {
it("falls back to the catalog band when the reading carries no source window", () => {
const eff = resolveEffectiveReferenceRange(3.5, 5, NO_SOURCE);
expect(eff).toMatchObject({
low: 3.5,
high: 5,
origin: "catalog",
catalogLow: 3.5,
catalogHigh: 5,
sourceText: null,
divergesFromCatalog: false,
});
});
it("reports no window when neither side states one", () => {
expect(resolveEffectiveReferenceRange(null, null, NO_SOURCE)).toMatchObject(
{ low: null, high: null, origin: "none", divergesFromCatalog: false },
);
});
it("lets the source window win over the catalog band for that reading", () => {
const eff = resolveEffectiveReferenceRange(3.5, 5, {
sourceReferenceLow: 3.9,
sourceReferenceHigh: 5.4,
sourceReferenceText: "3,9 - 5,4",
});
expect(eff).toMatchObject({
low: 3.9,
high: 5.4,
origin: "source",
catalogLow: 3.5,
catalogHigh: 5,
sourceText: "3,9 - 5,4",
divergesFromCatalog: true,
});
});
it("does not call an identical window a divergence", () => {
const eff = resolveEffectiveReferenceRange(3.5, 5, {
sourceReferenceLow: 3.5,
sourceReferenceHigh: 5,
sourceReferenceText: "3,5 - 5,0",
});
expect(eff.origin).toBe("source");
expect(eff.divergesFromCatalog).toBe(false);
});
it("takes a one-sided source window whole, not merged with the catalog", () => {
// A report that prints "< 5" states a ceiling and NO floor. Borrowing the
// catalog's floor would invent a window the report never stated.
const eff = resolveEffectiveReferenceRange(3.5, 5, {
sourceReferenceLow: null,
sourceReferenceHigh: 4.2,
sourceReferenceText: "< 4,2",
});
expect(eff).toMatchObject({
low: null,
high: 4.2,
origin: "source",
divergesFromCatalog: true,
});
});
it("does not displace the catalog for a printed window with no derivable bound", () => {
// "negativ" is carried through verbatim, but it is not an argument for
// discarding the catalog band.
const eff = resolveEffectiveReferenceRange(3.5, 5, {
sourceReferenceLow: null,
sourceReferenceHigh: null,
sourceReferenceText: "negativ",
});
expect(eff).toMatchObject({
low: 3.5,
high: 5,
origin: "catalog",
sourceText: "negativ",
divergesFromCatalog: false,
});
});
it("reports a source window as no divergence when the catalog has none", () => {
const eff = resolveEffectiveReferenceRange(null, null, {
sourceReferenceLow: 3.9,
sourceReferenceHigh: 5.4,
sourceReferenceText: "3,9 - 5,4",
});
expect(eff.origin).toBe("source");
expect(eff.divergesFromCatalog).toBe(false);
});
});
describe("classifyAgainstEffectiveRange", () => {
it("judges a reading by the source window, not the catalog band", () => {
// 5.2 sits inside the catalog band (3.5–5.0 would call it high) but the
// report that produced this value states 3.9–5.4, so it is in range.
const eff = resolveEffectiveReferenceRange(3.5, 5, {
sourceReferenceLow: 3.9,
sourceReferenceHigh: 5.4,
sourceReferenceText: "3,9 - 5,4",
});
expect(classifyAgainstEffectiveRange(5.2, eff)).toBe("in-range");
expect(classifyAgainstEffectiveRange(3.7, eff)).toBe("below");
expect(classifyAgainstEffectiveRange(5.5, eff)).toBe("above");
});
it("keeps the catalog verdict for a reading with no source window", () => {
const eff = resolveEffectiveReferenceRange(3.5, 5, NO_SOURCE);
expect(classifyAgainstEffectiveRange(5.2, eff)).toBe("above");
});
it("never classifies a qualitative reading", () => {
const eff = resolveEffectiveReferenceRange(3.5, 5, NO_SOURCE);
expect(classifyAgainstEffectiveRange(null, eff)).toBe("unknown");
});
});
@@ -0,0 +1,219 @@
import { describe, expect, it } from "vitest";
import { parseReferenceRange } from "@/lib/labs/parse-reference-range";
/**
* Real-shaped strings, taken from the notation German and English lab reports
* actually print. The parser's contract is asymmetric on purpose: deriving a
* bound is best-effort, KEEPING the printed string is mandatory. Every case
* below therefore asserts the verbatim text survives, and only the cases that
* can be read without guessing assert bounds.
*/
describe("parseReferenceRange — two-sided windows", () => {
it.each([
["3.5-5.0", 3.5, 5],
["3,5-5,0", 3.5, 5],
["3.5 - 5.0", 3.5, 5],
["3,5 – 5,0", 3.5, 5],
["3,5 — 5,0", 3.5, 5],
["3,5 bis 5,0", 3.5, 5],
["3.5 to 5.0", 3.5, 5],
["3,5 ... 5,0", 3.5, 5],
["3,5 .. 5,0", 3.5, 5],
["von 3,5 bis 5,0", 3.5, 5],
["[3,5 - 5,0]", 3.5, 5],
["(3.5-5.0)", 3.5, 5],
])("reads %s as %s..%s", (raw, low, high) => {
const parsed = parseReferenceRange(raw);
expect(parsed).toEqual({ low, high, text: raw.trim() });
});
it("reads a negative lower bound (base excess prints one)", () => {
expect(parseReferenceRange("-2,0 bis +3,0")).toEqual({
low: -2,
high: 3,
text: "-2,0 bis +3,0",
});
});
it("reads a German thousands separator as one number, not a decimal", () => {
// Platelets print as "150.000 - 400.000 /µl" on a German report. Reading
// the dot as a decimal point would yield a 150–400 window and silently
// flag every normal count as wildly high.
expect(parseReferenceRange("150.000 - 400.000")).toEqual({
low: 150000,
high: 400000,
text: "150.000 - 400.000",
});
});
it("reads an English thousands separator", () => {
expect(parseReferenceRange("150,000 - 400,000")).toEqual({
low: 150000,
high: 400000,
text: "150,000 - 400,000",
});
});
it("refuses a transposed window rather than swapping it", () => {
// Swapping would be a guess about which digit the lab meant to print.
expect(parseReferenceRange("5,0 - 3,5")).toEqual({
low: null,
high: null,
text: "5,0 - 3,5",
});
});
});
describe("parseReferenceRange — one-sided windows", () => {
it.each([
["< 5", 5],
["<5,0", 5],
["≤ 5", 5],
["<= 5", 5],
["bis 5,0", 5],
["bis zu 5,0", 5],
["max. 5,0", 5],
["max 5", 5],
["kleiner 5", 5],
["unter 5", 5],
["up to 5.0", 5],
])("reads %s as an upper bound of %s", (raw, high) => {
expect(parseReferenceRange(raw)).toEqual({
low: null,
high,
text: raw.trim(),
});
});
it.each([
["> 3.5", 3.5],
[">3,5", 3.5],
["≥ 3,5", 3.5],
[">= 3.5", 3.5],
["ab 3,5", 3.5],
["min. 3,5", 3.5],
["mind. 3,5", 3.5],
["mindestens 3,5", 3.5],
["größer 3,5", 3.5],
["groesser 3,5", 3.5],
["über 3,5", 3.5],
])("reads %s as a lower bound of %s", (raw, low) => {
expect(parseReferenceRange(raw)).toEqual({
low,
high: null,
text: raw.trim(),
});
});
});
describe("parseReferenceRange — units", () => {
it("reads a window that carries the reading's own unit", () => {
expect(parseReferenceRange("3,5 - 5,0 mmol/l", "mmol/L")).toEqual({
low: 3.5,
high: 5,
text: "3,5 - 5,0 mmol/l",
});
});
it("reads a trailing unit when the caller states none", () => {
expect(parseReferenceRange("0,5 - 1,2 mg/dl")).toEqual({
low: 0.5,
high: 1.2,
text: "0,5 - 1,2 mg/dl",
});
});
it("refuses bounds when the printed unit contradicts the reading's unit", () => {
// Converting mmol/L into mg/dL is interpretation and needs an analyte-
// specific factor. Keeping the string and no bounds is the honest answer.
const parsed = parseReferenceRange("3,5 - 5,0 mmol/l", "mg/dL");
expect(parsed).toEqual({
low: null,
high: null,
text: "3,5 - 5,0 mmol/l",
});
});
it("matches units case- and space-insensitively", () => {
expect(parseReferenceRange("< 5 MG/DL", "mg/dl")).toEqual({
low: null,
high: 5,
text: "< 5 MG/DL",
});
});
it("tolerates a percent window against a percent reading", () => {
expect(parseReferenceRange("4,0 - 6,0 %", "%")).toEqual({
low: 4,
high: 6,
text: "4,0 - 6,0 %",
});
});
});
describe("parseReferenceRange — text the parser must not invent bounds for", () => {
it.each([
"negativ",
"negative",
"nicht nachweisbar",
"siehe Befund",
"n.n.",
"unauffällig",
"altersabhängig",
"methodenabhängig",
])("keeps %s verbatim with no bounds", (raw) => {
expect(parseReferenceRange(raw)).toEqual({
low: null,
high: null,
text: raw,
});
});
it("keeps a bare number verbatim without treating it as a bound", () => {
// "5,0" alone states neither a ceiling nor a floor; picking one would be
// the parser deciding what the report meant.
expect(parseReferenceRange("5,0")).toEqual({
low: null,
high: null,
text: "5,0",
});
});
it("keeps a three-number string verbatim", () => {
expect(parseReferenceRange("3,5 - 5,0 - 7,0")).toEqual({
low: null,
high: null,
text: "3,5 - 5,0 - 7,0",
});
});
it("caps an over-long string rather than storing a whole paragraph", () => {
const parsed = parseReferenceRange("x".repeat(500));
expect(parsed?.text).toHaveLength(120);
expect(parsed?.low).toBeNull();
expect(parsed?.high).toBeNull();
});
it("collapses internal whitespace so the stored string stays one line", () => {
expect(parseReferenceRange(" 3,5\n\t- 5,0 ")).toEqual({
low: 3.5,
high: 5,
text: "3,5 - 5,0",
});
});
});
describe("parseReferenceRange — absence", () => {
it.each([null, undefined, "", " "])("returns null for %s", (raw) => {
expect(parseReferenceRange(raw)).toBeNull();
});
it("refuses a non-finite bound", () => {
expect(parseReferenceRange("1e999 - 2e999")).toEqual({
low: null,
high: null,
text: "1e999 - 2e999",
});
});
});
+3
View File
@@ -17,6 +17,9 @@ const baseRow: LabRow = {
unit: "mg/dl", // lowercase legacy unit
referenceLow: null,
referenceHigh: 100, // stale per-row bound
sourceReferenceLow: null,
sourceReferenceHigh: null,
sourceReferenceText: null,
takenAt: new Date("2026-01-01T08:00:00.000Z"),
source: "MANUAL",
biomarkerId: "bm_1",
+15 -6
View File
@@ -24,6 +24,7 @@ import {
} from "@/lib/ai/types";
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
import { prisma } from "@/lib/db";
import { parseReferenceRange } from "@/lib/labs/parse-reference-range";
import { annotate } from "@/lib/logging/context";
import {
extractedLabsSchema,
@@ -42,7 +43,8 @@ Extract every analyte reading you can read. For each reading capture:
- value: the numeric result as a number, OR null if the result is qualitative text.
- valueText: the qualitative result text (e.g. "negativ", "positiv", "nicht nachweisbar"), OR null if the result is numeric.
- unit: the measurement unit (e.g. "mg/dL", "%", "ng/mL"), or null if none is printed or the reading is qualitative.
- referenceLow / referenceHigh: the reference-range bounds as numbers, or null. A one-sided range like "< 116" sets referenceHigh only; "> 40" sets referenceLow only.
- referenceText: the reference range EXACTLY as printed beside the value, verbatim and complete, including its operator and unit if written (for example "3,5 - 5,0", "< 116", "bis 5,0", "150.000 - 400.000 /\u00b5l", "negativ"). Copy the characters; do NOT reformat, convert, or normalise them. Null only when no range is printed for that row.
- referenceLow / referenceHigh: the SAME range reduced to numbers, when it reduces to numbers, else null. A one-sided range like "< 116" sets referenceHigh only; "> 40" sets referenceLow only.
- takenAt: the per-row collection date in ISO 8601 (YYYY-MM-DD) if a date is printed for that row, else null.
- confidence: an object { analyte, value, unit, range } with a 0..1 score for how legible each field was. Use a low score when a field is blurred, ambiguous, or you had to guess.
@@ -51,7 +53,7 @@ Set EXACTLY ONE of value / valueText per reading. Never invent a value you canno
Also capture reportDate: the report's collection/sample date in ISO 8601 (YYYY-MM-DD), or null.
Respond ONLY with a JSON object of this exact shape:
{ "reportDate": string|null, "rows": [ { "analyte": string, "value": number|null, "valueText": string|null, "unit": string|null, "referenceLow": number|null, "referenceHigh": number|null, "takenAt": string|null, "confidence": { "analyte": number, "value": number, "unit": number, "range": number } } ] }`;
{ "reportDate": string|null, "rows": [ { "analyte": string, "value": number|null, "valueText": string|null, "unit": string|null, "referenceText": string|null, "referenceLow": number|null, "referenceHigh": number|null, "takenAt": string|null, "confidence": { "analyte": number, "value": number, "unit": number, "range": number } } ] }`;
const USER_PROMPT = `Transcribe the lab report in the attached image into the JSON schema described in the system prompt. Return only the JSON object.`;
@@ -70,7 +72,8 @@ Extract every analyte reading you can identify. For each reading capture:
- value: the numeric result as a number, OR null if the result is qualitative text. German reports use a decimal comma (e.g. "5,4") — emit it as 5.4.
- valueText: the qualitative result text (e.g. "negativ", "positiv", "nicht nachweisbar"), OR null if the result is numeric.
- unit: the measurement unit (e.g. "mg/dL", "%", "ng/mL"), or null if none is printed or the reading is qualitative.
- referenceLow / referenceHigh: the reference-range bounds as numbers, or null. A one-sided range like "< 116" sets referenceHigh only; "> 40" sets referenceLow only.
- referenceText: the reference range EXACTLY as printed beside the value, verbatim and complete, including its operator and unit if written (for example "3,5 - 5,0", "< 116", "bis 5,0", "150.000 - 400.000 /\u00b5l", "negativ"). Copy the characters; do NOT reformat, convert, or normalise them. Null only when no range is printed for that row.
- referenceLow / referenceHigh: the SAME range reduced to numbers, when it reduces to numbers, else null. A one-sided range like "< 116" sets referenceHigh only; "> 40" sets referenceLow only.
- takenAt: the per-row collection date in ISO 8601 (YYYY-MM-DD) if a date is printed for that row, else null.
- confidence: an object { analyte, value, unit, range } with a 0..1 score for how confident you are in each field. Use a LOW score when the OCR text was garbled, ambiguous, or you had to guess — the text is lossy, so be conservative.
@@ -79,7 +82,7 @@ Set EXACTLY ONE of value / valueText per reading. Never invent a value you canno
Also capture reportDate: the report's collection/sample date in ISO 8601 (YYYY-MM-DD), or null.
Respond ONLY with a JSON object of this exact shape:
{ "reportDate": string|null, "rows": [ { "analyte": string, "value": number|null, "valueText": string|null, "unit": string|null, "referenceLow": number|null, "referenceHigh": number|null, "takenAt": string|null, "confidence": { "analyte": number, "value": number, "unit": number, "range": number } } ] }`;
{ "reportDate": string|null, "rows": [ { "analyte": string, "value": number|null, "valueText": string|null, "unit": string|null, "referenceText": string|null, "referenceLow": number|null, "referenceHigh": number|null, "takenAt": string|null, "confidence": { "analyte": number, "value": number, "unit": number, "range": number } } ] }`;
/** A normalised ISO date string (YYYY-MM-DD) or null. */
function normaliseDate(raw: string | null): string | null {
@@ -130,6 +133,7 @@ async function annotateRow(
): Promise<OcrExtractedRowDto> {
const analyte = row.analyte.trim();
const takenAt = normaliseDate(row.takenAt) ?? reportDate;
const printed = parseReferenceRange(row.referenceText, row.unit);
// Biomarker match — case-insensitive on the catalog identity, presence-only.
const existingBiomarker = await prisma.biomarker.findFirst({
@@ -175,8 +179,13 @@ async function annotateRow(
value: row.value,
valueText: row.valueText,
unit: row.unit,
referenceLow: row.referenceLow,
referenceHigh: row.referenceHigh,
// Both readings of the printed window travel to the review screen: the
// derived bounds where the string yields them, and the string itself
// either way. A window the parser could not read is still the window the
// lab printed, so the user sees it and it reaches the row.
referenceLow: printed?.low ?? row.referenceLow,
referenceHigh: printed?.high ?? row.referenceHigh,
referenceText: printed?.text ?? null,
takenAt,
confidence: row.confidence,
biomarkerMatch: existingBiomarker ? "existing" : "new",
+207
View File
@@ -0,0 +1,207 @@
/**
* Free-text reference-range parser for the range a lab report prints beside a
* value.
*
* A report states its window in prose, not in columns: "3,5 - 5,0", "< 5",
* "bis 5,0", "≥ 3,5", "150.000 - 400.000 /µl", or a word like "negativ" that
* carries no window at all. The document-reading path and the manual entry
* form both hand that string here.
*
* The contract is deliberately asymmetric:
*
* - KEEPING the printed string is mandatory. `text` always carries what the
* report said, whitespace-normalised and length-capped, whether or not a
* bound could be derived from it. A window this parser cannot read is not
* a window that gets thrown away.
* - DERIVING bounds is best-effort and refuses on ambiguity. A string that
* does not match one of the notations below yields `low: null, high: null`
* beside the text. Absence reads as absence — the parser never picks a
* plausible bound out of a string it did not understand, because a fake
* window silently reclassifies a reading as normal or abnormal.
*
* Notations read (German and English, comma- or dot-decimal):
*
* two-sided 3.5-5.0 · 3,5 – 5,0 · 3,5 bis 5,0 · 3.5 to 5.0 · 3,5 ... 5,0
* von 3,5 bis 5,0 · [3,5 - 5,0] · (3.5-5.0)
* upper only < 5 · ≤ 5 · <= 5 · bis 5,0 · bis zu 5,0 · max. 5,0 ·
* kleiner 5 · unter 5 · up to 5.0 · less than 5
* lower only > 3.5 · ≥ 3,5 · >= 3.5 · ab 3,5 · min. 3,5 · mindestens 3,5 ·
* größer 3,5 · über 3,5 · at least 3.5
*
* Units are read, never converted. A window may carry the unit the report
* printed it in; when the caller states the reading's own unit and the two
* disagree, the bounds are refused and only the text survives. Converting
* mmol/L into mg/dL needs a per-analyte factor, so doing it here would be the
* parser deciding what the report meant.
*/
/** What a printed range yielded. `text` is set whenever the input was non-empty. */
export interface ParsedReferenceRange {
/** Lower bound, or null when the notation states none / could not be read. */
low: number | null;
/** Upper bound, or null when the notation states none / could not be read. */
high: number | null;
/** The printed string, whitespace-normalised and capped. Never null here. */
text: string;
}
/**
* Storage cap for the verbatim string. A reference-range cell is a few
* characters; anything longer is a mis-segmented paragraph, and truncating it
* keeps a runaway transcription out of the column.
*/
export const SOURCE_REFERENCE_TEXT_MAX = 120;
/** Magnitude ceiling. Beyond this a "bound" is a transcription artefact. */
const MAX_ABS_BOUND = 1e12;
/** One number token: optional sign, digits, any number of `.`/`,` groups. */
const NUM = String.raw`[+-]?\d+(?:[.,]\d+)*`;
/** Infix separators for a two-sided window. */
const TWO_SIDED_SEP = String.raw`(?:-|–|—|‒|…|\.{2,3}|bis\s+zu|bis|to|until)`;
/** Prefixes that state a ceiling only. */
const UPPER_PREFIX = String.raw`(?:<=|<|≤|bis\s+zu|bis|max\.|maximal|max|kleiner\s+als|kleiner|unter|höchstens|hoechstens|up\s+to|less\s+than|below)`;
/** Prefixes that state a floor only. */
const LOWER_PREFIX = String.raw`(?:>=|>|≥|ab|mind\.|mindestens|minimal|min\.|min|größer\s+als|größer|groesser\s+als|groesser|über|ueber|at\s+least|greater\s+than|above)`;
/**
* Trailing unit tail. Deliberately whitespace-free and comma-free: a real unit
* symbol ("mmol/l", "mg/dL", "%", "/µl", "10^9/L") never contains either, and
* refusing anything that does keeps a third number ("3,5 - 5,0 - 7,0") from
* being swallowed as if it were a unit.
*/
const UNIT_TAIL = String.raw`(?:\s*([^\s,]{1,24}))?`;
const TWO_SIDED_RE = new RegExp(
`^(${NUM})\\s*${TWO_SIDED_SEP}\\s*(${NUM})${UNIT_TAIL}$`,
"iu",
);
const UPPER_RE = new RegExp(`^${UPPER_PREFIX}\\s*(${NUM})${UNIT_TAIL}$`, "iu");
const LOWER_RE = new RegExp(`^${LOWER_PREFIX}\\s*(${NUM})${UNIT_TAIL}$`, "iu");
/** Leading words that only introduce the window ("von 3,5 bis 5,0"). */
const LEADING_FILLER_RE = /^(?:von|from|ref\.?|referenz|reference)\s+/iu;
/**
* Read one number token into a JS number, or null when its separators are
* ambiguous.
*
* Separator rules, in order:
* 1. Both `.` and `,` present → the LAST one is the decimal mark, the other
* groups thousands ("1.234,56" and "1,234.56" both read as 1234.56).
* 2. One separator kind, appearing more than once → all thousands
* ("1.234.567").
* 3. One separator, appearing once, group ≠ 3 digits → decimal ("3,5").
* 4. One separator, appearing once, group = 3 digits → ambiguous in the
* general case. Read as thousands only when the group is "000", which is
* how a German report prints a rounded count ("150.000 - 400.000 /µl");
* reading that as 150 would flag every normal platelet count as wildly
* high. Any other 3-digit group ("1.234" — 1234 or 1.234?) is refused.
*
* The limit of rule 4 is real: a report that prints "5.000" meaning five with
* three decimal places is read as five thousand. The verbatim text is stored
* either way, so the printed window is never lost — only the derived bound is.
*/
function readNumber(token: string): number | null {
const sign = token.startsWith("-") ? -1 : 1;
const body = token.replace(/^[+-]/u, "");
const dots = (body.match(/\./gu) ?? []).length;
const commas = (body.match(/,/gu) ?? []).length;
let normalised: string;
if (dots > 0 && commas > 0) {
const decimalMark =
body.lastIndexOf(".") > body.lastIndexOf(",") ? "." : ",";
const grouping = decimalMark === "." ? "," : ".";
normalised = body.split(grouping).join("").replace(decimalMark, ".");
} else if (dots + commas === 0) {
normalised = body;
} else if (dots > 1 || commas > 1) {
normalised = body.replace(/[.,]/gu, "");
} else {
const idx = body.search(/[.,]/u);
const group = body.slice(idx + 1);
if (group.length !== 3) {
normalised = `${body.slice(0, idx)}.${group}`;
} else if (group === "000") {
normalised = body.slice(0, idx) + group;
} else {
return null;
}
}
const value = sign * Number(normalised);
if (!Number.isFinite(value) || Math.abs(value) > MAX_ABS_BOUND) return null;
return value;
}
/** Compare a printed unit against the reading's unit, ignoring case + spaces. */
function unitsAgree(printed: string | undefined, reading: string | undefined) {
if (!printed) return true;
if (!reading || !reading.trim()) return true;
const norm = (u: string) => u.replace(/\s+/gu, "").toLowerCase();
return norm(printed) === norm(reading);
}
/**
* Parse the reference range a lab report printed for one reading.
*
* @param raw the printed string, exactly as transcribed.
* @param unit the reading's own unit, when known. A printed window in a
* different unit yields text-only — never a converted bound.
* @returns null when there was nothing printed at all.
*/
export function parseReferenceRange(
raw: string | null | undefined,
unit?: string | null,
): ParsedReferenceRange | null {
if (raw === null || raw === undefined) return null;
const text = raw
.replace(/\s+/gu, " ")
.trim()
.slice(0, SOURCE_REFERENCE_TEXT_MAX);
if (text === "") return null;
const noBounds: ParsedReferenceRange = { low: null, high: null, text };
// Strip a bracket wrapper and an introducing word for MATCHING only — the
// stored text stays exactly what the report printed.
let candidate = text;
const bracketed = /^[[(](.*)[\])]$/u.exec(candidate);
if (bracketed) candidate = bracketed[1].trim();
candidate = candidate.replace(LEADING_FILLER_RE, "").trim();
const two = TWO_SIDED_RE.exec(candidate);
if (two) {
if (!unitsAgree(two[3], unit ?? undefined)) return noBounds;
const low = readNumber(two[1]);
const high = readNumber(two[2]);
if (low === null || high === null) return noBounds;
// A transposed window is a transcription error, and swapping it would be a
// guess about which digit the lab meant. Refuse the bounds, keep the text.
if (low > high) return noBounds;
return { low, high, text };
}
const upper = UPPER_RE.exec(candidate);
if (upper) {
if (!unitsAgree(upper[2], unit ?? undefined)) return noBounds;
const high = readNumber(upper[1]);
return high === null ? noBounds : { low: null, high, text };
}
const lower = LOWER_RE.exec(candidate);
if (lower) {
if (!unitsAgree(lower[2], unit ?? undefined)) return noBounds;
const low = readNumber(lower[1]);
return low === null ? noBounds : { low, high: null, text };
}
// Anything else — "negativ", "siehe Befund", a bare number, prose — keeps its
// text and states no window.
return noBounds;
}
+110
View File
@@ -61,3 +61,113 @@ export function formatReferenceRange(
if (hasLow) return `≥ ${formatNumber(low as number)}`;
return opts?.emptyText ?? "";
}
/**
* The reference range a lab report printed beside ONE reading, as stored on
* `LabResult.sourceReference*`.
*/
export interface SourceReferenceRange {
sourceReferenceLow: number | null;
sourceReferenceHigh: number | null;
sourceReferenceText: string | null;
}
/** Where the bounds a reading is judged against came from. */
export type ReferenceRangeOrigin = "source" | "catalog" | "none";
/** The bounds one reading is judged against, plus how they were arrived at. */
export interface EffectiveReferenceRange {
/** The bound the verdict uses. */
low: number | null;
high: number | null;
origin: ReferenceRangeOrigin;
/** The catalog band, kept alongside so a surface can show both. */
catalogLow: number | null;
catalogHigh: number | null;
/** The printed window, kept verbatim. Null when the report stated none. */
sourceText: string | null;
/**
* True when the source window is in force AND the catalog states a window
* that differs from it. This is the case a reader must be told about: the
* same number reads differently against the two, so a surface that shows
* only one of them is showing a partial answer.
*/
divergesFromCatalog: boolean;
}
/** Two bounds are the same window when both ends match (null included). */
function sameWindow(
aLow: number | null,
aHigh: number | null,
bLow: number | null,
bHigh: number | null,
): boolean {
return aLow === bLow && aHigh === bHigh;
}
/**
* Resolve the reference window ONE reading is judged against.
*
* A lab runs its own method on its own device, and the physician reads the
* value against the window printed on that report. So for a reading that
* carries a source window, the source window wins — for that reading alone.
* The catalog band stays the net for every reading that carries none.
*
* A source window counts as stating a window only when it yields at least one
* numeric bound. A printed string with no derivable bound ("negativ", "siehe
* Befund") is carried through as `sourceText` but never displaces the catalog:
* a range that could not be read is not an argument for having no range.
*
* This function is the ONLY place that precedence is expressed. Every surface
* that classifies a reading — the API DTO, the doctor report, insights, the
* coach snapshot, the MCP read, the FHIR export — routes through it, so a
* reading cannot read as in-range on one surface and out on another.
*/
export function resolveEffectiveReferenceRange(
catalogLow: number | null | undefined,
catalogHigh: number | null | undefined,
source: SourceReferenceRange | null | undefined,
): EffectiveReferenceRange {
const cLow = catalogLow ?? null;
const cHigh = catalogHigh ?? null;
const sLow = source?.sourceReferenceLow ?? null;
const sHigh = source?.sourceReferenceHigh ?? null;
const sourceText = source?.sourceReferenceText ?? null;
if (sLow !== null || sHigh !== null) {
return {
low: sLow,
high: sHigh,
origin: "source",
catalogLow: cLow,
catalogHigh: cHigh,
sourceText,
divergesFromCatalog:
(cLow !== null || cHigh !== null) &&
!sameWindow(sLow, sHigh, cLow, cHigh),
};
}
return {
low: cLow,
high: cHigh,
origin: cLow !== null || cHigh !== null ? "catalog" : "none",
catalogLow: cLow,
catalogHigh: cHigh,
sourceText,
divergesFromCatalog: false,
};
}
/**
* Classify one reading against its effective window. Qualitative readings
* (no numeric value) always report `"unknown"` — there is nothing to compare
* against bounds, and a fabricated verdict is worse than none.
*/
export function classifyAgainstEffectiveRange(
value: number | null,
effective: EffectiveReferenceRange,
): ReferenceRangeStatus {
if (value === null) return "unknown";
return classifyReferenceRange(value, effective.low, effective.high);
}
+138 -58
View File
@@ -10,13 +10,40 @@
* - If the row is unlinked (legacy / pre-backfill), it falls back to its own
* free-text `analyte` / `unit` / `reference*` columns.
*
* Either way the verdict (`rangeStatus`) is computed from the RESOLVED bounds
* via `classifyReferenceRange`. The DTO carries the resolved values so the web
* client AND the iOS client render the same numbers — neither recomputes the
* range or guesses the unit. This is the server-authoritative-parity rule for
* Labs.
* On top of that the reading's OWN printed window wins. A lab report states
* the range its method and device produce right beside the value, and that is
* the window a physician evaluates against; the catalog band is the net for
* readings that carry none. `resolveEffectiveReferenceRange` owns that
* precedence and this file is one of its callers, not a second copy of it.
*
* The DTO therefore carries three windows and says which one is in force:
*
* - `referenceLow` / `referenceHigh` — the EFFECTIVE bounds, the ones
* `rangeStatus` was computed from. A client that reads only these fields
* (including an iOS build that predates the source window) renders the
* right numbers and the right verdict with no change of its own.
* - `catalogReferenceLow` / `catalogReferenceHigh` — the catalog band, so a
* surface can paint both and show where they part.
* - `sourceReferenceLow` / `sourceReferenceHigh` / `sourceReferenceText` —
* what the report printed, the last verbatim.
*
* `referenceOrigin` names which window is in force and
* `referenceDivergesFromCatalog` flags the case a reader must be told about.
* Every value is resolved server-side; neither client recomputes a range,
* guesses a unit, or re-derives the verdict.
*
* The two serialisers below spell every field out rather than spreading a
* shared helper: `written-outcome-response-consumer-guard` walks these object
* literals to enumerate the response leaves and pair each with a client
* reader, and a spread of a function call hides them from it. A field added
* here must be visible there.
*/
import { classifyReferenceRange } from "@/lib/labs/reference-range";
import {
classifyAgainstEffectiveRange,
resolveEffectiveReferenceRange,
type EffectiveReferenceRange,
type ReferenceRangeOrigin,
} from "@/lib/labs/reference-range";
/** The minimal biomarker shape the resolver needs (no encrypted context). */
export interface ResolvedBiomarker {
@@ -40,6 +67,10 @@ export interface LabRow {
unit: string;
referenceLow: number | null;
referenceHigh: number | null;
/** The window the source report printed for THIS reading. */
sourceReferenceLow: number | null;
sourceReferenceHigh: number | null;
sourceReferenceText: string | null;
takenAt: Date;
source: string;
biomarkerId: string | null;
@@ -48,60 +79,95 @@ export interface LabRow {
updatedAt: Date;
}
/**
* Resolve the canonical name / unit / bounds / panel for a lab row, preferring
* the linked biomarker over the legacy per-row fields. Returns a plain shape
* the list + detail serialisers both build on.
*/
export function resolveLabFields(
row: Pick<
LabRow,
| "analyte"
| "unit"
| "referenceLow"
| "referenceHigh"
| "panel"
| "biomarkerId"
>,
biomarker: ResolvedBiomarker | null | undefined,
): {
/** The columns `resolveLabFields` needs off a lab row. */
export type LabFieldRow = Pick<
LabRow,
| "analyte"
| "unit"
| "referenceLow"
| "referenceHigh"
| "sourceReferenceLow"
| "sourceReferenceHigh"
| "sourceReferenceText"
| "panel"
| "biomarkerId"
>;
/** What every lab surface reads off a row: identity, unit, and one window. */
export interface ResolvedLabFields {
analyte: string;
unit: string;
panel: string | null;
/** The bounds in force for this reading — source window first, catalog next. */
referenceLow: number | null;
referenceHigh: number | null;
panel: string | null;
} {
if (biomarker) {
return {
analyte: biomarker.name,
unit: biomarker.unit,
referenceLow: biomarker.lowerBound,
referenceHigh: biomarker.upperBound,
panel: biomarker.panel,
};
}
return {
analyte: row.analyte,
unit: row.unit,
referenceLow: row.referenceLow,
referenceHigh: row.referenceHigh,
panel: row.panel,
};
/** The catalog band, so a surface can show both windows. */
catalogReferenceLow: number | null;
catalogReferenceHigh: number | null;
/** What the source report printed for this reading. */
sourceReferenceLow: number | null;
sourceReferenceHigh: number | null;
sourceReferenceText: string | null;
referenceOrigin: ReferenceRangeOrigin;
referenceDivergesFromCatalog: boolean;
/** The resolved window as one value, for callers that pass it on. */
effectiveRange: EffectiveReferenceRange;
}
/**
* v1.18.9 — the reference-range verdict for a row. A qualitative row (no
* numeric `value`) has nothing to compare against the bounds, so it always
* reports `"unknown"` — the neutral, no-verdict state — never a fabricated
* in/out classification. A numeric row classifies against the resolved bounds.
* Resolve the canonical name / unit / window / panel for a lab row.
*
* Two resolutions happen here, in this order:
*
* 1. Identity + catalog band: the linked biomarker wins over the row's legacy
* free-text columns (an unlinked pre-backfill row falls back to its own).
* 2. The window in force: the reading's printed source range wins over the
* catalog band, through `resolveEffectiveReferenceRange`.
*
* Every lab surface calls this, so the window a reading is judged against is
* the same on the API, the doctor report, insights, the coach, MCP and FHIR.
*/
function rowRangeStatus(
value: number | null,
referenceLow: number | null,
referenceHigh: number | null,
) {
if (value === null) return "unknown" as const;
return classifyReferenceRange(value, referenceLow, referenceHigh);
export function resolveLabFields(
row: LabFieldRow,
biomarker: ResolvedBiomarker | null | undefined,
): ResolvedLabFields {
const identity = biomarker
? {
analyte: biomarker.name,
unit: biomarker.unit,
panel: biomarker.panel,
catalogLow: biomarker.lowerBound,
catalogHigh: biomarker.upperBound,
}
: {
analyte: row.analyte,
unit: row.unit,
panel: row.panel,
catalogLow: row.referenceLow,
catalogHigh: row.referenceHigh,
};
const effectiveRange = resolveEffectiveReferenceRange(
identity.catalogLow,
identity.catalogHigh,
row,
);
return {
analyte: identity.analyte,
unit: identity.unit,
panel: identity.panel,
referenceLow: effectiveRange.low,
referenceHigh: effectiveRange.high,
catalogReferenceLow: effectiveRange.catalogLow,
catalogReferenceHigh: effectiveRange.catalogHigh,
sourceReferenceLow: row.sourceReferenceLow,
sourceReferenceHigh: row.sourceReferenceHigh,
sourceReferenceText: effectiveRange.sourceText,
referenceOrigin: effectiveRange.origin,
referenceDivergesFromCatalog: effectiveRange.divergesFromCatalog,
effectiveRange,
};
}
/**
@@ -123,13 +189,21 @@ export function serialiseLabResult(
unit: resolved.unit,
referenceLow: resolved.referenceLow,
referenceHigh: resolved.referenceHigh,
catalogReferenceLow: resolved.catalogReferenceLow,
catalogReferenceHigh: resolved.catalogReferenceHigh,
sourceReferenceLow: resolved.sourceReferenceLow,
sourceReferenceHigh: resolved.sourceReferenceHigh,
sourceReferenceText: resolved.sourceReferenceText,
referenceOrigin: resolved.referenceOrigin,
referenceDivergesFromCatalog: resolved.referenceDivergesFromCatalog,
takenAt: row.takenAt.toISOString(),
source: row.source,
hasNote: row.noteEncrypted !== null,
rangeStatus: rowRangeStatus(
// A qualitative row has no number to place against bounds, so it reports
// the neutral "unknown" rather than a fabricated in/out verdict.
rangeStatus: classifyAgainstEffectiveRange(
row.value,
resolved.referenceLow,
resolved.referenceHigh,
resolved.effectiveRange,
),
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
@@ -156,13 +230,19 @@ export function serialiseLabResultDetail(
unit: resolved.unit,
referenceLow: resolved.referenceLow,
referenceHigh: resolved.referenceHigh,
catalogReferenceLow: resolved.catalogReferenceLow,
catalogReferenceHigh: resolved.catalogReferenceHigh,
sourceReferenceLow: resolved.sourceReferenceLow,
sourceReferenceHigh: resolved.sourceReferenceHigh,
sourceReferenceText: resolved.sourceReferenceText,
referenceOrigin: resolved.referenceOrigin,
referenceDivergesFromCatalog: resolved.referenceDivergesFromCatalog,
takenAt: row.takenAt.toISOString(),
source: row.source,
note,
rangeStatus: rowRangeStatus(
rangeStatus: classifyAgainstEffectiveRange(
row.value,
resolved.referenceLow,
resolved.referenceHigh,
resolved.effectiveRange,
),
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
+8 -9
View File
@@ -48,7 +48,7 @@ import {
type MetricStatusMetricId,
} from "@/lib/insights/metric-status-registry";
import { allSignals, getSignal } from "@/lib/signals/registry";
import { classifyReferenceRange } from "@/lib/labs/reference-range";
import { classifyAgainstEffectiveRange } from "@/lib/labs/reference-range";
import { resolveLabFields } from "@/lib/labs/serialise";
import { sanitizeValueText } from "@/lib/ai/coach/labs-snapshot";
import { encodeOffsetCursor } from "@/lib/mcp/pagination";
@@ -1316,6 +1316,9 @@ export async function getLabHistory(
unit: true,
referenceLow: true,
referenceHigh: true,
sourceReferenceLow: true,
sourceReferenceHigh: true,
sourceReferenceText: true,
takenAt: true,
biomarkerId: true,
biomarker: {
@@ -1350,14 +1353,10 @@ export async function getLabHistory(
unit: resolved.unit,
referenceLow: resolved.referenceLow,
referenceHigh: resolved.referenceHigh,
rangeStatus:
row.value === null
? "unknown"
: classifyReferenceRange(
row.value,
resolved.referenceLow,
resolved.referenceHigh,
),
rangeStatus: classifyAgainstEffectiveRange(
row.value,
resolved.effectiveRange,
),
takenAt: row.takenAt.toISOString(),
};
});
+15 -2
View File
@@ -44,7 +44,13 @@ listLabResultsSchema.meta({
const rangeStatusEnum = z.enum(["in-range", "below", "above", "unknown"]).meta({
id: "LabReferenceRangeStatus",
description:
"Server-computed, NEUTRAL reference-range verdict. `unknown` when the lab reported no usable bounds. Inclusive bounds: a value on the limit reads in-range. The badge that renders this must stay calm and informative — not an alarming red.",
"Server-computed, NEUTRAL reference-range verdict, always against `referenceLow` / `referenceHigh` (the window in force for that reading). `unknown` when no usable bounds are on file. Inclusive bounds: a value on the limit reads in-range. The badge that renders this must stay calm and informative — not an alarming red.",
});
const referenceOriginEnum = z.enum(["source", "catalog", "none"]).meta({
id: "LabReferenceRangeOrigin",
description:
"Which window `referenceLow` / `referenceHigh` came from. `source` = the range printed on this reading's own report, which wins for that reading because it is the window the physician evaluated the value against. `catalog` = the user's Biomarker band, the net for readings with no printed range. `none` = no window on file.",
});
const labResultRow = z
@@ -60,6 +66,13 @@ const labResultRow = z
unit: z.string(),
referenceLow: z.number().nullable(),
referenceHigh: z.number().nullable(),
catalogReferenceLow: z.number().nullable(),
catalogReferenceHigh: z.number().nullable(),
sourceReferenceLow: z.number().nullable(),
sourceReferenceHigh: z.number().nullable(),
sourceReferenceText: z.string().nullable(),
referenceOrigin: referenceOriginEnum,
referenceDivergesFromCatalog: z.boolean(),
takenAt: z.string(),
source: z.string(),
hasNote: z.boolean(),
@@ -70,7 +83,7 @@ const labResultRow = z
.meta({
id: "LabResult",
description:
'A stored lab result. Exactly one of `value` (numeric) or `valueText` (qualitative, e.g. "negativ") is non-null; `rangeStatus` is always `unknown` for a qualitative row (no number to classify). `biomarkerId` links the user-scoped catalog marker (null for legacy free-text rows); when linked, `analyte` / `unit` / `referenceLow` / `referenceHigh` are RESOLVED server-side from the Biomarker — the client renders them and never recomputes the range. The encrypted note is never echoed in list rows; `hasNote` flags its presence and the single-resource GET returns the decrypted `note`.',
"A stored lab result. Exactly one of `value` (numeric) or `valueText` (qualitative, e.g. \"negativ\") is non-null; `rangeStatus` is always `unknown` for a qualitative row (no number to classify). `biomarkerId` links the user-scoped catalog marker (null for legacy free-text rows); when linked, `analyte` / `unit` are RESOLVED server-side from the Biomarker.\n\nThree reference windows ride on the row and the server says which one applies. `referenceLow` / `referenceHigh` are the window IN FORCE and the one `rangeStatus` was computed from — read these to render the range and you are correct without doing any resolution of your own. `sourceReferenceLow` / `sourceReferenceHigh` / `sourceReferenceText` are the range this reading's own report printed (the text verbatim, kept even when no numbers could be read out of it). `catalogReferenceLow` / `catalogReferenceHigh` are the Biomarker band. `referenceOrigin` names the winner and `referenceDivergesFromCatalog` is true when the report's window and the catalog band state different limits — a surface showing only one of the two is showing a partial answer. The client renders these values and never recomputes the range. The encrypted note is never echoed in list rows; `hasNote` flags its presence and the single-resource GET returns the decrypted `note`.",
});
const labResultDetail = labResultRow
+16 -1
View File
@@ -53,6 +53,10 @@ const extractedRow = z
unit: z.string().nullable(),
referenceLow: z.number().nullable(),
referenceHigh: z.number().nullable(),
referenceText: z.string().nullable().meta({
description:
'The reference range EXACTLY as printed on the report, verbatim ("3,5 - 5,0", "< 116", "bis 5,0", "negativ"). `referenceLow` / `referenceHigh` carry the same window reduced to numbers WHEN it reduces to numbers; this field carries it either way, so a window stated in words is not lost between the report and the reading.',
}),
takenAt: z.string().nullable(),
confidence: extractConfidence,
biomarkerMatch: z.enum(["new", "existing"]),
@@ -87,6 +91,13 @@ const committedRow = z
unit: z.string(),
referenceLow: z.number().nullable(),
referenceHigh: z.number().nullable(),
catalogReferenceLow: z.number().nullable(),
catalogReferenceHigh: z.number().nullable(),
sourceReferenceLow: z.number().nullable(),
sourceReferenceHigh: z.number().nullable(),
sourceReferenceText: z.string().nullable(),
referenceOrigin: z.enum(["source", "catalog", "none"]),
referenceDivergesFromCatalog: z.boolean(),
takenAt: z.string(),
source: z.string(),
hasNote: z.boolean(),
@@ -94,7 +105,11 @@ const committedRow = z
createdAt: z.string(),
updatedAt: z.string(),
})
.meta({ id: "OcrCommittedLabResult" });
.meta({
id: "OcrCommittedLabResult",
description:
"A written reading. Its reference window resolves exactly as it does on `/api/labs` — see `LabResult`.",
});
const commitResponse = z
.object({
+5
View File
@@ -690,6 +690,11 @@ const labResultBackupSchema = z
unit: z.string().min(1),
referenceLow: z.number().nullable().optional(),
referenceHigh: z.number().nullable().optional(),
// Optional so a backup written before these columns existed still
// restores; absent reads as "this reading had no source window on file".
sourceReferenceLow: z.number().nullable().optional(),
sourceReferenceHigh: z.number().nullable().optional(),
sourceReferenceText: z.string().nullable().optional(),
takenAt: isoDateTime,
source: z.string().min(1),
biomarkerName: z.string().nullable().optional(),
+18
View File
@@ -167,6 +167,15 @@ export const extractedFactRawSchema = z.object({
unit: nullableText(80),
referenceLow: z.number().finite().nullable().catch(null),
referenceHigh: z.number().finite().nullable().catch(null),
/**
* Observation: the reference range EXACTLY as printed, verbatim. A report
* writes its window in prose ("3,5 - 5,0", "< 5", "bis 5,0", "negativ"), and
* `referenceLow` / `referenceHigh` above can only carry the cases that
* reduce to two numbers. This field is what stops every other case from
* being dropped on the floor — the string is kept whether or not the parser
* gets bounds out of it.
*/
referenceText: nullableText(120),
/** MedicationStatement: dose + stated status (verbatim). */
dose: nullableText(120),
medicationStatus: nullableText(64),
@@ -237,6 +246,14 @@ export interface ObservationFactData {
unit: string | null;
referenceLow: number | null;
referenceHigh: number | null;
/**
* The reference range as printed on the report, verbatim.
*
* Optional because it genuinely is: a fact staged before this field existed
* carries no key for it, and that is an honest absence rather than a shim
* for a caller we do not ship. Read it as `?? null`.
*/
referenceText?: string | null;
effectiveDate: string | null;
}
@@ -379,6 +396,7 @@ const observationEditSchema = z
unit: optText(80),
referenceLow: z.number().finite().nullable().optional(),
referenceHigh: z.number().finite().nullable().optional(),
referenceText: optText(120),
effectiveDate: isoDate,
})
// Numeric XOR qualitative, mirroring the lab-result discipline. A null/absent
+20
View File
@@ -78,6 +78,13 @@ export const extractedRowSchema = z.object({
unit: z.string().trim().max(80).nullable().catch(null),
referenceLow: z.number().finite().nullable().catch(null),
referenceHigh: z.number().finite().nullable().catch(null),
/**
* The reference range EXACTLY as printed, verbatim. The two numeric bounds
* above can only carry a window that reduces to two numbers; a report that
* prints "bis 5,0" or "negativ" states a window all the same, and this is
* what keeps it instead of dropping it.
*/
referenceText: z.string().trim().max(120).nullable().catch(null),
takenAt: z.string().nullable().catch(null),
confidence: extractedConfidenceSchema,
});
@@ -107,6 +114,8 @@ export interface OcrExtractedRowDto {
unit: string | null;
referenceLow: number | null;
referenceHigh: number | null;
/** The reference range as printed on the report, verbatim. */
referenceText: string | null;
takenAt: string | null;
confidence: {
analyte: number;
@@ -150,6 +159,17 @@ export const ocrCommitRowSchema = z
unit: z.string().trim().min(1).max(40).optional(),
referenceLow,
referenceHigh,
/**
* The window the scanned report printed for this row. Confirmed by the
* human on the review screen alongside everything else, and written onto
* the reading rather than only used to seed a new catalog marker.
*/
referenceText: z
.string()
.trim()
.max(120)
.optional()
.or(z.literal("").transform(() => undefined)),
takenAt: takenAtField,
})
// Numeric XOR qualitative: exactly one of value / valueText.
+60
View File
@@ -14,6 +14,7 @@
*/
import { z } from "zod/v4";
import { SOURCE_REFERENCE_TEXT_MAX } from "@/lib/labs/parse-reference-range";
import { validateEntryInstant } from "@/lib/validations/entry-instant";
/**
@@ -63,6 +64,29 @@ const optionalNote = z
const referenceLow = z.number().finite().optional();
const referenceHigh = z.number().finite().optional();
/**
* The reference range the SOURCE REPORT printed for this one reading.
*
* Distinct from `referenceLow` / `referenceHigh` above, which seed (and are
* stamped from) the catalog marker's band. A lab runs its own method on its
* own device, so the window a physician evaluates against is the one on that
* report; these fields carry it, and `resolveEffectiveReferenceRange` lets it
* win for that reading alone.
*
* `sourceReferenceText` is what was printed, verbatim. It is accepted with or
* without derivable bounds: a window stated as "negativ" or "siehe Befund"
* still belongs on the record, and dropping it because no number could be read
* out of it is exactly the loss this field exists to stop.
*/
const sourceReferenceLow = z.number().finite().optional();
const sourceReferenceHigh = z.number().finite().optional();
const sourceReferenceText = z
.string()
.trim()
.max(SOURCE_REFERENCE_TEXT_MAX)
.optional()
.or(z.literal("").transform(() => undefined));
/**
* v1.18.1 — optional link to a user-scoped `Biomarker` (the catalog row).
* When set, the structured-entry path resolves the unit + reference range
@@ -101,6 +125,9 @@ export const createLabResultSchema = z
unit: requiredText(40).optional(),
referenceLow,
referenceHigh,
sourceReferenceLow,
sourceReferenceHigh,
sourceReferenceText,
takenAt: takenAtField,
note: optionalNote,
source,
@@ -142,6 +169,19 @@ export const createLabResultSchema = z
message: "referenceLow must not exceed referenceHigh",
path: ["referenceLow"],
},
)
// Same guard on the source window. A transposed window is a client error
// here for the same reason: nothing downstream can tell it from a real one.
.refine(
(d) =>
d.valueText !== undefined ||
d.sourceReferenceLow === undefined ||
d.sourceReferenceHigh === undefined ||
d.sourceReferenceLow <= d.sourceReferenceHigh,
{
message: "sourceReferenceLow must not exceed sourceReferenceHigh",
path: ["sourceReferenceLow"],
},
);
export type CreateLabResultInput = z.infer<typeof createLabResultSchema>;
@@ -164,6 +204,9 @@ export const updateLabResultSchema = z
unit: requiredText(40).optional(),
referenceLow: referenceLow.or(z.null()),
referenceHigh: referenceHigh.or(z.null()),
sourceReferenceLow: sourceReferenceLow.or(z.null()),
sourceReferenceHigh: sourceReferenceHigh.or(z.null()),
sourceReferenceText: sourceReferenceText.or(z.null()),
takenAt: takenAtField.optional(),
note: optionalNote.or(z.null()),
})
@@ -183,6 +226,18 @@ export const updateLabResultSchema = z
message: "referenceLow must not exceed referenceHigh",
path: ["referenceLow"],
},
)
.refine(
(d) =>
d.sourceReferenceLow === undefined ||
d.sourceReferenceLow === null ||
d.sourceReferenceHigh === undefined ||
d.sourceReferenceHigh === null ||
d.sourceReferenceLow <= d.sourceReferenceHigh,
{
message: "sourceReferenceLow must not exceed sourceReferenceHigh",
path: ["sourceReferenceLow"],
},
);
export type UpdateLabResultInput = z.infer<typeof updateLabResultSchema>;
@@ -246,7 +301,12 @@ export type ListLabResultsInput = z.infer<typeof listLabResultsSchema>;
* implementation. Re-exported here for the existing import sites.
*/
export {
classifyAgainstEffectiveRange,
classifyReferenceRange,
formatReferenceRange,
resolveEffectiveReferenceRange,
type EffectiveReferenceRange,
type ReferenceRangeOrigin,
type ReferenceRangeStatus,
type SourceReferenceRange,
} from "@/lib/labs/reference-range";
@@ -0,0 +1,298 @@
/**
* The whole path a printed reference range travels, against a real Postgres.
*
* The failure this pins is a two-ended one: until now the document reading
* pulled the value off a lab report and dropped the range printed beside it,
* and every end of that pipe looked healthy on its own. So the assertions here
* follow ONE range through the real assembly rather than checking each end in
* isolation:
*
* extract → the staged fact carries the printed string
* commit → the written row carries the derived bounds AND the string
* read → the API DTO serves both windows and says which is in force
* verdict → the reading is judged against the REPORT's window, not the
* catalog band — the case where the two disagree is the point
* report → the doctor-report payload and the FHIR bundle agree with it
*
* The catalog band is deliberately set so that the same number reads
* "in-range" against the report and "above" against the catalog. A test whose
* two windows agree cannot tell whether the precedence rule is wired at all.
*/
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
const USER_ID = "user-lab-source-range";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => {
cookieJar.set(name, value);
},
delete: (name: string) => {
cookieJar.delete(name);
},
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
const prisma = getPrismaClient();
await prisma.user.create({
data: {
id: USER_ID,
username: "lab-source-range",
email: "lab-source-range@example.test",
},
});
const session = await prisma.session.create({
data: { userId: USER_ID, expiresAt: new Date(Date.now() + 3_600_000) },
});
cookieJar.set("healthlog_session", session.id);
});
/** The catalog band the marker carries. 5.2 sits ABOVE this window. */
const CATALOG = { lowerBound: 3.5, upperBound: 5.0 };
/** The window the report printed. 5.2 sits INSIDE this one. */
const PRINTED = "3,9 - 5,4";
const VALUE = 5.2;
async function seedMarker() {
return getPrismaClient().biomarker.create({
data: { userId: USER_ID, name: "Kalium", unit: "mmol/L", ...CATALOG },
});
}
function postReq(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/labs", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
async function readDto(analyte = "Kalium") {
const { GET } = await import("@/app/api/labs/route");
const res = await GET(
new NextRequest(`http://localhost/api/labs?analyte=${analyte}`),
);
expect(res.status).toBe(200);
const body = await res.json();
return body.data.results[0];
}
describe("a printed reference range, end to end", () => {
it("survives the document path onto the row and governs its verdict", async () => {
const prisma = getPrismaClient();
const marker = await seedMarker();
// The document path, through the REAL commit helper the confirm route
// calls. The fact carries what a report prints: a value, a unit, and a
// window stated in German notation beside it.
const { commitApprovedFact } = await import("@/lib/documents/commit");
const { encryptFactData, encryptFactProvenance } =
await import("@/lib/documents/store");
const doc = await prisma.inboundDocument.create({
data: {
userId: USER_ID,
filename: "befund.pdf",
mimeType: "application/pdf",
byteSize: 1024,
contentEncrypted: Buffer.from("stand-in for the stored bytes"),
status: "EXTRACTED",
},
});
const fact = await prisma.extractedFact.create({
data: {
documentId: doc.id,
userId: USER_ID,
factType: "OBSERVATION",
status: "APPROVED",
confidence: 0.9,
needsReview: false,
dataEncrypted: encryptFactData({
label: "Kalium",
code: null,
codeSystem: null,
value: VALUE,
valueText: null,
unit: "mmol/L",
referenceLow: null,
referenceHigh: null,
referenceText: PRINTED,
effectiveDate: "2026-07-01",
}),
provenanceEncrypted: encryptFactProvenance({
sourceText: "Kalium 5,2 mmol/l 3,9 - 5,4",
anchored: true,
sourceOffset: 0,
page: 0,
confidence: 0.9,
}),
},
});
const ref = await commitApprovedFact(USER_ID, fact);
expect(ref.recordType).toBe("labResult");
// The row carries BOTH readings of the printed window: the bounds derived
// from it and the string itself.
const row = await prisma.labResult.findUniqueOrThrow({
where: { id: ref.recordId },
});
expect(row.sourceReferenceLow).toBe(3.9);
expect(row.sourceReferenceHigh).toBe(5.4);
expect(row.sourceReferenceText).toBe(PRINTED);
// The catalog stamp is untouched — the marker already existed, so the
// document's window did not rewrite the user's own band.
expect(row.referenceLow).toBe(CATALOG.lowerBound);
expect(row.referenceHigh).toBe(CATALOG.upperBound);
const markerAfter = await prisma.biomarker.findUniqueOrThrow({
where: { id: marker.id },
});
expect(markerAfter.lowerBound).toBe(CATALOG.lowerBound);
expect(markerAfter.upperBound).toBe(CATALOG.upperBound);
// The read side serves both windows, names the winner, and flags that the
// two disagree.
const dto = await readDto();
expect(dto).toMatchObject({
value: VALUE,
referenceLow: 3.9,
referenceHigh: 5.4,
catalogReferenceLow: CATALOG.lowerBound,
catalogReferenceHigh: CATALOG.upperBound,
sourceReferenceLow: 3.9,
sourceReferenceHigh: 5.4,
sourceReferenceText: PRINTED,
referenceOrigin: "source",
referenceDivergesFromCatalog: true,
});
// The verdict is the point. Against the catalog band 5.2 is above range;
// against the window the lab printed it is inside it, and the lab's window
// is the one the physician read the value against.
expect(dto.rangeStatus).toBe("in-range");
// The doctor report reads the same window, and prints the report's own
// string rather than a reformatted version of it.
const { loadLabResults } =
await import("@/lib/doctor-report/clinical-records");
const labs = await loadLabResults(
USER_ID,
new Date("2026-01-01T00:00:00.000Z"),
new Date("2026-12-31T23:59:59.000Z"),
);
expect(labs?.[0]).toMatchObject({
referenceLow: 3.9,
referenceHigh: 5.4,
catalogReferenceLow: CATALOG.lowerBound,
catalogReferenceHigh: CATALOG.upperBound,
sourceReferenceText: PRINTED,
referenceOrigin: "source",
referenceDivergesFromCatalog: true,
});
// FHIR exports the same window — a receiver that trusts referenceRange
// must not be handed the catalog band while the app shows another.
const { labObservations } = await import("@/lib/fhir/resources/labs");
let seq = 0;
const observations = labObservations(
{ labResults: labs } as Parameters<typeof labObservations>[0],
() => `obs-${++seq}`,
);
expect(observations[0].referenceRange?.[0]).toMatchObject({
low: { value: 3.9 },
high: { value: 5.4 },
text: PRINTED,
});
});
it("keeps a window it cannot read as text and leaves the catalog in force", async () => {
const prisma = getPrismaClient();
await seedMarker();
const { POST } = await import("@/app/api/labs/route");
const marker = await prisma.biomarker.findFirstOrThrow({
where: { userId: USER_ID },
});
const res = await POST(
postReq({
biomarkerId: marker.id,
value: VALUE,
sourceReferenceText: "siehe Befund",
takenAt: "2026-07-02T08:00:00.000Z",
}),
);
expect(res.status).toBe(201);
const row = await prisma.labResult.findFirstOrThrow({
where: { userId: USER_ID },
});
// The string is on the record — absence would be the old failure, and a
// fabricated bound would be worse than either.
expect(row.sourceReferenceText).toBe("siehe Befund");
expect(row.sourceReferenceLow).toBeNull();
expect(row.sourceReferenceHigh).toBeNull();
const dto = await readDto();
expect(dto).toMatchObject({
sourceReferenceText: "siehe Befund",
referenceOrigin: "catalog",
referenceDivergesFromCatalog: false,
referenceLow: CATALOG.lowerBound,
referenceHigh: CATALOG.upperBound,
});
// No derivable window means the catalog stays the net, so the catalog's
// verdict stands.
expect(dto.rangeStatus).toBe("above");
});
it("carries the window through a backup round trip", async () => {
const prisma = getPrismaClient();
const marker = await seedMarker();
await prisma.labResult.create({
data: {
userId: USER_ID,
biomarkerId: marker.id,
analyte: "Kalium",
unit: "mmol/L",
value: VALUE,
referenceLow: CATALOG.lowerBound,
referenceHigh: CATALOG.upperBound,
sourceReferenceLow: 3.9,
sourceReferenceHigh: 5.4,
sourceReferenceText: PRINTED,
takenAt: new Date("2026-07-01T08:00:00.000Z"),
},
});
const { buildRecordsBackupSection } =
await import("@/lib/export/records-backup");
const backup = await buildRecordsBackupSection(prisma, USER_ID, {
purpose: "disaster-recovery",
});
expect(backup.labResults[0]).toMatchObject({
sourceReferenceLow: 3.9,
sourceReferenceHigh: 5.4,
sourceReferenceText: PRINTED,
});
});
});