mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
1 parent
2fbc0650a6
commit
f2bc4f478a
12 files changed
+538
-29
No files matched your search
+10
-1
@@ -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
@@ -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+$/);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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>;
|
||||
|
||||
Reference in new issue
Block a user