mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
Merge branch 'feat/v146-server-insights' into feat/v146-server
This commit is contained in:
commit
af15be4708
81 files changed
+18200
-1821
No files matched your search
@@ -41,7 +41,7 @@ const ADMITTED: Record<string, { jobs: string[]; why: string }> = {
|
||||
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.",
|
||||
},
|
||||
|
||||
@@ -177,7 +177,7 @@ const TS_READERS: Record<string, Entry> = {
|
||||
"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",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<ReturnType<typeof buildComprehensiveResponse>>
|
||||
>,
|
||||
`${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<string, { sum: number; count: number }>();
|
||||
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<string, DataSummary> = 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<string, { sum: number; count: number }>();
|
||||
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<string, typeof allEvents>();
|
||||
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<string, number>();
|
||||
for (const row of sysDaily) {
|
||||
sysByDay.set(row.day, row.value);
|
||||
}
|
||||
|
||||
const takenByDay = new Map<string, number>();
|
||||
// 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<string>();
|
||||
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<string, { sum: number; count: number }>();
|
||||
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<string, number>();
|
||||
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;
|
||||
}
|
||||
+128
-1109
File diff suppressed because it is too large.
Load diff
@@ -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",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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<string, unknown> }
|
||||
| { kind: "generate"; fallback: CachedFallback | null };
|
||||
|
||||
export async function briefingFromCache(
|
||||
ctx: GenerationContext,
|
||||
forceRefresh: boolean,
|
||||
): Promise<CacheDecision> {
|
||||
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<Record<string, unknown>> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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<string, unknown> }
|
||||
| { 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<boolean> {
|
||||
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<void> {
|
||||
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<CommitOutcome> {
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<RunRawWithFallbackResult> {
|
||||
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<FirstCompletion> {
|
||||
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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<GenerationContext> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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<ReturnType<typeof extractFeatures>>;
|
||||
|
||||
/**
|
||||
* 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<string> = [
|
||||
"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<BriefingFeatures | null> {
|
||||
// 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<string, unknown>;
|
||||
featuresJson: string;
|
||||
units: ReturnType<typeof resolveUnitPreferences>;
|
||||
}
|
||||
|
||||
export async function prepareBriefingFeatures(
|
||||
ctx: GenerationContext,
|
||||
): Promise<PreparedFeatures | null> {
|
||||
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<string, unknown>,
|
||||
);
|
||||
// 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 };
|
||||
}
|
||||
@@ -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<ProviderChainResolved[] | null> {
|
||||
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<string, unknown> }
|
||||
| { ok: false; refusal: BriefingRefusal };
|
||||
|
||||
export async function generateBriefing(args: {
|
||||
ctx: GenerationContext;
|
||||
chain: ProviderChainResolved[];
|
||||
ipAddress: string | null;
|
||||
}): Promise<GenerateOutcome> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
/** 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<string, unknown>;
|
||||
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<GroundedReply, "briefingOmittedReason">,
|
||||
): Promise<GroundedReply> {
|
||||
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<string, unknown>).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 };
|
||||
}
|
||||
@@ -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<ReturnType<typeof buildBriefingPrompt>>;
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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<typeof buildComprehensiveResponse>
|
||||
>;
|
||||
|
||||
/** BMI and the 30-day BP classification against the record's profile. */
|
||||
function classifyProfile(
|
||||
summaries: Record<string, DataSummary>,
|
||||
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<string, DataSummary> = 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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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<string, { sum: number; count: number }>();
|
||||
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<string, number>();
|
||||
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;
|
||||
}
|
||||
@@ -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<string, typeof allEvents>();
|
||||
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<string, number>();
|
||||
for (const row of sysDaily) {
|
||||
sysByDay.set(row.day, row.value);
|
||||
}
|
||||
|
||||
const takenByDay = new Map<string, number>();
|
||||
// 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<string>();
|
||||
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 };
|
||||
}
|
||||
@@ -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<OverviewMoodSeries> {
|
||||
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<string, { sum: number; count: number }>();
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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<ComprehensiveOverviewBody>,
|
||||
`${user.id}|comprehensive`,
|
||||
() => buildComprehensiveResponse(user),
|
||||
annotate,
|
||||
);
|
||||
|
||||
return { body, revalidating: outcome === "stale" };
|
||||
}
|
||||
@@ -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<DailyValue[]> {
|
||||
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<string, { sum: number; count: number }>();
|
||||
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 }));
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"status": 401,
|
||||
"body": {
|
||||
"data": null,
|
||||
"error": "Not authenticated",
|
||||
"meta": {
|
||||
"errorCode": "auth.missing",
|
||||
"requestId": "<requestId>"
|
||||
}
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"status": 401,
|
||||
"action": null,
|
||||
"meta": null,
|
||||
"warnings": null,
|
||||
"error": {
|
||||
"type": "HttpError",
|
||||
"message": "Not authenticated"
|
||||
}
|
||||
}
|
||||
],
|
||||
"effects": []
|
||||
}
|
||||
+1303
File diff suppressed because it is too large.
Load diff
+1274
File diff suppressed because it is too large.
Load diff
+1287
File diff suppressed because it is too large.
Load diff
+1274
File diff suppressed because it is too large.
Load diff
+1274
File diff suppressed because it is too large.
Load diff
+1303
File diff suppressed because it is too large.
Load diff
+26
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"status": 403,
|
||||
"body": {
|
||||
"data": null,
|
||||
"error": "Account access denied",
|
||||
"meta": {
|
||||
"errorCode": "sharing.access.denied",
|
||||
"requestId": "<requestId>"
|
||||
}
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"status": 403,
|
||||
"action": null,
|
||||
"meta": {
|
||||
"sharing_refusal": "insufficient_access"
|
||||
},
|
||||
"warnings": null,
|
||||
"error": {
|
||||
"type": "SharingAccessDeniedError",
|
||||
"message": "Account access denied"
|
||||
}
|
||||
}
|
||||
],
|
||||
"effects": []
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"status": 429,
|
||||
"body": {
|
||||
"data": null,
|
||||
"error": "Too many analytics requests. Please retry later.",
|
||||
"meta": {
|
||||
"errorCode": "http.rate_limited",
|
||||
"requestId": "<requestId>"
|
||||
}
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"status": 429,
|
||||
"action": null,
|
||||
"meta": null,
|
||||
"warnings": null,
|
||||
"error": null
|
||||
}
|
||||
],
|
||||
"effects": []
|
||||
}
|
||||
+884
@@ -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": "<id:1>",
|
||||
"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_bpRaw_ms>",
|
||||
"insights.sub_buckets_ms": "<insights.sub_buckets_ms>",
|
||||
"insights.sub_coverage_ms": "<insights.sub_coverage_ms>",
|
||||
"insights.sub_firstAt_ms": "<insights.sub_firstAt_ms>",
|
||||
"insights.sub_latests_ms": "<insights.sub_latests_ms>",
|
||||
"insights.sub_narrow_ms": "<insights.sub_narrow_ms>",
|
||||
"medications": 1,
|
||||
"moodEntries": 29,
|
||||
"totalMeasurements": 282
|
||||
},
|
||||
"warnings": null,
|
||||
"error": null
|
||||
}
|
||||
],
|
||||
"effects": [
|
||||
{
|
||||
"effect": "ensureUserRollupsFresh",
|
||||
"args": [
|
||||
"<id:2>"
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "ensureUserMoodRollupsFresh",
|
||||
"args": [
|
||||
"<id:2>"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+79
@@ -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_bpRaw_ms>",
|
||||
"insights.sub_buckets_ms": "<insights.sub_buckets_ms>",
|
||||
"insights.sub_coverage_ms": "<insights.sub_coverage_ms>",
|
||||
"insights.sub_heavy_ms": "<insights.sub_heavy_ms>",
|
||||
"insights.sub_latests_ms": "<insights.sub_latests_ms>",
|
||||
"medications": 0,
|
||||
"moodEntries": 0,
|
||||
"totalMeasurements": 0
|
||||
},
|
||||
"warnings": null,
|
||||
"error": null
|
||||
}
|
||||
],
|
||||
"effects": [
|
||||
{
|
||||
"effect": "ensureUserRollupsFresh",
|
||||
"args": [
|
||||
"<id:1>"
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "ensureUserMoodRollupsFresh",
|
||||
"args": [
|
||||
"<id:1>"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"status": 401,
|
||||
"body": {
|
||||
"data": null,
|
||||
"error": "Not authenticated",
|
||||
"meta": {
|
||||
"errorCode": "auth.missing",
|
||||
"requestId": "<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": []
|
||||
}
|
||||
}
|
||||
+35
@@ -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": "<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": []
|
||||
}
|
||||
}
|
||||
@@ -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": "<id:1>",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"stored": {
|
||||
"insightsCachedAt": null,
|
||||
"insightsCachedLocale": null,
|
||||
"cachedPayload": null,
|
||||
"snapshotHash": null,
|
||||
"audit": [],
|
||||
"usage": []
|
||||
}
|
||||
}
|
||||
@@ -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": []
|
||||
}
|
||||
}
|
||||
@@ -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": "<id:1>",
|
||||
"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": []
|
||||
}
|
||||
}
|
||||
+77
@@ -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": "<id:1>",
|
||||
"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": []
|
||||
}
|
||||
}
|
||||
+52
@@ -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": "<id:1>",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"stored": {
|
||||
"insightsCachedAt": "anchor+0d+11:00:00",
|
||||
"insightsCachedLocale": null,
|
||||
"cachedPayload": "{not json",
|
||||
"snapshotHash": null,
|
||||
"audit": [],
|
||||
"usage": []
|
||||
}
|
||||
}
|
||||
@@ -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": "<id:1>",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"stored": {
|
||||
"insightsCachedAt": "anchor+0d+11:00:00",
|
||||
"insightsCachedLocale": null,
|
||||
"cachedPayload": {
|
||||
"summary": "Legacy",
|
||||
"recommendations": [
|
||||
"Drink water."
|
||||
]
|
||||
},
|
||||
"snapshotHash": null,
|
||||
"audit": [],
|
||||
"usage": []
|
||||
}
|
||||
}
|
||||
+102
@@ -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": []
|
||||
}
|
||||
}
|
||||
+71
@@ -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": "<id:1>",
|
||||
"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": []
|
||||
}
|
||||
}
|
||||
+78
@@ -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": []
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"status": 401,
|
||||
"body": {
|
||||
"data": null,
|
||||
"error": "Not authenticated",
|
||||
"meta": {
|
||||
"errorCode": "auth.missing",
|
||||
"requestId": "<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": []
|
||||
}
|
||||
}
|
||||
+35
@@ -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": "<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": []
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"status": 413,
|
||||
"body": {
|
||||
"data": null,
|
||||
"error": "Request body exceeds 16384 bytes",
|
||||
"meta": {
|
||||
"errorCode": "http.payload_too_large",
|
||||
"requestId": "<requestId>"
|
||||
}
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"status": 413,
|
||||
"action": null,
|
||||
"meta": null,
|
||||
"warnings": null,
|
||||
"error": null
|
||||
}
|
||||
],
|
||||
"effects": [],
|
||||
"stored": {
|
||||
"insightsCachedAt": null,
|
||||
"insightsCachedLocale": null,
|
||||
"cachedPayload": null,
|
||||
"snapshotHash": null,
|
||||
"audit": [],
|
||||
"usage": []
|
||||
}
|
||||
}
|
||||
+86
@@ -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": []
|
||||
}
|
||||
}
|
||||
+276
@@ -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": [
|
||||
"<id:1>"
|
||||
]
|
||||
},
|
||||
{
|
||||
"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": "<id:1>",
|
||||
"metric": "blood-pressure",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "pulse",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "weight",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "bmi",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "mood",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "medication-compliance",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "general",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "metric:SLEEP_DURATION",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "metric:STEPS",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "metric:BLOOD_GLUCOSE",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "metric:OXYGEN_SATURATION",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "metric:HEART_RATE_VARIABILITY",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+275
@@ -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": [
|
||||
"<id:1>"
|
||||
]
|
||||
},
|
||||
{
|
||||
"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": "<id:1>",
|
||||
"metric": "blood-pressure",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "pulse",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "weight",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "bmi",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "mood",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "medication-compliance",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "general",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "metric:SLEEP_DURATION",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "metric:STEPS",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "metric:BLOOD_GLUCOSE",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "metric:OXYGEN_SATURATION",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "metric:HEART_RATE_VARIABILITY",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+62
@@ -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": []
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"status": 429,
|
||||
"body": {
|
||||
"data": null,
|
||||
"error": "Maximum 10 insight generations per hour.",
|
||||
"meta": {
|
||||
"errorCode": "http.rate_limited",
|
||||
"requestId": "<requestId>"
|
||||
}
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"status": 429,
|
||||
"action": null,
|
||||
"meta": null,
|
||||
"warnings": null,
|
||||
"error": null
|
||||
}
|
||||
],
|
||||
"effects": [],
|
||||
"stored": {
|
||||
"insightsCachedAt": null,
|
||||
"insightsCachedLocale": null,
|
||||
"cachedPayload": null,
|
||||
"snapshotHash": null,
|
||||
"audit": [],
|
||||
"usage": []
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"status": 500,
|
||||
"body": {
|
||||
"data": null,
|
||||
"error": "Interner Serverfehler",
|
||||
"meta": {
|
||||
"errorCode": "http.internal",
|
||||
"requestId": "<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": []
|
||||
}
|
||||
}
|
||||
+275
@@ -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": [
|
||||
"<id:1>"
|
||||
]
|
||||
},
|
||||
{
|
||||
"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": "<id:1>",
|
||||
"metric": "blood-pressure",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "pulse",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "weight",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "bmi",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "mood",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "medication-compliance",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "general",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "metric:SLEEP_DURATION",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "metric:STEPS",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "metric:BLOOD_GLUCOSE",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "metric:OXYGEN_SATURATION",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "metric:HEART_RATE_VARIABILITY",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+251
@@ -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": [
|
||||
"<id:1>"
|
||||
]
|
||||
},
|
||||
{
|
||||
"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": "<id:1>",
|
||||
"metric": "blood-pressure",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "pulse",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "weight",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "bmi",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "mood",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "medication-compliance",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "general",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "metric:SLEEP_DURATION",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "metric:STEPS",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "metric:BLOOD_GLUCOSE",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "metric:OXYGEN_SATURATION",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "metric:HEART_RATE_VARIABILITY",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+271
@@ -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": [
|
||||
"<id:1>"
|
||||
]
|
||||
},
|
||||
{
|
||||
"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": "<id:1>",
|
||||
"metric": "blood-pressure",
|
||||
"locale": "de"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "pulse",
|
||||
"locale": "de"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "weight",
|
||||
"locale": "de"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "bmi",
|
||||
"locale": "de"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "mood",
|
||||
"locale": "de"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "medication-compliance",
|
||||
"locale": "de"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "general",
|
||||
"locale": "de"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "metric:SLEEP_DURATION",
|
||||
"locale": "de"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "metric:STEPS",
|
||||
"locale": "de"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "metric:BLOOD_GLUCOSE",
|
||||
"locale": "de"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "metric:OXYGEN_SATURATION",
|
||||
"locale": "de"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "metric:HEART_RATE_VARIABILITY",
|
||||
"locale": "de"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+278
@@ -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": [
|
||||
"<id:1>"
|
||||
]
|
||||
},
|
||||
{
|
||||
"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": "<id:1>",
|
||||
"metric": "blood-pressure",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "pulse",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "weight",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "bmi",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "mood",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "medication-compliance",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "general",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "metric:SLEEP_DURATION",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "metric:STEPS",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "metric:BLOOD_GLUCOSE",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "metric:OXYGEN_SATURATION",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "metric:HEART_RATE_VARIABILITY",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+278
@@ -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": [
|
||||
"<id:1>"
|
||||
]
|
||||
},
|
||||
{
|
||||
"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": "<id:1>",
|
||||
"metric": "blood-pressure",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "pulse",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "weight",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "bmi",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "mood",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "medication-compliance",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "general",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "metric:SLEEP_DURATION",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "metric:STEPS",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "metric:BLOOD_GLUCOSE",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "metric:OXYGEN_SATURATION",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "metric:HEART_RATE_VARIABILITY",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+32
@@ -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": "<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": []
|
||||
}
|
||||
}
|
||||
+58
@@ -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": "<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": [
|
||||
"<id:1>"
|
||||
]
|
||||
}
|
||||
],
|
||||
"stored": {
|
||||
"insightsCachedAt": null,
|
||||
"insightsCachedLocale": null,
|
||||
"cachedPayload": null,
|
||||
"snapshotHash": null,
|
||||
"audit": [],
|
||||
"usage": [
|
||||
{
|
||||
"dateKey": "day+0",
|
||||
"totalTokens": 5000000,
|
||||
"operatorTokens": 0,
|
||||
"messageCount": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+103
@@ -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": "<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": [
|
||||
"<id:1>"
|
||||
]
|
||||
},
|
||||
{
|
||||
"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": "<id:1>",
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
{
|
||||
"status": 429,
|
||||
"body": {
|
||||
"data": null,
|
||||
"error": "AI provider rate-limited the request",
|
||||
"meta": {
|
||||
"errorCode": "http.rate_limited",
|
||||
"requestId": "<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": [
|
||||
"<id:1>"
|
||||
]
|
||||
},
|
||||
{
|
||||
"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": "<id:1>",
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
{
|
||||
"status": 503,
|
||||
"body": {
|
||||
"data": null,
|
||||
"error": "AI provider temporarily unavailable, try again in a moment",
|
||||
"meta": {
|
||||
"errorCode": "http.unavailable",
|
||||
"requestId": "<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": [
|
||||
"<id:1>"
|
||||
]
|
||||
},
|
||||
{
|
||||
"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": "<id:1>",
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
{
|
||||
"status": 422,
|
||||
"body": {
|
||||
"data": null,
|
||||
"error": "AI provider connection failed. Check your AI settings",
|
||||
"meta": {
|
||||
"errorCode": "http.invalid",
|
||||
"requestId": "<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": [
|
||||
"<id:1>"
|
||||
]
|
||||
},
|
||||
{
|
||||
"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": "<id:1>",
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
{
|
||||
"status": 503,
|
||||
"body": {
|
||||
"data": null,
|
||||
"error": "AI provider temporarily unavailable, try again in a moment",
|
||||
"meta": {
|
||||
"errorCode": "http.unavailable",
|
||||
"requestId": "<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": [
|
||||
"<id:1>"
|
||||
]
|
||||
},
|
||||
{
|
||||
"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": "<id:1>",
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
{
|
||||
"status": 422,
|
||||
"body": {
|
||||
"data": null,
|
||||
"error": "AI provider connection failed. Check your AI settings",
|
||||
"meta": {
|
||||
"errorCode": "http.invalid",
|
||||
"requestId": "<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": [
|
||||
"<id:1>"
|
||||
]
|
||||
},
|
||||
{
|
||||
"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": "<id:1>",
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+102
@@ -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": "<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": [
|
||||
"<id:1>"
|
||||
]
|
||||
},
|
||||
{
|
||||
"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": "<id:1>",
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
{
|
||||
"status": 429,
|
||||
"body": {
|
||||
"data": null,
|
||||
"error": "AI provider rate-limited the request",
|
||||
"meta": {
|
||||
"errorCode": "http.rate_limited",
|
||||
"requestId": "<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": [
|
||||
"<id:1>"
|
||||
]
|
||||
},
|
||||
{
|
||||
"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": "<id:1>",
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
{
|
||||
"status": 503,
|
||||
"body": {
|
||||
"data": null,
|
||||
"error": "AI provider temporarily unavailable, try again in a moment",
|
||||
"meta": {
|
||||
"errorCode": "http.unavailable",
|
||||
"requestId": "<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": [
|
||||
"<id:1>"
|
||||
]
|
||||
},
|
||||
{
|
||||
"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": "<id:1>",
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
{
|
||||
"status": 422,
|
||||
"body": {
|
||||
"data": null,
|
||||
"error": "AI response was not valid JSON",
|
||||
"meta": {
|
||||
"errorCode": "http.invalid",
|
||||
"requestId": "<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": [
|
||||
"<id:1>"
|
||||
]
|
||||
},
|
||||
{
|
||||
"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": "<id:1>",
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+98
@@ -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": "<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": [
|
||||
"<id:1>"
|
||||
]
|
||||
},
|
||||
{
|
||||
"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": "<id:1>",
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+295
@@ -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": [
|
||||
"<id:1>"
|
||||
]
|
||||
},
|
||||
{
|
||||
"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": "<id:1>",
|
||||
"metric": "blood-pressure",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "pulse",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "weight",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "bmi",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "mood",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "medication-compliance",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "general",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "metric:SLEEP_DURATION",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "metric:STEPS",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "metric:BLOOD_GLUCOSE",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "metric:OXYGEN_SATURATION",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "metric:HEART_RATE_VARIABILITY",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+280
@@ -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": [
|
||||
"<id:1>"
|
||||
]
|
||||
},
|
||||
{
|
||||
"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": "<id:1>",
|
||||
"metric": "blood-pressure",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "pulse",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "weight",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "bmi",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "mood",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "medication-compliance",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "general",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "metric:SLEEP_DURATION",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "metric:STEPS",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "metric:BLOOD_GLUCOSE",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "metric:OXYGEN_SATURATION",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "metric:HEART_RATE_VARIABILITY",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+294
@@ -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": [
|
||||
"<id:1>"
|
||||
]
|
||||
},
|
||||
{
|
||||
"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": "<id:1>",
|
||||
"reason": "briefing-ungrounded",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "blood-pressure",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "pulse",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "weight",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "bmi",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "mood",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "medication-compliance",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "general",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "metric:SLEEP_DURATION",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "metric:STEPS",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "metric:BLOOD_GLUCOSE",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "metric:OXYGEN_SATURATION",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "metric:HEART_RATE_VARIABILITY",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+294
@@ -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": [
|
||||
"<id:1>"
|
||||
]
|
||||
},
|
||||
{
|
||||
"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": "<id:1>",
|
||||
"reason": "briefing-ungrounded",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "blood-pressure",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "pulse",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "weight",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "bmi",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "mood",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "medication-compliance",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "general",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "metric:SLEEP_DURATION",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "metric:STEPS",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "metric:BLOOD_GLUCOSE",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "metric:OXYGEN_SATURATION",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"metric": "metric:HEART_RATE_VARIABILITY",
|
||||
"locale": "en"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"effect": "enqueueStatusGeneration",
|
||||
"args": [
|
||||
{
|
||||
"userId": "<id:1>",
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+76
@@ -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": "<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": [
|
||||
"<id:1>"
|
||||
]
|
||||
},
|
||||
{
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"status": 409,
|
||||
"body": {
|
||||
"data": null,
|
||||
"error": "Profile scope changed during insight generation",
|
||||
"meta": {
|
||||
"errorCode": "insights.generate.profileScopeChanged",
|
||||
"requestId": "<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": [
|
||||
"<id:1>"
|
||||
]
|
||||
},
|
||||
{
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"status": 409,
|
||||
"body": {
|
||||
"data": null,
|
||||
"error": "Insight scope changed during generation",
|
||||
"meta": {
|
||||
"errorCode": "insights.generate.scopeChanged",
|
||||
"requestId": "<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": [
|
||||
"<id:1>"
|
||||
]
|
||||
},
|
||||
{
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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<typeof import("@/lib/logging/transports")>();
|
||||
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<typeof import("@/lib/rollups/measurement-rollups")>();
|
||||
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<typeof import("@/lib/rollups/mood-rollups")>();
|
||||
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<string>()): Set<string> {
|
||||
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<string, unknown> | null };
|
||||
const meta = Object.fromEntries(
|
||||
Object.entries(f.meta ?? {}).filter(([k]) => k.startsWith("cache.")),
|
||||
);
|
||||
return { ...f, action: "<raced>", 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<string, unknown> | 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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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 });
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user