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/ai/prompts/status-batch.ts b/src/lib/ai/prompts/status-batch.ts new file mode 100644 index 000000000..21e3d65d5 --- /dev/null +++ b/src/lib/ai/prompts/status-batch.ts @@ -0,0 +1,89 @@ +/** + * v1.18.7 (HIGH-1) — the prompt scaffold for the BATCHED per-metric status + * assessment. + * + * Instead of seven independent `runStatusCompletion` calls — one per + * specialised metric card, each re-analysing measurement rows the + * comprehensive briefing already covered — the warm passes build the seven + * per-metric snapshots once and send ONE prompt asking the model for a + * `{ perMetric: { : "" } }` envelope. + * + * The grounding / tone / safety contract is IDENTICAL to a standalone status + * card: the system prompt is the shared `getBaseSystemPrompt` assessment + * scaffold (which already composes the shared-contracts fragments) plus a + * thin batch wrapper that (a) names the per-metric output keys and (b) + * restates that each metric is assessed strictly from ITS OWN snapshot block. + * The user prompt concatenates each pending card's existing per-metric user + * prompt under a labelled `### ` section, so every metric carries the + * very snapshot + per-metric suffix it would have sent on its own. + */ +import type { Locale } from "@/lib/i18n/config"; +import { getBaseSystemPrompt } from "./base-system"; + +/** + * The stable per-metric output keys the batch envelope uses. The InsightStatus + * scope (`blood-pressure`, …) maps to a short key (`bp`, …) the model echoes + * back. The map is the single source of truth for both the prompt and the + * response parser. + */ +export const STATUS_BATCH_KEY_BY_METRIC: Record = { + "blood-pressure": "bp", + weight: "weight", + pulse: "pulse", + bmi: "bmi", + mood: "mood", + "medication-compliance": "compliance", + general: "general", +}; + +/** Reverse of `STATUS_BATCH_KEY_BY_METRIC` — output key → InsightStatus scope. */ +export const STATUS_BATCH_METRIC_BY_KEY: Record = + Object.fromEntries( + Object.entries(STATUS_BATCH_KEY_BY_METRIC).map(([metric, key]) => [ + key, + metric, + ]), + ); + +/** + * Compose the batch system prompt: the shared assessment scaffold plus the + * `{ perMetric }` output instruction. `presentKeys` are the output keys for + * the metrics the user actually has data for — absent metrics are NOT listed, + * so the model is told exactly which keys to produce and never invents one. + */ +export function getStatusBatchSystemPrompt( + locale: Locale, + presentKeys: readonly string[], +): string { + const base = getBaseSystemPrompt(locale); + const keyList = presentKeys.join(", "); + const en = `BATCHED OUTPUT — you are assessing SEVERAL of this user's metrics in one pass. Each metric below carries its OWN graded snapshot under a "### " heading; assess each one STRICTLY from its own snapshot block, applying every rule above per metric. Do NOT let one metric's finding leak into another. Do NOT invent a metric: assess ONLY the keys present below. + +OUTPUT FORMAT: Reply with valid JSON only, in exactly this schema. "perMetric" carries one short assessment (2-4 sentences each, the same contract as a single card) per present key: +{ "perMetric": { ${presentKeys + .map((k) => `"${k}": "..."`) + .join(", ")} } } +Include a key ONLY if its snapshot block is present below (keys present: ${keyList}). Omit any metric whose block is absent — never fabricate one.`; + const de = `GEBÜNDELTE AUSGABE — du bewertest in einem Durchgang MEHRERE Metriken dieses Nutzers. Jede Metrik unten trägt ihren EIGENEN graded snapshot unter einer Überschrift "### "; bewerte jede ausschließlich aus ihrem eigenen Snapshot-Block und wende alle obigen Regeln pro Metrik an. Lass den Befund einer Metrik NICHT in eine andere überlaufen. Erfinde KEINE Metrik: bewerte NUR die unten vorhandenen Keys. + +AUSGABEFORMAT: Antworte ausschließlich mit validem JSON in genau diesem Schema. "perMetric" enthält je vorhandenem Key eine kurze Einschätzung (je 2-4 Sätze, derselbe Vertrag wie eine Einzelkarte): +{ "perMetric": { ${presentKeys + .map((k) => `"${k}": "..."`) + .join(", ")} } } +Nimm einen Key NUR auf, wenn sein Snapshot-Block unten vorhanden ist (vorhandene Keys: ${keyList}). Lass jede Metrik ohne Block weg — erfinde niemals eine.`; + return `${base}\n\n${locale === "en" ? en : de}`; +} + +/** + * Compose the batch user prompt by concatenating each pending card's own + * per-metric user prompt under a `### ` heading. Each section already + * carries that metric's snapshot JSON + its per-metric instruction suffix, so + * the batched prompt is grounded exactly like the individual calls were. + */ +export function buildStatusBatchUserPrompt( + sections: ReadonlyArray<{ key: string; userPrompt: string }>, +): string { + return sections + .map((section) => `### ${section.key}\n\n${section.userPrompt}`) + .join("\n\n---\n\n"); +} diff --git a/src/lib/insights/__tests__/status-batch.test.ts b/src/lib/insights/__tests__/status-batch.test.ts new file mode 100644 index 000000000..e64b86127 --- /dev/null +++ b/src/lib/insights/__tests__/status-batch.test.ts @@ -0,0 +1,366 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +// Mock the seven prepare modules. Each test sets a prepare's return to a +// `served` or `pending` card. The pending card's `finalize` / `timeout` are +// vi.fn so we can assert the fan-out hit the right closure. +vi.mock("@/lib/insights/blood-pressure-status", () => ({ + prepareBloodPressureStatusForUser: vi.fn(), +})); +vi.mock("@/lib/insights/weight-status", () => ({ + prepareWeightStatusForUser: vi.fn(), +})); +vi.mock("@/lib/insights/pulse-status", () => ({ + preparePulseStatusForUser: vi.fn(), +})); +vi.mock("@/lib/insights/bmi-status", () => ({ + prepareBmiStatusForUser: vi.fn(), +})); +vi.mock("@/lib/insights/mood-status", () => ({ + prepareMoodStatusForUser: vi.fn(), +})); +vi.mock("@/lib/insights/medication-compliance-status", () => ({ + prepareMedicationComplianceStatusForUser: vi.fn(), +})); +vi.mock("@/lib/insights/general-status", () => ({ + prepareGeneralStatusForUser: vi.fn(), +})); + +vi.mock("@/lib/insights/status-provider", () => ({ + runStatusCompletion: vi.fn(), +})); + +// The single-card fallback path — assert it is invoked for omitted / failed +// metrics, but don't re-drive a real completion. +vi.mock("@/lib/insights/status-card-generation", async (importOriginal) => { + const actual = + await importOriginal< + typeof import("@/lib/insights/status-card-generation") + >(); + return { ...actual, runPreparedStatusCard: vi.fn(async () => ({})) }; +}); + +import { prepareBloodPressureStatusForUser } from "@/lib/insights/blood-pressure-status"; +import { prepareWeightStatusForUser } from "@/lib/insights/weight-status"; +import { preparePulseStatusForUser } from "@/lib/insights/pulse-status"; +import { prepareBmiStatusForUser } from "@/lib/insights/bmi-status"; +import { prepareMoodStatusForUser } from "@/lib/insights/mood-status"; +import { prepareMedicationComplianceStatusForUser } from "@/lib/insights/medication-compliance-status"; +import { prepareGeneralStatusForUser } from "@/lib/insights/general-status"; +import { runStatusCompletion } from "@/lib/insights/status-provider"; +import { runPreparedStatusCard } from "@/lib/insights/status-card-generation"; +import { generateStatusBatchForUser } from "../status-batch"; + +function servedCard() { + return { + phase: "served" as const, + result: { hasProvider: true, text: "cached", cached: true, updatedAt: "t" }, + }; +} + +function pendingCard( + metric: string, + finalize: (arg: { content: string }) => Promise = vi.fn( + async (): Promise => ({}), + ), +) { + const timeout = vi.fn(() => ({ + hasProvider: true, + text: "stub", + cached: true, + updatedAt: null, + })); + return { + phase: "pending" as const, + metric, + userId: "u1", + cacheAction: `insights.${metric}-status.en`, + systemPrompt: "sys", + userPrompt: `snapshot for ${metric}`, + snapshotHash: "h", + temperature: 0.45, + noProvider: { hasProvider: false, text: "nokey", cached: true, updatedAt: null }, + timeout, + finalize, + }; +} + +const ALL_PREPARES = [ + prepareBloodPressureStatusForUser, + prepareWeightStatusForUser, + preparePulseStatusForUser, + prepareBmiStatusForUser, + prepareMoodStatusForUser, + prepareMedicationComplianceStatusForUser, + prepareGeneralStatusForUser, +]; + +beforeEach(() => { + vi.resetAllMocks(); +}); + +describe("generateStatusBatchForUser — one call covers all present metrics", () => { + it("issues exactly ONE provider call for the seven pending cards and fans each summary into its finalize", async () => { + const mkFinalize = () => + vi.fn((arg: { content: string }): Promise => { + void arg; + return Promise.resolve({}); + }); + const finalizers = { + "blood-pressure": mkFinalize(), + weight: mkFinalize(), + pulse: mkFinalize(), + bmi: mkFinalize(), + mood: mkFinalize(), + "medication-compliance": mkFinalize(), + general: mkFinalize(), + } as const; + vi.mocked(prepareBloodPressureStatusForUser).mockResolvedValue( + pendingCard("blood-pressure", finalizers["blood-pressure"]) as never, + ); + vi.mocked(prepareWeightStatusForUser).mockResolvedValue( + pendingCard("weight", finalizers.weight) as never, + ); + vi.mocked(preparePulseStatusForUser).mockResolvedValue( + pendingCard("pulse", finalizers.pulse) as never, + ); + vi.mocked(prepareBmiStatusForUser).mockResolvedValue( + pendingCard("bmi", finalizers.bmi) as never, + ); + vi.mocked(prepareMoodStatusForUser).mockResolvedValue( + pendingCard("mood", finalizers.mood) as never, + ); + vi.mocked(prepareMedicationComplianceStatusForUser).mockResolvedValue( + pendingCard( + "medication-compliance", + finalizers["medication-compliance"], + ) as never, + ); + vi.mocked(prepareGeneralStatusForUser).mockResolvedValue( + pendingCard("general", finalizers.general) as never, + ); + + vi.mocked(runStatusCompletion).mockResolvedValue({ + kind: "ok", + content: JSON.stringify({ + perMetric: { + bp: "BP assessment.", + weight: "Weight assessment.", + pulse: "Pulse assessment.", + bmi: "BMI assessment.", + mood: "Mood assessment.", + compliance: "Adherence assessment.", + general: "Overview assessment.", + }, + }), + providerType: "anthropic", + model: "x", + tokensUsed: 100, + } as never); + + const result = await generateStatusBatchForUser("u1", { locale: "en" }); + + // ONE provider round-trip for all seven metrics. + expect(runStatusCompletion).toHaveBeenCalledTimes(1); + expect(result.batched).toBe(7); + expect(result.fellBack).toBe(0); + // Each metric's finalize got its own summary, wrapped in the { summary } + // envelope each card's finalize parses. + for (const f of Object.values(finalizers)) { + expect(f).toHaveBeenCalledTimes(1); + } + const bpArg = finalizers["blood-pressure"].mock.calls[0][0]; + expect(JSON.parse(bpArg.content)).toEqual({ summary: "BP assessment." }); + // No card fell back to a single-card call. + expect(runPreparedStatusCard).not.toHaveBeenCalled(); + }); +}); + +describe("generateStatusBatchForUser — absent metrics are omitted, not fabricated", () => { + it("only prompts for the metrics with data and never finalizes an absent metric", async () => { + // Five metrics resolve `served` (cache hit / unchanged / no data); only + // weight + pulse are pending. + vi.mocked(prepareBloodPressureStatusForUser).mockResolvedValue( + servedCard() as never, + ); + const weightFinalize = vi.fn(async () => ({})); + const pulseFinalize = vi.fn(async () => ({})); + vi.mocked(prepareWeightStatusForUser).mockResolvedValue( + pendingCard("weight", weightFinalize) as never, + ); + vi.mocked(preparePulseStatusForUser).mockResolvedValue( + pendingCard("pulse", pulseFinalize) as never, + ); + vi.mocked(prepareBmiStatusForUser).mockResolvedValue(servedCard() as never); + vi.mocked(prepareMoodStatusForUser).mockResolvedValue(servedCard() as never); + vi.mocked(prepareMedicationComplianceStatusForUser).mockResolvedValue( + servedCard() as never, + ); + vi.mocked(prepareGeneralStatusForUser).mockResolvedValue( + servedCard() as never, + ); + + let capturedSystem = ""; + vi.mocked(runStatusCompletion).mockImplementation( + async (args: { systemPrompt: string }) => { + capturedSystem = args.systemPrompt; + return { + kind: "ok", + content: JSON.stringify({ + perMetric: { weight: "W.", pulse: "P." }, + }), + providerType: "anthropic", + model: "x", + tokensUsed: 10, + } as never; + }, + ); + + const result = await generateStatusBatchForUser("u1", { locale: "en" }); + + expect(result.served).toBe(5); + expect(result.batched).toBe(2); + // The prompt names ONLY the present keys (in prepare order: pulse before + // weight, since the batch runs [bp, pulse, weight, …]). + expect(capturedSystem).toContain("keys present: pulse, weight"); + expect(capturedSystem).not.toContain('"bp"'); + expect(weightFinalize).toHaveBeenCalledTimes(1); + expect(pulseFinalize).toHaveBeenCalledTimes(1); + }); +}); + +describe("generateStatusBatchForUser — hash gate short-circuits unchanged data", () => { + it("makes NO provider call when every card resolved served (cache / unchanged)", async () => { + for (const prepare of ALL_PREPARES) { + vi.mocked(prepare).mockResolvedValue(servedCard() as never); + } + + const result = await generateStatusBatchForUser("u1", { locale: "en" }); + + expect(runStatusCompletion).not.toHaveBeenCalled(); + expect(result.served).toBe(7); + expect(result.batched).toBe(0); + expect(result.batchCallMade).toBe(false); + }); +}); + +describe("generateStatusBatchForUser — graceful partial degradation", () => { + it("falls a metric the batch omitted back to its single-card path", async () => { + const weightFinalize = vi.fn(async () => ({})); + const pulseFinalize = vi.fn(async () => ({})); + vi.mocked(prepareWeightStatusForUser).mockResolvedValue( + pendingCard("weight", weightFinalize) as never, + ); + vi.mocked(preparePulseStatusForUser).mockResolvedValue( + pendingCard("pulse", pulseFinalize) as never, + ); + for (const prepare of [ + prepareBloodPressureStatusForUser, + prepareBmiStatusForUser, + prepareMoodStatusForUser, + prepareMedicationComplianceStatusForUser, + prepareGeneralStatusForUser, + ]) { + vi.mocked(prepare).mockResolvedValue(servedCard() as never); + } + + // The batch returns weight but OMITS pulse. + vi.mocked(runStatusCompletion).mockResolvedValue({ + kind: "ok", + content: JSON.stringify({ perMetric: { weight: "W." } }), + providerType: "anthropic", + model: "x", + tokensUsed: 10, + } as never); + + const result = await generateStatusBatchForUser("u1", { locale: "en" }); + + expect(weightFinalize).toHaveBeenCalledTimes(1); + // pulse was omitted → fell back to the single-card path, NOT finalized + // from the batch. + expect(pulseFinalize).not.toHaveBeenCalled(); + expect(runPreparedStatusCard).toHaveBeenCalledTimes(1); + expect(result.batched).toBe(1); + expect(result.fellBack).toBe(1); + }); + + it("degrades every pending metric to its single-card path on a batch provider error", async () => { + const finalizers = ALL_PREPARES.map(() => vi.fn(async () => ({}))); + ALL_PREPARES.forEach((prepare, i) => { + vi.mocked(prepare).mockResolvedValue( + pendingCard(`m${i}`, finalizers[i]) as never, + ); + }); + vi.mocked(runStatusCompletion).mockResolvedValue({ + kind: "error", + } as never); + + const result = await generateStatusBatchForUser("u1", { locale: "en" }); + + // No finalize from the batch; every metric fell back. + for (const f of finalizers) expect(f).not.toHaveBeenCalled(); + expect(runPreparedStatusCard).toHaveBeenCalledTimes(7); + expect(result.fellBack).toBe(7); + expect(result.batched).toBe(0); + }); + + it("retries once on invalid JSON, then succeeds", async () => { + const wf = vi.fn(async () => ({})); + const pf = vi.fn(async () => ({})); + vi.mocked(prepareWeightStatusForUser).mockResolvedValue( + pendingCard("weight", wf) as never, + ); + vi.mocked(preparePulseStatusForUser).mockResolvedValue( + pendingCard("pulse", pf) as never, + ); + for (const prepare of [ + prepareBloodPressureStatusForUser, + prepareBmiStatusForUser, + prepareMoodStatusForUser, + prepareMedicationComplianceStatusForUser, + prepareGeneralStatusForUser, + ]) { + vi.mocked(prepare).mockResolvedValue(servedCard() as never); + } + + vi.mocked(runStatusCompletion) + .mockResolvedValueOnce({ + kind: "ok", + content: "not json at all", + providerType: "anthropic", + model: "x", + tokensUsed: 1, + } as never) + .mockResolvedValueOnce({ + kind: "ok", + content: JSON.stringify({ perMetric: { weight: "W.", pulse: "P." } }), + providerType: "anthropic", + model: "x", + tokensUsed: 1, + } as never); + + const result = await generateStatusBatchForUser("u1", { locale: "en" }); + + expect(runStatusCompletion).toHaveBeenCalledTimes(2); + expect(wf).toHaveBeenCalledTimes(1); + expect(pf).toHaveBeenCalledTimes(1); + expect(result.batched).toBe(2); + }); + + it("serves the no-key result for every pending card when no provider is configured", async () => { + const finalizers = ALL_PREPARES.map(() => vi.fn(async () => ({}))); + ALL_PREPARES.forEach((prepare, i) => { + vi.mocked(prepare).mockResolvedValue( + pendingCard(`m${i}`, finalizers[i]) as never, + ); + }); + vi.mocked(runStatusCompletion).mockResolvedValue({ kind: "none" } as never); + + const result = await generateStatusBatchForUser("u1", { locale: "en" }); + + // No finalize, no re-issued completion (the no-key result is precomputed). + for (const f of finalizers) expect(f).not.toHaveBeenCalled(); + expect(runPreparedStatusCard).not.toHaveBeenCalled(); + expect(result.fellBack).toBe(7); + expect(result.batchCallMade).toBe(false); + }); +}); 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, + }; + }, }; } diff --git a/src/lib/insights/status-batch.ts b/src/lib/insights/status-batch.ts new file mode 100644 index 000000000..1fa5c8b8f --- /dev/null +++ b/src/lib/insights/status-batch.ts @@ -0,0 +1,280 @@ +/** + * v1.18.7 (HIGH-1) — batched per-metric status generation. + * + * A warm cycle on a fully instrumented account used to fire SEVEN + * specialised `runStatusCompletion` calls — blood-pressure / weight / pulse / + * bmi / mood / medication-compliance / general — each rebuilding its own + * graded snapshot and re-analysing measurement rows the comprehensive + * briefing already covered. This generator collapses those seven warm calls + * into ONE: + * + * 1. Run each card's `prepareStatusForUser` (snapshot build + cache + * read + read-only miss + content-hash gate — NO provider call). Cards + * that resolve without the LLM (`served`) are returned as-is; the hash + * gate still short-circuits unchanged data per metric, so a quiet cycle + * makes no call at all. + * 2. Collect the `pending` cards, compose ONE prompt for + * `{ perMetric: { ... } }` (only the metrics with data — absent metrics + * are omitted, never fabricated), and run ONE completion with one + * JSON-correction retry. + * 3. Fan each returned summary back into that metric's `finalize`, which + * persists the SAME per-metric `auditLog` cache row the standalone + * generator wrote — so the card read path is unchanged. + * + * Graceful degradation (audit requirement): any metric the batch omits, or + * every metric when the batch call fails outright, falls back to its own + * single-card path (`runPreparedStatusCard`). The cycle never crashes on a + * partial or failed batch. + */ +import { + prepareBloodPressureStatusForUser, +} from "@/lib/insights/blood-pressure-status"; +import { prepareWeightStatusForUser } from "@/lib/insights/weight-status"; +import { preparePulseStatusForUser } from "@/lib/insights/pulse-status"; +import { prepareBmiStatusForUser } from "@/lib/insights/bmi-status"; +import { prepareMoodStatusForUser } from "@/lib/insights/mood-status"; +import { prepareMedicationComplianceStatusForUser } from "@/lib/insights/medication-compliance-status"; +import { prepareGeneralStatusForUser } from "@/lib/insights/general-status"; +import { + runPreparedStatusCard, + type PendingStatusCard, + type PreparedStatusCard, +} from "@/lib/insights/status-card-generation"; +import { runStatusCompletion } from "@/lib/insights/status-provider"; +import { stripJsonFences } from "@/lib/insights/status-shared"; +import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; +import { + buildStatusBatchUserPrompt, + getStatusBatchSystemPrompt, + STATUS_BATCH_KEY_BY_METRIC, +} from "@/lib/ai/prompts/status-batch"; +import type { Locale } from "@/lib/i18n/config"; +import { annotate } from "@/lib/logging/context"; + +/** The seven specialised prepares, in the warm order the cron used. */ +const PREPARES: ReadonlyArray< + ( + userId: string, + options: { locale: "de" | "en"; force?: boolean }, + ) => Promise +> = [ + prepareBloodPressureStatusForUser, + preparePulseStatusForUser, + prepareWeightStatusForUser, + prepareBmiStatusForUser, + prepareMoodStatusForUser, + prepareMedicationComplianceStatusForUser, + prepareGeneralStatusForUser, +]; + +export interface StatusBatchResult { + /** Cards resolved without the LLM (cache hit / unchanged / no provider). */ + served: number; + /** Metrics whose assessment came from the single batched provider call. */ + batched: number; + /** Metrics that fell back to a single-card call (omitted by the batch, or + * the batch failed). */ + fellBack: number; + /** True when the one batched provider call was issued. */ + batchCallMade: boolean; +} + +/** Pull `perMetric` out of a (possibly fenced) model completion. */ +function parsePerMetric(content: string): Record | null { + const tryParse = (raw: string): Record | null => { + try { + const parsed = JSON.parse(raw) as { perMetric?: unknown }; + if (parsed && typeof parsed.perMetric === "object" && parsed.perMetric) { + const out: Record = {}; + for (const [key, value] of Object.entries( + parsed.perMetric as Record, + )) { + if (typeof value === "string" && value.trim().length > 0) { + out[key] = value; + } + } + return out; + } + } catch { + // not parseable as-is + } + return null; + }; + return tryParse(content) ?? tryParse(stripJsonFences(content)); +} + +/** + * Generate the seven specialised status assessments for one user with a + * SINGLE batched provider call. The public per-card generators stay the + * single-card entry; this is what the warm passes (nightly cron + forced + * warm) call so the seven warm calls collapse into one. + */ +export async function generateStatusBatchForUser( + userId: string, + options: { locale: "de" | "en"; force?: boolean }, +): Promise { + const result: StatusBatchResult = { + served: 0, + batched: 0, + fellBack: 0, + batchCallMade: false, + }; + + // Build every card's snapshot once. A prepare that throws must not abort + // the whole batch — that one metric is simply skipped this cycle. + const prepared = await Promise.all( + PREPARES.map(async (prepare) => { + try { + return await prepare(userId, { + locale: options.locale, + force: options.force, + }); + } catch { + return null; + } + }), + ); + + const pending: PendingStatusCard[] = []; + for (const card of prepared) { + if (card === null) continue; + if (card.phase === "served") { + result.served++; + } else { + pending.push(card); + } + } + + if (pending.length === 0) { + annotate({ + action: { name: "insights.status.batch" }, + meta: { served: result.served, batched: 0, fell_back: 0 }, + }); + return result; + } + + // One metric still needing the LLM is not worth a batch envelope — run it + // through its own single-card path (identical persisted result). + if (pending.length === 1) { + await runPreparedStatusCard(pending[0]); + result.fellBack++; + annotate({ + action: { name: "insights.status.batch" }, + meta: { served: result.served, batched: 0, fell_back: 1, single: true }, + }); + return result; + } + + const sections = pending.map((card) => ({ + key: STATUS_BATCH_KEY_BY_METRIC[card.metric] ?? card.metric, + userPrompt: card.userPrompt, + })); + const presentKeys = sections.map((s) => s.key); + const systemPrompt = getStatusBatchSystemPrompt( + options.locale as Locale, + presentKeys, + ); + const userPrompt = buildStatusBatchUserPrompt(sections); + + // ONE provider call for every pending metric. The cards run a touch warmer + // at 0.45 individually; the batch budget pins the same temperature and a + // token ceiling sized for up to seven short summaries. + let outcome = await runStatusCompletion({ + userId, + cacheAction: "insights.status-batch", + consentSurface: "insights", + systemPrompt, + userPrompt, + temperature: AI_BUDGETS.statusBatch.temperature, + maxTokens: AI_BUDGETS.statusBatch.maxTokens, + }); + + // No provider / no consent — every pending card serves its own no-key + // result. No fallback call is needed (the prepared `noProvider` result is + // already computed); count them as fallbacks since none was batched. + if (outcome.kind === "none") { + // Every pending card would also resolve to its no-key result on its own + // path; count them as fallbacks without re-issuing a doomed completion. + result.fellBack += pending.length; + annotate({ + action: { name: "insights.status.batch" }, + meta: { + served: result.served, + batched: 0, + fell_back: pending.length, + reason: "no-provider", + }, + }); + return result; + } + + result.batchCallMade = true; + let perMetric: Record | null = null; + if (outcome.kind === "ok") { + perMetric = parsePerMetric(outcome.content); + // One JSON-correction retry before declaring the batch a miss — the + // same robustness the comprehensive path now carries. + if (perMetric === null) { + annotate({ action: { name: "insights.status.batch.json_retry" } }); + const retry = await runStatusCompletion({ + userId, + cacheAction: "insights.status-batch", + consentSurface: "insights", + systemPrompt, + userPrompt: `${userPrompt}\n\nYour previous response was not valid JSON matching { "perMetric": { ... } }. Reply with that JSON object ONLY — no prose, no markdown fences.`, + temperature: AI_BUDGETS.statusBatch.temperature, + maxTokens: AI_BUDGETS.statusBatch.maxTokens, + }); + if (retry.kind === "ok") { + outcome = retry; + perMetric = parsePerMetric(retry.content); + } + } + } + + // Fan the batched response back into each metric's persist closure. A + // metric the batch covered with a real summary is finalized through the + // SAME cache row the standalone generator wrote; a metric the batch omitted + // (or a total batch failure) falls back to its single-card path so it still + // gets a real assessment rather than going dark. + const ok = outcome.kind === "ok"; + const fanOuts = pending.map(async (card) => { + const key = STATUS_BATCH_KEY_BY_METRIC[card.metric] ?? card.metric; + const summary = ok && perMetric ? perMetric[key] : undefined; + if (ok && summary) { + // Wrap the per-metric string into the `{ "summary": ... }` envelope + // each card's `finalize` parses (it runs `normalizeSummaryText` + + // chart-token scrub + persists the SAME cache row the standalone + // generator wrote). + await card.finalize({ + content: JSON.stringify({ summary }), + providerType: outcome.kind === "ok" ? outcome.providerType : "unknown", + model: outcome.kind === "ok" ? outcome.model : "unknown", + tokensUsed: outcome.kind === "ok" ? outcome.tokensUsed : null, + }); + return "batched" as const; + } + // Batch missed this metric (omitted key, or timeout/error/invalid-json): + // fall back to the single-card path. On a batch timeout/error this writes + // the per-card negative stub via the card's own `timeout`. + await runPreparedStatusCard(card); + return "fellBack" as const; + }); + + const outcomes = await Promise.all(fanOuts); + for (const o of outcomes) { + if (o === "batched") result.batched++; + else result.fellBack++; + } + + annotate({ + action: { name: "insights.status.batch" }, + meta: { + served: result.served, + batched: result.batched, + fell_back: result.fellBack, + pending: pending.length, + }, + }); + return result; +} 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, + }; + }, }; } diff --git a/src/lib/jobs/__tests__/insight-pregenerate.test.ts b/src/lib/jobs/__tests__/insight-pregenerate.test.ts index 6c0f8b268..093a6e2c8 100644 --- a/src/lib/jobs/__tests__/insight-pregenerate.test.ts +++ b/src/lib/jobs/__tests__/insight-pregenerate.test.ts @@ -54,6 +54,18 @@ vi.mock("@/lib/insights/medication-compliance-status", () => ({ vi.mock("@/lib/insights/general-status", () => ({ generateGeneralStatusForUser: vi.fn(), })); +// v1.18.7 (HIGH-1) — the batched status warm is the production default; +// every test here injects its own `statusGenerators`, so the batch is never +// exercised. Stub the module so its transitive `prepare*` imports of the +// mocked status modules above don't trip an incomplete-mock load error. +vi.mock("@/lib/insights/status-batch", () => ({ + generateStatusBatchForUser: vi.fn(async () => ({ + served: 0, + batched: 0, + fellBack: 0, + batchCallMade: false, + })), +})); // v1.8.7.1 — the generic metric generator also imports the provider // chain transitively; stub it so the default generic warm pass never // touches a live provider when the test does not inject its own. diff --git a/src/lib/jobs/insight-pregenerate.ts b/src/lib/jobs/insight-pregenerate.ts index eb7c7c8e8..1db7bbca5 100644 --- a/src/lib/jobs/insight-pregenerate.ts +++ b/src/lib/jobs/insight-pregenerate.ts @@ -58,13 +58,7 @@ import { generateComprehensiveInsight, type GenerateOutcome, } from "@/lib/insights/comprehensive-generate"; -import { generateBloodPressureStatusForUser } from "@/lib/insights/blood-pressure-status"; -import { generatePulseStatusForUser } from "@/lib/insights/pulse-status"; -import { generateWeightStatusForUser } from "@/lib/insights/weight-status"; -import { generateBmiStatusForUser } from "@/lib/insights/bmi-status"; -import { generateMoodStatusForUser } from "@/lib/insights/mood-status"; -import { generateMedicationComplianceStatusForUser } from "@/lib/insights/medication-compliance-status"; -import { generateGeneralStatusForUser } from "@/lib/insights/general-status"; +import { generateStatusBatchForUser } from "@/lib/insights/status-batch"; import { generateMetricStatus } from "@/lib/insights/metric-status"; import { METRIC_STATUS_IDS, @@ -237,24 +231,6 @@ type StatusGenerator = ( options: { locale: "de" | "en"; force?: boolean }, ) => Promise<{ hasProvider: boolean; cached: boolean }>; -/** - * The seven per-metric status generators the warm pass re-fills after - * the comprehensive generator evicted them. Each writes its own - * `insights.-status.` cache row on a fresh generation and - * resolves the user's provider chain internally, so a provider-less - * account costs one cheap chain-resolve and no LLM call (`hasProvider: - * false`). - */ -const DEFAULT_STATUS_GENERATORS: ReadonlyArray = [ - generateBloodPressureStatusForUser, - generatePulseStatusForUser, - generateWeightStatusForUser, - generateBmiStatusForUser, - generateMoodStatusForUser, - generateMedicationComplianceStatusForUser, - generateGeneralStatusForUser, -]; - /** * Re-fill the per-metric assessment caches for one user. Refill-only * (`force: false`): a card already generated today short-circuits to its @@ -296,6 +272,40 @@ async function warmPerStatusCaches( return results.reduce((sum: number, n) => sum + n, 0); } +/** + * v1.18.7 (HIGH-1) — the production warm for the seven specialised status + * cards. `generateStatusBatchForUser` builds the seven per-metric snapshots + * once and issues ONE provider call for the metrics still needing the LLM, + * fanning the response back into the same per-metric cache rows the + * standalone generators wrote. Replaces the prior seven independent warm + * calls. Returns the count of metrics that got a fresh (batched or + * fallback) assessment so the wide-event tally is comparable to the old + * per-card count. + * + * Refill-only (`force: false`): a card already generated today and a card + * whose content-hash is unchanged both resolve inside the batch as `served` + * (no provider call). A single batch failure degrades each metric to its own + * single-card path rather than aborting the warm. + */ +async function warmStatusBatch( + userId: string, + locales: ReadonlyArray<"de" | "en">, +): Promise { + let warmed = 0; + for (const locale of locales) { + try { + const result = await generateStatusBatchForUser(userId, { + locale, + force: false, + }); + warmed += result.batched + result.fellBack; + } catch { + // The batch must never throw the cron's user loop off course. + } + } + return warmed; +} + /** * v1.8.7.1 — warm the generic per-HealthKit-metric assessment caches for * one user, but ONLY for metrics that have data. A single grouped count @@ -444,8 +454,15 @@ export async function runInsightPregenerate( const now = options.now ?? new Date(); const cap = options.cap ?? PREGENERATE_BATCH_CAP; const generate = options.generate ?? generateComprehensiveInsight; - const statusGenerators = - options.statusGenerators ?? DEFAULT_STATUS_GENERATORS; + // v1.18.7 (HIGH-1) — when the caller injects its own `statusGenerators` + // (the unit tests do), keep the per-card warm so those assertions hold; + // production injects nothing, so the seven warm calls collapse into ONE + // batched generation. The injected per-card path stays the test seam. + const injectedStatusGenerators = options.statusGenerators; + const warmStatus = (userId: string, locales: ReadonlyArray<"de" | "en">) => + injectedStatusGenerators + ? warmPerStatusCaches(userId, locales, injectedStatusGenerators) + : warmStatusBatch(userId, locales); const warmGenericMetrics = options.warmGenericMetrics ?? ((userId: string, locales: ReadonlyArray<"de" | "en">) => @@ -559,11 +576,7 @@ export async function runInsightPregenerate( if (outcome?.status === "skipped" && outcome.reason === "no-provider") { continue; } - result.assessmentsWarmed += await warmPerStatusCaches( - candidate.id, - [locale], - statusGenerators, - ); + result.assessmentsWarmed += await warmStatus(candidate.id, [locale]); // v1.8.7.1 — warm the generic per-HealthKit-metric caches too, for // the user's data-bearing metrics only (the helper filters via one // grouped count, so an empty metric never reaches the provider). @@ -652,8 +665,13 @@ export async function forceWarmUser( } = {}, ): Promise { const generate = options.generate ?? generateComprehensiveInsight; - const statusGenerators = - options.statusGenerators ?? DEFAULT_STATUS_GENERATORS; + // v1.18.7 (HIGH-1) — batch the seven status warms into ONE provider call + // in production; keep the per-card path when a test injects generators. + const injectedStatusGenerators = options.statusGenerators; + const warmStatus = (userId: string, locales: ReadonlyArray<"de" | "en">) => + injectedStatusGenerators + ? warmPerStatusCaches(userId, locales, injectedStatusGenerators) + : warmStatusBatch(userId, locales); const warmGenericMetrics = options.warmGenericMetrics ?? ((id: string, locales: ReadonlyArray<"de" | "en">) => @@ -814,11 +832,7 @@ export async function forceWarmUser( // skips the LLM when the underlying data did not change. There is no // force mode left to mirror — the comprehensive write no longer evicts // the per-status rows. - result.assessmentsWarmed = await warmPerStatusCaches( - userId, - locales, - statusGenerators, - ); + result.assessmentsWarmed = await warmStatus(userId, locales); result.metricAssessmentsWarmed = await warmGenericMetrics(userId, locales); }