mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
The rollup-or-fallback read of the per-day mood points moves out of features.ts into mood-daily-points.ts, unchanged, which keeps features.ts under its size baseline after the calendar-day ages.
1924 lines
69 KiB
TypeScript
1924 lines
69 KiB
TypeScript
/**
|
||
* Feature extraction for OpenAI insights.
|
||
* Extracts aggregated health metrics from the database.
|
||
* No raw timestamps or exact values are sent in aggregated mode.
|
||
*/
|
||
import { prisma } from "@/lib/db";
|
||
import { summarize } from "@/lib/analytics/trends";
|
||
import type { DataPoint } from "@/lib/analytics/trends";
|
||
import {
|
||
buildComplianceMedicationContext,
|
||
COURSES_COMPLIANCE_SELECT,
|
||
calculateCompliance,
|
||
lastNonSkippedTakenAt,
|
||
SCHEDULE_COMPLIANCE_SELECT,
|
||
} from "@/lib/analytics/compliance";
|
||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||
import {
|
||
DEFAULT_TIMEZONE,
|
||
daysBetweenDateKeys,
|
||
userDayKey,
|
||
} from "@/lib/tz/format";
|
||
import { readMoodDailyPoints } from "@/lib/insights/mood-daily-points";
|
||
import { readingsMean } from "@/lib/measurements/day-mean";
|
||
import { usesHourlyMeanDay } from "@/lib/measurements/day-statistic";
|
||
import { isCurrentForTodayClaim } from "@/lib/insights/measurement-freshness";
|
||
import { computeMoodDimensionSeries } from "@/lib/insights/mood-dimension-series";
|
||
import { computeContextMoodComparison } from "@/lib/insights/mood-context-crosstab";
|
||
import { readMoodPrognosis } from "@/lib/analytics/mood-prognosis/read";
|
||
import { MOOD_DIMENSIONS } from "@/lib/mood/dimensions";
|
||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||
import {
|
||
reconstructSleepNights,
|
||
type SleepStageRow,
|
||
} from "@/lib/analytics/sleep-night";
|
||
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
|
||
import { getBpTargets } from "@/lib/analytics/bp-targets";
|
||
import { isBpReadingInTarget } from "@/lib/analytics/bp-in-target";
|
||
import {
|
||
READING_SPREAD,
|
||
standardDeviation,
|
||
type VarianceKind,
|
||
} from "@/lib/stats/spread";
|
||
import {
|
||
pairByTimestamp,
|
||
significantPearsonCorrelation,
|
||
type CorrelationResult,
|
||
} from "@/lib/analytics/correlations";
|
||
import { getMedicationCategories } from "@/lib/medication-category";
|
||
import { sanitizeForPrompt } from "@/lib/insights/sanitize";
|
||
import {
|
||
readAllTimeExtremes,
|
||
readBucketedSeries,
|
||
readEcgBriefingBlock,
|
||
readLabsBriefingBlock,
|
||
readPreventiveCareBlock,
|
||
readWorkoutsBlock,
|
||
} from "@/lib/insights/feature-blocks";
|
||
import {
|
||
computeHistoricalComparison,
|
||
computeSignalsOfDay,
|
||
type SignalOfDay,
|
||
} from "@/lib/insights/signals-of-day";
|
||
import type {
|
||
MeasurementType,
|
||
RollupGranularity,
|
||
} from "@/generated/prisma/client";
|
||
|
||
import { resolveWeightTargetOverride } from "@/lib/analytics/effective-range";
|
||
import {
|
||
buildWeightTargetFeature,
|
||
type WeightTargetFeature,
|
||
} from "@/lib/targets/weight-trend";
|
||
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
|
||
// The briefing read blocks and the signals-of-day builder moved to
|
||
// sibling modules; re-exported so every existing call site keeps
|
||
// importing from here.
|
||
export * from "@/lib/insights/feature-blocks";
|
||
export * from "@/lib/insights/signals-of-day";
|
||
|
||
/** Max chars of the free-text medication name / dose that may enter the Coach prompt. */
|
||
const MED_NAME_MAX_CHARS = 60;
|
||
const MED_DOSE_MAX_CHARS = 40;
|
||
|
||
interface DataCoverage {
|
||
count: number;
|
||
spanDays: number;
|
||
avgDaysBetween: number | null;
|
||
oldestDaysAgo: number;
|
||
newestDaysAgo: number;
|
||
}
|
||
|
||
/**
|
||
* One level-A dimension as the model reads it.
|
||
*
|
||
* `latest` never travels without `newestDaysAgo` and `current` beside it. A
|
||
* value with no age is exactly what produced a reading from last week stated
|
||
* as this morning's, so the age is part of the shape rather than something a
|
||
* caller is trusted to look up.
|
||
*/
|
||
export interface MoodDimensionFeature {
|
||
key: string;
|
||
/** The wording behind the numbers, so a 7 is not read as 7 out of 5. */
|
||
scale: string;
|
||
/** True when a higher number means a worse day — stress, and only stress. */
|
||
inverse: boolean;
|
||
avg7: number | null;
|
||
avg30: number | null;
|
||
latest: number | null;
|
||
/** The local day the newest value belongs to. */
|
||
latestDate: string | null;
|
||
/** Whole days between that day and today. */
|
||
newestDaysAgo: number | null;
|
||
/**
|
||
* Whether that value may back a present-tense claim. False for an absent
|
||
* age too: a missing age is not a fresh one.
|
||
*/
|
||
current: boolean;
|
||
/**
|
||
* Present only when `current` is false, and says in words what the flag
|
||
* says in a boolean. The model reads prose more reliably than it reads a
|
||
* field name, and a stale dimension narrated as today's is the one failure
|
||
* this block exists to prevent.
|
||
*/
|
||
staleNotice?: string;
|
||
count: number;
|
||
}
|
||
|
||
/**
|
||
* The level-A blocks for the model, read once over a bounded window.
|
||
*
|
||
* The rollup tier caches the mean of `score` and nothing else, so this is its
|
||
* own read rather than a widened rollup: a cached tier for four columns that
|
||
* may all be NULL would cost a migration to answer a question the live read
|
||
* answers in one indexed range scan. If a surface measurably slows, the tier
|
||
* grows then, as a read-swap with a fallback on a coverage miss — never as a
|
||
* second engine running beside this one.
|
||
*
|
||
* Only dimensions somebody answered are returned. An empty block would invite
|
||
* a sentence about a question that was never asked.
|
||
*
|
||
* A STALE dimension is still returned, carrying its age and an instruction,
|
||
* rather than filtered out the way the health-status surfaces drop a reading
|
||
* too old to describe. That is a deliberate difference and worth the sentence:
|
||
* those surfaces write one line about one metric, so a value they cannot
|
||
* narrate has nothing left to contribute and dropping it is the whole fix.
|
||
* This block is context for an open conversation, where "your stress was 9 on
|
||
* the third, and you have not answered it since" is a useful and true thing to
|
||
* say — and where silence is worse than history, because the reader then has
|
||
* no way to tell an unanswered dimension from one nobody has looked at in a
|
||
* week. The safety therefore rides on `current` plus `staleNotice` rather than
|
||
* on absence, and both are asserted at the shipped composition in
|
||
* `tests/integration/mood-dimension-features.test.ts`.
|
||
*/
|
||
async function buildMoodDimensionFeatures(
|
||
userId: string,
|
||
floor: Date | null,
|
||
): Promise<MoodDimensionFeature[] | undefined> {
|
||
const tz = await resolveUserTimezone(userId);
|
||
const { t } = getServerTranslator("en");
|
||
const since = laterDate(
|
||
new Date(Date.now() - 90 * 24 * 60 * 60 * 1000),
|
||
floor,
|
||
);
|
||
const rows = await prisma.moodEntry.findMany({
|
||
where: { userId, deletedAt: null, moodLoggedAt: { gte: since } },
|
||
orderBy: { moodLoggedAt: "desc" },
|
||
take: 2000,
|
||
select: {
|
||
date: true,
|
||
moodA1: true,
|
||
stressA2: true,
|
||
energyA3: true,
|
||
connectionA4: true,
|
||
stabilityA5: true,
|
||
},
|
||
});
|
||
if (rows.length === 0) return undefined;
|
||
|
||
// The same calculator the trend page uses, so the number in a Coach
|
||
// sentence and the number on the chart cannot disagree.
|
||
const summaries = computeMoodDimensionSeries(
|
||
rows.map((row) => ({
|
||
date: row.date,
|
||
a1: row.moodA1,
|
||
a2: row.stressA2,
|
||
a3: row.energyA3,
|
||
a4: row.connectionA4,
|
||
a5: row.stabilityA5,
|
||
})),
|
||
userDayKey(new Date(), tz),
|
||
);
|
||
|
||
const blocks = summaries
|
||
.filter((summary) => summary.present)
|
||
.map((summary) => {
|
||
const dimension = MOOD_DIMENSIONS.find((d) => d.key === summary.key)!;
|
||
const current = isCurrentForTodayClaim(summary.newestDaysAgo);
|
||
return {
|
||
key: summary.key,
|
||
// The anchors are resolved through the bundle rather than repeated
|
||
// here: the model must read the same words the person read when they
|
||
// moved the slider, and a second copy of that wording would drift
|
||
// from the one on screen the first time either was edited. English,
|
||
// like the legacy `scale` line beside it.
|
||
scale: `${t(dimension.labelKey)}, 0-10: 0 = ${t(dimension.lowAnchorKey)}, 10 = ${t(dimension.highAnchorKey)}`,
|
||
inverse: summary.inverse,
|
||
avg7: summary.avg7,
|
||
avg30: summary.avg30,
|
||
latest: summary.latest,
|
||
latestDate: summary.latestDate,
|
||
newestDaysAgo: summary.newestDaysAgo,
|
||
current,
|
||
...(current
|
||
? {}
|
||
: {
|
||
staleNotice: summary.latestDate
|
||
? `Last answered on ${summary.latestDate}. State it as history with that date; do not describe it as today.`
|
||
: "No dated value. Do not describe this dimension as current.",
|
||
}),
|
||
count: summary.count,
|
||
};
|
||
});
|
||
|
||
return blocks.length > 0 ? blocks : undefined;
|
||
}
|
||
|
||
/**
|
||
* The day-context comparisons, as the model may state them.
|
||
*
|
||
* Every row has already cleared the day floors and a family-wide
|
||
* Benjamini-Hochberg correction, so nothing here is a raw p < 0.05 finding.
|
||
* The counts ride with each row and the block carries one sentence saying
|
||
* what the rows are and are not — a model follows a stated constraint far
|
||
* more reliably than it infers one from a field name, and the constraint here
|
||
* is the difference between an observation and a claim about somebody's life.
|
||
*
|
||
* Bounded: the same 90-day window the dimensions use, and the comparison's own
|
||
* cap on how many rows survive.
|
||
*/
|
||
/** The later of `date` and an optional floor. */
|
||
function laterDate(date: Date, floor: Date | null): Date {
|
||
return floor !== null && floor.getTime() > date.getTime() ? floor : date;
|
||
}
|
||
|
||
async function buildMoodContextBlock(
|
||
userId: string,
|
||
floor: Date | null,
|
||
): Promise<{
|
||
context?: {
|
||
note: string;
|
||
comparisons: Array<{
|
||
field: string;
|
||
value: string;
|
||
withDays: number;
|
||
withoutDays: number;
|
||
withAvg: number;
|
||
withoutAvg: number;
|
||
}>;
|
||
};
|
||
}> {
|
||
const since = laterDate(
|
||
new Date(Date.now() - 90 * 24 * 60 * 60 * 1000),
|
||
floor,
|
||
);
|
||
const rows = await prisma.moodEntry.findMany({
|
||
where: {
|
||
userId,
|
||
deletedAt: null,
|
||
moodLoggedAt: { gte: since },
|
||
context: { isNot: null },
|
||
},
|
||
orderBy: { moodLoggedAt: "desc" },
|
||
take: 2000,
|
||
select: {
|
||
date: true,
|
||
moodA1: true,
|
||
context: {
|
||
select: {
|
||
workStatus: true,
|
||
contactCircles: true,
|
||
contactForm: true,
|
||
contactExtent: true,
|
||
leisureCategories: true,
|
||
eventType: true,
|
||
},
|
||
},
|
||
},
|
||
});
|
||
if (rows.length === 0) return {};
|
||
|
||
// Days WITHOUT a context belong on the "without" side of every comparison,
|
||
// so the second read takes the whole window rather than only the rows above.
|
||
const allDays = await prisma.moodEntry.findMany({
|
||
where: { userId, deletedAt: null, moodLoggedAt: { gte: since } },
|
||
orderBy: { moodLoggedAt: "desc" },
|
||
take: 2000,
|
||
select: { date: true, moodA1: true },
|
||
});
|
||
const contextByDay = new Map(rows.map((row) => [row.date, row.context]));
|
||
|
||
const comparisons = computeContextMoodComparison(
|
||
allDays.map((row) => {
|
||
const context = contextByDay.get(row.date) ?? null;
|
||
return {
|
||
day: row.date,
|
||
moodA1: row.moodA1,
|
||
workStatus: context?.workStatus ?? null,
|
||
contactCircles: context?.contactCircles ?? null,
|
||
contactForm: context?.contactForm ?? null,
|
||
contactExtent: context?.contactExtent ?? null,
|
||
leisureCategories: context?.leisureCategories ?? null,
|
||
eventType: context?.eventType ?? null,
|
||
};
|
||
}),
|
||
);
|
||
if (comparisons.length === 0) return {};
|
||
|
||
return {
|
||
context: {
|
||
note:
|
||
"Each row compares the average mood value on days that carried a " +
|
||
"context entry against the days that did not, over the last 90 days. " +
|
||
"These are associations and nothing more: state them with their day " +
|
||
"counts, and never say that the context caused the mood.",
|
||
comparisons: comparisons.map((row) => ({
|
||
field: row.field,
|
||
value: row.value,
|
||
withDays: row.withDays,
|
||
withoutDays: row.withoutDays,
|
||
withAvg: row.withAvg,
|
||
withoutAvg: row.withoutAvg,
|
||
})),
|
||
},
|
||
};
|
||
}
|
||
|
||
/**
|
||
* The dimensions block and the note that says how to read it beside the
|
||
* block-level `asOf`. Absent entirely when nobody has answered a dimension,
|
||
* so an account that only ever taps a face carries neither key.
|
||
*/
|
||
async function buildMoodDimensionBlock(
|
||
userId: string,
|
||
floor: Date | null,
|
||
): Promise<{
|
||
dimensions?: MoodDimensionFeature[];
|
||
dimensionsAsOfNote?: string;
|
||
}> {
|
||
const dimensions = await buildMoodDimensionFeatures(userId, floor);
|
||
if (!dimensions) return {};
|
||
return {
|
||
dimensions,
|
||
dimensionsAsOfNote:
|
||
"The block-level asOf describes the newest mood ENTRY on the 1-5 axis. " +
|
||
"For the five dimensions below, each carries its own newestDaysAgo and " +
|
||
"current, and those are the ones that govern what may be said about " +
|
||
"them today; they are often older than the entry itself.",
|
||
};
|
||
}
|
||
|
||
/**
|
||
* The day's two readings for the model, with the age of the second one.
|
||
*
|
||
* Absence is a present field rather than a missing one: `{ present: false }`
|
||
* with the reason and the count, so the model says "not enough entries yet"
|
||
* instead of inventing a number. That shape is a contract elsewhere in the
|
||
* product already and it is the reason this block is built even when there is
|
||
* nothing to report.
|
||
*
|
||
* The wording rule travels with the values. A model handed a number and no
|
||
* instruction writes the sentence its training suggests, and the sentence its
|
||
* training suggests about a forecast is a causal one.
|
||
*/
|
||
interface MoodPrognosisFeature {
|
||
present: boolean;
|
||
/** Why there is nothing, when there is nothing. */
|
||
reason?: string;
|
||
/** Days of the account's own history the ladder was read against. */
|
||
entries: number;
|
||
/** The day the forecast is about. */
|
||
date?: string;
|
||
/** The expected value, 0-10. Never to be stated without `n` and the band. */
|
||
predicted?: number;
|
||
ciLow?: number | null;
|
||
ciHigh?: number | null;
|
||
/** Complete days the fit used. State it in any sentence that uses the value. */
|
||
n?: number;
|
||
modelVersion?: string;
|
||
/** The person's own rating for that day, unchanged. */
|
||
selfAssessment?: number | null;
|
||
/** Where that rating sat against the band: within, above or below. */
|
||
deviation?: string | null;
|
||
/** Whole days between the forecast's day and today. */
|
||
daysAgo?: number | null;
|
||
/** Whether it is fresh enough to back a present-tense sentence. */
|
||
current?: boolean;
|
||
/** Present only when it is not, and says in words what the flag says. */
|
||
staleNotice?: string;
|
||
/** The wording rule, stated rather than implied. */
|
||
note: string;
|
||
}
|
||
|
||
/**
|
||
* The forecast block, or an honest absence.
|
||
*
|
||
* One reader for every surface: this calls the same `readMoodPrognosis` the
|
||
* route calls, so the Coach and the page cannot disagree about what today's
|
||
* comparison is. The freshness rule is the shared one — `isCurrentForTodayClaim`
|
||
* against the same `TODAY_CLAIM_MAX_AGE_DAYS` — because a second constant for
|
||
* "how old may a value be before today stops being true about it" is how two
|
||
* surfaces start narrating the same reading differently.
|
||
*/
|
||
async function buildMoodPrognosisBlock(userId: string): Promise<{
|
||
prognosis?: MoodPrognosisFeature;
|
||
}> {
|
||
const reading = await readMoodPrognosis(
|
||
userId,
|
||
new Date(),
|
||
await resolveUserTimezone(userId),
|
||
);
|
||
|
||
if (!reading.present) {
|
||
return {
|
||
prognosis: {
|
||
present: false,
|
||
reason: reading.reason,
|
||
entries: reading.entries,
|
||
note:
|
||
"There is no expected-value comparison for this account yet. Say so " +
|
||
"plainly if it comes up; do not estimate one, and do not describe " +
|
||
"the absence as a bad sign.",
|
||
},
|
||
};
|
||
}
|
||
|
||
return {
|
||
prognosis: {
|
||
present: true,
|
||
entries: reading.entries,
|
||
date: reading.date,
|
||
predicted: reading.predicted,
|
||
ciLow: reading.ciLow,
|
||
ciHigh: reading.ciHigh,
|
||
n: reading.n,
|
||
modelVersion: reading.modelVersion,
|
||
selfAssessment: reading.selfAssessment,
|
||
deviation: reading.deviation,
|
||
daysAgo: reading.ageDays,
|
||
current: reading.current,
|
||
...(reading.current
|
||
? {}
|
||
: {
|
||
staleNotice: `This comparison is about ${reading.date}. State it as that day, with the date; do not describe it as today.`,
|
||
}),
|
||
note:
|
||
"Two separate readings of one day: `selfAssessment` is what the person " +
|
||
"recorded and leads; `predicted` is what a comparison with their own " +
|
||
"past days would have expected, built from `n` days. Never merge them, " +
|
||
"never present `predicted` as the person's mood, and never state it " +
|
||
"without `n` and the band. Phrase it as a counterfactual — a value " +
|
||
"around X would have been expected — and never say that anything " +
|
||
"caused anything: the comparison is an association over the account's " +
|
||
"own days and supports no cause.",
|
||
},
|
||
};
|
||
}
|
||
|
||
export interface AggregatedFeatures {
|
||
weight?: {
|
||
latest: number;
|
||
avg7: number | null;
|
||
avg30: number | null;
|
||
avg90: number | null;
|
||
allTimeAvg: number | null;
|
||
allTimeMin: number | null;
|
||
allTimeMax: number | null;
|
||
slope30: number | null;
|
||
outlierCount: number;
|
||
bmi: number | null;
|
||
coverage: DataCoverage;
|
||
/**
|
||
* v1.39 (#1006) — the person's own stored weight target and which way is
|
||
* progress from where they are now. Absent when no target is stored, and
|
||
* then nothing here says which way is better. Present so a narrative
|
||
* never calls a gain a regression when gaining is exactly the goal.
|
||
*/
|
||
target?: WeightTargetFeature;
|
||
};
|
||
bloodPressure?: {
|
||
avgSys30: number | null;
|
||
avgDia30: number | null;
|
||
avgSys90: number | null;
|
||
avgDia90: number | null;
|
||
allTimeAvgSys: number | null;
|
||
allTimeAvgDia: number | null;
|
||
allTimeMinSys: number | null;
|
||
allTimeMaxSys: number | null;
|
||
allTimeMinDia: number | null;
|
||
allTimeMaxDia: number | null;
|
||
slopeSys30: number | null;
|
||
slopeDia30: number | null;
|
||
sdSys30: number | null;
|
||
sdDia30: number | null;
|
||
pulsePressure30: number | null;
|
||
pctInTarget: number | null;
|
||
coverage: DataCoverage;
|
||
};
|
||
pulse?: {
|
||
avg7: number | null;
|
||
avg30: number | null;
|
||
avg90: number | null;
|
||
allTimeAvg: number | null;
|
||
allTimeMin: number | null;
|
||
allTimeMax: number | null;
|
||
slope30: number | null;
|
||
anomalyCount: number;
|
||
coverage: DataCoverage;
|
||
};
|
||
bodyFat?: {
|
||
latest: number | null;
|
||
avg30: number | null;
|
||
slope30: number | null;
|
||
coverage: DataCoverage;
|
||
};
|
||
/**
|
||
* v1.25.1 — grip-strength aggregate (kg). A clinical-depth signal captured
|
||
* and shown on its own detail page with EWGSOP2 sarcopenia bands; this is its
|
||
* present-and-trend block so the daily briefing can narrate the trajectory.
|
||
* The sex-aware floor stays at the display edge (`norms.ts`); the briefing
|
||
* reads the trend. Omitted when no grip readings exist. PHQ-9/GAD-7 stay
|
||
* excluded — only physical signals reach the narrative.
|
||
*/
|
||
gripStrength?: {
|
||
latest: number | null;
|
||
avg30: number | null;
|
||
slope30: number | null;
|
||
coverage: DataCoverage;
|
||
};
|
||
/**
|
||
* v1.25.1 — waist aggregate. `latest`/`avg30` are the circumference (cm);
|
||
* `whtrLatest` is the waist-to-height ratio (NICE keeps it < 0.5), computed
|
||
* from the freshest circumference and the user's height. Omitted when no
|
||
* waist readings exist.
|
||
*/
|
||
waist?: {
|
||
latest: number | null;
|
||
avg30: number | null;
|
||
slope30: number | null;
|
||
whtrLatest: number | null;
|
||
coverage: DataCoverage;
|
||
};
|
||
/**
|
||
* v1.25.1 — pain aggregate (0–10 NRS, lower-better). Present-and-trend block
|
||
* so the briefing can flag a sustained or rising pain burden. Omitted when no
|
||
* pain readings exist.
|
||
*/
|
||
pain?: {
|
||
latest: number | null;
|
||
avg7: number | null;
|
||
avg30: number | null;
|
||
slope30: number | null;
|
||
coverage: DataCoverage;
|
||
};
|
||
mood?: {
|
||
scale: string;
|
||
avg7: number | null;
|
||
avg30: number | null;
|
||
latest: number | null;
|
||
trend30: "improving" | "declining" | "stable" | null;
|
||
totalEntries: number;
|
||
coverage: DataCoverage;
|
||
/**
|
||
* v1.37 — the five level-A self-state values, one block each, and only for
|
||
* the dimensions somebody actually answered. `scale` above describes the
|
||
* legacy 1-5 axis and is handed to the model verbatim, so it is left
|
||
* exactly as it was; these carry their own scale line beside it.
|
||
*
|
||
* Every block states its own age, because the ages differ: stress can be
|
||
* six days old while pleasantness was answered this morning, and a model
|
||
* given one age for the pair narrates the stale one as today's.
|
||
*/
|
||
dimensions?: MoodDimensionFeature[];
|
||
/**
|
||
* Says which age governs which claim, because two of them ride in the
|
||
* same block and they disagree by design.
|
||
*
|
||
* `annotateSnapshotFreshness` stamps every metric block with an `asOf`
|
||
* built from `coverage.newestDaysAgo`, which for mood is the age of the
|
||
* newest ENTRY — the 1-5 axis. Somebody who taps a face each morning and
|
||
* opens the sliders once a week has `asOf.daysAgo` of 0 and a stress
|
||
* reading six days old, so a reader taking the block-level stamp as
|
||
* covering the dimensions would state last week's answer as today's.
|
||
* That is the exact sentence this milestone exists to prevent, and it is
|
||
* cheaper to name the precedence than to leave two true numbers looking
|
||
* like a contradiction.
|
||
*
|
||
* Present only alongside `dimensions`.
|
||
*/
|
||
dimensionsAsOfNote?: string;
|
||
/**
|
||
* v1.38 — how the average mood value sat on days carrying each day-context
|
||
* entry against the days that did not, over the last 90 days.
|
||
*
|
||
* Present only when at least one comparison cleared the day floors and the
|
||
* family-wide correction. The `note` beside the rows states what they are:
|
||
* a model that is told in a sentence that these are associations states
|
||
* them as associations, and one that is left to infer it from a field name
|
||
* does not.
|
||
*/
|
||
context?: {
|
||
note: string;
|
||
comparisons: Array<{
|
||
field: string;
|
||
value: string;
|
||
withDays: number;
|
||
withoutDays: number;
|
||
withAvg: number;
|
||
withoutAvg: number;
|
||
}>;
|
||
};
|
||
/**
|
||
* v1.38 — the day's two readings and the distance between them.
|
||
*
|
||
* Always present once the mood module is on, because an honest absence is
|
||
* information: `present: false` with a reason and a count keeps the model
|
||
* from filling the gap with an estimate. The block carries its own age, so
|
||
* a comparison about the day before yesterday cannot back a sentence about
|
||
* today.
|
||
*/
|
||
prognosis?: MoodPrognosisFeature;
|
||
};
|
||
sleep?: {
|
||
avg7: number | null;
|
||
avg30: number | null;
|
||
latest: number | null;
|
||
coverage: DataCoverage;
|
||
};
|
||
activity?: {
|
||
avg7: number | null;
|
||
avg30: number | null;
|
||
latest: number | null;
|
||
coverage: DataCoverage;
|
||
};
|
||
ratePressureProduct?: {
|
||
rpp7: number | null;
|
||
rpp30: number | null;
|
||
risk: "normal" | "elevated" | null;
|
||
};
|
||
bodyCompositionDivergence?: {
|
||
weightStable: boolean;
|
||
bodyFatRising: boolean;
|
||
flag: boolean;
|
||
};
|
||
moodAdherenceRisk?: boolean;
|
||
seasonalVariation?: {
|
||
winterAvgSys: number | null;
|
||
summerAvgSys: number | null;
|
||
delta: number | null;
|
||
significance: "normal" | "elevated" | null;
|
||
};
|
||
correlations?: {
|
||
weightVsSystolic: CorrelationResult | null;
|
||
weightVsDiastolic: CorrelationResult | null;
|
||
pulseVsSystolic: CorrelationResult | null;
|
||
moodVsPulse: CorrelationResult | null;
|
||
moodVsSystolic: CorrelationResult | null;
|
||
moodVsWeight: CorrelationResult | null;
|
||
sleepVsPulse: CorrelationResult | null;
|
||
sleepVsSystolic: CorrelationResult | null;
|
||
};
|
||
historicalComparison?: {
|
||
weight?: {
|
||
current7dAvg: number | null;
|
||
previous30dAvg: number | null;
|
||
change: number | null;
|
||
};
|
||
systolic?: {
|
||
current7dAvg: number | null;
|
||
previous30dAvg: number | null;
|
||
change: number | null;
|
||
};
|
||
diastolic?: {
|
||
current7dAvg: number | null;
|
||
previous30dAvg: number | null;
|
||
change: number | null;
|
||
};
|
||
pulse?: {
|
||
current7dAvg: number | null;
|
||
previous30dAvg: number | null;
|
||
change: number | null;
|
||
};
|
||
};
|
||
medications?: Array<{
|
||
name: string;
|
||
dose: string;
|
||
category: string;
|
||
compliance7: number;
|
||
compliance30: number;
|
||
compliance90: number;
|
||
streak: number;
|
||
missedLast7: number;
|
||
}>;
|
||
context: {
|
||
heightCm: number | null;
|
||
hasBpTargets: boolean;
|
||
totalMeasurements: number;
|
||
dataSpanDays: number;
|
||
oldestMeasurementDaysAgo: number | null;
|
||
newestMeasurementDaysAgo: number | null;
|
||
ageYears: number | null;
|
||
gender: string | null;
|
||
};
|
||
/**
|
||
* v1.18.7 — "Signals of the day": the present-focused read the daily
|
||
* briefing leads with. Each entry compares the freshest reading against
|
||
* the user's own trailing-7d / 30d windows, flags an emerging slope, and
|
||
* surfaces a recent anomaly — ranked by clinical priority, ≤3 entries.
|
||
*
|
||
* Recomputed every day: the freshest reading, the 7/30d windows, and the
|
||
* recency move daily, so a flat 30/90d mean no longer pins the briefing.
|
||
* The block lands inside the compacted features payload, so it also seeds
|
||
* the content-hash gate (`hashInsightSnapshot`) — a fresh daily signal
|
||
* forces the briefing to regenerate. See `computeSignalsOfDay`.
|
||
*/
|
||
signalsOfDay?: SignalOfDay[];
|
||
/**
|
||
* v1.22 — glucose aggregate block. Glucose previously reached the briefing
|
||
* only as a single `signalOfDay` row; this is the present-and-trend block the
|
||
* other vitals already carry (7 / 30 / 90-day means + a 30-day slope), from
|
||
* the canonical stored value. Omitted when the user has no glucose readings.
|
||
*/
|
||
glucose?: {
|
||
avg7: number | null;
|
||
avg30: number | null;
|
||
avg90: number | null;
|
||
latest: number | null;
|
||
latestDaysAgo: number | null;
|
||
slope30: number | null;
|
||
coverage: DataCoverage;
|
||
};
|
||
/**
|
||
* v1.22 — recent FLAGGED biomarkers (abnormal or trending). Closes the
|
||
* biggest siloed domain: labs reached the Coach snapshot but never the
|
||
* briefing. Hidden biomarkers (the W3 catalog `hidden` flag) are excluded;
|
||
* qualitative rows carry a neutral `unknown` status (no fabricated verdict).
|
||
* Only abnormal / trending markers surface (the briefing-relevant ones) and
|
||
* the list is bounded. Omitted when nothing is flagged.
|
||
*/
|
||
labs?: {
|
||
flagged: Array<{
|
||
analyte: string;
|
||
value: number | null;
|
||
valueText: string | null;
|
||
unit: string;
|
||
rangeStatus: "in-range" | "below" | "above" | "unknown";
|
||
/** Direction vs the immediately prior reading; null without a prior. */
|
||
trend: "rising" | "falling" | "flat" | null;
|
||
takenAt: string;
|
||
daysAgo: number;
|
||
}>;
|
||
/** Count of flagged markers (may exceed the bounded `flagged` length). */
|
||
flaggedCount: number;
|
||
};
|
||
/**
|
||
* v1.22 — preventive-care (Vorsorge) read-side: due + overdue items. The
|
||
* early-nudge use case ("your screening is overdue"). Previously fully siloed
|
||
* — captured + scheduled but read by no AI surface. Labels are sanitised
|
||
* (user free-text). Omitted when nothing is due or overdue.
|
||
*/
|
||
preventiveCare?: {
|
||
overdue: Array<{ label: string; daysOverdue: number }>;
|
||
due: Array<{ label: string; daysUntil: number }>;
|
||
};
|
||
/**
|
||
* v1.22 — workout aggregate. The briefing previously saw activity only as
|
||
* ACTIVITY_STEPS; the `Workout` table never reached it. Provider-agnostic
|
||
* counts + load over the trailing windows. Omitted when no workouts logged.
|
||
*/
|
||
workouts?: {
|
||
last7: {
|
||
count: number;
|
||
totalDurationMin: number;
|
||
totalDistanceKm: number | null;
|
||
};
|
||
last30: {
|
||
count: number;
|
||
totalDurationMin: number;
|
||
totalDistanceKm: number | null;
|
||
};
|
||
latest: {
|
||
sportType: string;
|
||
daysAgo: number;
|
||
durationMin: number;
|
||
distanceKm: number | null;
|
||
} | null;
|
||
};
|
||
/**
|
||
* ECG recording descriptor (device-verdict only, NON-DIAGNOSTIC). A
|
||
* bounded, existence-and-count read of the user's on-device ECG recordings so
|
||
* the nightly narrative can reference them FACTUALLY and attributed to the
|
||
* device. `deviceVerdicts` / `latestDeviceVerdict` are the RECORDING DEVICE's
|
||
* OWN classifications — HealthLog never re-classifies, never reads the
|
||
* waveform (it never enters this payload), and never emits a verdict of its
|
||
* own. The prompt constrains any mention to the device's attribution; the
|
||
* grounding gate constrains any restated count. Omitted when no recordings
|
||
* fall in the window.
|
||
*/
|
||
ecg?: {
|
||
/** Number of ECG recordings in the trailing window. */
|
||
recordingCount: number;
|
||
/** Distribution of the DEVICE's own verdicts across the window. */
|
||
deviceVerdicts: {
|
||
irregular: number;
|
||
notDetected: number;
|
||
inconclusive: number;
|
||
};
|
||
/** The latest recording's DEVICE verdict, or null when unclassified. */
|
||
latestDeviceVerdict: "IRREGULAR" | "NOT_DETECTED" | "INCONCLUSIVE" | null;
|
||
/** Days since the latest recording. */
|
||
latestRecordedDaysAgo: number;
|
||
/** The latest recording's device-reported average HR, or null. */
|
||
latestAverageHeartRate: number | null;
|
||
};
|
||
}
|
||
|
||
/**
|
||
* One bucketed series. `granularity` decides the bucket width (DAY for
|
||
* the trailing 90-day window, WEEK for 90→365 days, MONTH for the
|
||
* 365→1825-day deep history). `bucketStart` is an ISO-8601 UTC
|
||
* timestamp anchored to the start of the bucket — Postgres
|
||
* `date_trunc()` semantics, same as the rollup populator.
|
||
*
|
||
* Replaces v1.4.35.x `rawMeasurements` (which appended every
|
||
* measurement row and blew the 10 MB Codex prompt ceiling on power
|
||
* users with multi-year imports — 25.9 MB observed on the maintainer's account
|
||
* with 311 775 rows). Reading from `measurement_rollups` instead caps
|
||
* the payload at O(types × buckets) regardless of underlying row
|
||
* count.
|
||
*/
|
||
export interface BucketedSeries {
|
||
type: string;
|
||
granularity: RollupGranularity;
|
||
buckets: Array<{
|
||
bucketStart: string;
|
||
mean: number;
|
||
count: number;
|
||
}>;
|
||
}
|
||
|
||
export interface RawFeatures extends AggregatedFeatures {
|
||
bucketedMeasurements: BucketedSeries[];
|
||
}
|
||
|
||
/**
|
||
* v1.4.36 — payload size ceiling. The Codex provider rejects
|
||
* any prompt-string above 10 MB; the features payload is roughly half
|
||
* the eventual prompt (the JSON dump plus the system + comparison
|
||
* blocks), so a 5 MB feature payload sits comfortably below the
|
||
* ceiling even after locale-prefix expansion. Throwing here lets the
|
||
* route handler downgrade to the non-raw payload + annotate so we
|
||
* catch the regression instead of blowing the upstream call.
|
||
*/
|
||
export const FEATURES_MAX_BYTES = 5 * 1024 * 1024;
|
||
|
||
/**
|
||
* v1.18.11 — default bounded read window for the comprehensive briefing.
|
||
*
|
||
* The briefing's prose only ever cites 7 / 30 / 90-day windows plus all-time
|
||
* extremes; the unbounded `measurement.findMany` it used to run materialised
|
||
* the entire history (tens to hundreds of thousands of rows for multi-year
|
||
* Withings / Apple imports) into JS on every generation, only to trim the
|
||
* payload after the fact through the 5 MB downgrade ladder. 400 days captures
|
||
* full history for the overwhelming majority of accounts while keeping the
|
||
* seasonal-BP contrast (winter vs summer, needs >180 d) intact; the genuinely
|
||
* long-horizon all-time min/max/avg figures are sourced separately from a
|
||
* single grouped aggregation over the whole history (see
|
||
* `readAllTimeExtremes`), so bounding the bulk read does not silently relabel a
|
||
* 400-day extreme as "all-time". Mirrors the Coach's 90-day bound
|
||
* (`coach/snapshot.ts`), just wider because the briefing narrates longer trends.
|
||
*/
|
||
export const BRIEFING_FEATURE_WINDOW_DAYS = 400;
|
||
|
||
/** Round an all-time mean to 1 decimal, matching the windowed `summarize` mean. */
|
||
function roundMean(v: number | null): number | null {
|
||
return v === null ? null : Math.round(v * 10) / 10;
|
||
}
|
||
|
||
export class FeaturesPayloadTooLargeError extends Error {
|
||
readonly code = "FEATURES_PAYLOAD_TOO_LARGE";
|
||
readonly sizeBytes: number;
|
||
readonly limitBytes: number;
|
||
|
||
constructor(sizeBytes: number, limitBytes: number) {
|
||
super(
|
||
`Features payload too large: ${sizeBytes} bytes exceeds ${limitBytes} byte cap`,
|
||
);
|
||
this.name = "FeaturesPayloadTooLargeError";
|
||
this.sizeBytes = sizeBytes;
|
||
this.limitBytes = limitBytes;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* A spread rounded to the payload's one decimal, `null` below two values: a
|
||
* single reading has no spread to report, not a spread of zero.
|
||
*/
|
||
export function roundedSpread(
|
||
values: readonly number[],
|
||
kind: VarianceKind,
|
||
): number | null {
|
||
if (values.length < 2) return null;
|
||
const sd = standardDeviation(values, kind);
|
||
return sd === null ? null : Math.round(sd * 10) / 10;
|
||
}
|
||
|
||
export function toDataPoints(
|
||
records: Array<{ value: number; measuredAt: Date }>,
|
||
): DataPoint[] {
|
||
return records.map((r) => ({ date: r.measuredAt, value: r.value }));
|
||
}
|
||
|
||
function computeCoverage(
|
||
records: Array<{ measuredAt: Date }>,
|
||
now: number,
|
||
): DataCoverage {
|
||
if (records.length === 0) {
|
||
return {
|
||
count: 0,
|
||
spanDays: 0,
|
||
avgDaysBetween: null,
|
||
oldestDaysAgo: 0,
|
||
newestDaysAgo: 0,
|
||
};
|
||
}
|
||
const oldest = records[0].measuredAt.getTime();
|
||
const newest = records[records.length - 1].measuredAt.getTime();
|
||
const spanDays = Math.round((newest - oldest) / (24 * 60 * 60 * 1000));
|
||
const avgDaysBetween =
|
||
records.length > 1
|
||
? Math.round((spanDays / (records.length - 1)) * 10) / 10
|
||
: null;
|
||
return {
|
||
count: records.length,
|
||
spanDays,
|
||
avgDaysBetween,
|
||
oldestDaysAgo: Math.round((now - oldest) / (24 * 60 * 60 * 1000)),
|
||
newestDaysAgo: Math.round((now - newest) / (24 * 60 * 60 * 1000)),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Compute average of values within a time window (days ago from now).
|
||
*
|
||
* `day` names the type and the zone its days are read in. For a type whose
|
||
* day is the mean of its hours' means (pulse, see `day-mean.ts`) the window
|
||
* is then the mean of its day values; every other type keeps the plain mean.
|
||
*/
|
||
export function avgInWindow(
|
||
records: Array<{ value: number; measuredAt: Date }>,
|
||
now: number,
|
||
fromDaysAgo: number,
|
||
toDaysAgo: number = 0,
|
||
day?: { type: string; tz: string },
|
||
): number | null {
|
||
const fromMs = now - fromDaysAgo * 24 * 60 * 60 * 1000;
|
||
const toMs = now - toDaysAgo * 24 * 60 * 60 * 1000;
|
||
const filtered = records.filter((r) => {
|
||
const t = r.measuredAt.getTime();
|
||
return t >= fromMs && t <= toMs;
|
||
});
|
||
if (filtered.length === 0) return null;
|
||
if (day && usesHourlyMeanDay(day.type)) {
|
||
const mean = readingsMean(day.type, filtered, day.tz);
|
||
return mean === null ? null : Math.round(mean * 100) / 100;
|
||
}
|
||
const sum = filtered.reduce((s, r) => s + r.value, 0);
|
||
return Math.round((sum / filtered.length) * 100) / 100;
|
||
}
|
||
|
||
// ─── v1.22 — cross-signal integration blocks (briefing-scoped) ────────────
|
||
|
||
/**
|
||
* v1.22 — the integration blocks (labs / preventive-care / workouts) carry
|
||
* extra DB reads. They are BRIEFING-scoped: the daily briefing reads a wide
|
||
* window (`BRIEFING_FEATURE_WINDOW_DAYS`) and narrates long trends, while the
|
||
* Coach snapshot path calls `extractFeatures` with a tight ~90-day window and
|
||
* already carries its own labs / illness / workout context. Gating on the read
|
||
* window keeps these extra reads off every Coach turn without a new caller flag:
|
||
* the briefing window (400) clears it, the Coach window (90) does not.
|
||
*/
|
||
const INTEGRATION_BLOCK_MIN_WINDOW_DAYS = 180;
|
||
|
||
/**
|
||
* The mood feature block and the per-day points it was built from. Split out of
|
||
* `extractFeatures` so a caller that needs only this block (the single-metric
|
||
* Coach read) runs exactly the same reads without the rest of the function.
|
||
*/
|
||
async function readMoodFeature(
|
||
userId: string,
|
||
/** The Coach's lookback floor, or null: see `extractFeatures`. */
|
||
historyFloor: Date | null,
|
||
): Promise<{
|
||
feature: AggregatedFeatures["mood"];
|
||
dailyPoints: Array<{ measuredAt: Date; value: number; count: number }>;
|
||
}> {
|
||
let feature: AggregatedFeatures["mood"];
|
||
const {
|
||
points: moodDailyPoints,
|
||
totalEntries: moodTotalEntries,
|
||
latestScore: moodLatestScore,
|
||
} = await readMoodDailyPoints(userId, historyFloor);
|
||
type MoodDayPoint = (typeof moodDailyPoints)[number];
|
||
|
||
if (moodDailyPoints.length > 0) {
|
||
const moodNow = Date.now();
|
||
const last7 = moodDailyPoints.filter(
|
||
(e) => moodNow - e.measuredAt.getTime() < 7 * 24 * 60 * 60 * 1000,
|
||
);
|
||
const last30 = moodDailyPoints.filter(
|
||
(e) => moodNow - e.measuredAt.getTime() < 30 * 24 * 60 * 60 * 1000,
|
||
);
|
||
|
||
const avg = (points: MoodDayPoint[]) =>
|
||
points.length > 0
|
||
? Math.round(
|
||
(points.reduce((s, e) => s + e.value, 0) / points.length) * 100,
|
||
) / 100
|
||
: null;
|
||
|
||
// Compute 30-day trend: compare first half vs second half of last 30 days
|
||
let trend30: "improving" | "declining" | "stable" | null = null;
|
||
if (last30.length >= 4) {
|
||
const firstHalf = last30.filter(
|
||
(e) => moodNow - e.measuredAt.getTime() >= 15 * 24 * 60 * 60 * 1000,
|
||
);
|
||
const secondHalf = last30.filter(
|
||
(e) => moodNow - e.measuredAt.getTime() < 15 * 24 * 60 * 60 * 1000,
|
||
);
|
||
if (firstHalf.length >= 2 && secondHalf.length >= 2) {
|
||
const avgFirst = avg(firstHalf)!;
|
||
const avgSecond = avg(secondHalf)!;
|
||
const diff = avgSecond - avgFirst;
|
||
if (diff > 0.3) trend30 = "improving";
|
||
else if (diff < -0.3) trend30 = "declining";
|
||
else trend30 = "stable";
|
||
}
|
||
}
|
||
|
||
// Ages and span in calendar days: the entries' own day labels against
|
||
// the reader's today. A rollup point sits at its label's UTC midnight,
|
||
// and elapsed hours rounded to days called a morning entry a day old
|
||
// from noon UTC on, so the answer depended on the hour of the question.
|
||
const todayKey = userDayKey(
|
||
new Date(moodNow),
|
||
await resolveUserTimezone(userId),
|
||
);
|
||
const oldestKey = moodDailyPoints[0].dayKey;
|
||
const newestKey = moodDailyPoints[moodDailyPoints.length - 1].dayKey;
|
||
const spanDays = daysBetweenDateKeys(oldestKey, newestKey);
|
||
const avgDaysBetween =
|
||
moodTotalEntries > 1
|
||
? Math.round((spanDays / (moodTotalEntries - 1)) * 10) / 10
|
||
: null;
|
||
|
||
feature = {
|
||
scale: "1=LAUSIG, 2=SCHLECHT, 3=OKAY, 4=GUT, 5=SUPER_GUT",
|
||
avg7: avg(last7),
|
||
avg30: avg(last30),
|
||
latest: moodLatestScore,
|
||
trend30,
|
||
totalEntries: moodTotalEntries,
|
||
...(await buildMoodDimensionBlock(userId, historyFloor)),
|
||
...(await buildMoodContextBlock(userId, historyFloor)),
|
||
...(historyFloor ? {} : await buildMoodPrognosisBlock(userId)),
|
||
coverage: {
|
||
count: moodTotalEntries,
|
||
spanDays,
|
||
avgDaysBetween,
|
||
oldestDaysAgo: daysBetweenDateKeys(oldestKey, todayKey),
|
||
newestDaysAgo: daysBetweenDateKeys(newestKey, todayKey),
|
||
},
|
||
};
|
||
}
|
||
|
||
return { feature, dailyPoints: moodDailyPoints };
|
||
}
|
||
|
||
/**
|
||
* v1.25 — newest-first row cap on the bulk measurement read in
|
||
* `extractFeatures`. Mirrors the Coach snapshot's `SNAPSHOT_MEASUREMENT_ROW_CAP`
|
||
* (6000): the prompt / insights aggregates only ever fold this read into a
|
||
* bounded set of summaries, so a year of dense PULSE / glucose rows is wasted
|
||
* I/O on the shared pool for a heavy-data tenant. Full-history extremes come
|
||
* from `readAllTimeExtremes`, not this read, so the cap stays correct.
|
||
*/
|
||
const FEATURE_MEASUREMENT_ROW_CAP = 6000;
|
||
|
||
/**
|
||
* The feature blocks the single-metric Coach read can ask for on their own.
|
||
* Each is built from exactly the reads the full extraction runs for it.
|
||
*/
|
||
export type ScopedFeatureBlock = "weight" | "bloodPressure" | "pulse" | "mood";
|
||
|
||
const SCOPED_BLOCK_TYPES: Readonly<
|
||
Record<ScopedFeatureBlock, readonly MeasurementType[]>
|
||
> = {
|
||
weight: ["WEIGHT"],
|
||
bloodPressure: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"],
|
||
pulse: ["PULSE"],
|
||
mood: [],
|
||
};
|
||
|
||
export async function extractFeatures(
|
||
userId: string,
|
||
includeRaw: boolean,
|
||
options: {
|
||
sinceDays?: number;
|
||
/**
|
||
* Build only these blocks (plus `context`) and return. Each block is
|
||
* identical to the one the full extraction builds: the bulk read keeps its
|
||
* all-type newest-first cap, so the rows a block sees do not change. Used
|
||
* by the single-metric Coach read, which reads one block and nothing else.
|
||
*/
|
||
only?: ReadonlySet<ScopedFeatureBlock>;
|
||
/**
|
||
* The Coach's lookback floor. Given, nothing older than it is read: the
|
||
* `allTime*` figures cover the history from the floor on, the mood
|
||
* history and its sub-blocks start at it, and the mood forecast, a model
|
||
* fitted on the whole history, is left out.
|
||
*/
|
||
historyFloor?: Date;
|
||
} = {},
|
||
): Promise<AggregatedFeatures | RawFeatures> {
|
||
const only = options.only;
|
||
const historyFloor = options.historyFloor ?? null;
|
||
const wants = (block: ScopedFeatureBlock) => !only || only.has(block);
|
||
const allTimeTypes = (["weight", "bloodPressure", "pulse"] as const).flatMap(
|
||
(block) => (wants(block) ? SCOPED_BLOCK_TYPES[block] : []),
|
||
);
|
||
// The bulk read feeds every block except mood; a mood-only read skips it.
|
||
const needsMeasurementRows = !only || allTimeTypes.length > 0;
|
||
const user = await prisma.user.findUnique({
|
||
where: { id: userId },
|
||
select: {
|
||
heightCm: true,
|
||
dateOfBirth: true,
|
||
gender: true,
|
||
thresholdsJson: true,
|
||
},
|
||
});
|
||
|
||
const now = Date.now();
|
||
|
||
// Fetch measurements. Default = ALL (full temporal context for the
|
||
// dashboard / insights generator). Callers that only consume the
|
||
// ≤90-day windows (Coach snapshot per turn) pass `sinceDays: 90` so
|
||
// the per-turn I/O stays bounded — `findMany({ where: { userId } })`
|
||
// is unbounded by user-history size and gets paid once per Coach turn
|
||
// for power users with multi-year Withings imports.
|
||
const sinceDays = options.sinceDays;
|
||
const sinceCutoff =
|
||
typeof sinceDays === "number" && sinceDays > 0
|
||
? new Date(now - sinceDays * 24 * 60 * 60 * 1000)
|
||
: null;
|
||
// v1.25 — newest-first cap on the bulk feature read, mirroring the Coach
|
||
// snapshot (`SNAPSHOT_MEASUREMENT_ROW_CAP`). Callers pass windows of 365-400
|
||
// days, and PULSE / glucose are 200k-row-class types, so an uncapped read
|
||
// pulled hundreds of thousands of rows per Coach turn and nightly briefing
|
||
// only to fold them into a handful of summaries. Read newest-first, cap, then
|
||
// reverse so every downstream consumer (`byType`, `summarize`, BP pairing,
|
||
// sleep reconstruction, oldest/newest span) still sees ascending order. The
|
||
// cap is safe even unbounded: the genuine all-time extremes are sourced
|
||
// separately via `readAllTimeExtremes` on the bounded branch, so capping only
|
||
// sheds the deepest rows of the bulk read and never relabels an extreme.
|
||
const measurements = !needsMeasurementRows
|
||
? []
|
||
: await prisma.measurement
|
||
.findMany({
|
||
where: sinceCutoff
|
||
? { userId, measuredAt: { gte: sinceCutoff }, deletedAt: null }
|
||
: { userId, deletedAt: null },
|
||
orderBy: { measuredAt: "desc" },
|
||
take: FEATURE_MEASUREMENT_ROW_CAP,
|
||
// Project only the columns every downstream consumer reads (`byType`,
|
||
// `summarize`, BP pairing, and `reconstructSleepNights`'s `SleepStageRow`).
|
||
// The PULSE / glucose windows are 200k-row-class; pulling every column
|
||
// (notes, externalId, …) is pure wasted I/O on the shared Prisma pool.
|
||
select: {
|
||
type: true,
|
||
value: true,
|
||
measuredAt: true,
|
||
sleepStage: true,
|
||
source: true,
|
||
deviceType: true,
|
||
},
|
||
})
|
||
// Restore ascending order so order-sensitive consumers (oldest/newest span,
|
||
// BP pairing, sleep reconstruction) see the same shape as before the cap.
|
||
.then((rows) => rows.reverse());
|
||
|
||
// v1.18.11 — when the bulk read is bounded to a recent window, the
|
||
// windowed `summarize()` no longer covers the full history, so the `allTime*`
|
||
// fields would silently become "last `sinceDays` days" extremes. Source the
|
||
// genuine full-history min / max / mean from one grouped aggregation (no row
|
||
// materialisation) so the labels stay honest. Unbounded reads (sinceCutoff
|
||
// null) already see the whole history and skip the extra query.
|
||
// Grouped by type, so asking for fewer types leaves each one's figures as
|
||
// they were.
|
||
const allTimeExtremes = sinceCutoff
|
||
? await readAllTimeExtremes(
|
||
userId,
|
||
only
|
||
? allTimeTypes
|
||
: ["WEIGHT", "BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA", "PULSE"],
|
||
historyFloor,
|
||
)
|
||
: null;
|
||
|
||
const byType = (type: string) => measurements.filter((m) => m.type === type);
|
||
// The zone pulse days are read in; resolved only when there is pulse to read.
|
||
const pulseTz = measurements.some((m) => m.type === "PULSE")
|
||
? await resolveUserTimezone(userId)
|
||
: DEFAULT_TIMEZONE;
|
||
|
||
const bpTargets = getBpTargets(user?.dateOfBirth ?? null);
|
||
|
||
// Compute overall data span
|
||
const oldestMeasurement =
|
||
measurements.length > 0 ? measurements[0].measuredAt : null;
|
||
const newestMeasurement =
|
||
measurements.length > 0
|
||
? measurements[measurements.length - 1].measuredAt
|
||
: null;
|
||
const overallSpanDays =
|
||
oldestMeasurement && newestMeasurement
|
||
? Math.round(
|
||
(newestMeasurement.getTime() - oldestMeasurement.getTime()) /
|
||
(24 * 60 * 60 * 1000),
|
||
)
|
||
: 0;
|
||
|
||
// Compute age
|
||
let ageYears: number | null = null;
|
||
if (user?.dateOfBirth) {
|
||
const dob = user.dateOfBirth;
|
||
const today = new Date();
|
||
ageYears = today.getFullYear() - dob.getFullYear();
|
||
const monthDiff = today.getMonth() - dob.getMonth();
|
||
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < dob.getDate())) {
|
||
ageYears--;
|
||
}
|
||
}
|
||
|
||
const features: AggregatedFeatures = {
|
||
context: {
|
||
heightCm: user?.heightCm ?? null,
|
||
hasBpTargets: !!bpTargets,
|
||
totalMeasurements: measurements.length,
|
||
dataSpanDays: overallSpanDays,
|
||
oldestMeasurementDaysAgo: oldestMeasurement
|
||
? Math.round(
|
||
(now - oldestMeasurement.getTime()) / (24 * 60 * 60 * 1000),
|
||
)
|
||
: null,
|
||
newestMeasurementDaysAgo: newestMeasurement
|
||
? Math.round(
|
||
(now - newestMeasurement.getTime()) / (24 * 60 * 60 * 1000),
|
||
)
|
||
: null,
|
||
ageYears,
|
||
gender: user?.gender ?? null,
|
||
},
|
||
};
|
||
|
||
// Weight
|
||
const weightData = byType("WEIGHT");
|
||
if (wants("weight") && weightData.length > 0) {
|
||
const summary = summarize(toDataPoints(weightData));
|
||
const bmi =
|
||
user?.heightCm && summary.latest
|
||
? parseFloat((summary.latest / (user.heightCm / 100) ** 2).toFixed(1))
|
||
: null;
|
||
|
||
features.weight = {
|
||
latest: summary.latest!,
|
||
avg7: summary.avg7,
|
||
avg30: summary.avg30,
|
||
avg90: avgInWindow(weightData, now, 90),
|
||
// v1.18.11 — full-history extremes from the grouped aggregation when
|
||
// the bulk read is windowed; the in-window summary otherwise.
|
||
allTimeAvg: allTimeExtremes
|
||
? roundMean(allTimeExtremes.get("WEIGHT")?.mean ?? null)
|
||
: summary.count > 0
|
||
? summary.mean
|
||
: null,
|
||
allTimeMin: allTimeExtremes
|
||
? (allTimeExtremes.get("WEIGHT")?.min ?? null)
|
||
: summary.count > 0
|
||
? summary.min
|
||
: null,
|
||
allTimeMax: allTimeExtremes
|
||
? (allTimeExtremes.get("WEIGHT")?.max ?? null)
|
||
: summary.count > 0
|
||
? summary.max
|
||
: null,
|
||
slope30: summary.slope30?.slope ?? null,
|
||
outlierCount: summary.anomalyCount ?? 0,
|
||
bmi,
|
||
coverage: computeCoverage(weightData, now),
|
||
};
|
||
const weightTarget = buildWeightTargetFeature(
|
||
resolveWeightTargetOverride(user?.thresholdsJson),
|
||
{ avg7: summary.avg7, latest: summary.latest },
|
||
);
|
||
if (weightTarget) features.weight.target = weightTarget;
|
||
}
|
||
|
||
// Blood Pressure
|
||
const sysData = byType("BLOOD_PRESSURE_SYS");
|
||
const diaData = byType("BLOOD_PRESSURE_DIA");
|
||
if (wants("bloodPressure") && (sysData.length > 0 || diaData.length > 0)) {
|
||
const sysSummary =
|
||
sysData.length > 0 ? summarize(toDataPoints(sysData)) : null;
|
||
const diaSummary =
|
||
diaData.length > 0 ? summarize(toDataPoints(diaData)) : null;
|
||
|
||
let pctInTarget: number | null = null;
|
||
if (bpTargets) {
|
||
const sysByTime = new Map(
|
||
sysData.map((m) => [m.measuredAt.getTime(), m.value]),
|
||
);
|
||
let inTargetCount = 0;
|
||
let pairedCount = 0;
|
||
for (const dia of diaData) {
|
||
const sysVal = sysByTime.get(dia.measuredAt.getTime());
|
||
if (sysVal === undefined) continue;
|
||
pairedCount++;
|
||
// v1.4.16 — one-sided ceiling semantics with hypotension
|
||
// floor. See lib/analytics/bp-in-target.ts.
|
||
if (isBpReadingInTarget(sysVal, dia.value, bpTargets)) {
|
||
inTargetCount++;
|
||
}
|
||
}
|
||
pctInTarget =
|
||
pairedCount > 0
|
||
? Math.round((inTargetCount / pairedCount) * 100)
|
||
: null;
|
||
}
|
||
|
||
features.bloodPressure = {
|
||
avgSys30: sysSummary?.avg30 ?? null,
|
||
avgDia30: diaSummary?.avg30 ?? null,
|
||
avgSys90: sysData.length > 0 ? avgInWindow(sysData, now, 90) : null,
|
||
avgDia90: diaData.length > 0 ? avgInWindow(diaData, now, 90) : null,
|
||
// v1.18.11 — full-history extremes when the bulk read is windowed.
|
||
allTimeAvgSys: allTimeExtremes
|
||
? roundMean(allTimeExtremes.get("BLOOD_PRESSURE_SYS")?.mean ?? null)
|
||
: sysSummary?.count
|
||
? sysSummary.mean
|
||
: null,
|
||
allTimeAvgDia: allTimeExtremes
|
||
? roundMean(allTimeExtremes.get("BLOOD_PRESSURE_DIA")?.mean ?? null)
|
||
: diaSummary?.count
|
||
? diaSummary.mean
|
||
: null,
|
||
allTimeMinSys: allTimeExtremes
|
||
? (allTimeExtremes.get("BLOOD_PRESSURE_SYS")?.min ?? null)
|
||
: sysSummary?.count
|
||
? sysSummary.min
|
||
: null,
|
||
allTimeMaxSys: allTimeExtremes
|
||
? (allTimeExtremes.get("BLOOD_PRESSURE_SYS")?.max ?? null)
|
||
: sysSummary?.count
|
||
? sysSummary.max
|
||
: null,
|
||
allTimeMinDia: allTimeExtremes
|
||
? (allTimeExtremes.get("BLOOD_PRESSURE_DIA")?.min ?? null)
|
||
: diaSummary?.count
|
||
? diaSummary.min
|
||
: null,
|
||
allTimeMaxDia: allTimeExtremes
|
||
? (allTimeExtremes.get("BLOOD_PRESSURE_DIA")?.max ?? null)
|
||
: diaSummary?.count
|
||
? diaSummary.max
|
||
: null,
|
||
slopeSys30: sysSummary?.slope30?.slope ?? null,
|
||
slopeDia30: diaSummary?.slope30?.slope ?? null,
|
||
sdSys30: (() => {
|
||
const fromMs = now - 30 * 24 * 60 * 60 * 1000;
|
||
const vals = sysData
|
||
.filter((m) => m.measuredAt.getTime() >= fromMs)
|
||
.map((m) => m.value);
|
||
return roundedSpread(vals, READING_SPREAD);
|
||
})(),
|
||
sdDia30: (() => {
|
||
const fromMs = now - 30 * 24 * 60 * 60 * 1000;
|
||
const vals = diaData
|
||
.filter((m) => m.measuredAt.getTime() >= fromMs)
|
||
.map((m) => m.value);
|
||
return roundedSpread(vals, READING_SPREAD);
|
||
})(),
|
||
pulsePressure30: (() => {
|
||
const avgSys = sysSummary?.avg30 ?? null;
|
||
const avgDia = diaSummary?.avg30 ?? null;
|
||
if (avgSys === null || avgDia === null) return null;
|
||
return Math.round((avgSys - avgDia) * 10) / 10;
|
||
})(),
|
||
pctInTarget,
|
||
coverage: computeCoverage(
|
||
[...sysData, ...diaData].sort(
|
||
(a, b) => a.measuredAt.getTime() - b.measuredAt.getTime(),
|
||
),
|
||
now,
|
||
),
|
||
};
|
||
}
|
||
|
||
// Pulse
|
||
const pulseData = byType("PULSE");
|
||
if (wants("pulse") && pulseData.length > 0) {
|
||
const summary = summarize(toDataPoints(pulseData));
|
||
// A day of pulse is the mean of its local hours' means and a window the
|
||
// mean of its days (see `day-mean.ts`): `summarize` means every reading,
|
||
// so the means come from the shared helper over the same windows.
|
||
const pulseDays = { type: "PULSE", tz: pulseTz };
|
||
const pulseMean = (fromDaysAgo: number) =>
|
||
avgInWindow(pulseData, now, fromDaysAgo, 0, pulseDays);
|
||
const allPulse = readingsMean("PULSE", pulseData, pulseTz);
|
||
features.pulse = {
|
||
avg7: pulseMean(7),
|
||
avg30: pulseMean(30),
|
||
avg90: pulseMean(90),
|
||
// v1.18.11 — full-history extremes when the bulk read is windowed.
|
||
allTimeAvg: allTimeExtremes
|
||
? roundMean(allTimeExtremes.get("PULSE")?.mean ?? null)
|
||
: allPulse === null
|
||
? null
|
||
: Math.round(allPulse * 100) / 100,
|
||
allTimeMin: allTimeExtremes
|
||
? (allTimeExtremes.get("PULSE")?.min ?? null)
|
||
: summary.count > 0
|
||
? summary.min
|
||
: null,
|
||
allTimeMax: allTimeExtremes
|
||
? (allTimeExtremes.get("PULSE")?.max ?? null)
|
||
: summary.count > 0
|
||
? summary.max
|
||
: null,
|
||
slope30: summary.slope30?.slope ?? null,
|
||
anomalyCount: summary.anomalyCount ?? 0,
|
||
coverage: computeCoverage(pulseData, now),
|
||
};
|
||
}
|
||
|
||
if (only) {
|
||
if (only.has("mood")) {
|
||
const moodRead = await readMoodFeature(userId, historyFloor);
|
||
if (moodRead.feature) features.mood = moodRead.feature;
|
||
}
|
||
return features;
|
||
}
|
||
|
||
// Body Fat
|
||
const fatData = byType("BODY_FAT");
|
||
if (fatData.length > 0) {
|
||
const summary = summarize(toDataPoints(fatData));
|
||
features.bodyFat = {
|
||
latest: summary.latest,
|
||
avg30: summary.avg30,
|
||
slope30: summary.slope30?.slope ?? null,
|
||
coverage: computeCoverage(fatData, now),
|
||
};
|
||
}
|
||
|
||
// Grip strength (kg) — clinical-depth signal; the briefing narrates the
|
||
// trajectory, the sex-aware EWGSOP2 floor stays at the display edge.
|
||
const gripData = byType("GRIP_STRENGTH");
|
||
if (gripData.length > 0) {
|
||
const summary = summarize(toDataPoints(gripData));
|
||
features.gripStrength = {
|
||
latest: summary.latest,
|
||
avg30: summary.avg30,
|
||
slope30: summary.slope30?.slope ?? null,
|
||
coverage: computeCoverage(gripData, now),
|
||
};
|
||
}
|
||
|
||
// Waist circumference (cm) + waist-to-height ratio. WHtR is computed from the
|
||
// freshest circumference and the user's height (the same canonical derivation
|
||
// the detail page uses); omitted when height is unknown.
|
||
const waistData = byType("WAIST_CIRCUMFERENCE");
|
||
if (waistData.length > 0) {
|
||
const summary = summarize(toDataPoints(waistData));
|
||
const whtrLatest =
|
||
user?.heightCm && summary.latest
|
||
? Math.round((summary.latest / user.heightCm) * 100) / 100
|
||
: null;
|
||
features.waist = {
|
||
latest: summary.latest,
|
||
avg30: summary.avg30,
|
||
slope30: summary.slope30?.slope ?? null,
|
||
whtrLatest,
|
||
coverage: computeCoverage(waistData, now),
|
||
};
|
||
}
|
||
|
||
// Pain (0–10 NRS, lower-better) — surface a sustained or rising pain burden.
|
||
const painData = byType("PAIN_NRS");
|
||
if (painData.length > 0) {
|
||
const summary = summarize(toDataPoints(painData));
|
||
features.pain = {
|
||
latest: summary.latest,
|
||
avg7: summary.avg7,
|
||
avg30: summary.avg30,
|
||
slope30: summary.slope30?.slope ?? null,
|
||
coverage: computeCoverage(painData, now),
|
||
};
|
||
}
|
||
|
||
// Sleep Duration
|
||
//
|
||
// SLEEP_DURATION is stored ONE ROW PER STAGE per night, so summarising the
|
||
// raw stage rows would average individual stages (and double-count a bare
|
||
// ASLEEP aggregate against its granular CORE/DEEP/REM twin). Route the
|
||
// feature block through the per-night dedup reconstruction — the same helper
|
||
// the dashboard / series / status path use — so `avg7` / `avg30` / `latest`
|
||
// are per-night TIME-ASLEEP totals in minutes, never stage averages.
|
||
const sleepData = byType("SLEEP_DURATION");
|
||
if (sleepData.length > 0) {
|
||
const [sleepTz, sleepPriority] = await Promise.all([
|
||
resolveUserTimezone(userId),
|
||
loadUserSourcePriority(userId),
|
||
]);
|
||
const sleepNights = reconstructSleepNights(
|
||
sleepData as unknown as SleepStageRow[],
|
||
sleepTz,
|
||
sleepPriority,
|
||
).filter((n) => n.asleepMinutes > 0);
|
||
const summary = summarize(
|
||
sleepNights.map((n) => ({ date: n.measuredAt, value: n.asleepMinutes })),
|
||
);
|
||
features.sleep = {
|
||
avg7: summary.avg7,
|
||
avg30: summary.avg30,
|
||
latest: summary.latest,
|
||
coverage: computeCoverage(
|
||
sleepNights.map((n) => ({ measuredAt: n.measuredAt })),
|
||
now,
|
||
),
|
||
};
|
||
}
|
||
|
||
// Activity Steps
|
||
const activityData = byType("ACTIVITY_STEPS");
|
||
if (activityData.length > 0) {
|
||
const summary = summarize(toDataPoints(activityData));
|
||
features.activity = {
|
||
avg7: summary.avg7,
|
||
avg30: summary.avg30,
|
||
latest: summary.latest,
|
||
coverage: computeCoverage(activityData, now),
|
||
};
|
||
}
|
||
|
||
// Mood — see `readMoodFeature`.
|
||
const moodRead = await readMoodFeature(userId, historyFloor);
|
||
const moodDailyPoints = moodRead.dailyPoints;
|
||
if (moodRead.feature) features.mood = moodRead.feature;
|
||
|
||
// Cross-metric correlations
|
||
const weightPoints = toDataPoints(weightData);
|
||
const sysPoints = toDataPoints(sysData);
|
||
const diaPoints = toDataPoints(diaData);
|
||
const pulsePoints = toDataPoints(pulseData);
|
||
const moodPoints: DataPoint[] = moodDailyPoints.map((e) => ({
|
||
date: e.measuredAt,
|
||
value: e.value,
|
||
}));
|
||
|
||
const computeCorr = (a: DataPoint[], b: DataPoint[]) =>
|
||
significantPearsonCorrelation(pairByTimestamp(a, b));
|
||
|
||
const sleepPoints = toDataPoints(sleepData);
|
||
|
||
features.correlations = {
|
||
weightVsSystolic: computeCorr(weightPoints, sysPoints),
|
||
weightVsDiastolic: computeCorr(weightPoints, diaPoints),
|
||
pulseVsSystolic: computeCorr(pulsePoints, sysPoints),
|
||
moodVsPulse: computeCorr(moodPoints, pulsePoints),
|
||
moodVsSystolic: computeCorr(moodPoints, sysPoints),
|
||
moodVsWeight: computeCorr(moodPoints, weightPoints),
|
||
sleepVsPulse:
|
||
sleepData.length > 0 ? computeCorr(sleepPoints, pulsePoints) : null,
|
||
sleepVsSystolic:
|
||
sleepData.length > 0 ? computeCorr(sleepPoints, sysPoints) : null,
|
||
};
|
||
|
||
// Rate-Pressure Product (RPP) — myocardial oxygen demand indicator
|
||
if (features.pulse && features.bloodPressure) {
|
||
const rpp7 =
|
||
features.pulse.avg7 !== null && features.bloodPressure.avgSys30 !== null
|
||
? Math.round(
|
||
features.pulse.avg7 *
|
||
(avgInWindow(sysData, now, 7) ?? features.bloodPressure.avgSys30),
|
||
)
|
||
: null;
|
||
const rpp30 =
|
||
features.pulse.avg30 !== null && features.bloodPressure.avgSys30 !== null
|
||
? Math.round(features.pulse.avg30 * features.bloodPressure.avgSys30)
|
||
: null;
|
||
const rppRef = rpp30 ?? rpp7;
|
||
features.ratePressureProduct = {
|
||
rpp7,
|
||
rpp30,
|
||
risk: rppRef !== null ? (rppRef > 12000 ? "elevated" : "normal") : null,
|
||
};
|
||
}
|
||
|
||
// Body Composition Divergence
|
||
if (features.weight && features.bodyFat) {
|
||
const weightStable =
|
||
features.weight.slope30 !== null &&
|
||
Math.abs(features.weight.slope30) < 0.01;
|
||
const bodyFatRising =
|
||
features.bodyFat.slope30 !== null && features.bodyFat.slope30 > 0;
|
||
features.bodyCompositionDivergence = {
|
||
weightStable,
|
||
bodyFatRising,
|
||
flag: weightStable && bodyFatRising,
|
||
};
|
||
}
|
||
|
||
// Mood-Adherence Risk Flag
|
||
if (
|
||
features.mood &&
|
||
features.medications &&
|
||
features.medications.length > 0
|
||
) {
|
||
features.moodAdherenceRisk =
|
||
features.mood.avg7 !== null &&
|
||
features.mood.avg7 <= 2.5 &&
|
||
features.mood.trend30 === "declining";
|
||
}
|
||
|
||
// Seasonal BP Variation (only if > 180 days of data)
|
||
if (features.context.dataSpanDays > 180 && sysData.length > 0) {
|
||
const winterMonths = [11, 0, 1]; // Dec, Jan, Feb (0-indexed)
|
||
const summerMonths = [5, 6, 7]; // Jun, Jul, Aug
|
||
const winterVals = sysData
|
||
.filter((m) => winterMonths.includes(m.measuredAt.getMonth()))
|
||
.map((m) => m.value);
|
||
const summerVals = sysData
|
||
.filter((m) => summerMonths.includes(m.measuredAt.getMonth()))
|
||
.map((m) => m.value);
|
||
const winterAvg =
|
||
winterVals.length > 0
|
||
? Math.round(
|
||
(winterVals.reduce((s, v) => s + v, 0) / winterVals.length) * 10,
|
||
) / 10
|
||
: null;
|
||
const summerAvg =
|
||
summerVals.length > 0
|
||
? Math.round(
|
||
(summerVals.reduce((s, v) => s + v, 0) / summerVals.length) * 10,
|
||
) / 10
|
||
: null;
|
||
const delta =
|
||
winterAvg !== null && summerAvg !== null
|
||
? Math.round((winterAvg - summerAvg) * 10) / 10
|
||
: null;
|
||
features.seasonalVariation = {
|
||
winterAvgSys: winterAvg,
|
||
summerAvgSys: summerAvg,
|
||
delta,
|
||
significance:
|
||
delta !== null ? (Math.abs(delta) > 5 ? "elevated" : "normal") : null,
|
||
};
|
||
}
|
||
|
||
// Historical comparison: current 7d avg vs previous 30d avg (days 7-37)
|
||
features.historicalComparison = {};
|
||
if (weightData.length > 0) {
|
||
features.historicalComparison.weight = computeHistoricalComparison(
|
||
weightData,
|
||
now,
|
||
);
|
||
}
|
||
if (sysData.length > 0) {
|
||
features.historicalComparison.systolic = computeHistoricalComparison(
|
||
sysData,
|
||
now,
|
||
);
|
||
}
|
||
if (diaData.length > 0) {
|
||
features.historicalComparison.diastolic = computeHistoricalComparison(
|
||
diaData,
|
||
now,
|
||
);
|
||
}
|
||
if (pulseData.length > 0) {
|
||
features.historicalComparison.pulse = computeHistoricalComparison(
|
||
pulseData,
|
||
now,
|
||
{ type: "PULSE", tz: pulseTz },
|
||
);
|
||
}
|
||
|
||
// Medications
|
||
const medications = await prisma.medication.findMany({
|
||
// v1.16.11 — as-needed (PRN) medications never surface a compliance
|
||
// rate (no expected doses), so the insight features exclude them.
|
||
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,
|
||
},
|
||
});
|
||
|
||
if (medications.length > 0) {
|
||
const categoryMap = await getMedicationCategories(
|
||
medications.map((med) => med.id),
|
||
);
|
||
|
||
// Single batched fetch + in-memory grouping replaces the per-medication
|
||
// findMany loop. Same shape as the v1.3.0 fix to /api/insights/comprehensive
|
||
// (the previous N+1 the v3 audit closed). 90 days is the longest window
|
||
// calculateCompliance uses below, so we don't need the full intake history.
|
||
const ninetyDaysAgo = laterDate(
|
||
new Date(Date.now() - 90 * 86_400_000),
|
||
historyFloor,
|
||
);
|
||
const allEvents = await prisma.medicationIntakeEvent.findMany({
|
||
where: {
|
||
userId,
|
||
// v1.7.0 sync — exclude tombstoned rows.
|
||
deletedAt: null,
|
||
medicationId: { in: medications.map((med) => med.id) },
|
||
scheduledFor: { gte: ninetyDaysAgo },
|
||
},
|
||
orderBy: { scheduledFor: "desc" },
|
||
select: {
|
||
medicationId: true,
|
||
takenAt: true,
|
||
skipped: true,
|
||
scheduledFor: true,
|
||
},
|
||
});
|
||
|
||
const eventsByMed = new Map<
|
||
string,
|
||
{ takenAt: Date | null; skipped: boolean; scheduledFor: Date }[]
|
||
>();
|
||
for (const e of allEvents) {
|
||
const list = eventsByMed.get(e.medicationId) ?? [];
|
||
list.push({
|
||
takenAt: e.takenAt,
|
||
skipped: e.skipped,
|
||
scheduledFor: e.scheduledFor,
|
||
});
|
||
eventsByMed.set(e.medicationId, list);
|
||
}
|
||
|
||
// v1.7.0 — resolve the user timezone once so every
|
||
// per-med compliance call can route its denominator through the
|
||
// canonical engine (RRULE / rolling / one-shot / PRN / cyclic).
|
||
const userTz = await resolveUserTimezone(userId);
|
||
|
||
features.medications = medications.map((med) => {
|
||
const mapped = eventsByMed.get(med.id) ?? [];
|
||
const medicationContext = buildComplianceMedicationContext(
|
||
med,
|
||
lastNonSkippedTakenAt(mapped),
|
||
userTz,
|
||
);
|
||
const c7 = calculateCompliance(mapped, med.schedules, 7, med.createdAt, {
|
||
medicationContext,
|
||
});
|
||
const c30 = calculateCompliance(
|
||
mapped,
|
||
med.schedules,
|
||
30,
|
||
med.createdAt,
|
||
{
|
||
medicationContext,
|
||
},
|
||
);
|
||
const c90 = calculateCompliance(
|
||
mapped,
|
||
med.schedules,
|
||
90,
|
||
med.createdAt,
|
||
{
|
||
medicationContext,
|
||
},
|
||
);
|
||
|
||
return {
|
||
// v1.30.25 — `name` / `dose` are free text and this struct lands in
|
||
// the Coach SNAPSHOT as `medications`. The category is resolved from
|
||
// `med.id` above, so sanitising the label cannot change any
|
||
// classification. Found by the snapshot free-text guard once its file
|
||
// list was derived from the import graph instead of hardcoded.
|
||
name: sanitizeForPrompt(med.name, MED_NAME_MAX_CHARS),
|
||
dose: sanitizeForPrompt(med.dose, MED_DOSE_MAX_CHARS),
|
||
category: categoryMap[med.id] ?? "OTHER",
|
||
compliance7: c7.rate,
|
||
compliance30: c30.rate,
|
||
compliance90: c90.rate,
|
||
streak: c30.streak,
|
||
missedLast7: c7.missed,
|
||
};
|
||
});
|
||
}
|
||
|
||
// v1.18.7 — present-focused "Signals of the day". Computed from the
|
||
// in-memory measurement set (no extra DB round-trip). Lands inside the
|
||
// compacted features payload, so it feeds both the briefing prompt AND the
|
||
// content-hash gate — a fresh daily signal forces the briefing to refresh.
|
||
// Each signal is a statement about today, which is a calendar day in the
|
||
// reader's own zone, so the zone is resolved for every account here, not
|
||
// only for the ones with pulse.
|
||
const signalsOfDay = computeSignalsOfDay(
|
||
byType,
|
||
now,
|
||
await resolveUserTimezone(userId),
|
||
);
|
||
if (signalsOfDay.length > 0) {
|
||
features.signalsOfDay = signalsOfDay;
|
||
}
|
||
|
||
// v1.22 — glucose aggregate block. Computed from the already-fetched
|
||
// measurement set (no extra DB read), so it is always cheap to emit. Glucose
|
||
// previously reached the briefing only as one `signalOfDay` row.
|
||
const glucoseData = byType("BLOOD_GLUCOSE");
|
||
if (glucoseData.length > 0) {
|
||
const summary = summarize(toDataPoints(glucoseData));
|
||
const newest = glucoseData[glucoseData.length - 1];
|
||
features.glucose = {
|
||
avg7: summary.avg7,
|
||
avg30: summary.avg30,
|
||
avg90: avgInWindow(glucoseData, now, 90),
|
||
latest: summary.latest,
|
||
latestDaysAgo: Math.round(
|
||
(now - newest.measuredAt.getTime()) / (24 * 60 * 60 * 1000),
|
||
),
|
||
slope30: summary.slope30?.slope ?? null,
|
||
coverage: computeCoverage(glucoseData, now),
|
||
};
|
||
}
|
||
|
||
// v1.22 — cross-signal integration blocks (labs / preventive-care /
|
||
// workouts). Briefing-scoped: only the wide briefing window pays the extra
|
||
// reads; the tight Coach-snapshot window (which carries its own labs / illness
|
||
// / workout context) skips them. Each block is omitted when its domain is
|
||
// empty (no fabricated zeros). Fetched in parallel — independent reads.
|
||
const includeIntegrations =
|
||
sinceCutoff === null ||
|
||
(typeof sinceDays === "number" &&
|
||
sinceDays >= INTEGRATION_BLOCK_MIN_WINDOW_DAYS);
|
||
if (includeIntegrations) {
|
||
// The preventive-care block states a distance in whole days, so it needs
|
||
// to know whose calendar it is counting on. Resolved here rather than
|
||
// inside the block because the resolver reads the database and this is
|
||
// the layer that already talks to it; the resolver's own 60 s cache makes
|
||
// the second call in this function free.
|
||
const preventiveTz = await resolveUserTimezone(userId);
|
||
const [labs, preventiveCare, workouts, ecg] = await Promise.all([
|
||
readLabsBriefingBlock(userId, now),
|
||
readPreventiveCareBlock(userId, now, preventiveTz),
|
||
readWorkoutsBlock(userId, now),
|
||
// ECG device-verdict descriptor (never the waveform). Weaves the
|
||
// ECG silo into the narrative; the model may reference it, attributed to
|
||
// the device, but never interprets the trace (it never sees one).
|
||
readEcgBriefingBlock(userId, now),
|
||
]);
|
||
if (labs) features.labs = labs;
|
||
if (preventiveCare) features.preventiveCare = preventiveCare;
|
||
if (workouts) features.workouts = workouts;
|
||
if (ecg) features.ecg = ecg;
|
||
}
|
||
|
||
// v1.4.36 — "raw" mode no longer dumps every measurement row
|
||
// into the prompt. Instead we attach DAY / WEEK / MONTH bucket means
|
||
// from `measurement_rollups` so the model gets the same temporal
|
||
// shape (granular near-term, coarser deep history) at O(buckets)
|
||
// instead of O(rows). The v1.4.35 shape blew past Codex's 10 MB
|
||
// string limit on power users (~25.9 MB observed); the bucketed
|
||
// shape lands at ~50–500 KB per account.
|
||
if (includeRaw) {
|
||
const bucketedMeasurements = await readBucketedSeries(userId, now);
|
||
const rawFeatures: RawFeatures = {
|
||
...features,
|
||
bucketedMeasurements,
|
||
};
|
||
enforceSizeGuard(rawFeatures);
|
||
return rawFeatures;
|
||
}
|
||
|
||
enforceSizeGuard(features);
|
||
return features;
|
||
}
|
||
|
||
/**
|
||
* Hard ceiling on the serialised payload. Throws
|
||
* `FeaturesPayloadTooLargeError` (tagged with `code` so the route
|
||
* handler can pattern-match) when the JSON dump crosses
|
||
* `FEATURES_MAX_BYTES`. The route handler downgrades to the non-raw
|
||
* shape and annotates so the regression is observable; the model
|
||
* call still succeeds.
|
||
*/
|
||
function enforceSizeGuard(payload: AggregatedFeatures | RawFeatures): void {
|
||
const sizeBytes = Buffer.byteLength(JSON.stringify(payload), "utf8");
|
||
if (sizeBytes > FEATURES_MAX_BYTES) {
|
||
throw new FeaturesPayloadTooLargeError(sizeBytes, FEATURES_MAX_BYTES);
|
||
}
|
||
}
|