mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
1 parent
2717297dae
commit
e33911e2d4
14 files changed
+1799
-4
No files matched your search
@@ -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:
|
||||
|
||||
@@ -40,6 +40,9 @@ const NON_COACH_GATED_ROUTES: ReadonlyArray<string> = [
|
||||
"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",
|
||||
|
||||
@@ -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<Response>;
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* v1.8.7.1 — generic per-HealthKit-metric assessment route.
|
||||
*
|
||||
* `GET /api/insights/metric-status?metric=<METRIC_ID>` 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);
|
||||
});
|
||||
@@ -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/<metric>.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}`;
|
||||
}
|
||||
@@ -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.<locale>`; 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.");
|
||||
});
|
||||
});
|
||||
@@ -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<MetricStatusMetricId, MetricStatusMeta> = {
|
||||
// ── 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;
|
||||
}
|
||||
@@ -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<number> {
|
||||
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<MetricStatusResult> {
|
||||
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<string, unknown>,
|
||||
);
|
||||
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.<scope>.<locale>`
|
||||
// action; the generic scope (`metric:<ID>-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);
|
||||
}
|
||||
@@ -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<ReadOnlyMissOutcome> {
|
||||
const hasProvider = await hasUsableStatusProvider(args.userId);
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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<number> {
|
||||
// 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<GenerateOutcome>;
|
||||
/** Injected for the test — defaults to the seven real status generators. */
|
||||
statusGenerators?: ReadonlyArray<StatusGenerator>;
|
||||
/**
|
||||
* 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<number>;
|
||||
} = {},
|
||||
): Promise<PregenerateRunResult> {
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -46,9 +46,26 @@ export const INSIGHT_STATUS_METRICS: ReadonlyArray<InsightStatusMetric> = [
|
||||
"medication-compliance",
|
||||
];
|
||||
|
||||
/**
|
||||
* v1.8.7.1 — the generic per-HealthKit-metric assessment scopes ride the
|
||||
* same on-demand queue. Their scope id is `metric:<METRIC_ID>` (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:<METRIC_ID>` 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";
|
||||
}
|
||||
|
||||
@@ -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<InsightStatusMetric, StatusGenerator> = GENERATORS,
|
||||
): Promise<void> {
|
||||
const generate = generators[payload.metric];
|
||||
// v1.8.7.1 — a `metric:<METRIC_ID>` 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" },
|
||||
|
||||
@@ -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"]> = {
|
||||
|
||||
Reference in new issue
Block a user