← back to Norma

agents/datasource-agent/skills/collect-statements.js

320 lines

/**
 * Collect Statements Skill
 *
 * Collects press releases and statements from non-profit organizations.
 * Queries the organizations table for orgs with websites, attempts to fetch
 * /press, /news, /blog, /press-releases pages, and uses Gemini to extract
 * and analyze statement data.
 */

const fetch = require('node-fetch');
const { logAction } = require('../../shared/audit-logger');

const AGENT = 'datasource-agent';
const PLATFORM = 'nonprofits';

const GEMINI_URL =
  'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${GOOGLE_API_KEY}';

const DB_URL = process.env.DATABASE_URL || 'postgresql://dw_admin@127.0.0.1:5432/sdcc';

const PRESS_PATHS = ['/press', '/news', '/blog', '/press-releases', '/media', '/newsroom', '/updates', '/statements'];

let _pool;
function getPool() {
  if (!_pool) {
    const { Pool } = require('pg');
    _pool = new Pool({ connectionString: DB_URL, max: 5 });
  }
  return _pool;
}

/**
 * Fetch a page with timeout and return text content.
 *
 * @param {string} url - URL to fetch
 * @returns {Promise<string|null>} HTML text or null
 */
async function fetchPage(url) {
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), 12000);
  try {
    const res = await fetch(url, {
      signal: controller.signal,
      headers: {
        'User-Agent': 'Norma-DataSource-Agent/1.0 (research; non-commercial)',
        'Accept': 'text/html,application/xhtml+xml,*/*',
      },
      redirect: 'follow',
    });
    clearTimeout(timeout);
    if (!res.ok) return null;
    const text = await res.text();
    // Only return if it looks like HTML with some content
    if (text.length < 200) return null;
    return text;
  } catch (err) {
    clearTimeout(timeout);
    return null;
  }
}

/**
 * Extract article/post links from an HTML page using regex.
 * Looks for common patterns: anchor tags with /press/, /news/, /blog/ in href.
 *
 * @param {string} html - HTML content
 * @param {string} baseUrl - Base URL for resolving relative links
 * @returns {Array<{ title: string, url: string }>}
 */
function extractArticleLinks(html, baseUrl) {
  const links = [];
  const seen = new Set();

  // Match anchor tags with href
  const linkRegex = /<a\s[^>]*href=["']([^"'#]+)["'][^>]*>([\s\S]*?)<\/a>/gi;
  let match;

  while ((match = linkRegex.exec(html)) !== null) {
    let href = match[1].trim();
    let title = match[2].replace(/<[^>]*>/g, '').trim();

    // Skip empty, javascript, mailto, and anchor-only links
    if (!href || href.startsWith('javascript:') || href.startsWith('mailto:') || href === '/') continue;

    // Resolve relative URLs
    if (href.startsWith('/')) {
      try {
        const base = new URL(baseUrl);
        href = base.origin + href;
      } catch (e) {
        continue;
      }
    } else if (!href.startsWith('http')) {
      continue;
    }

    // Filter for likely article/press release links
    const lowerHref = href.toLowerCase();
    const isArticle =
      lowerHref.includes('/press') ||
      lowerHref.includes('/news') ||
      lowerHref.includes('/blog') ||
      lowerHref.includes('/statement') ||
      lowerHref.includes('/release') ||
      lowerHref.includes('/update') ||
      lowerHref.includes('/article') ||
      lowerHref.includes('/post/') ||
      /\/\d{4}\/\d{2}\//.test(lowerHref); // date-based URLs

    if (isArticle && title.length >= 10 && !seen.has(href)) {
      seen.add(href);
      links.push({ title: title.substring(0, 300), url: href });
    }
  }

  return links.slice(0, 20);
}

/**
 * Use Gemini to analyze extracted statements/articles.
 *
 * @param {string} orgName - Organization name
 * @param {Array<{ title: string, url: string }>} articles - Article links found
 * @returns {Promise<Array<{ title: string, summary: string, topics: string[], sentiment: string, url: string }>>}
 */
async function geminiAnalyzeStatements(orgName, articles) {
  if (!articles || articles.length === 0) return [];

  const articleList = articles.slice(0, 10).map((a, i) => (i + 1) + '. "' + a.title + '" - ' + a.url).join('\n');

  try {
    const prompt = 'You are analyzing press releases and statements from the non-profit organization "' + orgName + '".\n\nHere are the article titles and URLs found on their website:\n' + articleList + '\n\nFor each article that appears to be a substantive statement or press release (skip navigation links, category pages, etc.), extract:\n- title: the article title\n- summary: a 1-2 sentence summary based on the title\n- topics: array of relevant topic tags\n- sentiment: positive, negative, neutral, or mixed\n- url: the article URL\n\nFocus especially on articles related to: nonprofit advocacy, education policy, consumer protection, economic justice, civic engagement, social impact.\n\nRespond in EXACTLY this JSON format (no markdown, no code fences):\n{"statements": [{"title": "...", "summary": "...", "topics": ["..."], "sentiment": "neutral", "url": "..."}]}\n\nReturn an empty array if none appear to be real statements.';

    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: 1500 },
      }),
    });

    const data = await res.json();
    const text = data?.candidates?.[0]?.content?.parts?.[0]?.text || '';

    const jsonMatch = text.match(/\{[\s\S]*\}/);
    if (jsonMatch) {
      const parsed = JSON.parse(jsonMatch[0]);
      return Array.isArray(parsed.statements) ? parsed.statements : [];
    }

    return [];
  } catch (err) {
    console.error('[' + AGENT + '] Gemini statement analysis error for ' + orgName + ':', err.message);
    return [];
  }
}

