diff --git a/src/components/insights/daily-briefing.tsx b/src/components/insights/daily-briefing.tsx
index d56baf4ac..92d06564c 100644
--- a/src/components/insights/daily-briefing.tsx
+++ b/src/components/insights/daily-briefing.tsx
@@ -123,6 +123,20 @@ interface DailyBriefingProps {
* comparison toggle migrates here from the hero in commit 5.
*/
metaSlot?: React.ReactNode;
+ /**
+ * v1.21.2 (A4) — server-resolved callback + forward-look. The briefing
+ * recalls the prior period and points ahead ("as I noted Monday, your sleep
+ * was short; it's recovered since — worth watching tonight"). Both strings
+ * are already localised and figure-anchored by the resolver; the card only
+ * renders them. One callback per surface — absent (the common case) when no
+ * prior narrative recall is on file.
+ */
+ memory?: {
+ /** The recall of the prior-period read, anchored to a figure. */
+ recall: string;
+ /** The forward-look pointing ahead. */
+ forward: string;
+ } | null;
}
const METRIC_ICON: Record<
@@ -335,6 +349,7 @@ export function DailyBriefing({
noProvider = false,
noProviderStale = false,
metaSlot,
+ memory = null,
}: DailyBriefingProps) {
const { t } = useTranslations();
@@ -374,6 +389,33 @@ export function DailyBriefing({
// looser `space-y-4`, which read as taller than its neighbours on
// the overview. One token, no new spacing scale.
+ {/* v1.21.2 (A4) — callback + forward-look. Recalls the prior
+ period and points ahead, both server-resolved and
+ figure-anchored. Leads the card so the briefing opens by
+ closing the loop on what it noted last, then looks forward. */}
+ {memory && (
+
+
+ {t("insights.briefing.memory.recall", {
+ text: memory.recall,
+ })}
+
+
+ {t("insights.briefing.memory.forward", {
+ text: memory.forward,
+ })}
+
+
+ )}
{/* v1.4.27 B1 — the leading narrative paragraph dropped.
The hero strip subtitle on `/insights` already renders
the same `briefing.paragraph` text directly above this
diff --git a/src/components/insights/health-score-card.tsx b/src/components/insights/health-score-card.tsx
index 1c92e616e..646ef15bc 100644
--- a/src/components/insights/health-score-card.tsx
+++ b/src/components/insights/health-score-card.tsx
@@ -1,7 +1,15 @@
"use client";
import { useId, useMemo, useState } from "react";
-import { ArrowDown, ArrowUp, ChevronDown, Minus, Moon } from "lucide-react";
+import {
+ ArrowDown,
+ ArrowUp,
+ ChevronDown,
+ CornerDownLeft,
+ Minus,
+ Moon,
+ Scale,
+} from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { HealthScoreDeltaExplainer } from "./health-score-delta-explainer";
@@ -96,6 +104,34 @@ export interface HealthScoreCardProps {
* active, not the episode details. Default off (the common case).
*/
restModeActive?: boolean;
+ /**
+ * v1.21.2 (A5) — Tension Verdict line. The server resolves the honest
+ * "internal read" when the readiness/recovery composite's contributors
+ * DISAGREE (good sleep but a rising resting pulse, …). The card renders it
+ * as one short line: the favourable contributor, the unfavourable one, and
+ * the band the composite lands on. Purely DTO-rendered — the card never
+ * recomputes the disagreement. Absent (the common case) when the
+ * contributors agree.
+ */
+ tension?: {
+ band: HealthScoreBand;
+ /** Favourable contributors, already localised by the resolver. */
+ positive: string[];
+ /** Unfavourable contributors, already localised by the resolver. */
+ negative: string[];
+ } | null;
+ /**
+ * v1.21.2 (A6) — return-to-baseline line. Present only when a metric has
+ * come BACK inside the user's own usual range after a prior out-of-band run.
+ * Actively closes a worry ("back inside your usual range after last week's
+ * dip"). Server-resolved DTO; the label is already localised.
+ */
+ returnToBand?: {
+ /** The metric's display label, already localised by the resolver. */
+ metricLabel: string;
+ /** Days the metric has now sat back inside its range. */
+ daysInside: number;
+ } | null;
}
type ComponentKey = keyof HealthScoreCardProps["components"];
@@ -178,6 +214,8 @@ export function HealthScoreCard({
initiallyExpanded = false,
moodEnabled = true,
restModeActive = false,
+ tension = null,
+ returnToBand = null,
}: HealthScoreCardProps) {
const { t, locale } = useTranslations();
// The asOf timestamps render under the source pill as a tooltip
@@ -201,6 +239,13 @@ export function HealthScoreCard({
if (Number.isNaN(parsed.getTime())) return null;
return asOfFormatter.format(parsed);
};
+ // v1.21.2 (A5) — locale-aware conjunction list for the Tension Verdict's
+ // favourable / unfavourable contributor lists ("sleep and mood" / "resting
+ // heart rate, HRV balance, and mood"). Memoised per locale.
+ const listFormatter = useMemo(
+ () => new Intl.ListFormat(locale, { style: "long", type: "conjunction" }),
+ [locale],
+ );
const [expanded, setExpanded] = useState(initiallyExpanded);
// `useId` keeps the aria-controls/section-id pair unique even when
// the card is mounted twice on the same page (lg+ hero strip vs
@@ -418,6 +463,51 @@ export function HealthScoreCard({
)}
+ {/* v1.21.2 (A5) — Tension Verdict. The honest "internal read" when the
+ composite's contributors disagree, resolved server-side and rendered
+ as one short line. The card never recomputes the disagreement; it
+ only renders the resolved favourable / unfavourable contributors and
+ the band the composite lands on. */}
+ {tension &&
+ tension.positive.length > 0 &&
+ tension.negative.length > 0 && (
+
+
+
+ {t("insights.tension.label", {
+ positive: listFormatter.format(tension.positive),
+ negative: listFormatter.format(tension.negative),
+ band: t(`insights.tension.contributor.${tension.band}`),
+ })}
+
+
+ )}
+
+ {/* v1.21.2 (A6) — return-to-baseline. Present only when a metric came
+ back inside the user's own usual range after a prior out-of-band run.
+ Actively closes a worry rather than reporting a number. */}
+ {returnToBand && (
+
+
+
+ {t("insights.streak.returnLabel", {
+ metric: returnToBand.metricLabel,
+ count: returnToBand.daysInside,
+ })}
+
+
+ )}
+
({
import { computeDerivedMetric } from "@/lib/insights/derived";
import { prisma } from "@/lib/db";
-import { buildDerivedSnapshotBlock } from "../derived-snapshot";
+import {
+ buildDerivedSnapshotBlock,
+ deriveTension,
+ TENSION_HIGH_SCORE,
+ TENSION_LOW_SCORE,
+} from "../derived-snapshot";
const compute = computeDerivedMetric as unknown as ReturnType;
const whoopFindFirst = prisma.measurement.findFirst as unknown as ReturnType<
@@ -166,4 +171,136 @@ describe("buildDerivedSnapshotBlock", () => {
},
});
});
+
+ it("attaches a TENSION flag when readiness contributors disagree", async () => {
+ // Good sleep (high) but a suppressed HRV-balance + elevated resting pulse
+ // (both low) — a genuine internal disagreement the band hides.
+ compute.mockImplementation(async (args?: { metric?: string }) => {
+ if (args?.metric === "READINESS")
+ return ok({
+ score: 58,
+ band: "yellow",
+ components: [
+ { key: "sleep", value: 90, weight: 0.3 },
+ { key: "hrv", value: 30, weight: 0.3 },
+ { key: "rhr", value: 35, weight: 0.3 },
+ { key: "mood", value: 60, weight: 0.1 },
+ ],
+ });
+ return insufficient;
+ });
+ const block = await buildDerivedSnapshotBlock("u1", PROFILE, NOW);
+ expect(block!.TENSION).toBeDefined();
+ expect(block!.TENSION!.fired).toBe(true);
+ expect(block!.TENSION!.band).toBe("yellow");
+ expect(block!.TENSION!.positive).toEqual(["sleep"]);
+ expect(block!.TENSION!.negative).toEqual([
+ "HRV balance",
+ "resting heart rate",
+ ]);
+ // No coincident flag fired (DB read fails-soft to null), so no red-flag.
+ expect(block!.TENSION!.clinicalOverride).toBe(false);
+ });
+
+ it("omits TENSION when the contributors agree", async () => {
+ compute.mockImplementation(async (args?: { metric?: string }) => {
+ if (args?.metric === "READINESS")
+ return ok({
+ score: 82,
+ band: "green",
+ components: [
+ { key: "sleep", value: 90, weight: 0.3 },
+ { key: "hrv", value: 85, weight: 0.3 },
+ { key: "rhr", value: 80, weight: 0.3 },
+ ],
+ });
+ return insufficient;
+ });
+ const block = await buildDerivedSnapshotBlock("u1", PROFILE, NOW);
+ expect(block!.READINESS).toBeDefined();
+ expect(block!.TENSION).toBeUndefined();
+ });
+});
+
+describe("deriveTension (pure)", () => {
+ it("fires only when at least one contributor sits on each side", () => {
+ const fired = deriveTension(
+ [
+ { key: "sleep", value: TENSION_HIGH_SCORE },
+ { key: "rhr", value: TENSION_LOW_SCORE },
+ ],
+ "yellow",
+ false,
+ );
+ expect(fired).not.toBeNull();
+ expect(fired!.positive).toEqual(["sleep"]);
+ expect(fired!.negative).toEqual(["resting heart rate"]);
+ });
+
+ it("returns null when every contributor is favourable", () => {
+ expect(
+ deriveTension(
+ [
+ { key: "sleep", value: 90 },
+ { key: "hrv", value: 85 },
+ ],
+ "green",
+ false,
+ ),
+ ).toBeNull();
+ });
+
+ it("returns null when every contributor is unfavourable", () => {
+ expect(
+ deriveTension(
+ [
+ { key: "sleep", value: 20 },
+ { key: "hrv", value: 30 },
+ ],
+ "red",
+ false,
+ ),
+ ).toBeNull();
+ });
+
+ it("ignores neutral-band contributors (neither side)", () => {
+ // A mid-range contributor (between the low and high cuts) is neither
+ // favourable nor unfavourable, so on its own it cannot make a tension.
+ const mid = Math.floor((TENSION_HIGH_SCORE + TENSION_LOW_SCORE) / 2);
+ expect(
+ deriveTension(
+ [
+ { key: "sleep", value: mid },
+ { key: "hrv", value: mid },
+ ],
+ "yellow",
+ false,
+ ),
+ ).toBeNull();
+ });
+
+ it("ignores null (absent) contributors", () => {
+ expect(
+ deriveTension(
+ [
+ { key: "sleep", value: 90 },
+ { key: "rhr", value: null },
+ ],
+ "yellow",
+ false,
+ ),
+ ).toBeNull();
+ });
+
+ it("carries the clinical-override bit through unchanged", () => {
+ const fired = deriveTension(
+ [
+ { key: "sleep", value: 90 },
+ { key: "rhr", value: 20 },
+ ],
+ "red",
+ true,
+ );
+ expect(fired!.clinicalOverride).toBe(true);
+ });
});
diff --git a/src/lib/ai/coach/__tests__/system-prompt.test.ts b/src/lib/ai/coach/__tests__/system-prompt.test.ts
index b9f887683..5231463db 100644
--- a/src/lib/ai/coach/__tests__/system-prompt.test.ts
+++ b/src/lib/ai/coach/__tests__/system-prompt.test.ts
@@ -118,6 +118,28 @@ describe("getCoachSystemPrompt — EN", () => {
expect(prompt).toMatch(/0–21/);
expect(prompt).toMatch(/0–100/);
});
+
+ it("carries the v1.21.2 narrative-presence clauses (A4/A5/A6/A7)", () => {
+ // A4 — briefing-style callback + forward-look from the memory block.
+ expect(prompt).toMatch(/17\. Recall \+ look ahead/);
+ expect(prompt).toMatch(/priorNarrative/);
+ expect(prompt).toMatch(/trendMemory/);
+ // A5 — Tension Verdict with the clinical-floors override.
+ expect(prompt).toMatch(/18\. Tension Verdict/);
+ expect(prompt).toMatch(/"TENSION" flag/);
+ expect(prompt).toMatch(/CLINICAL-FLOORS OVERRIDE/);
+ expect(prompt).toMatch(/clinicalOverride/);
+ expect(prompt).toMatch(/NEVER reconcile it\s+away into a calm verdict/i);
+ // A6 — streaks + return-to-baseline.
+ expect(prompt).toMatch(/19\. Streaks \+ return-to-baseline/);
+ expect(prompt).toMatch(/returnEvent/);
+ expect(prompt).toMatch(/back inside your usual range/i);
+ // A7 — MI freebies + anti-persuasion (Chain-of-Ethic).
+ expect(prompt).toMatch(/20\. Motivational-interviewing freebies/);
+ expect(prompt).toMatch(/DEVELOP DISCREPANCY/);
+ expect(prompt).toMatch(/ROLL WITH RESISTANCE/);
+ expect(prompt).toMatch(/Chain-of-Ethic/);
+ });
});
describe("getCoachSystemPrompt — DE", () => {
@@ -181,6 +203,20 @@ describe("getCoachSystemPrompt — DE", () => {
expect(prompt).toMatch(/mmol\/L/);
expect(prompt).toMatch(/min[\s\S]*\(Schlafdefizit/);
});
+
+ it("carries the v1.21.2 narrative-presence clauses (A4/A5/A6/A7)", () => {
+ expect(prompt).toMatch(/17\. Rückblick \+ Ausblick/);
+ expect(prompt).toMatch(/priorNarrative/);
+ expect(prompt).toMatch(/18\. Spannungs-Verdikt/);
+ expect(prompt).toMatch(/"TENSION"-Flag/);
+ expect(prompt).toMatch(/CLINICAL-FLOORS-OVERRIDE/);
+ expect(prompt).toMatch(/19\. Serien \+ Rückkehr zur Baseline/);
+ expect(prompt).toMatch(/wieder in deinem üblichen Bereich/i);
+ expect(prompt).toMatch(/20\. Motivational-Interviewing-Extras/);
+ expect(prompt).toMatch(/DISKREPANZ ENTWICKELN/);
+ expect(prompt).toMatch(/WIDERSTAND ANNEHMEN/);
+ expect(prompt).toMatch(/Chain-of-Ethic/);
+ });
});
/**
diff --git a/src/lib/ai/coach/derived-snapshot.ts b/src/lib/ai/coach/derived-snapshot.ts
index ad007f456..6d3fc75d1 100644
--- a/src/lib/ai/coach/derived-snapshot.ts
+++ b/src/lib/ai/coach/derived-snapshot.ts
@@ -74,17 +74,97 @@ interface CoincidentSnapshotEntry {
illnessExplained: boolean;
}
+/**
+ * v1.21.2 (A5) — the Tension Verdict flag, compactly. The readiness/recovery
+ * composite blends several contributors; when they DISAGREE (e.g. good sleep
+ * but a suppressed HRV-balance or an elevated resting pulse) the single band
+ * hides the conflict. This flag names the honest "internal read" the model
+ * narrates on the health-score card — "good sleep, but resting pulse is up — the
+ * composite lands yellow" — instead of papering the conflict over.
+ *
+ * DESCRIPTIVE multi-signal disagreement, never a cause. Computed deterministically
+ * server-side off the readiness contributors (each 0..100, higher = better); the
+ * model narrates the flag, it never invents it. Omitted entirely when the
+ * contributors agree (no tension to surface).
+ *
+ * CLINICAL-FLOORS OVERRIDE: `clinicalOverride` is true when a clinical red-flag
+ * is in play (the coincident-deviation flag fired WITHOUT an illness explanation).
+ * The narration must NOT reconcile a real red-flag away into a calm verdict — the
+ * flag carries the bit so the prompt clause can refuse the soft landing.
+ */
+interface TensionSnapshotEntry {
+ fired: true;
+ /** The composite band the disagreement resolved to (green/yellow/red). */
+ band: string;
+ /** Contributors reading FAVOURABLY, e.g. "sleep". */
+ positive: string[];
+ /** Contributors reading UNFAVOURABLY, e.g. "resting heart rate", "HRV balance". */
+ negative: string[];
+ /** True when a real clinical red-flag is in play — never reconcile it away. */
+ clinicalOverride: boolean;
+}
+
/**
* The derived block: per-metric compact score entries (keyed by metric id),
* plus the optional fired-only coincident-deviation flag under its own reserved
* `COINCIDENT_DEVIATION` key. The score entries keep their `DerivedSnapshotEntry`
* shape (so callers + tests read `block.READINESS.value` directly); the
- * coincident flag rides alongside without widening the score index signature.
+ * coincident + tension flags ride alongside without widening the score index
+ * signature.
*/
type DerivedSnapshotBlock = Record & {
COINCIDENT_DEVIATION?: CoincidentSnapshotEntry;
+ TENSION?: TensionSnapshotEntry;
};
+/**
+ * v1.21.2 (A5) — a contributor reading at/above this 0..100 score is FAVOURABLE;
+ * at/below `TENSION_LOW_SCORE` it is UNFAVOURABLE. The gap in the middle is
+ * "neutral" — neither side of a disagreement. A genuine tension needs at least
+ * one contributor on EACH side.
+ */
+export const TENSION_HIGH_SCORE = 70;
+export const TENSION_LOW_SCORE = 45;
+
+/** Human-readable contributor labels (no internal enum names reach the model). */
+const READINESS_CONTRIBUTOR_LABEL: Record = {
+ rhr: "resting heart rate",
+ hrv: "HRV balance",
+ sleep: "sleep",
+ respiratory: "respiratory rate",
+ mood: "mood stability",
+};
+
+/**
+ * v1.21.2 (A5) — derive the Tension Verdict from the readiness contributors.
+ * Pure: a list of `{ key, value }` contributors (value 0..100, higher = better,
+ * null = absent) plus the composite band and the clinical-override bit. Returns
+ * the fired flag ONLY when at least one contributor reads favourably AND at least
+ * one reads unfavourably — a genuine internal disagreement. Null otherwise.
+ */
+export function deriveTension(
+ contributors: Array<{ key: string; value: number | null }>,
+ band: string | undefined,
+ clinicalOverride: boolean,
+): TensionSnapshotEntry | null {
+ const positive: string[] = [];
+ const negative: string[] = [];
+ for (const c of contributors) {
+ if (c.value === null) continue;
+ const label = READINESS_CONTRIBUTOR_LABEL[c.key] ?? c.key;
+ if (c.value >= TENSION_HIGH_SCORE) positive.push(label);
+ else if (c.value <= TENSION_LOW_SCORE) negative.push(label);
+ }
+ if (positive.length === 0 || negative.length === 0) return null;
+ return {
+ fired: true,
+ band: band ?? "yellow",
+ positive,
+ negative,
+ clinicalOverride,
+ };
+}
+
/** Pull the headline number + band off each metric's value shape. */
function summariseValue(
metric: DerivedMetricId,
@@ -177,9 +257,26 @@ export async function buildDerivedSnapshotBlock(
}),
);
+ // v1.21.2 (A5) — capture the readiness contributors so the Tension Verdict can
+ // read the per-contributor directions the composite band hides. Only the
+ // readiness blend exposes them; recovery resolves to the same proxy when no
+ // device-native row exists, so reading readiness once is enough.
+ let readinessContributors: Array<{ key: string; value: number | null }> = [];
+ let readinessBand: string | undefined;
+
for (const { metric, derived } of computed) {
if (derived === null) continue;
if (!isDerivedOk(derived)) continue; // omit insufficient — no noise
+ if (metric === "READINESS") {
+ const v = derived.value as {
+ band?: string;
+ components?: Array<{ key: string; value: number | null }>;
+ };
+ readinessBand = typeof v.band === "string" ? v.band : undefined;
+ readinessContributors = Array.isArray(v.components)
+ ? v.components.map((c) => ({ key: c.key, value: c.value }))
+ : [];
+ }
const summary = summariseValue(metric, derived.value);
if (!summary) continue;
block[metric] = {
@@ -223,7 +320,9 @@ export async function buildDerivedSnapshotBlock(
// own reserved key alongside the score entries.
const out: DerivedSnapshotBlock = block;
const coincident = await coincidentPromise;
- if (coincident && isDerivedOk(coincident) && coincident.value.fired) {
+ const coincidentFired =
+ coincident !== null && isDerivedOk(coincident) && coincident.value.fired;
+ if (coincidentFired) {
out.COINCIDENT_DEVIATION = {
fired: true,
contributing: coincident.value.contributing.map(
@@ -235,5 +334,19 @@ export async function buildDerivedSnapshotBlock(
};
}
+ // v1.21.2 (A5) — the Tension Verdict. A real clinical red-flag is in play when
+ // the coincident-deviation flag fired WITHOUT an illness explanation; the flag
+ // carries that bit so the narration never reconciles a red-flag into a calm
+ // verdict (clinical-floors override). Only attached when the contributors
+ // genuinely disagree — a coherent readiness adds no entry.
+ const clinicalOverride =
+ coincidentFired && !coincident.value.illnessExplained;
+ const tension = deriveTension(
+ readinessContributors,
+ readinessBand,
+ clinicalOverride,
+ );
+ if (tension) out.TENSION = tension;
+
return Object.keys(out).length > 0 ? out : null;
}
diff --git a/src/lib/ai/coach/system-prompt.ts b/src/lib/ai/coach/system-prompt.ts
index 9dd21b76d..85ce52586 100644
--- a/src/lib/ai/coach/system-prompt.ts
+++ b/src/lib/ai/coach/system-prompt.ts
@@ -241,6 +241,50 @@ GROUND RULES
readiness is yellow, mostly the HRV-balance piece") instead of the
band alone. Stay descriptive; the driver is an observation, not a
diagnosis.
+17. Recall + look ahead (v1.21.2 A4). The SNAPSHOT's "memory" block MAY
+ carry a "priorNarrative" (the headline you noted at the start of the
+ prior period) and a "trendMemory" map (where each vital sat in the prior
+ period vs now). When you have one, you may open a turn with ONE brief
+ callback that recalls the prior read AND points ahead — "as I noted
+ Monday, your sleep was short; it's recovered since — worth watching
+ tonight". One callback per reply at most, always anchored to a real
+ figure or band the block carries, never a vague "last time". If the
+ block carries no recall, do not invent one — narrate only the present.
+18. Tension Verdict — narrate the honest internal read (v1.21.2 A5). The
+ "derived" block MAY carry a "TENSION" flag (fired-only) when the
+ readiness/recovery composite's contributors DISAGREE: { band, positive[],
+ negative[], clinicalOverride }. When present, surface the conflict in one
+ honest line rather than hiding it behind the band — "good sleep, but your
+ resting pulse is up, so the composite lands {band}". Name the favourable
+ and the unfavourable contributors as the user sees them, descriptively.
+ This is the moment the read feels genuinely intelligent — do not smooth it
+ into a bland "you're doing fine". CLINICAL-FLOORS OVERRIDE: when
+ "clinicalOverride" is true a real red-flag is in play — NEVER reconcile it
+ away into a calm verdict; keep the favourable contributor honest but lead
+ with the concern and the seek-care framing the safety clauses require.
+19. Streaks + return-to-baseline (v1.21.2 A6). The SNAPSHOT MAY carry a
+ "streaks" block: per metric, { inBand, streakDays, returnEvent? }. Use it
+ to actively CLOSE a worry the user might be carrying. When a "returnEvent"
+ is present ({ daysInside, priorDaysOutside, priorDirection }) the metric
+ has come BACK inside the user's own usual range after a prior run outside
+ it — say so plainly and reassuringly: "back inside your usual range after
+ last week's dip — whatever that was, it's passed". When "inBand" is true
+ with a long streak, you may affirm the steadiness ("eight steady days in
+ your range"). The band is the user's PERSONAL range, never a clinical
+ threshold; a return only fires after a genuine prior out-of-band run, so
+ trust the flag and never manufacture a "return" the block does not carry.
+20. Motivational-interviewing freebies + anti-persuasion (v1.21.2 A7).
+ Two more MI moves are available when they fit the user's OWN goal:
+ DEVELOP DISCREPANCY — gently hold up the gap between where the data sits
+ and a goal the user named, letting them feel it rather than you closing
+ it; and ROLL WITH RESISTANCE — when the user pushes back, move WITH the
+ resistance ("that's fair — what would feel workable instead?") rather than
+ arguing the point. Anti-persuasion (Chain-of-Ethic): before you nudge,
+ silently check that the change serves the user's stated goal and their
+ autonomy, not engagement, not a target you picked for them, and not
+ persuasion for its own sake. If a nudge would only serve to push, drop it
+ and reflect instead. You inform and support a decision that stays theirs;
+ you never manipulate it.
DAY-LEVEL READINGS — USE THE TIMELINE
@@ -746,6 +790,55 @@ GRUNDREGELN
ihn sieht ("deine Readiness ist gelb, hauptsächlich der
HRV-Balance-Anteil") statt nur das Band. Bleib beschreibend; der
Treiber ist eine Beobachtung, keine Diagnose.
+17. Rückblick + Ausblick (v1.21.2 A4). Der "memory"-Block des SNAPSHOT
+ kann eine "priorNarrative" (die Schlagzeile, die du zu Beginn der
+ vorigen Periode notiert hast) und eine "trendMemory"-Map (wo jeder
+ Vitalwert in der vorigen Periode lag vs. jetzt) tragen. Wenn du eine
+ hast, darfst du einen Turn mit GENAU EINEM kurzen Rückbezug eröffnen,
+ der die frühere Lesung aufruft UND nach vorn weist — "wie ich am
+ Montag anmerkte, war dein Schlaf kurz; er hat sich seither erholt —
+ heute Abend einen Blick wert". Höchstens ein Rückbezug pro Antwort,
+ immer an einer echten Zahl oder einem Band aus dem Block verankert, nie
+ ein vages "letztes Mal". Trägt der Block keine Erinnerung, erfinde keine
+ — erzähle nur die Gegenwart.
+18. Spannungs-Verdikt — die ehrliche innere Lesung (v1.21.2 A5). Der
+ "derived"-Block kann ein "TENSION"-Flag (nur bei Auslösung) tragen, wenn
+ die Beiträge des Readiness-/Erholungs-Komposits WIDERSPRECHEN: { band,
+ positive[], negative[], clinicalOverride }. Wenn vorhanden, benenne den
+ Konflikt in einer ehrlichen Zeile, statt ihn hinter dem Band zu
+ verstecken — "guter Schlaf, aber dein Ruhepuls ist erhöht, also landet
+ das Komposit bei {band}". Benenne die günstigen und die ungünstigen
+ Beiträge so, wie der Nutzer sie sieht, beschreibend. Das ist der Moment,
+ in dem die Lesung wirklich intelligent wirkt — glätte sie nicht zu einem
+ faden "dir geht's gut". CLINICAL-FLOORS-OVERRIDE: Ist "clinicalOverride"
+ true, ist ein echtes Red Flag im Spiel — versöhne es NIE zu einem ruhigen
+ Verdikt weg; halte den günstigen Beitrag ehrlich, führe aber mit der
+ Sorge und dem Abklären-lassen-Rahmen, den die Sicherheitsregeln verlangen.
+19. Serien + Rückkehr zur Baseline (v1.21.2 A6). Der SNAPSHOT kann einen
+ "streaks"-Block tragen: je Metrik { inBand, streakDays, returnEvent? }.
+ Nutze ihn, um eine Sorge des Nutzers AKTIV zu schließen. Ist ein
+ "returnEvent" vorhanden ({ daysInside, priorDaysOutside, priorDirection }),
+ ist die Metrik nach einem vorherigen Lauf außerhalb ZURÜCK in den eigenen
+ üblichen Bereich des Nutzers gekehrt — sag das klar und beruhigend:
+ "wieder in deinem üblichen Bereich nach dem Tief letzte Woche — was es auch
+ war, es ist vorbei". Ist "inBand" true mit langer Serie, darfst du die
+ Konstanz anerkennen ("acht ruhige Tage in deinem Bereich"). Der Bereich ist
+ der PERSÖNLICHE Bereich des Nutzers, nie ein klinischer Schwellwert; eine
+ Rückkehr löst nur nach einem echten vorherigen Lauf außerhalb aus —
+ vertraue dem Flag und erfinde nie eine "Rückkehr", die der Block nicht trägt.
+20. Motivational-Interviewing-Extras + Anti-Überzeugung (v1.21.2 A7). Zwei
+ weitere MI-Moves stehen bereit, wenn sie zum EIGENEN Ziel des Nutzers
+ passen: DISKREPANZ ENTWICKELN — halte behutsam die Lücke zwischen dem, wo
+ die Daten stehen, und einem vom Nutzer genannten Ziel hoch und lass ihn sie
+ spüren, statt sie für ihn zu schließen; und WIDERSTAND ANNEHMEN — zeigt der
+ Nutzer Widerstand, geh MIT ihm ("das ist fair — was würde sich stattdessen
+ machbar anfühlen?"), statt dagegen zu argumentieren. Anti-Überzeugung
+ (Chain-of-Ethic): bevor du anstößt, prüfe still, dass die Veränderung dem
+ genannten Ziel und der Autonomie des Nutzers dient — nicht der Bindung,
+ nicht einem von dir gewählten Zielwert und nicht der Überzeugung um ihrer
+ selbst willen. Würde ein Anstoß nur dem Drängen dienen, lass ihn weg und
+ spiegele stattdessen. Du informierst und unterstützt eine Entscheidung, die
+ seine bleibt; du manipulierst sie nie.
TAGES-LEVEL-MESSWERTE — NUTZE DIE TIMELINE
diff --git a/src/lib/ai/prompts/insight-generator.ts b/src/lib/ai/prompts/insight-generator.ts
index b5c522432..047682f4b 100644
--- a/src/lib/ai/prompts/insight-generator.ts
+++ b/src/lib/ai/prompts/insight-generator.ts
@@ -40,7 +40,7 @@ import {
} from "./shared-contracts";
/** Stable identifier for the active system prompt revision. */
-export const PROMPT_VERSION = "4.29.0" as const;
+export const PROMPT_VERSION = "4.30.0" as const;
/**
* The scope-hardened insight prompt is one structural skeleton with a
diff --git a/src/lib/insights/__tests__/streak-detector.test.ts b/src/lib/insights/__tests__/streak-detector.test.ts
new file mode 100644
index 000000000..ee0448ae9
--- /dev/null
+++ b/src/lib/insights/__tests__/streak-detector.test.ts
@@ -0,0 +1,137 @@
+import { describe, it, expect } from "vitest";
+
+import {
+ detectStreak,
+ MIN_OUT_RUN,
+ type StreakPoint,
+} from "@/lib/insights/streak-detector";
+
+/**
+ * v1.21.2 (A6) — determinism pins for the streak / return-to-baseline detector.
+ *
+ * The PROSE the Coach narrates is not tested; the FLAGS are. These assert:
+ * - an in-band streak counts consecutive in-band days,
+ * - a calendar GAP day breaks a streak,
+ * - a return-to-baseline event fires only after a genuine prior out-of-band
+ * run, and never on a one-day blip,
+ * - too little history omits rather than guesses.
+ */
+
+/** A series of N consecutive days starting at 2026-06-01, with given values. */
+function consecutive(values: number[], startDay = 1): StreakPoint[] {
+ return values.map((value, i) => ({
+ day: `2026-06-${String(startDay + i).padStart(2, "0")}`,
+ value,
+ }));
+}
+
+const IN_BAND = { low: 60, high: 80 };
+
+describe("detectStreak — in-band streak", () => {
+ it("counts consecutive in-band days back from the latest", () => {
+ // All five days sit inside [60,80].
+ const res = detectStreak(consecutive([70, 71, 69, 72, 70]), IN_BAND);
+ expect(res.latestPlacement).toBe("in");
+ expect(res.inBand).toBe(true);
+ expect(res.streakDays).toBe(5);
+ expect(res.returnEvent).toBeUndefined();
+ });
+
+ it("a calendar gap breaks the streak", () => {
+ // Days 1-3 in-band, then a gap (no day 4), then day 5 in-band. The streak
+ // counting back from day 5 is only 1 — the gap severs it.
+ const series: StreakPoint[] = [
+ { day: "2026-06-01", value: 70 },
+ { day: "2026-06-02", value: 71 },
+ { day: "2026-06-03", value: 69 },
+ // gap: no 2026-06-04
+ { day: "2026-06-05", value: 72 },
+ ];
+ const res = detectStreak(series, IN_BAND);
+ expect(res.inBand).toBe(true);
+ expect(res.streakDays).toBe(1);
+ });
+
+ it("an out-of-band streak counts only the same side", () => {
+ // Latest day is above; the prior day was below — different runs, so the
+ // streak counting back from the latest above-day is 1.
+ const res = detectStreak(consecutive([50, 95]), IN_BAND);
+ expect(res.latestPlacement).toBe("above");
+ expect(res.inBand).toBe(false);
+ expect(res.streakDays).toBe(1);
+ });
+});
+
+describe("detectStreak — return-to-baseline event", () => {
+ it("fires after a genuine prior out-of-band run", () => {
+ // Three days above the band, then three days back inside it. The latest is
+ // in-band with a settled run, preceded by an out-of-band run of 3.
+ const res = detectStreak(consecutive([95, 96, 94, 70, 71, 69]), IN_BAND);
+ expect(res.inBand).toBe(true);
+ expect(res.streakDays).toBe(3);
+ expect(res.returnEvent).toBeDefined();
+ expect(res.returnEvent).toEqual({
+ daysInside: 3,
+ priorDaysOutside: 3,
+ priorDirection: "above",
+ });
+ });
+
+ it("does not fire on a one-day blip (below MIN_OUT_RUN)", () => {
+ // A single above-band day before the in-band run is not a run to return
+ // from — MIN_OUT_RUN gates it out.
+ expect(MIN_OUT_RUN).toBe(2);
+ const res = detectStreak(consecutive([95, 70, 71, 69]), IN_BAND);
+ expect(res.inBand).toBe(true);
+ expect(res.returnEvent).toBeUndefined();
+ });
+
+ it("does not fire when a gap separates the out-of-band run from the return", () => {
+ // Out-of-band run, then a gap, then the in-band run. The return cannot
+ // bridge a missing calendar day, so no event fires.
+ const series: StreakPoint[] = [
+ { day: "2026-06-01", value: 95 },
+ { day: "2026-06-02", value: 96 },
+ // gap: no 2026-06-03
+ { day: "2026-06-04", value: 70 },
+ { day: "2026-06-05", value: 71 },
+ ];
+ const res = detectStreak(series, IN_BAND);
+ expect(res.inBand).toBe(true);
+ expect(res.returnEvent).toBeUndefined();
+ });
+
+ it("does not fire when the metric never left the band", () => {
+ const res = detectStreak(consecutive([70, 71, 69, 72]), IN_BAND);
+ expect(res.returnEvent).toBeUndefined();
+ });
+});
+
+describe("detectStreak — conservatism", () => {
+ it("omits (null placement, no event) on an empty series", () => {
+ const res = detectStreak([], IN_BAND);
+ expect(res.latestPlacement).toBeNull();
+ expect(res.streakDays).toBe(0);
+ expect(res.returnEvent).toBeUndefined();
+ });
+
+ it("builds a personal band from the series when none is supplied", () => {
+ // A long, steady run with one recent dip. The MAD band derives from the
+ // whole series; the steady days read in-band.
+ const steady = consecutive([
+ 70, 71, 69, 70, 72, 68, 70, 71, 69, 70, 71, 70,
+ ]);
+ const res = detectStreak(steady);
+ expect(res.latestPlacement).toBe("in");
+ expect(res.streakDays).toBeGreaterThan(0);
+ });
+
+ it("omits when there is too little history to build a band", () => {
+ // An empty band build returns null → a quiet omit. A single point gives a
+ // zero-spread band; supplying no band and one point yields an in placement
+ // but never a return. The conservatism we pin is the empty case above; a
+ // degenerate one-point series must at least never fabricate a return.
+ const res = detectStreak(consecutive([70]));
+ expect(res.returnEvent).toBeUndefined();
+ });
+});
diff --git a/src/lib/insights/streak-detector.ts b/src/lib/insights/streak-detector.ts
new file mode 100644
index 000000000..9484b3a02
--- /dev/null
+++ b/src/lib/insights/streak-detector.ts
@@ -0,0 +1,210 @@
+/**
+ * v1.21.2 (A6) — per-metric in/out-of-band streak + return-to-baseline event.
+ *
+ * A PURE detector over a DAY-bucket per-day-mean series. It answers two
+ * questions the "ambient Coach presence" surface needs to close a worry:
+ *
+ * 1. How many consecutive days has this metric sat INSIDE (or OUTSIDE) its
+ * personal range, counting back from the most recent day? A streak is
+ * strictly consecutive: a calendar GAP day (a day with no reading)
+ * BREAKS it — we never paper over a missing day as "still inside".
+ * 2. Did the metric just RETURN to its personal range after a run outside
+ * it? "Back inside your usual range after last week's dip — whatever that
+ * was, it's passed." A return only fires when a prior OUT-of-band run of
+ * at least `MIN_OUT_RUN` days is followed by an IN-band run of at least
+ * `MIN_IN_RUN` days ending on the latest day. Without a genuine prior
+ * out-of-band run there is nothing to "return" from.
+ *
+ * The band is the user's OWN personal range: median ± k·MAD (Hampel/Leys),
+ * built by `buildBaselineBand` from the SAME series — never an invented
+ * clinical threshold. The band is established over the WHOLE series so a
+ * single recent dip doesn't move the goalposts; callers that want a stricter
+ * "prior period establishes the band" split can pass a pre-built band.
+ *
+ * Inherent-low conservatism: when the series is too short to establish a band
+ * (or the caller passes none and `buildBaselineBand` returns null), the
+ * detector returns a quiet `null`-streak result and fires NO return event —
+ * it omits rather than guesses.
+ *
+ * Pure: no DB, no clock, no network. Fully unit-testable over injected series.
+ */
+import { buildBaselineBand } from "@/lib/insights/derived/baseline";
+
+/** One DAY-bucket point: a calendar day key (YYYY-MM-DD) + that day's mean. */
+export interface StreakPoint {
+ /** Calendar day key in the user's timezone (YYYY-MM-DD). */
+ day: string;
+ /** The day's mean value. */
+ value: number;
+}
+
+/** A personal range: the median ± k·MAD band a value sits inside or outside. */
+export interface StreakBand {
+ low: number;
+ high: number;
+}
+
+/** Where a single day's value sits relative to the personal band. */
+export type BandPlacement = "in" | "above" | "below";
+
+/**
+ * Minimum consecutive out-of-band days that count as a genuine "run" the user
+ * could have worried about — a one-day blip is not a run to return from.
+ */
+export const MIN_OUT_RUN = 2;
+/** Minimum consecutive in-band days that count as a settled "return". */
+export const MIN_IN_RUN = 2;
+
+/** The detector's result: the current streak + an optional return event. */
+export interface StreakResult {
+ /**
+ * The most-recent day's placement relative to the band, or null when no
+ * band could be established (too little history).
+ */
+ latestPlacement: BandPlacement | null;
+ /**
+ * Consecutive days (counting back from the latest day) the metric held the
+ * SAME in/out state. A calendar gap breaks the count. 0 when no band.
+ */
+ streakDays: number;
+ /** True when the current streak is an INSIDE-the-band run. */
+ inBand: boolean;
+ /**
+ * The return-to-baseline event, present only when a prior out-of-band run of
+ * ≥ MIN_OUT_RUN days is followed by an in-band run of ≥ MIN_IN_RUN days
+ * ending on the latest day. Absent (undefined) otherwise — silence is the
+ * default.
+ */
+ returnEvent?: {
+ /** Days the metric has now sat back inside its range (the in-band run). */
+ daysInside: number;
+ /** Days the prior out-of-band run lasted (the dip it returned from). */
+ priorDaysOutside: number;
+ /** Which side the prior run sat on. */
+ priorDirection: Exclude;
+ };
+}
+
+/** Classify one value against the personal band. */
+function placeValue(value: number, band: StreakBand): BandPlacement {
+ if (value > band.high) return "above";
+ if (value < band.low) return "below";
+ return "in";
+}
+
+/**
+ * Detect the current in/out-of-band streak and any return-to-baseline event
+ * from a DAY-bucket series.
+ *
+ * `series` need not be sorted; it is sorted ascending by day internally.
+ * Duplicate day keys are not expected (the caller passes per-day means) but a
+ * later entry for the same day wins, defensively.
+ *
+ * `band` may be supplied (e.g. a prior-period band) — when omitted it is built
+ * from the whole series via `buildBaselineBand`. A gap day (a calendar day with
+ * no point between two present days) BREAKS any streak that would span it.
+ */
+export function detectStreak(
+ series: StreakPoint[],
+ band?: StreakBand | null,
+): StreakResult {
+ const empty: StreakResult = {
+ latestPlacement: null,
+ streakDays: 0,
+ inBand: false,
+ };
+
+ // De-dupe by day (last wins) and sort ascending so "consecutive" is by date.
+ const byDay = new Map();
+ for (const p of series) {
+ if (!Number.isFinite(p.value)) continue;
+ byDay.set(p.day, p.value);
+ }
+ const points = [...byDay.entries()]
+ .map(([day, value]) => ({ day, value }))
+ .sort((a, b) => (a.day < b.day ? -1 : 1));
+ if (points.length === 0) return empty;
+
+ // Resolve the personal band: caller-supplied, else median ± k·MAD over the
+ // whole series. Null (too little history) → omit rather than guess.
+ const resolvedBand = band ?? buildBandFromSeries(points.map((p) => p.value));
+ if (!resolvedBand) return empty;
+
+ // Per-day placement, ascending. A gap day inserts an explicit break marker so
+ // the streak walk below cannot bridge a missing calendar day.
+ const placements: BandPlacement[] = points.map((p) =>
+ placeValue(p.value, resolvedBand),
+ );
+ const days = points.map((p) => p.day);
+
+ // ── current streak: walk back from the latest day, same state, no gap ──
+ const latestPlacement = placements[placements.length - 1];
+ const latestInBand = latestPlacement === "in";
+ let streakDays = 1;
+ for (let i = placements.length - 2; i >= 0; i--) {
+ if (dayGap(days[i], days[i + 1]) !== 1) break; // calendar gap breaks it
+ const sameState =
+ (placements[i] === "in") === latestInBand &&
+ // For an out-of-band streak, require the SAME side (above vs below); a
+ // flip from above to below is a different run, not one streak.
+ (latestInBand || placements[i] === latestPlacement);
+ if (!sameState) break;
+ streakDays += 1;
+ }
+
+ const result: StreakResult = {
+ latestPlacement,
+ streakDays,
+ inBand: latestInBand,
+ };
+
+ // ── return-to-baseline event ──
+ // Only when the metric is currently IN-band with a settled in-band run, and
+ // the days IMMEDIATELY before that run (no gap) were a genuine out-of-band
+ // run of ≥ MIN_OUT_RUN days on one side.
+ if (latestInBand && streakDays >= MIN_IN_RUN) {
+ const inRunStart = placements.length - streakDays; // index of the in-run's first day
+ // The out-of-band run must be calendar-adjacent to the in-band run.
+ if (
+ inRunStart > 0 &&
+ dayGap(days[inRunStart - 1], days[inRunStart]) === 1 &&
+ placements[inRunStart - 1] !== "in"
+ ) {
+ const priorDirection = placements[inRunStart - 1] as Exclude<
+ BandPlacement,
+ "in"
+ >;
+ let priorDaysOutside = 1;
+ for (let i = inRunStart - 2; i >= 0; i--) {
+ if (dayGap(days[i], days[i + 1]) !== 1) break;
+ if (placements[i] !== priorDirection) break;
+ priorDaysOutside += 1;
+ }
+ if (priorDaysOutside >= MIN_OUT_RUN) {
+ result.returnEvent = {
+ daysInside: streakDays,
+ priorDaysOutside,
+ priorDirection,
+ };
+ }
+ }
+ }
+
+ return result;
+}
+
+/** Build the personal band from a value series; null when too little data. */
+function buildBandFromSeries(values: number[]): StreakBand | null {
+ const band = buildBaselineBand(values);
+ if (!band) return null;
+ if (!Number.isFinite(band.low) || !Number.isFinite(band.high)) return null;
+ return { low: band.low, high: band.high };
+}
+
+/** Whole-day gap between two YYYY-MM-DD keys (>=1; 1 = adjacent). */
+function dayGap(earlier: string, later: string): number {
+ const a = Date.parse(`${earlier}T00:00:00Z`);
+ const b = Date.parse(`${later}T00:00:00Z`);
+ if (Number.isNaN(a) || Number.isNaN(b)) return Number.POSITIVE_INFINITY;
+ return Math.round((b - a) / (24 * 60 * 60 * 1000));
+}