← back to Norma
agents/onboard-agent/skills/reply.js
187 lines
/**
* OnBoard Reply Skill (Foundation Matcher)
*
* Matches foundations to discovered nonprofit organizations by:
* 1. Loading recent discoveries with status='discovered'
* 2. Finding foundations with overlapping focus_areas
* 3. Creating mind_map_links for funding_flow and advocacy_chain
* 4. Updating discoveries with recommended_actions
*/
const { query, pool } = require('../../shared/db');
const { logAction } = require('../../shared/audit-logger');
const { getIssueAreas } = require('../lib/ntee-matcher');
const { matchFoundations } = require('../lib/foundation-matcher');
const AGENT = 'onboard-agent';
const PLATFORM = 'foundations';
/**
* @param {Object} body - Request body
* @param {number} [body.limit=20] - Max discoveries to process
* @returns {Promise<Object>}
*/
module.exports = async function reply(body = {}) {
const limit = body.limit || 20;
// 1. Load recent discoveries that haven't been matched yet
const { rows: discoveries } = await query(
`SELECT d.id AS discovery_id, d.org_id, d.trigger_topic_id,
d.trigger_label, d.match_score, d.match_details,
o.name AS org_name, o.ntee_code, o.state, o.key_issues,
o.description, o.mission
FROM onboard_discoveries d
JOIN organizations o ON d.org_id = o.id
WHERE d.status = 'discovered'
ORDER BY d.match_score DESC
LIMIT $1`,
[limit]
);
if (discoveries.length === 0) {
await logAction({
agent: AGENT,
actionType: 'reply',
platform: PLATFORM,
status: 'skipped',
content: 'No unmatched discoveries to process',
});
return { matched: 0, links_created: 0 };
}
let totalMatched = 0;
let totalLinksCreated = 0;
for (const disc of discoveries) {
// Get issue areas for the org
const orgIssues = getIssueAreas(disc.ntee_code);
// Add key_issues from the org record if available
if (Array.isArray(disc.key_issues)) {
disc.key_issues.forEach(i => {
if (!orgIssues.includes(i.toLowerCase())) {
orgIssues.push(i.toLowerCase());
}
});
}
// Also add generic issues from mission/description keywords
const descText = `${disc.mission || ''} ${disc.description || ''}`.toLowerCase();
const relevantTerms = ['education', 'nonprofit', 'advocacy', 'financial', 'civic', 'legal',
'consumer', 'scholarship', 'community', 'policy', 'social impact'];
for (const term of relevantTerms) {
if (descText.includes(term) && !orgIssues.includes(term)) {
orgIssues.push(term);
}
}
if (orgIssues.length === 0) continue;
// 2. Find matching foundations
const matches = await matchFoundations(pool, orgIssues, disc.state);
if (matches.length === 0) continue;
totalMatched++;
// 3. Create mind_map_links for each foundation match
const recommendedActions = [];
for (const match of matches.slice(0, 5)) { // Top 5 foundation matches per org
// Link: foundation -> nonprofit (funding_flow)
try {
await query(
`INSERT INTO mind_map_links
(source_type, source_id, target_type, target_id, link_type, weight, metadata, auto_generated)
VALUES ('foundation', $1, 'nonprofit', $2, 'funding_flow', $3, $4, true)
ON CONFLICT (source_type, source_id, target_type, target_id, link_type) DO UPDATE
SET weight = $3, metadata = $4, created_at = NOW()`,
[
match.foundation_id,
disc.org_id,
match.score / 100,
JSON.stringify({
overlap_areas: match.overlap_areas,
geo_match: match.geo_match,
annual_giving: match.annual_giving,
discovered_by: AGENT,
}),
]
);
totalLinksCreated++;
} catch (err) {
console.error(`[${AGENT}] Failed to create funding_flow link:`, err.message);
}
// Link: nonprofit -> topic (advocacy_chain)
if (disc.trigger_topic_id) {
try {
await query(
`INSERT INTO mind_map_links
(source_type, source_id, target_type, target_id, link_type, weight, metadata, auto_generated)
VALUES ('nonprofit', $1, 'topic', $2, 'advocacy_chain', $3, $4, true)
ON CONFLICT (source_type, source_id, target_type, target_id, link_type) DO UPDATE
SET weight = $3, metadata = $4, created_at = NOW()`,
[
disc.org_id,
disc.trigger_topic_id,
disc.match_score / 100,
JSON.stringify({
trigger_label: disc.trigger_label,
foundation_count: matches.length,
discovered_by: AGENT,
}),
]
);
totalLinksCreated++;
} catch (err) {
// Likely already exists (unique constraint), that's fine
if (!err.message.includes('duplicate') && !err.message.includes('unique')) {
console.error(`[${AGENT}] Failed to create advocacy_chain link:`, err.message);
}
}
}
recommendedActions.push({
type: 'apply_for_funding',
foundation: match.foundation_name,
foundation_id: match.foundation_id,
overlap: match.overlap_areas,
score: match.score,
});
}
// 4. Update discovery with recommended actions
try {
await query(
`UPDATE onboard_discoveries
SET recommended_actions = $1, updated_at = NOW()
WHERE id = $2`,
[JSON.stringify(recommendedActions), disc.discovery_id]
);
} catch (err) {
console.error(`[${AGENT}] Failed to update discovery recommended_actions:`, err.message);
}
}
// Log the action
await logAction({
agent: AGENT,
actionType: 'reply',
platform: PLATFORM,
content: `Matched ${totalMatched} discoveries with foundations`,
responseData: {
discoveries_processed: discoveries.length,
matched: totalMatched,
links_created: totalLinksCreated,
},
status: 'success',
});
return {
discoveries_processed: discoveries.length,
matched: totalMatched,
links_created: totalLinksCreated,
};
};