From e33911e2d440389a7dc32a6e1dc258be0125f9b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 13:24:11 +0200 Subject: [PATCH] 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=: 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: 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) (cherry picked from commit 41453fbdfee60793c16269b9a9e5f04b6c3ea450) --- docs/api/openapi.yaml | 128 +++++ .../coach-route-gate-inventory.test.ts | 3 + .../metric-status/__tests__/route.test.ts | 127 +++++ src/app/api/insights/metric-status/route.ts | 86 ++++ src/lib/ai/prompts/metric-archetypes.ts | 145 ++++++ .../insights/__tests__/metric-status.test.ts | 218 +++++++++ src/lib/insights/metric-status-registry.ts | 451 ++++++++++++++++++ src/lib/insights/metric-status.ts | 349 ++++++++++++++ src/lib/insights/status-cache.ts | 4 +- .../__tests__/insight-pregenerate.test.ts | 56 +++ src/lib/jobs/insight-pregenerate.ts | 106 ++++ .../jobs/insight-status-generate-shared.ts | 19 +- src/lib/jobs/insight-status-generate.ts | 27 +- src/lib/openapi/routes.ts | 84 ++++ 14 files changed, 1799 insertions(+), 4 deletions(-) create mode 100644 src/app/api/insights/metric-status/__tests__/route.test.ts create mode 100644 src/app/api/insights/metric-status/route.ts create mode 100644 src/lib/ai/prompts/metric-archetypes.ts create mode 100644 src/lib/insights/__tests__/metric-status.test.ts create mode 100644 src/lib/insights/metric-status-registry.ts create mode 100644 src/lib/insights/metric-status.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index cd0f537f7..dfd5188dd 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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: diff --git a/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts b/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts index 669e476c7..f64973294 100644 --- a/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts +++ b/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts @@ -40,6 +40,9 @@ const NON_COACH_GATED_ROUTES: ReadonlyArray = [ "src/app/api/insights/cards/route.ts", "src/app/api/insights/correlations/route.ts", "src/app/api/insights/medication-compliance-status/route.ts", + // v1.8.7.1 — generic per-HealthKit-metric assessment. Gated on the + // same `insightStatus` sub-flag as the seven specialised status routes. + "src/app/api/insights/metric-status/route.ts", "src/app/api/insights/mood-status/route.ts", "src/app/api/insights/pulse-status/route.ts", "src/app/api/insights/weight-status/route.ts", diff --git a/src/app/api/insights/metric-status/__tests__/route.test.ts b/src/app/api/insights/metric-status/__tests__/route.test.ts new file mode 100644 index 000000000..45626b4a1 --- /dev/null +++ b/src/app/api/insights/metric-status/__tests__/route.test.ts @@ -0,0 +1,127 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + appSettings: { findUnique: vi.fn().mockResolvedValue(null) }, + }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +// Stub the heavy generator so the route test stays isolated from the +// real status pipeline. +vi.mock("@/lib/insights/metric-status", () => ({ + generateMetricStatus: vi.fn(async () => ({ + hasProvider: true, + text: "ok", + cached: true, + updatedAt: new Date().toISOString(), + })), + resolveMetricStatusLocale: () => "en", +})); + +import { GET } from "../route"; +import { getSession } from "@/lib/auth/session"; +import { prisma } from "@/lib/db"; +import { generateMetricStatus } from "@/lib/insights/metric-status"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "marc", role: "USER" as const, locale: "en" }, +}; + +const callGet = GET as unknown as (req: NextRequest) => Promise; +function makeReq(metric?: string): NextRequest { + const url = new URL("http://localhost/api/insights/metric-status"); + if (metric !== undefined) url.searchParams.set("metric", metric); + return new NextRequest(url); +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never); +}); + +describe("GET /api/insights/metric-status", () => { + it("returns 200 + envelope for a valid registered metric", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await callGet(makeReq("RESTING_HEART_RATE")); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { text: string; hasProvider: boolean } | null; + error: string | null; + }; + expect(body.error).toBeNull(); + expect(body.data?.text).toBe("ok"); + expect(generateMetricStatus).toHaveBeenCalledWith( + expect.objectContaining({ + metric: "RESTING_HEART_RATE", + userId: "user-1", + readOnly: true, + }), + ); + }); + + it("422s on an unknown metric without calling the generator", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await callGet(makeReq("NOT_A_METRIC")); + expect(res.status).toBe(422); + expect(generateMetricStatus).not.toHaveBeenCalled(); + }); + + it("422s on a missing metric param", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await callGet(makeReq()); + expect(res.status).toBe(422); + expect(generateMetricStatus).not.toHaveBeenCalled(); + }); + + it("422s when a specialised metric is requested through the generic route", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await callGet(makeReq("WEIGHT")); + expect(res.status).toBe(422); + expect(generateMetricStatus).not.toHaveBeenCalled(); + }); + + it("401s when unauthenticated", async () => { + vi.mocked(getSession).mockResolvedValue(null); + const res = await callGet(makeReq("SLEEP_DURATION")); + expect(res.status).toBe(401); + }); + + it("403s + errorCode when insightStatus is disabled", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({ + assistantEnabled: true, + assistantCoachEnabled: true, + assistantBriefingEnabled: true, + assistantInsightStatusEnabled: false, + assistantCorrelationsEnabled: true, + assistantHealthScoreExplainerEnabled: true, + } as never); + const res = await callGet(makeReq("SLEEP_DURATION")); + expect(res.status).toBe(403); + const body = (await res.json()) as { meta?: { errorCode?: string } }; + expect(body.meta?.errorCode).toBe("assistant.disabled.insightStatus"); + }); +}); diff --git a/src/app/api/insights/metric-status/route.ts b/src/app/api/insights/metric-status/route.ts new file mode 100644 index 000000000..3887c1eca --- /dev/null +++ b/src/app/api/insights/metric-status/route.ts @@ -0,0 +1,86 @@ +/** + * v1.8.7.1 — generic per-HealthKit-metric assessment route. + * + * `GET /api/insights/metric-status?metric=` serves the + * data-driven assessment for any metric registered in + * `metric-status-registry.ts` (RESTING_HEART_RATE, SLEEP_DURATION, …). + * The seven specialised metrics keep their own `*-status` routes; this + * single generic route covers the ~30 HealthKit pages without one route + * per metric. + * + * Read-only by construction (v1.8.3 freeze fix): a cache miss enqueues an + * out-of-band generation and serves the last-good (stale-while-revalidate) + * text, never blocking on the provider. An unknown `metric` 422s via the + * closed registry enum. + * + * Unlike the seven legacy `*-status` routes, this one carries OpenAPI + * coverage (the route schema lives in `src/lib/openapi/routes.ts`), + * establishing the pattern the legacy routes can be backfilled onto later. + */ +import { NextRequest } from "next/server"; +import { z } from "zod/v4"; +import { + apiSuccess, + returnAllZodIssues, +} from "@/lib/api-response"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { resolveServerLocale } from "@/lib/i18n/server-locale"; +import { requireAssistantSurface } from "@/lib/feature-flags"; +import { + generateMetricStatus, + resolveMetricStatusLocale, +} from "@/lib/insights/metric-status"; +import { + METRIC_STATUS_IDS, + type MetricStatusMetricId, +} from "@/lib/insights/metric-status-registry"; + +export const dynamic = "force-dynamic"; + +// Closed enum derived from the registry so the route + registry cannot +// drift — an id added to the registry is accepted automatically, and an +// unknown id 422s rather than silently resolving to no card. +const metricQuerySchema = z.object({ + metric: z.enum(METRIC_STATUS_IDS as [string, ...string[]]), +}); + +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + await requireAssistantSurface("insightStatus"); + + const parsed = metricQuerySchema.safeParse({ + metric: request.nextUrl.searchParams.get("metric"), + }); + if (!parsed.success) { + annotate({ + action: { name: "insights.metric-status.invalid-metric" }, + meta: { issue_count: parsed.error.issues.length }, + }); + return returnAllZodIssues(parsed.error, 422); + } + const metric = parsed.data.metric as MetricStatusMetricId; + + const localeParam = request.nextUrl.searchParams.get("locale"); + const resolved = await resolveServerLocale({ + request, + userLocale: user.locale ?? null, + override: localeParam, + }); + const locale = resolveMetricStatusLocale(resolved); + + const result = await generateMetricStatus({ + metric, + userId: user.id, + locale, + force: false, + readOnly: true, + }); + + annotate({ + action: { name: "insights.metric-status" }, + meta: { metric }, + }); + + return apiSuccess(result); +}); diff --git a/src/lib/ai/prompts/metric-archetypes.ts b/src/lib/ai/prompts/metric-archetypes.ts new file mode 100644 index 000000000..ef026b622 --- /dev/null +++ b/src/lib/ai/prompts/metric-archetypes.ts @@ -0,0 +1,145 @@ +/** + * v1.8.7.1 — archetype prompt templates for the generic per-metric + * assessment generator. + * + * The seven specialised status cards each ship a bespoke + * `src/lib/ai/prompts/.ts`. Hand-writing ~30 more for the + * HealthKit metric pages is not viable, so this module parameterises one + * template per archetype (five) plus a dedicated `sleep` template. Each + * template is driven entirely by the metric metadata (display name, + * unit, direction, normal range) + the same graded snapshot shape the + * specialised cards send, so a new metric in the registry produces a + * grounded assessment without a new prompt. + * + * Every template extends `getBaseSystemPrompt` (the shared "one short, + * grounded, warm assessment paragraph → { summary }" contract) and adds + * a per-archetype section describing how to read THAT family of metrics + * (a vital is a point-in-time reading placed against a normal range; an + * activity metric is a cumulative daily count, mostly higher-better; a + * mobility metric is a stability signal; …). The user prompt then ships + * the snapshot JSON + the metric metadata + the optional previous-context + * block, exactly like the specialised generators. + */ +import type { Locale } from "@/lib/i18n/config"; +import { getBaseSystemPrompt } from "./base-system"; +import type { + MetricArchetype, + MetricStatusMeta, +} from "@/lib/insights/metric-status-registry"; + +function directionPhrase( + meta: MetricStatusMeta, + locale: Locale, +): string { + if (locale === "en") { + switch (meta.direction) { + case "higher-better": + return "Higher values are generally more favourable for this metric."; + case "lower-better": + return "Lower values are generally more favourable for this metric."; + case "target-band": + return "Neither extreme is good — the value should sit inside its normal band; deviations in either direction are worth noting."; + } + } + switch (meta.direction) { + case "higher-better": + return "Höhere Werte sind bei dieser Metrik tendenziell günstiger."; + case "lower-better": + return "Niedrigere Werte sind bei dieser Metrik tendenziell günstiger."; + case "target-band": + return "Kein Extrem ist gut — der Wert sollte im Normalband liegen; Abweichungen in beide Richtungen sind erwähnenswert."; + } +} + +/** + * Render the metric-metadata block both the system section and the user + * prompt reference. Kept terse — the snapshot carries the numbers, this + * just tells the model what it is looking at. + */ +function metaBlock(meta: MetricStatusMeta, locale: Locale): string { + const range = meta.normalRange + ? locale === "en" + ? `Coarse population reference band: ${meta.normalRange.low}–${meta.normalRange.high} ${meta.unit} (a placement aid only — the user's OWN baseline leads).` + : `Grobes Referenzband (Population): ${meta.normalRange.low}–${meta.normalRange.high} ${meta.unit} (nur Orientierung — die EIGENE Baseline führt).` + : locale === "en" + ? "No fixed population band applies — judge purely against the user's own baseline." + : "Kein festes Referenzband — beurteile rein gegen die eigene Baseline der Person."; + if (locale === "en") { + return [ + `METRIC: ${meta.displayName} (unit: ${meta.unit}).`, + directionPhrase(meta, locale), + range, + ].join("\n"); + } + return [ + `METRIK: ${meta.displayName} (Einheit: ${meta.unit}).`, + directionPhrase(meta, locale), + range, + ].join("\n"); +} + +const ARCHETYPE_SECTION: Record< + MetricArchetype, + { de: string; en: string } +> = { + "physiological-vital": { + en: "ARCHETYPE — PHYSIOLOGICAL VITAL:\n- This is a point-in-time physiological reading. Name the recent level, place it against both the user's own baseline (recent vs monthly/yearly mean) and, when a reference band is given, that band.\n- A single out-of-band reading is rarely a finding; a sustained shift across the recent window against the user's baseline is. Note an out-of-band trend without alarm.\n- Frame any direction (rising/falling) as a possible explanation (fitness, stress, sleep, illness, medication), never a diagnosis.", + de: "ARCHETYP — PHYSIOLOGISCHER VITALWERT:\n- Das ist ein Momentanwert. Benenne das aktuelle Niveau und ordne es sowohl gegen die eigene Baseline (recent vs. Monats-/Jahresmittel) als auch — falls ein Referenzband vorliegt — gegen dieses Band ein.\n- Ein einzelner Wert außerhalb des Bands ist selten ein Befund; eine anhaltende Verschiebung über das recent-Fenster gegen die Baseline schon. Erwähne einen Trend außerhalb des Bands sachlich, ohne zu alarmieren.\n- Formuliere jede Richtung (steigend/fallend) als mögliche Erklärung (Fitness, Stress, Schlaf, Krankheit, Medikament), nie als Diagnose.", + }, + "activity-fitness": { + en: "ARCHETYPE — ACTIVITY / FITNESS:\n- This is a daily activity or fitness measure, mostly higher-better. Read the recent daily mean and compare it to the user's own weekly/monthly baseline — is their activity up, down, or steady?\n- Day-to-day swings are expected; report the multi-day trend, not a single low or high day. When a reference band is given, use it as a coarse target, not a verdict.\n- Close with one doable, specific step toward more (or sustained) movement — never the generic 'exercise more' platitude.", + de: "ARCHETYP — AKTIVITÄT / FITNESS:\n- Das ist ein tägliches Aktivitäts- oder Fitnessmaß, meist höher = besser. Lies das recent-Tagesmittel und vergleiche es mit der eigenen Wochen-/Monats-Baseline — ist die Aktivität gestiegen, gesunken oder stabil?\n- Tagesschwankungen sind normal; melde den Mehrtagestrend, nicht einen einzelnen niedrigen oder hohen Tag. Ein Referenzband ist ein grobes Ziel, kein Urteil.\n- Schließe mit einem machbaren, konkreten Schritt für mehr (oder gleichbleibende) Bewegung — nie die generische Floskel 'mehr Bewegung'.", + }, + "body-composition": { + en: "ARCHETYPE — BODY COMPOSITION:\n- This is a body-composition mass (or rating). A healthy absolute value depends entirely on body size, so judge the TREND toward or away from the user's own baseline, not an absolute number.\n- Report a sustained directional change across weeks/months; single readings carry scale + hydration noise. Tie it to weight context only if the snapshot supports it.\n- Close with one step that supports the favourable direction for THIS metric.", + de: "ARCHETYP — KÖRPERZUSAMMENSETZUNG:\n- Das ist eine Körperzusammensetzungs-Masse (oder Bewertung). Ein gesunder Absolutwert hängt stark von der Körpergröße ab — beurteile den TREND zur bzw. weg von der eigenen Baseline, nicht eine Absolutzahl.\n- Melde eine anhaltende Richtungsänderung über Wochen/Monate; Einzelwerte tragen Waagen- und Hydrationsrauschen. Stelle einen Gewichtsbezug nur her, wenn der Snapshot ihn stützt.\n- Schließe mit einem Schritt, der die für DIESE Metrik günstige Richtung unterstützt.", + }, + "mobility-gait": { + en: "ARCHETYPE — MOBILITY / GAIT:\n- This is a gait/mobility-stability signal Apple surfaces in its Mobility section. Its value is as a stability + anomaly flag: a steady reading near the user's baseline is reassuring; a sustained drift in the unfavourable direction is the signal to surface.\n- These metrics move slowly; one off day is noise. Only flag a multi-week drift, and frame it as a mobility observation, never a fall-risk diagnosis.\n- If steady, say so plainly. If drifting unfavourably, give one gentle, doable step.", + de: "ARCHETYP — MOBILITÄT / GANG:\n- Das ist ein Gang-/Mobilitäts-Stabilitätssignal aus Apples Mobilitätsbereich. Sein Wert liegt als Stabilitäts- und Anomalie-Flag: ein stabiler Wert nahe der Baseline beruhigt; eine anhaltende Drift in die ungünstige Richtung ist das Signal.\n- Diese Werte ändern sich langsam; ein Ausreißertag ist Rauschen. Melde nur eine mehrwöchige Drift und formuliere sie als Mobilitätsbeobachtung, nie als Sturzrisiko-Diagnose.\n- Ist es stabil, sage das klar. Driftet es ungünstig, gib einen sanften, machbaren Schritt.", + }, + "environmental-exposure": { + en: "ARCHETYPE — ENVIRONMENTAL EXPOSURE:\n- This is an environmental exposure (sound level / loud-event count), lower-better. The reference band is a threshold (e.g. WHO 80 dBA) — recent values above it sustained across days are the finding worth surfacing.\n- A single loud concert or flight is not a pattern. Report repeated above-threshold exposure, not an isolated spike.\n- Close with one practical step to reduce exposure (e.g. lower the listening volume, take a quiet break) — framed as protective, never alarming.", + de: "ARCHETYP — UMWELT-EXPOSITION:\n- Das ist eine Umwelt-Exposition (Schallpegel / Anzahl lauter Ereignisse), niedriger = besser. Das Referenzband ist ein Schwellenwert (z.B. WHO 80 dBA) — recent-Werte, die ihn über mehrere Tage überschreiten, sind der erwähnenswerte Befund.\n- Ein einzelnes lautes Konzert oder ein Flug ist kein Muster. Melde wiederholte Überschreitungen, nicht einen Einzelausschlag.\n- Schließe mit einem praktischen Schritt zur Reduktion (z.B. Hörlautstärke senken, eine ruhige Pause) — schützend formuliert, nie alarmierend.", + }, + sleep: { + en: "ARCHETYPE — SLEEP:\n- This is nightly sleep duration. The snapshot's values are in minutes; convert to hours for your prose (e.g. 450 min = 7.5 h). Assess both the typical duration and its CONSISTENCY: a stable ~7–9 h is the favourable pattern; high night-to-night variability is itself worth naming even when the mean looks fine.\n- Compare the recent nights to the user's own weekly/monthly baseline. A reference band of 7–9 h is a coarse adult guide, not a verdict — short or long sleepers vary.\n- Close with one doable step toward more consistent or sufficient sleep — never the forbidden 'get enough sleep' platitude; be specific (e.g. a steadier wind-down time).", + de: "ARCHETYP — SCHLAF:\n- Das ist die nächtliche Schlafdauer. Die Snapshot-Werte sind in Minuten; rechne für deinen Text in Stunden um (z.B. 450 min = 7,5 h). Bewerte sowohl die typische Dauer als auch ihre KONSTANZ: stabile ~7–9 h sind das günstige Muster; hohe Schwankung von Nacht zu Nacht ist selbst dann erwähnenswert, wenn der Mittelwert passt.\n- Vergleiche die jüngsten Nächte mit der eigenen Wochen-/Monats-Baseline. Ein Referenzband von 7–9 h ist eine grobe Erwachsenen-Orientierung, kein Urteil — Kurz- und Langschläfer variieren.\n- Schließe mit einem machbaren Schritt zu konstanterem oder ausreichenderem Schlaf — nie die verbotene Floskel 'achte auf ausreichend Schlaf'; sei konkret (z.B. eine festere Einschlafzeit).", + }, +}; + +export function getMetricArchetypeSystemPrompt( + meta: MetricStatusMeta, + locale: Locale, +): string { + const section = ARCHETYPE_SECTION[meta.archetype]; + const archetypeText = locale === "en" ? section.en : section.de; + return `${getBaseSystemPrompt(locale)} + +${archetypeText} + +${metaBlock(meta, locale)}`; +} + +export function getMetricArchetypeUserPrompt( + meta: MetricStatusMeta, + snapshotJson: string, + todayKey: string, + locale: Locale, + previousContextBlock?: string, +): string { + const ctxBlock = + previousContextBlock && previousContextBlock.trim().length > 0 + ? `\n\n${previousContextBlock}\n` + : ""; + if (locale === "en") { + return `Date: ${todayKey} (Europe/Berlin) +Write one short assessment of this person's ${meta.displayName.toLowerCase()}: name the current level with a concrete number from the snapshot, place the recent window against their own weekly/monthly baseline, and close with one doable step. Judge confidence from the measurement count and recency.${ctxBlock} + +${snapshotJson}`; + } + return `Datum: ${todayKey} (Europe/Berlin) +Schreibe eine kurze Einschätzung zu ${meta.displayName} dieser Person: benenne das aktuelle Niveau mit einer konkreten Zahl aus dem Snapshot, ordne das recent-Fenster gegen die eigene Wochen-/Monats-Baseline ein und schließe mit einem machbaren Schritt. Konfidenz aus Messanzahl und Aktualität ableiten.${ctxBlock} + +${snapshotJson}`; +} diff --git a/src/lib/insights/__tests__/metric-status.test.ts b/src/lib/insights/__tests__/metric-status.test.ts new file mode 100644 index 000000000..db98c42ec --- /dev/null +++ b/src/lib/insights/__tests__/metric-status.test.ts @@ -0,0 +1,218 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { findUnique: vi.fn() }, + auditLog: { findFirst: vi.fn(), create: vi.fn() }, + measurement: { findMany: vi.fn(), count: vi.fn() }, + measurementRollup: { findMany: vi.fn() }, + }, +})); + +vi.mock("@/lib/insights/status-provider", () => ({ + runStatusCompletion: vi.fn(), +})); + +vi.mock("@/lib/insights/memory", () => ({ + getPreviousInsightContext: vi.fn().mockResolvedValue(null), + formatPreviousContextForPrompt: vi.fn().mockReturnValue(""), +})); + +import { prisma } from "@/lib/db"; +import { runStatusCompletion } from "@/lib/insights/status-provider"; +import { generateMetricStatus } from "../metric-status"; +import { + getMetricArchetypeSystemPrompt, + getMetricArchetypeUserPrompt, +} from "@/lib/ai/prompts/metric-archetypes"; +import { + getMetricStatusMeta, + metricStatusScope, + METRIC_STATUS_IDS, +} from "../metric-status-registry"; + +const dayMs = 24 * 60 * 60 * 1000; + +function stubCompletion( + content: string, + capture?: { systemPrompt: string | null; userPrompt: string | null }, +) { + vi.mocked(runStatusCompletion).mockImplementation( + async (args: { systemPrompt: string; userPrompt: string }) => { + if (capture) { + capture.systemPrompt = args.systemPrompt; + capture.userPrompt = args.userPrompt; + } + return { + kind: "ok", + content, + providerType: "anthropic", + model: "x", + tokensUsed: 1, + } as never; + }, + ); +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue([] as never); +}); + +describe("metric-status registry", () => { + it("excludes the seven specialised metrics from the generic set", () => { + for (const excluded of [ + "WEIGHT", + "BLOOD_PRESSURE_SYS", + "BLOOD_PRESSURE_DIA", + "PULSE", + "BMI", + "MOOD", + "MEDICATION", + ]) { + expect(METRIC_STATUS_IDS).not.toContain(excluded); + } + }); + + it("scope id carries the metric: prefix and (with -status suffix) keeps the eviction substring", () => { + expect(metricStatusScope("RESTING_HEART_RATE")).toBe( + "metric:RESTING_HEART_RATE", + ); + // The cache action appends `-status.`; the eviction sweep + // matches on the `-status.` substring, so a generic scope is swept too. + expect( + `insights.${metricStatusScope("SLEEP_DURATION")}-status.de`, + ).toContain("-status."); + }); + + it("maps STEPS / ACTIVE_ENERGY ids onto their divergent MeasurementType", () => { + expect(getMetricStatusMeta("STEPS")?.measurementType).toBe( + "ACTIVITY_STEPS", + ); + expect(getMetricStatusMeta("ACTIVE_ENERGY")?.measurementType).toBe( + "ACTIVE_ENERGY_BURNED", + ); + }); +}); + +describe("archetype prompt templates", () => { + it("injects the metric metadata + normal range into the system prompt", () => { + const meta = getMetricStatusMeta("OXYGEN_SATURATION")!; + const sys = getMetricArchetypeSystemPrompt(meta, "en"); + expect(sys).toContain("Blood oxygen"); + expect(sys).toContain("95"); + expect(sys).toContain("PHYSIOLOGICAL VITAL"); + }); + + it("uses the dedicated sleep archetype for SLEEP_DURATION", () => { + const meta = getMetricStatusMeta("SLEEP_DURATION")!; + const sys = getMetricArchetypeSystemPrompt(meta, "en"); + expect(sys).toContain("SLEEP"); + expect(getMetricArchetypeUserPrompt(meta, "{}", "2026-06-02", "en")).toContain( + "sleep duration", + ); + }); +}); + +describe("generateMetricStatus — empty-data guard", () => { + it("returns insufficient WITHOUT calling the provider when the metric has no data", async () => { + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); + vi.mocked(prisma.measurement.count).mockResolvedValue(0 as never); + + const result = await generateMetricStatus({ + metric: "RESTING_HEART_RATE", + userId: "user-1", + locale: "en", + readOnly: true, + }); + + expect(result.insufficient).toBe(true); + expect(result.text).toBeNull(); + expect(runStatusCompletion).not.toHaveBeenCalled(); + // No raw read either — the guard short-circuits before the gather. + expect(prisma.measurement.findMany).not.toHaveBeenCalled(); + }); +}); + +describe("generateMetricStatus — generation path", () => { + it("builds a graded snapshot, runs the archetype completion, and persists", async () => { + const now = new Date(); + const records: Array<{ value: number; measuredAt: Date }> = []; + for (let day = 0; day < 400; day++) { + records.push({ + value: 55 + (day % 6), + measuredAt: new Date(now.getTime() - day * dayMs), + }); + } + + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); + vi.mocked(prisma.measurement.count).mockResolvedValue(400 as never); + vi.mocked(prisma.measurement.findMany).mockResolvedValue(records as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dateOfBirth: null, + gender: null, + } as never); + vi.mocked(prisma.auditLog.create).mockResolvedValue({ + createdAt: now, + } as never); + + const captured = { systemPrompt: null, userPrompt: null } as { + systemPrompt: string | null; + userPrompt: string | null; + }; + stubCompletion('{"summary":"Your resting heart rate is steady."}', captured); + + const result = await generateMetricStatus({ + metric: "RESTING_HEART_RATE", + userId: "user-1", + locale: "en", + }); + + expect(runStatusCompletion).toHaveBeenCalledTimes(1); + expect(result.text).toBe("Your resting heart rate is steady."); + expect(result.cached).toBe(false); + expect(result.hasProvider).toBe(true); + + // The snapshot embeds the graded series, not a raw daily array. + const match = captured.userPrompt!.match(/\{[\s\S]*\}/); + const snapshot = JSON.parse(match![0]); + expect(snapshot.RESTING_HEART_RATE.series).toHaveProperty("recent"); + expect(snapshot.metric.unit).toBe("bpm"); + + // Persisted under the generic scope cache action. + const createCall = vi.mocked(prisma.auditLog.create).mock.calls[0][0] as { + data: { action: string; details: string }; + }; + expect(createCall.data.action).toBe( + "insights.metric:RESTING_HEART_RATE-status.en", + ); + }); + + it("strips chart tokens from the persisted text", async () => { + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); + vi.mocked(prisma.measurement.count).mockResolvedValue(3 as never); + vi.mocked(prisma.measurement.findMany).mockResolvedValue([ + { value: 58, measuredAt: new Date() }, + ] as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dateOfBirth: null, + gender: null, + } as never); + vi.mocked(prisma.auditLog.create).mockResolvedValue({ + createdAt: new Date(), + } as never); + + stubCompletion( + '{"summary":"Steady. metric:RESTING_HEART_RATE Good baseline."}', + ); + + const result = await generateMetricStatus({ + metric: "RESTING_HEART_RATE", + userId: "user-1", + locale: "en", + }); + + expect(result.text).not.toContain("metric:"); + expect(result.text).toContain("Steady."); + }); +}); diff --git a/src/lib/insights/metric-status-registry.ts b/src/lib/insights/metric-status-registry.ts new file mode 100644 index 000000000..122648448 --- /dev/null +++ b/src/lib/insights/metric-status-registry.ts @@ -0,0 +1,451 @@ +/** + * v1.8.7.1 — generic per-metric assessment registry. + * + * The seven specialised assessment scopes (blood-pressure / pulse / + * weight / bmi / mood / medication-compliance / general) each ship a + * bespoke generator + prompt. The ~30 HealthKit metric pages + * (`HealthKitMetricPage`) carried charts but no plain-language + * assessment, because hand-writing 30 more prompts is not viable. + * + * This registry is the data-driven alternative: each HealthKit metric is + * described by the small amount of metadata a generic generator needs to + * assess it — a display name, a unit, the direction a "good" value moves, + * an optional clinical/consumer normal range, and an archetype that + * selects one of six shared prompt templates. A metric with no entry here + * simply gets no assessment card (the safe default the spec calls for). + * + * Metric ids are the existing `InsightMetric` HealthKit identifiers + * (WEIGHT, RESTING_HEART_RATE, SLEEP_DURATION, …) so the UI contract, + * the route query param, and the cache scope all speak one vocabulary. + * Internally each id maps to the `MeasurementType` the rollup tier and + * the raw `Measurement` rows are keyed by (a handful differ — `STEPS` + * is stored as `ACTIVITY_STEPS`, `ACTIVE_ENERGY` as `ACTIVE_ENERGY_BURNED`). + * + * The seven specialised metrics (WEIGHT, BLOOD_PRESSURE_*, PULSE, BMI, + * MOOD, MEDICATION) are intentionally NOT registered here — they keep + * their existing dedicated scopes and are never routed through the + * generic path. + * + * Medical note (design §"Medical note"): normal ranges are population + * anchors a generic consumer/clinical guideline supports, used only as a + * coarse placement aid. The generator's primary signal is the user's OWN + * baseline (the graded series), and where the profile stores age + sex + * (`dateOfBirth` + `gender`) the generator threads them so the model can + * sanity-check the placement. NO ancestry/region-of-birth ranges — that + * is medically contentious and a data category HealthLog does not + * collect. + */ +import type { MeasurementType } from "@/generated/prisma/client"; +import type { InsightMetric } from "@/lib/insights/metric-availability"; + +/** The five shared archetypes plus the dedicated sleep template. */ +export type MetricArchetype = + | "physiological-vital" + | "activity-fitness" + | "body-composition" + | "mobility-gait" + | "environmental-exposure" + | "sleep"; + +/** + * Direction a favourable value moves. `target-band` means neither + * extreme is good — the value should sit inside `normalRange`. + */ +export type MetricDirection = "higher-better" | "lower-better" | "target-band"; + +export interface MetricNormalRange { + low: number; + high: number; +} + +export interface MetricStatusMeta { + /** The HealthKit metric id — the public route param + cache scope. */ + id: MetricStatusMetricId; + /** The DB `MeasurementType` the rollup tier + raw reads are keyed by. */ + measurementType: MeasurementType; + /** Stable English display name (the model localises its prose itself). */ + displayName: string; + /** Canonical storage unit (matches the DB column semantics). */ + unit: string; + direction: MetricDirection; + /** Coarse population placement anchor; the user's own baseline leads. */ + normalRange?: MetricNormalRange; + archetype: MetricArchetype; +} + +/** + * The metric ids the generic assessment path covers. A strict subset of + * `InsightMetric` — the seven specialised metrics and the event-driven + * MOOD / MEDICATION / WORKOUTS keys are excluded by construction. + */ +export type MetricStatusMetricId = + | "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"; + +/** + * The registry. Keyed by the HealthKit metric id. Each entry's + * `measurementType` is the DB enum the rollup tier reads against. + * + * Normal ranges are coarse consumer/clinical anchors (resting-pulse + * 60–100 bpm, SpO2 95–100 %, …) — present only where a broadly-accepted + * placement exists. Body-composition mass metrics carry no fixed band: + * a healthy fat-free mass depends entirely on body size, so the + * `target-band` direction defers wholly to the user's own baseline. + */ +const REGISTRY: Record = { + // ── physiological-vital ── + RESTING_HEART_RATE: { + id: "RESTING_HEART_RATE", + measurementType: "RESTING_HEART_RATE", + displayName: "Resting heart rate", + unit: "bpm", + direction: "lower-better", + normalRange: { low: 50, high: 100 }, + archetype: "physiological-vital", + }, + HEART_RATE_VARIABILITY: { + id: "HEART_RATE_VARIABILITY", + measurementType: "HEART_RATE_VARIABILITY", + displayName: "Heart-rate variability (SDNN)", + unit: "ms", + direction: "higher-better", + archetype: "physiological-vital", + }, + OXYGEN_SATURATION: { + id: "OXYGEN_SATURATION", + measurementType: "OXYGEN_SATURATION", + displayName: "Blood oxygen (SpO₂)", + unit: "%", + direction: "higher-better", + normalRange: { low: 95, high: 100 }, + archetype: "physiological-vital", + }, + RESPIRATORY_RATE: { + id: "RESPIRATORY_RATE", + measurementType: "RESPIRATORY_RATE", + displayName: "Respiratory rate", + unit: "breaths/min", + direction: "target-band", + normalRange: { low: 12, high: 20 }, + archetype: "physiological-vital", + }, + BODY_TEMPERATURE: { + id: "BODY_TEMPERATURE", + measurementType: "BODY_TEMPERATURE", + displayName: "Body temperature", + unit: "°C", + direction: "target-band", + normalRange: { low: 36.1, high: 37.5 }, + archetype: "physiological-vital", + }, + SKIN_TEMPERATURE: { + id: "SKIN_TEMPERATURE", + measurementType: "SKIN_TEMPERATURE", + displayName: "Wrist skin temperature", + unit: "°C", + direction: "target-band", + archetype: "physiological-vital", + }, + BLOOD_GLUCOSE: { + id: "BLOOD_GLUCOSE", + measurementType: "BLOOD_GLUCOSE", + displayName: "Blood glucose", + unit: "mg/dL", + direction: "target-band", + normalRange: { low: 70, high: 140 }, + archetype: "physiological-vital", + }, + WALKING_HEART_RATE_AVERAGE: { + id: "WALKING_HEART_RATE_AVERAGE", + measurementType: "WALKING_HEART_RATE_AVERAGE", + displayName: "Walking heart rate", + unit: "bpm", + direction: "lower-better", + archetype: "physiological-vital", + }, + PULSE_WAVE_VELOCITY: { + id: "PULSE_WAVE_VELOCITY", + measurementType: "PULSE_WAVE_VELOCITY", + displayName: "Pulse-wave velocity", + unit: "m/s", + direction: "lower-better", + archetype: "physiological-vital", + }, + VASCULAR_AGE: { + id: "VASCULAR_AGE", + measurementType: "VASCULAR_AGE", + displayName: "Vascular age", + unit: "years", + direction: "lower-better", + archetype: "physiological-vital", + }, + // ── activity-fitness ── + STEPS: { + id: "STEPS", + measurementType: "ACTIVITY_STEPS", + displayName: "Steps", + unit: "steps/day", + direction: "higher-better", + normalRange: { low: 7000, high: 10000 }, + archetype: "activity-fitness", + }, + ACTIVE_ENERGY: { + id: "ACTIVE_ENERGY", + measurementType: "ACTIVE_ENERGY_BURNED", + displayName: "Active energy", + unit: "kcal/day", + direction: "higher-better", + archetype: "activity-fitness", + }, + FLIGHTS_CLIMBED: { + id: "FLIGHTS_CLIMBED", + measurementType: "FLIGHTS_CLIMBED", + displayName: "Flights climbed", + unit: "flights/day", + direction: "higher-better", + archetype: "activity-fitness", + }, + WALKING_RUNNING_DISTANCE: { + id: "WALKING_RUNNING_DISTANCE", + measurementType: "WALKING_RUNNING_DISTANCE", + displayName: "Walking + running distance", + unit: "m/day", + direction: "higher-better", + archetype: "activity-fitness", + }, + TIME_IN_DAYLIGHT: { + id: "TIME_IN_DAYLIGHT", + measurementType: "TIME_IN_DAYLIGHT", + displayName: "Time in daylight", + unit: "min/day", + direction: "higher-better", + normalRange: { low: 30, high: 120 }, + archetype: "activity-fitness", + }, + VO2_MAX: { + id: "VO2_MAX", + measurementType: "VO2_MAX", + displayName: "VO₂ max (cardio fitness)", + unit: "mL/(kg·min)", + direction: "higher-better", + archetype: "activity-fitness", + }, + // ── body-composition ── + TOTAL_BODY_WATER: { + id: "TOTAL_BODY_WATER", + measurementType: "TOTAL_BODY_WATER", + displayName: "Total body water", + unit: "kg", + direction: "target-band", + archetype: "body-composition", + }, + BONE_MASS: { + id: "BONE_MASS", + measurementType: "BONE_MASS", + displayName: "Bone mass", + unit: "kg", + direction: "target-band", + archetype: "body-composition", + }, + FAT_FREE_MASS: { + id: "FAT_FREE_MASS", + measurementType: "FAT_FREE_MASS", + displayName: "Fat-free mass", + unit: "kg", + direction: "target-band", + archetype: "body-composition", + }, + FAT_MASS: { + id: "FAT_MASS", + measurementType: "FAT_MASS", + displayName: "Fat mass", + unit: "kg", + direction: "lower-better", + archetype: "body-composition", + }, + MUSCLE_MASS: { + id: "MUSCLE_MASS", + measurementType: "MUSCLE_MASS", + displayName: "Muscle mass", + unit: "kg", + direction: "higher-better", + archetype: "body-composition", + }, + LEAN_BODY_MASS: { + id: "LEAN_BODY_MASS", + measurementType: "LEAN_BODY_MASS", + displayName: "Lean body mass", + unit: "kg", + direction: "target-band", + archetype: "body-composition", + }, + VISCERAL_FAT: { + id: "VISCERAL_FAT", + measurementType: "VISCERAL_FAT", + displayName: "Visceral fat rating", + unit: "rating", + direction: "lower-better", + normalRange: { low: 1, high: 12 }, + archetype: "body-composition", + }, + // ── mobility-gait ── + WALKING_STEADINESS: { + id: "WALKING_STEADINESS", + measurementType: "WALKING_STEADINESS", + displayName: "Walking steadiness", + unit: "%", + direction: "higher-better", + normalRange: { low: 50, high: 100 }, + archetype: "mobility-gait", + }, + WALKING_ASYMMETRY: { + id: "WALKING_ASYMMETRY", + measurementType: "WALKING_ASYMMETRY", + displayName: "Walking asymmetry", + unit: "%", + direction: "lower-better", + archetype: "mobility-gait", + }, + WALKING_DOUBLE_SUPPORT: { + id: "WALKING_DOUBLE_SUPPORT", + measurementType: "WALKING_DOUBLE_SUPPORT", + displayName: "Double-support time", + unit: "%", + direction: "lower-better", + normalRange: { low: 20, high: 40 }, + archetype: "mobility-gait", + }, + WALKING_STEP_LENGTH: { + id: "WALKING_STEP_LENGTH", + measurementType: "WALKING_STEP_LENGTH", + displayName: "Step length", + unit: "m", + direction: "higher-better", + archetype: "mobility-gait", + }, + WALKING_SPEED: { + id: "WALKING_SPEED", + measurementType: "WALKING_SPEED", + displayName: "Walking speed", + unit: "m/s", + direction: "higher-better", + normalRange: { low: 1.2, high: 1.4 }, + archetype: "mobility-gait", + }, + // ── environmental-exposure ── + AUDIO_EXPOSURE_ENV: { + id: "AUDIO_EXPOSURE_ENV", + measurementType: "AUDIO_EXPOSURE_ENV", + displayName: "Environmental sound exposure", + unit: "dBA", + direction: "lower-better", + normalRange: { low: 0, high: 80 }, + archetype: "environmental-exposure", + }, + AUDIO_EXPOSURE_HEADPHONE: { + id: "AUDIO_EXPOSURE_HEADPHONE", + measurementType: "AUDIO_EXPOSURE_HEADPHONE", + displayName: "Headphone audio exposure", + unit: "dBA", + direction: "lower-better", + normalRange: { low: 0, high: 80 }, + archetype: "environmental-exposure", + }, + AUDIO_EXPOSURE_EVENT: { + id: "AUDIO_EXPOSURE_EVENT", + measurementType: "AUDIO_EXPOSURE_EVENT", + displayName: "Loud-exposure events", + unit: "events", + direction: "lower-better", + archetype: "environmental-exposure", + }, + // ── sleep (dedicated template) ── + SLEEP_DURATION: { + id: "SLEEP_DURATION", + measurementType: "SLEEP_DURATION", + // Stored in minutes (see schema MeasurementType.SLEEP_DURATION); the + // template surfaces hours for the model's prose. + displayName: "Sleep duration", + unit: "min", + direction: "target-band", + normalRange: { low: 420, high: 540 }, + archetype: "sleep", + }, +}; + +/** Closed set of ids the generic route accepts (Zod enum source). */ +export const METRIC_STATUS_IDS = Object.keys( + REGISTRY, +) as MetricStatusMetricId[]; + +/** Type guard narrowing an arbitrary string to a registered metric id. */ +export function isMetricStatusId( + value: string, +): value is MetricStatusMetricId { + return Object.prototype.hasOwnProperty.call(REGISTRY, value); +} + +/** Resolve the metadata for a metric id, or null when unregistered. */ +export function getMetricStatusMeta( + metric: string, +): MetricStatusMeta | null { + return isMetricStatusId(metric) ? REGISTRY[metric] : null; +} + +/** + * The cache/scope id for a metric assessment. The `metric:` prefix keeps + * the generic scopes disjoint from the seven bare specialised scope + * slugs while the trailing `-status.` substring (appended at cache-key + * time, like every other scope) keeps the eviction + invalidation + * sweeps matching. Example cache action: + * `insights.metric:RESTING_HEART_RATE-status.de`. + */ +export function metricStatusScope( + metric: MetricStatusMetricId, +): `metric:${MetricStatusMetricId}` { + return `metric:${metric}`; +} + +/** + * Map a generic metric id to its `InsightMetric` key so the shared + * `hasMetricData` gate can answer the data-availability question with no + * new branch. The two vocabularies line up one-to-one for every + * registered metric except `STEPS` / `ACTIVE_ENERGY`, whose + * `InsightMetric` keys match the id directly (the divergence is only at + * the `MeasurementType` layer, which `hasMetricData` does not consult). + */ +export function metricStatusInsightKey( + metric: MetricStatusMetricId, +): InsightMetric { + return metric as InsightMetric; +} diff --git a/src/lib/insights/metric-status.ts b/src/lib/insights/metric-status.ts new file mode 100644 index 000000000..88a8d30dc --- /dev/null +++ b/src/lib/insights/metric-status.ts @@ -0,0 +1,349 @@ +/** + * v1.8.7.1 — generic per-HealthKit-metric assessment generator. + * + * The seven specialised generators (`pulse-status.ts`, …) each hand-roll + * a metric-specific snapshot + prompt. This one generator covers every + * registered HealthKit metric (`metric-status-registry.ts`) through the + * archetype prompt templates (`prompts/metric-archetypes.ts`), reusing + * the exact same machinery the specialised cards use: + * - the graded-series snapshot builder (`buildGradedSeriesWithRollups`), + * - the shared provider runner (`runStatusCompletion`), + * - the stale-while-revalidate cache (`readFreshStatusText` / + * `resolveReadOnlyStatusMiss`), + * - the standard cache-row persist (`persistStatusInsight`), + * - the previous-context comparison block (`getPreviousInsightContext`). + * + * The return shape is byte-identical to the specialised generators so + * `InsightStatusCard` consumes it unchanged. + * + * Empty-data guard (design §3): when the metric has no readings the + * generator returns an `insufficient` marker WITHOUT touching the + * provider chain — no LLM call, no cache write. The route maps that onto + * the card's insufficient-data state. + */ +import { prisma } from "@/lib/db"; +import { + getMetricArchetypeSystemPrompt, + getMetricArchetypeUserPrompt, +} from "@/lib/ai/prompts/metric-archetypes"; +import { + getMetricStatusMeta, + metricStatusScope, + type MetricStatusMeta, + type MetricStatusMetricId, +} from "@/lib/insights/metric-status-registry"; +import { + formatPreviousContextForPrompt, + getPreviousInsightContext, +} from "@/lib/insights/memory"; +import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets"; +import { getNoKeyGeneralStatusText } from "@/lib/insights/no-key-fallbacks"; +import { applyPayloadBudget } from "@/lib/insights/bucket-series"; +import { + buildGradedSeriesWithRollups, + degradeStatusSnapshotToBudget, +} from "@/lib/insights/graded-series"; +import { + type SupportedLocale, + normalizeLocale, + normalizeSummaryText, + parseSummaryFromContent, + persistStatusInsight, + summarizeSeries, +} from "@/lib/insights/status-shared"; +import { runStatusCompletion } from "@/lib/insights/status-provider"; +import { + readFreshStatusText, + resolveReadOnlyStatusMiss, +} from "@/lib/insights/status-cache"; +import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback"; +import { annotate } from "@/lib/logging/context"; +import { toBerlinDayKey } from "@/lib/tz/resolver"; + +export interface MetricStatusResult { + hasProvider: boolean; + text: string | null; + cached: boolean; + updatedAt: string | null; + preparing?: boolean; + /** + * v1.8.7.1 — true when the metric has no data at all. The route maps + * this onto the card's insufficient-data state; no LLM was called. + */ + insufficient?: boolean; +} + +/** Count this metric's live (non-deleted) readings — the empty-data gate. */ +async function countMetricReadings( + userId: string, + meta: MetricStatusMeta, +): Promise { + return prisma.measurement.count({ + where: { userId, type: meta.measurementType, deletedAt: null }, + }); +} + +/** + * Generate (or read from cache) the generic assessment for one metric. + * Mirrors the specialised generators' contract: + * - cache hit (today, non-stub) → serve it, + * - `readOnly` miss → enqueue out-of-band + serve last-good + * (stale-while-revalidate), + * - empty data → `insufficient`, no LLM call, + * - otherwise → build snapshot, run completion, persist. + */ +export async function generateMetricStatus(args: { + metric: MetricStatusMetricId; + userId: string; + locale?: string | null; + force?: boolean; + /** Read-only navigation path — never blocks on the provider. */ + readOnly?: boolean; +}): Promise { + const meta = getMetricStatusMeta(args.metric); + if (!meta) { + // Defensive — the route Zod-validates against the registry, so an + // unknown id never reaches here. Treat as insufficient rather than + // throwing so a stale caller degrades gracefully. + return { + hasProvider: false, + text: null, + cached: false, + updatedAt: null, + insufficient: true, + }; + } + + const locale = normalizeLocale(args.locale); + const force = args.force === true; + const readOnly = args.readOnly === true; + const scope = metricStatusScope(meta.id); + const cacheAction = `insights.${scope}-status.${locale}`; + const todayKey = toBerlinDayKey(new Date()); + + const cached = await readFreshStatusText({ + userId: args.userId, + cacheAction, + todayKey, + force, + }); + if (cached) { + return { + hasProvider: true, + text: cached.text, + cached: true, + updatedAt: cached.updatedAt, + }; + } + + // Empty-data guard — no readings means no assessment to generate. Done + // BEFORE the read-only enqueue so an empty metric never queues an LLM + // job nor blocks on the provider. The nightly warm pass applies the same + // gate so the cron never burns a call on an empty metric either. + const readingCount = await countMetricReadings(args.userId, meta); + if (readingCount === 0) { + annotate({ + action: { name: "insights.metric-status.insufficient" }, + meta: { metric: meta.id }, + }); + return { + hasProvider: true, + text: null, + cached: false, + updatedAt: null, + insufficient: true, + }; + } + + if (readOnly) { + const outcome = await resolveReadOnlyStatusMiss({ + userId: args.userId, + metric: scope, + locale, + }); + if (outcome.kind === "no-provider") { + return { + hasProvider: false, + text: getNoKeyGeneralStatusText(locale), + cached: true, + updatedAt: null, + }; + } + // Stale-while-revalidate: serve the last good assessment (if any) + // instantly while the worker re-warms the cache; only fall to the + // empty preparing skeleton when none was ever produced. + return { + hasProvider: true, + text: outcome.lastGood?.text ?? null, + cached: outcome.lastGood !== null, + updatedAt: outcome.lastGood?.updatedAt ?? null, + preparing: outcome.lastGood === null, + }; + } + + const user = await prisma.user.findUnique({ + where: { id: args.userId }, + select: { dateOfBirth: true, gender: true }, + }); + + const now = new Date(); + + // Bounded raw read for the derived recent stats; the monthly/yearly + // tail comes from the rollup tier (full-history fallback on a miss), + // exactly as the specialised cards do. + const measurements = await prisma.measurement + .findMany({ + where: { userId: args.userId, type: meta.measurementType, deletedAt: null }, + orderBy: { measuredAt: "desc" }, + take: 365, + select: { value: true, measuredAt: true }, + }) + .then((rows) => rows.reverse()); + + const points = measurements.map((m) => ({ + measuredAt: m.measuredAt, + value: m.value, + })); + const series = applyPayloadBudget(points, { now }); + const graded = await buildGradedSeriesWithRollups( + args.userId, + meta.measurementType, + now, + ); + const summary = summarizeSeries( + series.daily.map((bucket) => ({ value: bucket.value })), + ); + + const latest = series.daily[0] ?? null; + + const oldest = measurements.length > 0 ? measurements[0].measuredAt : null; + const newest = + measurements.length > 0 + ? measurements[measurements.length - 1].measuredAt + : null; + const totalSpanDays = + oldest && newest + ? Math.round( + (newest.getTime() - oldest.getTime()) / (24 * 60 * 60 * 1000), + ) + : 0; + const newestDaysAgo = newest + ? Math.round((Date.now() - newest.getTime()) / (24 * 60 * 60 * 1000)) + : null; + + const snapshot = { + locale, + generatedForDay: todayKey, + focus: meta.id, + metric: { + id: meta.id, + displayName: meta.displayName, + unit: meta.unit, + direction: meta.direction, + ...(meta.normalRange ? { normalRange: meta.normalRange } : {}), + }, + // Profile context for placement only when stored — the medical note + // limits population ranges to age + sex (NO ancestry/region). Omitted + // when the profile leaves them blank, so the model leans on baseline. + profile: { + ageYears: getAgeFromDateOfBirth(user?.dateOfBirth ?? null), + sex: user?.gender ?? null, + }, + dataCoverage: { + totalMeasurements: measurements.length, + totalSpanDays, + newestMeasurementDaysAgo: newestDaysAgo, + }, + [meta.id]: { + summary, + series: graded, + latestDayFocus: latest + ? { dayOffset: latest.dayOffset, value: latest.value } + : null, + }, + }; + + const shed = degradeStatusSnapshotToBudget( + snapshot as unknown as Record, + ); + const snapshotJson = JSON.stringify(snapshot, null, 2); + + annotate({ + action: { name: cacheAction }, + meta: { + payload_size_bytes: snapshotJson.length, + ...(shed.length > 0 ? { snapshot_shed: shed } : {}), + }, + }); + + const previousContext = await getPreviousInsightContext( + args.userId, + // memory.ts keys previous context by the `insights..` + // action; the generic scope (`metric:-status`) carries the same + // `-status` suffix so the comparison row reads back correctly. + `${scope}-status` as never, + locale, + 12, + ); + const previousContextBlock = formatPreviousContextForPrompt( + previousContext, + locale, + ); + + const outcome = await runStatusCompletion({ + userId: args.userId, + cacheAction, + systemPrompt: getMetricArchetypeSystemPrompt(meta, locale), + userPrompt: getMetricArchetypeUserPrompt( + meta, + snapshotJson, + todayKey, + locale, + previousContextBlock, + ), + temperature: 0.3, + maxTokens: 1000, + }); + + if (outcome.kind === "none") { + return { + hasProvider: false, + text: getNoKeyGeneralStatusText(locale), + cached: true, + updatedAt: null, + }; + } + if (outcome.kind === "timeout" || outcome.kind === "error") { + return returnTimeoutFallback({ + cacheAction, + reason: outcome.kind, + userId: args.userId, + todayKey, + stubText: getNoKeyGeneralStatusText(locale), + }); + } + + const text = normalizeSummaryText(parseSummaryFromContent(outcome.content)); + if (!text) { + throw new Error(`Metric-status summary was empty for ${meta.id}`); + } + + const updatedAt = await persistStatusInsight({ + userId: args.userId, + cacheAction, + todayKey, + locale, + text, + providerType: outcome.providerType, + model: outcome.model, + tokensUsed: outcome.tokensUsed, + }); + + return { hasProvider: true, text, cached: false, updatedAt }; +} + +export function resolveMetricStatusLocale( + locale: string | null | undefined, +): SupportedLocale { + return normalizeLocale(locale); +} diff --git a/src/lib/insights/status-cache.ts b/src/lib/insights/status-cache.ts index fc9e6e687..f5908cf7c 100644 --- a/src/lib/insights/status-cache.ts +++ b/src/lib/insights/status-cache.ts @@ -2,7 +2,7 @@ import { prisma } from "@/lib/db"; import { hasUsableStatusProvider } from "@/lib/insights/status-provider"; import { enqueueStatusGeneration, - type InsightStatusMetric, + type InsightStatusScope, } from "@/lib/jobs/insight-status-generate-shared"; import { annotate } from "@/lib/logging/context"; @@ -176,7 +176,7 @@ export type ReadOnlyMissOutcome = */ export async function resolveReadOnlyStatusMiss(args: { userId: string; - metric: InsightStatusMetric; + metric: InsightStatusScope; locale: "de" | "en"; }): Promise { const hasProvider = await hasUsableStatusProvider(args.userId); diff --git a/src/lib/jobs/__tests__/insight-pregenerate.test.ts b/src/lib/jobs/__tests__/insight-pregenerate.test.ts index 29b7d3e50..0d9ce5d53 100644 --- a/src/lib/jobs/__tests__/insight-pregenerate.test.ts +++ b/src/lib/jobs/__tests__/insight-pregenerate.test.ts @@ -51,6 +51,17 @@ vi.mock("@/lib/insights/medication-compliance-status", () => ({ vi.mock("@/lib/insights/general-status", () => ({ generateGeneralStatusForUser: vi.fn(), })); +// v1.8.7.1 — the generic metric generator also imports the provider +// chain transitively; stub it so the default generic warm pass never +// touches a live provider when the test does not inject its own. +vi.mock("@/lib/insights/metric-status", () => ({ + generateMetricStatus: vi.fn(async () => ({ + hasProvider: true, + text: "ok", + cached: false, + updatedAt: new Date().toISOString(), + })), +})); import { runInsightPregenerate, @@ -321,6 +332,51 @@ describe("runInsightPregenerate — per-metric warm pass", () => { }); }); +describe("runInsightPregenerate — generic metric warm pass (v1.8.7.1)", () => { + it("warms the generic metric caches after a successful comprehensive generation", async () => { + const { prisma } = makePrisma([{ id: "u1", locale: "de" }]); + const generate = vi + .fn() + .mockResolvedValue({ status: "generated", providerType: "x" }); + // Stub the seven specialised generators so only the generic count + // is under test. + const statusGenerators = Array.from({ length: 7 }, () => + vi.fn().mockResolvedValue({ hasProvider: true, cached: true }), + ); + const warmGenericMetrics = vi.fn().mockResolvedValue(9); + + const result = await runInsightPregenerate(prisma as never, { + generate, + statusGenerators, + warmGenericMetrics, + }); + + expect(warmGenericMetrics).toHaveBeenCalledTimes(1); + expect(warmGenericMetrics).toHaveBeenCalledWith("u1", ["de", "en"]); + expect(result.metricAssessmentsWarmed).toBe(9); + }); + + it("does NOT warm the generic metric caches when the comprehensive pass skipped (no provider)", async () => { + const { prisma } = makePrisma([{ id: "u1", locale: "en" }]); + const generate = vi + .fn() + .mockResolvedValue({ status: "skipped", reason: "no-provider" }); + const statusGenerators = Array.from({ length: 7 }, () => + vi.fn().mockResolvedValue({ hasProvider: false, cached: true }), + ); + const warmGenericMetrics = vi.fn().mockResolvedValue(9); + + const result = await runInsightPregenerate(prisma as never, { + generate, + statusGenerators, + warmGenericMetrics, + }); + + expect(warmGenericMetrics).not.toHaveBeenCalled(); + expect(result.metricAssessmentsWarmed).toBe(0); + }); +}); + describe("queue registration", () => { const workerSrc = fs.readFileSync( path.resolve(__dirname, "../reminder-worker.ts"), diff --git a/src/lib/jobs/insight-pregenerate.ts b/src/lib/jobs/insight-pregenerate.ts index 85c769e76..cea5a9418 100644 --- a/src/lib/jobs/insight-pregenerate.ts +++ b/src/lib/jobs/insight-pregenerate.ts @@ -62,6 +62,12 @@ import { generateBmiStatusForUser } from "@/lib/insights/bmi-status"; import { generateMoodStatusForUser } from "@/lib/insights/mood-status"; import { generateMedicationComplianceStatusForUser } from "@/lib/insights/medication-compliance-status"; import { generateGeneralStatusForUser } from "@/lib/insights/general-status"; +import { generateMetricStatus } from "@/lib/insights/metric-status"; +import { + METRIC_STATUS_IDS, + getMetricStatusMeta, + type MetricStatusMetricId, +} from "@/lib/insights/metric-status-registry"; export const INSIGHT_PREGENERATE_QUEUE = "insight-pregenerate"; @@ -114,6 +120,14 @@ export interface PregenerateRunResult { * one live status generation per card. This counts the warm writes. */ assessmentsWarmed: number; + /** + * v1.8.7.1 — count of generic per-HealthKit-metric assessment caches + * written warm across the run (the ~30 metric pages, data-bearing + * only). Tracked separately from `assessmentsWarmed` so the nightly + * dashboard can see the generic-tier coverage independently of the + * seven specialised cards. + */ + metricAssessmentsWarmed: number; } /** @@ -186,6 +200,76 @@ async function warmPerStatusCaches( return results.reduce((sum: number, n) => sum + n, 0); } +/** + * v1.8.7.1 — warm the generic per-HealthKit-metric assessment caches for + * one user, but ONLY for metrics that have data. A single grouped count + * answers "which metric types does this user have readings for?" in one + * query, so the cron never burns a (cheap) per-metric count nor (costly) + * an LLM call on a metric the user has never logged. The generic + * generator's own empty-data guard is the second line of defence, but the + * up-front filter keeps the cron from forcing ~30 metric generators per + * user when most have no data. + * + * Returns the count of fresh provider-backed assessments written. + */ +async function warmGenericMetricCaches( + prisma: PrismaClient, + userId: string, + locales: ReadonlyArray<"de" | "en">, +): Promise { + // One distinct read: the set of MeasurementTypes the user has live + // rows for. Best-effort — a read failure (or a worker prisma surface + // without `measurement`) yields zero metrics rather than aborting the + // user's whole pre-generation loop. + let rows: Array<{ type: string }>; + try { + rows = await prisma.measurement.findMany({ + where: { userId, deletedAt: null }, + distinct: ["type"], + select: { type: true }, + }); + } catch { + return 0; + } + const typesWithData = new Set(rows.map((r) => r.type)); + + const metricsWithData = METRIC_STATUS_IDS.filter( + (id: MetricStatusMetricId) => { + const meta = getMetricStatusMeta(id); + return meta !== null && typesWithData.has(meta.measurementType); + }, + ); + if (metricsWithData.length === 0) return 0; + + const limit = pLimit(WARM_PASS_CONCURRENCY); + const tasks = metricsWithData.flatMap((metric) => + locales.map((locale) => + limit(async () => { + try { + const outcome = await generateMetricStatus({ + metric, + userId, + locale, + force: true, + }); + // `insufficient` never reaches here (we pre-filtered to + // metrics with data), but guard anyway: count only a fresh + // provider-backed assessment. + return outcome.hasProvider && + !outcome.cached && + outcome.insufficient !== true + ? 1 + : 0; + } catch { + return 0; + } + }), + ), + ); + const results = await Promise.all(tasks); + return results.reduce((sum: number, n) => sum + n, 0); +} + interface PregenerateCandidate { id: string; locale: string | null; @@ -242,6 +326,15 @@ export async function runInsightPregenerate( ) => Promise; /** Injected for the test — defaults to the seven real status generators. */ statusGenerators?: ReadonlyArray; + /** + * v1.8.7.1 — injected for the test. Defaults to the real generic + * metric warm pass; the test stubs it to assert it runs only for + * data-bearing metrics without a live LLM. + */ + warmGenericMetrics?: ( + userId: string, + locales: ReadonlyArray<"de" | "en">, + ) => Promise; } = {}, ): Promise { const now = options.now ?? new Date(); @@ -249,6 +342,10 @@ export async function runInsightPregenerate( const generate = options.generate ?? generateComprehensiveInsight; const statusGenerators = options.statusGenerators ?? DEFAULT_STATUS_GENERATORS; + const warmGenericMetrics = + options.warmGenericMetrics ?? + ((userId: string, locales: ReadonlyArray<"de" | "en">) => + warmGenericMetricCaches(prisma, userId, locales)); const result: PregenerateRunResult = { total: 0, @@ -258,6 +355,7 @@ export async function runInsightPregenerate( failed: 0, budgetBlocked: 0, assessmentsWarmed: 0, + metricAssessmentsWarmed: 0, }; // Master kill-switch — when the operator disabled the briefing @@ -329,6 +427,13 @@ export async function runInsightPregenerate( ["de", "en"], statusGenerators, ); + // v1.8.7.1 — warm the generic per-HealthKit-metric caches too, for + // the user's data-bearing metrics only (the helper filters via one + // grouped count, so an empty metric never reaches the provider). + result.metricAssessmentsWarmed += await warmGenericMetrics( + candidate.id, + ["de", "en"], + ); } } @@ -344,6 +449,7 @@ export async function runInsightPregenerate( failed: result.failed, budget_blocked: result.budgetBlocked, assessments_warmed: result.assessmentsWarmed, + metric_assessments_warmed: result.metricAssessmentsWarmed, }, }); diff --git a/src/lib/jobs/insight-status-generate-shared.ts b/src/lib/jobs/insight-status-generate-shared.ts index 67581559e..bbe2a95ad 100644 --- a/src/lib/jobs/insight-status-generate-shared.ts +++ b/src/lib/jobs/insight-status-generate-shared.ts @@ -46,9 +46,26 @@ export const INSIGHT_STATUS_METRICS: ReadonlyArray = [ "medication-compliance", ]; +/** + * v1.8.7.1 — the generic per-HealthKit-metric assessment scopes ride the + * same on-demand queue. Their scope id is `metric:` (see + * `metricStatusScope` in the registry), which the worker dispatch routes + * to the generic generator rather than one of the seven specialised + * ones. The queue payload, the read-only resolver, and the enqueue helper + * all accept this broader scope so a generic card warms exactly like a + * specialised one. A specialised scope is one of `InsightStatusMetric`; + * a generic scope is any `metric:`-prefixed string. + */ +export type InsightStatusScope = InsightStatusMetric | `metric:${string}`; + export interface InsightStatusGeneratePayload { userId: string; - metric: InsightStatusMetric; + /** + * The scope to generate. A specialised scope (one of the seven) or a + * generic `metric:` scope (v1.8.7.1). The worker dispatch + * routes the prefix to the right generator. + */ + metric: InsightStatusScope; /** Resolved client locale — the cache key the route reads against. */ locale: "de" | "en"; } diff --git a/src/lib/jobs/insight-status-generate.ts b/src/lib/jobs/insight-status-generate.ts index 6a00ef3b2..9ee562b2a 100644 --- a/src/lib/jobs/insight-status-generate.ts +++ b/src/lib/jobs/insight-status-generate.ts @@ -34,6 +34,8 @@ import { generatePulseStatusForUser } from "@/lib/insights/pulse-status"; import { generateBmiStatusForUser } from "@/lib/insights/bmi-status"; 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 { INSIGHT_STATUS_GENERATE_QUEUE, INSIGHT_STATUS_GENERATE_CONCURRENCY, @@ -80,7 +82,30 @@ export async function runInsightStatusGenerate( payload: InsightStatusGeneratePayload, generators: Record = GENERATORS, ): Promise { - const generate = generators[payload.metric]; + // v1.8.7.1 — a `metric:` scope routes to the generic + // HealthKit-metric generator rather than one of the seven specialised + // ones. The generic generator applies its own empty-data guard, so a + // job enqueued for a metric that has since lost its data costs only a + // cheap count, not an LLM call. + if (payload.metric.startsWith("metric:")) { + const metricId = payload.metric.slice("metric:".length); + if (!isMetricStatusId(metricId)) { + annotate({ + action: { name: "insights.status.generate.unknown_metric" }, + meta: { metric: payload.metric }, + }); + return; + } + await generateMetricStatus({ + metric: metricId, + userId: payload.userId, + locale: payload.locale, + force: true, + }); + return; + } + + const generate = generators[payload.metric as InsightStatusMetric]; if (!generate) { annotate({ action: { name: "insights.status.generate.unknown_metric" }, diff --git a/src/lib/openapi/routes.ts b/src/lib/openapi/routes.ts index 448b91a42..6a0f3f9cc 100644 --- a/src/lib/openapi/routes.ts +++ b/src/lib/openapi/routes.ts @@ -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 = { }, }, }, + "/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 = {