mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
generateInsight() parsed against the strict aiInsightResponseSchema, cross-checked that every recommendation's metricSource appeared in citations[], and threw InsightSchemaError on a second failure. Its only caller was runWithFallback(), which had no production caller — every live surface runs runRawCompletionWithFallback. So none of it executed. Wiring it into the live path was the alternative and it is not viable: the live insightResultSchema has no citations[] field at all and treats metricSource as optional, so the cross-check would have rejected essentially every generation rather than tightening anything. Removed: runWithFallback and its param/result types, generateInsight and its private helpers, findUncitedRecommendations, findRecommendationsMissingRationale, the never-thrown InsightSchemaError and the isHardProviderFailure branch that excluded it, and computeConfidence — a deterministic override that only ran inside the dead wrapper. Kept: buildRetryCorrectionMessage, which comprehensive-generate sends on its own retry leg; the file it lived in is renamed to match what it now holds. aiInsightResponseSchema stays as the shape that message describes. Note the retry prompt asks for a stricter payload than the live validator requires — that mismatch predates this and is left alone. computeCitationCoverage is untouched and still live: it is a metric, not a gate, and v1.30.24 wired it into the comprehensive post-parse step. Chain-runner coverage the deleted tests carried (last-working cache, empty-input guard) is ported onto runRawCompletionWithFallback, which shares the machinery.
523 lines
21 KiB
TypeScript
523 lines
21 KiB
TypeScript
import { z } from "zod/v4";
|
|
import { MEDICAL_REFERENCE_IDS } from "./medical-references";
|
|
|
|
/**
|
|
* Strict response schema for v1.4.15 AI hardening (Phase C1).
|
|
*
|
|
* The maintainer, verbatim 2026-05-09: "Es darf null Halluzinationen haben und es
|
|
* muss sich halt irgendwie stützen auf medizinische Dinge." ("It must
|
|
* have zero hallucinations and must ground on medical facts.")
|
|
*
|
|
* The schema enforces:
|
|
* - A short natural-language summary.
|
|
* - A typed `recommendations[]` where each item is structured
|
|
* (`id`, `text`, `severity`) and MUST cite the user-data point that
|
|
* justified it via `metricSource`. An empty `metricSource.summary`
|
|
* fails parse — the model cannot fabricate recommendations without
|
|
* pointing to a number from the user's snapshot.
|
|
* - A `citations[]` array referencing the metrics the response drew
|
|
* on. May be empty when `recommendations[]` is empty (no data → no
|
|
* citation), but every recommendation's `metricSource` MUST also
|
|
* appear in `citations[]`.
|
|
* - A `warnings[]` array for guideline-flagged values (high BP,
|
|
* critical pulse, etc.). Free-form for now; v1.4.16 adds severity
|
|
* enum + medical-reference grounding.
|
|
*
|
|
* Legacy rich fields (`classification`, `findings`, `correlations`,
|
|
* `dataQuality`, `disclaimer`) remain accepted via `.passthrough()` so
|
|
* cached payloads from v1.4.14 still hydrate the dashboard. The
|
|
* v1.4.16 roadmap (`docs/audit/v1416-ai-roadmap.md`) plans the full
|
|
* migration to the strict shape once all consumers move over.
|
|
*/
|
|
|
|
// Severity levels — generic for v1.4.15. v1.4.16 adds a clinical
|
|
// severity enum keyed to ESH/ESC and AHA guidelines.
|
|
export const recommendationSeveritySchema = z.enum([
|
|
"info",
|
|
"suggestion",
|
|
"important",
|
|
"urgent",
|
|
]);
|
|
|
|
/**
|
|
* Citation-from-data: every recommendation must point to a concrete
|
|
* snapshot field that justified it. Empty `summary` is rejected.
|
|
*/
|
|
export const metricSourceSchema = z.object({
|
|
/**
|
|
* Snapshot key — e.g. "bloodPressure", "weight", "pulse",
|
|
* "medications.compliance30". Free-form because the snapshot shape
|
|
* itself is rich; v1.4.16 narrows to a typed enum.
|
|
*/
|
|
type: z.string().min(1, "metricSource.type required"),
|
|
/**
|
|
* Time window the value covers — e.g. "last7days", "last30days",
|
|
* "allTime". v1.4.16 narrows this enum.
|
|
*/
|
|
timeRange: z.string().min(1, "metricSource.timeRange required"),
|
|
/**
|
|
* Human-readable summary of the data point — e.g. "avg 142/88 over
|
|
* 12 readings" or "compliance 0.71 / 30d". Empty value rejected so
|
|
* the model cannot fake a citation.
|
|
*/
|
|
summary: z
|
|
.string()
|
|
.min(1, "metricSource.summary required (zero-hallucination)"),
|
|
/** Sample count behind the value, when known. */
|
|
n: z.number().int().nonnegative().optional(),
|
|
});
|
|
|
|
/**
|
|
* Set of valid `referenceId` values, derived from
|
|
* `MEDICAL_REFERENCES` so the two cannot drift. Validated lazily
|
|
* inside `superRefine` instead of `z.enum(...)` because the bundle is
|
|
* a `string[]` constant — `z.enum` requires a tuple type.
|
|
*/
|
|
const MEDICAL_REFERENCE_ID_SET: ReadonlySet<string> = new Set(
|
|
MEDICAL_REFERENCE_IDS,
|
|
);
|
|
|
|
/**
|
|
* v1.4.16 phase B5c — per-recommendation explainability rationale.
|
|
*
|
|
* Each rec carries a 3-field rationale that powers the Oura-style
|
|
* "Contributors" expand-card:
|
|
*
|
|
* - dataWindow: which time window the rec is based on. Mirrors the
|
|
* `metricSource.timeRange` enum so the UI's mini-chart can pin
|
|
* to the same window the rec was derived from.
|
|
* - comparedTo: the user's own baseline OR a population norm the
|
|
* deviation is being measured against — e.g.
|
|
* "your 90-day median (73 bpm)" or "ESH ceiling 140/90".
|
|
* - deviation: the size + direction of the deviation that triggered
|
|
* the rec — e.g. "+5 bpm above baseline over 7 of 7 days".
|
|
*
|
|
* Empty `comparedTo` or `deviation` is rejected so the model cannot
|
|
* emit a placeholder rationale card. the maintainer's mandate from B5a stays:
|
|
* zero hallucinations, every rec must trace back to user data.
|
|
*/
|
|
export const aiRecommendationRationaleSchema = z.object({
|
|
/**
|
|
* Time window the rec is based on. Same enum the UI's mini-chart
|
|
* uses, so the chart can pin to the rationale's window regardless
|
|
* of any parent range tab.
|
|
*/
|
|
dataWindow: z.enum(["last7days", "last30days", "last90days", "allTime"]),
|
|
/**
|
|
* What the deviation is being compared against — the user's own
|
|
* baseline or a guideline ceiling. Free-form so the model can be
|
|
* specific ("your 90-day median (73 bpm)").
|
|
*/
|
|
comparedTo: z.string().min(1, "rationale.comparedTo required"),
|
|
/**
|
|
* Size + direction of the deviation. Free-form so the model can be
|
|
* specific ("+5 bpm above baseline over 7 of 7 days").
|
|
*/
|
|
deviation: z.string().min(1, "rationale.deviation required"),
|
|
});
|
|
|
|
export const aiRecommendationSchema = z
|
|
.object({
|
|
/**
|
|
* Stable identifier for the recommendation within this response.
|
|
* Used by the future user-feedback loop ("was this helpful?",
|
|
* v1.4.16 roadmap). Format: short slug (`rec-1`, `bp-elevated`).
|
|
*/
|
|
id: z.string().min(1, "recommendation.id required"),
|
|
/** Human-readable recommendation text. */
|
|
text: z.string().min(1, "recommendation.text required"),
|
|
/**
|
|
* Mandatory citation — the data point that justified the
|
|
* recommendation. Closes the "ungrounded boilerplate" risk the maintainer
|
|
* called out: every recommendation must trace back to user data.
|
|
*/
|
|
metricSource: metricSourceSchema,
|
|
/** Severity — generic for v1.4.15, clinically-keyed in v1.4.16. */
|
|
severity: recommendationSeveritySchema,
|
|
/**
|
|
* v1.4.16 phase B5c — per-recommendation explainability rationale.
|
|
* Required on every rec so the UI's expand-card always has WHY +
|
|
* WINDOW + COMPARED-TO content to render. The strict parser
|
|
* rejects a legacy (pre-B5c) payload that omits it.
|
|
*/
|
|
rationale: aiRecommendationRationaleSchema,
|
|
/**
|
|
* Optional pointer into the curated medical-reference bundle
|
|
* (`src/lib/ai/medical-references.ts`). When set, the value MUST
|
|
* be a known id — fabricated ids are rejected.
|
|
*
|
|
* Optional in v1.4.16 (B5a): the prompt asks the model to cite a
|
|
* matching reference for normative claims, but a missing
|
|
* referenceId is logged as a citation-coverage warning rather
|
|
* than a parse failure. v1.4.16 phase B5c flips it to required
|
|
* when `severity >= "important"`.
|
|
*/
|
|
referenceId: z.string().optional(),
|
|
/**
|
|
* v1.4.16 phase B5d — deterministic confidence score (0-100).
|
|
*
|
|
* Optional at parse-time:
|
|
* - The LLM may emit a value (we accept it so the payload round-
|
|
* trips cleanly). A deterministic server-side override once
|
|
* replaced it post-validation, but that path never ran in
|
|
* production and was removed with the rest of the wrapper.
|
|
* - Legacy cached payloads from before B5d landed have no
|
|
* confidence field; the meter falls back to a "draft" pill.
|
|
*
|
|
* Integer-only and bounded so the meter component can render
|
|
* without clamping; a renegade provider that emits 101 or 67.5
|
|
* fails parse and triggers the wrapper's corrective retry.
|
|
*/
|
|
confidence: z.number().int().min(0).max(100).optional(),
|
|
})
|
|
.superRefine((rec, ctx) => {
|
|
if (rec.referenceId !== undefined) {
|
|
if (!MEDICAL_REFERENCE_ID_SET.has(rec.referenceId)) {
|
|
ctx.addIssue({
|
|
code: "custom",
|
|
message: `referenceId "${rec.referenceId}" is not in MEDICAL_REFERENCES`,
|
|
path: ["referenceId"],
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
export const aiCitationSchema = z.object({
|
|
/** Snapshot key being cited. */
|
|
type: z.string().min(1, "citation.type required"),
|
|
/** Window of the value. */
|
|
timeRange: z.string().min(1, "citation.timeRange required"),
|
|
/** Short description of the data point. */
|
|
summary: z.string().min(1, "citation.summary required"),
|
|
});
|
|
|
|
export const aiWarningSchema = z.object({
|
|
/** Topic — "blood_pressure", "pulse", etc. */
|
|
topic: z.string().min(1, "warning.topic required"),
|
|
/** What's flagged. */
|
|
message: z.string().min(1, "warning.message required"),
|
|
/** Severity tag for downstream filtering. */
|
|
severity: recommendationSeveritySchema.optional(),
|
|
});
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
* The maintainer'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.
|
|
*
|
|
* v1.4.23 — extended with the seven Apple Health categories landed in
|
|
* Wave 2 so the daily-briefing surface can cite HRV / sleep / resting
|
|
* HR / steps / active energy / flights / distance findings on iOS-
|
|
* connected accounts. Web-only accounts never see those metrics in
|
|
* the snapshot, so the prompt is told (rule 12) to treat the new
|
|
* tokens as optional.
|
|
*/
|
|
sourceMetric: z.enum([
|
|
"bp",
|
|
"weight",
|
|
"pulse",
|
|
"mood",
|
|
"compliance",
|
|
// ── v1.4.23 Apple Health additive ──
|
|
"hrv",
|
|
"sleep",
|
|
"resting_hr",
|
|
"steps",
|
|
"active_energy",
|
|
"flights",
|
|
"distance",
|
|
"vo2_max",
|
|
"body_temp",
|
|
// ── v1.4.25 W4d GLP-1 additive ──
|
|
// Findings emitted by the plateau-detection rule when a user has
|
|
// an active GLP-1 receptor agonist and weight has flattened on a
|
|
// stable dose for ≥21 days. The finding ALWAYS defers to the
|
|
// user's clinician (no dose recommendation); the enum value
|
|
// exists so the UI can pin the right icon + filter.
|
|
"glp1_plateau",
|
|
// ── v1.10.0 derived-wellness additive ──
|
|
// Findings emitted when a notable derived signal (a readiness /
|
|
// recovery shift gated on confidence) crosses the briefing's
|
|
// notability gate. Descriptive, never a clinical attribution; the
|
|
// enum value lets the UI pin the right icon + filter.
|
|
"readiness",
|
|
"recovery",
|
|
]),
|
|
});
|
|
|
|
export type DailyBriefingKeyFinding = z.infer<
|
|
typeof dailyBriefingKeyFindingSchema
|
|
>;
|
|
|
|
/**
|
|
* v1.18.7 — "Signals of the day".
|
|
*
|
|
* The present-focused lead of the rebuilt daily briefing. Each signal is a
|
|
* short, NOW-anchored read of one metric — what the freshest reading is doing
|
|
* against the user's own recent baseline — paired with a single concrete,
|
|
* doable nudge ("you're ~1h short on sleep this week — aim for an earlier
|
|
* night"). The deterministic `features.signalsOfDay` block hands the model
|
|
* the comparison already finished (delta, normal-swing verdict, emerging
|
|
* trend, recent anomaly), so the prose states it rather than re-deriving it.
|
|
*
|
|
* Hard cap of 3 so the briefing leads with what actually moved today and the
|
|
* card stays a glance, not a wall. The block is optional + nullable so cached
|
|
* payloads from before v1.18.7 round-trip without forcing a regenerate.
|
|
*/
|
|
export const dailyBriefingSignalSchema = z.object({
|
|
/** Metric the signal is drawn from — same discriminator as keyFindings. */
|
|
sourceMetric: dailyBriefingKeyFindingSchema.shape.sourceMetric,
|
|
/**
|
|
* Tone drives the accent colour, mirroring the keyFinding ladder:
|
|
* - "good" — moving the favourable way / target held
|
|
* - "watch" — a deviation worth a gentle nudge
|
|
* - "info" — a neutral present-tense observation
|
|
*/
|
|
tone: z.enum(["good", "watch", "info"]),
|
|
/** Present-tense headline — what is happening NOW (the user reads first). */
|
|
headline: z.string().min(1, "signal.headline required"),
|
|
/**
|
|
* One concrete, doable nudge tied to the signal. Empty rejected so a
|
|
* signal always carries an action the user can take, not just an
|
|
* observation. Stays inside the data boundary — never a prescription.
|
|
*/
|
|
nudge: z.string().min(1, "signal.nudge required"),
|
|
/** Optional delta string — e.g. "+6 mmHg vs your 30-day average". */
|
|
delta: z.string().nullable(),
|
|
});
|
|
|
|
export type DailyBriefingSignal = z.infer<typeof dailyBriefingSignalSchema>;
|
|
|
|
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"),
|
|
/**
|
|
* v1.18.7 — 0-3 present-focused "Signals of the day". The rebuilt
|
|
* briefing leads with these; `keyFindings` stays the longer-horizon
|
|
* trend list below. Optional + nullable so pre-v1.18.7 caches round-trip.
|
|
*/
|
|
signalsOfDay: z
|
|
.array(dailyBriefingSignalSchema)
|
|
.min(0)
|
|
.max(3)
|
|
.nullable()
|
|
.optional(),
|
|
/**
|
|
* 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>;
|
|
|
|
/**
|
|
* 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(),
|
|
// ── v1.4.23 Apple Health additive ──
|
|
// Each annotation stays one sentence, observational, ≤ 200 chars
|
|
// (mirrors the bp/weight/mood contract). The prompt instructs the
|
|
// model to omit any metric the snapshot doesn't carry.
|
|
hrv: z
|
|
.string()
|
|
.min(1, "trendAnnotations.hrv required when emitted")
|
|
.max(200, "trendAnnotations.hrv must be <= 200 chars")
|
|
.optional(),
|
|
sleep: z
|
|
.string()
|
|
.min(1, "trendAnnotations.sleep required when emitted")
|
|
.max(200, "trendAnnotations.sleep must be <= 200 chars")
|
|
.optional(),
|
|
resting_hr: z
|
|
.string()
|
|
.min(1, "trendAnnotations.resting_hr required when emitted")
|
|
.max(200, "trendAnnotations.resting_hr must be <= 200 chars")
|
|
.optional(),
|
|
steps: z
|
|
.string()
|
|
.min(1, "trendAnnotations.steps required when emitted")
|
|
.max(200, "trendAnnotations.steps must be <= 200 chars")
|
|
.optional(),
|
|
active_energy: z
|
|
.string()
|
|
.min(1, "trendAnnotations.active_energy required when emitted")
|
|
.max(200, "trendAnnotations.active_energy must be <= 200 chars")
|
|
.optional(),
|
|
});
|
|
|
|
export type TrendAnnotations = z.infer<typeof trendAnnotationsSchema>;
|
|
|
|
/**
|
|
* v1.4.20 phase B4 — Storyboard annotations.
|
|
*
|
|
* The 90-day BP timeline on `/insights` overlays factual events the user
|
|
* logged ("started medication X", "first sustained dip below 140") onto
|
|
* the chart as vertical reference lines + chapter cards beneath. Each
|
|
* annotation MUST cite a real event from the snapshot — the prose is
|
|
* neutral, never causal ("started medication X" not "improvement is due
|
|
* to X").
|
|
*
|
|
* Hard cap of 20 entries so a runaway model cannot blanket the chart
|
|
* with reference lines. The block itself is `.optional()` (no nullable
|
|
* wrap because zod treats an absent field as undefined for arrays
|
|
* uniformly).
|
|
*/
|
|
export const storyboardAnnotationSchema = z.object({
|
|
/** ISO date the annotation pins to (YYYY-MM-DD). */
|
|
date: z
|
|
.string()
|
|
.regex(
|
|
/^\d{4}-\d{2}-\d{2}$/,
|
|
"storyboardAnnotation.date must be YYYY-MM-DD",
|
|
),
|
|
/** Short label rendered next to the reference line (≤ 80 chars). */
|
|
label: z
|
|
.string()
|
|
.min(1, "storyboardAnnotation.label required")
|
|
.max(80, "storyboardAnnotation.label must be <= 80 chars"),
|
|
/**
|
|
* Category drives the annotation colour:
|
|
* - "medication" — Dracula pink (started/changed dose)
|
|
* - "event" — Dracula cyan (a notable user-logged event)
|
|
* - "milestone" — Dracula green (target hit, streak milestone)
|
|
* - "warning" — Dracula orange (a deviation worth flagging)
|
|
*/
|
|
category: z.enum(["medication", "event", "milestone", "warning"]),
|
|
/** One-paragraph detail that the chapter card surfaces below the chart. */
|
|
detail: z
|
|
.string()
|
|
.min(1, "storyboardAnnotation.detail required")
|
|
.max(400, "storyboardAnnotation.detail must be <= 400 chars"),
|
|
});
|
|
|
|
export const storyboardAnnotationsSchema = z
|
|
.array(storyboardAnnotationSchema)
|
|
.max(20, "storyboardAnnotations can hold at most 20 entries");
|
|
|
|
/**
|
|
* Canonical strict response schema. New strict fields are required;
|
|
* legacy rich fields ride along through `.passthrough()` for back-
|
|
* compat with cached payloads and the existing dashboard renderer.
|
|
*/
|
|
export const aiInsightResponseSchema = z
|
|
.object({
|
|
/** 2-3 sentence overall summary in user-facing locale. */
|
|
summary: z.string().min(1, "summary required"),
|
|
/** Structured recommendations with mandatory citations. */
|
|
recommendations: z.array(aiRecommendationSchema),
|
|
/** Citations supporting any claim in summary or recommendations. */
|
|
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(),
|
|
/**
|
|
* 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(),
|
|
/**
|
|
* v1.4.20 phase B4 — optional storyboard annotations. Each entry
|
|
* pins a vertical reference line + chapter card to a date on the
|
|
* 90-day BP timeline. Optional so legacy caches round-trip.
|
|
*/
|
|
storyboardAnnotations: storyboardAnnotationsSchema.optional(),
|
|
})
|
|
.passthrough();
|
|
|
|
export type AIInsightResponse = z.infer<typeof aiInsightResponseSchema>;
|
|
|
|
/**
|
|
* The strict-contract ENFORCEMENT that used to live here is gone: an
|
|
* `InsightSchemaError` class no caller threw, a `findUncitedRecommendations`
|
|
* cross-check (every recommendation's `metricSource` must appear in
|
|
* `citations[]`) and a `findRecommendationsMissingRationale` legacy-payload
|
|
* detector. All three existed only for `generateInsight()`, whose sole caller
|
|
* `runWithFallback()` had no production caller — so none of them ever ran.
|
|
*
|
|
* The schema above stays because it is not a guard: it is the shape the
|
|
* corrective retry prompt (`retry-correction.ts`) describes to the model, and
|
|
* it is the reference definition for the strict payload. NOTE the live
|
|
* comprehensive path validates against the looser `insightResultSchema` in
|
|
* `types.ts`, which has no `citations[]` and treats `metricSource` as
|
|
* optional — so the retry prompt asks for a stricter shape than the live
|
|
* validator requires. That mismatch predates this cleanup and is left alone
|
|
* deliberately; changing the retry prompt changes model output.
|
|
*/
|