mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge v1.25.3: v1253-briefing-timeout
This commit is contained in:
commit
d3057e4a92
18 files changed
+426
-41
No files matched your search
@@ -16945,8 +16945,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
|
||||
|
||||
+5
-2
@@ -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.",
|
||||
|
||||
+5
-2
@@ -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.",
|
||||
|
||||
+5
-2
@@ -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.",
|
||||
|
||||
+5
-2
@@ -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.",
|
||||
|
||||
+5
-2
@@ -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.",
|
||||
|
||||
+5
-2
@@ -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.",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 `<h2>` above it (mirroring the
|
||||
@@ -588,7 +611,7 @@ export function DailyBriefing({
|
||||
}
|
||||
description={
|
||||
generationFailed
|
||||
? t("insights.dailyBriefing.failedDescription")
|
||||
? t(failedDescriptionKey)
|
||||
: t("insights.dailyBriefing.emptyDescription")
|
||||
}
|
||||
action={
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<void> {
|
||||
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 }),
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
);
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user