mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
fix(medications): drop a medication with no schedule from every adherence surface
`calculateCompliance` answers `rate: 100, totalExpected: 0` for a medication that expects no dose. Arithmetically honest, and a lie on a surface that reads the number as adherence — it also inflated the averages the AI prompts are built from. The doctor report has excluded both the PRN arm and the empty-schedule arm since it was written, with the reason beside it. The insights envelope, the blood-pressure gate and the medication-compliance snapshot filtered only PRN, and the empty schedule is reachable: `POST /api/medications` accepts `schedules ?? []` on a scheduled medication. Move both arms into one predicate, `expectsDoses`, and route all four surfaces through it so they cannot drift apart again.
This commit is contained in:
1 parent
f72e236b8a
commit
04361a9fe7
7 files changed
+205
-6
No files matched your search
@@ -17,6 +17,7 @@ import {
|
||||
import {
|
||||
buildComplianceMedicationContext,
|
||||
calculateCompliance,
|
||||
expectsDoses,
|
||||
lastNonSkippedTakenAt,
|
||||
SCHEDULE_COMPLIANCE_SELECT,
|
||||
} from "@/lib/analytics/compliance";
|
||||
@@ -365,7 +366,7 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
|
||||
|
||||
// ── Medication compliance ────────────────────────────────
|
||||
// Bounded reads — left untouched per the directive.
|
||||
const medications = await prisma.medication.findMany({
|
||||
const medicationRows = await prisma.medication.findMany({
|
||||
// v1.16.11 — as-needed (PRN) medications never surface a compliance
|
||||
// rate (no expected doses).
|
||||
where: { userId, active: true, asNeeded: false },
|
||||
@@ -379,6 +380,12 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
|
||||
pauseEras: { select: { pausedAt: true, resumedAt: true } },
|
||||
},
|
||||
});
|
||||
// A medication with no schedule expects no dose, and `calculateCompliance`
|
||||
// answers 100 % for that case. The `asNeeded: false` filter above catches
|
||||
// only the PRN arm; the empty schedule is reachable on a scheduled
|
||||
// medication and would otherwise report perfect adherence.
|
||||
const medications = medicationRows.filter(expectsDoses);
|
||||
|
||||
const categoryMap = await getMedicationCategories(
|
||||
medications.map((m) => m.id),
|
||||
);
|
||||
|
||||
@@ -243,6 +243,28 @@ export function buildComplianceDisplay(
|
||||
* can be driven deterministically from a caller
|
||||
* that already pinned its own `now`.
|
||||
*/
|
||||
/**
|
||||
* Whether a medication expects any dose at all, and so has an adherence rate
|
||||
* worth showing.
|
||||
*
|
||||
* `calculateCompliance` below answers `rate: 100, totalExpected: 0` for a
|
||||
* medication with no schedule. That is arithmetically honest — nothing was
|
||||
* expected, so nothing was missed — and it is a lie on every surface that
|
||||
* reads the number as adherence. PRN is the obvious arm and every caller
|
||||
* already filtered it; the empty schedule on a NON-PRN medication is the arm
|
||||
* that got through, and it is reachable: `POST /api/medications` accepts
|
||||
* `schedules ?? []` on a scheduled medication. The doctor report has excluded
|
||||
* both arms since it was written, so the report never prints a fabricated
|
||||
* 100 %; the same reason applies to every other surface, so the two arms live
|
||||
* in one predicate rather than being re-derived at each call site.
|
||||
*/
|
||||
export function expectsDoses(medication: {
|
||||
asNeeded: boolean;
|
||||
schedules: readonly unknown[];
|
||||
}): boolean {
|
||||
return !medication.asNeeded && medication.schedules.length > 0;
|
||||
}
|
||||
|
||||
export function calculateCompliance(
|
||||
events: IntakeEvent[],
|
||||
schedules: ComplianceSchedule[],
|
||||
|
||||
@@ -21,6 +21,7 @@ import { userDayKey } from "@/lib/tz/resolver";
|
||||
import { resolveCanonicalRecovery } from "@/lib/insights/derived/recovery-resolve";
|
||||
import {
|
||||
buildComplianceMedicationContext,
|
||||
expectsDoses,
|
||||
lastNonSkippedTakenAt,
|
||||
tallyComplianceFromLedger,
|
||||
} from "@/lib/analytics/compliance";
|
||||
@@ -363,9 +364,9 @@ export function buildLedgerCompliance(
|
||||
const compliance: Record<string, DoctorReportCompliance> = {};
|
||||
for (const med of medications) {
|
||||
// PRN / as-needed carry no schedule and no expected dose — excluded so
|
||||
// the report never prints a fabricated 100 %.
|
||||
if (med.asNeeded) continue;
|
||||
if (med.schedules.length === 0) continue;
|
||||
// the report never prints a fabricated 100 %. Both arms now live in the
|
||||
// shared predicate, which is what the other adherence surfaces read.
|
||||
if (!expectsDoses(med)) continue;
|
||||
|
||||
const events = eventsByMedId.get(med.id) ?? [];
|
||||
const ctx = buildComplianceMedicationContext(
|
||||
|
||||
@@ -260,3 +260,76 @@ describe("generateBloodPressureStatusForUser — token-leak hardening (v1.4.27 F
|
||||
expect(parsed.text).not.toContain("metric:");
|
||||
});
|
||||
});
|
||||
|
||||
// The BP-status gate reads its medications' adherence to decide what to say
|
||||
// about a reading. `calculateCompliance` answers `rate: 100, totalExpected: 0`
|
||||
// for a medication with no schedule, so a blood-pressure medication saved
|
||||
// without one told the model the person was perfectly adherent — the exact
|
||||
// fabrication the doctor report has excluded since it was written.
|
||||
describe("generateBloodPressureStatusForUser — no rate without an expectation", () => {
|
||||
const SCHEDULE = {
|
||||
id: "sch-1",
|
||||
windowStart: "07:00",
|
||||
windowEnd: "08:00",
|
||||
timesOfDay: ["07:00"],
|
||||
daysOfWeek: null,
|
||||
rrule: null,
|
||||
rollingIntervalDays: null,
|
||||
reminderGraceMinutes: null,
|
||||
scheduleType: "SCHEDULED",
|
||||
cyclicOnWeeks: null,
|
||||
cyclicOffWeeks: null,
|
||||
doseWindows: [],
|
||||
};
|
||||
|
||||
function bpMed(id: string, name: string, schedules: unknown[]) {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
dose: "5mg",
|
||||
active: true,
|
||||
asNeeded: false,
|
||||
createdAt: new Date(Date.now() - 60 * dayMs),
|
||||
schedules,
|
||||
scheduleRevisions: [],
|
||||
pauseEras: [],
|
||||
};
|
||||
}
|
||||
|
||||
it("omits a blood-pressure medication that expects no dose", async () => {
|
||||
const now = new Date();
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
dateOfBirth: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
{ type: "BLOOD_PRESSURE_SYS", value: 132, measuredAt: now },
|
||||
{ type: "BLOOD_PRESSURE_DIA", value: 84, measuredAt: now },
|
||||
] as never);
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([
|
||||
bpMed("med-empty", "Unscheduled", []),
|
||||
bpMed("med-real", "Amlodipine", [SCHEDULE]),
|
||||
] as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({
|
||||
createdAt: new Date(),
|
||||
} as never);
|
||||
vi.mocked(getMedicationCategories).mockResolvedValue({
|
||||
"med-empty": "BLOOD_PRESSURE",
|
||||
"med-real": "BLOOD_PRESSURE",
|
||||
});
|
||||
|
||||
const captured: { userPrompt: string | null } = { userPrompt: null };
|
||||
stubCompletion('{"summary":"OK"}', captured);
|
||||
|
||||
await generateBloodPressureStatusForUser("user-1", { locale: "en" });
|
||||
|
||||
const snapshot = JSON.parse(captured.userPrompt!.match(/\{[\s\S]*\}/)![0]);
|
||||
const names = snapshot.bpMedications.map((m: { name: string }) => m.name);
|
||||
expect(names).not.toContain("Unscheduled");
|
||||
expect(names).toContain("Amlodipine");
|
||||
});
|
||||
});
|
||||
@@ -360,3 +360,85 @@ describe("generateMedicationComplianceStatusForUser — token-leak hardening (v1
|
||||
expect(parsed.medications[0].text).not.toContain("metric:");
|
||||
});
|
||||
});
|
||||
|
||||
// A medication with no schedule expects no dose. `calculateCompliance` answers
|
||||
// `rate: 100, totalExpected: 0` for that case, and this surface averaged that
|
||||
// 100 into `overall.averageCompliance30` and handed it to the model as the
|
||||
// person's adherence. The doctor report has excluded the empty schedule
|
||||
// alongside PRN since it was written — "so the report never prints a
|
||||
// fabricated 100 %" — and the same reason applies wherever a rate is shown.
|
||||
describe("generateMedicationComplianceStatusForUser — no rate without an expectation", () => {
|
||||
function scheduleless(now: Date) {
|
||||
return {
|
||||
id: "med-empty",
|
||||
name: "Unscheduled",
|
||||
dose: "5mg",
|
||||
active: true,
|
||||
asNeeded: false,
|
||||
createdAt: new Date(now.getTime() - 60 * dayMs),
|
||||
schedules: [],
|
||||
scheduleRevisions: [],
|
||||
pauseEras: [],
|
||||
};
|
||||
}
|
||||
|
||||
it("keeps a schedule-less medication out of the snapshot and its average", async () => {
|
||||
const now = new Date();
|
||||
const scheduled = { ...medFixture(now), asNeeded: false };
|
||||
const events = Array.from({ length: 30 }, (_, day) => {
|
||||
const scheduledFor = new Date(now.getTime() - day * dayMs);
|
||||
// Half the doses taken, so the real medication sits well below 100 %.
|
||||
return {
|
||||
medicationId: "med-1",
|
||||
scheduledFor,
|
||||
takenAt: day % 2 === 0 ? scheduledFor : null,
|
||||
skipped: false,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([
|
||||
scheduled,
|
||||
scheduleless(now),
|
||||
] as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
|
||||
events as never,
|
||||
);
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({
|
||||
createdAt: new Date(),
|
||||
} as never);
|
||||
|
||||
const captured: { userPrompt: string | null } = { userPrompt: null };
|
||||
stubCompletion('{"summary":"OK","medications":[]}', captured);
|
||||
|
||||
await generateMedicationComplianceStatusForUser("user-1", { locale: "en" });
|
||||
|
||||
const snapshot = JSON.parse(captured.userPrompt!.match(/\{[\s\S]*\}/)![0]);
|
||||
const names = snapshot.medications.map((m: { name: string }) => m.name);
|
||||
expect(names).not.toContain("Unscheduled");
|
||||
expect(names).toContain("Ramipril");
|
||||
expect(snapshot.overall.medicationCount).toBe(1);
|
||||
// The fabricated 100 is no longer half of the average handed to the model.
|
||||
expect(snapshot.overall.averageCompliance30).toBeLessThan(100);
|
||||
});
|
||||
|
||||
it("reports no active medications when the only one has no schedule", async () => {
|
||||
const now = new Date();
|
||||
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([
|
||||
scheduleless(now),
|
||||
] as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
|
||||
const outcome = (await generateMedicationComplianceStatusForUser("user-1", {
|
||||
locale: "en",
|
||||
})) as { summary: string | null; medications: unknown[] };
|
||||
|
||||
expect(outcome.medications).toEqual([]);
|
||||
expect(outcome.summary).toContain("no active medications");
|
||||
// Nothing to assess, so no model call and no cache row either.
|
||||
expect(runStatusCompletion).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import {
|
||||
buildComplianceMedicationContext,
|
||||
calculateCompliance,
|
||||
expectsDoses,
|
||||
lastNonSkippedTakenAt,
|
||||
SCHEDULE_COMPLIANCE_SELECT,
|
||||
} from "@/lib/analytics/compliance";
|
||||
@@ -324,7 +325,7 @@ export async function prepareBloodPressureStatusForUser(
|
||||
weightVsSystolicPairs,
|
||||
);
|
||||
|
||||
const activeMedications = await prisma.medication.findMany({
|
||||
const activeMedicationRows = await prisma.medication.findMany({
|
||||
// v1.16.11 — as-needed (PRN) medications never feed the BP-status
|
||||
// compliance gate (no expected doses, no rate).
|
||||
where: { userId, active: true, asNeeded: false },
|
||||
@@ -339,6 +340,12 @@ export async function prepareBloodPressureStatusForUser(
|
||||
},
|
||||
});
|
||||
|
||||
// A medication with no schedule expects no dose, and `calculateCompliance`
|
||||
// answers 100 % for that case. The `asNeeded: false` filter above catches
|
||||
// only the PRN arm; the empty schedule is reachable on a scheduled
|
||||
// medication and would otherwise report perfect adherence.
|
||||
const activeMedications = activeMedicationRows.filter(expectsDoses);
|
||||
|
||||
const categoryMap = await getMedicationCategories(
|
||||
activeMedications.map((medication) => medication.id),
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
buildComplianceMedicationContext,
|
||||
buildMedicationComplianceBundle,
|
||||
dailyComplianceRatesFromLedger,
|
||||
expectsDoses,
|
||||
lastNonSkippedTakenAt,
|
||||
SCHEDULE_COMPLIANCE_SELECT,
|
||||
} from "@/lib/analytics/compliance";
|
||||
@@ -208,7 +209,7 @@ export async function prepareMedicationComplianceStatusForUser(
|
||||
};
|
||||
}
|
||||
|
||||
const medications = await prisma.medication.findMany({
|
||||
const medicationRows = await prisma.medication.findMany({
|
||||
// v1.16.11 — as-needed (PRN) medications never surface a compliance
|
||||
// rate (no expected doses).
|
||||
where: { userId, active: true, asNeeded: false },
|
||||
@@ -224,6 +225,12 @@ export async function prepareMedicationComplianceStatusForUser(
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
|
||||
// A medication with no schedule expects no dose, and `calculateCompliance`
|
||||
// answers 100 % for that case. The `asNeeded: false` filter above catches
|
||||
// only the PRN arm; the empty schedule is reachable on a scheduled
|
||||
// medication and would otherwise report perfect adherence.
|
||||
const medications = medicationRows.filter(expectsDoses);
|
||||
|
||||
if (medications.length === 0) {
|
||||
return {
|
||||
phase: "served",
|
||||
|
||||
Reference in new issue
Block a user