mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
Merge wave-ai-status-batch: batch the per-metric status assessments
Split each per-metric status generator at the provider boundary so the snapshot build, cache read, and content-hash gate run without the model, then send one structured request covering every metric that has data and fan each summary back into the same per-metric cache row. Collapses the seven specialised status calls per warm cycle into one while leaving the read path, caching, and single-card behaviour unchanged.
This commit is contained in:
commit
208c607b93
15 files changed
+1816
-546
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,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");
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
// Mock the seven prepare modules. Each test sets a prepare's return to a
|
||||
// `served` or `pending` card. The pending card's `finalize` / `timeout` are
|
||||
// vi.fn so we can assert the fan-out hit the right closure.
|
||||
vi.mock("@/lib/insights/blood-pressure-status", () => ({
|
||||
prepareBloodPressureStatusForUser: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/insights/weight-status", () => ({
|
||||
prepareWeightStatusForUser: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/insights/pulse-status", () => ({
|
||||
preparePulseStatusForUser: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/insights/bmi-status", () => ({
|
||||
prepareBmiStatusForUser: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/insights/mood-status", () => ({
|
||||
prepareMoodStatusForUser: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/insights/medication-compliance-status", () => ({
|
||||
prepareMedicationComplianceStatusForUser: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/insights/general-status", () => ({
|
||||
prepareGeneralStatusForUser: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/insights/status-provider", () => ({
|
||||
runStatusCompletion: vi.fn(),
|
||||
}));
|
||||
|
||||
// The single-card fallback path — assert it is invoked for omitted / failed
|
||||
// metrics, but don't re-drive a real completion.
|
||||
vi.mock("@/lib/insights/status-card-generation", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<
|
||||
typeof import("@/lib/insights/status-card-generation")
|
||||
>();
|
||||
return { ...actual, runPreparedStatusCard: vi.fn(async () => ({})) };
|
||||
});
|
||||
|
||||
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 { runStatusCompletion } from "@/lib/insights/status-provider";
|
||||
import { runPreparedStatusCard } from "@/lib/insights/status-card-generation";
|
||||
import { generateStatusBatchForUser } from "../status-batch";
|
||||
|
||||
function servedCard() {
|
||||
return {
|
||||
phase: "served" as const,
|
||||
result: { hasProvider: true, text: "cached", cached: true, updatedAt: "t" },
|
||||
};
|
||||
}
|
||||
|
||||
function pendingCard(
|
||||
metric: string,
|
||||
finalize: (arg: { content: string }) => Promise<unknown> = vi.fn(
|
||||
async (): Promise<unknown> => ({}),
|
||||
),
|
||||
) {
|
||||
const timeout = vi.fn(() => ({
|
||||
hasProvider: true,
|
||||
text: "stub",
|
||||
cached: true,
|
||||
updatedAt: null,
|
||||
}));
|
||||
return {
|
||||
phase: "pending" as const,
|
||||
metric,
|
||||
userId: "u1",
|
||||
cacheAction: `insights.${metric}-status.en`,
|
||||
systemPrompt: "sys",
|
||||
userPrompt: `snapshot for ${metric}`,
|
||||
snapshotHash: "h",
|
||||
temperature: 0.45,
|
||||
noProvider: { hasProvider: false, text: "nokey", cached: true, updatedAt: null },
|
||||
timeout,
|
||||
finalize,
|
||||
};
|
||||
}
|
||||
|
||||
const ALL_PREPARES = [
|
||||
prepareBloodPressureStatusForUser,
|
||||
prepareWeightStatusForUser,
|
||||
preparePulseStatusForUser,
|
||||
prepareBmiStatusForUser,
|
||||
prepareMoodStatusForUser,
|
||||
prepareMedicationComplianceStatusForUser,
|
||||
prepareGeneralStatusForUser,
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("generateStatusBatchForUser — one call covers all present metrics", () => {
|
||||
it("issues exactly ONE provider call for the seven pending cards and fans each summary into its finalize", async () => {
|
||||
const mkFinalize = () =>
|
||||
vi.fn((arg: { content: string }): Promise<unknown> => {
|
||||
void arg;
|
||||
return Promise.resolve({});
|
||||
});
|
||||
const finalizers = {
|
||||
"blood-pressure": mkFinalize(),
|
||||
weight: mkFinalize(),
|
||||
pulse: mkFinalize(),
|
||||
bmi: mkFinalize(),
|
||||
mood: mkFinalize(),
|
||||
"medication-compliance": mkFinalize(),
|
||||
general: mkFinalize(),
|
||||
} as const;
|
||||
vi.mocked(prepareBloodPressureStatusForUser).mockResolvedValue(
|
||||
pendingCard("blood-pressure", finalizers["blood-pressure"]) as never,
|
||||
);
|
||||
vi.mocked(prepareWeightStatusForUser).mockResolvedValue(
|
||||
pendingCard("weight", finalizers.weight) as never,
|
||||
);
|
||||
vi.mocked(preparePulseStatusForUser).mockResolvedValue(
|
||||
pendingCard("pulse", finalizers.pulse) as never,
|
||||
);
|
||||
vi.mocked(prepareBmiStatusForUser).mockResolvedValue(
|
||||
pendingCard("bmi", finalizers.bmi) as never,
|
||||
);
|
||||
vi.mocked(prepareMoodStatusForUser).mockResolvedValue(
|
||||
pendingCard("mood", finalizers.mood) as never,
|
||||
);
|
||||
vi.mocked(prepareMedicationComplianceStatusForUser).mockResolvedValue(
|
||||
pendingCard(
|
||||
"medication-compliance",
|
||||
finalizers["medication-compliance"],
|
||||
) as never,
|
||||
);
|
||||
vi.mocked(prepareGeneralStatusForUser).mockResolvedValue(
|
||||
pendingCard("general", finalizers.general) as never,
|
||||
);
|
||||
|
||||
vi.mocked(runStatusCompletion).mockResolvedValue({
|
||||
kind: "ok",
|
||||
content: JSON.stringify({
|
||||
perMetric: {
|
||||
bp: "BP assessment.",
|
||||
weight: "Weight assessment.",
|
||||
pulse: "Pulse assessment.",
|
||||
bmi: "BMI assessment.",
|
||||
mood: "Mood assessment.",
|
||||
compliance: "Adherence assessment.",
|
||||
general: "Overview assessment.",
|
||||
},
|
||||
}),
|
||||
providerType: "anthropic",
|
||||
model: "x",
|
||||
tokensUsed: 100,
|
||||
} as never);
|
||||
|
||||
const result = await generateStatusBatchForUser("u1", { locale: "en" });
|
||||
|
||||
// ONE provider round-trip for all seven metrics.
|
||||
expect(runStatusCompletion).toHaveBeenCalledTimes(1);
|
||||
expect(result.batched).toBe(7);
|
||||
expect(result.fellBack).toBe(0);
|
||||
// Each metric's finalize got its own summary, wrapped in the { summary }
|
||||
// envelope each card's finalize parses.
|
||||
for (const f of Object.values(finalizers)) {
|
||||
expect(f).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
const bpArg = finalizers["blood-pressure"].mock.calls[0][0];
|
||||
expect(JSON.parse(bpArg.content)).toEqual({ summary: "BP assessment." });
|
||||
// No card fell back to a single-card call.
|
||||
expect(runPreparedStatusCard).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateStatusBatchForUser — absent metrics are omitted, not fabricated", () => {
|
||||
it("only prompts for the metrics with data and never finalizes an absent metric", async () => {
|
||||
// Five metrics resolve `served` (cache hit / unchanged / no data); only
|
||||
// weight + pulse are pending.
|
||||
vi.mocked(prepareBloodPressureStatusForUser).mockResolvedValue(
|
||||
servedCard() as never,
|
||||
);
|
||||
const weightFinalize = vi.fn(async () => ({}));
|
||||
const pulseFinalize = vi.fn(async () => ({}));
|
||||
vi.mocked(prepareWeightStatusForUser).mockResolvedValue(
|
||||
pendingCard("weight", weightFinalize) as never,
|
||||
);
|
||||
vi.mocked(preparePulseStatusForUser).mockResolvedValue(
|
||||
pendingCard("pulse", pulseFinalize) as never,
|
||||
);
|
||||
vi.mocked(prepareBmiStatusForUser).mockResolvedValue(servedCard() as never);
|
||||
vi.mocked(prepareMoodStatusForUser).mockResolvedValue(servedCard() as never);
|
||||
vi.mocked(prepareMedicationComplianceStatusForUser).mockResolvedValue(
|
||||
servedCard() as never,
|
||||
);
|
||||
vi.mocked(prepareGeneralStatusForUser).mockResolvedValue(
|
||||
servedCard() as never,
|
||||
);
|
||||
|
||||
let capturedSystem = "";
|
||||
vi.mocked(runStatusCompletion).mockImplementation(
|
||||
async (args: { systemPrompt: string }) => {
|
||||
capturedSystem = args.systemPrompt;
|
||||
return {
|
||||
kind: "ok",
|
||||
content: JSON.stringify({
|
||||
perMetric: { weight: "W.", pulse: "P." },
|
||||
}),
|
||||
providerType: "anthropic",
|
||||
model: "x",
|
||||
tokensUsed: 10,
|
||||
} as never;
|
||||
},
|
||||
);
|
||||
|
||||
const result = await generateStatusBatchForUser("u1", { locale: "en" });
|
||||
|
||||
expect(result.served).toBe(5);
|
||||
expect(result.batched).toBe(2);
|
||||
// The prompt names ONLY the present keys (in prepare order: pulse before
|
||||
// weight, since the batch runs [bp, pulse, weight, …]).
|
||||
expect(capturedSystem).toContain("keys present: pulse, weight");
|
||||
expect(capturedSystem).not.toContain('"bp"');
|
||||
expect(weightFinalize).toHaveBeenCalledTimes(1);
|
||||
expect(pulseFinalize).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateStatusBatchForUser — hash gate short-circuits unchanged data", () => {
|
||||
it("makes NO provider call when every card resolved served (cache / unchanged)", async () => {
|
||||
for (const prepare of ALL_PREPARES) {
|
||||
vi.mocked(prepare).mockResolvedValue(servedCard() as never);
|
||||
}
|
||||
|
||||
const result = await generateStatusBatchForUser("u1", { locale: "en" });
|
||||
|
||||
expect(runStatusCompletion).not.toHaveBeenCalled();
|
||||
expect(result.served).toBe(7);
|
||||
expect(result.batched).toBe(0);
|
||||
expect(result.batchCallMade).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateStatusBatchForUser — graceful partial degradation", () => {
|
||||
it("falls a metric the batch omitted back to its single-card path", async () => {
|
||||
const weightFinalize = vi.fn(async () => ({}));
|
||||
const pulseFinalize = vi.fn(async () => ({}));
|
||||
vi.mocked(prepareWeightStatusForUser).mockResolvedValue(
|
||||
pendingCard("weight", weightFinalize) as never,
|
||||
);
|
||||
vi.mocked(preparePulseStatusForUser).mockResolvedValue(
|
||||
pendingCard("pulse", pulseFinalize) as never,
|
||||
);
|
||||
for (const prepare of [
|
||||
prepareBloodPressureStatusForUser,
|
||||
prepareBmiStatusForUser,
|
||||
prepareMoodStatusForUser,
|
||||
prepareMedicationComplianceStatusForUser,
|
||||
prepareGeneralStatusForUser,
|
||||
]) {
|
||||
vi.mocked(prepare).mockResolvedValue(servedCard() as never);
|
||||
}
|
||||
|
||||
// The batch returns weight but OMITS pulse.
|
||||
vi.mocked(runStatusCompletion).mockResolvedValue({
|
||||
kind: "ok",
|
||||
content: JSON.stringify({ perMetric: { weight: "W." } }),
|
||||
providerType: "anthropic",
|
||||
model: "x",
|
||||
tokensUsed: 10,
|
||||
} as never);
|
||||
|
||||
const result = await generateStatusBatchForUser("u1", { locale: "en" });
|
||||
|
||||
expect(weightFinalize).toHaveBeenCalledTimes(1);
|
||||
// pulse was omitted → fell back to the single-card path, NOT finalized
|
||||
// from the batch.
|
||||
expect(pulseFinalize).not.toHaveBeenCalled();
|
||||
expect(runPreparedStatusCard).toHaveBeenCalledTimes(1);
|
||||
expect(result.batched).toBe(1);
|
||||
expect(result.fellBack).toBe(1);
|
||||
});
|
||||
|
||||
it("degrades every pending metric to its single-card path on a batch provider error", async () => {
|
||||
const finalizers = ALL_PREPARES.map(() => vi.fn(async () => ({})));
|
||||
ALL_PREPARES.forEach((prepare, i) => {
|
||||
vi.mocked(prepare).mockResolvedValue(
|
||||
pendingCard(`m${i}`, finalizers[i]) as never,
|
||||
);
|
||||
});
|
||||
vi.mocked(runStatusCompletion).mockResolvedValue({
|
||||
kind: "error",
|
||||
} as never);
|
||||
|
||||
const result = await generateStatusBatchForUser("u1", { locale: "en" });
|
||||
|
||||
// No finalize from the batch; every metric fell back.
|
||||
for (const f of finalizers) expect(f).not.toHaveBeenCalled();
|
||||
expect(runPreparedStatusCard).toHaveBeenCalledTimes(7);
|
||||
expect(result.fellBack).toBe(7);
|
||||
expect(result.batched).toBe(0);
|
||||
});
|
||||
|
||||
it("retries once on invalid JSON, then succeeds", async () => {
|
||||
const wf = vi.fn(async () => ({}));
|
||||
const pf = vi.fn(async () => ({}));
|
||||
vi.mocked(prepareWeightStatusForUser).mockResolvedValue(
|
||||
pendingCard("weight", wf) as never,
|
||||
);
|
||||
vi.mocked(preparePulseStatusForUser).mockResolvedValue(
|
||||
pendingCard("pulse", pf) as never,
|
||||
);
|
||||
for (const prepare of [
|
||||
prepareBloodPressureStatusForUser,
|
||||
prepareBmiStatusForUser,
|
||||
prepareMoodStatusForUser,
|
||||
prepareMedicationComplianceStatusForUser,
|
||||
prepareGeneralStatusForUser,
|
||||
]) {
|
||||
vi.mocked(prepare).mockResolvedValue(servedCard() as never);
|
||||
}
|
||||
|
||||
vi.mocked(runStatusCompletion)
|
||||
.mockResolvedValueOnce({
|
||||
kind: "ok",
|
||||
content: "not json at all",
|
||||
providerType: "anthropic",
|
||||
model: "x",
|
||||
tokensUsed: 1,
|
||||
} as never)
|
||||
.mockResolvedValueOnce({
|
||||
kind: "ok",
|
||||
content: JSON.stringify({ perMetric: { weight: "W.", pulse: "P." } }),
|
||||
providerType: "anthropic",
|
||||
model: "x",
|
||||
tokensUsed: 1,
|
||||
} as never);
|
||||
|
||||
const result = await generateStatusBatchForUser("u1", { locale: "en" });
|
||||
|
||||
expect(runStatusCompletion).toHaveBeenCalledTimes(2);
|
||||
expect(wf).toHaveBeenCalledTimes(1);
|
||||
expect(pf).toHaveBeenCalledTimes(1);
|
||||
expect(result.batched).toBe(2);
|
||||
});
|
||||
|
||||
it("serves the no-key result for every pending card when no provider is configured", async () => {
|
||||
const finalizers = ALL_PREPARES.map(() => vi.fn(async () => ({})));
|
||||
ALL_PREPARES.forEach((prepare, i) => {
|
||||
vi.mocked(prepare).mockResolvedValue(
|
||||
pendingCard(`m${i}`, finalizers[i]) as never,
|
||||
);
|
||||
});
|
||||
vi.mocked(runStatusCompletion).mockResolvedValue({ kind: "none" } as never);
|
||||
|
||||
const result = await generateStatusBatchForUser("u1", { locale: "en" });
|
||||
|
||||
// No finalize, no re-issued completion (the no-key result is precomputed).
|
||||
for (const f of finalizers) expect(f).not.toHaveBeenCalled();
|
||||
expect(runPreparedStatusCard).not.toHaveBeenCalled();
|
||||
expect(result.fellBack).toBe(7);
|
||||
expect(result.batchCallMade).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user