mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
feat(labs): resolve marker names through aliases and catalog shorthand
A printed name now resolves to one of the account's markers by its own name, then by an alias of the account, then by a catalog name for the same analyte when the reading's unit fits. The catalog gains seeds for alkaline phosphatase and total bilirubin and a checked list of common lab shorthand in seven languages; names that mean two analytes stay out.
This commit is contained in:
1 parent
20a1ac4f7d
commit
a3ce6af9a3
14 files changed
+1648
-101
No files matched your search
@@ -0,0 +1,286 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { foldForMatch } from "@/lib/i18n/fold-for-match";
|
||||
|
||||
import { catalogKeyForName, catalogNameIndex } from "../analyte-match";
|
||||
import { ANALYTE_ALIASES, LAB_NAME_ALIASES } from "../biomarker-catalog";
|
||||
import {
|
||||
addAliasToIndex,
|
||||
matchForReview,
|
||||
nameSimilarity,
|
||||
resolveInIndex,
|
||||
suggestInIndex,
|
||||
type IndexedMarker,
|
||||
type MarkerIndex,
|
||||
} from "../biomarker-resolve";
|
||||
|
||||
let seq = 0;
|
||||
function marker(
|
||||
name: string,
|
||||
unit: string,
|
||||
extra: Partial<IndexedMarker> = {},
|
||||
) {
|
||||
seq += 1;
|
||||
return {
|
||||
id: `bm-${seq}`,
|
||||
name,
|
||||
unit,
|
||||
lowerBound: null,
|
||||
upperBound: null,
|
||||
panel: null,
|
||||
analyteKey: null,
|
||||
hidden: false,
|
||||
createdAt: new Date(Date.UTC(2026, 0, seq)),
|
||||
...extra,
|
||||
} satisfies IndexedMarker;
|
||||
}
|
||||
|
||||
function index(
|
||||
markers: IndexedMarker[],
|
||||
aliases: Array<{ biomarkerId: string; name: string }> = [],
|
||||
): MarkerIndex {
|
||||
return {
|
||||
markers,
|
||||
aliases: aliases.map((a) => ({ ...a, nameFolded: foldForMatch(a.name) })),
|
||||
};
|
||||
}
|
||||
|
||||
describe("the catalog's shorthand (D1223)", () => {
|
||||
it.each([
|
||||
["Alk.P", "alkaline-phosphatase"],
|
||||
["Alk Phos", "alkaline-phosphatase"],
|
||||
["ALP", "alkaline-phosphatase"],
|
||||
["Alkalische Phosphatase (AP)", "alkaline-phosphatase"],
|
||||
["T.Bili", "bilirubin-total"],
|
||||
["T. Bili", "bilirubin-total"],
|
||||
["TBIL", "bilirubin-total"],
|
||||
["Bilirubin gesamt", "bilirubin-total"],
|
||||
["GPT", "alt"],
|
||||
["ALAT", "alt"],
|
||||
["AST(SGOT)", "ast"],
|
||||
["GOT", "ast"],
|
||||
["γ-GT", "ggt"],
|
||||
["Gamma-GT", "ggt"],
|
||||
["GGTP", "ggt"],
|
||||
["LDH", "ldh"],
|
||||
["CRP", "crp"],
|
||||
["hsCRP", "hs-crp"],
|
||||
["TSH basal", "tsh"],
|
||||
["fT3", "ft3"],
|
||||
["T4L", "ft4"],
|
||||
["HbA1c", "hba1c"],
|
||||
["eGFR", "egfr"],
|
||||
["Hb", "hemoglobin"],
|
||||
["Hkt", "hematocrit"],
|
||||
["MCV", "mcv"],
|
||||
["PLT", "platelets"],
|
||||
["Leuko", "wbc"],
|
||||
["Krea", "creatinine"],
|
||||
["25(OH)D", "vitamin-d"],
|
||||
["Ferr", "ferritin"],
|
||||
["B12", "vitamin-b12"],
|
||||
])("%s is %s", (name, key) => {
|
||||
expect(catalogKeyForName(name)).toBe(key);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Bilirubin (Total)", "bilirubin-total"],
|
||||
["Kreatinin (Serum)", "creatinine"],
|
||||
["GPT (ALT)", "alt"],
|
||||
])("%s, with its parenthetical, is %s", (name, key) => {
|
||||
expect(catalogKeyForName(name)).toBe(key);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["PCR"],
|
||||
["TG"],
|
||||
["T3"],
|
||||
["T4"],
|
||||
["Cr"],
|
||||
["Calcitriol"],
|
||||
["LDL-P"],
|
||||
["Calcium (ionisiert)"],
|
||||
["Glucose (urine)"],
|
||||
["GPT (AST)"],
|
||||
])("%s is no catalog name (ambiguous or another test)", (name) => {
|
||||
expect(catalogKeyForName(name)).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the standard and the high-sensitivity CRP apart", () => {
|
||||
expect(catalogKeyForName("CRP")).not.toBe(catalogKeyForName("hs-CRP"));
|
||||
});
|
||||
|
||||
it("writes every name already folded", () => {
|
||||
for (const { key, names } of [...ANALYTE_ALIASES, ...LAB_NAME_ALIASES]) {
|
||||
for (const name of names) {
|
||||
expect(foldForMatch(name), `${key}: ${name}`).toBe(name);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("has no name that two analytes claim", () => {
|
||||
expect(catalogNameIndex().ambiguous).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveInIndex", () => {
|
||||
it("resolves the marker's own name, case aside", () => {
|
||||
const ldl = marker("LDL", "mg/dL");
|
||||
expect(resolveInIndex(index([ldl]), "ldl")).toEqual({
|
||||
marker: ldl,
|
||||
matchKind: "name",
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers the exact spelling over a case variant", () => {
|
||||
const a = marker("ldl", "mg/dL");
|
||||
const b = marker("LDL", "mg/dL");
|
||||
expect(resolveInIndex(index([a, b]), "LDL")?.marker).toBe(b);
|
||||
});
|
||||
|
||||
it("resolves an alias of the account, folded", () => {
|
||||
const alp = marker("Alkaline Phosphatase", "U/L");
|
||||
const found = resolveInIndex(
|
||||
index([alp], [{ biomarkerId: alp.id, name: "Alk.P" }]),
|
||||
"ALK.P",
|
||||
);
|
||||
expect(found).toEqual({ marker: alp, matchKind: "alias" });
|
||||
});
|
||||
|
||||
it("resolves a catalog abbreviation to the account's marker of that analyte", () => {
|
||||
const alp = marker("Alkaline Phosphatase", "U/L");
|
||||
expect(resolveInIndex(index([alp]), "Alk.P", "U/L")).toEqual({
|
||||
marker: alp,
|
||||
matchKind: "catalog",
|
||||
});
|
||||
// The discussion's own pair: a marker named with a neutral qualifier.
|
||||
const bili = marker("Bilirubin (Total)", "mg/dL");
|
||||
expect(resolveInIndex(index([bili]), "T.Bili", "mg/dL")).toEqual({
|
||||
marker: bili,
|
||||
matchKind: "catalog",
|
||||
});
|
||||
});
|
||||
|
||||
it("recognises a marker by one of its aliases' catalog analyte", () => {
|
||||
const liver = marker("Leberwert 1", "U/L");
|
||||
const found = resolveInIndex(
|
||||
index([liver], [{ biomarkerId: liver.id, name: "ALT" }]),
|
||||
"GPT",
|
||||
"U/L",
|
||||
);
|
||||
expect(found).toEqual({ marker: liver, matchKind: "catalog" });
|
||||
});
|
||||
|
||||
it("converts across units in the catalog step where the registry can", () => {
|
||||
const glucose = marker("Glucose", "mg/dL", {
|
||||
analyteKey: "fasting-glucose",
|
||||
});
|
||||
expect(
|
||||
resolveInIndex(index([glucose]), "Blutzucker", "mmol/L")?.marker,
|
||||
).toBe(glucose);
|
||||
});
|
||||
|
||||
it("steps aside in the catalog step for a unit it cannot reconcile", () => {
|
||||
const alt = marker("ALT", "U/L");
|
||||
expect(resolveInIndex(index([alt]), "GPT", "µkat/L")).toBeNull();
|
||||
// A qualitative reading or none stated always fits.
|
||||
expect(resolveInIndex(index([alt]), "GPT", null)?.marker).toBe(alt);
|
||||
});
|
||||
|
||||
it("lets the name and the alias win regardless of the unit", () => {
|
||||
const alt = marker("ALT", "U/L");
|
||||
expect(resolveInIndex(index([alt]), "ALT", "µkat/L")?.matchKind).toBe(
|
||||
"name",
|
||||
);
|
||||
expect(
|
||||
resolveInIndex(
|
||||
index([alt], [{ biomarkerId: alt.id, name: "Leber ALT" }]),
|
||||
"Leber ALT",
|
||||
"µkat/L",
|
||||
)?.matchKind,
|
||||
).toBe("alias");
|
||||
});
|
||||
|
||||
it("prefers a visible marker over a hidden one in the catalog step", () => {
|
||||
const hidden = marker("ALT", "U/L", { hidden: true });
|
||||
const visible = marker("GPT (ALT)", "U/L");
|
||||
expect(
|
||||
resolveInIndex(index([hidden, visible]), "SGPT", "U/L")?.marker,
|
||||
).toBe(visible);
|
||||
});
|
||||
|
||||
it("resolves nothing for an unknown name", () => {
|
||||
expect(resolveInIndex(index([marker("ALT", "U/L")]), "Zinc")).toBeNull();
|
||||
expect(resolveInIndex(index([]), " ")).toBeNull();
|
||||
});
|
||||
|
||||
it("sees an alias added after the index was loaded", () => {
|
||||
const tsh = marker("TSH", "mIU/L");
|
||||
const idx = index([tsh]);
|
||||
addAliasToIndex(idx, tsh.id, "Thyreotropes Hormon");
|
||||
expect(resolveInIndex(idx, "thyreotropes hormon")?.matchKind).toBe("alias");
|
||||
});
|
||||
});
|
||||
|
||||
describe("suggestions", () => {
|
||||
it("offers the same analyte first when its unit kept it from matching", () => {
|
||||
const alt = marker("ALT", "U/L");
|
||||
const { match, suggestions } = matchForReview(
|
||||
index([alt, marker("AST", "U/L")]),
|
||||
"GPT",
|
||||
"µkat/L",
|
||||
);
|
||||
expect(match).toBeNull();
|
||||
expect(suggestions[0]).toEqual({
|
||||
biomarkerId: alt.id,
|
||||
name: "ALT",
|
||||
unit: "U/L",
|
||||
matchKind: "catalog",
|
||||
});
|
||||
});
|
||||
|
||||
it("offers similar names through the catalog's names for the marker", () => {
|
||||
const bili = marker("T.Bili", "mg/dL");
|
||||
const suggestions = suggestInIndex(index([bili]), "Bilirubin ges.");
|
||||
expect(suggestions).toEqual([
|
||||
{
|
||||
biomarkerId: bili.id,
|
||||
name: "T.Bili",
|
||||
unit: "mg/dL",
|
||||
matchKind: "similar",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("offers shorthand against a full name", () => {
|
||||
const alp = marker("Alkaline Phosphatase", "U/L");
|
||||
expect(suggestInIndex(index([alp]), "Alk. Phosph.")[0]?.biomarkerId).toBe(
|
||||
alp.id,
|
||||
);
|
||||
});
|
||||
|
||||
it("offers at most three and never a hidden marker", () => {
|
||||
const markers = [
|
||||
marker("Vitamin B1", "µg/L"),
|
||||
marker("Vitamin B2", "µg/L"),
|
||||
marker("Vitamin B6", "µg/L"),
|
||||
marker("Vitamin B9", "µg/L"),
|
||||
marker("Vitamin B3", "µg/L", { hidden: true }),
|
||||
];
|
||||
const suggestions = suggestInIndex(index(markers), "Vitamin B5");
|
||||
expect(suggestions).toHaveLength(3);
|
||||
expect(suggestions.map((s) => s.name)).not.toContain("Vitamin B3");
|
||||
});
|
||||
|
||||
it("offers nothing unrelated and nothing when the name matched", () => {
|
||||
const ldl = marker("LDL", "mg/dL");
|
||||
expect(suggestInIndex(index([ldl]), "Ferritin")).toEqual([]);
|
||||
expect(matchForReview(index([ldl]), "LDL").suggestions).toEqual([]);
|
||||
});
|
||||
|
||||
it("scores names", () => {
|
||||
expect(nameSimilarity("Ferritin", "ferritin")).toBe(1);
|
||||
expect(nameSimilarity("alk phos", "alkaline phosphatase")).toBe(0.8);
|
||||
expect(nameSimilarity("LDL", "Ferritin")).toBeLessThan(0.5);
|
||||
});
|
||||
});
|
||||
@@ -1,20 +1,24 @@
|
||||
/**
|
||||
* v1.18.1 — `resolveOrMintBiomarker`: the auto-link-on-write helper.
|
||||
* `resolveOrMintBiomarker`: the auto-link-on-write helper.
|
||||
*
|
||||
* Every free-text lab write resolves (or mints) a user-scoped Biomarker by
|
||||
* `(userId, lower(analyte))` so NO `LabResult` row ever persists unlinked.
|
||||
* Asserts: case-insensitive reuse, mint-on-miss from the reading's own
|
||||
* spelling, no silent rewrite of an existing marker, and the concurrent
|
||||
* Every free-text lab write resolves (or mints) a user-scoped Biomarker so NO
|
||||
* `LabResult` row ever persists unlinked. Since v1.43 the name resolves
|
||||
* through the account's markers and aliases (`resolveInIndex`), loaded in one
|
||||
* read. Asserts: case-insensitive reuse, alias reuse, mint-on-miss from the
|
||||
* reading's own spelling, no silent rewrite of an existing marker, a minted
|
||||
* marker found by the next row through a shared index, and the concurrent
|
||||
* unique-violation race adopting the winner.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const db = vi.hoisted(() => ({
|
||||
biomarker: { findFirst: vi.fn(), create: vi.fn() },
|
||||
biomarker: { findFirst: vi.fn(), findMany: vi.fn(), create: vi.fn() },
|
||||
biomarkerAlias: { findMany: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({ prisma: db }));
|
||||
|
||||
import { loadMarkerIndex } from "@/lib/labs/biomarker-resolve";
|
||||
import { resolveOrMintBiomarker } from "@/lib/labs/biomarker-store";
|
||||
|
||||
const baseInput = {
|
||||
@@ -25,21 +29,32 @@ const baseInput = {
|
||||
panel: "lipids" as string | null,
|
||||
};
|
||||
|
||||
function marker(overrides: Record<string, unknown>) {
|
||||
return {
|
||||
id: "bm",
|
||||
name: "Marker",
|
||||
unit: "mg/dL",
|
||||
lowerBound: null,
|
||||
upperBound: null,
|
||||
panel: null,
|
||||
analyteKey: null,
|
||||
hidden: false,
|
||||
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
db.biomarker.findMany.mockResolvedValue([]);
|
||||
db.biomarkerAlias.findMany.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
describe("resolveOrMintBiomarker", () => {
|
||||
it("reuses an existing marker (case-insensitive) without minting", async () => {
|
||||
db.biomarker.findFirst.mockResolvedValue({
|
||||
id: "bm_existing",
|
||||
name: "LDL Cholesterol",
|
||||
unit: "mg/dL",
|
||||
lowerBound: null,
|
||||
upperBound: 100,
|
||||
panel: "lipids",
|
||||
analyteKey: null,
|
||||
});
|
||||
db.biomarker.findMany.mockResolvedValue([
|
||||
marker({ id: "bm_existing", name: "LDL", upperBound: 100 }),
|
||||
]);
|
||||
|
||||
const out = await resolveOrMintBiomarker("u1", {
|
||||
...baseInput,
|
||||
@@ -51,23 +66,33 @@ describe("resolveOrMintBiomarker", () => {
|
||||
// values do NOT rewrite it.
|
||||
expect(out.upperBound).toBe(100);
|
||||
expect(db.biomarker.create).not.toHaveBeenCalled();
|
||||
// Match is case-insensitive on the catalog identity.
|
||||
const where = db.biomarker.findFirst.mock.calls[0][0].where;
|
||||
expect(where.name).toEqual({ equals: "ldl", mode: "insensitive" });
|
||||
expect(where.userId).toBe("u1");
|
||||
expect(db.biomarker.findMany.mock.calls[0][0].where).toEqual({
|
||||
userId: "u1",
|
||||
});
|
||||
});
|
||||
|
||||
it("reuses the marker an alias of the account names", async () => {
|
||||
db.biomarker.findMany.mockResolvedValue([
|
||||
marker({ id: "bm_alp", name: "Alkaline Phosphatase", unit: "U/L" }),
|
||||
]);
|
||||
db.biomarkerAlias.findMany.mockResolvedValue([
|
||||
{ biomarkerId: "bm_alp", name: "Alk.P", nameFolded: "alk.p" },
|
||||
]);
|
||||
|
||||
const out = await resolveOrMintBiomarker("u1", {
|
||||
...baseInput,
|
||||
analyte: "ALK.P",
|
||||
unit: "U/L",
|
||||
});
|
||||
|
||||
expect(out.id).toBe("bm_alp");
|
||||
expect(db.biomarker.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("mints a new marker from the reading when none exists", async () => {
|
||||
db.biomarker.findFirst.mockResolvedValue(null);
|
||||
db.biomarker.create.mockResolvedValue({
|
||||
id: "bm_new",
|
||||
name: "LDL",
|
||||
unit: "mg/dL",
|
||||
lowerBound: null,
|
||||
upperBound: 116,
|
||||
panel: "lipids",
|
||||
analyteKey: null,
|
||||
});
|
||||
db.biomarker.create.mockResolvedValue(
|
||||
marker({ id: "bm_new", name: "LDL", upperBound: 116, panel: "lipids" }),
|
||||
);
|
||||
|
||||
const out = await resolveOrMintBiomarker("u1", baseInput);
|
||||
|
||||
@@ -84,16 +109,9 @@ describe("resolveOrMintBiomarker", () => {
|
||||
});
|
||||
|
||||
it("trims the analyte before minting", async () => {
|
||||
db.biomarker.findFirst.mockResolvedValue(null);
|
||||
db.biomarker.create.mockResolvedValue({
|
||||
id: "bm_x",
|
||||
name: "Ferritin",
|
||||
unit: "ng/mL",
|
||||
lowerBound: 30,
|
||||
upperBound: 400,
|
||||
panel: null,
|
||||
analyteKey: null,
|
||||
});
|
||||
db.biomarker.create.mockResolvedValue(
|
||||
marker({ id: "bm_x", name: "Ferritin", unit: "ng/mL" }),
|
||||
);
|
||||
|
||||
await resolveOrMintBiomarker("u1", {
|
||||
analyte: " Ferritin ",
|
||||
@@ -106,17 +124,32 @@ describe("resolveOrMintBiomarker", () => {
|
||||
expect(db.biomarker.create.mock.calls[0][0].data.name).toBe("Ferritin");
|
||||
});
|
||||
|
||||
it("finds a marker minted for an earlier row through the shared index", async () => {
|
||||
db.biomarker.create.mockResolvedValue(
|
||||
marker({ id: "bm_tsh", name: "TSH", unit: "mIU/L" }),
|
||||
);
|
||||
const index = await loadMarkerIndex(db as never, "u1");
|
||||
const input = { ...baseInput, analyte: "TSH", unit: "mIU/L" };
|
||||
|
||||
const first = await resolveOrMintBiomarker("u1", input, { index });
|
||||
const second = await resolveOrMintBiomarker(
|
||||
"u1",
|
||||
{ ...input, analyte: "tsh" },
|
||||
{ index },
|
||||
);
|
||||
|
||||
expect(first.id).toBe("bm_tsh");
|
||||
expect(second.id).toBe("bm_tsh");
|
||||
expect(db.biomarker.create).toHaveBeenCalledTimes(1);
|
||||
expect(db.biomarker.findMany).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("adopts the race winner on a concurrent unique violation", async () => {
|
||||
// First lookup misses; create throws (the other writer won the unique
|
||||
// The index misses; create throws (the other writer won the unique
|
||||
// index); the re-lookup finds the winner.
|
||||
db.biomarker.findFirst.mockResolvedValueOnce(null).mockResolvedValueOnce({
|
||||
id: "bm_winner",
|
||||
name: "TSH",
|
||||
unit: "mIU/L",
|
||||
lowerBound: 0.4,
|
||||
upperBound: 4,
|
||||
panel: "thyroid",
|
||||
});
|
||||
db.biomarker.findFirst.mockResolvedValueOnce(
|
||||
marker({ id: "bm_winner", name: "TSH", unit: "mIU/L" }),
|
||||
);
|
||||
db.biomarker.create.mockRejectedValue(new Error("unique constraint"));
|
||||
|
||||
const out = await resolveOrMintBiomarker("u1", {
|
||||
@@ -128,7 +161,7 @@ describe("resolveOrMintBiomarker", () => {
|
||||
});
|
||||
|
||||
expect(out.id).toBe("bm_winner");
|
||||
expect(db.biomarker.findFirst).toHaveBeenCalledTimes(2);
|
||||
expect(db.biomarker.findFirst).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("throws when the create fails and no race winner exists", async () => {
|
||||
|
||||
@@ -22,7 +22,11 @@
|
||||
import { foldForMatch } from "@/lib/i18n/fold-for-match";
|
||||
import { localisedValues } from "@/lib/i18n/shared-resolve";
|
||||
|
||||
import { ANALYTE_ALIASES, BIOMARKER_CATALOG } from "./biomarker-catalog";
|
||||
import {
|
||||
ANALYTE_ALIASES,
|
||||
BIOMARKER_CATALOG,
|
||||
LAB_NAME_ALIASES,
|
||||
} from "./biomarker-catalog";
|
||||
import { analyteAcceptsUnit } from "./unit-conversion/convert";
|
||||
import { analyteConversionFor } from "./unit-conversion/registry";
|
||||
|
||||
@@ -77,7 +81,129 @@ export function matchAnalyteKey(input: {
|
||||
unit: string;
|
||||
}): string | null {
|
||||
cache ??= analyteNameIndex().index;
|
||||
const key = cache.get(foldForMatch(input.name));
|
||||
const key =
|
||||
cache.get(foldForMatch(input.name)) ??
|
||||
cache.get(foldWithoutDots(input.name));
|
||||
if (!key) return null;
|
||||
return analyteAcceptsUnit(key, input.unit) ? key : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The fold with every full stop and comma read as a space as well: lab
|
||||
* shorthand is printed "Alk.P", "T.Bili", "Bilirubin, total", and the dot or
|
||||
* comma carries no meaning there. Applied to both sides, like the fold itself.
|
||||
*/
|
||||
export function foldWithoutDots(raw: string): string {
|
||||
return foldForMatch(raw).replace(/[.,]/gu, " ").replace(/\s+/gu, " ").trim();
|
||||
}
|
||||
|
||||
let catalogCache: ReadonlyMap<string, string> | null = null;
|
||||
let catalogNamesCache: ReadonlyMap<string, readonly string[]> | null = null;
|
||||
|
||||
/**
|
||||
* Which catalog analyte a NAME is, for telling two markers of one account
|
||||
* apart from two spellings of one marker (D1223). Unlike `analyteNameIndex`
|
||||
* this needs no conversion: "GPT" and "ALT" are one analyte whether or not
|
||||
* the registry can move it between units, so every seed, every alias in
|
||||
* `ANALYTE_ALIASES` and every name in `LAB_NAME_ALIASES` claims here, folded
|
||||
* with and without dots. A name two analytes claim matches neither.
|
||||
* Exported for the guard test.
|
||||
*/
|
||||
export function catalogNameIndex(): {
|
||||
index: ReadonlyMap<string, string>;
|
||||
ambiguous: readonly string[];
|
||||
} {
|
||||
const claims = new Map<string, Set<string>>();
|
||||
const claim = (folded: string, key: string) => {
|
||||
if (!folded) return;
|
||||
const keys = claims.get(folded) ?? new Set<string>();
|
||||
keys.add(key);
|
||||
claims.set(folded, keys);
|
||||
};
|
||||
const claimName = (name: string, key: string) => {
|
||||
claim(foldForMatch(name), key);
|
||||
claim(foldWithoutDots(name), key);
|
||||
};
|
||||
|
||||
for (const seed of BIOMARKER_CATALOG) {
|
||||
for (const name of localisedValues(`labs.catalog.${seed.slug}`)) {
|
||||
claimName(name, seed.slug);
|
||||
claim(withoutGloss(foldForMatch(name)), seed.slug);
|
||||
}
|
||||
}
|
||||
for (const { key, names } of [...ANALYTE_ALIASES, ...LAB_NAME_ALIASES]) {
|
||||
for (const name of names) claimName(name, 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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Qualifiers that may stand in parentheses after a name without making it a
|
||||
* different measurement: "Bilirubin (Total)", "Kreatinin (Serum)". Anything
|
||||
* else in parentheses names something else ("Calcium (ionisiert)", "Glucose
|
||||
* (urine)") and keeps the name unmatched.
|
||||
*/
|
||||
const NEUTRAL_GLOSSES = new Set([
|
||||
"total",
|
||||
"totale",
|
||||
"totales",
|
||||
"gesamt",
|
||||
"serum",
|
||||
"s",
|
||||
"plasma",
|
||||
"blood",
|
||||
"blut",
|
||||
"sang",
|
||||
"sangre",
|
||||
"sangue",
|
||||
"krew",
|
||||
]);
|
||||
|
||||
function lookupWhole(name: string): string | null {
|
||||
catalogCache ??= catalogNameIndex().index;
|
||||
return (
|
||||
catalogCache.get(foldForMatch(name)) ??
|
||||
catalogCache.get(foldWithoutDots(name)) ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The catalog analyte a name belongs to, or null. Whole names only, with one
|
||||
* allowance for a trailing parenthetical: it is read when it names the same
|
||||
* analyte as the rest ("GPT (ALT)") or is a neutral qualifier ("Bilirubin
|
||||
* (Total)"), and never otherwise.
|
||||
*/
|
||||
export function catalogKeyForName(name: string): string | null {
|
||||
const whole = lookupWhole(name);
|
||||
if (whole) return whole;
|
||||
const glossed = /^(.+?)\s*\(([^()]+)\)\s*$/u.exec(name.trim());
|
||||
if (!glossed) return null;
|
||||
const outer = lookupWhole(glossed[1]);
|
||||
if (!outer) return null;
|
||||
const gloss = glossed[2];
|
||||
if (NEUTRAL_GLOSSES.has(foldWithoutDots(gloss))) return outer;
|
||||
return lookupWhole(gloss) === outer ? outer : null;
|
||||
}
|
||||
|
||||
/** Every folded name the catalog knows for one analyte, for suggestions. */
|
||||
export function catalogNamesForKey(key: string): readonly string[] {
|
||||
if (!catalogNamesCache) {
|
||||
catalogCache ??= catalogNameIndex().index;
|
||||
const byKey = new Map<string, string[]>();
|
||||
for (const [folded, k] of catalogCache) {
|
||||
const list = byKey.get(k) ?? [];
|
||||
list.push(folded);
|
||||
byKey.set(k, list);
|
||||
}
|
||||
catalogNamesCache = byKey;
|
||||
}
|
||||
return catalogNamesCache.get(key) ?? [];
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* The other spellings of a marker (D1223), stored per account in
|
||||
* `biomarker_aliases`.
|
||||
*
|
||||
* `nameFolded` is `foldForMatch(name)` and unique per account, so one spelling
|
||||
* resolves to one marker. An alias equal to its own marker's name (folded) is
|
||||
* never stored: the name already resolves.
|
||||
*
|
||||
* Sources: `MERGE` (a merged marker's name), `IMPORT_CHOICE` (a name mapped to
|
||||
* a marker on a scan or document review), `MANUAL` (typed on the marker).
|
||||
*/
|
||||
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
|
||||
import { foldForMatch } from "@/lib/i18n/fold-for-match";
|
||||
|
||||
export type AliasSource = "MERGE" | "IMPORT_CHOICE" | "MANUAL";
|
||||
|
||||
type AliasClient = Pick<PrismaClient, "biomarkerAlias" | "biomarker">;
|
||||
type AliasTx = Prisma.TransactionClient;
|
||||
|
||||
/** Longest alias, matching the column. */
|
||||
export const BIOMARKER_ALIAS_MAX_LENGTH = 120;
|
||||
|
||||
/**
|
||||
* The aliases of each marker, sorted, without the marker's own name. One read
|
||||
* for the whole set, so a list of markers costs one query.
|
||||
*/
|
||||
export async function aliasNamesByMarker(
|
||||
client: Pick<PrismaClient, "biomarkerAlias">,
|
||||
userId: string,
|
||||
markers: ReadonlyArray<{ id: string; name: string }>,
|
||||
): Promise<Map<string, string[]>> {
|
||||
const out = new Map<string, string[]>();
|
||||
if (markers.length === 0) return out;
|
||||
const rows = await client.biomarkerAlias.findMany({
|
||||
where: { userId, biomarkerId: { in: markers.map((m) => m.id) } },
|
||||
select: { biomarkerId: true, name: true, nameFolded: true },
|
||||
});
|
||||
const ownFold = new Map(markers.map((m) => [m.id, foldForMatch(m.name)]));
|
||||
for (const row of rows) {
|
||||
if (row.nameFolded === ownFold.get(row.biomarkerId)) continue;
|
||||
const list = out.get(row.biomarkerId) ?? [];
|
||||
list.push(row.name);
|
||||
out.set(row.biomarkerId, list);
|
||||
}
|
||||
for (const list of out.values()) {
|
||||
list.sort((a, b) => a.localeCompare(b, "en", { sensitivity: "base" }));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** What blocks an alias: another marker's name or another marker's alias. */
|
||||
export interface AliasConflict {
|
||||
alias: string;
|
||||
biomarkerId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The first alias in `names` that another marker of the account already
|
||||
* answers to, by name or alias; null when none does.
|
||||
*/
|
||||
export async function findAliasConflict(
|
||||
client: AliasClient | AliasTx,
|
||||
userId: string,
|
||||
biomarkerId: string,
|
||||
names: readonly string[],
|
||||
): Promise<AliasConflict | null> {
|
||||
if (names.length === 0) return null;
|
||||
const folds = new Map(names.map((n) => [foldForMatch(n), n]));
|
||||
const [markers, aliases] = await Promise.all([
|
||||
client.biomarker.findMany({
|
||||
where: { userId, id: { not: biomarkerId } },
|
||||
select: { id: true, name: true },
|
||||
}),
|
||||
client.biomarkerAlias.findMany({
|
||||
where: {
|
||||
userId,
|
||||
biomarkerId: { not: biomarkerId },
|
||||
nameFolded: { in: [...folds.keys()] },
|
||||
},
|
||||
select: { biomarkerId: true, nameFolded: true },
|
||||
}),
|
||||
]);
|
||||
for (const marker of markers) {
|
||||
const hit = folds.get(foldForMatch(marker.name));
|
||||
if (hit !== undefined) return { alias: hit, biomarkerId: marker.id };
|
||||
}
|
||||
const alias = aliases[0];
|
||||
if (alias) {
|
||||
return {
|
||||
alias: folds.get(alias.nameFolded) ?? alias.nameFolded,
|
||||
biomarkerId: alias.biomarkerId,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make `names` the marker's aliases: what is listed stays (keeping where it
|
||||
* came from) or is added as `MANUAL`, what is not listed is removed. The list
|
||||
* is the whole set the marker answers to, as `aliases` on the marker reads it;
|
||||
* the caller has already checked it for conflicts.
|
||||
*/
|
||||
export async function replaceAliases(
|
||||
tx: AliasTx,
|
||||
userId: string,
|
||||
marker: { id: string; name: string },
|
||||
names: readonly string[],
|
||||
): Promise<void> {
|
||||
const ownFold = foldForMatch(marker.name);
|
||||
const wanted = new Map<string, string>();
|
||||
for (const raw of names) {
|
||||
const name = raw.trim();
|
||||
const folded = foldForMatch(name);
|
||||
if (!folded || folded === ownFold || wanted.has(folded)) continue;
|
||||
wanted.set(folded, name);
|
||||
}
|
||||
const existing = await tx.biomarkerAlias.findMany({
|
||||
where: { userId, biomarkerId: marker.id },
|
||||
select: { id: true, nameFolded: true },
|
||||
});
|
||||
const stale = existing
|
||||
.filter((row) => !wanted.has(row.nameFolded))
|
||||
.map((row) => row.id);
|
||||
if (stale.length > 0) {
|
||||
await tx.biomarkerAlias.deleteMany({
|
||||
where: { userId, id: { in: stale } },
|
||||
});
|
||||
}
|
||||
const kept = new Set(existing.map((row) => row.nameFolded));
|
||||
const fresh = [...wanted].filter(([folded]) => !kept.has(folded));
|
||||
if (fresh.length > 0) {
|
||||
await tx.biomarkerAlias.createMany({
|
||||
data: fresh.map(([nameFolded, name]) => ({
|
||||
userId,
|
||||
biomarkerId: marker.id,
|
||||
name: name.slice(0, BIOMARKER_ALIAS_MAX_LENGTH),
|
||||
nameFolded: nameFolded.slice(0, BIOMARKER_ALIAS_MAX_LENGTH),
|
||||
source: "MANUAL",
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remember a name the person mapped to a marker on a review screen, so the
|
||||
* next report with the same spelling resolves without asking. A spelling
|
||||
* already attached to another marker moves to this one: the choice is the
|
||||
* person's latest word on it. The marker's own name is not stored.
|
||||
* Returns whether an alias was written.
|
||||
*/
|
||||
export async function rememberImportAlias(
|
||||
client: Pick<PrismaClient, "biomarkerAlias">,
|
||||
userId: string,
|
||||
marker: { id: string; name: string },
|
||||
printedName: string,
|
||||
): Promise<boolean> {
|
||||
const name = printedName.trim().slice(0, BIOMARKER_ALIAS_MAX_LENGTH);
|
||||
const nameFolded = foldForMatch(name).slice(0, BIOMARKER_ALIAS_MAX_LENGTH);
|
||||
if (!nameFolded || nameFolded === foldForMatch(marker.name)) return false;
|
||||
await client.biomarkerAlias.upsert({
|
||||
where: { userId_nameFolded: { userId, nameFolded } },
|
||||
create: {
|
||||
userId,
|
||||
biomarkerId: marker.id,
|
||||
name,
|
||||
nameFolded,
|
||||
source: "IMPORT_CHOICE",
|
||||
},
|
||||
update: { biomarkerId: marker.id, name, source: "IMPORT_CHOICE" },
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -217,6 +217,23 @@ export const BIOMARKER_CATALOG: readonly BiomarkerSeed[] = [
|
||||
lowerBound: null,
|
||||
upperBound: 55,
|
||||
},
|
||||
// v1.43 (D1223) — the two markers a report most often prints in shorthand
|
||||
// ("Alk.P", "T.Bili"). Adult reference windows per MedlinePlus: ALP 44-147
|
||||
// IU/L, total bilirubin 0.1-1.2 mg/dL. Editable defaults like every seed.
|
||||
{
|
||||
slug: "alkaline-phosphatase",
|
||||
panel: "liver",
|
||||
unit: "U/L",
|
||||
lowerBound: 44,
|
||||
upperBound: 147,
|
||||
},
|
||||
{
|
||||
slug: "bilirubin-total",
|
||||
panel: "liver",
|
||||
unit: "mg/dL",
|
||||
lowerBound: 0.1,
|
||||
upperBound: 1.2,
|
||||
},
|
||||
// Electrolytes
|
||||
{
|
||||
slug: "sodium",
|
||||
@@ -294,8 +311,8 @@ export const BIOMARKER_PANELS = [
|
||||
* 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.
|
||||
* nitrogen, uric acid, total calcium) have no seed, so their names live only
|
||||
* here; total bilirubin has had a seed since v1.43.
|
||||
*/
|
||||
export interface AnalyteAliases {
|
||||
/** The analyte key: a catalog slug, or a registry-only key. */
|
||||
@@ -336,6 +353,24 @@ export const ANALYTE_ALIASES: readonly AnalyteAliases[] = [
|
||||
"glycosylated hemoglobin",
|
||||
"glykohamoglobin",
|
||||
"hamoglobin a1c",
|
||||
// v1.43 (D1223) — MedlinePlus, Lab Tests Online (es, it, pl), Medics
|
||||
// Labor, CHU de Lille, IUCPQ / CHUM, Asan Medical Center.
|
||||
"hba1c",
|
||||
"glycohemoglobin",
|
||||
"glykohamoglobin a1c",
|
||||
"hemoglobina glicada",
|
||||
"hemoglobina glicosilada",
|
||||
"glicohemoglobina",
|
||||
"hemoglobine glyquee",
|
||||
"hb glyque",
|
||||
"glycohemoglobine",
|
||||
"hemoglobine glycosylee",
|
||||
"emoglobina a1c",
|
||||
"glicoemoglobina",
|
||||
"emoglobina glicosilata",
|
||||
"hemoglobina glikowana",
|
||||
"glikohemoglobina",
|
||||
"당화혈색소",
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -351,8 +386,33 @@ export const ANALYTE_ALIASES: readonly AnalyteAliases[] = [
|
||||
"cholesterol calkowity",
|
||||
],
|
||||
},
|
||||
{ key: "ldl", names: ["ldl", "ldl c", "ldl chol", "ldl cholesterin"] },
|
||||
{ key: "hdl", names: ["hdl", "hdl c", "hdl chol", "hdl cholesterin"] },
|
||||
{
|
||||
key: "ldl",
|
||||
names: [
|
||||
"ldl",
|
||||
"ldl c",
|
||||
"ldl chol",
|
||||
"ldl cholesterin",
|
||||
// v1.43 — Labor Limbach, Medics Labor, gesundheit.gv.at, CHU de Lille,
|
||||
// the Korean national screening form.
|
||||
"ldl direkt",
|
||||
"ldlch",
|
||||
"cholesterol ldl",
|
||||
"저밀도 콜레스테롤",
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "hdl",
|
||||
names: [
|
||||
"hdl",
|
||||
"hdl c",
|
||||
"hdl chol",
|
||||
"hdl cholesterin",
|
||||
"hdlch",
|
||||
"cholesterol hdl",
|
||||
"고밀도 콜레스테롤",
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "creatinine",
|
||||
names: [
|
||||
@@ -360,6 +420,18 @@ export const ANALYTE_ALIASES: readonly AnalyteAliases[] = [
|
||||
"serumkreatinin",
|
||||
"kreatinin im serum",
|
||||
"creatinine serique",
|
||||
// v1.43 — Labor Limbach ("Krea/S", serum only: the urine "Krea/U" is a
|
||||
// different measurement and is not here), Medics Labor, Lab Tests
|
||||
// Online (es, it, pl), CHU de Lille, CHUM, the Korean screening form.
|
||||
"krea s",
|
||||
"krea",
|
||||
"crea",
|
||||
"creatinin",
|
||||
"kreatinin",
|
||||
"creatininemie",
|
||||
"creat",
|
||||
"creatinina sierica",
|
||||
"혈청 크레아티닌",
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -371,6 +443,22 @@ export const ANALYTE_ALIASES: readonly AnalyteAliases[] = [
|
||||
"25 oh d",
|
||||
"calcidiol",
|
||||
"vitamin d 25 oh",
|
||||
// v1.43 — 25-OH only; calcitriol (1,25-(OH)2 D) is a different test and
|
||||
// stays out. MedlinePlus, Medics Labor, gesundheit.gv.at, Lab Tests
|
||||
// Online (es, it, pl), CHU de Lille, IUCPQ.
|
||||
"25(oh)d",
|
||||
"25 (oh)d",
|
||||
"calcifediol",
|
||||
"25 hydroxycholecalciferol",
|
||||
"vd25",
|
||||
"25 hidroxivitamina d",
|
||||
"25 (oh) vitamine d",
|
||||
"25 oh, vitamine d3",
|
||||
"25 oh vitamina d",
|
||||
"25 idrossi vitamina d",
|
||||
"25 hydroksy witamina d3",
|
||||
"kalcydiol",
|
||||
"25 hydroksykolekalciferol",
|
||||
],
|
||||
},
|
||||
{ key: "urea", names: ["urea", "harnstoff", "uree", "urea serum"] },
|
||||
@@ -422,6 +510,417 @@ export const ANALYTE_ALIASES: readonly AnalyteAliases[] = [
|
||||
"bilirrubina",
|
||||
"bilirubine",
|
||||
"bilirubina",
|
||||
// v1.43 (D1223) — "Alk.P" / "T.Bili" are how the reports in the
|
||||
// discussion print them; the rest are MedlinePlus, testing.com, Labor
|
||||
// Limbach, Medics Labor, Lab Tests Online (es, it), Dimedic (pl) and
|
||||
// Asan Medical Center. The bare "BIL" is left out: lower case it is the
|
||||
// urine dipstick field in German code lists.
|
||||
"t.bili",
|
||||
"t. bili",
|
||||
"tbil",
|
||||
"total serum bilirubin",
|
||||
"tsb",
|
||||
"bili",
|
||||
"totales bilirubin",
|
||||
"bilirrubina total",
|
||||
"bilirubina totale",
|
||||
"bilirubina całkowita",
|
||||
"총 빌리루빈",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* v1.43 (D1223) — other names for analytes, for recognising that two
|
||||
* spellings on two reports are one marker. Same shape and fold as
|
||||
* `ANALYTE_ALIASES`, and read together with it by `catalogNameIndex`, but
|
||||
* with no conversion attached: an analyte here may have no entry in the
|
||||
* unit-conversion registry ("GPT" is "ALT" whatever the unit), and the key may
|
||||
* be a catalog slug or a name-only key for an analyte with no seed (`ldh`,
|
||||
* `mcv`). Nothing here changes which unit conversion a marker gets.
|
||||
*
|
||||
* Every name is written folded (`foldForMatch`); a dot in shorthand is kept
|
||||
* ("alk.p") and also matched without it. Each name was checked against a
|
||||
* laboratory or clinical reference; a shorthand that names two analytes in
|
||||
* different places ("PCR", "AP" outside German reports, "Cr") is left out,
|
||||
* because a wrong automatic match moves a reading into the wrong history.
|
||||
*/
|
||||
export const LAB_NAME_ALIASES: readonly AnalyteAliases[] = [
|
||||
{
|
||||
key: "alkaline-phosphatase",
|
||||
names: [
|
||||
"alp",
|
||||
"alkp",
|
||||
"alk phos",
|
||||
"alk.p",
|
||||
"ap",
|
||||
"alk. phosphatase",
|
||||
"alkalische phosphatase",
|
||||
"fosfatasa alcalina",
|
||||
"pal",
|
||||
"phosphatases alcalines",
|
||||
"fosfatasi alcalina",
|
||||
"fosfataza alkaliczna",
|
||||
"fosfataza zasadowa",
|
||||
"알칼리인산분해효소",
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "alt",
|
||||
names: [
|
||||
"alt",
|
||||
"sgpt",
|
||||
"gpt",
|
||||
"alanine transaminase",
|
||||
"alanine aminotransferase",
|
||||
"alat",
|
||||
"glutamat pyruvat transaminase",
|
||||
"alanina aminotransferasa",
|
||||
"transaminasa glutamico piruvico",
|
||||
"tgp",
|
||||
"alanina aminotransferasi",
|
||||
"aminotransferaza alaninowa",
|
||||
"alt(sgpt)",
|
||||
"혈청gpt",
|
||||
"알라닌아미노전달효소",
|
||||
"알라닌아미노전이효소",
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "ast",
|
||||
names: [
|
||||
"ast",
|
||||
"sgot",
|
||||
"got",
|
||||
"aspartate transaminase",
|
||||
"aspartate aminotransferase",
|
||||
"asat",
|
||||
"glutamat oxalacetat transaminase",
|
||||
"aspartato aminotransferasa",
|
||||
"transaminasa glutamico oxalacetico",
|
||||
"tgo",
|
||||
"aspartato transaminasi",
|
||||
"aspat",
|
||||
"aminotransferaza asparaginianowa",
|
||||
"ast(sgot)",
|
||||
"혈청got",
|
||||
"아스파테이트아미노전달효소",
|
||||
"아스파테이트아미노전이효소",
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "ggt",
|
||||
names: [
|
||||
"ggt",
|
||||
"ggtp",
|
||||
"gtp",
|
||||
"gamma gt",
|
||||
"γ gt",
|
||||
"γgt",
|
||||
"gamma glutamyltransferase",
|
||||
"gammaglutamyltransferase",
|
||||
"gamma glutamil transferasa",
|
||||
"gamma glutamylotranspeptydaza",
|
||||
"감마지티피(γgtp)",
|
||||
"γgtp",
|
||||
"감마지티피",
|
||||
"감마지티",
|
||||
"r gtp",
|
||||
"감마 gtp",
|
||||
"감마글루타밀전이효소",
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "ldh",
|
||||
names: [
|
||||
"ldh",
|
||||
"ld",
|
||||
"lactate dehydrogenase",
|
||||
"lactic dehydrogenase",
|
||||
"lactatdehydrogenase",
|
||||
"laktatdehydrogenase",
|
||||
"lactato deshidrogenasa",
|
||||
"lactate deshydrogenase",
|
||||
"lattico deidrogenasi",
|
||||
"lattato deidrogenasi",
|
||||
"dehydrogenaza mleczanowa",
|
||||
"젖산탈수효소",
|
||||
],
|
||||
},
|
||||
{
|
||||
// The standard assay. "PCR" is left out: it is CRP on Spanish and
|
||||
// Italian reports and polymerase chain reaction everywhere else.
|
||||
key: "crp",
|
||||
names: [
|
||||
"crp",
|
||||
"c reactive protein",
|
||||
"c reaktives protein",
|
||||
"proteina c reactiva",
|
||||
"proteine c reactive",
|
||||
"proteina c reattiva",
|
||||
"białko c reaktywne",
|
||||
"c 반응성 단백",
|
||||
],
|
||||
},
|
||||
{
|
||||
// The high-sensitivity assay, kept apart from the standard one: the two
|
||||
// are read against different ranges, and a merge is the person's call.
|
||||
key: "hs-crp",
|
||||
names: [
|
||||
"hs crp",
|
||||
"hscrp",
|
||||
"high sensitivity crp",
|
||||
"cardiac crp",
|
||||
"crp hochsensitiv",
|
||||
"pcr ultrasensible",
|
||||
"pcr us",
|
||||
"proteina c reactiva ultrasensible",
|
||||
"crp ultrasensible",
|
||||
"crp hs",
|
||||
"hs pcr",
|
||||
"pcr ultrasensibile",
|
||||
"proteina c reattiva ultrasensibile",
|
||||
"crp wysoko czułe",
|
||||
"crp ultra czułe",
|
||||
"wysoko czułe białko c reaktywne",
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "tsh",
|
||||
names: [
|
||||
"tsh",
|
||||
"thyrotropin",
|
||||
"thyroid stimulating hormone",
|
||||
"thyreotropin",
|
||||
"tsh basal",
|
||||
"tirotropina",
|
||||
"hormona estimulante del tiroides",
|
||||
"hormona tiroestimulante",
|
||||
"tsh us",
|
||||
"tsh ultra sensible",
|
||||
"thyrotropine",
|
||||
"tireotropina",
|
||||
"ormone tireotropo",
|
||||
"tyreotropina",
|
||||
"hormon tyreotropowy",
|
||||
"갑상선자극호르몬",
|
||||
],
|
||||
},
|
||||
{
|
||||
// Free T3 only: a bare "T3" is the total hormone.
|
||||
key: "ft3",
|
||||
names: [
|
||||
"ft3",
|
||||
"free t3",
|
||||
"free triiodothyronine",
|
||||
"t3 frei",
|
||||
"freies t3",
|
||||
"trijodthyronin frei",
|
||||
"t3 libre",
|
||||
"t3l",
|
||||
"triiodotironina libera",
|
||||
"wolna t3",
|
||||
],
|
||||
},
|
||||
{
|
||||
// Free T4 only: a bare "T4" is the total hormone.
|
||||
key: "ft4",
|
||||
names: [
|
||||
"ft4",
|
||||
"free t4",
|
||||
"free thyroxine",
|
||||
"t4 frei",
|
||||
"freies t4",
|
||||
"thyroxin frei",
|
||||
"t4l",
|
||||
"t4 libre",
|
||||
"tiroxina libre",
|
||||
"thyroxine libre",
|
||||
"tiroxina libera",
|
||||
"wolna t4",
|
||||
"유리 싸이록신",
|
||||
],
|
||||
},
|
||||
{
|
||||
// Estimates only: a bare "GFR" may be a measured rate, and a
|
||||
// Cockcroft-Gault clearance is a different quantity.
|
||||
key: "egfr",
|
||||
names: [
|
||||
"egfr",
|
||||
"estimated gfr",
|
||||
"cgfr",
|
||||
"egfr nach ckd epi formel",
|
||||
"ckdepi",
|
||||
"egfr nach mdrd formel",
|
||||
"tefg",
|
||||
"tfg calculada",
|
||||
"tasa estimada de filtrado glomerular",
|
||||
"fge",
|
||||
"dfg",
|
||||
"dfge",
|
||||
"szacowana filtracja kłebuszkowa",
|
||||
"신사구체여과율 (e gfr)",
|
||||
"e gfr",
|
||||
"신사구체여과율",
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "hemoglobin",
|
||||
names: ["hb", "hgb", "hemogl", "hemoglobine", "혈색소"],
|
||||
},
|
||||
{
|
||||
key: "hematocrit",
|
||||
names: [
|
||||
"hct",
|
||||
"pcv",
|
||||
"crit",
|
||||
"packed cell volume",
|
||||
"hkt",
|
||||
"hto",
|
||||
"ht",
|
||||
"적혈구용적률",
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "mcv",
|
||||
names: [
|
||||
"mcv",
|
||||
"mittleres zellvolumen",
|
||||
"vcm",
|
||||
"volumen corpuscular medio",
|
||||
"vgm",
|
||||
"volume globulaire moyen",
|
||||
"평균적혈구용적",
|
||||
],
|
||||
},
|
||||
{
|
||||
// "Hb/E" is Labor Limbach's code for MCH, not haemoglobin.
|
||||
key: "mch",
|
||||
names: ["mch", "hb e", "hcm", "tcmh", "tgmh", "평균적혈구혈색소량"],
|
||||
},
|
||||
{
|
||||
key: "mchc",
|
||||
names: [
|
||||
"mchc",
|
||||
"chcm",
|
||||
"ccmh",
|
||||
"cgmh",
|
||||
"평균적혈구 헤모글로빈농도",
|
||||
"평균 적혈구혈색소 농도",
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "rbc",
|
||||
names: [
|
||||
"rbc",
|
||||
"rbc count",
|
||||
"erythrocyte count",
|
||||
"red count",
|
||||
"ery",
|
||||
"erythrozyten",
|
||||
"recuento de hematies",
|
||||
"recuento eritrocitario",
|
||||
"erythrocytes",
|
||||
"hematies",
|
||||
"liczba erytrocytow",
|
||||
"적혈구",
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "wbc",
|
||||
names: [
|
||||
"wbc",
|
||||
"leukocyte count",
|
||||
"white cell count",
|
||||
"leuko",
|
||||
"leukozyten",
|
||||
"leuc",
|
||||
"leucocitos",
|
||||
"leucocytes",
|
||||
"liczba leukocytow",
|
||||
"백혈구",
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "platelets",
|
||||
names: [
|
||||
"plt",
|
||||
"plt count",
|
||||
"thrombocyte count",
|
||||
"thromb",
|
||||
"thr",
|
||||
"thrombozyten",
|
||||
"plaq",
|
||||
"plaquetas",
|
||||
"plaquettes",
|
||||
"conta piastrinica",
|
||||
"liczba płytek krwi",
|
||||
"liczba trombocytow",
|
||||
"platelet",
|
||||
"혈소판",
|
||||
],
|
||||
},
|
||||
{
|
||||
// "TG" is left out: written "Tg" it is thyroglobulin, and the match
|
||||
// ignores case.
|
||||
key: "triglycerides",
|
||||
names: [
|
||||
"trig",
|
||||
"tgl",
|
||||
"trg",
|
||||
"triglyceride",
|
||||
"triglycerides",
|
||||
"trigliceridos",
|
||||
"trigliceridi",
|
||||
"triglicerydy",
|
||||
"중성지방",
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "ferritin",
|
||||
names: [
|
||||
"ferritin",
|
||||
"serum ferritin",
|
||||
"ferr",
|
||||
"ferritina",
|
||||
"ferritine",
|
||||
"ferritina sierica",
|
||||
"ferrytyna",
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "vitamin-b12",
|
||||
names: [
|
||||
"vitamin b12",
|
||||
"b12",
|
||||
"cobalamin",
|
||||
"cobalamina",
|
||||
"vitamine b12",
|
||||
"cyanocobalamine",
|
||||
"vb12",
|
||||
"vitamina b12",
|
||||
"witamina b12",
|
||||
"kobalamina",
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "sodium",
|
||||
names: ["na", "na+", "natrium", "sodium", "sodio", "natremie", "소디움"],
|
||||
},
|
||||
{
|
||||
// A bare "K" only ever reaches a potassium marker whose unit the reading
|
||||
// fits (`resolveInIndex`), never vitamin K.
|
||||
key: "potassium",
|
||||
names: [
|
||||
"k",
|
||||
"k+",
|
||||
"kalium",
|
||||
"potassium",
|
||||
"potasio",
|
||||
"potassio",
|
||||
"potas",
|
||||
"kaliemie",
|
||||
"포타슘",
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,379 @@
|
||||
/**
|
||||
* Which of an account's markers a printed name belongs to (D1223).
|
||||
*
|
||||
* One lab writes "Alkaline Phosphatase", the next "Alk.P", and each spelling
|
||||
* used to become a marker of its own with half the history. Every path that
|
||||
* turns a name into a marker resolves it here, in one order:
|
||||
*
|
||||
* 1. `name` the marker's own name, case and spacing aside;
|
||||
* 2. `alias` a spelling the account attached to a marker (merged in,
|
||||
* chosen at an import, or typed on the marker), folded;
|
||||
* 3. `catalog` a name the catalog knows for the same analyte as a marker of
|
||||
* the account ("GPT" for a marker called "ALT"), when the
|
||||
* reading's unit is that marker's or converts to it;
|
||||
* 4. nothing the caller mints a marker, or the review offers suggestions.
|
||||
*
|
||||
* Steps 1 and 2 are the account's own decisions and win regardless of the
|
||||
* unit; a unit that does not fit is then refused by the write, exactly as it
|
||||
* is for a marker picked by hand. Step 3 is the app's guess, so it steps aside
|
||||
* for a unit it cannot reconcile and the reading keeps its own marker.
|
||||
*
|
||||
* The matching is pure over a {@link MarkerIndex} loaded once per request, so
|
||||
* the scan review, the commit and the document import cannot disagree about
|
||||
* the same name.
|
||||
*/
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { foldForMatch } from "@/lib/i18n/fold-for-match";
|
||||
|
||||
import {
|
||||
catalogKeyForName,
|
||||
catalogNamesForKey,
|
||||
foldWithoutDots,
|
||||
} from "./analyte-match";
|
||||
import { convertLabValue } from "./unit-conversion/convert";
|
||||
import { sameLabUnit } from "./unit-normalise";
|
||||
|
||||
export type BiomarkerMatchKind = "name" | "alias" | "catalog";
|
||||
export type BiomarkerSuggestionKind = "catalog" | "similar";
|
||||
|
||||
/** Most suggestions a review row offers. */
|
||||
export const BIOMARKER_SUGGESTION_LIMIT = 3;
|
||||
|
||||
export interface IndexedMarker {
|
||||
id: string;
|
||||
name: string;
|
||||
unit: string;
|
||||
lowerBound: number | null;
|
||||
upperBound: number | null;
|
||||
panel: string | null;
|
||||
analyteKey: string | null;
|
||||
hidden: boolean;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface IndexedAlias {
|
||||
biomarkerId: string;
|
||||
name: string;
|
||||
nameFolded: string;
|
||||
}
|
||||
|
||||
export interface MarkerIndex {
|
||||
markers: readonly IndexedMarker[];
|
||||
aliases: readonly IndexedAlias[];
|
||||
}
|
||||
|
||||
export interface MarkerMatch {
|
||||
marker: IndexedMarker;
|
||||
matchKind: BiomarkerMatchKind;
|
||||
}
|
||||
|
||||
export interface MarkerSuggestion {
|
||||
biomarkerId: string;
|
||||
name: string;
|
||||
unit: string;
|
||||
matchKind: BiomarkerSuggestionKind;
|
||||
}
|
||||
|
||||
const MARKER_SELECT = {
|
||||
id: true,
|
||||
name: true,
|
||||
unit: true,
|
||||
lowerBound: true,
|
||||
upperBound: true,
|
||||
panel: true,
|
||||
analyteKey: true,
|
||||
hidden: true,
|
||||
createdAt: true,
|
||||
} as const;
|
||||
|
||||
/** Every marker and alias of the account, for {@link resolveInIndex}. */
|
||||
export async function loadMarkerIndex(
|
||||
client: Pick<PrismaClient, "biomarker" | "biomarkerAlias">,
|
||||
userId: string,
|
||||
): Promise<MarkerIndex> {
|
||||
const [markers, aliases] = await Promise.all([
|
||||
client.biomarker.findMany({
|
||||
where: { userId },
|
||||
select: MARKER_SELECT,
|
||||
orderBy: [{ createdAt: "asc" }, { id: "asc" }],
|
||||
}),
|
||||
client.biomarkerAlias.findMany({
|
||||
where: { userId },
|
||||
select: { biomarkerId: true, name: true, nameFolded: true },
|
||||
}),
|
||||
]);
|
||||
return { markers, aliases };
|
||||
}
|
||||
|
||||
/** Add a marker minted after the index was loaded, so later rows see it. */
|
||||
export function addToIndex(index: MarkerIndex, marker: IndexedMarker): void {
|
||||
if (index.markers.some((m) => m.id === marker.id)) return;
|
||||
(index.markers as IndexedMarker[]).push(marker);
|
||||
}
|
||||
|
||||
/** Record an alias written after the index was loaded. */
|
||||
export function addAliasToIndex(
|
||||
index: MarkerIndex,
|
||||
biomarkerId: string,
|
||||
name: string,
|
||||
): void {
|
||||
const nameFolded = foldForMatch(name.trim());
|
||||
const list = index.aliases as IndexedAlias[];
|
||||
const at = list.findIndex((a) => a.nameFolded === nameFolded);
|
||||
const entry = { biomarkerId, name: name.trim(), nameFolded };
|
||||
if (at >= 0) list[at] = entry;
|
||||
else list.push(entry);
|
||||
}
|
||||
|
||||
/** Visible before hidden, then the oldest: the order a tie is broken in. */
|
||||
function preferred(candidates: IndexedMarker[]): IndexedMarker | null {
|
||||
if (candidates.length === 0) return null;
|
||||
const visible = candidates.filter((m) => !m.hidden);
|
||||
return (visible.length > 0 ? visible : candidates)[0] ?? null;
|
||||
}
|
||||
|
||||
/** The catalog analytes a marker answers to: through its name and its aliases. */
|
||||
function markerCatalogKeys(
|
||||
index: MarkerIndex,
|
||||
marker: IndexedMarker,
|
||||
): Set<string> {
|
||||
const keys = new Set<string>();
|
||||
const own = catalogKeyForName(marker.name) ?? marker.analyteKey;
|
||||
if (own) keys.add(own);
|
||||
for (const alias of index.aliases) {
|
||||
if (alias.biomarkerId !== marker.id) continue;
|
||||
const key = catalogKeyForName(alias.name);
|
||||
if (key) keys.add(key);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a reading stated in `unit` can be stored under the marker: the same
|
||||
* unit, or one the conversion moves into it. No unit (a qualitative reading,
|
||||
* or none stated) always fits.
|
||||
*/
|
||||
export function unitFitsMarker(
|
||||
marker: Pick<IndexedMarker, "unit" | "analyteKey">,
|
||||
unit: string | null | undefined,
|
||||
analyteKey: string | null,
|
||||
): boolean {
|
||||
if (unit === null || unit === undefined || unit.trim() === "") return true;
|
||||
if (sameLabUnit(unit, marker.unit)) return true;
|
||||
return convertLabValue({
|
||||
analyteKey: marker.analyteKey ?? analyteKey,
|
||||
value: 1,
|
||||
from: unit,
|
||||
to: marker.unit,
|
||||
}).ok;
|
||||
}
|
||||
|
||||
/**
|
||||
* The marker a printed name resolves to, and how; null when nothing does.
|
||||
* `unit` is the unit the reading states, used only by the catalog step.
|
||||
*/
|
||||
export function resolveInIndex(
|
||||
index: MarkerIndex,
|
||||
rawName: string,
|
||||
unit?: string | null,
|
||||
): MarkerMatch | null {
|
||||
const name = rawName.trim();
|
||||
if (!name) return null;
|
||||
|
||||
// 1. The marker's own name. An exact spelling wins over one that differs in
|
||||
// case, which wins over one that differs only in punctuation and spacing,
|
||||
// and that last one only when it names a single marker.
|
||||
const exact = index.markers.find((m) => m.name === name);
|
||||
if (exact) return { marker: exact, matchKind: "name" };
|
||||
const lower = name.toLowerCase();
|
||||
const caseless = preferred(
|
||||
index.markers.filter((m) => m.name.trim().toLowerCase() === lower),
|
||||
);
|
||||
if (caseless) return { marker: caseless, matchKind: "name" };
|
||||
const folded = foldForMatch(name);
|
||||
const byFold = index.markers.filter((m) => foldForMatch(m.name) === folded);
|
||||
if (byFold.length === 1) return { marker: byFold[0], matchKind: "name" };
|
||||
|
||||
// 2. An alias of the account.
|
||||
const alias = index.aliases.find((a) => a.nameFolded === folded);
|
||||
if (alias) {
|
||||
const marker = index.markers.find((m) => m.id === alias.biomarkerId);
|
||||
if (marker) return { marker, matchKind: "alias" };
|
||||
}
|
||||
|
||||
// 3. The catalog: a marker of the same analyte that can take the unit.
|
||||
const key = catalogKeyForName(name);
|
||||
if (key) {
|
||||
const candidates = index.markers.filter(
|
||||
(m) =>
|
||||
markerCatalogKeys(index, m).has(key) && unitFitsMarker(m, unit, key),
|
||||
);
|
||||
const marker = preferred(candidates);
|
||||
if (marker) return { marker, matchKind: "catalog" };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Folded name without dots, commas or parentheses, for the similarity. */
|
||||
function loose(raw: string): string {
|
||||
return foldWithoutDots(raw)
|
||||
.replace(/[()[\]]/gu, " ")
|
||||
.replace(/\s+/gu, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function bigrams(text: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (let i = 0; i < text.length - 1; i++) out.push(text.slice(i, i + 2));
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Sørensen-Dice over character bigrams, 0..1. */
|
||||
export function diceSimilarity(a: string, b: string): number {
|
||||
const x = bigrams(a);
|
||||
const y = bigrams(b);
|
||||
if (x.length === 0 || y.length === 0) return a === b && a !== "" ? 1 : 0;
|
||||
const counts = new Map<string, number>();
|
||||
for (const g of y) counts.set(g, (counts.get(g) ?? 0) + 1);
|
||||
let shared = 0;
|
||||
for (const g of x) {
|
||||
const n = counts.get(g) ?? 0;
|
||||
if (n > 0) {
|
||||
shared += 1;
|
||||
counts.set(g, n - 1);
|
||||
}
|
||||
}
|
||||
return (2 * shared) / (x.length + y.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shorthand against the full name: every word of the shorter is the start of
|
||||
* a word of the longer, in order ("alk phos" against "alkaline phosphatase").
|
||||
*/
|
||||
function abbreviates(short: string, long: string): boolean {
|
||||
const s = short.split(" ").filter(Boolean);
|
||||
const l = long.split(" ").filter(Boolean);
|
||||
if (s.length === 0 || s.length > l.length) return false;
|
||||
let j = 0;
|
||||
for (const word of s) {
|
||||
while (j < l.length && !l[j].startsWith(word)) j++;
|
||||
if (j === l.length) return false;
|
||||
j++;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** How alike two names are, 0..1. */
|
||||
export function nameSimilarity(a: string, b: string): number {
|
||||
const x = loose(a);
|
||||
const y = loose(b);
|
||||
if (!x || !y) return 0;
|
||||
if (x === y) return 1;
|
||||
const [shorter, longer] = x.length <= y.length ? [x, y] : [y, x];
|
||||
const prefix = abbreviates(shorter, longer) ? 0.8 : 0;
|
||||
return Math.max(prefix, diceSimilarity(x, y));
|
||||
}
|
||||
|
||||
/** Below this a marker is not offered as similar. */
|
||||
export const SIMILARITY_FLOOR = 0.5;
|
||||
|
||||
/**
|
||||
* Up to three markers a review row offers for a name nothing resolved:
|
||||
* first the markers of the same catalog analyte (their unit kept the catalog
|
||||
* step from taking them), then the markers whose name, aliases or catalog
|
||||
* names are most alike. Hidden markers are not offered.
|
||||
*/
|
||||
export function suggestInIndex(
|
||||
index: MarkerIndex,
|
||||
rawName: string,
|
||||
limit = BIOMARKER_SUGGESTION_LIMIT,
|
||||
): MarkerSuggestion[] {
|
||||
const name = rawName.trim();
|
||||
if (!name) return [];
|
||||
const out: MarkerSuggestion[] = [];
|
||||
const taken = new Set<string>();
|
||||
const visible = index.markers.filter((m) => !m.hidden);
|
||||
|
||||
const key = catalogKeyForName(name);
|
||||
if (key) {
|
||||
for (const marker of visible) {
|
||||
if (out.length >= limit) break;
|
||||
if (!markerCatalogKeys(index, marker).has(key)) continue;
|
||||
out.push(toSuggestion(marker, "catalog"));
|
||||
taken.add(marker.id);
|
||||
}
|
||||
}
|
||||
|
||||
const scored: Array<{ marker: IndexedMarker; score: number }> = [];
|
||||
for (const marker of visible) {
|
||||
if (taken.has(marker.id)) continue;
|
||||
const names = [marker.name];
|
||||
for (const alias of index.aliases) {
|
||||
if (alias.biomarkerId === marker.id) names.push(alias.name);
|
||||
}
|
||||
for (const k of markerCatalogKeys(index, marker)) {
|
||||
names.push(...catalogNamesForKey(k));
|
||||
}
|
||||
let score = 0;
|
||||
for (const candidate of names) {
|
||||
score = Math.max(score, nameSimilarity(name, candidate));
|
||||
}
|
||||
if (score >= SIMILARITY_FLOOR) scored.push({ marker, score });
|
||||
}
|
||||
scored.sort(
|
||||
(a, b) => b.score - a.score || a.marker.name.localeCompare(b.marker.name),
|
||||
);
|
||||
for (const { marker } of scored) {
|
||||
if (out.length >= limit) break;
|
||||
out.push(toSuggestion(marker, "similar"));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function toSuggestion(
|
||||
marker: IndexedMarker,
|
||||
matchKind: BiomarkerSuggestionKind,
|
||||
): MarkerSuggestion {
|
||||
return {
|
||||
biomarkerId: marker.id,
|
||||
name: marker.name,
|
||||
unit: marker.unit,
|
||||
matchKind,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolution and suggestions for one name, as the review rows carry them:
|
||||
* the matched marker and how, or none and up to three to choose from.
|
||||
*/
|
||||
export function matchForReview(
|
||||
index: MarkerIndex,
|
||||
name: string,
|
||||
unit?: string | null,
|
||||
): {
|
||||
match: MarkerMatch | null;
|
||||
suggestions: MarkerSuggestion[];
|
||||
} {
|
||||
const match = resolveInIndex(index, name, unit);
|
||||
return {
|
||||
match,
|
||||
suggestions: match ? [] : suggestInIndex(index, name),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The marker a name asked for on a read path (MCP `get_labs`, the
|
||||
* `healthlog://lab/{analyte}` resource) resolves to: its name, an alias or a
|
||||
* catalog abbreviation, with no unit to weigh. Null when nothing resolves;
|
||||
* the caller then keeps its own text match.
|
||||
*/
|
||||
export async function resolveMarkerForRead(
|
||||
userId: string,
|
||||
name: string,
|
||||
): Promise<{ id: string; name: string } | null> {
|
||||
const index = await loadMarkerIndex(prisma, userId);
|
||||
const match = resolveInIndex(index, name);
|
||||
return match ? { id: match.marker.id, name: match.marker.name } : null;
|
||||
}
|
||||
@@ -14,6 +14,13 @@ import { Buffer } from "node:buffer";
|
||||
import { decrypt, encrypt } from "@/lib/crypto";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { matchAnalyteKey } from "@/lib/labs/analyte-match";
|
||||
import {
|
||||
addToIndex,
|
||||
loadMarkerIndex,
|
||||
resolveInIndex,
|
||||
type IndexedMarker,
|
||||
type MarkerIndex,
|
||||
} from "@/lib/labs/biomarker-resolve";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
|
||||
/** Encrypt a UTF-8 context note into the `Bytes` payload the schema stores. */
|
||||
@@ -72,18 +79,19 @@ export interface MintedBiomarker {
|
||||
* v1.18.1 — resolve (or mint) the user-scoped Biomarker for a free-text lab
|
||||
* write so NO `LabResult` row ever persists unlinked.
|
||||
*
|
||||
* The catalog identity is `(userId, lower(analyte))`: a reading for "LDL",
|
||||
* "ldl", and "LDL " all resolve to one marker. An existing marker (hand-
|
||||
* defined or previously minted) is reused as-is — its unit / range are
|
||||
* authoritative and the incoming free-text unit / bounds are NOT allowed to
|
||||
* silently rewrite it. When no marker exists yet, one is minted from the
|
||||
* reading's own free-text spelling / unit / bounds. The `@@unique([userId,
|
||||
* name])` index is the structural backstop; a concurrent double-write
|
||||
* converges via the catch-and-refetch.
|
||||
* Since v1.43 (D1223) the name resolves through `resolveInIndex`: the
|
||||
* marker's own name (case aside), then an alias of the account, then a
|
||||
* catalog name for the same analyte as one of the account's markers ("GPT"
|
||||
* for "ALT") when the reading's unit fits that marker. An existing marker is
|
||||
* reused as-is: its unit / range are authoritative and the incoming free-text
|
||||
* unit / bounds are NOT allowed to silently rewrite it. When nothing resolves,
|
||||
* a marker is minted from the reading's own spelling / unit / bounds. The
|
||||
* `@@unique([userId, name])` index is the structural backstop; a concurrent
|
||||
* double-write converges via the catch-and-refetch.
|
||||
*
|
||||
* Turning the boot-time backfill into a pure historical migration: from
|
||||
* v1.18.1 on, every write goes through here, so the only unlinked rows that
|
||||
* can exist are pre-upgrade ones the backfill heals.
|
||||
* `index` lets a caller writing many rows load the account's markers once;
|
||||
* a marker minted here is added to it so a later row with the same name finds
|
||||
* it.
|
||||
*/
|
||||
export async function resolveOrMintBiomarker(
|
||||
userId: string,
|
||||
@@ -94,28 +102,15 @@ export async function resolveOrMintBiomarker(
|
||||
referenceHigh: number | null;
|
||||
panel: string | null;
|
||||
},
|
||||
options: { index?: MarkerIndex } = {},
|
||||
): Promise<MintedBiomarker> {
|
||||
const name = input.analyte.trim();
|
||||
const select = {
|
||||
id: true,
|
||||
name: true,
|
||||
unit: true,
|
||||
lowerBound: true,
|
||||
upperBound: true,
|
||||
panel: true,
|
||||
analyteKey: true,
|
||||
} as const;
|
||||
|
||||
// Case-insensitive match on the catalog identity. Postgres is the store;
|
||||
// `mode: "insensitive"` keeps "LDL" === "ldl" without a functional index.
|
||||
const existing = await prisma.biomarker.findFirst({
|
||||
where: { userId, name: { equals: name, mode: "insensitive" } },
|
||||
select,
|
||||
});
|
||||
if (existing) return existing;
|
||||
const index = options.index ?? (await loadMarkerIndex(prisma, userId));
|
||||
const hit = resolveInIndex(index, name, input.unit || null);
|
||||
if (hit) return toMinted(hit.marker);
|
||||
|
||||
try {
|
||||
return await prisma.biomarker.create({
|
||||
const created = await prisma.biomarker.create({
|
||||
data: {
|
||||
userId,
|
||||
name,
|
||||
@@ -125,15 +120,44 @@ export async function resolveOrMintBiomarker(
|
||||
panel: input.panel,
|
||||
analyteKey: matchAnalyteKey({ name, unit: input.unit }),
|
||||
},
|
||||
select,
|
||||
select: INDEXED_SELECT,
|
||||
});
|
||||
addToIndex(index, created);
|
||||
return toMinted(created);
|
||||
} catch {
|
||||
// A concurrent write minted the same `(userId, name)` first — adopt it.
|
||||
const raced = await prisma.biomarker.findFirst({
|
||||
where: { userId, name: { equals: name, mode: "insensitive" } },
|
||||
select,
|
||||
select: INDEXED_SELECT,
|
||||
});
|
||||
if (raced) return raced;
|
||||
if (raced) {
|
||||
addToIndex(index, raced);
|
||||
return toMinted(raced);
|
||||
}
|
||||
throw new Error("Failed to resolve biomarker for lab write");
|
||||
}
|
||||
}
|
||||
|
||||
const INDEXED_SELECT = {
|
||||
id: true,
|
||||
name: true,
|
||||
unit: true,
|
||||
lowerBound: true,
|
||||
upperBound: true,
|
||||
panel: true,
|
||||
analyteKey: true,
|
||||
hidden: true,
|
||||
createdAt: true,
|
||||
} as const;
|
||||
|
||||
function toMinted(marker: IndexedMarker): MintedBiomarker {
|
||||
return {
|
||||
id: marker.id,
|
||||
name: marker.name,
|
||||
unit: marker.unit,
|
||||
lowerBound: marker.lowerBound,
|
||||
upperBound: marker.upperBound,
|
||||
panel: marker.panel,
|
||||
analyteKey: marker.analyteKey,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user