mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
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 = {
|
||||
|
||||
@@ -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`).
|
||||
|
||||
Reference in new issue
Block a user