perf(insights): compress status snapshots via graded series (raw->week->month->year)

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.
This commit is contained in:
Marc-André Bombeck committed 2026-05-31 21:51:18 +02:00
1 parent 1b78df345d
commit 11a06196f3
15 files changed
+448 -158

No files matched your search

@@ -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);
});
});
+15 -11
View File
@@ -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);
});
});
@@ -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"]);
});
});
@@ -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");
});
});
+15 -11
View File
@@ -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);
});
});
+15 -11
View File
@@ -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);
});
});
@@ -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);
});
});
+39 -7
View File
@@ -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<string, unknown>,
);
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(
+23 -20
View File
@@ -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<string, unknown>,
);
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(
+39 -13
View File
@@ -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<typeof applyPayloadBudget>
>();
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<string, unknown>,
);
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
+91
View File
@@ -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<string, unknown>,
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<string, unknown>, 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<string, unknown>;
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
@@ -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<string, unknown>,
);
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(
+20 -9
View File
@@ -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<string, unknown>,
);
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(
+28 -17
View File
@@ -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<string, unknown>,
);
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(
+60 -8
View File
@@ -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<string, unknown>,
);
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(