mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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.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.
|
const SYSTEM_PROMPT_EN = `You are a clinical-context summariser for a personal health-log app.
|
||||||
Prompt version: ${PROMPT_VERSION}.
|
Prompt version: ${PROMPT_VERSION}.
|
||||||
@@ -93,6 +93,26 @@ GROUND RULES — ZERO HALLUCINATIONS
|
|||||||
patterns include "Your data foundation is strong", "Datengrundlage
|
patterns include "Your data foundation is strong", "Datengrundlage
|
||||||
ist sehr stark", "You have a solid baseline", "Great dataset" and
|
ist sehr stark", "You have a solid baseline", "Great dataset" and
|
||||||
any rephrasing of the same sentiment.
|
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
|
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
|
||||||
@@ -148,16 +168,35 @@ You MUST return JSON matching this schema exactly:
|
|||||||
"message": "what is flagged and why",
|
"message": "what is flagged and why",
|
||||||
"severity": "info" | "suggestion" | "important" | "urgent" (optional)
|
"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
|
Every recommendation's metricSource (type + timeRange) MUST appear in
|
||||||
citations[]. If two recommendations cite the same data point, list
|
citations[]. If two recommendations cite the same data point, list
|
||||||
the citation once.
|
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
|
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.
|
||||||
|
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
|
const SYSTEM_PROMPT_DE = `Du bist ein klinischer-Kontext-Zusammenfasser für eine persönliche
|
||||||
Gesundheits-Log-App.
|
Gesundheits-Log-App.
|
||||||
@@ -227,6 +266,28 @@ GRUNDREGELN — NULL HALLUZINATIONEN
|
|||||||
Verbotene Eröffnungsmuster sind unter anderem "Datengrundlage ist
|
Verbotene Eröffnungsmuster sind unter anderem "Datengrundlage ist
|
||||||
sehr stark", "Your data foundation is strong", "Du hast eine solide
|
sehr stark", "Your data foundation is strong", "Du hast eine solide
|
||||||
Baseline", "Großartiger Datensatz" und jede sinngemäße Umformulierung.
|
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
|
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
|
||||||
@@ -283,17 +344,37 @@ Du MUSST JSON exakt nach diesem Schema liefern:
|
|||||||
"message": "was wird geflaggt und warum",
|
"message": "was wird geflaggt und warum",
|
||||||
"severity": "info" | "suggestion" | "important" | "urgent" (optional)
|
"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
|
Jede metricSource (type + timeRange) einer Empfehlung MUSS in
|
||||||
citations[] auftauchen. Zitieren zwei Empfehlungen denselben
|
citations[] auftauchen. Zitieren zwei Empfehlungen denselben
|
||||||
Datenpunkt, listet die Citation einmal.
|
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
|
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
|
||||||
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.
|
* Returns the active scope-hardened system prompt for a given locale.
|
||||||
|
|||||||
@@ -215,6 +215,71 @@ export const aiWarningSchema = z.object({
|
|||||||
|
|
||||||
export type AIWarning = z.infer<typeof aiWarningSchema>;
|
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;
|
* 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-
|
||||||
@@ -230,6 +295,12 @@ export const aiInsightResponseSchema = z
|
|||||||
citations: z.array(aiCitationSchema),
|
citations: z.array(aiCitationSchema),
|
||||||
/** Guideline-flagged values (e.g. BP > 140/90). May be empty. */
|
/** Guideline-flagged values (e.g. BP > 140/90). May be empty. */
|
||||||
warnings: z.array(aiWarningSchema),
|
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();
|
.passthrough();
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user