feat(coach): Apple Health metric slots + PROMPT_VERSION 4.23.0

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) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-11 01:07:03 +02:00
1 parent 2fbc0650a6
commit f2bc4f478a
12 files changed
+538 -29

No files matched your search

+10 -1
View File
@@ -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",
+10 -1
View File
@@ -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",
@@ -31,6 +31,20 @@ const METRIC_KEYS: Record<CoachProvenance["metrics"][number], string> = {
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.<token>` 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<CoachProvenance["windows"][number], string> = {
@@ -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<DailyBriefingKeyFinding["tone"], string> = {
@@ -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/);
});
});
@@ -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);
});
@@ -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<typeof vi.fn> };
moodEntry: { findMany: ReturnType<typeof vi.fn> };
medicationIntakeEvent: { findMany: ReturnType<typeof vi.fn> };
};
const featuresMock = extractFeatures as unknown as ReturnType<typeof vi.fn>;
/**
* 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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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");
});
});
@@ -14,9 +14,12 @@ import { getCoachSystemPrompt } from "../system-prompt";
* 3. The few-shot tone-calibration `<example>` 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+$/);
});
});
+66
View File
@@ -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");
}
+63 -4
View File
@@ -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,
+41 -5
View File
@@ -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)",
+56 -2
View File
@@ -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<typeof trendAnnotationsSchema>;