mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
The outbound safety screen ran on the Coach only. A dose-change imperative or a fabricated clinical risk score written onto a per-metric, biomarker or derived-score status card persisted as that day's cached assessment, while the identical sentence in the Coach was caught and replaced -- the only transform between provider and persistence was whitespace normalisation. Move the pattern banks into a shared screen that takes the reader's locale. The old signature took no locale, so the dose-refusal and risk-score contracts had no enforcement for fr/es/it/pl even though the ground rules ship in all six contract files. Banks now cover all six locales and each screen runs the reader's bank plus English, since a provider often answers in English regardless of the language directive. Status cards withhold on a trip: serve the deterministic stub and persist no model text. Nobody waits on a background-generated card, and persisting a refusal would pin it to the card for the day.
489 lines
15 KiB
TypeScript
489 lines
15 KiB
TypeScript
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 { applyPayloadBudget } from "@/lib/insights/bucket-series";
|
|
import {
|
|
buildGradedSeriesWithRollups,
|
|
scaleGradedSeries,
|
|
degradeStatusSnapshotToBudget,
|
|
} from "@/lib/insights/graded-series";
|
|
import { buildMetricSignal } from "@/lib/insights/metric-signal";
|
|
import {
|
|
type SupportedLocale,
|
|
normalizeLocale,
|
|
normalizeSummaryText,
|
|
finalizeStatusSummary,
|
|
parseSummaryFromContent,
|
|
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 {
|
|
runPreparedStatusCard,
|
|
type PreparedStatusCard,
|
|
type StatusCardResult,
|
|
} from "@/lib/insights/status-card-generation";
|
|
import { annotate } from "@/lib/logging/context";
|
|
import { resolveUserTimezone, userDayKey } from "@/lib/tz/resolver";
|
|
|
|
/**
|
|
* Public entry — unchanged signature. Prepares the card (cache-read,
|
|
* read-only miss, snapshot build, hash gate) then, when the LLM is still
|
|
* needed, runs ONE completion through the shared single-card path. The
|
|
* one-call-per-metric behaviour and the timeout / no-provider fallbacks are
|
|
* byte-for-byte the same as before the prepare split (v1.18.7 HIGH-1).
|
|
*/
|
|
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 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;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* v1.18.7 (HIGH-1) — 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.
|
|
*/
|
|
export async 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;
|
|
},
|
|
): 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 (QA F5) — 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,
|
|
});
|
|
// 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 {
|
|
phase: "served",
|
|
result: {
|
|
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 {
|
|
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,
|
|
},
|
|
};
|
|
}
|
|
|
|
const user = await prisma.user.findUnique({
|
|
where: { id: userId },
|
|
select: {
|
|
heightCm: true,
|
|
},
|
|
});
|
|
|
|
if (!user?.heightCm || user.heightCm <= 0) {
|
|
return {
|
|
phase: "served",
|
|
result: {
|
|
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.18.11 (P6) — input gate for this slow-moving metric. The snapshot
|
|
// derives from WEIGHT rows and the profile heightCm; a cheap probe over
|
|
// both fingerprints the inputs, so a non-forced unchanged week re-stamps
|
|
// the cached assessment and skips the whole build (findMany + BMI series +
|
|
// provider). A forced run never gates but still records a current
|
|
// fingerprint for the next day's gate.
|
|
const inputHash = await computeStatusInputFingerprint({
|
|
userId,
|
|
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,
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|
|
// 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();
|
|
// v1.2.5 (M-TZ3) — bucket day windows on the user's own calendar so a
|
|
// near-midnight reading lands on the user's day for non-Berlin self-hosters.
|
|
// `userTz` was already resolved above for the cache day-key.
|
|
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, tz: userTz });
|
|
// 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;
|
|
|
|
// v1.18.10 (HIGH-4) — hand the model the finished recent-vs-baseline
|
|
// comparison + normal-swing verdict instead of asking it to derive them.
|
|
// BMI reads against the WHO healthy band; "target-band" framing.
|
|
const bmiSignal = buildMetricSignal({
|
|
metric: locale === "en" ? "your BMI" : "dein BMI",
|
|
direction: "target-band",
|
|
graded: bmiGraded,
|
|
normalRange: { low: 18.5, high: 24.9 },
|
|
newestDaysAgo: newestMeasurementDaysAgo,
|
|
});
|
|
|
|
const snapshot = {
|
|
locale,
|
|
generatedForDay: todayKey,
|
|
focus: "bmi",
|
|
dataCoverage: {
|
|
totalMeasurements: measurements.length,
|
|
totalSpanDays,
|
|
newestMeasurementDaysAgo,
|
|
},
|
|
bmi: {
|
|
...(bmiSignal ? { signal: bmiSignal } : {}),
|
|
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 {
|
|
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: {
|
|
points: bmiSeries.daily.length,
|
|
newestDaysAgo: newestMeasurementDaysAgo,
|
|
},
|
|
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),
|
|
}),
|
|
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,
|
|
locale,
|
|
text: summary,
|
|
providerType: outcome.providerType,
|
|
model: outcome.model,
|
|
tokensUsed: outcome.tokensUsed,
|
|
snapshotHash,
|
|
// v1.18.11 (P6) — persist the input fingerprint for tomorrow's gate.
|
|
inputHash,
|
|
});
|
|
return {
|
|
hasProvider: true,
|
|
text: summary,
|
|
cached: false,
|
|
updatedAt,
|
|
};
|
|
},
|
|
};
|
|
}
|
|
|
|
export function resolveBmiStatusLocale(
|
|
locale: string | null | undefined,
|
|
): SupportedLocale {
|
|
return normalizeLocale(locale);
|
|
}
|