feat(insights): add relational context, repetition-awareness, and controlled variety to assessments

The per-metric assessment cards are tightly grounded but their form had
converged: every card opened the same way, forced a "one doable step"
even when nothing was actionable, and a metric that stayed steady for
weeks produced near-identical paragraphs. Squeeze more value from data
already computed server-side, without touching the grounding floor or any
safety contract.

- Surface FDR-surviving cross-metric correlations on the per-metric card.
  The discovery engine already screens behaviour×outcome pairs with a real
  Pearson + exact p-value + Benjamini-Hochberg FDR control; that
  intelligence only reached the period narrative. A new read-only consumer
  runs the same full-matrix discovery and filters to the pairs that
  involve the current metric, then feeds the engine's own conservative,
  descriptive, never-causal interpretation strings into the prompt as
  grounded relations. Best-effort: a correlation hiccup can never block the
  generation it only decorates.

- Streak-aware repetition. Derive a steady-run length from the graded
  series (consecutive recent weeks within a band of the user's own
  baseline) and tell the model when it has already reported "no material
  change" N times running, so it acknowledges the continuity in one clause
  and pivots to a different facet instead of restating the same level.

- Stop forcing a step; add controlled variety. The closing step is now
  conditional — when nothing is genuinely actionable the model affirms and
  names one thing to watch rather than manufacturing the filler that lets
  platitudes back in. A deterministic per-render variety token (seeded from
  user + metric + day, never Math.random / Date.now) rotates the opening
  angle so consecutive cards and days don't read identically, and the
  phrasing temperature lifts 0.3 → 0.45 while the facts stay pinned by the
  snapshot and the forbidden-phrase guards.

- Surface the computed data strength (n + recency) into the prose prompt so
  it hedges on thin data instead of guessing what "few" means, matching the
  UI confidence badge.

- Add locale-matched few-shot examples (a grounded assessment + a labelled
  banned-filler counter-example) to the assessment system prompt, which
  most helps weaker local-provider models follow the contract.

The own-baseline grounding, computed-not-hallucinated stats, schema-
enforced output, filler-phrase ban, and correlation-as-association
discipline are all preserved and re-pinned by new guard tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit c9d04cab1523e0e57a3f50c87c9f88684b01b26f)
This commit is contained in:
Marc-André Bombeck committed 2026-06-05 03:25:22 +02:00
1 parent 901e7d345d
commit 05cff531e4
9 files changed
+990 -7

No files matched your search

