From a3ce6af9a368117a19e3d116499ff51d2dee99ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 22:22:29 +0200 Subject: [PATCH] feat(labs): resolve marker names through aliases and catalog shorthand A printed name now resolves to one of the account's markers by its own name, then by an alias of the account, then by a catalog name for the same analyte when the reading's unit fits. The catalog gains seeds for alkaline phosphatase and total bilirubin and a checked list of common lab shorthand in seven languages; names that mean two analytes stay out. --- messages/de.json | 8 +- messages/en.json | 8 +- messages/es.json | 8 +- messages/fr.json | 8 +- messages/it.json | 8 +- messages/ko.json | 8 +- messages/pl.json | 8 +- .../labs/__tests__/biomarker-resolve.test.ts | 286 ++++++++++ .../resolve-or-mint-biomarker.test.ts | 131 +++-- src/lib/labs/analyte-match.ts | 130 ++++- src/lib/labs/biomarker-aliases.ts | 172 ++++++ src/lib/labs/biomarker-catalog.ts | 507 +++++++++++++++++- src/lib/labs/biomarker-resolve.ts | 379 +++++++++++++ src/lib/labs/biomarker-store.ts | 88 +-- 14 files changed, 1648 insertions(+), 101 deletions(-) create mode 100644 src/lib/labs/__tests__/biomarker-resolve.test.ts create mode 100644 src/lib/labs/biomarker-aliases.ts create mode 100644 src/lib/labs/biomarker-resolve.ts diff --git a/messages/de.json b/messages/de.json index 2abf8465c..fc3592090 100644 --- a/messages/de.json +++ b/messages/de.json @@ -9604,12 +9604,16 @@ "apob": "Apolipoprotein B ist das Strukturprotein, das jedes der atherogenen Lipoprotein-Partikel trägt; seine Messung zählt daher diese Partikel direkt und nicht das von ihnen transportierte Cholesterin. Da auf jedem LDL-, VLDL- und verwandten Partikel genau ein ApoB-Molekül sitzt, kann es kardiovaskuläres Risiko erfassen, das ein LDL-Cholesterinwert übersieht, besonders wenn Partikelzahl und Cholesteringehalt auseinandergehen. Aus diesem Grund wird es zunehmend genutzt, um die lipidbasierte Risikoeinschätzung zu verfeinern. Beurteilt wird es zusammen mit dem üblichen Lipidpanel.", "lp-a": "Lipoprotein(a) ist ein LDL-ähnliches Partikel mit einem zusätzlich angehefteten Protein, dessen Spiegel weitgehend genetisch festgelegt ist und ein Leben lang recht stabil bleibt. Ein erhöhtes Lp(a) ist ein unabhängiger, vererbter Risikofaktor für Herz-Kreislauf-Erkrankungen und eine Verengung der Aortenklappe. Da es sich kaum verändert, wird es meist einmal bestimmt, um das lebenslange Risiko einer Person festzulegen, statt es wiederholt zu verfolgen. Es wird in molaren Einheiten angegeben und als Teil des gesamten kardiovaskulären Risikos gelesen.", "fasting-insulin": "Das Nüchterninsulin ist der Spiegel des Insulins (des Hormons, das Glukose aus dem Blut in die Zellen schleust), gemessen nach einer nächtlichen Nüchternphase. Zusammen mit dem Nüchternblutzucker hilft es, die Insulinresistenz abzuschätzen, also den Zustand, in dem der Körper mehr Insulin braucht, um den Blutzucker im Bereich zu halten. Ein hohes Nüchterninsulin bei normalem Blutzucker kann ein frühes Zeichen dafür sein, dass dieser Ausgleich im Gange ist. Gelesen wird es als Teil eines Stoffwechselbilds und nicht für sich allein.", - "omega-3-index": "Der Omega-3-Index ist der Anteil von EPA und DHA (den beiden langkettigen marinen Omega-3-Fettsäuren) an den Fettsäuren in den Membranen der roten Blutkörperchen. Da rote Blutkörperchen sich langsam erneuern, spiegelt er die langfristige Omega-3-Zufuhr wider und nicht eine kürzliche Mahlzeit. Ein höherer Index wurde in Beobachtungsstudien mit einem niedrigeren kardiovaskulären Risiko in Verbindung gebracht. Er wird als stabiler Marker des Omega-3-Status verfolgt, der über Monate auf die Ernährung reagiert." + "omega-3-index": "Der Omega-3-Index ist der Anteil von EPA und DHA (den beiden langkettigen marinen Omega-3-Fettsäuren) an den Fettsäuren in den Membranen der roten Blutkörperchen. Da rote Blutkörperchen sich langsam erneuern, spiegelt er die langfristige Omega-3-Zufuhr wider und nicht eine kürzliche Mahlzeit. Ein höherer Index wurde in Beobachtungsstudien mit einem niedrigeren kardiovaskulären Risiko in Verbindung gebracht. Er wird als stabiler Marker des Omega-3-Status verfolgt, der über Monate auf die Ernährung reagiert.", + "alkaline-phosphatase": "Die alkalische Phosphatase (AP) ist ein Enzym, das vor allem in Leber, Gallenwegen und Knochen vorkommt. Sie steigt, wenn der Gallenfluss gestört ist, und in Phasen aktiven Knochenaufbaus, etwa im Wachstum von Kindern und Jugendlichen oder bei der Heilung eines Bruchs. Weil beide Organe sie abgeben, wird ein erhöhter Wert meist zusammen mit der GGT und den übrigen Leberwerten gelesen, um eine Ursache in der Leber von einer im Knochen zu unterscheiden. Sie gehört zu den üblichen Leberwerten.", + "bilirubin-total": "Das Gesamtbilirubin ist das gelbe Abbauprodukt des Hämoglobins aus gealterten roten Blutkörperchen, gemessen als Summe seiner unkonjugierten und konjugierten Form. Die Leber nimmt es auf, bindet es und scheidet es mit der Galle aus, daher zeigt der Wert, wie gut dieser Weg funktioniert. Er kann bei Lebererkrankungen, verschlossenen Gallenwegen oder vermehrtem Abbau roter Blutkörperchen steigen, harmlos auch beim Gilbert-Syndrom. Er wird zusammen mit den übrigen Leberwerten gelesen." }, "apob": "Apolipoprotein B (ApoB)", "lp-a": "Lipoprotein(a)", "fasting-insulin": "Nüchtern-Insulin", - "omega-3-index": "Omega-3-Index" + "omega-3-index": "Omega-3-Index", + "alkaline-phosphatase": "Alkalische Phosphatase (AP)", + "bilirubin-total": "Bilirubin gesamt" }, "noteLoadFailed": "Notiz konnte nicht geladen werden; sie bleibt beim Speichern unverändert.", "showingLatestOf": "Die neuesten {shown} von {total} Messwerten werden angezeigt", diff --git a/messages/en.json b/messages/en.json index 46880af6d..98a57afc3 100644 --- a/messages/en.json +++ b/messages/en.json @@ -9604,12 +9604,16 @@ "apob": "Apolipoprotein B is the structural protein carried by each of the atherogenic lipoprotein particles, so measuring it counts those particles directly rather than the cholesterol they carry. Because one ApoB molecule sits on each LDL, VLDL, and related particle, it can capture cardiovascular risk that an LDL-cholesterol value misses, particularly when particle number and cholesterol content diverge. For this reason it is increasingly used to refine lipid-based risk assessment. It is interpreted alongside the standard lipid panel.", "lp-a": "Lipoprotein(a) is an LDL-like particle with an extra attached protein, and its level is largely set by genetics and stays fairly stable through life. Elevated Lp(a) is an independent, inherited risk factor for cardiovascular disease and aortic-valve narrowing. Because it changes little, it is usually measured once to establish a person's lifelong risk rather than tracked repeatedly. It is reported in molar units and read as part of overall cardiovascular risk.", "fasting-insulin": "Fasting insulin is the level of insulin (the hormone that moves glucose from the blood into cells) measured after an overnight fast. Paired with fasting glucose, it helps estimate insulin resistance, the state in which the body needs more insulin to keep glucose in range. A high fasting insulin alongside normal glucose can be an early sign that this compensation is under way. It is read as part of a metabolic-health picture rather than in isolation.", - "omega-3-index": "The omega-3 index is the share of EPA and DHA (the two long-chain marine omega-3 fatty acids) among the fatty acids in red-blood-cell membranes. Because red cells turn over slowly, it reflects long-term omega-3 intake rather than a recent meal. A higher index has been associated in observational studies with lower cardiovascular risk. It is tracked as a stable marker of omega-3 status that responds to diet over months." + "omega-3-index": "The omega-3 index is the share of EPA and DHA (the two long-chain marine omega-3 fatty acids) among the fatty acids in red-blood-cell membranes. Because red cells turn over slowly, it reflects long-term omega-3 intake rather than a recent meal. A higher index has been associated in observational studies with lower cardiovascular risk. It is tracked as a stable marker of omega-3 status that responds to diet over months.", + "alkaline-phosphatase": "Alkaline phosphatase (ALP) is an enzyme found mainly in the liver, the bile ducts and bone. It rises when bile flow is blocked and during phases of active bone building, such as growth in children and adolescents or bone healing. Because both organs release it, a raised value is usually read together with GGT and the other liver markers to tell a liver source from a bone source. It is one of the standard liver-panel markers.", + "bilirubin-total": "Total bilirubin is the yellow breakdown product of haemoglobin from worn-out red blood cells, measured as the sum of its unconjugated and conjugated forms. The liver takes it up, conjugates it and excretes it in bile, so the level reflects how well this pathway is working. It can rise with liver disease, blocked bile ducts or increased breakdown of red cells, and harmlessly in Gilbert's syndrome. It is read alongside the other liver markers." }, "apob": "Apolipoprotein B (ApoB)", "lp-a": "Lipoprotein(a)", "fasting-insulin": "Fasting insulin", - "omega-3-index": "Omega-3 index" + "omega-3-index": "Omega-3 index", + "alkaline-phosphatase": "Alkaline phosphatase (ALP)", + "bilirubin-total": "Total bilirubin" }, "noteLoadFailed": "Note could not be loaded; it will be kept unchanged when you save.", "showingLatestOf": "Showing the latest {shown} of {total} readings", diff --git a/messages/es.json b/messages/es.json index 112c33f1d..4f8622fc0 100644 --- a/messages/es.json +++ b/messages/es.json @@ -9604,12 +9604,16 @@ "apob": "La apolipoproteína B es la proteína estructural que lleva cada una de las partículas de lipoproteína aterogénicas, de modo que medirla cuenta directamente esas partículas en lugar del colesterol que transportan. Como hay una molécula de ApoB en cada partícula de LDL, VLDL y afines, puede captar un riesgo cardiovascular que un valor de colesterol LDL pasa por alto, sobre todo cuando el número de partículas y su contenido de colesterol divergen. Por eso se usa cada vez más para afinar la evaluación del riesgo basada en los lípidos. Se interpreta junto con el perfil lipídico habitual.", "lp-a": "La lipoproteína(a) es una partícula similar al LDL con una proteína adicional unida, cuyo nivel viene determinado en gran medida por la genética y se mantiene bastante estable a lo largo de la vida. Una Lp(a) elevada es un factor de riesgo independiente y hereditario de enfermedad cardiovascular y de estrechamiento de la válvula aórtica. Como cambia poco, suele medirse una sola vez para establecer el riesgo vital de una persona, en lugar de seguirla de forma repetida. Se expresa en unidades molares y se lee como parte del riesgo cardiovascular global.", "fasting-insulin": "La insulina en ayunas es el nivel de insulina —la hormona que traslada la glucosa de la sangre a las células— medido tras un ayuno nocturno. Junto con la glucosa en ayunas, ayuda a estimar la resistencia a la insulina, el estado en el que el cuerpo necesita más insulina para mantener la glucosa dentro de su rango. Una insulina en ayunas alta con una glucosa normal puede ser un signo temprano de que esa compensación está en marcha. Se lee como parte de un cuadro de salud metabólica y no de forma aislada.", - "omega-3-index": "El índice omega-3 es la proporción de EPA y DHA —los dos ácidos grasos omega-3 marinos de cadena larga— entre los ácidos grasos de las membranas de los glóbulos rojos. Como los glóbulos rojos se renuevan despacio, refleja la ingesta de omega-3 a largo plazo y no una comida reciente. Un índice más alto se ha asociado en estudios observacionales a un menor riesgo cardiovascular. Se sigue como un marcador estable del estado de omega-3 que responde a la dieta a lo largo de meses." + "omega-3-index": "El índice omega-3 es la proporción de EPA y DHA —los dos ácidos grasos omega-3 marinos de cadena larga— entre los ácidos grasos de las membranas de los glóbulos rojos. Como los glóbulos rojos se renuevan despacio, refleja la ingesta de omega-3 a largo plazo y no una comida reciente. Un índice más alto se ha asociado en estudios observacionales a un menor riesgo cardiovascular. Se sigue como un marcador estable del estado de omega-3 que responde a la dieta a lo largo de meses.", + "alkaline-phosphatase": "La fosfatasa alcalina es una enzima presente sobre todo en el hígado, las vías biliares y el hueso. Sube cuando se obstruye el flujo de la bilis y en fases de formación ósea activa, como el crecimiento en niños y adolescentes o la consolidación de una fractura. Como la liberan ambos órganos, un valor alto suele leerse junto con la GGT y los demás marcadores hepáticos para distinguir un origen hepático de uno óseo. Es uno de los marcadores habituales del perfil hepático.", + "bilirubin-total": "La bilirrubina total es el producto amarillo de la degradación de la hemoglobina de los glóbulos rojos envejecidos, medido como la suma de sus formas no conjugada y conjugada. El hígado la capta, la conjuga y la elimina por la bilis, de modo que su nivel refleja cómo funciona esa vía. Puede subir con enfermedades del hígado, obstrucción de las vías biliares o mayor destrucción de glóbulos rojos, y de forma inofensiva en el síndrome de Gilbert. Se interpreta junto con los demás marcadores hepáticos." }, "apob": "Apolipoproteína B (ApoB)", "lp-a": "Lipoproteína(a)", "fasting-insulin": "Insulina en ayunas", - "omega-3-index": "Índice omega-3" + "omega-3-index": "Índice omega-3", + "alkaline-phosphatase": "Fosfatasa alcalina", + "bilirubin-total": "Bilirrubina total" }, "noteLoadFailed": "No se pudo cargar la nota; se mantendrá sin cambios al guardar.", "showingLatestOf": "Mostrando los {shown} resultados más recientes de {total}", diff --git a/messages/fr.json b/messages/fr.json index 6177b0427..48b16f632 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -9604,12 +9604,16 @@ "apob": "L'apolipoprotéine B est la protéine structurale portée par chacune des particules de lipoprotéines athérogènes ; la doser revient donc à compter directement ces particules plutôt que le cholestérol qu'elles transportent. Comme il y a une molécule d'ApoB sur chaque particule de LDL, de VLDL et apparentée, elle peut capter un risque cardiovasculaire qu'une valeur de cholestérol LDL manque, en particulier lorsque le nombre de particules et leur teneur en cholestérol divergent. C'est pourquoi elle est de plus en plus utilisée pour affiner l'évaluation du risque fondée sur les lipides. Elle s'interprète avec le bilan lipidique habituel.", "lp-a": "La lipoprotéine(a) est une particule semblable au LDL avec une protéine supplémentaire attachée, dont le taux est en grande partie déterminé par la génétique et reste assez stable toute la vie. Une Lp(a) élevée est un facteur de risque indépendant et héréditaire de maladie cardiovasculaire et de rétrécissement de la valve aortique. Comme elle varie peu, on la mesure généralement une seule fois pour établir le risque à vie d'une personne, plutôt que de la suivre de façon répétée. Elle s'exprime en unités molaires et se lit dans le cadre du risque cardiovasculaire global.", "fasting-insulin": "L'insuline à jeun est le taux d'insuline (l'hormone qui fait passer le glucose du sang vers les cellules) mesuré après un jeûne nocturne. Associée à la glycémie à jeun, elle aide à estimer la résistance à l'insuline, l'état dans lequel le corps a besoin de plus d'insuline pour maintenir le glucose dans sa plage. Une insuline à jeun élevée avec une glycémie normale peut être un signe précoce que cette compensation est en cours. Elle se lit dans le cadre d'un bilan de santé métabolique et non isolément.", - "omega-3-index": "L'indice oméga-3 est la part d'EPA et de DHA (les deux acides gras oméga-3 marins à longue chaîne) parmi les acides gras des membranes des globules rouges. Comme les globules rouges se renouvellent lentement, il reflète l'apport en oméga-3 sur le long terme plutôt qu'un repas récent. Un indice plus élevé a été associé, dans des études d'observation, à un risque cardiovasculaire moindre. Il est suivi comme un marqueur stable du statut en oméga-3, qui répond à l'alimentation sur plusieurs mois." + "omega-3-index": "L'indice oméga-3 est la part d'EPA et de DHA (les deux acides gras oméga-3 marins à longue chaîne) parmi les acides gras des membranes des globules rouges. Comme les globules rouges se renouvellent lentement, il reflète l'apport en oméga-3 sur le long terme plutôt qu'un repas récent. Un indice plus élevé a été associé, dans des études d'observation, à un risque cardiovasculaire moindre. Il est suivi comme un marqueur stable du statut en oméga-3, qui répond à l'alimentation sur plusieurs mois.", + "alkaline-phosphatase": "Les phosphatases alcalines (PAL) sont des enzymes présentes surtout dans le foie, les voies biliaires et l'os. Elles augmentent lorsque l'écoulement de la bile est entravé et pendant les phases de formation osseuse active, comme la croissance chez l'enfant et l'adolescent ou la consolidation d'une fracture. Comme les deux organes les libèrent, une valeur élevée se lit en général avec la GGT et les autres marqueurs hépatiques pour distinguer une origine hépatique d'une origine osseuse. Elles font partie du bilan hépatique habituel.", + "bilirubin-total": "La bilirubine totale est le produit jaune de la dégradation de l'hémoglobine des globules rouges usés, mesuré comme la somme de ses formes non conjuguée et conjuguée. Le foie la capte, la conjugue et l'élimine dans la bile, si bien que son taux reflète le bon fonctionnement de cette voie. Elle peut augmenter en cas de maladie du foie, d'obstruction des voies biliaires ou de destruction accrue des globules rouges, et sans gravité dans le syndrome de Gilbert. Elle se lit avec les autres marqueurs hépatiques." }, "apob": "Apolipoprotéine B (ApoB)", "lp-a": "Lipoprotéine(a)", "fasting-insulin": "Insuline à jeun", - "omega-3-index": "Indice oméga-3" + "omega-3-index": "Indice oméga-3", + "alkaline-phosphatase": "Phosphatases alcalines (PAL)", + "bilirubin-total": "Bilirubine totale" }, "noteLoadFailed": "La note n’a pas pu être chargée ; elle restera inchangée à l’enregistrement.", "showingLatestOf": "Affichage des {shown} derniers résultats sur {total}", diff --git a/messages/it.json b/messages/it.json index 3749f474f..a716cd7d7 100644 --- a/messages/it.json +++ b/messages/it.json @@ -9604,12 +9604,16 @@ "apob": "L'apolipoproteina B è la proteina strutturale presente su ciascuna delle particelle di lipoproteine aterogene, quindi misurarla significa contare direttamente quelle particelle anziché il colesterolo che trasportano. Poiché c'è una molecola di ApoB su ogni particella di LDL, VLDL e affini, può cogliere un rischio cardiovascolare che un valore di colesterolo LDL non rileva, soprattutto quando il numero di particelle e il loro contenuto di colesterolo divergono. Per questo è sempre più usata per affinare la valutazione del rischio basata sui lipidi. Si interpreta insieme al consueto profilo lipidico.", "lp-a": "La lipoproteina(a) è una particella simile all'LDL con una proteina aggiuntiva attaccata, il cui livello è in gran parte determinato dalla genetica e resta piuttosto stabile per tutta la vita. Una Lp(a) elevata è un fattore di rischio indipendente ed ereditario di malattia cardiovascolare e di restringimento della valvola aortica. Poiché varia poco, di solito si misura una sola volta per stabilire il rischio di una persona lungo l'arco della vita, anziché seguirla ripetutamente. Si esprime in unità molari e si legge nell'ambito del rischio cardiovascolare complessivo.", "fasting-insulin": "L'insulina a digiuno è il livello di insulina (l'ormone che fa passare il glucosio dal sangue alle cellule) misurato dopo un digiuno notturno. Insieme alla glicemia a digiuno, aiuta a stimare la resistenza all'insulina, lo stato in cui il corpo ha bisogno di più insulina per mantenere il glucosio nel suo intervallo. Un'insulina a digiuno alta con una glicemia normale può essere un segno precoce che questa compensazione è in atto. Si legge nell'ambito di un quadro di salute metabolica e non isolatamente.", - "omega-3-index": "L'indice omega-3 è la quota di EPA e DHA (i due acidi grassi omega-3 marini a catena lunga) tra gli acidi grassi delle membrane dei globuli rossi. Poiché i globuli rossi si rinnovano lentamente, riflette l'apporto di omega-3 a lungo termine anziché un pasto recente. Un indice più alto è stato associato, in studi osservazionali, a un rischio cardiovascolare più basso. Si segue come un marcatore stabile dello stato di omega-3 che risponde alla dieta nell'arco di mesi." + "omega-3-index": "L'indice omega-3 è la quota di EPA e DHA (i due acidi grassi omega-3 marini a catena lunga) tra gli acidi grassi delle membrane dei globuli rossi. Poiché i globuli rossi si rinnovano lentamente, riflette l'apporto di omega-3 a lungo termine anziché un pasto recente. Un indice più alto è stato associato, in studi osservazionali, a un rischio cardiovascolare più basso. Si segue come un marcatore stabile dello stato di omega-3 che risponde alla dieta nell'arco di mesi.", + "alkaline-phosphatase": "La fosfatasi alcalina (ALP) è un enzima presente soprattutto nel fegato, nelle vie biliari e nell'osso. Sale quando il flusso della bile è ostacolato e nelle fasi di formazione ossea attiva, come la crescita nei bambini e negli adolescenti o la guarigione di una frattura. Poiché la rilasciano entrambi gli organi, un valore alto si legge di solito insieme alla GGT e agli altri indici epatici per distinguere un'origine epatica da una ossea. È uno degli indici abituali del profilo epatico.", + "bilirubin-total": "La bilirubina totale è il prodotto giallo della degradazione dell'emoglobina dei globuli rossi invecchiati, misurato come somma delle sue forme non coniugata e coniugata. Il fegato la capta, la coniuga e la elimina con la bile, quindi il suo livello riflette il funzionamento di questa via. Può salire con malattie del fegato, ostruzione delle vie biliari o aumentata distruzione dei globuli rossi, e in modo innocuo nella sindrome di Gilbert. Si legge insieme agli altri indici epatici." }, "apob": "Apolipoproteina B (ApoB)", "lp-a": "Lipoproteina(a)", "fasting-insulin": "Insulina a digiuno", - "omega-3-index": "Indice omega-3" + "omega-3-index": "Indice omega-3", + "alkaline-phosphatase": "Fosfatasi alcalina (ALP)", + "bilirubin-total": "Bilirubina totale" }, "noteLoadFailed": "Impossibile caricare la nota; resterà invariata al salvataggio.", "showingLatestOf": "Visualizzazione degli ultimi {shown} risultati su {total}", diff --git a/messages/ko.json b/messages/ko.json index 4d17c9756..ebf33b659 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -9604,12 +9604,16 @@ "apob": "아포지단백 B는 죽상경화를 일으키는 지단백 입자마다 하나씩 붙어 있는 구조 단백질이라, 이 값을 재면 입자가 나르는 콜레스테롤이 아니라 입자 수를 직접 세는 셈이에요. LDL과 VLDL, 관련 입자 하나에 ApoB 분자가 하나씩 있기 때문에, 입자 수와 콜레스테롤 양이 어긋날 때 LDL 콜레스테롤이 놓치는 심혈관 위험을 잡아낼 수 있어요. 그래서 지질 기반 위험 평가를 다듬는 데 점점 많이 쓰여요. 기본 지질 검사와 함께 해석해요.", "lp-a": "지단백(a)는 LDL과 비슷한 입자에 단백질이 하나 더 붙은 형태로, 수치는 대체로 유전으로 정해지고 평생 꽤 일정하게 유지돼요. Lp(a)가 높으면 심혈관 질환과 대동맥판막 협착의 독립적인 유전 위험 요인이에요. 거의 변하지 않기 때문에 반복해서 추적하기보다 평생 위험을 파악하려고 보통 한 번만 재요. 몰 단위로 보고되고 전체 심혈관 위험의 일부로 읽어요.", "fasting-insulin": "공복 인슐린은 혈액 속 포도당을 세포로 옮기는 호르몬인 인슐린을 밤새 금식한 뒤 잰 값이에요. 공복혈당과 함께 보면 혈당을 유지하려고 몸이 더 많은 인슐린을 필요로 하는 상태, 즉 인슐린 저항성을 가늠할 수 있어요. 혈당은 정상인데 공복 인슐린이 높다면 이런 보상이 이미 진행 중이라는 이른 신호일 수 있어요. 단독으로 보기보다 대사 건강 전체 그림의 일부로 읽어요.", - "omega-3-index": "오메가3 지수는 적혈구 막의 지방산 가운데 긴사슬 해양 오메가3 지방산인 EPA와 DHA가 차지하는 비율이에요. 적혈구는 천천히 교체되기 때문에 최근 식사가 아니라 장기적인 오메가3 섭취를 반영해요. 지수가 높을수록 관찰연구에서 심혈관 위험이 낮은 것과 연관됐어요. 몇 달에 걸쳐 식사에 반응하는 안정적인 오메가3 상태 지표로 기록해요." + "omega-3-index": "오메가3 지수는 적혈구 막의 지방산 가운데 긴사슬 해양 오메가3 지방산인 EPA와 DHA가 차지하는 비율이에요. 적혈구는 천천히 교체되기 때문에 최근 식사가 아니라 장기적인 오메가3 섭취를 반영해요. 지수가 높을수록 관찰연구에서 심혈관 위험이 낮은 것과 연관됐어요. 몇 달에 걸쳐 식사에 반응하는 안정적인 오메가3 상태 지표로 기록해요.", + "alkaline-phosphatase": "알칼리성 인산분해효소(ALP)는 주로 간, 담관, 뼈에 있는 효소예요. 담즙 흐름이 막히거나, 어린이와 청소년의 성장기나 골절이 아무는 시기처럼 뼈가 활발히 만들어질 때 올라가요. 두 기관 모두에서 나오기 때문에 높은 값은 보통 GGT와 다른 간 지표와 함께 읽어서 간에서 온 것인지 뼈에서 온 것인지 구분해요. 기본 간 검사 지표 중 하나예요.", + "bilirubin-total": "총빌리루빈은 수명이 다한 적혈구의 헤모글로빈이 분해되면서 생기는 노란 물질로, 비포합형과 포합형을 합친 값으로 측정해요. 간이 이를 받아들여 포합한 뒤 담즙으로 내보내기 때문에, 수치는 이 경로가 얼마나 잘 작동하는지를 보여 줘요. 간 질환, 담관 폐쇄, 적혈구 파괴 증가 때 올라갈 수 있고, 길버트 증후군에서는 해롭지 않게 올라가기도 해요. 다른 간 지표와 함께 읽어요." }, "apob": "아포지단백 B (ApoB)", "lp-a": "지단백(a)", "fasting-insulin": "공복 인슐린", - "omega-3-index": "오메가3 지수" + "omega-3-index": "오메가3 지수", + "alkaline-phosphatase": "알칼리성 인산분해효소(ALP)", + "bilirubin-total": "총빌리루빈" }, "noteLoadFailed": "메모를 불러오지 못했어요. 저장해도 메모는 그대로 유지돼요.", "showingLatestOf": "측정값 {total}개 중 최근 {shown}개를 보여주고 있어요", diff --git a/messages/pl.json b/messages/pl.json index 87fa0206b..7a2f70e17 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -9604,12 +9604,16 @@ "apob": "Apolipoproteina B to białko strukturalne obecne na każdej z aterogennych cząstek lipoprotein, więc jej pomiar zlicza te cząstki bezpośrednio, a nie cholesterol, który przenoszą. Ponieważ na każdej cząstce LDL, VLDL i pokrewnej znajduje się jedna cząsteczka ApoB, może ona wychwycić ryzyko sercowo-naczyniowe, które umyka wartości cholesterolu LDL, zwłaszcza gdy liczba cząstek i ich zawartość cholesterolu się rozchodzą. Z tego powodu coraz częściej używa się jej do uściślenia oceny ryzyka opartej na lipidach. Interpretuje się ją wraz ze zwykłym profilem lipidowym.", "lp-a": "Lipoproteina(a) to cząstka podobna do LDL z dodatkowo przyłączonym białkiem, której poziom jest w dużej mierze uwarunkowany genetycznie i pozostaje dość stabilny przez całe życie. Podwyższona Lp(a) jest niezależnym, dziedzicznym czynnikiem ryzyka chorób sercowo-naczyniowych i zwężenia zastawki aortalnej. Ponieważ niewiele się zmienia, zwykle oznacza się ją raz, aby ustalić ryzyko danej osoby na całe życie, a nie śledzić wielokrotnie. Podaje się ją w jednostkach molowych i odczytuje w ramach całościowego ryzyka sercowo-naczyniowego.", "fasting-insulin": "Insulina na czczo to poziom insuliny (hormonu, który przenosi glukozę z krwi do komórek) mierzony po nocnym poście. W połączeniu z glukozą na czczo pomaga oszacować insulinooporność, czyli stan, w którym organizm potrzebuje więcej insuliny, aby utrzymać glukozę w zakresie. Wysoka insulina na czczo przy prawidłowej glukozie może być wczesnym znakiem, że ta kompensacja jest w toku. Odczytuje się ją w ramach obrazu zdrowia metabolicznego, a nie w oderwaniu.", - "omega-3-index": "Indeks omega-3 to udział EPA i DHA (dwóch długołańcuchowych morskich kwasów tłuszczowych omega-3) wśród kwasów tłuszczowych błon czerwonych krwinek. Ponieważ czerwone krwinki odnawiają się powoli, odzwierciedla on długoterminowe spożycie omega-3, a nie niedawny posiłek. Wyższy indeks wiązano w badaniach obserwacyjnych z niższym ryzykiem sercowo-naczyniowym. Śledzi się go jako stabilny marker stanu omega-3, który reaguje na dietę w ciągu miesięcy." + "omega-3-index": "Indeks omega-3 to udział EPA i DHA (dwóch długołańcuchowych morskich kwasów tłuszczowych omega-3) wśród kwasów tłuszczowych błon czerwonych krwinek. Ponieważ czerwone krwinki odnawiają się powoli, odzwierciedla on długoterminowe spożycie omega-3, a nie niedawny posiłek. Wyższy indeks wiązano w badaniach obserwacyjnych z niższym ryzykiem sercowo-naczyniowym. Śledzi się go jako stabilny marker stanu omega-3, który reaguje na dietę w ciągu miesięcy.", + "alkaline-phosphatase": "Fosfataza alkaliczna (ALP) to enzym występujący głównie w wątrobie, drogach żółciowych i kościach. Rośnie przy utrudnionym odpływie żółci oraz w okresach aktywnej przebudowy kości, na przykład podczas wzrostu u dzieci i młodzieży albo gojenia złamania. Ponieważ uwalniają ją oba narządy, podwyższony wynik czyta się zwykle razem z GGTP i pozostałymi próbami wątrobowymi, aby odróżnić przyczynę wątrobową od kostnej. Należy do standardowych prób wątrobowych.", + "bilirubin-total": "Bilirubina całkowita to żółty produkt rozpadu hemoglobiny ze zużytych czerwonych krwinek, mierzony jako suma jej postaci niesprzężonej i sprzężonej. Wątroba ją wychwytuje, sprzęga i wydala z żółcią, więc jej poziom pokazuje, jak działa ta droga. Może rosnąć przy chorobach wątroby, zablokowanych drogach żółciowych lub nasilonym rozpadzie czerwonych krwinek, a nieszkodliwie w zespole Gilberta. Odczytuje się ją razem z pozostałymi próbami wątrobowymi." }, "apob": "Apolipoproteina B (ApoB)", "lp-a": "Lipoproteina(a)", "fasting-insulin": "Insulina na czczo", - "omega-3-index": "Indeks omega-3" + "omega-3-index": "Indeks omega-3", + "alkaline-phosphatase": "Fosfataza alkaliczna (ALP)", + "bilirubin-total": "Bilirubina całkowita" }, "noteLoadFailed": "Nie udało się wczytać notatki; pozostanie niezmieniona po zapisaniu.", "showingLatestOf": "Wyświetlanie najnowszych {shown} z {total} wyników", diff --git a/src/lib/labs/__tests__/biomarker-resolve.test.ts b/src/lib/labs/__tests__/biomarker-resolve.test.ts new file mode 100644 index 000000000..6754c88b6 --- /dev/null +++ b/src/lib/labs/__tests__/biomarker-resolve.test.ts @@ -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 = {}, +) { + 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); + }); +}); diff --git a/src/lib/labs/__tests__/resolve-or-mint-biomarker.test.ts b/src/lib/labs/__tests__/resolve-or-mint-biomarker.test.ts index d6b1d3719..00bd4a313 100644 --- a/src/lib/labs/__tests__/resolve-or-mint-biomarker.test.ts +++ b/src/lib/labs/__tests__/resolve-or-mint-biomarker.test.ts @@ -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) { + 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 () => { diff --git a/src/lib/labs/analyte-match.ts b/src/lib/labs/analyte-match.ts index 2e7338ad4..02dbf3d4b 100644 --- a/src/lib/labs/analyte-match.ts +++ b/src/lib/labs/analyte-match.ts @@ -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 | null = null; +let catalogNamesCache: ReadonlyMap | 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; + ambiguous: readonly string[]; +} { + const claims = new Map>(); + const claim = (folded: string, key: string) => { + if (!folded) return; + const keys = claims.get(folded) ?? new Set(); + 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(); + 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(); + for (const [folded, k] of catalogCache) { + const list = byKey.get(k) ?? []; + list.push(folded); + byKey.set(k, list); + } + catalogNamesCache = byKey; + } + return catalogNamesCache.get(key) ?? []; +} diff --git a/src/lib/labs/biomarker-aliases.ts b/src/lib/labs/biomarker-aliases.ts new file mode 100644 index 000000000..fcb88a2b6 --- /dev/null +++ b/src/lib/labs/biomarker-aliases.ts @@ -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; +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, + userId: string, + markers: ReadonlyArray<{ id: string; name: string }>, +): Promise> { + const out = new Map(); + 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 { + 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 { + const ownFold = foldForMatch(marker.name); + const wanted = new Map(); + 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, + userId: string, + marker: { id: string; name: string }, + printedName: string, +): Promise { + 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; +} diff --git a/src/lib/labs/biomarker-catalog.ts b/src/lib/labs/biomarker-catalog.ts index 376a63b6a..1f722f255 100644 --- a/src/lib/labs/biomarker-catalog.ts +++ b/src/lib/labs/biomarker-catalog.ts @@ -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", + "포타슘", ], }, ]; diff --git a/src/lib/labs/biomarker-resolve.ts b/src/lib/labs/biomarker-resolve.ts new file mode 100644 index 000000000..e32a56bea --- /dev/null +++ b/src/lib/labs/biomarker-resolve.ts @@ -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, + userId: string, +): Promise { + 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 { + const keys = new Set(); + 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, + 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(); + 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(); + 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; +} diff --git a/src/lib/labs/biomarker-store.ts b/src/lib/labs/biomarker-store.ts index c6ca11e3a..fb1db574b 100644 --- a/src/lib/labs/biomarker-store.ts +++ b/src/lib/labs/biomarker-store.ts @@ -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 { 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, + }; +}