mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
fix(insights): honor the per-user AI timeout and keep the last good narrative
The daily briefing and the per-metric status assessments ignored the per-user response-timeout setting: they hardcoded their per-surface AI_BUDGETS default, so raising the timeout for a slow self-hosted or local backend had no effect and the surfaces still aborted early. Only the coach threaded the setting through. Resolve the effective timeout from User.aiResponseTimeoutSeconds in one shared helper and thread it onto every non-coach generation call: - runStatusCompletion is the single chokepoint every status / reference surface funnels through (per-metric cards, the batched assessment, the derived assessments, the period narratives), so resolving it there applies the setting to all of them at once — on both the upstream call's own timeout and the outer wall-clock cap that previously clipped a raised value at 60 s. - The comprehensive briefing generator and the user-initiated regenerate route thread it onto the main generation, the JSON retry, the grounding retry, and the paragraph re-roll. On a failed generation the briefing keeps its last good text — no cache row is written, so the previous payload stays intact — and records a dated, append-only failure marker (no migration; reuses the audit-log JSON pattern). The read path compares the marker against the last successful generation so it can tell a held briefing from a current one.
This commit is contained in:
1 parent
bc617adc2c
commit
556f442724
9 files changed
+484
-9
No files matched your search
@@ -48,6 +48,11 @@ vi.mock("@/lib/db", () => ({
|
||||
// generation. The test prisma mock has to surface the call so
|
||||
// the cache-invalidation test can assert against it.
|
||||
deleteMany: vi.fn(async () => ({ count: 0 })),
|
||||
// v1.25 — the GET read path consults the briefing-failure marker
|
||||
// (`readBriefingFailure`) and the failure paths append one
|
||||
// (`recordBriefingFailure`). Default to no marker.
|
||||
findFirst: vi.fn(async () => null),
|
||||
create: vi.fn(async () => ({})),
|
||||
},
|
||||
// v1.12.1 — the server-managed consent gate reads the latest active
|
||||
// receipt before egress. These fixtures resolve to an admin-openai
|
||||
|
||||
@@ -61,6 +61,11 @@ import {
|
||||
type InsightResult,
|
||||
} from "@/lib/ai/types";
|
||||
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
|
||||
import { resolveEffectiveTimeoutMs } from "@/lib/ai/effective-timeout";
|
||||
import {
|
||||
recordBriefingFailure,
|
||||
readBriefingFailure,
|
||||
} from "@/lib/insights/briefing-failure-marker";
|
||||
import { resolveProvider, resolveProviderChain } from "@/lib/ai/provider";
|
||||
import {
|
||||
AllProvidersFailedError,
|
||||
@@ -307,6 +312,15 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
// connect-an-AI-provider hint instead of implying the read is live.
|
||||
const hasProvider = await hasUsableStatusProvider(userId);
|
||||
|
||||
// v1.25 — surface whether the most recent generation attempt failed (a
|
||||
// marker newer than the last successful generation). The briefing keeps its
|
||||
// last good text on failure, so this is the only honest signal that a shown
|
||||
// briefing is held rather than freshly refreshed — and, when there is no
|
||||
// last good text, that the empty state should read "couldn't generate" with
|
||||
// a retry rather than the generic "no briefing yet".
|
||||
const generationFailed =
|
||||
(await readBriefingFailure({ userId, since: cachedAt })) !== null;
|
||||
|
||||
// Read-only: never block on the provider. Warm out of band only when the
|
||||
// cached briefing is stale / missing AND a provider is configured (a
|
||||
// provider-less account costs one cheap chain-resolve and shows the
|
||||
@@ -351,6 +365,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
// the served briefing can never refresh, so the UI pairs its age
|
||||
// with a connect-provider affordance.
|
||||
hasProvider,
|
||||
// v1.25 — true when the last generation attempt failed (held text).
|
||||
generationFailed,
|
||||
});
|
||||
} catch {
|
||||
// Invalid cache row — fall through to the empty payload below. The
|
||||
@@ -370,6 +386,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
revalidating,
|
||||
// v1.18.9 (#4) — see the cached branch above.
|
||||
hasProvider,
|
||||
// v1.25 — no last-good text AND the last attempt failed: the UI shows a
|
||||
// "couldn't generate" empty state with a retry instead of the generic one.
|
||||
generationFailed,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -403,9 +422,17 @@ export const POST = apiHandler((request: NextRequest) =>
|
||||
heightCm: true,
|
||||
// v1.22 (W6) — first name for the sparse, hash-gated briefing opener.
|
||||
displayName: true,
|
||||
// v1.25 — per-user response-timeout (seconds), threaded onto the
|
||||
// generation calls so a slow self-hosted backend honours the setting.
|
||||
aiResponseTimeoutSeconds: true,
|
||||
},
|
||||
});
|
||||
|
||||
const effectiveTimeoutMs = resolveEffectiveTimeoutMs(
|
||||
dbUser?.aiResponseTimeoutSeconds,
|
||||
AI_BUDGETS.comprehensive.timeoutMs,
|
||||
);
|
||||
|
||||
const locale = await resolveServerLocale({
|
||||
request,
|
||||
userLocale: dbUser?.locale ?? user.locale ?? null,
|
||||
@@ -717,7 +744,8 @@ export const POST = apiHandler((request: NextRequest) =>
|
||||
// v1.21.5 — wider upstream budget so the reasoning-heavy briefing
|
||||
// generation is not aborted at the client's 60 s default on large
|
||||
// accounts (which returned an empty briefing + trend narrative).
|
||||
timeoutMs: AI_BUDGETS.comprehensive.timeoutMs,
|
||||
// v1.25 — honours the per-user response-timeout setting.
|
||||
timeoutMs: effectiveTimeoutMs,
|
||||
// The reply is parsed with `JSON.parse` below, so opt the OpenAI /
|
||||
// Codex chains into their strict JSON mode (gated on this flag).
|
||||
responseFormat: "json",
|
||||
@@ -727,6 +755,18 @@ export const POST = apiHandler((request: NextRequest) =>
|
||||
workingProviderType = fallback.workingProvider.providerType;
|
||||
fallbackHopCount = fallback.fallbackHops.length;
|
||||
} catch (e) {
|
||||
// v1.25 — record a dated failure marker (no cache row is written on this
|
||||
// path, so the last good briefing stays intact). The read path pairs the
|
||||
// preserved text with a discreet "couldn't refresh" hint, or shows a
|
||||
// retry empty state when there is no last good text.
|
||||
void recordBriefingFailure({
|
||||
userId,
|
||||
reason:
|
||||
e instanceof AllProvidersFailedError
|
||||
? "all-providers-failed"
|
||||
: "provider-error",
|
||||
locale,
|
||||
});
|
||||
if (e instanceof AllProvidersFailedError) {
|
||||
annotate({
|
||||
meta: {
|
||||
@@ -826,6 +866,7 @@ export const POST = apiHandler((request: NextRequest) =>
|
||||
// breaks `await res.json()` on the client side. 422 stays passthrough
|
||||
// so the React Query mutation can read the JSON body and surface a
|
||||
// readable message. Same fix pattern as v1.4.5 ai/test.
|
||||
void recordBriefingFailure({ userId, reason: "invalid-json", locale });
|
||||
return apiError("AI response was not valid JSON", 422);
|
||||
}
|
||||
|
||||
@@ -856,7 +897,8 @@ export const POST = apiHandler((request: NextRequest) =>
|
||||
temperature: 0.3,
|
||||
maxTokens: 1500,
|
||||
// v1.21.5 — wider upstream budget; see the first generation call.
|
||||
timeoutMs: AI_BUDGETS.comprehensive.timeoutMs,
|
||||
// v1.25 — honours the per-user response-timeout setting.
|
||||
timeoutMs: effectiveTimeoutMs,
|
||||
// Parsed with `JSON.parse` below — opt OpenAI / Codex into JSON mode.
|
||||
responseFormat: "json",
|
||||
}),
|
||||
|
||||
Reference in new issue
Block a user