diff --git a/src/__tests__/background-reasoning-inventory.test.ts b/src/__tests__/background-reasoning-inventory.test.ts index ccb773f6c..0354a6a87 100644 --- a/src/__tests__/background-reasoning-inventory.test.ts +++ b/src/__tests__/background-reasoning-inventory.test.ts @@ -41,7 +41,7 @@ const ADMITTED: Record = { jobs: ["daily_briefing"], why: "The daily briefing's generation and its phrasing re-roll; the JSON and grounding repairs do not reason.", }, - "app/api/insights/generate/route.ts": { + "lib/insights/briefing-request/completion.ts": { jobs: ["daily_briefing"], why: "A briefing asked for by hand reasons like the scheduled one; its JSON and grounding repairs do not.", }, diff --git a/src/__tests__/pulse-day-mean-reader-guard.test.ts b/src/__tests__/pulse-day-mean-reader-guard.test.ts index 4f27170cb..19919be8d 100644 --- a/src/__tests__/pulse-day-mean-reader-guard.test.ts +++ b/src/__tests__/pulse-day-mean-reader-guard.test.ts @@ -177,7 +177,7 @@ const TS_READERS: Record = { "app/api/insights/cards/route.ts": { exempt: "pulse is summarised from day means (readDayMeanSeries)", }, - "app/api/insights/comprehensive/route.ts": { + "lib/insights/comprehensive-overview/build.ts": { exempt: "summarises the mood series only", }, }; diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts index 65c30785b..e8e67147d 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -76,7 +76,7 @@ import type { ShareScope } from "@/lib/sharing/scope"; import { ADMITTED_MUTATING_HANDLERS } from "../../tests/fixtures/v137/sharing-matrix"; import { API_HANDLER, guardModuleOf } from "./helpers/auth-guards"; -import { readSourceText } from "./helpers/source-files"; +import { readSourceText, walkSourceFiles } from "./helpers/source-files"; const SRC = join(process.cwd(), "src"); @@ -3316,15 +3316,30 @@ describe("(g) the MANAGE route set is frozen", () => { // and refreshes no token. Credential policy belongs inside every resolver // and presence helper so a delegate or managed system job cannot select a // personal BYOK key or custom base URL first. + // The route hands its body to the overview modules, so the leg reads the + // route and every one of them: the probe is called among them, and no + // resolver is called by any. + const comprehensiveModules = [ + "app/api/insights/comprehensive/route.ts", + ...walkSourceFiles(join(SRC, "lib/insights/comprehensive-overview"), { + floor: 5, + }).map((f) => `lib/insights/comprehensive-overview/${f}`), + ]; + expect(read(comprehensiveModules[0])).toContain( + '"@/lib/insights/comprehensive-overview/overview"', + ); expect( - callsSymbol( - "app/api/insights/comprehensive/route.ts", - "probeProviderPresence", - ), + comprehensiveModules.some((m) => callsSymbol(m, "probeProviderPresence")), ).toBe(true); - expect( - callsSymbol("app/api/insights/comprehensive/route.ts", "resolveProvider"), - ).toBe(false); + for (const m of comprehensiveModules) { + for (const name of [ + "resolveProvider", + "resolveProviderChain", + "probeProviderChain", + ]) { + expect(callsSymbol(m, name), `${m} calls ${name}`).toBe(false); + } + } for (const name of [ "resolveProvider", "resolveProviderChain", diff --git a/src/__tests__/unit-preference-display-guard.test.ts b/src/__tests__/unit-preference-display-guard.test.ts index b84376146..0f9126d56 100644 --- a/src/__tests__/unit-preference-display-guard.test.ts +++ b/src/__tests__/unit-preference-display-guard.test.ts @@ -506,7 +506,7 @@ const READER_UNIT_PRODUCERS = [ "lib/ai/prompts/weight.ts", "lib/insights/features-units.ts", "lib/insights/comprehensive-generate.ts", - "app/api/insights/generate/route.ts", + "lib/insights/briefing-request/features.ts", "lib/insights/weight-status.ts", "lib/insights/metric-status.ts", "lib/insights/narrative/period-narrative.ts", diff --git a/src/app/api/insights/comprehensive/route.ts b/src/app/api/insights/comprehensive/route.ts index a170b149c..bc3890f9c 100644 --- a/src/app/api/insights/comprehensive/route.ts +++ b/src/app/api/insights/comprehensive/route.ts @@ -1,59 +1,20 @@ -import { prisma } from "@/lib/db"; -import { probeProviderPresence } from "@/lib/ai/provider"; import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate"; import { apiError, apiSuccess } from "@/lib/api-response"; -import type { DataPoint, DataSummary } from "@/lib/analytics/trends"; -import { summarize } from "@/lib/analytics/trends"; -import { getBpTargets } from "@/lib/analytics/bp-targets"; -import { computeBpInTargetPct } from "@/lib/analytics/bp-in-target"; -import { - classifyBMI, - classifyBP, - generateAlerts, -} from "@/lib/analytics/classifications"; -import { - significantPearsonCorrelation, - type PairedPoint, -} from "@/lib/analytics/correlations"; -import { - buildComplianceMedicationContext, - COURSES_COMPLIANCE_SELECT, - calculateCompliance, - expectsDoses, - lastNonSkippedTakenAt, - SCHEDULE_COMPLIANCE_SELECT, -} from "@/lib/analytics/compliance"; -import { resolveMedicationCategories } from "@/lib/medication-category"; -import { resolveRestingPulseSeries } from "@/lib/analytics/resting-pulse"; -import { readRestingPulseProxy } from "@/lib/analytics/resting-pulse-read"; -import { - apiHandler, - requireRecordAuth, - type AuthContext, -} from "@/lib/api-handler"; -import { annotate } from "@/lib/logging/context"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; -import { - cachedSwrWithMeta, - caches, - type ServerCache, -} from "@/lib/cache/server-cache"; -import { buildComprehensiveAggregate } from "@/lib/insights/comprehensive-aggregator"; -import { - ensureUserMoodRollupsFresh, - readMoodDayRollups, -} from "@/lib/rollups/mood-rollups"; -import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/format"; -import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking"; -import { dayKeyAsUtcMidnight, dateOnlyKey } from "@/lib/tz/date-only"; +import { readComprehensiveOverview } from "@/lib/insights/comprehensive-overview/overview"; export const dynamic = "force-dynamic"; +/** + * GET /api/insights/comprehensive — the Insights overview's computed read. + * The body is built in `src/lib/insights/comprehensive-overview/`. + */ export const GET = apiHandler(async () => { - // v1.37.0 — MANAGE-level read: computed over the whole record, with no - // provider anywhere on the path. + // MANAGE-level read: computed over the whole record, with no provider + // anywhere on the path. const { user } = await requireRecordAuth("manage", "record"); - // v1.15.20 — shared analytics-read budget (generous; caps runaway loops). + // Shared analytics-read budget (generous; caps runaway loops). const rl = await checkAnalyticsReadRateLimit(user.id); if (!rl.allowed) { return apiError("Too many analytics requests. Please retry later.", 429); @@ -66,37 +27,7 @@ export const GET = apiHandler(async () => { // provider up, still gets the overview. The `ai` block beside the body says // whether the briefing and status notes that decorate the overview can be // shown, resolved per request and never cached with the body. - - // v1.4.35 — read-through the analytics cache keyed on - // (userId, "comprehensive"). The /insights page mount routinely - // fans out to this endpoint alongside the Coach drawer + the - // recommendations grid; the 60s TTL converts every duplicate - // mount within the window to a Map lookup. Invalidation is handled - // by `invalidateUserMeasurements`, which marks every key under the - // `${userId}|` prefix stale (see `lib/cache/invalidate.ts`). - // - // v1.16.7 — stale-while-revalidate. This is the single heaviest - // SQL-side aggregation on the /insights mount; serving the prior - // body instantly while ONE background recompute refreshes it keeps - // the page interactive across the bucket's 10-minute stale window - // (a measurement sync used to bust the entry into a blocking cold - // rebuild on the very next mount, all day). The truly-cold first - // read of the day still computes inline — there is no prior body to - // serve — but it no longer recurs per visit. - // - // The `revalidating` flag marks a stale-served body honestly so the - // client can poll (bounded) until the background rebuild lands — - // mirroring the `/api/insights/generate` GET contract. It rides on - // the response, never inside the cached body, so a warmed entry - // doesn't freeze the flag. - const { value: body, outcome } = await cachedSwrWithMeta( - caches.analytics as ServerCache< - Awaited> - >, - `${user.id}|comprehensive`, - () => buildComprehensiveResponse(user), - annotate, - ); + const { body, revalidating } = await readComprehensiveOverview(user); const [briefing, statusText] = await Promise.all([ aiCapabilityToServe(user.id, "briefing"), @@ -105,627 +36,7 @@ export const GET = apiHandler(async () => { return apiSuccess({ ...body, - revalidating: outcome === "stale", + revalidating, ai: { briefing, statusText }, }); }); - -type AuthedUser = AuthContext["user"]; - -/** - * v1.4.35 — comprehensive insights response body, lifted out of the - * route handler so `cached()` can wrap it. Replaces the previous - * 100k-row-in-JS aggregation with SQL-side aggregates served by - * `buildComprehensiveAggregate`. The medication-compliance block keeps - * its bounded Prisma reads (medications + intake events) — the directive - * called those out as already on the safe path. The byte-shape of the - * returned envelope is unchanged from the legacy route. - */ -export async function buildComprehensiveResponse(user: AuthedUser) { - const userId = user.id; - const ninetyDaysAgo = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000); - // v1.30.3 — the one shared per-user tz for every day-bucketing - // pass in this route (resting-pulse proxy, medication-continuity, BP - // pairing). Was previously re-derived ad hoc (or hardcoded UTC) at each - // call site. - const userTz = user.timezone || DEFAULT_TIMEZONE; - - // Fetch user profile (height + DOB drive BMI + BP targets). - const dbUser = await prisma.user.findUnique({ - where: { id: userId }, - select: { - heightCm: true, - dateOfBirth: true, - }, - }); - - // ── Aggregate-first reads ───────────────────────────────── - // SQL-side rollup replaces the 100k-row findMany on the legacy - // route. Returns per-type DataSummary identical to the JS - // `summarize()` semantics over the 90-day window, plus the bounded - // raw rows the BP target adherence pairing needs, plus daily-mean - // buckets for the correlation pairings. - const aggregate = await buildComprehensiveAggregate(userId); - - // v1.4.40 — swap the 90-day unbounded `prisma.moodEntry.findMany` - // for the persistent mood-rollup DAY tier (audit Critical Finding - // #2). The two consumers downstream — `dailyMoodEntries` for the - // mood × metric correlations and `moodSummary` for the dashboard - // mood block — read at a per-day resolution, which is precisely the - // shape the rollup writer emits. - // - // Coverage-fallback: when the user has mood entries but zero rollup - // rows yet (legacy account before the boot-time backfill has caught - // up), fall back to a bounded 90-day raw walk once and fire the - // warm-up so the next request lands on the rollup tier. Same posture - // as `/api/mood/analytics`. - // - // Since v1.32.12 the mood rollup writer mints `bucketStart` from the - // canonical local `MoodEntry.date` label at UTC midnight, so - // `.toISOString().slice(0, 10)` recovers the user's LOCAL calendar day — - // byte-identical to the live fallback's `entry.date`. Both mood paths - // therefore emit local-day keys. The mood × metric pairing partners - // below (sys BP, weight, resting pulse) are consequently derived in the - // user's tz (v1.32.17) so both join sides share ONE local-day key space. - // The measurement-rollup tier itself still buckets UTC (`dailyByType`) - // and migrates to local in a documented follow-up release; the two - // internally-consistent UTC joins (weight × BP, BP-med continuity) are - // left on it deliberately (see their call sites below). - void ensureUserMoodRollupsFresh(userId); - const moodRollupDayRows = await readMoodDayRollups(userId, ninetyDaysAgo); - let dailyMoodEntries: Array<{ day: string; value: number }>; - let moodDataPoints: DataPoint[]; - let moodEntryCount: number; - if (moodRollupDayRows.length > 0) { - dailyMoodEntries = moodRollupDayRows.map((r) => ({ - day: dateOnlyKey(r.bucketStart), - value: Math.round(r.mean * 100) / 100, - })); - moodDataPoints = moodRollupDayRows.map((r) => ({ - date: r.bucketStart, - value: r.mean, - })); - moodEntryCount = moodRollupDayRows.reduce((s, r) => s + r.count, 0); - } else { - // Coverage-fallback: legacy account with mood entries but no - // rollup coverage yet. Bounded by the 90-day window so the walk is - // capped even when the rollup miss happens. - const moodEntries = await prisma.moodEntry.findMany({ - // v1.7.0 sync — exclude tombstoned rows. - where: { userId, deletedAt: null, moodLoggedAt: { gte: ninetyDaysAgo } }, - orderBy: { moodLoggedAt: "asc" }, - select: { date: true, score: true, moodLoggedAt: true }, - }); - const moodByDay = new Map(); - for (const entry of moodEntries) { - const current = moodByDay.get(entry.date) ?? { sum: 0, count: 0 }; - current.sum += entry.score; - current.count += 1; - moodByDay.set(entry.date, current); - } - dailyMoodEntries = Array.from(moodByDay.entries()) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([day, stats]) => ({ - day, - value: Math.round((stats.sum / stats.count) * 100) / 100, - })); - // Feed per-day means into `summarize()` so the live-fallback path - // shares the rollup-tier semantic (one DataPoint per day, the - // daily mean) — mirrors the v1.4.39 mood/analytics QA UX-H1 fix. - moodDataPoints = dailyMoodEntries.map((e) => ({ - date: new Date(`${e.day}T12:00:00.000Z`), - value: e.value, - })); - moodEntryCount = moodEntries.length; - } - - const moodSummary = - moodDataPoints.length > 0 ? summarize(moodDataPoints) : null; - - const summaries: Record = aggregate.summaries; - - // ── BMI ────────────────────────────────────────────────── - let bmi: number | null = null; - let bmiClassification = null; - if (dbUser?.heightCm && summaries.WEIGHT?.latest) { - const heightM = dbUser.heightCm / 100; - bmi = Math.round((summaries.WEIGHT.latest / (heightM * heightM)) * 10) / 10; - bmiClassification = classifyBMI(bmi); - } - - // ── BP classification (30-day average) ──────────────────── - let bpClassification = null; - const bpTargets = getBpTargets(dbUser?.dateOfBirth ?? null); - if ( - summaries.BLOOD_PRESSURE_SYS?.avg30 && - summaries.BLOOD_PRESSURE_DIA?.avg30 - ) { - bpClassification = classifyBP( - summaries.BLOOD_PRESSURE_SYS.avg30, - summaries.BLOOD_PRESSURE_DIA.avg30, - ); - } - - // ── BP target adherence (trailing 90 days) ─────────────── - // Route through the canonical `computeBpInTargetPct` so this figure - // shares ONE definition with the dashboard tile, the insight cards, the - // AI snapshot and the targets endpoint (per the bp-in-target.ts - // contract — the inline copy that used to live here drifted: it paired - // sys+dia on the 5-minute window ONLY, dropping readings whose sys/dia - // timestamps drift past 5 min on Withings / Apple-Health imports, which - // skewed the share iOS renders on the Home Health Score). The helper - // adds the same-local-day pairing fallback (v1.30.3 QA F7 — keyed on - // the user's OWN tz, not a hardcoded Berlin day) so those readings - // pair, and keeps the ESH-2023 band + 90/50 hypotension floor (both sys - // AND dia at or below the age-band ceiling). Window is the aggregator's - // 90-day raw pull — a "recent control" figure, not the all-time headline. - let bpPctInTarget: number | null = null; - if (bpTargets) { - bpPctInTarget = - computeBpInTargetPct( - aggregate.bpRawRows.sys, - aggregate.bpRawRows.dia, - bpTargets, - userTz, - )?.pct ?? null; - } - - // ── Correlations: weight × Sys BP ───────────────────────── - // Daily-key pairing (the legacy `pairByTimestamp` ran with a - // 24-hour default tolerance — daily-mean buckets are the - // SQL-side equivalent and the directive explicitly accepts the - // small semantic shift to bound the raw-row footprint). - // - // v1.32.17 — this pair stays on the UTC `dailyByType` series: BOTH - // sides come from `dailyByType` (weight × sys), so they share one UTC - // day-key space and are internally consistent. Re-keying it to local - // is R12's read-swap (it migrates with the rollup tier), NOT this - // hotfix — doing it here would mean per-request raw re-aggregation - // without the local-bucketed rollup behind it. - const weightDaily = aggregate.dailyByType.WEIGHT ?? []; - const sysDaily = aggregate.dailyByType.BLOOD_PRESSURE_SYS ?? []; - const weightBpPairs: PairedPoint[] = joinDailyByDay(weightDaily, sysDaily); - const weightBpCorrelation = significantPearsonCorrelation(weightBpPairs); - const scatterData = weightBpPairs.map((p) => ({ - weight: p.a, - sysBP: p.b, - })); - - // ── Correlations: mood × {sys BP, weight, pulse} ───────── - // v1.32.17 — the mood series is LOCAL-day-keyed (since v1.32.12, see the - // rollup note above). `pairMoodWithDaily` joins on an EXACT YYYY-MM-DD - // key, so the metric partners MUST also be local-day-keyed or every - // evening logger west of UTC gets a lag-1 pairing sold as same-day. - // Derive the sys / weight partners as exact local-day means from the raw - // rows already on the request path (canonical-source deduped in the - // aggregator), NOT from the UTC `dailyByType` — a UTC-day MEAN cannot be - // relabeled into a local-day mean. `round2` mirrors the aggregator's - // `ROUND(AVG, 2)` so correlation thresholds don't drift. - const sysDailyLocal = deriveDailyLocalMeans(aggregate.bpRawRows.sys, userTz); - const weightDailyLocal = deriveDailyLocalMeans( - aggregate.weightRawRows, - userTz, - ); - - const moodBpPairs = pairMoodWithDaily(dailyMoodEntries, sysDailyLocal); - const moodBpCorrelation = significantPearsonCorrelation(moodBpPairs); - const moodBpScatterData = moodBpPairs.map((p) => ({ mood: p.a, sysBP: p.b })); - - const moodWeightPairs = pairMoodWithDaily(dailyMoodEntries, weightDailyLocal); - const moodWeightCorrelation = significantPearsonCorrelation(moodWeightPairs); - const moodWeightScatterData = moodWeightPairs.map((p) => ({ - mood: p.a, - weight: p.b, - })); - - // v1.2.5 — this correlation is labelled "Resting pulse (bpm)"; - // score it against the RESTING series, not the raw PULSE day-mean. The - // aggregator's `dailyByType.PULSE` is a per-day AVG that folds in workout - // HR for Apple-Health users and inflates the "resting" figure. Prefer the - // clean RESTING_HEART_RATE rows; only pull RAW PULSE (for the - // low-percentile daily proxy) when the user has no resting rows — the - // proxy needs the per-day distribution, which the day-mean discards. - // - // v1.32.17 — key this resting series on `userDayKey(d, userTz)`. Its ONLY - // pairing partner is `dailyMoodEntries`, which is LOCAL-day-keyed since - // v1.32.12 (the mood rollup writer now mints `bucketStart` from the - // canonical local `MoodEntry.date` label, not a UTC truncation). Keeping - // UTC here would offset every evening logger west of UTC by one day and - // sell a lag-1 correlation as same-day. This is exactly the flip the - // v1.30.3 F8 note said becomes correct once the mood side goes local — - // it now has. (Unlike the mood side, this series builds from raw rows in - // this route, so it re-keys directly with no rollup-tier change.) - const restingPulseRows = await prisma.measurement.findMany({ - where: { - userId, - type: "RESTING_HEART_RATE", - deletedAt: null, - measuredAt: { gte: ninetyDaysAgo }, - }, - select: { measuredAt: true, value: true }, - }); - // - // #1023 — the proxy folds in Postgres. It needs one percentile per day, and - // reading the raw samples to get it walked 130 000 rows per page view on a - // watch that records once a minute, blocking the event loop for over a - // second. `readRestingPulseProxy` returns the same series one row per day. - const localDayKey = (d: Date) => userDayKey(d, userTz); - const restingPulseSeries = - restingPulseRows.length > 0 - ? resolveRestingPulseSeries({ - restingSamples: restingPulseRows, - pulseSamples: [], - dayKeyOf: localDayKey, - }).series - : await readRestingPulseProxy({ - userId, - since: ninetyDaysAgo, - timeZone: userTz, - }); - const restingByDay = new Map(); - for (const s of restingPulseSeries) { - const key = localDayKey(s.measuredAt); - const cur = restingByDay.get(key) ?? { sum: 0, count: 0 }; - cur.sum += s.value; - cur.count += 1; - restingByDay.set(key, cur); - } - const restingPulseDaily = Array.from(restingByDay.entries()) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([day, stats]) => ({ day, value: stats.sum / stats.count })); - const moodPulsePairs = pairMoodWithDaily(dailyMoodEntries, restingPulseDaily); - const moodPulseCorrelation = significantPearsonCorrelation(moodPulsePairs); - const moodPulseScatterData = moodPulsePairs.map((p) => ({ - mood: p.a, - pulse: p.b, - })); - - // ── Medication compliance ──────────────────────────────── - // Bounded reads — left untouched per the directive. - const medicationRows = await prisma.medication.findMany({ - // v1.16.11 — as-needed (PRN) medications never surface a compliance - // rate (no expected doses). - where: { userId, active: true, asNeeded: false, ...TRACKED_INTAKE_WHERE }, - // v1.15.20 — schedules through the shared compliance select so the - // configured per-dose windows reach this surface like every other. - include: { - schedules: { select: SCHEDULE_COMPLIANCE_SELECT }, - // v1.16.3 — archived schedule eras for era-aware compliance. - scheduleRevisions: { orderBy: { validFrom: "asc" } }, - // v1.25 — pause eras so paused days drop out of the denominator. - pauseEras: { select: { pausedAt: true, resumedAt: true } }, - // v1.40 (#1024) — the courses, so a gap between two expects nothing. - courses: COURSES_COMPLIANCE_SELECT, - }, - }); - // A medication with no schedule expects no dose, and `calculateCompliance` - // answers 100 % for that case. The `asNeeded: false` filter above catches - // only the PRN arm; the empty schedule is reachable on a scheduled - // medication and would otherwise report perfect adherence. - const medications = medicationRows.filter(expectsDoses); - - const resolvedCategories = await resolveMedicationCategories( - medications.map((m) => m.id), - ); - const categoryMap = Object.fromEntries( - Object.entries(resolvedCategories).map(([id, r]) => [id, r.category]), - ); - - const medCompliance = []; - const bpMedicationEvents: Array<{ - scheduledFor: Date; - takenAt: Date | null; - skipped: boolean; - }> = []; - const bpMedications = medications.filter( - (med) => (categoryMap[med.id] ?? "OTHER") === "BLOOD_PRESSURE", - ); - // Single round-trip for all medications instead of N+1: one query keyed - // on `medicationId IN (...)`, then group in memory. - const allEvents = medications.length - ? await prisma.medicationIntakeEvent.findMany({ - where: { - medicationId: { in: medications.map((m) => m.id) }, - userId, - // v1.7.0 sync — exclude tombstoned rows. - deletedAt: null, - scheduledFor: { gte: ninetyDaysAgo }, - }, - orderBy: { scheduledFor: "desc" }, - }) - : []; - - const eventsByMed = new Map(); - for (const ev of allEvents) { - const list = eventsByMed.get(ev.medicationId); - if (list) list.push(ev); - else eventsByMed.set(ev.medicationId, [ev]); - } - - for (const med of medications) { - const events = eventsByMed.get(med.id) ?? []; - const mapped = events.map((e) => ({ - takenAt: e.takenAt, - skipped: e.skipped, - scheduledFor: e.scheduledFor, - })); - // v1.7.0 — engine-routed denominator. - const medicationContext = buildComplianceMedicationContext( - med, - lastNonSkippedTakenAt(mapped), - user.timezone || DEFAULT_TIMEZONE, - ); - const c7 = calculateCompliance(mapped, med.schedules, 7, med.createdAt, { - medicationContext, - }); - const c30 = calculateCompliance(mapped, med.schedules, 30, med.createdAt, { - medicationContext, - }); - - medCompliance.push({ - id: med.id, - name: med.name, - dose: med.dose, - category: categoryMap[med.id] ?? "OTHER", - // v1.40 (#1041) — the label of a custom category; null for a built-in. - categoryLabel: resolvedCategories[med.id]?.categoryLabel ?? null, - compliance7: c7.rate, - compliance30: c30.rate, - streak: c7.streak, - taken7: c7.taken, - skipped7: c7.skipped, - missed7: c7.missed, - }); - - if ((categoryMap[med.id] ?? "OTHER") === "BLOOD_PRESSURE") { - bpMedicationEvents.push( - ...events.map((e) => ({ - scheduledFor: e.scheduledFor, - takenAt: e.takenAt, - skipped: e.skipped, - })), - ); - } - } - - // ── Correlation: BP medication continuity × systolic BP ── - // Uses the same daily BP_SYS series the aggregator pre-computed — - // no second round-trip. The intake-side aggregation matches the - // legacy code: continuity = min(1, taken / expectedPerDay), pairing - // every day that had a sys reading. - let bpMedicationCorrelation: { - r: number; - strength: string; - n: number; - medicationCount: number; - } | null = null; - const bpMedicationScatterData: Array<{ - continuityPct: number; - sysBP: number; - }> = []; - - const expectedBpIntakesPerDay = bpMedications.reduce( - (sum, med) => sum + med.schedules.length, - 0, - ); - - if (expectedBpIntakesPerDay > 0) { - // Daily systolic means (already keyed YYYY-MM-DD) from the SQL pass. - // v1.32.17 — `sysDaily` (UTC `date_trunc` from the aggregator's - // `dailyByType`) and `takenByDay` below (intake `scheduledFor` - // truncated to UTC) both live in the SAME UTC day-key space and are - // internally consistent with each other — this join is NOT broken and - // stays as is. Only the mood pairings above moved to a separately - // derived local-day series (their partner, the mood series, is - // local-day-keyed). This correlation migrates to local when the - // measurement-rollup tier buckets per-user tz (R12), not in this hotfix - // — re-keying it here without the local-bucketed rollup would mean - // per-request raw re-aggregation. - const sysByDay = new Map(); - for (const row of sysDaily) { - sysByDay.set(row.day, row.value); - } - - const takenByDay = new Map(); - // Days that carry an intake record of ANY kind. A logged skip is a real - // zero and belongs in the series; a day the person never touched is not a - // zero, it is nothing, and pairing it against that day's systolic reading - // invented a data point they never produced. Absence is not zero, here as - // everywhere else in this project, so the unlogged day drops out of both - // the scatter and the coefficient rather than dragging continuity down. - const loggedDays = new Set(); - for (const event of bpMedicationEvents) { - // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: pairs intake days with the DAY rollup buckets in sysByDay, which are UTC days - const dayKey = event.scheduledFor.toISOString().slice(0, 10); - loggedDays.add(dayKey); - if (event.skipped || !event.takenAt) continue; - takenByDay.set(dayKey, (takenByDay.get(dayKey) ?? 0) + 1); - } - - const pairs: Array<{ a: number; b: number; date: Date }> = []; - for (const [dayKey, avgSys] of sysByDay.entries()) { - if (!loggedDays.has(dayKey)) continue; - const taken = takenByDay.get(dayKey) ?? 0; - const continuity = Math.min(1, taken / expectedBpIntakesPerDay); - pairs.push({ - a: continuity, - b: avgSys, - date: dayKeyAsUtcMidnight(dayKey), - }); - bpMedicationScatterData.push({ - continuityPct: Math.round(continuity * 100), - sysBP: Math.round(avgSys * 10) / 10, - }); - } - - const corr = significantPearsonCorrelation(pairs); - if (corr) { - bpMedicationCorrelation = { - ...corr, - medicationCount: bpMedications.length, - }; - } - } - - // Generate alerts - const alerts = generateAlerts({ - bmi, - bpAvgSys: summaries.BLOOD_PRESSURE_SYS?.avg30 ?? null, - bpAvgDia: summaries.BLOOD_PRESSURE_DIA?.avg30 ?? null, - bpPctInTarget, - weightSlope30: summaries.WEIGHT?.slope30?.slope ?? null, - pulseAvg30: summaries.PULSE?.avg30 ?? null, - pulseAnomalyCount: summaries.PULSE?.anomalyCount ?? 0, - medications: medCompliance.map((m) => ({ - name: m.name, - compliance7: m.compliance7, - compliance30: m.compliance30, - })), - }); - - // Data span — derived from the earliest 90-day measurement. - const dataSpanDays = aggregate.firstMeasurementAt - ? Math.ceil( - (Date.now() - aggregate.firstMeasurementAt.getTime()) / - (24 * 60 * 60 * 1000), - ) - : 0; - - annotate({ - action: { name: "insights.comprehensive" }, - meta: { - totalMeasurements: aggregate.totalMeasurements, - moodEntries: moodEntryCount, - medications: medications.length, - }, - }); - - return { - summaries, - bmi, - bmiClassification, - bpClassification, - bpPctInTarget, - bpTargets, - weightBpCorrelation, - scatterData, - bpMedicationCorrelation, - bpMedicationScatterData, - moodSummary, - moodBpCorrelation, - moodBpScatterData, - moodWeightCorrelation, - moodWeightScatterData, - moodPulseCorrelation, - moodPulseScatterData, - medications: medCompliance, - alerts, - // Presence only: no client is built and no token is refreshed on a read. - hasProvider: await probeProviderPresence(userId), - dataSpanDays, - totalMeasurements: aggregate.totalMeasurements, - }; -} - -// ───────────────────────────────────────────────────────────── -// Helpers -// ───────────────────────────────────────────────────────────── - -/** - * v1.32.17 — derive exact local-day mean series from raw measurement rows, - * keyed by the user's timezone via `userDayKey`. The mood series is - * local-day-keyed (since v1.32.12), and `pairMoodWithDaily` joins on an - * exact YYYY-MM-DD key, so its metric partners must be local-day-keyed too. - * A UTC-day mean (from `dailyByType`) cannot be relabeled into a local-day - * mean, so the mean is recomputed here from raw rows already on the request - * path. `userDayKey` formats via a memoised `Intl.DateTimeFormat` — no - * offset arithmetic — so DST-transition days (23 h / 25 h) bucket correctly - * and the result is host-TZ-independent (CI runs UTC). `round2` mirrors the - * aggregator's `ROUND(AVG, 2)` so correlation thresholds don't drift. - * Returns `{ day, value }[]` sorted ASC by day, the shape - * `pairMoodWithDaily` expects. - */ -function deriveDailyLocalMeans( - rows: Array<{ measuredAt: Date; value: number }>, - tz: string, -): Array<{ day: string; value: number }> { - const byDay = new Map(); - for (const r of rows) { - const key = userDayKey(r.measuredAt, tz); - const cur = byDay.get(key) ?? { sum: 0, count: 0 }; - cur.sum += r.value; - cur.count += 1; - byDay.set(key, cur); - } - return Array.from(byDay.entries()) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([day, stats]) => ({ - day, - value: Math.round((stats.sum / stats.count) * 100) / 100, - })); -} - -/** - * Daily-key inner join between two daily-aggregated series. Returns - * `PairedPoint` shapes compatible with `significantPearsonCorrelation`. - * - * Inputs are sorted ASC by `day` (the aggregator's ORDER BY - * guarantees that). The walk is O(n + m) merge-join style instead of - * Map.lookup-per-element so it scales with the worst-case 90 buckets - * x 90 buckets case without surprises. - */ -function joinDailyByDay( - a: Array<{ day: string; value: number }>, - b: Array<{ day: string; value: number }>, -): PairedPoint[] { - const pairs: PairedPoint[] = []; - let i = 0; - let j = 0; - while (i < a.length && j < b.length) { - const ai = a[i]; - const bj = b[j]; - if (ai.day === bj.day) { - pairs.push({ - a: ai.value, - b: bj.value, - date: new Date(`${ai.day}T12:00:00.000Z`), - }); - i += 1; - j += 1; - } else if (ai.day < bj.day) { - i += 1; - } else { - j += 1; - } - } - return pairs; -} - -/** - * Pair the daily mood series (left side) with a daily-aggregated - * metric (right side). Matches the legacy `buildMoodMetricPairs` - * helper byte-for-byte: same daily YYYY-MM-DD key, same - * `T12:00:00.000Z` anchor for the `date` field. - */ -function pairMoodWithDaily( - dailyMood: Array<{ day: string; value: number }>, - dailyMetric: Array<{ day: string; value: number }>, -): PairedPoint[] { - if (dailyMood.length === 0 || dailyMetric.length === 0) return []; - const metricByDay = new Map(); - for (const m of dailyMetric) { - metricByDay.set(m.day, m.value); - } - const pairs: PairedPoint[] = []; - for (const mood of dailyMood) { - const metric = metricByDay.get(mood.day); - if (metric !== undefined) { - pairs.push({ - a: mood.value, - b: metric, - date: new Date(`${mood.day}T12:00:00.000Z`), - }); - } - } - return pairs; -} diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts index 394be24bb..3884229d2 100644 --- a/src/app/api/insights/generate/route.ts +++ b/src/app/api/insights/generate/route.ts @@ -1,4 +1,8 @@ /** + * GET /api/insights/generate — the read-only advisor briefing; POST — an + * on-demand generation. Both live in `src/lib/insights/briefing-request/`; + * this file keeps the gates, the body, the hourly quota and the statuses. + * * Refs #786 — async-truth contract, server end (the client end lives in * `src/components/insights/use-insights-advisor.ts`, `AdvisorSettleState`): * the force POST generates INLINE and, on success, writes the fresh briefing @@ -9,131 +13,44 @@ * GET until `cachedAt` advances past its baseline (success) or a NEWER * failure marker appears (honest error). Renaming or short-circuiting either * write breaks that convergence — the settling seam tests pin the client - * half, this comment is the server half of the contract. + * half, this comment and `briefing-request/commit.ts` the server half. */ -import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; -import { readBriefingGeneratedAt } from "@/lib/insights/briefing-generated-at"; -import { - generatedOnLocalToday, - insightsPayloadForToday, -} from "@/lib/daily/briefing-today-read"; -import { withBriefingGeneratedAt } from "@/lib/insights/briefing-generated-at"; -import { prisma } from "@/lib/db"; -import { auditLog } from "@/lib/auth/audit"; +import { NextRequest } from "next/server"; + import { apiSuccess, apiError, getClientIp, safeJson, } from "@/lib/api-response"; -import { - extractFeatures, - FeaturesPayloadTooLargeError, - BRIEFING_FEATURE_WINDOW_DAYS, -} from "@/lib/insights/features"; -import { applyInsightsExcludeFilter } from "@/lib/insights/exclude-filter"; -import { - getCachedFeatures, - withFeatureCacheScope, -} from "@/lib/insights/feature-cache"; -import { - buildBriefingIllnessCycleContext, - buildBriefingIllnessCyclePrompt, -} from "@/lib/insights/illness-cycle-briefing"; -import { compactSections } from "@/lib/ai/prompts/compact-sections"; -import { - detectGlp1Plateau, - buildGlp1PlateauPrompt, -} from "@/lib/insights/glp1-plateau"; -import { - detectDerivedBriefingSignals, - buildDerivedBriefingPrompt, -} from "@/lib/insights/derived-briefing"; -import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets"; -import { buildUserPrompt } from "@/lib/ai/prompts/insight-system-prompt"; -import { - buildSystemPromptWithReferences, - buildBriefingPersonalisationBlock, -} from "@/lib/ai/prompts/insight-generator"; -import { - buildAboutMeInsightBlock, - getSelfContextTextForUser, -} from "@/lib/ai/coach/about-me"; -import { metricsFromPresentSections } from "@/lib/ai/medical-references"; -import { resolveUserTimezone } from "@/lib/tz/resolver"; -import { insightResultSchema, type InsightResult } from "@/lib/ai/types"; -import { AI_BUDGETS, resolveInsightsMaxTokens } from "@/lib/ai/ai-budgets"; -import { resolveEffectiveTimeoutMs } from "@/lib/ai/effective-timeout"; -import { providerFailureKind } from "@/lib/ai/failure-kind"; -import { - recordBriefingFailure, - readBriefingFailure, -} from "@/lib/insights/briefing-failure-marker"; -import { resolveProvider, resolveProviderChain } from "@/lib/ai/provider"; -import { AllProvidersFailedError } from "@/lib/ai/provider-runner"; -import { - BriefingBudgetExceededError, - runBriefingCompletion, -} from "@/lib/insights/briefing-provider"; -import { assertConsentForChain } from "@/lib/ai/consent-guard"; -import { screenInsightPayloadProse } from "@/lib/ai/safety/insight-payload-screen"; -import { - findUngroundedBriefingNumbers, - readBriefingBlock, - buildBriefingGroundingCorrection, -} from "@/lib/ai/briefing-grounding"; -import { isLegacyInsightPayload } from "@/lib/ai/legacy-payload"; -import { checkRateLimit } from "@/lib/rate-limit"; -import { NextRequest } from "next/server"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { requireAiCapability } from "@/lib/ai/capabilities/gate"; import { AI_PROVIDER_NONE_ERROR_CODE } from "@/lib/ai/capabilities/types"; -import { invalidateUserInsights } from "@/lib/cache/invalidate"; -import { annotate } from "@/lib/logging/context"; -import { toProfileSex } from "@/lib/profile/sex"; -import { resolveServerLocale } from "@/lib/i18n/server-locale"; -import { normalizeLocale } from "@/lib/insights/status-shared"; -import { hasUsableStatusProvider } from "@/lib/insights/status-provider"; -import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash"; +import { withFeatureCacheScope } from "@/lib/insights/feature-cache"; import { - applyBriefingShortForm, - briefingFormForRecord, - buildBriefingShortFormBlock, -} from "@/lib/insights/briefing-short-form"; -import { featuresInReaderUnits } from "@/lib/insights/features-units"; -import { resolveUnitPreferences } from "@/lib/measurements/display-transform"; -import { enqueueForceWarm } from "@/lib/jobs/insight-pregenerate-shared"; -// v1.28.25 — the comparison-snapshot builder was a private near-copy of -// the lib export (drifted only in comments); the route now shares the -// one implementation the nightly warm pass uses. + briefingFromCache, + serveCachedFallback, +} from "@/lib/insights/briefing-request/cache"; import { - buildComparisonSnapshotForUser, - enqueueStatusRefillForUser, -} from "@/lib/insights/comprehensive-generate"; -import { cachedPayloadCarriesBriefing } from "@/lib/insights/briefing-payload"; + loadGenerationContext, + type BriefingRefusal, +} from "@/lib/insights/briefing-request/context"; import { - INSIGHTS_CACHE_SELECT, - insightsCacheWrite, - openInsightsCachedText, -} from "@/lib/insights/cached-text-cipher"; + generateBriefing, + resolveConsentedBriefingChain, +} from "@/lib/insights/briefing-request/generate"; +import { readAdvisorBriefing } from "@/lib/insights/briefing-request/read"; +import { checkRateLimit } from "@/lib/rate-limit"; export const dynamic = "force-dynamic"; -/** - * Briefing freshness window for the read-only GET. A cached briefing read - * this recently is fresh and triggers no warm; older / missing enqueues an - * out-of-band regeneration. Mirrors the 24 h cache-hit window the POST path - * honours so the two stay consistent. - */ -const BRIEFING_FRESH_MS = 24 * 60 * 60 * 1000; - const DEFAULT_INSIGHTS_RATE_LIMIT_PER_HOUR = 10; /** * Reads `INSIGHTS_RATE_LIMIT_PER_HOUR` from the environment with a - * sensible 10/hour default. The previous hard-coded 2/hour limit (from - * v1.4 P13) was too aggressive for users iterating on settings or - * regenerating after adding measurements. Operators who run on a + * sensible 10/hour default. A hard-coded 2/hour limit was too aggressive + * for users iterating on settings or regenerating after adding + * measurements. Operators who run on a * stricter LLM budget can dial it down via env without redeploying * code; values <1 fall back to the default. */ @@ -147,18 +64,81 @@ export function resolveInsightsRateLimit(): number { return parsed; } +const PROVIDER_REJECTED = + "AI provider rejected the request. Check your API key in Settings > AI"; +const PROVIDER_UNAVAILABLE = + "AI provider temporarily unavailable, try again in a moment"; + /** - * GET /api/insights/generate — read-only advisor read. - * - * The advisor consumers (hero strip, daily-briefing card, trends row) used - * to mount against the POST handler, which generates inline on a cache miss - * and blocks the page-load path on the full provider chain (up to the - * client's 8 s abort). This read-only GET serves the cached payload from - * `User.insightsCachedTextEncrypted` immediately — NEVER calling the provider — and, - * when the cache is stale / missing AND a provider is configured, enqueues - * an out-of-band warm (the same `insight-pregenerate` queue the nightly - * cron and the "prepare assessments" button use). The next read reflects - * the fresh briefing. User-initiated regeneration stays on the POST path. + * The answer for each way a generation ends without a briefing. A 502 from + * a provider would be rewritten by Cloudflare into an HTML page the client + * cannot parse, so provider failures answer 422 / 429 / 503 instead. + */ +const REFUSALS: Record< + BriefingRefusal, + { message: string; status: number; errorCode?: string } +> = { + "payload-too-large": { + message: + "Your data exceeds the AI payload size limit. Reduce the data window or exclude optional blocks in Settings > AI.", + status: 422, + }, + // The day's token ceiling: no provider was contacted. The hourly rate + // limit bounds request frequency; this bounds cost, with its own code so + // the client can say so. + "budget-exceeded": { + message: + "Daily AI token budget reached. Insights will be available again tomorrow.", + status: 429, + errorCode: "insights.generate.budgetExceeded", + }, + "provider-rejected": { message: PROVIDER_REJECTED, status: 422 }, + "provider-rate-limited": { + message: "AI provider rate-limited the request", + status: 429, + }, + "provider-unavailable": { message: PROVIDER_UNAVAILABLE, status: 503 }, + "provider-connection": { + message: "AI provider connection failed. Check your AI settings", + status: 422, + }, + "response-truncated": { + message: + "AI response was cut off before the JSON completed. Raise the token limit (INSIGHTS_MAX_TOKENS)", + status: 422, + errorCode: "ai_response_truncated", + }, + "response-invalid-json": { + message: "AI response was not valid JSON", + status: 422, + }, + "outbound-screened": { + message: + "The generated insight did not pass the safety check and was discarded. Try regenerating.", + status: 502, + errorCode: "insights.generate.outboundScreened", + }, + "profile-scope-changed": { + message: "Profile scope changed during insight generation", + status: 409, + errorCode: "insights.generate.profileScopeChanged", + }, + "scope-changed": { + message: "Insight scope changed during generation", + status: 409, + errorCode: "insights.generate.scopeChanged", + }, +}; + +function refuse(refusal: BriefingRefusal) { + const { message, status, errorCode } = REFUSALS[refusal]; + return errorCode + ? apiError(message, status, { errorCode }) + : apiError(message, status); +} + +/** + * GET — read-only advisor read: the cached payload, never a provider call. * * A pure AI read: the payload is the model-written briefing and nothing * else, so it keeps a typed refusal rather than answering 200 with nothing. @@ -172,183 +152,23 @@ export function resolveInsightsRateLimit(): number { */ export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); - // The briefing is its own capability now, not the Coach's: a user with the + // The briefing is its own capability, not the Coach's: a user with the // Coach hidden still reads their briefing, and one who opted out of AI // analysis does not. await requireAiCapability("briefing"); - const userId = user.id; - - const dbUser = await prisma.user.findUnique({ - where: { id: userId }, - select: { - insightsCachedAt: true, - ...INSIGHTS_CACHE_SELECT, - insightsCachedLocale: true, - locale: true, - timezone: true, - }, - }); - const cachedText = openInsightsCachedText(dbUser); - - // Resolve the locale the caller is actually reading (cookie / - // Accept-Language fall-back when `User.locale` is unset) and validate it - // against the shipped list — the same convention the nightly's - // `normalizeLocale` and the status routes follow. The previous - // `(locale) === "en" ? "en" : "de"` collapsed a NULL `User.locale` - // to German even for a client reading the app in English, so the - // visit-triggered warm filled the wrong cache family. - const readerLocale = normalizeLocale( - await resolveServerLocale({ + return apiSuccess( + await readAdvisorBriefing({ request, - userLocale: dbUser?.locale ?? user.locale ?? null, + userId: user.id, + userLocale: user.locale ?? null, }), ); - - // The cache is one slot per user. Text written in another language is - // never the reader's briefing, however fresh: treat the slot as empty for - // prose, and re-warm in the reader's language below. An untagged row - // predates the tag and is served as before. - const languageMismatch = - dbUser?.insightsCachedLocale != null && - dbUser.insightsCachedLocale !== readerLocale; - - const cachedAt = dbUser?.insightsCachedAt ?? null; - const timezone = dbUser?.timezone ?? DEFAULT_TIMEZONE; - // Fresh means written today on the reader's calendar, by the moment the - // text was generated: `insightsCachedAt` also moves when an unchanged - // warm only re-stamps yesterday's text. - const generatedToday = generatedOnLocalToday( - readBriefingGeneratedAt(cachedText), - timezone, - new Date(), - ); - const isFresh = - !languageMismatch && - cachedAt !== null && - generatedToday && - Date.now() - cachedAt.getTime() < BRIEFING_FRESH_MS; - - // v1.18.9 (#4) — resolve provider availability once, up front. The - // read path never blocks on or generates through the provider, but it - // must REPORT whether one exists: a provider-less account's cached - // briefing freezes at the last successful generation (the pregenerate - // cron can't refill it), and the UI presented that stale text as - // current. Surfacing `hasProvider: false` lets the insights surfaces - // pair the honest "Stand: vor X Tagen" age with a discreet - // connect-an-AI-provider hint instead of implying the read is live. - const hasProvider = await hasUsableStatusProvider(userId); - - // v1.25 — surface whether the most recent generation attempt failed (a - // marker newer than the last successful generation). The briefing keeps its - // last good text on failure, so this is the only honest signal that a shown - // briefing is held rather than freshly refreshed — and, when there is no - // last good text, that the empty state should read "couldn't generate" with - // a retry rather than the generic "no briefing yet". - const briefingFailure = await readBriefingFailure({ - userId, - since: cachedAt, - }); - // v1.28.30 — a `briefing-ungrounded` marker is NOT a failure: the - // generation succeeded but the grounding gate withheld the briefing - // (marker written after the cache write, so it survives the `since` - // filter). Surface it as the persistent omission signal — the card's - // "briefing withheld" state used to exist only on the transient POST - // response, so a nightly strip read as a silent generic empty card. - const briefingOmittedReason: "ungrounded" | null = - briefingFailure?.reason === "briefing-ungrounded" ? "ungrounded" : null; - const generationFailed = - briefingFailure !== null && briefingOmittedReason === null; - // v1.25.3 — the failure class lets the empty state point its hint at the - // right lever (raise the response timeout vs re-check the provider). - const generationFailureClass = generationFailed - ? (briefingFailure?.failureClass ?? null) - : null; - - // Read-only: never block on the provider. Warm out of band only when the - // cached briefing is stale / missing AND a provider is configured (a - // provider-less account costs one cheap chain-resolve and shows the - // empty / connect-AI state instead of a wasted enqueue). - let revalidating = false; - if (!isFresh && hasProvider) { - void enqueueForceWarm({ userId, locale: readerLocale }); - revalidating = true; - } - - if (cachedText && !languageMismatch) { - try { - const cached = JSON.parse(cachedText); - const legacyPayload = isLegacyInsightPayload(cached); - annotate({ - action: { name: "insights.generate.read" }, - meta: { cached: true, legacyPayload, revalidating, hasProvider }, - }); - return apiSuccess({ - // The briefing as every surface serves it: today's text only, its - // signals measured today, its deltas at display precision. - insights: await insightsPayloadForToday(cached, { - userId, - timezone, - language: readerLocale, - }), - cached: true, - cachedAt, - legacyPayload, - // Honest stale-serve marker: true while the out-of-band warm is - // in flight, so the client can poll (bounded) until the fresh - // briefing lands instead of sitting on the stale payload for the - // rest of the session. - revalidating, - // v1.18.9 (#4) — false when no AI provider is configured anywhere: - // the served briefing can never refresh, so the UI pairs its age - // with a connect-provider affordance. - hasProvider, - // v1.25 — true when the last generation attempt failed (held text). - generationFailed, - // v1.25.3 — coarse failure class for the empty-state hint (null when - // the last attempt succeeded). - generationFailureClass, - // v1.28.30 — persistent grounding-omission signal (see above), so a - // nightly-stripped briefing renders "withheld", not "no briefing yet". - briefingOmittedReason, - }); - } catch { - // Invalid cache row — fall through to the empty payload below. The - // warm enqueue above (when a provider exists) repairs it for the - // next read. - } - } - - annotate({ - action: { name: "insights.generate.read" }, - meta: { - cached: false, - revalidating, - hasProvider, - languageMismatch, - readerLocale, - }, - }); - return apiSuccess({ - insights: null, - cached: false, - legacyPayload: false, - revalidating, - // v1.18.9 (#4) — see the cached branch above. - hasProvider, - // v1.25 — no last-good text AND the last attempt failed: the UI shows a - // "couldn't generate" empty state with a retry instead of the generic one. - generationFailed, - // v1.25.3 — coarse failure class for the empty-state hint. - generationFailureClass, - // v1.28.30 — see the cached branch above. - briefingOmittedReason, - }); }); export const POST = apiHandler((request: NextRequest) => - // v1.18.11 — open a request-scoped feature cache so the bounded feature - // read is computed once and reused across the downgrade ladder (and any - // sibling consumer) for this request. + // A request-scoped feature cache, so the bounded feature read is computed + // once and reused across the downgrade ladder (and any sibling consumer) + // for this request. withFeatureCacheScope(async () => { const { user } = await requireAuth(); // The `briefing` capability, before any read or provider work. Refused @@ -357,63 +177,11 @@ export const POST = apiHandler((request: NextRequest) => await requireAiCapability("briefing"); const userId = user.id; - const dbUser = await prisma.user.findUnique({ - where: { id: userId }, - select: { - insightsPrivacyMode: true, - insightsCachedAt: true, - ...INSIGHTS_CACHE_SELECT, - insightsCachedLocale: true, - timezone: true, - // v1.4.36 — per-user opt-out list mirroring the Coach - // settings. Filtered off `features` before serialisation so the - // LLM never sees the excluded blocks. - insightsExcludeMetrics: true, - locale: true, - // v1.10.0 — profile for the derived-signal briefing detector. - dateOfBirth: true, - gender: true, - heightCm: true, - // v1.22 — first name for the sparse, hash-gated briefing opener. - displayName: true, - // v1.25 — per-user response-timeout (seconds), threaded onto the - // generation calls so a slow self-hosted backend honours the setting. - aiResponseTimeoutSeconds: true, - // The reader's units; the feature set is converted into them before - // it is serialised, hashed and checked for grounding. - unitPreference: true, - glucoseUnit: true, - }, - }); - const cachedText = openInsightsCachedText(dbUser); - const modeAtRead = dbUser?.insightsPrivacyMode ?? "aggregated"; - // v1.34 — the commit guard below (line ~966) matches on these exact - // columns instead of the broad `updatedAt` timestamp. Every User write - // bumps `updatedAt`, including `resolveProviderChain()`'s OAuth - // token-refresh update later in this same request — keying the - // optimistic-concurrency check on it produced a false 409 whenever a - // provider credential happened to refresh mid-generation. These are - // exactly the columns selected above that feed the prompt built below; - // capturing them here and requiring an exact match at commit still - // catches a genuine privacy-mode / exclude-list / locale / profile - // change without colliding on unrelated column writes. - const excludeMetricsAtRead = dbUser?.insightsExcludeMetrics ?? []; - const localeAtRead = dbUser?.locale ?? null; - const dateOfBirthAtRead = dbUser?.dateOfBirth ?? null; - const genderAtRead = dbUser?.gender ?? null; - const heightCmAtRead = dbUser?.heightCm ?? null; - const displayNameAtRead = dbUser?.displayName ?? null; - - const effectiveTimeoutMs = resolveEffectiveTimeoutMs( - dbUser?.aiResponseTimeoutSeconds, - AI_BUDGETS.comprehensive.timeoutMs, - ); - - const locale = await resolveServerLocale({ + const ctx = await loadGenerationContext({ request, - userLocale: dbUser?.locale ?? user.locale ?? null, + userId, + userLocale: user.locale ?? null, }); - const postTimezone = dbUser?.timezone ?? DEFAULT_TIMEZONE; const { data: body, error: jsonError } = await safeJson<{ force?: unknown; @@ -421,97 +189,12 @@ export const POST = apiHandler((request: NextRequest) => if (jsonError) return jsonError; const forceRefresh = body.force === true; - // v1.28.30 — a fresh cache WITHOUT a briefing must not satisfy a - // briefing-expecting caller: after a failed nightly warm the cached - // payload can sit inside the 24 h window all day, and the old - // unconditional short-circuit served it to every POST (three - // regenerate attempts, three `cached: true` responses, zero - // generations). The short-circuit now holds only when the cached - // payload actually carries a briefing OR the account has no usable - // provider (regenerating would be futile — serve what exists). The - // read-only GET is untouched: it never generates and its out-of-band - // revalidation behaviour stays as-is. When the briefingless fall- - // through is rate-limited below, the cached payload is served after - // all so a POST-as-read client degrades to the old behaviour instead - // of a 429. - let briefinglessCacheFallback: { - cached: unknown; - legacyPayload: boolean; - cachedAt: Date; - } | null = null; - // A cache tagged with another language is not this reader's cache and - // never short-circuits; the generation below writes the reader's own. - const cachedLocaleMatches = - dbUser?.insightsCachedLocale == null || - dbUser.insightsCachedLocale === locale; - if ( - !forceRefresh && - dbUser?.insightsCachedAt && - cachedText && - cachedLocaleMatches && - Date.now() - dbUser.insightsCachedAt.getTime() < 24 * 60 * 60 * 1000 - ) { - try { - const cached = JSON.parse(cachedText); - // v1.4.16: detect legacy payloads (pre-rationale) so the UI - // can show a "regenerate for new explainability features" CTA. - // We don't auto-regenerate — that would burn rate-limit tokens - // on a cache-hit silently. User-initiated only. - const legacyPayload = isLegacyInsightPayload(cached); - // A briefing generated on an earlier day is not today's read: like - // a briefingless cache, it does not short-circuit a provider-backed - // account, and degrades to the cached payload on a 429 below. - const carriesBriefing = - cachedPayloadCarriesBriefing(cachedText) && - generatedOnLocalToday( - readBriefingGeneratedAt(cachedText), - postTimezone, - new Date(), - ); - if (carriesBriefing || !(await hasUsableStatusProvider(userId))) { - annotate({ - action: { name: "insights.generate" }, - meta: { - cached: true, - legacyPayload, - briefingless: !carriesBriefing, - }, - }); - return apiSuccess({ - insights: await insightsPayloadForToday(cached, { - userId, - timezone: postTimezone, - language: locale, - }), - cached: true, - cachedAt: dbUser.insightsCachedAt, - legacyPayload, - }); - } - briefinglessCacheFallback = { - cached, - legacyPayload, - cachedAt: dbUser.insightsCachedAt, - }; - annotate({ - action: { name: "insights.generate.briefingless_cache_bypassed" }, - meta: { locale }, - }); - } catch { - // Invalid cache, regenerate - } - } + const decision = await briefingFromCache(ctx, forceRefresh); + if (decision.kind === "serve") return apiSuccess(decision.data); - // P13: rate-limit check moved BELOW the cache return so a hit on the - // 24h cache never burns one of the LLM-generation tokens. Otherwise a - // noisy reload loop on the dashboard would lock out real refreshes - // for an hour for no benefit. - // - // v1.4.16: limit raised from 2 → 10 per hour (env-configurable via - // `INSIGHTS_RATE_LIMIT_PER_HOUR`). The 2/hour limit triggered too - // aggressively when a user iterated on settings or regenerated after - // adding a few measurements; 10/hour still cleanly bounds cost while - // staying out of the way for legitimate use. + // The rate limit sits BELOW the cache return so a hit on the 24 h cache + // never burns one of the generation tokens; otherwise a noisy reload + // loop on the dashboard would lock out real refreshes for an hour. const limit = resolveInsightsRateLimit(); const rl = await checkRateLimit( `insights:${userId}`, @@ -519,696 +202,32 @@ export const POST = apiHandler((request: NextRequest) => 60 * 60 * 1000, ); if (!rl.allowed) { - // v1.28.30 — a POST-as-read caller whose fresh-but-briefingless cache - // fell through to regeneration degrades to the cached payload when - // the hourly quota is exhausted, instead of surfacing a 429 it never - // used to see. Explicit `force` keeps the honest 429. - if (briefinglessCacheFallback !== null) { - annotate({ - action: { name: "insights.generate" }, - meta: { cached: true, rate_limited_fallback: true }, - }); - return apiSuccess({ - insights: await insightsPayloadForToday( - briefinglessCacheFallback.cached, - { userId, timezone: postTimezone, language: locale }, - ), - cached: true, - cachedAt: briefinglessCacheFallback.cachedAt, - legacyPayload: briefinglessCacheFallback.legacyPayload, - }); + // A POST-as-read caller whose fresh-but-briefingless cache fell + // through to regeneration degrades to the cached payload when the + // hourly quota is exhausted, instead of surfacing a 429 it never used + // to see. Explicit `force` keeps the honest 429. + if (decision.fallback !== null) { + return apiSuccess(await serveCachedFallback(ctx, decision.fallback)); } return apiError(`Maximum ${limit} insight generations per hour.`, 429); } - // v1.4.16: resolve the user's full provider chain so a hard - // failure on the primary (401, 5xx, network) cascades to the next - // configured fallback rather than surfacing a 503/422 to the - // dashboard. Falls back to the single-provider resolution when the - // chain comes back empty AND the legacy `aiProvider` field still - // points at something usable — preserving v1.4.15 behaviour for - // accounts that haven't customised the chain. - const chain = await resolveProviderChain(userId); - if (chain.length === 0) { - // Try the legacy single-provider path one more time so users who - // configured AI before v1.4.16 don't suddenly see the 422. - const legacy = await resolveProvider(userId); - if (legacy.type === "none") { - return apiError( - "No AI provider configured. Connect ChatGPT in settings or ask your admin to set up an API key.", - 422, - { errorCode: AI_PROVIDER_NONE_ERROR_CODE }, - ); - } - chain.push({ providerType: "admin-openai", instance: legacy }); - } - - // v1.12.1 — consent gate before any server-managed external egress. - // Required for the operator's global key (`admin-openai`); BYOK / local / - // ChatGPT-OAuth chains stay ungated. Throws ConsentRequiredError → - // apiHandler returns 403 + `consent.ai.required`. - await assertConsentForChain({ userId, chain, surface: "insights" }); - - const includeRaw = dbUser?.insightsPrivacyMode === "raw"; - // v1.4.36 — `extractFeatures` enforces a 5 MB ceiling on the - // serialised payload. If the raw (bucketed) shape ever blows past - // it (regression watch — the v1.4.35 rawMeasurements shape hit - // 25.9 MB on the largest tenant), the helper throws - // `FeaturesPayloadTooLargeError`. We downgrade to the aggregated - // shape rather than 500-ing so the user still gets an insight; the - // annotate event surfaces the regression to ops via the Logflare - // pipeline. - let features: Awaited>; - let payloadDowngraded = false; - let payloadOversizeBytes: number | null = null; - // v1.4.36 — extra fallback when even the aggregated shape - // crosses the 5 MB ceiling (very large medication history + multi- - // year context). Strips anthropometrics + medications + every other - // exclude-token-mapped block and tries one more time. On a third - // failure we return 422 with an annotate event for ops visibility - // rather than 500-ing out of an uncaught throw. - const MAX_DOWNGRADE_TOKENS: ReadonlyArray = [ - "anthropometrics", - "medications", - "compliance", - "sleep", - "steps", - "hrv", - "resting_hr", - ]; - let payloadHardDowngraded = false; - // v1.18.11 — bound the briefing's bulk feature read to a recent window - // (all-time extremes are sourced separately, see features.ts). The downgrade - // ladder below stays as the safety net for the rare oversize payload. - const featureWindow = { sinceDays: BRIEFING_FEATURE_WINDOW_DAYS }; - try { - // v1.18.11 — compute-once-per-request: the scope opened around this - // handler shares the bounded feature read across the downgrade ladder. - features = await getCachedFeatures({ - userId, - includeRaw, - sinceDays: featureWindow.sinceDays, - compute: () => extractFeatures(userId, includeRaw, featureWindow), - }); - } catch (err) { - if (err instanceof FeaturesPayloadTooLargeError) { - payloadDowngraded = true; - payloadOversizeBytes = err.sizeBytes; - try { - features = await getCachedFeatures({ - userId, - includeRaw: false, - sinceDays: featureWindow.sinceDays, - compute: () => extractFeatures(userId, false, featureWindow), - }); - } catch (retryErr) { - if (retryErr instanceof FeaturesPayloadTooLargeError) { - // Aggregated shape ALSO crossed the ceiling. Drop optional - // context blocks (anthropometrics, medications, sleep, - // steps, hrv, resting_hr) via the existing exclude filter - // and try once more. - payloadHardDowngraded = true; - payloadOversizeBytes = retryErr.sizeBytes; - try { - const aggregated = await getCachedFeatures({ - userId, - includeRaw: false, - sinceDays: featureWindow.sinceDays, - compute: () => extractFeatures(userId, false, featureWindow), - }); - features = applyInsightsExcludeFilter( - aggregated, - MAX_DOWNGRADE_TOKENS, - ); - } catch (finalErr) { - // Even the aggregated read itself blew up. Annotate and - // return 422 rather than let the uncaught throw 500 out. - annotate({ - meta: { - insights_payload_too_large: true, - insights_payload_oversize_bytes: - finalErr instanceof FeaturesPayloadTooLargeError - ? finalErr.sizeBytes - : payloadOversizeBytes, - }, - }); - return apiError( - "Your data exceeds the AI payload size limit. Reduce the data window or exclude optional blocks in Settings > AI.", - 422, - ); - } - } else { - throw retryErr; - } - } - } else { - throw err; - } - } - // v1.4.36 — apply the user's exclude-metrics opt-out list - // BEFORE serialisation so the model never sees the dropped blocks. - const excludeList = dbUser?.insightsExcludeMetrics ?? []; - features = applyInsightsExcludeFilter(features, excludeList); - // The reader's units, after all canonical arithmetic: the prompt, the - // stored hash and the grounding check read the same converted figures. - const units = resolveUnitPreferences({ - unitPreference: dbUser?.unitPreference, - glucoseUnit: dbUser?.glucoseUnit, - }); - features = featuresInReaderUnits(features, units); - // v1.4.36 — drop zero-row blocks so the prompt never carries - // labelled-empty sections (`"sleep": []`, `"medications": []`, - // etc.). Prevents the model from narrating "there are no - // medications in your data" when the user explicitly excluded the - // block. - const compactFeatures = compactSections( - features as unknown as Record, - ); - // Compact on the wire: indentation is about 40% of a pretty-printed - // payload, and the model reads the structure without it (#1217). - const featuresJson = JSON.stringify(compactFeatures); - if (payloadDowngraded) { - annotate({ - meta: { - insights_features_downgraded: true, - insights_features_oversize_bytes: payloadOversizeBytes, - insights_features_hard_downgraded: payloadHardDowngraded, - }, - }); - } - - // v1.4.16 — pick up the user's persisted comparison toggle - // and, when active, build a compact prior-period snapshot from the - // `features` summaries that already include `avg30LastMonth` / - // `avg30LastYear` (added in the analytics summarize() helper). The - // narrative is default-on whenever the toggle is on per research §7 - // Q4 — the pulldown is the single affordance. - const comparisonSnapshot = await buildComparisonSnapshotForUser(userId); - // v1.4.25 — GLP-1 plateau detector. Appends a SYSTEM CONTEXT - // block to the user prompt when the active GLP-1 medication has been - // on a stable dose for ≥21 days with weight delta within ±0.5 kg. - // The block instructs the model to emit a glp1_plateau keyFinding - // framed observationally (no dose recommendation per GROUND RULE 13). - // Returns null when no GLP-1 is active OR no plateau condition; - // 99% of users land in the null branch and pay zero token cost. - const plateauContext = await detectGlp1Plateau(userId); - let userPrompt = buildUserPrompt( - featuresJson, - dbUser?.insightsPrivacyMode ?? "aggregated", - locale, - comparisonSnapshot ?? undefined, - ); - if (plateauContext) { - userPrompt += buildGlp1PlateauPrompt( - plateauContext, - locale, - units.system, - ); - } - // v1.15.20 — fold the user-authored "about me" self-description - // (Settings → AI) into the briefing as a delimited, user-provided - // SYSTEM CONTEXT block. Null (no text / undecryptable) costs nothing. - const aboutMe = await getSelfContextTextForUser(userId, locale); - if (aboutMe) { - userPrompt += buildAboutMeInsightBlock(aboutMe, locale); - } - // v1.10.0 — fold a notable derived wellness signal (readiness / recovery - // shift, confidence-gated) into the briefing as a SYSTEM CONTEXT block. - // Reads the one derived contract; null (the common case) costs nothing. - const derivedSex = toProfileSex(dbUser?.gender); - const derivedBriefing = await detectDerivedBriefingSignals(userId, { - ageYears: getAgeFromDateOfBirth(dbUser?.dateOfBirth ?? null), - sex: derivedSex, - heightCm: dbUser?.heightCm ?? null, - }); - if (derivedBriefing) { - userPrompt += buildDerivedBriefingPrompt(derivedBriefing, locale); - } - - // v1.18.11 — fold illness-episode + cycle state into the briefing so a - // mid-illness or mid-cycle user gets context-aware prose. Reuses the same - // server-authoritative builders the Coach assembles; both are module-gated - // and short-circuit to null (zero token cost) for users without them. - const illnessCycleCtx = await buildBriefingIllnessCycleContext( - userId, - dbUser?.gender ?? null, - await resolveUserTimezone(userId), - ); - if (illnessCycleCtx) { - userPrompt += buildBriefingIllnessCyclePrompt(illnessCycleCtx, locale); - } - - // v1.22 — opener-archetype rotation + sparse first-name personalization - // (deterministic per user+day; omitted for unnamed accounts). - userPrompt += buildBriefingPersonalisationBlock( - userId, - dbUser?.displayName ?? null, - locale, - await resolveUserTimezone(userId), - ); - // v1.43 (#1217) — the short form on the `frugal` usage level; the full - // contract on every other level. - const briefingForm = await briefingFormForRecord(userId); - userPrompt += buildBriefingShortFormBlock(locale, briefingForm); - - // v1.12.7 — inject the curated SOURCES block for the metric sections - // this briefing actually carries, so a normative claim can cite a real - // `referenceId` the schema + UI footnote already support. Returns the plain - // prompt unchanged when no applicable metric section is present. - const referenceMetrics = metricsFromPresentSections({ - bloodPressure: features.bloodPressure != null, - weight: features.weight != null, - pulse: features.pulse != null, - mood: features.mood != null, - medication: (features.medications?.length ?? 0) > 0, - }); - - let result; - let workingProviderType: string; - let fallbackHopCount = 0; - try { - const fallback = await runBriefingCompletion({ - userId, - chain, - // The strict prompt (PROMPT_VERSION 4.20.x) carries GROUND RULE 8 - // — emit a top-level `dailyBriefing` block when the snapshot has - // analysable signal — plus the trendAnnotations and - // storyboardAnnotations rules. The legacy `getInsightsSystemPrompt` - // returned the v1.4.5 `{changed, stable, drivers, …}` shape and - // never asked the model for dailyBriefing, so the hero strip's - // "Re-run analysis" button kept producing a payload with no - // briefing block (Issue 1, v1.4.20 post-deploy). The route already - // tolerates the strict shape via `insightResultSchema.safeParse`'s - // soft fallback to `parsed` and `passthrough()` on the strict - // schema, so switching the prompt does not break legacy callers. - systemPrompt: buildSystemPromptWithReferences(locale, referenceMetrics), - userPrompt, - temperature: 0.3, - // v1.28.28 (#470) — env-tunable output ceiling (INSIGHTS_MAX_TOKENS, - // default 2500). The old fixed 1500 truncated verbose models' - // briefing JSON mid-string → generic invalid-JSON 422. - maxTokens: resolveInsightsMaxTokens(), - // v1.21.5 — wider upstream budget so the reasoning-heavy briefing - // generation is not aborted at the client's 60 s default on large - // accounts (which returned an empty briefing + trend narrative). - // v1.25 — honours the per-user response-timeout setting. - timeoutMs: effectiveTimeoutMs, - stage: "generate", - // v1.41 — a briefing asked for by hand reasons exactly like the - // scheduled one; the retries below repair a reply and do not. - reasoningJob: "daily_briefing", - }); - result = fallback.result; - workingProviderType = fallback.workingProvider.providerType; - fallbackHopCount = fallback.fallbackHops.length; - } catch (e) { - // The day's token ceiling refused the reservation — no provider was - // contacted, so this is NOT a provider failure and must not record a - // briefing-failure marker (which would make the read path claim the - // refresh broke). The hourly rate limit bounds request frequency; this - // bounds cost. 429 with a distinct code so the client can say so. - if (e instanceof BriefingBudgetExceededError) { - return apiError( - "Daily AI token budget reached. Insights will be available again tomorrow.", - 429, - { errorCode: "insights.generate.budgetExceeded" }, - ); - } - // v1.25 — record a dated failure marker (no cache row is written on this - // path, so the last good briefing stays intact). The read path pairs the - // preserved text with a discreet "couldn't refresh" hint, or shows a - // retry empty state when there is no last good text. - // v1.25.3 — carry the upstream status so the read-path hint can tell an - // auth / rate-limit failure apart from a plain timeout. For an - // all-providers-failed error the first hop's status is the most - // representative; a single provider error exposes its own `httpStatus`. - const failureHttpStatus = - e instanceof AllProvidersFailedError - ? (e.attempts[0]?.httpStatus ?? null) - : typeof (e as { httpStatus?: number }).httpStatus === "number" - ? (e as { httpStatus?: number }).httpStatus - : null; - void recordBriefingFailure({ - userId, - reason: - e instanceof AllProvidersFailedError - ? "all-providers-failed" - : "provider-error", - locale, - httpStatus: failureHttpStatus, - failureKind: - e instanceof AllProvidersFailedError - ? (e.attempts[0]?.failureKind ?? null) - : providerFailureKind(e).failure, - }); - if (e instanceof AllProvidersFailedError) { - annotate({ - meta: { - insights_chain_outcome: "all-failed", - insights_chain_attempts: e.attempts.length, - insights_chain_first_provider: e.attempts[0]?.providerType ?? null, - insights_chain_first_status: e.attempts[0]?.httpStatus ?? null, - }, - }); - // Pick the most specific user-facing message we can. Auth-class - // failures across the entire chain mean every provider's - // credential is bad — keep the v1.4.5 "check your API key" - // wording so the user knows where to act. 429 across the chain - // is rate-limit; 5xx is upstream brown-out; transport-only - // (ECONNRESET, no httpStatus) maps to the generic connection - // hint so users on flaky networks don't see "rate-limited". - const allAuth = e.attempts.every( - (a) => a.httpStatus === 401 || a.httpStatus === 403, - ); - if (allAuth) { - return apiError( - "AI provider rejected the request. Check your API key in Settings > AI", - 422, - ); - } - const all429 = e.attempts.every((a) => a.httpStatus === 429); - if (all429) { - return apiError("AI provider rate-limited the request", 429); - } - const has5xx = e.attempts.some( - (a) => a.httpStatus !== null && a.httpStatus >= 500, - ); - if (has5xx) { - return apiError( - "AI provider temporarily unavailable, try again in a moment", - 503, - ); - } - const allTransport = e.attempts.every( - (a) => a.httpStatus === null || a.httpStatus <= 0, - ); - if (allTransport) { - return apiError( - "AI provider connection failed. Check your AI settings", - 422, - ); - } - return apiError( - "AI provider temporarily unavailable, try again in a moment", - 503, - ); - } - // v1.4.6 T5 mapped only the parse-error branch from 502→422. Non- - // hard provider errors (e.g. a custom 4xx from a self-hosted local - // model) still propagate here. Cloudflare rewrites 5xx to its own - // HTML error page, which breaks `await res.json()` on the - // dashboard. Mirror the v1.4.5 ai/test categorisation. - const err = e as Error & { httpStatus?: number; bodyExcerpt?: string }; - annotate({ - meta: { - insights_provider_error: err.message.slice(0, 500), - insights_provider_status: err.httpStatus ?? null, - insights_provider_body_excerpt: - err.bodyExcerpt?.slice(0, 500) ?? null, - }, - }); - const status = err.httpStatus ?? 0; - if (status === 401 || status === 403) { - return apiError( - "AI provider rejected the request. Check your API key in Settings > AI", - 422, - ); - } - if (status === 429) { - return apiError("AI provider rate-limited the request", 429); - } - if (status >= 500) { - return apiError( - "AI provider temporarily unavailable, try again in a moment", - 503, - ); - } + // The chain, with the consent gate on it (403 `consent.ai.required` + // before any server-managed external egress). + const chain = await resolveConsentedBriefingChain(userId); + if (chain === null) { return apiError( - "AI provider connection failed. Check your AI settings", + "No AI provider configured. Connect ChatGPT in settings or ask your admin to set up an API key.", 422, + { errorCode: AI_PROVIDER_NONE_ERROR_CODE }, ); } - let insights: InsightResult | Record; - try { - const parsed = JSON.parse(result.content); - // Try new schema first, fall back to raw parsed if validation fails - const validated = insightResultSchema.safeParse(parsed); - insights = validated.success ? validated.data : parsed; - } catch { - // Returning 502 here triggers Cloudflare's HTML error rewrite, which - // breaks `await res.json()` on the client side. 422 stays passthrough - // so the React Query mutation can read the JSON body and surface a - // readable message. Same fix pattern as v1.4.5 ai/test. - void recordBriefingFailure({ userId, reason: "invalid-json", locale }); - // v1.28.28 (#470) — truncation-aware: `finishReason === "length"` means - // the model hit the output-token ceiling and the JSON was cut off - // mid-string, which is a budget problem, not a model-quality one. Say - // so with a distinct errorCode instead of the generic invalid-JSON line. - if (result.finishReason === "length") { - annotate({ meta: { insights_response_truncated: true } }); - return apiError( - "AI response was cut off before the JSON completed. Raise the token limit (INSIGHTS_MAX_TOKENS)", - 422, - { errorCode: "ai_response_truncated" }, - ); - } - return apiError("AI response was not valid JSON", 422); - } - - // v1.18.10 — daily-briefing number-grounding gate, mirroring the - // recommendations citation cross-check next door. The schema only checks the - // briefing's SHAPE; this asserts every number the briefing prose restates - // traces to a `features.signalsOfDay` figure. On a miss: one corrective - // retry, then drop the briefing block rather than ship a fabricated number. - // - // v1.28.28 (#470) — when the gate strips the briefing, SAY SO: the 200 - // used to carry a silently-null briefing, the card showed "no briefing - // yet", and the regenerate button read as doing nothing. The additive - // `briefingOmittedReason` field lets the card render an honest "a figure - // couldn't be verified, so the briefing wasn't shown" state instead. - let briefingOmittedReason: "ungrounded" | null = null; - { - const signals = features.signalsOfDay ?? null; - let ungrounded = findUngroundedBriefingNumbers( - readBriefingBlock(insights), - signals, - features, - comparisonSnapshot, - ); - if (ungrounded.length > 0) { - annotate({ - action: { name: "insights.generate.briefing_grounding_retry" }, - meta: { ungroundedCount: ungrounded.length }, - }); - try { - const retry = await runBriefingCompletion({ - userId, - chain, - systemPrompt: buildSystemPromptWithReferences( - locale, - referenceMetrics, - ), - userPrompt: `${userPrompt}\n\n${buildBriefingGroundingCorrection(ungrounded)}`, - temperature: 0.3, - // v1.28.28 (#470) — same env-tunable ceiling as the first call. - maxTokens: resolveInsightsMaxTokens(), - // v1.21.5 — wider upstream budget; see the first generation call. - // v1.25 — honours the per-user response-timeout setting. - timeoutMs: effectiveTimeoutMs, - stage: "grounding-retry", - }); - const parsedRetry = JSON.parse(retry.result.content); - const validatedRetry = insightResultSchema.safeParse(parsedRetry); - const retryInsights = validatedRetry.success - ? validatedRetry.data - : parsedRetry; - ungrounded = findUngroundedBriefingNumbers( - readBriefingBlock(retryInsights), - signals, - features, - comparisonSnapshot, - ); - if (ungrounded.length === 0) { - insights = retryInsights; - result = retry.result; - workingProviderType = retry.workingProvider.providerType; - } - } catch { - // Retry failed to produce parseable JSON, or the day's ceiling - // refused the correction pass — either way fall through to the - // strip. An ungrounded figure is never persisted. - } - } - // Still ungrounded after the retry: the freshly generated briefing is - // discarded rather than shipped — the anti-fabrication guarantee is - // absolute here too. But rather than leave a hole, stand the PREVIOUS - // cached briefing in its place: it passed this identical gate when it - // was written, so it carries no fabricated figure, and a day-old - // grounded briefing beats a vanished one. This mirrors the background - // generator, which is the path that writes the same cached payload; - // the two must not disagree on disposal. - // - // `briefingOmittedReason` is set ONLY when no previous briefing exists - // to stand in, so the card's honest "a figure couldn't be verified" - // state still fires exactly when the user really has no briefing. - if (ungrounded.length > 0 && insights && typeof insights === "object") { - let fallbackBriefing: unknown = null; - if (cachedText) { - try { - const prev = JSON.parse(cachedText) as Record; - fallbackBriefing = prev.dailyBriefing ?? null; - } catch { - // Unparseable previous payload — nothing safe to stand in. - } - } - (insights as Record).dailyBriefing = fallbackBriefing; - briefingOmittedReason = fallbackBriefing == null ? "ungrounded" : null; - annotate({ - action: { name: "insights.generate.briefing_grounding_stripped" }, - meta: { - ungroundedCount: ungrounded.length, - briefing_fallback: - fallbackBriefing != null ? "previous-cached" : "stripped", - }, - }); - } - } - - // Outbound safety screen over the whole prose surface, before the persist. - // - // This route is user-initiated but writes the SAME cached payload the - // background generator writes, so a violation must not be persisted here - // either. The user is waiting, so unlike the background path they get an - // explicit error rather than silence -- and because nothing was written, - // the previous cached insight is still what the app renders. - const screened = screenInsightPayloadProse(insights, locale); - if (screened) { - annotate({ - action: { name: "insights.generate.outbound_blocked" }, - meta: { locale, reason: screened }, - }); - return apiError( - "The generated insight did not pass the safety check and was discarded. Try regenerating.", - 502, - { errorCode: "insights.generate.outboundScreened" }, - ); - } - - const currentSelfContext = await getSelfContextTextForUser(userId, locale); - if (currentSelfContext !== aboutMe) { - return apiError("Profile scope changed during insight generation", 409, { - errorCode: "insights.generate.profileScopeChanged", - }); - } - if (briefingForm === "short") { - insights = applyBriefingShortForm(insights); - } - const stored = withBriefingGeneratedAt(insights, new Date()); - const committed = await prisma.user.updateMany({ - where: { - id: userId, - insightsPrivacyMode: modeAtRead, - insightsExcludeMetrics: { equals: excludeMetricsAtRead }, - locale: localeAtRead, - dateOfBirth: dateOfBirthAtRead, - gender: genderAtRead, - heightCm: heightCmAtRead, - displayName: displayNameAtRead, - }, - data: { - insightsCachedAt: new Date(), - ...insightsCacheWrite(JSON.stringify(stored)), - insightsCachedLocale: locale, - insightsSnapshotHash: hashInsightSnapshot({ - features: compactFeatures, - aboutMe: aboutMe ?? null, - comparisonBaseline: comparisonSnapshot?.baseline ?? "none", - generationLocale: locale, - ...(briefingForm === "short" ? { briefingForm } : {}), - }), - }, - }); - if (committed.count === 0) { - return apiError("Insight scope changed during generation", 409, { - errorCode: "insights.generate.scopeChanged", - }); - } - - // v1.28.30 — persist the grounding omission (marker AFTER the cache - // write so it is newer than `insightsCachedAt`). The transient - // `briefingOmittedReason` on this response only reached the caller that - // forced the regenerate; every later read showed a generic "no briefing - // yet". The dated marker lets the GET surface the same honest - // "briefing withheld" state until the next successful generation. - if (briefingOmittedReason === "ungrounded") { - void recordBriefingFailure({ - userId, - reason: "briefing-ungrounded", - locale, - }); - } - - // v1.16.8 — no blanket per-status eviction here any more (nuking ~45 - // cache rows per manual regenerate deleted every hash baseline and was - // the source of the post-regenerate card-regeneration storm). Instead, - // the manual regenerate takes the cards along through the hash-gated - // queue: every scope the user has data for is enqueued, the worker - // forces each generator past its same-day cache read, and the content- - // hash gate regenerates exactly the cards whose data changed while - // refreshing the unchanged ones for free. This is the user's escape - // hatch for a card they can SEE is stale — without it the cards had no - // refresh path until the next nightly warm. - const refillScopes = await enqueueStatusRefillForUser( - userId, - normalizeLocale(locale), - ); - annotate({ - action: { name: "insights.generate.cards_refill" }, - meta: { scopes: refillScopes }, - }); - - // v1.7.0 — the dashboard snapshot embeds the pre-generated daily - // briefing read-only; drop it so the next snapshot carries the fresh - // briefing instead of the stale one. - invalidateUserInsights(userId); - - await auditLog("insights.generate", { - userId, + const outcome = await generateBriefing({ + ctx, + chain, ipAddress: getClientIp(request), - details: { - privacyMode: dbUser?.insightsPrivacyMode, - tokensUsed: result.tokensUsed, - providerType: result.providerType, - chainProviderType: workingProviderType, - fallbackHopCount, - model: result.model, - }, - }); - - annotate({ - action: { name: "insights.generate" }, - meta: { - cached: false, - providerType: result.providerType, - chainProviderType: workingProviderType, - fallbackHopCount, - model: result.model, - tokensUsed: result.tokensUsed, - }, - }); - - return apiSuccess({ - insights: await insightsPayloadForToday(stored, { - userId, - timezone: postTimezone, - language: locale, - }), - cached: false, - legacyPayload: false, - // v1.28.28 (#470) — additive: non-null when the grounding gate stripped - // the briefing from THIS generation, so the card can explain the hole. - briefingOmittedReason, }); + return outcome.ok ? apiSuccess(outcome.data) : refuse(outcome.refusal); }), ); diff --git a/src/lib/analytics/compliance/__tests__/as-needed-compliance-exclusion.test.ts b/src/lib/analytics/compliance/__tests__/as-needed-compliance-exclusion.test.ts index af4b53233..af62e8406 100644 --- a/src/lib/analytics/compliance/__tests__/as-needed-compliance-exclusion.test.ts +++ b/src/lib/analytics/compliance/__tests__/as-needed-compliance-exclusion.test.ts @@ -31,7 +31,7 @@ const CALL_SITES: Array<{ file: string; surface: string }> = [ surface: "insight targets", }, { - file: "src/app/api/insights/comprehensive/route.ts", + file: "src/lib/insights/comprehensive-overview/medication-compliance.ts", surface: "comprehensive insight", }, { diff --git a/src/lib/insights/briefing-request/cache.ts b/src/lib/insights/briefing-request/cache.ts new file mode 100644 index 000000000..2673bf402 --- /dev/null +++ b/src/lib/insights/briefing-request/cache.ts @@ -0,0 +1,133 @@ +/** + * The generating POST's cache short-circuit, and the cached payload it + * degrades to when the hour's generation quota is spent. + * + * A fresh cache WITHOUT a briefing must not satisfy a briefing-expecting + * caller: after a failed nightly warm the cached payload can sit inside the + * 24 h window all day, and an unconditional short-circuit served it to every + * POST (three regenerate attempts, three `cached: true` responses, zero + * generations). The short-circuit holds only when the cached payload carries + * today's briefing OR the account has no usable provider (regenerating would + * be futile — serve what exists). When the briefingless fall-through is + * rate-limited, the cached payload is served after all, so a POST-as-read + * client degrades to the old behaviour instead of a 429. + */ +import { isLegacyInsightPayload } from "@/lib/ai/legacy-payload"; +import { + generatedOnLocalToday, + insightsPayloadForToday, +} from "@/lib/daily/briefing-today-read"; +import { readBriefingGeneratedAt } from "@/lib/insights/briefing-generated-at"; +import { cachedPayloadCarriesBriefing } from "@/lib/insights/briefing-payload"; +import { hasUsableStatusProvider } from "@/lib/insights/status-provider"; +import { annotate } from "@/lib/logging/context"; + +import type { GenerationContext } from "./context"; + +/** A fresh cached payload kept aside for the quota fallback. */ +export interface CachedFallback { + cached: unknown; + legacyPayload: boolean; + cachedAt: Date; +} + +export type CacheDecision = + | { kind: "serve"; data: Record } + | { kind: "generate"; fallback: CachedFallback | null }; + +export async function briefingFromCache( + ctx: GenerationContext, + forceRefresh: boolean, +): Promise { + const { dbUser, cachedText, userId, locale } = ctx; + // A cache tagged with another language is not this reader's cache and + // never short-circuits; the generation writes the reader's own. + const cachedLocaleMatches = + dbUser?.insightsCachedLocale == null || + dbUser.insightsCachedLocale === locale; + if ( + !forceRefresh && + dbUser?.insightsCachedAt && + cachedText && + cachedLocaleMatches && + Date.now() - dbUser.insightsCachedAt.getTime() < 24 * 60 * 60 * 1000 + ) { + try { + const cached = JSON.parse(cachedText); + // Legacy payloads (pre-rationale) are flagged so the UI can show a + // "regenerate for new explainability features" CTA. No automatic + // regeneration: that would burn rate-limit tokens on a cache hit + // silently. User-initiated only. + const legacyPayload = isLegacyInsightPayload(cached); + // A briefing generated on an earlier day is not today's read: like a + // briefingless cache, it does not short-circuit a provider-backed + // account, and degrades to the cached payload on a 429. + const carriesBriefing = + cachedPayloadCarriesBriefing(cachedText) && + generatedOnLocalToday( + readBriefingGeneratedAt(cachedText), + ctx.timezone, + new Date(), + ); + if (carriesBriefing || !(await hasUsableStatusProvider(userId))) { + annotate({ + action: { name: "insights.generate" }, + meta: { + cached: true, + legacyPayload, + briefingless: !carriesBriefing, + }, + }); + return { + kind: "serve", + data: { + insights: await insightsPayloadForToday(cached, { + userId, + timezone: ctx.timezone, + language: locale, + }), + cached: true, + cachedAt: dbUser.insightsCachedAt, + legacyPayload, + }, + }; + } + annotate({ + action: { name: "insights.generate.briefingless_cache_bypassed" }, + meta: { locale }, + }); + return { + kind: "generate", + fallback: { + cached, + legacyPayload, + cachedAt: dbUser.insightsCachedAt, + }, + }; + } catch { + // Invalid cache, regenerate + } + } + return { kind: "generate", fallback: null }; +} + +/** The cached payload, served when the hour's quota refused a generation. */ +export async function serveCachedFallback( + ctx: GenerationContext, + fallback: CachedFallback, +): Promise> { + annotate({ + action: { name: "insights.generate" }, + meta: { cached: true, rate_limited_fallback: true }, + }); + return { + insights: await insightsPayloadForToday(fallback.cached, { + userId: ctx.userId, + timezone: ctx.timezone, + language: ctx.locale, + }), + cached: true, + cachedAt: fallback.cachedAt, + legacyPayload: fallback.legacyPayload, + }; +} diff --git a/src/lib/insights/briefing-request/commit.ts b/src/lib/insights/briefing-request/commit.ts new file mode 100644 index 000000000..44141b255 --- /dev/null +++ b/src/lib/insights/briefing-request/commit.ts @@ -0,0 +1,202 @@ +/** + * The on-demand briefing's commit: the outbound safety screen, the two scope + * guards, the cache write, and everything that follows a written briefing. + * + * Contract with the client (`AdvisorSettleState` in + * `src/components/insights/use-insights-advisor.ts`, refs #786): on success + * this writes the fresh briefing to the user cache (`insightsCachedAt` + * advances) — even when the client has long since aborted its 45 s request — + * and a failed inline generation leaves a failure marker the read reports as + * `generationFailed`. The client settles a timed-out regenerate on exactly + * those two signals, so renaming or short-circuiting either write breaks + * that convergence. + */ +import { prisma } from "@/lib/db"; +import { auditLog } from "@/lib/auth/audit"; +import { getSelfContextTextForUser } from "@/lib/ai/coach/about-me"; +import { screenInsightPayloadProse } from "@/lib/ai/safety/insight-payload-screen"; +import { invalidateUserInsights } from "@/lib/cache/invalidate"; +import { insightsPayloadForToday } from "@/lib/daily/briefing-today-read"; +import { recordBriefingFailure } from "@/lib/insights/briefing-failure-marker"; +import { withBriefingGeneratedAt } from "@/lib/insights/briefing-generated-at"; +import { applyBriefingShortForm } from "@/lib/insights/briefing-short-form"; +import { insightsCacheWrite } from "@/lib/insights/cached-text-cipher"; +import { enqueueStatusRefillForUser } from "@/lib/insights/comprehensive-generate"; +import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash"; +import { normalizeLocale } from "@/lib/insights/status-shared"; +import { annotate } from "@/lib/logging/context"; + +import type { BriefingCall } from "./completion"; +import type { BriefingRefusal } from "./context"; +import type { PreparedFeatures } from "./features"; +import type { GroundedReply } from "./grounding"; + +export type CommitOutcome = + | { ok: true; data: Record } + | { ok: false; refusal: BriefingRefusal }; + +/** Write the briefing, guarded on the scope the prompt was built from. */ +async function writeBriefing( + call: BriefingCall, + prepared: PreparedFeatures, + stored: object, +): Promise { + const { ctx, prompt } = call; + const { scope, locale } = ctx; + const committed = await prisma.user.updateMany({ + where: { + id: ctx.userId, + insightsPrivacyMode: scope.mode, + insightsExcludeMetrics: { equals: scope.excludeMetrics }, + locale: scope.locale, + dateOfBirth: scope.dateOfBirth, + gender: scope.gender, + heightCm: scope.heightCm, + displayName: scope.displayName, + }, + data: { + insightsCachedAt: new Date(), + ...insightsCacheWrite(JSON.stringify(stored)), + insightsCachedLocale: locale, + insightsSnapshotHash: hashInsightSnapshot({ + features: prepared.compactFeatures, + aboutMe: prompt.aboutMe ?? null, + comparisonBaseline: prompt.comparisonSnapshot?.baseline ?? "none", + generationLocale: locale, + ...(prompt.briefingForm === "short" + ? { briefingForm: prompt.briefingForm } + : {}), + }), + }, + }); + return committed.count > 0; +} + +/** What follows a written briefing: the marker, the cards, the audit row. */ +async function afterWrite( + call: BriefingCall, + reply: GroundedReply, + ipAddress: string | null, + fallbackHopCount: number, +): Promise { + const { ctx } = call; + const { userId, locale } = ctx; + const { result, workingProviderType } = reply; + // The grounding omission persists (marker AFTER the cache write so it is + // newer than `insightsCachedAt`). The transient `briefingOmittedReason` on + // the response only reached the caller that forced the regenerate; every + // later read showed a generic "no briefing yet". The dated marker lets the + // read surface the same honest "briefing withheld" state until the next + // successful generation. + if (reply.briefingOmittedReason === "ungrounded") { + void recordBriefingFailure({ + userId, + reason: "briefing-ungrounded", + locale, + }); + } + + // No blanket per-status eviction (nuking ~45 cache rows per manual + // regenerate deleted every hash baseline and caused a card-regeneration + // storm). The manual regenerate takes the cards along through the + // hash-gated queue: every scope the user has data for is enqueued, the + // worker forces each generator past its same-day cache read, and the + // content-hash gate regenerates exactly the cards whose data changed while + // refreshing the unchanged ones for free. This is the user's escape hatch + // for a card they can SEE is stale — without it the cards had no refresh + // path until the next nightly warm. + const refillScopes = await enqueueStatusRefillForUser( + userId, + normalizeLocale(locale), + ); + annotate({ + action: { name: "insights.generate.cards_refill" }, + meta: { scopes: refillScopes }, + }); + + // The dashboard snapshot embeds the pre-generated daily briefing + // read-only; drop it so the next snapshot carries the fresh briefing + // instead of the stale one. + invalidateUserInsights(userId); + + await auditLog("insights.generate", { + userId, + ipAddress, + details: { + privacyMode: ctx.dbUser?.insightsPrivacyMode, + tokensUsed: result.tokensUsed, + providerType: result.providerType, + chainProviderType: workingProviderType, + fallbackHopCount, + model: result.model, + }, + }); + + annotate({ + action: { name: "insights.generate" }, + meta: { + cached: false, + providerType: result.providerType, + chainProviderType: workingProviderType, + fallbackHopCount, + model: result.model, + tokensUsed: result.tokensUsed, + }, + }); +} + +export async function commitBriefing( + call: BriefingCall, + prepared: PreparedFeatures, + reply: GroundedReply, + options: { ipAddress: string | null; fallbackHopCount: number }, +): Promise { + const { ctx, prompt } = call; + const { userId, locale } = ctx; + let { insights } = reply; + + // Outbound safety screen over the whole prose surface, before the + // persist. This path is user-initiated but writes the SAME cached payload + // the background generator writes, so a violation must not be persisted + // here either. The user is waiting, so unlike the background path they get + // an explicit error rather than silence — and because nothing was written, + // the previous cached insight is still what the app renders. + const screened = screenInsightPayloadProse(insights, locale); + if (screened) { + annotate({ + action: { name: "insights.generate.outbound_blocked" }, + meta: { locale, reason: screened }, + }); + return { ok: false, refusal: "outbound-screened" }; + } + + const currentSelfContext = await getSelfContextTextForUser(userId, locale); + if (currentSelfContext !== prompt.aboutMe) { + return { ok: false, refusal: "profile-scope-changed" }; + } + if (prompt.briefingForm === "short") { + insights = applyBriefingShortForm(insights); + } + const stored = withBriefingGeneratedAt(insights, new Date()); + if (!(await writeBriefing(call, prepared, stored))) { + return { ok: false, refusal: "scope-changed" }; + } + + await afterWrite(call, reply, options.ipAddress, options.fallbackHopCount); + + return { + ok: true, + data: { + insights: await insightsPayloadForToday(stored, { + userId, + timezone: ctx.timezone, + language: locale, + }), + cached: false, + legacyPayload: false, + // Additive: non-null when the grounding gate stripped the briefing + // from THIS generation, so the card can explain the hole (#470). + briefingOmittedReason: reply.briefingOmittedReason, + }, + }; +} diff --git a/src/lib/insights/briefing-request/completion.ts b/src/lib/insights/briefing-request/completion.ts new file mode 100644 index 000000000..a63f86804 --- /dev/null +++ b/src/lib/insights/briefing-request/completion.ts @@ -0,0 +1,192 @@ +/** + * The on-demand briefing's provider calls, and what a failed call means for + * the person waiting on it. + */ +import { resolveInsightsMaxTokens } from "@/lib/ai/ai-budgets"; +import { providerFailureKind } from "@/lib/ai/failure-kind"; +import { buildSystemPromptWithReferences } from "@/lib/ai/prompts/insight-generator"; +import { + AllProvidersFailedError, + type ProviderChainResolved, + type RunRawWithFallbackResult, +} from "@/lib/ai/provider-runner"; +import { + BriefingBudgetExceededError, + runBriefingCompletion, +} from "@/lib/insights/briefing-provider"; +import { recordBriefingFailure } from "@/lib/insights/briefing-failure-marker"; +import { annotate } from "@/lib/logging/context"; + +import type { BriefingRefusal, GenerationContext } from "./context"; +import type { BriefingPrompt } from "./prompt"; + +export interface BriefingCall { + ctx: GenerationContext; + chain: ProviderChainResolved[]; + prompt: BriefingPrompt; +} + +/** One briefing completion with the route's fixed parameters. */ +export function runBriefingCall( + call: BriefingCall, + options: { + userPrompt: string; + stage: "generate" | "grounding-retry"; + reasoning: boolean; + }, +): Promise { + const { ctx, chain, prompt } = call; + return runBriefingCompletion({ + userId: ctx.userId, + chain, + // The strict prompt carries GROUND RULE 8 — emit a top-level + // `dailyBriefing` block when the snapshot has analysable signal — plus + // the trendAnnotations and storyboardAnnotations rules. The legacy + // `getInsightsSystemPrompt` returned the old `{changed, stable, drivers, + // …}` shape and never asked the model for dailyBriefing, so the hero + // strip's "Re-run analysis" button kept producing a payload with no + // briefing block. The reply tolerates the strict shape via + // `insightResultSchema.safeParse`'s soft fallback to the parsed object. + systemPrompt: buildSystemPromptWithReferences( + ctx.locale, + prompt.referenceMetrics, + ), + userPrompt: options.userPrompt, + temperature: 0.3, + // Env-tunable output ceiling (INSIGHTS_MAX_TOKENS, default 2500, #470). + // A fixed 1500 truncated verbose models' briefing JSON mid-string → + // generic invalid-JSON 422. + maxTokens: resolveInsightsMaxTokens(), + // A wide upstream budget, honouring the per-user response-timeout + // setting, so the reasoning-heavy briefing generation is not aborted at + // the client's 60 s default on large accounts. + timeoutMs: ctx.effectiveTimeoutMs, + stage: options.stage, + // A briefing asked for by hand reasons exactly like the scheduled one; + // the retries repair a reply and do not. + ...(options.reasoning ? { reasoningJob: "daily_briefing" as const } : {}), + }); +} + +export type FirstCompletion = + | { + ok: true; + result: RunRawWithFallbackResult["result"]; + workingProviderType: string; + fallbackHopCount: number; + } + | { ok: false; refusal: BriefingRefusal }; + +/** The refusal for a chain on which every provider failed. */ +function chainRefusal(e: AllProvidersFailedError): BriefingRefusal { + annotate({ + meta: { + insights_chain_outcome: "all-failed", + insights_chain_attempts: e.attempts.length, + insights_chain_first_provider: e.attempts[0]?.providerType ?? null, + insights_chain_first_status: e.attempts[0]?.httpStatus ?? null, + }, + }); + // The most specific user-facing message available. Auth-class failures + // across the entire chain mean every provider's credential is bad — the + // "check your API key" wording tells the user where to act. 429 across the + // chain is rate-limit; 5xx is upstream brown-out; transport-only + // (ECONNRESET, no httpStatus) maps to the generic connection hint so users + // on flaky networks don't see "rate-limited". + if (e.attempts.every((a) => a.httpStatus === 401 || a.httpStatus === 403)) { + return "provider-rejected"; + } + if (e.attempts.every((a) => a.httpStatus === 429)) { + return "provider-rate-limited"; + } + if (e.attempts.some((a) => a.httpStatus !== null && a.httpStatus >= 500)) { + return "provider-unavailable"; + } + if (e.attempts.every((a) => a.httpStatus === null || a.httpStatus <= 0)) { + return "provider-connection"; + } + return "provider-unavailable"; +} + +/** + * The refusal for a single provider error that did not cascade (e.g. a + * custom 4xx from a self-hosted local model). Cloudflare rewrites 5xx to its + * own HTML error page, which breaks `await res.json()` on the dashboard, so + * the statuses map onto the ai/test categorisation. + */ +function providerRefusal(e: unknown): BriefingRefusal { + const err = e as Error & { httpStatus?: number; bodyExcerpt?: string }; + annotate({ + meta: { + insights_provider_error: err.message.slice(0, 500), + insights_provider_status: err.httpStatus ?? null, + insights_provider_body_excerpt: err.bodyExcerpt?.slice(0, 500) ?? null, + }, + }); + const status = err.httpStatus ?? 0; + if (status === 401 || status === 403) return "provider-rejected"; + if (status === 429) return "provider-rate-limited"; + if (status >= 500) return "provider-unavailable"; + return "provider-connection"; +} + +export async function runFirstCompletion( + call: BriefingCall, +): Promise { + try { + const fallback = await runBriefingCall(call, { + userPrompt: call.prompt.userPrompt, + stage: "generate", + reasoning: true, + }); + return { + ok: true, + result: fallback.result, + workingProviderType: fallback.workingProvider.providerType, + fallbackHopCount: fallback.fallbackHops.length, + }; + } catch (e) { + // The day's token ceiling refused the reservation — no provider was + // contacted, so this is NOT a provider failure and must not record a + // briefing-failure marker (which would make the read path claim the + // refresh broke). The hourly rate limit bounds request frequency; this + // bounds cost. + if (e instanceof BriefingBudgetExceededError) { + return { ok: false, refusal: "budget-exceeded" }; + } + // A dated failure marker (no cache row is written on this path, so the + // last good briefing stays intact). The read path pairs the preserved + // text with a discreet "couldn't refresh" hint, or shows a retry empty + // state when there is no last good text. The upstream status rides + // along so the hint can tell an auth / rate-limit failure apart from a + // plain timeout: for an all-providers-failed error the first hop's + // status is the most representative; a single provider error exposes its + // own `httpStatus`. + const failureHttpStatus = + e instanceof AllProvidersFailedError + ? (e.attempts[0]?.httpStatus ?? null) + : typeof (e as { httpStatus?: number }).httpStatus === "number" + ? (e as { httpStatus?: number }).httpStatus + : null; + void recordBriefingFailure({ + userId: call.ctx.userId, + reason: + e instanceof AllProvidersFailedError + ? "all-providers-failed" + : "provider-error", + locale: call.ctx.locale, + httpStatus: failureHttpStatus, + failureKind: + e instanceof AllProvidersFailedError + ? (e.attempts[0]?.failureKind ?? null) + : providerFailureKind(e).failure, + }); + return { + ok: false, + refusal: + e instanceof AllProvidersFailedError + ? chainRefusal(e) + : providerRefusal(e), + }; + } +} diff --git a/src/lib/insights/briefing-request/context.ts b/src/lib/insights/briefing-request/context.ts new file mode 100644 index 000000000..7c488fa3c --- /dev/null +++ b/src/lib/insights/briefing-request/context.ts @@ -0,0 +1,137 @@ +/** + * What an on-demand briefing generation reads before it decides anything: + * the account row, the cached payload, the scope columns the commit guard + * matches on, the response timeout, the reader's language and zone. + */ +import type { NextRequest } from "next/server"; + +import type { Prisma } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; +import { resolveEffectiveTimeoutMs } from "@/lib/ai/effective-timeout"; +import type { Locale } from "@/lib/i18n/config"; +import { resolveServerLocale } from "@/lib/i18n/server-locale"; +import { + INSIGHTS_CACHE_SELECT, + openInsightsCachedText, +} from "@/lib/insights/cached-text-cipher"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; + +export const GENERATION_USER_SELECT = { + insightsPrivacyMode: true, + insightsCachedAt: true, + ...INSIGHTS_CACHE_SELECT, + insightsCachedLocale: true, + timezone: true, + // The per-user opt-out list mirroring the Coach settings. Filtered off + // `features` before serialisation so the model never sees the excluded + // blocks. + insightsExcludeMetrics: true, + locale: true, + // The profile for the derived-signal briefing detector. + dateOfBirth: true, + gender: true, + heightCm: true, + // The first name for the sparse, hash-gated briefing opener. + displayName: true, + // The per-user response timeout (seconds), threaded onto the generation + // calls so a slow self-hosted backend honours the setting. + aiResponseTimeoutSeconds: true, + // The reader's units; the feature set is converted into them before it is + // serialised, hashed and checked for grounding. + unitPreference: true, + glucoseUnit: true, +} as const; + +export type GenerationUserRow = Prisma.UserGetPayload<{ + select: typeof GENERATION_USER_SELECT; +}>; + +/** + * The columns the commit guard matches on, captured at read time. + * + * Every User write bumps `updatedAt`, including `resolveProviderChain()`'s + * OAuth token-refresh update later in the same request, so keying the + * optimistic-concurrency check on it produced a false 409 whenever a + * provider credential happened to refresh mid-generation. These are exactly + * the selected columns that feed the prompt; requiring an exact match at + * commit still catches a genuine privacy-mode / exclude-list / locale / + * profile change without colliding on unrelated column writes. + */ +export interface GenerationScope { + mode: GenerationUserRow["insightsPrivacyMode"]; + excludeMetrics: GenerationUserRow["insightsExcludeMetrics"]; + locale: GenerationUserRow["locale"]; + dateOfBirth: GenerationUserRow["dateOfBirth"]; + gender: GenerationUserRow["gender"]; + heightCm: GenerationUserRow["heightCm"]; + displayName: GenerationUserRow["displayName"]; +} + +export interface GenerationContext { + userId: string; + dbUser: GenerationUserRow | null; + cachedText: string | null; + scope: GenerationScope; + effectiveTimeoutMs: number; + locale: Locale; + timezone: string; +} + +/** + * Why a generation ended without a briefing. The route owns the status and + * the wording for each. + */ +export type BriefingRefusal = + | "payload-too-large" + | "budget-exceeded" + | "provider-rejected" + | "provider-rate-limited" + | "provider-unavailable" + | "provider-connection" + | "response-truncated" + | "response-invalid-json" + | "outbound-screened" + | "profile-scope-changed" + | "scope-changed"; + +export async function loadGenerationContext(args: { + request: NextRequest; + userId: string; + userLocale: string | null; +}): Promise { + const { request, userId } = args; + const dbUser = await prisma.user.findUnique({ + where: { id: userId }, + select: GENERATION_USER_SELECT, + }); + const cachedText = openInsightsCachedText(dbUser); + const scope: GenerationScope = { + mode: dbUser?.insightsPrivacyMode ?? "aggregated", + excludeMetrics: dbUser?.insightsExcludeMetrics ?? [], + locale: dbUser?.locale ?? null, + dateOfBirth: dbUser?.dateOfBirth ?? null, + gender: dbUser?.gender ?? null, + heightCm: dbUser?.heightCm ?? null, + displayName: dbUser?.displayName ?? null, + }; + + const effectiveTimeoutMs = resolveEffectiveTimeoutMs( + dbUser?.aiResponseTimeoutSeconds, + AI_BUDGETS.comprehensive.timeoutMs, + ); + + const locale = await resolveServerLocale({ + request, + userLocale: dbUser?.locale ?? args.userLocale ?? null, + }); + return { + userId, + dbUser, + cachedText, + scope, + effectiveTimeoutMs, + locale, + timezone: dbUser?.timezone ?? DEFAULT_TIMEZONE, + }; +} diff --git a/src/lib/insights/briefing-request/features.ts b/src/lib/insights/briefing-request/features.ts new file mode 100644 index 000000000..d8e5be35f --- /dev/null +++ b/src/lib/insights/briefing-request/features.ts @@ -0,0 +1,150 @@ +/** + * The feature set an on-demand briefing is written from: the bounded read, + * its downgrade ladder for an oversized payload, the user's exclusions, the + * reader's units, and the compact wire form. + */ +import { compactSections } from "@/lib/ai/prompts/compact-sections"; +import { applyInsightsExcludeFilter } from "@/lib/insights/exclude-filter"; +import { getCachedFeatures } from "@/lib/insights/feature-cache"; +import { + BRIEFING_FEATURE_WINDOW_DAYS, + extractFeatures, + FeaturesPayloadTooLargeError, +} from "@/lib/insights/features"; +import { featuresInReaderUnits } from "@/lib/insights/features-units"; +import { annotate } from "@/lib/logging/context"; +import { resolveUnitPreferences } from "@/lib/measurements/display-transform"; + +import type { GenerationContext } from "./context"; + +export type BriefingFeatures = Awaited>; + +/** + * The blocks dropped when even the aggregated shape crosses the ceiling + * (very large medication history + multi-year context), before one last try. + */ +const MAX_DOWNGRADE_TOKENS: ReadonlyArray = [ + "anthropometrics", + "medications", + "compliance", + "sleep", + "steps", + "hrv", + "resting_hr", +]; + +interface Downgrade { + payloadDowngraded: boolean; + payloadHardDowngraded: boolean; + payloadOversizeBytes: number | null; +} + +/** + * The bounded feature read, computed once per request through the scope the + * route opened. `extractFeatures` enforces a 5 MB ceiling on the serialised + * payload and throws `FeaturesPayloadTooLargeError` past it; the raw + * (bucketed) shape downgrades to the aggregated one rather than 500-ing, so + * the user still gets an insight, and the annotation surfaces the + * regression to ops. Null when every rung is still too large. + */ +async function readWithDowngrade( + userId: string, + includeRaw: boolean, + downgrade: Downgrade, +): Promise { + // The bulk read is bounded to a recent window (all-time extremes are + // sourced separately, see features.ts); the ladder is the safety net for + // the rare oversize payload. + const featureWindow = { sinceDays: BRIEFING_FEATURE_WINDOW_DAYS }; + const read = (raw: boolean) => + getCachedFeatures({ + userId, + includeRaw: raw, + sinceDays: featureWindow.sinceDays, + compute: () => extractFeatures(userId, raw, featureWindow), + }); + try { + return await read(includeRaw); + } catch (err) { + if (!(err instanceof FeaturesPayloadTooLargeError)) throw err; + downgrade.payloadDowngraded = true; + downgrade.payloadOversizeBytes = err.sizeBytes; + } + try { + return await read(false); + } catch (retryErr) { + if (!(retryErr instanceof FeaturesPayloadTooLargeError)) throw retryErr; + // The aggregated shape ALSO crossed the ceiling. Drop the optional + // context blocks via the exclude filter and try once more. + downgrade.payloadHardDowngraded = true; + downgrade.payloadOversizeBytes = retryErr.sizeBytes; + } + try { + return applyInsightsExcludeFilter(await read(false), MAX_DOWNGRADE_TOKENS); + } catch (finalErr) { + // Even the last read blew up. Annotate and refuse rather than let the + // uncaught throw 500 out. + annotate({ + meta: { + insights_payload_too_large: true, + insights_payload_oversize_bytes: + finalErr instanceof FeaturesPayloadTooLargeError + ? finalErr.sizeBytes + : downgrade.payloadOversizeBytes, + }, + }); + return null; + } +} + +export interface PreparedFeatures { + features: BriefingFeatures; + compactFeatures: Record; + featuresJson: string; + units: ReturnType; +} + +export async function prepareBriefingFeatures( + ctx: GenerationContext, +): Promise { + const { dbUser, userId } = ctx; + const includeRaw = dbUser?.insightsPrivacyMode === "raw"; + const downgrade: Downgrade = { + payloadDowngraded: false, + payloadHardDowngraded: false, + payloadOversizeBytes: null, + }; + let features = await readWithDowngrade(userId, includeRaw, downgrade); + if (features === null) return null; + // The user's exclude-metrics opt-out list, applied BEFORE serialisation so + // the model never sees the dropped blocks. + const excludeList = dbUser?.insightsExcludeMetrics ?? []; + features = applyInsightsExcludeFilter(features, excludeList); + // The reader's units, after all canonical arithmetic: the prompt, the + // stored hash and the grounding check read the same converted figures. + const units = resolveUnitPreferences({ + unitPreference: dbUser?.unitPreference, + glucoseUnit: dbUser?.glucoseUnit, + }); + features = featuresInReaderUnits(features, units); + // Zero-row blocks are dropped so the prompt never carries labelled-empty + // sections (`"sleep": []`, `"medications": []`, etc.), which made the + // model narrate "there are no medications in your data" when the user + // explicitly excluded the block. + const compactFeatures = compactSections( + features as unknown as Record, + ); + // Compact on the wire: indentation is about 40% of a pretty-printed + // payload, and the model reads the structure without it (#1217). + const featuresJson = JSON.stringify(compactFeatures); + if (downgrade.payloadDowngraded) { + annotate({ + meta: { + insights_features_downgraded: true, + insights_features_oversize_bytes: downgrade.payloadOversizeBytes, + insights_features_hard_downgraded: downgrade.payloadHardDowngraded, + }, + }); + } + return { features, compactFeatures, featuresJson, units }; +} diff --git a/src/lib/insights/briefing-request/generate.ts b/src/lib/insights/briefing-request/generate.ts new file mode 100644 index 000000000..4ab6029be --- /dev/null +++ b/src/lib/insights/briefing-request/generate.ts @@ -0,0 +1,96 @@ +/** + * An on-demand briefing generation, from the resolved provider chain to the + * committed payload. The route owns authentication, the capability gate, + * the body, the hourly quota and the status of every refusal. + */ +import type { ProviderChainResolved } from "@/lib/ai/provider-runner"; +import { assertConsentForChain } from "@/lib/ai/consent-guard"; +import { resolveProvider, resolveProviderChain } from "@/lib/ai/provider"; +import { recordBriefingFailure } from "@/lib/insights/briefing-failure-marker"; +import { annotate } from "@/lib/logging/context"; + +import { commitBriefing } from "./commit"; +import { runFirstCompletion, type BriefingCall } from "./completion"; +import type { BriefingRefusal, GenerationContext } from "./context"; +import { prepareBriefingFeatures } from "./features"; +import { groundBriefing, parseBriefingReply } from "./grounding"; +import { buildBriefingPrompt } from "./prompt"; + +/** + * The user's full provider chain, so a hard failure on the primary (401, + * 5xx, network) cascades to the next configured fallback rather than + * surfacing to the dashboard. An empty chain falls back to the + * single-provider resolution when the legacy `aiProvider` field still points + * at something usable, so accounts that never customised the chain keep + * working. Null when there is no provider at all. + * + * The consent gate runs on the resolved chain before anything leaves for a + * model. Required for the operator's global key (`admin-openai`); BYOK / + * local / ChatGPT-OAuth chains stay ungated. It throws ConsentRequiredError, + * which `apiHandler` answers with 403 + `consent.ai.required`. + */ +export async function resolveConsentedBriefingChain( + userId: string, +): Promise { + const chain = await resolveProviderChain(userId); + if (chain.length === 0) { + const legacy = await resolveProvider(userId); + if (legacy.type === "none") return null; + chain.push({ providerType: "admin-openai", instance: legacy }); + } + await assertConsentForChain({ userId, chain, surface: "insights" }); + return chain; +} + +export type GenerateOutcome = + | { ok: true; data: Record } + | { ok: false; refusal: BriefingRefusal }; + +export async function generateBriefing(args: { + ctx: GenerationContext; + chain: ProviderChainResolved[]; + ipAddress: string | null; +}): Promise { + const { ctx, chain } = args; + const prepared = await prepareBriefingFeatures(ctx); + if (prepared === null) return { ok: false, refusal: "payload-too-large" }; + + const prompt = await buildBriefingPrompt(ctx, prepared); + const call: BriefingCall = { ctx, chain, prompt }; + + const first = await runFirstCompletion(call); + if (!first.ok) return first; + + let insights; + try { + insights = parseBriefingReply(first.result.content); + } catch { + // A 502 here triggers Cloudflare's HTML error rewrite, which breaks + // `await res.json()` on the client; the refusal stays a JSON 422 so the + // React Query mutation can read the body and surface a readable message. + void recordBriefingFailure({ + userId: ctx.userId, + reason: "invalid-json", + locale: ctx.locale, + }); + // Truncation-aware (#470): `finishReason === "length"` means the model + // hit the output-token ceiling and the JSON was cut off mid-string, which + // is a budget problem, not a model-quality one. + if (first.result.finishReason === "length") { + annotate({ meta: { insights_response_truncated: true } }); + return { ok: false, refusal: "response-truncated" }; + } + return { ok: false, refusal: "response-invalid-json" }; + } + + const reply = await groundBriefing(call, prepared, { + insights, + result: first.result, + workingProviderType: first.workingProviderType, + }); + + return commitBriefing(call, prepared, reply, { + ipAddress: args.ipAddress, + fallbackHopCount: first.fallbackHopCount, + }); +} diff --git a/src/lib/insights/briefing-request/grounding.ts b/src/lib/insights/briefing-request/grounding.ts new file mode 100644 index 000000000..d78653519 --- /dev/null +++ b/src/lib/insights/briefing-request/grounding.ts @@ -0,0 +1,127 @@ +/** + * The reply's parse and the daily-briefing number-grounding gate. + * + * The schema only checks the briefing's SHAPE; the gate asserts every + * number the briefing prose restates traces to a `features.signalsOfDay` + * figure, mirroring the recommendations citation cross-check. On a miss: one + * corrective retry, then the briefing block is dropped rather than shipping + * a fabricated number. + */ +import { + buildBriefingGroundingCorrection, + findUngroundedBriefingNumbers, + readBriefingBlock, +} from "@/lib/ai/briefing-grounding"; +import type { RunRawWithFallbackResult } from "@/lib/ai/provider-runner"; +import { insightResultSchema, type InsightResult } from "@/lib/ai/types"; +import { annotate } from "@/lib/logging/context"; + +import { runBriefingCall, type BriefingCall } from "./completion"; +import type { PreparedFeatures } from "./features"; + +export type BriefingInsights = InsightResult | Record; + +/** The parsed reply: the strict shape when it validates, the raw object if not. */ +export function parseBriefingReply(content: string): BriefingInsights { + const parsed = JSON.parse(content); + // Try the strict schema first, fall back to the raw parsed object. + const validated = insightResultSchema.safeParse(parsed); + return validated.success ? validated.data : parsed; +} + +export interface GroundedReply { + insights: BriefingInsights; + result: RunRawWithFallbackResult["result"]; + workingProviderType: string; + /** + * Non-null when the gate stripped the briefing from THIS generation and no + * previous one could stand in, so the card can explain the hole instead + * of reading "no briefing yet" (#470). + */ + briefingOmittedReason: "ungrounded" | null; +} + +/** The previous cached briefing, when the payload parses and carries one. */ +function previousBriefing(cachedText: string | null): unknown { + if (!cachedText) return null; + try { + const prev = JSON.parse(cachedText) as Record; + return prev.dailyBriefing ?? null; + } catch { + // Unparseable previous payload — nothing safe to stand in. + return null; + } +} + +export async function groundBriefing( + call: BriefingCall, + prepared: PreparedFeatures, + reply: Omit, +): Promise { + const { features } = prepared; + const { comparisonSnapshot } = call.prompt; + let { insights, result, workingProviderType } = reply; + let briefingOmittedReason: "ungrounded" | null = null; + const signals = features.signalsOfDay ?? null; + let ungrounded = findUngroundedBriefingNumbers( + readBriefingBlock(insights), + signals, + features, + comparisonSnapshot, + ); + if (ungrounded.length > 0) { + annotate({ + action: { name: "insights.generate.briefing_grounding_retry" }, + meta: { ungroundedCount: ungrounded.length }, + }); + try { + const retry = await runBriefingCall(call, { + userPrompt: `${call.prompt.userPrompt}\n\n${buildBriefingGroundingCorrection(ungrounded)}`, + stage: "grounding-retry", + reasoning: false, + }); + const retryInsights = parseBriefingReply(retry.result.content); + ungrounded = findUngroundedBriefingNumbers( + readBriefingBlock(retryInsights), + signals, + features, + comparisonSnapshot, + ); + if (ungrounded.length === 0) { + insights = retryInsights; + result = retry.result; + workingProviderType = retry.workingProvider.providerType; + } + } catch { + // Retry failed to produce parseable JSON, or the day's ceiling refused + // the correction pass — either way fall through to the strip. An + // ungrounded figure is never persisted. + } + } + // Still ungrounded after the retry: the freshly generated briefing is + // discarded rather than shipped — the anti-fabrication guarantee is + // absolute here too. But rather than leave a hole, the PREVIOUS cached + // briefing stands in its place: it passed this identical gate when it was + // written, so it carries no fabricated figure, and a day-old grounded + // briefing beats a vanished one. This mirrors the background generator, + // which is the path that writes the same cached payload; the two must not + // disagree on disposal. + // + // `briefingOmittedReason` is set ONLY when no previous briefing exists to + // stand in, so the card's honest "a figure couldn't be verified" state + // still fires exactly when the user really has no briefing. + if (ungrounded.length > 0 && insights && typeof insights === "object") { + const fallbackBriefing = previousBriefing(call.ctx.cachedText); + (insights as Record).dailyBriefing = fallbackBriefing; + briefingOmittedReason = fallbackBriefing == null ? "ungrounded" : null; + annotate({ + action: { name: "insights.generate.briefing_grounding_stripped" }, + meta: { + ungroundedCount: ungrounded.length, + briefing_fallback: + fallbackBriefing != null ? "previous-cached" : "stripped", + }, + }); + } + return { insights, result, workingProviderType, briefingOmittedReason }; +} diff --git a/src/lib/insights/briefing-request/prompt.ts b/src/lib/insights/briefing-request/prompt.ts new file mode 100644 index 000000000..cfebd7e88 --- /dev/null +++ b/src/lib/insights/briefing-request/prompt.ts @@ -0,0 +1,129 @@ +/** + * The on-demand briefing's prompt: the feature snapshot, and every + * SYSTEM CONTEXT block that rides on it. + */ +import { metricsFromPresentSections } from "@/lib/ai/medical-references"; +import { + buildAboutMeInsightBlock, + getSelfContextTextForUser, +} from "@/lib/ai/coach/about-me"; +import { buildBriefingPersonalisationBlock } from "@/lib/ai/prompts/insight-generator"; +import { buildUserPrompt } from "@/lib/ai/prompts/insight-system-prompt"; +import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets"; +import { + briefingFormForRecord, + buildBriefingShortFormBlock, +} from "@/lib/insights/briefing-short-form"; +import { buildComparisonSnapshotForUser } from "@/lib/insights/comprehensive-generate"; +import { + buildDerivedBriefingPrompt, + detectDerivedBriefingSignals, +} from "@/lib/insights/derived-briefing"; +import { + buildGlp1PlateauPrompt, + detectGlp1Plateau, +} from "@/lib/insights/glp1-plateau"; +import { + buildBriefingIllnessCycleContext, + buildBriefingIllnessCyclePrompt, +} from "@/lib/insights/illness-cycle-briefing"; +import { toProfileSex } from "@/lib/profile/sex"; +import { resolveUserTimezone } from "@/lib/tz/resolver"; + +import type { GenerationContext } from "./context"; +import type { PreparedFeatures } from "./features"; + +export type BriefingPrompt = Awaited>; + +export async function buildBriefingPrompt( + ctx: GenerationContext, + prepared: PreparedFeatures, +) { + const { userId, dbUser, locale } = ctx; + const { features, featuresJson, units } = prepared; + // The persisted comparison toggle: when active, a compact prior-period + // snapshot built from the `features` summaries that already include + // `avg30LastMonth` / `avg30LastYear`. The narrative is default-on whenever + // the toggle is on — the pulldown is the single affordance. + const comparisonSnapshot = await buildComparisonSnapshotForUser(userId); + // GLP-1 plateau detector: a SYSTEM CONTEXT block when the active GLP-1 + // medication has been on a stable dose for ≥21 days with the weight delta + // within ±0.5 kg, asking for an observational glp1_plateau keyFinding (no + // dose recommendation per GROUND RULE 13). Null — the common case — costs + // no tokens. + const plateauContext = await detectGlp1Plateau(userId); + let userPrompt = buildUserPrompt( + featuresJson, + dbUser?.insightsPrivacyMode ?? "aggregated", + locale, + comparisonSnapshot ?? undefined, + ); + if (plateauContext) { + userPrompt += buildGlp1PlateauPrompt(plateauContext, locale, units.system); + } + // The user-authored "about me" self-description (Settings → AI), as a + // delimited, user-provided SYSTEM CONTEXT block. Null (no text / + // undecryptable) costs nothing. + const aboutMe = await getSelfContextTextForUser(userId, locale); + if (aboutMe) { + userPrompt += buildAboutMeInsightBlock(aboutMe, locale); + } + // A notable derived wellness signal (readiness / recovery shift, + // confidence-gated), read from the one derived contract; null (the common + // case) costs nothing. + const derivedSex = toProfileSex(dbUser?.gender); + const derivedBriefing = await detectDerivedBriefingSignals(userId, { + ageYears: getAgeFromDateOfBirth(dbUser?.dateOfBirth ?? null), + sex: derivedSex, + heightCm: dbUser?.heightCm ?? null, + }); + if (derivedBriefing) { + userPrompt += buildDerivedBriefingPrompt(derivedBriefing, locale); + } + + // Illness-episode + cycle state, so a mid-illness or mid-cycle user gets + // context-aware prose. The same server-authoritative builders the Coach + // assembles; both are module-gated and short-circuit to null (zero token + // cost) for users without them. + const illnessCycleCtx = await buildBriefingIllnessCycleContext( + userId, + dbUser?.gender ?? null, + await resolveUserTimezone(userId), + ); + if (illnessCycleCtx) { + userPrompt += buildBriefingIllnessCyclePrompt(illnessCycleCtx, locale); + } + + // Opener-archetype rotation + sparse first-name personalization + // (deterministic per user+day; omitted for unnamed accounts). + userPrompt += buildBriefingPersonalisationBlock( + userId, + dbUser?.displayName ?? null, + locale, + await resolveUserTimezone(userId), + ); + // The short form on the `frugal` usage level; the full contract on every + // other level (#1217). + const briefingForm = await briefingFormForRecord(userId); + userPrompt += buildBriefingShortFormBlock(locale, briefingForm); + + // The curated SOURCES block for the metric sections this briefing + // actually carries, so a normative claim can cite a real `referenceId` the + // schema + UI footnote already support. The plain prompt stays unchanged + // when no applicable metric section is present. + const referenceMetrics = metricsFromPresentSections({ + bloodPressure: features.bloodPressure != null, + weight: features.weight != null, + pulse: features.pulse != null, + mood: features.mood != null, + medication: (features.medications?.length ?? 0) > 0, + }); + + return { + userPrompt, + comparisonSnapshot, + aboutMe, + briefingForm, + referenceMetrics, + }; +} diff --git a/src/lib/insights/briefing-request/read.ts b/src/lib/insights/briefing-request/read.ts new file mode 100644 index 000000000..23a8b7e91 --- /dev/null +++ b/src/lib/insights/briefing-request/read.ts @@ -0,0 +1,224 @@ +/** + * The read-only advisor briefing: the cached payload, served immediately and + * never generated here. + * + * The advisor consumers (hero strip, daily-briefing card, trends row) used + * to mount against the generating POST, which blocked the page-load path on + * the full provider chain. This read serves `User.insightsCachedTextEncrypted` + * as it is and, when the cache is stale or missing AND a provider is + * configured, enqueues an out-of-band warm (the same `insight-pregenerate` + * queue the nightly cron and the "prepare assessments" button use). The next + * read reflects the fresh briefing. + */ +import type { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { + generatedOnLocalToday, + insightsPayloadForToday, +} from "@/lib/daily/briefing-today-read"; +import { isLegacyInsightPayload } from "@/lib/ai/legacy-payload"; +import { resolveServerLocale } from "@/lib/i18n/server-locale"; +import { readBriefingFailure } from "@/lib/insights/briefing-failure-marker"; +import { readBriefingGeneratedAt } from "@/lib/insights/briefing-generated-at"; +import { + INSIGHTS_CACHE_SELECT, + openInsightsCachedText, +} from "@/lib/insights/cached-text-cipher"; +import { normalizeLocale } from "@/lib/insights/status-shared"; +import { hasUsableStatusProvider } from "@/lib/insights/status-provider"; +import { enqueueForceWarm } from "@/lib/jobs/insight-pregenerate-shared"; +import { annotate } from "@/lib/logging/context"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; + +/** + * Briefing freshness window for the read. A cached briefing read this + * recently is fresh and triggers no warm; older / missing enqueues an + * out-of-band regeneration. Mirrors the 24 h cache-hit window the POST path + * honours so the two stay consistent. + */ +const BRIEFING_FRESH_MS = 24 * 60 * 60 * 1000; + +/** + * The honesty signals beside the payload: whether a provider exists, and + * what the last generation attempt left behind. + */ +async function readGenerationSignals(userId: string, cachedAt: Date | null) { + // Provider availability, resolved once. The read never blocks on or + // generates through the provider, but it must REPORT whether one exists: + // a provider-less account's cached briefing freezes at the last successful + // generation (the pregenerate cron can't refill it), and the UI presented + // that stale text as current. `hasProvider: false` lets the insights + // surfaces pair the honest "Stand: vor X Tagen" age with a discreet + // connect-an-AI-provider hint instead of implying the read is live. + const hasProvider = await hasUsableStatusProvider(userId); + + // Whether the most recent generation attempt failed (a marker newer than + // the last successful generation). The briefing keeps its last good text + // on failure, so this is the only honest signal that a shown briefing is + // held rather than freshly refreshed — and, when there is no last good + // text, that the empty state should read "couldn't generate" with a retry + // rather than the generic "no briefing yet". + const briefingFailure = await readBriefingFailure({ + userId, + since: cachedAt, + }); + // A `briefing-ungrounded` marker is NOT a failure: the generation + // succeeded but the grounding gate withheld the briefing (marker written + // after the cache write, so it survives the `since` filter). It is the + // persistent omission signal — the card's "briefing withheld" state used + // to exist only on the transient POST response, so a nightly strip read as + // a silent generic empty card. + const briefingOmittedReason: "ungrounded" | null = + briefingFailure?.reason === "briefing-ungrounded" ? "ungrounded" : null; + const generationFailed = + briefingFailure !== null && briefingOmittedReason === null; + // The failure class lets the empty state point its hint at the right + // lever (raise the response timeout vs re-check the provider). + const generationFailureClass = generationFailed + ? (briefingFailure?.failureClass ?? null) + : null; + return { + hasProvider, + generationFailed, + generationFailureClass, + briefingOmittedReason, + }; +} + +export async function readAdvisorBriefing(args: { + request: NextRequest; + userId: string; + userLocale: string | null; +}) { + const { request, userId } = args; + const dbUser = await prisma.user.findUnique({ + where: { id: userId }, + select: { + insightsCachedAt: true, + ...INSIGHTS_CACHE_SELECT, + insightsCachedLocale: true, + locale: true, + timezone: true, + }, + }); + const cachedText = openInsightsCachedText(dbUser); + + // The locale the caller is actually reading (cookie / Accept-Language + // fall-back when `User.locale` is unset), validated against the shipped + // list — the same convention the nightly's `normalizeLocale` and the status + // routes follow. Collapsing a NULL `User.locale` to German filled the wrong + // cache family for a client reading the app in English. + const readerLocale = normalizeLocale( + await resolveServerLocale({ + request, + userLocale: dbUser?.locale ?? args.userLocale ?? null, + }), + ); + + // The cache is one slot per user. Text written in another language is + // never the reader's briefing, however fresh: treat the slot as empty for + // prose, and re-warm in the reader's language below. An untagged row + // predates the tag and is served as before. + const languageMismatch = + dbUser?.insightsCachedLocale != null && + dbUser.insightsCachedLocale !== readerLocale; + + const cachedAt = dbUser?.insightsCachedAt ?? null; + const timezone = dbUser?.timezone ?? DEFAULT_TIMEZONE; + // Fresh means written today on the reader's calendar, by the moment the + // text was generated: `insightsCachedAt` also moves when an unchanged + // warm only re-stamps yesterday's text. + const generatedToday = generatedOnLocalToday( + readBriefingGeneratedAt(cachedText), + timezone, + new Date(), + ); + const isFresh = + !languageMismatch && + cachedAt !== null && + generatedToday && + Date.now() - cachedAt.getTime() < BRIEFING_FRESH_MS; + + const signals = await readGenerationSignals(userId, cachedAt); + const { hasProvider } = signals; + + // Never block on the provider. Warm out of band only when the cached + // briefing is stale / missing AND a provider is configured (a + // provider-less account costs one cheap chain-resolve and shows the + // empty / connect-AI state instead of a wasted enqueue). + let revalidating = false; + if (!isFresh && hasProvider) { + void enqueueForceWarm({ userId, locale: readerLocale }); + revalidating = true; + } + + if (cachedText && !languageMismatch) { + try { + const cached = JSON.parse(cachedText); + const legacyPayload = isLegacyInsightPayload(cached); + annotate({ + action: { name: "insights.generate.read" }, + meta: { cached: true, legacyPayload, revalidating, hasProvider }, + }); + return { + // The briefing as every surface serves it: today's text only, its + // signals measured today, its deltas at display precision. + insights: await insightsPayloadForToday(cached, { + userId, + timezone, + language: readerLocale, + }), + cached: true, + cachedAt, + legacyPayload, + // Honest stale-serve marker: true while the out-of-band warm is in + // flight, so the client can poll (bounded) until the fresh briefing + // lands instead of sitting on the stale payload for the rest of the + // session. + revalidating, + // False when no AI provider is configured anywhere: the served + // briefing can never refresh, so the UI pairs its age with a + // connect-provider affordance. + hasProvider, + // True when the last generation attempt failed (held text). + generationFailed: signals.generationFailed, + // Coarse failure class for the empty-state hint (null when the last + // attempt succeeded). + generationFailureClass: signals.generationFailureClass, + // Persistent grounding-omission signal (see above), so a + // nightly-stripped briefing renders "withheld", not "no briefing + // yet". + briefingOmittedReason: signals.briefingOmittedReason, + }; + } catch { + // Invalid cache row — fall through to the empty payload below. The + // warm enqueue above (when a provider exists) repairs it for the next + // read. + } + } + + annotate({ + action: { name: "insights.generate.read" }, + meta: { + cached: false, + revalidating, + hasProvider, + languageMismatch, + readerLocale, + }, + }); + return { + insights: null, + cached: false, + legacyPayload: false, + revalidating, + hasProvider, + // No last-good text AND the last attempt failed: the UI shows a + // "couldn't generate" empty state with a retry instead of the generic + // one. + generationFailed: signals.generationFailed, + generationFailureClass: signals.generationFailureClass, + briefingOmittedReason: signals.briefingOmittedReason, + }; +} diff --git a/src/lib/insights/comprehensive-overview/build.ts b/src/lib/insights/comprehensive-overview/build.ts new file mode 100644 index 000000000..a96e8b751 --- /dev/null +++ b/src/lib/insights/comprehensive-overview/build.ts @@ -0,0 +1,198 @@ +/** + * The Insights overview's response body, computed from the record. + * + * SQL-side aggregates served by `buildComprehensiveAggregate` replace the + * 100k-row-in-JS aggregation the route once ran; the medication-compliance + * block keeps its bounded Prisma reads (medications + intake events). No + * provider is called anywhere on the path: `hasProvider` is the presence + * probe only. + * + * Every day-bucketing pass (resting-pulse proxy, medication continuity, BP + * pairing) shares the one per-user tz resolved here. + */ +import { prisma } from "@/lib/db"; +import { probeProviderPresence } from "@/lib/ai/provider"; +import type { DataSummary } from "@/lib/analytics/trends"; +import { summarize } from "@/lib/analytics/trends"; +import { getBpTargets } from "@/lib/analytics/bp-targets"; +import { computeBpInTargetPct } from "@/lib/analytics/bp-in-target"; +import { + classifyBMI, + classifyBP, + generateAlerts, +} from "@/lib/analytics/classifications"; +import { annotate } from "@/lib/logging/context"; +import { buildComprehensiveAggregate } from "@/lib/insights/comprehensive-aggregator"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; + +import { overviewCorrelations } from "./correlations"; +import { + bpMedicationContinuity, + readMedicationCompliance, +} from "./medication-compliance"; +import { readOverviewMoodSeries } from "./mood-series"; +import { readRestingPulseDaily } from "./resting-pulse-daily"; + +/** Who the overview is computed for: the record, with its zone. */ +export interface OverviewRecord { + id: string; + timezone?: string | null; +} + +export type ComprehensiveOverviewBody = Awaited< + ReturnType +>; + +/** BMI and the 30-day BP classification against the record's profile. */ +function classifyProfile( + summaries: Record, + dbUser: { heightCm: number | null; dateOfBirth: Date | null } | null, +) { + let bmi: number | null = null; + let bmiClassification = null; + if (dbUser?.heightCm && summaries.WEIGHT?.latest) { + const heightM = dbUser.heightCm / 100; + bmi = Math.round((summaries.WEIGHT.latest / (heightM * heightM)) * 10) / 10; + bmiClassification = classifyBMI(bmi); + } + + let bpClassification = null; + const bpTargets = getBpTargets(dbUser?.dateOfBirth ?? null); + if ( + summaries.BLOOD_PRESSURE_SYS?.avg30 && + summaries.BLOOD_PRESSURE_DIA?.avg30 + ) { + bpClassification = classifyBP( + summaries.BLOOD_PRESSURE_SYS.avg30, + summaries.BLOOD_PRESSURE_DIA.avg30, + ); + } + return { bmi, bmiClassification, bpClassification, bpTargets }; +} + +export async function buildComprehensiveResponse(user: OverviewRecord) { + const userId = user.id; + const ninetyDaysAgo = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000); + const userTz = user.timezone || DEFAULT_TIMEZONE; + + // Height + DOB drive BMI + BP targets. + const dbUser = await prisma.user.findUnique({ + where: { id: userId }, + select: { + heightCm: true, + dateOfBirth: true, + }, + }); + + // SQL-side rollup: per-type DataSummary identical to the JS `summarize()` + // semantics over the 90-day window, plus the bounded raw rows the BP target + // adherence pairing needs, plus daily-mean buckets for the correlations. + const aggregate = await buildComprehensiveAggregate(userId); + + const { dailyMoodEntries, moodDataPoints, moodEntryCount } = + await readOverviewMoodSeries(userId, ninetyDaysAgo); + const moodSummary = + moodDataPoints.length > 0 ? summarize(moodDataPoints) : null; + + const summaries: Record = aggregate.summaries; + const { bmi, bmiClassification, bpClassification, bpTargets } = + classifyProfile(summaries, dbUser); + + // BP target adherence over the trailing 90 days, through the canonical + // `computeBpInTargetPct` so this figure shares ONE definition with the + // dashboard tile, the insight cards, the AI snapshot and the targets + // endpoint. The helper pairs sys+dia on the 5-minute window with the + // same-local-day fallback (keyed on the user's OWN tz) and keeps the + // ESH-2023 band + 90/50 hypotension floor. Window is the aggregator's + // 90-day raw pull — a "recent control" figure, not the all-time headline. + let bpPctInTarget: number | null = null; + if (bpTargets) { + bpPctInTarget = + computeBpInTargetPct( + aggregate.bpRawRows.sys, + aggregate.bpRawRows.dia, + bpTargets, + userTz, + )?.pct ?? null; + } + + const restingPulseDaily = await readRestingPulseDaily( + userId, + ninetyDaysAgo, + userTz, + ); + const sysDaily = aggregate.dailyByType.BLOOD_PRESSURE_SYS ?? []; + const correlations = overviewCorrelations({ + aggregate, + dailyMoodEntries, + restingPulseDaily, + userTz, + }); + + const { medications, medCompliance, bpMedications, bpMedicationEvents } = + await readMedicationCompliance({ + userId, + since: ninetyDaysAgo, + timezone: user.timezone || DEFAULT_TIMEZONE, + }); + const { bpMedicationCorrelation, bpMedicationScatterData } = + bpMedicationContinuity({ bpMedications, bpMedicationEvents, sysDaily }); + + const alerts = generateAlerts({ + bmi, + bpAvgSys: summaries.BLOOD_PRESSURE_SYS?.avg30 ?? null, + bpAvgDia: summaries.BLOOD_PRESSURE_DIA?.avg30 ?? null, + bpPctInTarget, + weightSlope30: summaries.WEIGHT?.slope30?.slope ?? null, + pulseAvg30: summaries.PULSE?.avg30 ?? null, + pulseAnomalyCount: summaries.PULSE?.anomalyCount ?? 0, + medications: medCompliance.map((m) => ({ + name: m.name, + compliance7: m.compliance7, + compliance30: m.compliance30, + })), + }); + + // Data span — derived from the earliest 90-day measurement. + const dataSpanDays = aggregate.firstMeasurementAt + ? Math.ceil( + (Date.now() - aggregate.firstMeasurementAt.getTime()) / + (24 * 60 * 60 * 1000), + ) + : 0; + + annotate({ + action: { name: "insights.comprehensive" }, + meta: { + totalMeasurements: aggregate.totalMeasurements, + moodEntries: moodEntryCount, + medications: medications.length, + }, + }); + + return { + summaries, + bmi, + bmiClassification, + bpClassification, + bpPctInTarget, + bpTargets, + weightBpCorrelation: correlations.weightBpCorrelation, + scatterData: correlations.scatterData, + bpMedicationCorrelation, + bpMedicationScatterData, + moodSummary, + moodBpCorrelation: correlations.moodBpCorrelation, + moodBpScatterData: correlations.moodBpScatterData, + moodWeightCorrelation: correlations.moodWeightCorrelation, + moodWeightScatterData: correlations.moodWeightScatterData, + moodPulseCorrelation: correlations.moodPulseCorrelation, + moodPulseScatterData: correlations.moodPulseScatterData, + medications: medCompliance, + alerts, + // Presence only: no client is built and no token is refreshed on a read. + hasProvider: await probeProviderPresence(userId), + dataSpanDays, + totalMeasurements: aggregate.totalMeasurements, + }; +} diff --git a/src/lib/insights/comprehensive-overview/correlations.ts b/src/lib/insights/comprehensive-overview/correlations.ts new file mode 100644 index 000000000..ab9f6ce97 --- /dev/null +++ b/src/lib/insights/comprehensive-overview/correlations.ts @@ -0,0 +1,78 @@ +/** + * The overview's correlations: weight × systolic, and mood against + * systolic, weight and resting pulse. Pure: every series is already read. + */ +import { + significantPearsonCorrelation, + type PairedPoint, +} from "@/lib/analytics/correlations"; +import type { ComprehensiveAggregate } from "@/lib/insights/comprehensive-aggregator"; + +import { + deriveDailyLocalMeans, + joinDailyByDay, + pairMoodWithDaily, + type DailyValue, +} from "./daily-series"; + +export function overviewCorrelations(args: { + aggregate: ComprehensiveAggregate; + dailyMoodEntries: DailyValue[]; + restingPulseDaily: DailyValue[]; + userTz: string; +}) { + const { aggregate, dailyMoodEntries, restingPulseDaily, userTz } = args; + + // Weight × Sys BP, daily-key pairing. Both sides come from the UTC + // `dailyByType` series, so they share one UTC day-key space and are + // internally consistent; re-keying it to local migrates with the rollup + // tier. + const weightDaily = aggregate.dailyByType.WEIGHT ?? []; + const sysDaily = aggregate.dailyByType.BLOOD_PRESSURE_SYS ?? []; + const weightBpPairs: PairedPoint[] = joinDailyByDay(weightDaily, sysDaily); + const weightBpCorrelation = significantPearsonCorrelation(weightBpPairs); + const scatterData = weightBpPairs.map((p) => ({ + weight: p.a, + sysBP: p.b, + })); + + // Mood × {sys BP, weight, pulse}. The mood series is LOCAL-day-keyed and + // `pairMoodWithDaily` joins on an EXACT YYYY-MM-DD key, so the partners + // are exact local-day means from the raw rows already on the request path + // (canonical-source deduped in the aggregator), NOT the UTC `dailyByType` — + // a UTC-day MEAN cannot be relabeled into a local-day mean. + const sysDailyLocal = deriveDailyLocalMeans(aggregate.bpRawRows.sys, userTz); + const weightDailyLocal = deriveDailyLocalMeans( + aggregate.weightRawRows, + userTz, + ); + + const moodBpPairs = pairMoodWithDaily(dailyMoodEntries, sysDailyLocal); + const moodBpCorrelation = significantPearsonCorrelation(moodBpPairs); + const moodBpScatterData = moodBpPairs.map((p) => ({ mood: p.a, sysBP: p.b })); + + const moodWeightPairs = pairMoodWithDaily(dailyMoodEntries, weightDailyLocal); + const moodWeightCorrelation = significantPearsonCorrelation(moodWeightPairs); + const moodWeightScatterData = moodWeightPairs.map((p) => ({ + mood: p.a, + weight: p.b, + })); + + const moodPulsePairs = pairMoodWithDaily(dailyMoodEntries, restingPulseDaily); + const moodPulseCorrelation = significantPearsonCorrelation(moodPulsePairs); + const moodPulseScatterData = moodPulsePairs.map((p) => ({ + mood: p.a, + pulse: p.b, + })); + + return { + weightBpCorrelation, + scatterData, + moodBpCorrelation, + moodBpScatterData, + moodWeightCorrelation, + moodWeightScatterData, + moodPulseCorrelation, + moodPulseScatterData, + }; +} diff --git a/src/lib/insights/comprehensive-overview/daily-series.ts b/src/lib/insights/comprehensive-overview/daily-series.ts new file mode 100644 index 000000000..e80d9ba71 --- /dev/null +++ b/src/lib/insights/comprehensive-overview/daily-series.ts @@ -0,0 +1,110 @@ +/** + * Day-keyed series helpers for the Insights overview's correlations: local + * day means from raw rows, and the two joins that pair one daily series with + * another. + */ +import type { PairedPoint } from "@/lib/analytics/correlations"; +import { userDayKey } from "@/lib/tz/format"; + +export interface DailyValue { + day: string; + value: number; +} + +/** + * Derive exact local-day mean series from raw measurement rows, keyed by the + * user's timezone via `userDayKey`. The mood series is local-day-keyed, and + * `pairMoodWithDaily` joins on an exact YYYY-MM-DD key, so its metric + * partners must be local-day-keyed too. A UTC-day mean (from `dailyByType`) + * cannot be relabeled into a local-day mean, so the mean is recomputed here + * from raw rows already on the request path. `userDayKey` formats via a + * memoised `Intl.DateTimeFormat` — no offset arithmetic — so DST-transition + * days (23 h / 25 h) bucket correctly and the result is host-TZ-independent + * (CI runs UTC). `round2` mirrors the aggregator's `ROUND(AVG, 2)` so + * correlation thresholds don't drift. Returns `{ day, value }[]` sorted ASC + * by day, the shape `pairMoodWithDaily` expects. + */ +export function deriveDailyLocalMeans( + rows: Array<{ measuredAt: Date; value: number }>, + tz: string, +): DailyValue[] { + const byDay = new Map(); + for (const r of rows) { + const key = userDayKey(r.measuredAt, tz); + const cur = byDay.get(key) ?? { sum: 0, count: 0 }; + cur.sum += r.value; + cur.count += 1; + byDay.set(key, cur); + } + return Array.from(byDay.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([day, stats]) => ({ + day, + value: Math.round((stats.sum / stats.count) * 100) / 100, + })); +} + +/** + * Daily-key inner join between two daily-aggregated series. Returns + * `PairedPoint` shapes compatible with `significantPearsonCorrelation`. + * + * Inputs are sorted ASC by `day` (the aggregator's ORDER BY + * guarantees that). The walk is O(n + m) merge-join style instead of + * Map.lookup-per-element so it scales with the worst-case 90 buckets + * x 90 buckets case without surprises. + */ +export function joinDailyByDay( + a: DailyValue[], + b: DailyValue[], +): PairedPoint[] { + const pairs: PairedPoint[] = []; + let i = 0; + let j = 0; + while (i < a.length && j < b.length) { + const ai = a[i]; + const bj = b[j]; + if (ai.day === bj.day) { + pairs.push({ + a: ai.value, + b: bj.value, + date: new Date(`${ai.day}T12:00:00.000Z`), + }); + i += 1; + j += 1; + } else if (ai.day < bj.day) { + i += 1; + } else { + j += 1; + } + } + return pairs; +} + +/** + * Pair the daily mood series (left side) with a daily-aggregated + * metric (right side). Matches the legacy `buildMoodMetricPairs` + * helper byte-for-byte: same daily YYYY-MM-DD key, same + * `T12:00:00.000Z` anchor for the `date` field. + */ +export function pairMoodWithDaily( + dailyMood: DailyValue[], + dailyMetric: DailyValue[], +): PairedPoint[] { + if (dailyMood.length === 0 || dailyMetric.length === 0) return []; + const metricByDay = new Map(); + for (const m of dailyMetric) { + metricByDay.set(m.day, m.value); + } + const pairs: PairedPoint[] = []; + for (const mood of dailyMood) { + const metric = metricByDay.get(mood.day); + if (metric !== undefined) { + pairs.push({ + a: mood.value, + b: metric, + date: new Date(`${mood.day}T12:00:00.000Z`), + }); + } + } + return pairs; +} diff --git a/src/lib/insights/comprehensive-overview/medication-compliance.ts b/src/lib/insights/comprehensive-overview/medication-compliance.ts new file mode 100644 index 000000000..43a4567ab --- /dev/null +++ b/src/lib/insights/comprehensive-overview/medication-compliance.ts @@ -0,0 +1,235 @@ +/** + * The overview's medication block: per-medication compliance over 7 and 30 + * days, and the blood-pressure medication continuity × systolic correlation + * that pairs the BP medications' intake days with the daily systolic means. + */ +import { prisma } from "@/lib/db"; +import { + buildComplianceMedicationContext, + COURSES_COMPLIANCE_SELECT, + calculateCompliance, + expectsDoses, + lastNonSkippedTakenAt, + SCHEDULE_COMPLIANCE_SELECT, +} from "@/lib/analytics/compliance"; +import { significantPearsonCorrelation } from "@/lib/analytics/correlations"; +import { resolveMedicationCategories } from "@/lib/medication-category"; +import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking"; +import { dayKeyAsUtcMidnight } from "@/lib/tz/date-only"; + +import type { DailyValue } from "./daily-series"; + +interface BpIntakeEvent { + scheduledFor: Date; + takenAt: Date | null; + skipped: boolean; +} + +/** Read the tracked scheduled medications with what compliance needs. */ +async function readScheduledMedications(userId: string) { + const medicationRows = await prisma.medication.findMany({ + // As-needed (PRN) medications never surface a compliance rate (no + // expected doses). + where: { userId, active: true, asNeeded: false, ...TRACKED_INTAKE_WHERE }, + include: { + // Schedules through the shared compliance select so the configured + // per-dose windows reach this surface like every other. + schedules: { select: SCHEDULE_COMPLIANCE_SELECT }, + // Archived schedule eras for era-aware compliance. + scheduleRevisions: { orderBy: { validFrom: "asc" } }, + // Pause eras so paused days drop out of the denominator. + pauseEras: { select: { pausedAt: true, resumedAt: true } }, + // The courses, so a gap between two expects nothing (#1024). + courses: COURSES_COMPLIANCE_SELECT, + }, + }); + // A medication with no schedule expects no dose, and `calculateCompliance` + // answers 100 % for that case. The `asNeeded: false` filter above catches + // only the PRN arm; the empty schedule is reachable on a scheduled + // medication and would otherwise report perfect adherence. + return medicationRows.filter(expectsDoses); +} + +/** + * Per-medication compliance, plus the intake events of the blood-pressure + * medications for the continuity correlation. + */ +export async function readMedicationCompliance(args: { + userId: string; + since: Date; + timezone: string; +}) { + const { userId, since, timezone } = args; + const medications = await readScheduledMedications(userId); + + const resolvedCategories = await resolveMedicationCategories( + medications.map((m) => m.id), + ); + const categoryMap = Object.fromEntries( + Object.entries(resolvedCategories).map(([id, r]) => [id, r.category]), + ); + + const medCompliance = []; + const bpMedicationEvents: BpIntakeEvent[] = []; + const bpMedications = medications.filter( + (med) => (categoryMap[med.id] ?? "OTHER") === "BLOOD_PRESSURE", + ); + // Single round-trip for all medications instead of N+1: one query keyed + // on `medicationId IN (...)`, then group in memory. + const allEvents = medications.length + ? await prisma.medicationIntakeEvent.findMany({ + where: { + medicationId: { in: medications.map((m) => m.id) }, + userId, + // Tombstoned rows are excluded. + deletedAt: null, + scheduledFor: { gte: since }, + }, + orderBy: { scheduledFor: "desc" }, + }) + : []; + + const eventsByMed = new Map(); + for (const ev of allEvents) { + const list = eventsByMed.get(ev.medicationId); + if (list) list.push(ev); + else eventsByMed.set(ev.medicationId, [ev]); + } + + for (const med of medications) { + const events = eventsByMed.get(med.id) ?? []; + const mapped = events.map((e) => ({ + takenAt: e.takenAt, + skipped: e.skipped, + scheduledFor: e.scheduledFor, + })); + // Engine-routed denominator. + const medicationContext = buildComplianceMedicationContext( + med, + lastNonSkippedTakenAt(mapped), + timezone, + ); + const c7 = calculateCompliance(mapped, med.schedules, 7, med.createdAt, { + medicationContext, + }); + const c30 = calculateCompliance(mapped, med.schedules, 30, med.createdAt, { + medicationContext, + }); + + medCompliance.push({ + id: med.id, + name: med.name, + dose: med.dose, + category: categoryMap[med.id] ?? "OTHER", + // The label of a custom category; null for a built-in (#1041). + categoryLabel: resolvedCategories[med.id]?.categoryLabel ?? null, + compliance7: c7.rate, + compliance30: c30.rate, + streak: c7.streak, + taken7: c7.taken, + skipped7: c7.skipped, + missed7: c7.missed, + }); + + if ((categoryMap[med.id] ?? "OTHER") === "BLOOD_PRESSURE") { + bpMedicationEvents.push( + ...events.map((e) => ({ + scheduledFor: e.scheduledFor, + takenAt: e.takenAt, + skipped: e.skipped, + })), + ); + } + } + + return { medications, medCompliance, bpMedications, bpMedicationEvents }; +} + +/** + * Correlation: BP medication continuity × systolic BP. + * + * Uses the same daily BP_SYS series the aggregator pre-computed — no second + * round-trip. The intake-side aggregation matches the legacy code: + * continuity = min(1, taken / expectedPerDay), pairing every day that had a + * sys reading. + */ +export function bpMedicationContinuity(args: { + bpMedications: Array<{ schedules: unknown[] }>; + bpMedicationEvents: BpIntakeEvent[]; + sysDaily: DailyValue[]; +}) { + const { bpMedications, bpMedicationEvents, sysDaily } = args; + let bpMedicationCorrelation: { + r: number; + strength: string; + n: number; + medicationCount: number; + } | null = null; + const bpMedicationScatterData: Array<{ + continuityPct: number; + sysBP: number; + }> = []; + + const expectedBpIntakesPerDay = bpMedications.reduce( + (sum, med) => sum + med.schedules.length, + 0, + ); + + if (expectedBpIntakesPerDay > 0) { + // Daily systolic means (already keyed YYYY-MM-DD) from the SQL pass. + // `sysDaily` (UTC `date_trunc` from the aggregator's `dailyByType`) and + // `takenByDay` below (intake `scheduledFor` truncated to UTC) both live + // in the SAME UTC day-key space and are internally consistent with each + // other, so this join stays UTC. Only the mood pairings moved to a + // separately derived local-day series (their partner, the mood series, + // is local-day-keyed). This correlation migrates to local when the + // measurement-rollup tier buckets per-user tz; re-keying it here without + // the local-bucketed rollup would mean per-request raw re-aggregation. + const sysByDay = new Map(); + for (const row of sysDaily) { + sysByDay.set(row.day, row.value); + } + + const takenByDay = new Map(); + // Days that carry an intake record of ANY kind. A logged skip is a real + // zero and belongs in the series; a day the person never touched is not a + // zero, it is nothing, and pairing it against that day's systolic reading + // invented a data point they never produced. Absence is not zero, here as + // everywhere else in this project, so the unlogged day drops out of both + // the scatter and the coefficient rather than dragging continuity down. + const loggedDays = new Set(); + for (const event of bpMedicationEvents) { + // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: pairs intake days with the DAY rollup buckets in sysByDay, which are UTC days + const dayKey = event.scheduledFor.toISOString().slice(0, 10); + loggedDays.add(dayKey); + if (event.skipped || !event.takenAt) continue; + takenByDay.set(dayKey, (takenByDay.get(dayKey) ?? 0) + 1); + } + + const pairs: Array<{ a: number; b: number; date: Date }> = []; + for (const [dayKey, avgSys] of sysByDay.entries()) { + if (!loggedDays.has(dayKey)) continue; + const taken = takenByDay.get(dayKey) ?? 0; + const continuity = Math.min(1, taken / expectedBpIntakesPerDay); + pairs.push({ + a: continuity, + b: avgSys, + date: dayKeyAsUtcMidnight(dayKey), + }); + bpMedicationScatterData.push({ + continuityPct: Math.round(continuity * 100), + sysBP: Math.round(avgSys * 10) / 10, + }); + } + + const corr = significantPearsonCorrelation(pairs); + if (corr) { + bpMedicationCorrelation = { + ...corr, + medicationCount: bpMedications.length, + }; + } + } + + return { bpMedicationCorrelation, bpMedicationScatterData }; +} diff --git a/src/lib/insights/comprehensive-overview/mood-series.ts b/src/lib/insights/comprehensive-overview/mood-series.ts new file mode 100644 index 000000000..40efa5afd --- /dev/null +++ b/src/lib/insights/comprehensive-overview/mood-series.ts @@ -0,0 +1,92 @@ +/** + * The overview's 90-day mood series, read from the mood-rollup DAY tier. + * + * The two consumers downstream — `dailyMoodEntries` for the mood × metric + * correlations and the mood summary for the dashboard mood block — read at a + * per-day resolution, which is precisely the shape the rollup writer emits, + * so the tier replaces an unbounded `prisma.moodEntry.findMany`. + * + * Coverage-fallback: when the user has mood entries but zero rollup rows yet + * (legacy account before the boot-time backfill has caught up), fall back to + * a bounded 90-day raw walk once and fire the warm-up so the next request + * lands on the rollup tier. Same posture as `/api/mood/analytics`. + * + * The mood rollup writer mints `bucketStart` from the canonical local + * `MoodEntry.date` label at UTC midnight, so `.toISOString().slice(0, 10)` + * recovers the user's LOCAL calendar day — byte-identical to the live + * fallback's `entry.date`. Both mood paths therefore emit local-day keys, and + * the mood × metric pairing partners (sys BP, weight, resting pulse) are + * derived in the user's tz so both join sides share ONE local-day key space. + * The measurement-rollup tier itself still buckets UTC (`dailyByType`); the + * two internally-consistent UTC joins (weight × BP, BP-med continuity) are + * left on it deliberately (see their call sites in `build.ts`). + */ +import { prisma } from "@/lib/db"; +import type { DataPoint } from "@/lib/analytics/trends"; +import { + ensureUserMoodRollupsFresh, + readMoodDayRollups, +} from "@/lib/rollups/mood-rollups"; +import { dateOnlyKey } from "@/lib/tz/date-only"; + +import type { DailyValue } from "./daily-series"; + +export interface OverviewMoodSeries { + dailyMoodEntries: DailyValue[]; + moodDataPoints: DataPoint[]; + moodEntryCount: number; +} + +export async function readOverviewMoodSeries( + userId: string, + since: Date, +): Promise { + void ensureUserMoodRollupsFresh(userId); + const moodRollupDayRows = await readMoodDayRollups(userId, since); + if (moodRollupDayRows.length > 0) { + return { + dailyMoodEntries: moodRollupDayRows.map((r) => ({ + day: dateOnlyKey(r.bucketStart), + value: Math.round(r.mean * 100) / 100, + })), + moodDataPoints: moodRollupDayRows.map((r) => ({ + date: r.bucketStart, + value: r.mean, + })), + moodEntryCount: moodRollupDayRows.reduce((s, r) => s + r.count, 0), + }; + } + // Coverage-fallback: legacy account with mood entries but no + // rollup coverage yet. Bounded by the 90-day window so the walk is + // capped even when the rollup miss happens. + const moodEntries = await prisma.moodEntry.findMany({ + // Tombstoned rows are excluded. + where: { userId, deletedAt: null, moodLoggedAt: { gte: since } }, + orderBy: { moodLoggedAt: "asc" }, + select: { date: true, score: true, moodLoggedAt: true }, + }); + const moodByDay = new Map(); + for (const entry of moodEntries) { + const current = moodByDay.get(entry.date) ?? { sum: 0, count: 0 }; + current.sum += entry.score; + current.count += 1; + moodByDay.set(entry.date, current); + } + const dailyMoodEntries = Array.from(moodByDay.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([day, stats]) => ({ + day, + value: Math.round((stats.sum / stats.count) * 100) / 100, + })); + return { + dailyMoodEntries, + // Per-day means feed `summarize()` so the live-fallback path shares the + // rollup-tier semantic (one DataPoint per day, the daily mean), the same + // reading mood/analytics uses. + moodDataPoints: dailyMoodEntries.map((e) => ({ + date: new Date(`${e.day}T12:00:00.000Z`), + value: e.value, + })), + moodEntryCount: moodEntries.length, + }; +} diff --git a/src/lib/insights/comprehensive-overview/overview.ts b/src/lib/insights/comprehensive-overview/overview.ts new file mode 100644 index 000000000..15cc843e6 --- /dev/null +++ b/src/lib/insights/comprehensive-overview/overview.ts @@ -0,0 +1,49 @@ +/** + * The Insights overview read: the computed body through the analytics + * cache. The route resolves the per-request AI state beside it, which is + * never cached with the body. + */ +import { annotate } from "@/lib/logging/context"; +import { + cachedSwrWithMeta, + caches, + type ServerCache, +} from "@/lib/cache/server-cache"; + +import { + buildComprehensiveResponse, + type ComprehensiveOverviewBody, + type OverviewRecord, +} from "./build"; + +export async function readComprehensiveOverview( + user: OverviewRecord, +): Promise<{ body: ComprehensiveOverviewBody; revalidating: boolean }> { + // Read-through the analytics cache keyed on (userId, "comprehensive"). The + // /insights page mount routinely fans out to this endpoint alongside the + // Coach drawer + the recommendations grid; the 60s TTL converts every + // duplicate mount within the window to a Map lookup. Invalidation is + // handled by `invalidateUserMeasurements`, which marks every key under the + // `${userId}|` prefix stale (see `lib/cache/invalidate.ts`). + // + // Stale-while-revalidate: this is the single heaviest SQL-side aggregation + // on the /insights mount; serving the prior body instantly while ONE + // background recompute refreshes it keeps the page interactive across the + // bucket's 10-minute stale window (a measurement sync used to bust the + // entry into a blocking cold rebuild on the very next mount, all day). The + // truly-cold first read of the day still computes inline — there is no + // prior body to serve — but it no longer recurs per visit. + // + // The `revalidating` flag marks a stale-served body honestly so the client + // can poll (bounded) until the background rebuild lands — mirroring the + // `/api/insights/generate` GET contract. It rides on the response, never + // inside the cached body, so a warmed entry doesn't freeze the flag. + const { value: body, outcome } = await cachedSwrWithMeta( + caches.analytics as ServerCache, + `${user.id}|comprehensive`, + () => buildComprehensiveResponse(user), + annotate, + ); + + return { body, revalidating: outcome === "stale" }; +} diff --git a/src/lib/insights/comprehensive-overview/resting-pulse-daily.ts b/src/lib/insights/comprehensive-overview/resting-pulse-daily.ts new file mode 100644 index 000000000..acb54fc0d --- /dev/null +++ b/src/lib/insights/comprehensive-overview/resting-pulse-daily.ts @@ -0,0 +1,65 @@ +/** + * The overview's daily resting-pulse series, the mood × pulse correlation's + * partner. + * + * The correlation is labelled "Resting pulse (bpm)", so it is scored against + * the RESTING series, not the raw PULSE day-mean: the aggregator's + * `dailyByType.PULSE` is a per-day AVG that folds in workout HR for + * Apple-Health users and inflates the "resting" figure. The clean + * RESTING_HEART_RATE rows win; only without them does the low-percentile + * daily proxy over raw pulse stand in. The proxy folds in Postgres (#1023): + * it needs one percentile per day, and reading the raw samples to get it + * walked 130 000 rows per page view on a watch that records once a minute. + * + * Keyed on `userDayKey(d, userTz)`: its only pairing partner is the mood + * series, which is local-day-keyed, and a UTC key here would offset every + * evening logger west of UTC by one day and sell a lag-1 correlation as + * same-day. Unlike the mood side, this series builds from raw rows, so it + * re-keys directly with no rollup-tier change. + */ +import { prisma } from "@/lib/db"; +import { resolveRestingPulseSeries } from "@/lib/analytics/resting-pulse"; +import { readRestingPulseProxy } from "@/lib/analytics/resting-pulse-read"; +import { userDayKey } from "@/lib/tz/format"; + +import type { DailyValue } from "./daily-series"; + +export async function readRestingPulseDaily( + userId: string, + since: Date, + userTz: string, +): Promise { + const restingPulseRows = await prisma.measurement.findMany({ + where: { + userId, + type: "RESTING_HEART_RATE", + deletedAt: null, + measuredAt: { gte: since }, + }, + select: { measuredAt: true, value: true }, + }); + const localDayKey = (d: Date) => userDayKey(d, userTz); + const restingPulseSeries = + restingPulseRows.length > 0 + ? resolveRestingPulseSeries({ + restingSamples: restingPulseRows, + pulseSamples: [], + dayKeyOf: localDayKey, + }).series + : await readRestingPulseProxy({ + userId, + since, + timeZone: userTz, + }); + const restingByDay = new Map(); + for (const s of restingPulseSeries) { + const key = localDayKey(s.measuredAt); + const cur = restingByDay.get(key) ?? { sum: 0, count: 0 }; + cur.sum += s.value; + cur.count += 1; + restingByDay.set(key, cur); + } + return Array.from(restingByDay.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([day, stats]) => ({ day, value: stats.sum / stats.count })); +} diff --git a/tests/integration/routes/parity/__snapshots__/insights-comprehensive.01-anonymous.json.snap b/tests/integration/routes/parity/__snapshots__/insights-comprehensive.01-anonymous.json.snap new file mode 100644 index 000000000..b1fdd5326 --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/insights-comprehensive.01-anonymous.json.snap @@ -0,0 +1,24 @@ +{ + "status": 401, + "body": { + "data": null, + "error": "Not authenticated", + "meta": { + "errorCode": "auth.missing", + "requestId": "" + } + }, + "events": [ + { + "status": 401, + "action": null, + "meta": null, + "warnings": null, + "error": { + "type": "HttpError", + "message": "Not authenticated" + } + } + ], + "effects": [] +} diff --git a/tests/integration/routes/parity/__snapshots__/insights-comprehensive.02-cold.json.snap b/tests/integration/routes/parity/__snapshots__/insights-comprehensive.02-cold.json.snap new file mode 100644 index 000000000..44932c375 --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/insights-comprehensive.02-cold.json.snap @@ -0,0 +1,1303 @@ +{ + "status": 200, + "body": { + "data": { + "summaries": { + "WEIGHT": { + "count": 58, + "latest": 82, + "min": 82, + "max": 84.4, + "mean": 83.19, + "median": null, + "avg7": 82.11, + "avg30": 82.59, + "slope7": { + "slope": -0.039, + "direction": "down", + "confidence": 0.89 + }, + "slope30": { + "slope": -0.04, + "direction": "down", + "confidence": 0.99 + }, + "anomalyCount": 0, + "avg30LastMonth": 83.8, + "avg30LastYear": null + }, + "BLOOD_PRESSURE_SYS": { + "count": 87, + "latest": 123, + "min": 123, + "max": 137, + "mean": 130.2, + "median": null, + "avg7": 131.14, + "avg30": 130.1, + "slope7": { + "slope": -1.107, + "direction": "down", + "confidence": 0.22 + }, + "slope30": { + "slope": 0.043, + "direction": "up", + "confidence": 0.01 + }, + "anomalyCount": 0, + "avg30LastMonth": 130.24, + "avg30LastYear": null + }, + "BLOOD_PRESSURE_DIA": { + "count": 87, + "latest": 78, + "min": 78, + "max": 86, + "mean": 81.94, + "median": null, + "avg7": 81.43, + "avg30": 81.66, + "slope7": { + "slope": -0.5, + "direction": "down", + "confidence": 0.17 + }, + "slope30": { + "slope": -0.019, + "direction": "down", + "confidence": 0 + }, + "anomalyCount": 0, + "avg30LastMonth": 82.03, + "avg30LastYear": null + }, + "PULSE": { + "count": 58, + "latest": 64, + "min": 64, + "max": 69, + "mean": 66.57, + "median": null, + "avg7": 66.14, + "avg30": 66.55, + "slope7": { + "slope": -0.357, + "direction": "down", + "confidence": 0.16 + }, + "slope30": { + "slope": -0.035, + "direction": "down", + "confidence": 0.03 + }, + "anomalyCount": 0, + "avg30LastMonth": 66.59, + "avg30LastYear": null + }, + "BODY_FAT": { + "count": 8, + "latest": 24, + "min": 24, + "max": 25.1, + "mean": 24.61, + "median": null, + "avg7": 24, + "avg30": 24.33, + "slope7": null, + "slope30": { + "slope": -0.021, + "direction": "down", + "confidence": 0.99 + }, + "anomalyCount": 0, + "avg30LastMonth": 24.9, + "avg30LastYear": null + }, + "SLEEP_DURATION": { + "count": 81, + "latest": 12, + "min": 12, + "max": 240, + "mean": 98.64, + "median": null, + "avg7": 96.17, + "avg30": 98.64, + "slope7": { + "slope": -2.121, + "direction": "down", + "confidence": 0 + }, + "slope30": { + "slope": -0.379, + "direction": "down", + "confidence": 0 + }, + "anomalyCount": 0, + "avg30LastMonth": null, + "avg30LastYear": null + }, + "ACTIVITY_STEPS": { + "count": 29, + "latest": 6000, + "min": 6000, + "max": 9833, + "mean": 7780.83, + "median": null, + "avg7": 7119, + "avg30": 7780.83, + "slope7": { + "slope": -373, + "direction": "down", + "confidence": 1 + }, + "slope30": { + "slope": -34.049, + "direction": "down", + "confidence": 0.07 + }, + "anomalyCount": 0, + "avg30LastMonth": null, + "avg30LastYear": null + }, + "BLOOD_GLUCOSE": { + "count": 57, + "latest": 128, + "min": 92, + "max": 147, + "mean": 113.12, + "median": null, + "avg7": 112.76, + "avg30": 113.12, + "slope7": { + "slope": -1.064, + "direction": "down", + "confidence": 0.01 + }, + "slope30": { + "slope": -0.113, + "direction": "down", + "confidence": 0 + }, + "anomalyCount": 0, + "avg30LastMonth": null, + "avg30LastYear": null + }, + "OXYGEN_SATURATION": { + "count": 9, + "latest": 96, + "min": 96, + "max": 98, + "mean": 96.89, + "median": null, + "avg7": 96.86, + "avg30": 96.89, + "slope7": { + "slope": -0.036, + "direction": "down", + "confidence": 0.01 + }, + "slope30": { + "slope": -0.016, + "direction": "down", + "confidence": 0 + }, + "anomalyCount": 0, + "avg30LastMonth": null, + "avg30LastYear": null + }, + "HEART_RATE_VARIABILITY": { + "count": 29, + "latest": 45, + "min": 45, + "max": 52, + "mean": 48.17, + "median": null, + "avg7": 48, + "avg30": 48.17, + "slope7": { + "slope": -1, + "direction": "down", + "confidence": 1 + }, + "slope30": { + "slope": -0.046, + "direction": "down", + "confidence": 0.03 + }, + "anomalyCount": 0, + "avg30LastMonth": null, + "avg30LastYear": null + }, + "RESTING_HEART_RATE": { + "count": 38, + "latest": 58, + "min": 58, + "max": 61, + "mean": 59.45, + "median": null, + "avg7": 59.29, + "avg30": 59.38, + "slope7": { + "slope": -0.143, + "direction": "down", + "confidence": 0.08 + }, + "slope30": { + "slope": -0.009, + "direction": "stable", + "confidence": 0.01 + }, + "anomalyCount": 0, + "avg30LastMonth": 59.67, + "avg30LastYear": null + } + }, + "bmi": 25.9, + "bmiClassification": { + "category": "Overweight", + "color": "var(--warning)", + "severity": "warning" + }, + "bpClassification": { + "category": "High-normal", + "color": "var(--dracula-yellow)", + "severity": "elevated" + }, + "bpPctInTarget": 11, + "bpTargets": { + "sysLow": 120, + "sysHigh": 129, + "diaLow": 70, + "diaHigh": 79 + }, + "weightBpCorrelation": null, + "scatterData": [ + { + "weight": 84.4, + "sysBP": 131 + }, + { + "weight": 84.3, + "sysBP": 124 + }, + { + "weight": 84.3, + "sysBP": 132 + }, + { + "weight": 84.2, + "sysBP": 125 + }, + { + "weight": 84.2, + "sysBP": 133 + }, + { + "weight": 84.2, + "sysBP": 126 + }, + { + "weight": 84.1, + "sysBP": 134 + }, + { + "weight": 84.1, + "sysBP": 127 + }, + { + "weight": 84, + "sysBP": 135 + }, + { + "weight": 84, + "sysBP": 128 + }, + { + "weight": 84, + "sysBP": 136 + }, + { + "weight": 83.9, + "sysBP": 129 + }, + { + "weight": 83.9, + "sysBP": 137 + }, + { + "weight": 83.8, + "sysBP": 130 + }, + { + "weight": 83.8, + "sysBP": 123 + }, + { + "weight": 83.8, + "sysBP": 131 + }, + { + "weight": 83.7, + "sysBP": 124 + }, + { + "weight": 83.7, + "sysBP": 132 + }, + { + "weight": 83.6, + "sysBP": 125 + }, + { + "weight": 83.6, + "sysBP": 133 + }, + { + "weight": 83.6, + "sysBP": 126 + }, + { + "weight": 83.5, + "sysBP": 134 + }, + { + "weight": 83.5, + "sysBP": 127 + }, + { + "weight": 83.4, + "sysBP": 135 + }, + { + "weight": 83.4, + "sysBP": 128 + }, + { + "weight": 83.4, + "sysBP": 136 + }, + { + "weight": 83.3, + "sysBP": 129 + }, + { + "weight": 83.3, + "sysBP": 137 + }, + { + "weight": 83.2, + "sysBP": 130 + }, + { + "weight": 83.2, + "sysBP": 131 + }, + { + "weight": 83.1, + "sysBP": 124 + }, + { + "weight": 83.1, + "sysBP": 132 + }, + { + "weight": 83, + "sysBP": 125 + }, + { + "weight": 83, + "sysBP": 133 + }, + { + "weight": 83, + "sysBP": 126 + }, + { + "weight": 82.9, + "sysBP": 134 + }, + { + "weight": 82.9, + "sysBP": 127 + }, + { + "weight": 82.8, + "sysBP": 135 + }, + { + "weight": 82.8, + "sysBP": 128 + }, + { + "weight": 82.8, + "sysBP": 136 + }, + { + "weight": 82.7, + "sysBP": 129 + }, + { + "weight": 82.7, + "sysBP": 137 + }, + { + "weight": 82.6, + "sysBP": 130 + }, + { + "weight": 82.6, + "sysBP": 123 + }, + { + "weight": 82.6, + "sysBP": 131 + }, + { + "weight": 82.5, + "sysBP": 124 + }, + { + "weight": 82.5, + "sysBP": 132 + }, + { + "weight": 82.4, + "sysBP": 125 + }, + { + "weight": 82.4, + "sysBP": 133 + }, + { + "weight": 82.4, + "sysBP": 126 + }, + { + "weight": 82.3, + "sysBP": 134 + }, + { + "weight": 82.2, + "sysBP": 135 + }, + { + "weight": 82.2, + "sysBP": 128 + }, + { + "weight": 82.2, + "sysBP": 136 + }, + { + "weight": 82.1, + "sysBP": 129 + }, + { + "weight": 82.1, + "sysBP": 137 + }, + { + "weight": 82, + "sysBP": 130 + }, + { + "weight": 82, + "sysBP": 123 + } + ], + "bpMedicationCorrelation": null, + "bpMedicationScatterData": [ + { + "continuityPct": 100, + "sysBP": 131 + }, + { + "continuityPct": 100, + "sysBP": 124 + }, + { + "continuityPct": 0, + "sysBP": 132 + }, + { + "continuityPct": 100, + "sysBP": 125 + }, + { + "continuityPct": 100, + "sysBP": 133 + }, + { + "continuityPct": 100, + "sysBP": 126 + }, + { + "continuityPct": 100, + "sysBP": 134 + }, + { + "continuityPct": 100, + "sysBP": 127 + }, + { + "continuityPct": 0, + "sysBP": 128 + }, + { + "continuityPct": 100, + "sysBP": 136 + }, + { + "continuityPct": 100, + "sysBP": 129 + }, + { + "continuityPct": 100, + "sysBP": 137 + }, + { + "continuityPct": 100, + "sysBP": 130 + }, + { + "continuityPct": 100, + "sysBP": 131 + }, + { + "continuityPct": 0, + "sysBP": 124 + }, + { + "continuityPct": 100, + "sysBP": 125 + }, + { + "continuityPct": 100, + "sysBP": 133 + }, + { + "continuityPct": 100, + "sysBP": 126 + }, + { + "continuityPct": 100, + "sysBP": 134 + }, + { + "continuityPct": 100, + "sysBP": 127 + }, + { + "continuityPct": 0, + "sysBP": 135 + }, + { + "continuityPct": 100, + "sysBP": 128 + }, + { + "continuityPct": 100, + "sysBP": 136 + }, + { + "continuityPct": 100, + "sysBP": 137 + }, + { + "continuityPct": 100, + "sysBP": 130 + }, + { + "continuityPct": 100, + "sysBP": 123 + }, + { + "continuityPct": 0, + "sysBP": 131 + }, + { + "continuityPct": 100, + "sysBP": 124 + }, + { + "continuityPct": 100, + "sysBP": 132 + }, + { + "continuityPct": 100, + "sysBP": 125 + }, + { + "continuityPct": 100, + "sysBP": 133 + }, + { + "continuityPct": 100, + "sysBP": 134 + }, + { + "continuityPct": 100, + "sysBP": 135 + }, + { + "continuityPct": 100, + "sysBP": 128 + }, + { + "continuityPct": 100, + "sysBP": 136 + }, + { + "continuityPct": 100, + "sysBP": 129 + }, + { + "continuityPct": 100, + "sysBP": 137 + }, + { + "continuityPct": 100, + "sysBP": 130 + } + ], + "moodSummary": { + "count": 45, + "latest": 1, + "min": 1, + "max": 5, + "mean": 3, + "median": 3, + "avg7": 2.86, + "avg30": 3, + "slope7": { + "slope": -0.143, + "direction": "down", + "confidence": 0.04 + }, + "slope30": { + "slope": -0.013, + "direction": "down", + "confidence": 0.01 + }, + "anomalyCount": 0, + "avg30LastMonth": 3, + "avg30LastYear": null + }, + "moodBpCorrelation": null, + "moodBpScatterData": [ + { + "mood": 3, + "sysBP": 131 + }, + { + "mood": 5, + "sysBP": 124 + }, + { + "mood": 2, + "sysBP": 132 + }, + { + "mood": 4, + "sysBP": 125 + }, + { + "mood": 1, + "sysBP": 133 + }, + { + "mood": 3, + "sysBP": 126 + }, + { + "mood": 5, + "sysBP": 134 + }, + { + "mood": 2, + "sysBP": 127 + }, + { + "mood": 4, + "sysBP": 135 + }, + { + "mood": 1, + "sysBP": 128 + }, + { + "mood": 3, + "sysBP": 136 + }, + { + "mood": 5, + "sysBP": 129 + }, + { + "mood": 2, + "sysBP": 137 + }, + { + "mood": 4, + "sysBP": 130 + }, + { + "mood": 3, + "sysBP": 131 + }, + { + "mood": 5, + "sysBP": 124 + }, + { + "mood": 2, + "sysBP": 132 + }, + { + "mood": 4, + "sysBP": 125 + }, + { + "mood": 1, + "sysBP": 133 + }, + { + "mood": 3, + "sysBP": 126 + }, + { + "mood": 5, + "sysBP": 134 + }, + { + "mood": 2, + "sysBP": 127 + }, + { + "mood": 4, + "sysBP": 135 + }, + { + "mood": 1, + "sysBP": 128 + }, + { + "mood": 3, + "sysBP": 136 + }, + { + "mood": 5, + "sysBP": 129 + }, + { + "mood": 2, + "sysBP": 137 + }, + { + "mood": 4, + "sysBP": 130 + }, + { + "mood": 1, + "sysBP": 123 + }, + { + "mood": 3, + "sysBP": 131 + }, + { + "mood": 5, + "sysBP": 124 + }, + { + "mood": 2, + "sysBP": 132 + }, + { + "mood": 4, + "sysBP": 125 + }, + { + "mood": 1, + "sysBP": 133 + }, + { + "mood": 3, + "sysBP": 126 + }, + { + "mood": 5, + "sysBP": 134 + }, + { + "mood": 4, + "sysBP": 135 + }, + { + "mood": 1, + "sysBP": 128 + }, + { + "mood": 3, + "sysBP": 136 + }, + { + "mood": 5, + "sysBP": 129 + }, + { + "mood": 2, + "sysBP": 137 + }, + { + "mood": 4, + "sysBP": 130 + }, + { + "mood": 1, + "sysBP": 123 + } + ], + "moodWeightCorrelation": null, + "moodWeightScatterData": [ + { + "mood": 3, + "weight": 83.8 + }, + { + "mood": 5, + "weight": 83.7 + }, + { + "mood": 2, + "weight": 83.7 + }, + { + "mood": 4, + "weight": 83.6 + }, + { + "mood": 1, + "weight": 83.6 + }, + { + "mood": 3, + "weight": 83.6 + }, + { + "mood": 5, + "weight": 83.5 + }, + { + "mood": 2, + "weight": 83.5 + }, + { + "mood": 4, + "weight": 83.4 + }, + { + "mood": 1, + "weight": 83.4 + }, + { + "mood": 3, + "weight": 83.4 + }, + { + "mood": 5, + "weight": 83.3 + }, + { + "mood": 2, + "weight": 83.3 + }, + { + "mood": 4, + "weight": 83.2 + }, + { + "mood": 3, + "weight": 83.2 + }, + { + "mood": 5, + "weight": 83.1 + }, + { + "mood": 2, + "weight": 83.1 + }, + { + "mood": 4, + "weight": 83 + }, + { + "mood": 1, + "weight": 83 + }, + { + "mood": 3, + "weight": 83 + }, + { + "mood": 5, + "weight": 82.9 + }, + { + "mood": 2, + "weight": 82.9 + }, + { + "mood": 4, + "weight": 82.8 + }, + { + "mood": 1, + "weight": 82.8 + }, + { + "mood": 3, + "weight": 82.8 + }, + { + "mood": 5, + "weight": 82.7 + }, + { + "mood": 2, + "weight": 82.7 + }, + { + "mood": 4, + "weight": 82.6 + }, + { + "mood": 1, + "weight": 82.6 + }, + { + "mood": 3, + "weight": 82.6 + }, + { + "mood": 5, + "weight": 82.5 + }, + { + "mood": 2, + "weight": 82.5 + }, + { + "mood": 4, + "weight": 82.4 + }, + { + "mood": 1, + "weight": 82.4 + }, + { + "mood": 3, + "weight": 82.4 + }, + { + "mood": 5, + "weight": 82.3 + }, + { + "mood": 4, + "weight": 82.2 + }, + { + "mood": 1, + "weight": 82.2 + }, + { + "mood": 3, + "weight": 82.2 + }, + { + "mood": 5, + "weight": 82.1 + }, + { + "mood": 2, + "weight": 82.1 + }, + { + "mood": 4, + "weight": 82 + }, + { + "mood": 1, + "weight": 82 + } + ], + "moodPulseCorrelation": null, + "moodPulseScatterData": [ + { + "mood": 3, + "pulse": 61 + }, + { + "mood": 5, + "pulse": 60 + }, + { + "mood": 2, + "pulse": 59 + }, + { + "mood": 4, + "pulse": 58 + }, + { + "mood": 1, + "pulse": 61 + }, + { + "mood": 3, + "pulse": 60 + }, + { + "mood": 5, + "pulse": 59 + }, + { + "mood": 2, + "pulse": 58 + }, + { + "mood": 4, + "pulse": 61 + }, + { + "mood": 3, + "pulse": 59 + }, + { + "mood": 5, + "pulse": 58 + }, + { + "mood": 2, + "pulse": 61 + }, + { + "mood": 4, + "pulse": 60 + }, + { + "mood": 1, + "pulse": 59 + }, + { + "mood": 3, + "pulse": 58 + }, + { + "mood": 5, + "pulse": 61 + }, + { + "mood": 2, + "pulse": 60 + }, + { + "mood": 4, + "pulse": 59 + }, + { + "mood": 1, + "pulse": 58 + }, + { + "mood": 3, + "pulse": 61 + }, + { + "mood": 5, + "pulse": 60 + }, + { + "mood": 2, + "pulse": 59 + }, + { + "mood": 4, + "pulse": 58 + }, + { + "mood": 1, + "pulse": 61 + }, + { + "mood": 3, + "pulse": 60 + }, + { + "mood": 5, + "pulse": 59 + }, + { + "mood": 2, + "pulse": 58 + }, + { + "mood": 4, + "pulse": 61 + }, + { + "mood": 1, + "pulse": 60 + }, + { + "mood": 3, + "pulse": 59 + }, + { + "mood": 5, + "pulse": 58 + }, + { + "mood": 4, + "pulse": 60 + }, + { + "mood": 1, + "pulse": 59 + }, + { + "mood": 3, + "pulse": 58 + }, + { + "mood": 5, + "pulse": 61 + }, + { + "mood": 2, + "pulse": 60 + }, + { + "mood": 4, + "pulse": 59 + }, + { + "mood": 1, + "pulse": 58 + } + ], + "medications": [ + { + "id": "", + "name": "Ramipril", + "dose": "5mg", + "category": "BLOOD_PRESSURE", + "categoryLabel": null, + "compliance7": 100, + "compliance30": 76, + "streak": 0, + "taken7": 6, + "skipped7": 0, + "missed7": 0 + }, + { + "id": "", + "name": "Vitamin D", + "dose": "1000 IU", + "category": "OTHER", + "categoryLabel": null, + "compliance7": 43, + "compliance30": 47, + "streak": 1, + "taken7": 3, + "skipped7": 0, + "missed7": 4 + } + ], + "alerts": [ + { + "level": "info", + "title": "BMI in overweight range", + "message": "Your BMI of 25.9 is in the overweight range. Regular exercise and a balanced diet can help." + }, + { + "level": "warning", + "title": "Blood pressure often outside target range", + "message": "Only 11% of your readings are within the target range." + }, + { + "level": "success", + "title": "Excellent compliance: Ramipril", + "message": "100% compliance over the last 7 days. Keep it up!" + }, + { + "level": "warning", + "title": "Low compliance: Vitamin D", + "message": "Your 7-day compliance for Vitamin D is only 43%. Regular intake is important." + } + ], + "hasProvider": true, + "dataSpanDays": 90, + "totalMeasurements": 541, + "revalidating": false, + "ai": { + "briefing": { + "available": true, + "reason": null, + "onDeviceAllowed": true + }, + "statusText": { + "available": true, + "reason": null, + "onDeviceAllowed": true + } + } + }, + "error": null + }, + "events": [ + { + "status": 200, + "action": { + "name": "insights.comprehensive" + }, + "meta": { + "cache.analytics.key_hash": 3572442229, + "cache.analytics.outcome": "miss", + "insights": { + "slope_source": "rollup", + "anomaly_source": "live", + "regression_source": "rollup" + }, + "insights.sub_bpRaw_ms": "", + "insights.sub_buckets_ms": "", + "insights.sub_coverage_ms": "", + "insights.sub_firstAt_ms": "", + "insights.sub_latests_ms": "", + "insights.sub_narrow_ms": "", + "medications": 2, + "moodEntries": 45, + "totalMeasurements": 541 + }, + "warnings": null, + "error": null + } + ], + "effects": [ + { + "effect": "ensureUserRollupsFresh", + "args": [ + "" + ] + }, + { + "effect": "ensureUserMoodRollupsFresh", + "args": [ + "" + ] + } + ] +} diff --git a/tests/integration/routes/parity/__snapshots__/insights-comprehensive.03-warm.json.snap b/tests/integration/routes/parity/__snapshots__/insights-comprehensive.03-warm.json.snap new file mode 100644 index 000000000..cdd109367 --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/insights-comprehensive.03-warm.json.snap @@ -0,0 +1,1274 @@ +{ + "status": 200, + "body": { + "data": { + "summaries": { + "WEIGHT": { + "count": 58, + "latest": 82, + "min": 82, + "max": 84.4, + "mean": 83.19, + "median": null, + "avg7": 82.11, + "avg30": 82.59, + "slope7": { + "slope": -0.039, + "direction": "down", + "confidence": 0.89 + }, + "slope30": { + "slope": -0.04, + "direction": "down", + "confidence": 0.99 + }, + "anomalyCount": 0, + "avg30LastMonth": 83.8, + "avg30LastYear": null + }, + "BLOOD_PRESSURE_SYS": { + "count": 87, + "latest": 123, + "min": 123, + "max": 137, + "mean": 130.2, + "median": null, + "avg7": 131.14, + "avg30": 130.1, + "slope7": { + "slope": -1.107, + "direction": "down", + "confidence": 0.22 + }, + "slope30": { + "slope": 0.043, + "direction": "up", + "confidence": 0.01 + }, + "anomalyCount": 0, + "avg30LastMonth": 130.24, + "avg30LastYear": null + }, + "BLOOD_PRESSURE_DIA": { + "count": 87, + "latest": 78, + "min": 78, + "max": 86, + "mean": 81.94, + "median": null, + "avg7": 81.43, + "avg30": 81.66, + "slope7": { + "slope": -0.5, + "direction": "down", + "confidence": 0.17 + }, + "slope30": { + "slope": -0.019, + "direction": "down", + "confidence": 0 + }, + "anomalyCount": 0, + "avg30LastMonth": 82.03, + "avg30LastYear": null + }, + "PULSE": { + "count": 58, + "latest": 64, + "min": 64, + "max": 69, + "mean": 66.57, + "median": null, + "avg7": 66.14, + "avg30": 66.55, + "slope7": { + "slope": -0.357, + "direction": "down", + "confidence": 0.16 + }, + "slope30": { + "slope": -0.035, + "direction": "down", + "confidence": 0.03 + }, + "anomalyCount": 0, + "avg30LastMonth": 66.59, + "avg30LastYear": null + }, + "BODY_FAT": { + "count": 8, + "latest": 24, + "min": 24, + "max": 25.1, + "mean": 24.61, + "median": null, + "avg7": 24, + "avg30": 24.33, + "slope7": null, + "slope30": { + "slope": -0.021, + "direction": "down", + "confidence": 0.99 + }, + "anomalyCount": 0, + "avg30LastMonth": 24.9, + "avg30LastYear": null + }, + "SLEEP_DURATION": { + "count": 81, + "latest": 12, + "min": 12, + "max": 240, + "mean": 98.64, + "median": null, + "avg7": 96.17, + "avg30": 98.64, + "slope7": { + "slope": -2.121, + "direction": "down", + "confidence": 0 + }, + "slope30": { + "slope": -0.379, + "direction": "down", + "confidence": 0 + }, + "anomalyCount": 0, + "avg30LastMonth": null, + "avg30LastYear": null + }, + "ACTIVITY_STEPS": { + "count": 29, + "latest": 6000, + "min": 6000, + "max": 9833, + "mean": 7780.83, + "median": null, + "avg7": 7119, + "avg30": 7780.83, + "slope7": { + "slope": -373, + "direction": "down", + "confidence": 1 + }, + "slope30": { + "slope": -34.049, + "direction": "down", + "confidence": 0.07 + }, + "anomalyCount": 0, + "avg30LastMonth": null, + "avg30LastYear": null + }, + "BLOOD_GLUCOSE": { + "count": 57, + "latest": 128, + "min": 92, + "max": 147, + "mean": 113.12, + "median": null, + "avg7": 112.76, + "avg30": 113.12, + "slope7": { + "slope": -1.064, + "direction": "down", + "confidence": 0.01 + }, + "slope30": { + "slope": -0.113, + "direction": "down", + "confidence": 0 + }, + "anomalyCount": 0, + "avg30LastMonth": null, + "avg30LastYear": null + }, + "OXYGEN_SATURATION": { + "count": 9, + "latest": 96, + "min": 96, + "max": 98, + "mean": 96.89, + "median": null, + "avg7": 96.86, + "avg30": 96.89, + "slope7": { + "slope": -0.036, + "direction": "down", + "confidence": 0.01 + }, + "slope30": { + "slope": -0.016, + "direction": "down", + "confidence": 0 + }, + "anomalyCount": 0, + "avg30LastMonth": null, + "avg30LastYear": null + }, + "HEART_RATE_VARIABILITY": { + "count": 29, + "latest": 45, + "min": 45, + "max": 52, + "mean": 48.17, + "median": null, + "avg7": 48, + "avg30": 48.17, + "slope7": { + "slope": -1, + "direction": "down", + "confidence": 1 + }, + "slope30": { + "slope": -0.046, + "direction": "down", + "confidence": 0.03 + }, + "anomalyCount": 0, + "avg30LastMonth": null, + "avg30LastYear": null + }, + "RESTING_HEART_RATE": { + "count": 38, + "latest": 58, + "min": 58, + "max": 61, + "mean": 59.45, + "median": null, + "avg7": 59.29, + "avg30": 59.38, + "slope7": { + "slope": -0.143, + "direction": "down", + "confidence": 0.08 + }, + "slope30": { + "slope": -0.009, + "direction": "stable", + "confidence": 0.01 + }, + "anomalyCount": 0, + "avg30LastMonth": 59.67, + "avg30LastYear": null + } + }, + "bmi": 25.9, + "bmiClassification": { + "category": "Overweight", + "color": "var(--warning)", + "severity": "warning" + }, + "bpClassification": { + "category": "High-normal", + "color": "var(--dracula-yellow)", + "severity": "elevated" + }, + "bpPctInTarget": 11, + "bpTargets": { + "sysLow": 120, + "sysHigh": 129, + "diaLow": 70, + "diaHigh": 79 + }, + "weightBpCorrelation": null, + "scatterData": [ + { + "weight": 84.4, + "sysBP": 131 + }, + { + "weight": 84.3, + "sysBP": 124 + }, + { + "weight": 84.3, + "sysBP": 132 + }, + { + "weight": 84.2, + "sysBP": 125 + }, + { + "weight": 84.2, + "sysBP": 133 + }, + { + "weight": 84.2, + "sysBP": 126 + }, + { + "weight": 84.1, + "sysBP": 134 + }, + { + "weight": 84.1, + "sysBP": 127 + }, + { + "weight": 84, + "sysBP": 135 + }, + { + "weight": 84, + "sysBP": 128 + }, + { + "weight": 84, + "sysBP": 136 + }, + { + "weight": 83.9, + "sysBP": 129 + }, + { + "weight": 83.9, + "sysBP": 137 + }, + { + "weight": 83.8, + "sysBP": 130 + }, + { + "weight": 83.8, + "sysBP": 123 + }, + { + "weight": 83.8, + "sysBP": 131 + }, + { + "weight": 83.7, + "sysBP": 124 + }, + { + "weight": 83.7, + "sysBP": 132 + }, + { + "weight": 83.6, + "sysBP": 125 + }, + { + "weight": 83.6, + "sysBP": 133 + }, + { + "weight": 83.6, + "sysBP": 126 + }, + { + "weight": 83.5, + "sysBP": 134 + }, + { + "weight": 83.5, + "sysBP": 127 + }, + { + "weight": 83.4, + "sysBP": 135 + }, + { + "weight": 83.4, + "sysBP": 128 + }, + { + "weight": 83.4, + "sysBP": 136 + }, + { + "weight": 83.3, + "sysBP": 129 + }, + { + "weight": 83.3, + "sysBP": 137 + }, + { + "weight": 83.2, + "sysBP": 130 + }, + { + "weight": 83.2, + "sysBP": 131 + }, + { + "weight": 83.1, + "sysBP": 124 + }, + { + "weight": 83.1, + "sysBP": 132 + }, + { + "weight": 83, + "sysBP": 125 + }, + { + "weight": 83, + "sysBP": 133 + }, + { + "weight": 83, + "sysBP": 126 + }, + { + "weight": 82.9, + "sysBP": 134 + }, + { + "weight": 82.9, + "sysBP": 127 + }, + { + "weight": 82.8, + "sysBP": 135 + }, + { + "weight": 82.8, + "sysBP": 128 + }, + { + "weight": 82.8, + "sysBP": 136 + }, + { + "weight": 82.7, + "sysBP": 129 + }, + { + "weight": 82.7, + "sysBP": 137 + }, + { + "weight": 82.6, + "sysBP": 130 + }, + { + "weight": 82.6, + "sysBP": 123 + }, + { + "weight": 82.6, + "sysBP": 131 + }, + { + "weight": 82.5, + "sysBP": 124 + }, + { + "weight": 82.5, + "sysBP": 132 + }, + { + "weight": 82.4, + "sysBP": 125 + }, + { + "weight": 82.4, + "sysBP": 133 + }, + { + "weight": 82.4, + "sysBP": 126 + }, + { + "weight": 82.3, + "sysBP": 134 + }, + { + "weight": 82.2, + "sysBP": 135 + }, + { + "weight": 82.2, + "sysBP": 128 + }, + { + "weight": 82.2, + "sysBP": 136 + }, + { + "weight": 82.1, + "sysBP": 129 + }, + { + "weight": 82.1, + "sysBP": 137 + }, + { + "weight": 82, + "sysBP": 130 + }, + { + "weight": 82, + "sysBP": 123 + } + ], + "bpMedicationCorrelation": null, + "bpMedicationScatterData": [ + { + "continuityPct": 100, + "sysBP": 131 + }, + { + "continuityPct": 100, + "sysBP": 124 + }, + { + "continuityPct": 0, + "sysBP": 132 + }, + { + "continuityPct": 100, + "sysBP": 125 + }, + { + "continuityPct": 100, + "sysBP": 133 + }, + { + "continuityPct": 100, + "sysBP": 126 + }, + { + "continuityPct": 100, + "sysBP": 134 + }, + { + "continuityPct": 100, + "sysBP": 127 + }, + { + "continuityPct": 0, + "sysBP": 128 + }, + { + "continuityPct": 100, + "sysBP": 136 + }, + { + "continuityPct": 100, + "sysBP": 129 + }, + { + "continuityPct": 100, + "sysBP": 137 + }, + { + "continuityPct": 100, + "sysBP": 130 + }, + { + "continuityPct": 100, + "sysBP": 131 + }, + { + "continuityPct": 0, + "sysBP": 124 + }, + { + "continuityPct": 100, + "sysBP": 125 + }, + { + "continuityPct": 100, + "sysBP": 133 + }, + { + "continuityPct": 100, + "sysBP": 126 + }, + { + "continuityPct": 100, + "sysBP": 134 + }, + { + "continuityPct": 100, + "sysBP": 127 + }, + { + "continuityPct": 0, + "sysBP": 135 + }, + { + "continuityPct": 100, + "sysBP": 128 + }, + { + "continuityPct": 100, + "sysBP": 136 + }, + { + "continuityPct": 100, + "sysBP": 137 + }, + { + "continuityPct": 100, + "sysBP": 130 + }, + { + "continuityPct": 100, + "sysBP": 123 + }, + { + "continuityPct": 0, + "sysBP": 131 + }, + { + "continuityPct": 100, + "sysBP": 124 + }, + { + "continuityPct": 100, + "sysBP": 132 + }, + { + "continuityPct": 100, + "sysBP": 125 + }, + { + "continuityPct": 100, + "sysBP": 133 + }, + { + "continuityPct": 100, + "sysBP": 134 + }, + { + "continuityPct": 100, + "sysBP": 135 + }, + { + "continuityPct": 100, + "sysBP": 128 + }, + { + "continuityPct": 100, + "sysBP": 136 + }, + { + "continuityPct": 100, + "sysBP": 129 + }, + { + "continuityPct": 100, + "sysBP": 137 + }, + { + "continuityPct": 100, + "sysBP": 130 + } + ], + "moodSummary": { + "count": 45, + "latest": 1, + "min": 1, + "max": 5, + "mean": 3, + "median": 3, + "avg7": 2.86, + "avg30": 3, + "slope7": { + "slope": -0.143, + "direction": "down", + "confidence": 0.04 + }, + "slope30": { + "slope": -0.013, + "direction": "down", + "confidence": 0.01 + }, + "anomalyCount": 0, + "avg30LastMonth": 3, + "avg30LastYear": null + }, + "moodBpCorrelation": null, + "moodBpScatterData": [ + { + "mood": 3, + "sysBP": 131 + }, + { + "mood": 5, + "sysBP": 124 + }, + { + "mood": 2, + "sysBP": 132 + }, + { + "mood": 4, + "sysBP": 125 + }, + { + "mood": 1, + "sysBP": 133 + }, + { + "mood": 3, + "sysBP": 126 + }, + { + "mood": 5, + "sysBP": 134 + }, + { + "mood": 2, + "sysBP": 127 + }, + { + "mood": 4, + "sysBP": 135 + }, + { + "mood": 1, + "sysBP": 128 + }, + { + "mood": 3, + "sysBP": 136 + }, + { + "mood": 5, + "sysBP": 129 + }, + { + "mood": 2, + "sysBP": 137 + }, + { + "mood": 4, + "sysBP": 130 + }, + { + "mood": 3, + "sysBP": 131 + }, + { + "mood": 5, + "sysBP": 124 + }, + { + "mood": 2, + "sysBP": 132 + }, + { + "mood": 4, + "sysBP": 125 + }, + { + "mood": 1, + "sysBP": 133 + }, + { + "mood": 3, + "sysBP": 126 + }, + { + "mood": 5, + "sysBP": 134 + }, + { + "mood": 2, + "sysBP": 127 + }, + { + "mood": 4, + "sysBP": 135 + }, + { + "mood": 1, + "sysBP": 128 + }, + { + "mood": 3, + "sysBP": 136 + }, + { + "mood": 5, + "sysBP": 129 + }, + { + "mood": 2, + "sysBP": 137 + }, + { + "mood": 4, + "sysBP": 130 + }, + { + "mood": 1, + "sysBP": 123 + }, + { + "mood": 3, + "sysBP": 131 + }, + { + "mood": 5, + "sysBP": 124 + }, + { + "mood": 2, + "sysBP": 132 + }, + { + "mood": 4, + "sysBP": 125 + }, + { + "mood": 1, + "sysBP": 133 + }, + { + "mood": 3, + "sysBP": 126 + }, + { + "mood": 5, + "sysBP": 134 + }, + { + "mood": 4, + "sysBP": 135 + }, + { + "mood": 1, + "sysBP": 128 + }, + { + "mood": 3, + "sysBP": 136 + }, + { + "mood": 5, + "sysBP": 129 + }, + { + "mood": 2, + "sysBP": 137 + }, + { + "mood": 4, + "sysBP": 130 + }, + { + "mood": 1, + "sysBP": 123 + } + ], + "moodWeightCorrelation": null, + "moodWeightScatterData": [ + { + "mood": 3, + "weight": 83.8 + }, + { + "mood": 5, + "weight": 83.7 + }, + { + "mood": 2, + "weight": 83.7 + }, + { + "mood": 4, + "weight": 83.6 + }, + { + "mood": 1, + "weight": 83.6 + }, + { + "mood": 3, + "weight": 83.6 + }, + { + "mood": 5, + "weight": 83.5 + }, + { + "mood": 2, + "weight": 83.5 + }, + { + "mood": 4, + "weight": 83.4 + }, + { + "mood": 1, + "weight": 83.4 + }, + { + "mood": 3, + "weight": 83.4 + }, + { + "mood": 5, + "weight": 83.3 + }, + { + "mood": 2, + "weight": 83.3 + }, + { + "mood": 4, + "weight": 83.2 + }, + { + "mood": 3, + "weight": 83.2 + }, + { + "mood": 5, + "weight": 83.1 + }, + { + "mood": 2, + "weight": 83.1 + }, + { + "mood": 4, + "weight": 83 + }, + { + "mood": 1, + "weight": 83 + }, + { + "mood": 3, + "weight": 83 + }, + { + "mood": 5, + "weight": 82.9 + }, + { + "mood": 2, + "weight": 82.9 + }, + { + "mood": 4, + "weight": 82.8 + }, + { + "mood": 1, + "weight": 82.8 + }, + { + "mood": 3, + "weight": 82.8 + }, + { + "mood": 5, + "weight": 82.7 + }, + { + "mood": 2, + "weight": 82.7 + }, + { + "mood": 4, + "weight": 82.6 + }, + { + "mood": 1, + "weight": 82.6 + }, + { + "mood": 3, + "weight": 82.6 + }, + { + "mood": 5, + "weight": 82.5 + }, + { + "mood": 2, + "weight": 82.5 + }, + { + "mood": 4, + "weight": 82.4 + }, + { + "mood": 1, + "weight": 82.4 + }, + { + "mood": 3, + "weight": 82.4 + }, + { + "mood": 5, + "weight": 82.3 + }, + { + "mood": 4, + "weight": 82.2 + }, + { + "mood": 1, + "weight": 82.2 + }, + { + "mood": 3, + "weight": 82.2 + }, + { + "mood": 5, + "weight": 82.1 + }, + { + "mood": 2, + "weight": 82.1 + }, + { + "mood": 4, + "weight": 82 + }, + { + "mood": 1, + "weight": 82 + } + ], + "moodPulseCorrelation": null, + "moodPulseScatterData": [ + { + "mood": 3, + "pulse": 61 + }, + { + "mood": 5, + "pulse": 60 + }, + { + "mood": 2, + "pulse": 59 + }, + { + "mood": 4, + "pulse": 58 + }, + { + "mood": 1, + "pulse": 61 + }, + { + "mood": 3, + "pulse": 60 + }, + { + "mood": 5, + "pulse": 59 + }, + { + "mood": 2, + "pulse": 58 + }, + { + "mood": 4, + "pulse": 61 + }, + { + "mood": 3, + "pulse": 59 + }, + { + "mood": 5, + "pulse": 58 + }, + { + "mood": 2, + "pulse": 61 + }, + { + "mood": 4, + "pulse": 60 + }, + { + "mood": 1, + "pulse": 59 + }, + { + "mood": 3, + "pulse": 58 + }, + { + "mood": 5, + "pulse": 61 + }, + { + "mood": 2, + "pulse": 60 + }, + { + "mood": 4, + "pulse": 59 + }, + { + "mood": 1, + "pulse": 58 + }, + { + "mood": 3, + "pulse": 61 + }, + { + "mood": 5, + "pulse": 60 + }, + { + "mood": 2, + "pulse": 59 + }, + { + "mood": 4, + "pulse": 58 + }, + { + "mood": 1, + "pulse": 61 + }, + { + "mood": 3, + "pulse": 60 + }, + { + "mood": 5, + "pulse": 59 + }, + { + "mood": 2, + "pulse": 58 + }, + { + "mood": 4, + "pulse": 61 + }, + { + "mood": 1, + "pulse": 60 + }, + { + "mood": 3, + "pulse": 59 + }, + { + "mood": 5, + "pulse": 58 + }, + { + "mood": 4, + "pulse": 60 + }, + { + "mood": 1, + "pulse": 59 + }, + { + "mood": 3, + "pulse": 58 + }, + { + "mood": 5, + "pulse": 61 + }, + { + "mood": 2, + "pulse": 60 + }, + { + "mood": 4, + "pulse": 59 + }, + { + "mood": 1, + "pulse": 58 + } + ], + "medications": [ + { + "id": "", + "name": "Ramipril", + "dose": "5mg", + "category": "BLOOD_PRESSURE", + "categoryLabel": null, + "compliance7": 100, + "compliance30": 76, + "streak": 0, + "taken7": 6, + "skipped7": 0, + "missed7": 0 + }, + { + "id": "", + "name": "Vitamin D", + "dose": "1000 IU", + "category": "OTHER", + "categoryLabel": null, + "compliance7": 43, + "compliance30": 47, + "streak": 1, + "taken7": 3, + "skipped7": 0, + "missed7": 4 + } + ], + "alerts": [ + { + "level": "info", + "title": "BMI in overweight range", + "message": "Your BMI of 25.9 is in the overweight range. Regular exercise and a balanced diet can help." + }, + { + "level": "warning", + "title": "Blood pressure often outside target range", + "message": "Only 11% of your readings are within the target range." + }, + { + "level": "success", + "title": "Excellent compliance: Ramipril", + "message": "100% compliance over the last 7 days. Keep it up!" + }, + { + "level": "warning", + "title": "Low compliance: Vitamin D", + "message": "Your 7-day compliance for Vitamin D is only 43%. Regular intake is important." + } + ], + "hasProvider": true, + "dataSpanDays": 90, + "totalMeasurements": 541, + "revalidating": false, + "ai": { + "briefing": { + "available": true, + "reason": null, + "onDeviceAllowed": true + }, + "statusText": { + "available": true, + "reason": null, + "onDeviceAllowed": true + } + } + }, + "error": null + }, + "events": [ + { + "status": 200, + "action": null, + "meta": { + "cache.analytics.key_hash": 3572442229, + "cache.analytics.outcome": "hit" + }, + "warnings": null, + "error": null + } + ], + "effects": [] +} diff --git a/tests/integration/routes/parity/__snapshots__/insights-comprehensive.04-stale.json.snap b/tests/integration/routes/parity/__snapshots__/insights-comprehensive.04-stale.json.snap new file mode 100644 index 000000000..f2a85fa5b --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/insights-comprehensive.04-stale.json.snap @@ -0,0 +1,1287 @@ +{ + "status": 200, + "body": { + "data": { + "summaries": { + "WEIGHT": { + "count": 58, + "latest": 82, + "min": 82, + "max": 84.4, + "mean": 83.19, + "median": null, + "avg7": 82.11, + "avg30": 82.59, + "slope7": { + "slope": -0.039, + "direction": "down", + "confidence": 0.89 + }, + "slope30": { + "slope": -0.04, + "direction": "down", + "confidence": 0.99 + }, + "anomalyCount": 0, + "avg30LastMonth": 83.8, + "avg30LastYear": null + }, + "BLOOD_PRESSURE_SYS": { + "count": 87, + "latest": 123, + "min": 123, + "max": 137, + "mean": 130.2, + "median": null, + "avg7": 131.14, + "avg30": 130.1, + "slope7": { + "slope": -1.107, + "direction": "down", + "confidence": 0.22 + }, + "slope30": { + "slope": 0.043, + "direction": "up", + "confidence": 0.01 + }, + "anomalyCount": 0, + "avg30LastMonth": 130.24, + "avg30LastYear": null + }, + "BLOOD_PRESSURE_DIA": { + "count": 87, + "latest": 78, + "min": 78, + "max": 86, + "mean": 81.94, + "median": null, + "avg7": 81.43, + "avg30": 81.66, + "slope7": { + "slope": -0.5, + "direction": "down", + "confidence": 0.17 + }, + "slope30": { + "slope": -0.019, + "direction": "down", + "confidence": 0 + }, + "anomalyCount": 0, + "avg30LastMonth": 82.03, + "avg30LastYear": null + }, + "PULSE": { + "count": 58, + "latest": 64, + "min": 64, + "max": 69, + "mean": 66.57, + "median": null, + "avg7": 66.14, + "avg30": 66.55, + "slope7": { + "slope": -0.357, + "direction": "down", + "confidence": 0.16 + }, + "slope30": { + "slope": -0.035, + "direction": "down", + "confidence": 0.03 + }, + "anomalyCount": 0, + "avg30LastMonth": 66.59, + "avg30LastYear": null + }, + "BODY_FAT": { + "count": 8, + "latest": 24, + "min": 24, + "max": 25.1, + "mean": 24.61, + "median": null, + "avg7": 24, + "avg30": 24.33, + "slope7": null, + "slope30": { + "slope": -0.021, + "direction": "down", + "confidence": 0.99 + }, + "anomalyCount": 0, + "avg30LastMonth": 24.9, + "avg30LastYear": null + }, + "SLEEP_DURATION": { + "count": 81, + "latest": 12, + "min": 12, + "max": 240, + "mean": 98.64, + "median": null, + "avg7": 96.17, + "avg30": 98.64, + "slope7": { + "slope": -2.121, + "direction": "down", + "confidence": 0 + }, + "slope30": { + "slope": -0.379, + "direction": "down", + "confidence": 0 + }, + "anomalyCount": 0, + "avg30LastMonth": null, + "avg30LastYear": null + }, + "ACTIVITY_STEPS": { + "count": 29, + "latest": 6000, + "min": 6000, + "max": 9833, + "mean": 7780.83, + "median": null, + "avg7": 7119, + "avg30": 7780.83, + "slope7": { + "slope": -373, + "direction": "down", + "confidence": 1 + }, + "slope30": { + "slope": -34.049, + "direction": "down", + "confidence": 0.07 + }, + "anomalyCount": 0, + "avg30LastMonth": null, + "avg30LastYear": null + }, + "BLOOD_GLUCOSE": { + "count": 57, + "latest": 128, + "min": 92, + "max": 147, + "mean": 113.12, + "median": null, + "avg7": 112.76, + "avg30": 113.12, + "slope7": { + "slope": -1.064, + "direction": "down", + "confidence": 0.01 + }, + "slope30": { + "slope": -0.113, + "direction": "down", + "confidence": 0 + }, + "anomalyCount": 0, + "avg30LastMonth": null, + "avg30LastYear": null + }, + "OXYGEN_SATURATION": { + "count": 9, + "latest": 96, + "min": 96, + "max": 98, + "mean": 96.89, + "median": null, + "avg7": 96.86, + "avg30": 96.89, + "slope7": { + "slope": -0.036, + "direction": "down", + "confidence": 0.01 + }, + "slope30": { + "slope": -0.016, + "direction": "down", + "confidence": 0 + }, + "anomalyCount": 0, + "avg30LastMonth": null, + "avg30LastYear": null + }, + "HEART_RATE_VARIABILITY": { + "count": 29, + "latest": 45, + "min": 45, + "max": 52, + "mean": 48.17, + "median": null, + "avg7": 48, + "avg30": 48.17, + "slope7": { + "slope": -1, + "direction": "down", + "confidence": 1 + }, + "slope30": { + "slope": -0.046, + "direction": "down", + "confidence": 0.03 + }, + "anomalyCount": 0, + "avg30LastMonth": null, + "avg30LastYear": null + }, + "RESTING_HEART_RATE": { + "count": 38, + "latest": 58, + "min": 58, + "max": 61, + "mean": 59.45, + "median": null, + "avg7": 59.29, + "avg30": 59.38, + "slope7": { + "slope": -0.143, + "direction": "down", + "confidence": 0.08 + }, + "slope30": { + "slope": -0.009, + "direction": "stable", + "confidence": 0.01 + }, + "anomalyCount": 0, + "avg30LastMonth": 59.67, + "avg30LastYear": null + } + }, + "bmi": 25.9, + "bmiClassification": { + "category": "Overweight", + "color": "var(--warning)", + "severity": "warning" + }, + "bpClassification": { + "category": "High-normal", + "color": "var(--dracula-yellow)", + "severity": "elevated" + }, + "bpPctInTarget": 11, + "bpTargets": { + "sysLow": 120, + "sysHigh": 129, + "diaLow": 70, + "diaHigh": 79 + }, + "weightBpCorrelation": null, + "scatterData": [ + { + "weight": 84.4, + "sysBP": 131 + }, + { + "weight": 84.3, + "sysBP": 124 + }, + { + "weight": 84.3, + "sysBP": 132 + }, + { + "weight": 84.2, + "sysBP": 125 + }, + { + "weight": 84.2, + "sysBP": 133 + }, + { + "weight": 84.2, + "sysBP": 126 + }, + { + "weight": 84.1, + "sysBP": 134 + }, + { + "weight": 84.1, + "sysBP": 127 + }, + { + "weight": 84, + "sysBP": 135 + }, + { + "weight": 84, + "sysBP": 128 + }, + { + "weight": 84, + "sysBP": 136 + }, + { + "weight": 83.9, + "sysBP": 129 + }, + { + "weight": 83.9, + "sysBP": 137 + }, + { + "weight": 83.8, + "sysBP": 130 + }, + { + "weight": 83.8, + "sysBP": 123 + }, + { + "weight": 83.8, + "sysBP": 131 + }, + { + "weight": 83.7, + "sysBP": 124 + }, + { + "weight": 83.7, + "sysBP": 132 + }, + { + "weight": 83.6, + "sysBP": 125 + }, + { + "weight": 83.6, + "sysBP": 133 + }, + { + "weight": 83.6, + "sysBP": 126 + }, + { + "weight": 83.5, + "sysBP": 134 + }, + { + "weight": 83.5, + "sysBP": 127 + }, + { + "weight": 83.4, + "sysBP": 135 + }, + { + "weight": 83.4, + "sysBP": 128 + }, + { + "weight": 83.4, + "sysBP": 136 + }, + { + "weight": 83.3, + "sysBP": 129 + }, + { + "weight": 83.3, + "sysBP": 137 + }, + { + "weight": 83.2, + "sysBP": 130 + }, + { + "weight": 83.2, + "sysBP": 131 + }, + { + "weight": 83.1, + "sysBP": 124 + }, + { + "weight": 83.1, + "sysBP": 132 + }, + { + "weight": 83, + "sysBP": 125 + }, + { + "weight": 83, + "sysBP": 133 + }, + { + "weight": 83, + "sysBP": 126 + }, + { + "weight": 82.9, + "sysBP": 134 + }, + { + "weight": 82.9, + "sysBP": 127 + }, + { + "weight": 82.8, + "sysBP": 135 + }, + { + "weight": 82.8, + "sysBP": 128 + }, + { + "weight": 82.8, + "sysBP": 136 + }, + { + "weight": 82.7, + "sysBP": 129 + }, + { + "weight": 82.7, + "sysBP": 137 + }, + { + "weight": 82.6, + "sysBP": 130 + }, + { + "weight": 82.6, + "sysBP": 123 + }, + { + "weight": 82.6, + "sysBP": 131 + }, + { + "weight": 82.5, + "sysBP": 124 + }, + { + "weight": 82.5, + "sysBP": 132 + }, + { + "weight": 82.4, + "sysBP": 125 + }, + { + "weight": 82.4, + "sysBP": 133 + }, + { + "weight": 82.4, + "sysBP": 126 + }, + { + "weight": 82.3, + "sysBP": 134 + }, + { + "weight": 82.2, + "sysBP": 135 + }, + { + "weight": 82.2, + "sysBP": 128 + }, + { + "weight": 82.2, + "sysBP": 136 + }, + { + "weight": 82.1, + "sysBP": 129 + }, + { + "weight": 82.1, + "sysBP": 137 + }, + { + "weight": 82, + "sysBP": 130 + }, + { + "weight": 82, + "sysBP": 123 + } + ], + "bpMedicationCorrelation": null, + "bpMedicationScatterData": [ + { + "continuityPct": 100, + "sysBP": 131 + }, + { + "continuityPct": 100, + "sysBP": 124 + }, + { + "continuityPct": 0, + "sysBP": 132 + }, + { + "continuityPct": 100, + "sysBP": 125 + }, + { + "continuityPct": 100, + "sysBP": 133 + }, + { + "continuityPct": 100, + "sysBP": 126 + }, + { + "continuityPct": 100, + "sysBP": 134 + }, + { + "continuityPct": 100, + "sysBP": 127 + }, + { + "continuityPct": 0, + "sysBP": 128 + }, + { + "continuityPct": 100, + "sysBP": 136 + }, + { + "continuityPct": 100, + "sysBP": 129 + }, + { + "continuityPct": 100, + "sysBP": 137 + }, + { + "continuityPct": 100, + "sysBP": 130 + }, + { + "continuityPct": 100, + "sysBP": 131 + }, + { + "continuityPct": 0, + "sysBP": 124 + }, + { + "continuityPct": 100, + "sysBP": 125 + }, + { + "continuityPct": 100, + "sysBP": 133 + }, + { + "continuityPct": 100, + "sysBP": 126 + }, + { + "continuityPct": 100, + "sysBP": 134 + }, + { + "continuityPct": 100, + "sysBP": 127 + }, + { + "continuityPct": 0, + "sysBP": 135 + }, + { + "continuityPct": 100, + "sysBP": 128 + }, + { + "continuityPct": 100, + "sysBP": 136 + }, + { + "continuityPct": 100, + "sysBP": 137 + }, + { + "continuityPct": 100, + "sysBP": 130 + }, + { + "continuityPct": 100, + "sysBP": 123 + }, + { + "continuityPct": 0, + "sysBP": 131 + }, + { + "continuityPct": 100, + "sysBP": 124 + }, + { + "continuityPct": 100, + "sysBP": 132 + }, + { + "continuityPct": 100, + "sysBP": 125 + }, + { + "continuityPct": 100, + "sysBP": 133 + }, + { + "continuityPct": 100, + "sysBP": 134 + }, + { + "continuityPct": 100, + "sysBP": 135 + }, + { + "continuityPct": 100, + "sysBP": 128 + }, + { + "continuityPct": 100, + "sysBP": 136 + }, + { + "continuityPct": 100, + "sysBP": 129 + }, + { + "continuityPct": 100, + "sysBP": 137 + }, + { + "continuityPct": 100, + "sysBP": 130 + } + ], + "moodSummary": { + "count": 45, + "latest": 1, + "min": 1, + "max": 5, + "mean": 3, + "median": 3, + "avg7": 2.86, + "avg30": 3, + "slope7": { + "slope": -0.143, + "direction": "down", + "confidence": 0.04 + }, + "slope30": { + "slope": -0.013, + "direction": "down", + "confidence": 0.01 + }, + "anomalyCount": 0, + "avg30LastMonth": 3, + "avg30LastYear": null + }, + "moodBpCorrelation": null, + "moodBpScatterData": [ + { + "mood": 3, + "sysBP": 131 + }, + { + "mood": 5, + "sysBP": 124 + }, + { + "mood": 2, + "sysBP": 132 + }, + { + "mood": 4, + "sysBP": 125 + }, + { + "mood": 1, + "sysBP": 133 + }, + { + "mood": 3, + "sysBP": 126 + }, + { + "mood": 5, + "sysBP": 134 + }, + { + "mood": 2, + "sysBP": 127 + }, + { + "mood": 4, + "sysBP": 135 + }, + { + "mood": 1, + "sysBP": 128 + }, + { + "mood": 3, + "sysBP": 136 + }, + { + "mood": 5, + "sysBP": 129 + }, + { + "mood": 2, + "sysBP": 137 + }, + { + "mood": 4, + "sysBP": 130 + }, + { + "mood": 3, + "sysBP": 131 + }, + { + "mood": 5, + "sysBP": 124 + }, + { + "mood": 2, + "sysBP": 132 + }, + { + "mood": 4, + "sysBP": 125 + }, + { + "mood": 1, + "sysBP": 133 + }, + { + "mood": 3, + "sysBP": 126 + }, + { + "mood": 5, + "sysBP": 134 + }, + { + "mood": 2, + "sysBP": 127 + }, + { + "mood": 4, + "sysBP": 135 + }, + { + "mood": 1, + "sysBP": 128 + }, + { + "mood": 3, + "sysBP": 136 + }, + { + "mood": 5, + "sysBP": 129 + }, + { + "mood": 2, + "sysBP": 137 + }, + { + "mood": 4, + "sysBP": 130 + }, + { + "mood": 1, + "sysBP": 123 + }, + { + "mood": 3, + "sysBP": 131 + }, + { + "mood": 5, + "sysBP": 124 + }, + { + "mood": 2, + "sysBP": 132 + }, + { + "mood": 4, + "sysBP": 125 + }, + { + "mood": 1, + "sysBP": 133 + }, + { + "mood": 3, + "sysBP": 126 + }, + { + "mood": 5, + "sysBP": 134 + }, + { + "mood": 4, + "sysBP": 135 + }, + { + "mood": 1, + "sysBP": 128 + }, + { + "mood": 3, + "sysBP": 136 + }, + { + "mood": 5, + "sysBP": 129 + }, + { + "mood": 2, + "sysBP": 137 + }, + { + "mood": 4, + "sysBP": 130 + }, + { + "mood": 1, + "sysBP": 123 + } + ], + "moodWeightCorrelation": null, + "moodWeightScatterData": [ + { + "mood": 3, + "weight": 83.8 + }, + { + "mood": 5, + "weight": 83.7 + }, + { + "mood": 2, + "weight": 83.7 + }, + { + "mood": 4, + "weight": 83.6 + }, + { + "mood": 1, + "weight": 83.6 + }, + { + "mood": 3, + "weight": 83.6 + }, + { + "mood": 5, + "weight": 83.5 + }, + { + "mood": 2, + "weight": 83.5 + }, + { + "mood": 4, + "weight": 83.4 + }, + { + "mood": 1, + "weight": 83.4 + }, + { + "mood": 3, + "weight": 83.4 + }, + { + "mood": 5, + "weight": 83.3 + }, + { + "mood": 2, + "weight": 83.3 + }, + { + "mood": 4, + "weight": 83.2 + }, + { + "mood": 3, + "weight": 83.2 + }, + { + "mood": 5, + "weight": 83.1 + }, + { + "mood": 2, + "weight": 83.1 + }, + { + "mood": 4, + "weight": 83 + }, + { + "mood": 1, + "weight": 83 + }, + { + "mood": 3, + "weight": 83 + }, + { + "mood": 5, + "weight": 82.9 + }, + { + "mood": 2, + "weight": 82.9 + }, + { + "mood": 4, + "weight": 82.8 + }, + { + "mood": 1, + "weight": 82.8 + }, + { + "mood": 3, + "weight": 82.8 + }, + { + "mood": 5, + "weight": 82.7 + }, + { + "mood": 2, + "weight": 82.7 + }, + { + "mood": 4, + "weight": 82.6 + }, + { + "mood": 1, + "weight": 82.6 + }, + { + "mood": 3, + "weight": 82.6 + }, + { + "mood": 5, + "weight": 82.5 + }, + { + "mood": 2, + "weight": 82.5 + }, + { + "mood": 4, + "weight": 82.4 + }, + { + "mood": 1, + "weight": 82.4 + }, + { + "mood": 3, + "weight": 82.4 + }, + { + "mood": 5, + "weight": 82.3 + }, + { + "mood": 4, + "weight": 82.2 + }, + { + "mood": 1, + "weight": 82.2 + }, + { + "mood": 3, + "weight": 82.2 + }, + { + "mood": 5, + "weight": 82.1 + }, + { + "mood": 2, + "weight": 82.1 + }, + { + "mood": 4, + "weight": 82 + }, + { + "mood": 1, + "weight": 82 + } + ], + "moodPulseCorrelation": null, + "moodPulseScatterData": [ + { + "mood": 3, + "pulse": 61 + }, + { + "mood": 5, + "pulse": 60 + }, + { + "mood": 2, + "pulse": 59 + }, + { + "mood": 4, + "pulse": 58 + }, + { + "mood": 1, + "pulse": 61 + }, + { + "mood": 3, + "pulse": 60 + }, + { + "mood": 5, + "pulse": 59 + }, + { + "mood": 2, + "pulse": 58 + }, + { + "mood": 4, + "pulse": 61 + }, + { + "mood": 3, + "pulse": 59 + }, + { + "mood": 5, + "pulse": 58 + }, + { + "mood": 2, + "pulse": 61 + }, + { + "mood": 4, + "pulse": 60 + }, + { + "mood": 1, + "pulse": 59 + }, + { + "mood": 3, + "pulse": 58 + }, + { + "mood": 5, + "pulse": 61 + }, + { + "mood": 2, + "pulse": 60 + }, + { + "mood": 4, + "pulse": 59 + }, + { + "mood": 1, + "pulse": 58 + }, + { + "mood": 3, + "pulse": 61 + }, + { + "mood": 5, + "pulse": 60 + }, + { + "mood": 2, + "pulse": 59 + }, + { + "mood": 4, + "pulse": 58 + }, + { + "mood": 1, + "pulse": 61 + }, + { + "mood": 3, + "pulse": 60 + }, + { + "mood": 5, + "pulse": 59 + }, + { + "mood": 2, + "pulse": 58 + }, + { + "mood": 4, + "pulse": 61 + }, + { + "mood": 1, + "pulse": 60 + }, + { + "mood": 3, + "pulse": 59 + }, + { + "mood": 5, + "pulse": 58 + }, + { + "mood": 4, + "pulse": 60 + }, + { + "mood": 1, + "pulse": 59 + }, + { + "mood": 3, + "pulse": 58 + }, + { + "mood": 5, + "pulse": 61 + }, + { + "mood": 2, + "pulse": 60 + }, + { + "mood": 4, + "pulse": 59 + }, + { + "mood": 1, + "pulse": 58 + } + ], + "medications": [ + { + "id": "", + "name": "Ramipril", + "dose": "5mg", + "category": "BLOOD_PRESSURE", + "categoryLabel": null, + "compliance7": 100, + "compliance30": 76, + "streak": 0, + "taken7": 6, + "skipped7": 0, + "missed7": 0 + }, + { + "id": "", + "name": "Vitamin D", + "dose": "1000 IU", + "category": "OTHER", + "categoryLabel": null, + "compliance7": 43, + "compliance30": 47, + "streak": 1, + "taken7": 3, + "skipped7": 0, + "missed7": 4 + } + ], + "alerts": [ + { + "level": "info", + "title": "BMI in overweight range", + "message": "Your BMI of 25.9 is in the overweight range. Regular exercise and a balanced diet can help." + }, + { + "level": "warning", + "title": "Blood pressure often outside target range", + "message": "Only 11% of your readings are within the target range." + }, + { + "level": "success", + "title": "Excellent compliance: Ramipril", + "message": "100% compliance over the last 7 days. Keep it up!" + }, + { + "level": "warning", + "title": "Low compliance: Vitamin D", + "message": "Your 7-day compliance for Vitamin D is only 43%. Regular intake is important." + } + ], + "hasProvider": true, + "dataSpanDays": 90, + "totalMeasurements": 541, + "revalidating": true, + "ai": { + "briefing": { + "available": true, + "reason": null, + "onDeviceAllowed": true + }, + "statusText": { + "available": true, + "reason": null, + "onDeviceAllowed": true + } + } + }, + "error": null + }, + "events": [ + { + "status": 200, + "action": "", + "meta": { + "cache.analytics.key_hash": 3572442229, + "cache.analytics.outcome": "stale" + }, + "warnings": null, + "error": null + } + ], + "effects": [ + { + "effect": "ensureUserRollupsFresh", + "args": [ + "" + ] + }, + { + "effect": "ensureUserMoodRollupsFresh", + "args": [ + "" + ] + } + ] +} diff --git a/tests/integration/routes/parity/__snapshots__/insights-comprehensive.05-ai-analysis-off.json.snap b/tests/integration/routes/parity/__snapshots__/insights-comprehensive.05-ai-analysis-off.json.snap new file mode 100644 index 000000000..7235cd05c --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/insights-comprehensive.05-ai-analysis-off.json.snap @@ -0,0 +1,1274 @@ +{ + "status": 200, + "body": { + "data": { + "summaries": { + "WEIGHT": { + "count": 58, + "latest": 82, + "min": 82, + "max": 84.4, + "mean": 83.19, + "median": null, + "avg7": 82.11, + "avg30": 82.59, + "slope7": { + "slope": -0.039, + "direction": "down", + "confidence": 0.89 + }, + "slope30": { + "slope": -0.04, + "direction": "down", + "confidence": 0.99 + }, + "anomalyCount": 0, + "avg30LastMonth": 83.8, + "avg30LastYear": null + }, + "BLOOD_PRESSURE_SYS": { + "count": 87, + "latest": 123, + "min": 123, + "max": 137, + "mean": 130.2, + "median": null, + "avg7": 131.14, + "avg30": 130.1, + "slope7": { + "slope": -1.107, + "direction": "down", + "confidence": 0.22 + }, + "slope30": { + "slope": 0.043, + "direction": "up", + "confidence": 0.01 + }, + "anomalyCount": 0, + "avg30LastMonth": 130.24, + "avg30LastYear": null + }, + "BLOOD_PRESSURE_DIA": { + "count": 87, + "latest": 78, + "min": 78, + "max": 86, + "mean": 81.94, + "median": null, + "avg7": 81.43, + "avg30": 81.66, + "slope7": { + "slope": -0.5, + "direction": "down", + "confidence": 0.17 + }, + "slope30": { + "slope": -0.019, + "direction": "down", + "confidence": 0 + }, + "anomalyCount": 0, + "avg30LastMonth": 82.03, + "avg30LastYear": null + }, + "PULSE": { + "count": 58, + "latest": 64, + "min": 64, + "max": 69, + "mean": 66.57, + "median": null, + "avg7": 66.14, + "avg30": 66.55, + "slope7": { + "slope": -0.357, + "direction": "down", + "confidence": 0.16 + }, + "slope30": { + "slope": -0.035, + "direction": "down", + "confidence": 0.03 + }, + "anomalyCount": 0, + "avg30LastMonth": 66.59, + "avg30LastYear": null + }, + "BODY_FAT": { + "count": 8, + "latest": 24, + "min": 24, + "max": 25.1, + "mean": 24.61, + "median": null, + "avg7": 24, + "avg30": 24.33, + "slope7": null, + "slope30": { + "slope": -0.021, + "direction": "down", + "confidence": 0.99 + }, + "anomalyCount": 0, + "avg30LastMonth": 24.9, + "avg30LastYear": null + }, + "SLEEP_DURATION": { + "count": 81, + "latest": 12, + "min": 12, + "max": 240, + "mean": 98.64, + "median": null, + "avg7": 96.17, + "avg30": 98.64, + "slope7": { + "slope": -2.121, + "direction": "down", + "confidence": 0 + }, + "slope30": { + "slope": -0.379, + "direction": "down", + "confidence": 0 + }, + "anomalyCount": 0, + "avg30LastMonth": null, + "avg30LastYear": null + }, + "ACTIVITY_STEPS": { + "count": 29, + "latest": 6000, + "min": 6000, + "max": 9833, + "mean": 7780.83, + "median": null, + "avg7": 7119, + "avg30": 7780.83, + "slope7": { + "slope": -373, + "direction": "down", + "confidence": 1 + }, + "slope30": { + "slope": -34.049, + "direction": "down", + "confidence": 0.07 + }, + "anomalyCount": 0, + "avg30LastMonth": null, + "avg30LastYear": null + }, + "BLOOD_GLUCOSE": { + "count": 57, + "latest": 128, + "min": 92, + "max": 147, + "mean": 113.12, + "median": null, + "avg7": 112.76, + "avg30": 113.12, + "slope7": { + "slope": -1.064, + "direction": "down", + "confidence": 0.01 + }, + "slope30": { + "slope": -0.113, + "direction": "down", + "confidence": 0 + }, + "anomalyCount": 0, + "avg30LastMonth": null, + "avg30LastYear": null + }, + "OXYGEN_SATURATION": { + "count": 9, + "latest": 96, + "min": 96, + "max": 98, + "mean": 96.89, + "median": null, + "avg7": 96.86, + "avg30": 96.89, + "slope7": { + "slope": -0.036, + "direction": "down", + "confidence": 0.01 + }, + "slope30": { + "slope": -0.016, + "direction": "down", + "confidence": 0 + }, + "anomalyCount": 0, + "avg30LastMonth": null, + "avg30LastYear": null + }, + "HEART_RATE_VARIABILITY": { + "count": 29, + "latest": 45, + "min": 45, + "max": 52, + "mean": 48.17, + "median": null, + "avg7": 48, + "avg30": 48.17, + "slope7": { + "slope": -1, + "direction": "down", + "confidence": 1 + }, + "slope30": { + "slope": -0.046, + "direction": "down", + "confidence": 0.03 + }, + "anomalyCount": 0, + "avg30LastMonth": null, + "avg30LastYear": null + }, + "RESTING_HEART_RATE": { + "count": 38, + "latest": 58, + "min": 58, + "max": 61, + "mean": 59.45, + "median": null, + "avg7": 59.29, + "avg30": 59.38, + "slope7": { + "slope": -0.143, + "direction": "down", + "confidence": 0.08 + }, + "slope30": { + "slope": -0.009, + "direction": "stable", + "confidence": 0.01 + }, + "anomalyCount": 0, + "avg30LastMonth": 59.67, + "avg30LastYear": null + } + }, + "bmi": 25.9, + "bmiClassification": { + "category": "Overweight", + "color": "var(--warning)", + "severity": "warning" + }, + "bpClassification": { + "category": "High-normal", + "color": "var(--dracula-yellow)", + "severity": "elevated" + }, + "bpPctInTarget": 11, + "bpTargets": { + "sysLow": 120, + "sysHigh": 129, + "diaLow": 70, + "diaHigh": 79 + }, + "weightBpCorrelation": null, + "scatterData": [ + { + "weight": 84.4, + "sysBP": 131 + }, + { + "weight": 84.3, + "sysBP": 124 + }, + { + "weight": 84.3, + "sysBP": 132 + }, + { + "weight": 84.2, + "sysBP": 125 + }, + { + "weight": 84.2, + "sysBP": 133 + }, + { + "weight": 84.2, + "sysBP": 126 + }, + { + "weight": 84.1, + "sysBP": 134 + }, + { + "weight": 84.1, + "sysBP": 127 + }, + { + "weight": 84, + "sysBP": 135 + }, + { + "weight": 84, + "sysBP": 128 + }, + { + "weight": 84, + "sysBP": 136 + }, + { + "weight": 83.9, + "sysBP": 129 + }, + { + "weight": 83.9, + "sysBP": 137 + }, + { + "weight": 83.8, + "sysBP": 130 + }, + { + "weight": 83.8, + "sysBP": 123 + }, + { + "weight": 83.8, + "sysBP": 131 + }, + { + "weight": 83.7, + "sysBP": 124 + }, + { + "weight": 83.7, + "sysBP": 132 + }, + { + "weight": 83.6, + "sysBP": 125 + }, + { + "weight": 83.6, + "sysBP": 133 + }, + { + "weight": 83.6, + "sysBP": 126 + }, + { + "weight": 83.5, + "sysBP": 134 + }, + { + "weight": 83.5, + "sysBP": 127 + }, + { + "weight": 83.4, + "sysBP": 135 + }, + { + "weight": 83.4, + "sysBP": 128 + }, + { + "weight": 83.4, + "sysBP": 136 + }, + { + "weight": 83.3, + "sysBP": 129 + }, + { + "weight": 83.3, + "sysBP": 137 + }, + { + "weight": 83.2, + "sysBP": 130 + }, + { + "weight": 83.2, + "sysBP": 131 + }, + { + "weight": 83.1, + "sysBP": 124 + }, + { + "weight": 83.1, + "sysBP": 132 + }, + { + "weight": 83, + "sysBP": 125 + }, + { + "weight": 83, + "sysBP": 133 + }, + { + "weight": 83, + "sysBP": 126 + }, + { + "weight": 82.9, + "sysBP": 134 + }, + { + "weight": 82.9, + "sysBP": 127 + }, + { + "weight": 82.8, + "sysBP": 135 + }, + { + "weight": 82.8, + "sysBP": 128 + }, + { + "weight": 82.8, + "sysBP": 136 + }, + { + "weight": 82.7, + "sysBP": 129 + }, + { + "weight": 82.7, + "sysBP": 137 + }, + { + "weight": 82.6, + "sysBP": 130 + }, + { + "weight": 82.6, + "sysBP": 123 + }, + { + "weight": 82.6, + "sysBP": 131 + }, + { + "weight": 82.5, + "sysBP": 124 + }, + { + "weight": 82.5, + "sysBP": 132 + }, + { + "weight": 82.4, + "sysBP": 125 + }, + { + "weight": 82.4, + "sysBP": 133 + }, + { + "weight": 82.4, + "sysBP": 126 + }, + { + "weight": 82.3, + "sysBP": 134 + }, + { + "weight": 82.2, + "sysBP": 135 + }, + { + "weight": 82.2, + "sysBP": 128 + }, + { + "weight": 82.2, + "sysBP": 136 + }, + { + "weight": 82.1, + "sysBP": 129 + }, + { + "weight": 82.1, + "sysBP": 137 + }, + { + "weight": 82, + "sysBP": 130 + }, + { + "weight": 82, + "sysBP": 123 + } + ], + "bpMedicationCorrelation": null, + "bpMedicationScatterData": [ + { + "continuityPct": 100, + "sysBP": 131 + }, + { + "continuityPct": 100, + "sysBP": 124 + }, + { + "continuityPct": 0, + "sysBP": 132 + }, + { + "continuityPct": 100, + "sysBP": 125 + }, + { + "continuityPct": 100, + "sysBP": 133 + }, + { + "continuityPct": 100, + "sysBP": 126 + }, + { + "continuityPct": 100, + "sysBP": 134 + }, + { + "continuityPct": 100, + "sysBP": 127 + }, + { + "continuityPct": 0, + "sysBP": 128 + }, + { + "continuityPct": 100, + "sysBP": 136 + }, + { + "continuityPct": 100, + "sysBP": 129 + }, + { + "continuityPct": 100, + "sysBP": 137 + }, + { + "continuityPct": 100, + "sysBP": 130 + }, + { + "continuityPct": 100, + "sysBP": 131 + }, + { + "continuityPct": 0, + "sysBP": 124 + }, + { + "continuityPct": 100, + "sysBP": 125 + }, + { + "continuityPct": 100, + "sysBP": 133 + }, + { + "continuityPct": 100, + "sysBP": 126 + }, + { + "continuityPct": 100, + "sysBP": 134 + }, + { + "continuityPct": 100, + "sysBP": 127 + }, + { + "continuityPct": 0, + "sysBP": 135 + }, + { + "continuityPct": 100, + "sysBP": 128 + }, + { + "continuityPct": 100, + "sysBP": 136 + }, + { + "continuityPct": 100, + "sysBP": 137 + }, + { + "continuityPct": 100, + "sysBP": 130 + }, + { + "continuityPct": 100, + "sysBP": 123 + }, + { + "continuityPct": 0, + "sysBP": 131 + }, + { + "continuityPct": 100, + "sysBP": 124 + }, + { + "continuityPct": 100, + "sysBP": 132 + }, + { + "continuityPct": 100, + "sysBP": 125 + }, + { + "continuityPct": 100, + "sysBP": 133 + }, + { + "continuityPct": 100, + "sysBP": 134 + }, + { + "continuityPct": 100, + "sysBP": 135 + }, + { + "continuityPct": 100, + "sysBP": 128 + }, + { + "continuityPct": 100, + "sysBP": 136 + }, + { + "continuityPct": 100, + "sysBP": 129 + }, + { + "continuityPct": 100, + "sysBP": 137 + }, + { + "continuityPct": 100, + "sysBP": 130 + } + ], + "moodSummary": { + "count": 45, + "latest": 1, + "min": 1, + "max": 5, + "mean": 3, + "median": 3, + "avg7": 2.86, + "avg30": 3, + "slope7": { + "slope": -0.143, + "direction": "down", + "confidence": 0.04 + }, + "slope30": { + "slope": -0.013, + "direction": "down", + "confidence": 0.01 + }, + "anomalyCount": 0, + "avg30LastMonth": 3, + "avg30LastYear": null + }, + "moodBpCorrelation": null, + "moodBpScatterData": [ + { + "mood": 3, + "sysBP": 131 + }, + { + "mood": 5, + "sysBP": 124 + }, + { + "mood": 2, + "sysBP": 132 + }, + { + "mood": 4, + "sysBP": 125 + }, + { + "mood": 1, + "sysBP": 133 + }, + { + "mood": 3, + "sysBP": 126 + }, + { + "mood": 5, + "sysBP": 134 + }, + { + "mood": 2, + "sysBP": 127 + }, + { + "mood": 4, + "sysBP": 135 + }, + { + "mood": 1, + "sysBP": 128 + }, + { + "mood": 3, + "sysBP": 136 + }, + { + "mood": 5, + "sysBP": 129 + }, + { + "mood": 2, + "sysBP": 137 + }, + { + "mood": 4, + "sysBP": 130 + }, + { + "mood": 3, + "sysBP": 131 + }, + { + "mood": 5, + "sysBP": 124 + }, + { + "mood": 2, + "sysBP": 132 + }, + { + "mood": 4, + "sysBP": 125 + }, + { + "mood": 1, + "sysBP": 133 + }, + { + "mood": 3, + "sysBP": 126 + }, + { + "mood": 5, + "sysBP": 134 + }, + { + "mood": 2, + "sysBP": 127 + }, + { + "mood": 4, + "sysBP": 135 + }, + { + "mood": 1, + "sysBP": 128 + }, + { + "mood": 3, + "sysBP": 136 + }, + { + "mood": 5, + "sysBP": 129 + }, + { + "mood": 2, + "sysBP": 137 + }, + { + "mood": 4, + "sysBP": 130 + }, + { + "mood": 1, + "sysBP": 123 + }, + { + "mood": 3, + "sysBP": 131 + }, + { + "mood": 5, + "sysBP": 124 + }, + { + "mood": 2, + "sysBP": 132 + }, + { + "mood": 4, + "sysBP": 125 + }, + { + "mood": 1, + "sysBP": 133 + }, + { + "mood": 3, + "sysBP": 126 + }, + { + "mood": 5, + "sysBP": 134 + }, + { + "mood": 4, + "sysBP": 135 + }, + { + "mood": 1, + "sysBP": 128 + }, + { + "mood": 3, + "sysBP": 136 + }, + { + "mood": 5, + "sysBP": 129 + }, + { + "mood": 2, + "sysBP": 137 + }, + { + "mood": 4, + "sysBP": 130 + }, + { + "mood": 1, + "sysBP": 123 + } + ], + "moodWeightCorrelation": null, + "moodWeightScatterData": [ + { + "mood": 3, + "weight": 83.8 + }, + { + "mood": 5, + "weight": 83.7 + }, + { + "mood": 2, + "weight": 83.7 + }, + { + "mood": 4, + "weight": 83.6 + }, + { + "mood": 1, + "weight": 83.6 + }, + { + "mood": 3, + "weight": 83.6 + }, + { + "mood": 5, + "weight": 83.5 + }, + { + "mood": 2, + "weight": 83.5 + }, + { + "mood": 4, + "weight": 83.4 + }, + { + "mood": 1, + "weight": 83.4 + }, + { + "mood": 3, + "weight": 83.4 + }, + { + "mood": 5, + "weight": 83.3 + }, + { + "mood": 2, + "weight": 83.3 + }, + { + "mood": 4, + "weight": 83.2 + }, + { + "mood": 3, + "weight": 83.2 + }, + { + "mood": 5, + "weight": 83.1 + }, + { + "mood": 2, + "weight": 83.1 + }, + { + "mood": 4, + "weight": 83 + }, + { + "mood": 1, + "weight": 83 + }, + { + "mood": 3, + "weight": 83 + }, + { + "mood": 5, + "weight": 82.9 + }, + { + "mood": 2, + "weight": 82.9 + }, + { + "mood": 4, + "weight": 82.8 + }, + { + "mood": 1, + "weight": 82.8 + }, + { + "mood": 3, + "weight": 82.8 + }, + { + "mood": 5, + "weight": 82.7 + }, + { + "mood": 2, + "weight": 82.7 + }, + { + "mood": 4, + "weight": 82.6 + }, + { + "mood": 1, + "weight": 82.6 + }, + { + "mood": 3, + "weight": 82.6 + }, + { + "mood": 5, + "weight": 82.5 + }, + { + "mood": 2, + "weight": 82.5 + }, + { + "mood": 4, + "weight": 82.4 + }, + { + "mood": 1, + "weight": 82.4 + }, + { + "mood": 3, + "weight": 82.4 + }, + { + "mood": 5, + "weight": 82.3 + }, + { + "mood": 4, + "weight": 82.2 + }, + { + "mood": 1, + "weight": 82.2 + }, + { + "mood": 3, + "weight": 82.2 + }, + { + "mood": 5, + "weight": 82.1 + }, + { + "mood": 2, + "weight": 82.1 + }, + { + "mood": 4, + "weight": 82 + }, + { + "mood": 1, + "weight": 82 + } + ], + "moodPulseCorrelation": null, + "moodPulseScatterData": [ + { + "mood": 3, + "pulse": 61 + }, + { + "mood": 5, + "pulse": 60 + }, + { + "mood": 2, + "pulse": 59 + }, + { + "mood": 4, + "pulse": 58 + }, + { + "mood": 1, + "pulse": 61 + }, + { + "mood": 3, + "pulse": 60 + }, + { + "mood": 5, + "pulse": 59 + }, + { + "mood": 2, + "pulse": 58 + }, + { + "mood": 4, + "pulse": 61 + }, + { + "mood": 3, + "pulse": 59 + }, + { + "mood": 5, + "pulse": 58 + }, + { + "mood": 2, + "pulse": 61 + }, + { + "mood": 4, + "pulse": 60 + }, + { + "mood": 1, + "pulse": 59 + }, + { + "mood": 3, + "pulse": 58 + }, + { + "mood": 5, + "pulse": 61 + }, + { + "mood": 2, + "pulse": 60 + }, + { + "mood": 4, + "pulse": 59 + }, + { + "mood": 1, + "pulse": 58 + }, + { + "mood": 3, + "pulse": 61 + }, + { + "mood": 5, + "pulse": 60 + }, + { + "mood": 2, + "pulse": 59 + }, + { + "mood": 4, + "pulse": 58 + }, + { + "mood": 1, + "pulse": 61 + }, + { + "mood": 3, + "pulse": 60 + }, + { + "mood": 5, + "pulse": 59 + }, + { + "mood": 2, + "pulse": 58 + }, + { + "mood": 4, + "pulse": 61 + }, + { + "mood": 1, + "pulse": 60 + }, + { + "mood": 3, + "pulse": 59 + }, + { + "mood": 5, + "pulse": 58 + }, + { + "mood": 4, + "pulse": 60 + }, + { + "mood": 1, + "pulse": 59 + }, + { + "mood": 3, + "pulse": 58 + }, + { + "mood": 5, + "pulse": 61 + }, + { + "mood": 2, + "pulse": 60 + }, + { + "mood": 4, + "pulse": 59 + }, + { + "mood": 1, + "pulse": 58 + } + ], + "medications": [ + { + "id": "", + "name": "Ramipril", + "dose": "5mg", + "category": "BLOOD_PRESSURE", + "categoryLabel": null, + "compliance7": 100, + "compliance30": 76, + "streak": 0, + "taken7": 6, + "skipped7": 0, + "missed7": 0 + }, + { + "id": "", + "name": "Vitamin D", + "dose": "1000 IU", + "category": "OTHER", + "categoryLabel": null, + "compliance7": 43, + "compliance30": 47, + "streak": 1, + "taken7": 3, + "skipped7": 0, + "missed7": 4 + } + ], + "alerts": [ + { + "level": "info", + "title": "BMI in overweight range", + "message": "Your BMI of 25.9 is in the overweight range. Regular exercise and a balanced diet can help." + }, + { + "level": "warning", + "title": "Blood pressure often outside target range", + "message": "Only 11% of your readings are within the target range." + }, + { + "level": "success", + "title": "Excellent compliance: Ramipril", + "message": "100% compliance over the last 7 days. Keep it up!" + }, + { + "level": "warning", + "title": "Low compliance: Vitamin D", + "message": "Your 7-day compliance for Vitamin D is only 43%. Regular intake is important." + } + ], + "hasProvider": true, + "dataSpanDays": 90, + "totalMeasurements": 541, + "revalidating": false, + "ai": { + "briefing": { + "available": false, + "reason": "user_disabled", + "onDeviceAllowed": false + }, + "statusText": { + "available": false, + "reason": "user_disabled", + "onDeviceAllowed": false + } + } + }, + "error": null + }, + "events": [ + { + "status": 200, + "action": null, + "meta": { + "cache.analytics.key_hash": 3572442229, + "cache.analytics.outcome": "hit" + }, + "warnings": null, + "error": null + } + ], + "effects": [] +} diff --git a/tests/integration/routes/parity/__snapshots__/insights-comprehensive.06-operator-off.json.snap b/tests/integration/routes/parity/__snapshots__/insights-comprehensive.06-operator-off.json.snap new file mode 100644 index 000000000..f9b98e98f --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/insights-comprehensive.06-operator-off.json.snap @@ -0,0 +1,1274 @@ +{ + "status": 200, + "body": { + "data": { + "summaries": { + "WEIGHT": { + "count": 58, + "latest": 82, + "min": 82, + "max": 84.4, + "mean": 83.19, + "median": null, + "avg7": 82.11, + "avg30": 82.59, + "slope7": { + "slope": -0.039, + "direction": "down", + "confidence": 0.89 + }, + "slope30": { + "slope": -0.04, + "direction": "down", + "confidence": 0.99 + }, + "anomalyCount": 0, + "avg30LastMonth": 83.8, + "avg30LastYear": null + }, + "BLOOD_PRESSURE_SYS": { + "count": 87, + "latest": 123, + "min": 123, + "max": 137, + "mean": 130.2, + "median": null, + "avg7": 131.14, + "avg30": 130.1, + "slope7": { + "slope": -1.107, + "direction": "down", + "confidence": 0.22 + }, + "slope30": { + "slope": 0.043, + "direction": "up", + "confidence": 0.01 + }, + "anomalyCount": 0, + "avg30LastMonth": 130.24, + "avg30LastYear": null + }, + "BLOOD_PRESSURE_DIA": { + "count": 87, + "latest": 78, + "min": 78, + "max": 86, + "mean": 81.94, + "median": null, + "avg7": 81.43, + "avg30": 81.66, + "slope7": { + "slope": -0.5, + "direction": "down", + "confidence": 0.17 + }, + "slope30": { + "slope": -0.019, + "direction": "down", + "confidence": 0 + }, + "anomalyCount": 0, + "avg30LastMonth": 82.03, + "avg30LastYear": null + }, + "PULSE": { + "count": 58, + "latest": 64, + "min": 64, + "max": 69, + "mean": 66.57, + "median": null, + "avg7": 66.14, + "avg30": 66.55, + "slope7": { + "slope": -0.357, + "direction": "down", + "confidence": 0.16 + }, + "slope30": { + "slope": -0.035, + "direction": "down", + "confidence": 0.03 + }, + "anomalyCount": 0, + "avg30LastMonth": 66.59, + "avg30LastYear": null + }, + "BODY_FAT": { + "count": 8, + "latest": 24, + "min": 24, + "max": 25.1, + "mean": 24.61, + "median": null, + "avg7": 24, + "avg30": 24.33, + "slope7": null, + "slope30": { + "slope": -0.021, + "direction": "down", + "confidence": 0.99 + }, + "anomalyCount": 0, + "avg30LastMonth": 24.9, + "avg30LastYear": null + }, + "SLEEP_DURATION": { + "count": 81, + "latest": 12, + "min": 12, + "max": 240, + "mean": 98.64, + "median": null, + "avg7": 96.17, + "avg30": 98.64, + "slope7": { + "slope": -2.121, + "direction": "down", + "confidence": 0 + }, + "slope30": { + "slope": -0.379, + "direction": "down", + "confidence": 0 + }, + "anomalyCount": 0, + "avg30LastMonth": null, + "avg30LastYear": null + }, + "ACTIVITY_STEPS": { + "count": 29, + "latest": 6000, + "min": 6000, + "max": 9833, + "mean": 7780.83, + "median": null, + "avg7": 7119, + "avg30": 7780.83, + "slope7": { + "slope": -373, + "direction": "down", + "confidence": 1 + }, + "slope30": { + "slope": -34.049, + "direction": "down", + "confidence": 0.07 + }, + "anomalyCount": 0, + "avg30LastMonth": null, + "avg30LastYear": null + }, + "BLOOD_GLUCOSE": { + "count": 57, + "latest": 128, + "min": 92, + "max": 147, + "mean": 113.12, + "median": null, + "avg7": 112.76, + "avg30": 113.12, + "slope7": { + "slope": -1.064, + "direction": "down", + "confidence": 0.01 + }, + "slope30": { + "slope": -0.113, + "direction": "down", + "confidence": 0 + }, + "anomalyCount": 0, + "avg30LastMonth": null, + "avg30LastYear": null + }, + "OXYGEN_SATURATION": { + "count": 9, + "latest": 96, + "min": 96, + "max": 98, + "mean": 96.89, + "median": null, + "avg7": 96.86, + "avg30": 96.89, + "slope7": { + "slope": -0.036, + "direction": "down", + "confidence": 0.01 + }, + "slope30": { + "slope": -0.016, + "direction": "down", + "confidence": 0 + }, + "anomalyCount": 0, + "avg30LastMonth": null, + "avg30LastYear": null + }, + "HEART_RATE_VARIABILITY": { + "count": 29, + "latest": 45, + "min": 45, + "max": 52, + "mean": 48.17, + "median": null, + "avg7": 48, + "avg30": 48.17, + "slope7": { + "slope": -1, + "direction": "down", + "confidence": 1 + }, + "slope30": { + "slope": -0.046, + "direction": "down", + "confidence": 0.03 + }, + "anomalyCount": 0, + "avg30LastMonth": null, + "avg30LastYear": null + }, + "RESTING_HEART_RATE": { + "count": 38, + "latest": 58, + "min": 58, + "max": 61, + "mean": 59.45, + "median": null, + "avg7": 59.29, + "avg30": 59.38, + "slope7": { + "slope": -0.143, + "direction": "down", + "confidence": 0.08 + }, + "slope30": { + "slope": -0.009, + "direction": "stable", + "confidence": 0.01 + }, + "anomalyCount": 0, + "avg30LastMonth": 59.67, + "avg30LastYear": null + } + }, + "bmi": 25.9, + "bmiClassification": { + "category": "Overweight", + "color": "var(--warning)", + "severity": "warning" + }, + "bpClassification": { + "category": "High-normal", + "color": "var(--dracula-yellow)", + "severity": "elevated" + }, + "bpPctInTarget": 11, + "bpTargets": { + "sysLow": 120, + "sysHigh": 129, + "diaLow": 70, + "diaHigh": 79 + }, + "weightBpCorrelation": null, + "scatterData": [ + { + "weight": 84.4, + "sysBP": 131 + }, + { + "weight": 84.3, + "sysBP": 124 + }, + { + "weight": 84.3, + "sysBP": 132 + }, + { + "weight": 84.2, + "sysBP": 125 + }, + { + "weight": 84.2, + "sysBP": 133 + }, + { + "weight": 84.2, + "sysBP": 126 + }, + { + "weight": 84.1, + "sysBP": 134 + }, + { + "weight": 84.1, + "sysBP": 127 + }, + { + "weight": 84, + "sysBP": 135 + }, + { + "weight": 84, + "sysBP": 128 + }, + { + "weight": 84, + "sysBP": 136 + }, + { + "weight": 83.9, + "sysBP": 129 + }, + { + "weight": 83.9, + "sysBP": 137 + }, + { + "weight": 83.8, + "sysBP": 130 + }, + { + "weight": 83.8, + "sysBP": 123 + }, + { + "weight": 83.8, + "sysBP": 131 + }, + { + "weight": 83.7, + "sysBP": 124 + }, + { + "weight": 83.7, + "sysBP": 132 + }, + { + "weight": 83.6, + "sysBP": 125 + }, + { + "weight": 83.6, + "sysBP": 133 + }, + { + "weight": 83.6, + "sysBP": 126 + }, + { + "weight": 83.5, + "sysBP": 134 + }, + { + "weight": 83.5, + "sysBP": 127 + }, + { + "weight": 83.4, + "sysBP": 135 + }, + { + "weight": 83.4, + "sysBP": 128 + }, + { + "weight": 83.4, + "sysBP": 136 + }, + { + "weight": 83.3, + "sysBP": 129 + }, + { + "weight": 83.3, + "sysBP": 137 + }, + { + "weight": 83.2, + "sysBP": 130 + }, + { + "weight": 83.2, + "sysBP": 131 + }, + { + "weight": 83.1, + "sysBP": 124 + }, + { + "weight": 83.1, + "sysBP": 132 + }, + { + "weight": 83, + "sysBP": 125 + }, + { + "weight": 83, + "sysBP": 133 + }, + { + "weight": 83, + "sysBP": 126 + }, + { + "weight": 82.9, + "sysBP": 134 + }, + { + "weight": 82.9, + "sysBP": 127 + }, + { + "weight": 82.8, + "sysBP": 135 + }, + { + "weight": 82.8, + "sysBP": 128 + }, + { + "weight": 82.8, + "sysBP": 136 + }, + { + "weight": 82.7, + "sysBP": 129 + }, + { + "weight": 82.7, + "sysBP": 137 + }, + { + "weight": 82.6, + "sysBP": 130 + }, + { + "weight": 82.6, + "sysBP": 123 + }, + { + "weight": 82.6, + "sysBP": 131 + }, + { + "weight": 82.5, + "sysBP": 124 + }, + { + "weight": 82.5, + "sysBP": 132 + }, + { + "weight": 82.4, + "sysBP": 125 + }, + { + "weight": 82.4, + "sysBP": 133 + }, + { + "weight": 82.4, + "sysBP": 126 + }, + { + "weight": 82.3, + "sysBP": 134 + }, + { + "weight": 82.2, + "sysBP": 135 + }, + { + "weight": 82.2, + "sysBP": 128 + }, + { + "weight": 82.2, + "sysBP": 136 + }, + { + "weight": 82.1, + "sysBP": 129 + }, + { + "weight": 82.1, + "sysBP": 137 + }, + { + "weight": 82, + "sysBP": 130 + }, + { + "weight": 82, + "sysBP": 123 + } + ], + "bpMedicationCorrelation": null, + "bpMedicationScatterData": [ + { + "continuityPct": 100, + "sysBP": 131 + }, + { + "continuityPct": 100, + "sysBP": 124 + }, + { + "continuityPct": 0, + "sysBP": 132 + }, + { + "continuityPct": 100, + "sysBP": 125 + }, + { + "continuityPct": 100, + "sysBP": 133 + }, + { + "continuityPct": 100, + "sysBP": 126 + }, + { + "continuityPct": 100, + "sysBP": 134 + }, + { + "continuityPct": 100, + "sysBP": 127 + }, + { + "continuityPct": 0, + "sysBP": 128 + }, + { + "continuityPct": 100, + "sysBP": 136 + }, + { + "continuityPct": 100, + "sysBP": 129 + }, + { + "continuityPct": 100, + "sysBP": 137 + }, + { + "continuityPct": 100, + "sysBP": 130 + }, + { + "continuityPct": 100, + "sysBP": 131 + }, + { + "continuityPct": 0, + "sysBP": 124 + }, + { + "continuityPct": 100, + "sysBP": 125 + }, + { + "continuityPct": 100, + "sysBP": 133 + }, + { + "continuityPct": 100, + "sysBP": 126 + }, + { + "continuityPct": 100, + "sysBP": 134 + }, + { + "continuityPct": 100, + "sysBP": 127 + }, + { + "continuityPct": 0, + "sysBP": 135 + }, + { + "continuityPct": 100, + "sysBP": 128 + }, + { + "continuityPct": 100, + "sysBP": 136 + }, + { + "continuityPct": 100, + "sysBP": 137 + }, + { + "continuityPct": 100, + "sysBP": 130 + }, + { + "continuityPct": 100, + "sysBP": 123 + }, + { + "continuityPct": 0, + "sysBP": 131 + }, + { + "continuityPct": 100, + "sysBP": 124 + }, + { + "continuityPct": 100, + "sysBP": 132 + }, + { + "continuityPct": 100, + "sysBP": 125 + }, + { + "continuityPct": 100, + "sysBP": 133 + }, + { + "continuityPct": 100, + "sysBP": 134 + }, + { + "continuityPct": 100, + "sysBP": 135 + }, + { + "continuityPct": 100, + "sysBP": 128 + }, + { + "continuityPct": 100, + "sysBP": 136 + }, + { + "continuityPct": 100, + "sysBP": 129 + }, + { + "continuityPct": 100, + "sysBP": 137 + }, + { + "continuityPct": 100, + "sysBP": 130 + } + ], + "moodSummary": { + "count": 45, + "latest": 1, + "min": 1, + "max": 5, + "mean": 3, + "median": 3, + "avg7": 2.86, + "avg30": 3, + "slope7": { + "slope": -0.143, + "direction": "down", + "confidence": 0.04 + }, + "slope30": { + "slope": -0.013, + "direction": "down", + "confidence": 0.01 + }, + "anomalyCount": 0, + "avg30LastMonth": 3, + "avg30LastYear": null + }, + "moodBpCorrelation": null, + "moodBpScatterData": [ + { + "mood": 3, + "sysBP": 131 + }, + { + "mood": 5, + "sysBP": 124 + }, + { + "mood": 2, + "sysBP": 132 + }, + { + "mood": 4, + "sysBP": 125 + }, + { + "mood": 1, + "sysBP": 133 + }, + { + "mood": 3, + "sysBP": 126 + }, + { + "mood": 5, + "sysBP": 134 + }, + { + "mood": 2, + "sysBP": 127 + }, + { + "mood": 4, + "sysBP": 135 + }, + { + "mood": 1, + "sysBP": 128 + }, + { + "mood": 3, + "sysBP": 136 + }, + { + "mood": 5, + "sysBP": 129 + }, + { + "mood": 2, + "sysBP": 137 + }, + { + "mood": 4, + "sysBP": 130 + }, + { + "mood": 3, + "sysBP": 131 + }, + { + "mood": 5, + "sysBP": 124 + }, + { + "mood": 2, + "sysBP": 132 + }, + { + "mood": 4, + "sysBP": 125 + }, + { + "mood": 1, + "sysBP": 133 + }, + { + "mood": 3, + "sysBP": 126 + }, + { + "mood": 5, + "sysBP": 134 + }, + { + "mood": 2, + "sysBP": 127 + }, + { + "mood": 4, + "sysBP": 135 + }, + { + "mood": 1, + "sysBP": 128 + }, + { + "mood": 3, + "sysBP": 136 + }, + { + "mood": 5, + "sysBP": 129 + }, + { + "mood": 2, + "sysBP": 137 + }, + { + "mood": 4, + "sysBP": 130 + }, + { + "mood": 1, + "sysBP": 123 + }, + { + "mood": 3, + "sysBP": 131 + }, + { + "mood": 5, + "sysBP": 124 + }, + { + "mood": 2, + "sysBP": 132 + }, + { + "mood": 4, + "sysBP": 125 + }, + { + "mood": 1, + "sysBP": 133 + }, + { + "mood": 3, + "sysBP": 126 + }, + { + "mood": 5, + "sysBP": 134 + }, + { + "mood": 4, + "sysBP": 135 + }, + { + "mood": 1, + "sysBP": 128 + }, + { + "mood": 3, + "sysBP": 136 + }, + { + "mood": 5, + "sysBP": 129 + }, + { + "mood": 2, + "sysBP": 137 + }, + { + "mood": 4, + "sysBP": 130 + }, + { + "mood": 1, + "sysBP": 123 + } + ], + "moodWeightCorrelation": null, + "moodWeightScatterData": [ + { + "mood": 3, + "weight": 83.8 + }, + { + "mood": 5, + "weight": 83.7 + }, + { + "mood": 2, + "weight": 83.7 + }, + { + "mood": 4, + "weight": 83.6 + }, + { + "mood": 1, + "weight": 83.6 + }, + { + "mood": 3, + "weight": 83.6 + }, + { + "mood": 5, + "weight": 83.5 + }, + { + "mood": 2, + "weight": 83.5 + }, + { + "mood": 4, + "weight": 83.4 + }, + { + "mood": 1, + "weight": 83.4 + }, + { + "mood": 3, + "weight": 83.4 + }, + { + "mood": 5, + "weight": 83.3 + }, + { + "mood": 2, + "weight": 83.3 + }, + { + "mood": 4, + "weight": 83.2 + }, + { + "mood": 3, + "weight": 83.2 + }, + { + "mood": 5, + "weight": 83.1 + }, + { + "mood": 2, + "weight": 83.1 + }, + { + "mood": 4, + "weight": 83 + }, + { + "mood": 1, + "weight": 83 + }, + { + "mood": 3, + "weight": 83 + }, + { + "mood": 5, + "weight": 82.9 + }, + { + "mood": 2, + "weight": 82.9 + }, + { + "mood": 4, + "weight": 82.8 + }, + { + "mood": 1, + "weight": 82.8 + }, + { + "mood": 3, + "weight": 82.8 + }, + { + "mood": 5, + "weight": 82.7 + }, + { + "mood": 2, + "weight": 82.7 + }, + { + "mood": 4, + "weight": 82.6 + }, + { + "mood": 1, + "weight": 82.6 + }, + { + "mood": 3, + "weight": 82.6 + }, + { + "mood": 5, + "weight": 82.5 + }, + { + "mood": 2, + "weight": 82.5 + }, + { + "mood": 4, + "weight": 82.4 + }, + { + "mood": 1, + "weight": 82.4 + }, + { + "mood": 3, + "weight": 82.4 + }, + { + "mood": 5, + "weight": 82.3 + }, + { + "mood": 4, + "weight": 82.2 + }, + { + "mood": 1, + "weight": 82.2 + }, + { + "mood": 3, + "weight": 82.2 + }, + { + "mood": 5, + "weight": 82.1 + }, + { + "mood": 2, + "weight": 82.1 + }, + { + "mood": 4, + "weight": 82 + }, + { + "mood": 1, + "weight": 82 + } + ], + "moodPulseCorrelation": null, + "moodPulseScatterData": [ + { + "mood": 3, + "pulse": 61 + }, + { + "mood": 5, + "pulse": 60 + }, + { + "mood": 2, + "pulse": 59 + }, + { + "mood": 4, + "pulse": 58 + }, + { + "mood": 1, + "pulse": 61 + }, + { + "mood": 3, + "pulse": 60 + }, + { + "mood": 5, + "pulse": 59 + }, + { + "mood": 2, + "pulse": 58 + }, + { + "mood": 4, + "pulse": 61 + }, + { + "mood": 3, + "pulse": 59 + }, + { + "mood": 5, + "pulse": 58 + }, + { + "mood": 2, + "pulse": 61 + }, + { + "mood": 4, + "pulse": 60 + }, + { + "mood": 1, + "pulse": 59 + }, + { + "mood": 3, + "pulse": 58 + }, + { + "mood": 5, + "pulse": 61 + }, + { + "mood": 2, + "pulse": 60 + }, + { + "mood": 4, + "pulse": 59 + }, + { + "mood": 1, + "pulse": 58 + }, + { + "mood": 3, + "pulse": 61 + }, + { + "mood": 5, + "pulse": 60 + }, + { + "mood": 2, + "pulse": 59 + }, + { + "mood": 4, + "pulse": 58 + }, + { + "mood": 1, + "pulse": 61 + }, + { + "mood": 3, + "pulse": 60 + }, + { + "mood": 5, + "pulse": 59 + }, + { + "mood": 2, + "pulse": 58 + }, + { + "mood": 4, + "pulse": 61 + }, + { + "mood": 1, + "pulse": 60 + }, + { + "mood": 3, + "pulse": 59 + }, + { + "mood": 5, + "pulse": 58 + }, + { + "mood": 4, + "pulse": 60 + }, + { + "mood": 1, + "pulse": 59 + }, + { + "mood": 3, + "pulse": 58 + }, + { + "mood": 5, + "pulse": 61 + }, + { + "mood": 2, + "pulse": 60 + }, + { + "mood": 4, + "pulse": 59 + }, + { + "mood": 1, + "pulse": 58 + } + ], + "medications": [ + { + "id": "", + "name": "Ramipril", + "dose": "5mg", + "category": "BLOOD_PRESSURE", + "categoryLabel": null, + "compliance7": 100, + "compliance30": 76, + "streak": 0, + "taken7": 6, + "skipped7": 0, + "missed7": 0 + }, + { + "id": "", + "name": "Vitamin D", + "dose": "1000 IU", + "category": "OTHER", + "categoryLabel": null, + "compliance7": 43, + "compliance30": 47, + "streak": 1, + "taken7": 3, + "skipped7": 0, + "missed7": 4 + } + ], + "alerts": [ + { + "level": "info", + "title": "BMI in overweight range", + "message": "Your BMI of 25.9 is in the overweight range. Regular exercise and a balanced diet can help." + }, + { + "level": "warning", + "title": "Blood pressure often outside target range", + "message": "Only 11% of your readings are within the target range." + }, + { + "level": "success", + "title": "Excellent compliance: Ramipril", + "message": "100% compliance over the last 7 days. Keep it up!" + }, + { + "level": "warning", + "title": "Low compliance: Vitamin D", + "message": "Your 7-day compliance for Vitamin D is only 43%. Regular intake is important." + } + ], + "hasProvider": true, + "dataSpanDays": 90, + "totalMeasurements": 541, + "revalidating": false, + "ai": { + "briefing": { + "available": false, + "reason": "operator_disabled", + "onDeviceAllowed": false + }, + "statusText": { + "available": false, + "reason": "operator_disabled", + "onDeviceAllowed": false + } + } + }, + "error": null + }, + "events": [ + { + "status": 200, + "action": null, + "meta": { + "cache.analytics.key_hash": 3572442229, + "cache.analytics.outcome": "hit" + }, + "warnings": null, + "error": null + } + ], + "effects": [] +} diff --git a/tests/integration/routes/parity/__snapshots__/insights-comprehensive.07-manage-delegate.json.snap b/tests/integration/routes/parity/__snapshots__/insights-comprehensive.07-manage-delegate.json.snap new file mode 100644 index 000000000..06218e2c0 --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/insights-comprehensive.07-manage-delegate.json.snap @@ -0,0 +1,1303 @@ +{ + "status": 200, + "body": { + "data": { + "summaries": { + "WEIGHT": { + "count": 58, + "latest": 82, + "min": 82, + "max": 84.4, + "mean": 83.19, + "median": null, + "avg7": 82.11, + "avg30": 82.59, + "slope7": { + "slope": -0.039, + "direction": "down", + "confidence": 0.89 + }, + "slope30": { + "slope": -0.04, + "direction": "down", + "confidence": 0.99 + }, + "anomalyCount": 0, + "avg30LastMonth": 83.8, + "avg30LastYear": null + }, + "BLOOD_PRESSURE_SYS": { + "count": 87, + "latest": 123, + "min": 123, + "max": 137, + "mean": 130.2, + "median": null, + "avg7": 131.14, + "avg30": 130.1, + "slope7": { + "slope": -1.107, + "direction": "down", + "confidence": 0.22 + }, + "slope30": { + "slope": 0.043, + "direction": "up", + "confidence": 0.01 + }, + "anomalyCount": 0, + "avg30LastMonth": 130.24, + "avg30LastYear": null + }, + "BLOOD_PRESSURE_DIA": { + "count": 87, + "latest": 78, + "min": 78, + "max": 86, + "mean": 81.94, + "median": null, + "avg7": 81.43, + "avg30": 81.66, + "slope7": { + "slope": -0.5, + "direction": "down", + "confidence": 0.17 + }, + "slope30": { + "slope": -0.019, + "direction": "down", + "confidence": 0 + }, + "anomalyCount": 0, + "avg30LastMonth": 82.03, + "avg30LastYear": null + }, + "PULSE": { + "count": 58, + "latest": 64, + "min": 64, + "max": 69, + "mean": 66.57, + "median": null, + "avg7": 66.14, + "avg30": 66.55, + "slope7": { + "slope": -0.357, + "direction": "down", + "confidence": 0.16 + }, + "slope30": { + "slope": -0.035, + "direction": "down", + "confidence": 0.03 + }, + "anomalyCount": 0, + "avg30LastMonth": 66.59, + "avg30LastYear": null + }, + "BODY_FAT": { + "count": 8, + "latest": 24, + "min": 24, + "max": 25.1, + "mean": 24.61, + "median": null, + "avg7": 24, + "avg30": 24.33, + "slope7": null, + "slope30": { + "slope": -0.021, + "direction": "down", + "confidence": 0.99 + }, + "anomalyCount": 0, + "avg30LastMonth": 24.9, + "avg30LastYear": null + }, + "SLEEP_DURATION": { + "count": 81, + "latest": 12, + "min": 12, + "max": 240, + "mean": 98.64, + "median": null, + "avg7": 96.17, + "avg30": 98.64, + "slope7": { + "slope": -2.121, + "direction": "down", + "confidence": 0 + }, + "slope30": { + "slope": -0.379, + "direction": "down", + "confidence": 0 + }, + "anomalyCount": 0, + "avg30LastMonth": null, + "avg30LastYear": null + }, + "ACTIVITY_STEPS": { + "count": 29, + "latest": 6000, + "min": 6000, + "max": 9833, + "mean": 7780.83, + "median": null, + "avg7": 7119, + "avg30": 7780.83, + "slope7": { + "slope": -373, + "direction": "down", + "confidence": 1 + }, + "slope30": { + "slope": -34.049, + "direction": "down", + "confidence": 0.07 + }, + "anomalyCount": 0, + "avg30LastMonth": null, + "avg30LastYear": null + }, + "BLOOD_GLUCOSE": { + "count": 57, + "latest": 128, + "min": 92, + "max": 147, + "mean": 113.12, + "median": null, + "avg7": 112.76, + "avg30": 113.12, + "slope7": { + "slope": -1.064, + "direction": "down", + "confidence": 0.01 + }, + "slope30": { + "slope": -0.113, + "direction": "down", + "confidence": 0 + }, + "anomalyCount": 0, + "avg30LastMonth": null, + "avg30LastYear": null + }, + "OXYGEN_SATURATION": { + "count": 9, + "latest": 96, + "min": 96, + "max": 98, + "mean": 96.89, + "median": null, + "avg7": 96.86, + "avg30": 96.89, + "slope7": { + "slope": -0.036, + "direction": "down", + "confidence": 0.01 + }, + "slope30": { + "slope": -0.016, + "direction": "down", + "confidence": 0 + }, + "anomalyCount": 0, + "avg30LastMonth": null, + "avg30LastYear": null + }, + "HEART_RATE_VARIABILITY": { + "count": 29, + "latest": 45, + "min": 45, + "max": 52, + "mean": 48.17, + "median": null, + "avg7": 48, + "avg30": 48.17, + "slope7": { + "slope": -1, + "direction": "down", + "confidence": 1 + }, + "slope30": { + "slope": -0.046, + "direction": "down", + "confidence": 0.03 + }, + "anomalyCount": 0, + "avg30LastMonth": null, + "avg30LastYear": null + }, + "RESTING_HEART_RATE": { + "count": 38, + "latest": 58, + "min": 58, + "max": 61, + "mean": 59.45, + "median": null, + "avg7": 59.29, + "avg30": 59.38, + "slope7": { + "slope": -0.143, + "direction": "down", + "confidence": 0.08 + }, + "slope30": { + "slope": -0.009, + "direction": "stable", + "confidence": 0.01 + }, + "anomalyCount": 0, + "avg30LastMonth": 59.67, + "avg30LastYear": null + } + }, + "bmi": 25.9, + "bmiClassification": { + "category": "Overweight", + "color": "var(--warning)", + "severity": "warning" + }, + "bpClassification": { + "category": "High-normal", + "color": "var(--dracula-yellow)", + "severity": "elevated" + }, + "bpPctInTarget": 11, + "bpTargets": { + "sysLow": 120, + "sysHigh": 129, + "diaLow": 70, + "diaHigh": 79 + }, + "weightBpCorrelation": null, + "scatterData": [ + { + "weight": 84.4, + "sysBP": 131 + }, + { + "weight": 84.3, + "sysBP": 124 + }, + { + "weight": 84.3, + "sysBP": 132 + }, + { + "weight": 84.2, + "sysBP": 125 + }, + { + "weight": 84.2, + "sysBP": 133 + }, + { + "weight": 84.2, + "sysBP": 126 + }, + { + "weight": 84.1, + "sysBP": 134 + }, + { + "weight": 84.1, + "sysBP": 127 + }, + { + "weight": 84, + "sysBP": 135 + }, + { + "weight": 84, + "sysBP": 128 + }, + { + "weight": 84, + "sysBP": 136 + }, + { + "weight": 83.9, + "sysBP": 129 + }, + { + "weight": 83.9, + "sysBP": 137 + }, + { + "weight": 83.8, + "sysBP": 130 + }, + { + "weight": 83.8, + "sysBP": 123 + }, + { + "weight": 83.8, + "sysBP": 131 + }, + { + "weight": 83.7, + "sysBP": 124 + }, + { + "weight": 83.7, + "sysBP": 132 + }, + { + "weight": 83.6, + "sysBP": 125 + }, + { + "weight": 83.6, + "sysBP": 133 + }, + { + "weight": 83.6, + "sysBP": 126 + }, + { + "weight": 83.5, + "sysBP": 134 + }, + { + "weight": 83.5, + "sysBP": 127 + }, + { + "weight": 83.4, + "sysBP": 135 + }, + { + "weight": 83.4, + "sysBP": 128 + }, + { + "weight": 83.4, + "sysBP": 136 + }, + { + "weight": 83.3, + "sysBP": 129 + }, + { + "weight": 83.3, + "sysBP": 137 + }, + { + "weight": 83.2, + "sysBP": 130 + }, + { + "weight": 83.2, + "sysBP": 131 + }, + { + "weight": 83.1, + "sysBP": 124 + }, + { + "weight": 83.1, + "sysBP": 132 + }, + { + "weight": 83, + "sysBP": 125 + }, + { + "weight": 83, + "sysBP": 133 + }, + { + "weight": 83, + "sysBP": 126 + }, + { + "weight": 82.9, + "sysBP": 134 + }, + { + "weight": 82.9, + "sysBP": 127 + }, + { + "weight": 82.8, + "sysBP": 135 + }, + { + "weight": 82.8, + "sysBP": 128 + }, + { + "weight": 82.8, + "sysBP": 136 + }, + { + "weight": 82.7, + "sysBP": 129 + }, + { + "weight": 82.7, + "sysBP": 137 + }, + { + "weight": 82.6, + "sysBP": 130 + }, + { + "weight": 82.6, + "sysBP": 123 + }, + { + "weight": 82.6, + "sysBP": 131 + }, + { + "weight": 82.5, + "sysBP": 124 + }, + { + "weight": 82.5, + "sysBP": 132 + }, + { + "weight": 82.4, + "sysBP": 125 + }, + { + "weight": 82.4, + "sysBP": 133 + }, + { + "weight": 82.4, + "sysBP": 126 + }, + { + "weight": 82.3, + "sysBP": 134 + }, + { + "weight": 82.2, + "sysBP": 135 + }, + { + "weight": 82.2, + "sysBP": 128 + }, + { + "weight": 82.2, + "sysBP": 136 + }, + { + "weight": 82.1, + "sysBP": 129 + }, + { + "weight": 82.1, + "sysBP": 137 + }, + { + "weight": 82, + "sysBP": 130 + }, + { + "weight": 82, + "sysBP": 123 + } + ], + "bpMedicationCorrelation": null, + "bpMedicationScatterData": [ + { + "continuityPct": 100, + "sysBP": 131 + }, + { + "continuityPct": 100, + "sysBP": 124 + }, + { + "continuityPct": 0, + "sysBP": 132 + }, + { + "continuityPct": 100, + "sysBP": 125 + }, + { + "continuityPct": 100, + "sysBP": 133 + }, + { + "continuityPct": 100, + "sysBP": 126 + }, + { + "continuityPct": 100, + "sysBP": 134 + }, + { + "continuityPct": 100, + "sysBP": 127 + }, + { + "continuityPct": 0, + "sysBP": 128 + }, + { + "continuityPct": 100, + "sysBP": 136 + }, + { + "continuityPct": 100, + "sysBP": 129 + }, + { + "continuityPct": 100, + "sysBP": 137 + }, + { + "continuityPct": 100, + "sysBP": 130 + }, + { + "continuityPct": 100, + "sysBP": 131 + }, + { + "continuityPct": 0, + "sysBP": 124 + }, + { + "continuityPct": 100, + "sysBP": 125 + }, + { + "continuityPct": 100, + "sysBP": 133 + }, + { + "continuityPct": 100, + "sysBP": 126 + }, + { + "continuityPct": 100, + "sysBP": 134 + }, + { + "continuityPct": 100, + "sysBP": 127 + }, + { + "continuityPct": 0, + "sysBP": 135 + }, + { + "continuityPct": 100, + "sysBP": 128 + }, + { + "continuityPct": 100, + "sysBP": 136 + }, + { + "continuityPct": 100, + "sysBP": 137 + }, + { + "continuityPct": 100, + "sysBP": 130 + }, + { + "continuityPct": 100, + "sysBP": 123 + }, + { + "continuityPct": 0, + "sysBP": 131 + }, + { + "continuityPct": 100, + "sysBP": 124 + }, + { + "continuityPct": 100, + "sysBP": 132 + }, + { + "continuityPct": 100, + "sysBP": 125 + }, + { + "continuityPct": 100, + "sysBP": 133 + }, + { + "continuityPct": 100, + "sysBP": 134 + }, + { + "continuityPct": 100, + "sysBP": 135 + }, + { + "continuityPct": 100, + "sysBP": 128 + }, + { + "continuityPct": 100, + "sysBP": 136 + }, + { + "continuityPct": 100, + "sysBP": 129 + }, + { + "continuityPct": 100, + "sysBP": 137 + }, + { + "continuityPct": 100, + "sysBP": 130 + } + ], + "moodSummary": { + "count": 45, + "latest": 1, + "min": 1, + "max": 5, + "mean": 3, + "median": 3, + "avg7": 2.86, + "avg30": 3, + "slope7": { + "slope": -0.143, + "direction": "down", + "confidence": 0.04 + }, + "slope30": { + "slope": -0.013, + "direction": "down", + "confidence": 0.01 + }, + "anomalyCount": 0, + "avg30LastMonth": 3, + "avg30LastYear": null + }, + "moodBpCorrelation": null, + "moodBpScatterData": [ + { + "mood": 3, + "sysBP": 131 + }, + { + "mood": 5, + "sysBP": 124 + }, + { + "mood": 2, + "sysBP": 132 + }, + { + "mood": 4, + "sysBP": 125 + }, + { + "mood": 1, + "sysBP": 133 + }, + { + "mood": 3, + "sysBP": 126 + }, + { + "mood": 5, + "sysBP": 134 + }, + { + "mood": 2, + "sysBP": 127 + }, + { + "mood": 4, + "sysBP": 135 + }, + { + "mood": 1, + "sysBP": 128 + }, + { + "mood": 3, + "sysBP": 136 + }, + { + "mood": 5, + "sysBP": 129 + }, + { + "mood": 2, + "sysBP": 137 + }, + { + "mood": 4, + "sysBP": 130 + }, + { + "mood": 3, + "sysBP": 131 + }, + { + "mood": 5, + "sysBP": 124 + }, + { + "mood": 2, + "sysBP": 132 + }, + { + "mood": 4, + "sysBP": 125 + }, + { + "mood": 1, + "sysBP": 133 + }, + { + "mood": 3, + "sysBP": 126 + }, + { + "mood": 5, + "sysBP": 134 + }, + { + "mood": 2, + "sysBP": 127 + }, + { + "mood": 4, + "sysBP": 135 + }, + { + "mood": 1, + "sysBP": 128 + }, + { + "mood": 3, + "sysBP": 136 + }, + { + "mood": 5, + "sysBP": 129 + }, + { + "mood": 2, + "sysBP": 137 + }, + { + "mood": 4, + "sysBP": 130 + }, + { + "mood": 1, + "sysBP": 123 + }, + { + "mood": 3, + "sysBP": 131 + }, + { + "mood": 5, + "sysBP": 124 + }, + { + "mood": 2, + "sysBP": 132 + }, + { + "mood": 4, + "sysBP": 125 + }, + { + "mood": 1, + "sysBP": 133 + }, + { + "mood": 3, + "sysBP": 126 + }, + { + "mood": 5, + "sysBP": 134 + }, + { + "mood": 4, + "sysBP": 135 + }, + { + "mood": 1, + "sysBP": 128 + }, + { + "mood": 3, + "sysBP": 136 + }, + { + "mood": 5, + "sysBP": 129 + }, + { + "mood": 2, + "sysBP": 137 + }, + { + "mood": 4, + "sysBP": 130 + }, + { + "mood": 1, + "sysBP": 123 + } + ], + "moodWeightCorrelation": null, + "moodWeightScatterData": [ + { + "mood": 3, + "weight": 83.8 + }, + { + "mood": 5, + "weight": 83.7 + }, + { + "mood": 2, + "weight": 83.7 + }, + { + "mood": 4, + "weight": 83.6 + }, + { + "mood": 1, + "weight": 83.6 + }, + { + "mood": 3, + "weight": 83.6 + }, + { + "mood": 5, + "weight": 83.5 + }, + { + "mood": 2, + "weight": 83.5 + }, + { + "mood": 4, + "weight": 83.4 + }, + { + "mood": 1, + "weight": 83.4 + }, + { + "mood": 3, + "weight": 83.4 + }, + { + "mood": 5, + "weight": 83.3 + }, + { + "mood": 2, + "weight": 83.3 + }, + { + "mood": 4, + "weight": 83.2 + }, + { + "mood": 3, + "weight": 83.2 + }, + { + "mood": 5, + "weight": 83.1 + }, + { + "mood": 2, + "weight": 83.1 + }, + { + "mood": 4, + "weight": 83 + }, + { + "mood": 1, + "weight": 83 + }, + { + "mood": 3, + "weight": 83 + }, + { + "mood": 5, + "weight": 82.9 + }, + { + "mood": 2, + "weight": 82.9 + }, + { + "mood": 4, + "weight": 82.8 + }, + { + "mood": 1, + "weight": 82.8 + }, + { + "mood": 3, + "weight": 82.8 + }, + { + "mood": 5, + "weight": 82.7 + }, + { + "mood": 2, + "weight": 82.7 + }, + { + "mood": 4, + "weight": 82.6 + }, + { + "mood": 1, + "weight": 82.6 + }, + { + "mood": 3, + "weight": 82.6 + }, + { + "mood": 5, + "weight": 82.5 + }, + { + "mood": 2, + "weight": 82.5 + }, + { + "mood": 4, + "weight": 82.4 + }, + { + "mood": 1, + "weight": 82.4 + }, + { + "mood": 3, + "weight": 82.4 + }, + { + "mood": 5, + "weight": 82.3 + }, + { + "mood": 4, + "weight": 82.2 + }, + { + "mood": 1, + "weight": 82.2 + }, + { + "mood": 3, + "weight": 82.2 + }, + { + "mood": 5, + "weight": 82.1 + }, + { + "mood": 2, + "weight": 82.1 + }, + { + "mood": 4, + "weight": 82 + }, + { + "mood": 1, + "weight": 82 + } + ], + "moodPulseCorrelation": null, + "moodPulseScatterData": [ + { + "mood": 3, + "pulse": 61 + }, + { + "mood": 5, + "pulse": 60 + }, + { + "mood": 2, + "pulse": 59 + }, + { + "mood": 4, + "pulse": 58 + }, + { + "mood": 1, + "pulse": 61 + }, + { + "mood": 3, + "pulse": 60 + }, + { + "mood": 5, + "pulse": 59 + }, + { + "mood": 2, + "pulse": 58 + }, + { + "mood": 4, + "pulse": 61 + }, + { + "mood": 3, + "pulse": 59 + }, + { + "mood": 5, + "pulse": 58 + }, + { + "mood": 2, + "pulse": 61 + }, + { + "mood": 4, + "pulse": 60 + }, + { + "mood": 1, + "pulse": 59 + }, + { + "mood": 3, + "pulse": 58 + }, + { + "mood": 5, + "pulse": 61 + }, + { + "mood": 2, + "pulse": 60 + }, + { + "mood": 4, + "pulse": 59 + }, + { + "mood": 1, + "pulse": 58 + }, + { + "mood": 3, + "pulse": 61 + }, + { + "mood": 5, + "pulse": 60 + }, + { + "mood": 2, + "pulse": 59 + }, + { + "mood": 4, + "pulse": 58 + }, + { + "mood": 1, + "pulse": 61 + }, + { + "mood": 3, + "pulse": 60 + }, + { + "mood": 5, + "pulse": 59 + }, + { + "mood": 2, + "pulse": 58 + }, + { + "mood": 4, + "pulse": 61 + }, + { + "mood": 1, + "pulse": 60 + }, + { + "mood": 3, + "pulse": 59 + }, + { + "mood": 5, + "pulse": 58 + }, + { + "mood": 4, + "pulse": 60 + }, + { + "mood": 1, + "pulse": 59 + }, + { + "mood": 3, + "pulse": 58 + }, + { + "mood": 5, + "pulse": 61 + }, + { + "mood": 2, + "pulse": 60 + }, + { + "mood": 4, + "pulse": 59 + }, + { + "mood": 1, + "pulse": 58 + } + ], + "medications": [ + { + "id": "", + "name": "Ramipril", + "dose": "5mg", + "category": "BLOOD_PRESSURE", + "categoryLabel": null, + "compliance7": 100, + "compliance30": 76, + "streak": 0, + "taken7": 6, + "skipped7": 0, + "missed7": 0 + }, + { + "id": "", + "name": "Vitamin D", + "dose": "1000 IU", + "category": "OTHER", + "categoryLabel": null, + "compliance7": 43, + "compliance30": 47, + "streak": 1, + "taken7": 3, + "skipped7": 0, + "missed7": 4 + } + ], + "alerts": [ + { + "level": "info", + "title": "BMI in overweight range", + "message": "Your BMI of 25.9 is in the overweight range. Regular exercise and a balanced diet can help." + }, + { + "level": "warning", + "title": "Blood pressure often outside target range", + "message": "Only 11% of your readings are within the target range." + }, + { + "level": "success", + "title": "Excellent compliance: Ramipril", + "message": "100% compliance over the last 7 days. Keep it up!" + }, + { + "level": "warning", + "title": "Low compliance: Vitamin D", + "message": "Your 7-day compliance for Vitamin D is only 43%. Regular intake is important." + } + ], + "hasProvider": false, + "dataSpanDays": 90, + "totalMeasurements": 541, + "revalidating": false, + "ai": { + "briefing": { + "available": true, + "reason": null, + "onDeviceAllowed": true + }, + "statusText": { + "available": true, + "reason": null, + "onDeviceAllowed": true + } + } + }, + "error": null + }, + "events": [ + { + "status": 200, + "action": { + "name": "insights.comprehensive" + }, + "meta": { + "cache.analytics.key_hash": 3572442229, + "cache.analytics.outcome": "miss", + "insights": { + "slope_source": "rollup", + "anomaly_source": "live", + "regression_source": "rollup" + }, + "insights.sub_bpRaw_ms": "", + "insights.sub_buckets_ms": "", + "insights.sub_coverage_ms": "", + "insights.sub_firstAt_ms": "", + "insights.sub_latests_ms": "", + "insights.sub_narrow_ms": "", + "medications": 2, + "moodEntries": 45, + "totalMeasurements": 541 + }, + "warnings": null, + "error": null + } + ], + "effects": [ + { + "effect": "ensureUserRollupsFresh", + "args": [ + "" + ] + }, + { + "effect": "ensureUserMoodRollupsFresh", + "args": [ + "" + ] + } + ] +} diff --git a/tests/integration/routes/parity/__snapshots__/insights-comprehensive.08-read-delegate.json.snap b/tests/integration/routes/parity/__snapshots__/insights-comprehensive.08-read-delegate.json.snap new file mode 100644 index 000000000..b4e2a65a9 --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/insights-comprehensive.08-read-delegate.json.snap @@ -0,0 +1,26 @@ +{ + "status": 403, + "body": { + "data": null, + "error": "Account access denied", + "meta": { + "errorCode": "sharing.access.denied", + "requestId": "" + } + }, + "events": [ + { + "status": 403, + "action": null, + "meta": { + "sharing_refusal": "insufficient_access" + }, + "warnings": null, + "error": { + "type": "SharingAccessDeniedError", + "message": "Account access denied" + } + } + ], + "effects": [] +} diff --git a/tests/integration/routes/parity/__snapshots__/insights-comprehensive.09-rate-limited.json.snap b/tests/integration/routes/parity/__snapshots__/insights-comprehensive.09-rate-limited.json.snap new file mode 100644 index 000000000..6f6b705b2 --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/insights-comprehensive.09-rate-limited.json.snap @@ -0,0 +1,21 @@ +{ + "status": 429, + "body": { + "data": null, + "error": "Too many analytics requests. Please retry later.", + "meta": { + "errorCode": "http.rate_limited", + "requestId": "" + } + }, + "events": [ + { + "status": 429, + "action": null, + "meta": null, + "warnings": null, + "error": null + } + ], + "effects": [] +} diff --git a/tests/integration/routes/parity/__snapshots__/insights-comprehensive.10-sparse.json.snap b/tests/integration/routes/parity/__snapshots__/insights-comprehensive.10-sparse.json.snap new file mode 100644 index 000000000..d2362f2ef --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/insights-comprehensive.10-sparse.json.snap @@ -0,0 +1,884 @@ +{ + "status": 200, + "body": { + "data": { + "summaries": { + "WEIGHT": { + "count": 47, + "latest": 70.3, + "min": 70, + "max": 71.2, + "mean": 70.63, + "median": null, + "avg7": 70.55, + "avg30": 70.62, + "slope7": { + "slope": 0.043, + "direction": "up", + "confidence": 0.03 + }, + "slope30": { + "slope": -0.004, + "direction": "stable", + "confidence": 0.01 + }, + "anomalyCount": 0, + "avg30LastMonth": 70.63, + "avg30LastYear": null + }, + "BLOOD_PRESSURE_SYS": { + "count": 47, + "latest": 134, + "min": 131, + "max": 163, + "mean": 139.66, + "median": null, + "avg7": 139.67, + "avg30": 139.93, + "slope7": { + "slope": -2.057, + "direction": "down", + "confidence": 0.18 + }, + "slope30": { + "slope": -0.03, + "direction": "down", + "confidence": 0 + }, + "anomalyCount": 4, + "avg30LastMonth": 139.26, + "avg30LastYear": null + }, + "BLOOD_PRESSURE_DIA": { + "count": 47, + "latest": 86, + "min": 84, + "max": 90, + "mean": 87.04, + "median": null, + "avg7": 87.5, + "avg30": 87.07, + "slope7": { + "slope": -0.2, + "direction": "down", + "confidence": 0.04 + }, + "slope30": { + "slope": 0.034, + "direction": "up", + "confidence": 0.02 + }, + "anomalyCount": 0, + "avg30LastMonth": 87, + "avg30LastYear": null + }, + "PULSE": { + "count": 141, + "latest": 93, + "min": 66, + "max": 96, + "mean": 81.67, + "median": null, + "avg7": 81.61, + "avg30": 81.61, + "slope7": { + "slope": 0.186, + "direction": "up", + "confidence": 0 + }, + "slope30": { + "slope": 0.027, + "direction": "up", + "confidence": 0 + }, + "anomalyCount": 0, + "avg30LastMonth": 81.75, + "avg30LastYear": null + } + }, + "bmi": null, + "bmiClassification": null, + "bpClassification": { + "category": "High-normal", + "color": "var(--dracula-yellow)", + "severity": "elevated" + }, + "bpPctInTarget": 0, + "bpTargets": { + "sysLow": 130, + "sysHigh": 139, + "diaLow": 70, + "diaHigh": 79 + }, + "weightBpCorrelation": { + "r": -0.631, + "strength": "moderat", + "n": 47 + }, + "scatterData": [ + { + "weight": 71.2, + "sysBP": 135 + }, + { + "weight": 70.9, + "sysBP": 132 + }, + { + "weight": 70.6, + "sysBP": 140 + }, + { + "weight": 70.3, + "sysBP": 137 + }, + { + "weight": 70, + "sysBP": 156 + }, + { + "weight": 71.2, + "sysBP": 131 + }, + { + "weight": 70.9, + "sysBP": 139 + }, + { + "weight": 70.6, + "sysBP": 136 + }, + { + "weight": 70.3, + "sysBP": 133 + }, + { + "weight": 70, + "sysBP": 163 + }, + { + "weight": 71.2, + "sysBP": 138 + }, + { + "weight": 70.9, + "sysBP": 135 + }, + { + "weight": 70.6, + "sysBP": 132 + }, + { + "weight": 70.3, + "sysBP": 140 + }, + { + "weight": 70, + "sysBP": 159 + }, + { + "weight": 71.2, + "sysBP": 134 + }, + { + "weight": 70.9, + "sysBP": 131 + }, + { + "weight": 70.6, + "sysBP": 139 + }, + { + "weight": 70.3, + "sysBP": 136 + }, + { + "weight": 71.2, + "sysBP": 141 + }, + { + "weight": 70.9, + "sysBP": 138 + }, + { + "weight": 70.6, + "sysBP": 135 + }, + { + "weight": 70.3, + "sysBP": 132 + }, + { + "weight": 70, + "sysBP": 162 + }, + { + "weight": 71.2, + "sysBP": 137 + }, + { + "weight": 70.9, + "sysBP": 134 + }, + { + "weight": 70.6, + "sysBP": 131 + }, + { + "weight": 70.3, + "sysBP": 139 + }, + { + "weight": 70, + "sysBP": 158 + }, + { + "weight": 71.2, + "sysBP": 133 + }, + { + "weight": 70.9, + "sysBP": 141 + }, + { + "weight": 70.6, + "sysBP": 138 + }, + { + "weight": 70.3, + "sysBP": 135 + }, + { + "weight": 70, + "sysBP": 154 + }, + { + "weight": 71.2, + "sysBP": 140 + }, + { + "weight": 70.9, + "sysBP": 137 + }, + { + "weight": 70.6, + "sysBP": 134 + }, + { + "weight": 70.3, + "sysBP": 131 + }, + { + "weight": 70, + "sysBP": 161 + }, + { + "weight": 71.2, + "sysBP": 136 + }, + { + "weight": 70.9, + "sysBP": 133 + }, + { + "weight": 70.3, + "sysBP": 138 + }, + { + "weight": 70, + "sysBP": 157 + }, + { + "weight": 71.2, + "sysBP": 132 + }, + { + "weight": 70.9, + "sysBP": 140 + }, + { + "weight": 70.6, + "sysBP": 137 + }, + { + "weight": 70.3, + "sysBP": 134 + } + ], + "bpMedicationCorrelation": { + "r": -0.941, + "strength": "stark", + "n": 28, + "medicationCount": 1 + }, + "bpMedicationScatterData": [ + { + "continuityPct": 100, + "sysBP": 138 + }, + { + "continuityPct": 100, + "sysBP": 135 + }, + { + "continuityPct": 100, + "sysBP": 132 + }, + { + "continuityPct": 0, + "sysBP": 159 + }, + { + "continuityPct": 100, + "sysBP": 134 + }, + { + "continuityPct": 100, + "sysBP": 131 + }, + { + "continuityPct": 100, + "sysBP": 136 + }, + { + "continuityPct": 100, + "sysBP": 141 + }, + { + "continuityPct": 100, + "sysBP": 135 + }, + { + "continuityPct": 100, + "sysBP": 132 + }, + { + "continuityPct": 0, + "sysBP": 162 + }, + { + "continuityPct": 100, + "sysBP": 134 + }, + { + "continuityPct": 100, + "sysBP": 131 + }, + { + "continuityPct": 100, + "sysBP": 139 + }, + { + "continuityPct": 100, + "sysBP": 133 + }, + { + "continuityPct": 100, + "sysBP": 141 + }, + { + "continuityPct": 100, + "sysBP": 138 + }, + { + "continuityPct": 0, + "sysBP": 154 + }, + { + "continuityPct": 100, + "sysBP": 140 + }, + { + "continuityPct": 100, + "sysBP": 137 + }, + { + "continuityPct": 100, + "sysBP": 131 + }, + { + "continuityPct": 0, + "sysBP": 161 + }, + { + "continuityPct": 100, + "sysBP": 136 + }, + { + "continuityPct": 100, + "sysBP": 138 + }, + { + "continuityPct": 0, + "sysBP": 157 + }, + { + "continuityPct": 100, + "sysBP": 140 + }, + { + "continuityPct": 100, + "sysBP": 137 + }, + { + "continuityPct": 100, + "sysBP": 134 + } + ], + "moodSummary": { + "count": 29, + "latest": 3, + "min": 1, + "max": 5, + "mean": 3.07, + "median": 3, + "avg7": 3, + "avg30": 3.04, + "slope7": { + "slope": 0.286, + "direction": "up", + "confidence": 0.14 + }, + "slope30": { + "slope": 0, + "direction": "stable", + "confidence": 0 + }, + "anomalyCount": 0, + "avg30LastMonth": 4, + "avg30LastYear": null + }, + "moodBpCorrelation": { + "r": -0.683, + "strength": "moderat", + "n": 28 + }, + "moodBpScatterData": [ + { + "mood": 4, + "sysBP": 141 + }, + { + "mood": 2, + "sysBP": 138 + }, + { + "mood": 5, + "sysBP": 135 + }, + { + "mood": 3, + "sysBP": 132 + }, + { + "mood": 1, + "sysBP": 162 + }, + { + "mood": 4, + "sysBP": 137 + }, + { + "mood": 2, + "sysBP": 134 + }, + { + "mood": 5, + "sysBP": 131 + }, + { + "mood": 3, + "sysBP": 139 + }, + { + "mood": 1, + "sysBP": 158 + }, + { + "mood": 4, + "sysBP": 133 + }, + { + "mood": 2, + "sysBP": 141 + }, + { + "mood": 5, + "sysBP": 138 + }, + { + "mood": 3, + "sysBP": 135 + }, + { + "mood": 1, + "sysBP": 154 + }, + { + "mood": 4, + "sysBP": 140 + }, + { + "mood": 2, + "sysBP": 137 + }, + { + "mood": 5, + "sysBP": 134 + }, + { + "mood": 3, + "sysBP": 131 + }, + { + "mood": 1, + "sysBP": 161 + }, + { + "mood": 4, + "sysBP": 136 + }, + { + "mood": 2, + "sysBP": 133 + }, + { + "mood": 3, + "sysBP": 138 + }, + { + "mood": 1, + "sysBP": 157 + }, + { + "mood": 4, + "sysBP": 132 + }, + { + "mood": 2, + "sysBP": 140 + }, + { + "mood": 5, + "sysBP": 137 + }, + { + "mood": 3, + "sysBP": 134 + } + ], + "moodWeightCorrelation": { + "r": 0.482, + "strength": "moderat", + "n": 28 + }, + "moodWeightScatterData": [ + { + "mood": 4, + "weight": 71.2 + }, + { + "mood": 2, + "weight": 70.9 + }, + { + "mood": 5, + "weight": 70.6 + }, + { + "mood": 3, + "weight": 70.3 + }, + { + "mood": 1, + "weight": 70 + }, + { + "mood": 4, + "weight": 71.2 + }, + { + "mood": 2, + "weight": 70.9 + }, + { + "mood": 5, + "weight": 70.6 + }, + { + "mood": 3, + "weight": 70.3 + }, + { + "mood": 1, + "weight": 70 + }, + { + "mood": 4, + "weight": 71.2 + }, + { + "mood": 2, + "weight": 70.9 + }, + { + "mood": 5, + "weight": 70.6 + }, + { + "mood": 3, + "weight": 70.3 + }, + { + "mood": 1, + "weight": 70 + }, + { + "mood": 4, + "weight": 71.2 + }, + { + "mood": 2, + "weight": 70.9 + }, + { + "mood": 5, + "weight": 70.6 + }, + { + "mood": 3, + "weight": 70.3 + }, + { + "mood": 1, + "weight": 70 + }, + { + "mood": 4, + "weight": 71.2 + }, + { + "mood": 2, + "weight": 70.9 + }, + { + "mood": 3, + "weight": 70.3 + }, + { + "mood": 1, + "weight": 70 + }, + { + "mood": 4, + "weight": 71.2 + }, + { + "mood": 2, + "weight": 70.9 + }, + { + "mood": 5, + "weight": 70.6 + }, + { + "mood": 3, + "weight": 70.3 + } + ], + "moodPulseCorrelation": null, + "moodPulseScatterData": [ + { + "mood": 2, + "pulse": 72 + }, + { + "mood": 5, + "pulse": 71 + }, + { + "mood": 3, + "pulse": 78 + }, + { + "mood": 1, + "pulse": 77 + }, + { + "mood": 4, + "pulse": 76 + }, + { + "mood": 2, + "pulse": 75 + }, + { + "mood": 5, + "pulse": 74 + }, + { + "mood": 3, + "pulse": 73 + }, + { + "mood": 1, + "pulse": 72 + }, + { + "mood": 4, + "pulse": 74 + }, + { + "mood": 2, + "pulse": 73 + }, + { + "mood": 5, + "pulse": 77 + }, + { + "mood": 3, + "pulse": 76 + }, + { + "mood": 1, + "pulse": 75 + }, + { + "mood": 4, + "pulse": 74 + }, + { + "mood": 2, + "pulse": 73 + }, + { + "mood": 5, + "pulse": 75 + }, + { + "mood": 3, + "pulse": 74 + }, + { + "mood": 1, + "pulse": 73 + }, + { + "mood": 4, + "pulse": 72 + }, + { + "mood": 2, + "pulse": 76 + }, + { + "mood": 1, + "pulse": 76 + }, + { + "mood": 4, + "pulse": 75 + }, + { + "mood": 2, + "pulse": 74 + }, + { + "mood": 5, + "pulse": 73 + }, + { + "mood": 3, + "pulse": 72 + } + ], + "medications": [ + { + "id": "", + "name": "Amlodipine", + "dose": "5mg", + "category": "BLOOD_PRESSURE", + "categoryLabel": null, + "compliance7": 67, + "compliance30": 65, + "streak": 4, + "taken7": 4, + "skipped7": 1, + "missed7": 2 + } + ], + "alerts": [ + { + "level": "warning", + "title": "Blood pressure often outside target range", + "message": "Only 0% of your readings are within the target range." + }, + { + "level": "warning", + "title": "Low compliance: Amlodipine", + "message": "Your 7-day compliance for Amlodipine is only 67%. Regular intake is important." + } + ], + "hasProvider": false, + "dataSpanDays": 50, + "totalMeasurements": 282, + "revalidating": false, + "ai": { + "briefing": { + "available": false, + "reason": "no_provider", + "onDeviceAllowed": true + }, + "statusText": { + "available": false, + "reason": "no_provider", + "onDeviceAllowed": true + } + } + }, + "error": null + }, + "events": [ + { + "status": 200, + "action": { + "name": "insights.comprehensive" + }, + "meta": { + "cache.analytics.key_hash": 2016842294, + "cache.analytics.outcome": "miss", + "insights": { + "slope_source": "rollup", + "anomaly_source": "live", + "regression_source": "rollup" + }, + "insights.sub_bpRaw_ms": "", + "insights.sub_buckets_ms": "", + "insights.sub_coverage_ms": "", + "insights.sub_firstAt_ms": "", + "insights.sub_latests_ms": "", + "insights.sub_narrow_ms": "", + "medications": 1, + "moodEntries": 29, + "totalMeasurements": 282 + }, + "warnings": null, + "error": null + } + ], + "effects": [ + { + "effect": "ensureUserRollupsFresh", + "args": [ + "" + ] + }, + { + "effect": "ensureUserMoodRollupsFresh", + "args": [ + "" + ] + } + ] +} diff --git a/tests/integration/routes/parity/__snapshots__/insights-comprehensive.11-empty.json.snap b/tests/integration/routes/parity/__snapshots__/insights-comprehensive.11-empty.json.snap new file mode 100644 index 000000000..ecaa73274 --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/insights-comprehensive.11-empty.json.snap @@ -0,0 +1,79 @@ +{ + "status": 200, + "body": { + "data": { + "summaries": {}, + "bmi": null, + "bmiClassification": null, + "bpClassification": null, + "bpPctInTarget": null, + "bpTargets": null, + "weightBpCorrelation": null, + "scatterData": [], + "bpMedicationCorrelation": null, + "bpMedicationScatterData": [], + "moodSummary": null, + "moodBpCorrelation": null, + "moodBpScatterData": [], + "moodWeightCorrelation": null, + "moodWeightScatterData": [], + "moodPulseCorrelation": null, + "moodPulseScatterData": [], + "medications": [], + "alerts": [], + "hasProvider": false, + "dataSpanDays": 0, + "totalMeasurements": 0, + "revalidating": false, + "ai": { + "briefing": { + "available": false, + "reason": "no_provider", + "onDeviceAllowed": true + }, + "statusText": { + "available": false, + "reason": "no_provider", + "onDeviceAllowed": true + } + } + }, + "error": null + }, + "events": [ + { + "status": 200, + "action": { + "name": "insights.comprehensive" + }, + "meta": { + "cache.analytics.key_hash": 461242359, + "cache.analytics.outcome": "miss", + "insights.sub_bpRaw_ms": "", + "insights.sub_buckets_ms": "", + "insights.sub_coverage_ms": "", + "insights.sub_heavy_ms": "", + "insights.sub_latests_ms": "", + "medications": 0, + "moodEntries": 0, + "totalMeasurements": 0 + }, + "warnings": null, + "error": null + } + ], + "effects": [ + { + "effect": "ensureUserRollupsFresh", + "args": [ + "" + ] + }, + { + "effect": "ensureUserMoodRollupsFresh", + "args": [ + "" + ] + } + ] +} diff --git a/tests/integration/routes/parity/__snapshots__/insights-generate.G01-anonymous.json.snap b/tests/integration/routes/parity/__snapshots__/insights-generate.G01-anonymous.json.snap new file mode 100644 index 000000000..694a6f447 --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/insights-generate.G01-anonymous.json.snap @@ -0,0 +1,32 @@ +{ + "status": 401, + "body": { + "data": null, + "error": "Not authenticated", + "meta": { + "errorCode": "auth.missing", + "requestId": "" + } + }, + "events": [ + { + "status": 401, + "action": null, + "meta": null, + "warnings": null, + "error": { + "type": "HttpError", + "message": "Not authenticated" + } + } + ], + "effects": [], + "stored": { + "insightsCachedAt": null, + "insightsCachedLocale": null, + "cachedPayload": null, + "snapshotHash": null, + "audit": [], + "usage": [] + } +} diff --git a/tests/integration/routes/parity/__snapshots__/insights-generate.G02-opted-out.json.snap b/tests/integration/routes/parity/__snapshots__/insights-generate.G02-opted-out.json.snap new file mode 100644 index 000000000..b234a3fe3 --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/insights-generate.G02-opted-out.json.snap @@ -0,0 +1,35 @@ +{ + "status": 403, + "body": { + "data": null, + "error": "This AI feature is turned off in your settings", + "meta": { + "capability": "briefing", + "reason": "user_disabled", + "errorCode": "module.disabled", + "module": "insights", + "requestId": "" + } + }, + "events": [ + { + "status": 403, + "action": null, + "meta": null, + "warnings": null, + "error": { + "type": "AiUnavailableError", + "message": "This AI feature is turned off in your settings" + } + } + ], + "effects": [], + "stored": { + "insightsCachedAt": null, + "insightsCachedLocale": null, + "cachedPayload": null, + "snapshotHash": null, + "audit": [], + "usage": [] + } +} diff --git a/tests/integration/routes/parity/__snapshots__/insights-generate.G03-empty.json.snap b/tests/integration/routes/parity/__snapshots__/insights-generate.G03-empty.json.snap new file mode 100644 index 000000000..0ef9af169 --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/insights-generate.G03-empty.json.snap @@ -0,0 +1,52 @@ +{ + "status": 200, + "body": { + "data": { + "insights": null, + "cached": false, + "legacyPayload": false, + "revalidating": true, + "hasProvider": true, + "generationFailed": false, + "generationFailureClass": null, + "briefingOmittedReason": null + }, + "error": null + }, + "events": [ + { + "status": 200, + "action": { + "name": "insights.generate.read" + }, + "meta": { + "cached": false, + "hasProvider": true, + "languageMismatch": false, + "readerLocale": "en", + "revalidating": true + }, + "warnings": null, + "error": null + } + ], + "effects": [ + { + "effect": "enqueueForceWarm", + "args": [ + { + "userId": "", + "locale": "en" + } + ] + } + ], + "stored": { + "insightsCachedAt": null, + "insightsCachedLocale": null, + "cachedPayload": null, + "snapshotHash": null, + "audit": [], + "usage": [] + } +} diff --git a/tests/integration/routes/parity/__snapshots__/insights-generate.G04-fresh.json.snap b/tests/integration/routes/parity/__snapshots__/insights-generate.G04-fresh.json.snap new file mode 100644 index 000000000..0798c38b0 --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/insights-generate.G04-fresh.json.snap @@ -0,0 +1,92 @@ +{ + "status": 200, + "body": { + "data": { + "insights": { + "summary": "Cached summary.", + "classification": "gut", + "findings": [], + "correlations": [], + "recommendations": [], + "dataQuality": { + "coverage": "good", + "gaps": [], + "confidence": "hoch" + }, + "dailyBriefing": { + "paragraph": "Your readings held steady this week, with no surprises.", + "signalsOfDay": [ + { + "sourceMetric": "weight", + "tone": "info", + "headline": "Weight is steady", + "nudge": "Keep weighing in at the same time each morning.", + "delta": null + } + ], + "keyFindings": [] + }, + "briefingGeneratedAt": "anchor+0d+11:00:00" + }, + "cached": true, + "cachedAt": "anchor+0d+11:00:00", + "legacyPayload": false, + "revalidating": false, + "hasProvider": true, + "generationFailed": false, + "generationFailureClass": null, + "briefingOmittedReason": null + }, + "error": null + }, + "events": [ + { + "status": 200, + "action": { + "name": "insights.generate.read" + }, + "meta": { + "cached": true, + "hasProvider": true, + "legacyPayload": false, + "revalidating": false + }, + "warnings": null, + "error": null + } + ], + "effects": [], + "stored": { + "insightsCachedAt": "anchor+0d+11:00:00", + "insightsCachedLocale": "en", + "cachedPayload": { + "summary": "Cached summary.", + "classification": "gut", + "findings": [], + "correlations": [], + "recommendations": [], + "dataQuality": { + "coverage": "good", + "gaps": [], + "confidence": "hoch" + }, + "dailyBriefing": { + "paragraph": "Your readings held steady this week, with no surprises.", + "signalsOfDay": [ + { + "sourceMetric": "weight", + "tone": "info", + "headline": "Weight is steady", + "nudge": "Keep weighing in at the same time each morning.", + "delta": null + } + ], + "keyFindings": [] + }, + "briefingGeneratedAt": "anchor+0d+11:00:00" + }, + "snapshotHash": null, + "audit": [], + "usage": [] + } +} diff --git a/tests/integration/routes/parity/__snapshots__/insights-generate.G05-stale.json.snap b/tests/integration/routes/parity/__snapshots__/insights-generate.G05-stale.json.snap new file mode 100644 index 000000000..fe446ac21 --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/insights-generate.G05-stale.json.snap @@ -0,0 +1,90 @@ +{ + "status": 200, + "body": { + "data": { + "insights": { + "summary": "Cached summary.", + "classification": "gut", + "findings": [], + "correlations": [], + "recommendations": [], + "dataQuality": { + "coverage": "good", + "gaps": [], + "confidence": "hoch" + }, + "dailyBriefing": null, + "briefingGeneratedAt": "anchor-1d+16:00:00" + }, + "cached": true, + "cachedAt": "anchor+0d+10:00:00", + "legacyPayload": false, + "revalidating": true, + "hasProvider": true, + "generationFailed": false, + "generationFailureClass": null, + "briefingOmittedReason": null + }, + "error": null + }, + "events": [ + { + "status": 200, + "action": { + "name": "insights.generate.read" + }, + "meta": { + "cached": true, + "hasProvider": true, + "legacyPayload": false, + "revalidating": true + }, + "warnings": null, + "error": null + } + ], + "effects": [ + { + "effect": "enqueueForceWarm", + "args": [ + { + "userId": "", + "locale": "en" + } + ] + } + ], + "stored": { + "insightsCachedAt": "anchor+0d+10:00:00", + "insightsCachedLocale": "en", + "cachedPayload": { + "summary": "Cached summary.", + "classification": "gut", + "findings": [], + "correlations": [], + "recommendations": [], + "dataQuality": { + "coverage": "good", + "gaps": [], + "confidence": "hoch" + }, + "dailyBriefing": { + "paragraph": "Your readings held steady this week, with no surprises.", + "signalsOfDay": [ + { + "sourceMetric": "weight", + "tone": "info", + "headline": "Weight is steady", + "nudge": "Keep weighing in at the same time each morning.", + "delta": null + } + ], + "keyFindings": [] + }, + "briefingGeneratedAt": "anchor-1d+16:00:00" + }, + "snapshotHash": null, + "audit": [], + "usage": [] + } +} diff --git a/tests/integration/routes/parity/__snapshots__/insights-generate.G06-other-language.json.snap b/tests/integration/routes/parity/__snapshots__/insights-generate.G06-other-language.json.snap new file mode 100644 index 000000000..5e2b774d1 --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/insights-generate.G06-other-language.json.snap @@ -0,0 +1,77 @@ +{ + "status": 200, + "body": { + "data": { + "insights": null, + "cached": false, + "legacyPayload": false, + "revalidating": true, + "hasProvider": true, + "generationFailed": false, + "generationFailureClass": null, + "briefingOmittedReason": null + }, + "error": null + }, + "events": [ + { + "status": 200, + "action": { + "name": "insights.generate.read" + }, + "meta": { + "cached": false, + "hasProvider": true, + "languageMismatch": true, + "readerLocale": "en", + "revalidating": true + }, + "warnings": null, + "error": null + } + ], + "effects": [ + { + "effect": "enqueueForceWarm", + "args": [ + { + "userId": "", + "locale": "en" + } + ] + } + ], + "stored": { + "insightsCachedAt": "anchor+0d+11:00:00", + "insightsCachedLocale": "de", + "cachedPayload": { + "summary": "Cached summary.", + "classification": "gut", + "findings": [], + "correlations": [], + "recommendations": [], + "dataQuality": { + "coverage": "good", + "gaps": [], + "confidence": "hoch" + }, + "dailyBriefing": { + "paragraph": "Your readings held steady this week, with no surprises.", + "signalsOfDay": [ + { + "sourceMetric": "weight", + "tone": "info", + "headline": "Weight is steady", + "nudge": "Keep weighing in at the same time each morning.", + "delta": null + } + ], + "keyFindings": [] + }, + "briefingGeneratedAt": "anchor+0d+11:00:00" + }, + "snapshotHash": null, + "audit": [], + "usage": [] + } +} diff --git a/tests/integration/routes/parity/__snapshots__/insights-generate.G07-unparseable.json.snap b/tests/integration/routes/parity/__snapshots__/insights-generate.G07-unparseable.json.snap new file mode 100644 index 000000000..c09a6729d --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/insights-generate.G07-unparseable.json.snap @@ -0,0 +1,52 @@ +{ + "status": 200, + "body": { + "data": { + "insights": null, + "cached": false, + "legacyPayload": false, + "revalidating": true, + "hasProvider": true, + "generationFailed": false, + "generationFailureClass": null, + "briefingOmittedReason": null + }, + "error": null + }, + "events": [ + { + "status": 200, + "action": { + "name": "insights.generate.read" + }, + "meta": { + "cached": false, + "hasProvider": true, + "languageMismatch": false, + "readerLocale": "en", + "revalidating": true + }, + "warnings": null, + "error": null + } + ], + "effects": [ + { + "effect": "enqueueForceWarm", + "args": [ + { + "userId": "", + "locale": "en" + } + ] + } + ], + "stored": { + "insightsCachedAt": "anchor+0d+11:00:00", + "insightsCachedLocale": null, + "cachedPayload": "{not json", + "snapshotHash": null, + "audit": [], + "usage": [] + } +} diff --git a/tests/integration/routes/parity/__snapshots__/insights-generate.G08-legacy.json.snap b/tests/integration/routes/parity/__snapshots__/insights-generate.G08-legacy.json.snap new file mode 100644 index 000000000..96cf8ba74 --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/insights-generate.G08-legacy.json.snap @@ -0,0 +1,62 @@ +{ + "status": 200, + "body": { + "data": { + "insights": { + "summary": "Legacy", + "recommendations": [ + "Drink water." + ] + }, + "cached": true, + "cachedAt": "anchor+0d+11:00:00", + "legacyPayload": true, + "revalidating": true, + "hasProvider": true, + "generationFailed": false, + "generationFailureClass": null, + "briefingOmittedReason": null + }, + "error": null + }, + "events": [ + { + "status": 200, + "action": { + "name": "insights.generate.read" + }, + "meta": { + "cached": true, + "hasProvider": true, + "legacyPayload": true, + "revalidating": true + }, + "warnings": null, + "error": null + } + ], + "effects": [ + { + "effect": "enqueueForceWarm", + "args": [ + { + "userId": "", + "locale": "en" + } + ] + } + ], + "stored": { + "insightsCachedAt": "anchor+0d+11:00:00", + "insightsCachedLocale": null, + "cachedPayload": { + "summary": "Legacy", + "recommendations": [ + "Drink water." + ] + }, + "snapshotHash": null, + "audit": [], + "usage": [] + } +} diff --git a/tests/integration/routes/parity/__snapshots__/insights-generate.G09-held-after-failure.json.snap b/tests/integration/routes/parity/__snapshots__/insights-generate.G09-held-after-failure.json.snap new file mode 100644 index 000000000..bb82dddbc --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/insights-generate.G09-held-after-failure.json.snap @@ -0,0 +1,102 @@ +{ + "status": 200, + "body": { + "data": { + "insights": { + "summary": "Cached summary.", + "classification": "gut", + "findings": [], + "correlations": [], + "recommendations": [], + "dataQuality": { + "coverage": "good", + "gaps": [], + "confidence": "hoch" + }, + "dailyBriefing": { + "paragraph": "Your readings held steady this week, with no surprises.", + "signalsOfDay": [ + { + "sourceMetric": "weight", + "tone": "info", + "headline": "Weight is steady", + "nudge": "Keep weighing in at the same time each morning.", + "delta": null + } + ], + "keyFindings": [] + }, + "briefingGeneratedAt": "anchor+0d+09:00:00" + }, + "cached": true, + "cachedAt": "anchor+0d+09:00:00", + "legacyPayload": false, + "revalidating": false, + "hasProvider": true, + "generationFailed": true, + "generationFailureClass": "rate-limit", + "briefingOmittedReason": null + }, + "error": null + }, + "events": [ + { + "status": 200, + "action": { + "name": "insights.generate.read" + }, + "meta": { + "cached": true, + "hasProvider": true, + "legacyPayload": false, + "revalidating": false + }, + "warnings": null, + "error": null + } + ], + "effects": [], + "stored": { + "insightsCachedAt": "anchor+0d+09:00:00", + "insightsCachedLocale": "en", + "cachedPayload": { + "summary": "Cached summary.", + "classification": "gut", + "findings": [], + "correlations": [], + "recommendations": [], + "dataQuality": { + "coverage": "good", + "gaps": [], + "confidence": "hoch" + }, + "dailyBriefing": { + "paragraph": "Your readings held steady this week, with no surprises.", + "signalsOfDay": [ + { + "sourceMetric": "weight", + "tone": "info", + "headline": "Weight is steady", + "nudge": "Keep weighing in at the same time each morning.", + "delta": null + } + ], + "keyFindings": [] + }, + "briefingGeneratedAt": "anchor+0d+09:00:00" + }, + "snapshotHash": null, + "audit": [ + { + "action": "insights.briefing-failure", + "details": { + "reason": "all-providers-failed", + "httpStatus": 429, + "failureKind": null + }, + "ipAddress": null + } + ], + "usage": [] + } +} diff --git a/tests/integration/routes/parity/__snapshots__/insights-generate.G10-failed-empty.json.snap b/tests/integration/routes/parity/__snapshots__/insights-generate.G10-failed-empty.json.snap new file mode 100644 index 000000000..446fc6618 --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/insights-generate.G10-failed-empty.json.snap @@ -0,0 +1,71 @@ +{ + "status": 200, + "body": { + "data": { + "insights": null, + "cached": false, + "legacyPayload": false, + "revalidating": true, + "hasProvider": true, + "generationFailed": true, + "generationFailureClass": "format", + "briefingOmittedReason": null + }, + "error": null + }, + "events": [ + { + "status": 200, + "action": { + "name": "insights.generate.read" + }, + "meta": { + "cached": false, + "hasProvider": true, + "languageMismatch": false, + "readerLocale": "en", + "revalidating": true + }, + "warnings": null, + "error": null + } + ], + "effects": [ + { + "effect": "enqueueForceWarm", + "args": [ + { + "userId": "", + "locale": "en" + } + ] + } + ], + "stored": { + "insightsCachedAt": null, + "insightsCachedLocale": null, + "cachedPayload": null, + "snapshotHash": null, + "audit": [ + { + "action": "insights.briefing-failure", + "details": { + "reason": "provider-error", + "httpStatus": null, + "failureKind": null + }, + "ipAddress": null + }, + { + "action": "insights.briefing-failure", + "details": { + "reason": "invalid-json", + "httpStatus": null, + "failureKind": null + }, + "ipAddress": null + } + ], + "usage": [] + } +} diff --git a/tests/integration/routes/parity/__snapshots__/insights-generate.G11-withheld.json.snap b/tests/integration/routes/parity/__snapshots__/insights-generate.G11-withheld.json.snap new file mode 100644 index 000000000..82ae9ed31 --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/insights-generate.G11-withheld.json.snap @@ -0,0 +1,78 @@ +{ + "status": 200, + "body": { + "data": { + "insights": { + "summary": "Cached summary.", + "classification": "gut", + "findings": [], + "correlations": [], + "recommendations": [], + "dataQuality": { + "coverage": "good", + "gaps": [], + "confidence": "hoch" + }, + "dailyBriefing": null, + "briefingGeneratedAt": "anchor+0d+10:00:00" + }, + "cached": true, + "cachedAt": "anchor+0d+10:00:00", + "legacyPayload": false, + "revalidating": false, + "hasProvider": true, + "generationFailed": false, + "generationFailureClass": null, + "briefingOmittedReason": "ungrounded" + }, + "error": null + }, + "events": [ + { + "status": 200, + "action": { + "name": "insights.generate.read" + }, + "meta": { + "cached": true, + "hasProvider": true, + "legacyPayload": false, + "revalidating": false + }, + "warnings": null, + "error": null + } + ], + "effects": [], + "stored": { + "insightsCachedAt": "anchor+0d+10:00:00", + "insightsCachedLocale": "en", + "cachedPayload": { + "summary": "Cached summary.", + "classification": "gut", + "findings": [], + "correlations": [], + "recommendations": [], + "dataQuality": { + "coverage": "good", + "gaps": [], + "confidence": "hoch" + }, + "dailyBriefing": null, + "briefingGeneratedAt": "anchor+0d+10:00:00" + }, + "snapshotHash": null, + "audit": [ + { + "action": "insights.briefing-failure", + "details": { + "reason": "briefing-ungrounded", + "httpStatus": null, + "failureKind": null + }, + "ipAddress": null + } + ], + "usage": [] + } +} diff --git a/tests/integration/routes/parity/__snapshots__/insights-generate.P01-anonymous.json.snap b/tests/integration/routes/parity/__snapshots__/insights-generate.P01-anonymous.json.snap new file mode 100644 index 000000000..c8614fbac --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/insights-generate.P01-anonymous.json.snap @@ -0,0 +1,55 @@ +{ + "status": 401, + "body": { + "data": null, + "error": "Not authenticated", + "meta": { + "errorCode": "auth.missing", + "requestId": "" + } + }, + "events": [ + { + "status": 401, + "action": null, + "meta": null, + "warnings": null, + "error": { + "type": "HttpError", + "message": "Not authenticated" + } + } + ], + "effects": [], + "stored": { + "insightsCachedAt": "anchor+0d+10:00:00", + "insightsCachedLocale": "en", + "cachedPayload": { + "summary": "Cached summary.", + "classification": "gut", + "findings": [], + "correlations": [], + "recommendations": [], + "dataQuality": { + "coverage": "good", + "gaps": [], + "confidence": "hoch" + }, + "dailyBriefing": null, + "briefingGeneratedAt": "anchor+0d+10:00:00" + }, + "snapshotHash": null, + "audit": [ + { + "action": "insights.briefing-failure", + "details": { + "reason": "briefing-ungrounded", + "httpStatus": null, + "failureKind": null + }, + "ipAddress": null + } + ], + "usage": [] + } +} diff --git a/tests/integration/routes/parity/__snapshots__/insights-generate.P02-opted-out.json.snap b/tests/integration/routes/parity/__snapshots__/insights-generate.P02-opted-out.json.snap new file mode 100644 index 000000000..b234a3fe3 --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/insights-generate.P02-opted-out.json.snap @@ -0,0 +1,35 @@ +{ + "status": 403, + "body": { + "data": null, + "error": "This AI feature is turned off in your settings", + "meta": { + "capability": "briefing", + "reason": "user_disabled", + "errorCode": "module.disabled", + "module": "insights", + "requestId": "" + } + }, + "events": [ + { + "status": 403, + "action": null, + "meta": null, + "warnings": null, + "error": { + "type": "AiUnavailableError", + "message": "This AI feature is turned off in your settings" + } + } + ], + "effects": [], + "stored": { + "insightsCachedAt": null, + "insightsCachedLocale": null, + "cachedPayload": null, + "snapshotHash": null, + "audit": [], + "usage": [] + } +} diff --git a/tests/integration/routes/parity/__snapshots__/insights-generate.P03-body-too-large.json.snap b/tests/integration/routes/parity/__snapshots__/insights-generate.P03-body-too-large.json.snap new file mode 100644 index 000000000..83788011a --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/insights-generate.P03-body-too-large.json.snap @@ -0,0 +1,29 @@ +{ + "status": 413, + "body": { + "data": null, + "error": "Request body exceeds 16384 bytes", + "meta": { + "errorCode": "http.payload_too_large", + "requestId": "" + } + }, + "events": [ + { + "status": 413, + "action": null, + "meta": null, + "warnings": null, + "error": null + } + ], + "effects": [], + "stored": { + "insightsCachedAt": null, + "insightsCachedLocale": null, + "cachedPayload": null, + "snapshotHash": null, + "audit": [], + "usage": [] + } +} diff --git a/tests/integration/routes/parity/__snapshots__/insights-generate.P04-cache-hit.json.snap b/tests/integration/routes/parity/__snapshots__/insights-generate.P04-cache-hit.json.snap new file mode 100644 index 000000000..13f227223 --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/insights-generate.P04-cache-hit.json.snap @@ -0,0 +1,86 @@ +{ + "status": 200, + "body": { + "data": { + "insights": { + "summary": "Cached summary.", + "classification": "gut", + "findings": [], + "correlations": [], + "recommendations": [], + "dataQuality": { + "coverage": "good", + "gaps": [], + "confidence": "hoch" + }, + "dailyBriefing": { + "paragraph": "Your readings held steady this week, with no surprises.", + "signalsOfDay": [ + { + "sourceMetric": "weight", + "tone": "info", + "headline": "Weight is steady", + "nudge": "Keep weighing in at the same time each morning.", + "delta": null + } + ], + "keyFindings": [] + }, + "briefingGeneratedAt": "anchor+0d+11:00:00" + }, + "cached": true, + "cachedAt": "anchor+0d+11:00:00", + "legacyPayload": false + }, + "error": null + }, + "events": [ + { + "status": 200, + "action": { + "name": "insights.generate" + }, + "meta": { + "briefingless": false, + "cached": true, + "legacyPayload": false + }, + "warnings": null, + "error": null + } + ], + "effects": [], + "stored": { + "insightsCachedAt": "anchor+0d+11:00:00", + "insightsCachedLocale": "en", + "cachedPayload": { + "summary": "Cached summary.", + "classification": "gut", + "findings": [], + "correlations": [], + "recommendations": [], + "dataQuality": { + "coverage": "good", + "gaps": [], + "confidence": "hoch" + }, + "dailyBriefing": { + "paragraph": "Your readings held steady this week, with no surprises.", + "signalsOfDay": [ + { + "sourceMetric": "weight", + "tone": "info", + "headline": "Weight is steady", + "nudge": "Keep weighing in at the same time each morning.", + "delta": null + } + ], + "keyFindings": [] + }, + "briefingGeneratedAt": "anchor+0d+11:00:00" + }, + "snapshotHash": null, + "audit": [], + "usage": [] + } +} diff --git a/tests/integration/routes/parity/__snapshots__/insights-generate.P05-yesterday-regenerated.json.snap b/tests/integration/routes/parity/__snapshots__/insights-generate.P05-yesterday-regenerated.json.snap new file mode 100644 index 000000000..5e7bd73dd --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/insights-generate.P05-yesterday-regenerated.json.snap @@ -0,0 +1,276 @@ +{ + "status": 200, + "body": { + "data": { + "insights": { + "summary": "Your tracked metrics held steady over the last 30 days.", + "classification": "gut", + "findings": [], + "correlations": [], + "recommendations": [], + "dataQuality": { + "coverage": "good", + "gaps": [], + "confidence": "hoch" + }, + "dailyBriefing": { + "paragraph": "Your readings held steady this week, with no surprises.", + "signalsOfDay": [ + { + "sourceMetric": "weight", + "tone": "info", + "headline": "Weight is steady", + "nudge": "Keep weighing in at the same time each morning.", + "delta": null + } + ], + "keyFindings": [] + }, + "briefingGeneratedAt": "anchor+0d+12:00:00" + }, + "cached": false, + "legacyPayload": false, + "briefingOmittedReason": null + }, + "error": null + }, + "events": [ + { + "status": 200, + "action": { + "name": "insights.generate" + }, + "meta": { + "ai_reasoning_effort": "medium", + "ai_reasoning_job": "daily_briefing", + "ai_reasoning_source": "job", + "cached": false, + "chainProviderType": "anthropic", + "fallbackHopCount": 0, + "include_raw": false, + "locale": "en", + "model": "claude-parity", + "providerType": "anthropic", + "scopes": 13, + "since_days": 400, + "tokensUsed": 1200 + }, + "warnings": null, + "error": null + } + ], + "effects": [ + { + "effect": "ensureUserMoodRollupsFresh", + "args": [ + "" + ] + }, + { + "effect": "provider", + "args": [ + { + "surface": "job", + "chain": [ + "anthropic" + ], + "params": { + "temperature": 0.3, + "maxTokens": 2500, + "responseFormat": "json", + "timeoutMs": 180000, + "reasoning": { + "effort": "medium", + "summaries": false + } + }, + "promptChars": 34773, + "promptSha256": "2197bdec8af3ff7034e7b533788ac94d36b64d4edf8d211bbacfac7e2298ad72" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "blood-pressure", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "pulse", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "weight", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "bmi", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "mood", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "medication-compliance", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "general", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "metric:SLEEP_DURATION", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "metric:STEPS", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "metric:BLOOD_GLUCOSE", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "metric:OXYGEN_SATURATION", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "metric:HEART_RATE_VARIABILITY", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "metric:RESTING_HEART_RATE", + "locale": "en" + } + ] + } + ], + "stored": { + "insightsCachedAt": "anchor+0d+12:00:00", + "insightsCachedLocale": "en", + "cachedPayload": { + "summary": "Your tracked metrics held steady over the last 30 days.", + "classification": "gut", + "findings": [], + "correlations": [], + "recommendations": [], + "dataQuality": { + "coverage": "good", + "gaps": [], + "confidence": "hoch" + }, + "dailyBriefing": { + "paragraph": "Your readings held steady this week, with no surprises.", + "signalsOfDay": [ + { + "sourceMetric": "weight", + "tone": "info", + "headline": "Weight is steady", + "nudge": "Keep weighing in at the same time each morning.", + "delta": null + } + ], + "keyFindings": [] + }, + "briefingGeneratedAt": "anchor+0d+12:00:00" + }, + "snapshotHash": "4ac15bc271309c63b3b5d1e43a3367f7abdf952d3f4351bb1b22aa364bcb630a", + "audit": [ + { + "action": "insights.generate", + "details": { + "privacyMode": "aggregated", + "tokensUsed": 1200, + "providerType": "anthropic", + "chainProviderType": "anthropic", + "fallbackHopCount": 0, + "model": "claude-parity" + }, + "ipAddress": null + } + ], + "usage": [ + { + "dateKey": "day+0", + "totalTokens": 1200, + "operatorTokens": 0, + "messageCount": 1 + } + ] + } +} diff --git a/tests/integration/routes/parity/__snapshots__/insights-generate.P06-other-language-regenerated.json.snap b/tests/integration/routes/parity/__snapshots__/insights-generate.P06-other-language-regenerated.json.snap new file mode 100644 index 000000000..57099b957 --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/insights-generate.P06-other-language-regenerated.json.snap @@ -0,0 +1,275 @@ +{ + "status": 200, + "body": { + "data": { + "insights": { + "summary": "Your tracked metrics held steady over the last 30 days.", + "classification": "gut", + "findings": [], + "correlations": [], + "recommendations": [], + "dataQuality": { + "coverage": "good", + "gaps": [], + "confidence": "hoch" + }, + "dailyBriefing": { + "paragraph": "Your readings held steady this week, with no surprises.", + "signalsOfDay": [ + { + "sourceMetric": "weight", + "tone": "info", + "headline": "Weight is steady", + "nudge": "Keep weighing in at the same time each morning.", + "delta": null + } + ], + "keyFindings": [] + }, + "briefingGeneratedAt": "anchor+0d+12:00:00" + }, + "cached": false, + "legacyPayload": false, + "briefingOmittedReason": null + }, + "error": null + }, + "events": [ + { + "status": 200, + "action": { + "name": "insights.generate" + }, + "meta": { + "ai_reasoning_effort": "medium", + "ai_reasoning_job": "daily_briefing", + "ai_reasoning_source": "job", + "cached": false, + "chainProviderType": "anthropic", + "fallbackHopCount": 0, + "include_raw": false, + "model": "claude-parity", + "providerType": "anthropic", + "scopes": 13, + "since_days": 400, + "tokensUsed": 1200 + }, + "warnings": null, + "error": null + } + ], + "effects": [ + { + "effect": "ensureUserMoodRollupsFresh", + "args": [ + "" + ] + }, + { + "effect": "provider", + "args": [ + { + "surface": "job", + "chain": [ + "anthropic" + ], + "params": { + "temperature": 0.3, + "maxTokens": 2500, + "responseFormat": "json", + "timeoutMs": 180000, + "reasoning": { + "effort": "medium", + "summaries": false + } + }, + "promptChars": 34773, + "promptSha256": "2197bdec8af3ff7034e7b533788ac94d36b64d4edf8d211bbacfac7e2298ad72" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "blood-pressure", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "pulse", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "weight", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "bmi", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "mood", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "medication-compliance", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "general", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "metric:SLEEP_DURATION", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "metric:STEPS", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "metric:BLOOD_GLUCOSE", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "metric:OXYGEN_SATURATION", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "metric:HEART_RATE_VARIABILITY", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "metric:RESTING_HEART_RATE", + "locale": "en" + } + ] + } + ], + "stored": { + "insightsCachedAt": "anchor+0d+12:00:00", + "insightsCachedLocale": "en", + "cachedPayload": { + "summary": "Your tracked metrics held steady over the last 30 days.", + "classification": "gut", + "findings": [], + "correlations": [], + "recommendations": [], + "dataQuality": { + "coverage": "good", + "gaps": [], + "confidence": "hoch" + }, + "dailyBriefing": { + "paragraph": "Your readings held steady this week, with no surprises.", + "signalsOfDay": [ + { + "sourceMetric": "weight", + "tone": "info", + "headline": "Weight is steady", + "nudge": "Keep weighing in at the same time each morning.", + "delta": null + } + ], + "keyFindings": [] + }, + "briefingGeneratedAt": "anchor+0d+12:00:00" + }, + "snapshotHash": "4ac15bc271309c63b3b5d1e43a3367f7abdf952d3f4351bb1b22aa364bcb630a", + "audit": [ + { + "action": "insights.generate", + "details": { + "privacyMode": "aggregated", + "tokensUsed": 1200, + "providerType": "anthropic", + "chainProviderType": "anthropic", + "fallbackHopCount": 0, + "model": "claude-parity" + }, + "ipAddress": null + } + ], + "usage": [ + { + "dateKey": "day+0", + "totalTokens": 1200, + "operatorTokens": 0, + "messageCount": 1 + } + ] + } +} diff --git a/tests/integration/routes/parity/__snapshots__/insights-generate.P07-quota-cached-fallback.json.snap b/tests/integration/routes/parity/__snapshots__/insights-generate.P07-quota-cached-fallback.json.snap new file mode 100644 index 000000000..0a33c3fee --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/insights-generate.P07-quota-cached-fallback.json.snap @@ -0,0 +1,62 @@ +{ + "status": 200, + "body": { + "data": { + "insights": { + "summary": "Cached summary.", + "classification": "gut", + "findings": [], + "correlations": [], + "recommendations": [], + "dataQuality": { + "coverage": "good", + "gaps": [], + "confidence": "hoch" + }, + "dailyBriefing": null, + "briefingGeneratedAt": "anchor+0d+11:00:00" + }, + "cached": true, + "cachedAt": "anchor+0d+11:00:00", + "legacyPayload": false + }, + "error": null + }, + "events": [ + { + "status": 200, + "action": { + "name": "insights.generate" + }, + "meta": { + "cached": true, + "locale": "en", + "rate_limited_fallback": true + }, + "warnings": null, + "error": null + } + ], + "effects": [], + "stored": { + "insightsCachedAt": "anchor+0d+11:00:00", + "insightsCachedLocale": "en", + "cachedPayload": { + "summary": "Cached summary.", + "classification": "gut", + "findings": [], + "correlations": [], + "recommendations": [], + "dataQuality": { + "coverage": "good", + "gaps": [], + "confidence": "hoch" + }, + "dailyBriefing": null, + "briefingGeneratedAt": "anchor+0d+11:00:00" + }, + "snapshotHash": null, + "audit": [], + "usage": [] + } +} diff --git a/tests/integration/routes/parity/__snapshots__/insights-generate.P08-quota-refused.json.snap b/tests/integration/routes/parity/__snapshots__/insights-generate.P08-quota-refused.json.snap new file mode 100644 index 000000000..ba22d1ef5 --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/insights-generate.P08-quota-refused.json.snap @@ -0,0 +1,29 @@ +{ + "status": 429, + "body": { + "data": null, + "error": "Maximum 10 insight generations per hour.", + "meta": { + "errorCode": "http.rate_limited", + "requestId": "" + } + }, + "events": [ + { + "status": 429, + "action": null, + "meta": null, + "warnings": null, + "error": null + } + ], + "effects": [], + "stored": { + "insightsCachedAt": null, + "insightsCachedLocale": null, + "cachedPayload": null, + "snapshotHash": null, + "audit": [], + "usage": [] + } +} diff --git a/tests/integration/routes/parity/__snapshots__/insights-generate.P09-unreadable-key.json.snap b/tests/integration/routes/parity/__snapshots__/insights-generate.P09-unreadable-key.json.snap new file mode 100644 index 000000000..a0924a58b --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/insights-generate.P09-unreadable-key.json.snap @@ -0,0 +1,32 @@ +{ + "status": 500, + "body": { + "data": null, + "error": "Interner Serverfehler", + "meta": { + "errorCode": "http.internal", + "requestId": "" + } + }, + "events": [ + { + "status": 500, + "action": null, + "meta": null, + "warnings": null, + "error": { + "type": "TypeError", + "message": "Invalid authentication tag length: 0" + } + } + ], + "effects": [], + "stored": { + "insightsCachedAt": null, + "insightsCachedLocale": null, + "cachedPayload": null, + "snapshotHash": null, + "audit": [], + "usage": [] + } +} diff --git a/tests/integration/routes/parity/__snapshots__/insights-generate.P10-generated.json.snap b/tests/integration/routes/parity/__snapshots__/insights-generate.P10-generated.json.snap new file mode 100644 index 000000000..3ba9663b0 --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/insights-generate.P10-generated.json.snap @@ -0,0 +1,275 @@ +{ + "status": 200, + "body": { + "data": { + "insights": { + "summary": "Your tracked metrics held steady over the last 30 days.", + "classification": "gut", + "findings": [], + "correlations": [], + "recommendations": [], + "dataQuality": { + "coverage": "good", + "gaps": [], + "confidence": "hoch" + }, + "dailyBriefing": { + "paragraph": "Your readings held steady this week, with no surprises.", + "signalsOfDay": [ + { + "sourceMetric": "weight", + "tone": "info", + "headline": "Weight is steady", + "nudge": "Keep weighing in at the same time each morning.", + "delta": null + } + ], + "keyFindings": [] + }, + "briefingGeneratedAt": "anchor+0d+12:00:00" + }, + "cached": false, + "legacyPayload": false, + "briefingOmittedReason": null + }, + "error": null + }, + "events": [ + { + "status": 200, + "action": { + "name": "insights.generate" + }, + "meta": { + "ai_reasoning_effort": "medium", + "ai_reasoning_job": "daily_briefing", + "ai_reasoning_source": "job", + "cached": false, + "chainProviderType": "anthropic", + "fallbackHopCount": 0, + "include_raw": false, + "model": "claude-parity", + "providerType": "anthropic", + "scopes": 13, + "since_days": 400, + "tokensUsed": 2100 + }, + "warnings": null, + "error": null + } + ], + "effects": [ + { + "effect": "ensureUserMoodRollupsFresh", + "args": [ + "" + ] + }, + { + "effect": "provider", + "args": [ + { + "surface": "job", + "chain": [ + "anthropic" + ], + "params": { + "temperature": 0.3, + "maxTokens": 2500, + "responseFormat": "json", + "timeoutMs": 180000, + "reasoning": { + "effort": "medium", + "summaries": false + } + }, + "promptChars": 34773, + "promptSha256": "2197bdec8af3ff7034e7b533788ac94d36b64d4edf8d211bbacfac7e2298ad72" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "blood-pressure", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "pulse", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "weight", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "bmi", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "mood", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "medication-compliance", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "general", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "metric:SLEEP_DURATION", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "metric:STEPS", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "metric:BLOOD_GLUCOSE", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "metric:OXYGEN_SATURATION", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "metric:HEART_RATE_VARIABILITY", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "metric:RESTING_HEART_RATE", + "locale": "en" + } + ] + } + ], + "stored": { + "insightsCachedAt": "anchor+0d+12:00:00", + "insightsCachedLocale": "en", + "cachedPayload": { + "summary": "Your tracked metrics held steady over the last 30 days.", + "classification": "gut", + "findings": [], + "correlations": [], + "recommendations": [], + "dataQuality": { + "coverage": "good", + "gaps": [], + "confidence": "hoch" + }, + "dailyBriefing": { + "paragraph": "Your readings held steady this week, with no surprises.", + "signalsOfDay": [ + { + "sourceMetric": "weight", + "tone": "info", + "headline": "Weight is steady", + "nudge": "Keep weighing in at the same time each morning.", + "delta": null + } + ], + "keyFindings": [] + }, + "briefingGeneratedAt": "anchor+0d+12:00:00" + }, + "snapshotHash": "4ac15bc271309c63b3b5d1e43a3367f7abdf952d3f4351bb1b22aa364bcb630a", + "audit": [ + { + "action": "insights.generate", + "details": { + "privacyMode": "aggregated", + "tokensUsed": 2100, + "providerType": "anthropic", + "chainProviderType": "anthropic", + "fallbackHopCount": 0, + "model": "claude-parity" + }, + "ipAddress": null + } + ], + "usage": [ + { + "dateKey": "day+0", + "totalTokens": 2100, + "operatorTokens": 0, + "messageCount": 1 + } + ] + } +} diff --git a/tests/integration/routes/parity/__snapshots__/insights-generate.P11-strict-reply.json.snap b/tests/integration/routes/parity/__snapshots__/insights-generate.P11-strict-reply.json.snap new file mode 100644 index 000000000..646d8ce72 --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/insights-generate.P11-strict-reply.json.snap @@ -0,0 +1,251 @@ +{ + "status": 200, + "body": { + "data": { + "insights": { + "summary": "Your tracked metrics held steady over the last 30 days.", + "classification": "gut", + "findings": [], + "correlations": [], + "recommendations": [], + "dataQuality": { + "coverage": "good", + "gaps": [], + "confidence": "hoch" + }, + "disclaimer": "Consult your doctor.", + "briefingGeneratedAt": "anchor+0d+12:00:00" + }, + "cached": false, + "legacyPayload": false, + "briefingOmittedReason": null + }, + "error": null + }, + "events": [ + { + "status": 200, + "action": { + "name": "insights.generate" + }, + "meta": { + "ai_reasoning_effort": "medium", + "ai_reasoning_job": "daily_briefing", + "ai_reasoning_source": "job", + "cached": false, + "chainProviderType": "anthropic", + "fallbackHopCount": 0, + "include_raw": false, + "model": "claude-parity", + "providerType": "anthropic", + "scopes": 13, + "since_days": 400, + "tokensUsed": 1200 + }, + "warnings": null, + "error": null + } + ], + "effects": [ + { + "effect": "ensureUserMoodRollupsFresh", + "args": [ + "" + ] + }, + { + "effect": "provider", + "args": [ + { + "surface": "job", + "chain": [ + "anthropic" + ], + "params": { + "temperature": 0.3, + "maxTokens": 2500, + "responseFormat": "json", + "timeoutMs": 180000, + "reasoning": { + "effort": "medium", + "summaries": false + } + }, + "promptChars": 34773, + "promptSha256": "2197bdec8af3ff7034e7b533788ac94d36b64d4edf8d211bbacfac7e2298ad72" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "blood-pressure", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "pulse", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "weight", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "bmi", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "mood", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "medication-compliance", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "general", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "metric:SLEEP_DURATION", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "metric:STEPS", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "metric:BLOOD_GLUCOSE", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "metric:OXYGEN_SATURATION", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "metric:HEART_RATE_VARIABILITY", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "metric:RESTING_HEART_RATE", + "locale": "en" + } + ] + } + ], + "stored": { + "insightsCachedAt": "anchor+0d+12:00:00", + "insightsCachedLocale": "en", + "cachedPayload": { + "summary": "Your tracked metrics held steady over the last 30 days.", + "classification": "gut", + "findings": [], + "correlations": [], + "recommendations": [], + "dataQuality": { + "coverage": "good", + "gaps": [], + "confidence": "hoch" + }, + "disclaimer": "Consult your doctor.", + "briefingGeneratedAt": "anchor+0d+12:00:00" + }, + "snapshotHash": "4ac15bc271309c63b3b5d1e43a3367f7abdf952d3f4351bb1b22aa364bcb630a", + "audit": [ + { + "action": "insights.generate", + "details": { + "privacyMode": "aggregated", + "tokensUsed": 1200, + "providerType": "anthropic", + "chainProviderType": "anthropic", + "fallbackHopCount": 0, + "model": "claude-parity" + }, + "ipAddress": null + } + ], + "usage": [ + { + "dateKey": "day+0", + "totalTokens": 1200, + "operatorTokens": 0, + "messageCount": 1 + } + ] + } +} diff --git a/tests/integration/routes/parity/__snapshots__/insights-generate.P12-generated-de-short.json.snap b/tests/integration/routes/parity/__snapshots__/insights-generate.P12-generated-de-short.json.snap new file mode 100644 index 000000000..bc8dbcbf4 --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/insights-generate.P12-generated-de-short.json.snap @@ -0,0 +1,271 @@ +{ + "status": 200, + "body": { + "data": { + "insights": { + "summary": "Your tracked metrics held steady over the last 30 days.", + "classification": "gut", + "findings": [], + "correlations": [], + "recommendations": [], + "dataQuality": { + "coverage": "good", + "gaps": [], + "confidence": "hoch" + }, + "dailyBriefing": { + "paragraph": "Your readings held steady this week, with no surprises.", + "signalsOfDay": [ + { + "sourceMetric": "weight", + "tone": "info", + "headline": "Weight is steady", + "nudge": "Keep weighing in at the same time each morning.", + "delta": null + } + ], + "keyFindings": [] + }, + "briefingGeneratedAt": "anchor+0d+12:00:00" + }, + "cached": false, + "legacyPayload": false, + "briefingOmittedReason": null + }, + "error": null + }, + "events": [ + { + "status": 200, + "action": { + "name": "insights.generate" + }, + "meta": { + "ai_reasoning_effort": "off", + "ai_reasoning_job": "daily_briefing", + "ai_reasoning_usage_level": "frugal", + "cached": false, + "chainProviderType": "anthropic", + "fallbackHopCount": 0, + "include_raw": false, + "model": "claude-parity", + "providerType": "anthropic", + "scopes": 13, + "since_days": 400, + "tokensUsed": 1200 + }, + "warnings": null, + "error": null + } + ], + "effects": [ + { + "effect": "ensureUserMoodRollupsFresh", + "args": [ + "" + ] + }, + { + "effect": "provider", + "args": [ + { + "surface": "job", + "chain": [ + "anthropic" + ], + "params": { + "temperature": 0.3, + "maxTokens": 2500, + "responseFormat": "json", + "timeoutMs": 180000 + }, + "promptChars": 39012, + "promptSha256": "9b74ece939faf8f6643d894cc718bd3e0925133e2c6c12d98dfb3f9d68c82d61" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "blood-pressure", + "locale": "de" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "pulse", + "locale": "de" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "weight", + "locale": "de" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "bmi", + "locale": "de" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "mood", + "locale": "de" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "medication-compliance", + "locale": "de" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "general", + "locale": "de" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "metric:SLEEP_DURATION", + "locale": "de" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "metric:STEPS", + "locale": "de" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "metric:BLOOD_GLUCOSE", + "locale": "de" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "metric:OXYGEN_SATURATION", + "locale": "de" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "metric:HEART_RATE_VARIABILITY", + "locale": "de" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "metric:RESTING_HEART_RATE", + "locale": "de" + } + ] + } + ], + "stored": { + "insightsCachedAt": "anchor+0d+12:00:00", + "insightsCachedLocale": "de", + "cachedPayload": { + "summary": "Your tracked metrics held steady over the last 30 days.", + "classification": "gut", + "findings": [], + "correlations": [], + "recommendations": [], + "dataQuality": { + "coverage": "good", + "gaps": [], + "confidence": "hoch" + }, + "dailyBriefing": { + "paragraph": "Your readings held steady this week, with no surprises.", + "signalsOfDay": [ + { + "sourceMetric": "weight", + "tone": "info", + "headline": "Weight is steady", + "nudge": "Keep weighing in at the same time each morning.", + "delta": null + } + ], + "keyFindings": [] + }, + "briefingGeneratedAt": "anchor+0d+12:00:00" + }, + "snapshotHash": "c98e2d8e9f112318293af870eaa7c15d342f7f37a12047b0fd4e2a7f66d84676", + "audit": [ + { + "action": "insights.generate", + "details": { + "privacyMode": "aggregated", + "tokensUsed": 1200, + "providerType": "anthropic", + "chainProviderType": "anthropic", + "fallbackHopCount": 0, + "model": "claude-parity" + }, + "ipAddress": null + } + ], + "usage": [ + { + "dateKey": "day+0", + "totalTokens": 1200, + "operatorTokens": 0, + "messageCount": 1 + } + ] + } +} diff --git a/tests/integration/routes/parity/__snapshots__/insights-generate.P13-downgraded.json.snap b/tests/integration/routes/parity/__snapshots__/insights-generate.P13-downgraded.json.snap new file mode 100644 index 000000000..1e52a6832 --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/insights-generate.P13-downgraded.json.snap @@ -0,0 +1,278 @@ +{ + "status": 200, + "body": { + "data": { + "insights": { + "summary": "Your tracked metrics held steady over the last 30 days.", + "classification": "gut", + "findings": [], + "correlations": [], + "recommendations": [], + "dataQuality": { + "coverage": "good", + "gaps": [], + "confidence": "hoch" + }, + "dailyBriefing": { + "paragraph": "Your readings held steady this week, with no surprises.", + "signalsOfDay": [ + { + "sourceMetric": "weight", + "tone": "info", + "headline": "Weight is steady", + "nudge": "Keep weighing in at the same time each morning.", + "delta": null + } + ], + "keyFindings": [] + }, + "briefingGeneratedAt": "anchor+0d+12:00:00" + }, + "cached": false, + "legacyPayload": false, + "briefingOmittedReason": null + }, + "error": null + }, + "events": [ + { + "status": 200, + "action": { + "name": "insights.generate" + }, + "meta": { + "ai_reasoning_effort": "medium", + "ai_reasoning_job": "daily_briefing", + "ai_reasoning_source": "job", + "cached": false, + "chainProviderType": "anthropic", + "fallbackHopCount": 0, + "include_raw": false, + "insights_features_downgraded": true, + "insights_features_hard_downgraded": false, + "insights_features_oversize_bytes": 6100000, + "model": "claude-parity", + "providerType": "anthropic", + "scopes": 13, + "since_days": 400, + "tokensUsed": 1200 + }, + "warnings": null, + "error": null + } + ], + "effects": [ + { + "effect": "ensureUserMoodRollupsFresh", + "args": [ + "" + ] + }, + { + "effect": "provider", + "args": [ + { + "surface": "job", + "chain": [ + "anthropic" + ], + "params": { + "temperature": 0.3, + "maxTokens": 2500, + "responseFormat": "json", + "timeoutMs": 180000, + "reasoning": { + "effort": "medium", + "summaries": false + } + }, + "promptChars": 34783, + "promptSha256": "2aeca4de2fc6397815465ac4de27a2267fdfc69cd4a607b2e19d7b06502bc040" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "blood-pressure", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "pulse", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "weight", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "bmi", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "mood", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "medication-compliance", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "general", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "metric:SLEEP_DURATION", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "metric:STEPS", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "metric:BLOOD_GLUCOSE", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "metric:OXYGEN_SATURATION", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "metric:HEART_RATE_VARIABILITY", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "metric:RESTING_HEART_RATE", + "locale": "en" + } + ] + } + ], + "stored": { + "insightsCachedAt": "anchor+0d+12:00:00", + "insightsCachedLocale": "en", + "cachedPayload": { + "summary": "Your tracked metrics held steady over the last 30 days.", + "classification": "gut", + "findings": [], + "correlations": [], + "recommendations": [], + "dataQuality": { + "coverage": "good", + "gaps": [], + "confidence": "hoch" + }, + "dailyBriefing": { + "paragraph": "Your readings held steady this week, with no surprises.", + "signalsOfDay": [ + { + "sourceMetric": "weight", + "tone": "info", + "headline": "Weight is steady", + "nudge": "Keep weighing in at the same time each morning.", + "delta": null + } + ], + "keyFindings": [] + }, + "briefingGeneratedAt": "anchor+0d+12:00:00" + }, + "snapshotHash": "4ac15bc271309c63b3b5d1e43a3367f7abdf952d3f4351bb1b22aa364bcb630a", + "audit": [ + { + "action": "insights.generate", + "details": { + "privacyMode": "raw", + "tokensUsed": 1200, + "providerType": "anthropic", + "chainProviderType": "anthropic", + "fallbackHopCount": 0, + "model": "claude-parity" + }, + "ipAddress": null + } + ], + "usage": [ + { + "dateKey": "day+0", + "totalTokens": 1200, + "operatorTokens": 0, + "messageCount": 1 + } + ] + } +} diff --git a/tests/integration/routes/parity/__snapshots__/insights-generate.P14-hard-downgraded.json.snap b/tests/integration/routes/parity/__snapshots__/insights-generate.P14-hard-downgraded.json.snap new file mode 100644 index 000000000..77bdb63ba --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/insights-generate.P14-hard-downgraded.json.snap @@ -0,0 +1,278 @@ +{ + "status": 200, + "body": { + "data": { + "insights": { + "summary": "Your tracked metrics held steady over the last 30 days.", + "classification": "gut", + "findings": [], + "correlations": [], + "recommendations": [], + "dataQuality": { + "coverage": "good", + "gaps": [], + "confidence": "hoch" + }, + "dailyBriefing": { + "paragraph": "Your readings held steady this week, with no surprises.", + "signalsOfDay": [ + { + "sourceMetric": "weight", + "tone": "info", + "headline": "Weight is steady", + "nudge": "Keep weighing in at the same time each morning.", + "delta": null + } + ], + "keyFindings": [] + }, + "briefingGeneratedAt": "anchor+0d+12:00:00" + }, + "cached": false, + "legacyPayload": false, + "briefingOmittedReason": null + }, + "error": null + }, + "events": [ + { + "status": 200, + "action": { + "name": "insights.generate" + }, + "meta": { + "ai_reasoning_effort": "medium", + "ai_reasoning_job": "daily_briefing", + "ai_reasoning_source": "job", + "cached": false, + "chainProviderType": "anthropic", + "fallbackHopCount": 0, + "include_raw": false, + "insights_features_downgraded": true, + "insights_features_hard_downgraded": true, + "insights_features_oversize_bytes": 5400000, + "model": "claude-parity", + "providerType": "anthropic", + "scopes": 13, + "since_days": 400, + "tokensUsed": 1200 + }, + "warnings": null, + "error": null + } + ], + "effects": [ + { + "effect": "ensureUserMoodRollupsFresh", + "args": [ + "" + ] + }, + { + "effect": "provider", + "args": [ + { + "surface": "job", + "chain": [ + "anthropic" + ], + "params": { + "temperature": 0.3, + "maxTokens": 2500, + "responseFormat": "json", + "timeoutMs": 180000, + "reasoning": { + "effort": "medium", + "summaries": false + } + }, + "promptChars": 34193, + "promptSha256": "f11bae5de41f1344113977970c832df38427bcdf4b24a6f7fb8b05cef899c5af" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "blood-pressure", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "pulse", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "weight", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "bmi", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "mood", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "medication-compliance", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "general", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "metric:SLEEP_DURATION", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "metric:STEPS", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "metric:BLOOD_GLUCOSE", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "metric:OXYGEN_SATURATION", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "metric:HEART_RATE_VARIABILITY", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "metric:RESTING_HEART_RATE", + "locale": "en" + } + ] + } + ], + "stored": { + "insightsCachedAt": "anchor+0d+12:00:00", + "insightsCachedLocale": "en", + "cachedPayload": { + "summary": "Your tracked metrics held steady over the last 30 days.", + "classification": "gut", + "findings": [], + "correlations": [], + "recommendations": [], + "dataQuality": { + "coverage": "good", + "gaps": [], + "confidence": "hoch" + }, + "dailyBriefing": { + "paragraph": "Your readings held steady this week, with no surprises.", + "signalsOfDay": [ + { + "sourceMetric": "weight", + "tone": "info", + "headline": "Weight is steady", + "nudge": "Keep weighing in at the same time each morning.", + "delta": null + } + ], + "keyFindings": [] + }, + "briefingGeneratedAt": "anchor+0d+12:00:00" + }, + "snapshotHash": "11a4fb0ae0188e5a95cbe69124afaccdb326b76ea8e8ad73affbc819255a01cc", + "audit": [ + { + "action": "insights.generate", + "details": { + "privacyMode": "raw", + "tokensUsed": 1200, + "providerType": "anthropic", + "chainProviderType": "anthropic", + "fallbackHopCount": 0, + "model": "claude-parity" + }, + "ipAddress": null + } + ], + "usage": [ + { + "dateKey": "day+0", + "totalTokens": 1200, + "operatorTokens": 0, + "messageCount": 1 + } + ] + } +} diff --git a/tests/integration/routes/parity/__snapshots__/insights-generate.P15-too-large.json.snap b/tests/integration/routes/parity/__snapshots__/insights-generate.P15-too-large.json.snap new file mode 100644 index 000000000..6f9e66788 --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/insights-generate.P15-too-large.json.snap @@ -0,0 +1,32 @@ +{ + "status": 422, + "body": { + "data": null, + "error": "Your data exceeds the AI payload size limit. Reduce the data window or exclude optional blocks in Settings > AI.", + "meta": { + "errorCode": "http.invalid", + "requestId": "" + } + }, + "events": [ + { + "status": 422, + "action": null, + "meta": { + "insights_payload_oversize_bytes": 5200000, + "insights_payload_too_large": true + }, + "warnings": null, + "error": null + } + ], + "effects": [], + "stored": { + "insightsCachedAt": null, + "insightsCachedLocale": null, + "cachedPayload": null, + "snapshotHash": null, + "audit": [], + "usage": [] + } +} diff --git a/tests/integration/routes/parity/__snapshots__/insights-generate.P16-budget-exceeded.json.snap b/tests/integration/routes/parity/__snapshots__/insights-generate.P16-budget-exceeded.json.snap new file mode 100644 index 000000000..63e94ff84 --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/insights-generate.P16-budget-exceeded.json.snap @@ -0,0 +1,58 @@ +{ + "status": 429, + "body": { + "data": null, + "error": "Daily AI token budget reached. Insights will be available again tomorrow.", + "meta": { + "errorCode": "insights.generate.budgetExceeded", + "requestId": "" + } + }, + "events": [ + { + "status": 429, + "action": { + "name": "insights.briefing.budget_exceeded" + }, + "meta": { + "ai_reasoning_effort": "medium", + "ai_reasoning_job": "daily_briefing", + "ai_reasoning_source": "job", + "cap": 1600000, + "include_raw": false, + "limit": "owner-cap", + "operatorAfter": 0, + "owner": "user", + "since_days": 400, + "stage": "generate", + "surface": "job", + "totalAfter": 5000000 + }, + "warnings": null, + "error": null + } + ], + "effects": [ + { + "effect": "ensureUserMoodRollupsFresh", + "args": [ + "" + ] + } + ], + "stored": { + "insightsCachedAt": null, + "insightsCachedLocale": null, + "cachedPayload": null, + "snapshotHash": null, + "audit": [], + "usage": [ + { + "dateKey": "day+0", + "totalTokens": 5000000, + "operatorTokens": 0, + "messageCount": 0 + } + ] + } +} diff --git a/tests/integration/routes/parity/__snapshots__/insights-generate.P17-chain-auth.json.snap b/tests/integration/routes/parity/__snapshots__/insights-generate.P17-chain-auth.json.snap new file mode 100644 index 000000000..2b6a467d1 --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/insights-generate.P17-chain-auth.json.snap @@ -0,0 +1,103 @@ +{ + "status": 422, + "body": { + "data": null, + "error": "AI provider rejected the request. Check your API key in Settings > AI", + "meta": { + "errorCode": "http.invalid", + "requestId": "" + } + }, + "events": [ + { + "status": 422, + "action": { + "name": "insights.feature_cache.miss" + }, + "meta": { + "ai_reasoning_effort": "medium", + "ai_reasoning_job": "daily_briefing", + "ai_reasoning_source": "job", + "include_raw": false, + "insights_chain_attempts": 2, + "insights_chain_first_provider": "anthropic", + "insights_chain_first_status": 401, + "insights_chain_outcome": "all-failed", + "since_days": 400 + }, + "warnings": null, + "error": null + } + ], + "effects": [ + { + "effect": "ensureUserMoodRollupsFresh", + "args": [ + "" + ] + }, + { + "effect": "provider", + "args": [ + { + "surface": "job", + "chain": [ + "anthropic" + ], + "params": { + "temperature": 0.3, + "maxTokens": 2500, + "responseFormat": "json", + "timeoutMs": 180000, + "reasoning": { + "effort": "medium", + "summaries": false + } + }, + "promptChars": 34773, + "promptSha256": "2197bdec8af3ff7034e7b533788ac94d36b64d4edf8d211bbacfac7e2298ad72" + } + ] + }, + { + "effect": "recordBriefingFailure", + "args": [ + { + "userId": "", + "reason": "all-providers-failed", + "locale": "en", + "httpStatus": 401, + "failureKind": null + } + ] + } + ], + "stored": { + "insightsCachedAt": null, + "insightsCachedLocale": null, + "cachedPayload": null, + "snapshotHash": null, + "audit": [ + { + "action": "insights.briefing-failure", + "details": { + "dateKey": "day+0", + "reason": "all-providers-failed", + "httpStatus": 401, + "failureKind": null, + "locale": "en", + "triedAt": "anchor+0d+12:00:00" + }, + "ipAddress": null + } + ], + "usage": [ + { + "dateKey": "day+0", + "totalTokens": 0, + "operatorTokens": 0, + "messageCount": 1 + } + ] + } +} diff --git a/tests/integration/routes/parity/__snapshots__/insights-generate.P18-chain-rate-limited.json.snap b/tests/integration/routes/parity/__snapshots__/insights-generate.P18-chain-rate-limited.json.snap new file mode 100644 index 000000000..96934ea41 --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/insights-generate.P18-chain-rate-limited.json.snap @@ -0,0 +1,103 @@ +{ + "status": 429, + "body": { + "data": null, + "error": "AI provider rate-limited the request", + "meta": { + "errorCode": "http.rate_limited", + "requestId": "" + } + }, + "events": [ + { + "status": 429, + "action": { + "name": "insights.feature_cache.miss" + }, + "meta": { + "ai_reasoning_effort": "medium", + "ai_reasoning_job": "daily_briefing", + "ai_reasoning_source": "job", + "include_raw": false, + "insights_chain_attempts": 1, + "insights_chain_first_provider": "anthropic", + "insights_chain_first_status": 429, + "insights_chain_outcome": "all-failed", + "since_days": 400 + }, + "warnings": null, + "error": null + } + ], + "effects": [ + { + "effect": "ensureUserMoodRollupsFresh", + "args": [ + "" + ] + }, + { + "effect": "provider", + "args": [ + { + "surface": "job", + "chain": [ + "anthropic" + ], + "params": { + "temperature": 0.3, + "maxTokens": 2500, + "responseFormat": "json", + "timeoutMs": 180000, + "reasoning": { + "effort": "medium", + "summaries": false + } + }, + "promptChars": 34773, + "promptSha256": "2197bdec8af3ff7034e7b533788ac94d36b64d4edf8d211bbacfac7e2298ad72" + } + ] + }, + { + "effect": "recordBriefingFailure", + "args": [ + { + "userId": "", + "reason": "all-providers-failed", + "locale": "en", + "httpStatus": 429, + "failureKind": null + } + ] + } + ], + "stored": { + "insightsCachedAt": null, + "insightsCachedLocale": null, + "cachedPayload": null, + "snapshotHash": null, + "audit": [ + { + "action": "insights.briefing-failure", + "details": { + "dateKey": "day+0", + "reason": "all-providers-failed", + "httpStatus": 429, + "failureKind": null, + "locale": "en", + "triedAt": "anchor+0d+12:00:00" + }, + "ipAddress": null + } + ], + "usage": [ + { + "dateKey": "day+0", + "totalTokens": 0, + "operatorTokens": 0, + "messageCount": 1 + } + ] + } +} diff --git a/tests/integration/routes/parity/__snapshots__/insights-generate.P19-chain-5xx.json.snap b/tests/integration/routes/parity/__snapshots__/insights-generate.P19-chain-5xx.json.snap new file mode 100644 index 000000000..3a50c4531 --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/insights-generate.P19-chain-5xx.json.snap @@ -0,0 +1,103 @@ +{ + "status": 503, + "body": { + "data": null, + "error": "AI provider temporarily unavailable, try again in a moment", + "meta": { + "errorCode": "http.unavailable", + "requestId": "" + } + }, + "events": [ + { + "status": 503, + "action": { + "name": "insights.feature_cache.miss" + }, + "meta": { + "ai_reasoning_effort": "medium", + "ai_reasoning_job": "daily_briefing", + "ai_reasoning_source": "job", + "include_raw": false, + "insights_chain_attempts": 2, + "insights_chain_first_provider": "anthropic", + "insights_chain_first_status": 401, + "insights_chain_outcome": "all-failed", + "since_days": 400 + }, + "warnings": null, + "error": null + } + ], + "effects": [ + { + "effect": "ensureUserMoodRollupsFresh", + "args": [ + "" + ] + }, + { + "effect": "provider", + "args": [ + { + "surface": "job", + "chain": [ + "anthropic" + ], + "params": { + "temperature": 0.3, + "maxTokens": 2500, + "responseFormat": "json", + "timeoutMs": 180000, + "reasoning": { + "effort": "medium", + "summaries": false + } + }, + "promptChars": 34773, + "promptSha256": "2197bdec8af3ff7034e7b533788ac94d36b64d4edf8d211bbacfac7e2298ad72" + } + ] + }, + { + "effect": "recordBriefingFailure", + "args": [ + { + "userId": "", + "reason": "all-providers-failed", + "locale": "en", + "httpStatus": 401, + "failureKind": null + } + ] + } + ], + "stored": { + "insightsCachedAt": null, + "insightsCachedLocale": null, + "cachedPayload": null, + "snapshotHash": null, + "audit": [ + { + "action": "insights.briefing-failure", + "details": { + "dateKey": "day+0", + "reason": "all-providers-failed", + "httpStatus": 401, + "failureKind": null, + "locale": "en", + "triedAt": "anchor+0d+12:00:00" + }, + "ipAddress": null + } + ], + "usage": [ + { + "dateKey": "day+0", + "totalTokens": 0, + "operatorTokens": 0, + "messageCount": 1 + } + ] + } +} diff --git a/tests/integration/routes/parity/__snapshots__/insights-generate.P20-chain-transport.json.snap b/tests/integration/routes/parity/__snapshots__/insights-generate.P20-chain-transport.json.snap new file mode 100644 index 000000000..500f35359 --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/insights-generate.P20-chain-transport.json.snap @@ -0,0 +1,103 @@ +{ + "status": 422, + "body": { + "data": null, + "error": "AI provider connection failed. Check your AI settings", + "meta": { + "errorCode": "http.invalid", + "requestId": "" + } + }, + "events": [ + { + "status": 422, + "action": { + "name": "insights.feature_cache.miss" + }, + "meta": { + "ai_reasoning_effort": "medium", + "ai_reasoning_job": "daily_briefing", + "ai_reasoning_source": "job", + "include_raw": false, + "insights_chain_attempts": 2, + "insights_chain_first_provider": "anthropic", + "insights_chain_first_status": null, + "insights_chain_outcome": "all-failed", + "since_days": 400 + }, + "warnings": null, + "error": null + } + ], + "effects": [ + { + "effect": "ensureUserMoodRollupsFresh", + "args": [ + "" + ] + }, + { + "effect": "provider", + "args": [ + { + "surface": "job", + "chain": [ + "anthropic" + ], + "params": { + "temperature": 0.3, + "maxTokens": 2500, + "responseFormat": "json", + "timeoutMs": 180000, + "reasoning": { + "effort": "medium", + "summaries": false + } + }, + "promptChars": 34773, + "promptSha256": "2197bdec8af3ff7034e7b533788ac94d36b64d4edf8d211bbacfac7e2298ad72" + } + ] + }, + { + "effect": "recordBriefingFailure", + "args": [ + { + "userId": "", + "reason": "all-providers-failed", + "locale": "en", + "httpStatus": null, + "failureKind": null + } + ] + } + ], + "stored": { + "insightsCachedAt": null, + "insightsCachedLocale": null, + "cachedPayload": null, + "snapshotHash": null, + "audit": [ + { + "action": "insights.briefing-failure", + "details": { + "dateKey": "day+0", + "reason": "all-providers-failed", + "httpStatus": null, + "failureKind": null, + "locale": "en", + "triedAt": "anchor+0d+12:00:00" + }, + "ipAddress": null + } + ], + "usage": [ + { + "dateKey": "day+0", + "totalTokens": 0, + "operatorTokens": 0, + "messageCount": 1 + } + ] + } +} diff --git a/tests/integration/routes/parity/__snapshots__/insights-generate.P21-chain-mixed.json.snap b/tests/integration/routes/parity/__snapshots__/insights-generate.P21-chain-mixed.json.snap new file mode 100644 index 000000000..a249fe477 --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/insights-generate.P21-chain-mixed.json.snap @@ -0,0 +1,103 @@ +{ + "status": 503, + "body": { + "data": null, + "error": "AI provider temporarily unavailable, try again in a moment", + "meta": { + "errorCode": "http.unavailable", + "requestId": "" + } + }, + "events": [ + { + "status": 503, + "action": { + "name": "insights.feature_cache.miss" + }, + "meta": { + "ai_reasoning_effort": "medium", + "ai_reasoning_job": "daily_briefing", + "ai_reasoning_source": "job", + "include_raw": false, + "insights_chain_attempts": 2, + "insights_chain_first_provider": "anthropic", + "insights_chain_first_status": 404, + "insights_chain_outcome": "all-failed", + "since_days": 400 + }, + "warnings": null, + "error": null + } + ], + "effects": [ + { + "effect": "ensureUserMoodRollupsFresh", + "args": [ + "" + ] + }, + { + "effect": "provider", + "args": [ + { + "surface": "job", + "chain": [ + "anthropic" + ], + "params": { + "temperature": 0.3, + "maxTokens": 2500, + "responseFormat": "json", + "timeoutMs": 180000, + "reasoning": { + "effort": "medium", + "summaries": false + } + }, + "promptChars": 34773, + "promptSha256": "2197bdec8af3ff7034e7b533788ac94d36b64d4edf8d211bbacfac7e2298ad72" + } + ] + }, + { + "effect": "recordBriefingFailure", + "args": [ + { + "userId": "", + "reason": "all-providers-failed", + "locale": "en", + "httpStatus": 404, + "failureKind": null + } + ] + } + ], + "stored": { + "insightsCachedAt": null, + "insightsCachedLocale": null, + "cachedPayload": null, + "snapshotHash": null, + "audit": [ + { + "action": "insights.briefing-failure", + "details": { + "dateKey": "day+0", + "reason": "all-providers-failed", + "httpStatus": 404, + "failureKind": null, + "locale": "en", + "triedAt": "anchor+0d+12:00:00" + }, + "ipAddress": null + } + ], + "usage": [ + { + "dateKey": "day+0", + "totalTokens": 0, + "operatorTokens": 0, + "messageCount": 1 + } + ] + } +} diff --git a/tests/integration/routes/parity/__snapshots__/insights-generate.P22-provider-400.json.snap b/tests/integration/routes/parity/__snapshots__/insights-generate.P22-provider-400.json.snap new file mode 100644 index 000000000..8bd4eb339 --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/insights-generate.P22-provider-400.json.snap @@ -0,0 +1,102 @@ +{ + "status": 422, + "body": { + "data": null, + "error": "AI provider connection failed. Check your AI settings", + "meta": { + "errorCode": "http.invalid", + "requestId": "" + } + }, + "events": [ + { + "status": 422, + "action": { + "name": "insights.feature_cache.miss" + }, + "meta": { + "ai_reasoning_effort": "medium", + "ai_reasoning_job": "daily_briefing", + "ai_reasoning_source": "job", + "include_raw": false, + "insights_provider_body_excerpt": "{\"error\":\"status 400\"}", + "insights_provider_error": "upstream answered 400", + "insights_provider_status": 400, + "since_days": 400 + }, + "warnings": null, + "error": null + } + ], + "effects": [ + { + "effect": "ensureUserMoodRollupsFresh", + "args": [ + "" + ] + }, + { + "effect": "provider", + "args": [ + { + "surface": "job", + "chain": [ + "anthropic" + ], + "params": { + "temperature": 0.3, + "maxTokens": 2500, + "responseFormat": "json", + "timeoutMs": 180000, + "reasoning": { + "effort": "medium", + "summaries": false + } + }, + "promptChars": 34773, + "promptSha256": "2197bdec8af3ff7034e7b533788ac94d36b64d4edf8d211bbacfac7e2298ad72" + } + ] + }, + { + "effect": "recordBriefingFailure", + "args": [ + { + "userId": "", + "reason": "provider-error", + "locale": "en", + "httpStatus": 400, + "failureKind": "http" + } + ] + } + ], + "stored": { + "insightsCachedAt": null, + "insightsCachedLocale": null, + "cachedPayload": null, + "snapshotHash": null, + "audit": [ + { + "action": "insights.briefing-failure", + "details": { + "dateKey": "day+0", + "reason": "provider-error", + "httpStatus": 400, + "failureKind": "http", + "locale": "en", + "triedAt": "anchor+0d+12:00:00" + }, + "ipAddress": null + } + ], + "usage": [ + { + "dateKey": "day+0", + "totalTokens": 0, + "operatorTokens": 0, + "messageCount": 1 + } + ] + } +} diff --git a/tests/integration/routes/parity/__snapshots__/insights-generate.P22-provider-401.json.snap b/tests/integration/routes/parity/__snapshots__/insights-generate.P22-provider-401.json.snap new file mode 100644 index 000000000..6175d24a4 --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/insights-generate.P22-provider-401.json.snap @@ -0,0 +1,102 @@ +{ + "status": 422, + "body": { + "data": null, + "error": "AI provider rejected the request. Check your API key in Settings > AI", + "meta": { + "errorCode": "http.invalid", + "requestId": "" + } + }, + "events": [ + { + "status": 422, + "action": { + "name": "insights.feature_cache.miss" + }, + "meta": { + "ai_reasoning_effort": "medium", + "ai_reasoning_job": "daily_briefing", + "ai_reasoning_source": "job", + "include_raw": false, + "insights_provider_body_excerpt": "{\"error\":\"status 401\"}", + "insights_provider_error": "upstream answered 401", + "insights_provider_status": 401, + "since_days": 400 + }, + "warnings": null, + "error": null + } + ], + "effects": [ + { + "effect": "ensureUserMoodRollupsFresh", + "args": [ + "" + ] + }, + { + "effect": "provider", + "args": [ + { + "surface": "job", + "chain": [ + "anthropic" + ], + "params": { + "temperature": 0.3, + "maxTokens": 2500, + "responseFormat": "json", + "timeoutMs": 180000, + "reasoning": { + "effort": "medium", + "summaries": false + } + }, + "promptChars": 34773, + "promptSha256": "2197bdec8af3ff7034e7b533788ac94d36b64d4edf8d211bbacfac7e2298ad72" + } + ] + }, + { + "effect": "recordBriefingFailure", + "args": [ + { + "userId": "", + "reason": "provider-error", + "locale": "en", + "httpStatus": 401, + "failureKind": "http" + } + ] + } + ], + "stored": { + "insightsCachedAt": null, + "insightsCachedLocale": null, + "cachedPayload": null, + "snapshotHash": null, + "audit": [ + { + "action": "insights.briefing-failure", + "details": { + "dateKey": "day+0", + "reason": "provider-error", + "httpStatus": 401, + "failureKind": "http", + "locale": "en", + "triedAt": "anchor+0d+12:00:00" + }, + "ipAddress": null + } + ], + "usage": [ + { + "dateKey": "day+0", + "totalTokens": 0, + "operatorTokens": 0, + "messageCount": 1 + } + ] + } +} diff --git a/tests/integration/routes/parity/__snapshots__/insights-generate.P22-provider-429.json.snap b/tests/integration/routes/parity/__snapshots__/insights-generate.P22-provider-429.json.snap new file mode 100644 index 000000000..c220e2f05 --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/insights-generate.P22-provider-429.json.snap @@ -0,0 +1,102 @@ +{ + "status": 429, + "body": { + "data": null, + "error": "AI provider rate-limited the request", + "meta": { + "errorCode": "http.rate_limited", + "requestId": "" + } + }, + "events": [ + { + "status": 429, + "action": { + "name": "insights.feature_cache.miss" + }, + "meta": { + "ai_reasoning_effort": "medium", + "ai_reasoning_job": "daily_briefing", + "ai_reasoning_source": "job", + "include_raw": false, + "insights_provider_body_excerpt": "{\"error\":\"status 429\"}", + "insights_provider_error": "upstream answered 429", + "insights_provider_status": 429, + "since_days": 400 + }, + "warnings": null, + "error": null + } + ], + "effects": [ + { + "effect": "ensureUserMoodRollupsFresh", + "args": [ + "" + ] + }, + { + "effect": "provider", + "args": [ + { + "surface": "job", + "chain": [ + "anthropic" + ], + "params": { + "temperature": 0.3, + "maxTokens": 2500, + "responseFormat": "json", + "timeoutMs": 180000, + "reasoning": { + "effort": "medium", + "summaries": false + } + }, + "promptChars": 34773, + "promptSha256": "2197bdec8af3ff7034e7b533788ac94d36b64d4edf8d211bbacfac7e2298ad72" + } + ] + }, + { + "effect": "recordBriefingFailure", + "args": [ + { + "userId": "", + "reason": "provider-error", + "locale": "en", + "httpStatus": 429, + "failureKind": "http" + } + ] + } + ], + "stored": { + "insightsCachedAt": null, + "insightsCachedLocale": null, + "cachedPayload": null, + "snapshotHash": null, + "audit": [ + { + "action": "insights.briefing-failure", + "details": { + "dateKey": "day+0", + "reason": "provider-error", + "httpStatus": 429, + "failureKind": "http", + "locale": "en", + "triedAt": "anchor+0d+12:00:00" + }, + "ipAddress": null + } + ], + "usage": [ + { + "dateKey": "day+0", + "totalTokens": 0, + "operatorTokens": 0, + "messageCount": 1 + } + ] + } +} diff --git a/tests/integration/routes/parity/__snapshots__/insights-generate.P22-provider-500.json.snap b/tests/integration/routes/parity/__snapshots__/insights-generate.P22-provider-500.json.snap new file mode 100644 index 000000000..d8648f6fa --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/insights-generate.P22-provider-500.json.snap @@ -0,0 +1,102 @@ +{ + "status": 503, + "body": { + "data": null, + "error": "AI provider temporarily unavailable, try again in a moment", + "meta": { + "errorCode": "http.unavailable", + "requestId": "" + } + }, + "events": [ + { + "status": 503, + "action": { + "name": "insights.feature_cache.miss" + }, + "meta": { + "ai_reasoning_effort": "medium", + "ai_reasoning_job": "daily_briefing", + "ai_reasoning_source": "job", + "include_raw": false, + "insights_provider_body_excerpt": "{\"error\":\"status 500\"}", + "insights_provider_error": "upstream answered 500", + "insights_provider_status": 500, + "since_days": 400 + }, + "warnings": null, + "error": null + } + ], + "effects": [ + { + "effect": "ensureUserMoodRollupsFresh", + "args": [ + "" + ] + }, + { + "effect": "provider", + "args": [ + { + "surface": "job", + "chain": [ + "anthropic" + ], + "params": { + "temperature": 0.3, + "maxTokens": 2500, + "responseFormat": "json", + "timeoutMs": 180000, + "reasoning": { + "effort": "medium", + "summaries": false + } + }, + "promptChars": 34773, + "promptSha256": "2197bdec8af3ff7034e7b533788ac94d36b64d4edf8d211bbacfac7e2298ad72" + } + ] + }, + { + "effect": "recordBriefingFailure", + "args": [ + { + "userId": "", + "reason": "provider-error", + "locale": "en", + "httpStatus": 500, + "failureKind": "http" + } + ] + } + ], + "stored": { + "insightsCachedAt": null, + "insightsCachedLocale": null, + "cachedPayload": null, + "snapshotHash": null, + "audit": [ + { + "action": "insights.briefing-failure", + "details": { + "dateKey": "day+0", + "reason": "provider-error", + "httpStatus": 500, + "failureKind": "http", + "locale": "en", + "triedAt": "anchor+0d+12:00:00" + }, + "ipAddress": null + } + ], + "usage": [ + { + "dateKey": "day+0", + "totalTokens": 0, + "operatorTokens": 0, + "messageCount": 1 + } + ] + } +} diff --git a/tests/integration/routes/parity/__snapshots__/insights-generate.P23-invalid-json.json.snap b/tests/integration/routes/parity/__snapshots__/insights-generate.P23-invalid-json.json.snap new file mode 100644 index 000000000..a5781ea92 --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/insights-generate.P23-invalid-json.json.snap @@ -0,0 +1,97 @@ +{ + "status": 422, + "body": { + "data": null, + "error": "AI response was not valid JSON", + "meta": { + "errorCode": "http.invalid", + "requestId": "" + } + }, + "events": [ + { + "status": 422, + "action": { + "name": "insights.feature_cache.miss" + }, + "meta": { + "ai_reasoning_effort": "medium", + "ai_reasoning_job": "daily_briefing", + "ai_reasoning_source": "job", + "include_raw": false, + "since_days": 400 + }, + "warnings": null, + "error": null + } + ], + "effects": [ + { + "effect": "ensureUserMoodRollupsFresh", + "args": [ + "" + ] + }, + { + "effect": "provider", + "args": [ + { + "surface": "job", + "chain": [ + "anthropic" + ], + "params": { + "temperature": 0.3, + "maxTokens": 2500, + "responseFormat": "json", + "timeoutMs": 180000, + "reasoning": { + "effort": "medium", + "summaries": false + } + }, + "promptChars": 34773, + "promptSha256": "2197bdec8af3ff7034e7b533788ac94d36b64d4edf8d211bbacfac7e2298ad72" + } + ] + }, + { + "effect": "recordBriefingFailure", + "args": [ + { + "userId": "", + "reason": "invalid-json", + "locale": "en" + } + ] + } + ], + "stored": { + "insightsCachedAt": null, + "insightsCachedLocale": null, + "cachedPayload": null, + "snapshotHash": null, + "audit": [ + { + "action": "insights.briefing-failure", + "details": { + "dateKey": "day+0", + "reason": "invalid-json", + "httpStatus": null, + "failureKind": null, + "locale": "en", + "triedAt": "anchor+0d+12:00:00" + }, + "ipAddress": null + } + ], + "usage": [ + { + "dateKey": "day+0", + "totalTokens": 1200, + "operatorTokens": 0, + "messageCount": 1 + } + ] + } +} diff --git a/tests/integration/routes/parity/__snapshots__/insights-generate.P24-truncated.json.snap b/tests/integration/routes/parity/__snapshots__/insights-generate.P24-truncated.json.snap new file mode 100644 index 000000000..77c805581 --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/insights-generate.P24-truncated.json.snap @@ -0,0 +1,98 @@ +{ + "status": 422, + "body": { + "data": null, + "error": "AI response was cut off before the JSON completed. Raise the token limit (INSIGHTS_MAX_TOKENS)", + "meta": { + "errorCode": "ai_response_truncated", + "requestId": "" + } + }, + "events": [ + { + "status": 422, + "action": { + "name": "insights.feature_cache.miss" + }, + "meta": { + "ai_reasoning_effort": "medium", + "ai_reasoning_job": "daily_briefing", + "ai_reasoning_source": "job", + "include_raw": false, + "insights_response_truncated": true, + "since_days": 400 + }, + "warnings": null, + "error": null + } + ], + "effects": [ + { + "effect": "ensureUserMoodRollupsFresh", + "args": [ + "" + ] + }, + { + "effect": "provider", + "args": [ + { + "surface": "job", + "chain": [ + "anthropic" + ], + "params": { + "temperature": 0.3, + "maxTokens": 2500, + "responseFormat": "json", + "timeoutMs": 180000, + "reasoning": { + "effort": "medium", + "summaries": false + } + }, + "promptChars": 34773, + "promptSha256": "2197bdec8af3ff7034e7b533788ac94d36b64d4edf8d211bbacfac7e2298ad72" + } + ] + }, + { + "effect": "recordBriefingFailure", + "args": [ + { + "userId": "", + "reason": "invalid-json", + "locale": "en" + } + ] + } + ], + "stored": { + "insightsCachedAt": null, + "insightsCachedLocale": null, + "cachedPayload": null, + "snapshotHash": null, + "audit": [ + { + "action": "insights.briefing-failure", + "details": { + "dateKey": "day+0", + "reason": "invalid-json", + "httpStatus": null, + "failureKind": null, + "locale": "en", + "triedAt": "anchor+0d+12:00:00" + }, + "ipAddress": null + } + ], + "usage": [ + { + "dateKey": "day+0", + "totalTokens": 1200, + "operatorTokens": 0, + "messageCount": 1 + } + ] + } +} diff --git a/tests/integration/routes/parity/__snapshots__/insights-generate.P25-grounding-retry-kept.json.snap b/tests/integration/routes/parity/__snapshots__/insights-generate.P25-grounding-retry-kept.json.snap new file mode 100644 index 000000000..d02342f18 --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/insights-generate.P25-grounding-retry-kept.json.snap @@ -0,0 +1,295 @@ +{ + "status": 200, + "body": { + "data": { + "insights": { + "summary": "Your tracked metrics held steady over the last 30 days.", + "classification": "gut", + "findings": [], + "correlations": [], + "recommendations": [], + "dataQuality": { + "coverage": "good", + "gaps": [], + "confidence": "hoch" + }, + "dailyBriefing": { + "paragraph": "Your readings held steady this week, with no surprises.", + "signalsOfDay": [ + { + "sourceMetric": "weight", + "tone": "info", + "headline": "Weight is steady", + "nudge": "Keep weighing in at the same time each morning.", + "delta": null + } + ], + "keyFindings": [] + }, + "briefingGeneratedAt": "anchor+0d+12:00:00" + }, + "cached": false, + "legacyPayload": false, + "briefingOmittedReason": null + }, + "error": null + }, + "events": [ + { + "status": 200, + "action": { + "name": "insights.generate" + }, + "meta": { + "ai_reasoning_effort": "medium", + "ai_reasoning_job": "daily_briefing", + "ai_reasoning_source": "job", + "cached": false, + "chainProviderType": "anthropic", + "fallbackHopCount": 0, + "include_raw": false, + "model": "claude-parity", + "providerType": "anthropic", + "scopes": 13, + "since_days": 400, + "tokensUsed": 900, + "ungroundedCount": 1 + }, + "warnings": null, + "error": null + } + ], + "effects": [ + { + "effect": "ensureUserMoodRollupsFresh", + "args": [ + "" + ] + }, + { + "effect": "provider", + "args": [ + { + "surface": "job", + "chain": [ + "anthropic" + ], + "params": { + "temperature": 0.3, + "maxTokens": 2500, + "responseFormat": "json", + "timeoutMs": 180000, + "reasoning": { + "effort": "medium", + "summaries": false + } + }, + "promptChars": 34773, + "promptSha256": "2197bdec8af3ff7034e7b533788ac94d36b64d4edf8d211bbacfac7e2298ad72" + } + ] + }, + { + "effect": "provider", + "args": [ + { + "surface": "job", + "chain": [ + "anthropic" + ], + "params": { + "temperature": 0.3, + "maxTokens": 2500, + "responseFormat": "json", + "timeoutMs": 180000 + }, + "promptChars": 35302, + "promptSha256": "2b3978fcf41077f56835864a67264bea403c0dc31a6bd211ecc469afa16d8a7d" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "blood-pressure", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "pulse", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "weight", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "bmi", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "mood", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "medication-compliance", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "general", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "metric:SLEEP_DURATION", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "metric:STEPS", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "metric:BLOOD_GLUCOSE", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "metric:OXYGEN_SATURATION", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "metric:HEART_RATE_VARIABILITY", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "metric:RESTING_HEART_RATE", + "locale": "en" + } + ] + } + ], + "stored": { + "insightsCachedAt": "anchor+0d+12:00:00", + "insightsCachedLocale": "en", + "cachedPayload": { + "summary": "Your tracked metrics held steady over the last 30 days.", + "classification": "gut", + "findings": [], + "correlations": [], + "recommendations": [], + "dataQuality": { + "coverage": "good", + "gaps": [], + "confidence": "hoch" + }, + "dailyBriefing": { + "paragraph": "Your readings held steady this week, with no surprises.", + "signalsOfDay": [ + { + "sourceMetric": "weight", + "tone": "info", + "headline": "Weight is steady", + "nudge": "Keep weighing in at the same time each morning.", + "delta": null + } + ], + "keyFindings": [] + }, + "briefingGeneratedAt": "anchor+0d+12:00:00" + }, + "snapshotHash": "4ac15bc271309c63b3b5d1e43a3367f7abdf952d3f4351bb1b22aa364bcb630a", + "audit": [ + { + "action": "insights.generate", + "details": { + "privacyMode": "aggregated", + "tokensUsed": 900, + "providerType": "anthropic", + "chainProviderType": "anthropic", + "fallbackHopCount": 0, + "model": "claude-parity" + }, + "ipAddress": null + } + ], + "usage": [ + { + "dateKey": "day+0", + "totalTokens": 2100, + "operatorTokens": 0, + "messageCount": 2 + } + ] + } +} diff --git a/tests/integration/routes/parity/__snapshots__/insights-generate.P26-grounding-previous.json.snap b/tests/integration/routes/parity/__snapshots__/insights-generate.P26-grounding-previous.json.snap new file mode 100644 index 000000000..39f29a9ce --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/insights-generate.P26-grounding-previous.json.snap @@ -0,0 +1,280 @@ +{ + "status": 200, + "body": { + "data": { + "insights": { + "summary": "Your tracked metrics held steady over the last 30 days.", + "classification": "gut", + "findings": [], + "correlations": [], + "recommendations": [], + "dataQuality": { + "coverage": "good", + "gaps": [], + "confidence": "hoch" + }, + "dailyBriefing": { + "paragraph": "Yesterday's briefing, grounded when it was written.", + "signalsOfDay": [], + "keyFindings": [] + }, + "briefingGeneratedAt": "anchor+0d+12:00:00" + }, + "cached": false, + "legacyPayload": false, + "briefingOmittedReason": null + }, + "error": null + }, + "events": [ + { + "status": 200, + "action": { + "name": "insights.generate" + }, + "meta": { + "ai_reasoning_effort": "medium", + "ai_reasoning_job": "daily_briefing", + "ai_reasoning_source": "job", + "briefing_fallback": "previous-cached", + "cached": false, + "chainProviderType": "anthropic", + "fallbackHopCount": 0, + "include_raw": false, + "model": "claude-parity", + "providerType": "anthropic", + "scopes": 13, + "since_days": 400, + "tokensUsed": 1200, + "ungroundedCount": 1 + }, + "warnings": null, + "error": null + } + ], + "effects": [ + { + "effect": "ensureUserMoodRollupsFresh", + "args": [ + "" + ] + }, + { + "effect": "provider", + "args": [ + { + "surface": "job", + "chain": [ + "anthropic" + ], + "params": { + "temperature": 0.3, + "maxTokens": 2500, + "responseFormat": "json", + "timeoutMs": 180000, + "reasoning": { + "effort": "medium", + "summaries": false + } + }, + "promptChars": 34773, + "promptSha256": "2197bdec8af3ff7034e7b533788ac94d36b64d4edf8d211bbacfac7e2298ad72" + } + ] + }, + { + "effect": "provider", + "args": [ + { + "surface": "job", + "chain": [ + "anthropic" + ], + "params": { + "temperature": 0.3, + "maxTokens": 2500, + "responseFormat": "json", + "timeoutMs": 180000 + }, + "promptChars": 35302, + "promptSha256": "2b3978fcf41077f56835864a67264bea403c0dc31a6bd211ecc469afa16d8a7d" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "blood-pressure", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "pulse", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "weight", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "bmi", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "mood", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "medication-compliance", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "general", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "metric:SLEEP_DURATION", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "metric:STEPS", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "metric:BLOOD_GLUCOSE", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "metric:OXYGEN_SATURATION", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "metric:HEART_RATE_VARIABILITY", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "metric:RESTING_HEART_RATE", + "locale": "en" + } + ] + } + ], + "stored": { + "insightsCachedAt": "anchor+0d+12:00:00", + "insightsCachedLocale": "en", + "cachedPayload": { + "summary": "Your tracked metrics held steady over the last 30 days.", + "classification": "gut", + "findings": [], + "correlations": [], + "recommendations": [], + "dataQuality": { + "coverage": "good", + "gaps": [], + "confidence": "hoch" + }, + "dailyBriefing": { + "paragraph": "Yesterday's briefing, grounded when it was written.", + "signalsOfDay": [], + "keyFindings": [] + }, + "briefingGeneratedAt": "anchor+0d+12:00:00" + }, + "snapshotHash": "4ac15bc271309c63b3b5d1e43a3367f7abdf952d3f4351bb1b22aa364bcb630a", + "audit": [ + { + "action": "insights.generate", + "details": { + "privacyMode": "aggregated", + "tokensUsed": 1200, + "providerType": "anthropic", + "chainProviderType": "anthropic", + "fallbackHopCount": 0, + "model": "claude-parity" + }, + "ipAddress": null + } + ], + "usage": [ + { + "dateKey": "day+0", + "totalTokens": 2400, + "operatorTokens": 0, + "messageCount": 2 + } + ] + } +} diff --git a/tests/integration/routes/parity/__snapshots__/insights-generate.P27-grounding-stripped.json.snap b/tests/integration/routes/parity/__snapshots__/insights-generate.P27-grounding-stripped.json.snap new file mode 100644 index 000000000..6b9b73e81 --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/insights-generate.P27-grounding-stripped.json.snap @@ -0,0 +1,294 @@ +{ + "status": 200, + "body": { + "data": { + "insights": { + "summary": "Your tracked metrics held steady over the last 30 days.", + "classification": "gut", + "findings": [], + "correlations": [], + "recommendations": [], + "dataQuality": { + "coverage": "good", + "gaps": [], + "confidence": "hoch" + }, + "dailyBriefing": null, + "briefingGeneratedAt": "anchor+0d+12:00:00" + }, + "cached": false, + "legacyPayload": false, + "briefingOmittedReason": "ungrounded" + }, + "error": null + }, + "events": [ + { + "status": 200, + "action": { + "name": "insights.generate" + }, + "meta": { + "ai_reasoning_effort": "medium", + "ai_reasoning_job": "daily_briefing", + "ai_reasoning_source": "job", + "briefing_fallback": "stripped", + "cached": false, + "chainProviderType": "anthropic", + "fallbackHopCount": 0, + "include_raw": false, + "model": "claude-parity", + "providerType": "anthropic", + "scopes": 13, + "since_days": 400, + "tokensUsed": 1200, + "ungroundedCount": 1 + }, + "warnings": null, + "error": null + } + ], + "effects": [ + { + "effect": "ensureUserMoodRollupsFresh", + "args": [ + "" + ] + }, + { + "effect": "provider", + "args": [ + { + "surface": "job", + "chain": [ + "anthropic" + ], + "params": { + "temperature": 0.3, + "maxTokens": 2500, + "responseFormat": "json", + "timeoutMs": 180000, + "reasoning": { + "effort": "medium", + "summaries": false + } + }, + "promptChars": 34773, + "promptSha256": "2197bdec8af3ff7034e7b533788ac94d36b64d4edf8d211bbacfac7e2298ad72" + } + ] + }, + { + "effect": "provider", + "args": [ + { + "surface": "job", + "chain": [ + "anthropic" + ], + "params": { + "temperature": 0.3, + "maxTokens": 2500, + "responseFormat": "json", + "timeoutMs": 180000 + }, + "promptChars": 35302, + "promptSha256": "2b3978fcf41077f56835864a67264bea403c0dc31a6bd211ecc469afa16d8a7d" + } + ] + }, + { + "effect": "recordBriefingFailure", + "args": [ + { + "userId": "", + "reason": "briefing-ungrounded", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "blood-pressure", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "pulse", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "weight", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "bmi", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "mood", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "medication-compliance", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "general", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "metric:SLEEP_DURATION", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "metric:STEPS", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "metric:BLOOD_GLUCOSE", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "metric:OXYGEN_SATURATION", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "metric:HEART_RATE_VARIABILITY", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "metric:RESTING_HEART_RATE", + "locale": "en" + } + ] + } + ], + "stored": { + "insightsCachedAt": "anchor+0d+12:00:00", + "insightsCachedLocale": "en", + "cachedPayload": { + "summary": "Your tracked metrics held steady over the last 30 days.", + "classification": "gut", + "findings": [], + "correlations": [], + "recommendations": [], + "dataQuality": { + "coverage": "good", + "gaps": [], + "confidence": "hoch" + }, + "dailyBriefing": null, + "briefingGeneratedAt": "anchor+0d+12:00:00" + }, + "snapshotHash": "4ac15bc271309c63b3b5d1e43a3367f7abdf952d3f4351bb1b22aa364bcb630a", + "audit": [ + { + "action": "insights.briefing-failure", + "details": { + "dateKey": "day+0", + "reason": "briefing-ungrounded", + "httpStatus": null, + "failureKind": null, + "locale": "en", + "triedAt": "anchor+0d+12:00:00" + }, + "ipAddress": null + }, + { + "action": "insights.generate", + "details": { + "privacyMode": "aggregated", + "tokensUsed": 1200, + "providerType": "anthropic", + "chainProviderType": "anthropic", + "fallbackHopCount": 0, + "model": "claude-parity" + }, + "ipAddress": null + } + ], + "usage": [ + { + "dateKey": "day+0", + "totalTokens": 2400, + "operatorTokens": 0, + "messageCount": 2 + } + ] + } +} diff --git a/tests/integration/routes/parity/__snapshots__/insights-generate.P28-grounding-retry-unparseable.json.snap b/tests/integration/routes/parity/__snapshots__/insights-generate.P28-grounding-retry-unparseable.json.snap new file mode 100644 index 000000000..6b9b73e81 --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/insights-generate.P28-grounding-retry-unparseable.json.snap @@ -0,0 +1,294 @@ +{ + "status": 200, + "body": { + "data": { + "insights": { + "summary": "Your tracked metrics held steady over the last 30 days.", + "classification": "gut", + "findings": [], + "correlations": [], + "recommendations": [], + "dataQuality": { + "coverage": "good", + "gaps": [], + "confidence": "hoch" + }, + "dailyBriefing": null, + "briefingGeneratedAt": "anchor+0d+12:00:00" + }, + "cached": false, + "legacyPayload": false, + "briefingOmittedReason": "ungrounded" + }, + "error": null + }, + "events": [ + { + "status": 200, + "action": { + "name": "insights.generate" + }, + "meta": { + "ai_reasoning_effort": "medium", + "ai_reasoning_job": "daily_briefing", + "ai_reasoning_source": "job", + "briefing_fallback": "stripped", + "cached": false, + "chainProviderType": "anthropic", + "fallbackHopCount": 0, + "include_raw": false, + "model": "claude-parity", + "providerType": "anthropic", + "scopes": 13, + "since_days": 400, + "tokensUsed": 1200, + "ungroundedCount": 1 + }, + "warnings": null, + "error": null + } + ], + "effects": [ + { + "effect": "ensureUserMoodRollupsFresh", + "args": [ + "" + ] + }, + { + "effect": "provider", + "args": [ + { + "surface": "job", + "chain": [ + "anthropic" + ], + "params": { + "temperature": 0.3, + "maxTokens": 2500, + "responseFormat": "json", + "timeoutMs": 180000, + "reasoning": { + "effort": "medium", + "summaries": false + } + }, + "promptChars": 34773, + "promptSha256": "2197bdec8af3ff7034e7b533788ac94d36b64d4edf8d211bbacfac7e2298ad72" + } + ] + }, + { + "effect": "provider", + "args": [ + { + "surface": "job", + "chain": [ + "anthropic" + ], + "params": { + "temperature": 0.3, + "maxTokens": 2500, + "responseFormat": "json", + "timeoutMs": 180000 + }, + "promptChars": 35302, + "promptSha256": "2b3978fcf41077f56835864a67264bea403c0dc31a6bd211ecc469afa16d8a7d" + } + ] + }, + { + "effect": "recordBriefingFailure", + "args": [ + { + "userId": "", + "reason": "briefing-ungrounded", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "blood-pressure", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "pulse", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "weight", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "bmi", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "mood", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "medication-compliance", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "general", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "metric:SLEEP_DURATION", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "metric:STEPS", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "metric:BLOOD_GLUCOSE", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "metric:OXYGEN_SATURATION", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "metric:HEART_RATE_VARIABILITY", + "locale": "en" + } + ] + }, + { + "effect": "enqueueStatusGeneration", + "args": [ + { + "userId": "", + "metric": "metric:RESTING_HEART_RATE", + "locale": "en" + } + ] + } + ], + "stored": { + "insightsCachedAt": "anchor+0d+12:00:00", + "insightsCachedLocale": "en", + "cachedPayload": { + "summary": "Your tracked metrics held steady over the last 30 days.", + "classification": "gut", + "findings": [], + "correlations": [], + "recommendations": [], + "dataQuality": { + "coverage": "good", + "gaps": [], + "confidence": "hoch" + }, + "dailyBriefing": null, + "briefingGeneratedAt": "anchor+0d+12:00:00" + }, + "snapshotHash": "4ac15bc271309c63b3b5d1e43a3367f7abdf952d3f4351bb1b22aa364bcb630a", + "audit": [ + { + "action": "insights.briefing-failure", + "details": { + "dateKey": "day+0", + "reason": "briefing-ungrounded", + "httpStatus": null, + "failureKind": null, + "locale": "en", + "triedAt": "anchor+0d+12:00:00" + }, + "ipAddress": null + }, + { + "action": "insights.generate", + "details": { + "privacyMode": "aggregated", + "tokensUsed": 1200, + "providerType": "anthropic", + "chainProviderType": "anthropic", + "fallbackHopCount": 0, + "model": "claude-parity" + }, + "ipAddress": null + } + ], + "usage": [ + { + "dateKey": "day+0", + "totalTokens": 2400, + "operatorTokens": 0, + "messageCount": 2 + } + ] + } +} diff --git a/tests/integration/routes/parity/__snapshots__/insights-generate.P29-outbound-screened.json.snap b/tests/integration/routes/parity/__snapshots__/insights-generate.P29-outbound-screened.json.snap new file mode 100644 index 000000000..9509662c4 --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/insights-generate.P29-outbound-screened.json.snap @@ -0,0 +1,76 @@ +{ + "status": 502, + "body": { + "data": null, + "error": "The generated insight did not pass the safety check and was discarded. Try regenerating.", + "meta": { + "errorCode": "insights.generate.outboundScreened", + "requestId": "" + } + }, + "events": [ + { + "status": 502, + "action": { + "name": "insights.generate.outbound_blocked" + }, + "meta": { + "ai_reasoning_effort": "medium", + "ai_reasoning_job": "daily_briefing", + "ai_reasoning_source": "job", + "include_raw": false, + "locale": "en", + "reason": "risk_score", + "since_days": 400 + }, + "warnings": null, + "error": null + } + ], + "effects": [ + { + "effect": "ensureUserMoodRollupsFresh", + "args": [ + "" + ] + }, + { + "effect": "provider", + "args": [ + { + "surface": "job", + "chain": [ + "anthropic" + ], + "params": { + "temperature": 0.3, + "maxTokens": 2500, + "responseFormat": "json", + "timeoutMs": 180000, + "reasoning": { + "effort": "medium", + "summaries": false + } + }, + "promptChars": 34773, + "promptSha256": "2197bdec8af3ff7034e7b533788ac94d36b64d4edf8d211bbacfac7e2298ad72" + } + ] + } + ], + "stored": { + "insightsCachedAt": null, + "insightsCachedLocale": null, + "cachedPayload": null, + "snapshotHash": null, + "audit": [], + "usage": [ + { + "dateKey": "day+0", + "totalTokens": 1200, + "operatorTokens": 0, + "messageCount": 1 + } + ] + } +} diff --git a/tests/integration/routes/parity/__snapshots__/insights-generate.P30-profile-scope-changed.json.snap b/tests/integration/routes/parity/__snapshots__/insights-generate.P30-profile-scope-changed.json.snap new file mode 100644 index 000000000..3f34aaa8d --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/insights-generate.P30-profile-scope-changed.json.snap @@ -0,0 +1,74 @@ +{ + "status": 409, + "body": { + "data": null, + "error": "Profile scope changed during insight generation", + "meta": { + "errorCode": "insights.generate.profileScopeChanged", + "requestId": "" + } + }, + "events": [ + { + "status": 409, + "action": { + "name": "insights.feature_cache.miss" + }, + "meta": { + "ai_reasoning_effort": "medium", + "ai_reasoning_job": "daily_briefing", + "ai_reasoning_source": "job", + "include_raw": false, + "since_days": 400 + }, + "warnings": null, + "error": null + } + ], + "effects": [ + { + "effect": "ensureUserMoodRollupsFresh", + "args": [ + "" + ] + }, + { + "effect": "provider", + "args": [ + { + "surface": "job", + "chain": [ + "anthropic" + ], + "params": { + "temperature": 0.3, + "maxTokens": 2500, + "responseFormat": "json", + "timeoutMs": 180000, + "reasoning": { + "effort": "medium", + "summaries": false + } + }, + "promptChars": 34773, + "promptSha256": "2197bdec8af3ff7034e7b533788ac94d36b64d4edf8d211bbacfac7e2298ad72" + } + ] + } + ], + "stored": { + "insightsCachedAt": null, + "insightsCachedLocale": null, + "cachedPayload": null, + "snapshotHash": null, + "audit": [], + "usage": [ + { + "dateKey": "day+0", + "totalTokens": 1200, + "operatorTokens": 0, + "messageCount": 1 + } + ] + } +} diff --git a/tests/integration/routes/parity/__snapshots__/insights-generate.P31-scope-changed.json.snap b/tests/integration/routes/parity/__snapshots__/insights-generate.P31-scope-changed.json.snap new file mode 100644 index 000000000..a48fd39b2 --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/insights-generate.P31-scope-changed.json.snap @@ -0,0 +1,74 @@ +{ + "status": 409, + "body": { + "data": null, + "error": "Insight scope changed during generation", + "meta": { + "errorCode": "insights.generate.scopeChanged", + "requestId": "" + } + }, + "events": [ + { + "status": 409, + "action": { + "name": "insights.feature_cache.miss" + }, + "meta": { + "ai_reasoning_effort": "medium", + "ai_reasoning_job": "daily_briefing", + "ai_reasoning_source": "job", + "include_raw": false, + "since_days": 400 + }, + "warnings": null, + "error": null + } + ], + "effects": [ + { + "effect": "ensureUserMoodRollupsFresh", + "args": [ + "" + ] + }, + { + "effect": "provider", + "args": [ + { + "surface": "job", + "chain": [ + "anthropic" + ], + "params": { + "temperature": 0.3, + "maxTokens": 2500, + "responseFormat": "json", + "timeoutMs": 180000, + "reasoning": { + "effort": "medium", + "summaries": false + } + }, + "promptChars": 34773, + "promptSha256": "2197bdec8af3ff7034e7b533788ac94d36b64d4edf8d211bbacfac7e2298ad72" + } + ] + } + ], + "stored": { + "insightsCachedAt": null, + "insightsCachedLocale": null, + "cachedPayload": null, + "snapshotHash": null, + "audit": [], + "usage": [ + { + "dateKey": "day+0", + "totalTokens": 1200, + "operatorTokens": 0, + "messageCount": 1 + } + ] + } +} diff --git a/tests/integration/routes/parity/insights-comprehensive.parity.test.ts b/tests/integration/routes/parity/insights-comprehensive.parity.test.ts new file mode 100644 index 000000000..1518d3c8b --- /dev/null +++ b/tests/integration/routes/parity/insights-comprehensive.parity.test.ts @@ -0,0 +1,471 @@ +/** + * Parity for `GET /api/insights/comprehensive` while the overview builder + * moves out of the route into `src/lib/insights/`. + * + * One account with a full record (see `record-fixture.ts`) walks through the + * cases the route tells apart, in order, each request seeing the cache the + * earlier ones filled: an anonymous call, the overview cold, warm, and stale + * after a measurement write (served while one rebuild runs behind it), the + * `ai` block after the AI analysis opt-out and after the operator's master + * switch (both off the cached body), a delegate with MANAGE access reading + * the record cold, one with READ access refused, and the shared analytics + * read ceiling. A second account in a zone west of UTC carries the sparse + * shapes: mood entries with no rollup tier behind them, pulse without a + * resting series, a medication with no schedule, an as-needed one, and no + * height. An account with no data closes the walk. + * + * The clock is pinned (`Date` only) to noon of the day the test runs. The + * aggregator cuts its 7-, 30- and 60-day windows in SQL against the + * database clock, which the pin cannot reach, so the record's measurements + * on exactly those days back are removed: a reading there would fall in or + * out of a window with the hour the test runs. Every instant and day key in + * the snapshot is written relative to the start of the day. + * + * The rollup refreshes the route starts and does not await are recorded + * instead of run; the record's rollups are built once before the walk. + */ +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; + +import { GET } from "@/app/api/insights/comprehensive/route"; +import { __resetAllCachesForTests } from "@/lib/cache/server-cache"; +import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; +import { settleBackgroundTasks } from "@/lib/logging/background-tasks"; + +import { anonymous, callRoute, type RouteAuth } from "../../../kit/call-route"; +import { + makeGrant, + makeSession, + makeUser, + type SessionFixture, +} from "../../../kit/factories"; +import { + effectRecorder, + eventFacts, + expectParity, + relativeToAnchor, + stable, + startOfUtcToday, + wideEventRecorder, +} from "../../../kit/parity"; +import { + getPrismaClient, + switchSessionTo, + truncateAllTables, +} from "../../setup"; +import { parityId, seedParityRecord } from "./record-fixture"; + +vi.mock("@/lib/logging/transports", async (importOriginal) => { + const actual = + await importOriginal(); + const { wideEventRecorder: recorder } = await import("../../../kit/parity"); + return { + ...actual, + emitIfSampled: (event: (typeof recorder.events)[number]) => { + recorder.events.push(event); + }, + }; +}); + +vi.mock("@/lib/rollups/measurement-rollups", async (importOriginal) => { + const actual = + await importOriginal(); + const { effectRecorder } = await import("../../../kit/parity"); + return { + ...actual, + ensureUserRollupsFresh: async (...args: unknown[]) => { + effectRecorder.record("ensureUserRollupsFresh", args); + }, + }; +}); + +vi.mock("@/lib/rollups/mood-rollups", async (importOriginal) => { + const actual = + await importOriginal(); + const { effectRecorder } = await import("../../../kit/parity"); + return { + ...actual, + ensureUserMoodRollupsFresh: async (...args: unknown[]) => { + effectRecorder.record("ensureUserMoodRollupsFresh", args); + }, + }; +}); + +const prisma = getPrismaClient(); +const DAY = 86_400_000; +const ANCHOR = startOfUtcToday(); +const NOW = new Date(ANCHOR.getTime() + 12 * 60 * 60 * 1000); +/** Days back on which a SQL window edge falls (see the header). */ +const EDGE_DAYS = [7, 30, 60]; + +const OWNER_ID = parityId("insightscomp", 1); +const SPARSE_ID = parityId("insightscomp", 2); +const EMPTY_ID = parityId("insightscomp", 3); +const MANAGER_ID = parityId("insightscomp", 4); +const READER_ID = parityId("insightscomp", 5); + +let owner: SessionFixture; +let sparse: RouteAuth; +let empty: RouteAuth; +let manager: SessionFixture; +let reader: SessionFixture; + +/** + * Keys of the aggregator's per-phase timings, which the clock and the load + * decide. Their presence is kept, their value is not. + */ +function timingKeys(value: unknown, out = new Set()): Set { + if (Array.isArray(value)) value.forEach((v) => timingKeys(v, out)); + else if (value && typeof value === "object") { + for (const [k, v] of Object.entries(value)) { + if (/_ms$/.test(k)) out.add(k); + timingKeys(v, out); + } + } + return out; +} + +/** + * A stale serve starts one rebuild and returns without waiting for it. The + * rebuild annotates the request's event too, and whether it gets there before + * the event is emitted is a race between two sets of queries. On that case + * only the cache outcome is kept, which the route decides alone. + */ +function cacheFactsOnly(facts: unknown): unknown { + const f = facts as { meta?: Record | null }; + const meta = Object.fromEntries( + Object.entries(f.meta ?? {}).filter(([k]) => k.startsWith("cache.")), + ); + return { ...f, action: "", meta }; +} + +/** + * The aggregator's sub-queries run in parallel and annotate as each one + * finishes, so the order of their keys in the event follows the database, not + * the code. The keys are sorted; what they hold is unchanged. + */ +function sortedMeta(facts: unknown): unknown { + const f = facts as { meta?: Record | null }; + if (!f.meta) return facts; + const meta = Object.fromEntries( + Object.entries(f.meta).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)), + ); + return { ...f, meta }; +} + +async function step( + name: string, + options: { auth?: RouteAuth; raced?: boolean } = {}, +): Promise { + wideEventRecorder.take(); + effectRecorder.take(); + const res = await callRoute(GET, { + method: "GET", + route: "/api/insights/comprehensive", + auth: options.auth ?? owner, + }); + // A stale serve leaves one rebuild running; it finishes before the + // snapshot so the next case starts from the same cache. + await settleBackgroundTasks(); + const events = wideEventRecorder.take(); + expect(events.length).toBeGreaterThan(0); + const facts = events + .map(eventFacts) + .map(sortedMeta) + .map((f) => (options.raced ? cacheFactsOnly(f) : f)); + await expectParity( + `insights-comprehensive.${name}`, + stable( + relativeToAnchor( + { + status: res.status, + body: res.body, + events: facts, + effects: effectRecorder.take(), + }, + ANCHOR, + ), + { since: NOW, volatileKeys: [...timingKeys(facts)] }, + ), + ); +} + +/** Remove the record's measurements on the SQL window edge days. */ +async function dropEdgeDays(userId: string): Promise { + for (const d of EDGE_DAYS) { + await prisma.measurement.deleteMany({ + where: { + userId, + measuredAt: { + gte: new Date(ANCHOR.getTime() - d * DAY), + lt: new Date(ANCHOR.getTime() - (d - 1) * DAY), + }, + }, + }); + } +} + +/** Mood without rollups, pulse without a resting series, odd medications. */ +async function seedSparseRecord(userId: string): Promise { + const at = (d: number, hh: number, mm = 0) => + new Date(ANCHOR.getTime() - d * DAY + (hh * 60 + mm) * 60_000); + const rows = []; + for (let d = 1; d < 50; d++) { + if (EDGE_DAYS.includes(d)) continue; + rows.push( + { + userId, + type: "WEIGHT" as const, + value: 70 + (d % 5) * 0.3, + unit: "kg", + measuredAt: at(d, 3, 30), + }, + { + userId, + type: "BLOOD_PRESSURE_SYS" as const, + // Higher on the days the morning dose was skipped, so the + // continuity correlation has something to find. + value: 131 + ((d * 3) % 11) + (d % 5 === 0 ? 22 : 0), + unit: "mmHg", + measuredAt: at(d, 4, 10), + }, + { + userId, + type: "BLOOD_PRESSURE_DIA" as const, + value: 84 + ((d * 2) % 7), + unit: "mmHg", + measuredAt: at(d, 4, 10), + }, + { + userId, + type: "PULSE" as const, + value: 66 + (d % 9), + unit: "bpm", + measuredAt: at(d, 4, 0), + }, + { + userId, + type: "PULSE" as const, + value: 92 + (d % 5), + unit: "bpm", + measuredAt: at(d, 18, 0), + }, + { + userId, + type: "PULSE" as const, + value: 78 + (d % 7), + unit: "bpm", + measuredAt: at(d, 15, 0), + }, + ); + } + await prisma.measurement.createMany({ data: rows }); + const moods = ["LAUSIG", "SCHLECHT", "OKAY", "GUT", "SUPER_GUT"]; + const moodRows = []; + for (let d = 1; d < 30; d++) { + const loggedAt = at(d, 2, 15); + const score = 1 + ((d * 2) % 5); + moodRows.push({ + userId, + // 02:15 UTC is the evening before in Bogotá. + date: new Date(loggedAt.getTime() - 5 * 60 * 60 * 1000) + .toISOString() + .slice(0, 10), + mood: moods[score - 1], + score, + moodLoggedAt: loggedAt, + tz: "America/Bogota", + createdAt: loggedAt, + }); + } + await prisma.moodEntry.createMany({ data: moodRows }); + const created = new Date(ANCHOR.getTime() - 60 * DAY); + await prisma.medication.create({ + data: { + id: parityId("insightscompmed", 1), + userId, + name: "Amlodipine", + dose: "5mg", + createdAt: created, + schedules: { + create: [ + { + id: parityId("insightscompsch", 1), + windowStart: "07:00", + windowEnd: "09:00", + }, + ], + }, + categoryAssignment: { create: { category: "BLOOD_PRESSURE" } }, + }, + }); + await prisma.medication.create({ + data: { + id: parityId("insightscompmed", 2), + userId, + name: "No schedule", + dose: "1 tab", + createdAt: created, + }, + }); + await prisma.medication.create({ + data: { + id: parityId("insightscompmed", 3), + userId, + name: "Ibuprofen", + dose: "400mg", + asNeeded: true, + createdAt: created, + }, + }); + const intakes = []; + for (let d = 1; d <= 40; d++) { + if (d % 4 === 0 || EDGE_DAYS.includes(d)) continue; + intakes.push({ + id: parityId("insightscompie", d), + userId, + medicationId: parityId("insightscompmed", 1), + scheduledFor: at(d, 12, 0), + takenAt: d % 5 === 0 ? null : at(d, 12, 30), + skipped: d % 5 === 0, + createdAt: at(d, 12, 0), + }); + } + await prisma.medicationIntakeEvent.createMany({ data: intakes }); +} + +beforeAll(async () => { + vi.useFakeTimers({ toFake: ["Date"], now: NOW }); + await truncateAllTables(prisma); + __resetAllCachesForTests(); + const ownerRow = await makeUser({ + id: OWNER_ID, + timezone: "UTC", + locale: "en", + heightCm: 178, + dateOfBirth: new Date("1975-04-02T00:00:00.000Z"), + gender: "male", + aiProvider: "ANTHROPIC", + aiModel: "claude-sonnet-4-6", + // Presence only: the overview never decrypts a key. + aiAnthropicKeyEncrypted: "v1:presence-only", + }); + await prisma.consentReceipt.create({ + data: { + userId: OWNER_ID, + kind: "ai_full", + artefact: "test", + signedAt: NOW, + }, + }); + await seedParityRecord({ userId: OWNER_ID, now: NOW, tz: "UTC", tag: "ic" }); + await dropEdgeDays(OWNER_ID); + const rollups = await vi.importActual< + typeof import("@/lib/rollups/measurement-rollups") + >("@/lib/rollups/measurement-rollups"); + await rollups.ensureUserRollupsFresh(OWNER_ID); + const moodRollups = await vi.importActual< + typeof import("@/lib/rollups/mood-rollups") + >("@/lib/rollups/mood-rollups"); + await moodRollups.ensureUserMoodRollupsFresh(OWNER_ID); + await settleBackgroundTasks(); + owner = await makeSession(ownerRow); + + const sparseRow = await makeUser({ + id: SPARSE_ID, + timezone: "America/Bogota", + locale: "de", + dateOfBirth: new Date("1950-08-20T00:00:00.000Z"), + }); + await seedSparseRecord(SPARSE_ID); + // Measurement rollups only: the mood tier stays empty on purpose. + await rollups.ensureUserRollupsFresh(SPARSE_ID); + await settleBackgroundTasks(); + sparse = await makeSession(sparseRow); + + const emptyRow = await makeUser({ id: EMPTY_ID, timezone: "UTC" }); + empty = await makeSession(emptyRow); + + const managerRow = await makeUser({ id: MANAGER_ID, timezone: "UTC" }); + await makeGrant(ownerRow, managerRow, { access: "MANAGE" }); + manager = await makeSession(managerRow); + await switchSessionTo(manager.sessionId, OWNER_ID); + + const readerRow = await makeUser({ id: READER_ID, timezone: "UTC" }); + await makeGrant(ownerRow, readerRow, { access: "READ" }); + reader = await makeSession(readerRow); + await switchSessionTo(reader.sessionId, OWNER_ID); +}); + +afterAll(() => { + vi.useRealTimers(); +}); + +describe("GET /api/insights/comprehensive parity", () => { + it("01 an anonymous call", async () => { + await step("01-anonymous", { auth: anonymous }); + }); + + it("02 the overview, cold", async () => { + await step("02-cold"); + }); + + it("03 the overview, warm", async () => { + await step("03-warm"); + }); + + it("04 the overview, stale after a measurement write", async () => { + invalidateUserMeasurements(OWNER_ID); + await step("04-stale", { raced: true }); + }); + + it("05 the ai block after the AI analysis opt-out", async () => { + await prisma.user.update({ + where: { id: OWNER_ID }, + data: { modulePreferencesJson: { insights: false } }, + }); + await step("05-ai-analysis-off"); + await prisma.user.update({ + where: { id: OWNER_ID }, + data: { modulePreferencesJson: {} }, + }); + }); + + it("06 the ai block with the operator's master switch off", async () => { + await prisma.appSettings.upsert({ + where: { id: "singleton" }, + create: { id: "singleton", assistantEnabled: false }, + update: { assistantEnabled: false }, + }); + await step("06-operator-off"); + await prisma.appSettings.update({ + where: { id: "singleton" }, + data: { assistantEnabled: true }, + }); + }); + + it("07 a delegate with MANAGE access, cold", async () => { + __resetAllCachesForTests(); + await step("07-manage-delegate", { auth: manager }); + }); + + it("08 a delegate with READ access", async () => { + await step("08-read-delegate", { auth: reader }); + }); + + it("09 the shared analytics read ceiling", async () => { + await prisma.$executeRaw` + INSERT INTO rate_limits (key, count, reset_at) + VALUES (${`analytics-read:${OWNER_ID}`}, 100000, (NOW() AT TIME ZONE 'UTC') + INTERVAL '1 hour') + ON CONFLICT (key) DO UPDATE SET count = 100000`; + await step("09-rate-limited"); + await prisma.$executeRaw`DELETE FROM rate_limits WHERE key = ${`analytics-read:${OWNER_ID}`}`; + }); + + it("10 a sparse record west of UTC", async () => { + await step("10-sparse", { auth: sparse }); + }); + + it("11 an account with no data", async () => { + await step("11-empty", { auth: empty }); + }); +}); diff --git a/tests/integration/routes/parity/insights-generate.parity.test.ts b/tests/integration/routes/parity/insights-generate.parity.test.ts new file mode 100644 index 000000000..0b2d475e8 --- /dev/null +++ b/tests/integration/routes/parity/insights-generate.parity.test.ts @@ -0,0 +1,1027 @@ +/** + * Parity for `GET` and `POST /api/insights/generate` while the briefing read + * and generation move out of the route into `src/lib/insights/`. + * + * One account with a full record (see `record-fixture.ts`) and its own + * Anthropic key walks through every case the two handlers tell apart. Each + * case starts from a known briefing state (`resetBriefing`): the cached + * payload, its stamp and language, the failure markers, the day's token + * ledger and the hourly generation quota are written for that case alone. + * + * The read: refusals, an empty cache, a fresh, a stale, an other-language, + * an unparseable and a legacy cache, a held failure and a withheld + * (ungrounded) briefing. The generation: the cache short-circuit and its + * bypasses, the body cap, the hourly quota with and without the cached + * fallback, a chain that resolves to nothing, the three rungs of the payload + * downgrade ladder, the day's token ceiling, every provider failure class + * the route maps, invalid and truncated replies, the grounding gate (retry + * kept, previous briefing stood in, stripped, retry unparseable), the + * outbound screen, both scope races, and successes in two languages, one + * with the comparison snapshot, an about-me block and the short form. + * + * The provider is never called: `runRawCompletionWithFallback` is replaced + * by a script each case fills, and every call it receives is recorded (the + * chain, the parameters, and a digest of the prompt). The queue enqueues and + * the failure markers the route does not await are recorded too; the markers + * are also written, and waited for before the snapshot. + * + * The clock is pinned (`Date` only) to noon of the day the test runs, and + * every instant and day key in the snapshot is written relative to the start + * of the day. The record's measurements on the 7th, 30th and 60th day back + * are removed, so a window cut in SQL against the database clock never meets + * a reading on its edge. Two values change from one day to the next by + * design and are normalised here: the opener hint and the first-name line of + * the briefing prompt rotate per user and day, and the stored snapshot hash + * covers dated features, so it is recorded as the hash of the same input + * with its dates written relative to the anchor. + */ +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; + +import { GET, POST } from "@/app/api/insights/generate/route"; +import { encrypt } from "@/lib/crypto"; +import { AllProvidersFailedError } from "@/lib/ai/provider-runner"; +import { + insightsCacheWrite, + openInsightsCachedText, +} from "@/lib/insights/cached-text-cipher"; +import { BRIEFING_GENERATED_AT_KEY } from "@/lib/insights/briefing-generated-at"; +import { settleBackgroundTasks } from "@/lib/logging/background-tasks"; + +import { anonymous, callRoute, type RouteAuth } from "../../../kit/call-route"; +import { makeSession, makeUser } from "../../../kit/factories"; +import { + effectRecorder, + eventFacts, + expectParity, + relativeToAnchor, + stable, + startOfUtcToday, + wideEventRecorder, +} from "../../../kit/parity"; +import { getPrismaClient, truncateAllTables } from "../../setup"; +import { parityId, seedParityRecord } from "./record-fixture"; + +/** What the provider stand-in answers, one entry per call, in order. */ +type ScriptEntry = + | { + content: string; + finishReason?: string; + tokensUsed?: number; + /** Runs inside the call, before the answer: a write racing the request. */ + during?: () => Promise; + } + | { error: () => Error }; + +const seams = vi.hoisted(() => ({ + script: [] as ScriptEntry[], + /** Feature reads that fail as oversized before computing, in order. */ + oversize: [] as number[], + /** Real snapshot hash → the same input's hash with anchored dates. */ + hashes: new Map(), + pending: [] as Promise[], + /** A provider call no case scripted: the case is wrong, not the route. */ + unscripted: 0, +})); + +vi.mock("@/lib/logging/transports", async (importOriginal) => { + const actual = + await importOriginal(); + const { wideEventRecorder: recorder } = await import("../../../kit/parity"); + return { + ...actual, + emitIfSampled: (event: (typeof recorder.events)[number]) => { + recorder.events.push(event); + }, + }; +}); + +vi.mock("@/lib/rollups/measurement-rollups", async (importOriginal) => { + const actual = + await importOriginal(); + const { effectRecorder } = await import("../../../kit/parity"); + return { + ...actual, + ensureUserRollupsFresh: async (...args: unknown[]) => { + effectRecorder.record("ensureUserRollupsFresh", args); + }, + }; +}); + +vi.mock("@/lib/rollups/mood-rollups", async (importOriginal) => { + const actual = + await importOriginal(); + const { effectRecorder } = await import("../../../kit/parity"); + return { + ...actual, + ensureUserMoodRollupsFresh: async (...args: unknown[]) => { + effectRecorder.record("ensureUserMoodRollupsFresh", args); + }, + }; +}); + +vi.mock("@/lib/ai/provider-runner", async (importOriginal) => { + const actual = + await importOriginal(); + const { effectRecorder, relativeToAnchor, startOfUtcToday } = + await import("../../../kit/parity"); + return { + ...actual, + runRawCompletionWithFallback: async ( + args: Parameters[0], + ) => { + const { messages, system, signal: _signal, ...rest } = args.params; + const prompt = String( + relativeToAnchor( + `${system}\n---\n${messages + .map((m) => + typeof m.content === "string" + ? m.content + : JSON.stringify(m.content), + ) + .join("\n---\n")}`, + startOfUtcToday(), + ), + ) + .replace(/^(OPENER HINT|OPENER-HINWEIS): .*$/gm, "$1: ") + .replace(/\nNAME: [^\n]*/g, ""); + const { createHash: hash } = await import("node:crypto"); + effectRecorder.record("provider", [ + { + surface: args.surface, + chain: args.providers.map((p) => p.providerType), + params: rest, + promptChars: prompt.length, + promptSha256: hash("sha256").update(prompt).digest("hex"), + }, + ]); + const next = seams.script.shift(); + if (!next) { + seams.unscripted += 1; + throw new Error("parity: an unscripted provider call"); + } + if ("error" in next) throw next.error(); + if (next.during) await next.during(); + return { + result: { + content: next.content, + tokensUsed: next.tokensUsed ?? 1200, + model: "claude-parity", + providerType: "anthropic", + finishReason: next.finishReason ?? "stop", + }, + workingProvider: args.providers[0], + fallbackHops: [], + }; + }, + }; +}); + +vi.mock("@/lib/insights/features", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + extractFeatures: async ( + ...args: Parameters + ) => { + const size = seams.oversize.shift(); + if (size !== undefined) { + throw new actual.FeaturesPayloadTooLargeError(size, 5_000_000); + } + return actual.extractFeatures(...args); + }, + }; +}); + +vi.mock("@/lib/insights/snapshot-hash", async (importOriginal) => { + const actual = + await importOriginal(); + const { relativeToAnchor, startOfUtcToday } = + await import("../../../kit/parity"); + return { + ...actual, + hashInsightSnapshot: (value: unknown) => { + const real = actual.hashInsightSnapshot(value); + if (value && typeof value === "object" && "features" in value) { + seams.hashes.set( + real, + actual.hashInsightSnapshot( + relativeToAnchor(value, startOfUtcToday()), + ), + ); + } + return real; + }, + }; +}); + +vi.mock("@/lib/insights/briefing-failure-marker", async (importOriginal) => { + const actual = + await importOriginal< + typeof import("@/lib/insights/briefing-failure-marker") + >(); + const { effectRecorder } = await import("../../../kit/parity"); + return { + ...actual, + recordBriefingFailure: ( + args: Parameters[0], + ) => { + effectRecorder.record("recordBriefingFailure", [args]); + const written = actual.recordBriefingFailure(args); + seams.pending.push(written); + return written; + }, + }; +}); + +vi.mock("@/lib/jobs/insight-pregenerate-shared", async (importOriginal) => { + const actual = + await importOriginal< + typeof import("@/lib/jobs/insight-pregenerate-shared") + >(); + const { effectRecorder } = await import("../../../kit/parity"); + return { + ...actual, + enqueueForceWarm: async (...args: unknown[]) => { + effectRecorder.record("enqueueForceWarm", args); + }, + }; +}); + +vi.mock("@/lib/jobs/insight-status-generate-shared", async (importOriginal) => { + const actual = + await importOriginal< + typeof import("@/lib/jobs/insight-status-generate-shared") + >(); + const { effectRecorder } = await import("../../../kit/parity"); + return { + ...actual, + enqueueStatusGeneration: async (...args: unknown[]) => { + effectRecorder.record("enqueueStatusGeneration", args); + }, + }; +}); + +const prisma = getPrismaClient(); +const DAY = 86_400_000; +const HOUR = 3_600_000; +const ANCHOR = startOfUtcToday(); +const NOW = new Date(ANCHOR.getTime() + 12 * HOUR); +const EDGE_DAYS = [7, 30, 60]; +const OWNER_ID = parityId("insightsgen", 1); +const OPTED_OUT_ID = parityId("insightsgen", 2); +const UNREADABLE_KEY_ID = parityId("insightsgen", 3); + +let owner: RouteAuth; +let optedOut: RouteAuth; +let unreadableKey: RouteAuth; + +// ── Payloads ──────────────────────────────────────────────── + +const GROUNDED_BRIEFING = { + paragraph: "Your readings held steady this week, with no surprises.", + signalsOfDay: [ + { + sourceMetric: "weight", + tone: "info", + headline: "Weight is steady", + nudge: "Keep weighing in at the same time each morning.", + delta: null, + }, + ], + keyFindings: [], +}; + +const UNGROUNDED_BRIEFING = { + paragraph: "Your weight reached 123.4 kg this morning, up 9.7 kg.", + signalsOfDay: [], + keyFindings: [], +}; + +const PREVIOUS_BRIEFING = { + paragraph: "Yesterday's briefing, grounded when it was written.", + signalsOfDay: [], + keyFindings: [], +}; + +/** + * A reply in the rich shape the strict prompt asks for. It carries no + * `disclaimer`, so the strict schema rejects it and the route keeps the + * parsed object whole, briefing included. + */ +function reply(briefing: unknown, extra: Record = {}) { + return JSON.stringify({ + summary: "Your tracked metrics held steady over the last 30 days.", + classification: "gut", + findings: [], + correlations: [], + recommendations: [], + dataQuality: { coverage: "good", gaps: [], confidence: "hoch" }, + dailyBriefing: briefing, + ...extra, + }); +} + +/** A cached payload as the generator stores it. */ +function cached(briefing: unknown, generatedAt: Date | null) { + return { + summary: "Cached summary.", + classification: "gut", + findings: [], + correlations: [], + recommendations: [], + dataQuality: { coverage: "good", gaps: [], confidence: "hoch" }, + dailyBriefing: briefing, + ...(generatedAt + ? { [BRIEFING_GENERATED_AT_KEY]: generatedAt.toISOString() } + : {}), + }; +} + +function hop(httpStatus: number | null, attempt = 1) { + return { + providerType: "anthropic" as const, + attempt, + failureReason: httpStatus === null ? "ECONNRESET" : `HTTP ${httpStatus}`, + httpStatus, + }; +} + +function providerError(httpStatus: number): () => Error { + return () => + Object.assign(new Error(`upstream answered ${httpStatus}`), { + httpStatus, + bodyExcerpt: `{"error":"status ${httpStatus}"}`, + }); +} + +// ── State ─────────────────────────────────────────────────── + +interface BriefingState { + /** The cached payload: an object, a raw string, or nothing. */ + text?: unknown; + cachedAt?: Date | null; + cachedLocale?: string | null; + /** Failure markers to write, with their reason and age. */ + markers?: Array<{ reason: string; at: Date; httpStatus?: number | null }>; + /** Tokens already spent today. */ + spentToday?: number; + /** Generations already counted against this hour's quota. */ + quotaUsed?: number; +} + +async function resetBriefing(userId: string, state: BriefingState = {}) { + const text = + state.text === undefined + ? null + : typeof state.text === "string" + ? state.text + : JSON.stringify(state.text); + await prisma.user.update({ + where: { id: userId }, + data: { + insightsCachedAt: state.cachedAt ?? null, + insightsCachedTextEncrypted: text + ? insightsCacheWrite(text).insightsCachedTextEncrypted + : null, + insightsCachedLocale: state.cachedLocale ?? null, + insightsSnapshotHash: null, + }, + }); + await prisma.auditLog.deleteMany({ where: { userId } }); + for (const m of state.markers ?? []) { + await prisma.auditLog.create({ + data: { + userId, + action: "insights.briefing-failure", + createdAt: m.at, + details: JSON.stringify({ + reason: m.reason, + httpStatus: m.httpStatus ?? null, + failureKind: null, + }), + }, + }); + } + await prisma.coachUsage.deleteMany({ where: { userId } }); + if (state.spentToday) { + await prisma.coachUsage.create({ + data: { + userId, + dateKey: NOW.toISOString().slice(0, 10), + totalTokens: state.spentToday, + }, + }); + } + await prisma.rateLimit.deleteMany({ where: { key: `insights:${userId}` } }); + if (state.quotaUsed) { + await prisma.$executeRaw` + INSERT INTO rate_limits (key, count, reset_at) + VALUES (${`insights:${userId}`}, ${state.quotaUsed}, (NOW() AT TIME ZONE 'UTC') + INTERVAL '1 hour')`; + } + seams.script.length = 0; + seams.oversize.length = 0; +} + +async function stored(userId: string) { + const [user, audit, usage] = await Promise.all([ + prisma.user.findUniqueOrThrow({ + where: { id: userId }, + select: { + insightsCachedAt: true, + insightsCachedTextEncrypted: true, + insightsCachedLocale: true, + insightsSnapshotHash: true, + }, + }), + prisma.auditLog.findMany({ + where: { userId }, + orderBy: [{ createdAt: "asc" }, { action: "asc" }], + select: { action: true, details: true, ipAddress: true }, + }), + prisma.coachUsage.findMany({ + where: { userId }, + select: { + dateKey: true, + totalTokens: true, + operatorTokens: true, + messageCount: true, + }, + }), + ]); + const text = openInsightsCachedText(user); + let payload: unknown = text; + try { + payload = text === null ? null : JSON.parse(text); + } catch { + // Kept as the raw string. + } + const hash = user.insightsSnapshotHash; + return { + insightsCachedAt: user.insightsCachedAt, + insightsCachedLocale: user.insightsCachedLocale, + cachedPayload: payload, + snapshotHash: + hash === null ? null : (seams.hashes.get(hash) ?? ""), + audit: audit.map((row) => ({ + ...row, + details: row.details === null ? null : JSON.parse(row.details), + })), + usage, + }; +} + +/** + * The feature read annotates from parallel queries, so the order of its meta + * keys follows the database. The keys are sorted; what they hold is not + * touched. + */ +function sortedMeta(facts: unknown): unknown { + const f = facts as { meta?: Record | null }; + if (!f.meta) return facts; + return { + ...f, + meta: Object.fromEntries( + Object.entries(f.meta).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)), + ), + }; +} + +/** Keys of per-phase timings, which the clock and the load decide. */ +function timingKeys(value: unknown, out = new Set()): Set { + if (Array.isArray(value)) value.forEach((v) => timingKeys(v, out)); + else if (value && typeof value === "object") { + for (const [k, v] of Object.entries(value)) { + if (/_ms$|Ms$/.test(k)) out.add(k); + timingKeys(v, out); + } + } + return out; +} + +async function step( + name: string, + options: { + method: "GET" | "POST"; + auth?: RouteAuth; + body?: unknown; + userId?: string; + headers?: Record; + /** A status the contract does not declare; the reason says which. */ + undeclared?: string; + }, +): Promise { + wideEventRecorder.take(); + effectRecorder.take(); + const handler = options.method === "GET" ? GET : POST; + const res = await callRoute(handler, { + method: options.method, + route: "/api/insights/generate", + body: options.body, + headers: options.headers, + auth: options.auth ?? owner, + contract: options.undeclared ? { skip: options.undeclared } : undefined, + }); + await Promise.all(seams.pending.splice(0)); + await settleBackgroundTasks(); + const events = wideEventRecorder.take(); + expect(events.length).toBeGreaterThan(0); + const facts = events.map(eventFacts).map(sortedMeta); + expect(seams.script, "every scripted reply was asked for").toEqual([]); + expect(seams.unscripted, "no provider call went unscripted").toBe(0); + expect(seams.oversize, "every planted oversize was read").toEqual([]); + await expectParity( + `insights-generate.${name}`, + stable( + relativeToAnchor( + { + status: res.status, + body: res.body, + events: facts, + effects: effectRecorder.take(), + stored: await stored(options.userId ?? OWNER_ID), + }, + ANCHOR, + ), + { since: NOW, volatileKeys: [...timingKeys(facts)] }, + ), + ); +} + +const get = (name: string, opts: { auth?: RouteAuth; userId?: string } = {}) => + step(name, { method: "GET", ...opts }); +const post = ( + name: string, + body: unknown = { force: true }, + opts: { auth?: RouteAuth; userId?: string; undeclared?: string } = {}, +) => step(name, { method: "POST", body, ...opts }); + +async function dropEdgeDays(userId: string): Promise { + for (const d of EDGE_DAYS) { + await prisma.measurement.deleteMany({ + where: { + userId, + measuredAt: { + gte: new Date(ANCHOR.getTime() - d * DAY), + lt: new Date(ANCHOR.getTime() - (d - 1) * DAY), + }, + }, + }); + } +} + +async function consent(userId: string): Promise { + await prisma.consentReceipt.create({ + data: { userId, kind: "ai_full", artefact: "test", signedAt: NOW }, + }); +} + +beforeAll(async () => { + vi.useFakeTimers({ toFake: ["Date"], now: NOW }); + await truncateAllTables(prisma); + const ownerRow = await makeUser({ + id: OWNER_ID, + timezone: "UTC", + locale: "en", + heightCm: 178, + dateOfBirth: new Date("1975-04-02T00:00:00.000Z"), + gender: "male", + displayName: "Parity Person", + aiProvider: "ANTHROPIC", + aiModel: "claude-sonnet-4-6", + aiAnthropicKeyEncrypted: encrypt("sk-ant-parity"), + }); + await consent(OWNER_ID); + await seedParityRecord({ userId: OWNER_ID, now: NOW, tz: "UTC", tag: "ig" }); + await dropEdgeDays(OWNER_ID); + const rollups = await vi.importActual< + typeof import("@/lib/rollups/measurement-rollups") + >("@/lib/rollups/measurement-rollups"); + await rollups.ensureUserRollupsFresh(OWNER_ID); + const moodRollups = await vi.importActual< + typeof import("@/lib/rollups/mood-rollups") + >("@/lib/rollups/mood-rollups"); + await moodRollups.ensureUserMoodRollupsFresh(OWNER_ID); + await settleBackgroundTasks(); + owner = await makeSession(ownerRow); + + const optedOutRow = await makeUser({ + id: OPTED_OUT_ID, + timezone: "UTC", + locale: "en", + modulePreferencesJson: { insights: false }, + aiProvider: "ANTHROPIC", + aiModel: "claude-sonnet-4-6", + aiAnthropicKeyEncrypted: encrypt("sk-ant-parity"), + }); + await consent(OPTED_OUT_ID); + optedOut = await makeSession(optedOutRow); + + // A key the presence probe sees and no resolver can open. + const unreadableRow = await makeUser({ + id: UNREADABLE_KEY_ID, + timezone: "UTC", + locale: "en", + aiProvider: "ANTHROPIC", + aiModel: "claude-sonnet-4-6", + aiAnthropicKeyEncrypted: "v1:presence-only", + }); + await consent(UNREADABLE_KEY_ID); + unreadableKey = await makeSession(unreadableRow); +}); + +afterAll(() => { + vi.useRealTimers(); +}); + +describe("GET /api/insights/generate parity", () => { + it("G01 an anonymous call", async () => { + await get("G01-anonymous", { auth: anonymous }); + }); + + it("G02 the AI analysis opt-out", async () => { + await get("G02-opted-out", { auth: optedOut, userId: OPTED_OUT_ID }); + }); + + it("G03 no cached briefing", async () => { + await resetBriefing(OWNER_ID); + await get("G03-empty"); + }); + + it("G04 a briefing written today", async () => { + await resetBriefing(OWNER_ID, { + text: cached(GROUNDED_BRIEFING, new Date(NOW.getTime() - HOUR)), + cachedAt: new Date(NOW.getTime() - HOUR), + cachedLocale: "en", + }); + await get("G04-fresh"); + }); + + it("G05 a briefing written yesterday", async () => { + await resetBriefing(OWNER_ID, { + text: cached(GROUNDED_BRIEFING, new Date(NOW.getTime() - 20 * HOUR)), + cachedAt: new Date(NOW.getTime() - 2 * HOUR), + cachedLocale: "en", + }); + await get("G05-stale"); + }); + + it("G06 a briefing in another language", async () => { + await resetBriefing(OWNER_ID, { + text: cached(GROUNDED_BRIEFING, new Date(NOW.getTime() - HOUR)), + cachedAt: new Date(NOW.getTime() - HOUR), + cachedLocale: "de", + }); + await get("G06-other-language"); + }); + + it("G07 an unparseable cache row", async () => { + await resetBriefing(OWNER_ID, { + text: "{not json", + cachedAt: new Date(NOW.getTime() - HOUR), + }); + await get("G07-unparseable"); + }); + + it("G08 a legacy payload, untagged", async () => { + await resetBriefing(OWNER_ID, { + text: { summary: "Legacy", recommendations: ["Drink water."] }, + cachedAt: new Date(NOW.getTime() - HOUR), + }); + await get("G08-legacy"); + }); + + it("G09 a failure newer than the last good briefing", async () => { + await resetBriefing(OWNER_ID, { + text: cached(GROUNDED_BRIEFING, new Date(NOW.getTime() - 3 * HOUR)), + cachedAt: new Date(NOW.getTime() - 3 * HOUR), + cachedLocale: "en", + markers: [ + { + reason: "all-providers-failed", + at: new Date(NOW.getTime() - HOUR), + httpStatus: 429, + }, + ], + }); + await get("G09-held-after-failure"); + }); + + it("G10 a failure with no good briefing, and an older superseded one", async () => { + await resetBriefing(OWNER_ID, { + markers: [ + { reason: "provider-error", at: new Date(NOW.getTime() - 5 * HOUR) }, + { reason: "invalid-json", at: new Date(NOW.getTime() - HOUR) }, + ], + }); + await get("G10-failed-empty"); + }); + + it("G11 a briefing the grounding gate withheld", async () => { + await resetBriefing(OWNER_ID, { + text: cached(null, new Date(NOW.getTime() - 2 * HOUR)), + cachedAt: new Date(NOW.getTime() - 2 * HOUR), + cachedLocale: "en", + markers: [ + { reason: "briefing-ungrounded", at: new Date(NOW.getTime() - HOUR) }, + ], + }); + await get("G11-withheld"); + }); +}); + +describe("POST /api/insights/generate parity", () => { + it("P01 an anonymous call", async () => { + await post("P01-anonymous", { force: true }, { auth: anonymous }); + }); + + it("P02 the AI analysis opt-out", async () => { + await post( + "P02-opted-out", + { force: true }, + { auth: optedOut, userId: OPTED_OUT_ID }, + ); + }); + + it("P03 a body over the cap", async () => { + await resetBriefing(OWNER_ID); + await post( + "P03-body-too-large", + { pad: "x".repeat(17 * 1024) }, + { undeclared: "the body cap answers 413, which the contract omits" }, + ); + }); + + it("P04 a cached briefing written today", async () => { + await resetBriefing(OWNER_ID, { + text: cached(GROUNDED_BRIEFING, new Date(NOW.getTime() - HOUR)), + cachedAt: new Date(NOW.getTime() - HOUR), + cachedLocale: "en", + }); + await post("P04-cache-hit", {}); + }); + + it("P05 a cached briefing from yesterday, regenerated", async () => { + await resetBriefing(OWNER_ID, { + text: cached(GROUNDED_BRIEFING, new Date(NOW.getTime() - 20 * HOUR)), + cachedAt: new Date(NOW.getTime() - 2 * HOUR), + cachedLocale: "en", + }); + seams.script.push({ content: reply(GROUNDED_BRIEFING) }); + await post("P05-yesterday-regenerated", {}); + }); + + it("P06 a cache in another language, regenerated", async () => { + await resetBriefing(OWNER_ID, { + text: cached(GROUNDED_BRIEFING, new Date(NOW.getTime() - HOUR)), + cachedAt: new Date(NOW.getTime() - HOUR), + cachedLocale: "de", + }); + seams.script.push({ content: reply(GROUNDED_BRIEFING) }); + await post("P06-other-language-regenerated", {}); + }); + + it("P07 a briefingless cache when the hour's quota is spent", async () => { + await resetBriefing(OWNER_ID, { + text: cached(null, new Date(NOW.getTime() - HOUR)), + cachedAt: new Date(NOW.getTime() - HOUR), + cachedLocale: "en", + quotaUsed: 10, + }); + await post("P07-quota-cached-fallback", {}); + }); + + it("P08 a forced generation when the hour's quota is spent", async () => { + await resetBriefing(OWNER_ID, { quotaUsed: 10 }); + await post("P08-quota-refused"); + }); + + it("P09 a key no resolver can open", async () => { + await resetBriefing(UNREADABLE_KEY_ID); + await post( + "P09-unreadable-key", + { force: true }, + { + auth: unreadableKey, + userId: UNREADABLE_KEY_ID, + undeclared: "an unreadable stored key ends in 500 (pinned as it is)", + }, + ); + }); + + it("P10 a fresh generation", async () => { + await resetBriefing(OWNER_ID); + seams.script.push({ content: reply(GROUNDED_BRIEFING), tokensUsed: 2100 }); + await post("P10-generated"); + }); + + it("P11 a reply the strict schema accepts", async () => { + await resetBriefing(OWNER_ID); + seams.script.push({ + content: reply(GROUNDED_BRIEFING, { disclaimer: "Consult your doctor." }), + }); + await post("P11-strict-reply"); + }); + + it("P12 a generation in German with the comparison, about-me and short form", async () => { + await prisma.user.update({ + where: { id: OWNER_ID }, + data: { + locale: "de", + aiUsageLevel: "FRUGAL", + dashboardWidgetsJson: { comparisonBaseline: "lastMonth" }, + }, + }); + await prisma.allergy.create({ + data: { userId: OWNER_ID, substance: "Penicillin" }, + }); + await resetBriefing(OWNER_ID); + seams.script.push({ content: reply(GROUNDED_BRIEFING) }); + await post("P12-generated-de-short"); + await prisma.allergy.deleteMany({ where: { userId: OWNER_ID } }); + await prisma.user.update({ + where: { id: OWNER_ID }, + data: { locale: "en", aiUsageLevel: null, dashboardWidgetsJson: {} }, + }); + }); + + it("P13 an oversized raw payload, downgraded once", async () => { + await prisma.user.update({ + where: { id: OWNER_ID }, + data: { insightsPrivacyMode: "raw" }, + }); + await resetBriefing(OWNER_ID); + seams.oversize.push(6_100_000); + seams.script.push({ content: reply(GROUNDED_BRIEFING) }); + await post("P13-downgraded"); + }); + + it("P14 an oversized aggregate too, downgraded twice", async () => { + await resetBriefing(OWNER_ID); + seams.oversize.push(6_100_000, 5_400_000); + seams.script.push({ content: reply(GROUNDED_BRIEFING) }); + await post("P14-hard-downgraded"); + }); + + it("P15 oversized on every rung", async () => { + await resetBriefing(OWNER_ID); + seams.oversize.push(6_100_000, 5_400_000, 5_200_000); + await post("P15-too-large"); + await prisma.user.update({ + where: { id: OWNER_ID }, + data: { insightsPrivacyMode: "aggregated" }, + }); + }); + + it("P16 the day's token ceiling", async () => { + await resetBriefing(OWNER_ID, { spentToday: 5_000_000 }); + await post("P16-budget-exceeded"); + }); + + it("P17 every provider refused the credential", async () => { + await resetBriefing(OWNER_ID); + seams.script.push({ + error: () => new AllProvidersFailedError([hop(401), hop(403, 2)]), + }); + await post("P17-chain-auth"); + }); + + it("P18 every provider rate-limited", async () => { + await resetBriefing(OWNER_ID); + seams.script.push({ error: () => new AllProvidersFailedError([hop(429)]) }); + await post("P18-chain-rate-limited"); + }); + + it("P19 a provider in a 5xx", async () => { + await resetBriefing(OWNER_ID); + seams.script.push({ + error: () => new AllProvidersFailedError([hop(401), hop(502, 2)]), + }); + await post("P19-chain-5xx"); + }); + + it("P20 every provider unreachable", async () => { + await resetBriefing(OWNER_ID); + seams.script.push({ + error: () => new AllProvidersFailedError([hop(null), hop(null, 2)]), + }); + await post("P20-chain-transport"); + }); + + it("P21 a mixed chain failure", async () => { + await resetBriefing(OWNER_ID); + seams.script.push({ + error: () => new AllProvidersFailedError([hop(404), hop(429, 2)]), + }); + await post("P21-chain-mixed"); + }); + + it("P22 one provider error per status class", async () => { + for (const status of [401, 429, 500, 400]) { + await resetBriefing(OWNER_ID); + seams.script.push({ error: providerError(status) }); + await post(`P22-provider-${status}`); + } + }); + + it("P23 a reply that is not JSON", async () => { + await resetBriefing(OWNER_ID); + seams.script.push({ content: "Sure! Here is your briefing." }); + await post("P23-invalid-json"); + }); + + it("P24 a reply cut off at the token ceiling", async () => { + await resetBriefing(OWNER_ID); + seams.script.push({ content: '{"summary": "Your', finishReason: "length" }); + await post("P24-truncated"); + }); + + it("P25 an ungrounded briefing the retry fixes", async () => { + await resetBriefing(OWNER_ID); + seams.script.push( + { content: reply(UNGROUNDED_BRIEFING) }, + { content: reply(GROUNDED_BRIEFING), tokensUsed: 900 }, + ); + await post("P25-grounding-retry-kept"); + }); + + it("P26 still ungrounded, the previous briefing stands in", async () => { + await resetBriefing(OWNER_ID, { + text: cached(PREVIOUS_BRIEFING, new Date(NOW.getTime() - 26 * HOUR)), + cachedAt: new Date(NOW.getTime() - 26 * HOUR), + cachedLocale: "en", + }); + seams.script.push( + { content: reply(UNGROUNDED_BRIEFING) }, + { content: reply(UNGROUNDED_BRIEFING) }, + ); + await post("P26-grounding-previous"); + }); + + it("P27 still ungrounded, nothing to stand in", async () => { + await resetBriefing(OWNER_ID); + seams.script.push( + { content: reply(UNGROUNDED_BRIEFING) }, + { content: reply(UNGROUNDED_BRIEFING) }, + ); + await post("P27-grounding-stripped"); + }); + + it("P28 an ungrounded briefing whose retry is not JSON", async () => { + await resetBriefing(OWNER_ID); + seams.script.push( + { content: reply(UNGROUNDED_BRIEFING) }, + { content: "not json" }, + ); + await post("P28-grounding-retry-unparseable"); + }); + + it("P29 a reply the outbound screen blocks", async () => { + await resetBriefing(OWNER_ID); + seams.script.push({ + content: reply(GROUNDED_BRIEFING, { + summary: + "Your 10-year cardiovascular risk is elevated — consider stepping your dose up.", + }), + }); + await post( + "P29-outbound-screened", + { force: true }, + { + undeclared: "the outbound screen answers 502, which the contract omits", + }, + ); + }); + + it("P30 the about-me text changing during the generation", async () => { + await resetBriefing(OWNER_ID); + seams.script.push({ + content: reply(GROUNDED_BRIEFING), + during: async () => { + await prisma.allergy.create({ + data: { userId: OWNER_ID, substance: "Latex" }, + }); + }, + }); + await post("P30-profile-scope-changed"); + await prisma.allergy.deleteMany({ where: { userId: OWNER_ID } }); + }); + + it("P31 the profile changing during the generation", async () => { + await resetBriefing(OWNER_ID); + seams.script.push({ + content: reply(GROUNDED_BRIEFING), + during: async () => { + await prisma.user.update({ + where: { id: OWNER_ID }, + data: { displayName: "Renamed Person" }, + }); + }, + }); + await post("P31-scope-changed"); + await prisma.user.update({ + where: { id: OWNER_ID }, + data: { displayName: "Parity Person" }, + }); + }); +});