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

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

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

Regenerate docs/api/openapi.yaml.

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

No files matched your search

+25
View File
@@ -1445,6 +1445,26 @@ const derivedProvenance = z
})
.meta({ id: "DerivedProvenance" });
// v1.13.2 — per-derived-SCORE assessment text. Additive, non-breaking field
// on the derived response; the iOS field-name contract is LOCKED.
const derivedAssessment = z
.object({
text: z
.string()
.describe(
"Short, non-empty explanation of why the score sits where it does, referencing the score's contributors.",
),
source: z
.string()
.describe(
"'deterministic' for the always-on template text, or 'ai' when warmer provider prose has been cached.",
),
updatedAt: z.iso
.datetime({ offset: true })
.describe("When the text was produced / last warmed."),
})
.meta({ id: "DerivedAssessment" });
const derivedMetricResponse = z
.object({
metric: z
@@ -1470,6 +1490,11 @@ const derivedMetricResponse = z
.string()
.nullable()
.describe("Why the value could not be produced; null when status is 'ok'."),
assessment: derivedAssessment
.nullable()
.describe(
"v1.13.2 — short 'why is this score what it is' explanation, keyed to the SAME requested id (only for the per-score ids READINESS, SLEEP_SCORE, RECOVERY_SCORE, STRAIN_SCORE, STRESS_SCORE). Null for any other metric and whenever status !== 'ok'. Always non-empty when present: a deterministic text fills it (so provider-less accounts + the demo always get one) and warmer AI prose overrides it once cached.",
),
})
.meta({
id: "DerivedMetricResponse",