mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
feat(insights): guideline interpretation registry for per-metric assessments
Add a typed, hand-curated registry mapping metrics to guideline interpretation bands derived strictly from the health knowledge base — band edges, plain labels, valence, direction-of-good, and a source tag, each entry citing the KB file + section it came from. A metric the KB gives no numeric band for gets no entry, so its assessment stays personal-relative rather than inventing a threshold. The band position is computed server-side (`classifyBandPosition`, strict upper-edge semantics + edge-hugging proximity), and `buildInterpretationBlock` renders the table + placement into the assessment prompt so the model states where a value sits and what the band means instead of reciting counts. `interpretationBandEdges` exposes the edges for any numeric grounding gate. Covers visceral fat, resting heart rate, blood oxygen, respiratory rate, body temperature, sleep duration, waist (sex-split), waist-to-height, pulse-wave velocity, and BMI. Tests pin every band edge against the guideline value so a silent edit breaks a test.
This commit is contained in:
1 parent
ca1e8c2415
commit
8fc71620e8
4 files changed
+833
No files matched your search
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* v1.27.13 (Welle J) — the interpretation registry is the guideline source of
|
||||
* truth for the assessment prompt's band placement. These guards PIN the band
|
||||
* edges against the knowledge base's stated thresholds, so a silent edit to a
|
||||
* clinical number breaks a test rather than shipping. They also lock the
|
||||
* band-position classifier's edge semantics (strict `<`, edge-hugging
|
||||
* proximity) and the allow-set edge extraction.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
classifyBandPosition,
|
||||
interpretationBandEdges,
|
||||
resolveInterpretation,
|
||||
} from "@/lib/ai/insight-interpretation";
|
||||
|
||||
describe("interpretation registry — pinned band edges (KB-derived)", () => {
|
||||
it("visceral fat: healthy < 13, elevated above (consumer rating)", () => {
|
||||
const interp = resolveInterpretation("VISCERAL_FAT", null);
|
||||
expect(interp).not.toBeNull();
|
||||
expect(interp!.directionOfGood).toBe("lower");
|
||||
expect(interp!.bands.map((b) => b.upTo)).toEqual([13, null]);
|
||||
// The maintainer's headline example: 2.7 is comfortably healthy.
|
||||
const pos = classifyBandPosition(2.7, interp!.bands);
|
||||
expect(pos.band.label).toBe("healthy");
|
||||
expect(pos.band.valence).toBe("favourable");
|
||||
});
|
||||
|
||||
it("BMI: WHO adult bands at 18.5 / 25 / 30 / 35 / 40", () => {
|
||||
const interp = resolveInterpretation("BMI", null);
|
||||
expect(interp!.source).toBe("WHO 2000");
|
||||
expect(interp!.bands.map((b) => b.upTo)).toEqual([
|
||||
18.5,
|
||||
25,
|
||||
30,
|
||||
35,
|
||||
40,
|
||||
null,
|
||||
]);
|
||||
// 25.0 exactly falls into overweight (strict `<` on the normal edge).
|
||||
expect(classifyBandPosition(25, interp!.bands).band.label).toBe(
|
||||
"overweight",
|
||||
);
|
||||
expect(classifyBandPosition(24.9, interp!.bands).band.label).toBe(
|
||||
"normal weight",
|
||||
);
|
||||
});
|
||||
|
||||
it("resting heart rate: 60 / 100 (AHA)", () => {
|
||||
const interp = resolveInterpretation("RESTING_HEART_RATE", null);
|
||||
expect(interp!.bands.map((b) => b.upTo)).toEqual([60, 100, null]);
|
||||
});
|
||||
|
||||
it("SpO₂: significant-low < 90, mild < 95, healthy above", () => {
|
||||
const interp = resolveInterpretation("OXYGEN_SATURATION", null);
|
||||
expect(interp!.directionOfGood).toBe("higher");
|
||||
expect(interp!.bands.map((b) => b.upTo)).toEqual([90, 95, null]);
|
||||
expect(classifyBandPosition(97, interp!.bands).band.valence).toBe(
|
||||
"favourable",
|
||||
);
|
||||
});
|
||||
|
||||
it("respiratory rate: 12 / 20 target band", () => {
|
||||
const interp = resolveInterpretation("RESPIRATORY_RATE", null);
|
||||
expect(interp!.bands.map((b) => b.upTo)).toEqual([12, 20, null]);
|
||||
});
|
||||
|
||||
it("body temperature: 35.7 / 37.5 / 38 (fever at FEVER_BAND_C)", () => {
|
||||
const interp = resolveInterpretation("BODY_TEMPERATURE", null);
|
||||
expect(interp!.bands.map((b) => b.upTo)).toEqual([35.7, 37.5, 38, null]);
|
||||
expect(classifyBandPosition(38.4, interp!.bands).band.label).toBe(
|
||||
"fever range",
|
||||
);
|
||||
});
|
||||
|
||||
it("sleep duration: recommended adult range 420–540 min", () => {
|
||||
const interp = resolveInterpretation("SLEEP_DURATION", null);
|
||||
expect(interp!.bands.map((b) => b.upTo)).toEqual([420, 540, null]);
|
||||
});
|
||||
|
||||
it("waist circumference: sex-split (men 94/102, women 80/88)", () => {
|
||||
const male = resolveInterpretation("WAIST_CIRCUMFERENCE", "MALE");
|
||||
expect(male!.bands.map((b) => b.upTo)).toEqual([94, 102, null]);
|
||||
const female = resolveInterpretation("WAIST_CIRCUMFERENCE", "FEMALE");
|
||||
expect(female!.bands.map((b) => b.upTo)).toEqual([80, 88, null]);
|
||||
// Unknown sex → no interpretation (fail-soft, never guesses a sex).
|
||||
expect(resolveInterpretation("WAIST_CIRCUMFERENCE", null)).toBeNull();
|
||||
});
|
||||
|
||||
it("waist-to-height: increased risk at 0.5 (NICE)", () => {
|
||||
const interp = resolveInterpretation("WAIST_TO_HEIGHT", null);
|
||||
expect(interp!.bands.map((b) => b.upTo)).toEqual([0.5, null]);
|
||||
});
|
||||
|
||||
it("pulse-wave velocity: reference threshold 10 m/s (ESC/ESH 2018)", () => {
|
||||
const interp = resolveInterpretation("PULSE_WAVE_VELOCITY", null);
|
||||
expect(interp!.bands.map((b) => b.upTo)).toEqual([10, null]);
|
||||
expect(interp!.caveat).toContain("proxy");
|
||||
});
|
||||
|
||||
it("uncovered metrics have no interpretation (stay personal-relative)", () => {
|
||||
// HRV + glucose are deliberately NOT registered (the KB warns wearable HRV
|
||||
// is not the clinical SDNN band; glucose bands are meal-context-dependent).
|
||||
expect(resolveInterpretation("HEART_RATE_VARIABILITY", null)).toBeNull();
|
||||
expect(resolveInterpretation("BLOOD_GLUCOSE", null)).toBeNull();
|
||||
expect(resolveInterpretation("NOT_A_METRIC", null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifyBandPosition — edge semantics + proximity", () => {
|
||||
it("flags a value hugging the upper boundary of a bounded band", () => {
|
||||
const interp = resolveInterpretation("RESPIRATORY_RATE", null);
|
||||
// 19.5 in the 12–20 band sits within 20% of the upper edge (width 8,
|
||||
// margin 1.6): distance to 20 is 0.5 → near-upper-edge.
|
||||
const pos = classifyBandPosition(19.5, interp!.bands);
|
||||
expect(pos.band.label).toBe("the normal resting range");
|
||||
expect(pos.proximity).toBe("near-upper-edge");
|
||||
expect(pos.nearestEdge).toBe(20);
|
||||
});
|
||||
|
||||
it("reports central placement deep inside a band", () => {
|
||||
const interp = resolveInterpretation("RESPIRATORY_RATE", null);
|
||||
const pos = classifyBandPosition(16, interp!.bands);
|
||||
expect(pos.proximity).toBe("central");
|
||||
});
|
||||
});
|
||||
|
||||
describe("interpretationBandEdges — grounding allow-set", () => {
|
||||
it("returns exactly the finite band edges for a covered metric", () => {
|
||||
expect(
|
||||
interpretationBandEdges("VISCERAL_FAT").sort((a, b) => a - b),
|
||||
).toEqual([13]);
|
||||
expect(interpretationBandEdges("BMI").sort((a, b) => a - b)).toEqual([
|
||||
18.5, 25, 30, 35, 40,
|
||||
]);
|
||||
});
|
||||
|
||||
it("unions both sex band sets when the sex is unknown", () => {
|
||||
expect(
|
||||
interpretationBandEdges("WAIST_CIRCUMFERENCE").sort((a, b) => a - b),
|
||||
).toEqual([80, 88, 94, 102]);
|
||||
expect(
|
||||
interpretationBandEdges("WAIST_CIRCUMFERENCE", "MALE").sort(
|
||||
(a, b) => a - b,
|
||||
),
|
||||
).toEqual([94, 102]);
|
||||
});
|
||||
|
||||
it("returns [] for an uncovered metric", () => {
|
||||
expect(interpretationBandEdges("HEART_RATE_VARIABILITY")).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,421 @@
|
||||
/**
|
||||
* 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" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
/** The metric keys that carry a guideline interpretation. */
|
||||
export const INTERPRETED_METRIC_KEYS = Object.keys(INTERPRETATION_REGISTRY);
|
||||
|
||||
/**
|
||||
* 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];
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* v1.27.13 (Welle J) — the interpretation block renders the guideline band
|
||||
* table + server-computed position into the assessment prompt. These guards
|
||||
* assert it renders for a covered metric (with the value's band named) and is
|
||||
* ABSENT for an uncovered one or a sex-required metric with no known sex —
|
||||
* i.e. the surface fails soft to personal-relative exactly as before.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildInterpretationBlock } from "../interpretation-block";
|
||||
|
||||
describe("buildInterpretationBlock", () => {
|
||||
it("renders the band position for visceral fat 2.7 (the headline case)", () => {
|
||||
const en = buildInterpretationBlock({
|
||||
metricKey: "VISCERAL_FAT",
|
||||
value: 2.7,
|
||||
sex: null,
|
||||
locale: "en",
|
||||
});
|
||||
expect(en).toBeDefined();
|
||||
expect(en).toContain("INTERPRETATION CONTEXT");
|
||||
expect(en).toContain('"healthy" band');
|
||||
expect(en).toContain("2.7 rating");
|
||||
// The band table + source tag are present.
|
||||
expect(en).toContain("below 13 rating: healthy");
|
||||
expect(en).toContain("consumer rating convention");
|
||||
// No-diagnosis framing rule is present.
|
||||
expect(en).toContain("never");
|
||||
});
|
||||
|
||||
it("renders a German block with the localised heading", () => {
|
||||
const de = buildInterpretationBlock({
|
||||
metricKey: "OXYGEN_SATURATION",
|
||||
value: 97,
|
||||
sex: null,
|
||||
locale: "de",
|
||||
});
|
||||
expect(de).toBeDefined();
|
||||
expect(de).toContain("EINORDNUNGS-KONTEXT");
|
||||
expect(de).toContain("StatPearls/NIH 2023");
|
||||
});
|
||||
|
||||
it("names the boundary when a value hugs a band edge", () => {
|
||||
const en = buildInterpretationBlock({
|
||||
metricKey: "BODY_TEMPERATURE",
|
||||
value: 37.95,
|
||||
sex: null,
|
||||
locale: "en",
|
||||
});
|
||||
// 37.95 is in the borderline band (37.5–38) hugging the fever edge at 38.
|
||||
expect(en).toContain("borderline");
|
||||
expect(en).toContain("UPPER boundary");
|
||||
});
|
||||
|
||||
it("is undefined for an uncovered metric (personal-relative fallback)", () => {
|
||||
expect(
|
||||
buildInterpretationBlock({
|
||||
metricKey: "HEART_RATE_VARIABILITY",
|
||||
value: 42,
|
||||
sex: null,
|
||||
locale: "en",
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("is undefined for a sex-split metric when the sex is unknown", () => {
|
||||
expect(
|
||||
buildInterpretationBlock({
|
||||
metricKey: "WAIST_CIRCUMFERENCE",
|
||||
value: 96,
|
||||
sex: null,
|
||||
locale: "en",
|
||||
}),
|
||||
).toBeUndefined();
|
||||
// With a sex, it renders and names the increased-risk band.
|
||||
const male = buildInterpretationBlock({
|
||||
metricKey: "WAIST_CIRCUMFERENCE",
|
||||
value: 96,
|
||||
sex: "MALE",
|
||||
locale: "en",
|
||||
});
|
||||
expect(male).toContain("increased risk");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* v1.27.13 (Welle J) — the per-metric INTERPRETATION CONTEXT block.
|
||||
*
|
||||
* Renders the guideline band table + the server-computed band position for one
|
||||
* value into the assessment user prompt, so the model can say what the value
|
||||
* MEANS on the clinical scale (not just recite counts and a trend adjective).
|
||||
* The band membership + edge proximity are computed in
|
||||
* `insight-interpretation.ts` — the model states them, it does not recompute
|
||||
* them. Facts here; the "how to phrase it" rule lives in the shared
|
||||
* `interpretationDepth` contract on the system prompt.
|
||||
*
|
||||
* Returns undefined when the metric has no guideline band (fail-soft: the
|
||||
* assessment then stays personal-relative, exactly as before this landed).
|
||||
*/
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import {
|
||||
classifyBandPosition,
|
||||
resolveInterpretation,
|
||||
type BandValence,
|
||||
type DirectionOfGood,
|
||||
type InterpretationBand,
|
||||
} from "@/lib/ai/insight-interpretation";
|
||||
|
||||
function valencePhrase(valence: BandValence, locale: "en" | "de"): string {
|
||||
if (locale === "en") {
|
||||
switch (valence) {
|
||||
case "favourable":
|
||||
return "a healthy / low-risk range";
|
||||
case "neutral":
|
||||
return "a broadly typical range";
|
||||
case "caution":
|
||||
return "a range worth keeping an eye on";
|
||||
case "unfavourable":
|
||||
return "an elevated range guidelines flag";
|
||||
}
|
||||
}
|
||||
switch (valence) {
|
||||
case "favourable":
|
||||
return "ein gesunder / risikoarmer Bereich";
|
||||
case "neutral":
|
||||
return "ein weitgehend typischer Bereich";
|
||||
case "caution":
|
||||
return "ein Bereich, den man im Blick behalten sollte";
|
||||
case "unfavourable":
|
||||
return "ein erhöhter Bereich, den Leitlinien markieren";
|
||||
}
|
||||
}
|
||||
|
||||
function directionPhrase(dir: DirectionOfGood, locale: "en" | "de"): string {
|
||||
if (locale === "en") {
|
||||
switch (dir) {
|
||||
case "lower":
|
||||
return "lower values are more favourable";
|
||||
case "higher":
|
||||
return "higher values are more favourable";
|
||||
case "target":
|
||||
return "the middle band is the target — both extremes are less favourable";
|
||||
}
|
||||
}
|
||||
switch (dir) {
|
||||
case "lower":
|
||||
return "niedrigere Werte sind günstiger";
|
||||
case "higher":
|
||||
return "höhere Werte sind günstiger";
|
||||
case "target":
|
||||
return "das mittlere Band ist das Ziel — beide Extreme sind ungünstiger";
|
||||
}
|
||||
}
|
||||
|
||||
/** One band rendered as a plain range row against its edges. */
|
||||
function bandRow(
|
||||
band: InterpretationBand,
|
||||
lower: number | null,
|
||||
unit: string,
|
||||
locale: "en" | "de",
|
||||
): string {
|
||||
const u = unit;
|
||||
if (lower === null && band.upTo !== null) {
|
||||
return locale === "en"
|
||||
? `below ${band.upTo} ${u}: ${band.label}`
|
||||
: `unter ${band.upTo} ${u}: ${band.label}`;
|
||||
}
|
||||
if (lower !== null && band.upTo === null) {
|
||||
return locale === "en"
|
||||
? `${lower} ${u} and above: ${band.label}`
|
||||
: `ab ${lower} ${u}: ${band.label}`;
|
||||
}
|
||||
if (lower !== null && band.upTo !== null) {
|
||||
return `${lower}–${band.upTo} ${u}: ${band.label}`;
|
||||
}
|
||||
return band.label;
|
||||
}
|
||||
|
||||
function proximityPhrase(
|
||||
proximity: "central" | "near-lower-edge" | "near-upper-edge",
|
||||
nearestEdge: number | null,
|
||||
unit: string,
|
||||
locale: "en" | "de",
|
||||
): string {
|
||||
const at =
|
||||
nearestEdge !== null
|
||||
? locale === "en"
|
||||
? ` (nearest boundary at ${nearestEdge} ${unit})`
|
||||
: ` (nächste Grenze bei ${nearestEdge} ${unit})`
|
||||
: "";
|
||||
if (locale === "en") {
|
||||
switch (proximity) {
|
||||
case "central":
|
||||
return `sits comfortably inside this band${at}`;
|
||||
case "near-lower-edge":
|
||||
return `sits near the LOWER boundary of this band${at}`;
|
||||
case "near-upper-edge":
|
||||
return `sits near the UPPER boundary of this band${at}`;
|
||||
}
|
||||
}
|
||||
switch (proximity) {
|
||||
case "central":
|
||||
return `liegt komfortabel in diesem Band${at}`;
|
||||
case "near-lower-edge":
|
||||
return `liegt nahe der UNTEREN Grenze dieses Bands${at}`;
|
||||
case "near-upper-edge":
|
||||
return `liegt nahe der OBEREN Grenze dieses Bands${at}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the interpretation block for a metric + current value, or undefined
|
||||
* when the metric carries no guideline band (or needs a sex the profile lacks).
|
||||
*/
|
||||
export function buildInterpretationBlock(args: {
|
||||
metricKey: string;
|
||||
value: number;
|
||||
sex: "MALE" | "FEMALE" | null | undefined;
|
||||
locale: Locale;
|
||||
}): string | undefined {
|
||||
const resolved = resolveInterpretation(args.metricKey, args.sex ?? null);
|
||||
if (!resolved) return undefined;
|
||||
|
||||
const loc: "en" | "de" = args.locale === "en" ? "en" : "de";
|
||||
const pos = classifyBandPosition(args.value, resolved.bands);
|
||||
|
||||
const rows = resolved.bands
|
||||
.map((band, i) =>
|
||||
bandRow(
|
||||
band,
|
||||
i > 0 ? resolved.bands[i - 1].upTo : null,
|
||||
resolved.unit,
|
||||
loc,
|
||||
),
|
||||
)
|
||||
.map((r) => ` - ${r}`)
|
||||
.join("\n");
|
||||
|
||||
const proximity = proximityPhrase(
|
||||
pos.proximity,
|
||||
pos.nearestEdge,
|
||||
resolved.unit,
|
||||
loc,
|
||||
);
|
||||
const valence = valencePhrase(pos.band.valence, loc);
|
||||
const direction = directionPhrase(resolved.directionOfGood, loc);
|
||||
const caveat = resolved.caveat ? `\n- ${resolved.caveat}` : "";
|
||||
|
||||
if (loc === "en") {
|
||||
return `INTERPRETATION CONTEXT (guideline bands — computed server-side; state, do NOT recompute):
|
||||
- Current value ${args.value} ${resolved.unit} is in the "${pos.band.label}" band, and ${proximity}.
|
||||
- Guideline bands (${resolved.source}; ${direction}):
|
||||
${rows}
|
||||
- Values in the "${pos.band.label}" band are considered ${valence} per these guidelines.${caveat}
|
||||
- Name where the value sits and what that band means in plain words. Judge any trend BY this position: a shift deep inside a favourable band is a footnote; the same shift approaching or crossing a boundary is the headline. Frame consequence without diagnosis ("values in this range are considered …", never "you have / are at risk of …").`;
|
||||
}
|
||||
return `EINORDNUNGS-KONTEXT (Leitlinien-Bänder — serverseitig berechnet; nennen, NICHT neu rechnen):
|
||||
- Der aktuelle Wert ${args.value} ${resolved.unit} liegt im Band "${pos.band.label}" und ${proximity}.
|
||||
- Leitlinien-Bänder (${resolved.source}; ${direction}):
|
||||
${rows}
|
||||
- Werte im Band "${pos.band.label}" gelten laut diesen Leitlinien als ${valence}.${caveat}
|
||||
- Benenne, wo der Wert liegt und was dieses Band praktisch bedeutet. Beurteile jeden Trend GEMÄSS dieser Position: eine Verschiebung tief in einem günstigen Band ist eine Randnotiz; dieselbe Verschiebung nahe an oder über einer Grenze ist die Schlagzeile. Rahme die Konsequenz ohne Diagnose ("Werte in diesem Bereich gelten als …", nie "du hast / bist gefährdet für …").`;
|
||||
}
|
||||
Reference in new issue
Block a user