← 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 };