From fa91a73aef4cfa4a13906090c6e336b22f17b254 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 10 May 2026 13:14:53 +0200 Subject: [PATCH] fix(insights): strip lowercase chart tokens from AI prose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Marc reported a raw "metric: blood_pressure_sweet" string at the bottom of /insights — looked like a debug code or unrendered template. Root cause: the v1.4.16 strip regex `/metric:[A-Z_]+/g` only matches uppercase letters and underscores, but the model occasionally emits lowercase / snake_case forms like `metric:blood_pressure` or `metric:weight_trend`. Those slipped through `stripChartTokens()` unchanged and the literal token text landed in the DOM. Split the regex in two: - `STRIP_TOKEN_REGEX` (`[A-Za-z0-9_]+`) — permissive; cleaves any case from prose so unrenderable / hallucinated tokens never surface as text. - `PARSE_TOKEN_REGEX` (`[A-Z_]+`) — strict; the render path still only mounts charts for the uppercase allowlisted vocabulary. Allowlist semantics are unchanged — `parseChartTokens()` still returns only entries from `ALLOWED_CHART_TOKENS`. The change is purely about defending the visible-prose surface from the wider class of token shapes the LLM occasionally emits. XSS-style trailing junk (`metric:WEIGHT' onclick='alert(1)'`) still cleaves cleanly: the apostrophe terminates either class. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib/insights/chart-tokens.ts | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/src/lib/insights/chart-tokens.ts b/src/lib/insights/chart-tokens.ts index de859ea29..16e12c383 100644 --- a/src/lib/insights/chart-tokens.ts +++ b/src/lib/insights/chart-tokens.ts @@ -39,11 +39,20 @@ export type ChartToken = (typeof ALLOWED_CHART_TOKENS)[number]; const ALLOWED_SET = new Set(ALLOWED_CHART_TOKENS); -// Greedy `[A-Z_]+` match. Uppercase letters and underscores only — anything -// else (apostrophes, spaces, lowercase, digits) terminates the token, so -// trailing junk like `metric:WEIGHT' onclick='alert(1)'` cleaves cleanly -// into the safe metric and an inert text remainder. -const TOKEN_REGEX = /metric:[A-Z_]+/g; +// v1.4.19 A3 — strip vs parse split. The strip-side character class +// is permissive (`[A-Za-z0-9_]+`) so model-emitted lowercase or +// snake_case tokens like `metric:blood_pressure_sweet_spot` (verbatim +// Marc 2026-05-10) get cleaved out of prose instead of leaking to the +// DOM. The parse-side class stays uppercase-only so unrenderable / +// hallucinated tokens are dropped from the *render* path entirely; +// only the *strip* is permissive. +// +// Trailing junk like `metric:WEIGHT' onclick='alert(1)'` still +// cleaves cleanly: the apostrophe terminates either character class, +// and the surviving uppercase token is the only one filtered through +// the allowlist for rendering. +const STRIP_TOKEN_REGEX = /metric:[A-Za-z0-9_]+/g; +const PARSE_TOKEN_REGEX = /metric:[A-Z_]+/g; /** * Find every well-formed chart token in `text` and return only those that @@ -62,7 +71,7 @@ export function parseChartTokens( text: string | null | undefined, ): ChartToken[] { if (typeof text !== "string") return []; - const matches: string[] = text.match(TOKEN_REGEX) ?? []; + const matches: string[] = text.match(PARSE_TOKEN_REGEX) ?? []; return matches.filter((m): m is ChartToken => ALLOWED_SET.has(m)); } @@ -78,7 +87,7 @@ export function parseChartTokens( export function stripChartTokens(text: string | null | undefined): string { if (typeof text !== "string") return ""; return text - .replace(TOKEN_REGEX, "") + .replace(STRIP_TOKEN_REGEX, "") .replace(/\s{2,}/g, " ") .trim(); }