From f2bc4f478a5ab61a13f723b41f3ef44287ab83ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 11 May 2026 01:07:03 +0200 Subject: [PATCH] feat(coach): Apple Health metric slots + PROMPT_VERSION 4.23.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the strict insight schema, Coach snapshot pipeline, and provenance envelope with the seven Apple Health categories that landed in Wave 2 (HRV, sleep, resting HR, steps, active energy, flights, distance, VO2 max, body temperature). All fields are additive and optional — web-only accounts pay zero extra SQL and the prompt's new GROUND RULE 12 instructs the model to stay silent when the snapshot doesn't carry the HealthKit blocks. Ratchets PROMPT_VERSION 4.22.0 → 4.23.0 and refreshes the sourceMetric / trendAnnotations enums in both EN + DE. Two new test files pin the prompt body and the snapshot fan-out. Co-Authored-By: Claude Opus 4.7 (1M context) --- messages/de.json | 11 +- messages/en.json | 11 +- .../insights/coach-panel/source-chips.tsx | 14 ++ src/components/insights/daily-briefing.tsx | 22 +++ .../ai/__tests__/coach-prompt-v423.test.ts | 75 +++++++++ .../__tests__/daily-briefing-schema.test.ts | 42 +++-- .../__tests__/snapshot-new-metrics.test.ts | 146 ++++++++++++++++++ .../ai/coach/__tests__/system-prompt.test.ts | 9 +- src/lib/ai/coach/snapshot.ts | 66 ++++++++ src/lib/ai/coach/types.ts | 67 +++++++- src/lib/ai/prompts/insight-generator.ts | 46 +++++- src/lib/ai/schema.ts | 58 ++++++- 12 files changed, 538 insertions(+), 29 deletions(-) create mode 100644 src/lib/ai/__tests__/coach-prompt-v423.test.ts create mode 100644 src/lib/ai/coach/__tests__/snapshot-new-metrics.test.ts diff --git a/messages/de.json b/messages/de.json index 61931321d..12e9257b7 100644 --- a/messages/de.json +++ b/messages/de.json @@ -884,7 +884,16 @@ "pulse": "Puls", "mood": "Stimmung", "compliance": "Einnahmetreue", - "general": "Allgemein" + "general": "Allgemein", + "hrv": "HRV", + "sleep": "Schlaf", + "resting_hr": "Ruhepuls", + "steps": "Schritte", + "active_energy": "Aktive Energie", + "flights": "Stockwerke", + "distance": "Strecke", + "vo2_max": "VO₂ max", + "body_temp": "Körpertemperatur" }, "window": { "last7days": "letzte 7 Tage", diff --git a/messages/en.json b/messages/en.json index 85c0ac4c8..2300bd8af 100644 --- a/messages/en.json +++ b/messages/en.json @@ -884,7 +884,16 @@ "pulse": "Pulse", "mood": "Mood", "compliance": "Compliance", - "general": "General" + "general": "General", + "hrv": "HRV", + "sleep": "Sleep", + "resting_hr": "Resting HR", + "steps": "Steps", + "active_energy": "Active energy", + "flights": "Flights climbed", + "distance": "Distance", + "vo2_max": "VO₂ max", + "body_temp": "Body temperature" }, "window": { "last7days": "last 7 days", diff --git a/src/components/insights/coach-panel/source-chips.tsx b/src/components/insights/coach-panel/source-chips.tsx index d13ba8445..868b72c3e 100644 --- a/src/components/insights/coach-panel/source-chips.tsx +++ b/src/components/insights/coach-panel/source-chips.tsx @@ -31,6 +31,20 @@ const METRIC_KEYS: Record = { mood: "insights.coach.metric.mood", compliance: "insights.coach.metric.compliance", general: "insights.coach.metric.general", + // ── v1.4.23 Apple Health additive ── + // Optional metric tags surfaced when the iOS app's HealthKit snapshot + // contains the matching block. The translation keys follow the same + // `insights.coach.metric.` namespace and fall back to the raw + // token when a new metric ships before the locale files catch up. + hrv: "insights.coach.metric.hrv", + sleep: "insights.coach.metric.sleep", + resting_hr: "insights.coach.metric.resting_hr", + steps: "insights.coach.metric.steps", + active_energy: "insights.coach.metric.active_energy", + flights: "insights.coach.metric.flights", + distance: "insights.coach.metric.distance", + vo2_max: "insights.coach.metric.vo2_max", + body_temp: "insights.coach.metric.body_temp", }; const WINDOW_KEYS: Record = { diff --git a/src/components/insights/daily-briefing.tsx b/src/components/insights/daily-briefing.tsx index a5ce46408..8b8872145 100644 --- a/src/components/insights/daily-briefing.tsx +++ b/src/components/insights/daily-briefing.tsx @@ -3,11 +3,20 @@ import { Activity, FileText, + Flame, + Footprints, Heart, + HeartPulse, + Mountain, + Moon, Pill, + Route, Scale, Smile, Sparkles, + Thermometer, + Wind, + Zap, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; @@ -63,6 +72,19 @@ const METRIC_ICON: Record< pulse: Activity, mood: Smile, compliance: Pill, + // ── v1.4.23 Apple Health additive ── + // Icon picks track the metric domain rather than the brand. Web-only + // accounts never receive findings keyed to these metrics, so the + // mapping is only exercised on iOS-connected snapshots. + hrv: Wind, + sleep: Moon, + resting_hr: HeartPulse, + steps: Footprints, + active_energy: Flame, + flights: Mountain, + distance: Route, + vo2_max: Zap, + body_temp: Thermometer, }; const TONE_BAR_CLASSNAME: Record = { diff --git a/src/lib/ai/__tests__/coach-prompt-v423.test.ts b/src/lib/ai/__tests__/coach-prompt-v423.test.ts new file mode 100644 index 000000000..ec153166f --- /dev/null +++ b/src/lib/ai/__tests__/coach-prompt-v423.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect } from "vitest"; + +import { + PROMPT_VERSION, + getStrictInsightsSystemPrompt, +} from "@/lib/ai/prompts/insight-generator"; + +/** + * v1.4.23 W4 F6 — pins the additive Apple Health prompt ratchet. + * + * Asserts: + * 1. PROMPT_VERSION sits on the 4.23.x train. + * 2. GROUND RULE 12 ships in the EN prompt and tells the model to + * stay silent about HealthKit metrics when the snapshot doesn't + * carry them — defending against the "you don't have HRV data" + * apologetic-opener regression on web-only accounts. + * 3. The German prompt carries the same rule with the same intent. + * 4. The OUTPUT FORMAT block enumerates the new sourceMetric tokens + * so the model emits the additive enum values directly. + */ +describe("PROMPT_VERSION (v1.4.23 Apple Health ratchet)", () => { + it("matches the 4.23.x train", () => { + expect(PROMPT_VERSION).toMatch(/^4\.23\.\d+$/); + }); +}); + +describe("getStrictInsightsSystemPrompt — EN", () => { + const prompt = getStrictInsightsSystemPrompt("en"); + + it("carries GROUND RULE 12 with the Apple Health silent-fallback contract", () => { + expect(prompt).toMatch(/12\. v1\.4\.23/); + expect(prompt).toMatch(/Apple Health metric categories/); + expect(prompt).toMatch(/HRV/); + expect(prompt).toMatch(/sleep duration/); + expect(prompt).toMatch(/resting HR/); + expect(prompt).toMatch(/do not apologise/); + expect(prompt).toMatch(/silent/); + }); + + it("does not bake live tenant figures into the new rule", () => { + // PII rule (per the marathon brief): GROUND RULE 12 frames in terms + // of metric categories, never a specific number / email / username. + // The block starts at "12. v1.4.23" and ends at the + // GUIDELINE TARGETS heading that closes the ground-rules section. + const rule12 = + prompt.split("12. v1.4.23")[1]?.split("GUIDELINE TARGETS")[0] ?? ""; + expect(rule12.length).toBeGreaterThan(0); + expect(rule12).not.toMatch(/@.+\.(com|net|org|de|test)/); + // The rule mentions metric category names but no concrete sample + // values — categories only. + expect(rule12).not.toMatch(/\b\d{1,3}\s*(mmHg|bpm|kcal)\b/); + }); + + it("documents the additive sourceMetric tokens in the OUTPUT FORMAT", () => { + expect(prompt).toMatch(/hrv \| sleep \| resting_hr \| steps \| active_energy/); + }); +}); + +describe("getStrictInsightsSystemPrompt — DE", () => { + const prompt = getStrictInsightsSystemPrompt("de"); + + it("carries GROUND RULE 12 with the German silent-fallback wording", () => { + expect(prompt).toMatch(/12\. v1\.4\.23/); + expect(prompt).toMatch(/Apple-Health-Metrik-Kategorien/); + expect(prompt).toMatch(/HRV/); + expect(prompt).toMatch(/Schlafdauer/); + expect(prompt).toMatch(/Ruhepuls/); + expect(prompt).toMatch(/entschuldige dich nicht/); + expect(prompt).toMatch(/unsichtbar/); + }); + + it("documents the additive sourceMetric tokens in the German OUTPUT FORMAT", () => { + expect(prompt).toMatch(/hrv \| sleep \| resting_hr \| steps \| active_energy/); + }); +}); diff --git a/src/lib/ai/__tests__/daily-briefing-schema.test.ts b/src/lib/ai/__tests__/daily-briefing-schema.test.ts index 7fda27da2..8ed27a2cb 100644 --- a/src/lib/ai/__tests__/daily-briefing-schema.test.ts +++ b/src/lib/ai/__tests__/daily-briefing-schema.test.ts @@ -119,21 +119,37 @@ describe("dailyBriefingKeyFindingSchema", () => { } }); - it.each(["bp", "weight", "pulse", "mood", "compliance"] as const)( - "accepts sourceMetric=%s", - (sourceMetric) => { - const result = dailyBriefingKeyFindingSchema.safeParse({ - ...baseFinding, - sourceMetric, - }); - expect(result.success).toBe(true); - }, - ); - - it("rejects an unknown sourceMetric", () => { + it.each([ + "bp", + "weight", + "pulse", + "mood", + "compliance", + // ── v1.4.23 Apple Health additive ── + "hrv", + "sleep", + "resting_hr", + "steps", + "active_energy", + "flights", + "distance", + "vo2_max", + "body_temp", + ] as const)("accepts sourceMetric=%s", (sourceMetric) => { const result = dailyBriefingKeyFindingSchema.safeParse({ ...baseFinding, - sourceMetric: "hrv", + sourceMetric, + }); + expect(result.success).toBe(true); + }); + + it("rejects an unknown sourceMetric", () => { + // `bodyComposition` is intentionally NOT a token in the enum; the + // v1.4.23 extension keeps the unknown-rejection path exercised + // without leaking a brand-new sentinel value into the prompt body. + const result = dailyBriefingKeyFindingSchema.safeParse({ + ...baseFinding, + sourceMetric: "bodyComposition", }); expect(result.success).toBe(false); }); diff --git a/src/lib/ai/coach/__tests__/snapshot-new-metrics.test.ts b/src/lib/ai/coach/__tests__/snapshot-new-metrics.test.ts new file mode 100644 index 000000000..c70d8dd81 --- /dev/null +++ b/src/lib/ai/coach/__tests__/snapshot-new-metrics.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +import { buildCoachSnapshot } from "../snapshot"; + +vi.mock("@/lib/db", () => ({ + prisma: { + measurement: { findMany: vi.fn() }, + moodEntry: { findMany: vi.fn() }, + medicationIntakeEvent: { findMany: vi.fn() }, + }, +})); + +vi.mock("@/lib/insights/features", () => ({ + extractFeatures: vi.fn(), +})); + +import { prisma } from "@/lib/db"; +import { extractFeatures } from "@/lib/insights/features"; + +const prismaMock = prisma as unknown as { + measurement: { findMany: ReturnType }; + moodEntry: { findMany: ReturnType }; + medicationIntakeEvent: { findMany: ReturnType }; +}; +const featuresMock = extractFeatures as unknown as ReturnType; + +/** + * v1.4.23 W4 F6 — pins the Apple Health timeline blocks the Coach + * snapshot must ship when the user has HealthKit data and explicitly + * scopes the new sources. + * + * Defensive design: web-only accounts that omit the new scope tokens + * keep paying zero extra SQL, and accounts that toggle the scope on + * but have no rows still produce a `general` snapshot — the block is + * only emitted when at least one row exists. + */ +function daysAgo( + n: number, + value: number, + type: string, +): { type: string; value: number; measuredAt: Date } { + const ms = Date.now() - n * 24 * 60 * 60 * 1000; + const d = new Date(ms); + d.setUTCHours(9, 0, 0, 0); + return { type, value, measuredAt: d }; +} + +describe("buildCoachSnapshot — Apple Health additive metrics (v1.4.23)", () => { + beforeEach(() => { + vi.clearAllMocks(); + prismaMock.moodEntry.findMany.mockResolvedValue([]); + prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([]); + featuresMock.mockResolvedValue({ + bloodPressure: undefined, + weight: undefined, + pulse: undefined, + mood: undefined, + }); + }); + + it("emits an hrv timeline block when scope.sources includes 'hrv' and rows exist", async () => { + const rows = [ + daysAgo(2, 64, "HEART_RATE_VARIABILITY"), + daysAgo(5, 71, "HEART_RATE_VARIABILITY"), + ]; + prismaMock.measurement.findMany.mockResolvedValue(rows); + + const out = await buildCoachSnapshot("user-1", { + sources: ["hrv"], + window: "last30days", + }); + const snapshot = JSON.parse(out.snapshotJson) as Record; + + expect(snapshot.heartRateVariability).toBeDefined(); + expect(out.provenance.metrics).toContain("hrv"); + expect(out.provenance.counts?.hrv).toBe(2); + }); + + it("emits a sleep timeline block on SLEEP_DURATION rows", async () => { + const rows = [ + daysAgo(1, 420, "SLEEP_DURATION"), + daysAgo(3, 510, "SLEEP_DURATION"), + daysAgo(7, 480, "SLEEP_DURATION"), + ]; + prismaMock.measurement.findMany.mockResolvedValue(rows); + + const out = await buildCoachSnapshot("user-1", { + sources: ["sleep"], + window: "last30days", + }); + const snapshot = JSON.parse(out.snapshotJson) as Record; + + expect(snapshot.sleep).toBeDefined(); + expect(out.provenance.metrics).toContain("sleep"); + expect(out.provenance.counts?.sleep).toBe(3); + }); + + it("emits both hrv + sleep blocks when scope toggles both", async () => { + const rows = [ + daysAgo(2, 64, "HEART_RATE_VARIABILITY"), + daysAgo(3, 510, "SLEEP_DURATION"), + ]; + prismaMock.measurement.findMany.mockResolvedValue(rows); + + const out = await buildCoachSnapshot("user-1", { + sources: ["hrv", "sleep"], + window: "last30days", + }); + const snapshot = JSON.parse(out.snapshotJson) as Record; + + expect(snapshot.heartRateVariability).toBeDefined(); + expect(snapshot.sleep).toBeDefined(); + expect(out.provenance.metrics).toEqual( + expect.arrayContaining(["hrv", "sleep"]), + ); + }); + + it("omits the apple-health block entirely when rows are absent", async () => { + // Scope says we want HRV, but the SQL fetch returned nothing. + prismaMock.measurement.findMany.mockResolvedValue([]); + + const out = await buildCoachSnapshot("user-1", { + sources: ["hrv"], + window: "last30days", + }); + const snapshot = JSON.parse(out.snapshotJson) as Record; + + expect(snapshot.heartRateVariability).toBeUndefined(); + // Empty snapshot folds back to the `general` sentinel so the + // Coach surface stays useful for an iOS user mid-onboarding. + expect(out.provenance.metrics).toContain("general"); + }); + + it("does not query the new types when scope omits them (web default)", async () => { + // Default scope = bp, weight, pulse, mood, compliance. The + // implementation only requests the new types when they're scoped + // in — sanity-check the SQL parameters land that way. + await buildCoachSnapshot("user-1"); + + const callArgs = prismaMock.measurement.findMany.mock.calls[0]?.[0]; + const types = (callArgs?.where?.type?.in ?? []) as string[]; + expect(types).not.toContain("HEART_RATE_VARIABILITY"); + expect(types).not.toContain("SLEEP_DURATION"); + expect(types).not.toContain("ACTIVE_ENERGY_BURNED"); + }); +}); diff --git a/src/lib/ai/coach/__tests__/system-prompt.test.ts b/src/lib/ai/coach/__tests__/system-prompt.test.ts index 5d1d96c7c..772f28b00 100644 --- a/src/lib/ai/coach/__tests__/system-prompt.test.ts +++ b/src/lib/ai/coach/__tests__/system-prompt.test.ts @@ -14,9 +14,12 @@ import { getCoachSystemPrompt } from "../system-prompt"; * 3. The few-shot tone-calibration `` pairs are baked into * the prompt body (per the W1b research output). */ -describe("PROMPT_VERSION (v1.4.22 Coach rewrite)", () => { - it("matches the 4.22.x train", () => { - expect(PROMPT_VERSION).toMatch(/^4\.22\.\d+$/); +describe("PROMPT_VERSION (v1.4.23 Coach + Apple Health extension)", () => { + it("matches the 4.23.x train", () => { + // v1.4.23 ratcheted the prompt to add GROUND RULE 12 (Apple Health + // optional categories). Future patches stay on 4.23.x until a + // behavioural change ships. + expect(PROMPT_VERSION).toMatch(/^4\.23\.\d+$/); }); }); diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index c861d170b..34af8061d 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -278,6 +278,18 @@ export async function buildCoachSnapshot( const wantsPulse = sources.has("pulse"); const wantsMood = sources.has("mood"); const wantsCompliance = sources.has("compliance"); + // v1.4.23 — additive Apple Health sources. The default scope leaves + // them off, so non-iOS accounts never pay the SQL `WHERE type IN` + // overhead. iOS callers explicitly include them in `scope.sources`. + const wantsHrv = sources.has("hrv"); + const wantsSleep = sources.has("sleep"); + const wantsRestingHr = sources.has("resting_hr"); + const wantsSteps = sources.has("steps"); + const wantsActiveEnergy = sources.has("active_energy"); + const wantsFlights = sources.has("flights"); + const wantsDistance = sources.has("distance"); + const wantsVo2Max = sources.has("vo2_max"); + const wantsBodyTemp = sources.has("body_temp"); // Single fetch for all measurement types — Prisma's filter pushes // the type list into one SQL `WHERE type IN (…)` so we don't pay @@ -286,6 +298,15 @@ export async function buildCoachSnapshot( if (wantsBp) wantedTypes.push("BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"); if (wantsWeight) wantedTypes.push("WEIGHT"); if (wantsPulse) wantedTypes.push("PULSE"); + if (wantsHrv) wantedTypes.push("HEART_RATE_VARIABILITY"); + if (wantsSleep) wantedTypes.push("SLEEP_DURATION"); + if (wantsRestingHr) wantedTypes.push("RESTING_HEART_RATE"); + if (wantsSteps) wantedTypes.push("ACTIVITY_STEPS"); + if (wantsActiveEnergy) wantedTypes.push("ACTIVE_ENERGY_BURNED"); + if (wantsFlights) wantedTypes.push("FLIGHTS_CLIMBED"); + if (wantsDistance) wantedTypes.push("WALKING_RUNNING_DISTANCE"); + if (wantsVo2Max) wantedTypes.push("VO2_MAX"); + if (wantsBodyTemp) wantedTypes.push("BODY_TEMPERATURE"); const measurementRows = wantedTypes.length > 0 @@ -447,6 +468,51 @@ export async function buildCoachSnapshot( } } + // ── v1.4.23 Apple Health additive blocks ───────────────────────── + // + // Each new HealthKit-derived metric ships as a timeline-only block + // (recent day rows + older weekly buckets). The aggregate features + // pipeline doesn't carry them yet — that's a v1.5 follow-up — but + // the timeline alone is enough for the Coach to ground "your HRV + // last Tuesday was X" replies in real numbers without inventing a + // baseline. The block is omitted entirely when the user has no rows + // for that metric, so accounts without iOS data never see a void + // section in the prompt. + type AppleHealthMetric = Exclude< + CoachProvenance["metrics"][number], + "general" | "bp" | "weight" | "pulse" | "mood" | "compliance" + >; + type AppleHealthBlock = { + metric: AppleHealthMetric; + snapshotKey: string; + type: string; + enabled: boolean; + }; + const appleHealthBlocks: AppleHealthBlock[] = [ + { metric: "hrv", snapshotKey: "heartRateVariability", type: "HEART_RATE_VARIABILITY", enabled: wantsHrv }, + { metric: "sleep", snapshotKey: "sleep", type: "SLEEP_DURATION", enabled: wantsSleep }, + { metric: "resting_hr", snapshotKey: "restingHeartRate", type: "RESTING_HEART_RATE", enabled: wantsRestingHr }, + { metric: "steps", snapshotKey: "steps", type: "ACTIVITY_STEPS", enabled: wantsSteps }, + { metric: "active_energy", snapshotKey: "activeEnergy", type: "ACTIVE_ENERGY_BURNED", enabled: wantsActiveEnergy }, + { metric: "flights", snapshotKey: "flightsClimbed", type: "FLIGHTS_CLIMBED", enabled: wantsFlights }, + { metric: "distance", snapshotKey: "walkingRunningDistance", type: "WALKING_RUNNING_DISTANCE", enabled: wantsDistance }, + { metric: "vo2_max", snapshotKey: "vo2Max", type: "VO2_MAX", enabled: wantsVo2Max }, + { metric: "body_temp", snapshotKey: "bodyTemperature", type: "BODY_TEMPERATURE", enabled: wantsBodyTemp }, + ]; + for (const block of appleHealthBlocks) { + if (!block.enabled) continue; + const rows = byType(block.type); + if (rows.length === 0) continue; + snapshot[block.snapshotKey] = { + timeline: { + recent: buildDailyValueRows(rows, recentCutoff), + weekly: bucketWeekly(rows.filter((r) => r.measuredAt < recentCutoff)), + }, + }; + metrics.add(block.metric); + counts[block.metric] = rows.length; + } + if (Object.keys(snapshot).length === 0) { metrics.add("general"); } diff --git a/src/lib/ai/coach/types.ts b/src/lib/ai/coach/types.ts index 2b9f701a0..a18f128e0 100644 --- a/src/lib/ai/coach/types.ts +++ b/src/lib/ai/coach/types.ts @@ -50,11 +50,32 @@ export const coachScopeSourceSchema = z.enum([ "pulse", "mood", "compliance", + // ── v1.4.23 Apple Health additive ── + // Optional scope toggles for the new HealthKit metrics. Web-only + // accounts never carry data for these — `buildCoachSnapshot()` only + // emits the matching block when Apple-Health rows exist. + "hrv", + "sleep", + "resting_hr", + "steps", + "active_energy", + "flights", + "distance", + "vo2_max", + "body_temp", ]); export const coachScopeSchema = z.object({ - /** Which sources the snapshot may include. Empty array → no metrics. */ - sources: z.array(coachScopeSourceSchema).max(5).optional(), + /** + * Which sources the snapshot may include. Empty array → no metrics. + * + * v1.4.23 — cap raised from 5 to 14 to admit the Apple Health + * additions. The default-source list (`buildCoachSnapshot.DEFAULT_SOURCES`) + * still seeds 5 to keep the prompt budget tight for accounts without + * Apple Health data; iOS clients pass the extended set when they + * have HealthKit-derived rows. + */ + sources: z.array(coachScopeSourceSchema).max(14).optional(), /** Window the day-level timeline covers. Defaults to last30days. */ window: coachScopeWindowSchema.optional(), }); @@ -128,16 +149,54 @@ export interface CoachProvenance { /** * Metric topics referenced. Stable contract keys — translated by the * UI, never by the server. + * + * v1.4.23 — extended with the seven Apple Health categories landed in + * Wave 2 (HRV, sleep, resting HR, steps, active energy, flights, + * distance, VO2 max, body temp). Web-only accounts never see those + * values; the prompt's GROUND RULE 12 tells the model to treat the + * tokens as additive rather than required. */ metrics: ReadonlyArray< - "bp" | "weight" | "pulse" | "mood" | "compliance" | "general" + | "bp" + | "weight" + | "pulse" + | "mood" + | "compliance" + | "general" + // ── v1.4.23 Apple Health additive ── + | "hrv" + | "sleep" + | "resting_hr" + | "steps" + | "active_energy" + | "flights" + | "distance" + | "vo2_max" + | "body_temp" >; /** * Sample-count summary per metric — opaque labels, no raw timestamps * or values. Optional; absent when the snapshot was empty. */ counts?: Partial< - Record<"bp" | "weight" | "pulse" | "mood" | "compliance", number> + Record< + | "bp" + | "weight" + | "pulse" + | "mood" + | "compliance" + // ── v1.4.23 Apple Health additive ── + | "hrv" + | "sleep" + | "resting_hr" + | "steps" + | "active_energy" + | "flights" + | "distance" + | "vo2_max" + | "body_temp", + number + > >; /** * v1.4.22 — load-bearing numbers the Coach drew on for this turn, diff --git a/src/lib/ai/prompts/insight-generator.ts b/src/lib/ai/prompts/insight-generator.ts index 0d0268597..3ac8b40a5 100644 --- a/src/lib/ai/prompts/insight-generator.ts +++ b/src/lib/ai/prompts/insight-generator.ts @@ -27,7 +27,7 @@ import { } from "../medical-references"; /** Stable identifier for the active system prompt revision. */ -export const PROMPT_VERSION = "4.22.0" as const; +export const PROMPT_VERSION = "4.23.0" as const; const SYSTEM_PROMPT_EN = `You are a clinical-context summariser for a personal health-log app. Prompt version: ${PROMPT_VERSION}. @@ -174,6 +174,19 @@ GROUND RULES — ZERO HALLUCINATIONS Date format: "YYYY-MM-DD". Label cap: 80 chars. Detail cap: 400 chars. Hard array cap: 20. Omit the field entirely when the timeline has no notable events. +12. v1.4.23 — Optional Apple Health metric categories. When the + snapshot carries any of HRV, sleep duration, resting HR, step + count, active energy, flights climbed, walking/running distance, + VO2 max, or body temperature, you may reference those categories + in the same prose-first style you use for BP / weight / pulse / + mood — derive every claim from a number visible in the snapshot + block for that metric. When the snapshot does NOT carry any of + those categories (web-only or non-iOS accounts), treat them as + silent: do not apologise for missing Apple Health data, do not + mention the absence of HRV / sleep / HealthKit, do not suggest + the user connect a wearable. The presence or absence of the + HealthKit metric block in the snapshot is the only signal you + should act on. GUIDELINE TARGETS — generic, do NOT compute precise risk scores - Adult resting blood pressure (ESH/ESC 2024 generic): aim < 140/90 @@ -239,14 +252,19 @@ You MUST return JSON matching this schema exactly: "detail": "one-sentence detail", "delta": "optional delta string (e.g. '↓ 4 mmHg') or null", "sourceWindow": "7d | 30d | 90d | 1y", - "sourceMetric": "bp | weight | pulse | mood | compliance" + "sourceMetric": "bp | weight | pulse | mood | compliance | hrv | sleep | resting_hr | steps | active_energy | flights | distance | vo2_max | body_temp" } ] }, "trendAnnotations": { "bp": "one sentence, ≤200 chars, observational", "weight": "one sentence, ≤200 chars, observational", - "mood": "one sentence, ≤200 chars, observational" + "mood": "one sentence, ≤200 chars, observational", + "hrv": "one sentence, ≤200 chars, observational (Apple Health users)", + "sleep": "one sentence, ≤200 chars, observational (Apple Health users)", + "resting_hr": "one sentence, ≤200 chars, observational (Apple Health users)", + "steps": "one sentence, ≤200 chars, observational (Apple Health users)", + "active_energy": "one sentence, ≤200 chars, observational (Apple Health users)" }, "weeklyReport": { "weekISO": "YYYY-Www (e.g. 2026-W19)", @@ -449,6 +467,19 @@ GRUNDREGELN — NULL HALLUZINATIONEN Datumsformat: "YYYY-MM-DD". Label-Cap: 80 Zeichen. Detail-Cap: 400 Zeichen. Array-Höchstgrenze: 20. Lass das Feld komplett weg, wenn die Timeline keine bemerkenswerten Ereignisse hat. +12. v1.4.23 — Optionale Apple-Health-Metrik-Kategorien. Wenn der + Snapshot eine der folgenden Kategorien führt — HRV, Schlafdauer, + Ruhepuls, Schrittzahl, aktiver Energieumsatz, Stockwerke, + Geh-/Laufdistanz, VO2 max oder Körpertemperatur — kannst du diese + Kategorien im selben Fließtext-zuerst-Stil referenzieren wie BP / + Gewicht / Puls / Stimmung — leite jede Aussage aus einer Zahl im + entsprechenden Snapshot-Block ab. Wenn der Snapshot KEINE dieser + Kategorien führt (Web-only- oder Nicht-iOS-Konten), behandle sie + als unsichtbar: entschuldige dich nicht für fehlende + Apple-Health-Daten, erwähne nicht das Fehlen von HRV / Schlaf / + HealthKit, schlage nicht vor, ein Wearable zu verbinden. Das + Vorhandensein oder Fehlen des HealthKit-Metrik-Blocks im Snapshot + ist das einzige Signal, auf das du reagieren solltest. LEITLINIEN-ZIELWERTE — generisch, KEINE genauen Risiko-Scores berechnen - Erwachsenen-Ruheblutdruck (ESH/ESC 2024 generisch): Ziel < 140/90 @@ -515,14 +546,19 @@ Du MUSST JSON exakt nach diesem Schema liefern: "detail": "ein Satz Detail", "delta": "optionaler Delta-String (z.B. '↓ 4 mmHg') oder null", "sourceWindow": "7d | 30d | 90d | 1y", - "sourceMetric": "bp | weight | pulse | mood | compliance" + "sourceMetric": "bp | weight | pulse | mood | compliance | hrv | sleep | resting_hr | steps | active_energy | flights | distance | vo2_max | body_temp" } ] }, "trendAnnotations": { "bp": "ein Satz, ≤ 200 Zeichen, beobachtend", "weight": "ein Satz, ≤ 200 Zeichen, beobachtend", - "mood": "ein Satz, ≤ 200 Zeichen, beobachtend" + "mood": "ein Satz, ≤ 200 Zeichen, beobachtend", + "hrv": "ein Satz, ≤ 200 Zeichen, beobachtend (Apple-Health-Nutzer)", + "sleep": "ein Satz, ≤ 200 Zeichen, beobachtend (Apple-Health-Nutzer)", + "resting_hr": "ein Satz, ≤ 200 Zeichen, beobachtend (Apple-Health-Nutzer)", + "steps": "ein Satz, ≤ 200 Zeichen, beobachtend (Apple-Health-Nutzer)", + "active_energy": "ein Satz, ≤ 200 Zeichen, beobachtend (Apple-Health-Nutzer)" }, "weeklyReport": { "weekISO": "YYYY-Www (z.B. 2026-W19)", diff --git a/src/lib/ai/schema.ts b/src/lib/ai/schema.ts index 933236134..92b8e7a57 100644 --- a/src/lib/ai/schema.ts +++ b/src/lib/ai/schema.ts @@ -256,8 +256,33 @@ export const dailyBriefingKeyFindingSchema = z.object({ delta: z.string().nullable(), /** Window the finding was derived from — same enum as elsewhere. */ sourceWindow: z.enum(["7d", "30d", "90d", "1y"]).default("30d"), - /** Metric the finding was drawn from. */ - sourceMetric: z.enum(["bp", "weight", "pulse", "mood", "compliance"]), + /** + * Metric the finding was drawn from. + * + * v1.4.23 — extended with the seven Apple Health categories landed in + * Wave 2 so the daily-briefing surface can cite HRV / sleep / resting + * HR / steps / active energy / flights / distance findings on iOS- + * connected accounts. Web-only accounts never see those metrics in + * the snapshot, so the prompt is told (rule 12) to treat the new + * tokens as optional. + */ + sourceMetric: z.enum([ + "bp", + "weight", + "pulse", + "mood", + "compliance", + // ── v1.4.23 Apple Health additive ── + "hrv", + "sleep", + "resting_hr", + "steps", + "active_energy", + "flights", + "distance", + "vo2_max", + "body_temp", + ]), }); export type DailyBriefingKeyFinding = z.infer< @@ -311,6 +336,35 @@ export const trendAnnotationsSchema = z.object({ .min(1, "trendAnnotations.mood required when emitted") .max(200, "trendAnnotations.mood must be <= 200 chars") .optional(), + // ── v1.4.23 Apple Health additive ── + // Each annotation stays one sentence, observational, ≤ 200 chars + // (mirrors the bp/weight/mood contract). The prompt instructs the + // model to omit any metric the snapshot doesn't carry. + hrv: z + .string() + .min(1, "trendAnnotations.hrv required when emitted") + .max(200, "trendAnnotations.hrv must be <= 200 chars") + .optional(), + sleep: z + .string() + .min(1, "trendAnnotations.sleep required when emitted") + .max(200, "trendAnnotations.sleep must be <= 200 chars") + .optional(), + resting_hr: z + .string() + .min(1, "trendAnnotations.resting_hr required when emitted") + .max(200, "trendAnnotations.resting_hr must be <= 200 chars") + .optional(), + steps: z + .string() + .min(1, "trendAnnotations.steps required when emitted") + .max(200, "trendAnnotations.steps must be <= 200 chars") + .optional(), + active_energy: z + .string() + .min(1, "trendAnnotations.active_energy required when emitted") + .max(200, "trendAnnotations.active_energy must be <= 200 chars") + .optional(), }); export type TrendAnnotations = z.infer;