Files
HealthLog/src/lib/insights/medication-compliance-status.ts
T
Marc-André Bombeck 0baffe04cd fix(insights): screen the medication-adherence card and background lines uniformly
Route the medication-compliance status card through finalizeStatusSummary so
it enforces the same dose / risk / causal outbound contracts its seven sibling
status cards do, not only the unparseable-envelope withhold it already had. A
dose-change imperative or a fabricated figure is now withheld to the controlled
fallback on this surface too.

Add the status cards' structured-envelope detection to the two background hero
lines (arrival reaction, coach nudge): a brace-led or fenced JSON reply, or a
truncated envelope under the char cap, now resolves to the deterministic lead
rather than rendering raw.

Fold in the residuals from the same sweep: strip a truncated SUGGEST-ACTION
marker from the Coach prose tail, drop an unreachable zero-rate branch in the
adherence fold, and document why the internal conversation summary stays off
the render-boundary screen.
2026-07-24 14:32:21 +02:00

651 lines
22 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 {
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,
buildMedicationComplianceBundle,
dailyComplianceRatesFromLedger,
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 { buildMetricSignal } from "@/lib/insights/metric-signal";
import { degradeStatusSnapshotToBudget } from "@/lib/insights/graded-series";
import {
type SupportedLocale,
finalizeStatusSummary,
normalizeLocale,
normalizeSummaryText,
round,
} from "@/lib/insights/status-shared";
import {
isTimeoutStub,
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,
} from "@/lib/insights/status-card-generation";
import { annotate } from "@/lib/logging/context";
// 360 daily days + 24 monthly windows ≈ 1080 days of intake history.
const COMPLIANCE_HISTORY_DAYS = 360 + 24 * 30;
const MS_PER_DAY = 24 * 60 * 60 * 1000;
interface MedicationSummaryItem {
medicationId: string;
text: string;
}
/**
* The richer compliance result envelope (`summary` + per-medication
* `medications`) the cards read — distinct from the standard `text`-only
* status result. It flows through the shared `PreparedStatusCard` union via
* the open index signature on `StatusCardResult`.
*/
interface MedicationComplianceResult {
hasProvider: boolean;
summary: string | null;
medications: MedicationSummaryItem[];
cached: boolean;
updatedAt: string | null;
preparing?: boolean;
revalidating?: boolean;
}
/**
* Public entry — unchanged signature. Prepares the card then runs ONE
* completion through the shared single-card path when the LLM is still
* needed (v1.18.7 HIGH-1).
*/
export async function generateMedicationComplianceStatusForUser(
userId: string,
options?: {
locale?: string | null;
force?: boolean;
readOnly?: boolean;
},
): Promise<MedicationComplianceResult> {
const prepared = await prepareMedicationComplianceStatusForUser(
userId,
options,
);
const result = await runPreparedStatusCard(prepared);
return result as unknown as MedicationComplianceResult;
}
/**
* v1.18.7 (HIGH-1) — 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`.
*/
export async 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;
},
): 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 (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);
// This route carries a richer cached envelope (`summary` +
// `medications`) than the standard `text`-only generators, so it
// keeps its own cache-read — but it shares the stub-rejection
// predicate so a timeout stub never sticks for the day.
if (!force) {
const latestCache = await prisma.auditLog.findFirst({
where: { userId, action: cacheAction },
orderBy: { createdAt: "desc" },
select: { createdAt: true, details: true },
});
if (latestCache?.details) {
try {
const parsed = JSON.parse(latestCache.details) as {
dateKey?: string;
summary?: string;
text?: string;
model?: string;
timeout?: boolean;
medications?: MedicationSummaryItem[];
};
if (
parsed.dateKey === todayKey &&
!isTimeoutStub(parsed) &&
typeof parsed.summary === "string" &&
parsed.summary.trim().length > 0 &&
Array.isArray(parsed.medications)
) {
return {
phase: "served",
result: {
hasProvider: true,
summary: parsed.summary,
medications: parsed.medications.filter(
(entry): entry is MedicationSummaryItem =>
typeof entry?.medicationId === "string" &&
typeof entry?.text === "string" &&
entry.text.trim().length > 0,
),
cached: true,
updatedAt: latestCache.createdAt.toISOString(),
},
};
}
} catch {
// ignore invalid cache payload
}
}
}
// 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,
});
// 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 {
phase: "served",
result: {
hasProvider: false,
summary: getNoKeyMedicationComplianceStatusText(locale),
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.
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,
},
};
}
const medications = 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 },
// 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 H-MED1 — pause eras so paused days drop out of the denominator.
pauseEras: { select: { pausedAt: true, resumedAt: true } },
},
orderBy: { name: "asc" },
});
if (medications.length === 0) {
return {
phase: "served",
result: {
hasProvider: true,
summary:
locale === "de"
? "Aktuell sind keine aktiven Medikamente hinterlegt."
: "There are currently no active medications configured.",
medications: [],
cached: true,
updatedAt: null,
},
};
}
const categoryMap = await getMedicationCategories(
medications.map((medication) => medication.id),
);
const now = new Date();
// v1.4.6 widens the audit window to ~3 years so the bucketed payload
// has both daily (last 360 days) and monthly (months 12-36) coverage.
const rangeStart = new Date(
now.getTime() - COMPLIANCE_HISTORY_DAYS * MS_PER_DAY,
);
const medicationEvents = await prisma.medicationIntakeEvent.findMany({
where: {
userId,
// v1.7.0 sync — exclude tombstoned rows from compliance status.
deletedAt: null,
medicationId: { in: medications.map((medication) => medication.id) },
scheduledFor: { gte: rangeStart },
},
orderBy: { scheduledFor: "asc" },
select: {
medicationId: true,
scheduledFor: true,
takenAt: true,
skipped: true,
},
});
// v1.7.0 SB-SCHED-2 — the compliance-pillar denominators route through
// 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(
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);
const latestDay = dailyBudgeted.daily[0] ?? null;
// v1.18.10 (HIGH-4) — 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,
};
});
const avgCompliance30 =
medicationSnapshots.length > 0
? round(
medicationSnapshots.reduce(
(sum, medication) => sum + medication.compliance30,
0,
) / medicationSnapshots.length,
1,
)
: 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 snapshot = {
locale,
generatedForDay: todayKey,
focus: "medication_compliance",
dataCoverage: {
totalMeasurements: medicationEvents.length,
totalSpanDays,
newestMeasurementDaysAgo,
},
overall: {
medicationCount: medicationSnapshots.length,
averageCompliance30: avgCompliance30,
averageCompliance7: round(
medicationSnapshots.reduce(
(sum, medication) => sum + medication.compliance7,
0,
) / medicationSnapshots.length,
1,
),
},
medications: medicationSnapshots,
};
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.
// This generator caches the richer `{ summary, medications }` envelope,
// so it carries its own gate rather than the shared text-only one.
const snapshotHash = hashInsightSnapshot(snapshot);
const latestRow = await prisma.auditLog.findFirst({
where: { userId, action: cacheAction },
orderBy: { createdAt: "desc" },
select: { details: true },
});
if (latestRow?.details) {
try {
const parsed = JSON.parse(latestRow.details) as {
summary?: string;
medications?: MedicationSummaryItem[];
model?: string;
timeout?: boolean;
snapshotHash?: string;
};
if (
!isTimeoutStub(parsed) &&
typeof parsed.summary === "string" &&
parsed.summary.trim().length > 0 &&
Array.isArray(parsed.medications) &&
parsed.snapshotHash === snapshotHash
) {
const refreshed = await prisma.auditLog.create({
data: {
userId,
action: cacheAction,
details: JSON.stringify({ ...parsed, dateKey: todayKey }),
},
select: { createdAt: true },
});
annotate({
action: { name: "insights.status.skipped_unchanged" },
meta: { cache_action: cacheAction },
});
return {
phase: "served",
result: {
hasProvider: true,
summary: parsed.summary,
medications: parsed.medications,
cached: true,
updatedAt: refreshed.createdAt.toISOString(),
},
};
}
} catch {
// Malformed cache payload — fall through to a real generation.
}
}
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: {
points: medicationEvents.length,
newestDaysAgo: newestMeasurementDaysAgo,
},
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,
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 created = await prisma.auditLog.create({
data: {
userId,
action: cacheAction,
details: JSON.stringify({
dateKey: todayKey,
locale,
summary,
medications: medicationSummaries,
providerType: outcome.providerType,
model: outcome.model,
tokensUsed: outcome.tokensUsed,
snapshotHash,
}),
},
select: { createdAt: true },
});
return {
hasProvider: true,
summary,
medications: medicationSummaries,
cached: false,
updatedAt: created.createdAt.toISOString(),
};
},
};
}
export function resolveMedicationComplianceStatusLocale(
locale: string | null | undefined,
): SupportedLocale {
return normalizeLocale(locale);
}