From 97d0c26ceb7887accfcda1433a9161541c507178 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 23 Aug 2026 13:56:32 +0200 Subject: [PATCH 1/8] fix(glp1): match side-effect tags by key, not by an English/German word list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The GLP-1 side-effect chips in the mood form write the localised label into the flat `MoodEntry.tags` column, and three readers matched that label against a hand-written list of English and German words: the therapy timeline, the Coach snapshot, and the doctor report's GLP-1 block. The app ships six locales. Tap the nausea chip with the UI in French, Spanish, Italian or Polish and the day never reached the timeline, the Coach grounded its answers on an empty side-effect list, and a clinician read an empty table over a record that had three months of symptoms. Nothing on any of those surfaces said the table was a filter rather than the truth. The three lists had also drifted apart from each other. The chips are a closed set with stable keys, so matching the label was the defect and the key is the fix. `glp1-side-effect-tags.ts` holds the catalogue — seven keys, each with the i18n key its chip renders — and the mood form now draws its strip from it, so the write side and the read side cannot diverge again. `glp1-side-effect-tag-match.ts` builds the label-to-key index by reading `messages/.json` for every entry in `locales`, rather than transcribing the labels into a second list. Transcribing would have fixed today's four locales and reopened the same hole on the day a seventh is added; deriving means a new bundle is matchable as soon as it lands, with no edit in the matcher. Comparison folds case, accents and separators, so the English and German tags already in the database keep resolving and a straight apostrophe matches the typographic one the French bundle uses. All three readers now report the catalogue key instead of the stored string. The same recorded symptom yields one key whatever language it was tagged in, which also fixes the mixed-language output an account gets after switching UI locale; the doctor-report PDF resolves the key through the label map so the clinician reads it in the report's language. A tag outside the catalogue is still never counted — a free-text mood tag must not reach a doctor as an invented symptom, and the rest of somebody's day does not belong on a therapy timeline. It is no longer dropped without trace: the timeline route annotates the unresolved count on its wide event, so the classification rate stays answerable while the tag text stays in the database. The Coach's copy of the list also carried `vomiting`, `reflux` and `erbrechen`. Those are removed. No chip writes them, so they only ever matched a hand-typed tag in two of six languages; keeping them would have rebuilt the defect inside its own fix. Restoring either symptom means giving it a chip and a label in all six bundles. The response schema for `GET /api/insights/glp1-timeline` now enumerates the keys, and its description no longer documents the bilingual match as if it were the contract. --- docs/api/openapi.yaml | 18 +- .../glp1-timeline/__tests__/route.test.ts | 142 +++++++++++++ src/app/api/insights/glp1-timeline/route.ts | 74 +++---- src/components/mood/mood-form.tsx | 37 ++-- .../ai/coach/__tests__/glp1-snapshot.test.ts | 60 ++++++ src/lib/ai/coach/glp1-snapshot.ts | 60 +----- .../medication-mood-wellness-section.ts | 8 +- src/lib/doctor-report-types.ts | 9 +- .../glp1-side-effect-locales.test.ts | 82 ++++++++ src/lib/doctor-report/medications.ts | 54 +---- .../__tests__/glp1-side-effect-tags.test.ts | 196 ++++++++++++++++++ .../medications/glp1-side-effect-tag-match.ts | 124 +++++++++++ src/lib/medications/glp1-side-effect-tags.ts | 122 +++++++++++ src/lib/openapi/routes/insights/schemas.ts | 14 +- 14 files changed, 833 insertions(+), 167 deletions(-) create mode 100644 src/app/api/insights/glp1-timeline/__tests__/route.test.ts create mode 100644 src/lib/doctor-report/__tests__/glp1-side-effect-locales.test.ts create mode 100644 src/lib/medications/__tests__/glp1-side-effect-tags.test.ts create mode 100644 src/lib/medications/glp1-side-effect-tag-match.ts create mode 100644 src/lib/medications/glp1-side-effect-tags.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index c9b39cce4..44ca5d598 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -50680,12 +50680,24 @@ components: reason: type: string tags: - description: The recognised side-effect tags picked up that day. Matched against a FIXED bilingual list of English and - German tag strings, so a tag written in any other language — or spelled differently — does not appear - here at all. + description: "Catalogue keys of the side effects tagged that day, deduplicated. These are STABLE KEYS, not the strings + the entries were written with: the capture chip writes its label in the writer's language, and the + matcher indexes every shipped locale's label back to the key, so the same recorded symptom yields the + same key whether it was tagged in German, English, Spanish, French, Italian or Polish. Render the key + in the reader's own language rather than echoing it. A mood tag that is not a catalogue side effect — + anything the user typed freehand — is not reported here; it is not a side effect, and a therapy + timeline is not the place for the rest of somebody's day." type: array items: type: string + enum: + - nausea + - constipation + - diarrhea + - fatigue + - appetite-loss + - heartburn + - headache required: - date - kind diff --git a/src/app/api/insights/glp1-timeline/__tests__/route.test.ts b/src/app/api/insights/glp1-timeline/__tests__/route.test.ts new file mode 100644 index 000000000..966d55e94 --- /dev/null +++ b/src/app/api/insights/glp1-timeline/__tests__/route.test.ts @@ -0,0 +1,142 @@ +/** + * `GET /api/insights/glp1-timeline` — the reported surface. + * + * A side-effect day was assembled by matching the stored mood tag against a + * hand-written English/German word list. Tap the nausea chip with the UI in + * French, Spanish, Italian or Polish and the day never appeared on the + * timeline: a thinner therapy history, with nothing anywhere saying why. + * + * The property asserted here is equality, not presence — a tag recorded in any + * shipped locale contributes exactly what the English one does. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +const mocks = vi.hoisted(() => ({ + medicationFindMany: vi.fn(), + moodFindMany: vi.fn(), + annotate: vi.fn(), +})); + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: (handler: unknown) => handler, + requireRecordAuth: vi.fn(async () => ({ + user: { id: "user-1" }, + actor: { id: "user-1" }, + grantId: null, + })), +})); +vi.mock("@/lib/logging/context", () => ({ annotate: mocks.annotate })); +vi.mock("@/lib/db", () => ({ + prisma: { + medication: { findMany: mocks.medicationFindMany }, + moodEntry: { findMany: mocks.moodFindMany }, + }, +})); + +import { GET } from "../route"; + +const handler = GET as unknown as (req: NextRequest) => Promise; + +function request(): NextRequest { + return new NextRequest("http://localhost/api/insights/glp1-timeline"); +} + +/** One GLP-1 medication with no events of its own, so only mood days show. */ +function medication() { + return { + name: "Mounjaro", + doseChanges: [], + intakeEvents: [], + inventoryEvents: [], + }; +} + +function moodDay(day: number, tags: string[]) { + return { + moodLoggedAt: new Date(Date.UTC(2026, 3, day, 9, 0, 0)), + tags: JSON.stringify(tags), + }; +} + +interface TimelineBody { + data: { + hasGlp1: boolean; + entries: Array<{ date: string; kind: string; tags?: string[] }>; + }; +} + +async function timeline(moods: ReturnType[]) { + mocks.medicationFindMany.mockResolvedValue([medication()]); + mocks.moodFindMany.mockResolvedValue(moods); + const res = await handler(request()); + const body = (await res.json()) as TimelineBody; + return body.data; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("GET /api/insights/glp1-timeline side-effect days", () => { + it("builds the same timeline from a French record as from an English one", async () => { + const en = await timeline([ + moodDay(10, ["nausea"]), + moodDay(11, ["headache", "fatigue"]), + ]); + const fr = await timeline([ + moodDay(10, ["Nausées"]), + moodDay(11, ["Maux de tête", "Fatigue"]), + ]); + expect(fr).toEqual(en); + expect(fr.entries).toEqual([ + { + date: "2026-04-11T12:00:00Z", + kind: "side-effect", + tags: ["headache", "fatigue"], + }, + { date: "2026-04-10T12:00:00Z", kind: "side-effect", tags: ["nausea"] }, + ]); + }); + + it("recognises the chip label of every shipped locale", async () => { + const out = await timeline([ + moodDay(10, ["Übelkeit"]), + moodDay(11, ["Estreñimiento"]), + moodDay(12, ["Stitichezza"]), + moodDay(13, ["Zgaga"]), + moodDay(14, ["Perte d’appétit"]), + ]); + expect(out.entries.map((e) => e.tags)).toEqual([ + ["appetite-loss"], + ["heartburn"], + ["constipation"], + ["constipation"], + ["nausea"], + ]); + }); + + it("emits the catalogue key, not the string the entry was written with", async () => { + // The stored column mixes languages as soon as an account switches UI + // locale. A key renders in the reader's language; a stored label renders + // in whichever one the writer happened to be using. + const out = await timeline([moodDay(10, ["Nudności", "Übelkeit"])]); + expect(out.entries[0].tags).toEqual(["nausea"]); + }); + + it("leaves free-text mood tags off the therapy timeline", async () => { + const out = await timeline([moodDay(10, ["gym", "date night"])]); + expect(out.entries).toEqual([]); + }); + + it("counts what it could not classify on the wide event", async () => { + // Not dropped without trace: the miss rate stays answerable from a + // dashboard, while the tag text — the user's own prose — stays in the + // database. + await timeline([moodDay(10, ["Nudności", "siłownia", "rodzina"])]); + expect(mocks.annotate).toHaveBeenCalledWith({ + action: { name: "insights.glp1-timeline.read" }, + meta: { side_effect_days: 1, unresolved_mood_tags: 2 }, + }); + }); +}); diff --git a/src/app/api/insights/glp1-timeline/route.ts b/src/app/api/insights/glp1-timeline/route.ts index b7ab3184d..513cb76ef 100644 --- a/src/app/api/insights/glp1-timeline/route.ts +++ b/src/app/api/insights/glp1-timeline/route.ts @@ -13,25 +13,12 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { apiSuccess } from "@/lib/api-response"; import { readNote } from "@/lib/crypto/note-cipher"; +import { annotate } from "@/lib/logging/context"; +import { matchGlp1SideEffectTags } from "@/lib/medications/glp1-side-effect-tag-match"; +import type { Glp1SideEffectTag } from "@/lib/medications/glp1-side-effect-tags"; import { NextRequest } from "next/server"; const DEFAULT_LIMIT = 60; -const SIDE_EFFECT_TAGS = new Set([ - "nausea", - "constipation", - "diarrhea", - "fatigue", - "appetite-loss", - "heartburn", - "headache", - "übelkeit", - "verstopfung", - "durchfall", - "müdigkeit", - "appetitlosigkeit", - "sodbrennen", - "kopfschmerzen", -]); interface TimelineEntry { date: string; @@ -44,25 +31,7 @@ interface TimelineEntry { injectionSite?: string | null; inventoryDelta?: number; reason?: string; - tags?: string[]; -} - -function parseTagList(raw: string | null): string[] { - if (!raw) return []; - try { - const parsed = JSON.parse(raw); - if (Array.isArray(parsed)) { - return parsed - .map((v) => (typeof v === "string" ? v.trim() : "")) - .filter(Boolean); - } - } catch { - /* fall through */ - } - return raw - .split(",") - .map((p) => p.trim()) - .filter(Boolean); + tags?: Glp1SideEffectTag[]; } export const GET = apiHandler(async (request: NextRequest) => { @@ -157,17 +126,23 @@ export const GET = apiHandler(async (request: NextRequest) => { } } - // Side-effect days — collapse to one entry per day (most recent - // first) listing every recognised tag picked up that day. - const sideEffectByDay = new Map(); + // Side-effect days — collapse to one entry per day (most recent first) + // listing every catalogue side effect picked up that day. + // + // `tags` carries the catalogue KEY, not the string the entry was written + // with. The chip writes the label of whatever language the writer's UI was + // in, so the stored column mixes languages within one account the moment + // somebody switches locale; a key renders in the reader's language and a + // stored label renders in the writer's. + const sideEffectByDay = new Map(); + let unresolvedTagCount = 0; for (const mood of moods) { - const tags = parseTagList(mood.tags).filter((t) => - SIDE_EFFECT_TAGS.has(t.toLowerCase()), - ); - if (tags.length === 0) continue; + const { matched, unresolvedCount } = matchGlp1SideEffectTags(mood.tags); + unresolvedTagCount += unresolvedCount; + if (matched.length === 0) continue; const dayKey = mood.moodLoggedAt.toISOString().slice(0, 10); const existing = sideEffectByDay.get(dayKey) ?? []; - for (const t of tags) { + for (const t of matched) { if (!existing.includes(t)) existing.push(t); } sideEffectByDay.set(dayKey, existing); @@ -182,5 +157,18 @@ export const GET = apiHandler(async (request: NextRequest) => { entries.sort((a, b) => b.date.localeCompare(a.date)); + // Mood tags that are not catalogue side effects are correctly absent from a + // GLP-1 timeline — they are the user's own prose about their day, and this + // surface is not where it belongs. Absent is not the same as untracked + // though: the count rides the wide event so the classification rate stays + // answerable from a dashboard, without the text leaving the database. + annotate({ + action: { name: "insights.glp1-timeline.read" }, + meta: { + side_effect_days: sideEffectByDay.size, + unresolved_mood_tags: unresolvedTagCount, + }, + }); + return apiSuccess({ hasGlp1: true, entries: entries.slice(0, limit) }); }); diff --git a/src/components/mood/mood-form.tsx b/src/components/mood/mood-form.tsx index f618ea3bf..23b18f965 100644 --- a/src/components/mood/mood-form.tsx +++ b/src/components/mood/mood-form.tsx @@ -64,6 +64,10 @@ import { dayOfLocalInput, useLinkedDayContext } from "./use-linked-day-context"; import { MoodQuickTags } from "./mood-quick-tags"; import { useRecentTags } from "./recent-tags"; import { moodFaceIcon } from "./mood-tag-icons"; +// One catalogue for the chip strip and for every server-side reader of what it +// writes. Split apart, the write side gained a locale and the read side did +// not notice for four of them. +import { GLP1_SIDE_EFFECT_TAGS } from "@/lib/medications/glp1-side-effect-tags"; // v1.12.0 — best-on-the-left face order for the "How are you?" hero, // mirroring the iOS `Mood1…Mood5` imageset order. The numeric `score` @@ -82,24 +86,6 @@ const MOOD_LEVELS = [ // so the truncation at the cap is no longer silent. const NOTE_MAX_LENGTH = 500; -/** - * v1.4.25 W4d — curated GLP-1 side-effect chip strip. Tapping a chip - * appends the localised tag string to the existing free-text tag input - * (comma-separated). The list is intentionally short — these are the - * symptoms clinicians most often ask about at GLP-1 follow-up visits - * (cf. PMC GLP-1 adverse-effects review). Generic mood entries that - * never touch the chips keep their tag input byte-identical to v1.4.24. - */ -const GLP1_SIDE_EFFECT_KEYS = [ - "medications.sideEffectTagNausea", - "medications.sideEffectTagConstipation", - "medications.sideEffectTagDiarrhea", - "medications.sideEffectTagFatigue", - "medications.sideEffectTagAppetiteLoss", - "medications.sideEffectTagHeartburn", - "medications.sideEffectTagHeadache", -] as const; - interface MoodFormProps { onSuccess?: () => void; onCancel?: () => void; @@ -491,18 +477,19 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) { autoCapitalize="none" autoComplete="off" /> - {/* v1.4.25 W4d — GLP-1 side-effect quick-tags. Tapping a chip - appends the localised label to the free-text tag list. - Always visible for now (cheap UX; the Coach side-effect - aggregator filters on the canonical English tag set so the - German labels still register correctly). */} + {/* GLP-1 side-effect quick-tags. Tapping a chip appends the + localised label to the free-text tag list. The catalogue is + shared with the server-side matcher, which indexes every shipped + locale's label back to the chip's key — so what a chip writes is + what the timeline, the Coach snapshot and the doctor report + count, in all six languages. */}

{t("medications.sideEffectTagsHelp")}

