mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 12:22:03 +00:00
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:
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":/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in new issue
Block a user