From ce7372991661696c5f15578cbf06e88f4d917a71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 23 Aug 2026 14:19:18 +0200 Subject: [PATCH] fix(fhir): resolve a lab analyte's LOINC code from its name in any shipped locale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A biomarker minted from the labs catalog stores `t("labs.catalog.")` — the name in the language of whoever minted it — and that stored name is what reaches the FHIR exporter as free text. The exporter matched it against an alias table written in English and German. So an account working in French, Spanish, Italian or Polish exported "Cholestérol total", "Glucosa en ayunas", "Glicemia a digiuno" and "Cholesterol całkowity" as text-only concepts with no LOINC code, on the one document whose whole purpose is being read by another system. The names are DERIVED from `messages/.json` now, not transcribed into this file. Both sides of the new slug map are app-owned identities with no language in them, which is what makes it the one edit a new locale does not need: `locales` grows, `messages/xx.json` lands, and the analyte names it ships resolve the same day. Transcribing four more languages would have fixed today's six and reopened the hole at the seventh. The hand-written aliases keep precedence. They cover what the app owns no string for — clinical abbreviations, the ways a person shortens a name — and they were verified one at a time over several releases. A derived name landing on the same key is a harmless duplicate; one landing on a different key would be a translation typo re-pointing a verified mapping at another analyte, so the build refuses it and the guard suite asserts the refusal held. The lookup fold now drops accents before stripping punctuation. Without it a name carrying diacritics lost them entirely rather than reducing to its base letters — "Cholestérol total" folded to `cholestroltotal`, and "Hämoglobin" to `hmoglobin`. That last one means the German umlaut spellings never resolved either: the hand-written `hamoglobin` and `nuchternglucose` aliases only ever caught the un-umlauted typing. Every pre-existing alias is ASCII, so the fold is a widening and what resolved before resolves to the same key. --- .../fhir/__tests__/lab-loinc-locales.test.ts | 135 ++++++++++++++++++ src/lib/fhir/lab-loinc.ts | 135 ++++++++++++++++-- 2 files changed, 259 insertions(+), 11 deletions(-) create mode 100644 src/lib/fhir/__tests__/lab-loinc-locales.test.ts diff --git a/src/lib/fhir/__tests__/lab-loinc-locales.test.ts b/src/lib/fhir/__tests__/lab-loinc-locales.test.ts new file mode 100644 index 000000000..2e4363e45 --- /dev/null +++ b/src/lib/fhir/__tests__/lab-loinc-locales.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from "vitest"; + +import { + CATALOG_SLUG_TO_LOINC_KEY, + localisedLabAliases, + normaliseLabKey, + resolveLabCoding, +} from "@/lib/fhir/lab-loinc"; +import { locales } from "@/lib/i18n/config"; +import { allMessages, resolveKey } from "@/lib/i18n/shared-resolve"; +import { BIOMARKER_CATALOG } from "@/lib/labs/biomarker-catalog"; + +/** + * A biomarker minted from the labs catalog stores `t("labs.catalog.")` + * — the name in the language of whoever minted it — and that stored name is + * what reaches the FHIR exporter as free text. The alias table it was matched + * against was written in English and German, so four of the six shipped + * locales exported every one of these analytes as a text-only concept with no + * LOINC code. + */ +describe("resolveLabCoding — the analyte name in every shipped locale", () => { + it.each([ + ["Cholestérol total", "2093-3"], + ["Colesterol total", "2093-3"], + ["Colesterolo totale", "2093-3"], + ["Cholesterol całkowity", "2093-3"], + ["Créatinine", "2160-0"], + ["Creatinina", "2160-0"], + ["Kreatynina", "2160-0"], + ["Glycémie à jeun", "1558-6"], + ["Glucosa en ayunas", "1558-6"], + ["Glicemia a digiuno", "1558-6"], + ["Glukoza na czczo", "1558-6"], + ["Ferritine", "2276-4"], + ["Ferrytyna", "2276-4"], + ["Hémoglobine", "718-7"], + ["Emoglobina", "718-7"], + ["Cholestérol LDL", "18262-6"], + ["Colesterol HDL", "2085-9"], + ["Triglycérides", "2571-8"], + ["Trójglicerydy", "2571-8"], + ["Insuline à jeun", "27873-9"], + ["Lipoprotéine(a)", "43583-4"], + ])("codes %s as LOINC %s", (analyte, loinc) => { + expect(resolveLabCoding(analyte, "")?.loinc).toBe(loinc); + }); + + it("derives from the bundles rather than from a transcribed list", () => { + // The property that matters: every locale contributes. A hand-written + // table would pass the cases above and still be frozen at the languages + // somebody happened to type. + for (const locale of locales) { + const name = resolveKey( + allMessages[locale], + "labs.catalog.total-cholesterol", + ); + expect(name, `${locale} has no catalog name`).toBeTruthy(); + expect( + resolveLabCoding(name as string, "")?.loinc, + `${locale}: ${name}`, + ).toBe("2093-3"); + } + }); + + it("stamps the canonical UCUM when the recorded unit matches", () => { + expect(resolveLabCoding("Cholestérol total", "mg/dL")).toEqual({ + loinc: "2093-3", + display: "Cholesterol [Mass/volume] in Serum or Plasma", + ucum: "mg/dL", + }); + }); + + it("still keeps an unmapped analyte text-only", () => { + // Conservative by design: a name the app does not ship and no alias names + // gets no fabricated code, in any language. + expect(resolveLabCoding("Analyse inconnue", "mg/dL")).toBeNull(); + }); +}); + +describe("resolveLabCoding — the existing English and German behaviour", () => { + it.each([ + ["HbA1c", "4548-4"], + ["LDL-C", "18262-6"], + ["LDL Cholesterol", "18262-6"], + ["Gesamtcholesterin", "2093-3"], + ["Kreatinin", "2160-0"], + ["GPT", "1742-6"], + ["ASAT", "1920-8"], + ["hgb", "718-7"], + ["Nüchternglucose", "1558-6"], + ["Thyrotropin", "3016-3"], + ["Apolipoprotein B", "1884-6"], + ])("still codes %s as LOINC %s", (analyte, loinc) => { + expect(resolveLabCoding(analyte, "")?.loinc).toBe(loinc); + }); + + it("resolves a German name whose umlaut the old key fold destroyed", () => { + // "Hämoglobin" used to fold to `hmoglobin`, which matched nothing — the + // hand-written `hamoglobin` alias only ever caught the un-umlauted typing. + expect(normaliseLabKey("Hämoglobin")).toBe("hamoglobin"); + expect(resolveLabCoding("Hämoglobin", "g/dL")?.loinc).toBe("718-7"); + }); +}); + +describe("the derived alias index", () => { + it("contradicts no hand-written alias", () => { + // A derived name that resolved to a DIFFERENT analyte than a verified + // alias would be a bundle bug re-pointing an existing mapping. The build + // refuses it; this asserts the refusal held rather than that it exists. + for (const [name, key] of Object.entries(localisedLabAliases())) { + const viaHand = resolveLabCoding(name, ""); + expect(viaHand, name).not.toBeNull(); + expect(viaHand?.loinc, name).toBe(resolveLabCoding(key, "")?.loinc); + } + }); + + it("is not empty, so a matcher that matches nothing cannot pass", () => { + expect(Object.keys(localisedLabAliases()).length).toBeGreaterThan(20); + }); + + it("maps only slugs the labs catalog actually ships, in every locale", () => { + // A renamed slug or a bundle that dropped a name would silently stop + // deriving: the index would shrink and nothing else would say so. + const slugs = new Set(BIOMARKER_CATALOG.map((s) => s.slug)); + for (const slug of Object.keys(CATALOG_SLUG_TO_LOINC_KEY)) { + expect(slugs.has(slug), `catalog lost slug ${slug}`).toBe(true); + for (const locale of locales) { + expect( + resolveKey(allMessages[locale], `labs.catalog.${slug}`), + `${locale} has no name for ${slug}`, + ).toBeTruthy(); + } + } + }); +}); diff --git a/src/lib/fhir/lab-loinc.ts b/src/lib/fhir/lab-loinc.ts index 17eaf927f..0e748d0f9 100644 --- a/src/lib/fhir/lab-loinc.ts +++ b/src/lib/fhir/lab-loinc.ts @@ -30,9 +30,16 @@ * yet carry that field, so the call site passes only `analyte` today. When the * DTO gains a `biomarkerCanonicalName` (or similar), thread it in — no other * change to this module is required. + * + * SERVER-ONLY since the localised alias index landed: the index reads every + * shipped message bundle through `@/lib/i18n/shared-resolve`. The single + * caller is the FHIR exporter, which runs on the server; a client that needs + * to show a biomarker's name resolves it through the labs catalog instead. */ import { LOINC_SYSTEM, UCUM_SYSTEM } from "@/lib/fhir/loinc-map"; +import { localisedValues } from "@/lib/i18n/shared-resolve"; +import { stripDiacritics } from "@/lib/i18n/fold-for-match"; export { LOINC_SYSTEM, UCUM_SYSTEM }; @@ -45,13 +52,20 @@ export interface LabLoincMapping { } /** - * Normalise an analyte / unit string to a lookup key: lower-case, strip every - * character that is not a letter or digit. So "LDL-C", "LDL Cholesterol", - * "ldl_c" all fold to `ldlc`-ish keys; the alias table below pins the exact - * variants the app and German users actually type onto a single canonical key. + * Normalise an analyte / unit string to a lookup key: lower-case, drop + * accents, strip every character that is not a letter or digit. So "LDL-C", + * "LDL Cholesterol", "ldl_c" all fold to `ldlc`-ish keys. + * + * The accent fold is what lets a name carrying diacritics reach a key at all. + * Without it "Cholestérol total" lost its "é" entirely and folded to + * `cholestroltotal`, and "Hämoglobin" to `hmoglobin` — keys nothing was ever + * going to match, which is also why the hand-written German aliases below are + * spelled `hamoglobin` and `kreatinin` rather than as the language writes + * them. Every pre-existing alias is pure ASCII, so folding is a widening: what + * resolved before resolves to the same key now. */ export function normaliseLabKey(s: string): string { - return s.toLowerCase().replace(/[^a-z0-9]/g, ""); + return stripDiacritics(s.toLowerCase()).replace(/[^a-z0-9]/g, ""); } /** @@ -172,11 +186,17 @@ const LAB_LOINC_BY_KEY: Record = { }; /** - * Alias → canonical key. The LEFT side is the normalised form of a name a user - * (EN or DE) might type; the RIGHT side is a key in `LAB_LOINC_BY_KEY`. Every - * canonical key is also implicitly its own alias (looked up directly first). - * Keep the right side honest — only fold a synonym when it unambiguously names - * the same analyte. + * Alias → canonical key, hand-written. The LEFT side is the normalised form of + * a name a user might type; the RIGHT side is a key in `LAB_LOINC_BY_KEY`. + * Every canonical key is also implicitly its own alias (looked up directly + * first). Keep the right side honest — only fold a synonym when it + * unambiguously names the same analyte. + * + * This table covers spellings the app owns NO string for: clinical + * abbreviations ("GPT", "ASAT", "hgb"), and the ways a person shortens a name + * by hand. The NAMES the app itself displays are not transcribed here — see + * the derived index below — because that list exists in six languages already + * and copying it in would freeze it at today's six. */ const LAB_ALIASES: Record = { // HbA1c @@ -252,6 +272,94 @@ const LAB_ALIASES: Record = { nuchterninsulin: "fastinginsulin", }; +/** + * Labs-catalog slug → canonical LOINC key. + * + * Both sides are app-owned stable identities with no language in them, which + * is the whole point: this is the only edit a new locale does NOT need. A + * biomarker minted from the catalog stores whatever `labs.catalog.` said + * in the language of the person who minted it, and that name is what reaches + * the FHIR exporter as free text. Pinning the slug once means every + * translation of it resolves. + * + * Slugs absent here are catalog markers with no entry in `LAB_LOINC_BY_KEY` + * (T3, T4, B12, folate, the electrolytes, most of the blood count) — that map + * is conservative by design and text-only stays the honest default for them. + * Adding one is a decision about a LOINC term, not about a language. + */ +export const CATALOG_SLUG_TO_LOINC_KEY: Record = { + "total-cholesterol": "cholesterol", + ldl: "ldlc", + hdl: "hdlc", + triglycerides: "triglycerides", + "fasting-glucose": "glucosefasting", + hba1c: "hba1c", + tsh: "tsh", + ferritin: "ferritin", + "vitamin-d": "vitamind", + "hs-crp": "hscrp", + creatinine: "creatinine", + egfr: "egfr", + alt: "alt", + ast: "ast", + ggt: "ggt", + hemoglobin: "hemoglobin", + apob: "apob", + "lp-a": "lpa", + "fasting-insulin": "fastinginsulin", +}; + +/** + * Localised analyte name → canonical LOINC key, DERIVED from + * `messages/.json` rather than transcribed out of it. + * + * That is the load-bearing part. Before this index, the alias table above was + * the whole of the mapping and it was written in English and German, so a + * French account's "Cholestérol total", a Spanish "Colesterol total", an + * Italian "Colesterolo totale" and a Polish "Cholesterol całkowity" all + * exported as a text-only concept with no LOINC code, on a document a + * clinician or another system reads. Transcribing four more languages here + * would have fixed today's six locales and reopened the same hole on the day a + * seventh landed. Reading the bundles means a new locale needs no edit in this + * file: `locales` grows, `messages/xx.json` lands, and the names it ships + * resolve the same day. + * + * The hand-written aliases WIN on a conflict. They were verified one by one + * over several releases; a derived name that folds onto the same key as one of + * them is a duplicate and harmless, and one that folds onto a DIFFERENT key + * would be a bundle bug, so it is refused here and the guard suite fails on + * it — a translation typo must not quietly re-point an existing alias at + * another analyte. + * + * Known limit: the index is built from the names the bundles carry TODAY, + * while the analytes it resolves were typed in the past. Editing a shipped + * `labs.catalog.` string orphans the biomarkers minted under the old + * wording; the fix for that is a legacy row in `LAB_ALIASES`, not a migration. + */ +function buildLocalisedAliases(): Record { + const index: Record = {}; + for (const [slug, loincKey] of Object.entries(CATALOG_SLUG_TO_LOINC_KEY)) { + for (const name of localisedValues(`labs.catalog.${slug}`)) { + const key = normaliseLabKey(name); + if (!key) continue; + if (LAB_LOINC_BY_KEY[key] || LAB_ALIASES[key]) continue; + if (index[key] && index[key] !== loincKey) continue; + index[key] = loincKey; + } + } + return index; +} + +const LOCALISED_LAB_ALIASES = buildLocalisedAliases(); + +/** + * Test seam: the guard suite asserts the derived index covers every shipped + * locale and contradicts no hand-written alias. Production reads the constant. + */ +export function localisedLabAliases(): Readonly> { + return LOCALISED_LAB_ALIASES; +} + /** * Canonical UCUM symbols that a recorded free-text unit may normalise to. The * LEFT is the normalised (lower-cased, punctuation-stripped) recorded unit; @@ -344,7 +452,12 @@ export function resolveLabCoding( const source = canonicalName && canonicalName.length > 0 ? canonicalName : analyte; const key = normaliseLabKey(source); - const resolvedKey = LAB_LOINC_BY_KEY[key] ? key : LAB_ALIASES[key]; + // Canonical key first, then the hand-verified aliases, then the names the + // shipped bundles carry. The order is the confidence order, and it keeps + // every resolution that worked before working the same way. + const resolvedKey = LAB_LOINC_BY_KEY[key] + ? key + : (LAB_ALIASES[key] ?? LOCALISED_LAB_ALIASES[key]); if (!resolvedKey) return null; const mapping = LAB_LOINC_BY_KEY[resolvedKey]; if (!mapping) return null;