{
);
expect(found.map((f) => f.value)).toContain(195);
});
+
+ // S10 — the ECG device-verdict descriptor feeds the narrative; its
+ // server-computed counts must be admitted so a device-attributed sentence
+ // ("your device logged 5 ECG recordings") is not stripped by the gate, while
+ // a count the block never produced still trips it.
+ const ecgFeatures = {
+ ecg: {
+ recordingCount: 5,
+ deviceVerdicts: { irregular: 2, notDetected: 3, inconclusive: 0 },
+ latestDeviceVerdict: "IRREGULAR",
+ latestRecordedDaysAgo: 4,
+ latestAverageHeartRate: 61,
+ },
+ } as never;
+
+ it("grounds a restated ECG recording count + latest device figures", () => {
+ const found = findUngroundedBriefingNumbers(
+ {
+ paragraph:
+ "Your device logged 5 ECG recordings this month; the latest, 4 days ago, averaged 61 bpm.",
+ },
+ signals,
+ ecgFeatures,
+ );
+ expect(found).toEqual([]);
+ });
+
+ it("flags an ECG count the descriptor never produced", () => {
+ const found = findUngroundedBriefingNumbers(
+ { paragraph: "Your device logged 12 ECG recordings this month." },
+ signals,
+ ecgFeatures,
+ );
+ expect(found.map((f) => f.value)).toContain(12);
+ });
});
diff --git a/src/lib/ai/__tests__/insight-generator-prompt.test.ts b/src/lib/ai/__tests__/insight-generator-prompt.test.ts
index 0f4a049f9..7174248f5 100644
--- a/src/lib/ai/__tests__/insight-generator-prompt.test.ts
+++ b/src/lib/ai/__tests__/insight-generator-prompt.test.ts
@@ -52,6 +52,22 @@ describe("scope-hardened system prompt", () => {
expect(prompt).toMatch(/verschreibst NICHT/);
});
+ it("English prompt keeps ECG device-verdict-only and forbids interpretation", () => {
+ const prompt = getStrictInsightsSystemPrompt("en");
+ // Attribution + existence allowed; interpretation of the trace forbidden.
+ expect(prompt).toMatch(/ECG recordings are DEVICE-VERDICT ONLY/);
+ expect(prompt).toMatch(/never read morphology/i);
+ expect(prompt).toMatch(/never measure intervals/i);
+ expect(prompt).toMatch(/attribute its result to the device/i);
+ });
+
+ it("German prompt keeps ECG device-verdict-only and forbids interpretation", () => {
+ const prompt = getStrictInsightsSystemPrompt("de");
+ expect(prompt).toMatch(/EKG-Aufzeichnungen sind NUR das GERÄTE-URTEIL/);
+ expect(prompt).toMatch(/keine Morphologie/i);
+ expect(prompt).toMatch(/dem Gerät zuschreiben/i);
+ });
+
it("English prompt instructs the doctor-consult call-to-action", () => {
const prompt = getStrictInsightsSystemPrompt("en");
expect(prompt).toMatch(/consult your doctor/i);
diff --git a/src/lib/ai/briefing-grounding.ts b/src/lib/ai/briefing-grounding.ts
index 99c2419d1..16cb11def 100644
--- a/src/lib/ai/briefing-grounding.ts
+++ b/src/lib/ai/briefing-grounding.ts
@@ -153,6 +153,20 @@ function authoritativeValues(
push(w.latest.daysAgo);
}
}
+ // S10 — ECG device-verdict descriptor. Admit the pre-computed counts + the
+ // latest recording's device-reported HR / recency so a briefing that
+ // references the recordings ("your device logged 2 ECG recordings this
+ // month") stays grounded. Only server-computed figures are added; the
+ // waveform never reaches the payload, so no trace-derived number exists.
+ const ecg = features.ecg;
+ if (ecg) {
+ push(ecg.recordingCount);
+ push(ecg.deviceVerdicts.irregular);
+ push(ecg.deviceVerdicts.notDetected);
+ push(ecg.deviceVerdicts.inconclusive);
+ push(ecg.latestRecordedDaysAgo);
+ push(ecg.latestAverageHeartRate);
+ }
// v1.25.1 — clinical-depth aggregate blocks (grip / waist / pain) wired
// into the briefing. Admit their pre-computed figures so the prose may cite
// them, same posture as the glucose / labs / workout blocks above.
diff --git a/src/lib/ai/prompts/insight-generator.ts b/src/lib/ai/prompts/insight-generator.ts
index ca7fdbbfb..184367af3 100644
--- a/src/lib/ai/prompts/insight-generator.ts
+++ b/src/lib/ai/prompts/insight-generator.ts
@@ -397,7 +397,22 @@ gib die obige Verweigerung zurück.`,
typical mid-titration. Worth mentioning at the next visit if
it persists." This is a SAFETY contract; treat any
dose-prescriptive instinct as a sign the response is
- out-of-bounds.`,
+ out-of-bounds.
+14. ECG recordings are DEVICE-VERDICT ONLY. When the snapshot
+ carries an "ecg" block, it holds ONLY counts, the RECORDING
+ DEVICE's own verdicts (deviceVerdicts / latestDeviceVerdict:
+ irregular / notDetected / inconclusive), and the latest
+ recording's recency + average heart rate. You NEVER see the
+ waveform and you NEVER interpret one. You may note that a
+ recording exists and attribute its result to the device —
+ "your device logged an ECG on
and flagged a possible
+ irregular result" — and, for a non-normal device verdict,
+ suggest discussing that recording with a clinician. You must
+ NEVER present the verdict as your own, never read morphology,
+ never measure intervals or beats, never assign a rhythm
+ diagnosis, and never state or imply that HealthLog analysed the
+ trace. If the "ecg" block is absent, stay silent about ECG —
+ do not mention its absence or suggest recording one.`,
de: `GRUNDREGELN — NULL HALLUZINATIONEN
1. Jede Aussage in "summary" muss auf einer Zahl beruhen, die im
übergebenen Datenpaket sichtbar ist. Lässt sich die Aussage nicht
@@ -601,7 +616,23 @@ gib die obige Verweigerung zurück.`,
mid-titration Phase. Lohnt sich beim nächsten Termin
anzusprechen, falls es darüber hinaus persistiert." Das ist
ein SICHERHEITS-Vertrag; behandle jeden dosis-präskriptiven
- Impuls als Signal, dass die Antwort außerhalb des Skopus liegt.`,
+ Impuls als Signal, dass die Antwort außerhalb des Skopus liegt.
+14. EKG-Aufzeichnungen sind NUR das GERÄTE-URTEIL. Enthält der
+ Snapshot einen "ecg"-Block, trägt er AUSSCHLIESSLICH Zählungen,
+ die EIGENEN Urteile des AUFZEICHNUNGSGERÄTS (deviceVerdicts /
+ latestDeviceVerdict: irregular / notDetected / inconclusive)
+ sowie Aktualität und durchschnittliche Herzfrequenz der letzten
+ Aufzeichnung. Du siehst NIE die Kurve und interpretierst sie
+ NIE. Du darfst erwähnen, dass eine Aufzeichnung existiert, und
+ das Ergebnis dem Gerät zuschreiben — "dein Gerät hat am
+ ein EKG aufgezeichnet und ein mögliches unregelmäßiges Ergebnis
+ markiert" — und bei einem nicht-normalen Geräte-Urteil anregen,
+ diese Aufzeichnung mit einer Ärztin zu besprechen. Du darfst das
+ Urteil NIEMALS als dein eigenes ausgeben, keine Morphologie
+ lesen, keine Intervalle oder Schläge vermessen, keine
+ Rhythmus-Diagnose stellen und niemals andeuten, dass HealthLog
+ die Kurve analysiert hat. Fehlt der "ecg"-Block, schweige zum
+ Thema EKG — erwähne weder sein Fehlen noch rege eine Aufzeichnung an.`,
},
{
id: "guidelineTargets",
diff --git a/src/lib/daily/__tests__/digest.test.ts b/src/lib/daily/__tests__/digest.test.ts
index 45b94b26d..9fc5bae1f 100644
--- a/src/lib/daily/__tests__/digest.test.ts
+++ b/src/lib/daily/__tests__/digest.test.ts
@@ -534,3 +534,122 @@ describe("buildDailyDigest — S11 tension_window item", () => {
expect(tension(d)).toBeUndefined();
});
});
+
+describe("buildDailyDigest — ecg_new_recording (S10)", () => {
+ const ecgItem = (d: ReturnType) =>
+ d.worthALook.find((i) => i.kind === "ecg_new_recording");
+
+ it("emits ONE calm item for a recording within the last day", () => {
+ const d = buildDailyDigest(
+ input({
+ latestEcg: {
+ recordedAt: new Date(NOW.getTime() - 3 * 60 * 60 * 1000),
+ deviceVerdict: "NOT_DETECTED",
+ },
+ }),
+ t,
+ );
+ const item = ecgItem(d);
+ expect(item).toBeDefined();
+ expect(item?.status).toBe("info");
+ expect(item?.moduleKey).toBe("insights");
+ // Single action, deep-linking the ECG viewer.
+ expect(item?.actions).toHaveLength(1);
+ expect(item?.actions[0].intent).toBe("ecg.view");
+ expect(item?.actions[0].href).toBe("/insights#ecg");
+ });
+
+ it("attributes the verdict to the DEVICE (never a HealthLog reading)", () => {
+ const d = buildDailyDigest(
+ input({
+ latestEcg: {
+ recordedAt: new Date(NOW.getTime() - 60 * 60 * 1000),
+ deviceVerdict: "IRREGULAR",
+ },
+ }),
+ t,
+ );
+ const item = ecgItem(d);
+ // Copy leads with the device as the actor, echoes only its verdict, and
+ // never claims HealthLog interpreted the trace.
+ expect(item?.body).toContain("Your device recorded");
+ expect(item?.body).toContain("possible irregular rhythm");
+ expect(item?.body).not.toMatch(/we (detected|found|think)|HealthLog/i);
+ });
+
+ it("does not emit for an OLD recording (outside the last-day window)", () => {
+ const d = buildDailyDigest(
+ input({
+ latestEcg: {
+ recordedAt: new Date(NOW.getTime() - 2 * DAY),
+ deviceVerdict: "IRREGULAR",
+ },
+ }),
+ t,
+ );
+ expect(ecgItem(d)).toBeUndefined();
+ });
+
+ it("does not emit a future-dated recording (clock-skew guard)", () => {
+ const d = buildDailyDigest(
+ input({
+ latestEcg: {
+ recordedAt: new Date(NOW.getTime() + 60 * 60 * 1000),
+ deviceVerdict: "NOT_DETECTED",
+ },
+ }),
+ t,
+ );
+ expect(ecgItem(d)).toBeUndefined();
+ });
+
+ it("does not emit when the insights module is off", () => {
+ const d = buildDailyDigest(
+ input({
+ modules: { insights: false },
+ latestEcg: {
+ recordedAt: new Date(NOW.getTime() - 60 * 60 * 1000),
+ deviceVerdict: "IRREGULAR",
+ },
+ }),
+ t,
+ );
+ expect(ecgItem(d)).toBeUndefined();
+ });
+
+ it("emits nothing when there is no recent recording", () => {
+ const d = buildDailyDigest(input({ latestEcg: null }), t);
+ expect(ecgItem(d)).toBeUndefined();
+ });
+
+ it("uses the calm, verdict-less body when the device gave no verdict", () => {
+ const d = buildDailyDigest(
+ input({
+ latestEcg: {
+ recordedAt: new Date(NOW.getTime() - 60 * 60 * 1000),
+ deviceVerdict: null,
+ },
+ }),
+ t,
+ );
+ const item = ecgItem(d);
+ expect(item?.body).toBe(
+ "Your device recorded a new ECG — it's ready to view.",
+ );
+ });
+
+ it("carries no waveform / sample data on the item or its input DTO", () => {
+ const latestEcg = {
+ recordedAt: new Date(NOW.getTime() - 60 * 60 * 1000),
+ deviceVerdict: "NOT_DETECTED" as const,
+ };
+ // The input DTO is verdict + recordedAt only — no waveform channel exists.
+ expect(Object.keys(latestEcg).sort()).toEqual([
+ "deviceVerdict",
+ "recordedAt",
+ ]);
+ const d = buildDailyDigest(input({ latestEcg }), t);
+ const serialised = JSON.stringify(ecgItem(d));
+ expect(serialised).not.toMatch(/waveform|sample|signal|voltage|microvolt/i);
+ });
+});
diff --git a/src/lib/daily/digest.ts b/src/lib/daily/digest.ts
index ccc868275..c3b2c8aa2 100644
--- a/src/lib/daily/digest.ts
+++ b/src/lib/daily/digest.ts
@@ -108,6 +108,18 @@ export interface DailyDigestTensionWindow {
partOfDay: "morning" | "afternoon" | "evening" | "night";
}
+/**
+ * S10 — the latest ECG recording, for the `ecg_new_recording` rail item. Only
+ * ever the DEVICE's verdict + when it was recorded — NEVER the waveform (the
+ * DTO carries no samples). The builder decides "new" from `recordedAt`; the
+ * card attributes any verdict to the recording device.
+ */
+export interface DailyDigestEcg {
+ recordedAt: Date;
+ /** The recording device's OWN verdict, or null when unclassified. */
+ deviceVerdict: "IRREGULAR" | "NOT_DETECTED" | "INCONCLUSIVE" | null;
+}
+
/**
* A standing coach plan the IO seam offers as a check-in candidate (§2.3). The
* builder computes due-ness deterministically from the plain columns — it never
@@ -160,6 +172,12 @@ export interface DailyDigestInput {
milestone?: Milestone | null;
/** S11 — the day's detected elevated-at-rest window, or null (honest-absent). */
tensionWindow: DailyDigestTensionWindow | null;
+ /**
+ * S10 — the freshest ECG recording (device verdict + recordedAt only), or
+ * null. Optional so consumers that predate the ECG weave stay valid; the
+ * builder treats a missing value as "no recent recording".
+ */
+ latestEcg?: DailyDigestEcg | null;
}
export interface DailyDigest {
@@ -424,6 +442,69 @@ function buildCoachCheckinItem(
};
}
+/**
+ * S10 — how recently an ECG recording must have landed to count as "new" for
+ * the rail (§3.5.3). A calendar-day window: a recording synced within the last
+ * day surfaces once, then retires on its own (no persisted "seen" marker, no
+ * migration). The 24h window is itself the one/day cap — at most one recording
+ * is the freshest, and it drops off the rail after a day.
+ */
+const ECG_NEW_WINDOW_MS = MS_PER_DAY;
+
+/** Device verdict → the calm, device-attributed verdict key for the card body. */
+const ECG_VERDICT_KEYS: Record<
+ NonNullable,
+ string
+> = {
+ IRREGULAR: "daily.item.ecgNewRecording.verdict.irregular",
+ NOT_DETECTED: "daily.item.ecgNewRecording.verdict.notDetected",
+ INCONCLUSIVE: "daily.item.ecgNewRecording.verdict.inconclusive",
+};
+
+/**
+ * The one ECG "new recording" item (§3.5.3). Gated on the `insights` module
+ * (the ECG viewer's own gate). Fires ONLY when the freshest recording landed
+ * within the last day — a calm "a new ECG recording is ready to view" pointer
+ * into the viewer. NON-DIAGNOSTIC: the body echoes ONLY the RECORDING DEVICE's
+ * verdict, attributed to the device; HealthLog never interprets the trace (the
+ * DTO carries no waveform). The 24h window caps it at one/day and retires it on
+ * its own without a persisted seen-marker.
+ */
+function buildEcgNewRecordingItem(
+ ecg: DailyDigestEcg | null | undefined,
+ modules: DigestModuleMap,
+ now: Date,
+ t: Translate,
+): PriorityItem | null {
+ if (!moduleEnabled(modules, "insights")) return null;
+ if (!ecg) return null;
+ const age = now.getTime() - ecg.recordedAt.getTime();
+ // Skip a future-dated row (clock skew) and anything older than the window.
+ if (age < 0 || age > ECG_NEW_WINDOW_MS) return null;
+
+ const verdictKey = ecg.deviceVerdict
+ ? ECG_VERDICT_KEYS[ecg.deviceVerdict]
+ : null;
+ const body = verdictKey
+ ? t("daily.item.ecgNewRecording.bodyVerdict", { verdict: t(verdictKey) })
+ : t("daily.item.ecgNewRecording.body");
+
+ return {
+ kind: "ecg_new_recording",
+ title: t("daily.item.ecgNewRecording.title"),
+ body,
+ status: "info",
+ actions: [
+ {
+ labelKey: "daily.action.viewEcg",
+ intent: "ecg.view",
+ href: "/insights#ecg",
+ },
+ ],
+ moduleKey: "insights",
+ };
+}
+
/**
* The push / lock-screen line: prefer the warmer cached briefing lead, fall
* back to the top signal's headline, then a deterministic score floor, then
@@ -485,6 +566,13 @@ export function buildDailyDigest(
t,
);
if (checkin) worthALook.push(checkin);
+ const ecg = buildEcgNewRecordingItem(
+ input.latestEcg,
+ input.modules,
+ input.now,
+ t,
+ );
+ if (ecg) worthALook.push(ecg);
const preventive = buildPreventiveCareItem(input.preventiveDue, t);
if (preventive) worthALook.push(preventive);
// S11 — the calm, informational tension marker sits last: it is context, not
diff --git a/src/lib/daily/load-digest.ts b/src/lib/daily/load-digest.ts
index 308d917f1..1ce0fbbae 100644
--- a/src/lib/daily/load-digest.ts
+++ b/src/lib/daily/load-digest.ts
@@ -26,6 +26,7 @@ import {
buildDailyDigest,
type DailyDigest,
type DailyDigestCoachPlan,
+ type DailyDigestEcg,
type DailyDigestPreventiveDue,
type DailyDigestScore,
type DailyDigestSyncIssue,
@@ -134,6 +135,9 @@ async function gatherFreshMilestone(
return selectFreshMilestone(candidates, todayKey);
}
+/** S10 — how recently an ECG recording counts as "new" for the rail read. */
+const ECG_NEW_WINDOW_MS = 24 * 60 * 60 * 1000;
+
interface CoachPlanRow {
id: string;
status: string;
@@ -183,6 +187,7 @@ export async function loadDailyDigest(
syncRows,
dueReminders,
planRows,
+ ecgRow,
] = await Promise.all([
readDashboardSnapshotCached(user),
resolveModuleMap(user.id),
@@ -224,6 +229,18 @@ export async function loadDailyDigest(
orderBy: { updatedAt: "desc" },
take: CHECKIN_PLAN_READ_LIMIT,
}),
+ // S10 — the freshest ECG recording within the last-day window, for the
+ // `ecg_new_recording` rail item. Descriptors only (verdict + recordedAt) —
+ // the encrypted waveform is never selected, so no decrypt and no trace
+ // crosses into the digest. The builder gates on the `insights` module.
+ prisma.ecgRecording.findFirst({
+ where: {
+ userId: user.id,
+ recordedAt: { gte: new Date(now.getTime() - ECG_NEW_WINDOW_MS) },
+ },
+ orderBy: { recordedAt: "desc" },
+ select: { recordedAt: true, rhythmClassification: true },
+ }),
]);
const score: DailyDigestScore | null = snapshot.healthScore
@@ -256,6 +273,24 @@ export async function loadDailyDigest(
label: row.label,
}));
+ // S10 — the freshest ECG recording (device verdict + recordedAt only). The
+ // builder decides "new" and gates on the `insights` module. An ECG row only
+ // ever carries an AFib-screening verdict; the shared enum's other members
+ // (walking-steadiness / event codes) never apply, so anything else maps to
+ // null rather than leaking into the device-verdict copy.
+ const verdict = ecgRow?.rhythmClassification;
+ const latestEcg: DailyDigestEcg | null = ecgRow
+ ? {
+ recordedAt: ecgRow.recordedAt,
+ deviceVerdict:
+ verdict === "IRREGULAR" ||
+ verdict === "NOT_DETECTED" ||
+ verdict === "INCONCLUSIVE"
+ ? verdict
+ : null,
+ }
+ : null;
+
// Only decrypt plan prose for a coach-enabled account; the builder gates on
// the module too, so a disabled coach never surfaces a check-in either way.
const coachEnabled = modules.coach !== false;
@@ -297,6 +332,7 @@ export async function loadDailyDigest(
coachPlans,
milestone,
tensionWindow,
+ latestEcg,
},
t,
);
diff --git a/src/lib/insights/__tests__/features.test.ts b/src/lib/insights/__tests__/features.test.ts
index 7feeb4497..3f5037b66 100644
--- a/src/lib/insights/__tests__/features.test.ts
+++ b/src/lib/insights/__tests__/features.test.ts
@@ -13,6 +13,7 @@ vi.mock("@/lib/db", () => ({
labResult: { findMany: vi.fn() },
measurementReminder: { findMany: vi.fn() },
workout: { findMany: vi.fn() },
+ ecgRecording: { findMany: vi.fn() },
$queryRawUnsafe: vi.fn(),
},
}));
@@ -63,6 +64,7 @@ const prismaMock = prisma as unknown as {
labResult: { findMany: ReturnType };
measurementReminder: { findMany: ReturnType };
workout: { findMany: ReturnType };
+ ecgRecording: { findMany: ReturnType };
};
const dayMs = 24 * 60 * 60 * 1000;
@@ -100,6 +102,7 @@ beforeEach(() => {
prismaMock.labResult.findMany.mockResolvedValue([]);
prismaMock.measurementReminder.findMany.mockResolvedValue([]);
prismaMock.workout.findMany.mockResolvedValue([]);
+ prismaMock.ecgRecording.findMany.mockResolvedValue([]);
});
describe("extractFeatures — v1.4.36 W3 bucketed payload", () => {
@@ -433,6 +436,8 @@ describe("extractFeatures — v1.22 integration blocks are briefing-scoped", ()
expect(prismaMock.labResult.findMany).not.toHaveBeenCalled();
expect(prismaMock.measurementReminder.findMany).not.toHaveBeenCalled();
expect(prismaMock.workout.findMany).not.toHaveBeenCalled();
+ // S10 — the ECG descriptor rides the same briefing-scoped gate.
+ expect(prismaMock.ecgRecording.findMany).not.toHaveBeenCalled();
});
it("runs the extra reads on the wide briefing window (sinceDays: 400)", async () => {
@@ -440,5 +445,53 @@ describe("extractFeatures — v1.22 integration blocks are briefing-scoped", ()
expect(prismaMock.labResult.findMany).toHaveBeenCalled();
expect(prismaMock.measurementReminder.findMany).toHaveBeenCalled();
expect(prismaMock.workout.findMany).toHaveBeenCalled();
+ expect(prismaMock.ecgRecording.findMany).toHaveBeenCalled();
+ });
+});
+
+describe("extractFeatures — S10 ECG device-verdict descriptor", () => {
+ const DAY = 86_400_000;
+
+ it("emits a device-verdict descriptor and NEVER a waveform", async () => {
+ const now = Date.now();
+ prismaMock.ecgRecording.findMany.mockResolvedValue([
+ {
+ recordedAt: new Date(now - 2 * DAY),
+ rhythmClassification: "IRREGULAR",
+ averageHeartRate: 72,
+ },
+ {
+ recordedAt: new Date(now - 10 * DAY),
+ rhythmClassification: "NOT_DETECTED",
+ averageHeartRate: 61,
+ },
+ ]);
+ const f = await extractFeatures("user-1", false);
+ expect(f.ecg?.recordingCount).toBe(2);
+ expect(f.ecg?.deviceVerdicts).toEqual({
+ irregular: 1,
+ notDetected: 1,
+ inconclusive: 0,
+ });
+ // The LATEST recording's device verdict + HR (newest-first ordering).
+ expect(f.ecg?.latestDeviceVerdict).toBe("IRREGULAR");
+ expect(f.ecg?.latestAverageHeartRate).toBe(72);
+ // The waveform never reaches the payload — only descriptors.
+ expect(JSON.stringify(f.ecg)).not.toMatch(
+ /waveform|sample|signal|voltage|microvolt/i,
+ );
+ });
+
+ it("omits the ECG block entirely when the user has no recordings", async () => {
+ const f = await extractFeatures("user-1", false);
+ expect(f.ecg).toBeUndefined();
+ });
+
+ it("only ever reads descriptor columns, never the encrypted waveform", async () => {
+ await extractFeatures("user-1", false);
+ const call = prismaMock.ecgRecording.findMany.mock.calls[0]?.[0];
+ expect(call?.select?.waveformEncrypted).toBeUndefined();
+ expect(call?.select?.recordedAt).toBe(true);
+ expect(call?.select?.rhythmClassification).toBe(true);
});
});
diff --git a/src/lib/insights/feature-blocks.ts b/src/lib/insights/feature-blocks.ts
index 32754a075..191aef506 100644
--- a/src/lib/insights/feature-blocks.ts
+++ b/src/lib/insights/feature-blocks.ts
@@ -114,6 +114,12 @@ const LABS_LOOKBACK_MONTHS = 12;
/** Trailing window (days) for the workout aggregate. */
const WORKOUT_WINDOW_DAYS = 90;
+/** Trailing window (days) for the ECG recording descriptor. */
+const ECG_WINDOW_DAYS = 90;
+
+/** Defensive cap on ECG rows read for the descriptor (a bound, not a ceiling). */
+const ECG_MAX_ROWS = 500;
+
const MS_PER_DAY = 24 * 60 * 60 * 1000;
/**
@@ -347,6 +353,68 @@ export async function readWorkoutsBlock(
return { last7: tally(7), last30: tally(30), latest };
}
+/**
+ * S10 — ECG recording descriptor for the briefing narrative (device-verdict
+ * only). A bounded, non-diagnostic snapshot of the user's on-device ECG
+ * recordings over the trailing window: how many exist, the distribution of the
+ * RECORDING DEVICE's OWN verdicts (never HealthLog's), and the latest
+ * recording's device verdict + average heart rate. The waveform is NEVER read
+ * (no decrypt on this path) and NEVER enters the payload — the trace does not
+ * cross into the prompt, so the model cannot interpret morphology. The prompt
+ * (insight-generator rule) constrains any mention to the device's attribution;
+ * the grounding gate constrains any restated count. Returns `undefined` when no
+ * recordings fall in the window, so the block is omitted rather than emitting an
+ * empty shape.
+ */
+export async function readEcgBriefingBlock(
+ userId: string,
+ now: number,
+): Promise {
+ const since = new Date(now - ECG_WINDOW_DAYS * MS_PER_DAY);
+ const rows = await prisma.ecgRecording.findMany({
+ where: { userId, recordedAt: { gte: since } },
+ orderBy: { recordedAt: "desc" },
+ take: ECG_MAX_ROWS,
+ // Descriptors only — the encrypted waveform blob is never selected, so no
+ // decrypt happens and no trace can reach the narrative.
+ select: {
+ recordedAt: true,
+ rhythmClassification: true,
+ averageHeartRate: true,
+ },
+ });
+ if (rows.length === 0) return undefined;
+
+ const deviceVerdicts = { irregular: 0, notDetected: 0, inconclusive: 0 };
+ for (const r of rows) {
+ if (r.rhythmClassification === "IRREGULAR") deviceVerdicts.irregular += 1;
+ else if (r.rhythmClassification === "NOT_DETECTED")
+ deviceVerdicts.notDetected += 1;
+ else if (r.rhythmClassification === "INCONCLUSIVE")
+ deviceVerdicts.inconclusive += 1;
+ }
+
+ // An ECG row only ever carries an AFib-screening verdict; the shared enum's
+ // other members (walking-steadiness / event codes) never apply here, so
+ // anything else narrows to null.
+ const latest = rows[0];
+ const latestVerdict = latest.rhythmClassification;
+ return {
+ recordingCount: rows.length,
+ deviceVerdicts,
+ latestDeviceVerdict:
+ latestVerdict === "IRREGULAR" ||
+ latestVerdict === "NOT_DETECTED" ||
+ latestVerdict === "INCONCLUSIVE"
+ ? latestVerdict
+ : null,
+ latestRecordedDaysAgo: Math.round(
+ (now - latest.recordedAt.getTime()) / MS_PER_DAY,
+ ),
+ latestAverageHeartRate: latest.averageHeartRate,
+ };
+}
+
/**
* Read every BUCKETED_TYPES × BUCKET_WINDOWS combination from the
* persistent rollup table and project to the wire shape. Empty
diff --git a/src/lib/insights/features.ts b/src/lib/insights/features.ts
index 11379b31e..410ea5133 100644
--- a/src/lib/insights/features.ts
+++ b/src/lib/insights/features.ts
@@ -33,6 +33,7 @@ import {
import {
readAllTimeExtremes,
readBucketedSeries,
+ readEcgBriefingBlock,
readLabsBriefingBlock,
readPreventiveCareBlock,
readWorkoutsBlock,
@@ -321,6 +322,33 @@ export interface AggregatedFeatures {
distanceKm: number | null;
} | null;
};
+ /**
+ * S10 — ECG recording descriptor (device-verdict only, NON-DIAGNOSTIC). A
+ * bounded, existence-and-count read of the user's on-device ECG recordings so
+ * the nightly narrative can reference them FACTUALLY and attributed to the
+ * device. `deviceVerdicts` / `latestDeviceVerdict` are the RECORDING DEVICE's
+ * OWN classifications — HealthLog never re-classifies, never reads the
+ * waveform (it never enters this payload), and never emits a verdict of its
+ * own. The prompt constrains any mention to the device's attribution; the
+ * grounding gate constrains any restated count. Omitted when no recordings
+ * fall in the window.
+ */
+ ecg?: {
+ /** Number of ECG recordings in the trailing window. */
+ recordingCount: number;
+ /** Distribution of the DEVICE's own verdicts across the window. */
+ deviceVerdicts: {
+ irregular: number;
+ notDetected: number;
+ inconclusive: number;
+ };
+ /** The latest recording's DEVICE verdict, or null when unclassified. */
+ latestDeviceVerdict: "IRREGULAR" | "NOT_DETECTED" | "INCONCLUSIVE" | null;
+ /** Days since the latest recording. */
+ latestRecordedDaysAgo: number;
+ /** The latest recording's device-reported average HR, or null. */
+ latestAverageHeartRate: number | null;
+ };
}
/**
@@ -1316,14 +1344,19 @@ export async function extractFeatures(
(typeof sinceDays === "number" &&
sinceDays >= INTEGRATION_BLOCK_MIN_WINDOW_DAYS);
if (includeIntegrations) {
- const [labs, preventiveCare, workouts] = await Promise.all([
+ const [labs, preventiveCare, workouts, ecg] = await Promise.all([
readLabsBriefingBlock(userId, now),
readPreventiveCareBlock(userId, now),
readWorkoutsBlock(userId, now),
+ // S10 — ECG device-verdict descriptor (never the waveform). Weaves the
+ // ECG silo into the narrative; the model may reference it, attributed to
+ // the device, but never interprets the trace (it never sees one).
+ readEcgBriefingBlock(userId, now),
]);
if (labs) features.labs = labs;
if (preventiveCare) features.preventiveCare = preventiveCare;
if (workouts) features.workouts = workouts;
+ if (ecg) features.ecg = ecg;
}
// v1.4.36 W3 T1 — "raw" mode no longer dumps every measurement row