diff --git a/src/lib/ai/__tests__/weekly-report-schema.test.ts b/src/lib/ai/__tests__/weekly-report-schema.test.ts new file mode 100644 index 000000000..f048d768e --- /dev/null +++ b/src/lib/ai/__tests__/weekly-report-schema.test.ts @@ -0,0 +1,319 @@ +import { describe, it, expect } from "vitest"; +import { + aiInsightResponseSchema, + weeklyReportSchema, + storyboardAnnotationSchema, + storyboardAnnotationsSchema, + type AIInsightResponse, +} from "../schema"; +import { + PROMPT_VERSION, + getStrictInsightsSystemPrompt, +} from "../prompts/insight-generator"; + +/** + * v1.4.20 phase B4 — Weekly Report + Storyboard Annotations schema + + * prompt validation. + * + * Acceptance covered here: + * 1. PROMPT_VERSION matches `^4\.20\.\d+$` and is `4.20.2` (B4 bump). + * 2. weeklyReportSchema enforces section caps (summary 10-800, + * bullet ≤280, ≤5 bullets, dataQualityNotes optional ≤280). + * 3. storyboardAnnotationSchema enforces date / label / category / + * detail; the array is capped at 20. + * 4. aiInsightResponseSchema treats both blocks as optional + nullable + * so legacy 4.20.1 caches round-trip. + * 5. EN + DE prompts both contain GROUND RULE 10 (weeklyReport) + + * GROUND RULE 11 (storyboardAnnotations) with conservative + * phrasing + section-name match. + */ + +const baseResponse: AIInsightResponse = { + summary: "Things are trending well this week.", + recommendations: [], + citations: [], + warnings: [], +}; + +describe("PROMPT_VERSION (B4 bump)", () => { + it("is on the 4.20.x train", () => { + expect(PROMPT_VERSION).toMatch(/^4\.20\.\d+$/); + }); + + it("is bumped past 4.20.1 to signal weeklyReport + storyboardAnnotations", () => { + expect(PROMPT_VERSION).toBe("4.20.2"); + }); +}); + +describe("weeklyReportSchema", () => { + const validReport = { + weekISO: "2026-W19", + summary: + "Strong week — BP held under 130/85 on 9 of 10 readings and weight is down 0.6 kg.", + goingWell: [ + "BP under 130/85 on 9 of 10 readings.", + "Weight down 0.6 kg this week.", + "Compliance 94 % over the past 7 days.", + ], + worthWatching: ["Monday-morning systolic +6 mmHg over 6 weeks."], + tips: ["Consider a brief walk after dinner on Mondays."], + }; + + it("accepts a fully-populated report", () => { + expect(weeklyReportSchema.safeParse(validReport).success).toBe(true); + }); + + it("accepts a report with optional dataQualityNotes", () => { + const result = weeklyReportSchema.safeParse({ + ...validReport, + dataQualityNotes: "Only 5 BP readings this week — n is borderline.", + }); + expect(result.success).toBe(true); + }); + + it("rejects a malformed weekISO", () => { + expect( + weeklyReportSchema.safeParse({ ...validReport, weekISO: "2026-W9" }) + .success, + ).toBe(false); + expect( + weeklyReportSchema.safeParse({ ...validReport, weekISO: "2026/W19" }) + .success, + ).toBe(false); + }); + + it("rejects a summary < 10 chars", () => { + expect( + weeklyReportSchema.safeParse({ ...validReport, summary: "short" }) + .success, + ).toBe(false); + }); + + it("rejects a summary > 800 chars", () => { + const tooLong = "x".repeat(801); + expect( + weeklyReportSchema.safeParse({ ...validReport, summary: tooLong }) + .success, + ).toBe(false); + }); + + it("rejects a goingWell entry > 280 chars", () => { + const tooLong = "x".repeat(281); + expect( + weeklyReportSchema.safeParse({ + ...validReport, + goingWell: [tooLong], + }).success, + ).toBe(false); + }); + + it("rejects more than 5 entries in any bullet list", () => { + const six = ["a", "b", "c", "d", "e", "f"]; + expect( + weeklyReportSchema.safeParse({ ...validReport, goingWell: six }) + .success, + ).toBe(false); + expect( + weeklyReportSchema.safeParse({ ...validReport, worthWatching: six }) + .success, + ).toBe(false); + expect( + weeklyReportSchema.safeParse({ ...validReport, tips: six }).success, + ).toBe(false); + }); + + it("accepts empty bullet arrays (a quiet week is allowed)", () => { + const result = weeklyReportSchema.safeParse({ + ...validReport, + goingWell: [], + worthWatching: [], + tips: [], + }); + expect(result.success).toBe(true); + }); + + it("rejects empty strings in bullet arrays", () => { + expect( + weeklyReportSchema.safeParse({ ...validReport, goingWell: [""] }) + .success, + ).toBe(false); + }); +}); + +describe("storyboardAnnotationSchema", () => { + const valid = { + date: "2026-04-14", + label: "Started Ramipril 5 mg", + category: "medication" as const, + detail: + "Logged a new medication entry on April 14. Average systolic for the prior 7 days was 146 mmHg.", + }; + + it("accepts a fully-formed entry", () => { + expect(storyboardAnnotationSchema.safeParse(valid).success).toBe(true); + }); + + it("rejects a malformed date", () => { + expect( + storyboardAnnotationSchema.safeParse({ ...valid, date: "April 14" }) + .success, + ).toBe(false); + expect( + storyboardAnnotationSchema.safeParse({ ...valid, date: "2026-4-14" }) + .success, + ).toBe(false); + }); + + it("rejects an unknown category", () => { + expect( + storyboardAnnotationSchema.safeParse({ + ...valid, + category: "celebration" as never, + }).success, + ).toBe(false); + }); + + it("rejects a label > 80 chars", () => { + const tooLong = "x".repeat(81); + expect( + storyboardAnnotationSchema.safeParse({ ...valid, label: tooLong }) + .success, + ).toBe(false); + }); + + it("rejects a detail > 400 chars", () => { + const tooLong = "x".repeat(401); + expect( + storyboardAnnotationSchema.safeParse({ ...valid, detail: tooLong }) + .success, + ).toBe(false); + }); + + it("accepts each of the four canonical categories", () => { + for (const category of [ + "medication", + "event", + "milestone", + "warning", + ] as const) { + expect( + storyboardAnnotationSchema.safeParse({ ...valid, category }).success, + ).toBe(true); + } + }); + + it("array form caps at 20 entries", () => { + const entry = valid; + const ok = Array.from({ length: 20 }, () => entry); + const tooMany = Array.from({ length: 21 }, () => entry); + expect(storyboardAnnotationsSchema.safeParse(ok).success).toBe(true); + expect(storyboardAnnotationsSchema.safeParse(tooMany).success).toBe(false); + }); +}); + +describe("aiInsightResponseSchema — weeklyReport + storyboardAnnotations", () => { + it("legacy payload (no weeklyReport / storyboardAnnotations) still parses", () => { + expect(aiInsightResponseSchema.safeParse(baseResponse).success).toBe(true); + }); + + it("payload with weeklyReport=null parses", () => { + const result = aiInsightResponseSchema.safeParse({ + ...baseResponse, + weeklyReport: null, + }); + expect(result.success).toBe(true); + }); + + it("payload with a populated weeklyReport parses round-trip", () => { + const result = aiInsightResponseSchema.safeParse({ + ...baseResponse, + weeklyReport: { + weekISO: "2026-W19", + summary: "Things look good — BP held in target on 9 of 10 readings.", + goingWell: ["Compliance 94 %."], + worthWatching: [], + tips: ["Keep the evening dose at the same time each day."], + }, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.weeklyReport?.weekISO).toBe("2026-W19"); + } + }); + + it("payload with storyboardAnnotations parses round-trip", () => { + const result = aiInsightResponseSchema.safeParse({ + ...baseResponse, + storyboardAnnotations: [ + { + date: "2026-04-14", + label: "Started Ramipril 5 mg", + category: "medication", + detail: "Logged on April 14. 7-day avg systolic prior was 146 mmHg.", + }, + ], + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.storyboardAnnotations?.length).toBe(1); + } + }); + + it("payload with both blocks parses round-trip", () => { + const result = aiInsightResponseSchema.safeParse({ + ...baseResponse, + weeklyReport: { + weekISO: "2026-W19", + summary: "Things look good across the board this week.", + goingWell: [], + worthWatching: [], + tips: [], + }, + storyboardAnnotations: [], + }); + expect(result.success).toBe(true); + }); +}); + +describe("system prompt — GROUND RULE 10 (weeklyReport) + 11 (storyboardAnnotations)", () => { + it("English prompt declares the weeklyReport rule with section-name match", () => { + const prompt = getStrictInsightsSystemPrompt("en"); + expect(prompt).toContain("weeklyReport"); + expect(prompt).toMatch(/weekISO/); + expect(prompt).toContain("goingWell"); + expect(prompt).toContain("worthWatching"); + expect(prompt).toContain("tips"); + expect(prompt).toContain("dataQualityNotes"); + // Conservative phrasing reminder. + expect(prompt.toLowerCase()).toMatch(/no causal claims/); + }); + + it("English prompt declares the storyboardAnnotations rule", () => { + const prompt = getStrictInsightsSystemPrompt("en"); + expect(prompt).toContain("storyboardAnnotations"); + expect(prompt).toMatch(/medication|event|milestone|warning/); + expect(prompt).toMatch(/YYYY-MM-DD/); + // Neutral / factual phrasing reminder. + expect(prompt.toLowerCase()).toMatch(/neutral|factual/); + }); + + it("German prompt declares both blocks", () => { + const prompt = getStrictInsightsSystemPrompt("de"); + expect(prompt).toContain("weeklyReport"); + expect(prompt).toContain("storyboardAnnotations"); + expect(prompt).toMatch(/Kausalbehauptungen/); + expect(prompt).toContain("medication"); + expect(prompt).toContain("milestone"); + }); + + it("both prompts spell the JSON shape with the correct keys", () => { + for (const locale of ["en", "de"] as const) { + const prompt = getStrictInsightsSystemPrompt(locale); + expect(prompt).toMatch(/"weekISO":/); + expect(prompt).toMatch(/"goingWell":/); + expect(prompt).toMatch(/"worthWatching":/); + expect(prompt).toMatch(/"tips":/); + expect(prompt).toMatch(/"category":/); + } + }); +}); diff --git a/src/lib/ai/prompts/insight-generator.ts b/src/lib/ai/prompts/insight-generator.ts index 79a7381a9..d2d660375 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.20.1" as const; +export const PROMPT_VERSION = "4.20.2" as const; const SYSTEM_PROMPT_EN = `You are a clinical-context summariser for a personal health-log app. Prompt version: ${PROMPT_VERSION}. @@ -128,6 +128,52 @@ GROUND RULES — ZERO HALLUCINATIONS the entire block (or set null) when no metric qualifies. Trend annotations are SHORT — they sit below charts and compete for attention with the chart itself; one tight sentence is the goal. +10. v1.4.20 phase B4 — Optional "weeklyReport" block. When the snapshot + covers a full ISO week (sufficient signal across BP / weight / + mood / compliance), emit a top-level "weeklyReport" object with: + - weekISO: ISO week identifier in "YYYY-Www" format (e.g. + "2026-W19"). MUST match the week the snapshot covers. + - summary: 1-2 sentence TL;DR (10-800 chars). Conservative + phrasing, no causal claims, no diagnosis. + - goingWell: 0-5 short bullets (≤ 280 chars each) — what's + going well this week. Each bullet derives from a number in + the snapshot. + - worthWatching: 0-5 short bullets (≤ 280 chars each) — what + deserves attention. Observational ("Monday-morning systolic + +6 mmHg"), not causal ("Monday-morning systolic is caused by + short Sunday sleep"). + - tips: 0-5 short bullets (≤ 280 chars each) — small actionable + nudges. Generic ("consider a brief walk after dinner"); + clinical guidance belongs with the user's doctor. + - dataQualityNotes (optional, ≤ 280 chars): only when the + snapshot has gaps that materially limit the analysis (n<7 + readings in the analysed window, recencyDays>14, coverage + gaps that bias comparison). Omit when data is fine. + Section names match the report layout exactly. Use conservative + phrasing throughout — NEVER make causal claims ("X is causing Y") + and NEVER prescribe ("you should start taking X"). When the + snapshot does not cover a full week or has nothing analysable, + omit "weeklyReport" or set it to null. +11. v1.4.20 phase B4 — Optional "storyboardAnnotations" array (max 20). + When the 90-day BP timeline includes notable factual events the + user logged, emit annotations that pin to specific dates. Each + annotation MUST: + - cite a real event from the snapshot (a logged measurement + outlier, a medication start/dose change, a streak milestone, + a target hit). Do NOT invent events. + - use neutral, factual prose — "started Ramipril 5 mg" not + "improvement is due to Ramipril". The user reads the timeline + and forms their own causal hypothesis; you do not. + - assign a category that reflects the event: + * "medication" — started, stopped, or dose-changed a med + * "event" — a notable measurement / outlier / streak + * "milestone" — target hit, threshold crossed + * "warning" — a deviation worth flagging conservatively + - carry a one-paragraph "detail" (≤ 400 chars) the chapter card + renders below the timeline. + Date format: "YYYY-MM-DD". Label cap: 80 chars. Detail cap: + 400 chars. Hard array cap: 20. Omit the field entirely when the + timeline has no notable events. GUIDELINE TARGETS — generic, do NOT compute precise risk scores - Adult resting blood pressure (ESH/ESC 2024 generic): aim < 140/90 @@ -201,7 +247,23 @@ You MUST return JSON matching this schema exactly: "bp": "one sentence, ≤200 chars, observational", "weight": "one sentence, ≤200 chars, observational", "mood": "one sentence, ≤200 chars, observational" - } + }, + "weeklyReport": { + "weekISO": "YYYY-Www (e.g. 2026-W19)", + "summary": "1-2 sentence TL;DR (10-800 chars), conservative phrasing", + "goingWell": ["≤280 char bullet", "..."], + "worthWatching": ["≤280 char bullet", "..."], + "tips": ["≤280 char bullet", "..."], + "dataQualityNotes": "≤280 chars, ONLY when data quality limits analysis" + }, + "storyboardAnnotations": [ + { + "date": "YYYY-MM-DD", + "label": "≤80 char neutral label (e.g. 'Started Ramipril 5 mg')", + "category": "medication | event | milestone | warning", + "detail": "≤400 char neutral detail paragraph" + } + ] } Every recommendation's metricSource (type + timeRange) MUST appear in @@ -216,6 +278,14 @@ The trendAnnotations block is optional. Each metric (bp, weight, mood) is independently optional — emit only the metrics with usable signal. Each annotation is ONE sentence, observational, ≤ 200 chars. +The weeklyReport block is optional. Omit it (or set to null) when the +snapshot does not cover a full ISO week. Section names MUST match +the layout exactly. Phrasing stays conservative — no causal claims. + +The storyboardAnnotations array is optional. Omit when the 90-day +timeline has no notable factual events. Each entry pins to a real, +user-logged event with a neutral label + detail. Hard cap 20 entries. + LANGUAGE Respond in English. Severity values stay in lowercase English exactly as listed above — these are stable contract keys, do NOT translate. @@ -330,6 +400,55 @@ GRUNDREGELN — NULL HALLUZINATIONEN Metrik qualifiziert. Trend-Annotationen sind KURZ — sie stehen unter Charts und konkurrieren mit dem Chart selbst um Aufmerksamkeit; ein prägnanter Satz ist das Ziel. +10. v1.4.20 phase B4 — Optionaler "weeklyReport"-Block. Wenn der + Snapshot eine vollständige ISO-Woche abdeckt (genügend Signal + über BP / Gewicht / Stimmung / Compliance), emittiere ein Top- + Level-Objekt "weeklyReport" mit: + - weekISO: ISO-Wochen-ID im Format "YYYY-Www" (z.B. "2026-W19"). + MUSS der Woche entsprechen, die der Snapshot abdeckt. + - summary: 1-2 Sätze TL;DR (10-800 Zeichen). Sachliche Sprache, + keine Kausalbehauptungen, keine Diagnose. + - goingWell: 0-5 kurze Stichpunkte (≤ 280 Zeichen) — was + diese Woche gut läuft. Jeder Stichpunkt stützt sich auf + eine Zahl im Snapshot. + - worthWatching: 0-5 kurze Stichpunkte (≤ 280 Zeichen) — was + Beachtung verdient. Beobachtend ("Montag-morgen-Systole + +6 mmHg"), nicht kausal ("Montag-morgen-Systole verursacht + durch kurzen Sonntagsschlaf"). + - tips: 0-5 kurze Stichpunkte (≤ 280 Zeichen) — kleine + umsetzbare Anregungen. Generisch ("kleiner Spaziergang nach + dem Essen erwägen"); klinische Beratung gehört zum Arzt. + - dataQualityNotes (optional, ≤ 280 Zeichen): nur wenn der + Snapshot Lücken hat, die die Analyse substanziell + einschränken (n<7, recencyDays>14, Coverage-Lücken). Bei + ausreichender Datenlage weglassen. + Sektionsnamen entsprechen exakt dem Report-Layout. Sprache + durchgehend sachlich — KEINE Kausalbehauptungen ("X verursacht + Y") und KEINE Verschreibungen ("du solltest X einnehmen"). Wenn + der Snapshot keine vollständige Woche abdeckt oder nichts + Analysierbares enthält, lass "weeklyReport" weg oder setze null. +11. v1.4.20 phase B4 — Optionales "storyboardAnnotations"-Array + (max 20). Wenn die 90-Tage-BP-Timeline bemerkenswerte vom Nutzer + geloggte Ereignisse enthält, emittiere Annotationen, die auf + konkrete Daten zeigen. Jede Annotation MUSS: + - sich auf ein reales Ereignis im Snapshot beziehen (ein + geloggter Messwert-Outlier, ein Medikamentenstart/-wechsel, + ein Streak-Meilenstein, ein erreichter Zielwert). Erfinde + KEINE Ereignisse. + - sachlich-neutral formulieren — "Ramipril 5 mg gestartet" + statt "Verbesserung ist auf Ramipril zurückzuführen". Der + Nutzer liest die Timeline und bildet seine eigene kausale + Hypothese; du nicht. + - eine category zuweisen, die das Ereignis spiegelt: + * "medication" — Medikament gestartet/gestoppt/Dosis geändert + * "event" — bemerkenswerte Messung/Outlier/Streak + * "milestone" — Zielwert erreicht, Schwelle überschritten + * "warning" — Abweichung, die sachlich zu flaggen ist + - ein einzelnes "detail"-Paragraph (≤ 400 Zeichen) tragen, das + die Chapter-Karte unter der Timeline rendert. + Datumsformat: "YYYY-MM-DD". Label-Cap: 80 Zeichen. Detail-Cap: + 400 Zeichen. Array-Höchstgrenze: 20. Lass das Feld komplett + weg, wenn die Timeline keine bemerkenswerten Ereignisse hat. LEITLINIEN-ZIELWERTE — generisch, KEINE genauen Risiko-Scores berechnen - Erwachsenen-Ruheblutdruck (ESH/ESC 2024 generisch): Ziel < 140/90 @@ -404,7 +523,23 @@ Du MUSST JSON exakt nach diesem Schema liefern: "bp": "ein Satz, ≤ 200 Zeichen, beobachtend", "weight": "ein Satz, ≤ 200 Zeichen, beobachtend", "mood": "ein Satz, ≤ 200 Zeichen, beobachtend" - } + }, + "weeklyReport": { + "weekISO": "YYYY-Www (z.B. 2026-W19)", + "summary": "1-2 Sätze TL;DR (10-800 Zeichen), sachliche Sprache", + "goingWell": ["≤280 Zeichen Stichpunkt", "..."], + "worthWatching": ["≤280 Zeichen Stichpunkt", "..."], + "tips": ["≤280 Zeichen Stichpunkt", "..."], + "dataQualityNotes": "≤280 Zeichen, NUR wenn Datenqualität die Analyse einschränkt" + }, + "storyboardAnnotations": [ + { + "date": "YYYY-MM-DD", + "label": "≤80 Zeichen sachliches Label (z.B. 'Ramipril 5 mg gestartet')", + "category": "medication | event | milestone | warning", + "detail": "≤400 Zeichen sachlicher Detail-Paragraph" + } + ] } Jede metricSource (type + timeRange) einer Empfehlung MUSS in @@ -420,6 +555,16 @@ Der trendAnnotations-Block ist optional. Jede Metrik (bp, weight, mood) ist unabhängig optional — emittiere nur Metriken mit nutzbarem Signal. Jede Annotation ist EIN Satz, beobachtend, ≤ 200 Zeichen. +Der weeklyReport-Block ist optional. Lass ihn weg (oder setze null), +wenn der Snapshot keine vollständige ISO-Woche abdeckt. Sektionsnamen +MÜSSEN exakt dem Layout entsprechen. Sprache bleibt sachlich — keine +Kausalbehauptungen. + +Das storyboardAnnotations-Array ist optional. Lass es weg, wenn die +90-Tage-Timeline keine bemerkenswerten Ereignisse enthält. Jeder +Eintrag verweist auf ein reales, vom Nutzer geloggtes Ereignis mit +neutralem Label + Detail. Höchstgrenze: 20 Einträge. + SPRACHE Antworte auf Deutsch. Severity-Werte bleiben exakt in englischer Kleinschreibung wie oben gelistet — das sind stabile Vertragsschlüssel diff --git a/src/lib/ai/schema.ts b/src/lib/ai/schema.ts index 1fe96c796..6d1b342af 100644 --- a/src/lib/ai/schema.ts +++ b/src/lib/ai/schema.ts @@ -315,6 +315,127 @@ export const trendAnnotationsSchema = z.object({ export type TrendAnnotations = z.infer; +/** + * v1.4.20 phase B4 — Weekly Report block. + * + * The newsletter-style printable report at `/insights/report/[week]` + * renders six sections off this payload: Summary, What's going well, + * What's worth watching, Tips, Data-quality notes (optional), plus the + * `weekISO` label that pins the report to a specific ISO week. + * + * Hard caps are deliberate so a runaway model cannot pad the surface: + * - summary: 10..800 chars (TL;DR paragraph) + * - goingWell / worthWatching / tips: each item ≤ 280 chars, ≤ 5 items + * - dataQualityNotes: ≤ 280 chars, optional + * + * The block is `nullable().optional()` so cached payloads from before + * PROMPT_VERSION 4.20.2 round-trip without forcing a regenerate. Fresh + * generations after the bump emit the block when the snapshot covers + * a full ISO week. + */ +export const weeklyReportSchema = z.object({ + /** + * ISO week identifier, e.g. "2026-W19". Format: `YYYY-Www` where ww is + * the two-digit ISO week number. The route's `[week]` param pins to + * the same key so the report URL matches the AI payload. + */ + weekISO: z + .string() + .regex(/^\d{4}-W\d{2}$/, "weeklyReport.weekISO must match YYYY-Www"), + /** TL;DR paragraph — 1-2 sentences synthesising the week's signal. */ + summary: z + .string() + .min(10, "weeklyReport.summary must be at least 10 chars") + .max(800, "weeklyReport.summary must be <= 800 chars"), + /** "Wins" — what's going well. Up to 5 short bullets. */ + goingWell: z + .array( + z + .string() + .min(1, "weeklyReport.goingWell entry required") + .max(280, "weeklyReport.goingWell entry must be <= 280 chars"), + ) + .max(5, "weeklyReport.goingWell can hold at most 5 entries"), + /** "Watchlist" — what's worth watching. Up to 5 short bullets. */ + worthWatching: z + .array( + z + .string() + .min(1, "weeklyReport.worthWatching entry required") + .max(280, "weeklyReport.worthWatching entry must be <= 280 chars"), + ) + .max(5, "weeklyReport.worthWatching can hold at most 5 entries"), + /** Tips — small actionable nudges. Up to 5 short bullets. */ + tips: z + .array( + z + .string() + .min(1, "weeklyReport.tips entry required") + .max(280, "weeklyReport.tips entry must be <= 280 chars"), + ) + .max(5, "weeklyReport.tips can hold at most 5 entries"), + /** + * Data-quality notes — surfaced only when the snapshot has gaps that + * materially limit the analysis (n<7, recencyDays>14, etc.). Optional + * because most weeks don't need a caveat. + */ + dataQualityNotes: z + .string() + .max(280, "weeklyReport.dataQualityNotes must be <= 280 chars") + .optional(), +}); + +export type WeeklyReport = z.infer; + +/** + * v1.4.20 phase B4 — Storyboard annotations. + * + * The 90-day BP timeline on `/insights` overlays factual events the user + * logged ("started medication X", "first sustained dip below 140") onto + * the chart as vertical reference lines + chapter cards beneath. Each + * annotation MUST cite a real event from the snapshot — the prose is + * neutral, never causal ("started medication X" not "improvement is due + * to X"). + * + * Hard cap of 20 entries so a runaway model cannot blanket the chart + * with reference lines. The block itself is `.optional()` (no nullable + * wrap because zod treats an absent field as undefined for arrays + * uniformly). + */ +export const storyboardAnnotationSchema = z.object({ + /** ISO date the annotation pins to (YYYY-MM-DD). */ + date: z + .string() + .regex( + /^\d{4}-\d{2}-\d{2}$/, + "storyboardAnnotation.date must be YYYY-MM-DD", + ), + /** Short label rendered next to the reference line (≤ 80 chars). */ + label: z + .string() + .min(1, "storyboardAnnotation.label required") + .max(80, "storyboardAnnotation.label must be <= 80 chars"), + /** + * Category drives the annotation colour: + * - "medication" — Dracula pink (started/changed dose) + * - "event" — Dracula cyan (a notable user-logged event) + * - "milestone" — Dracula green (target hit, streak milestone) + * - "warning" — Dracula orange (a deviation worth flagging) + */ + category: z.enum(["medication", "event", "milestone", "warning"]), + /** One-paragraph detail that the chapter card surfaces below the chart. */ + detail: z + .string() + .min(1, "storyboardAnnotation.detail required") + .max(400, "storyboardAnnotation.detail must be <= 400 chars"), +}); + +export type StoryboardAnnotation = z.infer; + +export const storyboardAnnotationsSchema = z + .array(storyboardAnnotationSchema) + .max(20, "storyboardAnnotations can hold at most 20 entries"); + /** * Canonical strict response schema. New strict fields are required; * legacy rich fields ride along through `.passthrough()` for back- @@ -342,6 +463,18 @@ export const aiInsightResponseSchema = z * legacy 4.20.0 caches round-trip. */ trendAnnotations: trendAnnotationsSchema.nullable().optional(), + /** + * v1.4.20 phase B4 — optional Weekly Report block. Nullable + + * optional so legacy cached payloads from before PROMPT_VERSION + * 4.20.2 round-trip without forcing a regenerate. + */ + weeklyReport: weeklyReportSchema.nullable().optional(), + /** + * v1.4.20 phase B4 — optional storyboard annotations. Each entry + * pins a vertical reference line + chapter card to a date on the + * 90-day BP timeline. Optional so legacy caches round-trip. + */ + storyboardAnnotations: storyboardAnnotationsSchema.optional(), }) .passthrough();