Merge branch 'feat/v146-server-status' into feat/v146-server

This commit is contained in:
Marc-André Bombeck committed 2026-10-23 11:47:13 +02:00
commit 05b9697848
42 files changed
+23121 -3472

No files matched your search

@@ -55,6 +55,7 @@ const ADMITTED: Record<string, { jobs: string[]; why: string }> = {
const NEVER = [
"lib/insights/status-batch.ts",
"lib/insights/status-card-generation.ts",
"lib/insights/status-pipeline.ts",
"lib/insights/metric-status.ts",
"lib/insights/biomarker-status.ts",
"lib/insights/derived/derived-assessment-ai.ts",
@@ -128,7 +128,7 @@ const TS_READERS: Record<string, Entry> = {
/applyPayloadBudget\(\s*pulsePoints\s*,\s*\{[^}]*type\s*:\s*"PULSE"/,
},
"lib/insights/signals-of-day.ts": {
via: "lib/insights/features.ts",
via: "lib/insights/feature-window.ts",
passes: /byType\(\s*"PULSE"\s*\)[\s\S]{0,80}type\s*:\s*"PULSE"/,
},
"lib/ai/coach/snapshot-blocks/core-metrics-block.ts": {
@@ -54,10 +54,6 @@ vi.mock("@/lib/insights/biomarker-status", () => ({
})),
}));
vi.mock("@/lib/insights/metric-status", () => ({
resolveMetricStatusLocale: () => "en",
}));
import { GET } from "../route";
import { getSession } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
@@ -28,7 +28,7 @@ import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
import { unavailableStatusBody } from "@/lib/insights/status-unavailable";
import { generateBiomarkerStatus } from "@/lib/insights/biomarker-status";
import { resolveMetricStatusLocale } from "@/lib/insights/metric-status";
import { normalizeLocale as resolveMetricStatusLocale } from "@/lib/insights/status-shared";
export const dynamic = "force-dynamic";
@@ -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";
+2 -4
View File
@@ -1,9 +1,7 @@
import { NextRequest } from "next/server";
import { apiSuccess } from "@/lib/api-response";
import {
generateBmiStatusForUser,
resolveBmiStatusLocale,
} from "@/lib/insights/bmi-status";
import { generateBmiStatusForUser } from "@/lib/insights/bmi-status";
import { normalizeLocale as resolveBmiStatusLocale } 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";
@@ -1,9 +1,7 @@
import { NextRequest } from "next/server";
import { apiSuccess } from "@/lib/api-response";
import {
generateMedicationComplianceStatusForUser,
resolveMedicationComplianceStatusLocale,
} from "@/lib/insights/medication-compliance-status";
import { generateMedicationComplianceStatusForUser } from "@/lib/insights/medication-compliance-status";
import { normalizeLocale as resolveMedicationComplianceStatusLocale } 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";
@@ -54,7 +54,6 @@ vi.mock("@/lib/insights/metric-status", () => ({
cached: true,
updatedAt: new Date().toISOString(),
})),
resolveMetricStatusLocale: () => "en",
}));
import { GET } from "../route";
+2 -4
View File
@@ -33,10 +33,8 @@ import { requireModuleEnabled, type ModuleKey } from "@/lib/modules/gate";
import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
import { unavailableStatusBody } from "@/lib/insights/status-unavailable";
import { prisma } from "@/lib/db";
import {
generateMetricStatus,
resolveMetricStatusLocale,
} from "@/lib/insights/metric-status";
import { generateMetricStatus } from "@/lib/insights/metric-status";
import { normalizeLocale as resolveMetricStatusLocale } from "@/lib/insights/status-shared";
import {
METRIC_STATUS_IDS,
getMetricStatusMeta,
@@ -44,7 +44,6 @@ vi.mock("@/lib/insights/mood-status", () => ({
cached: true,
updatedAt: new Date().toISOString(),
})),
resolveMoodStatusLocale: () => "en",
}));
import { GET } from "../route";
+2 -4
View File
@@ -1,9 +1,7 @@
import { NextRequest } from "next/server";
import { apiSuccess } from "@/lib/api-response";
import {
generateMoodStatusForUser,
resolveMoodStatusLocale,
} from "@/lib/insights/mood-status";
import { generateMoodStatusForUser } from "@/lib/insights/mood-status";
import { normalizeLocale as resolveMoodStatusLocale } 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";
+2 -4
View File
@@ -1,9 +1,7 @@
import { NextRequest } from "next/server";
import { apiSuccess } from "@/lib/api-response";
import {
generatePulseStatusForUser,
resolvePulseStatusLocale,
} from "@/lib/insights/pulse-status";
import { generatePulseStatusForUser } from "@/lib/insights/pulse-status";
import { normalizeLocale as resolvePulseStatusLocale } 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";
+2 -4
View File
@@ -1,9 +1,7 @@
import { NextRequest } from "next/server";
import { apiSuccess } from "@/lib/api-response";
import {
generateWeightStatusForUser,
resolveWeightStatusLocale,
} from "@/lib/insights/weight-status";
import { generateWeightStatusForUser } from "@/lib/insights/weight-status";
import { normalizeLocale as resolveWeightStatusLocale } 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";
+118 -158
View File
@@ -3,12 +3,12 @@
*
* Mirrors `generateMetricStatus` (the generic HealthKit-metric generator)
* but reads `LabResult` rows for one user-scoped `Biomarker` instead of
* `Measurement` rows for a `MeasurementType`. It reuses the exact same
* machinery the metric cards use:
* - the shared provider runner (`runStatusCompletion`),
* - the stale-while-revalidate read-only path (`resolveReadOnlyStatusMiss`),
* - the standard cache-row persist (`persistStatusInsight`),
* - the cheap INPUT gate (`gateUnchangedStatusInput`) keyed on the latest
* `Measurement` rows for a `MeasurementType`. It runs on the same status
* pipeline stages (`status-pipeline.ts`) the metric cards use:
* - the single-card provider run (`runPreparedStatusCard`),
* - the stale-while-revalidate read-only path (`resolveReadOnlyStatus`),
* - the standard note persist (`textStatusCodec`),
* - the cheap INPUT gate (`gateUnchangedInputs`) keyed on the latest
* reading fingerprint so a fresh assessment is produced ONLY when a new
* reading lands — an idle day re-stamps the cached text without an LLM
* round-trip.
@@ -35,25 +35,25 @@ import {
} from "@/lib/labs/reference-range";
import { getNoKeyBiomarkerStatusText } from "@/lib/insights/no-key-fallbacks";
import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash";
import { runStatusCompletion } from "@/lib/insights/status-provider";
import { summarizeSeries } from "@/lib/insights/status-shared";
import {
normalizeLocale,
finalizeStatusSummary,
persistStatusInsight,
summarizeSeries,
} from "@/lib/insights/status-shared";
runPreparedStatusCard,
type PreparedStatusCard,
} from "@/lib/insights/status-card-generation";
import {
gateUnchangedStatusInput,
readFreshStatusText,
resolveReadOnlyStatusMiss,
statusCacheAction,
} from "@/lib/insights/status-cache";
import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback";
gateUnchangedInputs,
openStatusCard,
pendingStatusCard,
readCachedStatus,
resolveReadOnlyStatus,
textStatusCodec,
type StatusCardContext,
} from "@/lib/insights/status-pipeline";
import { annotate } from "@/lib/logging/context";
import { resolveUserTimezone, userDayKey } from "@/lib/tz/resolver";
import { userDayKey } from "@/lib/tz/resolver";
import { sanitizeForPrompt } from "@/lib/insights/sanitize";
export interface BiomarkerStatusResult {
export type BiomarkerStatusResult = {
hasProvider: boolean;
text: string | null;
cached: boolean;
@@ -63,7 +63,7 @@ export interface BiomarkerStatusResult {
revalidating?: boolean;
/** True when the marker has no numeric readings; no LLM was called. */
insufficient?: boolean;
}
};
/** The `biomarker:<id>` cache/queue scope for one marker. */
export function biomarkerStatusScope(
@@ -91,6 +91,14 @@ function insufficient(): BiomarkerStatusResult {
};
}
interface BiomarkerMarker {
id: string;
name: string;
unit: string;
lowerBound: number | null;
upperBound: number | null;
}
/**
* Generate (or read from cache) the assessment for one biomarker. Mirrors
* the metric generator's contract:
@@ -123,39 +131,39 @@ export async function generateBiomarkerStatus(args: {
return insufficient();
}
const locale = normalizeLocale(args.locale);
const force = args.force === true;
const readOnly = args.readOnly === true;
const scope = biomarkerStatusScope(marker.id);
const cacheAction = statusCacheAction(scope, 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).
const userTz = await resolveUserTimezone(args.userId);
const todayKey = userDayKey(new Date(), userTz);
const prepared = await prepareBiomarkerStatus(marker, args);
return (await runPreparedStatusCard(prepared)) as BiomarkerStatusResult;
}
const cached = await readFreshStatusText({
userId: args.userId,
cacheAction,
todayKey,
force,
});
if (cached) {
return {
hasProvider: true,
text: cached.text,
cached: true,
updatedAt: cached.updatedAt,
};
}
/**
* Everything up to (not including) the provider call, on the shared
* pipeline stages, with the empty-data gate between the cache read and the
* read-only path and the input gate in place of the snapshot gate.
*/
async function prepareBiomarkerStatus(
marker: BiomarkerMarker,
args: {
userId: string;
locale?: string | null;
force?: boolean;
readOnly?: boolean;
},
): Promise<PreparedStatusCard> {
const ctx = await openStatusCard(
args.userId,
biomarkerStatusScope(marker.id),
args,
);
const cached = await readCachedStatus(ctx, textStatusCodec);
if (cached) return { phase: "served", result: cached };
// Numeric readings only — a qualitative-only marker carries no range verdict
// or trend, so there is nothing to assess. Done BEFORE any enqueue so an
// empty marker never queues an LLM job nor blocks on the provider.
const rows = await prisma.labResult.findMany({
where: {
userId: args.userId,
userId: ctx.userId,
biomarkerId: marker.id,
deletedAt: null,
value: { not: null },
@@ -180,7 +188,7 @@ export async function generateBiomarkerStatus(args: {
action: { name: "insights.biomarker-status.insufficient" },
meta: { biomarkerId: marker.id },
});
return insufficient();
return { phase: "served", result: insufficient() };
}
const readings: BiomarkerReading[] = rows.map((r) => ({
@@ -199,34 +207,21 @@ export async function generateBiomarkerStatus(args: {
);
const totalCount = await prisma.labResult.count({
where: {
userId: args.userId,
userId: ctx.userId,
biomarkerId: marker.id,
deletedAt: null,
value: { not: null },
},
});
if (readOnly) {
const outcome = await resolveReadOnlyStatusMiss({
userId: args.userId,
metric: scope,
locale,
});
if (outcome.kind === "unavailable") {
return {
hasProvider: outcome.hasProvider,
text: getNoKeyBiomarkerStatusText(locale, marker.name),
cached: true,
updatedAt: null,
};
}
if (ctx.readOnly) {
return {
hasProvider: true,
text: outcome.lastGood?.text ?? null,
cached: outcome.lastGood !== null,
updatedAt: outcome.lastGood?.updatedAt ?? null,
preparing: outcome.lastGood === null,
revalidating: outcome.revalidating,
phase: "served",
result: await resolveReadOnlyStatus(
ctx,
textStatusCodec,
getNoKeyBiomarkerStatusText(ctx.locale, marker.name),
),
};
}
@@ -245,8 +240,8 @@ export async function generateBiomarkerStatus(args: {
// letting the input gate run keeps an idle marker to one cheap re-stamp per
// day instead of a daily regeneration.
const inputHash = hashInsightSnapshot({
scope,
locale,
scope: ctx.scope,
locale: ctx.locale,
promptVersion: PROMPT_VERSION,
count: totalCount,
bounds: { lower: latestRange.low, upper: latestRange.high },
@@ -256,22 +251,58 @@ export async function generateBiomarkerStatus(args: {
value: latest.value,
},
});
const unchanged = await gateUnchangedStatusInput({
userId: args.userId,
cacheAction,
todayKey,
inputHash,
force: false,
});
if (unchanged) {
return {
hasProvider: true,
text: unchanged.text,
cached: true,
updatedAt: unchanged.updatedAt,
};
}
const unchanged = await gateUnchangedInputs(ctx, inputHash, false);
if (unchanged) return { phase: "served", result: unchanged };
const { snapshotJson, safeMarkerName } = biomarkerSnapshot({
ctx,
marker,
readings,
latestRange,
totalCount,
});
annotate({
action: { name: ctx.cacheAction },
meta: { payload_size_bytes: snapshotJson.length },
});
return pendingStatusCard(ctx, textStatusCodec, {
systemPrompt: getBiomarkerSystemPrompt(
safeMarkerName,
ctx.locale as Locale,
),
userPrompt: getBiomarkerUserPrompt(
snapshotJson,
ctx.todayKey,
ctx.locale as Locale,
// The rotating opener hint the sibling cards carry, keyed per
// (user, marker, day) so consecutive markers do not all open alike.
openerArchetypeHint(
`${ctx.userId}:biomarker:${marker.id}:${ctx.todayKey}`,
ctx.locale as Locale,
),
),
hashes: { inputHash },
temperature: AI_BUDGETS.statusArchetype.temperature,
maxTokens: AI_BUDGETS.statusArchetype.maxTokens,
fallbackText: getNoKeyBiomarkerStatusText(ctx.locale, marker.name),
emptyMessage: `Biomarker-status summary was empty for ${marker.id}`,
detail: undefined,
});
}
/** The marker snapshot: the range, the latest verdict and the series. */
function biomarkerSnapshot(args: {
ctx: StatusCardContext;
marker: BiomarkerMarker;
readings: BiomarkerReading[];
latestRange: ReturnType<typeof resolveEffectiveReferenceRange>;
totalCount: number;
}): { snapshotJson: string; safeMarkerName: string } {
const { marker, readings, latestRange, totalCount } = args;
const { locale, todayKey, userTz } = args.ctx;
const latest = readings[0];
// Ascending series (oldest → newest) for the trend summary the prompt reads.
const ascending = [...readings].reverse();
const summary = summarizeSeries(ascending.map((r) => ({ value: r.value })));
@@ -317,76 +348,5 @@ export async function generateBiomarkerStatus(args: {
};
const snapshotJson = JSON.stringify(snapshot);
annotate({
action: { name: cacheAction },
meta: { payload_size_bytes: snapshotJson.length },
});
const outcome = await runStatusCompletion({
userId: args.userId,
cacheAction,
capability: "statusText",
systemPrompt: getBiomarkerSystemPrompt(safeMarkerName, locale as Locale),
userPrompt: getBiomarkerUserPrompt(
snapshotJson,
todayKey,
locale as Locale,
// The rotating opener hint the sibling cards carry, keyed per
// (user, marker, day) so consecutive markers do not all open alike.
openerArchetypeHint(
`${args.userId}:biomarker:${marker.id}:${todayKey}`,
locale as Locale,
),
),
temperature: AI_BUDGETS.statusArchetype.temperature,
maxTokens: AI_BUDGETS.statusArchetype.maxTokens,
});
if (outcome.kind === "none") {
return {
hasProvider: false,
text: getNoKeyBiomarkerStatusText(locale, marker.name),
cached: true,
updatedAt: null,
};
}
if (outcome.kind === "timeout" || outcome.kind === "error") {
return returnTimeoutFallback({
cacheAction,
reason: outcome.kind,
userId: args.userId,
todayKey,
stubText: getNoKeyBiomarkerStatusText(locale, marker.name),
});
}
// Outbound safety screen — WITHHOLD policy for a background-generated card.
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: args.userId,
todayKey,
stubText: getNoKeyBiomarkerStatusText(locale, marker.name),
});
}
const text = screened.text;
if (!text) {
throw new Error(`Biomarker-status summary was empty for ${marker.id}`);
}
const updatedAt = await persistStatusInsight({
userId: args.userId,
cacheAction,
todayKey,
text,
inputHash,
});
return { hasProvider: true, text, cached: false, updatedAt };
return { snapshotJson, safeMarkerName };
}
+296 -456
View File
@@ -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,
};
+70 -311
View File
@@ -1,51 +1,32 @@
import { prisma } from "@/lib/db";
import { getBmiSystemPrompt, getBmiUserPrompt } from "@/lib/ai/prompts/bmi";
import { openerArchetypeHint } from "@/lib/ai/prompts/opener-archetype";
import type { Locale } from "@/lib/i18n/config";
import { classifyBMI } from "@/lib/analytics/classifications";
import { getNoKeyBmiStatusText } from "@/lib/insights/no-key-fallbacks";
import {
formatPreviousContextForPrompt,
getPreviousInsightContext,
} from "@/lib/insights/memory";
import {
buildAssessmentContextBlock,
computeSteadyRun,
pickVarietyLead,
} from "@/lib/insights/assessment-context";
import { computeSteadyRun } from "@/lib/insights/assessment-context";
import { applyPayloadBudget } from "@/lib/insights/bucket-series";
import {
buildGradedSeriesWithRollups,
scaleGradedSeries,
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 {
computeStatusInputFingerprint,
gateUnchangedStatusInput,
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 { computeStatusInputFingerprint } from "@/lib/insights/status-cache";
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,
gateUnchangedInputs,
prepareStatusCard,
textStatusCodec,
type ServedBuild,
type StatusCardBuild,
type StatusCardContext,
type StatusCardOptions,
type StatusCardPlugin,
type StatusTextResult,
} from "@/lib/insights/status-pipeline";
/**
* Public entry — unchanged signature. Prepares the card (cache-read,
@@ -56,112 +37,29 @@ import { resolveUserTimezone, userDayKey } from "@/lib/tz/resolver";
*/
export async function generateBmiStatusForUser(
userId: string,
options?: {
locale?: string | null;
force?: boolean;
/** v1.8.3 — read-only navigation path; see weight-status for the rationale. */
readOnly?: boolean;
},
): Promise<{
hasProvider: boolean;
text: string | null;
cached: boolean;
updatedAt: string | null;
preparing?: boolean;
/** v1.9.0 — last-good text served while a refresh is in flight; keep polling. */
revalidating?: boolean;
}> {
options?: StatusCardOptions,
): Promise<StatusTextResult> {
const prepared = await prepareBmiStatusForUser(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.
* Returns a finished `served` result for every path that never needed the
* LLM, or a `pending` descriptor the single-card path and the batch path
* both drive. See `status-card-generation.ts` for the contract.
* Everything up to (not including) the provider call, through the shared
* status pipeline (`status-pipeline.ts`); this card supplies its height
* check, its input gate and its snapshot.
*/
export async function prepareBmiStatusForUser(
export function prepareBmiStatusForUser(
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("bmi", 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: "bmi",
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: getNoKeyBmiStatusText(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(BMI_STATUS_CARD, userId, options);
}
/** The BMI snapshot: the weight series scaled by the profile height. */
async function buildBmiSnapshot(
ctx: StatusCardContext,
): Promise<StatusCardBuild<undefined> | ServedBuild<StatusTextResult>> {
const { userId, locale, userTz, todayKey } = ctx;
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
@@ -195,25 +93,9 @@ export async function prepareBmiStatusForUser(
types: ["WEIGHT"],
extra: { heightCm: user.heightCm },
});
if (!force) {
const unchangedInput = await gateUnchangedStatusInput({
userId,
cacheAction,
todayKey,
inputHash,
force,
});
if (unchangedInput) {
return {
phase: "served",
result: {
hasProvider: true,
text: unchangedInput.text,
cached: true,
updatedAt: unchangedInput.updatedAt,
},
};
}
if (!ctx.force) {
const unchangedInput = await gateUnchangedInputs(ctx, inputHash, ctx.force);
if (unchangedInput) return { phase: "served", result: unchangedInput };
}
// v1.4.28 — cap the snapshot input (weight runs at most once
@@ -270,24 +152,13 @@ export async function prepareBmiStatusForUser(
const previousBmi = bmiSeries.daily[1] ?? null;
const latestClassification = latestBmi ? classifyBMI(latestBmi.value) : null;
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;
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 instead of asking it to derive them.
@@ -333,152 +204,40 @@ export async function prepareBmiStatusForUser(
},
};
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,
"bmi-status",
locale,
12,
);
const previousContextBlock = formatPreviousContextForPrompt(
previousContext,
locale,
);
// v1.12.7 — diversity / anti-repetition block (see blood-pressure-status).
// BMI is a per-user height transform of WEIGHT (no discovery channel of its
// own), so the RELATIONS sub-block stays empty; the variety / data-strength
// / repetition signals carry the rotation.
const varietyLead = pickVarietyLead(userId, "bmi", todayKey);
const steadyRun = computeSteadyRun(bmiGraded.weekly, bmiGraded.monthly);
const assessmentContextBlock = buildAssessmentContextBlock(
{
varietyLead,
dataStrength: {
return {
snapshot,
inputHash,
finish: () => ({
// BMI is a per-user height transform of WEIGHT (no discovery channel
// of its own), so the RELATIONS sub-block stays empty; the variety /
// data-strength / repetition signals carry the rotation.
context: {
repeatCount: computeSteadyRun(bmiGraded.weekly, bmiGraded.monthly),
points: bmiSeries.daily.length,
newestDaysAgo: newestMeasurementDaysAgo,
relationsFor: null,
},
repeatCount: steadyRun,
relations: [],
},
locale,
);
// The provider call is deferred to the caller — the single-card path runs
// ONE completion, the batch path folds this prompt into one shared call.
// A timeout / error is a transient miss (fallback served, no assessment
// persisted); `none` (no provider / no consent) serves the no-key text.
return {
phase: "pending",
metric: "bmi",
userId,
cacheAction,
systemPrompt: getBmiSystemPrompt(locale),
userPrompt: getBmiUserPrompt(
snapshotJson,
todayKey,
locale,
previousContextBlock,
assessmentContextBlock,
// v1.28.40 — rotating opener hint, per (user, metric, day).
openerArchetypeHint(`${userId}:bmi:${todayKey}`, locale as Locale),
),
snapshotHash,
// v1.12.7 — match the archetype cards' 0.45.
temperature: 0.45,
noProvider: {
hasProvider: false,
text: getNoKeyBmiStatusText(locale, bmiSignal),
cached: true,
updatedAt: null,
},
timeout: (reason): StatusCardResult =>
returnTimeoutFallback({
cacheAction,
reason,
userId,
todayKey,
stubText: getNoKeyBmiStatusText(locale, bmiSignal),
fallbackText: getNoKeyBmiStatusText(locale, bmiSignal),
prompts: (snapshotJson, context) => ({
systemPrompt: getBmiSystemPrompt(locale),
userPrompt: getBmiUserPrompt(
snapshotJson,
todayKey,
locale,
context.previousContextBlock,
context.assessmentContextBlock,
context.openerHint,
),
}),
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: getNoKeyBmiStatusText(locale, bmiSignal),
});
}
const summary = screened.text;
if (!summary) {
throw new Error("Bmi-status summary was empty after normalization");
}
const updatedAt = await persistStatusInsight({
userId,
cacheAction,
todayKey,
text: summary,
snapshotHash,
// v1.18.11 — persist the input fingerprint for tomorrow's gate.
inputHash,
});
return {
hasProvider: true,
text: summary,
cached: false,
updatedAt,
};
},
detail: undefined,
}),
};
}
export function resolveBmiStatusLocale(
locale: string | null | undefined,
): SupportedLocale {
return normalizeLocale(locale);
}
const BMI_STATUS_CARD: StatusCardPlugin<StatusTextResult, undefined> = {
scope: "bmi",
codec: textStatusCodec,
unavailableText: (locale) => getNoKeyBmiStatusText(locale),
emptyMessage: "Bmi-status summary was empty after normalization",
build: buildBmiSnapshot,
};
+58
View File
@@ -0,0 +1,58 @@
/**
* The window arithmetic the feature extractor and the signals-of-day builder
* share: a reading list as trend points, a mean over a days-ago window, and
* a spread rounded to the payload's one decimal. A leaf module, so the
* signals-of-day builder does not import the extractor that re-exports it.
*/
import type { DataPoint } from "@/lib/analytics/trends";
import { readingsMean } from "@/lib/measurements/day-mean";
import { usesHourlyMeanDay } from "@/lib/measurements/day-statistic";
import { standardDeviation, type VarianceKind } from "@/lib/stats/spread";
/**
* 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 }));
}
/**
* 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;
}
+5 -53
View File
@@ -21,7 +21,6 @@ import {
} 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";
@@ -35,11 +34,12 @@ import {
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 } from "@/lib/stats/spread";
import {
READING_SPREAD,
standardDeviation,
type VarianceKind,
} from "@/lib/stats/spread";
avgInWindow,
roundedSpread,
toDataPoints,
} from "@/lib/insights/feature-window";
import {
pairByTimestamp,
significantPearsonCorrelation,
@@ -900,25 +900,6 @@ export class FeaturesPayloadTooLargeError extends Error {
}
}
/**
* 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,
@@ -948,35 +929,6 @@ function computeCoverage(
};
}
/**
* 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) ────────────
/**
+186 -373
View File
@@ -5,48 +5,28 @@ import {
getGeneralStatusSystemPrompt,
getGeneralStatusUserPrompt,
} from "@/lib/ai/prompts/general-status";
import { openerArchetypeHint } from "@/lib/ai/prompts/opener-archetype";
import type { Locale } from "@/lib/i18n/config";
import { getBpTargets } from "@/lib/analytics/bp-targets";
import { isBpReadingInTarget } from "@/lib/analytics/bp-in-target";
import { getNoKeyGeneralStatusText } from "@/lib/insights/no-key-fallbacks";
import { measurementTypeEnum } from "@/lib/validations/measurement";
import {
formatPreviousContextForPrompt,
getPreviousInsightContext,
} from "@/lib/insights/memory";
import {
buildAssessmentContextBlock,
pickVarietyLead,
} from "@/lib/insights/assessment-context";
import { applyPayloadBudget } from "@/lib/insights/bucket-series";
import {
buildGradedSeriesFromPoints,
degradeStatusSnapshotToBudget,
} from "@/lib/insights/graded-series";
import { statusSnapshotCharsForRecord } from "@/lib/ai/capabilities/usage";
import {
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 { buildGradedSeriesFromPoints } from "@/lib/insights/graded-series";
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 { TRACKED_INTAKE_EVENT_WHERE } from "@/lib/medications/intake-tracking";
// Derived from canonical enum so a new measurement type is auto-included
@@ -63,106 +43,31 @@ const MEASUREMENT_TYPES = measurementTypeEnum.options;
export async function generateGeneralStatusForUser(
userId: string,
options?: {
locale?: string | null;
force?: boolean;
/** v1.8.3 — read-only navigation path; see weight-status for the rationale. */
readOnly?: boolean;
},
): Promise<{
hasProvider: boolean;
text: string | null;
cached: boolean;
updatedAt: string | null;
preparing?: boolean;
/** v1.9.0 — last-good text served while a refresh is in flight; keep polling. */
revalidating?: boolean;
}> {
options?: StatusCardOptions,
): Promise<StatusTextResult> {
const prepared = await prepareGeneralStatusForUser(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;
}
export async function prepareGeneralStatusForUser(
/**
* Everything up to (not including) the provider call, through the shared
* status pipeline (`status-pipeline.ts`); this card supplies its snapshot.
*/
export function prepareGeneralStatusForUser(
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("general", 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: "general",
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: getNoKeyGeneralStatusText(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(GENERAL_STATUS_CARD, userId, options);
}
/**
* The overview snapshot: every measured type as a summary + graded series,
* medication adherence, the BP targets, and mood context.
*/
async function buildGeneralSnapshot(
ctx: StatusCardContext,
): Promise<StatusCardBuild<undefined>> {
const { userId, locale, userTz, todayKey } = ctx;
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
@@ -222,6 +127,114 @@ export async function prepareGeneralStatusForUser(
}),
);
const { adherenceSeries, adherenceGraded } = await readAdherenceSeries(
userId,
now,
userTz,
);
// Fetch mood context (optional — for enrichment only). v1.4.28
// Cap at 90 entries.
const moodEntries = await readRecentMoodScores(userId, 90);
const moodRecords = moodEntries.map((entry) => ({
measuredAt: entry.moodLoggedAt,
value: entry.score,
}));
const moodSeries = applyPayloadBudget(moodRecords, { now, tz: userTz });
const moodGraded = buildGradedSeriesFromPoints(moodRecords, now, userTz);
const moodSummary = summarizeSeries(
moodSeries.daily.map((bucket) => ({ value: bucket.value })),
);
const moodMean = moodSummary?.mean ?? null;
const bpTargets = getBpTargets(user?.dateOfBirth ?? null);
const bpInTargetLast30Days = bpTargets
? bpInTargetLast30DailyPoints(dailyByType, bpTargets)
: null;
// Compute overall data coverage info
const { totalSpanDays, newestDaysAgo } = coverageSpan(
measurements.length > 0 ? measurements[0].measuredAt : null,
measurements.length > 0
? measurements[measurements.length - 1].measuredAt
: null,
);
const snapshot = {
locale,
generatedForDay: todayKey,
interpretationHint:
"Use trend direction and deltas. Prioritize the newest data if trends conflict. Consider dataCoverage for reliability assessment.",
dataCoverage: {
totalMeasurements: measurements.length,
totalSpanDays,
newestMeasurementDaysAgo: newestDaysAgo,
avgDaysBetweenMeasurements:
measurements.length > 1
? Math.round((totalSpanDays / (measurements.length - 1)) * 10) / 10
: null,
},
measurementSeries,
medicationAdherence: {
summary: summarizeSeries(
adherenceSeries.daily.map((bucket) => ({ value: bucket.value })),
),
series: adherenceGraded,
},
bloodPressureTargets: bpTargets
? {
systolic: { min: bpTargets.sysLow, max: bpTargets.sysHigh },
diastolic: { min: bpTargets.diaLow, max: bpTargets.diaHigh },
inTargetPctLast30DailyPoints: bpInTargetLast30Days,
}
: null,
moodContext:
moodSeries.daily.length >= 3
? {
points: moodSeries.daily.length,
mean: moodMean,
latest: moodSeries.daily[0]?.value ?? null,
series: moodGraded,
}
: null,
};
return {
snapshot,
finish: () => ({
// The overview spans many metrics with no single graded mean to band,
// so the steady-run signal is left to the previous-context comparison
// (repeatCount 0) and there is no single discovery channel (relations
// empty); the variety lead + overall data-strength carry the rotation.
context: {
repeatCount: 0,
points: measurements.length,
newestDaysAgo,
relationsFor: null,
},
fallbackText: getNoKeyGeneralStatusText(locale),
prompts: (snapshotJson, context) => ({
systemPrompt: getGeneralStatusSystemPrompt(locale),
userPrompt: getGeneralStatusUserPrompt(
snapshotJson,
todayKey,
locale,
context.previousContextBlock,
context.assessmentContextBlock,
context.openerHint,
),
}),
detail: undefined,
}),
};
}
/**
* Medication adherence as one rate per user-local day, bucketed and graded
* so the model gets the canonical 360+24 view.
*/
async function readAdherenceSeries(userId: string, now: Date, userTz: string) {
// v1.12.7 — bound the intake read to the ~365-day window the adherence
// series renders. Without a `scheduledFor` floor this scanned all intake
// history; the adherence fold below only consumes the trailing year.
@@ -292,250 +305,50 @@ export async function prepareGeneralStatusForUser(
userTz,
);
// Fetch mood context (optional — for enrichment only). v1.4.28
// Cap at 90 entries.
const moodEntries = await readRecentMoodScores(userId, 90);
const moodRecords = moodEntries.map((entry) => ({
measuredAt: entry.moodLoggedAt,
value: entry.score,
}));
const moodSeries = applyPayloadBudget(moodRecords, { now, tz: userTz });
const moodGraded = buildGradedSeriesFromPoints(moodRecords, now, userTz);
const moodSummary = summarizeSeries(
moodSeries.daily.map((bucket) => ({ value: bucket.value })),
);
const moodMean = moodSummary?.mean ?? null;
const bpTargets = getBpTargets(user?.dateOfBirth ?? null);
let bpInTargetLast30Days: number | null = null;
if (bpTargets) {
const sysDaily = dailyByType.get("BLOOD_PRESSURE_SYS")?.daily ?? [];
const diaMap = new Map(
(dailyByType.get("BLOOD_PRESSURE_DIA")?.daily ?? []).map(
(bucket) => [bucket.dayOffset, bucket.value] as const,
),
);
const paired = sysDaily
.map((bucket) => {
const dia = diaMap.get(bucket.dayOffset);
if (dia == null) return null;
return { dayOffset: bucket.dayOffset, sys: bucket.value, dia };
})
.filter(
(entry): entry is { dayOffset: number; sys: number; dia: number } =>
!!entry,
)
// Keep the legacy "last 30 daily points" semantics — newest 30
// paired daily buckets (dayOffset 0..29).
.filter((point) => point.dayOffset < 30);
if (paired.length > 0) {
const inTargetCount = paired.filter((point) =>
// v1.4.16 — one-sided ceiling semantics with hypotension
// floor. See lib/analytics/bp-in-target.ts.
isBpReadingInTarget(point.sys, point.dia, bpTargets),
).length;
bpInTargetLast30Days = round((inTargetCount / paired.length) * 100, 1);
}
}
// Compute overall data coverage info
const oldestDay = measurements.length > 0 ? measurements[0].measuredAt : null;
const newestDay =
measurements.length > 0
? measurements[measurements.length - 1].measuredAt
: null;
const totalSpanDays =
oldestDay && newestDay
? Math.round(
(newestDay.getTime() - oldestDay.getTime()) / (24 * 60 * 60 * 1000),
)
: 0;
const newestDaysAgo = newestDay
? Math.round((Date.now() - newestDay.getTime()) / (24 * 60 * 60 * 1000))
: null;
const snapshot = {
locale,
generatedForDay: todayKey,
interpretationHint:
"Use trend direction and deltas. Prioritize the newest data if trends conflict. Consider dataCoverage for reliability assessment.",
dataCoverage: {
totalMeasurements: measurements.length,
totalSpanDays,
newestMeasurementDaysAgo: newestDaysAgo,
avgDaysBetweenMeasurements:
measurements.length > 1
? Math.round((totalSpanDays / (measurements.length - 1)) * 10) / 10
: null,
},
measurementSeries,
medicationAdherence: {
summary: summarizeSeries(
adherenceSeries.daily.map((bucket) => ({ value: bucket.value })),
),
series: adherenceGraded,
},
bloodPressureTargets: bpTargets
? {
systolic: { min: bpTargets.sysLow, max: bpTargets.sysHigh },
diastolic: { min: bpTargets.diaLow, max: bpTargets.diaHigh },
inTargetPctLast30DailyPoints: bpInTargetLast30Days,
}
: null,
moodContext:
moodSeries.daily.length >= 3
? {
points: moodSeries.daily.length,
mean: moodMean,
latest: moodSeries.daily[0]?.value ?? null,
series: moodGraded,
}
: 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,
},
};
}
// v1.4: pull the previous cached general-status into the prompt so
// the model can compare to the user's last analysis. Falls back
// gracefully when there's no history (first-run users).
const previousContext = await getPreviousInsightContext(
userId,
"general-status",
locale,
12,
);
const previousContextBlock = formatPreviousContextForPrompt(
previousContext,
locale,
);
// v1.12.7 — diversity / anti-repetition block (see blood-pressure-status).
// The overview spans many metrics with no single graded mean to band, so
// the steady-run signal is left to the previous-context comparison
// (repeatCount 0) and there is no single discovery channel (relations
// empty); the variety lead + overall data-strength carry the rotation.
const varietyLead = pickVarietyLead(userId, "general", todayKey);
const assessmentContextBlock = buildAssessmentContextBlock(
{
varietyLead,
dataStrength: {
points: measurements.length,
newestDaysAgo,
},
repeatCount: 0,
relations: [],
},
locale,
);
// The provider call is deferred to the caller — the single-card path runs
// ONE completion, the batch path folds this prompt into one shared call.
// A timeout / error is a transient miss (fallback served, no assessment
// persisted); `none` (no provider / no consent) serves the no-key text.
return {
phase: "pending",
metric: "general",
userId,
cacheAction,
systemPrompt: getGeneralStatusSystemPrompt(locale),
userPrompt: getGeneralStatusUserPrompt(
snapshotJson,
todayKey,
locale,
previousContextBlock,
assessmentContextBlock,
// v1.28.40 — rotating opener hint, per (user, metric, day).
openerArchetypeHint(`${userId}:general:${todayKey}`, locale as Locale),
),
snapshotHash,
// v1.12.7 — match the archetype cards' 0.45.
temperature: 0.45,
noProvider: {
hasProvider: false,
text: getNoKeyGeneralStatusText(locale),
cached: true,
updatedAt: null,
},
timeout: (reason): StatusCardResult =>
returnTimeoutFallback({
cacheAction,
reason,
userId,
todayKey,
stubText: getNoKeyGeneralStatusText(locale),
}),
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: getNoKeyGeneralStatusText(locale),
});
}
const summary = screened.text;
if (!summary) {
throw new Error("General-status summary was empty after normalization");
}
const updatedAt = await persistStatusInsight({
userId,
cacheAction,
todayKey,
text: summary,
snapshotHash,
});
return {
hasProvider: true,
text: summary,
cached: false,
updatedAt,
};
},
};
return { adherenceSeries, adherenceGraded };
}
/**
* The share of the newest 30 paired systolic / diastolic daily buckets that
* sit in the person's BP targets, or `null` with no paired day.
*/
function bpInTargetLast30DailyPoints(
dailyByType: ReadonlyMap<string, ReturnType<typeof applyPayloadBudget>>,
bpTargets: NonNullable<ReturnType<typeof getBpTargets>>,
): number | null {
const sysDaily = dailyByType.get("BLOOD_PRESSURE_SYS")?.daily ?? [];
const diaMap = new Map(
(dailyByType.get("BLOOD_PRESSURE_DIA")?.daily ?? []).map(
(bucket) => [bucket.dayOffset, bucket.value] as const,
),
);
const paired = sysDaily
.map((bucket) => {
const dia = diaMap.get(bucket.dayOffset);
if (dia == null) return null;
return { dayOffset: bucket.dayOffset, sys: bucket.value, dia };
})
.filter(
(entry): entry is { dayOffset: number; sys: number; dia: number } =>
!!entry,
)
// Keep the legacy "last 30 daily points" semantics — newest 30
// paired daily buckets (dayOffset 0..29).
.filter((point) => point.dayOffset < 30);
if (paired.length === 0) return null;
const inTargetCount = paired.filter((point) =>
// v1.4.16 — one-sided ceiling semantics with hypotension
// floor. See lib/analytics/bp-in-target.ts.
isBpReadingInTarget(point.sys, point.dia, bpTargets),
).length;
return round((inTargetCount / paired.length) * 100, 1);
}
const GENERAL_STATUS_CARD: StatusCardPlugin<StatusTextResult, undefined> = {
scope: "general",
codec: textStatusCodec,
unavailableText: (locale) => getNoKeyGeneralStatusText(locale),
emptyMessage: "General-status summary was empty after normalization",
build: buildGeneralSnapshot,
};
+336 -409
View File
@@ -3,8 +3,6 @@ import {
getMedicationComplianceSystemPrompt,
getMedicationComplianceUserPrompt,
} from "@/lib/ai/prompts/medication-compliance";
import { openerArchetypeHint } from "@/lib/ai/prompts/opener-archetype";
import type { Locale } from "@/lib/i18n/config";
import {
buildComplianceMedicationContext,
COURSES_COMPLIANCE_SELECT,
@@ -14,44 +12,37 @@ import {
lastNonSkippedTakenAt,
SCHEDULE_COMPLIANCE_SELECT,
} from "@/lib/analytics/compliance";
import { resolveUserTimezone, userDayKey } from "@/lib/tz/resolver";
import { getMedicationCategories } from "@/lib/medication-category";
import { sanitizeForPrompt } from "@/lib/insights/sanitize";
import { getNoKeyMedicationComplianceStatusText } from "@/lib/insights/no-key-fallbacks";
import {
formatPreviousContextForPrompt,
getPreviousInsightContext,
} from "@/lib/insights/memory";
import {
buildAssessmentContextBlock,
pickVarietyLead,
} from "@/lib/insights/assessment-context";
import { applyPayloadBudget } from "@/lib/insights/bucket-series";
import { buildGradedSeriesFromPoints } from "@/lib/insights/graded-series";
import { statusSnapshotCharsForRecord } from "@/lib/ai/capabilities/usage";
import { buildMetricSignal } from "@/lib/insights/metric-signal";
import { degradeStatusSnapshotToBudget } from "@/lib/insights/graded-series";
import {
type SupportedLocale,
finalizeStatusSummary,
normalizeLocale,
normalizeSummaryText,
round,
type SupportedLocale,
} from "@/lib/insights/status-shared";
import {
readFreshStatusItems,
refreshUnchangedStatusItems,
resolveReadOnlyStatusMiss,
statusCacheAction,
writeStatusNote,
} from "@/lib/insights/status-cache";
import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash";
import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback";
import {
runPreparedStatusCard,
type PreparedStatusCard,
} from "@/lib/insights/status-card-generation";
import { annotate } from "@/lib/logging/context";
import {
coverageSpan,
prepareStatusCard,
type ServedBuild,
type StatusCardBuild,
type StatusCardContext,
type StatusCardOptions,
type StatusCardPlugin,
type StatusNoteCodec,
} from "@/lib/insights/status-pipeline";
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
// 360 daily days + 24 monthly windows ≈ 1080 days of intake history.
@@ -80,7 +71,7 @@ function parseMedicationItems(items: unknown[]): MedicationSummaryItem[] {
* status result. It flows through the shared `PreparedStatusCard` union via
* the open index signature on `StatusCardResult`.
*/
interface MedicationComplianceResult {
type MedicationComplianceResult = {
hasProvider: boolean;
summary: string | null;
medications: MedicationSummaryItem[];
@@ -88,7 +79,7 @@ interface MedicationComplianceResult {
updatedAt: string | null;
preparing?: boolean;
revalidating?: boolean;
}
};
/**
* Public entry — unchanged signature. Prepares the card then runs ONE
@@ -97,11 +88,7 @@ interface MedicationComplianceResult {
*/
export async function generateMedicationComplianceStatusForUser(
userId: string,
options?: {
locale?: string | null;
force?: boolean;
readOnly?: boolean;
},
options?: StatusCardOptions,
): Promise<MedicationComplianceResult> {
const prepared = await prepareMedicationComplianceStatusForUser(
userId,
@@ -112,114 +99,157 @@ export async function generateMedicationComplianceStatusForUser(
}
/**
* v1.18.7 — everything up to (not including) the provider call.
* Carries the richer `{ summary, medications }` result through the shared
* prepare/run/finalize contract. See `status-card-generation.ts`.
* Everything up to (not including) the provider call, through the shared
* status pipeline (`status-pipeline.ts`). Carries the richer
* `{ summary, medications }` result through its own note codec.
*/
export async function prepareMedicationComplianceStatusForUser(
export function prepareMedicationComplianceStatusForUser(
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("medication-compliance", 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);
return prepareStatusCard(MEDICATION_COMPLIANCE_STATUS_CARD, userId, options);
}
// This card stores per-medication lines next to its summary, so it reads
// its note through the items variant of the shared reader.
if (!force) {
/**
* This card stores per-medication lines next to its summary, so it reads and
* re-dates its note through the items variants of the shared readers, and
* its result carries the lines as `medications`.
*/
const medicationComplianceCodec: StatusNoteCodec<
MedicationComplianceResult,
MedicationSummaryItem[]
> = {
async readFresh(ctx) {
if (ctx.force) return null;
const cached = await readFreshStatusItems({
userId,
cacheAction,
todayKey,
userId: ctx.userId,
cacheAction: ctx.cacheAction,
todayKey: ctx.todayKey,
});
if (cached && Array.isArray(cached.items)) {
return {
phase: "served",
result: {
hasProvider: true,
summary: cached.text,
medications: parseMedicationItems(cached.items),
cached: true,
updatedAt: cached.updatedAt,
},
};
}
}
// v1.8.3 — read-only navigation path: never block on the provider.
// Enqueue generation out of band and return preparing / no-provider.
if (readOnly) {
const outcome = await resolveReadOnlyStatusMiss({
userId,
metric: "medication-compliance",
locale,
if (!cached || !Array.isArray(cached.items)) return null;
return {
hasProvider: true,
summary: cached.text,
medications: parseMedicationItems(cached.items),
cached: true,
updatedAt: cached.updatedAt,
};
},
async refreshUnchanged(ctx, snapshotHash) {
const unchanged = await refreshUnchangedStatusItems({
userId: ctx.userId,
cacheAction: ctx.cacheAction,
todayKey: ctx.todayKey,
snapshotHash,
});
if (!unchanged || !Array.isArray(unchanged.items)) return null;
return {
hasProvider: true,
summary: unchanged.text,
medications: parseMedicationItems(unchanged.items),
cached: true,
updatedAt: unchanged.updatedAt,
};
},
readOnly(outcome, unavailableText) {
// Unavailable for any reason: the deterministic line, no enqueue.
// `hasProvider` is provider presence only.
if (outcome.kind === "unavailable") {
return {
phase: "served",
result: {
hasProvider: outcome.hasProvider,
summary: getNoKeyMedicationComplianceStatusText(locale),
medications: [],
cached: true,
updatedAt: null,
},
hasProvider: outcome.hasProvider,
summary: unavailableText,
medications: [],
cached: true,
updatedAt: null,
};
}
// v1.8.7 — stale-while-revalidate: serve the last good narrative (if
// any) while the worker re-warms; only show the empty preparing
// skeleton when none was ever produced.
// Stale-while-revalidate: serve the last good narrative (if any) while
// the worker re-warms; only show the empty preparing skeleton when none
// was ever produced.
return {
phase: "served",
result: {
hasProvider: true,
summary: outcome.lastGood?.text ?? null,
medications: [],
cached: outcome.lastGood !== null,
updatedAt: outcome.lastGood?.updatedAt ?? null,
preparing: outcome.lastGood === null,
revalidating: outcome.revalidating,
},
hasProvider: true,
summary: outcome.lastGood?.text ?? null,
medications: [],
cached: outcome.lastGood !== null,
updatedAt: outcome.lastGood?.updatedAt ?? null,
preparing: outcome.lastGood === null,
revalidating: outcome.revalidating,
};
}
},
noProvider(text) {
return {
hasProvider: false,
summary: text,
medications: [],
cached: true,
updatedAt: null,
};
},
fallback(ctx, reason, stubText, medicationSummaries) {
// Transient miss or a withheld reply — write the short-TTL negative stub
// (so the read-only route does not re-enqueue while the provider is
// degraded) and serve the fallback for this render without persisting an
// assessment. A withheld reply still renders the per-medication rows.
returnTimeoutFallback({
cacheAction: ctx.cacheAction,
reason,
userId: ctx.userId,
todayKey: ctx.todayKey,
stubText,
});
return {
hasProvider: true,
summary: stubText,
medications: reason === "screened" ? medicationSummaries : [],
cached: true,
updatedAt: null,
};
},
async persist(ctx, summary, hashes, medicationSummaries) {
// The compliance prompt returns a single `{ summary }` envelope — it
// does not emit per-medication text. The per-medication cards carry a
// placeholder so the UI surfaces a row per active medication; the
// overall `summary` is the model-authored assessment.
const generatedAt = await writeStatusNote({
userId: ctx.userId,
cacheAction: ctx.cacheAction,
todayKey: ctx.todayKey,
text: summary,
items: medicationSummaries,
snapshotHash: hashes.snapshotHash,
});
return {
hasProvider: true,
summary,
medications: medicationSummaries,
cached: false,
updatedAt: generatedAt.toISOString(),
};
},
};
const medicationRows = await prisma.medication.findMany({
// v1.16.11 — as-needed (PRN) medications never surface a compliance
// rate (no expected doses).
where: { userId, active: true, asNeeded: false, ...TRACKED_INTAKE_WHERE },
// v1.15.20 — schedules through the shared compliance select so the
// configured per-dose windows reach this surface like every other.
include: {
schedules: { select: SCHEDULE_COMPLIANCE_SELECT },
// v1.16.3 — archived schedule eras for era-aware compliance.
scheduleRevisions: { orderBy: { validFrom: "asc" } },
// v1.25 — pause eras so paused days drop out of the denominator.
pauseEras: { select: { pausedAt: true, resumedAt: true } },
// v1.40 (#1024) — the courses, so a gap between two expects nothing.
courses: COURSES_COMPLIANCE_SELECT,
},
orderBy: { name: "asc" },
});
type ComplianceMedication = Awaited<
ReturnType<typeof readComplianceMedications>
>[number];
type ComplianceIntakeEvent = {
medicationId: string;
scheduledFor: Date;
takenAt: Date | null;
skipped: boolean;
};
// A medication with no schedule expects no dose, and `calculateCompliance`
// answers 100 % for that case. The `asNeeded: false` filter above catches
// only the PRN arm; the empty schedule is reachable on a scheduled
// medication and would otherwise report perfect adherence.
const medications = medicationRows.filter(expectsDoses);
/**
* The adherence snapshot: one block per active, scheduled medication with
* its compliance, streak and per-day series, and the overall averages.
*/
async function buildMedicationComplianceSnapshot(
ctx: StatusCardContext,
): Promise<
| StatusCardBuild<MedicationSummaryItem[]>
| ServedBuild<MedicationComplianceResult>
> {
const { userId, locale, userTz, todayKey } = ctx;
const medications = await readComplianceMedications(userId);
if (medications.length === 0) {
return {
@@ -269,89 +299,16 @@ export async function prepareMedicationComplianceStatusForUser(
// the canonical engine on the user's own tz. `userTz` was already
// resolved above for the cache day-key.
const medicationSnapshots = medications.map((medication) => {
const events = medicationEvents.filter(
(event) => event.medicationId === medication.id,
);
const medicationContext = buildComplianceMedicationContext(
const medicationSnapshots = medications.map((medication) =>
medicationSnapshot({
medication,
lastNonSkippedTakenAt(events),
userTz,
);
// v1.18.0 — one shared cadence-aware expansion pass yields BOTH the
// canonical compliance7 / compliance30 (the values shown in this same
// snapshot) AND the unified dose-history ledger the per-day series is
// built from, so the series can never disagree with compliance30.
const bundle = buildMedicationComplianceBundle(
events,
medication.schedules,
medicationContext,
medicationEvents,
categoryMap,
now,
);
const compliance7 = bundle.compliance7;
const compliance30 = bundle.compliance30;
// v1.18.0 — the per-day series honours the medication's cadence
// (daysOfWeek + intervalWeeks + rolling / RRULE / cyclic): only days the
// schedule actually expected a dose produce a point, and each day's rate
// uses the SAME taken / (taken + missed) tally as compliance30. The old
// path divided every event-day by `schedules.length`, treating every
// calendar day as fully dose-due — which made a weekly-cadence med's
// per-day series contradict its own compliance30. Reusing the canonical
// ledger fixes that by construction.
const perDayRecords = dailyComplianceRatesFromLedger(
bundle.ledgerRows,
userTz,
).map((day) => ({
measuredAt: day.date,
value: round(day.rate, 1),
}));
// `applyPayloadBudget` gives the latest-day focus; the compact
// graded series is what reaches the prompt.
const dailyBudgeted = applyPayloadBudget(perDayRecords, {
now,
tz: userTz,
});
const dailySeries = buildGradedSeriesFromPoints(perDayRecords, now, userTz);
const latestDay = dailyBudgeted.daily[0] ?? null;
// v1.18.10 — the finished recent-vs-baseline adherence comparison
// + normal-swing verdict, computed from the canonical per-day rate series
// so the model states the trend rather than deriving it. The compliance %
// itself stays server-computed (compliance7/30 above); this adds only the
// trend signal. Adherence reads higher-better; the band is 0–100%.
const signal = buildMetricSignal({
metric:
locale === "en"
? `adherence for ${sanitizeForPrompt(medication.name)}`
: `Einnahmetreue für ${sanitizeForPrompt(medication.name)}`,
unit: "%",
direction: "higher-better",
graded: dailySeries,
// Recency is surfaced separately via dataCoverage.newestMeasurementDaysAgo
// (computed after this map); the per-med signal leaves it null.
newestDaysAgo: null,
});
return {
medicationId: medication.id,
name: sanitizeForPrompt(medication.name),
dose: sanitizeForPrompt(medication.dose, 50),
category: categoryMap[medication.id] ?? "OTHER",
schedulesPerDay: medication.schedules.length,
compliance7: compliance7.rate,
compliance30: compliance30.rate,
streak: compliance7.streak,
taken7: compliance7.taken,
skipped7: compliance7.skipped,
missed7: compliance7.missed,
...(signal ? { signal } : {}),
dailySeries,
latestDay,
};
});
locale,
}),
);
const avgCompliance30 =
medicationSnapshots.length > 0
@@ -364,22 +321,13 @@ export async function prepareMedicationComplianceStatusForUser(
)
: null;
const oldestEvent =
medicationEvents.length > 0 ? medicationEvents[0].scheduledFor : null;
const newestEvent =
medicationEvents.length > 0
? medicationEvents[medicationEvents.length - 1].scheduledFor
: null;
const totalSpanDays =
oldestEvent && newestEvent
? Math.round(
(newestEvent.getTime() - oldestEvent.getTime()) /
(24 * 60 * 60 * 1000),
)
: 0;
const newestMeasurementDaysAgo = newestEvent
? Math.round((Date.now() - newestEvent.getTime()) / (24 * 60 * 60 * 1000))
: null;
const { totalSpanDays, newestDaysAgo: newestMeasurementDaysAgo } =
coverageSpan(
medicationEvents.length > 0 ? medicationEvents[0].scheduledFor : null,
medicationEvents.length > 0
? medicationEvents[medicationEvents.length - 1].scheduledFor
: null,
);
const snapshot = {
locale,
@@ -404,206 +352,185 @@ export async function prepareMedicationComplianceStatusForUser(
medications: medicationSnapshots,
};
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.
// This card stores per-medication lines next to the summary, so it uses
// the items variant of the shared gate.
const snapshotHash = hashInsightSnapshot(snapshot);
const unchanged = await refreshUnchangedStatusItems({
userId,
cacheAction,
todayKey,
snapshotHash,
});
if (unchanged && Array.isArray(unchanged.items)) {
return {
phase: "served",
result: {
hasProvider: true,
summary: unchanged.text,
medications: parseMedicationItems(unchanged.items),
cached: true,
updatedAt: unchanged.updatedAt,
},
};
}
const previousContext = await getPreviousInsightContext(
userId,
"medication-compliance-status",
locale,
12,
);
const previousContextBlock = formatPreviousContextForPrompt(
previousContext,
locale,
);
// v1.12.7 — diversity / anti-repetition block (see blood-pressure-status).
// Adherence spans multiple medications with no single card-level graded
// mean to band, so the steady-run signal is left to the previous-context
// comparison (repeatCount 0) and there is no measurement discovery channel
// (relations empty); the variety lead + data-strength carry the rotation.
const varietyLead = pickVarietyLead(
userId,
"medication-compliance",
todayKey,
);
const assessmentContextBlock = buildAssessmentContextBlock(
{
varietyLead,
dataStrength: {
return {
snapshot,
finish: () => ({
// Adherence spans multiple medications with no single card-level graded
// mean to band, so the steady-run signal is left to the previous-context
// comparison (repeatCount 0) and there is no measurement discovery
// channel (relations empty); the variety lead + data-strength carry the
// rotation.
context: {
repeatCount: 0,
points: medicationEvents.length,
newestDaysAgo: newestMeasurementDaysAgo,
relationsFor: null,
},
repeatCount: 0,
relations: [],
},
locale,
);
// The per-medication placeholder rows the UI surfaces alongside the
// model-authored overall `summary`. Computed here so both `finalize`
// (success) reuses them; the model's compliance prompt returns only the
// single `{ summary }` envelope.
const medicationSummaries: MedicationSummaryItem[] = medicationSnapshots.map(
(medication) => ({
medicationId: medication.medicationId,
text: normalizeSummaryText(
locale === "de"
? `${medication.name}: Es liegen noch nicht genügend konsistente Detaildaten für eine belastbare Kurzbewertung vor.`
: `${medication.name}: There is currently not enough consistent detail data for a robust short assessment.`,
),
}),
);
// 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: "medication-compliance",
userId,
cacheAction,
systemPrompt: getMedicationComplianceSystemPrompt(locale),
userPrompt: getMedicationComplianceUserPrompt(
snapshotJson,
todayKey,
locale,
previousContextBlock,
assessmentContextBlock,
// v1.28.40 — rotating opener hint, per (user, metric, day).
openerArchetypeHint(
`${userId}:medication-compliance:${todayKey}`,
locale as Locale,
),
),
snapshotHash,
// v1.12.7 — match the archetype cards' 0.45.
temperature: 0.45,
noProvider: {
hasProvider: false,
summary: getNoKeyMedicationComplianceStatusText(locale),
medications: [],
cached: true,
updatedAt: null,
},
timeout: (reason) => {
// Transient miss — write the short-TTL negative stub (so the read-only
// route does not re-enqueue while the provider is degraded) and serve
// the fallback for this render without persisting an assessment.
returnTimeoutFallback({
cacheAction,
reason,
userId,
todayKey,
stubText: getNoKeyMedicationComplianceStatusText(locale),
});
return {
hasProvider: true,
summary: getNoKeyMedicationComplianceStatusText(locale),
medications: [],
cached: true,
updatedAt: null,
};
},
finalize: async (outcome) => {
// The compliance prompt returns a single `{ summary }` envelope — it
// does not emit per-medication text. The per-medication cards carry a
// placeholder so the UI surfaces a row per active medication; the
// overall `summary` is the model-authored assessment.
//
// v1.32.20 — route the completion through the shared status chokepoint,
// so this card enforces the exact contracts its seven siblings do rather
// than only withholding unparseable envelopes. `finalizeStatusSummary`
// parses the `{ summary }` envelope, scrubs chart tokens + whitespace,
// then SCREENS the prose against `INSIGHTS_CONTRACTS` (dose / risk /
// causal). This is the highest-leverage surface for the outbound screen —
// a medication card is exactly where a dose-change imperative or a
// fabricated figure lands. WITHHOLD on any block or unparseable envelope:
// serve the deterministic no-key line, write a short negative stub, and
// persist no model text. The per-medication rows still render.
const finalized = finalizeStatusSummary(outcome.content, locale);
if (!finalized.ok) {
annotate({
action: { name: "insights.status.outbound_blocked" },
meta: { cacheAction, reason: finalized.reason },
});
returnTimeoutFallback({
cacheAction,
reason: "screened",
userId,
fallbackText: getNoKeyMedicationComplianceStatusText(locale),
prompts: (snapshotJson, context) => ({
systemPrompt: getMedicationComplianceSystemPrompt(locale),
userPrompt: getMedicationComplianceUserPrompt(
snapshotJson,
todayKey,
stubText: getNoKeyMedicationComplianceStatusText(locale),
});
return {
hasProvider: true,
summary: getNoKeyMedicationComplianceStatusText(locale),
medications: medicationSummaries,
cached: true,
updatedAt: null,
};
}
const summary = finalized.text;
if (!summary) {
throw new Error(
"Medication-compliance-status summary was empty after normalization",
);
}
const generatedAt = await writeStatusNote({
userId,
cacheAction,
todayKey,
text: summary,
items: medicationSummaries,
snapshotHash,
});
return {
hasProvider: true,
summary,
medications: medicationSummaries,
cached: false,
updatedAt: generatedAt.toISOString(),
};
},
locale,
context.previousContextBlock,
context.assessmentContextBlock,
context.openerHint,
),
}),
detail: medicationPlaceholderLines(medicationSnapshots, locale),
}),
};
}
export function resolveMedicationComplianceStatusLocale(
locale: string | null | undefined,
): SupportedLocale {
return normalizeLocale(locale);
/** The active, scheduled medications whose adherence the card reports. */
async function readComplianceMedications(userId: string) {
const medicationRows = await prisma.medication.findMany({
// v1.16.11 — as-needed (PRN) medications never surface a compliance
// rate (no expected doses).
where: { userId, active: true, asNeeded: false, ...TRACKED_INTAKE_WHERE },
// v1.15.20 — schedules through the shared compliance select so the
// configured per-dose windows reach this surface like every other.
include: {
schedules: { select: SCHEDULE_COMPLIANCE_SELECT },
// v1.16.3 — archived schedule eras for era-aware compliance.
scheduleRevisions: { orderBy: { validFrom: "asc" } },
// v1.25 — pause eras so paused days drop out of the denominator.
pauseEras: { select: { pausedAt: true, resumedAt: true } },
// v1.40 (#1024) — the courses, so a gap between two expects nothing.
courses: COURSES_COMPLIANCE_SELECT,
},
orderBy: { name: "asc" },
});
// A medication with no schedule expects no dose, and `calculateCompliance`
// answers 100 % for that case. The `asNeeded: false` filter above catches
// only the PRN arm; the empty schedule is reachable on a scheduled
// medication and would otherwise report perfect adherence.
return medicationRows.filter(expectsDoses);
}
/** One medication's block in the adherence snapshot. */
function medicationSnapshot(args: {
medication: ComplianceMedication;
medicationEvents: ComplianceIntakeEvent[];
categoryMap: Record<string, string>;
now: Date;
userTz: string;
locale: SupportedLocale;
}) {
const { medication, medicationEvents, categoryMap, now, userTz, locale } =
args;
const events = medicationEvents.filter(
(event) => event.medicationId === medication.id,
);
const medicationContext = buildComplianceMedicationContext(
medication,
lastNonSkippedTakenAt(events),
userTz,
);
// v1.18.0 — one shared cadence-aware expansion pass yields BOTH the
// canonical compliance7 / compliance30 (the values shown in this same
// snapshot) AND the unified dose-history ledger the per-day series is
// built from, so the series can never disagree with compliance30.
const bundle = buildMedicationComplianceBundle(
events,
medication.schedules,
medicationContext,
now,
);
const compliance7 = bundle.compliance7;
const compliance30 = bundle.compliance30;
// v1.18.0 — the per-day series honours the medication's cadence
// (daysOfWeek + intervalWeeks + rolling / RRULE / cyclic): only days the
// schedule actually expected a dose produce a point, and each day's rate
// uses the SAME taken / (taken + missed) tally as compliance30. The old
// path divided every event-day by `schedules.length`, treating every
// calendar day as fully dose-due — which made a weekly-cadence med's
// per-day series contradict its own compliance30. Reusing the canonical
// ledger fixes that by construction.
const perDayRecords = dailyComplianceRatesFromLedger(
bundle.ledgerRows,
userTz,
).map((day) => ({
measuredAt: day.date,
value: round(day.rate, 1),
}));
// `applyPayloadBudget` gives the latest-day focus; the compact
// graded series is what reaches the prompt.
const dailyBudgeted = applyPayloadBudget(perDayRecords, {
now,
tz: userTz,
});
const dailySeries = buildGradedSeriesFromPoints(perDayRecords, now, userTz);
const latestDay = dailyBudgeted.daily[0] ?? null;
// v1.18.10 — the finished recent-vs-baseline adherence comparison
// + normal-swing verdict, computed from the canonical per-day rate series
// so the model states the trend rather than deriving it. The compliance %
// itself stays server-computed (compliance7/30 above); this adds only the
// trend signal. Adherence reads higher-better; the band is 0–100%.
const signal = buildMetricSignal({
metric:
locale === "en"
? `adherence for ${sanitizeForPrompt(medication.name)}`
: `Einnahmetreue für ${sanitizeForPrompt(medication.name)}`,
unit: "%",
direction: "higher-better",
graded: dailySeries,
// Recency is surfaced separately via dataCoverage.newestMeasurementDaysAgo
// (computed after this map); the per-med signal leaves it null.
newestDaysAgo: null,
});
return {
medicationId: medication.id,
name: sanitizeForPrompt(medication.name),
dose: sanitizeForPrompt(medication.dose, 50),
category: categoryMap[medication.id] ?? "OTHER",
schedulesPerDay: medication.schedules.length,
compliance7: compliance7.rate,
compliance30: compliance30.rate,
streak: compliance7.streak,
taken7: compliance7.taken,
skipped7: compliance7.skipped,
missed7: compliance7.missed,
...(signal ? { signal } : {}),
dailySeries,
latestDay,
};
}
/**
* The per-medication placeholder rows the UI surfaces alongside the
* model-authored overall `summary`; the model's compliance prompt returns
* only the single `{ summary }` envelope.
*/
function medicationPlaceholderLines(
medicationSnapshots: ReadonlyArray<{ medicationId: string; name: string }>,
locale: SupportedLocale,
): MedicationSummaryItem[] {
return medicationSnapshots.map((medication) => ({
medicationId: medication.medicationId,
text: normalizeSummaryText(
locale === "de"
? `${medication.name}: Es liegen noch nicht genügend konsistente Detaildaten für eine belastbare Kurzbewertung vor.`
: `${medication.name}: There is currently not enough consistent detail data for a robust short assessment.`,
),
}));
}
const MEDICATION_COMPLIANCE_STATUS_CARD: StatusCardPlugin<
MedicationComplianceResult,
MedicationSummaryItem[]
> = {
scope: "medication-compliance",
codec: medicationComplianceCodec,
unavailableText: (locale) => getNoKeyMedicationComplianceStatusText(locale),
emptyMessage:
"Medication-compliance-status summary was empty after normalization",
build: buildMedicationComplianceSnapshot,
};
+330 -383
View File
@@ -1,17 +1,17 @@
/**
* v1.8.7.1 — generic per-HealthKit-metric assessment generator.
* Generic per-HealthKit-metric assessment generator.
*
* The seven specialised generators (`pulse-status.ts`, …) each hand-roll
* a metric-specific snapshot + prompt. This one generator covers every
* registered HealthKit metric (`metric-status-registry.ts`) through the
* archetype prompt templates (`prompts/metric-archetypes.ts`), reusing
* the exact same machinery the specialised cards use:
* The specialised cards (`pulse-status.ts`, …) each plug a metric-specific
* snapshot + prompt into the status pipeline (`status-pipeline.ts`). This one
* generator covers every registered HealthKit metric
* (`metric-status-registry.ts`) through the archetype prompt templates
* (`prompts/metric-archetypes.ts`), on the same pipeline stages:
* - the graded-series snapshot builder (`buildGradedSeriesWithRollups`),
* - the shared provider runner (`runStatusCompletion`),
* - the stale-while-revalidate cache (`readFreshStatusText` /
* `resolveReadOnlyStatusMiss`),
* - the standard cache-row persist (`persistStatusInsight`),
* - the previous-context comparison block (`getPreviousInsightContext`).
* - the stale-while-revalidate cache (`readCachedStatus` /
* `resolveReadOnlyStatus`),
* - the snapshot seal and content-hash gate,
* - the previous-context and anti-repetition blocks,
* - the single-card provider run and the standard note persist.
*
* The return shape is byte-identical to the specialised generators so
* `InsightStatusCard` consumes it unchanged.
@@ -34,23 +34,12 @@ import {
type MetricStatusMeta,
type MetricStatusMetricId,
} from "@/lib/insights/metric-status-registry";
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 { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets";
import { lookupNormalRange } from "@/lib/insights/derived/norms";
import { buildMetricSignal } from "@/lib/insights/metric-signal";
import { PROMPT_VERSION } from "@/lib/ai/prompts/base-system";
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
import { openerArchetypeHint } from "@/lib/ai/prompts/opener-archetype";
import type { Locale } from "@/lib/i18n/config";
import {
getNoKeyGeneralStatusText,
getNoKeyMetricStatusText,
@@ -60,29 +49,27 @@ import {
buildGradedSeriesFromPoints,
buildGradedSeriesWithRollups,
convertGradedSeries,
degradeStatusSnapshotToBudget,
type GradedSeries,
} from "@/lib/insights/graded-series";
import { statusSnapshotCharsForRecord } from "@/lib/ai/capabilities/usage";
import { summarizeSeries } from "@/lib/insights/status-shared";
import {
type SupportedLocale,
normalizeLocale,
finalizeStatusSummary,
persistStatusInsight,
summarizeSeries,
} from "@/lib/insights/status-shared";
import { runStatusCompletion } from "@/lib/insights/status-provider";
runPreparedStatusCard,
type PreparedStatusCard,
} from "@/lib/insights/status-card-generation";
import {
buildStatusPromptContext,
coverageSpan,
openStatusCard,
pendingStatusCard,
readCachedStatus,
refreshUnchangedStatus,
resolveReadOnlyStatus,
sealStatusSnapshot,
textStatusCodec,
type StatusCardContext,
} from "@/lib/insights/status-pipeline";
import { readSleepNights } from "@/lib/measurements/list/sleep-nights";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import { resolveUserTimezone, userDayKey } from "@/lib/tz/resolver";
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 { annotate } from "@/lib/logging/context";
import { binaryReferenceSex } from "@/lib/profile/sex";
import {
@@ -155,33 +142,32 @@ export async function generateMetricStatus(args: {
insufficient: true,
};
}
const prepared = await prepareMetricStatus(meta, args);
return (await runPreparedStatusCard(prepared)) as MetricStatusResult;
}
const locale = normalizeLocale(args.locale);
const force = args.force === true;
const readOnly = args.readOnly === true;
const scope = metricStatusScope(meta.id);
const cacheAction = statusCacheAction(scope, 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(args.userId);
const todayKey = userDayKey(new Date(), userTz);
/**
* Everything up to (not including) the provider call, on the shared
* pipeline stages, with the empty-data gate between the cache read and the
* read-only path.
*/
async function prepareMetricStatus(
meta: MetricStatusMeta,
args: {
userId: string;
locale?: string | null;
force?: boolean;
readOnly?: boolean;
},
): Promise<PreparedStatusCard> {
const ctx = await openStatusCard(
args.userId,
metricStatusScope(meta.id),
args,
);
const cached = await readFreshStatusText({
userId: args.userId,
cacheAction,
todayKey,
force,
});
if (cached) {
return {
hasProvider: true,
text: cached.text,
cached: true,
updatedAt: cached.updatedAt,
};
}
const cached = await readCachedStatus(ctx, textStatusCodec);
if (cached) return { phase: "served", result: cached };
// Empty-data guard — no readings means no assessment to generate. Done
// BEFORE the read-only enqueue so an empty metric never queues an LLM
@@ -194,45 +180,143 @@ export async function generateMetricStatus(args: {
meta: { metric: meta.id },
});
return {
hasProvider: true,
text: null,
cached: false,
updatedAt: null,
insufficient: true,
};
}
if (readOnly) {
const outcome = await resolveReadOnlyStatusMiss({
userId: args.userId,
metric: scope,
locale,
});
// Unavailable for any reason serves the deterministic line and enqueues
// nothing. `hasProvider` is provider presence only.
if (outcome.kind === "unavailable") {
return {
hasProvider: outcome.hasProvider,
text: getNoKeyGeneralStatusText(locale),
cached: true,
phase: "served",
result: {
hasProvider: true,
text: null,
cached: false,
updatedAt: null,
};
}
// Stale-while-revalidate: serve the last good assessment (if any)
// instantly while the worker re-warms the cache; only fall to the
// empty preparing skeleton when none was ever produced.
return {
hasProvider: true,
text: outcome.lastGood?.text ?? null,
cached: outcome.lastGood !== null,
updatedAt: outcome.lastGood?.updatedAt ?? null,
preparing: outcome.lastGood === null,
revalidating: outcome.revalidating,
insufficient: true,
},
};
}
if (ctx.readOnly) {
// The read-only miss serves the general deterministic line when notes
// are unavailable; it has no signal to ground a metric line in.
return {
phase: "served",
result: await resolveReadOnlyStatus(
ctx,
textStatusCodec,
getNoKeyGeneralStatusText(ctx.locale),
),
};
}
const built = await buildMetricSnapshot(ctx, meta);
const { snapshotJson, snapshotHash } = await sealStatusSnapshot(
ctx,
built.snapshot,
);
const unchanged = await refreshUnchangedStatus(
ctx,
textStatusCodec,
snapshotHash,
);
if (unchanged) return { phase: "served", result: unchanged };
const {
graded,
summary,
series,
newestDaysAgo,
signal,
latest,
reading,
sex,
} = built;
const { locale, todayKey } = ctx;
// v1.12.1 — diversity / anti-repetition context, all from already-computed
// data (no new statistics, no new persistence). Grounding is untouched:
// these blocks add a rotating opening angle, an explicit data-strength
// line, a steady-run repetition signal, and the FDR-surviving cross-metric
// correlations that involve THIS metric. The correlation fetch is
// best-effort and decorative — a failure resolves to no block, never a
// generation failure. The opener hint is keyed per (user, metric, day)
// exactly as `derived-assessment-ai.ts` does for the score cards, so the
// per-metric card opens verdict-first and varies run-to-run.
const context = await buildStatusPromptContext(ctx, {
varietyKey: meta.id,
repeatCount: computeSteadyRun(graded.weekly, graded.monthly),
points: summary?.points ?? series.daily.length,
newestDaysAgo,
relationsFor: meta.measurementType,
});
// v1.27.13 — the guideline interpretation block for metrics the
// knowledge base covers (visceral fat, resting HR, SpO₂, respiratory rate,
// body temperature, sleep duration, waist, PWV). The band position is
// computed from the signal's current value (or the latest day when no signal
// window exists); absent for uncovered metrics (fail-soft to personal-
// relative). Sex-split bands (waist) resolve only when the profile carries a
// sex.
// The guideline bands are canonical, so the band position is classified on
// the canonical value; the block then prints in the reader's unit.
const interpretationValue = signal?.current ?? latest?.value ?? null;
const interpretationBlock =
interpretationValue !== null
? buildInterpretationBlock({
metricKey: meta.id,
value: reading.toCanonical(interpretationValue),
sex,
locale,
...(reading.converts
? {
display: {
unit: reading.unit,
convert: reading.toDisplay,
},
}
: {}),
})
: undefined;
const fallbackText = getNoKeyMetricStatusText(
locale,
signal,
reading.decimals,
);
return pendingStatusCard(ctx, textStatusCodec, {
systemPrompt: getMetricArchetypeSystemPrompt(built.shownMeta, locale),
userPrompt: getMetricArchetypeUserPrompt(
built.shownMeta,
snapshotJson,
todayKey,
locale,
context.previousContextBlock,
context.assessmentContextBlock,
interpretationBlock,
context.openerHint,
),
hashes: { snapshotHash },
// v1.12.1 — the phrasing task benefits from a touch more sampling
// entropy while the FACTS stay pinned by the snapshot + the
// forbidden-phrase guards. 0.3 was conservative for a 2-4 sentence prose
// task; 0.45 varies cadence without loosening grounding. The pair used to
// sit inline here, bypassing the registry that documents every surface's
// ceiling; it now reads from `AI_BUDGETS.statusArchetype`.
temperature: AI_BUDGETS.statusArchetype.temperature,
maxTokens: AI_BUDGETS.statusArchetype.maxTokens,
fallbackText,
emptyMessage: `Metric-status summary was empty for ${meta.id}`,
detail: undefined,
});
}
/**
* The metric snapshot, in the reader's units: the series, the reference
* band (sharpened by age and sex where the norms cover the metric), the
* signal, and the data coverage.
*/
async function buildMetricSnapshot(
ctx: StatusCardContext,
meta: MetricStatusMeta,
) {
const { userId, locale, userTz, todayKey } = ctx;
const user = await prisma.user.findUnique({
where: { id: args.userId },
where: { id: userId },
select: {
dateOfBirth: true,
gender: true,
@@ -269,57 +353,14 @@ export async function generateMetricStatus(args: {
const now = new Date();
// Bounded raw read for the derived recent stats; the monthly/yearly
// tail comes from the rollup tier (full-history fallback on a miss),
// exactly as the specialised cards do.
//
// SLEEP_DURATION is the one type stored ONE ROW PER STAGE per night
// (IN_BED / AWAKE / CORE / DEEP / REM, minutes each), so a raw per-row
// read would feed the status prose a single ~16 min stage as the
// "current sleep" instead of the night total. Route it through
// `reconstructSleepNights` (the same helper the dashboard / series /
// targets use) so every derived value here — `series`, `summary`,
// `latest` — is a per-night TIME-ASLEEP total in minutes (the registry
// unit + normalRange are already minutes). The night reconstruction
// clusters stages into sessions, dedups multi-source nights via the
// user's `sleep` priority ladder, and handles the midnight split. Gated
// to SLEEP_DURATION only so no other metric's status path changes.
const isSleep = meta.measurementType === "SLEEP_DURATION";
// v1.2.5 — the day bucketing below keys on the user's own
// calendar regardless of branch. `userTz` was already resolved above for
// the cache day-key.
const points: Array<{ measuredAt: Date; value: number }> = [];
let measurements: Array<{ measuredAt: Date }> = [];
if (isSleep) {
// The latest 365 nights, the sleep twin of the 365 latest readings the
// other types read (`readRecentReadings`). A cut on stage rows instead (2190 of them) reached
// back a few weeks on a stage-rich watch and began mid-night.
const nights = (
await readSleepNights(
args.userId,
userTz,
await loadUserSourcePriority(args.userId),
{ maxNights: 365, newest: true },
)
).filter((n) => n.asleepMinutes > 0);
for (const n of nights) {
points.push({ measuredAt: n.measuredAt, value: n.asleepMinutes });
}
measurements = nights.map((n) => ({ measuredAt: n.measuredAt }));
} else {
// The latest 365 readings, never less than the last 30 days.
const rows = await readRecentReadings(args.userId, meta.measurementType);
for (const m of rows) {
points.push({
measuredAt: m.measuredAt,
value: reading.toDisplay(m.value),
});
}
measurements = rows.map((m) => ({ measuredAt: m.measuredAt }));
}
const { points, measurements } = await readMetricPoints(
userId,
meta,
reading,
userTz,
);
const series = applyPayloadBudget(points, { now, tz: userTz });
const isSleep = meta.measurementType === "SLEEP_DURATION";
// SLEEP_DURATION must never go through `buildGradedSeriesWithRollups`: that
// path reads RAW per-stage rows (and the stage-summed `measurement_rollups`
// tier) and folds IN_BED + AWAKE + bare ASLEEP + CORE/DEEP/REM into one
@@ -331,7 +372,7 @@ export async function generateMetricStatus(args: {
? buildGradedSeriesFromPoints(points, now, userTz)
: reading.convertGraded(
await buildGradedSeriesWithRollups(
args.userId,
userId,
meta.measurementType,
now,
userTz,
@@ -343,52 +384,23 @@ export async function generateMetricStatus(args: {
const latest = series.daily[0] ?? null;
const oldest = measurements.length > 0 ? measurements[0].measuredAt : null;
const newest =
const { totalSpanDays, newestDaysAgo } = coverageSpan(
measurements.length > 0 ? measurements[0].measuredAt : null,
measurements.length > 0
? measurements[measurements.length - 1].measuredAt
: null;
const totalSpanDays =
oldest && newest
? Math.round(
(newest.getTime() - oldest.getTime()) / (24 * 60 * 60 * 1000),
)
: 0;
const newestDaysAgo = newest
? Math.round((Date.now() - newest.getTime()) / (24 * 60 * 60 * 1000))
: null;
: null,
);
const ageYears = getAgeFromDateOfBirth(user?.dateOfBirth ?? null);
const sex = binaryReferenceSex(user?.gender);
// v1.10.0 — sharpen the coarse registry `normalRange` with the
// age/sex reference-range enabler when one is available for this
// metric + profile; otherwise keep the existing flat anchor (strictly
// additive, never a regression). Age + sex only — no ancestry/region.
const sharpenedRange = lookupNormalRange(meta.id, ageYears, sex);
const canonicalRange = sharpenedRange ?? meta.normalRange;
const normalRange = canonicalRange
? {
...canonicalRange,
low: reading.toDisplay(canonicalRange.low),
high: reading.toDisplay(canonicalRange.high),
}
: canonicalRange;
// v1.13.x — the SIGNAL block: the recent-vs-baseline comparison + the
// normal-swing verdict, computed server-side so the model states it rather
// than re-deriving it from the raw buckets (research-ai-quality.md §4/§5C).
// Built from the same graded series the snapshot already carries; null only
// when the recent window is empty, in which case it is omitted.
const signal = buildMetricSignal({
metric: meta.displayName,
unit: shownMeta.unit,
direction: meta.direction,
graded,
normalRange: normalRange ?? null,
normalRangeSource: sharpenedRange ? "age-sex-adjusted" : undefined,
newestDaysAgo,
});
const { ageYears, sex, sharpenedRange, normalRange, signal } =
metricRangeAndSignal({
meta,
shownUnit: shownMeta.unit,
reading,
graded,
newestDaysAgo,
dateOfBirth: user?.dateOfBirth ?? null,
gender: user?.gender ?? null,
});
const snapshot = {
locale,
@@ -425,199 +437,134 @@ export async function generateMetricStatus(args: {
},
};
const shed = degradeStatusSnapshotToBudget(
snapshot as unknown as Record<string, unknown>,
{ maxChars: await statusSnapshotCharsForRecord(args.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: args.userId,
cacheAction,
todayKey,
snapshotHash,
});
if (unchanged) {
return {
hasProvider: true,
text: unchanged.text,
cached: true,
updatedAt: unchanged.updatedAt,
};
}
const previousContext = await getPreviousInsightContext(
args.userId,
// memory.ts keys previous context by the `insights.<scope>.<locale>`
// action; the generic scope (`metric:<ID>-status`) carries the same
// `-status` suffix so the comparison row reads back correctly. The
// `getPreviousInsightContext` param is typed to accept this scope form
// (`PreviousContextScope`), so the argument type-checks honestly.
`${scope}-status`,
locale,
12,
);
const previousContextBlock = formatPreviousContextForPrompt(
previousContext,
locale,
);
// v1.12.1 — diversity / anti-repetition context, all from already-computed
// data (no new statistics, no new persistence). Grounding is untouched:
// these blocks add a rotating opening angle, an explicit data-strength
// line, a steady-run repetition signal, and the FDR-surviving cross-metric
// correlations that involve THIS metric. The correlation fetch is
// best-effort and decorative — a failure resolves to no block, never a
// generation failure.
const varietyLead = pickVarietyLead(args.userId, meta.id, todayKey);
const steadyRun = computeSteadyRun(graded.weekly, graded.monthly);
const relations = await getRelevantCorrelationsForMetric(
args.userId,
meta.measurementType,
locale,
);
const contextBlock = buildAssessmentContextBlock(
{
varietyLead,
dataStrength: {
points: summary?.points ?? series.daily.length,
newestDaysAgo,
},
repeatCount: steadyRun,
relations,
},
locale,
);
// v1.27.13 — the guideline interpretation block for metrics the
// knowledge base covers (visceral fat, resting HR, SpO₂, respiratory rate,
// body temperature, sleep duration, waist, PWV). The band position is
// computed from the signal's current value (or the latest day when no signal
// window exists); absent for uncovered metrics (fail-soft to personal-
// relative). Sex-split bands (waist) resolve only when the profile carries a
// sex.
// The guideline bands are canonical, so the band position is classified on
// the canonical value; the block then prints in the reader's unit.
const interpretationValue = signal?.current ?? latest?.value ?? null;
const interpretationBlock =
interpretationValue !== null
? buildInterpretationBlock({
metricKey: meta.id,
value: reading.toCanonical(interpretationValue),
sex,
locale,
...(reading.converts
? {
display: {
unit: reading.unit,
convert: reading.toDisplay,
},
}
: {}),
})
: undefined;
// v1.28.40 — the rotating opener-archetype hint, keyed per (user, metric,
// day) exactly as `derived-assessment-ai.ts` does for the score cards. This
// activates the base prompt's dormant "Lead per the OPENER HINT … do NOT open
// with the number" branch so the per-metric card opens verdict-first and
// varies run-to-run. No new model call — one arg on the existing completion.
const openerHint = openerArchetypeHint(
`${args.userId}:${scope}:${todayKey}`,
locale as Locale,
);
const outcome = await runStatusCompletion({
userId: args.userId,
cacheAction,
capability: "statusText",
systemPrompt: getMetricArchetypeSystemPrompt(shownMeta, locale),
userPrompt: getMetricArchetypeUserPrompt(
shownMeta,
snapshotJson,
todayKey,
locale,
previousContextBlock,
contextBlock,
interpretationBlock,
openerHint,
),
// v1.12.1 — the phrasing task benefits from a touch more sampling
// entropy while the FACTS stay pinned by the snapshot + the
// forbidden-phrase guards. 0.3 was conservative for a 2-4 sentence prose
// task; 0.45 varies cadence without loosening grounding. The pair used to
// sit inline here, bypassing the registry that documents every surface's
// ceiling; it now reads from `AI_BUDGETS.statusArchetype`.
temperature: AI_BUDGETS.statusArchetype.temperature,
maxTokens: AI_BUDGETS.statusArchetype.maxTokens,
});
if (outcome.kind === "none") {
return {
hasProvider: false,
text: getNoKeyMetricStatusText(locale, signal, reading.decimals),
cached: true,
updatedAt: null,
};
}
if (outcome.kind === "timeout" || outcome.kind === "error") {
return returnTimeoutFallback({
cacheAction,
reason: outcome.kind,
userId: args.userId,
todayKey,
stubText: getNoKeyMetricStatusText(locale, signal, reading.decimals),
});
}
// Outbound safety screen — WITHHOLD policy for a background-generated card.
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: args.userId,
todayKey,
stubText: getNoKeyMetricStatusText(locale, signal, reading.decimals),
});
}
const text = screened.text;
if (!text) {
throw new Error(`Metric-status summary was empty for ${meta.id}`);
}
const updatedAt = await persistStatusInsight({
userId: args.userId,
cacheAction,
todayKey,
text,
snapshotHash,
});
return { hasProvider: true, text, cached: false, updatedAt };
return {
snapshot,
shownMeta,
reading,
graded,
summary,
series,
latest,
signal,
sex,
newestDaysAgo,
};
}
export function resolveMetricStatusLocale(
locale: string | null | undefined,
): SupportedLocale {
return normalizeLocale(locale);
/**
* The metric's points in the reader's units, and the readings they came
* from. SLEEP_DURATION reads per-night totals, every other type its latest
* readings.
*/
async function readMetricPoints(
userId: string,
meta: MetricStatusMeta,
reading: ReadingDisplay,
userTz: string,
) {
// Bounded raw read for the derived recent stats; the monthly/yearly
// tail comes from the rollup tier (full-history fallback on a miss),
// exactly as the specialised cards do.
//
// SLEEP_DURATION is the one type stored ONE ROW PER STAGE per night
// (IN_BED / AWAKE / CORE / DEEP / REM, minutes each), so a raw per-row
// read would feed the status prose a single ~16 min stage as the
// "current sleep" instead of the night total. Route it through
// `reconstructSleepNights` (the same helper the dashboard / series /
// targets use) so every derived value here — `series`, `summary`,
// `latest` — is a per-night TIME-ASLEEP total in minutes (the registry
// unit + normalRange are already minutes). The night reconstruction
// clusters stages into sessions, dedups multi-source nights via the
// user's `sleep` priority ladder, and handles the midnight split. Gated
// to SLEEP_DURATION only so no other metric's status path changes.
const isSleep = meta.measurementType === "SLEEP_DURATION";
// v1.2.5 — the day bucketing below keys on the user's own
// calendar regardless of branch. `userTz` was already resolved above for
// the cache day-key.
const points: Array<{ measuredAt: Date; value: number }> = [];
let measurements: Array<{ measuredAt: Date }> = [];
if (isSleep) {
// The latest 365 nights, the sleep twin of the 365 latest readings the
// other types read (`readRecentReadings`). A cut on stage rows instead (2190 of them) reached
// back a few weeks on a stage-rich watch and began mid-night.
const nights = (
await readSleepNights(
userId,
userTz,
await loadUserSourcePriority(userId),
{ maxNights: 365, newest: true },
)
).filter((n) => n.asleepMinutes > 0);
for (const n of nights) {
points.push({ measuredAt: n.measuredAt, value: n.asleepMinutes });
}
measurements = nights.map((n) => ({ measuredAt: n.measuredAt }));
} else {
// The latest 365 readings, never less than the last 30 days.
const rows = await readRecentReadings(userId, meta.measurementType);
for (const m of rows) {
points.push({
measuredAt: m.measuredAt,
value: reading.toDisplay(m.value),
});
}
measurements = rows.map((m) => ({ measuredAt: m.measuredAt }));
}
return { points, measurements };
}
/**
* The reference band for this profile and the finished signal.
*/
function metricRangeAndSignal(args: {
meta: MetricStatusMeta;
shownUnit: string;
reading: ReadingDisplay;
graded: GradedSeries;
newestDaysAgo: number | null;
dateOfBirth: Date | null;
gender: string | null;
}) {
const { meta, reading, graded, newestDaysAgo } = args;
const ageYears = getAgeFromDateOfBirth(args.dateOfBirth);
const sex = binaryReferenceSex(args.gender);
// v1.10.0 — sharpen the coarse registry `normalRange` with the
// age/sex reference-range enabler when one is available for this
// metric + profile; otherwise keep the existing flat anchor (strictly
// additive, never a regression). Age + sex only — no ancestry/region.
const sharpenedRange = lookupNormalRange(meta.id, ageYears, sex);
const canonicalRange = sharpenedRange ?? meta.normalRange;
const normalRange = canonicalRange
? {
...canonicalRange,
low: reading.toDisplay(canonicalRange.low),
high: reading.toDisplay(canonicalRange.high),
}
: canonicalRange;
// v1.13.x — the SIGNAL block: the recent-vs-baseline comparison + the
// normal-swing verdict, computed server-side so the model states it rather
// than re-deriving it from the raw buckets (research-ai-quality.md §4/§5C).
// Built from the same graded series the snapshot already carries; null only
// when the recent window is empty, in which case it is omitted.
const signal = buildMetricSignal({
metric: meta.displayName,
unit: args.shownUnit,
direction: meta.direction,
graded,
normalRange: normalRange ?? null,
normalRangeSource: sharpenedRange ? "age-sex-adjusted" : undefined,
newestDaysAgo,
});
return { ageYears, sex, sharpenedRange, normalRange, signal };
}
type ReadingDisplay = ReturnType<typeof readingDisplay>;
/**
* How one metric's numbers are written for this reader: the unit symbol, the
* conversions in both directions, and the decimals a value is read at.
+8 -10
View File
@@ -283,16 +283,14 @@ export const MOOD_CROSS_METRIC_ROW_CAP = 50_000;
// --- Target bands (mirror mood-chart VALUE_BANDS + mood-status) ---
/**
* The mood-status generator grades "in target" as score >= 3.5. The
* mood-chart paints three reference bands (1–2 red, 2–3 orange, 3–5
* green). We keep both anchored here so the heatmap colour map and the
* in-target headline never drift from the line chart.
*/
export const MOOD_GREEN_MIN = 3.5;
export const MOOD_GREEN_MAX = 5;
export const MOOD_ORANGE_MIN = 2;
export const MOOD_ORANGE_MAX = 3.5;
// Defined in a leaf module so the per-domain modules this hub re-exports can
// read them without importing the hub back.
export {
MOOD_GREEN_MAX,
MOOD_GREEN_MIN,
MOOD_ORANGE_MAX,
MOOD_ORANGE_MIN,
} from "@/lib/insights/mood-bands";
// --- Pair two daily-bucket series on dayOffset (lifted from mood-status) ---
+12
View File
@@ -0,0 +1,12 @@
/**
* The mood scale's target bands (mirror mood-chart VALUE_BANDS + mood-status).
*
* The mood-status generator grades "in target" as score >= 3.5. The
* mood-chart paints three reference bands (1–2 red, 2–3 orange, 3–5
* green). We keep both anchored here so the heatmap colour map and the
* in-target headline never drift from the line chart.
*/
export const MOOD_GREEN_MIN = 3.5;
export const MOOD_GREEN_MAX = 5;
export const MOOD_ORANGE_MIN = 2;
export const MOOD_ORANGE_MAX = 3.5;
+4 -5
View File
@@ -11,11 +11,10 @@
*/
import { dayOffsetToDayKey } from "@/lib/insights/bucket-series";
import {
MOOD_GREEN_MAX,
MOOD_GREEN_MIN,
type DailyPoint,
type MoodAggregateEntry,
import { MOOD_GREEN_MAX, MOOD_GREEN_MIN } from "@/lib/insights/mood-bands";
import type {
DailyPoint,
MoodAggregateEntry,
} from "@/lib/insights/mood-aggregates";
import { round } from "@/lib/insights/status-shared";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
+313 -446
View File
@@ -1,19 +1,12 @@
import { prisma } from "@/lib/db";
import { getMoodSystemPrompt, getMoodUserPrompt } from "@/lib/ai/prompts/mood";
import { openerArchetypeHint } from "@/lib/ai/prompts/opener-archetype";
import type { Locale } from "@/lib/i18n/config";
import { getNoKeyMoodStatusText } from "@/lib/insights/no-key-fallbacks";
import {
formatPreviousContextForPrompt,
getPreviousInsightContext,
} from "@/lib/insights/memory";
import {
buildAssessmentContextBlock,
computeSteadyRun,
pickVarietyLead,
} from "@/lib/insights/assessment-context";
import { computeSteadyRun } from "@/lib/insights/assessment-context";
import { significantPearsonCorrelation } from "@/lib/analytics/correlations";
import { applyPayloadBudget } from "@/lib/insights/bucket-series";
import {
applyPayloadBudget,
type DailyBucket,
} from "@/lib/insights/bucket-series";
import {
type TagInfluenceRow,
MOOD_GREEN_MAX,
@@ -34,37 +27,26 @@ import {
pairDailyBuckets,
} from "@/lib/insights/mood-aggregates";
import { computeMoodNarratives } from "@/lib/insights/mood-narratives";
import {
buildGradedSeriesFromPoints,
degradeStatusSnapshotToBudget,
} from "@/lib/insights/graded-series";
import { statusSnapshotCharsForRecord } from "@/lib/ai/capabilities/usage";
import { buildGradedSeriesFromPoints } from "@/lib/insights/graded-series";
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 {
coverageSpan,
prepareStatusCard,
textStatusCodec,
type StatusCardBuild,
type StatusCardContext,
type StatusCardOptions,
type StatusCardPlugin,
type StatusTextResult,
} from "@/lib/insights/status-pipeline";
import { annotate } from "@/lib/logging/context";
import { createCustomLabelResolver } from "@/lib/mood/custom-tags";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import { resolveUserTimezone, userDayKey } from "@/lib/tz/resolver";
/**
* Drop the ranking-only `pooledSd` before a tag-influence row enters the
@@ -127,117 +109,212 @@ export function crossMetricDailySeries(
*/
export async function generateMoodStatusForUser(
userId: string,
options?: {
locale?: string | null;
force?: boolean;
/** v1.8.3 — read-only navigation path; see weight-status for the rationale. */
readOnly?: boolean;
},
): Promise<{
hasProvider: boolean;
text: string | null;
cached: boolean;
updatedAt: string | null;
preparing?: boolean;
/** v1.9.0 — last-good text served while a refresh is in flight; keep polling. */
revalidating?: boolean;
}> {
options?: StatusCardOptions,
): Promise<StatusTextResult> {
const prepared = await prepareMoodStatusForUser(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;
}
/**
* Everything up to (not including) the provider call, through the shared
* status pipeline (`status-pipeline.ts`); this card supplies its snapshot.
*/
export function prepareMoodStatusForUser(
userId: string,
options?: StatusCardOptions,
): Promise<PreparedStatusCard> {
return prepareStatusCard(MOOD_STATUS_CARD, userId, options);
}
type MoodStatusEntry = Awaited<
ReturnType<typeof readMoodEntriesForStatus>
>[number];
type CrossMetricMeasurement = Awaited<
ReturnType<typeof readCrossMetricMeasurements>
>[number];
/**
* The mood snapshot: the mood series against its band, the tag and factor
* boards the mood page shows, the narrative feed, and the cross-metric
* context.
*/
async function buildMoodSnapshot(
ctx: StatusCardContext,
): Promise<StatusCardBuild<undefined>> {
const { userId, locale, userTz, todayKey } = ctx;
const entries = await readMoodEntriesForStatus(userId);
const now = new Date();
// v1.2.5 — every day-bucketing pass below keys on the user's own
// calendar day. `userTz` was already resolved above for the cache day-key.
const moodPoints = entries.map((entry) => ({
measuredAt: entry.moodLoggedAt,
value: entry.score,
}));
// `applyPayloadBudget` daily buckets drive the in-target %, latest,
// and correlations; the compact graded series reaches the prompt.
const moodSeries = applyPayloadBudget(moodPoints, { now, tz: userTz });
const moodGraded = buildGradedSeriesFromPoints(moodPoints, now, userTz);
const moodSummary = summarizeSeries(
moodSeries.daily.map((bucket) => ({ value: bucket.value })),
);
const greenMin = MOOD_GREEN_MIN;
const greenMax = MOOD_GREEN_MAX;
const orangeMin = MOOD_ORANGE_MIN;
const orangeMax = MOOD_ORANGE_MAX;
// "Last 30 daily points" = the newest 30 daily buckets (offsets 0..29).
const inTargetPctLast30DailyPoints = computeInTargetPct(moodSeries.daily);
// daily[0] = newest bucket (lowest dayOffset).
const latestMood = moodSeries.daily[0] ?? null;
const previousMood = moodSeries.daily[1] ?? null;
const { totalSpanDays, newestDaysAgo: newestEntryDaysAgo } = coverageSpan(
entries.length > 0 ? entries[0].moodLoggedAt : null,
entries.length > 0 ? entries[entries.length - 1].moodLoggedAt : null,
);
const measurements = await readCrossMetricMeasurements(userId, now);
const relations = await computeMoodRelations({
userId,
entries,
measurements,
moodDaily: moodSeries.daily,
inTargetPctLast30DailyPoints,
now,
userTz,
});
// v1.18.10 — hand the model the finished recent-vs-baseline
// comparison + normal-swing verdict instead of asking it to derive them.
// Mood reads higher-better against the green band.
const moodSignal = buildMetricSignal({
metric: locale === "en" ? "your mood" : "deine Stimmung",
unit: "/5",
direction: "higher-better",
graded: moodGraded,
normalRange: { low: greenMin, high: greenMax },
newestDaysAgo: newestEntryDaysAgo,
});
const snapshot = {
locale,
generatedForDay: todayKey,
focus: "mood",
dataCoverage: {
totalEntries: entries.length,
totalSpanDays,
newestEntryDaysAgo,
},
mood: {
...(moodSignal ? { signal: moodSignal } : {}),
summary: moodSummary,
series: moodGraded,
latestDayFocus: latestMood
? {
dayOffset: latestMood.dayOffset,
value: latestMood.value,
deltaToPreviousDailyPoint:
previousMood == null
? null
: round(latestMood.value - previousMood.value, 2),
}
: null,
target: {
greenMin,
greenMax,
orangeMin,
orangeMax,
inTargetPctLast30DailyPoints,
},
tags: relations.tagSummary.length > 0 ? relations.tagSummary : null,
// v1.11.5 — with-vs-without tag influence (only the populated
// axes are emitted so the prompt stays compact).
tagInfluence:
relations.tagInfluence.flat.length > 0 ||
relations.tagInfluence.structured.length > 0
? {
flat:
relations.tagInfluence.flat.length > 0
? relations.tagInfluence.flat.map(stripRankingOnly)
: null,
structured:
relations.tagInfluence.structured.length > 0
? relations.tagInfluence.structured.map(stripRankingOnly)
: null,
}
: null,
// v1.11.5 — the ranked "better days" board the user sees, so the
// model's relations prose matches the on-screen list (shown == sent).
betterDays: relations.betterDays.length > 0 ? relations.betterDays : null,
// v1.14.0 — RATED-factor → vital deviation rows (low-vs-high-day Welch
// delta, FDR-gated). Lets the model say "your data shows sleep runs ~X
// shorter on days you rate work low (n=N, association not cause)". Only
// the surviving rows reach the prompt; an empty board emits null.
factorCrosstab:
relations.factorCrosstab.length > 0
? relations.factorCrosstab.map(stripFactorCrosstab)
: null,
narratives: relations.narratives.length > 0 ? relations.narratives : null,
// v1.9.0 — only emit the daypart pattern when it cleared its
// spread/sample floors, so the model never reasons over a once-a-day
// logger's single-bucket artefact.
timeOfDay: relations.timeOfDay.reliable
? {
buckets: relations.timeOfDay.buckets,
best: relations.timeOfDay.best,
worst: relations.timeOfDay.worst,
}
: null,
stability: relations.stability,
},
crossMetricContext: moodCrossMetricContext(relations),
};
// MOOD is mood-entry backed (not a MeasurementType), so the cross-metric
// RELATIONS sub-block is sourced from the snapshot's own crossMetricContext
// rather than the measurement discovery engine; relations stays empty here
// and the variety / data-strength / repetition signals carry the rotation.
return {
snapshot,
finish: () => ({
context: {
repeatCount: computeSteadyRun(moodGraded.weekly, moodGraded.monthly),
points: moodSeries.daily.length,
newestDaysAgo: newestEntryDaysAgo,
relationsFor: null,
},
fallbackText: getNoKeyMoodStatusText(locale, moodSignal),
prompts: (snapshotJson, context) => ({
systemPrompt: getMoodSystemPrompt(locale),
userPrompt: getMoodUserPrompt(
snapshotJson,
todayKey,
locale,
context.previousContextBlock,
context.assessmentContextBlock,
context.openerHint,
),
}),
detail: undefined,
}),
};
}
/**
* v1.18.7 — everything up to (not including) the provider call.
* Returns a finished `served` result for every path that never needed the
* LLM, or a `pending` descriptor the single-card path and the batch path
* both drive. See `status-card-generation.ts` for the contract.
* The latest 90 mood entries, oldest first, with their structured tags and
* their rated factors.
*/
export async function prepareMoodStatusForUser(
userId: string,
options?: {
locale?: string | null;
force?: boolean;
/** v1.8.3 — read-only navigation path; see weight-status for the rationale. */
readOnly?: boolean;
},
): Promise<PreparedStatusCard> {
const locale = normalizeLocale(options?.locale);
const force = options?.force === true;
const readOnly = options?.readOnly === true;
const cacheAction = statusCacheAction("mood", 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: "mood",
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: getNoKeyMoodStatusText(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,
},
};
}
function readMoodEntriesForStatus(userId: string) {
// v1.4.28 — cap at 90 entries (~3 months) for the mood
// surface. The downstream `applyPayloadBudget` slims further.
// Order desc + reverse so the per-row consumers downstream (which
// expect oldest-first) see the same shape.
const entries = await prisma.moodEntry
return prisma.moodEntry
.findMany({
where: {
userId,
@@ -318,50 +395,13 @@ export async function prepareMoodStatusForUser(
})),
}));
});
}
const now = new Date();
// v1.2.5 — every day-bucketing pass below keys on the user's own
// calendar day. `userTz` was already resolved above for the cache day-key.
const moodPoints = entries.map((entry) => ({
measuredAt: entry.moodLoggedAt,
value: entry.score,
}));
// `applyPayloadBudget` daily buckets drive the in-target %, latest,
// and correlations; the compact graded series reaches the prompt.
const moodSeries = applyPayloadBudget(moodPoints, { now, tz: userTz });
const moodGraded = buildGradedSeriesFromPoints(moodPoints, now, userTz);
const moodSummary = summarizeSeries(
moodSeries.daily.map((bucket) => ({ value: bucket.value })),
);
const greenMin = MOOD_GREEN_MIN;
const greenMax = MOOD_GREEN_MAX;
const orangeMin = MOOD_ORANGE_MIN;
const orangeMax = MOOD_ORANGE_MAX;
// "Last 30 daily points" = the newest 30 daily buckets (offsets 0..29).
const inTargetPctLast30DailyPoints = computeInTargetPct(moodSeries.daily);
// daily[0] = newest bucket (lowest dayOffset).
const latestMood = moodSeries.daily[0] ?? null;
const previousMood = moodSeries.daily[1] ?? null;
const oldestEntry = entries.length > 0 ? entries[0].moodLoggedAt : null;
const newestEntry =
entries.length > 0 ? entries[entries.length - 1].moodLoggedAt : null;
const totalSpanDays =
oldestEntry && newestEntry
? Math.round(
(newestEntry.getTime() - oldestEntry.getTime()) /
(24 * 60 * 60 * 1000),
)
: 0;
const newestEntryDaysAgo = newestEntry
? Math.round((Date.now() - newestEntry.getTime()) / (24 * 60 * 60 * 1000))
: null;
/**
* The cross-metric measurements the correlations and the factor crosstab
* pair against, bounded to the ~365-day window they can pair in.
*/
async function readCrossMetricMeasurements(userId: string, now: Date) {
// Fetch cross-metric context for enrichment. v1.4.28 FB-D2 — cap at
// 1095 = 365 d × 3 channels so power users do not pull unbounded
// rows. Order desc + reverse so the downstream filters see oldest-
@@ -415,6 +455,33 @@ export async function prepareMoodStatusForUser(
annotate({ meta: { cross_metric_truncated: true } });
}
return measurements;
}
/**
* Mood against the other metrics and its own tags: the correlations, the
* tag summaries and influence, the "better days" board, the factor
* crosstab, the time-of-day pattern, stability and the narrative feed — the
* same aggregates the visible mood page shows.
*/
async function computeMoodRelations(args: {
userId: string;
entries: MoodStatusEntry[];
measurements: CrossMetricMeasurement[];
moodDaily: DailyBucket[];
inTargetPctLast30DailyPoints: number | null;
now: Date;
userTz: string;
}) {
const {
userId,
entries,
measurements,
inTargetPctLast30DailyPoints,
now,
userTz,
} = args;
const moodSeries = { daily: args.moodDaily };
const weightSeries = crossMetricDailySeries(
measurements,
"WEIGHT",
@@ -540,275 +607,75 @@ export async function prepareMoodStatusForUser(
tz: userTz,
});
// v1.18.10 — hand the model the finished recent-vs-baseline
// comparison + normal-swing verdict instead of asking it to derive them.
// Mood reads higher-better against the green band.
const moodSignal = buildMetricSignal({
metric: locale === "en" ? "your mood" : "deine Stimmung",
unit: "/5",
direction: "higher-better",
graded: moodGraded,
normalRange: { low: greenMin, high: greenMax },
newestDaysAgo: newestEntryDaysAgo,
});
const snapshot = {
locale,
generatedForDay: todayKey,
focus: "mood",
dataCoverage: {
totalEntries: entries.length,
totalSpanDays,
newestEntryDaysAgo,
},
mood: {
...(moodSignal ? { signal: moodSignal } : {}),
summary: moodSummary,
series: moodGraded,
latestDayFocus: latestMood
? {
dayOffset: latestMood.dayOffset,
value: latestMood.value,
deltaToPreviousDailyPoint:
previousMood == null
? null
: round(latestMood.value - previousMood.value, 2),
}
: null,
target: {
greenMin,
greenMax,
orangeMin,
orangeMax,
inTargetPctLast30DailyPoints,
},
tags: tagSummary.length > 0 ? tagSummary : null,
// v1.11.5 — with-vs-without tag influence (only the populated
// axes are emitted so the prompt stays compact).
tagInfluence:
tagInfluence.flat.length > 0 || tagInfluence.structured.length > 0
? {
flat:
tagInfluence.flat.length > 0
? tagInfluence.flat.map(stripRankingOnly)
: null,
structured:
tagInfluence.structured.length > 0
? tagInfluence.structured.map(stripRankingOnly)
: null,
}
: null,
// v1.11.5 — the ranked "better days" board the user sees, so the
// model's relations prose matches the on-screen list (shown == sent).
betterDays: betterDays.length > 0 ? betterDays : null,
// v1.14.0 — RATED-factor → vital deviation rows (low-vs-high-day Welch
// delta, FDR-gated). Lets the model say "your data shows sleep runs ~X
// shorter on days you rate work low (n=N, association not cause)". Only
// the surviving rows reach the prompt; an empty board emits null.
factorCrosstab:
factorCrosstab.length > 0
? factorCrosstab.map(stripFactorCrosstab)
: null,
narratives: narratives.length > 0 ? narratives : null,
// v1.9.0 — only emit the daypart pattern when it cleared its
// spread/sample floors, so the model never reasons over a once-a-day
// logger's single-bucket artefact.
timeOfDay: timeOfDay.reliable
? {
buckets: timeOfDay.buckets,
best: timeOfDay.best,
worst: timeOfDay.worst,
}
: null,
stability,
},
crossMetricContext:
weightSeries.daily.length >= 3 ||
sysSeries.daily.length >= 3 ||
pulseSeries.daily.length >= 3
? {
weight:
weightSeries.daily.length >= 3
? {
summary: summarizeSeries(
weightSeries.daily.map((bucket) => ({
value: bucket.value,
})),
),
correlation: moodVsWeightCorrelation,
}
: null,
bloodPressureSystolic:
sysSeries.daily.length >= 3
? {
summary: summarizeSeries(
sysSeries.daily.map((bucket) => ({
value: bucket.value,
})),
),
correlation: moodVsSysCorrelation,
}
: null,
pulse:
pulseSeries.daily.length >= 3
? {
summary: summarizeSeries(
pulseSeries.daily.map((bucket) => ({
value: bucket.value,
})),
),
correlation: moodVsPulseCorrelation,
}
: null,
}
: 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,
"mood-status",
locale,
12,
);
const previousContextBlock = formatPreviousContextForPrompt(
previousContext,
locale,
);
// v1.12.7 — diversity / anti-repetition block (see blood-pressure-status).
// MOOD is mood-entry backed (not a MeasurementType), so the cross-metric
// RELATIONS sub-block is sourced from the snapshot's own crossMetricContext
// rather than the measurement discovery engine; relations stays empty here
// and the variety / data-strength / repetition signals carry the rotation.
const varietyLead = pickVarietyLead(userId, "mood", todayKey);
const steadyRun = computeSteadyRun(moodGraded.weekly, moodGraded.monthly);
const assessmentContextBlock = buildAssessmentContextBlock(
{
varietyLead,
dataStrength: {
points: moodSeries.daily.length,
newestDaysAgo: newestEntryDaysAgo,
},
repeatCount: steadyRun,
relations: [],
},
locale,
);
// The provider call is deferred to the caller — the single-card path runs
// ONE completion, the batch path folds this prompt into one shared call.
// A timeout / error is a transient miss (fallback served, no assessment
// persisted); `none` (no provider / no consent) serves the no-key text.
return {
phase: "pending",
metric: "mood",
userId,
cacheAction,
systemPrompt: getMoodSystemPrompt(locale),
userPrompt: getMoodUserPrompt(
snapshotJson,
todayKey,
locale,
previousContextBlock,
assessmentContextBlock,
// v1.28.40 — rotating opener hint, per (user, metric, day).
openerArchetypeHint(`${userId}:mood:${todayKey}`, locale as Locale),
),
snapshotHash,
// v1.12.7 — match the archetype cards' 0.45.
temperature: 0.45,
noProvider: {
hasProvider: false,
text: getNoKeyMoodStatusText(locale, moodSignal),
cached: true,
updatedAt: null,
},
timeout: (reason): StatusCardResult =>
returnTimeoutFallback({
cacheAction,
reason,
userId,
todayKey,
stubText: getNoKeyMoodStatusText(locale, moodSignal),
}),
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: getNoKeyMoodStatusText(locale, moodSignal),
});
}
const summary = screened.text;
if (!summary) {
throw new Error("Mood-status summary was empty after normalization");
}
const updatedAt = await persistStatusInsight({
userId,
cacheAction,
todayKey,
text: summary,
snapshotHash,
});
return {
hasProvider: true,
text: summary,
cached: false,
updatedAt,
};
},
weightSeries,
sysSeries,
pulseSeries,
moodVsWeightCorrelation,
moodVsSysCorrelation,
moodVsPulseCorrelation,
tagSummary,
tagInfluence,
betterDays,
factorCrosstab,
timeOfDay,
stability,
narratives,
};
}
export function resolveMoodStatusLocale(
locale: string | null | undefined,
): SupportedLocale {
return normalizeLocale(locale);
/**
* The cross-metric block: each of weight, systolic and pulse with at least
* three daily points, summarised with its mood correlation.
*/
function moodCrossMetricContext(
r: Awaited<ReturnType<typeof computeMoodRelations>>,
) {
return r.weightSeries.daily.length >= 3 ||
r.sysSeries.daily.length >= 3 ||
r.pulseSeries.daily.length >= 3
? {
weight:
r.weightSeries.daily.length >= 3
? {
summary: summarizeSeries(
r.weightSeries.daily.map((bucket) => ({
value: bucket.value,
})),
),
correlation: r.moodVsWeightCorrelation,
}
: null,
bloodPressureSystolic:
r.sysSeries.daily.length >= 3
? {
summary: summarizeSeries(
r.sysSeries.daily.map((bucket) => ({
value: bucket.value,
})),
),
correlation: r.moodVsSysCorrelation,
}
: null,
pulse:
r.pulseSeries.daily.length >= 3
? {
summary: summarizeSeries(
r.pulseSeries.daily.map((bucket) => ({
value: bucket.value,
})),
),
correlation: r.moodVsPulseCorrelation,
}
: null,
}
: null;
}
const MOOD_STATUS_CARD: StatusCardPlugin<StatusTextResult, undefined> = {
scope: "mood",
codec: textStatusCodec,
unavailableText: (locale) => getNoKeyMoodStatusText(locale),
emptyMessage: "Mood-status summary was empty after normalization",
build: buildMoodSnapshot,
};
+110 -322
View File
@@ -5,18 +5,7 @@ import {
getPulseSystemPrompt,
getPulseUserPrompt,
} from "@/lib/ai/prompts/pulse";
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 {
getAgeFromDateOfBirth,
getPersonalizedPulseTarget,
@@ -27,34 +16,25 @@ import { applyPayloadBudget } from "@/lib/insights/bucket-series";
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 {
coverageSpan,
prepareStatusCard,
textStatusCodec,
type StatusCardBuild,
type StatusCardContext,
type StatusCardOptions,
type StatusCardPlugin,
type StatusTextResult,
} from "@/lib/insights/status-pipeline";
import { binaryReferenceSex } from "@/lib/profile/sex";
import { resolveUserTimezone, userDayKey } from "@/lib/tz/resolver";
import { userDayKey } from "@/lib/tz/resolver";
/**
* Public entry — unchanged signature. Prepares the card (cache-read,
@@ -65,112 +45,31 @@ import { resolveUserTimezone, userDayKey } from "@/lib/tz/resolver";
*/
export async function generatePulseStatusForUser(
userId: string,
options?: {
locale?: string | null;
force?: boolean;
/** v1.8.3 — read-only navigation path; see weight-status for the rationale. */
readOnly?: boolean;
},
): Promise<{
hasProvider: boolean;
text: string | null;
cached: boolean;
updatedAt: string | null;
preparing?: boolean;
/** v1.9.0 — last-good text served while a refresh is in flight; keep polling. */
revalidating?: boolean;
}> {
options?: StatusCardOptions,
): Promise<StatusTextResult> {
const prepared = await preparePulseStatusForUser(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.
* Returns a finished `served` result for every path that never needed the
* LLM, or a `pending` descriptor the single-card path and the batch path
* both drive. See `status-card-generation.ts` for the 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 preparePulseStatusForUser(
export function preparePulseStatusForUser(
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("pulse", 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. This 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: "pulse",
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: getNoKeyPulseStatusText(locale),
cached: true,
updatedAt: null,
},
};
}
// v1.8.7 — stale-while-revalidate: serve the last good assessment (if
// any) instantly while the worker re-warms the cache; 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(PULSE_STATUS_CARD, userId, options);
}
/**
* The resting-pulse snapshot: the PULSE stream graded for the prompt, the
* resting band judged against the RESTING series, and the mood context.
*/
async function buildPulseSnapshot(
ctx: StatusCardContext,
): Promise<StatusCardBuild<undefined>> {
const { userId, locale, userTz, todayKey } = ctx;
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
@@ -257,62 +156,25 @@ export async function preparePulseStatusForUser(
binaryReferenceSex(user?.gender),
);
// v1.15.12 — resting-target in-target % over the RESTING series
// (preferring RESTING_HEART_RATE, else the low-percentile PULSE proxy),
// NOT the raw PULSE daily buckets which mix in workout HR. Scoring the
// raw stream against a resting band counted a workout day as "over
// target" and tanked the %.
const restingResolved = resolveRestingPulseSeries({
restingSamples: restingMeasurements.map((m) => ({
measuredAt: m.measuredAt,
value: m.value,
})),
pulseSamples: measurements.map((m) => ({
measuredAt: m.measuredAt,
value: m.value,
})),
dayKeyOf: (d: Date) => userDayKey(d, userTz),
const inTargetPctLast30DailyPoints = restingInTargetPct({
restingMeasurements,
measurements,
target: pulseTarget,
now,
userTz,
});
const thirtyDaysAgoMs = now.getTime() - 30 * 24 * 60 * 60 * 1000;
const recentResting = restingResolved.series.filter(
(p) => p.measuredAt.getTime() >= thirtyDaysAgoMs,
);
const inTargetPctLast30DailyPoints =
recentResting.length === 0
? null
: round(
(recentResting.filter(
(entry) =>
entry.value >= pulseTarget.greenMin &&
entry.value <= pulseTarget.greenMax,
).length /
recentResting.length) *
100,
1,
);
// Daily buckets are sorted oldest-first by dayOffset (lower = newer).
const latestPulse = pulseSeries.daily[0] ?? null;
const previousPulse = pulseSeries.daily[1] ?? null;
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;
const { totalSpanDays, newestDaysAgo: newestMeasurementDaysAgo } =
coverageSpan(
measurements.length > 0 ? measurements[0].measuredAt : null,
measurements.length > 0
? measurements[measurements.length - 1].measuredAt
: null,
);
// v1.18.10 — feed the model the finished recent-vs-baseline
// comparison + normal-swing verdict instead of asking it to derive them from
@@ -369,152 +231,78 @@ export async function preparePulseStatusForUser(
: 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,
"pulse-status",
locale,
12,
);
const previousContextBlock = formatPreviousContextForPrompt(
previousContext,
locale,
);
// v1.12.7 — diversity / anti-repetition block (see blood-pressure-status).
const varietyLead = pickVarietyLead(userId, "pulse", todayKey);
const steadyRun = computeSteadyRun(pulseGraded.weekly, pulseGraded.monthly);
const relations = await getRelevantCorrelationsForMetric(
userId,
"PULSE",
locale,
);
const assessmentContextBlock = buildAssessmentContextBlock(
{
varietyLead,
dataStrength: {
return {
snapshot,
finish: () => ({
context: {
repeatCount: computeSteadyRun(pulseGraded.weekly, pulseGraded.monthly),
points: pulseSeries.daily.length,
newestDaysAgo: newestMeasurementDaysAgo,
relationsFor: "PULSE",
},
repeatCount: steadyRun,
relations,
},
locale,
);
// The provider call is deferred to the caller — the single-card path runs
// ONE completion, the batch path folds this prompt into one shared call.
// A timeout / error is a transient miss (fallback served, no assessment
// persisted); `none` (no provider / no consent) serves the no-key text.
return {
phase: "pending",
metric: "pulse",
userId,
cacheAction,
systemPrompt: getPulseSystemPrompt(locale),
userPrompt: getPulseUserPrompt(
snapshotJson,
todayKey,
locale,
previousContextBlock,
assessmentContextBlock,
// v1.28.40 — rotating opener hint, per (user, metric, day).
openerArchetypeHint(`${userId}:pulse:${todayKey}`, locale as Locale),
),
snapshotHash,
// v1.12.7 — match the archetype cards' 0.45.
temperature: 0.45,
noProvider: {
hasProvider: false,
text: getNoKeyPulseStatusText(locale, pulseSignal),
cached: true,
updatedAt: null,
},
timeout: (reason): StatusCardResult =>
returnTimeoutFallback({
cacheAction,
reason,
userId,
todayKey,
stubText: getNoKeyPulseStatusText(locale, pulseSignal),
fallbackText: getNoKeyPulseStatusText(locale, pulseSignal),
prompts: (snapshotJson, context) => ({
systemPrompt: getPulseSystemPrompt(locale),
userPrompt: getPulseUserPrompt(
snapshotJson,
todayKey,
locale,
context.previousContextBlock,
context.assessmentContextBlock,
context.openerHint,
),
}),
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: getNoKeyPulseStatusText(locale, pulseSignal),
});
}
const summary = screened.text;
if (!summary) {
throw new Error("Pulse-status summary was empty after normalization");
}
const updatedAt = await persistStatusInsight({
userId,
cacheAction,
todayKey,
text: summary,
snapshotHash,
});
return {
hasProvider: true,
text: summary,
cached: false,
updatedAt,
};
},
detail: undefined,
}),
};
}
export function resolvePulseStatusLocale(
locale: string | null | undefined,
): SupportedLocale {
return normalizeLocale(locale);
/**
* Resting-target in-target % over the RESTING series (preferring
* RESTING_HEART_RATE, else the low-percentile PULSE proxy), NOT the raw PULSE
* daily buckets which mix in workout HR. Scoring the raw stream against a
* resting band counted a workout day as "over target" and tanked the %.
*/
function restingInTargetPct(args: {
restingMeasurements: ReadonlyArray<{ measuredAt: Date; value: number }>;
measurements: ReadonlyArray<{ measuredAt: Date; value: number }>;
target: { greenMin: number; greenMax: number };
now: Date;
userTz: string;
}): number | null {
const { target, now, userTz } = args;
const restingResolved = resolveRestingPulseSeries({
restingSamples: args.restingMeasurements.map((m) => ({
measuredAt: m.measuredAt,
value: m.value,
})),
pulseSamples: args.measurements.map((m) => ({
measuredAt: m.measuredAt,
value: m.value,
})),
dayKeyOf: (d: Date) => userDayKey(d, userTz),
});
const thirtyDaysAgoMs = now.getTime() - 30 * 24 * 60 * 60 * 1000;
const recentResting = restingResolved.series.filter(
(p) => p.measuredAt.getTime() >= thirtyDaysAgoMs,
);
return recentResting.length === 0
? null
: round(
(recentResting.filter(
(entry) =>
entry.value >= target.greenMin && entry.value <= target.greenMax,
).length /
recentResting.length) *
100,
1,
);
}
const PULSE_STATUS_CARD: StatusCardPlugin<StatusTextResult, undefined> = {
scope: "pulse",
codec: textStatusCodec,
unavailableText: (locale) => getNoKeyPulseStatusText(locale),
emptyMessage: "Pulse-status summary was empty after normalization",
build: buildPulseSnapshot,
};
+1 -1
View File
@@ -13,7 +13,7 @@ import {
avgInWindow,
roundedSpread,
toDataPoints,
} from "@/lib/insights/features";
} from "@/lib/insights/feature-window";
import {
SAME_HOURS_MIN_DAYS,
SAME_HOURS_TYPES,
+8 -2
View File
@@ -75,9 +75,15 @@ export interface PendingStatusCard {
systemPrompt: string;
userPrompt: string;
/** Snapshot fingerprint — persisted so the next run's hash gate can skip. */
snapshotHash: string;
snapshotHash?: string;
/** Per-card sampling temperature (the cards run a touch warmer at 0.45). */
temperature: number;
/**
* The answer budget for the single-card call; `AI_BUDGETS.status` when
* omitted. The archetype cards (generic metric, biomarker) compose a longer
* prompt and pass `AI_BUDGETS.statusArchetype`.
*/
maxTokens?: number;
/**
* The deterministic no-key / no-consent result. The provider call returns
* `{ kind: "none" }` for both, and the prepare step already knows the
@@ -129,7 +135,7 @@ export async function runPreparedStatusCard(
systemPrompt: prepared.systemPrompt,
userPrompt: prepared.userPrompt,
temperature: prepared.temperature,
maxTokens: AI_BUDGETS.status.maxTokens,
maxTokens: prepared.maxTokens ?? AI_BUDGETS.status.maxTokens,
});
if (outcome.kind === "none") {
+613
View File
@@ -0,0 +1,613 @@
/**
* The pipeline every per-card status generator runs, in stages.
*
* A card is a plug-in: what it reads and how it shapes that into a snapshot,
* the prompts it sends, and, for the one card that stores more than a note,
* how its note is stored and what its result looks like. Everything around
* that is the same for every card and lives here:
*
* openStatusCard locale, note name, the reader's day
* readCachedStatus today's note, when there is one
* resolveReadOnlyStatus a navigation read never waits on a provider
* gateUnchangedInputs slow-moving inputs unchanged: re-date the note
* sealStatusSnapshot budget, serialise, fingerprint
* refreshUnchangedStatus snapshot unchanged: re-date the note
* buildStatusPromptContext previous note, variety lead, data strength,
* relations, opener hint
* pendingStatusCard the descriptor the single-card path
* (`runPreparedStatusCard`) and the batch drive
*
* `prepareStatusCard` runs them in order for a plug-in. The generic metric
* card and the biomarker card compose the same stages themselves, because
* each puts an empty-data gate between the cache read and the read-only path.
*/
import type { MeasurementType } from "@/generated/prisma/client";
import { openerArchetypeHint } from "@/lib/ai/prompts/opener-archetype";
import { statusSnapshotCharsForRecord } from "@/lib/ai/capabilities/usage";
import type { Locale } from "@/lib/i18n/config";
import {
buildAssessmentContextBlock,
pickVarietyLead,
} from "@/lib/insights/assessment-context";
import { degradeStatusSnapshotToBudget } from "@/lib/insights/graded-series";
import {
formatPreviousContextForPrompt,
getPreviousInsightContext,
} from "@/lib/insights/memory";
import { getRelevantCorrelationsForMetric } from "@/lib/insights/metric-correlation-context";
import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash";
import {
gateUnchangedStatusInput,
readFreshStatusText,
refreshUnchangedStatusInsight,
resolveReadOnlyStatusMiss,
statusCacheAction,
type ReadOnlyMissOutcome,
} from "@/lib/insights/status-cache";
import type {
PendingStatusCard,
PreparedStatusCard,
StatusCardResult,
} from "@/lib/insights/status-card-generation";
import {
finalizeStatusSummary,
normalizeLocale,
persistStatusInsight,
type SupportedLocale,
} from "@/lib/insights/status-shared";
import {
returnTimeoutFallback,
type StatusFallbackReason,
} from "@/lib/insights/timeout-fallback";
import type {
InsightStatusMetric,
InsightStatusScope,
} from "@/lib/jobs/insight-status-generate-shared";
import { annotate } from "@/lib/logging/context";
import { resolveUserTimezone, userDayKey } from "@/lib/tz/resolver";
/** What every public generator entry accepts. */
export interface StatusCardOptions {
locale?: string | null;
force?: boolean;
/**
* Read-only mode for the navigation path. On a cache miss the generator
* does NOT run the SQL gather + blocking LLM inline; it enqueues an
* out-of-band generation and returns a `preparing` shape so the request
* never awaits the provider. The worker (and the nightly pre-generate cron)
* call with `readOnly: false` to actually generate.
*/
readOnly?: boolean;
}
/** The note-only result every card but medication compliance returns. */
export type StatusTextResult = {
hasProvider: boolean;
text: string | null;
cached: boolean;
updatedAt: string | null;
preparing?: boolean;
/** Last-good text served while a refresh is in flight; keep polling. */
revalidating?: boolean;
};
/** One card's run: who, which note, and the reader's day. */
export interface StatusCardContext<
S extends InsightStatusScope = InsightStatusScope,
> {
userId: string;
scope: S;
locale: SupportedLocale;
force: boolean;
readOnly: boolean;
/** `insights.<scope>.<locale>` — the note name and the consent-gate key. */
cacheAction: string;
userTz: string;
todayKey: string;
}
/** The fingerprints a written note carries for the next run's gates. */
export interface StatusNoteHashes {
snapshotHash?: string;
inputHash?: string;
}
/**
* How a card stores its note and shapes its result. Every card stores one
* note and answers `{ hasProvider, text, cached, updatedAt }`
* (`textStatusCodec`); medication compliance stores per-medication lines
* beside it and answers with them, so it brings its own codec. `I` is the
* card's per-run detail the codec needs on the way out (those lines).
*/
export interface StatusNoteCodec<R extends StatusCardResult, I> {
readFresh(ctx: StatusCardContext): Promise<R | null>;
refreshUnchanged(
ctx: StatusCardContext,
snapshotHash: string,
): Promise<R | null>;
readOnly(outcome: ReadOnlyMissOutcome, unavailableText: string): R;
noProvider(text: string): R;
fallback(
ctx: StatusCardContext,
reason: StatusFallbackReason,
stubText: string,
detail: I,
): R;
persist(
ctx: StatusCardContext,
text: string,
hashes: StatusNoteHashes,
detail: I,
): Promise<R>;
}
/** The standard codec: one encrypted note, a text-shaped result. */
export const textStatusCodec: StatusNoteCodec<StatusTextResult, undefined> = {
async readFresh(ctx) {
// Serve today's real assessment when present. The shared reader rejects
// timeout stubs, so a single stall no longer pins the fallback for the
// day.
const cached = await readFreshStatusText({
userId: ctx.userId,
cacheAction: ctx.cacheAction,
todayKey: ctx.todayKey,
force: ctx.force,
});
if (!cached) return null;
return {
hasProvider: true,
text: cached.text,
cached: true,
updatedAt: cached.updatedAt,
};
},
async refreshUnchanged(ctx, snapshotHash) {
const unchanged = await refreshUnchangedStatusInsight({
userId: ctx.userId,
cacheAction: ctx.cacheAction,
todayKey: ctx.todayKey,
snapshotHash,
});
if (!unchanged) return null;
return {
hasProvider: true,
text: unchanged.text,
cached: true,
updatedAt: unchanged.updatedAt,
};
},
readOnly(outcome, unavailableText) {
// Unavailable for any reason serves the deterministic line and enqueues
// nothing. `hasProvider` is provider presence only.
if (outcome.kind === "unavailable") {
return {
hasProvider: outcome.hasProvider,
text: unavailableText,
cached: true,
updatedAt: null,
};
}
// Stale-while-revalidate: serve the last good assessment (if any)
// instantly while the worker re-warms the cache; only fall to the empty
// preparing skeleton when none was ever produced.
return {
hasProvider: true,
text: outcome.lastGood?.text ?? null,
cached: outcome.lastGood !== null,
updatedAt: outcome.lastGood?.updatedAt ?? null,
preparing: outcome.lastGood === null,
revalidating: outcome.revalidating,
};
},
noProvider(text) {
return { hasProvider: false, text, cached: true, updatedAt: null };
},
fallback(ctx, reason, stubText) {
return returnTimeoutFallback({
cacheAction: ctx.cacheAction,
reason,
userId: ctx.userId,
todayKey: ctx.todayKey,
stubText,
});
},
async persist(ctx, text, hashes) {
const updatedAt = await persistStatusInsight({
userId: ctx.userId,
cacheAction: ctx.cacheAction,
todayKey: ctx.todayKey,
text,
snapshotHash: hashes.snapshotHash,
inputHash: hashes.inputHash,
});
return { hasProvider: true, text, cached: false, updatedAt };
},
};
/**
* Resolve the run: locale, the note name, and the reader's day. The user's
* own time zone is resolved BEFORE the day key so the cache rolls over at the
* user's local midnight, not Berlin's; it has to happen ahead of the cache
* read (the earliest possible return path), not just the later day-bucketing
* reads.
*/
export async function openStatusCard<S extends InsightStatusScope>(
userId: string,
scope: S,
options: StatusCardOptions | undefined,
): Promise<StatusCardContext<S>> {
const locale = normalizeLocale(options?.locale);
const cacheAction = statusCacheAction(scope, locale);
const userTz = await resolveUserTimezone(userId);
return {
userId,
scope,
locale,
force: options?.force === true,
readOnly: options?.readOnly === true,
cacheAction,
userTz,
todayKey: userDayKey(new Date(), userTz),
};
}
/** Today's stored note as the card's result, or `null` on a miss. */
export function readCachedStatus<R extends StatusCardResult, I>(
ctx: StatusCardContext,
codec: StatusNoteCodec<R, I>,
): Promise<R | null> {
return codec.readFresh(ctx);
}
/**
* The read-only navigation path: never block on the provider. Enqueue the
* generation out of band and serve the last good note, the preparing
* skeleton, or (no provider, no consent) the deterministic line.
*/
export async function resolveReadOnlyStatus<R extends StatusCardResult, I>(
ctx: StatusCardContext,
codec: StatusNoteCodec<R, I>,
unavailableText: string,
): Promise<R> {
const outcome = await resolveReadOnlyStatusMiss({
userId: ctx.userId,
metric: ctx.scope,
locale: ctx.locale,
});
return codec.readOnly(outcome, unavailableText);
}
/**
* The input gate for a slow-moving card: when the cheap fingerprint of its
* salient inputs matches the stored note's, re-date that note under today's
* key and skip the build and the provider. `null` when the build must run.
*/
export async function gateUnchangedInputs(
ctx: StatusCardContext,
inputHash: string,
force: boolean,
): Promise<StatusTextResult | null> {
const unchanged = await gateUnchangedStatusInput({
userId: ctx.userId,
cacheAction: ctx.cacheAction,
todayKey: ctx.todayKey,
inputHash,
force,
});
if (!unchanged) return null;
return {
hasProvider: true,
text: unchanged.text,
cached: true,
updatedAt: unchanged.updatedAt,
};
}
/**
* Fit the snapshot to the record's prompt budget (shedding the coarsest
* series first), serialise it, record its size, and fingerprint it.
*/
export async function sealStatusSnapshot(
ctx: StatusCardContext,
snapshot: object,
): Promise<{ snapshotJson: string; snapshotHash: string }> {
const shed = degradeStatusSnapshotToBudget(
snapshot as unknown as Record<string, unknown>,
{ maxChars: await statusSnapshotCharsForRecord(ctx.userId) },
);
const snapshotJson = JSON.stringify(snapshot);
annotate({
action: { name: ctx.cacheAction },
meta: {
payload_size_bytes: snapshotJson.length,
...(shed.length > 0 ? { snapshot_shed: shed } : {}),
},
});
return { snapshotJson, snapshotHash: hashInsightSnapshot(snapshot) };
}
/**
* Content-hash gate: when the snapshot is unchanged since the last real
* assessment, re-date the stored note and skip the LLM.
*/
export function refreshUnchangedStatus<R extends StatusCardResult, I>(
ctx: StatusCardContext,
codec: StatusNoteCodec<R, I>,
snapshotHash: string,
): Promise<R | null> {
return codec.refreshUnchanged(ctx, snapshotHash);
}
/** What the anti-repetition block is built from, per card. */
export interface StatusContextInputs {
/** The key the variety lead rotates on; the card's scope unless it differs. */
varietyKey?: string;
/** Consecutive steady weeks/months, or 0 where a card has no single mean. */
repeatCount: number;
points: number;
newestDaysAgo: number | null;
/**
* The discovery channel whose FDR-surviving relations the block lists, or
* `null` for a card with no measurement channel of its own (no fetch).
*/
relationsFor: MeasurementType | null;
}
/** The prompt blocks every assessment card carries beside its snapshot. */
export interface StatusPromptContext {
previousContextBlock: string;
assessmentContextBlock: string;
openerHint: string;
}
/**
* The previous-assessment comparison, the diversity / anti-repetition block
* and the rotating opener hint. All formatting of already-computed data; the
* correlation fetch is best-effort and resolves to no block on failure, never
* a generation failure. The opener hint is keyed per (user, card, day) and
* activates the base prompt's verdict-first opener branch.
*/
export async function buildStatusPromptContext(
ctx: StatusCardContext<InsightStatusMetric | `metric:${string}`>,
inputs: StatusContextInputs,
): Promise<StatusPromptContext> {
const previousContext = await getPreviousInsightContext(
ctx.userId,
`${ctx.scope}-status`,
ctx.locale,
12,
);
const previousContextBlock = formatPreviousContextForPrompt(
previousContext,
ctx.locale,
);
const varietyLead = pickVarietyLead(
ctx.userId,
inputs.varietyKey ?? ctx.scope,
ctx.todayKey,
);
const relations =
inputs.relationsFor === null
? []
: await getRelevantCorrelationsForMetric(
ctx.userId,
inputs.relationsFor,
ctx.locale,
);
const assessmentContextBlock = buildAssessmentContextBlock(
{
varietyLead,
dataStrength: {
points: inputs.points,
newestDaysAgo: inputs.newestDaysAgo,
},
repeatCount: inputs.repeatCount,
relations,
},
ctx.locale,
);
return {
previousContextBlock,
assessmentContextBlock,
openerHint: openerArchetypeHint(
`${ctx.userId}:${ctx.scope}:${ctx.todayKey}`,
ctx.locale as Locale,
),
};
}
/** Everything a pending card needs beyond the run itself. */
export interface PendingStatusSpec<I> {
systemPrompt: string;
userPrompt: string;
hashes: StatusNoteHashes;
temperature: number;
/** The answer budget; the status default when omitted. */
maxTokens?: number;
/**
* The deterministic line served with no provider, after a timeout or error,
* and when the reply is withheld.
*/
fallbackText: string;
/** Thrown if a reply survives the screen with no text at all. */
emptyMessage: string;
detail: I;
}
/**
* The descriptor the single-card path and the batch both drive. A timeout or
* error is a transient miss (fallback served, short negative window, no
* assessment persisted); `none` (no provider / no consent) serves the no-key
* line.
*
* The reply passes the outbound safety screen before anything is stored.
* 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`).
*/
export function pendingStatusCard<R extends StatusCardResult, I>(
ctx: StatusCardContext,
codec: StatusNoteCodec<R, I>,
spec: PendingStatusSpec<I>,
): PendingStatusCard {
return {
phase: "pending",
metric: ctx.scope,
userId: ctx.userId,
cacheAction: ctx.cacheAction,
systemPrompt: spec.systemPrompt,
userPrompt: spec.userPrompt,
...(spec.hashes.snapshotHash !== undefined
? { snapshotHash: spec.hashes.snapshotHash }
: {}),
temperature: spec.temperature,
...(spec.maxTokens !== undefined ? { maxTokens: spec.maxTokens } : {}),
noProvider: codec.noProvider(spec.fallbackText),
timeout: (reason) =>
codec.fallback(ctx, reason, spec.fallbackText, spec.detail),
finalize: async (outcome) => {
const screened = finalizeStatusSummary(outcome.content, ctx.locale);
if (!screened.ok) {
annotate({
action: { name: "insights.status.outbound_blocked" },
meta: { cacheAction: ctx.cacheAction, reason: screened.reason },
});
return codec.fallback(ctx, "screened", spec.fallbackText, spec.detail);
}
if (!screened.text) throw new Error(spec.emptyMessage);
return codec.persist(ctx, screened.text, spec.hashes, spec.detail);
},
};
}
const DAY_MS = 24 * 60 * 60 * 1000;
/**
* How far a card's data reaches: whole days between its oldest and newest
* reading, and how many days ago the newest one was (`null` with no data).
*/
export function coverageSpan(
oldest: Date | null,
newest: Date | null,
): { totalSpanDays: number; newestDaysAgo: number | null } {
return {
totalSpanDays:
oldest && newest
? Math.round((newest.getTime() - oldest.getTime()) / DAY_MS)
: 0,
newestDaysAgo: newest
? Math.round((Date.now() - newest.getTime()) / DAY_MS)
: null,
};
}
/** The temperature every specialised card runs at, matching the archetype cards. */
const STATUS_CARD_TEMPERATURE = 0.45;
/** What a card's snapshot stage hands the pipeline. */
export interface StatusCardBuild<I> {
snapshot: object;
/** The input fingerprint to store, for a card with an input gate. */
inputHash?: string;
/**
* The rest of the card, read once the snapshot is sealed and the hash gate
* missed. Sealing sheds graded slices from the snapshot IN PLACE when it is
* over budget, and the series objects the snapshot carries are the card's
* own, so whatever reads them (the steady-run count) reads them here, after
* the shed, never before it.
*/
finish(): StatusCardTail<I>;
}
/** The part of a card that is read after its snapshot is sealed. */
export interface StatusCardTail<I> {
context: StatusContextInputs;
/** The deterministic line, grounded in the card's signal when it has one. */
fallbackText: string;
prompts(
snapshotJson: string,
context: StatusPromptContext,
): { systemPrompt: string; userPrompt: string };
detail: I;
}
/** A served early return from a card's snapshot stage. */
export interface ServedBuild<R extends StatusCardResult> {
phase: "served";
result: R;
}
/** One specialised card, as a plug-in to `prepareStatusCard`. */
export interface StatusCardPlugin<R extends StatusCardResult, I> {
scope: InsightStatusMetric;
codec: StatusNoteCodec<R, I>;
/** The deterministic line a read-only miss serves when notes are unavailable. */
unavailableText(locale: SupportedLocale): string;
/** Thrown if a reply survives the screen with no text at all. */
emptyMessage: string;
build(ctx: StatusCardContext): Promise<StatusCardBuild<I> | ServedBuild<R>>;
}
/**
* Everything up to (not including) the provider call, for one specialised
* card. Returns a finished `served` result for every path that never needed
* the LLM, or a `pending` descriptor the single-card path and the batch path
* both drive. See `status-card-generation.ts` for the contract.
*/
export async function prepareStatusCard<R extends StatusCardResult, I>(
plugin: StatusCardPlugin<R, I>,
userId: string,
options: StatusCardOptions | undefined,
): Promise<PreparedStatusCard> {
const ctx = await openStatusCard(userId, plugin.scope, options);
const cached = await readCachedStatus(ctx, plugin.codec);
if (cached) return { phase: "served", result: cached };
if (ctx.readOnly) {
return {
phase: "served",
result: await resolveReadOnlyStatus(
ctx,
plugin.codec,
plugin.unavailableText(ctx.locale),
),
};
}
const build = await plugin.build(ctx);
if ("phase" in build) return build;
const { snapshotJson, snapshotHash } = await sealStatusSnapshot(
ctx,
build.snapshot,
);
const unchanged = await refreshUnchangedStatus(
ctx,
plugin.codec,
snapshotHash,
);
if (unchanged) return { phase: "served", result: unchanged };
const tail = build.finish();
const context = await buildStatusPromptContext(ctx, tail.context);
const { systemPrompt, userPrompt } = tail.prompts(snapshotJson, context);
return pendingStatusCard(ctx, plugin.codec, {
systemPrompt,
userPrompt,
hashes: {
snapshotHash,
...(build.inputHash !== undefined ? { inputHash: build.inputHash } : {}),
},
temperature: STATUS_CARD_TEMPERATURE,
fallbackText: tail.fallbackText,
emptyMessage: plugin.emptyMessage,
detail: tail.detail,
});
}
+354 -506
View File
@@ -4,8 +4,6 @@ import {
getWeightSystemPrompt,
getWeightUserPrompt,
} from "@/lib/ai/prompts/weight";
import { openerArchetypeHint } from "@/lib/ai/prompts/opener-archetype";
import type { Locale } from "@/lib/i18n/config";
import {
significantPearsonCorrelation,
type PairedPoint,
@@ -13,16 +11,7 @@ import {
import { getNoKeyWeightStatusText } from "@/lib/insights/no-key-fallbacks";
import { resolveWeightTargetOverride } from "@/lib/analytics/effective-range";
import { buildWeightTargetFeature } from "@/lib/targets/weight-trend";
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 {
applyPayloadBudget,
dayOffsetToDayKey,
@@ -32,42 +21,40 @@ import {
buildGradedSeriesFromPoints,
buildGradedSeriesWithRollups,
convertGradedSeries,
degradeStatusSnapshotToBudget,
type GradedSeries,
} from "@/lib/insights/graded-series";
import { statusSnapshotCharsForRecord } from "@/lib/ai/capabilities/usage";
import {
applyDisplayTransform,
applyDisplayTransformDelta,
getReadingTransform,
resolveUnitPreferences,
transformRescales,
type DisplayTransform,
type UnitPreferences,
} from "@/lib/measurements/display-transform";
import { buildMetricSignal } from "@/lib/insights/metric-signal";
import {
type SupportedLocale,
normalizeLocale,
finalizeStatusSummary,
persistStatusInsight,
round,
summarizeSeries,
type SupportedLocale,
} from "@/lib/insights/status-shared";
import {
computeStatusInputFingerprint,
gateUnchangedStatusInput,
readFreshStatusText,
refreshUnchangedStatusInsight,
resolveReadOnlyStatusMiss,
statusCacheAction,
} from "@/lib/insights/status-cache";
import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash";
import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback";
import { computeStatusInputFingerprint } from "@/lib/insights/status-cache";
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,
gateUnchangedInputs,
prepareStatusCard,
textStatusCodec,
type ServedBuild,
type StatusCardBuild,
type StatusCardContext,
type StatusCardOptions,
type StatusCardPlugin,
type StatusTextResult,
} from "@/lib/insights/status-pipeline";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { canonicalDailyTimestamp } from "@/lib/measurements/consolidation-tz";
@@ -120,129 +107,45 @@ function pairDailyBuckets(
*/
export async function generateWeightStatusForUser(
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 prepareWeightStatusForUser(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.
* Returns a finished `served` result for every path that never needed the
* LLM, or a `pending` descriptor the single-card path and the batch path
* both drive. See `status-card-generation.ts` for the contract.
* Everything up to (not including) the provider call, through the shared
* status pipeline (`status-pipeline.ts`); this card supplies its input gate
* and its snapshot.
*/
export async function prepareWeightStatusForUser(
export function prepareWeightStatusForUser(
userId: string,
options?: {
locale?: string | null;
force?: boolean;
/**
* v1.8.3 — read-only mode for the navigation path. On a cache miss the
* generator does NOT run the SQL gather + blocking LLM inline; it
* enqueues an out-of-band generation and returns a `preparing` shape so
* the request never awaits the provider. The worker (and the nightly
* pre-generate cron) call with `readOnly: false` to actually generate.
*/
readOnly?: boolean;
},
options?: StatusCardOptions,
): Promise<PreparedStatusCard> {
const locale = normalizeLocale(options?.locale);
const force = options?.force === true;
const readOnly = options?.readOnly === true;
const cacheAction = statusCacheAction("weight", 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);
return prepareStatusCard(WEIGHT_STATUS_CARD, userId, options);
}
// Serve today's real assessment when present. The shared reader
// rejects timeout stubs, so a single stall no longer pins the
// fallback for the day.
const cached = await readFreshStatusText({
userId,
cacheAction,
todayKey,
force,
});
if (cached) {
return {
phase: "served",
result: {
hasProvider: true,
text: cached.text,
cached: true,
updatedAt: cached.updatedAt,
},
};
}
interface WeightInputs {
weightTarget: ReturnType<typeof resolveWeightTargetOverride>;
units: UnitPreferences;
mass: DisplayTransform;
inputHash: string;
}
// v1.8.3 — read-only navigation path: never block on the provider.
// Enqueue generation out of band and return preparing / no-provider.
if (readOnly) {
const outcome = await resolveReadOnlyStatusMiss({
userId,
metric: "weight",
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: getNoKeyWeightStatusText(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,
},
};
}
// v1.18.11 — input gate for this slow-moving metric. A cheap grouped
// probe over the salient inputs (weight + BP channels + mood) fingerprints
// what the snapshot would read. On a non-forced run with an unchanged
// fingerprint the cached assessment is re-stamped under today's key and the
// whole heavy build below (bounded findMany + per-series rollup reads +
// correlation math + the provider call) is skipped. A forced run never
// gates — but still computes the fingerprint so the persisted row carries a
// current one for the next day's gate.
/**
* Input gate for this slow-moving metric. A cheap grouped probe over the
* salient inputs (weight + BP channels + mood) fingerprints what the snapshot
* would read. On a non-forced run with an unchanged fingerprint the cached
* assessment is re-stamped under today's key and the whole heavy build
* (bounded findMany + per-series rollup reads + correlation math + the
* provider call) is skipped. A forced run never gates — but still computes
* the fingerprint so the persisted row carries a current one for the next
* day's gate.
*/
async function readWeightInputs(
ctx: StatusCardContext,
): Promise<WeightInputs | ServedBuild<StatusTextResult>> {
const { userId } = ctx;
// v1.39 (#1006) — the person's own weight target decides which way is
// progress. Folded into the input fingerprint so editing the target flips
// the gate even when no weight row moved.
@@ -258,7 +161,6 @@ export async function prepareWeightStatusForUser(
unitPreference: userRow?.unitPreference,
});
const mass = getReadingTransform("WEIGHT", units);
const toMass = (kg: number) => applyDisplayTransform(kg, mass);
const inputHash = await computeStatusInputFingerprint({
userId,
types: ["WEIGHT", "BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"],
@@ -276,176 +178,48 @@ export async function prepareWeightStatusForUser(
// the discovery channels + the prior-analysis anchor into the fingerprint.
includeCorrelationChannels: true,
});
if (!force) {
const unchangedInput = await gateUnchangedStatusInput({
userId,
cacheAction,
todayKey,
inputHash,
force,
});
if (unchangedInput) {
return {
phase: "served",
result: {
hasProvider: true,
text: unchangedInput.text,
cached: true,
updatedAt: unchangedInput.updatedAt,
},
};
}
if (!ctx.force) {
const unchangedInput = await gateUnchangedInputs(ctx, inputHash, ctx.force);
if (unchangedInput) return { phase: "served", result: unchangedInput };
}
return { weightTarget, units, mass, inputHash };
}
// v1.4.28 — cap the snapshot input. BP captures three types
// per day so 1095 = 365 d × 3 channels. Order desc + reverse so we
// keep the most recent year of rows.
const measurements = await prisma.measurement
.findMany({
where: {
userId,
type: {
in: ["WEIGHT", "BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"],
},
// Soft-deleted rows must never reach the prompt snapshot.
deletedAt: null,
},
take: 1095,
orderBy: { measuredAt: "desc" },
select: {
type: true,
value: true,
measuredAt: true,
},
})
.then((rows) => rows.reverse());
/**
* The weight snapshot: the weight series in the reader's mass unit, the two
* BP channels as context, the weight-vs-BP correlations, and mood context.
*/
async function buildWeightSnapshot(
ctx: StatusCardContext,
): Promise<StatusCardBuild<undefined> | ServedBuild<StatusTextResult>> {
const { userId, locale, userTz, todayKey } = ctx;
const inputs = await readWeightInputs(ctx);
if ("phase" in inputs) return inputs;
const { weightTarget, units, mass, inputHash } = inputs;
const toMass = (kg: number) => applyDisplayTransform(kg, mass);
const now = new Date();
// v1.2.5 — every day-bucketing pass below keys on the user's own
// calendar day. `userTz` was already resolved above for the cache day-key.
const weightSeries = applyPayloadBudget(
measurements
.filter((measurement) => measurement.type === "WEIGHT")
.map((measurement) => ({
measuredAt: measurement.measuredAt,
value: measurement.value,
})),
{ now, tz: userTz },
);
const sysSeries = applyPayloadBudget(
measurements
.filter((measurement) => measurement.type === "BLOOD_PRESSURE_SYS")
.map((measurement) => ({
measuredAt: measurement.measuredAt,
value: measurement.value,
})),
{ now, tz: userTz },
);
const diaSeries = applyPayloadBudget(
measurements
.filter((measurement) => measurement.type === "BLOOD_PRESSURE_DIA")
.map((measurement) => ({
measuredAt: measurement.measuredAt,
value: measurement.value,
})),
{ now, tz: userTz },
);
// Fetch mood context (optional — for enrichment only). v1.4.28
// Cap at 90 entries.
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 + latest-day focus; only the
// graded shape is embedded so the full daily arrays never ship.
//
// Weight + the two BP context channels source their recent / weekly
// slices from a bounded raw read and their monthly / yearly tail from
// the MONTH / YEAR rollup tier (with a full-history in-memory fallback
// on a cold-tier coverage miss). Mood has no rollup tier, so it stays
// an in-memory fold.
const [canonicalWeightGraded, sysGraded, diaGraded] = await Promise.all([
buildGradedSeriesWithRollups(userId, "WEIGHT", now, userTz),
buildGradedSeriesWithRollups(userId, "BLOOD_PRESSURE_SYS", now, userTz),
buildGradedSeriesWithRollups(userId, "BLOOD_PRESSURE_DIA", now, userTz),
]);
const weightGraded = transformRescales(mass)
? convertGradedSeries(canonicalWeightGraded, mass, mass.decimals)
: canonicalWeightGraded;
const moodGraded = buildGradedSeriesFromPoints(
moodSeries.daily.map((b) => ({
measuredAt: canonicalDailyTimestamp(
dayOffsetToDayKey(now, b.dayOffset, userTz),
userTz,
),
value: b.value,
})),
const {
now,
userTz,
);
measurements,
weightSeries,
sysSeries,
diaSeries,
moodSeries,
moodMean,
canonicalWeightGraded,
weightGraded,
sysGraded,
diaGraded,
moodGraded,
} = await readWeightSeries(userId, mass, userTz);
const weightVsSystolicPairs = pairDailyBuckets(
const pairs = pairWeightWithBloodPressure(
weightSeries.daily,
sysSeries.daily,
now,
userTz,
);
const weightVsSystolicCorrelation = significantPearsonCorrelation(
weightVsSystolicPairs,
);
const pairedSystolicDiastolic = pairDailyBuckets(
sysSeries.daily,
diaSeries.daily,
now,
userTz,
).map((entry) => ({
day: entry.dayKey,
sys: entry.a,
dia: entry.b,
mean: round((entry.a + entry.b) / 2, 2),
}));
// Synthesise a daily-bucket-shaped BP-mean series so we can reuse
// pairDailyBuckets — derive dayOffset from the offsets in sysSeries.
const sysOffsetByDay = new Map(
sysSeries.daily.map((bucket) => {
const dayKey = dayOffsetToDayKey(now, bucket.dayOffset, userTz);
return [dayKey, bucket.dayOffset];
}),
);
const bpMeanDaily: DailyBucket[] = pairedSystolicDiastolic
.map((entry) => {
const dayOffset = sysOffsetByDay.get(entry.day);
if (dayOffset == null) return null;
return { dayOffset, value: entry.mean, n: 1 };
})
.filter((entry): entry is DailyBucket => entry !== null);
const weightVsMeanBpPairs = pairDailyBuckets(
weightSeries.daily,
bpMeanDaily,
now,
userTz,
);
const weightVsMeanBpCorrelation =
significantPearsonCorrelation(weightVsMeanBpPairs);
// daily[0] = newest bucket (lowest dayOffset).
const latestWeight = weightSeries.daily[0] ?? null;
@@ -454,61 +228,27 @@ export async function prepareWeightStatusForUser(
? dayOffsetToDayKey(now, latestWeight.dayOffset, userTz)
: null;
const sameDayBp = latestWeightDay
? (pairedSystolicDiastolic.find((entry) => entry.day === latestWeightDay) ??
? (pairs.systolicDiastolic.find((entry) => entry.day === latestWeightDay) ??
null)
: null;
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;
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 instead of asking it to derive them.
// Weight has no universal favourable direction or population band, so
// without a stored target the signal carries the neutral "target-band"
// framing and no normalRange. v1.39 (#1006) — with one, the direction is
// the person's own: below the band a rise is favourable, above it a fall.
const recentWeights = weightSeries.daily
.filter((bucket) => bucket.dayOffset < 7)
.map((bucket) => bucket.value);
const weightTargetFeature = buildWeightTargetFeature(
const { weightTargetFeature, weightSignal } = weightTargetAndSignal({
weightDaily: weightSeries.daily,
latestWeight,
weightTarget,
{
avg7:
recentWeights.length > 0
? recentWeights.reduce((sum, value) => sum + value, 0) /
recentWeights.length
: null,
latest: latestWeight?.value ?? null,
},
units.system,
);
const weightSignal = buildMetricSignal({
metric: locale === "en" ? "your weight" : "dein Gewicht",
unit: mass.displayUnit,
direction:
weightTargetFeature?.position === "below"
? "higher-better"
: weightTargetFeature?.position === "above"
? "lower-better"
: "target-band",
graded: weightGraded,
units,
mass,
weightGraded,
newestDaysAgo: newestMeasurementDaysAgo,
locale: ctx.locale,
});
const snapshot = {
@@ -560,11 +300,11 @@ export async function prepareWeightStatusForUser(
// Keep the correlation coefficients (computed over the full
// overlap) but cap the embedded pair arrays to the most recent
// paired days — the raw pair lists were a major payload driver.
pairedDaily: pairedSystolicDiastolic.slice(-CORRELATION_PAIR_CAP),
pairedDaily: pairs.systolicDiastolic.slice(-CORRELATION_PAIR_CAP),
},
weightVsSystolic: {
correlation: weightVsSystolicCorrelation,
pairs: weightVsSystolicPairs
correlation: pairs.weightVsSystolicCorrelation,
pairs: pairs.weightVsSystolic
.slice(-CORRELATION_PAIR_CAP)
.map((entry) => ({
day: entry.dayKey,
@@ -573,8 +313,8 @@ export async function prepareWeightStatusForUser(
})),
},
weightVsMeanBloodPressure: {
correlation: weightVsMeanBpCorrelation,
pairs: weightVsMeanBpPairs.slice(-CORRELATION_PAIR_CAP).map((entry) => ({
correlation: pairs.weightVsMeanBpCorrelation,
pairs: pairs.weightVsMeanBp.slice(-CORRELATION_PAIR_CAP).map((entry) => ({
day: entry.dayKey,
weight: toMass(round(entry.a, 2)),
meanBloodPressure: round(entry.b, 2),
@@ -600,158 +340,266 @@ export async function prepareWeightStatusForUser(
: 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,
"weight-status",
locale,
12,
);
const previousContextBlock = formatPreviousContextForPrompt(
previousContext,
locale,
);
// v1.12.7 — diversity / anti-repetition block (see blood-pressure-status).
const varietyLead = pickVarietyLead(userId, "weight", todayKey);
const steadyRun = computeSteadyRun(
canonicalWeightGraded.weekly,
canonicalWeightGraded.monthly,
);
const relations = await getRelevantCorrelationsForMetric(
userId,
"WEIGHT",
locale,
);
const assessmentContextBlock = buildAssessmentContextBlock(
{
varietyLead,
dataStrength: {
return {
snapshot,
inputHash,
finish: () => ({
context: {
repeatCount: computeSteadyRun(
canonicalWeightGraded.weekly,
canonicalWeightGraded.monthly,
),
points: weightSeries.daily.length,
newestDaysAgo: newestMeasurementDaysAgo,
relationsFor: "WEIGHT",
},
repeatCount: steadyRun,
relations,
},
locale,
);
// The provider call is deferred to the caller — the single-card path runs
// ONE completion, the batch path folds this prompt into one shared call.
// A timeout / error is a transient miss (fallback served, no assessment
// persisted); `none` (no provider / no consent) serves the no-key text.
return {
phase: "pending",
metric: "weight",
userId,
cacheAction,
systemPrompt: getWeightSystemPrompt(locale, units.system),
userPrompt: getWeightUserPrompt(
snapshotJson,
todayKey,
locale,
previousContextBlock,
assessmentContextBlock,
// v1.28.40 — rotating opener hint, per (user, metric, day).
openerArchetypeHint(`${userId}:weight:${todayKey}`, locale as Locale),
),
snapshotHash,
// v1.12.7 — match the archetype cards' 0.45.
temperature: 0.45,
noProvider: {
hasProvider: false,
text: getNoKeyWeightStatusText(locale, weightSignal),
cached: true,
updatedAt: null,
},
timeout: (reason): StatusCardResult =>
returnTimeoutFallback({
cacheAction,
reason,
userId,
todayKey,
stubText: getNoKeyWeightStatusText(locale, weightSignal),
fallbackText: getNoKeyWeightStatusText(locale, weightSignal),
prompts: (snapshotJson, context) => ({
systemPrompt: getWeightSystemPrompt(locale, units.system),
userPrompt: getWeightUserPrompt(
snapshotJson,
todayKey,
locale,
context.previousContextBlock,
context.assessmentContextBlock,
context.openerHint,
),
}),
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: getNoKeyWeightStatusText(locale, weightSignal),
});
}
const summary = screened.text;
if (!summary) {
throw new Error("Weight-status summary was empty after normalization");
}
const updatedAt = await persistStatusInsight({
userId,
cacheAction,
todayKey,
text: summary,
snapshotHash,
// v1.18.11 — persist the input fingerprint so tomorrow's input
// gate can skip the rebuild when nothing salient changed.
inputHash,
});
return {
hasProvider: true,
text: summary,
cached: false,
updatedAt,
};
},
detail: undefined,
}),
};
}
export function resolveWeightStatusLocale(
locale: string | null | undefined,
): SupportedLocale {
return normalizeLocale(locale);
/**
* The weight target feature and the finished recent-vs-baseline signal.
*/
function weightTargetAndSignal(args: {
weightDaily: DailyBucket[];
latestWeight: DailyBucket | null;
weightTarget: WeightInputs["weightTarget"];
units: UnitPreferences;
mass: DisplayTransform;
weightGraded: GradedSeries;
newestDaysAgo: number | null;
locale: SupportedLocale;
}) {
const { weightTarget, units, mass, weightGraded, locale } = args;
const newestMeasurementDaysAgo = args.newestDaysAgo;
const latestWeight = args.latestWeight;
// v1.18.10 — hand the model the finished recent-vs-baseline
// comparison + normal-swing verdict instead of asking it to derive them.
// Weight has no universal favourable direction or population band, so
// without a stored target the signal carries the neutral "target-band"
// framing and no normalRange. v1.39 (#1006) — with one, the direction is
// the person's own: below the band a rise is favourable, above it a fall.
const recentWeights = args.weightDaily
.filter((bucket) => bucket.dayOffset < 7)
.map((bucket) => bucket.value);
const weightTargetFeature = buildWeightTargetFeature(
weightTarget,
{
avg7:
recentWeights.length > 0
? recentWeights.reduce((sum, value) => sum + value, 0) /
recentWeights.length
: null,
latest: latestWeight?.value ?? null,
},
units.system,
);
const weightSignal = buildMetricSignal({
metric: locale === "en" ? "your weight" : "dein Gewicht",
unit: mass.displayUnit,
direction:
weightTargetFeature?.position === "below"
? "higher-better"
: weightTargetFeature?.position === "above"
? "lower-better"
: "target-band",
graded: weightGraded,
newestDaysAgo: newestMeasurementDaysAgo,
});
return { weightTargetFeature, weightSignal };
}
/**
* The weight snapshot's reads: weight and the two BP channels as daily
* buckets and graded series, and the mood context.
*/
async function readWeightSeries(
userId: string,
mass: DisplayTransform,
userTz: string,
) {
// v1.4.28 — cap the snapshot input. BP captures three types
// per day so 1095 = 365 d × 3 channels. Order desc + reverse so we
// keep the most recent year of rows.
const measurements = await prisma.measurement
.findMany({
where: {
userId,
type: {
in: ["WEIGHT", "BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"],
},
// Soft-deleted rows must never reach the prompt snapshot.
deletedAt: null,
},
take: 1095,
orderBy: { measuredAt: "desc" },
select: {
type: true,
value: true,
measuredAt: true,
},
})
.then((rows) => rows.reverse());
const now = new Date();
// v1.2.5 — every day-bucketing pass below keys on the user's own
// calendar day. `userTz` was already resolved above for the cache day-key.
const dailyOf = (type: string) =>
applyPayloadBudget(
measurements
.filter((measurement) => measurement.type === type)
.map((measurement) => ({
measuredAt: measurement.measuredAt,
value: measurement.value,
})),
{ now, tz: userTz },
);
const weightSeries = dailyOf("WEIGHT");
const sysSeries = dailyOf("BLOOD_PRESSURE_SYS");
const diaSeries = dailyOf("BLOOD_PRESSURE_DIA");
// Fetch mood context (optional — for enrichment only). v1.4.28
// Cap at 90 entries.
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 + latest-day focus; only the
// graded shape is embedded so the full daily arrays never ship.
//
// Weight + the two BP context channels source their recent / weekly
// slices from a bounded raw read and their monthly / yearly tail from
// the MONTH / YEAR rollup tier (with a full-history in-memory fallback
// on a cold-tier coverage miss). Mood has no rollup tier, so it stays
// an in-memory fold.
const [canonicalWeightGraded, sysGraded, diaGraded] = await Promise.all([
buildGradedSeriesWithRollups(userId, "WEIGHT", now, userTz),
buildGradedSeriesWithRollups(userId, "BLOOD_PRESSURE_SYS", now, userTz),
buildGradedSeriesWithRollups(userId, "BLOOD_PRESSURE_DIA", now, userTz),
]);
const weightGraded = transformRescales(mass)
? convertGradedSeries(canonicalWeightGraded, mass, mass.decimals)
: canonicalWeightGraded;
const moodGraded = buildGradedSeriesFromPoints(
moodSeries.daily.map((b) => ({
measuredAt: canonicalDailyTimestamp(
dayOffsetToDayKey(now, b.dayOffset, userTz),
userTz,
),
value: b.value,
})),
now,
userTz,
);
return {
now,
measurements,
weightSeries,
sysSeries,
diaSeries,
moodSeries,
moodMean,
canonicalWeightGraded,
weightGraded,
sysGraded,
diaGraded,
moodGraded,
};
}
/**
* Weight against the two BP channels: the paired systolic / diastolic days,
* weight against systolic, and weight against the daily BP mean, each with
* its significance-gated correlation.
*/
function pairWeightWithBloodPressure(
weightDaily: DailyBucket[],
sysDaily: DailyBucket[],
diaDaily: DailyBucket[],
now: Date,
userTz: string,
) {
const weightVsSystolic = pairDailyBuckets(weightDaily, sysDaily, now, userTz);
const weightVsSystolicCorrelation =
significantPearsonCorrelation(weightVsSystolic);
const systolicDiastolic = pairDailyBuckets(
sysDaily,
diaDaily,
now,
userTz,
).map((entry) => ({
day: entry.dayKey,
sys: entry.a,
dia: entry.b,
mean: round((entry.a + entry.b) / 2, 2),
}));
// Synthesise a daily-bucket-shaped BP-mean series so we can reuse
// pairDailyBuckets — derive dayOffset from the offsets in sysSeries.
const sysOffsetByDay = new Map(
sysDaily.map((bucket) => {
const dayKey = dayOffsetToDayKey(now, bucket.dayOffset, userTz);
return [dayKey, bucket.dayOffset];
}),
);
const bpMeanDaily: DailyBucket[] = systolicDiastolic
.map((entry) => {
const dayOffset = sysOffsetByDay.get(entry.day);
if (dayOffset == null) return null;
return { dayOffset, value: entry.mean, n: 1 };
})
.filter((entry): entry is DailyBucket => entry !== null);
const weightVsMeanBp = pairDailyBuckets(
weightDaily,
bpMeanDaily,
now,
userTz,
);
const weightVsMeanBpCorrelation =
significantPearsonCorrelation(weightVsMeanBp);
return {
weightVsSystolic,
weightVsSystolicCorrelation,
systolicDiastolic,
weightVsMeanBp,
weightVsMeanBpCorrelation,
};
}
const WEIGHT_STATUS_CARD: StatusCardPlugin<StatusTextResult, undefined> = {
scope: "weight",
codec: textStatusCodec,
unavailableText: (locale) => getNoKeyWeightStatusText(locale),
emptyMessage: "Weight-status summary was empty after normalization",
build: buildWeightSnapshot,
};
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,775 @@
/**
* Output parity for the per-card status generators.
*
* Every status card (the specialised cards, the generic HealthKit-metric
* card, the biomarker card, the general overview, and the batched warm pass)
* is driven against a real Postgres with fixed fixtures and a frozen clock,
* through its public entry point. The model is the only stub: it records the
* exact request it is sent and answers with a fixed completion. For each card
* and each arm (a fresh generation, a cache hit, an unchanged-data run, the
* next day, the read-only paths, no provider, no data, a screened reply and a
* failing model) the file records
*
* - every request the model received, prompts included,
* - the result the entry point returned,
* - the stored notes, decrypted, with their fingerprints and windows,
* - every out-of-band generation the read-only path queued,
*
* and compares the lot, byte for byte, with the golden files under
* `__golden__/status-parity/`. A refactor of the generators must leave every
* one of them unchanged; a deliberate change to what a card sends or answers
* rewrites them with `STATUS_PARITY_WRITE=1` in the same commit, so the diff
* shows exactly what moved.
*/
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
import { settleBackgroundTasks } from "@/lib/logging/background-tasks";
import { getPrismaClient, truncateAllTables } from "./setup";
interface ModelReply {
content: string;
model: string;
tokensUsed: number;
}
const model = vi.hoisted(() => ({
calls: [] as unknown[],
reply: async (_params: unknown): Promise<ModelReply> => {
throw new Error("no reply configured");
},
}));
const queue = vi.hoisted(() => ({ enqueued: [] as unknown[] }));
/**
* The negative-cache window a fallback opens is written without being awaited
* (the render must not wait on it). The writer is passed through unchanged;
* the test only keeps hold of the write so an arm can settle it before the
* stored notes are read.
*/
const pendingWrites = vi.hoisted(() => new Set<Promise<unknown>>());
vi.mock("@/lib/ai/provider", async (importOriginal) => ({
// The presence probe the capability reads stays real; only the chain the
// generation walks is replaced by the recording stand-in.
...(await importOriginal<typeof import("@/lib/ai/provider")>()),
resolveProviderChain: async () => [
{
providerType: "anthropic",
instance: {
type: "anthropic",
generateCompletion: async (params: unknown) => {
model.calls.push(params);
return model.reply(params);
},
},
},
],
resolveProvider: async () => ({ type: "none" }),
}));
vi.mock(
"@/lib/jobs/insight-status-generate-shared",
async (importOriginal) => ({
...(await importOriginal<
typeof import("@/lib/jobs/insight-status-generate-shared")
>()),
enqueueStatusGeneration: async (args: unknown) => {
queue.enqueued.push(args);
},
}),
);
vi.mock("@/lib/insights/status-cache", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/lib/insights/status-cache")>();
return {
...actual,
writeStatusNegativeWindow: (
args: Parameters<typeof actual.writeStatusNegativeWindow>[0],
) => {
const write = actual.writeStatusNegativeWindow(args);
pendingWrites.add(write);
return write;
},
};
});
const GOLDEN_DIR = resolve(__dirname, "__golden__/status-parity");
const WRITE = process.env.STATUS_PARITY_WRITE === "1";
const NOW = new Date("2026-06-15T10:00:00.000Z");
const DAY_MS = 24 * 60 * 60 * 1000;
const SLOW = 180_000;
const RICH = "parity-rich";
const BARE = "parity-bare";
const KEYLESS = "parity-keyless";
const BIOMARKER = "parity-ldl";
const EMPTY_BIOMARKER = "parity-empty-marker";
// ── The model stand-in ───────────────────────────────────────────────────────
function answer(content: string): void {
model.reply = async () => ({
content,
model: "parity-model",
tokensUsed: 42,
});
}
function answerSummary(text: string): void {
answer(JSON.stringify({ summary: text }));
}
function fail(): void {
model.reply = async () => {
throw new Error("provider unavailable");
};
}
/** One recorded request, with long prompt strings split into lines. */
function readable(value: unknown): unknown {
if (typeof value === "string") {
return value.includes("\n") ? value.split("\n") : value;
}
if (Array.isArray(value)) return value.map(readable);
if (value && typeof value === "object") {
return Object.fromEntries(
Object.entries(value).map(([key, entry]) => [key, readable(entry)]),
);
}
return value;
}
// ── Fixtures ─────────────────────────────────────────────────────────────────
function daysAgo(days: number, hour = 7): Date {
const at = new Date(NOW.getTime() - days * DAY_MS);
at.setUTCHours(hour, 15, 0, 0);
return at;
}
async function makeAccount(
id: string,
options: { withProvider: boolean; profile: "rich" | "plain" | "none" },
) {
const prisma = getPrismaClient();
const profile =
options.profile === "rich"
? {
heightCm: 178,
dateOfBirth: new Date("1984-03-09T00:00:00.000Z"),
gender: "MALE",
unitPreference: "imperial",
glucoseUnit: "mmol/L",
}
: options.profile === "plain"
? {
heightCm: 165,
dateOfBirth: new Date("1991-11-20T00:00:00.000Z"),
gender: "FEMALE",
}
: {};
return prisma.user.create({
data: {
id,
username: id,
email: `${id}@example.test`,
role: "USER",
timezone: "Europe/Berlin",
locale: "en",
onboardingCompletedAt: new Date("2026-01-01T00:00:00.000Z"),
...(options.withProvider
? {
aiProviderChain: [{ providerType: "anthropic", enabled: true }],
// Presence only: the capability probe never decrypts.
aiAnthropicKeyEncrypted: "key-present",
}
: {}),
...profile,
},
});
}
async function seedMeasurements(userId: string): Promise<void> {
const prisma = getPrismaClient();
const rows: Array<{
userId: string;
type:
| "WEIGHT"
| "BLOOD_PRESSURE_SYS"
| "BLOOD_PRESSURE_DIA"
| "PULSE"
| "RESTING_HEART_RATE"
| "BLOOD_GLUCOSE"
| "SLEEP_DURATION";
value: number;
unit: string;
source: "MANUAL" | "APPLE_HEALTH";
measuredAt: Date;
sleepStage?: "ASLEEP";
}> = [];
for (let day = 0; day < 140; day++) {
const wave = Math.sin(day / 5);
if (day % 2 === 0) {
rows.push({
userId,
type: "WEIGHT",
value: 84.2 - day * 0.015 + wave * 0.4,
unit: "kg",
source: "MANUAL",
measuredAt: daysAgo(day, 6),
});
}
if (day % 3 !== 2) {
rows.push(
{
userId,
type: "BLOOD_PRESSURE_SYS",
value: Math.round(128 + wave * 6 - (day % 7)),
unit: "mmHg",
source: "MANUAL",
measuredAt: daysAgo(day, 8),
},
{
userId,
type: "BLOOD_PRESSURE_DIA",
value: Math.round(82 + wave * 4 - (day % 5)),
unit: "mmHg",
source: "MANUAL",
measuredAt: daysAgo(day, 8),
},
);
}
for (const hour of [8, 13, 19]) {
rows.push({
userId,
type: "PULSE",
value: Math.round(66 + wave * 5 + (hour === 19 ? 12 : 0)),
unit: "bpm",
source: "APPLE_HEALTH",
measuredAt: daysAgo(day, hour),
});
}
rows.push({
userId,
type: "RESTING_HEART_RATE",
value: Math.round(58 + wave * 3 + (day % 4)),
unit: "bpm",
source: "APPLE_HEALTH",
measuredAt: daysAgo(day, 5),
});
if (day % 4 === 0) {
rows.push({
userId,
type: "BLOOD_GLUCOSE",
value: Math.round(96 + wave * 9 + (day % 6)),
unit: "mg/dL",
source: "MANUAL",
measuredAt: daysAgo(day, 7),
});
}
if (day > 0 && day < 90) {
rows.push({
userId,
type: "SLEEP_DURATION",
value: Math.round(410 + wave * 35),
unit: "min",
source: "APPLE_HEALTH",
measuredAt: daysAgo(day, 5),
sleepStage: "ASLEEP",
});
}
}
await prisma.measurement.createMany({ data: rows });
}
async function seedMood(userId: string): Promise<void> {
const prisma = getPrismaClient();
const binary = await prisma.moodTag.findFirst({
where: { kind: "BINARY", userId: null },
orderBy: { key: "asc" },
});
const rated = await prisma.moodTag.findFirst({
where: { kind: "RATED", userId: null },
orderBy: { key: "asc" },
});
const labels = ["LAUSIG", "SCHLECHT", "OKAY", "GUT", "SUPER_GUT"];
for (let day = 0; day < 70; day++) {
const score = 1 + ((day * 7 + Math.floor(day / 3)) % 5);
const at = daysAgo(day, day % 2 === 0 ? 20 : 9);
const links = [
...(binary && day % 3 === 0 ? [{ moodTagId: binary.id }] : []),
...(rated && day % 2 === 0
? [{ moodTagId: rated.id, rating: 1 + (day % 5) }]
: []),
];
await prisma.moodEntry.create({
data: {
id: `${userId}-mood-${day}`,
userId,
date: at.toISOString().slice(0, 10),
mood: labels[score - 1],
score,
tags:
day % 4 === 0
? JSON.stringify(["work"])
: day % 5 === 0
? JSON.stringify(["sport"])
: null,
tz: "Europe/Berlin",
moodLoggedAt: at,
...(links.length > 0 ? { tagLinks: { create: links } } : {}),
},
});
}
}
async function seedMedications(userId: string): Promise<void> {
const prisma = getPrismaClient();
const createdAt = daysAgo(120, 9);
const medications = [
{ id: `${userId}-med-bp`, name: "Ramipril", dose: "5mg", bp: true },
{ id: `${userId}-med-d`, name: "Vitamin D", dose: "1000 IE", bp: false },
];
for (const medication of medications) {
await prisma.medication.create({
data: {
id: medication.id,
userId,
name: medication.name,
dose: medication.dose,
createdAt,
schedules: {
create: [
{
id: `${medication.id}-schedule`,
windowStart: "08:00",
windowEnd: "10:00",
},
],
},
},
});
if (medication.bp) {
await prisma.medicationCategoryAssignment.create({
data: { medicationId: medication.id, category: "BLOOD_PRESSURE" },
});
}
const events = [];
for (let day = 1; day < 100; day++) {
if (day % 9 === 4) continue;
const scheduledFor = daysAgo(day, 8);
events.push({
id: `${medication.id}-intake-${day}`,
userId,
medicationId: medication.id,
scheduledFor,
skipped: day % 11 === 3,
takenAt:
day % 11 === 3
? null
: new Date(scheduledFor.getTime() + 20 * 60_000),
createdAt: scheduledFor,
});
}
await prisma.medicationIntakeEvent.createMany({ data: events });
}
}
async function seedBiomarkers(userId: string): Promise<void> {
const prisma = getPrismaClient();
await prisma.biomarker.create({
data: {
id: `${userId}-${BIOMARKER}`,
userId,
name: "LDL Cholesterol",
unit: "mg/dL",
lowerBound: 0,
upperBound: 116,
createdAt: daysAgo(400),
},
});
await prisma.biomarker.create({
data: {
id: `${userId}-${EMPTY_BIOMARKER}`,
userId,
name: "Ferritin",
unit: "ng/mL",
lowerBound: 30,
upperBound: 400,
createdAt: daysAgo(400),
},
});
const readings = [
{ day: 360, value: 142 },
{ day: 250, value: 131 },
{ day: 130, value: 118 },
{ day: 20, value: 104, source: { low: 0, high: 130, text: "< 130" } },
];
for (const [index, reading] of readings.entries()) {
await prisma.labResult.create({
data: {
id: `${userId}-lab-${index}`,
userId,
biomarkerId: `${userId}-${BIOMARKER}`,
analyte: "LDL",
value: reading.value,
unit: "mg/dL",
referenceLow: 0,
referenceHigh: 116,
takenAt: daysAgo(reading.day, 9),
...(reading.source
? {
sourceReferenceLow: reading.source.low,
sourceReferenceHigh: reading.source.high,
sourceReferenceText: reading.source.text,
}
: {}),
},
});
}
}
async function seedWorld(): Promise<void> {
await makeAccount(RICH, { withProvider: true, profile: "rich" });
await makeAccount(BARE, { withProvider: true, profile: "none" });
await makeAccount(KEYLESS, { withProvider: false, profile: "plain" });
for (const userId of [RICH, KEYLESS]) {
await seedMeasurements(userId);
await seedMood(userId);
await seedMedications(userId);
await seedBiomarkers(userId);
}
}
// ── Recording ────────────────────────────────────────────────────────────────
async function storedNotes(): Promise<unknown[]> {
const rows = await getPrismaClient().insightStatusCache.findMany({
orderBy: [{ userId: "asc" }, { metric: "asc" }, { locale: "asc" }],
});
return rows.map((row) => ({
userId: row.userId,
metric: row.metric,
locale: row.locale,
text: row.textEncrypted ? decryptFromBytes(row.textEncrypted) : null,
items: row.itemsEncrypted
? (JSON.parse(decryptFromBytes(row.itemsEncrypted)) as unknown)
: null,
inputHash: row.inputHash,
snapshotHash: row.snapshotHash,
dateKey: row.dateKey,
generatedAt: row.generatedAt?.toISOString() ?? null,
retryAt: row.retryAt?.toISOString() ?? null,
negativeReason: row.negativeReason,
}));
}
interface Arm {
/** Days after the frozen start the arm runs at. */
day?: number;
setup?: () => void;
run: () => Promise<unknown>;
}
async function record(arms: Record<string, Arm>): Promise<string> {
const out: Record<string, unknown> = {};
for (const [name, arm] of Object.entries(arms)) {
vi.setSystemTime(new Date(NOW.getTime() + (arm.day ?? 0) * DAY_MS));
model.calls = [];
queue.enqueued = [];
answerSummary(`Parity note for ${name}.`);
arm.setup?.();
const result = await arm.run();
await settleBackgroundTasks();
await Promise.allSettled([...pendingWrites]);
pendingWrites.clear();
out[name] = {
// The batch runs its cards concurrently, so the order the requests
// arrive in is not part of the contract; their content is.
calls: readable(
[...model.calls].sort((a, b) =>
JSON.stringify(a).localeCompare(JSON.stringify(b)),
),
),
enqueued: queue.enqueued,
result,
notes: await storedNotes(),
};
}
return `${JSON.stringify(out, null, 2)}\n`;
}
function compareGolden(name: string, actual: string): void {
const path = resolve(GOLDEN_DIR, `${name}.json`);
if (WRITE) {
mkdirSync(GOLDEN_DIR, { recursive: true });
writeFileSync(path, actual);
return;
}
expect(actual).toBe(readFileSync(path, "utf8"));
}
// ── The cards ────────────────────────────────────────────────────────────────
type Options = { locale?: string | null; force?: boolean; readOnly?: boolean };
type Entry = (userId: string, options?: Options) => Promise<unknown>;
/**
* The arms every standard card runs, in order. Each arm sees the notes the
* arms before it stored, which is what makes the cache, unchanged-data and
* next-day arms meaningful.
*/
function standardArms(entry: Entry): Record<string, Arm> {
return {
"read-only-cold": {
run: () => entry(RICH, { locale: "en", readOnly: true }),
},
"fresh-en": { run: () => entry(RICH, { locale: "en" }) },
"cached-en": { run: () => entry(RICH, { locale: "en" }) },
"forced-unchanged-en": {
run: () => entry(RICH, { locale: "en", force: true }),
},
"next-day-en": { day: 1, run: () => entry(RICH, { locale: "en" }) },
"read-only-stale": {
day: 3,
run: () => entry(RICH, { locale: "en", readOnly: true }),
},
"prose-de": {
setup: () => answer("Das ist kein JSON."),
run: () => entry(RICH, { locale: "de" }),
},
"unparseable-fr": {
setup: () => answer('{"summary": "Votre'),
run: () => entry(RICH, { locale: "fr" }),
},
"blocked-es": {
setup: () =>
answerSummary("Aumente su dosis a 2,4 mg la próxima semana."),
run: () => entry(RICH, { locale: "es" }),
},
"bare-error-en": {
setup: fail,
run: () => entry(BARE, { locale: "en" }),
},
"bare-error-de": {
setup: fail,
run: () => entry(BARE, { locale: "de", force: true }),
},
"keyless-en": { run: () => entry(KEYLESS, { locale: "en" }) },
"keyless-de": { run: () => entry(KEYLESS, { locale: "de" }) },
"keyless-read-only": {
run: () => entry(KEYLESS, { locale: "en", readOnly: true }),
},
};
}
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
vi.useFakeTimers({ toFake: ["Date"], now: NOW });
await seedWorld();
});
afterEach(() => {
vi.useRealTimers();
});
describe("status generator output parity", () => {
it(
"pulse",
async () => {
const { generatePulseStatusForUser } =
await import("@/lib/insights/pulse-status");
compareGolden(
"pulse",
await record(standardArms(generatePulseStatusForUser)),
);
},
SLOW,
);
it(
"weight",
async () => {
const { generateWeightStatusForUser } =
await import("@/lib/insights/weight-status");
compareGolden(
"weight",
await record(standardArms(generateWeightStatusForUser)),
);
},
SLOW,
);
it(
"blood-pressure",
async () => {
const { generateBloodPressureStatusForUser } =
await import("@/lib/insights/blood-pressure-status");
compareGolden(
"blood-pressure",
await record(standardArms(generateBloodPressureStatusForUser)),
);
},
SLOW,
);
it(
"mood",
async () => {
const { generateMoodStatusForUser } =
await import("@/lib/insights/mood-status");
compareGolden(
"mood",
await record(standardArms(generateMoodStatusForUser)),
);
},
SLOW,
);
it(
"bmi",
async () => {
const { generateBmiStatusForUser } =
await import("@/lib/insights/bmi-status");
compareGolden(
"bmi",
await record(standardArms(generateBmiStatusForUser)),
);
},
SLOW,
);
it(
"medication-compliance",
async () => {
const { generateMedicationComplianceStatusForUser } =
await import("@/lib/insights/medication-compliance-status");
compareGolden(
"medication-compliance",
await record(standardArms(generateMedicationComplianceStatusForUser)),
);
},
SLOW,
);
it(
"general",
async () => {
const { generateGeneralStatusForUser } =
await import("@/lib/insights/general-status");
compareGolden(
"general",
await record(standardArms(generateGeneralStatusForUser)),
);
},
SLOW,
);
it(
"metric",
async () => {
const { generateMetricStatus } =
await import("@/lib/insights/metric-status");
const arms: Record<string, Arm> = {};
for (const metric of [
"RESTING_HEART_RATE",
"BLOOD_GLUCOSE",
"SLEEP_DURATION",
] as const) {
const entry: Entry = (userId, options) =>
generateMetricStatus({ metric, userId, ...options });
for (const [name, arm] of Object.entries(standardArms(entry))) {
arms[`${metric} ${name}`] = arm;
}
}
arms["VO2_MAX no-data"] = {
run: () =>
generateMetricStatus({
metric: "VO2_MAX",
userId: RICH,
locale: "en",
}),
};
compareGolden("metric", await record(arms));
},
SLOW,
);
it(
"biomarker",
async () => {
const { generateBiomarkerStatus } =
await import("@/lib/insights/biomarker-status");
const arms: Record<string, Arm> = {};
const entry: Entry = (userId, options) =>
generateBiomarkerStatus({
biomarkerId: `${userId}-${BIOMARKER}`,
userId,
...options,
});
for (const [name, arm] of Object.entries(standardArms(entry))) {
arms[name] = arm;
}
arms["no-readings"] = {
run: () =>
generateBiomarkerStatus({
biomarkerId: `${RICH}-${EMPTY_BIOMARKER}`,
userId: RICH,
locale: "en",
}),
};
arms["foreign-marker"] = {
run: () =>
generateBiomarkerStatus({
biomarkerId: `${KEYLESS}-${BIOMARKER}`,
userId: RICH,
locale: "en",
}),
};
compareGolden("biomarker", await record(arms));
},
SLOW,
);
it(
"batch",
async () => {
const { generateStatusBatchForUser } =
await import("@/lib/insights/status-batch");
const { STATUS_BATCH_KEY_BY_METRIC } =
await import("@/lib/ai/prompts/status-batch");
const perMetric = Object.fromEntries(
Object.values(STATUS_BATCH_KEY_BY_METRIC)
.filter((key) => key !== "mood")
.map((key) => [key, `Batched parity note for ${key}.`]),
);
compareGolden(
"batch",
await record({
"batch-en": {
setup: () => answer(JSON.stringify({ perMetric })),
run: () =>
generateStatusBatchForUser(RICH, { locale: "en", force: true }),
},
"batch-unchanged-en": {
run: () =>
generateStatusBatchForUser(RICH, { locale: "en", force: true }),
},
"batch-failed-de": {
setup: fail,
run: () =>
generateStatusBatchForUser(RICH, { locale: "de", force: true }),
},
}),
);
},
SLOW,
);
});