Files
HealthLog/src/lib/ai/__tests__/legacy-payload-detection.test.ts
T
Marc-André BombeckandClaude Opus 4.7 c8b30c112d feat(ai): legacy insight payload detection with regenerate CTA
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>
2026-05-10 01:17:15 +02:00

131 lines
4.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});
});