diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts index 758ddb13a..7ce2885f7 100644 --- a/src/app/api/insights/generate/route.ts +++ b/src/app/api/insights/generate/route.ts @@ -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 { }); } +/** + * 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 { + // 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 = { + WEIGHT: "weight", + BLOOD_PRESSURE_SYS: "bloodPressureSys", + BLOOD_PRESSURE_DIA: "bloodPressureDia", + PULSE: "pulse", + BODY_FAT: "bodyFat", + SLEEP_DURATION: "sleep", + ACTIVITY_STEPS: "steps", + }; + const typeUnits: Record = { + 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; diff --git a/src/lib/ai/__tests__/medical-reference-prompt.test.ts b/src/lib/ai/__tests__/medical-reference-prompt.test.ts index 2414f8bd8..c7938e0d3 100644 --- a/src/lib/ai/__tests__/medical-reference-prompt.test.ts +++ b/src/lib/ai/__tests__/medical-reference-prompt.test.ts @@ -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+/); }); }); diff --git a/src/lib/ai/prompts/insight-generator.ts b/src/lib/ai/prompts/insight-generator.ts index 9e2403ef4..90482359c 100644 --- a/src/lib/ai/prompts/insight-generator.ts +++ b/src/lib/ai/prompts/insight-generator.ts @@ -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 diff --git a/src/lib/insights/__tests__/prompt-comparison.test.ts b/src/lib/insights/__tests__/prompt-comparison.test.ts new file mode 100644 index 000000000..f92408986 --- /dev/null +++ b/src/lib/insights/__tests__/prompt-comparison.test.ts @@ -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/); + }); +}); diff --git a/src/lib/insights/prompt.ts b/src/lib/insights/prompt.ts index b9b4ad31c..8046632a7 100644 --- a/src/lib/insights/prompt.ts +++ b/src/lib/insights/prompt.ts @@ -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; + /** 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")}`; }