mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
feat(medications): extend the drug-target resolver with ATC-prefix and lab-analyte tiers
Add an additive efficacy-target resolver alongside the existing metric-only class map: a discriminated metric|lab target type, a WHO ATC class-prefix table (consuming Medication.atcCode), and the statin/thyroid/supplement lab classes the v1.22 map documented as a follow-on. Resolution order is ATC prefix then whole-word name inference, conservative-fail to no target. The existing MED_TARGET_MAP / inferMedTargetClass path stays byte-identical so the adherence-storyline safety posture is untouched.
This commit is contained in:
1 parent
45b7eb53ff
commit
f3762cf0f9
2 files changed
+281
No files matched your search
@@ -3,6 +3,8 @@ import {
|
||||
inferMedTargetClass,
|
||||
primaryTargetForClass,
|
||||
MED_TARGET_MAP,
|
||||
resolveMedicationTargets,
|
||||
targetsForEfficacyClass,
|
||||
} from "@/lib/medications/med-target-map";
|
||||
|
||||
describe("med-target-map — class inference", () => {
|
||||
@@ -56,3 +58,83 @@ describe("med-target-map — targets", () => {
|
||||
expect(MED_TARGET_MAP.glp1).toEqual(["BLOOD_GLUCOSE", "WEIGHT"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveMedicationTargets — three-tier efficacy resolution", () => {
|
||||
it("prefers the ATC class prefix over the name", () => {
|
||||
// C09 (ACE/ARB) → blood pressure, even when the name is unrecognised.
|
||||
const r = resolveMedicationTargets({
|
||||
name: "SomeUnknownBrand",
|
||||
atcCode: "C09AA05",
|
||||
});
|
||||
expect(r?.tier).toBe("atc");
|
||||
expect(r?.cls).toBe("antihypertensive");
|
||||
expect(r?.targets[0]).toEqual({
|
||||
kind: "metric",
|
||||
measurementType: "BLOOD_PRESSURE_SYS",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps the lipid-modifier ATC class to an LDL lab target", () => {
|
||||
const r = resolveMedicationTargets({ name: "x", atcCode: "C10AA05" });
|
||||
expect(r?.cls).toBe("statin");
|
||||
expect(r?.targets[0]).toEqual({
|
||||
kind: "lab",
|
||||
analyte: "LDL",
|
||||
label: "LDL cholesterol",
|
||||
});
|
||||
});
|
||||
|
||||
it("routes the GLP-1 ATC leaf to glucose+weight, not the A10 fallback", () => {
|
||||
const r = resolveMedicationTargets({ name: "x", atcCode: "A10BJ06" });
|
||||
expect(r?.cls).toBe("glp1");
|
||||
const other = resolveMedicationTargets({ name: "x", atcCode: "A10BA02" });
|
||||
expect(other?.cls).toBe("antidiabetic");
|
||||
});
|
||||
|
||||
it("maps thyroid ATC to a TSH lab target", () => {
|
||||
const r = resolveMedicationTargets({ name: "x", atcCode: "H03AA01" });
|
||||
expect(r?.cls).toBe("thyroid");
|
||||
expect(r?.targets[0].kind).toBe("lab");
|
||||
});
|
||||
|
||||
it("falls back to name inference when no valid ATC code is present", () => {
|
||||
const r = resolveMedicationTargets({ name: "Atorvastatin 20mg" });
|
||||
expect(r?.tier).toBe("name");
|
||||
expect(r?.cls).toBe("statin");
|
||||
const bp = resolveMedicationTargets({ name: "Ramipril" });
|
||||
expect(bp?.tier).toBe("name");
|
||||
expect(bp?.cls).toBe("antihypertensive");
|
||||
});
|
||||
|
||||
it("infers supplement lab targets by name", () => {
|
||||
expect(resolveMedicationTargets({ name: "Cholecalciferol" })?.cls).toBe(
|
||||
"vitamin_d",
|
||||
);
|
||||
expect(
|
||||
resolveMedicationTargets({ name: "Ferrous sulfate 200mg" })?.cls,
|
||||
).toBe("iron");
|
||||
});
|
||||
|
||||
it("ignores a malformed ATC code and falls through to the name", () => {
|
||||
const r = resolveMedicationTargets({
|
||||
name: "Ramipril",
|
||||
atcCode: "not-an-atc",
|
||||
});
|
||||
expect(r?.tier).toBe("name");
|
||||
expect(r?.cls).toBe("antihypertensive");
|
||||
});
|
||||
|
||||
it("conservative-fails to null for an unknown med with no ATC", () => {
|
||||
expect(resolveMedicationTargets({ name: "Aspirin" })).toBeNull();
|
||||
expect(resolveMedicationTargets({ name: "" })).toBeNull();
|
||||
});
|
||||
|
||||
it("exposes the ordered target list per efficacy class", () => {
|
||||
expect(targetsForEfficacyClass("antihypertensive")).toHaveLength(2);
|
||||
expect(targetsForEfficacyClass("thyroid")[0]).toEqual({
|
||||
kind: "lab",
|
||||
analyte: "TSH",
|
||||
label: "TSH",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -158,3 +158,202 @@ export function inferMedTargetClass(
|
||||
}
|
||||
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-word 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",
|
||||
];
|
||||
const IRON_NEEDLES: readonly string[] = [
|
||||
"ferrous",
|
||||
"ferric",
|
||||
"iron bisglycinate",
|
||||
];
|
||||
|
||||
/** 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 normalised = normaliseName(name);
|
||||
if (!normalised) return null;
|
||||
if (STATIN_NEEDLES.some((n) => containsWord(normalised, n))) return "statin";
|
||||
if (THYROID_NEEDLES.some((n) => containsWord(normalised, n))) return "thyroid";
|
||||
if (VITAMIN_D_NEEDLES.some((n) => containsWord(normalised, n))) {
|
||||
return "vitamin_d";
|
||||
}
|
||||
if (IRON_NEEDLES.some((n) => containsWord(normalised, n))) 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];
|
||||
}
|
||||
Reference in new issue
Block a user