fix(insights): stop caching the timeout fallback and align the status timeout with the coach

The per-card status path capped provider generation at 20 s and, on
timeout, persisted the generic no-key fallback as a day-keyed cache row.
The cache-read only checked the day key and text, so that stub looked
like a real assessment and stuck until midnight — hiding the real,
data-driven text for the rest of the day on any account that hit a
single transient stall.

Align the budget with the provider clients' 60 s ceiling, route the
status path through resolveProviderChain + runRawCompletionWithFallback
so a degraded provider cascades like the coach, and stop persisting the
fallback: a timeout or error is a transient miss that serves the
fallback for the current render and re-attempts on the next mount. A
shared cache-read rejects any lingering timeout-stub rows by their
markers, and withTimeout now annotates real errors instead of folding
them silently into the timeout path.
This commit is contained in:
Marc-André Bombeck committed 2026-05-31 21:51:18 +02:00
1 parent 16b4768fd1
commit 5432063f5c
21 files changed
+1323 -1445

No files matched your search

@@ -11,8 +11,8 @@ vi.mock("@/lib/db", () => ({
},
}));
vi.mock("@/lib/ai/provider", () => ({
resolveProvider: vi.fn(),
vi.mock("@/lib/insights/status-provider", () => ({
runStatusCompletion: vi.fn(),
}));
vi.mock("@/lib/insights/memory", () => ({
@@ -25,12 +25,30 @@ vi.mock("@/lib/medication-category", () => ({
}));
import { prisma } from "@/lib/db";
import { resolveProvider } from "@/lib/ai/provider";
import { runStatusCompletion } from "@/lib/insights/status-provider";
import { getMedicationCategories } from "@/lib/medication-category";
import { generateBloodPressureStatusForUser } from "../blood-pressure-status";
const dayMs = 24 * 60 * 60 * 1000;
function stubCompletion(
content: string,
capture?: { userPrompt: string | null },
) {
vi.mocked(runStatusCompletion).mockImplementation(
async (args: { userPrompt: string }) => {
if (capture) capture.userPrompt = args.userPrompt;
return {
kind: "ok",
content,
providerType: "anthropic",
model: "x",
tokensUsed: 1,
} as never;
},
);
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getMedicationCategories).mockResolvedValue({});
@@ -40,7 +58,6 @@ describe("generateBloodPressureStatusForUser — v1.4.6 bucketed payload", () =>
it("emits {daily, monthly} per metric series with correct field shape", async () => {
const now = new Date();
// 3 years of SYS data — covers daily window + monthly window.
const records: Array<{
type: string;
value: number;
@@ -74,17 +91,7 @@ describe("generateBloodPressureStatusForUser — v1.4.6 bucketed payload", () =>
} as never);
const captured: { userPrompt: string | null } = { userPrompt: null };
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: vi.fn(async (args: { userPrompt: string }) => {
captured.userPrompt = args.userPrompt;
return {
content: '{"summary":"OK"}',
model: "x",
tokensUsed: 1,
};
}),
} as never);
stubCompletion('{"summary":"OK"}', captured);
await generateBloodPressureStatusForUser("user-1", { locale: "en" });
@@ -109,8 +116,8 @@ describe("generateBloodPressureStatusForUser — v1.4.6 bucketed payload", () =>
});
});
describe("generateBloodPressureStatusForUser — v1.4.41 timeout-stub persistence", () => {
it("persists a sentinel row keyed to today when the provider times out", async () => {
describe("generateBloodPressureStatusForUser — timeout/error never persists", () => {
it("serves the fallback without writing a cache row on timeout", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dateOfBirth: null,
} as never);
@@ -128,87 +135,65 @@ describe("generateBloodPressureStatusForUser — v1.4.41 timeout-stub persistenc
createdAt: new Date(),
} as never);
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: vi.fn(
() =>
new Promise(() => {
/* never resolves */
}),
),
} as never);
vi.useFakeTimers();
const promise = generateBloodPressureStatusForUser("user-1", {
locale: "en",
});
await vi.advanceTimersByTimeAsync(25_000);
const result = await promise;
vi.useRealTimers();
expect(result.text).toBeTruthy();
expect(result.cached).toBe(true);
expect(result.updatedAt).toBeTruthy();
const createCalls = vi.mocked(prisma.auditLog.create).mock.calls;
expect(createCalls.length).toBe(1);
const details = (createCalls[0][0] as { data: { details: string } }).data
.details;
const parsed = JSON.parse(details) as {
dateKey?: string;
text?: string;
timeout?: boolean;
model?: string;
};
expect(parsed.text).toBeTruthy();
expect(parsed.timeout).toBe(true);
expect(parsed.model).toBe("timeout-stub");
expect(parsed.dateKey).toMatch(/^\d{4}-\d{2}-\d{2}$/);
});
it("subsequent mounts short-circuit at the cache lookup and skip the race", async () => {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: "Europe/Berlin",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(new Date());
const y = parts.find((p) => p.type === "year")!.value;
const m = parts.find((p) => p.type === "month")!.value;
const d = parts.find((p) => p.type === "day")!.value;
const todayKey = `${y}-${m}-${d}`;
const stubRow = {
createdAt: new Date(),
details: JSON.stringify({
dateKey: todayKey,
locale: "en",
text: "Blood pressure assessment fallback…",
timeout: true,
}),
};
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dateOfBirth: null,
} as never);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(stubRow as never);
const providerCall = vi.fn();
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: providerCall,
vi.mocked(runStatusCompletion).mockResolvedValue({
kind: "timeout",
} as never);
const result = await generateBloodPressureStatusForUser("user-1", {
locale: "en",
});
expect(providerCall).not.toHaveBeenCalled();
expect(result.text).toBe("Blood pressure assessment fallback…");
expect(result.text).toBeTruthy();
expect(result.cached).toBe(true);
expect(result.updatedAt).toBe(stubRow.createdAt.toISOString());
expect(result.updatedAt).toBeNull();
expect(prisma.auditLog.create).not.toHaveBeenCalled();
});
});
describe("generateBloodPressureStatusForUser — cache-read skips a stub", () => {
it("regenerates when the only cached row is a timeout stub", async () => {
const now = new Date();
const todayKey = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Berlin",
}).format(now);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dateOfBirth: null,
} as never);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({
createdAt: now,
details: JSON.stringify({
dateKey: todayKey,
locale: "en",
text: "Blood pressure assessment fallback…",
model: "timeout-stub",
timeout: true,
}),
} as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ type: "BLOOD_PRESSURE_SYS", value: 132, measuredAt: now },
{ type: "BLOOD_PRESSURE_DIA", value: 84, measuredAt: now },
] as never);
vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: now,
} as never);
stubCompletion('{"summary":"Fresh BP assessment."}');
const result = await generateBloodPressureStatusForUser("user-1", {
locale: "en",
});
expect(runStatusCompletion).toHaveBeenCalledTimes(1);
expect(result.text).toBe("Fresh BP assessment.");
expect(result.cached).toBe(false);
});
});
describe("generateBloodPressureStatusForUser — token-leak hardening (v1.4.27 F16)", () => {
it("strips metric: tokens out of the cached text before persisting", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
@@ -228,15 +213,9 @@ describe("generateBloodPressureStatusForUser — token-leak hardening (v1.4.27 F
createdAt: new Date(),
} as never);
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: vi.fn(async () => ({
content:
'{"summary":"Systolic averaged 132. metric:BLOOD_PRESSURE_SYS Diastolic stable. metric:BLOOD_PRESSURE_DIA"}',
model: "x",
tokensUsed: 1,
})),
} as never);
stubCompletion(
'{"summary":"Systolic averaged 132. metric:BLOOD_PRESSURE_SYS Diastolic stable. metric:BLOOD_PRESSURE_DIA"}',
);
const result = await generateBloodPressureStatusForUser("user-1", {
locale: "en",
+92 -123
View File
@@ -8,8 +8,8 @@ vi.mock("@/lib/db", () => ({
},
}));
vi.mock("@/lib/ai/provider", () => ({
resolveProvider: vi.fn(),
vi.mock("@/lib/insights/status-provider", () => ({
runStatusCompletion: vi.fn(),
}));
vi.mock("@/lib/insights/memory", () => ({
@@ -18,11 +18,30 @@ vi.mock("@/lib/insights/memory", () => ({
}));
import { prisma } from "@/lib/db";
import { resolveProvider } from "@/lib/ai/provider";
import { runStatusCompletion } from "@/lib/insights/status-provider";
import { generateBmiStatusForUser } from "../bmi-status";
const dayMs = 24 * 60 * 60 * 1000;
/** A `runStatusCompletion` stub returning fixed content, capturing the prompt. */
function stubCompletion(
content: string,
capture?: { userPrompt: string | null },
) {
vi.mocked(runStatusCompletion).mockImplementation(
async (args: { userPrompt: string }) => {
if (capture) capture.userPrompt = args.userPrompt;
return {
kind: "ok",
content,
providerType: "anthropic",
model: "x",
tokensUsed: 1,
} as never;
},
);
}
beforeEach(() => {
vi.resetAllMocks();
});
@@ -48,17 +67,7 @@ describe("generateBmiStatusForUser — v1.4.6 bucketed payload", () => {
} as never);
const captured: { userPrompt: string | null } = { userPrompt: null };
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: vi.fn(async (args: { userPrompt: string }) => {
captured.userPrompt = args.userPrompt;
return {
content: '{"summary":"OK"}',
model: "x",
tokensUsed: 1,
};
}),
} as never);
stubCompletion('{"summary":"OK"}', captured);
await generateBmiStatusForUser("user-1", { locale: "en" });
@@ -79,8 +88,8 @@ describe("generateBmiStatusForUser — v1.4.6 bucketed payload", () => {
});
});
describe("generateBmiStatusForUser — v1.4.37 timeout-stub persistence", () => {
it("persists a sentinel row keyed to today when the provider times out", async () => {
describe("generateBmiStatusForUser — timeout/error never persists", () => {
it("serves the fallback without writing a cache row on timeout", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
heightCm: 175,
} as never);
@@ -92,111 +101,77 @@ describe("generateBmiStatusForUser — v1.4.37 timeout-stub persistence", () =>
createdAt: new Date(),
} as never);
// Simulate a provider that never resolves before the timeout. The
// helper races the call against `STATUS_PROVIDER_TIMEOUT_MS`; a
// promise that hangs forever cuts the race short.
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: vi.fn(
() => new Promise(() => {
/* never resolves */
}),
),
} as never);
// Shrink the timeout window so this test does not hang the suite
// for the full 20 s. The withTimeout helper polls in ~1 s
// increments; this still exercises the same race shape.
vi.useFakeTimers();
const promise = generateBmiStatusForUser("user-1", { locale: "en" });
await vi.advanceTimersByTimeAsync(25_000);
const result = await promise;
vi.useRealTimers();
// The user-facing payload still reads the deterministic no-key
// fallback, but the cached/updatedAt fields signal "this is a
// stub" so the next mount short-circuits at the cache lookup.
expect(result.text).toBeTruthy();
expect(result.cached).toBe(true);
expect(result.updatedAt).toBeTruthy();
const createCalls = vi.mocked(prisma.auditLog.create).mock.calls;
expect(createCalls.length).toBe(1);
const details = (createCalls[0][0] as { data: { details: string } }).data
.details;
const parsed = JSON.parse(details) as {
dateKey?: string;
text?: string;
timeout?: boolean;
model?: string;
};
expect(parsed.text).toBeTruthy();
expect(parsed.timeout).toBe(true);
expect(parsed.model).toBe("timeout-stub");
// The dateKey must read as a valid YYYY-MM-DD so the cache hit
// logic on the next mount matches against today's berlin key.
expect(parsed.dateKey).toMatch(/^\d{4}-\d{2}-\d{2}$/);
});
it("subsequent mounts short-circuit at the cache lookup and skip the race", async () => {
// First mount: cache miss, provider times out, stub persisted.
// Simulate that prior state by handing back the stub row from
// findFirst on the second invocation.
const stubRow = {
createdAt: new Date(),
details: JSON.stringify({
dateKey: new Intl.DateTimeFormat("en-US", {
timeZone: "Europe/Berlin",
year: "numeric",
month: "2-digit",
day: "2-digit",
})
.formatToParts(new Date())
.reduce<Record<string, string>>((acc, p) => {
if (p.type !== "literal") acc[p.type] = p.value;
return acc;
}, {}),
locale: "en",
text: "BMI is a directional metric…",
timeout: true,
}),
};
// Build a real YYYY-MM-DD string the same way the source helper
// does so the cache-key match is exact.
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: "Europe/Berlin",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(new Date());
const y = parts.find((p) => p.type === "year")!.value;
const m = parts.find((p) => p.type === "month")!.value;
const d = parts.find((p) => p.type === "day")!.value;
const todayKey = `${y}-${m}-${d}`;
stubRow.details = JSON.stringify({
dateKey: todayKey,
locale: "en",
text: "BMI is a directional metric…",
timeout: true,
});
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(stubRow as never);
const providerCall = vi.fn();
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: providerCall,
vi.mocked(runStatusCompletion).mockResolvedValue({
kind: "timeout",
} as never);
const result = await generateBmiStatusForUser("user-1", { locale: "en" });
// Cache short-circuit fires before the provider call — no race,
// no second persist.
expect(providerCall).not.toHaveBeenCalled();
expect(result.text).toBe("BMI is a directional metric…");
// The user-facing payload reads the deterministic fallback, but
// NOTHING is persisted — so the next mount re-attempts a real
// generation instead of sticking the fallback for the day.
expect(result.text).toBeTruthy();
expect(result.cached).toBe(true);
expect(result.updatedAt).toBe(stubRow.createdAt.toISOString());
expect(result.updatedAt).toBeNull();
expect(prisma.auditLog.create).not.toHaveBeenCalled();
});
it("serves the fallback without writing a cache row on provider error", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
heightCm: 175,
} as never);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ value: 80, measuredAt: new Date() },
] as never);
vi.mocked(runStatusCompletion).mockResolvedValue({
kind: "error",
} as never);
const result = await generateBmiStatusForUser("user-1", { locale: "en" });
expect(result.text).toBeTruthy();
expect(result.updatedAt).toBeNull();
expect(prisma.auditLog.create).not.toHaveBeenCalled();
});
});
describe("generateBmiStatusForUser — cache-read skips a stub", () => {
it("regenerates when the only cached row is a timeout stub", async () => {
const now = new Date();
const todayKey = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Berlin",
}).format(now);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
heightCm: 175,
} as never);
// The most recent cached row is a stub keyed to today.
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({
createdAt: now,
details: JSON.stringify({
dateKey: todayKey,
locale: "en",
text: "Generic stub fallback.",
model: "timeout-stub",
timeout: true,
}),
} as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ value: 80, measuredAt: now },
] as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: now,
} as never);
stubCompletion('{"summary":"Fresh real assessment."}');
const result = await generateBmiStatusForUser("user-1", { locale: "en" });
// The stub must NOT be served — a fresh generation runs instead.
expect(runStatusCompletion).toHaveBeenCalledTimes(1);
expect(result.text).toBe("Fresh real assessment.");
expect(result.cached).toBe(false);
});
});
describe("generateBmiStatusForUser — token-leak hardening (v1.4.27 F16)", () => {
@@ -212,15 +187,9 @@ describe("generateBmiStatusForUser — token-leak hardening (v1.4.27 F16)", () =
createdAt: new Date(),
} as never);
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: vi.fn(async () => ({
content:
'{"summary":"BMI sits in the green band. metric:WEIGHT and steady."}',
model: "x",
tokensUsed: 1,
})),
} as never);
stubCompletion(
'{"summary":"BMI sits in the green band. metric:WEIGHT and steady."}',
);
const result = await generateBmiStatusForUser("user-1", { locale: "en" });
@@ -10,8 +10,8 @@ vi.mock("@/lib/db", () => ({
},
}));
vi.mock("@/lib/ai/provider", () => ({
resolveProvider: vi.fn(),
vi.mock("@/lib/insights/status-provider", () => ({
runStatusCompletion: vi.fn(),
}));
vi.mock("@/lib/insights/memory", () => ({
@@ -20,11 +20,29 @@ vi.mock("@/lib/insights/memory", () => ({
}));
import { prisma } from "@/lib/db";
import { resolveProvider } from "@/lib/ai/provider";
import { runStatusCompletion } from "@/lib/insights/status-provider";
import { generateGeneralStatusForUser } from "../general-status";
const dayMs = 24 * 60 * 60 * 1000;
function stubCompletion(
content: string,
capture?: { userPrompt: string | null },
) {
vi.mocked(runStatusCompletion).mockImplementation(
async (args: { userPrompt: string }) => {
if (capture) capture.userPrompt = args.userPrompt;
return {
kind: "ok",
content,
providerType: "anthropic",
model: "x",
tokensUsed: 1,
} as never;
},
);
}
beforeEach(() => {
vi.resetAllMocks();
});
@@ -33,8 +51,6 @@ describe("generateGeneralStatusForUser — v1.4.6 bucketed payload", () => {
it("emits {daily, monthly} per metric series with dayOffset/monthOffset/value/n", async () => {
const now = new Date();
// 3 years of weight data — daily across the whole window so the
// bucketed output covers months 1-12 (daily) and 13-36 (monthly).
const weightRecords: Array<{
type: string;
value: number;
@@ -64,22 +80,11 @@ describe("generateGeneralStatusForUser — v1.4.6 bucketed payload", () => {
} as never);
const captured: { userPrompt: string | null } = { userPrompt: null };
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: vi.fn(async (args: { userPrompt: string }) => {
captured.userPrompt = args.userPrompt;
return {
content: '{"summary":"OK"}',
model: "x",
tokensUsed: 1,
};
}),
} as never);
stubCompletion('{"summary":"OK"}', captured);
await generateGeneralStatusForUser("user-1", { locale: "en" });
expect(captured.userPrompt).not.toBeNull();
// The snapshot JSON is embedded in the user prompt as a fenced block.
const match = captured.userPrompt!.match(/\{[\s\S]*\}/);
expect(match).not.toBeNull();
const snapshot = JSON.parse(match![0]);
@@ -100,8 +105,8 @@ describe("generateGeneralStatusForUser — v1.4.6 bucketed payload", () => {
});
});
describe("generateGeneralStatusForUser — v1.4.41 timeout-stub persistence", () => {
it("persists a sentinel row keyed to today when the provider times out", async () => {
describe("generateGeneralStatusForUser — timeout/error never persists", () => {
it("serves the fallback without writing a cache row on timeout", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dateOfBirth: null,
} as never);
@@ -117,85 +122,63 @@ describe("generateGeneralStatusForUser — v1.4.41 timeout-stub persistence", ()
createdAt: new Date(),
} as never);
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: vi.fn(
() =>
new Promise(() => {
/* never resolves */
}),
),
} as never);
vi.useFakeTimers();
const promise = generateGeneralStatusForUser("user-1", { locale: "en" });
await vi.advanceTimersByTimeAsync(25_000);
const result = await promise;
vi.useRealTimers();
expect(result.text).toBeTruthy();
expect(result.cached).toBe(true);
expect(result.updatedAt).toBeTruthy();
const createCalls = vi.mocked(prisma.auditLog.create).mock.calls;
expect(createCalls.length).toBe(1);
const details = (createCalls[0][0] as { data: { details: string } }).data
.details;
const parsed = JSON.parse(details) as {
dateKey?: string;
text?: string;
timeout?: boolean;
model?: string;
};
expect(parsed.text).toBeTruthy();
expect(parsed.timeout).toBe(true);
expect(parsed.model).toBe("timeout-stub");
expect(parsed.dateKey).toMatch(/^\d{4}-\d{2}-\d{2}$/);
});
it("subsequent mounts short-circuit at the cache lookup and skip the race", async () => {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: "Europe/Berlin",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(new Date());
const y = parts.find((p) => p.type === "year")!.value;
const m = parts.find((p) => p.type === "month")!.value;
const d = parts.find((p) => p.type === "day")!.value;
const todayKey = `${y}-${m}-${d}`;
const stubRow = {
createdAt: new Date(),
details: JSON.stringify({
dateKey: todayKey,
locale: "en",
text: "General status fallback…",
timeout: true,
}),
};
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dateOfBirth: null,
} as never);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(stubRow as never);
const providerCall = vi.fn();
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: providerCall,
vi.mocked(runStatusCompletion).mockResolvedValue({
kind: "timeout",
} as never);
const result = await generateGeneralStatusForUser("user-1", {
locale: "en",
});
expect(providerCall).not.toHaveBeenCalled();
expect(result.text).toBe("General status fallback…");
expect(result.text).toBeTruthy();
expect(result.cached).toBe(true);
expect(result.updatedAt).toBe(stubRow.createdAt.toISOString());
expect(result.updatedAt).toBeNull();
expect(prisma.auditLog.create).not.toHaveBeenCalled();
});
});
describe("generateGeneralStatusForUser — cache-read skips a stub", () => {
it("regenerates when the only cached row is a timeout stub", async () => {
const now = new Date();
const todayKey = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Berlin",
}).format(now);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dateOfBirth: null,
} as never);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({
createdAt: now,
details: JSON.stringify({
dateKey: todayKey,
locale: "en",
text: "General status fallback…",
model: "timeout-stub",
timeout: true,
}),
} as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ type: "WEIGHT", value: 82, measuredAt: now },
] as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: now,
} as never);
stubCompletion('{"summary":"Fresh general assessment."}');
const result = await generateGeneralStatusForUser("user-1", {
locale: "en",
});
expect(runStatusCompletion).toHaveBeenCalledTimes(1);
expect(result.text).toBe("Fresh general assessment.");
expect(result.cached).toBe(false);
});
});
describe("generateGeneralStatusForUser — token-leak hardening (v1.4.27 F16)", () => {
it("strips metric: tokens out of the cached text before persisting", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
@@ -213,15 +196,9 @@ describe("generateGeneralStatusForUser — token-leak hardening (v1.4.27 F16)",
createdAt: new Date(),
} as never);
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: vi.fn(async () => ({
content:
'{"summary":"Weight trended down. metric:WEIGHT BP stable. metric:BLOOD_PRESSURE_SYS"}',
model: "x",
tokensUsed: 1,
})),
} as never);
stubCompletion(
'{"summary":"Weight trended down. metric:WEIGHT BP stable. metric:BLOOD_PRESSURE_SYS"}',
);
const result = await generateGeneralStatusForUser("user-1", {
locale: "en",
@@ -8,8 +8,8 @@ vi.mock("@/lib/db", () => ({
},
}));
vi.mock("@/lib/ai/provider", () => ({
resolveProvider: vi.fn(),
vi.mock("@/lib/insights/status-provider", () => ({
runStatusCompletion: vi.fn(),
}));
vi.mock("@/lib/insights/memory", () => ({
@@ -22,12 +22,43 @@ vi.mock("@/lib/medication-category", () => ({
}));
import { prisma } from "@/lib/db";
import { resolveProvider } from "@/lib/ai/provider";
import { runStatusCompletion } from "@/lib/insights/status-provider";
import { getMedicationCategories } from "@/lib/medication-category";
import { generateMedicationComplianceStatusForUser } from "../medication-compliance-status";
const dayMs = 24 * 60 * 60 * 1000;
function stubCompletion(
content: string,
capture?: { userPrompt: string | null },
) {
vi.mocked(runStatusCompletion).mockImplementation(
async (args: { userPrompt: string }) => {
if (capture) capture.userPrompt = args.userPrompt;
return {
kind: "ok",
content,
providerType: "anthropic",
model: "x",
tokensUsed: 1,
} as never;
},
);
}
function medFixture(now: Date) {
return {
id: "med-1",
name: "Ramipril",
dose: "5mg",
active: true,
createdAt: new Date(now.getTime() - 60 * dayMs),
schedules: [
{ id: "s1", windowStart: "08:00", windowEnd: "09:00", daysOfWeek: null },
],
};
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getMedicationCategories).mockResolvedValue({});
@@ -42,7 +73,9 @@ describe("generateMedicationComplianceStatusForUser — v1.4.6 bucketed payload"
dose: "5mg",
active: true,
createdAt: new Date(now.getTime() - 1100 * dayMs),
schedules: [{ id: "s1", windowStart: "08:00", windowEnd: "09:00", daysOfWeek: null }],
schedules: [
{ id: "s1", windowStart: "08:00", windowEnd: "09:00", daysOfWeek: null },
],
};
const events: Array<{
@@ -73,17 +106,7 @@ describe("generateMedicationComplianceStatusForUser — v1.4.6 bucketed payload"
} as never);
const captured: { userPrompt: string | null } = { userPrompt: null };
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: vi.fn(async (args: { userPrompt: string }) => {
captured.userPrompt = args.userPrompt;
return {
content: '{"summary":"OK","medications":[]}',
model: "x",
tokensUsed: 1,
};
}),
} as never);
stubCompletion('{"summary":"OK","medications":[]}', captured);
await generateMedicationComplianceStatusForUser("user-1", {
locale: "en",
@@ -108,129 +131,81 @@ describe("generateMedicationComplianceStatusForUser — v1.4.6 bucketed payload"
});
});
describe("generateMedicationComplianceStatusForUser — v1.4.41 timeout-stub persistence", () => {
it("persists a sentinel row keyed to today when the provider times out", async () => {
describe("generateMedicationComplianceStatusForUser — timeout never persists", () => {
it("serves the fallback summary without writing a cache row on timeout", async () => {
const now = new Date();
const medication = {
id: "med-1",
name: "Ramipril",
dose: "5mg",
active: true,
createdAt: new Date(now.getTime() - 60 * dayMs),
schedules: [{ id: "s1", windowStart: "08:00", windowEnd: "09:00", daysOfWeek: null }],
};
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
vi.mocked(prisma.medication.findMany).mockResolvedValue([
medication,
medFixture(now),
] as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: new Date(),
createdAt: now,
} as never);
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: vi.fn(
() =>
new Promise(() => {
/* never resolves */
}),
),
} as never);
vi.useFakeTimers();
const promise = generateMedicationComplianceStatusForUser("user-1", {
locale: "en",
});
await vi.advanceTimersByTimeAsync(25_000);
const result = await promise;
vi.useRealTimers();
// Route returns the richer `{summary, medications}` shape — the
// helper writes the persist row and the route maps `summary` ←
// `text` on the way out.
expect(result.summary).toBeTruthy();
expect(result.medications).toEqual([]);
expect(result.cached).toBe(true);
expect(result.updatedAt).toBeTruthy();
const createCalls = vi.mocked(prisma.auditLog.create).mock.calls;
expect(createCalls.length).toBe(1);
const details = (createCalls[0][0] as { data: { details: string } }).data
.details;
const parsed = JSON.parse(details) as {
dateKey?: string;
text?: string;
timeout?: boolean;
model?: string;
};
expect(parsed.text).toBeTruthy();
expect(parsed.timeout).toBe(true);
expect(parsed.model).toBe("timeout-stub");
expect(parsed.dateKey).toMatch(/^\d{4}-\d{2}-\d{2}$/);
});
});
describe("generateMedicationComplianceStatusForUser — v1.4.41 timeout-stub short-circuit", () => {
it("subsequent mounts short-circuit when a stub row with text+timeout:true exists", async () => {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: "Europe/Berlin",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(new Date());
const y = parts.find((p) => p.type === "year")!.value;
const m = parts.find((p) => p.type === "month")!.value;
const d = parts.find((p) => p.type === "day")!.value;
const todayKey = `${y}-${m}-${d}`;
const stubRow = {
createdAt: new Date(),
details: JSON.stringify({
dateKey: todayKey,
locale: "en",
text: "Medication compliance fallback…",
timeout: true,
}),
};
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(stubRow as never);
const providerCall = vi.fn();
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: providerCall,
vi.mocked(runStatusCompletion).mockResolvedValue({
kind: "timeout",
} as never);
const result = await generateMedicationComplianceStatusForUser("user-1", {
locale: "en",
});
expect(providerCall).not.toHaveBeenCalled();
expect(result.summary).toBe("Medication compliance fallback…");
expect(result.summary).toBeTruthy();
expect(result.medications).toEqual([]);
expect(result.cached).toBe(true);
expect(result.updatedAt).toBe(stubRow.createdAt.toISOString());
expect(result.updatedAt).toBeNull();
expect(prisma.auditLog.create).not.toHaveBeenCalled();
});
});
describe("generateMedicationComplianceStatusForUser — cache-read skips a stub", () => {
it("regenerates when the only cached row is a timeout stub", async () => {
const now = new Date();
const todayKey = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Berlin",
}).format(now);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({
createdAt: now,
details: JSON.stringify({
dateKey: todayKey,
locale: "en",
text: "Medication compliance fallback…",
model: "timeout-stub",
timeout: true,
}),
} as never);
vi.mocked(prisma.medication.findMany).mockResolvedValue([
medFixture(now),
] as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: now,
} as never);
stubCompletion('{"summary":"Fresh compliance assessment.","medications":[]}');
const result = await generateMedicationComplianceStatusForUser("user-1", {
locale: "en",
});
expect(runStatusCompletion).toHaveBeenCalledTimes(1);
expect(result.summary).toBe("Fresh compliance assessment.");
expect(result.cached).toBe(false);
});
});
describe("generateMedicationComplianceStatusForUser — token-leak hardening (v1.4.27 F16)", () => {
it("strips metric: tokens out of the cached summary + per-medication text", async () => {
const now = new Date();
const medication = {
id: "med-1",
name: "Ramipril",
dose: "5mg",
active: true,
createdAt: new Date(now.getTime() - 60 * dayMs),
schedules: [{ id: "s1", windowStart: "08:00", windowEnd: "09:00", daysOfWeek: null }],
};
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
vi.mocked(prisma.medication.findMany).mockResolvedValue([
medication,
medFixture(now),
] as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
@@ -239,24 +214,19 @@ describe("generateMedicationComplianceStatusForUser — token-leak hardening (v1
createdAt: new Date(),
} as never);
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: vi.fn(async () => ({
content: JSON.stringify({
summary:
"Compliance held steady at 96%. metric:BLOOD_PRESSURE_SYS over the last 30 days.",
medications: [
{
medicationId: "med-1",
summary:
"Ramipril taken on schedule. metric:PULSE no missed doses in 30 days.",
},
],
}),
model: "x",
tokensUsed: 1,
})),
} as never);
stubCompletion(
JSON.stringify({
summary:
"Compliance held steady at 96%. metric:BLOOD_PRESSURE_SYS over the last 30 days.",
medications: [
{
medicationId: "med-1",
summary:
"Ramipril taken on schedule. metric:PULSE no missed doses in 30 days.",
},
],
}),
);
const result = await generateMedicationComplianceStatusForUser("user-1", {
locale: "en",
+64 -98
View File
@@ -8,8 +8,8 @@ vi.mock("@/lib/db", () => ({
},
}));
vi.mock("@/lib/ai/provider", () => ({
resolveProvider: vi.fn(),
vi.mock("@/lib/insights/status-provider", () => ({
runStatusCompletion: vi.fn(),
}));
vi.mock("@/lib/insights/memory", () => ({
@@ -18,11 +18,29 @@ vi.mock("@/lib/insights/memory", () => ({
}));
import { prisma } from "@/lib/db";
import { resolveProvider } from "@/lib/ai/provider";
import { runStatusCompletion } from "@/lib/insights/status-provider";
import { generateMoodStatusForUser } from "../mood-status";
const dayMs = 24 * 60 * 60 * 1000;
function stubCompletion(
content: string,
capture?: { userPrompt: string | null },
) {
vi.mocked(runStatusCompletion).mockImplementation(
async (args: { userPrompt: string }) => {
if (capture) capture.userPrompt = args.userPrompt;
return {
kind: "ok",
content,
providerType: "anthropic",
model: "x",
tokensUsed: 1,
} as never;
},
);
}
beforeEach(() => {
vi.resetAllMocks();
});
@@ -39,9 +57,6 @@ describe("generateMoodStatusForUser — v1.4.6 bucketed payload", () => {
for (let day = 0; day < 1000; day++) {
const t = new Date(now.getTime() - day * dayMs);
entries.push({
// production code keys mood buckets by `moodLoggedAt`, so the
// `date` string here is irrelevant for bucketing — keep it
// ISO-shaped so other downstream consumers don't choke.
date: t.toISOString().slice(0, 10),
score: 3 + (day % 3) * 0.5,
tags: [],
@@ -57,17 +72,7 @@ describe("generateMoodStatusForUser — v1.4.6 bucketed payload", () => {
} as never);
const captured: { userPrompt: string | null } = { userPrompt: null };
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: vi.fn(async (args: { userPrompt: string }) => {
captured.userPrompt = args.userPrompt;
return {
content: '{"summary":"OK"}',
model: "x",
tokensUsed: 1,
};
}),
} as never);
stubCompletion('{"summary":"OK"}', captured);
await generateMoodStatusForUser("user-1", { locale: "en" });
@@ -88,97 +93,65 @@ describe("generateMoodStatusForUser — v1.4.6 bucketed payload", () => {
});
});
describe("generateMoodStatusForUser — v1.4.41 timeout-stub persistence", () => {
it("persists a sentinel row keyed to today when the provider times out", async () => {
describe("generateMoodStatusForUser — timeout/error never persists", () => {
it("serves the fallback without writing a cache row on timeout", async () => {
const t = new Date();
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([
{
date: t.toISOString().slice(0, 10),
score: 4,
tags: [],
moodLoggedAt: t,
},
{ date: t.toISOString().slice(0, 10), score: 4, tags: [], moodLoggedAt: t },
] as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: new Date(),
} as never);
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: vi.fn(
() =>
new Promise(() => {
/* never resolves */
}),
),
} as never);
vi.useFakeTimers();
const promise = generateMoodStatusForUser("user-1", { locale: "en" });
await vi.advanceTimersByTimeAsync(25_000);
const result = await promise;
vi.useRealTimers();
expect(result.text).toBeTruthy();
expect(result.cached).toBe(true);
expect(result.updatedAt).toBeTruthy();
const createCalls = vi.mocked(prisma.auditLog.create).mock.calls;
expect(createCalls.length).toBe(1);
const details = (createCalls[0][0] as { data: { details: string } }).data
.details;
const parsed = JSON.parse(details) as {
dateKey?: string;
text?: string;
timeout?: boolean;
model?: string;
};
expect(parsed.text).toBeTruthy();
expect(parsed.timeout).toBe(true);
expect(parsed.model).toBe("timeout-stub");
expect(parsed.dateKey).toMatch(/^\d{4}-\d{2}-\d{2}$/);
});
it("subsequent mounts short-circuit at the cache lookup and skip the race", async () => {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: "Europe/Berlin",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(new Date());
const y = parts.find((p) => p.type === "year")!.value;
const m = parts.find((p) => p.type === "month")!.value;
const d = parts.find((p) => p.type === "day")!.value;
const todayKey = `${y}-${m}-${d}`;
const stubRow = {
createdAt: new Date(),
details: JSON.stringify({
dateKey: todayKey,
locale: "en",
text: "Mood fallback text…",
timeout: true,
}),
};
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(stubRow as never);
const providerCall = vi.fn();
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: providerCall,
vi.mocked(runStatusCompletion).mockResolvedValue({
kind: "timeout",
} as never);
const result = await generateMoodStatusForUser("user-1", { locale: "en" });
expect(providerCall).not.toHaveBeenCalled();
expect(result.text).toBe("Mood fallback text…");
expect(result.text).toBeTruthy();
expect(result.cached).toBe(true);
expect(result.updatedAt).toBe(stubRow.createdAt.toISOString());
expect(result.updatedAt).toBeNull();
expect(prisma.auditLog.create).not.toHaveBeenCalled();
});
});
describe("generateMoodStatusForUser — cache-read skips a stub", () => {
it("regenerates when the only cached row is a timeout stub", async () => {
const now = new Date();
const todayKey = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Berlin",
}).format(now);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({
createdAt: now,
details: JSON.stringify({
dateKey: todayKey,
locale: "en",
text: "Mood fallback text…",
model: "timeout-stub",
timeout: true,
}),
} as never);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([
{ date: now.toISOString().slice(0, 10), score: 4, tags: [], moodLoggedAt: now },
] as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: now,
} as never);
stubCompletion('{"summary":"Fresh mood assessment."}');
const result = await generateMoodStatusForUser("user-1", { locale: "en" });
expect(runStatusCompletion).toHaveBeenCalledTimes(1);
expect(result.text).toBe("Fresh mood assessment.");
expect(result.cached).toBe(false);
});
});
describe("generateMoodStatusForUser — token-leak hardening (v1.4.27 F16)", () => {
it("strips metric: tokens out of the cached text before persisting", async () => {
const t = new Date();
@@ -191,14 +164,7 @@ describe("generateMoodStatusForUser — token-leak hardening (v1.4.27 F16)", ()
createdAt: new Date(),
} as never);
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: vi.fn(async () => ({
content: '{"summary":"Mood stayed positive. metric:MOOD"}',
model: "x",
tokensUsed: 1,
})),
} as never);
stubCompletion('{"summary":"Mood stayed positive. metric:MOOD"}');
const result = await generateMoodStatusForUser("user-1", { locale: "en" });
@@ -1,19 +1,15 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { describe, expect, it, vi, beforeEach } from "vitest";
/**
* v1.4.28 R3a FB-D2 — provider timeout fallback for the pulse-status
* route. When the upstream `generateCompletion` call does not resolve
* inside the 20-second budget the route returns a deterministic
* cached-style envelope so the InsightStatusCard renders the
* fallback text instead of spinning behind React-Query's default
* retries.
*
* v1.4.41 — the timeout branch now persists a sentinel keyed to
* today (see `persistTimeoutStubAndReturn`) so subsequent mounts
* short-circuit at the cache lookup. The persisted row carries
* `timeout: true` + `model: "timeout-stub"` so the daily pre-warm
* job can recognise and overwrite the stub instead of treating it
* as a real assessment.
* Provider-timeout fallback for the pulse-status route. When the
* provider chain does not resolve inside `STATUS_PROVIDER_TIMEOUT_MS`
* the route returns the deterministic no-key fallback so the
* InsightStatusCard renders instead of spinning — but it must NOT
* persist the fallback. The earlier behaviour cached a `timeout-stub`
* row keyed to today; that stub stuck until midnight and hid the real
* data-driven assessment. The fix treats a timeout as a transient miss:
* serve the fallback for this render, write nothing, re-attempt next
* mount.
*/
vi.mock("@/lib/db", () => ({
@@ -25,8 +21,8 @@ vi.mock("@/lib/db", () => ({
},
}));
vi.mock("@/lib/ai/provider", () => ({
resolveProvider: vi.fn(),
vi.mock("@/lib/insights/status-provider", () => ({
runStatusCompletion: vi.fn(),
}));
vi.mock("@/lib/insights/memory", () => ({
@@ -35,20 +31,15 @@ vi.mock("@/lib/insights/memory", () => ({
}));
import { prisma } from "@/lib/db";
import { resolveProvider } from "@/lib/ai/provider";
import { runStatusCompletion } from "@/lib/insights/status-provider";
import { generatePulseStatusForUser } from "../pulse-status";
beforeEach(() => {
vi.resetAllMocks();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
describe("generatePulseStatusForUser — provider timeout fallback", () => {
it("returns a cached-style envelope when the provider exceeds 20 s", async () => {
it("returns the fallback without persisting a cache row on timeout", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dateOfBirth: null,
gender: null,
@@ -58,40 +49,21 @@ describe("generatePulseStatusForUser — provider timeout fallback", () => {
{ value: 72, measuredAt: new Date() },
] as never);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: new Date(),
vi.mocked(runStatusCompletion).mockResolvedValue({
kind: "timeout",
} as never);
// Provider stalls indefinitely.
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: vi.fn(
() => new Promise<{ content: string }>(() => {}),
),
} as never);
const promise = generatePulseStatusForUser("user-1", { locale: "en" });
// Advance past the 20-second budget so the timeout race wins.
await vi.advanceTimersByTimeAsync(21_000);
const result = await promise;
const result = await generatePulseStatusForUser("user-1", {
locale: "en",
});
expect(result.hasProvider).toBe(true);
expect(result.cached).toBe(true);
expect(typeof result.text).toBe("string");
expect(result.text?.length ?? 0).toBeGreaterThan(0);
// v1.4.41 — the timeout branch now persists a sentinel row so
// subsequent mounts short-circuit at the cache lookup. The
// daily pre-warm worker overwrites the stub by detecting
// `timeout: true` + `model: "timeout-stub"`.
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
const createArg = vi.mocked(prisma.auditLog.create).mock.calls[0][0] as {
data: { details: string };
};
const parsed = JSON.parse(createArg.data.details) as {
timeout?: boolean;
model?: string;
};
expect(parsed.timeout).toBe(true);
expect(parsed.model).toBe("timeout-stub");
// No persisted row — the sticky-stub bug is gone.
expect(result.updatedAt).toBeNull();
expect(prisma.auditLog.create).not.toHaveBeenCalled();
});
});
+51 -58
View File
@@ -9,8 +9,8 @@ vi.mock("@/lib/db", () => ({
},
}));
vi.mock("@/lib/ai/provider", () => ({
resolveProvider: vi.fn(),
vi.mock("@/lib/insights/status-provider", () => ({
runStatusCompletion: vi.fn(),
}));
vi.mock("@/lib/insights/memory", () => ({
@@ -19,11 +19,29 @@ vi.mock("@/lib/insights/memory", () => ({
}));
import { prisma } from "@/lib/db";
import { resolveProvider } from "@/lib/ai/provider";
import { runStatusCompletion } from "@/lib/insights/status-provider";
import { generatePulseStatusForUser } from "../pulse-status";
const dayMs = 24 * 60 * 60 * 1000;
function stubCompletion(
content: string,
capture?: { userPrompt: string | null },
) {
vi.mocked(runStatusCompletion).mockImplementation(
async (args: { userPrompt: string }) => {
if (capture) capture.userPrompt = args.userPrompt;
return {
kind: "ok",
content,
providerType: "anthropic",
model: "x",
tokensUsed: 1,
} as never;
},
);
}
beforeEach(() => {
vi.resetAllMocks();
});
@@ -51,17 +69,7 @@ describe("generatePulseStatusForUser — v1.4.6 bucketed payload", () => {
} as never);
const captured: { userPrompt: string | null } = { userPrompt: null };
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: vi.fn(async (args: { userPrompt: string }) => {
captured.userPrompt = args.userPrompt;
return {
content: '{"summary":"OK"}',
model: "x",
tokensUsed: 1,
};
}),
} as never);
stubCompletion('{"summary":"OK"}', captured);
await generatePulseStatusForUser("user-1", { locale: "en" });
@@ -82,50 +90,41 @@ describe("generatePulseStatusForUser — v1.4.6 bucketed payload", () => {
});
});
describe("generatePulseStatusForUser — v1.4.41 timeout-stub short-circuit", () => {
// The persist-on-timeout path is covered by
// `pulse-status-timeout.test.ts`. This block pins the second-mount
// behaviour: when a sentinel row already exists for today the cache
// lookup short-circuits and the provider is never invoked.
it("subsequent mounts short-circuit at the cache lookup and skip the race", async () => {
const parts = new Intl.DateTimeFormat("en-US", {
describe("generatePulseStatusForUser — cache-read skips a stub", () => {
it("regenerates when the only cached row is a timeout stub", async () => {
const now = new Date();
const todayKey = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Berlin",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(new Date());
const y = parts.find((p) => p.type === "year")!.value;
const m = parts.find((p) => p.type === "month")!.value;
const d = parts.find((p) => p.type === "day")!.value;
const todayKey = `${y}-${m}-${d}`;
const stubRow = {
createdAt: new Date(),
details: JSON.stringify({
dateKey: todayKey,
locale: "en",
text: "Pulse fallback text…",
timeout: true,
}),
};
}).format(now);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dateOfBirth: null,
gender: null,
} as never);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(stubRow as never);
const providerCall = vi.fn();
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: providerCall,
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({
createdAt: now,
details: JSON.stringify({
dateKey: todayKey,
locale: "en",
text: "Pulse fallback text…",
model: "timeout-stub",
timeout: true,
}),
} as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ value: 72, measuredAt: now },
] as never);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: now,
} as never);
stubCompletion('{"summary":"Fresh pulse assessment."}');
const result = await generatePulseStatusForUser("user-1", { locale: "en" });
expect(providerCall).not.toHaveBeenCalled();
expect(result.text).toBe("Pulse fallback text…");
expect(result.cached).toBe(true);
expect(result.updatedAt).toBe(stubRow.createdAt.toISOString());
expect(prisma.auditLog.create).not.toHaveBeenCalled();
expect(runStatusCompletion).toHaveBeenCalledTimes(1);
expect(result.text).toBe("Fresh pulse assessment.");
expect(result.cached).toBe(false);
});
});
@@ -144,15 +143,9 @@ describe("generatePulseStatusForUser — token-leak hardening (v1.4.27 F16)", ()
createdAt: new Date(),
} as never);
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: vi.fn(async () => ({
content:
'{"summary":"Your pulse is stable. metric:PULSE The 7-day average sits inside the band."}',
model: "x",
tokensUsed: 1,
})),
} as never);
stubCompletion(
'{"summary":"Your pulse is stable. metric:PULSE The 7-day average sits inside the band."}',
);
const result = await generatePulseStatusForUser("user-1", { locale: "en" });
@@ -0,0 +1,123 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
auditLog: { findFirst: vi.fn() },
},
}));
import { prisma } from "@/lib/db";
import { isTimeoutStub, readFreshStatusText } from "../status-cache";
const TODAY = "2026-05-31";
function cacheRow(details: Record<string, unknown>, createdAt = new Date()) {
return { createdAt, details: JSON.stringify(details) };
}
beforeEach(() => {
vi.resetAllMocks();
});
describe("isTimeoutStub", () => {
it("flags the timeout-stub model marker", () => {
expect(isTimeoutStub({ model: "timeout-stub" })).toBe(true);
});
it("flags the timeout:true marker", () => {
expect(isTimeoutStub({ timeout: true })).toBe(true);
});
it("does not flag a real assessment", () => {
expect(isTimeoutStub({ model: "gpt-4o-mini", timeout: false })).toBe(false);
expect(isTimeoutStub({})).toBe(false);
});
});
describe("readFreshStatusText", () => {
it("returns today's real assessment text", async () => {
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(
cacheRow({
dateKey: TODAY,
text: "Your weight trend is stable.",
model: "gpt-4o-mini",
}) as never,
);
const hit = await readFreshStatusText({
userId: "u1",
cacheAction: "insights.weight-status.en",
todayKey: TODAY,
force: false,
});
expect(hit?.text).toBe("Your weight trend is stable.");
});
it("skips a timeout-stub row keyed to today", async () => {
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(
cacheRow({
dateKey: TODAY,
text: "Generic fallback advice.",
model: "timeout-stub",
timeout: true,
}) as never,
);
const hit = await readFreshStatusText({
userId: "u1",
cacheAction: "insights.weight-status.en",
todayKey: TODAY,
force: false,
});
expect(hit).toBeNull();
});
it("skips a stale-day row", async () => {
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(
cacheRow({ dateKey: "2026-05-30", text: "Yesterday." }) as never,
);
const hit = await readFreshStatusText({
userId: "u1",
cacheAction: "insights.weight-status.en",
todayKey: TODAY,
force: false,
});
expect(hit).toBeNull();
});
it("skips an empty-text row", async () => {
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(
cacheRow({ dateKey: TODAY, text: " " }) as never,
);
const hit = await readFreshStatusText({
userId: "u1",
cacheAction: "insights.weight-status.en",
todayKey: TODAY,
force: false,
});
expect(hit).toBeNull();
});
it("does not read the cache under force", async () => {
const hit = await readFreshStatusText({
userId: "u1",
cacheAction: "insights.weight-status.en",
todayKey: TODAY,
force: true,
});
expect(hit).toBeNull();
expect(prisma.auditLog.findFirst).not.toHaveBeenCalled();
});
it("treats a malformed payload as a miss", async () => {
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({
createdAt: new Date(),
details: "{not json",
} as never);
const hit = await readFreshStatusText({
userId: "u1",
cacheAction: "insights.weight-status.en",
todayKey: TODAY,
force: false,
});
expect(hit).toBeNull();
});
});
@@ -8,8 +8,8 @@ vi.mock("@/lib/db", () => ({
},
}));
vi.mock("@/lib/ai/provider", () => ({
resolveProvider: vi.fn(),
vi.mock("@/lib/insights/status-provider", () => ({
runStatusCompletion: vi.fn(),
}));
vi.mock("@/lib/insights/memory", () => ({
@@ -18,11 +18,29 @@ vi.mock("@/lib/insights/memory", () => ({
}));
import { prisma } from "@/lib/db";
import { resolveProvider } from "@/lib/ai/provider";
import { runStatusCompletion } from "@/lib/insights/status-provider";
import { generateWeightStatusForUser } from "../weight-status";
const dayMs = 24 * 60 * 60 * 1000;
function stubCompletion(
content: string,
capture?: { userPrompt: string | null },
) {
vi.mocked(runStatusCompletion).mockImplementation(
async (args: { userPrompt: string }) => {
if (capture) capture.userPrompt = args.userPrompt;
return {
kind: "ok",
content,
providerType: "anthropic",
model: "x",
tokensUsed: 1,
} as never;
},
);
}
beforeEach(() => {
vi.resetAllMocks();
});
@@ -51,17 +69,7 @@ describe("generateWeightStatusForUser — v1.4.6 bucketed payload", () => {
} as never);
const captured: { userPrompt: string | null } = { userPrompt: null };
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: vi.fn(async (args: { userPrompt: string }) => {
captured.userPrompt = args.userPrompt;
return {
content: '{"summary":"OK"}',
model: "x",
tokensUsed: 1,
};
}),
} as never);
stubCompletion('{"summary":"OK"}', captured);
await generateWeightStatusForUser("user-1", { locale: "en" });
@@ -82,8 +90,8 @@ describe("generateWeightStatusForUser — v1.4.6 bucketed payload", () => {
});
});
describe("generateWeightStatusForUser — v1.4.41 timeout-stub persistence", () => {
it("persists a sentinel row keyed to today when the provider times out", async () => {
describe("generateWeightStatusForUser — timeout/error never persists", () => {
it("serves the fallback without writing a cache row on timeout", async () => {
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ type: "WEIGHT", value: 82, measuredAt: new Date() },
@@ -93,83 +101,57 @@ describe("generateWeightStatusForUser — v1.4.41 timeout-stub persistence", ()
createdAt: new Date(),
} as never);
// Provider stalls indefinitely so the timeout race wins.
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: vi.fn(
() =>
new Promise(() => {
/* never resolves */
}),
),
} as never);
vi.useFakeTimers();
const promise = generateWeightStatusForUser("user-1", { locale: "en" });
await vi.advanceTimersByTimeAsync(25_000);
const result = await promise;
vi.useRealTimers();
expect(result.text).toBeTruthy();
expect(result.cached).toBe(true);
expect(result.updatedAt).toBeTruthy();
const createCalls = vi.mocked(prisma.auditLog.create).mock.calls;
expect(createCalls.length).toBe(1);
const details = (createCalls[0][0] as { data: { details: string } }).data
.details;
const parsed = JSON.parse(details) as {
dateKey?: string;
text?: string;
timeout?: boolean;
model?: string;
};
expect(parsed.text).toBeTruthy();
expect(parsed.timeout).toBe(true);
expect(parsed.model).toBe("timeout-stub");
expect(parsed.dateKey).toMatch(/^\d{4}-\d{2}-\d{2}$/);
});
it("subsequent mounts short-circuit at the cache lookup and skip the race", async () => {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: "Europe/Berlin",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(new Date());
const y = parts.find((p) => p.type === "year")!.value;
const m = parts.find((p) => p.type === "month")!.value;
const d = parts.find((p) => p.type === "day")!.value;
const todayKey = `${y}-${m}-${d}`;
const stubRow = {
createdAt: new Date(),
details: JSON.stringify({
dateKey: todayKey,
locale: "en",
text: "Weight is a directional metric…",
timeout: true,
}),
};
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(stubRow as never);
const providerCall = vi.fn();
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: providerCall,
vi.mocked(runStatusCompletion).mockResolvedValue({
kind: "timeout",
} as never);
const result = await generateWeightStatusForUser("user-1", {
locale: "en",
});
expect(providerCall).not.toHaveBeenCalled();
expect(result.text).toBe("Weight is a directional metric…");
expect(result.text).toBeTruthy();
expect(result.cached).toBe(true);
expect(result.updatedAt).toBe(stubRow.createdAt.toISOString());
expect(result.updatedAt).toBeNull();
expect(prisma.auditLog.create).not.toHaveBeenCalled();
});
});
describe("generateWeightStatusForUser — cache-read skips a stub", () => {
it("regenerates when the only cached row is a timeout stub", async () => {
const now = new Date();
const todayKey = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Berlin",
}).format(now);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({
createdAt: now,
details: JSON.stringify({
dateKey: todayKey,
locale: "en",
text: "Generic stub fallback.",
model: "timeout-stub",
timeout: true,
}),
} as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ type: "WEIGHT", value: 82, measuredAt: now },
] as never);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: now,
} as never);
stubCompletion('{"summary":"Fresh real assessment."}');
const result = await generateWeightStatusForUser("user-1", {
locale: "en",
});
expect(runStatusCompletion).toHaveBeenCalledTimes(1);
expect(result.text).toBe("Fresh real assessment.");
expect(result.cached).toBe(false);
});
});
describe("generateWeightStatusForUser — token-leak hardening (v1.4.27 F16)", () => {
it("strips metric: tokens out of the cached text before persisting", async () => {
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
@@ -181,15 +163,9 @@ describe("generateWeightStatusForUser — token-leak hardening (v1.4.27 F16)", (
createdAt: new Date(),
} as never);
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: vi.fn(async () => ({
content:
'{"summary":"Weight trended down 0.4 kg last week. metric:WEIGHT"}',
model: "x",
tokensUsed: 1,
})),
} as never);
stubCompletion(
'{"summary":"Weight trended down 0.4 kg last week. metric:WEIGHT"}',
);
const result = await generateWeightStatusForUser("user-1", {
locale: "en",
+43 -80
View File
@@ -1,5 +1,4 @@
import { prisma } from "@/lib/db";
import { resolveProvider } from "@/lib/ai/provider";
import {
getBloodPressureSystemPrompt,
getBloodPressureUserPrompt,
@@ -29,11 +28,9 @@ import {
type DailyBucket,
} from "@/lib/insights/bucket-series";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
import {
withTimeout,
STATUS_PROVIDER_TIMEOUT_MS,
} from "@/lib/insights/with-timeout";
import { persistTimeoutStubAndReturn } from "@/lib/insights/persist-timeout-stub";
import { runStatusCompletion } from "@/lib/insights/status-provider";
import { readFreshStatusText } from "@/lib/insights/status-cache";
import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback";
import { annotate } from "@/lib/logging/context";
import { toBerlinDayKey } from "@/lib/tz/resolver";
@@ -134,13 +131,18 @@ export async function generateBloodPressureStatusForUser(
const cacheAction = `insights.blood-pressure-status.${locale}`;
const todayKey = toBerlinDayKey(new Date());
const provider = await resolveProvider(userId);
if (provider.type === "none") {
const cached = await readFreshStatusText({
userId,
cacheAction,
todayKey,
force,
});
if (cached) {
return {
hasProvider: false,
text: getNoKeyBloodPressureStatusText(locale),
hasProvider: true,
text: cached.text,
cached: true,
updatedAt: null,
updatedAt: cached.updatedAt,
};
}
@@ -151,35 +153,6 @@ export async function generateBloodPressureStatusForUser(
},
});
const latestCache = await prisma.auditLog.findFirst({
where: { userId, action: cacheAction },
orderBy: { createdAt: "desc" },
select: { createdAt: true, details: true },
});
if (!force && latestCache?.details) {
try {
const parsed = JSON.parse(latestCache.details) as {
dateKey?: string;
text?: string;
};
if (
parsed.dateKey === todayKey &&
typeof parsed.text === "string" &&
parsed.text.trim().length > 0
) {
return {
hasProvider: true,
text: parsed.text,
cached: true,
updatedAt: latestCache.createdAt.toISOString(),
};
}
} catch {
// ignore invalid cache payload
}
}
// v1.4.28 FB-D2 — cap the snapshot input. The downstream
// `applyPayloadBudget` trims further; this `take` keeps the read
// bounded for Apple-Health-rich accounts. BP captures three types
@@ -522,56 +495,46 @@ export async function generateBloodPressureStatusForUser(
locale,
);
// v1.4.28 FB-D2 — 20 s timeout race; fall back to the no-key text
// on stall so the InsightStatusCard renders deterministically.
const raced = await withTimeout(
() =>
provider.generateCompletion({
systemPrompt: getBloodPressureSystemPrompt(locale),
userPrompt: getBloodPressureUserPrompt(
snapshotJson,
todayKey,
locale,
previousContextBlock,
),
temperature: 0.3,
maxTokens: 1000,
}),
STATUS_PROVIDER_TIMEOUT_MS,
null,
);
if (raced.timedOut || raced.value === null) {
// v1.4.37 — persist a sentinel row keyed to today so the next
// mount short-circuits at the cache lookup above instead of
// re-racing the same 20 s provider call on every cold visit.
// See `persistTimeoutStubAndReturn` for the full rationale.
return persistTimeoutStubAndReturn({
userId,
cacheAction,
const outcome = await runStatusCompletion({
userId,
cacheAction,
systemPrompt: getBloodPressureSystemPrompt(locale),
userPrompt: getBloodPressureUserPrompt(
snapshotJson,
todayKey,
locale,
providerType: provider.type,
previousContextBlock,
),
temperature: 0.3,
maxTokens: 1000,
});
if (outcome.kind === "none") {
return {
hasProvider: false,
text: getNoKeyBloodPressureStatusText(locale),
cached: true,
updatedAt: null,
};
}
if (outcome.kind === "timeout" || outcome.kind === "error") {
return returnTimeoutFallback({
cacheAction,
reason: outcome.kind,
stubText: getNoKeyBloodPressureStatusText(locale),
});
}
const result = raced.value;
const content = result.content;
if (typeof content !== "string" || content.trim().length === 0) {
throw new Error("AI returned empty content for blood-pressure-status");
}
let summary = "";
try {
const parsed = JSON.parse(content) as { summary?: string };
const parsed = JSON.parse(outcome.content) as { summary?: string };
if (typeof parsed.summary === "string") {
summary = parsed.summary;
} else {
summary = content;
summary = outcome.content;
}
} catch {
summary = content;
summary = outcome.content;
}
summary = normalizeSummaryText(summary);
@@ -589,9 +552,9 @@ export async function generateBloodPressureStatusForUser(
dateKey: todayKey,
locale,
text: summary,
providerType: provider.type,
model: result.model ?? "unknown",
tokensUsed: result.tokensUsed ?? null,
providerType: outcome.providerType,
model: outcome.model,
tokensUsed: outcome.tokensUsed,
}),
},
select: { createdAt: true },
+44 -81
View File
@@ -1,5 +1,4 @@
import { prisma } from "@/lib/db";
import { resolveProvider } from "@/lib/ai/provider";
import { getBmiSystemPrompt, getBmiUserPrompt } from "@/lib/ai/prompts/bmi";
import { classifyBMI } from "@/lib/analytics/classifications";
import { getNoKeyBmiStatusText } from "@/lib/insights/no-key-fallbacks";
@@ -9,11 +8,9 @@ import {
} from "@/lib/insights/memory";
import { applyPayloadBudget } from "@/lib/insights/bucket-series";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
import {
withTimeout,
STATUS_PROVIDER_TIMEOUT_MS,
} from "@/lib/insights/with-timeout";
import { persistTimeoutStubAndReturn } from "@/lib/insights/persist-timeout-stub";
import { runStatusCompletion } from "@/lib/insights/status-provider";
import { readFreshStatusText } from "@/lib/insights/status-cache";
import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback";
import { annotate } from "@/lib/logging/context";
import { toBerlinDayKey } from "@/lib/tz/resolver";
@@ -80,13 +77,18 @@ export async function generateBmiStatusForUser(
const cacheAction = `insights.bmi-status.${locale}`;
const todayKey = toBerlinDayKey(new Date());
const provider = await resolveProvider(userId);
if (provider.type === "none") {
const cached = await readFreshStatusText({
userId,
cacheAction,
todayKey,
force,
});
if (cached) {
return {
hasProvider: false,
text: getNoKeyBmiStatusText(locale),
hasProvider: true,
text: cached.text,
cached: true,
updatedAt: null,
updatedAt: cached.updatedAt,
};
}
@@ -97,35 +99,6 @@ export async function generateBmiStatusForUser(
},
});
const latestCache = await prisma.auditLog.findFirst({
where: { userId, action: cacheAction },
orderBy: { createdAt: "desc" },
select: { createdAt: true, details: true },
});
if (!force && latestCache?.details) {
try {
const parsed = JSON.parse(latestCache.details) as {
dateKey?: string;
text?: string;
};
if (
parsed.dateKey === todayKey &&
typeof parsed.text === "string" &&
parsed.text.trim().length > 0
) {
return {
hasProvider: true,
text: parsed.text,
cached: true,
updatedAt: latestCache.createdAt.toISOString(),
};
}
} catch {
// ignore invalid cache payload
}
}
if (!user?.heightCm || user.heightCm <= 0) {
return {
hasProvider: true,
@@ -134,7 +107,7 @@ export async function generateBmiStatusForUser(
? "Für die BMI-Einschätzung fehlen aktuell Größenangaben im Profil."
: "BMI assessment currently requires height data in the profile.",
cached: true,
updatedAt: latestCache?.createdAt.toISOString() ?? null,
updatedAt: null,
};
}
@@ -253,56 +226,46 @@ export async function generateBmiStatusForUser(
locale,
);
// v1.4.28 FB-D2 — 20 s timeout race; fall back to the no-key text
// on stall so the InsightStatusCard renders deterministically.
const raced = await withTimeout(
() =>
provider.generateCompletion({
systemPrompt: getBmiSystemPrompt(locale),
userPrompt: getBmiUserPrompt(
snapshotJson,
todayKey,
locale,
previousContextBlock,
),
temperature: 0.3,
maxTokens: 1000,
}),
STATUS_PROVIDER_TIMEOUT_MS,
null,
);
if (raced.timedOut || raced.value === null) {
// v1.4.37 — persist a sentinel row keyed to today so the next
// mount short-circuits at the cache lookup above instead of
// re-racing the same 20 s provider call on every cold visit.
// See `persistTimeoutStubAndReturn` for the full rationale.
return persistTimeoutStubAndReturn({
userId,
cacheAction,
const outcome = await runStatusCompletion({
userId,
cacheAction,
systemPrompt: getBmiSystemPrompt(locale),
userPrompt: getBmiUserPrompt(
snapshotJson,
todayKey,
locale,
providerType: provider.type,
previousContextBlock,
),
temperature: 0.3,
maxTokens: 1000,
});
if (outcome.kind === "none") {
return {
hasProvider: false,
text: getNoKeyBmiStatusText(locale),
cached: true,
updatedAt: null,
};
}
if (outcome.kind === "timeout" || outcome.kind === "error") {
return returnTimeoutFallback({
cacheAction,
reason: outcome.kind,
stubText: getNoKeyBmiStatusText(locale),
});
}
const result = raced.value;
const content = result.content;
if (typeof content !== "string" || content.trim().length === 0) {
throw new Error("AI returned empty content for bmi-status");
}
let summary = "";
try {
const parsed = JSON.parse(content) as { summary?: string };
const parsed = JSON.parse(outcome.content) as { summary?: string };
if (typeof parsed.summary === "string") {
summary = parsed.summary;
} else {
summary = content;
summary = outcome.content;
}
} catch {
summary = content;
summary = outcome.content;
}
summary = normalizeSummaryText(summary);
@@ -318,9 +281,9 @@ export async function generateBmiStatusForUser(
dateKey: todayKey,
locale,
text: summary,
providerType: provider.type,
model: result.model ?? "unknown",
tokensUsed: result.tokensUsed ?? null,
providerType: outcome.providerType,
model: outcome.model,
tokensUsed: outcome.tokensUsed,
}),
},
select: { createdAt: true },
+43 -80
View File
@@ -1,5 +1,4 @@
import { prisma } from "@/lib/db";
import { resolveProvider } from "@/lib/ai/provider";
import {
getGeneralStatusSystemPrompt,
getGeneralStatusUserPrompt,
@@ -14,11 +13,9 @@ import {
} from "@/lib/insights/memory";
import { applyPayloadBudget } from "@/lib/insights/bucket-series";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
import {
withTimeout,
STATUS_PROVIDER_TIMEOUT_MS,
} from "@/lib/insights/with-timeout";
import { persistTimeoutStubAndReturn } from "@/lib/insights/persist-timeout-stub";
import { runStatusCompletion } from "@/lib/insights/status-provider";
import { readFreshStatusText } from "@/lib/insights/status-cache";
import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback";
import { annotate } from "@/lib/logging/context";
import { toBerlinDayKey } from "@/lib/tz/resolver";
@@ -88,13 +85,18 @@ export async function generateGeneralStatusForUser(
const cacheAction = `insights.general-status.${locale}`;
const todayKey = toBerlinDayKey(new Date());
const provider = await resolveProvider(userId);
if (provider.type === "none") {
const cached = await readFreshStatusText({
userId,
cacheAction,
todayKey,
force,
});
if (cached) {
return {
hasProvider: false,
text: getNoKeyGeneralStatusText(locale),
hasProvider: true,
text: cached.text,
cached: true,
updatedAt: null,
updatedAt: cached.updatedAt,
};
}
@@ -105,35 +107,6 @@ export async function generateGeneralStatusForUser(
},
});
const latestCache = await prisma.auditLog.findFirst({
where: { userId, action: cacheAction },
orderBy: { createdAt: "desc" },
select: { createdAt: true, details: true },
});
if (!force && latestCache?.details) {
try {
const parsed = JSON.parse(latestCache.details) as {
dateKey?: string;
text?: string;
};
if (
parsed.dateKey === todayKey &&
typeof parsed.text === "string" &&
parsed.text.trim().length > 0
) {
return {
hasProvider: true,
text: parsed.text,
cached: true,
updatedAt: latestCache.createdAt.toISOString(),
};
}
} catch {
// ignore invalid cache payload
}
}
// v1.4.28 FB-D2 — cap the snapshot input. General-status pulls
// every supported measurement type; without a cap the read scales
// linearly with account density.
@@ -353,56 +326,46 @@ export async function generateGeneralStatusForUser(
locale,
);
// v1.4.28 FB-D2 — 20 s timeout race; fall back to the no-key text
// on stall so the InsightStatusCard renders deterministically.
const raced = await withTimeout(
() =>
provider.generateCompletion({
systemPrompt: getGeneralStatusSystemPrompt(locale),
userPrompt: getGeneralStatusUserPrompt(
snapshotJson,
todayKey,
locale,
previousContextBlock,
),
temperature: 0.3,
maxTokens: 1000,
}),
STATUS_PROVIDER_TIMEOUT_MS,
null,
);
if (raced.timedOut || raced.value === null) {
// v1.4.37 — persist a sentinel row keyed to today so the next
// mount short-circuits at the cache lookup above instead of
// re-racing the same 20 s provider call on every cold visit.
// See `persistTimeoutStubAndReturn` for the full rationale.
return persistTimeoutStubAndReturn({
userId,
cacheAction,
const outcome = await runStatusCompletion({
userId,
cacheAction,
systemPrompt: getGeneralStatusSystemPrompt(locale),
userPrompt: getGeneralStatusUserPrompt(
snapshotJson,
todayKey,
locale,
providerType: provider.type,
previousContextBlock,
),
temperature: 0.3,
maxTokens: 1000,
});
if (outcome.kind === "none") {
return {
hasProvider: false,
text: getNoKeyGeneralStatusText(locale),
cached: true,
updatedAt: null,
};
}
if (outcome.kind === "timeout" || outcome.kind === "error") {
return returnTimeoutFallback({
cacheAction,
reason: outcome.kind,
stubText: getNoKeyGeneralStatusText(locale),
});
}
const result = raced.value;
const content = result.content;
if (typeof content !== "string" || content.trim().length === 0) {
throw new Error("AI returned empty content for general-status");
}
let summary = "";
try {
const parsed = JSON.parse(content) as { summary?: string };
const parsed = JSON.parse(outcome.content) as { summary?: string };
if (typeof parsed.summary === "string") {
summary = parsed.summary;
} else {
summary = content;
summary = outcome.content;
}
} catch {
summary = content;
summary = outcome.content;
}
summary = normalizeSummaryText(summary);
@@ -418,9 +381,9 @@ export async function generateGeneralStatusForUser(
dateKey: todayKey,
locale,
text: summary,
providerType: provider.type,
model: result.model ?? "unknown",
tokensUsed: result.tokensUsed ?? null,
providerType: outcome.providerType,
model: outcome.model,
tokensUsed: outcome.tokensUsed,
}),
},
select: { createdAt: true },
+78 -115
View File
@@ -1,5 +1,4 @@
import { prisma } from "@/lib/db";
import { resolveProvider } from "@/lib/ai/provider";
import {
getMedicationComplianceSystemPrompt,
getMedicationComplianceUserPrompt,
@@ -19,11 +18,9 @@ import {
} from "@/lib/insights/memory";
import { applyPayloadBudget } from "@/lib/insights/bucket-series";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
import {
withTimeout,
STATUS_PROVIDER_TIMEOUT_MS,
} from "@/lib/insights/with-timeout";
import { persistTimeoutStubAndReturn } from "@/lib/insights/persist-timeout-stub";
import { runStatusCompletion } from "@/lib/insights/status-provider";
import { isTimeoutStub } from "@/lib/insights/status-cache";
import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback";
import { annotate } from "@/lib/logging/context";
import { toBerlinDayKey } from "@/lib/tz/resolver";
@@ -69,73 +66,50 @@ export async function generateMedicationComplianceStatusForUser(
const cacheAction = `insights.medication-compliance-status.${locale}`;
const todayKey = toBerlinDayKey(new Date());
const provider = await resolveProvider(userId);
if (provider.type === "none") {
return {
hasProvider: false,
summary: getNoKeyMedicationComplianceStatusText(locale),
medications: [],
cached: true,
updatedAt: null,
};
}
const latestCache = await prisma.auditLog.findFirst({
where: { userId, action: cacheAction },
orderBy: { createdAt: "desc" },
select: { createdAt: true, details: true },
});
if (!force && latestCache?.details) {
try {
const parsed = JSON.parse(latestCache.details) as {
dateKey?: string;
summary?: string;
text?: string;
timeout?: boolean;
medications?: MedicationSummaryItem[];
};
if (
parsed.dateKey === todayKey &&
typeof parsed.summary === "string" &&
parsed.summary.trim().length > 0 &&
Array.isArray(parsed.medications)
) {
return {
hasProvider: true,
summary: parsed.summary,
medications: parsed.medications.filter(
(entry): entry is MedicationSummaryItem =>
typeof entry?.medicationId === "string" &&
typeof entry?.text === "string" &&
entry.text.trim().length > 0,
),
cached: true,
updatedAt: latestCache.createdAt.toISOString(),
// This route carries a richer cached envelope (`summary` +
// `medications`) than the standard `text`-only generators, so it
// keeps its own cache-read — but it shares the stub-rejection
// predicate so a timeout stub never sticks for the day.
if (!force) {
const latestCache = await prisma.auditLog.findFirst({
where: { userId, action: cacheAction },
orderBy: { createdAt: "desc" },
select: { createdAt: true, details: true },
});
if (latestCache?.details) {
try {
const parsed = JSON.parse(latestCache.details) as {
dateKey?: string;
summary?: string;
text?: string;
model?: string;
timeout?: boolean;
medications?: MedicationSummaryItem[];
};
}
// v1.4.41 — timeout-stub rows persist `text` + `timeout: true`
// instead of the standard `summary` + `medications` envelope.
// Recognise the stub so the second mount short-circuits at the
// cache lookup instead of re-racing the provider call.
if (
parsed.dateKey === todayKey &&
parsed.timeout === true &&
typeof parsed.text === "string" &&
parsed.text.trim().length > 0
) {
return {
hasProvider: true,
summary: parsed.text,
medications: [],
cached: true,
updatedAt: latestCache.createdAt.toISOString(),
};
if (
parsed.dateKey === todayKey &&
!isTimeoutStub(parsed) &&
typeof parsed.summary === "string" &&
parsed.summary.trim().length > 0 &&
Array.isArray(parsed.medications)
) {
return {
hasProvider: true,
summary: parsed.summary,
medications: parsed.medications.filter(
(entry): entry is MedicationSummaryItem =>
typeof entry?.medicationId === "string" &&
typeof entry?.text === "string" &&
entry.text.trim().length > 0,
),
cached: true,
updatedAt: latestCache.createdAt.toISOString(),
};
}
} catch {
// ignore invalid cache payload
}
} catch {
// ignore invalid cache payload
}
}
@@ -154,7 +128,7 @@ export async function generateMedicationComplianceStatusForUser(
: "There are currently no active medications configured.",
medications: [],
cached: true,
updatedAt: latestCache?.createdAt.toISOString() ?? null,
updatedAt: null,
};
}
@@ -333,58 +307,47 @@ export async function generateMedicationComplianceStatusForUser(
locale,
);
// v1.4.28 FB-D2 — 20 s timeout race; fall back to the no-key text
// on stall so the InsightStatusCard renders deterministically.
const raced = await withTimeout(
() =>
provider.generateCompletion({
systemPrompt: getMedicationComplianceSystemPrompt(locale),
userPrompt: getMedicationComplianceUserPrompt(
snapshotJson,
todayKey,
locale,
previousContextBlock,
),
temperature: 0.3,
maxTokens: 1000,
}),
STATUS_PROVIDER_TIMEOUT_MS,
null,
);
if (raced.timedOut || raced.value === null) {
// v1.4.37 — persist a sentinel row keyed to today so the next
// mount short-circuits at the cache lookup above instead of
// re-racing the same 20 s provider call on every cold visit.
// See `persistTimeoutStubAndReturn` for the full rationale.
// This route returns a richer shape (`summary`, `medications`)
// than the standard `text` envelope, so the helper is called
// for its persist side-effect and the route maps `summary` ←
// `text` on the way out.
const stubReturn = await persistTimeoutStubAndReturn({
userId,
cacheAction,
const outcome = await runStatusCompletion({
userId,
cacheAction,
systemPrompt: getMedicationComplianceSystemPrompt(locale),
userPrompt: getMedicationComplianceUserPrompt(
snapshotJson,
todayKey,
locale,
providerType: provider.type,
previousContextBlock,
),
temperature: 0.3,
maxTokens: 1000,
});
if (outcome.kind === "none") {
return {
hasProvider: false,
summary: getNoKeyMedicationComplianceStatusText(locale),
medications: [],
cached: true,
updatedAt: null,
};
}
if (outcome.kind === "timeout" || outcome.kind === "error") {
// Transient miss — serve the fallback for this render without
// persisting it, so the next mount re-attempts a real generation.
returnTimeoutFallback({
cacheAction,
reason: outcome.kind,
stubText: getNoKeyMedicationComplianceStatusText(locale),
});
return {
hasProvider: true,
summary: stubReturn.text,
summary: getNoKeyMedicationComplianceStatusText(locale),
medications: [],
cached: true,
updatedAt: stubReturn.updatedAt,
updatedAt: null,
};
}
const result = raced.value;
const content = result.content;
if (typeof content !== "string" || content.trim().length === 0) {
throw new Error(
"AI returned empty content for medication-compliance-status",
);
}
const content = outcome.content;
let summary = "";
let medicationSummaries: MedicationSummaryItem[] = [];
@@ -442,9 +405,9 @@ export async function generateMedicationComplianceStatusForUser(
locale,
summary,
medications: medicationSummaries,
providerType: provider.type,
model: result.model ?? "unknown",
tokensUsed: result.tokensUsed ?? null,
providerType: outcome.providerType,
model: outcome.model,
tokensUsed: outcome.tokensUsed,
}),
},
select: { createdAt: true },
+45 -82
View File
@@ -1,5 +1,4 @@
import { prisma } from "@/lib/db";
import { resolveProvider } from "@/lib/ai/provider";
import { getMoodSystemPrompt, getMoodUserPrompt } from "@/lib/ai/prompts/mood";
import { getNoKeyMoodStatusText } from "@/lib/insights/no-key-fallbacks";
import {
@@ -16,11 +15,9 @@ import {
type DailyBucket,
} from "@/lib/insights/bucket-series";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
import {
withTimeout,
STATUS_PROVIDER_TIMEOUT_MS,
} from "@/lib/insights/with-timeout";
import { persistTimeoutStubAndReturn } from "@/lib/insights/persist-timeout-stub";
import { runStatusCompletion } from "@/lib/insights/status-provider";
import { readFreshStatusText } from "@/lib/insights/status-cache";
import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback";
import { annotate } from "@/lib/logging/context";
import { toBerlinDayKey } from "@/lib/tz/resolver";
@@ -118,43 +115,19 @@ export async function generateMoodStatusForUser(
const cacheAction = `insights.mood-status.${locale}`;
const todayKey = toBerlinDayKey(new Date());
const provider = await resolveProvider(userId);
if (provider.type === "none") {
return {
hasProvider: false,
text: getNoKeyMoodStatusText(locale),
cached: true,
updatedAt: null,
};
}
const latestCache = await prisma.auditLog.findFirst({
where: { userId, action: cacheAction },
orderBy: { createdAt: "desc" },
select: { createdAt: true, details: true },
const cached = await readFreshStatusText({
userId,
cacheAction,
todayKey,
force,
});
if (!force && latestCache?.details) {
try {
const parsed = JSON.parse(latestCache.details) as {
dateKey?: string;
text?: string;
};
if (
parsed.dateKey === todayKey &&
typeof parsed.text === "string" &&
parsed.text.trim().length > 0
) {
return {
hasProvider: true,
text: parsed.text,
cached: true,
updatedAt: latestCache.createdAt.toISOString(),
};
}
} catch {
// ignore invalid cache payload
}
if (cached) {
return {
hasProvider: true,
text: cached.text,
cached: true,
updatedAt: cached.updatedAt,
};
}
// v1.4.28 FB-D2 — cap at 90 entries (~3 months) for the mood
@@ -409,56 +382,46 @@ export async function generateMoodStatusForUser(
locale,
);
// v1.4.28 FB-D2 — 20 s timeout race; fall back to the no-key text
// on stall so the InsightStatusCard renders deterministically.
const raced = await withTimeout(
() =>
provider.generateCompletion({
systemPrompt: getMoodSystemPrompt(locale),
userPrompt: getMoodUserPrompt(
snapshotJson,
todayKey,
locale,
previousContextBlock,
),
temperature: 0.3,
maxTokens: 1000,
}),
STATUS_PROVIDER_TIMEOUT_MS,
null,
);
if (raced.timedOut || raced.value === null) {
// v1.4.37 — persist a sentinel row keyed to today so the next
// mount short-circuits at the cache lookup above instead of
// re-racing the same 20 s provider call on every cold visit.
// See `persistTimeoutStubAndReturn` for the full rationale.
return persistTimeoutStubAndReturn({
userId,
cacheAction,
const outcome = await runStatusCompletion({
userId,
cacheAction,
systemPrompt: getMoodSystemPrompt(locale),
userPrompt: getMoodUserPrompt(
snapshotJson,
todayKey,
locale,
providerType: provider.type,
previousContextBlock,
),
temperature: 0.3,
maxTokens: 1000,
});
if (outcome.kind === "none") {
return {
hasProvider: false,
text: getNoKeyMoodStatusText(locale),
cached: true,
updatedAt: null,
};
}
if (outcome.kind === "timeout" || outcome.kind === "error") {
return returnTimeoutFallback({
cacheAction,
reason: outcome.kind,
stubText: getNoKeyMoodStatusText(locale),
});
}
const result = raced.value;
const content = result.content;
if (typeof content !== "string" || content.trim().length === 0) {
throw new Error("AI returned empty content for mood-status");
}
let summary = "";
try {
const parsed = JSON.parse(content) as { summary?: string };
const parsed = JSON.parse(outcome.content) as { summary?: string };
if (typeof parsed.summary === "string") {
summary = parsed.summary;
} else {
summary = content;
summary = outcome.content;
}
} catch {
summary = content;
summary = outcome.content;
}
summary = normalizeSummaryText(summary);
@@ -474,9 +437,9 @@ export async function generateMoodStatusForUser(
dateKey: todayKey,
locale,
text: summary,
providerType: provider.type,
model: result.model ?? "unknown",
tokensUsed: result.tokensUsed ?? null,
providerType: outcome.providerType,
model: outcome.model,
tokensUsed: outcome.tokensUsed,
}),
},
select: { createdAt: true },
-73
View File
@@ -1,73 +0,0 @@
import { prisma } from "@/lib/db";
/**
* v1.4.37 / v1.4.41 — shared timeout-stub persist for InsightStatus routes.
*
* When the upstream provider call exceeds `STATUS_PROVIDER_TIMEOUT_MS`
* the caller persists a sentinel `auditLog` row keyed to today's Berlin
* day so the next mount short-circuits at the cache lookup instead of
* re-racing the same 20 s provider call on every cold visit. Without
* this, a single stall (provider hiccup, network blip, model warm-up)
* leaves the user staring at the loading state on every reload until
* the daily 02:20 pre-warm job runs.
*
* The persisted body is the deterministic no-key fallback the caller
* would have returned in the bare-fallback shape, so the user-facing
* UI is identical; only the re-fire frequency drops to zero for the
* rest of the day.
*
* `meta.timeout` + `meta.model = "timeout-stub"` are set so the daily
* pg-boss pre-warm worker (the same job that originally seeded the
* cache) can recognise and overwrite the stub rather than respect it
* as a real assessment.
*
* The write is best-effort — if it fails the caller still sees the
* deterministic fallback text and the next mount falls back to the
* (still expensive) race. We do not surface the error: the user does
* not care that the cache write missed, only that the page rendered.
*/
export async function persistTimeoutStubAndReturn(input: {
userId: string;
cacheAction: string;
todayKey: string;
locale: string;
providerType: string;
stubText: string;
}): Promise<{
hasProvider: true;
text: string;
cached: true;
updatedAt: string | null;
}> {
const { userId, cacheAction, todayKey, locale, providerType, stubText } =
input;
let stubUpdatedAt: string | null = null;
try {
const stub = await prisma.auditLog.create({
data: {
userId,
action: cacheAction,
details: JSON.stringify({
dateKey: todayKey,
locale,
text: stubText,
providerType,
model: "timeout-stub",
tokensUsed: null,
timeout: true,
}),
},
select: { createdAt: true },
});
stubUpdatedAt = stub.createdAt.toISOString();
} catch {
// The persist is best-effort — silently swallow so the user-facing
// payload still renders even when the row write missed.
}
return {
hasProvider: true,
text: stubText,
cached: true,
updatedAt: stubUpdatedAt,
};
}
+43 -86
View File
@@ -1,5 +1,4 @@
import { prisma } from "@/lib/db";
import { resolveProvider } from "@/lib/ai/provider";
import {
getPulseSystemPrompt,
getPulseUserPrompt,
@@ -15,11 +14,9 @@ import {
import { getNoKeyPulseStatusText } from "@/lib/insights/no-key-fallbacks";
import { applyPayloadBudget } from "@/lib/insights/bucket-series";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
import {
withTimeout,
STATUS_PROVIDER_TIMEOUT_MS,
} from "@/lib/insights/with-timeout";
import { persistTimeoutStubAndReturn } from "@/lib/insights/persist-timeout-stub";
import { runStatusCompletion } from "@/lib/insights/status-provider";
import { readFreshStatusText } from "@/lib/insights/status-cache";
import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback";
import { annotate } from "@/lib/logging/context";
import { toBerlinDayKey } from "@/lib/tz/resolver";
@@ -85,13 +82,18 @@ export async function generatePulseStatusForUser(
const cacheAction = `insights.pulse-status.${locale}`;
const todayKey = toBerlinDayKey(new Date());
const provider = await resolveProvider(userId);
if (provider.type === "none") {
const cached = await readFreshStatusText({
userId,
cacheAction,
todayKey,
force,
});
if (cached) {
return {
hasProvider: false,
text: getNoKeyPulseStatusText(locale),
hasProvider: true,
text: cached.text,
cached: true,
updatedAt: null,
updatedAt: cached.updatedAt,
};
}
@@ -103,35 +105,6 @@ export async function generatePulseStatusForUser(
},
});
const latestCache = await prisma.auditLog.findFirst({
where: { userId, action: cacheAction },
orderBy: { createdAt: "desc" },
select: { createdAt: true, details: true },
});
if (!force && latestCache?.details) {
try {
const parsed = JSON.parse(latestCache.details) as {
dateKey?: string;
text?: string;
};
if (
parsed.dateKey === todayKey &&
typeof parsed.text === "string" &&
parsed.text.trim().length > 0
) {
return {
hasProvider: true,
text: parsed.text,
cached: true,
updatedAt: latestCache.createdAt.toISOString(),
};
}
} catch {
// ignore invalid cache payload
}
}
// v1.4.28 FB-D2 — cap the snapshot input. The downstream
// `applyPayloadBudget` trims further but the unbounded findMany was
// pulling tens of thousands of rows for Apple-Health-rich accounts
@@ -293,62 +266,46 @@ export async function generatePulseStatusForUser(
locale,
);
// v1.4.28 FB-D2 — cap the provider round-trip at 20 s. On timeout
// (or upstream failure) return the no-key fallback text in a
// cached-style envelope so the status card renders deterministically
// instead of spinning behind React-Query's default retry ladder.
// v1.4.41 — the timeout branch now persists a sentinel keyed to
// today (see `persistTimeoutStubAndReturn`) so subsequent mounts
// short-circuit at the cache lookup instead of re-racing the
// upstream call. The daily pre-warm job overwrites the stub.
const raced = await withTimeout(
() =>
provider.generateCompletion({
systemPrompt: getPulseSystemPrompt(locale),
userPrompt: getPulseUserPrompt(
snapshotJson,
todayKey,
locale,
previousContextBlock,
),
temperature: 0.3,
maxTokens: 1000,
}),
STATUS_PROVIDER_TIMEOUT_MS,
null,
);
if (raced.timedOut || raced.value === null) {
// v1.4.37 — persist a sentinel row keyed to today so the next
// mount short-circuits at the cache lookup above instead of
// re-racing the same 20 s provider call on every cold visit.
// See `persistTimeoutStubAndReturn` for the full rationale.
return persistTimeoutStubAndReturn({
userId,
cacheAction,
const outcome = await runStatusCompletion({
userId,
cacheAction,
systemPrompt: getPulseSystemPrompt(locale),
userPrompt: getPulseUserPrompt(
snapshotJson,
todayKey,
locale,
providerType: provider.type,
previousContextBlock,
),
temperature: 0.3,
maxTokens: 1000,
});
if (outcome.kind === "none") {
return {
hasProvider: false,
text: getNoKeyPulseStatusText(locale),
cached: true,
updatedAt: null,
};
}
if (outcome.kind === "timeout" || outcome.kind === "error") {
return returnTimeoutFallback({
cacheAction,
reason: outcome.kind,
stubText: getNoKeyPulseStatusText(locale),
});
}
const result = raced.value;
const content = result.content;
if (typeof content !== "string" || content.trim().length === 0) {
throw new Error("AI returned empty content for pulse-status");
}
let summary = "";
try {
const parsed = JSON.parse(content) as { summary?: string };
const parsed = JSON.parse(outcome.content) as { summary?: string };
if (typeof parsed.summary === "string") {
summary = parsed.summary;
} else {
summary = content;
summary = outcome.content;
}
} catch {
summary = content;
summary = outcome.content;
}
summary = normalizeSummaryText(summary);
@@ -364,9 +321,9 @@ export async function generatePulseStatusForUser(
dateKey: todayKey,
locale,
text: summary,
providerType: provider.type,
model: result.model ?? "unknown",
tokensUsed: result.tokensUsed ?? null,
providerType: outcome.providerType,
model: outcome.model,
tokensUsed: outcome.tokensUsed,
}),
},
select: { createdAt: true },
+94
View File
@@ -0,0 +1,94 @@
import { prisma } from "@/lib/db";
/**
* Shared cache-read for the seven `*-status.ts` insight generators.
*
* Every generator persists its assessment as an `auditLog` row keyed
* `insights.<metric>-status.<locale>` whose `details` JSON carries
* `{ dateKey, locale, text, providerType, model, tokensUsed }`. On the
* next mount the generator reads the most recent such row and, if it is
* still for today, serves it without re-hitting the provider.
*
* The timeout fallback used to poison this read. When a provider call
* exceeded the status budget the route persisted a `model:"timeout-stub"`
* / `timeout:true` row carrying the generic no-key text under the SAME
* `text` field a real assessment uses. The cache-read only checked
* `dateKey === today && text` — so the stub looked like a valid
* assessment and stuck until midnight, hiding the real data-driven text
* for the rest of the day.
*
* `readFreshStatusText` is the one cache-read every standard generator
* shares. It rejects stubs explicitly so a single stall no longer pins
* the fallback for the day, and a fresh generation is attempted instead.
*/
interface ParsedStatusCache {
dateKey?: string;
locale?: string;
text?: string;
summary?: string;
providerType?: string;
model?: string;
tokensUsed?: number | null;
timeout?: boolean;
}
/**
* A cached row is a timeout stub when it carries the sentinel marker the
* timeout path writes. Either flag is sufficient — older stub rows may
* predate one of the two markers, so both are honoured.
*/
export function isTimeoutStub(parsed: {
model?: string;
timeout?: boolean;
}): boolean {
return parsed.model === "timeout-stub" || parsed.timeout === true;
}
export interface FreshStatusCacheHit {
text: string;
updatedAt: string;
}
/**
* Read the latest cached assessment for `(userId, cacheAction)` and
* return its text only when it is (a) for today and (b) NOT a timeout
* stub. Returns `null` on a miss, a stale day, a stub, or a malformed
* payload — every one of those means the caller should regenerate.
*
* `force` short-circuits to `null` so a forced regeneration never reads
* the cache. The DB read is still skipped entirely under `force` to
* keep the forced path cheap.
*/
export async function readFreshStatusText(args: {
userId: string;
cacheAction: string;
todayKey: string;
force: boolean;
}): Promise<FreshStatusCacheHit | null> {
const { userId, cacheAction, todayKey, force } = args;
if (force) return null;
const latestCache = await prisma.auditLog.findFirst({
where: { userId, action: cacheAction },
orderBy: { createdAt: "desc" },
select: { createdAt: true, details: true },
});
if (!latestCache?.details) return null;
try {
const parsed = JSON.parse(latestCache.details) as ParsedStatusCache;
if (parsed.dateKey !== todayKey) return null;
if (isTimeoutStub(parsed)) return null;
if (typeof parsed.text !== "string" || parsed.text.trim().length === 0) {
return null;
}
return {
text: parsed.text,
updatedAt: latestCache.createdAt.toISOString(),
};
} catch {
// Malformed cache payload — treat as a miss and regenerate.
return null;
}
}
+126
View File
@@ -0,0 +1,126 @@
import { resolveProvider, resolveProviderChain } from "@/lib/ai/provider";
import {
AllProvidersFailedError,
runRawCompletionWithFallback,
type ProviderChainResolved,
} from "@/lib/ai/provider-runner";
import { annotate } from "@/lib/logging/context";
import { STATUS_PROVIDER_TIMEOUT_MS, withTimeout } from "./with-timeout";
/**
* Shared provider plumbing for the seven `*-status.ts` generators.
*
* Before this, each generator resolved a single provider via
* `resolveProvider()` and raced one `generateCompletion()` call against
* a 20 s cap. A degraded primary provider could not cascade, and the
* cap fired below the providers' own 60 s floor — converting healthy-
* but-slow generations into the generic fallback.
*
* This helper mirrors the Coach (`chat/route.ts`): resolve the user's
* provider chain, fall back to the legacy single provider when the chain
* is empty, and run `runRawCompletionWithFallback` so a degraded provider
* cascades to the next. The whole thing is still wrapped in
* `withTimeout` at the aligned 60 s budget so a total stall can't pin the
* card — but a timeout / error is reported as a transient miss (the
* caller serves the fallback for this render without persisting it),
* never as the day's cached assessment.
*/
export type StatusProviderResult =
| { kind: "none" }
| { kind: "timeout" }
| { kind: "error" }
| {
kind: "ok";
content: string;
providerType: string;
model: string;
tokensUsed: number | null;
};
interface RunStatusCompletionArgs {
userId: string;
cacheAction: string;
systemPrompt: string;
userPrompt: string;
temperature?: number;
maxTokens?: number;
}
/**
* Resolve the provider chain for `userId`, falling back to the legacy
* single provider. Returns `null` when the user has no usable provider
* anywhere (the caller surfaces the no-key fallback with
* `hasProvider:false`).
*/
async function resolveStatusChain(
userId: string,
): Promise<ProviderChainResolved[] | null> {
const chain = await resolveProviderChain(userId);
if (chain.length > 0) return chain;
const legacy = await resolveProvider(userId);
if (legacy.type === "none") return null;
return [{ providerType: "admin-openai", instance: legacy }];
}
/**
* Run a status generation across the user's provider chain, bounded by
* `STATUS_PROVIDER_TIMEOUT_MS`. The result discriminates between
* no-provider / timeout / provider-error / success so the caller can
* decide what to persist — only `ok` is ever cached as the day's
* assessment.
*/
export async function runStatusCompletion(
args: RunStatusCompletionArgs,
): Promise<StatusProviderResult> {
const { userId, cacheAction, systemPrompt, userPrompt } = args;
const chain = await resolveStatusChain(userId);
if (chain === null) {
return { kind: "none" };
}
const raced = await withTimeout(
() =>
runRawCompletionWithFallback({
userId,
providers: chain,
params: {
systemPrompt,
userPrompt,
temperature: args.temperature ?? 0.3,
maxTokens: args.maxTokens ?? 1000,
},
}),
STATUS_PROVIDER_TIMEOUT_MS,
null,
);
if (raced.timedOut) {
return { kind: "timeout" };
}
if (raced.errored || raced.value === null) {
return { kind: "error" };
}
const { result, workingProvider } = raced.value;
const content = result.content;
if (typeof content !== "string" || content.trim().length === 0) {
annotate({
action: { name: "insights.status.empty_content" },
meta: { cacheAction, providerType: workingProvider.providerType },
});
return { kind: "error" };
}
return {
kind: "ok",
content,
providerType: workingProvider.providerType,
model: result.model ?? "unknown",
tokensUsed: result.tokensUsed ?? null,
};
}
export { AllProvidersFailedError };
+46
View File
@@ -0,0 +1,46 @@
import { annotate } from "@/lib/logging/context";
/**
* Shared timeout / provider-error fallback for the InsightStatus routes.
*
* When the upstream provider call exceeds `STATUS_PROVIDER_TIMEOUT_MS`
* (or rejects outright) the route still has to return a deterministic
* envelope so the InsightStatusCard renders the no-key fallback text
* instead of spinning. It used to ALSO persist a sentinel `auditLog`
* row keyed to today's Berlin day. That stub carried the generic
* fallback under the same `text` field a real assessment uses, the
* cache-read served it as valid, and it stuck until midnight — hiding
* the real, data-driven assessment for the rest of the day on any
* account that hit a single transient stall.
*
* The fix removes the persist entirely: a timeout / error is treated as
* a transient miss. The current render still gets the fallback text, but
* nothing is written, so the next mount re-attempts a real generation.
* The shared cache-read (`status-cache.ts`) additionally rejects any
* legacy stub rows still in the table by their `model:"timeout-stub"` /
* `timeout:true` markers.
*
* `cached:true` is kept so the UI does not mislabel the fallback as a
* fresh assessment, and `updatedAt:null` signals "no persisted row".
*/
export function returnTimeoutFallback(input: {
cacheAction: string;
reason: "timeout" | "error";
stubText: string;
}): {
hasProvider: true;
text: string;
cached: true;
updatedAt: null;
} {
annotate({
action: { name: "insights.status.fallback_served" },
meta: { cacheAction: input.cacheAction, reason: input.reason },
});
return {
hasProvider: true,
text: input.stubText,
cached: true,
updatedAt: null,
};
}
+52 -82
View File
@@ -1,5 +1,4 @@
import { prisma } from "@/lib/db";
import { resolveProvider } from "@/lib/ai/provider";
import {
getWeightSystemPrompt,
getWeightUserPrompt,
@@ -19,11 +18,9 @@ import {
type DailyBucket,
} from "@/lib/insights/bucket-series";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
import {
withTimeout,
STATUS_PROVIDER_TIMEOUT_MS,
} from "@/lib/insights/with-timeout";
import { persistTimeoutStubAndReturn } from "@/lib/insights/persist-timeout-stub";
import { runStatusCompletion } from "@/lib/insights/status-provider";
import { readFreshStatusText } from "@/lib/insights/status-cache";
import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback";
import { annotate } from "@/lib/logging/context";
import { toBerlinDayKey } from "@/lib/tz/resolver";
@@ -121,43 +118,22 @@ export async function generateWeightStatusForUser(
const cacheAction = `insights.weight-status.${locale}`;
const todayKey = toBerlinDayKey(new Date());
const provider = await resolveProvider(userId);
if (provider.type === "none") {
return {
hasProvider: false,
text: getNoKeyWeightStatusText(locale),
cached: true,
updatedAt: null,
};
}
const latestCache = await prisma.auditLog.findFirst({
where: { userId, action: cacheAction },
orderBy: { createdAt: "desc" },
select: { createdAt: true, details: true },
// Serve today's real assessment when present. The shared reader
// rejects timeout stubs, so a single stall no longer pins the
// fallback for the day.
const cached = await readFreshStatusText({
userId,
cacheAction,
todayKey,
force,
});
if (!force && latestCache?.details) {
try {
const parsed = JSON.parse(latestCache.details) as {
dateKey?: string;
text?: string;
};
if (
parsed.dateKey === todayKey &&
typeof parsed.text === "string" &&
parsed.text.trim().length > 0
) {
return {
hasProvider: true,
text: parsed.text,
cached: true,
updatedAt: latestCache.createdAt.toISOString(),
};
}
} catch {
// ignore invalid cache payload
}
if (cached) {
return {
hasProvider: true,
text: cached.text,
cached: true,
updatedAt: cached.updatedAt,
};
}
// v1.4.28 FB-D2 — cap the snapshot input. BP captures three types
@@ -402,56 +378,50 @@ export async function generateWeightStatusForUser(
locale,
);
// v1.4.28 FB-D2 — 20 s timeout race; fall back to the no-key text
// on stall so the InsightStatusCard renders deterministically.
const raced = await withTimeout(
() =>
provider.generateCompletion({
systemPrompt: getWeightSystemPrompt(locale),
userPrompt: getWeightUserPrompt(
snapshotJson,
todayKey,
locale,
previousContextBlock,
),
temperature: 0.3,
maxTokens: 1000,
}),
STATUS_PROVIDER_TIMEOUT_MS,
null,
);
if (raced.timedOut || raced.value === null) {
// v1.4.37 — persist a sentinel row keyed to today so the next
// mount short-circuits at the cache lookup above instead of
// re-racing the same 20 s provider call on every cold visit.
// See `persistTimeoutStubAndReturn` for the full rationale.
return persistTimeoutStubAndReturn({
userId,
cacheAction,
// Run the provider chain bounded by the aligned 60 s budget. A
// timeout / error / empty content is a transient miss — serve the
// fallback for this render without persisting it, so the next mount
// re-attempts a real generation rather than sticking the fallback.
const outcome = await runStatusCompletion({
userId,
cacheAction,
systemPrompt: getWeightSystemPrompt(locale),
userPrompt: getWeightUserPrompt(
snapshotJson,
todayKey,
locale,
providerType: provider.type,
previousContextBlock,
),
temperature: 0.3,
maxTokens: 1000,
});
if (outcome.kind === "none") {
return {
hasProvider: false,
text: getNoKeyWeightStatusText(locale),
cached: true,
updatedAt: null,
};
}
if (outcome.kind === "timeout" || outcome.kind === "error") {
return returnTimeoutFallback({
cacheAction,
reason: outcome.kind,
stubText: getNoKeyWeightStatusText(locale),
});
}
const result = raced.value;
const content = result.content;
if (typeof content !== "string" || content.trim().length === 0) {
throw new Error("AI returned empty content for weight-status");
}
let summary = "";
try {
const parsed = JSON.parse(content) as { summary?: string };
const parsed = JSON.parse(outcome.content) as { summary?: string };
if (typeof parsed.summary === "string") {
summary = parsed.summary;
} else {
summary = content;
summary = outcome.content;
}
} catch {
summary = content;
summary = outcome.content;
}
summary = normalizeSummaryText(summary);
@@ -467,9 +437,9 @@ export async function generateWeightStatusForUser(
dateKey: todayKey,
locale,
text: summary,
providerType: provider.type,
model: result.model ?? "unknown",
tokensUsed: result.tokensUsed ?? null,
providerType: outcome.providerType,
model: outcome.model,
tokensUsed: outcome.tokensUsed,
}),
},
select: { createdAt: true },
+43 -25
View File
@@ -1,25 +1,30 @@
import { annotate } from "@/lib/logging/context";
/**
* v1.4.28 R3a FB-D2 — race an awaited promise against a hard timeout.
* Race an awaited promise against a hard timeout.
*
* The seven `*-status.ts` routes await `provider.generateCompletion()`
* with no client-side timeout. When the upstream provider stalls (cold
* cache, transient brown-out) React-Query's three default retries plus
* default exponential backoff stacks on top of the upstream latency
* and the InsightStatusCard spins for up to 90 s before the user sees
* either the cached fallback text or a generic failure.
* and need an upper bound so a stalled provider does not pin the
* InsightStatusCard behind React-Query's retries. `withTimeout` resolves
* with the wrapped promise's value when it lands inside the budget, or
* the `fallback` value otherwise.
*
* `withTimeout(promise, ms, fallback)` resolves with the wrapped
* promise's value when it lands inside the budget, or the `fallback`
* value otherwise. Use the `signal` payload to abort upstream work so
* the original promise does not keep consuming a provider slot after
* the caller has moved on.
* Returns a typed envelope so the call site can distinguish a real
* timeout from an upstream error from a normal value, and react
* differently to each — a timeout/error must NOT be persisted as a
* day-long cache entry the way a real assessment is.
*
* Returns a typed envelope so the call site can distinguish "timed
* out" from "the upstream returned this value" without re-encoding
* the fallback as a magic string.
* The budget matches the provider clients' own 60 s ceiling. The
* earlier 20 s cap fired below the providers' floor on cold starts and
* model warm-ups, converting healthy-but-slow generations into the
* generic fallback; aligning the two lets a slow-but-valid round-trip
* land instead of being discarded.
*/
export interface TimeoutEnvelope<T> {
/** True when the budget expired before the upstream settled. */
timedOut: boolean;
/** True when the upstream rejected before the budget expired. */
errored: boolean;
value: T;
}
@@ -35,7 +40,11 @@ export function withTimeout<T>(
timer = setTimeout(() => {
if (settled) return;
settled = true;
resolve({ timedOut: true, value: fallback });
annotate({
action: { name: "insights.status.provider_timeout" },
meta: { timeout_ms: ms },
});
resolve({ timedOut: true, errored: false, value: fallback });
}, ms);
start()
@@ -46,26 +55,35 @@ export function withTimeout<T>(
clearTimeout(timer);
timer = null;
}
resolve({ timedOut: false, value });
resolve({ timedOut: false, errored: false, value });
})
.catch(() => {
.catch((error: unknown) => {
if (settled) return;
settled = true;
if (timer) {
clearTimeout(timer);
timer = null;
}
// Upstream errored before the timeout fired — surface the
// fallback so the route still returns a deterministic shape
// and the caller does not have to learn another failure mode.
resolve({ timedOut: true, value: fallback });
// Upstream errored before the timeout fired. Surface it as a
// distinct `errored` envelope and annotate the reason so a real
// provider failure is observable in the wide events instead of
// being silently swallowed into the timeout path.
const reason =
error instanceof Error ? error.message : String(error ?? "unknown");
annotate({
action: { name: "insights.status.provider_error" },
meta: { reason: reason.slice(0, 240) },
});
resolve({ timedOut: false, errored: true, value: fallback });
});
});
}
/**
* The 20-second cap shipped in v1.4.28. Provider round-trips that
* exceed this budget are converted to a cached-fallback render with a
* "couldn't reach provider" caption on the InsightStatusCard.
* Status-path provider budget, aligned with the provider clients' own
* 60 s ceiling. A round-trip that exceeds this is treated as a transient
* miss — the caller returns the deterministic fallback for this render
* WITHOUT persisting it as the day's assessment, so the next mount
* re-attempts a real generation.
*/
export const STATUS_PROVIDER_TIMEOUT_MS = 20_000;
export const STATUS_PROVIDER_TIMEOUT_MS = 60_000;