feat(intelligence): GetCountryRisk RPC + MCP tool for per-country risk scores (#2502)

* feat(intelligence): GetCountryRisk RPC for per-country risk intelligence

Adds a new fast Redis-read RPC that consolidates CII score, travel advisory
level, and OFAC sanctions exposure into a single per-country response.

Replaces the need to call GetRiskScores (all-countries) and filter client-side.
Wired to MCP as get_country_risk tool (no LLM, ~200ms, good for agent screening).

- proto/intelligence/v1/get_country_risk.proto (new)
- server/intelligence/v1/get-country-risk.ts (reads 3 pre-seeded Redis keys)
- gateway.ts: slow cache tier
- api/mcp.ts: RpcToolDef with 8s timeout
- tests/mcp.test.mjs: update tool count 27→28

* fix(intelligence): upstream-unavailable signal, fetchedAt from CII, drop redundant catch

P1: return upstreamUnavailable:true when all Redis reads are null — prevents
CDN from caching false-negative sanctions/risk responses during Redis outages.

P2: fetchedAt now uses cii.computedAt (actual data age) instead of request time.

P2: removed redundant .catch(() => null) — getCachedJson already swallows errors.

* fix(intelligence): accurate OFAC counts and country names for GetCountryRisk

P1: sanctions:pressure:v1.countries is a top-12 slice — switch to a new
sanctions:country-counts:v1 key (ISO2→count across ALL 40K+ OFAC entries).
Written by seed-sanctions-pressure.mjs in afterPublish alongside entity index.

P1: trigger upstreamUnavailable:true when sanctions key alone is missing,
preventing false-negative sanctionsActive:false from being cached by CDN.

P2: advisory seeder now writes byCountryName (ISO2→display name) derived
from country-names.json reverse map. Handler uses it as fallback so countries
outside TIER1_COUNTRIES (TH, CO, BD, IT...) get proper names.
This commit is contained in:
Elie Habib
2026-03-29 17:07:03 +04:00
committed by GitHub
parent ebd778fe19
commit 8aee4d340e
13 changed files with 367 additions and 5 deletions

View File

@@ -60,6 +60,13 @@ function parseLevel(title, parser) {
const COUNTRY_NAMES = loadSharedConfig('country-names.json');
const SORTED_COUNTRY_ENTRIES = Object.entries(COUNTRY_NAMES).sort((a, b) => b[0].length - a[0].length);
// Reverse map: ISO2 → display name (title-cased from the config keys).
const BY_COUNTRY_NAME = Object.fromEntries(
Object.entries(COUNTRY_NAMES).map(([name, code]) => [
code,
name.replace(/\b\w/g, (c) => c.toUpperCase()),
]),
);
function extractCountry(title, feed) {
if (feed.targetCountry) return feed.targetCountry;
@@ -195,7 +202,7 @@ async function fetchAll() {
deduped.sort((a, b) => new Date(b.pubDate).getTime() - new Date(a.pubDate).getTime());
const byCountry = buildByCountryMap(deduped);
const report = { byCountry, advisories: deduped, fetchedAt: new Date().toISOString() };
const report = { byCountry, byCountryName: BY_COUNTRY_NAME, advisories: deduped, fetchedAt: new Date().toISOString() };
console.log(` ${deduped.length} advisories, ${Object.keys(byCountry).length} countries with levels`);