From 4ad4707be9ffc39f6aad13ad6ebded12bc2be9d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 29 Jun 2026 15:08:04 +0200 Subject: [PATCH] fix(insights): derive briefing warm budget from the response timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The comprehensive briefing warm/cron path wrapped generation in a fixed 130 s outer abort, but the per-user response-timeout setting accepts 10-600 s and the AI settings tell users to raise it for slow local models. Any account whose response timeout was >= ~130 s had every comprehensive warm clipped by the warm bound, not the provider, so the generation never landed: no cached briefing row was written and the dashboard fell to the hard empty state. Size the warm outer abort from the resolved effective timeout (`aiResponseTimeoutSeconds * 1000`, falling back to the comprehensive surface budget) plus a fixed headroom for feature extraction, prompt assembly, and the cache write. A raised setting now extends the warm and nightly budgets instead of being silently capped. An absolute ceiling (the 600 s write-time maximum plus the headroom) keeps a wedged call from pinning the worker while staying above the largest value a user can set. Both the nightly loop and the forced on-visit warm read the per-user setting and use the derived budget. Make the empty state more useful: record the upstream HTTP status on a failure marker and classify it (timeout / auth / rate-limit / provider / format), so the "couldn't generate" state points at the right lever — raise the response timeout for a timeout, re-check the provider for an auth rejection — instead of one generic line. Keep-last-good behaviour is unchanged. Align the response-timeout field copy with the actual default budget (~120 s, not ~180 s) across the placeholder, the OpenAPI description, and the inline note. --- docs/api/openapi.yaml | 4 +- messages/de.json | 7 +- messages/en.json | 7 +- messages/es.json | 7 +- messages/fr.json | 7 +- messages/it.json | 7 +- messages/pl.json | 7 +- src/app/api/insights/generate/route.ts | 26 ++++- src/app/insights/page.tsx | 3 + src/components/insights/daily-briefing.tsx | 25 ++++- .../insights/use-insights-advisor.ts | 19 +++- .../settings/ai/response-timeout-card.tsx | 3 +- .../__tests__/briefing-failure-marker.test.ts | 70 ++++++++++++ src/lib/insights/briefing-failure-marker.ts | 62 ++++++++++- src/lib/insights/comprehensive-generate.ts | 7 +- .../__tests__/insight-pregenerate.test.ts | 105 ++++++++++++++++-- src/lib/jobs/insight-pregenerate.ts | 99 +++++++++++++++-- src/lib/openapi/routes/profile.ts | 2 +- 18 files changed, 426 insertions(+), 41 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index eb8ad6004..0215b7388 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -16947,8 +16947,8 @@ components: minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" - description: v1.22 (#89) — per-user response timeout for AI generation, in seconds (10–600). Null = the generous - built-in default (~180 s). Mainly for slow local/self-hosted backends. + description: v1.22 (#89) — per-user response timeout for AI generation, in seconds (10–600). Null = the built-in + comprehensive-briefing default (~120 s). Mainly for slow local/self-hosted backends. ocrEnabled: type: boolean description: v1.22 (#90) — whether a dedicated document-scan (Lab-OCR) provider is active. When false the OCR path uses diff --git a/messages/de.json b/messages/de.json index c97fb115e..65ae113a8 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2066,7 +2066,10 @@ "refreshFailedHint": "Briefing konnte nicht aktualisiert werden — es wird das letzte angezeigt.", "retryAction": "Erneut versuchen", "failedTitle": "Briefing konnte nicht erstellt werden", - "failedDescription": "Der KI-Anbieter hat das Zeitlimit überschritten oder einen Fehler zurückgegeben. Versuche es erneut oder erhöhe in den KI-Einstellungen das Antwort-Zeitlimit für ein langsames lokales Modell." + "failedDescription": "Der KI-Anbieter hat das Zeitlimit überschritten oder einen Fehler zurückgegeben. Versuche es erneut oder erhöhe in den KI-Einstellungen das Antwort-Zeitlimit für ein langsames lokales Modell.", + "failedDescriptionTimeout": "Die KI-Anfrage hat das Zeitlimit überschritten, bevor das Briefing fertig war. Erhöhe für ein langsames lokales Modell in den Einstellungen das Antwort-Zeitlimit und versuche es dann erneut.", + "failedDescriptionProvider": "Der KI-Anbieter hat die Anfrage abgelehnt. Prüfe Anbieter und API-Schlüssel in den KI-Einstellungen und versuche es erneut.", + "failedDescriptionRateLimit": "Der KI-Anbieter drosselt derzeit die Anfragen. Versuche es in Kürze erneut." }, "trendsRow": { "title": "Trends", @@ -4671,7 +4674,7 @@ "responseTimeoutHeading": "Antwort-Timeout", "responseTimeoutBody": "Wie lange auf die Antwort der KI gewartet wird, bevor abgebrochen wird. Erhöhe den Wert für ein lokales oder selbst gehostetes Modell, das beim ersten Aufruf Zeit zum Laden braucht.", "responseTimeoutLabel": "Antwort-Timeout (Sekunden)", - "responseTimeoutPlaceholder": "Standard (~180)", + "responseTimeoutPlaceholder": "Standard (~120)", "responseTimeoutInvalid": "Gib eine Zahl zwischen 10 und 600 Sekunden ein.", "ocrHeading": "Dokumentenscan (Laborberichte)", "ocrBody": "Verwende einen eigenen Anbieter zum Auslesen hochgeladener Laborberichte. Ist die Option aus, nutzt der Scan deinen Hauptanbieter.", diff --git a/messages/en.json b/messages/en.json index 3f2844f28..d149fffea 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2066,7 +2066,10 @@ "refreshFailedHint": "Couldn't refresh the briefing — showing the last one.", "retryAction": "Retry", "failedTitle": "Couldn't generate the briefing", - "failedDescription": "The AI provider timed out or returned an error. Try again, or raise the response timeout in AI settings for a slow local model." + "failedDescription": "The AI provider timed out or returned an error. Try again, or raise the response timeout in AI settings for a slow local model.", + "failedDescriptionTimeout": "The AI request ran out of time before the briefing was ready. For a slow local model, raise the AI response timeout in Settings, then try again.", + "failedDescriptionProvider": "The AI provider rejected the request. Re-check the provider and API key in AI settings, then try again.", + "failedDescriptionRateLimit": "The AI provider is rate-limiting requests right now. Try again in a little while." }, "trendsRow": { "title": "Trends", @@ -4671,7 +4674,7 @@ "responseTimeoutHeading": "Response timeout", "responseTimeoutBody": "How long to wait for the AI to respond before giving up. Raise it for a local or self-hosted model that needs time to load on the first request.", "responseTimeoutLabel": "Response timeout (seconds)", - "responseTimeoutPlaceholder": "Default (~180)", + "responseTimeoutPlaceholder": "Default (~120)", "responseTimeoutInvalid": "Enter a number between 10 and 600 seconds.", "ocrHeading": "Document scanning (lab reports)", "ocrBody": "Use a dedicated provider for reading uploaded lab reports. When off, scanning uses your main provider.", diff --git a/messages/es.json b/messages/es.json index 98aee4b3d..f3090ccd7 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2066,7 +2066,10 @@ "refreshFailedHint": "No se pudo actualizar el briefing: se muestra el anterior.", "retryAction": "Reintentar", "failedTitle": "No se pudo generar el briefing", - "failedDescription": "El proveedor de IA agotó el tiempo de espera o devolvió un error. Inténtalo de nuevo o aumenta el tiempo de espera de respuesta en los ajustes de IA para un modelo local lento." + "failedDescription": "El proveedor de IA agotó el tiempo de espera o devolvió un error. Inténtalo de nuevo o aumenta el tiempo de espera de respuesta en los ajustes de IA para un modelo local lento.", + "failedDescriptionTimeout": "La solicitud de IA agotó el tiempo de espera antes de que el resumen estuviera listo. Para un modelo local lento, aumenta el tiempo de espera de respuesta en Ajustes y vuelve a intentarlo.", + "failedDescriptionProvider": "El proveedor de IA rechazó la solicitud. Revisa el proveedor y la clave de API en los ajustes de IA y vuelve a intentarlo.", + "failedDescriptionRateLimit": "El proveedor de IA está limitando las solicitudes en este momento. Vuelve a intentarlo en un rato." }, "trendsRow": { "title": "Tendencias", @@ -4671,7 +4674,7 @@ "responseTimeoutHeading": "Tiempo de espera de la respuesta", "responseTimeoutBody": "Cuánto esperar a que responda la IA antes de cancelar. Aumenta el valor para un modelo local o autoalojado que necesita tiempo para cargarse en la primera solicitud.", "responseTimeoutLabel": "Tiempo de espera de la respuesta (segundos)", - "responseTimeoutPlaceholder": "Predeterminado (~180)", + "responseTimeoutPlaceholder": "Predeterminado (~120)", "responseTimeoutInvalid": "Introduce un número entre 10 y 600 segundos.", "ocrHeading": "Escaneo de documentos (informes de laboratorio)", "ocrBody": "Usa un proveedor dedicado para leer los informes de laboratorio subidos. Si está desactivado, el escaneo usa tu proveedor principal.", diff --git a/messages/fr.json b/messages/fr.json index 80c0be5fe..2064f3c52 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2066,7 +2066,10 @@ "refreshFailedHint": "Impossible d'actualiser le briefing — affichage du précédent.", "retryAction": "Réessayer", "failedTitle": "Impossible de générer le briefing", - "failedDescription": "Le fournisseur d'IA a dépassé le délai ou renvoyé une erreur. Réessayez ou augmentez le délai de réponse dans les paramètres d'IA pour un modèle local lent." + "failedDescription": "Le fournisseur d'IA a dépassé le délai ou renvoyé une erreur. Réessayez ou augmentez le délai de réponse dans les paramètres d'IA pour un modèle local lent.", + "failedDescriptionTimeout": "La requête IA a dépassé le délai avant que le briefing soit prêt. Pour un modèle local lent, augmentez le délai de réponse dans les Paramètres, puis réessayez.", + "failedDescriptionProvider": "Le fournisseur d'IA a rejeté la requête. Vérifiez le fournisseur et la clé API dans les paramètres d'IA, puis réessayez.", + "failedDescriptionRateLimit": "Le fournisseur d'IA limite les requêtes pour le moment. Réessayez dans un instant." }, "trendsRow": { "title": "Tendances", @@ -4671,7 +4674,7 @@ "responseTimeoutHeading": "Délai de réponse", "responseTimeoutBody": "Durée d'attente de la réponse de l'IA avant d'abandonner. Augmentez-la pour un modèle local ou auto-hébergé qui a besoin de temps pour se charger à la première requête.", "responseTimeoutLabel": "Délai de réponse (secondes)", - "responseTimeoutPlaceholder": "Par défaut (~180)", + "responseTimeoutPlaceholder": "Par défaut (~120)", "responseTimeoutInvalid": "Saisissez un nombre entre 10 et 600 secondes.", "ocrHeading": "Numérisation de documents (comptes rendus de laboratoire)", "ocrBody": "Utilisez un fournisseur dédié pour lire les comptes rendus de laboratoire importés. Désactivé, la numérisation utilise votre fournisseur principal.", diff --git a/messages/it.json b/messages/it.json index 03a3fa8ed..3111edab1 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2066,7 +2066,10 @@ "refreshFailedHint": "Impossibile aggiornare il briefing: viene mostrato l'ultimo.", "retryAction": "Riprova", "failedTitle": "Impossibile generare il briefing", - "failedDescription": "Il provider IA ha superato il tempo limite o ha restituito un errore. Riprova oppure aumenta il timeout di risposta nelle impostazioni IA per un modello locale lento." + "failedDescription": "Il provider IA ha superato il tempo limite o ha restituito un errore. Riprova oppure aumenta il timeout di risposta nelle impostazioni IA per un modello locale lento.", + "failedDescriptionTimeout": "La richiesta IA ha superato il tempo limite prima che il briefing fosse pronto. Per un modello locale lento, aumenta il timeout di risposta nelle Impostazioni e riprova.", + "failedDescriptionProvider": "Il provider IA ha rifiutato la richiesta. Controlla il provider e la chiave API nelle impostazioni IA e riprova.", + "failedDescriptionRateLimit": "Il provider IA sta limitando le richieste in questo momento. Riprova tra poco." }, "trendsRow": { "title": "Tendenze", @@ -4671,7 +4674,7 @@ "responseTimeoutHeading": "Timeout della risposta", "responseTimeoutBody": "Quanto attendere la risposta dell'IA prima di annullare. Aumentalo per un modello locale o self-hosted che ha bisogno di tempo per caricarsi alla prima richiesta.", "responseTimeoutLabel": "Timeout della risposta (secondi)", - "responseTimeoutPlaceholder": "Predefinito (~180)", + "responseTimeoutPlaceholder": "Predefinito (~120)", "responseTimeoutInvalid": "Inserisci un numero tra 10 e 600 secondi.", "ocrHeading": "Scansione documenti (referti di laboratorio)", "ocrBody": "Usa un provider dedicato per leggere i referti di laboratorio caricati. Se disattivato, la scansione usa il tuo provider principale.", diff --git a/messages/pl.json b/messages/pl.json index 4b2ba446c..8ef6c060c 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2066,7 +2066,10 @@ "refreshFailedHint": "Nie udało się odświeżyć briefingu — pokazujemy poprzedni.", "retryAction": "Spróbuj ponownie", "failedTitle": "Nie udało się wygenerować briefingu", - "failedDescription": "Dostawca AI przekroczył limit czasu lub zwrócił błąd. Spróbuj ponownie albo zwiększ limit czasu odpowiedzi w ustawieniach AI dla wolnego modelu lokalnego." + "failedDescription": "Dostawca AI przekroczył limit czasu lub zwrócił błąd. Spróbuj ponownie albo zwiększ limit czasu odpowiedzi w ustawieniach AI dla wolnego modelu lokalnego.", + "failedDescriptionTimeout": "Żądanie AI przekroczyło limit czasu, zanim briefing był gotowy. W przypadku wolnego modelu lokalnego zwiększ limit czasu odpowiedzi w Ustawieniach i spróbuj ponownie.", + "failedDescriptionProvider": "Dostawca AI odrzucił żądanie. Sprawdź dostawcę i klucz API w ustawieniach AI, a następnie spróbuj ponownie.", + "failedDescriptionRateLimit": "Dostawca AI ogranicza teraz liczbę żądań. Spróbuj ponownie za chwilę." }, "trendsRow": { "title": "Trendy", @@ -4671,7 +4674,7 @@ "responseTimeoutHeading": "Limit czasu odpowiedzi", "responseTimeoutBody": "Jak długo czekać na odpowiedź AI, zanim nastąpi przerwanie. Zwiększ wartość dla lokalnego lub samodzielnie hostowanego modelu, który potrzebuje czasu na załadowanie przy pierwszym żądaniu.", "responseTimeoutLabel": "Limit czasu odpowiedzi (sekundy)", - "responseTimeoutPlaceholder": "Domyślnie (~180)", + "responseTimeoutPlaceholder": "Domyślnie (~120)", "responseTimeoutInvalid": "Podaj liczbę od 10 do 600 sekund.", "ocrHeading": "Skanowanie dokumentów (wyniki badań)", "ocrBody": "Użyj dedykowanego dostawcy do odczytu przesłanych wyników badań. Gdy wyłączone, skanowanie używa Twojego głównego dostawcy.", diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts index 5dcf2081c..9a16bad10 100644 --- a/src/app/api/insights/generate/route.ts +++ b/src/app/api/insights/generate/route.ts @@ -319,8 +319,14 @@ export const GET = apiHandler(async (request: NextRequest) => { // 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; + const briefingFailure = await readBriefingFailure({ + userId, + since: cachedAt, + }); + const generationFailed = briefingFailure !== null; + // v1.25.3 — the failure class lets the empty state point its hint at the + // right lever (raise the response timeout vs re-check the provider). + const generationFailureClass = briefingFailure?.failureClass ?? 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 @@ -368,6 +374,9 @@ export const GET = apiHandler(async (request: NextRequest) => { hasProvider, // v1.25 — true when the last generation attempt failed (held text). generationFailed, + // v1.25.3 — coarse failure class for the empty-state hint (null when + // the last attempt succeeded). + generationFailureClass, }); } catch { // Invalid cache row — fall through to the empty payload below. The @@ -390,6 +399,8 @@ export const GET = apiHandler(async (request: NextRequest) => { // 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, + // v1.25.3 — coarse failure class for the empty-state hint. + generationFailureClass, }); }); @@ -760,6 +771,16 @@ export const POST = apiHandler((request: NextRequest) => // 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. + // v1.25.3 — carry the upstream status so the read-path hint can tell an + // auth / rate-limit failure apart from a plain timeout. For an + // all-providers-failed error the first hop's status is the most + // representative; a single provider error exposes its own `httpStatus`. + const failureHttpStatus = + e instanceof AllProvidersFailedError + ? (e.attempts[0]?.httpStatus ?? null) + : typeof (e as { httpStatus?: number }).httpStatus === "number" + ? (e as { httpStatus?: number }).httpStatus + : null; void recordBriefingFailure({ userId, reason: @@ -767,6 +788,7 @@ export const POST = apiHandler((request: NextRequest) => ? "all-providers-failed" : "provider-error", locale, + httpStatus: failureHttpStatus, }); if (e instanceof AllProvidersFailedError) { annotate({ diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx index 48050141e..c65baad7b 100644 --- a/src/app/insights/page.tsx +++ b/src/app/insights/page.tsx @@ -480,6 +480,9 @@ export default function InsightsPage() { // generate" one. Suppressed when no provider is configured (that hint // owns the surface and a retry would be futile). generationFailed={advisor.generationFailed && advisor.hasProvider} + // v1.25.3 — failure class points the empty-state hint at the right + // lever (raise the response timeout vs re-check the provider). + generationFailureClass={advisor.generationFailureClass} // v1.21.2 (A4) — server-resolved recall + forward-look off the dashboard // snapshot. Both strings are already localised; null leaves the card's // memory block unrendered. diff --git a/src/components/insights/daily-briefing.tsx b/src/components/insights/daily-briefing.tsx index 311b761de..309da0fc7 100644 --- a/src/components/insights/daily-briefing.tsx +++ b/src/components/insights/daily-briefing.tsx @@ -36,6 +36,7 @@ import type { DailyBriefingKeyFinding, DailyBriefingSignal, } from "@/lib/ai/schema"; +import type { BriefingFailureClass } from "@/lib/insights/briefing-failure-marker"; /** * v1.4.27 MB7 / CF-68 — per-metric routing target for the briefing @@ -127,6 +128,13 @@ interface DailyBriefingProps { * generic empty state for a "couldn't generate" one whose CTA retries. */ generationFailed?: boolean; + /** + * v1.25.3 — coarse class of the most recent failure. When the empty state is + * shown ("couldn't generate"), it lets the hint point at the right lever: + * `timeout` → raise the AI response timeout, `auth` → re-check the provider. + * Absent / null → the generic failed-description holds. + */ + generationFailureClass?: BriefingFailureClass | null; /** * Optional slot for a meta control mounted in the card header — the * comparison toggle migrates here from the hero in commit 5. @@ -358,6 +366,7 @@ export function DailyBriefing({ noProvider = false, noProviderStale = false, generationFailed = false, + generationFailureClass = null, metaSlot, memory = null, }: DailyBriefingProps) { @@ -365,6 +374,20 @@ export function DailyBriefing({ const fmt = useFormatters(); const { user } = useAuth(); + // v1.25.3 — when there is no last good text and the last attempt failed, + // point the hint at the lever that matches the failure class. A timeout (the + // dominant failure on a slow self-hosted model) → raise the AI response + // timeout; an auth / config rejection → re-check the provider in Settings. + // Anything else keeps the calmer generic line. Non-alarming by design. + const failedDescriptionKey = + generationFailureClass === "timeout" + ? "insights.dailyBriefing.failedDescriptionTimeout" + : generationFailureClass === "auth" + ? "insights.dailyBriefing.failedDescriptionProvider" + : generationFailureClass === "rate-limit" + ? "insights.dailyBriefing.failedDescriptionRateLimit" + : "insights.dailyBriefing.failedDescription"; + return ( // v1.13.1 — heading-above-card pattern. The "Tagesbriefing" title // moves OUT of the card into an `

