import asyncio import json import logging import math import threading import zlib from typing import Any, Literal from urllib.parse import urlencode import cachetools import dotenv import numpy as np import pandas as pd from pydantic import ValidationError as PydanticValidationError from sklearn.linear_model import HuberRegressor from .config import get_data360_settings from .errors import ( Data360MCPError, NotFoundError, ParseError, classify_error, ) from .errors import ValidationError as Data360ValidationError from .http_client import get_shared_httpx_client from .models import ( ComparisonSnapshot, ComparisonTimeSeries, CountryComparisonResponse, DatasetDescription, DatasetSearchRequest, DatasetSearchResponse, DataSummaryResponse, DerivedDataResponse, DiagnosticSummaryResponse, DiscoveryResult, EnrichedIndicator, EnrichedSearchResponse, ExcludedCountry, GroupSummary, IndicatorDataRequest, IndicatorDataResponse, MetadataRequest, MetadataResponse, MultiQuerySearchResponse, PivotTableResponse, QueryGroup, QueryGroupResult, RankedCountry, RankingResponse, SearchRequest, SearchResponse, SeriesDescription, ) from .providers import get_database_mapping dotenv.load_dotenv() _logger = logging.getLogger(__name__) data360_config = get_data360_settings() # Constants for API logic COUNTRY_CODE_LENGTH = 3 SCORE_THRESHOLD = 70 MAX_RETURN_STATEMENTS = 6 DEFAULT_SEARCH_LIMIT = 5 # --------------------------------------------------------------------------- # Metadata / disaggregation response caches (Comment 1+8 — avsolatorio PR #75) # --------------------------------------------------------------------------- # Metadata changes rarely; a 1-day TTL prevents stale data on long-running # servers while eliminating redundant HTTP calls across multi-page aggregation # fetches (e.g. _fetch_all_pages issues one get_data call per page, each of # which would otherwise re-fetch the same metadata). _METADATA_CACHE_TTL = 86_400 # 24 hours in seconds _metadata_cache: cachetools.TTLCache = cachetools.TTLCache( maxsize=256, ttl=_METADATA_CACHE_TTL ) _disaggregation_cache: cachetools.TTLCache = cachetools.TTLCache( maxsize=256, ttl=_METADATA_CACHE_TTL ) # Locks to prevent thundering-herd on cache misses under async concurrency. _metadata_cache_lock = threading.Lock() _disaggregation_cache_lock = threading.Lock() _DIMENSIONS_API_CACHE_TTL = 600 # 10 minutes in seconds _dimensions_api_cache: cachetools.TTLCache = cachetools.TTLCache( maxsize=256, ttl=_DIMENSIONS_API_CACHE_TTL ) _dimensions_api_cache_lock = threading.Lock() _dimensions_api_inflight: dict[tuple[str, str], asyncio.Task] = {} _dimensions_api_inflight_lock = threading.Lock() # Fields fetched by _search_raw for LLM-friendly enrichment. # Shared between single-query and multi-query paths to ensure consistency. _ENRICHMENT_SELECT_FIELDS = [ "idno", "name", "database_id", "definition_long", "periodicity", "time_periods", "ref_country", "dimensions", "measurement_unit", ] # Prefiltering constants for get_data output. # Based on a 16-database survey (see payload_analysis.md for full documentation). # Always-keep fields: the core data the LLM needs. _CORE_FIELDS = frozenset( { "OBS_VALUE", "TIME_PERIOD", "REF_AREA", "REF_AREA_NAME", "country_name", "UNIT_MEASURE", "UNIT_MEASURE_NAME", "UNIT_MULT", "claim_id", } ) # Conditional fields: kept only when their value is non-trivial (not _T or _Z). # SEX/AGE/URBANISATION carry real disaggregation in WB_HCP, WB_SSGD, OECD_IDD. # COMP_BREAKDOWN_1/2 carry semantic data in IPC_IPC, OECD_BROADBAND, WB_SE4ALL, WEF_TTDI. _CONDITIONAL_FIELDS = frozenset( {"SEX", "AGE", "URBANISATION", "COMP_BREAKDOWN_1", "COMP_BREAKDOWN_2"} ) # SDMX standard codes meaning "total" and "not applicable". _TRIVIAL_VALUES = frozenset({"_T", "_Z"}) def _short_hash(data: dict[str, Any]) -> str: """PCN claim_id 8-character hash for data verification.""" return f"{zlib.crc32(json.dumps(data, sort_keys=True).encode()) & 0xFFFFFFFF:08x}" def _qualify_unit_name(unit_name: str | None, unit_mult: Any, unit_code: str | None = None) -> str | None: """Qualify a unit name/label using its unit multiplier (e.g. 'million people').""" if not unit_mult: return unit_name try: mult = int(unit_mult) except (ValueError, TypeError): return unit_name if mult == 0: return unit_name mult_map = { 3: "thousand", 6: "million", 9: "billion", 12: "trillion" } qualifier = mult_map.get(mult) if not qualifier: return unit_name if not unit_name: return qualifier unit_norm = unit_name.strip().lower() is_people = (unit_code and unit_code.upper() == "PS") or (unit_norm in ("persons", "people")) if is_people: return f"{qualifier} people" return f"{qualifier} {unit_name}" def _obs_value_to_float(val: Any) -> float | None: """Parse OBS_VALUE for aggregation paths; None if missing or non-numeric. The Data API sometimes returns the literal string ``\"null\"`` for missing values; ``val is not None`` is therefore not sufficient before ``float()``.""" if val is None: return None if isinstance(val, str): stripped = val.strip() if not stripped or stripped.lower() in ("null", "nan", "none"): return None try: out = float(val) except (TypeError, ValueError): return None if math.isnan(out): return None return out def _get_valid_disaggregations( disagg_res: list[dict[str, Any]], ) -> list[dict[str, Any]]: """Get valid disaggregation options from the raw response.""" null_values = ["_Z"] valid = [] for field in disagg_res: if field.get("field_value", ["_T"])[0] in null_values: continue else: valid.append(field) return valid def _parse_dimensions_response(dimensions_data: dict[str, Any]) -> list[dict[str, Any]]: """Translate the new dimensions API response back to the old disaggregation structure.""" raw_disaggregations = [] null_values = {"_Z"} for dim in dimensions_data.get("dimensions", []): field_name = dim.get("field_name") label_name = dim.get("label_name") codes = [] for val in dim.get("field_value", []): code = None if isinstance(val, dict) and "code" in val: code = val["code"] elif isinstance(val, str): code = val if code is not None and code not in null_values: codes.append(code) if field_name and codes: item = { "field_name": field_name, "field_value": codes, } if label_name is not None: item["label_name"] = label_name raw_disaggregations.append(item) return raw_disaggregations async def _fetch_dimensions_raw_uncached( database_id: str, indicator_id: str, ) -> dict[str, Any]: """Fetch dimensions from the API without caching, raising exceptions on failure. Only updates the cache on success. Pops itself from inflight tasks on completion. """ _cache_key = (database_id, indicator_id) try: dimensions_url = ( data360_config.dimensions_url or f"{data360_config.api_url}/portal/v1/dimensions" ) headers = {"accept": "*/*", "Content-Type": "application/json"} payload = {"database_id": database_id, "indicator_id": indicator_id} client = get_shared_httpx_client() response = await client.post( dimensions_url, json=payload, headers=headers, ) if response.status_code in (400, 404, 417): result = {"dimensions": []} with _dimensions_api_cache_lock: _dimensions_api_cache[_cache_key] = result return result response.raise_for_status() if not response.content or not response.text.strip(): result = {"dimensions": []} else: result = response.json() with _dimensions_api_cache_lock: _dimensions_api_cache[_cache_key] = result return result finally: with _dimensions_api_inflight_lock: _dimensions_api_inflight.pop(_cache_key, None) async def _fetch_dimensions_with_cache( database_id: str, indicator_id: str, ) -> dict[str, Any]: """Fetch dimensions from the API with a 10-minute in-memory cache and concurrent request deduplication. Only caches successful responses. Returns parsed JSON dict. Raises httpx.HTTPStatusError or other request exceptions on non-400/404 failures. """ _cache_key = (database_id, indicator_id) with _dimensions_api_cache_lock: _cached = _dimensions_api_cache.get(_cache_key) if _cached is not None: return _cached with _dimensions_api_inflight_lock: task = _dimensions_api_inflight.get(_cache_key) if task is None: coro = _fetch_dimensions_raw_uncached(database_id, indicator_id) task = asyncio.create_task(coro) _dimensions_api_inflight[_cache_key] = task return await task def _strip_data_row(row: dict[str, Any]) -> dict[str, Any]: """Strip boilerplate fields from a data row for LLM token savings. Keeps core fields (OBS_VALUE, TIME_PERIOD, REF_AREA, UNIT_MEASURE, claim_id) and conditionally includes disaggregation fields (SEX, AGE, URBANISATION, COMP_BREAKDOWN_1, COMP_BREAKDOWN_2) only when their value is non-trivial (i.e. not _T or _Z). Note: COMP_BREAKDOWN_3 is intentionally excluded -- it was observed as ``_Z`` across all 16 surveyed databases and never carries data. Based on a 16-database survey documented in docs/payload_analysis.md. """ filtered = {k: v for k, v in row.items() if k in _CORE_FIELDS} for field in _CONDITIONAL_FIELDS: val = row.get(field) if val and val not in _TRIVIAL_VALUES: filtered[field] = val return filtered # Dimensions to always strip from disaggregation output. _STRIP_DIMENSIONS = frozenset({"INDICATOR", "FREQ"}) # Dimensions where a single _T value means "no disaggregation available". _TRIVIAL_SINGLE_DIMENSIONS = frozenset({"SEX", "AGE", "URBANISATION"}) # Maximum number of REF_AREA codes to include in the sample. _REF_AREA_SAMPLE_SIZE = 5 def _strip_disaggregation( dimensions: list[dict[str, Any]], queried_countries: list[str] | None = None, ) -> list[dict[str, Any]]: """Strip bloat from disaggregation dimensions for LLM token savings. Rules: 1. Remove INDICATOR and FREQ (always single-value, already known). 2. Remove SEX/AGE/URBANISATION if only value is _T (no disaggregation). 3. Sort TIME_PERIOD chronologically. 4. Summarize REF_AREA: total count + which queried countries have data. """ result = [] for dim in dimensions: name = dim.get("field_name", "") values = dim.get("field_value", []) # Rule 1: strip trivial dimensions if name in _STRIP_DIMENSIONS: continue # Rule 2: strip single-value _T dimensions if name in _TRIVIAL_SINGLE_DIMENSIONS and values == ["_T"]: continue entry: dict[str, Any] = {"field_name": name} # Preserve label_name if present if "label_name" in dim: entry["label_name"] = dim["label_name"] # Rule 3: sort TIME_PERIOD if name == "TIME_PERIOD": entry["field_value"] = sorted(values) # Rule 4: summarize REF_AREA with country lookup elif name == "REF_AREA": entry["count"] = len(values) if queried_countries: ref_set = set(values) entry["queried"] = {code: code in ref_set for code in queried_countries} else: entry["sample"] = sorted(values)[:_REF_AREA_SAMPLE_SIZE] else: entry["field_value"] = values result.append(entry) return result async def _resolve_queried_countries( required_country: str | None, ) -> list[str] | None: """Resolve a required_country string into a list of 3-letter codes. Returns None if required_country is falsy or resolution fails. """ if not required_country: return None resolved = await _resolve_country_code(required_country) if not resolved: return None return [c.strip() for c in resolved.split(";") if c.strip()] def _validate_user_filters( user_filters: dict[str, str | None] | None, available_disaggregations: dict[str, list[str]], ) -> tuple[dict[str, str | None], list[str]]: """Validate user filters against available options. Returns: Tuple of (valid_filters_dict, error_messages_list). """ if not user_filters: return {}, [] valid_filters = {} errors = [] for dim, val in user_filters.items(): # Skip special 'None' filters (meaning "all") or known non-dims like REF_AREA if we don't have metadata for them # Note: API metadata usually returns REF_AREA as a dimension too if valid. if val is None: valid_filters[dim] = None continue # Treat empty or whitespace-only strings as unspecified (skip to let smart defaults apply) if isinstance(val, str) and not val.strip(): continue # Align with country_code convention: semicolons in REF_AREA become commas for the Data API. if dim == "REF_AREA" and isinstance(val, str) and ";" in val: val = ",".join(p.strip() for p in val.split(";") if p.strip()) # If dimension exists in metadata, check value if dim in available_disaggregations: valid_values = available_disaggregations[dim] is_valid = True # Support comma-separated lists for any dimension if "," in val: parts = [p.strip() for p in val.split(",") if p.strip()] for part in parts: if part not in valid_values: errors.append( f"Invalid value '{part}' in '{val}' for dimension '{dim}'. Available options: {valid_values}" ) is_valid = False # Standard single value check elif val not in valid_values: errors.append( f"Invalid value '{val}' for dimension '{dim}'. Available options: {valid_values}" ) is_valid = False if is_valid: valid_filters[dim] = val else: # If dimension is unknown, we treat it as valid/passthrough for now # but we could also flag it. For consistency with previous behavior, # we'll keep it as valid. valid_filters[dim] = val return valid_filters, errors def _build_disaggregation_params( disaggregation_filters: dict[str, str | None] | None, available_disaggregations: dict[str, list[str]] | None = None, ) -> dict[str, str]: """Build effective disaggregation params with smart defaults. This is the single source of truth for disaggregation filtering logic. Used by both get_data() and get_data_api_url(). Args: disaggregation_filters: User-provided filters. - None or {}: Use defaults (SEX=_T, AGE=_T, URBANISATION=_T) - {"SEX": "F"}: Use F for SEX, defaults for others - {"SEX": None}: Omit SEX filter (get all values), defaults for others available_disaggregations: Optional dict of {dimension: [values]} derived from indicator metadata. If provided, defaults (like _T) are only applied if they exist in the available values. Returns: Dict of dimension -> value to add to API params. Dimensions with None values are omitted (API returns all). """ effective = {} # 1. Start with user-provided filters if disaggregation_filters: for dim, raw_val in disaggregation_filters.items(): # Skip FREQ as it's not used for filtering in this API if dim == "FREQ": continue if raw_val is not None: # Clean value: remove spaces around commas for multi-value support val = raw_val if isinstance(val, str) and "," in val: val = ",".join([p.strip() for p in val.split(",") if p.strip()]) effective[dim] = val # 2. Apply smart defaults for unspecified dimensions if available_disaggregations: for dim, values in available_disaggregations.items(): # Skip if user already specified/excluded this dimension if disaggregation_filters and dim in disaggregation_filters: continue # Check if this dimension has a "Total" option (_T) if "_T" in values: # Redundancy check: if _T is the ONLY option, don't force it if len(values) == 1: continue # Otherwise, apply default effective[dim] = "_T" else: # Fallback for when metadata wasn't fetched or failed. # We CANNOT safely apply defaults like AGE=_T because we don't know if they exist. # It is better to return ALL data (no filter) than NONE (invalid filter). # So we leave 'effective' as-is (containing only user provided filters). pass return effective def _get_items_from_response(response_data: dict[str, Any]) -> list[SeriesDescription]: """Extract and validate series descriptions from API response. Supports both legacy nested SearchV2 format and flat SearchV3 format. """ values = response_data.get("value") if values is None: values = response_data.get("results") if values is None: values = response_data.get("items", []) items = [] for value in values: if "series_description" in value: # V2 format series_description = value.get("series_description", {}) additional = value.get("additional") if additional and isinstance(additional, dict): ml = additional.get("metadata_link") if ml and isinstance(ml, list): series_description["metadata_link"] = ml else: # V3 flat format databases = value.get("databases", []) db_id = None if databases and isinstance(databases, list) and len(databases) > 0: db_id = ( databases[0].get("idno") if isinstance(databases[0], dict) else None ) tp = value.get("time_period") time_periods = None if tp: time_periods = tp if isinstance(tp, list) else [tp] dims = value.get("dimensions", []) dimensions = [] if isinstance(dims, list): for d in dims: if isinstance(d, dict): dimensions.append(d) elif isinstance(d, str): dimensions.append({"label": d}) ml = value.get("metadata_link") if ml is None: additional = value.get("additional") if isinstance(additional, dict): ml = additional.get("metadata_link") series_description = { "idno": value.get("idno"), "name": value.get("name"), "database_id": db_id or value.get("database_id"), "definition_long": value.get("description") or value.get("definition_long"), "periodicity": value.get("frequency") or value.get("periodicity"), "time_periods": time_periods, "ref_country": value.get("ref_country"), "dimensions": dimensions, "metadata_link": ml or [], "connected_entities": value.get("connected_entities") if isinstance(value.get("connected_entities"), list) else None, } if ( series_description and series_description.get("idno") and series_description.get("name") and series_description.get("database_id") ): try: items.append(SeriesDescription.model_validate(series_description)) except Exception as e: _logger.warning( f"Failed to validate series_description: [{series_description}], error: {e}, skipping item" ) return items def _process_search_response( response_data: dict[str, Any], request: SearchRequest ) -> SearchResponse: """Process API response and build SearchResponse.""" search_response_data = { "items": _get_items_from_response(response_data), "total_count": response_data.get("count") if "count" in response_data else response_data.get("@odata.count"), "offset": request.offset, } search_response_data["count"] = len(search_response_data["items"]) # Calculate has_more and next_offset if ( search_response_data["total_count"] is not None and search_response_data["total_count"] > request.offset + request.limit ): search_response_data["has_more"] = True search_response_data["next_offset"] = request.offset + request.limit else: search_response_data["has_more"] = False search_response_data["next_offset"] = None return SearchResponse.model_validate(search_response_data) async def _search_raw( query: str, limit: int = 5, offset: int = 0, count: bool = True, economy_codes: list[str] | None = None, database: str | None = None, ) -> SearchResponse: """Internal: Raw search for data360 indicators using the World Bank Data360 API. This is the low-level API. Use `search()` for the enriched LLM-friendly version. Args: query: Search query string to find relevant data series limit: Number of results to return (default is 5) offset: Offset of the current page count: Whether to include total count in response economy_codes: Optional list of economy codes to filter the search results database: Optional database filter name or ID Returns: SearchResponse with raw API results. """ database_names = [] if database: from .providers import get_database_manager db_mgr = get_database_manager() try: db_ids = db_mgr.resolve_database_ids(database) mapping = await db_mgr.get_mapping() for db_id in db_ids: db_name = mapping.get(db_id) if db_name: database_names.append(db_name) except ValueError as e: return SearchResponse( error=str(e), items=[], total_count=0, count=0, ) request = SearchRequest( query=query, limit=limit, offset=offset, count=count, ) url = ( data360_config.search_url or f"{data360_config.api_url}/portal/v1/public_data360_search" ) payload = { "site": "data360", "query_string": request.query, "types": ["indicator"], "data_classification": ["public"], "skip": request.offset, "items_per_page": request.limit, } if economy_codes: payload["economy_codes"] = economy_codes if database_names: payload["database_names"] = database_names mcp_error: Data360MCPError | None = None try: client = get_shared_httpx_client() response = await client.post(url, json=payload) response.raise_for_status() try: response_data = response.json() except ValueError as e: raise ParseError(context="search", original_error=e) return _process_search_response(response_data, request) except Data360MCPError: # Re-raise our own errors to propagate isError: true to MCP client raise except Exception as e: # Convert unknown exceptions to Data360MCPError and raise raise classify_error(e, context="search") async def _resolve_country_code(country_query: str) -> str | None: """Resolve country name to code using cached REF_AREA codelist.""" from . import providers as data360_providers # noqa: PLC0415 if not country_query: return None # Handle multi-country: semicolons are the user-facing delimiter because # some country names contain commas (e.g. "Korea, Republic of"). if ";" in country_query: parts = [p.strip() for p in country_query.split(";") if p.strip()] resolved_codes = [] for part in parts: code = await _resolve_country_code(part) if code: resolved_codes.append(code) return ";".join(resolved_codes) if resolved_codes else None # Already a 3-letter code if len(country_query) == COUNTRY_CODE_LENGTH and country_query.isupper(): return country_query # Look up in codelist matches = await data360_providers.find_codelist_value( "REF_AREA", country_query, limit=1 ) if matches and matches[0].get("score", 0) >= SCORE_THRESHOLD: return matches[0].get("id") return None def _enrich_search_results( search_result: "SearchResponse", country_code: str | None, db_mapping: dict[str, str] | None = None, query: str | None = None, ) -> tuple[list["EnrichedIndicator"], list["EnrichedIndicator"]]: """Convert raw SearchResponse items into a list of EnrichedIndicator objects. Extracted to avoid duplicating enrichment logic between the single-query and multi-query paths in search(). Args: search_result: Raw response from _search_raw(). country_code: Resolved country code (or None). Used to compute covers_country. db_mapping: Optional dict mapping database_id -> human-readable name. Returns: Tuple of (indicators, indicators_to_verify). indicators: Full list of EnrichedIndicator objects. indicators_to_verify: Subset where covers_country has False entries that may be wrong because the search API omits group codes from ref_country. """ if not search_result.items: return [], [] _db = db_mapping or {} _label_to_code = { "sex": "SEX", "age": "AGE", "residential area": "URBANISATION", "urbanisation": "URBANISATION", "education": "EDUCATION", } # Determine upfront which requested codes are known groups/WLD — the search # API's ref_country array omits these even when the data endpoint supports them. from .providers import get_group_hierarchy_manager _group_manager = get_group_hierarchy_manager() _regional_codes: set[str] = set() if country_code: for c in country_code.split(";"): c = c.strip() if c and (c == "WLD" or _group_manager.is_group(c)): _regional_codes.add(c) indicators: list[EnrichedIndicator] = [] indicators_to_verify: list[EnrichedIndicator] = [] for item in search_result.items: raw = item.model_dump() # Extract latest_data and time_period_range time_periods = raw.get("time_periods", []) latest_data = None time_period_range = None if time_periods and isinstance(time_periods, list): tp = time_periods[0] if isinstance(time_periods[0], dict) else {} latest_data = tp.get("LATEST_DATA_POINT") or tp.get("end") start = tp.get("start") end = tp.get("end") if start and end: time_period_range = f"{start}-{end}" covers_country: dict[str, bool] | None = None if country_code: requested_codes = [c.strip() for c in country_code.split(";") if c.strip()] ref_list = raw.get("ref_country") # Under SearchV3, ref_country is absent or None. is_search_v3 = "ref_country" not in raw or raw["ref_country"] is None if len(requested_codes) == 1 and is_search_v3: # O(1) Optimization: Single-country queries filtered by economy_codes # are guaranteed to cover that country under SearchV3. covers_country = {requested_codes[0]: True} else: ref_countries = set() if ref_list and isinstance(ref_list, list): for rc in ref_list: if isinstance(rc, dict) and rc.get("code"): ref_countries.add(rc["code"]) elif isinstance(rc, str): ref_countries.add(rc) covers_country = { code: (code in ref_countries) for code in requested_codes } # Extract dimension names dimensions = raw.get("dimensions", []) useful_dims: list[str] = [] if dimensions and isinstance(dimensions, list): for dim in dimensions: if isinstance(dim, dict): label = (dim.get("label") or "").lower() if label in _label_to_code: useful_dims.append(_label_to_code[label]) # Apply primary indicator redirect if metadata_link has type='primary' # and a valid database_id (some entries have database_id=None). primary = item.primary_source original_idno: str | None = None if primary and primary.metadata_id and primary.database_id: original_idno = raw.get("idno") # Overwrite with primary source coordinates; raw["database_id"] is # read back into db_id on the next unconditional line below. raw["idno"] = primary.indicator_id raw["database_id"] = primary.database_id _logger.debug( "Redirected %s -> %s/%s (primary source)", original_idno, primary.database_id, primary.indicator_id, ) elif primary and primary.metadata_id and not primary.database_id: # database_id is None (e.g. META_SI.POV.MPWB) — cannot redirect # without a target database. Log for observability. _logger.warning( "Skipping primary redirect for %s: metadata_link has type='primary' " "but database_id is None (metadata_id=%s)", raw.get("idno"), primary.metadata_id, ) elif not primary and query and isinstance(raw.get("connected_entities"), list): # Check if query matches a connected entity (SearchV3 redirect direction is reversed) clean_query = query.strip().upper() for entity in raw["connected_entities"]: if ( isinstance(entity, dict) and entity.get("idno", "").upper() == clean_query ): original_idno = entity.get("idno") _logger.debug( "Mapped primary source %s -> %s/%s via connected_entities for query %s", original_idno, raw.get("database_id"), raw.get("idno"), query, ) break db_id = raw.get("database_id", "") ind = EnrichedIndicator( idno=raw.get("idno", ""), database_id=db_id, database_name=_db.get(db_id), name=raw.get("name", ""), truncated_definition=(raw.get("definition_long") or "")[:100], unit=raw.get("measurement_unit"), periodicity=raw.get("periodicity"), latest_data=latest_data, time_period_range=time_period_range, covers_country=covers_country, dimensions=useful_dims if useful_dims else None, primary_source_of=original_idno, ) indicators.append(ind) # Flag for verification if country_code is provided. if country_code and covers_country is not None: requested_codes = [c.strip() for c in country_code.split(";") if c.strip()] if len(requested_codes) > 1: indicators_to_verify.append(ind) # Set requested_country on all indicators for ind in indicators: ind.requested_country = country_code # Do not deduplicate here. `_enrich_search_results()` is used by both # single-query and multi-query flows, and multi-query needs access to the # full post-redirect candidate list so its `dedupe` flag and candidate # counts remain accurate. Any deduplication should happen in the caller # that owns the response semantics. return indicators, indicators_to_verify async def _backfill_primary_metadata( redirected: "list[EnrichedIndicator]", ) -> None: """Fetch fresh metadata for redirected primary indicators and update latest_data / time_period_range in place. When a secondary indicator is redirected to its primary source, the time-period data carried by the search result still belongs to the secondary (which may be a frozen snapshot while the primary is actively updated). This helper fetches the real time_periods from the primary indicator's metadata and patches both fields. Duplicate primary targets (multiple secondaries pointing to the same primary) are collapsed to a single fetch via the metadata cache. Args: redirected: EnrichedIndicator objects whose idno/database_id were rewritten to point at a primary source (identified by primary_source_of is not None). """ if not redirected: return # Deduplicate by (database_id, idno) so we issue at most one metadata # request per unique primary target. The metadata cache also handles # this, but deduplicating here avoids concurrent redundant fetches. seen: set[tuple[str, str]] = set() unique_redirected: list[EnrichedIndicator] = [] for ind in redirected: key = (ind.database_id, ind.idno) if key not in seen: seen.add(key) unique_redirected.append(ind) async def _fetch_and_patch(ind: "EnrichedIndicator") -> None: try: meta = await get_metadata( database_id=ind.database_id, indicator_id=ind.idno, select_fields=["time_periods"], fetch_disaggregation=False, ) if meta.indicator_metadata: time_periods = meta.indicator_metadata.get("time_periods", []) if time_periods and isinstance(time_periods, list): tp = time_periods[0] if isinstance(time_periods[0], dict) else {} ind.latest_data = tp.get("LATEST_DATA_POINT") or tp.get("end") start = tp.get("start") end = tp.get("end") if start and end: ind.time_period_range = f"{start}-{end}" _logger.debug( "Backfilled primary metadata for %s/%s: latest=%s range=%s", ind.database_id, ind.idno, ind.latest_data, ind.time_period_range, ) except Exception as e: _logger.warning( "Failed to backfill primary metadata for %s/%s: %s", ind.database_id, ind.idno, e, ) await asyncio.gather(*(_fetch_and_patch(ind) for ind in unique_redirected)) async def search( # noqa: PLR0911 query: str | None = None, required_country: str | None = None, limit: int = DEFAULT_SEARCH_LIMIT, offset: int = 0, # Multi-query parameters queries: list[str] | None = None, query_groups: list[QueryGroup] | None = None, result_layout: str = "merged", dedupe: bool = True, database: str | None = None, # The following parameters are accepted for robustness; LLM clients sometimes # hallucinate them from the internal SearchRequest model. # n_results/skip are treated as aliases for limit/offset when the primary # parameter is still at its default; the rest are silently ignored. count: bool = True, n_results: int | None = None, filter: str | None = None, # noqa: A002 - name must match LLM-hallucinated param orderby: str | None = None, select: str | None = None, skip: int | None = None, odata_options: dict[str, str] | None = None, ) -> "EnrichedSearchResponse | MultiQuerySearchResponse": """Search for Data360 indicators with enriched metadata for selection. Use this first when the user asks for data on a topic (e.g. unemployment, poverty, GDP). No other tools are required before this one. ENRICHED DATA VS. FETCHING DATA: - For METADATA questions (e.g. "What is the definition of the unemployment rate indicator?", "How frequently is it updated?"): The enriched data returned by this search tool is often sufficient! You can directly use the `truncated_definition`, `name`, `periodicity`, `database_id`, and `latest_data` fields from the search results to answer the user WITHOUT needing to call `data360_get_metadata` or `data360_get_data`. - For DATA questions (e.g. "What was Kenya's GDP in 2020?", "Show me the trend of poverty"): The enriched data does NOT contain actual data values (OBS_VALUE). You MUST proceed to call `data360_get_disaggregation` and then `data360_get_data` (or `data360_get_viz_spec` for charts) to retrieve real numbers. Use when the user already names a specific indicator or metric — for example: "GDP per capita for Kenya", "unemployment rate in Morocco", "life expectancy in Sub-Saharan Africa". For multiple topics in one call (e.g. "GDP, inflation, employment for Kenya"), pass them as queries=["GDP growth", "inflation rate", "unemployment"] instead of making separate calls. Do NOT use this tool when the user asks a broad or vague question that does not name a specific indicator — for example: "What makes a country great?", "What are Ghana's economic challenges?", "How is education performing in Africa?" In those cases, use data360_analyze_development_topic instead, which decomposes the question into specific sub-queries and searches for each one. PARAMETER SELECTION — follow this decision tree strictly: 1. ONE topic, any number of countries → use `query` + `required_country`. 2. MULTIPLE topics, ALL in the SAME country → use `queries` + `required_country`. 3. Topics targeting DIFFERENT countries → MUST use `query_groups`. Do NOT use `queries`. Example — "GDP for Japan and population for Philippines": query_groups=[ {"queries": ["GDP per capita"], "country": "Japan"}, {"queries": ["population"], "country": "Philippines"} ] Using `queries` for cross-country requests will lose per-country coverage data. Pass exactly ONE of query, queries, or query_groups. Omit the other two entirely. Args: query: Single search query (e.g., "unemployment rate", "poverty", "GDP per capita"). Use this for ONE topic. If using this, do not pass queries or query_groups. queries: List of search terms for multi-topic search in one call (e.g. ["GDP growth", "inflation rate", "unemployment"]). Use ONLY when ALL topics target the SAME country (set via required_country). If topics span different countries, use query_groups instead. Requires at least 2 non-empty strings. If using this, do not pass query or query_groups. query_groups: List of QueryGroup objects, each binding one or more search terms to an optional country scope. Use when different queries target different countries. Use this instead of queries when each query targets a different country. JSON schema for each group: {"queries": ["", ""], "country": ""} Example: [ {"queries": ["GDP per capita", "inflation"], "country": "Kenya"}, {"queries": ["Gini coefficient"], "country": "Morocco"} ] Requires at least 2 non-empty queries total across all groups. If using this, do not pass query or queries. required_country is ignored. required_country: Optional country name or 3-letter code (e.g. "Kenya", "KEN"). Use semicolon-separated names or codes to check multiple countries in one call (e.g. "China; USA"). Semicolons are used because some country names contain commas (e.g. "Korea, Republic of"). Shared across all queries — only when all topics share the same geographic scope. Ignored when query_groups is used (each group has its own country). limit: Maximum number of indicators per query (default 5). offset: Number of results to skip per query for pagination (default 0). result_layout: Only used with queries/query_groups. Use "merged" (default) when you want a single flat list to pick from. Use "by_query" when you need to know which indicators came from which query — e.g. to attribute country coverage per query or display grouped results. dedupe: Only used with queries/query_groups. When True (default), deduplicates by (database_id, idno) across all query groups. First-seen order is preserved. Returns: With query: EnrichedSearchResponse with indicators, required_country, pagination fields. Each indicator has covers_country (dict[str, bool], e.g. {\"KEN\": True}) and requested_country (resolved semicolon-separated code string). With queries/query_groups: MultiQuerySearchResponse with indicators (merged) or results (by_query), total_candidates, deduplicated_count, and per-group errors. Each indicator has requested_country showing which group's country it was evaluated against. error: Error message string if the request failed; otherwise None. """ # --- Validation --- # Normalise LLM-hallucinated empty defaults before mode detection. # When a client sends query="" or queries=[] alongside the real parameter # (e.g. query_groups), treat these as "not provided" — identical to None. if query is not None and not query.strip(): _logger.debug("query='%s' normalised to None (empty/whitespace-only)", query) query = None if queries is not None and not any(q and q.strip() for q in queries): _logger.debug("queries=%r normalised to None (all entries empty)", queries) queries = None if query_groups is not None and not query_groups: _logger.debug("query_groups=%r normalised to None (empty list)", query_groups) query_groups = None if query_groups is not None: parsed_groups = [] for g in query_groups: if isinstance(g, dict): try: parsed_groups.append(QueryGroup(**g)) except Exception as e: return MultiQuerySearchResponse( error=f"Invalid QueryGroup structure: {e}", queries=[], ) elif isinstance(g, QueryGroup): parsed_groups.append(g) else: return MultiQuerySearchResponse( error="query_groups must be a list of QueryGroup objects or dictionaries.", queries=[], ) query_groups = parsed_groups active_modes = sum( ( query is not None, queries is not None, query_groups is not None, ) ) if active_modes > 1: return EnrichedSearchResponse( error="Provide exactly one of 'query', 'queries', or 'query_groups', not multiple." ) if active_modes == 0: return EnrichedSearchResponse( error="Missing search term. You must provide exactly one of 'query', 'queries', or 'query_groups' to search for indicators, even when filtering by database." ) # --- Multi-query path (queries= flat list) --- if queries is not None: clean_queries = [q.strip() for q in queries if q and q.strip()] if len(clean_queries) < len(queries): _logger.warning( "Stripped %d empty/whitespace-only entries from queries " "(original: %d, kept: %d)", len(queries) - len(clean_queries), len(queries), len(clean_queries), ) if len(clean_queries) < 2: # noqa: PLR2004 return MultiQuerySearchResponse( error="'queries' must contain at least 2 non-empty search strings.", queries=queries or [], ) if result_layout not in ("merged", "by_query"): return MultiQuerySearchResponse( error="result_layout must be 'merged' or 'by_query'.", queries=clean_queries, ) # Handle limit/offset aliases (mirror single-query path warnings) if n_results is not None: if limit == DEFAULT_SEARCH_LIMIT: limit = n_results elif limit != n_results: _logger.warning( "Both limit=%d and n_results=%d provided; using limit", limit, n_results, ) if skip is not None: if offset == 0: offset = skip elif offset != skip: _logger.warning( "Both offset=%d and skip=%d provided; using offset", offset, skip, ) # Resolve shared country once for all sub-queries country_code: str | None = None if required_country: country_code = await _resolve_country_code(required_country) # Each query gets the same country code per_query_codes: list[str | None] = [country_code] * len(clean_queries) # Fan out concurrent _search_raw calls, one per query raw_tasks = [ _search_raw( query=q, limit=limit, offset=offset, economy_codes=[c.strip() for c in country_code.split(";")] if country_code else None, database=database, ) for q in clean_queries ] raw_results = await asyncio.gather(*raw_tasks, return_exceptions=True) db_mapping = await get_database_mapping() return await _build_multi_query_response( clean_queries=clean_queries, raw_results=raw_results, per_query_codes=per_query_codes, result_layout=result_layout, dedupe=dedupe, db_mapping=db_mapping, ) # --- query_groups path --- if query_groups is not None: if required_country: _logger.warning( "required_country is ignored when query_groups is used; " "set country per QueryGroup instead." ) # Flatten groups into (query, raw_country) pairs, stripping empty entries flat_pairs: list[tuple[str, str | None]] = [] for group in query_groups: for q in group.queries: stripped = q.strip() if q else "" if stripped: flat_pairs.append((stripped, group.country)) else: _logger.warning( "Empty/whitespace-only query stripped from query_groups." ) if len(flat_pairs) < 2: # noqa: PLR2004 return MultiQuerySearchResponse( error="query_groups must produce at least 2 non-empty queries total.", queries=[fp[0] for fp in flat_pairs], ) if result_layout not in ("merged", "by_query"): return MultiQuerySearchResponse( error="result_layout must be 'merged' or 'by_query'.", queries=[fp[0] for fp in flat_pairs], ) # Handle limit/offset aliases if n_results is not None: if limit == DEFAULT_SEARCH_LIMIT: limit = n_results elif limit != n_results: _logger.warning( "Both limit=%d and n_results=%d provided; using limit", limit, n_results, ) if skip is not None: if offset == 0: offset = skip elif offset != skip: _logger.warning( "Both offset=%d and skip=%d provided; using offset", offset, skip, ) # Resolve unique country values concurrently to avoid redundant codelist lookups unique_countries = list({c for _, c in flat_pairs if c}) resolved_map: dict[str, str | None] = {} if unique_countries: codes = await asyncio.gather( *[_resolve_country_code(c) for c in unique_countries] ) resolved_map = dict(zip(unique_countries, codes)) clean_queries = [q for q, _ in flat_pairs] per_query_codes = [resolved_map.get(c) if c else None for _, c in flat_pairs] # Fan out concurrent _search_raw calls, one per flattened query raw_tasks = [ _search_raw( query=q, limit=limit, offset=offset, economy_codes=[c.strip() for c in code.split(";")] if code else None, database=database, ) for q, code in zip(clean_queries, per_query_codes) ] raw_results = await asyncio.gather(*raw_tasks, return_exceptions=True) db_mapping = await get_database_mapping() return await _build_multi_query_response( clean_queries=clean_queries, raw_results=raw_results, per_query_codes=per_query_codes, result_layout=result_layout, dedupe=dedupe, db_mapping=db_mapping, ) # --- Single-query path (original behavior, fully preserved) --- # Handle common parameter aliases sent by LLM clients. # Aliases only apply when the primary parameter is at its default value; # an explicit limit/offset always takes precedence over n_results/skip. if n_results is not None: if limit == DEFAULT_SEARCH_LIMIT: limit = n_results elif limit != n_results: _logger.warning( "Both limit=%d and n_results=%d provided; using limit", limit, n_results, ) if skip is not None: if offset == 0: offset = skip elif offset != skip: _logger.warning( "Both offset=%d and skip=%d provided; using offset", offset, skip, ) # NOTE: This function is for MVP only, we should be testing the relevance and performance # of the retrieval process in the future. # Resolve country code upfront using cached codelist country_code = None if required_country: country_code = await _resolve_country_code(required_country) # Fetch all needed metadata in ONE search call try: search_result = await _search_raw( query=query, # type: ignore[arg-type] # validated non-None above limit=limit, offset=offset, economy_codes=[c.strip() for c in country_code.split(";")] if country_code else None, database=database, ) except PydanticValidationError as e: return EnrichedSearchResponse(error=str(e)) except Data360MCPError as e: return EnrichedSearchResponse(error=e.detail) if search_result.error: return EnrichedSearchResponse(error=search_result.error) if not search_result.items: return EnrichedSearchResponse( error=f"No indicators found for: '{query}'", total_count=search_result.total_count, count=0, offset=search_result.offset, has_more=search_result.has_more, next_offset=search_result.next_offset, ) db_mapping = await get_database_mapping() indicators, indicators_to_verify = _enrich_search_results( search_result, country_code, db_mapping, query=query ) # Backfill latest_data / time_period_range for redirected indicators so # the LLM sees the primary source's actual data range, not the secondary's. redirected = [ind for ind in indicators if ind.primary_source_of is not None] await _backfill_primary_metadata(redirected) # Secondary verification pass: the search API's ref_country array omits group # aggregate codes (SAS, WLD, etc.) even when the data endpoint supports them. # For flagged indicators, concurrently query the disaggregation endpoint to # determine the definitive True/False for each regional code. if country_code and indicators_to_verify: regional_codes = [c.strip() for c in country_code.split(";") if c.strip()] async def _verify_regional_coverage(ind: EnrichedIndicator) -> None: try: res = await get_disaggregation( ind.database_id, ind.idno, required_country=country_code ) for dim in res.get("dimensions", []): if dim.get("field_name") == "REF_AREA": queried = dim.get("queried", {}) if ind.covers_country is not None: for c in regional_codes: if c in ind.covers_country: ind.covers_country[c] = queried.get(c, False) break except Exception as e: _logger.warning( "Failed to verify regional coverage for %s: %s", ind.idno, e ) await asyncio.gather( *(_verify_regional_coverage(ind) for ind in indicators_to_verify) ) indicators.sort( key=lambda x: ( not any((x.covers_country or {}).values()), -(int(x.latest_data or 0) if str(x.latest_data or "").isdigit() else 0), ) ) name_map = None if country_code: name_map = await _resolve_country_names([c.strip() for c in country_code.split(";") if c.strip()]) return EnrichedSearchResponse( indicators=indicators, required_country=country_code, country_names=name_map, # Map pagination fields from underlying search result count=search_result.count, total_count=search_result.total_count, offset=search_result.offset, has_more=search_result.has_more, next_offset=search_result.next_offset, ) async def _build_multi_query_response( clean_queries: list[str], raw_results: list[Any], per_query_codes: list[str | None], result_layout: str, dedupe: bool, db_mapping: dict[str, str] | None = None, ) -> MultiQuerySearchResponse: """Shared response builder for queries= and query_groups= paths. Encapsulates enrichment, deduplication, and layout selection so both paths stay in sync without code duplication. """ seen_dict: dict[tuple[str, str], EnrichedIndicator] = {} groups: list[QueryGroupResult] = [] total_candidates = 0 deduplicated_count = 0 # Collect indicators to verify concurrently verify_tasks = [] # Helper to verify indicators for a specific country code async def _verify_coverage(ind: EnrichedIndicator, code: str) -> None: try: codes = [c.strip() for c in code.split(";") if c.strip()] res = await get_disaggregation( ind.database_id, ind.idno, required_country=code ) for dim in res.get("dimensions", []): if dim.get("field_name") == "REF_AREA": queried = dim.get("queried", {}) if ind.covers_country is not None: for c in codes: if c in ind.covers_country: ind.covers_country[c] = queried.get(c, False) break except Exception as e: _logger.warning("Failed to verify coverage for %s: %s", ind.idno, e) # First pass: enrich indicators and collect verification tasks enriched_lists = [] for i, (q, raw_result) in enumerate(zip(clean_queries, raw_results)): code_for_query = per_query_codes[i] if ( isinstance(raw_result, Exception) or raw_result.error or not raw_result.items ): enriched_lists.append((None, None)) continue enriched, to_verify = _enrich_search_results( raw_result, code_for_query, db_mapping, query=q ) # Backfill latest_data / time_period_range for any redirected indicators. redirected = [ind for ind in enriched if ind.primary_source_of is not None] await _backfill_primary_metadata(redirected) if code_for_query and to_verify: for ind in to_verify: verify_tasks.append(_verify_coverage(ind, code_for_query)) enriched_lists.append((enriched, code_for_query)) # Run verification tasks concurrently if verify_tasks: await asyncio.gather(*verify_tasks) # Second pass: build response groups and deduplicate for i, (q, raw_result) in enumerate(zip(clean_queries, raw_results)): if isinstance(raw_result, Exception): groups.append( QueryGroupResult( query=q, country_code=per_query_codes[i], error=str(raw_result), ) ) continue if raw_result.error or not raw_result.items: groups.append( QueryGroupResult( query=q, country_code=per_query_codes[i], error=raw_result.error or f"No indicators found for: '{q}'", ) ) continue enriched, code_for_query = enriched_lists[i] total_candidates += len(enriched) group_indicators: list[EnrichedIndicator] = [] group_seen: set[tuple[str, str]] = set() for ind in enriched: key = (ind.database_id, ind.idno) if key in seen_dict: existing_ind = seen_dict[key] # Merge covers_country data across queries/groups if ( existing_ind.covers_country is not None and ind.covers_country is not None ): existing_ind.covers_country.update(ind.covers_country) if dedupe and result_layout == "merged": deduplicated_count += 1 elif dedupe and key in group_seen: deduplicated_count += 1 else: group_seen.add(key) group_indicators.append(existing_ind) else: seen_dict[key] = ind group_seen.add(key) group_indicators.append(ind) groups.append( QueryGroupResult( query=q, country_code=code_for_query, indicators=group_indicators, count=len(group_indicators), ) ) # Compute response-level required_country: join all unique resolved codes with ";" all_codes = sorted({c for c in per_query_codes if c}) response_country = ";".join(all_codes) if all_codes else None # Resolve all individual codes from all queries individual_codes = set() for code in all_codes: individual_codes.update(c.strip() for c in code.split(";") if c.strip()) name_map = await _resolve_country_names(list(individual_codes)) if individual_codes else None if result_layout == "merged": merged_indicators: list[EnrichedIndicator] = [ ind for g in groups for ind in g.indicators ] # Sort: covers_country=True first (per-indicator, already correct), then recency if response_country: merged_indicators.sort( key=lambda x: ( not any((x.covers_country or {}).values()), -( int(x.latest_data or 0) if str(x.latest_data or "").isdigit() else 0 ), ) ) return MultiQuerySearchResponse( indicators=merged_indicators, result_layout="merged", queries=clean_queries, required_country=response_country, country_names=name_map, total_candidates=total_candidates, deduplicated_count=deduplicated_count if dedupe else None, ) else: # by_query return MultiQuerySearchResponse( results=groups, result_layout="by_query", queries=clean_queries, required_country=response_country, country_names=name_map, total_candidates=total_candidates, deduplicated_count=deduplicated_count if dedupe else None, ) async def search_datasets( query: str, limit: int = 10, offset: int = 0, ) -> DatasetSearchResponse: """Search for Data360 datasets matching the query. Use this first when the user asks for dataset details (e.g. Findex database, WDI database). Sanitizes special characters from the query string to prevent Search V3 API failures. """ try: request = DatasetSearchRequest(query=query, limit=limit, offset=offset) except PydanticValidationError as e: return DatasetSearchResponse(error=str(e)) url = ( data360_config.search_url or f"{data360_config.api_url}/portal/v1/public_data360_search" ) payload = { "site": "data360", "query_string": request.query, "types": ["dataset"], "data_classification": ["public"], "skip": request.offset, "items_per_page": request.limit, } try: client = get_shared_httpx_client() response = await client.post(url, json=payload) response.raise_for_status() response_data = response.json() results = response_data.get("results", []) items = [] for value in results: items.append( DatasetDescription( idno=value.get("idno", ""), name=value.get("name", ""), description=value.get("description"), data_classification=value.get("data_classification"), data_last_updated=value.get("data_last_updated"), economies_count=value.get("economies_count"), time_period=value.get("time_period"), ) ) total_count = ( response_data.get("count") or response_data.get("@odata.count") or len(items) ) has_more = False next_offset = None if total_count is not None and total_count > request.offset + request.limit: has_more = True next_offset = request.offset + request.limit return DatasetSearchResponse( items=items, count=len(items), total_count=total_count, offset=request.offset, has_more=has_more, next_offset=next_offset, ) except Exception as e: _logger.exception("Error searching datasets") mcp_err = classify_error(e, context="dataset") return DatasetSearchResponse(error=mcp_err.detail) # ruff: noqa: PLR0913, PLR0912, PLR0915 async def get_metadata( database_id: str, indicator_id: str, select_fields: list[str] | None = None, fetch_disaggregation: bool = True, required_country: str | None = None, ) -> MetadataResponse: # noqa: PLR0912 """Get metadata and disaggregation options for a Data360 indicator. Call after data360_search_indicators only when you need deep metadata NOT included in the enriched search results (e.g. methodology, source notes). If the user asks a basic metadata question (like definition or periodicity), simply answer using the fields provided by data360_search_indicators. For valid filter values (years, country codes, SEX/AGE/URBANISATION), prefer data360_get_disaggregation. data360_get_data and data360_get_viz_spec call get_metadata internally. Args: database_id: Database identifier (e.g., IPC_IPC, WB_GS). indicator_id: Indicator ID (e.g., IPC_IPC_PHASE, WB_GS_NY_GDP_PCAP_KD). select_fields: Optional list of metadata fields to return. If None, returns all fields. Available fields: methodology, statistical_concept, definition_long, limitation, relevance, aggregation_method, periodicity, time_periods, ref_country, sources_note. fetch_disaggregation: If True (default), also fetch disaggregation dimensions (field_name, field_value). required_country: Optional country name or 3-letter code (e.g. "Kenya", "KEN"). Use semicolon-separated for multiple (e.g. "China; USA"). When provided, REF_AREA in disaggregation shows which queried countries have data. Returns: MetadataResponse: indicator_metadata: Dict of requested metadata fields for the indicator, or None if not found. disaggregation_options: List of dicts with field_name and field_value (list of valid codes). Dimensions with no disaggregation (INDICATOR, FREQ, single-_T SEX/AGE/URBANISATION) are omitted. REF_AREA is summarized as {count, sample} or {count, queried} instead of the full field_value list. error: Error message string if any request failed; otherwise None. """ # Cache lookup — key includes select_fields (as frozenset) and required_country # because those affect what is returned. _cache_key = ( database_id, indicator_id, frozenset(select_fields) if select_fields else None, fetch_disaggregation, required_country, ) with _metadata_cache_lock: _cached = _metadata_cache.get(_cache_key) if _cached is not None: return _cached # Resolve country codes if provided queried_countries = await _resolve_queried_countries(required_country) # Validate inputs try: MetadataRequest(database_id=database_id, indicator_id=indicator_id) except PydanticValidationError as e: mcp_err = Data360ValidationError( context="metadata", detail=f"Invalid arguments: {e}", original_error=e, ) return MetadataResponse(error=mcp_err.detail) # Determine URLs metadata_url = data360_config.metadata_url or f"{data360_config.api_url}/metadata" disaggregation_url = ( data360_config.disaggregation_url or f"{data360_config.api_url}/disaggregation" ) indicator_metadata: dict[str, Any] | None = None disaggregations: list[dict[str, Any]] = [] errors: list[str] = [] headers = {"accept": "*/*", "Content-Type": "application/json"} # Build query with optional select clause query = f"series_description/idno eq '{indicator_id}'" if select_fields: select_clause = ", ".join(f"series_description/{f}" for f in select_fields) metadata_payload = {"query": query, "select": select_clause} else: metadata_payload = {"query": query} # 1. Fetch Metadata try: client = get_shared_httpx_client() metadata_res = await client.post( metadata_url, json=metadata_payload, headers=headers ) metadata_res.raise_for_status() try: metadata_json = metadata_res.json() if metadata_json and metadata_json.get("value"): indicator_metadata = metadata_json["value"][0].get( "series_description", {} ) # Inject database_name so clients always have the correct, grounded # label for the database_id — prevents LLMs from guessing # (e.g. WB_GS is "Gender Statistics", not "Global Statistics"). if indicator_metadata: db_id = indicator_metadata.get("database_id", database_id) db_mapping = await get_database_mapping() indicator_metadata["database_name"] = db_mapping.get(db_id) # Force filtering if select_fields provided (API might return more). # Always retain database_name regardless of select_fields. if select_fields and indicator_metadata: indicator_metadata = { k: v for k, v in indicator_metadata.items() if k in select_fields or k == "database_name" } else: mcp_err = NotFoundError( context="metadata", detail=f"No metadata found for indicator ID '{indicator_id}'", ) errors.append(mcp_err.detail) except ValueError as e: mcp_err = ParseError(context="metadata", original_error=e) errors.append(mcp_err.detail) except Exception as e: mcp_err = classify_error(e, context="metadata") errors.append(mcp_err.detail) # 2. Fetch Dimensions if fetch_disaggregation: try: dimensions_json = await _fetch_dimensions_with_cache( database_id, indicator_id ) raw_disaggregations = _parse_dimensions_response(dimensions_json) disaggregations = _strip_disaggregation( _get_valid_disaggregations(raw_disaggregations), queried_countries, ) except Exception as e: mcp_err = classify_error(e, context="disaggregation") errors.append(mcp_err.detail) # 3. Combine and Return error_message = "; ".join(errors) if errors else None result = MetadataResponse( indicator_metadata=indicator_metadata, disaggregation_options=disaggregations, error=error_message, ) # Only cache successful responses — don't cache errors so transient # network failures don't persist for a full day. if not error_message: with _metadata_cache_lock: _metadata_cache[_cache_key] = result return result async def get_disaggregation( database_id: str, indicator_id: str, required_country: str | None = None, ) -> dict[str, Any]: """Get disaggregation options for a Data360 indicator (valid filter values). Call before data360_get_data or data360_get_viz_spec to see which filter values are available. Typically call after data360_search_indicators when you need available years or breakdowns. Use the returned values in disaggregation_filters; do not use FREQ for filtering (it breaks queries). After calling this tool, use the returned field_value codes directly as disaggregation_filters in data360_get_data or data360_get_viz_spec. For example, if SEX returns ["M", "F", "_T"], pass {"SEX": "F"} to filter to female-only data. Args: database_id: Database identifier (e.g., WB_GS, WB_SSGD). indicator_id: Indicator ID (e.g., WB_GS_NY_GDP_PCAP_KD). required_country: Optional country name or 3-letter code (e.g. "Kenya", "KEN"). Use semicolon-separated names or codes to check multiple countries in one call (e.g. "China; USA"). When provided, REF_AREA shows which queried countries have data for this indicator. Returns: On success: dict with key "dimensions", a list of dicts. Trivial dimensions (INDICATOR, FREQ, single-_T SEX/AGE/URBANISATION) are omitted. Each dict has: field_name: Dimension name (e.g. TIME_PERIOD, REF_AREA, SEX). field_value: List of valid codes (for TIME_PERIOD sorted chronologically; for SEX/AGE etc.). REF_AREA is special: returns {count, sample} (5 sorted codes) or, when required_country is given, {count, queried: {code: bool}}. On failure: dict with key "error" and an error message string. TIME_PERIOD gives actual available years (may have gaps). """ # Cache lookup — required_country affects how REF_AREA is reported. _disagg_cache_key = (database_id, indicator_id, required_country) with _disaggregation_cache_lock: _cached_disagg = _disaggregation_cache.get(_disagg_cache_key) if _cached_disagg is not None: return _cached_disagg # Resolve country codes if provided queried_countries = await _resolve_queried_countries(required_country) try: dimensions_json = await _fetch_dimensions_with_cache(database_id, indicator_id) raw_data = _parse_dimensions_response(dimensions_json) # Filter out _Z values and format response valid_dimensions = _get_valid_disaggregations(raw_data) result_disagg = { "dimensions": _strip_disaggregation(valid_dimensions, queried_countries) } with _disaggregation_cache_lock: _disaggregation_cache[_disagg_cache_key] = result_disagg return result_disagg except Exception as e: mcp_err = classify_error(e, context="disaggregation") return {"error": mcp_err.detail} async def get_comp_breakdown_dim_names( database_id: str, indicator_id: str, ) -> dict[str, str]: """Return human-readable dimension names for comp_breakdown_1/2/3. Re-uses the cached disaggregation response so no extra HTTP call is made. Maps the column names used in the viz DataFrame (lowercase snake_case) to the best available human label for use as legend/tooltip titles. Returns a dict like:: {"comp_breakdown_1": "Analysis Period", "comp_breakdown_2": "Severity Phase"} Only dimensions that are actually present in the disaggregation response (i.e., have non-trivial values) are included. Falls back to "Dimension N" when the API echoes the field name back (e.g. WGI returns "COMP_BREAKDOWN_1" as its own label_name). """ import re as _re # Matches generic placeholder labels that APIs sometimes echo back instead of a # real human-readable name: "Custom Dimension 2", "Dimension 3", "Breakdown 1", etc. # These are no more informative than the fallback, so they are rejected. _GENERIC_LABEL_RE = _re.compile( r"^(custom\s+)?(dimension|dim|breakdown|comp_breakdown)\s*\d*$", _re.IGNORECASE, ) _FIELD_TO_COL = { "COMP_BREAKDOWN_1": "comp_breakdown_1", "COMP_BREAKDOWN_2": "comp_breakdown_2", "COMP_BREAKDOWN_3": "comp_breakdown_3", } _FALLBACK = { "comp_breakdown_1": "Dimension 1", "comp_breakdown_2": "Dimension 2", "comp_breakdown_3": "Dimension 3", } result = await get_disaggregation(database_id, indicator_id) dimensions = result.get("dimensions", []) dim_names: dict[str, str] = {} for dim in dimensions: field_name = dim.get("field_name", "") if field_name not in _FIELD_TO_COL: continue col = _FIELD_TO_COL[field_name] label_name = (dim.get("label_name") or "").strip() # Reject: (a) echoed field names e.g. "COMP_BREAKDOWN_1", # (b) generic placeholders e.g. "Custom Dimension 2", "Dimension 3". # Both are no more informative than the _FALLBACK label. is_generic = ( not label_name or label_name.upper() == field_name.upper() or bool(_GENERIC_LABEL_RE.match(label_name)) ) dim_names[col] = _FALLBACK[col] if is_generic else label_name return dim_names _DEFAULT_TIME_WINDOW_YEARS = 5 def _resolve_time_range( start_year: int | None, end_year: int | None, ) -> tuple[int, int]: """Resolve inclusive [start_year, end_year] for Data API timePeriodFrom/To. - Neither bound: last ``_DEFAULT_TIME_WINDOW_YEARS`` calendar years through today. - Only ``end_year``: same-width window ending at ``end_year``. - Only ``start_year``: from ``start_year`` through the current calendar year. - Both: use as given (swapped if reversed). """ from datetime import datetime # noqa: PLC0415 current_year = datetime.now().year span = _DEFAULT_TIME_WINDOW_YEARS - 1 if start_year is not None: # TODO: Handle invalid start year, e.g., "2020-01-01" or float start_year = int(start_year) if end_year is not None: # TODO: Handle invalid end year, e.g., "2020-01-01" end_year = int(end_year) if start_year is None and end_year is None: return current_year - span, current_year if start_year is None: return end_year - span, end_year if end_year is None: return start_year, max(start_year, current_year) if start_year > end_year: return end_year, start_year return start_year, end_year async def get_data( database_id: str, indicator_id: str, country_code: str | None = None, disaggregation_filters: dict[str, str | None] | None = None, start_year: int | None = None, end_year: int | None = None, limit: int = 50, offset: int = 0, ref_area_filter: Literal["none", "member_economies_only"] = "none", auto_resolve_time_range: bool = True, ) -> IndicatorDataResponse: """Fetch indicator data from the Data360 API with pagination. Call after you have database_id and indicator_id (from data360_search_indicators). Use data360_get_disaggregation to get valid filter values; passing invalid values can yield empty results. For charts, prefer data360_get_viz_spec, which fetches data internally. Args: database_id: Database identifier (e.g., "IPC_IPC", "WB_GS"). indicator_id: Indicator ID (e.g., "IPC_IPC_PHASE", "WB_GS_NY_GDP_PCAP_KD"). country_code: Optional 3-letter code or semicolon-separated list (e.g. "KEN" or "KEN;MAR"). Applied as REF_AREA filter. Takes precedence over REF_AREA in disaggregation_filters. disaggregation_filters: Optional dict of dimension filters. Keys: REF_AREA, SEX, AGE, URBANISATION, UNIT_MEASURE, etc. Values are str or None (not lists). REF_AREA supports comma-separated ISO codes (e.g. "KEN,TZA"); semicolons are normalized to commas. Use value None to request all values for a dimension (e.g. {"SEX": None}). When REF_AREA is omitted or None, the Data API returns all geographic series—including regional aggregates (e.g. EAS, EMU)—mixed with member economies. start_year: Optional start year (inclusive). With neither bound, defaults to last 5 years. With only ``end_year``, defaults to a 5-year window ending at ``end_year``. With only ``start_year``, defaults through the current calendar year. end_year: Optional end year (inclusive). See ``start_year`` for partial-bound behavior. limit: Maximum records per page (default 50, max 100). offset: Number of records to skip for pagination (default 0). ref_area_filter: When ``member_economies_only``, drop rows whose ``REF_AREA`` is not an FMR leaf member economy **only if** REF_AREA is not pinned (no ``country_code`` and no explicit ``REF_AREA`` string in filters). Otherwise a note is added to ``failed_validation`` and no filtering is applied. Returns: IndicatorDataResponse: data: List of data point dicts (e.g. TIME_PERIOD, REF_AREA, OBS_VALUE, claim_id). metadata: Indicator metadata dict if available. count: Number of records in this response. total_count: Total records available, or None. offset, has_more, next_offset: Use next_offset for the next page when has_more is True. PAGINATION NOTE: This tool returns ONE page. When has_more=True, call again with next_offset to retrieve more rows. For queries involving large country groups (e.g. from data360_expand_country_group with 20+ countries), prefer calling data360_rank_countries or data360_summarize_data instead — those tools paginate internally and return complete aggregated results without requiring manual looping. error: Error message if the request failed; otherwise None. If error contains "No metadata found", the indicator_id is invalid or stale. Do NOT retry with the same ID and do NOT call data360_search_indicators again. Instead, look back at the other indicators already returned by the previous data360_search_indicators call in this conversation and try the next best match. Only call data360_search_indicators again if no prior search results exist in context. If error is about a disaggregation or HTTP failure but data is still None, the upstream API may be temporarily unavailable — retry once or report the error. failed_validation: Optional list of filter validation messages. Non-empty means some filters were invalid; data may still be returned with valid filters applied. """ data_url = data360_config.data_url or f"{data360_config.api_url}/data" # Cap limit to prevent token overflow limit = min(limit, 100) if auto_resolve_time_range: resolved_start, resolved_end = _resolve_time_range(start_year, end_year) if (start_year, end_year) != (resolved_start, resolved_end): _logger.info( "Resolved time range %s-%s from start_year=%s end_year=%s", resolved_start, resolved_end, start_year, end_year, ) start_year, end_year = resolved_start, resolved_end # Validate arguments using Pydantic model try: IndicatorDataRequest( database_id=database_id, indicator_id=indicator_id, disaggregation_filters=disaggregation_filters, ) except PydanticValidationError as e: mcp_err = Data360ValidationError( context="data", detail=f"Invalid arguments: {e}", original_error=e, ) return IndicatorDataResponse(error=mcp_err.detail) # Prepare API parameters params: dict[str, Any] = { "DATABASE_ID": database_id, "INDICATOR": indicator_id, "timePeriodFrom": start_year, "timePeriodTo": end_year, "skip": offset, # Request one extra to detect if there are more results # Note: API uses "top" not "$top" (OData style would be URL-encoded to %24top which API ignores) "top": limit + 1, } # Convert semicolon-separated list into comma-separated list for Data API if country_code: # Takes precedence over REF_AREA in disaggregation_filters params["REF_AREA"] = country_code.replace(";", ",") # Fetch metadata and disaggregations FIRST to inform parameter building # This ensures we don't apply invalid defaults (like AGE=_T) which cause empty results metadata_res = await get_metadata( database_id, indicator_id, select_fields=[ "idno", "name", "database_id", "periodicity", "measurement_unit", "definition_short", ], fetch_disaggregation=True, # Crucial: fetch valid options required_country=country_code, # Pass for REF_AREA validation ) if metadata_res.error: if metadata_res.indicator_metadata is None: # Fatal: indicator not found or completely unavailable. _logger.warning( "Aborting get_data for %s: indicator metadata missing. Error: %s", indicator_id, metadata_res.error, ) return IndicatorDataResponse(error=metadata_res.error) # Non-fatal: disaggregation lookup failed but indicator metadata is valid. # Proceed without validated disaggregation defaults. _logger.warning( "Non-fatal metadata error for %s (proceeding without disaggregation defaults): %s", indicator_id, metadata_res.error, ) api_metadata = metadata_res.indicator_metadata or {} # Process valid disaggregations into {dim: [values]} format available_disaggregations = {} for d in metadata_res.disaggregation_options or []: if d.get("field_name") and d.get("field_value"): available_disaggregations[d["field_name"]] = d["field_value"] # Validate user filters BEFORE applying defaults # This gives hints for hallucinations like SEX=ALIEN valid_filters, validation_errors = _validate_user_filters( disaggregation_filters, available_disaggregations ) if validation_errors: _logger.warning(f"Validation errors for {indicator_id}: {validation_errors}") # Use only valid filters for building params effective_disagg = _build_disaggregation_params( valid_filters, available_disaggregations=available_disaggregations ) params.update(effective_disagg) ref_area_unpinned = not country_code and "REF_AREA" not in params try: client = get_shared_httpx_client() _logger.debug("Fetching data from %s with params: %s", data_url, params) data_res = await client.get(data_url, params=params) data_res.raise_for_status() try: data_json = data_res.json() except ValueError as e: # mcp_err = ParseError(context="data", original_error=e) # return IndicatorDataResponse(data=None, error=mcp_err.detail) raise ParseError(context="data", original_error=e) raw_data = data_json.get("value", []) total_count = data_json.get("@odata.count") # May be None # Compute API-level pagination BEFORE any filtering # This ensures next_offset correctly tracks position in the API result set api_returned_count = len(raw_data) has_more = api_returned_count > limit # Trim the extra detection row (we requested limit+1 to detect has_more) if api_returned_count > limit: raw_data = raw_data[:limit] # Compute API-based next_offset - the true cursor position for next page api_next_offset = offset + len(raw_data) if has_more else None # Sort by TIME_PERIOD descending (most recent first) raw_data.sort(key=lambda x: str(x.get("TIME_PERIOD", "")), reverse=True) # Final limit enforcement (safety check) if len(raw_data) > limit: raw_data = raw_data[:limit] from .providers import get_codelist_manager # noqa: PLC0415 _cm = get_codelist_manager() await _cm._ensure_extdataportal_loaded() for row in raw_data: row["claim_id"] = _short_hash(row) ref_area = row.get("REF_AREA") if ref_area: label = _cm.get_label("REF_AREA", str(ref_area)) if label and label != str(ref_area): row["REF_AREA_NAME"] = label unit_measure = row.get("UNIT_MEASURE") if unit_measure: unit_label = _cm.get_label("UNIT_MEASURE", str(unit_measure)) has_mapping = bool(unit_label and unit_label != str(unit_measure)) # Check if we have a valid multiplier that warrants qualification unit_mult = row.get("UNIT_MULT") try: mult = int(unit_mult) if unit_mult is not None else 0 except (ValueError, TypeError): mult = 0 has_valid_mult = mult in (3, 6, 9, 12) if has_mapping or has_valid_mult: base_unit = unit_label if has_mapping else row.get("UNIT_MEASURE_NAME") or str(unit_measure) qualified_unit = _qualify_unit_name(base_unit, unit_mult, str(unit_measure)) if qualified_unit: row["UNIT_MEASURE_NAME"] = qualified_unit # Promote COMMENT_TS to metadata (repeats identically per row) if raw_data and api_metadata is not None: comment_ts = next( (r.get("COMMENT_TS") for r in raw_data if r.get("COMMENT_TS")), None, ) if comment_ts: api_metadata["indicator_description"] = comment_ts # Strip boilerplate fields for LLM token savings raw_data = [_strip_data_row(row) for row in raw_data] filter_notes: list[str] = list(validation_errors) if validation_errors else [] if ref_area_filter == "member_economies_only": if ref_area_unpinned: from .providers import get_group_hierarchy_manager # noqa: PLC0415 _ghm = get_group_hierarchy_manager() raw_data = [ r for r in raw_data if _ghm.is_country(str(r.get("REF_AREA", ""))) ] else: filter_notes.append( "ref_area_filter=member_economies_only applies only when REF_AREA is " "unpinned (omit country_code and do not set REF_AREA to specific codes); " "filter was not applied." ) return IndicatorDataResponse( data=raw_data, metadata=api_metadata, count=len(raw_data), total_count=total_count, offset=offset, has_more=has_more, next_offset=api_next_offset, error=None, failed_validation=filter_notes if filter_notes else None, ) except Data360MCPError: # Re-raise our own errors to propagate isError: true to MCP client raise except Exception as e: # # Convert unknown exceptions to Data360MCPError and raise # mcp_err = classify_error(e, context="data") # return IndicatorDataResponse(data=None, error=mcp_err.detail) raise classify_error(e, context="data") async def get_indicators(database_id: str) -> list[str]: """Get all indicator IDs for a specific database. Use when you need the full list of indicator IDs for a dataset. For discovery by topic, use data360_search_indicators instead. You must know the database_id (e.g. from search or docs). Args: database_id: The database identifier (e.g., "WB_WDI", "WB_GS"). Returns: List of indicator ID strings for that database. Empty list on error or if none exist. """ url = f"{data360_config.api_url}/indicators" params = {"datasetId": database_id} try: client = get_shared_httpx_client() response = await client.get(url, params=params) response.raise_for_status() data = response.json() if isinstance(data, list): # Data is a list of indicator ID strings return data return [] except Exception as e: _logger.error(f"Failed to fetch indicators for {database_id}: {e}") raise async def discover_indicators( query: str, required_country: str | None = None, required_dimensions: list[str] | None = None, limit: int = 5, ) -> DiscoveryResult: """Search for indicators and validate their capabilities. .. deprecated:: This function is deprecated. Use the following workflow instead: 1. search() with select_fields for candidates 2. get_disaggregation() to validate availability 3. get_metadata() with select_fields for specific info This is the primary tool that combines: 1. Search for top K indicators matching the query 2. Fetch metadata for all K indicators in parallel 3. Cross-check capabilities (countries, dimensions available) 4. Return condensed, validated results for LLM to select from Args: query: Search query string (e.g., "unemployment rate", "poverty") required_country: Country name or code to validate (e.g., "Kenya" or "KEN") required_dimensions: List of required disaggregations (e.g., ["SEX", "AGE"]) limit: Maximum number of indicators to search and validate (default: 5) Returns: DiscoveryResult with list of validated indicator summaries and optional error Example: discover_indicators( query="unemployment rate", required_country="Kenya", required_dimensions=["SEX", "AGE"] ) """ import warnings # noqa: PLC0415 warnings.warn( "discover_indicators is deprecated. Use search() + get_disaggregation() + get_metadata() instead.", DeprecationWarning, stacklevel=2, ) # ... existing implementation ... # --- Visualization Workflow Tools --- async def get_data_api_url( database_id: str, indicator_id: str, country_code: str | None = None, start_year: int | None = None, end_year: int | None = None, disaggregation_filters: dict[str, str | None] | None = None, ) -> str: """Generate a Data360 API URL for a dataset without fetching data. Low-level tool: use only when you need the raw data API URL (e.g. custom clients or debugging). For charts, use data360_get_viz_spec instead; it builds the URL, fetches data, and generates the spec. Use data360_get_disaggregation to obtain valid filter values. Args: database_id: Database identifier (e.g., WB_HNP, WB_WDI). indicator_id: Indicator ID (e.g., WB_HNP_SP_POP_TOTL). country_code: Optional 3-letter code or semicolon-separated list (e.g. "KEN" or "CHN;USA"). start_year: Optional start year (inclusive). end_year: Optional end year (inclusive). disaggregation_filters: Optional dict of dimension filters (e.g. {"SEX": "F"}). Values are str or None. REF_AREA: comma-separated ISO codes; semicolons normalized. If omitted, defaults to totals (_T) for SEX, AGE, URBANISATION where applicable. Returns: Full Data360 data API URL string (query parameters included). Raises ValueError if the indicator is not found in the specified database. If this happens, the indicator_id is invalid or stale. Do NOT retry with the same ID. Look back at the other indicators already returned by the previous data360_search_indicators call in this conversation and try the next best match. Only call data360_search_indicators again if no prior search results exist in context. """ settings = get_data360_settings() # Fix double-slash: ensure base URL doesn't end with slash before appending /data base_url = settings.api_url.rstrip("/") base = f"{base_url}/data" # Construct query params params = {"DATABASE_ID": database_id, "INDICATOR": indicator_id} if country_code: # Same as get_data(): Data API expects comma-separated REF_AREA; allow semicolons in tool args. params["REF_AREA"] = country_code.replace(";", ",") if start_year: params["timePeriodFrom"] = start_year if end_year: params["timePeriodTo"] = end_year # Fetch metadata and disaggregations FIRST to inform parameter building # This ensures we don't apply invalid defaults (like AGE=_T) which cause empty results metadata_res = await get_metadata( database_id, indicator_id, select_fields=[], # We only need disaggregation options, metadata fields not needed fetch_disaggregation=True, ) if metadata_res.error: if metadata_res.indicator_metadata is None: raise ValueError( f"Indicator '{indicator_id}' not found: {metadata_res.error}" ) _logger.warning( "Non-fatal metadata error for %s in get_data_api_url (proceeding): %s", indicator_id, metadata_res.error, ) # Process valid disaggregations into {dim: [values]} format available_disaggregations = {} for d in metadata_res.disaggregation_options or []: if d.get("field_name") and d.get("field_value"): available_disaggregations[d["field_name"]] = d["field_value"] # Validate user filters valid_filters, validation_errors = _validate_user_filters( disaggregation_filters, available_disaggregations ) if validation_errors: # For URL generation, we might still want to raise if anything is invalid # or we could just skip invalid ones. Raising is safer for URL consistency. raise ValueError("\n\n".join(validation_errors)) # See _build_disaggregation_params() docstring for behavior effective_filters = _build_disaggregation_params( valid_filters, available_disaggregations=available_disaggregations ) # Add dimension filters to params params.update(effective_filters) # Default limit for viz limit = 1000 if country_code: # Increase limit based on number of countries requested (max ~60 years per country) # Using 1000 as a safe multiplier to cover most time series data including higher frequency ref_area_param = country_code.replace(";", ",") n_countries = max(1, len([p for p in ref_area_param.split(",") if p.strip()])) limit = max(1000, n_countries * 1000) params["top"] = limit query_string = urlencode(params, safe=",") return f"{base}?{query_string}" # --------------------------------------------------------------------------- # Data Aggregation Tools (Tier 1 — full implementation) # --------------------------------------------------------------------------- _PAGE_SIZE = 100 # rows per page for aggregation fetches async def _fetch_all_pages( database_id: str, indicator_id: str, country_code: str | None, disaggregation_filters: dict[str, str | None] | None, start_year: int | None, end_year: int | None, ) -> IndicatorDataResponse: """SERVER-SIDE pagination consumer for aggregation tools. Not an MCP tool. Design rationale — get_data vs _fetch_all_pages: data360_get_data (MCP tool, LLM-facing): Returns ONE page (limit rows) and exposes has_more + next_offset for the LLM to drive pagination. Correct for PATH A (point lookups) and any case where the LLM presents data directly to the user. The LLM sees and controls the cursor. _fetch_all_pages (internal, server-side): Loops get_data until has_more=False and merges ALL rows into one response. Required for aggregation tools (summarize_data, rank_countries, compare_countries) because statistics computed on a partial dataset are silently wrong — you cannot rank 48 Sub-Saharan African countries if you only have the first 100 rows of a 240-row response. This distinction becomes critical when data360_expand_country_group is used upstream: a group like SSF (48 countries) × 20 years = 960 rows requires 10 pages. Without this helper, any aggregation on large country groups would silently truncate at page 1. Rule: the LLM should NEVER call this directly. It is satisfied by the aggregation MCP tools (data360_summarize_data, data360_rank_countries, data360_compare_countries) which call it internally. Error handling: first-page errors propagate immediately. Mid-pagination errors log a warning and return the rows collected so far (partial > nothing). Safety: stops after _MAX_PAGES pages to guard against runaway loops if the API incorrectly signals has_more=True indefinitely. """ _MAX_PAGES = 50 # 50 * 100 = 5 000 rows — generous ceiling for any real indicator all_rows: list[dict[str, Any]] = [] metadata = None offset = 0 page_count = 0 while True: page_count += 1 if page_count > _MAX_PAGES: _logger.warning( "_fetch_all_pages: hit safety page limit (%d) for %s/%s after %d rows. " "Returning partial results.", _MAX_PAGES, database_id, indicator_id, len(all_rows), ) break page = await get_data( database_id=database_id, indicator_id=indicator_id, country_code=country_code, disaggregation_filters=disaggregation_filters, start_year=start_year, end_year=end_year, limit=_PAGE_SIZE, offset=offset, auto_resolve_time_range=False, ) if page.error: # Propagate first-page errors; for later pages keep what we have if offset == 0: return page _logger.warning( "Pagination error at offset %d for %s/%s: %s", offset, database_id, indicator_id, page.error, ) break if page.metadata and metadata is None: metadata = page.metadata if page.data: all_rows.extend(page.data) if not page.has_more: break offset = page.next_offset or (offset + _PAGE_SIZE) return IndicatorDataResponse( data=all_rows, metadata=metadata, count=len(all_rows), total_count=len(all_rows), offset=0, has_more=False, next_offset=None, ) async def _resolve_country_names(codes: list[str]) -> dict[str, str]: """Batch-resolve country codes to display names. Uses the in-memory CodelistManager to lookup human-readable country names from the REF_AREA codelist. Silently returns empty names on any error. """ from .providers import get_codelist_manager # noqa: PLC0415 if not codes: return {} try: _cm = get_codelist_manager() await _cm._ensure_extdataportal_loaded() return {code: _cm.get_label("REF_AREA", code) for code in codes} except Exception: return {} # Disaggregation dimensions that may carry meaningful sub-categories. # UNIT_MEASURE is handled via a separate branch in the helper below. _DISAGG_DIMS_TO_DETECT: tuple[str, ...] = ( "SEX", "AGE", "URBANISATION", "COMP_BREAKDOWN_1", "COMP_BREAKDOWN_2", ) async def _auto_detect_disagg_dimensions( database_id: str, indicator_id: str, sample_country: str | None, existing_filters: dict[str, str | None], *, expand_non_trivial: bool, ) -> tuple[dict[str, str | None], list[str]]: """Detect disaggregation dimensions and build effective filters. Calls get_disaggregation once and inspects SEX, AGE, URBANISATION, COMP_BREAKDOWN_1/2, and UNIT_MEASURE for each indicator. Two modes, controlled by `expand_non_trivial`: expand_non_trivial=True (summarize_data path): For each dim that has values beyond _T and is not already in existing_filters, set the filter to None (fetch all values) and record it in auto_expanded_dims so the caller can add it to group_by. expand_non_trivial=False (rank_countries / compare_countries path): For each such dim, pin to _T when available, otherwise to the first non-total value, to prevent duplicate rows per country per year which would corrupt ranking and comparison statistics. UNIT_MEASURE is always pinned to its sole value when exactly one exists (regardless of mode), matching the prior behaviour. Caller's existing_filters always take precedence — this function never overwrites a filter already set by the caller. Returns: (effective_filters, auto_expanded_dims) effective_filters: copy of existing_filters with auto-detected values. auto_expanded_dims: lowercase dim names that were expanded into None (non-empty only when expand_non_trivial=True). """ effective_filters: dict[str, str | None] = dict(existing_filters) auto_expanded_dims: list[str] = [] try: disagg_res = await get_disaggregation( database_id=database_id, indicator_id=indicator_id, required_country=sample_country, ) except Exception: return effective_filters, auto_expanded_dims if not disagg_res or disagg_res.get("error"): return effective_filters, auto_expanded_dims for dim in disagg_res.get("dimensions", []): field_name: str = dim.get("field_name", "") values: list[str] = dim.get("field_value", []) if not values or field_name in ("REF_AREA", "TIME_PERIOD"): continue # Honour the caller's explicit filter for this dimension. if field_name in effective_filters: continue # UNIT_MEASURE: always pin to its single value when unambiguous. if field_name == "UNIT_MEASURE": if len(values) == 1: effective_filters["UNIT_MEASURE"] = values[0] continue if field_name not in _DISAGG_DIMS_TO_DETECT: continue # Only act on dimensions that carry values beyond the aggregate total. non_total_values = [v for v in values if v not in ("_T", "_Z")] if not non_total_values: continue if expand_non_trivial: # Expand: set to None so _fetch_all_pages retrieves all breakdowns. effective_filters[field_name] = None auto_expanded_dims.append(field_name.lower()) else: # Pin to _T (aggregate total) when available to avoid duplicate rows. effective_filters[field_name] = ( "_T" if "_T" in values else non_total_values[0] ) return effective_filters, auto_expanded_dims def _compute_trend_direction(values: list[float]) -> str: """Determine trend direction from a time-ordered list of values. Uses Huber regression (outlier-robust) to fit a linear trend, then classifies the result into one of four categories: - R² < 0.3 → "volatile" (no clear linear trend) - |slope| < 1% of |mean| per step → "stable" - slope > 0 → "increasing" - slope < 0 → "decreasing" HuberRegressor is preferred over OLS because development indicators can contain anomalous years (conflict, crises, revisions) that would skew an OLS slope. The Huber loss function down-weights outliers automatically. Expects pre-cleaned, finite float values. The caller (_build_group_summary) applies .dropna() upstream; np.isfinite() below drops any remaining non-finite values (inf, -inf) before fitting. """ y = np.asarray(values, dtype=float) y = y[np.isfinite(y)] # drop inf/-inf; NaN already removed by caller if len(y) < 2: return "stable" x = np.arange(len(y)).reshape(-1, 1) model = HuberRegressor() try: model.fit(x, y) except Exception: # Fallback: all identical values or degenerate input return "stable" slope = float(model.coef_[0]) r_squared = float(model.score(x, y)) if r_squared < 0.3: return "volatile" y_mean = float(np.mean(y)) if y_mean != 0 and abs(slope / y_mean) < 0.01: return "stable" return "increasing" if slope > 0 else "decreasing" def _build_group_summary( group_key: dict[str, str], rows: list[dict[str, Any]], ) -> GroupSummary: """Build a GroupSummary from a list of data rows sharing the same group key. Uses pandas for type-safe numeric coercion and descriptive statistics. pd.to_numeric with errors='coerce' handles mixed-type OBS_VALUE values from the API (strings, None, empty string) without raising exceptions. TIME_PERIOD deduplication: indicators with multiple disaggregation dimensions (e.g. IPC_IPC_PHASE with COMP_BREAKDOWN_2) can return several rows per period when the caller has not fully specified all disaggregation filters. The .drop_duplicates(keep="last") call reduces these to one row per period so that trend statistics are computed on a single time series rather than a mix of disaggregated values. A warning is emitted when rows are actually dropped so the behaviour is observable; callers should pass disaggregation_filters to narrow to a single series and suppress the warning. """ df = pd.DataFrame(rows) # Preserve claim_ids before any filtering claim_ids: list[str] = ( df["claim_id"].dropna().tolist() if "claim_id" in df.columns else [] ) # Coerce OBS_VALUE — handles strings, None, empty strings from API df["OBS_VALUE"] = pd.to_numeric( df["OBS_VALUE"] if "OBS_VALUE" in df.columns else pd.Series(dtype=float), errors="coerce", ) if "TIME_PERIOD" not in df.columns: df["TIME_PERIOD"] = "" df["TIME_PERIOD"] = df["TIME_PERIOD"].astype(str) # Sort ascending, drop missing values, deduplicate periods (keep last). # See docstring for rationale on TIME_PERIOD deduplication. df_sorted = df.sort_values("TIME_PERIOD").dropna(subset=["OBS_VALUE"]) n_before_dedup = len(df_sorted) df = df_sorted.drop_duplicates(subset=["TIME_PERIOD"], keep="last").reset_index( drop=True ) n_dropped = n_before_dedup - len(df) if n_dropped > 0: _logger.warning( "_build_group_summary: dropped %d duplicate TIME_PERIOD row(s) for group %s. " "Pass disaggregation_filters to narrow to a single series and avoid this.", n_dropped, group_key, ) if df.empty: return GroupSummary(group_key=group_key, count=0, claim_ids=claim_ids) values_s = df["OBS_VALUE"] years = df["TIME_PERIOD"].tolist() earliest_val = float(values_s.iloc[0]) latest_val = float(values_s.iloc[-1]) earliest_yr: str = years[0] latest_yr: str = years[-1] total_change = latest_val - earliest_val pct_change = ( round((total_change / abs(earliest_val)) * 100, 2) if earliest_val != 0 else None ) time_range = ( f"{earliest_yr}-{latest_yr}" if earliest_yr != latest_yr else earliest_yr ) # Use .describe() for a single-pass computation of standard statistics. # More efficient than 4 separate aggregation calls on the same series. _desc = values_s.describe() return GroupSummary( group_key=group_key, count=len(df), latest_value=round(latest_val, 4), latest_year=latest_yr, earliest_value=round(earliest_val, 4), earliest_year=earliest_yr, min=round(float(_desc["min"]), 4), max=round(float(_desc["max"]), 4), mean=round(float(_desc["mean"]), 4), median=round(float(_desc["50%"]), 4), total_change=round(total_change, 4), pct_change=pct_change, trend_direction=_compute_trend_direction(values_s.tolist()), time_range=time_range, claim_ids=claim_ids, ) # Mapping from lowercase group_by column names to raw API field names. _GROUPBY_FIELD_MAP: dict[str, str] = { "ref_area": "REF_AREA", "region": "REGION", "income_group": "INCOME_GROUP", "time_period": "TIME_PERIOD", "sex": "SEX", "age": "AGE", "urbanisation": "URBANISATION", "residence": "URBANISATION", "unit_measure": "UNIT_MEASURE", "comp_breakdown_1": "COMP_BREAKDOWN_1", "comp_breakdown_2": "COMP_BREAKDOWN_2", } async def summarize_data( database_id: str, indicator_id: str, country_code: str | None = None, disaggregation_filters: dict[str, str | None] | None = None, start_year: int | None = None, end_year: int | None = None, group_by: list[str] | None = None, ) -> DataSummaryResponse: """Compute summary statistics for indicator data, grouped by one or more dimensions. Call instead of data360_get_data when the user asks about trends, changes over time, or general patterns — not specific year values. Particularly useful for PATH C (trend) questions like "How has X changed?" or "What is the trend of Y?". Use the default group_by=["ref_area"] for all single- or multi-country trend questions to group at the country level; this typically yields meaningful multi-point statistics per country, but additional auto-detected disaggregation dimensions may create multiple groups per country. The LLM should pick group_by columns based on the question's analytical intent. Do NOT call this when the user wants a specific data point for a specific year — use data360_get_data for that (PATH A). Do NOT call this for visualization — use data360_get_viz_spec which fetches data internally. Auto-detection of disaggregation dimensions: before fetching data, this tool calls get_disaggregation to discover non-trivial dimensions (SEX, AGE, URBANISATION, COMP_BREAKDOWN_1/2) that the indicator supports beyond the aggregate total (_T). Any such dimension not already specified in disaggregation_filters is automatically added to group_by and fetched with all its values. The auto-expanded dimension names are reported in ambiguous_dimensions. Caller-provided disaggregation_filters always take precedence and suppress auto-expansion for that dimension. Args: database_id: Database identifier (e.g., "WB_WDI", "WB_GS"). indicator_id: Indicator ID (e.g., "WB_WDI_NY_GDP_PCAP_KD"). country_code: Optional 3-letter code or semicolon-separated list (e.g. "KEN;NGA"). disaggregation_filters: Optional dimension filters (e.g. {"UNIT_MEASURE": "KD"}). Filters specified here are honoured as-is and suppress auto-detection for that dimension. Pass {"SEX": "_T"} to force totals only, or {"SEX": None} to explicitly request all sex breakdowns. start_year: Optional start year. Defaults to last 5 years. end_year: Optional end year. Defaults to current year. group_by: Dimensions to group by. Default ["ref_area"]. Valid columns: ref_area, time_period, sex, age, urbanisation, unit_measure, comp_breakdown_1, comp_breakdown_2. Non-trivial dimensions found via auto-detection are appended automatically when not already present. DECISION GUIDE — always match group_by to the analytical intent: - "How has Kenya's GDP changed?" → ["ref_area"] (DEFAULT). Produces ONE group (KEN) spanning all years — gives real trend direction, min, max, mean, pct_change over the full period. - "Compare GDP trends: Kenya vs. Nigeria" → ["ref_area"] (DEFAULT). Produces TWO groups, one trend line each. - "GDP by sex for Kenya" → ["ref_area", "sex"]. One group per (country, sex). WARNING — NEVER use ["time_period"] as the sole group_by for trend or single-country questions. Grouping by time_period alone typically creates ONE GROUP PER YEAR and, when a single series is returned, often leaves each group with exactly ONE observation. In that common n=1 case, every group shows min=max=mean=median=that single value, change=0 %, trend=stable — mathematically degenerate and usually useless for trend analysis. ["time_period"] is only valid for cross-country year-over-year aggregates (e.g. "global average per year") when country_code is NOT specified. Returns: DataSummaryResponse: groups: List of per-group summaries with count, latest/earliest values, min/max/mean/median, total_change, pct_change, trend_direction, and source claim_ids. metadata: Indicator metadata (name, definition, database_name). unit_measure: The unit for interpreting values. ambiguous_dimensions: Lowercase names of dimensions that were auto-detected as non-trivial and appended to group_by (e.g. ["sex", "age"]). None when no auto-expansion occurred. error: Error message if request failed; otherwise None. Falls back to data360_get_data if this tool encounters an error. """ if group_by is None: group_by = ["ref_area"] # Validate group_by columns before the disagg fetch to fail fast. invalid_cols = [c for c in group_by if c.lower() not in _GROUPBY_FIELD_MAP] if invalid_cols: return DataSummaryResponse( error=f"Invalid group_by columns: {invalid_cols}. " f"Valid options: {list(_GROUPBY_FIELD_MAP.keys())}" ) # Pre-fetch: detect non-trivial disaggregation dimensions and auto-expand # them into group_by + effective_filters before the data fetch. This ensures # _fetch_all_pages retrieves all breakdown values in a single call, avoiding # the silent data loss that occurred when the API defaulted to _T only. sample_country: str | None = None if country_code: sample_country = country_code.replace(";", ",").split(",")[0].strip() or None effective_filters, auto_expanded_dims = await _auto_detect_disagg_dimensions( database_id=database_id, indicator_id=indicator_id, sample_country=sample_country, existing_filters=dict(disaggregation_filters or {}), expand_non_trivial=True, ) # Append auto-detected dims to group_by (preserves caller's explicit list). group_by = list(group_by) # mutable copy for dim_lower in auto_expanded_dims: if dim_lower not in group_by: group_by.append(dim_lower) # Validate any auto-appended dims (defensive; _DISAGG_DIMS_TO_DETECT are # all in _GROUPBY_FIELD_MAP, but check to avoid silent runtime errors). invalid_auto = [c for c in group_by if c.lower() not in _GROUPBY_FIELD_MAP] if invalid_auto: return DataSummaryResponse( error=f"Auto-detected invalid group_by columns: {invalid_auto}. " f"Valid options: {list(_GROUPBY_FIELD_MAP.keys())}" ) # Fetch all pages — paginate automatically until has_more=False try: data_response = await _fetch_all_pages( database_id=database_id, indicator_id=indicator_id, country_code=country_code, disaggregation_filters=effective_filters or None, start_year=start_year, end_year=end_year, ) except Exception as e: mcp_err = classify_error(e, context="summarize") return DataSummaryResponse(error=mcp_err.detail) if data_response.error: return DataSummaryResponse(error=data_response.error) if not data_response.data: return DataSummaryResponse( error="No data returned for the specified parameters. " "Fall back to data360_get_data with broader filters.", metadata=data_response.metadata, ) # Extract unit_measure from first row unit_measure = None if data_response.data: raw_unit = data_response.data[0].get("UNIT_MEASURE") if raw_unit: from .providers import get_codelist_manager # noqa: PLC0415 _cm = get_codelist_manager() await _cm._ensure_extdataportal_loaded() unit_measure = _cm.get_label("UNIT_MEASURE", str(raw_unit)) # Group rows by the specified dimensions raw_field_names = [_GROUPBY_FIELD_MAP[c.lower()] for c in group_by] groups_dict: dict[tuple[str, ...], list[dict[str, Any]]] = {} # Region and Income mapping helpers — build an inverted lookup dict once # so that each per-row call is O(1) rather than O(n_groups). from .providers import get_group_hierarchy_manager # noqa: PLC0415 ghm = get_group_hierarchy_manager() # _country_to_group: (country_code_upper, group_type) -> group_id _country_to_group: dict[tuple[str, str], str] = {} for _gid, _ginfo in ghm._groups.items(): _gtype = _ginfo.get("type", "") # Member countries → their containing group for _c in _ginfo.get("countries", []): _country_to_group[(_c.upper(), _gtype)] = _gid # The group code itself maps to itself (e.g. SSF → SSF for REGION) _country_to_group[(_gid.upper(), _gtype)] = _gid def get_country_group(country: str, group_type: str) -> str: return _country_to_group.get((country.upper(), group_type), "_MISSING") for row in data_response.data: key_parts = [] for f in raw_field_names: val = row.get(f) if val is None: if f == "REGION": ref_area = row.get("REF_AREA") val = get_country_group(ref_area, "REGION") if ref_area else "_MISSING" elif f == "INCOME_GROUP": ref_area = row.get("REF_AREA") val = get_country_group(ref_area, "INCOME") if ref_area else "_MISSING" else: # The upstream API omits disaggregation keys when their value is # the default aggregate total (_T). val = ( "_T" if f in ( "SEX", "AGE", "URBANISATION", "COMP_BREAKDOWN_1", "COMP_BREAKDOWN_2", ) else "_MISSING" ) key_parts.append(str(val)) key = tuple(key_parts) groups_dict.setdefault(key, []).append(row) # Build summaries group_summaries = [] for key_tuple, rows in groups_dict.items(): group_key = {col: val for col, val in zip(group_by, key_tuple)} group_summaries.append(_build_group_summary(group_key, rows)) # Sort by latest_year descending, then by group_key group_summaries.sort( key=lambda g: (g.latest_year or "", str(g.group_key)), reverse=True, ) # Resolve human-readable country names for ref_area groups. # Reuses the same _resolve_country_names helper used by rank_countries and # compare_countries. Injects ref_area_name directly into the group_key dict # so it flows through GroupSummary.to_compact() without a schema change. # Silently no-ops on error — country name is optional enrichment. _area_codes = sorted( {g.group_key["ref_area"] for g in group_summaries if "ref_area" in g.group_key} ) if _area_codes: _name_map = await _resolve_country_names(_area_codes) for g in group_summaries: if "ref_area" in g.group_key: name = _name_map.get(g.group_key["ref_area"]) if name: g.group_key["ref_area_name"] = name return DataSummaryResponse( groups=group_summaries, metadata=data_response.metadata, unit_measure=unit_measure, ambiguous_dimensions=auto_expanded_dims if auto_expanded_dims else None, ) async def rank_countries( database_id: str, indicator_id: str, country_group: str | None = None, country_codes: str | None = None, year: int | None = None, order: str = "desc", top_n: int = 10, disaggregation_filters: dict[str, str | None] | None = None, rank_universe: Literal["explicit", "all_member_economies"] = "explicit", ) -> RankingResponse: """Rank countries by indicator value for a specific year. Call for PATH B questions involving large country sets: "Top 10 countries by GDP", "Which South Asian country has the lowest poverty rate?", "Rank Sub-Saharan African countries by life expectancy". Handles ties, missing data, and group expansion internally (calls data360_expand_country_group when country_group is provided). **Global rankings ("top 10 in the world"):** set ``rank_universe='all_member_economies'`` and omit both ``country_group`` and ``country_codes``. The tool fetches unpinned geographic data from the Data API (full REF_AREA coverage) and keeps only FMR leaf member economies—regional aggregates such as ``EAS`` or ``EMU`` are excluded. When year is None, the tool selects the ranking year automatically. It considers both the latest available year and the year with broadest country coverage, and reports which strategy was used in year_selection_note. Both approaches are valid — the LLM should evaluate which is more appropriate for the user's question. Do NOT call for 2-3 country comparisons — use data360_compare_countries instead. Do NOT call for time series or trend analysis — use data360_summarize_data instead. Args: database_id: Database identifier. indicator_id: Indicator ID. country_group: Group code to rank within (e.g. "SAS", "LIC", "SSF"). Expanded internally via data360_expand_country_group. country_codes: Semicolon-separated codes. Overrides country_group if both given. year: Ranking year. None = auto-select (see year_selection_note in response). order: "desc" (highest first) or "asc" (lowest first). top_n: Number of top results to return (default 10). disaggregation_filters: Optional dimension filters. Do not pin ``REF_AREA`` when using ``rank_universe='all_member_economies'`` (it is ignored for the fetch). rank_universe: ``explicit`` (default) requires ``country_codes`` or ``country_group``. Use ``all_member_economies`` for worldwide leaderboards when both are omitted. Returns: RankingResponse: year: The ranking year used. year_selection_note: How the year was chosen (coverage vs recency). order: "desc" or "asc". total_with_data: Countries that had data. total_requested: Countries attempted. universe / universe_size: Scope metadata (see field descriptions). rankings: Ranked list with rank, ref_area, country_name, obs_value, percentile, claim_id. Ties share the same rank. excluded: Countries with no data and reason. metadata: Indicator metadata. unit_measure: Unit string. error: Error message if request failed; otherwise None. Falls back to data360_get_data if this tool encounters an error. """ from .providers import ( # noqa: PLC0415 expand_country_group, get_group_hierarchy_manager, ) ghm = get_group_hierarchy_manager() universe: str | None = None universe_size: int | None = None global_member_ranking = False # Resolve country codes (explicit scope wins over rank_universe) resolved_codes: list[str] = [] if country_codes: resolved_codes = [ c.strip() for c in country_codes.replace(";", ",").split(",") if c.strip() ] universe = "explicit" elif country_group: # Gate on is_group() before attempting expansion — this prevents silent # failures where an unrecognised code reaches the API and returns 0 rows. if not ghm.is_group(country_group): return RankingResponse( error=( f"'{country_group}' is not a recognised country group. " "Use data360_find_codelist_value('REF_AREA', '') to find " "the correct group code (e.g. 'SSF', 'SAS', 'LIC')." ) ) try: expand_result = await expand_country_group(country_group) if isinstance(expand_result, dict) and expand_result.get("country_codes"): resolved_codes = [ c.strip() for c in expand_result["country_codes"].split(",") if c.strip() ] elif isinstance(expand_result, dict) and expand_result.get("error"): return RankingResponse(error=expand_result["error"]) else: return RankingResponse( error=f"Could not expand country group '{country_group}'." ) except Exception as e: return RankingResponse(error=f"Failed to expand country group: {e}") universe = "explicit" elif rank_universe == "all_member_economies": resolved_codes = ghm.list_rankable_country_codes() global_member_ranking = True universe = "all_member_economies" else: return RankingResponse( error=( "No countries specified. Provide country_codes or country_group, " "or set rank_universe='all_member_economies' to rank all World Bank " "member economies (regional aggregates excluded)." ) ) total_requested = len(resolved_codes) universe_size = total_requested filters_for_auto = dict(disaggregation_filters or {}) if global_member_ranking: filters_for_auto.pop("REF_AREA", None) # Auto-detect UNIT_MEASURE and pin non-trivial disaggregation dims (SEX, AGE, # URBANISATION, COMP_BREAKDOWN_1/2) to their _T (aggregate total) value. # This prevents duplicate rows per country per year — which would otherwise # corrupt ranking statistics — when an indicator carries sex/age breakdowns. # Caller-provided disaggregation_filters always take precedence. sample_country = resolved_codes[0] if resolved_codes else "WLD" effective_filters, _ = await _auto_detect_disagg_dimensions( database_id=database_id, indicator_id=indicator_id, sample_country=sample_country, existing_filters=filters_for_auto, expand_non_trivial=False, ) if global_member_ranking and effective_filters: effective_filters.pop("REF_AREA", None) # Fetch all pages: explicit list uses REF_AREA pin; global uses unpinned full geography. try: data_response = await _fetch_all_pages( database_id=database_id, indicator_id=indicator_id, country_code=(None if global_member_ranking else ";".join(resolved_codes)), disaggregation_filters=effective_filters or None, start_year=year - 2 if year else None, end_year=year + 1 if year else None, ) except Exception as e: mcp_err = classify_error(e, context="rank") return RankingResponse(error=mcp_err.detail) if data_response.error: return RankingResponse(error=data_response.error) if not data_response.data: return RankingResponse( error="No data returned. Fall back to data360_get_data with broader filters.", metadata=data_response.metadata, total_requested=total_requested, ) # Build {year: {country: (value, claim_id)}} index year_country_map: dict[str, dict[str, tuple[float, str]]] = {} for row in data_response.data: tp = str(row.get("TIME_PERIOD", "")) ra = str(row.get("REF_AREA", "")) if global_member_ranking and not ghm.is_country(ra): continue val = row.get("OBS_VALUE") cid = row.get("claim_id", "") obs = _obs_value_to_float(val) if tp and ra and obs is not None: year_country_map.setdefault(tp, {})[ra] = (obs, cid) # Select ranking year year_selection_note = None if year: ranking_year = str(year) year_selection_note = f"User-specified year: {year}" # If exact year not available, try closest if ranking_year not in year_country_map: available = sorted(year_country_map.keys()) closest = ( min(available, key=lambda y: abs(int(y) - year)) if available else None ) if closest: ranking_year = closest year_selection_note = f"Requested {year}, closest available: {closest}" else: return RankingResponse( error=f"No data available near year {year}.", metadata=data_response.metadata, total_requested=total_requested, ) else: # Auto-select: find the latest available year with data latest_year = max(year_country_map.keys()) ranking_year = latest_year # Find the year with the broadest coverage for comparison/caveat best_year = max( year_country_map.keys(), key=lambda y: (len(year_country_map[y]), y), ) latest_coverage = len(year_country_map[latest_year]) best_coverage = len(year_country_map[best_year]) if latest_year == best_year: year_selection_note = ( f"Latest available year ({latest_year}) with " f"{latest_coverage}/{total_requested} countries." ) else: year_selection_note = ( f"Latest available year: {latest_year} " f"({latest_coverage}/{total_requested} countries). " f"Note: Coverage is partial/incomplete for this year. " f"An older year ({best_year}) has broader coverage with " f"{best_coverage}/{total_requested} countries." ) # Build ranking from selected year year_data = year_country_map.get(ranking_year, {}) unit_measure = None if data_response.data: raw_unit = data_response.data[0].get("UNIT_MEASURE") if raw_unit: from .providers import get_codelist_manager # noqa: PLC0415 _cm = get_codelist_manager() await _cm._ensure_extdataportal_loaded() unit_measure = _cm.get_label("UNIT_MEASURE", str(raw_unit)) # Batch-resolve country names in a single call name_map = await _resolve_country_names(resolved_codes) # Sort and rank entries = [(code, val, cid) for code, (val, cid) in year_data.items()] reverse = order.lower() != "asc" entries.sort(key=lambda x: x[1], reverse=reverse) # Assign ranks with tie handling. # Uses standard competition ranking (1,2,2,4): tied items share the same rank # and the next rank skips the number of tied items. This matches the most # common convention for leaderboard rankings. rankings: list[RankedCountry] = [] n_ranked = len(entries) for i, (code, val, cid) in enumerate(entries[:top_n]): if i == 0: rank = 1 elif entries[i - 1][1] == val: # Same value as previous — share its rank rank = rankings[-1].rank else: # Different value — rank is one past the previous entry's position rank = i + 1 percentile = ( round(((n_ranked - i) / n_ranked) * 100, 1) if n_ranked > 0 else None ) rankings.append( RankedCountry( rank=rank, ref_area=code, country_name=name_map.get(code), obs_value=round(val, 4), percentile=percentile, claim_id=cid, ) ) # Build excluded list excluded = [] countries_with_data = set(year_data.keys()) for code in resolved_codes: if code not in countries_with_data: excluded.append( ExcludedCountry( ref_area=code, country_name=name_map.get(code), reason=f"No data for {ranking_year}", ) ) return RankingResponse( year=ranking_year, year_selection_note=year_selection_note, order=order, total_with_data=len(year_data), total_requested=total_requested, universe=universe, universe_size=universe_size, rankings=rankings, excluded=excluded, metadata=data_response.metadata, unit_measure=unit_measure, ) async def compare_countries( database_id: str, indicator_id: str, country_codes: str, year: int | None = None, include_time_series: bool = False, start_year: int | None = None, end_year: int | None = None, disaggregation_filters: dict[str, str | None] | None = None, ) -> CountryComparisonResponse: """Compare an indicator across multiple countries with ranking and gap analysis. Call for PATH B (comparison) questions like "Compare GDP between Kenya and Nigeria" or "How does Brazil compare to its neighbors on poverty?". Returns a pre-ranked snapshot and optional aligned time series with convergence analysis. IMPORTANT LIMITATION: This tool supports comparing 2 to 8 countries. If the user asks to compare more than 8 countries, you MUST NOT use this tool. Instead, use rank_countries or summarize_data. The snapshot includes a year_selection_note explaining how the comparison year was chosen — either the user-specified year, or the latest year where all compared countries have data. Both recency-based and coverage-based year selection are valid strategies depending on the analytical intent. Do NOT call this for single-country queries — use data360_get_data or data360_summarize_data. Do NOT call this for ranking within a large group (>8 countries) — use data360_rank_countries instead. Args: database_id: Database identifier (e.g., "WB_WDI"). indicator_id: Indicator ID (e.g., "WB_WDI_NY_GDP_PCAP_KD"). country_codes: Semicolon-separated country codes (e.g. "KEN;NGA;ZAF"). Supports 2-8 countries. year: Comparison year. None = latest year where all countries have data. include_time_series: If True, include aligned time series + convergence. start_year: For time series mode. Defaults to last 5 years. end_year: For time series mode. Defaults to current year. disaggregation_filters: Optional dimension filters. Returns: CountryComparisonResponse: snapshot: Single-year ranked comparison with spread statistics. time_series: Aligned series + convergence (when include_time_series=True). metadata: Indicator metadata. unit_measure: Unit string. error: Error message if request failed; otherwise None. Falls back to data360_get_data if this tool encounters an error. """ codes = [c.strip() for c in country_codes.replace(";", ",").split(",") if c.strip()] if len(codes) < 2: return CountryComparisonResponse( error="At least 2 country codes required for comparison." ) # Auto-detect UNIT_MEASURE and pin non-trivial disaggregation dims (SEX, AGE, # URBANISATION, COMP_BREAKDOWN_1/2) to their _T (aggregate total) value. # This prevents duplicate rows per country per year — which would otherwise # corrupt comparison statistics — when an indicator carries sex/age breakdowns. # Caller-provided disaggregation_filters always take precedence. effective_filters, _ = await _auto_detect_disagg_dimensions( database_id=database_id, indicator_id=indicator_id, sample_country=codes[0], existing_filters=dict(disaggregation_filters or {}), expand_non_trivial=False, ) # Fetch all pages for all countries in one batched call try: data_response = await _fetch_all_pages( database_id=database_id, indicator_id=indicator_id, country_code=";".join(codes), disaggregation_filters=effective_filters or None, start_year=start_year or (year - 10 if year else None), end_year=end_year or (year + 1 if year else None), ) except Exception as e: mcp_err = classify_error(e, context="compare") return CountryComparisonResponse(error=mcp_err.detail) if data_response.error: return CountryComparisonResponse(error=data_response.error) if not data_response.data: return CountryComparisonResponse( error="No data returned. Fall back to data360_get_data with broader filters.", metadata=data_response.metadata, ) # Index: {country: {year: (value, claim_id)}} country_year_map: dict[str, dict[str, tuple[float, str]]] = {} for row in data_response.data: ra = str(row.get("REF_AREA", "")) tp = str(row.get("TIME_PERIOD", "")) val = row.get("OBS_VALUE") cid = row.get("claim_id", "") obs = _obs_value_to_float(val) if ra and tp and obs is not None: country_year_map.setdefault(ra, {})[tp] = (obs, cid) # Batch-resolve country names in a single call name_map = await _resolve_country_names(codes) unit_measure = None if data_response.data: raw_unit = data_response.data[0].get("UNIT_MEASURE") if raw_unit: from .providers import get_codelist_manager # noqa: PLC0415 _cm = get_codelist_manager() await _cm._ensure_extdataportal_loaded() unit_measure = _cm.get_label("UNIT_MEASURE", str(raw_unit)) # Determine snapshot year all_years = set() for yrs in country_year_map.values(): all_years.update(yrs.keys()) common_years = sorted(all_years) for c in codes: if c not in country_year_map: common_years = [] break common_years = [y for y in common_years if y in country_year_map[c]] year_selection_note = None if year: snap_year = str(year) year_selection_note = f"User-specified year: {year}" elif common_years: snap_year = common_years[-1] # Latest common year n_countries = sum( 1 for c in codes if c in country_year_map and snap_year in country_year_map[c] ) year_selection_note = ( f"Latest year with data for all compared countries: {snap_year} " f"({n_countries}/{len(codes)} countries)" ) else: # Fallback: latest year from any country snap_year = max(all_years) if all_years else None if snap_year: n_countries = sum( 1 for c in codes if c in country_year_map and snap_year in country_year_map[c] ) year_selection_note = ( f"Latest year with data (partial coverage): {snap_year} " f"({n_countries}/{len(codes)} countries)" ) else: year_selection_note = "No data available for any country." # Build snapshot snapshot = None if snap_year: snap_entries = [] for code in codes: entry = country_year_map.get(code, {}).get(snap_year) if entry: snap_entries.append((code, entry[0], entry[1])) snap_entries.sort(key=lambda x: x[1], reverse=True) leader_val = snap_entries[0][1] if snap_entries else 0 ranked = [] for i, (code, val, cid) in enumerate(snap_entries): gap = ( round(((val - leader_val) / leader_val) * 100, 2) if leader_val != 0 else 0 ) ranked.append( RankedCountry( rank=i + 1, ref_area=code, country_name=name_map.get(code), obs_value=round(val, 4), percentile=None, claim_id=cid, ) ) vals_s = pd.Series([e[1] for e in snap_entries], dtype=float) spread: dict[str, float | None] = {} if not vals_s.empty: spread = { "min": round(float(vals_s.min()), 4), "max": round(float(vals_s.max()), 4), "range": round(float(vals_s.max() - vals_s.min()), 4), "coefficient_of_variation": ( round(float(vals_s.std() / vals_s.mean()), 4) if len(vals_s) > 1 and vals_s.mean() != 0 else None ), } snapshot = ComparisonSnapshot( year=snap_year, year_selection_note=year_selection_note, rankings=ranked, spread=spread, ) # Build time series (if requested) using pandas for alignment, CAGR, convergence ts_response = None if include_time_series and common_years: # Build a value pivot: rows=TIME_PERIOD, columns=REF_AREA # Use country_year_map (already built) to construct the pivot cleanly val_records = [ {"TIME_PERIOD": y, "REF_AREA": c, "OBS_VALUE": v, "claim_id": cid} for c, yr_map in country_year_map.items() for y, (v, cid) in yr_map.items() ] ts_df = pd.DataFrame(val_records) ts_df["OBS_VALUE"] = pd.to_numeric(ts_df["OBS_VALUE"], errors="coerce") # Pivot: rows=TIME_PERIOD, columns=REF_AREA — NaN where country has no data val_pivot = ts_df.pivot_table( index="TIME_PERIOD", columns="REF_AREA", values="OBS_VALUE", aggfunc="last" ) # Common years = rows where ALL requested countries have data aligned_pivot = val_pivot[codes].dropna(axis=0) aligned_years = sorted(aligned_pivot.index.tolist()) # Build series output (with claim_ids from country_year_map) series: dict[str, list[dict[str, Any]]] = {} for code in codes: code_data = country_year_map.get(code, {}) series[code] = [ { "time_period": y, "obs_value": code_data[y][0], "claim_id": code_data[y][1], } for y in aligned_years if y in code_data ] # CAGR: vectorized over aligned pivot cagr: dict[str, float | None] = {} if len(aligned_years) >= 2: first_y, last_y = aligned_years[0], aligned_years[-1] n_years = int(last_y) - int(first_y) for code in codes: if code in aligned_pivot.columns and n_years > 0: v0 = aligned_pivot.loc[first_y, code] v1 = aligned_pivot.loc[last_y, code] # CAGR requires a positive base and a non-negative end value. # Raising a negative ratio to a fractional power raises ValueError, # so guard against both v0 <= 0 and v1 < 0. cagr[code] = ( round(float(((v1 / v0) ** (1 / n_years) - 1) * 100), 2) if pd.notna(v0) and pd.notna(v1) and v0 > 0 and v1 >= 0 else None ) else: cagr[code] = None else: cagr = {code: None for code in codes} # Convergence: classify trend in coefficient of variation across aligned years convergence = None if len(aligned_years) >= 3 and len(codes) > 1: row_means = aligned_pivot[codes].mean(axis=1) row_stds = aligned_pivot[codes].std(axis=1, ddof=1) # CV per year — only where mean != 0 cvs_s = ( (row_stds / row_means) .replace([float("inf"), float("-inf")], pd.NA) .dropna() ) if len(cvs_s) >= 3: cv_trend = _compute_trend_direction(cvs_s.tolist()) convergence = { "decreasing": "converging", "increasing": "diverging", }.get(cv_trend, "parallel") ts_response = ComparisonTimeSeries( aligned_years=aligned_years, series=series, convergence=convergence, cagr=cagr, ) return CountryComparisonResponse( snapshot=snapshot, time_series=ts_response, metadata=data_response.metadata, unit_measure=unit_measure, country_names=name_map, ) # --------------------------------------------------------------------------- # Data Aggregation Tools (Tier 2 — stubs for future implementation) # --------------------------------------------------------------------------- _TIER2_STUB_MSG = ( "This tool is not yet implemented. " "Fall back to data360_get_data for raw data retrieval, " "then use data360_summarize_data or data360_rank_countries for analysis." ) async def compute_derived( database_id: str, indicator_id: str, country_code: str | None = None, computation: str = "growth_rate", window: int = 5, base_year: int | None = None, start_year: int | None = None, end_year: int | None = None, disaggregation_filters: dict[str, str | None] | None = None, ) -> DerivedDataResponse: """Compute derived/transformed values from raw indicator data (not yet implemented). Will support: growth_rate (YoY %), cagr (compound annual), moving_average, period_average, period_change (absolute + %), index (rebase to base_year=100). Useful for PATH C (trend) questions requiring arithmetic. Current status: stub. Falls back with recoverable error directing the LLM to use data360_get_data + data360_summarize_data as alternatives. """ return DerivedDataResponse(error=_TIER2_STUB_MSG) async def pivot_table( entries: list[dict[str, str]], country_codes: str, year: int | None = None, start_year: int | None = None, end_year: int | None = None, rows: str = "ref_area", columns: str = "indicator", value_agg: str = "latest", ) -> PivotTableResponse: """Build a cross-tabulation table from multiple indicators (not yet implemented). Will create a country-by-indicator matrix for PATH D (analytical) questions where multiple diagnostic indicators need to be organized into a single table. Current status: stub. Falls back with recoverable error directing the LLM to call data360_get_data separately per indicator. """ return PivotTableResponse(error=_TIER2_STUB_MSG) async def diagnostic_summary( topic: str, country_code: str, start_year: int | None = None, end_year: int | None = None, max_indicators: int = 5, ) -> DiagnosticSummaryResponse: """Produce a multi-indicator diagnostic summary for a topic (not yet implemented). Will map CONCEPT VOCABULARY categories to search queries, fetch data for the most diagnostic indicators, and return structured per-indicator trend analysis. Useful for PATH D (analytical) and PATH E (policy bridge) questions. Current status: stub. Falls back with recoverable error directing the LLM to decompose the question manually using data360_search_indicators + data360_get_data. """ return DiagnosticSummaryResponse(error=_TIER2_STUB_MSG)