mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
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:
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");
|
||||
});
|
||||
});
|
||||
@@ -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": "..." }`;
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user