Files
HealthLog/src/lib/ai/coach/reference-grounding.ts
T
Marc-André Bombeck 1f4ba5939d style: apply prettier across the tree
One-shot prettier pass so the new format:check gate starts from a clean
baseline. Formatting only — no behaviour change. Generator-owned and
vendored artifacts stay untouched via .prettierignore.
2026-06-19 22:20:31 +02:00

264 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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}`;
}