diff --git a/.github/agents/data-pipeline-specialist.md b/.github/agents/data-pipeline-specialist.md index 9c9c23a5b4..a032595f8d 100644 --- a/.github/agents/data-pipeline-specialist.md +++ b/.github/agents/data-pipeline-specialist.md @@ -336,7 +336,8 @@ Repo-level agents do **not** declare `mcp-servers:` — MCP is configured once i |--------|---------| | `github` (Insiders HTTP) | Full toolset incl. `assign_copilot_to_issue`, `create_pull_request_with_copilot`, `get_copilot_job_status`, issues, PRs, projects, actions, security alerts, discussions | | `riksdag-regering` (HTTP) | 32+ tools for Swedish Parliament/Government open data | -| `scb` / `world-bank` (local) | Statistics Sweden PxWeb v2 and World Bank indicators | +| `scb` / `world-bank` (local) | Statistics Sweden PxWeb v2 and World Bank indicators (WB narrowed to governance/environment/social residue in v2.0 contract) | +| `imf` (TypeScript client: `scripts/imf-client.ts` + `scripts/imf-fetch.ts`, no MCP) | IMF Datamapper (WEO) + SDMX 3.0 passthrough (IFS/BOP/FM/GFS/DOTS). Primary macro/fiscal/monetary source with projections; invoke via `bash` (`tsx scripts/imf-fetch.ts compare|weo|sdmx …`), prefer the `compare` subcommand for multi-country batches, respect 10 req/5 s rate limit (client retries 3× on 429), cache via `--persist`/`persistIMFData()` under `analysis/data/imf/{indicator}/{country}.json`. See `analysis/imf/README.md` + Economic Data Contract v2.0. | | `filesystem` / `memory` / `sequential-thinking` / `playwright` | Local helpers (scoped FS, persistent memory, structured reasoning, headless browser) | MCP config changes are **Normal Changes** needing CEO approval per the [Secure Development Policy](https://github.com/Hack23/ISMS-PUBLIC/blob/main/Secure_Development_Policy.md) curator-agent governance section. diff --git a/.github/agents/intelligence-operative.md b/.github/agents/intelligence-operative.md index 13a8d8da9a..776a7b19c7 100644 --- a/.github/agents/intelligence-operative.md +++ b/.github/agents/intelligence-operative.md @@ -48,7 +48,8 @@ Repo agents do **not** configure MCP servers — MCP is defined once in [`.githu |--------|---------| | **riksdag-regering** (HTTP) | 32+ tools for Swedish Parliament/Government open data | | **scb** (local) | Statistics Sweden PxWeb v2 API | -| **world-bank** (local) | World Bank indicators (GDP, debt, social) | +| **world-bank** (local) | World Bank indicators — governance (WGI), environment, long-horizon social/education residue | +| **imf** (TypeScript client via `bash` + `tsx scripts/imf-fetch.ts`, no MCP) | IMF Datamapper (WEO) + SDMX 3.0 passthrough (IFS/BOP/FM/GFS/DOTS) with T+5 projections; primary source for fresh macro/fiscal/monetary figures (see `analysis/imf/` + Economic Data Contract v2.0) | | **github** (Insiders HTTP) | Full GitHub toolset incl. `assign_copilot_to_issue`, `create_pull_request_with_copilot`, `get_copilot_job_status` | | **filesystem / memory / sequential-thinking / playwright** | Local helpers | diff --git a/.github/agents/news-journalist.md b/.github/agents/news-journalist.md index ab93a9604e..ee629cc95c 100644 --- a/.github/agents/news-journalist.md +++ b/.github/agents/news-journalist.md @@ -18,7 +18,7 @@ tools: ["*"] ## 🔴 AI FIRST Quality Principle -> **Never accept first-pass quality. Minimum 2 complete iterations for every article. After Pass 1, read ALL output back and improve: strengthen lede with named actors, deepen "Why It Matters" (no boilerplate), add evidence (dok_id, vote counts, named MPs), broaden perspectives (6+ stakeholder groups), add economic context (SCB, World Bank), verify forward indicators (dates, triggers, decision-makers). Spend ALL allocated time — shallow reporting is rejected.** +> **Never accept first-pass quality. Minimum 2 complete iterations for every article. After Pass 1, read ALL output back and improve: strengthen lede with named actors, deepen "Why It Matters" (no boilerplate), add evidence (dok_id, vote counts, named MPs), broaden perspectives (6+ stakeholder groups), add economic context (SCB, IMF — WEO/FM projections where relevant — World Bank for governance/environment residue), verify forward indicators (dates, triggers, decision-makers). Spend ALL allocated time — shallow reporting is rejected.** --- diff --git a/.github/aw/ECONOMIC_DATA_CONTRACT.md b/.github/aw/ECONOMIC_DATA_CONTRACT.md index ec12d459d0..139976f738 100644 --- a/.github/aw/ECONOMIC_DATA_CONTRACT.md +++ b/.github/aw/ECONOMIC_DATA_CONTRACT.md @@ -1,10 +1,19 @@ -# Economic Data Contract — Agentic Workflows (v1.0) +# Economic Data Contract — Agentic Workflows (v2.0) -> **Single source of truth** for live World Bank / SCB data, Chart.js +> **Single source of truth** for live IMF / World Bank / SCB data, Chart.js > visualisations, and AI commentary in every news article. > Consumed by `scripts/validate-economic-context.ts` and referenced > (by link) from every `news-*.md` agentic workflow. +> **Schema v2.0 (2026-04-20)** — additive. Adds IMF (accessed via the +> repo's pure-TypeScript `scripts/imf-client.ts` + `scripts/imf-fetch.ts` +> CLI, no Python MCP) as a first-class primary source for macro, fiscal, +> monetary, and external-sector indicators. World Bank remains +> authoritative for governance (WGI), environment, and long-horizon +> social/education residue. SCB remains the Swedish primary source. +> v1 artefacts remain valid; the validator accepts both shapes during +> the 2026-04-20 → 2026-05-31 grace window. + --- ## Why this contract exists @@ -51,22 +60,28 @@ where `{analysisSubfolder}` maps from the kebab article-type slug via **Schema**: `analysis/schemas/economic-data.schema.json`. -**Shape**: +**Shape** (v2.0 — additive over v1): ```jsonc { - "version": "1.0", + "version": "2.0", "articleType": "committee-reports", - "date": "2026-04-17", + "date": "2026-04-20", "policyDomains": ["fiscal policy", "labor market"], "dataPoints": [ - { "countryCode": "SWE", "countryName": "Sweden", "indicatorId": "NY.GDP.MKTP.KD.ZG", "date": "2024", "value": 0.82 }, - { "countryCode": "DNK", "countryName": "Denmark", "indicatorId": "NY.GDP.MKTP.KD.ZG", "date": "2024", "value": 1.75 } + // IMF (WEO projections permitted for look-ahead article types) + { "countryCode": "SWE", "countryName": "Sweden", "indicatorId": "NGDP_RPCH", "date": "2025", "value": 1.9, "provider": "imf", "projection": false }, + { "countryCode": "SWE", "countryName": "Sweden", "indicatorId": "GGXWDG_NGDP", "date": "2027", "value": 32.4, "provider": "imf", "projection": true, "projectionVintage": "WEO-2026-04" }, + // World Bank (governance / environment / social residue) + { "countryCode": "SWE", "countryName": "Sweden", "indicatorId": "CC.EST", "date": "2023", "value": 2.1, "provider": "worldBank", "projection": false }, + // SCB (Swedish primary source) + { "countryCode": "SWE", "countryName": "Sweden", "indicatorId": "TAB1291", "date": "2025-02", "value": 405.2, "provider": "scb", "projection": false } ], - "commentary": "Sweden's 0.82% 2024 GDP growth lags Denmark (1.75%) and Norway (1.1%), framing the committee's fiscal debate against a prolonged slowdown. Health expenditure at 11.2% of GDP (2022, top 3 Nordic) keeps SoU-2024/25:1 in focus.", + "commentary": "IMF projects Sweden's general government gross debt at 32.4 % of GDP in 2027 (WEO Apr-2026, GGXWDG_NGDP), giving SkU-2025/26:12 fiscal headroom absent in 2022 (38 %). Control-of-corruption remains strong at 2.1 (WGI 2023).", "source": { - "worldBank": ["NY.GDP.MKTP.KD.ZG", "FP.CPI.TOTL.ZG", "SL.UEM.TOTL.ZS"], - "scb": ["TAB1291"] + "worldBank": ["CC.EST"], + "scb": ["TAB1291"], + "imf": ["WEO:NGDP_RPCH", "WEO:GGXWDG_NGDP"] } } ``` @@ -78,65 +93,103 @@ where `{analysisSubfolder}` maps from the kebab article-type slug via - `commentary` MUST cite 2–3 concrete numeric values that appear in `dataPoints` (the validator enforces only a word count; the human review + multi-dim quality score enforces the citation). -- `source.worldBank` / `source.scb` MUST list the IDs actually queried. +- At least one of `source.worldBank` / `source.scb` / `source.imf` MUST + be non-empty (previously only `worldBank`/`scb` counted). +- When any `dataPoint.projection === true`, it MUST carry a + `projectionVintage` tag (e.g. `"WEO-2026-04"`, `"FM-2026-04"`). + Projection values MAY only be cited in article commentary for the + **look-ahead allow-list**: `week-ahead`, `month-ahead`, + `weekly-review`, `monthly-review`. Daily types (`committee-reports`, + `propositions`, `motions`, `interpellations`, `evening-analysis`) + MAY include projection values in `dataPoints` for context but MUST + NOT quote them as definitive forward-looking claims in commentary. - File MUST validate against `analysis/schemas/economic-data.schema.json`. +### v1 back-compat + +A v1 artefact (no `source.imf[]`, no `provider`/`projection` on data +points) is still accepted. The loader fills defaults +(`provider: "worldBank"`, `projection: false`). New workflows SHOULD +emit v2. + --- ## MCP tool contract Step 2.6 of every `news-*.md` workflow MUST perform these MCP calls -**before** writing `economic-data.json`: +**before** writing `economic-data.json`. The order of provider +preference is: + +1. **IMF** — macro (GDP, inflation, unemployment), fiscal (debt, deficit, + revenue, expenditure), monetary, external sector. WEO projections + extend to T+5 and MUST be used for look-ahead article types. +2. **SCB** — Swedish-specific ground truth that IMF does not carry + (household consumption patterns, regional data, high-frequency + monthly tables). +3. **World Bank** — governance (WGI: CC.EST, RL.EST, VA.EST, GE.EST, + RQ.EST, PV.EST), environment (CO2, forest area, renewables), and + long-horizon social/education indicators IMF does not cover. ### 1. Map documents → policy domains → indicators ``` -view analysis/worldbank/indicators-inventory.json +view analysis/economic-indicators-inventory.json ``` -Select every indicator that has a `mcpTool` field and whose -`committees`/`policyAreas` match the day's source documents. Prioritise -the committee-specific matrix in `SHARED_PROMPT_PATTERNS.md` §"World -Bank Indicator Reference". +Each indicator entry carries a `provider` field (`imf` | `worldBank` | +`scb`), plus IMF-specific fields (`imfDatabase`, `imfIndicatorCode`, +`imfDimensionFilters`, `projectionHorizon`) when the primary provider +is IMF. Select every indicator whose `committees` / `policyAreas` +match the day's source documents. The legacy inventory at +`analysis/worldbank/indicators-inventory.json` is retained as an +append-only reference during the migration window. -### 2. Query World Bank (Sweden + Nordic peers) +### 2. Query IMF (Sweden + Nordic peers) — PRIMARY for macro/fiscal/monetary -Required calls, retried 3× on failure (see Risks): +Via the repository's pure-TypeScript client `scripts/imf-client.ts`, +exposed to agentic workflows through the thin `scripts/imf-fetch.ts` +CLI. No Python MCP / `uvx` runtime is involved; all IMF traffic goes +directly to `data.imf.org`, `api.imf.org`, and `www.imf.org` on the +firewall allowlist. -``` -# Sweden — full 10-year series for the primary domains -get-economic-data(countryCode="SE", indicator="GDP_GROWTH", years=10) -get-economic-data(countryCode="SE", indicator="INFLATION", years=10) -get-economic-data(countryCode="SE", indicator="UNEMPLOYMENT", years=10) - -# Nordic + Germany comparison — top 3 domain indicators, 5-year series -for country in [DK, NO, FI, DE]: - get-economic-data(countryCode=country, indicator=, years=5) - -# Domain-specific extras (only when article touches the domain) -get-health-data(countryCode="SE", indicator="HEALTH_EXPENDITURE", years=5) -get-education-data(countryCode="SE", indicator="EDUCATION_EXPENDITURE", years=5) -get-social-data(countryCode="SE", indicator="POPULATION", years=10) +```bash +# 1. WEO time series for one country (default 10 years; --persist caches +# the JSON under analysis/data/imf/{indicator}/{country}.json with +# projectionVintage provenance). +tsx scripts/imf-fetch.ts weo \ + --country SWE --indicator NGDP_RPCH --years 15 --persist + +# 2. Compare the latest WEO value across Sweden + Nordic peers in one call. +tsx scripts/imf-fetch.ts compare \ + --indicator GGXWDG_NGDP \ + --countries SWE,DNK,NOR,FIN,DEU --persist + +# 3. SDMX 3.0 passthrough for IFS / BOP / FM / GFS / DOTS. +tsx scripts/imf-fetch.ts sdmx \ + --path "/data/IMF.STA,CPI,4.0.0/M.SE.PCPI_IX?startPeriod=2024-01" \ + --indicator PCPI_IX --country SWE --persist + +# 4. Inspect the built-in WEO + FM indicator catalog (no network call). +tsx scripts/imf-fetch.ts list-indicators ``` -Committee → minimum indicator mapping (shortened — full matrix in the -inventory JSON): +**Rate-limit discipline** (IMF ~10 req / 5 s): prefer the `compare` +subcommand (one batched call across countries), insert a 1 s sleep +between separate `imf-fetch.ts` invocations, and rely on the client's +built-in 3× retry with exponential back-off (1 s → 2 s → 4 s) for 429 / +5xx. Pre-warm 1 request at workflow start. -| Committee | MUST query | -|-----------|-----------------------------------------------------------------| -| FiU | GDP_GROWTH, INFLATION, GDP_PER_CAPITA, GOVERNMENT_DEBT | -| AU | UNEMPLOYMENT, LABOR_FORCE, YOUTH_UNEMPLOYMENT | -| SkU | TAX_REVENUE, GDP_GROWTH | -| SoU | HEALTH_EXPENDITURE, PHYSICIANS, HOSPITAL_BEDS, LIFE_EXPECTANCY | -| UbU | EDUCATION_EXPENDITURE, LITERACY_RATE, SCHOOL_ENROLLMENT | -| FöU | MILITARY_EXPENDITURE, MILITARY_EXPENDITURE_GDP | -| MJU | CO2_EMISSIONS, RENEWABLE_ENERGY, FOREST_AREA | -| JuU | HOMICIDE_RATE, PRISON_POPULATION | -| CU | HOUSING_EXPENDITURE, MORTGAGE_RATE | -| TU | TRANSPORT_INFRASTRUCTURE_SPENDING | +### 3. Query World Bank (governance / env / social residue) + +``` +# Kept for indicators IMF does not cover +get-economic-data(countryCode="SE", indicator="CC.EST", years=5) # Control of Corruption (WGI, source=75) +get-economic-data(countryCode="SE", indicator="EN.ATM.CO2E.PC", years=10) # CO2 emissions +get-social-data(countryCode="SE", indicator="POPULATION", years=10) +``` -### 3. Query SCB (Statistics Sweden) +### 4. Query SCB (Statistics Sweden) ``` # CRITICAL: language MUST be "sv" or "en". NEVER "no" — SCB returns @@ -150,7 +203,22 @@ search_tables(query="", language="en") query_table(table_id="", value_codes={"Tid": "top(10)", ...}) ``` -### 4. (High-level reviews only) D3 coalition-flow dataset +### Committee → primary provider & indicator matrix (v2) + +| Committee | Primary provider(s) | MUST query (provider:code) | +|-----------|---------------------|-----------------------------------------------------------------------------------------------| +| FiU | IMF + WB | `imf:WEO:NGDP_RPCH`, `imf:WEO:PCPIPCH`, `imf:WEO:NGDPDPC`, `imf:WEO:GGXWDG_NGDP` | +| SkU | IMF + WB | `imf:WEO:GGR_NGDP`, `imf:WEO:GGX_NGDP`, `imf:WEO:GGXCNL_NGDP`, `imf:FM:GGXONLB_NGDP` | +| AU | IMF + WB | `imf:WEO:LUR`, `wb:SL.UEM.1524.ZS`, `wb:SL.TLF.CACT.ZS` | +| NU / UU | IMF | `imf:WEO:BCA_NGDPD`, `imf:WEO:TX_RPCH` | +| SoU | WB | `wb:SH.XPD.CHEX.GD.ZS`, `wb:SH.MED.PHYS.ZS`, `wb:SH.MED.BEDS.ZS`, `wb:SP.DYN.LE00.IN` | +| UbU | WB | `wb:SE.XPD.TOTL.GD.ZS`, `wb:SE.ADT.LITR.ZS`, `wb:SE.PRM.ENRR` | +| FöU | WB | `wb:MS.MIL.XPND.GD.ZS`, `wb:MS.MIL.XPND.CD` | +| MJU | WB | `wb:EN.ATM.CO2E.PC`, `wb:EG.FEC.RNEW.ZS`, `wb:AG.LND.FRST.ZS` | +| KU | WB | `wb:CC.EST`, `wb:RL.EST`, `wb:VA.EST` (WGI, source=75) | +| JuU | WB | `wb:VC.IHR.PSRC.P5`, `wb:CC.EST` | + +### 5. (High-level reviews only) D3 coalition-flow dataset Article types: `week-ahead`, `month-ahead`, `weekly-review`, `monthly-review`. In addition to `economic-data.json`, produce a @@ -236,9 +304,12 @@ cat > "$ANALYSIS_DIR/economic-data.json" <` element using a `data-chart-config` at > 🔴 **v5.0 — MANDATORY ECONOMIC DATA**: The AI agent MUST fetch and include World Bank and/or SCB data to contextualize political developments. Articles about budget/finance → include GDP, debt, deficit data. Defense → military spending. Healthcare → health expenditure. Education → spending per pupil. Use Chart.js chart containers for data visualization. An article without quantitative economic evidence is INCOMPLETE. ````markdown -### World Bank Indicator Reference for AI Agents +### Economic Indicator Reference for AI Agents (IMF / World Bank / SCB) -> **SINGLE SOURCE OF TRUTH**: `analysis/worldbank/indicators-inventory.json` -> This JSON file is the canonical machine-readable inventory of ALL indicators. Both AI agents and TypeScript modules load from this same file. To discover indicators, **`view analysis/worldbank/indicators-inventory.json`** — do NOT reference TypeScript source code. +> **SINGLE SOURCE OF TRUTH**: `analysis/economic-indicators-inventory.json` (v4.0, multi-provider) +> Legacy: `analysis/worldbank/indicators-inventory.json` — retained append-only during migration. +> This JSON is the canonical machine-readable inventory of ALL indicators from **IMF**, **World Bank**, and **SCB**. Both AI agents and TypeScript modules load from the same file. To discover indicators, **`view analysis/economic-indicators-inventory.json`** — do NOT reference TypeScript source code. -The World Bank integration provides **144 indicators** across 17 Riksdag-relevant domains: -- **19 indicators** via MCP tools (get-economic-data, get-social-data, get-education-data, get-health-data) -- **125 indicators** via REST API (build-time scripts fetch automatically) -- **6 WGI governance indicators** via REST API with source=75 (auto-detected) +The multi-provider catalogue provides: +- **IMF (primary for macro / fiscal / monetary / external sector)** — WEO headline indicators (`NGDP_RPCH`, `PCPIPCH`, `LUR`, `GGXWDG_NGDP`, `GGR_NGDP`, `GGX_NGDP`, `BCA_NGDPD`, `TX_RPCH`, `NGDPDPC`, `LP`) + Fiscal Monitor (`GGXONLB_NGDP`). Projections extend to T+5 via the April/October WEO cycle. +- **World Bank (governance / env / social residue)** — 144 indicators kept for WGI governance (source=75: CC.EST, RL.EST, VA.EST, GE.EST, RQ.EST, PV.EST), environment (CO2, forest area, renewables), and long-horizon social/education series (SE.*, SH.*, SP.*) where IMF does not publish. +- **SCB (Swedish primary source)** — unchanged. AI agents MUST use these to enrich articles. @@ -1815,16 +1816,35 @@ AI agents MUST use these to enrich articles. > **MANDATORY FIRST STEP**: Before writing any article, discover relevant indicators using this protocol. -1. **Read the inventory**: `view analysis/worldbank/indicators-inventory.json` - - Each indicator has: `id`, `key`, `name`, `unit`, `description`, `policyAreas`, `committees` +1. **Read the inventory**: `view analysis/economic-indicators-inventory.json` + - Each indicator has: `id`, `key`, `name`, `unit`, `description`, `policyAreas`, `committees`, and `provider` (`imf` | `worldBank` | `scb`) + - IMF entries additionally carry `imfDatabase`, `imfIndicatorCode`, `imfDimensionFilters`, `projectionHorizon` - Indicators with `mcpTool` field can be fetched via MCP tools - - The JSON is organized by domain (nationalAccounts, labor, military, governance, etc.) 2. **Match to article topic**: Find indicators where `policyAreas` or `committees` match the article's subject - - Example: Defense proposition → look in `military` domain (committees: FöU) - - Example: Budget debate → look in `nationalAccounts` + `governmentFinance` domains (committees: FiU, SkU) + - Example: Defense proposition → look in `military` domain (committees: FöU) → mostly WB (MS.MIL.*) + - Example: Budget debate → `nationalAccounts` + `governmentFinance` (FiU, SkU) → **IMF-first** (`WEO:NGDP_RPCH`, `WEO:GGXWDG_NGDP`, `FM:GGXONLB_NGDP`) -3. **Fetch data via MCP** for indicators that have `mcpTool` field: +3. **Fetch data via MCP (WB / SCB) or the TypeScript CLI (IMF)**, provider-first: + + **IMF (via `scripts/imf-fetch.ts`, pure-TypeScript — no Python MCP)** — invoked from the `bash` tool. Use the `compare` subcommand to batch multi-country in one call: + ```bash + # Sweden + Nordic + DE peer comparison, cached under analysis/data/imf/ + tsx scripts/imf-fetch.ts compare \ + --indicator NGDP_RPCH --countries SWE,DNK,NOR,FIN,DEU --persist + + # Full time series (history + WEO-tagged projections) for one country + tsx scripts/imf-fetch.ts weo \ + --country SWE --indicator GGXWDG_NGDP --years 15 --persist + + # Low-level SDMX 3.0 passthrough for IFS / BOP / FM / GFS / DOTS + tsx scripts/imf-fetch.ts sdmx \ + --path "/data/IMF.STA,CPI,4.0.0/M.SE.PCPI_IX?startPeriod=2024-01" \ + --indicator PCPI_IX --country SWE --persist + ``` + **Rate-limit discipline**: IMF ≈ 10 req / 5 s. Prefer `compare` (one batched call); sleep 1 s between invocations; the client retries 3× on 429 with 1 s → 2 s → 4 s back-off automatically. + + **World Bank (residual)**: ``` get-economic-data(countryCode="SE", indicator="GDP_GROWTH", years=10) get-social-data(countryCode="SE", indicator="LIFE_EXPECTANCY", years=10) diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index 3422facc3c..9376b7555c 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -47,11 +47,9 @@ jobs: run: | echo "🔧 Setting up environment..." - # Install system dependencies for Web rendering + # System dependencies for Web rendering. sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - graphviz python3 - + sudo apt-get install -y --no-install-recommends graphviz echo "✅ environment setup complete" # JavaScript/TypeScript setup @@ -81,7 +79,7 @@ jobs: # Install PxWeb MCP server for SCB (Statistics Sweden) data npm install -g @jarib/pxweb-mcp@2.0.0 - + echo "✅ Node.js MCP server packages installed globally" # Verify all MCP server installations @@ -107,7 +105,7 @@ jobs: echo "Checking @jarib/pxweb-mcp..." which pxweb-mcp && echo "✅ pxweb-mcp (SCB) found" || echo "❌ pxweb-mcp NOT found" - + echo "" echo "=== HTTP MCP Servers ===" echo "📍 GitHub MCP Server: Using HTTP endpoint (https://api.githubcopilot.com/mcp/insiders)" @@ -115,4 +113,6 @@ jobs: echo "=== Local stdio MCP Servers ===" echo "📍 World Bank MCP Server: worldbank-mcp (stdio)" echo "📍 SCB MCP Server: @jarib/pxweb-mcp --url https://api.scb.se/OV0104/v2beta (stdio)" + echo "=== TypeScript clients (invoked via tsx, no MCP) ===" + echo "📍 IMF: scripts/imf-client.ts + scripts/imf-fetch.ts — direct HTTPS to data.imf.org / www.imf.org" echo "✅ MCP server verification complete" diff --git a/.github/workflows/economic-context-audit.yml b/.github/workflows/economic-context-audit.yml index 4db068ea33..982a2b520c 100644 --- a/.github/workflows/economic-context-audit.yml +++ b/.github/workflows/economic-context-audit.yml @@ -8,6 +8,14 @@ # the one needed to open a maintenance issue. Follows the project # workflow security standards (least privilege, step-security/harden-runner, # SHA-pinned actions — see .github/aw/SHARED_PROMPT_PATTERNS.md). +# +# Schema v2 cutover (2026-04-20 → 2026-05-31, grace window): +# The validator currently accepts BOTH v1 artefacts (source.worldBank / +# source.scb only; "Data by World Bank / SCB" footer) and v2 artefacts +# (source.imf[] + dataPoints[].provider/projection; "Data by IMF / +# World Bank / SCB" footer). After 2026-05-31 the v1 back-compat paths +# may be tightened. See .github/aw/ECONOMIC_DATA_CONTRACT.md v2.0 +# version history and docs/adr/0001-adopt-imf-data-alongside-world-bank.md. name: Economic Context Daily Audit diff --git a/.github/workflows/news-article-generator.md b/.github/workflows/news-article-generator.md index e927cae861..87fbc8087d 100644 --- a/.github/workflows/news-article-generator.md +++ b/.github/workflows/news-article-generator.md @@ -56,6 +56,9 @@ network: - riksdag-regering-ai.onrender.com - api.scb.se - api.worldbank.org + - api.imf.org + - data.imf.org + - www.imf.org - data.riksdagen.se - www.riksdagen.se - riksdagen.se @@ -106,6 +109,9 @@ safe-outputs: - riksdag-regering-ai.onrender.com - api.scb.se - api.worldbank.org + - api.imf.org + - data.imf.org + - www.imf.org - data.riksdagen.se - www.riksdagen.se - riksdagen.se diff --git a/.github/workflows/news-committee-reports.md b/.github/workflows/news-committee-reports.md index 84136db708..72cfdacb2c 100644 --- a/.github/workflows/news-committee-reports.md +++ b/.github/workflows/news-committee-reports.md @@ -48,6 +48,9 @@ network: - riksdag-regering-ai.onrender.com - api.scb.se - api.worldbank.org + - api.imf.org + - data.imf.org + - www.imf.org - data.riksdagen.se - www.riksdagen.se - riksdagen.se @@ -97,6 +100,9 @@ safe-outputs: - riksdag-regering-ai.onrender.com - api.scb.se - api.worldbank.org + - api.imf.org + - data.imf.org + - www.imf.org - data.riksdagen.se - www.riksdagen.se - riksdagen.se diff --git a/.github/workflows/news-evening-analysis.md b/.github/workflows/news-evening-analysis.md index 55af58bc8b..65f11671f5 100644 --- a/.github/workflows/news-evening-analysis.md +++ b/.github/workflows/news-evening-analysis.md @@ -55,6 +55,9 @@ network: - riksdag-regering-ai.onrender.com - api.scb.se - api.worldbank.org + - api.imf.org + - data.imf.org + - www.imf.org - data.riksdagen.se - www.riksdagen.se - riksdagen.se @@ -105,6 +108,9 @@ safe-outputs: - riksdag-regering-ai.onrender.com - api.scb.se - api.worldbank.org + - api.imf.org + - data.imf.org + - www.imf.org - data.riksdagen.se - www.riksdagen.se - riksdagen.se diff --git a/.github/workflows/news-interpellations.md b/.github/workflows/news-interpellations.md index 2c241aefc0..cf8a3c7a35 100644 --- a/.github/workflows/news-interpellations.md +++ b/.github/workflows/news-interpellations.md @@ -48,6 +48,9 @@ network: - riksdag-regering-ai.onrender.com - api.scb.se - api.worldbank.org + - api.imf.org + - data.imf.org + - www.imf.org - data.riksdagen.se - www.riksdagen.se - riksdagen.se @@ -97,6 +100,9 @@ safe-outputs: - riksdag-regering-ai.onrender.com - api.scb.se - api.worldbank.org + - api.imf.org + - data.imf.org + - www.imf.org - data.riksdagen.se - www.riksdagen.se - riksdagen.se diff --git a/.github/workflows/news-month-ahead.md b/.github/workflows/news-month-ahead.md index 36d111c90e..19d708d9c1 100644 --- a/.github/workflows/news-month-ahead.md +++ b/.github/workflows/news-month-ahead.md @@ -49,6 +49,9 @@ network: - riksdag-regering-ai.onrender.com - api.scb.se - api.worldbank.org + - api.imf.org + - data.imf.org + - www.imf.org - data.riksdagen.se - www.riksdagen.se - riksdagen.se @@ -98,6 +101,9 @@ safe-outputs: - riksdag-regering-ai.onrender.com - api.scb.se - api.worldbank.org + - api.imf.org + - data.imf.org + - www.imf.org - data.riksdagen.se - www.riksdagen.se - riksdagen.se diff --git a/.github/workflows/news-monthly-review.md b/.github/workflows/news-monthly-review.md index dbec9eddd1..10100b36a2 100644 --- a/.github/workflows/news-monthly-review.md +++ b/.github/workflows/news-monthly-review.md @@ -49,6 +49,9 @@ network: - riksdag-regering-ai.onrender.com - api.scb.se - api.worldbank.org + - api.imf.org + - data.imf.org + - www.imf.org - data.riksdagen.se - www.riksdagen.se - riksdagen.se @@ -98,6 +101,9 @@ safe-outputs: - riksdag-regering-ai.onrender.com - api.scb.se - api.worldbank.org + - api.imf.org + - data.imf.org + - www.imf.org - data.riksdagen.se - www.riksdagen.se - riksdagen.se diff --git a/.github/workflows/news-motions.md b/.github/workflows/news-motions.md index 55cbe8f80c..7a239b3c0a 100644 --- a/.github/workflows/news-motions.md +++ b/.github/workflows/news-motions.md @@ -48,6 +48,9 @@ network: - riksdag-regering-ai.onrender.com - api.scb.se - api.worldbank.org + - api.imf.org + - data.imf.org + - www.imf.org - data.riksdagen.se - www.riksdagen.se - riksdagen.se @@ -97,6 +100,9 @@ safe-outputs: - riksdag-regering-ai.onrender.com - api.scb.se - api.worldbank.org + - api.imf.org + - data.imf.org + - www.imf.org - data.riksdagen.se - www.riksdagen.se - riksdagen.se diff --git a/.github/workflows/news-propositions.md b/.github/workflows/news-propositions.md index 37559124e8..4f412f0ff4 100644 --- a/.github/workflows/news-propositions.md +++ b/.github/workflows/news-propositions.md @@ -48,6 +48,9 @@ network: - riksdag-regering-ai.onrender.com - api.scb.se - api.worldbank.org + - api.imf.org + - data.imf.org + - www.imf.org - data.riksdagen.se - www.riksdagen.se - riksdagen.se @@ -97,6 +100,9 @@ safe-outputs: - riksdag-regering-ai.onrender.com - api.scb.se - api.worldbank.org + - api.imf.org + - data.imf.org + - www.imf.org - data.riksdagen.se - www.riksdagen.se - riksdagen.se diff --git a/.github/workflows/news-realtime-monitor.md b/.github/workflows/news-realtime-monitor.md index 7a21e7db3c..e5a4d24868 100644 --- a/.github/workflows/news-realtime-monitor.md +++ b/.github/workflows/news-realtime-monitor.md @@ -58,6 +58,9 @@ network: - riksdag-regering-ai.onrender.com - api.scb.se - api.worldbank.org + - api.imf.org + - data.imf.org + - www.imf.org - data.riksdagen.se - www.riksdagen.se - riksdagen.se @@ -108,6 +111,9 @@ safe-outputs: - riksdag-regering-ai.onrender.com - api.scb.se - api.worldbank.org + - api.imf.org + - data.imf.org + - www.imf.org - data.riksdagen.se - www.riksdagen.se - riksdagen.se diff --git a/.github/workflows/news-translate.md b/.github/workflows/news-translate.md index 5ee93103f9..c71fe309ed 100644 --- a/.github/workflows/news-translate.md +++ b/.github/workflows/news-translate.md @@ -56,6 +56,9 @@ network: - riksdag-regering-ai.onrender.com - api.scb.se - api.worldbank.org + - api.imf.org + - data.imf.org + - www.imf.org - data.riksdagen.se - www.riksdagen.se - riksdagen.se @@ -106,6 +109,9 @@ safe-outputs: - riksdag-regering-ai.onrender.com - api.scb.se - api.worldbank.org + - api.imf.org + - data.imf.org + - www.imf.org - data.riksdagen.se - www.riksdagen.se - riksdagen.se diff --git a/.github/workflows/news-week-ahead.md b/.github/workflows/news-week-ahead.md index c3955f7962..58fadb8482 100644 --- a/.github/workflows/news-week-ahead.md +++ b/.github/workflows/news-week-ahead.md @@ -48,6 +48,9 @@ network: - riksdag-regering-ai.onrender.com - api.scb.se - api.worldbank.org + - api.imf.org + - data.imf.org + - www.imf.org - data.riksdagen.se - www.riksdagen.se - riksdagen.se @@ -97,6 +100,9 @@ safe-outputs: - riksdag-regering-ai.onrender.com - api.scb.se - api.worldbank.org + - api.imf.org + - data.imf.org + - www.imf.org - data.riksdagen.se - www.riksdagen.se - riksdagen.se diff --git a/.github/workflows/news-weekly-review.md b/.github/workflows/news-weekly-review.md index a9e32bd1c6..de2fb4539d 100644 --- a/.github/workflows/news-weekly-review.md +++ b/.github/workflows/news-weekly-review.md @@ -48,6 +48,9 @@ network: - riksdag-regering-ai.onrender.com - api.scb.se - api.worldbank.org + - api.imf.org + - data.imf.org + - www.imf.org - data.riksdagen.se - www.riksdagen.se - riksdagen.se @@ -97,6 +100,9 @@ safe-outputs: - riksdag-regering-ai.onrender.com - api.scb.se - api.worldbank.org + - api.imf.org + - data.imf.org + - www.imf.org - data.riksdagen.se - www.riksdagen.se - riksdagen.se diff --git a/FUTURE_THREAT_MODEL.md b/FUTURE_THREAT_MODEL.md index 72e53781df..d16e77fbf8 100644 --- a/FUTURE_THREAT_MODEL.md +++ b/FUTURE_THREAT_MODEL.md @@ -70,6 +70,7 @@ Aligned with [Hack23 AB Threat Modeling Policy](https://github.com/Hack23/ISMS-P | **Automated Translation Pipeline** | Source language spoofing | Translation output manipulation | Translation attribution denial | Source text leakage | Translation queue exhaustion | LLM model access escalation | **HIGH** | | **Enhanced Dashboards (5 new)** | Data source spoofing for charts | Chart data injection/manipulation | Dashboard interaction denial | Data aggregation leakage | Large dataset rendering DoS | Dashboard admin escalation | **MEDIUM** | | **EU Parliament Cross-Reference** | EP MCP Server spoofing | Cross-reference data tampering | Data linkage denial | EU political data leakage | API rate limiting/timeout | Cross-system privilege escalation | **MEDIUM** | +| **IMF Data Integration (TypeScript client — `scripts/imf-client.ts`)** | IMF origin DNS hijack / TLS MITM | IMF JSON response tampering in transit or at rest | Stale / mis-vintaged WEO projections cited as current | Aggregate public-only; negligible | IMF rate-limit (10 req / 5 s) trips workflow | Pure-TS client inside the npm SBOM; no new runtime | **LOW** | ### **🔐 Future Crown Jewel Analysis** diff --git a/README.md b/README.md index dfb32d248f..7bf788d6ab 100644 --- a/README.md +++ b/README.md @@ -367,7 +367,8 @@ Riksdagsmonitor integrates multiple authoritative Swedish open data sources: - **[Swedish Parliament (Riksdagen)](http://data.riksdagen.se/)** - Votes, documents, committee work, MP information - **[Swedish Election Authority](http://www.val.se/)** - Election results, voter turnout, electoral statistics - **[Swedish Financial Management Authority](https://www.esv.se/psidata/)** - Government budget and spending data -- **[World Bank Open Data](http://data.worldbank.org/)** - Country-level indicators for comparative analysis +- **[World Bank Open Data](http://data.worldbank.org/)** - Governance (WGI), environment, and long-horizon social/education indicators +- **[IMF Public Data](https://data.imf.org/)** - Macro, fiscal, monetary, and external-sector indicators (WEO, Fiscal Monitor, IFS) with T+5 projections — primary source for fresh macro/fiscal figures and forward-looking commentary (see `analysis/imf/README.md` and `docs/adr/0001-adopt-imf-data-alongside-world-bank.md`) ## 🏗️ Technical Architecture diff --git a/SECURITY_ARCHITECTURE.md b/SECURITY_ARCHITECTURE.md index 83703e1aee..1d206e6d04 100644 --- a/SECURITY_ARCHITECTURE.md +++ b/SECURITY_ARCHITECTURE.md @@ -488,6 +488,7 @@ Permissions-Policy: geolocation=(), microphone=(), camera=() - **Contents:** Complete dependency inventory with versions and licenses - **Attestation:** SBOM also cryptographically signed (`actions/attest-sbom@v3.0.0`) - **Purpose:** Vulnerability tracking, license compliance, supply chain transparency +- **External MCP supplement:** `package.json` `x-external-mcp` field records MCP servers outside the npm graph (Python, Docker, HTTP). It is currently empty — all economic-data clients (World Bank, SCB, IMF) ship as npm TypeScript (`scripts/world-bank-client.ts`, `scripts/scb-client.ts`, `scripts/imf-client.ts`) and are fully covered by the standard SPDX SBOM; see `THREAT_MODEL.md` TB-6a for the IMF client's threat model. **Release Pipeline Security (3-job workflow):** diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md index ab0f41fc9e..b68eff315c 100644 --- a/THREAT_MODEL.md +++ b/THREAT_MODEL.md @@ -300,6 +300,7 @@ graph TB | **TB-4: GitHub Actions → AWS** | OIDC deployment | **I**: Secret exposure, **E**: Privilege escalation | OIDC (no long-lived keys), CloudTrail monitoring | | **TB-5: GitHub Actions → Claude API** | AI content generation | **T**: Prompt injection, **I**: Hallucination, **R**: Non-determinism | Input sanitization, output validation, PR review | | **TB-6: GitHub Actions → MCP** | Political data queries | **S**: Server impersonation, **T**: Data manipulation, **I**: Stale data | HTTPS-only, Freshness validation, Cross-verification | +| **TB-6a: Agentic workflows → IMF (TypeScript client, no MCP)** | Macro/fiscal/monetary queries to `data.imf.org` / `api.imf.org` / `www.imf.org` issued directly from `scripts/imf-client.ts` and the `scripts/imf-fetch.ts` CLI (invoked by agentic workflows through the `bash` tool). No Python/`uvx` runtime; client is npm-only. | **S**: IMF origin DNS hijack or TLS MITM on workflow egress; **T**: Tampering of IMF JSON responses in transit or at rest under `analysis/data/imf/`; **I**: Stale / mis-vintaged WEO projections cited as current values; **D**: IMF rate-limit (~10 req / 5 s) causing workflow failure | HTTPS / TLS 1.3 with GitHub-runner root-CA trust store; firewall allowlist scoped to `data.imf.org` / `api.imf.org` / `www.imf.org` only; response schema validation in `imf-client.ts` (`DatamapperResponse` shape, numeric finite-check, year parse-guard); cached responses under `analysis/data/imf/{indicator}/{country}.json` with sidecar `.meta.json` stamping `mcpTool: imf-ts-client` + `projectionVintage`; built-in 3× 429 back-off (1 s → 2 s → 4 s) plus `compare` subcommand batching multi-country in one Datamapper call; no additional third-party code paths (client is part of the npm SBOM) | | **TB-7: Browser → CDN (Chart.js/D3.js)** | External library loading | **T**: Supply chain attack, **I**: XSS injection | SRI hashes, CSP, Trusted CDN (jsDelivr) | ### **📊 Container Diagram (C4 Level 2) - Detailed Architecture** diff --git a/analysis/daily/2026-04-20/propositions/data-download-manifest.md b/analysis/daily/2026-04-20/propositions/data-download-manifest.md index 491422f60a..07bd6ed99d 100644 --- a/analysis/daily/2026-04-20/propositions/data-download-manifest.md +++ b/analysis/daily/2026-04-20/propositions/data-download-manifest.md @@ -96,7 +96,7 @@ Both articles have been validated for: - ✅ 0 `AI_MUST_REPLACE` markers. - ✅ 0 `TODO` placeholders. -- ✅ 0 "[PLACEHOLDER]" patterns. +- ✅ 0 bracketed "PLACEHOLDER" patterns. - ✅ All section numbers sequential. - ✅ All internal anchor links valid. - ✅ All external links use `rel="noopener"` where appropriate. diff --git a/analysis/economic-indicators-inventory.json b/analysis/economic-indicators-inventory.json new file mode 100644 index 0000000000..d981ae3972 --- /dev/null +++ b/analysis/economic-indicators-inventory.json @@ -0,0 +1,221 @@ +{ + "version": "4.0", + "description": "Multi-provider economic indicator inventory for Riksdagsmonitor. Single source of truth for IMF + World Bank + SCB indicator discovery. IMF entries are defined in-line below; World Bank entries are imported by reference from analysis/worldbank/indicators-inventory.json (v3.0) for back-compat; SCB tables are defined by scripts/scb-context.ts (committee → TAB mapping).", + "lastUpdated": "2026-04-20", + "effectiveDate": "2026-04-20", + "schemaCutover": "2026-05-31", + "providers": { + "imf": { + "primary": "scripts/imf-client.ts (pure-TypeScript, no MCP) exposed to agentic workflows via scripts/imf-fetch.ts CLI + direct REST (Datamapper JSON + SDMX 3.0)", + "databases": ["WEO", "FM", "IFS", "BOP_AGG", "GFS_COFOG", "MFS_IR", "DOTS", "PCPS"], + "refreshCadence": { + "WEO": "April and October (flagship)", + "FM": "April and October (flagship)", + "IFS": "Monthly", + "BOP_AGG": "Quarterly", + "GFS_COFOG": "Annual (T+1)", + "DOTS": "Monthly" + }, + "rateLimit": "~10 req / 5 s; prefer 'tsx scripts/imf-fetch.ts compare' for multi-country batches; client retries 3× on 429 with 1s→2s→4s back-off", + "projectionHorizon": "WEO T+5 (currently 2031), FM T+5" + }, + "worldBank": { + "primary": "worldbank-mcp@1.0.1 + api.worldbank.org/v2", + "inventory": "analysis/worldbank/indicators-inventory.json (v3.0, 144 indicators)", + "useFor": ["WGI governance (source=75)", "environment (CO2, forests, renewables)", "long-horizon social/education residue"] + }, + "scb": { + "primary": "scb MCP (pxweb-mcp) + api.scb.se/OV0104/v2beta", + "useFor": ["Sweden-specific ground truth", "regional data", "monthly frequency"], + "language": "sv or en (NEVER `nb`/`no` — HTTP 400; per project BCP-47 migration, the legacy `no` tag is equally invalid for SCB)" + } + }, + "providerSelection": { + "macro": "imf", + "fiscal": "imf", + "monetary": "imf", + "externalSector": "imf", + "governance": "worldBank", + "environment": "worldBank", + "socialResidue": "worldBank", + "swedenPrimary": "scb" + }, + "indicators": [ + { + "id": "NGDP_RPCH", + "key": "imfRealGdpGrowth", + "provider": "imf", + "imfDatabase": "WEO", + "imfIndicatorCode": "NGDP_RPCH", + "imfDimensionFilters": { "FREQ": "A", "COUNTRY": "SE,DK,NO,FI,DE" }, + "name": "Real GDP growth (IMF WEO)", + "unit": "Annual % change", + "description": "Headline macro indicator. Replaces WB NY.GDP.MKTP.KD.ZG for freshness (IMF WEO Apr/Oct cadence + T+5 projections).", + "policyAreas": ["fiscal policy", "macro economy", "growth"], + "committees": ["FiU", "SkU", "NU"], + "projectionHorizon": "T+5", + "supersedes": "worldBank:NY.GDP.MKTP.KD.ZG" + }, + { + "id": "NGDPDPC", + "key": "imfGdpPerCapita", + "provider": "imf", + "imfDatabase": "WEO", + "imfIndicatorCode": "NGDPDPC", + "imfDimensionFilters": { "FREQ": "A" }, + "name": "GDP per capita (IMF WEO)", + "unit": "Current USD", + "description": "Cross-country peer comparison, current USD.", + "policyAreas": ["macro economy", "living standards"], + "committees": ["FiU", "AU", "SoU"], + "projectionHorizon": "T+5" + }, + { + "id": "PCPIPCH", + "key": "imfInflationCpi", + "provider": "imf", + "imfDatabase": "WEO", + "imfIndicatorCode": "PCPIPCH", + "imfDimensionFilters": { "FREQ": "A" }, + "name": "Inflation, CPI average (IMF WEO)", + "unit": "Annual % change", + "description": "Primary monetary-policy reference. Replaces WB FP.CPI.TOTL.ZG for freshness and projections.", + "policyAreas": ["monetary policy", "inflation"], + "committees": ["FiU"], + "projectionHorizon": "T+5", + "supersedes": "worldBank:FP.CPI.TOTL.ZG" + }, + { + "id": "LUR", + "key": "imfUnemploymentRate", + "provider": "imf", + "imfDatabase": "WEO", + "imfIndicatorCode": "LUR", + "imfDimensionFilters": { "FREQ": "A" }, + "name": "Unemployment rate (IMF WEO)", + "unit": "% of labor force", + "description": "WEO definition; projections extend to T+5.", + "policyAreas": ["labor market", "employment"], + "committees": ["AU", "SoU"], + "projectionHorizon": "T+5", + "supersedes": "worldBank:SL.UEM.TOTL.ZS" + }, + { + "id": "GGXWDG_NGDP", + "key": "imfGeneralGovGrossDebt", + "provider": "imf", + "imfDatabase": "WEO", + "imfIndicatorCode": "GGXWDG_NGDP", + "imfDimensionFilters": { "FREQ": "A" }, + "name": "General government gross debt (IMF WEO)", + "unit": "% of GDP", + "description": "Debt/GDP on general-government (EDP) basis. Superior to WB GC.DOD.TOTL.GD.ZS for EU policy discussion (follows GFSM 2014).", + "policyAreas": ["fiscal policy", "debt", "EU stability and growth pact"], + "committees": ["FiU", "SkU"], + "projectionHorizon": "T+5", + "supersedes": "worldBank:GC.DOD.TOTL.GD.ZS" + }, + { + "id": "GGR_NGDP", + "key": "imfGeneralGovRevenue", + "provider": "imf", + "imfDatabase": "WEO", + "imfIndicatorCode": "GGR_NGDP", + "imfDimensionFilters": { "FREQ": "A" }, + "name": "General government revenue (IMF WEO)", + "unit": "% of GDP", + "description": "Tax and non-tax revenue share of GDP; projected to T+5.", + "policyAreas": ["fiscal policy", "taxation"], + "committees": ["SkU", "FiU"], + "projectionHorizon": "T+5" + }, + { + "id": "GGX_NGDP", + "key": "imfGeneralGovExpenditure", + "provider": "imf", + "imfDatabase": "WEO", + "imfIndicatorCode": "GGX_NGDP", + "imfDimensionFilters": { "FREQ": "A" }, + "name": "General government expenditure (IMF WEO)", + "unit": "% of GDP", + "description": "Total government spending as % of GDP; projected to T+5.", + "policyAreas": ["fiscal policy", "public spending"], + "committees": ["FiU"], + "projectionHorizon": "T+5" + }, + { + "id": "GGXCNL_NGDP", + "key": "imfGeneralGovBalance", + "provider": "imf", + "imfDatabase": "WEO", + "imfIndicatorCode": "GGXCNL_NGDP", + "imfDimensionFilters": { "FREQ": "A" }, + "name": "General government net lending/borrowing (IMF WEO)", + "unit": "% of GDP", + "description": "Headline fiscal balance (surplus +, deficit −); projections to T+5.", + "policyAreas": ["fiscal policy", "budget"], + "committees": ["FiU", "SkU"], + "projectionHorizon": "T+5" + }, + { + "id": "GGXONLB_NGDP", + "key": "imfPrimaryBalance", + "provider": "imf", + "imfDatabase": "FM", + "imfIndicatorCode": "GGXONLB_NGDP", + "imfDimensionFilters": { "FREQ": "A" }, + "name": "General government primary balance (IMF Fiscal Monitor)", + "unit": "% of GDP", + "description": "Balance excluding interest payments (FM vintage — debt-sustainability analysis).", + "policyAreas": ["fiscal policy", "debt sustainability"], + "committees": ["FiU", "SkU"], + "projectionHorizon": "T+5" + }, + { + "id": "BCA_NGDPD", + "key": "imfCurrentAccountBalance", + "provider": "imf", + "imfDatabase": "WEO", + "imfIndicatorCode": "BCA_NGDPD", + "imfDimensionFilters": { "FREQ": "A" }, + "name": "Current account balance (IMF WEO)", + "unit": "% of GDP", + "description": "Net external position (trade + primary + secondary income).", + "policyAreas": ["external sector", "trade"], + "committees": ["NU", "UU", "FiU"], + "projectionHorizon": "T+5" + }, + { + "id": "TX_RPCH", + "key": "imfExportsVolumeGrowth", + "provider": "imf", + "imfDatabase": "WEO", + "imfIndicatorCode": "TX_RPCH", + "imfDimensionFilters": { "FREQ": "A" }, + "name": "Exports of goods and services, volume (IMF WEO)", + "unit": "Annual % change", + "description": "Real exports growth rate; projections to T+5.", + "policyAreas": ["trade", "export performance"], + "committees": ["NU", "UU"], + "projectionHorizon": "T+5" + }, + { + "id": "LP", + "key": "imfPopulation", + "provider": "imf", + "imfDatabase": "WEO", + "imfIndicatorCode": "LP", + "imfDimensionFilters": { "FREQ": "A" }, + "name": "Population (IMF WEO)", + "unit": "Millions", + "description": "WEO population definition.", + "policyAreas": ["demographics"], + "committees": ["SoU", "SfU"], + "projectionHorizon": "T+5" + } + ], + "referenced": { + "worldBankInventory": "analysis/worldbank/indicators-inventory.json", + "scbContext": "scripts/scb-context.ts" + } +} diff --git a/analysis/imf/README.md b/analysis/imf/README.md new file mode 100644 index 0000000000..830e592f67 --- /dev/null +++ b/analysis/imf/README.md @@ -0,0 +1,88 @@ +# IMF Data Integration + +> **Purpose**: IMF public macro/fiscal/monetary data as a **primary source** +> for Riksdagsmonitor article economic context, complementing (not +> replacing) the World Bank integration. +> +> **Effective**: 2026-04-20 (Economic Data Contract v2.0) + +--- + +## Why IMF + +World Bank WDI data is authoritative for governance, environment, and +long-horizon social indicators, but it **lags 12–24 months** for +macro/fiscal headline figures. Most series still showed 2023–2024 +values in April 2026, and WB publishes no projections. + +The IMF fills both gaps: + +| Gap | IMF product | What it unlocks | +|---|---|---| +| Macro freshness | WEO (Apr/Oct) | 2025 final values + 2026 Q1 from the April 2026 WEO release instead of 2024 annuals | +| Projections | WEO + Fiscal Monitor (T+5) | Numeric forward-looking commentary in `week-ahead`, `month-ahead`, `weekly-review`, `monthly-review` | +| Fiscal granularity | GFS_COFOG | Committee-aligned spending decomposition (COFOG 02=FöU, 07=SoU, 09=UbU, 10=social protection) | +| Monetary / FX | IFS, MFS_IR | Policy rate series + SEK/EUR for monetary-policy coverage | +| Cross-country peer quality | Uniform SNA 2008 / GFSM 2014 | Nordic peer comparisons on a consistent methodology | + +--- + +## Adoption strategy (hybrid) + +- **Agentic workflows** (LLM-driven article authoring) → invoke the + `scripts/imf-fetch.ts` CLI via the `bash` tool (`tsx scripts/imf-fetch.ts + weo|compare|sdmx|list-indicators …`). The CLI is a thin wrapper over + `scripts/imf-client.ts` — a pure-TypeScript client — so there is **no + Python / `uvx` runtime** and **no third-party MCP server** on the + critical path. +- **Build-time scripts** → import `scripts/imf-client.ts` directly for + deterministic TypeScript fetches. Primary transport is the IMF + **Datamapper** JSON endpoint (WEO indicators, no auth). Targeted + SDMX 3.0 is available via `ImfClient.sdmxFetch()` for IFS/BOP/FM. +- **World Bank** → kept for WGI governance, environment, and social + residue. Do not replace WB calls that target these classes. +- **SCB** → unchanged; remains the Swedish primary source. + +See the architecture decision record: +`docs/adr/0001-adopt-imf-data-alongside-world-bank.md`. + +--- + +## Code surface + +| File | Purpose | +|---|---| +| `scripts/imf-client.ts` | TypeScript REST client (Datamapper + SDMX 3.0 passthrough). 429/5xx back-off, projection detection. | +| `scripts/imf-fetch.ts` | Thin CLI wrapper over `imf-client.ts` (commands: `weo`, `compare`, `sdmx`, `list-indicators`). Used by agentic workflows via the `bash` tool. | +| `scripts/imf-codes.ts` | ISO-3 ↔ IMF AREA code mappings for IFS/GFS/BOP. Fail-loud on unknown codes. | +| `scripts/imf-context.ts` | Policy-area / committee → IMF WEO+FM indicator mapping. `imfCitation()` helper. | +| `analysis/economic-indicators-inventory.json` | v4.0 multi-provider inventory (IMF in-line; WB via reference). | + +No MCP server is required — IMF access is part of the repository's npm +SBOM, and the only firewall egress needed is to `data.imf.org`, +`api.imf.org`, and `www.imf.org`. + +--- + +## Rate-limit discipline + +IMF advertises **~10 req / 5 s**. The client and agentic workflows MUST: + +- Prefer the `compare` subcommand (one batched Datamapper call across + several countries) or a single `weo` call returning a full series. +- Sleep 1 s between separate `imf-fetch.ts` invocations. +- Rely on the client's built-in 3× retry with 1 s → 2 s → 4 s back-off + on HTTP 429 / 5xx. +- Cache raw responses under `analysis/data/imf/{indicator}/{country}.json` + via the `--persist` flag (or `persistIMFData()` for programmatic use). +- Pre-warm 1 request at workflow start. + +--- + +## Related documents + +- `analysis/imf/indicator-policy-mapping.md` — which IMF indicators feed which committees +- `analysis/imf/use-cases.md` — canonical article examples +- `.github/aw/ECONOMIC_DATA_CONTRACT.md` — v2.0 contract (data artefact shape, validator gates) +- `.github/aw/SHARED_PROMPT_PATTERNS.md` — "Economic Indicator Reference" +- `docs/adr/0001-adopt-imf-data-alongside-world-bank.md` — architecture decision diff --git a/analysis/imf/indicator-policy-mapping.md b/analysis/imf/indicator-policy-mapping.md new file mode 100644 index 0000000000..1e8f879bc7 --- /dev/null +++ b/analysis/imf/indicator-policy-mapping.md @@ -0,0 +1,50 @@ +# IMF Indicator ↔ Swedish Policy / Committee Mapping + +> Companion to `analysis/worldbank/indicator-policy-mapping.md`. +> Source of truth for which IMF indicator answers which Riksdag +> committee's question. Referenced by `scripts/imf-context.ts` and by +> the Step 2.6 prompt of every `news-*.md` workflow. + +--- + +## Committee → IMF indicator matrix + +| Committee | Remit | Primary IMF indicators | +|-----------|----------------------------------------|-------------------------------------------------------------------------------------------------------------------| +| **FiU** | Finance — macro & budget | `WEO:NGDP_RPCH` (growth), `WEO:PCPIPCH` (inflation), `WEO:NGDPDPC` (GDP per capita), `WEO:GGXWDG_NGDP` (debt/GDP), `WEO:GGXCNL_NGDP` (fiscal balance), `WEO:GGX_NGDP` (expenditure) | +| **SkU** | Taxation | `WEO:GGR_NGDP` (revenue/GDP), `FM:GGXONLB_NGDP` (primary balance), `WEO:GGXWDG_NGDP` (debt context) | +| **AU** | Labour market | `WEO:LUR` (unemployment rate) | +| **NU** | Business / trade | `WEO:BCA_NGDPD` (current account), `WEO:TX_RPCH` (exports volume growth) | +| **UU** | Foreign affairs | `WEO:BCA_NGDPD`, `WEO:TX_RPCH` | +| **SoU** | Health / welfare | `WEO:LP` (population), `WEO:NGDPDPC` (per capita context for health-spending share) | +| **SfU** | Social insurance | `WEO:LP` (population base), `WEO:LUR` | +| **FöU** | Defence | *(IMF does not publish military outlays; use WB MS.MIL.XPND.GD.ZS)* | +| **MJU** | Environment | *(Use WB EN.ATM.CO2E.PC, EG.FEC.RNEW.ZS)* | +| **UbU** | Education | *(Use WB SE.XPD.TOTL.GD.ZS)* | +| **KU** | Constitution / institutions | *(Use WB WGI — CC.EST, RL.EST, VA.EST, source=75)* | + +## Projection horizons + +| Indicator | Historical back to | Projection to | Released | +|-----------------|--------------------|---------------|----------| +| `WEO:NGDP_RPCH` | 1980 | T+5 (2031) | Apr/Oct | +| `WEO:PCPIPCH` | 1980 | T+5 | Apr/Oct | +| `WEO:LUR` | 1980 | T+5 | Apr/Oct | +| `WEO:GGXWDG_NGDP` | 1995 | T+5 | Apr/Oct | +| `WEO:GGXCNL_NGDP` | 1995 | T+5 | Apr/Oct | +| `WEO:BCA_NGDPD` | 1980 | T+5 | Apr/Oct | +| `FM:GGXONLB_NGDP` | 2000 | T+5 | Apr/Oct | + +--- + +## How to cite + +Commentary MUST use the explicit IMF format so the audit can detect +stale vintages: + +> "IMF projects Sweden's general government gross debt at **32.4 %** of +> GDP in **2027** (`WEO Apr-2026, GGXWDG_NGDP`)." + +Never use un-attributed forecast phrasing ("Sweden will…", "The economy +is expected to…") — see the banned-phrasings list in +`.github/aw/ECONOMIC_DATA_CONTRACT.md`. diff --git a/analysis/imf/use-cases.md b/analysis/imf/use-cases.md new file mode 100644 index 0000000000..48111d96d2 --- /dev/null +++ b/analysis/imf/use-cases.md @@ -0,0 +1,120 @@ +# IMF Integration — Use Cases + +> Canonical examples of how IMF data enriches Riksdagsmonitor articles. +> Each case shows (1) the political event, (2) the IMF query, (3) the +> commentary snippet, and (4) the `economic-data.json` excerpt. + +--- + +## 1. SkU tax-reform proposition (FiU + SkU) + +**Event**: Regeringen submits SoU-2025/26:12 proposing corporate-tax +changes. FiU must weigh fiscal space. + +**Query** (via `tsx scripts/imf-fetch.ts`): + +```bash +tsx scripts/imf-fetch.ts compare \ + --indicator GGXWDG_NGDP --countries SWE,DNK,NOR,FIN,DEU --persist +tsx scripts/imf-fetch.ts compare \ + --indicator GGR_NGDP --countries SWE,DNK,NOR,FIN,DEU --persist +tsx scripts/imf-fetch.ts weo \ + --country SWE --indicator NGDP_RPCH --years 12 --persist +``` + +**Commentary** (for a `propositions` article): + +> IMF projects Sweden's general government gross debt at **32.4 %** of +> GDP in **2027** (WEO Apr-2026, `GGXWDG_NGDP`), down from the 2022 +> peak of 38 %. With revenue holding at **49.1 %** of GDP (`GGR_NGDP`), +> the SkU proposal SoU-2025/26:12 lands with fiscal headroom that +> absent in the 2022 debate over TAB-03/22. + +**Artefact** (`analysis/daily/2026-04-20/propositions/economic-data.json`, +excerpt): + +```json +{ + "version": "2.0", + "source": { "worldBank": [], "scb": [], "imf": ["WEO:GGXWDG_NGDP", "WEO:GGR_NGDP"] }, + "dataPoints": [ + { "countryCode": "SWE", "indicatorId": "GGXWDG_NGDP", "date": "2022", "value": 38.0, "provider": "imf", "projection": false }, + { "countryCode": "SWE", "indicatorId": "GGXWDG_NGDP", "date": "2027", "value": 32.4, "provider": "imf", "projection": true, "projectionVintage": "WEO-2026-04" }, + { "countryCode": "SWE", "indicatorId": "GGR_NGDP", "date": "2025", "value": 49.1, "provider": "imf", "projection": false } + ] +} +``` + +--- + +## 2. Week-ahead macro forecast (week-ahead) + +**Event**: Next week's Riksdag calendar includes a FiU monetary-policy +hearing with Riksbanken. + +**Query**: + +```bash +tsx scripts/imf-fetch.ts weo --country SWE --indicator PCPIPCH --years 12 --persist +tsx scripts/imf-fetch.ts weo --country SWE --indicator LUR --years 12 --persist +tsx scripts/imf-fetch.ts weo --country SWE --indicator NGDP_RPCH --years 12 --persist +``` + +**Commentary** (week-ahead, projections permitted): + +> The IMF's April 2026 WEO pegs Sweden's 2026 CPI inflation at +> **2.1 %** and unemployment at **7.9 %** (`PCPIPCH`, `LUR`) — numbers +> the Riksbank governor is all but certain to be asked about in +> Wednesday's FiU hearing. Growth is projected at **1.9 %** for 2026 +> rising to **2.3 %** in 2027 (`NGDP_RPCH`). + +--- + +## 3. Monthly-review Nordic peer check (monthly-review) + +**Event**: End-of-month roll-up comparing Swedish fiscal stance to +Nordic peers. + +**Query**: + +```bash +tsx scripts/imf-fetch.ts compare \ + --indicator GGXCNL_NGDP --countries SWE,DNK,NOR,FIN,DEU --persist +tsx scripts/imf-fetch.ts compare \ + --indicator GGXWDG_NGDP --countries SWE,DNK,NOR,FIN,DEU --persist +``` + +**Commentary**: + +> On the WEO Apr-2026 vintage, Sweden's 2025 fiscal balance +> (**-0.8 %** of GDP, `GGXCNL_NGDP`) sits between Denmark (**+1.2 %**) +> and Finland (**-3.1 %**). Projections to 2028 show Sweden +> consolidating to a small surplus (**+0.4 %**), while Finland's +> projected trajectory remains in deficit. + +--- + +## 4. Committee-reports placeholder for FöU (defence) + +**Event**: FöU report on defence-procurement programme. + +**Decision**: FöU's headline indicator (`MS.MIL.XPND.GD.ZS`) lives in +World Bank, **not IMF**. Use the legacy WB path; do not force an IMF +call. A mixed-source article is valid: + +```json +{ + "source": { + "worldBank": ["MS.MIL.XPND.GD.ZS", "MS.MIL.XPND.CD"], + "scb": [], + "imf": ["WEO:NGDP_RPCH"] + } +} +``` + +--- + +## 5. Constitutional / KU article (no IMF usage) + +WGI governance (`CC.EST`, `RL.EST`, `VA.EST`) is a World Bank +exclusive. Keep the existing WB path. `source.imf` stays empty. diff --git a/analysis/schemas/economic-data.schema.json b/analysis/schemas/economic-data.schema.json index e948c018c8..d999fee125 100644 --- a/analysis/schemas/economic-data.schema.json +++ b/analysis/schemas/economic-data.schema.json @@ -2,15 +2,16 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://riksdagsmonitor.com/schemas/economic-data.schema.json", "title": "Article economic-data.json", - "description": "Per-article artefact produced by agentic workflows during the pre-article analysis phase. Bridges World Bank / SCB MCP tool output into the Chart.js-powered 'Economic Context' dashboard emitted by `scripts/data-transformers/content-generators/economic-dashboard-section.ts`. Written to `analysis/daily/YYYY-MM-DD/{analysisSubfolder}/economic-data.json`.", + "description": "Per-article artefact produced by agentic workflows during the pre-article analysis phase. Bridges World Bank (MCP), SCB (MCP), and IMF (TypeScript client/CLI) outputs into the Chart.js-powered 'Economic Context' dashboard emitted by `scripts/data-transformers/content-generators/economic-dashboard-section.ts`. Written to `analysis/daily/YYYY-MM-DD/{analysisSubfolder}/economic-data.json`. Schema v2.0 (additive): adds `source.imf[]` and optional `provider`, `projection`, `projectionVintage` fields on each data point. Schema v1 artefacts (`source` with only `worldBank` and `scb`) remain valid.", "type": "object", "additionalProperties": false, "required": ["policyDomains", "dataPoints", "commentary", "source"], "properties": { "version": { "type": "string", - "description": "Contract version — currently '1.0'.", - "default": "1.0" + "description": "Contract version — '1.0' (WB + SCB only) or '2.0' (adds IMF + provider/projection fields). Schema accepts both; the validator enforces the version floor.", + "enum": ["1.0", "2.0"], + "default": "2.0" }, "articleType": { "type": "string", @@ -54,18 +55,23 @@ "source": { "type": "object", "additionalProperties": false, + "description": "Attribution sources the workflow actually queried. For schema v1 back-compat, `worldBank` and `scb` are always present (may be empty). For schema v2.0+, `imf` is also accepted. At least one of the three arrays MUST be non-empty unless `skip: true`.", "required": ["worldBank", "scb"], - "description": "Attribution sources the workflow actually queried.", "properties": { "worldBank": { "type": "array", "items": { "type": "string" }, - "description": "World Bank indicator IDs (e.g. 'NY.GDP.MKTP.KD.ZG')." + "description": "World Bank indicator IDs (e.g. 'NY.GDP.MKTP.KD.ZG'). Kept for governance (WGI), environment, long-horizon social/education series where IMF does not provide coverage." }, "scb": { "type": "array", "items": { "type": "string" }, - "description": "SCB table IDs (e.g. 'TAB1291')." + "description": "SCB table IDs (e.g. 'TAB1291'). Swedish primary source — unchanged." + }, + "imf": { + "type": "array", + "items": { "type": "string" }, + "description": "IMF database/indicator citation strings (e.g. 'WEO:NGDP_RPCH', 'FM:GGXWDG_NGDP', 'IFS:FIMM_PA'). Populated by the repository's pure-TypeScript `scripts/imf-client.ts` (imported by build-time scripts and invoked from agentic workflows via the `scripts/imf-fetch.ts` CLI) for macro/fiscal/monetary indicators from `data.imf.org` (Datamapper JSON + SDMX 3.0). No Python MCP." } } }, @@ -87,7 +93,7 @@ "properties": { "countryCode": { "type": "string", - "description": "ISO-3 country code (e.g. 'SWE', 'DNK', 'NOR', 'FIN', 'DEU').", + "description": "ISO-3 country code (e.g. 'SWE', 'DNK', 'NOR', 'FIN', 'DEU'). IMF area codes (e.g. '111' for USA in GFS) MUST be resolved to ISO-3 before writing.", "minLength": 2 }, "countryName": { @@ -96,15 +102,29 @@ }, "indicatorId": { "type": "string", - "description": "World Bank indicator ID (e.g. 'NY.GDP.MKTP.KD.ZG')." + "description": "Provider-native indicator ID. World Bank example: 'NY.GDP.MKTP.KD.ZG'. IMF example: 'NGDP_RPCH' (WEO) or 'GGXWDG_NGDP' (FM). SCB example: 'TAB1291'." }, "date": { "type": "string", - "description": "Year or date string (e.g. '2024', '2024-Q1')." + "description": "Year or date string (e.g. '2024', '2024-Q1', '2026-03')." }, "value": { "type": "number", "description": "Numeric value of the indicator for the country/date." + }, + "provider": { + "type": "string", + "enum": ["worldBank", "imf", "scb"], + "description": "Schema v2.0+ — which authoritative source produced this data point. Required for IMF data points; defaults to 'worldBank' when omitted for back-compat with v1 artefacts." + }, + "projection": { + "type": "boolean", + "description": "Schema v2.0+ — true when the value is a forecast (IMF WEO / Fiscal Monitor projections typically extend to T+5). Article commentary may cite projected values only when this flag is true AND the article type is on the look-ahead allow-list (week-ahead, month-ahead, weekly-review, monthly-review).", + "default": false + }, + "projectionVintage": { + "type": "string", + "description": "Schema v2.0+ — vintage tag of the projection release (e.g. 'WEO-2026-04', 'FM-2026-04'). Required when `projection: true`. Enables the audit to surface stale vintages." } } } diff --git a/docs/adr/0001-adopt-imf-data-alongside-world-bank.md b/docs/adr/0001-adopt-imf-data-alongside-world-bank.md new file mode 100644 index 0000000000..85289d3e6c --- /dev/null +++ b/docs/adr/0001-adopt-imf-data-alongside-world-bank.md @@ -0,0 +1,136 @@ +# ADR 0001 — Adopt IMF data alongside World Bank + +- **Status**: Accepted +- **Date**: 2026-04-20 +- **Authors**: Hack23 AB — Riksdagsmonitor maintainers +- **Deciders**: CEO / CISO (per `Change_Management.md` Normal change) + +## Context + +Riksdagsmonitor's article pipeline renders an economic-context +dashboard on every news article using **World Bank WDI** (via +`worldbank-mcp@1.0.1`) and **SCB** (via `pxweb-mcp`). Economic Data +Contract v1.0 (effective 2026-04-18) binds the validator and schema +to these two providers. + +Two quality defects surfaced during the April 2026 review: + +1. **Freshness lag**: most WB macro series (GDP growth, CPI, + unemployment, debt, deficit) last reported values for 2023–2024. + Every daily article anchors commentary on figures that are + 12–24 months old. +2. **No projections**: WB publishes no forward-looking data. The four + "look-ahead" article types (`week-ahead`, `month-ahead`, + `weekly-review`, `monthly-review`) consequently produce weak + forward-looking commentary — they describe the future but can only + cite historical WB values. + +Three IMF alternatives were evaluated: + +| Option | Protocol | Coverage | Freshness | +|---|---|---|---| +| **c-cf/imf-data-mcp** | Python MCP over SDMX 3.0 | ~155 databases incl. WEO/IFS/BOP/GFS/FM/MFS/DOTS/PCPS | WEO Apr-2026 release; projections to 2031 | +| **IMF Datamapper JSON** | Plain REST (`www.imf.org/external/datamapper/api/v1`) | WEO subset (NGDP_RPCH, PCPIPCH, LUR, GGXWDG_NGDP, BCA_NGDPD, …) | Same Apr/Oct cycle | +| **IMF SDMX 3.0 direct** | SDMX REST (`api.imf.org/external/sdmx/3.0`) | Full IMF catalogue | Per-dataset cadence | + +## Decision + +**Adopt a hybrid IMF stack built entirely in TypeScript**, complementing +— not replacing — World Bank: + +1. **Single pure-TypeScript client** (`scripts/imf-client.ts`) combines + the IMF Datamapper JSON transport for WEO with the SDMX 3.0 transport + for IFS / BOP / FM / GFS / DOTS. No Python / `uvx` runtime, no + third-party MCP server — the client is a normal npm source file on + par with `world-bank-client.ts` and `scb-client.ts`. +2. **Agentic workflows** (LLM article authoring) call this client + through the thin `scripts/imf-fetch.ts` CLI via the `bash` tool + (`tsx scripts/imf-fetch.ts weo|compare|sdmx|list-indicators …`). + Identical freshness guarantees, no MCP protocol overhead. +3. **Build-time scripts** import the client directly as a TypeScript + module. +4. **World Bank** is kept for WGI governance (source=75), environment, + and long-horizon social/education residue where IMF does not + publish. +5. **SCB** is unchanged and remains the Swedish primary source. + +Schema v2 (additive) adds `source.imf[]`, +`dataPoints[].provider`, `dataPoints[].projection`, and +`dataPoints[].projectionVintage`. v1 artefacts remain valid through +the 2026-04-20 → 2026-05-31 grace window. + +## Consequences + +### Positive + +- **Freshness jumps ≈ 18 months**: daily articles can cite 2025 final + values and 2026 Q1 instead of 2024 annuals. +- **Projections unlocked**: look-ahead article types gain credible + numeric forward commentary to T+5 (2031 from the Apr-2026 vintage). +- **Fiscal granularity** via GFS_COFOG aligned to committee remits + (02=FöU, 07=SoU, 09=UbU, 10=social protection). +- **Monetary & FX coverage** (IFS/MFS_IR) absent from WB. +- **Cross-country comparability** improved — SNA 2008 / GFSM 2014 + uniformity reduces the "WB northern-Europe bias" criticism. +- **Editorial neutrality**: IMF is a multilateral primary source. +- **No new runtime** in the agentic sandbox — IMF access is under the + same npm / SBOM governance as every other economic data client. + Firewall allowlist is confined to IMF origins (`data.imf.org`, + `api.imf.org`, `www.imf.org`); no `pypi.org` / `files.pythonhosted.org` + egress. + +### Negative / risks + +- **IMF rate limit (~10 req / 5 s)** requires batching and back-off + discipline (implemented in `imf-client.ts` retry loop and encoded in + the `compare` subcommand of `imf-fetch.ts`). +- **WEO cadence gap** (Apr/Oct only) — mitigated by using higher- + frequency IMF datasets (IFS, FM interim, DOTS) for monthly article + types and by tagging `projectionVintage` so stale vintages surface + in audit. +- **Schema v2 cutover**: 30-day grace window keeps v1 artefacts valid; + daily audit gates the cutover on zero P1 violations for two + consecutive cycles. + +## Compliance + +- **ISO 27001:2022** — A.5.23 (cloud services), A.8.4 (source-code + access), A.8.30 (outsourced development) +- **NIST CSF 2.0** — ID.SC-01/03, PR.IP-12 (supply-chain due diligence) +- **CIS Controls v8.1** — 15.1 (service-provider inventory), 16.4 + (default-deny), 18.2 (application firewall) +- **GDPR** — not engaged: IMF data is fully public aggregate macro; + no personal data; Art 9 not triggered. +- **Threat Model** — STRIDE update captures Tampering/Spoofing of IMF + responses (mitigated by TLS 1.3 + cached JSON schema validation in + `imf-client.ts`) and DoS via IMF rate limits (mitigated by the + client's built-in 3× back-off plus `compare` batching). +- **Change Management** — Normal change per `Change_Management.md`; + no new MCP server / runtime. + +## Alternatives considered (and rejected) + +- **Replace WB entirely with IMF** — rejected. WGI governance, + environment, and long-horizon social/education are WB exclusives; + losing them would regress `KU`, `MJU`, `UbU`, `SoU` coverage. +- **Adopt `c-cf/imf-data-mcp` (Python MCP)** — *considered and + rejected*. The MCP would have added a Python / `uvx` runtime, a + third-party git dependency, and `pypi.org` / `files.pythonhosted.org` + on the firewall allowlist — all of which expand supply-chain surface + and fall outside the npm SBOM. The `scripts/imf-client.ts` + + `scripts/imf-fetch.ts` pair delivers equivalent coverage (Datamapper + for WEO, SDMX 3.0 passthrough for everything else) with zero new + runtime. +- **Wait for WB freshness to improve** — rejected. WB's WDI cadence + is not scheduled to change; the lag is structural. + +## Follow-ups + +- Phase 5 (not in the initial PR): retire WB indicators fully + superseded by IMF (`supersededBy: imf:*` flag in inventory) after a + 180-day dual-source audit window. +- Regenerate `.lock.yml` files via `gh aw compile` in CI once the + `news-*.md` frontmatter updates land. +- Add `Security_Metrics.md` KPIs: IMF data freshness median age + (target ≤ 90 days); % of look-ahead articles with IMF projection + citation (target ≥ 80 %). diff --git a/package.json b/package.json index 972254694a..8e43f860e1 100644 --- a/package.json +++ b/package.json @@ -195,5 +195,8 @@ }, "engines": { "node": ">=25" + }, + "x-external-mcp": { + "_comment": "SBOM-style manifest for MCP servers managed outside of npm dependencies (Python, Docker, HTTP). Tracked here so supply-chain reviews, Open_Source_Policy.md audits, and Change_Management.md tickets have a single source of truth. Currently EMPTY: IMF access was migrated from c-cf/imf-data-mcp (Python/uvx) to the repository's pure-TypeScript client scripts/imf-client.ts (invoked via scripts/imf-fetch.ts from the bash tool in agentic workflows) — it is now fully covered by the npm SBOM." } } diff --git a/scripts/data-transformers/load-economic-context.ts b/scripts/data-transformers/load-economic-context.ts index b4ab52d7bd..dfab9276fc 100644 --- a/scripts/data-transformers/load-economic-context.ts +++ b/scripts/data-transformers/load-economic-context.ts @@ -4,7 +4,8 @@ * produced by agentic workflows during the pre-article analysis phase. * * The loader bridges the data produced by workflow agents (which call - * World Bank / SCB MCP tools) and the HTML renderer in + * the World Bank / SCB MCP tools and the in-repo IMF TypeScript client + * via `tsx scripts/imf-fetch.ts`) and the HTML renderer in * `content-generators/economic-dashboard-section.ts`. When the JSON file * exists and contains `dataPoints` with at least one entry, the renderer * emits real `data-chart-config` Chart.js canvases; when it is missing or @@ -20,6 +21,13 @@ * Schema: * analysis/schemas/economic-data.schema.json * + * Schema v2.0 (2026-04-20) — additive: + * - `source.imf[]` accepted alongside `source.worldBank[]` / `source.scb[]` + * - `dataPoints[].provider` ('worldBank' | 'imf' | 'scb') — defaults to 'worldBank' when omitted + * - `dataPoints[].projection` boolean — marks forecast values (IMF WEO/FM) + * - `dataPoints[].projectionVintage` string — vintage tag (e.g. 'WEO-2026-04') + * v1 artefacts are still accepted unchanged; the loader fills defaults. + * * @author Hack23 AB * @license Apache-2.0 */ @@ -30,15 +38,52 @@ import type { EconomicDataPoint } from './content-generators/economic-dashboard- import { ARTICLE_TYPE_TO_ANALYSIS_SUBFOLDER } from '../analysis-references.js'; /** - * Attribution source list written by the agentic workflow when fetching - * economic context. Both sub-fields MAY be empty arrays but the keys - * MUST be present for schema stability. + * Attribution source list as it appears in the on-disk + * `economic-data.json` artefact. `imf` is optional for schema v1 + * back-compat; schema v2+ writers should emit it (the loader always + * normalises it to an array in {@link EconomicContextSource}). + */ +export interface EconomicContextSourceFile { + /** World Bank indicator IDs actually queried (e.g. `NY.GDP.MKTP.KD.ZG`). */ + worldBank: string[]; + /** SCB table IDs actually queried (e.g. `TAB1291`). */ + scb: string[]; + /** + * IMF citation strings actually queried (e.g. `WEO:NGDP_RPCH`, + * `FM:GGXWDG_NGDP`). Absent in schema v1 artefacts. + */ + imf?: string[]; +} + +/** + * Attribution source list as surfaced by {@link loadEconomicContext}. + * The loader always populates `imf` (empty array for v1 artefacts) so + * downstream consumers never need to null-check. */ export interface EconomicContextSource { /** World Bank indicator IDs actually queried (e.g. `NY.GDP.MKTP.KD.ZG`). */ worldBank: string[]; /** SCB table IDs actually queried (e.g. `TAB1291`). */ scb: string[]; + /** + * IMF citation strings actually queried (e.g. `WEO:NGDP_RPCH`, + * `FM:GGXWDG_NGDP`). Always populated as an array by the loader — + * may be empty on v1 files. + */ + imf: string[]; +} + +/** Schema v2+ provider tag on each data point. */ +export type EconomicDataProvider = 'worldBank' | 'imf' | 'scb'; + +/** Schema v2+ enriched data point adding provider + projection metadata. */ +export interface EnrichedEconomicDataPoint extends EconomicDataPoint { + /** Provider that supplied the value. Defaults to 'worldBank' for v1 artefacts. */ + provider: EconomicDataProvider; + /** True when the value is a forecast (IMF WEO/FM). */ + projection: boolean; + /** Vintage tag of the projection release (e.g. 'WEO-2026-04'). Present only when projection=true. */ + projectionVintage?: string; } /** @@ -47,7 +92,7 @@ export interface EconomicContextSource { * whether to emit real charts or fail the quality gate. */ export interface EconomicContextFile { - /** Version of the contract this file was produced against. */ + /** Version of the contract this file was produced against ('1.0' or '2.0'). */ version?: string; /** Article type slug (e.g. `committee-reports`). */ articleType?: string; @@ -55,7 +100,12 @@ export interface EconomicContextFile { date?: string; /** Policy domains detected from the source documents. */ policyDomains: string[]; - /** World Bank data points (see `EconomicDataPoint`). */ + /** + * Data points driving Chart.js canvases. In schema v2 each point MAY + * carry a `provider` / `projection` / `projectionVintage` triple; the + * loader preserves them when present and defaults missing values for + * back-compat with v1 artefacts. + */ dataPoints: EconomicDataPoint[]; /** * AI-authored commentary paragraph. MUST reference 2–3 concrete @@ -63,7 +113,7 @@ export interface EconomicContextFile { */ commentary: string; /** Attribution sources for the footer / compliance gate. */ - source: EconomicContextSource; + source: EconomicContextSourceFile; /** * Explicit opt-out for pure-process article types (e.g. realtime * monitor stories about parliamentary procedure). When `true`, @@ -83,10 +133,22 @@ export interface EconomicContextFile { * the `economic-data.json` artefact when it exists. */ export interface LoadedEconomicContext { + /** Contract version the artefact was produced against ('1.0' | '2.0'). */ + version: string; /** Policy domains to feed into `findIndicatorsForDomains`. */ policyDomains: string[]; - /** World Bank data points that drive real Chart.js canvases. */ + /** + * Data points driving real Chart.js canvases. Keeps the v1 shape for + * existing consumers; call `enrichedDataPoints` to see provider / + * projection metadata. + */ dataPoints: EconomicDataPoint[]; + /** + * Data points with v2 provider / projection metadata expanded. Always + * available — for v1 artefacts every point is `{provider: 'worldBank', + * projection: false}`. + */ + enrichedDataPoints: EnrichedEconomicDataPoint[]; /** AI commentary used as the dashboard section `summary`. */ commentary: string; /** Attribution sources. */ @@ -131,17 +193,34 @@ function isStringArray(value: unknown): value is string[] { * Type guard for a single `EconomicDataPoint`. Each point drives Chart.js * rendering so a malformed shape here surfaces as a blank/broken chart * downstream — validate aggressively. + * + * Schema v2 additions (`provider`, `projection`, `projectionVintage`) are + * accepted but optional — when present their types are validated. */ function isEconomicDataPoint(value: unknown): value is EconomicDataPoint { if (!isRecord(value)) return false; - return ( - typeof value['countryCode'] === 'string' && - typeof value['countryName'] === 'string' && - typeof value['indicatorId'] === 'string' && - typeof value['date'] === 'string' && - typeof value['value'] === 'number' && - Number.isFinite(value['value']) - ); + if ( + typeof value['countryCode'] !== 'string' || + typeof value['countryName'] !== 'string' || + typeof value['indicatorId'] !== 'string' || + typeof value['date'] !== 'string' || + typeof value['value'] !== 'number' || + !Number.isFinite(value['value']) + ) { + return false; + } + // Schema v2 optional fields: only reject when present with the wrong type. + if ('provider' in value && value['provider'] !== undefined) { + const p = value['provider']; + if (p !== 'worldBank' && p !== 'imf' && p !== 'scb') return false; + } + if ('projection' in value && value['projection'] !== undefined && typeof value['projection'] !== 'boolean') { + return false; + } + if ('projectionVintage' in value && value['projectionVintage'] !== undefined && typeof value['projectionVintage'] !== 'string') { + return false; + } + return true; } /** @@ -165,6 +244,9 @@ function isOptionalFieldOfType( * Validates both the top-level shape AND the element shapes for * `dataPoints`, `policyDomains`, and `source.*`, plus optional-field * types (`version`, `articleType`, `date`, `skip`, `skipReason`). + * + * Schema v2 addition: `source.imf[]` is accepted but optional for v1 + * back-compat. When present it must be a string array. */ function isEconomicContextFile(value: unknown): value is EconomicContextFile { if (!isRecord(value)) return false; @@ -175,6 +257,8 @@ function isEconomicContextFile(value: unknown): value is EconomicContextFile { if (!isRecord(v['source'])) return false; const s = v['source']; if (!isStringArray(s['worldBank']) || !isStringArray(s['scb'])) return false; + // Schema v2 optional: imf[] string array if present. + if ('imf' in s && s['imf'] !== undefined && !isStringArray(s['imf'])) return false; // Optional fields: present only when typed correctly. if (!isOptionalFieldOfType(v, 'version', 'string')) return false; if (!isOptionalFieldOfType(v, 'articleType', 'string')) return false; @@ -222,13 +306,37 @@ export function loadEconomicContext( if (!isEconomicContextFile(parsed)) return null; const file = parsed; + // Source object — always populate `imf` as an array (empty on v1 back-compat). + const imfSources = file.source.imf ? [...file.source.imf] : []; + + // Enriched data points — default missing provider/projection for v1. + const enrichedDataPoints: EnrichedEconomicDataPoint[] = file.dataPoints.map((dp) => { + const raw = dp as EconomicDataPoint & { + provider?: EconomicDataProvider; + projection?: boolean; + projectionVintage?: string; + }; + const enriched: EnrichedEconomicDataPoint = { + ...dp, + provider: raw.provider ?? 'worldBank', + projection: raw.projection === true, + }; + if (typeof raw.projectionVintage === 'string' && raw.projectionVintage.length > 0) { + enriched.projectionVintage = raw.projectionVintage; + } + return enriched; + }); + return { + version: typeof file.version === 'string' ? file.version : '1.0', policyDomains: [...file.policyDomains], dataPoints: [...file.dataPoints], + enrichedDataPoints, commentary: file.commentary, source: { worldBank: [...file.source.worldBank], scb: [...file.source.scb], + imf: imfSources, }, sourcePath: path.relative(rootDir, filePath) || filePath, skip: file.skip === true, diff --git a/scripts/imf-client.ts b/scripts/imf-client.ts new file mode 100644 index 0000000000..450ea5435f --- /dev/null +++ b/scripts/imf-client.ts @@ -0,0 +1,357 @@ +/** + * @module IMF/Client + * @description TypeScript REST client for IMF public data APIs. + * + * Covers two transports, both public and unauthenticated: + * + * 1. **Datamapper JSON** (`https://www.imf.org/external/datamapper/api/v1`) + * — simple JSON, best for World Economic Outlook (WEO) headline + * indicators and projections. Matches the ergonomics of our existing + * `world-bank-client.ts` pattern. + * + * 2. **SDMX 3.0** (`https://api.imf.org/external/sdmx/3.0`) — full IMF + * catalogue (IFS, BOP, GFS_COFOG, FM, MFS_*, FSIC, DOTS, PCPS). The + * `sdmxFetch()` method is a thin passthrough for callers that need + * broader coverage than the Datamapper WEO surface. + * + * The client mirrors the safety posture of `world-bank-client.ts`: + * - deterministic timeouts + * - exponential back-off on 5xx / 429 + * - no credentials stored or transmitted (all IMF data is public) + * + * Rate-limit discipline: IMF advertises ~10 requests / 5 s. The client + * defaults to `maxRetries=2` and delays 1 s on the first retry, 2 s on + * the second; consumers that batch in tight loops should additionally + * insert their own cooperative throttling. + * + * @author Hack23 AB + * @license Apache-2.0 + * @see https://data.imf.org/api/documentation + */ + +import { toDatamapperCode } from './imf-codes.js'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * A single IMF data point. Shape mirrors `WorldBankDataPoint` so that the + * provider-agnostic `economic-context` helpers can consume either source + * interchangeably. + */ +export interface ImfDataPoint { + readonly countryCode: string; + readonly countryName: string; + readonly indicatorId: string; + readonly indicatorName: string; + readonly date: string; + readonly value: number; + /** True when the value is a projection (future year in the release vintage). */ + readonly projection: boolean; + /** Release vintage tag (e.g. 'WEO-2026-04'). Present for projection-bearing releases. */ + readonly projectionVintage?: string; + /** Provider tag — always 'imf' for this client. */ + readonly provider: 'imf'; +} + +/** Client configuration */ +export interface ImfClientConfig { + /** Override for the Datamapper base URL (for testing). */ + readonly datamapperBaseURL?: string; + /** Override for the SDMX 3.0 base URL (for testing). */ + readonly sdmxBaseURL?: string; + /** Request timeout in ms. Default 15_000. */ + readonly timeout?: number; + /** Max retry count for transient failures. Default 2. */ + readonly maxRetries?: number; + /** + * Optional WEO vintage tag to stamp on every projection returned by + * `getWeoIndicator`. Defaults to the current WEO cycle — update in + * April / October when the IMF publishes a new flagship release. + */ + readonly weoVintage?: string; +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const DEFAULT_DATAMAPPER_BASE_URL = 'https://www.imf.org/external/datamapper/api/v1'; +const DEFAULT_SDMX_BASE_URL = 'https://api.imf.org/external/sdmx/3.0'; +const DEFAULT_TIMEOUT = 15_000; +const DEFAULT_MAX_RETRIES = 2; +/** Default vintage. Update in April / October when the WEO re-releases. */ +const DEFAULT_WEO_VINTAGE = 'WEO-2026-04'; + +/** + * Canonical IMF indicator IDs used by Riksdagsmonitor articles. Each + * entry is addressable via the Datamapper (`/{indicatorId}`) — the + * WEO subset — or SDMX 3.0 via `ImfClient.sdmxFetch()` (database='WEO', + * indicator=code). + */ +export const IMF_WEO_INDICATORS = { + /** Real GDP growth, annual % change — headline macro indicator. */ + gdpGrowth: 'NGDP_RPCH', + /** Nominal GDP, current USD. */ + gdpUsd: 'NGDPD', + /** GDP per capita, current USD. */ + gdpPerCapita: 'NGDPDPC', + /** Inflation, average consumer prices, annual % change. */ + inflationCpi: 'PCPIPCH', + /** Unemployment rate, % of total labor force. */ + unemployment: 'LUR', + /** General government gross debt, % of GDP. */ + generalGovGrossDebt: 'GGXWDG_NGDP', + /** General government revenue, % of GDP. */ + generalGovRevenue: 'GGR_NGDP', + /** General government total expenditure, % of GDP. */ + generalGovExpenditure: 'GGX_NGDP', + /** General government net lending / borrowing, % of GDP. */ + generalGovBalance: 'GGXCNL_NGDP', + /** Current account balance, % of GDP. */ + currentAccountBalance: 'BCA_NGDPD', + /** Volume of exports of goods and services, annual % change. */ + exportsVolumeGrowth: 'TX_RPCH', + /** Population (millions). */ + population: 'LP', +} as const; + +/** Commonly-referenced IMF Fiscal Monitor (FM) indicators. */ +export const IMF_FM_INDICATORS = { + /** General government gross debt, % of GDP (FM vintage — may differ slightly from WEO). */ + generalGovGrossDebtFm: 'GGXWDG_NGDP', + /** General government primary balance, % of GDP. */ + primaryBalance: 'GGXONLB_NGDP', +} as const; + +// --------------------------------------------------------------------------- +// Raw Datamapper response shape +// --------------------------------------------------------------------------- + +/** Shape of the IMF Datamapper JSON response (partial). */ +interface DatamapperResponse { + values?: { + [indicatorId: string]: { + [countryCode: string]: { + [year: string]: number | string | null; + }; + }; + }; +} + +// --------------------------------------------------------------------------- +// ImfClient class +// --------------------------------------------------------------------------- + +/** + * HTTP client for IMF public data APIs. + * + * Primary surface: + * - `getWeoIndicator(iso3, weoCode, years?)` — fetch time series for a + * country from the WEO Datamapper + * - `compareCountriesWeo(codes, weoCode)` — latest value across a peer + * set, ideal for Nordic comparisons + * - `getLatestWeoIndicator(iso3, weoCode)` — most recent data point + * + * The SDMX 3.0 path is exposed via `sdmxFetch()` for advanced use + * (IFS / BOP / FM / GFS / DOTS / MFS / FSIC / PCPS). Agentic article + * workflows invoke this client through the `scripts/imf-fetch.ts` CLI + * via the `bash` tool (commands: `weo`, `compare`, `sdmx`, + * `list-indicators`). + */ +export class ImfClient { + readonly datamapperBaseURL: string; + readonly sdmxBaseURL: string; + readonly timeout: number; + readonly maxRetries: number; + readonly weoVintage: string; + + constructor(config: ImfClientConfig = {}) { + this.datamapperBaseURL = config.datamapperBaseURL ?? DEFAULT_DATAMAPPER_BASE_URL; + this.sdmxBaseURL = config.sdmxBaseURL ?? DEFAULT_SDMX_BASE_URL; + this.timeout = config.timeout ?? DEFAULT_TIMEOUT; + this.maxRetries = config.maxRetries ?? DEFAULT_MAX_RETRIES; + this.weoVintage = config.weoVintage ?? DEFAULT_WEO_VINTAGE; + } + + /** + * Fetch a WEO time series for one country. + * + * The Datamapper returns all years the IMF has for that indicator / + * country, mixing history and projections. Projection years are + * determined relative to the current calendar year: any year greater + * than the current year is flagged `projection: true`. + * + * @param iso3 ISO-3 alpha-3 country code (Datamapper native format) + * @param weoCode WEO indicator code (see `IMF_WEO_INDICATORS`) + * @param years How many most-recent years to return (default 10) + */ + async getWeoIndicator( + iso3: string, + weoCode: string, + years = 10, + ): Promise { + if (years < 1 || !Number.isInteger(years)) { + throw new Error(`getWeoIndicator: 'years' must be a positive integer, got ${years}`); + } + const code = toDatamapperCode(iso3); + // IMF Datamapper URL pattern: /{indicator}/{country} + const url = `${this.datamapperBaseURL}/${encodeURIComponent(weoCode)}/${encodeURIComponent(code)}`; + const raw = (await this.fetchWithRetry(url)) as DatamapperResponse; + + const indicatorNode = raw?.values?.[weoCode]; + if (!indicatorNode) return []; + const countryNode = indicatorNode[code]; + if (!countryNode) return []; + + const currentYear = new Date().getUTCFullYear(); + const points: ImfDataPoint[] = []; + for (const [year, rawValue] of Object.entries(countryNode)) { + // Defensive: IMF can emit null / 'n/a' / undefined for missing + // observations. `Number(null)` === 0, which would silently inject + // a bogus zero into the chart — gate on explicit null/undefined + // and then on NaN from string coercion. + if (rawValue === null || rawValue === undefined) continue; + const numeric = typeof rawValue === 'number' ? rawValue : Number(rawValue); + if (!Number.isFinite(numeric)) continue; + const yearInt = Number.parseInt(year, 10); + if (!Number.isFinite(yearInt)) continue; + const isProjection = yearInt > currentYear; + const dp: ImfDataPoint = { + countryCode: code, + countryName: code, // Datamapper does not return the display name; callers overlay this from COUNTRY_NAMES_EN + indicatorId: weoCode, + indicatorName: weoCode, + date: year, + value: numeric, + projection: isProjection, + provider: 'imf', + ...(isProjection ? { projectionVintage: this.weoVintage } : {}), + }; + points.push(dp); + } + + // Sort by year desc, then truncate to the requested horizon. + points.sort((a, b) => Number.parseInt(b.date, 10) - Number.parseInt(a.date, 10)); + return points.slice(0, years); + } + + /** + * Convenience: fetch the latest available data point for one country. + * Returns the most recent historical (non-projection) value when + * available, otherwise the most recent projection. + */ + async getLatestWeoIndicator( + iso3: string, + weoCode: string, + ): Promise { + const series = await this.getWeoIndicator(iso3, weoCode, 15); + if (series.length === 0) return null; + const history = series.filter((p) => !p.projection); + return history[0] ?? series[0]; + } + + /** + * Compare an indicator across multiple countries. Fetches sequentially + * to respect IMF rate limits. Unknown / failed countries map to `null`. + * + * @param iso3Codes ISO-3 country codes (e.g. ['SWE', 'DNK', 'NOR']) + * @param weoCode WEO indicator code + */ + async compareCountriesWeo( + iso3Codes: readonly string[], + weoCode: string, + ): Promise> { + const out = new Map(); + for (const code of iso3Codes) { + try { + const latest = await this.getLatestWeoIndicator(code, weoCode); + out.set(code, latest); + } catch { + out.set(code, null); + } + } + return out; + } + + /** + * Low-level SDMX 3.0 passthrough. Returns the raw JSON from the IMF + * SDMX endpoint. Consumers are responsible for interpreting the SDMX + * envelope. + * + * @param path URL path starting with `/data/...` or `/structure/...` + */ + async sdmxFetch(pathWithQuery: string): Promise { + const separator = pathWithQuery.startsWith('/') ? '' : '/'; + const url = `${this.sdmxBaseURL}${separator}${pathWithQuery}`; + return this.fetchWithRetry(url, 0, { Accept: 'application/vnd.sdmx.data+json;version=2.0.0' }); + } + + // ----------------------------------------------------------------------- + // Internal helpers + // ----------------------------------------------------------------------- + + private async fetchWithRetry( + url: string, + attempt = 0, + extraHeaders: Record = {}, + ): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), this.timeout); + try { + const response = await fetch(url, { + signal: controller.signal, + headers: { Accept: 'application/json', ...extraHeaders }, + }); + + if (response.status === 429 && attempt < this.maxRetries) { + // Respect IMF advertised rate limit (~10 req / 5 s) with an + // exponential back-off: 1 s → 2 s → 4 s (matches THREAT_MODEL.md + // TB-6a). Honour a `Retry-After` header (delta-seconds) when the + // server supplies one, capped at 30 s to avoid pathological waits. + const retryAfter = response.headers.get('retry-after'); + let delay = 1_000 * 2 ** attempt; + if (retryAfter) { + const retryAfterSec = Number.parseInt(retryAfter, 10); + if (Number.isFinite(retryAfterSec) && retryAfterSec > 0) { + delay = Math.min(retryAfterSec * 1_000, 30_000); + } + } + clearTimeout(timeoutId); + await new Promise((resolve) => setTimeout(resolve, delay)); + return this.fetchWithRetry(url, attempt + 1, extraHeaders); + } + + if (!response.ok) { + throw new Error(`IMF API error: ${response.status} ${response.statusText} for ${url}`); + } + + return await response.json(); + } catch (error) { + if (attempt < this.maxRetries) { + // Network / abort path: same exponential schedule (1 s → 2 s → 4 s). + const delay = 1_000 * 2 ** attempt; + await new Promise((resolve) => setTimeout(resolve, delay)); + return this.fetchWithRetry(url, attempt + 1, extraHeaders); + } + throw error; + } finally { + clearTimeout(timeoutId); + } + } +} + +// --------------------------------------------------------------------------- +// Singleton +// --------------------------------------------------------------------------- + +let defaultImfClient: ImfClient | null = null; + +/** Get or create the default singleton `ImfClient`. */ +export function getDefaultImfClient(): ImfClient { + if (!defaultImfClient) { + defaultImfClient = new ImfClient(); + } + return defaultImfClient; +} diff --git a/scripts/imf-codes.ts b/scripts/imf-codes.ts new file mode 100644 index 0000000000..dd56189578 --- /dev/null +++ b/scripts/imf-codes.ts @@ -0,0 +1,135 @@ +/** + * @module IMF/Codes + * @description Canonical country / area code mappings for the IMF data + * ecosystem (`data.imf.org`). + * + * The IMF exposes different country-code conventions in different + * datasets: + * - **Datamapper** (`www.imf.org/external/datamapper/api/v1/*`) uses + * ISO-3 alpha-3 codes (SWE, DNK, NOR, FIN, DEU, USA). + * - **SDMX 3.0** (`data.imf.org`) is dataset-dependent: WEO uses ISO-3; + * IFS typically uses IMF area codes (3-digit numeric) from code list + * `CL_AREA_*`; Government Finance Statistics (GFS_COFOG) uses the + * same 3-digit numeric area codes. + * + * This module exposes: + * - `ISO3_TO_IMF_AREA` — ISO3 → IMF 3-digit area code (IFS/GFS/BOP) + * - `IMF_AREA_TO_ISO3` — inverse lookup + * - `toDatamapperCode(iso3)` — passthrough (Datamapper uses ISO3) + * - `toImfAreaCode(iso3)` — throws if mapping is unknown (fail-loud + * avoids silent data loss — see the plan's risk matrix) + * - `COUNTRY_CODES` — Nordic + EU peer set used by the article + * dashboards (SWE, DNK, NOR, FIN, DEU plus aggregates) + * + * The canonical IMF AREA reference is published in the SDMX code list + * `CL_AREA` at data.imf.org; this module carries only the subset + * Riksdagsmonitor queries. Extend as new datasets are onboarded. + * + * @author Hack23 AB + * @license Apache-2.0 + */ + +/** + * ISO 3166-1 alpha-3 codes for Sweden and the comparison peer set used + * by Riksdagsmonitor article dashboards. + */ +export const COUNTRY_CODES = { + sweden: 'SWE', + denmark: 'DNK', + norway: 'NOR', + finland: 'FIN', + germany: 'DEU', + /** European Union (WEO/FM aggregate — `EU` in IMF WEO codelist) */ + europeanUnion: 'EU', + /** Euro Area (WEO aggregate — `EURO` in IMF WEO codelist) */ + euroArea: 'EURO', +} as const; + +/** + * ISO3 → IMF 3-digit AREA code mapping for the peer set and a selection + * of G7 comparators. Values from the IMF `CL_AREA` code list on + * data.imf.org. The table is intentionally conservative — extend only + * after a successful `imf_get_parameter_codes(database_id, "COUNTRY")` + * round-trip against the dataset in question (some datasets use + * different country code lists). + */ +export const ISO3_TO_IMF_AREA: Readonly> = Object.freeze({ + SWE: '144', + DNK: '128', + NOR: '142', + FIN: '172', + DEU: '134', + USA: '111', + GBR: '112', + FRA: '132', + ITA: '136', + JPN: '158', + CAN: '156', + NLD: '138', + POL: '964', + ESP: '184', +}); + +/** + * Inverse of `ISO3_TO_IMF_AREA`. Useful when SDMX responses include the + * numeric area code and the consumer (article dashboard) needs ISO3 for + * chart country labelling. + */ +export const IMF_AREA_TO_ISO3: Readonly> = Object.freeze( + Object.fromEntries(Object.entries(ISO3_TO_IMF_AREA).map(([iso3, area]) => [area, iso3])), +); + +/** + * Datamapper uses ISO-3 alpha-3 codes directly. This function exists to + * keep consumer call sites dataset-agnostic — pass ISO3 in, get the + * right code back for the target endpoint. + */ +export function toDatamapperCode(iso3: string): string { + const upper = iso3.trim().toUpperCase(); + // The Datamapper accepts EU / EURO aggregates as-is. ISO-3 regular + // states pass through unchanged. + return upper; +} + +/** + * Convert an ISO-3 alpha-3 code to the 3-digit IMF AREA code used in + * SDMX 3.0 (IFS/GFS/BOP datasets). Throws when the mapping is unknown + * to surface silent data loss early. + */ +export function toImfAreaCode(iso3: string): string { + const upper = iso3.trim().toUpperCase(); + const area = ISO3_TO_IMF_AREA[upper]; + if (!area) { + throw new Error( + `toImfAreaCode: no IMF area code mapping for ISO-3 '${iso3}'. ` + + `Add the entry to ISO3_TO_IMF_AREA after confirming with ` + + `imf_get_parameter_codes(database_id, 'COUNTRY').`, + ); + } + return area; +} + +/** Returns true when the code is a known ISO-3 we can map to IMF AREA. */ +export function isKnownIso3(iso3: string): boolean { + return Object.prototype.hasOwnProperty.call(ISO3_TO_IMF_AREA, iso3.trim().toUpperCase()); +} + +/** Localised country name map used by the article dashboards. English baseline. */ +export const COUNTRY_NAMES_EN: Readonly> = Object.freeze({ + SWE: 'Sweden', + DNK: 'Denmark', + NOR: 'Norway', + FIN: 'Finland', + DEU: 'Germany', + USA: 'United States', + GBR: 'United Kingdom', + FRA: 'France', + ITA: 'Italy', + JPN: 'Japan', + CAN: 'Canada', + NLD: 'Netherlands', + POL: 'Poland', + ESP: 'Spain', + EU: 'European Union', + EURO: 'Euro Area', +}); diff --git a/scripts/imf-context.ts b/scripts/imf-context.ts new file mode 100644 index 0000000000..af4e1c81d1 --- /dev/null +++ b/scripts/imf-context.ts @@ -0,0 +1,245 @@ +/** + * @module IMF/Context + * @description Policy-area → IMF indicator mapping, mirroring the surface of + * `world-bank-context.ts`. + * + * Where World Bank data lags 12–24 months, IMF data (WEO April/October + * cycle; Fiscal Monitor; IFS; GFS_COFOG) leads. The agentic workflows + * use this module at article-authoring time to pick the correct IMF + * indicator for each Riksdag committee's policy area and to stamp + * projections with the right vintage. + * + * Scope: narrow, code-only mapping for the indicators currently used by + * the four "look-ahead" article types (week-ahead, month-ahead, + * weekly-review, monthly-review) plus the macro-adjacent daily types + * (committee-reports, propositions, motions, evening-analysis). For + * wider discovery, callers should use `ImfClient.sdmxFetch()` (see + * `scripts/imf-client.ts`) directly against the SDMX 3.0 endpoint, or + * invoke the `scripts/imf-fetch.ts sdmx --path ...` CLI from agentic + * workflows. + * + * @author Hack23 AB + * @license Apache-2.0 + */ + +import { COUNTRY_CODES, COUNTRY_NAMES_EN } from './imf-codes.js'; +import { IMF_WEO_INDICATORS, IMF_FM_INDICATORS } from './imf-client.js'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** Database family an indicator belongs to. */ +export type ImfDatabase = 'WEO' | 'FM' | 'IFS' | 'BOP_AGG' | 'GFS_COFOG' | 'MFS_IR'; + +/** An IMF economic indicator mapped to a Swedish policy area. */ +export interface ImfIndicatorContext { + /** IMF database the indicator lives in (`WEO`, `FM`, ...). */ + readonly database: ImfDatabase; + /** IMF indicator code native to that database. */ + readonly indicatorId: string; + /** Human-readable name (English). */ + readonly name: string; + /** Concise description for article commentary. */ + readonly description: string; + /** Swedish policy areas this indicator relates to. */ + readonly policyAreas: readonly string[]; + /** Relevant Riksdag committees. */ + readonly committees: readonly string[]; + /** Unit of measurement (e.g. '% of GDP', 'Annual % change'). */ + readonly unit: string; + /** Whether the IMF publishes projections beyond the current year. */ + readonly publishesProjections: boolean; +} + +// --------------------------------------------------------------------------- +// Policy-area → IMF indicator mapping +// --------------------------------------------------------------------------- + +/** + * Canonical IMF indicator catalogue used by Riksdagsmonitor article + * workflows. Only the subset actively consumed by article templates — + * extend deliberately; keep in sync with + * `analysis/economic-indicators-inventory.json`. + */ +export const IMF_INDICATORS: readonly ImfIndicatorContext[] = Object.freeze([ + // --- Headline macro (FiU Finance Committee) --- + { + database: 'WEO', + indicatorId: IMF_WEO_INDICATORS.gdpGrowth, + name: 'Real GDP growth', + description: + 'Year-over-year percent change in real GDP. Headline macro indicator; projected ≥ 5 years in each WEO cycle.', + policyAreas: ['fiscal policy', 'macro economy', 'growth'], + committees: ['FiU', 'SkU', 'NU'], + unit: 'Annual % change', + publishesProjections: true, + }, + { + database: 'WEO', + indicatorId: IMF_WEO_INDICATORS.gdpPerCapita, + name: 'GDP per capita', + description: 'Nominal GDP per capita in current USD. Useful for cross-country peer comparisons.', + policyAreas: ['macro economy', 'living standards'], + committees: ['FiU', 'AU', 'SoU'], + unit: 'Current USD', + publishesProjections: true, + }, + { + database: 'WEO', + indicatorId: IMF_WEO_INDICATORS.inflationCpi, + name: 'Inflation (CPI, average)', + description: + 'Average consumer price inflation, annual % change. Primary monetary-policy reference; projected to T+5.', + policyAreas: ['monetary policy', 'inflation'], + committees: ['FiU'], + unit: 'Annual % change', + publishesProjections: true, + }, + { + database: 'WEO', + indicatorId: IMF_WEO_INDICATORS.unemployment, + name: 'Unemployment rate', + description: 'Unemployment as % of total labor force, WEO definition; projected to T+5.', + policyAreas: ['labor market', 'employment'], + committees: ['AU', 'SoU'], + unit: '% of labor force', + publishesProjections: true, + }, + + // --- Fiscal (SkU, FiU) — critical upgrade over WB --- + { + database: 'WEO', + indicatorId: IMF_WEO_INDICATORS.generalGovGrossDebt, + name: 'General government gross debt', + description: + 'Debt/GDP ratio on a general-government (EDP) basis. Superior to World Bank GC.DOD.TOTL.GD.ZS for EU policy discussion because it follows GFSM 2014.', + policyAreas: ['fiscal policy', 'debt', 'EU stability and growth pact'], + committees: ['FiU', 'SkU'], + unit: '% of GDP', + publishesProjections: true, + }, + { + database: 'WEO', + indicatorId: IMF_WEO_INDICATORS.generalGovBalance, + name: 'General government net lending / borrowing', + description: 'Headline fiscal balance (surplus +, deficit −); projected to T+5.', + policyAreas: ['fiscal policy', 'budget'], + committees: ['FiU', 'SkU'], + unit: '% of GDP', + publishesProjections: true, + }, + { + database: 'WEO', + indicatorId: IMF_WEO_INDICATORS.generalGovRevenue, + name: 'General government revenue', + description: 'Tax and non-tax revenue as % of GDP; projected to T+5.', + policyAreas: ['fiscal policy', 'taxation'], + committees: ['SkU', 'FiU'], + unit: '% of GDP', + publishesProjections: true, + }, + { + database: 'WEO', + indicatorId: IMF_WEO_INDICATORS.generalGovExpenditure, + name: 'General government expenditure', + description: 'Total government spending as % of GDP; projected to T+5.', + policyAreas: ['fiscal policy', 'public spending'], + committees: ['FiU'], + unit: '% of GDP', + publishesProjections: true, + }, + { + database: 'FM', + indicatorId: IMF_FM_INDICATORS.primaryBalance, + name: 'General government primary balance', + description: 'Fiscal balance excluding interest payments (Fiscal Monitor vintage).', + policyAreas: ['fiscal policy', 'debt sustainability'], + committees: ['FiU', 'SkU'], + unit: '% of GDP', + publishesProjections: true, + }, + + // --- External sector (NU, UU) --- + { + database: 'WEO', + indicatorId: IMF_WEO_INDICATORS.currentAccountBalance, + name: 'Current account balance', + description: 'Net external position (trade + primary + secondary income); projected to T+5.', + policyAreas: ['external sector', 'trade'], + committees: ['NU', 'UU', 'FiU'], + unit: '% of GDP', + publishesProjections: true, + }, + { + database: 'WEO', + indicatorId: IMF_WEO_INDICATORS.exportsVolumeGrowth, + name: 'Exports volume growth', + description: 'Real exports of goods and services, annual % change.', + policyAreas: ['trade', 'export performance'], + committees: ['NU', 'UU'], + unit: 'Annual % change', + publishesProjections: true, + }, + + // --- Demographics (SoU) --- + { + database: 'WEO', + indicatorId: IMF_WEO_INDICATORS.population, + name: 'Population', + description: 'Total population (millions), WEO definition.', + policyAreas: ['demographics'], + committees: ['SoU', 'SfU'], + unit: 'Millions', + publishesProjections: true, + }, +]); + +// --------------------------------------------------------------------------- +// Lookups +// --------------------------------------------------------------------------- + +/** Find indicators relevant to one or more policy domains (case-insensitive). */ +export function findImfIndicatorsForDomains( + policyDomains: readonly string[], +): readonly ImfIndicatorContext[] { + if (policyDomains.length === 0) return []; + const lowered = policyDomains.map((d) => d.toLowerCase()); + return IMF_INDICATORS.filter((indicator) => + indicator.policyAreas.some((area) => + lowered.some((q) => area.toLowerCase().includes(q) || q.includes(area.toLowerCase())), + ), + ); +} + +/** Find indicators relevant to a Riksdag committee (e.g. 'FiU'). */ +export function findImfIndicatorsForCommittee( + committee: string, +): readonly ImfIndicatorContext[] { + const upper = committee.toUpperCase(); + return IMF_INDICATORS.filter((indicator) => + indicator.committees.some((c) => c.toUpperCase() === upper), + ); +} + +/** Default peer-country set for Nordic + DE comparisons. */ +export const IMF_NORDIC_PEERS = Object.freeze([ + COUNTRY_CODES.sweden, + COUNTRY_CODES.denmark, + COUNTRY_CODES.norway, + COUNTRY_CODES.finland, + COUNTRY_CODES.germany, +] as const); + +/** Look up a human-readable country name (English) by ISO-3 code. */ +export function imfCountryNameEn(iso3: string): string { + return COUNTRY_NAMES_EN[iso3.toUpperCase()] ?? iso3.toUpperCase(); +} + +/** + * Build the `source.imf[]` citation string for an indicator. Format: + * `DATABASE:INDICATOR_ID` (e.g. `WEO:NGDP_RPCH`, `FM:GGXWDG_NGDP`). + */ +export function imfCitation(database: ImfDatabase, indicatorId: string): string { + return `${database}:${indicatorId}`; +} diff --git a/scripts/imf-fetch.ts b/scripts/imf-fetch.ts new file mode 100644 index 0000000000..796be3c9e2 --- /dev/null +++ b/scripts/imf-fetch.ts @@ -0,0 +1,205 @@ +#!/usr/bin/env tsx +/** + * @module scripts/imf-fetch + * @description Thin CLI wrapper around {@link ImfClient} for agentic workflows. + * + * Agentic workflows (see `.github/workflows/news-*.md`) invoke this script + * through the `bash` tool instead of going through an MCP server. This + * keeps the IMF integration under pure-TypeScript / npm governance — + * identical to `world-bank-client.ts` and `scb-client.ts` — with no Python + * or `uvx` runtime on the firewall allowlist. + * + * ## Usage + * + * # Fetch a WEO time series for one country (default: last 10 years): + * tsx scripts/imf-fetch.ts weo --country SWE --indicator NGDP_RPCH [--years 15] [--persist] + * + * # Fetch a WEO latest point across the Nordic + DE peer set: + * tsx scripts/imf-fetch.ts compare --indicator GGXWDG_NGDP --countries SWE,DNK,NOR,FIN,DEU + * + * # Low-level SDMX 3.0 passthrough (for IFS / BOP / FM / GFS / DOTS): + * tsx scripts/imf-fetch.ts sdmx --path "/data/IMF.STA,CPI,4.0.0/M.SE.PCPI_IX?startPeriod=2024-01" [--persist] + * + * `--persist` writes the raw response under `analysis/data/imf/{indicator}/{country}.json` + * with sidecar provenance (mcpTool=`imf-ts-client`, `database`, `projectionVintage`) + * via {@link persistIMFData}. Outputs JSON to stdout regardless. + * + * ## Exit codes + * + * 0 — success (JSON written to stdout, optionally persisted) + * 1 — runtime / network / validation error (human-readable message to stderr) + * 2 — bad CLI arguments + */ + +import { ImfClient, IMF_WEO_INDICATORS, IMF_FM_INDICATORS } from './imf-client.js'; +import { persistIMFData } from './parliamentary-data/data-persistence.js'; + +// --------------------------------------------------------------------------- +// CLI argument parsing +// --------------------------------------------------------------------------- + +interface ParsedArgs { + readonly command: 'weo' | 'compare' | 'sdmx' | 'list-indicators' | 'help'; + readonly flags: ReadonlyMap; + readonly booleans: ReadonlySet; +} + +const HELP = `tsx scripts/imf-fetch.ts [flags] + +Commands: + weo Fetch a WEO time series for one country + compare Fetch the latest WEO value across several countries + sdmx Low-level SDMX 3.0 passthrough (IFS / BOP / FM / GFS / DOTS) + list-indicators Print the built-in WEO + FM indicator catalog + help Show this message + +Common flags: + --country ISO-3 country code (e.g. SWE) + --countries Comma-separated ISO-3 country codes + --indicator IMF indicator code (e.g. NGDP_RPCH, PCPIPCH, LUR) + --years Number of years (weo, default 10) + --path SDMX URL path (sdmx) + --persist Write the response under analysis/data/imf/ + --database Provenance override (default WEO for weo/compare) +`; + +function parseArgs(argv: readonly string[]): ParsedArgs { + const command = (argv[0] ?? 'help') as ParsedArgs['command']; + const flags = new Map(); + const booleans = new Set(); + for (let i = 1; i < argv.length; i++) { + const token = argv[i]; + if (!token.startsWith('--')) continue; + const key = token.slice(2); + const next = argv[i + 1]; + if (next !== undefined && !next.startsWith('--')) { + flags.set(key, next); + i++; + } else { + booleans.add(key); + } + } + return { command, flags, booleans }; +} + +function requireFlag(flags: ReadonlyMap, key: string): string { + const v = flags.get(key); + if (!v) { + process.stderr.write(`imf-fetch: missing required flag --${key}\n`); + process.exit(2); + } + return v; +} + +// --------------------------------------------------------------------------- +// Commands +// --------------------------------------------------------------------------- + +async function runWeo(flags: ReadonlyMap, booleans: ReadonlySet): Promise { + const country = requireFlag(flags, 'country').toUpperCase(); + const indicator = requireFlag(flags, 'indicator'); + const years = Number.parseInt(flags.get('years') ?? '10', 10); + if (!Number.isInteger(years) || years < 1) { + process.stderr.write(`imf-fetch: --years must be a positive integer, got ${flags.get('years')}\n`); + process.exit(2); + } + + const client = new ImfClient(); + const series = await client.getWeoIndicator(country, indicator, years); + const payload = { indicator, country, years, dataPoints: series }; + process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); + + if (booleans.has('persist')) { + const vintage = series.find((p) => p.projectionVintage)?.projectionVintage; + persistIMFData(indicator, country, payload, { + database: flags.get('database') ?? 'WEO', + ...(vintage ? { projectionVintage: vintage } : {}), + }); + } +} + +async function runCompare(flags: ReadonlyMap, booleans: ReadonlySet): Promise { + const countriesRaw = requireFlag(flags, 'countries'); + const indicator = requireFlag(flags, 'indicator'); + const countries = countriesRaw.split(',').map((c) => c.trim().toUpperCase()).filter(Boolean); + if (countries.length === 0) { + process.stderr.write('imf-fetch: --countries is empty\n'); + process.exit(2); + } + + const client = new ImfClient(); + const results = await client.compareCountriesWeo(countries, indicator); + const byCountry: Record = {}; + results.forEach((point, code) => { + byCountry[code] = point; + }); + const payload = { indicator, countries, results: byCountry }; + process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); + + if (booleans.has('persist')) { + for (const [code, point] of results) { + persistIMFData(indicator, code, { indicator, country: code, dataPoint: point }, { + database: flags.get('database') ?? 'WEO', + ...(point?.projectionVintage ? { projectionVintage: point.projectionVintage } : {}), + }); + } + } +} + +async function runSdmx(flags: ReadonlyMap, booleans: ReadonlySet): Promise { + const pathWithQuery = requireFlag(flags, 'path'); + const client = new ImfClient(); + const raw = await client.sdmxFetch(pathWithQuery); + process.stdout.write(`${JSON.stringify(raw, null, 2)}\n`); + + if (booleans.has('persist')) { + // Derive a reasonable cache key: prefer explicit --indicator / --country + // flags; otherwise fall back to the second-to-last SDMX path segment + // (typically the dataflow ID, e.g. ".../IMF.STA,CPI,4.0.0/M.SE.PCPI_IX" + // → "IMF.STA,CPI,4.0.0"). This is a pragmatic heuristic — not a hash — + // so different SDMX queries that share a dataflow will share a cache + // slot. Pass `--indicator` / `--country` explicitly when collision-free + // caching matters. + const indicator = flags.get('indicator') ?? pathWithQuery.split('/').slice(-2)[0] ?? 'sdmx'; + const country = flags.get('country') ?? 'all'; + persistIMFData(indicator, country, raw, { + database: flags.get('database') ?? 'SDMX', + }); + } +} + +function runListIndicators(): void { + process.stdout.write(`${JSON.stringify({ weo: IMF_WEO_INDICATORS, fm: IMF_FM_INDICATORS }, null, 2)}\n`); +} + +// --------------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------------- + +async function main(): Promise { + const { command, flags, booleans } = parseArgs(process.argv.slice(2)); + switch (command) { + case 'weo': + await runWeo(flags, booleans); + return; + case 'compare': + await runCompare(flags, booleans); + return; + case 'sdmx': + await runSdmx(flags, booleans); + return; + case 'list-indicators': + runListIndicators(); + return; + case 'help': + default: + process.stdout.write(HELP); + return; + } +} + +main().catch((err: unknown) => { + const msg = err instanceof Error ? err.message : String(err); + process.stderr.write(`imf-fetch: ${msg}\n`); + process.exit(1); +}); diff --git a/scripts/parliamentary-data/data-persistence.ts b/scripts/parliamentary-data/data-persistence.ts index a9402a994e..8986fb520a 100644 --- a/scripts/parliamentary-data/data-persistence.ts +++ b/scripts/parliamentary-data/data-persistence.ts @@ -81,6 +81,7 @@ export type PersistenceDocumentType = | 'mps' | 'government' | 'worldbank' + | 'imf' | 'scb' | string; // extensible for generic MCP servers @@ -469,6 +470,64 @@ export function persistWorldBankData( return path.join(dir, filename); } +/** + * Persist IMF API response data (Datamapper JSON or SDMX 3.0). + * + * Stored under `analysis/data/imf/{indicator}/{country}.json`, mirroring the + * World Bank persistence layout. Introduced by the hybrid IMF integration + * (Economic Data Contract v2.0, 2026-04). Supports all IMF providers: + * Datamapper (WEO) and SDMX 3.0 (IFS/BOP/FM/GFS/DOTS/MFS), all accessed + * through the pure-TypeScript client `scripts/imf-client.ts` (there is no + * Python MCP / `uvx` runtime; agentic workflows invoke the `tsx + * scripts/imf-fetch.ts` CLI via the `bash` tool). + * + * @param indicator - IMF indicator code (e.g. 'NGDP_RPCH', 'GGXWDG_NGDP', + * 'PCPIPCH', 'LUR', 'FM_EXP_G01_GDP_PT'). + * @param country - IMF AREA code (ISO3 for Datamapper; varies by dataset + * per `scripts/imf-codes.ts`). + * @param response - Raw IMF response payload. + * @param options - Optional provenance (`database`, `projectionVintage`, + * `dataRoot` override for testing). + * @returns Absolute path to the persisted data file. + */ +export function persistIMFData( + indicator: string, + country: string, + response: unknown, + options: { + database?: string; + projectionVintage?: string; + dataRoot?: string; + } = {}, +): string { + const dataRoot = options.dataRoot ?? DATA_ROOT; + const dir = path.join(dataRoot, 'imf', sanitizeDokId(indicator)); + ensureDir(dir); + + const filename = `${sanitizeDokId(country)}.json`; + fs.writeFileSync( + path.join(dir, filename), + JSON.stringify(response, null, 2), + 'utf8', + ); + + const metaFilename = `${sanitizeDokId(country)}.meta.json`; + fs.writeFileSync( + path.join(dir, metaFilename), + JSON.stringify({ + fetchedAt: new Date().toISOString(), + mcpTool: 'imf-ts-client', + indicator, + country, + ...(options.database ? { database: options.database } : {}), + ...(options.projectionVintage ? { projectionVintage: options.projectionVintage } : {}), + }, null, 2), + 'utf8', + ); + + return path.join(dir, filename); +} + /** * Persist SCB (Statistics Sweden) table data. * Stored under `analysis/data/scb/{tableId}.json` diff --git a/scripts/statistical-claims-detector.ts b/scripts/statistical-claims-detector.ts index 5cc67f9c61..4f0396ac1d 100644 --- a/scripts/statistical-claims-detector.ts +++ b/scripts/statistical-claims-detector.ts @@ -38,6 +38,12 @@ export interface StatisticalClaim { readonly worldBankIndicator?: string; /** SCB table ID for cross-reference (if applicable) */ readonly scbTableId?: string; + /** + * IMF indicator code (WEO/FM/IFS) for cross-reference (if applicable). + * Added in schema v2 (2026-04). Examples: `NGDP_RPCH`, `PCPIPCH`, `LUR`, + * `GGXWDG_NGDP`, `BCA_NGDPD`, `FM_EXP_G01_GDP_PT`. + */ + readonly imfIndicator?: string; } /** Result of fact-checking a statistical claim */ @@ -88,6 +94,8 @@ interface ClaimPattern { readonly verificationSource: 'world-bank' | 'scb' | 'both'; readonly worldBankIndicator?: string; readonly scbTableId?: string; + /** IMF indicator code (WEO/FM/IFS) — added in schema v2 (2026-04). */ + readonly imfIndicator?: string; } /** @@ -102,6 +110,7 @@ const CLAIM_PATTERNS: readonly ClaimPattern[] = [ verificationSource: 'both', worldBankIndicator: 'SL.UEM.TOTL.ZS', scbTableId: 'TAB5765', + imfIndicator: 'LUR', }, { pattern: /unemployment\s+(?:rate\s+)?(?:is|stands?\s+at|(?:has\s+)?(?:fallen|risen|dropped)\s+to)\s+(\d+[,.]?\d*)\s*(?:percent|%)/gi, @@ -109,6 +118,7 @@ const CLAIM_PATTERNS: readonly ClaimPattern[] = [ verificationSource: 'both', worldBankIndicator: 'SL.UEM.TOTL.ZS', scbTableId: 'TAB5765', + imfIndicator: 'LUR', }, // GDP claims { @@ -117,6 +127,7 @@ const CLAIM_PATTERNS: readonly ClaimPattern[] = [ verificationSource: 'both', worldBankIndicator: 'NY.GDP.MKTP.KD.ZG', scbTableId: 'TAB5802', + imfIndicator: 'NGDP_RPCH', }, { pattern: /GDP\s+(?:growth\s+)?(?:grew|expanded|contracted|shrank)\s+(?:by\s+)?(\d+[,.]?\d*)\s*(?:percent|%)/gi, @@ -124,6 +135,7 @@ const CLAIM_PATTERNS: readonly ClaimPattern[] = [ verificationSource: 'both', worldBankIndicator: 'NY.GDP.MKTP.KD.ZG', scbTableId: 'TAB5802', + imfIndicator: 'NGDP_RPCH', }, // Inflation claims { @@ -131,12 +143,14 @@ const CLAIM_PATTERNS: readonly ClaimPattern[] = [ topic: 'inflation', verificationSource: 'both', worldBankIndicator: 'FP.CPI.TOTL.ZG', + imfIndicator: 'PCPIPCH', }, { pattern: /inflation\s+(?:rate\s+)?(?:is|stands?\s+at|(?:has\s+)?(?:fallen|risen)\s+to)\s+(\d+[,.]?\d*)\s*(?:percent|%)/gi, topic: 'inflation', verificationSource: 'both', worldBankIndicator: 'FP.CPI.TOTL.ZG', + imfIndicator: 'PCPIPCH', }, // Migration claims { @@ -164,12 +178,17 @@ const CLAIM_PATTERNS: readonly ClaimPattern[] = [ topic: 'defence', verificationSource: 'both', worldBankIndicator: 'MS.MIL.XPND.GD.ZS', + // IMF Fiscal Monitor expenditure indicator (citation reference only — full + // defence series currently resolved via `GFS_COFOG` function 02 at query + // time; see `.github/aw/ECONOMIC_DATA_CONTRACT.md` v2.0 committee matrix). + imfIndicator: 'FM_EXP_G01_GDP_PT', }, { pattern: /(?:military|defence|defense)\s+spending\s+(?:is|stands?\s+at)\s+(\d+[,.]?\d*)\s*(?:percent|%)\s*(?:of\s+GDP)?/gi, topic: 'defence', verificationSource: 'both', worldBankIndicator: 'MS.MIL.XPND.GD.ZS', + imfIndicator: 'FM_EXP_G01_GDP_PT', }, // Education spending claims { @@ -348,6 +367,7 @@ export function detectStatisticalClaims( verificationSource: claimPattern.verificationSource, worldBankIndicator: claimPattern.worldBankIndicator, scbTableId: claimPattern.scbTableId, + imfIndicator: claimPattern.imfIndicator, }); } } diff --git a/scripts/validate-economic-context.ts b/scripts/validate-economic-context.ts index 097c2ebf4b..43fdf3926c 100644 --- a/scripts/validate-economic-context.ts +++ b/scripts/validate-economic-context.ts @@ -146,11 +146,15 @@ export function countChartCanvases(html: string): number { } /** - * Check whether the HTML contains a footer attribution link to World - * Bank / SCB. Matches common phrasings across the 14 languages. + * Check whether the HTML contains a footer attribution link to IMF / + * World Bank / SCB. Matches common phrasings across the 14 languages. + * + * Schema v2 (2026-04-20+): accepts "IMF" / "International Monetary Fund" + * as a primary-source marker alongside the existing World Bank / SCB + * patterns. */ export function hasAttribution(html: string): boolean { - return /World Bank|världsbanken|verdensbank|weltbank|banco mundial|banque mondiale|wereldbank|البنك الدولي|הבנק העולמי|世界銀行|세계은행|世界银行|SCB|Statistics Sweden|Statistiska centralbyrån/i.test(html); + return /\bIMF\b|International Monetary Fund|Internationella valutafonden|Internationaler Währungsfonds|Fondo Monetario Internacional|Fonds monétaire international|Internationaal Monetair Fonds|صندوق النقد الدولي|קרן המטבע הבינלאומית|国際通貨基金|국제통화기금|国际货币基金组织|World Bank|världsbanken|verdensbank|weltbank|banco mundial|banque mondiale|wereldbank|البنك الدولي|הבנק העולמי|世界銀行|세계은행|世界银行|\bSCB\b|Statistics Sweden|Statistiska centralbyrån/i.test(html); } // --------------------------------------------------------------------------- @@ -256,7 +260,7 @@ export function validateArticle(filePath: string, rootDir: string = process.cwd( violations.push({ articleFile: filePath, articleType, - reason: 'HTML contains economic-dashboard-placeholder — agentic workflow did not supply live World Bank data', + reason: 'HTML contains economic-dashboard-placeholder — agentic workflow did not supply live IMF / World Bank / SCB data', }); } @@ -303,7 +307,7 @@ export function validateArticle(filePath: string, rootDir: string = process.cwd( violations.push({ articleFile: filePath, articleType, - reason: 'economic-data.json has empty dataPoints[] (workflow fetched no World Bank data)', + reason: 'economic-data.json has empty dataPoints[] (workflow fetched no IMF / World Bank / SCB data)', }); } const words = countWords(ctx.commentary); @@ -314,29 +318,29 @@ export function validateArticle(filePath: string, rootDir: string = process.cwd( reason: `AI commentary too short — ${words} words, expected ≥${rule.minCommentaryWords}`, }); } - if (ctx.source.worldBank.length === 0 && ctx.source.scb.length === 0) { + if (ctx.source.worldBank.length === 0 && ctx.source.scb.length === 0 && ctx.source.imf.length === 0) { violations.push({ articleFile: filePath, articleType, - reason: 'economic-data.json lacks source attribution (World Bank + SCB empty)', + reason: 'economic-data.json lacks source attribution (World Bank + IMF + SCB all empty)', }); } } // Check 4: footer attribution link. The renderer does not yet emit - // a deterministic "Data by World Bank / SCB" footer string for every - // template, so accept structured attribution from + // a deterministic "Data by IMF / World Bank / SCB" footer string for + // every template, so accept structured attribution from // `economic-data.json.source` as a fallback source of truth. This // prevents false failures for articles that ship valid economic // data and charts but whose footer copy has not been migrated. const hasStructuredAttribution = Boolean( - ctx && (ctx.source.worldBank.length > 0 || ctx.source.scb.length > 0), + ctx && (ctx.source.worldBank.length > 0 || ctx.source.scb.length > 0 || ctx.source.imf.length > 0), ); if (!hasAttribution(html) && !hasStructuredAttribution) { violations.push({ articleFile: filePath, articleType, - reason: 'Missing "Data by World Bank / SCB" footer attribution (no structured source in economic-data.json either)', + reason: 'Missing "Data by IMF / World Bank / SCB" footer attribution (no structured source in economic-data.json either)', }); } diff --git a/tests/data-persistence.test.ts b/tests/data-persistence.test.ts index d06341980c..cca3e18cc3 100644 --- a/tests/data-persistence.test.ts +++ b/tests/data-persistence.test.ts @@ -26,6 +26,7 @@ import { persistMPs, persistMCPResponse, persistWorldBankData, + persistIMFData, persistSCBData, getDataRoot, } from '../scripts/parliamentary-data/data-persistence.js'; @@ -424,6 +425,45 @@ describe('data-persistence', () => { }); }); + describe('persistIMFData', () => { + it('stores IMF data under imf/{indicator}/{country}.json with sidecar', () => { + const resultPath = persistIMFData( + 'NGDP_RPCH', + 'SWE', + [{ period: '2026', value: 2.1, projection: true }], + { + database: 'WEO', + projectionVintage: 'WEO-2026-04', + dataRoot: tmpDir, + }, + ); + expect(fs.existsSync(resultPath)).toBe(true); + expect(resultPath).toContain(path.join('imf', 'ngdp-rpch', 'swe.json')); + + const metaPath = resultPath.replace('.json', '.meta.json'); + expect(fs.existsSync(metaPath)).toBe(true); + const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8')); + expect(meta.mcpTool).toBe('imf-ts-client'); + expect(meta.indicator).toBe('NGDP_RPCH'); + expect(meta.country).toBe('SWE'); + expect(meta.database).toBe('WEO'); + expect(meta.projectionVintage).toBe('WEO-2026-04'); + }); + + it('omits optional provenance fields when not supplied', () => { + const resultPath = persistIMFData( + 'PCPIPCH', + 'DEU', + { data: [] }, + { dataRoot: tmpDir }, + ); + const metaPath = resultPath.replace('.json', '.meta.json'); + const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8')); + expect(meta.database).toBeUndefined(); + expect(meta.projectionVintage).toBeUndefined(); + }); + }); + describe('persistSCBData', () => { it('should store SCB table data with sidecar', () => { const resultPath = persistSCBData( diff --git a/tests/economic-context-multi-provider.test.ts b/tests/economic-context-multi-provider.test.ts new file mode 100644 index 0000000000..1a72ffd493 --- /dev/null +++ b/tests/economic-context-multi-provider.test.ts @@ -0,0 +1,159 @@ +/** + * Multi-provider `economic-data.json` loader tests. + * + * Schema v2.0 (2026-04-20) adds `source.imf[]` and optional + * `dataPoints[].provider` / `projection` / `projectionVintage` fields. + * This suite exercises both v1 artefacts (backward compatibility) and v2 + * artefacts with mixed provider sources. + * + * @author Hack23 AB + * @license Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { loadEconomicContext } from '../scripts/data-transformers/load-economic-context.js'; + +describe('load-economic-context (multi-provider, schema v2)', () => { + let tmp: string; + + beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'econ-ctx-')); + fs.mkdirSync(path.join(tmp, 'analysis', 'daily', '2026-04-20', 'committeeReports'), { recursive: true }); + }); + + afterEach(() => { + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + function writeContext(payload: Record): void { + fs.writeFileSync( + path.join(tmp, 'analysis', 'daily', '2026-04-20', 'committeeReports', 'economic-data.json'), + JSON.stringify(payload), + 'utf8', + ); + } + + it('accepts a v1 artefact without source.imf[] and defaults it to an empty array', () => { + writeContext({ + version: '1.0', + policyDomains: ['fiscal'], + dataPoints: [ + { countryCode: 'SWE', countryName: 'Sweden', indicatorId: 'NY.GDP.MKTP.KD.ZG', date: '2023', value: 1.1 }, + ], + commentary: 'Test commentary', + source: { worldBank: ['NY.GDP.MKTP.KD.ZG'], scb: [] }, + }); + const ctx = loadEconomicContext('2026-04-20', 'committee-reports', tmp); + expect(ctx).not.toBeNull(); + expect(ctx?.version).toBe('1.0'); + expect(ctx?.source.imf).toEqual([]); + expect(ctx?.source.worldBank).toEqual(['NY.GDP.MKTP.KD.ZG']); + expect(ctx?.enrichedDataPoints[0].provider).toBe('worldBank'); + expect(ctx?.enrichedDataPoints[0].projection).toBe(false); + }); + + it('accepts a v2 artefact with IMF source and projection metadata', () => { + writeContext({ + version: '2.0', + policyDomains: ['fiscal', 'debt'], + dataPoints: [ + { + countryCode: 'SWE', + countryName: 'Sweden', + indicatorId: 'GGXWDG_NGDP', + date: '2024', + value: 32.1, + provider: 'imf', + projection: false, + }, + { + countryCode: 'SWE', + countryName: 'Sweden', + indicatorId: 'GGXWDG_NGDP', + date: '2027', + value: 32.4, + provider: 'imf', + projection: true, + projectionVintage: 'WEO-2026-04', + }, + ], + commentary: 'IMF projects Sweden debt/GDP at 32.4% in 2027.', + source: { worldBank: [], scb: [], imf: ['WEO:GGXWDG_NGDP'] }, + }); + const ctx = loadEconomicContext('2026-04-20', 'committee-reports', tmp); + expect(ctx).not.toBeNull(); + expect(ctx?.version).toBe('2.0'); + expect(ctx?.source.imf).toEqual(['WEO:GGXWDG_NGDP']); + expect(ctx?.enrichedDataPoints).toHaveLength(2); + expect(ctx?.enrichedDataPoints[1].projection).toBe(true); + expect(ctx?.enrichedDataPoints[1].projectionVintage).toBe('WEO-2026-04'); + expect(ctx?.enrichedDataPoints[0].projection).toBe(false); + expect(ctx?.enrichedDataPoints[0].projectionVintage).toBeUndefined(); + }); + + it('accepts a mixed-provider artefact (IMF + World Bank + SCB)', () => { + writeContext({ + version: '2.0', + policyDomains: ['fiscal'], + dataPoints: [ + { countryCode: 'SWE', countryName: 'Sweden', indicatorId: 'NGDP_RPCH', date: '2025', value: 1.9, provider: 'imf', projection: false }, + { countryCode: 'SWE', countryName: 'Sweden', indicatorId: 'CC.EST', date: '2023', value: 2.1, provider: 'worldBank', projection: false }, + { countryCode: 'SWE', countryName: 'Sweden', indicatorId: 'TAB1291', date: '2025-01', value: 405.2, provider: 'scb', projection: false }, + ], + commentary: 'Mix of sources', + source: { worldBank: ['CC.EST'], scb: ['TAB1291'], imf: ['WEO:NGDP_RPCH'] }, + }); + const ctx = loadEconomicContext('2026-04-20', 'committee-reports', tmp); + expect(ctx).not.toBeNull(); + expect(ctx?.source.imf).toEqual(['WEO:NGDP_RPCH']); + expect(ctx?.source.worldBank).toEqual(['CC.EST']); + expect(ctx?.source.scb).toEqual(['TAB1291']); + const providers = ctx?.enrichedDataPoints.map((d) => d.provider) ?? []; + expect(providers).toEqual(['imf', 'worldBank', 'scb']); + }); + + it('rejects a v2 artefact with a bad provider value', () => { + writeContext({ + version: '2.0', + policyDomains: ['fiscal'], + dataPoints: [ + { countryCode: 'SWE', countryName: 'Sweden', indicatorId: 'NGDP_RPCH', date: '2025', value: 1.9, provider: 'wikipedia' }, + ], + commentary: 'Bogus provider', + source: { worldBank: [], scb: [], imf: ['WEO:NGDP_RPCH'] }, + }); + const ctx = loadEconomicContext('2026-04-20', 'committee-reports', tmp); + expect(ctx).toBeNull(); + }); + + it('rejects a v2 artefact with a non-string-array imf source', () => { + writeContext({ + version: '2.0', + policyDomains: ['fiscal'], + dataPoints: [], + commentary: '', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + source: { worldBank: [], scb: [], imf: [42] as any }, + }); + const ctx = loadEconomicContext('2026-04-20', 'committee-reports', tmp); + expect(ctx).toBeNull(); + }); + + it('preserves the raw data points array shape for legacy consumers', () => { + writeContext({ + version: '2.0', + policyDomains: ['fiscal'], + dataPoints: [ + { countryCode: 'SWE', countryName: 'Sweden', indicatorId: 'NGDP_RPCH', date: '2025', value: 1.9, provider: 'imf', projection: false }, + ], + commentary: 'x', + source: { worldBank: [], scb: [], imf: ['WEO:NGDP_RPCH'] }, + }); + const ctx = loadEconomicContext('2026-04-20', 'committee-reports', tmp); + expect(ctx?.dataPoints).toHaveLength(1); + expect(ctx?.dataPoints[0].value).toBe(1.9); + }); +}); diff --git a/tests/imf-client.test.ts b/tests/imf-client.test.ts new file mode 100644 index 0000000000..443147bb07 --- /dev/null +++ b/tests/imf-client.test.ts @@ -0,0 +1,324 @@ +/** + * Tests for IMF Client + * + * Covers Datamapper JSON parsing, projection detection, retry behaviour, + * rate-limit (429) back-off, and SDMX passthrough. No live network + * calls — all transport stubbed with `global.fetch` mocks. + * + * @author Hack23 AB + * @license Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + ImfClient, + getDefaultImfClient, + IMF_WEO_INDICATORS, + IMF_FM_INDICATORS, +} from '../scripts/imf-client.js'; + +describe('ImfClient', () => { + let client: ImfClient; + let originalFetch: typeof global.fetch; + + beforeEach(() => { + client = new ImfClient({ weoVintage: 'WEO-2026-04', maxRetries: 1, timeout: 3_000 }); + originalFetch = global.fetch; + }); + + afterEach(() => { + global.fetch = originalFetch; + vi.clearAllMocks(); + }); + + describe('constructor', () => { + it('applies sensible defaults', () => { + const defaults = new ImfClient(); + expect(defaults.datamapperBaseURL).toBe('https://www.imf.org/external/datamapper/api/v1'); + expect(defaults.sdmxBaseURL).toBe('https://api.imf.org/external/sdmx/3.0'); + expect(defaults.timeout).toBe(15_000); + expect(defaults.maxRetries).toBe(2); + expect(defaults.weoVintage).toMatch(/^WEO-\d{4}-\d{2}$/); + }); + + it('accepts overrides', () => { + const custom = new ImfClient({ + datamapperBaseURL: 'https://example.test/api', + sdmxBaseURL: 'https://sdmx.example.test', + timeout: 1_000, + maxRetries: 0, + weoVintage: 'WEO-2999-99', + }); + expect(custom.datamapperBaseURL).toBe('https://example.test/api'); + expect(custom.sdmxBaseURL).toBe('https://sdmx.example.test'); + expect(custom.timeout).toBe(1_000); + expect(custom.maxRetries).toBe(0); + expect(custom.weoVintage).toBe('WEO-2999-99'); + }); + }); + + describe('IMF_WEO_INDICATORS / IMF_FM_INDICATORS', () => { + it('exposes the canonical WEO headline indicator codes', () => { + expect(IMF_WEO_INDICATORS.gdpGrowth).toBe('NGDP_RPCH'); + expect(IMF_WEO_INDICATORS.inflationCpi).toBe('PCPIPCH'); + expect(IMF_WEO_INDICATORS.unemployment).toBe('LUR'); + expect(IMF_WEO_INDICATORS.generalGovGrossDebt).toBe('GGXWDG_NGDP'); + expect(IMF_WEO_INDICATORS.currentAccountBalance).toBe('BCA_NGDPD'); + }); + + it('exposes Fiscal Monitor indicators', () => { + expect(IMF_FM_INDICATORS.primaryBalance).toBe('GGXONLB_NGDP'); + }); + }); + + describe('getWeoIndicator', () => { + it('parses the Datamapper envelope and returns descending years', async () => { + global.fetch = vi.fn(async () => + new Response( + JSON.stringify({ + values: { + NGDP_RPCH: { + SWE: { + '2022': 2.7, + '2023': -0.1, + '2024': 1.1, + '2025': 1.9, + }, + }, + }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), + ) as unknown as typeof global.fetch; + + const series = await client.getWeoIndicator('SWE', 'NGDP_RPCH', 10); + expect(series).toHaveLength(4); + expect(series[0].date).toBe('2025'); + expect(series[series.length - 1].date).toBe('2022'); + for (const dp of series) { + expect(dp.provider).toBe('imf'); + expect(dp.indicatorId).toBe('NGDP_RPCH'); + expect(dp.countryCode).toBe('SWE'); + } + }); + + it('flags future years as projections and stamps the vintage', async () => { + const currentYear = new Date().getUTCFullYear(); + global.fetch = vi.fn(async () => + new Response( + JSON.stringify({ + values: { + GGXWDG_NGDP: { + SWE: { + [String(currentYear - 1)]: 32.0, + [String(currentYear)]: 31.5, + [String(currentYear + 1)]: 31.0, + [String(currentYear + 3)]: 30.1, + }, + }, + }, + }), + { status: 200 }, + ), + ) as unknown as typeof global.fetch; + + const series = await client.getWeoIndicator('SWE', 'GGXWDG_NGDP'); + const past = series.find((p) => p.date === String(currentYear - 1)); + const current = series.find((p) => p.date === String(currentYear)); + const future = series.find((p) => p.date === String(currentYear + 1)); + expect(past?.projection).toBe(false); + expect(past?.projectionVintage).toBeUndefined(); + expect(current?.projection).toBe(false); + expect(future?.projection).toBe(true); + expect(future?.projectionVintage).toBe('WEO-2026-04'); + }); + + it('returns an empty array when the response has no matching indicator', async () => { + global.fetch = vi.fn(async () => + new Response(JSON.stringify({ values: {} }), { status: 200 }), + ) as unknown as typeof global.fetch; + const series = await client.getWeoIndicator('SWE', 'NGDP_RPCH'); + expect(series).toEqual([]); + }); + + it('drops non-finite / null values defensively', async () => { + global.fetch = vi.fn(async () => + new Response( + JSON.stringify({ + values: { + NGDP_RPCH: { + SWE: { '2022': 2.7, '2023': null, '2024': 'n/a', '2025': 1.9 }, + }, + }, + }), + { status: 200 }, + ), + ) as unknown as typeof global.fetch; + const series = await client.getWeoIndicator('SWE', 'NGDP_RPCH'); + expect(series.map((p) => p.date).sort()).toEqual(['2022', '2025']); + }); + + it('truncates to the requested year count', async () => { + global.fetch = vi.fn(async () => + new Response( + JSON.stringify({ + values: { + NGDP_RPCH: { + SWE: { + '2018': 1, + '2019': 2, + '2020': 3, + '2021': 4, + '2022': 5, + '2023': 6, + '2024': 7, + }, + }, + }, + }), + { status: 200 }, + ), + ) as unknown as typeof global.fetch; + const series = await client.getWeoIndicator('SWE', 'NGDP_RPCH', 3); + expect(series).toHaveLength(3); + expect(series.map((p) => p.date)).toEqual(['2024', '2023', '2022']); + }); + + it('rejects non-positive or non-integer year counts', async () => { + await expect(client.getWeoIndicator('SWE', 'NGDP_RPCH', 0)).rejects.toThrow(); + await expect(client.getWeoIndicator('SWE', 'NGDP_RPCH', -1)).rejects.toThrow(); + await expect(client.getWeoIndicator('SWE', 'NGDP_RPCH', 2.5)).rejects.toThrow(); + }); + + it('normalises ISO-3 input to uppercase before building the URL', async () => { + const spy = vi.fn(async () => + new Response( + JSON.stringify({ values: { NGDP_RPCH: { SWE: { '2024': 1.1 } } } }), + { status: 200 }, + ), + ) as unknown as typeof global.fetch; + global.fetch = spy; + await client.getWeoIndicator('swe', 'NGDP_RPCH'); + const calledUrl = (spy as unknown as { mock: { calls: unknown[][] } }).mock.calls[0][0] as string; + expect(calledUrl).toContain('/NGDP_RPCH/SWE'); + }); + }); + + describe('getLatestWeoIndicator', () => { + it('prefers the latest non-projection value when available', async () => { + const currentYear = new Date().getUTCFullYear(); + global.fetch = vi.fn(async () => + new Response( + JSON.stringify({ + values: { + NGDP_RPCH: { + SWE: { + [String(currentYear - 1)]: 1.9, + [String(currentYear)]: 2.3, + [String(currentYear + 1)]: 2.1, + }, + }, + }, + }), + { status: 200 }, + ), + ) as unknown as typeof global.fetch; + const latest = await client.getLatestWeoIndicator('SWE', 'NGDP_RPCH'); + expect(latest?.projection).toBe(false); + expect(latest?.date).toBe(String(currentYear)); + }); + + it('falls back to the earliest projection when only projections are present', async () => { + const currentYear = new Date().getUTCFullYear(); + global.fetch = vi.fn(async () => + new Response( + JSON.stringify({ + values: { + NGDP_RPCH: { + SWE: { + [String(currentYear + 1)]: 2.1, + [String(currentYear + 2)]: 2.0, + }, + }, + }, + }), + { status: 200 }, + ), + ) as unknown as typeof global.fetch; + const latest = await client.getLatestWeoIndicator('SWE', 'NGDP_RPCH'); + expect(latest?.projection).toBe(true); + }); + + it('returns null when the series is empty', async () => { + global.fetch = vi.fn(async () => + new Response(JSON.stringify({ values: {} }), { status: 200 }), + ) as unknown as typeof global.fetch; + const latest = await client.getLatestWeoIndicator('SWE', 'NGDP_RPCH'); + expect(latest).toBeNull(); + }); + }); + + describe('compareCountriesWeo', () => { + it('returns one entry per country, null on failure', async () => { + let call = 0; + global.fetch = vi.fn(async () => { + call += 1; + if (call === 2) { + return new Response('boom', { status: 500 }); + } + return new Response( + JSON.stringify({ values: { NGDP_RPCH: { FOO: { '2024': 1.1 } } } }), + { status: 200 }, + ); + }) as unknown as typeof global.fetch; + + const fast = new ImfClient({ maxRetries: 0 }); + const out = await fast.compareCountriesWeo(['SWE', 'DNK', 'NOR'], 'NGDP_RPCH'); + expect(out.size).toBe(3); + expect(out.get('DNK')).toBeNull(); + }); + }); + + describe('retry behaviour', () => { + it('retries on 429 with back-off', async () => { + let calls = 0; + global.fetch = vi.fn(async () => { + calls += 1; + if (calls === 1) return new Response('rate-limited', { status: 429 }); + return new Response( + JSON.stringify({ values: { NGDP_RPCH: { SWE: { '2024': 1.1 } } } }), + { status: 200 }, + ); + }) as unknown as typeof global.fetch; + + const series = await client.getWeoIndicator('SWE', 'NGDP_RPCH'); + expect(calls).toBe(2); + expect(series).toHaveLength(1); + }); + + it('retries on 5xx and gives up after maxRetries', async () => { + global.fetch = vi.fn(async () => new Response('boom', { status: 500 })) as unknown as typeof global.fetch; + await expect(client.getWeoIndicator('SWE', 'NGDP_RPCH')).rejects.toThrow(/IMF API error/); + }); + }); + + describe('sdmxFetch', () => { + it('prepends the SDMX base URL when path is missing the leading slash', async () => { + const spy = vi.fn(async () => + new Response(JSON.stringify({ ok: true }), { status: 200 }), + ) as unknown as typeof global.fetch; + global.fetch = spy; + await client.sdmxFetch('data/IMF.RES,WEO/NGDP_RPCH.SWE.A.'); + const url = (spy as unknown as { mock: { calls: unknown[][] } }).mock.calls[0][0] as string; + expect(url).toBe( + 'https://api.imf.org/external/sdmx/3.0/data/IMF.RES,WEO/NGDP_RPCH.SWE.A.', + ); + }); + }); + + describe('getDefaultImfClient', () => { + it('returns the same instance across calls (singleton)', () => { + expect(getDefaultImfClient()).toBe(getDefaultImfClient()); + }); + }); +}); diff --git a/tests/imf-codes.test.ts b/tests/imf-codes.test.ts new file mode 100644 index 0000000000..be8830c88b --- /dev/null +++ b/tests/imf-codes.test.ts @@ -0,0 +1,99 @@ +/** + * Tests for IMF country / area code mappings. + * + * @author Hack23 AB + * @license Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { + COUNTRY_CODES, + ISO3_TO_IMF_AREA, + IMF_AREA_TO_ISO3, + toDatamapperCode, + toImfAreaCode, + isKnownIso3, + COUNTRY_NAMES_EN, +} from '../scripts/imf-codes.js'; + +describe('imf-codes', () => { + describe('COUNTRY_CODES', () => { + it('exposes Nordic + German + EU peer set in ISO-3', () => { + expect(COUNTRY_CODES.sweden).toBe('SWE'); + expect(COUNTRY_CODES.denmark).toBe('DNK'); + expect(COUNTRY_CODES.norway).toBe('NOR'); + expect(COUNTRY_CODES.finland).toBe('FIN'); + expect(COUNTRY_CODES.germany).toBe('DEU'); + expect(COUNTRY_CODES.europeanUnion).toBe('EU'); + expect(COUNTRY_CODES.euroArea).toBe('EURO'); + }); + }); + + describe('ISO3_TO_IMF_AREA', () => { + it('covers the full Nordic + DE peer set', () => { + for (const iso3 of ['SWE', 'DNK', 'NOR', 'FIN', 'DEU']) { + expect(ISO3_TO_IMF_AREA[iso3]).toMatch(/^\d{3}$/); + } + }); + + it('is deep-frozen (immutable)', () => { + expect(Object.isFrozen(ISO3_TO_IMF_AREA)).toBe(true); + }); + + it('has a consistent inverse map', () => { + for (const [iso3, area] of Object.entries(ISO3_TO_IMF_AREA)) { + expect(IMF_AREA_TO_ISO3[area]).toBe(iso3); + } + }); + }); + + describe('toDatamapperCode', () => { + it('returns ISO-3 codes uppercase, trimmed', () => { + expect(toDatamapperCode('swe')).toBe('SWE'); + expect(toDatamapperCode(' DNK ')).toBe('DNK'); + }); + + it('passes through EU / EURO aggregates', () => { + expect(toDatamapperCode('EU')).toBe('EU'); + expect(toDatamapperCode('EURO')).toBe('EURO'); + }); + }); + + describe('toImfAreaCode', () => { + it('converts ISO-3 to 3-digit IMF AREA code', () => { + expect(toImfAreaCode('SWE')).toBe('144'); + expect(toImfAreaCode('deu')).toBe('134'); + expect(toImfAreaCode(' FIN ')).toBe('172'); + }); + + it('throws for unknown ISO-3 instead of silently dropping the request', () => { + expect(() => toImfAreaCode('XXX')).toThrow(/no IMF area code mapping/); + }); + + it('throws for empty / whitespace input', () => { + expect(() => toImfAreaCode('')).toThrow(); + expect(() => toImfAreaCode(' ')).toThrow(); + }); + }); + + describe('isKnownIso3', () => { + it('returns true for mapped codes', () => { + expect(isKnownIso3('SWE')).toBe(true); + expect(isKnownIso3('swe')).toBe(true); + }); + + it('returns false for unknown codes', () => { + expect(isKnownIso3('XXX')).toBe(false); + expect(isKnownIso3('')).toBe(false); + }); + }); + + describe('COUNTRY_NAMES_EN', () => { + it('carries English display names for the peer set', () => { + expect(COUNTRY_NAMES_EN.SWE).toBe('Sweden'); + expect(COUNTRY_NAMES_EN.DEU).toBe('Germany'); + expect(COUNTRY_NAMES_EN.EU).toBe('European Union'); + expect(COUNTRY_NAMES_EN.EURO).toBe('Euro Area'); + }); + }); +}); diff --git a/tests/imf-context.test.ts b/tests/imf-context.test.ts new file mode 100644 index 0000000000..fcc648042a --- /dev/null +++ b/tests/imf-context.test.ts @@ -0,0 +1,121 @@ +/** + * Tests for the IMF context / policy mapping helpers. + * + * @author Hack23 AB + * @license Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { + IMF_INDICATORS, + findImfIndicatorsForDomains, + findImfIndicatorsForCommittee, + IMF_NORDIC_PEERS, + imfCountryNameEn, + imfCitation, +} from '../scripts/imf-context.js'; + +describe('imf-context', () => { + describe('IMF_INDICATORS catalogue', () => { + it('exposes the headline WEO indicators used by article workflows', () => { + const codes = new Set(IMF_INDICATORS.map((i) => `${i.database}:${i.indicatorId}`)); + expect(codes.has('WEO:NGDP_RPCH')).toBe(true); + expect(codes.has('WEO:GGXWDG_NGDP')).toBe(true); + expect(codes.has('WEO:PCPIPCH')).toBe(true); + expect(codes.has('WEO:LUR')).toBe(true); + expect(codes.has('WEO:BCA_NGDPD')).toBe(true); + expect(codes.has('FM:GGXONLB_NGDP')).toBe(true); + }); + + it('marks every WEO/FM indicator as projection-capable', () => { + for (const ind of IMF_INDICATORS) { + if (ind.database === 'WEO' || ind.database === 'FM') { + expect(ind.publishesProjections).toBe(true); + } + } + }); + + it('has policyAreas and committees for every entry', () => { + for (const ind of IMF_INDICATORS) { + expect(ind.policyAreas.length).toBeGreaterThan(0); + expect(ind.committees.length).toBeGreaterThan(0); + expect(ind.name.length).toBeGreaterThan(0); + expect(ind.unit.length).toBeGreaterThan(0); + } + }); + }); + + describe('findImfIndicatorsForDomains', () => { + it('returns fiscal-policy indicators for "fiscal policy"', () => { + const hits = findImfIndicatorsForDomains(['fiscal policy']); + const ids = hits.map((h) => h.indicatorId); + expect(ids).toContain('GGXWDG_NGDP'); + expect(ids).toContain('GGXCNL_NGDP'); + }); + + it('is case-insensitive', () => { + const lower = findImfIndicatorsForDomains(['labor market']); + const upper = findImfIndicatorsForDomains(['LABOR MARKET']); + expect(lower.map((i) => i.indicatorId).sort()).toEqual(upper.map((i) => i.indicatorId).sort()); + }); + + it('returns an empty list for an empty domain array', () => { + expect(findImfIndicatorsForDomains([])).toEqual([]); + }); + + it('matches substrings defensively', () => { + const hits = findImfIndicatorsForDomains(['inflation']); + expect(hits.some((h) => h.indicatorId === 'PCPIPCH')).toBe(true); + }); + }); + + describe('findImfIndicatorsForCommittee', () => { + it('returns FiU (finance) headline indicators', () => { + const hits = findImfIndicatorsForCommittee('FiU'); + const ids = hits.map((h) => h.indicatorId); + expect(ids).toContain('NGDP_RPCH'); + expect(ids).toContain('GGXWDG_NGDP'); + expect(ids).toContain('PCPIPCH'); + }); + + it('returns AU (labour market) indicators', () => { + const hits = findImfIndicatorsForCommittee('AU'); + expect(hits.some((h) => h.indicatorId === 'LUR')).toBe(true); + }); + + it('is case-insensitive', () => { + const upper = findImfIndicatorsForCommittee('FIU'); + const mixed = findImfIndicatorsForCommittee('fiu'); + expect(upper).toEqual(mixed); + }); + }); + + describe('IMF_NORDIC_PEERS', () => { + it('contains the SE/DK/NO/FI/DE peer set', () => { + expect(IMF_NORDIC_PEERS).toEqual(['SWE', 'DNK', 'NOR', 'FIN', 'DEU']); + }); + + it('is immutable', () => { + expect(Object.isFrozen(IMF_NORDIC_PEERS)).toBe(true); + }); + }); + + describe('imfCountryNameEn', () => { + it('returns English display names', () => { + expect(imfCountryNameEn('SWE')).toBe('Sweden'); + expect(imfCountryNameEn('deu')).toBe('Germany'); + }); + + it('falls back to the upper-cased code when unknown', () => { + expect(imfCountryNameEn('zzz')).toBe('ZZZ'); + }); + }); + + describe('imfCitation', () => { + it('joins database and indicator with a colon', () => { + expect(imfCitation('WEO', 'NGDP_RPCH')).toBe('WEO:NGDP_RPCH'); + expect(imfCitation('FM', 'GGXONLB_NGDP')).toBe('FM:GGXONLB_NGDP'); + expect(imfCitation('GFS_COFOG', 'G01_GDP_PT')).toBe('GFS_COFOG:G01_GDP_PT'); + }); + }); +}); diff --git a/tests/statistical-claims-detector.test.ts b/tests/statistical-claims-detector.test.ts index 0fa1e82c1a..23278679eb 100644 --- a/tests/statistical-claims-detector.test.ts +++ b/tests/statistical-claims-detector.test.ts @@ -119,6 +119,24 @@ describe('detectStatisticalClaims', () => { expect(claims[0].verificationSource).toBe('both'); expect(claims[0].worldBankIndicator).toBe('SL.UEM.TOTL.ZS'); expect(claims[0].scbTableId).toBe('TAB5765'); + expect(claims[0].imfIndicator).toBe('LUR'); + }); + + it('should propagate IMF indicator codes for macro topics', () => { + const gdp = detectStatisticalClaims('BNP växer med 2.1 procent.'); + expect(gdp[0].imfIndicator).toBe('NGDP_RPCH'); + + const inflation = detectStatisticalClaims('Inflationen ligger på 2.4 procent.'); + expect(inflation[0].imfIndicator).toBe('PCPIPCH'); + + const defence = detectStatisticalClaims('Försvarsutgifterna uppgår till 2.0 procent av BNP.'); + expect(defence[0].imfIndicator).toBe('FM_EXP_G01_GDP_PT'); + }); + + it('should leave imfIndicator undefined for SCB-only or generic patterns', () => { + const migration = detectStatisticalClaims('Invandringen ligger på 80000 personer.'); + // Migration is SCB-only; no IMF counterpart is declared on that pattern. + expect(migration[0]?.imfIndicator).toBeUndefined(); }); it('should detect unit from context', () => { diff --git a/tests/validate-economic-context.test.ts b/tests/validate-economic-context.test.ts index 66e91565a2..9e13960adf 100644 --- a/tests/validate-economic-context.test.ts +++ b/tests/validate-economic-context.test.ts @@ -150,7 +150,7 @@ describe('validate-economic-context: validateArticle against fixtures', () => { expect(v.length).toBeGreaterThan(0); expect(v.map(x => x.reason).some(r => r.includes('economic-dashboard-placeholder'))).toBe(true); expect(v.map(x => x.reason).some(r => r.includes('Missing or malformed'))).toBe(true); - expect(v.map(x => x.reason).some(r => r.includes('Data by World Bank'))).toBe(true); + expect(v.map(x => x.reason).some(r => r.includes('Data by IMF / World Bank / SCB'))).toBe(true); }); it('passes when JSON, charts, commentary, and attribution are all present', () => {