mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
7db9608c4c
commit
30a7437f4d
14 files changed
+1282
-12
No files matched your search
@@ -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,
|
||||
},
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user