diff --git a/src/lib/ai/prompts/insight-generator.ts b/src/lib/ai/prompts/insight-generator.ts index 6d6d32dd7..0aa9ae197 100644 --- a/src/lib/ai/prompts/insight-generator.ts +++ b/src/lib/ai/prompts/insight-generator.ts @@ -31,7 +31,7 @@ import { } from "./native-prompts"; /** Stable identifier for the active system prompt revision. */ -export const PROMPT_VERSION = "4.28.0" as const; +export const PROMPT_VERSION = "4.29.0" as const; /** * The scope-hardened insight prompt is one structural skeleton with a @@ -179,24 +179,48 @@ gib die obige Verweigerung zurück.`, sentiment — earn the encouragement with the specific finding. 8. Optional "dailyBriefing" block. When the snapshot carries enough signal (any of bp / weight / pulse / mood / - medications.compliance) emit a top-level "dailyBriefing" object - with two fields: + medications.compliance) emit a top-level "dailyBriefing" object. + This is the user's daily read — it must feel PRESENT-FOCUSED, + warm, and motivating. Lead with NOW; draw on history for context, + but the user opens this to learn what is happening TODAY and what + one thing they can do about it. Fields: - paragraph: an 80-200 word narrative the user reads at the top - of /insights — their warm, motivating daily read. Lead with what - genuinely matters in their numbers today, name an earned win - where the data shows one, and frame anything unfavourable as one - doable opportunity. No diagnosis, no prescription. Use the - user's own data — do NOT extrapolate to "people like you" or - population norms. Earned encouragement only, never a reflexive - compliment; avoid the banned openers from rule 7. - - keyFindings: 0-5 short rows. Each row has tone (one of - "good" | "watch" | "info"), a headline (≤ 60 chars), a - one-sentence detail, an optional delta string (e.g. - "↓ 4 mmHg" or null), a sourceWindow (one of "7d" | "30d" | - "90d" | "1y"; default "30d") and a sourceMetric (one of - "bp" | "weight" | "pulse" | "mood" | "compliance"). Findings - MUST be derived from numbers in the snapshot. Five is the - hard cap — three is a healthier default. + of /insights. Open with the freshest, most salient signal — + what their newest readings are doing against their own recent + baseline — not a summary of the last quarter. Name an earned + win where the data shows one, and turn anything unfavourable + into ONE doable, specific next step the user can act on this + week ("you're ~1h short on sleep this week — aim for an earlier + night", "your systolic is creeping up — a quieter evening and + an earlier reading tomorrow would tell us a lot"). No + diagnosis, no prescription. Use the user's OWN data — never + "people like you" or population norms. Earned encouragement + only; avoid the banned openers from rule 7. + - signalsOfDay: 0-3 present-focused signals — the lead of the + briefing. PREFER the snapshot's "signalsOfDay" block when it is + present: each entry already carries the comparison finished for + you (latest, deltaVs7, deltaVs30, spread30, outsideNormalSwing, + emergingTrend, recentAnomaly), so STATE those numbers, do not + re-derive them. Each signal row has tone ("good" | "watch" | + "info"), a present-tense headline (≤ 60 chars, e.g. "Resting + heart rate is up this week"), a "nudge" (one concrete, doable + action tied to the signal — never a prescription), a sourceMetric + (same enum as keyFindings), and an optional delta string (e.g. + "+6 mmHg vs your 30-day average" or null). Emit a signal only + when the snapshot supports it; three is the hard cap and the + briefing is stronger with one or two sharp signals than three + weak ones. When "signalsOfDay" is absent or flat, omit the field + (or set null) — do NOT manufacture a signal from a quiet metric. + - keyFindings: 0-5 short rows — the longer-horizon trend list + below the signals. Each row has tone ("good" | "watch" | + "info"), a headline (≤ 60 chars), a one-sentence detail, an + optional delta string (e.g. "↓ 4 mmHg" or null), a sourceWindow + ("7d" | "30d" | "90d" | "1y"; default "30d") and a sourceMetric + ("bp" | "weight" | "pulse" | "mood" | "compliance"). Findings + MUST be derived from numbers in the snapshot. Five is the hard + cap — three is a healthier default. Do not repeat a signalsOfDay + entry verbatim as a keyFinding; the two surfaces complement + (now vs trend), they do not duplicate. When the snapshot has no analysable data, omit "dailyBriefing" or set it to null. Empty paragraph or filler-only findings are rejected by the parser. @@ -343,25 +367,48 @@ gib die obige Verweigerung zurück.`, 8. Optionaler "dailyBriefing"-Block. Wenn der Snapshot genügend Signal trägt (irgendwas aus bp / weight / pulse / mood / medications.compliance), emittiere ein Top-Level-Objekt - "dailyBriefing" mit zwei Feldern: + "dailyBriefing". Das ist der Tages-Überblick des Nutzers — er muss + sich GEGENWARTSBEZOGEN, warm und motivierend anfühlen. Führe mit dem + JETZT; nutze die Historie als Kontext, aber der Nutzer öffnet das, um + zu erfahren, was HEUTE passiert und welche eine Sache er dagegen tun + kann. Felder: - paragraph: ein 80-200 Wörter langer Fließtext, den der Nutzer - oben auf /insights liest — sein warmer, motivierender Tages- - Überblick. Führe mit dem, was in seinen Zahlen heute wirklich - zählt, benenne einen verdienten Erfolg, wo die Daten ihn zeigen, - und rahme Ungünstiges als eine machbare Chance. Keine Diagnose, - keine Verschreibung. Nutze die eigenen Daten des Nutzers — - extrapoliere NICHT auf "Menschen wie Sie" oder - Bevölkerungsnormen. Nur verdiente Ermutigung, nie ein reflexhaftes - Kompliment; verwende keine in Regel 7 verbotenen Eröffnungen. - - keyFindings: 0-5 kurze Zeilen. Jede Zeile hat tone (eines aus - "good" | "watch" | "info"), eine headline (≤ 60 Zeichen), - ein detail im Einzelsatz, einen optionalen delta-String - (z.B. "↓ 4 mmHg" oder null), ein sourceWindow (eines aus - "7d" | "30d" | "90d" | "1y"; Standard "30d") und ein - sourceMetric (eines aus "bp" | "weight" | "pulse" | "mood" | - "compliance"). Findings MÜSSEN aus Zahlen im Snapshot - abgeleitet sein. Fünf ist die harte Obergrenze — drei ist - der gesündere Standardwert. + oben auf /insights liest. Eröffne mit dem frischesten, wichtigsten + Signal — was seine neuesten Werte gegenüber seiner eigenen + jüngsten Baseline tun — nicht mit einer Zusammenfassung des letzten + Quartals. Benenne einen verdienten Erfolg, wo die Daten ihn zeigen, + und mach aus Ungünstigem EINEN machbaren, konkreten nächsten + Schritt für diese Woche ("dir fehlt diese Woche ~1h Schlaf — geh + früher ins Bett", "dein systolischer Wert steigt — ein ruhigerer + Abend und eine frühe Messung morgen würden viel sagen"). Keine + Diagnose, keine Verschreibung. Nutze die EIGENEN Daten des Nutzers + — nie "Menschen wie Sie" oder Bevölkerungsnormen. Nur verdiente + Ermutigung; verwende keine in Regel 7 verbotenen Eröffnungen. + - signalsOfDay: 0-3 gegenwartsbezogene Signale — der Aufmacher des + Briefings. BEVORZUGE den "signalsOfDay"-Block des Snapshots, wenn + er vorhanden ist: jeder Eintrag trägt den Vergleich bereits fertig + (latest, deltaVs7, deltaVs30, spread30, outsideNormalSwing, + emergingTrend, recentAnomaly) — NENNE diese Zahlen, leite sie nicht + neu ab. Jede Signal-Zeile hat tone ("good" | "watch" | "info"), + eine headline im Präsens (≤ 60 Zeichen, z.B. "Ruhepuls ist diese + Woche erhöht"), einen "nudge" (eine konkrete, machbare Aktion zum + Signal — nie eine Verschreibung), ein sourceMetric (gleiches Enum + wie keyFindings) und einen optionalen delta-String (z.B. "+6 mmHg + vs. dein 30-Tage-Mittel" oder null). Emittiere ein Signal nur, wenn + der Snapshot es stützt; drei ist die harte Obergrenze, und ein bis + zwei scharfe Signale sind stärker als drei schwache. Fehlt + "signalsOfDay" oder ist flach, lass das Feld weg (oder setze null) + — erfinde KEIN Signal aus einer ruhigen Metrik. + - keyFindings: 0-5 kurze Zeilen — die Trend-Liste mit längerem + Horizont unter den Signalen. Jede Zeile hat tone ("good" | "watch" + | "info"), eine headline (≤ 60 Zeichen), ein detail im Einzelsatz, + einen optionalen delta-String (z.B. "↓ 4 mmHg" oder null), ein + sourceWindow ("7d" | "30d" | "90d" | "1y"; Standard "30d") und ein + sourceMetric ("bp" | "weight" | "pulse" | "mood" | "compliance"). + Findings MÜSSEN aus Zahlen im Snapshot abgeleitet sein. Fünf ist + die harte Obergrenze — drei der gesündere Standardwert. Wiederhole + kein signalsOfDay als keyFinding wortgleich; die beiden Flächen + ergänzen sich (Jetzt vs. Trend), sie duplizieren nicht. Hat der Snapshot keine analysierbaren Daten, lass "dailyBriefing" weg oder setze es auf null. Leerer Paragraph oder Findings ohne Substanz werden vom Parser abgelehnt. @@ -544,7 +591,16 @@ Du MUSST JSON exakt nach diesem Schema liefern:`, } ], "dailyBriefing": { - "paragraph": "80-200 word narrative grounded in this snapshot's numbers", + "paragraph": "80-200 word PRESENT-focused narrative leading with today's signal, grounded in this snapshot's numbers", + "signalsOfDay": [ + { + "sourceMetric": "bp | weight | pulse | mood | compliance | hrv | sleep | resting_hr | steps | active_energy | flights | distance | vo2_max | body_temp", + "tone": "good | watch | info", + "headline": "present-tense ≤60 char headline of what is happening now", + "nudge": "one concrete, doable action tied to the signal", + "delta": "optional delta string (e.g. '+6 mmHg vs your 30-day average') or null" + } + ], "keyFindings": [ { "tone": "good | watch | info", @@ -610,7 +666,16 @@ Du MUSST JSON exakt nach diesem Schema liefern:`, } ], "dailyBriefing": { - "paragraph": "80-200 Wörter Fließtext, geerdet in den Zahlen dieses Snapshots", + "paragraph": "80-200 Wörter GEGENWARTSBEZOGENER Fließtext, der mit dem heutigen Signal eröffnet, geerdet in den Zahlen dieses Snapshots", + "signalsOfDay": [ + { + "sourceMetric": "bp | weight | pulse | mood | compliance | hrv | sleep | resting_hr | steps | active_energy | flights | distance | vo2_max | body_temp", + "tone": "good | watch | info", + "headline": "Präsens-Headline (≤60 Zeichen), was gerade jetzt passiert", + "nudge": "eine konkrete, machbare Aktion zum Signal", + "delta": "optionaler Delta-String (z.B. '+6 mmHg vs. dein 30-Tage-Mittel') oder null" + } + ], "keyFindings": [ { "tone": "good | watch | info", diff --git a/src/lib/ai/schema.ts b/src/lib/ai/schema.ts index 6a6e3f157..7a107c407 100644 --- a/src/lib/ai/schema.ts +++ b/src/lib/ai/schema.ts @@ -291,6 +291,45 @@ export type DailyBriefingKeyFinding = z.infer< typeof dailyBriefingKeyFindingSchema >; +/** + * v1.18.7 — "Signals of the day". + * + * The present-focused lead of the rebuilt daily briefing. Each signal is a + * short, NOW-anchored read of one metric — what the freshest reading is doing + * against the user's own recent baseline — paired with a single concrete, + * doable nudge ("you're ~1h short on sleep this week — aim for an earlier + * night"). The deterministic `features.signalsOfDay` block hands the model + * the comparison already finished (delta, normal-swing verdict, emerging + * trend, recent anomaly), so the prose states it rather than re-deriving it. + * + * Hard cap of 3 so the briefing leads with what actually moved today and the + * card stays a glance, not a wall. The block is optional + nullable so cached + * payloads from before v1.18.7 round-trip without forcing a regenerate. + */ +export const dailyBriefingSignalSchema = z.object({ + /** Metric the signal is drawn from — same discriminator as keyFindings. */ + sourceMetric: dailyBriefingKeyFindingSchema.shape.sourceMetric, + /** + * Tone drives the accent colour, mirroring the keyFinding ladder: + * - "good" — moving the favourable way / target held + * - "watch" — a deviation worth a gentle nudge + * - "info" — a neutral present-tense observation + */ + tone: z.enum(["good", "watch", "info"]), + /** Present-tense headline — what is happening NOW (the user reads first). */ + headline: z.string().min(1, "signal.headline required"), + /** + * One concrete, doable nudge tied to the signal. Empty rejected so a + * signal always carries an action the user can take, not just an + * observation. Stays inside the data boundary — never a prescription. + */ + nudge: z.string().min(1, "signal.nudge required"), + /** Optional delta string — e.g. "+6 mmHg vs your 30-day average". */ + delta: z.string().nullable(), +}); + +export type DailyBriefingSignal = z.infer; + export const dailyBriefingSchema = z.object({ /** * Narrative paragraph — ~80-200 words. Synthesised from the day's @@ -298,6 +337,17 @@ export const dailyBriefingSchema = z.object({ * strings are rejected so the briefing card never paints a void. */ paragraph: z.string().min(1, "dailyBriefing.paragraph required"), + /** + * v1.18.7 — 0-3 present-focused "Signals of the day". The rebuilt + * briefing leads with these; `keyFindings` stays the longer-horizon + * trend list below. Optional + nullable so pre-v1.18.7 caches round-trip. + */ + signalsOfDay: z + .array(dailyBriefingSignalSchema) + .min(0) + .max(3) + .nullable() + .optional(), /** * 0-5 key findings. Empty array is acceptable when the data is * truly flat; the hero strip simply hides the row in that case. diff --git a/src/lib/insights/features.ts b/src/lib/insights/features.ts index b24dc91c1..d9b4626fc 100644 --- a/src/lib/insights/features.ts +++ b/src/lib/insights/features.ts @@ -4,7 +4,7 @@ * No raw timestamps or exact values are sent in aggregated mode. */ import { prisma } from "@/lib/db"; -import { summarize } from "@/lib/analytics/trends"; +import { summarize, trendSlope } from "@/lib/analytics/trends"; import type { DataPoint } from "@/lib/analytics/trends"; import { buildComplianceMedicationContext, @@ -184,6 +184,64 @@ export interface AggregatedFeatures { ageYears: number | null; gender: string | null; }; + /** + * v1.18.7 — "Signals of the day": the present-focused read the daily + * briefing leads with. Each entry compares the freshest reading against + * the user's own trailing-7d / 30d windows, flags an emerging slope, and + * surfaces a recent anomaly — ranked by clinical priority, ≤5 entries. + * + * Recomputed every day: the freshest reading, the 7/30d windows, and the + * recency move daily, so a flat 30/90d mean no longer pins the briefing. + * The block lands inside the compacted features payload, so it also seeds + * the content-hash gate (`hashInsightSnapshot`) — a fresh daily signal + * forces the briefing to regenerate. See `computeSignalsOfDay`. + */ + signalsOfDay?: SignalOfDay[]; +} + +/** + * v1.18.7 — one present-focused signal feeding the daily briefing. Every + * numeric field is pre-computed so the model states it rather than + * re-deriving the comparison (which small LLMs do unreliably). + */ +export interface SignalOfDay { + /** Briefing `sourceMetric` discriminator the UI pins an icon + route on. */ + metric: + | "bp" + | "weight" + | "pulse" + | "mood" + | "sleep" + | "resting_hr" + | "steps"; + /** Natural-language label (no enum leak into prose). */ + label: string; + /** Unit string when the metric carries one. */ + unit?: string; + /** Freshest reading (the "now" value the briefing leads with). */ + latest: number; + /** Days since the freshest reading. */ + latestDaysAgo: number; + /** Trailing-7d mean. */ + avg7: number | null; + /** Trailing-30d mean. */ + avg30: number | null; + /** Signed `latest − avg7`, pre-computed. */ + deltaVs7: number | null; + /** Signed `latest − avg30`, pre-computed. */ + deltaVs30: number | null; + /** Normal-swing SD over the trailing-30d window. */ + spread30: number | null; + /** `|latest − avg30| > spread30` — the significance verdict as a boolean. */ + outsideNormalSwing: boolean; + /** Emerging direction over the trailing 30 days (slope sign). */ + emergingTrend: "rising" | "falling" | "flat" | null; + /** A peak / trough inside the last 14 days, when one stands out. */ + recentAnomaly: { + kind: "peak" | "trough"; + value: number; + daysAgo: number; + } | null; } /** @@ -359,6 +417,159 @@ function computeHistoricalComparison( return { current7dAvg, previous30dAvg, change }; } +/** + * v1.18.7 — clinical priority order for the signals block. BP / glucose + * lead, then resting HR / pulse, then weight, then mood / sleep / activity. + * Lower index = higher priority; the briefing surfaces the top ≤5. + */ +const SIGNAL_PRIORITY: SignalOfDay["metric"][] = [ + "bp", + "resting_hr", + "pulse", + "weight", + "sleep", + "mood", + "steps", +]; + +/** Round helper local to the signals builder. */ +function r2(v: number): number { + return Math.round(v * 100) / 100; +} + +/** + * Build one signal from a single metric's measurement records. Returns null + * when there is no fresh reading or fewer than three points in 30 days — a + * sparse metric cannot carry an honest "today vs your normal" read. + */ +function buildSignal( + metric: SignalOfDay["metric"], + label: string, + records: Array<{ value: number; measuredAt: Date }>, + now: number, + unit?: string, +): SignalOfDay | null { + if (records.length === 0) return null; + const newest = records[records.length - 1]; + const latestDaysAgo = Math.round( + (now - newest.measuredAt.getTime()) / (24 * 60 * 60 * 1000), + ); + // A reading older than two weeks is not a "signal of the day". + if (latestDaysAgo > 14) return null; + + const win30 = records.filter( + (rec) => rec.measuredAt.getTime() >= now - 30 * 24 * 60 * 60 * 1000, + ); + if (win30.length < 3) return null; + + const avg7 = avgInWindow(records, now, 7); + const avg30 = avgInWindow(records, now, 30); + const spread30 = stdDev(win30.map((rec) => rec.value)); + const deltaVs7 = avg7 !== null ? r2(newest.value - avg7) : null; + const deltaVs30 = avg30 !== null ? r2(newest.value - avg30) : null; + const outsideNormalSwing = + avg30 !== null && spread30 !== null && spread30 > 0 + ? Math.abs(newest.value - avg30) > spread30 + : false; + + const slope = trendSlope(toDataPoints(records), 30); + const emergingTrend: SignalOfDay["emergingTrend"] = slope + ? slope.direction === "up" + ? "rising" + : slope.direction === "down" + ? "falling" + : "flat" + : null; + + // Recent anomaly: an extreme inside the last 14 days vs the 30d mean ± 2 SD. + let recentAnomaly: SignalOfDay["recentAnomaly"] = null; + if (avg30 !== null && spread30 !== null && spread30 > 0) { + const recent = records.filter( + (rec) => rec.measuredAt.getTime() >= now - 14 * 24 * 60 * 60 * 1000, + ); + for (const rec of recent) { + const sd = (rec.value - avg30) / spread30; + if (Math.abs(sd) >= 2) { + const daysAgo = Math.round( + (now - rec.measuredAt.getTime()) / (24 * 60 * 60 * 1000), + ); + const candidate = { + kind: (sd > 0 ? "peak" : "trough") as "peak" | "trough", + value: r2(rec.value), + daysAgo, + }; + // Keep the single most extreme anomaly in the window. + if ( + recentAnomaly === null || + Math.abs(rec.value - avg30) > + Math.abs(recentAnomaly.value - avg30) + ) { + recentAnomaly = candidate; + } + } + } + } + + return { + metric, + label, + ...(unit ? { unit } : {}), + latest: r2(newest.value), + latestDaysAgo, + avg7, + avg30, + deltaVs7, + deltaVs30, + spread30, + outsideNormalSwing, + emergingTrend, + recentAnomaly, + }; +} + +/** + * v1.18.7 — assemble the present-focused "Signals of the day" block from the + * in-memory measurement set (no extra DB round-trip). Computes today-vs-7d/30d + * deltas, an emerging slope, and a recent anomaly per salient metric, then + * returns the top ≤5 ranked by clinical priority. Salient signals + * (outside-normal-swing or a recent anomaly) bubble above quiet ones inside + * each priority tier so the briefing leads with what actually moved. + */ +function computeSignalsOfDay( + byType: (type: string) => Array<{ value: number; measuredAt: Date }>, + now: number, +): SignalOfDay[] { + const candidates: SignalOfDay[] = []; + const push = (s: SignalOfDay | null) => { + if (s) candidates.push(s); + }; + + // Systolic carries the BP signal (the headline number clinicians read first). + push(buildSignal("bp", "blood pressure (systolic)", byType("BLOOD_PRESSURE_SYS"), now, "mmHg")); + push(buildSignal("resting_hr", "resting heart rate", byType("RESTING_HEART_RATE"), now, "bpm")); + push(buildSignal("pulse", "pulse", byType("PULSE"), now, "bpm")); + push(buildSignal("weight", "weight", byType("WEIGHT"), now, "kg")); + // Sleep is stored one row per stage per night, so a raw "latest" point + // would mis-sum; the sleep aggregates carry that signal already. Steps are + // daily totals — a clean per-day point. + push(buildSignal("steps", "daily steps", byType("ACTIVITY_STEPS"), now)); + + const priorityIndex = (m: SignalOfDay["metric"]) => { + const idx = SIGNAL_PRIORITY.indexOf(m); + return idx === -1 ? SIGNAL_PRIORITY.length : idx; + }; + const salience = (s: SignalOfDay) => + (s.outsideNormalSwing ? 2 : 0) + (s.recentAnomaly ? 1 : 0); + + return candidates + .sort((a, b) => { + const sal = salience(b) - salience(a); + if (sal !== 0) return sal; + return priorityIndex(a.metric) - priorityIndex(b.metric); + }) + .slice(0, 5); +} + export async function extractFeatures( userId: string, includeRaw: boolean, @@ -999,6 +1210,15 @@ export async function extractFeatures( }); } + // v1.18.7 — present-focused "Signals of the day". Computed from the + // in-memory measurement set (no extra DB round-trip). Lands inside the + // compacted features payload, so it feeds both the briefing prompt AND the + // content-hash gate — a fresh daily signal forces the briefing to refresh. + const signalsOfDay = computeSignalsOfDay(byType, now); + if (signalsOfDay.length > 0) { + features.signalsOfDay = signalsOfDay; + } + // v1.4.36 W3 T1 — "raw" mode no longer dumps every measurement row // into the prompt. Instead we attach DAY / WEEK / MONTH bucket means // from `measurement_rollups` so the model gets the same temporal