mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +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.
355 lines
10 KiB
TypeScript
355 lines
10 KiB
TypeScript
import { prisma } from "@/lib/db";
|
|
import { getBmiSystemPrompt, getBmiUserPrompt } from "@/lib/ai/prompts/bmi";
|
|
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 { applyPayloadBudget } from "@/lib/insights/bucket-series";
|
|
import {
|
|
buildGradedSeriesWithRollups,
|
|
scaleGradedSeries,
|
|
degradeStatusSnapshotToBudget,
|
|
} from "@/lib/insights/graded-series";
|
|
import {
|
|
type SupportedLocale,
|
|
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";
|
|
|
|
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;
|
|
}> {
|
|
const locale = normalizeLocale(options?.locale);
|
|
const force = options?.force === true;
|
|
const readOnly = options?.readOnly === true;
|
|
const cacheAction = statusCacheAction("bmi", 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: "bmi",
|
|
locale,
|
|
});
|
|
// v1.16.13 — `consent-missing` (provider configured but the
|
|
// server-managed consent gate blocks egress) serves the same no-key
|
|
// fallback; no enqueue happens for it (the resolver short-circuits).
|
|
if (outcome.kind === "no-provider" || outcome.kind === "consent-missing") {
|
|
return {
|
|
hasProvider: false,
|
|
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 {
|
|
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: {
|
|
heightCm: true,
|
|
},
|
|
});
|
|
|
|
if (!user?.heightCm || user.heightCm <= 0) {
|
|
return {
|
|
hasProvider: true,
|
|
text:
|
|
locale === "de"
|
|
? "Für die BMI-Einschätzung fehlen aktuell Größenangaben im Profil."
|
|
: "BMI assessment currently requires height data in the profile.",
|
|
cached: true,
|
|
updatedAt: null,
|
|
};
|
|
}
|
|
|
|
// v1.4.28 FB-D2 — cap the snapshot input (weight runs at most once
|
|
// per day for typical users; 365 covers a full year while the
|
|
// downstream payload budget trims further).
|
|
const measurements = await prisma.measurement
|
|
.findMany({
|
|
where: {
|
|
userId,
|
|
type: "WEIGHT",
|
|
// Soft-deleted rows must never reach the prompt snapshot.
|
|
deletedAt: null,
|
|
},
|
|
orderBy: { measuredAt: "desc" },
|
|
take: 365,
|
|
select: {
|
|
value: true,
|
|
measuredAt: true,
|
|
},
|
|
})
|
|
.then((rows) => rows.reverse());
|
|
|
|
const now = new Date();
|
|
const heightFactor = (user.heightCm / 100) ** 2;
|
|
|
|
const bmiPoints = measurements.map((measurement) => ({
|
|
measuredAt: measurement.measuredAt,
|
|
value: round(measurement.value / heightFactor, 2),
|
|
}));
|
|
// `applyPayloadBudget` daily buckets drive the latest/previous focus;
|
|
// the compact graded series is what reaches the prompt.
|
|
const weightSeries = applyPayloadBudget(bmiPoints, { now });
|
|
// BMI has no rollup tier of its own — it is weight ÷ height², a linear
|
|
// transform by the per-user height factor. Read the WEIGHT tier (recent
|
|
// / weekly from a bounded raw read, monthly / yearly from MONTH / YEAR)
|
|
// and scale by 1 / heightFactor; the scaled series is exact.
|
|
const weightGraded = await buildGradedSeriesWithRollups(
|
|
userId,
|
|
"WEIGHT",
|
|
now,
|
|
);
|
|
const bmiGraded = scaleGradedSeries(weightGraded, 1 / heightFactor, 2);
|
|
const bmiSeries = {
|
|
daily: weightSeries.daily,
|
|
monthly: weightSeries.monthly,
|
|
};
|
|
|
|
// daily[0] = newest bucket (lowest dayOffset).
|
|
const latestBmi = bmiSeries.daily[0] ?? null;
|
|
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 snapshot = {
|
|
locale,
|
|
generatedForDay: todayKey,
|
|
focus: "bmi",
|
|
dataCoverage: {
|
|
totalMeasurements: measurements.length,
|
|
totalSpanDays,
|
|
newestMeasurementDaysAgo,
|
|
},
|
|
bmi: {
|
|
summary: summarizeSeries(
|
|
bmiSeries.daily.map((bucket) => ({ value: bucket.value })),
|
|
),
|
|
series: bmiGraded,
|
|
latestDayFocus: latestBmi
|
|
? {
|
|
dayOffset: latestBmi.dayOffset,
|
|
value: latestBmi.value,
|
|
classification: latestClassification,
|
|
deltaToPreviousDailyPoint:
|
|
previousBmi == null
|
|
? null
|
|
: round(latestBmi.value - previousBmi.value, 2),
|
|
}
|
|
: null,
|
|
target: {
|
|
greenMin: 18.5,
|
|
greenMax: 24.9,
|
|
},
|
|
},
|
|
};
|
|
|
|
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,
|
|
};
|
|
}
|
|
|
|
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: {
|
|
points: bmiSeries.daily.length,
|
|
newestDaysAgo: newestMeasurementDaysAgo,
|
|
},
|
|
repeatCount: steadyRun,
|
|
relations: [],
|
|
},
|
|
locale,
|
|
);
|
|
|
|
const outcome = await runStatusCompletion({
|
|
userId,
|
|
cacheAction,
|
|
consentSurface: "insights",
|
|
systemPrompt: getBmiSystemPrompt(locale),
|
|
userPrompt: getBmiUserPrompt(
|
|
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: getNoKeyBmiStatusText(locale),
|
|
cached: true,
|
|
updatedAt: null,
|
|
};
|
|
}
|
|
if (outcome.kind === "timeout" || outcome.kind === "error") {
|
|
return returnTimeoutFallback({
|
|
cacheAction,
|
|
reason: outcome.kind,
|
|
userId,
|
|
todayKey,
|
|
stubText: getNoKeyBmiStatusText(locale),
|
|
});
|
|
}
|
|
|
|
const summary = normalizeSummaryText(parseSummaryFromContent(outcome.content));
|
|
if (!summary) {
|
|
throw new Error("Bmi-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,
|
|
};
|
|
}
|
|
|
|
export function resolveBmiStatusLocale(
|
|
locale: string | null | undefined,
|
|
): SupportedLocale {
|
|
return normalizeLocale(locale);
|
|
}
|