From 30a7437f4de213d267fac84572094100b2496fd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 5 Jun 2026 23:57:03 +0200 Subject: [PATCH] feat(insights): per-score assessment text + shared metric signal block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/api/openapi.yaml | 28 ++ src/app/api/insights/derived/batch/route.ts | 5 + src/app/api/insights/derived/route.ts | 23 ++ .../assessment-prompt-contract.test.ts | 6 +- src/lib/ai/prompts/base-system.ts | 37 +- .../insights/__tests__/metric-signal.test.ts | 146 ++++++++ .../__tests__/derived-assessment.test.ts | 149 ++++++++ .../insights/derived/derived-assessment-ai.ts | 227 +++++++++++ .../insights/derived/derived-assessment.ts | 354 ++++++++++++++++++ src/lib/insights/metric-signal.ts | 222 +++++++++++ src/lib/insights/metric-status.ts | 19 + .../jobs/insight-status-generate-shared.ts | 13 +- src/lib/jobs/insight-status-generate.ts | 40 ++ src/lib/openapi/routes.ts | 25 ++ 14 files changed, 1282 insertions(+), 12 deletions(-) create mode 100644 src/lib/insights/__tests__/metric-signal.test.ts create mode 100644 src/lib/insights/derived/__tests__/derived-assessment.test.ts create mode 100644 src/lib/insights/derived/derived-assessment-ai.ts create mode 100644 src/lib/insights/derived/derived-assessment.ts create mode 100644 src/lib/insights/metric-signal.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index b0120b86a..e658f77b8 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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` 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: diff --git a/src/app/api/insights/derived/batch/route.ts b/src/app/api/insights/derived/batch/route.ts index ea0c8e22d..51d5802b9 100644 --- a/src/app/api/insights/derived/batch/route.ts +++ b/src/app/api/insights/derived/batch/route.ts @@ -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, }, }; }), diff --git a/src/app/api/insights/derived/route.ts b/src/app/api/insights/derived/route.ts index c14536150..0cc883acf 100644 --- a/src/app/api/insights/derived/route.ts +++ b/src/app/api/insights/derived/route.ts @@ -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, }); }); diff --git a/src/lib/ai/prompts/__tests__/assessment-prompt-contract.test.ts b/src/lib/ai/prompts/__tests__/assessment-prompt-contract.test.ts index 8d0271b2c..8f9178570 100644 --- a/src/lib/ai/prompts/__tests__/assessment-prompt-contract.test.ts +++ b/src/lib/ai/prompts/__tests__/assessment-prompt-contract.test.ts @@ -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); } }); diff --git a/src/lib/ai/prompts/base-system.ts b/src/lib/ai/prompts/base-system.ts index 010d312f4..9de0f1ca9 100644 --- a/src/lib/ai/prompts/base-system.ts +++ b/src/lib/ai/prompts/base-system.ts @@ -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.`, }, { diff --git a/src/lib/insights/__tests__/metric-signal.test.ts b/src/lib/insights/__tests__/metric-signal.test.ts new file mode 100644 index 000000000..ef3f2a8ac --- /dev/null +++ b/src/lib/insights/__tests__/metric-signal.test.ts @@ -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 { + 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"); + }); +}); diff --git a/src/lib/insights/derived/__tests__/derived-assessment.test.ts b/src/lib/insights/derived/__tests__/derived-assessment.test.ts new file mode 100644 index 000000000..9aff6f211 --- /dev/null +++ b/src/lib/insights/derived/__tests__/derived-assessment.test.ts @@ -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(value: T): Derived { + 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 = { + 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"); + }); +}); diff --git a/src/lib/insights/derived/derived-assessment-ai.ts b/src/lib/insights/derived/derived-assessment-ai.ts new file mode 100644 index 000000000..b683a50ea --- /dev/null +++ b/src/lib/insights/derived/derived-assessment-ai.ts @@ -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:-status.` 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:`. */ +export function derivedScoreScope( + metric: DerivedMetricId, +): `derived-score:${DerivedMetricId}` { + return `derived-score:${metric}`; +} + +/** Read a `derived-score:` 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; + locale: string | null | undefined; + now?: Date; +}): Promise { + 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; + locale: string | null | undefined; + now?: Date; +}): Promise { + 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 }, + }); +} diff --git a/src/lib/insights/derived/derived-assessment.ts b/src/lib/insights/derived/derived-assessment.ts new file mode 100644 index 000000000..c09c08ae4 --- /dev/null +++ b/src/lib/insights/derived/derived-assessment.ts @@ -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:-status.`. 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 = 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 = { + 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, + 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"; +} diff --git a/src/lib/insights/metric-signal.ts b/src/lib/insights/metric-signal.ts new file mode 100644 index 000000000..6642ad3d4 --- /dev/null +++ b/src/lib/insights/metric-signal.ts @@ -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; +} diff --git a/src/lib/insights/metric-status.ts b/src/lib/insights/metric-status.ts index 4855464d8..631aa5472 100644 --- a/src/lib/insights/metric-status.ts +++ b/src/lib/insights/metric-status.ts @@ -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 diff --git a/src/lib/jobs/insight-status-generate-shared.ts b/src/lib/jobs/insight-status-generate-shared.ts index bbe2a95ad..42ac9c741 100644 --- a/src/lib/jobs/insight-status-generate-shared.ts +++ b/src/lib/jobs/insight-status-generate-shared.ts @@ -56,7 +56,18 @@ export const INSIGHT_STATUS_METRICS: ReadonlyArray = [ * 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:`; 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; diff --git a/src/lib/jobs/insight-status-generate.ts b/src/lib/jobs/insight-status-generate.ts index 9ee562b2a..1ca8d2a28 100644 --- a/src/lib/jobs/insight-status-generate.ts +++ b/src/lib/jobs/insight-status-generate.ts @@ -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:` 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({ diff --git a/src/lib/openapi/routes.ts b/src/lib/openapi/routes.ts index 276e31402..ac8bd49a2 100644 --- a/src/lib/openapi/routes.ts +++ b/src/lib/openapi/routes.ts @@ -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",