feat(insights): wire labs, preventive care, workouts and glucose into the briefing

The daily briefing's always-on feature payload carried only the core vitals,
mood, sleep, steps and medication compliance, leaving four captured domains
invisible to it. Add four blocks to the aggregated features:

- Labs: recent flagged biomarkers (out-of-range or trending vs the prior
  reading). Retired markers are excluded; qualitative results stay neutral with
  no fabricated verdict.
- Preventive care: due and overdue items off the server-authoritative next-due
  date, so an overdue screening can prompt an early nudge.
- Workouts: a provider-agnostic count, duration and distance aggregate over the
  trailing windows (the briefing previously saw activity only as step counts).
- Glucose: the present-and-trend block the other vitals carry, where glucose
  previously reached the briefing only as a single signal-of-the-day row.

The three extra reads are scoped to the wide briefing window; the tight
snapshot window that already carries its own context skips them. Every block is
omitted when its domain is empty rather than emitting zeros, and user-entered
labels are sanitised before they reach the prompt.
This commit is contained in:
Marc-André Bombeck committed 2026-06-27 13:34:43 +02:00
1 parent 138211b46a
commit 2f22d66ef7
2 files changed
+586 -2

No files matched your search

+212 -2
View File
@@ -3,12 +3,16 @@ import { describe, expect, it, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: vi.fn() },
measurement: { findMany: vi.fn() },
measurement: { findMany: vi.fn(), groupBy: vi.fn() },
measurementRollup: { findMany: vi.fn() },
moodEntry: { findMany: vi.fn(), findFirst: vi.fn() },
moodEntryRollup: { findMany: vi.fn(), findFirst: vi.fn() },
medication: { findMany: vi.fn() },
medicationIntakeEvent: { findMany: vi.fn() },
// v1.22 — cross-signal integration blocks.
labResult: { findMany: vi.fn() },
measurementReminder: { findMany: vi.fn() },
workout: { findMany: vi.fn() },
$queryRawUnsafe: vi.fn(),
},
}));
@@ -41,7 +45,10 @@ import {
const prismaMock = prisma as unknown as {
user: { findUnique: ReturnType<typeof vi.fn> };
measurement: { findMany: ReturnType<typeof vi.fn> };
measurement: {
findMany: ReturnType<typeof vi.fn>;
groupBy: ReturnType<typeof vi.fn>;
};
measurementRollup: { findMany: ReturnType<typeof vi.fn> };
moodEntry: {
findMany: ReturnType<typeof vi.fn>;
@@ -53,6 +60,9 @@ const prismaMock = prisma as unknown as {
};
medication: { findMany: ReturnType<typeof vi.fn> };
medicationIntakeEvent: { findMany: ReturnType<typeof vi.fn> };
labResult: { findMany: ReturnType<typeof vi.fn> };
measurementReminder: { findMany: ReturnType<typeof vi.fn> };
workout: { findMany: ReturnType<typeof vi.fn> };
};
const dayMs = 24 * 60 * 60 * 1000;
@@ -79,6 +89,7 @@ beforeEach(() => {
gender: "MALE",
});
prismaMock.measurement.findMany.mockResolvedValue([]);
prismaMock.measurement.groupBy.mockResolvedValue([]);
prismaMock.measurementRollup.findMany.mockResolvedValue([]);
prismaMock.moodEntry.findMany.mockResolvedValue([]);
prismaMock.moodEntry.findFirst.mockResolvedValue(null);
@@ -86,6 +97,9 @@ beforeEach(() => {
prismaMock.moodEntryRollup.findFirst.mockResolvedValue(null);
prismaMock.medication.findMany.mockResolvedValue([]);
prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([]);
prismaMock.labResult.findMany.mockResolvedValue([]);
prismaMock.measurementReminder.findMany.mockResolvedValue([]);
prismaMock.workout.findMany.mockResolvedValue([]);
});
describe("extractFeatures — v1.4.36 W3 bucketed payload", () => {
@@ -187,3 +201,199 @@ describe("extractFeatures — v1.4.36 W3 bucketed payload", () => {
expect(FEATURES_MAX_BYTES).toBe(5 * 1024 * 1024);
});
});
// ─── v1.22 — cross-signal integration blocks ──────────────────────────────
const DAY_MS = 24 * 60 * 60 * 1000;
function measurementRow(type: string, value: number, daysAgo: number) {
return {
type,
value,
measuredAt: new Date(Date.now() - daysAgo * DAY_MS),
sleepStage: null,
source: "MANUAL",
deviceType: null,
};
}
function labRow(opts: {
analyte: string;
value: number | null;
referenceLow: number | null;
referenceHigh: number | null;
daysAgo: number;
hidden?: boolean;
valueText?: string | null;
}) {
return {
analyte: opts.analyte,
panel: null,
unit: "mg/dL",
value: opts.value,
valueText: opts.valueText ?? null,
referenceLow: opts.referenceLow,
referenceHigh: opts.referenceHigh,
takenAt: new Date(Date.now() - opts.daysAgo * DAY_MS),
biomarkerId: opts.hidden === undefined ? null : "bm-" + opts.analyte,
biomarker:
opts.hidden === undefined
? null
: {
id: "bm-" + opts.analyte,
name: opts.analyte,
unit: "mg/dL",
lowerBound: opts.referenceLow,
upperBound: opts.referenceHigh,
panel: null,
hidden: opts.hidden,
},
};
}
describe("extractFeatures — v1.22 glucose aggregate block", () => {
it("emits a glucose block from BLOOD_GLUCOSE readings", async () => {
prismaMock.measurement.findMany.mockResolvedValue([
measurementRow("BLOOD_GLUCOSE", 95, 1),
measurementRow("BLOOD_GLUCOSE", 110, 10),
measurementRow("BLOOD_GLUCOSE", 105, 40),
]);
const f = await extractFeatures("user-1", false);
expect(f.glucose).toBeDefined();
expect(f.glucose?.latest).not.toBeNull();
expect(f.glucose?.coverage.count).toBe(3);
});
it("omits the glucose block when there are no readings", async () => {
const f = await extractFeatures("user-1", false);
expect(f.glucose).toBeUndefined();
});
});
describe("extractFeatures — v1.22 labs briefing block", () => {
it("surfaces an abnormal (out-of-range) biomarker", async () => {
prismaMock.labResult.findMany.mockResolvedValue([
labRow({
analyte: "LDL",
value: 200,
referenceLow: 0,
referenceHigh: 130,
daysAgo: 5,
}),
]);
const f = await extractFeatures("user-1", false);
expect(f.labs).toBeDefined();
expect(f.labs?.flagged[0]?.analyte).toBe("LDL");
expect(f.labs?.flagged[0]?.rangeStatus).toBe("above");
});
it("excludes a HIDDEN biomarker even when it is abnormal", async () => {
prismaMock.labResult.findMany.mockResolvedValue([
labRow({
analyte: "Ferritin",
value: 500,
referenceLow: 30,
referenceHigh: 300,
daysAgo: 3,
hidden: true,
}),
]);
const f = await extractFeatures("user-1", false);
// The only marker is hidden + abnormal → block omitted entirely.
expect(f.labs).toBeUndefined();
});
it("does not surface an in-range, non-trending marker", async () => {
prismaMock.labResult.findMany.mockResolvedValue([
labRow({
analyte: "TSH",
value: 2,
referenceLow: 0.4,
referenceHigh: 4,
daysAgo: 4,
}),
]);
const f = await extractFeatures("user-1", false);
expect(f.labs).toBeUndefined();
});
it("surfaces an in-range but trending marker (rising vs prior reading)", async () => {
prismaMock.labResult.findMany.mockResolvedValue([
// Newest first (route orders desc); both in range, but rising.
labRow({
analyte: "Glucose",
value: 99,
referenceLow: 70,
referenceHigh: 100,
daysAgo: 2,
}),
labRow({
analyte: "Glucose",
value: 80,
referenceLow: 70,
referenceHigh: 100,
daysAgo: 60,
}),
]);
const f = await extractFeatures("user-1", false);
expect(f.labs?.flagged[0]?.trend).toBe("rising");
});
});
describe("extractFeatures — v1.22 preventive-care block", () => {
it("buckets reminders into overdue and due", async () => {
prismaMock.measurementReminder.findMany.mockResolvedValue([
{ label: "Blutbild", nextDueAt: new Date(Date.now() - 5 * DAY_MS) },
{ label: "Augenarzt", nextDueAt: new Date(Date.now() + 7 * DAY_MS) },
]);
const f = await extractFeatures("user-1", false);
expect(f.preventiveCare?.overdue[0]?.label).toBe("Blutbild");
expect(f.preventiveCare?.overdue[0]?.daysOverdue).toBeGreaterThanOrEqual(4);
expect(f.preventiveCare?.due[0]?.label).toBe("Augenarzt");
});
it("omits the block when nothing is due or overdue", async () => {
const f = await extractFeatures("user-1", false);
expect(f.preventiveCare).toBeUndefined();
});
});
describe("extractFeatures — v1.22 workouts block", () => {
it("aggregates counts + distance over the windows", async () => {
prismaMock.workout.findMany.mockResolvedValue([
{
sportType: "running",
startedAt: new Date(Date.now() - 1 * DAY_MS),
durationSec: 1800,
totalDistanceM: 5000,
},
{
sportType: "cycling",
startedAt: new Date(Date.now() - 20 * DAY_MS),
durationSec: 3600,
totalDistanceM: 20000,
},
]);
const f = await extractFeatures("user-1", false);
expect(f.workouts?.last7.count).toBe(1);
expect(f.workouts?.last30.count).toBe(2);
expect(f.workouts?.last7.totalDistanceKm).toBe(5);
expect(f.workouts?.latest?.sportType).toBe("running");
});
});
describe("extractFeatures — v1.22 integration blocks are briefing-scoped", () => {
it("skips the extra reads on the tight Coach-snapshot window (sinceDays: 90)", async () => {
await extractFeatures("user-1", false, { sinceDays: 90 });
expect(prismaMock.labResult.findMany).not.toHaveBeenCalled();
expect(prismaMock.measurementReminder.findMany).not.toHaveBeenCalled();
expect(prismaMock.workout.findMany).not.toHaveBeenCalled();
});
it("runs the extra reads on the wide briefing window (sinceDays: 400)", async () => {
await extractFeatures("user-1", false, { sinceDays: 400 });
expect(prismaMock.labResult.findMany).toHaveBeenCalled();
expect(prismaMock.measurementReminder.findMany).toHaveBeenCalled();
expect(prismaMock.workout.findMany).toHaveBeenCalled();
});
});
+374
View File
@@ -26,6 +26,8 @@ import {
type CorrelationResult,
} from "@/lib/analytics/correlations";
import { getMedicationCategories } from "@/lib/medication-category";
import { classifyReferenceRange } from "@/lib/labs/reference-range";
import { resolveLabFields } from "@/lib/labs/serialise";
import { readRollupBuckets } from "@/lib/rollups/measurement-rollups";
import {
ensureUserMoodRollupsFresh,
@@ -197,6 +199,77 @@ export interface AggregatedFeatures {
* forces the briefing to regenerate. See `computeSignalsOfDay`.
*/
signalsOfDay?: SignalOfDay[];
/**
* v1.22 — glucose aggregate block. Glucose previously reached the briefing
* only as a single `signalOfDay` row; this is the present-and-trend block the
* other vitals already carry (7 / 30 / 90-day means + a 30-day slope), from
* the canonical stored value. Omitted when the user has no glucose readings.
*/
glucose?: {
avg7: number | null;
avg30: number | null;
avg90: number | null;
latest: number | null;
latestDaysAgo: number | null;
slope30: number | null;
coverage: DataCoverage;
};
/**
* v1.22 — recent FLAGGED biomarkers (abnormal or trending). Closes the
* biggest siloed domain: labs reached the Coach snapshot but never the
* briefing. Hidden biomarkers (the W3 catalog `hidden` flag) are excluded;
* qualitative rows carry a neutral `unknown` status (no fabricated verdict).
* Only abnormal / trending markers surface (the briefing-relevant ones) and
* the list is bounded. Omitted when nothing is flagged.
*/
labs?: {
flagged: Array<{
analyte: string;
value: number | null;
valueText: string | null;
unit: string;
rangeStatus: "in-range" | "below" | "above" | "unknown";
/** Direction vs the immediately prior reading; null without a prior. */
trend: "rising" | "falling" | "flat" | null;
takenAt: string;
daysAgo: number;
}>;
/** Count of flagged markers (may exceed the bounded `flagged` length). */
flaggedCount: number;
};
/**
* v1.22 — preventive-care (Vorsorge) read-side: due + overdue items. The
* early-nudge use case ("your screening is overdue"). Previously fully siloed
* — captured + scheduled but read by no AI surface. Labels are sanitised
* (user free-text). Omitted when nothing is due or overdue.
*/
preventiveCare?: {
overdue: Array<{ label: string; daysOverdue: number }>;
due: Array<{ label: string; daysUntil: number }>;
};
/**
* v1.22 — workout aggregate. The briefing previously saw activity only as
* ACTIVITY_STEPS; the `Workout` table never reached it. Provider-agnostic
* counts + load over the trailing windows. Omitted when no workouts logged.
*/
workouts?: {
last7: {
count: number;
totalDurationMin: number;
totalDistanceKm: number | null;
};
last30: {
count: number;
totalDurationMin: number;
totalDistanceKm: number | null;
};
latest: {
sportType: string;
daysAgo: number;
durationMin: number;
distanceKm: number | null;
} | null;
};
}
/**
@@ -649,6 +722,267 @@ function computeSignalsOfDay(
.slice(0, 3);
}
// ─── v1.22 — cross-signal integration blocks (briefing-scoped) ────────────
/**
* v1.22 — the integration blocks (labs / preventive-care / workouts) carry
* extra DB reads. They are BRIEFING-scoped: the daily briefing reads a wide
* window (`BRIEFING_FEATURE_WINDOW_DAYS`) and narrates long trends, while the
* Coach snapshot path calls `extractFeatures` with a tight ~90-day window and
* already carries its own labs / illness / workout context. Gating on the read
* window keeps these extra reads off every Coach turn without a new caller flag:
* the briefing window (400) clears it, the Coach window (90) does not.
*/
const INTEGRATION_BLOCK_MIN_WINDOW_DAYS = 180;
/** Days a preventive-care item must be due within to surface as "due soon". */
const PREVENTIVE_DUE_HORIZON_DAYS = 21;
/** Cap on items surfaced per preventive-care bucket. */
const PREVENTIVE_MAX_PER_BUCKET = 5;
/** Cap on flagged biomarkers surfaced to the briefing. */
const LABS_MAX_FLAGGED = 8;
/** Only lab readings within this many months are considered "recent". */
const LABS_LOOKBACK_MONTHS = 12;
/** Trailing window (days) for the workout aggregate. */
const WORKOUT_WINDOW_DAYS = 90;
const MS_PER_DAY = 24 * 60 * 60 * 1000;
/**
* Strip control chars + collapse whitespace, then bound the length, before a
* user-supplied label can reach the briefing prompt. Mirrors the labs /
* illness snapshot label handling — a self-scoped prompt-injection surface.
*/
function sanitizeLabel(text: string, max = 80): string {
let out = "";
for (const ch of text) {
const code = ch.codePointAt(0) ?? 0;
out += code < 0x20 || (code >= 0x7f && code <= 0x9f) ? " " : ch;
}
return out.replace(/\s+/g, " ").trim().slice(0, max);
}
/**
* v1.22 — recent FLAGGED biomarkers (abnormal or trending) for the briefing.
* Most-recent reading per biomarker over the lookback window; hidden markers
* excluded; qualitative rows neutral. Only abnormal (below/above) OR trending
* markers surface, bounded. Returns `undefined` when nothing is flagged so the
* block is omitted rather than emitting an empty shape.
*/
async function readLabsBriefingBlock(
userId: string,
now: number,
): Promise<AggregatedFeatures["labs"] | undefined> {
const nowDate = new Date(now);
const cutoff = new Date(nowDate);
cutoff.setMonth(cutoff.getMonth() - LABS_LOOKBACK_MONTHS);
const rows = await prisma.labResult.findMany({
where: { userId, deletedAt: null, takenAt: { gte: cutoff, lte: nowDate } },
orderBy: { takenAt: "desc" },
take: LABS_MAX_FLAGGED * 16,
select: {
analyte: true,
panel: true,
unit: true,
value: true,
valueText: true,
referenceLow: true,
referenceHigh: true,
takenAt: true,
biomarkerId: true,
biomarker: {
select: {
id: true,
name: true,
unit: true,
lowerBound: true,
upperBound: true,
panel: true,
hidden: true,
},
},
},
});
if (rows.length === 0) return undefined;
// Group rows per biomarker identity (linked id, else lower-cased analyte),
// newest-first, so we can read the latest reading + the immediately prior one
// for a trend. Hidden markers are dropped entirely.
const byMarker = new Map<string, typeof rows>();
for (const row of rows) {
if (row.biomarker?.hidden) continue;
const resolved = resolveLabFields(row, row.biomarker);
const key = row.biomarkerId ?? `analyte:${resolved.analyte.toLowerCase()}`;
const list = byMarker.get(key) ?? [];
list.push(row);
byMarker.set(key, list);
}
const flagged: NonNullable<AggregatedFeatures["labs"]>["flagged"] = [];
for (const list of byMarker.values()) {
const latest = list[0];
const resolved = resolveLabFields(latest, latest.biomarker);
const rangeStatus =
latest.value === null
? ("unknown" as const)
: classifyReferenceRange(
latest.value,
resolved.referenceLow,
resolved.referenceHigh,
);
// Trend = latest numeric reading vs the immediately prior numeric reading.
let trend: "rising" | "falling" | "flat" | null = null;
if (latest.value !== null) {
const prior = list.find((r, i) => i > 0 && r.value !== null);
if (prior?.value != null) {
const delta = latest.value - prior.value;
const eps = Math.max(Math.abs(prior.value) * 0.02, 1e-9);
trend = delta > eps ? "rising" : delta < -eps ? "falling" : "flat";
}
}
const isAbnormal = rangeStatus === "below" || rangeStatus === "above";
const isTrending = trend === "rising" || trend === "falling";
if (!isAbnormal && !isTrending) continue;
flagged.push({
analyte: resolved.analyte,
value: latest.value,
valueText: latest.valueText ? sanitizeLabel(latest.valueText, 60) : null,
unit: resolved.unit,
rangeStatus,
trend,
takenAt: latest.takenAt.toISOString(),
daysAgo: Math.round((now - latest.takenAt.getTime()) / MS_PER_DAY),
});
}
if (flagged.length === 0) return undefined;
// Abnormal markers lead, then most-recent first.
flagged.sort((a, b) => {
const abn = (s: typeof a.rangeStatus) =>
s === "below" || s === "above" ? 0 : 1;
const d = abn(a.rangeStatus) - abn(b.rangeStatus);
return d !== 0 ? d : a.daysAgo - b.daysAgo;
});
return {
flagged: flagged.slice(0, LABS_MAX_FLAGGED),
flaggedCount: flagged.length,
};
}
/**
* v1.22 — preventive-care (Vorsorge) due + overdue read-side. Reads the
* user's enabled, live reminders and buckets by the server-authoritative
* `nextDueAt`. Returns `undefined` when nothing is due or overdue.
*/
async function readPreventiveCareBlock(
userId: string,
now: number,
): Promise<AggregatedFeatures["preventiveCare"] | undefined> {
const horizon = new Date(now + PREVENTIVE_DUE_HORIZON_DAYS * MS_PER_DAY);
const rows = await prisma.measurementReminder.findMany({
where: {
userId,
deletedAt: null,
enabled: true,
nextDueAt: { not: null, lte: horizon },
},
orderBy: { nextDueAt: "asc" },
take: (PREVENTIVE_MAX_PER_BUCKET + 1) * 4,
select: { label: true, nextDueAt: true },
});
if (rows.length === 0) return undefined;
const overdue: NonNullable<AggregatedFeatures["preventiveCare"]>["overdue"] =
[];
const due: NonNullable<AggregatedFeatures["preventiveCare"]>["due"] = [];
for (const r of rows) {
if (!r.nextDueAt) continue;
const label = sanitizeLabel(r.label);
if (!label) continue;
const diffMs = r.nextDueAt.getTime() - now;
if (diffMs < 0) {
overdue.push({ label, daysOverdue: Math.round(-diffMs / MS_PER_DAY) });
} else {
due.push({ label, daysUntil: Math.round(diffMs / MS_PER_DAY) });
}
}
if (overdue.length === 0 && due.length === 0) return undefined;
return {
overdue: overdue.slice(0, PREVENTIVE_MAX_PER_BUCKET),
due: due.slice(0, PREVENTIVE_MAX_PER_BUCKET),
};
}
/**
* v1.22 — workout aggregate over the trailing window. Provider-agnostic:
* counts + summed duration + summed distance (km, when any source reported it)
* over 7 / 30 days, plus the latest workout. Returns `undefined` when no
* workouts fall in the window.
*/
async function readWorkoutsBlock(
userId: string,
now: number,
): Promise<AggregatedFeatures["workouts"] | undefined> {
const since = new Date(now - WORKOUT_WINDOW_DAYS * MS_PER_DAY);
const rows = await prisma.workout.findMany({
where: { userId, startedAt: { gte: since } },
orderBy: { startedAt: "desc" },
take: 2000,
select: {
sportType: true,
startedAt: true,
durationSec: true,
totalDistanceM: true,
},
});
if (rows.length === 0) return undefined;
const tally = (windowDays: number) => {
const cutoff = now - windowDays * MS_PER_DAY;
let count = 0;
let durationSec = 0;
let distanceM = 0;
let anyDistance = false;
for (const w of rows) {
if (w.startedAt.getTime() < cutoff) continue;
count += 1;
durationSec += w.durationSec;
if (w.totalDistanceM != null) {
distanceM += w.totalDistanceM;
anyDistance = true;
}
}
return {
count,
totalDurationMin: Math.round(durationSec / 60),
totalDistanceKm: anyDistance
? Math.round((distanceM / 1000) * 10) / 10
: null,
};
};
const newest = rows[0];
const latest = {
sportType: sanitizeLabel(newest.sportType, 40),
daysAgo: Math.round((now - newest.startedAt.getTime()) / MS_PER_DAY),
durationMin: Math.round(newest.durationSec / 60),
distanceKm:
newest.totalDistanceM != null
? Math.round((newest.totalDistanceM / 1000) * 10) / 10
: null,
};
return { last7: tally(7), last30: tally(30), latest };
}
export async function extractFeatures(
userId: string,
includeRaw: boolean,
@@ -1389,6 +1723,46 @@ export async function extractFeatures(
features.signalsOfDay = signalsOfDay;
}
// v1.22 — glucose aggregate block. Computed from the already-fetched
// measurement set (no extra DB read), so it is always cheap to emit. Glucose
// previously reached the briefing only as one `signalOfDay` row.
const glucoseData = byType("BLOOD_GLUCOSE");
if (glucoseData.length > 0) {
const summary = summarize(toDataPoints(glucoseData));
const newest = glucoseData[glucoseData.length - 1];
features.glucose = {
avg7: summary.avg7,
avg30: summary.avg30,
avg90: avgInWindow(glucoseData, now, 90),
latest: summary.latest,
latestDaysAgo: Math.round(
(now - newest.measuredAt.getTime()) / (24 * 60 * 60 * 1000),
),
slope30: summary.slope30?.slope ?? null,
coverage: computeCoverage(glucoseData, now),
};
}
// v1.22 — cross-signal integration blocks (labs / preventive-care /
// workouts). Briefing-scoped: only the wide briefing window pays the extra
// reads; the tight Coach-snapshot window (which carries its own labs / illness
// / workout context) skips them. Each block is omitted when its domain is
// empty (no fabricated zeros). Fetched in parallel — independent reads.
const includeIntegrations =
sinceCutoff === null ||
(typeof sinceDays === "number" &&
sinceDays >= INTEGRATION_BLOCK_MIN_WINDOW_DAYS);
if (includeIntegrations) {
const [labs, preventiveCare, workouts] = await Promise.all([
readLabsBriefingBlock(userId, now),
readPreventiveCareBlock(userId, now),
readWorkoutsBlock(userId, now),
]);
if (labs) features.labs = labs;
if (preventiveCare) features.preventiveCare = preventiveCare;
if (workouts) features.workouts = workouts;
}
// v1.4.36 W3 T1 — "raw" mode no longer dumps every measurement row
// into the prompt. Instead we attach DAY / WEEK / MONTH bucket means
// from `measurement_rollups` so the model gets the same temporal