feat(insights): per-score assessment text + shared metric signal block

Add an additive, non-breaking assessment field to GET /api/insights/derived:
{ text, source, updatedAt } | null, keyed to the requested metric id for the
five per-score ids (READINESS, SLEEP_SCORE, RECOVERY_SCORE, STRAIN_SCORE,
STRESS_SCORE). The field is null when the metric is not one of those or when
its status is not "ok". A deterministic text always fills it — composed from
the score's own contributors, so provider-less accounts and the demo are never
empty — and warmer provider prose overrides it once cached, warmed out of band
so the GET never blocks on a model round-trip. de/en, following the v1.12.12
deterministic-narrative model. The batch grid read returns assessment: null;
the detail sheets use the single-metric route.

Introduce a shared buildMetricSignal() helper: a token-lean, signal-rich
descriptor (current, own baseline, signed delta, spread, normal-swing verdict,
normal-range placement, contributors) computed server-side so the model states
the comparison instead of re-deriving it from raw buckets. Thread it into the
generic per-metric assessment snapshot and lead the base prompt from it. Add a
PROMPT_VERSION constant to the base system prompt so prompt changes are
traceable.

Regenerate docs/api/openapi.yaml.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.8 committed 2026-06-05 23:57:39 +02:00
1 parent 7db9608c4c
commit 30a7437f4d
14 files changed
+1282 -12

No files matched your search

