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
This commit is contained in:
@@ -208,6 +208,7 @@ const SEED_META = {
|
|||||||
euGasStorage: { key: 'seed-meta:economic:eu-gas-storage', maxStaleMin: 2880 }, // daily seed (T+1); 2880min = 48h = 2x interval
|
euGasStorage: { key: 'seed-meta:economic:eu-gas-storage', maxStaleMin: 2880 }, // daily seed (T+1); 2880min = 48h = 2x interval
|
||||||
euYieldCurve: { key: 'seed-meta:economic:yield-curve-eu', maxStaleMin: 2880 }, // daily seed (weekdays); 2880min = 48h = 2x interval
|
euYieldCurve: { key: 'seed-meta:economic:yield-curve-eu', maxStaleMin: 2880 }, // daily seed (weekdays); 2880min = 48h = 2x interval
|
||||||
euFsi: { key: 'seed-meta:economic:fsi-eu', maxStaleMin: 20160 }, // weekly seed (Saturday); 20160min = 14d = 2x interval
|
euFsi: { key: 'seed-meta:economic:fsi-eu', maxStaleMin: 20160 }, // weekly seed (Saturday); 20160min = 14d = 2x interval
|
||||||
|
newsThreatSummary: { key: 'seed-meta:news:threat-summary', maxStaleMin: 60 }, // relay classify every ~20min; 60min = 3x interval
|
||||||
};
|
};
|
||||||
|
|
||||||
// Standalone keys that are populated on-demand by RPC handlers (not seeds).
|
// Standalone keys that are populated on-demand by RPC handlers (not seeds).
|
||||||
|
|||||||
@@ -2802,6 +2802,83 @@ Output: [{"i":0,"l":"high","c":"conflict"}, ...]
|
|||||||
|
|
||||||
Focus: geopolitical events, conflicts, disasters, diplomacy. Classify by real-world severity and impact.`;
|
Focus: geopolitical events, conflicts, disasters, diplomacy. Classify by real-world severity and impact.`;
|
||||||
|
|
||||||
|
const NEWS_THREAT_SUMMARY_KEY = 'news:threat:summary:v1';
|
||||||
|
const NEWS_THREAT_SUMMARY_TTL = 1200; // 20 min — aligns with relay cadence
|
||||||
|
|
||||||
|
// Country name → ISO2 for threat summary geo-attribution (inline to avoid ESM import)
|
||||||
|
const THREAT_COUNTRY_NAME_TO_ISO2 = {
|
||||||
|
'afghanistan':'AF','albania':'AL','algeria':'DZ','angola':'AO','argentina':'AR',
|
||||||
|
'armenia':'AM','australia':'AU','austria':'AT','azerbaijan':'AZ','bahrain':'BH',
|
||||||
|
'bangladesh':'BD','belarus':'BY','belgium':'BE','bolivia':'BO','brazil':'BR',
|
||||||
|
'burkina faso':'BF','burma':'MM','cambodia':'KH','cameroon':'CM','canada':'CA',
|
||||||
|
'chad':'TD','chile':'CL','china':'CN','colombia':'CO','congo':'CG',
|
||||||
|
'costa rica':'CR','croatia':'HR','cuba':'CU','cyprus':'CY',
|
||||||
|
'czech republic':'CZ','czechia':'CZ',
|
||||||
|
'democratic republic of the congo':'CD','dr congo':'CD','drc':'CD',
|
||||||
|
'denmark':'DK','djibouti':'DJ','dominican republic':'DO',
|
||||||
|
'ecuador':'EC','egypt':'EG','el salvador':'SV','eritrea':'ER',
|
||||||
|
'estonia':'EE','ethiopia':'ET','finland':'FI','france':'FR',
|
||||||
|
'georgia':'GE','germany':'DE','ghana':'GH','greece':'GR',
|
||||||
|
'guatemala':'GT','guinea':'GN','haiti':'HT','honduras':'HN','hungary':'HU',
|
||||||
|
'iceland':'IS','india':'IN','indonesia':'ID','iran':'IR','iraq':'IQ',
|
||||||
|
'ireland':'IE','israel':'IL','italy':'IT','ivory coast':'CI',
|
||||||
|
'jamaica':'JM','japan':'JP','jordan':'JO','kazakhstan':'KZ',
|
||||||
|
'kenya':'KE','kosovo':'XK','kuwait':'KW','kyrgyzstan':'KG',
|
||||||
|
'laos':'LA','latvia':'LV','lebanon':'LB','libya':'LY','lithuania':'LT',
|
||||||
|
'mali':'ML','mauritania':'MR','mexico':'MX','moldova':'MD',
|
||||||
|
'mongolia':'MN','montenegro':'ME','morocco':'MA','mozambique':'MZ',
|
||||||
|
'myanmar':'MM','namibia':'NA','nepal':'NP','netherlands':'NL',
|
||||||
|
'new zealand':'NZ','nicaragua':'NI','niger':'NE','nigeria':'NG',
|
||||||
|
'north korea':'KP','north macedonia':'MK','norway':'NO',
|
||||||
|
'oman':'OM','pakistan':'PK','palestine':'PS','panama':'PA',
|
||||||
|
'paraguay':'PY','peru':'PE','philippines':'PH','poland':'PL',
|
||||||
|
'portugal':'PT','qatar':'QA','romania':'RO','russia':'RU','rwanda':'RW',
|
||||||
|
'saudi arabia':'SA','senegal':'SN','serbia':'RS','sierra leone':'SL',
|
||||||
|
'singapore':'SG','slovakia':'SK','slovenia':'SI','somalia':'SO',
|
||||||
|
'south africa':'ZA','south korea':'KR','south sudan':'SS','spain':'ES',
|
||||||
|
'sri lanka':'LK','sudan':'SD','sweden':'SE','switzerland':'CH',
|
||||||
|
'syria':'SY','taiwan':'TW','tajikistan':'TJ','tanzania':'TZ',
|
||||||
|
'thailand':'TH','togo':'TG','tunisia':'TN','turkey':'TR',
|
||||||
|
'turkmenistan':'TM','uganda':'UG','ukraine':'UA',
|
||||||
|
'united arab emirates':'AE','uae':'AE',
|
||||||
|
'united kingdom':'GB','uk':'GB','united states':'US','usa':'US',
|
||||||
|
'uruguay':'UY','uzbekistan':'UZ','venezuela':'VE','vietnam':'VN',
|
||||||
|
'yemen':'YE','zambia':'ZM','zimbabwe':'ZW',
|
||||||
|
// Key aliases
|
||||||
|
'tehran':'IR','moscow':'RU','beijing':'CN','kyiv':'UA','pyongyang':'KP',
|
||||||
|
'tel aviv':'IL','gaza':'PS','damascus':'SY','sanaa':'YE','houthi':'YE',
|
||||||
|
'kremlin':'RU','pentagon':'US','nato':'','irgc':'IR','hezbollah':'LB',
|
||||||
|
'hamas':'PS','taliban':'AF','riyadh':'SA','ankara':'TR',
|
||||||
|
};
|
||||||
|
// Sort by name length desc so longer multi-word names match first (used for tie-breaking same position)
|
||||||
|
const THREAT_COUNTRY_NAME_ENTRIES = Object.entries(THREAT_COUNTRY_NAME_TO_ISO2)
|
||||||
|
.filter(([name, iso2]) => name.length >= 3 && iso2.length === 2)
|
||||||
|
.sort((a, b) => b[0].length - a[0].length)
|
||||||
|
.map(([name, iso2]) => ({ name, iso2, regex: new RegExp(`\\b${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'i') }));
|
||||||
|
|
||||||
|
// Returns the single primary affected country — the country appearing immediately after a
|
||||||
|
// locative preposition or attack verb, which marks the grammatical object/affected entity.
|
||||||
|
// Returns [] when no such pattern fires (no attribution is better than wrong attribution).
|
||||||
|
// "UK and US launch strikes on Yemen" → ['YE']
|
||||||
|
// "US strikes on Yemen condemned by Iran" → ['YE'] (Iran is a reactor, not affected)
|
||||||
|
// "Yemen says UK and US strikes hit Hodeidah" → [] (Hodeidah is a city, skip)
|
||||||
|
// "Russia invades Ukraine" → ['UA']
|
||||||
|
const AFFECTED_PREFIX_RE = /\b(in|on|against|at|into|across|inside|targeting|toward[s]?|invad(?:es?|ed|ing)|attack(?:s|ed|ing)?|bomb(?:s|ed|ing)?|hitt?(?:ing|s)?|strik(?:es?|ing))\s+(?:the\s+)?/gi;
|
||||||
|
function matchCountryNamesInText(text) {
|
||||||
|
const lower = text.toLowerCase();
|
||||||
|
let match;
|
||||||
|
AFFECTED_PREFIX_RE.lastIndex = 0;
|
||||||
|
while ((match = AFFECTED_PREFIX_RE.exec(lower)) !== null) {
|
||||||
|
const afterPfx = lower.slice(match.index + match[0].length);
|
||||||
|
for (const { name, iso2 } of THREAT_COUNTRY_NAME_ENTRIES) {
|
||||||
|
if (afterPfx.startsWith(name) && (afterPfx.length === name.length || /\W/.test(afterPfx[name.length]))) {
|
||||||
|
return [iso2];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
function classifyCacheKey(title) {
|
function classifyCacheKey(title) {
|
||||||
const hash = crypto.createHash('sha256').update(title.toLowerCase()).digest('hex').slice(0, 16);
|
const hash = crypto.createHash('sha256').update(title.toLowerCase()).digest('hex').slice(0, 16);
|
||||||
return `classify:sebuf:v1:${hash}`;
|
return `classify:sebuf:v1:${hash}`;
|
||||||
@@ -2907,7 +2984,7 @@ async function classifyFetchLlm(titles) {
|
|||||||
|
|
||||||
let classifyInFlight = false;
|
let classifyInFlight = false;
|
||||||
|
|
||||||
async function seedClassifyForVariant(variant) {
|
async function seedClassifyForVariant(variant, seenTitles) {
|
||||||
const digestUrl = `https://api.worldmonitor.app/api/news/v1/list-feed-digest?variant=${variant}&lang=en`;
|
const digestUrl = `https://api.worldmonitor.app/api/news/v1/list-feed-digest?variant=${variant}&lang=en`;
|
||||||
let digest;
|
let digest;
|
||||||
try {
|
try {
|
||||||
@@ -2945,11 +3022,30 @@ async function seedClassifyForVariant(variant) {
|
|||||||
|
|
||||||
const cached = await upstashMGet(cacheKeys);
|
const cached = await upstashMGet(cacheKeys);
|
||||||
const misses = [];
|
const misses = [];
|
||||||
|
// byCountry accumulates threat counts while title+level are in scope
|
||||||
|
const byCountry = {};
|
||||||
|
const emptyLevel = () => ({ critical: 0, high: 0, medium: 0, low: 0, info: 0 });
|
||||||
|
|
||||||
for (let i = 0; i < titleArr.length; i++) {
|
for (let i = 0; i < titleArr.length; i++) {
|
||||||
if (!cached[i]) misses.push(titleArr[i]);
|
const hit = cached[i];
|
||||||
|
if (!hit) {
|
||||||
|
misses.push(titleArr[i]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Attribute cached hits while we still have the title
|
||||||
|
let parsed = hit;
|
||||||
|
if (typeof hit === 'string') { try { parsed = JSON.parse(hit); } catch { continue; } }
|
||||||
|
const level = parsed?.level;
|
||||||
|
if (!CLASSIFY_VALID_LEVELS.includes(level)) continue;
|
||||||
|
if (seenTitles.has(titleArr[i])) continue;
|
||||||
|
seenTitles.add(titleArr[i]);
|
||||||
|
for (const code of matchCountryNamesInText(titleArr[i])) {
|
||||||
|
if (!byCountry[code]) byCountry[code] = emptyLevel();
|
||||||
|
byCountry[code][level]++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (misses.length === 0) return { total: titleArr.length, classified: 0, skipped: 0 };
|
if (misses.length === 0) return { total: titleArr.length, classified: 0, skipped: 0, byCountry };
|
||||||
|
|
||||||
let classified = 0;
|
let classified = 0;
|
||||||
let skipped = 0;
|
let skipped = 0;
|
||||||
@@ -2977,6 +3073,14 @@ async function seedClassifyForVariant(variant) {
|
|||||||
classifiedSet.add(idx);
|
classifiedSet.add(idx);
|
||||||
await upstashSet(classifyCacheKey(chunk[idx]), { level, category, timestamp: Date.now() }, CLASSIFY_CACHE_TTL);
|
await upstashSet(classifyCacheKey(chunk[idx]), { level, category, timestamp: Date.now() }, CLASSIFY_CACHE_TTL);
|
||||||
classified++;
|
classified++;
|
||||||
|
// Attribute newly classified title while it's still in scope
|
||||||
|
if (!seenTitles.has(chunk[idx])) {
|
||||||
|
seenTitles.add(chunk[idx]);
|
||||||
|
for (const code of matchCountryNamesInText(chunk[idx])) {
|
||||||
|
if (!byCountry[code]) byCountry[code] = emptyLevel();
|
||||||
|
byCountry[code][level]++;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let i = 0; i < chunk.length; i++) {
|
for (let i = 0; i < chunk.length; i++) {
|
||||||
@@ -2987,7 +3091,7 @@ async function seedClassifyForVariant(variant) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { total: titleArr.length, classified, skipped };
|
return { total: titleArr.length, classified, skipped, byCountry };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function seedClassify() {
|
async function seedClassify() {
|
||||||
@@ -3003,18 +3107,32 @@ async function seedClassify() {
|
|||||||
|
|
||||||
let totalClassified = 0;
|
let totalClassified = 0;
|
||||||
let totalSkipped = 0;
|
let totalSkipped = 0;
|
||||||
|
const mergedByCountry = {};
|
||||||
|
const seenTitles = new Set();
|
||||||
for (let v = 0; v < CLASSIFY_VARIANTS.length; v++) {
|
for (let v = 0; v < CLASSIFY_VARIANTS.length; v++) {
|
||||||
if (v > 0) await new Promise((r) => setTimeout(r, CLASSIFY_VARIANT_STAGGER_MS));
|
if (v > 0) await new Promise((r) => setTimeout(r, CLASSIFY_VARIANT_STAGGER_MS));
|
||||||
try {
|
try {
|
||||||
const stats = await seedClassifyForVariant(CLASSIFY_VARIANTS[v]);
|
const stats = await seedClassifyForVariant(CLASSIFY_VARIANTS[v], seenTitles);
|
||||||
totalClassified += stats.classified;
|
totalClassified += stats.classified;
|
||||||
totalSkipped += stats.skipped;
|
totalSkipped += stats.skipped;
|
||||||
console.log(`[Classify] ${CLASSIFY_VARIANTS[v]}: ${stats.total} titles, ${stats.classified} classified, ${stats.skipped} skipped`);
|
console.log(`[Classify] ${CLASSIFY_VARIANTS[v]}: ${stats.total} titles, ${stats.classified} classified, ${stats.skipped} skipped`);
|
||||||
|
for (const [code, counts] of Object.entries(stats.byCountry || {})) {
|
||||||
|
if (!mergedByCountry[code]) mergedByCountry[code] = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
|
||||||
|
for (const lvl of ['critical', 'high', 'medium', 'low', 'info']) {
|
||||||
|
mergedByCountry[code][lvl] += counts[lvl] || 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn(`[Classify] ${CLASSIFY_VARIANTS[v]} error:`, e?.message || e);
|
console.warn(`[Classify] ${CLASSIFY_VARIANTS[v]} error:`, e?.message || e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await upstashSet('seed-meta:news:threat-summary', { fetchedAt: Date.now(), recordCount: Object.keys(mergedByCountry).length }, 604800);
|
||||||
|
if (Object.keys(mergedByCountry).length > 0) {
|
||||||
|
await upstashSet(NEWS_THREAT_SUMMARY_KEY, { byCountry: mergedByCountry, generatedAt: Date.now() }, NEWS_THREAT_SUMMARY_TTL);
|
||||||
|
console.log(`[Classify] Threat summary written for ${Object.keys(mergedByCountry).length} countries`);
|
||||||
|
}
|
||||||
|
|
||||||
await upstashSet('seed-meta:classify', { fetchedAt: Date.now(), recordCount: totalClassified }, 604800);
|
await upstashSet('seed-meta:classify', { fetchedAt: Date.now(), recordCount: totalClassified }, 604800);
|
||||||
console.log(`[Classify] Done in ${((Date.now() - t0) / 1000).toFixed(1)}s — ${totalClassified} classified, ${totalSkipped} skipped`);
|
console.log(`[Classify] Done in ${((Date.now() - t0) / 1000).toFixed(1)}s — ${totalClassified} classified, ${totalSkipped} skipped`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -176,6 +176,7 @@ interface CountrySignals {
|
|||||||
advisoryLevel: 'do-not-travel' | 'reconsider' | 'caution' | null;
|
advisoryLevel: 'do-not-travel' | 'reconsider' | 'caution' | null;
|
||||||
totalDisplaced: number;
|
totalDisplaced: number;
|
||||||
newsScore: number;
|
newsScore: number;
|
||||||
|
threatSummaryScore: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function emptySignals(): CountrySignals {
|
function emptySignals(): CountrySignals {
|
||||||
@@ -191,6 +192,7 @@ function emptySignals(): CountrySignals {
|
|||||||
advisoryLevel: null,
|
advisoryLevel: null,
|
||||||
totalDisplaced: 0,
|
totalDisplaced: 0,
|
||||||
newsScore: 0,
|
newsScore: 0,
|
||||||
|
threatSummaryScore: 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,11 +238,13 @@ interface AuxiliarySources {
|
|||||||
// Per-country displaced population by ISO3 code (UNHCR — persists after ceasefires)
|
// Per-country displaced population by ISO3 code (UNHCR — persists after ceasefires)
|
||||||
displacedByIso3: Record<string, number>;
|
displacedByIso3: Record<string, number>;
|
||||||
newsTopStories: Array<{ countryCode: string | null; threatLevel: string; primaryTitle: string }>;
|
newsTopStories: Array<{ countryCode: string | null; threatLevel: string; primaryTitle: string }>;
|
||||||
|
// Per-country classified headline counts from relay seedClassify() — written to news:threat:summary:v1
|
||||||
|
threatSummaryByCountry: Record<string, { critical: number; high: number; medium: number; low: number; info: number }> | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchAuxiliarySources(): Promise<AuxiliarySources> {
|
async function fetchAuxiliarySources(): Promise<AuxiliarySources> {
|
||||||
const currentYear = new Date().getFullYear();
|
const currentYear = new Date().getFullYear();
|
||||||
const [ucdpRaw, outagesRaw, climateRaw, cyberRaw, firesRaw, gpsRaw, iranRaw, orefRaw, advisoriesRaw, displacementRaw, insightsRaw] = await Promise.all([
|
const [ucdpRaw, outagesRaw, climateRaw, cyberRaw, firesRaw, gpsRaw, iranRaw, orefRaw, advisoriesRaw, displacementRaw, insightsRaw, threatSummaryRaw] = await Promise.all([
|
||||||
getCachedJson('conflict:ucdp-events:v1', true).catch(() => null),
|
getCachedJson('conflict:ucdp-events:v1', true).catch(() => null),
|
||||||
getCachedJson('infra:outages:v1', true).catch(() => null),
|
getCachedJson('infra:outages:v1', true).catch(() => null),
|
||||||
getCachedJson('climate:anomalies:v1', true).catch(() => null),
|
getCachedJson('climate:anomalies:v1', true).catch(() => null),
|
||||||
@@ -255,6 +259,7 @@ async function fetchAuxiliarySources(): Promise<AuxiliarySources> {
|
|||||||
.catch(() => null)
|
.catch(() => null)
|
||||||
.then(d => d ?? getCachedJson(`displacement:summary:v1:${currentYear - 1}`, true).catch(() => null)),
|
.then(d => d ?? getCachedJson(`displacement:summary:v1:${currentYear - 1}`, true).catch(() => null)),
|
||||||
getCachedJson('news:insights:v1', true).catch(() => null),
|
getCachedJson('news:insights:v1', true).catch(() => null),
|
||||||
|
getCachedJson('news:threat:summary:v1', true).catch(() => null),
|
||||||
]);
|
]);
|
||||||
const arr = (v: any, field?: string, maxLen = 10000) => {
|
const arr = (v: any, field?: string, maxLen = 10000) => {
|
||||||
let a: any[];
|
let a: any[];
|
||||||
@@ -294,6 +299,10 @@ async function fetchAuxiliarySources(): Promise<AuxiliarySources> {
|
|||||||
threatLevel: typeof s.threatLevel === 'string' ? s.threatLevel.toLowerCase() : 'low',
|
threatLevel: typeof s.threatLevel === 'string' ? s.threatLevel.toLowerCase() : 'low',
|
||||||
primaryTitle: typeof s.primaryTitle === 'string' ? s.primaryTitle : '',
|
primaryTitle: typeof s.primaryTitle === 'string' ? s.primaryTitle : '',
|
||||||
}));
|
}));
|
||||||
|
const threatSummaryByCountry: AuxiliarySources['threatSummaryByCountry'] =
|
||||||
|
threatSummaryRaw && typeof threatSummaryRaw === 'object' && (threatSummaryRaw as any).byCountry
|
||||||
|
? (threatSummaryRaw as any).byCountry
|
||||||
|
: null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ucdpEvents: arr(ucdpRaw, 'events'),
|
ucdpEvents: arr(ucdpRaw, 'events'),
|
||||||
@@ -309,6 +318,7 @@ async function fetchAuxiliarySources(): Promise<AuxiliarySources> {
|
|||||||
: null,
|
: null,
|
||||||
displacedByIso3,
|
displacedByIso3,
|
||||||
newsTopStories,
|
newsTopStories,
|
||||||
|
threatSummaryByCountry,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -444,6 +454,20 @@ export function computeCIIScores(
|
|||||||
if (signals) signals.newsScore += weight;
|
if (signals) signals.newsScore += weight;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- News threat summary (from relay seedClassify — all classified headlines) ---
|
||||||
|
if (aux.threatSummaryByCountry) {
|
||||||
|
const SUMMARY_WEIGHT: Record<string, number> = { critical: 4, high: 2, medium: 1, low: 0.5, info: 0 };
|
||||||
|
for (const [code, counts] of Object.entries(aux.threatSummaryByCountry)) {
|
||||||
|
const signals = data[code];
|
||||||
|
if (!signals) continue;
|
||||||
|
let score = 0;
|
||||||
|
for (const [lvl, w] of Object.entries(SUMMARY_WEIGHT)) {
|
||||||
|
score += (counts[lvl as keyof typeof counts] || 0) * w;
|
||||||
|
}
|
||||||
|
signals.threatSummaryScore = Math.min(20, score);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Scoring ---
|
// --- Scoring ---
|
||||||
const scores: CiiScore[] = [];
|
const scores: CiiScore[] = [];
|
||||||
for (const code of Object.keys(TIER1_COUNTRIES)) {
|
for (const code of Object.keys(TIER1_COUNTRIES)) {
|
||||||
@@ -475,7 +499,7 @@ export function computeCIIScores(
|
|||||||
const gpsJammingScore = Math.min(35, d.gpsHighCount * 5 + d.gpsMediumCount * 2);
|
const gpsJammingScore = Math.min(35, d.gpsHighCount * 5 + d.gpsMediumCount * 2);
|
||||||
const security = Math.min(100, Math.round(gpsJammingScore));
|
const security = Math.min(100, Math.round(gpsJammingScore));
|
||||||
|
|
||||||
const information = Math.min(20, d.newsScore);
|
const information = Math.min(20, d.newsScore + d.threatSummaryScore);
|
||||||
|
|
||||||
const eventScore = unrest * 0.25 + conflict * 0.30 + security * 0.20 + information * 0.25;
|
const eventScore = unrest * 0.25 + conflict * 0.30 + security * 0.20 + information * 0.25;
|
||||||
|
|
||||||
@@ -600,7 +624,7 @@ export async function getRiskScores(
|
|||||||
|
|
||||||
const stale = (await getCachedJson(RISK_STALE_CACHE_KEY)) as GetRiskScoresResponse | null;
|
const stale = (await getCachedJson(RISK_STALE_CACHE_KEY)) as GetRiskScoresResponse | null;
|
||||||
if (stale) return stale;
|
if (stale) return stale;
|
||||||
const emptyAux: AuxiliarySources = { ucdpEvents: [], outages: [], climate: [], cyber: [], fires: [], gpsHexes: [], iranEvents: [], orefData: null, advisories: null, displacedByIso3: {}, newsTopStories: [] };
|
const emptyAux: AuxiliarySources = { ucdpEvents: [], outages: [], climate: [], cyber: [], fires: [], gpsHexes: [], iranEvents: [], orefData: null, advisories: null, displacedByIso3: {}, newsTopStories: [], threatSummaryByCountry: null };
|
||||||
const ciiScores = computeCIIScores([], emptyAux);
|
const ciiScores = computeCIIScores([], emptyAux);
|
||||||
return { ciiScores, strategicRisks: computeStrategicRisks(ciiScores) };
|
return { ciiScores, strategicRisks: computeStrategicRisks(ciiScores) };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ function emptyAux() {
|
|||||||
advisories: null as { byCountry: Record<string, 'do-not-travel' | 'reconsider' | 'caution'> } | null,
|
advisories: null as { byCountry: Record<string, 'do-not-travel' | 'reconsider' | 'caution'> } | null,
|
||||||
displacedByIso3: {} as Record<string, number>,
|
displacedByIso3: {} as Record<string, number>,
|
||||||
newsTopStories: [] as Array<{ countryCode: string | null; threatLevel: string; primaryTitle: string }>,
|
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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,6 +192,16 @@ describe('CII scoring', () => {
|
|||||||
`RU with critical news (${withNews.combinedScore}) should exceed baseline (${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', () => {
|
it('newsTopStories newsActivity capped at 20', () => {
|
||||||
const aux = emptyAux();
|
const aux = emptyAux();
|
||||||
aux.newsTopStories = Array.from({ length: 20 }, () => ({
|
aux.newsTopStories = Array.from({ length: 20 }, () => ({
|
||||||
@@ -201,6 +212,14 @@ describe('CII scoring', () => {
|
|||||||
assert.ok(sy.components!.newsActivity <= 20, `newsActivity ${sy.components!.newsActivity} should be capped at 20`);
|
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)', () => {
|
it('newsTopStories moderate threat contributes (not silently dropped)', () => {
|
||||||
const aux = emptyAux();
|
const aux = emptyAux();
|
||||||
aux.newsTopStories = [
|
aux.newsTopStories = [
|
||||||
@@ -235,4 +254,17 @@ describe('CII scoring', () => {
|
|||||||
assert.equal(withInfo.components!.newsActivity, withoutNews.components!.newsActivity,
|
assert.equal(withInfo.components!.newsActivity, withoutNews.components!.newsActivity,
|
||||||
'info threat level should not affect 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`);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user