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:
Marc-André BombeckandClaude Opus 4.7 authored and GitHub committed 2026-05-08 14:09:27 +02:00
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");
});
});
+125 -23
View File
@@ -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>
+20
View File
@@ -63,6 +63,16 @@ TEMPORALE SCHICHTEN:
- avg7 vs avg30 zeigt aktuelle Tendenz, avg90/allTime zeigt Langzeit-Baseline
- Abweichungen von der Langzeit-Baseline sind klinisch aussagekräftiger als kurzfristige Schwankungen
INLINE-CHART-TOKENS (optional):
- Du darfst innerhalb von "summary" oder eines "findings[].label"/"findings[].guideline" GENAU einen Chart-Token einbetten, der unter dem entsprechenden Absatz das passende Diagramm rendert.
- Erlaubte Tokens (exakt diese Strings, sonst werden sie ohne Wirkung verworfen):
metric:WEIGHT, metric:BLOOD_PRESSURE_SYS, metric:BLOOD_PRESSURE_DIA,
metric:PULSE, metric:BODY_FAT, metric:SLEEP_DURATION, metric:ACTIVITY_STEPS,
metric:BLOOD_GLUCOSE, metric:TOTAL_BODY_WATER, metric:BONE_MASS,
metric:OXYGEN_SATURATION.
- Halluzinierte Tokens (z.B. metric:NUKE) werden serverseitig stumm verworfen — verwende ausschließlich einen der oben gelisteten Strings.
- Setze den Token bevorzugt nur dann, wenn der Absatz inhaltlich auf genau diese eine Metrik fokussiert ist; ansonsten weglassen.
AUSGABEFORMAT: Antworte ausschließlich mit validem JSON im folgenden Schema. Die Felder "classification" und "confidence" müssen exakt eine der englischen Enum-Bezeichnungen ("optimal|gut|grenzwertig|erhoht|kritisch" bzw. "hoch|mittel|gering"/"niedrig") sein — diese sind stabile Vertragsschlüssel und werden NICHT übersetzt. Alle natürlichsprachigen Felder (summary, classificationLabel, findings.label/value/assessment guideline, recommendations, etc.) MÜSSEN auf Deutsch sein.
{
"insightType": "blood_pressure|weight|pulse|mood|bmi|medication_compliance|general",
@@ -140,6 +150,16 @@ TEMPORAL LAYERS:
- avg7 vs avg30 shows the current tendency; avg90 / allTime reflects the long-term baseline.
- Deviations from the long-term baseline are clinically more meaningful than short-term swings.
INLINE CHART TOKENS (optional):
- You may embed exactly one chart-include token inside "summary" or any "findings[].label" / "findings[].guideline" to render the matching chart inline beneath that paragraph.
- Allowed tokens (use these literal strings — anything else is silently dropped server-side):
metric:WEIGHT, metric:BLOOD_PRESSURE_SYS, metric:BLOOD_PRESSURE_DIA,
metric:PULSE, metric:BODY_FAT, metric:SLEEP_DURATION, metric:ACTIVITY_STEPS,
metric:BLOOD_GLUCOSE, metric:TOTAL_BODY_WATER, metric:BONE_MASS,
metric:OXYGEN_SATURATION.
- Hallucinated tokens (e.g. metric:NUKE) are dropped — only the literal strings above render a chart.
- Prefer to include a token only when the paragraph is centred on exactly one of these metrics; otherwise leave it out.
OUTPUT FORMAT: Reply with valid JSON only, matching the schema below. The "classification" and "confidence" fields must contain exactly one of the stable English-style enum keys ("optimal|gut|grenzwertig|erhoht|kritisch" and "hoch|mittel|gering"/"niedrig") — these are stable contract keys and MUST NOT be translated. All natural-language fields (summary, classificationLabel, findings.label/value/assessment guideline, recommendations, etc.) MUST be in English.
{
"insightType": "blood_pressure|weight|pulse|mood|bmi|medication_compliance|general",
+4 -2
View File
@@ -27,7 +27,8 @@ const BP_SECTION_DE = `FACHSPEZIFISCH — BLUTDRUCK:
* Morgen-RR ≥ 165: HR 6.52
- Rate-Pressure Product: Wenn ratePressureProduct.rpp30 > 12.000: "Erhöhter kardialer Sauerstoffbedarf" mit assessment "warning" bewerten.
- Saisonale Variation: Falls seasonalVariation vorhanden und delta > 5 mmHg: "Physiologisch normale saisonale Schwankung — kein Grund zur Sorge, ggf. Winter-Dosisanpassung besprechen."
- Salz-Signal: Akuter Gewichtsanstieg ≥ 1 kg in 3 Tagen + systolischer Anstieg ≥ 5 mmHg = mögliche erhöhte Natriumzufuhr.`;
- Salz-Signal: Akuter Gewichtsanstieg ≥ 1 kg in 3 Tagen + systolischer Anstieg ≥ 5 mmHg = mögliche erhöhte Natriumzufuhr.
- Inline-Chart: Wenn die summary oder ein finding-Text klar auf eine einzelne Blutdruck-Komponente zielt, kannst du genau einen Token (metric:BLOOD_PRESSURE_SYS oder metric:BLOOD_PRESSURE_DIA) im Text einbetten, um das Diagramm darunter einzublenden.`;
const BP_SECTION_EN = `DOMAIN — BLOOD PRESSURE:
- ESH 2023 classification:
@@ -55,7 +56,8 @@ const BP_SECTION_EN = `DOMAIN — BLOOD PRESSURE:
* Morning BP ≥ 165: HR 6.52
- Rate-pressure product: If ratePressureProduct.rpp30 > 12,000, label "Elevated cardiac oxygen demand" with assessment "warning".
- Seasonal variation: If seasonalVariation is present and delta > 5 mmHg: "Physiologically normal seasonal swing — no cause for concern, optionally discuss a winter dose adjustment."
- Salt signal: Acute weight gain ≥ 1 kg over 3 days plus systolic rise ≥ 5 mmHg = possible elevated sodium intake.`;
- Salt signal: Acute weight gain ≥ 1 kg over 3 days plus systolic rise ≥ 5 mmHg = possible elevated sodium intake.
- Inline chart: If the summary or a finding text centres on a single BP component, embed exactly one token (metric:BLOOD_PRESSURE_SYS or metric:BLOOD_PRESSURE_DIA) inside that text to inline the chart beneath it.`;
export function getBloodPressureSystemPrompt(locale: Locale): string {
const section = locale === "en" ? BP_SECTION_EN : BP_SECTION_DE;
+4 -2
View File
@@ -13,7 +13,8 @@ const BMI_SECTION_DE = `FACHSPEZIFISCH — BMI:
- Geschlechtskontext: Bei verfügbarem Geschlecht unterschiedliche Risikoprofile berücksichtigen.
- Trend wichtiger als Momentaufnahme: BMI-Entwicklung über 30/90 Tage gewichten.
- Limitationen des BMI: Kein Maß für Körperzusammensetzung. Bei Sportlern oder muskulösen Personen eingeschränkt aussagekräftig — darauf hinweisen.
- Zusammenhang mit Blutdruck und Puls: Übergewicht als Risikofaktor für Hypertonie einordnen.`;
- Zusammenhang mit Blutdruck und Puls: Übergewicht als Risikofaktor für Hypertonie einordnen.
- Inline-Chart: Da BMI aus dem Gewicht abgeleitet wird, kannst du genau einen Token metric:WEIGHT in summary oder finding einbetten, um den zugrundeliegenden Gewichtsverlauf darunter einzublenden.`;
const BMI_SECTION_EN = `DOMAIN — BMI:
- Apply WHO classification strictly:
@@ -27,7 +28,8 @@ const BMI_SECTION_EN = `DOMAIN — BMI:
- Sex context: When sex is known, factor in different risk profiles.
- Trend over snapshot: Weight 30- and 90-day BMI trajectories more heavily than a single value.
- BMI limitations: Not a measure of body composition. Less informative for athletes or very muscular people — point this out.
- Relation to blood pressure and pulse: Treat overweight as a risk factor for hypertension.`;
- Relation to blood pressure and pulse: Treat overweight as a risk factor for hypertension.
- Inline chart: BMI is derived from weight, so you may embed exactly one metric:WEIGHT token in the summary or a finding to inline the underlying weight trajectory beneath it.`;
export function getBmiSystemPrompt(locale: Locale): string {
const section = locale === "en" ? BMI_SECTION_EN : BMI_SECTION_DE;
+4 -2
View File
@@ -16,7 +16,8 @@ const GENERAL_SECTION_DE = `FACHSPEZIFISCH — GESAMTBEWERTUNG:
- Aktivität: Falls activity-Daten vorhanden, Schrittzahl bewerten (≥ 8.000/Tag — Saint-Maurice et al., JAMA 2020; Mortalitäts-Plateau 8.000–12.000). Hinweis: WHO publiziert Aktivitätszeit (Min/Woche), KEIN Schritt-Soll — bitte keine "WHO ≥ 8.000 Schritte"-Formulierung. Zusammenhang mit Puls- und Gewichtstrend herstellen.
- Rate-Pressure Product: Falls ratePressureProduct vorhanden, als Indikator für kardiale Belastung in die Risikostratifizierung einbeziehen. > 12.000 = erhöhter myokardialer Sauerstoffbedarf.
- Body-Composition-Divergenz: Falls bodyCompositionDivergence.flag = true, als Frühzeichen für sarkopenische Adipositas in die Gesamtbewertung aufnehmen.
- Saisonale Variation: Falls seasonalVariation vorhanden, saisonale Blutdruckschwankungen kontextualisieren und ggf. beruhigend einordnen.`;
- Saisonale Variation: Falls seasonalVariation vorhanden, saisonale Blutdruckschwankungen kontextualisieren und ggf. beruhigend einordnen.
- Inline-Chart: Wenn die summary oder ein finding klar auf eine einzelne Metrik fokussiert (z.B. einen kritischen systolischen Trend), kannst du genau einen passenden Token aus der Allowlist im Text einbetten — der erste klar metrik-zentrierte Absatz erhält das Diagramm darunter.`;
const GENERAL_SECTION_EN = `DOMAIN — OVERALL ASSESSMENT:
- Integrate every available vital parameter into a single coherent picture.
@@ -33,7 +34,8 @@ const GENERAL_SECTION_EN = `DOMAIN — OVERALL ASSESSMENT:
- Activity: When activity data is present, evaluate step count (≥ 8,000/day — Saint-Maurice et al., JAMA 2020; mortality plateau 8,000–12,000). Note: WHO publishes activity *time* (min/week), NOT a step quota — do not phrase the target as "WHO ≥ 8,000 steps". Tie back to pulse and weight trends.
- Rate-pressure product: When ratePressureProduct is present, include it as a cardiac-load indicator. > 12,000 = elevated myocardial oxygen demand.
- Body-composition divergence: If bodyCompositionDivergence.flag = true, treat as an early sign of sarcopenic obesity.
- Seasonal variation: When seasonalVariation is present, contextualise seasonal BP swings and reassure where appropriate.`;
- Seasonal variation: When seasonalVariation is present, contextualise seasonal BP swings and reassure where appropriate.
- Inline chart: When a summary or finding centres on a single metric (e.g. a critical systolic trend), embed exactly one matching token from the allowlist in the text — the first clearly metric-focused paragraph receives the chart underneath.`;
export function getGeneralStatusSystemPrompt(locale: Locale): string {
const section = locale === "en" ? GENERAL_SECTION_EN : GENERAL_SECTION_DE;
+4 -2
View File
@@ -22,7 +22,8 @@ const PULSE_SECTION_DE = `FACHSPEZIFISCH — PULS/HERZFREQUENZ:
* slope30 > +0.2 bpm/Tag (≈ +6 bpm/Monat) OHNE Stimmungsabfall: Mögliche Dekonditionierung
* slope30 > +0.2 bpm/Tag MIT moodVsPulse r > 0.4: Stressbedingte Pulserhöhung
- Risikoband 80-100 bpm: Technisch "normal", aber erhöhtes CV-Risiko (+6-9% Mortalität pro 10 bpm, Meta-Analyse).
- Resting HR > 90 bpm: Unabhängiger Mortalitätsprädiktor — als eigenes Finding mit assessment "warning" erfassen.`;
- Resting HR > 90 bpm: Unabhängiger Mortalitätsprädiktor — als eigenes Finding mit assessment "warning" erfassen.
- Inline-Chart: Wenn die summary oder ein finding den Ruhepulsverlauf in den Mittelpunkt stellt, kannst du genau einen Token metric:PULSE im Text einbetten, um das Pulsdiagramm darunter einzublenden.`;
const PULSE_SECTION_EN = `DOMAIN — PULSE / HEART RATE:
- Resting-pulse zones:
@@ -45,7 +46,8 @@ const PULSE_SECTION_EN = `DOMAIN — PULSE / HEART RATE:
* slope30 > +0.2 bpm/day (≈ +6 bpm/month) WITHOUT mood drop: possible deconditioning
* slope30 > +0.2 bpm/day WITH moodVsPulse r > 0.4: stress-driven pulse rise
- Risk band 80-100 bpm: Technically "normal", but elevated CV risk (+6-9% mortality per 10 bpm, meta-analysis).
- Resting HR > 90 bpm: Independent mortality predictor — capture as its own finding with assessment "warning".`;
- Resting HR > 90 bpm: Independent mortality predictor — capture as its own finding with assessment "warning".
- Inline chart: When a summary or finding centres on the resting-pulse trajectory, embed exactly one metric:PULSE token in the text to inline the pulse chart beneath it.`;
export function getPulseSystemPrompt(locale: Locale): string {
const section = locale === "en" ? PULSE_SECTION_EN : PULSE_SECTION_DE;
+4 -2
View File
@@ -19,7 +19,8 @@ const WEIGHT_SECTION_DE = `FACHSPEZIFISCH — GEWICHT:
* Wenn erreicht: Explizit anerkennen und klinischen Kontext geben
- Zu schneller Verlust: > 1 kg/Woche über > 2 Wochen = Risiko für Muskelmasseverlust, nicht nachhaltig
- Body-Composition-Divergenz: Falls bodyCompositionDivergence.flag = true: "Gewicht stabil, aber Körperfettanteil steigend — möglicher Muskelmasseverlust. Krafttraining empfehlen."
- Schlaf-Gewicht-Verbindung: Falls sleep.avg7 < 6h UND weight.slope30 > 0: "Schlafmangel stört metabolische Regulation und kann Gewichtszunahme begünstigen."`;
- Schlaf-Gewicht-Verbindung: Falls sleep.avg7 < 6h UND weight.slope30 > 0: "Schlafmangel stört metabolische Regulation und kann Gewichtszunahme begünstigen."
- Inline-Chart: Bei einem klar gewichtsfokussierten summary- oder finding-Text kannst du genau einen Token (metric:WEIGHT, optional auch metric:BODY_FAT für Körperzusammensetzung) im Text einbetten, um das Diagramm darunter einzublenden.`;
const WEIGHT_SECTION_EN = `DOMAIN — WEIGHT:
- Always include BMI context (height from profile, if available).
@@ -39,7 +40,8 @@ const WEIGHT_SECTION_EN = `DOMAIN — WEIGHT:
* If reached: acknowledge explicitly and give clinical context
- Too rapid loss: > 1 kg/week sustained > 2 weeks = risk of muscle-mass loss, not sustainable
- Body-composition divergence: If bodyCompositionDivergence.flag = true: "Weight stable but body-fat percentage rising — possible muscle-mass loss. Recommend strength training."
- Sleep-weight link: If sleep.avg7 < 6h AND weight.slope30 > 0: "Sleep loss disrupts metabolic regulation and can favour weight gain."`;
- Sleep-weight link: If sleep.avg7 < 6h AND weight.slope30 > 0: "Sleep loss disrupts metabolic regulation and can favour weight gain."
- Inline chart: When a summary or finding text is centred on weight, embed exactly one token (metric:WEIGHT, or metric:BODY_FAT for body-composition findings) inside that text to inline the chart underneath.`;
export function getWeightSystemPrompt(locale: Locale): string {
const section = locale === "en" ? WEIGHT_SECTION_EN : WEIGHT_SECTION_DE;
@@ -0,0 +1,97 @@
import { describe, expect, it } from "vitest";
import { MeasurementType } from "@/generated/prisma/client";
import {
ALLOWED_CHART_TOKENS,
parseChartTokens,
stripChartTokens,
tokenToMetric,
} from "../chart-tokens";
describe("parseChartTokens", () => {
it("extracts a single allowlisted token", () => {
expect(
parseChartTokens("Dein BP ist top metric:BLOOD_PRESSURE_SYS"),
).toEqual(["metric:BLOOD_PRESSURE_SYS"]);
});
it("drops tokens that are not on the allowlist", () => {
expect(parseChartTokens("metric:NUKE will not render")).toEqual([]);
});
it("strips trailing junk via the regex character class", () => {
// The greedy [A-Z_]+ class stops at the apostrophe, so the surviving
// token is just `metric:WEIGHT` and the rest stays inert text.
expect(parseChartTokens("metric:WEIGHT' onclick='alert(1)'")).toEqual([
"metric:WEIGHT",
]);
});
it("returns multiple tokens preserving order", () => {
expect(
parseChartTokens(
"metric:WEIGHT and metric:PULSE and a fake metric:NUKE here",
),
).toEqual(["metric:WEIGHT", "metric:PULSE"]);
});
it("returns [] for empty input", () => {
expect(parseChartTokens("")).toEqual([]);
expect(parseChartTokens("no tokens here")).toEqual([]);
});
});
describe("stripChartTokens", () => {
it("removes a token and collapses surrounding whitespace", () => {
expect(stripChartTokens("BP top metric:BLOOD_PRESSURE_SYS now")).toBe(
"BP top now",
);
});
it("removes hallucinated tokens too — they are still well-formed", () => {
expect(stripChartTokens("metric:NUKE try this")).toBe("try this");
});
it("trims leading and trailing whitespace", () => {
expect(stripChartTokens(" metric:WEIGHT ")).toBe("");
});
});
describe("tokenToMetric", () => {
it("strips the `metric:` prefix", () => {
expect(tokenToMetric("metric:WEIGHT")).toBe("WEIGHT");
expect(tokenToMetric("metric:BLOOD_PRESSURE_SYS")).toBe(
"BLOOD_PRESSURE_SYS",
);
});
});
describe("ALLOWED_CHART_TOKENS — drift guard", () => {
it("has unique entries", () => {
expect(new Set(ALLOWED_CHART_TOKENS).size).toBe(
ALLOWED_CHART_TOKENS.length,
);
});
it("covers every MeasurementType enum value", () => {
// If a future schema migration adds a new MeasurementType, this test
// fails until the new `metric:<TYPE>` is also added to the allowlist.
// (`NOTE` is excluded if it ever appears — currently the schema has none.)
const enumTokens = (Object.values(MeasurementType) as string[])
.filter((value) => value !== "NOTE")
.map((value) => `metric:${value}`);
for (const token of enumTokens) {
expect(ALLOWED_CHART_TOKENS, `missing ${token}`).toContain(token);
}
});
it("excludes mood and compliance tokens until dedicated charts ship", () => {
// `<HealthChart>` validates `type` against `measurementTypeEnum`, so
// emitting `metric:MOOD` or `metric:COMPLIANCE` would render an empty
// panel. Keep them out of the allowlist until a real MoodChart /
// ComplianceChart is wired into the renderer.
expect(ALLOWED_CHART_TOKENS).not.toContain("metric:MOOD");
expect(ALLOWED_CHART_TOKENS).not.toContain("metric:COMPLIANCE");
});
});
+67
View File
@@ -0,0 +1,67 @@
/**
* Chart-include tokens for AI insight prose.
*
* The LLM can embed one of the literal strings below inside `summary` or
* `findings[].label` to inline-render the matching chart underneath the
* paragraph. Every token the model emits is matched against this allowlist
* — anything else (typos, hallucinations, attempts to inject other strings)
* is silently dropped before it ever reaches the renderer.
*
* Render path: prose → parseChartTokens(prose) → <HealthChart> per token.
* The visible prose is run through stripChartTokens so the literal token
* string never surfaces in the UI.
*/
// Allowlist deliberately limited to MeasurementType values that
// `<HealthChart>` already understands (the type param feeds into
// `/api/measurements?type=<TYPE>` which Zod-validates against
// `measurementTypeEnum`).
//
// `MOOD` and `COMPLIANCE` are NOT in the enum — they need their own chart
// components, so emitting them here would render an empty "no data" panel
// under the model's prose. Excluded until those dedicated charts ship.
export const ALLOWED_CHART_TOKENS = [
"metric:WEIGHT",
"metric:BLOOD_PRESSURE_SYS",
"metric:BLOOD_PRESSURE_DIA",
"metric:PULSE",
"metric:BODY_FAT",
"metric:SLEEP_DURATION",
"metric:ACTIVITY_STEPS",
"metric:BLOOD_GLUCOSE",
"metric:TOTAL_BODY_WATER",
"metric:BONE_MASS",
"metric:OXYGEN_SATURATION",
] as const;
export type ChartToken = (typeof ALLOWED_CHART_TOKENS)[number];
const ALLOWED_SET = new Set<string>(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;
/**
* Find every well-formed chart token in `text` and return only those that
* are in the allowlist. Hallucinated tokens (e.g. `metric:NUKE`) are
* silently dropped.
*/
export function parseChartTokens(text: string): ChartToken[] {
const matches: string[] = text.match(TOKEN_REGEX) ?? [];
return matches.filter((m): m is ChartToken => ALLOWED_SET.has(m));
}
/** Strip the chart tokens out of the visible insight text. */
export function stripChartTokens(text: string): string {
return text
.replace(TOKEN_REGEX, "")
.replace(/\s{2,}/g, " ")
.trim();
}
/** Map a chart token to the metric param the HealthChart component uses. */
export function tokenToMetric(token: ChartToken): string {
return token.slice("metric:".length);
}