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