From beb0be7452b1fc1f049d1a9821719d4c00ba9e58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 19 Jun 2026 21:31:16 +0200 Subject: [PATCH] refactor(insights): split weight-status at the provider boundary Introduce a prepare -> run -> finalize contract for the per-metric status cards so the snapshot build and the provider call can be driven separately. The public generator keeps its signature and one-call-per-metric behaviour; weight-status now resolves through the shared single-card path. Add the status-batch AI budget entry sized for one combined seven-metric call. --- src/generated | 1 + src/lib/ai/ai-budgets.ts | 12 ++ src/lib/insights/status-card-generation.ts | 150 ++++++++++++++++ src/lib/insights/weight-status.ts | 192 +++++++++++++-------- 4 files changed, 282 insertions(+), 73 deletions(-) create mode 120000 src/generated create mode 100644 src/lib/insights/status-card-generation.ts diff --git a/src/generated b/src/generated new file mode 120000 index 000000000..a5d735124 --- /dev/null +++ b/src/generated @@ -0,0 +1 @@ +/Users/marc/Projects/HealthLog/src/generated \ No newline at end of file diff --git a/src/lib/ai/ai-budgets.ts b/src/lib/ai/ai-budgets.ts index ce6c49434..29be245cb 100644 --- a/src/lib/ai/ai-budgets.ts +++ b/src/lib/ai/ai-budgets.ts @@ -51,6 +51,18 @@ export const AI_BUDGETS = { */ status: { temperature: 0.3, maxTokens: 250 }, + /** + * Batched per-metric status assessment (v1.18.7 HIGH-1) — ONE call that + * returns a `{ perMetric: { bp, weight, pulse, bmi, mood, compliance, + * general } }` envelope, each value a single 30-60-word assessment in the + * same contract as a standalone status card. The seven warm calls a fully + * instrumented account fired per cycle collapse into this one. Sized for + * up to seven ~80-token summaries plus the JSON envelope: 1400 tokens. + * Temperature matches the per-card 0.45 (cadence entropy while the FACTS + * stay pinned by the snapshot + the forbidden-phrase guards). + */ + statusBatch: { temperature: 0.45, maxTokens: 1400 }, + /** * Period narrative (week/month) — 2-4 short plain-text sentences * (period-narrative-generate.ts SYSTEM_PROMPT). 400 tokens. diff --git a/src/lib/insights/status-card-generation.ts b/src/lib/insights/status-card-generation.ts new file mode 100644 index 000000000..e2e6e3d93 --- /dev/null +++ b/src/lib/insights/status-card-generation.ts @@ -0,0 +1,150 @@ +/** + * v1.18.7 (HIGH-1) — the shared "prepare → run → finalize" contract for the + * seven specialised per-metric status cards. + * + * Before this, every `*-status.ts` generator was a single monolithic + * function: cache-read → read-only miss → snapshot build → content-hash + * gate → ONE `runStatusCompletion` → persist. A fully instrumented account + * fired seven of those LLM round-trips per warm cycle, each re-analysing the + * same measurement rows the comprehensive briefing already covered. + * + * The fix splits each generator at the LLM boundary: + * + * prepareStatusForUser(...) → PreparedStatusCard + * runs everything up to (but not including) the provider call. Either it + * resolves to a finished `served` result (a cache hit, a read-only miss, + * an unchanged-data refresh, or no provider / no consent — every shape + * that never needed the LLM), or it returns a `pending` descriptor: the + * built prompts + snapshot hash + a `finalize(summary)` closure that + * persists exactly the cache row the old code wrote. + * + * generateStatusForUser(...) (the unchanged public entry) + * calls prepare, and on `pending` runs ONE `runStatusCompletion` and + * finalizes — i.e. the single-card path is byte-for-byte the old + * behaviour, so a lone card visit still costs exactly one call and the + * graceful fallback is preserved. + * + * generateStatusBatchForUser(...) (status-batch.ts) + * calls all seven prepares, collects the `pending` ones, sends ONE + * prompt for `{ perMetric: { ... } }`, and fans each returned summary + * into that metric's `finalize` — so the warm passes pay one call for + * the whole set. A metric the batch omits or that the batch call fails + * for falls back to its own single-card path, never crashing the cycle. + * + * The cache + read contract is untouched: `finalize` persists the SAME + * `auditLog` row (`persistStatusInsight`, or the richer medication-compliance + * shape via a custom closure) the standalone generator wrote, so every card + * keeps reading per-metric results with the same keys. + */ +import { + runStatusCompletion, + type StatusProviderResult, +} from "@/lib/insights/status-provider"; +import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; + +/** + * The public return shape every standard `*-status.ts` generator shares. + * (Medication-compliance widens this with `summary` + `medications`; it + * supplies its own result objects through the same `finalize`/`served` + * channel, so the generic batch loop treats it uniformly.) + */ +export interface StatusCardResult { + hasProvider: boolean; + text?: string | null; + cached: boolean; + updatedAt: string | null; + preparing?: boolean; + revalidating?: boolean; + // Medication-compliance carries extra fields; kept open so its richer + // result flows through the shared union without a separate type. + [extra: string]: unknown; +} + +/** + * A status generation that still needs the provider. The `finalize` / + * `timeout` closures own the persist + the public result shape, so the batch + * loop never has to know a metric's snapshot internals. + */ +export interface PendingStatusCard { + phase: "pending"; + /** The InsightStatus scope (`blood-pressure`, `weight`, …). */ + metric: string; + userId: string; + /** `insights.-status.` — the consent gate / run key. */ + cacheAction: string; + systemPrompt: string; + userPrompt: string; + /** Snapshot fingerprint — persisted so the next run's hash gate can skip. */ + snapshotHash: string; + /** Per-card sampling temperature (the cards run a touch warmer at 0.45). */ + temperature: number; + /** + * The deterministic no-key / no-consent result. The provider call returns + * `{ kind: "none" }` for both, and the prepare step already knows the + * metric's no-key fallback text, so it precomputes the result rather than + * re-deriving it here. + */ + noProvider: StatusCardResult; + /** + * Build the success result + persist the cache row from the model's raw + * completion content (the `{ "summary": … }` envelope). Returns the + * generator's public result. + */ + finalize: (outcome: { + content: string; + providerType: string; + model: string; + tokensUsed: number | null; + }) => Promise; + /** + * Build the timeout / error fallback result (serves the no-key text for + * this render, writes the short-TTL negative stub, persists no assessment). + */ + timeout: (reason: "timeout" | "error") => StatusCardResult; +} + +/** A status generation that resolved without ever needing the provider. */ +export interface ServedStatusCard { + phase: "served"; + result: StatusCardResult; +} + +export type PreparedStatusCard = ServedStatusCard | PendingStatusCard; + +/** + * Single-card path: drive a prepared card through its own provider call. + * This is the exact tail every `*-status.ts` generator used to inline, so + * the standalone generators keep their one-call-per-metric behaviour and + * their existing tests stay green. + */ +export async function runPreparedStatusCard( + prepared: PreparedStatusCard, +): Promise { + if (prepared.phase === "served") return prepared.result; + + const outcome: StatusProviderResult = await runStatusCompletion({ + userId: prepared.userId, + cacheAction: prepared.cacheAction, + consentSurface: "insights", + systemPrompt: prepared.systemPrompt, + userPrompt: prepared.userPrompt, + temperature: prepared.temperature, + maxTokens: AI_BUDGETS.status.maxTokens, + }); + + if (outcome.kind === "none") { + // No provider / no consent — serve the precomputed no-key result. NOT + // `timeout` (that would write a negative stub for a provider that simply + // isn't configured). + return prepared.noProvider; + } + if (outcome.kind === "timeout" || outcome.kind === "error") { + return prepared.timeout(outcome.kind); + } + return prepared.finalize({ + content: outcome.content, + providerType: outcome.providerType, + model: outcome.model, + tokensUsed: outcome.tokensUsed, + }); +} diff --git a/src/lib/insights/weight-status.ts b/src/lib/insights/weight-status.ts index 1d06e5d41..8c4e7602f 100644 --- a/src/lib/insights/weight-status.ts +++ b/src/lib/insights/weight-status.ts @@ -37,7 +37,6 @@ import { round, summarizeSeries, } from "@/lib/insights/status-shared"; -import { runStatusCompletion } from "@/lib/insights/status-provider"; import { readFreshStatusText, refreshUnchangedStatusInsight, @@ -46,6 +45,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"; @@ -88,7 +92,47 @@ function pairDailyBuckets( ); } +/** + * 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 generateWeightStatusForUser( + userId: string, + options?: { + locale?: string | null; + force?: boolean; + readOnly?: boolean; + }, +): Promise<{ + hasProvider: boolean; + text: string | null; + cached: boolean; + updatedAt: string | null; + preparing?: boolean; + revalidating?: boolean; +}> { + const prepared = await prepareWeightStatusForUser(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 prepareWeightStatusForUser( userId: string, options?: { locale?: string | null; @@ -102,15 +146,7 @@ export async function generateWeightStatusForUser( */ readOnly?: boolean; }, -): Promise<{ - hasProvider: boolean; - text: string | null; - 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; @@ -128,10 +164,13 @@ export async function generateWeightStatusForUser( }); 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, + }, }; } @@ -147,22 +186,28 @@ export async function generateWeightStatusForUser( // bmi-status); no enqueue happens for it. if (outcome.kind === "no-provider" || outcome.kind === "consent-missing") { return { - hasProvider: false, - text: getNoKeyWeightStatusText(locale), - cached: true, - updatedAt: null, + phase: "served", + result: { + hasProvider: false, + text: getNoKeyWeightStatusText(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, + }, }; } @@ -441,10 +486,13 @@ export async function generateWeightStatusForUser( }); 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, + }, }; } @@ -476,14 +524,15 @@ export async function generateWeightStatusForUser( locale, ); - // Run the provider chain bounded by the aligned 60 s budget. A - // timeout / error / empty content is a transient miss — serve the - // fallback for this render without persisting it, so the next mount - // re-attempts a real generation rather than sticking the fallback. - 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: "weight", userId, cacheAction, - consentSurface: "insights", systemPrompt: getWeightSystemPrompt(locale), userPrompt: getWeightUserPrompt( snapshotJson, @@ -492,51 +541,48 @@ export async function generateWeightStatusForUser( 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: getNoKeyWeightStatusText(locale), cached: true, updatedAt: null, - }; - } - if (outcome.kind === "timeout" || outcome.kind === "error") { - return returnTimeoutFallback({ - cacheAction, - reason: outcome.kind, - userId, - todayKey, - stubText: getNoKeyWeightStatusText(locale), - }); - } - - const summary = normalizeSummaryText(parseSummaryFromContent(outcome.content)); - if (!summary) { - throw new Error("Weight-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: getNoKeyWeightStatusText(locale), + }), + finalize: async (outcome): Promise => { + const summary = normalizeSummaryText( + parseSummaryFromContent(outcome.content), + ); + if (!summary) { + throw new Error("Weight-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, + }; + }, }; }