refactor(insights): split the remaining status cards at the provider boundary

Apply the prepare -> run -> finalize split to blood-pressure, pulse, bmi,
mood, general, and medication-compliance status, matching weight-status.
Each public generator keeps its signature and one-call behaviour by driving
its prepared card through the shared single-card path; the snapshot build,
cache read, read-only miss, and content-hash gate are now reusable by a
batched caller without changing what each card persists.
This commit is contained in:
Marc-André Bombeck committed 2026-06-19 21:36:11 +02:00
1 parent beb0be7452
commit 52d3e5bc10
6 files changed
+734 -434

No files matched your search

+109 -64
View File
@@ -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<PreparedStatusCard> {
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<StatusCardResult> => {
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,
};
},
};
}
+122 -67
View File
@@ -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<PreparedStatusCard> {
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<StatusCardResult> => {
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,
};
},
};
}
+99 -60
View File
@@ -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<PreparedStatusCard> {
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<StatusCardResult> => {
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,
};
},
};
}
+180 -123
View File
@@ -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<MedicationComplianceResult> {
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<PreparedStatusCard> {
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(),
};
}
+112 -60
View File
@@ -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<PreparedStatusCard> {
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<StatusCardResult> => {
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,
};
},
};
}
+112 -60
View File
@@ -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<PreparedStatusCard> {
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<StatusCardResult> => {
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,
};
},
};
}