feat(labs): link a marker to its analyte for the unit conversion

Slice 1 of phase 3 of #1095. A Biomarker row keeps only its name, so nothing
said which analyte it was. This adds the explicit alias list in the catalog the
issue settled on, including the registry-only keys (urea, blood urea nitrogen,
uric acid, total calcium, total bilirubin), and sets biomarkers.analyte_key
(from migration 0385) from it.

matchAnalyteKey equals a marker's folded name against the localised names of the
catalog seeds the registry knows and against the alias list. It matches the
whole name only, so "Calcium (ionisiert)" is not "Calcium"; a name two analytes
claim matches neither; and the marker's own unit must be one the analyte reaches,
so a marker called "Glucose" in a unit the registry cannot convert stays
unlinked.

The key is set when a marker is created (the biomarker route, the mint used by
lab writes, the legacy backfill) and, for an existing marker, by
ensureAnalyteKey the first time a reading needs it. It never overwrites a key
that is set. There is no boot job for the existing markers: the conversion that
uses the key fills it on first use, and a sweep can be added later if wanted.

The key is returned on the biomarker API and in the OpenAPI schema.

Refs #1095
This commit is contained in:
BernhardBuckel committed 2026-10-11 20:04:43 +02:00
1 parent c2be166f72
commit 52ea3b9c2f
13 files changed
+660 -3

No files matched your search

