mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
Merge branch 'fix/375-generated-narratives' into release/v1.25.0
This commit is contained in:
commit
c0a91dbbcd
19 files changed
+643
-19
No files matched your search
+5
-1
@@ -2052,7 +2052,11 @@
|
||||
"noProviderTitle": "Kein KI-Anbieter verbunden",
|
||||
"noProviderDescription": "Das Tagesbriefing fasst deine Werte in Klartext zusammen. Dafür braucht HealthLog einen KI-Anbieter — nutze deinen eigenen Schlüssel (OpenAI, Anthropic) oder ein lokales Modell. Deine Daten bleiben dabei bei dir.",
|
||||
"noProviderAction": "Zu den KI-Einstellungen",
|
||||
"staleNoProviderHint": "Dieses Briefing kann nicht aktualisiert werden — es ist kein KI-Anbieter verbunden."
|
||||
"staleNoProviderHint": "Dieses Briefing kann nicht aktualisiert werden — es ist kein KI-Anbieter verbunden.",
|
||||
"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."
|
||||
},
|
||||
"trendsRow": {
|
||||
"title": "Trends",
|
||||
|
||||
+5
-1
@@ -2052,7 +2052,11 @@
|
||||
"noProviderTitle": "No AI provider connected",
|
||||
"noProviderDescription": "The daily briefing turns your readings into plain-language summary. HealthLog needs an AI provider for that — use your own key (OpenAI, Anthropic) or a local model. Your data stays with you.",
|
||||
"noProviderAction": "Open AI settings",
|
||||
"staleNoProviderHint": "This briefing can't refresh — no AI provider is connected."
|
||||
"staleNoProviderHint": "This briefing can't refresh — no AI provider is connected.",
|
||||
"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."
|
||||
},
|
||||
"trendsRow": {
|
||||
"title": "Trends",
|
||||
|
||||
+5
-1
@@ -2052,7 +2052,11 @@
|
||||
"noProviderTitle": "Ningún proveedor de IA conectado",
|
||||
"noProviderDescription": "El briefing diario resume tus lecturas en lenguaje claro. HealthLog necesita un proveedor de IA para ello: usa tu propia clave (OpenAI, Anthropic) o un modelo local. Tus datos siguen siendo tuyos.",
|
||||
"noProviderAction": "Abrir ajustes de IA",
|
||||
"staleNoProviderHint": "Este briefing no puede actualizarse: no hay ningún proveedor de IA conectado."
|
||||
"staleNoProviderHint": "Este briefing no puede actualizarse: no hay ningún proveedor de IA conectado.",
|
||||
"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."
|
||||
},
|
||||
"trendsRow": {
|
||||
"title": "Tendencias",
|
||||
|
||||
+5
-1
@@ -2052,7 +2052,11 @@
|
||||
"noProviderTitle": "Aucun fournisseur d'IA connecté",
|
||||
"noProviderDescription": "Le briefing quotidien résume tes mesures en langage clair. HealthLog a besoin d'un fournisseur d'IA pour cela : utilise ta propre clé (OpenAI, Anthropic) ou un modèle local. Tes données restent chez toi.",
|
||||
"noProviderAction": "Ouvrir les réglages IA",
|
||||
"staleNoProviderHint": "Ce briefing ne peut pas être actualisé — aucun fournisseur d'IA n'est connecté."
|
||||
"staleNoProviderHint": "Ce briefing ne peut pas être actualisé — aucun fournisseur d'IA n'est connecté.",
|
||||
"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."
|
||||
},
|
||||
"trendsRow": {
|
||||
"title": "Tendances",
|
||||
|
||||
+5
-1
@@ -2052,7 +2052,11 @@
|
||||
"noProviderTitle": "Nessun provider IA collegato",
|
||||
"noProviderDescription": "Il briefing giornaliero riassume le tue misurazioni in linguaggio chiaro. HealthLog ha bisogno di un provider IA per questo: usa la tua chiave (OpenAI, Anthropic) o un modello locale. I tuoi dati restano tuoi.",
|
||||
"noProviderAction": "Apri impostazioni IA",
|
||||
"staleNoProviderHint": "Questo briefing non può aggiornarsi: nessun provider IA è collegato."
|
||||
"staleNoProviderHint": "Questo briefing non può aggiornarsi: nessun provider IA è collegato.",
|
||||
"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."
|
||||
},
|
||||
"trendsRow": {
|
||||
"title": "Tendenze",
|
||||
|
||||
+5
-1
@@ -2052,7 +2052,11 @@
|
||||
"noProviderTitle": "Brak połączonego dostawcy AI",
|
||||
"noProviderDescription": "Codzienny briefing podsumowuje Twoje pomiary prostym językiem. HealthLog potrzebuje do tego dostawcy AI — użyj własnego klucza (OpenAI, Anthropic) lub modelu lokalnego. Twoje dane pozostają u Ciebie.",
|
||||
"noProviderAction": "Otwórz ustawienia AI",
|
||||
"staleNoProviderHint": "Tego briefingu nie można odświeżyć — nie połączono żadnego dostawcy AI."
|
||||
"staleNoProviderHint": "Tego briefingu nie można odświeżyć — nie połączono żadnego dostawcy AI.",
|
||||
"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."
|
||||
},
|
||||
"trendsRow": {
|
||||
"title": "Trendy",
|
||||
|
||||
@@ -48,6 +48,11 @@ vi.mock("@/lib/db", () => ({
|
||||
// generation. The test prisma mock has to surface the call so
|
||||
// the cache-invalidation test can assert against it.
|
||||
deleteMany: vi.fn(async () => ({ count: 0 })),
|
||||
// v1.25 — the GET read path consults the briefing-failure marker
|
||||
// (`readBriefingFailure`) and the failure paths append one
|
||||
// (`recordBriefingFailure`). Default to no marker.
|
||||
findFirst: vi.fn(async () => null),
|
||||
create: vi.fn(async () => ({})),
|
||||
},
|
||||
// v1.12.1 — the server-managed consent gate reads the latest active
|
||||
// receipt before egress. These fixtures resolve to an admin-openai
|
||||
|
||||
@@ -61,6 +61,11 @@ import {
|
||||
type InsightResult,
|
||||
} from "@/lib/ai/types";
|
||||
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
|
||||
import { resolveEffectiveTimeoutMs } from "@/lib/ai/effective-timeout";
|
||||
import {
|
||||
recordBriefingFailure,
|
||||
readBriefingFailure,
|
||||
} from "@/lib/insights/briefing-failure-marker";
|
||||
import { resolveProvider, resolveProviderChain } from "@/lib/ai/provider";
|
||||
import {
|
||||
AllProvidersFailedError,
|
||||
@@ -307,6 +312,15 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
// connect-an-AI-provider hint instead of implying the read is live.
|
||||
const hasProvider = await hasUsableStatusProvider(userId);
|
||||
|
||||
// v1.25 — surface whether the most recent generation attempt failed (a
|
||||
// marker newer than the last successful generation). The briefing keeps its
|
||||
// last good text on failure, so this is the only honest signal that a shown
|
||||
// briefing is held rather than freshly refreshed — and, when there is no
|
||||
// last good text, that the empty state should read "couldn't generate" with
|
||||
// a retry rather than the generic "no briefing yet".
|
||||
const generationFailed =
|
||||
(await readBriefingFailure({ userId, since: cachedAt })) !== null;
|
||||
|
||||
// Read-only: never block on the provider. Warm out of band only when the
|
||||
// cached briefing is stale / missing AND a provider is configured (a
|
||||
// provider-less account costs one cheap chain-resolve and shows the
|
||||
@@ -351,6 +365,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
// the served briefing can never refresh, so the UI pairs its age
|
||||
// with a connect-provider affordance.
|
||||
hasProvider,
|
||||
// v1.25 — true when the last generation attempt failed (held text).
|
||||
generationFailed,
|
||||
});
|
||||
} catch {
|
||||
// Invalid cache row — fall through to the empty payload below. The
|
||||
@@ -370,6 +386,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
revalidating,
|
||||
// v1.18.9 (#4) — see the cached branch above.
|
||||
hasProvider,
|
||||
// v1.25 — no last-good text AND the last attempt failed: the UI shows a
|
||||
// "couldn't generate" empty state with a retry instead of the generic one.
|
||||
generationFailed,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -403,9 +422,17 @@ export const POST = apiHandler((request: NextRequest) =>
|
||||
heightCm: true,
|
||||
// v1.22 (W6) — first name for the sparse, hash-gated briefing opener.
|
||||
displayName: true,
|
||||
// v1.25 — per-user response-timeout (seconds), threaded onto the
|
||||
// generation calls so a slow self-hosted backend honours the setting.
|
||||
aiResponseTimeoutSeconds: true,
|
||||
},
|
||||
});
|
||||
|
||||
const effectiveTimeoutMs = resolveEffectiveTimeoutMs(
|
||||
dbUser?.aiResponseTimeoutSeconds,
|
||||
AI_BUDGETS.comprehensive.timeoutMs,
|
||||
);
|
||||
|
||||
const locale = await resolveServerLocale({
|
||||
request,
|
||||
userLocale: dbUser?.locale ?? user.locale ?? null,
|
||||
@@ -717,7 +744,8 @@ export const POST = apiHandler((request: NextRequest) =>
|
||||
// v1.21.5 — wider upstream budget so the reasoning-heavy briefing
|
||||
// generation is not aborted at the client's 60 s default on large
|
||||
// accounts (which returned an empty briefing + trend narrative).
|
||||
timeoutMs: AI_BUDGETS.comprehensive.timeoutMs,
|
||||
// v1.25 — honours the per-user response-timeout setting.
|
||||
timeoutMs: effectiveTimeoutMs,
|
||||
// The reply is parsed with `JSON.parse` below, so opt the OpenAI /
|
||||
// Codex chains into their strict JSON mode (gated on this flag).
|
||||
responseFormat: "json",
|
||||
@@ -727,6 +755,18 @@ export const POST = apiHandler((request: NextRequest) =>
|
||||
workingProviderType = fallback.workingProvider.providerType;
|
||||
fallbackHopCount = fallback.fallbackHops.length;
|
||||
} catch (e) {
|
||||
// v1.25 — record a dated failure marker (no cache row is written on this
|
||||
// path, so the last good briefing stays intact). The read path pairs the
|
||||
// preserved text with a discreet "couldn't refresh" hint, or shows a
|
||||
// retry empty state when there is no last good text.
|
||||
void recordBriefingFailure({
|
||||
userId,
|
||||
reason:
|
||||
e instanceof AllProvidersFailedError
|
||||
? "all-providers-failed"
|
||||
: "provider-error",
|
||||
locale,
|
||||
});
|
||||
if (e instanceof AllProvidersFailedError) {
|
||||
annotate({
|
||||
meta: {
|
||||
@@ -826,6 +866,7 @@ export const POST = apiHandler((request: NextRequest) =>
|
||||
// breaks `await res.json()` on the client side. 422 stays passthrough
|
||||
// so the React Query mutation can read the JSON body and surface a
|
||||
// readable message. Same fix pattern as v1.4.5 ai/test.
|
||||
void recordBriefingFailure({ userId, reason: "invalid-json", locale });
|
||||
return apiError("AI response was not valid JSON", 422);
|
||||
}
|
||||
|
||||
@@ -856,7 +897,8 @@ export const POST = apiHandler((request: NextRequest) =>
|
||||
temperature: 0.3,
|
||||
maxTokens: 1500,
|
||||
// v1.21.5 — wider upstream budget; see the first generation call.
|
||||
timeoutMs: AI_BUDGETS.comprehensive.timeoutMs,
|
||||
// v1.25 — honours the per-user response-timeout setting.
|
||||
timeoutMs: effectiveTimeoutMs,
|
||||
// Parsed with `JSON.parse` below — opt OpenAI / Codex into JSON mode.
|
||||
responseFormat: "json",
|
||||
}),
|
||||
|
||||
@@ -474,6 +474,12 @@ export default function InsightsPage() {
|
||||
// briefing is actually rendered (the empty-state path owns the
|
||||
// `noProvider` branch above).
|
||||
noProviderStale={briefingPayload !== null && !advisor.hasProvider}
|
||||
// v1.25 — the last generation attempt failed. On a held briefing this
|
||||
// adds a discreet "couldn't refresh — retry" footer hint; on an empty
|
||||
// card it swaps the generic empty state for an honest "couldn't
|
||||
// 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.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.
|
||||
|
||||
@@ -238,4 +238,55 @@ describe("<DailyBriefing>", () => {
|
||||
expect(html).toContain('data-slot="daily-briefing-findings"');
|
||||
expect(html).not.toContain('data-slot="daily-briefing-no-provider"');
|
||||
});
|
||||
|
||||
// v1.25 — generation-failed affordances. The briefing keeps its last good
|
||||
// text; the card adds an honest "couldn't refresh" footer hint on a held
|
||||
// briefing and a "couldn't generate — retry" empty state when there is none.
|
||||
it("renders the refresh-failed footer hint on a held briefing", () => {
|
||||
const html = render(
|
||||
<DailyBriefing
|
||||
briefing={baseBriefing}
|
||||
onRegenerate={() => {}}
|
||||
generationFailed
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="daily-briefing-refresh-failed"');
|
||||
// The rendered apostrophe is HTML-escaped; match the unambiguous tail.
|
||||
expect(html).toContain("refresh the briefing");
|
||||
expect(html).toContain('data-slot="daily-briefing-refresh-failed-retry"');
|
||||
});
|
||||
|
||||
it("does NOT render the refresh-failed hint when generation did not fail", () => {
|
||||
const html = render(
|
||||
<DailyBriefing briefing={baseBriefing} onRegenerate={() => {}} />,
|
||||
);
|
||||
expect(html).not.toContain('data-slot="daily-briefing-refresh-failed"');
|
||||
});
|
||||
|
||||
it("suppresses the refresh-failed hint when no provider is connected", () => {
|
||||
const html = render(
|
||||
<DailyBriefing
|
||||
briefing={baseBriefing}
|
||||
onRegenerate={() => {}}
|
||||
generationFailed
|
||||
noProviderStale
|
||||
/>,
|
||||
);
|
||||
expect(html).not.toContain('data-slot="daily-briefing-refresh-failed"');
|
||||
expect(html).toContain('data-slot="daily-briefing-stale-no-provider"');
|
||||
});
|
||||
|
||||
it("renders the 'couldn't generate' empty state with a retry CTA when failed and empty", () => {
|
||||
const html = render(
|
||||
<DailyBriefing
|
||||
briefing={null}
|
||||
onRegenerate={() => {}}
|
||||
generationFailed
|
||||
/>,
|
||||
);
|
||||
// The rendered apostrophe is HTML-escaped; match the unambiguous tail.
|
||||
expect(html).toContain("generate the briefing");
|
||||
expect(html).toMatch(/data-slot="daily-briefing-empty-cta"/);
|
||||
expect(html).toContain("Retry");
|
||||
});
|
||||
});
|
||||
@@ -119,6 +119,14 @@ interface DailyBriefingProps {
|
||||
* the staleness reads as intentional, not as a live report.
|
||||
*/
|
||||
noProviderStale?: boolean;
|
||||
/**
|
||||
* v1.25 — the most recent generation attempt failed (provider timeout /
|
||||
* error). The briefing keeps its last good text, so this never blanks a
|
||||
* shown card; it only adds an honest "couldn't refresh" hint to the footer
|
||||
* of a held briefing, and — when there is no briefing to show — swaps the
|
||||
* generic empty state for a "couldn't generate" one whose CTA retries.
|
||||
*/
|
||||
generationFailed?: boolean;
|
||||
/**
|
||||
* Optional slot for a meta control mounted in the card header — the
|
||||
* comparison toggle migrates here from the hero in commit 5.
|
||||
@@ -349,6 +357,7 @@ export function DailyBriefing({
|
||||
regenerating = false,
|
||||
noProvider = false,
|
||||
noProviderStale = false,
|
||||
generationFailed = false,
|
||||
metaSlot,
|
||||
memory = null,
|
||||
}: DailyBriefingProps) {
|
||||
@@ -468,7 +477,7 @@ export function DailyBriefing({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(updatedAt || noProviderStale) && (
|
||||
{(updatedAt || noProviderStale || generationFailed) && (
|
||||
<div className="border-border/60 space-y-1.5 border-t pt-3">
|
||||
{updatedAt && (
|
||||
<p
|
||||
@@ -505,6 +514,32 @@ export function DailyBriefing({
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
{/* v1.25 — the held briefing is shown, but the last refresh
|
||||
attempt failed (provider timeout / error). State it plainly
|
||||
and offer a retry so the staleness reads as held, not
|
||||
current. Suppressed when no provider is configured (that
|
||||
hint owns the footer) — a retry would be futile there. */}
|
||||
{generationFailed && !noProviderStale && (
|
||||
<p
|
||||
data-slot="daily-briefing-refresh-failed"
|
||||
className="text-muted-foreground flex flex-wrap items-center justify-end gap-x-1.5 gap-y-1 text-right text-xs"
|
||||
>
|
||||
<span>
|
||||
{t("insights.dailyBriefing.refreshFailedHint")}
|
||||
</span>
|
||||
{onRegenerate && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRegenerate}
|
||||
disabled={regenerating}
|
||||
data-slot="daily-briefing-refresh-failed-retry"
|
||||
className="text-foreground/80 hover:text-foreground underline underline-offset-2 disabled:opacity-60"
|
||||
>
|
||||
{t("insights.dailyBriefing.retryAction")}
|
||||
</button>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* v1.21.0 (C4 H2) — hand off to the Coach for the whole
|
||||
@@ -542,8 +577,20 @@ export function DailyBriefing({
|
||||
data-slot="daily-briefing-empty"
|
||||
variant="plain"
|
||||
icon={<FileText className="size-5" />}
|
||||
title={t("insights.dailyBriefing.emptyTitle")}
|
||||
description={t("insights.dailyBriefing.emptyDescription")}
|
||||
// v1.25 — when the last attempt FAILED and there is no last good
|
||||
// text to fall back on, say so honestly ("couldn't generate")
|
||||
// rather than the generic "no briefing yet". The CTA below is the
|
||||
// same explicit regenerate — it retries the failed generation.
|
||||
title={
|
||||
generationFailed
|
||||
? t("insights.dailyBriefing.failedTitle")
|
||||
: t("insights.dailyBriefing.emptyTitle")
|
||||
}
|
||||
description={
|
||||
generationFailed
|
||||
? t("insights.dailyBriefing.failedDescription")
|
||||
: t("insights.dailyBriefing.emptyDescription")
|
||||
}
|
||||
action={
|
||||
onRegenerate ? (
|
||||
// v1.4.28 BK-M2 — switch the empty-state CTA from
|
||||
@@ -564,7 +611,9 @@ export function DailyBriefing({
|
||||
<span>
|
||||
{regenerating
|
||||
? t("insights.heroRegenerating")
|
||||
: t("insights.dailyBriefing.emptyAction")}
|
||||
: generationFailed
|
||||
? t("insights.dailyBriefing.retryAction")
|
||||
: t("insights.dailyBriefing.emptyAction")}
|
||||
</span>
|
||||
</Button>
|
||||
) : null
|
||||
|
||||
@@ -71,6 +71,15 @@ export interface InsightAdvisorPayload {
|
||||
* predates the field — treated as "provider present" (no hint).
|
||||
*/
|
||||
hasProvider?: boolean;
|
||||
/**
|
||||
* v1.25 — true when the most recent generation attempt failed (a failure
|
||||
* marker newer than the last successful generation). The briefing keeps its
|
||||
* last good text on failure, so this is the honest signal that a shown
|
||||
* briefing is held, or — with no last good text — that the empty state
|
||||
* should read "couldn't generate" with a retry rather than the generic one.
|
||||
* Absent on a pre-field cached payload → treated as "not failed" (no hint).
|
||||
*/
|
||||
generationFailed?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -279,6 +288,13 @@ export interface UseInsightsAdvisorResult {
|
||||
* or an unsettled query.
|
||||
*/
|
||||
hasProvider: boolean;
|
||||
/**
|
||||
* v1.25 — true when the GET reported the last generation attempt failed.
|
||||
* Pairs a shown-but-held briefing with a discreet "couldn't refresh" hint,
|
||||
* and swaps the generic empty state for a "couldn't generate — retry" one
|
||||
* when there is no last good text. Defaults false (no hint) when absent.
|
||||
*/
|
||||
generationFailed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -364,5 +380,8 @@ export function useInsightsAdvisorQuery(
|
||||
// assume a provider is present so a transient unknown never flashes a
|
||||
// false "no provider" hint.
|
||||
hasProvider: query.data?.payload?.hasProvider ?? true,
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { resolveEffectiveTimeoutMs } from "../effective-timeout";
|
||||
|
||||
const BUDGET = 120_000;
|
||||
|
||||
describe("resolveEffectiveTimeoutMs", () => {
|
||||
it("converts a positive per-user setting from seconds to ms", () => {
|
||||
expect(resolveEffectiveTimeoutMs(300, BUDGET)).toBe(300_000);
|
||||
expect(resolveEffectiveTimeoutMs(10, BUDGET)).toBe(10_000);
|
||||
});
|
||||
|
||||
it("falls back to the budget default when the setting is unset", () => {
|
||||
expect(resolveEffectiveTimeoutMs(null, BUDGET)).toBe(BUDGET);
|
||||
expect(resolveEffectiveTimeoutMs(undefined, BUDGET)).toBe(BUDGET);
|
||||
});
|
||||
|
||||
it("falls back to the budget default for a non-positive value", () => {
|
||||
expect(resolveEffectiveTimeoutMs(0, BUDGET)).toBe(BUDGET);
|
||||
expect(resolveEffectiveTimeoutMs(-5, BUDGET)).toBe(BUDGET);
|
||||
});
|
||||
|
||||
it("honours different budget defaults per surface", () => {
|
||||
expect(resolveEffectiveTimeoutMs(null, 60_000)).toBe(60_000);
|
||||
expect(resolveEffectiveTimeoutMs(45, 60_000)).toBe(45_000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Resolve the upstream provider timeout (ms) for a non-chat generation surface.
|
||||
*
|
||||
* The per-user `User.aiResponseTimeoutSeconds` setting (Settings → AI) is the
|
||||
* operator's lever for slow self-hosted / local backends whose first request
|
||||
* loads the model. Coach already threads it onto its provider call; the
|
||||
* generated-narrative surfaces (daily briefing, status cards, period
|
||||
* narratives, …) historically hardcoded their per-surface `AI_BUDGETS.*`
|
||||
* default and ignored the setting, so raising the timeout had no effect on
|
||||
* them. This helper is the one place that converts the stored seconds into the
|
||||
* milliseconds a `CompletionParams.timeoutMs` expects, falling back to the
|
||||
* surface's budget default when the user has not set a value.
|
||||
*
|
||||
* Semantics mirror Coach: a positive stored value wins (seconds → ms), and any
|
||||
* unset / non-positive value yields the surface default. The setting is bounded
|
||||
* to 10–600 s at write-time, so no read-time clamp is needed here.
|
||||
*/
|
||||
export function resolveEffectiveTimeoutMs(
|
||||
aiResponseTimeoutSeconds: number | null | undefined,
|
||||
budgetDefaultMs: number,
|
||||
): number {
|
||||
return aiResponseTimeoutSeconds != null && aiResponseTimeoutSeconds > 0
|
||||
? aiResponseTimeoutSeconds * 1000
|
||||
: budgetDefaultMs;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* v1.25 — the briefing failure marker drives the read path's "couldn't
|
||||
* refresh" signal WITHOUT touching the cached briefing text. These tests pin
|
||||
* that a marker newer than the last successful generation reads as a failure,
|
||||
* an older one is treated as superseded, and a write failure never throws.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const findFirst = vi.fn();
|
||||
const create = vi.fn();
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
auditLog: {
|
||||
findFirst: (...a: unknown[]) => findFirst(...a),
|
||||
create: (...a: unknown[]) => create(...a),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
readBriefingFailure,
|
||||
recordBriefingFailure,
|
||||
BRIEFING_FAILURE_ACTION,
|
||||
} from "../briefing-failure-marker";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("readBriefingFailure", () => {
|
||||
it("returns the failure when the marker is newer than the last success", 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).toEqual(
|
||||
expect.objectContaining({ reason: "provider-error" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("treats a marker older than the last success as superseded", async () => {
|
||||
findFirst.mockResolvedValue({
|
||||
createdAt: new Date("2026-06-28T08:00:00Z"),
|
||||
details: JSON.stringify({ reason: "provider-error" }),
|
||||
});
|
||||
|
||||
const result = await readBriefingFailure({
|
||||
userId: "u1",
|
||||
since: new Date("2026-06-28T09:00:00Z"),
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when no marker exists", async () => {
|
||||
findFirst.mockResolvedValue(null);
|
||||
expect(await readBriefingFailure({ userId: "u1", since: null })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("recordBriefingFailure", () => {
|
||||
it("writes an append-only marker row under the failure action", async () => {
|
||||
create.mockResolvedValue({});
|
||||
await recordBriefingFailure({
|
||||
userId: "u1",
|
||||
reason: "timeout",
|
||||
locale: "en",
|
||||
});
|
||||
expect(create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
userId: "u1",
|
||||
action: BRIEFING_FAILURE_ACTION,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("never throws when the write fails", async () => {
|
||||
create.mockRejectedValue(new Error("db down"));
|
||||
await expect(
|
||||
recordBriefingFailure({ userId: "u1", reason: "timeout" }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* v1.25 — the comprehensive briefing generator honours the per-user
|
||||
* `aiResponseTimeoutSeconds` setting and keeps its last good text on failure.
|
||||
*
|
||||
* Before this, every generation call hardcoded `AI_BUDGETS.comprehensive
|
||||
* .timeoutMs`, so raising the timeout for a slow self-hosted backend had no
|
||||
* effect on the briefing. These tests pin: a set value is threaded onto the
|
||||
* provider call (seconds → ms); an unset value falls back to the budget; and a
|
||||
* terminal failure writes NO cache row (last good preserved) while recording a
|
||||
* failure marker the read path can surface.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
|
||||
|
||||
const findUnique = vi.fn();
|
||||
const userUpdate = vi.fn();
|
||||
const resolveProviderChain = vi.fn();
|
||||
const resolveProvider = vi.fn();
|
||||
const runRawCompletionWithFallback = vi.fn();
|
||||
const extractFeatures = vi.fn();
|
||||
const recordBriefingFailure = vi.fn();
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique: (...a: unknown[]) => findUnique(...a),
|
||||
update: (...a: unknown[]) => userUpdate(...a),
|
||||
},
|
||||
auditLog: { deleteMany: vi.fn(), create: vi.fn() },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/ai/provider", () => ({
|
||||
resolveProviderChain: (...a: unknown[]) => resolveProviderChain(...a),
|
||||
resolveProvider: (...a: unknown[]) => resolveProvider(...a),
|
||||
}));
|
||||
vi.mock("@/lib/ai/provider-runner", () => ({
|
||||
AllProvidersFailedError: class extends Error {},
|
||||
runRawCompletionWithFallback: (...a: unknown[]) =>
|
||||
runRawCompletionWithFallback(...a),
|
||||
}));
|
||||
vi.mock("@/lib/insights/features", () => ({
|
||||
FeaturesPayloadTooLargeError: class extends Error {
|
||||
sizeBytes = 0;
|
||||
},
|
||||
extractFeatures: (...a: unknown[]) => extractFeatures(...a),
|
||||
BRIEFING_FEATURE_WINDOW_DAYS: 400,
|
||||
}));
|
||||
vi.mock("@/lib/insights/illness-cycle-briefing", () => ({
|
||||
buildBriefingIllnessCycleContext: vi.fn().mockResolvedValue(null),
|
||||
buildBriefingIllnessCyclePrompt: vi.fn().mockReturnValue(""),
|
||||
}));
|
||||
vi.mock("@/lib/insights/glp1-plateau", () => ({
|
||||
detectGlp1Plateau: vi.fn(async () => null),
|
||||
buildGlp1PlateauPrompt: vi.fn(() => ""),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/about-me", () => ({
|
||||
getSelfContextTextForUser: vi.fn(async () => null),
|
||||
buildAboutMeInsightBlock: vi.fn(() => ""),
|
||||
}));
|
||||
vi.mock("@/lib/cache/invalidate", () => ({
|
||||
invalidateUserInsights: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/insights/briefing-failure-marker", () => ({
|
||||
recordBriefingFailure: (...a: unknown[]) => recordBriefingFailure(...a),
|
||||
}));
|
||||
|
||||
import { generateComprehensiveInsight } from "../comprehensive-generate";
|
||||
|
||||
const FEATURES = { weight: { count: 12, latest: 81.4, mean30: 82.1 } };
|
||||
const VALID = JSON.stringify({ dailyBriefing: { paragraph: "ok" } });
|
||||
|
||||
function mockUser(overrides: Record<string, unknown> = {}) {
|
||||
findUnique.mockResolvedValue({
|
||||
insightsPrivacyMode: "aggregated",
|
||||
insightsCachedAt: null,
|
||||
insightsCachedText: null,
|
||||
insightsExcludeMetrics: [],
|
||||
insightsSnapshotHash: null,
|
||||
insightsBriefingRerollDate: null,
|
||||
aiResponseTimeoutSeconds: null,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resolveProviderChain.mockResolvedValue([
|
||||
{ providerType: "openai", instance: {} },
|
||||
]);
|
||||
resolveProvider.mockResolvedValue({ type: "none" });
|
||||
extractFeatures.mockResolvedValue(FEATURES);
|
||||
userUpdate.mockResolvedValue({});
|
||||
mockUser();
|
||||
});
|
||||
|
||||
describe("comprehensive timeout honouring", () => {
|
||||
it("threads the per-user timeout (seconds → ms) onto the provider call", async () => {
|
||||
mockUser({ aiResponseTimeoutSeconds: 300 });
|
||||
runRawCompletionWithFallback.mockResolvedValue({
|
||||
result: { content: VALID, tokensUsed: 10, model: "m" },
|
||||
workingProvider: { providerType: "openai" },
|
||||
fallbackHops: [],
|
||||
});
|
||||
|
||||
await generateComprehensiveInsight("u1", { locale: "de" });
|
||||
|
||||
const params = runRawCompletionWithFallback.mock.calls[0][0].params;
|
||||
expect(params.timeoutMs).toBe(300_000);
|
||||
expect(params.timeoutMs).not.toBe(AI_BUDGETS.comprehensive.timeoutMs);
|
||||
});
|
||||
|
||||
it("falls back to the comprehensive budget when the setting is unset", async () => {
|
||||
mockUser({ aiResponseTimeoutSeconds: null });
|
||||
runRawCompletionWithFallback.mockResolvedValue({
|
||||
result: { content: VALID, tokensUsed: 10, model: "m" },
|
||||
workingProvider: { providerType: "openai" },
|
||||
fallbackHops: [],
|
||||
});
|
||||
|
||||
await generateComprehensiveInsight("u1", { locale: "de" });
|
||||
|
||||
const params = runRawCompletionWithFallback.mock.calls[0][0].params;
|
||||
expect(params.timeoutMs).toBe(AI_BUDGETS.comprehensive.timeoutMs);
|
||||
});
|
||||
|
||||
it("keeps the last good text and records a failure marker on a provider error", async () => {
|
||||
mockUser({ aiResponseTimeoutSeconds: 300 });
|
||||
runRawCompletionWithFallback.mockRejectedValue(new Error("boom"));
|
||||
|
||||
const outcome = await generateComprehensiveInsight("u1", { locale: "de" });
|
||||
|
||||
expect(outcome.status).toBe("failed");
|
||||
// No cache row written → the prior `insightsCachedText` (last good) survives.
|
||||
expect(userUpdate).not.toHaveBeenCalled();
|
||||
// A dated failure marker is recorded so the read path can surface a hint.
|
||||
expect(recordBriefingFailure).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ userId: "u1", locale: "de" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* Dated failure marker for the daily-briefing generation.
|
||||
*
|
||||
* The briefing keeps its last good payload on a failed refresh: the generator
|
||||
* writes NO `User.insightsCachedText` row when a provider times out or errors,
|
||||
* so the previous briefing stays intact and the surface never blanks. What was
|
||||
* missing is a signal the READ path can use to tell the difference between "no
|
||||
* refresh was due" and "the last refresh attempt failed" — without that, a
|
||||
* stale-but-shown briefing reads as current and a never-generated one shows a
|
||||
* generic empty state instead of an honest "couldn't generate".
|
||||
*
|
||||
* This records an append-only `auditLog` row (no migration, no overwrite of the
|
||||
* cached text) carrying the calendar day, the reason, and the locale. The read
|
||||
* path compares the latest marker's timestamp against the last successful
|
||||
* generation (`User.insightsCachedAt`): a marker newer than the last success
|
||||
* means the most recent attempt failed.
|
||||
*/
|
||||
export const BRIEFING_FAILURE_ACTION = "insights.briefing-failure";
|
||||
|
||||
/** UTC YYYY-MM-DD calendar-day key for the marker payload. */
|
||||
function dateKey(at: Date = new Date()): string {
|
||||
return at.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a failure marker for `userId`. Best-effort: a write failure here must
|
||||
* never propagate out of the generation's own failure path.
|
||||
*/
|
||||
export async function recordBriefingFailure(args: {
|
||||
userId: string;
|
||||
reason: string;
|
||||
locale?: string;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
userId: args.userId,
|
||||
action: BRIEFING_FAILURE_ACTION,
|
||||
details: JSON.stringify({
|
||||
dateKey: dateKey(),
|
||||
reason: args.reason,
|
||||
locale: args.locale ?? null,
|
||||
triedAt: new Date().toISOString(),
|
||||
}),
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// Marker is best-effort — never throw out of the failure path.
|
||||
}
|
||||
}
|
||||
|
||||
export interface BriefingFailureState {
|
||||
triedAt: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the latest briefing-failure marker for `userId`, but only when it is
|
||||
* newer than the last successful generation (`since`). Returns `null` when the
|
||||
* most recent attempt succeeded (or there is no marker at all), so the read
|
||||
* path can render a discreet "couldn't refresh" hint exactly when it applies.
|
||||
*/
|
||||
export async function readBriefingFailure(args: {
|
||||
userId: string;
|
||||
since: Date | null;
|
||||
}): Promise<BriefingFailureState | null> {
|
||||
const latest = await prisma.auditLog.findFirst({
|
||||
where: { userId: args.userId, action: BRIEFING_FAILURE_ACTION },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { createdAt: true, details: true },
|
||||
});
|
||||
if (!latest) return null;
|
||||
// A failure older than (or equal to) the last successful generation has been
|
||||
// superseded — the surface is showing fresh text, not a held one.
|
||||
if (args.since && latest.createdAt <= args.since) return null;
|
||||
|
||||
let reason = "unknown";
|
||||
if (latest.details) {
|
||||
try {
|
||||
const parsed = JSON.parse(latest.details) as { reason?: unknown };
|
||||
if (typeof parsed.reason === "string") reason = parsed.reason;
|
||||
} catch {
|
||||
// Malformed marker payload — still report the failure, generic reason.
|
||||
}
|
||||
}
|
||||
return { triedAt: latest.createdAt.toISOString(), reason };
|
||||
}
|
||||
@@ -93,6 +93,8 @@ import {
|
||||
} from "@/lib/insights/metric-status-registry";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
|
||||
import { resolveEffectiveTimeoutMs } from "@/lib/ai/effective-timeout";
|
||||
import { recordBriefingFailure } from "@/lib/insights/briefing-failure-marker";
|
||||
|
||||
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
@@ -200,6 +202,13 @@ async function rerollBriefingParagraph(args: {
|
||||
* care / workouts) the reroll may now cite.
|
||||
*/
|
||||
features?: AggregatedFeatures | null;
|
||||
/**
|
||||
* The upstream provider timeout for this re-roll, resolved from the user's
|
||||
* response-timeout setting (falling back to the comprehensive budget). The
|
||||
* caller resolves it once and threads it in so the re-roll honours the same
|
||||
* slow-backend allowance the main generation does.
|
||||
*/
|
||||
effectiveTimeoutMs: number;
|
||||
}): Promise<{ text: string; providerType: string } | null> {
|
||||
let cached: Record<string, unknown>;
|
||||
try {
|
||||
@@ -232,7 +241,8 @@ async function rerollBriefingParagraph(args: {
|
||||
maxTokens: AI_BUDGETS.comprehensive.maxTokens,
|
||||
// v1.21.5 — wider upstream budget so the reasoning-heavy briefing
|
||||
// generation is not clipped at the client's 60 s default mid-stream.
|
||||
timeoutMs: AI_BUDGETS.comprehensive.timeoutMs,
|
||||
// v1.25 — honours the per-user response-timeout setting (see caller).
|
||||
timeoutMs: args.effectiveTimeoutMs,
|
||||
responseFormat: "json",
|
||||
}),
|
||||
});
|
||||
@@ -730,9 +740,22 @@ export async function generateComprehensiveInsight(
|
||||
gender: true,
|
||||
// v1.22 (W6) — first name for the sparse, hash-gated briefing opener.
|
||||
displayName: true,
|
||||
// v1.25 — per-user response-timeout (seconds). Threaded onto every
|
||||
// provider call below so a raised value for a slow self-hosted backend
|
||||
// actually applies to the briefing, not just to Coach.
|
||||
aiResponseTimeoutSeconds: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Resolve the upstream provider timeout once from the user's setting,
|
||||
// falling back to the comprehensive budget. Every generation call in this
|
||||
// function (main, JSON retry, grounding retry, and the paragraph re-roll)
|
||||
// uses it so the whole briefing path honours the operator's allowance.
|
||||
const effectiveTimeoutMs = resolveEffectiveTimeoutMs(
|
||||
dbUser?.aiResponseTimeoutSeconds,
|
||||
AI_BUDGETS.comprehensive.timeoutMs,
|
||||
);
|
||||
|
||||
if (
|
||||
!force &&
|
||||
dbUser?.insightsCachedAt &&
|
||||
@@ -930,6 +953,7 @@ export async function generateComprehensiveInsight(
|
||||
locale,
|
||||
signals: features.signalsOfDay ?? null,
|
||||
features,
|
||||
effectiveTimeoutMs,
|
||||
});
|
||||
if (rerolled) {
|
||||
await prisma.user.update({
|
||||
@@ -986,7 +1010,8 @@ export async function generateComprehensiveInsight(
|
||||
maxTokens: AI_BUDGETS.comprehensive.maxTokens,
|
||||
// v1.21.5 — wider upstream budget so the reasoning-heavy briefing
|
||||
// generation is not clipped at the client's 60 s default mid-stream.
|
||||
timeoutMs: AI_BUDGETS.comprehensive.timeoutMs,
|
||||
// v1.25 — honours the per-user response-timeout setting.
|
||||
timeoutMs: effectiveTimeoutMs,
|
||||
// v1.18.7 — structured surface: opt the non-OpenAI chains into their
|
||||
// strongest JSON mode (Ollama `format`, Anthropic `{` prefill) so a
|
||||
// first-pass JSON miss is rarer; stripJsonFences stays the net.
|
||||
@@ -1019,6 +1044,11 @@ export async function generateComprehensiveInsight(
|
||||
provider_message: err.message?.slice(0, 240) ?? null,
|
||||
},
|
||||
});
|
||||
// v1.25 — record a dated failure marker so the read path can surface a
|
||||
// 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 });
|
||||
return { status: "failed", reason };
|
||||
}
|
||||
|
||||
@@ -1050,17 +1080,20 @@ export async function generateComprehensiveInsight(
|
||||
temperature: AI_BUDGETS.comprehensive.temperature,
|
||||
maxTokens: AI_BUDGETS.comprehensive.maxTokens,
|
||||
// v1.21.5 — wider upstream budget; see the first generation call.
|
||||
timeoutMs: AI_BUDGETS.comprehensive.timeoutMs,
|
||||
// v1.25 — honours the per-user response-timeout setting.
|
||||
timeoutMs: effectiveTimeoutMs,
|
||||
responseFormat: "json",
|
||||
}),
|
||||
});
|
||||
result = retry.result;
|
||||
workingProviderType = retry.workingProvider.providerType;
|
||||
} catch {
|
||||
void recordBriefingFailure({ userId, reason: "invalid-json", locale });
|
||||
return { status: "failed", reason: "invalid-json" };
|
||||
}
|
||||
insights = parseComprehensiveResult(result.content);
|
||||
if (insights === null) {
|
||||
void recordBriefingFailure({ userId, reason: "invalid-json", locale });
|
||||
return { status: "failed", reason: "invalid-json" };
|
||||
}
|
||||
}
|
||||
@@ -1092,7 +1125,8 @@ export async function generateComprehensiveInsight(
|
||||
temperature: AI_BUDGETS.comprehensive.temperature,
|
||||
maxTokens: AI_BUDGETS.comprehensive.maxTokens,
|
||||
// v1.21.5 — wider upstream budget; see the first generation call.
|
||||
timeoutMs: AI_BUDGETS.comprehensive.timeoutMs,
|
||||
// v1.25 — honours the per-user response-timeout setting.
|
||||
timeoutMs: effectiveTimeoutMs,
|
||||
responseFormat: "json",
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -13,6 +13,8 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { AI_BUDGETS, REFERENCE_AI_SEED } from "@/lib/ai/ai-budgets";
|
||||
import { singleUserTurn } from "@/lib/ai/types";
|
||||
import { STATUS_PROVIDER_TIMEOUT_MS, withTimeout } from "./with-timeout";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { resolveEffectiveTimeoutMs } from "@/lib/ai/effective-timeout";
|
||||
|
||||
/**
|
||||
* Shared provider plumbing for the seven `*-status.ts` generators.
|
||||
@@ -134,8 +136,9 @@ export async function statusConsentBlocksGeneration(
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a status generation across the user's provider chain, bounded by
|
||||
* `STATUS_PROVIDER_TIMEOUT_MS`. The result discriminates between
|
||||
* Run a status generation across the user's provider chain, bounded by the
|
||||
* per-user response-timeout setting (falling back to `STATUS_PROVIDER_TIMEOUT_MS`
|
||||
* when unset). The result discriminates between
|
||||
* no-provider / timeout / provider-error / success so the caller can
|
||||
* decide what to persist — only `ok` is ever cached as the day's
|
||||
* assessment.
|
||||
@@ -168,6 +171,24 @@ export async function runStatusCompletion(
|
||||
return { kind: "none" };
|
||||
}
|
||||
|
||||
// Honour the per-user response-timeout setting the operator dials in for a
|
||||
// slow self-hosted / local backend (Settings → AI). This is the single
|
||||
// chokepoint every status / reference surface funnels through — per-metric
|
||||
// cards, the batched assessment, the derived assessments, and the period
|
||||
// narratives — so resolving it here threads the setting onto all of them at
|
||||
// once. A positive stored value wins (seconds → ms); unset falls back to the
|
||||
// status-path budget. Applied to BOTH the upstream call's own `timeoutMs`
|
||||
// and the outer `withTimeout` cap so a raised value is not silently clipped
|
||||
// by the 60 s wall-clock that previously bounded the path.
|
||||
const settingsRow = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { aiResponseTimeoutSeconds: true },
|
||||
});
|
||||
const effectiveTimeoutMs = resolveEffectiveTimeoutMs(
|
||||
settingsRow?.aiResponseTimeoutSeconds,
|
||||
STATUS_PROVIDER_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
const raced = await withTimeout(
|
||||
() =>
|
||||
runRawCompletionWithFallback({
|
||||
@@ -184,9 +205,10 @@ export async function runStatusCompletion(
|
||||
// Status cards are JSON by default; the narrative opts out via
|
||||
// `"text"`.
|
||||
responseFormat: args.responseFormat === "text" ? undefined : "json",
|
||||
timeoutMs: effectiveTimeoutMs,
|
||||
}),
|
||||
}),
|
||||
STATUS_PROVIDER_TIMEOUT_MS,
|
||||
effectiveTimeoutMs,
|
||||
null,
|
||||
);
|
||||
|
||||
|
||||
Reference in new issue
Block a user