diff --git a/src/lib/insights/blood-pressure-status.ts b/src/lib/insights/blood-pressure-status.ts index 787a76970..0d4be7335 100644 --- a/src/lib/insights/blood-pressure-status.ts +++ b/src/lib/insights/blood-pressure-status.ts @@ -48,7 +48,6 @@ import { round, summarizeSeries, } from "@/lib/insights/status-shared"; -import { runStatusCompletion } from "@/lib/insights/status-provider"; import { readFreshStatusText, refreshUnchangedStatusInsight, @@ -57,6 +56,11 @@ import { } from "@/lib/insights/status-cache"; import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash"; import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback"; +import { + runPreparedStatusCard, + type PreparedStatusCard, + type StatusCardResult, +} from "@/lib/insights/status-card-generation"; import { annotate } from "@/lib/logging/context"; import { toBerlinDayKey } from "@/lib/tz/resolver"; @@ -102,12 +106,17 @@ function pairDailyBuckets( ); } +/** + * Public entry — unchanged signature. Prepares the card then, when the LLM + * is still needed, runs ONE completion through the shared single-card path + * (v1.18.7 HIGH-1). One-call-per-metric behaviour and the fallbacks are + * preserved. + */ export async function generateBloodPressureStatusForUser( userId: string, options?: { locale?: string | null; force?: boolean; - /** v1.8.3 — read-only navigation path; see weight-status for the rationale. */ readOnly?: boolean; }, ): Promise<{ @@ -116,9 +125,33 @@ export async function generateBloodPressureStatusForUser( cached: boolean; updatedAt: string | null; preparing?: boolean; - /** v1.9.0 — last-good text served while a refresh is in flight; keep polling. */ revalidating?: boolean; }> { + const prepared = await prepareBloodPressureStatusForUser(userId, options); + const result = await runPreparedStatusCard(prepared); + return result as { + hasProvider: boolean; + text: string | null; + cached: boolean; + updatedAt: string | null; + preparing?: boolean; + revalidating?: boolean; + }; +} + +/** + * v1.18.7 (HIGH-1) — everything up to (not including) the provider call. + * See `status-card-generation.ts` for the prepare/run/finalize contract. + */ +export async function prepareBloodPressureStatusForUser( + userId: string, + options?: { + locale?: string | null; + force?: boolean; + /** v1.8.3 — read-only navigation path; see weight-status for the rationale. */ + readOnly?: boolean; + }, +): Promise { const locale = normalizeLocale(options?.locale); const force = options?.force === true; const readOnly = options?.readOnly === true; @@ -133,10 +166,13 @@ export async function generateBloodPressureStatusForUser( }); if (cached) { return { - hasProvider: true, - text: cached.text, - cached: true, - updatedAt: cached.updatedAt, + phase: "served", + result: { + hasProvider: true, + text: cached.text, + cached: true, + updatedAt: cached.updatedAt, + }, }; } @@ -150,22 +186,28 @@ export async function generateBloodPressureStatusForUser( // bmi-status); no enqueue happens for it. if (outcome.kind === "no-provider" || outcome.kind === "consent-missing") { return { - hasProvider: false, - text: getNoKeyBloodPressureStatusText(locale), - cached: true, - updatedAt: null, + phase: "served", + result: { + hasProvider: false, + text: getNoKeyBloodPressureStatusText(locale), + cached: true, + updatedAt: null, + }, }; } // v1.8.7 — stale-while-revalidate: serve the last good assessment // (if any) instantly while the worker re-warms; only fall to the empty // preparing skeleton when none was ever produced. return { - hasProvider: true, - text: outcome.lastGood?.text ?? null, - cached: outcome.lastGood !== null, - updatedAt: outcome.lastGood?.updatedAt ?? null, - preparing: outcome.lastGood === null, - revalidating: outcome.revalidating, + phase: "served", + result: { + hasProvider: true, + text: outcome.lastGood?.text ?? null, + cached: outcome.lastGood !== null, + updatedAt: outcome.lastGood?.updatedAt ?? null, + preparing: outcome.lastGood === null, + revalidating: outcome.revalidating, + }, }; } @@ -559,10 +601,13 @@ export async function generateBloodPressureStatusForUser( }); if (unchanged) { return { - hasProvider: true, - text: unchanged.text, - cached: true, - updatedAt: unchanged.updatedAt, + phase: "served", + result: { + hasProvider: true, + text: unchanged.text, + cached: true, + updatedAt: unchanged.updatedAt, + }, }; } @@ -600,10 +645,13 @@ export async function generateBloodPressureStatusForUser( locale, ); - const outcome = await runStatusCompletion({ + // The provider call is deferred to the caller — single-card path runs ONE + // completion; the batch path folds this prompt into one shared call. + return { + phase: "pending", + metric: "blood-pressure", userId, cacheAction, - consentSurface: "insights", systemPrompt: getBloodPressureSystemPrompt(locale), userPrompt: getBloodPressureUserPrompt( snapshotJson, @@ -612,54 +660,51 @@ export async function generateBloodPressureStatusForUser( previousContextBlock, assessmentContextBlock, ), + snapshotHash, // v1.12.7 — match the archetype cards' 0.45: more cadence entropy while // FACTS stay pinned by the snapshot + the forbidden-phrase guards. temperature: 0.45, - maxTokens: 1000, - }); - - if (outcome.kind === "none") { - return { + noProvider: { hasProvider: false, text: getNoKeyBloodPressureStatusText(locale), cached: true, updatedAt: null, - }; - } - if (outcome.kind === "timeout" || outcome.kind === "error") { - return returnTimeoutFallback({ - cacheAction, - reason: outcome.kind, - userId, - todayKey, - stubText: getNoKeyBloodPressureStatusText(locale), - }); - } - - const summary = normalizeSummaryText(parseSummaryFromContent(outcome.content)); - if (!summary) { - throw new Error( - "Blood-pressure-status summary was empty after normalization", - ); - } - - const updatedAt = await persistStatusInsight({ - userId, - cacheAction, - todayKey, - locale, - text: summary, - providerType: outcome.providerType, - model: outcome.model, - tokensUsed: outcome.tokensUsed, - snapshotHash, - }); - - return { - hasProvider: true, - text: summary, - cached: false, - updatedAt, + }, + timeout: (reason): StatusCardResult => + returnTimeoutFallback({ + cacheAction, + reason, + userId, + todayKey, + stubText: getNoKeyBloodPressureStatusText(locale), + }), + finalize: async (outcome): Promise => { + const summary = normalizeSummaryText( + parseSummaryFromContent(outcome.content), + ); + if (!summary) { + throw new Error( + "Blood-pressure-status summary was empty after normalization", + ); + } + const updatedAt = await persistStatusInsight({ + userId, + cacheAction, + todayKey, + locale, + text: summary, + providerType: outcome.providerType, + model: outcome.model, + tokensUsed: outcome.tokensUsed, + snapshotHash, + }); + return { + hasProvider: true, + text: summary, + cached: false, + updatedAt, + }; + }, }; } diff --git a/src/lib/insights/bmi-status.ts b/src/lib/insights/bmi-status.ts index 0fa917af6..39f86af73 100644 --- a/src/lib/insights/bmi-status.ts +++ b/src/lib/insights/bmi-status.ts @@ -26,7 +26,6 @@ import { round, summarizeSeries, } from "@/lib/insights/status-shared"; -import { runStatusCompletion } from "@/lib/insights/status-provider"; import { readFreshStatusText, refreshUnchangedStatusInsight, @@ -35,9 +34,21 @@ import { } from "@/lib/insights/status-cache"; import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash"; import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback"; +import { + runPreparedStatusCard, + type PreparedStatusCard, + type StatusCardResult, +} from "@/lib/insights/status-card-generation"; import { annotate } from "@/lib/logging/context"; import { toBerlinDayKey } from "@/lib/tz/resolver"; +/** + * Public entry — unchanged signature. Prepares the card (cache-read, + * read-only miss, snapshot build, hash gate) then, when the LLM is still + * needed, runs ONE completion through the shared single-card path. The + * one-call-per-metric behaviour and the timeout / no-provider fallbacks are + * byte-for-byte the same as before the prepare split (v1.18.7 HIGH-1). + */ export async function generateBmiStatusForUser( userId: string, options?: { @@ -55,6 +66,33 @@ export async function generateBmiStatusForUser( /** v1.9.0 — last-good text served while a refresh is in flight; keep polling. */ revalidating?: boolean; }> { + const prepared = await prepareBmiStatusForUser(userId, options); + const result = await runPreparedStatusCard(prepared); + return result as { + hasProvider: boolean; + text: string | null; + cached: boolean; + updatedAt: string | null; + preparing?: boolean; + revalidating?: boolean; + }; +} + +/** + * v1.18.7 (HIGH-1) — everything up to (not including) the provider call. + * Returns a finished `served` result for every path that never needed the + * LLM, or a `pending` descriptor the single-card path and the batch path + * both drive. See `status-card-generation.ts` for the contract. + */ +export async function prepareBmiStatusForUser( + userId: string, + options?: { + locale?: string | null; + force?: boolean; + /** v1.8.3 — read-only navigation path; see weight-status for the rationale. */ + readOnly?: boolean; + }, +): Promise { const locale = normalizeLocale(options?.locale); const force = options?.force === true; const readOnly = options?.readOnly === true; @@ -69,10 +107,13 @@ export async function generateBmiStatusForUser( }); if (cached) { return { - hasProvider: true, - text: cached.text, - cached: true, - updatedAt: cached.updatedAt, + phase: "served", + result: { + hasProvider: true, + text: cached.text, + cached: true, + updatedAt: cached.updatedAt, + }, }; } @@ -87,22 +128,28 @@ export async function generateBmiStatusForUser( // fallback; no enqueue happens for it (the resolver short-circuits). if (outcome.kind === "no-provider" || outcome.kind === "consent-missing") { return { - hasProvider: false, - text: getNoKeyBmiStatusText(locale), - cached: true, - updatedAt: null, + phase: "served", + result: { + hasProvider: false, + text: getNoKeyBmiStatusText(locale), + cached: true, + updatedAt: null, + }, }; } // v1.8.7 — stale-while-revalidate: serve the last good assessment // (if any) instantly while the worker re-warms; only fall to the empty // preparing skeleton when none was ever produced. return { - hasProvider: true, - text: outcome.lastGood?.text ?? null, - cached: outcome.lastGood !== null, - updatedAt: outcome.lastGood?.updatedAt ?? null, - preparing: outcome.lastGood === null, - revalidating: outcome.revalidating, + phase: "served", + result: { + hasProvider: true, + text: outcome.lastGood?.text ?? null, + cached: outcome.lastGood !== null, + updatedAt: outcome.lastGood?.updatedAt ?? null, + preparing: outcome.lastGood === null, + revalidating: outcome.revalidating, + }, }; } @@ -115,13 +162,16 @@ export async function generateBmiStatusForUser( if (!user?.heightCm || user.heightCm <= 0) { return { - hasProvider: true, - text: - locale === "de" - ? "Für die BMI-Einschätzung fehlen aktuell Größenangaben im Profil." - : "BMI assessment currently requires height data in the profile.", - cached: true, - updatedAt: null, + phase: "served", + result: { + hasProvider: true, + text: + locale === "de" + ? "Für die BMI-Einschätzung fehlen aktuell Größenangaben im Profil." + : "BMI assessment currently requires height data in the profile.", + cached: true, + updatedAt: null, + }, }; } @@ -250,10 +300,13 @@ export async function generateBmiStatusForUser( }); if (unchanged) { return { - hasProvider: true, - text: unchanged.text, - cached: true, - updatedAt: unchanged.updatedAt, + phase: "served", + result: { + hasProvider: true, + text: unchanged.text, + cached: true, + updatedAt: unchanged.updatedAt, + }, }; } @@ -287,10 +340,15 @@ export async function generateBmiStatusForUser( locale, ); - const outcome = await runStatusCompletion({ + // The provider call is deferred to the caller — the single-card path runs + // ONE completion, the batch path folds this prompt into one shared call. + // A timeout / error is a transient miss (fallback served, no assessment + // persisted); `none` (no provider / no consent) serves the no-key text. + return { + phase: "pending", + metric: "bmi", userId, cacheAction, - consentSurface: "insights", systemPrompt: getBmiSystemPrompt(locale), userPrompt: getBmiUserPrompt( snapshotJson, @@ -299,51 +357,48 @@ export async function generateBmiStatusForUser( previousContextBlock, assessmentContextBlock, ), + snapshotHash, // v1.12.7 — match the archetype cards' 0.45. temperature: 0.45, - maxTokens: 1000, - }); - - if (outcome.kind === "none") { - return { + noProvider: { hasProvider: false, text: getNoKeyBmiStatusText(locale), cached: true, updatedAt: null, - }; - } - if (outcome.kind === "timeout" || outcome.kind === "error") { - return returnTimeoutFallback({ - cacheAction, - reason: outcome.kind, - userId, - todayKey, - stubText: getNoKeyBmiStatusText(locale), - }); - } - - const summary = normalizeSummaryText(parseSummaryFromContent(outcome.content)); - if (!summary) { - throw new Error("Bmi-status summary was empty after normalization"); - } - - const updatedAt = await persistStatusInsight({ - userId, - cacheAction, - todayKey, - locale, - text: summary, - providerType: outcome.providerType, - model: outcome.model, - tokensUsed: outcome.tokensUsed, - snapshotHash, - }); - - return { - hasProvider: true, - text: summary, - cached: false, - updatedAt, + }, + timeout: (reason): StatusCardResult => + returnTimeoutFallback({ + cacheAction, + reason, + userId, + todayKey, + stubText: getNoKeyBmiStatusText(locale), + }), + finalize: async (outcome): Promise => { + const summary = normalizeSummaryText( + parseSummaryFromContent(outcome.content), + ); + if (!summary) { + throw new Error("Bmi-status summary was empty after normalization"); + } + const updatedAt = await persistStatusInsight({ + userId, + cacheAction, + todayKey, + locale, + text: summary, + providerType: outcome.providerType, + model: outcome.model, + tokensUsed: outcome.tokensUsed, + snapshotHash, + }); + return { + hasProvider: true, + text: summary, + cached: false, + updatedAt, + }; + }, }; } diff --git a/src/lib/insights/general-status.ts b/src/lib/insights/general-status.ts index f1be66c83..e545f6ac5 100644 --- a/src/lib/insights/general-status.ts +++ b/src/lib/insights/general-status.ts @@ -28,7 +28,6 @@ import { round, summarizeSeries, } from "@/lib/insights/status-shared"; -import { runStatusCompletion } from "@/lib/insights/status-provider"; import { readFreshStatusText, refreshUnchangedStatusInsight, @@ -37,6 +36,11 @@ import { } from "@/lib/insights/status-cache"; import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash"; import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback"; +import { + runPreparedStatusCard, + type PreparedStatusCard, + type StatusCardResult, +} from "@/lib/insights/status-card-generation"; import { annotate } from "@/lib/logging/context"; import { toBerlinDayKey } from "@/lib/tz/resolver"; @@ -69,6 +73,27 @@ export async function generateGeneralStatusForUser( /** v1.9.0 — last-good text served while a refresh is in flight; keep polling. */ revalidating?: boolean; }> { + const prepared = await prepareGeneralStatusForUser(userId, options); + const result = await runPreparedStatusCard(prepared); + return result as { + hasProvider: boolean; + text: string | null; + cached: boolean; + updatedAt: string | null; + preparing?: boolean; + revalidating?: boolean; + }; +} + +export async function prepareGeneralStatusForUser( + userId: string, + options?: { + locale?: string | null; + force?: boolean; + /** v1.8.3 — read-only navigation path; see weight-status for the rationale. */ + readOnly?: boolean; + }, +): Promise { const locale = normalizeLocale(options?.locale); const force = options?.force === true; const readOnly = options?.readOnly === true; @@ -83,10 +108,13 @@ export async function generateGeneralStatusForUser( }); if (cached) { return { - hasProvider: true, - text: cached.text, - cached: true, - updatedAt: cached.updatedAt, + phase: "served", + result: { + hasProvider: true, + text: cached.text, + cached: true, + updatedAt: cached.updatedAt, + }, }; } @@ -100,22 +128,28 @@ export async function generateGeneralStatusForUser( // bmi-status); no enqueue happens for it. if (outcome.kind === "no-provider" || outcome.kind === "consent-missing") { return { - hasProvider: false, - text: getNoKeyGeneralStatusText(locale), - cached: true, - updatedAt: null, + phase: "served", + result: { + hasProvider: false, + text: getNoKeyGeneralStatusText(locale), + cached: true, + updatedAt: null, + }, }; } // v1.8.7 — stale-while-revalidate: serve the last good assessment // (if any) instantly while the worker re-warms; only fall to the empty // preparing skeleton when none was ever produced. return { - hasProvider: true, - text: outcome.lastGood?.text ?? null, - cached: outcome.lastGood !== null, - updatedAt: outcome.lastGood?.updatedAt ?? null, - preparing: outcome.lastGood === null, - revalidating: outcome.revalidating, + phase: "served", + result: { + hasProvider: true, + text: outcome.lastGood?.text ?? null, + cached: outcome.lastGood !== null, + updatedAt: outcome.lastGood?.updatedAt ?? null, + preparing: outcome.lastGood === null, + revalidating: outcome.revalidating, + }, }; } @@ -376,10 +410,13 @@ export async function generateGeneralStatusForUser( }); if (unchanged) { return { - hasProvider: true, - text: unchanged.text, - cached: true, - updatedAt: unchanged.updatedAt, + phase: "served", + result: { + hasProvider: true, + text: unchanged.text, + cached: true, + updatedAt: unchanged.updatedAt, + }, }; } @@ -416,10 +453,15 @@ export async function generateGeneralStatusForUser( locale, ); - const outcome = await runStatusCompletion({ + // The provider call is deferred to the caller — the single-card path runs + // ONE completion, the batch path folds this prompt into one shared call. + // A timeout / error is a transient miss (fallback served, no assessment + // persisted); `none` (no provider / no consent) serves the no-key text. + return { + phase: "pending", + metric: "general", userId, cacheAction, - consentSurface: "insights", systemPrompt: getGeneralStatusSystemPrompt(locale), userPrompt: getGeneralStatusUserPrompt( snapshotJson, @@ -428,51 +470,48 @@ export async function generateGeneralStatusForUser( previousContextBlock, assessmentContextBlock, ), + snapshotHash, // v1.12.7 — match the archetype cards' 0.45. temperature: 0.45, - maxTokens: 1000, - }); - - if (outcome.kind === "none") { - return { + noProvider: { hasProvider: false, text: getNoKeyGeneralStatusText(locale), cached: true, updatedAt: null, - }; - } - if (outcome.kind === "timeout" || outcome.kind === "error") { - return returnTimeoutFallback({ - cacheAction, - reason: outcome.kind, - userId, - todayKey, - stubText: getNoKeyGeneralStatusText(locale), - }); - } - - const summary = normalizeSummaryText(parseSummaryFromContent(outcome.content)); - if (!summary) { - throw new Error("General-status summary was empty after normalization"); - } - - const updatedAt = await persistStatusInsight({ - userId, - cacheAction, - todayKey, - locale, - text: summary, - providerType: outcome.providerType, - model: outcome.model, - tokensUsed: outcome.tokensUsed, - snapshotHash, - }); - - return { - hasProvider: true, - text: summary, - cached: false, - updatedAt, + }, + timeout: (reason): StatusCardResult => + returnTimeoutFallback({ + cacheAction, + reason, + userId, + todayKey, + stubText: getNoKeyGeneralStatusText(locale), + }), + finalize: async (outcome): Promise => { + const summary = normalizeSummaryText( + parseSummaryFromContent(outcome.content), + ); + if (!summary) { + throw new Error("General-status summary was empty after normalization"); + } + const updatedAt = await persistStatusInsight({ + userId, + cacheAction, + todayKey, + locale, + text: summary, + providerType: outcome.providerType, + model: outcome.model, + tokensUsed: outcome.tokensUsed, + snapshotHash, + }); + return { + hasProvider: true, + text: summary, + cached: false, + updatedAt, + }; + }, }; } diff --git a/src/lib/insights/medication-compliance-status.ts b/src/lib/insights/medication-compliance-status.ts index d8e7a8c14..312a8cd32 100644 --- a/src/lib/insights/medication-compliance-status.ts +++ b/src/lib/insights/medication-compliance-status.ts @@ -32,7 +32,6 @@ import { parseSummaryFromContent, round, } from "@/lib/insights/status-shared"; -import { runStatusCompletion } from "@/lib/insights/status-provider"; import { isTimeoutStub, resolveReadOnlyStatusMiss, @@ -40,6 +39,10 @@ import { } from "@/lib/insights/status-cache"; import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash"; import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback"; +import { + runPreparedStatusCard, + type PreparedStatusCard, +} from "@/lib/insights/status-card-generation"; import { annotate } from "@/lib/logging/context"; import { toBerlinDayKey } from "@/lib/tz/resolver"; @@ -52,7 +55,49 @@ interface MedicationSummaryItem { text: string; } +/** + * The richer compliance result envelope (`summary` + per-medication + * `medications`) the cards read — distinct from the standard `text`-only + * status result. It flows through the shared `PreparedStatusCard` union via + * the open index signature on `StatusCardResult`. + */ +interface MedicationComplianceResult { + hasProvider: boolean; + summary: string | null; + medications: MedicationSummaryItem[]; + cached: boolean; + updatedAt: string | null; + preparing?: boolean; + revalidating?: boolean; +} + +/** + * Public entry — unchanged signature. Prepares the card then runs ONE + * completion through the shared single-card path when the LLM is still + * needed (v1.18.7 HIGH-1). + */ export async function generateMedicationComplianceStatusForUser( + userId: string, + options?: { + locale?: string | null; + force?: boolean; + readOnly?: boolean; + }, +): Promise { + const prepared = await prepareMedicationComplianceStatusForUser( + userId, + options, + ); + const result = await runPreparedStatusCard(prepared); + return result as unknown as MedicationComplianceResult; +} + +/** + * v1.18.7 (HIGH-1) — everything up to (not including) the provider call. + * Carries the richer `{ summary, medications }` result through the shared + * prepare/run/finalize contract. See `status-card-generation.ts`. + */ +export async function prepareMedicationComplianceStatusForUser( userId: string, options?: { locale?: string | null; @@ -60,16 +105,7 @@ export async function generateMedicationComplianceStatusForUser( /** v1.8.3 — read-only navigation path; see weight-status for the rationale. */ readOnly?: boolean; }, -): Promise<{ - hasProvider: boolean; - summary: string | null; - medications: MedicationSummaryItem[]; - cached: boolean; - updatedAt: string | null; - preparing?: boolean; - /** v1.9.0 — last-good text served while a refresh is in flight; keep polling. */ - revalidating?: boolean; -}> { +): Promise { const locale = normalizeLocale(options?.locale); const force = options?.force === true; const readOnly = options?.readOnly === true; @@ -105,16 +141,19 @@ export async function generateMedicationComplianceStatusForUser( Array.isArray(parsed.medications) ) { return { - hasProvider: true, - summary: parsed.summary, - medications: parsed.medications.filter( - (entry): entry is MedicationSummaryItem => - typeof entry?.medicationId === "string" && - typeof entry?.text === "string" && - entry.text.trim().length > 0, - ), - cached: true, - updatedAt: latestCache.createdAt.toISOString(), + phase: "served", + result: { + hasProvider: true, + summary: parsed.summary, + medications: parsed.medications.filter( + (entry): entry is MedicationSummaryItem => + typeof entry?.medicationId === "string" && + typeof entry?.text === "string" && + entry.text.trim().length > 0, + ), + cached: true, + updatedAt: latestCache.createdAt.toISOString(), + }, }; } } catch { @@ -135,24 +174,30 @@ export async function generateMedicationComplianceStatusForUser( // bmi-status); no enqueue happens for it. if (outcome.kind === "no-provider" || outcome.kind === "consent-missing") { return { - hasProvider: false, - summary: getNoKeyMedicationComplianceStatusText(locale), - medications: [], - cached: true, - updatedAt: null, + phase: "served", + result: { + hasProvider: false, + summary: getNoKeyMedicationComplianceStatusText(locale), + medications: [], + cached: true, + updatedAt: null, + }, }; } // v1.8.7 — stale-while-revalidate: serve the last good narrative (if // any) while the worker re-warms; only show the empty preparing // skeleton when none was ever produced. return { - hasProvider: true, - summary: outcome.lastGood?.text ?? null, - medications: [], - cached: outcome.lastGood !== null, - updatedAt: outcome.lastGood?.updatedAt ?? null, - preparing: outcome.lastGood === null, - revalidating: outcome.revalidating, + phase: "served", + result: { + hasProvider: true, + summary: outcome.lastGood?.text ?? null, + medications: [], + cached: outcome.lastGood !== null, + updatedAt: outcome.lastGood?.updatedAt ?? null, + preparing: outcome.lastGood === null, + revalidating: outcome.revalidating, + }, }; } @@ -172,14 +217,17 @@ export async function generateMedicationComplianceStatusForUser( if (medications.length === 0) { return { - hasProvider: true, - summary: - locale === "de" - ? "Aktuell sind keine aktiven Medikamente hinterlegt." - : "There are currently no active medications configured.", - medications: [], - cached: true, - updatedAt: null, + phase: "served", + result: { + hasProvider: true, + summary: + locale === "de" + ? "Aktuell sind keine aktiven Medikamente hinterlegt." + : "There are currently no active medications configured.", + medications: [], + cached: true, + updatedAt: null, + }, }; } @@ -379,11 +427,14 @@ export async function generateMedicationComplianceStatusForUser( meta: { cache_action: cacheAction }, }); return { - hasProvider: true, - summary: parsed.summary, - medications: parsed.medications, - cached: true, - updatedAt: refreshed.createdAt.toISOString(), + phase: "served", + result: { + hasProvider: true, + summary: parsed.summary, + medications: parsed.medications, + cached: true, + updatedAt: refreshed.createdAt.toISOString(), + }, }; } } catch { @@ -425,56 +476,10 @@ export async function generateMedicationComplianceStatusForUser( locale, ); - const outcome = await runStatusCompletion({ - userId, - cacheAction, - consentSurface: "insights", - systemPrompt: getMedicationComplianceSystemPrompt(locale), - userPrompt: getMedicationComplianceUserPrompt( - snapshotJson, - todayKey, - locale, - previousContextBlock, - assessmentContextBlock, - ), - // v1.12.7 — match the archetype cards' 0.45. - temperature: 0.45, - maxTokens: 1000, - }); - - if (outcome.kind === "none") { - return { - hasProvider: false, - summary: getNoKeyMedicationComplianceStatusText(locale), - medications: [], - cached: true, - updatedAt: null, - }; - } - if (outcome.kind === "timeout" || outcome.kind === "error") { - // Transient miss — serve the fallback for this render without - // persisting it, so the next mount re-attempts a real generation. - returnTimeoutFallback({ - cacheAction, - reason: outcome.kind, - userId, - todayKey, - stubText: getNoKeyMedicationComplianceStatusText(locale), - }); - return { - hasProvider: true, - summary: getNoKeyMedicationComplianceStatusText(locale), - medications: [], - cached: true, - updatedAt: null, - }; - } - - // The compliance prompt returns a single `{ summary }` envelope — it - // does not emit per-medication text. The per-medication cards carry a - // placeholder so the UI surfaces a row per active medication; the - // overall `summary` is the model-authored assessment. - const summary = normalizeSummaryText(parseSummaryFromContent(outcome.content)); + // The per-medication placeholder rows the UI surfaces alongside the + // model-authored overall `summary`. Computed here so both `finalize` + // (success) reuses them; the model's compliance prompt returns only the + // single `{ summary }` envelope. const medicationSummaries: MedicationSummaryItem[] = medicationSnapshots.map( (medication) => ({ medicationId: medication.medicationId, @@ -486,36 +491,88 @@ export async function generateMedicationComplianceStatusForUser( }), ); - if (!summary) { - throw new Error( - "Medication-compliance-status summary was empty after normalization", - ); - } - - const created = await prisma.auditLog.create({ - data: { - userId, - action: cacheAction, - details: JSON.stringify({ - dateKey: todayKey, - locale, + // The provider call is deferred to the caller — single-card path runs ONE + // completion; the batch path folds this prompt into one shared call. + return { + phase: "pending", + metric: "medication-compliance", + userId, + cacheAction, + systemPrompt: getMedicationComplianceSystemPrompt(locale), + userPrompt: getMedicationComplianceUserPrompt( + snapshotJson, + todayKey, + locale, + previousContextBlock, + assessmentContextBlock, + ), + snapshotHash, + // v1.12.7 — match the archetype cards' 0.45. + temperature: 0.45, + noProvider: { + hasProvider: false, + summary: getNoKeyMedicationComplianceStatusText(locale), + medications: [], + cached: true, + updatedAt: null, + }, + timeout: (reason) => { + // Transient miss — write the short-TTL negative stub (so the read-only + // route does not re-enqueue while the provider is degraded) and serve + // the fallback for this render without persisting an assessment. + returnTimeoutFallback({ + cacheAction, + reason, + userId, + todayKey, + stubText: getNoKeyMedicationComplianceStatusText(locale), + }); + return { + hasProvider: true, + summary: getNoKeyMedicationComplianceStatusText(locale), + medications: [], + cached: true, + updatedAt: null, + }; + }, + finalize: async (outcome) => { + // The compliance prompt returns a single `{ summary }` envelope — it + // does not emit per-medication text. The per-medication cards carry a + // placeholder so the UI surfaces a row per active medication; the + // overall `summary` is the model-authored assessment. + const summary = normalizeSummaryText( + parseSummaryFromContent(outcome.content), + ); + if (!summary) { + throw new Error( + "Medication-compliance-status summary was empty after normalization", + ); + } + const created = await prisma.auditLog.create({ + data: { + userId, + action: cacheAction, + details: JSON.stringify({ + dateKey: todayKey, + locale, + summary, + medications: medicationSummaries, + providerType: outcome.providerType, + model: outcome.model, + tokensUsed: outcome.tokensUsed, + snapshotHash, + }), + }, + select: { createdAt: true }, + }); + return { + hasProvider: true, summary, medications: medicationSummaries, - providerType: outcome.providerType, - model: outcome.model, - tokensUsed: outcome.tokensUsed, - snapshotHash, - }), + cached: false, + updatedAt: created.createdAt.toISOString(), + }; }, - select: { createdAt: true }, - }); - - return { - hasProvider: true, - summary, - medications: medicationSummaries, - cached: false, - updatedAt: created.createdAt.toISOString(), }; } diff --git a/src/lib/insights/mood-status.ts b/src/lib/insights/mood-status.ts index e41790659..dc1c7fa06 100644 --- a/src/lib/insights/mood-status.ts +++ b/src/lib/insights/mood-status.ts @@ -45,7 +45,6 @@ import { round, summarizeSeries, } from "@/lib/insights/status-shared"; -import { runStatusCompletion } from "@/lib/insights/status-provider"; import { readFreshStatusText, refreshUnchangedStatusInsight, @@ -54,6 +53,11 @@ import { } from "@/lib/insights/status-cache"; import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash"; import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback"; +import { + runPreparedStatusCard, + type PreparedStatusCard, + type StatusCardResult, +} from "@/lib/insights/status-card-generation"; import { annotate } from "@/lib/logging/context"; import { createCustomLabelResolver } from "@/lib/mood/custom-tags"; import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; @@ -85,6 +89,13 @@ function stripFactorCrosstab( return rest; } +/** + * Public entry — unchanged signature. Prepares the card (cache-read, + * read-only miss, snapshot build, hash gate) then, when the LLM is still + * needed, runs ONE completion through the shared single-card path. The + * one-call-per-metric behaviour and the timeout / no-provider fallbacks are + * byte-for-byte the same as before the prepare split (v1.18.7 HIGH-1). + */ export async function generateMoodStatusForUser( userId: string, options?: { @@ -102,6 +113,33 @@ export async function generateMoodStatusForUser( /** v1.9.0 — last-good text served while a refresh is in flight; keep polling. */ revalidating?: boolean; }> { + const prepared = await prepareMoodStatusForUser(userId, options); + const result = await runPreparedStatusCard(prepared); + return result as { + hasProvider: boolean; + text: string | null; + cached: boolean; + updatedAt: string | null; + preparing?: boolean; + revalidating?: boolean; + }; +} + +/** + * v1.18.7 (HIGH-1) — everything up to (not including) the provider call. + * Returns a finished `served` result for every path that never needed the + * LLM, or a `pending` descriptor the single-card path and the batch path + * both drive. See `status-card-generation.ts` for the contract. + */ +export async function prepareMoodStatusForUser( + userId: string, + options?: { + locale?: string | null; + force?: boolean; + /** v1.8.3 — read-only navigation path; see weight-status for the rationale. */ + readOnly?: boolean; + }, +): Promise { const locale = normalizeLocale(options?.locale); const force = options?.force === true; const readOnly = options?.readOnly === true; @@ -116,10 +154,13 @@ export async function generateMoodStatusForUser( }); if (cached) { return { - hasProvider: true, - text: cached.text, - cached: true, - updatedAt: cached.updatedAt, + phase: "served", + result: { + hasProvider: true, + text: cached.text, + cached: true, + updatedAt: cached.updatedAt, + }, }; } @@ -133,22 +174,28 @@ export async function generateMoodStatusForUser( // bmi-status); no enqueue happens for it. if (outcome.kind === "no-provider" || outcome.kind === "consent-missing") { return { - hasProvider: false, - text: getNoKeyMoodStatusText(locale), - cached: true, - updatedAt: null, + phase: "served", + result: { + hasProvider: false, + text: getNoKeyMoodStatusText(locale), + cached: true, + updatedAt: null, + }, }; } // v1.8.7 — stale-while-revalidate: serve the last good assessment // (if any) instantly while the worker re-warms; only fall to the empty // preparing skeleton when none was ever produced. return { - hasProvider: true, - text: outcome.lastGood?.text ?? null, - cached: outcome.lastGood !== null, - updatedAt: outcome.lastGood?.updatedAt ?? null, - preparing: outcome.lastGood === null, - revalidating: outcome.revalidating, + phase: "served", + result: { + hasProvider: true, + text: outcome.lastGood?.text ?? null, + cached: outcome.lastGood !== null, + updatedAt: outcome.lastGood?.updatedAt ?? null, + preparing: outcome.lastGood === null, + revalidating: outcome.revalidating, + }, }; } @@ -577,10 +624,13 @@ export async function generateMoodStatusForUser( }); if (unchanged) { return { - hasProvider: true, - text: unchanged.text, - cached: true, - updatedAt: unchanged.updatedAt, + phase: "served", + result: { + hasProvider: true, + text: unchanged.text, + cached: true, + updatedAt: unchanged.updatedAt, + }, }; } @@ -615,10 +665,15 @@ export async function generateMoodStatusForUser( locale, ); - const outcome = await runStatusCompletion({ + // The provider call is deferred to the caller — the single-card path runs + // ONE completion, the batch path folds this prompt into one shared call. + // A timeout / error is a transient miss (fallback served, no assessment + // persisted); `none` (no provider / no consent) serves the no-key text. + return { + phase: "pending", + metric: "mood", userId, cacheAction, - consentSurface: "insights", systemPrompt: getMoodSystemPrompt(locale), userPrompt: getMoodUserPrompt( snapshotJson, @@ -627,51 +682,48 @@ export async function generateMoodStatusForUser( previousContextBlock, assessmentContextBlock, ), + snapshotHash, // v1.12.7 — match the archetype cards' 0.45. temperature: 0.45, - maxTokens: 1000, - }); - - if (outcome.kind === "none") { - return { + noProvider: { hasProvider: false, text: getNoKeyMoodStatusText(locale), cached: true, updatedAt: null, - }; - } - if (outcome.kind === "timeout" || outcome.kind === "error") { - return returnTimeoutFallback({ - cacheAction, - reason: outcome.kind, - userId, - todayKey, - stubText: getNoKeyMoodStatusText(locale), - }); - } - - const summary = normalizeSummaryText(parseSummaryFromContent(outcome.content)); - if (!summary) { - throw new Error("Mood-status summary was empty after normalization"); - } - - const updatedAt = await persistStatusInsight({ - userId, - cacheAction, - todayKey, - locale, - text: summary, - providerType: outcome.providerType, - model: outcome.model, - tokensUsed: outcome.tokensUsed, - snapshotHash, - }); - - return { - hasProvider: true, - text: summary, - cached: false, - updatedAt, + }, + timeout: (reason): StatusCardResult => + returnTimeoutFallback({ + cacheAction, + reason, + userId, + todayKey, + stubText: getNoKeyMoodStatusText(locale), + }), + finalize: async (outcome): Promise => { + const summary = normalizeSummaryText( + parseSummaryFromContent(outcome.content), + ); + if (!summary) { + throw new Error("Mood-status summary was empty after normalization"); + } + const updatedAt = await persistStatusInsight({ + userId, + cacheAction, + todayKey, + locale, + text: summary, + providerType: outcome.providerType, + model: outcome.model, + tokensUsed: outcome.tokensUsed, + snapshotHash, + }); + return { + hasProvider: true, + text: summary, + cached: false, + updatedAt, + }; + }, }; } diff --git a/src/lib/insights/pulse-status.ts b/src/lib/insights/pulse-status.ts index 877a7c097..cb24bf74e 100644 --- a/src/lib/insights/pulse-status.ts +++ b/src/lib/insights/pulse-status.ts @@ -34,7 +34,6 @@ import { round, summarizeSeries, } from "@/lib/insights/status-shared"; -import { runStatusCompletion } from "@/lib/insights/status-provider"; import { readFreshStatusText, refreshUnchangedStatusInsight, @@ -43,9 +42,21 @@ import { } from "@/lib/insights/status-cache"; import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash"; import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback"; +import { + runPreparedStatusCard, + type PreparedStatusCard, + type StatusCardResult, +} from "@/lib/insights/status-card-generation"; import { annotate } from "@/lib/logging/context"; import { toBerlinDayKey, userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; +/** + * Public entry — unchanged signature. Prepares the card (cache-read, + * read-only miss, snapshot build, hash gate) then, when the LLM is still + * needed, runs ONE completion through the shared single-card path. The + * one-call-per-metric behaviour and the timeout / no-provider fallbacks are + * byte-for-byte the same as before the prepare split (v1.18.7 HIGH-1). + */ export async function generatePulseStatusForUser( userId: string, options?: { @@ -63,6 +74,33 @@ export async function generatePulseStatusForUser( /** v1.9.0 — last-good text served while a refresh is in flight; keep polling. */ revalidating?: boolean; }> { + const prepared = await preparePulseStatusForUser(userId, options); + const result = await runPreparedStatusCard(prepared); + return result as { + hasProvider: boolean; + text: string | null; + cached: boolean; + updatedAt: string | null; + preparing?: boolean; + revalidating?: boolean; + }; +} + +/** + * v1.18.7 (HIGH-1) — everything up to (not including) the provider call. + * Returns a finished `served` result for every path that never needed the + * LLM, or a `pending` descriptor the single-card path and the batch path + * both drive. See `status-card-generation.ts` for the contract. + */ +export async function preparePulseStatusForUser( + userId: string, + options?: { + locale?: string | null; + force?: boolean; + /** v1.8.3 — read-only navigation path; see weight-status for the rationale. */ + readOnly?: boolean; + }, +): Promise { const locale = normalizeLocale(options?.locale); const force = options?.force === true; const readOnly = options?.readOnly === true; @@ -77,10 +115,13 @@ export async function generatePulseStatusForUser( }); if (cached) { return { - hasProvider: true, - text: cached.text, - cached: true, - updatedAt: cached.updatedAt, + phase: "served", + result: { + hasProvider: true, + text: cached.text, + cached: true, + updatedAt: cached.updatedAt, + }, }; } @@ -94,22 +135,28 @@ export async function generatePulseStatusForUser( // bmi-status); no enqueue happens for it. if (outcome.kind === "no-provider" || outcome.kind === "consent-missing") { return { - hasProvider: false, - text: getNoKeyPulseStatusText(locale), - cached: true, - updatedAt: null, + phase: "served", + result: { + hasProvider: false, + text: getNoKeyPulseStatusText(locale), + cached: true, + updatedAt: null, + }, }; } // v1.8.7 — stale-while-revalidate: serve the last good assessment (if // any) instantly while the worker re-warms the cache; only fall to the // empty preparing skeleton when none was ever produced. return { - hasProvider: true, - text: outcome.lastGood?.text ?? null, - cached: outcome.lastGood !== null, - updatedAt: outcome.lastGood?.updatedAt ?? null, - preparing: outcome.lastGood === null, - revalidating: outcome.revalidating, + phase: "served", + result: { + hasProvider: true, + text: outcome.lastGood?.text ?? null, + cached: outcome.lastGood !== null, + updatedAt: outcome.lastGood?.updatedAt ?? null, + preparing: outcome.lastGood === null, + revalidating: outcome.revalidating, + }, }; } @@ -333,10 +380,13 @@ export async function generatePulseStatusForUser( }); if (unchanged) { return { - hasProvider: true, - text: unchanged.text, - cached: true, - updatedAt: unchanged.updatedAt, + phase: "served", + result: { + hasProvider: true, + text: unchanged.text, + cached: true, + updatedAt: unchanged.updatedAt, + }, }; } @@ -368,10 +418,15 @@ export async function generatePulseStatusForUser( locale, ); - const outcome = await runStatusCompletion({ + // The provider call is deferred to the caller — the single-card path runs + // ONE completion, the batch path folds this prompt into one shared call. + // A timeout / error is a transient miss (fallback served, no assessment + // persisted); `none` (no provider / no consent) serves the no-key text. + return { + phase: "pending", + metric: "pulse", userId, cacheAction, - consentSurface: "insights", systemPrompt: getPulseSystemPrompt(locale), userPrompt: getPulseUserPrompt( snapshotJson, @@ -380,51 +435,48 @@ export async function generatePulseStatusForUser( previousContextBlock, assessmentContextBlock, ), + snapshotHash, // v1.12.7 — match the archetype cards' 0.45. temperature: 0.45, - maxTokens: 1000, - }); - - if (outcome.kind === "none") { - return { + noProvider: { hasProvider: false, text: getNoKeyPulseStatusText(locale), cached: true, updatedAt: null, - }; - } - if (outcome.kind === "timeout" || outcome.kind === "error") { - return returnTimeoutFallback({ - cacheAction, - reason: outcome.kind, - userId, - todayKey, - stubText: getNoKeyPulseStatusText(locale), - }); - } - - const summary = normalizeSummaryText(parseSummaryFromContent(outcome.content)); - if (!summary) { - throw new Error("Pulse-status summary was empty after normalization"); - } - - const updatedAt = await persistStatusInsight({ - userId, - cacheAction, - todayKey, - locale, - text: summary, - providerType: outcome.providerType, - model: outcome.model, - tokensUsed: outcome.tokensUsed, - snapshotHash, - }); - - return { - hasProvider: true, - text: summary, - cached: false, - updatedAt, + }, + timeout: (reason): StatusCardResult => + returnTimeoutFallback({ + cacheAction, + reason, + userId, + todayKey, + stubText: getNoKeyPulseStatusText(locale), + }), + finalize: async (outcome): Promise => { + const summary = normalizeSummaryText( + parseSummaryFromContent(outcome.content), + ); + if (!summary) { + throw new Error("Pulse-status summary was empty after normalization"); + } + const updatedAt = await persistStatusInsight({ + userId, + cacheAction, + todayKey, + locale, + text: summary, + providerType: outcome.providerType, + model: outcome.model, + tokensUsed: outcome.tokensUsed, + snapshotHash, + }); + return { + hasProvider: true, + text: summary, + cached: false, + updatedAt, + }; + }, }; }