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 1/5] 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 …").`; +} From 3ce396081f33b82fbe57e817dfb1a8003f57a409 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 7 Jul 2026 00:36:34 +0200 Subject: [PATCH 2/5] feat(insights): anti-recitation and interpretation-depth prompt contracts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add two shared cross-surface contracts and compose them into the assessment cards (base-system) and the comprehensive overview + daily briefing (insight-generator): - anti-recitation: a measurement count, a logging cadence, or page mechanics is not an insight and may only appear when it carries a consequence — stated as the consequence, not the count. - interpretation-depth: say what a value MEANS, place it on its guideline scale where one is attached, judge the trend by band position, and frame the consequence without diagnosis; where no band exists, interpret against the person's own baseline and say so honestly. The cross-surface coverage guard now asserts both contracts reach the surfaces that enforce them. The briefing's grounding and format machinery is untouched. --- .../shared-contracts-coverage.test.ts | 9 ++++++ src/lib/ai/prompts/base-system.ts | 14 +++++++++ src/lib/ai/prompts/insight-generator.ts | 16 ++++++++++ src/lib/ai/prompts/shared-contracts.ts | 30 +++++++++++++++++++ 4 files changed, 69 insertions(+) diff --git a/src/lib/ai/prompts/__tests__/shared-contracts-coverage.test.ts b/src/lib/ai/prompts/__tests__/shared-contracts-coverage.test.ts index 18eda4d66..a03314091 100644 --- a/src/lib/ai/prompts/__tests__/shared-contracts-coverage.test.ts +++ b/src/lib/ai/prompts/__tests__/shared-contracts-coverage.test.ts @@ -13,6 +13,8 @@ import { describe, expect, it } from "vitest"; import { grounding, toneContract, + antiRecitation, + interpretationDepth, safetyGlp1, safetyAcute, metricIdentifierBan, @@ -53,6 +55,10 @@ const SURFACES: Record< contracts: [ grounding, toneContract, + // v1.27.13 (Welle J) — anti-recitation + interpretation-depth reach the + // overview + briefing text too. + antiRecitation, + interpretationDepth, safetyGlp1, safetyAcute, metricIdentifierBan, @@ -69,6 +75,9 @@ const SURFACES: Record< contracts: [ grounding, toneContract, + // v1.27.13 (Welle J) — the assessment cards enforce both new contracts. + antiRecitation, + interpretationDepth, safetyGlp1, safetyAcute, metricIdentifierBan, diff --git a/src/lib/ai/prompts/base-system.ts b/src/lib/ai/prompts/base-system.ts index d3b78e88c..29446c95a 100644 --- a/src/lib/ai/prompts/base-system.ts +++ b/src/lib/ai/prompts/base-system.ts @@ -2,6 +2,8 @@ import type { Locale } from "@/lib/i18n/config"; import { grounding, toneContract, + antiRecitation, + interpretationDepth, safetyGlp1, safetyAcute, metricIdentifierBan, @@ -197,6 +199,18 @@ Synthese statt Aufzählung: die Geschichte dessen, was die Daten bedeuten, zähl // the cross-surface wording so an edit lands here and everywhere at once. { id: "sharedGrounding", en: grounding.en, de: grounding.de }, { id: "sharedTone", en: toneContract.en, de: toneContract.de }, + // v1.27.13 (Welle J) — the anti-recitation + interpretation-depth contracts. + // The assessment surface is exactly where the maintainer's "counts are nice + // but useless" complaint lands, so both contracts are enforced here: don't + // narrate mechanics, and place the value on its guideline scale + judge the + // trend by position (the per-metric INTERPRETATION CONTEXT block carries the + // computed placement). + { id: "sharedAntiRecitation", en: antiRecitation.en, de: antiRecitation.de }, + { + id: "sharedInterpretationDepth", + en: interpretationDepth.en, + de: interpretationDepth.de, + }, // v1.22 (W6) — paragraph formatting contract so a longer assessment renders // as real paragraphs through the shared `ProseBlocks` helper. { diff --git a/src/lib/ai/prompts/insight-generator.ts b/src/lib/ai/prompts/insight-generator.ts index 8ffd561e8..211640416 100644 --- a/src/lib/ai/prompts/insight-generator.ts +++ b/src/lib/ai/prompts/insight-generator.ts @@ -37,6 +37,8 @@ import { import { grounding, toneContract, + antiRecitation, + interpretationDepth, safetyGlp1, safetyAcute, metricIdentifierBan, @@ -841,6 +843,20 @@ neutralem Label + Detail. Höchstgrenze: 20 Einträge.`, // future safety edit is made once in `shared-contracts.ts`. { id: "sharedGrounding", en: grounding.en, de: grounding.de }, { id: "sharedTone", en: toneContract.en, de: toneContract.de }, + // v1.27.13 (Welle J) — the overview + briefing text carried the same + // recitation disease (counts, cadence). Both contracts apply here so the + // summary and the daily briefing interpret rather than enumerate; the + // briefing's own grounding/format machinery is untouched. + { + id: "sharedAntiRecitation", + en: antiRecitation.en, + de: antiRecitation.de, + }, + { + id: "sharedInterpretationDepth", + en: interpretationDepth.en, + de: interpretationDepth.de, + }, { id: "sharedSafetyGlp1", en: safetyGlp1.en, de: safetyGlp1.de }, { id: "sharedSafetyAcute", en: safetyAcute.en, de: safetyAcute.de }, { diff --git a/src/lib/ai/prompts/shared-contracts.ts b/src/lib/ai/prompts/shared-contracts.ts index 5e867e73b..a88e29ffd 100644 --- a/src/lib/ai/prompts/shared-contracts.ts +++ b/src/lib/ai/prompts/shared-contracts.ts @@ -76,6 +76,34 @@ Write in the second person, warm and direct. When the data earns it, name the ge Schreibe in der zweiten Person, warm und direkt. Wenn die Daten es hergeben, benenne den echten Erfolg klar und baue ein wenig Schwung auf — die Person soll sich gesehen und unterstützt fühlen, nicht belehrt. Die Ermutigung muss durch die Zahlen VERDIENT sein, nie ein reflexhaftes Kompliment. Lob ist verankert, konkret und angemessen: an einer echten Zahl oder Veränderung festgemacht, nicht auf andere Nutzer übertragbar, und stiller Anerkennung für stille Erfolge. Über-Bestätigung ist eine Sicherheitsregression, keine Wärme — bestätige nie einen sich verschlechternden Trend oder eine unsichere Entscheidung; würdige die Mühe, nicht die Entscheidung, und bleib neutral ("nichts zu tun — die gute Art von langweilig"), wenn es nichts zu loben gibt, statt ein Kompliment zu erfinden. Sei autonomie-unterstützend ("einen Versuch wert", "kann helfen", nie "du musst"). Benenne auch ungünstige Werte ehrlich — Befund, dann gegen die eigene Baseline einordnen, dann ein kleiner machbarer Schritt, als Chance formuliert. Nie alarmierend, nie moralisierend, nie diagnostisch. Keine Floskeln und keine bloße Zahlenwiederholung. Beginne NICHT mit einem Kompliment über Datenmenge oder -qualität; verbotene Eröffnungen sind u.a. "Datengrundlage ist sehr stark", "Your data foundation is strong", "Du hast eine solide Baseline", "Großartiger Datensatz", ein generisches "Deine Werte sehen gut aus" und jede sinngemäße Umformulierung.`, }; +/** + * v1.27.13 (Welle J) — anti-recitation contract. The maintainer's complaint, + * verbatim intent: "that I logged it three times, or in what rhythm values were + * taken, is nice but does nothing for me." Measurement counts, logging cadence, + * and page-mechanics narration are NOT insights. They may appear ONLY when they + * carry a consequence — and then as the consequence, not the count. + */ +export const antiRecitation: SharedContract = { + en: `NOT AN INSIGHT — never recite mechanics +A measurement count ("logged 3 times"), a logging cadence ("measured every few days"), or page-mechanics narration is NOT an insight and does not belong in the prose on its own. Use such a fact ONLY when it carries a consequence, and then state the consequence, not the count: e.g. "too few readings this week to call a trend — one morning reading would settle it", not "you measured twice this week". Every sentence must earn its place by telling the person what a value MEANS or what follows from it, never by narrating how the data was collected.`, + de: `KEINE EINSCHÄTZUNG — nie Mechanik nacherzählen +Eine Messanzahl ("3-mal erfasst"), ein Erfassungsrhythmus ("alle paar Tage gemessen") oder Bedien-Mechanik ist KEINE Einschätzung und gehört für sich genommen nicht in den Text. Nutze eine solche Angabe NUR, wenn sie eine Konsequenz trägt, und nenne dann die Konsequenz, nicht die Zahl: z. B. "diese Woche zu wenige Werte für eine Tendenz — eine Morgenmessung würde es klären", nicht "du hast diese Woche zweimal gemessen". Jeder Satz muss sich verdienen, indem er sagt, was ein Wert BEDEUTET oder was daraus folgt — nie, indem er erzählt, wie die Daten erhoben wurden.`, +}; + +/** + * v1.27.13 (Welle J) — interpretation-depth contract. Assessments must + * interpret, not enumerate: place the value on its guideline scale, judge the + * trend BY that position, frame the consequence without diagnosis. Where the + * user prompt carries an INTERPRETATION CONTEXT block, lead from it; where it + * does not, interpret against the person's own baseline and say so honestly. + */ +export const interpretationDepth: SharedContract = { + en: `INTERPRET, DON'T ENUMERATE +Say what a value MEANS, not just what it is. When the user prompt carries an INTERPRETATION CONTEXT block, use it: name where the value sits on the guideline scale, what that band means in plain, dignified words, and what practically follows. Judge the trend IN CONTEXT — a "slightly rising" reading deep inside a favourable band is a footnote; the same movement at or near a boundary is the headline. Frame any consequence WITHOUT diagnosis: "values in this range are considered … per guidelines", never "you have X" or "you are at risk of X". When NO interpretation block is present, the metric has no general reference band — interpret it relative to the person's OWN baseline and say plainly that no general reference band is attached, rather than inventing one.`, + de: `EINORDNEN, NICHT AUFZÄHLEN +Sag, was ein Wert BEDEUTET, nicht nur, was er ist. Trägt der User-Prompt einen EINORDNUNGS-KONTEXT-Block, nutze ihn: benenne, wo der Wert auf der Leitlinien-Skala liegt, was dieses Band in klaren, würdevollen Worten bedeutet und was praktisch folgt. Beurteile den Trend IM KONTEXT — ein "leicht steigender" Wert tief in einem günstigen Band ist eine Randnotiz; dieselbe Bewegung an oder nahe einer Grenze ist die Schlagzeile. Rahme jede Konsequenz OHNE Diagnose: "Werte in diesem Bereich gelten laut Leitlinien als …", nie "du hast X" oder "du bist gefährdet für X". Ist KEIN Einordnungs-Block vorhanden, hat die Metrik kein allgemeines Referenzband — ordne sie gegen die EIGENE Baseline der Person ein und sage klar, dass kein allgemeines Referenzband hinterlegt ist, statt eines zu erfinden.`, +}; + /** * GLP-1 dose-prescription safety contract. Previously present ONLY in the * comprehensive + Coach prompts; now enforced on every surface that can name @@ -163,6 +191,8 @@ export const formattingContract: SharedContract = { export const SHARED_CONTRACTS = { grounding, toneContract, + antiRecitation, + interpretationDepth, safetyGlp1, safetyAcute, metricIdentifierBan, From 52d09d17c7279d644a6ef9f67f93a6eba9517c92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 7 Jul 2026 00:36:43 +0200 Subject: [PATCH 3/5] feat(insights): inject guideline band placement into generic metric assessments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thread the interpretation block into the generic per-metric assessment path (the ~40 HealthKit metric cards). For a metric the knowledge base covers, the prompt now carries the band table plus the server-computed position of the current value, so the assessment names where the value sits on the guideline scale and judges the trend by that position — the visceral-fat 2.7 case now reads as "comfortably in the healthy band" rather than reciting how many times it was logged. The block is absent for uncovered metrics and for a sex-split band with no known sex, so those cards fall back to exactly today's personal-relative behaviour. Band edges carry no strip risk here — the generic assessment path runs no post-generation numeric grounding gate. --- .../assessment-prompt-contract.test.ts | 19 ++++++++++++++++++ src/lib/ai/prompts/metric-archetypes.ts | 15 ++++++++++++-- src/lib/insights/metric-status.ts | 20 +++++++++++++++++++ 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/src/lib/ai/prompts/__tests__/assessment-prompt-contract.test.ts b/src/lib/ai/prompts/__tests__/assessment-prompt-contract.test.ts index ed68da4d4..ade53e8e8 100644 --- a/src/lib/ai/prompts/__tests__/assessment-prompt-contract.test.ts +++ b/src/lib/ai/prompts/__tests__/assessment-prompt-contract.test.ts @@ -119,6 +119,25 @@ describe("metric-archetype user prompt — context block plumbing", () => { expect(out).not.toContain("undefined"); }); + it("appends the interpretation block when provided (Welle J)", () => { + const out = getMetricArchetypeUserPrompt( + meta, + "{}", + "2026-06-05", + "en", + "", + "", + "INTERPRETATION CONTEXT (guideline bands): the healthy band.", + ); + expect(out).toContain("INTERPRETATION CONTEXT"); + }); + + it("omits the interpretation block cleanly when absent", () => { + const out = getMetricArchetypeUserPrompt(meta, "{}", "2026-06-05", "en"); + expect(out).not.toContain("INTERPRETATION CONTEXT"); + expect(out).not.toContain("undefined"); + }); + it("makes the closing step conditional in the user instruction (both locales)", () => { expect( getMetricArchetypeUserPrompt(meta, "{}", "2026-06-05", "en"), diff --git a/src/lib/ai/prompts/metric-archetypes.ts b/src/lib/ai/prompts/metric-archetypes.ts index f03de0c36..902e1d8d7 100644 --- a/src/lib/ai/prompts/metric-archetypes.ts +++ b/src/lib/ai/prompts/metric-archetypes.ts @@ -127,6 +127,13 @@ export function getMetricArchetypeUserPrompt( * grounded in already-computed data; optional and may be empty. */ assessmentContextBlock?: string, + /** + * v1.27.13 (Welle J) — the guideline interpretation block (band table + + * server-computed position) for metrics the knowledge base covers. Absent + * for metrics with no guideline band — the prompt then stays personal- + * relative, exactly as before. + */ + interpretationBlock?: string, ): string { const ctxBlock = previousContextBlock && previousContextBlock.trim().length > 0 @@ -136,14 +143,18 @@ export function getMetricArchetypeUserPrompt( assessmentContextBlock && assessmentContextBlock.trim().length > 0 ? `\n\n${assessmentContextBlock}\n` : ""; + const interpBlock = + interpretationBlock && interpretationBlock.trim().length > 0 + ? `\n\n${interpretationBlock}\n` + : ""; if (locale === "en") { return `Date: ${todayKey} (Europe/Berlin) -Write one short assessment of this person's ${meta.displayName.toLowerCase()}: name the current level with a concrete number from the snapshot, place the recent window against their own weekly/monthly baseline, and — when something is genuinely actionable — close with one doable step; when nothing is, skip the step rather than manufacture filler. Judge confidence from the measurement count and recency.${ctxBlock}${extraBlock} +Write one short assessment of this person's ${meta.displayName.toLowerCase()}: name the current level with a concrete number from the snapshot, place the recent window against their own weekly/monthly baseline, and — when something is genuinely actionable — close with one doable step; when nothing is, skip the step rather than manufacture filler. Judge confidence from the measurement count and recency.${ctxBlock}${extraBlock}${interpBlock} ${snapshotJson}`; } return `Datum: ${todayKey} (Europe/Berlin) -Schreibe eine kurze Einschätzung zu ${meta.displayName} dieser Person: benenne das aktuelle Niveau mit einer konkreten Zahl aus dem Snapshot, ordne das recent-Fenster gegen die eigene Wochen-/Monats-Baseline ein und schließe — wenn etwas wirklich umsetzbar ist — mit einem machbaren Schritt; ist nichts umsetzbar, lass den Schritt weg statt Fülltext zu erfinden. Konfidenz aus Messanzahl und Aktualität ableiten.${ctxBlock}${extraBlock} +Schreibe eine kurze Einschätzung zu ${meta.displayName} dieser Person: benenne das aktuelle Niveau mit einer konkreten Zahl aus dem Snapshot, ordne das recent-Fenster gegen die eigene Wochen-/Monats-Baseline ein und schließe — wenn etwas wirklich umsetzbar ist — mit einem machbaren Schritt; ist nichts umsetzbar, lass den Schritt weg statt Fülltext zu erfinden. Konfidenz aus Messanzahl und Aktualität ableiten.${ctxBlock}${extraBlock}${interpBlock} ${snapshotJson}`; } diff --git a/src/lib/insights/metric-status.ts b/src/lib/insights/metric-status.ts index b7fcde718..2ea11308b 100644 --- a/src/lib/insights/metric-status.ts +++ b/src/lib/insights/metric-status.ts @@ -26,6 +26,7 @@ import { getMetricArchetypeSystemPrompt, getMetricArchetypeUserPrompt, } from "@/lib/ai/prompts/metric-archetypes"; +import { buildInterpretationBlock } from "@/lib/ai/prompts/interpretation-block"; import { getMetricStatusMeta, metricStatusScope, @@ -461,6 +462,24 @@ export async function generateMetricStatus(args: { locale, ); + // v1.27.13 (Welle J) — the guideline interpretation block for metrics the + // knowledge base covers (visceral fat, resting HR, SpO₂, respiratory rate, + // body temperature, sleep duration, waist, PWV). The band position is + // computed from the signal's current value (or the latest day when no signal + // window exists); absent for uncovered metrics (fail-soft to personal- + // relative). Sex-split bands (waist) resolve only when the profile carries a + // sex. + const interpretationValue = signal?.current ?? latest?.value ?? null; + const interpretationBlock = + interpretationValue !== null + ? buildInterpretationBlock({ + metricKey: meta.id, + value: interpretationValue, + sex, + locale, + }) + : undefined; + const outcome = await runStatusCompletion({ userId: args.userId, cacheAction, @@ -473,6 +492,7 @@ export async function generateMetricStatus(args: { locale, previousContextBlock, contextBlock, + interpretationBlock, ), // v1.12.1 (D1) — the phrasing task benefits from a touch more sampling // entropy while the FACTS stay pinned by the snapshot + the From c7b4ccf24af367c3404320f3c7d97085d294c430 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 7 Jul 2026 00:41:30 +0200 Subject: [PATCH 4/5] =?UTF-8?q?chore(release):=20v1.27.13=20=E2=80=94=20as?= =?UTF-8?q?sessments=20that=20interpret?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 8 ++++++++ docs/api/openapi.yaml | 2 +- package.json | 2 +- public/sw.js | 2 +- 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ca1c3a1d..7ba9bb892 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## [Unreleased] +## [1.27.13] — 2026-07-07 — Assessments that interpret + +### Changed + +- The per-metric AI assessments now interpret values instead of enumerating them: where the current value sits on guideline reference bands (resting heart rate, SpO₂, respiratory rate, body temperature, sleep duration, waist measures, pulse-wave velocity, BMI, visceral-fat rating — each derived from cited primary sources), what that band means in plain words, and a trend judged by its position — a shift deep inside a healthy band reads as a footnote, the same shift near a boundary leads the text. Metrics without an established general reference band say so honestly and interpret against the person's own baseline. +- Two contract rules now bind every AI text surface: measurement counts and logging cadence are not insights (they may only appear when they carry a consequence), and the tone standard is encouraging and dignified — celebrates what is genuinely good, names what deserves attention, never alarms or moralises. +- Band positions are computed server-side and handed to the model as context — the model states them, it never computes them; no diagnosis language anywhere. + ## [1.27.12] — 2026-07-06 — Google Health daily totals ### Fixed diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index fc2a1abab..138d1ae88 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.27.12 + version: 1.27.13 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/package.json b/package.json index 14beda31d..4b0145bdc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.27.12", + "version": "1.27.13", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index 3bcf0d986..43d6d908b 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.27.12"; + /* @sw-version-fallback */ "v1.27.13"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` From 9ea22b8a98059eb8a6ab8925f413b44673da2632 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 7 Jul 2026 00:45:23 +0200 Subject: [PATCH 5/5] chore: drop an unused registry-keys export --- src/lib/ai/insight-interpretation.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/lib/ai/insight-interpretation.ts b/src/lib/ai/insight-interpretation.ts index 3b02e8d10..71ad3909e 100644 --- a/src/lib/ai/insight-interpretation.ts +++ b/src/lib/ai/insight-interpretation.ts @@ -292,9 +292,6 @@ const INTERPRETATION_REGISTRY: Record = { }, }; -/** 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