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/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/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); }