From c8b30c112d2fbfd06ab7b0ad7deefccf52ecabc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 10 May 2026 01:05:41 +0200 Subject: [PATCH] feat(ai): legacy insight payload detection with regenerate CTA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/app/api/insights/generate/route.ts | 11 +- .../legacy-payload-detection.test.ts | 130 ++++++++++++++++++ src/lib/ai/legacy-payload.ts | 55 ++++++++ 3 files changed, 194 insertions(+), 2 deletions(-) create mode 100644 src/lib/ai/__tests__/legacy-payload-detection.test.ts create mode 100644 src/lib/ai/legacy-payload.ts diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts index ed3812702..758ddb13a 100644 --- a/src/app/api/insights/generate/route.ts +++ b/src/app/api/insights/generate/route.ts @@ -12,6 +12,7 @@ import { AllProvidersFailedError, runRawCompletionWithFallback, } from "@/lib/ai/provider-runner"; +import { isLegacyInsightPayload } from "@/lib/ai/legacy-payload"; import { checkRateLimit } from "@/lib/rate-limit"; import { NextRequest } from "next/server"; import { apiHandler, requireAuth } from "@/lib/api-handler"; @@ -94,14 +95,20 @@ export const POST = apiHandler(async (request: NextRequest) => { ) { try { const cached = JSON.parse(dbUser.insightsCachedText); + // v1.4.16 B5c: detect legacy payloads (pre-rationale) so the UI + // can show a "regenerate for new explainability features" CTA. + // We don't auto-regenerate — that would burn rate-limit tokens + // on a cache-hit silently. User-initiated only. + const legacyPayload = isLegacyInsightPayload(cached); annotate({ action: { name: "insights.generate" }, - meta: { cached: true }, + meta: { cached: true, legacyPayload }, }); return apiSuccess({ insights: cached, cached: true, cachedAt: dbUser.insightsCachedAt, + legacyPayload, }); } catch { // Invalid cache, regenerate @@ -312,5 +319,5 @@ export const POST = apiHandler(async (request: NextRequest) => { }, }); - return apiSuccess({ insights, cached: false }); + return apiSuccess({ insights, cached: false, legacyPayload: false }); }); diff --git a/src/lib/ai/__tests__/legacy-payload-detection.test.ts b/src/lib/ai/__tests__/legacy-payload-detection.test.ts new file mode 100644 index 000000000..43974d3b7 --- /dev/null +++ b/src/lib/ai/__tests__/legacy-payload-detection.test.ts @@ -0,0 +1,130 @@ +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); + }); +}); diff --git a/src/lib/ai/legacy-payload.ts b/src/lib/ai/legacy-payload.ts new file mode 100644 index 000000000..9174bbd76 --- /dev/null +++ b/src/lib/ai/legacy-payload.ts @@ -0,0 +1,55 @@ +/** + * 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 surfaces a + * `legacyPayload: true` flag on cache-hit so the UI can render a + * "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 and surprise users who explicitly toggled cache-only). + * + * Detection is lenient on purpose: the cached payload may carry + * either the canonical `AIInsightResponse` shape (structured recs) + * or the legacy `InsightResult` shape (string-only recommendations). + * Both predate B5c rationale; both flag. + */ + +interface UnknownRec { + rationale?: unknown; +} + +/** + * Returns true when the payload is non-empty and at least one + * recommendation lacks a rationale object (or is a plain string from + * the v1.4.14/v1.4.15 InsightResult shape). + * + * - Non-object / null / undefined input → false (nothing to migrate). + * - Missing recommendations[] → false (refusal payload, not legacy). + * - Empty recommendations[] → false (no recs to migrate). + * - Any rec is a plain string → true. + * - Any rec is an object missing `rationale` → true. + * - Otherwise → false. + */ +export function isLegacyInsightPayload(payload: unknown): boolean { + if (payload === null || payload === undefined) return false; + if (typeof payload !== "object") return false; + const recs = (payload as { recommendations?: unknown }).recommendations; + if (!Array.isArray(recs)) return false; + if (recs.length === 0) return false; + for (const rec of recs) { + // Legacy InsightResult shape: string-only recommendations[] + if (typeof rec === "string") return true; + // Structured rec: rationale must be a populated object. + if (rec === null || typeof rec !== "object") return true; + const rationale = (rec as UnknownRec).rationale; + if ( + rationale === undefined || + rationale === null || + typeof rationale !== "object" + ) { + return true; + } + } + return false; +}