From f9afaf77a0bf50d57977c1d31ccf1d8b87ed18f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 10 May 2026 16:44:20 +0200 Subject: [PATCH] feat(insights): trendAnnotations schema + prompt rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../trend-annotations-schema.test.ts | 145 ++++++++++++++++++ src/lib/ai/prompts/insight-generator.ts | 53 ++++++- src/lib/ai/schema.ts | 41 +++++ 3 files changed, 238 insertions(+), 1 deletion(-) create mode 100644 src/lib/ai/__tests__/trend-annotations-schema.test.ts diff --git a/src/lib/ai/__tests__/trend-annotations-schema.test.ts b/src/lib/ai/__tests__/trend-annotations-schema.test.ts new file mode 100644 index 000000000..30451f1ef --- /dev/null +++ b/src/lib/ai/__tests__/trend-annotations-schema.test.ts @@ -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":/); + } + }); +}); diff --git a/src/lib/ai/prompts/insight-generator.ts b/src/lib/ai/prompts/insight-generator.ts index fb1f7feb8..79a7381a9 100644 --- a/src/lib/ai/prompts/insight-generator.ts +++ b/src/lib/ai/prompts/insight-generator.ts @@ -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 diff --git a/src/lib/ai/schema.ts b/src/lib/ai/schema.ts index c114ee5c0..1fe96c796 100644 --- a/src/lib/ai/schema.ts +++ b/src/lib/ai/schema.ts @@ -280,6 +280,41 @@ export const dailyBriefingSchema = z.object({ export type DailyBriefing = z.infer; +/** + * 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; + /** * 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();