Files
HealthLog/src/lib/medications/med-target-map.ts
T
Marc-André Bombeck bec4cb3dfb fix(mcp): let five of the six shipped languages name a metric
Three resolvers turned a user-authored word into an internal code by
comparing it against a hand-typed English list. The app ships six
languages, so five of them resolved nothing: `Fer` and `Żelazo` came back
as `unknown_nutrient` while the settings card rendered exactly those
words, and `poids` and `sommeil` reached no series at all.

Stop transcribing. `nutrients.names.*` and `measurements.type*` already
label these concepts in every bundle, so the match index is derived from
`locales` instead — a seventh language works the day its bundle lands,
with no edit at any call site. The nutrient tool's English labels now come
out of `messages/en.json` too, so the wire wording cannot drift from the
screen.

The MCP contract is unchanged in shape and stays protocol-level English.
What moves is which words are understood: `unknown_nutrient` /
`unknown_metric` still mean "could not place this word", distinct from a
placed name answering `no_data`. Nothing turns absence into data.

Two findings the sweep did not predict:

- `Puls` resolved to pulse-wave velocity, because the English
  display-name pass matches substrings and "pulse-wave velocity" contains
  it. An exact name now beats a loose English fragment.
- French and Italian use one phrase for body fat and fat mass, and three
  bundles use one for fat-free mass and lean body mass. A word two
  metrics claim resolves to neither; answering kilograms to someone who
  asked for a percentage is worse than saying the word was not
  recognised. The known set is pinned so a new collision surfaces.

The medication needles are the exception: they are drug names, with no
bundle to derive from. They fold to a normalised INN stem rather than to
a language, so `Metformin`, `Metformine`, `Metformina` and `Metforminum`
are one entry, and `Semaglutyd` and `Atorwastatyna` cost nothing to add.
A guard asserts no two molecules collapse onto one stem. Iron is the one
class with no stem to fold to — it is named for the element — so its word
list is hand-tabulated and says so.

Every English resolution that worked before returns the same answer.
2026-08-23 14:46:10 +02:00

513 lines
19 KiB
TypeScript

