← back to Norma

agents/onboard-agent/lib/ntee-matcher.js

84 lines

/**
 * NTEE Code to Issue Area Mapper
 *
 * Maps National Taxonomy of Exempt Entities (NTEE) codes
 * to human-readable issue areas relevant to nonprofit advocacy,
 * education, consumer protection, and civic engagement.
 */

// NTEE Major Groups relevant to nonprofit advocacy / education / consumer protection
const NTEE_ISSUE_MAP = {
  'B':   ['education', 'higher_education'],
  'B40': ['higher_education'],
  'B41': ['college_universities'],
  'B42': ['graduate_schools'],
  'B43': ['technical_schools'],
  'B60': ['student_services'],
  'B82': ['scholarships'],
  'B83': ['student_financial_aid'],
  'I':   ['crime_legal', 'consumer_protection'],
  'I80': ['legal_services'],
  'P':   ['human_services'],
  'R':   ['civil_rights'],
  'S':   ['community_development'],
  'W':   ['public_policy', 'advocacy'],
  'W20': ['government_reform'],
  'W30': ['military_veteran'],
};

/**
 * Get issue areas for a given NTEE code.
 * Checks exact match, major group (first char), and 3-char prefix.
 *
 * @param {string} nteeCode - NTEE code (e.g. 'B83', 'W20', 'I')
 * @returns {string[]} Array of issue area strings
 */
function getIssueAreas(nteeCode) {
  if (!nteeCode) return [];

  const issues = new Set();
  const major = nteeCode.charAt(0);

  // Major group match (single letter)
  if (NTEE_ISSUE_MAP[major]) {
    NTEE_ISSUE_MAP[major].forEach(i => issues.add(i));
  }

  // Exact match
  if (NTEE_ISSUE_MAP[nteeCode]) {
    NTEE_ISSUE_MAP[nteeCode].forEach(i => issues.add(i));
  }

  // 3-char prefix match (e.g. 'B83' from 'B830')
  const prefix = nteeCode.substring(0, 3);
  if (prefix !== nteeCode && prefix !== major && NTEE_ISSUE_MAP[prefix]) {
    NTEE_ISSUE_MAP[prefix].forEach(i => issues.add(i));
  }

  return [...issues];
}

/**
 * Compute overlap score between org issue areas and topic keywords.
 *
 * @param {string[]} orgIssues - Issue areas derived from NTEE code
 * @param {string[]} topicKeywords - Keywords from the pulse topic
 * @returns {number} Score from 0 to 1
 */
function issueOverlapScore(orgIssues, topicKeywords) {
  if (!orgIssues.length || !topicKeywords.length) return 0;

  const topicLower = topicKeywords.map(k => k.toLowerCase());

  let matches = 0;
  for (const issue of orgIssues) {
    if (topicLower.some(k => issue.includes(k) || k.includes(issue))) {
      matches++;
    }
  }

  return Math.min(1, matches / Math.max(orgIssues.length, topicLower.length));
}

module.exports = { NTEE_ISSUE_MAP, getIssueAreas, issueOverlapScore };