feat(coach): snapshot weeklyContext.glp1 names the drug + dose + schedule

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.
This commit is contained in:
Marc-André Bombeck committed 2026-05-14 13:28:00 +02:00
1 parent a732b1926e
commit 2bd29022b1
2 files changed
+421

No files matched your search

+402
View File
@@ -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<string, string> = {
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<Glp1SnapshotBlock | null> {
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<string, number>();
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,
};
+19
View File
@@ -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");
}