mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
feat(insights): inline charts in AI findings via allowlisted metric tokens (#138)
* feat(insights): inline charts in AI findings via allowlisted metric tokens Lets the AI insight advisor inline a Recharts panel directly under the prose paragraph it talks about — e.g. a finding centred on systolic BP renders the BP chart immediately beneath itself, instead of forcing the user to scroll back to the dashboard charts. Tokens are routed through a server-side allowlist: - 13 fixed strings (one per MeasurementType plus MOOD and COMPLIANCE). - A drift test fails if a new MeasurementType is added without a token. - The greedy `[A-Z_]+` regex caps the parse so trailing junk like `metric:WEIGHT' onclick='alert(1)'` cleaves cleanly into the safe metric and inert React text — there is no markdown / no eval path. - Hallucinated tokens (`metric:NUKE`) are silently dropped. - The visible prose is run through `stripChartTokens` so the literal token never reaches the DOM. Prompts (base + 7 domain prompts, EN + DE) tell the model it may embed exactly one of the allowlisted tokens inside `summary` or a finding's `label`/`guideline` — nothing else has any effect. Co-Authored-By: Marc-André Bombeck <mbombeck@gmail.com> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(insights): drop metric:MOOD and metric:COMPLIANCE until dedicated charts exist Review-pass M1: those two tokens routed through HealthChart, which validates `type` against the `MeasurementType` Zod enum. MOOD and COMPLIANCE aren't in the enum, so the request 422'd and the chart panel rendered "no data" — silently degrading the most-promoted UX path under prose that explicitly talked about the user's mood or adherence. Allowlist + every prompt now lists only the 11 tokens that actually render a real chart. The drift test inverts the previous "must include MOOD/COMPLIANCE" assertion to "must NOT include them", locking the contract until dedicated MoodChart / ComplianceChart components ship. 494 tests green, typecheck clean. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
4a6be429ca
commit
ddab92e42b
10 files changed
+493
-33
No files matched your search
@@ -0,0 +1,164 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
// HealthChart pulls in TanStack Query + recharts. We don't care about its
|
||||
// internal rendering — we only need to know *that it was rendered* and
|
||||
// with which `types[]` (which is the chart's metric handle, since the
|
||||
// real component takes `types: string[]` not `metric: string`).
|
||||
vi.mock("@/components/charts/health-chart", () => ({
|
||||
HealthChart: ({ types, title }: { types: string[]; title: string }) => (
|
||||
<div data-testid="health-chart" data-types={types.join(",")}>
|
||||
chart:{title}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Stub TanStack Query — InsightAdvisorCard itself doesn't query, but
|
||||
// nested clients (and the HealthChart mock above replaces the real one)
|
||||
// pull from this. Safe no-op.
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({ data: null, isLoading: false }),
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { InsightAdvisorCard } from "../insight-advisor-card";
|
||||
import type { InsightResult } from "@/lib/ai/types";
|
||||
|
||||
function buildInsight(overrides: Partial<InsightResult> = {}): InsightResult {
|
||||
return {
|
||||
insightType: "general",
|
||||
summary: "All good",
|
||||
classification: "gut",
|
||||
classificationLabel: "Good",
|
||||
findings: [
|
||||
{
|
||||
label: "Heart rate stable",
|
||||
value: "72 bpm",
|
||||
assessment: "positive",
|
||||
},
|
||||
],
|
||||
correlations: [],
|
||||
primaryRecommendation: "Keep going",
|
||||
recommendations: [],
|
||||
dataQuality: {
|
||||
coverage: "good",
|
||||
gaps: [],
|
||||
confidence: "hoch",
|
||||
},
|
||||
disclaimer: "Not a substitute for medical advice.",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function render(insight: InsightResult) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<InsightAdvisorCard title="Test card" insight={insight} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("<InsightAdvisorCard> — inline chart tokens", () => {
|
||||
it("renders an inline HealthChart for an allowlisted token in summary", () => {
|
||||
const html = render(
|
||||
buildInsight({
|
||||
summary: "BP looking great metric:BLOOD_PRESSURE_SYS today",
|
||||
}),
|
||||
);
|
||||
|
||||
// Chart is rendered with the token's metric as the type.
|
||||
expect(html).toContain('data-testid="health-chart"');
|
||||
expect(html).toContain('data-types="BLOOD_PRESSURE_SYS"');
|
||||
|
||||
// Token literal is stripped from the visible prose.
|
||||
expect(html).not.toContain("metric:BLOOD_PRESSURE_SYS");
|
||||
|
||||
// Surrounding prose survives unchanged.
|
||||
expect(html).toContain("BP looking great");
|
||||
expect(html).toContain("today");
|
||||
});
|
||||
|
||||
it("drops hallucinated tokens silently and still strips the literal", () => {
|
||||
const html = render(
|
||||
buildInsight({
|
||||
summary: "metric:NUKE try this",
|
||||
}),
|
||||
);
|
||||
|
||||
// No chart rendered.
|
||||
expect(html).not.toContain('data-testid="health-chart"');
|
||||
|
||||
// Token literal is gone from the DOM.
|
||||
expect(html).not.toContain("metric:NUKE");
|
||||
|
||||
// Visible text is the surrounding prose only.
|
||||
expect(html).toContain("try this");
|
||||
});
|
||||
|
||||
it("treats injection attempts as plain text — no script, no markup", () => {
|
||||
const html = render(
|
||||
buildInsight({
|
||||
summary: "metric:WEIGHT' onclick='alert(1)'",
|
||||
}),
|
||||
);
|
||||
|
||||
// The chart still renders for the safe metric prefix.
|
||||
expect(html).toContain('data-testid="health-chart"');
|
||||
expect(html).toContain('data-types="WEIGHT"');
|
||||
|
||||
// The trailing junk is preserved as escaped text — no script tag,
|
||||
// no live `onclick` attribute survives the React text path.
|
||||
expect(html).not.toContain("<script");
|
||||
expect(html).not.toMatch(/<[^>]*\sonclick\s*=/i);
|
||||
|
||||
// The escaped remnant still appears in the prose so the user can see
|
||||
// what the model emitted (alert(1) is now inert HTML-encoded text).
|
||||
expect(html).toContain("alert(1)");
|
||||
});
|
||||
|
||||
it("renders an inline chart attached to a hero finding token", () => {
|
||||
const html = render(
|
||||
buildInsight({
|
||||
findings: [
|
||||
{
|
||||
label: "Pulse trending down metric:PULSE",
|
||||
value: "68 bpm",
|
||||
assessment: "positive",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(html).toContain('data-types="PULSE"');
|
||||
expect(html).not.toContain("metric:PULSE");
|
||||
expect(html).toContain("Pulse trending down");
|
||||
});
|
||||
|
||||
it("renders inline charts for secondary findings without leaking tokens", () => {
|
||||
const html = render(
|
||||
buildInsight({
|
||||
findings: [
|
||||
{
|
||||
label: "Hero finding",
|
||||
value: "120/80",
|
||||
assessment: "neutral",
|
||||
},
|
||||
{
|
||||
label: "Weight stable metric:WEIGHT",
|
||||
value: "82 kg",
|
||||
assessment: "positive",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(html).toContain('data-types="WEIGHT"');
|
||||
expect(html).not.toContain("metric:WEIGHT");
|
||||
expect(html).toContain("Weight stable");
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,13 @@ import {
|
||||
Info,
|
||||
} from "lucide-react";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { HealthChart } from "@/components/charts/health-chart";
|
||||
import {
|
||||
parseChartTokens,
|
||||
stripChartTokens,
|
||||
tokenToMetric,
|
||||
type ChartToken,
|
||||
} from "@/lib/insights/chart-tokens";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────
|
||||
|
||||
@@ -125,6 +132,14 @@ function HeroFinding({ finding }: { finding: InsightFinding }) {
|
||||
: finding.assessment === "attention"
|
||||
? "insights.heroFindingAttention"
|
||||
: "insights.heroFindingNeutral";
|
||||
const labelTokens = parseChartTokens(finding.label);
|
||||
const cleanLabel = stripChartTokens(finding.label);
|
||||
const guidelineTokens = finding.guideline
|
||||
? parseChartTokens(finding.guideline)
|
||||
: [];
|
||||
const cleanGuideline = finding.guideline
|
||||
? stripChartTokens(finding.guideline)
|
||||
: "";
|
||||
return (
|
||||
<div
|
||||
data-slot="insight-hero-finding"
|
||||
@@ -141,10 +156,10 @@ function HeroFinding({ finding }: { finding: InsightFinding }) {
|
||||
>
|
||||
{t(labelKey)}
|
||||
</p>
|
||||
<p className="text-sm leading-snug font-medium">{finding.label}</p>
|
||||
{finding.guideline && (
|
||||
<p className="text-sm leading-snug font-medium">{cleanLabel}</p>
|
||||
{cleanGuideline && (
|
||||
<p className="text-muted-foreground text-xs leading-snug">
|
||||
{finding.guideline}
|
||||
{cleanGuideline}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -153,6 +168,11 @@ function HeroFinding({ finding }: { finding: InsightFinding }) {
|
||||
{finding.value}
|
||||
</span>
|
||||
</div>
|
||||
{(labelTokens.length > 0 || guidelineTokens.length > 0) && (
|
||||
<div className="mt-3">
|
||||
<InlineCharts tokens={[...labelTokens, ...guidelineTokens]} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -171,6 +191,69 @@ const CONFIDENCE_LABEL_KEYS: Record<string, string> = {
|
||||
gering: "insights.confidenceGering",
|
||||
};
|
||||
|
||||
// ─── Inline Chart Renderer ────────────────────────────────
|
||||
//
|
||||
// Expand a list of allowlisted ChartTokens into HealthChart instances.
|
||||
// HealthChart already understands the `MeasurementType`-style strings
|
||||
// (WEIGHT, BLOOD_PRESSURE_SYS, …); the synthetic MOOD and COMPLIANCE
|
||||
// tokens map to the dedicated chart components shipped elsewhere — for
|
||||
// now we render them as a HealthChart with the token's metric name as
|
||||
// the type, which the chart will gracefully render empty if there's no
|
||||
// matching data, instead of silently swallowing the user's expectation.
|
||||
//
|
||||
// Tokens are deduped per call so the prose `metric:WEIGHT … metric:WEIGHT`
|
||||
// doesn't render the same chart twice in a row.
|
||||
|
||||
const INLINE_CHART_TITLE_KEYS: Record<string, string> = {
|
||||
WEIGHT: "charts.weight",
|
||||
BLOOD_PRESSURE_SYS: "charts.systolic",
|
||||
BLOOD_PRESSURE_DIA: "charts.diastolic",
|
||||
PULSE: "charts.pulse",
|
||||
BODY_FAT: "charts.bodyFat",
|
||||
SLEEP_DURATION: "charts.sleep",
|
||||
ACTIVITY_STEPS: "charts.steps",
|
||||
BLOOD_GLUCOSE: "measurements.typeBloodGlucose",
|
||||
TOTAL_BODY_WATER: "charts.bodyWater",
|
||||
BONE_MASS: "charts.boneMass",
|
||||
OXYGEN_SATURATION: "charts.spo2",
|
||||
MOOD: "insights.navMood",
|
||||
COMPLIANCE: "insights.navMedication",
|
||||
};
|
||||
|
||||
function InlineCharts({ tokens }: { tokens: ChartToken[] }) {
|
||||
const { t } = useTranslations();
|
||||
if (tokens.length === 0) return null;
|
||||
|
||||
// Dedup while preserving the order the model emitted them.
|
||||
const seen = new Set<ChartToken>();
|
||||
const unique: ChartToken[] = [];
|
||||
for (const token of tokens) {
|
||||
if (!seen.has(token)) {
|
||||
seen.add(token);
|
||||
unique.push(token);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-slot="insight-inline-charts" className="space-y-3">
|
||||
{unique.map((token) => {
|
||||
const metric = tokenToMetric(token);
|
||||
const titleKey = INLINE_CHART_TITLE_KEYS[metric];
|
||||
const title = titleKey ? t(titleKey) : metric;
|
||||
return (
|
||||
<div
|
||||
key={token}
|
||||
data-slot="insight-inline-chart"
|
||||
data-metric={metric}
|
||||
>
|
||||
<HealthChart types={[metric]} title={title} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Section Separator ────────────────────────────────────
|
||||
|
||||
function SectionSeparator({ label }: { label: string }) {
|
||||
@@ -322,10 +405,15 @@ export function InsightAdvisorCard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary */}
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
{insight.summary}
|
||||
</p>
|
||||
{/* Summary — prose first, then any allowlisted charts the model
|
||||
* inlined via `metric:<TYPE>` tokens. Tokens are dropped from the
|
||||
* visible text so the literal substring never reaches the DOM. */}
|
||||
<div className="space-y-3">
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
{stripChartTokens(insight.summary)}
|
||||
</p>
|
||||
<InlineCharts tokens={parseChartTokens(insight.summary)} />
|
||||
</div>
|
||||
|
||||
{/* Findings — top finding gets a hero treatment, rest are a compact list */}
|
||||
{insight.findings.length > 0 && (
|
||||
@@ -339,24 +427,38 @@ export function InsightAdvisorCard({
|
||||
<HeroFinding finding={insight.findings[0]} />
|
||||
{insight.findings.length > 1 && (
|
||||
<div className="space-y-2 pt-1">
|
||||
{insight.findings.slice(1).map((finding, i) => (
|
||||
<div key={i}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<AssessmentIcon assessment={finding.assessment} />
|
||||
<span className="text-sm">{finding.label}</span>
|
||||
{insight.findings.slice(1).map((finding, i) => {
|
||||
const labelTokens = parseChartTokens(finding.label);
|
||||
const guidelineTokens = finding.guideline
|
||||
? parseChartTokens(finding.guideline)
|
||||
: [];
|
||||
const tokens = [...labelTokens, ...guidelineTokens];
|
||||
return (
|
||||
<div key={i}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<AssessmentIcon assessment={finding.assessment} />
|
||||
<span className="text-sm">
|
||||
{stripChartTokens(finding.label)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium tabular-nums">
|
||||
{finding.value}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium tabular-nums">
|
||||
{finding.value}
|
||||
</span>
|
||||
{finding.guideline && (
|
||||
<p className="text-muted-foreground mt-0.5 ml-6 text-xs">
|
||||
{stripChartTokens(finding.guideline)}
|
||||
</p>
|
||||
)}
|
||||
{tokens.length > 0 && (
|
||||
<div className="mt-2 ml-6">
|
||||
<InlineCharts tokens={tokens} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{finding.guideline && (
|
||||
<p className="text-muted-foreground mt-0.5 ml-6 text-xs">
|
||||
{finding.guideline}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user