diff --git a/src/lib/ai/__tests__/daily-briefing-schema.test.ts b/src/lib/ai/__tests__/daily-briefing-schema.test.ts new file mode 100644 index 000000000..67548e1db --- /dev/null +++ b/src/lib/ai/__tests__/daily-briefing-schema.test.ts @@ -0,0 +1,277 @@ +import { describe, it, expect } from "vitest"; +import { + aiInsightResponseSchema, + dailyBriefingKeyFindingSchema, + dailyBriefingSchema, + type AIInsightResponse, +} from "../schema"; +import { PROMPT_VERSION } from "../prompts/insight-generator"; + +/** + * v1.4.20 phase B1 — Daily Briefing schema validation. + * + * The Insights redesign hero strip + briefing card render a narrative + * paragraph + 0-5 key findings. The schema is `nullable().optional()` + * on the response so legacy v1.4.19 cached payloads round-trip without + * forcing a regenerate. Fresh generations after PROMPT_VERSION 4.20.0 + * emit the block. + * + * Acceptance covered here: + * 1. Legacy payload (no `dailyBriefing` field at all) parses. + * 2. Well-formed Daily Briefing parses round-trip. + * 3. > 5 findings is rejected (hard cap so the model can't pad). + * 4. Empty paragraph is rejected (briefing card never paints void). + * 5. Each key finding requires headline + detail (no placeholders). + * 6. PROMPT_VERSION matches `^4\.20\.\d+$`. + */ + +const baseFinding = { + tone: "good" as const, + headline: "Blood pressure entered target", + detail: "9 of last 10 readings under 130/85.", + delta: "↓ 4 mmHg", + sourceWindow: "30d" as const, + sourceMetric: "bp" as const, +}; + +const baseBriefing = { + paragraph: + "You're trending well this week — blood pressure is settling into your target band " + + "for the first time since February. Three things stand out: medication compliance " + + "is a 21-day streak, weight is down 2.5 kg over 30 days, and the mood-vs-pulse " + + "correlation that worried you in March has weakened. One thing to watch: morning " + + "systolic is still 6 mmHg above target on Mondays.", + keyFindings: [ + baseFinding, + { + tone: "watch" as const, + headline: "Monday-morning systolic spike", + detail: "+6 mmHg vs other weekdays. Often follows late Sunday sleep.", + delta: "+6 mmHg", + sourceWindow: "30d" as const, + sourceMetric: "bp" as const, + }, + { + tone: "info" as const, + headline: "Weight down 30 d", + detail: "Linear, sustainable rate. BMI is now 26.0.", + delta: "−2.5 kg", + sourceWindow: "30d" as const, + sourceMetric: "weight" as const, + }, + ], +}; + +const baseResponse: AIInsightResponse = { + summary: "Things are trending well this week.", + recommendations: [], + citations: [], + warnings: [], +}; + +describe("PROMPT_VERSION", () => { + it("matches the v1.4.20 series 4.20.x", () => { + expect(PROMPT_VERSION).toMatch(/^4\.20\.\d+$/); + }); +}); + +describe("dailyBriefingKeyFindingSchema", () => { + it("accepts a well-formed finding", () => { + expect(dailyBriefingKeyFindingSchema.safeParse(baseFinding).success).toBe( + true, + ); + }); + + it.each(["good", "watch", "info"] as const)( + "accepts tone=%s", + (tone) => { + const result = dailyBriefingKeyFindingSchema.safeParse({ + ...baseFinding, + tone, + }); + expect(result.success).toBe(true); + }, + ); + + it("rejects an unknown tone", () => { + const result = dailyBriefingKeyFindingSchema.safeParse({ + ...baseFinding, + tone: "danger", + }); + expect(result.success).toBe(false); + }); + + it.each(["7d", "30d", "90d", "1y"] as const)( + "accepts sourceWindow=%s", + (sourceWindow) => { + const result = dailyBriefingKeyFindingSchema.safeParse({ + ...baseFinding, + sourceWindow, + }); + expect(result.success).toBe(true); + }, + ); + + it("defaults sourceWindow to 30d when omitted", () => { + const { sourceWindow: _omit, ...withoutWindow } = baseFinding; + void _omit; + const result = dailyBriefingKeyFindingSchema.safeParse(withoutWindow); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.sourceWindow).toBe("30d"); + } + }); + + it.each(["bp", "weight", "pulse", "mood", "compliance"] as const)( + "accepts sourceMetric=%s", + (sourceMetric) => { + const result = dailyBriefingKeyFindingSchema.safeParse({ + ...baseFinding, + sourceMetric, + }); + expect(result.success).toBe(true); + }, + ); + + it("rejects an unknown sourceMetric", () => { + const result = dailyBriefingKeyFindingSchema.safeParse({ + ...baseFinding, + sourceMetric: "hrv", + }); + expect(result.success).toBe(false); + }); + + it("accepts a null delta", () => { + const result = dailyBriefingKeyFindingSchema.safeParse({ + ...baseFinding, + delta: null, + }); + expect(result.success).toBe(true); + }); + + it("rejects empty headline", () => { + const result = dailyBriefingKeyFindingSchema.safeParse({ + ...baseFinding, + headline: "", + }); + expect(result.success).toBe(false); + }); + + it("rejects empty detail", () => { + const result = dailyBriefingKeyFindingSchema.safeParse({ + ...baseFinding, + detail: "", + }); + expect(result.success).toBe(false); + }); +}); + +describe("dailyBriefingSchema", () => { + it("accepts a well-formed briefing", () => { + expect(dailyBriefingSchema.safeParse(baseBriefing).success).toBe(true); + }); + + it("rejects empty paragraph", () => { + const result = dailyBriefingSchema.safeParse({ + ...baseBriefing, + paragraph: "", + }); + expect(result.success).toBe(false); + }); + + it("accepts an empty keyFindings array", () => { + const result = dailyBriefingSchema.safeParse({ + ...baseBriefing, + keyFindings: [], + }); + expect(result.success).toBe(true); + }); + + it("rejects > 5 keyFindings (hard cap)", () => { + const result = dailyBriefingSchema.safeParse({ + ...baseBriefing, + keyFindings: [ + baseFinding, + baseFinding, + baseFinding, + baseFinding, + baseFinding, + baseFinding, + ], + }); + expect(result.success).toBe(false); + }); + + it("accepts exactly 5 keyFindings (boundary)", () => { + const result = dailyBriefingSchema.safeParse({ + ...baseBriefing, + keyFindings: [ + baseFinding, + baseFinding, + baseFinding, + baseFinding, + baseFinding, + ], + }); + expect(result.success).toBe(true); + }); +}); + +describe("aiInsightResponseSchema — dailyBriefing integration", () => { + it("legacy payload (no dailyBriefing field) still parses", () => { + expect(aiInsightResponseSchema.safeParse(baseResponse).success).toBe(true); + }); + + it("payload with dailyBriefing=null still parses", () => { + const result = aiInsightResponseSchema.safeParse({ + ...baseResponse, + dailyBriefing: null, + }); + expect(result.success).toBe(true); + }); + + it("payload with a well-formed dailyBriefing parses round-trip", () => { + const result = aiInsightResponseSchema.safeParse({ + ...baseResponse, + dailyBriefing: baseBriefing, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.dailyBriefing?.paragraph).toBe(baseBriefing.paragraph); + expect(result.data.dailyBriefing?.keyFindings).toHaveLength(3); + } + }); + + it("payload with > 5 keyFindings is rejected at the response level too", () => { + const result = aiInsightResponseSchema.safeParse({ + ...baseResponse, + dailyBriefing: { + ...baseBriefing, + keyFindings: Array(6).fill(baseFinding), + }, + }); + expect(result.success).toBe(false); + }); + + it("payload with empty paragraph is rejected at the response level too", () => { + const result = aiInsightResponseSchema.safeParse({ + ...baseResponse, + dailyBriefing: { + paragraph: "", + keyFindings: [], + }, + }); + expect(result.success).toBe(false); + }); + + it("payload with a finding missing required fields is rejected", () => { + const result = aiInsightResponseSchema.safeParse({ + ...baseResponse, + dailyBriefing: { + paragraph: baseBriefing.paragraph, + keyFindings: [{ tone: "good", headline: "", detail: "x" }], + }, + }); + expect(result.success).toBe(false); + }); +}); diff --git a/src/lib/ai/prompts/insight-generator.ts b/src/lib/ai/prompts/insight-generator.ts index a4943be14..fb1f7feb8 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.19.0" as const; +export const PROMPT_VERSION = "4.20.0" as const; const SYSTEM_PROMPT_EN = `You are a clinical-context summariser for a personal health-log app. Prompt version: ${PROMPT_VERSION}. @@ -93,6 +93,26 @@ GROUND RULES — ZERO HALLUCINATIONS patterns include "Your data foundation is strong", "Datengrundlage ist sehr stark", "You have a solid baseline", "Great dataset" and any rephrasing of the same sentiment. +8. v1.4.20 — Optional "dailyBriefing" block. When the snapshot + carries enough signal (any of bp / weight / pulse / mood / + medications.compliance) emit a top-level "dailyBriefing" object + with two fields: + - paragraph: an 80-200 word narrative the user reads at the top + of /insights. Conservative phrasing, no diagnosis, no + prescription. Use the user's own data — do NOT extrapolate + to "people like you" or population norms. Avoid the banned + openers from rule 7. + - keyFindings: 0-5 short rows. Each row has tone (one of + "good" | "watch" | "info"), a headline (≤ 60 chars), a + one-sentence detail, an optional delta string (e.g. + "↓ 4 mmHg" or null), a sourceWindow (one of "7d" | "30d" | + "90d" | "1y"; default "30d") and a sourceMetric (one of + "bp" | "weight" | "pulse" | "mood" | "compliance"). Findings + MUST be derived from numbers in the snapshot. Five is the + hard cap — three is a healthier default. + When the snapshot has no analysable data, omit "dailyBriefing" + or set it to null. Empty paragraph or filler-only findings are + rejected by the parser. GUIDELINE TARGETS — generic, do NOT compute precise risk scores - Adult resting blood pressure (ESH/ESC 2024 generic): aim < 140/90 @@ -148,16 +168,35 @@ You MUST return JSON matching this schema exactly: "message": "what is flagged and why", "severity": "info" | "suggestion" | "important" | "urgent" (optional) } - ] + ], + "dailyBriefing": { + "paragraph": "80-200 word narrative grounded in this snapshot's numbers", + "keyFindings": [ + { + "tone": "good | watch | info", + "headline": "≤60 char headline", + "detail": "one-sentence detail", + "delta": "optional delta string (e.g. '↓ 4 mmHg') or null", + "sourceWindow": "7d | 30d | 90d | 1y", + "sourceMetric": "bp | weight | pulse | mood | compliance" + } + ] + } } Every recommendation's metricSource (type + timeRange) MUST appear in citations[]. If two recommendations cite the same data point, list the citation once. +The dailyBriefing block is optional. Omit it (or set to null) when the +snapshot has nothing analysable. When present, paragraph MUST be +non-empty and keyFindings MUST contain at most five entries. + LANGUAGE Respond in English. Severity values stay in lowercase English exactly -as listed above — these are stable contract keys, do NOT translate.`; +as listed above — these are stable contract keys, do NOT translate. +The dailyBriefing.tone, sourceWindow and sourceMetric values stay in +lowercase English exactly as listed — also stable contract keys.`; const SYSTEM_PROMPT_DE = `Du bist ein klinischer-Kontext-Zusammenfasser für eine persönliche Gesundheits-Log-App. @@ -227,6 +266,28 @@ GRUNDREGELN — NULL HALLUZINATIONEN Verbotene Eröffnungsmuster sind unter anderem "Datengrundlage ist sehr stark", "Your data foundation is strong", "Du hast eine solide Baseline", "Großartiger Datensatz" und jede sinngemäße Umformulierung. +8. v1.4.20 — Optionaler "dailyBriefing"-Block. Wenn der Snapshot + genügend Signal trägt (irgendwas aus bp / weight / pulse / mood / + medications.compliance), emittiere ein Top-Level-Objekt + "dailyBriefing" mit zwei Feldern: + - paragraph: ein 80-200 Wörter langer Fließtext, den der Nutzer + oben auf /insights liest. Sachliche Sprache, keine Diagnose, + keine Verschreibung. Nutze die eigenen Daten des Nutzers — + extrapoliere NICHT auf "Menschen wie Sie" oder + Bevölkerungsnormen. Verwende keine in Regel 7 verbotenen + Eröffnungen. + - keyFindings: 0-5 kurze Zeilen. Jede Zeile hat tone (eines aus + "good" | "watch" | "info"), eine headline (≤ 60 Zeichen), + ein detail im Einzelsatz, einen optionalen delta-String + (z.B. "↓ 4 mmHg" oder null), ein sourceWindow (eines aus + "7d" | "30d" | "90d" | "1y"; Standard "30d") und ein + sourceMetric (eines aus "bp" | "weight" | "pulse" | "mood" | + "compliance"). Findings MÜSSEN aus Zahlen im Snapshot + abgeleitet sein. Fünf ist die harte Obergrenze — drei ist + der gesündere Standardwert. + Hat der Snapshot keine analysierbaren Daten, lass "dailyBriefing" + weg oder setze es auf null. Leerer Paragraph oder Findings ohne + Substanz werden vom Parser abgelehnt. LEITLINIEN-ZIELWERTE — generisch, KEINE genauen Risiko-Scores berechnen - Erwachsenen-Ruheblutdruck (ESH/ESC 2024 generisch): Ziel < 140/90 @@ -283,17 +344,37 @@ Du MUSST JSON exakt nach diesem Schema liefern: "message": "was wird geflaggt und warum", "severity": "info" | "suggestion" | "important" | "urgent" (optional) } - ] + ], + "dailyBriefing": { + "paragraph": "80-200 Wörter Fließtext, geerdet in den Zahlen dieses Snapshots", + "keyFindings": [ + { + "tone": "good | watch | info", + "headline": "≤60 Zeichen Headline", + "detail": "ein Satz Detail", + "delta": "optionaler Delta-String (z.B. '↓ 4 mmHg') oder null", + "sourceWindow": "7d | 30d | 90d | 1y", + "sourceMetric": "bp | weight | pulse | mood | compliance" + } + ] + } } Jede metricSource (type + timeRange) einer Empfehlung MUSS in citations[] auftauchen. Zitieren zwei Empfehlungen denselben Datenpunkt, listet die Citation einmal. +Der dailyBriefing-Block ist optional. Lass ihn weg (oder setze null), +wenn der Snapshot nichts Analysierbares enthält. Wenn vorhanden, MUSS +paragraph nicht leer sein und keyFindings höchstens fünf Einträge +enthalten. + SPRACHE Antworte auf Deutsch. Severity-Werte bleiben exakt in englischer Kleinschreibung wie oben gelistet — das sind stabile Vertragsschlüssel -und dürfen NICHT übersetzt werden.`; +und dürfen NICHT übersetzt werden. Auch dailyBriefing.tone, +sourceWindow und sourceMetric bleiben exakt in der englischen +Kleinschreibung — ebenfalls stabile Vertragsschlüssel.`; /** * Returns the active scope-hardened system prompt for a given locale. diff --git a/src/lib/ai/schema.ts b/src/lib/ai/schema.ts index 452aae2dd..c114ee5c0 100644 --- a/src/lib/ai/schema.ts +++ b/src/lib/ai/schema.ts @@ -215,6 +215,71 @@ export const aiWarningSchema = z.object({ export type AIWarning = z.infer; +/** + * v1.4.20 phase B1 — Daily Briefing block. + * + * The Insights redesign hero strip + full-width briefing card render a + * narrative paragraph synthesised from the day's data plus up to five + * "key findings" — one-liners that summarise a single trend with a + * tone (good / watch / info), an optional delta string ("↓ 4 mmHg"), + * and the metric + window the finding was drawn from. + * + * The block is `nullable().optional()` so cached payloads from + * v1.4.19 (which predate the field) still parse cleanly. Fresh + * generations after the v1.4.20 PROMPT_VERSION bump emit the block. + * + * Marc's "zero hallucinations" mandate stays: the prompt instructs the + * model to derive every finding from a number visible in the snapshot, + * and `keyFindings.length` is hard-capped at 5 so a runaway model + * cannot pad the surface with filler. + */ +export const dailyBriefingKeyFindingSchema = z.object({ + /** + * Tone drives the left-bar colour on the finding row: + * - "good" — Dracula green (target hit, streak, etc.) + * - "watch" — Dracula orange (deviation worth noting) + * - "info" — Dracula cyan (neutral observation) + * Severity stays separate from the recommendation severity ladder + * so the briefing surface can be lighter-weight than the advisor. + */ + tone: z.enum(["good", "watch", "info"]), + /** Headline — the one-liner the user reads first. */ + headline: z.string().min(1, "keyFinding.headline required"), + /** One-sentence detail expanding on the headline. */ + detail: z.string().min(1, "keyFinding.detail required"), + /** + * Optional delta string — e.g. "↓ 4 mmHg" or "+6 bpm". Null when the + * finding does not naturally carry a single delta (e.g. compliance + * streak: the "delta" is the streak length itself, captured in the + * detail). + */ + delta: z.string().nullable(), + /** Window the finding was derived from — same enum as elsewhere. */ + sourceWindow: z.enum(["7d", "30d", "90d", "1y"]).default("30d"), + /** Metric the finding was drawn from. */ + sourceMetric: z.enum(["bp", "weight", "pulse", "mood", "compliance"]), +}); + +export type DailyBriefingKeyFinding = z.infer< + typeof dailyBriefingKeyFindingSchema +>; + +export const dailyBriefingSchema = z.object({ + /** + * Narrative paragraph — ~80-200 words. Synthesised from the day's + * data, conservative phrasing, no medical advice claims. Empty + * strings are rejected so the briefing card never paints a void. + */ + paragraph: z.string().min(1, "dailyBriefing.paragraph required"), + /** + * 0-5 key findings. Empty array is acceptable when the data is + * truly flat; the hero strip simply hides the row in that case. + */ + keyFindings: z.array(dailyBriefingKeyFindingSchema).min(0).max(5), +}); + +export type DailyBriefing = z.infer; + /** * Canonical strict response schema. New strict fields are required; * legacy rich fields ride along through `.passthrough()` for back- @@ -230,6 +295,12 @@ export const aiInsightResponseSchema = z citations: z.array(aiCitationSchema), /** Guideline-flagged values (e.g. BP > 140/90). May be empty. */ warnings: z.array(aiWarningSchema), + /** + * v1.4.20 phase B1 — optional Daily Briefing payload. Nullable + + * optional so legacy cached payloads (from before PROMPT_VERSION + * 4.20.0) round-trip without forcing a regenerate. + */ + dailyBriefing: dailyBriefingSchema.nullable().optional(), }) .passthrough();