← back to Norma

agents/price-agent/skills/detect-changes.js

193 lines

/**
 * detect-changes.js — Monitor skill
 *
 * Compares tuition_history across academic years to detect
 * significant changes. Creates cost_alerts and pushes
 * critical alerts to Pulse.
 */

const { query } = require('../../shared/db');
const { logAction } = require('../../shared/audit-logger');
const { pushSocialFeed } = require('../../shared/pulse-reporter');
const { detectChanges, THRESHOLDS } = require('../lib/change-detector');

const AGENT_NAME = 'price-agent';

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

  let schoolsChecked = 0;
  let alertsCreated = 0;
  let criticalCount = 0;
  let warningCount = 0;
  let infoCount = 0;
  const errors = [];

  try {
    // 1. Query schools with data in 2+ academic_years
    const { rows: schoolsWithMultipleYears } = await query(
      `SELECT unit_id, school_name, institution_type,
              COUNT(DISTINCT academic_year) as year_count
       FROM tuition_history
       WHERE state = 'CA'
       GROUP BY unit_id, school_name, institution_type
       HAVING COUNT(DISTINCT academic_year) >= 2
       ORDER BY school_name`
    );

    console.log(`[detect-changes] Found ${schoolsWithMultipleYears.length} schools with 2+ years of data`);

    // 2. For each school, get the 2 most recent years and compare
    for (const school of schoolsWithMultipleYears) {
      try {
        const { rows: recentYears } = await query(
          `SELECT *
           FROM tuition_history
           WHERE unit_id = $1
           ORDER BY academic_year DESC
           LIMIT 2`,
          [school.unit_id]
        );

        if (recentYears.length < 2) continue;

        schoolsChecked++;
        const current = recentYears[0];
        const previous = recentYears[1];

        // 3. Call detectChanges from change-detector lib
        const changes = detectChanges(previous, current);

        if (!changes || changes.length === 0) continue;

        // 4. Insert alerts for each detected change
        for (const change of changes) {
          try {
            const severity = change.severity || 'info';

            await query(
              `INSERT INTO cost_alerts (
                unit_id, school_name, alert_type, metric,
                previous_value, new_value, change_pct,
                academic_year, severity
              ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
              [
                school.unit_id,
                school.school_name,
                change.alert_type || 'tuition_change',
                change.metric,
                change.previous_value,
                change.new_value,
                change.change_pct,
                current.academic_year,
                severity,
              ]
            );

            alertsCreated++;

            if (severity === 'critical') criticalCount++;
            else if (severity === 'warning') warningCount++;
            else infoCount++;

            // 5. Push critical alerts to Pulse
            if (severity === 'critical') {
              const changePctStr = change.change_pct != null
                ? `${change.change_pct > 0 ? '+' : ''}${change.change_pct.toFixed(1)}%`
                : 'N/A';

              await pushSocialFeed({
                agent: AGENT_NAME,
                platform: 'price_monitor',
                type: 'alert',
                title: `CRITICAL: ${school.school_name} — ${change.metric} ${changePctStr}`,
                content: `${change.metric} changed from $${change.previous_value || 0} to $${change.new_value || 0} (${changePctStr}) for academic year ${current.academic_year}`,
                metadata: {
                  unit_id: school.unit_id,
                  institution_type: school.institution_type,
                  metric: change.metric,
                  previous_value: change.previous_value,
                  new_value: change.new_value,
                  change_pct: change.change_pct,
                  severity: 'critical',
                },
              }).catch((pulseErr) => {
                console.warn(`[detect-changes] Pulse push failed for ${school.school_name}:`, pulseErr.message);
              });
            }
          } catch (alertErr) {
            errors.push({
              school: school.school_name,
              metric: change.metric,
              error: alertErr.message,
            });
            console.error(`[detect-changes] Alert insert error for ${school.school_name}/${change.metric}:`, alertErr.message);
          }
        }
      } catch (schoolErr) {
        errors.push({ school: school.school_name, error: schoolErr.message });
        console.error(`[detect-changes] Error processing ${school.school_name}:`, schoolErr.message);
      }
    }

    // Also push warning-level alerts to Pulse if there are many
    if (warningCount >= 5) {
      await pushSocialFeed({
        agent: AGENT_NAME,
        platform: 'price_monitor',
        type: 'summary',
        title: `Price Monitor: ${warningCount} warning-level changes detected`,
        content: `${schoolsChecked} schools checked. ${criticalCount} critical, ${warningCount} warnings, ${infoCount} info alerts created.`,
        metadata: {
          schools_checked: schoolsChecked,
          alerts_created: alertsCreated,
          critical_count: criticalCount,
          warning_count: warningCount,
        },
      }).catch(() => {});
    }

    const durationMs = Date.now() - startTime;

    await logAction({
      agent: AGENT_NAME,
      actionType: 'monitor',
      platform: 'price_monitor',
      content: `Checked ${schoolsChecked} schools: ${alertsCreated} alerts (${criticalCount} critical, ${warningCount} warning)`,
      responseData: {
        schools_checked: schoolsChecked,
        alerts_created: alertsCreated,
        critical_count: criticalCount,
        warning_count: warningCount,
        info_count: infoCount,
      },
      status: 'success',
    });

    console.log(`[detect-changes] Complete: ${schoolsChecked} schools, ${alertsCreated} alerts (${criticalCount}C/${warningCount}W/${infoCount}I) in ${durationMs}ms`);

    return {
      schools_checked: schoolsChecked,
      alerts_created: alertsCreated,
      critical_count: criticalCount,
      warning_count: warningCount,
      info_count: infoCount,
      errors_count: errors.length,
      duration_ms: durationMs,
      thresholds: THRESHOLDS,
    };
  } catch (err) {
    await logAction({
      agent: AGENT_NAME,
      actionType: 'monitor',
      platform: 'price_monitor',
      content: 'Change detection failed',
      status: 'error',
      errorMessage: err.message,
    }).catch(() => {});

    console.error('[detect-changes] Fatal error:', err);
    throw err;
  }
};