From 8fc71620e8724ca9abbe1ba58310267cf896157f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 7 Jul 2026 00:36:25 +0200 Subject: [PATCH] feat(insights): guideline interpretation registry for per-metric assessments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../__tests__/insight-interpretation.test.ts | 151 +++++++ src/lib/ai/insight-interpretation.ts | 421 ++++++++++++++++++ .../__tests__/interpretation-block.test.ts | 83 ++++ src/lib/ai/prompts/interpretation-block.ts | 178 ++++++++ 4 files changed, 833 insertions(+) create mode 100644 src/lib/ai/__tests__/insight-interpretation.test.ts create mode 100644 src/lib/ai/insight-interpretation.ts create mode 100644 src/lib/ai/prompts/__tests__/interpretation-block.test.ts create mode 100644 src/lib/ai/prompts/interpretation-block.ts diff --git a/src/lib/ai/__tests__/insight-interpretation.test.ts b/src/lib/ai/__tests__/insight-interpretation.test.ts new file mode 100644 index 000000000..977a8397c --- /dev/null +++ b/src/lib/ai/__tests__/insight-interpretation.test.ts @@ -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([]); + }); +}); diff --git a/src/lib/ai/insight-interpretation.ts b/src/lib/ai/insight-interpretation.ts new file mode 100644 index 000000000..3b02e8d10 --- /dev/null +++ b/src/lib/ai/insight-interpretation.ts @@ -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 = { + // 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(); + 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(); + for (const b of resolved.bands) if (b.upTo !== null) edges.add(b.upTo); + return [...edges]; +} diff --git a/src/lib/ai/prompts/__tests__/interpretation-block.test.ts b/src/lib/ai/prompts/__tests__/interpretation-block.test.ts new file mode 100644 index 000000000..31a04abca --- /dev/null +++ b/src/lib/ai/prompts/__tests__/interpretation-block.test.ts @@ -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"); + }); +}); diff --git a/src/lib/ai/prompts/interpretation-block.ts b/src/lib/ai/prompts/interpretation-block.ts new file mode 100644 index 000000000..9d83b7663 --- /dev/null +++ b/src/lib/ai/prompts/interpretation-block.ts @@ -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 …").`; +}