mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
refactor(insights): reduce the blood-pressure card to its snapshot
This commit is contained in:
1 parent
6d3cfbba05
commit
87a35b670d
3 files changed
+298
-461
No files matched your search
@@ -41,7 +41,6 @@ vi.mock("next/headers", () => ({
|
||||
|
||||
vi.mock("@/lib/insights/blood-pressure-status", () => ({
|
||||
generateBloodPressureStatusForUser: vi.fn(),
|
||||
resolveBloodPressureStatusLocale: () => "en",
|
||||
}));
|
||||
|
||||
// The `statusText` capability decides whether the note is served; the
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import {
|
||||
generateBloodPressureStatusForUser,
|
||||
resolveBloodPressureStatusLocale,
|
||||
} from "@/lib/insights/blood-pressure-status";
|
||||
import { generateBloodPressureStatusForUser } from "@/lib/insights/blood-pressure-status";
|
||||
import { normalizeLocale as resolveBloodPressureStatusLocale } from "@/lib/insights/status-shared";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
|
||||
@@ -4,18 +4,7 @@ import {
|
||||
getBloodPressureSystemPrompt,
|
||||
getBloodPressureUserPrompt,
|
||||
} from "@/lib/ai/prompts/blood-pressure";
|
||||
import { openerArchetypeHint } from "@/lib/ai/prompts/opener-archetype";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import {
|
||||
formatPreviousContextForPrompt,
|
||||
getPreviousInsightContext,
|
||||
} from "@/lib/insights/memory";
|
||||
import {
|
||||
buildAssessmentContextBlock,
|
||||
computeSteadyRun,
|
||||
pickVarietyLead,
|
||||
} from "@/lib/insights/assessment-context";
|
||||
import { getRelevantCorrelationsForMetric } from "@/lib/insights/metric-correlation-context";
|
||||
import { computeSteadyRun } from "@/lib/insights/assessment-context";
|
||||
import { getBpTargets } from "@/lib/analytics/bp-targets";
|
||||
import { isBpReadingInTarget } from "@/lib/analytics/bp-in-target";
|
||||
import {
|
||||
@@ -41,33 +30,24 @@ import {
|
||||
import {
|
||||
buildGradedSeriesFromPoints,
|
||||
buildGradedSeriesWithRollups,
|
||||
degradeStatusSnapshotToBudget,
|
||||
} from "@/lib/insights/graded-series";
|
||||
import { statusSnapshotCharsForRecord } from "@/lib/ai/capabilities/usage";
|
||||
import { buildMetricSignal } from "@/lib/insights/metric-signal";
|
||||
import {
|
||||
type SupportedLocale,
|
||||
normalizeLocale,
|
||||
finalizeStatusSummary,
|
||||
persistStatusInsight,
|
||||
round,
|
||||
summarizeSeries,
|
||||
} from "@/lib/insights/status-shared";
|
||||
import {
|
||||
readFreshStatusText,
|
||||
refreshUnchangedStatusInsight,
|
||||
resolveReadOnlyStatusMiss,
|
||||
statusCacheAction,
|
||||
} from "@/lib/insights/status-cache";
|
||||
import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash";
|
||||
import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback";
|
||||
import { round, summarizeSeries } from "@/lib/insights/status-shared";
|
||||
import {
|
||||
runPreparedStatusCard,
|
||||
type PreparedStatusCard,
|
||||
type StatusCardResult,
|
||||
} from "@/lib/insights/status-card-generation";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { resolveUserTimezone, userDayKey } from "@/lib/tz/resolver";
|
||||
import {
|
||||
coverageSpan,
|
||||
prepareStatusCard,
|
||||
textStatusCodec,
|
||||
type StatusCardBuild,
|
||||
type StatusCardContext,
|
||||
type StatusCardOptions,
|
||||
type StatusCardPlugin,
|
||||
type StatusTextResult,
|
||||
} from "@/lib/insights/status-pipeline";
|
||||
import { userDayKey } from "@/lib/tz/resolver";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
|
||||
import { canonicalDailyTimestamp } from "@/lib/measurements/consolidation-tz";
|
||||
@@ -122,108 +102,32 @@ function pairDailyBuckets(
|
||||
*/
|
||||
export async function generateBloodPressureStatusForUser(
|
||||
userId: string,
|
||||
options?: {
|
||||
locale?: string | null;
|
||||
force?: boolean;
|
||||
readOnly?: boolean;
|
||||
},
|
||||
): Promise<{
|
||||
hasProvider: boolean;
|
||||
text: string | null;
|
||||
cached: boolean;
|
||||
updatedAt: string | null;
|
||||
preparing?: boolean;
|
||||
revalidating?: boolean;
|
||||
}> {
|
||||
options?: StatusCardOptions,
|
||||
): Promise<StatusTextResult> {
|
||||
const prepared = await prepareBloodPressureStatusForUser(userId, options);
|
||||
const result = await runPreparedStatusCard(prepared);
|
||||
return result as {
|
||||
hasProvider: boolean;
|
||||
text: string | null;
|
||||
cached: boolean;
|
||||
updatedAt: string | null;
|
||||
preparing?: boolean;
|
||||
revalidating?: boolean;
|
||||
};
|
||||
return (await runPreparedStatusCard(prepared)) as StatusTextResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.18.7 — everything up to (not including) the provider call.
|
||||
* See `status-card-generation.ts` for the prepare/run/finalize contract.
|
||||
* Everything up to (not including) the provider call, through the shared
|
||||
* status pipeline (`status-pipeline.ts`); this card supplies its snapshot.
|
||||
*/
|
||||
export async function prepareBloodPressureStatusForUser(
|
||||
export function prepareBloodPressureStatusForUser(
|
||||
userId: string,
|
||||
options?: {
|
||||
locale?: string | null;
|
||||
force?: boolean;
|
||||
/** v1.8.3 — read-only navigation path; see weight-status for the rationale. */
|
||||
readOnly?: boolean;
|
||||
},
|
||||
options?: StatusCardOptions,
|
||||
): Promise<PreparedStatusCard> {
|
||||
const locale = normalizeLocale(options?.locale);
|
||||
const force = options?.force === true;
|
||||
const readOnly = options?.readOnly === true;
|
||||
const cacheAction = statusCacheAction("blood-pressure", locale);
|
||||
// v1.30.3 — resolve the user's own tz BEFORE the day-key so the
|
||||
// cache rolls over at the user's local midnight, not Berlin's. Has to
|
||||
// happen ahead of the cache read below (the earliest possible return
|
||||
// path), not just the later day-bucketing reads.
|
||||
const userTz = await resolveUserTimezone(userId);
|
||||
const todayKey = userDayKey(new Date(), userTz);
|
||||
|
||||
const cached = await readFreshStatusText({
|
||||
userId,
|
||||
cacheAction,
|
||||
todayKey,
|
||||
force,
|
||||
});
|
||||
if (cached) {
|
||||
return {
|
||||
phase: "served",
|
||||
result: {
|
||||
hasProvider: true,
|
||||
text: cached.text,
|
||||
cached: true,
|
||||
updatedAt: cached.updatedAt,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (readOnly) {
|
||||
const outcome = await resolveReadOnlyStatusMiss({
|
||||
userId,
|
||||
metric: "blood-pressure",
|
||||
locale,
|
||||
});
|
||||
// Unavailable for any reason serves the deterministic line and enqueues
|
||||
// nothing. `hasProvider` is provider presence only.
|
||||
if (outcome.kind === "unavailable") {
|
||||
return {
|
||||
phase: "served",
|
||||
result: {
|
||||
hasProvider: outcome.hasProvider,
|
||||
text: getNoKeyBloodPressureStatusText(locale),
|
||||
cached: true,
|
||||
updatedAt: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
// v1.8.7 — stale-while-revalidate: serve the last good assessment
|
||||
// (if any) instantly while the worker re-warms; only fall to the empty
|
||||
// preparing skeleton when none was ever produced.
|
||||
return {
|
||||
phase: "served",
|
||||
result: {
|
||||
hasProvider: true,
|
||||
text: outcome.lastGood?.text ?? null,
|
||||
cached: outcome.lastGood !== null,
|
||||
updatedAt: outcome.lastGood?.updatedAt ?? null,
|
||||
preparing: outcome.lastGood === null,
|
||||
revalidating: outcome.revalidating,
|
||||
},
|
||||
};
|
||||
}
|
||||
return prepareStatusCard(BLOOD_PRESSURE_STATUS_CARD, userId, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* The blood-pressure snapshot: both channels against the person's targets,
|
||||
* weight against systolic, BP-medication adherence against systolic, and
|
||||
* mood context.
|
||||
*/
|
||||
async function buildBloodPressureSnapshot(
|
||||
ctx: StatusCardContext,
|
||||
): Promise<StatusCardBuild<undefined>> {
|
||||
const { userId, locale, userTz, todayKey } = ctx;
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
@@ -231,6 +135,188 @@ export async function prepareBloodPressureStatusForUser(
|
||||
},
|
||||
});
|
||||
|
||||
const { measurements, now, weightSeries, sysSeries, diaSeries } =
|
||||
await readBpChannels(userId, userTz);
|
||||
|
||||
const bpTargets = getBpTargets(user?.dateOfBirth ?? null);
|
||||
const { pairedBloodPressure, bpInTargetPctLast30DailyPoints } =
|
||||
pairBloodPressure(sysSeries.daily, diaSeries.daily, bpTargets, now, userTz);
|
||||
|
||||
const weightVsSystolicPairs = pairDailyBuckets(
|
||||
weightSeries.daily,
|
||||
sysSeries.daily,
|
||||
now,
|
||||
userTz,
|
||||
);
|
||||
const weightVsSystolicCorrelation = significantPearsonCorrelation(
|
||||
weightVsSystolicPairs,
|
||||
);
|
||||
|
||||
const medicationContext = await readBpMedicationContext(
|
||||
userId,
|
||||
now,
|
||||
userTz,
|
||||
sysSeries.daily,
|
||||
);
|
||||
|
||||
const { moodSeries, moodMean, moodGraded } = await readBpMoodContext(
|
||||
userId,
|
||||
now,
|
||||
userTz,
|
||||
);
|
||||
|
||||
// The two BP channels source their recent / weekly slices from a
|
||||
// bounded raw read and their monthly / yearly tail from the MONTH /
|
||||
// YEAR rollup tier (full-history in-memory fallback on a cold-tier
|
||||
// coverage miss). Mood has no rollup tier, so it stays an in-memory fold.
|
||||
const [sysGraded, diaGraded] = await Promise.all([
|
||||
buildGradedSeriesWithRollups(userId, "BLOOD_PRESSURE_SYS", now, userTz),
|
||||
buildGradedSeriesWithRollups(userId, "BLOOD_PRESSURE_DIA", now, userTz),
|
||||
]);
|
||||
|
||||
const { totalSpanDays, newestDaysAgo: newestMeasurementDaysAgo } =
|
||||
coverageSpan(
|
||||
measurements.length > 0 ? measurements[0].measuredAt : null,
|
||||
measurements.length > 0
|
||||
? measurements[measurements.length - 1].measuredAt
|
||||
: null,
|
||||
);
|
||||
|
||||
// v1.18.10 — hand the model the finished recent-vs-baseline
|
||||
// comparison + normal-swing verdict per channel instead of asking it to
|
||||
// derive them. BP needs two signals; the user's own targets are the coarse
|
||||
// band anchor when present.
|
||||
const sysSignal = buildMetricSignal({
|
||||
metric: locale === "en" ? "your systolic" : "deine Systole",
|
||||
unit: "mmHg",
|
||||
direction: "lower-better",
|
||||
graded: sysGraded,
|
||||
normalRange: bpTargets
|
||||
? { low: bpTargets.sysLow, high: bpTargets.sysHigh }
|
||||
: null,
|
||||
newestDaysAgo: newestMeasurementDaysAgo,
|
||||
});
|
||||
const diaSignal = buildMetricSignal({
|
||||
metric: locale === "en" ? "your diastolic" : "deine Diastole",
|
||||
unit: "mmHg",
|
||||
direction: "lower-better",
|
||||
graded: diaGraded,
|
||||
normalRange: bpTargets
|
||||
? { low: bpTargets.diaLow, high: bpTargets.diaHigh }
|
||||
: null,
|
||||
newestDaysAgo: newestMeasurementDaysAgo,
|
||||
});
|
||||
|
||||
const snapshot = {
|
||||
locale,
|
||||
generatedForDay: todayKey,
|
||||
focus: "blood_pressure",
|
||||
dataCoverage: {
|
||||
totalMeasurements: measurements.length,
|
||||
totalSpanDays,
|
||||
newestMeasurementDaysAgo,
|
||||
},
|
||||
bloodPressure: {
|
||||
systolic: {
|
||||
...(sysSignal ? { signal: sysSignal } : {}),
|
||||
summary: summarizeSeries(
|
||||
sysSeries.daily.map((bucket) => ({ value: bucket.value })),
|
||||
),
|
||||
series: sysGraded,
|
||||
},
|
||||
diastolic: {
|
||||
...(diaSignal ? { signal: diaSignal } : {}),
|
||||
summary: summarizeSeries(
|
||||
diaSeries.daily.map((bucket) => ({ value: bucket.value })),
|
||||
),
|
||||
series: diaGraded,
|
||||
},
|
||||
paired: {
|
||||
summary: summarizeSeries(
|
||||
pairedBloodPressure.map((entry) => ({
|
||||
value: (entry.sys + entry.dia) / 2,
|
||||
})),
|
||||
),
|
||||
series: pairedBloodPressure.slice(-CORRELATION_PAIR_CAP),
|
||||
},
|
||||
targets: bpTargets
|
||||
? {
|
||||
systolic: { min: bpTargets.sysLow, max: bpTargets.sysHigh },
|
||||
diastolic: { min: bpTargets.diaLow, max: bpTargets.diaHigh },
|
||||
inTargetPctLast30DailyPoints: bpInTargetPctLast30DailyPoints,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
weightVsSystolic: {
|
||||
correlation: weightVsSystolicCorrelation,
|
||||
pairs: weightVsSystolicPairs
|
||||
.slice(-CORRELATION_PAIR_CAP)
|
||||
.map((entry) => ({
|
||||
day: entry.dayKey,
|
||||
weight: round(entry.a, 2),
|
||||
systolic: round(entry.b, 2),
|
||||
})),
|
||||
},
|
||||
bpMedicationContinuityVsSystolic: {
|
||||
expectedIntakesPerDay: medicationContext.expectedIntakesPerDay,
|
||||
medicationCount: medicationContext.medicationCount,
|
||||
correlation: medicationContext.continuityVsSystolicCorrelation,
|
||||
series:
|
||||
medicationContext.continuityVsSystolicSeries.slice(
|
||||
-CORRELATION_PAIR_CAP,
|
||||
),
|
||||
},
|
||||
bpMedications: medicationContext.medicationCompliance,
|
||||
moodContext:
|
||||
moodSeries.daily.length >= 3
|
||||
? {
|
||||
points: moodSeries.daily.length,
|
||||
mean: moodMean,
|
||||
latest: moodSeries.daily[0]?.value ?? null,
|
||||
series: moodGraded,
|
||||
moodVsSystolicCorrelation: (() => {
|
||||
const moodVsSysPairs = pairDailyBuckets(
|
||||
moodSeries.daily,
|
||||
sysSeries.daily,
|
||||
now,
|
||||
userTz,
|
||||
);
|
||||
return significantPearsonCorrelation(moodVsSysPairs);
|
||||
})(),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
|
||||
return {
|
||||
snapshot,
|
||||
finish: () => ({
|
||||
context: {
|
||||
repeatCount: computeSteadyRun(sysGraded.weekly, sysGraded.monthly),
|
||||
points: sysSeries.daily.length,
|
||||
newestDaysAgo: newestMeasurementDaysAgo,
|
||||
relationsFor: "BLOOD_PRESSURE_SYS",
|
||||
},
|
||||
fallbackText: getNoKeyBloodPressureStatusText(locale, sysSignal),
|
||||
prompts: (snapshotJson, context) => ({
|
||||
systemPrompt: getBloodPressureSystemPrompt(locale),
|
||||
userPrompt: getBloodPressureUserPrompt(
|
||||
snapshotJson,
|
||||
todayKey,
|
||||
locale,
|
||||
context.previousContextBlock,
|
||||
context.assessmentContextBlock,
|
||||
context.openerHint,
|
||||
),
|
||||
}),
|
||||
detail: undefined,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Weight and the two BP channels, read once and bucketed per user-local day.
|
||||
*/
|
||||
async function readBpChannels(userId: string, userTz: string) {
|
||||
// v1.4.28 — cap the snapshot input. The downstream
|
||||
// `applyPayloadBudget` trims further; this `take` keeps the read
|
||||
// bounded for Apple-Health-rich accounts. BP captures three types
|
||||
@@ -291,10 +377,23 @@ export async function prepareBloodPressureStatusForUser(
|
||||
{ now, tz: userTz },
|
||||
);
|
||||
|
||||
const bpTargets = getBpTargets(user?.dateOfBirth ?? null);
|
||||
return { measurements, now, weightSeries, sysSeries, diaSeries };
|
||||
}
|
||||
|
||||
/**
|
||||
* Systolic paired with diastolic per day, each day judged against the
|
||||
* person's targets, and the share of paired days in target.
|
||||
*/
|
||||
function pairBloodPressure(
|
||||
sysDaily: DailyBucket[],
|
||||
diaDaily: DailyBucket[],
|
||||
bpTargets: ReturnType<typeof getBpTargets>,
|
||||
now: Date,
|
||||
userTz: string,
|
||||
) {
|
||||
const pairedBloodPressure = pairDailyBuckets(
|
||||
sysSeries.daily,
|
||||
diaSeries.daily,
|
||||
sysDaily,
|
||||
diaDaily,
|
||||
now,
|
||||
userTz,
|
||||
).map((entry) => ({
|
||||
@@ -320,16 +419,58 @@ export async function prepareBloodPressureStatusForUser(
|
||||
1,
|
||||
);
|
||||
|
||||
const weightVsSystolicPairs = pairDailyBuckets(
|
||||
weightSeries.daily,
|
||||
sysSeries.daily,
|
||||
now,
|
||||
userTz,
|
||||
);
|
||||
const weightVsSystolicCorrelation = significantPearsonCorrelation(
|
||||
weightVsSystolicPairs,
|
||||
);
|
||||
return { pairedBloodPressure, bpInTargetPctLast30DailyPoints };
|
||||
}
|
||||
|
||||
/** The mood context: daily buckets, their mean, and the graded series. */
|
||||
async function readBpMoodContext(userId: string, now: Date, userTz: string) {
|
||||
// Fetch mood context (optional — for enrichment only). v1.4.28
|
||||
// Cap at 90 entries (~3 months) so power users don't pull
|
||||
// unbounded rows before the bucket-series budget runs.
|
||||
const moodEntries = await readRecentMoodScores(userId, 90);
|
||||
|
||||
const moodSeries = applyPayloadBudget(
|
||||
moodEntries.map((entry) => ({
|
||||
measuredAt: entry.moodLoggedAt,
|
||||
value: entry.score,
|
||||
})),
|
||||
{ now, tz: userTz },
|
||||
);
|
||||
const moodSummary = summarizeSeries(
|
||||
moodSeries.daily.map((bucket) => ({ value: bucket.value })),
|
||||
);
|
||||
const moodMean = moodSummary?.mean ?? null;
|
||||
|
||||
// Compact graded series for the prompt. The `*Series.daily` buckets
|
||||
// above stay for the correlation pairing + in-target gate; only the
|
||||
// graded shape is embedded so the full daily arrays never ship.
|
||||
const gradedFromDaily = (buckets: DailyBucket[]) =>
|
||||
buildGradedSeriesFromPoints(
|
||||
buckets.map((b) => ({
|
||||
measuredAt: canonicalDailyTimestamp(
|
||||
dayOffsetToDayKey(now, b.dayOffset, userTz),
|
||||
userTz,
|
||||
),
|
||||
value: b.value,
|
||||
})),
|
||||
now,
|
||||
userTz,
|
||||
);
|
||||
const moodGraded = gradedFromDaily(moodSeries.daily);
|
||||
return { moodSeries, moodMean, moodGraded };
|
||||
}
|
||||
|
||||
/**
|
||||
* The active, scheduled BP medications with their 7- and 30-day compliance,
|
||||
* and the per-day intake continuity against the systolic series with its
|
||||
* significance-gated correlation.
|
||||
*/
|
||||
async function readBpMedicationContext(
|
||||
userId: string,
|
||||
now: Date,
|
||||
userTz: string,
|
||||
sysDaily: DailyBucket[],
|
||||
) {
|
||||
const activeMedicationRows = await prisma.medication.findMany({
|
||||
// v1.16.11 — as-needed (PRN) medications never feed the BP-status
|
||||
// compliance gate (no expected doses, no rate).
|
||||
@@ -440,7 +581,7 @@ export async function prepareBloodPressureStatusForUser(
|
||||
takenByDay.set(dayKey, (takenByDay.get(dayKey) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const continuityVsSystolicSeries = sysSeries.daily.map((point) => {
|
||||
const continuityVsSystolicSeries = sysDaily.map((point) => {
|
||||
// DST-safe: dayOffsetToDayKey computes calendar days, not 24h ticks.
|
||||
const dayKey = dayOffsetToDayKey(now, point.dayOffset, userTz);
|
||||
const taken = takenByDay.get(dayKey) ?? 0;
|
||||
@@ -456,48 +597,6 @@ export async function prepareBloodPressureStatusForUser(
|
||||
};
|
||||
});
|
||||
|
||||
// Fetch mood context (optional — for enrichment only). v1.4.28
|
||||
// Cap at 90 entries (~3 months) so power users don't pull
|
||||
// unbounded rows before the bucket-series budget runs.
|
||||
const moodEntries = await readRecentMoodScores(userId, 90);
|
||||
|
||||
const moodSeries = applyPayloadBudget(
|
||||
moodEntries.map((entry) => ({
|
||||
measuredAt: entry.moodLoggedAt,
|
||||
value: entry.score,
|
||||
})),
|
||||
{ now, tz: userTz },
|
||||
);
|
||||
const moodSummary = summarizeSeries(
|
||||
moodSeries.daily.map((bucket) => ({ value: bucket.value })),
|
||||
);
|
||||
const moodMean = moodSummary?.mean ?? null;
|
||||
|
||||
// Compact graded series for the prompt. The `*Series.daily` buckets
|
||||
// above stay for the correlation pairing + in-target gate; only the
|
||||
// graded shape is embedded so the full daily arrays never ship.
|
||||
const gradedFromDaily = (buckets: DailyBucket[]) =>
|
||||
buildGradedSeriesFromPoints(
|
||||
buckets.map((b) => ({
|
||||
measuredAt: canonicalDailyTimestamp(
|
||||
dayOffsetToDayKey(now, b.dayOffset, userTz),
|
||||
userTz,
|
||||
),
|
||||
value: b.value,
|
||||
})),
|
||||
now,
|
||||
userTz,
|
||||
);
|
||||
// The two BP channels source their recent / weekly slices from a
|
||||
// bounded raw read and their monthly / yearly tail from the MONTH /
|
||||
// YEAR rollup tier (full-history in-memory fallback on a cold-tier
|
||||
// coverage miss). Mood has no rollup tier, so it stays an in-memory fold.
|
||||
const [sysGraded, diaGraded] = await Promise.all([
|
||||
buildGradedSeriesWithRollups(userId, "BLOOD_PRESSURE_SYS", now, userTz),
|
||||
buildGradedSeriesWithRollups(userId, "BLOOD_PRESSURE_DIA", now, userTz),
|
||||
]);
|
||||
const moodGraded = gradedFromDaily(moodSeries.daily);
|
||||
|
||||
const continuityVsSystolicPairs: PairedPoint[] = continuityVsSystolicSeries
|
||||
.map((entry) => {
|
||||
if (entry.continuityPct == null) return null;
|
||||
@@ -514,281 +613,22 @@ export async function prepareBloodPressureStatusForUser(
|
||||
continuityVsSystolicPairs,
|
||||
);
|
||||
|
||||
const oldestMeasurement =
|
||||
measurements.length > 0 ? measurements[0].measuredAt : null;
|
||||
const newestMeasurement =
|
||||
measurements.length > 0
|
||||
? measurements[measurements.length - 1].measuredAt
|
||||
: null;
|
||||
const totalSpanDays =
|
||||
oldestMeasurement && newestMeasurement
|
||||
? Math.round(
|
||||
(newestMeasurement.getTime() - oldestMeasurement.getTime()) /
|
||||
(24 * 60 * 60 * 1000),
|
||||
)
|
||||
: 0;
|
||||
const newestMeasurementDaysAgo = newestMeasurement
|
||||
? Math.round(
|
||||
(Date.now() - newestMeasurement.getTime()) / (24 * 60 * 60 * 1000),
|
||||
)
|
||||
: null;
|
||||
|
||||
// v1.18.10 — hand the model the finished recent-vs-baseline
|
||||
// comparison + normal-swing verdict per channel instead of asking it to
|
||||
// derive them. BP needs two signals; the user's own targets are the coarse
|
||||
// band anchor when present.
|
||||
const sysSignal = buildMetricSignal({
|
||||
metric: locale === "en" ? "your systolic" : "deine Systole",
|
||||
unit: "mmHg",
|
||||
direction: "lower-better",
|
||||
graded: sysGraded,
|
||||
normalRange: bpTargets
|
||||
? { low: bpTargets.sysLow, high: bpTargets.sysHigh }
|
||||
: null,
|
||||
newestDaysAgo: newestMeasurementDaysAgo,
|
||||
});
|
||||
const diaSignal = buildMetricSignal({
|
||||
metric: locale === "en" ? "your diastolic" : "deine Diastole",
|
||||
unit: "mmHg",
|
||||
direction: "lower-better",
|
||||
graded: diaGraded,
|
||||
normalRange: bpTargets
|
||||
? { low: bpTargets.diaLow, high: bpTargets.diaHigh }
|
||||
: null,
|
||||
newestDaysAgo: newestMeasurementDaysAgo,
|
||||
});
|
||||
|
||||
const snapshot = {
|
||||
locale,
|
||||
generatedForDay: todayKey,
|
||||
focus: "blood_pressure",
|
||||
dataCoverage: {
|
||||
totalMeasurements: measurements.length,
|
||||
totalSpanDays,
|
||||
newestMeasurementDaysAgo,
|
||||
},
|
||||
bloodPressure: {
|
||||
systolic: {
|
||||
...(sysSignal ? { signal: sysSignal } : {}),
|
||||
summary: summarizeSeries(
|
||||
sysSeries.daily.map((bucket) => ({ value: bucket.value })),
|
||||
),
|
||||
series: sysGraded,
|
||||
},
|
||||
diastolic: {
|
||||
...(diaSignal ? { signal: diaSignal } : {}),
|
||||
summary: summarizeSeries(
|
||||
diaSeries.daily.map((bucket) => ({ value: bucket.value })),
|
||||
),
|
||||
series: diaGraded,
|
||||
},
|
||||
paired: {
|
||||
summary: summarizeSeries(
|
||||
pairedBloodPressure.map((entry) => ({
|
||||
value: (entry.sys + entry.dia) / 2,
|
||||
})),
|
||||
),
|
||||
series: pairedBloodPressure.slice(-CORRELATION_PAIR_CAP),
|
||||
},
|
||||
targets: bpTargets
|
||||
? {
|
||||
systolic: { min: bpTargets.sysLow, max: bpTargets.sysHigh },
|
||||
diastolic: { min: bpTargets.diaLow, max: bpTargets.diaHigh },
|
||||
inTargetPctLast30DailyPoints: bpInTargetPctLast30DailyPoints,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
weightVsSystolic: {
|
||||
correlation: weightVsSystolicCorrelation,
|
||||
pairs: weightVsSystolicPairs
|
||||
.slice(-CORRELATION_PAIR_CAP)
|
||||
.map((entry) => ({
|
||||
day: entry.dayKey,
|
||||
weight: round(entry.a, 2),
|
||||
systolic: round(entry.b, 2),
|
||||
})),
|
||||
},
|
||||
bpMedicationContinuityVsSystolic: {
|
||||
expectedIntakesPerDay: expectedBpIntakesPerDay,
|
||||
medicationCount: bpMedications.length,
|
||||
correlation: continuityVsSystolicCorrelation,
|
||||
series: continuityVsSystolicSeries.slice(-CORRELATION_PAIR_CAP),
|
||||
},
|
||||
bpMedications: medicationCompliance,
|
||||
moodContext:
|
||||
moodSeries.daily.length >= 3
|
||||
? {
|
||||
points: moodSeries.daily.length,
|
||||
mean: moodMean,
|
||||
latest: moodSeries.daily[0]?.value ?? null,
|
||||
series: moodGraded,
|
||||
moodVsSystolicCorrelation: (() => {
|
||||
const moodVsSysPairs = pairDailyBuckets(
|
||||
moodSeries.daily,
|
||||
sysSeries.daily,
|
||||
now,
|
||||
userTz,
|
||||
);
|
||||
return significantPearsonCorrelation(moodVsSysPairs);
|
||||
})(),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
|
||||
const shed = degradeStatusSnapshotToBudget(
|
||||
snapshot as unknown as Record<string, unknown>,
|
||||
{ maxChars: await statusSnapshotCharsForRecord(userId) },
|
||||
);
|
||||
const snapshotJson = JSON.stringify(snapshot);
|
||||
|
||||
annotate({
|
||||
action: { name: cacheAction },
|
||||
meta: {
|
||||
payload_size_bytes: snapshotJson.length,
|
||||
...(shed.length > 0 ? { snapshot_shed: shed } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
// Content-hash gate (v1.16.8): when the snapshot is unchanged since the
|
||||
// last real assessment, refresh the cache timestamp and skip the LLM.
|
||||
const snapshotHash = hashInsightSnapshot(snapshot);
|
||||
const unchanged = await refreshUnchangedStatusInsight({
|
||||
userId,
|
||||
cacheAction,
|
||||
todayKey,
|
||||
snapshotHash,
|
||||
});
|
||||
if (unchanged) {
|
||||
return {
|
||||
phase: "served",
|
||||
result: {
|
||||
hasProvider: true,
|
||||
text: unchanged.text,
|
||||
cached: true,
|
||||
updatedAt: unchanged.updatedAt,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const previousContext = await getPreviousInsightContext(
|
||||
userId,
|
||||
"blood-pressure-status",
|
||||
locale,
|
||||
12,
|
||||
);
|
||||
const previousContextBlock = formatPreviousContextForPrompt(
|
||||
previousContext,
|
||||
locale,
|
||||
);
|
||||
|
||||
// v1.12.7 — same diversity / anti-repetition block the archetype cards
|
||||
// carry, now threaded into this headline card. Pure formatting of
|
||||
// already-computed data; the correlation fetch is best-effort and resolves
|
||||
// to no block on failure, never a generation failure.
|
||||
const varietyLead = pickVarietyLead(userId, "blood-pressure", todayKey);
|
||||
const steadyRun = computeSteadyRun(sysGraded.weekly, sysGraded.monthly);
|
||||
const relations = await getRelevantCorrelationsForMetric(
|
||||
userId,
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
locale,
|
||||
);
|
||||
const assessmentContextBlock = buildAssessmentContextBlock(
|
||||
{
|
||||
varietyLead,
|
||||
dataStrength: {
|
||||
points: sysSeries.daily.length,
|
||||
newestDaysAgo: newestMeasurementDaysAgo,
|
||||
},
|
||||
repeatCount: steadyRun,
|
||||
relations,
|
||||
},
|
||||
locale,
|
||||
);
|
||||
|
||||
// The provider call is deferred to the caller — single-card path runs ONE
|
||||
// completion; the batch path folds this prompt into one shared call.
|
||||
return {
|
||||
phase: "pending",
|
||||
metric: "blood-pressure",
|
||||
userId,
|
||||
cacheAction,
|
||||
systemPrompt: getBloodPressureSystemPrompt(locale),
|
||||
userPrompt: getBloodPressureUserPrompt(
|
||||
snapshotJson,
|
||||
todayKey,
|
||||
locale,
|
||||
previousContextBlock,
|
||||
assessmentContextBlock,
|
||||
// v1.28.40 — rotating opener hint, per (user, metric, day); activates the
|
||||
// base prompt's verdict-first opener branch on the per-metric card.
|
||||
openerArchetypeHint(
|
||||
`${userId}:blood-pressure:${todayKey}`,
|
||||
locale as Locale,
|
||||
),
|
||||
),
|
||||
snapshotHash,
|
||||
// v1.12.7 — match the archetype cards' 0.45: more cadence entropy while
|
||||
// FACTS stay pinned by the snapshot + the forbidden-phrase guards.
|
||||
temperature: 0.45,
|
||||
noProvider: {
|
||||
hasProvider: false,
|
||||
text: getNoKeyBloodPressureStatusText(locale, sysSignal),
|
||||
cached: true,
|
||||
updatedAt: null,
|
||||
},
|
||||
timeout: (reason): StatusCardResult =>
|
||||
returnTimeoutFallback({
|
||||
cacheAction,
|
||||
reason,
|
||||
userId,
|
||||
todayKey,
|
||||
stubText: getNoKeyBloodPressureStatusText(locale, sysSignal),
|
||||
}),
|
||||
finalize: async (outcome): Promise<StatusCardResult> => {
|
||||
// Outbound safety screen. The only transform here used to be
|
||||
// whitespace-normalisation, so a dose-change imperative or a fabricated
|
||||
// clinical risk score persisted as the day's cached assessment. Policy
|
||||
// for a background-generated card is WITHHOLD: serve the deterministic
|
||||
// stub and persist no model text (see `finalizeStatusSummary`).
|
||||
const screened = finalizeStatusSummary(outcome.content, locale);
|
||||
if (!screened.ok) {
|
||||
annotate({
|
||||
action: { name: "insights.status.outbound_blocked" },
|
||||
meta: { cacheAction, reason: screened.reason },
|
||||
});
|
||||
return returnTimeoutFallback({
|
||||
cacheAction,
|
||||
reason: "screened",
|
||||
userId: userId,
|
||||
todayKey: todayKey,
|
||||
stubText: getNoKeyBloodPressureStatusText(locale, sysSignal),
|
||||
});
|
||||
}
|
||||
const summary = screened.text;
|
||||
if (!summary) {
|
||||
throw new Error(
|
||||
"Blood-pressure-status summary was empty after normalization",
|
||||
);
|
||||
}
|
||||
const updatedAt = await persistStatusInsight({
|
||||
userId,
|
||||
cacheAction,
|
||||
todayKey,
|
||||
text: summary,
|
||||
snapshotHash,
|
||||
});
|
||||
return {
|
||||
hasProvider: true,
|
||||
text: summary,
|
||||
cached: false,
|
||||
updatedAt,
|
||||
};
|
||||
},
|
||||
medicationCompliance,
|
||||
expectedIntakesPerDay: expectedBpIntakesPerDay,
|
||||
medicationCount: bpMedications.length,
|
||||
continuityVsSystolicSeries,
|
||||
continuityVsSystolicCorrelation,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveBloodPressureStatusLocale(
|
||||
locale: string | null | undefined,
|
||||
): SupportedLocale {
|
||||
return normalizeLocale(locale);
|
||||
}
|
||||
const BLOOD_PRESSURE_STATUS_CARD: StatusCardPlugin<
|
||||
StatusTextResult,
|
||||
undefined
|
||||
> = {
|
||||
scope: "blood-pressure",
|
||||
codec: textStatusCodec,
|
||||
unavailableText: (locale) => getNoKeyBloodPressureStatusText(locale),
|
||||
emptyMessage: "Blood-pressure-status summary was empty after normalization",
|
||||
build: buildBloodPressureSnapshot,
|
||||
};
|
||||
Reference in new issue
Block a user