From 05cff531e48f8c80d65850890890cbe7b718b26a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 5 Jun 2026 03:18:39 +0200 Subject: [PATCH] feat(insights): add relational context, repetition-awareness, and controlled variety to assessments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) (cherry picked from commit c9d04cab1523e0e57a3f50c87c9f88684b01b26f) --- .../assessment-prompt-contract.test.ts | 128 ++++++++ src/lib/ai/prompts/base-system.ts | 18 +- src/lib/ai/prompts/metric-archetypes.ts | 14 +- .../__tests__/assessment-context.test.ts | 208 +++++++++++++ .../metric-correlation-context.test.ts | 73 +++++ .../insights/__tests__/metric-status.test.ts | 69 +++++ src/lib/insights/assessment-context.ts | 283 ++++++++++++++++++ .../insights/metric-correlation-context.ts | 165 ++++++++++ src/lib/insights/metric-status.ts | 39 ++- 9 files changed, 990 insertions(+), 7 deletions(-) create mode 100644 src/lib/ai/prompts/__tests__/assessment-prompt-contract.test.ts create mode 100644 src/lib/insights/__tests__/assessment-context.test.ts create mode 100644 src/lib/insights/__tests__/metric-correlation-context.test.ts create mode 100644 src/lib/insights/assessment-context.ts create mode 100644 src/lib/insights/metric-correlation-context.ts diff --git a/src/lib/ai/prompts/__tests__/assessment-prompt-contract.test.ts b/src/lib/ai/prompts/__tests__/assessment-prompt-contract.test.ts new file mode 100644 index 000000000..8d0271b2c --- /dev/null +++ b/src/lib/ai/prompts/__tests__/assessment-prompt-contract.test.ts @@ -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"); + }); +}); diff --git a/src/lib/ai/prompts/base-system.ts b/src/lib/ai/prompts/base-system.ts index f96f225e8..ccea377da 100644 --- a/src/lib/ai/prompts/base-system.ts +++ b/src/lib/ai/prompts/base-system.ts @@ -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": "..." }`; diff --git a/src/lib/ai/prompts/metric-archetypes.ts b/src/lib/ai/prompts/metric-archetypes.ts index ef026b622..8ab256981 100644 --- a/src/lib/ai/prompts/metric-archetypes.ts +++ b/src/lib/ai/prompts/metric-archetypes.ts @@ -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}`; } diff --git a/src/lib/insights/__tests__/assessment-context.test.ts b/src/lib/insights/__tests__/assessment-context.test.ts new file mode 100644 index 000000000..ea7711560 --- /dev/null +++ b/src/lib/insights/__tests__/assessment-context.test.ts @@ -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"); + }); +}); diff --git a/src/lib/insights/__tests__/metric-correlation-context.test.ts b/src/lib/insights/__tests__/metric-correlation-context.test.ts new file mode 100644 index 000000000..465d7dd4e --- /dev/null +++ b/src/lib/insights/__tests__/metric-correlation-context.test.ts @@ -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([]); + }); +}); diff --git a/src/lib/insights/__tests__/metric-status.test.ts b/src/lib/insights/__tests__/metric-status.test.ts index 769b2ae13..1fc2c4ccd 100644 --- a/src/lib/insights/__tests__/metric-status.test.ts +++ b/src/lib/insights/__tests__/metric-status.test.ts @@ -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 () => { diff --git a/src/lib/insights/assessment-context.ts b/src/lib/insights/assessment-context.ts new file mode 100644 index 000000000..e950704ee --- /dev/null +++ b/src/lib/insights/assessment-context.ts @@ -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"); +} diff --git a/src/lib/insights/metric-correlation-context.ts b/src/lib/insights/metric-correlation-context.ts new file mode 100644 index 000000000..4eb43ee37 --- /dev/null +++ b/src/lib/insights/metric-correlation-context.ts @@ -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(); + 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 { + 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>(); + 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 []; + } +} diff --git a/src/lib/insights/metric-status.ts b/src/lib/insights/metric-status.ts index d0b94bf5c..2fb488d0b 100644 --- a/src/lib/insights/metric-status.ts +++ b/src/lib/insights/metric-status.ts @@ -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, });