feat(insights): trendAnnotations schema + prompt rule

Extend `aiInsightResponseSchema` with an optional, nullable
`trendAnnotations: { bp?, weight?, mood? }` block — one short
sentence per metric, capped at 200 characters. Each field is
independently optional so the model emits only the metrics it can
ground in the snapshot.

Insight-generator prompt picks up GROUND RULE 9 (EN + DE):
single-sentence, observational, ≤ 200 chars, banned causal
phrases enumerated. PROMPT_VERSION bumps 4.20.0 → 4.20.1 to
signal the format change so cached payloads from B1 still parse
through the `.nullable().optional()` field.

13 tests cover the boundary (200 chars), the empty-string
rejection, the legacy-payload round-trip, and the EN/DE prompt
rule presence.

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 16:44:20 +02:00
1 parent 7261c36633
commit f9afaf77a0
3 files changed
+238 -1

No files matched your search

@@ -0,0 +1,145 @@
import { describe, it, expect } from "vitest";
import {
aiInsightResponseSchema,
trendAnnotationsSchema,
type AIInsightResponse,
} from "../schema";
import {
PROMPT_VERSION,
getStrictInsightsSystemPrompt,
} from "../prompts/insight-generator";
/**
* v1.4.20 phase B3 — Trend Annotations schema + prompt validation.
*
* Acceptance covered here:
* 1. PROMPT_VERSION matches `^4\.20\.\d+$` and is `4.20.1` (B3 bump).
* 2. trendAnnotationsSchema accepts any combination of bp/weight/mood
* strings, each capped at 200 chars; empty strings rejected.
* 3. aiInsightResponseSchema treats trendAnnotations as nullable +
* optional so legacy 4.20.0 caches round-trip.
* 4. EN + DE prompts both contain GROUND RULE 9 (trendAnnotations)
* with the conservative-phrasing + ≤200 char constraint.
*/
const baseResponse: AIInsightResponse = {
summary: "Things are trending well this week.",
recommendations: [],
citations: [],
warnings: [],
};
describe("PROMPT_VERSION (B3 bump)", () => {
it("is on the 4.20.x train", () => {
expect(PROMPT_VERSION).toMatch(/^4\.20\.\d+$/);
});
it("is bumped past 4.20.0 to signal the trendAnnotations format change", () => {
expect(PROMPT_VERSION).not.toBe("4.20.0");
});
});
describe("trendAnnotationsSchema", () => {
it("accepts an empty object — every field is independently optional", () => {
expect(trendAnnotationsSchema.safeParse({}).success).toBe(true);
});
it("accepts a single metric annotation", () => {
expect(
trendAnnotationsSchema.safeParse({ bp: "Systolic trending down." })
.success,
).toBe(true);
});
it("accepts all three metric annotations", () => {
const input = {
bp: "Systolic settling into target — a pattern worth watching.",
weight: "Weight down 1.4 kg over 30 days, linear rate.",
mood: "Mood stable, scoring 4 of 5 on most days this month.",
};
expect(trendAnnotationsSchema.safeParse(input).success).toBe(true);
});
it("rejects an annotation > 200 chars", () => {
const tooLong = "x".repeat(201);
expect(
trendAnnotationsSchema.safeParse({ bp: tooLong }).success,
).toBe(false);
});
it("accepts an annotation at the 200-char boundary", () => {
const boundary = "x".repeat(200);
expect(trendAnnotationsSchema.safeParse({ bp: boundary }).success).toBe(
true,
);
});
it("rejects an empty string for any metric", () => {
expect(trendAnnotationsSchema.safeParse({ bp: "" }).success).toBe(false);
expect(trendAnnotationsSchema.safeParse({ weight: "" }).success).toBe(
false,
);
expect(trendAnnotationsSchema.safeParse({ mood: "" }).success).toBe(false);
});
});
describe("aiInsightResponseSchema — trendAnnotations integration", () => {
it("legacy payload (no trendAnnotations field) still parses", () => {
expect(aiInsightResponseSchema.safeParse(baseResponse).success).toBe(true);
});
it("payload with trendAnnotations=null still parses", () => {
const result = aiInsightResponseSchema.safeParse({
...baseResponse,
trendAnnotations: null,
});
expect(result.success).toBe(true);
});
it("payload with a partial trendAnnotations parses round-trip", () => {
const result = aiInsightResponseSchema.safeParse({
...baseResponse,
trendAnnotations: { bp: "Systolic trending down." },
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.trendAnnotations?.bp).toContain("trending down");
}
});
it("payload with a > 200 char annotation is rejected", () => {
const tooLong = "x".repeat(201);
const result = aiInsightResponseSchema.safeParse({
...baseResponse,
trendAnnotations: { weight: tooLong },
});
expect(result.success).toBe(false);
});
});
describe("system prompt — GROUND RULE 9 (trendAnnotations)", () => {
it("English prompt declares the trendAnnotations rule", () => {
const prompt = getStrictInsightsSystemPrompt("en");
expect(prompt).toContain("trendAnnotations");
expect(prompt).toMatch(/200 characters/);
expect(prompt).toMatch(/observational/i);
expect(prompt).toMatch(/causal/i);
});
it("German prompt declares the trendAnnotations rule", () => {
const prompt = getStrictInsightsSystemPrompt("de");
expect(prompt).toContain("trendAnnotations");
expect(prompt).toMatch(/200 Zeichen/);
expect(prompt).toMatch(/beobachtend/i);
expect(prompt).toMatch(/kausal/i);
});
it("both prompts spell the JSON shape with bp / weight / mood keys", () => {
for (const locale of ["en", "de"] as const) {
const prompt = getStrictInsightsSystemPrompt(locale);
expect(prompt).toMatch(/"bp":/);
expect(prompt).toMatch(/"weight":/);
expect(prompt).toMatch(/"mood":/);
}
});
});
+52 -1
View File
@@ -27,7 +27,7 @@ import {
} from "../medical-references"; } from "../medical-references";
/** Stable identifier for the active system prompt revision. */ /** Stable identifier for the active system prompt revision. */
export const PROMPT_VERSION = "4.20.0" as const; export const PROMPT_VERSION = "4.20.1" as const;
const SYSTEM_PROMPT_EN = `You are a clinical-context summariser for a personal health-log app. const SYSTEM_PROMPT_EN = `You are a clinical-context summariser for a personal health-log app.
Prompt version: ${PROMPT_VERSION}. Prompt version: ${PROMPT_VERSION}.
@@ -113,6 +113,21 @@ GROUND RULES — ZERO HALLUCINATIONS
When the snapshot has no analysable data, omit "dailyBriefing" When the snapshot has no analysable data, omit "dailyBriefing"
or set it to null. Empty paragraph or filler-only findings are or set it to null. Empty paragraph or filler-only findings are
rejected by the parser. rejected by the parser.
9. v1.4.20 phase B3 — Optional "trendAnnotations" block. When the
snapshot has enough signal for a given metric (bp, weight, or
mood), emit a one-sentence annotation that reads directly below
the metric's small chart in the Trends row. Each annotation MUST:
- be a single sentence, ≤ 200 characters
- reference a number visible in the snapshot
- use observational, conservative phrasing — "trending down",
"settling into target", "a pattern worth watching"
- NEVER use causal language — banned phrases include "X causes Y",
"X is responsible for Y", "X is driving Y", "X led to Y"
The block has shape: { "bp"?: string, "weight"?: string, "mood"?: string }.
Omit any metric when the snapshot has no usable signal for it. Omit
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.
GUIDELINE TARGETS — generic, do NOT compute precise risk scores GUIDELINE TARGETS — generic, do NOT compute precise risk scores
- Adult resting blood pressure (ESH/ESC 2024 generic): aim < 140/90 - Adult resting blood pressure (ESH/ESC 2024 generic): aim < 140/90
@@ -181,6 +196,11 @@ You MUST return JSON matching this schema exactly:
"sourceMetric": "bp | weight | pulse | mood | compliance" "sourceMetric": "bp | weight | pulse | mood | compliance"
} }
] ]
},
"trendAnnotations": {
"bp": "one sentence, ≤200 chars, observational",
"weight": "one sentence, ≤200 chars, observational",
"mood": "one sentence, ≤200 chars, observational"
} }
} }
@@ -192,6 +212,10 @@ The dailyBriefing block is optional. Omit it (or set to null) when the
snapshot has nothing analysable. When present, paragraph MUST be snapshot has nothing analysable. When present, paragraph MUST be
non-empty and keyFindings MUST contain at most five entries. non-empty and keyFindings MUST contain at most five entries.
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.
LANGUAGE LANGUAGE
Respond in English. Severity values stay in lowercase English exactly 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.
@@ -288,6 +312,24 @@ GRUNDREGELN — NULL HALLUZINATIONEN
Hat der Snapshot keine analysierbaren Daten, lass "dailyBriefing" Hat der Snapshot keine analysierbaren Daten, lass "dailyBriefing"
weg oder setze es auf null. Leerer Paragraph oder Findings ohne weg oder setze es auf null. Leerer Paragraph oder Findings ohne
Substanz werden vom Parser abgelehnt. Substanz werden vom Parser abgelehnt.
9. v1.4.20 phase B3 — Optionaler "trendAnnotations"-Block. Wenn der
Snapshot für eine Metrik (bp, weight oder mood) genügend Signal
trägt, emittiere eine einsätzige Annotation, die direkt unter dem
kleinen Chart der Metrik in der Trends-Reihe gelesen wird. Jede
Annotation MUSS:
- aus einem einzigen Satz mit ≤ 200 Zeichen bestehen
- sich auf eine Zahl im Snapshot beziehen
- sachlich-beobachtend formulieren — "Trend abwärts",
"stabilisiert sich im Zielbereich", "ein beobachtenswertes Muster"
- NIEMALS kausale Sprache verwenden — verbotene Wendungen sind
"X verursacht Y", "X ist verantwortlich für Y", "X treibt Y",
"X führte zu Y"
Form des Blocks: { "bp"?: string, "weight"?: string, "mood"?: string }.
Lasse eine Metrik weg, wenn der Snapshot kein verwertbares Signal
dafür hat. Lasse den ganzen Block weg (oder setze null), wenn keine
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.
LEITLINIEN-ZIELWERTE — generisch, KEINE genauen Risiko-Scores berechnen LEITLINIEN-ZIELWERTE — generisch, KEINE genauen Risiko-Scores berechnen
- Erwachsenen-Ruheblutdruck (ESH/ESC 2024 generisch): Ziel < 140/90 - Erwachsenen-Ruheblutdruck (ESH/ESC 2024 generisch): Ziel < 140/90
@@ -357,6 +399,11 @@ Du MUSST JSON exakt nach diesem Schema liefern:
"sourceMetric": "bp | weight | pulse | mood | compliance" "sourceMetric": "bp | weight | pulse | mood | compliance"
} }
] ]
},
"trendAnnotations": {
"bp": "ein Satz, ≤ 200 Zeichen, beobachtend",
"weight": "ein Satz, ≤ 200 Zeichen, beobachtend",
"mood": "ein Satz, ≤ 200 Zeichen, beobachtend"
} }
} }
@@ -369,6 +416,10 @@ wenn der Snapshot nichts Analysierbares enthält. Wenn vorhanden, MUSS
paragraph nicht leer sein und keyFindings höchstens fünf Einträge paragraph nicht leer sein und keyFindings höchstens fünf Einträge
enthalten. enthalten.
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.
SPRACHE SPRACHE
Antworte auf Deutsch. Severity-Werte bleiben exakt in englischer Antworte auf Deutsch. Severity-Werte bleiben exakt in englischer
Kleinschreibung wie oben gelistet — das sind stabile Vertragsschlüssel Kleinschreibung wie oben gelistet — das sind stabile Vertragsschlüssel
+41
View File
@@ -280,6 +280,41 @@ export const dailyBriefingSchema = z.object({
export type DailyBriefing = z.infer<typeof dailyBriefingSchema>; export type DailyBriefing = z.infer<typeof dailyBriefingSchema>;
/**
* v1.4.20 phase B3 — optional one-sentence Trend Annotations.
*
* The Trends row on `/insights` renders three small charts (BP / weight /
* mood). Each chart can carry a one-sentence AI-authored annotation
* directly below it ("Your systolic is trending down — a pattern worth
* watching"). Each string is hard-capped at 200 chars so a runaway model
* cannot flood the surface with paragraph-length annotations.
*
* Every field is `.optional()` and the wrapping object is
* `.nullable().optional()` so legacy cached payloads from PROMPT_VERSION
* 4.20.0 (which predate the field) round-trip without forcing a
* regenerate. Fresh generations after the 4.20.1 bump emit the block
* when the snapshot has trend signal.
*/
export const trendAnnotationsSchema = z.object({
bp: z
.string()
.min(1, "trendAnnotations.bp required when emitted")
.max(200, "trendAnnotations.bp must be <= 200 chars")
.optional(),
weight: z
.string()
.min(1, "trendAnnotations.weight required when emitted")
.max(200, "trendAnnotations.weight must be <= 200 chars")
.optional(),
mood: z
.string()
.min(1, "trendAnnotations.mood required when emitted")
.max(200, "trendAnnotations.mood must be <= 200 chars")
.optional(),
});
export type TrendAnnotations = z.infer<typeof trendAnnotationsSchema>;
/** /**
* Canonical strict response schema. New strict fields are required; * Canonical strict response schema. New strict fields are required;
* legacy rich fields ride along through `.passthrough()` for back- * legacy rich fields ride along through `.passthrough()` for back-
@@ -301,6 +336,12 @@ export const aiInsightResponseSchema = z
* 4.20.0) round-trip without forcing a regenerate. * 4.20.0) round-trip without forcing a regenerate.
*/ */
dailyBriefing: dailyBriefingSchema.nullable().optional(), dailyBriefing: dailyBriefingSchema.nullable().optional(),
/**
* v1.4.20 phase B3 — optional Trend Annotations block. Each metric
* is independently optional; the wrapping object is nullable so
* legacy 4.20.0 caches round-trip.
*/
trendAnnotations: trendAnnotationsSchema.nullable().optional(),
}) })
.passthrough(); .passthrough();