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";
|
||||
|
||||
/** 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.
|
||||
Prompt version: ${PROMPT_VERSION}.
|
||||
@@ -93,6 +93,26 @@ GROUND RULES — ZERO HALLUCINATIONS
|
||||
patterns include "Your data foundation is strong", "Datengrundlage
|
||||
ist sehr stark", "You have a solid baseline", "Great dataset" and
|
||||
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
|
||||
- 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",
|
||||
"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
|
||||
citations[]. If two recommendations cite the same data point, list
|
||||
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
|
||||
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
|
||||
Gesundheits-Log-App.
|
||||
@@ -227,6 +266,28 @@ GRUNDREGELN — NULL HALLUZINATIONEN
|
||||
Verbotene Eröffnungsmuster sind unter anderem "Datengrundlage ist
|
||||
sehr stark", "Your data foundation is strong", "Du hast eine solide
|
||||
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
|
||||
- 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",
|
||||
"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
|
||||
citations[] auftauchen. Zitieren zwei Empfehlungen denselben
|
||||
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
|
||||
Antworte auf Deutsch. Severity-Werte bleiben exakt in englischer
|
||||
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.
|
||||
|
||||
@@ -215,6 +215,71 @@ export const aiWarningSchema = z.object({
|
||||
|
||||
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;
|
||||
* legacy rich fields ride along through `.passthrough()` for back-
|
||||
@@ -230,6 +295,12 @@ export const aiInsightResponseSchema = z
|
||||
citations: z.array(aiCitationSchema),
|
||||
/** Guideline-flagged values (e.g. BP > 140/90). May be empty. */
|
||||
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();
|
||||
|
||||
|
||||
Reference in new issue
Block a user