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