mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
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:
1 parent
2dfcadbea1
commit
157809c24f
5 files changed
+100
-4
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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 ?? "",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user