fix(fhir): code a qualitative lab result recorded in any shipped locale

The lab form offers "negative" and "positive" as quick picks and writes the
label it rendered, so the column holds the bundle's own string in the language
the reading was recorded in. The SNOMED mapper matched that string against a
list transcribed in English and German, so "négatif", "negativo", "ujemny",
"positif" and "dodatni" all exported without a code — text-only on the one
document meant to be machine-readable.

The negative and positive arms read `labs.form.qualNegative` and
`labs.form.qualPositive` out of every bundle now. The form writes those exact
keys, so the write side and the read side cannot drift, and a new locale is
coded the day its bundle lands.

The detected / not-detected pair stays hand-written English and German, and
that is the honest limit of this change: the app ships no string for those
terms, they are prose a lab printed and a person copied in, and coding "non
détecté" or "niewykrywalny" would mean inventing clinical vocabulary in four
languages nobody here can check. This module's own rule is that an unverified
code is worse than an honest `.text`. When they need coding they need a quick
pick and a bundle key, which is the route the other two arms took. A test pins
that they stay uncoded rather than leaving it to be discovered.

Borderline stays uncoded as before; a test now pins that the derivation did
not sweep its bundle key up by accident. Comparison folds accents as well as
case, so a result typed without them still resolves.
This commit is contained in:
Marc-André Bombeck committed 2026-08-23 14:21:34 +02:00
1 parent ce73729916
commit c5a01767aa
2 files changed
+234 -25

No files matched your search

