mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge wave-coachctx: tiered + illness/article-aware Coach context
This commit is contained in:
commit
c29dae7ad9
4 files changed
+272
-2
No files matched your search
@@ -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");
|
||||
}
|
||||
@@ -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<CoarseTimelineTail | undefined> {
|
||||
// `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<ReturnType<typeof buildTieredSeries>>;
|
||||
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<CoarseTimelineTail | undefined>>
|
||||
> = {};
|
||||
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)) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<TieredRawPoint[]>([])
|
||||
: 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),
|
||||
|
||||
Reference in new issue
Block a user