mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user