From 2bd29022b1e5544016b3fe463bff17aaccb1f8d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 14 May 2026 13:28:00 +0200 Subject: [PATCH] feat(coach): snapshot weeklyContext.glp1 names the drug + dose + schedule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2a of W4d GLP-1 full integration. New `buildGlp1SnapshotBlock()` helper reads every active medication classified as GLP1 (treatmentClass column from migration 0046) and assembles a compact block the snapshot ships under `weeklyContext.glp1` — drug display name + generic active ingredient, current dose + weeks-on-dose, chronological titration history, weekly cadence (parsed from MedicationSchedule.daysOfWeek), last + next injection date, last injection site, pen inventory math, and side-effect tag counts from the last 14 days of MoodEntry rows. The block is omitted entirely for users with no GLP-1 medications (the helper short-circuits to null after one indexed Prisma lookup), so web-only generic accounts pay zero token cost. Marc directive 2026-05-14: the Coach must say "your Mounjaro 7.5 mg" instead of "your medication" when the user has named the drug. The snapshot is the load-bearing surface for that — the system-prompt guardrail (GROUND RULE 9) lands in the follow-up commit and forbids any dose-prescriptive use of this data. --- src/lib/ai/coach/glp1-snapshot.ts | 402 ++++++++++++++++++++++++++++++ src/lib/ai/coach/snapshot.ts | 19 ++ 2 files changed, 421 insertions(+) create mode 100644 src/lib/ai/coach/glp1-snapshot.ts diff --git a/src/lib/ai/coach/glp1-snapshot.ts b/src/lib/ai/coach/glp1-snapshot.ts new file mode 100644 index 000000000..84278726d --- /dev/null +++ b/src/lib/ai/coach/glp1-snapshot.ts @@ -0,0 +1,402 @@ +/** + * GLP-1 snapshot block builder for the Coach prompt (v1.4.25 W4d). + * + * Reads every active medication classified as GLP-1 receptor agonist + * (Mounjaro, Ozempic, Wegovy, Zepbound, Trulicity, Saxenda, compounded + * tirzepatide/semaglutide) and assembles a compact block the Coach + * snapshot ships verbatim inside `weeklyContext.glp1`. The Coach uses + * it to ground replies about weight progression, side-effect timing, + * and injection cadence in the user's actual therapy state instead of + * answering generically. + * + * Guardrail context: this block is read-only data — the Coach's system + * prompt (GROUND RULE 9 from v1.4.25 W4d) explicitly forbids dose + * prescriptions. The drug name + current dose + titration history land + * in the prompt so the Coach can say "Mounjaro 7.5 mg, week 3" instead + * of "your medication" — never to make a dose recommendation. + * + * Block omitted entirely when the user has no GLP-1 medications, so + * 99% of users never pay the read cost. + */ +import { prisma } from "@/lib/db"; + +/** + * Recommended generic name for the canonical GLP-1 drug brand. The Coach + * cites the active-ingredient name when discussing pharmacology so + * users with compounded prescriptions (which carry no brand name) + * still get a named reply. + */ +const GLP1_GENERIC_NAMES: Record = { + mounjaro: "Tirzepatide", + zepbound: "Tirzepatide", + ozempic: "Semaglutide", + wegovy: "Semaglutide", + rybelsus: "Semaglutide", + saxenda: "Liraglutide", + victoza: "Liraglutide", + trulicity: "Dulaglutide", +}; + +/** + * Heuristic name normaliser — the Medication.name is free text, so the + * Coach can encounter "Mounjaro KwikPen 5 mg", "Mounjaro_5mg", + * "Tirzepatide (compounded)", etc. We surface a display name (first + * brand-ish token) and the generic active ingredient if we can + * recognise it. Both fields fall back to the raw row name so the Coach + * never sees an empty string. + */ +function deriveDrugNames(raw: string): { display: string; generic: string } { + const trimmed = raw.trim(); + if (!trimmed) return { display: "", generic: "" }; + const lower = trimmed.toLowerCase(); + let display = trimmed; + let generic = trimmed; + for (const [brand, gen] of Object.entries(GLP1_GENERIC_NAMES)) { + if (lower.includes(brand)) { + // Title-case the brand for display so "MOUNJARO_KWIK_5MG" surfaces + // as "Mounjaro" in the prompt — the user shouldn't see DB-shaped + // tokens (matches GROUND RULE 8's "no internal identifiers" rule). + display = brand.charAt(0).toUpperCase() + brand.slice(1); + generic = gen; + break; + } + if (lower.includes(gen.toLowerCase())) { + display = trimmed; + generic = gen; + break; + } + } + return { display, generic }; +} + +const WEEKDAY_KEYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const; + +function parseDaysOfWeek(raw: string | null): number[] { + if (!raw) return []; + return raw + .split(",") + .map((p) => Number(p.trim())) + .filter((n) => Number.isInteger(n) && n >= 0 && n <= 6); +} + +interface DailyValueRow { + date: string; + weekday: string; + value: number; +} + +interface DoseHistoryEntry { + value: number; + unit: string; + effectiveFrom: string; + note: string | null; +} + +interface CurrentDose { + value: number; + unit: string; + since: string; + weeksOnDose: number; +} + +interface ScheduleEntry { + cadence: "weekly" | "daily" | "custom"; + daysOfWeek: number[]; + windowStart: string; + windowEnd: string; +} + +interface LastInjection { + date: string; + site: string | null; + weeksAgo: number; +} + +interface NextInjection { + date: string; + daysAway: number; +} + +interface PenInventory { + pensRemaining: number | null; + dosesRemaining: number | null; + weeksOfSupplyApprox: number | null; +} + +interface SideEffectSummary { + tag: string; + count: number; +} + +export interface Glp1MedicationBlock { + name: string; + genericName: string; + currentDose: CurrentDose | null; + doseHistory: DoseHistoryEntry[]; + schedule: ScheduleEntry | null; + lastInjection: LastInjection | null; + nextInjection: NextInjection | null; + penInventory: PenInventory | null; + sideEffects: SideEffectSummary[]; +} + +export interface Glp1SnapshotBlock { + active: boolean; + medications: Glp1MedicationBlock[]; +} + +interface RawMoodEntry { + moodLoggedAt: Date; + tags: string | null; +} + +const SIDE_EFFECT_TAGS = new Set([ + "nausea", + "constipation", + "diarrhea", + "fatigue", + "appetite-loss", + "heartburn", + "headache", + "vomiting", + "reflux", + // German variants the mood-tag picker exposes — Marc's userbase types + // in both languages and we don't want the snapshot to miss "Übelkeit" + // because the user picked the German chip. + "übelkeit", + "verstopfung", + "durchfall", + "müdigkeit", + "appetitlosigkeit", + "sodbrennen", + "kopfschmerzen", + "erbrechen", +]); + +function parseTagList(raw: string | null): string[] { + if (!raw) return []; + // The MoodEntry.tags column carries either a JSON array (modern + // mood-form writes) or a comma-separated list (legacy moodLog + // imports). Be permissive. + try { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) { + return parsed + .map((v) => (typeof v === "string" ? v.trim() : "")) + .filter(Boolean); + } + } catch { + /* fall through to CSV */ + } + return raw + .split(",") + .map((p) => p.trim()) + .filter(Boolean); +} + +function weeksBetween(from: Date, to: Date): number { + const ms = to.getTime() - from.getTime(); + return Math.max(0, Math.round(ms / (7 * 24 * 60 * 60 * 1000))); +} + +function isoDate(d: Date): string { + return d.toISOString().slice(0, 10); +} + +/** + * Predict next injection from cadence + last intake. + * + * - Weekly cadence with a single weekday: project to the next matching + * weekday after the last intake (or after today if no last intake). + * - Daily / unset cadence: next is tomorrow (the existing reminder + * worker fires daily so the Coach can answer "is today an injection + * day?" honestly without a schema look-up). + */ +function predictNextInjection( + schedule: ScheduleEntry | null, + lastInjection: LastInjection | null, + now: Date, +): NextInjection | null { + if (!schedule) return null; + if (schedule.cadence === "weekly" && schedule.daysOfWeek.length > 0) { + const targetDow = schedule.daysOfWeek[0]; + const anchor = lastInjection + ? new Date(lastInjection.date + "T00:00:00Z") + : now; + // Project forward from the anchor (lastInjection if known, otherwise + // today) until we land on the target weekday. + const cursor = new Date(anchor.getTime()); + for (let i = 1; i <= 14; i += 1) { + cursor.setUTCDate(cursor.getUTCDate() + 1); + if (cursor.getUTCDay() === targetDow) { + const daysAway = Math.max( + 0, + Math.round( + (cursor.getTime() - now.getTime()) / (24 * 60 * 60 * 1000), + ), + ); + return { date: isoDate(cursor), daysAway }; + } + } + } + // Daily cadence fallback: the next dose is "today or tomorrow" + // depending on what's already been logged. Surfacing "tomorrow" keeps + // the Coach from making confident "today is your injection day" + // claims on partial data. + const tomorrow = new Date(now.getTime()); + tomorrow.setUTCDate(tomorrow.getUTCDate() + 1); + return { date: isoDate(tomorrow), daysAway: 1 }; +} + +/** + * Build the GLP-1 snapshot block. Returns `null` when the user has no + * GLP-1 medications so the caller can omit the field entirely (web-only + * generic accounts pay zero token cost). + */ +export async function buildGlp1SnapshotBlock( + userId: string, + now: Date = new Date(), +): Promise { + const meds = await prisma.medication.findMany({ + where: { + userId, + treatmentClass: "GLP1", + active: true, + }, + include: { + schedules: true, + doseChanges: { orderBy: { effectiveFrom: "asc" } }, + inventoryEvents: { orderBy: { occurredAt: "asc" } }, + intakeEvents: { + where: { takenAt: { not: null } }, + orderBy: { takenAt: "desc" }, + take: 1, + select: { + takenAt: true, + injectionSite: true, + }, + }, + }, + }); + + if (meds.length === 0) return null; + + // Recent side-effect tags — last 14 days of mood entries, filtered to + // the curated GLP-1 tag list. We pull once per user even if there are + // multiple GLP-1 meds (rare) and attribute the same recent symptoms + // to each — the Coach can disambiguate from context. + const moodCutoff = new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000); + const moods: RawMoodEntry[] = await prisma.moodEntry.findMany({ + where: { userId, moodLoggedAt: { gte: moodCutoff } }, + select: { moodLoggedAt: true, tags: true }, + }); + const tagCounts = new Map(); + for (const row of moods) { + for (const rawTag of parseTagList(row.tags)) { + const tag = rawTag.toLowerCase(); + if (!SIDE_EFFECT_TAGS.has(tag)) continue; + tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1); + } + } + const recentSideEffects: SideEffectSummary[] = Array.from(tagCounts.entries()) + .sort((a, b) => b[1] - a[1]) + .map(([tag, count]) => ({ tag, count })); + + const medications: Glp1MedicationBlock[] = meds.map((med) => { + const { display, generic } = deriveDrugNames(med.name); + const latestChange = med.doseChanges[med.doseChanges.length - 1] ?? null; + + const doseHistory: DoseHistoryEntry[] = med.doseChanges.map((dc) => ({ + value: dc.doseValue, + unit: dc.doseUnit, + effectiveFrom: isoDate(dc.effectiveFrom), + note: dc.note ?? null, + })); + + const currentDose: CurrentDose | null = latestChange + ? { + value: latestChange.doseValue, + unit: latestChange.doseUnit, + since: isoDate(latestChange.effectiveFrom), + weeksOnDose: weeksBetween(latestChange.effectiveFrom, now), + } + : null; + + // Resolve cadence from the first schedule. Multiple schedules on a + // weekly injection are rare (and conceptually noisy) so we surface + // the first; the medications page is where the user manages the + // detail. + const sched = med.schedules[0] ?? null; + const dows = sched ? parseDaysOfWeek(sched.daysOfWeek) : []; + const schedule: ScheduleEntry | null = sched + ? { + cadence: dows.length === 1 ? "weekly" : dows.length === 0 ? "daily" : "custom", + daysOfWeek: dows, + windowStart: sched.windowStart, + windowEnd: sched.windowEnd, + } + : null; + + const last = med.intakeEvents[0]; + const lastInjection: LastInjection | null = + last && last.takenAt + ? { + date: isoDate(last.takenAt), + site: last.injectionSite ?? null, + weeksAgo: weeksBetween(last.takenAt, now), + } + : null; + + const nextInjection = predictNextInjection(schedule, lastInjection, now); + + // Inventory math: running sum of all inventory events for this med. + // Convert to "weeks of supply" via dosesPerUnit + the cadence so the + // Coach can answer "how long until my next refill?". + let dosesRemaining: number | null = null; + let pensRemaining: number | null = null; + let weeksOfSupplyApprox: number | null = null; + if (med.inventoryEvents.length > 0 && med.dosesPerUnit) { + const units = med.inventoryEvents.reduce((sum, ev) => sum + ev.delta, 0); + pensRemaining = Math.max(0, units); + dosesRemaining = pensRemaining * med.dosesPerUnit; + const dosesPerWeek = + schedule?.cadence === "weekly" + ? 1 + : schedule?.cadence === "daily" + ? 7 + : 1; + weeksOfSupplyApprox = Math.round(dosesRemaining / dosesPerWeek); + } + + const penInventory: PenInventory | null = + pensRemaining !== null + ? { pensRemaining, dosesRemaining, weeksOfSupplyApprox } + : null; + + return { + name: display, + genericName: generic, + currentDose, + doseHistory, + schedule, + lastInjection, + nextInjection, + penInventory, + sideEffects: recentSideEffects, + }; + }); + + return { + active: true, + medications, + }; +} + +/** Exported for tests. */ +export const __testables = { + deriveDrugNames, + parseTagList, + parseDaysOfWeek, + predictNextInjection, + WEEKDAY_KEYS, +}; diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index ab7de9f8f..c4169b61b 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -22,6 +22,7 @@ import { type CoachExcludeMetric, } from "@/lib/validations/coach-prefs"; import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; +import { buildGlp1SnapshotBlock } from "./glp1-snapshot"; import type { CoachProvenance, CoachScope, @@ -650,6 +651,24 @@ export async function buildCoachSnapshot( counts[block.metric] = rows.length; } + // ── v1.4.25 W4d — GLP-1 weeklyContext block ────────────────────── + // + // Only emitted when the user has at least one active GLP-1 medication + // (Medication.treatmentClass = GLP1). Web-only generic accounts never + // pay the read cost — the helper short-circuits to `null` after a + // single indexed Prisma lookup. + // + // The block names the drug, current dose, titration history, last + + // next injection, pen inventory, and recent side-effect tags. The + // Coach's GROUND RULE 9 forbids dose prescriptions — this block + // exists so the reply can SAY "your Mounjaro 7.5 mg" instead of + // "your medication", never to make recommendations. + const glp1Block = await buildGlp1SnapshotBlock(userId, now); + if (glp1Block) { + snapshot.weeklyContext = { glp1: glp1Block }; + metrics.add("compliance"); + } + if (Object.keys(snapshot).length === 0) { metrics.add("general"); }