+28
View File
@@ -9306,6 +9306,14 @@ components:
- type: string
- type: "null"
description: Why the value could not be produced; null when status is 'ok'.
assessment:
anyOf:
- $ref: "#/components/schemas/DerivedAssessment"
- type: "null"
description: "v1.13.2 — short 'why is this score what it is' explanation, keyed to the SAME requested id (only for the
per-score ids READINESS, SLEEP_SCORE, RECOVERY_SCORE, STRAIN_SCORE, STRESS_SCORE). Null for any other metric
and whenever status !== 'ok'. Always non-empty when present: a deterministic text fills it (so provider-less
accounts + the demo always get one) and warmer AI prose overrides it once cached."
required:
- metric
- status
@@ -9314,6 +9322,7 @@ components:
- confidence
- provenance
- reason
- assessment
additionalProperties: false
description: Flat `Derived<T>` envelope for one derived wellness metric. Pure compute over the rollup tier (no LLM, no
narrative). iOS decodes one stable shape and combines values across metrics; coverage/confidence/provenance let
@@ -9400,6 +9409,25 @@ components:
- windowDays
- computedAt
additionalProperties: false
DerivedAssessment:
type: object
properties:
text:
type: string
description: Short, non-empty explanation of why the score sits where it does, referencing the score's contributors.
source:
type: string
description: "'deterministic' for the always-on template text, or 'ai' when warmer provider prose has been cached."
updatedAt:
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
description: When the text was produced / last warmed.
required:
- text
- source
- updatedAt
additionalProperties: false
DerivedBatchResponseEnvelope:
type: object
properties:
@@ -167,6 +167,11 @@ export const GET = apiHandler(async (request: NextRequest) => {
provenance: derived.provenance,
reason:
derived.status === "insufficient" ? derived.reason : null,
// The batch backs the dashboard GRID; per-score assessment text is
// a detail-sheet concern served by the single-metric route. Always
// null here so the wire shape matches `DerivedMetricResponse`
// without paying the assessment cost on a 17-item grid read.
assessment: null,
},
};
}),
+23
View File
@@ -31,6 +31,8 @@ import {
DERIVED_METRIC_IDS,
type DerivedMetricId,
} from "@/lib/insights/derived";
import { resolveDerivedAssessment } from "@/lib/insights/derived/derived-assessment-ai";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
export const dynamic = "force-dynamic";
@@ -80,6 +82,26 @@ export const GET = apiHandler(async (request: NextRequest) => {
meta: { metric, status: derived.status },
});
// v1.13.2 — additive per-score assessment: a short "why is this score what
// it is" explanation, keyed to the SAME id the caller passed (READINESS,
// SLEEP_SCORE, RECOVERY_SCORE, STRAIN_SCORE, STRESS_SCORE). Null for any
// other id, and null when status !== "ok". Always a non-empty deterministic
// text when present (the demo + provider-less accounts fill); warmer AI
// prose overrides it once cached. The warm enqueue is fire-and-forget — this
// never blocks on an LLM round-trip.
const localeParam = request.nextUrl.searchParams.get("locale");
const resolvedLocale = await resolveServerLocale({
request,
userLocale: user.locale ?? null,
override: localeParam,
});
const assessment = await resolveDerivedAssessment({
metric,
userId: user.id,
derived,
locale: resolvedLocale,
});
// Flatten the discriminated union for the wire: `metric` tags it,
// `value`/`reason` are nullable so iOS decodes one stable shape.
return apiSuccess({
@@ -90,5 +112,6 @@ export const GET = apiHandler(async (request: NextRequest) => {
confidence: derived.status === "ok" ? derived.confidence : null,
provenance: derived.provenance,
reason: derived.status === "insufficient" ? derived.reason : null,
assessment,
});
});
@@ -21,10 +21,14 @@ describe("base-system assessment prompt — grounding floor preserved", () => {
if (locale === "en") {
expect(p).toMatch(/own baseline/i);
expect(p).toMatch(/never against a population norm/i);
expect(p).toMatch(/a value inside the user's normal swing is NOT a finding/i);
// v1.13.x — the normal-swing verdict is now a pre-computed boolean the
// prompt leads from; the grounding floor is that an in-swing value is
// explicitly NOT a finding (phrased against `outsideNormalSwing`).
expect(p).toMatch(/outsideNormalSwing: false.*not a finding/i);
} else {
expect(p).toMatch(/EIGENE Baseline/);
expect(p).toMatch(/nicht gegen Bevölkerungsnormen/i);
expect(p).toMatch(/outsideNormalSwing: false.*kein Befund/i);
}
});
+27 -10
View File
@@ -1,5 +1,22 @@
import type { Locale } from "@/lib/i18n/config";
/**
* Version of the per-metric assessment base prompt + its signal contract.
*
* Bump this whenever the base-system sections, the signal-block instruction,
* or the shared snapshot shape change, so the cross-feature quality
* attribution (and the per-score assessment provenance) can slice prose
* before/after a prompt edit. The other AI surfaces already carry their own
* (`insight-generator.ts` `PROMPT_VERSION`, `period-narrative`
* `NARRATIVE_PROMPT_VERSION`); the base system had none — this is it.
*
* 5.0.0 — the SIGNAL block lands: the snapshot now carries a pre-computed
* `current · baseline · signed delta · spread · outsideNormalSwing`
* descriptor and the prompt leads from it instead of asking the model to
* derive the comparison from raw buckets.
*/
export const PROMPT_VERSION = "5.0.0" as const;
/**
* Base system prompt for the per-metric Insights *assessment* cards.
*
@@ -67,33 +84,33 @@ const ASSESSMENT_SECTIONS: readonly AssessmentSection[] = [
{
id: "data",
en: `YOUR DATA (graded snapshot):
- signal: the finished comparison — { metric, unit, current, currentWindowDays, baseline, baselineLabel, delta, deltaPct, spread, outsideNormalSwing, direction, normalRange?, placement?, n, newestDaysAgo, contributors? }. \`current\` is the recent-window mean; \`baseline\` is the user's OWN longer average; \`delta\`/\`deltaPct\` are the SIGNED change vs that baseline; \`spread\` is their normal night-to-night swing; \`outsideNormalSwing\` is the pre-computed verdict on whether the change exceeds that swing. These are already computed — state them, do NOT recompute them. \`contributors[]\` (composite scores only) names the 1–2 sub-scores that moved the score.
- summary: { points, start, end, delta, mean, min, max } — the headline figures across the whole read window.
- series.recent[]: the last ~21 days, one row per day ({ date, min, max, mean, n }).
- series.weekly[]: the ~10 weeks before that, one ISO-week aggregate each.
- series.monthly[]: the ~12 months before that, one calendar-month aggregate each.
- series.yearly[]: everything older, one row per year ({ year, mean, min, max, n, slope }).
- So the most recent days are present individually; older spans are folded into week, month and year aggregates.
- Read the personal baseline from the weekly/monthly/yearly means; the short window is the recent days.
- A slice can be empty when the history is short — do not pretend it exists.`,
- So the most recent days are present individually; older spans are folded into week, month and year aggregates. Consult the series only for a specific day; lead from the signal block.
- A slice can be empty when the history is short — do not pretend it exists. When \`signal.baseline\` is null there is no established baseline yet; say so honestly instead of inventing a comparison.`,
de: `DEINE DATENGRUNDLAGE (graded snapshot):
- signal: der fertige Vergleich — { metric, unit, current, currentWindowDays, baseline, baselineLabel, delta, deltaPct, spread, outsideNormalSwing, direction, normalRange?, placement?, n, newestDaysAgo, contributors? }. \`current\` ist das recent-Mittel; \`baseline\` ist der EIGENE längere Durchschnitt; \`delta\`/\`deltaPct\` sind die VORZEICHENBEHAFTETE Änderung gegenüber dieser Baseline; \`spread\` ist die normale Schwankung; \`outsideNormalSwing\` ist das vorab berechnete Urteil, ob die Änderung diese Schwankung übersteigt. Diese Werte sind bereits berechnet — nenne sie, rechne sie NICHT neu. \`contributors[]\` (nur Score-Komposite) benennt die 1–2 Teil-Scores, die den Score bewegt haben.
- summary: { points, start, end, delta, mean, min, max } — die Eckwerte des gesamten Lesefensters.
- series.recent[]: die jüngsten ~21 Tage, je ein Eintrag pro Tag ({ date, min, max, mean, n }).
- series.weekly[]: die ~10 Wochen davor, je ein ISO-Wochen-Aggregat.
- series.monthly[]: die ~12 Monate davor, je ein Monatsaggregat.
- series.yearly[]: alles ältere, je ein Jahr ({ year, mean, min, max, n, slope }).
- Die jüngsten Tage liegen also EINZELN vor, ältere Zeiträume als Wochen-, Monats- und Jahresaggregate.
- Lies die persönliche Baseline aus den weekly/monthly/yearly-Mitteln; das kurze Fenster sind die recent-Tage.
- Ein Slice kann leer sein, wenn die Historie kurz ist. Dann nicht so tun, als gäbe es ihn.`,
- Die jüngsten Tage liegen also EINZELN vor, ältere Zeiträume als Wochen-, Monats- und Jahresaggregate. Ziehe die series nur für einen konkreten Tag heran; führe sonst mit dem signal-Block.
- Ein Slice kann leer sein, wenn die Historie kurz ist. Dann nicht so tun, als gäbe es ihn. Ist \`signal.baseline\` null, gibt es noch keine etablierte Baseline; sage das ehrlich, statt einen Vergleich zu erfinden.`,
},
{
id: "build",
en: `HOW TO BUILD THE ASSESSMENT (as flowing prose):
1. NAME the current finding with a concrete number from the snapshot (e.g. the recent mean or the latest daily value).
2. PLACE it against the user's OWN baseline: compare the short window (recent, ~week) to the long baseline (monthly/yearly mean). Report only SIGNIFICANT deviations — a value inside the user's normal swing is NOT a finding.
1. NAME the current finding with a concrete number — the signal block's \`current\` (or, for a day-level question, a value from series.recent).
2. PLACE it against the user's OWN baseline using the signal block's pre-computed \`delta\` vs \`baselineLabel\`: state the signed change, not a re-derived one. Treat \`outsideNormalSwing: false\` as "inside your usual range — not a finding" and do NOT manufacture a trend; treat \`outsideNormalSwing: true\` as the real, reportable change. When a \`normalRange\`/\`placement\` is present, use it only as a coarse secondary anchor — the personal delta leads.
3. ONE step — ONLY IF IT IS REAL: close with EXACTLY ONE concrete, doable suggestion WHEN the finding genuinely implies an action. One message = one behaviour, no list. If the value is steady and in a good place and there is nothing useful to do, do NOT manufacture a step — affirm briefly and name one thing worth keeping an eye on instead. A fabricated step is exactly the platitude we ban.`,
de: `SO BAUST DU DIE EINSCHÄTZUNG (als fließender Text):
1. BENENNEN: Nenne den aktuellen Befund mit einer konkreten Zahl aus dem Snapshot (z.B. das recent-Mittel oder den letzten Tageswert).
2. EINORDNEN gegen die EIGENE Baseline: Vergleiche das kurze Fenster (recent, ~Woche) mit der langen Baseline (monthly/yearly-Mittel). Melde nur SIGNIFIKANTE Abweichungen — eine Zahl, die innerhalb der normalen Schwankung des Nutzers liegt, ist KEIN Befund.
1. BENENNEN: Nenne den aktuellen Befund mit einer konkreten Zahl — dem \`current\` aus dem signal-Block (oder, für eine Tagesfrage, einem Wert aus series.recent).
2. EINORDNEN gegen die EIGENE Baseline mit dem vorab berechneten \`delta\` gegenüber \`baselineLabel\`: nenne die vorzeichenbehaftete Änderung, leite sie nicht neu her. Behandle \`outsideNormalSwing: false\` als "innerhalb der üblichen Schwankung — kein Befund" und ERFINDE KEINEN Trend; behandle \`outsideNormalSwing: true\` als die echte, meldenswerte Änderung. Liegt ein \`normalRange\`/\`placement\` vor, nutze es nur als groben sekundären Anker — das persönliche delta führt.
3. EIN SCHRITT — NUR WENN ER ECHT IST: Schließe mit GENAU EINER konkreten, machbaren Empfehlung, WENN aus dem Befund wirklich etwas Umsetzbares folgt. Eine Botschaft = ein Verhalten, keine Liste. Ist der Wert stabil und im grünen Bereich und gibt es nichts sinnvoll zu tun, dann ERZWINGE KEINEN Schritt — bestätige kurz und nenne stattdessen einen Punkt, den man im Auge behalten kann. Ein erfundener Schritt ist genau die Floskel, die wir vermeiden.`,
},
{
@@ -0,0 +1,146 @@
import { describe, it, expect } from "vitest";
import {
buildMetricSignal,
placeInBand,
NORMAL_SWING_K,
} from "@/lib/insights/metric-signal";
import type { GradedSeries } from "@/lib/insights/graded-series";
function graded(partial: Partial<GradedSeries>): GradedSeries {
return {
recent: partial.recent ?? [],
weekly: partial.weekly ?? [],
monthly: partial.monthly ?? [],
yearly: partial.yearly ?? [],
};
}
describe("buildMetricSignal", () => {
it("returns null when the recent window is empty (no current value)", () => {
const signal = buildMetricSignal({
metric: "resting heart rate",
direction: "lower-better",
graded: graded({ monthly: [{ month: "2026-05", min: 60, max: 70, mean: 66, n: 30 }] }),
});
expect(signal).toBeNull();
});
it("computes current, baseline, signed delta and deltaPct from the series", () => {
const signal = buildMetricSignal({
metric: "resting heart rate",
unit: "bpm",
direction: "lower-better",
graded: graded({
recent: [
{ date: "2026-06-01", min: 60, max: 62, mean: 61, n: 1 },
{ date: "2026-06-02", min: 60, max: 62, mean: 61, n: 1 },
],
monthly: [
{ month: "2026-04", min: 64, max: 68, mean: 66, n: 30 },
{ month: "2026-05", min: 64, max: 68, mean: 66, n: 30 },
],
}),
});
expect(signal).not.toBeNull();
expect(signal!.current).toBe(61);
expect(signal!.baseline).toBe(66);
expect(signal!.delta).toBe(-5);
expect(signal!.deltaPct).toBeCloseTo(-7.6, 1);
expect(signal!.n).toBe(2);
expect(signal!.unit).toBe("bpm");
expect(signal!.baselineLabel).toBe("your monthly average");
});
it("falls back monthly→weekly→yearly for the baseline", () => {
const weekly = buildMetricSignal({
metric: "x",
direction: "higher-better",
graded: graded({
recent: [{ date: "d", min: 10, max: 10, mean: 10, n: 1 }],
weekly: [{ weekISO: "2026-W20", min: 8, max: 8, mean: 8, n: 7 }],
}),
});
expect(weekly!.baseline).toBe(8);
expect(weekly!.baselineLabel).toBe("your recent-weeks average");
const yearly = buildMetricSignal({
metric: "x",
direction: "higher-better",
graded: graded({
recent: [{ date: "d", min: 10, max: 10, mean: 10, n: 1 }],
yearly: [{ year: "2025", min: 6, max: 6, mean: 6, n: 100, slope: null }],
}),
});
expect(yearly!.baseline).toBe(6);
expect(yearly!.baselineLabel).toBe("your long-term average");
});
it("nulls baseline/delta when there is no longer-window history", () => {
const signal = buildMetricSignal({
metric: "x",
direction: "higher-better",
graded: graded({ recent: [{ date: "d", min: 10, max: 10, mean: 10, n: 1 }] }),
});
expect(signal!.baseline).toBeNull();
expect(signal!.delta).toBeNull();
expect(signal!.deltaPct).toBeNull();
expect(signal!.outsideNormalSwing).toBeNull();
expect(signal!.baselineLabel).toBeUndefined();
});
it("flags outsideNormalSwing via |delta| > k·spread", () => {
// baseline means 60/60/60/60 → spread 0; a non-zero delta is outside.
const flat = buildMetricSignal({
metric: "x",
direction: "lower-better",
graded: graded({
recent: [{ date: "d", min: 70, max: 70, mean: 70, n: 1 }],
monthly: [
{ month: "a", min: 60, max: 60, mean: 60, n: 1 },
{ month: "b", min: 60, max: 60, mean: 60, n: 1 },
],
}),
});
expect(flat!.spread).toBe(0);
expect(flat!.outsideNormalSwing).toBe(true);
// wide spread swallows a small delta as inside the normal swing.
const noisy = buildMetricSignal({
metric: "x",
direction: "lower-better",
graded: graded({
recent: [{ date: "d", min: 62, max: 62, mean: 62, n: 1 }],
monthly: [
{ month: "a", min: 50, max: 50, mean: 50, n: 1 },
{ month: "b", min: 70, max: 70, mean: 70, n: 1 },
],
}),
});
// baseline 60, spread ~14.1, delta 2 → 2 <= 1*14.1 → inside.
expect(noisy!.outsideNormalSwing).toBe(false);
expect(NORMAL_SWING_K).toBe(1);
});
it("places the current value against a normal range", () => {
const signal = buildMetricSignal({
metric: "x",
direction: "lower-better",
graded: graded({
recent: [{ date: "d", min: 110, max: 110, mean: 110, n: 1 }],
monthly: [{ month: "a", min: 100, max: 100, mean: 100, n: 1 }],
}),
normalRange: { low: 60, high: 100 },
normalRangeSource: "age-sex-adjusted",
});
expect(signal!.normalRange).toEqual({ low: 60, high: 100, source: "age-sex-adjusted" });
expect(signal!.placement).toBe("above band");
});
});
describe("placeInBand", () => {
it("classifies below / in / above", () => {
expect(placeInBand(50, { low: 60, high: 100 })).toBe("below band");
expect(placeInBand(80, { low: 60, high: 100 })).toBe("in band");
expect(placeInBand(120, { low: 60, high: 100 })).toBe("above band");
});
});
@@ -0,0 +1,149 @@
import { describe, it, expect } from "vitest";
import {
resolveDeterministicAssessment,
buildScoreSignal,
isAssessableDerivedScore,
ASSESSABLE_DERIVED_SCORES,
} from "@/lib/insights/derived/derived-assessment";
import type { Derived } from "@/lib/insights/derived/types";
import type { ReadinessValue } from "@/lib/insights/derived/readiness";
import type { SleepScoreValue } from "@/lib/insights/derived/sleep-score";
import type { WellnessScoreValue } from "@/lib/insights/derived/wellness-scores";
const NOW = new Date("2026-06-05T12:00:00Z");
function okDerived<T>(value: T): Derived<T> {
return {
status: "ok",
value,
coverage: { requiredInputs: 1, presentInputs: 1, historyDays: 30, missing: [] },
confidence: { score: 90, band: "high" },
provenance: { inputs: ["X"], source: "DAY", windowDays: 30, computedAt: NOW.toISOString() },
};
}
const insufficient: Derived<unknown> = {
status: "insufficient",
coverage: { requiredInputs: 1, presentInputs: 0, historyDays: 0, missing: ["X"] },
provenance: { inputs: ["X"], source: "none", windowDays: 0, computedAt: NOW.toISOString() },
reason: "no_score_in_window",
};
const readiness: ReadinessValue = {
score: 64,
band: "yellow",
components: [
{ key: "rhr", value: 40, weight: 0.3 },
{ key: "hrv", value: 80, weight: 0.3 },
{ key: "sleep", value: 70, weight: 0.25 },
{ key: "respiratory", value: null, weight: 0 },
{ key: "mood", value: 60, weight: 0.15 },
],
};
const sleepScore: SleepScoreValue = {
score: 82,
band: "green",
night: "2026-06-04",
asleepMinutes: 440,
inBedMinutes: 470,
subScores: [
{ key: "sufficiency", value: 95, weight: 0.3 },
{ key: "efficiency", value: 90, weight: 0.25 },
{ key: "consistency", value: 70, weight: 0.2 },
{ key: "timing", value: 80, weight: 0.1 },
{ key: "composition", value: 88, weight: 0.15 },
],
windowNights: 20,
};
const recovery: WellnessScoreValue = {
score: 55,
band: "yellow",
trendDelta: -8,
daysInWindow: 12,
asOf: NOW.toISOString(),
series: [60, 58, 55],
};
describe("assessable derived scores", () => {
it("is exactly the five iOS ids", () => {
expect([...ASSESSABLE_DERIVED_SCORES].sort()).toEqual([
"READINESS",
"RECOVERY_SCORE",
"SLEEP_SCORE",
"STRAIN_SCORE",
"STRESS_SCORE",
]);
expect(isAssessableDerivedScore("READINESS")).toBe(true);
expect(isAssessableDerivedScore("BMI")).toBe(false);
});
});
describe("resolveDeterministicAssessment", () => {
it("returns null for a non-assessable metric", () => {
expect(
resolveDeterministicAssessment("BMI", okDerived({ score: 50, band: "green" }), "en", NOW),
).toBeNull();
});
it("returns null when status !== ok (the locked contract)", () => {
expect(
resolveDeterministicAssessment("READINESS", insufficient, "en", NOW),
).toBeNull();
});
it("always fills a non-empty deterministic text for an ok score", () => {
const a = resolveDeterministicAssessment("READINESS", okDerived(readiness), "en", NOW);
expect(a).not.toBeNull();
expect(a!.source).toBe("deterministic");
expect(a!.text.length).toBeGreaterThan(0);
expect(a!.updatedAt).toBe(NOW.toISOString());
});
it("names the score and the weakest contributors for a yellow readiness (en)", () => {
const a = resolveDeterministicAssessment("READINESS", okDerived(readiness), "en", NOW);
expect(a!.text).toContain("64 out of 100");
// rhr (40) is the lowest present contributor → mentioned.
expect(a!.text.toLowerCase()).toContain("resting heart rate");
// never references the null (respiratory) contributor.
expect(a!.text.toLowerCase()).not.toContain("respiratory");
});
it("affirms the strongest contributor for a green sleep score (de)", () => {
const a = resolveDeterministicAssessment("SLEEP_SCORE", okDerived(sleepScore), "de", NOW);
expect(a!.text).toContain("82 von 100");
expect(a!.text).toContain("im guten Bereich");
// sufficiency (95) is the strongest → affirmed for a green band.
expect(a!.text).toContain("Schlafmenge");
});
it("uses the trend for a contributor-less recovery score (en)", () => {
const a = resolveDeterministicAssessment("RECOVERY_SCORE", okDerived(recovery), "en", NOW);
expect(a!.text).toContain("55 out of 100");
// trendDelta -8 → "8 points lower".
expect(a!.text).toContain("−8 points lower");
});
});
describe("buildScoreSignal", () => {
it("carries contributors for READINESS", () => {
const signal = buildScoreSignal("READINESS", readiness, "en");
expect(signal!.current).toBe(64);
expect(signal!.contributors).toHaveLength(5);
expect(signal!.metric).toBe("your readiness");
});
it("derives a signed delta from trendDelta for RECOVERY", () => {
const signal = buildScoreSignal("RECOVERY_SCORE", recovery, "en");
expect(signal!.current).toBe(55);
expect(signal!.baseline).toBe(63); // 55 - (-8)
expect(signal!.delta).toBe(-8);
expect(signal!.direction).toBe("higher-better");
});
it("frames stress/strain as lower-better", () => {
const strain = buildScoreSignal("STRAIN_SCORE", recovery, "en");
expect(strain!.direction).toBe("lower-better");
});
});
@@ -0,0 +1,227 @@
/**
* v1.13.2 — AI-warm layer for the per-derived-SCORE assessment.
*
* The deterministic text (in `derived-assessment.ts`) always fills the field
* synchronously, so a provider-less account and the demo are never empty.
* THIS module adds the warmer AI prose on top, reusing the exact
* stale-while-revalidate plumbing the per-metric status cards use:
*
* - `resolveDerivedAssessment` (the route entry) reads a fresh cached AI
* assessment for today; on a hit it serves it (source = provider type).
* On a miss it serves the deterministic text and, when a provider is
* usable, fire-and-forget enqueues a warm generation out of band — the
* GET never blocks on an LLM round-trip.
* - `generateDerivedScoreAssessment` (the worker entry) builds the
* signal-led prompt, runs the user's provider chain through
* `runStatusCompletion`, and persists the text under the same
* `auditLog` cache row the status cards use.
*
* The cache action is `insights.derived-score:<ID>-status.<locale>` so the
* read, the worker, and the enqueue helper all speak one vocabulary.
*
* Server-only — reads the DB + the provider chain.
*/
import {
getBaseSystemPrompt,
PROMPT_VERSION,
} from "@/lib/ai/prompts/base-system";
import {
normalizeLocale,
normalizeSummaryText,
parseSummaryFromContent,
persistStatusInsight,
type SupportedLocale,
} from "@/lib/insights/status-shared";
import { runStatusCompletion } from "@/lib/insights/status-provider";
import {
readFreshStatusText,
resolveReadOnlyStatusMiss,
statusCacheAction,
} from "@/lib/insights/status-cache";
import { toBerlinDayKey } from "@/lib/tz/resolver";
import { annotate } from "@/lib/logging/context";
import {
buildScoreSignal,
isAssessableDerivedScore,
resolveDeterministicAssessment,
type DerivedAssessment,
} from "./derived-assessment";
import type { Derived } from "./types";
import type { DerivedMetricId } from "./registry";
import type { MetricSignal } from "@/lib/insights/metric-signal";
/** The `metric` scope id this generator warms — `derived-score:<ID>`. */
export function derivedScoreScope(
metric: DerivedMetricId,
): `derived-score:${DerivedMetricId}` {
return `derived-score:${metric}`;
}
/** Read a `derived-score:<ID>` scope back to its `DerivedMetricId`. */
export function parseDerivedScoreScope(scope: string): DerivedMetricId | null {
if (!scope.startsWith("derived-score:")) return null;
return scope.slice("derived-score:".length) as DerivedMetricId;
}
// ── prompt ────────────────────────────────────────────────────────────────
function scoreSystemPrompt(locale: SupportedLocale): string {
const base = getBaseSystemPrompt(locale);
const section =
locale === "en"
? `ARCHETYPE — COMPOSITE WELLNESS SCORE:
- This is a 0–100 composite score, not a raw measurement. Explain WHY it sits where it does by naming the 1–2 contributors in \`signal.contributors[]\` that moved it most — a lower contributor value drags the score down. Each contributor is itself 0–100.
- Lead with the score and its standing, then attribute it to the contributor(s). Do not invent a contributor that is not listed. When there are no contributors, use the trend (signal.delta) instead.
- This is a daily wellness proxy, not a clinical or training-recovery verdict. Stay descriptive, never diagnostic.`
: `ARCHETYP — ZUSAMMENGESETZTER WELLNESS-SCORE:
- Das ist ein 0–100-Kompositscore, keine Rohmessung. Erkläre, WARUM er dort liegt, indem du die 1–2 Beiträge aus \`signal.contributors[]\` nennst, die ihn am stärksten bewegt haben — ein niedriger Beitragswert zieht den Score nach unten. Jeder Beitrag ist selbst 0–100.
- Führe mit dem Score und seiner Einordnung, dann ordne ihn den Beiträgen zu. Erfinde keinen Beitrag, der nicht gelistet ist. Gibt es keine Beiträge, nutze stattdessen den Trend (signal.delta).
- Das ist ein täglicher Wellness-Indikator, kein klinisches Urteil und keine Trainings-Recovery-Bewertung. Bleibe beschreibend, nie diagnostisch.`;
return `${base}\n\n${section}`;
}
function scoreUserPrompt(
signal: MetricSignal,
band: string,
todayKey: string,
locale: SupportedLocale,
): string {
const snapshot = JSON.stringify(
{ promptVersion: PROMPT_VERSION, generatedForDay: todayKey, band, signal },
null,
2,
);
if (locale === "en") {
return `Date: ${todayKey} (Europe/Berlin)
Write one short assessment of why ${signal.metric} is the score it is today: name the score and its standing, then attribute it to the 1–2 contributors that moved it most. Keep it to 2–3 sentences, descriptive only.
${snapshot}`;
}
return `Datum: ${todayKey} (Europe/Berlin)
Schreibe eine kurze Einschätzung, warum ${signal.metric} heute diesen Score hat: nenne den Score und seine Einordnung und ordne ihn dann den 1–2 stärksten Beiträgen zu. Halte dich an 2–3 Sätze, rein beschreibend.
${snapshot}`;
}
// ── read (route entry) ──────────────────────────────────────────────────
/**
* Resolve the per-score assessment for the route. Always returns the
* deterministic text (never empty for an ok score); serves a fresh cached AI
* assessment instead when one exists for today, and warms one out of band on
* a miss. Returns null when the metric is not assessable or status !== "ok".
*/
export async function resolveDerivedAssessment(args: {
metric: DerivedMetricId;
userId: string;
derived: Derived<unknown>;
locale: string | null | undefined;
now?: Date;
}): Promise<DerivedAssessment | null> {
const now = args.now ?? new Date();
const locale = normalizeLocale(args.locale);
const deterministic = resolveDeterministicAssessment(
args.metric,
args.derived,
locale,
now,
);
// Not assessable, or status !== ok → no field (the locked contract).
if (!deterministic) return null;
const scope = derivedScoreScope(args.metric);
const cacheAction = statusCacheAction(scope, locale);
const todayKey = toBerlinDayKey(now);
// Fresh AI text for today → serve it (warmer prose overrides the template).
const fresh = await readFreshStatusText({
userId: args.userId,
cacheAction,
todayKey,
force: false,
});
if (fresh) {
return { text: fresh.text, source: "ai", updatedAt: fresh.updatedAt };
}
// No fresh AI text. Warm one out of band (best-effort, never blocks): the
// read-only resolver enqueues a generation when a provider is usable and
// de-dupes per (user, scope, locale). Serve the deterministic text now; the
// next read upgrades to the warmed prose.
await resolveReadOnlyStatusMiss({
userId: args.userId,
metric: scope,
locale,
});
return deterministic;
}
// ── generate (worker entry) ──────────────────────────────────────────────
/**
* Worker entry: build the signal-led prompt for one derived score, run the
* provider chain, and persist the AI assessment under the shared status-cache
* row. Recomputes the derived value via the passed `derived` so the worker
* runs the same numbers the route did. A no-provider / timeout / error
* outcome simply writes nothing — the route keeps serving the deterministic
* text, so the field is never empty.
*/
export async function generateDerivedScoreAssessment(args: {
metric: DerivedMetricId;
userId: string;
derived: Derived<unknown>;
locale: string | null | undefined;
now?: Date;
}): Promise<void> {
const now = args.now ?? new Date();
const locale = normalizeLocale(args.locale);
if (!isAssessableDerivedScore(args.metric)) return;
if (args.derived.status !== "ok") return;
const signal = buildScoreSignal(args.metric, args.derived.value, locale);
if (!signal) return;
const band =
(args.derived.value as { band?: string }).band ?? "yellow";
const scope = derivedScoreScope(args.metric);
const cacheAction = statusCacheAction(scope, locale);
const todayKey = toBerlinDayKey(now);
const outcome = await runStatusCompletion({
userId: args.userId,
cacheAction,
consentSurface: "insights",
systemPrompt: scoreSystemPrompt(locale),
userPrompt: scoreUserPrompt(signal, band, todayKey, locale),
temperature: 0.45,
maxTokens: 600,
});
if (outcome.kind !== "ok") {
annotate({
action: { name: "insights.derived-assessment.skip" },
meta: { metric: args.metric, reason: outcome.kind },
});
return;
}
const text = normalizeSummaryText(parseSummaryFromContent(outcome.content));
if (!text) return;
await persistStatusInsight({
userId: args.userId,
cacheAction,
todayKey,
locale,
text,
providerType: outcome.providerType,
model: outcome.model,
tokensUsed: outcome.tokensUsed,
});
annotate({
action: { name: "insights.derived-assessment.warmed" },
meta: { metric: args.metric, provider: outcome.providerType },
});
}
@@ -0,0 +1,354 @@
/**
* v1.13.2 — per-derived-SCORE assessment text ("why is this score what it is").
*
* iOS asks for a short "Einschätzung" on each derived-score detail sheet
* (READINESS, SLEEP_SCORE, RECOVERY_SCORE, STRAIN_SCORE, STRESS_SCORE)
* explaining WHY the score sits where it does, referencing the score's
* own contributors. The contract (LOCKED with iOS) is an additive,
* non-breaking field on `GET /api/insights/derived`:
*
* assessment: { text, source, updatedAt } | null
*
* null whenever the metric `status !== "ok"`; otherwise always a non-empty
* `text` (so a provider-less account and the demo always get one). The
* shape follows the v1.12.12 deterministic-narrative model: a deterministic
* fallback composed from the SAME structured signal the model would read —
* never empty — PLUS warmer AI prose when a provider is configured + warm.
*
* `source` is `"deterministic"` for the template text, or the provider type
* (`"openai"`, `"anthropic"`, …) when a cached AI assessment is served.
*
* Pure prose lives here; the AI-warm read + enqueue is a thin layer over the
* existing status cache (the same `auditLog` row pattern the per-metric cards
* use), keyed `insights.derived-score:<ID>-status.<locale>`. de/en only — the
* narratives, like the period narrative, are bilingual by design.
*/
import { buildMetricSignal, type MetricSignal } from "@/lib/insights/metric-signal";
import type { GradedSeries } from "@/lib/insights/graded-series";
import type { ReadinessValue, ReadinessComponentKey } from "./readiness";
import type { SleepScoreValue, SleepSubScoreKey } from "./sleep-score";
import type { WellnessScoreValue } from "./wellness-scores";
import type { Derived } from "./types";
import type { DerivedMetricId } from "./registry";
type Locale = "de" | "en";
/** The additive per-score assessment field on the derived response. */
export interface DerivedAssessment {
/** Non-empty short explanation of why the score is what it is. */
text: string;
/** "deterministic" for the template, or the provider type for AI prose. */
source: string;
/** ISO timestamp the text was produced / last warmed. */
updatedAt: string;
}
/**
* The derived-score ids that carry a per-score assessment. These are the
* five iOS requests; the assessment is keyed to the SAME id the caller
* passed (so READINESS → assessment on READINESS, etc.). Any other derived
* id returns no assessment (the field stays null / absent).
*/
export const ASSESSABLE_DERIVED_SCORES: ReadonlySet<DerivedMetricId> = new Set([
"READINESS",
"SLEEP_SCORE",
"RECOVERY_SCORE",
"STRAIN_SCORE",
"STRESS_SCORE",
]);
/** `true` when the id is one iOS requests a per-score assessment for. */
export function isAssessableDerivedScore(metric: DerivedMetricId): boolean {
return ASSESSABLE_DERIVED_SCORES.has(metric);
}
// ── bilingual labels ────────────────────────────────────────────────────
/** Display label for each assessable score. */
const SCORE_LABELS: Record<string, { de: string; en: string }> = {
READINESS: { de: "deine Tagesform", en: "your readiness" },
SLEEP_SCORE: { de: "dein Schlafscore", en: "your sleep score" },
RECOVERY_SCORE: { de: "deine Erholung", en: "your recovery" },
STRAIN_SCORE: { de: "deine Belastung", en: "your strain" },
STRESS_SCORE: { de: "dein Stress", en: "your stress" },
};
/** Readiness contributor labels (its `components[].key`). */
const READINESS_COMPONENT_LABELS: Record<
ReadinessComponentKey,
{ de: string; en: string }
> = {
rhr: { de: "dein Ruhepuls", en: "your resting heart rate" },
hrv: { de: "deine Herzfrequenzvariabilität", en: "your heart-rate variability" },
sleep: { de: "dein Schlaf", en: "your sleep" },
respiratory: { de: "deine Atemfrequenz", en: "your respiratory rate" },
mood: { de: "deine Stimmung", en: "your mood" },
};
/** Sleep sub-score labels (its `subScores[].key`). */
const SLEEP_SUBSCORE_LABELS: Record<
SleepSubScoreKey,
{ de: string; en: string }
> = {
sufficiency: { de: "die Schlafmenge", en: "sleep duration" },
efficiency: { de: "die Schlafeffizienz", en: "sleep efficiency" },
consistency: { de: "die Regelmäßigkeit", en: "sleep consistency" },
timing: { de: "der Schlafrhythmus", en: "sleep timing" },
composition: { de: "die Schlafphasen", en: "sleep composition" },
};
function scoreLabel(metric: string, locale: Locale): string {
return SCORE_LABELS[metric]?.[locale] ?? (locale === "de" ? "dein Wert" : "your score");
}
/** Band → bilingual standing phrase. */
function bandPhrase(
band: "green" | "yellow" | "red",
locale: Locale,
): string {
if (locale === "de") {
return band === "green"
? "im guten Bereich"
: band === "yellow"
? "im mittleren Bereich"
: "im niedrigen Bereich";
}
return band === "green"
? "in a good place"
: band === "yellow"
? "in the middle"
: "on the low side";
}
// ── signal builders per score ─────────────────────────────────────────────
/**
* Build a one-day graded series from a single score value so a derived
* score (which carries no raw bucket series) can flow through the SAME
* `buildMetricSignal` contract every other surface uses. The recent slice
* holds today's score; an optional trend baseline (score − trendDelta)
* becomes a one-point monthly slice so the signal carries a signed delta.
*/
function singlePointGraded(
score: number,
baseline: number | null,
): GradedSeries {
return {
recent: [{ date: "today", min: score, max: score, mean: score, n: 1 }],
weekly: [],
monthly:
baseline !== null
? [{ month: "baseline", min: baseline, max: baseline, mean: baseline, n: 1 }]
: [],
yearly: [],
};
}
/** Map a derived OK value for one assessable score to its `MetricSignal`. */
export function buildScoreSignal(
metric: DerivedMetricId,
value: unknown,
locale: Locale,
): MetricSignal | null {
const label = scoreLabel(metric, locale);
if (metric === "READINESS") {
const v = value as ReadinessValue;
const signal = buildMetricSignal({
metric: label,
direction: "higher-better",
graded: singlePointGraded(v.score, null),
});
if (!signal) return null;
signal.contributors = v.components.map((c) => ({
key: c.key,
value: c.value,
weight: c.weight,
}));
return signal;
}
if (metric === "SLEEP_SCORE") {
const v = value as SleepScoreValue;
const signal = buildMetricSignal({
metric: label,
direction: "higher-better",
graded: singlePointGraded(v.score, null),
});
if (!signal) return null;
signal.contributors = v.subScores.map((s) => ({
key: s.key,
value: s.value,
weight: s.weight,
}));
return signal;
}
// RECOVERY / STRESS / STRAIN — a persisted composite with a trend delta but
// no component breakdown. The trend baseline (score − trendDelta) gives a
// signed delta; direction follows the metric (recovery higher-better,
// stress/strain lower-better).
const v = value as WellnessScoreValue;
const baseline =
v.trendDelta !== null ? v.score - v.trendDelta : null;
const direction = metric === "RECOVERY_SCORE" ? "higher-better" : "lower-better";
return buildMetricSignal({
metric: label,
direction,
graded: singlePointGraded(v.score, baseline),
});
}
// ── deterministic prose ────────────────────────────────────────────────────
/** Format a signed integer with the typographic minus glyph. */
function fmtSignedInt(n: number): string {
const sign = n > 0 ? "+" : n < 0 ? "−" : "±";
return `${sign}${Math.abs(Math.round(n))}`;
}
/** Rank present contributors by how far they sit from a full 100 (impact). */
function rankContributors(
signal: MetricSignal,
): { key: string; value: number }[] {
return (signal.contributors ?? [])
.filter(
(c): c is { key: string; value: number; weight: number } =>
c.value !== null,
)
.map((c) => ({ key: c.key, value: c.value, weight: c.weight }))
.sort((a, b) => a.value - b.value);
}
function contributorLabel(
metric: DerivedMetricId,
key: string,
locale: Locale,
): string {
if (metric === "READINESS") {
return (
READINESS_COMPONENT_LABELS[key as ReadinessComponentKey]?.[locale] ?? key
);
}
if (metric === "SLEEP_SCORE") {
return SLEEP_SUBSCORE_LABELS[key as SleepSubScoreKey]?.[locale] ?? key;
}
return key;
}
/**
* Compose the deterministic per-score assessment from the score's signal +
* its contributors. Always returns a non-empty, factual, non-causal text.
* Leads with the score + standing, then names the 1–2 lowest contributors
* (the ones holding the score back) or the trend for the contributor-less
* scores.
*/
export function buildDeterministicScoreAssessment(
metric: DerivedMetricId,
signal: MetricSignal,
band: "green" | "yellow" | "red",
locale: Locale,
): string {
const label = scoreLabel(metric, locale);
const score = Math.round(signal.current);
const standing = bandPhrase(band, locale);
const sentences: string[] = [];
sentences.push(
locale === "de"
? `${capitalise(label)} liegt heute bei ${score} von 100 — ${standing}.`
: `${capitalise(label)} is ${score} out of 100 today — ${standing}.`,
);
const ranked = rankContributors(signal);
if (ranked.length > 0) {
// Name the 1–2 weakest contributors (driving the score down) when the
// score is not maxed; affirm the strongest when it is high.
if (band === "green") {
const top = ranked[ranked.length - 1];
const topLabel = contributorLabel(metric, top.key, locale);
sentences.push(
locale === "de"
? `Getragen vor allem von ${topLabel}.`
: `Carried mostly by ${topLabel}.`,
);
} else {
const weak = ranked.slice(0, 2).map((c) =>
contributorLabel(metric, c.key, locale),
);
const joined =
locale === "de"
? joinList(weak, "und")
: joinList(weak, "and");
sentences.push(
locale === "de"
? `Am stärksten gedämpft durch ${joined}.`
: `Held back most by ${joined}.`,
);
}
} else if (signal.delta !== null && signal.delta !== 0) {
// No contributor breakdown (recovery/stress/strain): use the trend.
const dir =
signal.delta > 0
? locale === "de"
? "höher"
: "higher"
: locale === "de"
? "niedriger"
: "lower";
sentences.push(
locale === "de"
? `Das sind ${fmtSignedInt(signal.delta)} Punkte ${dir} als zuletzt.`
: `That is ${fmtSignedInt(signal.delta)} points ${dir} than your recent average.`,
);
} else {
sentences.push(
locale === "de"
? "Das entspricht deinem üblichen Niveau."
: "That is in line with your usual level.",
);
}
return sentences.join(" ");
}
function capitalise(s: string): string {
return s.length === 0 ? s : s[0].toUpperCase() + s.slice(1);
}
function joinList(items: string[], conjunction: string): string {
if (items.length === 0) return "";
if (items.length === 1) return items[0];
return `${items.slice(0, -1).join(", ")} ${conjunction} ${items[items.length - 1]}`;
}
/**
* Resolve the assessment field for a derived metric result. Returns null
* when the metric is not assessable or its status is not `ok` (the locked
* contract: assessment is null unless status === "ok"). Otherwise always a
* non-empty deterministic text; a warmer AI text overrides it when one has
* been cached (see `derived-assessment-ai.ts`).
*/
export function resolveDeterministicAssessment(
metric: DerivedMetricId,
derived: Derived<unknown>,
locale: Locale,
now: Date,
): DerivedAssessment | null {
if (!isAssessableDerivedScore(metric)) return null;
if (derived.status !== "ok") return null;
const signal = buildScoreSignal(metric, derived.value, locale);
if (!signal) return null;
const band = readBand(derived.value);
const text = buildDeterministicScoreAssessment(metric, signal, band, locale);
return {
text,
source: "deterministic",
updatedAt: now.toISOString(),
};
}
/** Read the band off any assessable score value (all carry `band`). */
function readBand(value: unknown): "green" | "yellow" | "red" {
const b = (value as { band?: unknown }).band;
return b === "green" || b === "yellow" || b === "red" ? b : "yellow";
}
+222
View File
@@ -0,0 +1,222 @@
/**
* v1.13.x — the shared metric SIGNAL block.
*
* Every per-metric AI surface today sends the model raw graded buckets and
* an instruction to "read the personal baseline from the means and compare
* the two". That comparison is arithmetic an LLM does unreliably, and the
* significance verdict ("is this inside my normal swing?") has no number to
* anchor to — which is exactly where generic prose leaks in. The fix
* (research-ai-quality.md §4) is a single token-lean, signal-RICH descriptor
* that hands the model the comparison already finished:
*
* metric · current · own baseline · signed delta (+%) · spread /
* normal-swing verdict · coarse normal-range placement · n / recency ·
* (for a composite score) the 1–2 biggest-moving contributors.
*
* This is "surface C" of the audit — the generic per-metric assessment base —
* built first because it is also the per-score-assessment path.
*
* Pure + side-effect-free: it folds data the snapshot builders already hold
* (the graded series, `summarizeSeries`, the registry norms, the profile).
* No DB read, no provider call. The deterministic per-score assessment writer
* and the generic metric-status snapshot both build their signal from here, so
* one builder is the single source of the comparison every surface used to
* re-derive by hand.
*/
import type { GradedSeries } from "@/lib/insights/graded-series";
import type { NormRange } from "@/lib/insights/derived/norms";
/** Favourable-direction framing — mirrors the metric-status registry. */
export type MetricDirection =
| "higher-better"
| "lower-better"
| "target-band";
/** One contributor to a composite score (the Oura move for per-score cards). */
export interface MetricSignalContributor {
/** Stable component key (e.g. "rhr", "sufficiency"). */
key: string;
/** 0..100 component score, or null when the input was missing. */
value: number | null;
/** Effective weight after null-redistribution, 0..1. */
weight: number;
}
/**
* The token-lean signal descriptor for one metric. Every numeric field is
* pre-computed so the model states it rather than re-deriving it. Optional
* fields are omitted (not nulled) when they do not apply, keeping the JSON
* the prompt carries small.
*/
export interface MetricSignal {
/** Natural-language label (no enum leak into prose). */
metric: string;
/** Unit string, when the metric has one. */
unit?: string;
/** Recent-window mean — the headline number (NOT a start/end value). */
current: number;
/** Days the recent window spans. */
currentWindowDays: number;
/** The user's OWN longer baseline (monthly mean, fallback weekly→yearly). */
baseline: number | null;
/** What the baseline IS, in words ("your 90-day average"). */
baselineLabel?: string;
/** Signed `current − baseline`, pre-computed. */
delta: number | null;
/** Signed percent change vs baseline, pre-computed. */
deltaPct: number | null;
/** Spread (SD) of the baseline window — the "normal swing". */
spread: number | null;
/** `|delta| > k·spread` — the significance verdict as a boolean, not a guess. */
outsideNormalSwing: boolean | null;
/** Favourable-direction framing. */
direction: MetricDirection;
/** Coarse population anchor (age/sex when sharpened), optional. */
normalRange?: { low: number; high: number; source?: string };
/** Pre-computed placement vs `normalRange`. */
placement?: "below band" | "in band" | "above band";
/** Sample count backing the recent window. */
n: number;
/** Recency of the newest reading, in days (null when unknown). */
newestDaysAgo: number | null;
/** For composite/derived SCORES only — the 1–2 biggest-moving contributors. */
contributors?: MetricSignalContributor[];
}
/** `k` in `|delta| > k·spread`. One spread of deviation is the finding floor. */
export const NORMAL_SWING_K = 1;
function round(value: number, digits = 1): number {
const factor = 10 ** digits;
return Math.round(value * factor) / factor;
}
/** Mean of a numeric array, or null when empty. */
function meanOf(values: number[]): number | null {
if (values.length === 0) return null;
let sum = 0;
for (const v of values) sum += v;
return sum / values.length;
}
/** Sample standard deviation, or null with fewer than two points. */
function sampleSd(values: number[]): number | null {
if (values.length < 2) return null;
const m = meanOf(values);
if (m === null) return null;
let acc = 0;
for (const v of values) acc += (v - m) ** 2;
return Math.sqrt(acc / (values.length - 1));
}
/** Placement of a value against a coarse normal range. */
export function placeInBand(
value: number,
range: { low: number; high: number },
): "below band" | "in band" | "above band" {
if (value < range.low) return "below band";
if (value > range.high) return "above band";
return "in band";
}
export interface BuildMetricSignalArgs {
/** Natural-language metric label. */
metric: string;
unit?: string;
direction: MetricDirection;
/** The graded snapshot the surface already built. */
graded: GradedSeries;
/** Coarse normal range (sharpened by age/sex upstream), optional. */
normalRange?: NormRange | null;
/** Where the normal range came from, for the `source` annotation. */
normalRangeSource?: string;
/** Newest-reading recency, in days, when known. */
newestDaysAgo?: number | null;
}
/**
* Build the §4.1 signal block from a graded series.
*
* - `current` = mean of the recent daily means (the short window).
* - `baseline` = mean of the monthly means, falling back to weekly then
* yearly when the longer slices are empty (short history). When NO longer
* slice exists the baseline is null and the deltas/verdict are null too —
* an honest "no baseline yet" rather than a fabricated comparison.
* - `spread` = SD of the baseline window's means (the normal swing).
* - `outsideNormalSwing` = `|delta| > k·spread`.
*
* Returns null only when the recent window itself is empty (no current value
* to anchor on) — the caller then skips the signal block entirely.
*/
export function buildMetricSignal(
args: BuildMetricSignalArgs,
): MetricSignal | null {
const { graded } = args;
const recentMeans = graded.recent.map((b) => b.mean);
const current = meanOf(recentMeans);
if (current === null) return null;
const recentN = graded.recent.reduce((s, b) => s + b.n, 0);
// The longer baseline: monthly means first (the audit's canonical own
// baseline), then weekly, then yearly — whichever the history supports.
let baselineMeans: number[] = graded.monthly.map((b) => b.mean);
let baselineLabel = "your monthly average";
if (baselineMeans.length === 0) {
baselineMeans = graded.weekly.map((b) => b.mean);
baselineLabel = "your recent-weeks average";
}
if (baselineMeans.length === 0) {
baselineMeans = graded.yearly.map((b) => b.mean);
baselineLabel = "your long-term average";
}
const baseline = meanOf(baselineMeans);
const spread = sampleSd(baselineMeans);
let delta: number | null = null;
let deltaPct: number | null = null;
let outsideNormalSwing: boolean | null = null;
if (baseline !== null) {
delta = round(current - baseline, 2);
deltaPct = baseline !== 0 ? round(((current - baseline) / baseline) * 100, 1) : null;
if (spread !== null && spread > 0) {
outsideNormalSwing = Math.abs(current - baseline) > NORMAL_SWING_K * spread;
} else {
// No usable spread (a flat or single-point baseline): a non-zero delta
// is the only signal we can honestly give — treat any real change as
// outside the (zero) swing, an exact match as inside it.
outsideNormalSwing = delta !== 0;
}
}
const signal: MetricSignal = {
metric: args.metric,
...(args.unit ? { unit: args.unit } : {}),
current: round(current, 2),
currentWindowDays: graded.recent.length,
baseline: baseline !== null ? round(baseline, 2) : null,
...(baseline !== null ? { baselineLabel } : {}),
delta,
deltaPct,
spread: spread !== null ? round(spread, 2) : null,
outsideNormalSwing,
direction: args.direction,
n: recentN,
newestDaysAgo: args.newestDaysAgo ?? null,
};
if (args.normalRange) {
signal.normalRange = {
low: args.normalRange.low,
high: args.normalRange.high,
...(args.normalRangeSource ? { source: args.normalRangeSource } : {}),
};
signal.placement = placeInBand(round(current, 2), {
low: args.normalRange.low,
high: args.normalRange.high,
});
}
return signal;
}
+19
View File
@@ -44,6 +44,8 @@ import {
import { getRelevantCorrelationsForMetric } from "@/lib/insights/metric-correlation-context";
import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets";
import { lookupNormalRange } from "@/lib/insights/derived/norms";
import { buildMetricSignal } from "@/lib/insights/metric-signal";
import { PROMPT_VERSION } from "@/lib/ai/prompts/base-system";
import { getNoKeyGeneralStatusText } from "@/lib/insights/no-key-fallbacks";
import { applyPayloadBudget } from "@/lib/insights/bucket-series";
import {
@@ -320,8 +322,24 @@ export async function generateMetricStatus(args: {
const sharpenedRange = lookupNormalRange(meta.id, ageYears, sex);
const normalRange = sharpenedRange ?? meta.normalRange;
// v1.13.x — the SIGNAL block: the recent-vs-baseline comparison + the
// normal-swing verdict, computed server-side so the model states it rather
// than re-deriving it from the raw buckets (research-ai-quality.md §4/§5C).
// Built from the same graded series the snapshot already carries; null only
// when the recent window is empty, in which case it is omitted.
const signal = buildMetricSignal({
metric: meta.displayName,
unit: meta.unit,
direction: meta.direction,
graded,
normalRange: normalRange ?? null,
normalRangeSource: sharpenedRange ? "age-sex-adjusted" : undefined,
newestDaysAgo,
});
const snapshot = {
locale,
promptVersion: PROMPT_VERSION,
generatedForDay: todayKey,
focus: meta.id,
metric: {
@@ -345,6 +363,7 @@ export async function generateMetricStatus(args: {
newestMeasurementDaysAgo: newestDaysAgo,
},
[meta.id]: {
...(signal ? { signal } : {}),
summary,
series: graded,
latestDayFocus: latest
+12 -1
View File
@@ -56,7 +56,18 @@ export const INSIGHT_STATUS_METRICS: ReadonlyArray<InsightStatusMetric> = [
* specialised one. A specialised scope is one of `InsightStatusMetric`;
* a generic scope is any `metric:`-prefixed string.
*/
export type InsightStatusScope = InsightStatusMetric | `metric:${string}`;
export type InsightStatusScope =
| InsightStatusMetric
| `metric:${string}`
/**
* v1.13.2 — per-derived-SCORE assessment scopes (READINESS, SLEEP_SCORE,
* RECOVERY_SCORE, STRAIN_SCORE, STRESS_SCORE) ride the same on-demand queue.
* Their scope id is `derived-score:<DERIVED_METRIC_ID>`; the worker routes
* the prefix to the per-score assessment generator. The deterministic text
* always fills the field synchronously — this queue only warms the AI prose
* that overrides it on the next read.
*/
| `derived-score:${string}`;
export interface InsightStatusGeneratePayload {
userId: string;
+40
View File
@@ -36,6 +36,16 @@ import { generateMoodStatusForUser } from "@/lib/insights/mood-status";
import { generateMedicationComplianceStatusForUser } from "@/lib/insights/medication-compliance-status";
import { generateMetricStatus } from "@/lib/insights/metric-status";
import { isMetricStatusId } from "@/lib/insights/metric-status-registry";
import { prisma } from "@/lib/db";
import {
computeDerivedMetric,
loadBaselineProfile,
} from "@/lib/insights/derived";
import {
generateDerivedScoreAssessment,
parseDerivedScoreScope,
} from "@/lib/insights/derived/derived-assessment-ai";
import { isDerivedMetricId } from "@/lib/insights/derived/registry";
import {
INSIGHT_STATUS_GENERATE_QUEUE,
INSIGHT_STATUS_GENERATE_CONCURRENCY,
@@ -105,6 +115,36 @@ export async function runInsightStatusGenerate(
return;
}
// v1.13.2 — a `derived-score:<ID>` scope warms the per-score assessment AI
// prose. The route already filled the deterministic text synchronously; this
// recomputes the derived value and persists the warmer prose so the next
// read upgrades. A score that lost its data resolves to insufficient and the
// generator no-ops without an LLM call.
if (payload.metric.startsWith("derived-score:")) {
const metricId = parseDerivedScoreScope(payload.metric);
if (!metricId || !isDerivedMetricId(metricId)) {
annotate({
action: { name: "insights.status.generate.unknown_metric" },
meta: { metric: payload.metric },
});
return;
}
const profile = await loadBaselineProfile(prisma, payload.userId);
const derived = await computeDerivedMetric({
metric: metricId,
userId: payload.userId,
profile,
type: null,
});
await generateDerivedScoreAssessment({
metric: metricId,
userId: payload.userId,
derived,
locale: payload.locale,
});
return;
}
const generate = generators[payload.metric as InsightStatusMetric];
if (!generate) {
annotate({
+25
View File
@@ -1445,6 +1445,26 @@ const derivedProvenance = z
})
.meta({ id: "DerivedProvenance" });
// v1.13.2 — per-derived-SCORE assessment text. Additive, non-breaking field
// on the derived response; the iOS field-name contract is LOCKED.
const derivedAssessment = z
.object({
text: z
.string()
.describe(
"Short, non-empty explanation of why the score sits where it does, referencing the score's contributors.",
),
source: z
.string()
.describe(
"'deterministic' for the always-on template text, or 'ai' when warmer provider prose has been cached.",
),
updatedAt: z.iso
.datetime({ offset: true })
.describe("When the text was produced / last warmed."),
})
.meta({ id: "DerivedAssessment" });
const derivedMetricResponse = z
.object({
metric: z
@@ -1470,6 +1490,11 @@ const derivedMetricResponse = z
.string()
.nullable()
.describe("Why the value could not be produced; null when status is 'ok'."),
assessment: derivedAssessment
.nullable()
.describe(
"v1.13.2 — short 'why is this score what it is' explanation, keyed to the SAME requested id (only for the per-score ids READINESS, SLEEP_SCORE, RECOVERY_SCORE, STRAIN_SCORE, STRESS_SCORE). Null for any other metric and whenever status !== 'ok'. Always non-empty when present: a deterministic text fills it (so provider-less accounts + the demo always get one) and warmer AI prose overrides it once cached.",
),
})
.meta({
id: "DerivedMetricResponse",