mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
13f1ae5c0b
commit
c8b30c112d
3 files changed
+194
-2
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user