← back to Norma
agents/onboard-agent/skills/monitor.js
173 lines
/**
* OnBoard Monitor Skill
*
* Watches pulse_topics for new high-urgency topics (last 30 minutes).
* If a new topic doesn't already have discoveries, triggers a mini
* discovery run for that topic.
*/
const { query } = require('../../shared/db');
const { logAction } = require('../../shared/audit-logger');
const { searchNonprofits } = require('../lib/propublica');
const { getIssueAreas, issueOverlapScore } = require('../lib/ntee-matcher');
const AGENT = 'onboard-agent';
const PLATFORM = 'pulse';
/**
* Extract search keywords from a topic name + description.
*/
function extractKeywords(topic) {
const text = `${topic.name || ''} ${topic.description || ''}`.toLowerCase();
const stopWords = new Set(['the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been',
'being', 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could',
'should', 'may', 'might', 'can', 'shall', 'to', 'of', 'in', 'for', 'on', 'with',
'at', 'by', 'from', 'as', 'into', 'through', 'during', 'before', 'after',
'and', 'but', 'or', 'nor', 'not', 'so', 'yet', 'both', 'either', 'neither',
'this', 'that', 'these', 'those', 'it', 'its', 'they', 'them', 'their',
'about', 'up', 'out', 'than', 'also', 'just', 'more', 'very']);
const words = text.replace(/[^a-z0-9\s]/g, ' ').split(/\s+/).filter(w => w.length > 2 && !stopWords.has(w));
return [...new Set(words)].slice(0, 5);
}
/**
* @param {Object} body - Request body
* @param {number} [body.minutes_back=30] - How many minutes back to check for new topics
* @returns {Promise<Object>}
*/
module.exports = async function monitor(body = {}) {
const minutesBack = body.minutes_back || 30;
// 1. Query new high-urgency topics from the last N minutes
const { rows: newTopics } = await query(
`SELECT id, name, category, urgency, description, created_at
FROM pulse_topics
WHERE urgency IN ('critical', 'high')
AND created_at > NOW() - INTERVAL '1 minute' * $1
ORDER BY created_at DESC`,
[minutesBack]
);
if (newTopics.length === 0) {
return { new_topics: 0, triggered_discoveries: 0, message: 'No new high-urgency topics' };
}
let triggeredDiscoveries = 0;
for (const topic of newTopics) {
// 2. Check if we already have discoveries for this topic
const { rows: existingDisc } = await query(
`SELECT COUNT(*)::int AS count FROM onboard_discoveries
WHERE trigger_topic_id = $1`,
[topic.id]
);
if (existingDisc[0].count > 0) {
continue; // Already have discoveries for this topic
}
// 3. Trigger mini discovery run
const keywords = extractKeywords(topic);
if (keywords.length === 0) continue;
const searchQuery = keywords.join(' ');
const result = await searchNonprofits(searchQuery);
const orgs = result.organizations || [];
for (const org of orgs.slice(0, 5)) { // Smaller batch for monitor
const orgIssues = getIssueAreas(org.ntee_code);
const issueScore = issueOverlapScore(orgIssues, keywords) * 40;
const matchScore = Math.round(issueScore * 100) / 100;
if (matchScore < 5) continue;
// Insert org if new
let orgId;
try {
if (org.ein) {
const { rows: existingOrg } = await query(
`SELECT id FROM organizations WHERE ein = $1 LIMIT 1`,
[String(org.ein)]
);
if (existingOrg.length > 0) {
orgId = existingOrg[0].id;
}
}
if (!orgId) {
const { rows: inserted } = await query(
`INSERT INTO organizations (name, ein, type, ntee_code, city, state,
annual_revenue, total_assets, source, source_id)
VALUES ($1, $2, 'nonprofit', $3, $4, $5, $6, $7, 'onboard-agent', $8)
ON CONFLICT (ein) DO UPDATE SET updated_at = NOW()
RETURNING id`,
[
org.name,
org.ein ? String(org.ein) : null,
org.ntee_code || null,
org.city || null,
org.state || null,
org.total_revenue || null,
org.total_assets || null,
org.ein ? `propublica-${org.ein}` : null,
]
);
orgId = inserted[0]?.id;
}
} catch (err) {
console.error(`[${AGENT}] Monitor insert org error:`, err.message);
continue;
}
if (!orgId) continue;
// Insert discovery
try {
await query(
`INSERT INTO onboard_discoveries
(org_id, trigger_topic_id, trigger_type, trigger_label, match_score, match_details, status)
VALUES ($1, $2, 'pulse_monitor', $3, $4, $5, 'discovered')
ON CONFLICT DO NOTHING`,
[
orgId,
topic.id,
topic.name,
matchScore,
JSON.stringify({
issue_score: matchScore,
search_query: searchQuery,
source: 'monitor_auto',
}),
]
);
triggeredDiscoveries++;
} catch (err) {
// Ignore duplicate key errors
if (!err.message.includes('duplicate') && !err.message.includes('unique')) {
console.error(`[${AGENT}] Monitor insert discovery error:`, err.message);
}
}
}
}
// Log the action
await logAction({
agent: AGENT,
actionType: 'monitor',
platform: PLATFORM,
content: `Checked ${newTopics.length} new topics (last ${minutesBack}min)`,
responseData: {
new_topics: newTopics.length,
triggered_discoveries: triggeredDiscoveries,
minutes_back: minutesBack,
},
status: 'success',
});
return {
new_topics: newTopics.length,
triggered_discoveries: triggeredDiscoveries,
};
};