@@ -0,0 +1,138 @@
import { describe, expect, it } from "vitest";
import {
localisedQualitativeIndex,
qualitativeValueConcept,
} from "@/lib/fhir/lab-qualitative";
import { locales } from "@/lib/i18n/config";
import { allMessages, resolveKey } from "@/lib/i18n/shared-resolve";
const SNOMED = "http://snomed.info/sct";
function codeOf(valueText: string): string | undefined {
const concept = qualitativeValueConcept(valueText);
return concept.coding?.[0]?.code;
}
/**
* The lab form offers "negative" and "positive" as quick picks and writes the
* LABEL it rendered, so the column holds the bundle's own string in the
* language the reading was recorded in. Matched against a transcribed
* English/German list, four of the six shipped locales exported a result the
* receiving system could not read.
*/
describe("qualitativeValueConcept — the quick-pick label in every locale", () => {
it.each([
["négatif", "260385009"],
["negativo", "260385009"],
["ujemny", "260385009"],
["positif", "10828004"],
["positivo", "10828004"],
["dodatni", "10828004"],
])("codes %s as SNOMED %s", (valueText, code) => {
expect(codeOf(valueText)).toBe(code);
});
it("derives from the bundles rather than from a transcribed list", () => {
for (const locale of locales) {
const negative = resolveKey(
allMessages[locale],
"labs.form.qualNegative",
);
const positive = resolveKey(
allMessages[locale],
"labs.form.qualPositive",
);
expect(negative, `${locale} has no negative label`).toBeTruthy();
expect(positive, `${locale} has no positive label`).toBeTruthy();
expect(codeOf(negative as string), `${locale}: ${negative}`).toBe(
"260385009",
);
expect(codeOf(positive as string), `${locale}: ${positive}`).toBe(
"10828004",
);
}
});
it("tolerates a term typed without its accents", () => {
expect(codeOf("Negatif")).toBe("260385009");
expect(codeOf("NÉGATIF")).toBe("260385009");
});
it("keeps the raw text on the concept whatever the language", () => {
expect(qualitativeValueConcept("négatif")).toEqual({
coding: [{ system: SNOMED, code: "260385009", display: "Negative" }],
text: "négatif",
});
});
});
describe("qualitativeValueConcept — what stays text-only, and why", () => {
it("leaves borderline uncoded in every locale", () => {
// The candidate "Borderline" qualifier concept was never confidently
// verified, so `labs.form.qualBorderline` is read by nothing. This pins
// that the derivation did not sweep it up by accident.
for (const locale of locales) {
const label = resolveKey(allMessages[locale], "labs.form.qualBorderline");
expect(label, `${locale} has no borderline label`).toBeTruthy();
expect(
qualitativeValueConcept(label as string).coding,
`${locale}: ${label}`,
).toBeUndefined();
}
});
it("leaves the detected / not-detected pair uncoded outside English and German", () => {
// The honest limit of this change, written down rather than discovered
// later: the app ships no string for these, they are prose a lab printed,
// and inventing the four other languages would be inventing clinical
// vocabulary nobody here can check.
expect(codeOf("nicht nachweisbar")).toBe("260415000");
expect(codeOf("not detected")).toBe("260415000");
expect(codeOf("non détecté")).toBeUndefined();
expect(codeOf("niewykrywalny")).toBeUndefined();
expect(qualitativeValueConcept("non détecté")).toEqual({
text: "non détecté",
});
});
it("leaves free prose uncoded", () => {
expect(qualitativeValueConcept("siehe Befund")).toEqual({
text: "siehe Befund",
});
});
});
describe("qualitativeValueConcept — the existing English and German terms", () => {
it.each([
["negativ", "260385009"],
["negative", "260385009"],
["Negativ", "260385009"],
["neg", "260385009"],
["positiv", "10828004"],
["positive", "10828004"],
["pos", "10828004"],
["nachweisbar", "260373001"],
["detected", "260373001"],
])("still codes %s as SNOMED %s", (valueText, code) => {
expect(codeOf(valueText)).toBe(code);
});
});
describe("the derived qualitative index", () => {
it("is not empty, so a matcher that matches nothing cannot pass", () => {
expect(Object.keys(localisedQualitativeIndex()).length).toBeGreaterThan(3);
});
it("never puts one word on both arms", () => {
// Two locales spelling a term the same way ("negativo" in Spanish and
// Italian) is expected and folds onto one row. The same word meaning both
// negative and positive would be a bundle bug.
const negatives = new Set<string>();
const positives = new Set<string>();
for (const [word, concept] of Object.entries(localisedQualitativeIndex())) {
(concept.code === "260385009" ? negatives : positives).add(word);
}
for (const word of negatives) expect(positives.has(word)).toBe(false);
});
});
+96 -25
View File
@@ -3,29 +3,52 @@
*
* A qualitative serology result ("negativ" / "positiv" / "nicht nachweisbar")
* has no number, so its FHIR Observation carries a `valueCodeableConcept`
* instead of a `valueQuantity`. This module maps the common German / English
* result terms onto a small set of WELL-ESTABLISHED SNOMED CT qualifier-value
* concepts. The raw recorded text ALWAYS rides `.text`; a coded `coding` is
* added only when the term resolves CONFIDENTLY. When in doubt the result stays
* text-only — never a fabricated code — mirroring the conservative stance of
* the `lab-loinc` and `illness-snomed` mappers.
* instead of a `valueQuantity`. This module maps common result terms onto a
* small set of WELL-ESTABLISHED SNOMED CT qualifier-value concepts. The raw
* recorded text ALWAYS rides `.text`; a coded `coding` is added only when the
* term resolves CONFIDENTLY. When in doubt the result stays text-only — never
* a fabricated code — mirroring the conservative stance of the `lab-loinc` and
* `illness-snomed` mappers.
*
* Concepts (SNOMED CT International, qualifier value, REFERENCED not
* redistributed per the licence):
* - 260385009 "Negative" — negativ / negative
* - 10828004 "Positive" — positiv / positive
* - 260385009 "Negative" — every locale's `labs.form.qualNegative`
* - 10828004 "Positive" — every locale's `labs.form.qualPositive`
* - 260415000 "Not detected" — nicht nachweisbar / not detected
* - 260373001 "Detected" — nachweisbar / detected
*
* The negative and positive arms are DERIVED from `messages/<locale>.json`.
* The lab form offers those two as quick picks and writes the label it
* rendered, so what lands in the column is the bundle's own string —
* "négatif", "negativo", "ujemny" — and matching it against a transcribed
* English/German list meant four of the six shipped locales recorded a result
* the export then could not code. Deriving means a new locale is coded the day
* its bundle lands, with no edit here.
*
* The detected / not-detected arm is NOT derived, because the app ships no
* string for it: those are terms a LAB printed and a person copied in. It
* stays the hand-written English and German pair it has always been, and the
* honest cost is that "non détecté" or "niewykrywalny" stay text-only. Coding
* them would mean inventing clinical vocabulary in four languages nobody here
* can check, and this module's own rule is that an unverified code is worse
* than an honest `.text`. When those terms need coding, they need a quick pick
* in the form and a bundle key — the same route the negative and positive arms
* took.
*
* Borderline / grenzwertig is DELIBERATELY left text-only: the candidate
* "Borderline" qualifier concept could not be confidently verified, and an
* unverified code is worse than an honest `.text`. Any unrecognised term
* "Borderline" qualifier concept could not be confidently verified. Its bundle
* key is therefore read by nothing here, on purpose. Any unrecognised term
* likewise stays text-only.
*
* SERVER-ONLY: the derived index reads every shipped message bundle. Its one
* caller is the FHIR exporter, which runs on the server.
*
* Systems: SNOMED CT — http://snomed.info/sct
*/
import type { FhirCodeableConcept } from "@/lib/fhir/types";
import { foldForMatch } from "@/lib/i18n/fold-for-match";
import { localisedValues } from "@/lib/i18n/shared-resolve";
/**
* SNOMED CT system URI. Inlined (rather than imported from `resources.ts`) to
@@ -39,29 +62,76 @@ interface QualitativeConcept {
display: string;
}
const NEGATIVE: QualitativeConcept = { code: "260385009", display: "Negative" };
const POSITIVE: QualitativeConcept = { code: "10828004", display: "Positive" };
/**
* Normalised result term → SNOMED qualifier-value concept. The left side is the
* lower-cased, whitespace-collapsed recorded text. Only confidently-known terms
* appear; everything else falls through to text-only.
* Hand-written result terms → SNOMED qualifier-value concept. The left side is
* the folded recorded text.
*
* What belongs here is what the app ships no string for: the clinical
* shorthand a person types ("neg", "pos") and the detected / not-detected pair
* a lab prints. The negative and positive WORDS are not transcribed here — see
* the derived index below.
*/
const QUALITATIVE_SNOMED: Record<string, QualitativeConcept> = {
// Negative-like
negativ: { code: "260385009", display: "Negative" },
negative: { code: "260385009", display: "Negative" },
neg: { code: "260385009", display: "Negative" },
const HAND_WRITTEN_SNOMED: Record<string, QualitativeConcept> = {
neg: NEGATIVE,
pos: POSITIVE,
"nicht nachweisbar": { code: "260415000", display: "Not detected" },
"not detected": { code: "260415000", display: "Not detected" },
// Positive-like
positiv: { code: "10828004", display: "Positive" },
positive: { code: "10828004", display: "Positive" },
pos: { code: "10828004", display: "Positive" },
nachweisbar: { code: "260373001", display: "Detected" },
detected: { code: "260373001", display: "Detected" },
};
/** Collapse a recorded qualitative term to a lookup key. */
/**
* The quick-pick labels, in every shipped locale, read out of the bundles.
*
* The form writes the label it rendered, so this index and the write side
* cannot drift: whatever `labs.form.qualNegative` says in a language is
* exactly what a reading recorded in that language holds. `qualBorderline` is
* deliberately absent — see the module comment.
*/
function buildLocalisedIndex(): Record<string, QualitativeConcept> {
const index: Record<string, QualitativeConcept> = {};
const arms: [string, QualitativeConcept][] = [
["labs.form.qualNegative", NEGATIVE],
["labs.form.qualPositive", POSITIVE],
];
for (const [key, concept] of arms) {
for (const label of localisedValues(key)) {
const folded = foldForMatch(label);
// A folded label already claimed by the OTHER arm would mean a bundle
// says the same word means both negative and positive. First wins so a
// typo cannot take the export down at boot; the guard suite fails on it.
if (!folded || index[folded]) continue;
index[folded] = concept;
}
}
return index;
}
const LOCALISED_SNOMED = buildLocalisedIndex();
/**
* Test seam: the guard suite asserts the derived index covers every shipped
* locale and disagrees with no hand-written term. Production reads the
* constant.
*/
export function localisedQualitativeIndex(): Readonly<
Record<string, QualitativeConcept>
> {
return LOCALISED_SNOMED;
}
/**
* Collapse a recorded qualitative term to a lookup key.
*
* Folds case, accents and separators, so a reading typed "Negatif" on a
* keyboard without accents meets the bundle's "négatif", and a stored
* "negativ" resolves exactly as it did before the fold widened.
*/
function normaliseQualitativeKey(text: string): string {
return text.trim().toLowerCase().replace(/\s+/g, " ");
return foldForMatch(text);
}
/**
@@ -73,7 +143,8 @@ function normaliseQualitativeKey(text: string): string {
export function qualitativeValueConcept(
valueText: string,
): FhirCodeableConcept {
const concept = QUALITATIVE_SNOMED[normaliseQualitativeKey(valueText)];
const key = normaliseQualitativeKey(valueText);
const concept = HAND_WRITTEN_SNOMED[key] ?? LOCALISED_SNOMED[key];
if (!concept) {
return { text: valueText };
}