← back to Norma
agents/onboard-agent/skills/discover.js
287 lines
/**
* OnBoard Discover Skill
*
* Finds non-profit organizations matching trending pulse topics by:
* 1. Querying pulse_topics for critical/high urgency items
* 2. Searching ProPublica Nonprofit Explorer for each topic
* 3. Cross-referencing against existing organizations to avoid duplicates
* 4. Scoring each match: issue_overlap 40% + geo_overlap 30% + AI_assessment 30%
* 5. Inserting new discoveries into onboard_discoveries
*/
const { query } = require('../../shared/db');
const { logAction } = require('../../shared/audit-logger');
const { searchNonprofits } = require('../lib/propublica');
const { getIssueAreas, issueOverlapScore } = require('../lib/ntee-matcher');
const fetch = require('node-fetch');
const AGENT = 'onboard-agent';
const PLATFORM = 'propublica';
const GEMINI_API_KEY = '${GOOGLE_API_KEY}';
const GEMINI_URL = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${GEMINI_API_KEY}`;
/**
* Call Gemini 2.0 Flash for AI scoring/assessment.
*
* @param {string} prompt - Prompt text
* @returns {Promise<string>} Response text
*/
async function geminiScore(prompt) {
try {
const res = await fetch(GEMINI_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contents: [{ parts: [{ text: prompt }] }],
generationConfig: { temperature: 0.2, maxOutputTokens: 500 },
}),
});
const data = await res.json();
return data?.candidates?.[0]?.content?.parts?.[0]?.text || '';
} catch (err) {
console.error(`[${AGENT}] Gemini score error:`, err.message);
return '';
}
}
/**
* Extract keywords from a pulse topic name and description.
*
* @param {Object} topic - Pulse topic row
* @returns {string[]} Array of search keywords
*/
function extractKeywords(topic) {
const text = `${topic.name || ''} ${topic.description || ''}`.toLowerCase();
// Remove common stop words, split on whitespace and punctuation
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 unique keywords, max 5
return [...new Set(words)].slice(0, 5);
}
/**
* Check if an organization already exists in the DB by name similarity.
*
* @param {string} orgName - Organization name from ProPublica
* @param {string|null} ein - EIN if available
* @returns {Promise<Object|null>} Existing org row or null
*/
async function findExistingOrg(orgName, ein) {
// First try exact EIN match
if (ein) {
const { rows } = await query(
`SELECT id, name, ein FROM organizations WHERE ein = $1 LIMIT 1`,
[String(ein)]
);
if (rows.length > 0) return rows[0];
}
// Then try text search on name
const { rows } = await query(
`SELECT id, name, ein FROM organizations
WHERE to_tsvector('english', name) @@ plainto_tsquery('english', $1)
LIMIT 5`,
[orgName]
);
// Check for close match
const nameLower = orgName.toLowerCase();
for (const row of rows) {
const rowLower = row.name.toLowerCase();
if (rowLower === nameLower || rowLower.includes(nameLower) || nameLower.includes(rowLower)) {
return row;
}
}
return null;
}
/**
* @param {Object} body - Request body
* @param {number} [body.limit=10] - Max topics to process
* @param {boolean} [body.skip_ai=false] - Skip AI assessment (faster, for testing)
* @returns {Promise<Object>}
*/
module.exports = async function discover(body = {}) {
const topicLimit = body.limit || 10;
const skipAi = body.skip_ai || false;
// 1. Query pulse_topics for critical/high urgency
const { rows: topics } = await query(
`SELECT id, name, category, urgency, description, created_at
FROM pulse_topics
WHERE urgency IN ('critical', 'high')
ORDER BY created_at DESC
LIMIT $1`,
[topicLimit]
);
if (topics.length === 0) {
await logAction({
agent: AGENT,
actionType: 'discover',
platform: PLATFORM,
status: 'skipped',
content: 'No critical/high urgency topics found',
});
return { topics_searched: 0, orgs_found: 0, new_discoveries: 0 };
}
let totalOrgsFound = 0;
let totalNewDiscoveries = 0;
// 2. For each topic, search ProPublica
for (const topic of topics) {
const keywords = extractKeywords(topic);
if (keywords.length === 0) continue;
const searchQuery = keywords.join(' ');
const result = await searchNonprofits(searchQuery);
const orgs = result.organizations || [];
totalOrgsFound += orgs.length;
// 3. Process each org result
for (const org of orgs.slice(0, 10)) { // Limit to 10 orgs per topic
// Cross-reference with existing organizations
const existing = await findExistingOrg(org.name, org.ein);
// Get issue areas from NTEE code
const orgIssues = getIssueAreas(org.ntee_code);
// Score: issue_overlap 40%
const issueScore = issueOverlapScore(orgIssues, keywords) * 40;
// Score: geo_overlap 30% (check if topic description mentions state)
const topicText = `${topic.name} ${topic.description || ''}`.toLowerCase();
const geoScore = org.state && topicText.includes((org.state || '').toLowerCase()) ? 30 : 0;
// Score: AI_assessment 30%
let aiScore = 15; // Default mid-range
if (!skipAi) {
try {
const aiPrompt = `Score how well this nonprofit matches this issue topic on a scale of 0-100.
Topic: "${topic.name}" - ${topic.description || 'no description'}
Nonprofit: "${org.name}" in ${org.city || '?'}, ${org.state || '?'} (NTEE: ${org.ntee_code || 'unknown'})
Revenue: $${(org.total_revenue || 0).toLocaleString()}
Reply with ONLY a number 0-100, nothing else.`;
const aiResult = await geminiScore(aiPrompt);
const parsed = parseInt(aiResult.trim(), 10);
if (!isNaN(parsed) && parsed >= 0 && parsed <= 100) {
aiScore = (parsed / 100) * 30;
}
} catch (err) {
console.error(`[${AGENT}] AI score error for ${org.name}:`, err.message);
}
}
const matchScore = Math.round((issueScore + geoScore + aiScore) * 100) / 100;
// Skip low-score matches
if (matchScore < 10) continue;
// 4. Insert into organizations if new
let orgId;
if (existing) {
orgId = existing.id;
} else {
try {
const { rows: insertedOrg } = 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(),
source = CASE WHEN organizations.source = 'manual' THEN organizations.source ELSE 'onboard-agent' END
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 = insertedOrg[0]?.id;
} catch (err) {
console.error(`[${AGENT}] Insert org error for ${org.name}:`, err.message);
continue;
}
}
if (!orgId) continue;
// 5. Insert discovery
try {
// Check if discovery already exists for this org + topic
const { rows: existingDisc } = await query(
`SELECT id FROM onboard_discoveries
WHERE org_id = $1 AND trigger_topic_id = $2
LIMIT 1`,
[orgId, topic.id]
);
if (existingDisc.length > 0) continue; // Skip duplicate
await query(
`INSERT INTO onboard_discoveries
(org_id, trigger_topic_id, trigger_type, trigger_label, match_score, match_details, status)
VALUES ($1, $2, 'pulse_topic', $3, $4, $5, 'discovered')`,
[
orgId,
topic.id,
topic.name,
matchScore,
JSON.stringify({
issue_score: Math.round(issueScore * 100) / 100,
geo_score: geoScore,
ai_score: Math.round(aiScore * 100) / 100,
org_issues: orgIssues,
search_query: searchQuery,
propublica_ein: org.ein,
}),
]
);
totalNewDiscoveries++;
} catch (err) {
console.error(`[${AGENT}] Insert discovery error:`, err.message);
}
}
}
// Log the action
await logAction({
agent: AGENT,
actionType: 'discover',
platform: PLATFORM,
content: `Searched ${topics.length} topics`,
responseData: {
topics_searched: topics.length,
orgs_found: totalOrgsFound,
new_discoveries: totalNewDiscoveries,
},
status: 'success',
});
return {
topics_searched: topics.length,
orgs_found: totalOrgsFound,
new_discoveries: totalNewDiscoveries,
};
};