diff --git a/src/lib/ai/__tests__/rationale-prompt.test.ts b/src/lib/ai/__tests__/rationale-prompt.test.ts new file mode 100644 index 000000000..08ba488f0 --- /dev/null +++ b/src/lib/ai/__tests__/rationale-prompt.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect } from "vitest"; +import { getStrictInsightsSystemPrompt } from "../prompts/insight-generator"; + +/** + * v1.4.16 phase B5c — system prompt advertises the new `rationale` + * field shape and instructs the model to populate it on every rec. + * + * The output-format block in the prompt was previously a 4-field + * schema (id / text / severity / metricSource). With B5c the + * recommendation shape grows a `rationale` object. The model is told + * the exact shape AND that it MUST be populated for every rec — + * legacy "drop the rec if you can't ground it" guidance from B5a + * stays in place; the new ground rule extends "and you cannot ground + * a rec without naming the data window, the comparison baseline, and + * the deviation." + */ + +describe("system prompt — rationale block (B5c)", () => { + it("EN prompt advertises the rationale shape in the output-format block", () => { + const prompt = getStrictInsightsSystemPrompt("en"); + // The shape itself + expect(prompt).toMatch(/rationale/i); + expect(prompt).toMatch(/dataWindow/); + expect(prompt).toMatch(/comparedTo/); + expect(prompt).toMatch(/deviation/); + // The enum is named in the prompt so the model doesn't have to + // guess the value vocabulary + expect(prompt).toMatch(/last7days/); + expect(prompt).toMatch(/last30days/); + expect(prompt).toMatch(/last90days/); + expect(prompt).toMatch(/allTime/); + }); + + it("DE prompt advertises the rationale shape in the output-format block", () => { + const prompt = getStrictInsightsSystemPrompt("de"); + expect(prompt).toMatch(/rationale/i); + expect(prompt).toMatch(/dataWindow/); + expect(prompt).toMatch(/comparedTo/); + expect(prompt).toMatch(/deviation/); + expect(prompt).toMatch(/last7days/); + expect(prompt).toMatch(/allTime/); + }); + + it("EN prompt has a GROUND RULE requiring rationale on every rec", () => { + const prompt = getStrictInsightsSystemPrompt("en"); + // The ground rule names the three required fields together so the + // model can't truncate to one of them + expect(prompt).toMatch(/MUST carry a rationale/); + }); + + it("DE prompt has a GROUNDREGEL requiring rationale on every rec", () => { + const prompt = getStrictInsightsSystemPrompt("de"); + expect(prompt).toMatch(/MUSS .* rationale/i); + }); + + it("EN prompt mentions clear/factual language for rationale", () => { + const prompt = getStrictInsightsSystemPrompt("en"); + // factual / actual data trends — guards against vague placeholder + // rationale text + expect(prompt).toMatch(/factual/i); + }); + + it("DE prompt mentions clear/factual language for rationale", () => { + const prompt = getStrictInsightsSystemPrompt("de"); + // German "sachlich" or "konkret" — same intent + expect(prompt).toMatch(/sachlich|konkret/i); + }); +}); diff --git a/src/lib/ai/prompts/insight-generator.ts b/src/lib/ai/prompts/insight-generator.ts index 180d896d0..9e2403ef4 100644 --- a/src/lib/ai/prompts/insight-generator.ts +++ b/src/lib/ai/prompts/insight-generator.ts @@ -71,6 +71,11 @@ GROUND RULES — ZERO HALLUCINATIONS referencing population norms. "Your avg7 (78) is 5 bpm above your 90-day median (73)" is preferred over "above the population average". +5. Every recommendation MUST carry a rationale object with + dataWindow, comparedTo, and deviation. Use clear, factual + 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. GUIDELINE TARGETS — generic, do NOT compute precise risk scores - Adult resting blood pressure (ESH/ESC 2024 generic): aim < 140/90 @@ -105,6 +110,11 @@ You MUST return JSON matching this schema exactly: "timeRange": "last7days | last30days | last90days | allTime", "summary": "concrete data point that justifies this recommendation", "n": optional integer sample count + }, + "rationale": { + "dataWindow": "last7days | last30days | last90days | allTime", + "comparedTo": "what the deviation is measured against — user baseline (e.g. 'your 90-day median (73 bpm)') OR a guideline ceiling (e.g. 'ESH ceiling 140/90')", + "deviation": "size + direction of the deviation — e.g. '+5 bpm above baseline over 7 of 7 days'" } } ], @@ -176,6 +186,12 @@ GRUNDREGELN — NULL HALLUZINATIONEN gegenüber Bevölkerungswerten. "Dein avg7 (78) liegt 5 bpm über deinem 90-Tage-Median (73)" ist besser als "über dem Bevölkerungsdurchschnitt". +5. Jede Empfehlung MUSS ein rationale-Objekt mit dataWindow, + comparedTo und deviation tragen. Schreibe sachlich und konkret. + Beziehe dich auf die tatsächlichen Datentrends des Nutzers — kein + Platzhaltertext. rationale.dataWindow MUSS gleich + metricSource.timeRange sein, damit die UI das Mini-Chart auf das + gleiche Fenster fixieren kann. LEITLINIEN-ZIELWERTE — generisch, KEINE genauen Risiko-Scores berechnen - Erwachsenen-Ruheblutdruck (ESH/ESC 2024 generisch): Ziel < 140/90 @@ -211,6 +227,11 @@ Du MUSST JSON exakt nach diesem Schema liefern: "timeRange": "last7days | last30days | last90days | allTime", "summary": "konkreter Datenpunkt, der die Empfehlung stützt", "n": optionale Sample-Anzahl + }, + "rationale": { + "dataWindow": "last7days | last30days | last90days | allTime", + "comparedTo": "wogegen die Abweichung verglichen wird — Baseline des Nutzers (z.B. 'dein 90-Tage-Median (73 bpm)') ODER ein Leitlinien-Schwellwert (z.B. 'ESH-Ziel 140/90')", + "deviation": "Größe + Richtung der Abweichung — z.B. '+5 bpm über Baseline an 7 von 7 Tagen'" } } ],