feat(insights): plumb stripChartTokens through every AI-prose surface

The v1.4.22 stripper only ran on the InsightAdvisorCard summary,
findings, primaryRecommendation, and the RecommendationCard top-line
rec text. Every other AI-prose surface bypassed it entirely, so the
v1.4.25 W5b regex extension (capitalised Metric + orphan enum
patterns) silently could not help them.

Wire stripChartTokens() through every render path that pipes AI
output to the user:

- DailyBriefing — paragraph, finding.headline, finding.detail
- WeeklyReport — summary, goingWell[], worthWatching[], tips[],
  dataQualityNotes
- TrendAnnotation — annotation sentence
- RecommendationCard — rationale rows (dataWindow, comparedTo,
  deviation) inside the expanded RationaleCard
- InsightsCardPreview — dashboard mini-card rec text
- Coach message-thread — assistant bubble content (streaming +
  persisted). User bubbles stay untouched on purpose: their content
  is user-typed, not AI-emitted.

User bubbles, evidence-block key/value rows (constrained to the
sentinel grammar by the Coach prompt), and the deterministic
server-generated correlation interpretations stay raw — the leak
surface is AI prose, not those structured fields.
This commit is contained in:
Marc-André Bombeck committed 2026-05-14 12:47:40 +02:00
1 parent 6d6f4c4efb
commit a19768c5d4
6 files changed
+37 -14

No files matched your search

@@ -13,6 +13,7 @@ import {
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
import { useAuth } from "@/hooks/use-auth";
import { useCoachPrefs } from "@/hooks/use-coach-prefs";
@@ -330,7 +331,17 @@ function ChatBubble({
inProgress && "animate-pulse motion-reduce:animate-none",
)}
>
{content || (inProgress ? t("insights.coach.thinking") : "")}
{/* v1.4.25 W5b — strip stray Metric/enum leak tokens from
the assistant prose before it lands in the bubble. The
raw `content` is the streamed Coach reply (or the
persisted twin after `done` fires); both paths are AI-
authored so they share the same leak surface as the
insight prose elsewhere. */}
{content
? stripChartTokens(content)
: inProgress
? t("insights.coach.thinking")
: ""}
</div>
{safeError && (
<p className="text-dracula-orange/90 text-xs">{safeError}</p>
+6 -3
View File
@@ -23,6 +23,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { useTranslations } from "@/lib/i18n/context";
import { formatRelativeTime } from "@/lib/i18n/relative-time";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
import { cn } from "@/lib/utils";
import type {
DailyBriefing as DailyBriefingPayload,
@@ -143,11 +144,13 @@ function KeyFindingRow({ finding }: { finding: DailyBriefingKeyFinding }) {
/>
<div className="min-w-0 flex-1 space-y-1">
<div className="flex items-start justify-between gap-3">
<p className="text-sm leading-snug font-medium">{finding.headline}</p>
<p className="text-sm leading-snug font-medium">
{stripChartTokens(finding.headline)}
</p>
<DeltaBadge delta={finding.delta} tone={finding.tone} />
</div>
<p className="text-muted-foreground text-xs leading-snug">
{finding.detail}
{stripChartTokens(finding.detail)}
</p>
</div>
</div>
@@ -228,7 +231,7 @@ export function DailyBriefing({
data-slot="daily-briefing-paragraph"
className="text-foreground text-sm leading-relaxed"
>
{briefing.paragraph}
{stripChartTokens(briefing.paragraph)}
</p>
{briefing.keyFindings.length > 0 && (
<div className="space-y-2">
+4 -1
View File
@@ -5,6 +5,7 @@ import { Sparkles, ArrowRight } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
import type { InsightResult } from "@/lib/ai/types";
import { sortRecommendationsBySeverity } from "./recommendations-grid";
import { ConfidenceMeter } from "./confidence-meter";
@@ -101,7 +102,9 @@ export function InsightsCardPreview({
key={id ?? index}
className={`bg-card/40 flex items-start justify-between gap-2 rounded-md border-l-2 px-3 py-2 ${borderClass}`}
>
<p className="text-sm leading-snug">{text}</p>
<p className="text-sm leading-snug">
{stripChartTokens(text)}
</p>
{typeof confidence === "number" && (
<span className="shrink-0">
<ConfidenceMeter value={confidence} variant="ring" />
@@ -208,15 +208,15 @@ function RationaleCard({
<div className="space-y-1.5">
<RationaleRow
label={t("insights.recommendation.rationaleWindow")}
value={rationale.dataWindow}
value={stripChartTokens(rationale.dataWindow)}
/>
<RationaleRow
label={t("insights.recommendation.rationaleComparedTo")}
value={rationale.comparedTo}
value={stripChartTokens(rationale.comparedTo)}
/>
<RationaleRow
label={t("insights.recommendation.rationaleDeviation")}
value={rationale.deviation}
value={stripChartTokens(rationale.deviation)}
/>
</div>
+4 -1
View File
@@ -3,6 +3,7 @@
import { Sparkles } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { useTranslations } from "@/lib/i18n/context";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
import { cn } from "@/lib/utils";
import { CONFIDENCE_BADGE_CLASS } from "./confidence-badge";
@@ -73,7 +74,9 @@ export function TrendAnnotation({
aria-hidden="true"
/>
<div className="min-w-0 flex-1 space-y-1">
<p className="text-foreground text-xs leading-snug">{annotation}</p>
<p className="text-foreground text-xs leading-snug">
{stripChartTokens(annotation)}
</p>
{confidence ? (
<Badge
data-slot="trend-annotation-confidence"
@@ -16,6 +16,7 @@ import {
import { Button } from "@/components/ui/button";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
import { weekISOToRange } from "@/lib/insights/week-iso";
import type { WeeklyReport } from "@/lib/ai/schema";
import { useAuth } from "@/hooks/use-auth";
@@ -189,7 +190,9 @@ export function WeeklyReportPresentation({
title={t("insights.report.sectionSummary")}
accent="text-dracula-purple"
>
<p className="text-sm leading-relaxed">{report.summary}</p>
<p className="text-sm leading-relaxed">
{stripChartTokens(report.summary)}
</p>
</Section>
{/* Going well */}
@@ -200,7 +203,7 @@ export function WeeklyReportPresentation({
accent="text-dracula-green"
icon={<CheckCircle2 className="h-3.5 w-3.5" aria-hidden="true" />}
>
<BulletList items={report.goingWell} />
<BulletList items={report.goingWell.map(stripChartTokens)} />
</Section>
)}
@@ -212,7 +215,7 @@ export function WeeklyReportPresentation({
accent="text-dracula-orange"
icon={<Eye className="h-3.5 w-3.5" aria-hidden="true" />}
>
<BulletList items={report.worthWatching} />
<BulletList items={report.worthWatching.map(stripChartTokens)} />
</Section>
)}
@@ -224,7 +227,7 @@ export function WeeklyReportPresentation({
accent="text-dracula-cyan"
icon={<Lightbulb className="h-3.5 w-3.5" aria-hidden="true" />}
>
<BulletList items={report.tips} />
<BulletList items={report.tips.map(stripChartTokens)} />
</Section>
)}
@@ -236,7 +239,7 @@ export function WeeklyReportPresentation({
accent="text-muted-foreground"
>
<p className="text-muted-foreground text-sm leading-relaxed">
{report.dataQualityNotes}
{stripChartTokens(report.dataQualityNotes)}
</p>
</Section>
)}