← back to AbramsOS

lib/chat-grounding.js

165 lines

// Grounding layer: given an agent scope, pull the user's OWN records from the
// relevant domain tables (via lib/db.js only) and format them into a compact
// CONTEXT block. Also returns the real person-names present so the redaction
// pass can strip them on external lanes. Read-only — never writes.

const db = require('../lib/db');

const LIMIT = 40; // keep context bounded

function fmt(rows, cols) {
  if (!rows.length) return '(none on record)';
  return rows
    .map((r) => cols.map((c) => `${c}: ${r[c] ?? ''}`).join(' | '))
    .join('\n');
}

// Person-name columns that carry a real human name in each scope's rows. These
// are extracted UNIFORMLY (not just for vitals) and fed to redact() so grounded
// DB records — bill payees, claim providers, merchant/warranty/service-provider
// names, free-text reminder titles — never reach an external model un-redacted.
function collectNames(rows, cols) {
  const out = [];
  for (const r of rows) {
    for (const c of cols) {
      const v = r[c];
      if (v != null && String(v).trim()) out.push(String(v).trim());
    }
  }
  return out;
}

// Household roster — pulled for EVERY scope so a person's name appearing in any
// free-text field is redacted on external lanes.
async function householdNames(userId) {
  try {
    const p = await db.query(
      `SELECT full_name, nickname FROM person WHERE user_id = $1 LIMIT 50`,
      [userId]
    );
    return p.rows.flatMap((x) => [x.full_name, x.nickname]).filter(Boolean);
  } catch (_) {
    return [];
  }
}

const SCOPES = {
  async claims(userId) {
    const r = await db.query(
      `SELECT claim_type, routing, jurisdiction, state, desired_remedy,
              due_at, draft_subject
         FROM claim_case WHERE user_id = $1
        ORDER BY due_at NULLS LAST, created_at DESC LIMIT $2`,
      [userId, LIMIT]
    );
    return {
      title: 'Open claim cases',
      block: fmt(r.rows, ['claim_type', 'routing', 'jurisdiction', 'state', 'desired_remedy', 'due_at']),
      names: collectNames(r.rows, ['jurisdiction', 'draft_subject', 'desired_remedy']),
    };
  },

  async savings(userId) {
    const r = await db.query(
      `SELECT title, current_item, current_price, suggested_item, suggested_price,
              est_savings, merchant, status
         FROM savings_suggestion WHERE user_id = $1 AND status IN ('new','saved')
        ORDER BY est_savings DESC NULLS LAST LIMIT $2`,
      [userId, LIMIT]
    );
    const c = await db.query(
      `SELECT merchant, title, code, discount_text, expires_on
         FROM merchant_coupon WHERE user_id = $1 AND status = 'active'
        ORDER BY created_at DESC LIMIT 20`,
      [userId]
    );
    return {
      title: 'Savings suggestions and coupons',
      block:
        'SUGGESTIONS:\n' + fmt(r.rows, ['title', 'current_item', 'current_price', 'suggested_item', 'suggested_price', 'est_savings', 'merchant']) +
        '\n\nCOUPONS:\n' + fmt(c.rows, ['merchant', 'title', 'code', 'discount_text', 'expires_on']),
      names: [
        ...collectNames(r.rows, ['merchant', 'title', 'current_item', 'suggested_item']),
        ...collectNames(c.rows, ['merchant', 'title']),
      ],
    };
  },

  async bills(userId) {
    const r = await db.query(
      `SELECT name, payee, category, amount, currency, cadence, due_date, autopay, status
         FROM bill WHERE user_id = $1 AND status <> 'archived'
        ORDER BY due_date NULLS LAST LIMIT $2`,
      [userId, LIMIT]
    );
    return {
      title: 'Tracked bills',
      block: fmt(r.rows, ['name', 'payee', 'category', 'amount', 'cadence', 'due_date', 'autopay', 'status']),
      names: collectNames(r.rows, ['name', 'payee']),
    };
  },

  async vitals(userId) {
    const r = await db.query(
      `SELECT metric, systolic, diastolic, value, unit, category, measured_at
         FROM health_reading WHERE user_id = $1
        ORDER BY measured_at DESC LIMIT $2`,
      [userId, LIMIT]
    );
    // Household roster is merged in for every scope by ground(); nothing special
    // needed here anymore (person names can appear on readings via person_id).
    return {
      title: 'Health readings',
      block: fmt(r.rows, ['metric', 'systolic', 'diastolic', 'value', 'unit', 'measured_at']),
      names: [],
    };
  },

  async reminders(userId) {
    const r = await db.query(
      `SELECT title, reason_code, due_at, state
         FROM calendar_reminder WHERE user_id = $1 AND state <> 'dismissed'
        ORDER BY due_at NULLS LAST LIMIT $2`,
      [userId, LIMIT]
    );
    return {
      title: 'Upcoming reminders and deadlines',
      block: fmt(r.rows, ['title', 'reason_code', 'due_at', 'state']),
      names: collectNames(r.rows, ['title']),
    };
  },

  async warranties(userId) {
    const r = await db.query(
      `SELECT provider_name, commitment_type, promised_outcome, window_days,
              refund_window_ends_at
         FROM service_commitment WHERE user_id = $1
        ORDER BY refund_window_ends_at NULLS LAST LIMIT $2`,
      [userId, LIMIT]
    );
    return {
      title: 'Service commitments and warranties',
      block: fmt(r.rows, ['provider_name', 'commitment_type', 'promised_outcome', 'window_days', 'refund_window_ends_at']),
      names: collectNames(r.rows, ['provider_name', 'promised_outcome']),
    };
  },
};

// Returns { context, names } for an agent scope, or empty when scope is null.
// names ALWAYS includes the household person roster (for every scope, not just
// vitals) merged with the scope's own person-name columns, deduped and sorted
// longest-first so a full-name match is tried before its bare tokens.
async function ground(scope, userId) {
  if (!scope || !SCOPES[scope]) return { context: '', names: [] };
  const [{ title, block, names: scopeNames }, roster] = await Promise.all([
    SCOPES[scope](userId),
    householdNames(userId),
  ]);
  const names = [...new Set([...(scopeNames || []), ...roster].filter(Boolean))]
    .sort((a, b) => b.length - a.length);
  const context = `--- CONTEXT: ${title} (user's own records) ---\n${block}\n--- END CONTEXT ---`;
  return { context, names };
}

module.exports = { ground, SCOPES };