- {GLP1_SIDE_EFFECT_KEYS.map((key) => { - const label = t(key); + {GLP1_SIDE_EFFECT_TAGS.map(({ key, labelKey }) => { + const label = t(labelKey); const tags = tagsInput .split(",") .map((p) => p.trim().toLowerCase()); diff --git a/src/lib/ai/coach/__tests__/glp1-snapshot.test.ts b/src/lib/ai/coach/__tests__/glp1-snapshot.test.ts index 0e507c45e..1d93cc3c8 100644 --- a/src/lib/ai/coach/__tests__/glp1-snapshot.test.ts +++ b/src/lib/ai/coach/__tests__/glp1-snapshot.test.ts @@ -201,3 +201,63 @@ describe("buildGlp1SnapshotBlock", () => { expect(out?.medications[0].penInventory).toBeNull(); }); }); + +/** + * The Coach's side-effect tally used to match the stored mood tag against a + * hand-written English/German word list. Four of the six shipped locales + * matched nothing, so the Coach grounded its answers about side-effect timing + * on an empty list and never said the list was empty for a reason. + */ +describe("buildGlp1SnapshotBlock side-effect tags across locales", () => { + function moodDays(...tagSets: string[][]) { + return tagSets.map((tags, i) => ({ + moodLoggedAt: new Date(Date.UTC(2026, 3, 10 + i)), + tags: JSON.stringify(tags), + })); + } + + it("counts a French-labelled tag exactly as an English one", async () => { + prismaMock.medication.findMany.mockResolvedValue([fakeMedication()]); + + prismaMock.moodEntry.findMany.mockResolvedValue( + moodDays(["nausea"], ["headache"]), + ); + const en = await buildGlp1SnapshotBlock("user-1"); + + prismaMock.moodEntry.findMany.mockResolvedValue( + moodDays(["Nausées"], ["Maux de tête"]), + ); + const fr = await buildGlp1SnapshotBlock("user-1"); + + expect(fr?.medications[0].sideEffects).toEqual( + en?.medications[0].sideEffects, + ); + expect(fr?.medications[0].sideEffects).toEqual([ + { tag: "nausea", count: 1 }, + { tag: "headache", count: 1 }, + ]); + }); + + it("reports the catalogue key rather than the language it was typed in", async () => { + prismaMock.medication.findMany.mockResolvedValue([fakeMedication()]); + prismaMock.moodEntry.findMany.mockResolvedValue( + moodDays(["Nudności"], ["Übelkeit"], ["Náuseas"]), + ); + const out = await buildGlp1SnapshotBlock("user-1"); + // One symptom, three languages, one grounded fact for the model. + expect(out?.medications[0].sideEffects).toEqual([ + { tag: "nausea", count: 3 }, + ]); + }); + + it("leaves free-text mood tags out of the block", async () => { + prismaMock.medication.findMany.mockResolvedValue([fakeMedication()]); + prismaMock.moodEntry.findMany.mockResolvedValue( + moodDays(["gym", "Feierabend"], ["Zgaga"]), + ); + const out = await buildGlp1SnapshotBlock("user-1"); + expect(out?.medications[0].sideEffects).toEqual([ + { tag: "heartburn", count: 1 }, + ]); + }); +}); diff --git a/src/lib/ai/coach/glp1-snapshot.ts b/src/lib/ai/coach/glp1-snapshot.ts index cbfeb6faf..87373e495 100644 --- a/src/lib/ai/coach/glp1-snapshot.ts +++ b/src/lib/ai/coach/glp1-snapshot.ts @@ -21,6 +21,8 @@ import { prisma } from "@/lib/db"; import { sanitizeForPrompt } from "@/lib/insights/sanitize"; import { readNote } from "@/lib/crypto/note-cipher"; +import { matchGlp1SideEffectTags } from "@/lib/medications/glp1-side-effect-tag-match"; +import type { Glp1SideEffectTag } from "@/lib/medications/glp1-side-effect-tags"; /** * Recommended generic name for the canonical GLP-1 drug brand. The Coach @@ -118,7 +120,13 @@ interface PenInventory { } interface SideEffectSummary { - tag: string; + /** + * The catalogue KEY, not the string the entry was written with. The model + * gets one stable vocabulary for a symptom instead of whichever language the + * chip was tapped in, which also keeps the block's wording out of the + * prompt-injection surface the free-text columns need sanitising for. + */ + tag: Glp1SideEffectTag; count: number; } @@ -144,50 +152,6 @@ interface RawMoodEntry { tags: string | null; } -const SIDE_EFFECT_TAGS = new Set([ - "nausea", - "constipation", - "diarrhea", - "fatigue", - "appetite-loss", - "heartburn", - "headache", - "vomiting", - "reflux", - // German variants the mood-tag picker exposes — the maintainer's userbase types - // in both languages and we don't want the snapshot to miss "Übelkeit" - // because the user picked the German chip. - "übelkeit", - "verstopfung", - "durchfall", - "müdigkeit", - "appetitlosigkeit", - "sodbrennen", - "kopfschmerzen", - "erbrechen", -]); - -function parseTagList(raw: string | null): string[] { - if (!raw) return []; - // The MoodEntry.tags column carries either a JSON array (modern - // mood-form writes) or a comma-separated list (legacy imported - // imports). Be permissive. - try { - const parsed = JSON.parse(raw); - if (Array.isArray(parsed)) { - return parsed - .map((v) => (typeof v === "string" ? v.trim() : "")) - .filter(Boolean); - } - } catch { - /* fall through to CSV */ - } - return raw - .split(",") - .map((p) => p.trim()) - .filter(Boolean); -} - function weeksBetween(from: Date, to: Date): number { const ms = to.getTime() - from.getTime(); return Math.max(0, Math.round(ms / (7 * 24 * 60 * 60 * 1000))); @@ -297,11 +261,9 @@ export async function buildGlp1SnapshotBlock( where: { userId, deletedAt: null, moodLoggedAt: { gte: moodCutoff } }, select: { moodLoggedAt: true, tags: true }, }); - const tagCounts = new Map(); + const tagCounts = new Map(); for (const row of moods) { - for (const rawTag of parseTagList(row.tags)) { - const tag = rawTag.toLowerCase(); - if (!SIDE_EFFECT_TAGS.has(tag)) continue; + for (const tag of matchGlp1SideEffectTags(row.tags).matched) { tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1); } } diff --git a/src/lib/doctor-report-pdf/medication-mood-wellness-section.ts b/src/lib/doctor-report-pdf/medication-mood-wellness-section.ts index 8565769c3..ce7929457 100644 --- a/src/lib/doctor-report-pdf/medication-mood-wellness-section.ts +++ b/src/lib/doctor-report-pdf/medication-mood-wellness-section.ts @@ -6,6 +6,7 @@ import { type DoctorReportPdfCursorState, type DoctorReportPdfRenderContext, } from "./render-context"; +import { GLP1_SIDE_EFFECT_TAG_LABEL_KEYS } from "../medications/glp1-side-effect-tags"; const MOOD_LABEL_KEYS: Record = { 1: "doctorReport.moodAwful", @@ -242,7 +243,12 @@ export function buildMedicationMoodWellnessSection( } if (data.glp1.sideEffects.length > 0) { - const seRows = data.glp1.sideEffects.map((s) => [s.tag, String(s.count)]); + // The tally arrives keyed, so the clinician reads the symptom in the + // report's language regardless of the one it was captured in. + const seRows = data.glp1.sideEffects.map((s) => [ + t(GLP1_SIDE_EFFECT_TAG_LABEL_KEYS[s.tag]), + String(s.count), + ]); // Keep the heading with the table; the side-effect tally reads as one // block. y = ensureSpace(y, 5 + estimateTableHeight(seRows.length)); diff --git a/src/lib/doctor-report-types.ts b/src/lib/doctor-report-types.ts index c342f50e8..34cb5605a 100644 --- a/src/lib/doctor-report-types.ts +++ b/src/lib/doctor-report-types.ts @@ -14,6 +14,7 @@ import type { GlucoseUnit } from "@/lib/glucose"; import type { GlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics"; import type { MeasurementSource } from "@/generated/prisma/client"; import type { ModuleKey } from "@/lib/modules/gate"; +import type { Glp1SideEffectTag } from "@/lib/medications/glp1-side-effect-tags"; import type { ComplianceSchedule, IntakeEvent, @@ -186,7 +187,13 @@ export interface DoctorReportData { weightDeltaKg: number | null; weightStartKg: number | null; weightEndKg: number | null; - sideEffects: Array<{ tag: string; count: number }>; + /** + * Side-effect tallies keyed by `Glp1SideEffectTag`, never by the string the + * mood entry was written with: the report renders in the reader's language + * and the tag may have been captured in another one. The PDF resolves each + * key through `GLP1_SIDE_EFFECT_TAG_LABEL_KEYS`. + */ + sideEffects: Array<{ tag: Glp1SideEffectTag; count: number }>; } | null; /** * v1.10.0 — the server-derived nightly wellness scores diff --git a/src/lib/doctor-report/__tests__/glp1-side-effect-locales.test.ts b/src/lib/doctor-report/__tests__/glp1-side-effect-locales.test.ts new file mode 100644 index 000000000..fb6aefeb7 --- /dev/null +++ b/src/lib/doctor-report/__tests__/glp1-side-effect-locales.test.ts @@ -0,0 +1,82 @@ +/** + * The GLP-1 block of the doctor report must tally a side effect the same way + * whatever language the patient recorded it in. + * + * It did not. The tally matched the stored tag string against a hand-written + * English/German word list, so a clinician reading a French, Spanish, Italian + * or Polish patient's report saw an empty side-effect table over a record that + * had three months of them — and nothing on the page said the table was a + * filter rather than the truth. + */ +import { describe, expect, it } from "vitest"; + +import { buildGlp1Block } from "../medications"; +import type { MedicationRowForReport } from "../medications"; + +const MED: MedicationRowForReport = { + id: "med-1", + name: "Mounjaro", + dose: "7.5 mg", + treatmentClass: "GLP1", + doseChanges: [ + { + effectiveFrom: new Date("2026-04-01T00:00:00.000Z"), + doseValue: 7.5, + doseUnit: "mg", + note: null, + noteEncrypted: null, + }, + ], + intakeEvents: [], +} as unknown as MedicationRowForReport; + +function block(tagRows: string[]) { + return buildGlp1Block({ + medications: [MED], + compliance: {}, + weightSeries: [], + moodTagRows: tagRows.map((tags) => ({ tags })), + }); +} + +describe("doctor report GLP-1 side-effect tally across locales", () => { + it("tallies the French chip label exactly as the English one", () => { + const en = block([JSON.stringify(["nausea", "headache"])]); + const fr = block([JSON.stringify(["Nausées", "Maux de tête"])]); + expect(fr?.sideEffects).toEqual(en?.sideEffects); + expect(fr?.sideEffects).toEqual([ + { tag: "nausea", count: 1 }, + { tag: "headache", count: 1 }, + ]); + }); + + it("tallies Spanish, Italian and Polish chip labels", () => { + expect(block([JSON.stringify(["Estreñimiento"])])?.sideEffects).toEqual([ + { tag: "constipation", count: 1 }, + ]); + expect(block([JSON.stringify(["Affaticamento"])])?.sideEffects).toEqual([ + { tag: "fatigue", count: 1 }, + ]); + expect(block([JSON.stringify(["Zgaga"])])?.sideEffects).toEqual([ + { tag: "heartburn", count: 1 }, + ]); + }); + + it("adds up one symptom recorded in several languages", () => { + // An account that switched UI language mid-therapy holds both spellings. + // They are one symptom and must count as three days of it, not as two + // unrelated rows or one. + const out = block([ + JSON.stringify(["nausea"]), + JSON.stringify(["Übelkeit"]), + JSON.stringify(["Nudności"]), + ]); + expect(out?.sideEffects).toEqual([{ tag: "nausea", count: 3 }]); + }); + + it("still refuses free text, so no invented symptom reaches a clinician", () => { + expect( + block([JSON.stringify(["gym", "date night", "Feierabend"])])?.sideEffects, + ).toEqual([]); + }); +}); diff --git a/src/lib/doctor-report/medications.ts b/src/lib/doctor-report/medications.ts index 517a0b650..fa1def120 100644 --- a/src/lib/doctor-report/medications.ts +++ b/src/lib/doctor-report/medications.ts @@ -16,6 +16,8 @@ import type { DoctorReportData, } from "@/lib/doctor-report-types"; import { resolveMaxMedicationAdministrations } from "@/lib/doctor-report-helpers"; +import { matchGlp1SideEffectTags } from "@/lib/medications/glp1-side-effect-tag-match"; +import type { Glp1SideEffectTag } from "@/lib/medications/glp1-side-effect-tags"; /** The medication columns the ledger and the GLP-1 block read. */ export interface MedicationRowForReport { @@ -140,44 +142,6 @@ export function buildAdministrationLedger( }; } -/** - * Mood tags that mark a GLP-1 side effect, in both languages the tag - * vocabulary ships. A tag not on this list is never counted, so a free-text - * tag cannot leak into the block as an invented side effect. - */ -const SIDE_EFFECT_TAGS = new Set([ - "nausea", - "constipation", - "diarrhea", - "fatigue", - "appetite-loss", - "heartburn", - "headache", - "übelkeit", - "verstopfung", - "durchfall", - "müdigkeit", - "appetitlosigkeit", - "sodbrennen", - "kopfschmerzen", -]); - -function parseTags(raw: string): string[] { - try { - const parsed = JSON.parse(raw); - return Array.isArray(parsed) - ? parsed - .map((v: unknown) => (typeof v === "string" ? v.trim() : "")) - .filter(Boolean) - : []; - } catch { - return raw - .split(",") - .map((p) => p.trim()) - .filter(Boolean); - } -} - /** * The GLP-1 therapy block: current dose, dose history, last injection, the * weight delta over the window, and side-effect tallies. @@ -205,12 +169,16 @@ export function buildGlp1Block(params: { ? Math.round((weightEndKg - weightStartKg) * 10) / 10 : null; - const sideEffectCounts = new Map(); + // Tallied by catalogue KEY, in every language the app ships. The tally used + // to match the stored label against an English/German word list, so a + // clinician reading a French, Spanish, Italian or Polish patient's report + // saw "no side effects logged" over a record that had them. A tag outside + // the catalogue is still never counted — a free-text mood tag must not + // reach a doctor as an invented symptom. + const sideEffectCounts = new Map(); for (const row of params.moodTagRows) { - for (const tag of parseTags(row.tags ?? "")) { - const lower = tag.toLowerCase(); - if (!SIDE_EFFECT_TAGS.has(lower)) continue; - sideEffectCounts.set(lower, (sideEffectCounts.get(lower) ?? 0) + 1); + for (const tag of matchGlp1SideEffectTags(row.tags).matched) { + sideEffectCounts.set(tag, (sideEffectCounts.get(tag) ?? 0) + 1); } } diff --git a/src/lib/medications/__tests__/glp1-side-effect-tags.test.ts b/src/lib/medications/__tests__/glp1-side-effect-tags.test.ts new file mode 100644 index 000000000..77154b5df --- /dev/null +++ b/src/lib/medications/__tests__/glp1-side-effect-tags.test.ts @@ -0,0 +1,196 @@ +/** + * The GLP-1 side-effect tag catalogue, and the property the three readers + * used to get wrong: a tag recorded in ANY shipped locale resolves to the same + * key, so it contributes exactly as much as the English one. + * + * Before this module the readers matched the stored LABEL against a + * hand-written English/German word list. A French, Spanish, Italian or Polish + * account tapping the same chip contributed nothing to the timeline, the Coach + * snapshot or the doctor report, and nothing said so. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { locales } from "@/lib/i18n/config"; +import { resolveKey } from "@/lib/i18n/resolve-key"; +import { + GLP1_SIDE_EFFECT_TAGS, + GLP1_SIDE_EFFECT_TAG_KEYS, + GLP1_SIDE_EFFECT_TAG_LABEL_KEYS, + normaliseSideEffectTag, + parseMoodTagList, +} from "../glp1-side-effect-tags"; +import { + glp1SideEffectTagIndex, + matchGlp1SideEffectTags, + resolveGlp1SideEffectTag, +} from "../glp1-side-effect-tag-match"; + +const ROOT = join(__dirname, "../../../.."); + +function bundle(locale: string): Record { + return JSON.parse( + readFileSync(join(ROOT, `messages/${locale}.json`), "utf8"), + ) as Record; +} + +describe("GLP-1 side-effect tag catalogue", () => { + it("resolves the chip label of every shipped locale to the same key", () => { + for (const { key, labelKey } of GLP1_SIDE_EFFECT_TAGS) { + for (const locale of locales) { + const label = resolveKey(bundle(locale), labelKey); + expect( + label, + `${labelKey} missing from messages/${locale}.json`, + ).toBeTruthy(); + expect( + resolveGlp1SideEffectTag(label as string), + `${locale} label "${label}" must resolve to ${key}`, + ).toBe(key); + } + } + }); + + it("counts a French-labelled tag exactly as an English one", () => { + // The reported defect, stated as an equality rather than a spot check. + for (const { key, labelKey } of GLP1_SIDE_EFFECT_TAGS) { + const en = resolveKey(bundle("en"), labelKey) as string; + const fr = resolveKey(bundle("fr"), labelKey) as string; + expect(matchGlp1SideEffectTags(JSON.stringify([fr]))).toEqual( + matchGlp1SideEffectTags(JSON.stringify([en])), + ); + expect(matchGlp1SideEffectTags(JSON.stringify([fr])).matched).toEqual([ + key, + ]); + } + }); + + it("resolves the Spanish, Italian and Polish chip labels", () => { + // Named explicitly so a regression reads as "Polish stopped working" + // rather than as an anonymous loop failure. + expect(resolveGlp1SideEffectTag("Náuseas")).toBe("nausea"); + expect(resolveGlp1SideEffectTag("Pérdida de apetito")).toBe( + "appetite-loss", + ); + expect(resolveGlp1SideEffectTag("Bruciore di stomaco")).toBe("heartburn"); + expect(resolveGlp1SideEffectTag("Stitichezza")).toBe("constipation"); + expect(resolveGlp1SideEffectTag("Nudności")).toBe("nausea"); + expect(resolveGlp1SideEffectTag("Ból głowy")).toBe("headache"); + expect(resolveGlp1SideEffectTag("Zmęczenie")).toBe("fatigue"); + }); + + it("keeps matching the English and German tags already in the database", () => { + // The pre-fix word list is the historical write vocabulary; every string + // on it must survive the swap or existing records lose their history. + for (const legacy of [ + "nausea", + "constipation", + "diarrhea", + "fatigue", + "appetite-loss", + "heartburn", + "headache", + "übelkeit", + "verstopfung", + "durchfall", + "müdigkeit", + "appetitlosigkeit", + "sodbrennen", + "kopfschmerzen", + ]) { + expect( + resolveGlp1SideEffectTag(legacy), + `legacy tag "${legacy}" must still resolve`, + ).not.toBeNull(); + } + }); + + it("tolerates case, accents and separator drift", () => { + expect(resolveGlp1SideEffectTag(" NAUSEA ")).toBe("nausea"); + expect(resolveGlp1SideEffectTag("ubelkeit")).toBe("nausea"); + expect(resolveGlp1SideEffectTag("appetite loss")).toBe("appetite-loss"); + // The French bundle uses a typographic apostrophe; a keyboard produces a + // straight one. + expect(resolveGlp1SideEffectTag("Perte d'appétit")).toBe("appetite-loss"); + expect(resolveGlp1SideEffectTag("Perte d’appétit")).toBe("appetite-loss"); + }); + + it("refuses free text rather than inventing a side effect", () => { + for (const free of ["gym", "date night", "Arbeit", "", " "]) { + expect(resolveGlp1SideEffectTag(free)).toBeNull(); + } + }); + + it("counts what it could not classify instead of dropping it silently", () => { + const match = matchGlp1SideEffectTags( + JSON.stringify(["Nudności", "siłownia", "rodzina"]), + ); + expect(match.matched).toEqual(["nausea"]); + expect(match.unresolvedCount).toBe(2); + }); + + it("deduplicates a key reached through two different spellings", () => { + const match = matchGlp1SideEffectTags( + JSON.stringify(["nausea", "Übelkeit", "Náuseas"]), + ); + expect(match.matched).toEqual(["nausea"]); + expect(match.unresolvedCount).toBe(0); + }); + + it("reads both stored column shapes", () => { + expect(parseMoodTagList('["nausea","gym"]')).toEqual(["nausea", "gym"]); + expect(parseMoodTagList("nausea, gym")).toEqual(["nausea", "gym"]); + expect(parseMoodTagList(null)).toEqual([]); + expect(matchGlp1SideEffectTags("Übelkeit, Sport").matched).toEqual([ + "nausea", + ]); + }); + + it("maps every key to a label key that resolves in all six locales", () => { + for (const key of GLP1_SIDE_EFFECT_TAG_KEYS) { + const labelKey = GLP1_SIDE_EFFECT_TAG_LABEL_KEYS[key]; + expect(labelKey, `${key} has no label key`).toBeTruthy(); + for (const locale of locales) { + expect( + resolveKey(bundle(locale), labelKey), + `${labelKey} missing from messages/${locale}.json`, + ).toBeTruthy(); + } + } + }); + + it("folds no two catalogue entries onto the same index row", () => { + // A collision would make one symptom shadow another. Cheap to assert, and + // the only way a new locale can break the index. + const index = glp1SideEffectTagIndex(); + const byKey = new Map(); + for (const [folded, key] of index) { + byKey.set(key, [...(byKey.get(key) ?? []), folded]); + } + expect([...byKey.keys()].sort()).toEqual( + [...GLP1_SIDE_EFFECT_TAG_KEYS].sort(), + ); + // Every catalogue entry carries at least its own key plus the six labels + // it ships under, minus whatever two locales happen to spell identically. + for (const key of GLP1_SIDE_EFFECT_TAG_KEYS) { + expect( + (byKey.get(key) ?? []).length, + `${key} resolves from too few spellings — a locale is missing`, + ).toBeGreaterThanOrEqual(2); + } + }); + + it("covers a locale the day it is added, with no edit here", () => { + // The property that closes the class: every locale in `locales` has its + // labels in the index already. Adding messages/xx.json and listing it in + // `locales` is the whole change. + const index = glp1SideEffectTagIndex(); + for (const locale of locales) { + for (const { key, labelKey } of GLP1_SIDE_EFFECT_TAGS) { + const label = resolveKey(bundle(locale), labelKey) as string; + expect(index.get(normaliseSideEffectTag(label))).toBe(key); + } + } + }); +}); diff --git a/src/lib/medications/glp1-side-effect-tag-match.ts b/src/lib/medications/glp1-side-effect-tag-match.ts new file mode 100644 index 000000000..1dcd7d880 --- /dev/null +++ b/src/lib/medications/glp1-side-effect-tag-match.ts @@ -0,0 +1,124 @@ +/** + * The GLP-1 side-effect tag catalogue — server-only matching half. + * + * Turns a stored flat mood tag back into the catalogue key it was written + * from, in any locale the app ships. + * + * The index is DERIVED from `messages/.json`, not transcribed from it. + * That is the load-bearing part. The three readers this replaces each carried + * their own hand-written word list, covering English and German because those + * were the two languages the maintainer typed in; the four other shipped + * locales silently matched nothing, and the three lists had already drifted + * apart from each other. A transcription would fix today's four locales and + * reopen the same hole the day a seventh is added. Reading the bundles means a + * new locale needs no edit in this file — `locales` grows, `messages/xx.json` + * lands, and the labels that ship in it are matchable the same day. + * + * `allMessages` statically imports all six bundles and is SERVER-ONLY: do not + * import this module from a client component. The three callers (the timeline + * route, the Coach snapshot builder, the doctor-report aggregator) all run on + * the server. A browser that needs to render a key resolves it through + * `GLP1_SIDE_EFFECT_TAG_LABEL_KEYS` in the isomorphic half instead. + * + * Known limit, stated rather than engineered around: the index is built from + * the labels a bundle carries TODAY, and it is applied to tags written in the + * past. Editing a shipped translation therefore orphans the rows written under + * the old wording. If a label has to change, keep the old string reachable — + * the fix is a legacy alias here, not a data migration. + */ +import { locales } from "@/lib/i18n/config"; +import { allMessages, resolveKey } from "@/lib/i18n/shared-resolve"; +import { + GLP1_SIDE_EFFECT_TAGS, + normaliseSideEffectTag, + parseMoodTagList, + type Glp1SideEffectTag, +} from "./glp1-side-effect-tags"; + +/** + * Normalised label (and canonical key) → catalogue key, across every shipped + * locale. Built once at module load: 7 entries × 6 bundles is a handful of + * string folds, and the bundles are already resident for the server + * translator. + * + * Two locales spelling one catalogue entry the same way ("Constipation" in + * English and French) is expected and folds onto one row. Two DIFFERENT + * entries folding together would be a bundle bug; it resolves first-wins here + * — a typo must not take down every GLP-1 surface at boot — and the guard + * suite fails on it through `glp1SideEffectTagIndex()`. + */ +function buildIndex(): Map { + const index = new Map(); + for (const { key, labelKey } of GLP1_SIDE_EFFECT_TAGS) { + // The canonical key itself, so a client or importer that already speaks + // keys round-trips without going through a label. + index.set(normaliseSideEffectTag(key), key); + for (const locale of locales) { + const label = resolveKey(allMessages[locale], labelKey); + if (!label) continue; + const folded = normaliseSideEffectTag(label); + if (!folded || index.has(folded)) continue; + index.set(folded, key); + } + } + return index; +} + +const TAG_INDEX = buildIndex(); + +/** + * Test seam: the guard suite asserts the index covers every locale and that no + * two catalogue entries fold together. Production reads `TAG_INDEX` directly. + */ +export function glp1SideEffectTagIndex(): ReadonlyMap< + string, + Glp1SideEffectTag +> { + return TAG_INDEX; +} + +/** + * A single stored tag → its catalogue key, or `null` when the tag is not a + * catalogue side effect. + * + * `null` is the honest answer for genuine free text ("gym", "date night"): + * those are mood tags, not side effects, and admitting them would invent + * symptoms in a doctor's report. What can no longer produce `null` is a tag + * the app's own chip strip wrote, in any shipped language. + */ +export function resolveGlp1SideEffectTag( + raw: string, +): Glp1SideEffectTag | null { + return TAG_INDEX.get(normaliseSideEffectTag(raw)) ?? null; +} + +/** What one `MoodEntry.tags` column contributed. */ +export interface Glp1SideEffectTagMatch { + /** Catalogue keys found, deduplicated, in the order they were written. */ + matched: Glp1SideEffectTag[]; + /** + * How many tags on the row were not catalogue entries. Carried so callers + * can report the miss rate instead of dropping the tags without trace; it is + * a COUNT and never the tag text, because the text is the user's own prose + * and these surfaces (a therapy timeline, a clinician PDF, a model prompt) + * are exactly where it must not turn up uninvited. + */ + unresolvedCount: number; +} + +/** Parse a stored tags column and resolve every tag on it. */ +export function matchGlp1SideEffectTags( + rawTagsColumn: string | null | undefined, +): Glp1SideEffectTagMatch { + const matched: Glp1SideEffectTag[] = []; + let unresolvedCount = 0; + for (const raw of parseMoodTagList(rawTagsColumn)) { + const key = resolveGlp1SideEffectTag(raw); + if (!key) { + unresolvedCount += 1; + continue; + } + if (!matched.includes(key)) matched.push(key); + } + return { matched, unresolvedCount }; +} diff --git a/src/lib/medications/glp1-side-effect-tags.ts b/src/lib/medications/glp1-side-effect-tags.ts new file mode 100644 index 000000000..3566729dc --- /dev/null +++ b/src/lib/medications/glp1-side-effect-tags.ts @@ -0,0 +1,122 @@ +/** + * The GLP-1 side-effect tag catalogue — isomorphic half. + * + * A mood entry carries two tag axes: the structured `MoodEntryTagLink` + * taxonomy, and the flat free-text `MoodEntry.tags` column. The GLP-1 + * side-effect chips in the mood form write onto the FLAT axis, and they write + * `t(labelKey)` — the label in whatever language the writer's UI was in. So + * the column holds "nausea" for an English account and "Nudności" for a Polish + * one, for the same recorded symptom. + * + * The chips are nonetheless a CLOSED set with stable keys. That is the whole + * point of this module: the timeline, the Coach snapshot and the doctor report + * used to match the stored label against a hand-written English/German word + * list, which meant a French, Spanish, Italian or Polish account recorded a + * side effect and every downstream surface counted zero. Matching the label + * was the defect; the key is the fix. + * + * This file holds only what a browser may also need — the key set and each + * key's label key, so the PDF renderer can print a canonical key in the + * reader's language. The label → key direction needs every locale's bundle and + * therefore lives server-side in `./glp1-side-effect-tag-match`. + */ + +/** + * The catalogue, in display order. `key` is the stable machine value that + * crosses the API and the Coach prompt; `labelKey` is the i18n key the chip + * renders and — through the six bundles — the set of strings that can appear + * in the stored column. + * + * Adding an entry here is a product decision about what the capture UI asks: + * the strip is deliberately short, covering the symptoms clinicians ask about + * at a GLP-1 follow-up. Adding a LOCALE, by contrast, needs no edit here at + * all — the matcher reads whatever `messages/.json` says. + * + * The Coach's own copy of the old word list also carried `vomiting`, `reflux` + * and `erbrechen`. Those are gone on purpose. No chip writes them, so they + * only ever matched a hand-typed tag, and only in English and German — an + * English speaker who typed "vomiting" got a line and an Italian who typed + * "vomito" did not. Keeping them would have rebuilt the defect inside its own + * fix. Bringing either symptom back means giving it a chip and a label in all + * six bundles, which is the product decision above. + */ +export const GLP1_SIDE_EFFECT_TAGS = [ + { key: "nausea", labelKey: "medications.sideEffectTagNausea" }, + { key: "constipation", labelKey: "medications.sideEffectTagConstipation" }, + { key: "diarrhea", labelKey: "medications.sideEffectTagDiarrhea" }, + { key: "fatigue", labelKey: "medications.sideEffectTagFatigue" }, + { key: "appetite-loss", labelKey: "medications.sideEffectTagAppetiteLoss" }, + { key: "heartburn", labelKey: "medications.sideEffectTagHeartburn" }, + { key: "headache", labelKey: "medications.sideEffectTagHeadache" }, +] as const; + +/** Stable machine key of a catalogue side effect. */ +export type Glp1SideEffectTag = (typeof GLP1_SIDE_EFFECT_TAGS)[number]["key"]; + +/** Catalogue order, for callers that only need the keys. */ +export const GLP1_SIDE_EFFECT_TAG_KEYS: readonly Glp1SideEffectTag[] = + GLP1_SIDE_EFFECT_TAGS.map((t) => t.key); + +/** + * Key → i18n label key. Every surface that shows a canonical key to a person + * (the doctor-report PDF, any client rendering the timeline) resolves it + * through here so the reader sees their own language rather than the language + * the tag happened to be typed in. + */ +export const GLP1_SIDE_EFFECT_TAG_LABEL_KEYS: Record< + Glp1SideEffectTag, + string +> = Object.fromEntries( + GLP1_SIDE_EFFECT_TAGS.map((t) => [t.key, t.labelKey]), +) as Record; + +/** + * Fold a tag string to its comparison form. + * + * Applied identically to the stored tag and to every catalogue label, so the + * two only have to agree up to case, accents and separators. That tolerance is + * what lets "Perte d'appétit" typed with a straight quote match the bundle's + * "Perte d’appétit", and "ubelkeit" match "Übelkeit" — without it the matcher + * would be exact-string again, one keyboard away from the defect it replaces. + * + * Note the tolerance is one-directional folding, not transliteration: German + * "ue" for "ü" does not fold to the same form, and the Polish ł is a single + * codepoint that NFD leaves alone. Both are fine — the label the chip wrote is + * the label the matcher indexes. + */ +export function normaliseSideEffectTag(raw: string): string { + return raw + .toLowerCase() + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/[\u2018\u2019\u02bc'`]/g, " ") + .replace(/[-_/]/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +/** + * Read the flat `MoodEntry.tags` column. + * + * The column carries either a JSON array (mood-form writes) or a + * comma-separated list (legacy imports). Be permissive — this used to be + * copy-pasted into all three readers, which is how their tag vocabularies + * drifted apart in the first place. + */ +export function parseMoodTagList(raw: string | null | undefined): string[] { + if (!raw) return []; + try { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) { + return parsed + .map((v) => (typeof v === "string" ? v.trim() : "")) + .filter(Boolean); + } + } catch { + /* fall through to the CSV form */ + } + return raw + .split(",") + .map((p) => p.trim()) + .filter(Boolean); +} diff --git a/src/lib/openapi/routes/insights/schemas.ts b/src/lib/openapi/routes/insights/schemas.ts index c7d9dd371..31778b692 100644 --- a/src/lib/openapi/routes/insights/schemas.ts +++ b/src/lib/openapi/routes/insights/schemas.ts @@ -2606,10 +2606,20 @@ export const glp1TimelineResponse = z inventoryDelta: z.number().int().optional(), reason: z.string().optional(), tags: z - .array(z.string()) + .array( + z.enum([ + "nausea", + "constipation", + "diarrhea", + "fatigue", + "appetite-loss", + "heartburn", + "headache", + ]), + ) .optional() .describe( - "The recognised side-effect tags picked up that day. Matched against a FIXED bilingual list of English and German tag strings, so a tag written in any other language — or spelled differently — does not appear here at all.", + "Catalogue keys of the side effects tagged that day, deduplicated. These are STABLE KEYS, not the strings the entries were written with: the capture chip writes its label in the writer's language, and the matcher indexes every shipped locale's label back to the key, so the same recorded symptom yields the same key whether it was tagged in German, English, Spanish, French, Italian or Polish. Render the key in the reader's own language rather than echoing it. A mood tag that is not a catalogue side effect — anything the user typed freehand — is not reported here; it is not a side effect, and a therapy timeline is not the place for the rest of somebody's day.", ), }), ) From 2dc553057d20118dc72e6db365c824d851b9a5f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 23 Aug 2026 14:16:15 +0200 Subject: [PATCH 2/8] fix(labs): read a reference range printed in French, Spanish, Italian or Polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The free-text reference-range parser decided what a printed window meant from an alternation of German and English words. The app ships six locales, and a lab prints in the language of the country it stands in, so `jusqu'à 5,0`, `hasta 5,0`, `fino a 5,0` and `do 5,0` all fell through to `low: null, high: null`. The string was stored, the reading was then judged against the biomarker's own band or against nothing at all, and no surface anywhere said that the lab had printed a window the app could not read. The vocabulary is TABULATED here, not derived from `messages/.json` the way an app-owned word list would be. These words are prose a lab printed on paper; the app owns no string they could be read out of, and the language of a report has nothing to do with the language of the UI. So the table is hand-written, keyed by the shipped `Locale` union so that adding a locale fails to compile until someone has answered how that language prints a range, and every locale's words are tried against every input at once. The cost is written down at the table: a report in a language absent from it stays text-only. Three things had to change beside the word lists. Introducing words are now stripped only on a second pass, because Italian "da", Polish "od" and French "de" also state a floor on their own and stripping first read "da 3,5" as a bare number. Input and table are diacritic-folded and their apostrophes normalised, so a table row written "jusqu'a" meets a report's "jusqu'à" and the curled apostrophe a word processor emits. And one-letter separators ("a", "e", "y") are only accepted with whitespace on both sides, so "3.5e5" is not read as a window. Decimal commas already worked and are pinned by the new cases. An unreadable window now has a name. `isUnreadableRange` separates "the report stated nothing" from "the report stated something nobody could read" — the two were indistinguishable at every call site, which is what let the French case pass unnoticed. The manual form already showed the reader that the text was kept but not read; the two ingest paths now count it into their wide event, so an unsupported notation surfaces as a number rather than as silence. The German and English lists are the previous alternation split word for word, and the existing suite covering them passes unchanged. --- src/components/labs/lab-form.tsx | 7 +- src/lib/documents/commit.ts | 19 +- src/lib/i18n/fold-for-match.ts | 59 +++ src/lib/i18n/shared-resolve.ts | 31 +- .../parse-reference-range-locales.test.ts | 197 ++++++++++ src/lib/labs/ocr-extract.ts | 15 +- src/lib/labs/parse-reference-range.ts | 359 ++++++++++++++++-- 7 files changed, 647 insertions(+), 40 deletions(-) create mode 100644 src/lib/i18n/fold-for-match.ts create mode 100644 src/lib/labs/__tests__/parse-reference-range-locales.test.ts diff --git a/src/components/labs/lab-form.tsx b/src/components/labs/lab-form.tsx index 6403444d8..f22d1efe8 100644 --- a/src/components/labs/lab-form.tsx +++ b/src/components/labs/lab-form.tsx @@ -22,7 +22,10 @@ import { import { Textarea } from "@/components/ui/textarea"; import { apiGet, apiPost } from "@/lib/api/api-fetch"; import { localizedApiError } from "@/lib/api/localized-error"; -import { parseReferenceRange } from "@/lib/labs/parse-reference-range"; +import { + isUnreadableRange, + parseReferenceRange, +} from "@/lib/labs/parse-reference-range"; import { formatReferenceRange } from "@/lib/labs/reference-range"; import { formatLabValue } from "@/lib/labs/format-value"; import { EncounterSuggestionField } from "@/components/encounters/encounter-suggestion-field"; @@ -300,7 +303,7 @@ export function LabForm({ // the hint says the text is kept as written rather than staying silent. const parsedSourceRange = parseReferenceRange(sourceRange, selected?.unit); const sourceRangeHint = parsedSourceRange - ? parsedSourceRange.low === null && parsedSourceRange.high === null + ? isUnreadableRange(parsedSourceRange) ? t("labs.form.sourceRangeUnparsed") : `${formatReferenceRange( parsedSourceRange.low, diff --git a/src/lib/documents/commit.ts b/src/lib/documents/commit.ts index b960b39b9..0cecca25a 100644 --- a/src/lib/documents/commit.ts +++ b/src/lib/documents/commit.ts @@ -28,7 +28,11 @@ import { invalidateUserHealthScore } from "@/lib/cache/invalidate"; import { emitDataArrival } from "@/lib/arrivals/emit-shared"; import { decryptFactData } from "@/lib/documents/store"; import { resolveOrMintBiomarker } from "@/lib/labs/biomarker-store"; -import { parseReferenceRange } from "@/lib/labs/parse-reference-range"; +import { + isUnreadableRange, + parseReferenceRange, +} from "@/lib/labs/parse-reference-range"; +import { annotate } from "@/lib/logging/context"; import type { ExtractedFact } from "@/generated/prisma/client"; import type { ConditionFactData, @@ -82,6 +86,19 @@ async function commitObservation( const sourceLow = printed?.low ?? data.referenceLow; const sourceHigh = printed?.high ?? data.referenceHigh; const sourceText = printed?.text ?? null; + // A window the report printed and the parser could not read is the case that + // used to pass without a trace: the reading is then judged against the + // catalog band, or against nothing, and no surface says the lab had stated + // something else. Count it here so an unreadable notation — a language the + // prose table does not carry, a layout nobody anticipated — shows up as a + // number on a dashboard instead of as silence. The string itself is the + // user's document and stays out of the event. + if (isUnreadableRange(printed)) { + annotate({ + action: { name: "labs.referenceRange.unreadable" }, + meta: { surface: "document.commit", length: printed?.text.length ?? 0 }, + }); + } const biomarker = await resolveOrMintBiomarker(userId, { analyte: data.label, diff --git a/src/lib/i18n/fold-for-match.ts b/src/lib/i18n/fold-for-match.ts new file mode 100644 index 000000000..db0815e91 --- /dev/null +++ b/src/lib/i18n/fold-for-match.ts @@ -0,0 +1,59 @@ +/** + * Character folds used when app text has to be matched against text a PERSON + * or a LAB typed, rather than compared byte-for-byte. + * + * Every matcher that reads a shipped translation and looks for it in stored + * or transcribed text needs the same three tolerances — case, accents, and the + * apostrophe a word processor curled — and every matcher that grows its own + * copy of them drifts from the others. One home, so "Perte d'appétit" typed + * with a straight quote meets the bundle's "Perte d’appétit", and a French + * report's "jusqu'à" meets a table written "jusqu'a". + * + * The folds are one-directional and deliberately NOT transliteration: German + * "ue" does not fold onto "ü", "ß" is left alone (it has no canonical + * decomposition), and the Polish "ł" is a single codepoint NFD does not touch. + * That is fine — what has to agree is a shipped string with itself as a person + * retyped it, not two spellings of the same word. + * + * Client-safe: no message bundles are imported here, so a client component may + * use these. Building an index OVER the bundles is a server concern and lives + * in `./shared-resolve`. + */ + +/** Curly, modifier and backtick apostrophes a keyboard or word processor emits. */ +const APOSTROPHES = /[\u2018\u2019\u02bc\u00b4`']/gu; + +/** Combining marks left behind by an NFD decomposition. */ +const COMBINING_MARKS = /[\u0300-\u036f]/gu; + +/** + * Drop accents by decomposing and removing the combining marks: "é" → "e", + * "ż" → "z", "Ü" → "U". Punctuation, case and spacing survive untouched, so + * this is the fold to use where the surrounding characters carry meaning — a + * printed range's "3,5-5,0", a unit's "mg/dL". + */ +export function stripDiacritics(raw: string): string { + return raw.normalize("NFD").replace(COMBINING_MARKS, ""); +} + +/** Collapse every apostrophe variant onto the straight ASCII one. */ +export function normaliseApostrophes(raw: string): string { + return raw.replace(APOSTROPHES, "'"); +} + +/** + * Fold a label to its comparison form: lower case, no accents, and every + * apostrophe / hyphen / underscore / slash reduced to a single space. + * + * This is the fold for whole LABELS — a tag, an analyte name, a qualitative + * result term — where internal punctuation is noise. Apply it identically to + * both sides of a comparison; a fold applied to only one side is an + * exact-string match wearing a disguise. + */ +export function foldForMatch(raw: string): string { + return stripDiacritics(raw.toLowerCase()) + .replace(APOSTROPHES, " ") + .replace(/[-_/]/gu, " ") + .replace(/\s+/gu, " ") + .trim(); +} diff --git a/src/lib/i18n/shared-resolve.ts b/src/lib/i18n/shared-resolve.ts index f08790d05..fba5d019f 100644 --- a/src/lib/i18n/shared-resolve.ts +++ b/src/lib/i18n/shared-resolve.ts @@ -20,9 +20,10 @@ import frMessages from "../../../messages/fr.json"; import esMessages from "../../../messages/es.json"; import itMessages from "../../../messages/it.json"; import plMessages from "../../../messages/pl.json"; -import { type Locale } from "./config"; +import { locales, type Locale } from "./config"; +import { resolveKey } from "./resolve-key"; -export { resolveKey } from "./resolve-key"; +export { resolveKey }; /** * Single source of truth for the per-locale message bundle on the @@ -36,3 +37,29 @@ export const allMessages: Record> = { it: itMessages, pl: plMessages, }; + +/** + * Every shipped translation of one key, in `locales` order, deduplicated and + * with missing entries dropped. + * + * This is the primitive a DERIVED match index is built from. A matcher that + * has to recognise an app-owned label in text a person typed must not + * transcribe the label into its own source: transcribing fixes the locales + * shipped on the day it was written and reopens the same hole when a new + * bundle lands. Reading the bundles means a new locale is matchable the day + * `messages/.json` arrives, with no edit in the matcher. + * + * Known limit, worth stating where it bites: the values returned are what the + * bundles carry TODAY, while a matcher applies them to text written in the + * past. Editing a shipped translation therefore orphans whatever was written + * under the old wording — the fix for that is a legacy alias at the call site, + * not a data migration. + */ +export function localisedValues(key: string): string[] { + const seen = new Set(); + for (const locale of locales) { + const value = resolveKey(allMessages[locale], key); + if (value && value.trim()) seen.add(value.trim()); + } + return [...seen]; +} diff --git a/src/lib/labs/__tests__/parse-reference-range-locales.test.ts b/src/lib/labs/__tests__/parse-reference-range-locales.test.ts new file mode 100644 index 000000000..933bbce75 --- /dev/null +++ b/src/lib/labs/__tests__/parse-reference-range-locales.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, it } from "vitest"; + +import { locales } from "@/lib/i18n/config"; +import { + isUnreadableRange, + parseReferenceRange, + RANGE_PROSE, +} from "@/lib/labs/parse-reference-range"; + +/** + * The notation a lab prints is the LAB's prose, not the app's, so this parser + * cannot derive its vocabulary from `messages/.json` the way a matcher + * over app-owned labels does. The table is hand-written, and these are the + * strings it has to read: real reference-range notation as French, Spanish, + * Italian and Polish reports print it, decimal comma included. + * + * Before the table existed, every case below returned `low: null, high: null` + * — the string was stored and the reading was then judged against a band the + * lab never printed, or against no band at all, with nothing anywhere saying + * so. + */ +describe("parseReferenceRange — French notation", () => { + it.each([ + ["jusqu'à 5,0", null, 5], + ["jusqu’à 5,0", null, 5], + ["JUSQU'À 5,0", null, 5], + ["inférieur à 5,0", null, 5], + ["moins de 5,0", null, 5], + ["à partir de 3,5", 3.5, null], + ["supérieur à 3,5", 3.5, null], + ["au moins 3,5", 3.5, null], + ["3,5 à 5,0", 3.5, 5], + ["de 3,5 à 5,0", 3.5, 5], + ["entre 3,5 et 5,0", 3.5, 5], + ["valeurs de référence 3,5 - 5,0", 3.5, 5], + ])("reads %s as %s..%s", (raw, low, high) => { + expect(parseReferenceRange(raw)).toEqual({ low, high, text: raw }); + }); +}); + +describe("parseReferenceRange — Spanish notation", () => { + it.each([ + ["hasta 5,0", null, 5], + ["menor de 5,0", null, 5], + ["inferior a 5,0", null, 5], + ["como máximo 5,0", null, 5], + ["desde 3,5", 3.5, null], + ["mayor que 3,5", 3.5, null], + ["superior a 3,5", 3.5, null], + ["más de 3,5", 3.5, null], + ["3,5 a 5,0", 3.5, 5], + ["de 3,5 a 5,0", 3.5, 5], + ["entre 3,5 y 5,0", 3.5, 5], + ["valores de referencia 3,5 - 5,0", 3.5, 5], + ])("reads %s as %s..%s", (raw, low, high) => { + expect(parseReferenceRange(raw)).toEqual({ low, high, text: raw }); + }); +}); + +describe("parseReferenceRange — Italian notation", () => { + it.each([ + ["fino a 5,0", null, 5], + ["inferiore a 5,0", null, 5], + ["meno di 5,0", null, 5], + ["oltre 3,5", 3.5, null], + ["superiore a 3,5", 3.5, null], + ["più di 3,5", 3.5, null], + ["almeno 3,5", 3.5, null], + ["3,5 a 5,0", 3.5, 5], + ["da 3,5 a 5,0", 3.5, 5], + ["tra 3,5 e 5,0", 3.5, 5], + ["valori di riferimento 3,5 - 5,0", 3.5, 5], + ])("reads %s as %s..%s", (raw, low, high) => { + expect(parseReferenceRange(raw)).toEqual({ low, high, text: raw }); + }); + + it("reads a bare Italian floor rather than swallowing its introducing word", () => { + // "da" both introduces a two-sided window ("da 3,5 a 5,0") and states a + // floor on its own. Stripping introducing words before matching — which is + // what the parser used to do unconditionally — turns the second form into + // a bare number with no bound at all. + expect(parseReferenceRange("da 3,5")).toEqual({ + low: 3.5, + high: null, + text: "da 3,5", + }); + }); +}); + +describe("parseReferenceRange — Polish notation", () => { + it.each([ + ["do 5,0", null, 5], + ["poniżej 5,0", null, 5], + ["mniej niż 5,0", null, 5], + ["maks. 5,0", null, 5], + ["od 3,5", 3.5, null], + ["powyżej 3,5", 3.5, null], + ["co najmniej 3,5", 3.5, null], + ["3,5 do 5,0", 3.5, 5], + ["od 3,5 do 5,0", 3.5, 5], + ["zakres referencyjny 3,5 - 5,0", 3.5, 5], + ])("reads %s as %s..%s", (raw, low, high) => { + expect(parseReferenceRange(raw)).toEqual({ low, high, text: raw }); + }); +}); + +describe("parseReferenceRange — the new prose does not loosen the old rules", () => { + it("still refuses a bare number in any language", () => { + expect(parseReferenceRange("5,0")).toEqual({ + low: null, + high: null, + text: "5,0", + }); + }); + + it("does not read scientific notation as a two-sided window", () => { + // "e" is an Italian separator ("tra 3,5 e 5,0") and "y" a Spanish one. + // Both are one letter, so they are only accepted with whitespace on both + // sides — otherwise "3.5e5" would parse as a 3.5–5 window. + expect(parseReferenceRange("3.5e5")).toEqual({ + low: null, + high: null, + text: "3.5e5", + }); + }); + + it("still refuses a transposed window printed in French", () => { + expect(parseReferenceRange("de 5,0 à 3,5")).toEqual({ + low: null, + high: null, + text: "de 5,0 à 3,5", + }); + }); + + it("still refuses bounds when the printed unit contradicts the reading", () => { + expect(parseReferenceRange("jusqu'à 5,0 mmol/l", "mg/dL")).toEqual({ + low: null, + high: null, + text: "jusqu'à 5,0 mmol/l", + }); + }); +}); + +describe("parseReferenceRange — an unreadable window is named, not silent", () => { + it("separates 'nothing printed' from 'printed and not read'", () => { + expect(parseReferenceRange(null)).toBeNull(); + expect(isUnreadableRange(parseReferenceRange(null))).toBe(false); + + const prose = parseReferenceRange("voir le compte rendu"); + expect(prose).toEqual({ + low: null, + high: null, + text: "voir le compte rendu", + }); + expect(isUnreadableRange(prose)).toBe(true); + }); + + it("reports a window it DID read as readable", () => { + expect(isUnreadableRange(parseReferenceRange("jusqu'à 5,0"))).toBe(false); + }); +}); + +describe("RANGE_PROSE coverage", () => { + it("carries an entry for every shipped locale", () => { + // Keyed by the `Locale` union on purpose: a seventh locale cannot be added + // to `locales` without someone answering how that language prints a range. + // This asserts the runtime side of the same promise. + expect(Object.keys(RANGE_PROSE).sort()).toEqual([...locales].sort()); + }); + + it("states at least one word for each notation in each locale", () => { + for (const locale of locales) { + const prose = RANGE_PROSE[locale]; + expect(prose.twoSided.length, `${locale}.twoSided`).toBeGreaterThan(0); + expect(prose.upper.length, `${locale}.upper`).toBeGreaterThan(0); + expect(prose.lower.length, `${locale}.lower`).toBeGreaterThan(0); + } + }); + + it("carries the table already folded, so both sides of a match agree", () => { + // A row written with an accent would never match: the input is + // diacritic-folded before it reaches the alternation, so an accented row + // is a row that can only ever miss. + for (const locale of locales) { + const prose = RANGE_PROSE[locale]; + for (const word of [ + ...prose.twoSided, + ...prose.upper, + ...prose.lower, + ...prose.filler, + ]) { + expect(word.normalize("NFD"), `${locale}: ${word}`).not.toMatch(/[̀-ͯ]/u); + expect(word, `${locale}: ${word}`).toBe(word.toLowerCase()); + } + } + }); +}); diff --git a/src/lib/labs/ocr-extract.ts b/src/lib/labs/ocr-extract.ts index fcdb01563..4329211b8 100644 --- a/src/lib/labs/ocr-extract.ts +++ b/src/lib/labs/ocr-extract.ts @@ -24,7 +24,10 @@ import { } from "@/lib/ai/types"; import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; import { prisma } from "@/lib/db"; -import { parseReferenceRange } from "@/lib/labs/parse-reference-range"; +import { + isUnreadableRange, + parseReferenceRange, +} from "@/lib/labs/parse-reference-range"; import { annotate } from "@/lib/logging/context"; import { extractedLabsSchema, @@ -270,7 +273,16 @@ export async function runOcrExtraction( // Cap defensively (the schema already bounds it) and annotate each row. const rows: OcrExtractedRowDto[] = []; + // How many rows printed a window the parser could not read. Those rows fall + // back to whatever bounds the model itself reported, or to none — either way + // the notation went unread, and a count of it is the difference between + // noticing that a report layout or a language is unsupported and never + // hearing about it. A count only: the string is the user's document. + let unreadableRanges = 0; for (const row of envelope.rows.slice(0, OCR_MAX_ROWS)) { + if (isUnreadableRange(parseReferenceRange(row.referenceText, row.unit))) { + unreadableRanges += 1; + } rows.push(await annotateRow(args.userId, row, reportDate)); } @@ -282,6 +294,7 @@ export async function runOcrExtraction( rows: rows.length, duplicates: rows.filter((r) => r.duplicateOf !== null).length, newBiomarkers: rows.filter((r) => r.biomarkerMatch === "new").length, + unreadableRanges, }, }); diff --git a/src/lib/labs/parse-reference-range.ts b/src/lib/labs/parse-reference-range.ts index 24e413f37..f16765ff6 100644 --- a/src/lib/labs/parse-reference-range.ts +++ b/src/lib/labs/parse-reference-range.ts @@ -19,14 +19,31 @@ * plausible bound out of a string it did not understand, because a fake * window silently reclassifies a reading as normal or abnormal. * - * Notations read (German and English, comma- or dot-decimal): + * Notations read (comma- or dot-decimal): * * two-sided 3.5-5.0 · 3,5 – 5,0 · 3,5 bis 5,0 · 3.5 to 5.0 · 3,5 ... 5,0 - * von 3,5 bis 5,0 · [3,5 - 5,0] · (3.5-5.0) + * von 3,5 bis 5,0 · 3,5 à 5,0 · de 3,5 a 5,0 · da 3,5 a 5,0 · + * od 3,5 do 5,0 · [3,5 - 5,0] · (3.5-5.0) * upper only < 5 · ≤ 5 · <= 5 · bis 5,0 · bis zu 5,0 · max. 5,0 · - * kleiner 5 · unter 5 · up to 5.0 · less than 5 + * kleiner 5 · unter 5 · up to 5.0 · less than 5 · jusqu'à 5,0 · + * hasta 5,0 · fino a 5,0 · do 5,0 · poniżej 5,0 * lower only > 3.5 · ≥ 3,5 · >= 3.5 · ab 3,5 · min. 3,5 · mindestens 3,5 · - * größer 3,5 · über 3,5 · at least 3.5 + * größer 3,5 · über 3,5 · at least 3.5 · à partir de 3,5 · + * desde 3,5 · da 3,5 · od 3,5 · powyżej 3,5 + * + * WHY THE VOCABULARY IS TABULATED AND NOT DERIVED. Elsewhere in the app, a + * matcher that has to recognise a word list in six languages builds its index + * out of `messages/.json`, so a seventh locale works the day its + * bundle lands. That does not apply here. "jusqu'à", "hasta", "fino a" and + * "do" are prose a LAB printed on a sheet of paper; the app owns no string + * they could be derived from, and the language of the report has nothing to do + * with the language of the UI — a French self-hoster running the app in + * English still receives French reports. So the table below is hand-written, + * every locale's prose is tried against every input at once, and the honest + * cost is written down: a report printed in a language absent from the table + * yields text-only. `RANGE_PROSE` is keyed by the shipped `Locale` union + * precisely so that adding a locale to `locales` fails to compile until + * someone has answered "and how does that language print a range?". * * Units are read, never converted. A window may carry the unit the report * printed it in; when the caller states the reading's own unit and the two @@ -34,6 +51,11 @@ * mmol/L into mg/dL needs a per-analyte factor, so doing it here would be the * parser deciding what the report meant. */ +import { type Locale } from "@/lib/i18n/config"; +import { + normaliseApostrophes, + stripDiacritics, +} from "@/lib/i18n/fold-for-match"; /** What a printed range yielded. `text` is set whenever the input was non-empty. */ export interface ParsedReferenceRange { @@ -58,14 +80,224 @@ const MAX_ABS_BOUND = 1e12; /** One number token: optional sign, digits, any number of `.`/`,` groups. */ const NUM = String.raw`[+-]?\d+(?:[.,]\d+)*`; -/** Infix separators for a two-sided window. */ -const TWO_SIDED_SEP = String.raw`(?:-|–|—|‒|…|\.{2,3}|bis\s+zu|bis|to|until)`; +/** How one language prints a range. Every field is a list of literal words. */ +export interface RangeProse { + /** Infix words joining two bounds: "3,5 5,0". */ + twoSided: string[]; + /** Words that introduce a ceiling: " 5,0". */ + upper: string[]; + /** Words that introduce a floor: " 3,5". */ + lower: string[]; + /** + * Words that only announce that a window follows and carry no bound of + * their own ("von 3,5 bis 5,0"). Stripped and the input retried — never on + * the first pass, because several of them ("da", "od", "de") are also a + * floor prefix in their own language, and stripping first would read + * "da 3,5" as a bare number. + */ + filler: string[]; +} + +/** + * The prose, per shipped locale. Written ALREADY FOLDED — lower case and + * accent-free — because the input is diacritic-folded before matching, so + * "jusqu'à", "jusqu'a" and "JUSQU'À" are one row. The German "ß" survives the + * fold (it has no canonical decomposition), which is why "größer" appears here + * as "großer"; its "oe"/"ss" twins are listed beside it, exactly as the + * hand-written alternation carried them before. + * + * The `de` and `en` lists are the pre-existing alternation, split apart word + * for word and nothing more — this change is about the four locales that had + * no entry at all, and a German or English range must read the same afterwards + * as it did before. + * + * Every list is applied to every input regardless of the reader's locale — see + * the module comment for why. Multi-word entries match across any run of + * whitespace, so a report that printed a non-breaking space still reads. + */ +export const RANGE_PROSE: Record = { + de: { + twoSided: ["bis zu", "bis"], + upper: [ + "bis zu", + "bis", + "max.", + "maximal", + "max", + "kleiner als", + "kleiner", + "unter", + "hochstens", + "hoechstens", + ], + lower: [ + "ab", + "mind.", + "mindestens", + "minimal", + "min.", + "min", + "großer als", + "großer", + "groesser als", + "groesser", + "uber", + "ueber", + ], + filler: ["von", "referenz", "ref.", "ref"], + }, + en: { + twoSided: ["to", "until"], + upper: ["up to", "less than", "below"], + lower: ["at least", "greater than", "above"], + filler: ["from", "reference", "ref.", "ref"], + }, + fr: { + twoSided: ["a", "et"], + upper: [ + "jusqu'a", + "jusque", + "inferieur a", + "inferieure a", + "moins de", + "au plus", + "au maximum", + ], + lower: [ + "a partir de", + "superieur a", + "superieure a", + "plus de", + "au moins", + "au minimum", + ], + filler: [ + "de", + "entre", + "valeurs de reference", + "valeur de reference", + "intervalle de reference", + "plage de reference", + ], + }, + es: { + twoSided: ["a", "y"], + upper: [ + "hasta", + "menor que", + "menor de", + "menor a", + "inferior a", + "menos de", + "como maximo", + "maximo", + ], + lower: [ + "desde", + "a partir de", + "mayor que", + "mayor de", + "mayor a", + "superior a", + "mas de", + "al menos", + "como minimo", + "minimo", + ], + filler: [ + "de", + "entre", + "valores de referencia", + "valor de referencia", + "rango de referencia", + "intervalo de referencia", + ], + }, + it: { + twoSided: ["a", "e"], + upper: [ + "fino a", + "inferiore a", + "minore di", + "meno di", + "al massimo", + "massimo", + ], + lower: [ + "da", + "oltre", + "superiore a", + "maggiore di", + "piu di", + "almeno", + "al minimo", + "minimo", + ], + filler: [ + "da", + "tra", + "fra", + "valori di riferimento", + "valore di riferimento", + "intervallo di riferimento", + ], + }, + pl: { + twoSided: ["do"], + upper: ["do", "ponizej", "mniej niz", "nie wiecej niz", "maks.", "maks"], + lower: [ + "od", + "powyzej", + "wiecej niz", + "co najmniej", + "nie mniej niz", + "minimum", + ], + filler: [ + "od", + "zakres referencyjny", + "wartosci referencyjne", + "normy laboratoryjne", + ], + }, +}; + +/** Escape a literal for regex use and let any run of whitespace join its words. */ +function literalToPattern(word: string): string { + return word + .split(/\s+/u) + .map((part) => part.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&")) + .join(String.raw`\s+`); +} + +/** + * Alternation over every locale's list for one field, longest literal first so + * the reader sees the specific form ahead of the prefix it contains. + */ +function proseAlternation(field: keyof RangeProse): string { + const words = new Set(); + for (const prose of Object.values(RANGE_PROSE)) { + for (const word of prose[field]) words.add(word); + } + return [...words] + .sort((a, b) => b.length - a.length) + .map(literalToPattern) + .join("|"); +} + +/** + * Symbolic infix separators. Kept apart from the word separators because they + * need no surrounding whitespace, while a word separator must have it: "a", + * "e" and "y" are one letter long, and a bare `e` that could sit between two + * digits would read "3.5e5" as a window. + */ +const SYMBOL_SEP = String.raw`-|–|—|‒|…|\.{2,3}`; /** Prefixes that state a ceiling only. */ -const UPPER_PREFIX = String.raw`(?:<=|<|≤|bis\s+zu|bis|max\.|maximal|max|kleiner\s+als|kleiner|unter|höchstens|hoechstens|up\s+to|less\s+than|below)`; +const UPPER_PREFIX = `(?:<=|<|≤|${proseAlternation("upper")})`; /** Prefixes that state a floor only. */ -const LOWER_PREFIX = String.raw`(?:>=|>|≥|ab|mind\.|mindestens|minimal|min\.|min|größer\s+als|größer|groesser\s+als|groesser|über|ueber|at\s+least|greater\s+than|above)`; +const LOWER_PREFIX = `(?:>=|>|≥|${proseAlternation("lower")})`; /** * Trailing unit tail. Deliberately whitespace-free and comma-free: a real unit @@ -76,14 +308,19 @@ const LOWER_PREFIX = String.raw`(?:>=|>|≥|ab|mind\.|mindestens|minimal|min\.|m const UNIT_TAIL = String.raw`(?:\s*([^\s,]{1,24}))?`; const TWO_SIDED_RE = new RegExp( - `^(${NUM})\\s*${TWO_SIDED_SEP}\\s*(${NUM})${UNIT_TAIL}$`, + `^(${NUM})(?:\\s*(?:${SYMBOL_SEP})\\s*|\\s+(?:${proseAlternation( + "twoSided", + )})\\s+)(${NUM})${UNIT_TAIL}$`, "iu", ); const UPPER_RE = new RegExp(`^${UPPER_PREFIX}\\s*(${NUM})${UNIT_TAIL}$`, "iu"); const LOWER_RE = new RegExp(`^${LOWER_PREFIX}\\s*(${NUM})${UNIT_TAIL}$`, "iu"); /** Leading words that only introduce the window ("von 3,5 bis 5,0"). */ -const LEADING_FILLER_RE = /^(?:von|from|ref\.?|referenz|reference)\s+/iu; +const LEADING_FILLER_RE = new RegExp( + `^(?:${proseAlternation("filler")})\\s+`, + "iu", +); /** * Read one number token into a JS number, or null when its separators are @@ -148,33 +385,18 @@ function unitsAgree(printed: string | undefined, reading: string | undefined) { } /** - * Parse the reference range a lab report printed for one reading. - * - * @param raw the printed string, exactly as transcribed. - * @param unit the reading's own unit, when known. A printed window in a - * different unit yields text-only — never a converted bound. - * @returns null when there was nothing printed at all. + * Try the three notations against one candidate string. Null means none of + * them matched; a result with both bounds null means one matched but the + * numbers or the unit refused it, which is a final answer and not a reason to + * retry a differently-trimmed candidate. */ -export function parseReferenceRange( - raw: string | null | undefined, - unit?: string | null, +function matchNotation( + candidate: string, + text: string, + unit: string | null | undefined, ): ParsedReferenceRange | null { - if (raw === null || raw === undefined) return null; - const text = raw - .replace(/\s+/gu, " ") - .trim() - .slice(0, SOURCE_REFERENCE_TEXT_MAX); - if (text === "") return null; - const noBounds: ParsedReferenceRange = { low: null, high: null, text }; - // Strip a bracket wrapper and an introducing word for MATCHING only — the - // stored text stays exactly what the report printed. - let candidate = text; - const bracketed = /^[[(](.*)[\])]$/u.exec(candidate); - if (bracketed) candidate = bracketed[1].trim(); - candidate = candidate.replace(LEADING_FILLER_RE, "").trim(); - const two = TWO_SIDED_RE.exec(candidate); if (two) { if (!unitsAgree(two[3], unit ?? undefined)) return noBounds; @@ -201,7 +423,76 @@ export function parseReferenceRange( return low === null ? noBounds : { low, high: null, text }; } + return null; +} + +/** + * Parse the reference range a lab report printed for one reading. + * + * @param raw the printed string, exactly as transcribed. + * @param unit the reading's own unit, when known. A printed window in a + * different unit yields text-only — never a converted bound. + * @returns null when there was nothing printed at all. A non-null result with + * both bounds null means the report DID print something the parser + * could not read — see `isUnreadableRange`, which is the difference + * between "no window" and "a window nobody checked". + */ +export function parseReferenceRange( + raw: string | null | undefined, + unit?: string | null, +): ParsedReferenceRange | null { + if (raw === null || raw === undefined) return null; + const text = raw + .replace(/\s+/gu, " ") + .trim() + .slice(0, SOURCE_REFERENCE_TEXT_MAX); + if (text === "") return null; + + // Fold for MATCHING only — the stored text stays exactly what the report + // printed. Accents go so a table written "jusqu'a" meets a report's + // "jusqu'à"; the curled apostrophe a word processor emits becomes the + // straight one the table carries. Everything else, notably the digits and + // their separators, is left alone. + let candidate = normaliseApostrophes(stripDiacritics(text)); + const bracketed = /^[[(](.*)[\])]$/u.exec(candidate); + if (bracketed) candidate = bracketed[1].trim(); + + // Untrimmed first. Several introducing words are also a bound prefix in + // their own language — Italian "da", Polish "od", French "de" — so stripping + // before matching would read "da 3,5" as a bare number with no floor. + const direct = matchNotation(candidate, text, unit); + if (direct) return direct; + + const trimmed = candidate.replace(LEADING_FILLER_RE, "").trim(); + if (trimmed !== candidate) { + const viaFiller = matchNotation(trimmed, text, unit); + if (viaFiller) return viaFiller; + } + // Anything else — "negativ", "siehe Befund", a bare number, prose — keeps its // text and states no window. - return noBounds; + return { low: null, high: null, text }; +} + +/** + * True when the report printed a range and the parser could not derive a + * single bound from it. + * + * This is the case worth naming. `null` from the parser means the report + * stated nothing, and the reading falls back to the biomarker's own band as it + * always did. A parsed result with two null bounds means something else + * entirely: the lab DID state a window, on this reading, and the app is about + * to judge the value against a different band or against none at all. Left + * unnamed it reads identically to "no range" at every call site, which is how + * a French account's `jusqu'à 5,0` became a reading nobody ever flagged. + * + * Callers are expected to SAY so rather than to guess a bound — the manual + * form shows the reader that the string was kept but not read, and the ingest + * paths annotate it so an unreadable notation shows up as a number in the wide + * event instead of as silence. + */ +export function isUnreadableRange( + parsed: ParsedReferenceRange | null | undefined, +): boolean { + return !!parsed && parsed.low === null && parsed.high === null; } From ce7372991661696c5f15578cbf06e88f4d917a71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 23 Aug 2026 14:19:18 +0200 Subject: [PATCH 3/8] fix(fhir): resolve a lab analyte's LOINC code from its name in any shipped locale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A biomarker minted from the labs catalog stores `t("labs.catalog.")` — the name in the language of whoever minted it — and that stored name is what reaches the FHIR exporter as free text. The exporter matched it against an alias table written in English and German. So an account working in French, Spanish, Italian or Polish exported "Cholestérol total", "Glucosa en ayunas", "Glicemia a digiuno" and "Cholesterol całkowity" as text-only concepts with no LOINC code, on the one document whose whole purpose is being read by another system. The names are DERIVED from `messages/.json` now, not transcribed into this file. Both sides of the new slug map are app-owned identities with no language in them, which is what makes it the one edit a new locale does not need: `locales` grows, `messages/xx.json` lands, and the analyte names it ships resolve the same day. Transcribing four more languages would have fixed today's six and reopened the hole at the seventh. The hand-written aliases keep precedence. They cover what the app owns no string for — clinical abbreviations, the ways a person shortens a name — and they were verified one at a time over several releases. A derived name landing on the same key is a harmless duplicate; one landing on a different key would be a translation typo re-pointing a verified mapping at another analyte, so the build refuses it and the guard suite asserts the refusal held. The lookup fold now drops accents before stripping punctuation. Without it a name carrying diacritics lost them entirely rather than reducing to its base letters — "Cholestérol total" folded to `cholestroltotal`, and "Hämoglobin" to `hmoglobin`. That last one means the German umlaut spellings never resolved either: the hand-written `hamoglobin` and `nuchternglucose` aliases only ever caught the un-umlauted typing. Every pre-existing alias is ASCII, so the fold is a widening and what resolved before resolves to the same key. --- .../fhir/__tests__/lab-loinc-locales.test.ts | 135 ++++++++++++++++++ src/lib/fhir/lab-loinc.ts | 135 ++++++++++++++++-- 2 files changed, 259 insertions(+), 11 deletions(-) create mode 100644 src/lib/fhir/__tests__/lab-loinc-locales.test.ts diff --git a/src/lib/fhir/__tests__/lab-loinc-locales.test.ts b/src/lib/fhir/__tests__/lab-loinc-locales.test.ts new file mode 100644 index 000000000..2e4363e45 --- /dev/null +++ b/src/lib/fhir/__tests__/lab-loinc-locales.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from "vitest"; + +import { + CATALOG_SLUG_TO_LOINC_KEY, + localisedLabAliases, + normaliseLabKey, + resolveLabCoding, +} from "@/lib/fhir/lab-loinc"; +import { locales } from "@/lib/i18n/config"; +import { allMessages, resolveKey } from "@/lib/i18n/shared-resolve"; +import { BIOMARKER_CATALOG } from "@/lib/labs/biomarker-catalog"; + +/** + * A biomarker minted from the labs catalog stores `t("labs.catalog.")` + * — the name in the language of whoever minted it — and that stored name is + * what reaches the FHIR exporter as free text. The alias table it was matched + * against was written in English and German, so four of the six shipped + * locales exported every one of these analytes as a text-only concept with no + * LOINC code. + */ +describe("resolveLabCoding — the analyte name in every shipped locale", () => { + it.each([ + ["Cholestérol total", "2093-3"], + ["Colesterol total", "2093-3"], + ["Colesterolo totale", "2093-3"], + ["Cholesterol całkowity", "2093-3"], + ["Créatinine", "2160-0"], + ["Creatinina", "2160-0"], + ["Kreatynina", "2160-0"], + ["Glycémie à jeun", "1558-6"], + ["Glucosa en ayunas", "1558-6"], + ["Glicemia a digiuno", "1558-6"], + ["Glukoza na czczo", "1558-6"], + ["Ferritine", "2276-4"], + ["Ferrytyna", "2276-4"], + ["Hémoglobine", "718-7"], + ["Emoglobina", "718-7"], + ["Cholestérol LDL", "18262-6"], + ["Colesterol HDL", "2085-9"], + ["Triglycérides", "2571-8"], + ["Trójglicerydy", "2571-8"], + ["Insuline à jeun", "27873-9"], + ["Lipoprotéine(a)", "43583-4"], + ])("codes %s as LOINC %s", (analyte, loinc) => { + expect(resolveLabCoding(analyte, "")?.loinc).toBe(loinc); + }); + + it("derives from the bundles rather than from a transcribed list", () => { + // The property that matters: every locale contributes. A hand-written + // table would pass the cases above and still be frozen at the languages + // somebody happened to type. + for (const locale of locales) { + const name = resolveKey( + allMessages[locale], + "labs.catalog.total-cholesterol", + ); + expect(name, `${locale} has no catalog name`).toBeTruthy(); + expect( + resolveLabCoding(name as string, "")?.loinc, + `${locale}: ${name}`, + ).toBe("2093-3"); + } + }); + + it("stamps the canonical UCUM when the recorded unit matches", () => { + expect(resolveLabCoding("Cholestérol total", "mg/dL")).toEqual({ + loinc: "2093-3", + display: "Cholesterol [Mass/volume] in Serum or Plasma", + ucum: "mg/dL", + }); + }); + + it("still keeps an unmapped analyte text-only", () => { + // Conservative by design: a name the app does not ship and no alias names + // gets no fabricated code, in any language. + expect(resolveLabCoding("Analyse inconnue", "mg/dL")).toBeNull(); + }); +}); + +describe("resolveLabCoding — the existing English and German behaviour", () => { + it.each([ + ["HbA1c", "4548-4"], + ["LDL-C", "18262-6"], + ["LDL Cholesterol", "18262-6"], + ["Gesamtcholesterin", "2093-3"], + ["Kreatinin", "2160-0"], + ["GPT", "1742-6"], + ["ASAT", "1920-8"], + ["hgb", "718-7"], + ["Nüchternglucose", "1558-6"], + ["Thyrotropin", "3016-3"], + ["Apolipoprotein B", "1884-6"], + ])("still codes %s as LOINC %s", (analyte, loinc) => { + expect(resolveLabCoding(analyte, "")?.loinc).toBe(loinc); + }); + + it("resolves a German name whose umlaut the old key fold destroyed", () => { + // "Hämoglobin" used to fold to `hmoglobin`, which matched nothing — the + // hand-written `hamoglobin` alias only ever caught the un-umlauted typing. + expect(normaliseLabKey("Hämoglobin")).toBe("hamoglobin"); + expect(resolveLabCoding("Hämoglobin", "g/dL")?.loinc).toBe("718-7"); + }); +}); + +describe("the derived alias index", () => { + it("contradicts no hand-written alias", () => { + // A derived name that resolved to a DIFFERENT analyte than a verified + // alias would be a bundle bug re-pointing an existing mapping. The build + // refuses it; this asserts the refusal held rather than that it exists. + for (const [name, key] of Object.entries(localisedLabAliases())) { + const viaHand = resolveLabCoding(name, ""); + expect(viaHand, name).not.toBeNull(); + expect(viaHand?.loinc, name).toBe(resolveLabCoding(key, "")?.loinc); + } + }); + + it("is not empty, so a matcher that matches nothing cannot pass", () => { + expect(Object.keys(localisedLabAliases()).length).toBeGreaterThan(20); + }); + + it("maps only slugs the labs catalog actually ships, in every locale", () => { + // A renamed slug or a bundle that dropped a name would silently stop + // deriving: the index would shrink and nothing else would say so. + const slugs = new Set(BIOMARKER_CATALOG.map((s) => s.slug)); + for (const slug of Object.keys(CATALOG_SLUG_TO_LOINC_KEY)) { + expect(slugs.has(slug), `catalog lost slug ${slug}`).toBe(true); + for (const locale of locales) { + expect( + resolveKey(allMessages[locale], `labs.catalog.${slug}`), + `${locale} has no name for ${slug}`, + ).toBeTruthy(); + } + } + }); +}); diff --git a/src/lib/fhir/lab-loinc.ts b/src/lib/fhir/lab-loinc.ts index 17eaf927f..0e748d0f9 100644 --- a/src/lib/fhir/lab-loinc.ts +++ b/src/lib/fhir/lab-loinc.ts @@ -30,9 +30,16 @@ * yet carry that field, so the call site passes only `analyte` today. When the * DTO gains a `biomarkerCanonicalName` (or similar), thread it in — no other * change to this module is required. + * + * SERVER-ONLY since the localised alias index landed: the index reads every + * shipped message bundle through `@/lib/i18n/shared-resolve`. The single + * caller is the FHIR exporter, which runs on the server; a client that needs + * to show a biomarker's name resolves it through the labs catalog instead. */ import { LOINC_SYSTEM, UCUM_SYSTEM } from "@/lib/fhir/loinc-map"; +import { localisedValues } from "@/lib/i18n/shared-resolve"; +import { stripDiacritics } from "@/lib/i18n/fold-for-match"; export { LOINC_SYSTEM, UCUM_SYSTEM }; @@ -45,13 +52,20 @@ export interface LabLoincMapping { } /** - * Normalise an analyte / unit string to a lookup key: lower-case, strip every - * character that is not a letter or digit. So "LDL-C", "LDL Cholesterol", - * "ldl_c" all fold to `ldlc`-ish keys; the alias table below pins the exact - * variants the app and German users actually type onto a single canonical key. + * Normalise an analyte / unit string to a lookup key: lower-case, drop + * accents, strip every character that is not a letter or digit. So "LDL-C", + * "LDL Cholesterol", "ldl_c" all fold to `ldlc`-ish keys. + * + * The accent fold is what lets a name carrying diacritics reach a key at all. + * Without it "Cholestérol total" lost its "é" entirely and folded to + * `cholestroltotal`, and "Hämoglobin" to `hmoglobin` — keys nothing was ever + * going to match, which is also why the hand-written German aliases below are + * spelled `hamoglobin` and `kreatinin` rather than as the language writes + * them. Every pre-existing alias is pure ASCII, so folding is a widening: what + * resolved before resolves to the same key now. */ export function normaliseLabKey(s: string): string { - return s.toLowerCase().replace(/[^a-z0-9]/g, ""); + return stripDiacritics(s.toLowerCase()).replace(/[^a-z0-9]/g, ""); } /** @@ -172,11 +186,17 @@ const LAB_LOINC_BY_KEY: Record = { }; /** - * Alias → canonical key. The LEFT side is the normalised form of a name a user - * (EN or DE) might type; the RIGHT side is a key in `LAB_LOINC_BY_KEY`. Every - * canonical key is also implicitly its own alias (looked up directly first). - * Keep the right side honest — only fold a synonym when it unambiguously names - * the same analyte. + * Alias → canonical key, hand-written. The LEFT side is the normalised form of + * a name a user might type; the RIGHT side is a key in `LAB_LOINC_BY_KEY`. + * Every canonical key is also implicitly its own alias (looked up directly + * first). Keep the right side honest — only fold a synonym when it + * unambiguously names the same analyte. + * + * This table covers spellings the app owns NO string for: clinical + * abbreviations ("GPT", "ASAT", "hgb"), and the ways a person shortens a name + * by hand. The NAMES the app itself displays are not transcribed here — see + * the derived index below — because that list exists in six languages already + * and copying it in would freeze it at today's six. */ const LAB_ALIASES: Record = { // HbA1c @@ -252,6 +272,94 @@ const LAB_ALIASES: Record = { nuchterninsulin: "fastinginsulin", }; +/** + * Labs-catalog slug → canonical LOINC key. + * + * Both sides are app-owned stable identities with no language in them, which + * is the whole point: this is the only edit a new locale does NOT need. A + * biomarker minted from the catalog stores whatever `labs.catalog.` said + * in the language of the person who minted it, and that name is what reaches + * the FHIR exporter as free text. Pinning the slug once means every + * translation of it resolves. + * + * Slugs absent here are catalog markers with no entry in `LAB_LOINC_BY_KEY` + * (T3, T4, B12, folate, the electrolytes, most of the blood count) — that map + * is conservative by design and text-only stays the honest default for them. + * Adding one is a decision about a LOINC term, not about a language. + */ +export const CATALOG_SLUG_TO_LOINC_KEY: Record = { + "total-cholesterol": "cholesterol", + ldl: "ldlc", + hdl: "hdlc", + triglycerides: "triglycerides", + "fasting-glucose": "glucosefasting", + hba1c: "hba1c", + tsh: "tsh", + ferritin: "ferritin", + "vitamin-d": "vitamind", + "hs-crp": "hscrp", + creatinine: "creatinine", + egfr: "egfr", + alt: "alt", + ast: "ast", + ggt: "ggt", + hemoglobin: "hemoglobin", + apob: "apob", + "lp-a": "lpa", + "fasting-insulin": "fastinginsulin", +}; + +/** + * Localised analyte name → canonical LOINC key, DERIVED from + * `messages/.json` rather than transcribed out of it. + * + * That is the load-bearing part. Before this index, the alias table above was + * the whole of the mapping and it was written in English and German, so a + * French account's "Cholestérol total", a Spanish "Colesterol total", an + * Italian "Colesterolo totale" and a Polish "Cholesterol całkowity" all + * exported as a text-only concept with no LOINC code, on a document a + * clinician or another system reads. Transcribing four more languages here + * would have fixed today's six locales and reopened the same hole on the day a + * seventh landed. Reading the bundles means a new locale needs no edit in this + * file: `locales` grows, `messages/xx.json` lands, and the names it ships + * resolve the same day. + * + * The hand-written aliases WIN on a conflict. They were verified one by one + * over several releases; a derived name that folds onto the same key as one of + * them is a duplicate and harmless, and one that folds onto a DIFFERENT key + * would be a bundle bug, so it is refused here and the guard suite fails on + * it — a translation typo must not quietly re-point an existing alias at + * another analyte. + * + * Known limit: the index is built from the names the bundles carry TODAY, + * while the analytes it resolves were typed in the past. Editing a shipped + * `labs.catalog.` string orphans the biomarkers minted under the old + * wording; the fix for that is a legacy row in `LAB_ALIASES`, not a migration. + */ +function buildLocalisedAliases(): Record { + const index: Record = {}; + for (const [slug, loincKey] of Object.entries(CATALOG_SLUG_TO_LOINC_KEY)) { + for (const name of localisedValues(`labs.catalog.${slug}`)) { + const key = normaliseLabKey(name); + if (!key) continue; + if (LAB_LOINC_BY_KEY[key] || LAB_ALIASES[key]) continue; + if (index[key] && index[key] !== loincKey) continue; + index[key] = loincKey; + } + } + return index; +} + +const LOCALISED_LAB_ALIASES = buildLocalisedAliases(); + +/** + * Test seam: the guard suite asserts the derived index covers every shipped + * locale and contradicts no hand-written alias. Production reads the constant. + */ +export function localisedLabAliases(): Readonly> { + return LOCALISED_LAB_ALIASES; +} + /** * Canonical UCUM symbols that a recorded free-text unit may normalise to. The * LEFT is the normalised (lower-cased, punctuation-stripped) recorded unit; @@ -344,7 +452,12 @@ export function resolveLabCoding( const source = canonicalName && canonicalName.length > 0 ? canonicalName : analyte; const key = normaliseLabKey(source); - const resolvedKey = LAB_LOINC_BY_KEY[key] ? key : LAB_ALIASES[key]; + // Canonical key first, then the hand-verified aliases, then the names the + // shipped bundles carry. The order is the confidence order, and it keeps + // every resolution that worked before working the same way. + const resolvedKey = LAB_LOINC_BY_KEY[key] + ? key + : (LAB_ALIASES[key] ?? LOCALISED_LAB_ALIASES[key]); if (!resolvedKey) return null; const mapping = LAB_LOINC_BY_KEY[resolvedKey]; if (!mapping) return null; From c5a01767aaf7fe83d1abf5faae7549d0ed14082c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 23 Aug 2026 14:21:34 +0200 Subject: [PATCH 4/8] fix(fhir): code a qualitative lab result recorded in any shipped locale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lab form offers "negative" and "positive" as quick picks and writes the label it rendered, so the column holds the bundle's own string in the language the reading was recorded in. The SNOMED mapper matched that string against a list transcribed in English and German, so "négatif", "negativo", "ujemny", "positif" and "dodatni" all exported without a code — text-only on the one document meant to be machine-readable. The negative and positive arms read `labs.form.qualNegative` and `labs.form.qualPositive` out of every bundle now. The form writes those exact keys, so the write side and the read side cannot drift, and a new locale is coded the day its bundle lands. The detected / not-detected pair stays hand-written English and German, and that is the honest limit of this change: the app ships no string for those terms, they are prose a lab printed and a person copied in, and coding "non détecté" or "niewykrywalny" would mean inventing clinical vocabulary in four languages nobody here can check. This module's own rule is that an unverified code is worse than an honest `.text`. When they need coding they need a quick pick and a bundle key, which is the route the other two arms took. A test pins that they stay uncoded rather than leaving it to be discovered. Borderline stays uncoded as before; a test now pins that the derivation did not sweep its bundle key up by accident. Comparison folds accents as well as case, so a result typed without them still resolves. --- .../__tests__/lab-qualitative-locales.test.ts | 138 ++++++++++++++++++ src/lib/fhir/lab-qualitative.ts | 121 +++++++++++---- 2 files changed, 234 insertions(+), 25 deletions(-) create mode 100644 src/lib/fhir/__tests__/lab-qualitative-locales.test.ts diff --git a/src/lib/fhir/__tests__/lab-qualitative-locales.test.ts b/src/lib/fhir/__tests__/lab-qualitative-locales.test.ts new file mode 100644 index 000000000..7ba526323 --- /dev/null +++ b/src/lib/fhir/__tests__/lab-qualitative-locales.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from "vitest"; + +import { + localisedQualitativeIndex, + qualitativeValueConcept, +} from "@/lib/fhir/lab-qualitative"; +import { locales } from "@/lib/i18n/config"; +import { allMessages, resolveKey } from "@/lib/i18n/shared-resolve"; + +const SNOMED = "http://snomed.info/sct"; + +function codeOf(valueText: string): string | undefined { + const concept = qualitativeValueConcept(valueText); + return concept.coding?.[0]?.code; +} + +/** + * The lab form offers "negative" and "positive" as quick picks and writes the + * LABEL it rendered, so the column holds the bundle's own string in the + * language the reading was recorded in. Matched against a transcribed + * English/German list, four of the six shipped locales exported a result the + * receiving system could not read. + */ +describe("qualitativeValueConcept — the quick-pick label in every locale", () => { + it.each([ + ["négatif", "260385009"], + ["negativo", "260385009"], + ["ujemny", "260385009"], + ["positif", "10828004"], + ["positivo", "10828004"], + ["dodatni", "10828004"], + ])("codes %s as SNOMED %s", (valueText, code) => { + expect(codeOf(valueText)).toBe(code); + }); + + it("derives from the bundles rather than from a transcribed list", () => { + for (const locale of locales) { + const negative = resolveKey( + allMessages[locale], + "labs.form.qualNegative", + ); + const positive = resolveKey( + allMessages[locale], + "labs.form.qualPositive", + ); + expect(negative, `${locale} has no negative label`).toBeTruthy(); + expect(positive, `${locale} has no positive label`).toBeTruthy(); + expect(codeOf(negative as string), `${locale}: ${negative}`).toBe( + "260385009", + ); + expect(codeOf(positive as string), `${locale}: ${positive}`).toBe( + "10828004", + ); + } + }); + + it("tolerates a term typed without its accents", () => { + expect(codeOf("Negatif")).toBe("260385009"); + expect(codeOf("NÉGATIF")).toBe("260385009"); + }); + + it("keeps the raw text on the concept whatever the language", () => { + expect(qualitativeValueConcept("négatif")).toEqual({ + coding: [{ system: SNOMED, code: "260385009", display: "Negative" }], + text: "négatif", + }); + }); +}); + +describe("qualitativeValueConcept — what stays text-only, and why", () => { + it("leaves borderline uncoded in every locale", () => { + // The candidate "Borderline" qualifier concept was never confidently + // verified, so `labs.form.qualBorderline` is read by nothing. This pins + // that the derivation did not sweep it up by accident. + for (const locale of locales) { + const label = resolveKey(allMessages[locale], "labs.form.qualBorderline"); + expect(label, `${locale} has no borderline label`).toBeTruthy(); + expect( + qualitativeValueConcept(label as string).coding, + `${locale}: ${label}`, + ).toBeUndefined(); + } + }); + + it("leaves the detected / not-detected pair uncoded outside English and German", () => { + // The honest limit of this change, written down rather than discovered + // later: the app ships no string for these, they are prose a lab printed, + // and inventing the four other languages would be inventing clinical + // vocabulary nobody here can check. + expect(codeOf("nicht nachweisbar")).toBe("260415000"); + expect(codeOf("not detected")).toBe("260415000"); + expect(codeOf("non détecté")).toBeUndefined(); + expect(codeOf("niewykrywalny")).toBeUndefined(); + expect(qualitativeValueConcept("non détecté")).toEqual({ + text: "non détecté", + }); + }); + + it("leaves free prose uncoded", () => { + expect(qualitativeValueConcept("siehe Befund")).toEqual({ + text: "siehe Befund", + }); + }); +}); + +describe("qualitativeValueConcept — the existing English and German terms", () => { + it.each([ + ["negativ", "260385009"], + ["negative", "260385009"], + ["Negativ", "260385009"], + ["neg", "260385009"], + ["positiv", "10828004"], + ["positive", "10828004"], + ["pos", "10828004"], + ["nachweisbar", "260373001"], + ["detected", "260373001"], + ])("still codes %s as SNOMED %s", (valueText, code) => { + expect(codeOf(valueText)).toBe(code); + }); +}); + +describe("the derived qualitative index", () => { + it("is not empty, so a matcher that matches nothing cannot pass", () => { + expect(Object.keys(localisedQualitativeIndex()).length).toBeGreaterThan(3); + }); + + it("never puts one word on both arms", () => { + // Two locales spelling a term the same way ("negativo" in Spanish and + // Italian) is expected and folds onto one row. The same word meaning both + // negative and positive would be a bundle bug. + const negatives = new Set(); + const positives = new Set(); + for (const [word, concept] of Object.entries(localisedQualitativeIndex())) { + (concept.code === "260385009" ? negatives : positives).add(word); + } + for (const word of negatives) expect(positives.has(word)).toBe(false); + }); +}); diff --git a/src/lib/fhir/lab-qualitative.ts b/src/lib/fhir/lab-qualitative.ts index fb614e39c..7b5448ddc 100644 --- a/src/lib/fhir/lab-qualitative.ts +++ b/src/lib/fhir/lab-qualitative.ts @@ -3,29 +3,52 @@ * * A qualitative serology result ("negativ" / "positiv" / "nicht nachweisbar") * has no number, so its FHIR Observation carries a `valueCodeableConcept` - * instead of a `valueQuantity`. This module maps the common German / English - * result terms onto a small set of WELL-ESTABLISHED SNOMED CT qualifier-value - * concepts. The raw recorded text ALWAYS rides `.text`; a coded `coding` is - * added only when the term resolves CONFIDENTLY. When in doubt the result stays - * text-only — never a fabricated code — mirroring the conservative stance of - * the `lab-loinc` and `illness-snomed` mappers. + * instead of a `valueQuantity`. This module maps common result terms onto a + * small set of WELL-ESTABLISHED SNOMED CT qualifier-value concepts. The raw + * recorded text ALWAYS rides `.text`; a coded `coding` is added only when the + * term resolves CONFIDENTLY. When in doubt the result stays text-only — never + * a fabricated code — mirroring the conservative stance of the `lab-loinc` and + * `illness-snomed` mappers. * * Concepts (SNOMED CT International, qualifier value, REFERENCED not * redistributed per the licence): - * - 260385009 "Negative" — negativ / negative - * - 10828004 "Positive" — positiv / positive + * - 260385009 "Negative" — every locale's `labs.form.qualNegative` + * - 10828004 "Positive" — every locale's `labs.form.qualPositive` * - 260415000 "Not detected" — nicht nachweisbar / not detected * - 260373001 "Detected" — nachweisbar / detected * + * The negative and positive arms are DERIVED from `messages/.json`. + * The lab form offers those two as quick picks and writes the label it + * rendered, so what lands in the column is the bundle's own string — + * "négatif", "negativo", "ujemny" — and matching it against a transcribed + * English/German list meant four of the six shipped locales recorded a result + * the export then could not code. Deriving means a new locale is coded the day + * its bundle lands, with no edit here. + * + * The detected / not-detected arm is NOT derived, because the app ships no + * string for it: those are terms a LAB printed and a person copied in. It + * stays the hand-written English and German pair it has always been, and the + * honest cost is that "non détecté" or "niewykrywalny" stay text-only. Coding + * them would mean inventing clinical vocabulary in four languages nobody here + * can check, and this module's own rule is that an unverified code is worse + * than an honest `.text`. When those terms need coding, they need a quick pick + * in the form and a bundle key — the same route the negative and positive arms + * took. + * * Borderline / grenzwertig is DELIBERATELY left text-only: the candidate - * "Borderline" qualifier concept could not be confidently verified, and an - * unverified code is worse than an honest `.text`. Any unrecognised term + * "Borderline" qualifier concept could not be confidently verified. Its bundle + * key is therefore read by nothing here, on purpose. Any unrecognised term * likewise stays text-only. * + * SERVER-ONLY: the derived index reads every shipped message bundle. Its one + * caller is the FHIR exporter, which runs on the server. + * * Systems: SNOMED CT — http://snomed.info/sct */ import type { FhirCodeableConcept } from "@/lib/fhir/types"; +import { foldForMatch } from "@/lib/i18n/fold-for-match"; +import { localisedValues } from "@/lib/i18n/shared-resolve"; /** * SNOMED CT system URI. Inlined (rather than imported from `resources.ts`) to @@ -39,29 +62,76 @@ interface QualitativeConcept { display: string; } +const NEGATIVE: QualitativeConcept = { code: "260385009", display: "Negative" }; +const POSITIVE: QualitativeConcept = { code: "10828004", display: "Positive" }; + /** - * Normalised result term → SNOMED qualifier-value concept. The left side is the - * lower-cased, whitespace-collapsed recorded text. Only confidently-known terms - * appear; everything else falls through to text-only. + * Hand-written result terms → SNOMED qualifier-value concept. The left side is + * the folded recorded text. + * + * What belongs here is what the app ships no string for: the clinical + * shorthand a person types ("neg", "pos") and the detected / not-detected pair + * a lab prints. The negative and positive WORDS are not transcribed here — see + * the derived index below. */ -const QUALITATIVE_SNOMED: Record = { - // Negative-like - negativ: { code: "260385009", display: "Negative" }, - negative: { code: "260385009", display: "Negative" }, - neg: { code: "260385009", display: "Negative" }, +const HAND_WRITTEN_SNOMED: Record = { + neg: NEGATIVE, + pos: POSITIVE, "nicht nachweisbar": { code: "260415000", display: "Not detected" }, "not detected": { code: "260415000", display: "Not detected" }, - // Positive-like - positiv: { code: "10828004", display: "Positive" }, - positive: { code: "10828004", display: "Positive" }, - pos: { code: "10828004", display: "Positive" }, nachweisbar: { code: "260373001", display: "Detected" }, detected: { code: "260373001", display: "Detected" }, }; -/** Collapse a recorded qualitative term to a lookup key. */ +/** + * The quick-pick labels, in every shipped locale, read out of the bundles. + * + * The form writes the label it rendered, so this index and the write side + * cannot drift: whatever `labs.form.qualNegative` says in a language is + * exactly what a reading recorded in that language holds. `qualBorderline` is + * deliberately absent — see the module comment. + */ +function buildLocalisedIndex(): Record { + const index: Record = {}; + const arms: [string, QualitativeConcept][] = [ + ["labs.form.qualNegative", NEGATIVE], + ["labs.form.qualPositive", POSITIVE], + ]; + for (const [key, concept] of arms) { + for (const label of localisedValues(key)) { + const folded = foldForMatch(label); + // A folded label already claimed by the OTHER arm would mean a bundle + // says the same word means both negative and positive. First wins so a + // typo cannot take the export down at boot; the guard suite fails on it. + if (!folded || index[folded]) continue; + index[folded] = concept; + } + } + return index; +} + +const LOCALISED_SNOMED = buildLocalisedIndex(); + +/** + * Test seam: the guard suite asserts the derived index covers every shipped + * locale and disagrees with no hand-written term. Production reads the + * constant. + */ +export function localisedQualitativeIndex(): Readonly< + Record +> { + return LOCALISED_SNOMED; +} + +/** + * Collapse a recorded qualitative term to a lookup key. + * + * Folds case, accents and separators, so a reading typed "Negatif" on a + * keyboard without accents meets the bundle's "négatif", and a stored + * "negativ" resolves exactly as it did before the fold widened. + */ function normaliseQualitativeKey(text: string): string { - return text.trim().toLowerCase().replace(/\s+/g, " "); + return foldForMatch(text); } /** @@ -73,7 +143,8 @@ function normaliseQualitativeKey(text: string): string { export function qualitativeValueConcept( valueText: string, ): FhirCodeableConcept { - const concept = QUALITATIVE_SNOMED[normaliseQualitativeKey(valueText)]; + const key = normaliseQualitativeKey(valueText); + const concept = HAND_WRITTEN_SNOMED[key] ?? LOCALISED_SNOMED[key]; if (!concept) { return { text: valueText }; } From b06d7dbd92f9c41f3ead587c313a7a606e83d93e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 23 Aug 2026 14:28:53 +0200 Subject: [PATCH 5/8] fix(documents): recognise a lab report printed outside English and German MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gate that decides whether a transcribed document is worth an extraction call scored four classes of evidence and needed two. Three of them — the reference-range label, the analyte names, the report header — were written in English and German, so a French, Spanish, Italian or Polish report scored at most one: the units, which are the same everywhere. Auto-staging never fired for it. The document was lost from the flow silently, because the manual extract button stays available and nothing reads as broken. The text is folded once — lower case, accents dropped — and every pattern is matched against that, which on its own recovers a good deal: "Cholestérol", "Créatinine" and "Emoglobina" reach the existing stems as soon as the accents are out of the way. The reference-range labels and report headers of the four locales are added by hand, because they are prose a lab printed and the app owns no string for them. The analyte names ARE derived, from `labs.catalog.` across every shipped bundle: those are names the app itself displays, and a new locale should not need an edit here to be recognised. Names under four folded characters are dropped — "ALT" is German for "old", and a three-letter alternative in a gate this cheap would make every German letter a lab report. The acronyms worth having are in the hand-written half, where each was considered on its own. Which way this errs, stated at the code: a false negative loses a whole document from auto-staging, while a false positive costs one extraction call and stages facts a person then declines — nothing commits either way. So the vocabulary is widened, and the two-distinct-class rule holds the other side. The derived names extend the analyte class rather than forming a fifth one, so a document naming one analyte in two languages still scores one. Tests pin each addition on a case that fails the gate without it, and pin the prose that must keep failing it. --- .../__tests__/auto-stage-labs-locales.test.ts | 213 ++++++++++++++++++ src/lib/documents/auto-stage-labs.ts | 123 +++++++++- 2 files changed, 326 insertions(+), 10 deletions(-) create mode 100644 src/lib/documents/__tests__/auto-stage-labs-locales.test.ts diff --git a/src/lib/documents/__tests__/auto-stage-labs-locales.test.ts b/src/lib/documents/__tests__/auto-stage-labs-locales.test.ts new file mode 100644 index 000000000..53fad3039 --- /dev/null +++ b/src/lib/documents/__tests__/auto-stage-labs-locales.test.ts @@ -0,0 +1,213 @@ +import { describe, expect, it } from "vitest"; + +import { + derivedAnalyteNamePattern, + looksLikeLabDocument, +} from "@/lib/documents/auto-stage-labs"; +import { locales } from "@/lib/i18n/config"; +import { allMessages, resolveKey } from "@/lib/i18n/shared-resolve"; +import { stripDiacritics } from "@/lib/i18n/fold-for-match"; + +/** + * The gate decides whether a transcribed document is worth an extraction call. + * A lab report that fails it is lost from auto-staging entirely, and nothing + * says so — the manual extract button stays available, so the flow reads as + * working. + * + * Before this, the four signal classes were English and German only, so a + * report printed anywhere else scored at most one hit (the units, which are + * the same everywhere) and never reached two. + */ +const FRENCH_REPORT = ` +Laboratoire d'analyses médicales +Compte rendu du 14 mars 2026 + +Cholestérol total 5,8 mmol/l Valeurs de référence: jusqu'à 5,0 +Créatinine 88 µmol/l Plage de référence: 62 - 106 +Hémoglobine 14,2 g/dl +`; + +const SPANISH_REPORT = ` +Laboratorio de análisis clínicos +Analítica de sangre + +Colesterol total 210 mg/dl Valores de referencia: hasta 200 +Glucosa en ayunas 92 mg/dl Rango de referencia: 70 - 100 +`; + +const ITALIAN_REPORT = ` +Referto di laboratorio — esami del sangue + +Colesterolo totale 5,4 mmol/l Valori di riferimento: fino a 5,0 +Emoglobina 13,8 g/dl Intervallo di riferimento: 12 - 16 +`; + +const POLISH_REPORT = ` +Laboratorium analiz lekarskich +Wyniki badań + +Cholesterol całkowity 5,2 mmol/l Zakres referencyjny: do 5,0 +Kreatynina 82 µmol/l Wartości referencyjne: 62 - 106 +`; + +describe("looksLikeLabDocument — a report printed outside English and German", () => { + it.each([ + ["French", FRENCH_REPORT], + ["Spanish", SPANISH_REPORT], + ["Italian", ITALIAN_REPORT], + ["Polish", POLISH_REPORT], + ])("recognises a %s lab report", (_language, text) => { + expect(looksLikeLabDocument(text)).toBe(true); + }); + + it("recognises a report on its analyte names and units alone", () => { + // No header, no reference-range label — the two classes left are the ones + // a stripped-down result table still carries. + expect( + looksLikeLabDocument("Triglycérides 1,4 mmol/l\nFerritine 120 ng/ml"), + ).toBe(true); + }); +}); + +/** + * The four reports above each carry several kinds of evidence at once, so any + * one of the additions could carry them alone. These cases isolate one + * addition each: remove the piece each names and the case drops back to a + * single class and fails the gate. + */ +describe("looksLikeLabDocument — each addition carries its own weight", () => { + it("needs the DERIVED analyte names for a panel of markers no stem covers", () => { + // The electrolytes are in the catalog and in no hand-written stem, so the + // derived names are the only analyte evidence here. Units are the second + // class. + expect( + looksLikeLabDocument("Sodium 140 mmol/l\nPotassium 4,2 mmol/l"), + ).toBe(true); + expect(looksLikeLabDocument("Sodio 140 mmol/l\nPotassio 4,2 mmol/l")).toBe( + true, + ); + }); + + it("needs the reference-range labels of the four locales", () => { + // "Płytki krwi" is a derived name; the label is the only other class here. + expect( + looksLikeLabDocument("Płytki krwi 250\nZakres referencyjny: 150 - 400"), + ).toBe(true); + expect( + looksLikeLabDocument("Plaquettes 250\nValeurs de référence: 150 - 400"), + ).toBe(true); + }); + + it("needs the report headers of the four locales", () => { + expect(looksLikeLabDocument("Referto di laboratorio\nPiastrine 250")).toBe( + true, + ); + expect(looksLikeLabDocument("Wyniki badań\nPłytki krwi 250")).toBe(true); + expect(looksLikeLabDocument("Analítica de sangre\nPlaquetas 250")).toBe( + true, + ); + }); +}); + +describe("looksLikeLabDocument — what must still fail the gate", () => { + it.each([ + [ + "English prose with one incidental unit", + "Please take 500 mg twice daily.", + ], + [ + "a French letter mentioning one analyte", + "Cher patient, votre taux de cholestérol sera revu lors du prochain rendez-vous.", + ], + [ + "a Spanish appointment letter", + "Estimado paciente, su cita en el hospital es el martes a las diez.", + ], + [ + "an Italian pharmacy note", + "Ritirare la confezione in farmacia entro venerdì.", + ], + ["an invoice with a currency amount", "Rechnung über 120,00 EUR. Danke."], + ])("rejects %s", (_case, text) => { + expect(looksLikeLabDocument(text)).toBe(false); + }); + + it("does not let one analyte named twice reach two classes", () => { + // The derived names extend the analyte class rather than forming their + // own. If they were a fifth class, a document saying "Cholestérol total" + // and "cholesterol" would score two and pass on one piece of evidence. + expect(looksLikeLabDocument("Cholestérol total et cholesterol")).toBe( + false, + ); + }); + + it("ignores a derived analyte name embedded in a longer word", () => { + // The hand-written half of the analyte class matches STEMS on purpose + // ("ferritin" catches "Ferritinwert"); the derived half is bounded, + // because a whole catalog name inside another word is noise. + expect(looksLikeLabDocument("Xhematocritx 5 mg/dl")).toBe(false); + }); +}); + +describe("looksLikeLabDocument — the English and German behaviour is unchanged", () => { + it("still recognises a German report", () => { + expect( + looksLikeLabDocument( + "Laborbefund. Hämoglobin 14.2 g/dl. Referenzbereich 13-17. LDL 120 mg/dl.", + ), + ).toBe(true); + }); + + it("still recognises an English report", () => { + expect( + looksLikeLabDocument( + "Laboratory report. Hemoglobin 14.2 g/dL. Reference range 13-17.", + ), + ).toBe(true); + }); + + it("still rejects the prose the previous guard rejected", () => { + expect( + looksLikeLabDocument("Dear patient, please take 500 mg twice daily."), + ).toBe(false); + }); +}); + +describe("the derived analyte-name pattern", () => { + it("carries a name from every shipped locale", () => { + // The property that matters. A transcribed list would satisfy the report + // cases above and still be frozen at the languages somebody typed. + for (const locale of locales) { + const name = resolveKey( + allMessages[locale], + "labs.catalog.total-cholesterol", + ); + expect(name, `${locale} has no catalog name`).toBeTruthy(); + expect( + derivedAnalyteNamePattern().test( + stripDiacritics((name as string).toLowerCase()), + ), + `${locale}: ${name}`, + ).toBe(true); + } + }); + + it("excludes the short acronyms that are ordinary words elsewhere", () => { + // "ALT" is German for "old". A three-character alternative in a gate this + // cheap would make every German letter a lab report. The acronyms worth + // having live in the hand-written class, each considered on its own. + const pattern = derivedAnalyteNamePattern(); + for (const acronym of ["alt", "ast", "ggt", "sod"]) { + expect(pattern.test(acronym), acronym).toBe(false); + } + }); + + it("is not empty, so a matcher that matches nothing cannot pass", () => { + expect(derivedAnalyteNamePattern().test("triglycerides")).toBe(true); + // The Polish "ł" is a single codepoint NFD does not decompose, so it + // survives the fold on both sides and has to be written as the bundle + // writes it. A document that spelled it "plytki" would not match, which is + // the stated limit of folding rather than transliterating. + expect(derivedAnalyteNamePattern().test("płytki krwi")).toBe(true); + }); +}); diff --git a/src/lib/documents/auto-stage-labs.ts b/src/lib/documents/auto-stage-labs.ts index 1c815b7d7..b001471d7 100644 --- a/src/lib/documents/auto-stage-labs.ts +++ b/src/lib/documents/auto-stage-labs.ts @@ -42,28 +42,131 @@ import { } from "@/lib/documents/extract"; import { encryptFactData, encryptFactProvenance } from "@/lib/documents/store"; import { prisma } from "@/lib/db"; +import { stripDiacritics } from "@/lib/i18n/fold-for-match"; +import { localisedValues } from "@/lib/i18n/shared-resolve"; +import { BIOMARKER_CATALOG } from "@/lib/labs/biomarker-catalog"; import { annotate } from "@/lib/logging/context"; import { isModuleEnabled } from "@/lib/modules/gate"; /** * Lab-report signals used to decide whether a transcribed document is worth an - * extraction call. Each pattern is one class of evidence (a reference-range - * label, a lab unit, a common analyte name, a report header); requiring TWO - * distinct hits keeps a stray "mg" in a prose letter from tripping the auto - * extraction while still catching a real panel in German or English. + * extraction call. Each entry is one CLASS of evidence — a reference-range + * label, a lab unit, a common analyte name, a report header — and contributes + * at most one hit however many of its alternatives fire. Requiring TWO + * distinct classes keeps a stray "mg" in a prose letter from tripping the auto + * extraction while still catching a real panel. + * + * Every pattern is tested against the text LOWER-CASED AND ACCENT-FOLDED, not + * against the transcription as written, so the patterns are spelled without + * accents. That is also what lets one pattern serve "Hämoglobin", "haemoglobin" + * and "hemoglobin" instead of three. + * + * Until now the four classes were English and German only, so a French, + * Spanish, Italian or Polish lab report scored at most one hit — the units, + * which are the same everywhere — and auto-staging never fired for it. The + * whole document was lost from the flow, silently: the manual extract button + * stays available, so nothing anywhere reads as broken. + * + * WHICH WAY THIS ERRS. A false negative loses a real report from auto-staging; + * a false positive burns one extraction call on a document that is not a lab + * report, and stages facts a person then declines on the review screen — + * nothing commits either way. The cost is lopsided, so the vocabulary is + * widened rather than kept tight, and the two-distinct-class rule is what + * holds the other side: an incidental "sodium" in a prose letter is one class + * and still fails the gate. Terms whose only home is a lab report ("emocromo", + * "bilan sanguin", "zakres referencyjny") were preferred over generic ones, + * and short acronyms below four characters are excluded from the derived names + * for exactly this reason — the German word "alt" would otherwise make every + * German letter a lab report. */ const LAB_SIGNALS: readonly RegExp[] = [ - /reference range|referenzbereich|normbereich|normal range|ref\.?-?bereich/i, + // 1 — a reference-range label. Only a report that prints windows says this. + /reference range|referenzbereich|normbereich|normal range|ref\.?-?bereich|valeurs? de reference|intervalle de reference|plage de reference|valores? de referencia|rango de referencia|intervalo de referencia|valori di riferimento|intervallo di riferimento|zakres referencyjny|wartosci referencyjne/i, + // 2 — a lab unit. Language-independent already; unchanged. /\b(mg\/dl|mmol\/l|nmol\/l|µmol\/l|umol\/l|g\/dl|µg\/l|ug\/l|ng\/ml|pg\/ml|mmol\/mol|u\/l|iu\/l|mg\/l|\/µl|\/ul)\b/i, - /h(?:ä|ae)moglobin|hemoglobin|glu[ck]ose|cholesterin|cholesterol|kreatinin|creatinine|hba1c|leuko|erythro|thrombo|ferritin|triglycerid|\btsh\b|\bldl\b|\bhdl\b|\bcrp\b|vitamin\s?d/i, - /labor(?:befund|wert)?|laboratory|blutbild|\bbefund\b/i, + // 3 — an analyte name. The hand-written half: word stems and the short + // acronyms, which are too short to derive safely but were vetted long ago. + /h(?:a|ae|e)moglobin|glu[ck]ose|cholesterin|cholesterol|kreatinin|creatinine|hba1c|leuko|erythro|thrombo|ferritin|triglycerid|\btsh\b|\bldl\b|\bhdl\b|\bcrp\b|vitamin\s?d/i, + // 4 — a report header. + /labor(?:befund|wert)?|laboratory|blutbild|\bbefund\b|laboratoire|bilan sanguin|analyses medicales|hemogramme|laboratorio|analitica|hemograma|analisis de sangre|esami del sangue|emocromo|referto|laboratorium|morfologia krwi|wyniki badan/i, ]; -/** True when the transcribed text carries at least two lab-report signals. */ +/** + * Class 3 again, DERIVED from `messages/.json` instead of transcribed. + * + * The names above are stems a maintainer typed. These are the names the app + * itself displays for the markers in its catalog, in every language it ships — + * so a French report's "Cholestérol total" or a Polish "Płytki krwi" counts as + * an analyte the day the bundle carries it, with no edit here. Transcribing + * thirty names times four more languages would have fixed today's six locales + * and reopened the hole at the seventh. + * + * Names shorter than four folded characters are dropped: "ALT", "AST", "GGT", + * "TSH" and Polish "Sód" are ordinary words or fragments in some language, and + * a three-letter alternative in a gate this cheap is a false-positive machine. + * The acronyms worth having are already in the hand-written class above, where + * each was considered one at a time. + */ +function buildAnalyteNamePattern(): RegExp { + const names = new Set(); + for (const seed of BIOMARKER_CATALOG) { + for (const name of localisedValues(`labs.catalog.${seed.slug}`)) { + const folded = stripDiacritics(name.toLowerCase()).trim(); + // Also the name without its parenthetical gloss. A report prints + // "Apolipoprotéine B", not "Apolipoprotéine B (ApoB)", and the gloss is + // the app's way of naming a marker two ways at once. + const bare = folded.replace(/\s*\([^)]*\)/gu, "").trim(); + for (const candidate of [folded, bare]) { + if (candidate.length >= 4) names.add(candidate); + } + } + } + const alternation = [...names] + .sort((a, b) => b.length - a.length) + .map((name) => + name + .split(/\s+/u) + .map((part) => part.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&")) + // Any run of whitespace, so a name broken across a transcribed line + // still reads as one name. + .join(String.raw`\s+`), + ) + .join("|"); + // Bounded by "not a letter or digit" rather than by \b, because several + // names end in punctuation ("Lipoprotéine(a)") where \b cannot match, and + // because the Polish "ł" is not a word character to \b. + return new RegExp( + `(?= 2) return true; + for (const [index, signal] of LAB_SIGNALS.entries()) { + const analyteClass = index === 2; + const hit = + signal.test(folded) || + (analyteClass && DERIVED_ANALYTE_NAMES.test(folded)); + if (hit && ++hits >= 2) return true; } return false; } From 154b6097680cf98cb4ac40f95f4a3faa405819b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 23 Aug 2026 14:44:23 +0200 Subject: [PATCH 6/8] refactor(documents): attach the derived analyte names to their signal class by identity The derived names extended the analyte class by list index. Reordering the signal list would have re-pointed them at the units or the report header with nothing to notice, so the class is a named constant and the check compares against it. --- src/lib/documents/auto-stage-labs.ts | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/lib/documents/auto-stage-labs.ts b/src/lib/documents/auto-stage-labs.ts index b001471d7..52ccaca2c 100644 --- a/src/lib/documents/auto-stage-labs.ts +++ b/src/lib/documents/auto-stage-labs.ts @@ -79,14 +79,23 @@ import { isModuleEnabled } from "@/lib/modules/gate"; * for exactly this reason — the German word "alt" would otherwise make every * German letter a lab report. */ +/** + * The analyte class, hand-written half: word stems and the short acronyms, + * which are too short to derive safely but were vetted one at a time long ago. + * Named rather than inlined so the derived names can be attached to THIS class + * by identity — an index would silently re-point them at another class the + * first time somebody reorders the list. + */ +const ANALYTE_SIGNAL = + /h(?:a|ae|e)moglobin|glu[ck]ose|cholesterin|cholesterol|kreatinin|creatinine|hba1c|leuko|erythro|thrombo|ferritin|triglycerid|\btsh\b|\bldl\b|\bhdl\b|\bcrp\b|vitamin\s?d/i; + const LAB_SIGNALS: readonly RegExp[] = [ // 1 — a reference-range label. Only a report that prints windows says this. /reference range|referenzbereich|normbereich|normal range|ref\.?-?bereich|valeurs? de reference|intervalle de reference|plage de reference|valores? de referencia|rango de referencia|intervalo de referencia|valori di riferimento|intervallo di riferimento|zakres referencyjny|wartosci referencyjne/i, // 2 — a lab unit. Language-independent already; unchanged. /\b(mg\/dl|mmol\/l|nmol\/l|µmol\/l|umol\/l|g\/dl|µg\/l|ug\/l|ng\/ml|pg\/ml|mmol\/mol|u\/l|iu\/l|mg\/l|\/µl|\/ul)\b/i, - // 3 — an analyte name. The hand-written half: word stems and the short - // acronyms, which are too short to derive safely but were vetted long ago. - /h(?:a|ae|e)moglobin|glu[ck]ose|cholesterin|cholesterol|kreatinin|creatinine|hba1c|leuko|erythro|thrombo|ferritin|triglycerid|\btsh\b|\bldl\b|\bhdl\b|\bcrp\b|vitamin\s?d/i, + // 3 — an analyte name. + ANALYTE_SIGNAL, // 4 — a report header. /labor(?:befund|wert)?|laboratory|blutbild|\bbefund\b|laboratoire|bilan sanguin|analyses medicales|hemogramme|laboratorio|analitica|hemograma|analisis de sangre|esami del sangue|emocromo|referto|laboratorium|morfologia krwi|wyniki badan/i, ]; @@ -161,11 +170,10 @@ export function looksLikeLabDocument(text: string): boolean { // pattern needs the same folded form. const folded = stripDiacritics(text.toLowerCase()); let hits = 0; - for (const [index, signal] of LAB_SIGNALS.entries()) { - const analyteClass = index === 2; + for (const signal of LAB_SIGNALS) { const hit = signal.test(folded) || - (analyteClass && DERIVED_ANALYTE_NAMES.test(folded)); + (signal === ANALYTE_SIGNAL && DERIVED_ANALYTE_NAMES.test(folded)); if (hit && ++hits >= 2) return true; } return false; From bec4cb3dfbe258ecbf93542d885e09c3a44383b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 23 Aug 2026 14:46:10 +0200 Subject: [PATCH 7/8] fix(mcp): let five of the six shipped languages name a metric MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/api/mcp-capabilities.md | 29 +++ .../__tests__/localised-label-index.test.ts | 111 +++++++++ src/lib/i18n/localised-label-index.ts | 139 +++++++++++ src/lib/mcp/__tests__/nutrients-read.test.ts | 85 ++++++- src/lib/mcp/__tests__/rich-reads.test.ts | 167 +++++++++++++ src/lib/mcp/nutrients-read.ts | 110 +++++---- src/lib/mcp/rich-reads.ts | 85 ++++++- src/lib/mcp/tools.ts | 10 +- .../__tests__/med-target-map.test.ts | 158 +++++++++++++ src/lib/medications/med-target-map.ts | 220 +++++++++++++++--- 10 files changed, 1026 insertions(+), 88 deletions(-) create mode 100644 src/lib/i18n/__tests__/localised-label-index.test.ts create mode 100644 src/lib/i18n/localised-label-index.ts diff --git a/docs/api/mcp-capabilities.md b/docs/api/mcp-capabilities.md index 2117f6b8b..92e0a0e23 100644 --- a/docs/api/mcp-capabilities.md +++ b/docs/api/mcp-capabilities.md @@ -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)**, diff --git a/src/lib/i18n/__tests__/localised-label-index.test.ts b/src/lib/i18n/__tests__/localised-label-index.test.ts new file mode 100644 index 000000000..971260c29 --- /dev/null +++ b/src/lib/i18n/__tests__/localised-label-index.test.ts @@ -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"); + }); +}); diff --git a/src/lib/i18n/localised-label-index.ts b/src/lib/i18n/localised-label-index.ts new file mode 100644 index 000000000..b285af109 --- /dev/null +++ b/src/lib/i18n/localised-label-index.ts @@ -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/.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 = [ + [/ł/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 { + /** + * 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 { + /** Folded label → value. Ambiguous folds are absent, never guessed. */ + index: ReadonlyMap; + /** + * 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; +} + +/** + * 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( + entries: readonly LocalisedLabelEntry[], +): LocalisedLabelIndex { + const index = new Map(); + const owner = new Map(); + const ambiguous = new Set(); + + 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 }; +} diff --git a/src/lib/mcp/__tests__/nutrients-read.test.ts b/src/lib/mcp/__tests__/nutrients-read.test.ts index 921dbc6a5..6f7822ea0 100644 --- a/src/lib/mcp/__tests__/nutrients-read.test.ts +++ b/src/lib/mcp/__tests__/nutrients-read.test.ts @@ -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); diff --git a/src/lib/mcp/__tests__/rich-reads.test.ts b/src/lib/mcp/__tests__/rich-reads.test.ts index 66c03b607..8b67adda7 100644 --- a/src/lib/mcp/__tests__/rich-reads.test.ts +++ b/src/lib/mcp/__tests__/rich-reads.test.ts @@ -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 = [ diff --git a/src/lib/mcp/nutrients-read.ts b/src/lib/mcp/nutrients-read.ts index 6d4b457ac..1696fd5a6 100644 --- a/src/lib/mcp/nutrients-read.ts +++ b/src/lib/mcp/nutrients-read.ts @@ -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> = { - 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> = + 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, + ); + +/** + * 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( + 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`. */ diff --git a/src/lib/mcp/rich-reads.ts b/src/lib/mcp/rich-reads.ts index 1dacd61cb..566e5b81e 100644 --- a/src/lib/mcp/rich-reads.ts +++ b/src/lib/mcp/rich-reads.ts @@ -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(); + 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[] = 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 = + 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); diff --git a/src/lib/mcp/tools.ts b/src/lib/mcp/tools.ts index 87216ec11..2c7281413 100644 --- a/src/lib/mcp/tools.ts +++ b/src/lib/mcp/tools.ts @@ -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() diff --git a/src/lib/medications/__tests__/med-target-map.test.ts b/src/lib/medications/__tests__/med-target-map.test.ts index 3c3bf6ecf..9376b415b 100644 --- a/src/lib/medications/__tests__/med-target-map.test.ts +++ b/src/lib/medications/__tests__/med-target-map.test.ts @@ -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(); + 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(); + }); +}); diff --git a/src/lib/medications/med-target-map.ts b/src/lib/medications/med-target-map.ts index 01d360a65..63d49768a 100644 --- a/src/lib/medications/med-target-map.ts +++ b/src/lib/medications/med-target-map.ts @@ -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 = [ + [/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 { + 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, +): 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 = [ 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 +> = { + 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; } From 4f2c90b66cd93ac710a5764117393bede9f577a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 23 Aug 2026 14:58:51 +0200 Subject: [PATCH 8/8] Fold the side-effect tag through the shared helper The three locale matchers were built in parallel and each grew its own fold. Two of them are the same function: the side-effect tag fold and foldForMatch agree character for character, so one now calls the other. The third stays. foldLabel decomposes with NFKD, maps sharp s to ss and joins with underscores; foldForMatch decomposes with NFD, leaves sharp s alone and joins with spaces. That is a disagreement about who typed the text, not an accident: one side of a lab comparison is a shipped label retyped or prose a lab printed, where tolerating a different spelling claims more than the fold can prove, while someone naming a metric to an assistant is composing rather than reproducing. Both docblocks now say so and name the other. --- src/lib/i18n/fold-for-match.ts | 18 ++++++++++++++++++ src/lib/medications/glp1-side-effect-tags.ts | 11 +++-------- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/src/lib/i18n/fold-for-match.ts b/src/lib/i18n/fold-for-match.ts index db0815e91..733dad6d5 100644 --- a/src/lib/i18n/fold-for-match.ts +++ b/src/lib/i18n/fold-for-match.ts @@ -18,6 +18,24 @@ * Client-safe: no message bundles are imported here, so a client component may * use these. Building an index OVER the bundles is a server concern and lives * in `./shared-resolve`. + * + * ## Why there is a second fold, and when to reach for it + * + * `./localised-label-index` carries `foldLabel`, and the two disagree ON + * PURPOSE rather than by accident. That one decomposes with NFKD, maps "ß" to + * "ss" and "œ" to "oe", and joins with "_"; this one decomposes with NFD, + * leaves "ß" alone, and joins with spaces. + * + * The difference is who typed the text. Here the other side of the comparison + * is a shipped label a person retyped, or prose a lab printed, so tolerating a + * DIFFERENT SPELLING would be reaching beyond what the fold can honestly + * claim. There, someone is naming a metric to an assistant in their own words, + * and "Suessstoff" for "Süßstoff" is the same request; the wider fold earns + * its keep because the caller is composing, not reproducing. + * + * Keep them apart. Whichever you pick, apply it to BOTH sides of the + * comparison — a fold on one side only is an exact-string match in a + * disguise. */ /** Curly, modifier and backtick apostrophes a keyboard or word processor emits. */ diff --git a/src/lib/medications/glp1-side-effect-tags.ts b/src/lib/medications/glp1-side-effect-tags.ts index 3566729dc..065755469 100644 --- a/src/lib/medications/glp1-side-effect-tags.ts +++ b/src/lib/medications/glp1-side-effect-tags.ts @@ -21,6 +21,8 @@ * therefore lives server-side in `./glp1-side-effect-tag-match`. */ +import { foldForMatch } from "@/lib/i18n/fold-for-match"; + /** * The catalogue, in display order. `key` is the stable machine value that * crosses the API and the Coach prompt; `labelKey` is the i18n key the chip @@ -85,14 +87,7 @@ export const GLP1_SIDE_EFFECT_TAG_LABEL_KEYS: Record< * the label the matcher indexes. */ export function normaliseSideEffectTag(raw: string): string { - return raw - .toLowerCase() - .normalize("NFD") - .replace(/[\u0300-\u036f]/g, "") - .replace(/[\u2018\u2019\u02bc'`]/g, " ") - .replace(/[-_/]/g, " ") - .replace(/\s+/g, " ") - .trim(); + return foldForMatch(raw); } /**