From c334be22e91ccb47e1f120688da16c72acea677a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 19 Jun 2026 20:00:04 +0200 Subject: [PATCH] feat(coach): tier history context + illness and learn-guide awareness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the Coach snapshot's core-metric history onto the shared tiered series so blood pressure, weight, and pulse carry a coarse tail the in-window weekly fold could not produce — 90d-1y monthly and >1y yearly buckets plus the preserved peak/trough envelope, so a spike from months ago survives a coarse bucket. The tiered read skips the raw 0-14d band the snapshot already holds, so no duplicate round-trip; the coarse tail is bounded per band and sheds first under the prompt-budget degrader, so token size holds. Surface the active illness episodes and Rest Mode state to the Coach prompt: the snapshot already carries the illness block, and the system prompt now instructs the Coach to read an off-band vital as plausibly illness-explained and avoid pushing a heavier cadence while the user is in Rest Mode. Add a static catalog of the public /learn guides and inject a compact list into the system prompt. The Coach may reference at most one guide by topic and URL when genuinely helpful, and only a slug the catalog lists — a fabricated /learn URL is impossible by construction. --- src/lib/ai/coach/learn-catalog.ts | 110 +++++++++++++++++++++++++++ src/lib/ai/coach/snapshot.ts | 119 ++++++++++++++++++++++++++++++ src/lib/ai/coach/system-prompt.ts | 34 ++++++++- src/lib/rollups/tiered-context.ts | 11 ++- 4 files changed, 272 insertions(+), 2 deletions(-) create mode 100644 src/lib/ai/coach/learn-catalog.ts diff --git a/src/lib/ai/coach/learn-catalog.ts b/src/lib/ai/coach/learn-catalog.ts new file mode 100644 index 000000000..b1c3f38cf --- /dev/null +++ b/src/lib/ai/coach/learn-catalog.ts @@ -0,0 +1,110 @@ +/** + * v1.18.7 — static catalog of the public /learn guides for the Coach. + * + * The Coach is article-aware: when a reply would genuinely benefit from a + * deeper read, it MAY point the user at the matching guide by topic + URL. + * The article wording itself lives on the public site (docs.healthlog.dev / + * healthlog.dev/learn) — the Coach never invents article content and may only + * reference a slug that appears in this catalog, so a fabricated /learn URL is + * impossible by construction. + * + * Keep this list in lock-step with the published guides. The compact prompt + * injection (`learnCatalogPromptBlock`) groups entries by topic so the model + * picks the most relevant single link rather than dumping the whole list. + */ + +const LEARN_BASE_URL = "https://healthlog.dev/learn"; + +export interface LearnGuide { + /** URL slug under /learn. */ + slug: string; + /** Concise English title. */ + title: string; + /** 3–6 word topic the guide covers, for matching. */ + topic: string; + /** Absolute public URL. */ + url: string; +} + +function guide(slug: string, title: string, topic: string): LearnGuide { + return { slug, title, topic, url: `${LEARN_BASE_URL}/${slug}` }; +} + +/** + * The 19 published /learn guides. Order groups roughly by domain (overview → + * cardiovascular → sleep/respiratory → metabolic → body → mood/cycle → + * device/trends → lifestyle) so the prompt block reads coherently. + */ +export const LEARN_GUIDES: readonly LearnGuide[] = [ + guide( + "understanding-your-health-metrics", + "Understanding Your Health Metrics", + "what each metric means", + ), + guide("resting-heart-rate", "Resting Heart Rate", "resting heart rate"), + guide( + "heart-rate-variability", + "Heart Rate Variability", + "HRV and autonomic balance", + ), + guide( + "reading-your-blood-pressure", + "Reading Your Blood Pressure", + "blood pressure readings", + ), + guide("sleep-consistency", "Sleep Consistency", "consistent sleep timing"), + guide("respiratory-rate", "Respiratory Rate", "breathing rate at rest"), + guide("blood-oxygen-spo2", "Blood Oxygen (SpO2)", "blood oxygen saturation"), + guide( + "body-temperature-baseline", + "Body Temperature Baseline", + "body temperature deviation", + ), + guide( + "blood-sugar-beyond-diabetes", + "Blood Sugar Beyond Diabetes", + "glucose for everyone", + ), + guide( + "vo2max-and-longevity", + "VO2 Max and Longevity", + "cardiorespiratory fitness", + ), + guide("beyond-the-scale", "Beyond the Scale", "weight and body composition"), + guide("tracking-mood", "Tracking Mood", "mood and wellbeing"), + guide( + "the-cycle-as-a-vital-sign", + "The Cycle as a Vital Sign", + "menstrual cycle health", + ), + guide( + "how-wearables-measure-you", + "How Wearables Measure You", + "how devices read vitals", + ), + guide("reading-your-trends", "Reading Your Trends", "interpreting trends"), + guide("steps-and-movement", "Steps and Movement", "daily activity"), + guide( + "caffeine-alcohol-and-your-readings", + "Caffeine, Alcohol and Your Readings", + "caffeine and alcohol effects", + ), + guide("hydration-and-your-body", "Hydration and Your Body", "hydration"), + guide("stress-and-recovery", "Stress and Recovery", "stress and recovery"), +] as const; + +/** + * Compact, token-light prompt block listing every guide as `topic — url`. + * Injected into the Coach system prompt so the model can offer the relevant + * link. One line per guide keeps it ~600 chars; the model picks at most one. + */ +export function learnCatalogPromptBlock(): string { + const lines = LEARN_GUIDES.map((g) => `- ${g.topic}: ${g.url}`).join("\n"); + return [ + "LEARN GUIDES (public articles you MAY link when genuinely helpful):", + lines, + "Only link a URL from this list — never invent a /learn URL or article", + "content. Reference at most one guide, and only when it adds real value,", + 'e.g. "more on this: ' + LEARN_GUIDES[1].url + '".', + ].join("\n"); +} diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index 339482f58..5879d4188 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -53,6 +53,8 @@ import { buildReferenceGroundingBlock, type GroundingMetricInput, } from "./reference-grounding"; +import { buildTieredSeries } from "@/lib/rollups/tiered-context"; +import type { MeasurementType } from "@/generated/prisma/client"; import type { ReferenceMetric } from "@/lib/reference-ranges"; import { isCycleAvailableForUser } from "@/lib/cycle/gate"; import { resolveModuleMap, type ModuleKey } from "@/lib/modules/gate"; @@ -368,6 +370,76 @@ function buildDailyValueRows( .sort((a, b) => a.date.localeCompare(b.date)); } +/** + * v1.18.7 — coarse tail + anomaly envelope for one metric, from the shared + * tiered-context builder. The Coach already ships the 0–14d raw + 30–90d + * weekly band (above); this adds only the bands the weekly-fold CANNOT + * produce — 90d–1y MONTH, >1y YEAR, and the preserved peak/trough envelope — + * so a glucose spike or a BP outlier from months ago survives a coarse + * bucket. Token-bounded by the helper's per-band row caps (~10 month + ≤5 + * year + ≤5 anomalies ≈ 240 tokens), and the degrader sheds it first (it is + * the lowest-value, oldest detail). Returns `undefined` when the metric has + * no coarse history at all, so the block is omitted rather than shipped empty. + */ +interface CoarseTimelineTail { + /** 90d–1y monthly buckets: `[bucketStart, mean, min, max]`. */ + monthly: Array<[string, number, number, number]>; + /** >1y yearly buckets: `[bucketStart, mean, min, max]`. */ + yearly: Array<[string, number, number, number]>; + /** Preserved peaks/troughs, ≤5, ranked by |deltaSd|. */ + anomalies: Array<{ + band: string; + date: string; + kind: string; + value: number; + deltaSd: number; + }>; +} + +async function buildCoarseTimelineTail( + userId: string, + type: MeasurementType, + now: Date, +): Promise { + // `buildTieredSeries` reads fall back on miss — a coverage miss yields empty + // bands, which collapse to `undefined` here. The try/catch keeps the coarse + // tail a strictly additive enrichment: any unexpected reader failure simply + // drops the tail rather than failing the whole snapshot (matching the + // "fall back on miss" contract the rest of the snapshot honours). + let series: Awaited>; + try { + // `skipRecentDaily` — the snapshot already holds the raw 0–14d rows from + // its own window read, so the tiered builder skips that duplicate raw + // round-trip and returns only the coarse MONTH/YEAR bands + anomalies. + series = await buildTieredSeries(userId, type, { + now: now.getTime(), + skipRecentDaily: true, + }); + } catch { + return undefined; + } + const monthly = series.monthBand.map( + (b) => [b.bucketStart, b.mean, b.min, b.max] as [string, number, number, number], + ); + const yearly = series.yearBand.map( + (b) => [b.bucketStart, b.mean, b.min, b.max] as [string, number, number, number], + ); + if (monthly.length === 0 && yearly.length === 0 && series.anomalies.length === 0) { + return undefined; + } + return { + monthly, + yearly, + anomalies: series.anomalies.map((a) => ({ + band: a.band, + date: a.date, + kind: a.kind, + value: a.value, + deltaSd: a.deltaSd, + })), + }; +} + /** * Pair systolic + diastolic into one row per day. Days with only one * side measured are dropped — the Coach is asked about "BP" as a pair, @@ -772,6 +844,43 @@ async function buildCoachSnapshotImpl( const wantsMood = sources.has("mood"); const wantsCompliance = sources.has("compliance"); + // v1.18.7 — coarse tail (90d–1y MONTH + >1y YEAR) + anomaly envelope for the + // core clinical metrics, from the shared tiered-context builder. Only the + // bands the in-window weekly-fold cannot produce are fetched here; the recent + // + weekly bands stay as built below. Bounded per-band, so this holds or + // reduces the per-metric token cost while letting the Coach see a spike from + // months ago. Reads never throw — a coverage miss yields `undefined`, the + // block is then omitted. Run in parallel; awaited at the block site. + const coarseTailPromises: Partial< + Record<"bp" | "weight" | "pulse", Promise> + > = {}; + if (wantsBp) { + coarseTailPromises.bp = buildCoarseTimelineTail( + userId, + "BLOOD_PRESSURE_SYS" as MeasurementType, + now, + ); + } + if (wantsWeight) { + coarseTailPromises.weight = buildCoarseTimelineTail( + userId, + "WEIGHT" as MeasurementType, + now, + ); + } + if (wantsPulse) { + coarseTailPromises.pulse = buildCoarseTimelineTail( + userId, + "PULSE" as MeasurementType, + now, + ); + } + const [bpCoarseTail, weightCoarseTail, pulseCoarseTail] = await Promise.all([ + coarseTailPromises.bp ?? Promise.resolve(undefined), + coarseTailPromises.weight ?? Promise.resolve(undefined), + coarseTailPromises.pulse ?? Promise.resolve(undefined), + ]); + // v1.4.23 W6 (S-04) — single source of truth for the // CoachScopeSource → MeasurementType[] mapping. Drives both the // SQL `WHERE type IN (…)` build below and the Apple-Health timeline @@ -1162,6 +1271,7 @@ async function buildCoachSnapshotImpl( recent: recentDaily, weeklySys: bucketWeekly(olderSys, userTz), weeklyDia: bucketWeekly(olderDia, userTz), + ...(bpCoarseTail ? { coarse: bpCoarseTail } : {}), }, }; metrics.add("bp"); @@ -1184,6 +1294,7 @@ async function buildCoachSnapshotImpl( rows.filter((r) => r.measuredAt < recentCutoff), userTz, ), + ...(weightCoarseTail ? { coarse: weightCoarseTail } : {}), }, }; metrics.add("weight"); @@ -1209,6 +1320,7 @@ async function buildCoachSnapshotImpl( rows.filter((r) => r.measuredAt < recentCutoff), userTz, ), + ...(pulseCoarseTail ? { coarse: pulseCoarseTail } : {}), }, }; metrics.add("pulse"); @@ -2086,6 +2198,13 @@ function degradeToBudget( delete timeline.recent; changed = true; } + // v1.18.7 — the coarse MONTH/YEAR tail + anomaly envelope is the + // lowest-value, oldest detail, so it sheds in the same first pass as the + // dense per-day rows. + if (timeline && "coarse" in timeline) { + delete timeline.coarse; + changed = true; + } const byContext = asRecord(block.byContext); if (byContext) { for (const ctx of Object.keys(byContext)) { diff --git a/src/lib/ai/coach/system-prompt.ts b/src/lib/ai/coach/system-prompt.ts index 0c94d276b..826b7af66 100644 --- a/src/lib/ai/coach/system-prompt.ts +++ b/src/lib/ai/coach/system-prompt.ts @@ -19,6 +19,7 @@ import type { Locale } from "@/lib/i18n/config"; import { PROMPT_VERSION } from "@/lib/ai/prompts/insight-generator"; import { buildNativeCoachPrompt } from "@/lib/ai/prompts/native-prompts"; +import { learnCatalogPromptBlock } from "./learn-catalog"; import { DEFAULT_COACH_PREFS, type CoachPrefs, @@ -223,6 +224,13 @@ ISO-week means. available day from the timeline. - Older weekday questions (>14 days back) fall back to "timeline.weekly" or, if neither has the day, the aggregate. +- A metric MAY also carry "timeline.coarse" — the longer-horizon tail: + "monthly" and "yearly" buckets each as [start, mean, min, max], plus + an "anomalies" list of preserved peaks/troughs ({ band, date, kind, + value, deltaSd }). Use it for "compared with last year" or "have I + ever been this high" questions and to acknowledge a months-old spike + the weekly view would hide. Cite the bucket's date; never read a single + coarse bucket as a present-day reading. - The "scope" object names the user-selected window + sources. When the user asks about a metric that isn't listed in scope.sources, say it isn't part of this conversation's scope. @@ -294,6 +302,16 @@ ISO-week means. context, DESCRIPTIVE not diagnostic — never restate a "condition" fact as a medical finding, and never invent a fact the block does not carry. If a fact seems outdated, gently check it rather than assume. +- The SNAPSHOT MAY carry an "illness" block: { restMode, active[], + recentResolved[] }. When "restMode" is true the user has one or more + ACTIVE conditions right now (each with a label, type, lifecycle, and + onset date) — read an off-band vital or a low recovery as plausibly + illness-explained rather than alarming, do not push a heavier + measurement cadence, and lean toward rest-and-recover framing. Treat + the labels as the user's OWN descriptive context, never a diagnosis; + "recentResolved" is light history for "how often do I get sick" + questions. When no "illness" block is present, assume nothing about + the user's health status. EVIDENCE BLOCK @@ -659,6 +677,15 @@ ISO-Wochenmittel zusammen. nie als medizinischen Befund um und erfinde nie einen Fakt, den der Block nicht enthält. Wirkt ein Fakt veraltet, frage behutsam nach, statt es anzunehmen. +- Der SNAPSHOT KANN einen "illness"-Block tragen: { restMode, active[], + recentResolved[] }. Ist "restMode" true, hat der Nutzer gerade eine oder + mehrere AKTIVE Erkrankungen (je mit Label, Typ, Lebenszyklus, Beginn) — + lies einen abweichenden Vitalwert oder eine niedrige Erholung eher als + krankheitsbedingt denn als alarmierend, dränge nicht auf eine engere + Mess-Kadenz und neige zu einem Ruhe-und-Erholung-Rahmen. Behandle die + Labels als die EIGENE beschreibende Angabe des Nutzers, nie als Diagnose; + "recentResolved" ist leichte Historie für "wie oft bin ich krank". + Fehlt der "illness"-Block, nimm nichts über den Gesundheitszustand an. EVIDENZ-BLOCK @@ -838,10 +865,15 @@ export function getCoachSystemPrompt( } const prefix = buildPrefsPrefix(locale, prefs); const withPrefix = prefix ? `${prefix}\n\n${base}` : base; + // v1.18.7 — article-awareness: a compact catalog of the public /learn + // guides so the Coach can point the user at the relevant deeper read. The + // block is locale-independent (English topic labels + URLs) and the model + // may only link a slug it lists, so a fabricated /learn URL is impossible. + const withLearn = `${withPrefix}\n\n${learnCatalogPromptBlock()}`; // v1.15.20 — the user-authored "about me" self-description rides the // system prompt as a delimited, user-provided block (Settings → AI). const suffix = aboutMe ? buildAboutMeBlock(locale, aboutMe) : ""; - return suffix ? `${withPrefix}\n\n${suffix}` : withPrefix; + return suffix ? `${withLearn}\n\n${suffix}` : withLearn; } /** diff --git a/src/lib/rollups/tiered-context.ts b/src/lib/rollups/tiered-context.ts index d05761879..accf0d0c3 100644 --- a/src/lib/rollups/tiered-context.ts +++ b/src/lib/rollups/tiered-context.ts @@ -117,6 +117,13 @@ export interface BuildTieredSeriesOptions { * `|deltaSd|` before the cap. */ maxAnomalies?: number; + /** + * v1.18.7 — skip the raw 0–14d `Measurement` read and return an empty + * `recentDaily`. For callers (the Coach snapshot) that already hold the + * recent daily rows from their own query and only need the coarser + * MONTH/YEAR bands + anomaly envelope; avoids a duplicate raw round-trip. + */ + skipRecentDaily?: boolean; } interface RollupBucket { @@ -275,7 +282,9 @@ export async function buildTieredSeries( const [recentDaily, dayBand, weekBand, monthBand, yearBand] = await Promise.all([ - readRecentDaily(userId, type, now), + options.skipRecentDaily + ? Promise.resolve([]) + : readRecentDaily(userId, type, now), readBand(userId, type, "DAY", TIERED_BANDS.dayUntil, TIERED_BANDS.rawDays, now), readBand(userId, type, "WEEK", TIERED_BANDS.weekUntil, TIERED_BANDS.dayUntil, now), readBand(userId, type, "MONTH", TIERED_BANDS.monthUntil, TIERED_BANDS.weekUntil, now),