+2
View File
@@ -41,6 +41,7 @@ function serialiseBiomarker(row: {
lowerBound: number | null;
upperBound: number | null;
panel: string | null;
analyteKey: string | null;
hidden: boolean;
contextEncrypted: Uint8Array | null;
createdAt: Date;
@@ -53,6 +54,7 @@ function serialiseBiomarker(row: {
lowerBound: row.lowerBound,
upperBound: row.upperBound,
panel: row.panel,
analyteKey: row.analyteKey,
hasContext: row.contextEncrypted !== null,
context: row.contextEncrypted
? decryptContextFromBytes(row.contextEncrypted)
+6
View File
@@ -17,6 +17,7 @@ import {
decryptContextSoft,
encryptContextToBytes,
} from "@/lib/labs/biomarker-store";
import { matchAnalyteKey } from "@/lib/labs/analyte-match";
import { annotate } from "@/lib/logging/context";
import { createBiomarkerSchema } from "@/lib/validations/biomarkers";
@@ -51,6 +52,7 @@ function serialiseBiomarker(row: {
lowerBound: number | null;
upperBound: number | null;
panel: string | null;
analyteKey: string | null;
hidden: boolean;
contextEncrypted: Uint8Array | null;
createdAt: Date;
@@ -63,6 +65,7 @@ function serialiseBiomarker(row: {
lowerBound: row.lowerBound,
upperBound: row.upperBound,
panel: row.panel,
analyteKey: row.analyteKey,
hasContext: row.contextEncrypted !== null,
context: decryptContextSoft(row.contextEncrypted),
hidden: row.hidden,
@@ -158,6 +161,9 @@ async function postBiomarker(request: NextRequest) {
lowerBound: lowerBound ?? null,
upperBound: upperBound ?? null,
panel: panel ?? null,
// Which analyte it is, for the unit conversion; null when the name is no
// analyte the registry knows.
analyteKey: matchAnalyteKey({ name, unit }),
contextEncrypted: context ? encryptContextToBytes(context) : null,
},
});
+5
View File
@@ -60,6 +60,11 @@ export interface BiomarkerDto {
lowerBound: number | null;
upperBound: number | null;
panel: string | null;
/**
* Which analyte the marker is, for converting a reading in another unit
* (the unit-conversion registry's key), or null when it is none that is known.
*/
analyteKey: string | null;
hasContext: boolean;
context: string | null;
/** v1.22 — a hidden marker drops from the active list + lab-entry pickers. */
+2
View File
@@ -28,6 +28,7 @@ import { prisma } from "@/lib/db";
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import { integrationBackfillSourceOptions } from "@/lib/jobs/integration-backfill-admission";
import { matchAnalyteKey } from "@/lib/labs/analyte-match";
import { annotate } from "@/lib/logging/context";
export const LAB_BIOMARKER_BACKFILL_QUEUE = "lab-biomarker-backfill";
@@ -161,6 +162,7 @@ export async function runLabBiomarkerBackfillForUser(
lowerBound: group.referenceLow,
upperBound: group.referenceHigh,
panel: group.panel,
analyteKey: matchAnalyteKey({ name: group.name, unit: group.unit }),
},
});
markers += 1;
@@ -0,0 +1,164 @@
import { describe, expect, it } from "vitest";
import { foldForMatch } from "@/lib/i18n/fold-for-match";
import { matchAnalyteKey, analyteNameIndex } from "../analyte-match";
import { ANALYTE_ALIASES, BIOMARKER_CATALOG } from "../biomarker-catalog";
import { analyteAcceptsUnit } from "../unit-conversion/convert";
import {
allAnalyteConversions,
analyteConversionFor,
} from "../unit-conversion/registry";
const match = (name: string, unit: string) => matchAnalyteKey({ name, unit });
describe("the catalog's own names, in every language", () => {
it.each([
["Fasting glucose", "mg/dL", "fasting-glucose"],
["Nüchternglukose", "mg/dL", "fasting-glucose"],
["Glucosa en ayunas", "mg/dL", "fasting-glucose"],
["Glycémie à jeun", "mmol/L", "fasting-glucose"],
["Glicemia a digiuno", "mg/dL", "fasting-glucose"],
["공복혈당", "mg/dL", "fasting-glucose"],
["Glukoza na czczo", "mg/dL", "fasting-glucose"],
["HbA1c", "%", "hba1c"],
["Total cholesterol", "mg/dL", "total-cholesterol"],
["Gesamtcholesterin", "mg/dL", "total-cholesterol"],
["Cholestérol total", "mmol/L", "total-cholesterol"],
["LDL cholesterol", "mg/dL", "ldl"],
["LDL-Cholesterin", "mg/dL", "total-cholesterol"],
["Colesterolo HDL", "mg/dL", "total-cholesterol"],
["Vitamin D (25-OH)", "ng/mL", "vitamin-d"],
["Créatinine", "mg/dL", "creatinine"],
["Kreatynina", "µmol/L", "creatinine"],
])("%s (%s) is %s", (name, unit, key) => {
// LDL and HDL are aliases of cholesterol: the registry reports one key.
const expected = analyteConversionFor(key)!.key;
expect(match(name, unit)).toBe(expected);
});
it("recognises a seed typed without the app's gloss", () => {
expect(match("Vitamin D", "ng/mL")).toBe("vitamin-d");
});
});
describe("typed names", () => {
it.each([
["Glucose", "mg/dL", "fasting-glucose"],
["Blutzucker", "mg/dL", "fasting-glucose"],
["blood sugar", "mmol/L", "fasting-glucose"],
["Cholesterin", "mg/dL", "total-cholesterol"],
["Serum Creatinine", "mg/dL", "creatinine"],
["Urea", "mmol/L", "urea"],
["Harnstoff", "mg/dL", "urea"],
["BUN", "mg/dL", "blood-urea-nitrogen"],
["Blood Urea Nitrogen", "mg/dL", "blood-urea-nitrogen"],
["Harnsäure", "µmol/L", "uric-acid"],
["Uric acid", "mg/dL", "uric-acid"],
["Kalzium", "mmol/L", "calcium-total"],
["Calcium", "mg/dL", "calcium-total"],
["Gesamtbilirubin", "µmol/L", "bilirubin-total"],
["Bilirubin total", "mg/dL", "bilirubin-total"],
])("%s (%s) is %s", (name, unit, key) => {
expect(match(name, unit)).toBe(key);
});
it("folds case, accents, hyphens and spacing the same on both sides", () => {
expect(match(" HARNSÄURE ", "mg/dL")).toBe("uric-acid");
expect(match("ldl-c", "mg/dL")).toBe(analyteConversionFor("ldl")!.key);
expect(match("blood urea-nitrogen", "mg/dL")).toBe("blood-urea-nitrogen");
});
});
describe("a name that is not the whole analyte name stays unlinked", () => {
it.each([
["Calcium (ionisiert)", "mmol/L"],
["Ionised calcium", "mmol/L"],
["Calcium ionisiert", "mmol/L"],
["Urea nitrogen ratio", "mg/dL"],
["Glucose (urine)", "mg/dL"],
["Glucose tolerance", "mg/dL"],
["Non-HDL cholesterol", "mg/dL"],
["Cholesterol ratio", "mg/dL"],
["Direct bilirubin", "mg/dL"],
["Free thyroxine", "pmol/L"],
["", "mg/dL"],
])("%s", (name, unit) => {
expect(match(name, unit)).toBeNull();
});
it("keeps urea and blood urea nitrogen apart", () => {
expect(match("Urea", "mmol/L")).toBe("urea");
expect(match("Urea nitrogen", "mg/dL")).toBe("blood-urea-nitrogen");
});
});
describe("the marker's unit must be one the analyte reaches", () => {
it("links a unit the entry lists and one that differs by a power of ten", () => {
expect(match("Glucose", "mg/dL")).toBe("fasting-glucose");
expect(match("Glucose", "mmol/L")).toBe("fasting-glucose");
expect(match("Glucose", "g/L")).toBe("fasting-glucose");
expect(match("Creatinine", "mmol/L")).toBe("creatinine");
});
it("leaves a unit the registry cannot reach unlinked", () => {
expect(match("Glucose", "mEq/L")).toBeNull();
expect(match("Glucose", "%")).toBeNull();
expect(match("HbA1c", "mg/dL")).toBeNull();
expect(match("Calcium", "furlongs")).toBeNull();
});
it("reads a spelling of the unit through the normaliser", () => {
expect(match("Glucose", "MG/DL")).toBe("fasting-glucose");
expect(match("Creatinine", "umol/l")).toBe("creatinine");
});
});
describe("the alias list, as a guard", () => {
it("names only analytes the registry knows", () => {
for (const { key } of ANALYTE_ALIASES) {
expect(analyteConversionFor(key), key).not.toBeNull();
}
});
it("writes every name already folded, so what is read is what is matched", () => {
for (const { key, names } of ANALYTE_ALIASES) {
for (const name of names) {
expect(foldForMatch(name), `${key}: ${name}`).toBe(name);
}
}
});
it("has no name that two analytes claim", () => {
expect(analyteNameIndex().ambiguous).toEqual([]);
});
it("gives every registry entry at least one name to be found by", () => {
const { index } = analyteNameIndex();
const reached = new Set(index.values());
for (const entry of allAnalyteConversions()) {
expect(reached.has(entry.key), entry.key).toBe(true);
}
});
it("covers every catalog seed the registry knows, in all seven languages", () => {
const { index } = analyteNameIndex();
const seeds = BIOMARKER_CATALOG.filter((seed) =>
analyteConversionFor(seed.slug),
);
expect(seeds.length).toBeGreaterThanOrEqual(6);
for (const seed of seeds) {
const key = analyteConversionFor(seed.slug)!.key;
const names = [...index.entries()].filter(([, k]) => k === key);
expect(names.length, seed.slug).toBeGreaterThan(0);
}
});
it("links each seed at its own catalog unit", () => {
for (const seed of BIOMARKER_CATALOG) {
const entry = analyteConversionFor(seed.slug);
if (!entry) continue;
expect(analyteAcceptsUnit(entry.key, seed.unit), seed.slug).toBe(true);
}
});
});
+37
View File
@@ -0,0 +1,37 @@
/**
* Link a marker to its analyte for the unit conversion.
*
* The key is set when a marker is created and, for a marker that already
* existed, the first time a reading needs it. It is never overwritten: a key a
* person (or an earlier pass) set stands, and `updateMany` is gated on the
* column still being null, so two concurrent writers converge. A marker whose
* name matches no analyte keeps a null key and gets no conversion.
*/
import type { PrismaClient } from "@/generated/prisma/client";
import { matchAnalyteKey } from "./analyte-match";
export async function ensureAnalyteKey(
client: Pick<PrismaClient, "biomarker">,
marker: {
id: string;
name: string;
unit: string;
analyteKey?: string | null;
},
): Promise<string | null> {
if (marker.analyteKey) return marker.analyteKey;
const key = matchAnalyteKey({ name: marker.name, unit: marker.unit });
if (!key) return null;
const { count } = await client.biomarker.updateMany({
where: { id: marker.id, analyteKey: null },
data: { analyteKey: key },
});
if (count > 0) return key;
// Another writer set it first; read what stands.
const current = await client.biomarker.findUnique({
where: { id: marker.id },
select: { analyteKey: true },
});
return current?.analyteKey ?? null;
}
+81
View File
@@ -0,0 +1,81 @@
/**
* Which analyte a marker is, for the unit conversion.
*
* `matchAnalyteKey` answers from two places: the localised names of the
* catalog seeds the registry knows (so a marker created from the "pick a common
* marker" list is recognised in every language the app ships) and the alias
* list in the catalog. The name must equal one of them as a WHOLE, folded the
* same way on both sides; a part of a name never matches.
*
* Two safeguards keep a link from being wrong:
* - a folded name that two analytes claim matches neither;
* - the marker's own unit must be one the analyte reaches, so a marker called
* "Glucose" in a unit the registry cannot convert is left unlinked.
*
* Returns the registry key, or null. Pure apart from reading the message
* bundles once.
*/
import { foldForMatch } from "@/lib/i18n/fold-for-match";
import { localisedValues } from "@/lib/i18n/shared-resolve";
import { ANALYTE_ALIASES, BIOMARKER_CATALOG } from "./biomarker-catalog";
import { analyteAcceptsUnit } from "./unit-conversion/convert";
import { analyteConversionFor } from "./unit-conversion/registry";
/** The folded name without a trailing parenthetical gloss: "Vitamin D (25-OH)" -> "vitamin d". */
function withoutGloss(folded: string): string {
return folded.replace(/\s*\([^)]*\)/gu, "").trim();
}
let cache: ReadonlyMap<string, string> | null = null;
/**
* folded name -> registry key, with every name two analytes claim removed.
* Exported for the guard test, which asserts nothing was dropped silently.
*/
export function analyteNameIndex(): {
index: ReadonlyMap<string, string>;
ambiguous: readonly string[];
} {
const claims = new Map<string, Set<string>>();
const claim = (name: string, key: string) => {
const folded = foldForMatch(name);
if (!folded) return;
const keys = claims.get(folded) ?? new Set<string>();
keys.add(key);
claims.set(folded, keys);
};
for (const seed of BIOMARKER_CATALOG) {
const entry = analyteConversionFor(seed.slug);
if (!entry) continue;
for (const name of localisedValues(`labs.catalog.${seed.slug}`)) {
claim(name, entry.key);
// The bare name too, for a marker typed without the app's gloss.
claim(withoutGloss(foldForMatch(name)), entry.key);
}
}
for (const { key, names } of ANALYTE_ALIASES) {
const entry = analyteConversionFor(key);
if (!entry) continue;
for (const name of names) claim(name, entry.key);
}
const index = new Map<string, string>();
const ambiguous: string[] = [];
for (const [folded, keys] of claims) {
if (keys.size === 1) index.set(folded, [...keys][0]);
else ambiguous.push(folded);
}
return { index, ambiguous };
}
export function matchAnalyteKey(input: {
name: string;
unit: string;
}): string | null {
cache ??= analyteNameIndex().index;
const key = cache.get(foldForMatch(input.name));
if (!key) return null;
return analyteAcceptsUnit(key, input.unit) ? key : null;
}
+147
View File
@@ -276,3 +276,150 @@ export const BIOMARKER_PANELS = [
"electrolytes",
"bloodCount",
] as const;
/**
* Names a marker may carry for an analyte the unit-conversion registry knows
* (`src/lib/labs/unit-conversion/registry.ts`), keyed by the registry's key.
*
* A `Biomarker` row keeps only its name, not the seed it came from, so this is
* how a marker is told which analyte it is: its folded name equals one of these,
* or one of the localised names of a seed the registry knows (`ldl`, `hba1c`,
* `fasting-glucose`, ...). The match is the WHOLE name, never a part of it, so
* "Calcium (ionisiert)" is not "Calcium" and "Urea nitrogen" is not "Urea". A
* name that two analytes claim is left out of the match, and a marker named in
* some other way stays unlinked, which is the safe default.
*
* Names are compared folded (lower case, no accents, hyphens and slashes as
* spaces), so "Harnsäure" is written "harnsaure" here and "Nüchtern-Glukose"
* needs no entry of its own. The registry-only keys (urea, blood urea
* nitrogen, uric acid, total calcium, total bilirubin) have no seed, so their
* names live only here.
*/
export interface AnalyteAliases {
/** The registry key of the analyte. */
key: string;
names: readonly string[];
}
export const ANALYTE_ALIASES: readonly AnalyteAliases[] = [
{
key: "fasting-glucose",
names: [
"glucose",
"blood glucose",
"blood sugar",
"plasma glucose",
"fasting blood glucose",
"fasting blood sugar",
"fasting plasma glucose",
"glukose",
"blutglukose",
"blutzucker",
"nuchternblutzucker",
"glucosa",
"glicemia",
"glycemie",
"glucosio",
"glukoza",
"glikemia",
],
},
{
key: "hba1c",
names: [
"hb a1c",
"a1c",
"hemoglobin a1c",
"glycated hemoglobin",
"glycosylated hemoglobin",
"glykohamoglobin",
"hamoglobin a1c",
],
},
{
key: "total-cholesterol",
names: [
"cholesterol",
"cholesterin",
"cholesterol total",
"cholesterin gesamt",
"cholesterol gesamt",
"colesterol",
"colesterolo",
"cholesterol calkowity",
],
},
{ key: "ldl", names: ["ldl", "ldl c", "ldl chol", "ldl cholesterin"] },
{ key: "hdl", names: ["hdl", "hdl c", "hdl chol", "hdl cholesterin"] },
{
key: "creatinine",
names: [
"serum creatinine",
"serumkreatinin",
"kreatinin im serum",
"creatinine serique",
],
},
{
key: "vitamin-d",
names: [
"25 oh vitamin d",
"25 hydroxy vitamin d",
"25 hydroxyvitamin d",
"25 oh d",
"calcidiol",
"vitamin d 25 oh",
],
},
{ key: "urea", names: ["urea", "harnstoff", "uree", "urea serum"] },
{
key: "blood-urea-nitrogen",
names: [
"bun",
"blood urea nitrogen",
"urea nitrogen",
"harnstoff stickstoff",
"stickstoff harnstoff",
"azote ureique",
],
},
{
key: "uric-acid",
names: [
"uric acid",
"urate",
"harnsaure",
"acide urique",
"acido urico",
"kwas moczowy",
],
},
{
key: "calcium-total",
names: [
"calcium",
"total calcium",
"calcium total",
"calcium gesamt",
"gesamtcalcium",
"kalzium",
"kalzium gesamt",
"gesamtkalzium",
"calcio",
"wapn",
],
},
{
key: "bilirubin-total",
names: [
"bilirubin",
"total bilirubin",
"bilirubin total",
"bilirubin gesamt",
"gesamtbilirubin",
"bilirrubina",
"bilirubine",
"bilirubina",
],
},
];
+2
View File
@@ -13,6 +13,7 @@ import { Buffer } from "node:buffer";
import { decrypt, encrypt } from "@/lib/crypto";
import { prisma } from "@/lib/db";
import { matchAnalyteKey } from "@/lib/labs/analyte-match";
import { getEvent } from "@/lib/logging/context";
/** Encrypt a UTF-8 context note into the `Bytes` payload the schema stores. */
@@ -120,6 +121,7 @@ export async function resolveOrMintBiomarker(
lowerBound: input.referenceLow,
upperBound: input.referenceHigh,
panel: input.panel,
analyteKey: matchAnalyteKey({ name, unit: input.unit }),
},
select,
});
+16
View File
@@ -165,3 +165,19 @@ export function conversionsFor(input: {
found.delete(to);
return [...found].filter((unit) => normaliseLabUnit(unit) !== to);
}
/**
* Whether the analyte's entry reaches `unit`: the unit is one it lists, or
* differs from one only by a power of ten. A marker is linked to an analyte only
* when its own unit passes this, so a marker called "Glucose" tracked in some
* unit the registry cannot reach is not given a conversion it cannot make.
*/
export function analyteAcceptsUnit(
analyteKey: string | null | undefined,
unit: string,
): boolean {
const entry = analyteConversionFor(analyteKey);
return (
entry !== null && intoEntryUnit(entry, normaliseLabUnit(unit)) !== null
);
}
+2 -1
View File
@@ -53,6 +53,7 @@ const biomarkerRow = z
lowerBound: z.number().nullable(),
upperBound: z.number().nullable(),
panel: z.string().nullable(),
analyteKey: z.string().nullable(),
hasContext: z.boolean(),
context: z.string().nullable(),
hidden: z.boolean(),
@@ -62,7 +63,7 @@ const biomarkerRow = z
.meta({
id: "Biomarker",
description:
"A user-scoped catalog marker. `context` is the decrypted per-marker note (or null); `hasContext` flags its presence. `hidden` drops the marker from the active catalog list and the lab-entry pickers without deleting it (its readings and unit/range survive). A `LabResult` linking this marker resolves its unit + reference bounds from here.",
"A user-scoped catalog marker. `context` is the decrypted per-marker note (or null); `hasContext` flags its presence. `analyteKey` is the analyte the marker was recognised as (the unit-conversion registry's key, e.g. `fasting-glucose`), or null when its name matches none; a reading in another unit is converted only for a marker that has one. `hidden` drops the marker from the active catalog list and the lab-entry pickers without deleting it (its readings and unit/range survive). A `LabResult` linking this marker resolves its unit + reference bounds from here.",
});
const listResponse = z