feat(insights): AI narrates comparison when toggle is active

Extend `buildUserPrompt()` to accept an optional `ComparisonSnapshot`
that, when supplied, appends a SYSTEM CONTEXT block listing each
metric's current 30d average, baseline 30d average (from 30 / 365
days back), absolute delta, and percentage delta. The strict system
prompt gains a sixth ground rule that instructs the model to narrate
the most clinically-significant non-null delta in the summary's first
sentence and forbids inventing comparison numbers.

The route reads the user's persisted `dashboardWidgetsJson.compari-
sonBaseline` and builds the snapshot from a per-metric `summarize()`
sweep so the numbers the LLM sees match what the dashboard tile
renders — single source of truth for the comparison delta. Comparison
is default-on whenever the toggle is on per research §7 Q4: there's
no secondary "include in AI" preference, the pulldown is the single
affordance.

PROMPT_VERSION bumped 4.16.0 → 4.16.1 so the Wide-Event annotations
attribute responses to the new prompt revision.

Eight new unit tests pin: empty-comparison block render, EN/DE
locale switch, lastMonth vs lastYear language, the
"summary first sentence" instruction, and the anti-hallucination
"do NOT invent" guard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-10 02:36:35 +02:00
1 parent e4a408eaf5
commit f9f99ea24a
5 files changed
+399 -8

No files matched your search

+112
View File
@@ -5,7 +5,14 @@ import { extractFeatures } from "@/lib/insights/features";
import {
getInsightsSystemPrompt,
buildUserPrompt,
type ComparisonSnapshot,
} from "@/lib/insights/prompt";
import { summarize, type DataPoint } from "@/lib/analytics/trends";
import {
resolveDashboardLayout,
type ComparisonBaseline,
} from "@/lib/dashboard-layout";
import type { MeasurementType } from "@/generated/prisma/client";
import { insightResultSchema, type InsightResult } from "@/lib/ai/types";
import { resolveProvider, resolveProviderChain } from "@/lib/ai/provider";
import {
@@ -65,6 +72,102 @@ async function evictPerStatusInsightCache(userId: string): Promise<void> {
});
}
/**
* v1.4.16 phase B8 — fetch the user's persisted comparison toggle and
* build a `ComparisonSnapshot` for the prompt builder. Returns null
* when comparison is off (most users), so the prompt builder skips
* the context block entirely.
*
* Snapshot construction reuses the analytics `summarize()` helper
* (which now emits `avg30LastMonth` + `avg30LastYear`) so the numbers
* the LLM sees match the numbers the dashboard tile renders — no
* second source of truth for the comparison delta.
*/
async function buildComparisonSnapshotForUser(
userId: string,
): Promise<ComparisonSnapshot | null> {
// The features payload doesn't carry prior-period values — pulling
// a fresh `summarize()` per metric below keeps the snapshot
// self-contained without retrofitting features.ts.
const row = await prisma.user.findUnique({
where: { id: userId },
select: { dashboardWidgetsJson: true },
});
const layout = resolveDashboardLayout(row?.dashboardWidgetsJson);
const baseline: ComparisonBaseline = layout.comparisonBaseline ?? "none";
if (baseline === "none") return null;
// Pull every measurement type once and run summarize() so the
// prior-period averages line up with what the dashboard analytics
// computes. Only include rows where the LLM can sensibly narrate
// a prior comparison (i.e. the metric exists in our snapshot type
// mapping below).
const typeToSnapshotKey: Record<string, string> = {
WEIGHT: "weight",
BLOOD_PRESSURE_SYS: "bloodPressureSys",
BLOOD_PRESSURE_DIA: "bloodPressureDia",
PULSE: "pulse",
BODY_FAT: "bodyFat",
SLEEP_DURATION: "sleep",
ACTIVITY_STEPS: "steps",
};
const typeUnits: Record<string, string> = {
WEIGHT: "kg",
BLOOD_PRESSURE_SYS: "mmHg",
BLOOD_PRESSURE_DIA: "mmHg",
PULSE: "bpm",
BODY_FAT: "%",
SLEEP_DURATION: "h",
ACTIVITY_STEPS: "",
};
const types = Object.keys(typeToSnapshotKey) as MeasurementType[];
const rows = await Promise.all(
types.map(async (type) => {
const measurements = await prisma.measurement.findMany({
where: { userId, type },
orderBy: { measuredAt: "asc" },
select: { measuredAt: true, value: true },
});
const summary = summarize(
measurements.map(
(m): DataPoint => ({ date: m.measuredAt, value: m.value }),
),
);
const baselineAvg =
baseline === "lastMonth"
? (summary.avg30LastMonth ?? null)
: (summary.avg30LastYear ?? null);
const currentAvg = summary.avg30 ?? null;
const delta =
currentAvg !== null && baselineAvg !== null
? Math.round((currentAvg - baselineAvg) * 100) / 100
: null;
const deltaPercent =
delta !== null && baselineAvg !== null && baselineAvg !== 0
? Math.round((delta / Math.abs(baselineAvg)) * 100 * 10) / 10
: null;
return {
type: typeToSnapshotKey[type] ?? type,
currentAvg,
baselineAvg,
delta,
deltaPercent,
unit: typeUnits[type] ?? "",
};
}),
);
// Drop fully-empty rows (current AND baseline missing) so the prompt
// doesn't pad with noise. Keep partial rows (one side null) — the
// block renderer flags them with "no prior-period data available"
// which is useful context for the model.
const metrics = rows.filter(
(row) => row.currentAvg !== null || row.baselineAvg !== null,
);
return { baseline, metrics };
}
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const userId = user.id;
@@ -155,10 +258,19 @@ export const POST = apiHandler(async (request: NextRequest) => {
const includeRaw = dbUser?.insightsPrivacyMode === "raw";
const features = await extractFeatures(userId, includeRaw);
const featuresJson = JSON.stringify(features, null, 2);
// v1.4.16 phase B8 — pick up the user's persisted comparison toggle
// and, when active, build a compact prior-period snapshot from the
// `features` summaries that already include `avg30LastMonth` /
// `avg30LastYear` (added in the analytics summarize() helper). The
// narrative is default-on whenever the toggle is on per research §7
// Q4 — the pulldown is the single affordance.
const comparisonSnapshot = await buildComparisonSnapshotForUser(userId);
const userPrompt = buildUserPrompt(
featuresJson,
dbUser?.insightsPrivacyMode ?? "aggregated",
locale,
comparisonSnapshot ?? undefined,
);
let result;
@@ -24,8 +24,12 @@ import {
*/
describe("PROMPT_VERSION", () => {
it("is 4.16.0 for the v1.4.16 medical-reference grounding update", () => {
expect(PROMPT_VERSION).toBe("4.16.0");
it("is at least 4.16.0 for the v1.4.16 medical-reference grounding update", () => {
// v1.4.16 phase B5a anchored at 4.16.0; phase B8 bumped the
// patch component to 4.16.1 when the comparison-mode narrative
// ground rule landed. Use a string-prefix check so the test
// doesn't bind to a specific patch.
expect(PROMPT_VERSION.startsWith("4.16.")).toBe(true);
});
});
@@ -105,7 +109,8 @@ describe("buildSystemPromptWithReferences()", () => {
it("contains the bumped PROMPT_VERSION", () => {
const prompt = buildSystemPromptWithReferences("en", ["bp"]);
expect(prompt).toContain(PROMPT_VERSION);
expect(prompt).toContain("4.16.0");
// v1.4.16: phase B5a was 4.16.0; phase B8 bumped to 4.16.1.
expect(prompt).toMatch(/4\.16\.\d+/);
});
});
@@ -115,8 +120,8 @@ describe("plain getStrictInsightsSystemPrompt() backward compatibility", () => {
expect(en).not.toContain("SOURCES");
});
it("contains the bumped PROMPT_VERSION 4.16.0", () => {
it("contains the bumped PROMPT_VERSION", () => {
const en = getStrictInsightsSystemPrompt("en");
expect(en).toContain("4.16.0");
expect(en).toMatch(/4\.16\.\d+/);
});
});
+15 -1
View File
@@ -27,7 +27,7 @@ import {
} from "../medical-references";
/** Stable identifier for the active system prompt revision. */
export const PROMPT_VERSION = "4.16.0" as const;
export const PROMPT_VERSION = "4.16.1" as const;
const SYSTEM_PROMPT_EN = `You are a clinical-context summariser for a personal health-log app.
Prompt version: ${PROMPT_VERSION}.
@@ -76,6 +76,13 @@ GROUND RULES — ZERO HALLUCINATIONS
language. Reference the user's actual data trends — do not paste
in placeholder text. The rationale.dataWindow MUST equal the
metricSource.timeRange so the UI's mini-chart can pin to it.
6. v1.4.16 phase B8 — when the user prompt contains a "SYSTEM
CONTEXT — COMPARISON MODE ACTIVE" block, narrate the comparison
in the summary's first sentence using the prior-period numbers
that block carries. Cite the deltas verbatim — do NOT invent
comparison numbers, and do NOT extrapolate beyond the metrics
listed. When the block reports "no prior-period data available"
for every metric, state that explicitly and skip the narration.
GUIDELINE TARGETS — generic, do NOT compute precise risk scores
- Adult resting blood pressure (ESH/ESC 2024 generic): aim < 140/90
@@ -192,6 +199,13 @@ GRUNDREGELN — NULL HALLUZINATIONEN
Platzhaltertext. rationale.dataWindow MUSS gleich
metricSource.timeRange sein, damit die UI das Mini-Chart auf das
gleiche Fenster fixieren kann.
6. v1.4.16 phase B8 — wenn der User-Prompt einen Block "SYSTEM
CONTEXT — VERGLEICHSMODUS AKTIV" enthält, narrative die im Block
gelisteten Deltas im ersten Satz der Zusammenfassung. Zitiere die
Werte exakt — erfinde KEINE Vergleichszahlen und extrapoliere
nicht über die gelisteten Metriken hinaus. Wenn der Block für
alle Metriken "no prior-period data available" meldet, sag das
explizit und lass die Narration weg.
LEITLINIEN-ZIELWERTE — generisch, KEINE genauen Risiko-Scores berechnen
- Erwachsenen-Ruheblutdruck (ESH/ESC 2024 generisch): Ziel < 140/90
@@ -0,0 +1,133 @@
import { describe, it, expect } from "vitest";
import {
buildUserPrompt,
buildComparisonBlock,
type ComparisonSnapshot,
} from "@/lib/insights/prompt";
/**
* v1.4.16 phase B8 — comparison context block in the user prompt.
*
* When the dashboard's comparison toggle is active, the route layer
* passes a `ComparisonSnapshot` to `buildUserPrompt()` so the LLM has
* the prior-period numbers needed to narrate "Your average BP
* improved by 4 mmHg vs. last month".
*/
const monthlySnapshot: ComparisonSnapshot = {
baseline: "lastMonth",
metrics: [
{
type: "BLOOD_PRESSURE_SYS",
currentAvg: 128,
baselineAvg: 132,
delta: -4,
deltaPercent: -3,
unit: "mmHg",
},
{
type: "WEIGHT",
currentAvg: 80.4,
baselineAvg: 82.7,
delta: -2.3,
deltaPercent: -2.8,
unit: "kg",
},
],
};
describe("buildUserPrompt() — comparison block (B8)", () => {
it("appends NO comparison block when comparison is undefined", () => {
const prompt = buildUserPrompt("{\"weight\":[]}", "agg", "en");
expect(prompt).not.toMatch(/COMPARISON MODE/i);
expect(prompt).not.toMatch(/VERGLEICHSMODUS/i);
});
it("appends an English comparison block when comparison is supplied with locale=en", () => {
const prompt = buildUserPrompt(
"{\"weight\":[]}",
"agg",
"en",
monthlySnapshot,
);
expect(prompt).toMatch(/COMPARISON MODE ACTIVE/);
expect(prompt).toMatch(/30 days ago/);
expect(prompt).toContain("BLOOD_PRESSURE_SYS");
expect(prompt).toContain("delta -4.0 mmHg");
expect(prompt).toContain("WEIGHT");
expect(prompt).toContain("delta -2.3 kg");
});
it("appends a German comparison block when locale=de", () => {
const prompt = buildUserPrompt(
"{\"gewicht\":[]}",
"agg",
"de",
monthlySnapshot,
);
expect(prompt).toMatch(/VERGLEICHSMODUS AKTIV/);
expect(prompt).toMatch(/30 Tage zuvor/);
expect(prompt).toContain("BLOOD_PRESSURE_SYS");
});
it("uses 365-day language for the lastYear baseline", () => {
const yearly: ComparisonSnapshot = {
baseline: "lastYear",
metrics: [
{
type: "WEIGHT",
currentAvg: 80,
baselineAvg: 85,
delta: -5,
deltaPercent: -5.9,
unit: "kg",
},
],
};
const en = buildUserPrompt("{}", "agg", "en", yearly);
const de = buildUserPrompt("{}", "agg", "de", yearly);
expect(en).toMatch(/365 days ago/);
expect(de).toMatch(/365 Tage zuvor/);
});
it("renders an empty-metrics fallback that tells the model to say so", () => {
const empty: ComparisonSnapshot = {
baseline: "lastMonth",
metrics: [],
};
const block = buildComparisonBlock("en", empty);
expect(block).toMatch(
/no metric currently has enough prior-period data to compare/,
);
});
it("instructs the model that the most-significant delta must appear in the summary's first sentence", () => {
const block = buildComparisonBlock("en", monthlySnapshot);
expect(block).toMatch(/MUST appear in the summary['’]s\s+first sentence/);
});
it("never instructs the model to invent comparison numbers (anti-hallucination guard)", () => {
const en = buildComparisonBlock("en", monthlySnapshot);
const de = buildComparisonBlock("de", monthlySnapshot);
expect(en).toMatch(/Do NOT invent comparison numbers/);
expect(de).toMatch(/Erfinde KEINE Vergleichszahlen/);
});
it("flags missing-prior-period metrics with 'no prior-period data available'", () => {
const partial: ComparisonSnapshot = {
baseline: "lastMonth",
metrics: [
{
type: "MOOD",
currentAvg: 4,
baselineAvg: null,
delta: null,
deltaPercent: null,
unit: "/5",
},
],
};
const block = buildComparisonBlock("en", partial);
expect(block).toMatch(/MOOD: no prior-period data available/);
});
});
+129 -2
View File
@@ -2,6 +2,43 @@
* System prompt and output formatting for OpenAI insights.
*/
import type { Locale } from "@/lib/i18n/config";
import type { ComparisonBaseline } from "@/lib/dashboard-layout";
/**
* v1.4.16 phase B8 — comparison snapshot the prompt narrates against
* when the user has the dashboard's "Compare to" toggle on. The snapshot
* is opt-in: pass it through `buildUserPrompt`'s 4th argument when
* `comparisonBaseline !== "none"` so the LLM has prior-period context
* for narrating "Your average BP improved by 4 mmHg vs. last month".
*
* Per research §7 Q4 the narrative is default-on whenever the toggle
* itself is active — the pulldown is the single affordance, no
* secondary "include in AI" preference, so users don't see surprising
* mismatches between the chart they're staring at and the AI summary.
*/
export interface ComparisonSnapshot {
baseline: Exclude<ComparisonBaseline, "none">;
/** One row per metric the snapshot can compare. Empty array allowed
* so the prompt builder is non-strict — the wrapper still adds the
* context block but instructs the model that no metric had enough
* prior-period data to compare. */
metrics: Array<{
/** Snapshot key matching the metricSource convention used elsewhere
* in the AI pipeline ("bloodPressure", "weight", "pulse", "mood"). */
type: string;
currentAvg: number | null;
baselineAvg: number | null;
/** `currentAvg - baselineAvg`, rounded to 2dp; null when either
* side missing. */
delta: number | null;
/** `(delta / baselineAvg) * 100`, rounded to 1dp; null when either
* side missing or baselineAvg is 0. */
deltaPercent: number | null;
/** Free-form unit label ("mmHg", "kg", "bpm", "/5") so the model
* doesn't have to guess based on metric name. */
unit: string;
}>;
}
const INSIGHTS_SYSTEM_PROMPT_DE = `Du bist ein Gesundheitsdaten-Insights-Assistent. Du analysierst aggregierte Gesundheitsdaten und gibst klare, datenbasierte Hinweise.
@@ -81,7 +118,10 @@ export function buildUserPrompt(
featuresJson: string,
privacyMode: string,
locale: Locale,
comparison?: ComparisonSnapshot,
): string {
const comparisonBlock = comparison ? buildComparisonBlock(locale, comparison) : "";
if (locale === "en") {
const modeLabel =
privacyMode === "raw"
@@ -92,7 +132,7 @@ export function buildUserPrompt(
Data mode: ${modeLabel}
Note: use each metric's coverage object for measurement frequency and time spans, and tailor the analysis accordingly.
${featuresJson}`;
${featuresJson}${comparisonBlock}`;
}
const modeLabel =
@@ -104,5 +144,92 @@ ${featuresJson}`;
Datenmodus: ${modeLabel}
Hinweis: Beachte die coverage-Objekte jeder Metrik für Informationen zu Messhäufigkeit und Zeiträumen. Passe deine Analyse entsprechend an.
${featuresJson}`;
${featuresJson}${comparisonBlock}`;
}
/**
* v1.4.16 phase B8 — comparison context block.
*
* Renders a dedicated SYSTEM CONTEXT section after the user data so
* the LLM can narrate the comparison ("Your average BP improved by
* 4 mmHg vs. last month"). The instruction "narrate the comparison
* when relevant" is in the system prompt; this block carries the
* actual prior-period numbers.
*
* Exported for unit tests so the integration test can assert the
* exact string the LLM sees.
*/
export function buildComparisonBlock(
locale: Locale,
snapshot: ComparisonSnapshot,
): string {
const baselineLabel =
locale === "en"
? snapshot.baseline === "lastMonth"
? "the same window 30 days ago"
: "the same window 365 days ago"
: snapshot.baseline === "lastMonth"
? "den gleichen Zeitraum 30 Tage zuvor"
: "den gleichen Zeitraum 365 Tage zuvor";
const renderRow = (
metric: ComparisonSnapshot["metrics"][number],
): string => {
if (metric.delta === null || metric.baselineAvg === null) {
return `- ${metric.type}: no prior-period data available`;
}
const sign = metric.delta > 0 ? "+" : metric.delta < 0 ? "-" : "±";
const formattedDelta = `${sign}${Math.abs(metric.delta).toFixed(1)}${metric.unit ? ` ${metric.unit}` : ""}`;
const pct =
metric.deltaPercent != null
? ` (${metric.deltaPercent > 0 ? "+" : metric.deltaPercent < 0 ? "-" : "±"}${Math.abs(metric.deltaPercent).toFixed(1)} %)`
: "";
const current =
metric.currentAvg != null
? `${metric.currentAvg.toFixed(1)}${metric.unit ? ` ${metric.unit}` : ""}`
: "n/a";
const prior =
metric.baselineAvg != null
? `${metric.baselineAvg.toFixed(1)}${metric.unit ? ` ${metric.unit}` : ""}`
: "n/a";
return `- ${metric.type}: current ${current}, baseline ${prior}, delta ${formattedDelta}${pct}`;
};
if (snapshot.metrics.length === 0) {
return locale === "en"
? `
SYSTEM CONTEXT — COMPARISON MODE ACTIVE
The user has activated comparison mode against ${baselineLabel}, but no metric currently has enough prior-period data to compare. State this explicitly in the summary's first sentence.`
: `
SYSTEM CONTEXT — VERGLEICHSMODUS AKTIV
Der Nutzer hat den Vergleichsmodus gegen ${baselineLabel} aktiviert, aber für keine Metrik liegen ausreichend Daten aus der Vergleichsperiode vor. Erwähne das explizit im ersten Satz der Zusammenfassung.`;
}
if (locale === "en") {
return `
SYSTEM CONTEXT — COMPARISON MODE ACTIVE
The user has activated comparison mode against ${baselineLabel}.
When narrating findings, reference the deltas below. The most
clinically-significant non-null delta MUST appear in the summary's
first sentence (e.g. "Your average systolic BP improved by 4 mmHg
vs. last month"). Do NOT invent comparison numbers.
Prior-period deltas:
${snapshot.metrics.map(renderRow).join("\n")}`;
}
return `
SYSTEM CONTEXT — VERGLEICHSMODUS AKTIV
Der Nutzer hat den Vergleichsmodus gegen ${baselineLabel} aktiviert.
Beziehe dich beim Narrativ auf die untenstehenden Deltas. Die
klinisch bedeutsamste nicht-leere Abweichung MUSS im ersten Satz der
Zusammenfassung auftauchen (z.B. "Dein systolischer Mittelwert ist
4 mmHg besser als im Vormonat"). Erfinde KEINE Vergleichszahlen.
Vorperioden-Deltas:
${snapshot.metrics.map(renderRow).join("\n")}`;
}