← back to AbramsOS
lib/vitals-insights.js
113 lines
// vitals-insights.js — trends + a plain-English "what changed" summary for Health.
//
// HONESTY / SAFETY rails:
// • The LLM is fed ONLY computed numeric deltas (this-month vs last-month averages it
// did not invent) and writes a short narrative of THOSE facts — it never makes up a
// reading or a number. If there's no data, there's no summary.
// • Output is explicitly informational, NOT medical advice/diagnosis. The Stage-2/crisis
// recheck flag is a deterministic RULE (not the LLM), phrased gently.
// • Local Ollama only ($0).
const db = require('./db');
const ollama = require('./ollama');
const METRICS = [
{ key: 'blood_pressure', label: 'blood pressure', unit: 'mmHg', bp: true },
{ key: 'resting_heart_rate', label: 'resting heart rate', unit: 'bpm' },
{ key: 'heart_rate', label: 'heart rate', unit: 'bpm' },
{ key: 'weight', label: 'weight', unit: 'lb' },
{ key: 'spo2', label: 'SpO₂', unit: '%' },
{ key: 'blood_glucose', label: 'blood glucose', unit: 'mg/dL' },
];
function avg(nums) {
const v = nums.filter((n) => Number.isFinite(n));
return v.length ? v.reduce((a, b) => a + b, 0) / v.length : null;
}
// Compute this-30-days vs prior-30-days averages per metric for one person (or all).
async function computeTrends(userId, personId) {
const params = [userId];
let where = 'user_id = $1';
if (personId) { params.push(personId); where += ` AND person_id = $${params.length}`; }
const rows = (await db.query(
`SELECT metric, systolic, diastolic, value, category, measured_at
FROM health_reading WHERE ${where} AND measured_at > now() - interval '60 days'`,
params
)).rows;
const now = Date.now();
const DAY = 86400000;
const trends = [];
for (const m of METRICS) {
const mine = rows.filter((r) => r.metric === m.key);
if (!mine.length) continue;
const recent = mine.filter((r) => now - new Date(r.measured_at).getTime() <= 30 * DAY);
const prior = mine.filter((r) => now - new Date(r.measured_at).getTime() > 30 * DAY);
const pick = (r) => (m.bp ? Number(r.systolic) : Number(r.value));
const rNow = avg(recent.map(pick));
const rPrev = avg(prior.map(pick));
if (rNow == null) continue;
const t = {
metric: m.key, label: m.label, unit: m.unit, bp: !!m.bp,
recentAvg: round1(rNow), priorAvg: rPrev == null ? null : round1(rPrev),
delta: rPrev == null ? null : round1(rNow - rPrev), n: recent.length,
};
if (m.bp) {
t.recentDiaAvg = round1(avg(recent.map((r) => Number(r.diastolic))));
t.highReadings = recent.filter((r) => r.category === 'hypertension_2' || r.category === 'crisis').length;
}
trends.push(t);
}
return trends;
}
// Deterministic gentle recheck flag (RULE, not LLM). Returns null or a message.
function recheckFlag(trends) {
const bp = trends.find((t) => t.bp);
if (bp && bp.highReadings > 0) {
return `${bp.highReadings} recent blood-pressure reading${bp.highReadings > 1 ? 's were' : ' was'} in the elevated range. Consider taking it again when relaxed, and mentioning the trend to your doctor. (Informational only — not medical advice.)`;
}
return null;
}
// LLM narrates ONLY the computed deltas. Returns a short string, or null if no data / no model.
async function summarize(trends, opts = {}) {
if (!trends.length) return null;
const facts = trends.map((t) =>
t.bp
? `blood pressure averaged ${t.recentAvg}/${t.recentDiaAvg} over ${t.n} recent readings` +
(t.delta != null ? ` (systolic ${t.delta >= 0 ? 'up' : 'down'} ${Math.abs(t.delta)} vs the prior month)` : '')
: `${t.label} averaged ${t.recentAvg} ${t.unit} over ${t.n} recent readings` +
(t.delta != null ? ` (${t.delta >= 0 ? 'up' : 'down'} ${Math.abs(t.delta)} vs the prior month)` : '')
).join('; ');
const system = `You summarize personal health trends in 2-3 short, calm, plain-English sentences.
Use ONLY the numbers provided — never invent readings. Do NOT diagnose or give medical advice or
treatment recommendations. It's fine to gently suggest discussing a notable change with a doctor.
No alarm. Plain text only.`;
try {
const out = await ollama.generate(`Recent health data: ${facts}.\n\nWrite the summary.`, {
system, timeoutMs: opts.timeoutMs || 45_000, model: opts.model,
});
return (out || '').replace(/^["']|["']$/g, '').trim() || null;
} catch (_) {
return null;
}
}
async function insights(userId, personId, opts = {}) {
const trends = await computeTrends(userId, personId);
return {
trends,
recheck: recheckFlag(trends),
summary: trends.length ? await summarize(trends, opts) : null,
generatedAt: new Date().toISOString(),
};
}
function round1(n) { return n == null ? null : Math.round(n * 10) / 10; }
module.exports = { insights, computeTrends, recheckFlag, summarize };