@@ -0,0 +1,128 @@
import { describe, expect, it } from "vitest";
import { getBaseSystemPrompt } from "../base-system";
import {
getMetricArchetypeSystemPrompt,
getMetricArchetypeUserPrompt,
} from "../metric-archetypes";
import { getMetricStatusMeta } from "@/lib/insights/metric-status-registry";
/**
* v1.12.1 — the per-metric assessment prompt gains diversity / anti-
* repetition affordances (skip-the-step, few-shot, variety/relations
* context). These tests pin BOTH the NEW behaviour and the UNCHANGED
* grounding + filler-ban contracts so a future rewrite can't silently
* regress either axis.
*/
describe("base-system assessment prompt — grounding floor preserved", () => {
for (const locale of ["en", "de"] as const) {
it(`${locale}: keeps own-baseline grounding (not population norms)`, () => {
const p = getBaseSystemPrompt(locale);
if (locale === "en") {
expect(p).toMatch(/own baseline/i);
expect(p).toMatch(/never against a population norm/i);
expect(p).toMatch(/a value inside the user's normal swing is NOT a finding/i);
} else {
expect(p).toMatch(/EIGENE Baseline/);
expect(p).toMatch(/nicht gegen Bevölkerungsnormen/i);
}
});
it(`${locale}: keeps the filler-phrase ban`, () => {
const p = getBaseSystemPrompt(locale);
if (locale === "en") {
expect(p).toMatch(/FORBIDDEN PHRASES/);
expect(p).toContain("make sure to get enough sleep");
expect(p).toContain("consult your doctor");
} else {
expect(p).toMatch(/VERBOTENE FLOSKELN/);
expect(p).toContain("achte auf ausreichend Schlaf");
}
});
it(`${locale}: keeps the correlation guard (|r|>0.4, association not cause)`, () => {
const p = getBaseSystemPrompt(locale);
expect(p).toContain("0.4");
if (locale === "en") {
expect(p).toMatch(/association.*never a "cause"/i);
} else {
expect(p).toMatch(/Zusammenhang.*nie als "Ursache"/i);
}
});
}
});
describe("base-system assessment prompt — D1: step is now skippable", () => {
it("English: instructs to skip a manufactured step when nothing is actionable", () => {
const p = getBaseSystemPrompt("en");
expect(p).toMatch(/do NOT manufacture a step/i);
expect(p).toMatch(/fabricated step is exactly the platitude/i);
});
it("German: instructs to skip a forced step when nothing is actionable", () => {
const p = getBaseSystemPrompt("de");
expect(p).toMatch(/ERZWINGE KEINEN Schritt/);
expect(p).toMatch(/erfundener Schritt/i);
});
});
describe("base-system assessment prompt — V2: few-shot examples present", () => {
it("English: carries a GOOD grounded example and a BAD banned-filler counter-example", () => {
const p = getBaseSystemPrompt("en");
expect(p).toMatch(/EXAMPLES/);
expect(p).toMatch(/GOOD/);
expect(p).toMatch(/BAD/);
// The counter-example deliberately contains a banned phrase, labelled
// "do NOT write this" so the model learns the failure form.
expect(p).toMatch(/do NOT write this/i);
});
it("German: carries grounded + counter examples", () => {
const p = getBaseSystemPrompt("de");
expect(p).toMatch(/BEISPIELE/);
expect(p).toMatch(/GUT/);
expect(p).toMatch(/SCHLECHT/);
expect(p).toMatch(/so NICHT/i);
});
});
describe("metric-archetype user prompt — context block plumbing", () => {
const meta = getMetricStatusMeta("RESTING_HEART_RATE")!;
it("appends the assessment-context block when provided", () => {
const out = getMetricArchetypeUserPrompt(
meta,
"{}",
"2026-06-05",
"en",
"",
"VARIETY: lead with the trend.\n\nRELATIONS: a relation.",
);
expect(out).toContain("VARIETY: lead with the trend.");
expect(out).toContain("RELATIONS: a relation.");
});
it("omits the block cleanly when empty (no dangling separators)", () => {
const out = getMetricArchetypeUserPrompt(meta, "{}", "2026-06-05", "en");
expect(out).not.toContain("VARIETY");
expect(out).not.toContain("undefined");
});
it("makes the closing step conditional in the user instruction (both locales)", () => {
expect(
getMetricArchetypeUserPrompt(meta, "{}", "2026-06-05", "en"),
).toMatch(/when nothing is, skip the step/i);
expect(
getMetricArchetypeUserPrompt(meta, "{}", "2026-06-05", "de"),
).toMatch(/lass den Schritt weg/i);
});
});
describe("metric-archetype system prompt — archetype grounding intact", () => {
it("still injects the archetype section + metadata", () => {
const meta = getMetricStatusMeta("SLEEP_DURATION")!;
const sys = getMetricArchetypeSystemPrompt(meta, "en");
expect(sys).toContain("SLEEP");
expect(sys).toContain("Sleep duration");
});
});
+14 -4
View File
@@ -52,10 +52,10 @@ DEINE DATENGRUNDLAGE (graded snapshot):
- Lies die persönliche Baseline aus den weekly/monthly/yearly-Mitteln; das kurze Fenster sind die recent-Tage.
- Ein Slice kann leer sein, wenn die Historie kurz ist. Dann nicht so tun, als gäbe es ihn.
SO BAUST DU DIE EINSCHÄTZUNG (genau diese drei Schritte, als fließender Text):
SO BAUST DU DIE EINSCHÄTZUNG (als fließender Text):
1. BENENNEN: Nenne den aktuellen Befund mit einer konkreten Zahl aus dem Snapshot (z.B. das recent-Mittel oder den letzten Tageswert).
2. EINORDNEN gegen die EIGENE Baseline: Vergleiche das kurze Fenster (recent, ~Woche) mit der langen Baseline (monthly/yearly-Mittel). Melde nur SIGNIFIKANTE Abweichungen — eine Zahl, die innerhalb der normalen Schwankung des Nutzers liegt, ist KEIN Befund.
3. EIN SCHRITT: Schließe mit GENAU EINER konkreten, machbaren Empfehlung. Eine Botschaft = ein Verhalten. Keine Liste.
3. EIN SCHRITT — NUR WENN ER ECHT IST: Schließe mit GENAU EINER konkreten, machbaren Empfehlung, WENN aus dem Befund wirklich etwas Umsetzbares folgt. Eine Botschaft = ein Verhalten, keine Liste. Ist der Wert stabil und im grünen Bereich und gibt es nichts sinnvoll zu tun, dann ERZWINGE KEINEN Schritt — bestätige kurz und nenne stattdessen einen Punkt, den man im Auge behalten kann. Ein erfundener Schritt ist genau die Floskel, die wir vermeiden.
TONALITÄT:
- Zweite Person ("dein Blutdruck", "deine Werte"), warm, direkt, ehrlich.
@@ -73,6 +73,11 @@ DATENLAGE EHRLICH BEWERTEN (nie einen Trend erfinden):
VERBOTENE FLOSKELN (signalisieren ungegroundeten Fülltext — nie ausgeben, außer im disclaimer):
"achte auf ausreichend Schlaf", "trinke genug Wasser", "regelmäßige Bewegung", "ärztlicher Rat empfohlen".
BEISPIELE (illustrieren Form und Erdung — übernimm sie nicht wörtlich, jede Einschätzung nutzt die echten Snapshot-Zahlen):
- GUT (gegroundet, konkret, mit echtem Schritt): "Dein Ruhepuls liegt diese Woche im Schnitt bei 61 bpm — 5 unter deinem Monatsmittel von 66 und dein niedrigster Wert seit Wochen. Das passt zu mehr Bewegung; behalte den Trend einfach bei."
- GUT (stabil, KEIN erzwungener Schritt): "Deine SpO₂ ist mit 97 % stabil und liegt genau in deinem üblichen Bereich — kein Befund. Nichts zu tun; ein gelegentlicher Check reicht."
- SCHLECHT (verbotene Floskel, ungegroundet — so NICHT): "Deine Werte sehen gut aus. Achte auf ausreichend Schlaf und regelmäßige Bewegung."
AUSGABEFORMAT: Antworte ausschließlich mit validem JSON in genau diesem Schema. Das Feld "summary" enthält die komplette Einschätzung als deutscher Fließtext (2-4 Sätze):
{ "summary": "..." }`;
@@ -88,10 +93,10 @@ YOUR DATA (graded snapshot):
- Read the personal baseline from the weekly/monthly/yearly means; the short window is the recent days.
- A slice can be empty when the history is short — do not pretend it exists.
HOW TO BUILD THE ASSESSMENT (exactly these three moves, as flowing prose):
HOW TO BUILD THE ASSESSMENT (as flowing prose):
1. NAME the current finding with a concrete number from the snapshot (e.g. the recent mean or the latest daily value).
2. PLACE it against the user's OWN baseline: compare the short window (recent, ~week) to the long baseline (monthly/yearly mean). Report only SIGNIFICANT deviations — a value inside the user's normal swing is NOT a finding.
3. ONE step: close with EXACTLY ONE concrete, doable suggestion. One message = one behaviour. No list.
3. ONE step — ONLY IF IT IS REAL: close with EXACTLY ONE concrete, doable suggestion WHEN the finding genuinely implies an action. One message = one behaviour, no list. If the value is steady and in a good place and there is nothing useful to do, do NOT manufacture a step — affirm briefly and name one thing worth keeping an eye on instead. A fabricated step is exactly the platitude we ban.
TONE:
- Second person ("your blood pressure", "your values"), warm, direct, honest.
@@ -109,6 +114,11 @@ JUDGE THE DATA HONESTLY (never invent a trend):
FORBIDDEN PHRASES (they signal ungrounded filler — never emit, except in the disclaimer):
"make sure to get enough sleep", "drink enough water", "regular exercise", "consult your doctor".
EXAMPLES (they illustrate form and grounding — do not copy them verbatim; every assessment uses the real snapshot numbers):
- GOOD (grounded, specific, with a real step): "Your resting heart rate is averaging 61 bpm this week — 5 below your monthly mean of 66 and your lowest in weeks. That tracks with more movement; just keep the trend going."
- GOOD (steady, NO forced step): "Your SpO₂ is steady at 97 %, right inside your usual range — no finding here. Nothing to act on; an occasional check is enough."
- BAD (banned filler, ungrounded — do NOT write this): "Your numbers look good. Make sure to get enough sleep and keep up regular exercise."
OUTPUT FORMAT: Reply with valid JSON only, in exactly this schema. The "summary" field holds the complete assessment as English flowing prose (2-4 sentences):
{ "summary": "..." }`;
+12 -2
View File
@@ -127,19 +127,29 @@ export function getMetricArchetypeUserPrompt(
todayKey: string,
locale: Locale,
previousContextBlock?: string,
/**
* v1.12.1 — diversity / anti-repetition context (variety lead, data
* strength, steady-run repetition signal, cross-metric relations). All
* grounded in already-computed data; optional and may be empty.
*/
assessmentContextBlock?: string,
): string {
const ctxBlock =
previousContextBlock && previousContextBlock.trim().length > 0
? `\n\n${previousContextBlock}\n`
: "";
const extraBlock =
assessmentContextBlock && assessmentContextBlock.trim().length > 0
? `\n\n${assessmentContextBlock}\n`
: "";
if (locale === "en") {
return `Date: ${todayKey} (Europe/Berlin)
Write one short assessment of this person's ${meta.displayName.toLowerCase()}: name the current level with a concrete number from the snapshot, place the recent window against their own weekly/monthly baseline, and close with one doable step. Judge confidence from the measurement count and recency.${ctxBlock}
Write one short assessment of this person's ${meta.displayName.toLowerCase()}: name the current level with a concrete number from the snapshot, place the recent window against their own weekly/monthly baseline, and — when something is genuinely actionable — close with one doable step; when nothing is, skip the step rather than manufacture filler. Judge confidence from the measurement count and recency.${ctxBlock}${extraBlock}
${snapshotJson}`;
}
return `Datum: ${todayKey} (Europe/Berlin)
Schreibe eine kurze Einschätzung zu ${meta.displayName} dieser Person: benenne das aktuelle Niveau mit einer konkreten Zahl aus dem Snapshot, ordne das recent-Fenster gegen die eigene Wochen-/Monats-Baseline ein und schließe mit einem machbaren Schritt. Konfidenz aus Messanzahl und Aktualität ableiten.${ctxBlock}
Schreibe eine kurze Einschätzung zu ${meta.displayName} dieser Person: benenne das aktuelle Niveau mit einer konkreten Zahl aus dem Snapshot, ordne das recent-Fenster gegen die eigene Wochen-/Monats-Baseline ein und schließe — wenn etwas wirklich umsetzbar ist — mit einem machbaren Schritt; ist nichts umsetzbar, lass den Schritt weg statt Fülltext zu erfinden. Konfidenz aus Messanzahl und Aktualität ableiten.${ctxBlock}${extraBlock}
${snapshotJson}`;
}
@@ -0,0 +1,208 @@
import { describe, expect, it } from "vitest";
import {
buildAssessmentContextBlock,
computeSteadyRun,
formatDataStrength,
formatRelationsBlock,
formatRepetitionSignal,
formatVarietyHint,
pickVarietyLead,
type RelevantCorrelation,
} from "../assessment-context";
describe("pickVarietyLead — deterministic per-render variety token", () => {
it("is stable for the same (user, metric, day)", () => {
const a = pickVarietyLead("u-1", "RESTING_HEART_RATE", "2026-06-05");
const b = pickVarietyLead("u-1", "RESTING_HEART_RATE", "2026-06-05");
expect(a).toBe(b);
});
it("rotates across days and across metrics (not a constant)", () => {
const days = ["2026-06-01", "2026-06-02", "2026-06-03", "2026-06-04"].map(
(d) => pickVarietyLead("u-1", "STEPS", d),
);
const metrics = ["STEPS", "SLEEP_DURATION", "VO2_MAX", "BLOOD_GLUCOSE"].map(
(m) => pickVarietyLead("u-1", m, "2026-06-05"),
);
// Across these inputs we expect more than one distinct lead to appear,
// proving the token is not collapsed to a single constant.
expect(new Set([...days, ...metrics]).size).toBeGreaterThan(1);
});
it("only ever returns one of the three known leads", () => {
for (let i = 0; i < 50; i++) {
const lead = pickVarietyLead("u", `M${i}`, "2026-06-05");
expect(["trend", "latest", "consistency"]).toContain(lead);
}
});
it("never uses Math.random / Date.now (reproducible across calls in different ticks)", () => {
const first = pickVarietyLead("u-7", "HEART_RATE_VARIABILITY", "2026-06-05");
// A second call after some work must match — proves no time/random seed.
for (let i = 0; i < 1000; i++) fnvBusy(i);
const second = pickVarietyLead(
"u-7",
"HEART_RATE_VARIABILITY",
"2026-06-05",
);
expect(second).toBe(first);
});
});
function fnvBusy(n: number): number {
let x = n;
for (let i = 0; i < 100; i++) x = (x * 31 + i) >>> 0;
return x;
}
describe("formatVarietyHint", () => {
it("never forces a trend — advisory only", () => {
for (const lead of ["trend", "latest", "consistency"] as const) {
expect(formatVarietyHint(lead, "en")).toMatch(/never invent a trend/i);
expect(formatVarietyHint(lead, "de")).toMatch(/nie einen Trend erfinden/i);
}
});
});
describe("formatDataStrength — surfaces n + recency for honest hedging", () => {
it("flags thin data so the prose hedges (English)", () => {
const out = formatDataStrength({ points: 3, newestDaysAgo: 1 }, "en");
expect(out).toContain("DATA STRENGTH");
expect(out).toContain("3 day-buckets");
expect(out).toMatch(/too few points/i);
});
it("flags stale data when newest reading is old (German)", () => {
const out = formatDataStrength({ points: 30, newestDaysAgo: 19 }, "de");
expect(out).toContain("DATENLAGE");
expect(out).toMatch(/19 Tagen/);
expect(out).toMatch(/veralten/i);
});
it("adds no hedge guidance when data is strong and fresh", () => {
const out = formatDataStrength({ points: 40, newestDaysAgo: 0 }, "en");
expect(out).toContain("40 day-buckets");
expect(out).not.toMatch(/too few points/i);
expect(out).not.toMatch(/going stale/i);
});
});
describe("formatRepetitionSignal — streak-aware anti-repetition", () => {
it("is silent when there is no run to call out", () => {
expect(formatRepetitionSignal(0, "en")).toBe("");
expect(formatRepetitionSignal(-1, "de")).toBe("");
});
it("tells the model to pivot, not restate, on a run (English)", () => {
const out = formatRepetitionSignal(3, "en");
expect(out).toContain("REPETITION");
expect(out).toContain("3 time(s)");
expect(out).toMatch(/pivot to a DIFFERENT facet/i);
expect(out).toMatch(/skip the manufactured step/i);
});
it("renders the continuity clause in German", () => {
const out = formatRepetitionSignal(2, "de");
expect(out).toContain("WIEDERHOLUNG");
expect(out).toMatch(/3 Checks in Folge/);
});
});
describe("formatRelationsBlock — cross-metric correlations, descriptive only", () => {
const rels: RelevantCorrelation[] = [
{
interpretation:
"Higher time in daylight tends to go with higher next-day sleep duration in your data — a pattern worth watching, not a cause.",
n: 42,
r: 0.51,
},
{ interpretation: "Second relation here.", n: 30, r: -0.44 },
{ interpretation: "Third relation should be capped out.", n: 25, r: 0.4 },
];
it("is empty when no correlations involve the metric", () => {
expect(formatRelationsBlock([], "en")).toBe("");
});
it("passes the engine interpretation verbatim and caps at two", () => {
const out = formatRelationsBlock(rels, "en");
expect(out).toContain("Higher time in daylight tends to go with");
expect(out).toContain("Second relation here.");
expect(out).not.toContain("Third relation");
expect(out).toContain("n=42");
expect(out).toContain("r=0.51");
});
it("frames the block as association, never cause (both locales)", () => {
expect(formatRelationsBlock(rels, "en")).toMatch(/NEVER causal/);
expect(formatRelationsBlock(rels, "de")).toMatch(/NIE kausal/);
});
});
describe("computeSteadyRun — grounded repetition proxy from the graded series", () => {
const monthly = [{ mean: 100 }, { mean: 100 }, { mean: 100 }];
it("returns 0 with too few weekly buckets", () => {
expect(computeSteadyRun([{ mean: 100 }], monthly)).toBe(0);
});
it("counts a trailing run of weeks inside the band", () => {
const weekly = [
{ mean: 130 }, // far from baseline → breaks the run
{ mean: 101 },
{ mean: 99 },
{ mean: 102 },
];
// The last three weeks are within 8% of baseline 100 → run = 3.
expect(computeSteadyRun(weekly, monthly)).toBe(3);
});
it("returns 0 when the most recent week deviates (not steady right now)", () => {
const weekly = [{ mean: 100 }, { mean: 100 }, { mean: 130 }];
expect(computeSteadyRun(weekly, monthly)).toBe(0);
});
it("treats a single steady week as not-yet-repetition (returns 0)", () => {
const weekly = [{ mean: 130 }, { mean: 100 }];
expect(computeSteadyRun(weekly, monthly)).toBe(0);
});
it("falls back to the weekly mean when no monthly history exists", () => {
const weekly = [{ mean: 50 }, { mean: 50 }, { mean: 50 }];
expect(computeSteadyRun(weekly, [])).toBe(3);
});
});
describe("buildAssessmentContextBlock — assembles only the non-empty blocks", () => {
it("drops empty blocks (first-run thin metric, no correlations)", () => {
const out = buildAssessmentContextBlock(
{
varietyLead: "latest",
dataStrength: { points: 2, newestDaysAgo: 0 },
repeatCount: 0,
relations: [],
},
"en",
);
expect(out).toContain("VARIETY");
expect(out).toContain("DATA STRENGTH");
expect(out).not.toContain("REPETITION");
expect(out).not.toContain("RELATIONS");
});
it("includes every block when all signals are present", () => {
const out = buildAssessmentContextBlock(
{
varietyLead: "trend",
dataStrength: { points: 40, newestDaysAgo: 0 },
repeatCount: 3,
relations: [{ interpretation: "A relation.", n: 30, r: 0.5 }],
},
"en",
);
expect(out).toContain("VARIETY");
expect(out).toContain("DATA STRENGTH");
expect(out).toContain("REPETITION");
expect(out).toContain("RELATIONS");
});
});
@@ -0,0 +1,73 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: vi.fn() },
measurement: { findMany: vi.fn() },
moodEntry: { findMany: vi.fn() },
},
}));
import { prisma } from "@/lib/db";
import { getRelevantCorrelationsForMetric } from "../metric-correlation-context";
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.user.findUnique).mockResolvedValue({
timezone: "Europe/Berlin",
} as never);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
});
describe("getRelevantCorrelationsForMetric", () => {
it("returns [] without any DB read for a non-discovery metric", async () => {
// VO2_MAX is not part of the curated discovery matrix.
const out = await getRelevantCorrelationsForMetric("u-1", "VO2_MAX");
expect(out).toEqual([]);
expect(prisma.measurement.findMany).not.toHaveBeenCalled();
});
it("surfaces only FDR-surviving pairs that involve the metric's channel", async () => {
// Build two strongly-correlated daily series over 60 days:
// TIME_IN_DAYLIGHT (behaviour) → next-day RESTING_HEART_RATE (outcome),
// anti-correlated, plus uncorrelated noise on other channels.
const rows: Array<{ type: string; value: number; measuredAt: Date }> = [];
const base = new Date("2026-01-01T12:00:00Z");
for (let d = 0; d < 60; d++) {
const day = new Date(base.getTime() + d * 86_400_000);
const daylight = 30 + (d % 10) * 6; // varies 30..84
rows.push({ type: "TIME_IN_DAYLIGHT", value: daylight, measuredAt: day });
// next-day RHR moves opposite to today's daylight (lag-1 anti-corr).
const next = new Date(base.getTime() + (d + 1) * 86_400_000);
rows.push({
type: "RESTING_HEART_RATE",
value: 90 - daylight * 0.4,
measuredAt: next,
});
}
vi.mocked(prisma.measurement.findMany).mockResolvedValue(rows as never);
const out = await getRelevantCorrelationsForMetric(
"u-1",
"RESTING_HEART_RATE",
);
expect(out.length).toBeGreaterThan(0);
// Every surfaced relation mentions resting heart rate (the metric's channel).
for (const c of out) {
expect(c.interpretation.toLowerCase()).toContain("resting heart rate");
expect(Number.isFinite(c.r)).toBe(true);
expect(c.n).toBeGreaterThanOrEqual(20);
}
});
it("is best-effort: a DB failure resolves to [] rather than throwing", async () => {
vi.mocked(prisma.measurement.findMany).mockRejectedValue(
new Error("db down"),
);
const out = await getRelevantCorrelationsForMetric(
"u-1",
"RESTING_HEART_RATE",
);
expect(out).toEqual([]);
});
});
@@ -18,8 +18,14 @@ vi.mock("@/lib/insights/memory", () => ({
formatPreviousContextForPrompt: vi.fn().mockReturnValue(""),
}));
vi.mock("@/lib/insights/metric-correlation-context", () => ({
getRelevantCorrelationsForMetric: vi.fn().mockResolvedValue([]),
}));
import { prisma } from "@/lib/db";
import { runStatusCompletion } from "@/lib/insights/status-provider";
import { getRelevantCorrelationsForMetric } from "@/lib/insights/metric-correlation-context";
import { formatPreviousContextForPrompt } from "@/lib/insights/memory";
import { generateMetricStatus } from "../metric-status";
import {
getMetricArchetypeSystemPrompt,
@@ -57,6 +63,11 @@ function stubCompletion(
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue([] as never);
// resetAllMocks clears the module-mock default impls too — restore the
// benign defaults so the relations fetch and the previous-context format
// are no-ops unless a test overrides them.
vi.mocked(getRelevantCorrelationsForMetric).mockResolvedValue([]);
vi.mocked(formatPreviousContextForPrompt).mockReturnValue("");
});
describe("metric-status registry", () => {
@@ -211,6 +222,64 @@ describe("generateMetricStatus — generation path", () => {
expect(createCall.data.action).toBe(
"insights.metric:RESTING_HEART_RATE-status.en",
);
// v1.12.1 — the diversity context (variety lead + explicit data strength)
// reaches the user prompt, and the relations fetch is keyed by this
// metric's DB measurement type.
expect(captured.userPrompt).toContain("VARIETY");
expect(captured.userPrompt).toContain("DATA STRENGTH");
expect(getRelevantCorrelationsForMetric).toHaveBeenCalledWith(
"user-1",
"RESTING_HEART_RATE",
);
});
it("weaves a returned correlation into the prompt as a descriptive relation", async () => {
const now = new Date();
const records: Array<{ value: number; measuredAt: Date }> = [];
for (let day = 0; day < 60; day++) {
records.push({
value: 60,
measuredAt: new Date(now.getTime() - day * dayMs),
});
}
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
vi.mocked(prisma.measurement.count).mockResolvedValue(60 as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue(records as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dateOfBirth: null,
gender: null,
} as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: now,
} as never);
vi.mocked(getRelevantCorrelationsForMetric).mockResolvedValueOnce([
{
interpretation:
"Higher time in daylight tends to go with lower next-day resting heart rate in your data — a pattern worth watching, not a cause.",
n: 35,
r: -0.46,
},
]);
const captured = { systemPrompt: null, userPrompt: null } as {
systemPrompt: string | null;
userPrompt: string | null;
};
stubCompletion('{"summary":"Steady RHR."}', captured);
await generateMetricStatus({
metric: "RESTING_HEART_RATE",
userId: "user-1",
locale: "en",
});
expect(captured.userPrompt).toContain("RELATIONS");
expect(captured.userPrompt).toContain(
"Higher time in daylight tends to go with",
);
// Descriptive framing preserved verbatim — never recast as cause.
expect(captured.userPrompt).toMatch(/NEVER causal/);
});
it("strips chart tokens from the persisted text", async () => {
+283
View File
@@ -0,0 +1,283 @@
/**
* v1.12.1 — assessment-context helpers for the per-metric Insights cards.
*
* The per-metric assessment prompt is tightly grounded but its *form* used
* to converge: every card opened the same way, forced the same "one doable
* step", and a metric that stayed steady for weeks produced near-identical
* paragraphs. None of that is a grounding or safety problem — it is a
* diversity / repetition problem, fixable with signals already computed
* server-side. This module turns those signals into small, locale-aware
* prompt blocks the generator interpolates:
*
* - a deterministic per-render VARIETY token (lead-with-trend vs
* lead-with-latest vs lead-with-consistency) so consecutive cards and
* consecutive days don't read the same. Seeded from
* `userId + metricId + dayKey` (NOT Math.random / Date.now — those are
* unavailable on this codebase's deterministic paths) so it is stable
* per render and unit-testable.
* - an explicit DATA STRENGTH line (n + recency) so the prose hedges on
* thin data instead of the model guessing what "few" means.
* - a REPETITION line ("you've reported 'steady' N times running") so the
* model varies or escalates instead of restating the same level.
* - a RELATIONS block carrying the FDR-surviving cross-metric correlations
* that involve THIS metric — already computed by the discovery engine,
* previously only surfaced in the period narrative.
*
* Everything here is PURE: callers fetch the data, this module formats it.
* No grounding floor is touched — these blocks add context, they never relax
* the own-baseline rule, the computed-stats rule, or the filler-phrase ban.
*/
import type { Locale } from "@/lib/i18n/config";
/**
* Three rhetorical entry points the card can lead with. The grounding is
* identical in every case — only the opening *angle* rotates, so a user
* paging through ten metric cards doesn't read ten identically-shaped
* paragraphs.
*/
export type VarietyLead = "trend" | "latest" | "consistency";
const VARIETY_LEADS: readonly VarietyLead[] = [
"trend",
"latest",
"consistency",
] as const;
/**
* Deterministic 32-bit FNV-1a hash. Stable across processes (no salt, no
* platform dependence), so the same `(userId, metricId, dayKey)` always
* picks the same lead — which makes the variety token reproducible and
* directly unit-testable, and keeps a render idempotent within a day.
*/
function fnv1a(input: string): number {
let hash = 0x811c9dc5;
for (let i = 0; i < input.length; i++) {
hash ^= input.charCodeAt(i);
// Multiply by the FNV prime (0x01000193) in 32-bit space.
hash = Math.imul(hash, 0x01000193);
}
// Coerce to an unsigned 32-bit integer.
return hash >>> 0;
}
/**
* Pick the per-render variety lead for this user × metric × day. Stable
* (seeded, not random) so the same card re-renders identically within a
* day, but rotates across metrics and across days.
*/
export function pickVarietyLead(
userId: string,
metricId: string,
dayKey: string,
): VarietyLead {
const seed = fnv1a(`${userId}|${metricId}|${dayKey}`);
return VARIETY_LEADS[seed % VARIETY_LEADS.length];
}
/**
* One short locale-aware hint nudging the opening angle. Kept advisory
* ("you may open with …") so it never overrides the grounding rules when
* the data makes a different lead the honest one.
*/
export function formatVarietyHint(lead: VarietyLead, locale: Locale): string {
if (locale === "en") {
const angle =
lead === "trend"
? "the recent direction of travel (rising / falling / flat vs the baseline)"
: lead === "latest"
? "the single most recent value and where it sits"
: "how consistent or variable the readings have been";
return `VARIETY: to keep your cards from reading the same, you may open this one by leading with ${angle} — only if the data genuinely supports that angle; never force it, never invent a trend.`;
}
const angle =
lead === "trend"
? "der jüngsten Richtung (steigend / fallend / flach gegenüber der Baseline)"
: lead === "latest"
? "dem aktuellsten Einzelwert und seiner Einordnung"
: "der Konstanz bzw. Streuung der Werte";
return `ABWECHSLUNG: damit deine Karten nicht gleich klingen, darfst du diese mit ${angle} eröffnen — nur wenn die Daten diesen Blickwinkel wirklich tragen; nie erzwingen, nie einen Trend erfinden.`;
}
/**
* Surface the computed data strength (n + recency) explicitly so the prose
* and the UI confidence badge agree. The base prompt already tells the
* model to be honest about thin data but leaves "few" undefined; this pins
* the numbers the model would otherwise have to guess at.
*
* `points` is the number of daily buckets in the read window;
* `newestDaysAgo` is how stale the most recent reading is (null when none).
*/
export function formatDataStrength(
args: { points: number; newestDaysAgo: number | null },
locale: Locale,
): string {
const { points, newestDaysAgo } = args;
const thin = points < 7;
const stale = newestDaysAgo != null && newestDaysAgo > 7;
if (locale === "en") {
const recency =
newestDaysAgo == null
? "no dated reading"
: newestDaysAgo <= 0
? "latest reading today"
: newestDaysAgo === 1
? "latest reading 1 day ago"
: `latest reading ${newestDaysAgo} days ago`;
const guidance = thin
? " — too few points for a reliable trend; hedge accordingly and avoid claiming a direction."
: stale
? " — the data is going stale; note that the picture may be out of date."
: "";
return `DATA STRENGTH: ${points} day-buckets in the window, ${recency}.${guidance}`;
}
const recency =
newestDaysAgo == null
? "kein datierter Wert"
: newestDaysAgo <= 0
? "letzter Wert heute"
: newestDaysAgo === 1
? "letzter Wert vor 1 Tag"
: `letzter Wert vor ${newestDaysAgo} Tagen`;
const guidance = thin
? " — zu wenige Punkte für einen belastbaren Trend; entsprechend vorsichtig formulieren, keine Richtung behaupten."
: stale
? " — die Daten veralten; weise darauf hin, dass das Bild nicht mehr aktuell sein könnte."
: "";
return `DATENLAGE: ${points} Tages-Buckets im Fenster, ${recency}.${guidance}`;
}
/**
* Repetition signal — how many of the most recent assessments in a row
* carried the same direction/classification. Drives the model to vary or
* escalate ("still steady, 3rd week running — let's look at a different
* facet") instead of restating the same level verbatim.
*
* `repeatCount` is the run length of same-classification prior assessments
* (0 when this is the first, or when the last assessment differed).
*/
export function formatRepetitionSignal(
repeatCount: number,
locale: Locale,
): string {
if (repeatCount <= 0) {
// No run to call out — stay silent so the block adds no noise.
return "";
}
if (locale === "en") {
return `REPETITION: you have already given a similar "no material change" assessment for this metric ${repeatCount} time(s) in a row. Do NOT restate the same level again. Acknowledge the continuity in one short clause ("still holding steady, ${repeatCount + 1} checks running") and then pivot to a DIFFERENT facet — consistency, time-of-day pattern, or a correlated metric — or, if nothing is genuinely actionable, say so plainly and skip the manufactured step.`;
}
return `WIEDERHOLUNG: du hast für diese Metrik bereits ${repeatCount}-mal in Folge eine ähnliche "keine wesentliche Änderung"-Einschätzung gegeben. Wiederhole NICHT erneut dasselbe Niveau. Benenne die Kontinuität in einem kurzen Nebensatz ("weiterhin stabil, ${repeatCount + 1} Checks in Folge") und wende dich dann einem ANDEREN Aspekt zu — Konstanz, Tageszeit-Muster oder einer korrelierten Metrik — oder sage, falls nichts wirklich umsetzbar ist, dies klar und lass den erzwungenen Schritt weg.`;
}
/**
* Compute the "steady run" length from the graded series alone — how many
* of the most recent weekly buckets in a row sit within a small band of the
* user's own longer baseline. This is the grounded, computed-not-stored
* proxy for "you've already noted this is steady N times": it derives the
* repetition signal from the same graded data the model sees, so there is
* no new persistence and no fragile prose-similarity matching.
*
* Returns 0 when the picture is NOT steady (most recent week deviates) or
* when there are too few weekly buckets to judge — in both cases the
* repetition block stays silent and the model proceeds normally.
*
* `weekly` / `monthly` are the graded-series slices (ascending: oldest →
* newest). `bandFraction` is the relative tolerance around the baseline
* mean that still counts as "no material change" (default 8 %).
*/
export function computeSteadyRun(
weekly: ReadonlyArray<{ mean: number }>,
monthly: ReadonlyArray<{ mean: number }>,
bandFraction = 0.08,
): number {
if (weekly.length < 2) return 0;
// Baseline: the longer-horizon mean the recent weeks are placed against.
// Prefer the monthly means; fall back to the full weekly mean when no
// monthly history exists yet.
const baselineSource = monthly.length > 0 ? monthly : weekly;
const baseline =
baselineSource.reduce((sum, b) => sum + b.mean, 0) / baselineSource.length;
if (!Number.isFinite(baseline) || baseline === 0) return 0;
const band = Math.abs(baseline) * bandFraction;
// Walk newest → oldest, counting consecutive weeks within the band.
let run = 0;
for (let i = weekly.length - 1; i >= 0; i--) {
if (Math.abs(weekly[i].mean - baseline) <= band) {
run += 1;
} else {
break;
}
}
// A run of 1 is not yet repetition worth flagging; report 0 so the block
// only fires once the steadiness has actually persisted.
return run >= 2 ? run : 0;
}
/**
* A single FDR-surviving correlation that involves the current metric,
* reduced to the conservative descriptive `interpretation` string the
* discovery engine already produced (never causal) plus its n and r for
* the model to weigh.
*/
export interface RelevantCorrelation {
interpretation: string;
n: number;
r: number;
}
/**
* Format the cross-metric RELATIONS block. The interpretation strings are
* passed VERBATIM — they are the engine's conservative, descriptive,
* never-causal phrasings, already FDR-controlled. The instruction reminds
* the model these are associations, optional to mention, and must never be
* recast as causes or have their numbers altered.
*/
export function formatRelationsBlock(
correlations: RelevantCorrelation[],
locale: Locale,
): string {
if (correlations.length === 0) return "";
// Cap at two so the block never crowds out the metric's own grounding.
const top = correlations.slice(0, 2);
const lines = top.map(
(c) => `- ${c.interpretation} (n=${c.n}, r=${c.r.toFixed(2)})`,
);
if (locale === "en") {
return [
"RELATIONS (statistically-screened associations in YOUR data involving this metric — descriptive, NEVER causal):",
...lines,
'You MAY weave at most ONE of these in if it adds genuine insight, phrased as an "association" / "tends to go with" — never as a cause, and never alter the numbers. Skip them if the metric\'s own finding is the stronger story.',
].join("\n");
}
return [
"ZUSAMMENHÄNGE (statistisch geprüfte Assoziationen in DEINEN Daten, die diese Metrik betreffen — beschreibend, NIE kausal):",
...lines,
'Du DARFST höchstens EINEN davon einflechten, wenn er echten Mehrwert bringt — als "Zusammenhang" / "geht tendenziell einher mit" formuliert, nie als Ursache, und ohne die Zahlen zu verändern. Lass sie weg, wenn der eigene Befund der Metrik die stärkere Geschichte ist.',
].join("\n");
}
/**
* Assemble the optional context blocks into one string the user prompt
* appends after the previous-context block. Empty blocks drop out, so a
* first-run thin-data metric with no correlations adds nothing.
*/
export function buildAssessmentContextBlock(
args: {
varietyLead: VarietyLead;
dataStrength: { points: number; newestDaysAgo: number | null };
repeatCount: number;
relations: RelevantCorrelation[];
},
locale: Locale,
): string {
const parts = [
formatVarietyHint(args.varietyLead, locale),
formatDataStrength(args.dataStrength, locale),
formatRepetitionSignal(args.repeatCount, locale),
formatRelationsBlock(args.relations, locale),
].filter((p) => p.trim().length > 0);
return parts.join("\n\n");
}
@@ -0,0 +1,165 @@
/**
* v1.12.1 (D3) — feed FDR-surviving cross-metric correlations into the
* per-metric assessment card.
*
* The discovery engine (`correlation-discovery.ts`) already computes, with
* a real Pearson + exact p-value + Benjamini-Hochberg FDR control, which
* behaviour×outcome pairs are statistically defensible. Until now that
* intelligence only reached the period narrative; the per-metric cards —
* where many users actually live — never saw it, so every card read in
* isolation ("your resting HR is X") instead of relationally ("your
* resting HR rose the same week your sleep dropped").
*
* This module runs the SAME full-matrix discovery the
* `/api/insights/correlations` route runs (so a card never surfaces a pair
* the correlations page wouldn't), then filters to the surviving pairs that
* INVOLVE the current metric's discovery channel. The result is the
* engine's own conservative, descriptive, never-causal `interpretation`
* strings — passed verbatim into the prompt as grounded context.
*
* Read-only consumption. No new statistics are computed here; the only
* change vs the route is the filter to one metric and a shorter window cap.
*/
import type { MeasurementType } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { getLocalDateParts } from "@/lib/timezone";
import {
discoverCorrelations,
DISCOVERY_BEHAVIOURS,
DISCOVERY_OUTCOMES,
type DailySeriesPoint,
type NamedSeries,
} from "@/lib/insights/correlation-discovery";
import type { RelevantCorrelation } from "@/lib/insights/assessment-context";
const MS_PER_DAY = 24 * 60 * 60 * 1000;
/** Trailing window for the discovery scan — mirrors the route. */
const WINDOW_DAYS = 180;
/**
* The discovery-channel key a `MeasurementType` participates as, or null
* when the metric is not part of the curated discovery matrix. The channel
* key equals the measurement type for every participating metric
* (`TIME_IN_DAYLIGHT`, `BLOOD_GLUCOSE`, `ACTIVITY_STEPS`, `SLEEP_DURATION`,
* `HEART_RATE_VARIABILITY`, `RESTING_HEART_RATE`).
*/
function channelKeyForType(type: MeasurementType): string | null {
const key = type as string;
if (
(DISCOVERY_BEHAVIOURS as readonly string[]).includes(key) ||
(DISCOVERY_OUTCOMES as readonly string[]).includes(key)
) {
return key;
}
return null;
}
/** Day key (YYYY-MM-DD) for an instant in the user's display timezone. */
function tzDayKey(at: Date, tz: string): string {
const { year, month, day } = getLocalDateParts(at, tz);
return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
}
/** Collapse rows to per-day means keyed in the user's tz. */
function toDailyMeans(
rows: Array<{ value: number; at: Date }>,
tz: string,
): DailySeriesPoint[] {
const byDay = new Map<string, { sum: number; count: number }>();
for (const r of rows) {
if (!Number.isFinite(r.value)) continue;
const day = tzDayKey(r.at, tz);
const acc = byDay.get(day) ?? { sum: 0, count: 0 };
acc.sum += r.value;
acc.count += 1;
byDay.set(day, acc);
}
return [...byDay.entries()]
.map(([day, acc]) => ({ day, value: acc.sum / acc.count }))
.sort((a, b) => (a.day < b.day ? -1 : 1));
}
/**
* Fetch the FDR-surviving correlations that involve `measurementType`.
*
* Returns an empty array when the metric is not a discovery channel, when
* there is too little paired data, or when no pair survives the FDR control
* — the relations prompt block then simply drops out. Best-effort: any
* read/compute failure resolves to `[]` so a correlation hiccup can never
* block the assessment generation it only decorates.
*/
export async function getRelevantCorrelationsForMetric(
userId: string,
measurementType: MeasurementType,
): Promise<RelevantCorrelation[]> {
const channel = channelKeyForType(measurementType);
if (!channel) return [];
try {
const profile = await prisma.user.findUnique({
where: { id: userId },
select: { timezone: true },
});
const tz = profile?.timezone ?? "Europe/Berlin";
const since = new Date(Date.now() - WINDOW_DAYS * MS_PER_DAY);
// MOOD is mood-entry backed, not a measurement type — strip it from the
// measurement query and source it separately, exactly like the route.
const behaviourTypes = DISCOVERY_BEHAVIOURS.filter(
(k) => k !== "MOOD",
) as MeasurementType[];
const outcomeTypes = DISCOVERY_OUTCOMES.filter(
(k) => k !== "MOOD",
) as MeasurementType[];
const [measurements, moodEntries] = await Promise.all([
prisma.measurement.findMany({
where: {
userId,
deletedAt: null,
measuredAt: { gte: since },
type: { in: [...behaviourTypes, ...outcomeTypes] },
},
orderBy: { measuredAt: "asc" },
take: 20000,
select: { type: true, value: true, measuredAt: true },
}),
prisma.moodEntry.findMany({
where: { userId, deletedAt: null, moodLoggedAt: { gte: since } },
orderBy: { moodLoggedAt: "asc" },
take: 5000,
select: { score: true, moodLoggedAt: true },
}),
]);
const byType = new Map<string, Array<{ value: number; at: Date }>>();
for (const m of measurements) {
const list = byType.get(m.type) ?? [];
list.push({ value: m.value, at: m.measuredAt });
byType.set(m.type, list);
}
const moodDaily = toDailyMeans(
moodEntries.map((e) => ({ value: e.score, at: e.moodLoggedAt })),
tz,
);
const series: NamedSeries[] = [];
for (const key of DISCOVERY_BEHAVIOURS) {
const points =
key === "MOOD" ? moodDaily : toDailyMeans(byType.get(key) ?? [], tz);
series.push({ key, role: "behaviour", points });
}
for (const key of DISCOVERY_OUTCOMES) {
const points =
key === "MOOD" ? moodDaily : toDailyMeans(byType.get(key) ?? [], tz);
series.push({ key, role: "outcome", points });
}
const result = discoverCorrelations(series);
return result.discovered
.filter((d) => d.behaviour === channel || d.outcome === channel)
.map((d) => ({ interpretation: d.interpretation, n: d.n, r: d.r }));
} catch {
return [];
}
}
+38 -1
View File
@@ -36,6 +36,12 @@ import {
formatPreviousContextForPrompt,
getPreviousInsightContext,
} from "@/lib/insights/memory";
import {
buildAssessmentContextBlock,
computeSteadyRun,
pickVarietyLead,
} from "@/lib/insights/assessment-context";
import { getRelevantCorrelationsForMetric } from "@/lib/insights/metric-correlation-context";
import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets";
import { lookupNormalRange } from "@/lib/insights/derived/norms";
import { getNoKeyGeneralStatusText } from "@/lib/insights/no-key-fallbacks";
@@ -315,6 +321,32 @@ export async function generateMetricStatus(args: {
locale,
);
// v1.12.1 — diversity / anti-repetition context, all from already-computed
// data (no new statistics, no new persistence). Grounding is untouched:
// these blocks add a rotating opening angle, an explicit data-strength
// line, a steady-run repetition signal, and the FDR-surviving cross-metric
// correlations that involve THIS metric. The correlation fetch is
// best-effort and decorative — a failure resolves to no block, never a
// generation failure.
const varietyLead = pickVarietyLead(args.userId, meta.id, todayKey);
const steadyRun = computeSteadyRun(graded.weekly, graded.monthly);
const relations = await getRelevantCorrelationsForMetric(
args.userId,
meta.measurementType,
);
const contextBlock = buildAssessmentContextBlock(
{
varietyLead,
dataStrength: {
points: summary?.points ?? series.daily.length,
newestDaysAgo,
},
repeatCount: steadyRun,
relations,
},
locale,
);
const outcome = await runStatusCompletion({
userId: args.userId,
cacheAction,
@@ -326,8 +358,13 @@ export async function generateMetricStatus(args: {
todayKey,
locale,
previousContextBlock,
contextBlock,
),
temperature: 0.3,
// v1.12.1 (D1) — the phrasing task benefits from a touch more sampling
// entropy while the FACTS stay pinned by the snapshot + the
// forbidden-phrase guards. 0.3 was conservative for a 2-4 sentence prose
// task; 0.45 varies cadence without loosening grounding.
temperature: 0.45,
maxTokens: 1000,
});