mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
feat(ai): system prompt includes curated medical references for citation grounding
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) <noreply@anthropic.com>
This commit is contained in:
1 parent
466b8b5604
commit
7fbfca672e
2 files changed
+193
No files matched your search
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user