Merge branch 'v143/labs' into release/v1.43.0

This commit is contained in:
Marc-André Bombeck committed 2026-10-15 23:06:51 +02:00
commit 74fa4e10b6
96 files changed
+6992 -626

No files matched your search

+14 -5
View File
@@ -34,8 +34,9 @@ import type {
/**
* Structured lab results over the window, reduced to the latest reading per
* analyte with a count, so the report is a concise panel rather than a raw
* dump. Analyte keys fold case-insensitively.
* marker with a count, so the report is a concise panel rather than a raw
* dump. Readings group by their biomarker and carry its name; a legacy
* unlinked reading groups by its own name, case-insensitively.
*/
export async function loadLabResults(
userId: string,
@@ -58,6 +59,8 @@ export async function loadLabResults(
sourceReferenceText: true,
sourceUnit: true,
takenAt: true,
biomarkerId: true,
biomarker: { select: { name: true, panel: true } },
},
});
const byAnalyte = new Map<
@@ -65,7 +68,13 @@ export async function loadLabResults(
NonNullable<DoctorReportData["labResults"]>[number]
>();
for (const r of rows) {
const key = r.analyte.toLowerCase();
// One line per marker: readings filed under one biomarker are one series
// whatever each report printed (a merge keeps the printed `analyte` on
// every reading), and the marker's own name heads it. A legacy unlinked
// reading still groups by its name.
const key = r.biomarkerId
? `bm:${r.biomarkerId}`
: `analyte:${r.analyte.toLowerCase()}`;
const prev = byAnalyte.get(key);
// The row already stamps the catalog band it was written under; the
// reading's own report window, when present, is what the physician read
@@ -77,8 +86,8 @@ export async function loadLabResults(
r,
);
byAnalyte.set(key, {
panel: r.panel,
analyte: r.analyte,
panel: r.biomarker?.panel ?? r.panel,
analyte: r.biomarker?.name ?? r.analyte,
value: r.value,
valueText: r.valueText,
unit: r.unit,
+52 -9
View File
@@ -27,7 +27,11 @@ import { prisma } from "@/lib/db";
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
import { emitDataArrival } from "@/lib/arrivals/emit-shared";
import { decryptFactData } from "@/lib/documents/store";
import { resolveOrMintBiomarker } from "@/lib/labs/biomarker-store";
import { rememberImportAlias } from "@/lib/labs/biomarker-aliases";
import {
resolveOrMintBiomarker,
type MintedBiomarker,
} from "@/lib/labs/biomarker-store";
import { readingInMarkerUnit } from "@/lib/labs/reading-conversion";
import {
isUnreadableRange,
@@ -67,9 +71,19 @@ function statedDateOrNow(date: string | null): Date {
return new Date();
}
/**
* v1.43 (D1223) — the marker the person picked for an OBSERVATION on the
* review screen, and whether to remember the label as its alias.
*/
export interface ObservationChoice {
biomarkerId?: string;
rememberAlias?: boolean;
}
async function commitObservation(
userId: string,
data: ObservationFactData,
choice: ObservationChoice = {},
): Promise<CommittedRecordRef> {
const isQualitative = typeof data.value !== "number";
// A numeric reading needs a unit so the catalog has something to mint with —
@@ -121,13 +135,41 @@ async function commitObservation(
});
}
const biomarker = await resolveOrMintBiomarker(userId, {
analyte: data.label,
unit: isQualitative ? (data.unit ?? "") : (data.unit as string),
referenceLow: isQualitative ? null : sourceLow,
referenceHigh: isQualitative ? null : sourceHigh,
panel: null,
});
// The marker the person chose wins; otherwise the label resolves through
// the account's names, aliases and the catalog, or a marker is minted.
let biomarker: MintedBiomarker;
if (choice.biomarkerId) {
const chosen = await prisma.biomarker.findFirst({
where: { id: choice.biomarkerId, userId },
select: {
id: true,
name: true,
unit: true,
lowerBound: true,
upperBound: true,
panel: true,
analyteKey: true,
},
});
if (!chosen) {
throw new FactCommitError(
"observation.unknownBiomarker",
"The chosen marker does not exist",
);
}
if (choice.rememberAlias !== false) {
await rememberImportAlias(prisma, userId, chosen, data.label);
}
biomarker = chosen;
} else {
biomarker = await resolveOrMintBiomarker(userId, {
analyte: data.label,
unit: isQualitative ? (data.unit ?? "") : (data.unit as string),
referenceLow: isQualitative ? null : sourceLow,
referenceHigh: isQualitative ? null : sourceHigh,
panel: null,
});
}
// A unit that differs from an EXISTING marker's is converted when the
// marker's analyte is known and the registry has the pair (a document states
// "6.1 mmol/L" against a marker stored in mg/dL), keeping what the document
@@ -274,12 +316,13 @@ async function commitMedication(
export async function commitApprovedFact(
userId: string,
fact: ExtractedFact,
choice: ObservationChoice = {},
): Promise<CommittedRecordRef> {
// Decrypt the staged payload at confirm time to write it into the normal
// structured store. Fail-closed: a bad key id throws and aborts the commit.
const data = decryptFactData(fact.dataEncrypted);
if (fact.factType === "OBSERVATION") {
return commitObservation(userId, data as ObservationFactData);
return commitObservation(userId, data as ObservationFactData, choice);
}
if (fact.factType === "CONDITION") {
return commitCondition(userId, data as ConditionFactData);
+48 -5
View File
@@ -16,7 +16,13 @@ import type {
InboundDocument,
InboundDocumentStatus,
} from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { servingClassFor } from "@/lib/documents/upload-policy";
import {
loadMarkerIndex,
matchForReview,
type MarkerIndex,
} from "@/lib/labs/biomarker-resolve";
import {
toDocumentSourceSystem,
toIndexOutcome,
@@ -32,6 +38,7 @@ import type {
FactProvenance,
InboundDocumentDetailDto,
InboundDocumentDto,
ObservationFactData,
InboundDocumentKindValue,
InboundFactType,
} from "@/lib/validations/inbound-documents";
@@ -254,19 +261,54 @@ export function serialiseDocument(
};
}
/** Map a persisted staged-fact row to the review DTO. */
export function serialiseFact(fact: ExtractedFact): ExtractedFactDto {
return {
/**
* Map a persisted staged-fact row to the review DTO.
*
* v1.43 (D1223) — with the account's `markerIndex`, a pending OBSERVATION
* also says which marker its label resolves to (`biomarkerId`, `matchKind`)
* or offers up to three (`suggestions`), the same resolution the confirm
* applies. Without an index, or for any other fact, the fields are absent.
*/
export function serialiseFact(
fact: ExtractedFact,
markerIndex?: MarkerIndex | null,
): ExtractedFactDto {
const data = decryptFactData(fact.dataEncrypted);
const dto: ExtractedFactDto = {
id: fact.id,
factType: fact.factType as InboundFactType,
status: fact.status as ExtractedFactStatus,
confidence: fact.confidence,
needsReview: fact.needsReview,
data: decryptFactData(fact.dataEncrypted),
data,
provenance: decryptFactProvenance(fact.provenanceEncrypted),
committedRecordId: fact.committedRecordId,
committedRecordType: fact.committedRecordType,
};
if (markerIndex && fact.factType === "OBSERVATION") {
const observation = data as ObservationFactData;
const isNumeric = typeof observation.value === "number";
const { match, suggestions } = matchForReview(
markerIndex,
observation.label ?? "",
isNumeric ? (observation.unit ?? null) : null,
);
dto.biomarkerId = match?.marker.id ?? null;
dto.matchKind = match?.matchKind ?? null;
dto.suggestions = suggestions;
}
return dto;
}
/** The marker index a review needs, or null when no fact would use it. */
export async function markerIndexForFacts(
userId: string,
facts: readonly Pick<ExtractedFact, "factType" | "status">[],
): Promise<MarkerIndex | null> {
const needed = facts.some(
(f) => f.factType === "OBSERVATION" && f.status === "PENDING",
);
return needed ? loadMarkerIndex(prisma, userId) : null;
}
/** Assemble the detail DTO (document + its staged facts + links). */
@@ -279,6 +321,7 @@ export function serialiseDocumentDetail(
hasThumbnail = false,
encounterLinks: DocumentEncounterLinkDto[] = [],
vaccinationLinks: DocumentVaccinationLinkDto[] | null = [],
markerIndex: MarkerIndex | null = null,
): InboundDocumentDetailDto {
const pendingCount = facts.filter((f) => f.status === "PENDING").length;
// `factCount` excludes REJECTED facts (a rejected fact is discarded, not part
@@ -327,7 +370,7 @@ export function serialiseDocumentDetail(
hasThumbnail,
encounterLinks,
),
facts: facts.map(serialiseFact),
facts: facts.map((fact) => serialiseFact(fact, markerIndex)),
vaccinationLinks,
summary,
summaryGeneratedAt: doc.summaryGeneratedAt
@@ -73,6 +73,8 @@ function buildMockPrisma() {
},
]),
},
biomarkerAlias: { findMany: vi.fn().mockResolvedValue([]) },
biomarkerMerge: { findMany: vi.fn().mockResolvedValue([]) },
illnessEpisode: {
findMany: vi.fn().mockResolvedValue([
{
@@ -160,6 +160,8 @@ function sourceClient() {
// Records section: empty, exercised by its own suite.
labResult: empty,
biomarker: empty,
biomarkerAlias: empty,
biomarkerMerge: empty,
illnessEpisode: empty,
allergy: empty,
familyHistoryEntry: empty,
+8 -4
View File
@@ -576,6 +576,14 @@ export const TWO_ENDED_MODELS = [
// questionnaire over every module decision taken in Settings since. It is
// carried and asserted, not defaulted.
"OnboardingRecord",
// v1.43 (D1223) — a marker's other spellings ride on the marker as
// `aliases[]`, and the merge ledger travels as its own list whose target is
// resolved by id or by name like a lab result's marker. Without the aliases a
// restored account is asked again at the next scan which marker each
// spelling belongs to; without the ledger a link to a merged marker reads as
// a plain 404 instead of leading to the marker it went into.
"BiomarkerAlias",
"BiomarkerMerge",
] as const;
/** One model claimed to travel both ways. */
@@ -614,10 +622,6 @@ export const STRUCTURALLY_UNATTRIBUTABLE: Readonly<Record<string, string>> = {
export const COVERAGE_PENDING: Readonly<Record<string, string>> = {
// v1.43 — the new tables are declared here with the schema and move to
// TWO_ENDED_MODELS when their section writer and restore branch land.
BiomarkerAlias:
"Every other spelling a marker answers to: the names folded in by a merge, the choices made at a scan or a document import, the ones typed on the marker. Without them a restored account asks again at the next scan which marker each spelling belongs to.",
BiomarkerMerge:
"The ledger of which markers were merged into which. Without it a link or a cached screen that still names a merged marker reads as a plain 404 instead of being sent to the marker it went into.",
MedicationIngredient:
"What one tablet or capsule of a supplement contains. Without it a restored account's supplements stop counting toward the day's micronutrients, and the composition has to be typed in again from the label.",
DawarichDay:
+77
View File
@@ -126,10 +126,38 @@ export interface BiomarkerBackupEntry {
context: string | null;
/** The catalogue key the marker's name and unit resolve to. */
analyteKey?: string | null;
/**
* v1.43 (D1223) — the other spellings the marker answers to, with where
* each came from. Absent on files written before them.
*/
aliases?: BiomarkerAliasBackupEntry[];
createdAt?: string;
updatedAt?: string;
}
export interface BiomarkerAliasBackupEntry {
/** Present in canonical DR payloads. */
id?: string;
name: string;
source: "MERGE" | "IMPORT_CHOICE" | "MANUAL";
createdAt?: string;
}
/**
* v1.43 (D1223) — one marker folded into another. The target travels by id
* in a disaster-recovery file and by name in both, the way a lab result's
* marker does; the source is gone, so its id and name are the record.
*/
export interface BiomarkerMergeBackupEntry {
id?: string;
targetBiomarkerId?: string;
targetName: string;
sourceBiomarkerId: string;
sourceName: string;
readingCount: number;
mergedAt: string;
}
export interface WorkoutBackupEntry {
/** Present in canonical DR payloads for manual workouts without externalId. */
id?: string;
@@ -237,6 +265,7 @@ type CanonicalAllergy = AllergyDTO & {
export interface RecordsBackupSection {
labResults: LabResultBackupEntry[];
biomarkers: BiomarkerBackupEntry[];
biomarkerMerges: BiomarkerMergeBackupEntry[];
illnessEpisodes: CanonicalIllnessEpisode[];
allergies: CanonicalAllergy[];
familyHistory: FamilyHistoryEntryDTO[];
@@ -286,6 +315,8 @@ export async function buildRecordsBackupSection(
PrismaClient,
| "labResult"
| "biomarker"
| "biomarkerAlias"
| "biomarkerMerge"
| "illnessEpisode"
| "allergy"
| "familyHistoryEntry"
@@ -351,6 +382,8 @@ export async function buildRecordsBackupSection(
const [
labResultRows,
biomarkerRows,
biomarkerAliasRows,
biomarkerMergeRows,
episodeRows,
allergyRows,
familyRows,
@@ -366,6 +399,22 @@ export async function buildRecordsBackupSection(
where: { userId },
orderBy: { name: "asc" },
}),
prisma.biomarkerAlias.findMany({
where: { userId },
orderBy: [{ biomarkerId: "asc" }, { nameFolded: "asc" }],
select: {
id: true,
biomarkerId: true,
name: true,
source: true,
createdAt: true,
},
}),
prisma.biomarkerMerge.findMany({
where: { userId },
orderBy: { mergedAt: "asc" },
include: { target: { select: { name: true } } },
}),
prisma.illnessEpisode.findMany({
where: disasterRecovery ? { userId } : { userId, deletedAt: null },
orderBy: { onsetAt: "desc" },
@@ -429,6 +478,32 @@ export async function buildRecordsBackupSection(
biomarkerName: r.biomarker?.name ?? null,
}));
const aliasesByMarker = new Map<string, BiomarkerAliasBackupEntry[]>();
for (const alias of biomarkerAliasRows) {
const list = aliasesByMarker.get(alias.biomarkerId) ?? [];
list.push({
name: alias.name,
source: alias.source as BiomarkerAliasBackupEntry["source"],
...(disasterRecovery
? { id: alias.id, createdAt: alias.createdAt.toISOString() }
: {}),
});
aliasesByMarker.set(alias.biomarkerId, list);
}
const biomarkerMerges: BiomarkerMergeBackupEntry[] = biomarkerMergeRows.map(
(m) => ({
...(disasterRecovery
? { id: m.id, targetBiomarkerId: m.targetBiomarkerId }
: {}),
targetName: m.target.name,
sourceBiomarkerId: m.sourceBiomarkerId,
sourceName: m.sourceName,
readingCount: m.readingCount,
mergedAt: m.mergedAt.toISOString(),
}),
);
const biomarkers: BiomarkerBackupEntry[] = biomarkerRows.map((b) => ({
...(disasterRecovery
? {
@@ -449,6 +524,7 @@ export async function buildRecordsBackupSection(
decryptContextFromBytes,
),
analyteKey: b.analyteKey,
aliases: aliasesByMarker.get(b.id) ?? [],
}));
const illnessEpisodes: CanonicalIllnessEpisode[] = disasterRecovery
@@ -598,6 +674,7 @@ export async function buildRecordsBackupSection(
return {
labResults,
biomarkers,
biomarkerMerges,
illnessEpisodes,
allergies,
familyHistory,
+71
View File
@@ -72,6 +72,7 @@ import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { encryptContextToBytes } from "@/lib/labs/biomarker-store";
import { encryptNoteToBytes } from "@/lib/labs/store";
import { annotate } from "@/lib/logging/context";
import { foldForMatch } from "@/lib/i18n/fold-for-match";
import type { BackupMeasurement } from "@/lib/validations/backup";
import {
isCompatibleSchemaVersion,
@@ -2076,6 +2077,76 @@ export async function restoreBackup(
});
biomarkerByName.set(biomarker.name, created.id);
restoredBiomarkerIds.add(created.id);
// v1.43 (D1223) — the other spellings, folded the way the resolver
// reads them, so the next scan matches them again.
const aliasRows = (biomarker.aliases ?? [])
.map((alias) => ({
id: alias.id,
name: alias.name.trim().slice(0, 120),
nameFolded: foldForMatch(alias.name).slice(0, 120),
source: alias.source,
createdAt: alias.createdAt,
}))
.filter(
(alias) =>
alias.nameFolded !== "" &&
alias.nameFolded !== foldForMatch(biomarker.name),
);
if (aliasRows.length > 0) {
await tx.biomarkerAlias.createMany({
data: aliasRows.map((alias) => ({
...(alias.id ? { id: alias.id } : {}),
userId: ownerId,
biomarkerId: created.id,
name: alias.name,
nameFolded: alias.nameFolded,
source: alias.source,
...(alias.createdAt
? { createdAt: new Date(alias.createdAt) }
: {}),
})),
// One spelling answers to one marker per account; a file that
// names it twice keeps the first.
skipDuplicates: true,
});
}
}
// v1.43 (D1223) — the merge ledger, against the markers just written:
// by id where the file carries one this restore kept, by name
// otherwise. A row whose target the file does not carry has nothing
// to point at and is left out (counted on the event).
let unresolvedMerges = 0;
const mergeRows = (payload.biomarkerMerges ?? []).flatMap((merge) => {
const targetId =
merge.targetBiomarkerId &&
restoredBiomarkerIds.has(merge.targetBiomarkerId)
? merge.targetBiomarkerId
: biomarkerByName.get(merge.targetName);
if (!targetId) {
unresolvedMerges += 1;
return [];
}
return [
{
...(merge.id ? { id: merge.id } : {}),
userId: ownerId,
targetBiomarkerId: targetId,
sourceBiomarkerId: merge.sourceBiomarkerId,
sourceName: merge.sourceName,
readingCount: merge.readingCount,
mergedAt: new Date(merge.mergedAt),
},
];
});
if (mergeRows.length > 0) {
await tx.biomarkerMerge.createMany({ data: mergeRows });
}
if (unresolvedMerges > 0) {
annotate({
action: { name: "backup.restore.biomarkerMerge.unresolved" },
meta: { count: unresolvedMerges },
});
}
// Collected for the staged facts further down: an approved fact's
+3 -3
View File
@@ -35,9 +35,9 @@ export function labObservations(
const observations: FhirObservation[] = [];
for (const lab of data.labResults ?? []) {
// Forward-compat: when the DTO carries a resolved canonical analyte name,
// thread it as the third arg so the catalog name wins over the free-text
// `analyte`. Absent today.
// `analyte` is the marker's own name (v1.43): readings of one biomarker
// are one Observation whatever each report printed, so two spellings
// merged into one marker never reach a receiver as two analytes.
const coding = resolveLabCoding(lab.analyte, lab.unit);
const text = lab.panel ? `${lab.analyte} (${lab.panel})` : lab.analyte;
const code = coding
@@ -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 () => {
+128 -2
View File
@@ -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) ?? [];
}
+172
View File
@@ -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;
}
+503 -4
View File
@@ -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",
"포타슘",
],
},
];
+56
View File
@@ -0,0 +1,56 @@
/**
* The `Biomarker` DTO, one shape for the list, the detail, the edit and the
* merge result. The encrypted context bytes are never echoed: `hasContext`
* flags them and `context` carries the plaintext. `aliases` are the other
* spellings the marker answers to (v1.43), sorted, without its own name.
*/
import {
decryptContextFromBytes,
decryptContextSoft,
} from "@/lib/labs/biomarker-store";
export interface BiomarkerDtoRow {
id: string;
name: string;
unit: string;
lowerBound: number | null;
upperBound: number | null;
panel: string | null;
analyteKey: string | null;
hidden: boolean;
contextEncrypted: Uint8Array | null;
createdAt: Date;
updatedAt: Date;
}
/**
* `soft` decrypts fail-soft (the list: one bad row must not 500 the catalog);
* otherwise a decrypt failure throws so a key gap surfaces on the detail.
*/
export function serialiseBiomarker(
row: BiomarkerDtoRow,
aliases: readonly string[],
options: { soft?: boolean } = {},
) {
return {
id: row.id,
name: row.name,
unit: row.unit,
lowerBound: row.lowerBound,
upperBound: row.upperBound,
panel: row.panel,
analyteKey: row.analyteKey,
hasContext: row.contextEncrypted !== null,
context: options.soft
? decryptContextSoft(row.contextEncrypted)
: row.contextEncrypted
? decryptContextFromBytes(row.contextEncrypted)
: null,
hidden: row.hidden,
aliases: [...aliases],
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
export type BiomarkerDto = ReturnType<typeof serialiseBiomarker>;
+388
View File
@@ -0,0 +1,388 @@
/**
* Merge markers into one (D1223): "Alk.P" and "Alkaline Phosphatase" become
* one marker with one history.
*
* The plan and the write are one function, so a dry run answers exactly what
* the merge would do: it reads the same rows, converts the same values and
* returns the same numbers, and only the write step is skipped.
*
* What a merge does, in one transaction:
* - every reading of every source moves to the target. A reading in a unit
* other than the target's is converted with the target's analyte
* (`convertLabValue`), together with the windows stamped on it; the value
* and unit the report printed stay in `sourceValue` / `sourceUnit` (a
* reading that was already converted keeps its original pair). The
* reading's own `analyte` stays the name that was printed.
* - a pair of units the registry cannot convert refuses the WHOLE merge;
* nothing moves, not even the sources that would convert;
* - medication efficacy targets follow their marker;
* - each source's name and its aliases become aliases of the target
* (`MERGE`), unless the target's name or another marker already answers
* to that spelling;
* - one `biomarker_merges` row per source, and the rows that already pointed
* at a source now point at the target, so an id merged twice still finds
* where it went;
* - the sources are deleted.
*
* There is no undo. The ledger keeps what a later split would need to know
* (which marker, which name, how many readings).
*/
import type { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { foldForMatch } from "@/lib/i18n/fold-for-match";
import { matchAnalyteKey } from "./analyte-match";
import {
BIOMARKER_ALIAS_MAX_LENGTH,
aliasNamesByMarker,
} from "./biomarker-aliases";
import { serialiseBiomarker, type BiomarkerDto } from "./biomarker-dto";
import {
convertLabRange,
convertLabValue,
type ConversionRefusal,
} from "./unit-conversion/convert";
import { sameLabUnit } from "./unit-normalise";
export type MergeConversion = "none" | "converted" | "refused";
export interface MergeSourceSummary {
id: string;
name: string;
unit: string;
readingCount: number;
conversion: MergeConversion;
refusal: ConversionRefusal | null;
}
export interface BiomarkerMergeResult {
dryRun: boolean;
target: BiomarkerDto;
sources: MergeSourceSummary[];
movedReadings: number;
convertedReadings: number;
aliasesAdded: string[];
mergeId: string | null;
}
export type BiomarkerMergeOutcome =
| { kind: "ok"; result: BiomarkerMergeResult }
| { kind: "notFound"; ids: string[] }
| {
kind: "unitMismatch";
sources: Array<{
id: string;
unit: string;
targetUnit: string;
reason: ConversionRefusal;
}>;
};
interface ReadingUpdate {
id: string;
value: number;
unit: string;
referenceLow: number | null;
referenceHigh: number | null;
sourceReferenceLow: number | null;
sourceReferenceHigh: number | null;
sourceValue: number;
sourceUnit: string;
}
/** Thrown inside the transaction to roll it back with the refusal. */
class MergeRefused extends Error {
constructor(readonly outcome: BiomarkerMergeOutcome) {
super("merge refused");
}
}
export async function mergeBiomarkers(input: {
userId: string;
targetId: string;
sourceIds: readonly string[];
dryRun: boolean;
}): Promise<BiomarkerMergeOutcome> {
try {
return await prisma.$transaction(
(tx) => runMerge(tx, input),
// A source with a long history updates row by row where it converts.
{ timeout: 60_000, maxWait: 10_000 },
);
} catch (err) {
if (err instanceof MergeRefused) return err.outcome;
throw err;
}
}
async function runMerge(
tx: Prisma.TransactionClient,
input: {
userId: string;
targetId: string;
sourceIds: readonly string[];
dryRun: boolean;
},
): Promise<BiomarkerMergeOutcome> {
const { userId, targetId, sourceIds, dryRun } = input;
const markers = await tx.biomarker.findMany({
where: { userId, id: { in: [targetId, ...sourceIds] } },
});
const byId = new Map(markers.map((m) => [m.id, m]));
const missing = [targetId, ...sourceIds].filter((id) => !byId.has(id));
if (missing.length > 0) return { kind: "notFound", ids: missing };
const target = byId.get(targetId)!;
const sources = sourceIds.map((id) => byId.get(id)!);
// The target's analyte, derived the way every write path derives it, so a
// stale stored key cannot pick the wrong factor.
const analyteKey = matchAnalyteKey({ name: target.name, unit: target.unit });
const readings = await tx.labResult.findMany({
where: { userId, biomarkerId: { in: [...sourceIds] } },
select: {
id: true,
biomarkerId: true,
value: true,
unit: true,
referenceLow: true,
referenceHigh: true,
sourceReferenceLow: true,
sourceReferenceHigh: true,
sourceValue: true,
sourceUnit: true,
deletedAt: true,
},
});
const summaries: MergeSourceSummary[] = [];
const updates: ReadingUpdate[] = [];
let movedReadings = 0;
// Counted like `readingCount`: the live readings a person sees. A tombstoned
// reading moves and converts with them, so an undo brings it back right.
let convertedLive = 0;
for (const source of sources) {
const own = readings.filter((r) => r.biomarkerId === source.id);
let converted = 0;
let refusal: ConversionRefusal | null = null;
for (const reading of own) {
// A qualitative reading, or one already in the target's unit, moves as
// it is.
if (reading.value === null || sameLabUnit(reading.unit, target.unit)) {
continue;
}
const value = convertLabValue({
analyteKey,
value: reading.value,
from: reading.unit,
to: target.unit,
});
const stamped = convertLabRange({
analyteKey,
low: reading.referenceLow,
high: reading.referenceHigh,
from: reading.unit,
to: target.unit,
});
const printed = convertLabRange({
analyteKey,
low: reading.sourceReferenceLow,
high: reading.sourceReferenceHigh,
from: reading.unit,
to: target.unit,
});
if (!value.ok || !stamped.ok || !printed.ok) {
refusal ??= !value.ok
? value.reason
: !stamped.ok
? stamped.reason
: !printed.ok
? printed.reason
: "unknown_unit";
continue;
}
converted += 1;
if (reading.deletedAt === null) convertedLive += 1;
updates.push({
id: reading.id,
value: value.value,
unit: target.unit,
referenceLow: stamped.low,
referenceHigh: stamped.high,
sourceReferenceLow: printed.low,
sourceReferenceHigh: printed.high,
// A reading converted on its way in keeps what the report printed.
sourceValue: reading.sourceValue ?? reading.value,
sourceUnit: reading.sourceUnit ?? reading.unit.trim(),
});
}
const live = own.filter((r) => r.deletedAt === null).length;
movedReadings += live;
summaries.push({
id: source.id,
name: source.name,
unit: source.unit,
readingCount: live,
conversion: refusal ? "refused" : converted > 0 ? "converted" : "none",
refusal,
});
}
const convertedReadings = convertedLive;
// The spellings the target gains: each source's name and its aliases, once
// each, except what the target's name already is or another marker (not a
// source) already answers to.
const sourceAliases = await tx.biomarkerAlias.findMany({
where: { userId, biomarkerId: { in: [...sourceIds] } },
select: { id: true, name: true, nameFolded: true },
});
const otherAliases = await tx.biomarkerAlias.findMany({
where: { userId, biomarkerId: { notIn: [...sourceIds] } },
select: { biomarkerId: true, nameFolded: true },
});
const blocked = new Set(
otherAliases
.filter((a) => a.biomarkerId !== targetId)
.map((a) => a.nameFolded),
);
const targetAliasFolds = new Set(
otherAliases
.filter((a) => a.biomarkerId === targetId)
.map((a) => a.nameFolded),
);
const otherMarkerFolds = new Set(
(
await tx.biomarker.findMany({
where: { userId, id: { notIn: [targetId, ...sourceIds] } },
select: { name: true },
})
).map((m) => foldForMatch(m.name)),
);
const targetFold = foldForMatch(target.name);
const added = new Map<string, string>();
const consider = (name: string) => {
const folded = foldForMatch(name).slice(0, BIOMARKER_ALIAS_MAX_LENGTH);
if (!folded || folded === targetFold) return;
if (targetAliasFolds.has(folded) || added.has(folded)) return;
if (blocked.has(folded) || otherMarkerFolds.has(folded)) return;
added.set(folded, name.trim().slice(0, BIOMARKER_ALIAS_MAX_LENGTH));
};
for (const source of sources) consider(source.name);
for (const alias of sourceAliases) consider(alias.name);
const aliasesAdded = [...added.values()];
const refused = summaries.filter((s) => s.conversion === "refused");
if (!dryRun && refused.length > 0) {
throw new MergeRefused({
kind: "unitMismatch",
sources: refused.map((s) => ({
id: s.id,
unit: s.unit,
targetUnit: target.unit,
reason: s.refusal ?? "unknown_unit",
})),
});
}
let mergeId: string | null = null;
if (!dryRun) {
for (const update of updates) {
await tx.labResult.update({
where: { id: update.id },
data: {
biomarkerId: targetId,
value: update.value,
unit: update.unit,
referenceLow: update.referenceLow,
referenceHigh: update.referenceHigh,
sourceReferenceLow: update.sourceReferenceLow,
sourceReferenceHigh: update.sourceReferenceHigh,
sourceValue: update.sourceValue,
sourceUnit: update.sourceUnit,
},
});
}
await tx.labResult.updateMany({
where: { userId, biomarkerId: { in: [...sourceIds] } },
data: { biomarkerId: targetId },
});
await tx.medicationEfficacyTarget.updateMany({
where: { biomarkerId: { in: [...sourceIds] } },
data: { biomarkerId: targetId },
});
// The sources' own alias rows go with the sources; the spellings that
// survive are written again on the target below.
await tx.biomarkerAlias.deleteMany({
where: { userId, id: { in: sourceAliases.map((a) => a.id) } },
});
if (added.size > 0) {
await tx.biomarkerAlias.createMany({
data: [...added].map(([nameFolded, name]) => ({
userId,
biomarkerId: targetId,
name,
nameFolded,
source: "MERGE",
})),
});
}
await tx.biomarkerMerge.updateMany({
where: { userId, targetBiomarkerId: { in: [...sourceIds] } },
data: { targetBiomarkerId: targetId },
});
const now = new Date();
for (const summary of summaries) {
const row = await tx.biomarkerMerge.create({
data: {
userId,
targetBiomarkerId: targetId,
sourceBiomarkerId: summary.id,
sourceName: summary.name.slice(0, BIOMARKER_ALIAS_MAX_LENGTH),
readingCount: summary.readingCount,
mergedAt: now,
},
select: { id: true },
});
mergeId ??= row.id;
}
await tx.biomarker.deleteMany({
where: { userId, id: { in: [...sourceIds] } },
});
}
const after = dryRun
? target
: await tx.biomarker.findUniqueOrThrow({ where: { id: targetId } });
const aliases = await aliasNamesByMarker(tx, userId, [after]);
return {
kind: "ok",
result: {
dryRun,
target: serialiseBiomarker(after, aliases.get(after.id) ?? []),
sources: summaries,
movedReadings,
convertedReadings,
aliasesAdded,
mergeId,
},
};
}
/**
* Where a merged marker went: the target of the newest merge that removed
* `id`, or null. Chains resolve because a merge re-points the rows that named
* its sources.
*/
export async function mergedInto(
userId: string,
id: string,
): Promise<string | null> {
const row = await prisma.biomarkerMerge.findFirst({
where: { userId, sourceBiomarkerId: id },
orderBy: { mergedAt: "desc" },
select: { targetBiomarkerId: true },
});
return row?.targetBiomarkerId ?? null;
}
+379
View File
@@ -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;
}
+56 -32
View File
@@ -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,
};
}
+25 -7
View File
@@ -29,6 +29,11 @@ import {
parseReferenceRange,
} from "@/lib/labs/parse-reference-range";
import { matchAnalyteKey } from "@/lib/labs/analyte-match";
import {
loadMarkerIndex,
matchForReview,
type MarkerIndex,
} from "@/lib/labs/biomarker-resolve";
import { sameLabUnit } from "@/lib/labs/unit-normalise";
import { annotate } from "@/lib/logging/context";
import {
@@ -138,16 +143,18 @@ export async function annotateRow(
row: ExtractedRow,
reportDate: string | null,
tz: string,
/** The account's markers and aliases, loaded once per extraction. */
markerIndex?: MarkerIndex,
): Promise<OcrExtractedRowDto> {
const analyte = row.analyte.trim();
const takenAt = normaliseDate(row.takenAt) ?? reportDate;
const printed = parseReferenceRange(row.referenceText, row.unit);
// Biomarker match — case-insensitive on the catalog identity, presence-only.
const existingBiomarker = await prisma.biomarker.findFirst({
where: { userId, name: { equals: analyte, mode: "insensitive" } },
select: { id: true, name: true, unit: true },
});
// v1.43 (D1223) — the marker this name resolves to (name, alias, catalog),
// exactly as the commit resolves it, or up to three to choose from.
const index = markerIndex ?? (await loadMarkerIndex(prisma, userId));
const { match, suggestions } = matchForReview(index, analyte, row.unit);
const existingBiomarker = match?.marker ?? null;
// The analyte the marker is, so the review can say whether a row in another
// unit converts. Derived from the name and unit exactly as the commit derives
// it (a stored key is only that derivation, written down), so the review and
@@ -171,7 +178,12 @@ export async function annotateRow(
where: {
userId,
deletedAt: null,
analyte: { equals: analyte, mode: "insensitive" },
// The printed name, or any reading already filed under the marker it
// resolves to: an earlier report may have spelled it another way.
OR: [
{ analyte: { equals: analyte, mode: "insensitive" } },
...(existingBiomarker ? [{ biomarkerId: existingBiomarker.id }] : []),
],
takenAt: labReadingDaySearchRange(takenAt),
},
select: {
@@ -235,6 +247,9 @@ export async function annotateRow(
markerUnit: existingBiomarker?.unit ?? null,
markerAnalyteKey,
duplicateOf,
biomarkerId: existingBiomarker?.id ?? null,
matchKind: match?.matchKind ?? null,
suggestions,
};
}
@@ -322,11 +337,12 @@ export async function runOcrExtraction(
// hearing about it. A count only: the string is the user's document.
let unreadableRanges = 0;
const tz = await resolveUserTimezone(args.userId);
const markerIndex = await loadMarkerIndex(prisma, args.userId);
for (const row of envelope.rows.slice(0, OCR_MAX_ROWS)) {
if (isUnreadableRange(parseReferenceRange(row.referenceText, row.unit))) {
unreadableRanges += 1;
}
rows.push(await annotateRow(args.userId, row, reportDate, tz));
rows.push(await annotateRow(args.userId, row, reportDate, tz, markerIndex));
}
annotate({
@@ -337,6 +353,8 @@ export async function runOcrExtraction(
rows: rows.length,
duplicates: rows.filter((r) => r.duplicateOf !== null).length,
newBiomarkers: rows.filter((r) => r.biomarkerMatch === "new").length,
aliasMatches: rows.filter((r) => r.matchKind === "alias").length,
catalogMatches: rows.filter((r) => r.matchKind === "catalog").length,
unreadableRanges,
},
});
@@ -51,6 +51,7 @@ vi.mock("@/lib/db", () => ({
})),
},
labResult: { findMany: vi.fn(async () => [{ analyte: "LDL" }]) },
biomarker: { findMany: vi.fn(async () => []) },
encounter: {
findMany: vi.fn(async () => [
{
@@ -4,6 +4,7 @@ vi.mock("@/lib/db", () => ({
prisma: {
medication: { findMany: vi.fn(), findFirst: vi.fn() },
labResult: { findMany: vi.fn() },
biomarker: { findMany: vi.fn(async () => []) },
// v1.30 (G1) — the nutrients pipeline.
nutrientIntakeDay: {
findMany: vi.fn(async () => []),
@@ -13,6 +14,9 @@ vi.mock("@/lib/db", () => ({
},
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/labs/biomarker-resolve", () => ({
resolveMarkerForRead: vi.fn(async () => null),
}));
vi.mock("@/lib/ai/coach/tools/executor", () => ({
executeCoachTool: vi.fn(),
}));
@@ -284,15 +288,25 @@ describe("argument completion (user-scoped, doubles as discovery)", () => {
});
it("completes analytes from ONLY the user's own lab set", async () => {
// One name per marker: the markers with readings, then the names of
// readings filed under no marker (v1.43).
vi.mocked(prisma.biomarker.findMany).mockResolvedValue([
{ name: "LDL" },
] as never);
vi.mocked(prisma.labResult.findMany).mockResolvedValue([
{ analyte: "LDL" },
{ analyte: "HDL" },
{ analyte: "ldl" },
] as never);
const complete = template("lab").complete!.analyte;
const result = await complete(CTX, "ld");
expect(prisma.biomarker.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { userId: "user-1", results: { some: { deletedAt: null } } },
}),
);
expect(prisma.labResult.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { userId: "user-1", deletedAt: null },
where: { userId: "user-1", deletedAt: null, biomarkerId: null },
distinct: ["analyte"],
}),
);
+5
View File
@@ -27,6 +27,11 @@ const { labResult, measurement, user } = vi.hoisted(() => ({
// narrated sentence, which reads the stored preference.
user: { findUnique: vi.fn(async () => ({ locale: "de" })) },
}));
// v1.43 — a lab name resolves to a marker through the account's names and
// aliases; no marker resolves here, so the reads keep their text match.
vi.mock("@/lib/labs/biomarker-resolve", () => ({
resolveMarkerForRead: vi.fn(async () => null),
}));
vi.mock("@/lib/db", () => ({ prisma: { labResult, measurement, user } }));
import {
+5
View File
@@ -23,6 +23,11 @@ vi.mock("@/lib/mcp/record-search", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/mcp/record-search")>()),
loadRecordCandidates: vi.fn(async () => []),
}));
// v1.43 — a lab name resolves to a marker through the account's names and
// aliases; no marker resolves here, so the reads keep their text match.
vi.mock("@/lib/labs/biomarker-resolve", () => ({
resolveMarkerForRead: vi.fn(async () => null),
}));
// v1.22.0 — `search` reads the record directly via Prisma; stub it so the
// registry-wide loops never reach a DB.
vi.mock("@/lib/db", () => ({
+36 -8
View File
@@ -26,6 +26,7 @@
* external-assistant boundary.
*/
import { prisma } from "@/lib/db";
import { resolveMarkerForRead } from "@/lib/labs/biomarker-resolve";
import { fenceUserText } from "@/lib/ai/coach/data-fence";
import { resolveMedicationCategories } from "@/lib/medication-category";
import { dayKeyOfDate } from "@/lib/medications/course-window";
@@ -397,13 +398,35 @@ async function completeMetric(
async function userAnalytes(ctx: McpAuthContext): Promise<string[]> {
// A switched-off labs module lists and completes nothing (v1.39.3).
if (!(await isModuleEnabled(ctx.userId, "labs"))) return [];
const rows = await prisma.labResult.findMany({
where: { userId: ctx.userId, deletedAt: null },
select: { analyte: true },
distinct: ["analyte"],
take: 200,
});
return rows.map((r) => r.analyte);
// One name per marker (v1.43): readings filed under a biomarker list as the
// marker's name, whatever each report printed, so two spellings merged into
// one marker are one resource. A legacy unlinked reading keeps its own name.
const [markers, unlinked] = await Promise.all([
prisma.biomarker.findMany({
where: {
userId: ctx.userId,
results: { some: { deletedAt: null } },
},
select: { name: true },
orderBy: { name: "asc" },
take: 200,
}),
prisma.labResult.findMany({
where: { userId: ctx.userId, deletedAt: null, biomarkerId: null },
select: { analyte: true },
distinct: ["analyte"],
take: 200,
}),
]);
const names = new Map<string, string>();
for (const name of [
...markers.map((m) => m.name),
...unlinked.map((r) => r.analyte),
]) {
const key = name.trim().toLowerCase();
if (!names.has(key)) names.set(key, name);
}
return [...names.values()].slice(0, 200);
}
async function completeAnalyte(
@@ -514,7 +537,12 @@ export const MCP_RESOURCE_TEMPLATES: McpResourceTemplateDefinition[] = [
if (!(await isModuleEnabled(ctx.userId, "labs"))) {
return MODULE_DISABLED;
}
const analyte = decodeURIComponent(firstVar(variables.analyte));
const asked = decodeURIComponent(firstVar(variables.analyte));
// An alias or a catalog abbreviation reads as the marker's own name.
const marker = asked.trim()
? await resolveMarkerForRead(ctx.userId, asked)
: null;
const analyte = marker?.name ?? asked;
const result = await executeCoachTool({
userId: ctx.userId,
name: "get_labs",
+27 -11
View File
@@ -31,6 +31,7 @@
import type { MeasurementType } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { resolveMarkerForRead } from "@/lib/labs/biomarker-resolve";
import { windowWeighting } from "@/lib/measurements/day-statistic";
import { annotate } from "@/lib/logging/context";
import { isModuleEnabled } from "@/lib/modules/gate";
@@ -1400,18 +1401,33 @@ export async function getLabHistory(
: LAB_HISTORY_MAX_LIMIT;
const limit = Math.min(LAB_HISTORY_MAX_LIMIT, rawLimit);
// Match the analyte either on the LabResult's own `analyte` column or on the
// linked Biomarker's canonical name. `take: limit + 1` peeks one row past the
// page to decide whether a `nextCursor` is warranted. Field-by-field `where`.
// A name that resolves to one marker (its name, an alias of the account, a
// catalog abbreviation) reads that marker's whole series, merged spellings
// included (v1.43). Anything else matches the analyte either on the
// LabResult's own `analyte` column, on the linked Biomarker's canonical name
// or on one of its aliases. `take: limit + 1` peeks one row past the page to
// decide whether a `nextCursor` is warranted. Field-by-field `where`.
const marker = await resolveMarkerForRead(userId, needle);
const rows = await prisma.labResult.findMany({
where: {
userId,
deletedAt: null,
OR: [
{ analyte: { contains: needle, mode: "insensitive" } },
{ biomarker: { name: { contains: needle, mode: "insensitive" } } },
],
},
where: marker
? { userId, deletedAt: null, biomarkerId: marker.id }
: {
userId,
deletedAt: null,
OR: [
{ analyte: { contains: needle, mode: "insensitive" } },
{
biomarker: { name: { contains: needle, mode: "insensitive" } },
},
{
biomarker: {
aliases: {
some: { name: { contains: needle, mode: "insensitive" } },
},
},
},
],
},
orderBy: { takenAt: "desc" },
skip: offset,
take: limit + 1,
+13 -4
View File
@@ -20,6 +20,7 @@
import { z } from "zod/v4";
import { prisma } from "@/lib/db";
import { resolveMarkerForRead } from "@/lib/labs/biomarker-resolve";
import { annotate } from "@/lib/logging/context";
import { getDayArgsSchema } from "@/lib/ai/coach/tools/definitions";
import { readDayForTool } from "@/lib/ai/coach/tools/day-read";
@@ -1510,10 +1511,18 @@ function readLabs(
limit: LAB_HISTORY_MAX_LIMIT,
});
}
// Default: latest reading per biomarker via the Coach labs read.
return runCoachTool(ctx, "get_labs", {
...(typeof args.analyte === "string" ? { analyte: args.analyte } : {}),
});
// Default: latest reading per biomarker via the Coach labs read. A name that
// resolves to one of the user's markers (an alias or a catalog abbreviation
// such as "GPT" for "ALT") is read as that marker's own name (v1.43).
return (async () => {
const asked = typeof args.analyte === "string" ? args.analyte : null;
const marker = asked?.trim()
? await resolveMarkerForRead(ctx.userId, asked)
: null;
return runCoachTool(ctx, "get_labs", {
...(asked !== null ? { analyte: marker?.name ?? asked } : {}),
});
})();
}
export const MCP_TOOLS: McpToolDefinition[] = [
+2
View File
@@ -216,9 +216,11 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
"insights.generate.scopeChanged",
],
labs: [
"labs.biomarker.alias.taken",
"labs.biomarker.create.invalid",
"labs.biomarker.duplicate",
"labs.biomarker.merge.invalid",
"labs.biomarker.merge.unitMismatch",
"labs.biomarker.notFound",
"labs.biomarker.update.invalid",
"labs.biomarker.update.referenceRangeInvalid",
+19 -6
View File
@@ -29,7 +29,6 @@ import {
errorEnvelope,
idempotencyKeyParameter,
idempotentWrite,
notImplementedResponse,
recordRefusal,
recordWriteRateLimitResponse,
stdResponses,
@@ -89,6 +88,14 @@ const conflict = {
},
};
const updateConflict = {
"409": {
description:
"`labs.biomarker.duplicate`: a rename collides with another of the caller's markers. `labs.biomarker.alias.taken` (v1.43): one of `aliases` is already the name or an alias of another marker; `meta.alias` names the spelling and `meta.biomarkerId` the marker that answers to it.",
content: { "application/json": { schema: errorEnvelope } },
},
};
const notFound = {
"404": {
description:
@@ -184,7 +191,7 @@ export const biomarkerPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Labs"],
summary: "Fetch a single biomarker",
description:
"Returns the biomarker including its decrypted `context`. Cross-user rows surface as 404.",
"Returns the biomarker including its decrypted `context` and, since v1.43, its `aliases`. Cross-user rows surface as 404. A marker merged into another answers 404 with `meta.mergedInto`.",
requestParams: { path: z.object({ id: z.string() }) },
responses: {
...recordRefusal(),
@@ -204,7 +211,7 @@ export const biomarkerPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Labs"],
summary: "Edit a biomarker",
description:
"Partial edit; omitted fields are untouched, an explicit null clears `context` / `panel` / a bound. Audits as `biomarker.update`. Since v1.40.0 a `unit` different from the stored one is refused with 422 `biomarkers.unit.locked` while any reading is linked to the marker (`meta.readingCount` carries how many): readers label every linked reading with the marker's unit, so a change would relabel stored numbers without converting them. Sending the current unit, or changing it on a marker with no readings, is accepted.",
"Partial edit; omitted fields are untouched, an explicit null clears `context` / `panel` / a bound. Since v1.43 `aliases` (0..50, each 1..120 characters) replaces the marker's set of other spellings: listed spellings stay or are added, unlisted ones are removed; omitting the field leaves them as they are. Audits as `biomarker.update`. Since v1.40.0 a `unit` different from the stored one is refused with 422 `biomarkers.unit.locked` while any reading is linked to the marker (`meta.readingCount` carries how many): readers label every linked reading with the marker's unit, so a change would relabel stored numbers without converting them. Sending the current unit, or changing it on a marker with no readings, is accepted.",
requestParams: { path: z.object({ id: z.string() }) },
requestBody: {
required: true,
@@ -220,7 +227,7 @@ export const biomarkerPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
...conflict,
...updateConflict,
...notFound,
...stdResponses,
},
@@ -263,8 +270,10 @@ export const biomarkerPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
responses: {
...idempotentWrite(),
...recordRefusal(),
"200": {
description: "The merge result (or the dry-run preview).",
description:
'The merge result, or the dry-run preview. A preview reports a source that cannot be converted with `conversion: "refused"` and its `refusal`; the same request without `dryRun` answers 422 `labs.biomarker.merge.unitMismatch`.',
content: {
"application/json": {
schema: dataEnvelope(
@@ -276,8 +285,12 @@ export const biomarkerPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
...notFound,
...stdResponses,
"422": {
description:
"`labs.biomarker.merge.invalid`: a malformed body, or the target among the sources (Zod issues in `details.issues[]`). `labs.biomarker.merge.unitMismatch`: a source holds readings in a unit the registry cannot convert to the target's; `meta.sources` lists each `{ id, unit, targetUnit, reason }` with `reason` one of `unknown_analyte`, `unknown_unit`, `not_finite`. Nothing was moved.",
content: { "application/json": { schema: errorEnvelope } },
},
...recordWriteRateLimitResponse,
...notImplementedResponse,
},
},
},
+6 -4
View File
@@ -177,10 +177,12 @@ const commitResponse = z
skipped: z.array(
z.object({
analyte: z.string(),
reason: z.enum(["duplicate", "unit_mismatch"]).meta({
description:
"`duplicate`: the row matched a live reading at commit time. `unit_mismatch`: the row states a unit other than the one its marker is tracked in and the pair cannot be converted, so it was not written. A pair that can be converted is written in the marker's unit and reported with `converted: true`.",
}),
reason: z
.enum(["duplicate", "unit_mismatch", "unknown_biomarker"])
.meta({
description:
"`duplicate`: the row matched a live reading at commit time. `unit_mismatch`: the row states a unit other than the one its marker is tracked in and the pair cannot be converted, so it was not written. A pair that can be converted is written in the marker's unit and reported with `converted: true`. `unknown_biomarker` (v1.43): the row named a `biomarkerId` that is not one of the record's markers.",
}),
}),
),
outcome: z.enum(["empty", "failed", "partial", "success"]).meta({
+2
View File
@@ -179,6 +179,8 @@ export function recordActivityVerbLine(
return t("recordSharing.activityVerb.biomarkerUpdate", { name });
case "biomarker.delete":
return t("recordSharing.activityVerb.biomarkerDelete", { name });
case "labs.biomarker.merge":
return t("recordSharing.activityVerb.biomarkerMerge", { name });
case "allergy.update":
return t("recordSharing.activityVerb.allergyUpdate", { name });
+29
View File
@@ -1134,11 +1134,38 @@ const biomarkerBackupSchema = z
// v1.42 (#1095) — the catalogue analyte the marker resolves to.
analyteKey: z.string().nullable().optional(),
context: z.string().nullable().optional(),
// v1.43 (D1223) — the other spellings the marker answers to. Absent on
// files written before them.
aliases: z
.array(
z
.object({
id: z.string().min(1).optional(),
name: z.string().min(1).max(120),
source: z.enum(["MERGE", "IMPORT_CHOICE", "MANUAL"]),
createdAt: isoDateTime.optional(),
})
.passthrough(),
)
.optional(),
createdAt: isoDateTime.optional(),
updatedAt: isoDateTime.optional(),
})
.passthrough();
// v1.43 (D1223) — the ledger of markers merged into others.
const biomarkerMergeBackupSchema = z
.object({
id: z.string().min(1).optional(),
targetBiomarkerId: z.string().min(1).optional(),
targetName: z.string().min(1),
sourceBiomarkerId: z.string().min(1),
sourceName: z.string().min(1).max(120),
readingCount: z.number().int().min(0),
mergedAt: isoDateTime,
})
.passthrough();
const illnessSymptomBackupSchema = z
.object({
key: z.string().min(1),
@@ -2101,6 +2128,8 @@ export const backupPayloadSchema = z
// fields; portable exports retain the metadata-only subset.
labResults: z.array(labResultBackupSchema).default([]),
biomarkers: z.array(biomarkerBackupSchema).default([]),
// Defaulted: a file written before v1.43 carries no key.
biomarkerMerges: z.array(biomarkerMergeBackupSchema).default([]),
illnessEpisodes: z.array(illnessEpisodeBackupSchema).default([]),
// Defaulted: a file written before v1.40 carries no key.
symptomDefinitions: z.array(symptomDefinitionBackupSchema).default([]),
+4
View File
@@ -75,6 +75,10 @@ export const updateBiomarkerSchema = z
// v1.22 — hide / unhide a marker. An omitted key leaves visibility
// untouched; `true` drops the marker from the active list + pickers.
hidden: z.boolean().optional(),
// v1.43 (D1223) — the other spellings this marker answers to, as a whole
// set: what is listed stays or is added, what is not is removed. Omitted
// leaves them untouched, so an older client's PUT keeps them.
aliases: z.array(z.string().trim().min(1).max(120)).max(50).optional(),
})
.refine(
(d) =>
+21
View File
@@ -340,6 +340,18 @@ export interface ExtractedFactDto {
provenance: FactProvenance;
committedRecordId: string | null;
committedRecordType: string | null;
/**
* v1.43 (D1223), pending OBSERVATION facts only — the marker the label
* resolves to and how, or up to three markers to choose from.
*/
biomarkerId?: string | null;
matchKind?: "name" | "alias" | "catalog" | null;
suggestions?: Array<{
biomarkerId: string;
name: string;
unit: string;
matchKind: "catalog" | "similar";
}>;
}
/** One condition link on a document DTO (chip → `/illness/[id]`). */
@@ -602,6 +614,15 @@ export const inboundConfirmSchema = z.object({
z.object({
factId: z.string().trim().min(1).max(40),
action: z.enum(["approve", "reject"]),
/**
* v1.43 (D1223), OBSERVATION only — the marker the person chose for
* the fact; the reading is filed under it whatever the label says.
* An id that is not one of the record's markers fails the fact with
* `observation.unknownBiomarker`.
*/
biomarkerId: z.string().trim().min(1).max(64).optional(),
/** With `biomarkerId`: remember the label as an alias. Default true. */
rememberAlias: z.boolean().optional(),
}),
)
.min(1)
+28 -2
View File
@@ -138,6 +138,19 @@ export interface OcrExtractedRowDto {
*/
markerAnalyteKey: string | null;
duplicateOf: string | null;
/**
* v1.43 (D1223) — the marker the row was matched to automatically: by its
* name, an alias of the account, or a catalog abbreviation. Null when none.
*/
biomarkerId: string | null;
matchKind: "name" | "alias" | "catalog" | null;
/** Up to three markers to choose from when nothing matched; else empty. */
suggestions: Array<{
biomarkerId: string;
name: string;
unit: string;
matchKind: "catalog" | "similar";
}>;
}
export interface OcrExtractResponseDto {
@@ -190,6 +203,18 @@ export const ocrCommitRowSchema = z
.optional()
.or(z.literal("").transform(() => undefined)),
takenAt: takenAtField,
/**
* v1.43 (D1223) — the marker the person chose for this row on the review
* screen. The row is written under it whatever `analyte` says; an id that
* is not one of the record's markers skips the row (`unknown_biomarker`).
*/
biomarkerId: z.string().trim().min(1).max(64).optional(),
/**
* With `biomarkerId`: remember `analyte` as another spelling of that
* marker, so the next report with the same name matches on its own.
* Default true. Ignored when the name is the marker's own.
*/
rememberAlias: z.boolean().optional(),
})
// Numeric XOR qualitative: exactly one of value / valueText.
.refine((d) => (d.value !== undefined) !== (d.valueText !== undefined), {
@@ -246,11 +271,12 @@ export type OcrCommitInput = z.infer<typeof ocrCommitSchema>;
/**
* A row the commit route did not write. `duplicate`: it matched a live reading
* at commit time. `unit_mismatch`: it states a unit other than the one its
* marker is tracked in.
* marker is tracked in. `unknown_biomarker`: the chosen `biomarkerId` is not
* one of the record's markers.
*/
export interface OcrSkippedRowDto {
analyte: string;
reason: "duplicate" | "unit_mismatch";
reason: "duplicate" | "unit_mismatch" | "unknown_biomarker";
}
export interface OcrCommitResponseDto {