From 11a06196f35258e6d44ec185090044953710e531 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 31 May 2026 21:47:09 +0200 Subject: [PATCH] perf(insights): compress status snapshots via graded series (raw->week->month->year) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The seven per-card generators embedded the full ~360-day daily array per metric plus correlation pair arrays, and never touched the WEEK/MONTH/YEAR rollup tier. A dense daily weigher ran the weight snapshot ~80-110 KB; a many-type account could push general-status past 100 K tokens. Embed the shared graded series instead of the full daily array, cap the embedded correlation/paired arrays to their most recent entries, skip measurement types with no data in general-status, and apply a generic 24 000-char snapshot budget that sheds yearly -> weekly -> array-tails -> recent until it fits. The applyPayloadBudget daily buckets stay in-process to drive the latest-day focus, in-target gates, and correlation pairing; only the compact shape reaches the prompt. Measured on a 2-year daily account: weight-status ~15 KB, general-status ~12 KB across six dense types — both well under the cap. --- .../__tests__/blood-pressure-status.test.ts | 28 +++--- src/lib/insights/__tests__/bmi-status.test.ts | 26 +++--- .../insights/__tests__/general-status.test.ts | 55 ++++++++--- .../medication-compliance-status.test.ts | 20 ++-- .../insights/__tests__/mood-status.test.ts | 26 +++--- .../insights/__tests__/pulse-status.test.ts | 26 +++--- .../insights/__tests__/weight-status.test.ts | 32 ++++--- src/lib/insights/blood-pressure-status.ts | 46 ++++++++-- src/lib/insights/bmi-status.ts | 43 +++++---- src/lib/insights/general-status.ts | 52 ++++++++--- src/lib/insights/graded-series.ts | 91 +++++++++++++++++++ .../insights/medication-compliance-status.ts | 19 +++- src/lib/insights/mood-status.ts | 29 ++++-- src/lib/insights/pulse-status.ts | 45 +++++---- src/lib/insights/weight-status.ts | 68 ++++++++++++-- 15 files changed, 448 insertions(+), 158 deletions(-) diff --git a/src/lib/insights/__tests__/blood-pressure-status.test.ts b/src/lib/insights/__tests__/blood-pressure-status.test.ts index f5186c345..2a4085c6e 100644 --- a/src/lib/insights/__tests__/blood-pressure-status.test.ts +++ b/src/lib/insights/__tests__/blood-pressure-status.test.ts @@ -54,8 +54,8 @@ beforeEach(() => { vi.mocked(getMedicationCategories).mockResolvedValue({}); }); -describe("generateBloodPressureStatusForUser — v1.4.6 bucketed payload", () => { - it("emits {daily, monthly} per metric series with correct field shape", async () => { +describe("generateBloodPressureStatusForUser — graded payload", () => { + it("emits a graded {recent, weekly, monthly} per-metric series, not the full daily array", async () => { const now = new Date(); const records: Array<{ @@ -99,20 +99,22 @@ describe("generateBloodPressureStatusForUser — v1.4.6 bucketed payload", () => const snapshot = JSON.parse(match![0]); const sys = snapshot.bloodPressure.systolic.series; - expect(sys).toHaveProperty("daily"); + expect(sys).toHaveProperty("recent"); + expect(sys).toHaveProperty("weekly"); expect(sys).toHaveProperty("monthly"); - expect(sys.daily.length).toBeGreaterThan(0); - expect(sys.monthly.length).toBeGreaterThan(0); - expect(sys.daily[0]).toHaveProperty("dayOffset"); - expect(sys.daily[0]).toHaveProperty("value"); - expect(sys.daily[0]).toHaveProperty("n"); - expect(sys.monthly[0]).toHaveProperty("monthOffset"); - expect(sys.monthly[0]).toHaveProperty("value"); - expect(sys.monthly[0]).toHaveProperty("n"); + expect(sys).toHaveProperty("yearly"); + expect(sys.recent.length).toBeLessThanOrEqual(21); + expect(sys.recent[0]).toHaveProperty("date"); + expect(sys.recent[0]).toHaveProperty("mean"); + expect(sys.monthly[0]).toHaveProperty("month"); const dia = snapshot.bloodPressure.diastolic.series; - expect(dia.daily.length).toBeGreaterThan(0); - expect(dia.monthly.length).toBeGreaterThan(0); + expect(dia).toHaveProperty("recent"); + expect(dia).toHaveProperty("monthly"); + expect(dia.recent.length).toBeLessThanOrEqual(21); + + // Embedded correlation pair arrays are capped, not full-length. + expect(snapshot.weightVsSystolic.pairs.length).toBeLessThanOrEqual(30); }); }); diff --git a/src/lib/insights/__tests__/bmi-status.test.ts b/src/lib/insights/__tests__/bmi-status.test.ts index 8cece73ec..bb9a6ae53 100644 --- a/src/lib/insights/__tests__/bmi-status.test.ts +++ b/src/lib/insights/__tests__/bmi-status.test.ts @@ -46,8 +46,8 @@ beforeEach(() => { vi.resetAllMocks(); }); -describe("generateBmiStatusForUser — v1.4.6 bucketed payload", () => { - it("emits {daily, monthly} BMI series derived from bucketed weight", async () => { +describe("generateBmiStatusForUser — graded payload", () => { + it("emits a graded {recent, weekly, monthly} BMI series, not the full daily array", async () => { const now = new Date(); const records: Array<{ value: number; measuredAt: Date }> = []; for (let day = 0; day < 1000; day++) { @@ -75,16 +75,20 @@ describe("generateBmiStatusForUser — v1.4.6 bucketed payload", () => { const snapshot = JSON.parse(match![0]); const bmi = snapshot.bmi.series; - expect(bmi).toHaveProperty("daily"); + expect(bmi).toHaveProperty("recent"); + expect(bmi).toHaveProperty("weekly"); expect(bmi).toHaveProperty("monthly"); - expect(bmi.daily.length).toBeGreaterThan(0); - expect(bmi.monthly.length).toBeGreaterThan(0); - expect(bmi.daily[0]).toHaveProperty("dayOffset"); - expect(bmi.daily[0]).toHaveProperty("value"); - expect(bmi.daily[0]).toHaveProperty("n"); - expect(bmi.monthly[0]).toHaveProperty("monthOffset"); - expect(bmi.monthly[0]).toHaveProperty("value"); - expect(bmi.monthly[0]).toHaveProperty("n"); + expect(bmi).toHaveProperty("yearly"); + expect(bmi.recent.length).toBeLessThanOrEqual(21); + expect(bmi.recent[0]).toHaveProperty("date"); + expect(bmi.recent[0]).toHaveProperty("mean"); + expect(bmi.monthly[0]).toHaveProperty("month"); + const total = + bmi.recent.length + + bmi.weekly.length + + bmi.monthly.length + + bmi.yearly.length; + expect(total).toBeLessThanOrEqual(50); }); }); diff --git a/src/lib/insights/__tests__/general-status.test.ts b/src/lib/insights/__tests__/general-status.test.ts index f8c3fc5af..afed1f0da 100644 --- a/src/lib/insights/__tests__/general-status.test.ts +++ b/src/lib/insights/__tests__/general-status.test.ts @@ -47,8 +47,8 @@ beforeEach(() => { vi.resetAllMocks(); }); -describe("generateGeneralStatusForUser — v1.4.6 bucketed payload", () => { - it("emits {daily, monthly} per metric series with dayOffset/monthOffset/value/n", async () => { +describe("generateGeneralStatusForUser — graded payload", () => { + it("emits a graded {recent, weekly, monthly} per-metric series, not the full daily array", async () => { const now = new Date(); const weightRecords: Array<{ @@ -90,18 +90,47 @@ describe("generateGeneralStatusForUser — v1.4.6 bucketed payload", () => { const snapshot = JSON.parse(match![0]); const weight = snapshot.measurementSeries.WEIGHT.series; - expect(weight).toHaveProperty("daily"); + expect(weight).toHaveProperty("recent"); + expect(weight).toHaveProperty("weekly"); expect(weight).toHaveProperty("monthly"); - expect(Array.isArray(weight.daily)).toBe(true); - expect(Array.isArray(weight.monthly)).toBe(true); - expect(weight.daily.length).toBeGreaterThan(0); - expect(weight.monthly.length).toBeGreaterThan(0); - expect(weight.daily[0]).toHaveProperty("dayOffset"); - expect(weight.daily[0]).toHaveProperty("value"); - expect(weight.daily[0]).toHaveProperty("n"); - expect(weight.monthly[0]).toHaveProperty("monthOffset"); - expect(weight.monthly[0]).toHaveProperty("value"); - expect(weight.monthly[0]).toHaveProperty("n"); + expect(weight).toHaveProperty("yearly"); + expect(weight.recent.length).toBeLessThanOrEqual(21); + expect(weight.recent[0]).toHaveProperty("date"); + expect(weight.recent[0]).toHaveProperty("mean"); + expect(weight.monthly[0]).toHaveProperty("month"); + const total = + weight.recent.length + + weight.weekly.length + + weight.monthly.length + + weight.yearly.length; + expect(total).toBeLessThanOrEqual(50); + }); + + it("omits measurement types with no data", async () => { + const now = new Date(); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dateOfBirth: null, + } as never); + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); + vi.mocked(prisma.measurement.findMany).mockResolvedValue([ + { type: "WEIGHT", value: 80, measuredAt: now }, + ] as never); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.auditLog.create).mockResolvedValue({ + createdAt: now, + } as never); + + const captured: { userPrompt: string | null } = { userPrompt: null }; + stubCompletion('{"summary":"OK"}', captured); + + await generateGeneralStatusForUser("user-1", { locale: "en" }); + const snapshot = JSON.parse(captured.userPrompt!.match(/\{[\s\S]*\}/)![0]); + + // Only WEIGHT has data — no empty PULSE/BP/etc. series objects. + expect(Object.keys(snapshot.measurementSeries)).toEqual(["WEIGHT"]); }); }); diff --git a/src/lib/insights/__tests__/medication-compliance-status.test.ts b/src/lib/insights/__tests__/medication-compliance-status.test.ts index 6ed8366c6..6cfc875b8 100644 --- a/src/lib/insights/__tests__/medication-compliance-status.test.ts +++ b/src/lib/insights/__tests__/medication-compliance-status.test.ts @@ -64,8 +64,8 @@ beforeEach(() => { vi.mocked(getMedicationCategories).mockResolvedValue({}); }); -describe("generateMedicationComplianceStatusForUser — v1.4.6 bucketed payload", () => { - it("emits {daily, monthly} compliance series per medication spanning 3 years", async () => { +describe("generateMedicationComplianceStatusForUser — graded payload", () => { + it("emits a graded {recent, weekly, monthly} compliance series per medication", async () => { const now = new Date(); const medication = { id: "med-1", @@ -118,16 +118,14 @@ describe("generateMedicationComplianceStatusForUser — v1.4.6 bucketed payload" expect(snapshot.medications).toBeInstanceOf(Array); expect(snapshot.medications.length).toBe(1); const dailySeries = snapshot.medications[0].dailySeries; - expect(dailySeries).toHaveProperty("daily"); + expect(dailySeries).toHaveProperty("recent"); + expect(dailySeries).toHaveProperty("weekly"); expect(dailySeries).toHaveProperty("monthly"); - expect(dailySeries.daily.length).toBeGreaterThan(0); - expect(dailySeries.monthly.length).toBeGreaterThan(0); - expect(dailySeries.daily[0]).toHaveProperty("dayOffset"); - expect(dailySeries.daily[0]).toHaveProperty("value"); - expect(dailySeries.daily[0]).toHaveProperty("n"); - expect(dailySeries.monthly[0]).toHaveProperty("monthOffset"); - expect(dailySeries.monthly[0]).toHaveProperty("value"); - expect(dailySeries.monthly[0]).toHaveProperty("n"); + expect(dailySeries).toHaveProperty("yearly"); + expect(dailySeries.recent.length).toBeLessThanOrEqual(21); + expect(dailySeries.recent[0]).toHaveProperty("date"); + expect(dailySeries.recent[0]).toHaveProperty("mean"); + expect(dailySeries.monthly[0]).toHaveProperty("month"); }); }); diff --git a/src/lib/insights/__tests__/mood-status.test.ts b/src/lib/insights/__tests__/mood-status.test.ts index 73a6bcc4d..cf4dc245b 100644 --- a/src/lib/insights/__tests__/mood-status.test.ts +++ b/src/lib/insights/__tests__/mood-status.test.ts @@ -45,8 +45,8 @@ beforeEach(() => { vi.resetAllMocks(); }); -describe("generateMoodStatusForUser — v1.4.6 bucketed payload", () => { - it("emits {daily, monthly} mood series spanning 3 years", async () => { +describe("generateMoodStatusForUser — graded payload", () => { + it("emits a graded {recent, weekly, monthly} mood series, not the full daily array", async () => { const now = new Date(); const entries: Array<{ date: string; @@ -80,16 +80,20 @@ describe("generateMoodStatusForUser — v1.4.6 bucketed payload", () => { const snapshot = JSON.parse(match![0]); const mood = snapshot.mood.series; - expect(mood).toHaveProperty("daily"); + expect(mood).toHaveProperty("recent"); + expect(mood).toHaveProperty("weekly"); expect(mood).toHaveProperty("monthly"); - expect(mood.daily.length).toBeGreaterThan(0); - expect(mood.monthly.length).toBeGreaterThan(0); - expect(mood.daily[0]).toHaveProperty("dayOffset"); - expect(mood.daily[0]).toHaveProperty("value"); - expect(mood.daily[0]).toHaveProperty("n"); - expect(mood.monthly[0]).toHaveProperty("monthOffset"); - expect(mood.monthly[0]).toHaveProperty("value"); - expect(mood.monthly[0]).toHaveProperty("n"); + expect(mood).toHaveProperty("yearly"); + expect(mood.recent.length).toBeLessThanOrEqual(21); + expect(mood.recent[0]).toHaveProperty("date"); + expect(mood.recent[0]).toHaveProperty("mean"); + expect(mood.monthly[0]).toHaveProperty("month"); + const total = + mood.recent.length + + mood.weekly.length + + mood.monthly.length + + mood.yearly.length; + expect(total).toBeLessThanOrEqual(50); }); }); diff --git a/src/lib/insights/__tests__/pulse-status.test.ts b/src/lib/insights/__tests__/pulse-status.test.ts index ce1624c8a..8077133b4 100644 --- a/src/lib/insights/__tests__/pulse-status.test.ts +++ b/src/lib/insights/__tests__/pulse-status.test.ts @@ -46,8 +46,8 @@ beforeEach(() => { vi.resetAllMocks(); }); -describe("generatePulseStatusForUser — v1.4.6 bucketed payload", () => { - it("emits {daily, monthly} pulse series with dayOffset/monthOffset/value/n", async () => { +describe("generatePulseStatusForUser — graded payload", () => { + it("emits a graded {recent, weekly, monthly} pulse series, not the full daily array", async () => { const now = new Date(); const records: Array<{ value: number; measuredAt: Date }> = []; for (let day = 0; day < 1000; day++) { @@ -77,16 +77,20 @@ describe("generatePulseStatusForUser — v1.4.6 bucketed payload", () => { const snapshot = JSON.parse(match![0]); const pulse = snapshot.pulse.series; - expect(pulse).toHaveProperty("daily"); + expect(pulse).toHaveProperty("recent"); + expect(pulse).toHaveProperty("weekly"); expect(pulse).toHaveProperty("monthly"); - expect(pulse.daily.length).toBeGreaterThan(0); - expect(pulse.monthly.length).toBeGreaterThan(0); - expect(pulse.daily[0]).toHaveProperty("dayOffset"); - expect(pulse.daily[0]).toHaveProperty("value"); - expect(pulse.daily[0]).toHaveProperty("n"); - expect(pulse.monthly[0]).toHaveProperty("monthOffset"); - expect(pulse.monthly[0]).toHaveProperty("value"); - expect(pulse.monthly[0]).toHaveProperty("n"); + expect(pulse).toHaveProperty("yearly"); + expect(pulse.recent.length).toBeLessThanOrEqual(21); + expect(pulse.recent[0]).toHaveProperty("date"); + expect(pulse.recent[0]).toHaveProperty("mean"); + expect(pulse.monthly[0]).toHaveProperty("month"); + const total = + pulse.recent.length + + pulse.weekly.length + + pulse.monthly.length + + pulse.yearly.length; + expect(total).toBeLessThanOrEqual(50); }); }); diff --git a/src/lib/insights/__tests__/weight-status.test.ts b/src/lib/insights/__tests__/weight-status.test.ts index 012bafb3f..4582be077 100644 --- a/src/lib/insights/__tests__/weight-status.test.ts +++ b/src/lib/insights/__tests__/weight-status.test.ts @@ -45,8 +45,8 @@ beforeEach(() => { vi.resetAllMocks(); }); -describe("generateWeightStatusForUser — v1.4.6 bucketed payload", () => { - it("emits {daily, monthly} weight series with bucket fields", async () => { +describe("generateWeightStatusForUser — graded payload", () => { + it("emits a graded {recent, weekly, monthly} weight series, not the full daily array", async () => { const now = new Date(); const records: Array<{ type: string; @@ -77,16 +77,26 @@ describe("generateWeightStatusForUser — v1.4.6 bucketed payload", () => { const snapshot = JSON.parse(match![0]); const weight = snapshot.weight.series; - expect(weight).toHaveProperty("daily"); + expect(weight).toHaveProperty("recent"); + expect(weight).toHaveProperty("weekly"); expect(weight).toHaveProperty("monthly"); - expect(weight.daily.length).toBeGreaterThan(0); - expect(weight.monthly.length).toBeGreaterThan(0); - expect(weight.daily[0]).toHaveProperty("dayOffset"); - expect(weight.daily[0]).toHaveProperty("value"); - expect(weight.daily[0]).toHaveProperty("n"); - expect(weight.monthly[0]).toHaveProperty("monthOffset"); - expect(weight.monthly[0]).toHaveProperty("value"); - expect(weight.monthly[0]).toHaveProperty("n"); + expect(weight).toHaveProperty("yearly"); + // No raw daily array beyond the bounded recent window. + expect(weight.recent.length).toBeLessThanOrEqual(21); + expect(weight.recent[0]).toHaveProperty("date"); + expect(weight.recent[0]).toHaveProperty("mean"); + expect(weight.recent[0]).toHaveProperty("min"); + expect(weight.recent[0]).toHaveProperty("max"); + expect(weight.monthly[0]).toHaveProperty("month"); + expect(weight.monthly[0]).toHaveProperty("mean"); + // The whole graded series collapses 1000 daily readings to a tiny + // bucket count. + const total = + weight.recent.length + + weight.weekly.length + + weight.monthly.length + + weight.yearly.length; + expect(total).toBeLessThanOrEqual(50); }); }); diff --git a/src/lib/insights/blood-pressure-status.ts b/src/lib/insights/blood-pressure-status.ts index 53e4afc40..f7dbbbd2c 100644 --- a/src/lib/insights/blood-pressure-status.ts +++ b/src/lib/insights/blood-pressure-status.ts @@ -27,6 +27,10 @@ import { dayOffsetToBerlinDayKey, type DailyBucket, } from "@/lib/insights/bucket-series"; +import { + buildGradedSeriesFromPoints, + degradeStatusSnapshotToBudget, +} from "@/lib/insights/graded-series"; import { stripChartTokens } from "@/lib/insights/chart-tokens"; import { runStatusCompletion } from "@/lib/insights/status-provider"; import { readFreshStatusText } from "@/lib/insights/status-cache"; @@ -36,6 +40,13 @@ import { toBerlinDayKey } from "@/lib/tz/resolver"; type SupportedLocale = "de" | "en"; +/** + * Cap on the embedded correlation / paired-daily arrays. The Pearson + * coefficients stay computed over the full overlap; only the row-by-row + * lists the prompt carries are trimmed to their most recent entries. + */ +const CORRELATION_PAIR_CAP = 30; + function round(value: number, digits = 1): number { const factor = 10 ** digits; return Math.round(value * factor) / factor; @@ -370,6 +381,21 @@ export async function generateBloodPressureStatusForUser( ); const moodMean = moodSummary?.mean ?? null; + // Compact graded series for the prompt. The `*Series.daily` buckets + // above stay for the correlation pairing + in-target gate; only the + // graded shape is embedded so the full daily arrays never ship. + const gradedFromDaily = (buckets: DailyBucket[]) => + buildGradedSeriesFromPoints( + buckets.map((b) => ({ + measuredAt: new Date(dayOffsetToBerlinDayKey(now, b.dayOffset)), + value: b.value, + })), + now, + ); + const sysGraded = gradedFromDaily(sysSeries.daily); + const diaGraded = gradedFromDaily(diaSeries.daily); + const moodGraded = gradedFromDaily(moodSeries.daily); + const continuityVsSystolicPairs: PairedPoint[] = continuityVsSystolicSeries .map((entry) => { if (entry.continuityPct == null) return null; @@ -419,13 +445,13 @@ export async function generateBloodPressureStatusForUser( summary: summarizeSeries( sysSeries.daily.map((bucket) => ({ value: bucket.value })), ), - series: sysSeries, + series: sysGraded, }, diastolic: { summary: summarizeSeries( diaSeries.daily.map((bucket) => ({ value: bucket.value })), ), - series: diaSeries, + series: diaGraded, }, paired: { summary: summarizeSeries( @@ -433,7 +459,7 @@ export async function generateBloodPressureStatusForUser( value: (entry.sys + entry.dia) / 2, })), ), - series: pairedBloodPressure, + series: pairedBloodPressure.slice(-CORRELATION_PAIR_CAP), }, targets: bpTargets ? { @@ -445,7 +471,7 @@ export async function generateBloodPressureStatusForUser( }, weightVsSystolic: { correlation: weightVsSystolicCorrelation, - pairs: weightVsSystolicPairs.map((entry) => ({ + pairs: weightVsSystolicPairs.slice(-CORRELATION_PAIR_CAP).map((entry) => ({ day: entry.dayKey, weight: round(entry.a, 2), systolic: round(entry.b, 2), @@ -455,7 +481,7 @@ export async function generateBloodPressureStatusForUser( expectedIntakesPerDay: expectedBpIntakesPerDay, medicationCount: bpMedications.length, correlation: continuityVsSystolicCorrelation, - series: continuityVsSystolicSeries, + series: continuityVsSystolicSeries.slice(-CORRELATION_PAIR_CAP), }, bpMedications: medicationCompliance, moodContext: @@ -464,7 +490,7 @@ export async function generateBloodPressureStatusForUser( points: moodSeries.daily.length, mean: moodMean, latest: moodSeries.daily[0]?.value ?? null, - series: moodSeries, + series: moodGraded, moodVsSystolicCorrelation: (() => { const moodVsSysPairs = pairDailyBuckets( moodSeries.daily, @@ -477,11 +503,17 @@ export async function generateBloodPressureStatusForUser( : null, }; + const shed = degradeStatusSnapshotToBudget( + snapshot as unknown as Record, + ); const snapshotJson = JSON.stringify(snapshot, null, 2); annotate({ action: { name: cacheAction }, - meta: { payload_size_bytes: snapshotJson.length }, + meta: { + payload_size_bytes: snapshotJson.length, + ...(shed.length > 0 ? { snapshot_shed: shed } : {}), + }, }); const previousContext = await getPreviousInsightContext( diff --git a/src/lib/insights/bmi-status.ts b/src/lib/insights/bmi-status.ts index 2b3c11bb2..b605dbc35 100644 --- a/src/lib/insights/bmi-status.ts +++ b/src/lib/insights/bmi-status.ts @@ -7,6 +7,10 @@ import { getPreviousInsightContext, } from "@/lib/insights/memory"; import { applyPayloadBudget } from "@/lib/insights/bucket-series"; +import { + buildGradedSeriesFromPoints, + degradeStatusSnapshotToBudget, +} from "@/lib/insights/graded-series"; import { stripChartTokens } from "@/lib/insights/chart-tokens"; import { runStatusCompletion } from "@/lib/insights/status-provider"; import { readFreshStatusText } from "@/lib/insights/status-cache"; @@ -130,26 +134,19 @@ export async function generateBmiStatusForUser( .then((rows) => rows.reverse()); const now = new Date(); - const weightSeries = applyPayloadBudget( - measurements.map((measurement) => ({ - measuredAt: measurement.measuredAt, - value: measurement.value, - })), - { now }, - ); - const heightFactor = (user.heightCm / 100) ** 2; + + const bmiPoints = measurements.map((measurement) => ({ + measuredAt: measurement.measuredAt, + value: round(measurement.value / heightFactor, 2), + })); + // `applyPayloadBudget` daily buckets drive the latest/previous focus; + // the compact graded series is what reaches the prompt. + const weightSeries = applyPayloadBudget(bmiPoints, { now }); + const bmiGraded = buildGradedSeriesFromPoints(bmiPoints, now); const bmiSeries = { - daily: weightSeries.daily.map((bucket) => ({ - dayOffset: bucket.dayOffset, - value: round(bucket.value / heightFactor, 2), - n: bucket.n, - })), - monthly: weightSeries.monthly.map((bucket) => ({ - monthOffset: bucket.monthOffset, - value: round(bucket.value / heightFactor, 2), - n: bucket.n, - })), + daily: weightSeries.daily, + monthly: weightSeries.monthly, }; // daily[0] = newest bucket (lowest dayOffset). @@ -189,7 +186,7 @@ export async function generateBmiStatusForUser( summary: summarizeSeries( bmiSeries.daily.map((bucket) => ({ value: bucket.value })), ), - series: bmiSeries, + series: bmiGraded, latestDayFocus: latestBmi ? { dayOffset: latestBmi.dayOffset, @@ -208,11 +205,17 @@ export async function generateBmiStatusForUser( }, }; + const shed = degradeStatusSnapshotToBudget( + snapshot as unknown as Record, + ); const snapshotJson = JSON.stringify(snapshot, null, 2); annotate({ action: { name: cacheAction }, - meta: { payload_size_bytes: snapshotJson.length }, + meta: { + payload_size_bytes: snapshotJson.length, + ...(shed.length > 0 ? { snapshot_shed: shed } : {}), + }, }); const previousContext = await getPreviousInsightContext( diff --git a/src/lib/insights/general-status.ts b/src/lib/insights/general-status.ts index 385b6d1ca..7989bbbbe 100644 --- a/src/lib/insights/general-status.ts +++ b/src/lib/insights/general-status.ts @@ -12,6 +12,10 @@ import { getPreviousInsightContext, } from "@/lib/insights/memory"; import { applyPayloadBudget } from "@/lib/insights/bucket-series"; +import { + buildGradedSeriesFromPoints, + degradeStatusSnapshotToBudget, +} from "@/lib/insights/graded-series"; import { stripChartTokens } from "@/lib/insights/chart-tokens"; import { runStatusCompletion } from "@/lib/insights/status-provider"; import { readFreshStatusText } from "@/lib/insights/status-cache"; @@ -130,25 +134,39 @@ export async function generateGeneralStatusForUser( const now = new Date(); + // `dailyByType` keeps the `applyPayloadBudget` daily buckets for the + // downstream BP in-target pairing; the prompt embeds the compact + // graded series instead of the full daily array. Skip types with no + // data so a many-metric account can't balloon the snapshot with empty + // series objects. + const dailyByType = new Map< + (typeof MEASUREMENT_TYPES)[number], + ReturnType + >(); const measurementSeries = Object.fromEntries( - MEASUREMENT_TYPES.map((type) => { + MEASUREMENT_TYPES.flatMap((type) => { const records = measurements .filter((measurement) => measurement.type === type) .map((measurement) => ({ measuredAt: measurement.measuredAt, value: measurement.value, })); + if (records.length === 0) return []; const series = applyPayloadBudget(records, { now }); + dailyByType.set(type, series); + const graded = buildGradedSeriesFromPoints(records, now); return [ - type, - { - summary: summarizeSeries( - series.daily.map((bucket) => ({ value: bucket.value })), - ), - series, - }, + [ + type, + { + summary: summarizeSeries( + series.daily.map((bucket) => ({ value: bucket.value })), + ), + series: graded, + }, + ] as const, ]; }), ); @@ -195,6 +213,7 @@ export async function generateGeneralStatusForUser( value: value.total > 0 ? round((value.taken / value.total) * 100, 1) : 0, })); const adherenceSeries = applyPayloadBudget(adherenceRecords, { now }); + const adherenceGraded = buildGradedSeriesFromPoints(adherenceRecords, now); // Fetch mood context (optional — for enrichment only). v1.4.28 // FB-D2 — cap at 90 entries. @@ -212,6 +231,7 @@ export async function generateGeneralStatusForUser( value: entry.score, })); const moodSeries = applyPayloadBudget(moodRecords, { now }); + const moodGraded = buildGradedSeriesFromPoints(moodRecords, now); const moodSummary = summarizeSeries( moodSeries.daily.map((bucket) => ({ value: bucket.value })), ); @@ -220,9 +240,9 @@ export async function generateGeneralStatusForUser( const bpTargets = getBpTargets(user?.dateOfBirth ?? null); let bpInTargetLast30Days: number | null = null; if (bpTargets) { - const sysDaily = measurementSeries.BLOOD_PRESSURE_SYS?.series.daily ?? []; + const sysDaily = dailyByType.get("BLOOD_PRESSURE_SYS")?.daily ?? []; const diaMap = new Map( - (measurementSeries.BLOOD_PRESSURE_DIA?.series.daily ?? []).map( + (dailyByType.get("BLOOD_PRESSURE_DIA")?.daily ?? []).map( (bucket) => [bucket.dayOffset, bucket.value] as const, ), ); @@ -285,7 +305,7 @@ export async function generateGeneralStatusForUser( summary: summarizeSeries( adherenceSeries.daily.map((bucket) => ({ value: bucket.value })), ), - series: adherenceSeries, + series: adherenceGraded, }, bloodPressureTargets: bpTargets ? { @@ -300,16 +320,22 @@ export async function generateGeneralStatusForUser( points: moodSeries.daily.length, mean: moodMean, latest: moodSeries.daily[0]?.value ?? null, - series: moodSeries, + series: moodGraded, } : null, }; + const shed = degradeStatusSnapshotToBudget( + snapshot as unknown as Record, + ); const snapshotJson = JSON.stringify(snapshot, null, 2); annotate({ action: { name: cacheAction }, - meta: { payload_size_bytes: snapshotJson.length }, + meta: { + payload_size_bytes: snapshotJson.length, + ...(shed.length > 0 ? { snapshot_shed: shed } : {}), + }, }); // v1.4: pull the previous cached general-status into the prompt so diff --git a/src/lib/insights/graded-series.ts b/src/lib/insights/graded-series.ts index 6638e9106..79b761c95 100644 --- a/src/lib/insights/graded-series.ts +++ b/src/lib/insights/graded-series.ts @@ -365,6 +365,97 @@ export async function buildGradedSeriesWithRollups( }; } +/** + * Assembled-snapshot soft char cap, mirroring the Coach's + * `MAX_SNAPSHOT_CHARS` (~24 000 chars ≈ ~6 000 tokens against the + * pretty-printed form). The status snapshots had no cap at all; a + * many-metric `general-status` could balloon past 100 K tokens. + */ +export const MAX_SNAPSHOT_CHARS = 24_000; + +/** + * Walk an arbitrary snapshot object and, while it exceeds + * `MAX_SNAPSHOT_CHARS` (measured against the pretty-printed form the + * prompt ships), shed the lowest-signal slices in order: + * 1. drop every graded `yearly` array, + * 2. drop every graded `weekly` array, + * 3. truncate any other array to its tail `arrayTailCap` entries + * (correlation pair arrays, paired-daily rows, …), + * 4. drop every graded `recent` array (last resort — the coarse + * monthly summary survives). + * + * Mutates `snapshot` in place and returns the slices it shed so the + * caller can annotate. Generic by design: it keys off the graded field + * names + array length, never off a specific generator's shape. + */ +export function degradeStatusSnapshotToBudget( + snapshot: Record, + arrayTailCap = 30, +): string[] { + const shed: string[] = []; + const size = () => JSON.stringify(snapshot, null, 2).length; + if (size() <= MAX_SNAPSHOT_CHARS) return shed; + + const walk = ( + node: unknown, + fn: (parent: Record, key: string, value: unknown) => void, + ): void => { + if (Array.isArray(node)) { + for (const item of node) walk(item, fn); + return; + } + if (node && typeof node === "object") { + const obj = node as Record; + for (const key of Object.keys(obj)) { + fn(obj, key, obj[key]); + walk(obj[key], fn); + } + } + }; + + const dropField = (field: string) => { + walk(snapshot, (parent, key) => { + if (key === field && Array.isArray(parent[key])) { + delete parent[key]; + } + }); + }; + + dropField("yearly"); + if (size() <= MAX_SNAPSHOT_CHARS) { + shed.push("yearly"); + return shed; + } + shed.push("yearly"); + + dropField("weekly"); + if (size() <= MAX_SNAPSHOT_CHARS) { + shed.push("weekly"); + return shed; + } + shed.push("weekly"); + + // Truncate every remaining non-graded array to its tail. + walk(snapshot, (parent, key, value) => { + if ( + Array.isArray(value) && + key !== "recent" && + value.length > arrayTailCap + ) { + parent[key] = value.slice(-arrayTailCap); + } + }); + if (size() <= MAX_SNAPSHOT_CHARS) { + shed.push("array-tails"); + return shed; + } + shed.push("array-tails"); + + dropField("recent"); + shed.push("recent"); + return shed; +} + /** * Headline summary across the whole graded series — min/max/mean/n — * for the snapshot's summary line. Composes the per-bucket stats; uses diff --git a/src/lib/insights/medication-compliance-status.ts b/src/lib/insights/medication-compliance-status.ts index eebc855ba..cc354ec26 100644 --- a/src/lib/insights/medication-compliance-status.ts +++ b/src/lib/insights/medication-compliance-status.ts @@ -17,6 +17,10 @@ import { getPreviousInsightContext, } from "@/lib/insights/memory"; import { applyPayloadBudget } from "@/lib/insights/bucket-series"; +import { + buildGradedSeriesFromPoints, + degradeStatusSnapshotToBudget, +} from "@/lib/insights/graded-series"; import { stripChartTokens } from "@/lib/insights/chart-tokens"; import { runStatusCompletion } from "@/lib/insights/status-provider"; import { isTimeoutStub } from "@/lib/insights/status-cache"; @@ -218,8 +222,11 @@ export async function generateMedicationComplianceStatusForUser( value: round(Math.min(100, (stats.taken / expectedPerDay) * 100), 1), })); - const dailySeries = applyPayloadBudget(perDayRecords, { now }); - const latestDay = dailySeries.daily[0] ?? null; + // `applyPayloadBudget` gives the latest-day focus; the compact + // graded series is what reaches the prompt. + const dailyBudgeted = applyPayloadBudget(perDayRecords, { now }); + const dailySeries = buildGradedSeriesFromPoints(perDayRecords, now); + const latestDay = dailyBudgeted.daily[0] ?? null; return { medicationId: medication.id, @@ -289,11 +296,17 @@ export async function generateMedicationComplianceStatusForUser( medications: medicationSnapshots, }; + const shed = degradeStatusSnapshotToBudget( + snapshot as unknown as Record, + ); const snapshotJson = JSON.stringify(snapshot, null, 2); annotate({ action: { name: cacheAction }, - meta: { payload_size_bytes: snapshotJson.length }, + meta: { + payload_size_bytes: snapshotJson.length, + ...(shed.length > 0 ? { snapshot_shed: shed } : {}), + }, }); const previousContext = await getPreviousInsightContext( diff --git a/src/lib/insights/mood-status.ts b/src/lib/insights/mood-status.ts index 2b8d44eed..cdfff7665 100644 --- a/src/lib/insights/mood-status.ts +++ b/src/lib/insights/mood-status.ts @@ -14,6 +14,10 @@ import { dayOffsetToBerlinDayKey, type DailyBucket, } from "@/lib/insights/bucket-series"; +import { + buildGradedSeriesFromPoints, + degradeStatusSnapshotToBudget, +} from "@/lib/insights/graded-series"; import { stripChartTokens } from "@/lib/insights/chart-tokens"; import { runStatusCompletion } from "@/lib/insights/status-provider"; import { readFreshStatusText } from "@/lib/insights/status-cache"; @@ -152,13 +156,14 @@ export async function generateMoodStatusForUser( const now = new Date(); - const moodSeries = applyPayloadBudget( - entries.map((entry) => ({ - measuredAt: entry.moodLoggedAt, - value: entry.score, - })), - { now }, - ); + const moodPoints = entries.map((entry) => ({ + measuredAt: entry.moodLoggedAt, + value: entry.score, + })); + // `applyPayloadBudget` daily buckets drive the in-target %, latest, + // and correlations; the compact graded series reaches the prompt. + const moodSeries = applyPayloadBudget(moodPoints, { now }); + const moodGraded = buildGradedSeriesFromPoints(moodPoints, now); const moodSummary = summarizeSeries( moodSeries.daily.map((bucket) => ({ value: bucket.value })), ); @@ -302,7 +307,7 @@ export async function generateMoodStatusForUser( }, mood: { summary: moodSummary, - series: moodSeries, + series: moodGraded, latestDayFocus: latestMood ? { dayOffset: latestMood.dayOffset, @@ -364,11 +369,17 @@ export async function generateMoodStatusForUser( : null, }; + const shed = degradeStatusSnapshotToBudget( + snapshot as unknown as Record, + ); const snapshotJson = JSON.stringify(snapshot, null, 2); annotate({ action: { name: cacheAction }, - meta: { payload_size_bytes: snapshotJson.length }, + meta: { + payload_size_bytes: snapshotJson.length, + ...(shed.length > 0 ? { snapshot_shed: shed } : {}), + }, }); const previousContext = await getPreviousInsightContext( diff --git a/src/lib/insights/pulse-status.ts b/src/lib/insights/pulse-status.ts index 7a5bf1caa..fdb0b584d 100644 --- a/src/lib/insights/pulse-status.ts +++ b/src/lib/insights/pulse-status.ts @@ -13,6 +13,10 @@ import { } from "@/lib/analytics/pulse-targets"; import { getNoKeyPulseStatusText } from "@/lib/insights/no-key-fallbacks"; import { applyPayloadBudget } from "@/lib/insights/bucket-series"; +import { + buildGradedSeriesFromPoints, + degradeStatusSnapshotToBudget, +} from "@/lib/insights/graded-series"; import { stripChartTokens } from "@/lib/insights/chart-tokens"; import { runStatusCompletion } from "@/lib/insights/status-provider"; import { readFreshStatusText } from "@/lib/insights/status-cache"; @@ -125,13 +129,15 @@ export async function generatePulseStatusForUser( const now = new Date(); - const pulseSeries = applyPayloadBudget( - measurements.map((measurement) => ({ - measuredAt: measurement.measuredAt, - value: measurement.value, - })), - { now }, - ); + const pulsePoints = measurements.map((measurement) => ({ + measuredAt: measurement.measuredAt, + value: measurement.value, + })); + // `applyPayloadBudget` daily buckets still drive the derived stats + // below (latest, in-target %, delta). They are NOT embedded in the + // prompt — the compact graded series replaces the full daily array. + const pulseSeries = applyPayloadBudget(pulsePoints, { now }); + const pulseGraded = buildGradedSeriesFromPoints(pulsePoints, now); const pulseSummary = summarizeSeries( pulseSeries.daily.map((bucket) => ({ value: bucket.value })), ); @@ -148,13 +154,12 @@ export async function generatePulseStatusForUser( }) .then((rows) => rows.reverse()); - const moodSeries = applyPayloadBudget( - moodEntries.map((entry) => ({ - measuredAt: entry.moodLoggedAt, - value: entry.score, - })), - { now }, - ); + const moodPoints = moodEntries.map((entry) => ({ + measuredAt: entry.moodLoggedAt, + value: entry.score, + })); + const moodSeries = applyPayloadBudget(moodPoints, { now }); + const moodGraded = buildGradedSeriesFromPoints(moodPoints, now); const moodSummary = summarizeSeries( moodSeries.daily.map((bucket) => ({ value: bucket.value })), ); @@ -218,7 +223,7 @@ export async function generatePulseStatusForUser( }, pulse: { summary: pulseSummary, - series: pulseSeries, + series: pulseGraded, latestDayFocus: latestPulse ? { dayOffset: latestPulse.dayOffset, @@ -243,16 +248,22 @@ export async function generatePulseStatusForUser( points: moodSeries.daily.length, mean: moodMean, latest: moodSeries.daily[0]?.value ?? null, - series: moodSeries, + series: moodGraded, } : null, }; + const shed = degradeStatusSnapshotToBudget( + snapshot as unknown as Record, + ); const snapshotJson = JSON.stringify(snapshot, null, 2); annotate({ action: { name: cacheAction }, - meta: { payload_size_bytes: snapshotJson.length }, + meta: { + payload_size_bytes: snapshotJson.length, + ...(shed.length > 0 ? { snapshot_shed: shed } : {}), + }, }); const previousContext = await getPreviousInsightContext( diff --git a/src/lib/insights/weight-status.ts b/src/lib/insights/weight-status.ts index c4f718452..74454a95d 100644 --- a/src/lib/insights/weight-status.ts +++ b/src/lib/insights/weight-status.ts @@ -17,6 +17,10 @@ import { dayOffsetToBerlinDayKey, type DailyBucket, } from "@/lib/insights/bucket-series"; +import { + buildGradedSeriesFromPoints, + degradeStatusSnapshotToBudget, +} from "@/lib/insights/graded-series"; import { stripChartTokens } from "@/lib/insights/chart-tokens"; import { runStatusCompletion } from "@/lib/insights/status-provider"; import { readFreshStatusText } from "@/lib/insights/status-cache"; @@ -26,6 +30,13 @@ import { toBerlinDayKey } from "@/lib/tz/resolver"; type SupportedLocale = "de" | "en"; +/** + * Cap on the embedded correlation pair arrays. The Pearson coefficient + * is still computed over the full overlap; only the row-by-row pair + * list the prompt carries is trimmed to its most recent entries. + */ +const CORRELATION_PAIR_CAP = 30; + function round(value: number, digits = 1): number { const factor = 10 ** digits; return Math.round(value * factor) / factor; @@ -212,6 +223,38 @@ export async function generateWeightStatusForUser( ); const moodMean = moodSummary?.mean ?? null; + // Compact graded series for the prompt. The `*Series.daily` buckets + // above stay for the correlation pairing + latest-day focus; only the + // graded shape is embedded so the full daily arrays never ship. + const weightGraded = buildGradedSeriesFromPoints( + weightSeries.daily.map((b) => ({ + measuredAt: new Date(dayOffsetToBerlinDayKey(now, b.dayOffset)), + value: b.value, + })), + now, + ); + const sysGraded = buildGradedSeriesFromPoints( + sysSeries.daily.map((b) => ({ + measuredAt: new Date(dayOffsetToBerlinDayKey(now, b.dayOffset)), + value: b.value, + })), + now, + ); + const diaGraded = buildGradedSeriesFromPoints( + diaSeries.daily.map((b) => ({ + measuredAt: new Date(dayOffsetToBerlinDayKey(now, b.dayOffset)), + value: b.value, + })), + now, + ); + const moodGraded = buildGradedSeriesFromPoints( + moodSeries.daily.map((b) => ({ + measuredAt: new Date(dayOffsetToBerlinDayKey(now, b.dayOffset)), + value: b.value, + })), + now, + ); + const weightVsSystolicPairs = pairDailyBuckets( weightSeries.daily, sysSeries.daily, @@ -296,7 +339,7 @@ export async function generateWeightStatusForUser( summary: summarizeSeries( weightSeries.daily.map((bucket) => ({ value: bucket.value })), ), - series: weightSeries, + series: weightGraded, latestDayFocus: latestWeight ? { day: latestWeightDay, @@ -315,19 +358,22 @@ export async function generateWeightStatusForUser( summary: summarizeSeries( sysSeries.daily.map((bucket) => ({ value: bucket.value })), ), - series: sysSeries, + series: sysGraded, }, diastolic: { summary: summarizeSeries( diaSeries.daily.map((bucket) => ({ value: bucket.value })), ), - series: diaSeries, + series: diaGraded, }, - pairedDaily: pairedSystolicDiastolic, + // Keep the correlation coefficients (computed over the full + // overlap) but cap the embedded pair arrays to the most recent + // paired days — the raw pair lists were a major payload driver. + pairedDaily: pairedSystolicDiastolic.slice(-CORRELATION_PAIR_CAP), }, weightVsSystolic: { correlation: weightVsSystolicCorrelation, - pairs: weightVsSystolicPairs.map((entry) => ({ + pairs: weightVsSystolicPairs.slice(-CORRELATION_PAIR_CAP).map((entry) => ({ day: entry.dayKey, weight: round(entry.a, 2), systolic: round(entry.b, 2), @@ -335,7 +381,7 @@ export async function generateWeightStatusForUser( }, weightVsMeanBloodPressure: { correlation: weightVsMeanBpCorrelation, - pairs: weightVsMeanBpPairs.map((entry) => ({ + pairs: weightVsMeanBpPairs.slice(-CORRELATION_PAIR_CAP).map((entry) => ({ day: entry.dayKey, weight: round(entry.a, 2), meanBloodPressure: round(entry.b, 2), @@ -347,7 +393,7 @@ export async function generateWeightStatusForUser( points: moodSeries.daily.length, mean: moodMean, latest: moodSeries.daily[0]?.value ?? null, - series: moodSeries, + series: moodGraded, moodVsWeightCorrelation: (() => { const moodVsWeightPairs = pairDailyBuckets( moodSeries.daily, @@ -360,11 +406,17 @@ export async function generateWeightStatusForUser( : null, }; + const shed = degradeStatusSnapshotToBudget( + snapshot as unknown as Record, + ); const snapshotJson = JSON.stringify(snapshot, null, 2); annotate({ action: { name: cacheAction }, - meta: { payload_size_bytes: snapshotJson.length }, + meta: { + payload_size_bytes: snapshotJson.length, + ...(shed.length > 0 ? { snapshot_shed: shed } : {}), + }, }); const previousContext = await getPreviousInsightContext(