Files
HealthLog/src/lib/insights/feature-blocks.ts
T
Marc-André Bombeck fd2bd67ff3 feat(labs): keep the reference range a report prints beside a value
A lab runs its own method on its own device, so the window a physician
evaluates a value against is the one printed next to it on that report,
not a catalog band averaged over every lab. Reading a report pulled the
value and dropped that range: the extraction asked for it, the commit
handed it to the biomarker resolver, and the resolver only uses it when
it mints a NEW marker. For every analyte the user already had — the
normal case after a first panel — the printed range was read and then
thrown away.

Add `sourceReferenceLow` / `sourceReferenceHigh` / `sourceReferenceText`
to `LabResult` and carry the range from the report to the reading. The
text is stored whether or not numbers can be read out of it, because
"bis 5,0" and "negativ" state a window too; a range that could not be
parsed is kept verbatim, never replaced with invented bounds.

`resolveEffectiveReferenceRange` is the one place the precedence lives:
the source window wins for that single reading, and the catalog stays
the net for readings without one. A printed string with no derivable
bound does not displace the catalog. Every surface that judges a
reading routes through it — the API verdict, the doctor report, the
insights blocks and per-marker assessment, the coach snapshot, the MCP
read and the FHIR `referenceRange` — so a value cannot read in range on
one surface and out on another.

The API keeps `referenceLow` / `referenceHigh` as the window in force,
so a client that reads only those two renders the right range and the
right verdict with no change of its own; the catalog band, the source
window, its origin and a divergence flag ride alongside for clients
that want to show both.

Read side: the chart draws the source window as a second, visually
distinct band with the reading it came from named beneath it, the
reading rows name the printed range and the catalog band where the two
disagree, and the doctor-report PDF prints the report's own string with
a footnote naming the saved range. Entry side: one optional field on
the lab form, folded away until asked for, and the printed range is
editable on the scan review screen.

The range parser covers the notations German and English reports
actually print — two-sided, one-sided, comma or dot decimals, thousands
separators, trailing units — reads units without ever converting them,
and refuses to guess on anything ambiguous.
2026-08-07 21:40:41 +02:00

