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>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-10 01:17:15 +02:00
1 parent 13f1ae5c0b
commit c8b30c112d
3 files changed
+194 -2

No files matched your search

+9 -2
View File
@@ -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 });
});
@@ -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);
});
});
+55
View File
@@ -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;
}