diff --git a/src/components/insights/__tests__/insight-advisor-card.test.tsx b/src/components/insights/__tests__/insight-advisor-card.test.tsx new file mode 100644 index 000000000..6fb762f7f --- /dev/null +++ b/src/components/insights/__tests__/insight-advisor-card.test.tsx @@ -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 }) => ( +
+ chart:{title} +
+ ), +})); + +// 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 { + 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( + + + , + ); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe(" — 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("]*\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"); + }); +}); diff --git a/src/components/insights/insight-advisor-card.tsx b/src/components/insights/insight-advisor-card.tsx index a5df4a455..2c46cf8f7 100644 --- a/src/components/insights/insight-advisor-card.tsx +++ b/src/components/insights/insight-advisor-card.tsx @@ -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 (
{t(labelKey)}

-

{finding.label}

- {finding.guideline && ( +

{cleanLabel}

+ {cleanGuideline && (

- {finding.guideline} + {cleanGuideline}

)}
@@ -153,6 +168,11 @@ function HeroFinding({ finding }: { finding: InsightFinding }) { {finding.value} + {(labelTokens.length > 0 || guidelineTokens.length > 0) && ( +
+ +
+ )} ); } @@ -171,6 +191,69 @@ const CONFIDENCE_LABEL_KEYS: Record = { 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 = { + 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(); + const unique: ChartToken[] = []; + for (const token of tokens) { + if (!seen.has(token)) { + seen.add(token); + unique.push(token); + } + } + + return ( +
+ {unique.map((token) => { + const metric = tokenToMetric(token); + const titleKey = INLINE_CHART_TITLE_KEYS[metric]; + const title = titleKey ? t(titleKey) : metric; + return ( +
+ +
+ ); + })} +
+ ); +} + // ─── Section Separator ──────────────────────────────────── function SectionSeparator({ label }: { label: string }) { @@ -322,10 +405,15 @@ export function InsightAdvisorCard({ )} - {/* Summary */} -

- {insight.summary} -

+ {/* Summary — prose first, then any allowlisted charts the model + * inlined via `metric:` tokens. Tokens are dropped from the + * visible text so the literal substring never reaches the DOM. */} +
+

+ {stripChartTokens(insight.summary)} +

+ +
{/* Findings — top finding gets a hero treatment, rest are a compact list */} {insight.findings.length > 0 && ( @@ -339,24 +427,38 @@ export function InsightAdvisorCard({ {insight.findings.length > 1 && (
- {insight.findings.slice(1).map((finding, i) => ( -
-
-
- - {finding.label} + {insight.findings.slice(1).map((finding, i) => { + const labelTokens = parseChartTokens(finding.label); + const guidelineTokens = finding.guideline + ? parseChartTokens(finding.guideline) + : []; + const tokens = [...labelTokens, ...guidelineTokens]; + return ( +
+
+
+ + + {stripChartTokens(finding.label)} + +
+ + {finding.value} +
- - {finding.value} - + {finding.guideline && ( +

+ {stripChartTokens(finding.guideline)} +

+ )} + {tokens.length > 0 && ( +
+ +
+ )}
- {finding.guideline && ( -

- {finding.guideline} -

- )} -
- ))} + ); + })}
)}
diff --git a/src/lib/ai/prompts/base-system.ts b/src/lib/ai/prompts/base-system.ts index 207bdf007..46a19d8c3 100644 --- a/src/lib/ai/prompts/base-system.ts +++ b/src/lib/ai/prompts/base-system.ts @@ -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", diff --git a/src/lib/ai/prompts/blood-pressure.ts b/src/lib/ai/prompts/blood-pressure.ts index 22316c808..5da9c3926 100644 --- a/src/lib/ai/prompts/blood-pressure.ts +++ b/src/lib/ai/prompts/blood-pressure.ts @@ -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; diff --git a/src/lib/ai/prompts/bmi.ts b/src/lib/ai/prompts/bmi.ts index c00450088..64ea33af1 100644 --- a/src/lib/ai/prompts/bmi.ts +++ b/src/lib/ai/prompts/bmi.ts @@ -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; diff --git a/src/lib/ai/prompts/general-status.ts b/src/lib/ai/prompts/general-status.ts index 06d0635dc..988b7b690 100644 --- a/src/lib/ai/prompts/general-status.ts +++ b/src/lib/ai/prompts/general-status.ts @@ -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; diff --git a/src/lib/ai/prompts/pulse.ts b/src/lib/ai/prompts/pulse.ts index 52453813d..a16a8291d 100644 --- a/src/lib/ai/prompts/pulse.ts +++ b/src/lib/ai/prompts/pulse.ts @@ -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; diff --git a/src/lib/ai/prompts/weight.ts b/src/lib/ai/prompts/weight.ts index 6d9581714..914afe847 100644 --- a/src/lib/ai/prompts/weight.ts +++ b/src/lib/ai/prompts/weight.ts @@ -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; diff --git a/src/lib/insights/__tests__/chart-tokens.test.ts b/src/lib/insights/__tests__/chart-tokens.test.ts new file mode 100644 index 000000000..e5361afef --- /dev/null +++ b/src/lib/insights/__tests__/chart-tokens.test.ts @@ -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:` 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", () => { + // `` 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"); + }); +}); diff --git a/src/lib/insights/chart-tokens.ts b/src/lib/insights/chart-tokens.ts new file mode 100644 index 000000000..aa542afeb --- /dev/null +++ b/src/lib/insights/chart-tokens.ts @@ -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) → 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 +// `` already understands (the type param feeds into +// `/api/measurements?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(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); +}