mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
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:
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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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;
|
||||
Reference in new issue
Block a user