From 157809c24f361eff9b76444a5fc817dc218cc055 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 10 Nov 2026 18:37:01 +0100 Subject: [PATCH] fix(labs): drop a lab's calculated-value note from the unit (#1313) A read that copied "(calc)" into the unit minted a marker in "g/dL (calc)" that refused the next report's "g/dL". The note is dropped at the read and ignored in the unit comparison; any other bracket still counts. --- CHANGELOG.md | 12 ++++++++ src/lib/documents/__tests__/extract.test.ts | 30 +++++++++++++++++++ src/lib/documents/extract.ts | 16 ++++++++-- src/lib/labs/__tests__/unit-normalise.test.ts | 22 +++++++++++++- src/lib/labs/unit-normalise.ts | 24 ++++++++++++++- 5 files changed, 100 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 33a91d90e..6bf618510 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,18 @@ fell through to the international set with 112, which reaches the police there, not an ambulance. +### Fixed + +- **A lab's "(calc)" note no longer becomes part of the unit.** Some + reports print "(calc)" beside a calculated value, and a document read + could copy it into the unit, so a marker was created in "g/dL (calc)" + or just "(calc)" and the next report's plain "g/dL" was refused as a + different unit. The read now drops that note (also "calculated", + "computed" and the German "berechnet"), a unit that was only the note + counts as no unit, and a marker already saved with the note accepts + the plain unit. Any other text in brackets still counts as part of + the unit. (#1313) + ## [1.47.1] — 2026-11-07 ### Added diff --git a/src/lib/documents/__tests__/extract.test.ts b/src/lib/documents/__tests__/extract.test.ts index a637df428..af441a5ba 100644 --- a/src/lib/documents/__tests__/extract.test.ts +++ b/src/lib/documents/__tests__/extract.test.ts @@ -193,6 +193,36 @@ describe("runInboundExtraction — stated-status-only mapping", () => { expect(result.facts.map((f) => f.needsReview)).toEqual([true, false]); }); + it('drops a lab\'s "(calc)" note from the unit, so two reports land in one', async () => { + const provider = fakeProvider( + JSON.stringify({ + facts: [ + { + type: "OBSERVATION", + label: "Globulin", + value: 2.6, + unit: "g/dL (calc)", + confidence: 0.95, + sourceText: "Globulin 2.6 g/dL (calc)", + }, + { + type: "OBSERVATION", + label: "A/G Ratio", + value: 1.7, + unit: "(calc)", + confidence: 0.95, + sourceText: "A/G Ratio 1.7 (calc)", + }, + ], + }), + ); + + const result = await runInboundExtraction(oneRead(provider)); + expect( + result.facts.map((f) => (f.data as ObservationFactData).unit), + ).toEqual(["g/dL", null]); + }); + it("stores the document's real span, not the model's echo of it", async () => { // The OCR text says "Hämoglobin 14.2 g/dL"; the model echoes a tidied // rendering of it. The stored provenance must be the document's characters. diff --git a/src/lib/documents/extract.ts b/src/lib/documents/extract.ts index 4c793ec39..d1e6bc8bc 100644 --- a/src/lib/documents/extract.ts +++ b/src/lib/documents/extract.ts @@ -58,6 +58,7 @@ import { type OcrReadSurface, } from "@/lib/documents/ocr-read"; import { isDimensionlessUnit } from "@/lib/labs/dimensionless"; +import { withoutLabUnitAnnotation } from "@/lib/labs/unit-normalise"; import { annotate } from "@/lib/logging/context"; import { INBOUND_CONFIDENCE_FLOOR, @@ -168,7 +169,7 @@ function mapFactData(raw: ExtractedFactRaw): FactData | null { codeSystem: system === "LOINC" ? system : null, value: hasNumeric ? raw.value : null, valueText: hasNumeric ? null : raw.valueText, - unit: raw.unit, + unit: observationUnit(raw.unit), referenceLow: raw.referenceLow, referenceHigh: raw.referenceHigh, referenceText: raw.referenceText, @@ -189,6 +190,17 @@ function mapFactData(raw: ExtractedFactRaw): FactData | null { return data; } +/** + * The printed unit without a lab's "(calc)" note, so the same marker read + * from two reports, one with the note and one without, lands in one unit. + * A unit that was only the note is no unit. + */ +function observationUnit(unit: string | null): string | null { + if (unit === null) return null; + const bare = withoutLabUnitAnnotation(unit); + return bare === "" ? null : bare; +} + /** A staged fact ready to persist (before it gets an id). */ export interface StagedFactInput { factType: InboundFactType; @@ -306,7 +318,7 @@ function factKey(raw: ExtractedFactRaw, readDate: string | null): string { raw.label.trim().toLowerCase(), raw.value ?? "", (raw.valueText ?? "").trim().toLowerCase(), - (raw.unit ?? "").trim().toLowerCase(), + (observationUnit(raw.unit) ?? "").toLowerCase(), (raw.referenceText ?? "").trim().toLowerCase(), (raw.dose ?? "").trim().toLowerCase(), normaliseDate(raw.effectiveDate) ?? readDate ?? "", diff --git a/src/lib/labs/__tests__/unit-normalise.test.ts b/src/lib/labs/__tests__/unit-normalise.test.ts index 5fb4e9d12..81ae7367e 100644 --- a/src/lib/labs/__tests__/unit-normalise.test.ts +++ b/src/lib/labs/__tests__/unit-normalise.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; -import { normaliseLabUnit, sameLabUnit } from "../unit-normalise"; +import { + normaliseLabUnit, + sameLabUnit, + withoutLabUnitAnnotation, +} from "../unit-normalise"; describe("sameLabUnit — spellings of one unit", () => { it.each([ @@ -88,3 +92,19 @@ describe("normaliseLabUnit", () => { expect(normaliseLabUnit("MG/DL")).toBe("mg/dL"); }); }); + +describe("a calculated-value note in the unit", () => { + it("is not part of the unit", () => { + expect(sameLabUnit("g/dL (calc)", "g/dL")).toBe(true); + expect(sameLabUnit("mmol/l (berechnet)", "mmol/L")).toBe(true); + expect(sameLabUnit("mg/dL (Calc.)", "MG/DL")).toBe(true); + expect(withoutLabUnitAnnotation("(calc)")).toBe(""); + expect(withoutLabUnitAnnotation(" g/dL (calculated) ")).toBe("g/dL"); + }); + + it("keeps any other bracket, so it still surfaces as a mismatch", () => { + expect(sameLabUnit("mg/dL (fasting)", "mg/dL")).toBe(false); + expect(withoutLabUnitAnnotation("U/L (37 °C)")).toBe("U/L (37 °C)"); + expect(withoutLabUnitAnnotation("(calc) g/dL")).toBe("(calc) g/dL"); + }); +}); diff --git a/src/lib/labs/unit-normalise.ts b/src/lib/labs/unit-normalise.ts index b67cc3346..7b4a2eeff 100644 --- a/src/lib/labs/unit-normalise.ts +++ b/src/lib/labs/unit-normalise.ts @@ -21,9 +21,31 @@ * does not recognise is compared exactly, so an unfamiliar spelling surfaces as * a mismatch rather than being guessed at. * + * A lab's note that a value was calculated rather than measured (`g/dL + * (calc)`, `(berechnet)`) is not part of the unit and is dropped first; see + * `withoutLabUnitAnnotation`. + * * Pure and dependency-free. */ +/** + * A trailing note in brackets that says the lab calculated the value. Some + * reports print it in the unit column, and a read that copies the column + * verbatim then minted a marker in "g/dL (calc)", which refused the next + * report's plain "g/dL". Only these words are dropped, so a bracket that + * carries meaning stays and still surfaces as a mismatch. + */ +const CALCULATED_NOTE = + /\s*\((?:calc\.?|calculated|computed|berechnet|ber\.?)\)\s*$/i; + +/** + * The unit without a trailing calculated-value note, trimmed: `g/dL (calc)` + * reads `g/dL`, and a unit that was only the note reads `""`. + */ +export function withoutLabUnitAnnotation(raw: string): string { + return raw.replace(CALCULATED_NOTE, "").trim(); +} + /** Units that may be recognised when printed entirely in capitals. */ const SHOUTED_UNITS: ReadonlyMap = new Map( [ @@ -79,7 +101,7 @@ function foldLitre(text: string): string { * means the same thing, and the (trimmed) string itself otherwise. */ export function normaliseLabUnit(raw: string): string { - const spaced = foldSpacing(raw); + const spaced = foldSpacing(withoutLabUnitAnnotation(raw)); const hasLowercase = /[a-zµμ]/.test(spaced); const hasUppercase = /[A-Z]/.test(spaced);