/**
 * Insert a statement into the database.
 *
 * @param {string} orgId - Organization UUID
 * @param {string} orgName - Organization name
 * @param {Object} statement - Extracted statement data
 * @returns {Promise<Object|null>}
 */
async function insertStatement(orgId, orgName, statement) {
  const pool = getPool();
  try {
    const { rows } = await pool.query(
      'INSERT INTO nonprofit_statements\n        (org_id, org_name, statement_title, statement_body, statement_url, category, topics, sentiment, source_url)\n       VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)\n       RETURNING id, statement_title',
      [
        orgId || null,
        orgName,
        statement.title || 'Untitled Statement',
        statement.summary || '',
        statement.url || null,
        'press-release',
        statement.topics || [],
        statement.sentiment || 'neutral',
        statement.url || null,
      ]
    );
    return rows[0] || null;
  } catch (err) {
    // Skip duplicates silently
    if (err.code === '23505') return null;
    console.error('[' + AGENT + '] DB insert error for statement "' + (statement.title || '') + '":', err.message);
    return null;
  }
}

/**
 * Main collect-statements skill handler.
 *
 * @param {Object} body - Request body
 * @param {number} [body.max_orgs=20] - Max organizations to check
 * @param {boolean} [body.analyze=true] - Whether to AI-analyze found articles
 * @returns {Promise<Object>} Collection results
 */
module.exports = async function collectStatements(body = {}) {
  const maxOrgs = body.max_orgs || 20;
  const shouldAnalyze = body.analyze !== false;

  const pool = getPool();

  // Get organizations with websites
  let orgs;
  try {
    const { rows } = await pool.query(
      "SELECT id, name, website FROM organizations WHERE website IS NOT NULL AND website != '' ORDER BY updated_at DESC LIMIT $1",
      [maxOrgs]
    );
    orgs = rows;
  } catch (err) {
    console.error('[' + AGENT + '] DB query error for organizations:', err.message);
    orgs = [];
  }

  let orgsChecked = 0;
  let statementsFound = 0;
  let pagesChecked = 0;
  const statementsByOrg = [];

  for (const org of orgs) {
    orgsChecked++;
    const orgStatements = [];
    let baseUrl = org.website;

    // Normalize the URL
    if (!baseUrl.startsWith('http')) {
      baseUrl = 'https://' + baseUrl;
    }
    // Remove trailing slash
    baseUrl = baseUrl.replace(/\/+$/, '');

    console.log('[' + AGENT + '] Checking ' + org.name + ' (' + baseUrl + ')...');

    // Try each press path
    for (const path of PRESS_PATHS) {
      const pageUrl = baseUrl + path;
      pagesChecked++;

      const html = await fetchPage(pageUrl);
      if (!html) continue;

      const articleLinks = extractArticleLinks(html, baseUrl);
      if (articleLinks.length === 0) continue;

      console.log('[' + AGENT + ']   Found ' + articleLinks.length + ' links at ' + path);

      if (shouldAnalyze) {
        const analyzed = await geminiAnalyzeStatements(org.name, articleLinks);
        // Delay for Gemini rate limits
        await new Promise(function(r) { setTimeout(r, 400); });

        for (const stmt of analyzed) {
          const inserted = await insertStatement(org.id, org.name, stmt);
          if (inserted) {
            statementsFound++;
            orgStatements.push({
              title: inserted.statement_title,
              topics: stmt.topics,
              sentiment: stmt.sentiment,
            });
          }
        }
      } else {
        // Without analysis, insert raw links
        for (const link of articleLinks.slice(0, 5)) {
          const inserted = await insertStatement(org.id, org.name, {
            title: link.title,
            summary: '',
            url: link.url,
            topics: [],
            sentiment: 'neutral',
          });
          if (inserted) {
            statementsFound++;
            orgStatements.push({ title: inserted.statement_title });
          }
        }
      }

      // Only scrape the first successful path per org to be polite
      break;
    }

    if (orgStatements.length > 0) {
      statementsByOrg.push({
        org: org.name,
        count: orgStatements.length,
        statements: orgStatements.slice(0, 3),
      });
    }
  }

  await logAction({
    agent: AGENT,
    actionType: 'collect',
    platform: PLATFORM,
    content: 'Checked ' + orgsChecked + ' orgs, found ' + statementsFound + ' statements',
    responseData: {
      orgs_checked: orgsChecked,
      statements_found: statementsFound,
      pages_checked: pagesChecked,
      orgs_with_statements: statementsByOrg.length,
    },
    status: 'success',
  });

  return {
    orgs_checked: orgsChecked,
    statements_found: statementsFound,
    pages_checked: pagesChecked,
    by_org: statementsByOrg,
    fetched_at: new Date().toISOString(),
  };
};