mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
feat(briefing): GLP1 plateau detection rule with clinical referral framing
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.
This commit is contained in:
1 parent
8b79ed89c8
commit
6148a179ca
3 files changed
+221
-1
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<Glp1PlateauContext | null> {
|
||||
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)`;
|
||||
}
|
||||
Reference in new issue
Block a user