diff --git a/src/components/insights/insight-advisor-card.tsx b/src/components/insights/insight-advisor-card.tsx index 2c46cf8f7..20246da2b 100644 --- a/src/components/insights/insight-advisor-card.tsx +++ b/src/components/insights/insight-advisor-card.tsx @@ -19,9 +19,11 @@ import { } from "lucide-react"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { HealthChart } from "@/components/charts/health-chart"; +import { MoodChart } from "@/components/charts/mood-chart"; import { parseChartTokens, stripChartTokens, + tokenKind, tokenToMetric, type ChartToken, } from "@/lib/insights/chart-tokens"; @@ -238,6 +240,7 @@ function InlineCharts({ tokens }: { tokens: ChartToken[] }) {
{unique.map((token) => { const metric = tokenToMetric(token); + const kind = tokenKind(token); const titleKey = INLINE_CHART_TITLE_KEYS[metric]; const title = titleKey ? t(titleKey) : metric; return ( @@ -246,7 +249,11 @@ function InlineCharts({ tokens }: { tokens: ChartToken[] }) { data-slot="insight-inline-chart" data-metric={metric} > - + {kind === "mood" ? ( + + ) : ( + + )}
); })} diff --git a/src/lib/ai/prompts/base-system.ts b/src/lib/ai/prompts/base-system.ts index 724235a91..df2755584 100644 --- a/src/lib/ai/prompts/base-system.ts +++ b/src/lib/ai/prompts/base-system.ts @@ -63,15 +63,22 @@ 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. +INLINE-CHART-TOKENS (aktiv nutzen, wenn ein Befund auf eine Metrik fokussiert): +- Innerhalb von "summary", "findings[].label", "findings[].guideline" oder "primaryRecommendation" darfst du je einen Chart-Token einbetten, der unter dem entsprechenden Absatz das passende Diagramm rendert. +- Bevorzuge Tokens immer dann, wenn (a) eine zeitliche Aussage gemacht wird ("seit 3 Wochen", "im 30-Tage-Trend"), (b) ein Schwellwertbezug vorliegt ("über deinem Zielband"), oder (c) ein Vergleich zwischen Fenstern gezogen wird (avg7 vs avg30). In diesen Fällen FÜGE den Token AKTIV ein — er macht den Befund visuell greifbar und ist günstiger als zusätzliche Prosa. - 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. + metric:OXYGEN_SATURATION, metric:MOOD. - 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. + +EVIDENZ-VERTRAG (bricht den Static-Look auf): +- Jeder Eintrag in "findings[]" MUSS in "value" eine konkrete Zahl aus dem Snapshot tragen — kein "leicht erhöht", sondern "138/85 mmHg" oder "+0.4 mmol/L vs. 30d-Avg". +- Beziehe dich in "assessment" + "guideline" auf das spezifische Datenfeld, nicht auf eine generische Empfehlung. Beispiel: "Dein avg7 (78) liegt 5 bpm über deinem 90-Tage-Median (73)" statt "Puls leicht erhöht". +- Wenn keine konkrete Zahl im Snapshot diesen Befund stützt, lass das Finding weg. Ein leeres findings[] mit guter summary ist besser als gepolsterte Boilerplate. +- Variable Anzahl: Liefere zwischen 0 und 8 Findings — sortiert nach Salienz (auffälligster Befund zuerst). KEIN Padding. +- Tabuliste — diese Phrasen NIE ausgeben: "achte auf ausreichend Schlaf", "trinke genug Wasser", "regelmäßige Bewegung", "ärztlicher Rat empfohlen" (außer im disclaimer-Feld). Sie signalisieren ungrounded Boilerplate und entwerten den ganzen Output. 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. { @@ -150,15 +157,22 @@ 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. +INLINE CHART TOKENS (use proactively when a finding is focused on one metric): +- You may embed a chart-include token inside "summary", "findings[].label", "findings[].guideline" or "primaryRecommendation". The token is replaced with an inline chart for that metric. +- Prefer a token whenever (a) the paragraph makes a temporal claim ("for 3 weeks", "in the 30-day trend"), (b) it references a threshold ("above your target band"), or (c) it compares two windows (avg7 vs avg30). In those cases ACTIVELY include the token — the chart is cheaper and clearer than additional prose. - 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. + metric:OXYGEN_SATURATION, metric:MOOD. - 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. + +EVIDENCE CONTRACT (breaks the static-feel boilerplate): +- Every entry in "findings[]" MUST place a concrete number from the snapshot into "value" — not "slightly elevated" but "138/85 mmHg" or "+0.4 mmol/L vs 30d-avg". +- Anchor "assessment" and "guideline" to a specific snapshot field, not a generic recommendation. Example: "Your avg7 (78) is 5 bpm above your 90-day median (73)" rather than "pulse is slightly elevated". +- If no concrete number in the snapshot supports a finding, leave it out. An empty findings[] with a strong summary is better than padded boilerplate. +- Variable cardinality: emit 0–8 findings, sorted by salience (most striking first). NO padding. +- Forbidden phrases — never emit any of these (except in the disclaimer): "make sure to get enough sleep", "drink enough water", "regular exercise", "consult your doctor". They signal ungrounded boilerplate and devalue the rest of the output. 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. { diff --git a/src/lib/insights/__tests__/chart-tokens.test.ts b/src/lib/insights/__tests__/chart-tokens.test.ts index e5361afef..b2ce4ec57 100644 --- a/src/lib/insights/__tests__/chart-tokens.test.ts +++ b/src/lib/insights/__tests__/chart-tokens.test.ts @@ -86,12 +86,19 @@ describe("ALLOWED_CHART_TOKENS — drift guard", () => { } }); - 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"); + it("includes metric:MOOD now that is wired into the renderer", () => { + // v1.4.3 enabled `metric:MOOD`. The renderer in + // `` branches on `tokenKind(token) === "mood"` + // and mounts the dedicated, self-fetching `` instead of + // the generic `` (which Zod-validates against + // `measurementTypeEnum` and would silently render empty). + expect(ALLOWED_CHART_TOKENS).toContain("metric:MOOD"); + }); + + it("excludes metric:COMPLIANCE because no self-fetching wrapper exists yet", () => { + // `` requires pre-aggregated daily data via + // props; without a self-fetching wrapper the AI's inline rendering + // would silently empty out. Land in v1.5 once the wrapper exists. expect(ALLOWED_CHART_TOKENS).not.toContain("metric:COMPLIANCE"); }); }); diff --git a/src/lib/insights/chart-tokens.ts b/src/lib/insights/chart-tokens.ts index aa542afeb..d4cd58686 100644 --- a/src/lib/insights/chart-tokens.ts +++ b/src/lib/insights/chart-tokens.ts @@ -7,18 +7,19 @@ * — 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. + * Render path: prose → parseChartTokens(prose) → matching chart component + * 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`). +// v1.4.3: allowlist extended with `metric:MOOD` so the AI can illustrate +// findings about mood drift inline. is self-fetching, so the +// renderer just mounts it. // -// `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. +// `metric:COMPLIANCE` was prepared but pulled before ship — the existing +// takes pre-aggregated daily data via props and +// has no self-fetching wrapper, so wiring it inline would silently +// render an empty chart. Land it in v1.5 once a self-fetching wrapper +// exists. export const ALLOWED_CHART_TOKENS = [ "metric:WEIGHT", "metric:BLOOD_PRESSURE_SYS", @@ -31,6 +32,7 @@ export const ALLOWED_CHART_TOKENS = [ "metric:TOTAL_BODY_WATER", "metric:BONE_MASS", "metric:OXYGEN_SATURATION", + "metric:MOOD", ] as const; export type ChartToken = (typeof ALLOWED_CHART_TOKENS)[number]; @@ -61,7 +63,18 @@ export function stripChartTokens(text: string): string { .trim(); } -/** Map a chart token to the metric param the HealthChart component uses. */ +/** Map a chart token to the metric param the underlying chart component + * expects. For MeasurementType-backed tokens this is a real enum value + * (`WEIGHT`, `PULSE`, …); for `MOOD` and `COMPLIANCE` it's a synthetic + * identifier the renderer routes to a dedicated chart component. */ export function tokenToMetric(token: ChartToken): string { return token.slice("metric:".length); } + +/** Token kinds the renderer needs to distinguish. MeasurementType tokens + * feed ``; `metric:MOOD` mounts the dedicated, self-fetching + * `` instead. */ +export function tokenKind(token: ChartToken): "measurement" | "mood" { + if (token === "metric:MOOD") return "mood"; + return "measurement"; +}