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