From 87a35b670dec539aa42144bb84fd584bc63a438e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 11:08:35 +0200 Subject: [PATCH] refactor(insights): reduce the blood-pressure card to its snapshot --- .../__tests__/route.test.ts | 1 - .../insights/blood-pressure-status/route.ts | 6 +- src/lib/insights/blood-pressure-status.ts | 752 +++++++----------- 3 files changed, 298 insertions(+), 461 deletions(-) diff --git a/src/app/api/insights/blood-pressure-status/__tests__/route.test.ts b/src/app/api/insights/blood-pressure-status/__tests__/route.test.ts index 748572207..6bec84c6f 100644 --- a/src/app/api/insights/blood-pressure-status/__tests__/route.test.ts +++ b/src/app/api/insights/blood-pressure-status/__tests__/route.test.ts @@ -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 diff --git a/src/app/api/insights/blood-pressure-status/route.ts b/src/app/api/insights/blood-pressure-status/route.ts index 9a0cec204..2671d62fd 100644 --- a/src/app/api/insights/blood-pressure-status/route.ts +++ b/src/app/api/insights/blood-pressure-status/route.ts @@ -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"; diff --git a/src/lib/insights/blood-pressure-status.ts b/src/lib/insights/blood-pressure-status.ts index 2063bbed6..f5e5d80bd 100644 --- a/src/lib/insights/blood-pressure-status.ts +++ b/src/lib/insights/blood-pressure-status.ts @@ -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 { 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 { - 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> { + 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, + 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, - { 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 => { - // 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, +};