From 6148a179cab6c070993a0a8048c8f721ca07c000 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 14 May 2026 13:52:31 +0200 Subject: [PATCH] feat(briefing): GLP1 plateau detection rule with clinical referral framing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 9 of W4d GLP-1 full integration. New `detectGlp1Plateau()` helper flags the clinical plateau pattern for GLP-1 receptor agonists: ≥21 days on a stable dose AND weight delta within ±0.5 kg over that same window. When the rule fires, a SYSTEM CONTEXT block is appended to the insight-generation user prompt with the named drug, current dose, days-on-dose, and weight delta — and instructs the model to emit a glp1_plateau keyFinding framed observationally. GROUND RULE 14 (insight prompt) keeps the safety contract: the finding NAMES the plateau and the conversation it should prompt ("worth mentioning at the next visit"), but NEVER recommends a dose change. The plateau is detection, not prescription. Detector returns `null` when no GLP-1 medication is active OR no plateau condition is met, so 99% of users pay zero token cost and never see a plateau-shaped section in their briefing. The schema gains "glp1_plateau" in the keyFinding.sourceMetric enum (landed earlier this phase) and the daily-briefing icon mapping inherits the Pill icon for visual consistency with other medication findings. --- src/app/api/insights/generate/route.ts | 17 ++- .../insights/__tests__/glp1-plateau.test.ts | 67 +++++++++ src/lib/insights/glp1-plateau.ts | 138 ++++++++++++++++++ 3 files changed, 221 insertions(+), 1 deletion(-) create mode 100644 src/lib/insights/__tests__/glp1-plateau.test.ts create mode 100644 src/lib/insights/glp1-plateau.ts diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts index 428b4bcda..457300985 100644 --- a/src/app/api/insights/generate/route.ts +++ b/src/app/api/insights/generate/route.ts @@ -2,6 +2,10 @@ import { prisma } from "@/lib/db"; import { auditLog } from "@/lib/auth/audit"; import { apiSuccess, apiError, getClientIp } from "@/lib/api-response"; import { extractFeatures } from "@/lib/insights/features"; +import { + detectGlp1Plateau, + buildGlp1PlateauPrompt, +} from "@/lib/insights/glp1-plateau"; import { buildUserPrompt, type ComparisonSnapshot, @@ -266,12 +270,23 @@ export const POST = apiHandler(async (request: NextRequest) => { // narrative is default-on whenever the toggle is on per research §7 // Q4 — the pulldown is the single affordance. const comparisonSnapshot = await buildComparisonSnapshotForUser(userId); - const userPrompt = buildUserPrompt( + // v1.4.25 W4d — GLP-1 plateau detector. Appends a SYSTEM CONTEXT + // block to the user prompt when the active GLP-1 medication has been + // on a stable dose for ≥21 days with weight delta within ±0.5 kg. + // The block instructs the model to emit a glp1_plateau keyFinding + // framed observationally (no dose recommendation per GROUND RULE 14). + // Returns null when no GLP-1 is active OR no plateau condition; + // 99% of users land in the null branch and pay zero token cost. + const plateauContext = await detectGlp1Plateau(userId); + let userPrompt = buildUserPrompt( featuresJson, dbUser?.insightsPrivacyMode ?? "aggregated", locale, comparisonSnapshot ?? undefined, ); + if (plateauContext) { + userPrompt += buildGlp1PlateauPrompt(plateauContext, locale); + } let result; let workingProviderType: string; diff --git a/src/lib/insights/__tests__/glp1-plateau.test.ts b/src/lib/insights/__tests__/glp1-plateau.test.ts new file mode 100644 index 000000000..707c30690 --- /dev/null +++ b/src/lib/insights/__tests__/glp1-plateau.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; + +import { buildGlp1PlateauPrompt } from "@/lib/insights/glp1-plateau"; + +describe("glp1-plateau", () => { + describe("buildGlp1PlateauPrompt()", () => { + it("renders the EN block with the named drug and dose", () => { + const prompt = buildGlp1PlateauPrompt( + { + drug: "Mounjaro", + doseValue: 7.5, + doseUnit: "mg", + doseSince: "2026-04-01", + daysOnDose: 30, + weightDeltaKg: -0.2, + readingsCount: 9, + }, + "en", + ); + + expect(prompt).toContain("GLP-1 PLATEAU ACTIVE"); + expect(prompt).toContain("Mounjaro 7.5 mg"); + expect(prompt).toContain("2026-04-01"); + expect(prompt).toContain("-0.2 kg"); + expect(prompt).toContain("glp1_plateau"); + expect(prompt).toContain("GROUND RULE 14"); + expect(prompt).toContain("NEVER recommend a dose change"); + }); + + it("renders the DE block with German framing", () => { + const prompt = buildGlp1PlateauPrompt( + { + drug: "Mounjaro", + doseValue: 7.5, + doseUnit: "mg", + doseSince: "2026-04-01", + daysOnDose: 30, + weightDeltaKg: -0.2, + readingsCount: 9, + }, + "de", + ); + + expect(prompt).toContain("GLP-1-PLATEAU AKTIV"); + expect(prompt).toContain("Mounjaro 7.5 mg"); + expect(prompt).toContain("KEINE Dosis-Empfehlung"); + expect(prompt).toContain("GRUNDREGEL 14"); + }); + + it("computes week number from days on dose", () => { + const prompt = buildGlp1PlateauPrompt( + { + drug: "Mounjaro", + doseValue: 7.5, + doseUnit: "mg", + doseSince: "2026-04-01", + daysOnDose: 30, + weightDeltaKg: -0.2, + readingsCount: 9, + }, + "en", + ); + + expect(prompt).toContain("week 4"); + }); + }); +}); diff --git a/src/lib/insights/glp1-plateau.ts b/src/lib/insights/glp1-plateau.ts new file mode 100644 index 000000000..501ee5b78 --- /dev/null +++ b/src/lib/insights/glp1-plateau.ts @@ -0,0 +1,138 @@ +/** + * v1.4.25 W4d — GLP-1 plateau-detection rule. + * + * Server-side detector that flags weight plateau when a user on an + * active GLP-1 medication has been on a stable dose for ≥21 days AND + * their weight has not decreased by more than 0.5 kg in that same + * trailing window. The result is injected into the insight-generation + * user prompt so the LLM can include a plateau finding in the daily + * briefing — framed conservatively per GROUND RULE 14 of the insight + * prompt (defer dose decisions to the clinician, observational tone). + * + * Returns `null` when: + * - the user has no GLP-1 medications, + * - the current dose has been in place < 21 days, + * - weight has dropped by more than 0.5 kg in the same window, + * - or there are fewer than two weight readings to compare. + */ +import { prisma } from "@/lib/db"; + +const PLATEAU_WINDOW_DAYS = 21; +const PLATEAU_THRESHOLD_KG = 0.5; + +export interface Glp1PlateauContext { + /** Display drug name ("Mounjaro", "Ozempic"). */ + drug: string; + /** Current dose value (e.g. 7.5). */ + doseValue: number; + /** Dose unit (e.g. "mg"). */ + doseUnit: string; + /** ISO date the current dose started. */ + doseSince: string; + /** Days the user has been on the current dose. */ + daysOnDose: number; + /** Weight delta in kg over the trailing window (negative = loss). */ + weightDeltaKg: number; + /** Number of weight readings considered. */ + readingsCount: number; +} + +export async function detectGlp1Plateau( + userId: string, + now: Date = new Date(), +): Promise { + const meds = await prisma.medication.findMany({ + where: { userId, treatmentClass: "GLP1", active: true }, + include: { + doseChanges: { orderBy: { effectiveFrom: "desc" }, take: 1 }, + }, + }); + if (meds.length === 0) return null; + + const med = meds[0]; + const latestDose = med.doseChanges[0]; + if (!latestDose) return null; + + const daysOnDose = Math.floor( + (now.getTime() - latestDose.effectiveFrom.getTime()) / + (24 * 60 * 60 * 1000), + ); + if (daysOnDose < PLATEAU_WINDOW_DAYS) return null; + + const windowStart = new Date(now.getTime()); + windowStart.setDate(windowStart.getDate() - PLATEAU_WINDOW_DAYS); + const weightRows = await prisma.measurement.findMany({ + where: { + userId, + type: "WEIGHT", + measuredAt: { gte: windowStart }, + }, + orderBy: { measuredAt: "asc" }, + select: { value: true, measuredAt: true }, + }); + if (weightRows.length < 2) return null; + + const first = weightRows[0].value; + const last = weightRows[weightRows.length - 1].value; + const delta = last - first; + // Plateau condition: weight has NOT decreased by more than the + // threshold. Mild loss within the threshold still counts as a plateau + // because that's the clinically relevant pattern ("stuck at this + // dose"); a hard drop bigger than the threshold means the user is + // still responding. + if (delta < -PLATEAU_THRESHOLD_KG) return null; + + // First word of the drug name as the display token. Falls back to + // the full name if the user typed something unusual. + const drug = med.name.trim().split(/[\s_]/)[0] || med.name.trim(); + + return { + drug, + doseValue: latestDose.doseValue, + doseUnit: latestDose.doseUnit, + doseSince: latestDose.effectiveFrom.toISOString().slice(0, 10), + daysOnDose, + weightDeltaKg: Math.round(delta * 10) / 10, + readingsCount: weightRows.length, + }; +} + +/** + * Render a Markdown-flavoured prompt block for the insight generator + * that names the plateau and tells the model how to frame the + * resulting finding (deferring to the clinician). Returned string is + * appended to the user prompt; the model's GROUND RULE 14 carries + * the dose-prescription refusal. + */ +export function buildGlp1PlateauPrompt( + ctx: Glp1PlateauContext, + locale: "en" | "de", +): string { + if (locale === "de") { + return ` + +SYSTEM CONTEXT — GLP-1-PLATEAU AKTIV + +Der Nutzer nimmt aktuell ${ctx.drug} ${ctx.doseValue} ${ctx.doseUnit} (seit ${ctx.doseSince}, ${ctx.daysOnDose} Tage). In den letzten ${PLATEAU_WINDOW_DAYS} Tagen hat sich das Gewicht nur um ${ctx.weightDeltaKg} kg verändert (n=${ctx.readingsCount} Messungen). Das ist das klinische Plateau-Muster für GLP-1-Rezeptoragonisten. + +Wenn du einen dailyBriefing-keyFinding zu diesem Plateau emittierst: +- sourceMetric: "glp1_plateau" +- tone: "info" +- detail: nenne den Wirkstoff und die aktuelle Dosis ("${ctx.drug} ${ctx.doseValue} ${ctx.doseUnit}, Woche ${Math.floor(ctx.daysOnDose / 7)}") +- Rahmen es als beobachtetes Muster, keine Empfehlung; weise auf das Gespräch mit der behandelnden Ärztin beim nächsten Termin hin +- KEINE Dosis-Empfehlung (GRUNDREGEL 14)`; + } + + return ` + +SYSTEM CONTEXT — GLP-1 PLATEAU ACTIVE + +The user is currently on ${ctx.drug} ${ctx.doseValue} ${ctx.doseUnit} (since ${ctx.doseSince}, ${ctx.daysOnDose} days). In the last ${PLATEAU_WINDOW_DAYS} days their weight has shifted by ${ctx.weightDeltaKg} kg (n=${ctx.readingsCount} readings). That is the clinical plateau pattern for GLP-1 receptor agonists. + +If you emit a dailyBriefing keyFinding for this plateau: +- sourceMetric: "glp1_plateau" +- tone: "info" +- detail: name the drug and current dose ("${ctx.drug} ${ctx.doseValue} ${ctx.doseUnit}, week ${Math.floor(ctx.daysOnDose / 7)}") +- frame as observed pattern, not recommendation; mention conversation with the prescribing clinician at the next visit +- NEVER recommend a dose change (GROUND RULE 14)`; +}