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 });
});