feat(insights): weeklyReport + storyboardAnnotations schema + prompt

Extend aiInsightResponseSchema with weeklyReport (ISO week + summary +
goingWell/worthWatching/tips bullets + optional dataQualityNotes) and
storyboardAnnotations (date + label + category + detail; max 20).

Both blocks are nullable + optional so legacy 4.20.1 caches round-trip.

Bump PROMPT_VERSION 4.20.1 -> 4.20.2 and add GROUND RULE 10 (weekly
report sections match layout, conservative phrasing, no causal claims)
+ GROUND RULE 11 (storyboard annotations are factual user-logged events
with neutral prose) to both EN and DE prompts.

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 17:06:04 +02:00
1 parent bbd00ca848
commit 6d4cba1aad
3 files changed
+600 -3

No files matched your search

@@ -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":/);
}
});
});
+148 -3
View File
@@ -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
+133
View File
@@ -315,6 +315,127 @@ export const trendAnnotationsSchema = z.object({
export type TrendAnnotations = z.infer<typeof trendAnnotationsSchema>;
/**
* 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<typeof weeklyReportSchema>;
/**
* 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<typeof storyboardAnnotationSchema>;
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();