Files
worldmonitor/scripts/lib/user-context.cjs
Elie Habib 00320c26cf feat(notifications): proactive intelligence agent (Phase 4) (#2889)
* feat(notifications): proactive intelligence agent (Phase 4)

New Railway cron (every 6 hours) that detects signal landscape changes
and generates proactive intelligence briefs before events break.

Reads ~8 Redis signal keys (CII risk, GPS interference, unrest, sanctions,
cyber threats, thermal anomalies, weather, commodities), computes a
landscape snapshot, diffs against the previous run, and generates an
LLM brief when the diff score exceeds threshold.

Key features:
- Signal landscape diff with weighted scoring (new risk countries = 2pts,
  GPS zone changes = 1pt per zone, commodity movers >3% = 1pt)
- Server-side convergence detection: countries with 3+ signal types flagged
- First run stores baseline only (no false-positive brief)
- Delivers via all 5 channels (Telegram, Slack, Discord, Email, Webhook)
- PROACTIVE_INTEL_ENABLED=0 env var to disable
- Skips users without saved preferences or deliverable channels

Requires: Railway cron service configuration (every 6 hours)

* fix(proactive): fetch all enabled rules + expand convergence to all signal types

1. Replace /relay/digest-rules (digest-mode only) with ConvexHttpClient
   query alertRules:getByEnabled to include ALL enabled rules, not just
   digest-mode users. Proactive briefs now reach real-time users too.
2. Expand convergence detection from 3 signal families (risk, unrest,
   sanctions) to all 7 (add GPS interference, cyber, thermal, weather).
   Track signal TYPES per country (Set), not event counts, so convergence
   means 3+ distinct signal categories, not 3+ events from one category.
3. Include signal type names in convergence zone output for LLM context
   and webhook payload.

* fix(proactive): check channels before LLM + deactivate stale channels

1. Move channel fetch + deliverability check BEFORE user prefs and LLM
   call to avoid wasting LLM calls on users with no verified channels
2. Add deactivateChannel() calls on 404/410/403 responses in all delivery
   helpers (Telegram, Slack, Discord, Webhook), matching the pattern in
   notification-relay.cjs and seed-digest-notifications.mjs

* fix(proactive): preserve landscape on transient failures + drop Telegram Markdown

1. Don't advance landscape baseline when channel fetch or LLM fails,
   so the brief retries on the next run instead of permanently suppressing
   the change window
2. Remove parse_mode: 'Markdown' from Telegram sendMessage to avoid 400
   errors from unescaped characters in LLM output (matches digest pattern)

* fix(proactive): only advance landscape baseline after successful delivery

* fix(proactive): abort on degraded signals + don't advance on prefs failure

1. Track loaded signal key count. Abort run if <60% of keys loaded
   to prevent false diffs from degraded Redis snapshots becoming
   the new baseline.
2. Don't advance landscape when fetchUserPreferences() returns null
   (could be transient failure, not just "no saved prefs"). Retries
   next run instead of permanently suppressing the brief.

* fix(notifications): distinguish no-prefs from fetch-error in user-context

fetchUserPreferences() now returns { data, error } instead of bare null.
error=true means transient failure (retry next run, don't advance baseline).
data=null + error=false means user has no saved preferences (skip + advance).

Proactive script: retry on error, skip+advance on no-prefs.
Digest script: updated to destructure new return shape (behavior unchanged,
  both cases skip AI summary).

* fix(proactive): address all Greptile review comments

P1: Add link-local (169.254) and 0.0.0.0 to isPrivateIP SSRF check
P1: Log channel-fetch failures (was silent catch{})
P2: Remove unused createHash import and BRIEF_TTL constant
P2: Remove dead ?? 'full' fallback (rule.variant validated above)
P2: Add HTTPS enforcement to sendSlack/sendDiscord (matching sendWebhook)
2026-04-10 08:08:27 +04:00

124 lines
4.6 KiB
JavaScript

'use strict';
const CONVEX_SITE_URL =
process.env.CONVEX_SITE_URL ??
(process.env.CONVEX_URL ?? '').replace('.convex.cloud', '.convex.site');
const RELAY_SECRET = process.env.RELAY_SHARED_SECRET ?? '';
/**
* Fetch the raw user preferences blob from Convex via the relay endpoint.
* Returns the parsed data object, or null on failure.
*
* @param {string} userId
* @param {string} variant
* @returns {Promise<Record<string, unknown> | null>}
*/
/**
* @returns {{ data: object|null, error: boolean }} data=null + error=false means no prefs saved; error=true means transient failure
*/
async function fetchUserPreferences(userId, variant) {
if (!CONVEX_SITE_URL || !RELAY_SECRET) {
console.warn('[user-context] CONVEX_SITE_URL or RELAY_SHARED_SECRET not set');
return { data: null, error: true };
}
try {
const res = await fetch(`${CONVEX_SITE_URL}/relay/user-preferences`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${RELAY_SECRET}`,
'User-Agent': 'worldmonitor-relay/1.0',
},
body: JSON.stringify({ userId, variant }),
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) {
console.warn(`[user-context] fetchUserPreferences: ${res.status}`);
return { data: null, error: true };
}
const data = await res.json();
return { data, error: false };
} catch (err) {
console.warn(`[user-context] fetchUserPreferences failed: ${err.message}`);
return { data: null, error: true };
}
}
/**
* Extract actionable user context from the raw Convex preferences blob.
*
* @param {Record<string, unknown> | null} prefs - Raw data blob from userPreferences
* @returns {{ tickers: string[], airports: string[], airlines: string[], frameworkName: string | null, enabledPanels: string[], disabledFeeds: string[] }}
*/
function extractUserContext(prefs) {
const ctx = {
tickers: [],
airports: [],
airlines: [],
frameworkName: null,
enabledPanels: [],
disabledFeeds: [],
};
if (!prefs || typeof prefs !== 'object') return ctx;
const watchlist = prefs['wm-market-watchlist-v1'];
if (Array.isArray(watchlist)) {
ctx.tickers = watchlist
.filter(w => w && typeof w === 'object' && w.symbol)
.map(w => w.symbol)
.slice(0, 20);
}
const aviation = prefs['aviation:watchlist:v1'];
if (aviation && typeof aviation === 'object') {
if (Array.isArray(aviation.airports)) ctx.airports = aviation.airports.slice(0, 10);
if (Array.isArray(aviation.airlines)) ctx.airlines = aviation.airlines.slice(0, 10);
}
const frameworks = prefs['wm-analysis-frameworks'];
const panelFrameworks = prefs['wm-panel-frameworks'];
if (frameworks && typeof frameworks === 'object') {
const activeId = frameworks.activeId;
const list = Array.isArray(frameworks.frameworks) ? frameworks.frameworks : [];
const active = list.find(f => f && f.id === activeId);
if (active && active.name) ctx.frameworkName = active.name;
}
if (!ctx.frameworkName && panelFrameworks && typeof panelFrameworks === 'object') {
const firstActive = Object.values(panelFrameworks).find(v => v && typeof v === 'string');
if (firstActive) ctx.frameworkName = firstActive;
}
const panels = prefs['worldmonitor-panels'];
if (Array.isArray(panels)) {
ctx.enabledPanels = panels.filter(p => typeof p === 'string').slice(0, 30);
}
const disabled = prefs['worldmonitor-disabled-feeds'];
if (Array.isArray(disabled)) {
ctx.disabledFeeds = disabled.filter(d => typeof d === 'string').slice(0, 20);
}
return ctx;
}
/**
* Build a concise user profile string for LLM prompts.
*
* @param {{ tickers: string[], airports: string[], airlines: string[], frameworkName: string | null, enabledPanels: string[], disabledFeeds: string[] }} ctx
* @param {string} variant
* @returns {string}
*/
function formatUserProfile(ctx, variant) {
const lines = [];
lines.push(`Variant: ${variant}`);
if (ctx.tickers.length > 0) lines.push(`Watches: ${ctx.tickers.join(', ')}`);
if (ctx.airports.length > 0) lines.push(`Monitors airports: ${ctx.airports.join(', ')}`);
if (ctx.airlines.length > 0) lines.push(`Monitors airlines: ${ctx.airlines.join(', ')}`);
if (ctx.frameworkName) lines.push(`Analysis framework: ${ctx.frameworkName}`);
if (ctx.enabledPanels.length > 0) lines.push(`Active domains: ${ctx.enabledPanels.slice(0, 15).join(', ')}`);
if (ctx.disabledFeeds.length > 0) lines.push(`Ignores: ${ctx.disabledFeeds.slice(0, 10).join(', ')}`);
return lines.join('\n');
}
module.exports = { fetchUserPreferences, extractUserContext, formatUserProfile };