feat(insights): signals-of-the-day briefing and fix the stale-briefing gate

Compute a present-focused "signals of the day" block in extractFeatures from
the in-memory measurement set (no extra query): the freshest reading vs the
user's own 7d/30d windows, the normal-swing verdict, an emerging slope, and a
recent anomaly per salient metric, ranked by clinical priority and capped at
five. Add a signalsOfDay array to the dailyBriefing schema feeding the
briefing alongside the existing keyFindings cap, and rewrite the briefing
prompt (EN + DE) to lead with NOW: a warmer, motivating read that surfaces the
emerging signal and gives one concrete, doable nudge while still drawing on the
tiered history.

The signals block lands inside the compacted features payload, so it also
seeds insightsSnapshotHash — a fresh daily signal now changes the fingerprint
and forces the daily 04:30 warm to regenerate instead of re-stamping a
days-old narrative. Bump PROMPT_VERSION to 4.29.0.
This commit is contained in:
Marc-André Bombeck committed 2026-06-19 19:36:51 +02:00
1 parent 775d624228
commit 0b01bfb483
3 files changed
+374 -39

No files matched your search

+103 -38
View File
@@ -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",
+50
View File
@@ -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<typeof dailyBriefingSignalSchema>;
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.
+221 -1
View File
@@ -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