Hugging Face Deployer commited on
Commit
2f203f5
·
0 Parent(s):

Deploy explorer to Hugging Face Spaces

Browse files
Dockerfile ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ # Install system dependencies (needed for browser screenshot captures)
4
+ RUN apt-get update && apt-get install -y \
5
+ curl \
6
+ git \
7
+ procps \
8
+ gnupg \
9
+ libnss3 \
10
+ libatk1.0-0 \
11
+ libatk-bridge2.0-0 \
12
+ libcups2 \
13
+ libdrm2 \
14
+ libxkbcommon0 \
15
+ libxcomposite1 \
16
+ libxdamage1 \
17
+ libxext6 \
18
+ libxfixes3 \
19
+ libxrandr2 \
20
+ libgbm1 \
21
+ libpango-1.0-0 \
22
+ libcairo2 \
23
+ libasound2 \
24
+ && rm -rf /var/lib/apt/lists/*
25
+
26
+ # Install uv for fast package management
27
+ COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
28
+
29
+ # Set working directory
30
+ WORKDIR /app
31
+
32
+ # Copy dependency configs
33
+ COPY pyproject.toml uv.lock ./
34
+
35
+ # Install python dependencies (exclude the project itself to cache dependencies)
36
+ RUN uv sync --frozen --no-dev --no-install-project
37
+
38
+ # Install playwright browsers (for chart screenshot captures)
39
+ RUN uv run playwright install chromium
40
+
41
+ # Copy project source code
42
+ COPY . .
43
+
44
+ # Install the project itself
45
+ RUN uv sync --frozen --no-dev
46
+
47
+ # Ensure entrypoint is executable
48
+ RUN chmod +x /app/entrypoint.sh
49
+
50
+ # Expose the default Hugging Face Space port
51
+ EXPOSE 7860
52
+
53
+ # Environment variables
54
+ ENV PYTHONUNBUFFERED=1 \
55
+ PREFAB_BUNDLED_RENDERER=1
56
+
57
+ # Run the entrypoint script
58
+ CMD ["/app/entrypoint.sh"]
README.md ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Data360-MCP Visualization Engine Explorer
3
+ emoji: 📊
4
+ colorFrom: blue
5
+ colorTo: indigo
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ ---
10
+
11
+ # Data360-MCP Visualization Engine Explorer
12
+
13
+ This repository contains the deployment configuration for hosting the **Data360-MCP Visualization Engine Explorer** in Hugging Face Spaces.
14
+
15
+ ## 🚀 Deployment Instructions
16
+
17
+ ### 1. Create a Hugging Face Space
18
+ - Go to [Hugging Face Spaces](https://huggingface.co/spaces) and click **Create new Space**.
19
+ - Name the Space (e.g. `data360-mcp-explorer`).
20
+ - Select **Docker** as the SDK.
21
+ - Choose **Blank** template.
22
+
23
+ ### 2. Configure Repository Secrets & Environment Variables
24
+ In your Space settings page, add the following under **Repository secrets**:
25
+ - `OPENAI_API_KEY`: Required for natural query question framing and running vision-based chart audits.
26
+ - `HF_TOKEN`: Your Hugging Face write access token (required to pull/push persistent data).
27
+
28
+ Under **Variables**:
29
+ - `HF_DATASET_ID`: Set to `rafmacalaba/data360-explorer-reports` (defaults to this if not set).
30
+
31
+ ### 3. Sync Repository Files
32
+ Add the Hugging Face Space remote and push the branch:
33
+ ```bash
34
+ # Add the remote and push
35
+ git remote add space https://huggingface.co/spaces/rafmacalaba/data360-mcp-explorer
36
+ git push space main
37
+ ```
38
+
39
+ The Space will automatically build the container using the provided `Dockerfile` and boot the server on port `7860`.
deploy.sh ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ # Helper script to deploy the explorer to Hugging Face Spaces from evals/deepeval_visualization
3
+
4
+ set -e
5
+
6
+ # Colors for output
7
+ BLUE='\033[0;34m'
8
+ GREEN='\033[0;32m'
9
+ RED='\033[0;31m'
10
+ NC='\033[0m'
11
+
12
+ echo -e "${BLUE}=== Data360-MCP HF Space Deployment (from evals/deepeval_visualization) ===${NC}"
13
+
14
+ # Navigate to the workspace root
15
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
16
+ WORKSPACE_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
17
+
18
+ # Define paths relative to workspace root
19
+ DEPLOY_DIR="$WORKSPACE_ROOT/evals/deepeval_visualization"
20
+
21
+ # Copy source and dependency files to deploy dir to make it self-contained
22
+ echo "Copying application files to deploy directory..."
23
+ mkdir -p "$DEPLOY_DIR/evals"
24
+ cp -r "$WORKSPACE_ROOT/src" "$DEPLOY_DIR/"
25
+ cp "$WORKSPACE_ROOT/pyproject.toml" "$DEPLOY_DIR/"
26
+ # Strip workspace and source references from the copied pyproject.toml
27
+ python3 -c '
28
+ with open("'"$DEPLOY_DIR"'/pyproject.toml", "r") as f:
29
+ lines = f.readlines()
30
+ new_lines = []
31
+ skip = False
32
+ for line in lines:
33
+ if "[tool.uv.workspace]" in line:
34
+ skip = True
35
+ continue
36
+ if "[tool.uv.sources]" in line:
37
+ skip = True
38
+ continue
39
+ if skip and (line.startswith("[") or line.strip() == ""):
40
+ if line.strip().startswith("["):
41
+ skip = False
42
+ else:
43
+ skip = False
44
+ continue
45
+ if not skip:
46
+ new_lines.append(line)
47
+ with open("'"$DEPLOY_DIR"'/pyproject.toml", "w") as f:
48
+ f.writelines(new_lines)
49
+ '
50
+ cp "$WORKSPACE_ROOT/uv.lock" "$DEPLOY_DIR/"
51
+ cp "$WORKSPACE_ROOT/evals/interactive_explorer.py" "$DEPLOY_DIR/evals/"
52
+
53
+ # Clean up any pycache or python build artifacts from the copied files
54
+ echo "Cleaning up python build artifacts and pycache..."
55
+ find "$DEPLOY_DIR" -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
56
+ find "$DEPLOY_DIR" -name "*.pyc" -delete 2>/dev/null || true
57
+ find "$DEPLOY_DIR" -name "*.egg-info" -exec rm -rf {} + 2>/dev/null || true
58
+
59
+ # Navigate to deploy directory
60
+ cd "$DEPLOY_DIR"
61
+
62
+ # Initialize git if not already a repo
63
+ if [ ! -d ".git" ]; then
64
+ git init -b main
65
+ git config user.name "Hugging Face Deployer"
66
+ git config user.email "deploy@huggingface.co"
67
+ fi
68
+
69
+ # Get HF remote URL
70
+ REMOTE_URL=$(git remote get-url space 2>/dev/null || true)
71
+ if [ -z "$REMOTE_URL" ]; then
72
+ read -p "Enter your Hugging Face Space Git URL (default: https://huggingface.co/spaces/rafmacalaba/data360-mcp-explorer): " USER_URL
73
+ if [ -z "$USER_URL" ]; then
74
+ USER_URL="https://huggingface.co/spaces/rafmacalaba/data360-mcp-explorer"
75
+ fi
76
+ git remote add space "$USER_URL"
77
+ echo -e "${GREEN}Added git remote 'space' pointing to $USER_URL${NC}"
78
+ else
79
+ echo -e "Found existing 'space' remote pointing to: $REMOTE_URL"
80
+ fi
81
+
82
+ # Stage and commit locally inside the deploy folder
83
+ git add -A
84
+ git commit -m "Deploy explorer to Hugging Face Spaces" || echo "No changes to commit"
85
+
86
+ # Push to Hugging Face Space
87
+ echo -e "${BLUE}Pushing code to Hugging Face Space remote...${NC}"
88
+ git push --force space main
89
+
90
+ # Clean up copied files to keep local workspace clean
91
+ echo "Cleaning up copied files from deploy directory..."
92
+ rm -rf src pyproject.toml uv.lock evals
93
+
94
+ echo -e "${GREEN}Deployment complete! Your Space is building at: $REMOTE_URL${NC}"
entrypoint.sh ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ set -e
3
+
4
+ # 1. Start the Data360 MCP server in the background
5
+ echo "Starting Data360 MCP Server on port 8021..."
6
+ PREFAB_BUNDLED_RENDERER=1 uv run uvicorn data360.server:app --port 8021 --host 127.0.0.1 &
7
+
8
+ # 2. Give the MCP server a moment to boot
9
+ sleep 3
10
+
11
+ # 3. Start the Explorer in the foreground on HF Space's exposed port 7860
12
+ echo "Starting Visualization Engine Explorer on port 7860..."
13
+ uv run python evals/interactive_explorer.py --port 7860
evals/interactive_explorer.py ADDED
The diff for this file is too large to render. See raw diff
 
pyproject.toml ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ [project]
3
+ name = "data360-mcp"
4
+ version = "0.1.2"
5
+ description = "Model Context Protocol (MCP) server that gives LLM agents and chatbots direct access to the World Bank's Data360 Platform. Search, validate, and retrieve development indicators—from GDP and poverty to gender equality and climate—with structured metadata and time-series data, without hallucinating values."
6
+ readme = "README.md"
7
+ requires-python = ">=3.11"
8
+ dependencies = [
9
+ "fastmcp>=3.4.3",
10
+ "prefab-ui>=0.20.2",
11
+ "mcp>=1.18.0",
12
+ "fastapi>=0.115.0",
13
+ "uvicorn[standard]>=0.30.0",
14
+ "typer>=0.20.1",
15
+ "gunicorn>=23.0.0",
16
+ "pydantic-settings>=2.0.0",
17
+ "python-dotenv>=1.0.0",
18
+ "draco @ git+https://github.com/avsolatorio/draco2.git@data360-mcp",
19
+ "opencensus-ext-azure>=1.1.13",
20
+ "azure-monitor-opentelemetry>=1.6.0",
21
+ "pandas>=3.0.0",
22
+ "cachetools>=6.2.4",
23
+ "scikit-learn>=1.8.0",
24
+ "opentelemetry-instrumentation-httpx>=0.50b0",
25
+ "opentelemetry-sdk>=1.28.0",
26
+ "opentelemetry-exporter-otlp>=1.28.0",
27
+ "vl-convert-python>=1.9.0.post1",
28
+ "huggingface-hub",
29
+ ]
30
+
31
+ [project.optional-dependencies]
32
+ # LangChain / LangGraph client (workspace package); use: uv sync --extra agent
33
+ agent = ["data360-mcp-agent"]
34
+
35
+ [build-system]
36
+ requires = ["setuptools>=42", 'setuptools-scm']
37
+ build-backend = "setuptools.build_meta"
38
+
39
+ [tool.setuptools.packages.find]
40
+ where = ["src"]
41
+
42
+ [tool.setuptools.package-data]
43
+ data360 = ["tool_contract_version.json", "databases.json", "ref_area_groups.json"]
44
+
45
+ [dependency-groups]
46
+ dev = [
47
+ "data360-mcp-agent",
48
+ "pre-commit>=4.5.0",
49
+ "pyright>=1.1.407",
50
+ "ruff>=0.14.8",
51
+ "poethepoet>=0.27.0",
52
+ "pytest>=8.0.0",
53
+ "pytest-asyncio>=0.23.0",
54
+ "pytest-httpx>=0.27.0",
55
+ "pytest-cov>=4.1.0",
56
+ "pip-licenses>=5.0.0",
57
+ "pip-audit>=2.10.0",
58
+ "requests>=2.32.0",
59
+ "deepeval>=4.1.0",
60
+ ]
61
+
62
+ [tool.ruff.lint]
63
+ select = ['I', 'UP', 'PL', 'F401']
64
+
65
+ [[tool.uv.index]]
66
+ name = "pypi"
67
+ url = "https://pypi.org/simple"
68
+ default = true
69
+
70
+ [tool.pyright]
71
+ typeCheckingMode = "standard"
72
+ extraPaths = ["packages/data360-mcp-agent/src"]
73
+
74
+ [tool.poe.tasks.serve]
75
+ help = "Run the Data360 MCP server"
76
+ cmd = "uv run fastmcp run src/data360/server.py --transport ${transport} --port ${port}"
77
+ args = [
78
+ {name = "transport", options = ["--transport", "-t"], default = "streamable-http"},
79
+ {name = "port", options = ["--port", "-p"], default = "8021"},
80
+ ]
81
+
82
+ [tool.poe.tasks.test]
83
+ help = "Run tests"
84
+ cmd = "pytest"
85
+
86
+ [tool.poe.tasks.test-cov]
87
+ help = "Run tests with coverage"
88
+ cmd = "pytest --cov=src/data360 --cov-report=term-missing"
89
+
90
+ [tool.poe.tasks.licenses]
91
+ help = "Regenerate THIRD_PARTY_LICENSES.md"
92
+ cmd = "python scripts/generate_licenses.py"
93
+
94
+ [tool.pytest.ini_options]
95
+ testpaths = ["tests"]
96
+ python_files = ["test_*.py"]
97
+ python_classes = ["Test*"]
98
+ python_functions = ["test_*"]
99
+ asyncio_mode = "auto"
100
+ asyncio_default_fixture_loop_scope = "function"
src/data360/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ from data360.tool_contract import get_tool_contract_version
2
+
3
+ __version__ = "0.1.2"
4
+
5
+ __all__ = ["__version__", "get_tool_contract_version"]
src/data360/api.py ADDED
The diff for this file is too large to render. See raw diff
 
src/data360/config.py ADDED
@@ -0,0 +1,227 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import functools as ft
2
+ import logging
3
+ import os
4
+
5
+ # Load environment variables from .env file at import time, but only when not
6
+ # running under pytest. Test sessions set env vars explicitly via conftest/fixtures;
7
+ # unconditional load_dotenv() would stomp on those values with whatever is in a
8
+ # local .env file, making tests environment-dependent.
9
+ import os as _os
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ from dotenv import load_dotenv
14
+
15
+ # Skip .env during pytest (collection and execution) so tests control DATA360_* URLs.
16
+ if not _os.environ.get("PYTEST_CURRENT_TEST") and not _os.environ.get("PYTEST_RUNNING"):
17
+ load_dotenv()
18
+ del _os
19
+
20
+
21
+ from pydantic import Field
22
+ from pydantic_settings import BaseSettings, SettingsConfigDict
23
+
24
+
25
+ class MCPServerSettings(BaseSettings):
26
+ """Configuration settings for MCP server."""
27
+
28
+ port: int = Field(
29
+ default=8000,
30
+ description="Port for the MCP server",
31
+ )
32
+ transport: str = Field(
33
+ default="http",
34
+ description="Transport for the MCP server",
35
+ )
36
+ log_file: str | None = Field(
37
+ default=None,
38
+ description="Path to log file. If None, logs go to stderr/stdout.",
39
+ )
40
+ log_level: str = Field(
41
+ default="INFO",
42
+ description="Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)",
43
+ )
44
+ charts_api_url: str | None = Field(
45
+ default=None,
46
+ description="URL for external charts API to store Vega-Lite specs (e.g. https://.../api/v1/charts). When set, viz specs are POSTed here instead of saving to static.",
47
+ )
48
+ charts_api_token: str | None = Field(
49
+ default=None,
50
+ description="Optional bearer token for external charts API (Authorization header).",
51
+ )
52
+ env: str | None = Field(
53
+ default=None,
54
+ description="Deployment environment (e.g. local, dev, staging, prod). Azure App Insights "
55
+ "and OpenTelemetry export are disabled when set to 'local' unless you set "
56
+ "OTEL_EXPORTER_OTLP_ENDPOINT (or OTEL_EXPORTER_OTLP_TRACES_ENDPOINT) and/or "
57
+ "MCP_OTEL_CONSOLE=1 for local trace export; see data360.otel_setup.",
58
+ )
59
+ azure_connection_string: str | None = Field(
60
+ default=None,
61
+ description="Azure Application Insights connection string. If unset, falls back to APPLICATIONINSIGHTS_CONNECTION_STRING env var.",
62
+ )
63
+ readiness_enabled: bool = Field(
64
+ default=True,
65
+ description="When false, GET /ready returns 200 with readiness_checks=disabled (no dependency probes).",
66
+ )
67
+ health_check_timeout: float = Field(
68
+ default=5.0,
69
+ description="Per-check timeout in seconds for GET /ready outbound probes.",
70
+ )
71
+
72
+
73
+ model_config = SettingsConfigDict(env_prefix="MCP_")
74
+
75
+
76
+ class Data360Settings(BaseSettings):
77
+ """Configuration settings for Data360 API integration."""
78
+
79
+ api_base_url: str = Field(
80
+ ...,
81
+ description="Base URL for the Data360 API",
82
+ )
83
+ codelist_api_base_url: str = Field(
84
+ default="https://extdataportal.worldbank.org/api/data360/metadata/codelist",
85
+ description=(
86
+ "URL for the unified Data360 codelist API. Returns all dimension codelists "
87
+ "(COMP_BREAKDOWN, UNIT_MEASURE, SEX, AGE, URBANISATION, FREQ, REF_AREA, …) "
88
+ "in a single call. Used by scripts/build_extdataportal_codelists.py to regenerate "
89
+ "the bundled src/data360/extdataportal_codelists.json."
90
+ ),
91
+ )
92
+ search_url: str | None = Field(
93
+ default=None,
94
+ description="URL for search endpoint (defaults to {api_base_url}/data360/portal/v1/public_data360_search)",
95
+ )
96
+ metadata_url: str | None = Field(
97
+ default=None,
98
+ description="URL for metadata endpoint (defaults to {api_base_url}/data360/metadata)",
99
+ )
100
+ disaggregation_url: str | None = Field(
101
+ default=None,
102
+ description="URL for disaggregation endpoint (defaults to {api_base_url}/data360/disaggregation)",
103
+ )
104
+ dimensions_url: str | None = Field(
105
+ default=None,
106
+ description="URL for dimensions endpoint (defaults to {api_base_url}/data360/portal/v1/dimensions)",
107
+ )
108
+ data_url: str | None = Field(
109
+ default=None,
110
+ description="URL for data endpoint (defaults to {api_base_url}/data)",
111
+ )
112
+ metadata_search_fields: list[str] = Field(
113
+ default=[
114
+ "series_description/idno",
115
+ "series_description/name",
116
+ "series_description/database_id",
117
+ "series_description/definition_long",
118
+ "series_description/methodology",
119
+ "series_description/limitation",
120
+ "series_description/relevance",
121
+ "series_description/aggregation_method",
122
+ ]
123
+ )
124
+
125
+ # Limit the data observations to the following confidentiality levels
126
+ data_obs_confidentiality_levels: list[str] = Field(
127
+ default=[
128
+ "PU", # Public
129
+ # "OU", # Official Use
130
+ # "CO", # Confidential
131
+ # "SC", # Strictly Confidential
132
+ ],
133
+ description="Confidentiality levels to limit the data observations to",
134
+ )
135
+
136
+ model_config = SettingsConfigDict(env_prefix="DATA360_")
137
+
138
+ @property
139
+ def api_url(self) -> str:
140
+ """Base path for Data360 HTTP APIs: ``{api_base_url}/data360`` (no trailing slash)."""
141
+ return f"{self.api_base_url.rstrip('/')}/data360"
142
+
143
+
144
+ @ft.cache
145
+ def get_data360_settings() -> Data360Settings:
146
+ """Get cached Data360 settings instance."""
147
+ return Data360Settings() # pyright: ignore[reportCallIssue]
148
+
149
+
150
+ @ft.cache
151
+ def get_mcp_server_settings() -> MCPServerSettings:
152
+ """Get cached MCP server settings instance."""
153
+ return MCPServerSettings() # pyright: ignore[reportCallIssue]
154
+
155
+
156
+ def setup_logging(
157
+ log_file: str | None = None,
158
+ log_level: str = "INFO",
159
+ env: str | None = None,
160
+ azure_connection_string: str | None = None,
161
+ ) -> None:
162
+ """Configure logging to write to a file and/or console, and optionally Azure App Insights.
163
+
164
+ Args:
165
+ log_file: Path to log file. If None, logs only go to stderr.
166
+ log_level: Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
167
+ env: Deployment environment. Azure handler is skipped when 'local'.
168
+ azure_connection_string: Azure App Insights connection string. Falls back to
169
+ APPLICATIONINSIGHTS_CONNECTION_STRING env var if not provided.
170
+ """
171
+ # Convert string level to logging constant
172
+ numeric_level = getattr(logging, log_level.upper(), logging.INFO)
173
+
174
+ # Create formatter
175
+ formatter = logging.Formatter(
176
+ fmt="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
177
+ datefmt="%Y-%m-%d %H:%M:%S",
178
+ )
179
+
180
+ # Get root logger
181
+ root_logger = logging.getLogger()
182
+ root_logger.setLevel(numeric_level)
183
+
184
+ # Remove existing handlers to avoid duplicates
185
+ root_logger.handlers.clear()
186
+
187
+ # Console handler (stderr)
188
+ console_handler = logging.StreamHandler(sys.stderr)
189
+ console_handler.setLevel(numeric_level)
190
+ console_handler.setFormatter(formatter)
191
+ root_logger.addHandler(console_handler)
192
+
193
+ # File handler (if log_file is specified)
194
+ if log_file:
195
+ log_path = Path(log_file)
196
+ # Create parent directories if they don't exist
197
+ log_path.parent.mkdir(parents=True, exist_ok=True)
198
+
199
+ file_handler = logging.FileHandler(log_path, encoding="utf-8")
200
+ file_handler.setLevel(numeric_level)
201
+ file_handler.setFormatter(formatter)
202
+ root_logger.addHandler(file_handler)
203
+
204
+ # Azure App Insights handler (skipped for local environment or when no key is configured)
205
+ effective_connection_string = azure_connection_string or os.environ.get(
206
+ "APPLICATIONINSIGHTS_CONNECTION_STRING"
207
+ )
208
+ if env != "local" and effective_connection_string:
209
+ try:
210
+ from opencensus.ext.azure.log_exporter import (
211
+ AzureLogHandler, # type: ignore[import-untyped]
212
+ )
213
+
214
+ def _callback(_):
215
+ return True
216
+
217
+ azure_handler = AzureLogHandler(
218
+ connection_string=effective_connection_string
219
+ )
220
+ azure_handler.setLevel(numeric_level)
221
+ azure_handler.add_telemetry_processor(_callback)
222
+ root_logger.addHandler(azure_handler)
223
+ root_logger.info("Azure App Insights logging enabled (env=%s).", env)
224
+ except ImportError:
225
+ root_logger.warning(
226
+ "opencensus-ext-azure not installed; skipping Azure App Insights handler."
227
+ )
src/data360/databases.json ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "BS_BTI": "The Bertelsmann Stiftung\u2019s Transformation Index (BTI)",
3
+ "BS_SGI": "Sustainable Governance Indicators (SGI)",
4
+ "DRMKC_INFORM": "INFORM Risk Index",
5
+ "EIA_EIAOD": "U.S. Energy Information Administration",
6
+ "EIU_DI": "Democracy Index",
7
+ "EPC_AI": "Epoch AI",
8
+ "FAO_AS": "Aquastat",
9
+ "FAO_CAHD": "Cost and Affordability of a Healthy Diet (CoAHD)",
10
+ "FAO_CP": "Consumer Price Indices",
11
+ "FAO_EMS": "Emissions Database",
12
+ "FAO_EMSTOT": "Emissions Totals",
13
+ "FAO_FBS": "Food Balance Sheet",
14
+ "FAO_FDI": "Foreign Direct Investment (FDI)",
15
+ "FAO_FS": "Suite of Food Security Indicators",
16
+ "FAO_GFRA": "Global Forest Resources Assessment",
17
+ "FAO_IC": "Credit to Agriculture",
18
+ "FAO_MK": "Macro-Economic Indicators",
19
+ "FAO_RP": "Pesticides Use",
20
+ "FAO_SDGB": "SDG Indicators",
21
+ "FH_FIW": "Freedom in the World",
22
+ "FH_NIT": "Nations in Transit",
23
+ "FSIN_GRFC": "Global Report on Food Crises (GRFC) Database",
24
+ "GDIP_DVC": "The Price of the World in Your Pocket: A Price Only Some Can Afford.",
25
+ "GEM_APS": "Adult Population Survey (APS)",
26
+ "GEM_NES": "National Expert Survey (NES)",
27
+ "GI_AII": "Africa Integrity Indicators",
28
+ "IBP_OBS": "Open Budget Survey",
29
+ "IDB_INFRALATAM": "Infralatam",
30
+ "IFC_GB": "EM Thematic Bond Database",
31
+ "IFC_GEM": "Global Emerging Markets (GEMs) Risk Database",
32
+ "IFPRI_ASTI": "Agrifood Systems Technologies and Innovations (ASTI)",
33
+ "ILO_EMP": "Employment by economic activity",
34
+ "IMF_AI": "Artificial Intelligence (AI) Preparedness Index",
35
+ "IMF_BOP": "Balance of Payments (BOP) and International Investment Position (IIP)",
36
+ "IMF_BOPAGG": "Balance of Payments (BOP), World and Regional Aggregates",
37
+ "IMF_CDIR": "Climate-driven INFORM Risk",
38
+ "IMF_CDIS": "Coordinated Direct Investment Survey (CDIS)",
39
+ "IMF_CPIS": "Coordinated Portfolio Investment Survey (CPIS)",
40
+ "IMF_DOT": "Direction of Trade Statistics (DOTS)",
41
+ "IMF_ET": "Environmental Taxes",
42
+ "IMF_FAS": "Financial Access Survey (FAS)",
43
+ "IMF_FFS": "Fossil Fuel Subsidies",
44
+ "IMF_FISCALDECENTRALIZATION": "Fiscal Decentralization",
45
+ "IMF_FM": "Fiscal Monitor (FM)",
46
+ "IMF_FPR": "Country Fiscal Measures in Response to the COVID-19 Pandemic",
47
+ "IMF_FSI": "Financial Soundness Indicators (FSIs)",
48
+ "IMF_FSIRE": "Financial Soundness Indicators: Reporting entities",
49
+ "IMF_GENDER_EQUALITY": "Gender Equality",
50
+ "IMF_GFSCOFOG": "Government Finance Statistics (GFS), Expenditure by Function of Government (COFOG)",
51
+ "IMF_GFSE": "Government Finance Statistics (GFS), Expense",
52
+ "IMF_GFSFALCS": "Government Finance Statistics (GFS), Financial Assets and Liabilities by Counterpart Sector",
53
+ "IMF_GFSIBS": "Government Finance Statistics (GFS), Integrated Balance Sheet (Stock Positions and Flows in Assets and Liabilities)",
54
+ "IMF_GFSMAB": "Government Finance Statistics (GFS), Main Aggregates and Balances",
55
+ "IMF_GFSR": "Government Finance Statistics (GFS), Revenue",
56
+ "IMF_GFSSSUC": "Government Finance Statistics (GFS), Statement of Sources and Uses of Cash",
57
+ "IMF_GHW": "Global Housing Watch, House Price-to-Income Ratio Around the World",
58
+ "IMF_IFS": "International Financial Statistics (IFS)",
59
+ "IMF_IRFCL": "International Reserves and Foreign Currency Liquidity (IRFCL)",
60
+ "IMF_PCTOT": "Commodity Terms of Trade",
61
+ "IMF_WEO": "World Economic Outlook (WEO)",
62
+ "IMF_WORLD": "World Revenue Longitudinal Data (WoRLD)",
63
+ "IPC_IPC": "Integrated Food Security Phase Classification",
64
+ "ITU_DH": "ITU DataHub",
65
+ "ITU_GCI": "Global Cybersecurity Index",
66
+ "ITU_ICT": "ICT Regulatory Tracker",
67
+ "IUU_FRI": "IUU Fishing Risk Index",
68
+ "JRC_EDGAR": "Emissions Database for Global Atmospheric Research (EDGAR)",
69
+ "OECDWBG_PMR": "Product Market Regulation Database",
70
+ "OECD_AI": "OECD Artificial Intelligence",
71
+ "OECD_BROADBAND": "Broadband and Telecom Database",
72
+ "OECD_DSTRI": "Digital Services Trade Restrictive Index",
73
+ "OECD_IDD": "Income Distribution Database",
74
+ "OECD_PMR": "Product Market Regulation (PMR)",
75
+ "OECD_STRI": "Services Trade Restrictiveness Index Regulatory Database",
76
+ "OECD_TIVA": "Trade in Value Added (TiVA)",
77
+ "OHI_OHI": "Ocean Health Index (OHI)",
78
+ "OOKLA_ST": "Speed Test",
79
+ "OWID_CB": "CO2 and Greenhouse Gas Emissions",
80
+ "PCH_IXP": "Internet Exchange Directory",
81
+ "PEERING_DB": "Interconnection Database, Number of Connected Data Centers",
82
+ "POLITY5_PRC": "Political Regime Characteristics Database",
83
+ "RWB_PFI": "Press Freedom Index",
84
+ "T500_TL": "Top 500: The list",
85
+ "UNCTAD_DE": "Digital Economy and Technology",
86
+ "UNCTAD_LSC": "Liner Shipping Connectivity Index",
87
+ "UNCTAD_MT": "Maritime Transport",
88
+ "UNDRR_SFM": "Sendai Framework Monitor (SFM)",
89
+ "UND_NDGAIN": "Notre Dame Global Adaptation Initiative (ND-GAIN)",
90
+ "UNEP_OPH": "Ocean+ Habitats",
91
+ "UNESCO_UIS": "UNESCO Education Statistics",
92
+ "UNICEF_DW": "UNICEF Data Warehouse",
93
+ "UNSD_EI": "UNSD Environmental Indicators",
94
+ "UN_EGDI": "E-Government Development Index (EGDI)",
95
+ "UN_SDG": "Sustainable Development Goals (SDG) Database",
96
+ "VDEM_CORE": "V-Dem Core",
97
+ "WB_AGCI": "AgriConnect Indicators",
98
+ "WB_ASPD": "Global Productivity: A Cross-country Database of Productivity",
99
+ "WB_ASPIRE": "The Atlas of Social Protection Indicators of Resilience and Equity (ASPIRE)",
100
+ "WB_BID": "Benchmarking Infrastructure Development",
101
+ "WB_BOOST": "BOOST: Open Budget Portal",
102
+ "WB_BPS": "COVID-19 Business Pulse Surveys",
103
+ "WB_BREADY": "Business Ready",
104
+ "WB_CCDFS": "A Cross-Country Database of Fiscal Space",
105
+ "WB_CCKP": "CCKP ERA5 Dataset",
106
+ "WB_CLEAR": "Climate and Economic Analyses for Resilience in Water (CLEAR Water)",
107
+ "WB_CPIA": "Country Policy and Institutional Assessments (CPIA)",
108
+ "WB_CSC": "World Bank Group Corporate Scorecard",
109
+ "WB_CWON": "The Changing Wealth of Nations (CWON)",
110
+ "WB_EDSTATS": "Education Statistics",
111
+ "WB_EQOSOGI": "Equality of Opportunity for Sexual and Gender Minorities (EQOSOGI)",
112
+ "WB_ES": "Enterprise Surveys",
113
+ "WB_ESG": "Environment, Social & Governance (ESG)",
114
+ "WB_EWSA": "Europe and Central Asia (ECA) Water Security Assessment",
115
+ "WB_FINDEX": "Global Findex Database",
116
+ "WB_FSI": "Financial Sector Indicators",
117
+ "WB_GBIOD": "Global Biodiversity Data",
118
+ "WB_GEP": "Global Economic Prospects",
119
+ "WB_GIRG": "Global Indicators of Regulatory Governance",
120
+ "WB_GS": "Gender Statistics",
121
+ "WB_GTMI": "GovTech Maturity Index 2022",
122
+ "WB_GWA": "Global Wind Atlas (GWA)",
123
+ "WB_HCI": "Human Capital Index (HCI)",
124
+ "WB_HCIP": "Human Capital Index Plus (HCI+)",
125
+ "WB_HCP": "Human Capital Project (HCP)",
126
+ "WB_HLO": "Harmonized Learning Outcomes (HLO)",
127
+ "WB_HNP": "Health Nutrition and Population Statistics",
128
+ "WB_ID4D": "Identification for Development (ID4D) Global Dataset",
129
+ "WB_IDS": "International Debt Statistics (IDS)",
130
+ "WB_INFECDB": "Informal Economy Database",
131
+ "WB_KNOMAD": "The Global Knowledge Partnership on Migration and Development (KNOMAD) database",
132
+ "WB_LPGD": "Learning Poverty Global Database",
133
+ "WB_LPI": "Logistics Performance Index (LPI)",
134
+ "WB_LPI_20": "Logistic Performance Indicators (LPI) 2.0",
135
+ "WB_MPM": "Multidimensional Poverty Measure",
136
+ "WB_MPO": "Macro Poverty Outlook (MPO)",
137
+ "WB_PIP": "Poverty and Inequality Platform (PIP)",
138
+ "WB_PPI": "Private Participation in Infrastructure Database (PPI)",
139
+ "WB_RISE": "Regulatory Indicators for Sustainable Energy (RISE)",
140
+ "WB_SE4ALL": "Sustainable Energy For All (SE4ALL)",
141
+ "WB_SHP": "Global Database Of Shared Prosperity",
142
+ "WB_SOLAR_ATLAS": "Global Solar Atlas",
143
+ "WB_SPI": "Statistical Performance Indicators (SPI)",
144
+ "WB_SSGD": "Social sustainability global database",
145
+ "WB_THINK_HAZARD": "ThinkHazard!",
146
+ "WB_UHC": "Universal Health Coverage (UHC)",
147
+ "WB_WAW": "What a Waste (3.0)",
148
+ "WB_WBL": "Women, Business and the Law (WBL)",
149
+ "WB_WDI": "World Development Indicators (WDI)",
150
+ "WB_WGI": "Worldwide Governance Indicators (WGI)",
151
+ "WB_WITS": "World Integrated Trade Solution (WITS)",
152
+ "WB_WWBI": "Worldwide Bureaucracy Indicators (WWBI)",
153
+ "WEF_GCI": "Global Competitiveness Index (GCI) 4.0",
154
+ "WEF_GCIHH": "Global Competitiveness Index (GCI) - Historical Dataset",
155
+ "WEF_TTDI": "Travel & Tourism Development Index (TTDI)",
156
+ "WHO_GHO": "Global Health Observatory Indicators",
157
+ "WIPO_ICT": "Intellectual Property Statistics - Patent indicators",
158
+ "WI_GRT": "Green Recovery Tracker",
159
+ "WJP_ROL": "Rule of Law Index",
160
+ "WRI_AQDT": "Aqueduct 4.0 Current and Future Country Rankings",
161
+ "WRI_CLIMATEWATCH": "Climate Watch",
162
+ "WRI_GFW": "Global Forest Watch"
163
+ }
src/data360/errors.py ADDED
@@ -0,0 +1,348 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Centralized error management for the Data360 MCP server.
2
+
3
+ Provides a unified error hierarchy for consistent, LLM-actionable error messages.
4
+ Follows the pattern from https://github.com/avsolatorio/data-ai-chatbot/blob/dev/backend/app/core/errors.py
5
+
6
+ Error codes follow the format: "<type>:<context>" (e.g. "http_error:search", "timeout:metadata").
7
+ """
8
+
9
+ import logging
10
+ from typing import Any
11
+
12
+ _logger = logging.getLogger(__name__)
13
+
14
+
15
+ # ---------------------------------------------------------------------------
16
+ # Message registry - maps error codes to user/LLM-friendly messages
17
+ # ---------------------------------------------------------------------------
18
+ _ERROR_MESSAGES: dict[str, str] = {
19
+ # HTTP / network errors
20
+ "http_error:search": "The search request failed with an HTTP error. Please try again.",
21
+ "http_error:dataset": "The dataset search request failed with an HTTP error. Please try again.",
22
+ "http_error:metadata": "Failed to fetch metadata due to an HTTP error. Verify the indicator_id and database_id are correct.",
23
+ "http_error:disaggregation": "Failed to fetch disaggregation options due to an HTTP error. Verify the indicator_id and database_id are correct.",
24
+ "http_error:data": "Failed to fetch data due to an HTTP error. Verify the indicator_id, database_id, and filters are correct.",
25
+ # Timeouts
26
+ "timeout:search": "The search request timed out. Please try again.",
27
+ "timeout:dataset": "The dataset search request timed out. Please try again.",
28
+ "timeout:metadata": "The metadata request timed out. Please try again.",
29
+ "timeout:disaggregation": "The disaggregation request timed out. Please try again.",
30
+ "timeout:data": "The data request timed out. Please try again.",
31
+ # Request errors (connection issues, DNS, etc.)
32
+ "request_error:search": "A network error occurred during search. Check connectivity and try again.",
33
+ "request_error:dataset": "A network error occurred searching datasets. Check connectivity and try again.",
34
+ "request_error:metadata": "A network error occurred fetching metadata. Check connectivity and try again.",
35
+ "request_error:disaggregation": "A network error occurred fetching disaggregation options. Check connectivity and try again.",
36
+ "request_error:data": "A network error occurred fetching data. Check connectivity and try again.",
37
+ # Parse errors
38
+ "parse_error:search": "Failed to parse the search API response. The upstream API may be returning unexpected data.",
39
+ "parse_error:dataset": "Failed to parse the dataset search response.",
40
+ "parse_error:metadata": "Failed to parse the metadata API response.",
41
+ "parse_error:disaggregation": "Failed to parse the disaggregation API response.",
42
+ "parse_error:data": "Failed to parse the data API response.",
43
+ # Validation errors
44
+ "validation_error:search": "Invalid search parameters. Please check your query and filters.",
45
+ "validation_error:dataset": "Invalid dataset search parameters.",
46
+ "validation_error:metadata": "Invalid metadata request parameters. Check the indicator_id and database_id.",
47
+ "validation_error:data": "Invalid data request parameters. Check the indicator_id, database_id, and filters.",
48
+ "validation_error:api_response": "The API response failed validation. The data format may have changed.",
49
+ # Not found
50
+ "not_found:indicator": "No indicators found matching your query. Try broadening your search terms.",
51
+ "not_found:metadata": "No metadata found for the specified indicator. Verify the indicator_id is correct.",
52
+ # Unexpected
53
+ "unexpected:search": "An unexpected error occurred during search.",
54
+ "unexpected:dataset": "An unexpected error occurred searching datasets.",
55
+ "unexpected:metadata": "An unexpected error occurred fetching metadata.",
56
+ "unexpected:disaggregation": "An unexpected error occurred fetching disaggregation options.",
57
+ "unexpected:data": "An unexpected error occurred fetching data.",
58
+ }
59
+
60
+
61
+ class Data360MCPError(Exception):
62
+ """Base exception for all Data360 MCP errors.
63
+
64
+ Attributes:
65
+ error_code: Structured code like "http_error:search".
66
+ detail: Human/LLM-readable error message.
67
+ original_error: The original exception that caused this error, if any.
68
+ log_level: logging level used when this error is constructed. Subclasses
69
+ may override (e.g. NotFoundError uses WARNING).
70
+ """
71
+
72
+ log_level: int = logging.ERROR
73
+
74
+ def __init__(
75
+ self,
76
+ error_code: str,
77
+ detail: str | None = None,
78
+ original_error: Exception | None = None,
79
+ ):
80
+ self.error_code = error_code
81
+ self.detail = detail or self._get_message(error_code)
82
+ self.original_error = original_error
83
+ super().__init__(self.detail)
84
+ exc_info = (
85
+ (type(original_error), original_error, original_error.__traceback__)
86
+ if original_error is not None
87
+ else None
88
+ )
89
+ _logger.log(
90
+ self.log_level,
91
+ "[%s] %s",
92
+ self.error_code,
93
+ self.detail,
94
+ exc_info=exc_info,
95
+ )
96
+
97
+ def _get_message(self, error_code: str) -> str:
98
+ return _ERROR_MESSAGES.get(
99
+ error_code, "Something went wrong. Please try again."
100
+ )
101
+
102
+ def to_dict(self) -> dict[str, Any]:
103
+ """Serialize error for structured responses."""
104
+ result: dict[str, Any] = {
105
+ "error_code": self.error_code,
106
+ "detail": self.detail,
107
+ }
108
+ if self.original_error:
109
+ result["original_error"] = str(self.original_error)
110
+ return result
111
+
112
+
113
+ class APIError(Data360MCPError):
114
+ """HTTP errors from the Data360 API (4xx, 5xx responses)."""
115
+
116
+ def __init__(
117
+ self,
118
+ context: str,
119
+ status_code: int,
120
+ response_text: str = "",
121
+ original_error: Exception | None = None,
122
+ ):
123
+ self.status_code = status_code
124
+ self.response_text = response_text
125
+
126
+ # Sanitize response text - avoid leaking WAF HTML into error messages
127
+ sanitized_response = self._sanitize_error_response(response_text, status_code)
128
+ detail = f"HTTP {status_code}: {sanitized_response}"
129
+
130
+ super().__init__(
131
+ error_code=f"http_error:{context}",
132
+ detail=detail,
133
+ original_error=original_error,
134
+ )
135
+
136
+ @staticmethod
137
+ def _sanitize_error_response(response_text: str, status_code: int) -> str:
138
+ """Sanitize error responses to avoid leaking HTML/WAF content.
139
+
140
+ Args:
141
+ response_text: Raw response body from the backend
142
+ status_code: HTTP status code
143
+
144
+ Returns:
145
+ Clean, user-friendly error message
146
+ """
147
+ # Empty or whitespace-only response
148
+ if not response_text or not response_text.strip():
149
+ return _get_status_message(status_code)
150
+
151
+ # If response looks like HTML (WAF error pages), don't include it
152
+ # Check both raw and after stripping common whitespace/newlines
153
+ text_to_check = response_text.lstrip()
154
+ if text_to_check.startswith(
155
+ ("<html", "<!DOCTYPE", "<HTML", "<!doctype", "<!Doctype")
156
+ ):
157
+ return _get_status_message(status_code)
158
+
159
+ # For JSON error responses, try to extract the error message
160
+ if text_to_check.startswith("{"):
161
+ try:
162
+ import json
163
+
164
+ error_data = json.loads(response_text)
165
+ # Common error message fields
166
+ for key in ["error", "message", "detail", "error_description", "code"]:
167
+ if key in error_data:
168
+ msg = error_data[key]
169
+ # Skip if the JSON error field itself contains HTML
170
+ msg_str = str(msg)
171
+ if msg_str.lstrip().startswith(
172
+ ("<html", "<!DOCTYPE", "<HTML", "<!doctype")
173
+ ):
174
+ return _get_status_message(status_code)
175
+ # Limit length to avoid verbose error dumps
176
+ return (
177
+ msg_str[:200]
178
+ if len(msg_str) <= 200
179
+ else msg_str[:200] + "..."
180
+ )
181
+ except Exception:
182
+ pass # Fall through to default message
183
+
184
+ # Check if response contains HTML tags anywhere (not just at start)
185
+ # Common WAF patterns: <html, <body, <head, <title
186
+ lower_text = response_text.lower()
187
+ if any(
188
+ tag in lower_text
189
+ for tag in ["<html", "<body", "<head", "<title", "<!doctype"]
190
+ ):
191
+ return _get_status_message(status_code)
192
+
193
+ # For other responses, truncate and sanitize
194
+ # Remove excessive whitespace and newlines
195
+ cleaned = " ".join(response_text.split())
196
+ if len(cleaned) > 200:
197
+ return cleaned[:200] + "..."
198
+
199
+ return cleaned if cleaned else _get_status_message(status_code)
200
+
201
+
202
+ def _get_status_message(status_code: int) -> str:
203
+ """Get a user-friendly message for common HTTP status codes."""
204
+ status_messages = {
205
+ 400: "Bad Request - Invalid parameters",
206
+ 401: "Unauthorized - Authentication required",
207
+ 403: "Forbidden - Access denied",
208
+ 404: "Not Found - Resource does not exist",
209
+ 408: "Request Timeout - Session expired",
210
+ 413: "Payload Too Large - Request exceeds size limit",
211
+ 429: "Too Many Requests - Rate limit exceeded",
212
+ 500: "Internal Server Error - Backend service error",
213
+ 502: "Bad Gateway - Backend service unavailable",
214
+ 503: "Service Unavailable - Backend temporarily down",
215
+ 504: "Gateway Timeout - Backend did not respond in time",
216
+ }
217
+ return status_messages.get(
218
+ status_code, f"The request failed with status {status_code}"
219
+ )
220
+
221
+
222
+ class Data360TimeoutError(Data360MCPError):
223
+ """Timeout errors when calling the Data360 API."""
224
+
225
+ def __init__(
226
+ self,
227
+ context: str,
228
+ original_error: Exception | None = None,
229
+ ):
230
+ detail = _ERROR_MESSAGES.get(
231
+ f"timeout:{context}", f"Request timed out: {context}"
232
+ )
233
+ super().__init__(
234
+ error_code=f"timeout:{context}",
235
+ detail=detail,
236
+ original_error=original_error,
237
+ )
238
+
239
+
240
+ class RequestError(Data360MCPError):
241
+ """Network-level errors (DNS, connection refused, etc.)."""
242
+
243
+ def __init__(
244
+ self,
245
+ context: str,
246
+ original_error: Exception | None = None,
247
+ ):
248
+ detail = _ERROR_MESSAGES.get(
249
+ f"request_error:{context}", f"Request error: {context}"
250
+ )
251
+ super().__init__(
252
+ error_code=f"request_error:{context}",
253
+ detail=detail,
254
+ original_error=original_error,
255
+ )
256
+
257
+
258
+ class ParseError(Data360MCPError):
259
+ """JSON parsing or response validation errors."""
260
+
261
+ def __init__(
262
+ self,
263
+ context: str,
264
+ detail: str | None = None,
265
+ original_error: Exception | None = None,
266
+ ):
267
+ detail = detail or _ERROR_MESSAGES.get(
268
+ f"parse_error:{context}", f"Failed to parse response: {context}"
269
+ )
270
+ super().__init__(
271
+ error_code=f"parse_error:{context}",
272
+ detail=detail,
273
+ original_error=original_error,
274
+ )
275
+
276
+
277
+ class ValidationError(Data360MCPError):
278
+ """Invalid input parameters or failed response validation."""
279
+
280
+ def __init__(
281
+ self,
282
+ context: str,
283
+ detail: str | None = None,
284
+ original_error: Exception | None = None,
285
+ ):
286
+ detail = detail or _ERROR_MESSAGES.get(
287
+ f"validation_error:{context}", f"Validation error: {context}"
288
+ )
289
+ super().__init__(
290
+ error_code=f"validation_error:{context}",
291
+ detail=detail,
292
+ original_error=original_error,
293
+ )
294
+
295
+
296
+ class NotFoundError(Data360MCPError):
297
+ """Resource not found errors."""
298
+
299
+ log_level: int = logging.WARNING
300
+
301
+ def __init__(
302
+ self,
303
+ context: str,
304
+ detail: str | None = None,
305
+ original_error: Exception | None = None,
306
+ ):
307
+ detail = detail or _ERROR_MESSAGES.get(
308
+ f"not_found:{context}", f"Not found: {context}"
309
+ )
310
+ super().__init__(
311
+ error_code=f"not_found:{context}",
312
+ detail=detail,
313
+ original_error=original_error,
314
+ )
315
+
316
+
317
+ # ---------------------------------------------------------------------------
318
+ # Helper to convert exceptions into Data360MCPError
319
+ # ---------------------------------------------------------------------------
320
+ def classify_error(exc: Exception, context: str) -> Data360MCPError:
321
+ """Convert an exception into the appropriate Data360MCPError subclass.
322
+
323
+ Args:
324
+ exc: The original httpx exception.
325
+ context: The operation context (e.g. "search", "metadata", "data").
326
+
327
+ Returns:
328
+ The appropriate Data360MCPError subclass instance.
329
+ """
330
+ import httpx
331
+
332
+ if isinstance(exc, httpx.HTTPStatusError):
333
+ return APIError(
334
+ context=context,
335
+ status_code=exc.response.status_code,
336
+ response_text=exc.response.text,
337
+ original_error=exc,
338
+ )
339
+ elif isinstance(exc, httpx.TimeoutException):
340
+ return Data360TimeoutError(context=context, original_error=exc)
341
+ elif isinstance(exc, httpx.RequestError):
342
+ return RequestError(context=context, original_error=exc)
343
+ else:
344
+ return Data360MCPError(
345
+ error_code=f"unexpected:{context}",
346
+ detail=f"Unexpected error: {str(exc)}",
347
+ original_error=exc,
348
+ )
src/data360/health.py ADDED
@@ -0,0 +1,278 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Liveness and readiness checks for the Data360 MCP HTTP server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import os
7
+ import time
8
+ from datetime import UTC, datetime
9
+ from importlib.metadata import PackageNotFoundError, version
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ import httpx
14
+
15
+ from data360.config import get_data360_settings, get_mcp_server_settings
16
+ from data360.http_client import get_shared_httpx_client
17
+ from data360.providers import get_database_mapping
18
+
19
+ _SERVICE_NAME = "data360-mcp"
20
+ _PROBE_FILENAME = ".health_probe"
21
+ _CHARTS_UP_STATUS_CODES = frozenset({401, 403, 405, 422})
22
+
23
+
24
+ def _package_version() -> str:
25
+ try:
26
+ return version("data360")
27
+ except PackageNotFoundError:
28
+ return "unknown"
29
+
30
+
31
+ def get_liveness_body() -> dict[str, Any]:
32
+ """Return the JSON body for GET /health (no outbound I/O)."""
33
+ settings = get_mcp_server_settings()
34
+ return {
35
+ "status": "ok",
36
+ "service": _SERVICE_NAME,
37
+ "version": _package_version(),
38
+ "env": settings.env,
39
+ }
40
+
41
+
42
+ def _static_viz_specs_dir() -> Path:
43
+ if os.environ.get("PYTEST_CURRENT_TEST"):
44
+ return Path(os.getcwd()) / "static" / "viz_specs"
45
+ server_dir = os.path.dirname(os.path.abspath(__file__))
46
+ project_root = os.path.abspath(os.path.join(server_dir, "..", ".."))
47
+ return Path(project_root) / "static" / "viz_specs"
48
+
49
+
50
+ async def _check_data360_api(timeout: float) -> dict[str, Any]:
51
+ """Probe Data360 search API with a minimal dataset query."""
52
+ data360 = get_data360_settings()
53
+ url = data360.search_url or f"{data360.api_url}/searchv2"
54
+ started = time.perf_counter()
55
+
56
+ async def _probe() -> dict[str, Any]:
57
+ client = get_shared_httpx_client()
58
+ response = await client.post(
59
+ url,
60
+ headers={"accept": "*/*", "Content-Type": "application/json"},
61
+ json={
62
+ "filter": "type eq 'dataset'",
63
+ "select": "series_description/database_id",
64
+ "top": 1,
65
+ "skip": 0,
66
+ },
67
+ )
68
+ response.raise_for_status()
69
+ response.json()
70
+ return {
71
+ "ok": True,
72
+ "latency_ms": round((time.perf_counter() - started) * 1000),
73
+ }
74
+
75
+ try:
76
+ return await asyncio.wait_for(_probe(), timeout=timeout)
77
+ except TimeoutError:
78
+ return {"ok": False, "detail": "timeout"}
79
+ except httpx.HTTPStatusError as e:
80
+ return {"ok": False, "detail": f"http_{e.response.status_code}"}
81
+ except httpx.RequestError:
82
+ return {"ok": False, "detail": "connection_error"}
83
+ except Exception:
84
+ return {"ok": False, "detail": "probe_failed"}
85
+
86
+
87
+ async def _check_database_mapping() -> dict[str, Any]:
88
+ """Verify the in-memory database_id → name cache is usable."""
89
+ try:
90
+ mapping = await get_database_mapping()
91
+ except Exception:
92
+ return {"ok": False, "detail": "mapping_unavailable", "count": 0}
93
+
94
+ valid = [
95
+ (k, v)
96
+ for k, v in mapping.items()
97
+ if isinstance(k, str) and k.strip() and isinstance(v, str) and v.strip()
98
+ ]
99
+ count = len(mapping)
100
+ if count == 0:
101
+ return {"ok": False, "detail": "empty_cache", "count": 0, "source": "cache"}
102
+ if not valid:
103
+ return {
104
+ "ok": False,
105
+ "detail": "invalid_entries",
106
+ "count": count,
107
+ "source": "cache",
108
+ }
109
+ return {"ok": True, "count": count, "source": "cache"}
110
+
111
+
112
+ async def _check_static_writable() -> dict[str, Any]:
113
+ """Verify static/viz_specs exists and accepts writes."""
114
+ specs_dir = _static_viz_specs_dir()
115
+ try:
116
+ specs_dir.mkdir(parents=True, exist_ok=True)
117
+ probe_path = specs_dir / _PROBE_FILENAME
118
+ probe_path.write_text("ok", encoding="utf-8")
119
+ probe_path.unlink(missing_ok=True)
120
+ return {"ok": True}
121
+ except OSError:
122
+ return {"ok": False, "detail": "not_writable"}
123
+
124
+
125
+ async def _check_charts_api_reachable(timeout: float) -> dict[str, Any]:
126
+ """Probe Charts API reachability without creating a chart."""
127
+ charts_url = get_mcp_server_settings().charts_api_url
128
+ if not charts_url:
129
+ return {"ok": False, "detail": "not_configured"}
130
+
131
+ started = time.perf_counter()
132
+
133
+ async def _probe() -> dict[str, Any]:
134
+ client = get_shared_httpx_client()
135
+ response = await client.head(charts_url)
136
+ if response.status_code >= 500:
137
+ response.raise_for_status()
138
+ if (
139
+ response.status_code < 400
140
+ or response.status_code in _CHARTS_UP_STATUS_CODES
141
+ ):
142
+ return {
143
+ "ok": True,
144
+ "latency_ms": round((time.perf_counter() - started) * 1000),
145
+ }
146
+ response.raise_for_status()
147
+ return {"ok": True}
148
+
149
+ try:
150
+ return await asyncio.wait_for(_probe(), timeout=timeout)
151
+ except TimeoutError:
152
+ return {"ok": False, "detail": "timeout"}
153
+ except httpx.HTTPStatusError as e:
154
+ status = e.response.status_code
155
+ if status in _CHARTS_UP_STATUS_CODES:
156
+ return {
157
+ "ok": True,
158
+ "latency_ms": round((time.perf_counter() - started) * 1000),
159
+ }
160
+ return {"ok": False, "detail": f"http_{status}"}
161
+ except httpx.RequestError:
162
+ return {"ok": False, "detail": "connection_error"}
163
+ except Exception:
164
+ return {"ok": False, "detail": "probe_failed"}
165
+
166
+
167
+ async def _check_viz_storage(timeout: float) -> dict[str, Any]:
168
+ """Check viz spec persistence: Charts API and/or static fallback."""
169
+ settings = get_mcp_server_settings()
170
+ static_result = await _check_static_writable()
171
+ static_ok = static_result.get("ok", False)
172
+
173
+ if settings.charts_api_url:
174
+ charts_result = await _check_charts_api_reachable(timeout)
175
+ charts_ok = charts_result.get("ok", False)
176
+ ok = charts_ok or static_ok
177
+ entry: dict[str, Any] = {
178
+ "ok": ok,
179
+ "primary": "charts_api",
180
+ "fallback": "static",
181
+ "charts_api": charts_result,
182
+ "static": static_result,
183
+ }
184
+ if charts_ok:
185
+ entry["backend"] = "charts_api"
186
+ elif static_ok:
187
+ entry["backend"] = "static"
188
+ else:
189
+ entry["detail"] = "charts_and_static_unavailable"
190
+ return entry
191
+
192
+ entry = {
193
+ "ok": static_ok,
194
+ "backend": "static",
195
+ "primary": "static",
196
+ "static": static_result,
197
+ }
198
+ if not static_ok:
199
+ entry["detail"] = static_result.get("detail", "not_writable")
200
+ return entry
201
+
202
+
203
+ async def _check_codelist_api(timeout: float) -> dict[str, Any]:
204
+ """Optional probe for REF_AREA codelist API (non-blocking for readiness)."""
205
+ data360 = get_data360_settings()
206
+ url = f"{data360.api_url}/codelist"
207
+ started = time.perf_counter()
208
+
209
+ async def _probe() -> dict[str, Any]:
210
+ client = get_shared_httpx_client()
211
+ response = await client.get(url, params={"type": "REF_AREA"})
212
+ response.raise_for_status()
213
+ response.json()
214
+ return {
215
+ "ok": True,
216
+ "latency_ms": round((time.perf_counter() - started) * 1000),
217
+ }
218
+
219
+ try:
220
+ result = await asyncio.wait_for(_probe(), timeout=timeout)
221
+ return result
222
+ except TimeoutError:
223
+ return {"ok": False, "detail": "timeout", "degraded": True}
224
+ except httpx.HTTPStatusError as e:
225
+ return {
226
+ "ok": False,
227
+ "detail": f"http_{e.response.status_code}",
228
+ "degraded": True,
229
+ }
230
+ except httpx.RequestError:
231
+ return {"ok": False, "detail": "connection_error", "degraded": True}
232
+ except Exception:
233
+ return {"ok": False, "detail": "probe_failed", "degraded": True}
234
+
235
+
236
+ def _critical_checks_ok(checks: dict[str, Any]) -> bool:
237
+ for name in ("data360_api", "database_mapping", "viz_storage"):
238
+ entry = checks.get(name)
239
+ if not entry or not entry.get("ok"):
240
+ return False
241
+ return True
242
+
243
+
244
+ async def run_readiness() -> tuple[int, dict[str, Any]]:
245
+ """Run dependency probes; return (HTTP status code, JSON body)."""
246
+ settings = get_mcp_server_settings()
247
+ if not settings.readiness_enabled:
248
+ return 200, {
249
+ "status": "ready",
250
+ "checks": {},
251
+ "readiness_checks": "disabled",
252
+ }
253
+
254
+ timeout = settings.health_check_timeout
255
+ timestamp = datetime.now(UTC).isoformat()
256
+
257
+ (
258
+ data360_result,
259
+ mapping_result,
260
+ viz_result,
261
+ codelist_result,
262
+ ) = await asyncio.gather(
263
+ _check_data360_api(timeout),
264
+ _check_database_mapping(),
265
+ _check_viz_storage(timeout),
266
+ _check_codelist_api(timeout),
267
+ )
268
+
269
+ checks: dict[str, Any] = {
270
+ "data360_api": data360_result,
271
+ "database_mapping": mapping_result,
272
+ "viz_storage": viz_result,
273
+ "codelist_api": codelist_result,
274
+ }
275
+
276
+ if _critical_checks_ok(checks):
277
+ return 200, {"status": "ready", "timestamp": timestamp, "checks": checks}
278
+ return 503, {"status": "not_ready", "timestamp": timestamp, "checks": checks}
src/data360/http_client.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared httpx.AsyncClient for connection reuse and consistent outbound behavior."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+
7
+ import httpx
8
+
9
+ _DEFAULT_TIMEOUT = 30.0
10
+
11
+ _client: httpx.AsyncClient | None = None
12
+ _client_lock = threading.Lock()
13
+
14
+
15
+ def get_shared_httpx_client() -> httpx.AsyncClient:
16
+ """Return a process-wide async HTTP client (lazy singleton)."""
17
+ global _client # noqa: PLW0603
18
+ client = _client
19
+ if client is not None and not client.is_closed:
20
+ return client
21
+
22
+ with _client_lock:
23
+ client = _client
24
+ if client is None or client.is_closed:
25
+ client = httpx.AsyncClient(timeout=_DEFAULT_TIMEOUT)
26
+ _client = client
27
+ return client
28
+
29
+
30
+ async def aclose_shared_httpx_client() -> None:
31
+ """Close the shared client (call from ASGI shutdown)."""
32
+ global _client # noqa: PLW0603
33
+ with _client_lock:
34
+ client = _client
35
+ _client = None
36
+
37
+ if client is not None and not client.is_closed:
38
+ await client.aclose()
39
+
src/data360/mcp_server/__init__.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from . import (
2
+ prompts,
3
+ resources,
4
+ tools,
5
+ )
6
+ from ._server_definition import mcp
7
+
8
+ __all__ = [
9
+ "mcp",
10
+ "tools",
11
+ "resources",
12
+ "prompts",
13
+ ]
src/data360/mcp_server/__main__.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import dotenv
2
+ import typer
3
+ from fastmcp.server.server import Transport
4
+
5
+ from data360.config import get_mcp_server_settings, setup_logging
6
+ from data360.mcp_server import mcp
7
+
8
+
9
+ def main(
10
+ transport: Transport = typer.Option(
11
+ "streamable-http", "-t", "--transport", help="Transport to use."
12
+ ),
13
+ port: int = typer.Option(8021, "-p", "--port", help="Port to bind the server to."),
14
+ ):
15
+ """Run the MCP server with configurable transport and port."""
16
+ dotenv.load_dotenv()
17
+
18
+ # Setup logging from configuration
19
+ mcp_settings = get_mcp_server_settings()
20
+ setup_logging(
21
+ log_file=mcp_settings.log_file,
22
+ log_level=mcp_settings.log_level,
23
+ env=mcp_settings.env,
24
+ azure_connection_string=mcp_settings.azure_connection_string,
25
+ )
26
+
27
+ kwargs = {"port": port} if transport != "stdio" else {}
28
+ mcp.run(transport=transport, **kwargs)
29
+
30
+
31
+ if __name__ == "__main__":
32
+ typer.run(main)
src/data360/mcp_server/_server_definition.py ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ from fastmcp import FastMCP
2
+
3
+ # NOTE: base definition to allow for mounting of resources, prompts, tools, independently
4
+ mcp = FastMCP(
5
+ "Data360 MCP Server",
6
+ version="0.1.0",
7
+ )
src/data360/mcp_server/agent_recipe.py ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Integration recipe text for hosts using ``data360-mcp-agent`` or custom LangGraph clients.
2
+
3
+ Published as MCP resource ``data360://agent-recipe`` so the server remains the
4
+ single catalog of: resources, named prompts, and how to compose them.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ # Keep in sync with module docstrings in prompts.py and the data360-mcp-agent README.
10
+ AGENT_RECIPE_MARKDOWN = """# Data360 MCP — host integration recipe
11
+
12
+ Use this with **LangGraph**, **LangChain**, or any client that loads MCP resources
13
+ and optional **MCP prompts** before the user message.
14
+
15
+ ## 1. What to read from this server
16
+
17
+ | URI | Load | Role |
18
+ |-----|------|------|
19
+ | `data360://system-prompt` | **Required** | Search → codes → disaggregation → `get_data` → viz decision tree. |
20
+ | `data360://context` | **Recommended** | JSON: `current_date`, `current_year` (for “last N years”). |
21
+ | `data360://k360-narrative-style` | Optional | Markdown response contract for staged K360 narrative renderers. |
22
+ | `data360://agent-recipe` | Optional | This page; wire-up only (no extra tool semantics). |
23
+
24
+ **On-demand reference** (larger): `metadata-fields`, `data-schema`, `data-filters`, `search-usage`, `codelists`, `databases`.
25
+
26
+ ## 2. MCP named prompts (`prompts/list` + `prompts/get`)
27
+
28
+ Call by name when the user turn matches the scenario; prepend the returned **string**
29
+ to the conversation as a **system** or **user** block (your host convention).
30
+
31
+ | Prompt | Arguments | When to use |
32
+ |--------|-----------|-------------|
33
+ | `indicator_search` | `query`, optional `country`, `required_dimensions` | Pick one indicator among many search hits. |
34
+ | `indicator_details` | `indicator_id`, `database_id`, optional `question` | Methodology / definition questions. |
35
+ | `country_data` | `query`, `country`, optional `start_year`, `end_year` | One country (or small list) + indicator theme → data + chart. |
36
+ | `gate_classifier` | _(none)_ | Decide in/out of scope for WB/Data360 **before** tool loop (mirrors gated agent). |
37
+ | `thematic_to_data` | `user_message` | Turn a broad development-economics question into economies, terms, years, peers. |
38
+ | `k360_research_compiler` | `user_question`, optional `data_question`, `tool_calls_json` | Build a stable JSON content packet from tool trace. |
39
+ | `k360_narrative` | `user_question`, `content_packet_json`, optional `raw_tool_results_json`, `include_claim_tags` | Convert packet + evidence into polished narrative markdown. |
40
+
41
+ **Typical composed turn (thematic question):**
42
+
43
+ 1. (Optional) `prompts/get` → `gate_classifier` — run a small classifier; if out of scope, skip tools.
44
+ 2. (Optional) `prompts/get` → `thematic_to_data` with the user text — paste result into `HumanMessage` or augment the last user turn.
45
+ 3. Run the tool-using assistant with **`data360://system-prompt`** (plus `context`) as system instructions.
46
+
47
+ **Typical composed turn (country + theme):**
48
+
49
+ 1. `prompts/get` → `country_data` with `query` + `country` (+ years).
50
+ 2. Run the assistant with the same system stack as above.
51
+
52
+ ## 3. Python: ``data360-mcp-agent`` package
53
+
54
+ - **Env:** `DATA360_MCP_URL` (e.g. `http://127.0.0.1:8000/mcp`), LLM key or inject `llm=`.
55
+ - **`create_data360_mcp_agent()`** — fetches tools + `system-prompt` and `context`, builds a LangChain agent.
56
+ - **`create_data360_gated_langgraph_node()`** — adds a **local** gate + reform step (same *intent* as `gate_classifier` + `thematic_to_data`; you can later replace those steps with `prompts/get` for a single source of truth).
57
+ - **Extra resources in the system stack:** `DATA360_AGENT_EXTRA_RESOURCES=data360://agent-recipe` (comma-separated URIs).
58
+
59
+ ## 4. Composition order (recommended)
60
+
61
+ 1. **System text:** `system-prompt` + `context` + any extra resource bodies + optional tool-name summary (as your client does).
62
+ 2. **Optional prompt blocks:** from `prompts/get` (`country_data`, `thematic_to_data`, …).
63
+ 3. **User:** current user message (optionally rewritten using `thematic_to_data` output).
64
+ 4. **Assistant:** tool calls until done, then natural-language answer.
65
+
66
+ ### Staged K360 flow
67
+
68
+ `Gate -> Rewriter -> Compile -> Narrative`
69
+
70
+ 1. `gate_classifier` decides relevance.
71
+ 2. `thematic_to_data` rewrites broad questions to data tasks.
72
+ 3. Run tool loop (`system-prompt`) and collect tool trace + packet (`k360_research_compiler` optional).
73
+ 4. Render final markdown with `k360_narrative` (+ optional `data360://k360-narrative-style`).
74
+
75
+ ## 5. Design note
76
+
77
+ **Resources** carry always-on behavior and schemas. **MCP prompts** carry parameterized
78
+ playbooks for a *single* user turn. The **gate**/**reform** pair is playbook + policy on
79
+ the host; exposing them as prompts lets non-Python integrators reuse the same wording.
80
+ """
src/data360/mcp_server/prompts.py ADDED
@@ -0,0 +1,571 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Prompts for the Data360 MCP Server.
2
+
3
+ Exposes:
4
+
5
+ - ``SYSTEM_PROMPT``: default assistant instructions (search → codes →
6
+ disaggregation → data → visualization), including single-indicator
7
+ (``data360_get_viz_spec``) and multi-indicator
8
+ (``data360_get_multi_indicator_viz_spec``) paths.
9
+ - ``GATE_CLASSIFIER_PROMPT`` / thematic reform text: shared wording for hosts
10
+ and for ``data360-mcp-agent`` gated flows (keep in sync with that package's
11
+ ``gate.py`` defaults).
12
+ - ``@mcp.prompt()`` functions: reusable templates (indicator search, metadata,
13
+ country series, gate/reform for LangGraph hosts) that clients invoke by name.
14
+
15
+ The system prompt intentionally keeps both **operational detail** (filters, batch
16
+ codes, when to chart vs table) and the **ASCII tool-choice summary** so models
17
+ do not drop ``relevant_fields`` / ``indicator_ids`` when calling viz tools.
18
+
19
+ Integration overview: MCP resource ``data360://agent-recipe`` describes how to
20
+ compose resources + these prompts for ``data360-mcp-agent`` or custom clients.
21
+ """
22
+
23
+ from ._server_definition import mcp
24
+
25
+ # Default MCP prompt resource: full loop + viz decision tree (was originally in
26
+ # resources.py; extended for multi-indicator + 5-year defaults).
27
+ SYSTEM_PROMPT = """## Data360 Assistant
28
+
29
+ You are a tool-using assistant for World Bank Data360 indicators.
30
+
31
+ ### Non-negotiable rule
32
+ If the user request requires indicator lookup, metadata, codes, or data values, you MUST call tools.
33
+ Do not answer with guesses. Do not stop after describing a plan.
34
+
35
+ ### Operating loop (repeat until done)
36
+ 1) If you need indicators or statistical series → call data360_search_indicators.
37
+ - **CRITICAL: Search query is required**: You must always provide a search topic/term in `query`, `queries`, or `query_groups`. Do not omit it or pass empty values, even when filtering by database.
38
+ - **CRITICAL: Parameter Selection Decision Tree**: Default to using the single `query` parameter for any single topic/indicator search. Use the `queries` or `query_groups` parameters ONLY when the request involves multiple topics or scopes (2 or more):
39
+ - **Exactly 1 Topic** (e.g. "life expectancy") for any number of countries → you MUST use the single `query` parameter (e.g. `query="life expectancy"`) + `required_country`. Do NOT use `queries` with only one element, as it will fail. Do NOT combine multiple topics with 'and' or 'or' in `query`.
40
+ - **Multiple Topics, Same Geographic Scope** (e.g. "life expectancy and GDP per capita" for Japan) → you MUST use the `queries` list parameter (e.g. `queries=["life expectancy", "GDP per capita"]`) + `required_country`. Do NOT make multiple tool calls. Do NOT pass multiple topics combined as a single `query` string (e.g. `query="life expectancy and GDP per capita"` is invalid).
41
+ - **Different Topics targeting Different Country/Regional Scopes** (e.g. "life expectancy for Japan, but GDP and mortality rate for Korea") → you MUST use the `query_groups` list parameter. Example: `query_groups=[{"queries": ["life expectancy"], "country": "JPN"}, {"queries": ["GDP", "mortality rate"], "country": "KOR"}]`.
42
+ - **Multiple Databases**: Use a semicolon-separated string for `database` (e.g., `database="pip; wdi"`).
43
+ Example: `data360_search_indicators(queries=["population", "poverty"], database="pip; wdi")`
44
+ - **Database filter**: If the user's request specifies or strongly implies a specific database (e.g. "World Development Indicators", "WDI", "Worldwide Governance Indicators", "WGI"), pass it to the `database` argument (e.g. `database="wdi"`). Multiple databases can be filtered at once by passing a semicolon-separated string (e.g. `database="mpo; pip; lpgd"`).
45
+ If you need high-level dataset catalogs or source databases (e.g. Findex) → call data360_search_datasets.
46
+ - **CRITICAL**: The search API is sensitive to special characters. Strip parentheses `(`, `)` and currency signs like `$` from your query (e.g. search for "GDP per capita current US", NOT "GDP per capita (current US$)").
47
+ - **CRITICAL** when search returns multiple results: STOP — do not loop every row.
48
+ - **CRITICAL: Resolving Ambiguity**: If there are multiple matching indicators representing different metrics (e.g. constant prices vs current prices, real vs nominal GDP, or purchasing power parity vs market exchange rate), or if you are unsure which one the user wanted, do NOT guess. Instead, immediately call `data360_interactive_choices` with a clarifying prompt and the options (e.g. `data360_interactive_choices(prompt="Which GDP per capita series would you like to view?", options=["Real GDP per capita (constant 2015 US$)", "Nominal GDP per capita (current US$)", "PPP GDP per capita (constant 2017 int'l $)", "Specify custom..."], title="Select Indicator Variant")`). Then, STOP and wait for the user to make a selection.
49
+ - **Dynamic Custom Option**: When calling `data360_interactive_choices`, if the user might need an option outside of the static ones presented, you MUST dynamically append a customizable option at the end of the `options` list (e.g., `"Specify a custom range"`, `"Other (specify)"`, `"Choose a country..."`, or `"Specify custom..."`).
50
+ - Pick the **single best** indicator (relevance + coverage), then state:
51
+ "Selected Indicator: [ID] — [Name]" and "Why: [reason]".
52
+
53
+ 2) If you need country/dimension codes → call data360_find_codelist_value.
54
+ - Country: codelist_type="REF_AREA" (e.g. query="Kenya") → "KEN"
55
+ - Multi-country: pass a comma-separated query in **one** call (e.g. "Kenya, Uganda").
56
+ - Unit: codelist_type="UNIT_MEASURE" (e.g. "Current US$") when you must disambiguate units.
57
+ - Pass the **codes** (e.g. "KEN", "USA") into get_data filters, not display names.
58
+
59
+ #### Country Groups & Regional Aggregates
60
+ When data360_find_codelist_value returns a result with `is_group=true`:
61
+ - The code (e.g. "SAS", "LIC", "SSF") is a country **group**, not an individual country.
62
+ - Groups can be used directly in get_data for **aggregate/regional totals**.
63
+ - To work with **individual countries**, call data360_expand_country_group first.
64
+
65
+ **Decide based on the user's intent:**
66
+
67
+ | Intent | Example phrasing | Action |
68
+ |--------|-----------------|--------|
69
+ | Aggregate / regional view | "What is South Asia's GDP?" | Use group code directly → get_data(REF_AREA="SAS") |
70
+ | Country-level comparison | "Compare GDP across South Asian countries" | Expand → data360_expand_country_group("SAS") → use country_codes |
71
+ | Country-level comparison | "List poverty rates in low income countries" | Expand → data360_expand_country_group("LIC") → use country_codes |
72
+
73
+ When calling data360_expand_country_group, always check the returned `count` field:
74
+ - If count <= 20: proceed with country-level expansion without asking.
75
+ - If count > 20: **inform the user** before fetching. Say:
76
+ "This group contains N countries. Do you want individual country-level data
77
+ for all of them, or would you prefer the regional aggregate?"
78
+ Wait for confirmation before making N individual country calls.
79
+ Natural-language group phrases are recognized automatically:
80
+ - "South Asian countries" → SAS (6 countries)
81
+ - "Low income countries" → LIC (26 countries)
82
+ - "Sub-Saharan Africa" → SSF (48 countries)
83
+ - "Fragile states" → FCS (39 countries)
84
+ - "MENA" → MEA
85
+ - and many more via data360_find_codelist_value
86
+
87
+
88
+ 3) Confirm availability → call data360_get_disaggregation.
89
+ - **CRITICAL**: If UNIT_MEASURE has multiple values (e.g. KD vs CD), there are multiple options for a critical dimension (such as breakdowns like Sex, Age, or Education), or the timeframe/year range and disaggregation filters are ambiguous:
90
+ 1. Call `data360_interactive_choices` to let the user select:
91
+ * Between unit measures (e.g. "Constant 2015 US$ (Real)" vs "Current US$ (Nominal)" vs "Other (specify)")
92
+ * Between breakdowns/disaggregations (e.g. "National Average (Total)" vs "Disaggregate by Gender (Male vs Female)" vs "Other (specify)")
93
+ * Between timeframes/year ranges (e.g. "Latest available year" vs "Historical trend (last 10 years)" vs "Specify a custom range")
94
+ 2. **Dynamic Custom Option**: Always include a customizable option at the end of the `options` list (e.g., `"Specify custom..."` or `"Specify a custom range"`) so the user can enter their own input if none of the options are suitable.
95
+ 3. STOP and wait for the user to make a selection. Do not proceed until you receive the selection.
96
+ - Otherwise, pick **one** and filter.
97
+
98
+ 4) If you need raw data values for a **specific point lookup or small dataset** → call data360_get_data.
99
+ - **CRITICAL**: pass disaggregation_filters={"REF_AREA": "..."} when the user asked for a geography.
100
+ - Multiple countries: {"REF_AREA": "KEN,TZA"} in **one** call — not one call per country.
101
+ - Unpinned REF_AREA (no country_code / no REF_AREA string) returns **all geographic series** from the Data API, including regional aggregates (EAS, EMU, …). For **member economies only**, pass ref_area_filter="member_economies_only".
102
+ - Do not call get_data with no REF_AREA filter unless you want that full mix—or use ref_area_filter to narrow it.
103
+ - The response already includes indicator name/definition in many cases; you may not need a separate metadata call only for the title.
104
+ - **PAGINATION**: get_data returns ONE page. When has_more=True, call again with next_offset.
105
+ EXCEPTION: If the query involves 20+ countries (e.g. from data360_expand_country_group), do NOT
106
+ manually paginate get_data — use the aggregation tools in step 4b instead. They paginate
107
+ internally and return complete results without requiring you to loop.
108
+
109
+ 4b) If the user needs **analysis or the dataset is large** → use aggregation tools (these paginate
110
+ internally — you never need to call get_data in a loop when using them):
111
+
112
+ ┌─ WHEN TO USE AGGREGATION TOOLS (not get_data) ────────────────────────────┐
113
+ │ �� Country group was expanded via data360_expand_country_group (20+ codes) │
114
+ │ • User asks for a ranking, trend, summary, or comparison — not a lookup │
115
+ │ • You would otherwise need to loop get_data across multiple pages │
116
+ └───────────────────────────────────────────────────────────────────────────┘
117
+
118
+ ┌─ COMPARISON (2-8 countries)? ──────────────────────────────────────────┐
119
+ │ "Compare X across countries" / "How does A compare to B on Y?" │
120
+ │ → data360_compare_countries(country_codes="KEN;NGA;ZAF") │
121
+ │ Returns ranked snapshot + optional aligned time series + CAGR. │
122
+ └───────────────────────────────────────────────────────────────────────┘
123
+
124
+ ┌─ RANKING (large group / top-N)? ──────────────────────────────────────┐
125
+ │ **Within a region or group:** │
126
+ │ → data360_rank_countries(country_group="SAS", top_n=10) │
127
+ │ **Worldwide / all economies:** omit country_group and country_codes; │
128
+ │ → data360_rank_countries(..., rank_universe="all_member_economies") │
129
+ │ Returns ordered list + universe metadata; aggregates excluded. │
130
+ │ For expanded groups (SSF=48, HIC=83), this handles all pagination. │
131
+ └───────────────────────────────────────────────────────────────────────┘
132
+
133
+ ┌─ TREND / SUMMARY? ───────────────────────────────────────────────────┐
134
+ │ "How has X changed?" / "What is the trend of Y?" / "Summarize Z" │
135
+ │ → data360_summarize_data(country_code="KEN") │
136
+ │ Returns min/max/mean/trend_direction + percent change. │
137
+ │ group_by supports multiple columns (e.g. ["ref_area", "sex"]). │
138
+ └───────────────────────────────────────────────────────────────────────┘
139
+
140
+ 5) Visualization — choose the right tool:
141
+
142
+ - Call data360_get_supported_chart_types to see every option and required columns.
143
+ - **DECIDE**: Does the frame support a chart? (e.g. time_period + obs_value for lines.)
144
+ - If the shape does **not** support a chart, present the **table** — do not force a broken viz.
145
+
146
+ #### Grammar-of-graphics rule for disaggregation_filters
147
+ **Do NOT pin a dimension in disaggregation_filters to simplify a chart.**
148
+ Pinning collapses multi-series data into a single line and silently discards information.
149
+
150
+ | Situation | Correct action |
151
+ |-----------|---------------|
152
+ | User said "show only females" | `disaggregation_filters={"SEX": "F"}` |
153
+ | User said "totals only" / "aggregate" | `disaggregation_filters={"SEX": "_T"}` |
154
+ | Dimension not applicable | `disaggregation_filters={"SEX": "_Z"}` |
155
+ | Multiple values exist, user has NO preference | **OMIT the dimension entirely** — the pipeline maps it to color/facet automatically |
156
+
157
+ The pipeline auto-detects non-trivial dimensions (_T/_Z are trivial) and routes:
158
+ - breakdown + multi-year + 1 country → TEMPORAL_SINGLE (color = breakdown dim)
159
+ - breakdown + multi-country → SMALL_MULTIPLES (facet = country, color = breakdown)
160
+ - no breakdown, multi-year → TEMPORAL_SINGLE (color = country)
161
+
162
+ #### Mixed-unit breakdown warning (Option A)
163
+ Some indicators carry comp_breakdown_1 values that represent **structurally different
164
+ metric types** — not just different categories of the same quantity. Example: WGI uses
165
+ WGI_EST (estimate, ~−2.5 to +2.5), WGI_SC (percentile rank, 0–100), WGI_SE (standard
166
+ error), WGI_SR (source count), WGI_SC_LB/UB (confidence bounds). Plotting all on one
167
+ Y-axis is misleading because the scales are incompatible.
168
+
169
+ **When you call data360_get_disaggregation and find comp_breakdown_1 has 3+ values,
170
+ check if they represent different metric types** (e.g. estimate + rank + error + count).
171
+ If so, BEFORE calling the visualization tool:
172
+ 1. List the available breakdown values and their meanings to the user.
173
+ 2. Recommend the primary analytic series (e.g. WGI_EST for governance analysis,
174
+ WGI_SC for cross-country rank comparison).
175
+ 3. Ask which breakdown the user wants, or proceed with the recommended one and say so.
176
+
177
+ The chart subtitle will always list all series present — users can read it to understand
178
+ what is shown. But proactively surfacing the choice is better than a crowded chart.
179
+
180
+ ┌─ ONE indicator? ──────────────────────────────────────────────────────────┐
181
+ │ Call data360_get_viz_spec │
182
+ │ • Multi-year, 1-8 countries → chart_type="line" (auto color by cntry) │
183
+ │ • Single year, ≤8 countries → chart_type="bar" │
184
+ │ • Single year, >8 countries → chart_type="strip" │
185
+ │ • Sex/age breakdown present → chart_type="small_multiples" │
186
+ │ • Pass relevant_fields=["time_period","obs_value",...] when you must │
187
+ │ pin exact columns; the tool can auto-enrich dimensions when needed. │
188
+ │ • If the user asked for a style ("bar chart"), pass chart_type="bar". │
189
+ └───────────────────────────────────────────────────────────────────────────┘
190
+
191
+ ┌─ TWO OR MORE indicators? ─────────────────────────────────────────────────┐
192
+ │ Call data360_get_multi_indicator_viz_spec │
193
+ │ • REQUIRED: indicator_ids — JSON array of 2–4 objects (never omit). │
194
+ │ Each object MUST be {"database_id": "<db>", "indicator_id": "<id>"}. │
195
+ │ Example: │
196
+ │ [{"database_id": "WB_WDI", "indicator_id": "WB_WDI_NY_GDP_PCAP_KD"}, │
197
+ │ {"database_id": "WB_WDI", "indicator_id": "WB_WDI_SP_DYN_LE00_IN"}] │
198
+ │ • Optional: country_code, start_year, end_year, disaggregation_filters, │
199
+ │ chart_type — same names as data360_get_viz_spec except there is NO │
200
+ │ relevant_fields or custom_constraints. │
201
+ │ │
202
+ │ Chart type selection: │
203
+ │ • "Compare X vs Y across countries, one year" → chart_type="scatter" │
204
+ │ • "How X and Y moved together over time" → chart_type="connected_scatter"│
205
+ │ • "Show X and Y trends for one country" → chart_type="layered_lines"│
206
+ │ • Let the tool auto-select when unsure → omit chart_type │
207
+ └───────────────────────────────────────────────────────────────────────────┘
208
+
209
+ Call data360_get_supported_chart_types for the full list of options and requirements.
210
+
211
+ Then provide the final answer to the user (after tools complete).
212
+
213
+ ### Defaults
214
+ - Time range: last 5 years unless user specifies otherwise.
215
+ start_year = (current_year - 4), end_year = current_year
216
+ - Breakdowns (e.g. by sex): use disaggregation_filters={"SEX": null} to get all groups.
217
+
218
+ ### Output behavior
219
+ - When a tool is needed, your next message MUST be a tool call (no extra text).
220
+ - After tools return, continue with the next needed tool call.
221
+ - Only produce a normal user-facing response when no further tool calls are required.
222
+ - When presenting a chart, always describe what the visualization shows in 1-2 sentences.
223
+
224
+ ### Rules for Follow-ups and Elicitations (data360_interactive_choices)
225
+ You typically provide follow-ups and elicitations using the `data360_interactive_choices` tool based on the natural flow of our conversation and the type of information we are discussing. Your goal is to anticipate the user's next question or provide an easy way to steer a broad topic.
226
+
227
+ **CRITICAL: Dynamic Customizable Options on Non-Exhaustive Lists**
228
+ Whenever you call `data360_interactive_choices` with a list of options that is not exhaustive (for example, listing a few popular countries/economies, specific years, indicator variants, or breakdowns), you **MUST** dynamically include a customizable option as the last item in the `options` list. Clicking this option will dynamically present the user with a text input field to type their response.
229
+ - **Country selection (Non-exhaustive)**: `options=["Kenya", "Nigeria", "South Africa", "United States", "India", "Specify another country..."]`
230
+ - **Timeframe/Year range**: `options=["2024 (latest)", "Last 5 years", "Last 10 years", "Specify a custom range"]`
231
+ - **Ambiguity resolution**: `options=["Total Average", "Breakdown by Gender", "Other (specify)"]`
232
+
233
+ Here are the specific scenarios when you should call `data360_interactive_choices`:
234
+
235
+ #### 1. Single Follow-up (1 choice)
236
+ * **The "Obvious Next Step"**: When there is one highly logical action to take after your response. For example, if you explain a mathematical concept, the follow-up might offer to walk through a practical example.
237
+ * **Deep Dives into Jargon**: If your response introduces a complex technical term or a new concept, you might offer a single follow-up to explain that specific term so the main response does not get too cluttered.
238
+ * **Launching Interactive Tools**: If you mention that you can build a widget or run a simulation, provide a single button to let the user trigger that specific interactive element directly.
239
+
240
+ #### 2. Multiple Choices (2+ choices)
241
+ * **Broad Overviews & Branching Paths**: When you give a high-level summary of a massive topic, use multiple choices to let the user choose exactly which sub-category or "branch" you want to zoom in on next.
242
+ * **Disambiguation (Clarifying Intent)**: If the user's request is open-ended or could be interpreted in a few different ways (such as selecting between real or nominal series, different indicator options, timeframe/year ranges, or disaggregations/breakdowns), present options so the user can clarify exactly which direction they meant to take. **Always dynamically include a customizable option (e.g. "Specify custom...", "Other (specify)", or "Specify a custom range") at the end of the options list to allow custom typing if none of the predefined options suffice.**
243
+ * **Menus and Brainstorming**: When generating lists of ideas—like different programming frameworks, design patterns, or troubleshooting steps—use multiple choices to act like a clickable menu, letting the user instantly select the one you want to explore.
244
+
245
+ Essentially, surface these components using `data360_interactive_choices` whenever you can save the user the effort of typing out the logical next prompt, or when the conversation has reached a crossroads and you need the user to choose the direction. Always make sure to include a dynamic customizable option if the static list might not fully satisfy the user's possible need.
246
+ """
247
+
248
+ # Mirrors ``data360_mcp_agent.gate.GATE_SYSTEM_PROMPT`` — update both when changing rules.
249
+ GATE_CLASSIFIER_PROMPT = """You are a classifier for a Data360 / World Bank data assistant.
250
+
251
+ Decide if the user's latest message should engage the Data360 MCP tool-using agent.
252
+
253
+ **In scope (relevant = true)** — include ALL of:
254
+ 1. Direct data questions: indicators, countries/economies, years, comparisons, charts.
255
+ 2. Development economics and World Bank–aligned operations questions where World Bank / Data360-style **data** can illuminate the answer — macro, fiscal/public spending, poverty, inequality, labor markets, human capital, trade, investment climate, climate-related **development** metrics, etc. — even if the user did not name an indicator code.
256
+ 3. Country or regional **policy-relevant themes** that can be grounded in measurable series or country metadata (e.g. "main challenges facing Ghana's growth and public spending", "structural labor market challenges in Morocco", "climate change and economic development in Bangladesh").
257
+
258
+ **Out of scope (relevant = false)**:
259
+ - Pure chit-chat, unrelated trivia, entertainment.
260
+ - Homework or tasks with no plausible path through WB-style data.
261
+ - Medical, legal, or personal advice.
262
+ - Questions with no link to development data (e.g. street-level weather, sports scores, generic coding help unrelated to this data assistant).
263
+
264
+ **Bias**: When the topic is clearly development- or WB-adjacent and data could support an evidence-based answer, choose **relevant = true**.
265
+
266
+ When relevant is false, set refusal_text to one short, polite sentence explaining the assistant focuses on WB/Data360 data (optional; a default may be used). When relevant is true, leave refusal_text null or empty."""
267
+
268
+ # Mirrors ``data360_mcp_agent.gate.REFORM_SYSTEM_PROMPT`` — update both when changing.
269
+ THEMATIC_REFORM_SYSTEM = """You rewrite user questions into **one** concrete orchestration prompt for a Data360 MCP agent that will search indicators, fetch series, and build charts.
270
+
271
+ Given the user's message (often thematic or knowledge-style), output:
272
+ - data_question: A single clear instruction naming **economy/ies** (ISO codes or standard country names), **indicator themes or concrete search terms**, a **reasonable year range**, and **peer or benchmark** comparisons when useful (e.g. regional peers, world, income group).
273
+ - search_hints: Short bullet-style strings the agent can use when searching tools (e.g. "GDP growth annual", "general government final consumption", "labor force participation", "youth unemployment", "ND-GAIN" or other WB-relevant climate vulnerability metrics if applicable).
274
+ - rewritten: true if you materially rewrote/expanded the user request; false if the user request is already an explicit, well-formed data question and should be preserved as-is.
275
+
276
+ Rules:
277
+ - Do not broaden scope for explicit data asks. Example: "Latest GDP growth in Vietnam." should remain focused on Vietnam latest GDP growth.
278
+ - Rewrite when the immediate question is thematic, underspecified, or depends on prior context.
279
+ - Do not answer the question yourself; only produce the reformulated task. Stay within evidence the World Bank / Data360 tools could retrieve."""
280
+
281
+ K360_RESEARCH_COMPILER_PROMPT = """You are the K360 compile stage for a Data360 tool-using agent.
282
+
283
+ You are given:
284
+ - Original user question
285
+ - Rewritten data question (if available)
286
+ - Tool call trace and tool results
287
+
288
+ Output ONLY a JSON object with this shape:
289
+ {
290
+ "query_focus": "short sentence",
291
+ "selected_indicators": [{"database_id":"...", "indicator_id":"...", "name":"optional"}],
292
+ "geographies": ["KEN", "UGA"],
293
+ "time_range": {"start_year": 2015, "end_year": 2024},
294
+ "key_observations": ["..."],
295
+ "sources": ["WB_WDI"],
296
+ "viz_assets": [{"url":"...", "chart_type":"optional"}],
297
+ "data_gaps": ["..."]
298
+ }
299
+
300
+ Rules:
301
+ - Be faithful to tool outputs; do not invent values.
302
+ - Keep key_observations concise and evidence-grounded.
303
+ - If information is missing, leave arrays empty or note in data_gaps.
304
+ - No markdown, no prose, no code fences, JSON only.
305
+ """
306
+
307
+ K360_NARRATIVE_PROMPT = """You are the K360 narrative stage for Data360 outputs.
308
+
309
+ Write a polished markdown answer using this exact section style (omit empty sections):
310
+ **Data:** factual results from the content packet and tool outputs
311
+ **Analysis:** interpretation and comparisons
312
+ **Note:** caveats, data gaps, or definition warnings
313
+ **Sources:** concise source list
314
+
315
+ Formatting rules:
316
+ - Keep it concise and policy-usable.
317
+ - Start with a 1-2 sentence high-level insight, then details.
318
+ - Prefer bullet points for multi-country comparisons and timelines.
319
+ - If presenting 3+ related numeric values (years/countries/metrics), use a markdown table.
320
+ - If fewer than 3 related numeric values, use short bullets or a paragraph.
321
+ - Do NOT prefix section labels with list markers (no "- **Data:**"). Section labels must appear as plain bold labels.
322
+ - Include units and time period whenever showing numeric values.
323
+ - For large numbers (money, GDP, population), use comma separators or compact human-readable forms (e.g., 1.2 million).
324
+ - Never use scientific notation unless explicitly requested by the user.
325
+ - When multiple source lines exist, list them as bullets under **Sources:** in this format:
326
+ **Database name** — Indicator name — methodology note (if available)
327
+ - If there is no usable data, say so clearly and suggest one next-best query.
328
+ - If chart URLs are present, mention what each chart shows in plain language.
329
+ - If include_claim_tags is true, wrap numeric claims as:
330
+ <claim id="short-id">...</claim>
331
+
332
+ Do not fabricate data. Use only provided tool evidence.
333
+ """
334
+
335
+
336
+ @mcp.prompt()
337
+ def gate_classifier() -> str:
338
+ """In/out-of-scope classifier instructions for Data360 (for hosts that fetch prompts from MCP).
339
+
340
+ Pair with a structured-output LLM call or with ``data360-mcp-agent``'s gated node,
341
+ which embeds the same rules locally by default.
342
+ """
343
+ return GATE_CLASSIFIER_PROMPT
344
+
345
+
346
+ @mcp.prompt()
347
+ def thematic_to_data(user_message: str) -> str:
348
+ """Rewrite a thematic development question into a concrete indicator/data task.
349
+
350
+ Hosts often follow this with ``data360://system-prompt`` and the tool loop.
351
+ """
352
+ return f"""{THEMATIC_REFORM_SYSTEM}
353
+
354
+ ---
355
+ User message to reformulate:
356
+ {user_message}
357
+ """
358
+
359
+
360
+ @mcp.prompt()
361
+ def indicator_search(
362
+ query: str,
363
+ country: str = "",
364
+ required_dimensions: str = "",
365
+ database: str = "",
366
+ ) -> str:
367
+ """Guide LLM to find and select the best indicator for a query.
368
+
369
+ Args:
370
+ query: Search query (e.g., "unemployment rate", "poverty")
371
+ country: Optional country to validate (e.g., "Kenya")
372
+ required_dimensions: Optional comma-separated dimensions (e.g., "SEX,AGE")
373
+ database: Optional database filter (e.g., "wdi", "World Development Indicators"). Multiple databases can be filtered at once by separating them with a semicolon (e.g. "pip; lpgd; sgi").
374
+ """
375
+ dims_list = required_dimensions.split(",") if required_dimensions else []
376
+ db_arg = f',\n database="{database}"' if database else ""
377
+
378
+ return f"""To find the best indicator for '{query}':
379
+
380
+ 1. Use enriched search:
381
+ data360_search_indicators(
382
+ query="{query}",
383
+ limit=5{db_arg}
384
+ )
385
+
386
+ 2. For promising candidates, validate with get_disaggregation:
387
+ - Check TIME_PERIOD for actual years (may have gaps)
388
+ - Check REF_AREA for country coverage{f" - verify '{country}' is available" if country else ""}
389
+ - Check available dimensions{f" - need: {dims_list}" if dims_list else ""}
390
+
391
+ 3. **Dimension Analysis (CRITICAL)**:
392
+ - Look at the `dimensions` list from step 2 (or call available_dimensions) or multiple candidate indicators.
393
+ - **Identify Ambiguity**: Are there dimensions with multiple values (besides TIME_PERIOD and REF_AREA) or multiple matching indicator series (e.g., constant vs current)?
394
+ - Example: `UNIT_MEASURE: ["KD", "CD"]` (Constant vs Current).
395
+ - Example: `VALUATION: ["MER", "PPP"]`.
396
+ - **Clarify via Choice Card**: If you cannot resolve this ambiguity based on user context, immediately call `data360_interactive_choices` to let the user select via interactive buttons.
397
+ Example: `data360_interactive_choices(prompt="Which series would you like to view?", options=["Real GDP per capita", "Nominal GDP per capita", "Specify custom..."])`
398
+ After calling it, STOP and wait for the user to make a selection. Do not proceed until you receive the selection.
399
+
400
+ 4. **Validation Check**:
401
+ - If the user asked for a specific country (e.g. Kenya), do NOT call `get_data` without `disaggregation_filters={{"REF_AREA": "KEN"}}` (plus your selected dimension filters). Values must be strings or null per dimension — not JSON arrays.
402
+ - Multiple ISO codes in one filter: comma-separated string, e.g. `{{"REF_AREA": "KEN,TZA", "UNIT_MEASURE": "KD"}}`. Prefer commas in `disaggregation_filters`; the server also normalizes semicolons in REF_AREA to commas. The top-level `country_code` argument uses semicolons for multiple codes (e.g. `KEN;TZA`).
403
+ - Use `null` only for a **specific** dimension when you want every value of that dimension (e.g. all sexes), not to skip geography when the user named a country.
404
+ - Asking for `disaggregation_filters=null` returns global aggregates AND all unit variants, which ruins charts.
405
+
406
+ 5. **Selection**:
407
+ - Pick the SINGLE best indicator ID. Do not loop.
408
+ - Use the `database_id` exactly as returned in the search result (do not guess).
409
+ - Ensure your `get_data` call will carry the specific filters you decided on.
410
+ """
411
+
412
+
413
+ @mcp.prompt()
414
+ def indicator_details(
415
+ indicator_id: str,
416
+ database_id: str,
417
+ question: str = "",
418
+ ) -> str:
419
+ """Guide LLM to get appropriate metadata based on user question.
420
+
421
+ Args:
422
+ indicator_id: Indicator ID (e.g., "WB_GS_NY_GDP_PCAP_KD")
423
+ database_id: Database ID (e.g., "WB_GS")
424
+ question: Optional specific question to answer
425
+ """
426
+ return f"""To answer questions about indicator '{indicator_id}':
427
+
428
+ 1. Read data360://metadata-fields to find which field answers the question
429
+
430
+ 2. Map user question to field(s):
431
+ - "how is it calculated" → ["methodology"]
432
+ - "statistical concept" → ["statistical_concept"]
433
+ - "what is this" → ["definition_long"]
434
+ - "limitations" → ["limitation"]
435
+ - "why important" → ["relevance"]
436
+
437
+ 3. Call data360_get_metadata with select_fields to fetch ONLY needed fields:
438
+ data360_get_metadata(
439
+ database_id="{database_id}",
440
+ indicator_id="{indicator_id}",
441
+ select_fields=["methodology"] # Only fetch what's needed
442
+ )
443
+
444
+ User question: {question if question else "(general overview - use definition_long)"}
445
+ """
446
+
447
+
448
+ @mcp.prompt()
449
+ def country_data(
450
+ query: str,
451
+ country: str,
452
+ start_year: str = "",
453
+ end_year: str = "",
454
+ database: str = "",
455
+ ) -> str:
456
+ """Guide LLM through end-to-end data retrieval for a country.
457
+
458
+ Args:
459
+ query: Indicator search query
460
+ country: Country name or comma-separated list (e.g., "Kenya" or "Kenya, Uganda")
461
+ start_year: Optional start year
462
+ end_year: Optional end year
463
+ database: Optional database filter (e.g., "wdi", "World Development Indicators"). Multiple databases can be filtered at once by separating them with a semicolon (e.g. "pip; lpgd; sgi").
464
+ """
465
+ db_arg = f',\n database="{database}"' if database else ""
466
+ return f"""To get {query} data for {country}:
467
+
468
+ <thinking>
469
+ 1. Need to find the right indicator
470
+ 2. Need to convert country name(s) to codes
471
+ 3. Need to validate data availability
472
+ 4. Then fetch the data in ONE call
473
+ </thinking>
474
+
475
+ **Step 1: Resolve country code**
476
+ data360_find_codelist_value(codelist_type="REF_AREA", query="{country}")
477
+ # Returns list of codes, e.g. "KEN" or "KEN,UGA"
478
+
479
+ **Step 2: Search for indicator**
480
+ data360_search_indicators(
481
+ query="{query}",
482
+ limit=5,
483
+ required_country="{country}"{db_arg} # Pass the list string as-is
484
+ )
485
+
486
+ **Step 3: Validate availability & Dimensions**
487
+ For chosen indicator, call:
488
+ data360_get_disaggregation(database_id=<db_id>, indicator_id=<ind_id>)
489
+ - Confirm country code is in REF_AREA
490
+ - Check TIME_PERIOD
491
+ - **CRITICAL**: Check for multiple values in other dimensions (e.g. UNIT_MEASURE).
492
+ - If found, pick ONE.
493
+ - If you need ALL values for a dimension (e.g. SEX), pass `NULL` in the filter.
494
+
495
+ **Step 4: Get data**
496
+ data360_get_data(
497
+ database_id=<db_id>,
498
+ indicator_id=<ind_id>,
499
+ disaggregation_filters={{"REF_AREA": "<ISO comma-separated, e.g. KEN or KEN,TZA>", "UNIT_MEASURE": "..."}},
500
+ start_year={start_year if start_year else "None (Defaults to last 5 years)"},
501
+ end_year={end_year if end_year else "None"}
502
+ )
503
+ # Or omit REF_AREA in disaggregation_filters and use top-level country_code="KEN" or "KEN;MAR".
504
+
505
+ **Step 5: Visualize**
506
+ If data is suitable (time series), visualize directly.
507
+ For multi-country, the tool auto-handles color-coding.
508
+
509
+ Grammar-of-graphics rule: do NOT pin disaggregation dimensions to simplify the chart.
510
+ If the indicator has breakdowns (sex, age, comp_breakdown_1, etc.) and the user did not
511
+ request a specific value, OMIT those dimensions from disaggregation_filters entirely.
512
+ The pipeline maps them to color/facet channels automatically.
513
+
514
+ WRONG: disaggregation_filters={{"COMP_BREAKDOWN_1": "WGI_EST"}} # silently drops 5 series
515
+ RIGHT: (omit COMP_BREAKDOWN_1 — pipeline auto-routes to SMALL_MULTIPLES or multi-series line)
516
+
517
+ data360_get_viz_spec(
518
+ database_id=<db_id>,
519
+ indicator_id=<ind_id>,
520
+ country_code="<ISO: one code, or several with semicolons e.g. KEN;UGA>",
521
+ # Only pin a dimension when the user explicitly requested it:
522
+ # disaggregation_filters={{"SEX": "F"}}, # user said "females only"
523
+ # disaggregation_filters={{"SEX": "_T"}}, # user said "totals only"
524
+ chart_type="line"
525
+ )
526
+ """
527
+
528
+
529
+ @mcp.prompt()
530
+ def k360_research_compiler(
531
+ user_question: str,
532
+ data_question: str = "",
533
+ tool_calls_json: str = "[]",
534
+ ) -> str:
535
+ """Compile tool outputs into a stable content packet (JSON only)."""
536
+ return f"""{K360_RESEARCH_COMPILER_PROMPT}
537
+
538
+ ---
539
+ User question:
540
+ {user_question}
541
+
542
+ Rewritten data question:
543
+ {data_question}
544
+
545
+ Tool calls JSON:
546
+ {tool_calls_json}
547
+ """
548
+
549
+
550
+ @mcp.prompt()
551
+ def k360_narrative(
552
+ user_question: str,
553
+ content_packet_json: str,
554
+ raw_tool_results_json: str = "[]",
555
+ include_claim_tags: str = "false",
556
+ ) -> str:
557
+ """Generate a user-facing narrative from K360 content packet + evidence."""
558
+ return f"""{K360_NARRATIVE_PROMPT}
559
+
560
+ include_claim_tags={include_claim_tags}
561
+
562
+ ---
563
+ User question:
564
+ {user_question}
565
+
566
+ Content packet JSON:
567
+ {content_packet_json}
568
+
569
+ Raw tool results JSON:
570
+ {raw_tool_results_json}
571
+ """
src/data360/mcp_server/resources.py ADDED
@@ -0,0 +1,624 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Resources for the Data360 MCP Server.
2
+
3
+ These resources provide static context to help LLMs understand the Data360 system.
4
+ Includes ``data360://agent-recipe`` for host integrators (LangGraph / data360-mcp-agent).
5
+ """
6
+
7
+ import json
8
+ from datetime import datetime
9
+
10
+ from fastmcp.apps import AppConfig, ResourceCSP
11
+ from data360.providers import get_database_mapping
12
+
13
+ from ._server_definition import mcp
14
+ from .agent_recipe import AGENT_RECIPE_MARKDOWN
15
+
16
+ # System prompt with chain-of-thought guidance for chatbot integration
17
+ from .prompts import SYSTEM_PROMPT
18
+
19
+
20
+ CODELISTS = {
21
+ "auto_resolved": {
22
+ "description": (
23
+ "The pipeline auto-resolves these dimensions from the extdataportal codelist. "
24
+ "series_labels is NOT required for these dimensions."
25
+ ),
26
+ "source": "https://extdataportal.worldbank.org/api/data360/metadata/codelist",
27
+ "dimensions": {
28
+ "COMP_BREAKDOWN_1": "5 191 indicator-subtype codes (e.g. WGI_EST, IPC_IPC_PHASE3, WEF_TTDI_RNK)",
29
+ "COMP_BREAKDOWN_2": "Same pool as COMP_BREAKDOWN_1",
30
+ "UNIT_MEASURE": "769 unit codes auto-resolved in Y-axis labels and subtitles",
31
+ "SEX": "7 codes: F=Female, M=Male, _T=Total, _O=Other, _U=Unknown, _Z=Not applicable",
32
+ "AGE": "173 codes: _T=All ages, Y15T24=15-24 years, Y_GE25=25+ years, etc.",
33
+ "URBANISATION": "16 codes: URB=Urban area, RUR=Rural area, CITY=City, VILL=Village, etc.",
34
+ "FREQ": "34 codes: A=Annual, M=Monthly, Q=Quarterly, etc.",
35
+ },
36
+ },
37
+ "manual_override": {
38
+ "description": (
39
+ "Provide series_labels only to shorten or rename auto-resolved labels, "
40
+ "e.g. to show 'Estimate' instead of 'Governance estimate (approx. -2.5 to +2.5)'."
41
+ ),
42
+ "example": {"WGI_EST": "Estimate", "WGI_SC": "Score", "WGI_SE": "Std. Error"},
43
+ },
44
+ "geographic": {
45
+ "description": "REF_AREA groups resolved via GroupHierarchyManager (FMR H_REF_AREA_GROUPS)",
46
+ "individual_countries": "532 codes — resolved automatically to country names",
47
+ "groups": "147 group codes (REGION, INCOME, LENDING, CONTINENT) — use expand_country_group",
48
+ },
49
+ }
50
+
51
+
52
+ METADATA_FIELDS = {
53
+ "fields": {
54
+ "methodology": {
55
+ "description": "How the indicator is calculated/measured",
56
+ "use_when": ["how is it calculated", "calculation method", "methodology"],
57
+ },
58
+ "statistical_concept": {
59
+ "description": "Statistical definition and conceptual framework",
60
+ "use_when": ["statistical concept", "what does it measure", "definition"],
61
+ },
62
+ "definition_long": {
63
+ "description": "Full description of the indicator",
64
+ "use_when": ["what is", "describe", "explanation"],
65
+ },
66
+ "limitation": {
67
+ "description": "Known data limitations and caveats",
68
+ "use_when": ["limitations", "caveats", "data quality", "issues"],
69
+ },
70
+ "relevance": {
71
+ "description": "Policy relevance and why this indicator matters",
72
+ "use_when": ["why important", "relevance", "policy implications"],
73
+ },
74
+ "aggregation_method": {
75
+ "description": "How values are aggregated (Sum, Average, etc.)",
76
+ "use_when": ["aggregation", "how combined", "sum or average"],
77
+ },
78
+ "periodicity": {
79
+ "description": "Data frequency (Annual, Monthly, etc.)",
80
+ "use_when": ["frequency", "how often", "periodicity"],
81
+ },
82
+ "time_periods": {
83
+ "description": "Nominal time range (may have gaps)",
84
+ "use_when": ["time range", "years available", "coverage"],
85
+ "note": "Call get_disaggregation for actual available years",
86
+ },
87
+ "ref_country": {
88
+ "description": "List of countries with data",
89
+ "use_when": ["countries", "coverage", "available for"],
90
+ },
91
+ "sources_note": {
92
+ "description": "Information about data sources",
93
+ "use_when": ["source", "where from", "data provider"],
94
+ },
95
+ }
96
+ }
97
+
98
+
99
+ DATA_FILTERS = {
100
+ "workflow": "Call get_disaggregation first to see available values for each filter",
101
+ "supported_filters": {
102
+ "timePeriodFrom": {"description": "Start year", "example": "2020"},
103
+ "timePeriodTo": {"description": "End year", "example": "2023"},
104
+ "REF_AREA": {
105
+ "description": "Country code(s). Use comma-separated for multiple.",
106
+ "example": "KEN,TZA",
107
+ },
108
+ "SEX": {"values": ["F", "M", "_T", "_O", "_U", "_Z"]},
109
+ "AGE": {
110
+ "description": "173 age codes — common ones below; use get_disaggregation for indicator-specific values",
111
+ "common_values": ["_T", "Y15T24", "Y15T29", "Y30T59", "Y_GE25", "Y_GE60", "Y18T65"],
112
+ },
113
+ "URBANISATION": {
114
+ "values": ["_T", "URB", "RUR", "CITY", "VILL", "DTOW", "TSUB", "STOW", "SUBU", "SURB", "LURB", "_O", "_Z"],
115
+ },
116
+ },
117
+ "excluded_filters": {"FREQ": "DO NOT USE - breaks queries"},
118
+ "important": "Check TIME_PERIOD in disaggregation for actual available years (may have gaps)",
119
+ }
120
+
121
+
122
+ DATA_SCHEMA = {
123
+ "description": "Data rows are prefiltered to only include relevant fields. Always present: 5 core fields. Conditionally present: disaggregation fields when their values are non-trivial.",
124
+ "core_fields": {
125
+ "obs_value": "The numeric data value.",
126
+ "time_period": "Date or year of the observation (e.g., '2023', '2024-07-01').",
127
+ "ref_area": "Country or region code (e.g., 'KEN').",
128
+ "unit_measure": "Unit of measurement (e.g., 'PT', 'USD_K_2015', 'PS').",
129
+ "claim_id": "Verification hash for data provenance.",
130
+ },
131
+ "conditional_fields": {
132
+ "description": "Included only when values carry real disaggregation (not _T total or _Z not-applicable).",
133
+ "sex": "Gender breakdown ('F', 'M'). Present in WB_HCP, WB_SSGD, WB_GS.",
134
+ "age": "Age group ('Y15T24', 'Y18T65', etc.). Present in WB_SSGD, OECD_IDD.",
135
+ "urbanisation": "Urban/Rural ('URB', 'RUR'). Present in WB_SSGD.",
136
+ "comp_breakdown_1": "Indicator subtype (e.g., IPC phase period, OECD indicator type, WEF rank/value/score).",
137
+ "comp_breakdown_2": "Secondary breakdown (e.g., IPC phase level, OECD income definition).",
138
+ },
139
+ "visualization_guidance": "When calling get_viz_spec(relevant_fields=...), prioritize 'time_period' and 'obs_value'. Include 'ref_area' or dimensions like 'sex' only for comparison/grouping.",
140
+ }
141
+
142
+
143
+ SEARCH_USAGE = {
144
+ "basic_search": {
145
+ "example": "data360_search_indicators(query='poverty', limit=10)",
146
+ "note": "Uses default select_fields",
147
+ },
148
+ "enriched_search": {
149
+ "example": "data360_search_indicators(query='poverty', limit=5, select_fields=['idno', 'name', 'database_id', 'definition_long', 'periodicity', 'time_periods', 'dimensions'])",
150
+ "note": "Use when LLM needs to pick best indicator",
151
+ },
152
+ "indicator_selection_workflow": [
153
+ "1. Use enriched search with select_fields for extra coverage info",
154
+ "2. Call get_disaggregation to check TIME_PERIOD and REF_AREA",
155
+ "3. Pick indicator based on coverage, time range, and relevance",
156
+ ],
157
+ "warning": "DO NOT use odata_options - it is deprecated",
158
+ }
159
+
160
+ K360_NARRATIVE_STYLE = {
161
+ "sections": ["Data", "Analysis", "Note", "Sources"],
162
+ "required_behavior": [
163
+ "Ground every statement in tool evidence or content packet fields.",
164
+ "Use concise markdown suitable for analyst and policy audiences.",
165
+ "When chart outputs exist, describe what each chart conveys in 1-2 sentences.",
166
+ "If no data is available, clearly state the gap and suggest a narrower follow-up query.",
167
+ ],
168
+ "optional_claim_tags": {
169
+ "enabled_by": "include_claim_tags=true",
170
+ "format": "<claim id=\"short-id\">numeric statement</claim>",
171
+ },
172
+ }
173
+
174
+
175
+ @mcp.resource("data360://system-prompt")
176
+ async def system_prompt_resource() -> str:
177
+ """System prompt with chain-of-thought guidance for chatbot integration."""
178
+ return SYSTEM_PROMPT
179
+
180
+
181
+ @mcp.resource("data360://agent-recipe")
182
+ async def agent_recipe_resource() -> str:
183
+ """How to compose MCP resources + named prompts for LangGraph / ``data360-mcp-agent``."""
184
+ return AGENT_RECIPE_MARKDOWN
185
+
186
+
187
+ @mcp.resource("data360://context")
188
+ async def context_resource() -> str:
189
+ """Runtime context including current date. Read this to know today's date."""
190
+ return json.dumps(
191
+ {
192
+ "current_date": datetime.now().strftime("%Y-%m-%d"),
193
+ "current_year": datetime.now().year,
194
+ "note": "Use current_year to calculate 'last N years' queries",
195
+ },
196
+ indent=2,
197
+ )
198
+
199
+
200
+ @mcp.resource("data360://databases")
201
+ async def databases_resource() -> str:
202
+ """List of available Data360 databases."""
203
+ db_mapping = await get_database_mapping()
204
+ formatted = {"databases": [{"id": k, "name": v} for k, v in db_mapping.items()]}
205
+ return json.dumps(formatted, indent=2)
206
+
207
+
208
+ @mcp.resource("data360://codelists")
209
+ async def codelists_resource() -> str:
210
+ """Codelist reference information (global and indicator-level)."""
211
+ return json.dumps(CODELISTS, indent=2)
212
+
213
+
214
+ @mcp.resource("data360://metadata-fields")
215
+ async def metadata_fields_resource() -> str:
216
+ """Metadata field mapping for smart routing based on user questions."""
217
+ return json.dumps(METADATA_FIELDS, indent=2)
218
+
219
+
220
+ @mcp.resource("data360://data-filters")
221
+ async def data_filters_resource() -> str:
222
+ """Available data filters and usage guidance."""
223
+ return json.dumps(DATA_FILTERS, indent=2)
224
+
225
+
226
+ @mcp.resource("data360://data-schema")
227
+ async def data_schema_resource() -> str:
228
+ """Standard data schema and column definitions for visualization."""
229
+ return json.dumps(DATA_SCHEMA, indent=2)
230
+
231
+
232
+ @mcp.resource("data360://search-usage")
233
+ async def search_usage_resource() -> str:
234
+ """Search tool usage guidance."""
235
+ return json.dumps(SEARCH_USAGE, indent=2)
236
+
237
+
238
+ @mcp.resource("data360://k360-narrative-style")
239
+ async def k360_narrative_style_resource() -> str:
240
+ """Narrative formatting contract for K360 staged agent hosts."""
241
+ return json.dumps(K360_NARRATIVE_STYLE, indent=2)
242
+
243
+
244
+ # ---------------------------------------------------------------------------
245
+ # Chart Grammar Resource — grammar-of-graphics decision rules
246
+ # ---------------------------------------------------------------------------
247
+
248
+ CHART_GRAMMAR = """# Data360 Chart Grammar — Decision Rules for Visualization
249
+
250
+ This resource teaches you how to reason about data shapes and select the correct
251
+ chart strategy. The visualization engine applies these rules automatically, but
252
+ understanding them lets you make better upstream decisions (which tool to call,
253
+ what chart_type to pass, and how to narrate the result).
254
+
255
+ ## 1. Strategy Selection Rules
256
+
257
+ The engine selects a strategy based on the **data shape** after fetching:
258
+
259
+ | Condition | Strategy | Chart type |
260
+ |-----------|----------|-----------|
261
+ | 1 indicator, temporal, 1–8 countries | `temporal_single` | Line chart (color=country) |
262
+ | 1 indicator, temporal, >8 countries, no breakdowns | `heatmap` | Heatmap matrix (country × year) |
263
+ | 1 indicator, single year, ≤8 countries | `cross_sectional` | Horizontal bar chart |
264
+ | 1 indicator, single year, >8 countries | `distribution` | Strip/beeswarm chart |
265
+ | 1 indicator, breakdown dimensions present | `breakdown_comparison` or `small_multiples` | Grouped bar or faceted panels |
266
+ | 2+ indicators, temporal, 1 country | `temporal_multi_indicator` | Layered lines or stacked panels |
267
+ | 2+ indicators, single year, multiple countries | `scatter` or `cross_sectional` | Scatter or grouped bar |
268
+ | Composition data (parts sum to ~100%) | `stacked_area` or `stacked_bar` | Stacked marks |
269
+
270
+ ## 2. Layout Composition Rules (Multi-Indicator)
271
+
272
+ When comparing 2+ indicators, the engine decides whether to use a **single shared
273
+ panel** or **vertically stacked panels with independent Y-axes**.
274
+
275
+ The decision is based on the `data_profile.scale_compatibility` in the tool response:
276
+
277
+ | Condition | Layout | Reason |
278
+ |-----------|--------|--------|
279
+ | Same scale type AND value ratio ≤ 10× | Single panel, shared Y-axis | Values are comparable |
280
+ | Same scale type BUT value ratio > 10× | vconcat panels, independent Y-axes | Large magnitude difference distorts one series |
281
+ | Different scale types (e.g. % vs USD) | vconcat panels, independent Y-axes | Incomparable units |
282
+ | All values are percentages in [0, 100] | Single panel | Natural shared range |
283
+
284
+ **How to use**: After calling `data360_get_multi_indicator_viz_spec`, read
285
+ `data_profile.scale_compatibility.can_share_axis` and `data_profile.indicators`
286
+ to understand the layout decision and narrate it to the user.
287
+
288
+ ## 3. Encoding Grammar
289
+
290
+ The engine maps data dimensions to visual channels:
291
+
292
+ | Data dimension | Vega-Lite encoding | When used |
293
+ |---|---|---|
294
+ | year / time_period | `x` (temporal) | Time-series charts |
295
+ | country | `color` (nominal) | Multi-country lines; `y` for cross-sectional bars |
296
+ | value / obs_value | `y` (quantitative) | Always the measurement axis |
297
+ | indicator | `color` (nominal) | Multi-indicator overlays |
298
+ | breakdown dim (sex, age, etc.) | `color` or `facet` | Disaggregation present |
299
+
300
+ ## 4. Data Profile Fields
301
+
302
+ Every viz tool response now includes a `data_profile` with these sections:
303
+
304
+ - **indicators**: Per-indicator value ranges (min/max/median), unit codes, scale
305
+ types (percentage/currency/persons/index), and whether values are proportions.
306
+ - **scale_compatibility** (multi-indicator): Whether indicators can share a Y-axis.
307
+ - **structure**: Country list, year range, temporal density (dense/moderate/sparse).
308
+ - **breakdowns**: Available disaggregation dimensions with actual values and meanings.
309
+ - **composition_hint**: Whether data looks like parts-of-a-whole (suitable for stacked).
310
+
311
+ Use these fields to:
312
+ 1. **Narrate accurately**: "GDP ranges from $1,200 to $63,000" instead of guessing.
313
+ 2. **Assess chart quality**: If `temporal_density` is "sparse", note potential gaps.
314
+ 3. **Suggest alternatives**: If `composition_hint.suitable_for_stacked` is true,
315
+ suggest a stacked area view.
316
+
317
+ ## 5. When NOT to Pass chart_type
318
+
319
+ Let the engine auto-select when:
320
+ - The data shape is unambiguous (single indicator, clear temporal or cross-sectional)
321
+ - You are unsure which chart fits the data
322
+
323
+ Only override chart_type when:
324
+ - The user explicitly asked for a style ("show me a bar chart")
325
+ - You need a specific multi-indicator layout ("scatter", "connected_scatter")
326
+
327
+ ## 6. Tool Selection
328
+
329
+ | Scenario | Tool |
330
+ |----------|------|
331
+ | 1 indicator | `data360_get_viz_spec` |
332
+ | 2–4 indicators to compare | `data360_get_multi_indicator_viz_spec` |
333
+ | Need to summarize without a chart | `data360_summarize_data` |
334
+ """
335
+
336
+
337
+ @mcp.resource("data360://viz/chart-grammar")
338
+ async def chart_grammar_resource() -> str:
339
+ """Grammar-of-graphics decision rules for Data360 visualization.
340
+
341
+ Teaches the LLM how to reason about data shapes, scale compatibility,
342
+ encoding rules, and layout decisions. Read this resource to understand
343
+ how the visualization engine selects strategies and how to interpret
344
+ the data_profile in tool responses.
345
+ """
346
+ return CHART_GRAMMAR
347
+
348
+
349
+ VEGA_LITE_RENDERER_HTML = """<!DOCTYPE html>
350
+ <html>
351
+ <head>
352
+ <meta charset="utf-8">
353
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
354
+ <title>Data360 Vega-Lite Renderer</title>
355
+ <style>
356
+ body {
357
+ margin: 0;
358
+ padding: 8px;
359
+ background: transparent;
360
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
361
+ }
362
+ #vis {
363
+ width: 100%;
364
+ height: 100%;
365
+ min-height: 400px;
366
+ }
367
+ #error-display {
368
+ display: none;
369
+ color: #721c24;
370
+ background-color: #f8d7da;
371
+ border: 1px solid #f5c6cb;
372
+ padding: 15px;
373
+ margin: 10px;
374
+ border-radius: 4px;
375
+ }
376
+ #error-display h3 {
377
+ margin-top: 0;
378
+ margin-bottom: 8px;
379
+ }
380
+ #error-display pre {
381
+ white-space: pre-wrap;
382
+ font-size: 11px;
383
+ margin-top: 10px;
384
+ background: #fff;
385
+ padding: 8px;
386
+ border: 1px solid #ddd;
387
+ font-family: monospace;
388
+ }
389
+ </style>
390
+ <script src="<<<SERVER_BASE>>>/static/libs/vega.js"></script>
391
+ <script src="<<<SERVER_BASE>>>/static/libs/vega-lite.js"></script>
392
+ <script src="<<<SERVER_BASE>>>/static/libs/vega-embed.js"></script>
393
+ </head>
394
+ <body>
395
+ <div id="vis"></div>
396
+ <div id="error-display">
397
+ <h3>Renderer Error</h3>
398
+ <p id="error-message"></p>
399
+ <pre id="error-stack"></pre>
400
+ </div>
401
+ <script type="module">
402
+ const serverBaseUrl = "<<<SERVER_BASE>>>";
403
+
404
+ function showError(message, stack) {
405
+ document.getElementById('vis').style.display = 'none';
406
+ const display = document.getElementById('error-display');
407
+ display.style.display = 'block';
408
+ document.getElementById('error-message').textContent = message;
409
+ document.getElementById('error-stack').textContent = stack || 'No stack trace available';
410
+ }
411
+
412
+ async function logToServer(msg, detail) {
413
+ try {
414
+ await fetch(`${serverBaseUrl}/debug-log`, {
415
+ method: "POST",
416
+ headers: { "Content-Type": "application/json" },
417
+ body: JSON.stringify({ message: msg, detail: detail })
418
+ });
419
+ } catch (e) {
420
+ console.error("Failed to log to server:", e);
421
+ }
422
+ }
423
+
424
+ window.addEventListener('error', (event) => {
425
+ const msg = event.message || event.error?.message || 'Unknown error';
426
+ const stack = event.error?.stack || '';
427
+ showError(msg, stack);
428
+ logToServer("Unhandled error", { message: msg, stack: stack });
429
+ });
430
+
431
+ window.addEventListener('unhandledrejection', (event) => {
432
+ const msg = event.reason?.message || String(event.reason);
433
+ const stack = event.reason?.stack || '';
434
+ showError("Promise Rejection: " + msg, stack);
435
+ logToServer("Unhandled promise rejection", { message: msg, stack: stack });
436
+ });
437
+
438
+ import { App } from "<<<SERVER_BASE>>>/static/libs/ext-apps.js";
439
+
440
+ if (window.PRE_LOADED_SPEC) {
441
+ vegaEmbed("#vis", window.PRE_LOADED_SPEC, {
442
+ actions: false,
443
+ theme: "default"
444
+ }).catch(err => {
445
+ console.error(err);
446
+ showError(`Failed to render chart spec: ${err.message}`, err.stack);
447
+ });
448
+ } else {
449
+ const app = new App({ name: "Data360 Vega-Lite Renderer", version: "1.0.0" });
450
+
451
+ app.ontoolresult = async (result) => {
452
+ if (result.isError) {
453
+ document.getElementById('vis').innerHTML = `<p style="color:red;">Error: ${result.content || "Failed to render chart"}</p>`;
454
+ return;
455
+ }
456
+
457
+ // 1. Try to get spec from structuredContent (default)
458
+ let spec = result.structuredContent?.spec;
459
+ let fetchError = null;
460
+
461
+ // 2. Fallback: Parse the spec URL from text content and fetch it
462
+ if (!spec && result.content) {
463
+ try {
464
+ const textBlock = result.content.find(
465
+ (block) => block.type === "text" && block.text && block.text.includes("View spec:")
466
+ );
467
+ if (textBlock) {
468
+ const match = textBlock.text.match(/View spec:\s*(https?:\/\/[^\s\n]+)/);
469
+ if (match && match[1]) {
470
+ const specUrl = match[1];
471
+ const response = await fetch(specUrl);
472
+ if (response.ok) {
473
+ spec = await response.json();
474
+ } else {
475
+ fetchError = `HTTP ${response.status}: ${response.statusText}`;
476
+ }
477
+ }
478
+ }
479
+ } catch (e) {
480
+ fetchError = e.message;
481
+ console.error("Failed to fetch spec fallback:", e);
482
+ }
483
+ }
484
+
485
+ if (spec) {
486
+ vegaEmbed("#vis", spec, {
487
+ actions: false,
488
+ theme: "default"
489
+ }).catch(err => {
490
+ console.error(err);
491
+ showError(`Failed to render chart spec: ${err.message}`, err.stack);
492
+ });
493
+ } else {
494
+ document.getElementById('vis').innerHTML = `
495
+ <div>
496
+ <p>No visualization spec available.</p>
497
+ <pre style="white-space: pre-wrap; font-size: 11px; background: #fee; padding: 8px; border: 1px solid #fcc; font-family: monospace;">
498
+ Result Keys: ${result ? Object.keys(result).join(', ') : 'null'}
499
+ Fetch Error: ${fetchError || 'none'}
500
+ Result JSON: ${result ? JSON.stringify(result, null, 2) : 'null'}
501
+ </pre>
502
+ </div>
503
+ `;
504
+ }
505
+ };
506
+
507
+ await app.connect();
508
+ }
509
+ </script>
510
+ </body>
511
+ </html>
512
+ """
513
+
514
+
515
+ @mcp.resource(
516
+ "ui://data360/vega-lite-renderer.html{?spec}",
517
+ app=AppConfig(
518
+ csp=ResourceCSP(
519
+ connect_domains=["*"],
520
+ resource_domains=[
521
+ "http://localhost:*",
522
+ "http://127.0.0.1:*",
523
+ "https://unpkg.com",
524
+ "https://cdn.jsdelivr.net",
525
+ "'unsafe-eval'",
526
+ ],
527
+ )
528
+ ),
529
+ )
530
+ async def vega_lite_renderer(spec: str | None = None) -> str:
531
+ """HTML renderer template for Vega-Lite v6 charts."""
532
+ from data360.config import get_mcp_server_settings
533
+
534
+ settings = get_mcp_server_settings()
535
+ port = settings.port or 8021
536
+ server_base = f"http://localhost:{port}"
537
+ # Use an explicit sentinel that cannot appear in real HTML/JS content.
538
+ # All occurrences in VEGA_LITE_RENDERER_HTML are replaced in one pass.
539
+ html = VEGA_LITE_RENDERER_HTML.replace("<<<SERVER_BASE>>>", server_base)
540
+ if spec:
541
+ injection = f"\n window.PRE_LOADED_SPEC = {spec};\n"
542
+ html = html.replace("<body>", f"<body>\n <script>{injection}</script>")
543
+ return html
544
+
545
+
546
+ import os
547
+ from fastapi.staticfiles import StaticFiles
548
+ from starlette.requests import Request
549
+ from starlette.responses import JSONResponse, Response
550
+
551
+ from starlette.exceptions import HTTPException
552
+
553
+ class CORSStaticFiles(StaticFiles):
554
+ async def __call__(self, scope, receive, send) -> None:
555
+ if scope["type"] != "http":
556
+ await super().__call__(scope, receive, send)
557
+ return
558
+
559
+ if scope["method"] == "OPTIONS":
560
+ response = Response(
561
+ "OK",
562
+ status_code=200,
563
+ headers={
564
+ "Access-Control-Allow-Origin": "*",
565
+ "Access-Control-Allow-Methods": "GET, HEAD, OPTIONS",
566
+ "Access-Control-Allow-Headers": "*",
567
+ }
568
+ )
569
+ await response(scope, receive, send)
570
+ return
571
+
572
+ async def cors_send(message) -> None:
573
+ if message["type"] == "http.response.start":
574
+ headers = list(message.get("headers", []))
575
+ has_origin = any(h[0].lower() == b"access-control-allow-origin" for h in headers)
576
+ if not has_origin:
577
+ headers.append((b"access-control-allow-origin", b"*"))
578
+ headers.append((b"access-control-allow-methods", b"GET, HEAD, OPTIONS"))
579
+ headers.append((b"access-control-allow-headers", b"*"))
580
+ message["headers"] = headers
581
+ await send(message)
582
+
583
+ await super().__call__(scope, receive, cors_send)
584
+
585
+ async def get_response(self, path: str, scope) -> Response:
586
+ try:
587
+ return await super().get_response(path, scope)
588
+ except HTTPException as exc:
589
+ return JSONResponse(
590
+ {"detail": exc.detail},
591
+ status_code=exc.status_code,
592
+ headers=exc.headers
593
+ )
594
+
595
+
596
+
597
+
598
+ @mcp.custom_route("/debug-log", methods=["POST", "OPTIONS"])
599
+ async def debug_log(request: Request) -> Response:
600
+ if request.method == "OPTIONS":
601
+ return Response(
602
+ "OK",
603
+ status_code=200,
604
+ headers={
605
+ "Access-Control-Allow-Origin": "*",
606
+ "Access-Control-Allow-Methods": "POST, OPTIONS",
607
+ "Access-Control-Allow-Headers": "Content-Type",
608
+ }
609
+ )
610
+
611
+ try:
612
+ body = await request.json()
613
+ print(f"\n[IFRAME DEBUG LOG] {body}\n", flush=True)
614
+ return JSONResponse(
615
+ {"status": "ok"},
616
+ headers={"Access-Control-Allow-Origin": "*"}
617
+ )
618
+ except Exception as e:
619
+ print(f"Error reading debug log: {e}", flush=True)
620
+ return JSONResponse(
621
+ {"error": str(e)},
622
+ status_code=400,
623
+ headers={"Access-Control-Allow-Origin": "*"}
624
+ )
src/data360/mcp_server/security_validator.py ADDED
@@ -0,0 +1,228 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Security validation for MCP tool calls to prevent prompt injection attacks."""
2
+
3
+ import logging
4
+ import re
5
+ from typing import Any
6
+
7
+ _logger = logging.getLogger(__name__)
8
+
9
+ # Prompt injection patterns that indicate malicious intent
10
+ PROMPT_INJECTION_PATTERNS = [
11
+ # Tool enumeration attempts
12
+ r"list\s+(all\s+)?(available\s+)?tools",
13
+ r"show\s+me\s+(all\s+)?(available\s+)?tools",
14
+ r"what\s+tools\s+(do\s+you\s+have|are\s+available)",
15
+ r"enumerate\s+tools",
16
+ r"get\s+all\s+tools",
17
+ # Instruction override attempts
18
+ r"ignore\s+(previous|all|your)\s+instructions",
19
+ r"disregard\s+(previous|all|your)\s+instructions",
20
+ r"forget\s+(previous|all|your)\s+instructions",
21
+ r"new\s+instructions?:",
22
+ r"system\s+prompt:",
23
+ r"\byou\s+are\s+now\s+(?:a|an|the)\b",
24
+ # Role manipulation
25
+ r"^\s*act\s+as\s+(?:a|an|the)\b",
26
+ r"pretend\s+to\s+be",
27
+ r"you\s+are\s+(a\s+)?developer",
28
+ r"you\s+are\s+(a\s+)?admin",
29
+ # Multi-tool chaining attempts
30
+ r"\bthen\s+(call|execute|run)\b",
31
+ r"after\s+that,?\s+(call|execute|run)",
32
+ r"next,?\s+(call|execute|run)",
33
+ r"and\s+then\s+(call|execute|run)",
34
+ # System/internal method access
35
+ r"__\w+__", # Dunder methods
36
+ r"(?:^|[^\w])\.system\b",
37
+ r"(?:^|[^\w])\.internal\b",
38
+ r"(?:^|[^\w])\.admin\b",
39
+ ]
40
+
41
+ # Compile patterns for performance
42
+ _INJECTION_REGEX = [
43
+ re.compile(pattern, re.IGNORECASE) for pattern in PROMPT_INJECTION_PATTERNS
44
+ ]
45
+
46
+ MIN_SEARCH_QUERY_LENGTH = 3
47
+ MAX_TOOL_PARAM_LENGTH = 5000
48
+ MAX_SEARCH_QUERY_LENGTH = 100
49
+
50
+
51
+ def validate_tool_call(
52
+ tool_name: str, arguments: dict[str, Any]
53
+ ) -> tuple[bool, str | None]:
54
+ """
55
+ Validate a tool call for security issues.
56
+
57
+ Returns:
58
+ (is_valid, error_message)
59
+ - (True, None) if valid
60
+ - (False, "error message") if invalid
61
+ """
62
+
63
+ # Check tool name for suspicious patterns
64
+ if not tool_name or not isinstance(tool_name, str):
65
+ return (False, "Invalid tool name")
66
+
67
+ # Only allow data360 tools
68
+ if not tool_name.startswith("data360_"):
69
+ return (
70
+ False,
71
+ f"Unauthorized tool: {tool_name}. Only data360_* tools are allowed.",
72
+ )
73
+
74
+ # Check arguments for prompt injection
75
+ if arguments:
76
+ for param_name, value in arguments.items():
77
+ if isinstance(value, str):
78
+ # Check for prompt injection patterns
79
+ for pattern in _INJECTION_REGEX:
80
+ if pattern.search(value):
81
+ _logger.warning(
82
+ f"Prompt injection detected in {param_name}: {value[:100]}"
83
+ )
84
+ return (
85
+ False,
86
+ f"Security violation: Suspicious pattern detected in '{param_name}'. "
87
+ "Please use specific, factual queries only.",
88
+ )
89
+
90
+ # Check for excessive length (potential attack)
91
+ if len(value) > MAX_TOOL_PARAM_LENGTH:
92
+ return (
93
+ False,
94
+ f"Parameter '{param_name}' exceeds maximum length of "
95
+ f"{MAX_TOOL_PARAM_LENGTH} characters.",
96
+ )
97
+
98
+ return (True, None)
99
+
100
+
101
+ def validate_search_query(query: str) -> tuple[bool, str | None]:
102
+ """
103
+ Validate a single search term to prevent enumeration attacks.
104
+
105
+ Returns:
106
+ (is_valid, error_message)
107
+ """
108
+ if not query or not isinstance(query, str):
109
+ return (False, "Search query must be a non-empty string")
110
+
111
+ stripped = query.strip()
112
+ if not stripped:
113
+ return (False, "Search query must be a non-empty string")
114
+
115
+ # Minimum query length to prevent enumeration
116
+ if len(stripped) < MIN_SEARCH_QUERY_LENGTH:
117
+ return (
118
+ False,
119
+ f"Search query must be at least {MIN_SEARCH_QUERY_LENGTH} characters. "
120
+ "Use specific, meaningful search terms (e.g., 'GDP growth', 'unemployment rate').",
121
+ )
122
+
123
+ # Block single character or wildcard queries
124
+ if stripped in ["*", "?", "%", "_", ".", ".*"]:
125
+ return (
126
+ False,
127
+ "Wildcard-only queries are not allowed. Please use specific search terms.",
128
+ )
129
+
130
+ # Check for prompt injection in query
131
+ for pattern in _INJECTION_REGEX:
132
+ if pattern.search(stripped):
133
+ _logger.warning(
134
+ f"Prompt injection detected in search query: {stripped[:MAX_SEARCH_QUERY_LENGTH]}"
135
+ )
136
+ return (
137
+ False,
138
+ "Security violation: Suspicious pattern detected in query. "
139
+ "Please use specific, factual search terms only.",
140
+ )
141
+
142
+ return (True, None)
143
+
144
+
145
+ def _collect_search_query_strings(arguments: dict[str, Any]) -> list[str]:
146
+ """
147
+ Collect non-empty search terms from data360_search_indicators arguments.
148
+
149
+ Mirrors search() input modes: ``query``, ``queries``, and ``query_groups``.
150
+ Empty or whitespace-only entries are skipped (same as api.search normalisation).
151
+ """
152
+ terms: list[str] = []
153
+
154
+ query = arguments.get("query")
155
+ if isinstance(query, str) and query.strip():
156
+ terms.append(query)
157
+
158
+ queries = arguments.get("queries")
159
+ if isinstance(queries, list):
160
+ for item in queries:
161
+ if isinstance(item, str) and item.strip():
162
+ terms.append(item)
163
+
164
+ query_groups = arguments.get("query_groups")
165
+ if isinstance(query_groups, list):
166
+ for group in query_groups:
167
+ if not isinstance(group, dict):
168
+ continue
169
+ group_queries = group.get("queries")
170
+ if not isinstance(group_queries, list):
171
+ continue
172
+ for item in group_queries:
173
+ if isinstance(item, str) and item.strip():
174
+ terms.append(item)
175
+
176
+ return terms
177
+
178
+
179
+ def validate_search_arguments(arguments: dict[str, Any]) -> tuple[bool, str | None]:
180
+ """
181
+ Validate all search terms for data360_search_indicators.
182
+
183
+ Applies per-term checks for ``query``, each entry in ``queries``, and each
184
+ nested term in ``query_groups[].queries``.
185
+
186
+ Returns:
187
+ (is_valid, error_message)
188
+ """
189
+ terms = _collect_search_query_strings(arguments)
190
+ if not terms:
191
+ return (
192
+ False,
193
+ "One of 'query', 'queries', or 'query_groups' must include at least "
194
+ "one non-empty search term.",
195
+ )
196
+
197
+ for term in terms:
198
+ is_valid, error_msg = validate_search_query(term)
199
+ if not is_valid:
200
+ return (False, error_msg)
201
+
202
+ return (True, None)
203
+
204
+
205
+ def sanitize_string(value: str, max_length: int = 1000) -> str:
206
+ """
207
+ Sanitize string input by removing potentially dangerous characters.
208
+
209
+ Args:
210
+ value: Input string
211
+ max_length: Maximum allowed length
212
+
213
+ Returns:
214
+ Sanitized string
215
+ """
216
+ if not isinstance(value, str):
217
+ return str(value)
218
+
219
+ # Truncate to max length
220
+ sanitized = value[:max_length]
221
+
222
+ # Remove null bytes and other dangerous characters
223
+ sanitized = sanitized.replace("\x00", "")
224
+
225
+ # Remove excessive whitespace
226
+ sanitized = " ".join(sanitized.split())
227
+
228
+ return sanitized
src/data360/mcp_server/tool_spans.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """OpenTelemetry parent spans for each MCP tool invocation (nests httpx child spans)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import functools
6
+ import inspect
7
+ from collections.abc import Callable
8
+ from typing import Any, cast
9
+
10
+ from opentelemetry import trace
11
+
12
+ _tracer = trace.get_tracer("data360.mcp.tools")
13
+
14
+
15
+ def instrument_mcp_tool(fn: Callable[..., Any], *, tool_name: str) -> Callable[..., Any]:
16
+ """Wrap a tool function so each call runs under ``mcp.tool.<name>`` span."""
17
+ if inspect.iscoroutinefunction(fn):
18
+ fn_async = cast("Callable[..., Any]", fn)
19
+
20
+ @functools.wraps(fn_async)
21
+ async def _async_impl(*args: Any, **kwargs: Any) -> Any:
22
+ with _tracer.start_as_current_span(
23
+ f"mcp.tool.{tool_name}",
24
+ attributes={"mcp.tool.name": tool_name},
25
+ ):
26
+ return await fn_async(*args, **kwargs)
27
+
28
+ return _async_impl
29
+
30
+ fn_sync = cast("Callable[..., Any]", fn)
31
+
32
+ @functools.wraps(fn_sync)
33
+ def _sync_impl(*args: Any, **kwargs: Any) -> Any:
34
+ with _tracer.start_as_current_span(
35
+ f"mcp.tool.{tool_name}",
36
+ attributes={"mcp.tool.name": tool_name},
37
+ ):
38
+ return fn_sync(*args, **kwargs)
39
+
40
+ return _sync_impl
src/data360/mcp_server/tools.py ADDED
@@ -0,0 +1,1605 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """MCP Tools for the Data360 server.
2
+
3
+ Thin wrapper layer that registers API functions as MCP tools with optimized signatures,
4
+ concise docstrings to reduce token context bloat, and validation schemas.
5
+ """
6
+
7
+ import json
8
+ import os
9
+ import threading
10
+ from typing import Any, Literal, Optional
11
+
12
+ import pydantic_core
13
+ from fastmcp.apps import AppConfig
14
+ from fastmcp.exceptions import ToolError
15
+ from fastmcp.tools import ToolResult
16
+ from fastmcp.tools.tool import Tool
17
+ from mcp.types import TextContent
18
+
19
+ from data360 import api as data360_api
20
+ from data360 import providers as data360_providers
21
+ from data360 import visualization as data360_viz
22
+ from data360 import viz_config as data360_viz_config
23
+
24
+ from ._server_definition import mcp
25
+ from .tool_spans import instrument_mcp_tool
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # Serializer for aggregation tools
29
+ # ---------------------------------------------------------------------------
30
+
31
+
32
+ def _compact_aggregation_serializer(data: Any) -> str:
33
+ """Compact serializer for aggregation tool responses.
34
+
35
+ Calls ``to_compact()`` on the response model if available, producing a
36
+ token-efficient JSON representation while preserving all PCN claim_ids
37
+ for data provenance verification.
38
+ """
39
+ if hasattr(data, "to_compact"):
40
+ return json.dumps(data.to_compact(), separators=(",", ":"))
41
+ return pydantic_core.to_json(data, fallback=str).decode()
42
+
43
+
44
+ def _normalize_disaggregation_filters(filters: dict[str, Any] | None) -> dict[str, str | None] | None:
45
+ """Normalize user-provided disaggregation filters.
46
+ Converts list values (e.g., ["F", "M"]) to comma-separated strings (e.g., "F,M")
47
+ to conform to the underlying API support while remaining type-flexible for LLM callers.
48
+ """
49
+ if filters is None:
50
+ return None
51
+ normalized = {}
52
+ for k, v in filters.items():
53
+ if v is None:
54
+ normalized[k] = None
55
+ elif isinstance(v, list):
56
+ normalized[k] = ",".join(str(item).strip() for item in v if item is not None)
57
+ else:
58
+ normalized[k] = str(v)
59
+ return normalized
60
+
61
+
62
+ # ---------------------------------------------------------------------------
63
+ # Tool Wrapper Functions
64
+ # ---------------------------------------------------------------------------
65
+
66
+
67
+ async def _search_indicators(
68
+ query: str | None = None,
69
+ required_country: str | None = None,
70
+ limit: int = 5,
71
+ offset: int = 0,
72
+ queries: list[str] | None = None,
73
+ query_groups: list[dict[str, Any]] | None = None,
74
+ result_layout: str = "merged",
75
+ dedupe: bool = True,
76
+ database: str | None = None,
77
+ ) -> Any:
78
+ """Search for Data360 indicators with enriched metadata for selection.
79
+
80
+ Use when the user asks for data on a development topic (e.g. GDP, poverty, education).
81
+ Default to using the single `query` parameter for any single topic/indicator search. Use the `queries` or `query_groups` parameters ONLY when the request involves multiple topics or scopes (2 or more).
82
+ Provide exactly one of `query`, `queries`, or `query_groups`. One of these is strictly required.
83
+
84
+ ### Parameter Selection Decision Tree (CRITICAL):
85
+ 1. **Exactly 1 Topic** (e.g., "life expectancy" or "mortality rate") for any number of countries → you MUST use the single `query` parameter + `required_country`. Do NOT use `queries` with only one element, as it will fail. Do NOT combine multiple topics with 'and' or 'or' in `query` (e.g. do NOT use query="GDP and inflation").
86
+ 2. **Multiple Topics, Same Country/Countries** (e.g., "life expectancy and GDP per capita" for Japan) → you MUST use the `queries` list parameter (e.g. `queries=["life expectancy", "GDP per capita"]`) + `required_country`. Do NOT make multiple tool calls. Do NOT pass multiple topics as a single query string (e.g., query="life expectancy and GDP per capita" is invalid).
87
+ 3. **Different Topics targeting Different Countries** (e.g., "life expectancy for Japan, but GDP and mortality rate for Korea") → you MUST use the `query_groups` parameter. Do NOT use `queries`.
88
+
89
+ Args:
90
+ query: Single topic query (e.g. "unemployment"). Use ONLY for a single topic. Do NOT combine multiple topics with 'and' or 'or' (e.g. do NOT use query="population and life expectancy"). Avoid special characters like parentheses () or dollar signs $. Example: 'GDP per capita'.
91
+ required_country: Semicolon-separated ISO country codes (e.g. "KEN;USA"). Shared across all queries in 'query' or 'queries'. Consider calling `data360_expand_country_group` to find country codes in regional/income groups, or `data360_find_codelist_value` to resolve country names.
92
+ limit: Max indicators per query (default 5).
93
+ offset: Offset for pagination.
94
+ queries: List of topics for multi-topic search (must contain at least 2 non-empty search strings). Use ONLY when 2 or more topics target the SAME countries/geographic scope (e.g. ['GDP per capita', 'inflation rate']).
95
+ query_groups: Grouped queries with specific country scopes. Use ONLY when different topics/queries target different country scopes. Example: [{'queries': ['life expectancy'], 'country': 'JPN'}, {'queries': ['GDP per capita'], 'country': 'KOR'}].
96
+ result_layout: Mode to return results: "merged" (flat, deduped list of indicators) or "by_query" (indicators grouped by search query).
97
+ dedupe: De-duplicate indicators across query results.
98
+ database: Optional database name or ID to filter search results (e.g. "wdi", "wgi", "World Development Indicators"). Multiple databases can be queried at once by separating them with a semicolon (e.g. "pip; lpgd; sgi").
99
+ """
100
+ # Robustness fallback: if queries is passed as a list of exactly 1 item,
101
+ # normalize it to a single query parameter to prevent validation failure.
102
+ if queries is not None:
103
+ clean_queries = [q.strip() for q in queries if q and q.strip()]
104
+ if len(clean_queries) == 1:
105
+ if not query:
106
+ query = clean_queries[0]
107
+ queries = None
108
+
109
+ return await data360_api.search(
110
+ query=query,
111
+ required_country=required_country,
112
+ limit=limit,
113
+ offset=offset,
114
+ queries=queries,
115
+ query_groups=query_groups,
116
+ result_layout=result_layout,
117
+ dedupe=dedupe,
118
+ database=database,
119
+ )
120
+
121
+
122
+ async def _search_datasets(
123
+ query: str,
124
+ limit: int = 10,
125
+ offset: int = 0,
126
+ ) -> Any:
127
+ """Search for Data360 datasets matching a query.
128
+
129
+ Use when the user asks for dataset details, catalogs, or source databases (e.g. "Findex", "WDI").
130
+
131
+ Args:
132
+ query: Topic or dataset search term (e.g. "findex"). Avoid special characters like parentheses () or dollar signs $ as they cause search failures.
133
+ limit: Max datasets to return (default 10).
134
+ offset: Offset for pagination.
135
+ """
136
+ return await data360_api.search_datasets(
137
+ query=query,
138
+ limit=limit,
139
+ offset=offset,
140
+ )
141
+
142
+
143
+ async def _get_metadata(
144
+ database_id: str,
145
+ indicator_id: str,
146
+ select_fields: list[str] | None = None,
147
+ fetch_disaggregation: bool = True,
148
+ required_country: str | None = None,
149
+ ) -> Any:
150
+ """Get metadata and disaggregation options for a Data360 indicator.
151
+
152
+ Use when you need detailed methodology, source notes, or limitations for an indicator.
153
+ Ensure the database ID and the indicator ID are already in context (e.g., from `data360_search_indicators`) before using this tool. Do not guess or hallucinate these IDs.
154
+
155
+ Args:
156
+ database_id: Database identifier (e.g., "WB_WDI").
157
+ indicator_id: Indicator ID (e.g., "WB_WDI_NY_GDP_PCAP_KD").
158
+ select_fields: Optional metadata fields to return (e.g., ["methodology", "relevance"]).
159
+ fetch_disaggregation: Whether to include disaggregation options.
160
+ required_country: Semicolon-separated ISO country codes to check coverage.
161
+ """
162
+ return await data360_api.get_metadata(
163
+ database_id=database_id,
164
+ indicator_id=indicator_id,
165
+ select_fields=select_fields,
166
+ fetch_disaggregation=fetch_disaggregation,
167
+ required_country=required_country,
168
+ )
169
+
170
+
171
+ async def _get_data(
172
+ database_id: str,
173
+ indicator_id: str,
174
+ country_code: str | None = None,
175
+ disaggregation_filters: dict[str, Any] | None = None,
176
+ start_year: int | None = None,
177
+ end_year: int | None = None,
178
+ limit: int = 50,
179
+ offset: int = 0,
180
+ ref_area_filter: Literal["none", "member_economies_only"] = "member_economies_only",
181
+ year: int | None = None,
182
+ ) -> Any:
183
+ """Retrieve indicator observations from the Data360 API.
184
+
185
+ Use when you need actual numeric values (OBS_VALUE) for specific countries and years.
186
+ Ensure the database ID and the indicator ID are already in context before using this tool. Do not guess or hallucinate these IDs.
187
+ Call `data360_get_disaggregation` first to find available years and breakdowns for the `disaggregation_filters`.
188
+
189
+ Args:
190
+ database_id: Database identifier (e.g., "WB_WDI").
191
+ indicator_id: Indicator ID (e.g., "WB_WDI_NY_GDP_PCAP_KD").
192
+ country_code: Semicolon-separated ISO country codes (e.g. "KEN;USA").
193
+ disaggregation_filters: Optional dimension filters. Values must be strings or null. Call `data360_get_disaggregation` first to find valid options.
194
+ start_year: Start year (inclusive). Defaults to last 5 years if both bounds omitted;
195
+ if only end_year is set, defaults to a 5-year window ending at end_year.
196
+ end_year: End year (inclusive). See start_year for partial-bound defaults.
197
+ limit: Max records per page (default 50, max 100).
198
+ offset: Number of records to skip for pagination.
199
+ ref_area_filter: Filter mode: "member_economies_only" (default) or "none".
200
+ year: Specific single year to retrieve data for. Maps internally to start_year and end_year.
201
+ """
202
+ if year is not None:
203
+ if start_year is None:
204
+ start_year = year
205
+ if end_year is None:
206
+ end_year = year
207
+
208
+
209
+ norm_filters = _normalize_disaggregation_filters(disaggregation_filters)
210
+
211
+ return await data360_api.get_data(
212
+ database_id=database_id,
213
+ indicator_id=indicator_id,
214
+ country_code=country_code,
215
+ disaggregation_filters=norm_filters,
216
+ start_year=start_year,
217
+ end_year=end_year,
218
+ limit=limit,
219
+ offset=offset,
220
+ ref_area_filter=ref_area_filter,
221
+ )
222
+
223
+
224
+ async def _get_disaggregation(
225
+ database_id: str,
226
+ indicator_id: str,
227
+ required_country: str | None = None,
228
+ ) -> dict[str, Any]:
229
+ """Get valid filter values and disaggregation options for an indicator.
230
+
231
+ Use to find available dimensions (e.g., SEX, AGE) and years before querying data or charts.
232
+ Ensure the database ID and the indicator ID are already in context before using this tool. Do not guess or hallucinate these IDs.
233
+
234
+ Args:
235
+ database_id: Database identifier (e.g., "WB_WDI").
236
+ indicator_id: Indicator ID (e.g., "WB_WDI_NY_GDP_PCAP_KD").
237
+ required_country: Semicolon-separated ISO country codes to check coverage.
238
+ """
239
+ return await data360_api.get_disaggregation(
240
+ database_id=database_id,
241
+ indicator_id=indicator_id,
242
+ required_country=required_country,
243
+ )
244
+
245
+
246
+ async def _find_codelist_value(
247
+ codelist_type: str, query: str, limit: int = 5
248
+ ) -> list[dict[str, Any]]:
249
+ """Resolve user-friendly names to API dimension codes.
250
+
251
+ Use when you need to find codes for country names, sex, age, urbanisation, etc.
252
+
253
+ Args:
254
+ codelist_type: Dimension name (e.g. "REF_AREA", "SEX", "AGE", "URBANISATION").
255
+ query: Search term (e.g. "Kenya", "female").
256
+ limit: Max results to return (default 5).
257
+ """
258
+ return await data360_providers.find_codelist_value(
259
+ codelist_type=codelist_type, query=query, limit=limit
260
+ )
261
+
262
+
263
+ async def _list_indicators(database_id: str) -> list[str]:
264
+ """Get all indicator IDs for a specific database.
265
+
266
+ Use when you need the full list of indicator IDs for a dataset.
267
+
268
+ Args:
269
+ database_id: The database identifier (e.g., "WB_WDI").
270
+ """
271
+ return await data360_api.get_indicators(database_id=database_id)
272
+
273
+
274
+ async def _get_data_api_url(
275
+ database_id: str,
276
+ indicator_id: str,
277
+ country_code: str | None = None,
278
+ start_year: int | None = None,
279
+ end_year: int | None = None,
280
+ disaggregation_filters: dict[str, Any] | None = None,
281
+ year: int | None = None,
282
+ ) -> str:
283
+ """Generate the raw Data360 API URL for an indicator request.
284
+
285
+ Low-level tool: use only when the caller specifically asks for the URL.
286
+ Ensure the database ID and the indicator ID are already in context before using this tool. Do not guess or hallucinate these IDs.
287
+
288
+ Args:
289
+ database_id: Database identifier (e.g. "WB_WDI").
290
+ indicator_id: Indicator ID (e.g. "WB_WDI_NY_GDP_PCAP_KD").
291
+ country_code: Semicolon-separated ISO country codes.
292
+ start_year: Start year (inclusive). Defaults to last 5 years if omitted.
293
+ end_year: End year (inclusive). Defaults to current year if omitted.
294
+ disaggregation_filters: Optional dimension filters.
295
+ year: Specific single year to generate the URL for. Maps internally to start_year and end_year.
296
+ """
297
+ if year is not None:
298
+ if start_year is None:
299
+ start_year = year
300
+ if end_year is None:
301
+ end_year = year
302
+
303
+
304
+ norm_filters = _normalize_disaggregation_filters(disaggregation_filters)
305
+
306
+ return await data360_api.get_data_api_url(
307
+ database_id=database_id,
308
+ indicator_id=indicator_id,
309
+ country_code=country_code,
310
+ start_year=start_year,
311
+ end_year=end_year,
312
+ disaggregation_filters=norm_filters,
313
+ )
314
+
315
+
316
+
317
+
318
+
319
+ # ---------------------------------------------------------------------------
320
+ # Bundled Vega library cache (thread-safe, loaded once on first use)
321
+ # ---------------------------------------------------------------------------
322
+
323
+ _vega_libs_lock = threading.Lock()
324
+ _vega_libs_cache: tuple[str, str, str, str] | None = None
325
+
326
+
327
+ def get_cached_vega_libs() -> tuple[str, str, str, str]:
328
+ """Load and cache local Vega library scripts from static/libs (thread-safe)."""
329
+ global _vega_libs_cache
330
+ if _vega_libs_cache is not None:
331
+ return _vega_libs_cache
332
+ with _vega_libs_lock:
333
+ if _vega_libs_cache is not None: # double-check after acquiring lock
334
+ return _vega_libs_cache
335
+ from pathlib import Path
336
+ import logging
337
+
338
+ libs_dir = (
339
+ Path(__file__).resolve().parent.parent.parent.parent / "static" / "libs"
340
+ )
341
+ try:
342
+ vega_js = (libs_dir / "vega.js").read_text(encoding="utf-8")
343
+ vega_lite_js = (libs_dir / "vega-lite.js").read_text(encoding="utf-8")
344
+ vega_embed_js = (libs_dir / "vega-embed.js").read_text(encoding="utf-8")
345
+ vega_interp_js = (libs_dir / "vega-interpreter.js").read_text(
346
+ encoding="utf-8"
347
+ )
348
+ except Exception as e:
349
+ logging.getLogger("data360").warning(
350
+ "Failed to load local Vega library scripts: %s", e
351
+ )
352
+ vega_js = vega_lite_js = vega_embed_js = vega_interp_js = ""
353
+ _vega_libs_cache = (vega_js, vega_lite_js, vega_embed_js, vega_interp_js)
354
+ return _vega_libs_cache
355
+
356
+
357
+ # ---------------------------------------------------------------------------
358
+ # Markdown summary helper (text content block for viz ToolResults)
359
+ # ---------------------------------------------------------------------------
360
+
361
+
362
+ def _make_text_summary(
363
+ spec: "dict[str, Any] | None",
364
+ strategy: str,
365
+ reason: str,
366
+ warning: str | None = None,
367
+ subtitle_line: str | None = None,
368
+ source_line: str | None = None,
369
+ url: str | None = None,
370
+ ) -> str:
371
+ """Build a markdown summary table from a Vega-Lite spec for the text content block."""
372
+ import pandas as pd
373
+
374
+ lines: list[str] = []
375
+
376
+ if warning:
377
+ lines.append(f"### Warning\n{warning}\n")
378
+
379
+ lines.append(f"### Data Summary ({strategy})")
380
+ lines.append(reason)
381
+ if subtitle_line:
382
+ lines.append(f"*{subtitle_line}*")
383
+ lines.append("")
384
+
385
+ data_rows: list[dict] = []
386
+ if isinstance(spec, dict):
387
+ data_rows = spec.get("data", {}).get("values", [])
388
+
389
+ if data_rows:
390
+ try:
391
+ df = pd.DataFrame(data_rows)
392
+ # Reorder: put time/area columns first
393
+ cols = list(df.columns)
394
+ for p in reversed(
395
+ ["TIME_PERIOD", "time_period", "REF_AREA", "ref_area"]
396
+ ):
397
+ if p in cols:
398
+ cols.remove(p)
399
+ cols.insert(0, p)
400
+ df = df[cols]
401
+
402
+ headers = [c.replace("_", " ").title() for c in df.columns]
403
+ lines.append("| " + " | ".join(headers) + " |")
404
+ lines.append("| " + " | ".join(["---"] * len(df.columns)) + " |")
405
+ for _, row in df.iterrows():
406
+ vals = []
407
+ for col in df.columns:
408
+ v = row[col]
409
+ if v is None or (isinstance(v, float) and pd.isna(v)):
410
+ vals.append("")
411
+ elif isinstance(v, float):
412
+ vals.append(f"{v:,.2f}")
413
+ else:
414
+ vals.append(str(v))
415
+ lines.append("| " + " | ".join(vals) + " |")
416
+ except Exception:
417
+ lines.append("No tabular data available.")
418
+ else:
419
+ lines.append("No data available.")
420
+
421
+ if source_line:
422
+ lines.append(f"\n*{source_line}*")
423
+ if url:
424
+ lines.append(f"\n*Vega-Lite Spec URL:* {url}")
425
+
426
+ return "\n".join(lines)
427
+
428
+ async def _get_viz_spec(
429
+ database_id: str,
430
+ indicator_id: str,
431
+ country_code: str | None = None,
432
+ start_year: int | None = None,
433
+ end_year: int | None = None,
434
+ disaggregation_filters: dict[str, Any] | None = None,
435
+ chart_type: str | None = None,
436
+ relevant_fields: list[str] | None = None,
437
+ custom_constraints: list[str] | None = None,
438
+ use_default_constraints: bool = True,
439
+ chart_title: str | dict | None = None,
440
+ series_labels: dict[str, str] | None = None,
441
+ strategy_override: str | None = None,
442
+ year: int | None = None,
443
+ ) -> ToolResult:
444
+ """Generate a Vega-Lite chart from a single Data360 indicator.
445
+
446
+ Use when the user requests a chart or plot for a single indicator.
447
+ Ensure the database ID and the indicator ID are already in context before using this tool. Do not guess or hallucinate these IDs.
448
+ Call `data360_get_disaggregation` first to find available years and breakdowns for the `disaggregation_filters`.
449
+
450
+ Args:
451
+ database_id: Database identifier (e.g. "WB_WDI").
452
+ indicator_id: Indicator ID (e.g. "WB_WDI_NY_GDP_PCAP_KD").
453
+ country_code: Semicolon-separated ISO country codes (e.g. "KEN;USA").
454
+ start_year: Start year (inclusive). Defaults to last 5 years if omitted.
455
+ end_year: End year (inclusive). Defaults to current year if omitted.
456
+ disaggregation_filters: Optional dimension filters.
457
+ chart_type: Optional chart type suggestion. If omitted (recommended), the routing engine automatically determines the optimal chart type and strategy based on the data profile. Do not specify this argument unless the user explicitly requested a specific chart type.
458
+ relevant_fields: Fields to include in visual encodings.
459
+ custom_constraints: Custom Draco design rules.
460
+ use_default_constraints: Whether to apply default Draco design constraints.
461
+ chart_title: A concise, human-synthesized title summarizing the data insight (e.g. 'Renewable Energy Share in South Asia (2020)'). Prefer clean, natural phrasing instead of raw long indicator names.
462
+ series_labels: Rename dimension codes for legend (e.g. {"WGI_EST": "Estimate"}).
463
+ strategy_override: Explicitly force a chart strategy (e.g. "stacked_bar", "temporal_single").
464
+ year: Specific single year to generate the chart for. Maps internally to start_year and end_year.
465
+ """
466
+ if year is not None:
467
+ if start_year is None:
468
+ start_year = year
469
+ if end_year is None:
470
+ end_year = year
471
+
472
+ norm_filters = _normalize_disaggregation_filters(disaggregation_filters)
473
+
474
+ res = await data360_viz.get_viz_spec(
475
+ database_id=database_id,
476
+ indicator_id=indicator_id,
477
+ country_code=country_code,
478
+ start_year=start_year,
479
+ end_year=end_year,
480
+ disaggregation_filters=norm_filters,
481
+ chart_type=chart_type,
482
+ relevant_fields=relevant_fields,
483
+ custom_constraints=custom_constraints,
484
+ use_default_constraints=use_default_constraints,
485
+ chart_title=chart_title,
486
+ series_labels=series_labels,
487
+ strategy_override=strategy_override,
488
+ )
489
+
490
+ if res.get("error"):
491
+ raise ToolError(res["error"])
492
+
493
+ url = res.get("url")
494
+ strategy = res.get("strategy") or "unknown"
495
+ reason = res.get("reason") or ""
496
+ warning = res.get("warning")
497
+ source_line = res.get("source_line")
498
+ subtitle_line = res.get("subtitle_line")
499
+
500
+ # Prefer the spec already carried in the result dict (populated by _ok() in
501
+ # visualization.py). The disk-reload below is a fallback for callers that
502
+ # do not propagate the spec (e.g. when the chart URL points to an external
503
+ # charts API rather than the local static file server).
504
+ spec: dict | None = res.get("spec") or None
505
+ if spec is None and url:
506
+ try:
507
+ spec_id = url.split("/")[-1].replace("_vega.json", "")
508
+ if os.environ.get("PYTEST_CURRENT_TEST"):
509
+ specs_dir = os.path.join(os.getcwd(), "static", "viz_specs")
510
+ else:
511
+ server_dir = os.path.dirname(os.path.abspath(__file__))
512
+ project_root = os.path.abspath(os.path.join(server_dir, "..", "..", ".."))
513
+ specs_dir = os.path.join(project_root, "static", "viz_specs")
514
+ vega_path = os.path.join(specs_dir, f"{spec_id}_vega.json")
515
+ if os.path.exists(vega_path):
516
+ with open(vega_path, "r") as f:
517
+ spec = json.load(f)
518
+ except Exception:
519
+ pass
520
+
521
+ text_summary = _make_text_summary(
522
+ spec=spec,
523
+ strategy=strategy,
524
+ reason=reason,
525
+ warning=warning,
526
+ source_line=source_line,
527
+ subtitle_line=subtitle_line,
528
+ url=url,
529
+ )
530
+
531
+ structured = {
532
+ "spec": spec,
533
+ "strategy": strategy,
534
+ "url": url,
535
+ "error": None,
536
+ "warning": warning,
537
+ "reason": reason,
538
+ "source_line": source_line,
539
+ "subtitle_line": subtitle_line,
540
+ }
541
+ return ToolResult(
542
+ content=[
543
+ TextContent(type="text", text=json.dumps(structured)),
544
+ TextContent(type="text", text=text_summary),
545
+ ],
546
+ structured_content=structured,
547
+ )
548
+
549
+
550
+ async def _get_multi_indicator_viz_spec(
551
+ indicator_ids: list[dict[str, str]] | None = None,
552
+ country_code: str | None = None,
553
+ start_year: int | None = None,
554
+ end_year: int | None = None,
555
+ disaggregation_filters: dict[str, Any] | None = None,
556
+ chart_type: str | None = None,
557
+ chart_title: str | dict | None = None,
558
+ series_labels: dict[str, str] | None = None,
559
+ strategy_override: str | None = None,
560
+ year: int | None = None,
561
+ ) -> ToolResult:
562
+ """Generate a Vega-Lite chart comparing multiple Data360 indicators.
563
+
564
+ Use when you need to compare 2–4 indicators (e.g. via scatterplot or dual-axis line chart).
565
+ Ensure the database IDs and indicator IDs are already in context before using this tool. Do not guess or hallucinate these IDs.
566
+
567
+ Args:
568
+ indicator_ids: List of database/indicator dicts, e.g. [{"database_id": "WB_WDI", "indicator_id": "..."}].
569
+ country_code: Semicolon-separated ISO country codes (e.g. "KEN;USA").
570
+ start_year: Start year (inclusive). Defaults to last 5 years if omitted.
571
+ end_year: End year (inclusive). Defaults to current year if omitted.
572
+ disaggregation_filters: Optional dimension filters.
573
+ chart_type: Optional chart type suggestion. If omitted (recommended), the routing engine automatically determines the optimal chart type and strategy based on the data profile. Do not specify this argument unless the user explicitly requested a specific chart type.
574
+ chart_title: A concise, human-synthesized title summarizing the data insight (e.g. 'Renewable Energy Share in South Asia (2020)'). Prefer clean, natural phrasing instead of raw long indicator names.
575
+ series_labels: Rename dimension codes for legend.
576
+ strategy_override: Explicitly force a chart strategy (e.g. "stacked_bar", "vconcat_panels").
577
+ year: Specific single year to compare indicators for. Maps internally to start_year and end_year.
578
+ """
579
+ if year is not None:
580
+ if start_year is None:
581
+ start_year = year
582
+ if end_year is None:
583
+ end_year = year
584
+
585
+ norm_filters = _normalize_disaggregation_filters(disaggregation_filters)
586
+
587
+ res = await data360_viz.get_multi_indicator_viz_spec(
588
+ indicator_ids=indicator_ids,
589
+ country_code=country_code,
590
+ start_year=start_year,
591
+ end_year=end_year,
592
+ disaggregation_filters=norm_filters,
593
+ chart_type=chart_type,
594
+ chart_title=chart_title,
595
+ series_labels=series_labels,
596
+ strategy_override=strategy_override,
597
+ )
598
+
599
+ if res.get("error"):
600
+ raise ToolError(res["error"])
601
+
602
+ url = res.get("url")
603
+ strategy = res.get("strategy") or "unknown"
604
+ reason = res.get("reason") or ""
605
+ warning = res.get("warning")
606
+ source_line = res.get("source_line")
607
+ subtitle_line = res.get("subtitle_line")
608
+
609
+ # Prefer the spec already carried in the result dict (populated by _ok() in
610
+ # visualization.py). The disk-reload below is a fallback for callers that
611
+ # do not propagate the spec (e.g. when the chart URL points to an external
612
+ # charts API rather than the local static file server).
613
+ spec: dict | None = res.get("spec") or None
614
+ if spec is None and url:
615
+ try:
616
+ spec_id = url.split("/")[-1].replace("_vega.json", "")
617
+ if os.environ.get("PYTEST_CURRENT_TEST"):
618
+ specs_dir = os.path.join(os.getcwd(), "static", "viz_specs")
619
+ else:
620
+ server_dir = os.path.dirname(os.path.abspath(__file__))
621
+ project_root = os.path.abspath(os.path.join(server_dir, "..", "..", ".."))
622
+ specs_dir = os.path.join(project_root, "static", "viz_specs")
623
+ vega_path = os.path.join(specs_dir, f"{spec_id}_vega.json")
624
+ if os.path.exists(vega_path):
625
+ with open(vega_path, "r") as f:
626
+ spec = json.load(f)
627
+ except Exception:
628
+ pass
629
+
630
+ text_summary = _make_text_summary(
631
+ spec=spec,
632
+ strategy=strategy,
633
+ reason=reason,
634
+ warning=warning,
635
+ source_line=source_line,
636
+ subtitle_line=subtitle_line,
637
+ url=url,
638
+ )
639
+
640
+ structured = {
641
+ "spec": spec,
642
+ "strategy": strategy,
643
+ "url": url,
644
+ "error": None,
645
+ "warning": warning,
646
+ "reason": reason,
647
+ "source_line": source_line,
648
+ "subtitle_line": subtitle_line,
649
+ }
650
+ return ToolResult(
651
+ content=[
652
+ TextContent(type="text", text=json.dumps(structured)),
653
+ TextContent(type="text", text=text_summary),
654
+ ],
655
+ structured_content=structured,
656
+ )
657
+
658
+
659
+ def _get_supported_chart_types() -> str:
660
+ """Return supported chart types and their data requirements as JSON.
661
+
662
+ **DEPRECATED**: Read the ``data360://viz/chart-grammar`` resource instead.
663
+ This tool is preserved for backward compatibility.
664
+ """
665
+ import json
666
+
667
+ result = data360_viz.get_supported_chart_types()
668
+ parsed = json.loads(result)
669
+ parsed["_deprecation_notice"] = (
670
+ "This tool is deprecated. Read the data360://viz/chart-grammar resource "
671
+ "for comprehensive chart strategy rules. The data_profile in every viz "
672
+ "response now includes per-indicator ranges and scale compatibility."
673
+ )
674
+ return json.dumps(parsed, indent=2)
675
+
676
+
677
+
678
+ async def _expand_country_group(
679
+ group_code: str,
680
+ ) -> dict[str, Any]:
681
+ """Expand a REF_AREA group code into its constituent country codes.
682
+
683
+ Use when you need individual country codes for a regional or income group code (e.g. "SAS").
684
+
685
+ Args:
686
+ group_code: The group code to expand (e.g. "SAS" for South Asia, "LIC" for Low Income).
687
+ """
688
+ return await data360_providers.expand_country_group(group_code=group_code)
689
+
690
+
691
+ async def _summarize_data(
692
+ database_id: str,
693
+ indicator_id: str,
694
+ country_code: str | None = None,
695
+ disaggregation_filters: dict[str, Any] | None = None,
696
+ start_year: int | None = None,
697
+ end_year: int | None = None,
698
+ group_by: list[str] | None = None,
699
+ ) -> Any:
700
+ """Compute summary statistics for indicator data, grouped by dimensions.
701
+
702
+ Use when the user asks about trends, changes over time, or general statistical summaries.
703
+ Ensure the database ID and the indicator ID are already in context before using this tool. Do not guess or hallucinate these IDs.
704
+
705
+ Args:
706
+ database_id: Database identifier (e.g. "WB_WDI").
707
+ indicator_id: Indicator ID (e.g. "WB_WDI_NY_GDP_PCAP_KD").
708
+ country_code: Semicolon-separated ISO country codes (e.g. "KEN;USA").
709
+ disaggregation_filters: Optional dimension filters.
710
+ start_year: Start year (inclusive). Defaults to last 5 years if omitted.
711
+ end_year: End year (inclusive). Defaults to current year if omitted.
712
+ group_by: Dimensions to group by (default is ["ref_area"]).
713
+ """
714
+ norm_filters = _normalize_disaggregation_filters(disaggregation_filters)
715
+
716
+ return await data360_api.summarize_data(
717
+ database_id=database_id,
718
+ indicator_id=indicator_id,
719
+ country_code=country_code,
720
+ disaggregation_filters=norm_filters,
721
+ start_year=start_year,
722
+ end_year=end_year,
723
+ group_by=group_by,
724
+ )
725
+
726
+
727
+ async def _rank_countries(
728
+ database_id: str,
729
+ indicator_id: str,
730
+ country_group: str | None = None,
731
+ country_codes: str | None = None,
732
+ year: int | None = None,
733
+ order: Literal["desc", "asc"] = "desc",
734
+ top_n: int = 10,
735
+ disaggregation_filters: dict[str, Any] | None = None,
736
+ rank_universe: Literal["explicit", "all_member_economies"] = "explicit",
737
+ ) -> Any:
738
+ """Rank countries by indicator value for a specific year.
739
+
740
+ Use when asked to rank countries, find leaderboards, or query top/bottom performing economies.
741
+ Ensure the database ID and the indicator ID are already in context before using this tool. Do not guess or hallucinate these IDs.
742
+
743
+ Args:
744
+ database_id: Database identifier (e.g. "WB_WDI").
745
+ indicator_id: Indicator ID (e.g. "WB_WDI_NY_GDP_PCAP_KD").
746
+ country_group: Code of region/income group (e.g. "SAS").
747
+ country_codes: Semicolon-separated ISO country codes (e.g. "KEN;USA;NGA").
748
+ year: Year for ranking. If omitted, selected automatically based on coverage.
749
+ order: Sort order: "desc" (default, highest first) or "asc" (lowest first).
750
+ top_n: Number of ranked entries to return.
751
+ disaggregation_filters: Optional dimension filters.
752
+ rank_universe: "explicit" (default, uses codes/group) or "all_member_economies" (world).
753
+ """
754
+ norm_filters = _normalize_disaggregation_filters(disaggregation_filters)
755
+
756
+ return await data360_api.rank_countries(
757
+ database_id=database_id,
758
+ indicator_id=indicator_id,
759
+ country_group=country_group,
760
+ country_codes=country_codes,
761
+ year=year,
762
+ order=order,
763
+ top_n=top_n,
764
+ disaggregation_filters=norm_filters,
765
+ rank_universe=rank_universe,
766
+ )
767
+
768
+
769
+ async def _compare_countries(
770
+ database_id: str,
771
+ indicator_id: str,
772
+ country_codes: str,
773
+ year: int | None = None,
774
+ include_time_series: bool = False,
775
+ start_year: int | None = None,
776
+ end_year: int | None = None,
777
+ disaggregation_filters: dict[str, Any] | None = None,
778
+ ) -> Any:
779
+ """Compare an indicator across multiple countries (2 to 8).
780
+
781
+ Use when asked to compare specific countries or find gaps/convergence between them.
782
+ Ensure the database ID and the indicator ID are already in context before using this tool. Do not guess or hallucinate these IDs.
783
+ Call `data360_get_disaggregation` first to find available years and breakdowns for the `disaggregation_filters`.
784
+
785
+ Args:
786
+ database_id: Database identifier (e.g. "WB_WDI").
787
+ indicator_id: Indicator ID (e.g. "WB_WDI_NY_GDP_PCAP_KD").
788
+ country_codes: Semicolon-separated ISO country codes (e.g. "KEN;NGA;ZAF").
789
+ year: Snapshot comparison year. If omitted, selected automatically.
790
+ include_time_series: Whether to return time-series data for trend comparison.
791
+ start_year: Start year for time-series alignment. Defaults to last 5 years if omitted.
792
+ end_year: End year for time-series alignment. Defaults to current year if omitted.
793
+ disaggregation_filters: Optional dimension filters.
794
+ """
795
+ norm_filters = _normalize_disaggregation_filters(disaggregation_filters)
796
+
797
+ return await data360_api.compare_countries(
798
+ database_id=database_id,
799
+ indicator_id=indicator_id,
800
+ country_codes=country_codes,
801
+ year=year,
802
+ include_time_series=include_time_series,
803
+ start_year=start_year,
804
+ end_year=end_year,
805
+ disaggregation_filters=norm_filters,
806
+ )
807
+
808
+
809
+ # ---------------------------------------------------------------------------
810
+ # Tool Registration
811
+ # ---------------------------------------------------------------------------
812
+
813
+ search_indicators = mcp.tool(
814
+ instrument_mcp_tool(_search_indicators, tool_name="data360_search_indicators"),
815
+ name="data360_search_indicators",
816
+ )
817
+
818
+ search_datasets = mcp.tool(
819
+ instrument_mcp_tool(_search_datasets, tool_name="data360_search_datasets"),
820
+ name="data360_search_datasets",
821
+ )
822
+
823
+ get_metadata = mcp.tool(
824
+ instrument_mcp_tool(_get_metadata, tool_name="data360_get_metadata"),
825
+ name="data360_get_metadata",
826
+ )
827
+
828
+ get_data = mcp.tool(
829
+ instrument_mcp_tool(_get_data, tool_name="data360_get_data"),
830
+ name="data360_get_data",
831
+ )
832
+
833
+ get_disaggregation = mcp.tool(
834
+ instrument_mcp_tool(_get_disaggregation, tool_name="data360_get_disaggregation"),
835
+ name="data360_get_disaggregation",
836
+ )
837
+
838
+ find_codelist_value = mcp.tool(
839
+ instrument_mcp_tool(_find_codelist_value, tool_name="data360_find_codelist_value"),
840
+ name="data360_find_codelist_value",
841
+ )
842
+
843
+ list_indicators = mcp.tool(
844
+ instrument_mcp_tool(_list_indicators, tool_name="data360_list_indicators"),
845
+ name="data360_list_indicators",
846
+ )
847
+
848
+ get_data_api_url = mcp.tool(
849
+ instrument_mcp_tool(_get_data_api_url, tool_name="data360_get_data_api_url"),
850
+ name="data360_get_data_api_url",
851
+ )
852
+
853
+ get_viz_spec = mcp.tool(
854
+ instrument_mcp_tool(_get_viz_spec, tool_name="data360_get_viz_spec"),
855
+ name="data360_get_viz_spec",
856
+ app=AppConfig(resource_uri="ui://data360-chart/index.html"),
857
+ )
858
+
859
+ get_multi_indicator_viz_spec = mcp.tool(
860
+ instrument_mcp_tool(
861
+ _get_multi_indicator_viz_spec,
862
+ tool_name="data360_get_multi_indicator_viz_spec",
863
+ ),
864
+ name="data360_get_multi_indicator_viz_spec",
865
+ app=AppConfig(resource_uri="ui://data360-chart/index.html"),
866
+ )
867
+
868
+ get_supported_chart_types = mcp.tool(
869
+ instrument_mcp_tool(
870
+ _get_supported_chart_types,
871
+ tool_name="data360_get_supported_chart_types",
872
+ ),
873
+ name="data360_get_supported_chart_types",
874
+ )
875
+
876
+ expand_country_group = mcp.tool(
877
+ instrument_mcp_tool(
878
+ _expand_country_group, tool_name="data360_expand_country_group"
879
+ ),
880
+ name="data360_expand_country_group",
881
+ )
882
+
883
+ # ---------------------------------------------------------------------------
884
+ # Data Aggregation Tools (with custom serialization)
885
+ # ---------------------------------------------------------------------------
886
+
887
+ summarize_data = mcp.add_tool(
888
+ Tool.from_function(
889
+ instrument_mcp_tool(_summarize_data, tool_name="data360_summarize_data"),
890
+ name="data360_summarize_data",
891
+ serializer=_compact_aggregation_serializer,
892
+ )
893
+ )
894
+
895
+ rank_countries = mcp.add_tool(
896
+ Tool.from_function(
897
+ instrument_mcp_tool(_rank_countries, tool_name="data360_rank_countries"),
898
+ name="data360_rank_countries",
899
+ serializer=_compact_aggregation_serializer,
900
+ )
901
+ )
902
+
903
+ compare_countries = mcp.add_tool(
904
+ Tool.from_function(
905
+ instrument_mcp_tool(_compare_countries, tool_name="data360_compare_countries"),
906
+ name="data360_compare_countries",
907
+ serializer=_compact_aggregation_serializer,
908
+ )
909
+ )
910
+
911
+
912
+
913
+ @mcp.resource("ui://data360-chart/index.html")
914
+ def data360_chart_html() -> str:
915
+ """HTML resource for the Data360 self-contained Vega-Lite chart viewer Custom HTML app."""
916
+ vega_js, vega_lite_js, vega_embed_js, vega_interpreter_js = get_cached_vega_libs()
917
+ html_template = """<!DOCTYPE html>
918
+ <html>
919
+ <head>
920
+ <meta charset="utf-8">
921
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
922
+ <title>Data360 Vega-Lite Renderer</title>
923
+ <style>
924
+ body {
925
+ margin: 0;
926
+ padding: 8px;
927
+ background: transparent;
928
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
929
+ }
930
+ #vis {
931
+ width: 100%;
932
+ height: 100%;
933
+ min-height: 400px;
934
+ }
935
+ </style>
936
+ <script>{vega_js}</script>
937
+ <script>{vega_interpreter_js}</script>
938
+ <script>{vega_lite_js}</script>
939
+ <script>{vega_embed_js}</script>
940
+ </head>
941
+ <body>
942
+ <div id="vis"></div>
943
+
944
+ <script type="module">
945
+ class McpAppClient {
946
+ constructor() {
947
+ this.pendingRequests = new Map();
948
+ this.requestId = 0;
949
+ this.initialized = false;
950
+ this.hostContext = null;
951
+ window.addEventListener('message', (e) => this.handleMessage(e));
952
+ this.initialize();
953
+ }
954
+
955
+ async initialize() {
956
+ try {
957
+ const result = await this.request('ui/initialize', {
958
+ appInfo: { name: 'Data360 Chart', version: '1.0.0' },
959
+ appCapabilities: {},
960
+ protocolVersion: '2025-11-21'
961
+ });
962
+ this.hostContext = result.hostContext;
963
+ this.initialized = true;
964
+ this.notify('ui/notifications/initialized', {});
965
+ this.reportSize();
966
+ } catch (error) {
967
+ console.error('Failed to initialize MCP App:', error);
968
+ }
969
+ }
970
+
971
+ handleMessage(event) {
972
+ const data = event.data;
973
+ if (!data || typeof data !== 'object') return;
974
+ if ('id' in data && this.pendingRequests.has(data.id)) {
975
+ const { resolve, reject } = this.pendingRequests.get(data.id);
976
+ this.pendingRequests.delete(data.id);
977
+ if (data.error) {
978
+ reject(new Error(data.error.message));
979
+ } else {
980
+ resolve(data.result);
981
+ }
982
+ return;
983
+ }
984
+ if (data.method === 'ui/notifications/tool-result') {
985
+ try {
986
+ const result = data.params;
987
+ let spec = null;
988
+ let strategy = null;
989
+ if (result.structuredContent) {
990
+ spec = result.structuredContent.spec;
991
+ strategy = result.structuredContent.strategy;
992
+ }
993
+ if (!spec && result.content) {
994
+ const textBlock = result.content.find(c => c.type === 'text');
995
+ if (textBlock) {
996
+ try {
997
+ const payload = JSON.parse(textBlock.text);
998
+ spec = payload.spec;
999
+ strategy = payload.strategy;
1000
+ } catch (e) {
1001
+ // Ignore JSON parse error for plain text
1002
+ }
1003
+ }
1004
+ }
1005
+ renderChart(spec, strategy);
1006
+ } catch (e) {
1007
+ console.error('Error parsing tool result:', e);
1008
+ }
1009
+ }
1010
+ }
1011
+
1012
+ request(method, params) {
1013
+ return new Promise((resolve, reject) => {
1014
+ const id = ++this.requestId;
1015
+ this.pendingRequests.set(id, { resolve, reject });
1016
+ window.parent.postMessage({ jsonrpc: '2.0', id, method, params }, '*');
1017
+ setTimeout(() => {
1018
+ if (this.pendingRequests.has(id)) {
1019
+ this.pendingRequests.delete(id);
1020
+ reject(new Error('Request timed out'));
1021
+ }
1022
+ }, 30000);
1023
+ });
1024
+ }
1025
+
1026
+ notify(method, params) {
1027
+ window.parent.postMessage({ jsonrpc: '2.0', method, params }, '*');
1028
+ }
1029
+
1030
+ reportSize() {
1031
+ this.notify('ui/notifications/size-changed', {
1032
+ height: document.body.scrollHeight
1033
+ });
1034
+ }
1035
+ }
1036
+
1037
+ const mcpApp = new McpAppClient();
1038
+ const visDiv = document.getElementById('vis');
1039
+
1040
+ function renderChart(spec, strategy) {
1041
+ if (!spec) {
1042
+ visDiv.innerHTML = '<p>No visualization spec available</p>';
1043
+ mcpApp.reportSize();
1044
+ return;
1045
+ }
1046
+
1047
+ try {
1048
+ vegaEmbed("#vis", spec, {
1049
+ actions: false,
1050
+ theme: mcpApp.hostContext?.theme === 'dark' ? 'dark' : 'default',
1051
+ ast: true,
1052
+ expr: vega.expressionInterpreter
1053
+ }).then(() => {
1054
+ mcpApp.reportSize();
1055
+ }).catch(err => {
1056
+ console.error(err);
1057
+ visDiv.innerHTML = `<p style="color:red;">Failed to render chart spec: ${err.message}</p>`;
1058
+ mcpApp.reportSize();
1059
+ });
1060
+ } catch (e) {
1061
+ visDiv.innerHTML = `<p style="color:red;">Error preparing spec: ${e.message}</p>`;
1062
+ mcpApp.reportSize();
1063
+ }
1064
+ }
1065
+
1066
+ window.addEventListener('load', () => {
1067
+ mcpApp.reportSize();
1068
+ });
1069
+ </script>
1070
+ </body>
1071
+ </html>
1072
+ """
1073
+ return html_template.replace("{vega_js}", vega_js).replace("{vega_lite_js}", vega_lite_js).replace("{vega_embed_js}", vega_embed_js).replace("{vega_interpreter_js}", vega_interpreter_js)
1074
+
1075
+
1076
+
1077
+ async def _search_indicators_for_ui(
1078
+ query: str,
1079
+ database: Optional[str] = None,
1080
+ limit: int = 20,
1081
+ ) -> list[dict]:
1082
+ """Private helper: returns a flat indicator list for the UI HTML app.
1083
+
1084
+ Not registered as an MCP tool — called internally by data360_indicator_explorer
1085
+ and by the /api/indicators/search FastAPI endpoint.
1086
+ """
1087
+ if not query.strip():
1088
+ return []
1089
+ res = await _search_indicators(query=query, database=database, limit=limit)
1090
+ indicators_data = []
1091
+ if hasattr(res, "indicators") and res.indicators:
1092
+ for ind in res.indicators:
1093
+ indicators_data.append({
1094
+ "idno": ind.idno,
1095
+ "database_id": ind.database_id,
1096
+ "database_name": ind.database_name,
1097
+ "name": ind.name,
1098
+ "truncated_definition": ind.truncated_definition,
1099
+ "time_period_range": ind.time_period_range,
1100
+ })
1101
+ return indicators_data
1102
+
1103
+
1104
+
1105
+ @mcp.resource("ui://data360-choice/index.html")
1106
+ def data360_choice_html() -> str:
1107
+ """HTML resource for the Data360 self-contained choice Custom HTML app."""
1108
+ return """<!DOCTYPE html>
1109
+ <html>
1110
+ <head>
1111
+ <meta charset="utf-8">
1112
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1113
+ <title>Data360 Option Selector</title>
1114
+ <link rel="preconnect" href="https://fonts.googleapis.com">
1115
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
1116
+ <link href="https://fonts.googleapis.com/css2?family=Noto+Sans:ital,wght@0,100..900;1,100..900&display=swap" rel="stylesheet">
1117
+ <style>
1118
+ :root, .light {
1119
+ --bg-color: transparent;
1120
+ --text-color: #0f172a;
1121
+ --card-bg: #f1f5f9;
1122
+ --card-border: transparent;
1123
+ --btn-hover: #e2e8f0;
1124
+ --btn-border: #cbd5e1;
1125
+ --muted-color: #64748b;
1126
+ }
1127
+
1128
+ .dark {
1129
+ --bg-color: transparent;
1130
+ --text-color: #cbd5e1;
1131
+ --card-bg: #1e293b;
1132
+ --card-border: transparent;
1133
+ --btn-hover: #334155;
1134
+ --btn-border: #475569;
1135
+ --muted-color: #94a3b8;
1136
+ }
1137
+
1138
+ @media (prefers-color-scheme: dark) {
1139
+ :root:not(.light) {
1140
+ --bg-color: transparent;
1141
+ --text-color: #cbd5e1;
1142
+ --card-bg: #1e293b;
1143
+ --card-border: transparent;
1144
+ --btn-hover: #334155;
1145
+ --btn-border: #475569;
1146
+ --muted-color: #94a3b8;
1147
+ }
1148
+ }
1149
+
1150
+ body {
1151
+ margin: 0;
1152
+ padding: 8px 12px;
1153
+ background: var(--bg-color);
1154
+ color: var(--text-color);
1155
+ font-family: "Noto Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
1156
+ box-sizing: border-box;
1157
+ }
1158
+ .prompt-title {
1159
+ font-size: 1.15rem;
1160
+ font-weight: 500;
1161
+ color: var(--text-color);
1162
+ margin: 0 0 16px 0;
1163
+ line-height: 1.4;
1164
+ }
1165
+ .choices-container {
1166
+ display: flex;
1167
+ gap: 12px;
1168
+ flex-wrap: wrap;
1169
+ width: 100%;
1170
+ }
1171
+ .choice-card {
1172
+ display: flex;
1173
+ flex-direction: column;
1174
+ justify-content: space-between;
1175
+ align-items: flex-start;
1176
+ flex: 1 1 calc(33.333% - 8px);
1177
+ min-width: 180px;
1178
+ padding: 16px 20px;
1179
+ background: var(--card-bg);
1180
+ border: 1px solid var(--card-border);
1181
+ border-radius: 1.25rem;
1182
+ color: var(--text-color);
1183
+ font-size: 0.95rem;
1184
+ font-weight: 500;
1185
+ font-family: inherit;
1186
+ cursor: pointer;
1187
+ text-align: left;
1188
+ outline: none;
1189
+ box-sizing: border-box;
1190
+ transition: background-color 0.15s, border-color 0.15s, transform 0.1s;
1191
+ }
1192
+ .choice-card:hover:not(:disabled) {
1193
+ background: var(--btn-hover);
1194
+ border-color: var(--btn-border);
1195
+ transform: translateY(-1px);
1196
+ }
1197
+ .choice-card:active:not(:disabled) {
1198
+ transform: translateY(0);
1199
+ }
1200
+ .choice-card:disabled {
1201
+ cursor: not-allowed;
1202
+ }
1203
+ .choice-card:disabled:not(.selected) {
1204
+ opacity: 0.4;
1205
+ }
1206
+ .choice-card.selected {
1207
+ background: var(--btn-hover) !important;
1208
+ border-color: var(--btn-border) !important;
1209
+ opacity: 1 !important;
1210
+ transform: none !important;
1211
+ }
1212
+ .choice-text {
1213
+ flex-grow: 1;
1214
+ margin-bottom: 16px;
1215
+ line-height: 1.35;
1216
+ }
1217
+ .routing-icon {
1218
+ font-size: 1.25rem;
1219
+ font-weight: bold;
1220
+ color: var(--text-color);
1221
+ opacity: 0.8;
1222
+ }
1223
+ .response-sent {
1224
+ font-size: 0.9rem;
1225
+ color: var(--muted-color);
1226
+ margin-top: 12px;
1227
+ display: none;
1228
+ }
1229
+ .choice-card.specify-mode {
1230
+ cursor: default;
1231
+ transform: none !important;
1232
+ background: var(--btn-hover);
1233
+ border-color: var(--btn-border);
1234
+ width: 100%;
1235
+ flex: 1 1 100%;
1236
+ align-items: stretch;
1237
+ }
1238
+ .specify-input {
1239
+ flex-grow: 1;
1240
+ background: transparent;
1241
+ border: none;
1242
+ outline: none;
1243
+ color: var(--text-color);
1244
+ font-size: 0.95rem;
1245
+ font-family: inherit;
1246
+ font-weight: 500;
1247
+ padding: 4px 0;
1248
+ width: 100%;
1249
+ }
1250
+ .specify-input::placeholder {
1251
+ color: var(--muted-color);
1252
+ opacity: 0.6;
1253
+ }
1254
+ .specify-submit-btn {
1255
+ background: transparent;
1256
+ border: none;
1257
+ cursor: pointer;
1258
+ font-size: 1.25rem;
1259
+ font-weight: bold;
1260
+ color: var(--text-color);
1261
+ padding: 0 4px;
1262
+ display: flex;
1263
+ align-items: center;
1264
+ outline: none;
1265
+ transition: transform 0.1s;
1266
+ }
1267
+ .specify-submit-btn:hover {
1268
+ transform: scale(1.1);
1269
+ }
1270
+ .specify-submit-btn:active {
1271
+ transform: scale(1.0);
1272
+ }
1273
+ </style>
1274
+ </head>
1275
+ <body>
1276
+ <p class="prompt-title" id="card-prompt">Loading...</p>
1277
+ <div class="choices-container" id="choices-container"></div>
1278
+ <div class="response-sent" id="sent-msg">Response sent.</div>
1279
+
1280
+ <script type="module">
1281
+ class McpAppClient {
1282
+ constructor() {
1283
+ this.pendingRequests = new Map();
1284
+ this.requestId = 0;
1285
+ this.initialized = false;
1286
+ this.hostContext = null;
1287
+ window.addEventListener('message', (e) => this.handleMessage(e));
1288
+ this.initialize();
1289
+ }
1290
+
1291
+ async initialize() {
1292
+ try {
1293
+ const result = await this.request('ui/initialize', {
1294
+ appInfo: { name: 'Data360 Choice', version: '1.0.0' },
1295
+ appCapabilities: {},
1296
+ protocolVersion: '2025-11-21'
1297
+ });
1298
+ this.hostContext = result.hostContext;
1299
+ this.initialized = true;
1300
+ this.notify('ui/notifications/initialized', {});
1301
+ this.applyTheme();
1302
+ this.reportSize();
1303
+ } catch (error) {
1304
+ console.error('Failed to initialize MCP App:', error);
1305
+ }
1306
+ }
1307
+
1308
+ applyTheme() {
1309
+ const theme = this.hostContext?.theme || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
1310
+ if (theme === 'dark') {
1311
+ document.documentElement.classList.add('dark');
1312
+ document.documentElement.classList.remove('light');
1313
+ } else {
1314
+ document.documentElement.classList.add('light');
1315
+ document.documentElement.classList.remove('dark');
1316
+ }
1317
+ }
1318
+
1319
+ handleMessage(event) {
1320
+ const data = event.data;
1321
+ if (!data || typeof data !== 'object') return;
1322
+ if ('id' in data && this.pendingRequests.has(data.id)) {
1323
+ const { resolve, reject } = this.pendingRequests.get(data.id);
1324
+ this.pendingRequests.delete(data.id);
1325
+ if (data.error) {
1326
+ reject(new Error(data.error.message));
1327
+ } else {
1328
+ resolve(data.result);
1329
+ }
1330
+ return;
1331
+ }
1332
+ if (data.method === 'ui/notifications/host-context-changed') {
1333
+ this.hostContext = { ...this.hostContext, ...data.params };
1334
+ this.applyTheme();
1335
+ return;
1336
+ }
1337
+ if (data.method === 'ui/notifications/tool-result') {
1338
+ try {
1339
+ const result = data.params;
1340
+ let payload = null;
1341
+ if (result.content) {
1342
+ const textBlock = result.content.find(c => c.type === 'text');
1343
+ if (textBlock) {
1344
+ payload = JSON.parse(textBlock.text);
1345
+ }
1346
+ }
1347
+ if (payload) {
1348
+ renderChoiceCard(payload);
1349
+ }
1350
+ } catch (e) {
1351
+ console.error('Error parsing tool result:', e);
1352
+ }
1353
+ }
1354
+ }
1355
+
1356
+ request(method, params) {
1357
+ return new Promise((resolve, reject) => {
1358
+ const id = ++this.requestId;
1359
+ this.pendingRequests.set(id, { resolve, reject });
1360
+ window.parent.postMessage({ jsonrpc: '2.0', id, method, params }, '*');
1361
+ setTimeout(() => {
1362
+ if (this.pendingRequests.has(id)) {
1363
+ this.pendingRequests.delete(id);
1364
+ reject(new Error('Request timed out'));
1365
+ }
1366
+ }, 30000);
1367
+ });
1368
+ }
1369
+
1370
+ notify(method, params) {
1371
+ window.parent.postMessage({ jsonrpc: '2.0', method, params }, '*');
1372
+ }
1373
+
1374
+ reportSize() {
1375
+ this.notify('ui/notifications/size-changed', {
1376
+ height: document.body.scrollHeight
1377
+ });
1378
+ }
1379
+
1380
+ async sendMessageToChat(text) {
1381
+ return this.request('ui/message', {
1382
+ role: 'user',
1383
+ content: [{ type: 'text', text }]
1384
+ });
1385
+ }
1386
+ }
1387
+
1388
+ const mcpApp = new McpAppClient();
1389
+ const promptEl = document.getElementById('card-prompt');
1390
+ const containerEl = document.getElementById('choices-container');
1391
+ const sentMsgEl = document.getElementById('sent-msg');
1392
+
1393
+ function renderChoiceCard(payload) {
1394
+ const prompt = payload.prompt || "";
1395
+ const options = payload.options || [];
1396
+
1397
+ promptEl.textContent = prompt;
1398
+ containerEl.innerHTML = "";
1399
+
1400
+ options.forEach(opt => {
1401
+ const btn = document.createElement('button');
1402
+ btn.className = 'choice-card';
1403
+
1404
+ const txtDiv = document.createElement('div');
1405
+ txtDiv.className = 'choice-text';
1406
+ txtDiv.textContent = opt;
1407
+
1408
+ const iconDiv = document.createElement('div');
1409
+ iconDiv.className = 'routing-icon';
1410
+ iconDiv.textContent = '↪';
1411
+
1412
+ btn.appendChild(txtDiv);
1413
+ btn.appendChild(iconDiv);
1414
+
1415
+ btn.addEventListener('click', async (e) => {
1416
+ const lowerOpt = opt.toLowerCase();
1417
+ if (lowerOpt.includes('specify') || lowerOpt.includes('other') || lowerOpt.includes('custom') || lowerOpt.includes('enter') || opt.endsWith('...')) {
1418
+ if (btn.classList.contains('specify-mode')) {
1419
+ return;
1420
+ }
1421
+
1422
+ // Enter specify mode
1423
+ btn.classList.add('specify-mode');
1424
+ btn.innerHTML = '';
1425
+
1426
+ // Disable other buttons
1427
+ const cards = containerEl.querySelectorAll('.choice-card');
1428
+ cards.forEach(c => {
1429
+ if (c !== btn) {
1430
+ c.style.opacity = '0.3';
1431
+ c.disabled = true;
1432
+ }
1433
+ });
1434
+
1435
+ const form = document.createElement('form');
1436
+ form.style.display = 'flex';
1437
+ form.style.width = '100%';
1438
+ form.style.gap = '8px';
1439
+ form.style.alignItems = 'center';
1440
+ form.style.boxSizing = 'border-box';
1441
+
1442
+ const input = document.createElement('input');
1443
+ input.type = 'text';
1444
+ input.className = 'specify-input';
1445
+ let placeholder = 'Type here...';
1446
+ if (opt.toLowerCase().includes('country')) {
1447
+ placeholder = 'Enter country name...';
1448
+ } else if (opt.toLowerCase().includes('year') || opt.toLowerCase().includes('range') || opt.toLowerCase().includes('timeframe')) {
1449
+ placeholder = 'e.g. 2015-2020';
1450
+ }
1451
+ input.placeholder = placeholder;
1452
+ input.required = true;
1453
+
1454
+ // Focus input
1455
+ setTimeout(() => input.focus(), 10);
1456
+
1457
+ // Handle Escape key to cancel/revert specify mode
1458
+ input.addEventListener('keydown', (ev) => {
1459
+ if (ev.key === 'Escape') {
1460
+ ev.preventDefault();
1461
+ ev.stopPropagation();
1462
+ renderChoiceCard(payload);
1463
+ }
1464
+ });
1465
+
1466
+ const submitBtn = document.createElement('button');
1467
+ submitBtn.type = 'submit';
1468
+ submitBtn.className = 'specify-submit-btn';
1469
+ submitBtn.textContent = '↪';
1470
+
1471
+ form.appendChild(input);
1472
+ form.appendChild(submitBtn);
1473
+ btn.appendChild(form);
1474
+
1475
+ mcpApp.reportSize();
1476
+
1477
+ form.addEventListener('click', (ev) => ev.stopPropagation());
1478
+ form.addEventListener('submit', async (ev) => {
1479
+ ev.preventDefault();
1480
+ const val = input.value.trim();
1481
+ if (!val) return;
1482
+
1483
+ btn.classList.remove('specify-mode');
1484
+ btn.classList.add('selected');
1485
+ btn.innerHTML = '';
1486
+
1487
+ const finalTxt = document.createElement('div');
1488
+ finalTxt.className = 'choice-text';
1489
+ finalTxt.textContent = val;
1490
+
1491
+ const finalIcon = document.createElement('div');
1492
+ finalIcon.className = 'routing-icon';
1493
+ finalIcon.textContent = '↪';
1494
+
1495
+ btn.appendChild(finalTxt);
1496
+ btn.appendChild(finalIcon);
1497
+
1498
+ try {
1499
+ await mcpApp.sendMessageToChat(`\u21AA\uFE0E *${val}*`);
1500
+ } catch (err) {
1501
+ console.error(err);
1502
+ btn.classList.remove('selected');
1503
+ // Restore original list on error
1504
+ renderChoiceCard(payload);
1505
+ }
1506
+ });
1507
+ return;
1508
+ }
1509
+
1510
+ const cards = containerEl.querySelectorAll('.choice-card');
1511
+ cards.forEach(c => c.disabled = true);
1512
+ btn.classList.add('selected');
1513
+
1514
+ mcpApp.reportSize();
1515
+
1516
+ try {
1517
+ await mcpApp.sendMessageToChat(`\u21AA\uFE0E *${opt}*`);
1518
+ } catch (err) {
1519
+ console.error(err);
1520
+ btn.classList.remove('selected');
1521
+ cards.forEach(c => c.disabled = false);
1522
+ mcpApp.reportSize();
1523
+ }
1524
+ });
1525
+ containerEl.appendChild(btn);
1526
+ });
1527
+
1528
+ mcpApp.reportSize();
1529
+ setTimeout(() => mcpApp.reportSize(), 50);
1530
+ }
1531
+
1532
+ window.addEventListener('load', () => {
1533
+ mcpApp.reportSize();
1534
+ });
1535
+ </script>
1536
+ </body>
1537
+ </html>
1538
+ """
1539
+
1540
+
1541
+ @mcp.tool(
1542
+ name="data360_interactive_choices",
1543
+ app=AppConfig(resource_uri="ui://data360-choice/index.html", prefers_border=False),
1544
+ )
1545
+ async def data360_interactive_choices(
1546
+ prompt: str,
1547
+ options: list[str],
1548
+ title: Optional[str] = None,
1549
+ ) -> ToolResult:
1550
+ """Present the user with a set of options to choose from using a custom HTML renderer.
1551
+
1552
+ Always call this tool to provide follow-ups and elicitations based on the natural flow of the
1553
+ conversation and the type of information being discussed. Your goal is to anticipate the
1554
+ user's next question or provide an easy way to steer a broad topic.
1555
+
1556
+ Call this tool in the following scenarios:
1557
+
1558
+ 1. Single Follow-up (1 choice):
1559
+ - The "Obvious Next Step": When there is one highly logical action to take after your response.
1560
+ For example, if you explain a mathematical concept, offer a follow-up to walk through a practical example.
1561
+ - Deep Dives into Jargon: If your response introduces a complex technical term or a new concept,
1562
+ offer a single follow-up to explain that specific term so the main response does not get too cluttered.
1563
+ - Launching Interactive Tools: If you mention that you can build a widget or run a simulation,
1564
+ provide a single button to let the user trigger that specific interactive element directly.
1565
+
1566
+ 2. Multiple Choices (2+ choices):
1567
+ - Broad Overviews & Branching Paths: When you give a high-level summary of a massive topic,
1568
+ use this to let the user choose exactly which sub-category or "branch" you want to zoom in on next.
1569
+ - Disambiguation (Clarifying Intent): If the user's request is open-ended or could be interpreted in
1570
+ a few different ways, present options so the user can clarify exactly which direction they meant to take.
1571
+ Examples:
1572
+ * GDP/Metric variant: "Real GDP per capita (constant 2015 US$)" vs "Nominal GDP per capita (current US$)"
1573
+ * Timeframe/Year range: "Latest available year" vs "Historical trend (last 10 years)" vs "Specify a custom range"
1574
+ * Breakdown/Disaggregation: "Total economy average" vs "Break down by gender (Male vs Female)" vs "Break down by geographic area (Urban vs Rural)"
1575
+ - Menus and Brainstorming: When generating lists of ideas (like different programming frameworks,
1576
+ design patterns, or troubleshooting steps), use this to act like a clickable menu, letting the user
1577
+ instantly select the one you want to explore.
1578
+
1579
+ 3. Non-exhaustive Lists (CRITICAL):
1580
+ - If you present a list of choices that is not exhaustive (such as listing a few popular countries,
1581
+ specific years, indicator variants, or breakdowns), you MUST always dynamically include a customizable
1582
+ option as the last item in the options list.
1583
+ Examples:
1584
+ * Country list: options=["Kenya", "Nigeria", "South Africa", "United States", "India", "Specify another country..."]
1585
+ * Year list: options=["2024 (latest)", "Last 5 years", "Last 10 years", "Specify a custom range"]
1586
+ * Breakdowns: options=["Total Average", "Breakdown by Gender", "Other (specify)"]
1587
+
1588
+ Essentially, surface these components whenever you can save the user the effort of typing out the
1589
+ logical next prompt, or when the conversation has reached a crossroads and you need the user to choose
1590
+ the direction.
1591
+
1592
+ Args:
1593
+ prompt: The question or decision to present to the user.
1594
+ options: List of options the user can choose from.
1595
+ title: Optional heading for the card.
1596
+ """
1597
+ payload = {
1598
+ "prompt": prompt,
1599
+ "options": options,
1600
+ "title": title or "Choose an Option"
1601
+ }
1602
+ return ToolResult(
1603
+ content=[TextContent(type="text", text=json.dumps(payload))]
1604
+ )
1605
+
src/data360/models.py ADDED
@@ -0,0 +1,942 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+ from typing import Any, Literal
3
+
4
+ from pydantic import BaseModel, Field, model_validator
5
+
6
+ # Prefix used by the search API's metadata_link entries.
7
+ # Strip this to derive the usable indicator_id.
8
+ _META_ID_PREFIX = "META_"
9
+
10
+
11
+ def sanitize_search_query(query: str) -> str:
12
+ """Validate search query and sanitize unsafe characters for Search V3."""
13
+ if not query or not query.strip():
14
+ raise ValueError("Search query cannot be empty")
15
+ # Strip parentheses, dollar signs, and other punctuation that causes Search V3 to return 0 results.
16
+ # Preserve alphanumerics, underscores, hyphens, commas, and periods.
17
+ cleaned = re.sub(r"[^\w\s\-\,\.]", " ", query)
18
+ sanitized = " ".join(cleaned.split())
19
+ if not sanitized or not sanitized.strip():
20
+ raise ValueError("Search query cannot be empty after sanitization")
21
+ return sanitized
22
+
23
+
24
+
25
+ class MCPPagedResponse(BaseModel):
26
+ """Response model for MCP paged results.
27
+ For more information, see: https://github.com/anthropics/skills/blob/main/skills/mcp-builder/reference/mcp_best_practices.md#pagination
28
+
29
+ Always respect limit parameter
30
+ Return has_more, next_offset, total_count
31
+ Default to 20-50 items
32
+ """
33
+
34
+ count: int = Field(default=0, description="Number of results in the current page")
35
+ total_count: int | None = Field(default=None, description="Total number of results")
36
+ offset: int | None = Field(default=None, description="Offset of the current page")
37
+ has_more: bool | None = Field(
38
+ default=None, description="Whether there are more results"
39
+ )
40
+ next_offset: int | None = Field(default=None, description="Offset of the next page")
41
+
42
+
43
+ class SearchRequest(BaseModel):
44
+ """Request model for data360 search queries."""
45
+
46
+ query: str = Field(
47
+ ..., description="Search query string to find relevant data series"
48
+ )
49
+ limit: int = Field(
50
+ default=10,
51
+ description="Number of results to return (default is 10)",
52
+ ge=1,
53
+ le=50,
54
+ )
55
+ count: bool = Field(
56
+ default=True, description="Whether to include total count in response"
57
+ )
58
+ filter: str | None = Field(
59
+ default=None,
60
+ description="OData filter expression (e.g., \"type eq 'indicator'\")",
61
+ )
62
+ orderby: str | None = Field(
63
+ default=None,
64
+ description='OData orderby expression (e.g., "series_description/name")',
65
+ )
66
+ select: str | None = Field(
67
+ default=None,
68
+ description='OData select expression (e.g., "series_description/idno, series_description/name")',
69
+ )
70
+ offset: int = Field(default=0, description="Offset of the current page")
71
+
72
+ @model_validator(mode="after")
73
+ def validate_query(self) -> "SearchRequest":
74
+ """Validate search query and sanitize unsafe characters."""
75
+ self.query = sanitize_search_query(self.query)
76
+ return self
77
+
78
+ @model_validator(mode="after")
79
+ def set_select_default(self) -> "SearchRequest":
80
+ """Set default select value when None is provided."""
81
+ if self.select is None:
82
+ self.select = "series_description/idno, series_description/name, series_description/database_id, series_description/definition_long"
83
+ return self
84
+
85
+ @model_validator(mode="after")
86
+ def set_filter_default(self) -> "SearchRequest":
87
+ """Set default filter value when None is provided."""
88
+ if self.filter is None:
89
+ # Default to indicator
90
+ self.filter = "type eq 'indicator'"
91
+ return self
92
+
93
+
94
+ class PrimarySourceInfo(BaseModel):
95
+ """A single metadata_link entry identifying a primary source indicator.
96
+
97
+ The search API returns this under ``additional.metadata_link`` when an
98
+ indicator has been curated to point to its authoritative primary source
99
+ (typically in WDI).
100
+ """
101
+
102
+ type: str = Field(..., description="Link type (e.g. 'primary')")
103
+ metadata_id: str = Field(
104
+ ...,
105
+ description="Metadata ID with META_ prefix (e.g. META_WB_WDI_SP_POP_TOTL)",
106
+ )
107
+ database_id: str | None = Field(
108
+ None, description="Primary source database (e.g. WB_WDI)"
109
+ )
110
+ database_name: str | None = Field(
111
+ None, description="Human-readable database name"
112
+ )
113
+
114
+ @property
115
+ def indicator_id(self) -> str:
116
+ """Derive the usable indicator_id by stripping the META_ prefix."""
117
+ if self.metadata_id.startswith(_META_ID_PREFIX):
118
+ return self.metadata_id[len(_META_ID_PREFIX) :]
119
+ return self.metadata_id
120
+
121
+
122
+ class SeriesDescription(BaseModel):
123
+ """Model for series description in search results.
124
+
125
+ Fields available via select_fields in search:
126
+ - idno, name, database_id, definition_long (core)
127
+ - periodicity, time_periods, ref_country, dimensions (extended)
128
+ """
129
+
130
+ idno: str = Field(..., description="Series identifier")
131
+ name: str = Field(..., description="Series name")
132
+ database_id: str = Field(..., description="Database identifier")
133
+ definition_long: str | None = Field(None, description="Series definition")
134
+ periodicity: str | None = Field(
135
+ None, description="Data periodicity (Annual, Monthly, etc)"
136
+ )
137
+ time_periods: list[dict[str, Any]] | None = Field(
138
+ None, description="Time period coverage"
139
+ )
140
+ ref_country: list[dict[str, Any] | str] | None = Field(
141
+ None, description="Countries with data"
142
+ )
143
+ dimensions: list[dict[str, Any]] | None = Field(
144
+ None, description="Available disaggregations"
145
+ )
146
+ metadata_link: list[PrimarySourceInfo] = Field(
147
+ default_factory=list,
148
+ description="Metadata links from the API's additional.metadata_link field.",
149
+ )
150
+ connected_entities: list[dict[str, Any]] | None = Field(
151
+ default=None,
152
+ description="Connected secondary entities for SearchV3 redirect mapping.",
153
+ )
154
+
155
+ @property
156
+ def primary_source(self) -> PrimarySourceInfo | None:
157
+ """Return the first primary-type metadata link, or None."""
158
+ return next((link for link in self.metadata_link if link.type == "primary"), None)
159
+
160
+
161
+ class SearchResponse(MCPPagedResponse):
162
+ """Response model for data360 search results (raw API response)."""
163
+
164
+ items: list[SeriesDescription] | None = Field(
165
+ default=None, description="List of search results containing series information"
166
+ )
167
+ error: str | None = Field(
168
+ default=None, description="Error message if search failed"
169
+ )
170
+
171
+
172
+ class EnrichedIndicator(BaseModel):
173
+ """Model for an enriched indicator in search results.
174
+
175
+ Optimized for LLM consumption with compact, relevant fields.
176
+ """
177
+
178
+ idno: str = Field(..., description="Indicator ID (e.g., WB_GS_NY_GDP_PCAP_KD)")
179
+ database_id: str = Field(..., description="Database ID (e.g., WB_GS)")
180
+ database_name: str | None = Field(
181
+ None,
182
+ description="Human-readable dataset name for the database_id "
183
+ "(e.g., 'Gender Statistics' for WB_GS). "
184
+ "Use this when presenting data to users — never expand database_id by guessing.",
185
+ )
186
+ name: str = Field(..., description="Indicator name")
187
+ truncated_definition: str = Field(
188
+ ..., description="Truncated definition (max 100 chars)"
189
+ )
190
+ periodicity: str | None = Field(
191
+ None, description="Data periodicity (Annual, Monthly)"
192
+ )
193
+ latest_data: str | None = Field(None, description="Most recent year with data")
194
+ time_period_range: str | None = Field(
195
+ None, description="Data availability range (e.g., '1990-2024')"
196
+ )
197
+ covers_country: dict[str, bool] | None = Field(
198
+ None,
199
+ description="Per-country coverage map (e.g. {'KEN': True, 'GHA': False}). "
200
+ "Populated when required_country is provided. None when no country was requested.",
201
+ )
202
+ requested_country: str | None = Field(
203
+ None,
204
+ description="Resolved country code this indicator was evaluated against "
205
+ "(set when per-group countries are used via query_groups; also set for "
206
+ "single-query path when required_country is provided).",
207
+ )
208
+ dimensions: list[str] | None = Field(
209
+ None, description="Available disaggregations (SEX, AGE, URBANISATION)"
210
+ )
211
+ primary_source_of: str | None = Field(
212
+ None,
213
+ description="When this indicator was redirected from a secondary source, "
214
+ "contains the original secondary idno (e.g. 'WB_HNP_SP_POP_TOTL'). "
215
+ "None if the indicator was already the primary source.",
216
+ )
217
+
218
+
219
+ class EnrichedSearchResponse(MCPPagedResponse):
220
+ """Response model for enriched search (LLM-optimized).
221
+
222
+ Returns indicators sorted by country coverage and recency.
223
+ """
224
+
225
+ indicators: list[EnrichedIndicator] = Field(
226
+ default_factory=list, description="Enriched indicators sorted by relevance"
227
+ )
228
+ required_country: str | None = Field(
229
+ None,
230
+ description="Resolved country code(s). Semicolon-separated for multiple countries "
231
+ "(e.g. 'KEN' or 'KEN;GHA').",
232
+ )
233
+ country_names: dict[str, str] | None = Field(
234
+ None, description="Resolved names of requested countries"
235
+ )
236
+ error: str | None = Field(None, description="Error message if search failed")
237
+
238
+
239
+ class QueryGroup(BaseModel):
240
+ """A group of search queries scoped to an optional country.
241
+
242
+ Allows binding multiple search terms to a specific geographic scope
243
+ in a single search() call. Used with the query_groups parameter.
244
+
245
+ Example::
246
+
247
+ QueryGroup(queries=["GDP per capita", "inflation rate"], country="Kenya")
248
+ """
249
+
250
+ queries: list[str] = Field(
251
+ ...,
252
+ description="Search terms for this group (e.g., ['GDP per capita', 'inflation rate']). "
253
+ "At least one non-empty string required.",
254
+ min_length=1,
255
+ )
256
+ country: str | None = Field(
257
+ None,
258
+ description="Country name or 3-letter code for this group (e.g., 'Kenya' or 'KEN'). "
259
+ "If None, no country filtering is applied to indicators in this group.",
260
+ )
261
+
262
+
263
+ class QueryGroupResult(BaseModel):
264
+ """Result group for a single query within a multi-query search.
265
+
266
+ Only returned when result_layout='by_query'.
267
+ """
268
+
269
+ query: str = Field(..., description="The search query that produced these results")
270
+ country_code: str | None = Field(
271
+ None,
272
+ description="Resolved country code for this query group (e.g., 'KEN'). "
273
+ "Set when query_groups is used and a country was specified for this group.",
274
+ )
275
+ indicators: list[EnrichedIndicator] = Field(
276
+ default_factory=list, description="Indicators found for this query"
277
+ )
278
+ count: int = Field(default=0, description="Number of indicators in this group")
279
+ error: str | None = Field(
280
+ None, description="Error message if this sub-query failed"
281
+ )
282
+
283
+
284
+ class MultiQuerySearchResponse(BaseModel):
285
+ """Response for multi-query search (when queries parameter is used).
286
+
287
+ result_layout='merged': indicators contains a flat, deduped list.
288
+ result_layout='by_query': results contains one group per input query.
289
+ dedupe=True with by_query means cross-group dedup — first group to
290
+ claim an indicator keeps it; later groups skip it.
291
+ """
292
+
293
+ indicators: list[EnrichedIndicator] = Field(
294
+ default_factory=list,
295
+ description="Flat indicator list. Populated when result_layout='merged'; "
296
+ "empty when 'by_query' (see results field instead).",
297
+ )
298
+ results: list[QueryGroupResult] | None = Field(
299
+ None,
300
+ description="Per-query result groups (result_layout='by_query')",
301
+ )
302
+ result_layout: Literal["merged", "by_query"] = Field(
303
+ "merged", description="Layout mode used: 'merged' or 'by_query'"
304
+ )
305
+ queries: list[str] = Field(
306
+ default_factory=list, description="The input query strings"
307
+ )
308
+ required_country: str | None = Field(
309
+ None,
310
+ description="Resolved country code(s) used for all sub-queries. "
311
+ "Semicolon-separated for multiple countries (e.g. 'KEN;GHA').",
312
+ )
313
+ country_names: dict[str, str] | None = Field(
314
+ None, description="Resolved names of requested countries"
315
+ )
316
+ total_candidates: int = Field(
317
+ 0,
318
+ description="Total indicators found before dedup (merged) or across all groups (by_query)",
319
+ )
320
+ deduplicated_count: int | None = Field(
321
+ None, description="Number of duplicates removed (merged layout only)"
322
+ )
323
+ error: str | None = Field(
324
+ None, description="Top-level error if the entire multi-query operation failed"
325
+ )
326
+
327
+
328
+ class MetadataRequest(BaseModel):
329
+ """Request model for data 360 metadata retrieval."""
330
+
331
+ indicator_id: str = Field(
332
+ ..., description="Series ID (idno) to retrieve metadata for"
333
+ )
334
+ database_id: str = Field(
335
+ ..., description="Database identifier (e.g., IPC_IPC, WB_GS)"
336
+ )
337
+
338
+ @model_validator(mode="after")
339
+ def validate_ids(self) -> "MetadataRequest":
340
+ """Validate database_id and indicator_id logic."""
341
+ if not self.database_id or not self.database_id.strip():
342
+ raise ValueError("database_id cannot be empty or whitespace-only.")
343
+ if not self.indicator_id or not self.indicator_id.strip():
344
+ raise ValueError("indicator_id cannot be empty or whitespace-only.")
345
+ if self.database_id == self.indicator_id:
346
+ raise ValueError(
347
+ f"Invalid database_id: '{self.database_id}'. It matches indicator_id."
348
+ )
349
+
350
+ return self
351
+
352
+
353
+ class MetadataResponse(BaseModel):
354
+ """Response model for metadata retrieval."""
355
+
356
+ indicator_metadata: dict[str, Any] | None = Field(
357
+ default=None, description="Metadata information for the requested series"
358
+ )
359
+ disaggregation_options: list[dict[str, Any]] = Field(
360
+ default_factory=list,
361
+ description="Available disaggregation options for the indicator",
362
+ )
363
+ error: str | None = Field(
364
+ default=None, description="Error message if metadata retrieval failed"
365
+ )
366
+
367
+
368
+ class IndicatorDataRequest(BaseModel):
369
+ """Request model for retrieving indicator data from Data360 API."""
370
+
371
+ database_id: str = Field(
372
+ ..., description="Unique identifier for the database (e.g., WB_GS)"
373
+ )
374
+ indicator_id: str = Field(
375
+ ..., description="Indicator ID (e.g., WB_GS_NY_GDP_PCAP_KD)"
376
+ )
377
+ disaggregation_filters: dict[str, str | None] | None = Field(
378
+ default=None,
379
+ description=(
380
+ "Per-dimension filters: each value is a string or null (never a JSON array). "
381
+ "Example: {'REF_AREA': 'KEN', 'UNIT_MEASURE': 'KD'}. "
382
+ "Multiple areas: comma-separated ISO codes in REF_AREA (e.g. 'KEN,TZA'); "
383
+ "semicolons in REF_AREA are accepted and normalized to commas. "
384
+ "Use null for a dimension to request all values of that dimension."
385
+ ),
386
+ )
387
+
388
+ @model_validator(mode="after")
389
+ def validate_ids(self) -> "IndicatorDataRequest":
390
+ """Validate database_id and indicator_id logic."""
391
+ if not self.database_id or not self.database_id.strip():
392
+ raise ValueError("database_id cannot be empty or whitespace-only.")
393
+ if not self.indicator_id or not self.indicator_id.strip():
394
+ raise ValueError("indicator_id cannot be empty or whitespace-only.")
395
+ # 1. Check if database_id is suspicious (same as indicator_id)
396
+ if self.database_id == self.indicator_id:
397
+ raise ValueError(
398
+ f"Invalid database_id: '{self.database_id}'. It matches indicator_id. "
399
+ "Database ID should be the short dataset code (e.g., 'WB_GS', 'WB_HCP')."
400
+ )
401
+
402
+ return self
403
+
404
+
405
+ class IndicatorDataResponse(MCPPagedResponse):
406
+ """Response model for indicator data retrieval."""
407
+
408
+ data: list[dict[str, Any]] | None = Field(
409
+ default=None, description="List of indicator data points"
410
+ )
411
+ metadata: dict[str, Any] | None = Field(
412
+ default=None,
413
+ description="Basic metadata for the indicator (e.g., name, definition)",
414
+ )
415
+ error: str | None = Field(
416
+ default=None, description="Error message if data retrieval failed"
417
+ )
418
+ failed_validation: list[str] | None = Field(
419
+ default=None, description="List of filter validation errors"
420
+ )
421
+
422
+
423
+ # ---------------------------------------------------------------------------
424
+ # Data Aggregation Tool Models (Tier 1 — full implementation)
425
+ # ---------------------------------------------------------------------------
426
+
427
+
428
+ class GroupSummary(BaseModel):
429
+ """Summary statistics for a single group in a summarize_data response."""
430
+
431
+ group_key: dict[str, str] = Field(
432
+ ...,
433
+ description="Dimension values defining this group "
434
+ '(e.g. {"ref_area": "KEN"} or {"ref_area": "KEN", "sex": "F"})',
435
+ )
436
+ count: int = Field(..., description="Number of observations in this group")
437
+ latest_value: float | None = Field(None, description="Most recent obs_value")
438
+ latest_year: str | None = Field(None, description="Year of latest_value")
439
+ earliest_value: float | None = Field(None, description="Oldest obs_value in range")
440
+ earliest_year: str | None = Field(None, description="Year of earliest_value")
441
+ min: float | None = Field(None, description="Minimum obs_value")
442
+ max: float | None = Field(None, description="Maximum obs_value")
443
+ mean: float | None = Field(None, description="Arithmetic mean of obs_values")
444
+ median: float | None = Field(None, description="Median obs_value")
445
+ total_change: float | None = Field(
446
+ None, description="latest - earliest (absolute change)"
447
+ )
448
+ pct_change: float | None = Field(
449
+ None,
450
+ description="((latest - earliest) / |earliest|) * 100. "
451
+ "None if earliest is zero or missing.",
452
+ )
453
+ trend_direction: str | None = Field(
454
+ None,
455
+ description="'increasing', 'decreasing', 'stable', or 'volatile'. "
456
+ "Based on linear regression slope and R² over the series.",
457
+ )
458
+ time_range: str | None = Field(
459
+ None, description="Actual data range (e.g. '2005-2023')"
460
+ )
461
+ claim_ids: list[str] = Field(
462
+ default_factory=list,
463
+ description="Source claim_ids from underlying raw observations",
464
+ )
465
+
466
+ def to_compact(self) -> dict[str, Any]:
467
+ """Return a slimmed dict for LLM context.
468
+
469
+ claim_ids are retained here — they are 8-character PCN hashes and the
470
+ UI needs them to render provenance attribution per group. The token cost
471
+ is bounded (one hash per observation per group) and preserves the
472
+ group→claim_ids association that a flat top-level list would lose.
473
+ """
474
+ return {
475
+ "group": self.group_key,
476
+ "n": self.count,
477
+ "latest": {"value": self.latest_value, "year": self.latest_year},
478
+ "earliest": {"value": self.earliest_value, "year": self.earliest_year},
479
+ "range": self.time_range,
480
+ "stats": {
481
+ "min": self.min,
482
+ "max": self.max,
483
+ "mean": self.mean,
484
+ "median": self.median,
485
+ },
486
+ "change": {"abs": self.total_change, "pct": self.pct_change},
487
+ "trend": self.trend_direction,
488
+ "claim_ids": self.claim_ids,
489
+ }
490
+
491
+
492
+ class DataSummaryResponse(BaseModel):
493
+ """Response model for data360_summarize_data."""
494
+
495
+ groups: list[GroupSummary] = Field(
496
+ default_factory=list, description="Per-group summary statistics"
497
+ )
498
+ metadata: dict[str, Any] | None = Field(
499
+ None, description="Indicator metadata (name, definition, database_name)"
500
+ )
501
+ unit_measure: str | None = Field(
502
+ None, description="Unit of measurement for interpreting values"
503
+ )
504
+ error: str | None = Field(
505
+ None, description="Error message if request failed; otherwise None"
506
+ )
507
+ ambiguous_dimensions: list[str] | None = Field(
508
+ None,
509
+ description=(
510
+ "Disaggregation dimensions present in the data with more than one distinct "
511
+ "value that are NOT included in group_by. When non-empty, the per-group "
512
+ "time-series statistics may be computed over mixed disaggregation values "
513
+ "(e.g. SEX=M, F, and _T all collapsed into one group), making trend and "
514
+ "summary stats unreliable. To fix: either add these dimensions to group_by "
515
+ "(e.g. group_by=['ref_area', 'sex']) or pass disaggregation_filters to pin "
516
+ "each dimension to a single value (e.g. {'SEX': '_T'})."
517
+ ),
518
+ )
519
+
520
+ def to_compact(self) -> dict[str, Any]:
521
+ """Return a slimmed dict for LLM context.
522
+
523
+ claim_ids are excluded from each GroupSummary entry — they are PCN
524
+ hashes retained in the full model for provenance traceability.
525
+ """
526
+ return {
527
+ "indicator": self.metadata.get("name") if self.metadata else None,
528
+ "unit": self.unit_measure,
529
+ "ambiguous_dimensions": self.ambiguous_dimensions,
530
+ "groups": [g.to_compact() for g in self.groups],
531
+ "error": self.error,
532
+ }
533
+
534
+
535
+ class RankedCountry(BaseModel):
536
+ """A single country entry in a ranking result."""
537
+
538
+ rank: int = Field(..., description="Ordinal rank (ties share the same rank)")
539
+ ref_area: str = Field(..., description="Country/region code (e.g. 'KEN')")
540
+ country_name: str | None = Field(None, description="Human-readable country name")
541
+ obs_value: float = Field(..., description="The indicator value for ranking year")
542
+ percentile: float | None = Field(
543
+ None,
544
+ description="Percentile position (0-100) within the ranked set",
545
+ )
546
+ claim_id: str | None = Field(
547
+ None, description="Claim ID from the source observation"
548
+ )
549
+
550
+ def to_compact(self) -> dict[str, Any]:
551
+ """Return a slimmed dict for LLM context.
552
+
553
+ claim_id is retained — it is the PCN hash for this observation and the
554
+ UI needs it to render per-entry provenance attribution. Only percentile
555
+ is dropped; it is derivable from rank order and adds no LLM value.
556
+ """
557
+ return {
558
+ "rank": self.rank,
559
+ "code": self.ref_area,
560
+ "country": self.country_name or self.ref_area,
561
+ "value": self.obs_value,
562
+ "claim_id": self.claim_id,
563
+ }
564
+
565
+
566
+ class ExcludedCountry(BaseModel):
567
+ """A country excluded from ranking due to missing data."""
568
+
569
+ ref_area: str = Field(..., description="Country/region code")
570
+ country_name: str | None = Field(None, description="Human-readable country name")
571
+ reason: str = Field(..., description="Why the country was excluded")
572
+
573
+
574
+ class RankingResponse(BaseModel):
575
+ """Response model for data360_rank_countries."""
576
+
577
+ year: str | None = Field(None, description="The year used for ranking")
578
+ year_selection_note: str | None = Field(
579
+ None,
580
+ description="Explains how the ranking year was chosen. "
581
+ "E.g. 'Latest year with broadest coverage (2022, 18/20 countries)' "
582
+ "or 'Most recent year (2023, 12/20 countries)'.",
583
+ )
584
+ order: str = Field(
585
+ "desc", description="'desc' (highest first) or 'asc' (lowest first)"
586
+ )
587
+ total_with_data: int = Field(0, description="Number of countries that had data")
588
+ total_requested: int = Field(0, description="Number of countries attempted")
589
+ universe: str | None = Field(
590
+ None,
591
+ description=(
592
+ "'explicit' when country_group or country_codes was used; "
593
+ "'all_member_economies' when ranking used full geographic fetch with "
594
+ "member-economy row filtering."
595
+ ),
596
+ )
597
+ universe_size: int | None = Field(
598
+ None,
599
+ description=(
600
+ "For explicit scope: same as total_requested. For all_member_economies: "
601
+ "count of known FMR leaf economies in the ranking universe."
602
+ ),
603
+ )
604
+ rankings: list[RankedCountry] = Field(
605
+ default_factory=list, description="Ranked list of countries"
606
+ )
607
+ excluded: list[ExcludedCountry] = Field(
608
+ default_factory=list, description="Countries with no data for ranking year"
609
+ )
610
+ metadata: dict[str, Any] | None = Field(None, description="Indicator metadata")
611
+ unit_measure: str | None = Field(None, description="Unit of measurement")
612
+ error: str | None = Field(None, description="Error message if request failed")
613
+
614
+ def to_compact(self) -> dict[str, Any]:
615
+ """Return a slimmed dict for LLM context.
616
+
617
+ Key reductions vs. the full model:
618
+ - rankings: claim_id and percentile dropped from each entry (PCN hash
619
+ retained in the full RankedCountry model).
620
+ - excluded: capped at 5 sample entries; full count is in excluded_count.
621
+ This prevents 30+ excluded entries from flooding the context when
622
+ ranking a large group like SSF (48 countries).
623
+ """
624
+ return {
625
+ "year": self.year,
626
+ "year_selection_note": self.year_selection_note,
627
+ "order": self.order,
628
+ "counts": {
629
+ "with_data": self.total_with_data,
630
+ "requested": self.total_requested,
631
+ },
632
+ "unit": self.unit_measure,
633
+ "indicator": self.metadata.get("name") if self.metadata else None,
634
+ "rankings": [r.to_compact() for r in self.rankings],
635
+ "excluded_count": len(self.excluded),
636
+ "excluded_sample": [
637
+ {"code": e.ref_area, "name": e.country_name}
638
+ for e in self.excluded[:5]
639
+ ],
640
+ "error": self.error,
641
+ }
642
+
643
+
644
+ class ComparisonSnapshot(BaseModel):
645
+ """Single-year comparison snapshot across countries."""
646
+
647
+ year: str = Field(..., description="The comparison year")
648
+ year_selection_note: str | None = Field(
649
+ None,
650
+ description="Explains how the comparison year was chosen. "
651
+ "E.g. 'User-specified year: 2022' or 'Latest year with data for all compared countries: 2023'.",
652
+ )
653
+ rankings: list[RankedCountry] = Field(
654
+ default_factory=list,
655
+ description="Countries sorted by obs_value with rank and gap_to_leader",
656
+ )
657
+ spread: dict[str, float | None] = Field(
658
+ default_factory=dict,
659
+ description="Spread statistics: min, max, range, coefficient_of_variation",
660
+ )
661
+
662
+ def to_compact(self) -> dict[str, Any]:
663
+ """Return a slimmed dict for LLM context.
664
+
665
+ Delegates to RankedCountry.to_compact() for each ranked entry, which
666
+ retains claim_id (PCN hash) and drops percentile. claim_id is preserved
667
+ here so the UI can render per-country provenance attribution in the
668
+ snapshot table.
669
+ """
670
+ return {
671
+ "year": self.year,
672
+ "year_selection_note": self.year_selection_note,
673
+ "rankings": [r.to_compact() for r in self.rankings],
674
+ "spread": self.spread,
675
+ }
676
+
677
+
678
+ class ComparisonTimeSeries(BaseModel):
679
+ """Time-series comparison across countries."""
680
+
681
+ aligned_years: list[str] = Field(
682
+ default_factory=list,
683
+ description="Years where ALL compared countries have data",
684
+ )
685
+ series: dict[str, list[dict[str, Any]]] = Field(
686
+ default_factory=dict,
687
+ description="Per-country time series: {ref_area: [{time_period, obs_value, claim_id}]}",
688
+ )
689
+ convergence: str | None = Field(
690
+ None,
691
+ description="'converging', 'diverging', or 'parallel'. "
692
+ "Based on coefficient of variation trend across aligned years.",
693
+ )
694
+ cagr: dict[str, float | None] = Field(
695
+ default_factory=dict,
696
+ description="Compound annual growth rate per country over aligned period",
697
+ )
698
+
699
+ def to_compact(self) -> dict[str, Any]:
700
+ """Return a slimmed dict for LLM context.
701
+
702
+ The per-year ``series`` dict is retained but restructured: each data
703
+ point is encoded as a positional array ``[time_period, obs_value, claim_id]``
704
+ instead of a named dict. This reduces per-point overhead from ~55 chars
705
+ to ~24 chars (~56% reduction) while preserving the year→value→PCN
706
+ association the UI needs for provenance attribution.
707
+
708
+ A ``series_schema`` field documents the array positions so the UI
709
+ decoder does not need to hard-code positional assumptions.
710
+
711
+ ``aligned_years`` list is replaced by ``year_range`` + ``n_aligned_years``
712
+ since the LLM only needs to know the span, not the individual years.
713
+ """
714
+ year_range = (
715
+ f"{self.aligned_years[0]}-{self.aligned_years[-1]}"
716
+ if self.aligned_years
717
+ else None
718
+ )
719
+ compact_series = {
720
+ country: [
721
+ [pt["time_period"], pt["obs_value"], pt.get("claim_id")]
722
+ for pt in points
723
+ ]
724
+ for country, points in self.series.items()
725
+ }
726
+ return {
727
+ "year_range": year_range,
728
+ "n_aligned_years": len(self.aligned_years),
729
+ "convergence": self.convergence,
730
+ "cagr": self.cagr,
731
+ "series_schema": ["time_period", "obs_value", "claim_id"],
732
+ "series": compact_series,
733
+ }
734
+
735
+
736
+ class CountryComparisonResponse(BaseModel):
737
+ """Response model for data360_compare_countries."""
738
+
739
+ snapshot: ComparisonSnapshot | None = Field(
740
+ None, description="Single-year ranked comparison"
741
+ )
742
+ time_series: ComparisonTimeSeries | None = Field(
743
+ None,
744
+ description="Aligned time-series comparison (when include_time_series=True)",
745
+ )
746
+ metadata: dict[str, Any] | None = Field(None, description="Indicator metadata")
747
+ unit_measure: str | None = Field(None, description="Unit of measurement")
748
+ error: str | None = Field(None, description="Error message if request failed")
749
+ country_names: dict[str, str] | None = Field(None, description="Resolved names of compared countries")
750
+
751
+ def to_compact(self) -> dict[str, Any]:
752
+ """Return a slimmed dict for LLM context.
753
+
754
+ Delegates to ComparisonSnapshot.to_compact() and
755
+ ComparisonTimeSeries.to_compact(), which strip claim_ids (PCN hashes)
756
+ and per-year series data respectively.
757
+ """
758
+ return {
759
+ "indicator": self.metadata.get("name") if self.metadata else None,
760
+ "unit": self.unit_measure,
761
+ "snapshot": self.snapshot.to_compact() if self.snapshot else None,
762
+ "time_series": self.time_series.to_compact() if self.time_series else None,
763
+ "country_names": self.country_names,
764
+ "error": self.error,
765
+ }
766
+
767
+
768
+ # ---------------------------------------------------------------------------
769
+ # Data Aggregation Tool Models (Tier 2 — stubs for future implementation)
770
+ # ---------------------------------------------------------------------------
771
+
772
+
773
+ class DerivedDataResponse(BaseModel):
774
+ """Response model for data360_compute_derived (stub — not yet implemented).
775
+
776
+ Will contain derived/transformed values (growth rates, CAGR, moving averages,
777
+ index rebasing) computed from raw indicator data.
778
+ """
779
+
780
+ computation: str | None = Field(None, description="Computation type applied")
781
+ data: list[dict[str, Any]] = Field(
782
+ default_factory=list, description="Computed values"
783
+ )
784
+ summary: str | None = Field(None, description="Human-readable one-line summary")
785
+ metadata: dict[str, Any] | None = Field(None, description="Indicator metadata")
786
+ unit_measure: str | None = Field(None, description="Original unit")
787
+ derived_unit: str | None = Field(
788
+ None, description="Unit for derived values (e.g. '%' for growth_rate)"
789
+ )
790
+ error: str | None = Field(None, description="Error message if request failed")
791
+
792
+
793
+ class PivotTableResponse(BaseModel):
794
+ """Response model for data360_pivot_table (stub — not yet implemented).
795
+
796
+ Will contain a cross-tabulation of multiple indicators and/or countries,
797
+ organized as a structured table with row/column dimensions.
798
+ """
799
+
800
+ table: list[dict[str, Any]] = Field(default_factory=list, description="Table rows")
801
+ column_metadata: list[dict[str, Any]] = Field(
802
+ default_factory=list, description="Per-column metadata"
803
+ )
804
+ claim_map: dict[str, str] = Field(
805
+ default_factory=dict,
806
+ description="Mapping of cell keys to source claim_ids",
807
+ )
808
+ missing_cells: list[dict[str, str]] = Field(
809
+ default_factory=list,
810
+ description="Cells with no data: [{row, column, reason}]",
811
+ )
812
+ error: str | None = Field(None, description="Error message if request failed")
813
+
814
+
815
+ class DiagnosticIndicatorSummary(BaseModel):
816
+ """Per-indicator summary within a diagnostic summary response (stub)."""
817
+
818
+ indicator_id: str = Field(..., description="Indicator ID")
819
+ database_id: str = Field(..., description="Database ID")
820
+ name: str = Field(..., description="Indicator name")
821
+ latest_value: float | None = Field(None, description="Most recent value")
822
+ latest_year: str | None = Field(None, description="Year of latest value")
823
+ trend_direction: str | None = Field(
824
+ None, description="'increasing', 'decreasing', 'stable', 'volatile'"
825
+ )
826
+ pct_change: float | None = Field(None, description="Percent change over period")
827
+ time_range: str | None = Field(None, description="Actual data range")
828
+ claim_ids: list[str] = Field(default_factory=list, description="Source claim_ids")
829
+ coverage_note: str | None = Field(None, description="Gaps or caveats")
830
+
831
+
832
+ class DiagnosticSummaryResponse(BaseModel):
833
+ """Response model for data360_diagnostic_summary (stub — not yet implemented).
834
+
835
+ Will contain a multi-indicator diagnostic summary for a topic and country,
836
+ with per-indicator trend analysis and cross-indicator notes.
837
+ """
838
+
839
+ topic: str | None = Field(None, description="Diagnostic category used")
840
+ country_code: str | None = Field(None, description="Resolved country code(s)")
841
+ indicators: list[DiagnosticIndicatorSummary] = Field(
842
+ default_factory=list, description="Per-indicator summaries"
843
+ )
844
+ gaps: list[str] = Field(
845
+ default_factory=list,
846
+ description="Topics searched but no indicator found",
847
+ )
848
+ metadata_sources: list[dict[str, str]] = Field(
849
+ default_factory=list,
850
+ description="List of {database_id, database_name} used",
851
+ )
852
+ error: str | None = Field(None, description="Error message if request failed")
853
+
854
+
855
+ class DiscoveredIndicator(BaseModel):
856
+ """Model for a discovered and validated indicator."""
857
+
858
+ indicator_id: str = Field(..., description="Indicator ID")
859
+ database_id: str = Field(..., description="Database identifier")
860
+ name: str = Field(..., description="Indicator name")
861
+ truncated_definition: str = Field(
862
+ ..., description="Short definition (max 100 chars)"
863
+ )
864
+ has_country: bool = Field(
865
+ ..., description="Whether data exists for the requested country"
866
+ )
867
+ country_code: str | None = Field(
868
+ default=None, description="Country code used for validation"
869
+ )
870
+ available_dimensions: list[str] = Field(
871
+ default_factory=list, description="List of available disaggregation dimensions"
872
+ )
873
+ available_frequencies: list[str] = Field(
874
+ default_factory=list, description="List of available frequencies"
875
+ )
876
+ periodicity: str | None = Field(
877
+ default=None, description="Periodicity of the indicator"
878
+ )
879
+ has_required_dimensions: bool = Field(
880
+ default=True, description="Whether the indicator has all required dimensions"
881
+ )
882
+ time_range: dict[str, str | None] | None = Field(
883
+ default=None, description="Start and end years of data availability"
884
+ )
885
+ error: str | None = Field(
886
+ default=None, description="Error message if validation failed"
887
+ )
888
+
889
+
890
+ class DiscoveryResult(BaseModel):
891
+ """Result of indicator discovery process."""
892
+
893
+ indicators: list[DiscoveredIndicator] = Field(
894
+ default_factory=list, description="List of discovered and validated indicators"
895
+ )
896
+ error: str | None = Field(
897
+ default=None, description="Error message if discovery failed entirely"
898
+ )
899
+
900
+
901
+ class DatasetSearchRequest(BaseModel):
902
+ """Request model for dataset search queries. Includes V3 special character sanitization."""
903
+
904
+ query: str = Field(
905
+ ..., description="Search query string to find relevant datasets"
906
+ )
907
+ limit: int = Field(
908
+ default=10,
909
+ description="Number of results to return (default is 10)",
910
+ ge=1,
911
+ le=50,
912
+ )
913
+ offset: int = Field(default=0, description="Offset of the current page")
914
+
915
+ @model_validator(mode="after")
916
+ def validate_query(self) -> "DatasetSearchRequest":
917
+ """Validate search query and sanitize unsafe characters."""
918
+ self.query = sanitize_search_query(self.query)
919
+ return self
920
+
921
+
922
+ class DatasetDescription(BaseModel):
923
+ """Model for dataset description in search results."""
924
+
925
+ idno: str = Field(..., description="Dataset identifier")
926
+ name: str = Field(..., description="Dataset name")
927
+ description: str | None = Field(None, description="Dataset description")
928
+ data_classification: str | None = Field(None, description="Data classification (e.g. public)")
929
+ data_last_updated: str | None = Field(None, description="Last updated timestamp")
930
+ economies_count: int | None = Field(None, description="Number of economies covered")
931
+ time_period: dict[str, Any] | None = Field(None, description="Time period range covered")
932
+
933
+
934
+ class DatasetSearchResponse(MCPPagedResponse):
935
+ """Response model for data360 dataset search results."""
936
+
937
+ items: list[DatasetDescription] = Field(
938
+ default_factory=list, description="List of search results containing dataset information"
939
+ )
940
+ error: str | None = Field(
941
+ default=None, description="Error message if search failed"
942
+ )
src/data360/otel_setup.py ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """OpenTelemetry setup: Azure Monitor (deployed), OTLP/console (local), httpx outbound spans."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ from typing import TYPE_CHECKING, Any, cast
8
+ from urllib.parse import urlparse
9
+
10
+ if TYPE_CHECKING:
11
+ from data360.config import MCPServerSettings
12
+
13
+ _logger = logging.getLogger(__name__)
14
+
15
+ _HTTPX_INSTRUMENTED = False
16
+ _MIN_TRANSPORT_PARTS_FOR_URL = 2
17
+
18
+
19
+ def data360_operation_from_url(url: str | None) -> str: # noqa: PLR0911
20
+ """Derive a stable operation label for spans from the outbound request URL (no query logged)."""
21
+ if not url:
22
+ return "http"
23
+ try:
24
+ path = urlparse(url).path.lower()
25
+ except Exception:
26
+ return "http"
27
+ if "searchv2" in path:
28
+ return "search"
29
+ if "metadata" in path:
30
+ return "metadata"
31
+ if "disaggregation" in path:
32
+ return "disaggregation"
33
+ # Avoid matching .../metadata/... "data" substring before generic data path
34
+ if path.rstrip("/").endswith("/data") or "/data360/data" in path:
35
+ return "data"
36
+ if "codelist" in path:
37
+ return "codelist"
38
+ if path.rstrip("/").endswith("/indicators"):
39
+ return "indicators"
40
+ if "chart" in path or "vega" in path:
41
+ return "charts"
42
+ return "http"
43
+
44
+
45
+ def _httpx_request_hook(span, request: object) -> None:
46
+ """Attach Data360-specific attributes; never log bodies or full URLs with secrets."""
47
+ try:
48
+ if span is None:
49
+ return
50
+ is_recording = getattr(span, "is_recording", None)
51
+ if callable(is_recording) and not is_recording():
52
+ return
53
+ url_str: str | None = None
54
+ if isinstance(request, tuple) and len(request) >= _MIN_TRANSPORT_PARTS_FOR_URL:
55
+ url_str = str(request[1])
56
+ else:
57
+ req_any = cast("Any", request)
58
+ url_attr = getattr(req_any, "url", None)
59
+ if url_attr is not None:
60
+ url_str = str(url_attr)
61
+ op = data360_operation_from_url(url_str)
62
+ span.set_attribute("data360.operation", op)
63
+ if url_str:
64
+ try:
65
+ parsed = urlparse(url_str)
66
+ netloc = parsed.netloc.split("@")[-1]
67
+ span.set_attribute("server.address", netloc.split(":")[0])
68
+ except Exception:
69
+ pass
70
+ except Exception:
71
+ pass
72
+
73
+
74
+ def _httpx_response_hook(span, request: object, response: object) -> None:
75
+ """Record outcome; avoid logging bodies."""
76
+ try:
77
+ if span is None:
78
+ return
79
+ is_recording = getattr(span, "is_recording", None)
80
+ if callable(is_recording) and not is_recording():
81
+ return
82
+ status = getattr(response, "status_code", None)
83
+ if status is not None:
84
+ span.set_attribute("http.response.status_code", int(status))
85
+ except Exception:
86
+ pass
87
+
88
+
89
+ def configure_open_telemetry_for_server(settings: MCPServerSettings) -> None:
90
+ """Configure trace export: Azure Monitor when deployed; OTLP or console when local."""
91
+ connection_string = settings.azure_connection_string or os.environ.get(
92
+ "APPLICATIONINSIGHTS_CONNECTION_STRING"
93
+ )
94
+ env = (settings.env or "").lower()
95
+
96
+ if env != "local" and connection_string:
97
+ try:
98
+ from azure.monitor.opentelemetry import ( # type: ignore[import-untyped] # noqa: PLC0415
99
+ configure_azure_monitor,
100
+ )
101
+
102
+ configure_azure_monitor(connection_string=connection_string)
103
+ _logger.info("Azure Monitor OpenTelemetry configured.")
104
+ except ImportError:
105
+ _logger.warning("azure-monitor-opentelemetry not available.")
106
+ return
107
+
108
+ # Local / no Azure: optional OTLP (Jaeger, collector) or console exporter
109
+ endpoint = os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT") or os.environ.get(
110
+ "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT"
111
+ )
112
+ console = os.environ.get("MCP_OTEL_CONSOLE", "").lower() in ("1", "true", "yes")
113
+
114
+ if not endpoint and not console:
115
+ _logger.debug(
116
+ "Local telemetry: no OTLP endpoint and MCP_OTEL_CONSOLE unset; "
117
+ "traces use noop unless OTEL_* env configures a provider elsewhere."
118
+ )
119
+ return
120
+
121
+ from opentelemetry import trace # noqa: PLC0415
122
+ from opentelemetry.sdk.resources import SERVICE_NAME, Resource # noqa: PLC0415
123
+ from opentelemetry.sdk.trace import TracerProvider # noqa: PLC0415
124
+ from opentelemetry.sdk.trace.export import BatchSpanProcessor # noqa: PLC0415
125
+
126
+ service_name = os.environ.get("OTEL_SERVICE_NAME", "data360-mcp")
127
+ resource = Resource.create({SERVICE_NAME: service_name})
128
+ provider = TracerProvider(resource=resource)
129
+
130
+ if console:
131
+ from opentelemetry.sdk.trace.export import ConsoleSpanExporter # noqa: PLC0415
132
+
133
+ provider.add_span_processor(BatchSpanProcessor(ConsoleSpanExporter()))
134
+ _logger.info("OpenTelemetry console span export enabled (MCP_OTEL_CONSOLE).")
135
+
136
+ if endpoint:
137
+ try:
138
+ use_http = endpoint.startswith(("http://", "https://"))
139
+ proto = os.environ.get("OTEL_EXPORTER_OTLP_PROTOCOL", "").lower()
140
+ if proto in ("http/protobuf", "http/json"):
141
+ use_http = True
142
+ if use_http:
143
+ from opentelemetry.exporter.otlp.proto.http.trace_exporter import ( # noqa: PLC0415
144
+ OTLPSpanExporter,
145
+ )
146
+ else:
147
+ from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import ( # noqa: PLC0415
148
+ OTLPSpanExporter,
149
+ )
150
+
151
+ exporter = OTLPSpanExporter()
152
+ provider.add_span_processor(BatchSpanProcessor(exporter))
153
+ _logger.info(
154
+ "OpenTelemetry OTLP trace export enabled (endpoint from OTEL_EXPORTER_OTLP_*)."
155
+ )
156
+ except Exception:
157
+ _logger.exception("Failed to configure OTLP trace exporter; traces may be incomplete.")
158
+
159
+ trace.set_tracer_provider(provider)
160
+
161
+
162
+ def instrument_httpx_outbound() -> None:
163
+ """Patch httpx so all AsyncClient/Client requests emit dependency spans."""
164
+ global _HTTPX_INSTRUMENTED # noqa: PLW0603
165
+ if _HTTPX_INSTRUMENTED:
166
+ return
167
+ try:
168
+ from opentelemetry.instrumentation.httpx import ( # noqa: PLC0415
169
+ HTTPXClientInstrumentor,
170
+ )
171
+
172
+ HTTPXClientInstrumentor().instrument(
173
+ request_hook=_httpx_request_hook,
174
+ response_hook=_httpx_response_hook,
175
+ )
176
+ _HTTPX_INSTRUMENTED = True
177
+ _logger.info("HTTPX OpenTelemetry instrumentation enabled.")
178
+ except ImportError:
179
+ _logger.warning("opentelemetry-instrumentation-httpx not installed.")
src/data360/providers.py ADDED
@@ -0,0 +1,1512 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Providers for Data360 codelist and reference area data."""
2
+
3
+ import asyncio
4
+ import json
5
+ import logging
6
+ import os
7
+ import random
8
+ import time
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ import httpx
13
+
14
+ from data360.config import get_data360_settings
15
+ from data360.http_client import get_shared_httpx_client
16
+
17
+ data360_config = get_data360_settings()
18
+
19
+ _logger = logging.getLogger(__name__)
20
+
21
+
22
+ class DatabaseManager:
23
+ """Manages the list of databases dynamically fetched from the Data360 API.
24
+
25
+ On startup, loads a complete JSON fallback so the mapping is never empty.
26
+ All subsequent live refreshes happen in a background asyncio task, ensuring
27
+ that get_mapping() is always a sub-millisecond in-memory dict lookup with
28
+ no I/O or network cost on the hot path.
29
+ """
30
+
31
+ def __init__(self, ttl_seconds: float = 86400.0):
32
+ self._cache: dict[str, str] = {}
33
+ self._last_fetched: float = 0.0
34
+ self._ttl = ttl_seconds
35
+ self._bg_task: asyncio.Task | None = None
36
+ self._load_fallback()
37
+
38
+ def _load_fallback(self):
39
+ """Load the bundled databases.json at startup to guarantee a non-empty cache."""
40
+ fallback_path = Path(__file__).parent / "databases.json"
41
+ try:
42
+ with open(fallback_path, encoding="utf-8") as f:
43
+ self._cache = json.load(f)
44
+ _logger.info("Loaded %d databases from fallback JSON.", len(self._cache))
45
+ except Exception as e:
46
+ _logger.error("Failed to load fallback database mapping: %s", e)
47
+ self._cache = {}
48
+
49
+ # ------------------------------------------------------------------
50
+ # Public API — always a pure in-memory read, never blocks on network
51
+ # ------------------------------------------------------------------
52
+
53
+ async def get_mapping(self) -> dict[str, str]:
54
+ """Return the cached database_id→name mapping.
55
+
56
+ Always returns immediately from in-memory cache. A background task
57
+ is responsible for keeping the cache fresh via periodic API fetches.
58
+ """
59
+ self._ensure_background_sync()
60
+ return self._cache
61
+
62
+ def resolve_database_ids(self, query: str | None, strict: bool = False) -> list[str]:
63
+ """Resolve database search terms (semicolon separated) to database IDs from the cache.
64
+
65
+ Matches by exact ID, exact acronym suffix, exact name, or substring (if not strict).
66
+ Raises ValueError if any term cannot be resolved.
67
+ """
68
+ if not query:
69
+ return []
70
+ tokens = [t.strip() for t in query.split(';') if t.strip()]
71
+ resolved_ids = []
72
+ for token in tokens:
73
+ resolved = self._resolve_single_database_id(token, strict=strict)
74
+ if resolved:
75
+ if resolved not in resolved_ids:
76
+ resolved_ids.append(resolved)
77
+ else:
78
+ raise ValueError(f"Database '{token}' could not be resolved.")
79
+ return resolved_ids
80
+
81
+ def _resolve_single_database_id(self, query: str, strict: bool = False) -> str | None:
82
+ query_lower = query.lower().strip()
83
+
84
+ # 1. Exact match on database ID (key)
85
+ for db_id in self._cache:
86
+ if query_lower == db_id.lower():
87
+ return db_id
88
+
89
+ # 2. Exact match on database ID acronym suffix (e.g. "wdi" matches "wb_wdi")
90
+ for db_id in self._cache:
91
+ parts = db_id.split('_')
92
+ if len(parts) > 1 and query_lower == parts[-1].lower():
93
+ return db_id
94
+
95
+ # 3. Exact match on database name (value)
96
+ for db_id, name in self._cache.items():
97
+ if query_lower == name.lower():
98
+ return db_id
99
+
100
+ if not strict:
101
+ # 4. Substring match on database ID
102
+ for db_id in self._cache:
103
+ if query_lower in db_id.lower():
104
+ return db_id
105
+
106
+ # 5. Substring match on database name
107
+ for db_id, name in self._cache.items():
108
+ if query_lower in name.lower():
109
+ return db_id
110
+
111
+ # 6. Fuzzy prefix/character match on ID and name as fallback
112
+ best_match = None
113
+ best_score = 0.70
114
+ for db_id, name in self._cache.items():
115
+ score_id = self._calculate_similarity(query_lower, db_id.lower())
116
+ score_name = self._calculate_similarity(query_lower, name.lower())
117
+ max_score = max(score_id, score_name)
118
+ if max_score > best_score:
119
+ best_score = max_score
120
+ best_match = db_id
121
+ if best_match:
122
+ return best_match
123
+
124
+ return None
125
+
126
+ def _calculate_similarity(self, s1: str, s2: str) -> float:
127
+ """Calculate similarity ratio between two strings."""
128
+ if not s1 or not s2:
129
+ return 0.0
130
+
131
+ if len(s1) <= 3:
132
+ if s1 in s2 or s2.startswith(s1):
133
+ return 0.8
134
+ return 0.0
135
+
136
+ len1, len2 = len(s1), len(s2)
137
+ if abs(len1 - len2) > max(len1, len2) * 0.5:
138
+ return 0.0
139
+
140
+ s1_chars = set(s1)
141
+ s2_chars = set(s2)
142
+ common = len(s1_chars & s2_chars)
143
+ total = len(s1_chars | s2_chars)
144
+ char_similarity = common / total if total > 0 else 0
145
+
146
+ prefix_len = 0
147
+ for c1, c2 in zip(s1, s2):
148
+ if c1 == c2:
149
+ prefix_len += 1
150
+ else:
151
+ break
152
+ prefix_ratio = prefix_len / min(len1, len2)
153
+
154
+ return (char_similarity * 0.4) + (prefix_ratio * 0.6)
155
+
156
+ def resolve_database_id(self, query: str | None) -> str | None:
157
+ """Resolve a database search term to a single database ID from the cache.
158
+
159
+ Deprecated: use resolve_database_ids instead.
160
+ """
161
+ try:
162
+ ids = self.resolve_database_ids(query)
163
+ return ids[0] if ids else None
164
+ except ValueError:
165
+ return None
166
+
167
+ # ------------------------------------------------------------------
168
+ # Background sync machinery
169
+ # ------------------------------------------------------------------
170
+
171
+ def _ensure_background_sync(self) -> None:
172
+ """Spawn the background refresh loop if it is not already running."""
173
+ if os.environ.get("PYTEST_RUNNING"):
174
+ return
175
+ if self._bg_task is None or self._bg_task.done():
176
+ self._bg_task = asyncio.create_task(self._background_sync_loop())
177
+
178
+ async def _background_sync_loop(self) -> None:
179
+ """Run forever, refreshing the cache from the API when the TTL expires."""
180
+ while True:
181
+ elapsed = time.monotonic() - self._last_fetched
182
+ if elapsed >= self._ttl:
183
+ try:
184
+ mapping = await self._fetch_all()
185
+ if mapping:
186
+ self._cache = mapping
187
+ _logger.info(
188
+ "Background refresh: updated %d databases.", len(mapping)
189
+ )
190
+ except Exception as e:
191
+ _logger.error("Background database fetch failed: %s", e)
192
+ # Keep existing cache; retry after the next full TTL cycle.
193
+ finally:
194
+ self._last_fetched = time.monotonic()
195
+
196
+ sleep_for = max(0.0, self._ttl - (time.monotonic() - self._last_fetched))
197
+ await asyncio.sleep(sleep_for)
198
+
199
+ async def _fetch_all(self) -> dict[str, str]:
200
+ """Fetch all datasets from the search endpoint using pagination."""
201
+ url = data360_config.search_url or f"{data360_config.api_url}/searchv2"
202
+ mapping: dict[str, str] = {}
203
+ skip = 0
204
+ limit = 50
205
+
206
+ client = get_shared_httpx_client()
207
+ while True:
208
+ items = None
209
+ last_error = None
210
+ for attempt in range(3):
211
+ try:
212
+ response = await client.post(
213
+ url,
214
+ headers={
215
+ "accept": "*/*",
216
+ "Content-Type": "application/json",
217
+ },
218
+ json={
219
+ "filter": "type eq 'dataset' and (is_active ne false or is_active eq null)",
220
+ "orderby": "series_description/name",
221
+ "select": "series_description/database_id, series_description/name",
222
+ "skip": skip,
223
+ "top": limit,
224
+ },
225
+ )
226
+ response.raise_for_status()
227
+ data = response.json()
228
+ items = data.get("value", [])
229
+ break # Success
230
+ except Exception as e:
231
+ last_error = e
232
+ _logger.warning(
233
+ "Fetch attempt %d failed for skip=%d: %s",
234
+ attempt + 1,
235
+ skip,
236
+ e,
237
+ )
238
+ if attempt < 2:
239
+ await asyncio.sleep(2**attempt) # Backoff: 1s, 2s
240
+
241
+ if items is None:
242
+ # All attempts failed
243
+ raise (
244
+ last_error
245
+ if last_error
246
+ else Exception("Unknown error during fetch")
247
+ )
248
+
249
+ if not items:
250
+ break
251
+
252
+ for x in items:
253
+ sd = x.get("series_description", {})
254
+ db_id = sd.get("database_id")
255
+ db_name = sd.get("name")
256
+ if db_id and db_name and db_id not in mapping:
257
+ mapping[db_id] = db_name
258
+
259
+ skip += limit
260
+
261
+ return mapping
262
+
263
+
264
+ # Global instance
265
+ _database_manager: DatabaseManager | None = None
266
+
267
+
268
+ def get_database_manager() -> DatabaseManager:
269
+ """Get the global DatabaseManager instance."""
270
+ global _database_manager
271
+ if _database_manager is None:
272
+ _database_manager = DatabaseManager()
273
+ return _database_manager
274
+
275
+
276
+ async def get_database_mapping() -> dict[str, str]:
277
+ """Get mapping of database IDs to their actual names (e.g., {'WB_GS': 'Gender Statistics'})."""
278
+ return await get_database_manager().get_mapping()
279
+
280
+
281
+ # ---------------------------------------------------------------------------
282
+ # Natural-language aliases for group codes.
283
+ #
284
+ # This map is intentionally minimal: it contains ONLY phrases where the
285
+ # existing fuzzy/substring search in CodelistManager._search_global would
286
+ # genuinely fail to return the correct code. The LLM and the fuzzy layer
287
+ # already handle most natural-language variations (e.g. "South Asia",
288
+ # "low income countries", "sub-saharan africa") without help from this map.
289
+ #
290
+ # Entries kept here fall into four categories:
291
+ # 1. "and" vs "&" variants — substring check fails because the official
292
+ # codelist uses "&" but users type "and".
293
+ # 2. Abbreviations not present in the official name (e.g. "mena").
294
+ # 3. Common shorthands whose words don't appear in the official name
295
+ # (e.g. "fragile states" vs "Fragile and conflict affected situations").
296
+ # 4. One semantic synonym where the variant word is absent from the
297
+ # official name ("lower income" vs "Low income").
298
+ #
299
+ # All values are FMR group codes that must exist in ref_area_groups.json.
300
+ # Enforced by TestDataIntegrity.test_all_alias_values_exist_in_shipped_data.
301
+ #
302
+ # Future path if coverage proves insufficient: build-time sentence-transformer
303
+ # embeddings over all group names, cosine-similarity fallback after alias miss.
304
+ # ---------------------------------------------------------------------------
305
+ _GROUP_ALIASES: dict[str, str] = {
306
+ # Category 1: "and" vs "&" — fuzzy fails because "&" != "and"
307
+ "east asia and pacific": "EAS", # official: "East Asia & Pacific"
308
+ "europe and central asia": "ECS", # official: "Europe & Central Asia"
309
+ "latin america and the caribbean": "LCN", # official: "Latin America & Caribbean"
310
+ "middle east and north africa": "MEA", # official: "Middle East, North Africa, Afghanistan & Pakistan"
311
+ "middle east & north africa": "MEA", # same but omits "Afghanistan & Pakistan"
312
+ "low and middle income": "LMY", # official: "Low & middle income"
313
+ # Category 2: abbreviation not in official name
314
+ "mena": "MEA", # common abbreviation for the region
315
+ # Category 3: shorthands whose words don't appear in the official name
316
+ "fragile states": "FCS", # official: "Fragile and conflict affected situations"
317
+ "small island states": "SST", # official: "Small states"
318
+ "eastern africa": "AFE", # official: "Africa Eastern and Southern"
319
+ "western africa": "AFW", # official: "Africa Western and Central"
320
+ # Category 4: semantic synonym — "lower" absent from "Low income"
321
+ "lower income": "LIC",
322
+ }
323
+
324
+
325
+ # FMR endpoint templates (version segment is optional; omitting returns latest).
326
+ _FMR_HIERARCHY_URL = (
327
+ "https://fmr.worldbank.org/FMR/sdmx/v2/structure/hierarchy/WB/H_REF_AREA_GROUPS/{version}"
328
+ "?format=sdmx-json"
329
+ )
330
+ _FMR_CODELIST_URL = (
331
+ "https://fmr.worldbank.org/FMR/sdmx/v2/structure/codelist/WB/CL_REF_GROUPINGS/{version}"
332
+ "?format=sdmx-json"
333
+ )
334
+ # Group types whose entries are all leaf countries — not expandable groups.
335
+ _LEAF_ONLY_TYPES: frozenset[str] = frozenset({"WLD", "_T"})
336
+
337
+
338
+ class GroupHierarchyManager:
339
+ """Manages REF_AREA group-to-country mappings from the FMR hierarchy.
340
+
341
+ Startup behaviour (zero-latency):
342
+ Loads the bundled src/data360/ref_area_groups.json immediately on first
343
+ access so the tool is usable without any network I/O on the hot path.
344
+ Re-generate that file with::
345
+
346
+ uv run python scripts/build_ref_area_groups.py
347
+
348
+ Background sync (TTL = 7 days):
349
+ The first public method call from within an async context spawns a
350
+ long-lived asyncio task that wakes once every 7 days and re-fetches
351
+ the FMR hierarchy and codelist endpoints. On a successful fetch the
352
+ in-memory state is atomically replaced.
353
+
354
+ FMR (https://fmr.worldbank.org) is a VPN-restricted resource. A
355
+ failed fetch is logged at WARNING level and the existing in-memory
356
+ state is kept intact. The loop then sleeps another full TTL cycle
357
+ before trying again — non-VPN deployments therefore produce at most
358
+ one warning per week and never retry aggressively.
359
+
360
+ Covers all 147 group codes across 6 group types:
361
+ REGION, INCOME, LENDING, OTHER, REGION_UN, CONTINENT.
362
+ """
363
+
364
+ # 7-day TTL: income group compositions change at most once a year (July 1).
365
+ _TTL: float = 7 * 24 * 3600.0
366
+
367
+ _DATA_FILE = Path(__file__).parent / "ref_area_groups.json"
368
+
369
+ def __init__(self, include_types: set[str] | None = None) -> None:
370
+ """Initialize and load the bundled JSON fallback synchronously.
371
+
372
+ Args:
373
+ include_types: If provided, only expose groups of these types.
374
+ Defaults to all types. Common subset:
375
+ {"REGION", "INCOME", "LENDING", "OTHER"}
376
+ """
377
+ self._groups: dict[str, dict[str, Any]] = {}
378
+ self._all_countries: set[str] = set()
379
+ self._meta: dict[str, Any] = {}
380
+ self._include_types = include_types
381
+ self._loaded = False
382
+ self._initial_fetch_succeeded = False
383
+ self._last_fetched: float = 0.0
384
+ self._bg_task: asyncio.Task | None = None # type: ignore[type-arg]
385
+
386
+ # ------------------------------------------------------------------
387
+ # SDMX parsing helpers (static — also imported by build_ref_area_groups.py)
388
+ # ------------------------------------------------------------------
389
+
390
+ @staticmethod
391
+ def parse_name_map(codelist_data: dict) -> dict[str, str]:
392
+ """Build a {code: name} map from a CL_REF_GROUPINGS SDMX-JSON payload."""
393
+ codes = codelist_data["data"]["codelists"][0]["codes"]
394
+ return {c["id"]: c["name"] for c in codes}
395
+
396
+ @staticmethod
397
+ def parse_hierarchy(
398
+ hierarchy_data: dict,
399
+ name_map: dict[str, str],
400
+ include_types: set[str] | None = None,
401
+ ) -> tuple[dict[str, dict], set[str], str]:
402
+ """Parse H_REF_AREA_GROUPS SDMX-JSON into (groups, all_countries, version).
403
+
404
+ groups format:
405
+ {
406
+ "SAS": {"name": "South Asia", "type": "REGION", "countries": [...]},
407
+ ...
408
+ }
409
+ all_countries: set of every individual country code that appears in at
410
+ least one group.
411
+ version: hierarchy version string from the SDMX payload (e.g. "38.0").
412
+ """
413
+ hcl = hierarchy_data["data"]["hierarchicalCodelists"][0]
414
+ version = hcl["version"]
415
+ top_level = hcl["hierarchies"][0]["hierarchicalCodes"]
416
+
417
+ groups: dict[str, dict] = {}
418
+ all_countries: set[str] = set()
419
+
420
+ for type_node in top_level:
421
+ group_type = type_node["id"]
422
+ if group_type in _LEAF_ONLY_TYPES:
423
+ continue
424
+ if include_types and group_type not in include_types:
425
+ continue
426
+
427
+ for group_node in type_node.get("hierarchicalCodes", []):
428
+ group_id = group_node["id"]
429
+ country_nodes = group_node.get("hierarchicalCodes", [])
430
+ if not country_nodes:
431
+ continue
432
+
433
+ countries = sorted(c["id"] for c in country_nodes)
434
+ all_countries.update(countries)
435
+
436
+ # Strip UTF-8 mojibake from some FMR source names.
437
+ # (e.g. 9WN: "Western Asia\u00c2\u00a0 and ..." — \u00c2\u00a0 is
438
+ # a UTF-8 non-breaking space decoded as Latin-1.)
439
+ raw_name = name_map.get(group_id, group_id)
440
+ clean_name = " ".join(raw_name.replace("\u00c2\u00a0", " ").split())
441
+
442
+ groups[group_id] = {
443
+ "name": clean_name,
444
+ "type": group_type,
445
+ "countries": countries,
446
+ }
447
+
448
+ return groups, all_countries, version
449
+
450
+ # ------------------------------------------------------------------
451
+ # Bundled-JSON loader (synchronous, called once at __init__)
452
+ # ------------------------------------------------------------------
453
+
454
+ def _load(self) -> None:
455
+ """Load the bundled ref_area_groups.json. Called once at __init__."""
456
+ if self._loaded:
457
+ return
458
+ try:
459
+ with open(self._DATA_FILE, encoding="utf-8") as f:
460
+ data = json.load(f)
461
+ self._apply(data)
462
+ _logger.info(
463
+ "GroupHierarchyManager: loaded %d groups (%d countries) from bundled file. Source: %s",
464
+ len(self._groups),
465
+ len(self._all_countries),
466
+ self._meta.get("source", "unknown"),
467
+ )
468
+ except FileNotFoundError:
469
+ _logger.error(
470
+ "GroupHierarchyManager: bundled data file not found at %s. "
471
+ "Run scripts/build_ref_area_groups.py to generate it.",
472
+ self._DATA_FILE,
473
+ )
474
+ except Exception as e:
475
+ _logger.error("GroupHierarchyManager: failed to load bundled data: %s", e)
476
+ finally:
477
+ # Mark as loaded regardless so _ensure_loaded does not retry
478
+ # the synchronous disk read in a loop.
479
+ self._loaded = True
480
+
481
+ def _apply(self, data: dict) -> None:
482
+ """Atomically swap the in-memory state from a parsed data dict."""
483
+ raw_groups: dict[str, dict] = data.get("groups", {})
484
+ if self._include_types:
485
+ raw_groups = {
486
+ k: v
487
+ for k, v in raw_groups.items()
488
+ if v.get("type") in self._include_types
489
+ }
490
+ self._groups = raw_groups
491
+ self._all_countries = set(data.get("all_countries", []))
492
+ self._meta = data.get("_meta", {})
493
+
494
+ def _ensure_loaded(self) -> None:
495
+ if not self._loaded:
496
+ self._load()
497
+
498
+ # ------------------------------------------------------------------
499
+ # Background sync machinery (mirrors DatabaseManager)
500
+ # ------------------------------------------------------------------
501
+
502
+ def _ensure_background_sync(self) -> None:
503
+ """Spawn the background refresh loop if it is not already running.
504
+
505
+ No-op when called outside a running event loop (e.g. in sync test code
506
+ or during module import). The bundled JSON is always pre-loaded, so
507
+ the background task is only an update mechanism, not a prerequisite.
508
+ """
509
+ if os.environ.get("PYTEST_RUNNING"):
510
+ return
511
+ try:
512
+ asyncio.get_running_loop()
513
+ except RuntimeError:
514
+ # No running event loop — skip task creation.
515
+ return
516
+ if self._bg_task is None or self._bg_task.done():
517
+ self._bg_task = asyncio.create_task(self._background_sync_loop())
518
+
519
+ async def _background_sync_loop(self) -> None:
520
+ """Run forever, waking every TTL seconds to refresh from FMR.
521
+
522
+ TTL policy:
523
+ - Success: in-memory state is replaced; next wake is _TTL seconds later.
524
+ - Failure: existing state is kept; _last_fetched is still advanced by
525
+ _TTL so the next wake is also _TTL seconds later. This means a
526
+ non-VPN deployment gets at most one WARNING log per week rather than
527
+ retrying aggressively.
528
+
529
+ The loop is safe to cancel: cancellation propagates through
530
+ asyncio.sleep and exits cleanly.
531
+ """
532
+ backoff = 5.0
533
+ max_backoff = 900.0 # 15 minutes
534
+ while True:
535
+ if self._initial_fetch_succeeded:
536
+ elapsed = time.monotonic() - self._last_fetched
537
+ sleep_for = max(0.0, self._TTL - elapsed)
538
+ if sleep_for > 0:
539
+ await asyncio.sleep(sleep_for)
540
+ continue
541
+
542
+ try:
543
+ groups, all_countries, version = await self._fetch_fmr_data()
544
+ # Build a minimal data dict compatible with _apply.
545
+ data = {
546
+ "_meta": {
547
+ "source": f"FMR H_REF_AREA_GROUPS v{version} + CL_REF_GROUPINGS (live)",
548
+ "hierarchy_version": version,
549
+ },
550
+ "groups": groups,
551
+ "all_countries": sorted(all_countries),
552
+ }
553
+ self._apply(data)
554
+ self._initial_fetch_succeeded = True
555
+ self._last_fetched = time.monotonic()
556
+ _logger.info(
557
+ "GroupHierarchyManager: background refresh completed — "
558
+ "%d groups (%d countries), hierarchy v%s.",
559
+ len(self._groups),
560
+ len(self._all_countries),
561
+ version,
562
+ )
563
+ backoff = 5.0
564
+ sleep_for = self._TTL
565
+ except Exception as e:
566
+ if not self._initial_fetch_succeeded:
567
+ sleep_for = backoff
568
+ backoff = min(backoff * 2 + random.uniform(0.1, 1.0), max_backoff)
569
+ _logger.warning(
570
+ "GroupHierarchyManager: initial background FMR fetch failed (%s). "
571
+ "Retrying in %.1f seconds.",
572
+ e,
573
+ sleep_for,
574
+ )
575
+ else:
576
+ sleep_for = self._TTL
577
+ self._last_fetched = time.monotonic()
578
+ _logger.warning(
579
+ "GroupHierarchyManager: background FMR fetch failed (%s). "
580
+ "Next attempt in %.0f days.",
581
+ e,
582
+ self._TTL / 86400,
583
+ )
584
+
585
+ await asyncio.sleep(sleep_for)
586
+
587
+ async def _fetch_fmr_data(
588
+ self,
589
+ hierarchy_version: str = "",
590
+ codelist_version: str = "",
591
+ ) -> tuple[dict[str, dict], set[str], str]:
592
+ """Fetch and parse the FMR hierarchy + codelist endpoints.
593
+
594
+ Args:
595
+ hierarchy_version: Specific version string (e.g. "38.0"). If empty,
596
+ the FMR API returns the latest version.
597
+ codelist_version: Specific codelist version (e.g. "2.0"). If empty,
598
+ the FMR API returns the latest version.
599
+
600
+ Returns:
601
+ Tuple of (groups, all_countries, version) — same shape as
602
+ parse_hierarchy().
603
+ """
604
+ hierarchy_url = _FMR_HIERARCHY_URL.format(version=hierarchy_version)
605
+ codelist_url = _FMR_CODELIST_URL.format(version=codelist_version)
606
+
607
+ client = get_shared_httpx_client()
608
+ h_resp, cl_resp = await asyncio.gather(
609
+ client.get(hierarchy_url, headers={"Accept": "application/json"}),
610
+ client.get(codelist_url, headers={"Accept": "application/json"}),
611
+ )
612
+ h_resp.raise_for_status()
613
+ cl_resp.raise_for_status()
614
+ hierarchy_data = h_resp.json()
615
+ codelist_data = cl_resp.json()
616
+
617
+ name_map = self.parse_name_map(codelist_data)
618
+ return self.parse_hierarchy(hierarchy_data, name_map, self._include_types)
619
+
620
+ # ------------------------------------------------------------------
621
+ # Public API
622
+ # ------------------------------------------------------------------
623
+
624
+ def is_group(self, code: str) -> bool:
625
+ """Return True if the code is a known country group (not a leaf country)."""
626
+ self._ensure_loaded()
627
+ self._ensure_background_sync()
628
+ return code.upper() in self._groups
629
+
630
+ def is_country(self, code: str) -> bool:
631
+ """Return True if the code is an individual country in the hierarchy."""
632
+ self._ensure_loaded()
633
+ return code.upper() in self._all_countries and not self.is_group(code.upper())
634
+
635
+ def list_rankable_country_codes(self) -> list[str]:
636
+ """Return sorted leaf economy codes (FMR member countries, excluding group aggregates).
637
+
638
+ Used for ranking metadata and for ``member_economies_only`` filtering; the Data API
639
+ may still return additional aggregate codes when ``REF_AREA`` is unpinned—those
640
+ should be dropped with :meth:`is_country` per row.
641
+ """
642
+ self._ensure_loaded()
643
+ self._ensure_background_sync()
644
+ return sorted(c for c in self._all_countries if self.is_country(c))
645
+
646
+ def get_group_info(self, code: str) -> dict[str, Any] | None:
647
+ """Return {name, type, countries} for a group code, or None if unknown."""
648
+ self._ensure_loaded()
649
+ self._ensure_background_sync()
650
+ return self._groups.get(code.upper())
651
+
652
+ def get_group_type(self, code: str) -> str | None:
653
+ """Return the group type string (REGION, INCOME, ...) or None."""
654
+ info = self.get_group_info(code)
655
+ return info["type"] if info else None
656
+
657
+ def expand_group(self, code: str) -> list[str]:
658
+ """Return sorted list of country codes in a group, or [] if unknown."""
659
+ info = self.get_group_info(code)
660
+ return list(info["countries"]) if info else []
661
+
662
+ def get_meta(self) -> dict[str, Any]:
663
+ """Return metadata about the loaded hierarchy (version, built_at, etc.)."""
664
+ self._ensure_loaded()
665
+ return self._meta
666
+
667
+ def search_groups(self, query: str, limit: int = 10) -> list[dict[str, Any]]:
668
+ """Search group names by substring. Returns [{id, name, type, count}]."""
669
+ self._ensure_loaded()
670
+ q = query.lower().strip()
671
+ results = []
672
+ for gid, info in self._groups.items():
673
+ if q in info["name"].lower() or q == gid.lower():
674
+ results.append(
675
+ {
676
+ "id": gid,
677
+ "name": info["name"],
678
+ "type": info["type"],
679
+ "count": len(info["countries"]),
680
+ }
681
+ )
682
+ results.sort(key=lambda x: x["name"])
683
+ return results[:limit]
684
+
685
+
686
+ # Global instance
687
+ _group_hierarchy_manager: GroupHierarchyManager | None = None
688
+
689
+
690
+ def get_group_hierarchy_manager() -> GroupHierarchyManager:
691
+ """Get the global GroupHierarchyManager instance."""
692
+ global _group_hierarchy_manager
693
+ if _group_hierarchy_manager is None:
694
+ _group_hierarchy_manager = GroupHierarchyManager()
695
+ return _group_hierarchy_manager
696
+
697
+
698
+ def _make_group_note(group_code: str, info: dict) -> str:
699
+ """Build the standard guidance note for a REF_AREA group result."""
700
+ return (
701
+ f"This is a {info['type'].lower()} group with "
702
+ f"{len(info['countries'])} member countries. "
703
+ f"Use data360_expand_country_group('{group_code}') "
704
+ "to get individual country codes."
705
+ )
706
+
707
+
708
+ _UNIT_MEASURE_FALLBACKS = {
709
+ "ZS": "Percent",
710
+ "PS": "Persons",
711
+ "USD": "US Dollars",
712
+ "LCU": "Local Currency Unit",
713
+ "DY": "Days",
714
+ "YR": "Years",
715
+ "MR": "Meters",
716
+ "KG": "Kilograms",
717
+ "TN": "Tonnes",
718
+ }
719
+
720
+
721
+ class CodelistManager:
722
+ """Unified manager for all Data360 codelists.
723
+
724
+ Primary data source (lazy startup fetch, then background refresh):
725
+ On first use, it fetches all dimension codelists from the extdataportal
726
+ metadata API in a single HTTP call and populates ``_extdataportal``.
727
+ Covers COMP_BREAKDOWN (5 000+ codes), UNIT_MEASURE (769 codes),
728
+ AGE (173 codes), URBANISATION (16 codes), SEX (7 codes), FREQ (34 codes),
729
+ and REF_AREA (532 codes).
730
+
731
+ After the initial fetch a background task wakes every ``_TTL`` seconds
732
+ (default 7 days) and atomically replaces the in-memory mapping so the
733
+ server always has fresh labels without restarting.
734
+
735
+ Graceful degradation:
736
+ If the network is unavailable at startup ``_extdataportal`` stays empty
737
+ and ``get_label()`` / ``get_dimension_labels()`` return the raw code
738
+ unchanged. The background loop retries after the next TTL cycle.
739
+
740
+ Legacy paths (kept for backward compatibility):
741
+ ``find_value()`` / ``get_codelist_mapping()`` still work for REF_AREA and
742
+ UNIT_MEASURE via the old per-type API fetch (used by the
743
+ ``find_codelist_value`` MCP tool).
744
+ ``STATIC_MAPPINGS`` (name→code) is kept for the ``find_value()`` search
745
+ path on SEX, AGE, URBANISATION, FREQ.
746
+
747
+ New preferred path:
748
+ ``get_label(dimension, code)`` — O(1) code→name lookup.
749
+ ``get_dimension_labels(dimension)`` — full {code: name} dict.
750
+ """
751
+
752
+ # 7-day TTL: codelists rarely change; this mirrors GroupHierarchyManager.
753
+ _TTL: float = 7 * 24 * 3600.0
754
+
755
+ # COMP_BREAKDOWN_1/2/3 are identical on the API — stored once under this key.
756
+ _COMP_BREAKDOWN_DIMS: frozenset[str] = frozenset(
757
+ {"COMP_BREAKDOWN_1", "COMP_BREAKDOWN_2", "COMP_BREAKDOWN_3"}
758
+ )
759
+ _COMP_BREAKDOWN_KEY = "COMP_BREAKDOWN"
760
+
761
+ # Prefix stripped from COMP_BREAKDOWN labels for chart readability.
762
+ _COMP_BREAKDOWN_STRIP_PREFIX = "Metric: "
763
+
764
+ # Codelists available via /codelist?type=X API
765
+ GLOBAL_CODELISTS = ["REF_AREA", "UNIT_MEASURE"]
766
+
767
+ # Static mappings for codelists without global API endpoints
768
+ # These are based on actual values from disaggregation responses
769
+ # Reference: WB_SSGD_UNEMPLOYMENT_RATE_disaggregation.json
770
+ STATIC_MAPPINGS: dict[str, dict[str, str]] = {
771
+ "FREQ": {
772
+ # Common frequency codes found in Data360
773
+ "annual": "A",
774
+ "yearly": "A",
775
+ "year": "A",
776
+ "monthly": "M",
777
+ "month": "M",
778
+ "quarterly": "Q",
779
+ "quarter": "Q",
780
+ "other": "_O",
781
+ "irregular": "_O",
782
+ },
783
+ "SEX": {
784
+ # Sex codes from actual disaggregation data
785
+ "female": "F",
786
+ "women": "F",
787
+ "woman": "F",
788
+ "girls": "F",
789
+ "girl": "F",
790
+ "male": "M",
791
+ "men": "M",
792
+ "man": "M",
793
+ "boys": "M",
794
+ "boy": "M",
795
+ "total": "_T",
796
+ "all": "_T",
797
+ "both": "_T",
798
+ "overall": "_T",
799
+ },
800
+ "AGE": {
801
+ # Age group codes from actual disaggregation data
802
+ "youth": "Y15T24",
803
+ "young": "Y15T24",
804
+ "15-24": "Y15T24",
805
+ "15 to 24": "Y15T24",
806
+ "young adults": "Y15T29",
807
+ "15-29": "Y15T29",
808
+ "adults": "Y30T59",
809
+ "30-59": "Y30T59",
810
+ "25+": "Y_GE25",
811
+ "25 and over": "Y_GE25",
812
+ "adult": "Y_GE25",
813
+ "elderly": "Y_GE60",
814
+ "60+": "Y_GE60",
815
+ "60 and over": "Y_GE60",
816
+ "seniors": "Y_GE60",
817
+ "total": "_T",
818
+ "all ages": "_T",
819
+ },
820
+ "URBANISATION": {
821
+ # Urbanisation codes from actual disaggregation data
822
+ "urban": "URB",
823
+ "city": "URB",
824
+ "cities": "URB",
825
+ "rural": "RUR",
826
+ "countryside": "RUR",
827
+ "village": "RUR",
828
+ "total": "_T",
829
+ "all": "_T",
830
+ },
831
+ }
832
+
833
+ def __init__(self) -> None:
834
+ """Initialise the CodelistManager.
835
+
836
+ ``_extdataportal`` starts empty. It is populated lazily on first use.
837
+ """
838
+ self._cache: dict[str, list[dict[str, Any]]] = {}
839
+ self._loaded: set[str] = set()
840
+ # Runtime-fetched extdataportal data: {dimension → {code → name}}.
841
+ # Populated lazily; stays empty (graceful fallback) if offline.
842
+ self._extdataportal: dict[str, dict[str, str]] = {}
843
+ self._initial_fetch_succeeded = False
844
+ self._last_fetched: float = 0.0
845
+ self._bg_task: asyncio.Task | None = None # type: ignore[type-arg]
846
+
847
+ # ------------------------------------------------------------------
848
+ # Extdataportal fetch helpers
849
+ # ------------------------------------------------------------------
850
+
851
+ @staticmethod
852
+ def _parse_extdataportal_response(raw: dict) -> dict[str, dict[str, str]]:
853
+ """Parse the extdataportal JSON response into {dimension: {code: name}}.
854
+
855
+ Deduplicates COMP_BREAKDOWN_1/2/3 into a single COMP_BREAKDOWN key and
856
+ strips the ``'Metric: '`` prefix from COMP_BREAKDOWN labels.
857
+ """
858
+ built: dict[str, dict[str, str]] = {}
859
+ strip = CodelistManager._COMP_BREAKDOWN_STRIP_PREFIX
860
+ cb_key = CodelistManager._COMP_BREAKDOWN_KEY
861
+ cb_dims = CodelistManager._COMP_BREAKDOWN_DIMS
862
+
863
+ for dim, items in raw.items():
864
+ if dim == "_meta" or not isinstance(items, list):
865
+ continue
866
+ # Normalise dimension key
867
+ norm = dim.upper()
868
+ target_key = cb_key if norm in cb_dims else norm
869
+ # Build {id: name} — strip 'Metric: ' prefix from COMP_BREAKDOWN
870
+ mapping: dict[str, str] = {}
871
+ for item in items:
872
+ if not isinstance(item, dict) or "id" not in item:
873
+ continue
874
+ name = item.get("name", item["id"])
875
+ if target_key == cb_key and name.startswith(strip):
876
+ name = name[len(strip):]
877
+ mapping[item["id"]] = name
878
+ # Merge into existing entry (COMP_BREAKDOWN_1/2/3 all write to cb_key)
879
+ if target_key in built:
880
+ built[target_key].update(mapping)
881
+ else:
882
+ built[target_key] = mapping
883
+
884
+ return built
885
+
886
+ async def _fetch_extdataportal(self) -> dict[str, dict[str, str]]:
887
+ """Fetch all dimension codelists from the extdataportal metadata API.
888
+
889
+ Returns:
890
+ Parsed {dimension: {code: name}} mapping.
891
+
892
+ Raises:
893
+ httpx.HTTPStatusError / httpx.RequestError on network failure.
894
+ """
895
+ url = data360_config.codelist_api_base_url
896
+ client = get_shared_httpx_client()
897
+ response = await client.get(url, timeout=30.0)
898
+ response.raise_for_status()
899
+ raw: dict = response.json()
900
+ return self._parse_extdataportal_response(raw)
901
+
902
+ def _apply_extdataportal(self, mapping: dict[str, dict[str, str]]) -> None:
903
+ """Atomically replace the in-memory extdataportal mapping."""
904
+ self._extdataportal = mapping
905
+ _logger.info(
906
+ "CodelistManager: extdataportal codelists loaded — %d dimensions, "
907
+ "COMP_BREAKDOWN=%d codes.",
908
+ len(mapping),
909
+ len(mapping.get(self._COMP_BREAKDOWN_KEY, {})),
910
+ )
911
+
912
+ # ------------------------------------------------------------------
913
+ # Lazy loader (same pattern as _ensure_loaded for REF_AREA)
914
+ # ------------------------------------------------------------------
915
+
916
+ async def _ensure_extdataportal_loaded(self) -> None:
917
+ """Fetch extdataportal codelists on first use (lazy load).
918
+
919
+ Mirrors the pattern of ``_ensure_loaded()`` for REF_AREA: called at the
920
+ top of any async path that reads ``_extdataportal`` (e.g.
921
+ ``_map_dimension_codes``). Subsequent calls are instant no-ops once the
922
+ data is populated. Starts the background refresh loop on first call.
923
+ """
924
+ if not self._extdataportal:
925
+ try:
926
+ mapping = await self._fetch_extdataportal()
927
+ self._apply_extdataportal(mapping)
928
+ self._initial_fetch_succeeded = True
929
+ self._last_fetched = time.monotonic()
930
+ except Exception as exc:
931
+ _logger.warning(
932
+ "CodelistManager: extdataportal fetch failed (%s). "
933
+ "Dimension codes will display as raw values until the "
934
+ "next retry.",
935
+ exc,
936
+ )
937
+ self._ensure_background_refresh()
938
+
939
+ # ------------------------------------------------------------------
940
+ # Background refresh (mirrors GroupHierarchyManager pattern)
941
+ # ------------------------------------------------------------------
942
+
943
+ def _ensure_background_refresh(self) -> None:
944
+ """Spawn the background refresh task if not already running.
945
+
946
+ No-op outside a running event loop (e.g. in sync tests).
947
+ """
948
+ if os.environ.get("PYTEST_RUNNING"):
949
+ return
950
+ try:
951
+ asyncio.get_running_loop()
952
+ except RuntimeError:
953
+ return
954
+ if self._bg_task is None or self._bg_task.done():
955
+ self._bg_task = asyncio.create_task(self._background_refresh_loop())
956
+
957
+ async def _background_refresh_loop(self) -> None:
958
+ """Wake every TTL seconds and refresh from extdataportal.
959
+
960
+ On success the in-memory mapping is atomically replaced.
961
+ On failure the existing mapping is kept and a WARNING is logged;
962
+ the loop sleeps another full TTL before retrying.
963
+ """
964
+ backoff = 5.0
965
+ max_backoff = 900.0 # 15 minutes
966
+ while True:
967
+ if self._initial_fetch_succeeded:
968
+ elapsed = time.monotonic() - self._last_fetched
969
+ sleep_for = max(0.0, self._TTL - elapsed)
970
+ if sleep_for > 0:
971
+ await asyncio.sleep(sleep_for)
972
+ continue
973
+
974
+ try:
975
+ mapping = await self._fetch_extdataportal()
976
+ self._apply_extdataportal(mapping)
977
+ self._initial_fetch_succeeded = True
978
+ self._last_fetched = time.monotonic()
979
+ _logger.info(
980
+ "CodelistManager: background refresh completed — "
981
+ "%d dimensions, COMP_BREAKDOWN=%d codes.",
982
+ len(mapping),
983
+ len(mapping.get(self._COMP_BREAKDOWN_KEY, {})),
984
+ )
985
+ backoff = 5.0
986
+ sleep_for = self._TTL
987
+ except Exception as exc:
988
+ if not self._initial_fetch_succeeded:
989
+ sleep_for = backoff
990
+ backoff = min(backoff * 2 + random.uniform(0.1, 1.0), max_backoff)
991
+ _logger.warning(
992
+ "CodelistManager: initial background refresh failed (%s). "
993
+ "Retrying in %.1f seconds.",
994
+ exc,
995
+ sleep_for,
996
+ )
997
+ else:
998
+ sleep_for = self._TTL
999
+ self._last_fetched = time.monotonic()
1000
+ _logger.warning(
1001
+ "CodelistManager: background refresh failed (%s). "
1002
+ "Next attempt in %.0f days.",
1003
+ exc,
1004
+ self._TTL / 86400,
1005
+ )
1006
+
1007
+ await asyncio.sleep(sleep_for)
1008
+
1009
+ # ------------------------------------------------------------------
1010
+ # Public label-lookup API (synchronous — reads pre-fetched dict)
1011
+ # ------------------------------------------------------------------
1012
+
1013
+ def _resolve_extdataportal_key(self, dimension: str) -> str:
1014
+ """Normalise dimension name for extdataportal lookup.
1015
+
1016
+ Maps COMP_BREAKDOWN_1/2/3 → COMP_BREAKDOWN; everything else is
1017
+ upper-cased and passed through unchanged.
1018
+ """
1019
+ upper = dimension.upper()
1020
+ if upper in self._COMP_BREAKDOWN_DIMS:
1021
+ return self._COMP_BREAKDOWN_KEY
1022
+ return upper
1023
+
1024
+ def get_label(self, dimension: str, code: str) -> str:
1025
+ """Return the human-readable label for a dimension code.
1026
+
1027
+ Reads from the in-memory ``_extdataportal`` dict populated by
1028
+ extdataportal mapping (lazy-loaded). Returns the raw ``code`` unchanged if the
1029
+ dimension or code is not found (graceful degradation).
1030
+
1031
+ Args:
1032
+ dimension: Dimension name, e.g. ``"COMP_BREAKDOWN_1"``, ``"SEX"``.
1033
+ code: The raw code value, e.g. ``"WGI_EST"``, ``"F"``.
1034
+
1035
+ Returns:
1036
+ Human-readable label or the original ``code`` if not found.
1037
+ """
1038
+ key = self._resolve_extdataportal_key(dimension)
1039
+ if key == "UNIT_MEASURE" and code in _UNIT_MEASURE_FALLBACKS:
1040
+ return _UNIT_MEASURE_FALLBACKS[code]
1041
+ return self._extdataportal.get(key, {}).get(code, code)
1042
+
1043
+ def get_dimension_labels(self, dimension: str) -> dict[str, str]:
1044
+ """Return a ``{code: name}`` mapping for an entire dimension.
1045
+
1046
+ Suitable for vectorised DataFrame replacement via ``df[col].map(lookup)``.
1047
+ Returns an empty dict if the dimension is not found.
1048
+
1049
+ Args:
1050
+ dimension: Dimension name, e.g. ``"COMP_BREAKDOWN_1"``, ``"UNIT_MEASURE"``.
1051
+
1052
+ Returns:
1053
+ A copy of the code→name dict for the dimension.
1054
+ """
1055
+ key = self._resolve_extdataportal_key(dimension)
1056
+ return dict(self._extdataportal.get(key, {}))
1057
+
1058
+ async def _ensure_loaded(self, codelist_type: str) -> None:
1059
+ """Ensure a global codelist is loaded from extdataportal."""
1060
+ if codelist_type in self._loaded:
1061
+ return
1062
+ if codelist_type in self.GLOBAL_CODELISTS:
1063
+ await self._ensure_extdataportal_loaded()
1064
+ ext_key = self._resolve_extdataportal_key(codelist_type)
1065
+ if ext_key in self._extdataportal and self._extdataportal[ext_key]:
1066
+ items = [
1067
+ {"Id": code, "Name": name}
1068
+ for code, name in self._extdataportal[ext_key].items()
1069
+ ]
1070
+ self._cache[codelist_type] = items
1071
+ self._loaded.add(codelist_type)
1072
+
1073
+ def _normalize_query(self, query: str) -> str:
1074
+ """Normalize query for case-insensitive and whitespace-invariant search."""
1075
+ return query.lower().strip()
1076
+
1077
+ async def find_value(
1078
+ self, codelist_type: str, query: str, limit: int = 5
1079
+ ) -> list[dict[str, Any]]:
1080
+ """Find values in a codelist matching the query.
1081
+
1082
+ Args:
1083
+ codelist_type: Type of codelist (REF_AREA, FREQ, SEX, etc.)
1084
+ query: Search query (e.g., "Kenya", "monthly", "female")
1085
+ limit: Maximum number of results to return
1086
+
1087
+ Returns:
1088
+ List of matches with id, name, and score
1089
+ """
1090
+ codelist_type = codelist_type.upper()
1091
+
1092
+ # Check for multi-value query (comma-separated)
1093
+ if "," in query:
1094
+ parts = [p.strip() for p in query.split(",") if p.strip()]
1095
+ all_results = []
1096
+ seen_ids = set()
1097
+
1098
+ for part in parts:
1099
+ part_lower = self._normalize_query(part)
1100
+ # Recurse for single value
1101
+ matches = await self.find_value(
1102
+ codelist_type, part, limit=1
1103
+ ) # find top match for each
1104
+ for m in matches:
1105
+ if m["id"] not in seen_ids:
1106
+ all_results.append(m)
1107
+ seen_ids.add(m["id"])
1108
+ return all_results
1109
+
1110
+ # Explicitly normalize using helper
1111
+ query_lower = self._normalize_query(query)
1112
+
1113
+ # Handle global codelists (API-based)
1114
+ if codelist_type in self.GLOBAL_CODELISTS:
1115
+ await self._ensure_loaded(codelist_type)
1116
+ return self._search_global(codelist_type, query_lower, limit)
1117
+
1118
+ # Handle static codelists
1119
+ if codelist_type in self.STATIC_MAPPINGS:
1120
+ return self._search_static(codelist_type, query_lower, limit)
1121
+
1122
+ # Unknown codelist
1123
+ _logger.warning(f"Unknown codelist type: {codelist_type}")
1124
+ return []
1125
+
1126
+ def _search_global(
1127
+ self, codelist_type: str, query_lower: str, limit: int
1128
+ ) -> list[dict[str, Any]]:
1129
+ """Search in a global (API-fetched) codelist.
1130
+
1131
+ For REF_AREA, results are enriched with group metadata (is_group,
1132
+ group_type, member_count) sourced from the GroupHierarchyManager.
1133
+ """
1134
+ items = self._cache.get(codelist_type, [])
1135
+ results: list[dict[str, Any]] = []
1136
+
1137
+ # Also search without spaces for cases like "Vietnam" vs "Viet Nam"
1138
+ query_no_spaces = query_lower.replace(" ", "")
1139
+
1140
+ for item in items:
1141
+ item_id = item.get("Id", "")
1142
+ item_name = item.get("Name", "")
1143
+ name_lower = item_name.lower()
1144
+ name_no_spaces = name_lower.replace(" ", "")
1145
+
1146
+ score = 0
1147
+
1148
+ # Exact ID match
1149
+ if query_lower == item_id.lower():
1150
+ score = 100
1151
+ # Exact name match
1152
+ elif query_lower == name_lower:
1153
+ score = 100
1154
+ # Exact match ignoring spaces (Vietnam == Viet Nam)
1155
+ elif query_no_spaces == name_no_spaces:
1156
+ score = 100
1157
+ # ID starts with query
1158
+ elif item_id.lower().startswith(query_lower):
1159
+ score = 95
1160
+ # Name contains query exactly
1161
+ elif query_lower in name_lower:
1162
+ score = 90
1163
+ # No-space match (vietnam in vietnam)
1164
+ elif query_no_spaces in name_no_spaces:
1165
+ score = 90
1166
+ # Query contains name
1167
+ elif name_lower in query_lower:
1168
+ score = 85
1169
+ else:
1170
+ # Fuzzy: prefix matching
1171
+ similarity = self._calculate_similarity(query_lower, name_lower)
1172
+ if similarity > 0.7:
1173
+ score = int(similarity * 100)
1174
+
1175
+ if score > 0:
1176
+ results.append(
1177
+ {
1178
+ "id": item_id,
1179
+ "name": item_name,
1180
+ "score": score,
1181
+ }
1182
+ )
1183
+
1184
+ results.sort(key=lambda x: (-x["score"], x["name"]))
1185
+ matches = results[:limit]
1186
+
1187
+ # Enrich REF_AREA results with group metadata.
1188
+ if codelist_type == "REF_AREA":
1189
+ ghm = get_group_hierarchy_manager()
1190
+ for match in matches:
1191
+ info = ghm.get_group_info(match["id"])
1192
+ if info:
1193
+ match["is_group"] = True
1194
+ match["group_type"] = info["type"]
1195
+ match["member_count"] = len(info["countries"])
1196
+ match["note"] = _make_group_note(match["id"], info)
1197
+ else:
1198
+ match["is_group"] = False
1199
+
1200
+ return matches
1201
+
1202
+ def _search_static(
1203
+ self, codelist_type: str, query_lower: str, limit: int
1204
+ ) -> list[dict[str, Any]]:
1205
+ """Search in a static (hardcoded) codelist."""
1206
+ mapping = self.STATIC_MAPPINGS.get(codelist_type, {})
1207
+ results: list[dict[str, Any]] = []
1208
+
1209
+ for name, code in mapping.items():
1210
+ name_lower = name.lower()
1211
+ score = 0
1212
+
1213
+ # Exact match
1214
+ if query_lower == name_lower:
1215
+ score = 100
1216
+ # Query is the code itself
1217
+ elif query_lower == code.lower():
1218
+ score = 100
1219
+ # Name contains query
1220
+ elif query_lower in name_lower:
1221
+ score = 90
1222
+ # Query contains name
1223
+ elif name_lower in query_lower:
1224
+ score = 80
1225
+
1226
+ if score > 0:
1227
+ results.append(
1228
+ {
1229
+ "id": code,
1230
+ "name": name.capitalize(),
1231
+ "score": score,
1232
+ }
1233
+ )
1234
+
1235
+ # Deduplicate by id (keep highest score)
1236
+ seen: dict[str, dict[str, Any]] = {}
1237
+ for r in results:
1238
+ rid = r["id"]
1239
+ if rid not in seen or r["score"] > seen[rid]["score"]:
1240
+ seen[rid] = r
1241
+
1242
+ deduped = list(seen.values())
1243
+ deduped.sort(key=lambda x: (-x["score"], x["name"]))
1244
+ return deduped[:limit]
1245
+
1246
+ def _calculate_similarity(self, s1: str, s2: str) -> float:
1247
+ """Calculate similarity ratio between two strings."""
1248
+ if not s1 or not s2:
1249
+ return 0.0
1250
+
1251
+ if len(s1) <= 3:
1252
+ if s1 in s2 or s2.startswith(s1):
1253
+ return 0.8
1254
+ return 0.0
1255
+
1256
+ len1, len2 = len(s1), len(s2)
1257
+ if abs(len1 - len2) > max(len1, len2) * 0.5:
1258
+ return 0.0
1259
+
1260
+ s1_chars = set(s1)
1261
+ s2_chars = set(s2)
1262
+ common = len(s1_chars & s2_chars)
1263
+ total = len(s1_chars | s2_chars)
1264
+ char_similarity = common / total if total > 0 else 0
1265
+
1266
+ prefix_len = 0
1267
+ for c1, c2 in zip(s1, s2):
1268
+ if c1 == c2:
1269
+ prefix_len += 1
1270
+ else:
1271
+ break
1272
+ prefix_ratio = prefix_len / min(len1, len2)
1273
+
1274
+ return (char_similarity * 0.4) + (prefix_ratio * 0.6)
1275
+
1276
+ async def get_codelist_mapping(self, codelist_type: str) -> dict[str, str]:
1277
+ """Get a dictionary mapping codes to names (e.g., {'KEN': 'Kenya'})."""
1278
+ codelist_type = codelist_type.upper()
1279
+
1280
+ # Try from extdataportal first
1281
+ try:
1282
+ await self._ensure_extdataportal_loaded()
1283
+ ext_key = self._resolve_extdataportal_key(codelist_type)
1284
+ if ext_key in self._extdataportal and self._extdataportal[ext_key]:
1285
+ return dict(self._extdataportal[ext_key])
1286
+ except Exception:
1287
+ _logger.debug("Failed to load from extdataportal.", exc_info=True)
1288
+
1289
+ # Fallback: derive global codelist from extdataportal via _ensure_loaded cache path
1290
+ if codelist_type in self.GLOBAL_CODELISTS:
1291
+ try:
1292
+ await self._ensure_loaded(codelist_type)
1293
+ items = self._cache.get(codelist_type, [])
1294
+ return {item.get("Id", ""): item.get("Name", "") for item in items}
1295
+ except Exception:
1296
+ _logger.warning(
1297
+ "Global codelist load failed for %s.", codelist_type, exc_info=True
1298
+ )
1299
+
1300
+ # Static mappings (reverse the value->code mapping to code->name)
1301
+ if codelist_type in self.STATIC_MAPPINGS:
1302
+ # STATIC_MAPPINGS is Name -> Code. We want Code -> Name.
1303
+ # Names in static mapping are lower case keys, we should capitalize for display.
1304
+ mapping = {}
1305
+ for name, code in self.STATIC_MAPPINGS[codelist_type].items():
1306
+ if (
1307
+ code not in mapping
1308
+ ): # First win or preferred name logic could be added
1309
+ mapping[code] = name.capitalize()
1310
+ return mapping
1311
+
1312
+ return {}
1313
+
1314
+
1315
+ # Global instance
1316
+ _codelist_manager: CodelistManager | None = None
1317
+
1318
+
1319
+ def get_codelist_manager() -> CodelistManager:
1320
+ """Get the global CodelistManager instance."""
1321
+ global _codelist_manager
1322
+ if _codelist_manager is None:
1323
+ _codelist_manager = CodelistManager()
1324
+ return _codelist_manager
1325
+
1326
+
1327
+ async def find_codelist_value(
1328
+ codelist_type: str, query: str, limit: int = 5
1329
+ ) -> list[dict[str, Any]]:
1330
+ """Find values in a Data360 codelist by name (e.g. country or dimension labels).
1331
+
1332
+ Use when you need to convert a user-friendly name to an API code. Helpful before
1333
+ data360_search_indicators (required_country) or when building disaggregation_filters
1334
+ for data360_get_data / data360_get_viz_spec. Not required if 3-letter codes are already known.
1335
+
1336
+ For REF_AREA queries, results include group metadata:
1337
+ - is_group (bool): whether the code is a country group or an individual country
1338
+ - group_type (str): REGION, INCOME, LENDING, OTHER, REGION_UN, or CONTINENT
1339
+ - member_count (int): number of countries in the group
1340
+ - note (str): guidance on using data360_expand_country_group
1341
+
1342
+ Workflow for country groups (e.g. "South Asian countries", "low income countries"):
1343
+ 1. Call this function with codelist_type="REF_AREA" and the group name as query.
1344
+ 2. If the result has is_group=True, you have the group code (e.g. "SAS", "LIC").
1345
+ 3. To get individual country-level codes, call data360_expand_country_group with
1346
+ that code. Use the returned country_codes string directly in data360_get_data
1347
+ or data360_search_indicators disaggregation_filters.
1348
+ 4. To use the group as a regional aggregate instead, pass the group code directly
1349
+ (e.g. REF_AREA="SAS") without expanding.
1350
+
1351
+ Args:
1352
+ codelist_type: One of REF_AREA (countries/regions), FREQ, SEX, AGE, URBANISATION, UNIT_MEASURE.
1353
+ query: Search term (e.g. "Kenya", "female", "annual"). Comma-separated for multiple
1354
+ (e.g. "Kenya, Tanzania") returns one match per part. For REF_AREA, natural-language
1355
+ group phrases are also recognized (e.g. "South Asian countries", "low income countries").
1356
+ limit: Maximum number of matches to return (default 5).
1357
+
1358
+ Returns:
1359
+ List of dicts, each with: id (code, e.g. "KEN"), name (e.g. "Kenya"), score (relevance 0–100).
1360
+ For REF_AREA, also includes is_group, group_type, member_count, note when applicable.
1361
+ Sorted by score descending. Empty list if no matches or unknown codelist_type.
1362
+ """
1363
+ # Check alias map for REF_AREA before fuzzy search.
1364
+ if codelist_type.upper() == "REF_AREA":
1365
+ alias_key = query.lower().strip()
1366
+ if alias_key in _GROUP_ALIASES:
1367
+ group_code = _GROUP_ALIASES[alias_key]
1368
+ ghm = get_group_hierarchy_manager()
1369
+ info = ghm.get_group_info(group_code)
1370
+ if info:
1371
+ result = {
1372
+ "id": group_code,
1373
+ "name": info["name"],
1374
+ "score": 100,
1375
+ "is_group": True,
1376
+ "group_type": info["type"],
1377
+ "member_count": len(info["countries"]),
1378
+ "note": _make_group_note(group_code, info),
1379
+ }
1380
+ return [result]
1381
+
1382
+ manager = get_codelist_manager()
1383
+ return await manager.find_value(codelist_type, query, limit)
1384
+
1385
+
1386
+ async def get_codelist_mapping(codelist_type: str) -> dict[str, str]:
1387
+ """Get mapping of codes to names for a codelist."""
1388
+ manager = get_codelist_manager()
1389
+ return await manager.get_codelist_mapping(codelist_type)
1390
+
1391
+
1392
+ # Convenience functions for common codelists
1393
+ async def find_reference_area(query: str, limit: int = 5) -> list[dict[str, Any]]:
1394
+ """Find reference areas (countries/regions) matching the query."""
1395
+ return await find_codelist_value("REF_AREA", query, limit)
1396
+
1397
+
1398
+ async def find_frequency(query: str, limit: int = 5) -> list[dict[str, Any]]:
1399
+ """Find frequency codes matching the query."""
1400
+ return await find_codelist_value("FREQ", query, limit)
1401
+
1402
+
1403
+ async def find_sex(query: str, limit: int = 5) -> list[dict[str, Any]]:
1404
+ """Find sex/gender codes matching the query."""
1405
+ return await find_codelist_value("SEX", query, limit)
1406
+
1407
+
1408
+ async def find_age_group(query: str, limit: int = 5) -> list[dict[str, Any]]:
1409
+ """Find age group codes matching the query."""
1410
+ return await find_codelist_value("AGE", query, limit)
1411
+
1412
+
1413
+ async def expand_country_group(
1414
+ group_code: str,
1415
+ ) -> dict[str, Any]:
1416
+ """Expand a REF_AREA group code into its constituent country codes.
1417
+
1418
+ Use this when a user asks about a region, income group, or lending category
1419
+ and you need individual country-level data rather than the aggregate.
1420
+
1421
+ Workflow for group discovery and expansion:
1422
+ 1. If you only have a natural-language name (e.g. "South Asian countries"),
1423
+ call data360_find_codelist_value(codelist_type="REF_AREA", query="<name>")
1424
+ first to resolve it to a group code (e.g. "SAS").
1425
+ 2. Pass that code to this function to get the full country list.
1426
+ 3. Use the returned country_codes string as disaggregation_filters['REF_AREA']
1427
+ (already comma-separated ISO codes), or pass the same codes via get_data's
1428
+ country_code using semicolons between codes (e.g. 'KEN;MAR').
1429
+
1430
+ Decision guidance — check the returned `count` after calling this function:
1431
+ - count <= 20: proceed with country-level data retrieval directly.
1432
+ - count > 20: inform the user before fetching all countries. Say:
1433
+ "This group contains N countries. Do you want individual country-level
1434
+ data for all of them, or would you prefer the regional aggregate?"
1435
+ Wait for their answer before proceeding.
1436
+
1437
+ Covers groups sourced from FMR H_REF_AREA_GROUPS v38.0:
1438
+ REGION - WB regional classifications (SAS, SSF, EAS, ECS, LCN, ...)
1439
+ INCOME - LIC, LMC, UMC, HIC, MIC, LMY, MIX
1440
+ LENDING - IDA, IBRD, blend classifications
1441
+ OTHER - FCS, LDC, SST, OED, EUU, ...
1442
+ REGION_UN - UN M49 statistical divisions
1443
+ CONTINENT - Continental groupings
1444
+
1445
+ Args:
1446
+ group_code: REF_AREA group code (e.g. "SAS", "LIC", "EAS", "FCS").
1447
+ Case-insensitive. Natural-language names (e.g. "south asian countries")
1448
+ are also accepted as a convenience; they are resolved via the alias map.
1449
+ For reliable discovery, use data360_find_codelist_value first.
1450
+
1451
+ Returns:
1452
+ Dict with:
1453
+ - group_code (str): uppercased code
1454
+ - group_name (str): human-readable name
1455
+ - group_type (str): REGION | INCOME | LENDING | OTHER | REGION_UN | CONTINENT
1456
+ - countries (list[dict]): [{code, name}] for each member country
1457
+ - country_codes (str): comma-separated codes for direct use in API calls
1458
+ - count (int): number of member countries
1459
+ - hierarchy_version (str): FMR hierarchy version used
1460
+ On failure:
1461
+ - error (str): description of what went wrong
1462
+ """
1463
+ ghm = get_group_hierarchy_manager()
1464
+ code_upper = group_code.strip().upper()
1465
+ info = ghm.get_group_info(code_upper)
1466
+
1467
+ if not info:
1468
+ # Try alias resolution as a fallback
1469
+ alias_key = group_code.lower().strip()
1470
+ if alias_key in _GROUP_ALIASES:
1471
+ code_upper = _GROUP_ALIASES[alias_key]
1472
+ info = ghm.get_group_info(code_upper)
1473
+
1474
+ if not info:
1475
+ return {
1476
+ "error": (
1477
+ f"Unknown group code: '{group_code}'. "
1478
+ "Use data360_find_codelist_value('REF_AREA', '<name>') to find "
1479
+ "the correct code, or check that the group exists in the FMR hierarchy."
1480
+ )
1481
+ }
1482
+
1483
+ # Build country list with names from the Data360 codelist (best-effort).
1484
+ # Codes are always available; names may be missing for some entries.
1485
+ cl_manager = get_codelist_manager()
1486
+ country_name_map: dict[str, str] = {}
1487
+ try:
1488
+ country_name_map = await cl_manager.get_codelist_mapping("REF_AREA")
1489
+ except Exception as e:
1490
+ # Names are a convenience; codes are always returned even without them.
1491
+ _logger.warning(
1492
+ "expand_country_group: failed to fetch REF_AREA name mapping for '%s': %s. "
1493
+ "Country names will fall back to code strings.",
1494
+ code_upper,
1495
+ e,
1496
+ )
1497
+
1498
+ countries = [
1499
+ {"code": c, "name": country_name_map.get(c, c)} for c in info["countries"]
1500
+ ]
1501
+
1502
+ meta = ghm.get_meta()
1503
+
1504
+ return {
1505
+ "group_code": code_upper,
1506
+ "group_name": info["name"],
1507
+ "group_type": info["type"],
1508
+ "countries": countries,
1509
+ "country_codes": ",".join(c["code"] for c in countries),
1510
+ "count": len(countries),
1511
+ "hierarchy_version": meta.get("hierarchy_version", "unknown"),
1512
+ }
src/data360/ref_area_groups.json ADDED
@@ -0,0 +1,5633 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "_meta": {
3
+ "source": "FMR H_REF_AREA_GROUPS v38.0 + CL_REF_GROUPINGS",
4
+ "built_at": "2026-04-26",
5
+ "hierarchy_version": "38.0",
6
+ "total_groups": 147,
7
+ "total_countries": 251,
8
+ "group_types": [
9
+ "CONTINENT",
10
+ "INCOME",
11
+ "LENDING",
12
+ "OTHER",
13
+ "REGION",
14
+ "REGION_UN"
15
+ ],
16
+ "note": "Re-generate with: uv run python scripts/build_ref_area_groups.py"
17
+ },
18
+ "groups": {
19
+ "AFE": {
20
+ "name": "Africa Eastern and Southern",
21
+ "type": "REGION",
22
+ "countries": [
23
+ "AGO",
24
+ "BDI",
25
+ "BWA",
26
+ "COD",
27
+ "COM",
28
+ "ERI",
29
+ "ETH",
30
+ "KEN",
31
+ "LSO",
32
+ "MDG",
33
+ "MOZ",
34
+ "MUS",
35
+ "MWI",
36
+ "NAM",
37
+ "RWA",
38
+ "SDN",
39
+ "SOM",
40
+ "SSD",
41
+ "STP",
42
+ "SWZ",
43
+ "SYC",
44
+ "TZA",
45
+ "UGA",
46
+ "ZAF",
47
+ "ZMB",
48
+ "ZWE"
49
+ ]
50
+ },
51
+ "AFW": {
52
+ "name": "Africa Western and Central",
53
+ "type": "REGION",
54
+ "countries": [
55
+ "BEN",
56
+ "BFA",
57
+ "CAF",
58
+ "CIV",
59
+ "CMR",
60
+ "COG",
61
+ "CPV",
62
+ "GAB",
63
+ "GHA",
64
+ "GIN",
65
+ "GMB",
66
+ "GNB",
67
+ "GNQ",
68
+ "LBR",
69
+ "MLI",
70
+ "MRT",
71
+ "NER",
72
+ "NGA",
73
+ "SEN",
74
+ "SLE",
75
+ "TCD",
76
+ "TGO"
77
+ ]
78
+ },
79
+ "ARB": {
80
+ "name": "Arab World",
81
+ "type": "REGION",
82
+ "countries": [
83
+ "ARE",
84
+ "BHR",
85
+ "COM",
86
+ "DJI",
87
+ "DZA",
88
+ "EGY",
89
+ "IRQ",
90
+ "JOR",
91
+ "KWT",
92
+ "LBN",
93
+ "LBY",
94
+ "MAR",
95
+ "MRT",
96
+ "OMN",
97
+ "PSE",
98
+ "QAT",
99
+ "SAU",
100
+ "SDN",
101
+ "SOM",
102
+ "SYR",
103
+ "TUN",
104
+ "YEM"
105
+ ]
106
+ },
107
+ "BEA": {
108
+ "name": "East Asia & Pacific (IBRD only)",
109
+ "type": "REGION",
110
+ "countries": [
111
+ "CHN",
112
+ "IDN",
113
+ "MNG",
114
+ "MYS",
115
+ "NRU",
116
+ "PHL",
117
+ "PLW",
118
+ "THA",
119
+ "VNM"
120
+ ]
121
+ },
122
+ "BEC": {
123
+ "name": "Europe & Central Asia (IBRD only)",
124
+ "type": "REGION",
125
+ "countries": [
126
+ "ALB",
127
+ "ARM",
128
+ "AZE",
129
+ "BGR",
130
+ "BIH",
131
+ "BLR",
132
+ "GEO",
133
+ "HRV",
134
+ "KAZ",
135
+ "MDA",
136
+ "MKD",
137
+ "MNE",
138
+ "POL",
139
+ "ROU",
140
+ "RUS",
141
+ "SRB",
142
+ "TKM",
143
+ "TUR",
144
+ "UKR"
145
+ ]
146
+ },
147
+ "BLA": {
148
+ "name": "Latin America & Caribbean (IBRD only)",
149
+ "type": "REGION",
150
+ "countries": [
151
+ "ARG",
152
+ "ATG",
153
+ "BLZ",
154
+ "BOL",
155
+ "BRA",
156
+ "CHL",
157
+ "COL",
158
+ "CRI",
159
+ "DOM",
160
+ "ECU",
161
+ "GTM",
162
+ "JAM",
163
+ "KNA",
164
+ "MEX",
165
+ "PAN",
166
+ "PER",
167
+ "PRY",
168
+ "SLV",
169
+ "SUR",
170
+ "TTO",
171
+ "URY",
172
+ "VEN"
173
+ ]
174
+ },
175
+ "BMN": {
176
+ "name": "Middle East, North Africa, Afghanistan & Pakistan (IBRD only)",
177
+ "type": "REGION",
178
+ "countries": [
179
+ "DZA",
180
+ "EGY",
181
+ "IRN",
182
+ "IRQ",
183
+ "JOR",
184
+ "LBN",
185
+ "LBY",
186
+ "MAR",
187
+ "TUN"
188
+ ]
189
+ },
190
+ "BSA": {
191
+ "name": "South Asia (IBRD only)",
192
+ "type": "REGION",
193
+ "countries": [
194
+ "IND"
195
+ ]
196
+ },
197
+ "BSS": {
198
+ "name": "Sub-Saharan Africa (IBRD only)",
199
+ "type": "REGION",
200
+ "countries": [
201
+ "AGO",
202
+ "BWA",
203
+ "GAB",
204
+ "GNQ",
205
+ "MUS",
206
+ "NAM",
207
+ "SWZ",
208
+ "SYC",
209
+ "ZAF"
210
+ ]
211
+ },
212
+ "CAM": {
213
+ "name": "Central America (excluding high income)",
214
+ "type": "REGION",
215
+ "countries": [
216
+ "BLZ",
217
+ "CRI",
218
+ "GTM",
219
+ "HND",
220
+ "MEX",
221
+ "NIC",
222
+ "SLV"
223
+ ]
224
+ },
225
+ "CAS": {
226
+ "name": "Central Asia (excluding high income)",
227
+ "type": "REGION",
228
+ "countries": [
229
+ "KAZ",
230
+ "KGZ",
231
+ "TJK",
232
+ "TKM",
233
+ "UZB"
234
+ ]
235
+ },
236
+ "CAT": {
237
+ "name": "Central Asia and Turkiye",
238
+ "type": "REGION",
239
+ "countries": [
240
+ "KAZ",
241
+ "KGZ",
242
+ "TJK",
243
+ "TKM",
244
+ "TUR",
245
+ "UZB"
246
+ ]
247
+ },
248
+ "CFR": {
249
+ "name": "Central Africa (excluding high income)",
250
+ "type": "REGION",
251
+ "countries": [
252
+ "AGO",
253
+ "CAF",
254
+ "CMR",
255
+ "COD",
256
+ "COG",
257
+ "GAB",
258
+ "GNQ",
259
+ "STP",
260
+ "TCD"
261
+ ]
262
+ },
263
+ "CMD": {
264
+ "name": "Middle East",
265
+ "type": "REGION",
266
+ "countries": [
267
+ "ARE",
268
+ "BHR",
269
+ "IRN",
270
+ "IRQ",
271
+ "JOR",
272
+ "KWT",
273
+ "LBN",
274
+ "OMN",
275
+ "PSE",
276
+ "QAT",
277
+ "SAU",
278
+ "SYR",
279
+ "YEM"
280
+ ]
281
+ },
282
+ "CNA": {
283
+ "name": "Central Asia",
284
+ "type": "REGION",
285
+ "countries": [
286
+ "KAZ",
287
+ "KGZ",
288
+ "TJK",
289
+ "TKM",
290
+ "UZB"
291
+ ]
292
+ },
293
+ "CRB": {
294
+ "name": "Caribbean (excluding high income)",
295
+ "type": "REGION",
296
+ "countries": [
297
+ "CUB",
298
+ "DMA",
299
+ "DOM",
300
+ "GRD",
301
+ "HTI",
302
+ "JAM",
303
+ "LCA",
304
+ "VCT"
305
+ ]
306
+ },
307
+ "DEA": {
308
+ "name": "East Asia & Pacific (IDA total)",
309
+ "type": "REGION",
310
+ "countries": [
311
+ "FJI",
312
+ "FSM",
313
+ "KHM",
314
+ "KIR",
315
+ "LAO",
316
+ "MHL",
317
+ "MMR",
318
+ "PNG",
319
+ "SLB",
320
+ "TLS",
321
+ "TON",
322
+ "TUV",
323
+ "VUT",
324
+ "WSM"
325
+ ]
326
+ },
327
+ "DEC": {
328
+ "name": "Europe & Central Asia (IDA total)",
329
+ "type": "REGION",
330
+ "countries": [
331
+ "KGZ",
332
+ "TJK",
333
+ "UZB",
334
+ "XKX"
335
+ ]
336
+ },
337
+ "DLA": {
338
+ "name": "Latin America & Caribbean (IDA total)",
339
+ "type": "REGION",
340
+ "countries": [
341
+ "DMA",
342
+ "GRD",
343
+ "GUY",
344
+ "HND",
345
+ "HTI",
346
+ "LCA",
347
+ "NIC",
348
+ "VCT"
349
+ ]
350
+ },
351
+ "DMN": {
352
+ "name": "Middle East, North Africa, Afghanistan & Pakistan (IDA total)",
353
+ "type": "REGION",
354
+ "countries": [
355
+ "AFG",
356
+ "DJI",
357
+ "PAK",
358
+ "SYR",
359
+ "YEM"
360
+ ]
361
+ },
362
+ "DSA": {
363
+ "name": "South Asia (IDA total)",
364
+ "type": "REGION",
365
+ "countries": [
366
+ "BGD",
367
+ "BTN",
368
+ "LKA",
369
+ "MDV",
370
+ "NPL"
371
+ ]
372
+ },
373
+ "DSS": {
374
+ "name": "Sub-Saharan Africa (IDA total)",
375
+ "type": "REGION",
376
+ "countries": [
377
+ "BDI",
378
+ "BEN",
379
+ "BFA",
380
+ "CAF",
381
+ "CIV",
382
+ "CMR",
383
+ "COD",
384
+ "COG",
385
+ "COM",
386
+ "CPV",
387
+ "ERI",
388
+ "ETH",
389
+ "GHA",
390
+ "GIN",
391
+ "GMB",
392
+ "GNB",
393
+ "KEN",
394
+ "LBR",
395
+ "LSO",
396
+ "MDG",
397
+ "MLI",
398
+ "MOZ",
399
+ "MRT",
400
+ "MWI",
401
+ "NER",
402
+ "NGA",
403
+ "RWA",
404
+ "SDN",
405
+ "SEN",
406
+ "SLE",
407
+ "SOM",
408
+ "SSD",
409
+ "STP",
410
+ "TCD",
411
+ "TGO",
412
+ "TZA",
413
+ "UGA",
414
+ "ZMB",
415
+ "ZWE"
416
+ ]
417
+ },
418
+ "EAP": {
419
+ "name": "East Asia & Pacific (excluding high income)",
420
+ "type": "REGION",
421
+ "countries": [
422
+ "CHN",
423
+ "FJI",
424
+ "FSM",
425
+ "IDN",
426
+ "KHM",
427
+ "KIR",
428
+ "LAO",
429
+ "MHL",
430
+ "MMR",
431
+ "MNG",
432
+ "MYS",
433
+ "PHL",
434
+ "PLW",
435
+ "PNG",
436
+ "PRK",
437
+ "SLB",
438
+ "THA",
439
+ "TLS",
440
+ "TON",
441
+ "TUV",
442
+ "VNM",
443
+ "VUT",
444
+ "WSM"
445
+ ]
446
+ },
447
+ "EAS": {
448
+ "name": "East Asia & Pacific",
449
+ "type": "REGION",
450
+ "countries": [
451
+ "ASM",
452
+ "AUS",
453
+ "BRN",
454
+ "CHN",
455
+ "FJI",
456
+ "FSM",
457
+ "GUM",
458
+ "HKG",
459
+ "IDN",
460
+ "JPN",
461
+ "KHM",
462
+ "KIR",
463
+ "KOR",
464
+ "LAO",
465
+ "MAC",
466
+ "MHL",
467
+ "MMR",
468
+ "MNG",
469
+ "MNP",
470
+ "MYS",
471
+ "NCL",
472
+ "NRU",
473
+ "NZL",
474
+ "PHL",
475
+ "PLW",
476
+ "PNG",
477
+ "PRK",
478
+ "PYF",
479
+ "SGP",
480
+ "SLB",
481
+ "THA",
482
+ "TLS",
483
+ "TON",
484
+ "TUV",
485
+ "TWN",
486
+ "VNM",
487
+ "VUT",
488
+ "WSM"
489
+ ]
490
+ },
491
+ "ECA": {
492
+ "name": "Europe & Central Asia (excluding high income)",
493
+ "type": "REGION",
494
+ "countries": [
495
+ "ALB",
496
+ "ARM",
497
+ "AZE",
498
+ "BGR",
499
+ "BIH",
500
+ "BLR",
501
+ "GEO",
502
+ "KAZ",
503
+ "KGZ",
504
+ "MDA",
505
+ "MKD",
506
+ "MNE",
507
+ "RUS",
508
+ "SRB",
509
+ "TJK",
510
+ "TKM",
511
+ "TUR",
512
+ "UKR",
513
+ "UZB",
514
+ "XKX"
515
+ ]
516
+ },
517
+ "ECS": {
518
+ "name": "Europe & Central Asia",
519
+ "type": "REGION",
520
+ "countries": [
521
+ "ALB",
522
+ "AND",
523
+ "ARM",
524
+ "AUT",
525
+ "AZE",
526
+ "BEL",
527
+ "BGR",
528
+ "BIH",
529
+ "BLR",
530
+ "CHE",
531
+ "CHI",
532
+ "CYP",
533
+ "CZE",
534
+ "DEU",
535
+ "DNK",
536
+ "ESP",
537
+ "EST",
538
+ "FIN",
539
+ "FRA",
540
+ "FRO",
541
+ "GBR",
542
+ "GEO",
543
+ "GIB",
544
+ "GRC",
545
+ "GRL",
546
+ "HRV",
547
+ "HUN",
548
+ "IMN",
549
+ "IRL",
550
+ "ISL",
551
+ "ITA",
552
+ "KAZ",
553
+ "KGZ",
554
+ "LIE",
555
+ "LTU",
556
+ "LUX",
557
+ "LVA",
558
+ "MCO",
559
+ "MDA",
560
+ "MKD",
561
+ "MNE",
562
+ "NLD",
563
+ "NOR",
564
+ "POL",
565
+ "PRT",
566
+ "ROU",
567
+ "RUS",
568
+ "SMR",
569
+ "SRB",
570
+ "SVK",
571
+ "SVN",
572
+ "SWE",
573
+ "TJK",
574
+ "TKM",
575
+ "TUR",
576
+ "UKR",
577
+ "UZB",
578
+ "XKX"
579
+ ]
580
+ },
581
+ "EER": {
582
+ "name": "Eastern Europe (excluding high income)",
583
+ "type": "REGION",
584
+ "countries": [
585
+ "BGR",
586
+ "BLR",
587
+ "MDA",
588
+ "RUS",
589
+ "UKR"
590
+ ]
591
+ },
592
+ "ESA": {
593
+ "name": "East and Southern Africa (excluding high income)",
594
+ "type": "REGION",
595
+ "countries": [
596
+ "BDI",
597
+ "BWA",
598
+ "COM",
599
+ "ERI",
600
+ "ETH",
601
+ "KEN",
602
+ "LSO",
603
+ "MDG",
604
+ "MOZ",
605
+ "MUS",
606
+ "MWI",
607
+ "NAM",
608
+ "RWA",
609
+ "SDN",
610
+ "SOM",
611
+ "SSD",
612
+ "SWZ",
613
+ "TZA",
614
+ "UGA",
615
+ "ZAF",
616
+ "ZMB",
617
+ "ZWE"
618
+ ]
619
+ },
620
+ "ESS": {
621
+ "name": "Eastern Asia (excluding high income)",
622
+ "type": "REGION",
623
+ "countries": [
624
+ "CHN",
625
+ "MNG",
626
+ "PRK"
627
+ ]
628
+ },
629
+ "LAC": {
630
+ "name": "Latin America & Caribbean (excluding high income)",
631
+ "type": "REGION",
632
+ "countries": [
633
+ "ARG",
634
+ "BLZ",
635
+ "BOL",
636
+ "BRA",
637
+ "COL",
638
+ "CRI",
639
+ "CUB",
640
+ "DMA",
641
+ "DOM",
642
+ "ECU",
643
+ "GRD",
644
+ "GTM",
645
+ "HND",
646
+ "HTI",
647
+ "JAM",
648
+ "LCA",
649
+ "MEX",
650
+ "NIC",
651
+ "PER",
652
+ "PRY",
653
+ "SLV",
654
+ "SUR",
655
+ "VCT"
656
+ ]
657
+ },
658
+ "LCN": {
659
+ "name": "Latin America & Caribbean",
660
+ "type": "REGION",
661
+ "countries": [
662
+ "ABW",
663
+ "ARG",
664
+ "ATG",
665
+ "BHS",
666
+ "BLZ",
667
+ "BOL",
668
+ "BRA",
669
+ "BRB",
670
+ "CHL",
671
+ "COL",
672
+ "CRI",
673
+ "CUB",
674
+ "CUW",
675
+ "CYM",
676
+ "DMA",
677
+ "DOM",
678
+ "ECU",
679
+ "GRD",
680
+ "GTM",
681
+ "GUY",
682
+ "HND",
683
+ "HTI",
684
+ "JAM",
685
+ "KNA",
686
+ "LCA",
687
+ "MAF",
688
+ "MEX",
689
+ "NIC",
690
+ "PAN",
691
+ "PER",
692
+ "PRI",
693
+ "PRY",
694
+ "SLV",
695
+ "SUR",
696
+ "SXM",
697
+ "TCA",
698
+ "TTO",
699
+ "URY",
700
+ "VCT",
701
+ "VEN",
702
+ "VGB",
703
+ "VIR"
704
+ ]
705
+ },
706
+ "MCT": {
707
+ "name": "Middle East, Central Asia and Turkiye",
708
+ "type": "REGION",
709
+ "countries": [
710
+ "AFG",
711
+ "ARE",
712
+ "BHR",
713
+ "IRN",
714
+ "IRQ",
715
+ "JOR",
716
+ "KAZ",
717
+ "KGZ",
718
+ "KWT",
719
+ "LBN",
720
+ "OMN",
721
+ "PAK",
722
+ "PSE",
723
+ "QAT",
724
+ "SAU",
725
+ "SYR",
726
+ "TJK",
727
+ "TKM",
728
+ "TUR",
729
+ "UZB",
730
+ "YEM"
731
+ ]
732
+ },
733
+ "MDE": {
734
+ "name": "Middle East (excluding high income)",
735
+ "type": "REGION",
736
+ "countries": [
737
+ "DJI",
738
+ "IRN",
739
+ "IRQ",
740
+ "JOR",
741
+ "LBN",
742
+ "PSE",
743
+ "SYR",
744
+ "YEM"
745
+ ]
746
+ },
747
+ "MEA": {
748
+ "name": "Middle East, North Africa, Afghanistan & Pakistan",
749
+ "type": "REGION",
750
+ "countries": [
751
+ "AFG",
752
+ "ARE",
753
+ "BHR",
754
+ "DJI",
755
+ "DZA",
756
+ "EGY",
757
+ "IRN",
758
+ "IRQ",
759
+ "ISR",
760
+ "JOR",
761
+ "KWT",
762
+ "LBN",
763
+ "LBY",
764
+ "MAR",
765
+ "MLT",
766
+ "OMN",
767
+ "PAK",
768
+ "PSE",
769
+ "QAT",
770
+ "SAU",
771
+ "SYR",
772
+ "TUN",
773
+ "YEM"
774
+ ]
775
+ },
776
+ "MNA": {
777
+ "name": "Middle East, North Africa, Afghanistan & Pakistan (excluding high income)",
778
+ "type": "REGION",
779
+ "countries": [
780
+ "AFG",
781
+ "DJI",
782
+ "DZA",
783
+ "EGY",
784
+ "IRN",
785
+ "IRQ",
786
+ "JOR",
787
+ "LBN",
788
+ "LBY",
789
+ "MAR",
790
+ "PAK",
791
+ "PSE",
792
+ "SYR",
793
+ "TUN",
794
+ "YEM"
795
+ ]
796
+ },
797
+ "MPA": {
798
+ "name": "Middle East, Pakistan, and Afghanistan",
799
+ "type": "REGION",
800
+ "countries": [
801
+ "AFG",
802
+ "ARE",
803
+ "BHR",
804
+ "IRN",
805
+ "IRQ",
806
+ "JOR",
807
+ "KWT",
808
+ "LBN",
809
+ "OMN",
810
+ "PAK",
811
+ "PSE",
812
+ "QAT",
813
+ "SAU",
814
+ "SYR",
815
+ "YEM"
816
+ ]
817
+ },
818
+ "NAC": {
819
+ "name": "North America",
820
+ "type": "REGION",
821
+ "countries": [
822
+ "BMU",
823
+ "CAN",
824
+ "USA"
825
+ ]
826
+ },
827
+ "NAF": {
828
+ "name": "North Africa",
829
+ "type": "REGION",
830
+ "countries": [
831
+ "DZA",
832
+ "EGY",
833
+ "LBY",
834
+ "MAR",
835
+ "TUN"
836
+ ]
837
+ },
838
+ "PAC": {
839
+ "name": "Pacific (excluding high income)",
840
+ "type": "REGION",
841
+ "countries": [
842
+ "FJI",
843
+ "FSM",
844
+ "KIR",
845
+ "MHL",
846
+ "PNG",
847
+ "SLB",
848
+ "TON",
849
+ "TUV",
850
+ "VUT",
851
+ "WSM"
852
+ ]
853
+ },
854
+ "SAM": {
855
+ "name": "South America (excluding high income)",
856
+ "type": "REGION",
857
+ "countries": [
858
+ "ARG",
859
+ "BOL",
860
+ "BRA",
861
+ "COL",
862
+ "ECU",
863
+ "PER",
864
+ "PRY",
865
+ "SUR"
866
+ ]
867
+ },
868
+ "SAS": {
869
+ "name": "South Asia",
870
+ "type": "REGION",
871
+ "countries": [
872
+ "BGD",
873
+ "BTN",
874
+ "IND",
875
+ "LKA",
876
+ "MDV",
877
+ "NPL"
878
+ ]
879
+ },
880
+ "SAX": {
881
+ "name": "South Asia, excluding India",
882
+ "type": "REGION",
883
+ "countries": [
884
+ "BGD",
885
+ "BTN",
886
+ "LKA",
887
+ "MDV",
888
+ "NPL"
889
+ ]
890
+ },
891
+ "SEA": {
892
+ "name": "South-Eastern Asia (excluding high income)",
893
+ "type": "REGION",
894
+ "countries": [
895
+ "IDN",
896
+ "KHM",
897
+ "LAO",
898
+ "MMR",
899
+ "MYS",
900
+ "PHL",
901
+ "THA",
902
+ "TLS",
903
+ "VNM"
904
+ ]
905
+ },
906
+ "SER": {
907
+ "name": "Southern Europe (excluding high income)",
908
+ "type": "REGION",
909
+ "countries": [
910
+ "ALB",
911
+ "ARM",
912
+ "AZE",
913
+ "BIH",
914
+ "GEO",
915
+ "MKD",
916
+ "MNE",
917
+ "SRB",
918
+ "TUR",
919
+ "XKX"
920
+ ]
921
+ },
922
+ "SSA": {
923
+ "name": "Sub-Saharan Africa (excluding high income)",
924
+ "type": "REGION",
925
+ "countries": [
926
+ "AGO",
927
+ "BDI",
928
+ "BEN",
929
+ "BFA",
930
+ "BWA",
931
+ "CAF",
932
+ "CIV",
933
+ "CMR",
934
+ "COD",
935
+ "COG",
936
+ "COM",
937
+ "CPV",
938
+ "ERI",
939
+ "ETH",
940
+ "GAB",
941
+ "GHA",
942
+ "GIN",
943
+ "GMB",
944
+ "GNB",
945
+ "GNQ",
946
+ "KEN",
947
+ "LBR",
948
+ "LSO",
949
+ "MDG",
950
+ "MLI",
951
+ "MOZ",
952
+ "MRT",
953
+ "MUS",
954
+ "MWI",
955
+ "NAM",
956
+ "NER",
957
+ "NGA",
958
+ "RWA",
959
+ "SDN",
960
+ "SEN",
961
+ "SLE",
962
+ "SOM",
963
+ "SSD",
964
+ "STP",
965
+ "SWZ",
966
+ "TCD",
967
+ "TGO",
968
+ "TZA",
969
+ "UGA",
970
+ "ZAF",
971
+ "ZMB",
972
+ "ZWE"
973
+ ]
974
+ },
975
+ "SSF": {
976
+ "name": "Sub-Saharan Africa",
977
+ "type": "REGION",
978
+ "countries": [
979
+ "AGO",
980
+ "BDI",
981
+ "BEN",
982
+ "BFA",
983
+ "BWA",
984
+ "CAF",
985
+ "CIV",
986
+ "CMR",
987
+ "COD",
988
+ "COG",
989
+ "COM",
990
+ "CPV",
991
+ "ERI",
992
+ "ETH",
993
+ "GAB",
994
+ "GHA",
995
+ "GIN",
996
+ "GMB",
997
+ "GNB",
998
+ "GNQ",
999
+ "KEN",
1000
+ "LBR",
1001
+ "LSO",
1002
+ "MDG",
1003
+ "MLI",
1004
+ "MOZ",
1005
+ "MRT",
1006
+ "MUS",
1007
+ "MWI",
1008
+ "NAM",
1009
+ "NER",
1010
+ "NGA",
1011
+ "RWA",
1012
+ "SDN",
1013
+ "SEN",
1014
+ "SLE",
1015
+ "SOM",
1016
+ "SSD",
1017
+ "STP",
1018
+ "SWZ",
1019
+ "SYC",
1020
+ "TCD",
1021
+ "TGO",
1022
+ "TZA",
1023
+ "UGA",
1024
+ "ZAF",
1025
+ "ZMB",
1026
+ "ZWE"
1027
+ ]
1028
+ },
1029
+ "TEA": {
1030
+ "name": "East Asia & Pacific (IDA & IBRD)",
1031
+ "type": "REGION",
1032
+ "countries": [
1033
+ "CHN",
1034
+ "FJI",
1035
+ "FSM",
1036
+ "IDN",
1037
+ "KHM",
1038
+ "KIR",
1039
+ "LAO",
1040
+ "MHL",
1041
+ "MMR",
1042
+ "MNG",
1043
+ "MYS",
1044
+ "NRU",
1045
+ "PHL",
1046
+ "PLW",
1047
+ "PNG",
1048
+ "SLB",
1049
+ "THA",
1050
+ "TLS",
1051
+ "TON",
1052
+ "TUV",
1053
+ "VNM",
1054
+ "VUT",
1055
+ "WSM"
1056
+ ]
1057
+ },
1058
+ "TEC": {
1059
+ "name": "Europe & Central Asia (IDA & IBRD)",
1060
+ "type": "REGION",
1061
+ "countries": [
1062
+ "ALB",
1063
+ "ARM",
1064
+ "AZE",
1065
+ "BGR",
1066
+ "BIH",
1067
+ "BLR",
1068
+ "GEO",
1069
+ "HRV",
1070
+ "KAZ",
1071
+ "KGZ",
1072
+ "MDA",
1073
+ "MKD",
1074
+ "MNE",
1075
+ "POL",
1076
+ "ROU",
1077
+ "RUS",
1078
+ "SRB",
1079
+ "TJK",
1080
+ "TKM",
1081
+ "TUR",
1082
+ "UKR",
1083
+ "UZB",
1084
+ "XKX"
1085
+ ]
1086
+ },
1087
+ "TLA": {
1088
+ "name": "Latin America & Caribbean (IDA & IBRD)",
1089
+ "type": "REGION",
1090
+ "countries": [
1091
+ "ARG",
1092
+ "ATG",
1093
+ "BLZ",
1094
+ "BOL",
1095
+ "BRA",
1096
+ "CHL",
1097
+ "COL",
1098
+ "CRI",
1099
+ "DMA",
1100
+ "DOM",
1101
+ "ECU",
1102
+ "GRD",
1103
+ "GTM",
1104
+ "GUY",
1105
+ "HND",
1106
+ "HTI",
1107
+ "JAM",
1108
+ "KNA",
1109
+ "LCA",
1110
+ "MEX",
1111
+ "NIC",
1112
+ "PAN",
1113
+ "PER",
1114
+ "PRY",
1115
+ "SLV",
1116
+ "SUR",
1117
+ "TTO",
1118
+ "URY",
1119
+ "VCT",
1120
+ "VEN"
1121
+ ]
1122
+ },
1123
+ "TMN": {
1124
+ "name": "Middle East, North Africa, Afghanistan & Pakistan (IDA & IBRD)",
1125
+ "type": "REGION",
1126
+ "countries": [
1127
+ "AFG",
1128
+ "DJI",
1129
+ "DZA",
1130
+ "EGY",
1131
+ "IRN",
1132
+ "IRQ",
1133
+ "JOR",
1134
+ "LBN",
1135
+ "LBY",
1136
+ "MAR",
1137
+ "PAK",
1138
+ "SYR",
1139
+ "TUN",
1140
+ "YEM"
1141
+ ]
1142
+ },
1143
+ "TSA": {
1144
+ "name": "South Asia (IDA & IBRD)",
1145
+ "type": "REGION",
1146
+ "countries": [
1147
+ "BGD",
1148
+ "BTN",
1149
+ "IND",
1150
+ "LKA",
1151
+ "MDV",
1152
+ "NPL"
1153
+ ]
1154
+ },
1155
+ "TSS": {
1156
+ "name": "Sub-Saharan Africa (IDA & IBRD)",
1157
+ "type": "REGION",
1158
+ "countries": [
1159
+ "AGO",
1160
+ "BDI",
1161
+ "BEN",
1162
+ "BFA",
1163
+ "BWA",
1164
+ "CAF",
1165
+ "CIV",
1166
+ "CMR",
1167
+ "COD",
1168
+ "COG",
1169
+ "COM",
1170
+ "CPV",
1171
+ "ERI",
1172
+ "ETH",
1173
+ "GAB",
1174
+ "GHA",
1175
+ "GIN",
1176
+ "GMB",
1177
+ "GNB",
1178
+ "GNQ",
1179
+ "KEN",
1180
+ "LBR",
1181
+ "LSO",
1182
+ "MDG",
1183
+ "MLI",
1184
+ "MOZ",
1185
+ "MRT",
1186
+ "MUS",
1187
+ "MWI",
1188
+ "NAM",
1189
+ "NER",
1190
+ "NGA",
1191
+ "RWA",
1192
+ "SDN",
1193
+ "SEN",
1194
+ "SLE",
1195
+ "SOM",
1196
+ "SSD",
1197
+ "STP",
1198
+ "SWZ",
1199
+ "SYC",
1200
+ "TCD",
1201
+ "TGO",
1202
+ "TZA",
1203
+ "UGA",
1204
+ "ZAF",
1205
+ "ZMB",
1206
+ "ZWE"
1207
+ ]
1208
+ },
1209
+ "WAF": {
1210
+ "name": "West Africa (excluding high income)",
1211
+ "type": "REGION",
1212
+ "countries": [
1213
+ "BEN",
1214
+ "BFA",
1215
+ "CIV",
1216
+ "CPV",
1217
+ "GHA",
1218
+ "GIN",
1219
+ "GMB",
1220
+ "GNB",
1221
+ "LBR",
1222
+ "MLI",
1223
+ "MRT",
1224
+ "NER",
1225
+ "NGA",
1226
+ "SEN",
1227
+ "SLE",
1228
+ "TGO"
1229
+ ]
1230
+ },
1231
+ "9AF": {
1232
+ "name": "UN M49 Africa",
1233
+ "type": "REGION_UN",
1234
+ "countries": [
1235
+ "AGO",
1236
+ "ATF",
1237
+ "BDI",
1238
+ "BEN",
1239
+ "BFA",
1240
+ "BWA",
1241
+ "CAF",
1242
+ "CIV",
1243
+ "CMR",
1244
+ "COD",
1245
+ "COG",
1246
+ "COM",
1247
+ "CPV",
1248
+ "DJI",
1249
+ "DZA",
1250
+ "EGY",
1251
+ "ERI",
1252
+ "ESH",
1253
+ "ETH",
1254
+ "GAB",
1255
+ "GHA",
1256
+ "GIN",
1257
+ "GMB",
1258
+ "GNB",
1259
+ "GNQ",
1260
+ "IOT",
1261
+ "KEN",
1262
+ "LBR",
1263
+ "LBY",
1264
+ "LSO",
1265
+ "MAR",
1266
+ "MDG",
1267
+ "MLI",
1268
+ "MOZ",
1269
+ "MRT",
1270
+ "MUS",
1271
+ "MWI",
1272
+ "MYT",
1273
+ "NAM",
1274
+ "NER",
1275
+ "NGA",
1276
+ "REU",
1277
+ "RWA",
1278
+ "SDN",
1279
+ "SEN",
1280
+ "SHN",
1281
+ "SLE",
1282
+ "SOM",
1283
+ "SSD",
1284
+ "STP",
1285
+ "SWZ",
1286
+ "SYC",
1287
+ "TCD",
1288
+ "TGO",
1289
+ "TUN",
1290
+ "TZA",
1291
+ "UGA",
1292
+ "ZAF",
1293
+ "ZMB",
1294
+ "ZWE"
1295
+ ]
1296
+ },
1297
+ "9AM": {
1298
+ "name": "UN M49 Americas",
1299
+ "type": "REGION_UN",
1300
+ "countries": [
1301
+ "ABW",
1302
+ "AIA",
1303
+ "ARG",
1304
+ "ATG",
1305
+ "BES",
1306
+ "BHS",
1307
+ "BLM",
1308
+ "BLZ",
1309
+ "BMU",
1310
+ "BOL",
1311
+ "BRA",
1312
+ "BRB",
1313
+ "BVT",
1314
+ "CAN",
1315
+ "CHL",
1316
+ "COL",
1317
+ "CRI",
1318
+ "CUB",
1319
+ "CUW",
1320
+ "CYM",
1321
+ "DMA",
1322
+ "DOM",
1323
+ "ECU",
1324
+ "FLK",
1325
+ "GLP",
1326
+ "GRD",
1327
+ "GRL",
1328
+ "GTM",
1329
+ "GUF",
1330
+ "GUY",
1331
+ "HND",
1332
+ "HTI",
1333
+ "JAM",
1334
+ "KNA",
1335
+ "LCA",
1336
+ "MAF",
1337
+ "MEX",
1338
+ "MSR",
1339
+ "MTQ",
1340
+ "NIC",
1341
+ "PAN",
1342
+ "PER",
1343
+ "PRI",
1344
+ "PRY",
1345
+ "SGS",
1346
+ "SLV",
1347
+ "SPM",
1348
+ "SUR",
1349
+ "SXM",
1350
+ "TCA",
1351
+ "TTO",
1352
+ "URY",
1353
+ "USA",
1354
+ "VCT",
1355
+ "VEN",
1356
+ "VGB",
1357
+ "VIR"
1358
+ ]
1359
+ },
1360
+ "9AS": {
1361
+ "name": "UN M49 Asia",
1362
+ "type": "REGION_UN",
1363
+ "countries": [
1364
+ "AFG",
1365
+ "ARE",
1366
+ "ARM",
1367
+ "AZE",
1368
+ "BGD",
1369
+ "BHR",
1370
+ "BRN",
1371
+ "BTN",
1372
+ "CHN",
1373
+ "CYP",
1374
+ "GEO",
1375
+ "HKG",
1376
+ "IDN",
1377
+ "IND",
1378
+ "IRN",
1379
+ "IRQ",
1380
+ "ISR",
1381
+ "JOR",
1382
+ "JPN",
1383
+ "KAZ",
1384
+ "KGZ",
1385
+ "KHM",
1386
+ "KOR",
1387
+ "KWT",
1388
+ "LAO",
1389
+ "LBN",
1390
+ "LKA",
1391
+ "MAC",
1392
+ "MDV",
1393
+ "MMR",
1394
+ "MNG",
1395
+ "MYS",
1396
+ "NPL",
1397
+ "OMN",
1398
+ "PAK",
1399
+ "PHL",
1400
+ "PRK",
1401
+ "PSE",
1402
+ "QAT",
1403
+ "SAU",
1404
+ "SGP",
1405
+ "SYR",
1406
+ "THA",
1407
+ "TJK",
1408
+ "TKM",
1409
+ "TLS",
1410
+ "TUR",
1411
+ "UZB",
1412
+ "VNM",
1413
+ "YEM"
1414
+ ]
1415
+ },
1416
+ "9AZ": {
1417
+ "name": "UN M49 Australia and New Zealand",
1418
+ "type": "REGION_UN",
1419
+ "countries": [
1420
+ "AUS",
1421
+ "CCK",
1422
+ "CXR",
1423
+ "HMD",
1424
+ "NFK",
1425
+ "NZL"
1426
+ ]
1427
+ },
1428
+ "9CA": {
1429
+ "name": "UN M49 Central Asia",
1430
+ "type": "REGION_UN",
1431
+ "countries": [
1432
+ "KAZ",
1433
+ "KGZ",
1434
+ "TJK",
1435
+ "TKM",
1436
+ "UZB"
1437
+ ]
1438
+ },
1439
+ "9CM": {
1440
+ "name": "UN M49 Central America",
1441
+ "type": "REGION_UN",
1442
+ "countries": [
1443
+ "BLZ",
1444
+ "CRI",
1445
+ "GTM",
1446
+ "HND",
1447
+ "MEX",
1448
+ "NIC",
1449
+ "PAN",
1450
+ "SLV"
1451
+ ]
1452
+ },
1453
+ "9CR": {
1454
+ "name": "UN M49 Caribbean",
1455
+ "type": "REGION_UN",
1456
+ "countries": [
1457
+ "ABW",
1458
+ "AIA",
1459
+ "ATG",
1460
+ "BES",
1461
+ "BHS",
1462
+ "BLM",
1463
+ "BRB",
1464
+ "CUB",
1465
+ "CUW",
1466
+ "CYM",
1467
+ "DMA",
1468
+ "DOM",
1469
+ "GLP",
1470
+ "GRD",
1471
+ "HTI",
1472
+ "JAM",
1473
+ "KNA",
1474
+ "LCA",
1475
+ "MAF",
1476
+ "MSR",
1477
+ "MTQ",
1478
+ "PRI",
1479
+ "SXM",
1480
+ "TCA",
1481
+ "TTO",
1482
+ "VCT",
1483
+ "VGB",
1484
+ "VIR"
1485
+ ]
1486
+ },
1487
+ "9CS": {
1488
+ "name": "UN M49 Central Asia and Southern Asia (MDG=M49)",
1489
+ "type": "REGION_UN",
1490
+ "countries": [
1491
+ "AFG",
1492
+ "BGD",
1493
+ "BTN",
1494
+ "IND",
1495
+ "IRN",
1496
+ "KAZ",
1497
+ "KGZ",
1498
+ "LKA",
1499
+ "MDV",
1500
+ "NPL",
1501
+ "PAK",
1502
+ "TJK",
1503
+ "TKM",
1504
+ "UZB"
1505
+ ]
1506
+ },
1507
+ "9EA": {
1508
+ "name": "UN M49 Eastern Asia",
1509
+ "type": "REGION_UN",
1510
+ "countries": [
1511
+ "CHN",
1512
+ "HKG",
1513
+ "JPN",
1514
+ "KOR",
1515
+ "MAC",
1516
+ "MNG",
1517
+ "PRK"
1518
+ ]
1519
+ },
1520
+ "9EE": {
1521
+ "name": "UN M49 Eastern Europe",
1522
+ "type": "REGION_UN",
1523
+ "countries": [
1524
+ "BGR",
1525
+ "BLR",
1526
+ "CZE",
1527
+ "HUN",
1528
+ "MDA",
1529
+ "POL",
1530
+ "ROU",
1531
+ "RUS",
1532
+ "SVK",
1533
+ "UKR"
1534
+ ]
1535
+ },
1536
+ "9EF": {
1537
+ "name": "UN M49 Eastern Africa",
1538
+ "type": "REGION_UN",
1539
+ "countries": [
1540
+ "ATF",
1541
+ "BDI",
1542
+ "COM",
1543
+ "DJI",
1544
+ "ERI",
1545
+ "ETH",
1546
+ "IOT",
1547
+ "KEN",
1548
+ "MDG",
1549
+ "MOZ",
1550
+ "MUS",
1551
+ "MWI",
1552
+ "MYT",
1553
+ "REU",
1554
+ "RWA",
1555
+ "SOM",
1556
+ "SSD",
1557
+ "SYC",
1558
+ "TZA",
1559
+ "UGA",
1560
+ "ZMB",
1561
+ "ZWE"
1562
+ ]
1563
+ },
1564
+ "9ES": {
1565
+ "name": "UN M49 Eastern Asia and South-eastern Asia (MDG=M49)",
1566
+ "type": "REGION_UN",
1567
+ "countries": [
1568
+ "BRN",
1569
+ "CHN",
1570
+ "HKG",
1571
+ "IDN",
1572
+ "JPN",
1573
+ "KHM",
1574
+ "KOR",
1575
+ "LAO",
1576
+ "MAC",
1577
+ "MMR",
1578
+ "MNG",
1579
+ "MYS",
1580
+ "PHL",
1581
+ "PRK",
1582
+ "SGP",
1583
+ "THA",
1584
+ "TLS",
1585
+ "VNM"
1586
+ ]
1587
+ },
1588
+ "9EU": {
1589
+ "name": "UN M49 Europe",
1590
+ "type": "REGION_UN",
1591
+ "countries": [
1592
+ "ALA",
1593
+ "ALB",
1594
+ "AND",
1595
+ "AUT",
1596
+ "BEL",
1597
+ "BGR",
1598
+ "BIH",
1599
+ "BLR",
1600
+ "CHE",
1601
+ "CZE",
1602
+ "DEU",
1603
+ "DNK",
1604
+ "ESP",
1605
+ "EST",
1606
+ "FIN",
1607
+ "FRA",
1608
+ "FRO",
1609
+ "GBR",
1610
+ "GGY",
1611
+ "GIB",
1612
+ "GRC",
1613
+ "HRV",
1614
+ "HUN",
1615
+ "IMN",
1616
+ "IRL",
1617
+ "ISL",
1618
+ "ITA",
1619
+ "JEY",
1620
+ "LIE",
1621
+ "LTU",
1622
+ "LUX",
1623
+ "LVA",
1624
+ "MCO",
1625
+ "MDA",
1626
+ "MKD",
1627
+ "MLT",
1628
+ "MNE",
1629
+ "NLD",
1630
+ "NOR",
1631
+ "POL",
1632
+ "PRT",
1633
+ "ROU",
1634
+ "RUS",
1635
+ "SJM",
1636
+ "SMR",
1637
+ "SRB",
1638
+ "SVK",
1639
+ "SVN",
1640
+ "SWE",
1641
+ "UKR",
1642
+ "VAT"
1643
+ ]
1644
+ },
1645
+ "9LC": {
1646
+ "name": "UN M49 Latin America and the Caribbean (MDG=M49)",
1647
+ "type": "REGION_UN",
1648
+ "countries": [
1649
+ "ABW",
1650
+ "AIA",
1651
+ "ARG",
1652
+ "ATG",
1653
+ "BES",
1654
+ "BHS",
1655
+ "BLM",
1656
+ "BLZ",
1657
+ "BOL",
1658
+ "BRA",
1659
+ "BRB",
1660
+ "BVT",
1661
+ "CHL",
1662
+ "COL",
1663
+ "CRI",
1664
+ "CUB",
1665
+ "CUW",
1666
+ "CYM",
1667
+ "DMA",
1668
+ "DOM",
1669
+ "ECU",
1670
+ "FLK",
1671
+ "GLP",
1672
+ "GRD",
1673
+ "GTM",
1674
+ "GUF",
1675
+ "GUY",
1676
+ "HND",
1677
+ "HTI",
1678
+ "JAM",
1679
+ "KNA",
1680
+ "LCA",
1681
+ "MAF",
1682
+ "MEX",
1683
+ "MSR",
1684
+ "MTQ",
1685
+ "NIC",
1686
+ "PAN",
1687
+ "PER",
1688
+ "PRI",
1689
+ "PRY",
1690
+ "SGS",
1691
+ "SLV",
1692
+ "SUR",
1693
+ "SXM",
1694
+ "TCA",
1695
+ "TTO",
1696
+ "URY",
1697
+ "VCT",
1698
+ "VEN",
1699
+ "VGB",
1700
+ "VIR"
1701
+ ]
1702
+ },
1703
+ "9MC": {
1704
+ "name": "UN M49 Micronesia",
1705
+ "type": "REGION_UN",
1706
+ "countries": [
1707
+ "FSM",
1708
+ "GUM",
1709
+ "KIR",
1710
+ "MHL",
1711
+ "MNP",
1712
+ "NRU",
1713
+ "PLW",
1714
+ "UMI"
1715
+ ]
1716
+ },
1717
+ "9MF": {
1718
+ "name": "UN M49 Middle Africa",
1719
+ "type": "REGION_UN",
1720
+ "countries": [
1721
+ "AGO",
1722
+ "CAF",
1723
+ "CMR",
1724
+ "COD",
1725
+ "COG",
1726
+ "GAB",
1727
+ "GNQ",
1728
+ "STP",
1729
+ "TCD"
1730
+ ]
1731
+ },
1732
+ "9ML": {
1733
+ "name": "UN M49 Melanesia",
1734
+ "type": "REGION_UN",
1735
+ "countries": [
1736
+ "FJI",
1737
+ "NCL",
1738
+ "PNG",
1739
+ "SLB",
1740
+ "VUT"
1741
+ ]
1742
+ },
1743
+ "9NE": {
1744
+ "name": "UN M49 Northern America and Europe",
1745
+ "type": "REGION_UN",
1746
+ "countries": [
1747
+ "ALA",
1748
+ "ALB",
1749
+ "AND",
1750
+ "AUT",
1751
+ "BEL",
1752
+ "BGR",
1753
+ "BIH",
1754
+ "BLR",
1755
+ "BMU",
1756
+ "CAN",
1757
+ "CHE",
1758
+ "CZE",
1759
+ "DEU",
1760
+ "DNK",
1761
+ "ESP",
1762
+ "EST",
1763
+ "FIN",
1764
+ "FRA",
1765
+ "FRO",
1766
+ "GBR",
1767
+ "GGY",
1768
+ "GIB",
1769
+ "GRC",
1770
+ "GRL",
1771
+ "HRV",
1772
+ "HUN",
1773
+ "IMN",
1774
+ "IRL",
1775
+ "ISL",
1776
+ "ITA",
1777
+ "JEY",
1778
+ "LIE",
1779
+ "LTU",
1780
+ "LUX",
1781
+ "LVA",
1782
+ "MCO",
1783
+ "MDA",
1784
+ "MKD",
1785
+ "MLT",
1786
+ "MNE",
1787
+ "NLD",
1788
+ "NOR",
1789
+ "POL",
1790
+ "PRT",
1791
+ "ROU",
1792
+ "RUS",
1793
+ "SJM",
1794
+ "SMR",
1795
+ "SPM",
1796
+ "SRB",
1797
+ "SVK",
1798
+ "SVN",
1799
+ "SWE",
1800
+ "UKR",
1801
+ "USA",
1802
+ "VAT"
1803
+ ]
1804
+ },
1805
+ "9NF": {
1806
+ "name": "UN M49 Northern Africa",
1807
+ "type": "REGION_UN",
1808
+ "countries": [
1809
+ "DZA",
1810
+ "EGY",
1811
+ "ESH",
1812
+ "LBY",
1813
+ "MAR",
1814
+ "SDN",
1815
+ "TUN"
1816
+ ]
1817
+ },
1818
+ "9NM": {
1819
+ "name": "UN M49 Northern America",
1820
+ "type": "REGION_UN",
1821
+ "countries": [
1822
+ "BMU",
1823
+ "CAN",
1824
+ "GRL",
1825
+ "SPM",
1826
+ "USA"
1827
+ ]
1828
+ },
1829
+ "9NU": {
1830
+ "name": "UN M49 Northern Europe",
1831
+ "type": "REGION_UN",
1832
+ "countries": [
1833
+ "ALA",
1834
+ "DNK",
1835
+ "EST",
1836
+ "FIN",
1837
+ "FRO",
1838
+ "GBR",
1839
+ "GGY",
1840
+ "IMN",
1841
+ "IRL",
1842
+ "ISL",
1843
+ "JEY",
1844
+ "LTU",
1845
+ "LVA",
1846
+ "NOR",
1847
+ "SJM",
1848
+ "SWE"
1849
+ ]
1850
+ },
1851
+ "9OC": {
1852
+ "name": "UN M49 Oceania",
1853
+ "type": "REGION_UN",
1854
+ "countries": [
1855
+ "ASM",
1856
+ "AUS",
1857
+ "CCK",
1858
+ "COK",
1859
+ "CXR",
1860
+ "FJI",
1861
+ "FSM",
1862
+ "GUM",
1863
+ "HMD",
1864
+ "KIR",
1865
+ "MHL",
1866
+ "MNP",
1867
+ "NCL",
1868
+ "NFK",
1869
+ "NIU",
1870
+ "NRU",
1871
+ "NZL",
1872
+ "PCN",
1873
+ "PLW",
1874
+ "PNG",
1875
+ "PYF",
1876
+ "SLB",
1877
+ "TKL",
1878
+ "TON",
1879
+ "TUV",
1880
+ "UMI",
1881
+ "VUT",
1882
+ "WLF",
1883
+ "WSM"
1884
+ ]
1885
+ },
1886
+ "9OX": {
1887
+ "name": "UN M49 Oceania excluding Australia and New Zealand",
1888
+ "type": "REGION_UN",
1889
+ "countries": [
1890
+ "ASM",
1891
+ "COK",
1892
+ "FJI",
1893
+ "FSM",
1894
+ "GUM",
1895
+ "KIR",
1896
+ "MHL",
1897
+ "MNP",
1898
+ "NCL",
1899
+ "NIU",
1900
+ "NRU",
1901
+ "PCN",
1902
+ "PLW",
1903
+ "PNG",
1904
+ "PYF",
1905
+ "SLB",
1906
+ "TKL",
1907
+ "TON",
1908
+ "TUV",
1909
+ "UMI",
1910
+ "VUT",
1911
+ "WLF",
1912
+ "WSM"
1913
+ ]
1914
+ },
1915
+ "9PL": {
1916
+ "name": "UN M49 Polynesia",
1917
+ "type": "REGION_UN",
1918
+ "countries": [
1919
+ "ASM",
1920
+ "COK",
1921
+ "NIU",
1922
+ "PCN",
1923
+ "PYF",
1924
+ "TKL",
1925
+ "TON",
1926
+ "TUV",
1927
+ "WLF",
1928
+ "WSM"
1929
+ ]
1930
+ },
1931
+ "9SA": {
1932
+ "name": "UN M49 Southern Asia (MDG=M49)",
1933
+ "type": "REGION_UN",
1934
+ "countries": [
1935
+ "AFG",
1936
+ "BGD",
1937
+ "BTN",
1938
+ "IND",
1939
+ "IRN",
1940
+ "LKA",
1941
+ "MDV",
1942
+ "NPL",
1943
+ "PAK"
1944
+ ]
1945
+ },
1946
+ "9SE": {
1947
+ "name": "UN M49 South-eastern Asia (MDG=M49)",
1948
+ "type": "REGION_UN",
1949
+ "countries": [
1950
+ "BRN",
1951
+ "IDN",
1952
+ "KHM",
1953
+ "LAO",
1954
+ "MMR",
1955
+ "MYS",
1956
+ "PHL",
1957
+ "SGP",
1958
+ "THA",
1959
+ "TLS",
1960
+ "VNM"
1961
+ ]
1962
+ },
1963
+ "9SF": {
1964
+ "name": "UN M49 Southern Africa",
1965
+ "type": "REGION_UN",
1966
+ "countries": [
1967
+ "BWA",
1968
+ "LSO",
1969
+ "NAM",
1970
+ "SWZ",
1971
+ "ZAF"
1972
+ ]
1973
+ },
1974
+ "9SM": {
1975
+ "name": "UN M49 South America",
1976
+ "type": "REGION_UN",
1977
+ "countries": [
1978
+ "ARG",
1979
+ "BOL",
1980
+ "BRA",
1981
+ "BVT",
1982
+ "CHL",
1983
+ "COL",
1984
+ "ECU",
1985
+ "FLK",
1986
+ "GUF",
1987
+ "GUY",
1988
+ "PER",
1989
+ "PRY",
1990
+ "SGS",
1991
+ "SUR",
1992
+ "URY",
1993
+ "VEN"
1994
+ ]
1995
+ },
1996
+ "9SS": {
1997
+ "name": "UN M49 Sub-Saharan Africa",
1998
+ "type": "REGION_UN",
1999
+ "countries": [
2000
+ "AGO",
2001
+ "ATF",
2002
+ "BDI",
2003
+ "BEN",
2004
+ "BFA",
2005
+ "BWA",
2006
+ "CAF",
2007
+ "CIV",
2008
+ "CMR",
2009
+ "COD",
2010
+ "COG",
2011
+ "COM",
2012
+ "CPV",
2013
+ "DJI",
2014
+ "ERI",
2015
+ "ETH",
2016
+ "GAB",
2017
+ "GHA",
2018
+ "GIN",
2019
+ "GMB",
2020
+ "GNB",
2021
+ "GNQ",
2022
+ "IOT",
2023
+ "KEN",
2024
+ "LBR",
2025
+ "LSO",
2026
+ "MDG",
2027
+ "MLI",
2028
+ "MOZ",
2029
+ "MRT",
2030
+ "MUS",
2031
+ "MWI",
2032
+ "MYT",
2033
+ "NAM",
2034
+ "NER",
2035
+ "NGA",
2036
+ "REU",
2037
+ "RWA",
2038
+ "SEN",
2039
+ "SHN",
2040
+ "SLE",
2041
+ "SOM",
2042
+ "SSD",
2043
+ "STP",
2044
+ "SWZ",
2045
+ "SYC",
2046
+ "TCD",
2047
+ "TGO",
2048
+ "TZA",
2049
+ "UGA",
2050
+ "ZAF",
2051
+ "ZMB",
2052
+ "ZWE"
2053
+ ]
2054
+ },
2055
+ "9SU": {
2056
+ "name": "UN M49 Southern Europe",
2057
+ "type": "REGION_UN",
2058
+ "countries": [
2059
+ "ALB",
2060
+ "AND",
2061
+ "BIH",
2062
+ "ESP",
2063
+ "GIB",
2064
+ "GRC",
2065
+ "HRV",
2066
+ "ITA",
2067
+ "MKD",
2068
+ "MLT",
2069
+ "MNE",
2070
+ "PRT",
2071
+ "SMR",
2072
+ "SRB",
2073
+ "SVN",
2074
+ "VAT"
2075
+ ]
2076
+ },
2077
+ "9WA": {
2078
+ "name": "UN M49 Western Asia",
2079
+ "type": "REGION_UN",
2080
+ "countries": [
2081
+ "ARE",
2082
+ "ARM",
2083
+ "AZE",
2084
+ "BHR",
2085
+ "CYP",
2086
+ "GEO",
2087
+ "IRQ",
2088
+ "ISR",
2089
+ "JOR",
2090
+ "KWT",
2091
+ "LBN",
2092
+ "OMN",
2093
+ "PSE",
2094
+ "QAT",
2095
+ "SAU",
2096
+ "SYR",
2097
+ "TUR",
2098
+ "YEM"
2099
+ ]
2100
+ },
2101
+ "9WE": {
2102
+ "name": "UN M49 Western Europe",
2103
+ "type": "REGION_UN",
2104
+ "countries": [
2105
+ "AUT",
2106
+ "BEL",
2107
+ "CHE",
2108
+ "DEU",
2109
+ "FRA",
2110
+ "LIE",
2111
+ "LUX",
2112
+ "MCO",
2113
+ "NLD"
2114
+ ]
2115
+ },
2116
+ "9WF": {
2117
+ "name": "UN M49 Western Africa",
2118
+ "type": "REGION_UN",
2119
+ "countries": [
2120
+ "BEN",
2121
+ "BFA",
2122
+ "CIV",
2123
+ "CPV",
2124
+ "GHA",
2125
+ "GIN",
2126
+ "GMB",
2127
+ "GNB",
2128
+ "LBR",
2129
+ "MLI",
2130
+ "MRT",
2131
+ "NER",
2132
+ "NGA",
2133
+ "SEN",
2134
+ "SHN",
2135
+ "SLE",
2136
+ "TGO"
2137
+ ]
2138
+ },
2139
+ "9WL": {
2140
+ "name": "UN M49 World",
2141
+ "type": "REGION_UN",
2142
+ "countries": [
2143
+ "ABW",
2144
+ "AFG",
2145
+ "AGO",
2146
+ "AIA",
2147
+ "ALA",
2148
+ "ALB",
2149
+ "AND",
2150
+ "ARE",
2151
+ "ARG",
2152
+ "ARM",
2153
+ "ASM",
2154
+ "ATF",
2155
+ "ATG",
2156
+ "AUS",
2157
+ "AUT",
2158
+ "AZE",
2159
+ "BDI",
2160
+ "BEL",
2161
+ "BEN",
2162
+ "BES",
2163
+ "BFA",
2164
+ "BGD",
2165
+ "BGR",
2166
+ "BHR",
2167
+ "BHS",
2168
+ "BIH",
2169
+ "BLM",
2170
+ "BLR",
2171
+ "BLZ",
2172
+ "BMU",
2173
+ "BOL",
2174
+ "BRA",
2175
+ "BRB",
2176
+ "BRN",
2177
+ "BTN",
2178
+ "BVT",
2179
+ "BWA",
2180
+ "CAF",
2181
+ "CAN",
2182
+ "CCK",
2183
+ "CHE",
2184
+ "CHL",
2185
+ "CHN",
2186
+ "CIV",
2187
+ "CMR",
2188
+ "COD",
2189
+ "COG",
2190
+ "COK",
2191
+ "COL",
2192
+ "COM",
2193
+ "CPV",
2194
+ "CRI",
2195
+ "CUB",
2196
+ "CUW",
2197
+ "CXR",
2198
+ "CYM",
2199
+ "CYP",
2200
+ "CZE",
2201
+ "DEU",
2202
+ "DJI",
2203
+ "DMA",
2204
+ "DNK",
2205
+ "DOM",
2206
+ "DZA",
2207
+ "ECU",
2208
+ "EGY",
2209
+ "ERI",
2210
+ "ESH",
2211
+ "ESP",
2212
+ "EST",
2213
+ "ETH",
2214
+ "FIN",
2215
+ "FJI",
2216
+ "FLK",
2217
+ "FRA",
2218
+ "FRO",
2219
+ "FSM",
2220
+ "GAB",
2221
+ "GBR",
2222
+ "GEO",
2223
+ "GGY",
2224
+ "GHA",
2225
+ "GIB",
2226
+ "GIN",
2227
+ "GLP",
2228
+ "GMB",
2229
+ "GNB",
2230
+ "GNQ",
2231
+ "GRC",
2232
+ "GRD",
2233
+ "GRL",
2234
+ "GTM",
2235
+ "GUF",
2236
+ "GUM",
2237
+ "GUY",
2238
+ "HKG",
2239
+ "HMD",
2240
+ "HND",
2241
+ "HRV",
2242
+ "HTI",
2243
+ "HUN",
2244
+ "IDN",
2245
+ "IMN",
2246
+ "IND",
2247
+ "IOT",
2248
+ "IRL",
2249
+ "IRN",
2250
+ "IRQ",
2251
+ "ISL",
2252
+ "ISR",
2253
+ "ITA",
2254
+ "JAM",
2255
+ "JEY",
2256
+ "JOR",
2257
+ "JPN",
2258
+ "KAZ",
2259
+ "KEN",
2260
+ "KGZ",
2261
+ "KHM",
2262
+ "KIR",
2263
+ "KNA",
2264
+ "KOR",
2265
+ "KWT",
2266
+ "LAO",
2267
+ "LBN",
2268
+ "LBR",
2269
+ "LBY",
2270
+ "LCA",
2271
+ "LIE",
2272
+ "LKA",
2273
+ "LSO",
2274
+ "LTU",
2275
+ "LUX",
2276
+ "LVA",
2277
+ "MAC",
2278
+ "MAF",
2279
+ "MAR",
2280
+ "MCO",
2281
+ "MDA",
2282
+ "MDG",
2283
+ "MDV",
2284
+ "MEX",
2285
+ "MHL",
2286
+ "MKD",
2287
+ "MLI",
2288
+ "MLT",
2289
+ "MMR",
2290
+ "MNE",
2291
+ "MNG",
2292
+ "MNP",
2293
+ "MOZ",
2294
+ "MRT",
2295
+ "MSR",
2296
+ "MTQ",
2297
+ "MUS",
2298
+ "MWI",
2299
+ "MYS",
2300
+ "MYT",
2301
+ "NAM",
2302
+ "NCL",
2303
+ "NER",
2304
+ "NFK",
2305
+ "NGA",
2306
+ "NIC",
2307
+ "NIU",
2308
+ "NLD",
2309
+ "NOR",
2310
+ "NPL",
2311
+ "NRU",
2312
+ "NZL",
2313
+ "OMN",
2314
+ "PAK",
2315
+ "PAN",
2316
+ "PCN",
2317
+ "PER",
2318
+ "PHL",
2319
+ "PLW",
2320
+ "PNG",
2321
+ "POL",
2322
+ "PRI",
2323
+ "PRK",
2324
+ "PRT",
2325
+ "PRY",
2326
+ "PSE",
2327
+ "PYF",
2328
+ "QAT",
2329
+ "REU",
2330
+ "ROU",
2331
+ "RUS",
2332
+ "RWA",
2333
+ "SAU",
2334
+ "SDN",
2335
+ "SEN",
2336
+ "SGP",
2337
+ "SGS",
2338
+ "SHN",
2339
+ "SJM",
2340
+ "SLB",
2341
+ "SLE",
2342
+ "SLV",
2343
+ "SMR",
2344
+ "SOM",
2345
+ "SPM",
2346
+ "SRB",
2347
+ "SSD",
2348
+ "STP",
2349
+ "SUR",
2350
+ "SVK",
2351
+ "SVN",
2352
+ "SWE",
2353
+ "SWZ",
2354
+ "SXM",
2355
+ "SYC",
2356
+ "SYR",
2357
+ "TCA",
2358
+ "TCD",
2359
+ "TGO",
2360
+ "THA",
2361
+ "TJK",
2362
+ "TKL",
2363
+ "TKM",
2364
+ "TLS",
2365
+ "TON",
2366
+ "TTO",
2367
+ "TUN",
2368
+ "TUR",
2369
+ "TUV",
2370
+ "TZA",
2371
+ "UGA",
2372
+ "UKR",
2373
+ "UMI",
2374
+ "URY",
2375
+ "USA",
2376
+ "UZB",
2377
+ "VAT",
2378
+ "VCT",
2379
+ "VEN",
2380
+ "VGB",
2381
+ "VIR",
2382
+ "VNM",
2383
+ "VUT",
2384
+ "WLF",
2385
+ "WSM",
2386
+ "YEM",
2387
+ "ZAF",
2388
+ "ZMB",
2389
+ "ZWE"
2390
+ ]
2391
+ },
2392
+ "9WN": {
2393
+ "name": "UN M49 Western Asia and Northern Africa",
2394
+ "type": "REGION_UN",
2395
+ "countries": [
2396
+ "ARE",
2397
+ "ARM",
2398
+ "AZE",
2399
+ "BHR",
2400
+ "CYP",
2401
+ "DZA",
2402
+ "EGY",
2403
+ "ESH",
2404
+ "GEO",
2405
+ "IRQ",
2406
+ "ISR",
2407
+ "JOR",
2408
+ "KWT",
2409
+ "LBN",
2410
+ "LBY",
2411
+ "MAR",
2412
+ "OMN",
2413
+ "PSE",
2414
+ "QAT",
2415
+ "SAU",
2416
+ "SDN",
2417
+ "SYR",
2418
+ "TUN",
2419
+ "TUR",
2420
+ "YEM"
2421
+ ]
2422
+ },
2423
+ "24F": {
2424
+ "name": "FY24 IDA countries classified as fragile situations",
2425
+ "type": "LENDING",
2426
+ "countries": [
2427
+ "AFG",
2428
+ "BDI",
2429
+ "BFA",
2430
+ "CAF",
2431
+ "CMR",
2432
+ "COD",
2433
+ "COG",
2434
+ "COM",
2435
+ "ERI",
2436
+ "ETH",
2437
+ "FSM",
2438
+ "GNB",
2439
+ "HTI",
2440
+ "KIR",
2441
+ "MHL",
2442
+ "MLI",
2443
+ "MMR",
2444
+ "MOZ",
2445
+ "NER",
2446
+ "NGA",
2447
+ "PNG",
2448
+ "SDN",
2449
+ "SLB",
2450
+ "SOM",
2451
+ "SSD",
2452
+ "STP",
2453
+ "SYR",
2454
+ "TCD",
2455
+ "TLS",
2456
+ "TUV",
2457
+ "XKX",
2458
+ "YEM",
2459
+ "ZWE"
2460
+ ]
2461
+ },
2462
+ "24T": {
2463
+ "name": "FY24 IDA & IBRD countries classified as fragile situations",
2464
+ "type": "LENDING",
2465
+ "countries": [
2466
+ "AFG",
2467
+ "BDI",
2468
+ "BFA",
2469
+ "CAF",
2470
+ "CMR",
2471
+ "COD",
2472
+ "COG",
2473
+ "COM",
2474
+ "ERI",
2475
+ "ETH",
2476
+ "FSM",
2477
+ "GNB",
2478
+ "HTI",
2479
+ "IRQ",
2480
+ "KIR",
2481
+ "LBN",
2482
+ "LBY",
2483
+ "MHL",
2484
+ "MLI",
2485
+ "MMR",
2486
+ "MOZ",
2487
+ "NER",
2488
+ "NGA",
2489
+ "PNG",
2490
+ "SDN",
2491
+ "SLB",
2492
+ "SOM",
2493
+ "SSD",
2494
+ "STP",
2495
+ "SYR",
2496
+ "TCD",
2497
+ "TLS",
2498
+ "TUV",
2499
+ "UKR",
2500
+ "VEN",
2501
+ "XKX",
2502
+ "YEM",
2503
+ "ZWE"
2504
+ ]
2505
+ },
2506
+ "25F": {
2507
+ "name": "FY25 IDA countries classified as fragile situations",
2508
+ "type": "LENDING",
2509
+ "countries": [
2510
+ "AFG",
2511
+ "BDI",
2512
+ "BFA",
2513
+ "CAF",
2514
+ "CMR",
2515
+ "COD",
2516
+ "COG",
2517
+ "COM",
2518
+ "ERI",
2519
+ "ETH",
2520
+ "FSM",
2521
+ "GNB",
2522
+ "HTI",
2523
+ "KIR",
2524
+ "MHL",
2525
+ "MLI",
2526
+ "MMR",
2527
+ "MOZ",
2528
+ "NER",
2529
+ "NGA",
2530
+ "PNG",
2531
+ "SDN",
2532
+ "SLB",
2533
+ "SOM",
2534
+ "SSD",
2535
+ "STP",
2536
+ "SYR",
2537
+ "TCD",
2538
+ "TLS",
2539
+ "TUV",
2540
+ "XKX",
2541
+ "YEM",
2542
+ "ZWE"
2543
+ ]
2544
+ },
2545
+ "BHI": {
2546
+ "name": "IBRD countries classified as high income",
2547
+ "type": "LENDING",
2548
+ "countries": [
2549
+ "ATG",
2550
+ "CHL",
2551
+ "HRV",
2552
+ "KNA",
2553
+ "NRU",
2554
+ "PAN",
2555
+ "POL",
2556
+ "ROU",
2557
+ "SYC",
2558
+ "TTO",
2559
+ "URY"
2560
+ ]
2561
+ },
2562
+ "DFS": {
2563
+ "name": "IDA countries classified as fragile situations",
2564
+ "type": "LENDING",
2565
+ "countries": [
2566
+ "AFG",
2567
+ "BDI",
2568
+ "BFA",
2569
+ "CAF",
2570
+ "CMR",
2571
+ "COD",
2572
+ "COG",
2573
+ "COM",
2574
+ "ERI",
2575
+ "ETH",
2576
+ "FSM",
2577
+ "GNB",
2578
+ "HTI",
2579
+ "KIR",
2580
+ "MHL",
2581
+ "MLI",
2582
+ "MMR",
2583
+ "MOZ",
2584
+ "NER",
2585
+ "NGA",
2586
+ "PNG",
2587
+ "SDN",
2588
+ "SLB",
2589
+ "SOM",
2590
+ "SSD",
2591
+ "STP",
2592
+ "SYR",
2593
+ "TCD",
2594
+ "TLS",
2595
+ "TUV",
2596
+ "XKX",
2597
+ "YEM",
2598
+ "ZWE"
2599
+ ]
2600
+ },
2601
+ "DNF": {
2602
+ "name": "IDA countries not classified as fragile situations",
2603
+ "type": "LENDING",
2604
+ "countries": [
2605
+ "BEN",
2606
+ "BGD",
2607
+ "BTN",
2608
+ "CIV",
2609
+ "CPV",
2610
+ "DJI",
2611
+ "DMA",
2612
+ "FJI",
2613
+ "GHA",
2614
+ "GIN",
2615
+ "GMB",
2616
+ "GRD",
2617
+ "GUY",
2618
+ "HND",
2619
+ "KEN",
2620
+ "KGZ",
2621
+ "KHM",
2622
+ "LAO",
2623
+ "LBR",
2624
+ "LCA",
2625
+ "LKA",
2626
+ "LSO",
2627
+ "MDG",
2628
+ "MDV",
2629
+ "MRT",
2630
+ "MWI",
2631
+ "NIC",
2632
+ "NPL",
2633
+ "PAK",
2634
+ "RWA",
2635
+ "SEN",
2636
+ "SLE",
2637
+ "TGO",
2638
+ "TJK",
2639
+ "TON",
2640
+ "TZA",
2641
+ "UGA",
2642
+ "UZB",
2643
+ "VCT",
2644
+ "VUT",
2645
+ "WSM",
2646
+ "ZMB"
2647
+ ]
2648
+ },
2649
+ "IBB": {
2650
+ "name": "IBRD, including blend",
2651
+ "type": "LENDING",
2652
+ "countries": [
2653
+ "AGO",
2654
+ "ALB",
2655
+ "ARG",
2656
+ "ARM",
2657
+ "ATG",
2658
+ "AZE",
2659
+ "BGR",
2660
+ "BIH",
2661
+ "BLR",
2662
+ "BLZ",
2663
+ "BOL",
2664
+ "BRA",
2665
+ "BWA",
2666
+ "CHL",
2667
+ "CHN",
2668
+ "CMR",
2669
+ "COG",
2670
+ "COL",
2671
+ "CPV",
2672
+ "CRI",
2673
+ "DMA",
2674
+ "DOM",
2675
+ "DZA",
2676
+ "ECU",
2677
+ "EGY",
2678
+ "FJI",
2679
+ "GAB",
2680
+ "GEO",
2681
+ "GNQ",
2682
+ "GRD",
2683
+ "GTM",
2684
+ "HRV",
2685
+ "IDN",
2686
+ "IND",
2687
+ "IRN",
2688
+ "IRQ",
2689
+ "JAM",
2690
+ "JOR",
2691
+ "KAZ",
2692
+ "KEN",
2693
+ "KNA",
2694
+ "LBN",
2695
+ "LBY",
2696
+ "LCA",
2697
+ "MAR",
2698
+ "MDA",
2699
+ "MEX",
2700
+ "MKD",
2701
+ "MNE",
2702
+ "MNG",
2703
+ "MUS",
2704
+ "MYS",
2705
+ "NAM",
2706
+ "NGA",
2707
+ "NRU",
2708
+ "PAK",
2709
+ "PAN",
2710
+ "PER",
2711
+ "PHL",
2712
+ "PLW",
2713
+ "PNG",
2714
+ "POL",
2715
+ "PRY",
2716
+ "ROU",
2717
+ "RUS",
2718
+ "SLV",
2719
+ "SRB",
2720
+ "SUR",
2721
+ "SWZ",
2722
+ "SYC",
2723
+ "THA",
2724
+ "TKM",
2725
+ "TLS",
2726
+ "TTO",
2727
+ "TUN",
2728
+ "TUR",
2729
+ "UKR",
2730
+ "URY",
2731
+ "UZB",
2732
+ "VCT",
2733
+ "VEN",
2734
+ "VNM",
2735
+ "ZAF",
2736
+ "ZWE"
2737
+ ]
2738
+ },
2739
+ "IBD": {
2740
+ "name": "IBRD only",
2741
+ "type": "LENDING",
2742
+ "countries": [
2743
+ "AGO",
2744
+ "ALB",
2745
+ "ARG",
2746
+ "ARM",
2747
+ "ATG",
2748
+ "AZE",
2749
+ "BGR",
2750
+ "BIH",
2751
+ "BLR",
2752
+ "BLZ",
2753
+ "BOL",
2754
+ "BRA",
2755
+ "BWA",
2756
+ "CHL",
2757
+ "CHN",
2758
+ "COL",
2759
+ "CRI",
2760
+ "DOM",
2761
+ "DZA",
2762
+ "ECU",
2763
+ "EGY",
2764
+ "GAB",
2765
+ "GEO",
2766
+ "GNQ",
2767
+ "GTM",
2768
+ "HRV",
2769
+ "IDN",
2770
+ "IND",
2771
+ "IRN",
2772
+ "IRQ",
2773
+ "JAM",
2774
+ "JOR",
2775
+ "KAZ",
2776
+ "KNA",
2777
+ "LBN",
2778
+ "LBY",
2779
+ "MAR",
2780
+ "MDA",
2781
+ "MEX",
2782
+ "MKD",
2783
+ "MNE",
2784
+ "MNG",
2785
+ "MUS",
2786
+ "MYS",
2787
+ "NAM",
2788
+ "NRU",
2789
+ "PAN",
2790
+ "PER",
2791
+ "PHL",
2792
+ "PLW",
2793
+ "POL",
2794
+ "PRY",
2795
+ "ROU",
2796
+ "RUS",
2797
+ "SLV",
2798
+ "SRB",
2799
+ "SUR",
2800
+ "SWZ",
2801
+ "SYC",
2802
+ "THA",
2803
+ "TKM",
2804
+ "TTO",
2805
+ "TUN",
2806
+ "TUR",
2807
+ "UKR",
2808
+ "URY",
2809
+ "VEN",
2810
+ "VNM",
2811
+ "ZAF"
2812
+ ]
2813
+ },
2814
+ "IBT": {
2815
+ "name": "IDA & IBRD total",
2816
+ "type": "LENDING",
2817
+ "countries": [
2818
+ "AFG",
2819
+ "AGO",
2820
+ "ALB",
2821
+ "ARG",
2822
+ "ARM",
2823
+ "ATG",
2824
+ "AZE",
2825
+ "BDI",
2826
+ "BEN",
2827
+ "BFA",
2828
+ "BGD",
2829
+ "BGR",
2830
+ "BIH",
2831
+ "BLR",
2832
+ "BLZ",
2833
+ "BOL",
2834
+ "BRA",
2835
+ "BTN",
2836
+ "BWA",
2837
+ "CAF",
2838
+ "CHL",
2839
+ "CHN",
2840
+ "CIV",
2841
+ "CMR",
2842
+ "COD",
2843
+ "COG",
2844
+ "COL",
2845
+ "COM",
2846
+ "CPV",
2847
+ "CRI",
2848
+ "DJI",
2849
+ "DMA",
2850
+ "DOM",
2851
+ "DZA",
2852
+ "ECU",
2853
+ "EGY",
2854
+ "ERI",
2855
+ "ETH",
2856
+ "FJI",
2857
+ "FSM",
2858
+ "GAB",
2859
+ "GEO",
2860
+ "GHA",
2861
+ "GIN",
2862
+ "GMB",
2863
+ "GNB",
2864
+ "GNQ",
2865
+ "GRD",
2866
+ "GTM",
2867
+ "GUY",
2868
+ "HND",
2869
+ "HRV",
2870
+ "HTI",
2871
+ "IDN",
2872
+ "IND",
2873
+ "IRN",
2874
+ "IRQ",
2875
+ "JAM",
2876
+ "JOR",
2877
+ "KAZ",
2878
+ "KEN",
2879
+ "KGZ",
2880
+ "KHM",
2881
+ "KIR",
2882
+ "KNA",
2883
+ "LAO",
2884
+ "LBN",
2885
+ "LBR",
2886
+ "LBY",
2887
+ "LCA",
2888
+ "LKA",
2889
+ "LSO",
2890
+ "MAR",
2891
+ "MDA",
2892
+ "MDG",
2893
+ "MDV",
2894
+ "MEX",
2895
+ "MHL",
2896
+ "MKD",
2897
+ "MLI",
2898
+ "MMR",
2899
+ "MNE",
2900
+ "MNG",
2901
+ "MOZ",
2902
+ "MRT",
2903
+ "MUS",
2904
+ "MWI",
2905
+ "MYS",
2906
+ "NAM",
2907
+ "NER",
2908
+ "NGA",
2909
+ "NIC",
2910
+ "NPL",
2911
+ "NRU",
2912
+ "PAK",
2913
+ "PAN",
2914
+ "PER",
2915
+ "PHL",
2916
+ "PLW",
2917
+ "PNG",
2918
+ "POL",
2919
+ "PRY",
2920
+ "ROU",
2921
+ "RUS",
2922
+ "RWA",
2923
+ "SDN",
2924
+ "SEN",
2925
+ "SLB",
2926
+ "SLE",
2927
+ "SLV",
2928
+ "SOM",
2929
+ "SRB",
2930
+ "SSD",
2931
+ "STP",
2932
+ "SUR",
2933
+ "SWZ",
2934
+ "SYC",
2935
+ "SYR",
2936
+ "TCD",
2937
+ "TGO",
2938
+ "THA",
2939
+ "TJK",
2940
+ "TKM",
2941
+ "TLS",
2942
+ "TON",
2943
+ "TTO",
2944
+ "TUN",
2945
+ "TUR",
2946
+ "TUV",
2947
+ "TZA",
2948
+ "UGA",
2949
+ "UKR",
2950
+ "URY",
2951
+ "UZB",
2952
+ "VCT",
2953
+ "VEN",
2954
+ "VNM",
2955
+ "VUT",
2956
+ "WSM",
2957
+ "XKX",
2958
+ "YEM",
2959
+ "ZAF",
2960
+ "ZMB",
2961
+ "ZWE"
2962
+ ]
2963
+ },
2964
+ "IDA": {
2965
+ "name": "IDA total",
2966
+ "type": "LENDING",
2967
+ "countries": [
2968
+ "AFG",
2969
+ "BDI",
2970
+ "BEN",
2971
+ "BFA",
2972
+ "BGD",
2973
+ "BTN",
2974
+ "CAF",
2975
+ "CIV",
2976
+ "CMR",
2977
+ "COD",
2978
+ "COG",
2979
+ "COM",
2980
+ "CPV",
2981
+ "DJI",
2982
+ "DMA",
2983
+ "ERI",
2984
+ "ETH",
2985
+ "FJI",
2986
+ "FSM",
2987
+ "GHA",
2988
+ "GIN",
2989
+ "GMB",
2990
+ "GNB",
2991
+ "GRD",
2992
+ "GUY",
2993
+ "HND",
2994
+ "HTI",
2995
+ "KEN",
2996
+ "KGZ",
2997
+ "KHM",
2998
+ "KIR",
2999
+ "LAO",
3000
+ "LBR",
3001
+ "LCA",
3002
+ "LKA",
3003
+ "LSO",
3004
+ "MDG",
3005
+ "MDV",
3006
+ "MHL",
3007
+ "MLI",
3008
+ "MMR",
3009
+ "MOZ",
3010
+ "MRT",
3011
+ "MWI",
3012
+ "NER",
3013
+ "NGA",
3014
+ "NIC",
3015
+ "NPL",
3016
+ "PAK",
3017
+ "PNG",
3018
+ "RWA",
3019
+ "SDN",
3020
+ "SEN",
3021
+ "SLB",
3022
+ "SLE",
3023
+ "SOM",
3024
+ "SSD",
3025
+ "STP",
3026
+ "SYR",
3027
+ "TCD",
3028
+ "TGO",
3029
+ "TJK",
3030
+ "TLS",
3031
+ "TON",
3032
+ "TUV",
3033
+ "TZA",
3034
+ "UGA",
3035
+ "UZB",
3036
+ "VCT",
3037
+ "VUT",
3038
+ "WSM",
3039
+ "XKX",
3040
+ "YEM",
3041
+ "ZMB",
3042
+ "ZWE"
3043
+ ]
3044
+ },
3045
+ "IDB": {
3046
+ "name": "IDA blend",
3047
+ "type": "LENDING",
3048
+ "countries": [
3049
+ "CMR",
3050
+ "COG",
3051
+ "CPV",
3052
+ "DMA",
3053
+ "FJI",
3054
+ "GRD",
3055
+ "KEN",
3056
+ "LCA",
3057
+ "NGA",
3058
+ "PAK",
3059
+ "PNG",
3060
+ "TLS",
3061
+ "UZB",
3062
+ "VCT",
3063
+ "ZWE"
3064
+ ]
3065
+ },
3066
+ "IDX": {
3067
+ "name": "IDA only",
3068
+ "type": "LENDING",
3069
+ "countries": [
3070
+ "AFG",
3071
+ "BDI",
3072
+ "BEN",
3073
+ "BFA",
3074
+ "BGD",
3075
+ "BTN",
3076
+ "CAF",
3077
+ "CIV",
3078
+ "COD",
3079
+ "COM",
3080
+ "DJI",
3081
+ "ERI",
3082
+ "ETH",
3083
+ "FSM",
3084
+ "GHA",
3085
+ "GIN",
3086
+ "GMB",
3087
+ "GNB",
3088
+ "GUY",
3089
+ "HND",
3090
+ "HTI",
3091
+ "KGZ",
3092
+ "KHM",
3093
+ "KIR",
3094
+ "LAO",
3095
+ "LBR",
3096
+ "LKA",
3097
+ "LSO",
3098
+ "MDG",
3099
+ "MDV",
3100
+ "MHL",
3101
+ "MLI",
3102
+ "MMR",
3103
+ "MOZ",
3104
+ "MRT",
3105
+ "MWI",
3106
+ "NER",
3107
+ "NIC",
3108
+ "NPL",
3109
+ "RWA",
3110
+ "SDN",
3111
+ "SEN",
3112
+ "SLB",
3113
+ "SLE",
3114
+ "SOM",
3115
+ "SSD",
3116
+ "STP",
3117
+ "SYR",
3118
+ "TCD",
3119
+ "TGO",
3120
+ "TJK",
3121
+ "TON",
3122
+ "TUV",
3123
+ "TZA",
3124
+ "UGA",
3125
+ "VUT",
3126
+ "WSM",
3127
+ "XKX",
3128
+ "YEM",
3129
+ "ZMB"
3130
+ ]
3131
+ },
3132
+ "IFS": {
3133
+ "name": "IDA & IBRD countries classified as fragile situations",
3134
+ "type": "LENDING",
3135
+ "countries": [
3136
+ "AFG",
3137
+ "BDI",
3138
+ "BFA",
3139
+ "CAF",
3140
+ "CMR",
3141
+ "COD",
3142
+ "COG",
3143
+ "COM",
3144
+ "ERI",
3145
+ "ETH",
3146
+ "FSM",
3147
+ "GNB",
3148
+ "HTI",
3149
+ "IRQ",
3150
+ "KIR",
3151
+ "LBN",
3152
+ "LBY",
3153
+ "MHL",
3154
+ "MLI",
3155
+ "MMR",
3156
+ "MOZ",
3157
+ "NER",
3158
+ "NGA",
3159
+ "PNG",
3160
+ "SDN",
3161
+ "SLB",
3162
+ "SOM",
3163
+ "SSD",
3164
+ "STP",
3165
+ "SYR",
3166
+ "TCD",
3167
+ "TLS",
3168
+ "TUV",
3169
+ "UKR",
3170
+ "VEN",
3171
+ "XKX",
3172
+ "YEM",
3173
+ "ZWE"
3174
+ ]
3175
+ },
3176
+ "HIC": {
3177
+ "name": "High income",
3178
+ "type": "INCOME",
3179
+ "countries": [
3180
+ "ABW",
3181
+ "AND",
3182
+ "ARE",
3183
+ "ASM",
3184
+ "ATG",
3185
+ "AUS",
3186
+ "AUT",
3187
+ "BEL",
3188
+ "BHR",
3189
+ "BHS",
3190
+ "BMU",
3191
+ "BRB",
3192
+ "BRN",
3193
+ "CAN",
3194
+ "CHE",
3195
+ "CHI",
3196
+ "CHL",
3197
+ "CUW",
3198
+ "CYM",
3199
+ "CYP",
3200
+ "CZE",
3201
+ "DEU",
3202
+ "DNK",
3203
+ "ESP",
3204
+ "EST",
3205
+ "FIN",
3206
+ "FRA",
3207
+ "FRO",
3208
+ "GBR",
3209
+ "GIB",
3210
+ "GRC",
3211
+ "GRL",
3212
+ "GUM",
3213
+ "GUY",
3214
+ "HKG",
3215
+ "HRV",
3216
+ "HUN",
3217
+ "IMN",
3218
+ "IRL",
3219
+ "ISL",
3220
+ "ISR",
3221
+ "ITA",
3222
+ "JPN",
3223
+ "KNA",
3224
+ "KOR",
3225
+ "KWT",
3226
+ "LIE",
3227
+ "LTU",
3228
+ "LUX",
3229
+ "LVA",
3230
+ "MAC",
3231
+ "MAF",
3232
+ "MCO",
3233
+ "MLT",
3234
+ "MNP",
3235
+ "NCL",
3236
+ "NLD",
3237
+ "NOR",
3238
+ "NRU",
3239
+ "NZL",
3240
+ "OMN",
3241
+ "PAN",
3242
+ "POL",
3243
+ "PRI",
3244
+ "PRT",
3245
+ "PYF",
3246
+ "QAT",
3247
+ "ROU",
3248
+ "SAU",
3249
+ "SGP",
3250
+ "SMR",
3251
+ "SVK",
3252
+ "SVN",
3253
+ "SWE",
3254
+ "SXM",
3255
+ "SYC",
3256
+ "TCA",
3257
+ "TTO",
3258
+ "TWN",
3259
+ "URY",
3260
+ "USA",
3261
+ "VGB",
3262
+ "VIR"
3263
+ ]
3264
+ },
3265
+ "LIC": {
3266
+ "name": "Low income",
3267
+ "type": "INCOME",
3268
+ "countries": [
3269
+ "AFG",
3270
+ "BDI",
3271
+ "BFA",
3272
+ "CAF",
3273
+ "COD",
3274
+ "ERI",
3275
+ "ETH",
3276
+ "GMB",
3277
+ "GNB",
3278
+ "LBR",
3279
+ "MDG",
3280
+ "MLI",
3281
+ "MOZ",
3282
+ "MWI",
3283
+ "NER",
3284
+ "PRK",
3285
+ "RWA",
3286
+ "SDN",
3287
+ "SLE",
3288
+ "SOM",
3289
+ "SSD",
3290
+ "SYR",
3291
+ "TCD",
3292
+ "TGO",
3293
+ "UGA",
3294
+ "YEM"
3295
+ ]
3296
+ },
3297
+ "LMC": {
3298
+ "name": "Lower middle income",
3299
+ "type": "INCOME",
3300
+ "countries": [
3301
+ "AGO",
3302
+ "BEN",
3303
+ "BGD",
3304
+ "BOL",
3305
+ "BTN",
3306
+ "CIV",
3307
+ "CMR",
3308
+ "COG",
3309
+ "COM",
3310
+ "CPV",
3311
+ "DJI",
3312
+ "DZA",
3313
+ "EGY",
3314
+ "FSM",
3315
+ "GHA",
3316
+ "GIN",
3317
+ "HND",
3318
+ "HTI",
3319
+ "IND",
3320
+ "IRN",
3321
+ "JOR",
3322
+ "KEN",
3323
+ "KGZ",
3324
+ "KHM",
3325
+ "KIR",
3326
+ "LAO",
3327
+ "LBN",
3328
+ "LKA",
3329
+ "LSO",
3330
+ "MAR",
3331
+ "MMR",
3332
+ "MNG",
3333
+ "MRT",
3334
+ "NGA",
3335
+ "NIC",
3336
+ "NPL",
3337
+ "PAK",
3338
+ "PHL",
3339
+ "PNG",
3340
+ "SEN",
3341
+ "SLB",
3342
+ "STP",
3343
+ "SWZ",
3344
+ "TJK",
3345
+ "TLS",
3346
+ "TUN",
3347
+ "TZA",
3348
+ "UKR",
3349
+ "UZB",
3350
+ "VNM",
3351
+ "VUT",
3352
+ "WSM",
3353
+ "ZMB",
3354
+ "ZWE"
3355
+ ]
3356
+ },
3357
+ "LMY": {
3358
+ "name": "Low & middle income",
3359
+ "type": "INCOME",
3360
+ "countries": [
3361
+ "AFG",
3362
+ "AGO",
3363
+ "ALB",
3364
+ "ARG",
3365
+ "ARM",
3366
+ "AZE",
3367
+ "BDI",
3368
+ "BEN",
3369
+ "BFA",
3370
+ "BGD",
3371
+ "BGR",
3372
+ "BIH",
3373
+ "BLR",
3374
+ "BLZ",
3375
+ "BOL",
3376
+ "BRA",
3377
+ "BTN",
3378
+ "BWA",
3379
+ "CAF",
3380
+ "CHN",
3381
+ "CIV",
3382
+ "CMR",
3383
+ "COD",
3384
+ "COG",
3385
+ "COL",
3386
+ "COM",
3387
+ "CPV",
3388
+ "CRI",
3389
+ "CUB",
3390
+ "DJI",
3391
+ "DMA",
3392
+ "DOM",
3393
+ "DZA",
3394
+ "ECU",
3395
+ "EGY",
3396
+ "ERI",
3397
+ "ETH",
3398
+ "FJI",
3399
+ "FSM",
3400
+ "GAB",
3401
+ "GEO",
3402
+ "GHA",
3403
+ "GIN",
3404
+ "GMB",
3405
+ "GNB",
3406
+ "GNQ",
3407
+ "GRD",
3408
+ "GTM",
3409
+ "HND",
3410
+ "HTI",
3411
+ "IDN",
3412
+ "IND",
3413
+ "IRN",
3414
+ "IRQ",
3415
+ "JAM",
3416
+ "JOR",
3417
+ "KAZ",
3418
+ "KEN",
3419
+ "KGZ",
3420
+ "KHM",
3421
+ "KIR",
3422
+ "LAO",
3423
+ "LBN",
3424
+ "LBR",
3425
+ "LBY",
3426
+ "LCA",
3427
+ "LKA",
3428
+ "LSO",
3429
+ "MAR",
3430
+ "MDA",
3431
+ "MDG",
3432
+ "MDV",
3433
+ "MEX",
3434
+ "MHL",
3435
+ "MKD",
3436
+ "MLI",
3437
+ "MMR",
3438
+ "MNE",
3439
+ "MNG",
3440
+ "MOZ",
3441
+ "MRT",
3442
+ "MUS",
3443
+ "MWI",
3444
+ "MYS",
3445
+ "NAM",
3446
+ "NER",
3447
+ "NGA",
3448
+ "NIC",
3449
+ "NPL",
3450
+ "PAK",
3451
+ "PER",
3452
+ "PHL",
3453
+ "PLW",
3454
+ "PNG",
3455
+ "PRK",
3456
+ "PRY",
3457
+ "PSE",
3458
+ "RUS",
3459
+ "RWA",
3460
+ "SDN",
3461
+ "SEN",
3462
+ "SLB",
3463
+ "SLE",
3464
+ "SLV",
3465
+ "SOM",
3466
+ "SRB",
3467
+ "SSD",
3468
+ "STP",
3469
+ "SUR",
3470
+ "SWZ",
3471
+ "SYR",
3472
+ "TCD",
3473
+ "TGO",
3474
+ "THA",
3475
+ "TJK",
3476
+ "TKM",
3477
+ "TLS",
3478
+ "TON",
3479
+ "TUN",
3480
+ "TUR",
3481
+ "TUV",
3482
+ "TZA",
3483
+ "UGA",
3484
+ "UKR",
3485
+ "UZB",
3486
+ "VCT",
3487
+ "VNM",
3488
+ "VUT",
3489
+ "WSM",
3490
+ "XKX",
3491
+ "YEM",
3492
+ "ZAF",
3493
+ "ZMB",
3494
+ "ZWE"
3495
+ ]
3496
+ },
3497
+ "MIC": {
3498
+ "name": "Middle income",
3499
+ "type": "INCOME",
3500
+ "countries": [
3501
+ "AGO",
3502
+ "ALB",
3503
+ "ARG",
3504
+ "ARM",
3505
+ "AZE",
3506
+ "BEN",
3507
+ "BGD",
3508
+ "BGR",
3509
+ "BIH",
3510
+ "BLR",
3511
+ "BLZ",
3512
+ "BOL",
3513
+ "BRA",
3514
+ "BTN",
3515
+ "BWA",
3516
+ "CHN",
3517
+ "CIV",
3518
+ "CMR",
3519
+ "COG",
3520
+ "COL",
3521
+ "COM",
3522
+ "CPV",
3523
+ "CRI",
3524
+ "CUB",
3525
+ "DJI",
3526
+ "DMA",
3527
+ "DOM",
3528
+ "DZA",
3529
+ "ECU",
3530
+ "EGY",
3531
+ "FJI",
3532
+ "FSM",
3533
+ "GAB",
3534
+ "GEO",
3535
+ "GHA",
3536
+ "GIN",
3537
+ "GNQ",
3538
+ "GRD",
3539
+ "GTM",
3540
+ "HND",
3541
+ "HTI",
3542
+ "IDN",
3543
+ "IND",
3544
+ "IRN",
3545
+ "IRQ",
3546
+ "JAM",
3547
+ "JOR",
3548
+ "KAZ",
3549
+ "KEN",
3550
+ "KGZ",
3551
+ "KHM",
3552
+ "KIR",
3553
+ "LAO",
3554
+ "LBN",
3555
+ "LBY",
3556
+ "LCA",
3557
+ "LKA",
3558
+ "LSO",
3559
+ "MAR",
3560
+ "MDA",
3561
+ "MDV",
3562
+ "MEX",
3563
+ "MHL",
3564
+ "MKD",
3565
+ "MMR",
3566
+ "MNE",
3567
+ "MNG",
3568
+ "MRT",
3569
+ "MUS",
3570
+ "MYS",
3571
+ "NAM",
3572
+ "NGA",
3573
+ "NIC",
3574
+ "NPL",
3575
+ "PAK",
3576
+ "PER",
3577
+ "PHL",
3578
+ "PLW",
3579
+ "PNG",
3580
+ "PRY",
3581
+ "PSE",
3582
+ "RUS",
3583
+ "SEN",
3584
+ "SLB",
3585
+ "SLV",
3586
+ "SRB",
3587
+ "STP",
3588
+ "SUR",
3589
+ "SWZ",
3590
+ "THA",
3591
+ "TJK",
3592
+ "TKM",
3593
+ "TLS",
3594
+ "TON",
3595
+ "TUN",
3596
+ "TUR",
3597
+ "TUV",
3598
+ "TZA",
3599
+ "UKR",
3600
+ "UZB",
3601
+ "VCT",
3602
+ "VNM",
3603
+ "VUT",
3604
+ "WSM",
3605
+ "XKX",
3606
+ "ZAF",
3607
+ "ZMB",
3608
+ "ZWE"
3609
+ ]
3610
+ },
3611
+ "MIX": {
3612
+ "name": "Middle income, excluding China & India",
3613
+ "type": "INCOME",
3614
+ "countries": [
3615
+ "AGO",
3616
+ "ALB",
3617
+ "ARG",
3618
+ "ARM",
3619
+ "AZE",
3620
+ "BEN",
3621
+ "BGD",
3622
+ "BGR",
3623
+ "BIH",
3624
+ "BLR",
3625
+ "BLZ",
3626
+ "BOL",
3627
+ "BRA",
3628
+ "BTN",
3629
+ "BWA",
3630
+ "CIV",
3631
+ "CMR",
3632
+ "COG",
3633
+ "COL",
3634
+ "COM",
3635
+ "CPV",
3636
+ "CRI",
3637
+ "CUB",
3638
+ "DJI",
3639
+ "DMA",
3640
+ "DOM",
3641
+ "DZA",
3642
+ "ECU",
3643
+ "EGY",
3644
+ "FJI",
3645
+ "FSM",
3646
+ "GAB",
3647
+ "GEO",
3648
+ "GHA",
3649
+ "GIN",
3650
+ "GNQ",
3651
+ "GRD",
3652
+ "GTM",
3653
+ "HND",
3654
+ "HTI",
3655
+ "IDN",
3656
+ "IRN",
3657
+ "IRQ",
3658
+ "JAM",
3659
+ "JOR",
3660
+ "KAZ",
3661
+ "KEN",
3662
+ "KGZ",
3663
+ "KHM",
3664
+ "KIR",
3665
+ "LAO",
3666
+ "LBN",
3667
+ "LBY",
3668
+ "LCA",
3669
+ "LKA",
3670
+ "LSO",
3671
+ "MAR",
3672
+ "MDA",
3673
+ "MDV",
3674
+ "MEX",
3675
+ "MHL",
3676
+ "MKD",
3677
+ "MMR",
3678
+ "MNE",
3679
+ "MNG",
3680
+ "MRT",
3681
+ "MUS",
3682
+ "MYS",
3683
+ "NAM",
3684
+ "NGA",
3685
+ "NIC",
3686
+ "NPL",
3687
+ "PAK",
3688
+ "PER",
3689
+ "PHL",
3690
+ "PLW",
3691
+ "PNG",
3692
+ "PRY",
3693
+ "PSE",
3694
+ "RUS",
3695
+ "SEN",
3696
+ "SLB",
3697
+ "SLV",
3698
+ "SRB",
3699
+ "STP",
3700
+ "SUR",
3701
+ "SWZ",
3702
+ "THA",
3703
+ "TJK",
3704
+ "TKM",
3705
+ "TLS",
3706
+ "TON",
3707
+ "TUN",
3708
+ "TUR",
3709
+ "TUV",
3710
+ "TZA",
3711
+ "UKR",
3712
+ "UZB",
3713
+ "VCT",
3714
+ "VNM",
3715
+ "VUT",
3716
+ "WSM",
3717
+ "XKX",
3718
+ "ZAF",
3719
+ "ZMB",
3720
+ "ZWE"
3721
+ ]
3722
+ },
3723
+ "UMC": {
3724
+ "name": "Upper middle income",
3725
+ "type": "INCOME",
3726
+ "countries": [
3727
+ "ALB",
3728
+ "ARG",
3729
+ "ARM",
3730
+ "AZE",
3731
+ "BGR",
3732
+ "BIH",
3733
+ "BLR",
3734
+ "BLZ",
3735
+ "BRA",
3736
+ "BWA",
3737
+ "CHN",
3738
+ "COL",
3739
+ "CRI",
3740
+ "CUB",
3741
+ "DMA",
3742
+ "DOM",
3743
+ "ECU",
3744
+ "FJI",
3745
+ "GAB",
3746
+ "GEO",
3747
+ "GNQ",
3748
+ "GRD",
3749
+ "GTM",
3750
+ "IDN",
3751
+ "IRQ",
3752
+ "JAM",
3753
+ "KAZ",
3754
+ "LBY",
3755
+ "LCA",
3756
+ "MDA",
3757
+ "MDV",
3758
+ "MEX",
3759
+ "MHL",
3760
+ "MKD",
3761
+ "MNE",
3762
+ "MUS",
3763
+ "MYS",
3764
+ "NAM",
3765
+ "PER",
3766
+ "PLW",
3767
+ "PRY",
3768
+ "PSE",
3769
+ "RUS",
3770
+ "SLV",
3771
+ "SRB",
3772
+ "SUR",
3773
+ "THA",
3774
+ "TKM",
3775
+ "TON",
3776
+ "TUR",
3777
+ "TUV",
3778
+ "VCT",
3779
+ "XKX",
3780
+ "ZAF"
3781
+ ]
3782
+ },
3783
+ "CEB": {
3784
+ "name": "Central Europe and the Baltics",
3785
+ "type": "OTHER",
3786
+ "countries": [
3787
+ "BGR",
3788
+ "CZE",
3789
+ "EST",
3790
+ "HRV",
3791
+ "HUN",
3792
+ "LTU",
3793
+ "LVA",
3794
+ "POL",
3795
+ "ROU",
3796
+ "SVK",
3797
+ "SVN"
3798
+ ]
3799
+ },
3800
+ "CSS": {
3801
+ "name": "Caribbean small states",
3802
+ "type": "OTHER",
3803
+ "countries": [
3804
+ "ATG",
3805
+ "BHS",
3806
+ "BLZ",
3807
+ "BRB",
3808
+ "DMA",
3809
+ "GRD",
3810
+ "GUY",
3811
+ "JAM",
3812
+ "KNA",
3813
+ "LCA",
3814
+ "SUR",
3815
+ "TTO",
3816
+ "VCT"
3817
+ ]
3818
+ },
3819
+ "DAC": {
3820
+ "name": "Development Assistance Committee members",
3821
+ "type": "OTHER",
3822
+ "countries": [
3823
+ "AUS",
3824
+ "AUT",
3825
+ "BEL",
3826
+ "CAN",
3827
+ "CHE",
3828
+ "CZE",
3829
+ "DEU",
3830
+ "DNK",
3831
+ "ESP",
3832
+ "FIN",
3833
+ "FRA",
3834
+ "GBR",
3835
+ "GRC",
3836
+ "HUN",
3837
+ "IRL",
3838
+ "ISL",
3839
+ "ITA",
3840
+ "JPN",
3841
+ "KOR",
3842
+ "LTU",
3843
+ "LUX",
3844
+ "NLD",
3845
+ "NOR",
3846
+ "NZL",
3847
+ "POL",
3848
+ "PRT",
3849
+ "SVK",
3850
+ "SVN",
3851
+ "SWE",
3852
+ "USA"
3853
+ ]
3854
+ },
3855
+ "EAR": {
3856
+ "name": "Early-demographic dividend",
3857
+ "type": "OTHER",
3858
+ "countries": [
3859
+ "ARG",
3860
+ "BGD",
3861
+ "BHR",
3862
+ "BLZ",
3863
+ "BOL",
3864
+ "BTN",
3865
+ "BWA",
3866
+ "CPV",
3867
+ "DJI",
3868
+ "DOM",
3869
+ "DZA",
3870
+ "ECU",
3871
+ "EGY",
3872
+ "ETH",
3873
+ "FSM",
3874
+ "GAB",
3875
+ "GHA",
3876
+ "GRD",
3877
+ "GTM",
3878
+ "HND",
3879
+ "HTI",
3880
+ "IDN",
3881
+ "IND",
3882
+ "IRN",
3883
+ "ISR",
3884
+ "JOR",
3885
+ "KHM",
3886
+ "KIR",
3887
+ "LAO",
3888
+ "LBY",
3889
+ "LSO",
3890
+ "MDV",
3891
+ "MEX",
3892
+ "MMR",
3893
+ "NAM",
3894
+ "NIC",
3895
+ "NPL",
3896
+ "PAK",
3897
+ "PAN",
3898
+ "PER",
3899
+ "PHL",
3900
+ "PNG",
3901
+ "PRY",
3902
+ "PSE",
3903
+ "RWA",
3904
+ "SAU",
3905
+ "SLB",
3906
+ "SLV",
3907
+ "SUR",
3908
+ "SWZ",
3909
+ "SYR",
3910
+ "TJK",
3911
+ "TKM",
3912
+ "TON",
3913
+ "TUR",
3914
+ "UZB",
3915
+ "VEN",
3916
+ "VUT",
3917
+ "WSM",
3918
+ "YEM",
3919
+ "ZAF",
3920
+ "ZWE"
3921
+ ]
3922
+ },
3923
+ "EMU": {
3924
+ "name": "Euro area",
3925
+ "type": "OTHER",
3926
+ "countries": [
3927
+ "AUT",
3928
+ "BEL",
3929
+ "CYP",
3930
+ "DEU",
3931
+ "ESP",
3932
+ "EST",
3933
+ "FIN",
3934
+ "FRA",
3935
+ "GRC",
3936
+ "HRV",
3937
+ "IRL",
3938
+ "ITA",
3939
+ "LTU",
3940
+ "LUX",
3941
+ "LVA",
3942
+ "MLT",
3943
+ "NLD",
3944
+ "PRT",
3945
+ "SVK",
3946
+ "SVN"
3947
+ ]
3948
+ },
3949
+ "EUU": {
3950
+ "name": "European Union",
3951
+ "type": "OTHER",
3952
+ "countries": [
3953
+ "AUT",
3954
+ "BEL",
3955
+ "BGR",
3956
+ "CYP",
3957
+ "CZE",
3958
+ "DEU",
3959
+ "DNK",
3960
+ "ESP",
3961
+ "EST",
3962
+ "FIN",
3963
+ "FRA",
3964
+ "GRC",
3965
+ "HRV",
3966
+ "HUN",
3967
+ "IRL",
3968
+ "ITA",
3969
+ "LTU",
3970
+ "LUX",
3971
+ "LVA",
3972
+ "MLT",
3973
+ "NLD",
3974
+ "POL",
3975
+ "PRT",
3976
+ "ROU",
3977
+ "SVK",
3978
+ "SVN",
3979
+ "SWE"
3980
+ ]
3981
+ },
3982
+ "FCS": {
3983
+ "name": "Fragile and conflict affected situations",
3984
+ "type": "OTHER",
3985
+ "countries": [
3986
+ "AFG",
3987
+ "BDI",
3988
+ "BFA",
3989
+ "CAF",
3990
+ "CMR",
3991
+ "COD",
3992
+ "COG",
3993
+ "COM",
3994
+ "ERI",
3995
+ "ETH",
3996
+ "FSM",
3997
+ "GNB",
3998
+ "HTI",
3999
+ "IRQ",
4000
+ "KIR",
4001
+ "LBN",
4002
+ "LBY",
4003
+ "MHL",
4004
+ "MLI",
4005
+ "MMR",
4006
+ "MOZ",
4007
+ "NER",
4008
+ "NGA",
4009
+ "PNG",
4010
+ "PSE",
4011
+ "SDN",
4012
+ "SLB",
4013
+ "SOM",
4014
+ "SSD",
4015
+ "STP",
4016
+ "SYR",
4017
+ "TCD",
4018
+ "TLS",
4019
+ "TUV",
4020
+ "UKR",
4021
+ "VEN",
4022
+ "XKX",
4023
+ "YEM",
4024
+ "ZWE"
4025
+ ]
4026
+ },
4027
+ "FTI": {
4028
+ "name": "Global Partnership for Education",
4029
+ "type": "OTHER",
4030
+ "countries": [
4031
+ "AFG",
4032
+ "ALB",
4033
+ "BDI",
4034
+ "BEN",
4035
+ "BFA",
4036
+ "BGD",
4037
+ "BTN",
4038
+ "CAF",
4039
+ "CIV",
4040
+ "CMR",
4041
+ "COD",
4042
+ "COG",
4043
+ "COM",
4044
+ "CPV",
4045
+ "DJI",
4046
+ "DMA",
4047
+ "ERI",
4048
+ "ETH",
4049
+ "FSM",
4050
+ "GEO",
4051
+ "GHA",
4052
+ "GIN",
4053
+ "GMB",
4054
+ "GNB",
4055
+ "GRD",
4056
+ "GUY",
4057
+ "HND",
4058
+ "HTI",
4059
+ "KEN",
4060
+ "KGZ",
4061
+ "KHM",
4062
+ "KIR",
4063
+ "LAO",
4064
+ "LBR",
4065
+ "LCA",
4066
+ "LSO",
4067
+ "MDA",
4068
+ "MDG",
4069
+ "MDV",
4070
+ "MHL",
4071
+ "MLI",
4072
+ "MMR",
4073
+ "MNG",
4074
+ "MOZ",
4075
+ "MRT",
4076
+ "MWI",
4077
+ "NER",
4078
+ "NGA",
4079
+ "NIC",
4080
+ "NPL",
4081
+ "PAK",
4082
+ "PNG",
4083
+ "RWA",
4084
+ "SDN",
4085
+ "SEN",
4086
+ "SLE",
4087
+ "SOM",
4088
+ "SSD",
4089
+ "STP",
4090
+ "TCD",
4091
+ "TGO",
4092
+ "TJK",
4093
+ "TLS",
4094
+ "TZA",
4095
+ "UGA",
4096
+ "UZB",
4097
+ "VCT",
4098
+ "VNM",
4099
+ "VUT",
4100
+ "YEM",
4101
+ "ZMB",
4102
+ "ZWE"
4103
+ ]
4104
+ },
4105
+ "GCC": {
4106
+ "name": "Gulf Cooperation Council",
4107
+ "type": "OTHER",
4108
+ "countries": [
4109
+ "ARE",
4110
+ "BHR",
4111
+ "KWT",
4112
+ "OMN",
4113
+ "QAT",
4114
+ "SAU"
4115
+ ]
4116
+ },
4117
+ "HPC": {
4118
+ "name": "Heavily indebted poor countries (HIPC)",
4119
+ "type": "OTHER",
4120
+ "countries": [
4121
+ "AFG",
4122
+ "BDI",
4123
+ "BEN",
4124
+ "BFA",
4125
+ "BOL",
4126
+ "CAF",
4127
+ "CIV",
4128
+ "CMR",
4129
+ "COD",
4130
+ "COG",
4131
+ "COM",
4132
+ "ERI",
4133
+ "ETH",
4134
+ "GHA",
4135
+ "GIN",
4136
+ "GMB",
4137
+ "GNB",
4138
+ "GUY",
4139
+ "HND",
4140
+ "HTI",
4141
+ "LBR",
4142
+ "MDG",
4143
+ "MLI",
4144
+ "MOZ",
4145
+ "MRT",
4146
+ "MWI",
4147
+ "NER",
4148
+ "NIC",
4149
+ "RWA",
4150
+ "SDN",
4151
+ "SEN",
4152
+ "SLE",
4153
+ "SOM",
4154
+ "STP",
4155
+ "TCD",
4156
+ "TGO",
4157
+ "TZA",
4158
+ "UGA",
4159
+ "ZMB"
4160
+ ]
4161
+ },
4162
+ "LDC": {
4163
+ "name": "Least developed countries: UN classification",
4164
+ "type": "OTHER",
4165
+ "countries": [
4166
+ "AFG",
4167
+ "AGO",
4168
+ "BDI",
4169
+ "BEN",
4170
+ "BFA",
4171
+ "BGD",
4172
+ "CAF",
4173
+ "COD",
4174
+ "COM",
4175
+ "DJI",
4176
+ "ERI",
4177
+ "ETH",
4178
+ "GIN",
4179
+ "GMB",
4180
+ "GNB",
4181
+ "HTI",
4182
+ "KHM",
4183
+ "KIR",
4184
+ "LAO",
4185
+ "LBR",
4186
+ "LSO",
4187
+ "MDG",
4188
+ "MLI",
4189
+ "MMR",
4190
+ "MOZ",
4191
+ "MRT",
4192
+ "MWI",
4193
+ "NER",
4194
+ "NPL",
4195
+ "RWA",
4196
+ "SDN",
4197
+ "SEN",
4198
+ "SLB",
4199
+ "SLE",
4200
+ "SOM",
4201
+ "SSD",
4202
+ "TCD",
4203
+ "TGO",
4204
+ "TLS",
4205
+ "TUV",
4206
+ "TZA",
4207
+ "UGA",
4208
+ "YEM",
4209
+ "ZMB"
4210
+ ]
4211
+ },
4212
+ "LLC": {
4213
+ "name": "Landlocked developing countries: UN classification",
4214
+ "type": "OTHER",
4215
+ "countries": [
4216
+ "AFG",
4217
+ "ARM",
4218
+ "AZE",
4219
+ "BDI",
4220
+ "BFA",
4221
+ "BOL",
4222
+ "BTN",
4223
+ "BWA",
4224
+ "CAF",
4225
+ "ETH",
4226
+ "KAZ",
4227
+ "KGZ",
4228
+ "LAO",
4229
+ "LSO",
4230
+ "MDA",
4231
+ "MKD",
4232
+ "MLI",
4233
+ "MNG",
4234
+ "MWI",
4235
+ "NER",
4236
+ "NPL",
4237
+ "PRY",
4238
+ "RWA",
4239
+ "SSD",
4240
+ "SWZ",
4241
+ "TCD",
4242
+ "TJK",
4243
+ "TKM",
4244
+ "UGA",
4245
+ "UZB",
4246
+ "ZMB",
4247
+ "ZWE"
4248
+ ]
4249
+ },
4250
+ "LTE": {
4251
+ "name": "Late-demographic dividend",
4252
+ "type": "OTHER",
4253
+ "countries": [
4254
+ "ABW",
4255
+ "ALB",
4256
+ "ARE",
4257
+ "ARM",
4258
+ "AZE",
4259
+ "BHS",
4260
+ "BRA",
4261
+ "BRN",
4262
+ "CHL",
4263
+ "CHN",
4264
+ "COL",
4265
+ "CRI",
4266
+ "CYP",
4267
+ "EST",
4268
+ "FJI",
4269
+ "GEO",
4270
+ "GUM",
4271
+ "GUY",
4272
+ "IRL",
4273
+ "ISL",
4274
+ "JAM",
4275
+ "KAZ",
4276
+ "KGZ",
4277
+ "KWT",
4278
+ "LBN",
4279
+ "LCA",
4280
+ "LKA",
4281
+ "LVA",
4282
+ "MAR",
4283
+ "MDA",
4284
+ "MKD",
4285
+ "MNE",
4286
+ "MNG",
4287
+ "MUS",
4288
+ "MYS",
4289
+ "NCL",
4290
+ "OMN",
4291
+ "POL",
4292
+ "PRI",
4293
+ "PRK",
4294
+ "PYF",
4295
+ "QAT",
4296
+ "ROU",
4297
+ "RUS",
4298
+ "SRB",
4299
+ "SVK",
4300
+ "SYC",
4301
+ "THA",
4302
+ "TTO",
4303
+ "TUN",
4304
+ "URY",
4305
+ "VCT",
4306
+ "VIR",
4307
+ "VNM"
4308
+ ]
4309
+ },
4310
+ "NOC": {
4311
+ "name": "High income: non OECD",
4312
+ "type": "OTHER",
4313
+ "countries": [
4314
+ "ABW",
4315
+ "AND",
4316
+ "ARE",
4317
+ "ASM",
4318
+ "ATG",
4319
+ "BHR",
4320
+ "BHS",
4321
+ "BMU",
4322
+ "BRB",
4323
+ "BRN",
4324
+ "CHI",
4325
+ "CUW",
4326
+ "CYM",
4327
+ "CYP",
4328
+ "FRO",
4329
+ "GIB",
4330
+ "GRL",
4331
+ "GUM",
4332
+ "GUY",
4333
+ "HKG",
4334
+ "HRV",
4335
+ "IMN",
4336
+ "KNA",
4337
+ "KWT",
4338
+ "LIE",
4339
+ "MAC",
4340
+ "MAF",
4341
+ "MCO",
4342
+ "MLT",
4343
+ "MNP",
4344
+ "NCL",
4345
+ "NRU",
4346
+ "OMN",
4347
+ "PAN",
4348
+ "PRI",
4349
+ "PYF",
4350
+ "QAT",
4351
+ "ROU",
4352
+ "SAU",
4353
+ "SGP",
4354
+ "SMR",
4355
+ "SXM",
4356
+ "SYC",
4357
+ "TCA",
4358
+ "TTO",
4359
+ "TWN",
4360
+ "URY",
4361
+ "VGB",
4362
+ "VIR"
4363
+ ]
4364
+ },
4365
+ "OEC": {
4366
+ "name": "High income: OECD",
4367
+ "type": "OTHER",
4368
+ "countries": [
4369
+ "AUS",
4370
+ "AUT",
4371
+ "BEL",
4372
+ "CAN",
4373
+ "CHE",
4374
+ "CHL",
4375
+ "CZE",
4376
+ "DEU",
4377
+ "DNK",
4378
+ "ESP",
4379
+ "EST",
4380
+ "FIN",
4381
+ "FRA",
4382
+ "GBR",
4383
+ "GRC",
4384
+ "HUN",
4385
+ "IRL",
4386
+ "ISL",
4387
+ "ISR",
4388
+ "ITA",
4389
+ "JPN",
4390
+ "KOR",
4391
+ "LTU",
4392
+ "LUX",
4393
+ "LVA",
4394
+ "NLD",
4395
+ "NOR",
4396
+ "NZL",
4397
+ "POL",
4398
+ "PRT",
4399
+ "SVK",
4400
+ "SVN",
4401
+ "SWE",
4402
+ "USA"
4403
+ ]
4404
+ },
4405
+ "OED": {
4406
+ "name": "OECD members",
4407
+ "type": "OTHER",
4408
+ "countries": [
4409
+ "AUS",
4410
+ "AUT",
4411
+ "BEL",
4412
+ "CAN",
4413
+ "CHE",
4414
+ "CHL",
4415
+ "COL",
4416
+ "CRI",
4417
+ "CZE",
4418
+ "DEU",
4419
+ "DNK",
4420
+ "ESP",
4421
+ "EST",
4422
+ "FIN",
4423
+ "FRA",
4424
+ "GBR",
4425
+ "GRC",
4426
+ "HUN",
4427
+ "IRL",
4428
+ "ISL",
4429
+ "ISR",
4430
+ "ITA",
4431
+ "JPN",
4432
+ "KOR",
4433
+ "LTU",
4434
+ "LUX",
4435
+ "LVA",
4436
+ "MEX",
4437
+ "NLD",
4438
+ "NOR",
4439
+ "NZL",
4440
+ "POL",
4441
+ "PRT",
4442
+ "SVK",
4443
+ "SVN",
4444
+ "SWE",
4445
+ "TUR",
4446
+ "USA"
4447
+ ]
4448
+ },
4449
+ "OSS": {
4450
+ "name": "Other small states",
4451
+ "type": "OTHER",
4452
+ "countries": [
4453
+ "BHR",
4454
+ "BRN",
4455
+ "BTN",
4456
+ "BWA",
4457
+ "COM",
4458
+ "CPV",
4459
+ "CYP",
4460
+ "DJI",
4461
+ "EST",
4462
+ "GAB",
4463
+ "GMB",
4464
+ "GNB",
4465
+ "GNQ",
4466
+ "ISL",
4467
+ "LSO",
4468
+ "MDV",
4469
+ "MLT",
4470
+ "MNE",
4471
+ "MUS",
4472
+ "NAM",
4473
+ "QAT",
4474
+ "SMR",
4475
+ "STP",
4476
+ "SWZ",
4477
+ "SYC",
4478
+ "TLS"
4479
+ ]
4480
+ },
4481
+ "PRE": {
4482
+ "name": "Pre-demographic dividend",
4483
+ "type": "OTHER",
4484
+ "countries": [
4485
+ "AFG",
4486
+ "AGO",
4487
+ "BDI",
4488
+ "BEN",
4489
+ "BFA",
4490
+ "CAF",
4491
+ "CIV",
4492
+ "CMR",
4493
+ "COD",
4494
+ "COG",
4495
+ "COM",
4496
+ "ERI",
4497
+ "GIN",
4498
+ "GMB",
4499
+ "GNB",
4500
+ "GNQ",
4501
+ "IRQ",
4502
+ "KEN",
4503
+ "LBR",
4504
+ "MDG",
4505
+ "MLI",
4506
+ "MOZ",
4507
+ "MRT",
4508
+ "MWI",
4509
+ "NER",
4510
+ "NGA",
4511
+ "SDN",
4512
+ "SEN",
4513
+ "SLE",
4514
+ "SOM",
4515
+ "SSD",
4516
+ "TCD",
4517
+ "TGO",
4518
+ "TLS",
4519
+ "TZA",
4520
+ "UGA",
4521
+ "ZMB"
4522
+ ]
4523
+ },
4524
+ "PSS": {
4525
+ "name": "Pacific island small states",
4526
+ "type": "OTHER",
4527
+ "countries": [
4528
+ "FJI",
4529
+ "FSM",
4530
+ "KIR",
4531
+ "MHL",
4532
+ "NRU",
4533
+ "PLW",
4534
+ "SLB",
4535
+ "TON",
4536
+ "TUV",
4537
+ "VUT",
4538
+ "WSM"
4539
+ ]
4540
+ },
4541
+ "PST": {
4542
+ "name": "Post-demographic dividend",
4543
+ "type": "OTHER",
4544
+ "countries": [
4545
+ "ATG",
4546
+ "AUS",
4547
+ "AUT",
4548
+ "BEL",
4549
+ "BGR",
4550
+ "BIH",
4551
+ "BLR",
4552
+ "BRB",
4553
+ "CAN",
4554
+ "CHE",
4555
+ "CUB",
4556
+ "CZE",
4557
+ "DEU",
4558
+ "DNK",
4559
+ "ESP",
4560
+ "FIN",
4561
+ "FRA",
4562
+ "GBR",
4563
+ "GRC",
4564
+ "HKG",
4565
+ "HRV",
4566
+ "HUN",
4567
+ "ITA",
4568
+ "JPN",
4569
+ "KOR",
4570
+ "LTU",
4571
+ "LUX",
4572
+ "MAC",
4573
+ "MLT",
4574
+ "NLD",
4575
+ "NOR",
4576
+ "NZL",
4577
+ "PRT",
4578
+ "SGP",
4579
+ "SVN",
4580
+ "SWE",
4581
+ "UKR",
4582
+ "USA"
4583
+ ]
4584
+ },
4585
+ "SDC": {
4586
+ "name": "Southern African Development Community",
4587
+ "type": "OTHER",
4588
+ "countries": [
4589
+ "AGO",
4590
+ "BWA",
4591
+ "COD",
4592
+ "COM",
4593
+ "LSO",
4594
+ "MDG",
4595
+ "MOZ",
4596
+ "MUS",
4597
+ "MWI",
4598
+ "NAM",
4599
+ "SWZ",
4600
+ "SYC",
4601
+ "TZA",
4602
+ "ZAF",
4603
+ "ZMB",
4604
+ "ZWE"
4605
+ ]
4606
+ },
4607
+ "SID": {
4608
+ "name": "Small island developing states: UN classification",
4609
+ "type": "OTHER",
4610
+ "countries": [
4611
+ "ABW",
4612
+ "AIA",
4613
+ "ASM",
4614
+ "ATG",
4615
+ "BES",
4616
+ "BHS",
4617
+ "BLZ",
4618
+ "BRB",
4619
+ "COK",
4620
+ "COM",
4621
+ "CPV",
4622
+ "CUB",
4623
+ "CUW",
4624
+ "DMA",
4625
+ "DOM",
4626
+ "FJI",
4627
+ "FSM",
4628
+ "GNB",
4629
+ "GRD",
4630
+ "GUM",
4631
+ "GUY",
4632
+ "HTI",
4633
+ "JAM",
4634
+ "KIR",
4635
+ "KNA",
4636
+ "LCA",
4637
+ "MDV",
4638
+ "MHL",
4639
+ "MNP",
4640
+ "MSR",
4641
+ "MUS",
4642
+ "NCL",
4643
+ "NIU",
4644
+ "NRU",
4645
+ "PLW",
4646
+ "PNG",
4647
+ "PRI",
4648
+ "PYF",
4649
+ "SGP",
4650
+ "SLB",
4651
+ "STP",
4652
+ "SUR",
4653
+ "SXM",
4654
+ "SYC",
4655
+ "TLS",
4656
+ "TON",
4657
+ "TTO",
4658
+ "TUV",
4659
+ "VCT",
4660
+ "VGB",
4661
+ "VIR",
4662
+ "VUT",
4663
+ "WSM"
4664
+ ]
4665
+ },
4666
+ "SSB": {
4667
+ "name": "Small states (IBRD only)",
4668
+ "type": "OTHER",
4669
+ "countries": [
4670
+ "ATG",
4671
+ "BLZ",
4672
+ "BWA",
4673
+ "GAB",
4674
+ "GNQ",
4675
+ "JAM",
4676
+ "KNA",
4677
+ "MNE",
4678
+ "MUS",
4679
+ "NAM",
4680
+ "NRU",
4681
+ "PLW",
4682
+ "SUR",
4683
+ "SWZ",
4684
+ "SYC",
4685
+ "TTO"
4686
+ ]
4687
+ },
4688
+ "SSH": {
4689
+ "name": "Small states (high income only)",
4690
+ "type": "OTHER",
4691
+ "countries": [
4692
+ "BHR",
4693
+ "BHS",
4694
+ "BRB",
4695
+ "BRN",
4696
+ "CYP",
4697
+ "EST",
4698
+ "GUY",
4699
+ "ISL",
4700
+ "KNA",
4701
+ "MLT",
4702
+ "NRU",
4703
+ "QAT",
4704
+ "SMR",
4705
+ "SYC",
4706
+ "TTO"
4707
+ ]
4708
+ },
4709
+ "SSI": {
4710
+ "name": "Small states (IDA only)",
4711
+ "type": "OTHER",
4712
+ "countries": [
4713
+ "BTN",
4714
+ "COM",
4715
+ "CPV",
4716
+ "DJI",
4717
+ "DMA",
4718
+ "FJI",
4719
+ "FSM",
4720
+ "GMB",
4721
+ "GNB",
4722
+ "GRD",
4723
+ "GUY",
4724
+ "KIR",
4725
+ "LCA",
4726
+ "LSO",
4727
+ "MDV",
4728
+ "MHL",
4729
+ "SLB",
4730
+ "STP",
4731
+ "TLS",
4732
+ "TON",
4733
+ "TUV",
4734
+ "VCT",
4735
+ "VUT",
4736
+ "WSM"
4737
+ ]
4738
+ },
4739
+ "SST": {
4740
+ "name": "Small states",
4741
+ "type": "OTHER",
4742
+ "countries": [
4743
+ "ATG",
4744
+ "BHR",
4745
+ "BHS",
4746
+ "BLZ",
4747
+ "BRB",
4748
+ "BRN",
4749
+ "BTN",
4750
+ "BWA",
4751
+ "COM",
4752
+ "CPV",
4753
+ "CYP",
4754
+ "DJI",
4755
+ "DMA",
4756
+ "EST",
4757
+ "FJI",
4758
+ "FSM",
4759
+ "GAB",
4760
+ "GMB",
4761
+ "GNB",
4762
+ "GNQ",
4763
+ "GRD",
4764
+ "GUY",
4765
+ "ISL",
4766
+ "JAM",
4767
+ "KIR",
4768
+ "KNA",
4769
+ "LCA",
4770
+ "LSO",
4771
+ "MDV",
4772
+ "MHL",
4773
+ "MLT",
4774
+ "MNE",
4775
+ "MUS",
4776
+ "NAM",
4777
+ "NRU",
4778
+ "PLW",
4779
+ "QAT",
4780
+ "SLB",
4781
+ "SMR",
4782
+ "STP",
4783
+ "SUR",
4784
+ "SWZ",
4785
+ "SYC",
4786
+ "TLS",
4787
+ "TON",
4788
+ "TTO",
4789
+ "TUV",
4790
+ "VCT",
4791
+ "VUT",
4792
+ "WSM"
4793
+ ]
4794
+ },
4795
+ "WA1": {
4796
+ "name": "ESCWA: Gulf Cooperation Council (GCC)",
4797
+ "type": "OTHER",
4798
+ "countries": [
4799
+ "ARE",
4800
+ "BHR",
4801
+ "KWT",
4802
+ "OMN",
4803
+ "QAT",
4804
+ "SAU"
4805
+ ]
4806
+ },
4807
+ "WA2": {
4808
+ "name": "ESCWA: Mashreq subregion",
4809
+ "type": "OTHER",
4810
+ "countries": [
4811
+ "EGY",
4812
+ "IRQ",
4813
+ "JOR",
4814
+ "LBN",
4815
+ "PSE",
4816
+ "SYR"
4817
+ ]
4818
+ },
4819
+ "WA3": {
4820
+ "name": "ESCWA: Maghreb subregion",
4821
+ "type": "OTHER",
4822
+ "countries": [
4823
+ "DZA",
4824
+ "LBY",
4825
+ "MAR",
4826
+ "TUN"
4827
+ ]
4828
+ },
4829
+ "WA4": {
4830
+ "name": "ESCWA: Arab LDCs subregion",
4831
+ "type": "OTHER",
4832
+ "countries": [
4833
+ "COM",
4834
+ "DJI",
4835
+ "MRT",
4836
+ "SDN",
4837
+ "SOM",
4838
+ "YEM"
4839
+ ]
4840
+ },
4841
+ "DM": {
4842
+ "name": "Developed markets",
4843
+ "type": "OTHER",
4844
+ "countries": [
4845
+ "AUS",
4846
+ "AUT",
4847
+ "BEL",
4848
+ "CAN",
4849
+ "CHE",
4850
+ "DEU",
4851
+ "DNK",
4852
+ "ESP",
4853
+ "FIN",
4854
+ "FRA",
4855
+ "GBR",
4856
+ "GRC",
4857
+ "IRL",
4858
+ "ISL",
4859
+ "ISR",
4860
+ "ITA",
4861
+ "JPN",
4862
+ "KOR",
4863
+ "LUX",
4864
+ "NLD",
4865
+ "NOR",
4866
+ "NZL",
4867
+ "PRT",
4868
+ "QAT",
4869
+ "SGP",
4870
+ "SWE",
4871
+ "USA"
4872
+ ]
4873
+ },
4874
+ "G_COA": {
4875
+ "name": "Coastal Countries",
4876
+ "type": "OTHER",
4877
+ "countries": [
4878
+ "AGO",
4879
+ "ALB",
4880
+ "ARE",
4881
+ "ARG",
4882
+ "AUS",
4883
+ "BEL",
4884
+ "BEN",
4885
+ "BGD",
4886
+ "BGR",
4887
+ "BHR",
4888
+ "BIH",
4889
+ "BLZ",
4890
+ "BRA",
4891
+ "BRN",
4892
+ "CAN",
4893
+ "CHL",
4894
+ "CHN",
4895
+ "CIV",
4896
+ "CMR",
4897
+ "COD",
4898
+ "COG",
4899
+ "COL",
4900
+ "CRI",
4901
+ "CUB",
4902
+ "CYP",
4903
+ "DEU",
4904
+ "DJI",
4905
+ "DNK",
4906
+ "DZA",
4907
+ "ECU",
4908
+ "EGY",
4909
+ "ERI",
4910
+ "ESH",
4911
+ "ESP",
4912
+ "EST",
4913
+ "FIN",
4914
+ "FRA",
4915
+ "GAB",
4916
+ "GBR",
4917
+ "GEO",
4918
+ "GHA",
4919
+ "GIB",
4920
+ "GIN",
4921
+ "GMB",
4922
+ "GNB",
4923
+ "GNQ",
4924
+ "GRC",
4925
+ "GRD",
4926
+ "GRL",
4927
+ "GTM",
4928
+ "GUF",
4929
+ "GUY",
4930
+ "HKG",
4931
+ "HND",
4932
+ "HRV",
4933
+ "IDN",
4934
+ "IND",
4935
+ "IRL",
4936
+ "IRN",
4937
+ "IRQ",
4938
+ "ISL",
4939
+ "ISR",
4940
+ "ITA",
4941
+ "JOR",
4942
+ "JPN",
4943
+ "KEN",
4944
+ "KHM",
4945
+ "KOR",
4946
+ "KWT",
4947
+ "LBN",
4948
+ "LBR",
4949
+ "LBY",
4950
+ "LKA",
4951
+ "LTU",
4952
+ "LVA",
4953
+ "MAC",
4954
+ "MAR",
4955
+ "MCO",
4956
+ "MDG",
4957
+ "MEX",
4958
+ "MMR",
4959
+ "MNE",
4960
+ "MOZ",
4961
+ "MRT",
4962
+ "MYS",
4963
+ "NAM",
4964
+ "NGA",
4965
+ "NIC",
4966
+ "NLD",
4967
+ "NOR",
4968
+ "NZL",
4969
+ "OMN",
4970
+ "PAK",
4971
+ "PAN",
4972
+ "PER",
4973
+ "PHL",
4974
+ "PNG",
4975
+ "POL",
4976
+ "PRK",
4977
+ "PRT",
4978
+ "PSE",
4979
+ "QAT",
4980
+ "ROU",
4981
+ "RUS",
4982
+ "SAU",
4983
+ "SDN",
4984
+ "SEN",
4985
+ "SGP",
4986
+ "SLE",
4987
+ "SLV",
4988
+ "SOM",
4989
+ "SUR",
4990
+ "SVN",
4991
+ "SWE",
4992
+ "SYR",
4993
+ "TGO",
4994
+ "THA",
4995
+ "TUN",
4996
+ "TUR",
4997
+ "TWN",
4998
+ "TZA",
4999
+ "UKR",
5000
+ "URY",
5001
+ "USA",
5002
+ "VEN",
5003
+ "VNM",
5004
+ "YEM",
5005
+ "ZAF"
5006
+ ]
5007
+ },
5008
+ "G_LLD": {
5009
+ "name": "Landlocked Countries",
5010
+ "type": "OTHER",
5011
+ "countries": [
5012
+ "AFG",
5013
+ "AND",
5014
+ "ARM",
5015
+ "AUT",
5016
+ "AZE",
5017
+ "BDI",
5018
+ "BFA",
5019
+ "BLR",
5020
+ "BOL",
5021
+ "BTN",
5022
+ "BWA",
5023
+ "CAF",
5024
+ "CHE",
5025
+ "CZE",
5026
+ "ETH",
5027
+ "HUN",
5028
+ "KAZ",
5029
+ "KGZ",
5030
+ "LAO",
5031
+ "LIE",
5032
+ "LSO",
5033
+ "LUX",
5034
+ "MDA",
5035
+ "MKD",
5036
+ "MLI",
5037
+ "MNG",
5038
+ "MWI",
5039
+ "NER",
5040
+ "NPL",
5041
+ "PRY",
5042
+ "RWA",
5043
+ "SMR",
5044
+ "SRB",
5045
+ "SSD",
5046
+ "SVK",
5047
+ "SWZ",
5048
+ "TCD",
5049
+ "TJK",
5050
+ "TKM",
5051
+ "UGA",
5052
+ "UZB",
5053
+ "VAT",
5054
+ "XKX",
5055
+ "ZMB",
5056
+ "ZWE"
5057
+ ]
5058
+ },
5059
+ "G_SIC": {
5060
+ "name": "Small Island Countries",
5061
+ "type": "OTHER",
5062
+ "countries": [
5063
+ "ABW",
5064
+ "AIA",
5065
+ "ALA",
5066
+ "ANT",
5067
+ "ASM",
5068
+ "ATF",
5069
+ "ATG",
5070
+ "BES",
5071
+ "BHS",
5072
+ "BLM",
5073
+ "BMU",
5074
+ "BRB",
5075
+ "BVT",
5076
+ "CCK",
5077
+ "COK",
5078
+ "COM",
5079
+ "CPV",
5080
+ "CUW",
5081
+ "CXR",
5082
+ "CYM",
5083
+ "DMA",
5084
+ "DOM",
5085
+ "FJI",
5086
+ "FLK",
5087
+ "FRO",
5088
+ "FSM",
5089
+ "GGY",
5090
+ "GLP",
5091
+ "GUM",
5092
+ "HMD",
5093
+ "HTI",
5094
+ "IMN",
5095
+ "IOT",
5096
+ "JAM",
5097
+ "JEY",
5098
+ "KIR",
5099
+ "KNA",
5100
+ "LCA",
5101
+ "MAF",
5102
+ "MDV",
5103
+ "MHL",
5104
+ "MLT",
5105
+ "MNP",
5106
+ "MSR",
5107
+ "MTQ",
5108
+ "MUS",
5109
+ "MYT",
5110
+ "NCL",
5111
+ "NFK",
5112
+ "NIU",
5113
+ "NRU",
5114
+ "PCN",
5115
+ "PLW",
5116
+ "PRI",
5117
+ "PYF",
5118
+ "REU",
5119
+ "SGS",
5120
+ "SHN",
5121
+ "SJM",
5122
+ "SLB",
5123
+ "SPM",
5124
+ "STP",
5125
+ "SXM",
5126
+ "SYC",
5127
+ "TCA",
5128
+ "TKL",
5129
+ "TLS",
5130
+ "TON",
5131
+ "TTO",
5132
+ "TUV",
5133
+ "UMI",
5134
+ "VCT",
5135
+ "VGB",
5136
+ "VIR",
5137
+ "VUT",
5138
+ "WLF",
5139
+ "WSM"
5140
+ ]
5141
+ },
5142
+ "002": {
5143
+ "name": "Africa",
5144
+ "type": "CONTINENT",
5145
+ "countries": [
5146
+ "AGO",
5147
+ "ATF",
5148
+ "BDI",
5149
+ "BEN",
5150
+ "BFA",
5151
+ "BWA",
5152
+ "CAF",
5153
+ "CIV",
5154
+ "CMR",
5155
+ "COD",
5156
+ "COG",
5157
+ "COM",
5158
+ "CPV",
5159
+ "DJI",
5160
+ "DZA",
5161
+ "EGY",
5162
+ "ERI",
5163
+ "ESH",
5164
+ "ETH",
5165
+ "GAB",
5166
+ "GHA",
5167
+ "GIN",
5168
+ "GMB",
5169
+ "GNB",
5170
+ "GNQ",
5171
+ "IOT",
5172
+ "KEN",
5173
+ "LBR",
5174
+ "LBY",
5175
+ "LSO",
5176
+ "MAR",
5177
+ "MDG",
5178
+ "MLI",
5179
+ "MOZ",
5180
+ "MRT",
5181
+ "MUS",
5182
+ "MWI",
5183
+ "MYT",
5184
+ "NAM",
5185
+ "NER",
5186
+ "NGA",
5187
+ "REU",
5188
+ "RWA",
5189
+ "SDN",
5190
+ "SEN",
5191
+ "SHN",
5192
+ "SLE",
5193
+ "SOM",
5194
+ "SSD",
5195
+ "STP",
5196
+ "SWZ",
5197
+ "SYC",
5198
+ "TCD",
5199
+ "TGO",
5200
+ "TUN",
5201
+ "TZA",
5202
+ "UGA",
5203
+ "ZAF",
5204
+ "ZMB",
5205
+ "ZWE"
5206
+ ]
5207
+ },
5208
+ "150": {
5209
+ "name": "Europe",
5210
+ "type": "CONTINENT",
5211
+ "countries": [
5212
+ "ALA",
5213
+ "ALB",
5214
+ "AND",
5215
+ "AUT",
5216
+ "BEL",
5217
+ "BGR",
5218
+ "BIH",
5219
+ "BLR",
5220
+ "CHE",
5221
+ "CZE",
5222
+ "DEU",
5223
+ "DNK",
5224
+ "ESP",
5225
+ "EST",
5226
+ "FIN",
5227
+ "FRA",
5228
+ "FRO",
5229
+ "GBR",
5230
+ "GGY",
5231
+ "GIB",
5232
+ "GRC",
5233
+ "HRV",
5234
+ "HUN",
5235
+ "IMN",
5236
+ "IRL",
5237
+ "ISL",
5238
+ "ITA",
5239
+ "JEY",
5240
+ "LIE",
5241
+ "LTU",
5242
+ "LUX",
5243
+ "LVA",
5244
+ "MCO",
5245
+ "MDA",
5246
+ "MKD",
5247
+ "MLT",
5248
+ "MNE",
5249
+ "NLD",
5250
+ "NOR",
5251
+ "POL",
5252
+ "PRT",
5253
+ "ROU",
5254
+ "RUS",
5255
+ "SJM",
5256
+ "SMR",
5257
+ "SRB",
5258
+ "SVK",
5259
+ "SVN",
5260
+ "SWE",
5261
+ "UKR",
5262
+ "VAT",
5263
+ "XKX"
5264
+ ]
5265
+ },
5266
+ "142": {
5267
+ "name": "Asia",
5268
+ "type": "CONTINENT",
5269
+ "countries": [
5270
+ "AFG",
5271
+ "ARE",
5272
+ "ARM",
5273
+ "AZE",
5274
+ "BGD",
5275
+ "BHR",
5276
+ "BRN",
5277
+ "BTN",
5278
+ "CHN",
5279
+ "CYP",
5280
+ "GEO",
5281
+ "HKG",
5282
+ "IDN",
5283
+ "IND",
5284
+ "IRN",
5285
+ "IRQ",
5286
+ "ISR",
5287
+ "JOR",
5288
+ "JPN",
5289
+ "KAZ",
5290
+ "KGZ",
5291
+ "KHM",
5292
+ "KOR",
5293
+ "KWT",
5294
+ "LAO",
5295
+ "LBN",
5296
+ "LKA",
5297
+ "MAC",
5298
+ "MDV",
5299
+ "MMR",
5300
+ "MNG",
5301
+ "MYS",
5302
+ "NPL",
5303
+ "OMN",
5304
+ "PAK",
5305
+ "PHL",
5306
+ "PRK",
5307
+ "PSE",
5308
+ "QAT",
5309
+ "SAU",
5310
+ "SGP",
5311
+ "SYR",
5312
+ "THA",
5313
+ "TJK",
5314
+ "TKM",
5315
+ "TLS",
5316
+ "TUR",
5317
+ "UZB",
5318
+ "VNM",
5319
+ "YEM"
5320
+ ]
5321
+ },
5322
+ "005": {
5323
+ "name": "South America",
5324
+ "type": "CONTINENT",
5325
+ "countries": [
5326
+ "ARG",
5327
+ "BOL",
5328
+ "BRA",
5329
+ "BVT",
5330
+ "CHL",
5331
+ "COL",
5332
+ "ECU",
5333
+ "FLK",
5334
+ "GUF",
5335
+ "GUY",
5336
+ "PER",
5337
+ "PRY",
5338
+ "SGS",
5339
+ "SUR",
5340
+ "URY",
5341
+ "VEN"
5342
+ ]
5343
+ },
5344
+ "009": {
5345
+ "name": "Oceania",
5346
+ "type": "CONTINENT",
5347
+ "countries": [
5348
+ "ASM",
5349
+ "AUS",
5350
+ "CCK",
5351
+ "COK",
5352
+ "CXR",
5353
+ "FJI",
5354
+ "FSM",
5355
+ "GUM",
5356
+ "HMD",
5357
+ "KIR",
5358
+ "MHL",
5359
+ "MNP",
5360
+ "NCL",
5361
+ "NFK",
5362
+ "NIU",
5363
+ "NRU",
5364
+ "NZL",
5365
+ "PCN",
5366
+ "PLW",
5367
+ "PNG",
5368
+ "PYF",
5369
+ "SLB",
5370
+ "TKL",
5371
+ "TON",
5372
+ "TUV",
5373
+ "UMI",
5374
+ "VUT",
5375
+ "WLF",
5376
+ "WSM"
5377
+ ]
5378
+ }
5379
+ },
5380
+ "all_countries": [
5381
+ "ABW",
5382
+ "AFG",
5383
+ "AGO",
5384
+ "AIA",
5385
+ "ALA",
5386
+ "ALB",
5387
+ "AND",
5388
+ "ANT",
5389
+ "ARE",
5390
+ "ARG",
5391
+ "ARM",
5392
+ "ASM",
5393
+ "ATF",
5394
+ "ATG",
5395
+ "AUS",
5396
+ "AUT",
5397
+ "AZE",
5398
+ "BDI",
5399
+ "BEL",
5400
+ "BEN",
5401
+ "BES",
5402
+ "BFA",
5403
+ "BGD",
5404
+ "BGR",
5405
+ "BHR",
5406
+ "BHS",
5407
+ "BIH",
5408
+ "BLM",
5409
+ "BLR",
5410
+ "BLZ",
5411
+ "BMU",
5412
+ "BOL",
5413
+ "BRA",
5414
+ "BRB",
5415
+ "BRN",
5416
+ "BTN",
5417
+ "BVT",
5418
+ "BWA",
5419
+ "CAF",
5420
+ "CAN",
5421
+ "CCK",
5422
+ "CHE",
5423
+ "CHI",
5424
+ "CHL",
5425
+ "CHN",
5426
+ "CIV",
5427
+ "CMR",
5428
+ "COD",
5429
+ "COG",
5430
+ "COK",
5431
+ "COL",
5432
+ "COM",
5433
+ "CPV",
5434
+ "CRI",
5435
+ "CUB",
5436
+ "CUW",
5437
+ "CXR",
5438
+ "CYM",
5439
+ "CYP",
5440
+ "CZE",
5441
+ "DEU",
5442
+ "DJI",
5443
+ "DMA",
5444
+ "DNK",
5445
+ "DOM",
5446
+ "DZA",
5447
+ "ECU",
5448
+ "EGY",
5449
+ "ERI",
5450
+ "ESH",
5451
+ "ESP",
5452
+ "EST",
5453
+ "ETH",
5454
+ "FIN",
5455
+ "FJI",
5456
+ "FLK",
5457
+ "FRA",
5458
+ "FRO",
5459
+ "FSM",
5460
+ "GAB",
5461
+ "GBR",
5462
+ "GEO",
5463
+ "GGY",
5464
+ "GHA",
5465
+ "GIB",
5466
+ "GIN",
5467
+ "GLP",
5468
+ "GMB",
5469
+ "GNB",
5470
+ "GNQ",
5471
+ "GRC",
5472
+ "GRD",
5473
+ "GRL",
5474
+ "GTM",
5475
+ "GUF",
5476
+ "GUM",
5477
+ "GUY",
5478
+ "HKG",
5479
+ "HMD",
5480
+ "HND",
5481
+ "HRV",
5482
+ "HTI",
5483
+ "HUN",
5484
+ "IDN",
5485
+ "IMN",
5486
+ "IND",
5487
+ "IOT",
5488
+ "IRL",
5489
+ "IRN",
5490
+ "IRQ",
5491
+ "ISL",
5492
+ "ISR",
5493
+ "ITA",
5494
+ "JAM",
5495
+ "JEY",
5496
+ "JOR",
5497
+ "JPN",
5498
+ "KAZ",
5499
+ "KEN",
5500
+ "KGZ",
5501
+ "KHM",
5502
+ "KIR",
5503
+ "KNA",
5504
+ "KOR",
5505
+ "KWT",
5506
+ "LAO",
5507
+ "LBN",
5508
+ "LBR",
5509
+ "LBY",
5510
+ "LCA",
5511
+ "LIE",
5512
+ "LKA",
5513
+ "LSO",
5514
+ "LTU",
5515
+ "LUX",
5516
+ "LVA",
5517
+ "MAC",
5518
+ "MAF",
5519
+ "MAR",
5520
+ "MCO",
5521
+ "MDA",
5522
+ "MDG",
5523
+ "MDV",
5524
+ "MEX",
5525
+ "MHL",
5526
+ "MKD",
5527
+ "MLI",
5528
+ "MLT",
5529
+ "MMR",
5530
+ "MNE",
5531
+ "MNG",
5532
+ "MNP",
5533
+ "MOZ",
5534
+ "MRT",
5535
+ "MSR",
5536
+ "MTQ",
5537
+ "MUS",
5538
+ "MWI",
5539
+ "MYS",
5540
+ "MYT",
5541
+ "NAM",
5542
+ "NCL",
5543
+ "NER",
5544
+ "NFK",
5545
+ "NGA",
5546
+ "NIC",
5547
+ "NIU",
5548
+ "NLD",
5549
+ "NOR",
5550
+ "NPL",
5551
+ "NRU",
5552
+ "NZL",
5553
+ "OMN",
5554
+ "PAK",
5555
+ "PAN",
5556
+ "PCN",
5557
+ "PER",
5558
+ "PHL",
5559
+ "PLW",
5560
+ "PNG",
5561
+ "POL",
5562
+ "PRI",
5563
+ "PRK",
5564
+ "PRT",
5565
+ "PRY",
5566
+ "PSE",
5567
+ "PYF",
5568
+ "QAT",
5569
+ "REU",
5570
+ "ROU",
5571
+ "RUS",
5572
+ "RWA",
5573
+ "SAU",
5574
+ "SDN",
5575
+ "SEN",
5576
+ "SGP",
5577
+ "SGS",
5578
+ "SHN",
5579
+ "SJM",
5580
+ "SLB",
5581
+ "SLE",
5582
+ "SLV",
5583
+ "SMR",
5584
+ "SOM",
5585
+ "SPM",
5586
+ "SRB",
5587
+ "SSD",
5588
+ "STP",
5589
+ "SUR",
5590
+ "SVK",
5591
+ "SVN",
5592
+ "SWE",
5593
+ "SWZ",
5594
+ "SXM",
5595
+ "SYC",
5596
+ "SYR",
5597
+ "TCA",
5598
+ "TCD",
5599
+ "TGO",
5600
+ "THA",
5601
+ "TJK",
5602
+ "TKL",
5603
+ "TKM",
5604
+ "TLS",
5605
+ "TON",
5606
+ "TTO",
5607
+ "TUN",
5608
+ "TUR",
5609
+ "TUV",
5610
+ "TWN",
5611
+ "TZA",
5612
+ "UGA",
5613
+ "UKR",
5614
+ "UMI",
5615
+ "URY",
5616
+ "USA",
5617
+ "UZB",
5618
+ "VAT",
5619
+ "VCT",
5620
+ "VEN",
5621
+ "VGB",
5622
+ "VIR",
5623
+ "VNM",
5624
+ "VUT",
5625
+ "WLF",
5626
+ "WSM",
5627
+ "XKX",
5628
+ "YEM",
5629
+ "ZAF",
5630
+ "ZMB",
5631
+ "ZWE"
5632
+ ]
5633
+ }
src/data360/server.py ADDED
@@ -0,0 +1,374 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import hashlib
2
+ import json
3
+ import logging
4
+ import os
5
+ import uuid
6
+
7
+ from contextlib import asynccontextmanager
8
+ from datetime import UTC, datetime
9
+ from typing import Any, Dict, List, Optional
10
+
11
+ from fastapi import FastAPI, Request
12
+ from fastapi.responses import JSONResponse
13
+ from fastapi.staticfiles import StaticFiles
14
+ from pydantic import BaseModel
15
+ from data360.mcp_server.resources import CORSStaticFiles
16
+ from starlette.middleware.base import BaseHTTPMiddleware
17
+ from starlette.requests import Request as StarletteRequest
18
+
19
+ from data360.config import get_mcp_server_settings, setup_logging
20
+ from data360.health import get_liveness_body, run_readiness
21
+ from data360.http_client import aclose_shared_httpx_client
22
+ from data360.otel_setup import (
23
+ configure_open_telemetry_for_server,
24
+ instrument_httpx_outbound,
25
+ )
26
+
27
+ _audit_logger = logging.getLogger("audit")
28
+ _telemetry_client = None
29
+
30
+ # Setup logging from configuration
31
+ import sys
32
+
33
+ _logger = logging.getLogger(__name__)
34
+ mcp_settings = get_mcp_server_settings()
35
+
36
+ # Parse port from argv, supporting both '--port 8021' and '--port=8021' forms.
37
+ _parsed_port: int | None = None
38
+ for _arg in sys.argv:
39
+ if _arg.startswith("--port="):
40
+ try:
41
+ _parsed_port = int(_arg.split("=", 1)[1])
42
+ except ValueError:
43
+ logging.warning("Could not parse port from argv argument '%s'; using default.", _arg)
44
+ break
45
+ if _arg == "--port":
46
+ _idx = sys.argv.index(_arg)
47
+ try:
48
+ _parsed_port = int(sys.argv[_idx + 1])
49
+ except (ValueError, IndexError):
50
+ logging.warning("Could not parse port after '--port' in argv; using default.")
51
+ break
52
+ if _parsed_port is not None:
53
+ mcp_settings.port = _parsed_port
54
+
55
+ setup_logging(
56
+ log_file=mcp_settings.log_file,
57
+ log_level=mcp_settings.log_level,
58
+ env=mcp_settings.env,
59
+ azure_connection_string=mcp_settings.azure_connection_string,
60
+ )
61
+
62
+ # Tracer export (Azure in deployed envs; optional OTLP/console when MCP_ENV=local) and httpx spans
63
+ configure_open_telemetry_for_server(mcp_settings)
64
+ instrument_httpx_outbound()
65
+
66
+ # Import MCP after telemetry so the process uses an instrumented httpx from the first request.
67
+ from data360.mcp_server import mcp # noqa: E402
68
+
69
+ _connection_string = mcp_settings.azure_connection_string or os.environ.get(
70
+ "APPLICATIONINSIGHTS_CONNECTION_STRING"
71
+ )
72
+
73
+ # Initialize OpenCensus TelemetryClient for custom events (forwarded to Splunk)
74
+ if mcp_settings.env != "local" and _connection_string:
75
+ try:
76
+ from opencensus.ext.azure.log_exporter import (
77
+ AzureEventHandler, # type: ignore[import-untyped]
78
+ )
79
+
80
+ # Create a dedicated logger for custom events
81
+ event_logger = logging.getLogger("customEvents")
82
+ event_logger.setLevel(logging.INFO)
83
+
84
+ # Add Azure handler that sends to customEvents table
85
+ azure_handler = AzureEventHandler(connection_string=_connection_string)
86
+ event_logger.addHandler(azure_handler)
87
+
88
+ _telemetry_client = event_logger
89
+ except ImportError:
90
+ pass
91
+
92
+
93
+ class SecurityValidationMiddleware(BaseHTTPMiddleware):
94
+ """Validate MCP tool calls to prevent prompt injection and unauthorized access."""
95
+
96
+ async def dispatch(self, request: Request, call_next):
97
+ # Only validate MCP JSON-RPC tool calls (not health probes under /mcp/*)
98
+ if request.url.path in ("/mcp/health", "/mcp/ready"):
99
+ return await call_next(request)
100
+ if not request.url.path.startswith("/mcp"):
101
+ return await call_next(request)
102
+
103
+ try:
104
+ # Read and parse body
105
+ body_bytes = await request.body()
106
+ if not body_bytes:
107
+ return await call_next(request)
108
+
109
+ body = json.loads(body_bytes)
110
+ method = body.get("method", "")
111
+
112
+ # Log tools/list requests for monitoring (allowed but monitored)
113
+ if method == "tools/list":
114
+ client_ip = request.headers.get(
115
+ "X-Forwarded-For",
116
+ request.client.host if request.client else "unknown",
117
+ )
118
+ logging.info(f"tools/list called from IP: {client_ip}")
119
+
120
+ # Validate tools/call requests
121
+ if method == "tools/call":
122
+ from data360.mcp_server.security_validator import ( # noqa: PLC0415
123
+ validate_search_arguments,
124
+ validate_tool_call,
125
+ )
126
+
127
+ params = body.get("params", {})
128
+ tool_name = params.get("name", "")
129
+ arguments = params.get("arguments", {})
130
+
131
+ # Validate tool call
132
+ is_valid, error_msg = validate_tool_call(tool_name, arguments)
133
+ if not is_valid:
134
+ logging.warning(
135
+ f"Security violation: {error_msg} | Tool: {tool_name}"
136
+ )
137
+ return JSONResponse(
138
+ status_code=403,
139
+ content={
140
+ "jsonrpc": "2.0",
141
+ "id": body.get("id"),
142
+ "error": {"code": -32001, "message": error_msg},
143
+ },
144
+ )
145
+
146
+ # Additional validation for all search term inputs
147
+ if tool_name == "data360_search_indicators":
148
+ is_valid, error_msg = validate_search_arguments(arguments)
149
+ if not is_valid:
150
+ logging.warning(
151
+ "Search query blocked: %s",
152
+ str(arguments)[:200],
153
+ )
154
+ return JSONResponse(
155
+ status_code=403,
156
+ content={
157
+ "jsonrpc": "2.0",
158
+ "id": body.get("id"),
159
+ "error": {"code": -32001, "message": error_msg},
160
+ },
161
+ )
162
+
163
+ except json.JSONDecodeError:
164
+ pass # Let MCP handle invalid JSON
165
+ except Exception:
166
+ logging.error("Security validation error", exc_info=True)
167
+ # Continue on validation errors to avoid blocking legitimate requests
168
+
169
+ return await call_next(request)
170
+
171
+
172
+ class AuditLogMiddleware(BaseHTTPMiddleware):
173
+ """Log structured audit entries for every MCP request."""
174
+
175
+ async def dispatch(self, request: Request, call_next):
176
+ # Only audit MCP JSON-RPC calls (not health probes)
177
+ if request.url.path in ("/mcp/health", "/mcp/ready"):
178
+ return await call_next(request)
179
+ if not request.url.path.startswith("/mcp"):
180
+ return await call_next(request)
181
+ session_id = str(uuid.uuid4())
182
+ requestor_id = request.headers.get(
183
+ "X-Forwarded-For", request.client.host if request.client else "unknown"
184
+ )
185
+ timestamp = datetime.now(UTC).isoformat()
186
+ # Read and restore body so downstream handlers still receive it
187
+ body_bytes = await request.body()
188
+ prompt = ""
189
+ prompt_hash = ""
190
+ try:
191
+ body = json.loads(body_bytes)
192
+ method = body.get("method", "")
193
+ params = body.get("params", {})
194
+ prompt = json.dumps(
195
+ {"method": method, "params": params}, separators=(",", ":")
196
+ )
197
+ prompt_hash = hashlib.sha256(prompt.encode()).hexdigest()[:16]
198
+ except Exception:
199
+ pass
200
+ response = await call_next(request)
201
+
202
+ properties = {
203
+ "session_id": session_id,
204
+ "requestor_id": requestor_id,
205
+ "timestamp": timestamp,
206
+ "prompt": prompt,
207
+ "prompt_hash": prompt_hash,
208
+ "status_code": str(response.status_code),
209
+ "path": request.url.path,
210
+ }
211
+
212
+ # Log to traces with custom dimensions
213
+ _audit_logger.info("mcp_audit", extra={"custom_dimensions": properties})
214
+
215
+ # Also log as custom event for Splunk forwarding
216
+ if _telemetry_client:
217
+ _telemetry_client.info(
218
+ "MCP_Request",
219
+ extra={
220
+ "custom_dimensions": properties,
221
+ "event_name": "MCP_Request",
222
+ },
223
+ )
224
+
225
+ return response
226
+
227
+
228
+ from fastmcp import settings
229
+ settings.stateless_http = True
230
+
231
+ # NOTE: import to be able to run the server with all definitions loaded
232
+ # path="/mcp" means the MCP endpoint lives at /mcp (no trailing slash needed)
233
+ mcp_app = mcp.http_app(path="/mcp")
234
+
235
+
236
+ async def health_check(request: StarletteRequest) -> JSONResponse:
237
+ """Liveness probe under the MCP URL prefix (GET /mcp/health)."""
238
+ del request
239
+ return JSONResponse(get_liveness_body())
240
+
241
+
242
+ async def ready_check(request: StarletteRequest) -> JSONResponse:
243
+ """Readiness probe under the MCP URL prefix (GET /mcp/ready)."""
244
+ del request
245
+ status_code, body = await run_readiness()
246
+ return JSONResponse(content=body, status_code=status_code)
247
+
248
+
249
+ # Starlette routes on mcp_app: paths are absolute from mount root (not nested under /mcp).
250
+ # Use /mcp/health so probes sit beside the streamable HTTP endpoint at /mcp.
251
+ mcp_app.add_route("/mcp/health", health_check, methods=["GET", "HEAD"])
252
+ mcp_app.add_route("/mcp/ready", ready_check, methods=["GET", "HEAD"])
253
+
254
+
255
+ @asynccontextmanager
256
+ async def _lifespan_with_http_cleanup(app: FastAPI):
257
+ """Run MCP startup/shutdown, then close the shared httpx client."""
258
+ async with mcp_app.router.lifespan_context(mcp_app):
259
+ yield
260
+ await aclose_shared_httpx_client()
261
+
262
+
263
+ # https://gofastmcp.com/deployment/http#asgi-application
264
+ # redirect_slashes=False prevents 308 redirects between /mcp and /mcp/
265
+ app = FastAPI(
266
+ title="Data360 MCP Server",
267
+ lifespan=_lifespan_with_http_cleanup,
268
+ redirect_slashes=False,
269
+ ) # pyright: ignore[reportUnusedExpression]
270
+
271
+ app.add_middleware(AuditLogMiddleware)
272
+ # SecurityValidationMiddleware is enabled for incoming request validation.
273
+ app.add_middleware(SecurityValidationMiddleware)
274
+
275
+ from fastapi.middleware.cors import CORSMiddleware
276
+ app.add_middleware(
277
+ CORSMiddleware,
278
+ allow_origins=["*"],
279
+ allow_credentials=True,
280
+ allow_methods=["*"],
281
+ allow_headers=["*"],
282
+ )
283
+
284
+ # Instrument FastAPI for incoming request tracking
285
+ if mcp_settings.env != "local" and _connection_string:
286
+ try:
287
+ from opentelemetry.instrumentation.fastapi import (
288
+ FastAPIInstrumentor, # type: ignore[import-untyped]
289
+ )
290
+
291
+ FastAPIInstrumentor.instrument_app(app)
292
+ except ImportError:
293
+ pass
294
+
295
+
296
+ @app.get("/")
297
+ async def root():
298
+ return {
299
+ "service": "data360-mcp",
300
+ "health": "/mcp/health",
301
+ "ready": "/mcp/ready",
302
+ "mcp": "/mcp",
303
+ }
304
+
305
+
306
+
307
+ class VizSpecRequest(BaseModel):
308
+ database_id: str
309
+ indicator_id: str
310
+ country_code: str | None = None
311
+ start_year: int | None = None
312
+ end_year: int | None = None
313
+ disaggregation_filters: dict[str, str | None] | None = None
314
+ chart_type: str | None = None
315
+ relevant_fields: list[str] | None = None
316
+ chart_title: str | None = None
317
+ series_labels: dict[str, str] | None = None
318
+
319
+
320
+ @app.post("/api/viz-spec")
321
+ async def get_viz_spec_endpoint(req: VizSpecRequest):
322
+ from data360 import visualization as data360_viz
323
+
324
+ try:
325
+ # Pass charts_api_url_override=None to force local static file storage,
326
+ # avoiding mutation of the shared @ft.cache singleton (which is a race condition
327
+ # under concurrent requests). The HTML app always wants local specs for direct
328
+ # file access; blob storage routing happens via the MCP tool path, not this endpoint.
329
+ res = await data360_viz.get_viz_spec(
330
+ database_id=req.database_id,
331
+ indicator_id=req.indicator_id,
332
+ country_code=req.country_code,
333
+ start_year=req.start_year,
334
+ end_year=req.end_year,
335
+ disaggregation_filters=req.disaggregation_filters,
336
+ chart_type=req.chart_type,
337
+ relevant_fields=req.relevant_fields,
338
+ chart_title=req.chart_title,
339
+ series_labels=req.series_labels,
340
+ charts_api_url_override=None,
341
+ )
342
+ except Exception as e:
343
+ return JSONResponse(status_code=500, content={"error": str(e)})
344
+
345
+ if res.get("error"):
346
+ return JSONResponse(status_code=400, content={"error": res.get("error")})
347
+
348
+ spec = res.get("spec")
349
+ if not spec:
350
+ return JSONResponse(status_code=500, content={"error": "Vega-Lite spec was not generated."})
351
+
352
+ return {k: v for k, v in {
353
+ "spec": spec,
354
+ "reason": res.get("reason"),
355
+ "strategy": res.get("strategy"),
356
+ }.items() if v is not None}
357
+
358
+
359
+
360
+
361
+ if os.environ.get("PYTEST_CURRENT_TEST"):
362
+ static_dir = os.path.join(os.getcwd(), "static")
363
+ else:
364
+ server_dir = os.path.dirname(os.path.abspath(__file__))
365
+ project_root = os.path.abspath(os.path.join(server_dir, "..", ".."))
366
+ static_dir = os.path.join(project_root, "static")
367
+ os.makedirs(static_dir, exist_ok=True)
368
+ app.mount("/static", CORSStaticFiles(directory=static_dir), name="static")
369
+ # Mount MCP app at root — the path="/mcp" in http_app() handles the /mcp route
370
+ app.mount("/", mcp_app)
371
+
372
+
373
+ # Tools and other resources are automatically registered via imports in mcp_server/__init__.py
374
+ # See src/data360/mcp_server/tools.py for tool definitions
src/data360/tool_contract.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared versioning between MCP tool JSON contracts and `@data360/tool-types` / UI packages."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from functools import lru_cache
7
+ from importlib import resources
8
+
9
+
10
+ @lru_cache(maxsize=1)
11
+ def get_tool_contract_version() -> str:
12
+ """Return the tool contract version (semver) shipped with this server build."""
13
+ raw = resources.files("data360").joinpath("tool_contract_version.json").read_text(
14
+ encoding="utf-8"
15
+ )
16
+ data = json.loads(raw)
17
+ version = data.get("version")
18
+ if not isinstance(version, str) or not version.strip():
19
+ raise ValueError("tool_contract_version.json: missing or invalid 'version'")
20
+ return version.strip()
src/data360/tool_contract_version.json ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ {
2
+ "version": "1.0.0",
3
+ "description": "Bump this and @data360/tool-types / UI packages when MCP tool JSON shapes change in a breaking way."
4
+ }
src/data360/visualization.py ADDED
The diff for this file is too large to render. See raw diff
 
src/data360/viz_config.py ADDED
The diff for this file is too large to render. See raw diff
 
uv.lock ADDED
The diff for this file is too large to render. See raw diff