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