feat(insights): metric:MOOD inline charts + evidence-contract prompt

Bucket D phase 1 of the v1.4.3 polish pass — the AI insights felt
"static" because (a) findings repeated phrasing with no concrete
numbers and (b) the inline-chart machinery existed but was prompted
permissively, so the model rarely used it.

Two concrete moves, scoped to ship in this hotfix:

1. **`metric:MOOD` is now an allowlisted chart token.** Renderer in
   `<InsightAdvisorCard>` branches on `tokenKind(token)` — measurement
   tokens still mount `<HealthChart>`, but `metric:MOOD` mounts the
   dedicated, self-fetching `<MoodChart>` instead. The mood timeline
   was previously the most under-served by the AI prose because it
   couldn't be charted inline; now the model can illustrate adherence-
   risk and mood-drift findings with the same chart users see on the
   /mood page. Drift test was updated in lockstep — the v1.4.2 guard
   that asserted MOOD was excluded now asserts it's present, plus a
   new guard asserts COMPLIANCE is still excluded (no self-fetching
   wrapper exists yet for `<ComplianceLineChart>`; ship that in v1.5).

2. **Prompt grew an EVIDENCE CONTRACT and a forbidden-phrase list.**
   The base system prompt (DE + EN both) now requires every
   `findings[]` entry to put a concrete number from the snapshot in
   `value` ("138/85 mmHg", "+0.4 mmol/L vs. 30d-avg") instead of
   adjective prose ("slightly elevated"). Findings without a snapshot
   anchor MUST be omitted — variable cardinality 0..8 explicitly
   stated, sorted by salience, NO padding. The forbidden-phrase list
   blocks generic boilerplate that signals ungrounded output: "achte
   auf ausreichend Schlaf", "drink enough water", "regular exercise",
   "consult your doctor" (except in the disclaimer field which exists
   precisely for that line). The chart-token instruction flipped from
   passive ("Du DARFST") to active ("Bevorzuge Tokens immer dann,
   wenn …") with three explicit triggers — temporal claims, threshold
   references, window comparisons — that reliably surface chart-worthy
   findings.

Carved out of v1.4.3 for v1.5: telescoping multi-resolution payload
(14d daily + 10w weekly + 21mo monthly + yearly summary per QA
research 1) and the evidence-ref output schema extension (QA 2).
Both touch the prompt's input shape and would change every cache
key + token bill — they want their own phase.

Tests + typecheck green (671/671 — one new chart-tokens drift
guard).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-08 21:41:29 +02:00
1 parent c05f48f520
commit dc01ab7934
4 files changed
+67 -26

No files matched your search

@@ -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[] }) {
<div data-slot="insight-inline-charts" className="space-y-3">
{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}
>
<HealthChart types={[metric]} title={title} />
{kind === "mood" ? (
<MoodChart />
) : (
<HealthChart types={[metric]} title={title} />
)}
</div>
);
})}
+22 -8
View File
@@ -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.
{
@@ -86,12 +86,19 @@ describe("ALLOWED_CHART_TOKENS — drift guard", () => {
}
});
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");
it("includes metric:MOOD now that <MoodChart> is wired into the renderer", () => {
// v1.4.3 enabled `metric:MOOD`. The renderer in
// `<InsightAdvisorCard>` branches on `tokenKind(token) === "mood"`
// and mounts the dedicated, self-fetching `<MoodChart>` instead of
// the generic `<HealthChart>` (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", () => {
// `<ComplianceLineChart>` 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");
});
});
+24 -11
View File
@@ -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) → <HealthChart> 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
// `<HealthChart>` already understands (the type param feeds into
// `/api/measurements?type=<TYPE>` which Zod-validates against
// `measurementTypeEnum`).
// v1.4.3: allowlist extended with `metric:MOOD` so the AI can illustrate
// findings about mood drift inline. <MoodChart> 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
// <ComplianceLineChart> 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 `<HealthChart>`; `metric:MOOD` mounts the dedicated, self-fetching
* `<MoodChart>` instead. */
export function tokenKind(token: ChartToken): "measurement" | "mood" {
if (token === "metric:MOOD") return "mood";
return "measurement";
}