mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +00:00
Cached insight blobs persisted before B5c shipped don't carry the new `rationale` field — the strict parser would reject them, and auto-regenerating on every cache-hit would burn rate-limit tokens silently. Instead the route now detects the legacy shape and surfaces a `legacyPayload: true` flag in the response so the UI can render a one-time "regenerate for new explainability features" CTA. User-initiated regeneration stays the trigger. `isLegacyInsightPayload(payload)` lives in `src/lib/ai/legacy-payload.ts` and is intentionally lenient: any rec missing rationale (or the v1.4.14/v1.4.15 string-only InsightResult shape) flags. Empty recommendations[] is NOT legacy — it's a legitimate refusal payload. The annotate() Wide-Event also carries `legacyPayload` on cached hits so the admin AI quality dashboard can chart the migration ratio over time. v1.4.17 can flip auto-eviction on once the ratio drops near zero. 7 new tests in `legacy-payload-detection.test.ts` pin the contract. Existing 223 AI tests still green; typecheck clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
131 lines
4.1 KiB
TypeScript
131 lines
4.1 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
||
import { isLegacyInsightPayload } from "../legacy-payload";
|
||
|
||
/**
|
||
* v1.4.16 phase B5c — legacy-payload detection.
|
||
*
|
||
* Cached insight blobs persisted before B5c shipped don't carry the
|
||
* `rationale` field. The /api/insights/generate route returns a
|
||
* `legacyPayload: true` flag on cache-hit so the UI can render a
|
||
* one-time "Insights updated — regenerate for new explainability
|
||
* features" CTA. User-initiated regeneration stays the trigger; we
|
||
* do NOT auto-regenerate on cache-hit (that would burn rate-limit
|
||
* tokens silently).
|
||
*
|
||
* Detection is intentionally lenient — the cached payload may carry
|
||
* either the canonical `AIInsightResponse` shape or the legacy
|
||
* `InsightResult` shape (string-only recommendations[]). Both pre-
|
||
* date B5c rationale; both should be flagged for regeneration.
|
||
*/
|
||
|
||
describe("isLegacyInsightPayload()", () => {
|
||
it("returns false for a B5c-shaped payload (every rec has rationale)", () => {
|
||
const payload = {
|
||
summary: "x",
|
||
recommendations: [
|
||
{
|
||
id: "rec-1",
|
||
text: "Walk more",
|
||
severity: "suggestion",
|
||
metricSource: {
|
||
type: "activity",
|
||
timeRange: "last7days",
|
||
summary: "5,000 steps",
|
||
},
|
||
rationale: {
|
||
dataWindow: "last7days",
|
||
comparedTo: "your 90-day median (8,000 steps)",
|
||
deviation: "−3,000 steps below baseline",
|
||
},
|
||
},
|
||
],
|
||
};
|
||
expect(isLegacyInsightPayload(payload)).toBe(false);
|
||
});
|
||
|
||
it("returns true for a payload where every rec lacks rationale (pre-B5c)", () => {
|
||
const payload = {
|
||
summary: "x",
|
||
recommendations: [
|
||
{
|
||
id: "rec-1",
|
||
text: "Walk more",
|
||
severity: "suggestion",
|
||
metricSource: {
|
||
type: "activity",
|
||
timeRange: "last7days",
|
||
summary: "5,000 steps",
|
||
},
|
||
},
|
||
],
|
||
};
|
||
expect(isLegacyInsightPayload(payload)).toBe(true);
|
||
});
|
||
|
||
it("returns true for the legacy InsightResult-style string-only recommendations[]", () => {
|
||
// v1.4.14 / v1.4.15 cached InsightResult blobs sometimes have
|
||
// string-recs (`recommendations: ["Walk more", "Hydrate"]`).
|
||
// These also predate B5c.
|
||
const payload = {
|
||
summary: "x",
|
||
recommendations: ["Walk more", "Hydrate"],
|
||
};
|
||
expect(isLegacyInsightPayload(payload)).toBe(true);
|
||
});
|
||
|
||
it("returns false for an empty recommendations[] (no recs to migrate)", () => {
|
||
// Empty recs is a legitimate refusal payload — not legacy.
|
||
const payload = {
|
||
summary: "Insufficient data.",
|
||
recommendations: [],
|
||
};
|
||
expect(isLegacyInsightPayload(payload)).toBe(false);
|
||
});
|
||
|
||
it("returns true when ANY rec is missing rationale (mixed payload)", () => {
|
||
const payload = {
|
||
summary: "x",
|
||
recommendations: [
|
||
{
|
||
id: "rec-1",
|
||
text: "Walk more",
|
||
severity: "suggestion",
|
||
metricSource: {
|
||
type: "activity",
|
||
timeRange: "last7days",
|
||
summary: "5,000 steps",
|
||
},
|
||
rationale: {
|
||
dataWindow: "last7days",
|
||
comparedTo: "your 90-day median",
|
||
deviation: "−3,000 steps",
|
||
},
|
||
},
|
||
{
|
||
id: "rec-2",
|
||
text: "Hydrate",
|
||
severity: "info",
|
||
metricSource: {
|
||
type: "fluid",
|
||
timeRange: "last7days",
|
||
summary: "low intake",
|
||
},
|
||
// no rationale
|
||
},
|
||
],
|
||
};
|
||
expect(isLegacyInsightPayload(payload)).toBe(true);
|
||
});
|
||
|
||
it("returns false for null / non-object input (defensive)", () => {
|
||
expect(isLegacyInsightPayload(null)).toBe(false);
|
||
expect(isLegacyInsightPayload(undefined)).toBe(false);
|
||
expect(isLegacyInsightPayload("not an object")).toBe(false);
|
||
expect(isLegacyInsightPayload(42)).toBe(false);
|
||
});
|
||
|
||
it("returns false when recommendations is missing entirely (not legacy, just empty)", () => {
|
||
expect(isLegacyInsightPayload({ summary: "x" })).toBe(false);
|
||
});
|
||
});
|