diff --git a/src/lib/ai/__tests__/medical-references.test.ts b/src/lib/ai/__tests__/medical-references.test.ts new file mode 100644 index 000000000..5631047ad --- /dev/null +++ b/src/lib/ai/__tests__/medical-references.test.ts @@ -0,0 +1,175 @@ +import { describe, it, expect } from "vitest"; +import { + MEDICAL_REFERENCES, + MEDICAL_REFERENCE_IDS, + MEDICAL_REFERENCE_ORGS, + selectReferencesForMetrics, + getMedicalReferenceById, +} from "../medical-references"; + +/** + * v1.4.16 phase B5a — curated medical-reference bundle. + * + * The bundle is the single source of truth for any normative claim the + * AI surface makes ("target < 140/90", "BMI 18.5-24.9", "≥ 7h sleep"). + * Schema validation in `aiInsightResponseSchema` rejects any + * `recommendation.referenceId` that is not present in this list, so the + * model cannot fabricate a guideline citation. + * + * These tests pin: + * - Every entry has the required fields (id, org, title, titleDe, + * url, publishedYear, scope, metricApplicability). + * - URLs parse as well-formed https URLs (defence against typos that + * would render a footnote pointing at a broken link). + * - IDs are unique slugs in [a-z0-9-] (stable joining key for the + * schema). + * - The bundle covers every applicability bucket the schema will see + * (bp, weight, pulse, mood, medication) — at least one reference + * per bucket so the prompt never injects an empty SOURCES list. + * - `selectReferencesForMetrics()` filters by overlap, not exact + * match. + * - `getMedicalReferenceById()` returns undefined for unknown ids. + */ + +describe("MEDICAL_REFERENCES bundle", () => { + it("is non-empty", () => { + expect(MEDICAL_REFERENCES.length).toBeGreaterThanOrEqual(5); + }); + + it("every entry has the required fields populated", () => { + for (const ref of MEDICAL_REFERENCES) { + expect(ref.id, `id missing on ${JSON.stringify(ref)}`).toBeTruthy(); + expect(ref.org, `org missing on ${ref.id}`).toBeTruthy(); + expect(ref.title, `title missing on ${ref.id}`).toBeTruthy(); + expect(ref.titleDe, `titleDe missing on ${ref.id}`).toBeTruthy(); + expect(ref.url, `url missing on ${ref.id}`).toBeTruthy(); + expect(ref.publishedYear, `year missing on ${ref.id}`).toBeGreaterThan( + 1990, + ); + expect( + Array.isArray(ref.scope), + `scope must be array on ${ref.id}`, + ).toBe(true); + expect( + ref.scope.length, + `scope cannot be empty on ${ref.id}`, + ).toBeGreaterThan(0); + expect( + Array.isArray(ref.metricApplicability), + `metricApplicability must be array on ${ref.id}`, + ).toBe(true); + expect( + ref.metricApplicability.length, + `metricApplicability cannot be empty on ${ref.id}`, + ).toBeGreaterThan(0); + } + }); + + it("every URL parses as a well-formed https:// URL", () => { + for (const ref of MEDICAL_REFERENCES) { + expect(() => new URL(ref.url), `URL malformed on ${ref.id}`).not.toThrow(); + const parsed = new URL(ref.url); + expect(parsed.protocol, `non-https URL on ${ref.id}`).toBe("https:"); + expect(parsed.hostname, `empty hostname on ${ref.id}`).toBeTruthy(); + } + }); + + it("every id is a stable slug in [a-z0-9-] and unique", () => { + const slugRe = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/; + const seen = new Set(); + for (const ref of MEDICAL_REFERENCES) { + expect(ref.id, `id ${ref.id} not a slug`).toMatch(slugRe); + expect(seen.has(ref.id), `duplicate id ${ref.id}`).toBe(false); + seen.add(ref.id); + } + }); + + it("publishedYear is sane (1990-2100)", () => { + const now = new Date().getUTCFullYear(); + for (const ref of MEDICAL_REFERENCES) { + expect(ref.publishedYear).toBeGreaterThanOrEqual(1990); + expect(ref.publishedYear).toBeLessThanOrEqual(now + 1); + } + }); + + it("covers every metric the AI surface emits (bp, weight, pulse, mood, medication)", () => { + const requiredBuckets = [ + "bp", + "weight", + "pulse", + "mood", + "medication", + ] as const; + for (const bucket of requiredBuckets) { + const matching = MEDICAL_REFERENCES.filter((r) => + r.metricApplicability.includes(bucket), + ); + expect( + matching.length, + `no reference covers bucket "${bucket}"`, + ).toBeGreaterThan(0); + } + }); + + it("MEDICAL_REFERENCE_IDS is the same length as the bundle", () => { + expect(MEDICAL_REFERENCE_IDS.length).toBe(MEDICAL_REFERENCES.length); + for (const ref of MEDICAL_REFERENCES) { + expect(MEDICAL_REFERENCE_IDS).toContain(ref.id); + } + }); + + it("MEDICAL_REFERENCE_ORGS is the union of orgs", () => { + const orgs = new Set(MEDICAL_REFERENCES.map((r) => r.org)); + expect(MEDICAL_REFERENCE_ORGS.length).toBe(orgs.size); + for (const org of orgs) { + expect(MEDICAL_REFERENCE_ORGS).toContain(org); + } + }); +}); + +describe("selectReferencesForMetrics()", () => { + it("returns only references whose metricApplicability overlaps the requested metrics", () => { + const refs = selectReferencesForMetrics(["bp"]); + expect(refs.length).toBeGreaterThan(0); + for (const ref of refs) { + expect(ref.metricApplicability).toContain("bp"); + } + }); + + it("deduplicates when multiple metrics map to the same reference", () => { + // A reference applicable to both bp and pulse must appear once in + // the union, not twice. + const refs = selectReferencesForMetrics(["bp", "bp"]); + const ids = refs.map((r) => r.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + it("returns an empty array when no metric matches", () => { + // @ts-expect-error — pass an unknown metric to test the no-overlap path. + const refs = selectReferencesForMetrics(["sodium-intake"]); + expect(refs).toEqual([]); + }); + + it("empty input returns empty output", () => { + expect(selectReferencesForMetrics([])).toEqual([]); + }); + + it("preserves bundle order for stable prompt rendering", () => { + const refs = selectReferencesForMetrics(["bp", "weight"]); + const orderInBundle = MEDICAL_REFERENCES.filter((r) => + r.metricApplicability.some((m) => m === "bp" || m === "weight"), + ); + expect(refs.map((r) => r.id)).toEqual(orderInBundle.map((r) => r.id)); + }); +}); + +describe("getMedicalReferenceById()", () => { + it("returns the reference for a known id", () => { + const sample = MEDICAL_REFERENCES[0]; + expect(getMedicalReferenceById(sample.id)).toEqual(sample); + }); + + it("returns undefined for an unknown id", () => { + expect(getMedicalReferenceById("nonexistent-fake-2099")).toBeUndefined(); + }); +}); diff --git a/src/lib/ai/medical-references.ts b/src/lib/ai/medical-references.ts new file mode 100644 index 000000000..5e9d3bd63 --- /dev/null +++ b/src/lib/ai/medical-references.ts @@ -0,0 +1,185 @@ +/** + * v1.4.16 phase B5a — curated medical-reference bundle. + * + * Every normative claim in an AI-generated recommendation ("target + * < 140/90", "BMI 18.5-24.9", "≥ 7h sleep") MUST cite an entry in + * this list via `recommendation.referenceId`. The schema in + * `src/lib/ai/schema.ts` validates `referenceId` against + * `MEDICAL_REFERENCE_IDS`, so the model cannot fabricate a guideline + * citation pointing at a source that does not exist. + * + * Curation policy: + * - Only major bodies: AHA, ESH, ESC, WHO, DGE (German nutrition + * society), ADA. No advocacy groups, no SEO health sites, no + * research previews. + * - URLs go directly to the guideline page, not to a press release. + * They open in a new tab via `target="_blank" rel="noreferrer"`. + * - publishedYear matches the document version we cite. When a body + * refreshes the guideline, bump the year and update the URL in the + * same commit. + * - Locale: `title` is English-canonical, `titleDe` is the German + * UI label. The body of the guideline itself is whatever language + * the publishing body uses; the URL is the truth. + * + * The shape is intentionally narrower than the full §1.4.16 roadmap's + * "embedded excerpt" plan — that is a v1.4.17+ enhancement and would + * require fair-use-length quotations, locale-routed excerpts, and a + * scrape pipeline. v1.4.16 ships the citation grounding only: the + * model emits an id, the UI renders a labelled link. + */ + +export type MedicalReferenceOrg = + | "AHA" + | "ESH" + | "ESC" + | "WHO" + | "DGE" + | "ADA"; + +export type MedicalReferenceMetric = + | "bp" + | "weight" + | "pulse" + | "mood" + | "medication"; + +export interface MedicalReference { + /** Stable slug — used as `recommendation.referenceId`. */ + id: string; + /** Issuing body. Stable contract value, do not translate. */ + org: MedicalReferenceOrg; + /** Title in English (canonical UI text). */ + title: string; + /** Title in German for the de-locale UI footnote. */ + titleDe: string; + /** Direct link to the guideline page. https only. */ + url: string; + /** Year the guideline / position paper was published. */ + publishedYear: number; + /** + * Fine-grained scope tags — used by future filtering / display + * logic. Free-form strings; not validated. + */ + scope: string[]; + /** + * High-level metric buckets the reference applies to. The prompt + * builder picks references whose buckets overlap the metrics in the + * current snapshot. + */ + metricApplicability: MedicalReferenceMetric[]; +} + +export const MEDICAL_REFERENCES: MedicalReference[] = [ + { + id: "esh-2023-bp-adults", + org: "ESH", + title: "ESH 2023 Guidelines for the Management of Arterial Hypertension", + titleDe: "ESH-Leitlinie 2023 zur Behandlung der arteriellen Hypertonie", + url: "https://www.eshonline.org/guidelines/2023-esh-guidelines/", + publishedYear: 2023, + scope: ["bp_target", "bp_classification", "lifestyle"], + metricApplicability: ["bp", "medication"], + }, + { + id: "esc-2024-bp-cv", + org: "ESC", + title: + "ESC 2024 Guidelines for the Management of Elevated Blood Pressure and Hypertension", + titleDe: + "ESC-Leitlinie 2024 zur Behandlung von erhöhtem Blutdruck und Hypertonie", + url: "https://www.escardio.org/Guidelines/Clinical-Practice-Guidelines/2024-Guidelines-on-Elevated-BP-and-Hypertension", + publishedYear: 2024, + scope: ["bp_target", "bp_thresholds", "cv_risk"], + metricApplicability: ["bp", "pulse", "medication"], + }, + { + id: "aha-2017-hypertension", + org: "AHA", + title: + "ACC/AHA 2017 Guideline for the Prevention, Detection, Evaluation and Management of High Blood Pressure in Adults", + titleDe: + "ACC/AHA-Leitlinie 2017 zur Prävention, Erkennung, Bewertung und Behandlung von Bluthochdruck bei Erwachsenen", + url: "https://www.ahajournals.org/doi/10.1161/HYP.0000000000000065", + publishedYear: 2017, + scope: ["bp_target", "bp_classification"], + metricApplicability: ["bp"], + }, + { + id: "who-2021-hypertension", + org: "WHO", + title: "WHO Guideline for the Pharmacological Treatment of Hypertension in Adults", + titleDe: + "WHO-Leitlinie zur pharmakologischen Behandlung der Hypertonie bei Erwachsenen", + url: "https://www.who.int/publications/i/item/9789240033986", + publishedYear: 2021, + scope: ["bp_target", "treatment", "lifestyle"], + metricApplicability: ["bp", "medication"], + }, + { + id: "who-2024-bmi-classification", + org: "WHO", + title: "WHO BMI classification for adults", + titleDe: "WHO-BMI-Klassifikation für Erwachsene", + url: "https://www.who.int/europe/news-room/fact-sheets/item/a-healthy-lifestyle---who-recommendations", + publishedYear: 2024, + scope: ["bmi_classification", "weight_target"], + metricApplicability: ["weight"], + }, + { + id: "dge-2024-healthy-eating", + org: "DGE", + title: "DGE recommendations for a wholesome diet", + titleDe: "DGE-Empfehlungen für eine vollwertige Ernährung", + url: "https://www.dge.de/gesunde-ernaehrung/dge-empfehlungen/", + publishedYear: 2024, + scope: ["nutrition", "weight_target", "lifestyle"], + metricApplicability: ["weight", "mood"], + }, + { + id: "aha-2023-resting-heart-rate", + org: "AHA", + title: "AHA — All About Heart Rate (Pulse)", + titleDe: "AHA — Alles über die Herzfrequenz (Puls)", + url: "https://www.heart.org/en/health-topics/high-blood-pressure/the-facts-about-high-blood-pressure/all-about-heart-rate-pulse", + publishedYear: 2023, + scope: ["pulse_target", "resting_heart_rate"], + metricApplicability: ["pulse"], + }, +]; + +/** Stable list of all valid `recommendation.referenceId` values. */ +export const MEDICAL_REFERENCE_IDS: readonly string[] = MEDICAL_REFERENCES.map( + (r) => r.id, +); + +/** Stable list of all distinct issuing bodies. */ +export const MEDICAL_REFERENCE_ORGS: readonly MedicalReferenceOrg[] = Array.from( + new Set(MEDICAL_REFERENCES.map((r) => r.org)), +); + +/** + * Look up a reference by id. Returns `undefined` for unknown ids — the + * UI uses this to silently drop a footnote rather than render a broken + * link if a payload sneaks through with an unknown id (defence in + * depth alongside the schema check). + */ +export function getMedicalReferenceById( + id: string, +): MedicalReference | undefined { + return MEDICAL_REFERENCES.find((r) => r.id === id); +} + +/** + * Pick the references whose `metricApplicability` overlaps any of the + * supplied metric buckets. Order preserved from the canonical bundle + * for stable prompt rendering. + */ +export function selectReferencesForMetrics( + metrics: readonly MedicalReferenceMetric[], +): MedicalReference[] { + if (metrics.length === 0) return []; + const wanted = new Set(metrics); + return MEDICAL_REFERENCES.filter((r) => + r.metricApplicability.some((m) => wanted.has(m)), + ); +}