` above it (mirroring the @@ -588,7 +611,7 @@ export function DailyBriefing({ } description={ generationFailed - ? t("insights.dailyBriefing.failedDescription") + ? t(failedDescriptionKey) : t("insights.dailyBriefing.emptyDescription") } action={ diff --git a/src/components/insights/use-insights-advisor.ts b/src/components/insights/use-insights-advisor.ts index d271742df..83f0eaadc 100644 --- a/src/components/insights/use-insights-advisor.ts +++ b/src/components/insights/use-insights-advisor.ts @@ -16,6 +16,7 @@ import type { } from "@/lib/ai/schema"; import { queryKeys } from "@/lib/query-keys"; import { apiFetchRaw } from "@/lib/api/api-fetch"; +import type { BriefingFailureClass } from "@/lib/insights/briefing-failure-marker"; /** * v1.4.16 phase D reconcile (CRITICAL C1 + C2) — shared TanStack Query @@ -80,6 +81,13 @@ export interface InsightAdvisorPayload { * Absent on a pre-field cached payload → treated as "not failed" (no hint). */ generationFailed?: boolean; + /** + * v1.25.3 — coarse class of the most recent failure (`timeout`, `auth`, + * `rate-limit`, `provider`, `format`, `unknown`), so the empty state can + * point its hint at the right lever. Null / absent → no specific hint, the + * generic failed-description holds. + */ + generationFailureClass?: BriefingFailureClass | null; } /** @@ -99,8 +107,7 @@ const ADVISOR_TIMEOUT_MS = 8_000; * discarded a slow-but-successful generation — the abort returned a null * payload, the mutation only invalidated (re-reading the OLD cache), yet * the server had often already written the fresh briefing. Give the force - * branch the same 45 s budget the out-of-band warm job uses - * (`COMPREHENSIVE_WARM_TIMEOUT_MS`) so a slow success is kept, not + * branch a generous 45 s client budget so a slow success is kept, not * dropped. The READ path stays fast at 8 s — only the explicit user tap * may wait. Per `.planning/v1.15.18-daily-briefing-audit.md` Fix 1b. */ @@ -291,6 +298,12 @@ export interface UseInsightsAdvisorResult { * when there is no last good text. Defaults false (no hint) when absent. */ generationFailed: boolean; + /** + * v1.25.3 — coarse class of the most recent failure, so the empty state can + * point its hint at the right lever. Null when the last attempt succeeded or + * the payload predates the field. + */ + generationFailureClass: BriefingFailureClass | null; } /** @@ -379,5 +392,7 @@ export function useInsightsAdvisorQuery( // Absent (pre-field cached payload or unsettled query) → not failed, so a // transient unknown never flashes a false "couldn't refresh" hint. generationFailed: query.data?.payload?.generationFailed ?? false, + // Absent → no specific lever hint; the generic failed-description holds. + generationFailureClass: query.data?.payload?.generationFailureClass ?? null, }; } diff --git a/src/components/settings/ai/response-timeout-card.tsx b/src/components/settings/ai/response-timeout-card.tsx index c87f64e2d..590d50c26 100644 --- a/src/components/settings/ai/response-timeout-card.tsx +++ b/src/components/settings/ai/response-timeout-card.tsx @@ -7,7 +7,8 @@ * call (`CompletionParams.timeoutMs`). Surfaced here mainly for local / * self-hosted backends: an MLX/exo server can take >60 s on the first request * while it loads the model, which the legacy 60 s default timed out before the - * first token landed. Empty = the generous built-in default (~180 s). + * first token landed. Empty = the built-in comprehensive-briefing default + * (~120 s, `AI_BUDGETS.comprehensive.timeoutMs`). * ──────────────────────────────────────────────────────────────── */ import { useState } from "react"; diff --git a/src/lib/insights/__tests__/briefing-failure-marker.test.ts b/src/lib/insights/__tests__/briefing-failure-marker.test.ts index e50ff58d3..3ea4e5ad0 100644 --- a/src/lib/insights/__tests__/briefing-failure-marker.test.ts +++ b/src/lib/insights/__tests__/briefing-failure-marker.test.ts @@ -21,6 +21,7 @@ vi.mock("@/lib/db", () => ({ import { readBriefingFailure, recordBriefingFailure, + classifyBriefingFailure, BRIEFING_FAILURE_ACTION, } from "../briefing-failure-marker"; @@ -45,6 +46,34 @@ describe("readBriefingFailure", () => { ); }); + it("derives a failure class from the recorded reason + httpStatus (v1.25.3)", async () => { + findFirst.mockResolvedValue({ + createdAt: new Date("2026-06-28T10:00:00Z"), + details: JSON.stringify({ reason: "provider-error", httpStatus: 401 }), + }); + + const result = await readBriefingFailure({ + userId: "u1", + since: new Date("2026-06-28T09:00:00Z"), + }); + + expect(result?.failureClass).toBe("auth"); + }); + + it("classes a status-less failure as a timeout (the slow-backend case)", async () => { + findFirst.mockResolvedValue({ + createdAt: new Date("2026-06-28T10:00:00Z"), + details: JSON.stringify({ reason: "provider-error" }), + }); + + const result = await readBriefingFailure({ + userId: "u1", + since: new Date("2026-06-28T09:00:00Z"), + }); + + expect(result?.failureClass).toBe("timeout"); + }); + it("treats a marker older than the last success as superseded", async () => { findFirst.mockResolvedValue({ createdAt: new Date("2026-06-28T08:00:00Z"), @@ -89,4 +118,45 @@ describe("recordBriefingFailure", () => { recordBriefingFailure({ userId: "u1", reason: "timeout" }), ).resolves.toBeUndefined(); }); + + it("persists the upstream httpStatus when one is supplied (v1.25.3)", async () => { + create.mockResolvedValue({}); + await recordBriefingFailure({ + userId: "u1", + reason: "provider-error", + httpStatus: 429, + }); + const details = create.mock.calls[0][0].data.details as string; + expect(JSON.parse(details).httpStatus).toBe(429); + }); +}); + +describe("classifyBriefingFailure", () => { + it("maps invalid-json to format regardless of status", () => { + expect(classifyBriefingFailure({ reason: "invalid-json" })).toBe("format"); + }); + + it("maps 429 to rate-limit, other 4xx to auth, 5xx to provider", () => { + expect( + classifyBriefingFailure({ reason: "provider-error", httpStatus: 429 }), + ).toBe("rate-limit"); + expect( + classifyBriefingFailure({ reason: "provider-error", httpStatus: 403 }), + ).toBe("auth"); + expect( + classifyBriefingFailure({ reason: "provider-error", httpStatus: 503 }), + ).toBe("provider"); + }); + + it("maps a missing / sentinel-zero status to timeout", () => { + expect(classifyBriefingFailure({ reason: "provider-error" })).toBe( + "timeout", + ); + expect( + classifyBriefingFailure({ + reason: "all-providers-failed", + httpStatus: 0, + }), + ).toBe("timeout"); + }); }); diff --git a/src/lib/insights/briefing-failure-marker.ts b/src/lib/insights/briefing-failure-marker.ts index ad11c863d..71abc304a 100644 --- a/src/lib/insights/briefing-failure-marker.ts +++ b/src/lib/insights/briefing-failure-marker.ts @@ -19,6 +19,46 @@ import { prisma } from "@/lib/db"; */ export const BRIEFING_FAILURE_ACTION = "insights.briefing-failure"; +/** + * Coarse class of a briefing-generation failure, used by the read path to + * point the empty-state hint at the right lever: + * - `timeout` — the generation ran out of time (no upstream HTTP status, + * or a transport/abort error). Lever: raise the AI response + * timeout for a slow local model. + * - `auth` — the provider rejected the request (4xx that is not 429). + * Lever: re-check the provider / API key in Settings. + * - `rate-limit` — the provider returned 429. + * - `provider` — a 5xx / generic upstream failure. + * - `format` — the model returned unparseable output (invalid JSON). + * - `unknown` — anything that does not classify. + */ +export type BriefingFailureClass = + "timeout" | "auth" | "rate-limit" | "provider" | "format" | "unknown"; + +/** + * Classify a failure from its recorded reason and (optional) upstream HTTP + * status. A reason of `invalid-json` is always a format miss; a 401/403 (or any + * other non-429 4xx) is an auth / configuration problem; 429 is a rate limit; a + * 5xx is a generic provider failure. With no status at all — the timeout / + * transport / abort path, which is the dominant briefing failure on a slow + * self-hosted backend — the most useful lever is the response-timeout, so it + * classifies as `timeout`. + */ +export function classifyBriefingFailure(args: { + reason: string; + httpStatus?: number | null; +}): BriefingFailureClass { + if (args.reason === "invalid-json") return "format"; + const status = args.httpStatus; + if (typeof status === "number" && status > 0) { + if (status === 429) return "rate-limit"; + if (status >= 400 && status < 500) return "auth"; + if (status >= 500) return "provider"; + } + // No / sentinel-zero upstream status: a transport-level timeout or abort. + return "timeout"; +} + /** UTC YYYY-MM-DD calendar-day key for the marker payload. */ function dateKey(at: Date = new Date()): string { return at.toISOString().slice(0, 10); @@ -32,6 +72,12 @@ export async function recordBriefingFailure(args: { userId: string; reason: string; locale?: string; + /** + * v1.25.3 — upstream HTTP status when the failure carried one, so the read + * path can tell an auth / rate-limit / provider failure apart from a plain + * timeout and point the hint at the right lever. + */ + httpStatus?: number | null; }): Promise { try { await prisma.auditLog.create({ @@ -41,6 +87,7 @@ export async function recordBriefingFailure(args: { details: JSON.stringify({ dateKey: dateKey(), reason: args.reason, + httpStatus: args.httpStatus ?? null, locale: args.locale ?? null, triedAt: new Date().toISOString(), }), @@ -54,6 +101,8 @@ export async function recordBriefingFailure(args: { export interface BriefingFailureState { triedAt: string; reason: string; + /** v1.25.3 — coarse class for the empty-state hint (see classifyBriefingFailure). */ + failureClass: BriefingFailureClass; } /** @@ -77,13 +126,22 @@ export async function readBriefingFailure(args: { if (args.since && latest.createdAt <= args.since) return null; let reason = "unknown"; + let httpStatus: number | null = null; if (latest.details) { try { - const parsed = JSON.parse(latest.details) as { reason?: unknown }; + const parsed = JSON.parse(latest.details) as { + reason?: unknown; + httpStatus?: unknown; + }; if (typeof parsed.reason === "string") reason = parsed.reason; + if (typeof parsed.httpStatus === "number") httpStatus = parsed.httpStatus; } catch { // Malformed marker payload — still report the failure, generic reason. } } - return { triedAt: latest.createdAt.toISOString(), reason }; + return { + triedAt: latest.createdAt.toISOString(), + reason, + failureClass: classifyBriefingFailure({ reason, httpStatus }), + }; } diff --git a/src/lib/insights/comprehensive-generate.ts b/src/lib/insights/comprehensive-generate.ts index 5d754e993..be4ab8ca4 100644 --- a/src/lib/insights/comprehensive-generate.ts +++ b/src/lib/insights/comprehensive-generate.ts @@ -1049,7 +1049,12 @@ export async function generateComprehensiveInsight( // discreet "couldn't refresh" hint alongside the preserved last-good // briefing. No cache row is written here, so `insightsCachedText` (the // last good payload) stays intact and the surface never blanks. - void recordBriefingFailure({ userId, reason, locale }); + void recordBriefingFailure({ + userId, + reason, + locale, + httpStatus: typeof err.httpStatus === "number" ? err.httpStatus : null, + }); return { status: "failed", reason }; } diff --git a/src/lib/jobs/__tests__/insight-pregenerate.test.ts b/src/lib/jobs/__tests__/insight-pregenerate.test.ts index 1dc34196b..5dea6830d 100644 --- a/src/lib/jobs/__tests__/insight-pregenerate.test.ts +++ b/src/lib/jobs/__tests__/insight-pregenerate.test.ts @@ -82,13 +82,21 @@ import { runInsightPregenerate, forceWarmUser, findPregenerateCandidates, + comprehensiveWarmBudgetMs, PREGENERATE_STALE_MS, FORCE_WARM_DAILY_LIMIT, INSIGHT_PREGENERATE_QUEUE, INSIGHT_PREGENERATE_CRON, } from "../insight-pregenerate"; +import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; -function makePrisma(users: Array<{ id: string; locale: string | null }>) { +function makePrisma( + users: Array<{ + id: string; + locale: string | null; + aiResponseTimeoutSeconds?: number | null; + }>, +) { const findMany = vi.fn().mockResolvedValue(users); // forceWarmUser reads `insightsCachedAt` / `insightsWarmFailedAt` at job // start and maintains the failure marker; default: never warmed, never @@ -296,8 +304,9 @@ describe("runInsightPregenerate — outcome tally", () => { observedSignal = opts.signal; setTimeout( () => resolve({ status: "generated", providerType: "x" }), - // v1.21.5 — above COMPREHENSIVE_WARM_TIMEOUT_MS (130 s) so the - // bounded budget fires before this "slow" generation settles. + // v1.25.3 — above the default warm budget (~150 s = the 120 s + // comprehensive provider budget + headroom) so the bounded budget + // fires before this "slow" generation settles. 200_000, ); }), @@ -314,7 +323,7 @@ describe("runInsightPregenerate — outcome tally", () => { statusGenerators, warmGenericMetrics, }); - await vi.advanceTimersByTimeAsync(140_000); + await vi.advanceTimersByTimeAsync(160_000); const result = await promise; // The timeout fired, aborted the still-running generation (so its @@ -789,8 +798,9 @@ describe("forceWarmUser — on-demand single-user warm (v1.8.7.1)", () => { // advance past it so the warm continues. setTimeout( () => resolve({ status: "generated", providerType: "x" }), - // v1.21.5 — above COMPREHENSIVE_WARM_TIMEOUT_MS (130 s) so the - // bounded budget fires before this "slow" generation settles. + // v1.25.3 — above the default warm budget (~150 s = the 120 s + // comprehensive provider budget + headroom) so the bounded budget + // fires before this "slow" generation settles. 200_000, ); }), @@ -808,7 +818,7 @@ describe("forceWarmUser — on-demand single-user warm (v1.8.7.1)", () => { warmGenericMetrics, }); // Advance past the comprehensive budget so withTimeout fires. - await vi.advanceTimersByTimeAsync(140_000); + await vi.advanceTimersByTimeAsync(160_000); const result = await promise; for (const g of statusGenerators) { @@ -906,8 +916,9 @@ describe("forceWarmUser — on-demand single-user warm (v1.8.7.1)", () => { observedSignal = opts.signal; setTimeout( () => resolve({ status: "generated", providerType: "x" }), - // v1.21.5 — above COMPREHENSIVE_WARM_TIMEOUT_MS (130 s) so the - // bounded budget fires before this "slow" generation settles. + // v1.25.3 — above the default warm budget (~150 s = the 120 s + // comprehensive provider budget + headroom) so the bounded budget + // fires before this "slow" generation settles. 200_000, ); }), @@ -924,7 +935,7 @@ describe("forceWarmUser — on-demand single-user warm (v1.8.7.1)", () => { statusGenerators, warmGenericMetrics, }); - await vi.advanceTimersByTimeAsync(140_000); + await vi.advanceTimersByTimeAsync(160_000); const result = await promise; // The timeout fired and aborted the still-running generation. @@ -975,3 +986,77 @@ describe("queue registration", () => { expect(INSIGHT_PREGENERATE_CRON).toMatch(/^\d+ \d+ \* \* \*$/); }); }); + +describe("comprehensiveWarmBudgetMs — warm budget scales with the response timeout (v1.25.3)", () => { + const HEADROOM_MS = 30_000; + const DEFAULT_MS = AI_BUDGETS.comprehensive.timeoutMs; + + it("falls back to the comprehensive surface budget + headroom for an unset value", () => { + expect(comprehensiveWarmBudgetMs(null)).toBe(DEFAULT_MS + HEADROOM_MS); + expect(comprehensiveWarmBudgetMs(undefined)).toBe(DEFAULT_MS + HEADROOM_MS); + // A non-positive stored value is treated as unset. + expect(comprehensiveWarmBudgetMs(0)).toBe(DEFAULT_MS + HEADROOM_MS); + }); + + it("scales the budget with a raised setting and stays above the old fixed 130 s cap", () => { + // The regression: any account whose response timeout was >= ~130 s had its + // comprehensive warm clipped by the old flat 130 s cap, never landing. + const budget = comprehensiveWarmBudgetMs(300); + expect(budget).toBe(300_000 + HEADROOM_MS); + expect(budget).toBeGreaterThan(130_000); + }); + + it("clamps to an absolute ceiling that still sits above the 600 s write-time maximum", () => { + // The 600 s max a user can configure is honoured (not clipped below it); + // the ceiling adds the headroom so a legitimate 600 s setting fits. + expect(comprehensiveWarmBudgetMs(600)).toBe(600_000 + HEADROOM_MS); + // A value beyond the write-time bound can never reach here, but the clamp + // is the structural backstop against a wedged call regardless. + expect(comprehensiveWarmBudgetMs(100_000)).toBe(600_000 + HEADROOM_MS); + }); +}); + +describe("runInsightPregenerate — warm budget honours the response timeout (v1.25.3)", () => { + it("does NOT clip a slow comprehensive for an account whose response timeout is raised past the old cap", async () => { + const { prisma } = makePrisma([ + { id: "u1", locale: "de", aiResponseTimeoutSeconds: 300 }, + ]); + let observedSignal: AbortSignal | undefined; + const generate = vi.fn().mockImplementation( + (_userId: string, opts: { signal?: AbortSignal }) => + new Promise((resolve) => { + observedSignal = opts.signal; + // 200 s: above the OLD fixed 130 s cap (which would have aborted it) + // but well within the 330 s budget a 300 s setting now yields. + setTimeout( + () => resolve({ status: "generated", providerType: "x" }), + 200_000, + ); + }), + ); + const statusGenerators = Array.from({ length: 7 }, () => + vi.fn().mockResolvedValue({ hasProvider: true, cached: false }), + ); + const warmGenericMetrics = vi.fn().mockResolvedValue(0); + + vi.useFakeTimers(); + try { + const promise = runInsightPregenerate(prisma as never, { + generate, + statusGenerators, + warmGenericMetrics, + }); + // Advance past the slow generation but below the 330 s warm budget. + await vi.advanceTimersByTimeAsync(210_000); + const result = await promise; + + // The generation landed instead of being clipped: counted as generated, + // not failed, and the abort never fired. + expect(result.generated).toBe(1); + expect(result.failed).toBe(0); + expect(observedSignal?.aborted).toBe(false); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/src/lib/jobs/insight-pregenerate.ts b/src/lib/jobs/insight-pregenerate.ts index 0c8af42ea..080707a2c 100644 --- a/src/lib/jobs/insight-pregenerate.ts +++ b/src/lib/jobs/insight-pregenerate.ts @@ -67,6 +67,8 @@ import { type MetricStatusMetricId, } from "@/lib/insights/metric-status-registry"; import { withTimeout } from "@/lib/insights/with-timeout"; +import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; +import { resolveEffectiveTimeoutMs } from "@/lib/ai/effective-timeout"; import { INSIGHT_PREGENERATE_QUEUE, enqueueForceWarm, @@ -164,15 +166,72 @@ const WARM_PASS_CONCURRENCY = 3; * the provider's own abort governs a genuine completion while this stays the * backstop against a wedged call. The per-user budget gate + the WARM_PASS * concurrency cap still bound total nightly cost. + * + * v1.25.3 — the cap is no longer a fixed constant. The per-user + * `User.aiResponseTimeoutSeconds` setting (bounded 10–600 s at write-time) is + * the operator's lever for slow self-hosted / local backends, and the AI + * settings UI tells users to RAISE it for a model that loads slowly. The old + * fixed 130 s cap sat below the upper end of that range, so any account whose + * response timeout was ≥ ~130 s had every comprehensive warm clipped by the + * warm bound — not the provider — before the generation could land, leaving the + * briefing on its hard empty state. The warm budget now derives from the + * resolved effective per-request timeout plus a fixed headroom for feature + * extraction / prompt assembly / IO, so raising the response timeout actually + * extends the warm/cron budget. A sane absolute ceiling (the 600 s write-time + * maximum plus the same headroom) keeps a single wedged call from pinning the + * worker forever, while staying ABOVE the largest value a user can configure. */ -const COMPREHENSIVE_WARM_TIMEOUT_MS = 130_000; + +/** + * Fixed slack added on top of the resolved upstream provider timeout when + * sizing the warm/cron outer abort: bounded-feature extraction, prompt + * assembly, and the cache write all run inside the warm wrapper but outside the + * single provider round-trip the response-timeout governs. + */ +const COMPREHENSIVE_WARM_HEADROOM_MS = 30_000; + +/** + * Largest value `User.aiResponseTimeoutSeconds` accepts (see the 10–600 s + * write-time bound in `POST /api/user/ai-provider`), in ms. The warm ceiling is + * derived from it so the ceiling is always ABOVE the maximum a user can set. + */ +const AI_RESPONSE_TIMEOUT_MAX_MS = 600_000; + +/** + * Absolute backstop on the warm outer abort. A misconfigured 600 s response + * timeout still cannot pin the worker beyond this; the ceiling sits above the + * write-time maximum (by exactly the headroom) so a legitimate 600 s setting is + * honoured rather than silently clipped. + */ +const COMPREHENSIVE_WARM_CEILING_MS = + AI_RESPONSE_TIMEOUT_MAX_MS + COMPREHENSIVE_WARM_HEADROOM_MS; + +/** + * Outer-abort budget (ms) for one comprehensive warm, derived from the user's + * resolved effective upstream timeout. `effectiveTimeoutMs + headroom`, clamped + * to the absolute ceiling. A null/unset setting falls back to the comprehensive + * surface budget (`AI_BUDGETS.comprehensive.timeoutMs`), so a default account + * gets `120 s + headroom` rather than the old flat 130 s. + */ +export function comprehensiveWarmBudgetMs( + aiResponseTimeoutSeconds: number | null | undefined, +): number { + const effectiveTimeoutMs = resolveEffectiveTimeoutMs( + aiResponseTimeoutSeconds, + AI_BUDGETS.comprehensive.timeoutMs, + ); + return Math.min( + effectiveTimeoutMs + COMPREHENSIVE_WARM_HEADROOM_MS, + COMPREHENSIVE_WARM_CEILING_MS, + ); +} /** Per-user result of one forced full warm. */ export interface ForceWarmResult { /** * Outcome of the comprehensive-insight generation. `"timeout"` is * distinct from `"failed"`: the comprehensive step exceeded its own - * bounded budget (see `COMPREHENSIVE_WARM_TIMEOUT_MS`) and was + * bounded budget (see `comprehensiveWarmBudgetMs`) and was * abandoned so the per-status + generic-metric passes could still run. * * v1.16.8 — three pre-flight outcomes that never reach the generator: @@ -384,6 +443,12 @@ async function warmGenericMetricCaches( interface PregenerateCandidate { id: string; locale: string | null; + /** + * v1.25.3 — per-user response-timeout (seconds), feeds the warm outer + * abort so a raised setting is not clipped by a fixed cap. Null = the + * comprehensive surface default. + */ + aiResponseTimeoutSeconds: number | null; } /** @@ -410,7 +475,9 @@ export async function findPregenerateCandidates( // never-generated users sort ahead of merely-stale ones. orderBy: { insightsCachedAt: "asc" }, take: cap, - select: { id: true, locale: true }, + // v1.25.3 — carry the per-user response-timeout so the warm outer abort + // scales with the operator's setting instead of a fixed cap. + select: { id: true, locale: true, aiResponseTimeoutSeconds: true }, }); } @@ -531,6 +598,12 @@ export async function runInsightPregenerate( // late resolve would still write a cache row + timestamp after the // loop moved on (the same race the forced single-user warm closes). const controller = new AbortController(); + // v1.25.3 — size the outer abort from the candidate's resolved + // response-timeout so a raised setting extends the warm budget + // instead of being clipped by a fixed cap. + const warmBudgetMs = comprehensiveWarmBudgetMs( + candidate.aiResponseTimeoutSeconds, + ); const bounded = await withTimeout( () => generate(candidate.id, { @@ -538,7 +611,7 @@ export async function runInsightPregenerate( force: true, signal: controller.signal, }), - COMPREHENSIVE_WARM_TIMEOUT_MS, + warmBudgetMs, null, () => controller.abort(), ); @@ -657,7 +730,7 @@ export async function runInsightPregenerate( * actually reads warms lazily through the read-path enqueue. * * The three sections are decoupled: the comprehensive step runs under its - * own bounded budget (`COMPREHENSIVE_WARM_TIMEOUT_MS`) and its + * own bounded budget (`comprehensiveWarmBudgetMs`) and its * failure / timeout / skip is non-fatal — the per-status (7) and * generic-metric (~30) warm passes ALWAYS run afterwards. The earlier * design short-circuited both passes on a non-`generated`/`cached` @@ -760,11 +833,17 @@ export async function forceWarmUser( let freshness: { insightsCachedAt: Date | null; insightsWarmFailedAt: Date | null; + // v1.25.3 — feeds the warm outer abort (see comprehensiveWarmBudgetMs). + aiResponseTimeoutSeconds: number | null; } | null = null; try { freshness = await prisma.user.findUnique({ where: { id: userId }, - select: { insightsCachedAt: true, insightsWarmFailedAt: true }, + select: { + insightsCachedAt: true, + insightsWarmFailedAt: true, + aiResponseTimeoutSeconds: true, + }, }); } catch { // Best-effort pre-flight — on a read failure fall through to the @@ -807,6 +886,12 @@ export async function forceWarmUser( }); } else { const controller = new AbortController(); + // v1.25.3 — size the outer abort from the user's resolved + // response-timeout (read with the freshness gate above) so a raised + // setting extends the warm budget instead of being clipped. + const warmBudgetMs = comprehensiveWarmBudgetMs( + freshness?.aiResponseTimeoutSeconds, + ); const comprehensive = await withTimeout( () => generate(userId, { @@ -814,7 +899,7 @@ export async function forceWarmUser( force: true, signal: controller.signal, }), - COMPREHENSIVE_WARM_TIMEOUT_MS, + warmBudgetMs, null, () => controller.abort(), ); diff --git a/src/lib/openapi/routes/profile.ts b/src/lib/openapi/routes/profile.ts index d987f1fc7..e6f43ee11 100644 --- a/src/lib/openapi/routes/profile.ts +++ b/src/lib/openapi/routes/profile.ts @@ -339,7 +339,7 @@ const aiProviderResponse = z .int() .nullable() .describe( - "v1.22 (#89) — per-user response timeout for AI generation, in seconds (10–600). Null = the generous built-in default (~180 s). Mainly for slow local/self-hosted backends.", + "v1.22 (#89) — per-user response timeout for AI generation, in seconds (10–600). Null = the built-in comprehensive-briefing default (~120 s). Mainly for slow local/self-hosted backends.", ), ocrEnabled: z .boolean()