Hugging Face Deployer commited on
Commit ·
2f203f5
0
Parent(s):
Deploy explorer to Hugging Face Spaces
Browse files- Dockerfile +58 -0
- README.md +39 -0
- deploy.sh +94 -0
- entrypoint.sh +13 -0
- evals/interactive_explorer.py +0 -0
- pyproject.toml +100 -0
- src/data360/__init__.py +5 -0
- src/data360/api.py +0 -0
- src/data360/config.py +227 -0
- src/data360/databases.json +163 -0
- src/data360/errors.py +348 -0
- src/data360/health.py +278 -0
- src/data360/http_client.py +39 -0
- src/data360/mcp_server/__init__.py +13 -0
- src/data360/mcp_server/__main__.py +32 -0
- src/data360/mcp_server/_server_definition.py +7 -0
- src/data360/mcp_server/agent_recipe.py +80 -0
- src/data360/mcp_server/prompts.py +571 -0
- src/data360/mcp_server/resources.py +624 -0
- src/data360/mcp_server/security_validator.py +228 -0
- src/data360/mcp_server/tool_spans.py +40 -0
- src/data360/mcp_server/tools.py +1605 -0
- src/data360/models.py +942 -0
- src/data360/otel_setup.py +179 -0
- src/data360/providers.py +1512 -0
- src/data360/ref_area_groups.json +5633 -0
- src/data360/server.py +374 -0
- src/data360/tool_contract.py +20 -0
- src/data360/tool_contract_version.json +4 -0
- src/data360/visualization.py +0 -0
- src/data360/viz_config.py +0 -0
- uv.lock +0 -0
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
|
|
|