mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
6fc4b62988
commit
22ffb19fa5
6 files changed
+714
-1
No files matched your search
@@ -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(),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user