470 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Briefing-scoped feature read blocks: full-history extremes, the
* bucketed rollup series, and the v1.22 cross-signal integration blocks
* (flagged labs, preventive-care due/overdue, workout aggregate). Each
* is one bounded DB read projecting to its slice of the
* `AggregatedFeatures` payload.
*
* Extracted verbatim from `features.ts`, which re-exports this module
* so every existing call site keeps importing from there; the assembly
* point (`extractFeatures`) stays in the hub.
*/
import { prisma } from "@/lib/db";
import { classifyAgainstEffectiveRange } from "@/lib/labs/reference-range";
import { calendarDaysUntil } from "@/lib/measurement-reminders/due-day";
import { resolveLabFields } from "@/lib/labs/serialise";
import { readRollupBuckets } from "@/lib/rollups/measurement-rollups";
import { deriveBucketedTypes } from "@/lib/signals/adapters/correlation";
import type {
AggregatedFeatures,
BucketedSeries,
} from "@/lib/insights/features";
import type {
MeasurementType,
RollupGranularity,
} from "@/generated/prisma/client";
/** All-time aggregate (full history) for one measurement type. */
interface AllTimeExtremes {
mean: number | null;
min: number | null;
max: number | null;
}
/**
* v1.18.11 P1 — full-history min / max / mean per measurement type via ONE
* grouped SQL aggregation, with NO row materialisation in JS. Used to fill the
* `allTime*` feature fields honestly when the bulk feature read is bounded to a
* recent window: the windowed `summarize()` covers trends + recent windows, and
* this covers the long-horizon extremes the prompt labels "allTime".
*
* Only the four types that expose `allTime*` fields are aggregated (weight,
* systolic, diastolic, pulse). Returns a map keyed by `MeasurementType`; a type
* with no rows is simply absent.
*/
export async function readAllTimeExtremes(
userId: string,
types: readonly MeasurementType[],
): Promise<Map<MeasurementType, AllTimeExtremes>> {
const rows = await prisma.measurement.groupBy({
by: ["type"],
where: { userId, deletedAt: null, type: { in: [...types] } },
_avg: { value: true },
_min: { value: true },
_max: { value: true },
});
const out = new Map<MeasurementType, AllTimeExtremes>();
for (const r of rows) {
out.set(r.type, {
mean: r._avg.value ?? null,
min: r._min.value ?? null,
max: r._max.value ?? null,
});
}
return out;
}
/**
* v1.4.36 W3 T1 — bucket-window definitions for the
* `bucketedMeasurements` payload. Mirrors the rollup populator's
* granularity ladder so the read-side picks up whatever the persistent
* table holds without a recompute round-trip.
*
* The 90 / 365 / 1825-day windows are non-overlapping: each row of
* `measurement_rollups` lives at exactly one granularity, and the
* downstream model reads the union of the three series per type.
* Total volume per type for a heavy power user (5 years of daily
* data): 90 DAY + 39 WEEK + 50 MONTH = 179 buckets × ~40 bytes JSON
* each ≈ 7 KB. Eight metric types lands at ~56 KB — well under the
* 5 MB cap above, vs 25.9 MB for the v1.4.35 rawMeasurements shape.
*/
const BUCKET_WINDOWS: Array<{
granularity: RollupGranularity;
fromDays: number;
toDays: number;
}> = [
{ granularity: "DAY", fromDays: 0, toDays: 90 },
{ granularity: "WEEK", fromDays: 90, toDays: 365 },
{ granularity: "MONTH", fromDays: 365, toDays: 1825 },
];
/**
* Types the bucketed payload covers. Mirrors the aggregate branches
* above so the model never sees a bucket for a metric whose aggregate
* block was suppressed. New `MeasurementType` enum values flow in by
* adding one row; the rollup populator already covers every type.
*/
// Derived from the signal registry: every signal flagged
// `surfaces.correlationEligible` projects to its DB `MeasurementType`. The list
// is a membership/iteration set (each type is read independently), so order is
// not significant; the registry-invariant test pins the set byte-for-byte.
const BUCKETED_TYPES: MeasurementType[] = deriveBucketedTypes();
/** 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;
/** 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;
/**
* 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.
*/
export 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,
sourceReferenceLow: true,
sourceReferenceHigh: true,
sourceReferenceText: 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 = classifyAgainstEffectiveRange(
latest.value,
resolved.effectiveRange,
);
// 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.
*
* `daysUntil` / `daysOverdue` are CALENDAR days in `timeZone`, counted by the
* same `calendarDaysUntil` the checkup screens phrase their due line with.
* They used to be an hour gap divided by 24, which is a different question:
* a checkup at 09:00 read that evening came out "overdue by 0 days", and one
* due tomorrow morning read late tonight came out due "in 0 days" — today's.
* The model narrates these numbers, so it repeated whichever the arithmetic
* had drifted to while the screen next to it said something else.
*
* `timeZone` is the user's profile zone; the horizon that bounds the read
* stays an hours-based cutoff on purpose — it is a query bound, not a
* statement to anyone about which day something falls on.
*/
export async function readPreventiveCareBlock(
userId: string,
now: number,
timeZone: string,
): 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"] = [];
const nowDate = new Date(now);
for (const r of rows) {
if (!r.nextDueAt) continue;
const label = sanitizeLabel(r.label);
if (!label) continue;
const days = calendarDaysUntil(r.nextDueAt, nowDate, timeZone);
// Today's checkup is due, not overdue — the hour it was booked for may
// have passed, but the day it belongs to has not.
if (days < 0) {
overdue.push({ label, daysOverdue: -days });
} else {
due.push({ label, daysUntil: days });
}
}
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.
*/
export 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 };
}
/**
* 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<AggregatedFeatures["ecg"] | undefined> {
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
* (type, granularity) combinations are dropped so the payload never
* carries a labelled-but-empty series.
*/
export async function readBucketedSeries(
userId: string,
now: number,
): Promise<BucketedSeries[]> {
const series: BucketedSeries[] = [];
for (const type of BUCKETED_TYPES) {
for (const window of BUCKET_WINDOWS) {
const from = new Date(now - window.toDays * 24 * 60 * 60 * 1000);
const to = new Date(now - window.fromDays * 24 * 60 * 60 * 1000);
const rows = await readRollupBuckets(
userId,
type,
window.granularity,
from,
to,
);
if (rows.length === 0) continue;
series.push({
type,
granularity: window.granularity,
buckets: rows.map((r) => ({
bucketStart: r.bucketStart.toISOString(),
mean: Math.round(r.mean * 100) / 100,
count: r.count,
})),
});
}
}
return series;
}