mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
fix(insights): strip metric tokens in recommendation prose + DE component labels
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.
- `<RecommendationCard>` 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) <noreply@anthropic.com>
This commit is contained in:
1 parent
4d3b12936a
commit
59a4a89b62
4 files changed
+72
-4
No files matched your search
+3
-3
@@ -774,10 +774,10 @@
|
|||||||
"progressAria": "Gesundheitsscore-Fortschritt",
|
"progressAria": "Gesundheitsscore-Fortschritt",
|
||||||
"deltaVsLastWeek": "{delta} im Vergleich zur Vorwoche",
|
"deltaVsLastWeek": "{delta} im Vergleich zur Vorwoche",
|
||||||
"deltaUnavailable": "Noch keine Historie — schau nächste Woche wieder vorbei.",
|
"deltaUnavailable": "Noch keine Historie — schau nächste Woche wieder vorbei.",
|
||||||
"componentBp": "BD",
|
"componentBp": "Blutdruck",
|
||||||
"componentWeight": "Gewicht",
|
"componentWeight": "Gewicht",
|
||||||
"componentMood": "Mood",
|
"componentMood": "Stimmung",
|
||||||
"componentCompliance": "Medis",
|
"componentCompliance": "Einnahmetreue",
|
||||||
"disclaimer": "Orientierungshilfe — keine ärztliche Bewertung.",
|
"disclaimer": "Orientierungshilfe — keine ärztliche Bewertung.",
|
||||||
"askCoach": "Coach fragen",
|
"askCoach": "Coach fragen",
|
||||||
"coachPrompt": "Warum ist mein Gesundheitsscore {score} von 100?"
|
"coachPrompt": "Warum ist mein Gesundheitsscore {score} von 100?"
|
||||||
|
|||||||
@@ -243,4 +243,36 @@ describe("<RecommendationCard>", () => {
|
|||||||
);
|
);
|
||||||
expect(html).not.toContain("data-feedback-thumb=");
|
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(<RecommendationCard rec={recWithToken} index={0} />);
|
||||||
|
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(
|
||||||
|
<RecommendationCard rec={recWithLowercaseToken} index={0} />,
|
||||||
|
);
|
||||||
|
expect(html).not.toContain("metric:blood_pressure_sys");
|
||||||
|
expect(html).toContain("Watch the trend.");
|
||||||
|
expect(html).toContain("today");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -11,6 +11,7 @@ import type {
|
|||||||
import { HealthChart } from "@/components/charts/health-chart";
|
import { HealthChart } from "@/components/charts/health-chart";
|
||||||
import { MoodChart } from "@/components/charts/mood-chart";
|
import { MoodChart } from "@/components/charts/mood-chart";
|
||||||
import { getMedicalReferenceById } from "@/lib/ai/medical-references";
|
import { getMedicalReferenceById } from "@/lib/ai/medical-references";
|
||||||
|
import { stripChartTokens } from "@/lib/insights/chart-tokens";
|
||||||
import type { Locale } from "@/lib/i18n/config";
|
import type { Locale } from "@/lib/i18n/config";
|
||||||
import {
|
import {
|
||||||
RecommendationFeedback,
|
RecommendationFeedback,
|
||||||
@@ -333,7 +334,9 @@ export function RecommendationCard({
|
|||||||
{norm.severity}
|
{norm.severity}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
<p className="text-sm leading-snug">{norm.text}</p>
|
<p className="text-sm leading-snug">
|
||||||
|
{stripChartTokens(norm.text)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -327,4 +327,37 @@ describe("i18n locale file integrity", () => {
|
|||||||
todos.map((s) => ` ${s}`).join("\n"),
|
todos.map((s) => ` ${s}`).join("\n"),
|
||||||
).toEqual([]);
|
).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);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user