feat(insights): generic per-metric assessment coverage for HealthKit metrics

Extend the assessment-card system beyond the seven specialised scopes to
the ~30 HealthKit metric pages, without hand-writing a prompt per metric.

- Add a metric-metadata registry keyed by HealthKit id (display name,
  unit, direction, optional normal range, archetype, scope), covering the
  data-bearing HealthKit metrics. The seven specialised metrics keep
  their own scopes and are excluded from the generic set; an unregistered
  id gets no card.
- Add a generic data-driven generator that reuses the existing graded
  snapshot, provider runner, stale-while-revalidate cache, and persist
  path. One prompt template per archetype (physiological-vital,
  activity-fitness, body-composition, mobility-gait,
  environmental-exposure) plus a dedicated sleep template, parameterised
  by metric metadata. Empty-data metrics return an insufficient marker
  with no provider call. Normal ranges use age + sex where the profile
  stores them; no ancestry/region ranges.
- Add GET /api/insights/metric-status?metric=<id>: apiHandler-wrapped,
  Zod-validated against the closed registry id set (422 on unknown),
  read-only with stale-while-revalidate. First insights route with
  OpenAPI coverage; regenerate docs/api/openapi.yaml.
- Route the generic metric:<id> scope through the on-demand status queue
  worker dispatch.
- Extend the nightly warm pass to the generic metric caches for a user's
  data-bearing metrics only (one distinct read gates the set), keeping
  de+en warm and never calling a provider for an empty metric.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 41453fbdfee60793c16269b9a9e5f04b6c3ea450)
This commit is contained in:
Marc-André Bombeck committed 2026-06-02 13:26:10 +02:00
1 parent 2717297dae
commit e33911e2d4
14 files changed
+1799 -4

No files matched your search

+128
View File
@@ -1927,6 +1927,78 @@ paths:
"401": *a1
"422": *a2
"429": *a3
/api/insights/metric-status:
get:
tags:
- Insights
summary: Generic per-HealthKit-metric assessment
description: "v1.8.7.1 — data-driven plain-language assessment for any registered HealthKit metric (resting heart rate,
sleep, glucose, body composition, gait, audio exposure, …). One generic route covering ~30 metric pages via
archetype prompt templates + per-metric metadata. Read-only: a cache miss warms a generation out of band and
serves the last-good text meanwhile (stale-while-revalidate). An unknown `metric` 422s against the closed
registry enum. Auth via cookie or Bearer."
parameters:
- in: query
name: metric
schema:
type: string
enum:
- RESTING_HEART_RATE
- HEART_RATE_VARIABILITY
- OXYGEN_SATURATION
- RESPIRATORY_RATE
- BODY_TEMPERATURE
- SKIN_TEMPERATURE
- BLOOD_GLUCOSE
- WALKING_HEART_RATE_AVERAGE
- PULSE_WAVE_VELOCITY
- VASCULAR_AGE
- STEPS
- ACTIVE_ENERGY
- FLIGHTS_CLIMBED
- WALKING_RUNNING_DISTANCE
- TIME_IN_DAYLIGHT
- VO2_MAX
- TOTAL_BODY_WATER
- BONE_MASS
- FAT_FREE_MASS
- FAT_MASS
- MUSCLE_MASS
- LEAN_BODY_MASS
- VISCERAL_FAT
- WALKING_STEADINESS
- WALKING_ASYMMETRY
- WALKING_DOUBLE_SUPPORT
- WALKING_STEP_LENGTH
- WALKING_SPEED
- AUDIO_EXPOSURE_ENV
- AUDIO_EXPOSURE_HEADPHONE
- AUDIO_EXPOSURE_EVENT
- SLEEP_DURATION
description: "HealthKit metric id to assess (e.g. RESTING_HEART_RATE, SLEEP_DURATION). Closed enum: an unknown id 422s.
The seven specialised metrics are served by their own routes and are not accepted here."
required: true
description: "HealthKit metric id to assess (e.g. RESTING_HEART_RATE, SLEEP_DURATION). Closed enum: an unknown id 422s.
The seven specialised metrics are served by their own routes and are not accepted here."
- in: query
name: locale
schema:
description: Optional UI-locale override; defaults to the session locale.
type: string
enum:
- de
- en
description: Optional UI-locale override; defaults to the session locale.
responses:
"200":
description: Assessment envelope (fresh, cached, or preparing).
content:
application/json:
schema:
$ref: "#/components/schemas/MetricStatusResponseEnvelope"
"401": *a1
"422": *a2
"429": *a3
components:
schemas:
PasskeyLoginVerifyRequest:
@@ -6171,6 +6243,62 @@ components:
additionalProperties: false
description: AI-generated insights bundle. Strict-schema validated server-side; Coach-routed when the insight surface
needs day-level grounding.
MetricStatusResponseEnvelope:
type: object
properties:
data:
$ref: "#/components/schemas/MetricStatusResponse"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
MetricStatusResponse:
type: object
properties:
hasProvider:
type: boolean
description: False when the user has no usable AI provider — `text` then carries the generic no-key guidance.
text:
anyOf:
- type: string
- type: "null"
description: The assessment narrative (plain text, rendered as React text children). Null while a first generation is
preparing, or when the metric has insufficient data.
cached:
type: boolean
description: True when `text` is served from cache (incl. last-good).
updatedAt:
anyOf:
- 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)))$
- type: "null"
description: When the served assessment was generated; null when none.
preparing:
description: True when a first assessment is being generated out of band and no prior text exists yet — the client polls
until it lands.
type: boolean
insufficient:
description: True when the metric has no readings; no assessment is generated (no LLM call). The card shows its
insufficient-data state.
type: boolean
required:
- hasProvider
- text
- cached
- updatedAt
additionalProperties: false
description: "Generic per-metric assessment envelope. Identical shape to the seven specialised `*-status` cards so the
`InsightStatusCard` consumes it unchanged. Read-only + stale-while-revalidate: a cache miss warms a generation
out of band and serves the last-good text meanwhile."
MedicationScheduleOutput:
type: object
properties: