mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
* feat(intelligence): emit news:threat:summary:v1 from relay classify loop for CII
During seedClassifyForVariant(), attribute each title to ISO2 countries
while both title and classification result are in scope. At the end of
seedClassify(), merge per-country threat counts across all variants and
write news:threat:summary:v1 (20min TTL) with { byCountry: { [iso2]: {
critical, high, medium, low, info } }, generatedAt }.
get-risk-scores.ts reads the new key via fetchAuxiliarySources() and
applies weighted scores (critical→4, high→2, medium→1, low→0.5, info→0,
capped at 20) per country into the information component of CII eventScore.
Closes #2053
* fix(intelligence): register news:threat-summary in health.js and expand tests
- Add newsThreatSummary to BOOTSTRAP_KEYS (seed-meta:news:threat-summary,
maxStaleMin: 60) so relay classify outages surface in health dashboard
- Add 4 tests: boost verification, cap-at-20, unknown-country safety,
null-threatSummary zero baseline
* fix(classify): de-dup cross-variant titles and attribute to last-mentioned country
P1-A: seedClassify() was summing byCountry across all 5 variants (full/tech/
finance/happy/commodity) without de-duplicating. Shared feeds (CNBC, Yahoo
Finance, FT, HN, Ars) let a single headline count up to 4x before reaching
CII, saturating threatSummaryScore on one story.
Fix: pass seenTitles Set into seedClassifyForVariant; skip attribution for
titles already counted by an earlier variant.
P1-B: matchCountryNamesInText() was attributing every country mentioned in a
headline equally. "UK and US launch strikes on Yemen" raised GB, US, and YE
with identical weight, inflating actor-country CII.
Fix: return only the last country in document order — the grammatical object
of the headline, which is the primary affected country in SVO structure.
* fix(classify): replace last-position heuristic with preposition-pattern attribution
The previous "last-mentioned country" fix still failed for:
- "Yemen says UK and US strikes hit Hodeidah" → returned US (wrong)
- "US strikes on Yemen condemned by Iran" → returned IR (wrong)
Both failures stem from position not conveying grammatical role. Switch to a
preposition/verb-pattern approach: only attribute to a country that immediately
follows a locative preposition (in/on/against/at/into/targeting/toward) or an
attack verb (invades/attacks/bombs/hits/strikes). No pattern match → return []
(skip attribution rather than attribute to the wrong country).
* fix(classify): fix regex hitting, gaza/hamas geo mapping, seed-meta always written
- hitt?(?:ing|s)? instead of hit(?:s|ting)? so "hitting" is matched
- gaza → PS (Palestinian Territories), hamas → PS (was IL)
- seed-meta:news:threat-summary written unconditionally so health check
does not fire false alerts during no-attribution runs
271 lines
13 KiB
TypeScript
271 lines
13 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { describe, it } from 'node:test';
|
|
|
|
import { computeCIIScores } from '../server/worldmonitor/intelligence/v1/get-risk-scores.ts';
|
|
|
|
function emptyAux() {
|
|
return {
|
|
ucdpEvents: [] as any[],
|
|
outages: [] as any[],
|
|
climate: [] as any[],
|
|
cyber: [] as any[],
|
|
fires: [] as any[],
|
|
gpsHexes: [] as any[],
|
|
iranEvents: [] as any[],
|
|
orefData: null as { activeAlertCount: number; historyCount24h: number } | null,
|
|
advisories: null as { byCountry: Record<string, 'do-not-travel' | 'reconsider' | 'caution'> } | null,
|
|
displacedByIso3: {} as Record<string, number>,
|
|
newsTopStories: [] as Array<{ countryCode: string | null; threatLevel: string; primaryTitle: string }>,
|
|
threatSummaryByCountry: null as Record<string, { critical: number; high: number; medium: number; low: number; info: number }> | null,
|
|
};
|
|
}
|
|
|
|
function acledEvent(country: string, type: string, fatalities = 0) {
|
|
return { country, event_type: type, fatalities };
|
|
}
|
|
|
|
function scoreFor(scores: ReturnType<typeof computeCIIScores>, code: string) {
|
|
return scores.find((s) => s.region === code);
|
|
}
|
|
|
|
describe('CII scoring', () => {
|
|
it('returns scores for all 31 tier-1 countries including MX, BR, AE, LB, IQ, AF', () => {
|
|
const scores = computeCIIScores([], emptyAux());
|
|
assert.equal(scores.length, 31);
|
|
assert.ok(scoreFor(scores, 'MX'), 'MX missing');
|
|
assert.ok(scoreFor(scores, 'BR'), 'BR missing');
|
|
assert.ok(scoreFor(scores, 'AE'), 'AE missing');
|
|
assert.ok(scoreFor(scores, 'LB'), 'LB missing');
|
|
assert.ok(scoreFor(scores, 'IQ'), 'IQ missing');
|
|
assert.ok(scoreFor(scores, 'AF'), 'AF missing');
|
|
assert.ok(scoreFor(scores, 'KR'), 'KR missing');
|
|
assert.ok(scoreFor(scores, 'EG'), 'EG missing');
|
|
assert.ok(scoreFor(scores, 'JP'), 'JP missing');
|
|
assert.ok(scoreFor(scores, 'QA'), 'QA missing');
|
|
});
|
|
|
|
it('UCDP war floor: composite >= 70', () => {
|
|
const aux = emptyAux();
|
|
aux.ucdpEvents = [{ country: 'Ukraine', intensity_level: '2' }];
|
|
const scores = computeCIIScores([], aux);
|
|
const ua = scoreFor(scores, 'UA')!;
|
|
assert.ok(ua.combinedScore >= 70, `UA score ${ua.combinedScore} should be >= 70 with UCDP war`);
|
|
});
|
|
|
|
it('UCDP minor conflict floor: composite >= 50', () => {
|
|
const aux = emptyAux();
|
|
aux.ucdpEvents = [{ country: 'Pakistan', intensity_level: '1' }];
|
|
const scores = computeCIIScores([], aux);
|
|
const pk = scoreFor(scores, 'PK')!;
|
|
assert.ok(pk.combinedScore >= 50, `PK score ${pk.combinedScore} should be >= 50 with UCDP minor`);
|
|
});
|
|
|
|
it('advisory do-not-travel floor: composite >= 60', () => {
|
|
const scores = computeCIIScores([], emptyAux());
|
|
for (const code of ['UA', 'SY', 'YE', 'MM']) {
|
|
const s = scoreFor(scores, code)!;
|
|
assert.ok(s.combinedScore >= 60, `${code} score ${s.combinedScore} should be >= 60 (do-not-travel)`);
|
|
}
|
|
});
|
|
|
|
it('advisory reconsider floor: composite >= 50', () => {
|
|
const scores = computeCIIScores([], emptyAux());
|
|
for (const code of ['MX', 'IR', 'PK', 'VE', 'CU']) {
|
|
const s = scoreFor(scores, code)!;
|
|
assert.ok(s.combinedScore >= 50, `${code} score ${s.combinedScore} should be >= 50 (reconsider)`);
|
|
}
|
|
});
|
|
|
|
it('OREF active alerts boost IL conflict score', () => {
|
|
const aux = emptyAux();
|
|
aux.orefData = { activeAlertCount: 5, historyCount24h: 12 };
|
|
const withOref = scoreFor(computeCIIScores([], aux), 'IL')!;
|
|
const withoutOref = scoreFor(computeCIIScores([], emptyAux()), 'IL')!;
|
|
assert.ok(withOref.combinedScore > withoutOref.combinedScore,
|
|
`IL with OREF (${withOref.combinedScore}) should be > without (${withoutOref.combinedScore})`);
|
|
});
|
|
|
|
it('outage TOTAL severity gives higher unrest component than PARTIAL', () => {
|
|
const auxTotal = emptyAux();
|
|
auxTotal.outages = [{ countryCode: 'DE', severity: 'OUTAGE_SEVERITY_TOTAL' }];
|
|
const auxPartial = emptyAux();
|
|
auxPartial.outages = [{ countryCode: 'DE', severity: 'OUTAGE_SEVERITY_PARTIAL' }];
|
|
const total = scoreFor(computeCIIScores([], auxTotal), 'DE')!;
|
|
const partial = scoreFor(computeCIIScores([], auxPartial), 'DE')!;
|
|
assert.ok(total.components!.ciiContribution > partial.components!.ciiContribution,
|
|
`TOTAL unrest (${total.components!.ciiContribution}) should be > PARTIAL (${partial.components!.ciiContribution})`);
|
|
});
|
|
|
|
it('GPS high level gives higher weight than medium', () => {
|
|
const auxHigh = emptyAux();
|
|
auxHigh.gpsHexes = Array.from({ length: 5 }, () => ({ lat: 33.0, lon: 35.0, level: 'high' }));
|
|
const auxMed = emptyAux();
|
|
auxMed.gpsHexes = Array.from({ length: 5 }, () => ({ lat: 33.0, lon: 35.0, level: 'medium' }));
|
|
const high = scoreFor(computeCIIScores([], auxHigh), 'IL')!;
|
|
const med = scoreFor(computeCIIScores([], auxMed), 'IL')!;
|
|
assert.ok(high.components!.militaryActivity >= med.components!.militaryActivity,
|
|
`GPS high (${high.components!.militaryActivity}) should be >= medium (${med.components!.militaryActivity})`);
|
|
});
|
|
|
|
it('conflict fatalities use sqrt scaling', () => {
|
|
const acled100 = [acledEvent('Ukraine', 'Battles', 100)];
|
|
const acled400 = [acledEvent('Ukraine', 'Battles', 400)];
|
|
const s100 = scoreFor(computeCIIScores(acled100, emptyAux()), 'UA')!;
|
|
const s400 = scoreFor(computeCIIScores(acled400, emptyAux()), 'UA')!;
|
|
const diff = s400.combinedScore - s100.combinedScore;
|
|
assert.ok(diff < (s400.combinedScore - s100.staticBaseline) * 0.5,
|
|
'sqrt scaling should produce diminishing returns for 4x fatalities');
|
|
});
|
|
|
|
it('log2 scaling dampens high-volume low-multiplier countries vs linear', () => {
|
|
const manyProtests = Array.from({ length: 100 }, () => acledEvent('United States', 'Protests'));
|
|
const fewProtests = Array.from({ length: 10 }, () => acledEvent('United States', 'Protests'));
|
|
const many = scoreFor(computeCIIScores(manyProtests, emptyAux()), 'US')!;
|
|
const few = scoreFor(computeCIIScores(fewProtests, emptyAux()), 'US')!;
|
|
const ratio = many.components!.ciiContribution / Math.max(1, few.components!.ciiContribution);
|
|
assert.ok(ratio < 5, `10x events should produce < 5x unrest ratio (got ${ratio.toFixed(2)}), log2 dampens`);
|
|
});
|
|
|
|
it('iran high severity strikes boost conflict', () => {
|
|
const aux1 = emptyAux();
|
|
aux1.iranEvents = [{ lat: 33.0, lon: 35.0, severity: 'high' }];
|
|
const aux2 = emptyAux();
|
|
aux2.iranEvents = [{ lat: 33.0, lon: 35.0, severity: 'low' }];
|
|
const highSev = scoreFor(computeCIIScores([], aux1), 'IL')!;
|
|
const lowSev = scoreFor(computeCIIScores([], aux2), 'IL')!;
|
|
assert.ok(highSev.combinedScore >= lowSev.combinedScore,
|
|
`High severity strike (${highSev.combinedScore}) should be >= low (${lowSev.combinedScore})`);
|
|
});
|
|
|
|
it('IL scores higher than MX with active conflict signals', () => {
|
|
const acled = [
|
|
acledEvent('Israel', 'Battles', 10),
|
|
acledEvent('Israel', 'Explosions/Remote violence', 5),
|
|
acledEvent('Mexico', 'Riots', 3),
|
|
];
|
|
const aux = emptyAux();
|
|
aux.ucdpEvents = [{ country: 'Israel', intensity_level: '1' }];
|
|
aux.orefData = { activeAlertCount: 3, historyCount24h: 8 };
|
|
const scores = computeCIIScores(acled, aux);
|
|
const il = scoreFor(scores, 'IL')!;
|
|
const mx = scoreFor(scores, 'MX')!;
|
|
assert.ok(il.combinedScore > mx.combinedScore,
|
|
`IL (${il.combinedScore}) should be > MX (${mx.combinedScore})`);
|
|
});
|
|
|
|
it('scores capped at 100', () => {
|
|
const acled = Array.from({ length: 200 }, () => acledEvent('Syria', 'Battles', 50));
|
|
const aux = emptyAux();
|
|
aux.ucdpEvents = [{ country: 'Syria', intensity_level: '2' }];
|
|
aux.iranEvents = Array.from({ length: 50 }, () => ({ lat: 35.0, lon: 38.0, severity: 'critical' }));
|
|
const scores = computeCIIScores(acled, aux);
|
|
for (const s of scores) {
|
|
assert.ok(s.combinedScore <= 100, `${s.region} score ${s.combinedScore} should be <= 100`);
|
|
}
|
|
});
|
|
|
|
it('UAE geo events attributed to AE not SA despite bbox overlap', () => {
|
|
const aux = emptyAux();
|
|
aux.gpsHexes = [{ lat: 25.2, lon: 55.3, level: 'high' }];
|
|
const scores = computeCIIScores([], aux);
|
|
const ae = scoreFor(scores, 'AE')!;
|
|
const sa = scoreFor(scores, 'SA')!;
|
|
assert.ok(ae.components!.militaryActivity > 0, 'AE should get the Dubai GPS hex');
|
|
assert.equal(sa.components!.militaryActivity, 0, 'SA should not get the Dubai GPS hex');
|
|
});
|
|
|
|
it('empty data returns baseline-derived scores with floors', () => {
|
|
const scores = computeCIIScores([], emptyAux());
|
|
const us = scoreFor(scores, 'US')!;
|
|
assert.ok(us.combinedScore >= 2 && us.combinedScore <= 10, `US baseline score ${us.combinedScore} should be ~2-10`);
|
|
});
|
|
|
|
it('newsTopStories critical threat boosts newsActivity for attributed country', () => {
|
|
const aux = emptyAux();
|
|
aux.newsTopStories = [
|
|
{ countryCode: 'RU', threatLevel: 'critical', primaryTitle: 'Russia launches strikes' },
|
|
];
|
|
const withNews = scoreFor(computeCIIScores([], aux), 'RU')!;
|
|
const withoutNews = scoreFor(computeCIIScores([], emptyAux()), 'RU')!;
|
|
assert.ok(withNews.components!.newsActivity > 0, 'newsActivity should be > 0 with critical story');
|
|
assert.ok(withNews.combinedScore > withoutNews.combinedScore,
|
|
`RU with critical news (${withNews.combinedScore}) should exceed baseline (${withoutNews.combinedScore})`);
|
|
});
|
|
|
|
it('threatSummaryByCountry boosts newsActivity for target country', () => {
|
|
const aux = emptyAux();
|
|
aux.threatSummaryByCountry = { RU: { critical: 3, high: 2, medium: 1, low: 1, info: 0 } };
|
|
const withThreat = scoreFor(computeCIIScores([], aux), 'RU')!;
|
|
const withoutThreat = scoreFor(computeCIIScores([], emptyAux()), 'RU')!;
|
|
assert.ok(withThreat.components!.newsActivity > 0, 'newsActivity should be > 0 with threat summary');
|
|
assert.ok(withThreat.combinedScore > withoutThreat.combinedScore,
|
|
`RU with threat summary (${withThreat.combinedScore}) should exceed baseline (${withoutThreat.combinedScore})`);
|
|
});
|
|
|
|
it('newsTopStories newsActivity capped at 20', () => {
|
|
const aux = emptyAux();
|
|
aux.newsTopStories = Array.from({ length: 20 }, () => ({
|
|
countryCode: 'SY', threatLevel: 'critical', primaryTitle: 'Syria conflict escalates',
|
|
}));
|
|
const scores = computeCIIScores([], aux);
|
|
const sy = scoreFor(scores, 'SY')!;
|
|
assert.ok(sy.components!.newsActivity <= 20, `newsActivity ${sy.components!.newsActivity} should be capped at 20`);
|
|
});
|
|
|
|
it('threatSummaryByCountry newsActivity capped at 20', () => {
|
|
const aux = emptyAux();
|
|
aux.threatSummaryByCountry = { SY: { critical: 100, high: 100, medium: 100, low: 100, info: 100 } };
|
|
const scores = computeCIIScores([], aux);
|
|
const sy = scoreFor(scores, 'SY')!;
|
|
assert.ok(sy.components!.newsActivity <= 20, `newsActivity ${sy.components!.newsActivity} should be capped at 20`);
|
|
});
|
|
|
|
it('newsTopStories moderate threat contributes (not silently dropped)', () => {
|
|
const aux = emptyAux();
|
|
aux.newsTopStories = [
|
|
{ countryCode: 'DE', threatLevel: 'moderate', primaryTitle: 'Germany election results' },
|
|
];
|
|
const withNews = scoreFor(computeCIIScores([], aux), 'DE')!;
|
|
const withoutNews = scoreFor(computeCIIScores([], emptyAux()), 'DE')!;
|
|
assert.ok(withNews.components!.newsActivity > 0, 'moderate threat should produce non-zero newsActivity');
|
|
assert.ok(withNews.combinedScore >= withoutNews.combinedScore,
|
|
`DE with moderate news (${withNews.combinedScore}) should be >= baseline (${withoutNews.combinedScore})`);
|
|
});
|
|
|
|
it('newsTopStories null countryCode falls back to title keyword match', () => {
|
|
const aux = emptyAux();
|
|
aux.newsTopStories = [
|
|
{ countryCode: null, threatLevel: 'high', primaryTitle: 'Iran launches ballistic missile test' },
|
|
];
|
|
const withNews = scoreFor(computeCIIScores([], aux), 'IR')!;
|
|
const withoutNews = scoreFor(computeCIIScores([], emptyAux()), 'IR')!;
|
|
assert.ok(withNews.components!.newsActivity > 0, 'null countryCode with Iran keyword should attribute to IR');
|
|
assert.ok(withNews.components!.newsActivity > withoutNews.components!.newsActivity,
|
|
`IR newsActivity with keyword-matched news (${withNews.components!.newsActivity}) should exceed baseline (${withoutNews.components!.newsActivity})`);
|
|
});
|
|
|
|
it('newsTopStories info threat is not counted', () => {
|
|
const aux = emptyAux();
|
|
aux.newsTopStories = [
|
|
{ countryCode: 'JP', threatLevel: 'info', primaryTitle: 'Japan trade summit scheduled' },
|
|
];
|
|
const withInfo = scoreFor(computeCIIScores([], aux), 'JP')!;
|
|
const withoutNews = scoreFor(computeCIIScores([], emptyAux()), 'JP')!;
|
|
assert.equal(withInfo.components!.newsActivity, withoutNews.components!.newsActivity,
|
|
'info threat level should not affect newsActivity');
|
|
});
|
|
|
|
it('threatSummaryByCountry unknown country code is safely ignored', () => {
|
|
const aux = emptyAux();
|
|
aux.threatSummaryByCountry = { XX: { critical: 10, high: 5, medium: 2, low: 1, info: 0 } };
|
|
assert.doesNotThrow(() => computeCIIScores([], aux), 'unknown country code should not throw');
|
|
});
|
|
|
|
it('null threatSummaryByCountry produces zero newsActivity', () => {
|
|
const scores = computeCIIScores([], emptyAux());
|
|
for (const s of scores) {
|
|
assert.equal(s.components!.newsActivity, 0, `${s.region} should have zero newsActivity with null threatSummary`);
|
|
}
|
|
});
|
|
});
|