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.
This commit is contained in:
Marc-André Bombeck committed 2026-11-10 18:37:01 +01:00
1 parent 2dfcadbea1
commit 157809c24f
5 files changed
+100 -4

No files matched your search

+12
View File
@@ -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
@@ -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.
+14 -2
View File
@@ -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 ?? "",
+21 -1
View File
@@ -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");
});
});
+23 -1
View File
@@ -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<string, string> = 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);