diff --git a/src/lib/insights/__tests__/blood-pressure-status.test.ts b/src/lib/insights/__tests__/blood-pressure-status.test.ts index 2a4085c6e..ee6263f12 100644 --- a/src/lib/insights/__tests__/blood-pressure-status.test.ts +++ b/src/lib/insights/__tests__/blood-pressure-status.test.ts @@ -5,6 +5,7 @@ vi.mock("@/lib/db", () => ({ user: { findUnique: vi.fn() }, auditLog: { findFirst: vi.fn(), create: vi.fn() }, measurement: { findMany: vi.fn() }, + measurementRollup: { findMany: vi.fn() }, medication: { findMany: vi.fn() }, medicationIntakeEvent: { findMany: vi.fn() }, moodEntry: { findMany: vi.fn() }, @@ -52,6 +53,9 @@ function stubCompletion( beforeEach(() => { vi.resetAllMocks(); vi.mocked(getMedicationCategories).mockResolvedValue({}); + // Cold rollup tier: the BP channels fold monthly/yearly from the + // full-history `measurement.findMany` fallback on a tier miss. + vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue([] as never); }); describe("generateBloodPressureStatusForUser — graded payload", () => { diff --git a/src/lib/insights/__tests__/bmi-status.test.ts b/src/lib/insights/__tests__/bmi-status.test.ts index bb9a6ae53..9b23a6811 100644 --- a/src/lib/insights/__tests__/bmi-status.test.ts +++ b/src/lib/insights/__tests__/bmi-status.test.ts @@ -5,6 +5,7 @@ vi.mock("@/lib/db", () => ({ user: { findUnique: vi.fn() }, auditLog: { findFirst: vi.fn(), create: vi.fn() }, measurement: { findMany: vi.fn() }, + measurementRollup: { findMany: vi.fn() }, }, })); @@ -44,6 +45,9 @@ function stubCompletion( beforeEach(() => { vi.resetAllMocks(); + // Cold rollup tier: the BMI graded series scales the WEIGHT tier, which + // folds from the full-history `measurement.findMany` fallback on a miss. + vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue([] as never); }); describe("generateBmiStatusForUser — graded payload", () => { diff --git a/src/lib/insights/__tests__/graded-series-rollups.test.ts b/src/lib/insights/__tests__/graded-series-rollups.test.ts new file mode 100644 index 000000000..37009d705 --- /dev/null +++ b/src/lib/insights/__tests__/graded-series-rollups.test.ts @@ -0,0 +1,131 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const findMany = vi.fn(); +vi.mock("@/lib/db", () => ({ + prisma: { + measurement: { + findMany: (...args: unknown[]) => findMany(...args), + }, + }, +})); + +const readBestGranularityRollups = vi.fn(); +const rollupRow = (bucketStart: Date, mean: number) => ({ + bucketStart, + count: 30, + mean, + sd: null, + slope: null, + r2: null, + sumValue: null, + minValue: mean - 1, + maxValue: mean + 1, +}); +vi.mock("@/lib/rollups/measurement-read-wmy", () => ({ + readBestGranularityRollups: (...args: unknown[]) => + readBestGranularityRollups(...args), + // aggregateWmyBuckets is no longer imported by graded-series, but keep the + // module shape intact for any transitive consumer. + aggregateWmyBuckets: vi.fn(), +})); + +import { buildGradedSeriesWithRollups } from "../graded-series"; + +const dayMs = 24 * 60 * 60 * 1000; + +/** Daily readings spanning `days` back from `now`. */ +function dailyRows(days: number, now: Date) { + const rows: Array<{ measuredAt: Date; value: number }> = []; + for (let i = 0; i < days; i++) { + rows.push({ measuredAt: new Date(now.getTime() - i * dayMs), value: 80 }); + } + return rows; +} + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe("buildGradedSeriesWithRollups — rollup-miss fallback", () => { + const now = new Date("2026-05-31T12:00:00Z"); + + it("folds monthly/yearly from the full history when the tier has no coverage", async () => { + // The bounded recent read (~90 d) returns only recent points; the + // full-history read returns 2+ years so the fold has monthly/yearly. + findMany + // 1st call — bounded recent read (gte: since) + .mockResolvedValueOnce(dailyRows(80, now)) + // 2nd call — full-history fallback read (no gte) + .mockResolvedValueOnce(dailyRows(800, now)); + + // Tier miss: no MONTH / YEAR coverage at all. + readBestGranularityRollups.mockResolvedValue(null); + + const series = await buildGradedSeriesWithRollups("u1", "WEIGHT", now); + + // The bug: before the fix monthly/yearly were derived from the + // bounded ~90-day read and so were always empty even when years of + // raw history existed. After the fix they fold from the full history. + expect(series.monthly.length).toBeGreaterThan(0); + // 800 days of history puts at least one year into the yearly tail. + expect(series.yearly.length).toBeGreaterThan(0); + // The full-history fallback read must have been issued. + expect(findMany).toHaveBeenCalledTimes(2); + }); + + it("reads monthly/yearly from the tier when it has coverage and skips the full read", async () => { + findMany.mockResolvedValueOnce(dailyRows(80, now)); + + // MONTH coverage for the 1-year window, YEAR coverage for the 3-year. + readBestGranularityRollups.mockImplementation( + async (_u: string, _t: string, windowDays: number) => { + if (windowDays === 365) { + return { + granularity: "MONTH", + rows: [ + rollupRow(new Date("2025-09-01T00:00:00Z"), 79), + rollupRow(new Date("2025-10-01T00:00:00Z"), 80), + ], + }; + } + return { + granularity: "YEAR", + rows: [rollupRow(new Date("2023-01-01T00:00:00Z"), 82)], + }; + }, + ); + + const series = await buildGradedSeriesWithRollups("u1", "WEIGHT", now); + + expect(series.monthly.length).toBeGreaterThan(0); + expect(series.yearly.length).toBeGreaterThan(0); + // Only the bounded recent read — no full-history fallback when the + // tier covers both coarse slices. + expect(findMany).toHaveBeenCalledTimes(1); + }); + + it("falls back to a full read for only the slice the tier misses", async () => { + findMany + .mockResolvedValueOnce(dailyRows(80, now)) // bounded recent + .mockResolvedValueOnce(dailyRows(800, now)); // full-history fallback + + // MONTH covered, YEAR missing. + readBestGranularityRollups.mockImplementation( + async (_u: string, _t: string, windowDays: number) => { + if (windowDays === 365) { + return { + granularity: "MONTH", + rows: [rollupRow(new Date("2025-10-01T00:00:00Z"), 80)], + }; + } + return null; + }, + ); + + const series = await buildGradedSeriesWithRollups("u1", "WEIGHT", now); + + expect(series.monthly.length).toBeGreaterThan(0); + expect(series.yearly.length).toBeGreaterThan(0); + expect(findMany).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/lib/insights/__tests__/medication-compliance-status.test.ts b/src/lib/insights/__tests__/medication-compliance-status.test.ts index 6cfc875b8..fd3d19e16 100644 --- a/src/lib/insights/__tests__/medication-compliance-status.test.ts +++ b/src/lib/insights/__tests__/medication-compliance-status.test.ts @@ -198,7 +198,7 @@ describe("generateMedicationComplianceStatusForUser — cache-read skips a stub" }); describe("generateMedicationComplianceStatusForUser — token-leak hardening (v1.4.27 F16)", () => { - it("strips metric: tokens out of the cached summary + per-medication text", async () => { + it("strips metric: tokens out of the cached summary", async () => { const now = new Date(); vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); @@ -212,17 +212,13 @@ describe("generateMedicationComplianceStatusForUser — token-leak hardening (v1 createdAt: new Date(), } as never); + // The compliance prompt returns only a `{ summary }` envelope; the + // per-medication cards are placeholder text built server-side, never + // model-authored. So token-leak hardening only has to scrub `summary`. stubCompletion( JSON.stringify({ summary: "Compliance held steady at 96%. metric:BLOOD_PRESSURE_SYS over the last 30 days.", - medications: [ - { - medicationId: "med-1", - summary: - "Ramipril taken on schedule. metric:PULSE no missed doses in 30 days.", - }, - ], }), ); @@ -232,6 +228,9 @@ describe("generateMedicationComplianceStatusForUser — token-leak hardening (v1 expect(result.summary).toBeTruthy(); expect(result.summary).not.toContain("metric:"); + // A per-medication placeholder row is still surfaced for the active med. + expect(result.medications).toHaveLength(1); + expect(result.medications[0].medicationId).toBe("med-1"); expect(result.medications[0].text).not.toContain("metric:"); const createCalls = vi.mocked(prisma.auditLog.create).mock.calls; expect(createCalls.length).toBeGreaterThan(0); diff --git a/src/lib/insights/__tests__/payload-budget.test.ts b/src/lib/insights/__tests__/payload-budget.test.ts index 000d9a20b..7557eb04c 100644 --- a/src/lib/insights/__tests__/payload-budget.test.ts +++ b/src/lib/insights/__tests__/payload-budget.test.ts @@ -17,6 +17,7 @@ vi.mock("@/lib/db", () => ({ user: { findUnique: vi.fn() }, auditLog: { findFirst: vi.fn(), create: vi.fn() }, measurement: { findMany: vi.fn() }, + measurementRollup: { findMany: vi.fn() }, medicationIntakeEvent: { findMany: vi.fn() }, moodEntry: { findMany: vi.fn() }, }, @@ -72,6 +73,7 @@ beforeEach(() => { vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( [] as never, ); + vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue([] as never); vi.mocked(prisma.user.findUnique).mockResolvedValue({ dateOfBirth: null, gender: null, diff --git a/src/lib/insights/__tests__/pulse-status-timeout.test.ts b/src/lib/insights/__tests__/pulse-status-timeout.test.ts index 127f7e623..4fad2c3c9 100644 --- a/src/lib/insights/__tests__/pulse-status-timeout.test.ts +++ b/src/lib/insights/__tests__/pulse-status-timeout.test.ts @@ -17,6 +17,7 @@ vi.mock("@/lib/db", () => ({ user: { findUnique: vi.fn() }, auditLog: { findFirst: vi.fn(), create: vi.fn() }, measurement: { findMany: vi.fn() }, + measurementRollup: { findMany: vi.fn() }, moodEntry: { findMany: vi.fn() }, }, })); @@ -36,6 +37,7 @@ import { generatePulseStatusForUser } from "../pulse-status"; beforeEach(() => { vi.resetAllMocks(); + vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue([] as never); }); describe("generatePulseStatusForUser — provider timeout fallback", () => { diff --git a/src/lib/insights/__tests__/pulse-status.test.ts b/src/lib/insights/__tests__/pulse-status.test.ts index 8077133b4..96a90b9bb 100644 --- a/src/lib/insights/__tests__/pulse-status.test.ts +++ b/src/lib/insights/__tests__/pulse-status.test.ts @@ -5,6 +5,7 @@ vi.mock("@/lib/db", () => ({ user: { findUnique: vi.fn() }, auditLog: { findFirst: vi.fn(), create: vi.fn() }, measurement: { findMany: vi.fn() }, + measurementRollup: { findMany: vi.fn() }, moodEntry: { findMany: vi.fn() }, }, })); @@ -44,6 +45,9 @@ function stubCompletion( beforeEach(() => { vi.resetAllMocks(); + // Cold rollup tier: the pulse graded series folds monthly/yearly from + // the full-history `measurement.findMany` fallback on a tier miss. + vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue([] as never); }); describe("generatePulseStatusForUser — graded payload", () => { diff --git a/src/lib/insights/__tests__/weight-status.test.ts b/src/lib/insights/__tests__/weight-status.test.ts index 4582be077..cff1f31b4 100644 --- a/src/lib/insights/__tests__/weight-status.test.ts +++ b/src/lib/insights/__tests__/weight-status.test.ts @@ -4,6 +4,7 @@ vi.mock("@/lib/db", () => ({ prisma: { auditLog: { findFirst: vi.fn(), create: vi.fn() }, measurement: { findMany: vi.fn() }, + measurementRollup: { findMany: vi.fn() }, moodEntry: { findMany: vi.fn() }, }, })); @@ -43,6 +44,9 @@ function stubCompletion( beforeEach(() => { vi.resetAllMocks(); + // Cold rollup tier: the graded builder folds monthly/yearly from the + // full-history `measurement.findMany` fallback the test already mocks. + vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue([] as never); }); describe("generateWeightStatusForUser — graded payload", () => { diff --git a/src/lib/insights/blood-pressure-status.ts b/src/lib/insights/blood-pressure-status.ts index f7dbbbd2c..a2257b251 100644 --- a/src/lib/insights/blood-pressure-status.ts +++ b/src/lib/insights/blood-pressure-status.ts @@ -29,17 +29,24 @@ import { } from "@/lib/insights/bucket-series"; import { buildGradedSeriesFromPoints, + buildGradedSeriesWithRollups, degradeStatusSnapshotToBudget, } from "@/lib/insights/graded-series"; -import { stripChartTokens } from "@/lib/insights/chart-tokens"; +import { + type SupportedLocale, + normalizeLocale, + normalizeSummaryText, + parseSummaryFromContent, + persistStatusInsight, + round, + summarizeSeries, +} from "@/lib/insights/status-shared"; import { runStatusCompletion } from "@/lib/insights/status-provider"; import { readFreshStatusText } from "@/lib/insights/status-cache"; import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback"; import { annotate } from "@/lib/logging/context"; 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 @@ -47,49 +54,6 @@ type SupportedLocale = "de" | "en"; */ const CORRELATION_PAIR_CAP = 30; -function round(value: number, digits = 1): number { - const factor = 10 ** digits; - return Math.round(value * factor) / factor; -} - -function normalizeSummaryText(value: string): string { - return stripChartTokens(value).replace(/\s+/g, " ").trim(); -} - -function normalizeLocale(value: string | null | undefined): SupportedLocale { - return value === "en" ? "en" : "de"; -} - -function summarizeSeries(series: Array<{ value: number }>) { - if (series.length === 0) return null; - const first = series[0].value; - const last = series[series.length - 1].value; - // v1.4.33 — fold sum/min/max into a single walk. The previous - // `Math.min(...series.map(...))` / `Math.max(...series.map(...))` - // spread tripped V8's ~125 000-arg ceiling on the bound /api/analytics - // path; see `.planning/round-v1433-analytics-500-report.md` §"Carry- - // over". These helpers are fed bounded windows today so the crash - // never reached them, but the spread allocates a transient args array - // on every call — the fold is both stack-safe and cheaper. - let sum = 0; - let minVal = series[0].value; - let maxVal = series[0].value; - for (const entry of series) { - sum += entry.value; - if (entry.value < minVal) minVal = entry.value; - if (entry.value > maxVal) maxVal = entry.value; - } - return { - points: series.length, - start: round(first, 2), - end: round(last, 2), - delta: round(last - first, 2), - mean: round(sum / series.length, 2), - min: round(minVal, 2), - max: round(maxVal, 2), - }; -} - /** * Pair two daily-bucket series on `dayOffset`. The synthesised `date` * field is anchored at the UTC midnight of the Berlin calendar day — @@ -392,8 +356,14 @@ export async function generateBloodPressureStatusForUser( })), now, ); - const sysGraded = gradedFromDaily(sysSeries.daily); - const diaGraded = gradedFromDaily(diaSeries.daily); + // The two BP channels source their recent / weekly slices from a + // bounded raw read and their monthly / yearly tail from the MONTH / + // YEAR rollup tier (full-history in-memory fallback on a cold-tier + // coverage miss). Mood has no rollup tier, so it stays an in-memory fold. + const [sysGraded, diaGraded] = await Promise.all([ + buildGradedSeriesWithRollups(userId, "BLOOD_PRESSURE_SYS", now), + buildGradedSeriesWithRollups(userId, "BLOOD_PRESSURE_DIA", now), + ]); const moodGraded = gradedFromDaily(moodSeries.daily); const continuityVsSystolicPairs: PairedPoint[] = continuityVsSystolicSeries @@ -557,46 +527,29 @@ export async function generateBloodPressureStatusForUser( }); } - let summary = ""; - try { - const parsed = JSON.parse(outcome.content) as { summary?: string }; - if (typeof parsed.summary === "string") { - summary = parsed.summary; - } else { - summary = outcome.content; - } - } catch { - summary = outcome.content; - } - - summary = normalizeSummaryText(summary); + const summary = normalizeSummaryText(parseSummaryFromContent(outcome.content)); if (!summary) { throw new Error( "Blood-pressure-status summary was empty after normalization", ); } - const created = await prisma.auditLog.create({ - data: { - userId, - action: cacheAction, - details: JSON.stringify({ - dateKey: todayKey, - locale, - text: summary, - providerType: outcome.providerType, - model: outcome.model, - tokensUsed: outcome.tokensUsed, - }), - }, - select: { createdAt: true }, + const updatedAt = await persistStatusInsight({ + userId, + cacheAction, + todayKey, + locale, + text: summary, + providerType: outcome.providerType, + model: outcome.model, + tokensUsed: outcome.tokensUsed, }); return { hasProvider: true, text: summary, cached: false, - updatedAt: created.createdAt.toISOString(), + updatedAt, }; } diff --git a/src/lib/insights/bmi-status.ts b/src/lib/insights/bmi-status.ts index b605dbc35..03be6fcf2 100644 --- a/src/lib/insights/bmi-status.ts +++ b/src/lib/insights/bmi-status.ts @@ -8,62 +8,25 @@ import { } from "@/lib/insights/memory"; import { applyPayloadBudget } from "@/lib/insights/bucket-series"; import { - buildGradedSeriesFromPoints, + buildGradedSeriesWithRollups, + scaleGradedSeries, degradeStatusSnapshotToBudget, } from "@/lib/insights/graded-series"; -import { stripChartTokens } from "@/lib/insights/chart-tokens"; +import { + type SupportedLocale, + normalizeLocale, + normalizeSummaryText, + parseSummaryFromContent, + persistStatusInsight, + round, + summarizeSeries, +} from "@/lib/insights/status-shared"; import { runStatusCompletion } from "@/lib/insights/status-provider"; import { readFreshStatusText } from "@/lib/insights/status-cache"; import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback"; import { annotate } from "@/lib/logging/context"; import { toBerlinDayKey } from "@/lib/tz/resolver"; -type SupportedLocale = "de" | "en"; - -function round(value: number, digits = 1): number { - const factor = 10 ** digits; - return Math.round(value * factor) / factor; -} - -function normalizeSummaryText(value: string): string { - return stripChartTokens(value).replace(/\s+/g, " ").trim(); -} - -function normalizeLocale(value: string | null | undefined): SupportedLocale { - return value === "en" ? "en" : "de"; -} - -function summarizeSeries(series: Array<{ value: number }>) { - if (series.length === 0) return null; - const first = series[0].value; - const last = series[series.length - 1].value; - // v1.4.33 — fold sum/min/max into a single walk. The previous - // `Math.min(...series.map(...))` / `Math.max(...series.map(...))` - // spread tripped V8's ~125 000-arg ceiling on the bound /api/analytics - // path and is the same anti-pattern fixed in `summarize()` (see - // `.planning/round-v1433-analytics-500-report.md` §"Carry-over"). - // These helpers feed off bounded windows today so they did not crash, - // but the spread allocates a transient args array on every call; - // folding once is both stack-safe and cheaper. - let sum = 0; - let minVal = series[0].value; - let maxVal = series[0].value; - for (const entry of series) { - sum += entry.value; - if (entry.value < minVal) minVal = entry.value; - if (entry.value > maxVal) maxVal = entry.value; - } - return { - points: series.length, - start: round(first, 2), - end: round(last, 2), - delta: round(last - first, 2), - mean: round(sum / series.length, 2), - min: round(minVal, 2), - max: round(maxVal, 2), - }; -} - export async function generateBmiStatusForUser( userId: string, options?: { @@ -143,7 +106,16 @@ export async function generateBmiStatusForUser( // `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); + // BMI has no rollup tier of its own — it is weight ÷ height², a linear + // transform by the per-user height factor. Read the WEIGHT tier (recent + // / weekly from a bounded raw read, monthly / yearly from MONTH / YEAR) + // and scale by 1 / heightFactor; the scaled series is exact. + const weightGraded = await buildGradedSeriesWithRollups( + userId, + "WEIGHT", + now, + ); + const bmiGraded = scaleGradedSeries(weightGraded, 1 / heightFactor, 2); const bmiSeries = { daily: weightSeries.daily, monthly: weightSeries.monthly, @@ -259,44 +231,27 @@ export async function generateBmiStatusForUser( }); } - let summary = ""; - try { - const parsed = JSON.parse(outcome.content) as { summary?: string }; - if (typeof parsed.summary === "string") { - summary = parsed.summary; - } else { - summary = outcome.content; - } - } catch { - summary = outcome.content; - } - - summary = normalizeSummaryText(summary); + const summary = normalizeSummaryText(parseSummaryFromContent(outcome.content)); if (!summary) { throw new Error("Bmi-status summary was empty after normalization"); } - const created = await prisma.auditLog.create({ - data: { - userId, - action: cacheAction, - details: JSON.stringify({ - dateKey: todayKey, - locale, - text: summary, - providerType: outcome.providerType, - model: outcome.model, - tokensUsed: outcome.tokensUsed, - }), - }, - select: { createdAt: true }, + const updatedAt = await persistStatusInsight({ + userId, + cacheAction, + todayKey, + locale, + text: summary, + providerType: outcome.providerType, + model: outcome.model, + tokensUsed: outcome.tokensUsed, }); return { hasProvider: true, text: summary, cached: false, - updatedAt: created.createdAt.toISOString(), + updatedAt, }; } diff --git a/src/lib/insights/general-status.ts b/src/lib/insights/general-status.ts index 7989bbbbe..2fd89efb3 100644 --- a/src/lib/insights/general-status.ts +++ b/src/lib/insights/general-status.ts @@ -16,61 +16,31 @@ import { buildGradedSeriesFromPoints, degradeStatusSnapshotToBudget, } from "@/lib/insights/graded-series"; -import { stripChartTokens } from "@/lib/insights/chart-tokens"; +import { + normalizeLocale, + normalizeSummaryText, + parseSummaryFromContent, + persistStatusInsight, + round, + summarizeSeries, +} from "@/lib/insights/status-shared"; import { runStatusCompletion } from "@/lib/insights/status-provider"; import { readFreshStatusText } from "@/lib/insights/status-cache"; import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback"; import { annotate } from "@/lib/logging/context"; import { toBerlinDayKey } from "@/lib/tz/resolver"; -type SupportedLocale = "de" | "en"; - // Derived from canonical enum so a new measurement type is auto-included // in the AI general-status fetch (V3 audit: enum drift cousins). const MEASUREMENT_TYPES = measurementTypeEnum.options; -function round(value: number, digits = 1): number { - const factor = 10 ** digits; - return Math.round(value * factor) / factor; -} - -function normalizeSummaryText(value: string): string { - return stripChartTokens(value).replace(/\s+/g, " ").trim(); -} - -function summarizeSeries(series: Array<{ value: number }>) { - if (series.length === 0) return null; - const first = series[0].value; - const last = series[series.length - 1].value; - // v1.4.33 — fold sum/min/max into a single walk. The previous - // `Math.min(...series.map(...))` / `Math.max(...series.map(...))` - // spread tripped V8's ~125 000-arg ceiling on the bound /api/analytics - // path; see `.planning/round-v1433-analytics-500-report.md` §"Carry- - // over". These helpers are fed bounded windows today so the crash - // never reached them, but the spread allocates a transient args array - // on every call — the fold is both stack-safe and cheaper. - let sum = 0; - let minVal = series[0].value; - let maxVal = series[0].value; - for (const entry of series) { - sum += entry.value; - if (entry.value < minVal) minVal = entry.value; - if (entry.value > maxVal) maxVal = entry.value; - } - return { - points: series.length, - start: round(first, 2), - end: round(last, 2), - delta: round(last - first, 2), - mean: round(sum / series.length, 2), - min: round(minVal, 2), - max: round(maxVal, 2), - }; -} - -function normalizeLocale(value: string | null | undefined): SupportedLocale { - return value === "en" ? "en" : "de"; -} +// The general overview folds its per-type graded series in memory from a +// single bounded read (take 5000, desc) rather than per-type rollup-tier +// reads: a many-metric account would otherwise fan out ~15 metrics × 3 +// rollup round-trips per render. `degradeStatusSnapshotToBudget` already +// caps the multi-metric snapshot, so the bounded in-memory fold is the +// deliberate design here; the rollup tier is wired into the focused +// single-metric cards (weight / pulse / bmi / blood-pressure) instead. export async function generateGeneralStatusForUser( userId: string, @@ -382,44 +352,27 @@ export async function generateGeneralStatusForUser( }); } - let summary = ""; - try { - const parsed = JSON.parse(outcome.content) as { summary?: string }; - if (typeof parsed.summary === "string") { - summary = parsed.summary; - } else { - summary = outcome.content; - } - } catch { - summary = outcome.content; - } - - summary = normalizeSummaryText(summary); + const summary = normalizeSummaryText(parseSummaryFromContent(outcome.content)); if (!summary) { throw new Error("General-status summary was empty after normalization"); } - const created = await prisma.auditLog.create({ - data: { - userId, - action: cacheAction, - details: JSON.stringify({ - dateKey: todayKey, - locale, - text: summary, - providerType: outcome.providerType, - model: outcome.model, - tokensUsed: outcome.tokensUsed, - }), - }, - select: { createdAt: true }, + const updatedAt = await persistStatusInsight({ + userId, + cacheAction, + todayKey, + locale, + text: summary, + providerType: outcome.providerType, + model: outcome.model, + tokensUsed: outcome.tokensUsed, }); return { hasProvider: true, text: summary, cached: false, - updatedAt: created.createdAt.toISOString(), + updatedAt, }; } diff --git a/src/lib/insights/graded-series.ts b/src/lib/insights/graded-series.ts index 79b761c95..ff97961cd 100644 --- a/src/lib/insights/graded-series.ts +++ b/src/lib/insights/graded-series.ts @@ -4,13 +4,20 @@ * Background — see `.planning/research/ai-payload-compression-audit.md`. * The seven per-card status generators embedded the FULL daily array * per metric (`bucketSeries`, up to ~360 daily buckets + ~24 monthly) - * plus correlation pair arrays, and never touched the WEEK / MONTH / - * YEAR rollup tier. A daily-weigher's `weight-status` snapshot ran + * plus correlation pair arrays, and originally never touched the WEEK / + * MONTH / YEAR rollup tier. A daily-weigher's `weight-status` snapshot ran * ~23 KB for weight alone; `general-status` across many Apple-Health * types could blow past 100 K tokens. The Coach is the one path that * already grades its timeline (recent days verbatim → weekly means → * bounded window). * + * As of v1.8.0 the focused single-metric cards (weight / pulse / bmi / + * blood-pressure) source their monthly / yearly slices from the MONTH / + * YEAR rollup tier via `buildGradedSeriesWithRollups`, so the coarse + * tiers finally earn their write amplification. The multi-metric + * `general-status` and the tier-less mood / adherence series stay on the + * bounded in-memory fold (`buildGradedSeriesFromPoints`). + * * This module is the single graded shape every status path uses: * * recent last ~21 days daily min/max/mean/n (~14-21 rows) @@ -43,7 +50,6 @@ import type { MeasurementType } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; import { - aggregateWmyBuckets, readBestGranularityRollups, type RollupBucketRow, } from "@/lib/rollups/measurement-read-wmy"; @@ -310,12 +316,22 @@ function rollupYearly(rows: RollupBucketRow[]): YearlyBucket[] { * bounded raw read and the monthly / yearly slices read from the * pre-aggregated rollup tier. * - * The raw read is capped to the recent + weekly horizon (last ~90 days), - * so dense accounts never pull the full multi-year history into memory. - * The rollup router picks MONTH for the ~1-year window and YEAR for the - * multi-year tail, falling back to a finer tier on coverage miss; when - * the tier has no coverage at all the in-memory folder still produces - * monthly/yearly buckets from whatever the raw read returned. + * The recent / weekly read is capped to the recent + weekly horizon + * (last ~90 days), so the common warm-tier path never pulls the full + * multi-year history into memory. The rollup router picks MONTH for the + * ~1-year window and YEAR for the multi-year tail. + * + * Coverage-miss fallback: when the tier has no MONTH (resp. YEAR) + * coverage for a metric — a fresh account the boot-backfill has not yet + * caught up on — the bounded ~90-day read can NOT supply the monthly / + * yearly slices (every row in it folds into recent / weekly), so naively + * reusing it would ship empty monthly / yearly arrays even when years of + * raw history exist. On that miss this falls back to a full-history + * in-memory fold so the coarse slices are populated from the raw rows + * rather than left falsely empty. The full read only happens when the + * tier is cold, which is exactly the case the write-amplified tier was + * meant to avoid — so the hot path stays bounded and the cold path stays + * correct. */ export async function buildGradedSeriesWithRollups( userId: string, @@ -324,7 +340,7 @@ export async function buildGradedSeriesWithRollups( ): Promise { const since = new Date(now.getTime() - WEEKLY_WINDOW_MS); const rawRecent = await prisma.measurement.findMany({ - where: { userId, type, measuredAt: { gte: since } }, + where: { userId, type, deletedAt: null, measuredAt: { gte: since } }, orderBy: { measuredAt: "asc" }, select: { measuredAt: true, value: true }, }); @@ -339,22 +355,49 @@ export async function buildGradedSeriesWithRollups( // Yearly slice: route a multi-year window through the tier. const yearlyRouted = await readBestGranularityRollups(userId, type, 1095); - let monthly: MonthlyBucket[] = recentGraded.monthly; - let yearly: YearlyBucket[] = recentGraded.yearly; + const monthlyCovered = + monthlyRouted !== null && monthlyRouted.granularity === "MONTH"; + const yearlyCovered = + yearlyRouted !== null && yearlyRouted.granularity === "YEAR"; - if (monthlyRouted && monthlyRouted.granularity === "MONTH") { + let monthly: MonthlyBucket[]; + let yearly: YearlyBucket[]; + + if (monthlyCovered) { // Drop the months already covered by recent/weekly (last ~90 d). const cutoff = new Date(now.getTime() - WEEKLY_WINDOW_MS); monthly = rollupMonthly( monthlyRouted.rows.filter((r) => r.bucketStart < cutoff), ).slice(-MONTHLY_MONTHS); + } else { + monthly = []; } - if (yearlyRouted && yearlyRouted.granularity === "YEAR") { + if (yearlyCovered) { // Keep only years older than the monthly horizon. const cutoff = new Date(now.getTime() - MONTHLY_WINDOW_MS); yearly = rollupYearly( yearlyRouted.rows.filter((r) => r.bucketStart < cutoff), ); + } else { + yearly = []; + } + + // Tier coverage miss for one or both coarse slices: the bounded + // ~90-day read can't supply them, so fold the FULL history in memory + // and take whichever slices the tier left empty. The full read is the + // exception (cold-tier accounts), never the warm-tier norm. + if (!monthlyCovered || !yearlyCovered) { + const allRows = await prisma.measurement.findMany({ + where: { userId, type, deletedAt: null }, + orderBy: { measuredAt: "asc" }, + select: { measuredAt: true, value: true }, + }); + const fullGraded = buildGradedSeriesFromPoints( + allRows.map((r) => ({ measuredAt: r.measuredAt, value: r.value })), + now, + ); + if (!monthlyCovered) monthly = fullGraded.monthly; + if (!yearlyCovered) yearly = fullGraded.yearly; } return { @@ -365,6 +408,50 @@ export async function buildGradedSeriesWithRollups( }; } +/** + * Apply a constant multiplier to every value in a graded series, keeping + * the bucket keys / counts intact. BMI = weight ÷ height² is a linear + * transform of weight by a per-user constant, so the BMI status card can + * read the WEIGHT rollup tier and scale it rather than maintaining a + * separate (non-existent) BMI tier — `mean / min / max / slope` all carry + * the factor exactly, so the scaled series is identical to folding the + * derived BMI points directly. + */ +export function scaleGradedSeries( + series: GradedSeries, + factor: number, + digits = 2, +): GradedSeries { + const r = (v: number) => round(v * factor, digits); + return { + recent: series.recent.map((b) => ({ + ...b, + min: r(b.min), + max: r(b.max), + mean: r(b.mean), + })), + weekly: series.weekly.map((b) => ({ + ...b, + min: r(b.min), + max: r(b.max), + mean: r(b.mean), + })), + monthly: series.monthly.map((b) => ({ + ...b, + min: r(b.min), + max: r(b.max), + mean: r(b.mean), + })), + yearly: series.yearly.map((b) => ({ + ...b, + min: r(b.min), + max: r(b.max), + mean: r(b.mean), + slope: b.slope === null ? null : round(b.slope * factor, 4), + })), + }; +} + /** * Assembled-snapshot soft char cap, mirroring the Coach's * `MAX_SNAPSHOT_CHARS` (~24 000 chars ≈ ~6 000 tokens against the @@ -455,40 +542,3 @@ export function degradeStatusSnapshotToBudget( 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 - * `aggregateWmyBuckets` semantics for the count-weighted mean. - */ -export function summarizeGradedSeries(series: GradedSeries): { - n: number; - min: number | null; - max: number | null; - mean: number | null; -} { - const rows: RollupBucketRow[] = []; - const push = (mean: number, min: number, max: number, n: number) => - rows.push({ - bucketStart: new Date(0), - count: n, - mean, - sd: null, - slope: null, - r2: null, - sumValue: null, - minValue: min, - maxValue: max, - }); - for (const r of series.recent) push(r.mean, r.min, r.max, r.n); - for (const w of series.weekly) push(w.mean, w.min, w.max, w.n); - for (const m of series.monthly) push(m.mean, m.min, m.max, m.n); - for (const y of series.yearly) push(y.mean, y.min, y.max, y.n); - const agg = aggregateWmyBuckets(rows); - return { - n: agg.count, - min: agg.min === null ? null : round(agg.min), - max: agg.max === null ? null : round(agg.max), - mean: agg.mean === null ? null : round(agg.mean), - }; -} diff --git a/src/lib/insights/medication-compliance-status.ts b/src/lib/insights/medication-compliance-status.ts index cc354ec26..04bf1e49b 100644 --- a/src/lib/insights/medication-compliance-status.ts +++ b/src/lib/insights/medication-compliance-status.ts @@ -17,11 +17,15 @@ import { getPreviousInsightContext, } from "@/lib/insights/memory"; import { applyPayloadBudget } from "@/lib/insights/bucket-series"; +import { buildGradedSeriesFromPoints } from "@/lib/insights/graded-series"; +import { degradeStatusSnapshotToBudget } from "@/lib/insights/graded-series"; import { - buildGradedSeriesFromPoints, - degradeStatusSnapshotToBudget, -} from "@/lib/insights/graded-series"; -import { stripChartTokens } from "@/lib/insights/chart-tokens"; + type SupportedLocale, + normalizeLocale, + normalizeSummaryText, + parseSummaryFromContent, + round, +} from "@/lib/insights/status-shared"; import { runStatusCompletion } from "@/lib/insights/status-provider"; import { isTimeoutStub } from "@/lib/insights/status-cache"; import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback"; @@ -32,26 +36,11 @@ import { toBerlinDayKey } from "@/lib/tz/resolver"; const COMPLIANCE_HISTORY_DAYS = 360 + 24 * 30; const MS_PER_DAY = 24 * 60 * 60 * 1000; -type SupportedLocale = "de" | "en"; - interface MedicationSummaryItem { medicationId: string; text: string; } -function round(value: number, digits = 1): number { - const factor = 10 ** digits; - return Math.round(value * factor) / factor; -} - -function normalizeSummaryText(value: string): string { - return stripChartTokens(value).replace(/\s+/g, " ").trim(); -} - -function normalizeLocale(value: string | null | undefined): SupportedLocale { - return value === "en" ? "en" : "de"; -} - export async function generateMedicationComplianceStatusForUser( userId: string, options?: { @@ -360,49 +349,22 @@ export async function generateMedicationComplianceStatusForUser( }; } - const content = outcome.content; - - let summary = ""; - let medicationSummaries: MedicationSummaryItem[] = []; - try { - const parsed = JSON.parse(content) as { - summary?: string; - medications?: Array<{ medicationId?: string; summary?: string }>; - }; - summary = typeof parsed.summary === "string" ? parsed.summary : content; - - const incomingMap = new Map(); - for (const entry of parsed.medications ?? []) { - if ( - typeof entry?.medicationId === "string" && - typeof entry?.summary === "string" && - entry.summary.trim().length > 0 - ) { - incomingMap.set(entry.medicationId, entry.summary); - } - } - - medicationSummaries = medicationSnapshots.map((medication) => ({ + // 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)); + const medicationSummaries: MedicationSummaryItem[] = medicationSnapshots.map( + (medication) => ({ medicationId: medication.medicationId, text: normalizeSummaryText( - incomingMap.get(medication.medicationId) ?? - (locale === "de" - ? `${medication.name}: Es liegen noch nicht genügend konsistente Detaildaten für eine belastbare Kurzbewertung vor.` - : `${medication.name}: There is currently not enough consistent detail data for a robust short assessment.`), - ), - })); - } catch { - summary = content; - medicationSummaries = medicationSnapshots.map((medication) => ({ - medicationId: medication.medicationId, - text: locale === "de" - ? `${medication.name}: Die medikamentenspezifische Kurzbewertung konnte heute nicht separat aufbereitet werden.` - : `${medication.name}: The medication-specific short assessment could not be prepared separately today.`, - })); - } + ? `${medication.name}: Es liegen noch nicht genügend konsistente Detaildaten für eine belastbare Kurzbewertung vor.` + : `${medication.name}: There is currently not enough consistent detail data for a robust short assessment.`, + ), + }), + ); - summary = normalizeSummaryText(summary); if (!summary) { throw new Error( "Medication-compliance-status summary was empty after normalization", diff --git a/src/lib/insights/mood-status.ts b/src/lib/insights/mood-status.ts index cdfff7665..4eb950e51 100644 --- a/src/lib/insights/mood-status.ts +++ b/src/lib/insights/mood-status.ts @@ -18,58 +18,21 @@ import { buildGradedSeriesFromPoints, degradeStatusSnapshotToBudget, } from "@/lib/insights/graded-series"; -import { stripChartTokens } from "@/lib/insights/chart-tokens"; +import { + type SupportedLocale, + normalizeLocale, + normalizeSummaryText, + parseSummaryFromContent, + persistStatusInsight, + round, + summarizeSeries, +} from "@/lib/insights/status-shared"; import { runStatusCompletion } from "@/lib/insights/status-provider"; import { readFreshStatusText } from "@/lib/insights/status-cache"; import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback"; import { annotate } from "@/lib/logging/context"; import { toBerlinDayKey } from "@/lib/tz/resolver"; -type SupportedLocale = "de" | "en"; - -function round(value: number, digits = 1): number { - const factor = 10 ** digits; - return Math.round(value * factor) / factor; -} - -function normalizeSummaryText(value: string): string { - return stripChartTokens(value).replace(/\s+/g, " ").trim(); -} - -function normalizeLocale(value: string | null | undefined): SupportedLocale { - return value === "en" ? "en" : "de"; -} - -function summarizeSeries(series: Array<{ value: number }>) { - if (series.length === 0) return null; - const first = series[0].value; - const last = series[series.length - 1].value; - // v1.4.33 — fold sum/min/max into a single walk. The previous - // `Math.min(...series.map(...))` / `Math.max(...series.map(...))` - // spread tripped V8's ~125 000-arg ceiling on the bound /api/analytics - // path; see `.planning/round-v1433-analytics-500-report.md` §"Carry- - // over". These helpers are fed bounded windows today so the crash - // never reached them, but the spread allocates a transient args array - // on every call — the fold is both stack-safe and cheaper. - let sum = 0; - let minVal = series[0].value; - let maxVal = series[0].value; - for (const entry of series) { - sum += entry.value; - if (entry.value < minVal) minVal = entry.value; - if (entry.value > maxVal) maxVal = entry.value; - } - return { - points: series.length, - start: round(first, 2), - end: round(last, 2), - delta: round(last - first, 2), - mean: round(sum / series.length, 2), - min: round(minVal, 2), - max: round(maxVal, 2), - }; -} - /** * Pair two daily-bucket series on `dayOffset`. The synthesised `date` * field is anchored at the UTC midnight of the Berlin calendar day — @@ -423,44 +386,27 @@ export async function generateMoodStatusForUser( }); } - let summary = ""; - try { - const parsed = JSON.parse(outcome.content) as { summary?: string }; - if (typeof parsed.summary === "string") { - summary = parsed.summary; - } else { - summary = outcome.content; - } - } catch { - summary = outcome.content; - } - - summary = normalizeSummaryText(summary); + const summary = normalizeSummaryText(parseSummaryFromContent(outcome.content)); if (!summary) { throw new Error("Mood-status summary was empty after normalization"); } - const created = await prisma.auditLog.create({ - data: { - userId, - action: cacheAction, - details: JSON.stringify({ - dateKey: todayKey, - locale, - text: summary, - providerType: outcome.providerType, - model: outcome.model, - tokensUsed: outcome.tokensUsed, - }), - }, - select: { createdAt: true }, + const updatedAt = await persistStatusInsight({ + userId, + cacheAction, + todayKey, + locale, + text: summary, + providerType: outcome.providerType, + model: outcome.model, + tokensUsed: outcome.tokensUsed, }); return { hasProvider: true, text: summary, cached: false, - updatedAt: created.createdAt.toISOString(), + updatedAt, }; } diff --git a/src/lib/insights/pulse-status.ts b/src/lib/insights/pulse-status.ts index fdb0b584d..91429acbe 100644 --- a/src/lib/insights/pulse-status.ts +++ b/src/lib/insights/pulse-status.ts @@ -15,60 +15,24 @@ import { getNoKeyPulseStatusText } from "@/lib/insights/no-key-fallbacks"; import { applyPayloadBudget } from "@/lib/insights/bucket-series"; import { buildGradedSeriesFromPoints, + buildGradedSeriesWithRollups, degradeStatusSnapshotToBudget, } from "@/lib/insights/graded-series"; -import { stripChartTokens } from "@/lib/insights/chart-tokens"; +import { + type SupportedLocale, + normalizeLocale, + normalizeSummaryText, + parseSummaryFromContent, + persistStatusInsight, + round, + summarizeSeries, +} from "@/lib/insights/status-shared"; import { runStatusCompletion } from "@/lib/insights/status-provider"; import { readFreshStatusText } from "@/lib/insights/status-cache"; import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback"; import { annotate } from "@/lib/logging/context"; import { toBerlinDayKey } from "@/lib/tz/resolver"; -type SupportedLocale = "de" | "en"; - -function round(value: number, digits = 1): number { - const factor = 10 ** digits; - return Math.round(value * factor) / factor; -} - -function normalizeSummaryText(value: string): string { - return stripChartTokens(value).replace(/\s+/g, " ").trim(); -} - -function normalizeLocale(value: string | null | undefined): SupportedLocale { - return value === "en" ? "en" : "de"; -} - -function summarizeSeries(series: Array<{ value: number }>) { - if (series.length === 0) return null; - const first = series[0].value; - const last = series[series.length - 1].value; - // v1.4.33 — fold sum/min/max into a single walk. The previous - // `Math.min(...series.map(...))` / `Math.max(...series.map(...))` - // spread tripped V8's ~125 000-arg ceiling on the bound /api/analytics - // path; see `.planning/round-v1433-analytics-500-report.md` §"Carry- - // over". These helpers are fed bounded windows today so the crash - // never reached them, but the spread allocates a transient args array - // on every call — the fold is both stack-safe and cheaper. - let sum = 0; - let minVal = series[0].value; - let maxVal = series[0].value; - for (const entry of series) { - sum += entry.value; - if (entry.value < minVal) minVal = entry.value; - if (entry.value > maxVal) maxVal = entry.value; - } - return { - points: series.length, - start: round(first, 2), - end: round(last, 2), - delta: round(last - first, 2), - mean: round(sum / series.length, 2), - min: round(minVal, 2), - max: round(maxVal, 2), - }; -} - export async function generatePulseStatusForUser( userId: string, options?: { @@ -137,7 +101,10 @@ export async function generatePulseStatusForUser( // 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); + // Primary metric: recent / weekly fold from the bounded raw read, the + // monthly / yearly tail comes from the MONTH / YEAR rollup tier (with + // a full-history in-memory fallback on a cold-tier coverage miss). + const pulseGraded = await buildGradedSeriesWithRollups(userId, "PULSE", now); const pulseSummary = summarizeSeries( pulseSeries.daily.map((bucket) => ({ value: bucket.value })), ); @@ -307,44 +274,27 @@ export async function generatePulseStatusForUser( }); } - let summary = ""; - try { - const parsed = JSON.parse(outcome.content) as { summary?: string }; - if (typeof parsed.summary === "string") { - summary = parsed.summary; - } else { - summary = outcome.content; - } - } catch { - summary = outcome.content; - } - - summary = normalizeSummaryText(summary); + const summary = normalizeSummaryText(parseSummaryFromContent(outcome.content)); if (!summary) { throw new Error("Pulse-status summary was empty after normalization"); } - const created = await prisma.auditLog.create({ - data: { - userId, - action: cacheAction, - details: JSON.stringify({ - dateKey: todayKey, - locale, - text: summary, - providerType: outcome.providerType, - model: outcome.model, - tokensUsed: outcome.tokensUsed, - }), - }, - select: { createdAt: true }, + const updatedAt = await persistStatusInsight({ + userId, + cacheAction, + todayKey, + locale, + text: summary, + providerType: outcome.providerType, + model: outcome.model, + tokensUsed: outcome.tokensUsed, }); return { hasProvider: true, text: summary, cached: false, - updatedAt: created.createdAt.toISOString(), + updatedAt, }; } diff --git a/src/lib/insights/status-shared.ts b/src/lib/insights/status-shared.ts new file mode 100644 index 000000000..5c0c488ad --- /dev/null +++ b/src/lib/insights/status-shared.ts @@ -0,0 +1,137 @@ +/** + * Shared helpers for the seven per-card status generators. + * + * Every `src/lib/insights/*-status.ts` generator carried byte-identical + * copies of `round`, `normalizeSummaryText`, `normalizeLocale`, and (in + * six of seven) `summarizeSeries`, plus an identical "parse the model's + * `{summary}` envelope" block and an identical `prisma.auditLog.create` + * persist block. This module is the single source of truth so a change + * to the rounding precision, the chart-token scrub, or the cache-row + * shape lands once rather than seven times. + * + * The shapes are intentionally narrow — `SupportedLocale` is `de | en` + * because the status prompts ship only those two locales. The + * medication-compliance generator writes a richer cache `details` shape + * (it carries a per-medication array), so it shares `round`, + * `normalizeSummaryText`, `normalizeLocale`, and `parseSummaryFromContent` + * but keeps its own `auditLog.create`. + */ +import { prisma } from "@/lib/db"; +import { stripChartTokens } from "@/lib/insights/chart-tokens"; + +export type SupportedLocale = "de" | "en"; + +/** Round to `digits` decimal places. */ +export function round(value: number, digits = 1): number { + const factor = 10 ** digits; + return Math.round(value * factor) / factor; +} + +/** Scrub chart tokens and collapse whitespace out of a model summary. */ +export function normalizeSummaryText(value: string): string { + return stripChartTokens(value).replace(/\s+/g, " ").trim(); +} + +/** Narrow an arbitrary locale string to the two the prompts support. */ +export function normalizeLocale( + value: string | null | undefined, +): SupportedLocale { + return value === "en" ? "en" : "de"; +} + +export interface SeriesSummary { + points: number; + start: number; + end: number; + delta: number; + mean: number; + min: number; + max: number; +} + +/** + * Fold a value series into a single start/end/delta/mean/min/max + * summary. Returns null for an empty series. + * + * v1.4.33 — fold sum/min/max into a single walk. The previous + * `Math.min(...series.map(...))` / `Math.max(...series.map(...))` + * spread tripped V8's ~125 000-arg ceiling on the bound /api/analytics + * path; see `.planning/round-v1433-analytics-500-report.md` §"Carry- + * over". These helpers are fed bounded windows today so the crash + * never reached them, but the spread allocates a transient args array + * on every call — the fold is both stack-safe and cheaper. + */ +export function summarizeSeries( + series: Array<{ value: number }>, +): SeriesSummary | null { + if (series.length === 0) return null; + const first = series[0].value; + const last = series[series.length - 1].value; + let sum = 0; + let minVal = series[0].value; + let maxVal = series[0].value; + for (const entry of series) { + sum += entry.value; + if (entry.value < minVal) minVal = entry.value; + if (entry.value > maxVal) maxVal = entry.value; + } + return { + points: series.length, + start: round(first, 2), + end: round(last, 2), + delta: round(last - first, 2), + mean: round(sum / series.length, 2), + min: round(minVal, 2), + max: round(maxVal, 2), + }; +} + +/** + * Pull the `summary` string out of a model completion. The status + * prompts return a `{ "summary": "…" }` envelope; a model that ignores + * the contract and returns bare prose falls back to the raw content. + */ +export function parseSummaryFromContent(content: string): string { + try { + const parsed = JSON.parse(content) as { summary?: string }; + if (typeof parsed.summary === "string") return parsed.summary; + } catch { + // not JSON — fall through to the raw content + } + return content; +} + +/** + * Persist one status assessment cache row in the standard text-only + * shape (`{ dateKey, locale, text, providerType, model, tokensUsed }`). + * Returns the row's `createdAt` ISO string. The medication-compliance + * generator writes a richer `details` shape (per-medication array) and + * keeps its own `auditLog.create` call. + */ +export async function persistStatusInsight(args: { + userId: string; + cacheAction: string; + todayKey: string; + locale: SupportedLocale; + text: string; + providerType: string; + model: string; + tokensUsed: number | null; +}): Promise { + const created = await prisma.auditLog.create({ + data: { + userId: args.userId, + action: args.cacheAction, + details: JSON.stringify({ + dateKey: args.todayKey, + locale: args.locale, + text: args.text, + providerType: args.providerType, + model: args.model, + tokensUsed: args.tokensUsed, + }), + }, + select: { createdAt: true }, + }); + return created.createdAt.toISOString(); +} diff --git a/src/lib/insights/weight-status.ts b/src/lib/insights/weight-status.ts index 74454a95d..179de247b 100644 --- a/src/lib/insights/weight-status.ts +++ b/src/lib/insights/weight-status.ts @@ -19,17 +19,24 @@ import { } from "@/lib/insights/bucket-series"; import { buildGradedSeriesFromPoints, + buildGradedSeriesWithRollups, degradeStatusSnapshotToBudget, } from "@/lib/insights/graded-series"; -import { stripChartTokens } from "@/lib/insights/chart-tokens"; +import { + type SupportedLocale, + normalizeLocale, + normalizeSummaryText, + parseSummaryFromContent, + persistStatusInsight, + round, + summarizeSeries, +} from "@/lib/insights/status-shared"; import { runStatusCompletion } from "@/lib/insights/status-provider"; import { readFreshStatusText } from "@/lib/insights/status-cache"; import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback"; import { annotate } from "@/lib/logging/context"; 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 @@ -37,49 +44,6 @@ type SupportedLocale = "de" | "en"; */ const CORRELATION_PAIR_CAP = 30; -function round(value: number, digits = 1): number { - const factor = 10 ** digits; - return Math.round(value * factor) / factor; -} - -function normalizeSummaryText(value: string): string { - return stripChartTokens(value).replace(/\s+/g, " ").trim(); -} - -function normalizeLocale(value: string | null | undefined): SupportedLocale { - return value === "en" ? "en" : "de"; -} - -function summarizeSeries(series: Array<{ value: number }>) { - if (series.length === 0) return null; - const first = series[0].value; - const last = series[series.length - 1].value; - // v1.4.33 — fold sum/min/max into a single walk. The previous - // `Math.min(...series.map(...))` / `Math.max(...series.map(...))` - // spread tripped V8's ~125 000-arg ceiling on the bound /api/analytics - // path; see `.planning/round-v1433-analytics-500-report.md` §"Carry- - // over". These helpers are fed bounded windows today so the crash - // never reached them, but the spread allocates a transient args array - // on every call — the fold is both stack-safe and cheaper. - let sum = 0; - let minVal = series[0].value; - let maxVal = series[0].value; - for (const entry of series) { - sum += entry.value; - if (entry.value < minVal) minVal = entry.value; - if (entry.value > maxVal) maxVal = entry.value; - } - return { - points: series.length, - start: round(first, 2), - end: round(last, 2), - delta: round(last - first, 2), - mean: round(sum / series.length, 2), - min: round(minVal, 2), - max: round(maxVal, 2), - }; -} - /** * Pair two daily-bucket series on `dayOffset`. The synthesised `date` * field is anchored at the UTC midnight of the Berlin calendar day — @@ -226,27 +190,17 @@ export async function generateWeightStatusForUser( // 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, - ); + // + // Weight + the two BP context channels source their recent / weekly + // slices from a bounded raw read and their monthly / yearly tail from + // the MONTH / YEAR rollup tier (with a full-history in-memory fallback + // on a cold-tier coverage miss). Mood has no rollup tier, so it stays + // an in-memory fold. + const [weightGraded, sysGraded, diaGraded] = await Promise.all([ + buildGradedSeriesWithRollups(userId, "WEIGHT", now), + buildGradedSeriesWithRollups(userId, "BLOOD_PRESSURE_SYS", now), + buildGradedSeriesWithRollups(userId, "BLOOD_PRESSURE_DIA", now), + ]); const moodGraded = buildGradedSeriesFromPoints( moodSeries.daily.map((b) => ({ measuredAt: new Date(dayOffsetToBerlinDayKey(now, b.dayOffset)), @@ -464,44 +418,27 @@ export async function generateWeightStatusForUser( }); } - let summary = ""; - try { - const parsed = JSON.parse(outcome.content) as { summary?: string }; - if (typeof parsed.summary === "string") { - summary = parsed.summary; - } else { - summary = outcome.content; - } - } catch { - summary = outcome.content; - } - - summary = normalizeSummaryText(summary); + const summary = normalizeSummaryText(parseSummaryFromContent(outcome.content)); if (!summary) { throw new Error("Weight-status summary was empty after normalization"); } - const created = await prisma.auditLog.create({ - data: { - userId, - action: cacheAction, - details: JSON.stringify({ - dateKey: todayKey, - locale, - text: summary, - providerType: outcome.providerType, - model: outcome.model, - tokensUsed: outcome.tokensUsed, - }), - }, - select: { createdAt: true }, + const updatedAt = await persistStatusInsight({ + userId, + cacheAction, + todayKey, + locale, + text: summary, + providerType: outcome.providerType, + model: outcome.model, + tokensUsed: outcome.tokensUsed, }); return { hasProvider: true, text: summary, cached: false, - updatedAt: created.createdAt.toISOString(), + updatedAt, }; }