From d82862b384336a43b932470b42a17fad09cb4553 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 17 Jul 2026 08:19:17 +0200 Subject: [PATCH] feat(insights): weave ECG into the briefing narrative and daily rail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fold the ECG silo into the insights narrative without crossing the non-diagnostic line — the app reports the recording device's own verdict and the existence/count of recordings, never an interpretation of the trace. - Feature snapshot: a bounded, device-verdict-only ECG descriptor (recording count, device-verdict distribution, latest recorded-days-ago + average HR) flows into the nightly briefing prompt. The waveform never enters the payload; the grounding gate admits the pre-computed counts so a device-attributed sentence stays grounded. A prompt rule (EN + DE) constrains any mention to the device's attribution and forbids reading morphology, measuring intervals, or emitting a verdict of the app's own. - Pulse cross-link: a device-attributed pointer from the resting-HR / pulse page into the ECG viewer, un-mounting when no recordings exist. - Daily rail: an ecg_new_recording item surfaces once when a recording landed within the last day — calm, info-level, deep-linking the viewer. Gated on the insights module; the DTO carries no waveform. - i18n across all six locales; non-diagnostic disclaimer copy per language. --- messages/de.json | 20 ++- messages/en.json | 20 ++- messages/es.json | 20 ++- messages/fr.json | 20 ++- messages/it.json | 20 ++- messages/pl.json | 20 ++- src/app/insights/pulse/page.tsx | 7 ++ .../__tests__/ecg-cross-link.test.tsx | 81 ++++++++++++ src/components/insights/ecg-cross-link.tsx | 106 ++++++++++++++++ src/components/insights/ecg-section.tsx | 3 +- .../ai/__tests__/briefing-grounding.test.ts | 35 ++++++ .../insight-generator-prompt.test.ts | 16 +++ src/lib/ai/briefing-grounding.ts | 14 +++ src/lib/ai/prompts/insight-generator.ts | 35 +++++- src/lib/daily/__tests__/digest.test.ts | 119 ++++++++++++++++++ src/lib/daily/digest.ts | 88 +++++++++++++ src/lib/daily/load-digest.ts | 36 ++++++ src/lib/insights/__tests__/features.test.ts | 53 ++++++++ src/lib/insights/feature-blocks.ts | 68 ++++++++++ src/lib/insights/features.ts | 35 +++++- 20 files changed, 806 insertions(+), 10 deletions(-) create mode 100644 src/components/insights/__tests__/ecg-cross-link.test.tsx create mode 100644 src/components/insights/ecg-cross-link.tsx diff --git a/messages/de.json b/messages/de.json index c8907407d..cba744ebf 100644 --- a/messages/de.json +++ b/messages/de.json @@ -3856,6 +3856,13 @@ }, "waveform": { "ariaLabel": "EKG-Streifen aufgezeichnet {date}, Dauer {duration}, durchschnittliche Herzfrequenz {heartRate}, aufgezeichnetes Ergebnis: {result}." + }, + "crossLink": { + "title": "EKG-Aufzeichnungen", + "recordingsOne": "1 Aufzeichnung vorhanden.", + "recordingsMany": "{count} Aufzeichnungen vorhanden.", + "latestResult": "Letztes Geräte-Ergebnis: {result}.", + "cta": "Aufzeichnungen ansehen" } } }, @@ -8549,6 +8556,16 @@ "title": "Zeit, einen Plan anzuschauen", "body": "Es ist etwa eine Woche her, seit du diesen Plan gesetzt hast — behalte ihn, passe ihn an oder lass ihn los. Ganz ohne Druck.", "bodyPlan": "Vor etwa einer Woche hast du dir vorgenommen: „{plan}“. Behalte es, passe es an oder lass es los — ganz ohne Druck." + }, + "ecgNewRecording": { + "title": "Neue EKG-Aufzeichnung", + "body": "Dein Gerät hat ein neues EKG aufgezeichnet — bereit zum Ansehen.", + "bodyVerdict": "Dein Gerät hat ein neues EKG aufgezeichnet und meldet: {verdict}.", + "verdict": { + "irregular": "einen möglichen unregelmäßigen Rhythmus", + "notDetected": "keinen unregelmäßigen Rhythmus", + "inconclusive": "ein nicht eindeutiges Ergebnis" + } } }, "action": { @@ -8557,7 +8574,8 @@ "viewCheckups": "Vorsorge ansehen", "checkinKeep": "Behalten", "checkinAdjust": "Anpassen", - "checkinLetGo": "Loslassen" + "checkinLetGo": "Loslassen", + "viewEcg": "Aufzeichnung ansehen" }, "today": { "scoreLabel": "Gesundheitsscore", diff --git a/messages/en.json b/messages/en.json index 11f67878f..50e35cab9 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3856,6 +3856,13 @@ }, "waveform": { "ariaLabel": "ECG strip recorded {date}, duration {duration}, average heart rate {heartRate}, recorded result: {result}." + }, + "crossLink": { + "title": "ECG recordings", + "recordingsOne": "1 recording on file.", + "recordingsMany": "{count} recordings on file.", + "latestResult": "Latest device result: {result}.", + "cta": "View recordings" } } }, @@ -8549,6 +8556,16 @@ "title": "Time to check in on a plan", "body": "It's been about a week since you set this plan — keep it, adjust it, or let it go. No pressure either way.", "bodyPlan": "About a week ago you set: “{plan}”. Keep it, adjust it, or let it go — no pressure either way." + }, + "ecgNewRecording": { + "title": "New ECG recording", + "body": "Your device recorded a new ECG — it's ready to view.", + "bodyVerdict": "Your device recorded a new ECG and reported: {verdict}.", + "verdict": { + "irregular": "a possible irregular rhythm", + "notDetected": "no irregular rhythm", + "inconclusive": "an inconclusive result" + } } }, "action": { @@ -8557,7 +8574,8 @@ "viewCheckups": "View check-ups", "checkinKeep": "Keep it", "checkinAdjust": "Adjust", - "checkinLetGo": "Let it go" + "checkinLetGo": "Let it go", + "viewEcg": "View recording" }, "today": { "scoreLabel": "Health score", diff --git a/messages/es.json b/messages/es.json index 79883e68e..ed0f1ecbd 100644 --- a/messages/es.json +++ b/messages/es.json @@ -3856,6 +3856,13 @@ }, "waveform": { "ariaLabel": "Tira de ECG registrada el {date}, duración {duration}, frecuencia cardíaca media {heartRate}, resultado registrado: {result}." + }, + "crossLink": { + "title": "Registros de ECG", + "recordingsOne": "1 registro guardado.", + "recordingsMany": "{count} registros guardados.", + "latestResult": "Último resultado del dispositivo: {result}.", + "cta": "Ver registros" } } }, @@ -8549,6 +8556,16 @@ "title": "Es buen momento para revisar un plan", "body": "Ha pasado alrededor de una semana desde que definiste este plan: consérvalo, ajústalo o déjalo ir. Sin presión.", "bodyPlan": "Hace aproximadamente una semana te propusiste: «{plan}». Consérvalo, ajústalo o déjalo ir, sin presión." + }, + "ecgNewRecording": { + "title": "Nuevo registro de ECG", + "body": "Tu dispositivo registró un nuevo ECG: está listo para verlo.", + "bodyVerdict": "Tu dispositivo registró un nuevo ECG e informó: {verdict}.", + "verdict": { + "irregular": "un posible ritmo irregular", + "notDetected": "ningún ritmo irregular", + "inconclusive": "un resultado no concluyente" + } } }, "action": { @@ -8557,7 +8574,8 @@ "viewCheckups": "Ver revisiones", "checkinKeep": "Conservar", "checkinAdjust": "Ajustar", - "checkinLetGo": "Dejarlo ir" + "checkinLetGo": "Dejarlo ir", + "viewEcg": "Ver registro" }, "today": { "scoreLabel": "Puntuación de salud", diff --git a/messages/fr.json b/messages/fr.json index 85d4f3acf..2b120dd2f 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -3856,6 +3856,13 @@ }, "waveform": { "ariaLabel": "Tracé ECG enregistré le {date}, durée {duration}, fréquence cardiaque moyenne {heartRate}, résultat enregistré : {result}." + }, + "crossLink": { + "title": "Enregistrements ECG", + "recordingsOne": "1 enregistrement disponible.", + "recordingsMany": "{count} enregistrements disponibles.", + "latestResult": "Dernier résultat de l’appareil : {result}.", + "cta": "Voir les enregistrements" } } }, @@ -8549,6 +8556,16 @@ "title": "Il est temps de revoir un plan", "body": "Cela fait environ une semaine que vous avez défini ce plan : gardez-le, ajustez-le ou laissez-le. Sans pression.", "bodyPlan": "Il y a environ une semaine, vous vous étiez fixé : « {plan} ». Gardez-le, ajustez-le ou laissez-le, sans pression." + }, + "ecgNewRecording": { + "title": "Nouvel enregistrement ECG", + "body": "Votre appareil a enregistré un nouvel ECG : prêt à être consulté.", + "bodyVerdict": "Votre appareil a enregistré un nouvel ECG et a signalé : {verdict}.", + "verdict": { + "irregular": "un possible rythme irrégulier", + "notDetected": "aucun rythme irrégulier", + "inconclusive": "un résultat non concluant" + } } }, "action": { @@ -8557,7 +8574,8 @@ "viewCheckups": "Voir les examens", "checkinKeep": "Garder", "checkinAdjust": "Ajuster", - "checkinLetGo": "Laisser tomber" + "checkinLetGo": "Laisser tomber", + "viewEcg": "Voir l’enregistrement" }, "today": { "scoreLabel": "Score de santé", diff --git a/messages/it.json b/messages/it.json index b0702e182..843e9d3a0 100644 --- a/messages/it.json +++ b/messages/it.json @@ -3856,6 +3856,13 @@ }, "waveform": { "ariaLabel": "Tracciato ECG registrato il {date}, durata {duration}, frequenza cardiaca media {heartRate}, risultato registrato: {result}." + }, + "crossLink": { + "title": "Registrazioni ECG", + "recordingsOne": "1 registrazione disponibile.", + "recordingsMany": "{count} registrazioni disponibili.", + "latestResult": "Ultimo risultato del dispositivo: {result}.", + "cta": "Vedi registrazioni" } } }, @@ -8549,6 +8556,16 @@ "title": "È il momento di rivedere un piano", "body": "È passata circa una settimana da quando hai definito questo piano: mantienilo, modificalo o lascialo andare. Senza pressioni.", "bodyPlan": "Circa una settimana fa ti eri proposto: «{plan}». Mantienilo, modificalo o lascialo andare, senza pressioni." + }, + "ecgNewRecording": { + "title": "Nuova registrazione ECG", + "body": "Il tuo dispositivo ha registrato un nuovo ECG: è pronto da vedere.", + "bodyVerdict": "Il tuo dispositivo ha registrato un nuovo ECG e ha segnalato: {verdict}.", + "verdict": { + "irregular": "un possibile ritmo irregolare", + "notDetected": "nessun ritmo irregolare", + "inconclusive": "un risultato non conclusivo" + } } }, "action": { @@ -8557,7 +8574,8 @@ "viewCheckups": "Vedi controlli", "checkinKeep": "Mantieni", "checkinAdjust": "Modifica", - "checkinLetGo": "Lascia andare" + "checkinLetGo": "Lascia andare", + "viewEcg": "Vedi registrazione" }, "today": { "scoreLabel": "Punteggio di salute", diff --git a/messages/pl.json b/messages/pl.json index b057686a9..9f5f5d914 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -3856,6 +3856,13 @@ }, "waveform": { "ariaLabel": "Zapis EKG zarejestrowany {date}, czas trwania {duration}, średnie tętno {heartRate}, zarejestrowany wynik: {result}." + }, + "crossLink": { + "title": "Zapisy EKG", + "recordingsOne": "1 zapis w kartotece.", + "recordingsMany": "Zapisy w kartotece: {count}.", + "latestResult": "Ostatni wynik urządzenia: {result}.", + "cta": "Zobacz zapisy" } } }, @@ -8549,6 +8556,16 @@ "title": "Czas przyjrzeć się planowi", "body": "Minął mniej więcej tydzień, odkąd ustaliłeś ten plan — zachowaj go, dostosuj lub odpuść. Bez presji.", "bodyPlan": "Około tygodnia temu postanowiłeś: „{plan}”. Zachowaj to, dostosuj lub odpuść — bez presji." + }, + "ecgNewRecording": { + "title": "Nowy zapis EKG", + "body": "Twoje urządzenie zarejestrowało nowe EKG — gotowe do podglądu.", + "bodyVerdict": "Twoje urządzenie zarejestrowało nowe EKG i zgłosiło: {verdict}.", + "verdict": { + "irregular": "możliwy nieregularny rytm", + "notDetected": "brak nieregularnego rytmu", + "inconclusive": "wynik niejednoznaczny" + } } }, "action": { @@ -8557,7 +8574,8 @@ "viewCheckups": "Zobacz badania", "checkinKeep": "Zachowaj", "checkinAdjust": "Dostosuj", - "checkinLetGo": "Odpuść" + "checkinLetGo": "Odpuść", + "viewEcg": "Zobacz zapis" }, "today": { "scoreLabel": "Wynik zdrowia", diff --git a/src/app/insights/pulse/page.tsx b/src/app/insights/pulse/page.tsx index cb3963e16..14c71bfe0 100644 --- a/src/app/insights/pulse/page.tsx +++ b/src/app/insights/pulse/page.tsx @@ -17,6 +17,7 @@ import { CoachReadStrip } from "@/components/insights/derived/coach-read-strip"; import { MetricCorrelationCard } from "@/components/insights/metric-correlation-card"; import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge"; import { MetricTargetSummary } from "@/components/insights/metric-target-summary"; +import { EcgCrossLink } from "@/components/insights/ecg-cross-link"; import { SubPageShell } from "@/components/insights/sub-page-shell"; import { TileHeader } from "@/components/insights/tile-header"; import { @@ -188,6 +189,12 @@ export default function InsightsPulsPage() { icon={} /> + {/* S10 — device-attributed pointer into the ECG viewer, in the + resting-HR / pulse context. Un-mounts when the user has no ECG + recordings; surfaces only that recordings exist + the device's own + latest result, never a HealthLog interpretation. */} + + {/* VO₂ max is a cardio-fitness metric (Apple's Health app surfaces it under "Heart"), so the pulse page links across to its dedicated `/insights/cardio-fitness` page — the single surface that carries diff --git a/src/components/insights/__tests__/ecg-cross-link.test.tsx b/src/components/insights/__tests__/ecg-cross-link.test.tsx new file mode 100644 index 000000000..b5aa7a7d8 --- /dev/null +++ b/src/components/insights/__tests__/ecg-cross-link.test.tsx @@ -0,0 +1,81 @@ +import { describe, it, expect, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; + +/** + * S10 — `` unit tests. + * + * The load-bearing behaviour under test: + * - data-availability gating: the pointer un-mounts entirely when the user + * has no ECG recordings; + * - the NON-DIAGNOSTIC framing: it surfaces only that recordings exist and + * the RECORDING DEVICE's own latest result, attributed to the device — no + * HealthLog interpretation, no waveform. + * + * `useAuth` + TanStack Query are mocked and assertions run through SSR, exactly + * like the sibling `ecg-section` test. + */ + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: vi.fn(() => ({ isAuthenticated: true, user: null })), +})); + +const useQueryMock = vi.fn(); +vi.mock("@tanstack/react-query", () => ({ + useQuery: (opts: unknown) => useQueryMock(opts), +})); + +const { EcgCrossLink } = await import("../ecg-cross-link"); + +interface Item { + id: string; + classification: "IRREGULAR" | "NOT_DETECTED" | "INCONCLUSIVE" | null; +} + +function render( + data: { recordings: Item[]; hasRecordings: boolean } | undefined, +) { + useQueryMock.mockReturnValue({ data }); + return renderToStaticMarkup( + + + , + ); +} + +describe("", () => { + it("renders nothing before the payload resolves", () => { + expect(render(undefined)).toBe(""); + }); + + it("renders nothing when the user has no recordings (gate)", () => { + expect(render({ recordings: [], hasRecordings: false })).toBe(""); + }); + + it("links into the ECG viewer and attributes the latest result to the device", () => { + const html = render({ + recordings: [ + { id: "a", classification: "IRREGULAR" }, + { id: "b", classification: "NOT_DETECTED" }, + ], + hasRecordings: true, + }); + expect(html).toContain('data-slot="ecg-cross-link"'); + expect(html).toContain('href="/insights#ecg"'); + expect(html).toContain("2 recordings on file."); + // The latest (first) recording's DEVICE result, attributed to the device. + expect(html).toContain("Latest device result:"); + // Never a HealthLog interpretation. + expect(html).not.toMatch(/we (detected|found)|our (reading|analysis)/i); + }); + + it("omits the result line when the latest recording has no device verdict", () => { + const html = render({ + recordings: [{ id: "a", classification: null }], + hasRecordings: true, + }); + expect(html).toContain("1 recording on file."); + expect(html).not.toContain("Latest device result:"); + }); +}); diff --git a/src/components/insights/ecg-cross-link.tsx b/src/components/insights/ecg-cross-link.tsx new file mode 100644 index 000000000..043fff504 --- /dev/null +++ b/src/components/insights/ecg-cross-link.tsx @@ -0,0 +1,106 @@ +"use client"; + +import Link from "next/link"; +import { ArrowRight, HeartPulse } from "lucide-react"; +import { useQuery } from "@tanstack/react-query"; + +import { useAuth } from "@/hooks/use-auth"; +import { queryKeys } from "@/lib/query-keys"; +import { apiGet } from "@/lib/api/api-fetch"; +import { useTranslations } from "@/lib/i18n/context"; +import { cn } from "@/lib/utils"; +import { TileHeader } from "@/components/insights/tile-header"; + +/** + * S10 — ECG cross-link, the pointer from the resting-HR / pulse context into + * the ECG viewer (`/insights#ecg`). + * + * NON-DIAGNOSTIC (load-bearing, mirrors `EcgSection` / `RhythmEventsCard`): the + * card surfaces ONLY that recordings exist + the RECORDING DEVICE's OWN latest + * result, attributed to the device. HealthLog never reads or interprets the + * waveform, and this pointer never touches it — the list route returns metadata + * only. All copy renders as plain React text children (no markdown library). + * + * Data-availability-gated: the card un-mounts entirely (`return null`) when the + * user has no recordings, so a pulse page without any ECG never paints it. It + * reuses the SAME query cell as `EcgSection` (`insightsEcgList`), so the two + * surfaces share one cache entry with an identical response shape. + */ + +type EcgClassification = "IRREGULAR" | "NOT_DETECTED" | "INCONCLUSIVE" | null; + +interface EcgRecordingListItem { + id: string; + classification: EcgClassification; +} + +interface EcgListResponse { + recordings: EcgRecordingListItem[]; + hasRecordings: boolean; +} + +/** The device verdict, surfaced verbatim and attributed to the device. */ +const RESULT_LABEL_KEYS: Record = { + IRREGULAR: "insights.ecg.result.irregular", + NOT_DETECTED: "insights.ecg.result.notDetected", + INCONCLUSIVE: "insights.ecg.result.inconclusive", +}; + +interface EcgCrossLinkProps { + enabled?: boolean; + className?: string; +} + +export function EcgCrossLink({ enabled = true, className }: EcgCrossLinkProps) { + const { isAuthenticated } = useAuth(); + const { t } = useTranslations(); + + const { data } = useQuery({ + queryKey: queryKeys.insightsEcgList(), + queryFn: async () => { + try { + return await apiGet("/api/insights/ecg"); + } catch { + throw new Error(t("insights.ecg.loadError")); + } + }, + enabled: enabled && isAuthenticated, + }); + + // Data-availability gate — never paint an empty pointer. + if (!data || !data.hasRecordings || data.recordings.length === 0) return null; + + const count = data.recordings.length; + const latestClassification = data.recordings[0]?.classification ?? null; + const resultKey = latestClassification + ? RESULT_LABEL_KEYS[latestClassification] + : null; + + const countText = + count === 1 + ? t("insights.ecg.crossLink.recordingsOne") + : t("insights.ecg.crossLink.recordingsMany", { count }); + const resultText = resultKey + ? t("insights.ecg.crossLink.latestResult", { result: t(resultKey) }) + : null; + + return ( + + + + {resultText ? `${countText} ${resultText}` : countText} + + + {t("insights.ecg.crossLink.cta")} + + + ); +} diff --git a/src/components/insights/ecg-section.tsx b/src/components/insights/ecg-section.tsx index 324846d49..6a5b4afb5 100644 --- a/src/components/insights/ecg-section.tsx +++ b/src/components/insights/ecg-section.tsx @@ -118,9 +118,10 @@ export function EcgSection({ enabled = true, className }: EcgSectionProps) { return (
{ ); 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 b98156f08..6e21b4db7 100644 --- a/src/lib/daily/__tests__/digest.test.ts +++ b/src/lib/daily/__tests__/digest.test.ts @@ -413,3 +413,122 @@ describe("buildDailyDigest — coach check-in (S3)", () => { expect(d.worthALook[0].kind).toBe("dose_window"); }); }); + +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 4f4ff9753..a4355674d 100644 --- a/src/lib/daily/digest.ts +++ b/src/lib/daily/digest.ts @@ -92,6 +92,18 @@ export interface DailyDigestPreventiveDue { label: string; } +/** + * 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 @@ -135,6 +147,12 @@ export interface DailyDigestInput { preventiveDue: DailyDigestPreventiveDue[]; /** Standing coach plans (active + reviewed) — check-in candidates (§2.3). */ coachPlans: DailyDigestCoachPlan[]; + /** + * 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 { @@ -336,6 +354,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 @@ -392,6 +473,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); diff --git a/src/lib/daily/load-digest.ts b/src/lib/daily/load-digest.ts index 8747c5908..2bf6ba389 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, @@ -43,6 +44,9 @@ const CHECKIN_PLAN_STATES = ["active", "reviewed"] as const; /** Cap on plans scanned for the (one/day) check-in candidate. */ const CHECKIN_PLAN_READ_LIMIT = 50; +/** 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; @@ -92,6 +96,7 @@ export async function loadDailyDigest( syncRows, dueReminders, planRows, + ecgRow, ] = await Promise.all([ readDashboardSnapshotCached(user), resolveModuleMap(user.id), @@ -133,6 +138,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 @@ -165,6 +182,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; @@ -186,6 +221,7 @@ export async function loadDailyDigest( syncIssues, preventiveDue, coachPlans, + 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