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";
/** 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.
Prompt version: ${PROMPT_VERSION}.
@@ -113,6 +113,21 @@ GROUND RULES — ZERO HALLUCINATIONS
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.
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
- 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"
}
]
},
"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
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
Respond in English. Severity values stay in lowercase English exactly
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"
weg oder setze es auf null. Leerer Paragraph oder Findings ohne
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
- 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"
}
]
},
"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
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
Antworte auf Deutsch. Severity-Werte bleiben exakt in englischer
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>;
/**
* 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;
* 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.
*/
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();