/**
* v1.22 (W9, B5) — closed medication-class → target-vital map.
*
* The adherence→symptom storyline needs to know which VITAL a medication is
* meant to move, so the Coach can say "the vital this drug targets drifted",
* not just "an outcome moved". Today the only drug-class knowledge in the tree
* is the GLP-1 catalog (`glp1-knowledge.ts`); this generalises that, minimally
* and conservatively, into a hand-curated class→`MeasurementType[]` table.
*
* Design rules (all safety-relevant):
* - The table is CLOSED. A medication whose class we cannot confidently infer
* maps to `null` — and a null class yields NO storyline. We never guess a
* target; a wrong target is a wrong medication claim.
* - Only classes with a first-class `MeasurementType` target are listed. Statin
* (LDL) and thyroid (TSH) act on LAB analytes that live in `LabResult`, not
* the measurement series the storyline reads, so they are deliberately
* OUT of scope for v1.22 (documented here, not silently dropped).
* - Class inference is whole-token, case-insensitive INN/brand matching plus
* the structured `treatmentClass` discriminator + the GLP-1 catalog. No
* fuzzy matching — a partial hit is treated as unknown.
* - Matching runs on a normalised INN STEM rather than on a language. See
* `innStem` below: `Metformin`, `Metformine`, `Metformina`, `Metforminum`
* are one molecule under four national conventions, and the needle list
* stays one entry long.
*
* The storyline that consumes this stays association-only and never advises a
* dose change; that framing is enforced in the prompt + the B0 eval cases.
*/
import type { MeasurementType } from "@/generated/prisma/client";
import { findDrugIdByBrand } from "@/lib/medications/glp1-knowledge";
import { foldLabel } from "@/lib/i18n/localised-label-index";
/** The medication classes the storyline can reason about (closed set). */
export type MedTargetClass = "antihypertensive" | "antidiabetic" | "glp1";
/**
* Class → the vital(s) the class is prescribed to move. Conservative: every
* entry is a metric stored as a first-class `MeasurementType` series, so the
* storyline can read a DAY-bucket mean for it. Statin→LDL and thyroid→TSH are
* intentionally absent (lab analytes, not measurement series — a follow-on).
*/
export const MED_TARGET_MAP: Readonly<
Record<MedTargetClass, readonly MeasurementType[]>
> = {
// Antihypertensives move blood pressure. Systolic leads the storyline
// (the number users track); diastolic is the secondary target.
antihypertensive: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"],
// Oral/injectable antidiabetics (metformin, sulfonylureas, SGLT2, DPP-4,
// insulin) move blood glucose.
antidiabetic: ["BLOOD_GLUCOSE"],
// GLP-1 / GIP agonists move glucose and weight; both are first-class series.
glp1: ["BLOOD_GLUCOSE", "WEIGHT"],
} as const;
/** The primary (storyline-leading) target for a class. */
export function primaryTargetForClass(cls: MedTargetClass): MeasurementType {
return MED_TARGET_MAP[cls][0];
}
/**
* Whole-token INN needles per class, written ONCE in their English/Latin INN
* form. Each list is conservative — common generics only — so a name we do not
* recognise stays unknown (conservative-fail) rather than being mapped to a
* plausible-but-wrong class. GLP-1 is additionally resolved through the
* structured brand catalog, not by this list.
*
* These are drug names, not app-owned labels: there is no message bundle to
* derive them from, and adding a locale to `locales` cannot supply them. What
* makes the next language work is `innStem` — the needle is matched against a
* spelling-normalised stem, so a national convention costs no entry here.
*/
const ANTIHYPERTENSIVE_NEEDLES: readonly string[] = [
// ACE inhibitors
"ramipril",
"lisinopril",
"enalapril",
"perindopril",
"captopril",
// ARBs
"losartan",
"candesartan",
"valsartan",
"irbesartan",
"telmisartan",
"olmesartan",
// calcium-channel blockers
"amlodipine",
"nifedipine",
"lercanidipine",
"felodipine",
// beta blockers (BP/HR)
"bisoprolol",
"metoprolol",
"carvedilol",
"nebivolol",
"atenolol",
// diuretics
"hydrochlorothiazide",
"indapamide",
"chlortalidone",
"furosemide",
// alpha blockers
"doxazosin",
];
const ANTIDIABETIC_NEEDLES: readonly string[] = [
"metformin",
"insulin",
"gliclazide",
"glimepiride",
"glibenclamide",
"sitagliptin",
"linagliptin",
"saxagliptin",
"empagliflozin",
"dapagliflozin",
"canagliflozin",
"pioglitazone",
"repaglinide",
];
const GLP1_INN_NEEDLES: readonly string[] = [
"semaglutide",
"tirzepatide",
"liraglutide",
"dulaglutide",
"exenatide",
];
/** Lowercase, trim — the verbatim comparison form the brand lookup wants. */
function normaliseName(name: string): string {
return name.trim().toLowerCase();
}
/**
* The spelling conventions national INN lists apply to one Latin stem, as a
* closed rewrite set. Every rule below is an orthographic equivalence, never a
* similarity heuristic: applied to BOTH the needle and the name, they make the
* national spellings of ONE molecule converge, and leave two molecules apart.
*
* ch/th/ph digraphs the Romance and Slavic lists simplify
* (chlortalidone / clortalidone, levothyroxine / lewotyroksyna)
* x → ks (doxazosin / doksazosyna)
* w → v (valsartan / walsartan, carvedilol / karwedilol)
* y → i (semaglutide / semaglutyd, ramipril / ramipryl)
* z → s (gliclazide / gliklazyd, lisinopril / lizynopryl)
* c → k (captopril / kaptopril, canagliflozin / kanagliflozyna)
* h → ∅ the leftover silent h (hydrochlorothiazide / idroclorotiazide)
*
* Order matters: the digraphs resolve before the single letters they contain,
* and the silent-h drop is last so it cannot eat a digraph's h.
*
* A structurally distinct alternative exists and is preferred where the data
* has it: `Medication.atcCode`. The WHO ATC class prefix is language-free by
* construction and `resolveMedicationTargets` consults it FIRST. This fold is
* the fallback for the (common) case of a hand-typed name with no ATC code.
*/
const INN_ORTHOGRAPHY: ReadonlyArray<readonly [RegExp, string]> = [
[/ch/g, "k"],
[/th/g, "t"],
[/ph/g, "f"],
[/x/g, "ks"],
[/w/g, "v"],
[/y/g, "i"],
[/z/g, "s"],
[/c/g, "k"],
[/h/g, ""],
];
/**
* The grammatical endings national lists attach to the Latin stem — `-um` is
* the Latin nominative, the bare vowels are the Romance and Slavic forms
* (metformina / metformine / amlodipino). Only ONE is stripped, and only when
* a substantial stem is left: shortening a five-letter word to four turns a
* closed drug list into a source of accidents.
*/
const INN_SUFFIXES: readonly string[] = ["um", "a", "e", "o"];
const INN_MIN_STEM = 6;
/** One already-folded token reduced to its INN stem. */
function innStem(token: string): string {
let stem = token;
for (const [pattern, replacement] of INN_ORTHOGRAPHY) {
stem = stem.replace(pattern, replacement);
}
for (const suffix of INN_SUFFIXES) {
if (stem.endsWith(suffix) && stem.length - suffix.length >= INN_MIN_STEM) {
return stem.slice(0, -suffix.length);
}
}
return stem;
}
/** A name or a needle as its sequence of INN stems. */
function innTokens(text: string): string[] {
return foldLabel(text)
.split("_")
.filter((token) => token !== "")
.map(innStem);
}
/** Pre-fold a needle list once — the lists are module constants. */
function stemNeedles(needles: readonly string[]): ReadonlyArray<string[]> {
return needles.map(innTokens);
}
/**
* True when `nameTokens` contains `needleTokens` as a contiguous run of WHOLE
* tokens. A needle that merely appears inside a longer token is not a hit —
* the conservative-fail rule, unchanged: a German compound such as
* "Metforminhydrochlorid" stays unknown rather than being guessed at.
*/
function containsTokens(
nameTokens: readonly string[],
needleTokens: readonly string[],
): boolean {
if (needleTokens.length === 0) return false;
for (let i = 0; i + needleTokens.length <= nameTokens.length; i++) {
let matched = true;
for (let j = 0; j < needleTokens.length; j++) {
if (nameTokens[i + j] !== needleTokens[j]) {
matched = false;
break;
}
}
if (matched) return true;
}
return false;
}
/** True when any needle in the pre-stemmed list matches the name's tokens. */
function matchesAny(
nameTokens: readonly string[],
needles: ReadonlyArray<readonly string[]>,
): boolean {
return needles.some((needle) => containsTokens(nameTokens, needle));
}
const ANTIHYPERTENSIVE_STEMS = stemNeedles(ANTIHYPERTENSIVE_NEEDLES);
const ANTIDIABETIC_STEMS = stemNeedles(ANTIDIABETIC_NEEDLES);
const GLP1_INN_STEMS = stemNeedles(GLP1_INN_NEEDLES);
/**
* Infer the target-class of a medication from its structured discriminator,
* the GLP-1 catalog, and a whole-token INN-stem name match — in that priority
* order.
* Returns `null` when the class is not confidently known: the caller then
* surfaces NO storyline for that medication (conservative-fail, never a guess).
*
* `treatmentClass` is the `MedicationCategory` enum on `Medication`
* (`GENERIC` | `GLP1`). Passing it is optional so a name-only caller still
* works.
*/
export function inferMedTargetClass(
name: string,
treatmentClass?: string | null,
): MedTargetClass | null {
if (treatmentClass === "GLP1") return "glp1";
const normalised = normaliseName(name);
if (!normalised) return null;
// The structured GLP-1 catalog is authoritative for its brands/INNs. Brands
// are trade names, not INNs — matched verbatim, never through the stem fold.
if (findDrugIdByBrand(normalised) !== null) return "glp1";
const tokens = innTokens(name);
if (matchesAny(tokens, GLP1_INN_STEMS)) return "glp1";
if (matchesAny(tokens, ANTIDIABETIC_STEMS)) return "antidiabetic";
if (matchesAny(tokens, ANTIHYPERTENSIVE_STEMS)) return "antihypertensive";
return null;
}
// ─── v1.28 efficacy resolver (extends the closed map above) ───────────
//
// The efficacy view ("Wirkung" tab + the Insights summary) needs a target
// resolver that (a) can point at LAB analytes (statin→LDL, thyroid→TSH,
// supplements→their marker) — the documented follow-on the v1.22 map left
// out — and (b) consults the WHO ATC class prefix on `Medication.atcCode`
// before falling back to name inference. It is ADDITIVE: the metric-only
// `MED_TARGET_MAP` / `inferMedTargetClass` / `primaryTargetForClass` above
// stay byte-identical so the adherence-storyline safety path is untouched.
//
// Same discipline as the map above: closed tables, whole-word / fixed-prefix
// matching, conservative-fail to an EMPTY target list (never a guess). The
// clinical association ("this class is prescribed to move this outcome") is
// documented, guideline-cited and vendor-blind in the external knowledge base
// (`medications/drug-class-targets.md`); this code is its mechanical mirror.
// No efficacy claim is encoded here — only what a class is prescribed to move.
/**
* A resolved target: either a first-class measurement series or a lab analyte.
* The lab variant carries a `contains`-match needle (matched against a
* `LabResult.analyte` or the linked `Biomarker.name`, exactly as
* `getLabHistory` matches) plus a human label for the DTO / UI.
*/
export type MedTarget =
| { kind: "metric"; measurementType: MeasurementType }
| { kind: "lab"; analyte: string; label: string };
/**
* The wider class set the efficacy resolver reasons about: the three
* metric classes above plus the lab-analyte classes (statin→LDL,
* thyroid→TSH) and the two supplement markers named in the plan.
*/
export type EfficacyMedClass =
MedTargetClass | "statin" | "thyroid" | "vitamin_d" | "iron";
/** Class → its ordered target list (primary first). Closed + conservative. */
const EFFICACY_TARGETS: Readonly<
Record<EfficacyMedClass, readonly MedTarget[]>
> = {
antihypertensive: [
{ kind: "metric", measurementType: "BLOOD_PRESSURE_SYS" },
{ kind: "metric", measurementType: "BLOOD_PRESSURE_DIA" },
],
antidiabetic: [{ kind: "metric", measurementType: "BLOOD_GLUCOSE" }],
glp1: [
{ kind: "metric", measurementType: "WEIGHT" },
{ kind: "metric", measurementType: "BLOOD_GLUCOSE" },
],
// Lipid-modifiers (statins et al.) are monitored on LDL — a lab analyte.
statin: [{ kind: "lab", analyte: "LDL", label: "LDL cholesterol" }],
// Thyroid therapy is titrated against TSH — a lab analyte.
thyroid: [{ kind: "lab", analyte: "TSH", label: "TSH" }],
// Supplements are tracked against their own marker.
vitamin_d: [
{ kind: "lab", analyte: "Vitamin D", label: "Vitamin D (25-OH)" },
],
iron: [{ kind: "lab", analyte: "Ferritin", label: "Ferritin" }],
} as const;
/**
* WHO ATC class-prefix → efficacy class. Prefix-level ONLY (the class the
* substance belongs to), never a specific-product claim. Longer prefixes are
* tested first so `A10BJ` (GLP-1/GIP agonists) wins over the `A10` fallback.
* The prefix is upper-cased + validated against the `atcCode` shape before use.
*/
const ATC_PREFIX_CLASS: ReadonlyArray<readonly [string, EfficacyMedClass]> = [
// Antidiabetics: GLP-1 / GIP agonists move glucose AND weight; the rest of
// A10 (metformin, sulfonylureas, SGLT2, DPP-4, insulin) move glucose.
["A10BJ", "glp1"],
["A10", "antidiabetic"],
// Cardiovascular: antihypertensives (C02 other, C03 diuretics, C07 beta
// blockers, C08 CCB, C09 ACE/ARB) → blood pressure.
["C02", "antihypertensive"],
["C03", "antihypertensive"],
["C07", "antihypertensive"],
["C08", "antihypertensive"],
["C09", "antihypertensive"],
// Lipid-modifying agents → LDL.
["C10", "statin"],
// Thyroid therapy → TSH.
["H03A", "thyroid"],
// Vitamin D / analogues (A11CC) and vitamin-D-only combos (A11CB).
["A11CC", "vitamin_d"],
["A11CB", "vitamin_d"],
// Iron preparations (oral B03AA/AB/AD/AE, parenteral B03AC).
["B03A", "iron"],
];
const ATC_CODE_RE = /^[A-Z]\d{2}[A-Z]{2}\d{2}$/;
/** Whole-token name needles for the lab classes (metric classes stay above). */
const STATIN_NEEDLES: readonly string[] = [
"atorvastatin",
"simvastatin",
"rosuvastatin",
"pravastatin",
"fluvastatin",
"lovastatin",
"pitavastatin",
"ezetimibe",
];
const THYROID_NEEDLES: readonly string[] = [
"levothyroxine",
"liothyronine",
"thyroxine",
"euthyrox",
];
const VITAMIN_D_NEEDLES: readonly string[] = [
"cholecalciferol",
"colecalciferol",
"ergocalciferol",
"calcifediol",
"calcitriol",
];
/**
* Iron is the one class the INN fold cannot carry, because iron preparations
* are named for the ELEMENT rather than by an INN: there is no Latin stem for
* "Eisen" and "Żelazo" to converge on. So this list is hand-tabulated, and a
* seventh language needs a line added here — the honest exception to the rule
* the other lists follow. `Medication.atcCode` B03A remains the language-free
* path and is consulted first.
*
* Known limit: the whole-token rule means a German compound ("Eisensulfat")
* is not a hit, the same way "Metforminhydrochlorid" is not.
*/
const IRON_NEEDLES: readonly string[] = [
// Salt adjectives (the Latin/English/Romance forms).
"ferrous",
"ferroso",
"ferreux",
"ferric",
"bisglycinate",
// The element, in each shipped language.
"iron",
"eisen",
"fer",
"hierro",
"ferro",
"żelazo",
];
const STATIN_STEMS = stemNeedles(STATIN_NEEDLES);
const THYROID_STEMS = stemNeedles(THYROID_NEEDLES);
const VITAMIN_D_STEMS = stemNeedles(VITAMIN_D_NEEDLES);
const IRON_STEMS = stemNeedles(IRON_NEEDLES);
/**
* The whole stem vocabulary, per class — the fold's own audit surface.
*
* A rewrite rule that made two different molecules collapse onto one stem
* would map a real drug to the wrong class, silently, and a wrong target is a
* wrong medication claim. So the vocabulary is exported and a guard asserts
* that no stem is claimed by two classes and that none is short enough to
* collide with an ordinary word.
*/
export const MED_NEEDLE_STEMS: Readonly<
Record<EfficacyMedClass, readonly string[]>
> = {
antihypertensive: ANTIHYPERTENSIVE_STEMS.map((t) => t.join("_")),
antidiabetic: ANTIDIABETIC_STEMS.map((t) => t.join("_")),
glp1: GLP1_INN_STEMS.map((t) => t.join("_")),
statin: STATIN_STEMS.map((t) => t.join("_")),
thyroid: THYROID_STEMS.map((t) => t.join("_")),
vitamin_d: VITAMIN_D_STEMS.map((t) => t.join("_")),
iron: IRON_STEMS.map((t) => t.join("_")),
};
/** Resolve a med's efficacy class from its ATC prefix, or `null`. */
function classFromAtc(
atcCode: string | null | undefined,
): EfficacyMedClass | null {
if (!atcCode) return null;
const code = atcCode.trim().toUpperCase();
if (!ATC_CODE_RE.test(code)) return null;
for (const [prefix, cls] of ATC_PREFIX_CLASS) {
if (code.startsWith(prefix)) return cls;
}
return null;
}
/** Resolve a med's efficacy class from its name (metric + lab needles). */
function classFromName(
name: string,
treatmentClass?: string | null,
): EfficacyMedClass | null {
// Metric classes stay authoritative through the existing inferer.
const metricClass = inferMedTargetClass(name, treatmentClass);
if (metricClass) return metricClass;
const tokens = innTokens(name);
if (tokens.length === 0) return null;
if (matchesAny(tokens, STATIN_STEMS)) return "statin";
if (matchesAny(tokens, THYROID_STEMS)) return "thyroid";
if (matchesAny(tokens, VITAMIN_D_STEMS)) return "vitamin_d";
if (matchesAny(tokens, IRON_STEMS)) return "iron";
return null;
}
/** The tier a target list was resolved through (provenance for the DTO/UI). */
export type MedTargetTier = "atc" | "name";
export interface ResolvedMedTargets {
cls: EfficacyMedClass;
tier: MedTargetTier;
targets: readonly MedTarget[];
}
/**
* Resolve the derived (non-override) efficacy targets for a medication:
* ATC class-prefix FIRST (the guideline-native class key), then whole-word
* name inference. Returns `null` when no class is confidently known — the
* caller then falls back to the user's own explicit pick (tier 1, persisted)
* or the "track this against…" chooser. Never guesses a target.
*
* Tier 1 (user override) is NOT resolved here — it is persisted and applied by
* the server efficacy builder, which layers it on top of this derived result.
*/
export function resolveMedicationTargets(med: {
name: string;
treatmentClass?: string | null;
atcCode?: string | null;
}): ResolvedMedTargets | null {
const viaAtc = classFromAtc(med.atcCode);
if (viaAtc) {
return { cls: viaAtc, tier: "atc", targets: EFFICACY_TARGETS[viaAtc] };
}
const viaName = classFromName(med.name, med.treatmentClass);
if (viaName) {
return { cls: viaName, tier: "name", targets: EFFICACY_TARGETS[viaName] };
}
return null;
}
/** The ordered target list for an efficacy class (primary first). */
export function targetsForEfficacyClass(
cls: EfficacyMedClass,
): readonly MedTarget[] {
return EFFICACY_TARGETS[cls];
}