mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
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:
1 parent
8b3b1e1985
commit
beb0be7452
4 files changed
+282
-73
No files matched your search
Symlink
+1
@@ -0,0 +1 @@
|
||||
/Users/marc/Projects/HealthLog/src/generated
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user