feat(ai): schema requires rationale (window + comparedTo + deviation) per recommendation

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) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-10 01:17:15 +02:00
1 parent 612ee364db
commit 8a438a0680
6 files changed
+418

No files matched your search

@@ -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],
@@ -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],
@@ -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
@@ -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"]);
});
});
@@ -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 = {
+91
View File
@@ -79,6 +79,56 @@ 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. 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`).