refactor(insights): split weight-status at the provider boundary

Introduce a prepare -> run -> finalize contract for the per-metric status
cards so the snapshot build and the provider call can be driven separately.
The public generator keeps its signature and one-call-per-metric behaviour;
weight-status now resolves through the shared single-card path. Add the
status-batch AI budget entry sized for one combined seven-metric call.
This commit is contained in:
Marc-André Bombeck committed 2026-06-19 21:31:16 +02:00
1 parent 8b3b1e1985
commit beb0be7452
4 files changed
+282 -73

No files matched your search

+1
View File
@@ -0,0 +1 @@
/Users/marc/Projects/HealthLog/src/generated
+12
View File
@@ -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.
+150
View File
@@ -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:
*
* prepare<Metric>StatusForUser(...) → 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.
*
* generate<Metric>StatusForUser(...) (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.<metric>-status.<locale>` — 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<StatusCardResult>;
/**
* 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<StatusCardResult> {
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,
});
}
+119 -73
View File
@@ -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<PreparedStatusCard> {
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<StatusCardResult> => {
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,
};
},
};
}