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

+84
View File
@@ -46,6 +46,7 @@ import {
import { medicationExtractionSchema } from "@/lib/ai/coach/medication-extract-prompt";
import { ACCEPTED_INSIGHTS_TILE_IDS } from "@/lib/insights-layout";
import { exportSelectionSchema } from "@/lib/validations/health-record-export";
import { METRIC_STATUS_IDS } from "@/lib/insights/metric-status-registry";
/**
* Common envelopes — every HealthLog API response wraps payload in
@@ -1097,6 +1098,64 @@ const insightsComprehensiveResponse = z
"AI-generated insights bundle. Strict-schema validated server-side; Coach-routed when the insight surface needs day-level grounding.",
});
// v1.8.7.1 — generic per-HealthKit-metric assessment. The query enum is
// derived from the same registry the route validates against, so the
// spec, the route, and the cache scope cannot drift. The seven
// specialised metrics (weight / blood-pressure / pulse / bmi / mood /
// medication-compliance) keep their own routes and are NOT accepted here.
const metricStatusQuery = z
.object({
metric: z
.enum(METRIC_STATUS_IDS as [string, ...string[]])
.describe(
"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.",
),
locale: z
.enum(["de", "en"])
.optional()
.describe("Optional UI-locale override; defaults to the session locale."),
})
.meta({ id: "MetricStatusQuery" });
const metricStatusResponse = z
.object({
hasProvider: z
.boolean()
.describe(
"False when the user has no usable AI provider — `text` then carries the generic no-key guidance.",
),
text: z
.string()
.nullable()
.describe(
"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: z
.boolean()
.describe("True when `text` is served from cache (incl. last-good)."),
updatedAt: z.iso
.datetime({ offset: true })
.nullable()
.describe("When the served assessment was generated; null when none."),
preparing: z
.boolean()
.optional()
.describe(
"True when a first assessment is being generated out of band and no prior text exists yet — the client polls until it lands.",
),
insufficient: z
.boolean()
.optional()
.describe(
"True when the metric has no readings; no assessment is generated (no LLM call). The card shows its insufficient-data state.",
),
})
.meta({
id: "MetricStatusResponse",
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.",
});
// v1.7.0 — unified dashboard first-paint snapshot. One GET that
// assembles every above-the-fold tile field in a single round-trip.
// Two-phase shape: `tiles` (fast, always present) + `extras` (thick,
@@ -2350,6 +2409,31 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"/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.",
requestParams: {
query: metricStatusQuery,
},
responses: {
"200": {
description: "Assessment envelope (fresh, cached, or preparing).",
content: {
"application/json": {
schema: dataEnvelope(
metricStatusResponse,
"MetricStatusResponseEnvelope",
),
},
},
},
...stdResponses,
},
},
},
};
export const openApiComponents: NonNullable<ZodOpenApiObject["components"]> = {