mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
419 lines
15 KiB
TypeScript
419 lines
15 KiB
TypeScript
/**
|
||
* v1.27.13 (Welle J) — the interpretation-context registry.
|
||
*
|
||
* The per-metric assessment prompt used to hand the model a coarse
|
||
* single-edge `normalRange` and the personal baseline, and nothing that says
|
||
* what a value MEANS on a clinical scale. So the prose recited counts and a
|
||
* trend adjective ("slightly rising") without ever placing the value: is a
|
||
* visceral-fat rating of 2.7 good? Where does it sit? What follows?
|
||
*
|
||
* This module is the hand-curated answer: a typed registry mapping a metric to
|
||
* the guideline INTERPRETATION BANDS the knowledge base
|
||
* (`~/Projects/health-knowledge-base`, vendor-blind, guideline-cited) actually
|
||
* carries — band edges, plain labels, a favourable/neutral/caution/unfavourable
|
||
* valence, the direction a good value moves, and a short source tag. Every
|
||
* entry cites the KB file + section it was derived from in a comment; NO
|
||
* threshold is invented. A metric the KB gives no numeric band for has NO
|
||
* entry here — its assessment then stays purely personal-relative (fail-soft),
|
||
* which is the honest posture the KB itself takes for those metrics.
|
||
*
|
||
* The registry feeds two things:
|
||
* 1. `buildInterpretationBlock` (prompts/interpretation-block.ts) renders the
|
||
* computed band position + table into the per-metric user prompt.
|
||
* 2. `interpretationBandEdges` yields the band-edge numbers so any surface
|
||
* with a numeric grounding gate can allow-list a quoted guideline
|
||
* threshold (the v1.25.13 class of bug: an unlisted number strips text).
|
||
*
|
||
* The band position is computed HERE, server-side — never left to the model.
|
||
*/
|
||
|
||
/** How a band reads against health: the four-way valence the prose leans on. */
|
||
export type BandValence = "favourable" | "neutral" | "caution" | "unfavourable";
|
||
|
||
/**
|
||
* One guideline band. `upTo` is the EXCLUSIVE upper edge in the metric's unit;
|
||
* the last band in an ascending list carries `upTo: null` ("and above"). A
|
||
* value belongs to the first band whose `upTo` it is strictly below.
|
||
*/
|
||
export interface InterpretationBand {
|
||
upTo: number | null;
|
||
/** Plain-language label ("healthy", "increased risk", "obesity class I"). */
|
||
label: string;
|
||
valence: BandValence;
|
||
}
|
||
|
||
/** Which direction a favourable value moves. */
|
||
export type DirectionOfGood = "lower" | "higher" | "target";
|
||
|
||
export interface MetricInterpretation {
|
||
/** The unit the band edges are expressed in. */
|
||
unit: string;
|
||
directionOfGood: DirectionOfGood;
|
||
/** Short guideline/source tag, e.g. "WHO 2000". */
|
||
source: string;
|
||
/** Ascending bands when the metric is not sex-split. */
|
||
bands?: InterpretationBand[];
|
||
/** Sex-split bands; used only when the profile carries the sex. */
|
||
sexBands?: Record<"MALE" | "FEMALE", InterpretationBand[]>;
|
||
/**
|
||
* Optional honesty caveat surfaced in the prompt block — used where the KB
|
||
* hedges the edges (consumer rating scales, proxy measurements).
|
||
*/
|
||
caveat?: string;
|
||
}
|
||
|
||
/**
|
||
* The registry. Keys are the generic metric ids (`MetricStatusMetricId`) plus
|
||
* the specialised `"BMI"` scope, so both the generic archetype path and a
|
||
* future BMI-card wiring resolve from one table. A metric absent here has no
|
||
* guideline band and stays personal-relative.
|
||
*
|
||
* Bands are ascending; edges are the KB's stated guideline thresholds. Where
|
||
* the KB gives a range like "normal 35.7–37.4", the band's `upTo` is the next
|
||
* band's floor (so the normal band runs up to the fever line), keeping the
|
||
* partition total and gap-free.
|
||
*/
|
||
const INTERPRETATION_REGISTRY: Record<string, MetricInterpretation> = {
|
||
// Visceral fat — the maintainer's headline example. KB:
|
||
// metrics/body-composition.md → "### Visceral fat", consumer unitless rating
|
||
// scale (~1–59): ≈1–12 healthy, ≈13+ elevated ("by common convention"; the
|
||
// rating is NOT a cm² value). Lower is better.
|
||
VISCERAL_FAT: {
|
||
unit: "rating",
|
||
directionOfGood: "lower",
|
||
source: "consumer rating convention",
|
||
bands: [
|
||
{ upTo: 13, label: "healthy", valence: "favourable" },
|
||
{ upTo: null, label: "elevated", valence: "caution" },
|
||
],
|
||
caveat:
|
||
"This is the consumer 1–59 rating scale (by common convention, not a formal guideline cut-off), not a cm² area.",
|
||
},
|
||
|
||
// Resting heart rate — KB: metrics/resting-heart-rate.md → "## Reference
|
||
// ranges". Normal adult resting 60–100 bpm (AHA 2024); 40–60 bpm is benign
|
||
// in endurance athletes; sustained > 100 resting is tachycardia. Lower is
|
||
// generally more favourable within physiologic limits.
|
||
RESTING_HEART_RATE: {
|
||
unit: "bpm",
|
||
directionOfGood: "lower",
|
||
source: "AHA 2024",
|
||
bands: [
|
||
{
|
||
upTo: 60,
|
||
label: "below the standard resting range (common in trained hearts)",
|
||
valence: "neutral",
|
||
},
|
||
{ upTo: 100, label: "the standard resting range", valence: "favourable" },
|
||
{
|
||
upTo: null,
|
||
label: "above the standard resting range",
|
||
valence: "caution",
|
||
},
|
||
],
|
||
},
|
||
|
||
// Blood oxygen (SpO₂) — KB: metrics/spo2.md → "## Reference ranges". Healthy
|
||
// room-air 95–100%; 91–94% mild hypoxaemia; < 90% clinically significant.
|
||
// Higher is better but with a ceiling (above ~98% adds no benefit).
|
||
OXYGEN_SATURATION: {
|
||
unit: "%",
|
||
directionOfGood: "higher",
|
||
source: "StatPearls/NIH 2023",
|
||
bands: [
|
||
{
|
||
upTo: 90,
|
||
label: "clinically significant low oxygen",
|
||
valence: "unfavourable",
|
||
},
|
||
{ upTo: 95, label: "mildly low", valence: "caution" },
|
||
{ upTo: null, label: "the healthy range", valence: "favourable" },
|
||
],
|
||
},
|
||
|
||
// Respiratory rate — KB: metrics/respiratory-rate.md → "## Reference ranges".
|
||
// Normal resting adult 12–20 breaths/min; > 20 tachypnoea; < 12 bradypnoea.
|
||
// Target-band: both extremes are less favourable.
|
||
RESPIRATORY_RATE: {
|
||
unit: "breaths/min",
|
||
directionOfGood: "target",
|
||
source: "American Lung Association; StatPearls/NIH 2023",
|
||
bands: [
|
||
{
|
||
upTo: 12,
|
||
label: "below the normal resting range",
|
||
valence: "caution",
|
||
},
|
||
{ upTo: 20, label: "the normal resting range", valence: "favourable" },
|
||
{
|
||
upTo: null,
|
||
label: "above the normal resting range",
|
||
valence: "caution",
|
||
},
|
||
],
|
||
},
|
||
|
||
// Body temperature — KB: metrics/temperature.md → "## Reference ranges".
|
||
// Normal oral range ~35.7–37.4 °C (mean 36.6); fever (oral) ≥ 38.0 °C. The
|
||
// fever edge is the canonical FEVER_BAND_C so the band and the illness
|
||
// engine's escalation stay one intentional pair; the 37.5 sub-edge marks the
|
||
// borderline zone below the fever line. Sites differ by up to ~1 °C, so the
|
||
// read is a coarse placement.
|
||
BODY_TEMPERATURE: {
|
||
unit: "°C",
|
||
directionOfGood: "target",
|
||
source: "J Gen Intern Med systematic review 2019; CDC",
|
||
bands: [
|
||
{ upTo: 35.7, label: "below the normal range", valence: "caution" },
|
||
{ upTo: 37.5, label: "the normal range", valence: "favourable" },
|
||
{ upTo: 38, label: "borderline / low-grade", valence: "caution" },
|
||
{ upTo: null, label: "fever range", valence: "unfavourable" },
|
||
],
|
||
},
|
||
|
||
// Sleep duration — KB: metrics/sleep.md → "### Duration by age". Adult
|
||
// (18–64) recommended 7–9 h/night (NSF 2015; AASM & SRS 2015). Stored in
|
||
// MINUTES here (420–540). Target-band (U-shaped risk).
|
||
SLEEP_DURATION: {
|
||
unit: "min",
|
||
directionOfGood: "target",
|
||
source: "NSF 2015; AASM/SRS 2015",
|
||
bands: [
|
||
{
|
||
upTo: 420,
|
||
label: "below the recommended adult range",
|
||
valence: "caution",
|
||
},
|
||
{
|
||
upTo: 540,
|
||
label: "the recommended adult range",
|
||
valence: "favourable",
|
||
},
|
||
{
|
||
upTo: null,
|
||
label: "above the recommended adult range",
|
||
valence: "neutral",
|
||
},
|
||
],
|
||
},
|
||
|
||
// Waist circumference — KB: metrics/weight-bmi.md → "### Waist circumference
|
||
// and ratios (European-origin populations)". Sex-specific: men > 94 cm
|
||
// increased risk, > 102 cm substantially increased; women > 80 cm / > 88 cm
|
||
// (WHO 2008/2011; NIH/NHLBI 1998). Lower is better. Requires profile sex.
|
||
WAIST_CIRCUMFERENCE: {
|
||
unit: "cm",
|
||
directionOfGood: "lower",
|
||
source: "WHO 2008/2011; NIH/NHLBI 1998",
|
||
sexBands: {
|
||
MALE: [
|
||
{
|
||
upTo: 94,
|
||
label: "below the increased-risk threshold",
|
||
valence: "favourable",
|
||
},
|
||
{ upTo: 102, label: "increased risk", valence: "caution" },
|
||
{
|
||
upTo: null,
|
||
label: "substantially increased risk",
|
||
valence: "unfavourable",
|
||
},
|
||
],
|
||
FEMALE: [
|
||
{
|
||
upTo: 80,
|
||
label: "below the increased-risk threshold",
|
||
valence: "favourable",
|
||
},
|
||
{ upTo: 88, label: "increased risk", valence: "caution" },
|
||
{
|
||
upTo: null,
|
||
label: "substantially increased risk",
|
||
valence: "unfavourable",
|
||
},
|
||
],
|
||
},
|
||
},
|
||
|
||
// Waist-to-height ratio — KB: metrics/weight-bmi.md → same section. WHtR
|
||
// ≥ 0.5 flags increased risk in both sexes (NICE). Lower is better.
|
||
WAIST_TO_HEIGHT: {
|
||
unit: "ratio",
|
||
directionOfGood: "lower",
|
||
source: "NICE",
|
||
bands: [
|
||
{
|
||
upTo: 0.5,
|
||
label: "below the increased-risk threshold",
|
||
valence: "favourable",
|
||
},
|
||
{ upTo: null, label: "increased risk", valence: "caution" },
|
||
],
|
||
},
|
||
|
||
// Pulse-wave velocity — KB: metrics/blood-pressure.md → derived support
|
||
// signals. cf-PWV > 10 m/s is the European organ-damage marker (ESC/ESH
|
||
// 2018). A consumer estimate is a PROXY, so this is a coarse "below 10 is the
|
||
// reference side" placement only. Lower is better.
|
||
PULSE_WAVE_VELOCITY: {
|
||
unit: "m/s",
|
||
directionOfGood: "lower",
|
||
source: "ESC/ESH 2018",
|
||
bands: [
|
||
{
|
||
upTo: 10,
|
||
label: "below the arterial-stiffness reference threshold",
|
||
valence: "favourable",
|
||
},
|
||
{
|
||
upTo: null,
|
||
label: "above the arterial-stiffness reference threshold",
|
||
valence: "caution",
|
||
},
|
||
],
|
||
caveat:
|
||
"A consumer PWV estimate is a proxy for clinical carotid-femoral PWV, not interchangeable with it.",
|
||
},
|
||
|
||
// BMI — KB: metrics/weight-bmi.md → "### BMI". WHO adult bands (WHO 2000).
|
||
// Registered for the specialised BMI card + tests; target mid-range.
|
||
BMI: {
|
||
unit: "kg/m²",
|
||
directionOfGood: "target",
|
||
source: "WHO 2000",
|
||
bands: [
|
||
{ upTo: 18.5, label: "underweight", valence: "caution" },
|
||
{ upTo: 25, label: "normal weight", valence: "favourable" },
|
||
{ upTo: 30, label: "overweight", valence: "caution" },
|
||
{ upTo: 35, label: "obesity class I", valence: "unfavourable" },
|
||
{ upTo: 40, label: "obesity class II", valence: "unfavourable" },
|
||
{ upTo: null, label: "obesity class III", valence: "unfavourable" },
|
||
],
|
||
},
|
||
};
|
||
|
||
/**
|
||
* Resolve the interpretation for a metric key, picking the sex-split bands when
|
||
* the entry is sex-specific and a sex is known. Returns null when the metric
|
||
* has no entry, OR when it needs a sex the profile does not carry (fail-soft to
|
||
* personal-relative rather than guessing a sex).
|
||
*/
|
||
export function resolveInterpretation(
|
||
metricKey: string,
|
||
sex: "MALE" | "FEMALE" | null | undefined,
|
||
): {
|
||
unit: string;
|
||
directionOfGood: DirectionOfGood;
|
||
source: string;
|
||
bands: InterpretationBand[];
|
||
caveat?: string;
|
||
} | null {
|
||
const entry = INTERPRETATION_REGISTRY[metricKey];
|
||
if (!entry) return null;
|
||
let bands: InterpretationBand[] | undefined = entry.bands;
|
||
if (entry.sexBands) {
|
||
if (sex !== "MALE" && sex !== "FEMALE") return null;
|
||
bands = entry.sexBands[sex];
|
||
}
|
||
if (!bands || bands.length === 0) return null;
|
||
return {
|
||
unit: entry.unit,
|
||
directionOfGood: entry.directionOfGood,
|
||
source: entry.source,
|
||
bands,
|
||
...(entry.caveat ? { caveat: entry.caveat } : {}),
|
||
};
|
||
}
|
||
|
||
/** Where a value sits, plus how close it is to a band boundary. */
|
||
export interface BandPosition {
|
||
/** Index of the band in the ascending list. */
|
||
index: number;
|
||
band: InterpretationBand;
|
||
/** Finite lower edge of the band (null when open-bottom). */
|
||
lowerEdge: number | null;
|
||
/** Finite upper edge of the band (null when open-top). */
|
||
upperEdge: number | null;
|
||
/** Whether the value sits centrally or hugs an edge — drives trend severity. */
|
||
proximity: "central" | "near-lower-edge" | "near-upper-edge";
|
||
/** The nearest finite band boundary, when one exists. */
|
||
nearestEdge: number | null;
|
||
}
|
||
|
||
/**
|
||
* Classify a value against an ascending band list. Uses a strict `<` on each
|
||
* `upTo`, so a value exactly on an edge falls into the HIGHER band (25.0 is
|
||
* overweight, not normal — matching the WHO convention). Proximity flags an
|
||
* edge-hugging value (within 20% of the band's finite width, or within 15% of
|
||
* an open band's single finite edge) so the prompt can escalate a trend that
|
||
* approaches a boundary.
|
||
*/
|
||
export function classifyBandPosition(
|
||
value: number,
|
||
bands: InterpretationBand[],
|
||
): BandPosition {
|
||
let index = bands.findIndex((b) => b.upTo !== null && value < b.upTo);
|
||
if (index === -1) index = bands.length - 1;
|
||
const band = bands[index];
|
||
const lowerEdge = index > 0 ? bands[index - 1].upTo : null;
|
||
const upperEdge = band.upTo;
|
||
|
||
let proximity: BandPosition["proximity"] = "central";
|
||
let nearestEdge: number | null = null;
|
||
|
||
if (lowerEdge !== null && upperEdge !== null) {
|
||
const width = upperEdge - lowerEdge;
|
||
const margin = width * 0.2;
|
||
const distLower = value - lowerEdge;
|
||
const distUpper = upperEdge - value;
|
||
if (distUpper <= distLower && distUpper <= margin) {
|
||
proximity = "near-upper-edge";
|
||
nearestEdge = upperEdge;
|
||
} else if (distLower < distUpper && distLower <= margin) {
|
||
proximity = "near-lower-edge";
|
||
nearestEdge = lowerEdge;
|
||
} else {
|
||
nearestEdge = distUpper <= distLower ? upperEdge : lowerEdge;
|
||
}
|
||
} else if (upperEdge !== null) {
|
||
nearestEdge = upperEdge;
|
||
if (upperEdge !== 0 && (upperEdge - value) / Math.abs(upperEdge) <= 0.15) {
|
||
proximity = "near-upper-edge";
|
||
}
|
||
} else if (lowerEdge !== null) {
|
||
nearestEdge = lowerEdge;
|
||
if (lowerEdge !== 0 && (value - lowerEdge) / Math.abs(lowerEdge) <= 0.15) {
|
||
proximity = "near-lower-edge";
|
||
}
|
||
}
|
||
|
||
return { index, band, lowerEdge, upperEdge, proximity, nearestEdge };
|
||
}
|
||
|
||
/**
|
||
* Every finite band edge for a metric, for a numeric grounding allow-set. A
|
||
* surface that injects the interpretation block AND runs a number-grounding
|
||
* gate must feed these into its allow-set so a quoted guideline threshold can
|
||
* never strip the assessment. Returns [] for an uncovered metric.
|
||
*/
|
||
export function interpretationBandEdges(
|
||
metricKey: string,
|
||
sex?: "MALE" | "FEMALE" | null,
|
||
): number[] {
|
||
const resolved = resolveInterpretation(metricKey, sex ?? null);
|
||
if (!resolved) {
|
||
// Sex-split metric with unknown sex: still surface the union of both sets
|
||
// so a gate is complete even before a sex is known.
|
||
const entry = INTERPRETATION_REGISTRY[metricKey];
|
||
if (!entry?.sexBands) return [];
|
||
const union = new Set<number>();
|
||
for (const bands of Object.values(entry.sexBands)) {
|
||
for (const b of bands) if (b.upTo !== null) union.add(b.upTo);
|
||
}
|
||
return [...union];
|
||
}
|
||
const edges = new Set<number>();
|
||
for (const b of resolved.bands) if (b.upTo !== null) edges.add(b.upTo);
|
||
return [...edges];
|
||
}
|