| 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() |
|
|
| |
| COUNTRY_CODE_LENGTH = 3 |
| SCORE_THRESHOLD = 70 |
| MAX_RETURN_STATEMENTS = 6 |
| DEFAULT_SEARCH_LIMIT = 5 |
|
|
| |
| |
| |
| |
| |
| |
| |
| _METADATA_CACHE_TTL = 86_400 |
| _metadata_cache: cachetools.TTLCache = cachetools.TTLCache( |
| maxsize=256, ttl=_METADATA_CACHE_TTL |
| ) |
| _disaggregation_cache: cachetools.TTLCache = cachetools.TTLCache( |
| maxsize=256, ttl=_METADATA_CACHE_TTL |
| ) |
| |
| _metadata_cache_lock = threading.Lock() |
| _disaggregation_cache_lock = threading.Lock() |
|
|
| _DIMENSIONS_API_CACHE_TTL = 600 |
| _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() |
|
|
| |
| |
| _ENRICHMENT_SELECT_FIELDS = [ |
| "idno", |
| "name", |
| "database_id", |
| "definition_long", |
| "periodicity", |
| "time_periods", |
| "ref_country", |
| "dimensions", |
| "measurement_unit", |
| ] |
|
|
| |
| |
| |
| _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 = frozenset( |
| {"SEX", "AGE", "URBANISATION", "COMP_BREAKDOWN_1", "COMP_BREAKDOWN_2"} |
| ) |
| |
| _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 |
|
|
|
|
| |
| _STRIP_DIMENSIONS = frozenset({"INDICATOR", "FREQ"}) |
| |
| _TRIVIAL_SINGLE_DIMENSIONS = frozenset({"SEX", "AGE", "URBANISATION"}) |
| |
| _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", []) |
|
|
| |
| if name in _STRIP_DIMENSIONS: |
| continue |
|
|
| |
| if name in _TRIVIAL_SINGLE_DIMENSIONS and values == ["_T"]: |
| continue |
|
|
| entry: dict[str, Any] = {"field_name": name} |
|
|
| |
| if "label_name" in dim: |
| entry["label_name"] = dim["label_name"] |
|
|
| |
| if name == "TIME_PERIOD": |
| entry["field_value"] = sorted(values) |
| |
| 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(): |
| |
| |
| if val is None: |
| valid_filters[dim] = None |
| continue |
|
|
| |
| if isinstance(val, str) and not val.strip(): |
| continue |
|
|
| |
| if dim == "REF_AREA" and isinstance(val, str) and ";" in val: |
| val = ",".join(p.strip() for p in val.split(";") if p.strip()) |
|
|
| |
| if dim in available_disaggregations: |
| valid_values = available_disaggregations[dim] |
| is_valid = True |
|
|
| |
| 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 |
| |
| 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: |
| |
| |
| |
| 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 = {} |
|
|
| |
| if disaggregation_filters: |
| for dim, raw_val in disaggregation_filters.items(): |
| |
| if dim == "FREQ": |
| continue |
| if raw_val is not None: |
| |
| 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 |
|
|
| |
| if available_disaggregations: |
| for dim, values in available_disaggregations.items(): |
| |
| if disaggregation_filters and dim in disaggregation_filters: |
| continue |
|
|
| |
| if "_T" in values: |
| |
| if len(values) == 1: |
| continue |
|
|
| |
| effective[dim] = "_T" |
|
|
| else: |
| |
| |
| |
| |
| 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: |
| |
| 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: |
| |
| 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"]) |
|
|
| |
| 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: |
| |
| raise |
| except Exception as e: |
| |
| 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 |
|
|
| if not country_query: |
| return None |
|
|
| |
| |
| 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 |
|
|
| |
| if len(country_query) == COUNTRY_CODE_LENGTH and country_query.isupper(): |
| return country_query |
| |
| 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", |
| } |
|
|
| |
| |
| 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() |
|
|
| |
| 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") |
| |
| is_search_v3 = "ref_country" not in raw or raw["ref_country"] is None |
|
|
| if len(requested_codes) == 1 and is_search_v3: |
| |
| |
| 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 |
| } |
|
|
| |
| 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]) |
|
|
| |
| |
| primary = item.primary_source |
| original_idno: str | None = None |
| if primary and primary.metadata_id and primary.database_id: |
| original_idno = raw.get("idno") |
| |
| |
| 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: |
| |
| |
| _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): |
| |
| 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) |
|
|
| |
| 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) |
|
|
| |
| for ind in indicators: |
| ind.requested_country = country_code |
|
|
| |
| |
| |
| |
| |
| 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 |
|
|
| |
| |
| |
| 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( |
| query: str | None = None, |
| required_country: str | None = None, |
| limit: int = DEFAULT_SEARCH_LIMIT, |
| offset: int = 0, |
| |
| queries: list[str] | None = None, |
| query_groups: list[QueryGroup] | None = None, |
| result_layout: str = "merged", |
| dedupe: bool = True, |
| database: str | None = None, |
| |
| |
| |
| |
| count: bool = True, |
| n_results: int | None = None, |
| filter: str | None = None, |
| 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": ["<term1>", "<term2>"], "country": "<name or 3-letter code>"} |
| 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. |
| """ |
|
|
| |
| |
| |
| |
| 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." |
| ) |
|
|
| |
| 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: |
| 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, |
| ) |
|
|
| |
| 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, |
| ) |
|
|
| |
| country_code: str | None = None |
| if required_country: |
| country_code = await _resolve_country_code(required_country) |
|
|
| |
| per_query_codes: list[str | None] = [country_code] * len(clean_queries) |
|
|
| |
| 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, |
| ) |
|
|
| |
| 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." |
| ) |
|
|
| |
| 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: |
| 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], |
| ) |
|
|
| |
| 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, |
| ) |
|
|
| |
| 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] |
|
|
| |
| 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, |
| ) |
|
|
| |
| |
| |
| |
| 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, |
| ) |
|
|
| |
| |
| |
| country_code = None |
| if required_country: |
| country_code = await _resolve_country_code(required_country) |
|
|
| |
| try: |
| search_result = await _search_raw( |
| query=query, |
| 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 |
| ) |
|
|
| |
| |
| redirected = [ind for ind in indicators if ind.primary_source_of is not None] |
| await _backfill_primary_metadata(redirected) |
|
|
| |
| |
| |
| |
| 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, |
| |
| 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 |
|
|
| |
| verify_tasks = [] |
|
|
| |
| 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) |
|
|
| |
| 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 |
| ) |
| |
| 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)) |
|
|
| |
| if verify_tasks: |
| await asyncio.gather(*verify_tasks) |
|
|
| |
| 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] |
|
|
| |
| 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), |
| ) |
| ) |
|
|
| |
| all_codes = sorted({c for c in per_query_codes if c}) |
| response_country = ";".join(all_codes) if all_codes else None |
|
|
| |
| 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 |
| ] |
| |
| 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: |
| 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) |
|
|
|
|
| |
| 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: |
| """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_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 |
|
|
| |
| queried_countries = await _resolve_queried_countries(required_country) |
|
|
| |
| 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) |
|
|
| |
| 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"} |
|
|
| |
| 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} |
|
|
| |
| 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", {} |
| ) |
| |
| |
| |
| 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) |
| |
| |
| 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) |
|
|
| |
| 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) |
|
|
| |
| error_message = "; ".join(errors) if errors else None |
|
|
| result = MetadataResponse( |
| indicator_metadata=indicator_metadata, |
| disaggregation_options=disaggregations, |
| error=error_message, |
| ) |
| |
| |
| 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). |
| """ |
| |
| _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 |
|
|
| |
| 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) |
| |
| 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 |
|
|
| |
| |
| |
| _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() |
| |
| |
| |
| 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 |
|
|
| current_year = datetime.now().year |
| span = _DEFAULT_TIME_WINDOW_YEARS - 1 |
|
|
| if start_year is not None: |
| |
| start_year = int(start_year) |
| if end_year is not None: |
| |
| 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" |
|
|
| |
| 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 |
|
|
| |
| 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) |
|
|
| |
| params: dict[str, Any] = { |
| "DATABASE_ID": database_id, |
| "INDICATOR": indicator_id, |
| "timePeriodFrom": start_year, |
| "timePeriodTo": end_year, |
| "skip": offset, |
| |
| |
| "top": limit + 1, |
| } |
|
|
| |
| if country_code: |
| |
| params["REF_AREA"] = country_code.replace(";", ",") |
|
|
| |
| |
| metadata_res = await get_metadata( |
| database_id, |
| indicator_id, |
| select_fields=[ |
| "idno", |
| "name", |
| "database_id", |
| "periodicity", |
| "measurement_unit", |
| "definition_short", |
| ], |
| fetch_disaggregation=True, |
| required_country=country_code, |
| ) |
|
|
| if metadata_res.error: |
| if metadata_res.indicator_metadata is None: |
| |
| _logger.warning( |
| "Aborting get_data for %s: indicator metadata missing. Error: %s", |
| indicator_id, |
| metadata_res.error, |
| ) |
| return IndicatorDataResponse(error=metadata_res.error) |
| |
| |
| _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 {} |
|
|
| |
| 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"] |
|
|
| |
| |
| valid_filters, validation_errors = _validate_user_filters( |
| disaggregation_filters, available_disaggregations |
| ) |
| if validation_errors: |
| _logger.warning(f"Validation errors for {indicator_id}: {validation_errors}") |
|
|
| |
| 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: |
| |
| |
| raise ParseError(context="data", original_error=e) |
|
|
| raw_data = data_json.get("value", []) |
| total_count = data_json.get("@odata.count") |
|
|
| |
| |
| api_returned_count = len(raw_data) |
| has_more = api_returned_count > limit |
|
|
| |
| if api_returned_count > limit: |
| raw_data = raw_data[:limit] |
|
|
| |
| api_next_offset = offset + len(raw_data) if has_more else None |
|
|
| |
| raw_data.sort(key=lambda x: str(x.get("TIME_PERIOD", "")), reverse=True) |
|
|
| |
| if len(raw_data) > limit: |
| raw_data = raw_data[:limit] |
|
|
| from .providers import get_codelist_manager |
|
|
| _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)) |
|
|
| |
| 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 |
|
|
|
|
| |
| 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 |
|
|
| |
| 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 |
|
|
| _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: |
| |
| raise |
| except Exception as e: |
| |
| |
| |
| 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): |
| |
| 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 |
|
|
| warnings.warn( |
| "discover_indicators is deprecated. Use search() + get_disaggregation() + get_metadata() instead.", |
| DeprecationWarning, |
| stacklevel=2, |
| ) |
|
|
| |
|
|
|
|
| |
|
|
|
|
| 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() |
|
|
| |
| base_url = settings.api_url.rstrip("/") |
| base = f"{base_url}/data" |
|
|
| |
| params = {"DATABASE_ID": database_id, "INDICATOR": indicator_id} |
|
|
| if country_code: |
| |
| params["REF_AREA"] = country_code.replace(";", ",") |
|
|
| if start_year: |
| params["timePeriodFrom"] = start_year |
|
|
| if end_year: |
| params["timePeriodTo"] = end_year |
| |
| |
| metadata_res = await get_metadata( |
| database_id, |
| indicator_id, |
| select_fields=[], |
| 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, |
| ) |
|
|
| |
| 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"] |
|
|
| |
| valid_filters, validation_errors = _validate_user_filters( |
| disaggregation_filters, available_disaggregations |
| ) |
| if validation_errors: |
| |
| |
| raise ValueError("\n\n".join(validation_errors)) |
|
|
| |
| effective_filters = _build_disaggregation_params( |
| valid_filters, available_disaggregations=available_disaggregations |
| ) |
|
|
| |
| params.update(effective_filters) |
|
|
| |
| limit = 1000 |
| if country_code: |
| |
| |
| 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}" |
|
|
|
|
| |
| |
| |
|
|
| _PAGE_SIZE = 100 |
|
|
|
|
| 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 |
| 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: |
| |
| 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 |
|
|
| 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 {} |
|
|
|
|
| |
| |
| _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 |
|
|
| |
| if field_name in effective_filters: |
| continue |
|
|
| |
| 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 |
|
|
| |
| non_total_values = [v for v in values if v not in ("_T", "_Z")] |
| if not non_total_values: |
| continue |
|
|
| if expand_non_trivial: |
| |
| effective_filters[field_name] = None |
| auto_expanded_dims.append(field_name.lower()) |
| else: |
| |
| 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)] |
|
|
| if len(y) < 2: |
| return "stable" |
|
|
| x = np.arange(len(y)).reshape(-1, 1) |
|
|
| model = HuberRegressor() |
| try: |
| model.fit(x, y) |
| except Exception: |
| |
| 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) |
|
|
| |
| claim_ids: list[str] = ( |
| df["claim_id"].dropna().tolist() if "claim_id" in df.columns else [] |
| ) |
|
|
| |
| 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) |
|
|
| |
| |
| 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 |
| ) |
|
|
| |
| |
| _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, |
| ) |
|
|
|
|
| |
| _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"] |
|
|
| |
| 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())}" |
| ) |
|
|
| |
| |
| |
| |
| 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, |
| ) |
|
|
| |
| group_by = list(group_by) |
| for dim_lower in auto_expanded_dims: |
| if dim_lower not in group_by: |
| group_by.append(dim_lower) |
|
|
| |
| |
| 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())}" |
| ) |
|
|
| |
| 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, |
| ) |
|
|
| |
| 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 |
| _cm = get_codelist_manager() |
| await _cm._ensure_extdataportal_loaded() |
| unit_measure = _cm.get_label("UNIT_MEASURE", str(raw_unit)) |
|
|
| |
| raw_field_names = [_GROUPBY_FIELD_MAP[c.lower()] for c in group_by] |
| groups_dict: dict[tuple[str, ...], list[dict[str, Any]]] = {} |
|
|
| |
| |
| from .providers import get_group_hierarchy_manager |
| ghm = get_group_hierarchy_manager() |
| |
| _country_to_group: dict[tuple[str, str], str] = {} |
| for _gid, _ginfo in ghm._groups.items(): |
| _gtype = _ginfo.get("type", "") |
| |
| for _c in _ginfo.get("countries", []): |
| _country_to_group[(_c.upper(), _gtype)] = _gid |
| |
| _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: |
| |
| |
| 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) |
|
|
| |
| 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)) |
|
|
| |
| group_summaries.sort( |
| key=lambda g: (g.latest_year or "", str(g.group_key)), |
| reverse=True, |
| ) |
|
|
| |
| |
| |
| |
| |
| _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 ( |
| 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 |
|
|
| |
| 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: |
| |
| |
| 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', '<name>') 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) |
|
|
| |
| |
| |
| |
| |
| 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) |
|
|
| |
| 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, |
| ) |
|
|
| |
| 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) |
|
|
| |
| year_selection_note = None |
| if year: |
| ranking_year = str(year) |
| year_selection_note = f"User-specified year: {year}" |
| |
| 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: |
| |
| latest_year = max(year_country_map.keys()) |
| ranking_year = latest_year |
|
|
| |
| 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." |
| ) |
|
|
| |
| 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 |
| _cm = get_codelist_manager() |
| await _cm._ensure_extdataportal_loaded() |
| unit_measure = _cm.get_label("UNIT_MEASURE", str(raw_unit)) |
|
|
| |
| name_map = await _resolve_country_names(resolved_codes) |
|
|
| |
| 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) |
|
|
| |
| |
| |
| |
| 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: |
| |
| rank = rankings[-1].rank |
| else: |
| |
| 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, |
| ) |
| ) |
|
|
| |
| 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." |
| ) |
|
|
| |
| |
| |
| |
| |
| 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, |
| ) |
|
|
| |
| 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, |
| ) |
|
|
| |
| 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) |
|
|
| |
| 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 |
| _cm = get_codelist_manager() |
| await _cm._ensure_extdataportal_loaded() |
| unit_measure = _cm.get_label("UNIT_MEASURE", str(raw_unit)) |
|
|
| |
| 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] |
| 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: |
| |
| 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." |
|
|
| |
| 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, |
| ) |
|
|
| |
| ts_response = None |
| if include_time_series and common_years: |
| |
| |
| 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") |
|
|
| |
| val_pivot = ts_df.pivot_table( |
| index="TIME_PERIOD", columns="REF_AREA", values="OBS_VALUE", aggfunc="last" |
| ) |
| |
| aligned_pivot = val_pivot[codes].dropna(axis=0) |
| aligned_years = sorted(aligned_pivot.index.tolist()) |
|
|
| |
| 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: 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[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 = 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) |
| |
| 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, |
| ) |
|
|
|
|
| |
| |
| |
|
|
| _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) |
|
|