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