Files
HealthLog/src/lib/insights/general-status.ts
T
Marc-André Bombeck f9876f9492 feat(consent): mint AI consent receipt from the web client
The server-side gate requires an active ConsentReceipt before any health
snapshot egresses via the operator's server-managed key, but the web
client never minted one — so web users on a shared-key deployment hit the
fail-closed no-key fallback on every AI surface.

Add ensureWebAiConsentReceipt() and POST /api/consent/ai/web: an
idempotent ai_full grant mirroring the iOS master grant (a user with an
active receipt is a no-op). The AI-settings card calls it on mount — the
web equivalent of the iOS shell-mount heal — so existing accounts gain a
receipt without a re-consent step.

Surface an honest signal: resolveReadOnlyStatusMiss now returns a
consent-missing outcome when a provider is configured but the gate would
block egress, distinct from no-provider, and skips the wasted enqueue.

Document the receipt precondition + the consent surface in the OpenAPI
contract (it shipped without a bump): the /api/consent/ai family, a
Consent tag, and a 403 consent.ai.required response on the AI-egress
route.
2026-06-14 10:25:18 +02:00

479 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { prisma } from "@/lib/db";
import {
getGeneralStatusSystemPrompt,
getGeneralStatusUserPrompt,
} from "@/lib/ai/prompts/general-status";
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 {
normalizeLocale,
normalizeSummaryText,
parseSummaryFromContent,
persistStatusInsight,
round,
summarizeSeries,
} from "@/lib/insights/status-shared";
import { runStatusCompletion } from "@/lib/insights/status-provider";
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 { toBerlinDayKey } from "@/lib/tz/resolver";
// Derived from canonical enum so a new measurement type is auto-included
// in the AI general-status fetch (V3 audit: enum drift cousins).
const MEASUREMENT_TYPES = measurementTypeEnum.options;
// The general overview folds its per-type graded series in memory from a
// single bounded read (take 5000, desc) rather than per-type rollup-tier
// reads: a many-metric account would otherwise fan out ~15 metrics × 3
// rollup round-trips per render. `degradeStatusSnapshotToBudget` already
// caps the multi-metric snapshot, so the bounded in-memory fold is the
// deliberate design here; the rollup tier is wired into the focused
// single-metric cards (weight / pulse / bmi / blood-pressure) instead.
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;
}> {
const locale = normalizeLocale(options?.locale);
const force = options?.force === true;
const readOnly = options?.readOnly === true;
const cacheAction = statusCacheAction("general", locale);
const todayKey = toBerlinDayKey(new Date());
const cached = await readFreshStatusText({
userId,
cacheAction,
todayKey,
force,
});
if (cached) {
return {
hasProvider: true,
text: cached.text,
cached: true,
updatedAt: cached.updatedAt,
};
}
if (readOnly) {
const outcome = await resolveReadOnlyStatusMiss({
userId,
metric: "general",
locale,
});
// v1.16.13 — `consent-missing` serves the same no-key fallback (see
// bmi-status); no enqueue happens for it.
if (outcome.kind === "no-provider" || outcome.kind === "consent-missing") {
return {
hasProvider: false,
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 {
hasProvider: true,
text: outcome.lastGood?.text ?? null,
cached: outcome.lastGood !== null,
updatedAt: outcome.lastGood?.updatedAt ?? null,
preparing: outcome.lastGood === null,
revalidating: outcome.revalidating,
};
}
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
dateOfBirth: true,
},
});
// v1.4.28 FB-D2 — cap the snapshot input. General-status pulls
// every supported measurement type; without a cap the read scales
// linearly with account density.
const measurements = await prisma.measurement
.findMany({
where: {
userId,
type: {
in: [...MEASUREMENT_TYPES],
},
// Soft-deleted rows must never reach the prompt snapshot.
deletedAt: null,
},
orderBy: { measuredAt: "desc" },
take: 5000,
select: {
type: true,
value: true,
measuredAt: true,
},
})
.then((rows) => rows.reverse());
const now = new Date();
// `dailyByType` keeps the `applyPayloadBudget` daily buckets for the
// downstream BP in-target pairing; the prompt embeds the compact
// graded series instead of the full daily array. Skip types with no
// data so a many-metric account can't balloon the snapshot with empty
// series objects.
const dailyByType = new Map<
(typeof MEASUREMENT_TYPES)[number],
ReturnType<typeof applyPayloadBudget>
>();
const measurementSeries = Object.fromEntries(
MEASUREMENT_TYPES.flatMap((type) => {
const records = measurements
.filter((measurement) => measurement.type === type)
.map((measurement) => ({
measuredAt: measurement.measuredAt,
value: measurement.value,
}));
if (records.length === 0) return [];
const series = applyPayloadBudget(records, { now });
dailyByType.set(type, series);
const graded = buildGradedSeriesFromPoints(records, now);
return [
[
type,
{
summary: summarizeSeries(
series.daily.map((bucket) => ({ value: bucket.value })),
),
series: graded,
},
] as const,
];
}),
);
// 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.
const intakeWindowStart = new Date(
now.getTime() - 365 * 24 * 60 * 60 * 1000,
);
const intakeEvents = await prisma.medicationIntakeEvent.findMany({
// v1.7.0 sync — exclude tombstoned rows.
where: {
userId,
deletedAt: null,
scheduledFor: { gte: intakeWindowStart },
},
orderBy: { scheduledFor: "asc" },
select: {
scheduledFor: true,
takenAt: true,
skipped: true,
},
});
const adherenceByDay = new Map<
string,
{ total: number; taken: number; skipped: number }
>();
for (const event of intakeEvents) {
const dayKey = toBerlinDayKey(event.scheduledFor);
const bucket = adherenceByDay.get(dayKey) ?? {
total: 0,
taken: 0,
skipped: 0,
};
bucket.total += 1;
if (!event.skipped && event.takenAt) {
bucket.taken += 1;
} else if (event.skipped) {
bucket.skipped += 1;
}
adherenceByDay.set(dayKey, bucket);
}
// Adherence series — bucket the per-day rate so the model gets the
// canonical 360+24 view. We collapse one rate per day first, then feed
// it into applyPayloadBudget over a synthesised `{measuredAt, value}`
// record list.
const adherenceRecords = Array.from(adherenceByDay.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([day, value]) => ({
measuredAt: new Date(`${day}T00:00:00.000Z`),
value: value.total > 0 ? round((value.taken / value.total) * 100, 1) : 0,
}));
const adherenceSeries = applyPayloadBudget(adherenceRecords, { now });
const adherenceGraded = buildGradedSeriesFromPoints(adherenceRecords, now);
// Fetch mood context (optional — for enrichment only). v1.4.28
// FB-D2 — cap at 90 entries.
const moodEntries = await prisma.moodEntry
.findMany({
where: { userId },
orderBy: { moodLoggedAt: "desc" },
take: 90,
select: { date: true, score: true, moodLoggedAt: true },
})
.then((rows) => rows.reverse());
const moodRecords = moodEntries.map((entry) => ({
measuredAt: entry.moodLoggedAt,
value: entry.score,
}));
const moodSeries = applyPayloadBudget(moodRecords, { now });
const moodGraded = buildGradedSeriesFromPoints(moodRecords, now);
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 A2 — 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>,
);
const snapshotJson = JSON.stringify(snapshot, null, 2);
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 {
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,
);
const outcome = await runStatusCompletion({
userId,
cacheAction,
consentSurface: "insights",
systemPrompt: getGeneralStatusSystemPrompt(locale),
userPrompt: getGeneralStatusUserPrompt(
snapshotJson,
todayKey,
locale,
previousContextBlock,
assessmentContextBlock,
),
// v1.12.7 — match the archetype cards' 0.45.
temperature: 0.45,
maxTokens: 1000,
});
if (outcome.kind === "none") {
return {
hasProvider: false,
text: getNoKeyGeneralStatusText(locale),
cached: true,
updatedAt: null,
};
}
if (outcome.kind === "timeout" || outcome.kind === "error") {
return returnTimeoutFallback({
cacheAction,
reason: outcome.kind,
userId,
todayKey,
stubText: getNoKeyGeneralStatusText(locale),
});
}
const summary = normalizeSummaryText(parseSummaryFromContent(outcome.content));
if (!summary) {
throw new Error("General-status summary was empty after normalization");
}
const updatedAt = await persistStatusInsight({
userId,
cacheAction,
todayKey,
locale,
text: summary,
providerType: outcome.providerType,
model: outcome.model,
tokensUsed: outcome.tokensUsed,
snapshotHash,
});
return {
hasProvider: true,
text: summary,
cached: false,
updatedAt,
};
}