mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
merge(W7): ground the Coach in cited reference ranges
Inject a deterministic, citation-aware grounding block into the coach snapshot/prompt for the metrics present in a user's snapshot: each metric's reference band + where the value sits, framed as general guidance, never a diagnosis, no brand attribution, BP labelled ESH 2023. Glucose respects the diabetes opt-in resolver. Also fixes the reference classifier to resolve the normal band by an explicit marker rather than index 0 (BMI/pulse-pressure were mis-placed) — correcting both the grounding and the in-app four-state explainer. Steps stay out of the grounding until step aggregation sums intra-day rows.
This commit is contained in:
commit
ae3091df9e
8 files changed
+886
-31
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}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
classifyReference,
|
||||
getReferenceRange,
|
||||
isReferenceMetric,
|
||||
normalBandIndex,
|
||||
referenceCitation,
|
||||
referenceLabel,
|
||||
type ReferenceMetric,
|
||||
@@ -54,6 +55,42 @@ describe("reference-ranges backbone", () => {
|
||||
expect(classifyReference("OXYGEN_SATURATION", 98)).toBe("within");
|
||||
});
|
||||
|
||||
it("every banded metric marks exactly one normal band", () => {
|
||||
for (const metric of REFERENCE_METRICS) {
|
||||
const bands = REFERENCE_RANGES[metric].bands;
|
||||
if (bands.length === 0) continue;
|
||||
const marked = bands.filter((b) => b.normal === true);
|
||||
expect(marked, `${metric} normal-band count`).toHaveLength(1);
|
||||
// `normalBandIndex` points at that marked band.
|
||||
expect(bands[normalBandIndex(metric)].normal, `${metric} index`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves the normal band by marker, not by index 0", () => {
|
||||
// BMI authors Underweight FIRST; the normal band is the 18.5–24.9 entry.
|
||||
expect(REFERENCE_RANGES.BMI.bands[0].label).toBe("Underweight");
|
||||
expect(REFERENCE_RANGES.BMI.bands[normalBandIndex("BMI")].label).toBe("Normal");
|
||||
// BMI 22 is healthy → within (the index-0 bug reported it slightly-outside).
|
||||
expect(classifyReference("BMI", 22)).toBe("within");
|
||||
// BMI 17 is underweight → one band below normal → slightly-outside
|
||||
// (the index-0 bug reported it as within).
|
||||
expect(classifyReference("BMI", 17)).toBe("slightly-outside");
|
||||
// BMI 27 overweight (one above) → slightly-outside; 35 obesity (two
|
||||
// above) → outside.
|
||||
expect(classifyReference("BMI", 27)).toBe("slightly-outside");
|
||||
expect(classifyReference("BMI", 35)).toBe("outside");
|
||||
});
|
||||
|
||||
it("classifies pulse pressure around its interior normal band", () => {
|
||||
// Narrow is the FIRST band; the normal band is "Typical at rest".
|
||||
expect(REFERENCE_RANGES.PULSE_PRESSURE.bands[0].label).toBe("Narrow");
|
||||
expect(classifyReference("PULSE_PRESSURE", 40)).toBe("within");
|
||||
// Narrow (≤25) is one band below normal → slightly-outside.
|
||||
expect(classifyReference("PULSE_PRESSURE", 20)).toBe("slightly-outside");
|
||||
// Wide (≥60) is one band above normal → slightly-outside.
|
||||
expect(classifyReference("PULSE_PRESSURE", 75)).toBe("slightly-outside");
|
||||
});
|
||||
|
||||
it("returns insufficient for absent values and band-less metrics", () => {
|
||||
expect(classifyReference("RESTING_HEART_RATE", null)).toBe("insufficient");
|
||||
expect(classifyReference("RESTING_HEART_RATE", Number.NaN)).toBe("insufficient");
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
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("grounds BMI against the WHO normal band, not the first (Underweight) band", () => {
|
||||
// The headline band rendered must be the 18.5–24.9 normal range — the
|
||||
// index-0 bug surfaced the Underweight "≤18.5" ceiling instead.
|
||||
const normal = buildReferenceGroundingBlock({
|
||||
metrics: [{ metric: "BMI", value: 22 }],
|
||||
hasDiabetes: false,
|
||||
})!;
|
||||
const bmiLine = normal.split("\n").find((l) => l.startsWith("- BMI:"))!;
|
||||
expect(bmiLine).toContain("18.5–24.9 kg/m²");
|
||||
expect(bmiLine).not.toContain("≤18.5");
|
||||
// A healthy 22 sits INSIDE the band (was wrongly "just outside").
|
||||
expect(bmiLine).toContain("sits inside the general reference band");
|
||||
|
||||
// Underweight 17 sits just outside (was wrongly "inside").
|
||||
const under = buildReferenceGroundingBlock({
|
||||
metrics: [{ metric: "BMI", value: 17 }],
|
||||
hasDiabetes: false,
|
||||
})!;
|
||||
expect(under.split("\n").find((l) => l.startsWith("- BMI:"))).toContain(
|
||||
"sits just outside the general reference band",
|
||||
);
|
||||
|
||||
// Overweight 27 sits just outside.
|
||||
const over = buildReferenceGroundingBlock({
|
||||
metrics: [{ metric: "BMI", value: 27 }],
|
||||
hasDiabetes: false,
|
||||
})!;
|
||||
expect(over.split("\n").find((l) => l.startsWith("- BMI:"))).toContain(
|
||||
"sits just outside the general reference band",
|
||||
);
|
||||
});
|
||||
|
||||
it("grounds pulse pressure against its interior normal band", () => {
|
||||
const block = buildReferenceGroundingBlock({
|
||||
metrics: [{ metric: "PULSE_PRESSURE", value: 40 }],
|
||||
hasDiabetes: false,
|
||||
})!;
|
||||
const line = block.split("\n").find((l) => l.startsWith("- pulse pressure:"))!;
|
||||
// The normal "Typical at rest" band (25–60), not the first "Narrow" band.
|
||||
expect(line).toContain("25–60 mmHg");
|
||||
expect(line).toContain("sits inside the general reference band");
|
||||
});
|
||||
|
||||
it("does not ground steps (mean-aggregation misplacement; follow-up)", () => {
|
||||
const block = buildReferenceGroundingBlock({
|
||||
metrics: [
|
||||
{ metric: "STEPS", value: 9000 },
|
||||
{ metric: "BMI", value: 22 },
|
||||
],
|
||||
hasDiabetes: false,
|
||||
})!;
|
||||
// STEPS is dropped; the BMI line still renders.
|
||||
expect(block).not.toContain("daily steps");
|
||||
expect(block.split("\n").some((l) => l.startsWith("- BMI:"))).toBe(true);
|
||||
});
|
||||
|
||||
it("returns null when steps is the only metric (it is not grounded)", () => {
|
||||
expect(
|
||||
buildReferenceGroundingBlock({
|
||||
metrics: [{ metric: "STEPS", value: 9000 }],
|
||||
hasDiabetes: false,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
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,263 @@
|
||||
/**
|
||||
* 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,
|
||||
normalBandIndex,
|
||||
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 NORMAL band — resolved by the `normal` marker, NOT by index 0
|
||||
* (some metrics author an abnormal band first, e.g. BMI Underweight or
|
||||
* pulse-pressure Narrow). 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 idx = normalBandIndex(metric);
|
||||
const band = range.bands[idx];
|
||||
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;
|
||||
// STEPS is excluded from grounding for now: the caller feeds the
|
||||
// intra-day MEAN of the ACTIVITY_STEPS rows, so a day logged across
|
||||
// several rows is averaged DOWN and can be misclassified below the
|
||||
// 8,000-step floor. Grounding it on a sum-per-day aggregate is the
|
||||
// correct fix and is tracked as a follow-up; until then we drop the
|
||||
// line rather than ship a wrong placement.
|
||||
if (m.metric === "STEPS") 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
|
||||
|
||||
|
||||
+59
-30
@@ -39,6 +39,16 @@ export interface ReferenceBand {
|
||||
readonly high?: number;
|
||||
/** The body + year that publishes this band. */
|
||||
readonly citation: CitationId;
|
||||
/**
|
||||
* Marks THE population-normal / target band — the placement
|
||||
* `classifyReference` treats as "within" and `headlineBandText` renders
|
||||
* as the headline. Exactly one band per metric (with at least one band)
|
||||
* carries this. Bands are NOT assumed normal by index: a metric whose
|
||||
* first band is abnormal (BMI → Underweight first; pulse pressure →
|
||||
* Narrow first) marks the correct interior band instead. A test asserts
|
||||
* the one-marker-per-banded-metric invariant.
|
||||
*/
|
||||
readonly normal?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,7 +65,12 @@ export interface ReferenceConflict {
|
||||
export interface ReferenceRange {
|
||||
/** Canonical display unit (matches the metric tile + registry). */
|
||||
readonly unit: string;
|
||||
/** Ordered population bands; the first is the "good"/normal placement. */
|
||||
/**
|
||||
* Ordered population bands. The "good"/normal placement is the band
|
||||
* flagged `normal: true` — NOT necessarily the first entry. Some metrics
|
||||
* (BMI, pulse pressure) author an abnormal band first, so consumers must
|
||||
* resolve the normal band by the marker, never by index.
|
||||
*/
|
||||
readonly bands: readonly ReferenceBand[];
|
||||
/** The body the headline band is sourced to (drives the cited sentence). */
|
||||
readonly referenceId: CitationId;
|
||||
@@ -95,7 +110,7 @@ export const REFERENCE_RANGES: Record<ReferenceMetric, ReferenceRange> = {
|
||||
unit: "mmHg",
|
||||
bands: [
|
||||
// ESH grade bands (office, mmHg, systolic anchors shown).
|
||||
{ label: "Optimal", high: 120, citation: "ESH_2023_HYPERTENSION" },
|
||||
{ label: "Optimal", high: 120, citation: "ESH_2023_HYPERTENSION", normal: true },
|
||||
{ label: "Normal", low: 120, high: 129, citation: "ESH_2023_HYPERTENSION" },
|
||||
{ label: "High-normal", low: 130, high: 139, citation: "ESH_2023_HYPERTENSION" },
|
||||
{ label: "Hypertension", low: 140, citation: "ESH_2023_HYPERTENSION" },
|
||||
@@ -120,7 +135,7 @@ export const REFERENCE_RANGES: Record<ReferenceMetric, ReferenceRange> = {
|
||||
unit: "mmHg",
|
||||
bands: [
|
||||
{ label: "Narrow", high: 25, citation: "STATPEARLS_PULSE_PRESSURE" },
|
||||
{ label: "Typical at rest", low: 25, high: 60, citation: "STATPEARLS_PULSE_PRESSURE" },
|
||||
{ label: "Typical at rest", low: 25, high: 60, citation: "STATPEARLS_PULSE_PRESSURE", normal: true },
|
||||
{ label: "Wide", low: 60, citation: "STATPEARLS_PULSE_PRESSURE" },
|
||||
],
|
||||
referenceId: "STATPEARLS_PULSE_PRESSURE",
|
||||
@@ -132,7 +147,7 @@ export const REFERENCE_RANGES: Record<ReferenceMetric, ReferenceRange> = {
|
||||
MEAN_ARTERIAL_PRESSURE: {
|
||||
unit: "mmHg",
|
||||
bands: [
|
||||
{ label: "Resting band", low: 70, high: 100, citation: "STATPEARLS_MAP" },
|
||||
{ label: "Resting band", low: 70, high: 100, citation: "STATPEARLS_MAP", normal: true },
|
||||
],
|
||||
referenceId: "STATPEARLS_MAP",
|
||||
guidanceCaveat:
|
||||
@@ -142,7 +157,7 @@ export const REFERENCE_RANGES: Record<ReferenceMetric, ReferenceRange> = {
|
||||
PULSE_WAVE_VELOCITY: {
|
||||
unit: "m/s",
|
||||
bands: [
|
||||
{ label: "Reference", high: 10, citation: "ESC_ESH_2018_PWV" },
|
||||
{ label: "Reference", high: 10, citation: "ESC_ESH_2018_PWV", normal: true },
|
||||
{ label: "Elevated stiffness", low: 10, citation: "ESC_ESH_2018_PWV" },
|
||||
],
|
||||
referenceId: "ESC_ESH_2018_PWV",
|
||||
@@ -151,14 +166,14 @@ export const REFERENCE_RANGES: Record<ReferenceMetric, ReferenceRange> = {
|
||||
},
|
||||
|
||||
// ── Resting heart rate — uses RESTING_HEART_RATE, never workout pulse. ──
|
||||
// Bands are authored normal-first so `classifyReference` treats the
|
||||
// population-normal band as the "within" placement. The athletic band
|
||||
// The 60–100 band carries `normal: true`, so `classifyReference` treats it
|
||||
// as the "within" placement regardless of band order. The athletic band
|
||||
// (40–60) is benign for a trained heart but sits below the standard
|
||||
// normal range — the caveat copy carries that nuance.
|
||||
RESTING_HEART_RATE: {
|
||||
unit: "bpm",
|
||||
bands: [
|
||||
{ label: "Normal adult resting", low: 60, high: 100, citation: "AHA_2024_RHR" },
|
||||
{ label: "Normal adult resting", low: 60, high: 100, citation: "AHA_2024_RHR", normal: true },
|
||||
{ label: "Athletic (benign)", low: 40, high: 60, citation: "AHA_2024_RHR" },
|
||||
],
|
||||
referenceId: "AHA_2024_RHR",
|
||||
@@ -178,7 +193,7 @@ export const REFERENCE_RANGES: Record<ReferenceMetric, ReferenceRange> = {
|
||||
OXYGEN_SATURATION: {
|
||||
unit: "%",
|
||||
bands: [
|
||||
{ label: "Healthy room air", low: 95, high: 100, citation: "STATPEARLS_PULSE_OX" },
|
||||
{ label: "Healthy room air", low: 95, high: 100, citation: "STATPEARLS_PULSE_OX", normal: true },
|
||||
{ label: "Mild hypoxaemia", low: 91, high: 94, citation: "STATPEARLS_PULSE_OX" },
|
||||
],
|
||||
referenceId: "STATPEARLS_PULSE_OX",
|
||||
@@ -189,7 +204,7 @@ export const REFERENCE_RANGES: Record<ReferenceMetric, ReferenceRange> = {
|
||||
RESPIRATORY_RATE: {
|
||||
unit: "breaths/min",
|
||||
bands: [
|
||||
{ label: "Normal resting adult", low: 12, high: 20, citation: "ALA_RESPIRATORY_RATE" },
|
||||
{ label: "Normal resting adult", low: 12, high: 20, citation: "ALA_RESPIRATORY_RATE", normal: true },
|
||||
{ label: "Worth attention", low: 22, citation: "RCP_2017_NEWS2" },
|
||||
],
|
||||
referenceId: "ALA_RESPIRATORY_RATE",
|
||||
@@ -201,7 +216,7 @@ export const REFERENCE_RANGES: Record<ReferenceMetric, ReferenceRange> = {
|
||||
BODY_TEMPERATURE: {
|
||||
unit: "°C",
|
||||
bands: [
|
||||
{ label: "Normal oral range", low: 35.7, high: 37.4, citation: "JGIM_2019_TEMPERATURE" },
|
||||
{ label: "Normal oral range", low: 35.7, high: 37.4, citation: "JGIM_2019_TEMPERATURE", normal: true },
|
||||
{ label: "Fever", low: 38.0, citation: "JGIM_2019_TEMPERATURE" },
|
||||
],
|
||||
referenceId: "JGIM_2019_TEMPERATURE",
|
||||
@@ -213,7 +228,7 @@ export const REFERENCE_RANGES: Record<ReferenceMetric, ReferenceRange> = {
|
||||
BLOOD_GLUCOSE: {
|
||||
unit: "mg/dL",
|
||||
bands: [
|
||||
{ label: "Normal fasting", high: 100, citation: "ADA_2024_GLYCEMIC" },
|
||||
{ label: "Normal fasting", high: 100, citation: "ADA_2024_GLYCEMIC", normal: true },
|
||||
{ label: "Prediabetes (fasting)", low: 100, high: 125, citation: "ADA_2024_GLYCEMIC" },
|
||||
{ label: "Diabetes range (fasting)", low: 126, citation: "ADA_2024_GLYCEMIC" },
|
||||
],
|
||||
@@ -231,7 +246,7 @@ export const REFERENCE_RANGES: Record<ReferenceMetric, ReferenceRange> = {
|
||||
HBA1C: {
|
||||
unit: "%",
|
||||
bands: [
|
||||
{ label: "Normal", high: 5.7, citation: "ADA_2024_GLYCEMIC" },
|
||||
{ label: "Normal", high: 5.7, citation: "ADA_2024_GLYCEMIC", normal: true },
|
||||
{ label: "Prediabetes", low: 5.7, high: 6.4, citation: "ADA_2024_GLYCEMIC" },
|
||||
{ label: "Diabetes", low: 6.5, citation: "ADA_2024_GLYCEMIC" },
|
||||
],
|
||||
@@ -244,7 +259,7 @@ export const REFERENCE_RANGES: Record<ReferenceMetric, ReferenceRange> = {
|
||||
unit: "kg/m²",
|
||||
bands: [
|
||||
{ label: "Underweight", high: 18.5, citation: "WHO_2020_PA" },
|
||||
{ label: "Normal", low: 18.5, high: 24.9, citation: "WHO_2020_PA" },
|
||||
{ label: "Normal", low: 18.5, high: 24.9, citation: "WHO_2020_PA", normal: true },
|
||||
{ label: "Overweight", low: 25.0, high: 29.9, citation: "WHO_2020_PA" },
|
||||
{ label: "Obesity", low: 30.0, citation: "WHO_2020_PA" },
|
||||
],
|
||||
@@ -257,7 +272,7 @@ export const REFERENCE_RANGES: Record<ReferenceMetric, ReferenceRange> = {
|
||||
STEPS: {
|
||||
unit: "steps/day",
|
||||
bands: [
|
||||
{ label: "Higher mortality benefit", low: 8000, citation: "STEPS_SAINT_MAURICE_2020" },
|
||||
{ label: "Higher mortality benefit", low: 8000, citation: "STEPS_SAINT_MAURICE_2020", normal: true },
|
||||
],
|
||||
referenceId: "STEPS_SAINT_MAURICE_2020",
|
||||
guidanceCaveat:
|
||||
@@ -267,7 +282,7 @@ export const REFERENCE_RANGES: Record<ReferenceMetric, ReferenceRange> = {
|
||||
VISCERAL_FAT: {
|
||||
unit: "rating",
|
||||
bands: [
|
||||
{ label: "Healthy rating", low: 1, high: 12, citation: "VAT_IMAGING_THRESHOLD" },
|
||||
{ label: "Healthy rating", low: 1, high: 12, citation: "VAT_IMAGING_THRESHOLD", normal: true },
|
||||
{ label: "Elevated rating", low: 13, citation: "VAT_IMAGING_THRESHOLD" },
|
||||
],
|
||||
referenceId: "VAT_IMAGING_THRESHOLD",
|
||||
@@ -279,7 +294,7 @@ export const REFERENCE_RANGES: Record<ReferenceMetric, ReferenceRange> = {
|
||||
SLEEP_DURATION: {
|
||||
unit: "h",
|
||||
bands: [
|
||||
{ label: "Adult 18–64", low: 7, high: 9, citation: "AASM_2015_SLEEP" },
|
||||
{ label: "Adult 18–64", low: 7, high: 9, citation: "AASM_2015_SLEEP", normal: true },
|
||||
{ label: "Older adult 65+", low: 7, high: 8, citation: "AASM_2015_SLEEP" },
|
||||
],
|
||||
referenceId: "AASM_2015_SLEEP",
|
||||
@@ -337,6 +352,20 @@ export type ReferencePlacement =
|
||||
* baseline). Pure + side-effect free; the user's own baseline still leads
|
||||
* the read — this is only a coarse placement aid.
|
||||
*/
|
||||
/**
|
||||
* Resolve the index of a metric's population-normal band — the band the
|
||||
* placement contract treats as "within". Returns the index of the band
|
||||
* flagged `normal: true`; falls back to band 0 only for the legacy
|
||||
* (untagged) shape so an unmarked metric degrades to the old normal-first
|
||||
* assumption rather than throwing. A metric with no bands returns -1.
|
||||
*/
|
||||
export function normalBandIndex(metric: ReferenceMetric): number {
|
||||
const bands = REFERENCE_RANGES[metric].bands;
|
||||
if (bands.length === 0) return -1;
|
||||
const marked = bands.findIndex((b) => b.normal === true);
|
||||
return marked === -1 ? 0 : marked;
|
||||
}
|
||||
|
||||
export function classifyReference(
|
||||
metric: ReferenceMetric,
|
||||
value: number | null | undefined,
|
||||
@@ -345,26 +374,26 @@ export function classifyReference(
|
||||
if (value == null || !Number.isFinite(value) || range.bands.length === 0) {
|
||||
return "insufficient";
|
||||
}
|
||||
// Find the first band whose half-open interval contains the value.
|
||||
// Bands are authored in ascending order; the FIRST band is the
|
||||
// normal/good placement, so its membership maps to `within`.
|
||||
const normalIdx = normalBandIndex(metric);
|
||||
// Find the band whose half-open interval contains the value. Bands are
|
||||
// authored in ascending order; the placement is the band's DISTANCE from
|
||||
// the normal band (resolved by the `normal` marker, NOT by index 0 —
|
||||
// some metrics author an abnormal band first, e.g. BMI Underweight,
|
||||
// pulse-pressure Narrow). One step from normal = watch tier; two or more
|
||||
// = attention tier.
|
||||
for (let i = 0; i < range.bands.length; i++) {
|
||||
const band = range.bands[i];
|
||||
const aboveLow = band.low == null || value >= band.low;
|
||||
const belowHigh = band.high == null || value <= band.high;
|
||||
if (aboveLow && belowHigh) {
|
||||
if (i === 0) return "within";
|
||||
// The band immediately adjacent to normal is the watch tier; any
|
||||
// band beyond it is the attention tier.
|
||||
return i === 1 ? "slightly-outside" : "outside";
|
||||
const distance = Math.abs(i - normalIdx);
|
||||
if (distance === 0) return "within";
|
||||
return distance === 1 ? "slightly-outside" : "outside";
|
||||
}
|
||||
}
|
||||
// Value sits beyond every authored band (e.g. below an open-low normal,
|
||||
// or above the top band's open-high). Treat an under-shoot of the first
|
||||
// band's low as within when the first band is open-low; otherwise the
|
||||
// value is outside the covered range → attention.
|
||||
const first = range.bands[0];
|
||||
if (first.low != null && value < first.low) return "outside";
|
||||
// Value sits beyond every authored band (below an open-low first band, or
|
||||
// above an open-high last band). Either way it is outside the covered
|
||||
// bands → attention.
|
||||
return "outside";
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user