mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
feat(ai): curated medical-reference bundle for citation grounding
Recommendations that assert a normative claim ("BP target < 140/90",
"BMI 18.5-24.9") need to point at the guideline they came from. This
adds the static reference bundle the schema and prompt will hang
citations off in the next two commits.
Bundle covers AHA / ESH / ESC / WHO / DGE / ADA across blood pressure,
weight, pulse, mood and medication buckets. Each entry carries an EN
+ DE title, a direct https URL, the publication year, and the metric
buckets it applies to. `selectReferencesForMetrics()` picks the
overlap so a snapshot focused on BP only sees the relevant references
in the system prompt.
15 unit tests pin shape, slug uniqueness, https URL validity, year
sanity, bucket coverage and the selector helpers — so a typo in a URL
or a missing bucket fails CI before the model ever sees it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
e994926746
commit
27f3933344
2 files changed
+360
No files matched your search
@@ -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<string>();
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user