feat(coach): ground replies in cited reference ranges

Surface the population reference bands behind the metrics a Coach turn
carries as a compact, citation-aware grounding block appended after the
SNAPSHOT. For each present metric the block states the headline band, its
source (blood pressure on the ESH 2023 line, US/ESC framings as context
only), and where the user's value sits via the shared four-state
placement contract.

Framed strictly as general guidance: the block opens and closes with a
not-a-diagnosis caveat, never overrides the existing refusal/dose/drug-
level safety discipline, and carries no commercial brand name. Glucose
respects the diabetes opt-in — the tighter ADA management goal only when
the user has declared it, otherwise the general non-diabetic band.

The block is deterministic and inspectable: built purely from the
reference-range backbone over the same scalars the snapshot already
surfaces, so it can never cite a value or band the snapshot does not
carry.
This commit is contained in:
Marc-André Bombeck committed 2026-06-18 20:32:45 +02:00
1 parent 6fc4b62988
commit 22ffb19fa5
6 files changed
+714 -1

No files matched your search

+10 -1
View File
@@ -393,9 +393,18 @@ The user's message answers this clarifying question from their self-context ques
React briefly and personally to the answer; do not repeat the question and do not ask it again.
`
: "";
// v1.18.6 (W7) — citation-aware reference-range grounding for the metrics
// present in this snapshot. Deterministic + brand-free; framed as general
// guidance, never a diagnosis. Appended after the SNAPSHOT so it is fully
// inspectable and the model reads the published population bands + the
// user's placement. Omitted entirely when no present metric is covered by
// the reference backbone (the builder returns null).
const groundingBlock = snapshot.referenceGrounding
? `\n${snapshot.referenceGrounding}\n`
: "";
const userPrompt = `SNAPSHOT
${snapshot.snapshotJson || "(no metric data in this user's log yet)"}
${guidedBlock}
${groundingBlock}${guidedBlock}
CONVERSATION
${transcript}
@@ -0,0 +1,202 @@
import { describe, expect, it } from "vitest";
import { buildReferenceGroundingBlock } from "../reference-grounding";
import { CITATIONS } from "@/lib/medical-citations";
/**
* W7 — the Coach reference-grounding block. These tests pin the
* deterministic shape the hallucination-QA pass audits: the right band per
* metric, the right four-state placement, the general-guidance caveat, the
* ESH-2023 blood-pressure label, the diabetes opt-in gating, and the hard
* no-commercial-brand rule.
*/
/**
* Every commercial brand the Coach prompt already names elsewhere (the
* GLP-1 block / dose-deferral rule). The grounding block must contain NONE
* of them — it is brand-free by construction, and this list is the guard.
*/
const BRAND_TOKENS = [
"Mounjaro",
"Ozempic",
"Wegovy",
"Zepbound",
"Trulicity",
"Saxenda",
"Rybelsus",
"Eli Lilly",
"Novo Nordisk",
"Withings",
"WHOOP",
"Oura",
"Fitbit",
"Garmin",
"Apple",
"Polar",
"Nightscout",
"Dexcom",
"Libre",
];
describe("buildReferenceGroundingBlock", () => {
it("returns null when no metric is covered by the backbone", () => {
expect(buildReferenceGroundingBlock({ metrics: [], hasDiabetes: false })).toBeNull();
});
it("cites the ESH 2023 source for blood pressure and never the US/ESC line", () => {
const block = buildReferenceGroundingBlock({
metrics: [{ metric: "BLOOD_PRESSURE", value: 118 }],
hasDiabetes: false,
});
expect(block).not.toBeNull();
expect(block).toContain("ESH 2023");
// The headline normal band edge (≤120 mmHg) is surfaced.
expect(block).toContain("120 mmHg");
// The conflicting US/ESC framings stay out of the per-line copy; only
// the preamble's neutral "(ACC/AHA)" context mentions them.
expect(block).not.toContain("Stage 1");
// 118 sits inside the optimal band → "within" placement phrasing.
expect(block).toContain("sits inside the general reference band");
});
it("places an out-of-band value as outside", () => {
const block = buildReferenceGroundingBlock({
metrics: [{ metric: "BLOOD_PRESSURE", value: 165 }],
hasDiabetes: false,
});
expect(block).toContain("sits outside the general reference band");
});
it("opens and closes with a general-guidance, not-a-diagnosis caveat", () => {
const block = buildReferenceGroundingBlock({
metrics: [{ metric: "BMI", value: 22 }],
hasDiabetes: false,
})!;
expect(block).toContain("general guidance");
expect(block).toContain("not a diagnosis");
expect(block.toLowerCase()).toContain(
"not personal medical advice",
);
// The closing reminder line is present.
expect(block).toMatch(/general guidance, not personal medical advice\.$/);
});
it("grounds glucose against the general non-diabetic band when the opt-in is off", () => {
const block = buildReferenceGroundingBlock({
metrics: [{ metric: "BLOOD_GLUCOSE", value: 92 }],
hasDiabetes: false,
})!;
expect(block).toContain("general non-diabetic normal");
// The non-diabetic fasting normal ceiling is 100 mg/dL.
expect(block).toContain("100 mg/dL");
expect(block).not.toContain("80–130");
expect(block).toContain("sits inside the general reference band");
});
it("grounds glucose against the tighter ADA goal band when diabetes is opted in", () => {
const inGoal = buildReferenceGroundingBlock({
metrics: [{ metric: "BLOOD_GLUCOSE", value: 110 }],
hasDiabetes: true,
})!;
// The ADA diabetic fasting goal band, framed as a clinician-set goal.
expect(inGoal).toContain("80–130 mg/dL");
expect(inGoal).toContain("management goal");
expect(inGoal).toContain("clinician-set goal");
// The line explicitly frames the band as a goal, NOT a screening line.
expect(inGoal).toContain("not a screening line");
expect(inGoal).toContain("inside the typical diabetes management goal");
const outOfGoal = buildReferenceGroundingBlock({
metrics: [{ metric: "BLOOD_GLUCOSE", value: 200 }],
hasDiabetes: true,
})!;
expect(outOfGoal).toContain("outside the typical diabetes management goal");
});
it("treats a band-less metric (HRV) as baseline-led, never out-of-band", () => {
const block = buildReferenceGroundingBlock({
metrics: [{ metric: "HEART_RATE_VARIABILITY", value: 45 }],
hasDiabetes: false,
})!;
expect(block).toContain("no fixed population band");
expect(block).not.toContain("sits outside");
});
it("is deterministic — identical inputs produce byte-identical output", () => {
const input = {
metrics: [
{ metric: "BLOOD_PRESSURE" as const, value: 134 },
{ metric: "BLOOD_GLUCOSE" as const, value: 105 },
{ metric: "SLEEP_DURATION" as const, value: 7.5 },
],
hasDiabetes: false,
};
expect(buildReferenceGroundingBlock(input)).toEqual(
buildReferenceGroundingBlock(input),
);
});
it("caps the number of grounded lines and de-dupes metrics", () => {
const block = buildReferenceGroundingBlock({
metrics: [
{ metric: "BMI", value: 22 },
{ metric: "BMI", value: 23 },
],
hasDiabetes: false,
})!;
// De-duped: only one BMI line.
const bmiLines = block.split("\n").filter((l) => l.startsWith("- BMI:"));
expect(bmiLines).toHaveLength(1);
});
it("contains NO commercial brand token in any branch", () => {
// Exercise both the non-diabetic and diabetic glucose branches plus a
// broad metric spread so every code path's text is scanned.
const blocks = [
buildReferenceGroundingBlock({
metrics: [
{ metric: "BLOOD_PRESSURE", value: 130 },
{ metric: "BLOOD_GLUCOSE", value: 95 },
{ metric: "RESTING_HEART_RATE", value: 58 },
{ metric: "OXYGEN_SATURATION", value: 97 },
{ metric: "RESPIRATORY_RATE", value: 16 },
{ metric: "BODY_TEMPERATURE", value: 36.8 },
{ metric: "BMI", value: 27 },
{ metric: "STEPS", value: 9000 },
{ metric: "VISCERAL_FAT", value: 8 },
{ metric: "SLEEP_DURATION", value: 8 },
{ metric: "HEART_RATE_VARIABILITY", value: 40 },
],
hasDiabetes: false,
}),
buildReferenceGroundingBlock({
metrics: [{ metric: "BLOOD_GLUCOSE", value: 140 }],
hasDiabetes: true,
}),
];
for (const block of blocks) {
expect(block).not.toBeNull();
for (const brand of BRAND_TOKENS) {
expect(block!.toLowerCase()).not.toContain(brand.toLowerCase());
}
}
});
it("only cites real entries from the medical-citations registry", () => {
const block = buildReferenceGroundingBlock({
metrics: [
{ metric: "BLOOD_PRESSURE", value: 120 },
{ metric: "STEPS", value: 9000 },
{ metric: "SLEEP_DURATION", value: 8 },
],
hasDiabetes: false,
})!;
// Each cited source label ("NAME (YEAR)") corresponds to a real
// registry entry — no fabricated guideline names.
const citationNames = Object.values(CITATIONS).map((c) => c.name);
// The BP / steps / sleep headline sources must all appear by name.
expect(citationNames.some((n) => block.includes(n))).toBe(true);
expect(block).toContain("ESH 2023");
expect(block).toContain("AASM");
});
});
@@ -897,3 +897,96 @@ describe("Coach snapshot — free-text fields wrap through sanitizeForPrompt (L-
expect(hit).toBe(true);
});
});
// v1.18.6 (W7) — the citation-aware reference-range grounding rides through
// buildCoachSnapshot for the metrics present, gated on the W6 diabetes opt-in.
describe("buildCoachSnapshot — reference grounding (W7)", () => {
beforeEach(() => {
vi.clearAllMocks();
__resetCoachSnapshotCacheForTests();
prismaMock.measurement.findMany.mockResolvedValue([]);
prismaMock.moodEntry.findMany.mockResolvedValue([]);
prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([]);
prismaMock.medication.findMany.mockResolvedValue([]);
prismaMock.user.findUnique.mockResolvedValue({ coachPrefsJson: null });
resolveModuleMapMock.mockResolvedValue(allModulesEnabled());
featuresMock.mockResolvedValue({});
});
it("attaches an ESH-2023-cited grounding block when blood pressure is present", async () => {
featuresMock.mockResolvedValue({
bloodPressure: { avgSys30: 138, avgDia30: 85, coverage: { count: 4 } },
});
prismaMock.measurement.findMany.mockResolvedValue([
daysAgo(2, 138, "BLOOD_PRESSURE_SYS"),
daysAgo(2, 85, "BLOOD_PRESSURE_DIA"),
]);
const out = await buildCoachSnapshot("user-1", { sources: ["bp"] });
expect(out.referenceGrounding).not.toBeNull();
expect(out.referenceGrounding).toContain("REFERENCE GROUNDING");
expect(out.referenceGrounding).toContain("ESH 2023");
// 138 sits in the high-normal band (130–139), two tiers past optimal →
// the four-state contract reads that as "outside".
expect(out.referenceGrounding).toContain(
"sits outside the general reference band",
);
expect(out.referenceGrounding).toContain("not a diagnosis");
});
it("returns null grounding when no present metric is covered", async () => {
const out = await buildCoachSnapshot("user-1");
// Empty snapshot — no reference-covered scalar collected.
expect(out.referenceGrounding).toBeNull();
});
it("uses the general non-diabetic glucose band when hasDiabetes is unset", async () => {
featuresMock.mockResolvedValue({});
prismaMock.measurement.findMany.mockResolvedValue([
{ ...daysAgo(2, 95, "BLOOD_GLUCOSE"), glucoseContext: "FASTING" },
]);
prismaMock.user.findUnique.mockResolvedValue({
coachPrefsJson: null,
hasDiabetes: false,
});
const out = await buildCoachSnapshot("user-1", { sources: ["glucose"] });
expect(out.referenceGrounding).toContain("general non-diabetic normal");
expect(out.referenceGrounding).not.toContain("management goal");
});
it("uses the tighter ADA goal band when hasDiabetes is opted in", async () => {
featuresMock.mockResolvedValue({});
prismaMock.measurement.findMany.mockResolvedValue([
{ ...daysAgo(2, 150, "BLOOD_GLUCOSE"), glucoseContext: "FASTING" },
]);
prismaMock.user.findUnique.mockResolvedValue({
coachPrefsJson: null,
hasDiabetes: true,
});
const out = await buildCoachSnapshot("user-1", { sources: ["glucose"] });
expect(out.referenceGrounding).toContain("80–130 mg/dL");
expect(out.referenceGrounding).toContain("management goal");
// 150 fasting is outside the diabetic goal band.
expect(out.referenceGrounding).toContain(
"outside the typical diabetes management goal",
);
});
it("never emits a commercial brand token in the grounding block", async () => {
featuresMock.mockResolvedValue({
bloodPressure: { avgSys30: 120, coverage: { count: 4 } },
});
prismaMock.measurement.findMany.mockResolvedValue([
daysAgo(2, 120, "BLOOD_PRESSURE_SYS"),
daysAgo(2, 78, "BLOOD_PRESSURE_DIA"),
]);
const out = await buildCoachSnapshot("user-1", { sources: ["bp"] });
for (const brand of ["Mounjaro", "Withings", "WHOOP", "Oura", "Dexcom"]) {
expect(out.referenceGrounding!.toLowerCase()).not.toContain(
brand.toLowerCase(),
);
}
});
});
+252
View File
@@ -0,0 +1,252 @@
/**
* v1.18.6 — Coach reference-range grounding (W7).
*
* Turns the population reference bands W5 landed in
* `src/lib/reference-ranges.ts` into a compact, citation-aware text block
* the Coach prompt can read so a reply is GROUNDED in published guidance
* rather than the model's parametric memory of "what's normal". For each
* metric the snapshot actually carries, the block states:
* - the metric's headline reference band(s), cited to the publishing
* body + year (via `referenceLabel` → `medical-citations`), and
* - where the user's current value sits against those bands, using the
* SAME deterministic `classifyReference()` four-state contract the UI
* tiles use — never a recomputed placement.
*
* GUARDRAILS (firm — a hallucination-QA pass audits this exact text):
* - GENERAL guidance only. The block opens AND closes with a caveat that
* these are population anchors, not personal medical advice or a
* diagnosis. The Coach safety discipline (refusal rules, dose-deferral)
* is unchanged — this is read-only context, never an instruction.
* - NO commercial brand names. Every string is sourced from
* `reference-ranges.ts` (brand-free by construction) + the metric's
* natural-language label here. The unit test asserts the rendered
* block contains no brand token.
* - Blood pressure is labelled to ESH 2023 (the SHIPPED European
* default); the ACC/AHA + ESC framings ride only as `conflicts`
* CONTEXT, exactly as the band data authored them — never as the
* placement.
* - GLUCOSE respects the W6 `hasDiabetes` opt-in. When the user has NOT
* opted in, glucose is grounded against the GENERAL non-diabetic
* fasting bands. When they HAVE, the block states the tighter ADA
* glycemic GOAL band (fasting 80–130 mg/dL) as a clinician-set target,
* never a screening threshold and never a diagnosis.
*
* Deterministic + pure: same inputs → byte-identical block, no DB, no LLM,
* no per-call randomness. The block is surfaced verbatim in the Coach
* user-prompt after the SNAPSHOT so it is fully inspectable.
*
* Token budget: the block is bounded by the number of metrics present
* (one short line each) and a hard `MAX_GROUNDING_METRICS` cap, so even a
* 15-metric snapshot stays a few hundred tokens.
*/
import {
classifyReference,
getReferenceRange,
referenceLabel,
type ReferenceMetric,
type ReferencePlacement,
} from "@/lib/reference-ranges";
/**
* One metric the snapshot carries, paired with the user's current
* representative value in the metric's reference unit. The caller
* (`snapshot.ts`) resolves the value from the same aggregates it already
* built, so the grounding reads the SAME number the rest of the snapshot
* does.
*/
export interface GroundingMetricInput {
readonly metric: ReferenceMetric;
/**
* Current representative value in the metric's reference unit (e.g. the
* 30-day mean systolic in mmHg, the fasting-glucose mean in mg/dL). Null
* when the snapshot has the metric block but no usable scalar — the line
* still cites the band, with an `insufficient` placement.
*/
readonly value: number | null;
}
export interface ReferenceGroundingInput {
readonly metrics: readonly GroundingMetricInput[];
/**
* The user's explicit, declared diabetes opt-in (`User.hasDiabetes`,
* resolved by the W6 path). Selects the tighter ADA glucose GOAL band
* for the glucose line ONLY. Never inferred from a reading.
*/
readonly hasDiabetes: boolean;
}
/**
* Hard cap on grounding lines so a maxed-out cluster set cannot balloon
* the block. Metrics beyond the cap are dropped silently (the snapshot
* still carries their data; only the extra grounding line is shed). The
* cap sits above the universal-metric count so a normal account is never
* trimmed.
*/
const MAX_GROUNDING_METRICS = 14;
/**
* Natural-language label for each grounded metric. Deliberately the
* user-facing phrasing the Coach prompt already mandates (GROUND RULE 8 —
* never the DB enum). No brand names by construction.
*/
const METRIC_LABEL: Record<ReferenceMetric, string> = {
BLOOD_PRESSURE: "blood pressure (systolic)",
PULSE_PRESSURE: "pulse pressure",
MEAN_ARTERIAL_PRESSURE: "mean arterial pressure",
PULSE_WAVE_VELOCITY: "pulse-wave velocity",
RESTING_HEART_RATE: "resting heart rate",
HEART_RATE_VARIABILITY: "heart-rate variability",
OXYGEN_SATURATION: "oxygen saturation",
RESPIRATORY_RATE: "respiratory rate",
BODY_TEMPERATURE: "body temperature",
BLOOD_GLUCOSE: "fasting glucose",
HBA1C: "HbA1c",
BMI: "BMI",
STEPS: "daily steps",
VISCERAL_FAT: "visceral-fat rating",
SLEEP_DURATION: "sleep duration",
};
/**
* The ADA diabetic glycemic GOAL fasting band (mg/dL), kept in lock-step
* with `src/lib/targets/glucose-targets.ts` `DIABETIC_GOAL_BANDS.FASTING`.
* Only used to phrase the glucose grounding line when `hasDiabetes` is set;
* the placement still defers to the user's clinician, never to a band edge.
*/
const ADA_DIABETIC_FASTING_GOAL = { low: 80, high: 130 } as const;
/** Map a four-state placement to a short, general-guidance phrase. */
function placementPhrase(placement: ReferencePlacement): string {
switch (placement) {
case "within":
return "sits inside the general reference band";
case "slightly-outside":
return "sits just outside the general reference band";
case "outside":
return "sits outside the general reference band";
case "insufficient":
// No scalar, or a metric with no fixed population band (e.g. HRV) —
// the band is cited for education, the placement is left to the
// user's own baseline.
return "has no fixed population band — your own trend leads";
}
}
/**
* Render the half-open band as a compact "low–high unit" descriptor for the
* metric's headline (first/normal) band. Open-ended bounds render with a
* leading/trailing ≤ / ≥.
*/
function headlineBandText(metric: ReferenceMetric): string | null {
const range = getReferenceRange(metric);
if (!range || range.bands.length === 0) return null;
const band = range.bands[0];
const unit = range.unit;
if (band.low != null && band.high != null) {
return `${band.low}–${band.high} ${unit}`;
}
if (band.high != null) return `≤${band.high} ${unit}`;
if (band.low != null) return `≥${band.low} ${unit}`;
return null;
}
/**
* Build the glucose grounding line. Branches on the W6 diabetes opt-in:
* general non-diabetic fasting band by default, the tighter ADA glycemic
* GOAL band when the user declared diabetes. The diabetic branch frames the
* band as a clinician-set GOAL, never a screening threshold.
*/
function glucoseLine(value: number | null, hasDiabetes: boolean): string {
const label = METRIC_LABEL.BLOOD_GLUCOSE;
if (hasDiabetes) {
const { low, high } = ADA_DIABETIC_FASTING_GOAL;
// Placement against the diabetic GOAL band, framed as a target the
// user's clinician individualises — never a diagnostic call.
let where: string;
if (value == null || !Number.isFinite(value)) {
where = "no usable value this window";
} else if (value >= low && value <= high) {
where = "is inside the typical diabetes management goal";
} else {
where = "is outside the typical diabetes management goal";
}
return `- ${label}: a common diabetes management goal is ${low}–${high} mg/dL fasting (${referenceLabel(
"BLOOD_GLUCOSE",
)} — clinician-set goal, individualised, not a screening line). Yours ${where}.`;
}
const band = headlineBandText("BLOOD_GLUCOSE");
const placement = classifyReference("BLOOD_GLUCOSE", value);
return `- ${label}: general non-diabetic normal is ${band} (${referenceLabel(
"BLOOD_GLUCOSE",
)}). Yours ${placementPhrase(placement)}.`;
}
/** Build one standard (non-glucose) grounding line for a metric. */
function standardLine(metric: ReferenceMetric, value: number | null): string {
const label = METRIC_LABEL[metric];
const band = headlineBandText(metric);
const placement = classifyReference(metric, value);
const cite = referenceLabel(metric);
// Blood pressure cites ESH 2023 by construction (the reference-range
// headline band is sourced to ESH_2023_HYPERTENSION); the conflicts
// (ACC/AHA, ESC) stay out of the per-line copy and live in the block
// preamble's "guidelines disagree" note so the Coach never mislabels the
// shipped European line.
if (band) {
return `- ${label}: general reference ${band} (${cite}). Yours ${placementPhrase(
placement,
)}.`;
}
// No fixed band (HRV) — cite the source, defer wholly to the baseline.
return `- ${label}: ${placementPhrase(placement)} (${cite}).`;
}
/**
* Build the Coach reference-grounding block, or null when no metric in the
* input is covered by the reference backbone (so the prompt carries no
* empty section). The returned string is plain text with a stable shape:
* a header, a general-guidance preamble, one line per metric, and a
* closing caveat. The route appends it verbatim after the SNAPSHOT.
*/
export function buildReferenceGroundingBlock(
input: ReferenceGroundingInput,
): string | null {
// De-dupe + keep input order; only metrics the backbone covers.
const seen = new Set<ReferenceMetric>();
const ordered: GroundingMetricInput[] = [];
for (const m of input.metrics) {
if (seen.has(m.metric)) continue;
if (!getReferenceRange(m.metric)) continue;
seen.add(m.metric);
ordered.push(m);
if (ordered.length >= MAX_GROUNDING_METRICS) break;
}
if (ordered.length === 0) return null;
const lines = ordered.map((m) =>
m.metric === "BLOOD_GLUCOSE"
? glucoseLine(m.value, input.hasDiabetes)
: standardLine(m.metric, m.value),
);
// The preamble + closing caveat carry the firm general-guidance framing.
// Blood-pressure guideline disagreement is surfaced once here (context),
// never as a per-line placement, so the Coach reads ESH 2023 as the
// shipped line.
const header = "REFERENCE GROUNDING (general guidance — not a diagnosis)";
const preamble = [
"These are published POPULATION reference bands for context only — they",
"are not personal medical advice, not a diagnosis, and not a target set",
"for this user. The user's own baseline always leads the read. Cite a",
"band only to orient a reply (\"the general reference range is …\"); never",
"tell the user they \"have\" or \"don't have\" a condition from a band.",
"Blood pressure follows the European ESH 2023 line; US (ACC/AHA) and",
"ESC framings differ and are context, not the call. This block does not",
"change any safety rule above — keep deferring dose / diagnosis / drug-",
"level questions to the user's clinician exactly as instructed.",
].join("\n");
const closing =
"Reminder: ranges are general guidance, not personal medical advice.";
return `${header}\n${preamble}\n\n${lines.join("\n")}\n\n${closing}`;
}
+122
View File
@@ -49,6 +49,11 @@ import { buildCoachMemoryBlock } from "./memory-snapshot";
import { buildTrajectorySnapshotBlock } from "./trajectory-snapshot";
import { buildCycleSnapshotBlock } from "./cycle-snapshot";
import { buildIllnessSnapshotBlock } from "./illness-snapshot";
import {
buildReferenceGroundingBlock,
type GroundingMetricInput,
} from "./reference-grounding";
import type { ReferenceMetric } from "@/lib/reference-ranges";
import { isCycleAvailableForUser } from "@/lib/cycle/gate";
import { resolveModuleMap, type ModuleKey } from "@/lib/modules/gate";
import {
@@ -81,6 +86,15 @@ export interface CoachSnapshotResult {
* the model could see.
*/
provenance: CoachProvenance;
/**
* v1.18.6 (W7) — citation-aware reference-range grounding for the
* metrics present in this snapshot, or null when none is covered by the
* reference backbone. The Coach route appends it verbatim after the
* SNAPSHOT so the model reads published population bands + the user's
* placement (general guidance, never a diagnosis). Built deterministically
* from `src/lib/reference-ranges.ts`; carries no commercial brand name.
*/
referenceGrounding: string | null;
}
/**
@@ -565,6 +579,11 @@ async function buildCoachSnapshotImpl(
// display unit so the Coach reads the same number every other surface
// shows. Read it on this existing prefs hop (no extra round-trip).
glucoseUnit: true,
// v1.18.6 (W7) — the explicit, user-declared diabetes opt-in. Selects
// the tighter ADA glycemic GOAL band for the glucose reference-grounding
// line only; never inferred from a reading, never a diagnosis. Read on
// this existing prefs hop (no extra round-trip).
hasDiabetes: true,
},
});
const prefs = parseCoachPrefs(prefsRow?.coachPrefsJson);
@@ -652,6 +671,37 @@ async function buildCoachSnapshotImpl(
const metrics = new Set<CoachProvenance["metrics"][number]>();
const counts: NonNullable<CoachProvenance["counts"]> = {};
// v1.18.6 (W7) — representative scalar per reference-covered metric, in the
// metric's reference unit, collected as the blocks below build. Feeds the
// citation-aware grounding block (population band + the user's placement).
// Each entry reads the SAME number the snapshot already surfaces — no
// independent recompute — so the grounding can never cite a value the
// snapshot doesn't carry. A metric with a block but no clean scalar is left
// unset (the grounding line still cites its band with an insufficient
// placement only if it is added with a null value; we omit such metrics).
const groundingValues = new Map<ReferenceMetric, number>();
// Additive `MeasurementType` → reference metric, for the value-block loop.
const TYPE_TO_REFERENCE_METRIC: Record<string, ReferenceMetric> = {
RESTING_HEART_RATE: "RESTING_HEART_RATE",
OXYGEN_SATURATION: "OXYGEN_SATURATION",
RESPIRATORY_RATE: "RESPIRATORY_RATE",
PULSE_WAVE_VELOCITY: "PULSE_WAVE_VELOCITY",
BODY_TEMPERATURE: "BODY_TEMPERATURE",
BODY_MASS_INDEX: "BMI",
VISCERAL_FAT: "VISCERAL_FAT",
ACTIVITY_STEPS: "STEPS",
};
/** Mean of the most recent N daily-value rows (already user-tz day means). */
const recentRowsMean = (
rows: DailyValueRow[],
take = DAILY_TIMELINE_DAYS,
): number | null => {
if (rows.length === 0) return null;
const slice = rows.slice(-take);
const sum = slice.reduce((s, r) => s + r.value, 0);
return sum / slice.length;
};
// v1.7.0 — block registry. Maps each emitted snapshot top-level key
// to the cluster it belongs to so the soft-cap degradation pass
// (below) can walk blocks in reverse cluster-priority order and shed
@@ -1119,6 +1169,10 @@ async function buildCoachSnapshotImpl(
windows.add("last90days");
counts.bp = features.bloodPressure.coverage?.count ?? undefined;
registerBlock("bloodPressure", "bp");
// W7 grounding: systolic 30-day mean against the ESH 2023 office bands.
const sys30 =
features.bloodPressure.avgSys30 ?? features.bloodPressure.allTimeAvgSys;
if (sys30 != null) groundingValues.set("BLOOD_PRESSURE", sys30);
}
if (wantsWeight && features.weight) {
const rows = byType("WEIGHT");
@@ -1137,6 +1191,13 @@ async function buildCoachSnapshotImpl(
windows.add("last30days");
counts.weight = features.weight.coverage?.count ?? undefined;
registerBlock("weight", "weight");
// W7 grounding: weight has no population band, but the derived BMI does.
// Use the features-computed BMI (same value the weight tile shows) so the
// grounding reads the WHO band. A dedicated BODY_MASS_INDEX block (below)
// wins if the user also syncs a measured BMI series.
if (features.weight.bmi != null) {
groundingValues.set("BMI", features.weight.bmi);
}
}
if (wantsPulse && features.pulse) {
const rows = byType("PULSE");
@@ -1412,6 +1473,14 @@ async function buildCoachSnapshotImpl(
metrics.add(block.metric);
counts[block.metric] = rows.length;
registerBlock(block.snapshotKey, block.source);
// W7 grounding: when this additive series maps to a reference metric,
// record the recent daily mean (same per-day means the timeline shows).
const refMetric = TYPE_TO_REFERENCE_METRIC[block.type];
if (refMetric) {
const recentRows = buildDailyValueRows(rows, recentCutoff, userTz);
const mean = recentRowsMean(recentRows);
if (mean != null) groundingValues.set(refMetric, mean);
}
}
// ── v1.7.0 sleep block (with optional per-stage enrichment) ───────
@@ -1480,6 +1549,19 @@ async function buildCoachSnapshotImpl(
metrics.add("sleep");
counts.sleep = sleepRows.length;
registerBlock("sleep", "sleep");
// W7 grounding: recent nightly asleep duration in HOURS against the
// AASM 7–9 h band (the reference unit is hours; the snapshot stores
// minutes). Mean over the recent nights the block already reconstructed.
if (recentNights.length > 0) {
const meanMin =
recentNights.reduce(
(s, n) => s + (typeof n.minutes === "number" ? n.minutes : 0),
0,
) / recentNights.length;
if (meanMin > 0) {
groundingValues.set("SLEEP_DURATION", meanMin / 60);
}
}
}
}
@@ -1653,6 +1735,22 @@ async function buildCoachSnapshotImpl(
metrics.add("glucose");
counts.glucose = glucoseRows.length;
registerBlock("glucose", "glucose");
// W7 grounding: fasting glucose mean in RAW mg/dL (the reference band's
// unit), independent of the user's mmol/L display preference. The
// grounding line's band selection respects the W6 `hasDiabetes` opt-in;
// here we only feed the representative fasting value. Fall back to the
// overall mean when no row is tagged FASTING so the band is still cited.
const fastingRows = glucoseRows.filter(
(r) => String(r.glucoseContext).toUpperCase() === "FASTING",
);
const glucoseScalarRows =
fastingRows.length > 0 ? fastingRows : glucoseRows;
const glucoseMeanMgdl =
glucoseScalarRows.reduce((s, r) => s + r.value, 0) /
glucoseScalarRows.length;
if (Number.isFinite(glucoseMeanMgdl)) {
groundingValues.set("BLOOD_GLUCOSE", glucoseMeanMgdl);
}
}
}
@@ -1894,6 +1992,29 @@ async function buildCoachSnapshotImpl(
// `coach.snapshot.truncated` annotation when it sheds anything.
degradeToBudget(compactSnapshot, blockClusters);
// v1.18.6 (W7) — build the citation-aware reference-grounding block from the
// representative scalars collected above. Deterministic + pure; the route
// appends it verbatim after the SNAPSHOT. Null when no present metric is
// covered by the reference backbone. Insertion order follows the block-build
// order (BP first), giving a stable, inspectable block for the
// hallucination-QA pass.
const groundingMetrics: GroundingMetricInput[] = Array.from(
groundingValues.entries(),
).map(([metric, value]) => ({ metric, value }));
const referenceGrounding = buildReferenceGroundingBlock({
metrics: groundingMetrics,
hasDiabetes: prefsRow?.hasDiabetes ?? false,
});
if (referenceGrounding) {
annotate({
action: { name: "coach.grounding.attached" },
meta: {
metrics: groundingMetrics.map((m) => m.metric).sort(),
hasDiabetes: prefsRow?.hasDiabetes ?? false,
},
});
}
return {
snapshotJson: JSON.stringify(compactSnapshot, null, 2),
provenance: {
@@ -1901,6 +2022,7 @@ async function buildCoachSnapshotImpl(
metrics: Array.from(metrics),
counts: Object.keys(counts).length > 0 ? counts : undefined,
},
referenceGrounding,
};
}
+35
View File
@@ -189,6 +189,22 @@ GROUND RULES
day, decline and say the forecast is a descriptive estimate, not
a contraceptive method. When NO "cycle" block is present, do not
infer or guess a phase.
13. The user prompt MAY carry a "REFERENCE GROUNDING" block after the
SNAPSHOT. It lists published POPULATION reference bands for the
metrics present plus where the user's value sits, each cited to its
source (e.g. ESH 2023 for blood pressure). Use it ONLY as general
orientation — "the general reference range is …", "that sits a little
above the usual band". It is NOT personal medical advice, NOT a
diagnosis, and NOT a target for this user; the user's own baseline
still leads. Never tell the user they "have" or "don't have" a
condition from a band, never convert a band into a risk score, and
never let the grounding soften the safety rules above — keep deferring
dose / diagnosis / drug-level questions to their clinician. Blood
pressure reads on the European ESH 2023 line; the US (ACC/AHA) and
ESC framings differ and are context only. If the user has declared
diabetes, the glucose line states a clinician-set management GOAL, not
a screening threshold. When no REFERENCE GROUNDING block is present,
do not invent a band — pivot to the user's own trend.
DAY-LEVEL READINGS — USE THE TIMELINE
@@ -580,6 +596,25 @@ GRUNDREGELN
dass die Prognose eine beschreibende Schätzung ist, keine
Verhütungsmethode. Wenn KEIN "cycle"-Block vorhanden ist, leite
oder rate keine Phase ab.
13. Der User-Prompt kann nach dem SNAPSHOT einen "REFERENCE
GROUNDING"-Block mitbringen. Er nennt publizierte POPULATIONS-
Referenzbereiche für die vorhandenen Metriken plus die Einordnung des
Nutzerwerts, jeweils mit Quelle zitiert (z. B. ESH 2023 für den
Blutdruck). Nutze ihn AUSSCHLIESSLICH zur groben Orientierung — "der
allgemeine Referenzbereich liegt bei …", "das liegt etwas über dem
üblichen Bereich". Er ist KEINE persönliche medizinische Beratung,
KEINE Diagnose und KEIN Zielwert für diesen Nutzer; die eigene
Ausgangslage des Nutzers führt weiterhin. Sag dem Nutzer nie, er
"habe" oder "habe nicht" eine Erkrankung aus einem Bereich, mach aus
einem Bereich nie einen Risiko-Score, und lass das Grounding nie die
Sicherheitsregeln oben aufweichen — verweise Dosis-/Diagnose-/
Wirkstoffspiegel-Fragen weiter an die behandelnde Ärztin. Der
Blutdruck liest sich auf der europäischen ESH-2023-Linie; die US-
(ACC/AHA) und ESC-Einordnungen weichen ab und sind nur Kontext. Hat
der Nutzer Diabetes angegeben, nennt die Glukose-Zeile ein
klinisch gesetztes Management-ZIEL, keinen Screening-Schwellwert.
Ist kein REFERENCE-GROUNDING-Block vorhanden, erfinde keinen Bereich
— wechsle zum eigenen Trend des Nutzers.
TAGES-LEVEL-MESSWERTE — NUTZE DIE TIMELINE