← back to Norma

agents/price-agent/skills/report.js

233 lines

/**
 * report.js — Report skill
 *
 * Generates a comprehensive summary report of tuition data,
 * alerts, federal rates, and crawl status. Pushes status
 * to Pulse agent.
 */

const { query } = require('../../shared/db');
const { logAction } = require('../../shared/audit-logger');
const { reportAgentStatus } = require('../../shared/pulse-reporter');

const AGENT_NAME = 'price-agent';

module.exports = async function report(body = {}) {
  const startTime = Date.now();

  try {
    // 1. Total CA schools in tuition_history
    const totalSchoolsRes = await query(
      `SELECT COUNT(DISTINCT unit_id) as total
       FROM tuition_history
       WHERE state = 'CA'`
    );
    const totalSchools = parseInt(totalSchoolsRes.rows[0].total, 10) || 0;

    // 2. Average tuition by institution_type
    const avgTuitionRes = await query(
      `SELECT
         institution_type,
         COUNT(DISTINCT unit_id) as school_count,
         ROUND(AVG(tuition_in_state)) as avg_tuition_in_state,
         ROUND(AVG(tuition_out_state)) as avg_tuition_out_state,
         ROUND(AVG(room_board_on_campus)) as avg_room_board,
         ROUND(AVG(coa_on_campus)) as avg_coa_on_campus,
         ROUND(AVG(net_price_overall)) as avg_net_price,
         MIN(academic_year) as earliest_year,
         MAX(academic_year) as latest_year
       FROM tuition_history
       WHERE state = 'CA'
         AND academic_year = (
           SELECT MAX(academic_year)
           FROM tuition_history
           WHERE state = 'CA'
         )
       GROUP BY institution_type
       ORDER BY avg_tuition_in_state DESC NULLS LAST`
    );
    const avgTuitionByType = avgTuitionRes.rows.map((r) => ({
      institution_type: r.institution_type,
      school_count: parseInt(r.school_count, 10),
      avg_tuition_in_state: parseInt(r.avg_tuition_in_state, 10) || null,
      avg_tuition_out_state: parseInt(r.avg_tuition_out_state, 10) || null,
      avg_room_board: parseInt(r.avg_room_board, 10) || null,
      avg_coa_on_campus: parseInt(r.avg_coa_on_campus, 10) || null,
      avg_net_price: parseInt(r.avg_net_price, 10) || null,
      earliest_year: r.earliest_year,
      latest_year: r.latest_year,
    }));

    // 3. Active (unread) alerts by severity
    const alertsRes = await query(
      `SELECT
         severity,
         COUNT(*)::int as count,
         MAX(created_at) as latest_at
       FROM cost_alerts
       WHERE is_read = false
       GROUP BY severity
       ORDER BY
         CASE severity
           WHEN 'critical' THEN 1
           WHEN 'warning' THEN 2
           WHEN 'info' THEN 3
           ELSE 4
         END`
    );
    const activeAlerts = alertsRes.rows.map((r) => ({
      severity: r.severity,
      count: r.count,
      latest_at: r.latest_at,
    }));

    const totalActiveAlerts = activeAlerts.reduce((sum, a) => sum + a.count, 0);

    // 4. Latest federal rates
    const federalRatesRes = await query(
      `SELECT rate_type, interest_rate, origination_fee, academic_year, data_source
       FROM federal_rates
       WHERE academic_year = (SELECT MAX(academic_year) FROM federal_rates)
       ORDER BY rate_type`
    );
    const latestFederalRates = federalRatesRes.rows.map((r) => ({
      rate_type: r.rate_type,
      interest_rate: parseFloat(r.interest_rate),
      origination_fee: r.origination_fee ? parseFloat(r.origination_fee) : null,
      academic_year: r.academic_year,
      data_source: r.data_source,
    }));

    // 5. Last crawl timestamp per crawl_type
    const crawlStatusRes = await query(
      `SELECT DISTINCT ON (crawl_type)
         crawl_type, status, schools_found, schools_updated,
         duration_ms, started_at, completed_at, error_message
       FROM price_crawl_log
       ORDER BY crawl_type, started_at DESC`
    );
    const crawlStatus = crawlStatusRes.rows.map((r) => ({
      crawl_type: r.crawl_type,
      status: r.status,
      schools_found: r.schools_found,
      schools_updated: r.schools_updated,
      duration_ms: r.duration_ms,
      started_at: r.started_at,
      completed_at: r.completed_at,
      error_message: r.error_message,
    }));

    // Get data coverage stats
    const coverageRes = await query(
      `SELECT
         COUNT(DISTINCT unit_id) as schools_with_data,
         COUNT(DISTINCT academic_year) as years_of_data,
         MIN(academic_year) as earliest_year,
         MAX(academic_year) as latest_year,
         COUNT(*) as total_records,
         COUNT(DISTINCT data_source) as source_count
       FROM tuition_history
       WHERE state = 'CA'`
    );
    const coverage = coverageRes.rows[0] || {};

    // Top 10 most expensive schools (latest year)
    const topExpensiveRes = await query(
      `SELECT school_name, institution_type, tuition_in_state, tuition_out_state,
              coa_on_campus, academic_year
       FROM tuition_history
       WHERE state = 'CA'
         AND academic_year = (SELECT MAX(academic_year) FROM tuition_history WHERE state = 'CA')
         AND tuition_in_state IS NOT NULL
       ORDER BY tuition_in_state DESC
       LIMIT 10`
    );
    const topExpensive = topExpensiveRes.rows;

    // Top 10 largest year-over-year increases (from cost_alerts)
    const topIncreasesRes = await query(
      `SELECT school_name, metric, previous_value, new_value, change_pct,
              academic_year, severity, created_at
       FROM cost_alerts
       WHERE change_pct > 0
       ORDER BY change_pct DESC
       LIMIT 10`
    );
    const topIncreases = topIncreasesRes.rows;

    // Build the full report
    const durationMs = Date.now() - startTime;

    const reportData = {
      generated_at: new Date().toISOString(),
      duration_ms: durationMs,

      summary: {
        total_ca_schools: totalSchools,
        total_active_alerts: totalActiveAlerts,
        data_coverage: {
          schools_with_data: parseInt(coverage.schools_with_data, 10) || 0,
          years_of_data: parseInt(coverage.years_of_data, 10) || 0,
          earliest_year: coverage.earliest_year,
          latest_year: coverage.latest_year,
          total_records: parseInt(coverage.total_records, 10) || 0,
          source_count: parseInt(coverage.source_count, 10) || 0,
        },
      },

      avg_tuition_by_type: avgTuitionByType,
      active_alerts: activeAlerts,
      latest_federal_rates: latestFederalRates,
      crawl_status: crawlStatus,
      top_expensive_schools: topExpensive,
      top_increases: topIncreases,
    };

    // 6. Push status to Pulse
    await reportAgentStatus(AGENT_NAME, {
      state: 'running',
      lastAction: {
        type: 'report',
        timestamp: new Date().toISOString(),
        summary: `${totalSchools} schools tracked, ${totalActiveAlerts} active alerts`,
      },
      metrics: {
        total_schools: totalSchools,
        active_alerts: totalActiveAlerts,
        federal_rates_count: latestFederalRates.length,
        crawl_types: crawlStatus.length,
      },
    }).catch((pulseErr) => {
      console.warn('[report] Pulse status push failed:', pulseErr.message);
    });

    await logAction({
      agent: AGENT_NAME,
      actionType: 'report',
      platform: 'price_monitor',
      content: `Generated report: ${totalSchools} schools, ${totalActiveAlerts} active alerts, ${latestFederalRates.length} federal rate types`,
      responseData: {
        total_schools: totalSchools,
        total_alerts: totalActiveAlerts,
      },
      status: 'success',
    });

    console.log(`[report] Generated in ${durationMs}ms: ${totalSchools} schools, ${totalActiveAlerts} alerts`);

    return reportData;
  } catch (err) {
    await logAction({
      agent: AGENT_NAME,
      actionType: 'report',
      platform: 'price_monitor',
      content: 'Report generation failed',
      status: 'error',
      errorMessage: err.message,
    }).catch(() => {});

    console.error('[report] Fatal error:', err);
    throw err;
  }
};