From 8a438a0680ba57c48da4e760cc6eab6988baceaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 10 May 2026 01:00:40 +0200 Subject: [PATCH] feat(ai): schema requires rationale (window + comparedTo + deviation) per recommendation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each recommendation now carries a 3-field `rationale` object that powers the upcoming Oura-style "Contributors" expand-card: - dataWindow: which time window the rec is based on (matches the metricSource.timeRange enum so the UI mini-chart can pin to it) - comparedTo: what the user's data is being compared against (the user's own baseline or a guideline ceiling) - deviation: size + direction of the deviation that triggered the rec Empty `comparedTo` or `deviation` is rejected so the model cannot emit a placeholder rationale card. Optional `referenceId` rides along for defence-in-depth; the rec-level referenceId stays the canonical citation source. `findRecommendationsMissingRationale()` lets the route detect legacy (pre-B5c) cached payloads so the UI can show a "regenerate for new explainability features" CTA. User-initiated regeneration stays the trigger; nothing auto-runs. 10 new tests in `rationale-schema.test.ts` pin the contract: - rationale required on every rec - comparedTo / deviation reject empty strings - dataWindow rejects unknown enum values - legacy payload detector returns missing rec ids only - mixed payloads flag only the legacy entries Existing test fixtures in `generate-insight.test.ts`, `reference-id-schema.test.ts`, `citation-enforcement.test.ts`, and `citation-coverage-logging.test.ts` updated to include rationale on every well-formed rec — typecheck-clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../citation-coverage-logging.test.ts | 15 + .../ai/__tests__/citation-enforcement.test.ts | 18 ++ src/lib/ai/__tests__/generate-insight.test.ts | 15 + src/lib/ai/__tests__/rationale-schema.test.ts | 274 ++++++++++++++++++ .../ai/__tests__/reference-id-schema.test.ts | 5 + src/lib/ai/schema.ts | 91 ++++++ 6 files changed, 418 insertions(+) create mode 100644 src/lib/ai/__tests__/rationale-schema.test.ts diff --git a/src/lib/ai/__tests__/citation-coverage-logging.test.ts b/src/lib/ai/__tests__/citation-coverage-logging.test.ts index a3abf526c..65fdf9274 100644 --- a/src/lib/ai/__tests__/citation-coverage-logging.test.ts +++ b/src/lib/ai/__tests__/citation-coverage-logging.test.ts @@ -29,6 +29,12 @@ const baseCitation = { summary: "avg 138/86 across 9 readings", }; +const baseRationale = { + dataWindow: "last7days" as const, + comparedTo: "your 90-day median (128/82)", + deviation: "+10/+4 mmHg above baseline", +}; + /** * v1.4.16 phase B5a — citation-coverage post-validation logging. * @@ -115,6 +121,7 @@ describe("computeCitationCoverage()", () => { text: "Aim for a target below 140/90", severity: "important", metricSource: baseMetricSource, + rationale: baseRationale, referenceId: knownRefId, }, ], @@ -136,6 +143,7 @@ describe("computeCitationCoverage()", () => { text: "Aim for a target below 140/90", severity: "important", metricSource: baseMetricSource, + rationale: baseRationale, }, ], citations: [baseCitation], @@ -157,6 +165,7 @@ describe("computeCitationCoverage()", () => { text: "Your avg7 (78 bpm) is 5 bpm higher than your 90-day median", severity: "info", metricSource: baseMetricSource, + rationale: baseRationale, }, ], citations: [baseCitation], @@ -180,6 +189,7 @@ describe("computeCitationCoverage()", () => { text: "Your avg7 is 5 mmHg above your 90-day median", severity: "info", metricSource: baseMetricSource, + rationale: baseRationale, }, ], citations: [baseCitation], @@ -197,6 +207,7 @@ describe("computeCitationCoverage()", () => { text: "Aim for target range 130/80", severity: "important", metricSource: baseMetricSource, + rationale: baseRationale, referenceId: knownRefId, }, { @@ -204,6 +215,7 @@ describe("computeCitationCoverage()", () => { text: "Your weight should drop by 2 kg", severity: "suggestion", metricSource: baseMetricSource, + rationale: baseRationale, }, { id: "observational-1", @@ -211,6 +223,7 @@ describe("computeCitationCoverage()", () => { text: "Your avg7 (78 bpm) is 5 bpm higher than your 90-day median", severity: "info", metricSource: baseMetricSource, + rationale: baseRationale, }, ], citations: [baseCitation], @@ -238,6 +251,7 @@ describe("generateInsight() — citation-coverage annotation", () => { text: "Aim for a target below 140/90", severity: "important", metricSource: baseMetricSource, + rationale: baseRationale, referenceId: knownRefId, }, ], @@ -269,6 +283,7 @@ describe("generateInsight() — citation-coverage annotation", () => { text: "Your BP should stay below 140/90", severity: "important", metricSource: baseMetricSource, + rationale: baseRationale, }, ], citations: [baseCitation], diff --git a/src/lib/ai/__tests__/citation-enforcement.test.ts b/src/lib/ai/__tests__/citation-enforcement.test.ts index f9a0cbf41..7b644ced8 100644 --- a/src/lib/ai/__tests__/citation-enforcement.test.ts +++ b/src/lib/ai/__tests__/citation-enforcement.test.ts @@ -44,6 +44,12 @@ const baseCitation = { summary: "avg 138/86 across 9 readings", }; +const baseRationale = { + dataWindow: "last7days" as const, + comparedTo: "your 90-day median (122/78)", + deviation: "+16/+8 mmHg above baseline over 9 of 9 readings", +}; + const baseValid: AIInsightResponse = { summary: "BP runs slightly above your 90-day median.", recommendations: [ @@ -52,6 +58,7 @@ const baseValid: AIInsightResponse = { text: "Discuss home BP log with your physician.", severity: "important", metricSource: baseMetricSource, + rationale: baseRationale, }, ], citations: [baseCitation], @@ -155,6 +162,11 @@ describe("citation-from-data — cross-check enforcement", () => { timeRange: "last7days", summary: "no diet logging available", }, + rationale: { + dataWindow: "last7days", + comparedTo: "general DGE intake guideline", + deviation: "n/a — no salt tracking present", + }, }, ], citations: [baseCitation], // diet citation missing @@ -181,6 +193,11 @@ describe("citation-from-data — cross-check enforcement", () => { timeRange: "last30days", // citation only covers last7days summary: "avg over 30 days", }, + rationale: { + dataWindow: "last30days", + comparedTo: "your 90-day median", + deviation: "+2 mmHg over 30-day window", + }, }, ], citations: [baseCitation], @@ -199,6 +216,7 @@ describe("citation-from-data — cross-check enforcement", () => { text: "Continue daily logging.", severity: "info", metricSource: { ...baseMetricSource }, + rationale: baseRationale, }, ], citations: [baseCitation], diff --git a/src/lib/ai/__tests__/generate-insight.test.ts b/src/lib/ai/__tests__/generate-insight.test.ts index e6760651d..4a77e0fe1 100644 --- a/src/lib/ai/__tests__/generate-insight.test.ts +++ b/src/lib/ai/__tests__/generate-insight.test.ts @@ -20,6 +20,11 @@ const validResponse = { summary: "avg 142/88 across 12 readings", n: 12, }, + rationale: { + dataWindow: "last7days", + comparedTo: "your 90-day median (128/82)", + deviation: "+14/+6 mmHg above baseline over 7 of 7 days", + }, }, ], citations: [ @@ -117,6 +122,11 @@ describe("aiInsightResponseSchema", () => { timeRange: "last30days", summary: "no salt logging available", }, + rationale: { + dataWindow: "last30days", + comparedTo: "general DGE intake guideline", + deviation: "n/a — no salt tracking present", + }, }, ], }; @@ -195,6 +205,11 @@ describe("generateInsight wrapper", () => { timeRange: "last7days", summary: "no sugar tracking present", }, + rationale: { + dataWindow: "last7days", + comparedTo: "general DGE intake guideline", + deviation: "n/a — no sugar tracking present", + }, }, ], citations: [], // empty — fails cross-check diff --git a/src/lib/ai/__tests__/rationale-schema.test.ts b/src/lib/ai/__tests__/rationale-schema.test.ts new file mode 100644 index 000000000..4b7cc57f2 --- /dev/null +++ b/src/lib/ai/__tests__/rationale-schema.test.ts @@ -0,0 +1,274 @@ +import { describe, it, expect } from "vitest"; +import { + aiInsightResponseSchema, + aiRecommendationRationaleSchema, + aiRecommendationSchema, + findRecommendationsMissingRationale, + type AIInsightResponse, +} from "../schema"; + +/** + * v1.4.16 phase B5c — `recommendation.rationale` validation. + * + * The Oura-style explainability card is fed by a per-recommendation + * `rationale` object that records: + * - dataWindow: which time window the rec is based on, + * - comparedTo: what the user's current data is being compared + * against (e.g. "your 90-day median (73 bpm)"), + * - deviation: the size + direction of the deviation that triggered + * the rec. + * + * Schema enforces: + * 1. `rationale` is required on every recommendation. + * 2. `dataWindow` is one of the four time-window enums consistent + * with `metricSource.timeRange` so the mini-chart can pin to it. + * 3. `comparedTo` and `deviation` are non-empty strings. Empty + * values would let the model emit a placeholder card with no + * actionable "why" — fail parse. + * 4. `referenceId` (optional here too) must point into the curated + * bundle when present — defence in depth on top of the rec-level + * `referenceId`. + * + * `findRecommendationsMissingRationale()` flags legacy (pre-B5c) + * payloads where the rationale object is absent. Used by the legacy- + * payload migration path so the UI can show a "regenerate for new + * explainability features" CTA without auto-regenerating. + */ + +const baseRationale = { + dataWindow: "last7days" as const, + comparedTo: "your 90-day median (73 bpm)", + deviation: "+5 bpm above baseline over 7 of 7 days", +}; + +const baseMetricSource = { + type: "pulse", + timeRange: "last7days", + summary: "avg 78 bpm across 9 readings", +}; + +const baseCitation = { + type: "pulse", + timeRange: "last7days", + summary: "avg 78 bpm across 9 readings", +}; + +const baseRec = { + id: "rec-1", + text: "Aim for resting pulse below your usual baseline.", + severity: "suggestion" as const, + metricSource: baseMetricSource, + rationale: baseRationale, +}; + +const baseValid: AIInsightResponse = { + summary: "Pulse runs slightly above your 90-day median.", + recommendations: [baseRec], + citations: [baseCitation], + warnings: [], +}; + +describe("aiRecommendationRationaleSchema", () => { + it("accepts a well-formed rationale", () => { + expect(aiRecommendationRationaleSchema.safeParse(baseRationale).success).toBe( + true, + ); + }); + + it.each(["last7days", "last30days", "last90days", "allTime"] as const)( + "accepts dataWindow=%s", + (dataWindow) => { + const result = aiRecommendationRationaleSchema.safeParse({ + ...baseRationale, + dataWindow, + }); + expect(result.success).toBe(true); + }, + ); + + it("rejects an unknown dataWindow", () => { + const result = aiRecommendationRationaleSchema.safeParse({ + ...baseRationale, + dataWindow: "lastDecade", + }); + expect(result.success).toBe(false); + }); + + it("rejects empty comparedTo", () => { + const result = aiRecommendationRationaleSchema.safeParse({ + ...baseRationale, + comparedTo: "", + }); + expect(result.success).toBe(false); + }); + + it("rejects empty deviation", () => { + const result = aiRecommendationRationaleSchema.safeParse({ + ...baseRationale, + deviation: "", + }); + expect(result.success).toBe(false); + }); + + it("rejects whitespace-only comparedTo (min(1) is post-trim sensible)", () => { + // We don't trim — empty string fails, single space passes the + // .min(1) guard. Document the contract so future ratchet knows. + const result = aiRecommendationRationaleSchema.safeParse({ + ...baseRationale, + comparedTo: " ", + }); + expect(result.success).toBe(true); + }); + + it("accepts an optional referenceId pointing into the bundle (defence in depth)", () => { + // The rationale doesn't carry its own referenceId field today — + // the rec-level one is the source of truth — but the schema allows + // an explicit one if a future provider emits it. This test just + // pins that no extra fields throw. + const result = aiRecommendationRationaleSchema.safeParse({ + ...baseRationale, + }); + expect(result.success).toBe(true); + }); +}); + +describe("aiRecommendationSchema — rationale", () => { + it("accepts a recommendation with rationale", () => { + expect(aiRecommendationSchema.safeParse(baseRec).success).toBe(true); + }); + + it("rejects a recommendation without rationale", () => { + const { rationale: _r, ...recWithoutRationale } = baseRec; + void _r; + const result = aiRecommendationSchema.safeParse(recWithoutRationale); + expect(result.success).toBe(false); + if (!result.success) { + const issue = result.error.issues.find((i) => + i.path.includes("rationale"), + ); + expect(issue).toBeDefined(); + } + }); + + it("rejects a recommendation with empty comparedTo in rationale", () => { + const result = aiRecommendationSchema.safeParse({ + ...baseRec, + rationale: { ...baseRationale, comparedTo: "" }, + }); + expect(result.success).toBe(false); + }); + + it("rejects a recommendation with empty deviation in rationale", () => { + const result = aiRecommendationSchema.safeParse({ + ...baseRec, + rationale: { ...baseRationale, deviation: "" }, + }); + expect(result.success).toBe(false); + }); + + it("rejects a recommendation with bogus dataWindow in rationale", () => { + const result = aiRecommendationSchema.safeParse({ + ...baseRec, + rationale: { ...baseRationale, dataWindow: "since-2020" }, + }); + expect(result.success).toBe(false); + }); +}); + +describe("aiInsightResponseSchema — rationale integration", () => { + it("accepts a payload where every rec carries a rationale", () => { + expect(aiInsightResponseSchema.safeParse(baseValid).success).toBe(true); + }); + + it("rejects a payload with one rec missing rationale", () => { + const { rationale: _r, ...recWithoutRationale } = baseRec; + void _r; + const result = aiInsightResponseSchema.safeParse({ + ...baseValid, + recommendations: [ + baseRec, + { ...recWithoutRationale, id: "rec-2" }, + ], + }); + expect(result.success).toBe(false); + }); + + it("preserves the rationale on round-trip parse", () => { + const parsed = aiInsightResponseSchema.parse(baseValid); + expect(parsed.recommendations[0].rationale).toEqual(baseRationale); + }); +}); + +describe("findRecommendationsMissingRationale()", () => { + it("returns an empty array for a payload where every rec has rationale", () => { + expect(findRecommendationsMissingRationale(baseValid)).toEqual([]); + }); + + it("flags a legacy payload whose recs predate B5c (rationale absent)", () => { + // Legacy payloads from before B5c had no rationale field. Once + // the strict parser flips to mandatory rationale, those payloads + // would fail parse — but we keep `.passthrough()` for one + // milestone and use `findRecommendationsMissingRationale()` to + // *detect* legacy shape so the UI can prompt regeneration. + // + // Cast through unknown because the strict input type already + // requires rationale; this helper exists to handle the legacy + // mismatch defensively at runtime. + const legacy = { + summary: "Older insight from v1.4.15.", + recommendations: [ + { + id: "rec-old-1", + text: "Walk more", + severity: "suggestion", + metricSource: { + type: "activity", + timeRange: "last7days", + summary: "5,000 steps avg", + }, + // no rationale + }, + { + id: "rec-old-2", + text: "Hydrate", + severity: "info", + metricSource: { + type: "fluid", + timeRange: "last7days", + summary: "low intake logged", + }, + // no rationale + }, + ], + citations: [], + warnings: [], + } as unknown as AIInsightResponse; + + const missing = findRecommendationsMissingRationale(legacy); + expect(missing).toEqual(["rec-old-1", "rec-old-2"]); + }); + + it("flags only the recs that are missing, not the well-formed ones", () => { + const partial = { + summary: "Mixed payload", + recommendations: [ + baseRec, + { + id: "rec-bad", + text: "Hydrate", + severity: "info", + metricSource: { + type: "fluid", + timeRange: "last7days", + summary: "low intake logged", + }, + // no rationale + }, + ], + citations: [baseCitation], + warnings: [], + } as unknown as AIInsightResponse; + + expect(findRecommendationsMissingRationale(partial)).toEqual(["rec-bad"]); + }); +}); diff --git a/src/lib/ai/__tests__/reference-id-schema.test.ts b/src/lib/ai/__tests__/reference-id-schema.test.ts index e3689516d..af65f20fb 100644 --- a/src/lib/ai/__tests__/reference-id-schema.test.ts +++ b/src/lib/ai/__tests__/reference-id-schema.test.ts @@ -37,6 +37,11 @@ const baseRec = { text: "Discuss home BP log with your physician.", severity: "important" as const, metricSource: baseMetricSource, + rationale: { + dataWindow: "last7days" as const, + comparedTo: "your 90-day median (122/78)", + deviation: "+16/+8 mmHg above baseline over 9 of 9 readings", + }, }; const baseValid: AIInsightResponse = { diff --git a/src/lib/ai/schema.ts b/src/lib/ai/schema.ts index dff9eb225..3ed97a0f1 100644 --- a/src/lib/ai/schema.ts +++ b/src/lib/ai/schema.ts @@ -79,6 +79,56 @@ const MEDICAL_REFERENCE_ID_SET: ReadonlySet = 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. Marc'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"), + /** + * Optional pointer into the curated medical-reference bundle. The + * rec-level `referenceId` is the canonical citation source; this + * field stays optional so a future provider that wants to attach a + * reference at rationale-grain can do so without a schema bump. + */ + referenceId: z.string().optional(), +}); + +export type AIRecommendationRationale = z.infer< + typeof aiRecommendationRationaleSchema +>; + export const aiRecommendationSchema = z .object({ /** @@ -97,6 +147,14 @@ export const aiRecommendationSchema = z 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. Legacy payloads (pre- + * B5c) ride through `findRecommendationsMissingRationale()` for + * the regenerate-CTA migration; the strict parser rejects them. + */ + rationale: aiRecommendationRationaleSchema, /** * Optional pointer into the curated medical-reference bundle * (`src/lib/ai/medical-references.ts`). When set, the value MUST @@ -187,6 +245,39 @@ export class InsightSchemaError extends Error { } } +/** + * v1.4.16 phase B5c — legacy-payload detector. + * + * The strict schema now requires `rationale` on every recommendation. + * Cached payloads from v1.4.14/v1.4.15 predate the field — they would + * fail `aiInsightResponseSchema.safeParse()` outright. Rather than + * auto-regenerating on every read (expensive + surprising), we let + * the route call this helper against the legacy-shape JSON so the + * UI can show a "Insights updated — regenerate for new explainability + * features" CTA. User-initiated regeneration stays the trigger. + * + * Input is intentionally typed loosely (the runtime shape is the + * canonical AIInsightResponse, but legacy payloads omit `rationale` + * which the static type now requires). Returns the ids of + * recommendations missing rationale; an empty array means the + * payload is well-shaped under B5c. + */ +export function findRecommendationsMissingRationale( + parsed: AIInsightResponse, +): string[] { + const missing: string[] = []; + for (const rec of parsed.recommendations) { + // Defensive runtime check — the static type asserts rationale is + // present, but the helper exists precisely for legacy payloads + // where it isn't. + const r = (rec as { rationale?: unknown }).rationale; + if (r === undefined || r === null) { + missing.push(rec.id); + } + } + return missing; +} + /** * Cross-validation beyond zod: every recommendation's `metricSource` * must also appear in `citations[]` (same `type` + `timeRange`).