feat(insights): ambient narrative presence — callbacks, tension verdict, streaks, MI moves

Add a deterministic narrative layer the Coach narrates rather than invents:

- Briefing callbacks + forward-looks: the daily briefing recalls the prior
  period and points ahead, anchored to a figure, one callback per surface.
- Tension Verdict: when the readiness composite's contributors disagree
  (good sleep but a rising resting pulse), surface a brief honest internal
  read on the health-score card. A real clinical red-flag is never reconciled
  away into a calm verdict.
- Streaks + return-to-baseline: a pure detector over a DAY-bucket series
  closes a worry once a metric returns inside the personal MAD band after a
  genuine out-of-band run; a gap day breaks a streak.
- Coach prompt: add develop-discrepancy, roll-with-resistance, and an
  anti-persuasion clause; ratchet PROMPT_VERSION.

The tension flag and streak event are computed server-side; the card lines
render resolved fields and the model narrates them.
This commit is contained in:
Marc-André Bombeck committed 2026-06-26 23:57:37 +02:00
1 parent 096a486bcc
commit fe6c2e281b
9 files changed
+863 -5

No files matched your search

@@ -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.
<div className="space-y-3">
{/* 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 && (
<div
data-slot="daily-briefing-memory"
className="border-border/60 bg-card/40 space-y-1 rounded-md border px-3 py-2"
>
<p
data-slot="daily-briefing-memory-recall"
className="text-muted-foreground text-xs leading-snug"
>
{t("insights.briefing.memory.recall", {
text: memory.recall,
})}
</p>
<p
data-slot="daily-briefing-memory-forward"
className="text-foreground/80 text-xs leading-snug"
>
{t("insights.briefing.memory.forward", {
text: memory.forward,
})}
</p>
</div>
)}
{/* 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
+91 -1
View File
@@ -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({
</p>
)}
{/* 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 && (
<p
data-slot="health-score-card-tension"
data-band={tension.band}
className="text-muted-foreground inline-flex items-start gap-1.5 text-[11px] leading-relaxed"
>
<Scale className="mt-0.5 size-3 shrink-0" aria-hidden="true" />
<span>
{t("insights.tension.label", {
positive: listFormatter.format(tension.positive),
negative: listFormatter.format(tension.negative),
band: t(`insights.tension.contributor.${tension.band}`),
})}
</span>
</p>
)}
{/* 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 && (
<p
data-slot="health-score-card-return-to-band"
className="text-success/90 inline-flex items-start gap-1.5 text-[11px] leading-relaxed"
>
<CornerDownLeft
className="mt-0.5 size-3 shrink-0"
aria-hidden="true"
/>
<span>
{t("insights.streak.returnLabel", {
metric: returnToBand.metricLabel,
count: returnToBand.daysInside,
})}
</span>
</p>
)}
<ul
data-slot="health-score-card-components"
// v1.8.5 W4a — the always-visible "Zusammensetzung" breakdown is
@@ -12,7 +12,12 @@ vi.mock("@/lib/db", () => ({
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<typeof vi.fn>;
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);
});
});
@@ -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/);
});
});
/**
+115 -2
View File
@@ -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<string, DerivedSnapshotEntry> & {
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<string, string> = {
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;
}
+93
View File
@@ -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
+1 -1
View File
@@ -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
@@ -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();
});
});
+210
View File
@@ -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<BandPlacement, "in">;
};
}
/** 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<string, number>();
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));
}