mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
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.
479 lines
15 KiB
TypeScript
479 lines
15 KiB
TypeScript
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,
|
||
};
|
||
}
|
||
|