From 7fbfca672e8fa48446d470ce6629769e5ddf853d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 9 May 2026 23:49:25 +0200 Subject: [PATCH] feat(ai): system prompt includes curated medical references for citation grounding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds a SOURCES block dynamically — only references whose applicability overlaps the metrics in the current snapshot get listed, so a weight-only call doesn't burn tokens on ESH BP guidance. Each line carries the id (lowercase, parser-validated), org, year, title (locale-routed) and direct URL so the model can cite by id. Adds a GROUND RULE that tells the model to set `recommendation.referenceId` to the matching SOURCES id when making a target-range or normative claim, and to leave it empty for purely observational comparisons. The schema check from the previous commit rejects fabricated ids — defence in depth on the prompt instruction. Plain `getStrictInsightsSystemPrompt(locale)` stays unchanged for back-compat with the existing assertions and any call-site that hasn't migrated yet. The new `buildSystemPromptWithReferences(locale, metrics)` is the path forward. 10 unit tests pin the SOURCES-block contents, locale-routed titles, deduplication when metrics repeat, and that the existing scope- hardened guards (refusal pattern, citation cross-check) survive. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../medical-reference-prompt.test.ts | 122 ++++++++++++++++++ src/lib/ai/prompts/insight-generator.ts | 71 ++++++++++ 2 files changed, 193 insertions(+) create mode 100644 src/lib/ai/__tests__/medical-reference-prompt.test.ts diff --git a/src/lib/ai/__tests__/medical-reference-prompt.test.ts b/src/lib/ai/__tests__/medical-reference-prompt.test.ts new file mode 100644 index 000000000..2414f8bd8 --- /dev/null +++ b/src/lib/ai/__tests__/medical-reference-prompt.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect } from "vitest"; +import { + getStrictInsightsSystemPrompt, + buildSystemPromptWithReferences, + PROMPT_VERSION, +} from "../prompts/insight-generator"; +import { + MEDICAL_REFERENCES, + selectReferencesForMetrics, +} from "../medical-references"; + +/** + * v1.4.16 phase B5a — system prompt embeds the curated medical- + * reference bundle as a SOURCES block so the model can cite by id. + * + * The block is injected dynamically: only references that overlap the + * metrics in the current snapshot show up. This keeps the prompt + * focused (no irrelevant ESH text on a weight-only call) and stays + * inside the token budget. + * + * The plain `getStrictInsightsSystemPrompt(locale)` continues to work + * (no metrics → no SOURCES block) so legacy call-sites and the + * existing prompt assertions stay green during the migration. + */ + +describe("PROMPT_VERSION", () => { + it("is 4.16.0 for the v1.4.16 medical-reference grounding update", () => { + expect(PROMPT_VERSION).toBe("4.16.0"); + }); +}); + +describe("buildSystemPromptWithReferences()", () => { + it("with no metrics returns the plain prompt with no SOURCES block", () => { + const prompt = buildSystemPromptWithReferences("en", []); + expect(prompt).toContain("YOUR ROLE"); + expect(prompt).not.toContain("SOURCES"); + expect(prompt).not.toContain("MEDICAL_REFERENCES"); + }); + + it("for a bp-only snapshot includes a SOURCES block listing the bp-applicable references", () => { + const prompt = buildSystemPromptWithReferences("en", ["bp"]); + const refs = selectReferencesForMetrics(["bp"]); + expect(refs.length).toBeGreaterThan(0); + expect(prompt).toContain("SOURCES"); + for (const ref of refs) { + expect(prompt).toContain(ref.id); + expect(prompt).toContain(ref.org); + expect(prompt).toContain(ref.title); + expect(prompt).toContain(ref.url); + } + }); + + it("English variant uses the English title; German variant uses titleDe", () => { + const en = buildSystemPromptWithReferences("en", ["bp"]); + const de = buildSystemPromptWithReferences("de", ["bp"]); + const sample = MEDICAL_REFERENCES.find((r) => + r.metricApplicability.includes("bp"), + ); + expect(sample).toBeDefined(); + if (sample && sample.title !== sample.titleDe) { + expect(en).toContain(sample.title); + expect(de).toContain(sample.titleDe); + } + }); + + it("teaches the model the citation rule under GROUND RULES", () => { + const prompt = buildSystemPromptWithReferences("en", ["bp"]); + // Allow whitespace incl. newline between phrase halves so a future + // re-flow doesn't break the assertion. + expect(prompt).toMatch(/cite the matching\s+reference id/i); + expect(prompt).toMatch(/SOURCES/); + }); + + it("German prompt teaches the citation rule under GROUNDREGELN", () => { + const prompt = buildSystemPromptWithReferences("de", ["bp"]); + // The prompt wraps; allow whitespace incl. newline between phrase + // halves so a future re-flow doesn't break the assertion. + expect(prompt).toMatch(/zitiere die passende\s+Referenz-ID/i); + expect(prompt).toMatch(/SOURCES/); + }); + + it("dedupes references when multiple metrics map to the same source", () => { + const prompt = buildSystemPromptWithReferences("en", ["bp", "bp", "bp"]); + const sample = MEDICAL_REFERENCES.find((r) => + r.metricApplicability.includes("bp"), + ); + expect(sample).toBeDefined(); + if (sample) { + // Each id appears at least once but not duplicated within the + // SOURCES block (a duplicate would confuse the model). + const occurrences = ( + prompt.match(new RegExp(`id: ${sample.id}\\b`, "g")) ?? [] + ).length; + expect(occurrences).toBe(1); + } + }); + + it("keeps the existing scope-hardened guards (refusal pattern, citation cross-check)", () => { + const prompt = buildSystemPromptWithReferences("en", ["bp"]); + expect(prompt).toMatch(/OUT-OF-SCOPE/); + expect(prompt).toMatch(/ZERO HALLUCINATIONS/); + expect(prompt).toContain("citations[]"); + }); + + it("contains the bumped PROMPT_VERSION", () => { + const prompt = buildSystemPromptWithReferences("en", ["bp"]); + expect(prompt).toContain(PROMPT_VERSION); + expect(prompt).toContain("4.16.0"); + }); +}); + +describe("plain getStrictInsightsSystemPrompt() backward compatibility", () => { + it("does NOT include the SOURCES block (back-compat)", () => { + const en = getStrictInsightsSystemPrompt("en"); + expect(en).not.toContain("SOURCES"); + }); + + it("contains the bumped PROMPT_VERSION 4.16.0", () => { + const en = getStrictInsightsSystemPrompt("en"); + expect(en).toContain("4.16.0"); + }); +}); diff --git a/src/lib/ai/prompts/insight-generator.ts b/src/lib/ai/prompts/insight-generator.ts index 4a9691e72..180d896d0 100644 --- a/src/lib/ai/prompts/insight-generator.ts +++ b/src/lib/ai/prompts/insight-generator.ts @@ -21,6 +21,10 @@ */ import type { Locale } from "@/lib/i18n/config"; +import { + selectReferencesForMetrics, + type MedicalReferenceMetric, +} from "../medical-references"; /** Stable identifier for the active system prompt revision. */ export const PROMPT_VERSION = "4.16.0" as const; @@ -244,6 +248,73 @@ export function getStrictInsightsSystemPrompt(locale: Locale): string { return locale === "en" ? SYSTEM_PROMPT_EN : SYSTEM_PROMPT_DE; } +/** + * v1.4.16 phase B5a — return the scope-hardened prompt with a + * dynamically-built SOURCES block injected at the end. Only + * references whose `metricApplicability` overlaps the current + * `metrics[]` are listed, so a weight-only call doesn't burn tokens + * on ESH BP guidance. + * + * The model is told to cite the SOURCES id from the + * `recommendation.referenceId` field (validated against the curated + * bundle in `src/lib/ai/medical-references.ts`). When no metrics are + * supplied the function returns the plain prompt unchanged — useful + * for legacy call-sites and the existing prompt assertions. + */ +export function buildSystemPromptWithReferences( + locale: Locale, + metrics: readonly MedicalReferenceMetric[], +): string { + const base = getStrictInsightsSystemPrompt(locale); + if (metrics.length === 0) return base; + + const refs = selectReferencesForMetrics(metrics); + if (refs.length === 0) return base; + + if (locale === "en") { + const sourcesBlock = refs + .map( + (r) => + `- id: ${r.id} | org: ${r.org} | year: ${r.publishedYear} | title: ${r.title} | url: ${r.url}`, + ) + .join("\n"); + return `${base} + +SOURCES — curated medical references applicable to the current metrics +${sourcesBlock} + +GROUND RULE — REFERENCE CITATION +When making a target-range claim or normative comparison ("target +< 140/90", "BMI 18.5-24.9", "≥ 7h sleep"), cite the matching +reference id from the SOURCES list above by setting +"recommendation.referenceId" to that id (lowercase, exact match). +Use null / omit the field when the recommendation is observational +only (e.g. "your avg7 is 4 mmHg above your 90-day median"). Never +invent an id — the parser rejects fabricated values.`; + } + + const sourcesBlock = refs + .map( + (r) => + `- id: ${r.id} | org: ${r.org} | jahr: ${r.publishedYear} | titel: ${r.titleDe} | url: ${r.url}`, + ) + .join("\n"); + return `${base} + +SOURCES — kuratierte medizinische Referenzen für die aktuellen Metriken +${sourcesBlock} + +GROUNDREGEL — REFERENZ-ZITAT +Bei einer Zielwert-Aussage oder einem normativen Vergleich ("Ziel +< 140/90", "BMI 18,5-24,9", "≥ 7 h Schlaf") zitiere die passende +Referenz-ID aus der obigen SOURCES-Liste, indem du +"recommendation.referenceId" auf diese ID setzt (Kleinbuchstaben, +exakter Treffer). Lass das Feld weg oder setze null, wenn die +Empfehlung rein beobachtend ist (z.B. "dein avg7 liegt 4 mmHg über +deinem 90-Tage-Median"). Erfinde nie eine ID — der Parser lehnt +erfundene Werte ab.`; +} + /** * Out-of-scope refusal payload — what the prompt instructs the model * to return when the snapshot has nothing to summarise. Exposed for