feat(insights): add Daily Briefing schema + prompt v4.20.0

Extends `aiInsightResponseSchema` with an optional `dailyBriefing`
block (paragraph + 0-5 keyFindings, each with tone / headline / detail
/ delta / sourceWindow / sourceMetric). Nullable + optional so v1.4.19
cached payloads still parse cleanly.

Bumps PROMPT_VERSION 4.19.0 → 4.20.0 and adds GROUND RULE 8 in both
EN and DE system prompts, instructing the model to emit a briefing
block grounded in the user's own snapshot numbers (no extrapolation,
no medical advice claims, hard-cap at 5 findings).

TDD: new daily-briefing-schema.test.ts covers legacy back-compat,
well-formed parsing, > 5 findings rejection, empty-paragraph
rejection, finding-field validation, and the PROMPT_VERSION 4.20.x
match. Existing medical-reference-prompt.test.ts assertion stays
broad enough to ride the bump.

+31 tests (1672 → 1703); typecheck clean.

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 15:35:26 +02:00
1 parent 81a40f7955
commit 9873363fa3
3 files changed
+434 -5

No files matched your search

@@ -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);
});
});
+86 -5
View File
@@ -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.
+71
View File
@@ -215,6 +215,71 @@ export const aiWarningSchema = z.object({
export type AIWarning = z.infer<typeof aiWarningSchema>;
/**
* 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<typeof dailyBriefingSchema>;
/**
* 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();