mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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:
1 parent
a732b1926e
commit
2bd29022b1
2 files changed
+421
No files matched your search
@@ -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,
|
||||
};
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user