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.
This commit is contained in:
Marc-André Bombeck committed 2026-08-23 14:46:10 +02:00
1 parent b76468f455
commit bec4cb3dfb
10 files changed
+1026 -88

No files matched your search

+29
View File
@@ -99,6 +99,35 @@ Every read tool is annotated read-only / non-destructive / idempotent /
closed-world — the read-only guarantee is structural (only read tools are
registered for a read session), and the annotation merely advertises it.
### Naming a metric or a nutrient
The tools that take a free-text name — `compare_metric`,
`get_metric_baseline`, `detect_changepoints`, `get_nutrients` — accept the
metric's or nutrient's own display name in **any language HealthLog ships**
(de / en / es / fr / it / pl), alongside the internal code and the English
aliases. `poids`, `Blutzucker`, `Fer` and `Żelazo` reach the same series as
`weight`, `blood_glucose` and `iron`. Case, accents and punctuation are
folded, so `magnésium` and `MAGNESIUM` are one word.
The accepted names are read out of the app's own message bundles rather
than listed in the resolver, so a language works as soon as its bundle
ships. Two consequences worth relying on:
- **The vocabulary is still closed.** A word in no bundle is a miss, not a
guess: `{ present: false, reason: "unknown_metric" }` or
`"unknown_nutrient"`. That reason is distinct from `"no_data"`, which
means the name WAS understood and the record honestly holds nothing.
Widening the accepted spellings moves words from the first answer to the
second; it never turns absence into data.
- **An ambiguous word is refused, not resolved.** Where a language uses one
phrase for two different measurements — French and Italian call both body
fat and fat mass `masse grasse` / `massa grassa`, and French, Italian and
Polish use one phrase for fat-free mass and lean body mass — the word
resolves to neither. Name those in English, or by their metric key.
Responses are unchanged: labels and units come back in protocol-level
English whatever language the request used.
### Clinical signals
The v1.25 clinical-signals set — **grip strength**, **pain (0–10 NRS)**,
@@ -0,0 +1,111 @@
import { describe, it, expect } from "vitest";
import { locales } from "@/lib/i18n/config";
import {
buildLocalisedLabelIndex,
foldLabel,
labelIn,
labelsInEveryLocale,
} from "@/lib/i18n/localised-label-index";
describe("foldLabel", () => {
it("lower-cases and collapses every separator run to one underscore", () => {
expect(foldLabel("Pantothenic acid (B5)")).toBe("pantothenic_acid_b5");
expect(foldLabel("waist-to-height ratio")).toBe("waist_to_height_ratio");
expect(foldLabel(" Grip strength ")).toBe("grip_strength");
});
it("strips diacritics so an accented word matches its plain spelling", () => {
expect(foldLabel("Magnésium")).toBe(foldLabel("magnesium"));
expect(foldLabel("Żelazo")).toBe("zelazo");
expect(foldLabel("Sélénium")).toBe("selenium");
expect(foldLabel("Wapń")).toBe("wapn");
});
it("folds the stroked letters Unicode decomposition leaves whole", () => {
// "ż" decomposes to z + a combining dot; "ł" does not decompose at all,
// so Polish folds only half way without the explicit map.
expect(foldLabel("Siła chwytu")).toBe("sila_chwytu");
expect(foldLabel("Masa beztłuszczowa")).toBe("masa_beztluszczowa");
expect(foldLabel("Größe")).toBe("grosse");
expect(foldLabel("Grösse")).toBe("grosse");
});
it("compatibility-normalises so a subscript agrees with its digit", () => {
expect(foldLabel("VO₂ max")).toBe("vo2_max");
});
it("folds an en-dash range the same as a hyphen one", () => {
expect(foldLabel("Pain (0–10)")).toBe(foldLabel("pain 0-10"));
});
it("folds to the empty string when nothing alphanumeric is left", () => {
expect(foldLabel(" ")).toBe("");
expect(foldLabel("—")).toBe("");
});
});
describe("labelIn / labelsInEveryLocale", () => {
it("reads a real key out of a named bundle", () => {
expect(labelIn("fr", "nutrients.names.iron")).toBe("Fer");
expect(labelIn("pl", "nutrients.names.iron")).toBe("Żelazo");
});
it("returns null for a key no bundle carries", () => {
expect(labelIn("en", "nutrients.names.unobtanium")).toBeNull();
});
it("yields one rendering per shipped locale", () => {
const labels = labelsInEveryLocale("nutrients.names.iron");
expect(labels).toHaveLength(locales.length);
expect(labels).toContain("Iron");
expect(labels).toContain("Fer");
});
it("yields nothing at all for an absent key", () => {
expect(labelsInEveryLocale("nutrients.names.unobtanium")).toEqual([]);
});
});
describe("buildLocalisedLabelIndex", () => {
it("indexes a concept under its name in EVERY shipped locale", () => {
const { index, ambiguous } = buildLocalisedLabelIndex([
{ id: "iron", messageKey: "nutrients.names.iron", value: "iron" },
{ id: "water", messageKey: "nutrients.names.water", value: "water" },
]);
expect(ambiguous.size).toBe(0);
// One entry per locale — the property that makes a new locale work with
// no edit here: `locales` is walked, not a hand-typed language list.
for (const locale of locales) {
const iron = labelIn(locale, "nutrients.names.iron");
expect(iron).not.toBeNull();
expect(index.get(foldLabel(iron!))).toBe("iron");
}
});
it("drops a fold two different concepts claim rather than guessing", () => {
const { index, ambiguous } = buildLocalisedLabelIndex([
{ id: "a", messageKey: "nutrients.names.iron", value: "a" },
{ id: "b", messageKey: "nutrients.names.iron", value: "b" },
]);
expect(ambiguous).toContain("fer");
expect(index.get("fer")).toBeUndefined();
});
it("treats a repeated id as one concept, not an ambiguity", () => {
const { index, ambiguous } = buildLocalisedLabelIndex([
{ id: "iron", messageKey: "nutrients.names.iron", value: "iron" },
{ id: "iron", messageKey: "nutrients.names.iron", value: "iron" },
]);
expect(ambiguous.size).toBe(0);
expect(index.get("fer")).toBe("iron");
});
it("degrades to the locales that carry the key instead of failing", () => {
const { index } = buildLocalisedLabelIndex([
{ id: "x", messageKey: "nutrients.names.unobtanium", value: "x" },
{ id: "iron", messageKey: "nutrients.names.iron", value: "iron" },
]);
expect(index.get("fer")).toBe("iron");
});
});
+139
View File
@@ -0,0 +1,139 @@
/**
* Match indexes derived from the shipped message bundles.
*
* The recurring defect this closes: a resolver that has to turn a
* user-authored word ("Fer", "Żelazo", "Sommeil") into an internal code
* transcribes the ENGLISH display names into a hand-typed table in the
* source. Five of the six shipped locales then resolve to nothing, even
* though their translations are already on disk in `messages/<locale>.json`
* and are already what the app renders for the same concept.
*
* The fix is to stop transcribing. A caller names the i18n key that already
* labels the concept; this module reads that key out of EVERY entry in
* `locales` and builds the folded-name → value index. Adding a locale to
* `locales` and shipping its bundle is then the whole change — no edit here,
* and no edit at any call site.
*
* SERVER-ONLY, like `shared-resolve` (whose `allMessages` it reads): pulling
* this into a client chunk would drag all six catalogs back in. Both current
* consumers are MCP reads, which are server-side by construction.
*
* Deliberately NOT a translation layer for OUTPUT. The MCP surface answers in
* protocol-level English on purpose (see `nutrients-read.ts` and the
* `metric-status-registry`'s `displayName` comment); this module only widens
* what the resolver ACCEPTS.
*/
import { locales, type Locale } from "./config";
import { allMessages, resolveKey } from "./shared-resolve";
/**
* Letters Unicode decomposition leaves alone because their mark is part of
* the glyph rather than a combining character — a stroke, or a ligature. NFKD
* turns "ż" into "z" + a dot but leaves "ł" whole, so Polish would fold half
* way without this. Expressed as ASCII equivalents, the same target the
* combining-mark strip below produces.
*/
const UNDECOMPOSED_LETTERS: ReadonlyArray<readonly [RegExp, string]> = [
[/ł/g, "l"],
[/ø/g, "o"],
[/đ/g, "d"],
[/ß/g, "ss"],
[/æ/g, "ae"],
[/œ/g, "oe"],
];
/**
* Fold a label or a free-text term to its match form: compatibility-normalised
* (so "VO₂" and "VO2" agree), diacritics stripped (so "Glycémie" answers to
* "glycemie" and "Siła" to "sila"), lower-cased, and every run of
* non-alphanumerics collapsed to a single `_` with the ends trimmed (so
* "Pain (0–10)" and "pain 0 10" agree).
*
* Applied to BOTH sides of every comparison — a bundle label and the caller's
* word go through the same function, so the index cannot drift from the lookup.
*/
export function foldLabel(raw: string): string {
let folded = raw
.normalize("NFKD")
.replace(/\p{Diacritic}/gu, "")
.toLowerCase();
for (const [pattern, ascii] of UNDECOMPOSED_LETTERS) {
folded = folded.replace(pattern, ascii);
}
return folded.replace(/[^\p{L}\p{N}]+/gu, "_").replace(/^_+|_+$/gu, "");
}
/** One locale's rendering of `key`, or `null` when that bundle omits it. */
export function labelIn(locale: Locale, key: string): string | null {
const value = resolveKey(allMessages[locale], key);
return typeof value === "string" && value.trim() !== "" ? value : null;
}
/** Every shipped locale's rendering of `key`, skipping bundles that omit it. */
export function labelsInEveryLocale(key: string): string[] {
const out: string[] = [];
for (const locale of locales) {
const label = labelIn(locale, key);
if (label !== null) out.push(label);
}
return out;
}
/** One concept: a stable identity, the i18n key that labels it, its payload. */
export interface LocalisedLabelEntry<T> {
/**
* Stable identity of the concept. Two entries sharing an id are the same
* thing (a label they both fold to is not an ambiguity); two entries with
* DIFFERENT ids that fold to one string are, and the string is dropped.
*/
id: string;
/** The i18n key whose per-locale renderings become accepted spellings. */
messageKey: string;
/** What a match resolves to. */
value: T;
}
export interface LocalisedLabelIndex<T> {
/** Folded label → value. Ambiguous folds are absent, never guessed. */
index: ReadonlyMap<string, T>;
/**
* Folds two different ids both claimed. Dropped from `index` rather than
* resolved to whichever entry happened to be built first — a resolver that
* guesses between two concepts is worse than one that answers "not
* recognised". Consumers assert this is empty.
*/
ambiguous: ReadonlySet<string>;
}
/**
* Build the folded-label → value index across every shipped locale.
*
* A bundle that omits an entry's key simply contributes nothing for that
* locale — a partially translated bundle degrades to the locales that do
* carry the key instead of failing the module load.
*/
export function buildLocalisedLabelIndex<T>(
entries: readonly LocalisedLabelEntry<T>[],
): LocalisedLabelIndex<T> {
const index = new Map<string, T>();
const owner = new Map<string, string>();
const ambiguous = new Set<string>();
for (const entry of entries) {
for (const label of labelsInEveryLocale(entry.messageKey)) {
const folded = foldLabel(label);
if (folded === "") continue;
const claimed = owner.get(folded);
if (claimed === undefined) {
owner.set(folded, entry.id);
index.set(folded, entry.value);
continue;
}
if (claimed === entry.id) continue;
ambiguous.add(folded);
index.delete(folded);
}
}
return { index, ambiguous };
}
+84 -1
View File
@@ -7,12 +7,19 @@ const { nutrientIntakeDay, user } = vi.hoisted(() => ({
}));
vi.mock("@/lib/db", () => ({ prisma: { nutrientIntakeDay, user } }));
import { getNutrients, resolveNutrientCode } from "../nutrients-read";
import {
getNutrients,
resolveNutrientCode,
NUTRIENT_LABELS,
} from "../nutrients-read";
import type {
NutrientsOverviewResult,
NutrientsDailyResult,
} from "../nutrients-read";
import { isModuleEnabled } from "@/lib/modules/gate";
import { locales } from "@/lib/i18n/config";
import { labelIn } from "@/lib/i18n/localised-label-index";
import { NUTRIENT_CODES } from "@/lib/nutrients/catalog";
/** Narrow the union to the overview shape for a test's own assertions. */
function overview(
@@ -60,6 +67,82 @@ describe("resolveNutrientCode", () => {
});
});
describe("resolveNutrientCode — every shipped locale, not just English", () => {
// `nutrients.names.*` already ships in all six bundles and is what the
// settings card renders. The resolver used to compare against a hand-typed
// English copy of that bundle, so an account whose language is French or
// Polish got `unknown_nutrient` for the word its own screen shows.
it.each([
["de", "Eisen", "iron"],
["es", "Hierro", "iron"],
["fr", "Fer", "iron"],
["it", "Ferro", "iron"],
["pl", "Żelazo", "iron"],
["de", "Wasser", "water"],
["es", "Agua", "water"],
["fr", "Eau", "water"],
["it", "Acqua", "water"],
["pl", "Woda", "water"],
["fr", "Magnésium", "magnesium"],
["pl", "Wapń", "calcium"],
["de", "Koffein", "caffeine"],
["it", "Iodio", "iodine"],
["pl", "Kwas pantotenowy (B5)", "pantothenic_acid"],
["fr", "Acide pantothénique (B5)", "pantothenic_acid"],
] as const)("resolves the %s name %s", (_locale, name, code) => {
expect(resolveNutrientCode(name)).toBe(code);
});
it("folds case, accents and separators on the localised names too", () => {
expect(resolveNutrientCode(" magnésium ")).toBe("magnesium");
expect(resolveNutrientCode("MAGNESIO")).toBe("magnesium");
expect(resolveNutrientCode("zelazo")).toBe("iron");
});
it("keeps every English spelling that resolved before resolving the same", () => {
// The pre-fix English arms: exact code, space/hyphen folding, and a
// display-label match. None of them may move.
expect(resolveNutrientCode("water")).toBe("water");
expect(resolveNutrientCode("vitamin_d")).toBe("vitamin_d");
expect(resolveNutrientCode("Vitamin D")).toBe("vitamin_d");
expect(resolveNutrientCode("vitamin-d")).toBe("vitamin_d");
expect(resolveNutrientCode("MAGNESIUM")).toBe("magnesium");
expect(resolveNutrientCode("Vitamin B12")).toBe("vitamin_b12");
expect(resolveNutrientCode("Pantothenic acid B5")).toBe("pantothenic_acid");
expect(resolveNutrientCode("Caffeine")).toBe("caffeine");
});
it("still refuses a word that is in no bundle — a parse miss stays a miss", () => {
expect(resolveNutrientCode("bananas")).toBeNull();
expect(resolveNutrientCode("Bananen")).toBeNull();
expect(resolveNutrientCode("protein")).toBeNull();
});
// The property, not a sample: EVERY code's name in EVERY shipped locale
// round-trips. A seventh locale added to `locales` is covered by this test
// the moment its bundle lands — which is the point of deriving the index
// instead of transcribing it.
it("round-trips every catalog code from every shipped locale's bundle", () => {
for (const locale of locales) {
for (const code of NUTRIENT_CODES) {
const label = labelIn(locale, `nutrients.names.${code}`);
expect(label, `${locale} is missing nutrients.names.${code}`).not.toBe(
null,
);
expect(resolveNutrientCode(label!), `${locale}/${code}`).toBe(code);
}
}
});
it("exposes the same English label the bundle carries, not a copy of it", () => {
for (const code of NUTRIENT_CODES) {
expect(NUTRIENT_LABELS[code]).toBe(
labelIn("en", `nutrients.names.${code}`),
);
}
});
});
describe("getNutrients — module gate", () => {
it("returns { present: false, reason: module_disabled } when the opt-in module is off", async () => {
vi.mocked(isModuleEnabled).mockResolvedValue(false);
+167
View File
@@ -37,8 +37,12 @@ import {
getLabHistory,
resolveRichMetric,
MCP_METRIC_STATUS_DISCOVERY,
LOCALISED_METRIC_NAME_COLLISIONS,
metricStatusDiscoveryRows,
} from "../rich-reads";
import { locales } from "@/lib/i18n/config";
import { labelIn } from "@/lib/i18n/localised-label-index";
import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/lib/measurements/type-label-keys";
import { readCoachCorrelations } from "@/lib/ai/coach/tools/correlations-read";
import { buildCoachReadStrip } from "@/lib/insights/derived/coach-read";
import { readBestGranularityRollups } from "@/lib/rollups/measurement-read-wmy";
@@ -178,6 +182,169 @@ describe("resolveRichMetric", () => {
});
});
// ── localised metric names ───────────────────────────────────────────
//
// The alias tables are app-owned slugs (`resting_hr`, `spo2`, `vo2max`) and
// stay English by design — they are protocol vocabulary, not language. What
// was missing is the metric's NAME: the resolver only ever compared against
// English display names, so five of the six shipped languages could not name
// a metric at all. The names now come from the same `measurements.type*` keys
// the app renders, read out of every bundle.
describe("resolveRichMetric — localised metric names", () => {
it.each([
["fr", "poids", "WEIGHT"],
["de", "Gewicht", "WEIGHT"],
["es", "Peso", "WEIGHT"],
["pl", "Waga", "WEIGHT"],
["fr", "Sommeil", "SLEEP_DURATION"],
["de", "Schlaf", "SLEEP_DURATION"],
["it", "Sonno", "SLEEP_DURATION"],
["pl", "Sen", "SLEEP_DURATION"],
["fr", "Pas", "ACTIVITY_STEPS"],
["de", "Schritte", "ACTIVITY_STEPS"],
["fr", "Glycémie", "BLOOD_GLUCOSE"],
["de", "Blutzucker", "BLOOD_GLUCOSE"],
["fr", "Fréquence cardiaque au repos", "RESTING_HEART_RATE"],
["de", "Ruheherzfrequenz", "RESTING_HEART_RATE"],
["fr", "Indice de masse corporelle", "BODY_MASS_INDEX"],
["pl", "Tętno", "PULSE"],
] as const)("resolves the %s name %s", (_locale, name, type) => {
expect(resolveRichMetric(name)?.measurementType).toBe(type);
});
it("reaches the MCP-opt-in clinical signals in every language too", () => {
expect(resolveRichMetric("Griffstärke")?.measurementType).toBe(
"GRIP_STRENGTH",
);
expect(resolveRichMetric("Force de préhension")?.measurementType).toBe(
"GRIP_STRENGTH",
);
expect(resolveRichMetric("Obwód talii")?.measurementType).toBe(
"WAIST_CIRCUMFERENCE",
);
// Accents and the (0–10) suffix fold on both sides.
expect(resolveRichMetric("Ból (0-10)")?.measurementType).toBe("PAIN_NRS");
});
it("keeps the English resolutions that already worked byte-identical", () => {
expect(resolveRichMetric("resting_hr")?.measurementType).toBe(
"RESTING_HEART_RATE",
);
expect(resolveRichMetric("weight")?.measurementType).toBe("WEIGHT");
expect(resolveRichMetric("pulse")?.measurementType).toBe("PULSE");
expect(resolveRichMetric("heart-rate variability")?.measurementType).toBe(
"HEART_RATE_VARIABILITY",
);
expect(resolveRichMetric("grip strength")?.measurementType).toBe(
"GRIP_STRENGTH",
);
expect(resolveRichMetric("vo2_max")?.measurementType).toBe("VO2_MAX");
});
it("does not widen exposure — a vetoed metric stays unreachable in every language", () => {
// The localised index is built from the metrics the English resolver can
// ALREADY produce, so the `mcp:false` veto and the screener/environmental
// exclusions carry over unchanged. A name-based bypass here would be a
// leak on the one surface that egresses to a third-party assistant.
for (const off of [
"PHQ-9-Wert",
"Puntuación PHQ-9",
"Score PHQ-9",
"GAD-7-Wert",
"Wynik GAD-7",
"Maximaler Puls",
"Fréquence cardiaque maximale",
"Frequenza cardiaca massima",
]) {
expect(resolveRichMetric(off)).toBeNull();
}
});
it("still refuses a word that is in no bundle", () => {
expect(resolveRichMetric("bananes")).toBeNull();
expect(resolveRichMetric("Bananen")).toBeNull();
expect(resolveRichMetric("")).toBeNull();
});
it("resolves a short name to the metric it names, not to an English word containing it", () => {
// "pulse-wave velocity" contains "puls", so the substring pass used to
// hand German and Polish speakers a different metric than they asked for.
expect(resolveRichMetric("Puls")?.measurementType).toBe("PULSE");
expect(resolveRichMetric("Tętno")?.measurementType).toBe("PULSE");
// The English fragment still works where nothing names it exactly.
expect(resolveRichMetric("pulse-wave")?.measurementType).toBe(
"PULSE_WAVE_VELOCITY",
);
});
// Body fat, fat mass, fat-free mass and lean body mass are four distinct
// measurements that the Romance and Polish bundles collapse into two words:
// "masse grasse" / "massa grassa" cover the first pair, "masse maigre" /
// "massa magra" / "masa beztłuszczowa" the second. The index drops a word
// two metrics claim rather than picking one — answering kilograms to
// someone who asked for a percentage is worse than saying the word was not
// recognised. Pinned, so a NEW collision fails here instead of silently
// resolving to whichever entry happened to be built first.
it("refuses a word two different metrics claim, and only those words", () => {
expect([...LOCALISED_METRIC_NAME_COLLISIONS].sort()).toEqual([
"masa_beztluszczowa",
"massa_grassa",
"massa_magra",
"masse_grasse",
"masse_maigre",
]);
});
// The property, not a sample: over every metric and every shipped locale,
// so a seventh language is covered the day its bundle lands.
it("never lets a non-English name point at a different metric", () => {
const misdirected: string[] = [];
for (const [type, key] of Object.entries(MEASUREMENT_TYPE_LABEL_KEYS)) {
for (const locale of locales) {
const label = labelIn(locale, key);
expect(label, `${locale} is missing ${key}`).not.toBe(null);
const answer = resolveRichMetric(label!)?.measurementType ?? null;
if (answer !== null && answer !== type) {
misdirected.push(`${locale}/${type} -> ${answer}`);
}
}
}
// Every remaining entry is `en`, which is the point: the derived index
// never misdirects. These three come from the English substring pass —
// an event type whose label is contained in the running metric's display
// name — which predates this index and is deliberately left alone so no
// English resolution moves.
expect(misdirected.sort()).toEqual([
"en/BODY_TEMPERATURE_DEVIATION -> BODY_TEMPERATURE",
"en/BREATHING_DISTURBANCE_EVENT -> BREATHING_DISTURBANCES",
"en/WALKING_STEADINESS_EVENT -> WALKING_STEADINESS",
]);
});
it("makes a metric nameable in every language or in none", () => {
const partial: string[] = [];
for (const [type, key] of Object.entries(MEASUREMENT_TYPE_LABEL_KEYS)) {
const resolved = locales.filter(
(locale) => resolveRichMetric(labelIn(locale, key)!) !== null,
);
if (resolved.length !== 0 && resolved.length !== locales.length) {
partial.push(`${type}: ${resolved.join(",")}`);
}
}
// The body-composition types whose bundles are genuinely ambiguous (see
// the collision guard above), plus the same three English-substring
// artefacts.
expect(partial.sort()).toEqual([
"BODY_TEMPERATURE_DEVIATION: en",
"BREATHING_DISTURBANCE_EVENT: en",
"FAT_FREE_MASS: de,en,es",
"FAT_MASS: de,en,es,pl",
"LEAN_BODY_MASS: de,en,es",
"WALKING_STEADINESS_EVENT: en",
]);
});
});
// ── 1. get_correlation ───────────────────────────────────────────────
describe("get_correlation", () => {
const drivers = [
+64 -46
View File
@@ -29,44 +29,62 @@ import {
type NutrientCode,
type ResolvedNutrientReference,
} from "@/lib/nutrients/catalog";
import { defaultLocale } from "@/lib/i18n/config";
import {
buildLocalisedLabelIndex,
foldLabel,
labelIn,
} from "@/lib/i18n/localised-label-index";
import { DEFAULT_TIMEZONE, shiftDateKey, userDayKey } from "@/lib/tz/format";
/** The i18n key the app already labels a nutrient with, in every bundle. */
function nutrientLabelKey(code: NutrientCode): string {
return `nutrients.names.${code}`;
}
/**
* English display labels for the closed nutrient catalog. MCP text is
* protocol-level, not rendered to a localised UI (see `MCP_SERVER_INSTRUCTIONS`'s
* own docblock) — hardcoded here exactly like the `SUPPLEMENT` labels in
* `rich-reads.ts` (Weight / Pulse / Body-mass index). Mirrors
* `messages/en.json`'s `nutrients.names.*` bundle so the wording matches the
* app's own settings card.
* English display labels for the closed nutrient catalog, READ OUT of
* `messages/en.json`'s `nutrients.names.*` bundle rather than transcribed
* here. MCP text stays protocol-level English on purpose (see
* `MCP_SERVER_INSTRUCTIONS`'s own docblock); what changed is only where the
* English wording comes from, so it can no longer drift from the settings
* card the user reads.
*
* A missing key throws at module load — the same "fail loudly rather than
* silently under-serve" posture `MCP_METRIC_STATUS_DISCOVERY` takes in
* `rich-reads.ts`, and the i18n bundle guards would have caught it first.
*/
export const NUTRIENT_LABELS: Readonly<Record<NutrientCode, string>> = {
vitamin_a: "Vitamin A",
thiamin: "Thiamin (B1)",
riboflavin: "Riboflavin (B2)",
niacin: "Niacin (B3)",
pantothenic_acid: "Pantothenic acid (B5)",
vitamin_b6: "Vitamin B6",
biotin: "Biotin (B7)",
folate: "Folate (B9)",
vitamin_b12: "Vitamin B12",
vitamin_c: "Vitamin C",
vitamin_d: "Vitamin D",
vitamin_e: "Vitamin E",
vitamin_k: "Vitamin K",
calcium: "Calcium",
iron: "Iron",
magnesium: "Magnesium",
phosphorus: "Phosphorus",
zinc: "Zinc",
copper: "Copper",
manganese: "Manganese",
selenium: "Selenium",
chromium: "Chromium",
molybdenum: "Molybdenum",
iodine: "Iodine",
water: "Water",
caffeine: "Caffeine",
};
export const NUTRIENT_LABELS: Readonly<Record<NutrientCode, string>> =
Object.freeze(
Object.fromEntries(
NUTRIENT_CODES.map((code) => {
const label = labelIn(defaultLocale, nutrientLabelKey(code));
if (label === null) {
throw new Error(
`message bundle is missing ${nutrientLabelKey(code)}`,
);
}
return [code, label];
}),
) as Record<NutrientCode, string>,
);
/**
* Folded nutrient name → code, across EVERY shipped locale.
*
* `Fer` and `Żelazo` used to reach `unknown_nutrient` while the app rendered
* exactly those words on its own settings card, because the resolver compared
* against a hand-typed English copy of the bundle. The index is derived from
* `locales`, so a seventh language starts resolving the moment its bundle
* lands, with no edit here.
*/
const NUTRIENT_NAME_INDEX = buildLocalisedLabelIndex<NutrientCode>(
NUTRIENT_CODES.map((code) => ({
id: code,
messageKey: nutrientLabelKey(code),
value: code,
})),
);
/** Overview mode (no `nutrient` arg) mirrors `GET /api/nutrients`'s bounds. */
const OVERVIEW_DEFAULT_DAYS = 14;
@@ -102,24 +120,24 @@ export interface NutrientsDailyResult {
/**
* Resolve a free-text nutrient name to a catalog code, or `null`. Forgiving
* for an NL assistant (exact code, spaces/hyphens folded to underscores, or a
* display-label match e.g. "Vitamin D") but closed to the 26-code catalog —
* an unresolved name reports `{ present: false, reason: "unknown_nutrient" }`
* rather than inventing a series.
* for an NL assistant (the exact code, or the display name in ANY shipped
* language, with case, accents and separators folded) but closed to the
* 26-code catalog — an unresolved name reports
* `{ present: false, reason: "unknown_nutrient" }` rather than inventing a
* series.
*
* `unknown_nutrient` therefore keeps meaning "we could not place this word",
* distinct from a resolved code that then answers
* `{ present: false, reason: "no_data" }` — "placed, and honestly not
* recorded". Widening what resolves moves words from the first answer to the
* second; it never turns absence into data.
*/
export function resolveNutrientCode(input: string): NutrientCode | null {
const raw = input.trim();
if (!raw) return null;
const key = raw.toLowerCase().replace(/[\s-]+/g, "_");
if (isNutrientCode(key)) return key;
for (const code of NUTRIENT_CODES) {
const folded = NUTRIENT_LABELS[code]
.toLowerCase()
.replace(/[\s()-]+/g, "_")
.replace(/_+$/, "");
if (folded === key) return code;
}
return null;
return NUTRIENT_NAME_INDEX.index.get(foldLabel(raw)) ?? null;
}
/** Presence overview across every logged code — mirrors `GET /api/nutrients`. */
+81 -4
View File
@@ -37,6 +37,12 @@ import { moduleForMeasurementType } from "@/lib/modules/measurement-scope";
import { readCoachCorrelations } from "@/lib/ai/coach/tools/correlations-read";
import { buildCoachReadStrip } from "@/lib/insights/derived/coach-read";
import { resolveLocaleForUser } from "@/lib/i18n/user-locale";
import {
buildLocalisedLabelIndex,
foldLabel,
type LocalisedLabelEntry,
} from "@/lib/i18n/localised-label-index";
import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/lib/measurements/type-label-keys";
import {
readBestGranularityRollups,
aggregateWmyBuckets,
@@ -510,11 +516,71 @@ export async function metricStatusDiscoveryRows(userId: string): Promise<
});
}
// ── localised metric names ───────────────────────────────────────────
//
// Every other step of `resolveRichMetric` compares against ENGLISH: the alias
// tables and the metric-status registry's `displayName`. Five of the six
// shipped languages could therefore not name a metric at all — `poids` and
// `sommeil` resolved to nothing over MCP even though the app renders exactly
// those words.
//
// The names are not this file's to invent: `MEASUREMENT_TYPE_LABEL_KEYS`
// already binds every `MeasurementType` to the i18n key the app labels it
// with, and every bundle carries that key. So the index is DERIVED — one
// entry per (measurement type × shipped locale) — and a seventh language
// starts resolving the moment its bundle lands.
//
// A value is attached only for the metrics the English resolver can ALREADY
// produce (`SUPPLEMENT`, the registry-derived clinical allowlist, and the
// metric-status ids that survive `fromRegistry`'s `mcp:false` veto), so the
// index widens SPELLINGS, never exposure. A screener or an `ENV_*` signal is
// value-less here for the same reason it is unresolvable in English.
//
// Every OTHER measurement type is still entered, value-less, because
// ambiguity has to be judged over the whole label space rather than over the
// exposed slice of it. French and Italian call body fat and fat mass by one
// phrase ("masse grasse" / "massa grassa"); only one of the two is exposed,
// so an index that knew about the exposed one alone would have seen no
// collision and answered kilograms to someone who asked for a percentage.
//
// The alias tables stay as they are and stay English: `resting_hr`, `spo2`,
// `vo2max` are protocol slugs, not translations of anything.
const LOCALISED_METRIC_INDEX = (() => {
const exposed = new Map<MeasurementType, RichMetric>();
const claim = (metric: RichMetric | null) => {
if (metric && !exposed.has(metric.measurementType)) {
exposed.set(metric.measurementType, metric);
}
};
for (const metric of Object.values(SUPPLEMENT)) claim(metric);
for (const metric of CLINICAL_SIGNAL_BY_KEY.values()) claim(metric);
for (const id of METRIC_STATUS_IDS) claim(fromRegistry(id));
const entries: LocalisedLabelEntry<RichMetric | null>[] = Object.entries(
MEASUREMENT_TYPE_LABEL_KEYS,
).map(([type, messageKey]) => ({
id: type,
messageKey,
value: exposed.get(type as MeasurementType) ?? null,
}));
return buildLocalisedLabelIndex(entries);
})();
/**
* Words two different measurement types claim in some bundle, and which
* therefore resolve to neither. Exported so the guard can pin the set: the
* known members are a genuine translation collision, a NEW one is a
* regression to look at rather than something to resolve by coin flip.
*/
export const LOCALISED_METRIC_NAME_COLLISIONS: ReadonlySet<string> =
LOCALISED_METRIC_INDEX.ambiguous;
/**
* Resolve a free-text metric name to a single scalar series. Forgiving for an
* NL assistant (alias, exact id, display-name match) but closed — an
* unresolved name returns `null` so the tool reports `{ present: false }`
* rather than inventing a series. Multi-series metrics (blood pressure) are not
* NL assistant (alias, exact id, the metric's name in any shipped language,
* or an English display-name fragment) but closed — an unresolved name
* returns `null` so the tool reports `{ present: false }` rather than
* inventing a series. Multi-series metrics (blood pressure) are not
* resolvable here by design; the prompt + `get_metric_series` cover BP.
*/
export function resolveRichMetric(input: string): RichMetric | null {
@@ -543,7 +609,18 @@ export function resolveRichMetric(input: string): RichMetric | null {
if (direct) return direct;
}
// 4. display-name match (e.g. "heart-rate variability").
// 4. the metric's own name, in any shipped language, matched EXACTLY.
//
// Ahead of the substring pass below on purpose: an exact name beats a loose
// English fragment. It has to, or a short name in another language loses to
// an unrelated English word that merely contains it — "Puls" (de/pl) landed
// on PULSE_WAVE_VELOCITY, because "pulse-wave velocity" contains "puls".
// A value-less hit (a metric this surface does not expose) falls through
// rather than short-circuiting, so the English behaviour below is untouched.
const localised = LOCALISED_METRIC_INDEX.index.get(foldLabel(raw));
if (localised) return localised;
// 5. English display-name substring match (e.g. "heart-rate variability").
const needle = raw.toLowerCase();
for (const id of METRIC_STATUS_IDS) {
const meta = getMetricStatusMeta(id);
+5 -5
View File
@@ -1225,7 +1225,7 @@ export const MCP_TOOLS: McpToolDefinition[] = [
name: "compare_metric",
title: "Compare metrics or windows",
description:
"Compare one metric against another over the same horizon, OR a single metric across two horizons. A horizon is either one of the five fixed trailing windows OR an explicit {from,to} date range (use `range`/`rangeB` for before-vs-after-a-date). Also resolves the clinical-signal metrics (grip strength, pain 0–10 NRS, waist circumference, waist-to-height ratio). Returns each side's rollup statistics (count, mean, min, max) with units and reference bands, plus a delta + percent change when both sides share a unit. Returns { present: false } when a side has no data.",
"Compare one metric against another over the same horizon, OR a single metric across two horizons. A horizon is either one of the five fixed trailing windows OR an explicit {from,to} date range (use `range`/`rangeB` for before-vs-after-a-date). Also resolves the clinical-signal metrics (grip strength, pain 0–10 NRS, waist circumference, waist-to-height ratio). A metric can be named by its alias, its key, or its display name in any language HealthLog ships (e.g. 'poids', 'Blutzucker'). Returns each side's rollup statistics (count, mean, min, max) with units and reference bands, plus a delta + percent change when both sides share a unit. Returns { present: false } when a side has no data.",
inputShape: {
metric: z.string().min(1).max(60),
metricB: z.string().min(1).max(60).optional(),
@@ -1251,7 +1251,7 @@ export const MCP_TOOLS: McpToolDefinition[] = [
name: "get_metric_baseline",
title: "Get a metric's personal baseline",
description:
"Fetch where the user's latest reading for ONE metric sits against their own usual range (median ± robust deviation), plus the strongest lagged driver of that metric. Re-exports the same baseline engine the metric page renders. Also resolves the clinical-signal metrics (grip strength, pain 0–10 NRS, waist circumference, waist-to-height ratio). Returns the personal band, today's value + placement (within/above/below), and the population reference band. Returns { present: false } with reason 'insufficient_history' below the 7-day learning floor — never a fabricated range.",
"Fetch where the user's latest reading for ONE metric sits against their own usual range (median ± robust deviation), plus the strongest lagged driver of that metric. Re-exports the same baseline engine the metric page renders. Also resolves the clinical-signal metrics (grip strength, pain 0–10 NRS, waist circumference, waist-to-height ratio). A metric can be named by its alias, its key, or its display name in any language HealthLog ships (e.g. 'poids', 'Blutzucker'). Returns the personal band, today's value + placement (within/above/below), and the population reference band. Returns { present: false } with reason 'insufficient_history' below the 7-day learning floor — never a fabricated range.",
inputShape: {
metric: z.string().min(1).max(60),
},
@@ -1267,7 +1267,7 @@ export const MCP_TOOLS: McpToolDefinition[] = [
name: "detect_changepoints",
title: "Detect level shifts in a metric",
description:
"Surface points where a metric's level shifted over a trailing window OR an explicit {from,to} date range (e.g. 'when did my weight trend change?'). Also resolves the clinical-signal metrics (grip strength, pain 0–10 NRS, waist circumference, waist-to-height ratio). Runs a conservative changepoint scan over the rollup tier's bucket means and reports each shift's date, direction, and before/after means with units. High firing bar — returns { present: false } when too little data exists or no shift clears the threshold.",
"Surface points where a metric's level shifted over a trailing window OR an explicit {from,to} date range (e.g. 'when did my weight trend change?'). Also resolves the clinical-signal metrics (grip strength, pain 0–10 NRS, waist circumference, waist-to-height ratio). A metric can be named by its alias, its key, or its display name in any language HealthLog ships (e.g. 'poids', 'Blutzucker'). Runs a conservative changepoint scan over the rollup tier's bucket means and reports each shift's date, direction, and before/after means with units. High firing bar — returns { present: false } when too little data exists or no shift clears the threshold.",
inputShape: {
metric: z.string().min(1).max(60),
window: coachScopeWindowSchema.optional(),
@@ -1744,7 +1744,7 @@ export const MCP_TOOLS: McpToolDefinition[] = [
name: "get_nutrients",
title: "Get nutrient intake",
description:
"Fetch the user's synced micronutrient intake — water, caffeine, and the 24 tracked vitamins/minerals — as day totals summed across sources (e.g. an Apple Health sync AND a manual water entry on the same day). Omit `nutrient` for a presence overview across every logged code (latest logged day, latest day's total, days with data). Pass a catalog code or display name (e.g. 'water', 'magnesium', 'vitamin_d') for that nutrient's per-day series over a trailing window, plus its EFSA dietary reference resolved against the user's own profile sex — omitted, never guessed, when sex is not on file. Gated on the opt-in `nutrients` module. Returns { present: false, reason: \"module_disabled\" } when the module is off, or { present: false } when nothing matches.",
"Fetch the user's synced micronutrient intake — water, caffeine, and the 24 tracked vitamins/minerals — as day totals summed across sources (e.g. an Apple Health sync AND a manual water entry on the same day). Omit `nutrient` for a presence overview across every logged code (latest logged day, latest day's total, days with data). Pass a catalog code, or the nutrient's display name in any language HealthLog ships (e.g. 'water', 'magnesium', 'vitamin_d', 'Magnésium', 'Żelazo'), for that nutrient's per-day series over a trailing window, plus its EFSA dietary reference resolved against the user's own profile sex — omitted, never guessed, when sex is not on file. Gated on the opt-in `nutrients` module. Returns { present: false, reason: \"module_disabled\" } when the module is off, or { present: false } when nothing matches.",
inputShape: {
nutrient: z
.string()
@@ -1752,7 +1752,7 @@ export const MCP_TOOLS: McpToolDefinition[] = [
.max(40)
.optional()
.describe(
"A catalog code (e.g. 'water', 'caffeine', 'vitamin_d', 'magnesium') or its display name. Omit for a presence overview across all logged nutrients.",
"A catalog code (e.g. 'water', 'caffeine', 'vitamin_d', 'magnesium') or its display name in any language HealthLog ships. Omit for a presence overview across all logged nutrients.",
),
days: z
.number()
@@ -3,6 +3,7 @@ import {
inferMedTargetClass,
primaryTargetForClass,
MED_TARGET_MAP,
MED_NEEDLE_STEMS,
resolveMedicationTargets,
targetsForEfficacyClass,
} from "@/lib/medications/med-target-map";
@@ -138,3 +139,160 @@ describe("resolveMedicationTargets — three-tier efficacy resolution", () => {
});
});
});
// ── national INN spellings ───────────────────────────────────────────
//
// The needle lists are drug names, not app-owned labels: there is no bundle
// to derive them from. What there IS is the regularity of the INN system —
// `Metformin`, `Metformine`, `Metformina` and `Metforminum` are one molecule
// under four national conventions. The matcher folds both sides to a
// normalised INN stem instead of enumerating languages, so all of these reach
// the same class off ONE English needle.
describe("med-target-map — national INN spellings fold to one stem", () => {
it.each([
// antidiabetic
["de", "Metformin 1000mg", "antidiabetic"],
["fr", "Metformine 1000 mg", "antidiabetic"],
["es", "Metformina 1000 mg", "antidiabetic"],
["it", "Metformina", "antidiabetic"],
["pl", "Metformina", "antidiabetic"],
["la", "Metforminum", "antidiabetic"],
["fr", "Insuline glargine", "antidiabetic"],
["es", "Insulina glargina", "antidiabetic"],
["pl", "Empagliflozyna 10 mg", "antidiabetic"],
["pl", "Gliklazyd 30 mg", "antidiabetic"],
["pl", "Sitagliptyna", "antidiabetic"],
["es", "Pioglitazona", "antidiabetic"],
// GLP-1
["de", "Semaglutid 1 mg", "glp1"],
["fr", "Sémaglutide 1 mg", "glp1"],
["es", "Semaglutida", "glp1"],
["pl", "Semaglutyd", "glp1"],
["de", "Tirzepatid", "glp1"],
["pl", "Liraglutyd", "glp1"],
["pl", "Eksenatyd", "glp1"],
// antihypertensive
["de", "Amlodipin 5mg", "antihypertensive"],
["es", "Amlodipino 5 mg", "antihypertensive"],
["pl", "Amlodypina 5 mg", "antihypertensive"],
["pl", "Ramipryl 5 mg", "antihypertensive"],
["it", "Bisoprololo 2,5 mg", "antihypertensive"],
["pl", "Karwedilol", "antihypertensive"],
["pl", "Nebiwolol", "antihypertensive"],
["pl", "Walsartan 80 mg", "antihypertensive"],
["pl", "Kandesartan", "antihypertensive"],
["it", "Idroclorotiazide", "antihypertensive"],
["pl", "Hydrochlorotiazyd", "antihypertensive"],
["de", "Furosemid 40 mg", "antihypertensive"],
["pl", "Doksazosyna", "antihypertensive"],
["pl", "Lizynopryl", "antihypertensive"],
["de", "Enalapril", "antihypertensive"],
["pl", "Kaptopril", "antihypertensive"],
] as const)("reads the %s spelling %s as %s", (_lang, name, cls) => {
expect(inferMedTargetClass(name)).toBe(cls);
});
it("carries the fold into the lab-target classes too", () => {
expect(resolveMedicationTargets({ name: "Atorwastatyna 20 mg" })?.cls).toBe(
"statin",
);
expect(resolveMedicationTargets({ name: "Atorvastatina 20 mg" })?.cls).toBe(
"statin",
);
expect(resolveMedicationTargets({ name: "Rozuwastatyna" })?.cls).toBe(
"statin",
);
expect(resolveMedicationTargets({ name: "Lewotyroksyna" })?.cls).toBe(
"thyroid",
);
expect(resolveMedicationTargets({ name: "Levotiroxina 75 µg" })?.cls).toBe(
"thyroid",
);
expect(resolveMedicationTargets({ name: "L-Thyroxin 100" })?.cls).toBe(
"thyroid",
);
expect(resolveMedicationTargets({ name: "Cholekalcyferol" })?.cls).toBe(
"vitamin_d",
);
expect(resolveMedicationTargets({ name: "Colecalciferolo" })?.cls).toBe(
"vitamin_d",
);
});
it("names iron by its element, which is a word list and not an INN", () => {
// Iron preparations are named for the element, so no INN stem exists to
// fold to. The element word IS in every language, hand-tabulated.
expect(
resolveMedicationTargets({ name: "Ferrous sulfate 200mg" })?.cls,
).toBe("iron");
expect(resolveMedicationTargets({ name: "Fer 14 mg" })?.cls).toBe("iron");
expect(resolveMedicationTargets({ name: "Eisen 100 mg" })?.cls).toBe(
"iron",
);
expect(resolveMedicationTargets({ name: "Hierro 80 mg" })?.cls).toBe(
"iron",
);
expect(resolveMedicationTargets({ name: "Żelazo 50 mg" })?.cls).toBe(
"iron",
);
});
it("keeps every English name resolving exactly as before", () => {
expect(inferMedTargetClass("Metformin 1000mg")).toBe("antidiabetic");
expect(inferMedTargetClass("insulin glargine")).toBe("antidiabetic");
expect(inferMedTargetClass("semaglutide 1mg")).toBe("glp1");
expect(inferMedTargetClass("Ramipril")).toBe("antihypertensive");
expect(inferMedTargetClass("amlodipine 5mg")).toBe("antihypertensive");
expect(inferMedTargetClass("Bisoprolol")).toBe("antihypertensive");
expect(inferMedTargetClass("Mounjaro")).toBe("glp1");
expect(inferMedTargetClass("Ozempic")).toBe("glp1");
expect(resolveMedicationTargets({ name: "Atorvastatin 20mg" })?.cls).toBe(
"statin",
);
expect(resolveMedicationTargets({ name: "Cholecalciferol" })?.cls).toBe(
"vitamin_d",
);
});
// The fold is only safe if it keeps molecules apart. A rewrite rule that
// merged two of them would map a real drug to the wrong class silently.
it("keeps every needle's stem unique across classes", () => {
const owner = new Map<string, string>();
const collisions: string[] = [];
for (const [cls, stems] of Object.entries(MED_NEEDLE_STEMS)) {
for (const stem of stems) {
const claimed = owner.get(stem);
if (claimed !== undefined && claimed !== cls) {
collisions.push(`${stem}: ${claimed} + ${cls}`);
}
owner.set(stem, cls);
}
}
expect(collisions).toEqual([]);
});
it("keeps every stem long enough not to collide with an ordinary word", () => {
const short = Object.values(MED_NEEDLE_STEMS)
.flat()
.filter((stem) => stem.length < 5)
.sort();
// The two deliberately short ones are element names, not INN stems; the
// whole-token rule is what keeps them safe.
expect(short).toEqual(["fer", "iron"]);
});
it("still conservative-fails — a looser fold must not invent a class", () => {
expect(inferMedTargetClass("Aspirin")).toBeNull();
expect(inferMedTargetClass("Paracetamol")).toBeNull();
expect(inferMedTargetClass("Ibuprofen")).toBeNull();
expect(inferMedTargetClass("Vitamin D")).toBeNull();
expect(inferMedTargetClass("Pantoprazol")).toBeNull();
expect(inferMedTargetClass("Amoxicillin")).toBeNull();
expect(inferMedTargetClass("")).toBeNull();
expect(resolveMedicationTargets({ name: "Aspirin" })).toBeNull();
// A word that merely CONTAINS a stem is not a whole-token hit.
expect(
inferMedTargetClass("Metforminhydrochlorid-Fertigarznei"),
).toBeNull();
});
});
+188 -32
View File
@@ -15,15 +15,20 @@
* (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-word, case-insensitive INN/brand matching plus the
* structured `treatmentClass` discriminator + the GLP-1 catalog. No fuzzy
* matching — a partial hit is treated as unknown.
* - 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";
@@ -53,10 +58,16 @@ export function primaryTargetForClass(cls: MedTargetClass): MeasurementType {
}
/**
* Whole-word INN/brand needles per class. Each list is conservative — common
* generics and brands 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 resolved separately via the structured catalog, not by this list.
* 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
@@ -116,20 +127,123 @@ const GLP1_INN_NEEDLES: readonly string[] = [
"exenatide",
];
/** Lowercase, trim — the comparison form for a med name. */
/** Lowercase, trim — the verbatim comparison form the brand lookup wants. */
function normaliseName(name: string): string {
return name.trim().toLowerCase();
}
/** True when `name` contains `needle` as a whole word (no partial hits). */
function containsWord(name: string, needle: string): boolean {
const re = new RegExp(`(?:^|[^a-z])${needle}(?:[^a-z]|$)`, "i");
return re.test(name);
/**
* 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-word name match — in that priority order.
* 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).
*
@@ -146,16 +260,14 @@ export function inferMedTargetClass(
const normalised = normaliseName(name);
if (!normalised) return null;
// The structured GLP-1 catalog is authoritative for its brands/INNs.
// 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";
if (GLP1_INN_NEEDLES.some((n) => containsWord(normalised, n))) return "glp1";
if (ANTIDIABETIC_NEEDLES.some((n) => containsWord(normalised, n))) {
return "antidiabetic";
}
if (ANTIHYPERTENSIVE_NEEDLES.some((n) => containsWord(normalised, n))) {
return "antihypertensive";
}
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;
}
@@ -249,7 +361,7 @@ const ATC_PREFIX_CLASS: ReadonlyArray<readonly [string, EfficacyMedClass]> = [
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). */
/** Whole-token name needles for the lab classes (metric classes stay above). */
const STATIN_NEEDLES: readonly string[] = [
"atorvastatin",
"simvastatin",
@@ -273,12 +385,59 @@ const VITAMIN_D_NEEDLES: readonly string[] = [
"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",
"iron bisglycinate",
"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,
@@ -301,15 +460,12 @@ function classFromName(
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";
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;
}