From 59a4a89b62d8e5ff4f030e0e54d3a718b763e0f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 10 May 2026 20:54:21 +0200 Subject: [PATCH] fix(insights): strip metric tokens in recommendation prose + DE component labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related leak fixes traced to the same root: every other AI-prose surface ran user-visible text through `stripChartTokens()`, but the recommendation card's always-visible text was rendered directly. The model embeds `metric:WEIGHT` and friends in the rec text expecting an inline mini-chart, but the chart slot lives in the EXPANDED rationale block — so the token had nowhere to be consumed and leaked verbatim to the DOM. - `` now wraps `norm.text` in `stripChartTokens()`, matching the contract every other rec / finding / summary surface already uses. - DE Health-Score component labels surfaced English nouns ("Mood", "Medis", "BD") instead of full German. Rename to "Blutdruck", "Stimmung", "Einnahmetreue", "Gewicht" so the four sub-bars read natively German. The "Mut" the maintainer reported on prod was voice-to-text rendering "Mood". - i18n-integrity test pins the four component labels so a copy-paste regression can't reintroduce the leak. Co-Authored-By: Claude Opus 4.7 (1M context) --- messages/de.json | 6 ++-- .../__tests__/recommendation-card.test.tsx | 32 ++++++++++++++++++ .../insights/recommendation-card.tsx | 5 ++- .../__tests__/i18n-locale-integrity.test.ts | 33 +++++++++++++++++++ 4 files changed, 72 insertions(+), 4 deletions(-) diff --git a/messages/de.json b/messages/de.json index 4629a54dc..4d9862a6c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -774,10 +774,10 @@ "progressAria": "Gesundheitsscore-Fortschritt", "deltaVsLastWeek": "{delta} im Vergleich zur Vorwoche", "deltaUnavailable": "Noch keine Historie — schau nächste Woche wieder vorbei.", - "componentBp": "BD", + "componentBp": "Blutdruck", "componentWeight": "Gewicht", - "componentMood": "Mood", - "componentCompliance": "Medis", + "componentMood": "Stimmung", + "componentCompliance": "Einnahmetreue", "disclaimer": "Orientierungshilfe — keine ärztliche Bewertung.", "askCoach": "Coach fragen", "coachPrompt": "Warum ist mein Gesundheitsscore {score} von 100?" diff --git a/src/components/insights/__tests__/recommendation-card.test.tsx b/src/components/insights/__tests__/recommendation-card.test.tsx index a37ba0b3b..57ffdd9ee 100644 --- a/src/components/insights/__tests__/recommendation-card.test.tsx +++ b/src/components/insights/__tests__/recommendation-card.test.tsx @@ -243,4 +243,36 @@ describe("", () => { ); expect(html).not.toContain("data-feedback-thumb="); }); + + /** + * v1.4.22 A6 — strip chart-tokens from the always-visible + * recommendation text. W1a §3 traced the production "metric:WEIGHT" + * leak Marc saw at the bottom of the advisor card to this exact + * render path: every other AI-prose surface ran through + * `stripChartTokens()`, the recommendation text didn't. Pin the + * strip so a future copy-paste regression can't reintroduce the + * leak. + */ + it("strips literal chart tokens (metric:WEIGHT et al) from the rec text", () => { + const recWithToken = { + ...recBase, + text: "Increase fluids. metric:WEIGHT", + }; + const html = render(); + expect(html).not.toContain("metric:WEIGHT"); + expect(html).toContain("Increase fluids."); + }); + + it("strips lowercase / mixed-case model output from the rec text", () => { + const recWithLowercaseToken = { + ...recBase, + text: "Watch the trend. metric:blood_pressure_sys today", + }; + const html = render( + , + ); + expect(html).not.toContain("metric:blood_pressure_sys"); + expect(html).toContain("Watch the trend."); + expect(html).toContain("today"); + }); }); diff --git a/src/components/insights/recommendation-card.tsx b/src/components/insights/recommendation-card.tsx index 464544afa..cf3d5e63f 100644 --- a/src/components/insights/recommendation-card.tsx +++ b/src/components/insights/recommendation-card.tsx @@ -11,6 +11,7 @@ import type { import { HealthChart } from "@/components/charts/health-chart"; import { MoodChart } from "@/components/charts/mood-chart"; import { getMedicalReferenceById } from "@/lib/ai/medical-references"; +import { stripChartTokens } from "@/lib/insights/chart-tokens"; import type { Locale } from "@/lib/i18n/config"; import { RecommendationFeedback, @@ -333,7 +334,9 @@ export function RecommendationCard({ {norm.severity} )} -

{norm.text}

+

+ {stripChartTokens(norm.text)} +

diff --git a/src/lib/__tests__/i18n-locale-integrity.test.ts b/src/lib/__tests__/i18n-locale-integrity.test.ts index 2b9cd4f54..486484205 100644 --- a/src/lib/__tests__/i18n-locale-integrity.test.ts +++ b/src/lib/__tests__/i18n-locale-integrity.test.ts @@ -327,4 +327,37 @@ describe("i18n locale file integrity", () => { todos.map((s) => ` ${s}`).join("\n"), ).toEqual([]); }); + + /** + * v1.4.22 A6 — DE locale must not leak English nouns for the + * Health-Score component contract. + * + * The four `componentBp` / `componentWeight` / `componentMood` / + * `componentCompliance` keys are rendered as the four sub-bar + * labels on the Health Score card. Up to v1.4.21 the DE locale + * shipped `componentMood: "Mood"` (an English noun voice-to-text + * rendered as "Mut" on a quick read). Pin the German values so a + * future copy-paste regression can't reintroduce the leak. + */ + it("DE locale renders Health-Score component labels in German", () => { + const de = JSON.parse(readFileSync(DE_PATH, "utf8")) as Record< + string, + unknown + >; + const flat: [string, string][] = []; + flattenValues(de, "", flat); + const byKey = new Map(flat); + + const EXPECTED: Array<[string, string]> = [ + ["insights.healthScore.componentBp", "Blutdruck"], + ["insights.healthScore.componentWeight", "Gewicht"], + ["insights.healthScore.componentMood", "Stimmung"], + ["insights.healthScore.componentCompliance", "Einnahmetreue"], + ]; + + for (const [key, expectedDe] of EXPECTED) { + const actual = byKey.get(key); + expect(actual, `DE label for ${key}`).toBe(expectedDe); + } + }); });