feat(insights): batch the seven status assessments into one provider call

Add generateStatusBatchForUser: it prepares the seven specialised status
snapshots once, sends one prompt for a { perMetric } envelope covering only
the metrics with data, and fans each summary back into the same per-metric
cache rows the standalone generators write. One JSON-correction retry guards
the parse; any omitted metric or a failed batch degrades to that card's own
single-call path, so a partial response never goes dark. The nightly warm and
the forced warm route through the batch in production while the injected
per-card seam keeps the cron tests intact. Adds the status-batch prompt
scaffold composed from the shared assessment contract.
This commit is contained in:
Marc-André Bombeck committed 2026-06-19 21:41:14 +02:00
1 parent 52d3e5bc10
commit b3b6720723
4 files changed
+434 -39

No files matched your search

+89
View File
@@ -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: { <key>: "<assessment>" } }` 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 `### <key>` 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<string, string> = {
"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<string, string> =
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 "### <key>" 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 "### <key>"; 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 `### <key>` 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");
}
+280
View File
@@ -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 `prepare<Metric>StatusForUser` (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<PreparedStatusCard>
> = [
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<string, string> | null {
const tryParse = (raw: string): Record<string, string> | null => {
try {
const parsed = JSON.parse(raw) as { perMetric?: unknown };
if (parsed && typeof parsed.perMetric === "object" && parsed.perMetric) {
const out: Record<string, string> = {};
for (const [key, value] of Object.entries(
parsed.perMetric as Record<string, unknown>,
)) {
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<StatusBatchResult> {
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<string, string> | 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;
}
@@ -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.
+53 -39
View File
@@ -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.<scope>-status.<locale>` 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<StatusGenerator> = [
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<number> {
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<ForceWarmResult> {
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);
}