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";
+}