File size: 37,852 Bytes
2f203f5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
import re
from typing import Any, Literal

from pydantic import BaseModel, Field, model_validator

# Prefix used by the search API's metadata_link entries.
# Strip this to derive the usable indicator_id.
_META_ID_PREFIX = "META_"


def sanitize_search_query(query: str) -> str:
    """Validate search query and sanitize unsafe characters for Search V3."""
    if not query or not query.strip():
        raise ValueError("Search query cannot be empty")
    # Strip parentheses, dollar signs, and other punctuation that causes Search V3 to return 0 results.
    # Preserve alphanumerics, underscores, hyphens, commas, and periods.
    cleaned = re.sub(r"[^\w\s\-\,\.]", " ", query)
    sanitized = " ".join(cleaned.split())
    if not sanitized or not sanitized.strip():
        raise ValueError("Search query cannot be empty after sanitization")
    return sanitized



class MCPPagedResponse(BaseModel):
    """Response model for MCP paged results.
    For more information, see: https://github.com/anthropics/skills/blob/main/skills/mcp-builder/reference/mcp_best_practices.md#pagination

    Always respect limit parameter
    Return has_more, next_offset, total_count
    Default to 20-50 items
    """

    count: int = Field(default=0, description="Number of results in the current page")
    total_count: int | None = Field(default=None, description="Total number of results")
    offset: int | None = Field(default=None, description="Offset of the current page")
    has_more: bool | None = Field(
        default=None, description="Whether there are more results"
    )
    next_offset: int | None = Field(default=None, description="Offset of the next page")


class SearchRequest(BaseModel):
    """Request model for data360 search queries."""

    query: str = Field(
        ..., description="Search query string to find relevant data series"
    )
    limit: int = Field(
        default=10,
        description="Number of results to return (default is 10)",
        ge=1,
        le=50,
    )
    count: bool = Field(
        default=True, description="Whether to include total count in response"
    )
    filter: str | None = Field(
        default=None,
        description="OData filter expression (e.g., \"type eq 'indicator'\")",
    )
    orderby: str | None = Field(
        default=None,
        description='OData orderby expression (e.g., "series_description/name")',
    )
    select: str | None = Field(
        default=None,
        description='OData select expression (e.g., "series_description/idno, series_description/name")',
    )
    offset: int = Field(default=0, description="Offset of the current page")

    @model_validator(mode="after")
    def validate_query(self) -> "SearchRequest":
        """Validate search query and sanitize unsafe characters."""
        self.query = sanitize_search_query(self.query)
        return self

    @model_validator(mode="after")
    def set_select_default(self) -> "SearchRequest":
        """Set default select value when None is provided."""
        if self.select is None:
            self.select = "series_description/idno, series_description/name, series_description/database_id, series_description/definition_long"
        return self

    @model_validator(mode="after")
    def set_filter_default(self) -> "SearchRequest":
        """Set default filter value when None is provided."""
        if self.filter is None:
            # Default to indicator
            self.filter = "type eq 'indicator'"
        return self


class PrimarySourceInfo(BaseModel):
    """A single metadata_link entry identifying a primary source indicator.

    The search API returns this under ``additional.metadata_link`` when an
    indicator has been curated to point to its authoritative primary source
    (typically in WDI).
    """

    type: str = Field(..., description="Link type (e.g. 'primary')")
    metadata_id: str = Field(
        ...,
        description="Metadata ID with META_ prefix (e.g. META_WB_WDI_SP_POP_TOTL)",
    )
    database_id: str | None = Field(
        None, description="Primary source database (e.g. WB_WDI)"
    )
    database_name: str | None = Field(
        None, description="Human-readable database name"
    )

    @property
    def indicator_id(self) -> str:
        """Derive the usable indicator_id by stripping the META_ prefix."""
        if self.metadata_id.startswith(_META_ID_PREFIX):
            return self.metadata_id[len(_META_ID_PREFIX) :]
        return self.metadata_id


class SeriesDescription(BaseModel):
    """Model for series description in search results.

    Fields available via select_fields in search:
    - idno, name, database_id, definition_long (core)
    - periodicity, time_periods, ref_country, dimensions (extended)
    """

    idno: str = Field(..., description="Series identifier")
    name: str = Field(..., description="Series name")
    database_id: str = Field(..., description="Database identifier")
    definition_long: str | None = Field(None, description="Series definition")
    periodicity: str | None = Field(
        None, description="Data periodicity (Annual, Monthly, etc)"
    )
    time_periods: list[dict[str, Any]] | None = Field(
        None, description="Time period coverage"
    )
    ref_country: list[dict[str, Any] | str] | None = Field(
        None, description="Countries with data"
    )
    dimensions: list[dict[str, Any]] | None = Field(
        None, description="Available disaggregations"
    )
    metadata_link: list[PrimarySourceInfo] = Field(
        default_factory=list,
        description="Metadata links from the API's additional.metadata_link field.",
    )
    connected_entities: list[dict[str, Any]] | None = Field(
        default=None,
        description="Connected secondary entities for SearchV3 redirect mapping.",
    )

    @property
    def primary_source(self) -> PrimarySourceInfo | None:
        """Return the first primary-type metadata link, or None."""
        return next((link for link in self.metadata_link if link.type == "primary"), None)


class SearchResponse(MCPPagedResponse):
    """Response model for data360 search results (raw API response)."""

    items: list[SeriesDescription] | None = Field(
        default=None, description="List of search results containing series information"
    )
    error: str | None = Field(
        default=None, description="Error message if search failed"
    )


class EnrichedIndicator(BaseModel):
    """Model for an enriched indicator in search results.

    Optimized for LLM consumption with compact, relevant fields.
    """

    idno: str = Field(..., description="Indicator ID (e.g., WB_GS_NY_GDP_PCAP_KD)")
    database_id: str = Field(..., description="Database ID (e.g., WB_GS)")
    database_name: str | None = Field(
        None,
        description="Human-readable dataset name for the database_id "
        "(e.g., 'Gender Statistics' for WB_GS). "
        "Use this when presenting data to users — never expand database_id by guessing.",
    )
    name: str = Field(..., description="Indicator name")
    truncated_definition: str = Field(
        ..., description="Truncated definition (max 100 chars)"
    )
    periodicity: str | None = Field(
        None, description="Data periodicity (Annual, Monthly)"
    )
    latest_data: str | None = Field(None, description="Most recent year with data")
    time_period_range: str | None = Field(
        None, description="Data availability range (e.g., '1990-2024')"
    )
    covers_country: dict[str, bool] | None = Field(
        None,
        description="Per-country coverage map (e.g. {'KEN': True, 'GHA': False}). "
        "Populated when required_country is provided. None when no country was requested.",
    )
    requested_country: str | None = Field(
        None,
        description="Resolved country code this indicator was evaluated against "
        "(set when per-group countries are used via query_groups; also set for "
        "single-query path when required_country is provided).",
    )
    dimensions: list[str] | None = Field(
        None, description="Available disaggregations (SEX, AGE, URBANISATION)"
    )
    primary_source_of: str | None = Field(
        None,
        description="When this indicator was redirected from a secondary source, "
        "contains the original secondary idno (e.g. 'WB_HNP_SP_POP_TOTL'). "
        "None if the indicator was already the primary source.",
    )


class EnrichedSearchResponse(MCPPagedResponse):
    """Response model for enriched search (LLM-optimized).

    Returns indicators sorted by country coverage and recency.
    """

    indicators: list[EnrichedIndicator] = Field(
        default_factory=list, description="Enriched indicators sorted by relevance"
    )
    required_country: str | None = Field(
        None,
        description="Resolved country code(s). Semicolon-separated for multiple countries "
        "(e.g. 'KEN' or 'KEN;GHA').",
    )
    country_names: dict[str, str] | None = Field(
        None, description="Resolved names of requested countries"
    )
    error: str | None = Field(None, description="Error message if search failed")


class QueryGroup(BaseModel):
    """A group of search queries scoped to an optional country.

    Allows binding multiple search terms to a specific geographic scope
    in a single search() call. Used with the query_groups parameter.

    Example::

        QueryGroup(queries=["GDP per capita", "inflation rate"], country="Kenya")
    """

    queries: list[str] = Field(
        ...,
        description="Search terms for this group (e.g., ['GDP per capita', 'inflation rate']). "
        "At least one non-empty string required.",
        min_length=1,
    )
    country: str | None = Field(
        None,
        description="Country name or 3-letter code for this group (e.g., 'Kenya' or 'KEN'). "
        "If None, no country filtering is applied to indicators in this group.",
    )


class QueryGroupResult(BaseModel):
    """Result group for a single query within a multi-query search.

    Only returned when result_layout='by_query'.
    """

    query: str = Field(..., description="The search query that produced these results")
    country_code: str | None = Field(
        None,
        description="Resolved country code for this query group (e.g., 'KEN'). "
        "Set when query_groups is used and a country was specified for this group.",
    )
    indicators: list[EnrichedIndicator] = Field(
        default_factory=list, description="Indicators found for this query"
    )
    count: int = Field(default=0, description="Number of indicators in this group")
    error: str | None = Field(
        None, description="Error message if this sub-query failed"
    )


class MultiQuerySearchResponse(BaseModel):
    """Response for multi-query search (when queries parameter is used).

    result_layout='merged': indicators contains a flat, deduped list.
    result_layout='by_query': results contains one group per input query.
    dedupe=True with by_query means cross-group dedup — first group to
    claim an indicator keeps it; later groups skip it.
    """

    indicators: list[EnrichedIndicator] = Field(
        default_factory=list,
        description="Flat indicator list. Populated when result_layout='merged'; "
        "empty when 'by_query' (see results field instead).",
    )
    results: list[QueryGroupResult] | None = Field(
        None,
        description="Per-query result groups (result_layout='by_query')",
    )
    result_layout: Literal["merged", "by_query"] = Field(
        "merged", description="Layout mode used: 'merged' or 'by_query'"
    )
    queries: list[str] = Field(
        default_factory=list, description="The input query strings"
    )
    required_country: str | None = Field(
        None,
        description="Resolved country code(s) used for all sub-queries. "
        "Semicolon-separated for multiple countries (e.g. 'KEN;GHA').",
    )
    country_names: dict[str, str] | None = Field(
        None, description="Resolved names of requested countries"
    )
    total_candidates: int = Field(
        0,
        description="Total indicators found before dedup (merged) or across all groups (by_query)",
    )
    deduplicated_count: int | None = Field(
        None, description="Number of duplicates removed (merged layout only)"
    )
    error: str | None = Field(
        None, description="Top-level error if the entire multi-query operation failed"
    )


class MetadataRequest(BaseModel):
    """Request model for data 360 metadata retrieval."""

    indicator_id: str = Field(
        ..., description="Series ID (idno) to retrieve metadata for"
    )
    database_id: str = Field(
        ..., description="Database identifier (e.g., IPC_IPC, WB_GS)"
    )

    @model_validator(mode="after")
    def validate_ids(self) -> "MetadataRequest":
        """Validate database_id and indicator_id logic."""
        if not self.database_id or not self.database_id.strip():
            raise ValueError("database_id cannot be empty or whitespace-only.")
        if not self.indicator_id or not self.indicator_id.strip():
            raise ValueError("indicator_id cannot be empty or whitespace-only.")
        if self.database_id == self.indicator_id:
            raise ValueError(
                f"Invalid database_id: '{self.database_id}'. It matches indicator_id."
            )

        return self


class MetadataResponse(BaseModel):
    """Response model for metadata retrieval."""

    indicator_metadata: dict[str, Any] | None = Field(
        default=None, description="Metadata information for the requested series"
    )
    disaggregation_options: list[dict[str, Any]] = Field(
        default_factory=list,
        description="Available disaggregation options for the indicator",
    )
    error: str | None = Field(
        default=None, description="Error message if metadata retrieval failed"
    )


class IndicatorDataRequest(BaseModel):
    """Request model for retrieving indicator data from Data360 API."""

    database_id: str = Field(
        ..., description="Unique identifier for the database (e.g., WB_GS)"
    )
    indicator_id: str = Field(
        ..., description="Indicator ID (e.g., WB_GS_NY_GDP_PCAP_KD)"
    )
    disaggregation_filters: dict[str, str | None] | None = Field(
        default=None,
        description=(
            "Per-dimension filters: each value is a string or null (never a JSON array). "
            "Example: {'REF_AREA': 'KEN', 'UNIT_MEASURE': 'KD'}. "
            "Multiple areas: comma-separated ISO codes in REF_AREA (e.g. 'KEN,TZA'); "
            "semicolons in REF_AREA are accepted and normalized to commas. "
            "Use null for a dimension to request all values of that dimension."
        ),
    )

    @model_validator(mode="after")
    def validate_ids(self) -> "IndicatorDataRequest":
        """Validate database_id and indicator_id logic."""
        if not self.database_id or not self.database_id.strip():
            raise ValueError("database_id cannot be empty or whitespace-only.")
        if not self.indicator_id or not self.indicator_id.strip():
            raise ValueError("indicator_id cannot be empty or whitespace-only.")
        # 1. Check if database_id is suspicious (same as indicator_id)
        if self.database_id == self.indicator_id:
            raise ValueError(
                f"Invalid database_id: '{self.database_id}'. It matches indicator_id. "
                "Database ID should be the short dataset code (e.g., 'WB_GS', 'WB_HCP')."
            )

        return self


class IndicatorDataResponse(MCPPagedResponse):
    """Response model for indicator data retrieval."""

    data: list[dict[str, Any]] | None = Field(
        default=None, description="List of indicator data points"
    )
    metadata: dict[str, Any] | None = Field(
        default=None,
        description="Basic metadata for the indicator (e.g., name, definition)",
    )
    error: str | None = Field(
        default=None, description="Error message if data retrieval failed"
    )
    failed_validation: list[str] | None = Field(
        default=None, description="List of filter validation errors"
    )


# ---------------------------------------------------------------------------
# Data Aggregation Tool Models (Tier 1 — full implementation)
# ---------------------------------------------------------------------------


class GroupSummary(BaseModel):
    """Summary statistics for a single group in a summarize_data response."""

    group_key: dict[str, str] = Field(
        ...,
        description="Dimension values defining this group "
        '(e.g. {"ref_area": "KEN"} or {"ref_area": "KEN", "sex": "F"})',
    )
    count: int = Field(..., description="Number of observations in this group")
    latest_value: float | None = Field(None, description="Most recent obs_value")
    latest_year: str | None = Field(None, description="Year of latest_value")
    earliest_value: float | None = Field(None, description="Oldest obs_value in range")
    earliest_year: str | None = Field(None, description="Year of earliest_value")
    min: float | None = Field(None, description="Minimum obs_value")
    max: float | None = Field(None, description="Maximum obs_value")
    mean: float | None = Field(None, description="Arithmetic mean of obs_values")
    median: float | None = Field(None, description="Median obs_value")
    total_change: float | None = Field(
        None, description="latest - earliest (absolute change)"
    )
    pct_change: float | None = Field(
        None,
        description="((latest - earliest) / |earliest|) * 100. "
        "None if earliest is zero or missing.",
    )
    trend_direction: str | None = Field(
        None,
        description="'increasing', 'decreasing', 'stable', or 'volatile'. "
        "Based on linear regression slope and R² over the series.",
    )
    time_range: str | None = Field(
        None, description="Actual data range (e.g. '2005-2023')"
    )
    claim_ids: list[str] = Field(
        default_factory=list,
        description="Source claim_ids from underlying raw observations",
    )

    def to_compact(self) -> dict[str, Any]:
        """Return a slimmed dict for LLM context.

        claim_ids are retained here — they are 8-character PCN hashes and the
        UI needs them to render provenance attribution per group. The token cost
        is bounded (one hash per observation per group) and preserves the
        group→claim_ids association that a flat top-level list would lose.
        """
        return {
            "group": self.group_key,
            "n": self.count,
            "latest": {"value": self.latest_value, "year": self.latest_year},
            "earliest": {"value": self.earliest_value, "year": self.earliest_year},
            "range": self.time_range,
            "stats": {
                "min": self.min,
                "max": self.max,
                "mean": self.mean,
                "median": self.median,
            },
            "change": {"abs": self.total_change, "pct": self.pct_change},
            "trend": self.trend_direction,
            "claim_ids": self.claim_ids,
        }


class DataSummaryResponse(BaseModel):
    """Response model for data360_summarize_data."""

    groups: list[GroupSummary] = Field(
        default_factory=list, description="Per-group summary statistics"
    )
    metadata: dict[str, Any] | None = Field(
        None, description="Indicator metadata (name, definition, database_name)"
    )
    unit_measure: str | None = Field(
        None, description="Unit of measurement for interpreting values"
    )
    error: str | None = Field(
        None, description="Error message if request failed; otherwise None"
    )
    ambiguous_dimensions: list[str] | None = Field(
        None,
        description=(
            "Disaggregation dimensions present in the data with more than one distinct "
            "value that are NOT included in group_by. When non-empty, the per-group "
            "time-series statistics may be computed over mixed disaggregation values "
            "(e.g. SEX=M, F, and _T all collapsed into one group), making trend and "
            "summary stats unreliable. To fix: either add these dimensions to group_by "
            "(e.g. group_by=['ref_area', 'sex']) or pass disaggregation_filters to pin "
            "each dimension to a single value (e.g. {'SEX': '_T'})."
        ),
    )

    def to_compact(self) -> dict[str, Any]:
        """Return a slimmed dict for LLM context.

        claim_ids are excluded from each GroupSummary entry — they are PCN
        hashes retained in the full model for provenance traceability.
        """
        return {
            "indicator": self.metadata.get("name") if self.metadata else None,
            "unit": self.unit_measure,
            "ambiguous_dimensions": self.ambiguous_dimensions,
            "groups": [g.to_compact() for g in self.groups],
            "error": self.error,
        }


class RankedCountry(BaseModel):
    """A single country entry in a ranking result."""

    rank: int = Field(..., description="Ordinal rank (ties share the same rank)")
    ref_area: str = Field(..., description="Country/region code (e.g. 'KEN')")
    country_name: str | None = Field(None, description="Human-readable country name")
    obs_value: float = Field(..., description="The indicator value for ranking year")
    percentile: float | None = Field(
        None,
        description="Percentile position (0-100) within the ranked set",
    )
    claim_id: str | None = Field(
        None, description="Claim ID from the source observation"
    )

    def to_compact(self) -> dict[str, Any]:
        """Return a slimmed dict for LLM context.

        claim_id is retained — it is the PCN hash for this observation and the
        UI needs it to render per-entry provenance attribution. Only percentile
        is dropped; it is derivable from rank order and adds no LLM value.
        """
        return {
            "rank": self.rank,
            "code": self.ref_area,
            "country": self.country_name or self.ref_area,
            "value": self.obs_value,
            "claim_id": self.claim_id,
        }


class ExcludedCountry(BaseModel):
    """A country excluded from ranking due to missing data."""

    ref_area: str = Field(..., description="Country/region code")
    country_name: str | None = Field(None, description="Human-readable country name")
    reason: str = Field(..., description="Why the country was excluded")


class RankingResponse(BaseModel):
    """Response model for data360_rank_countries."""

    year: str | None = Field(None, description="The year used for ranking")
    year_selection_note: str | None = Field(
        None,
        description="Explains how the ranking year was chosen. "
        "E.g. 'Latest year with broadest coverage (2022, 18/20 countries)' "
        "or 'Most recent year (2023, 12/20 countries)'.",
    )
    order: str = Field(
        "desc", description="'desc' (highest first) or 'asc' (lowest first)"
    )
    total_with_data: int = Field(0, description="Number of countries that had data")
    total_requested: int = Field(0, description="Number of countries attempted")
    universe: str | None = Field(
        None,
        description=(
            "'explicit' when country_group or country_codes was used; "
            "'all_member_economies' when ranking used full geographic fetch with "
            "member-economy row filtering."
        ),
    )
    universe_size: int | None = Field(
        None,
        description=(
            "For explicit scope: same as total_requested. For all_member_economies: "
            "count of known FMR leaf economies in the ranking universe."
        ),
    )
    rankings: list[RankedCountry] = Field(
        default_factory=list, description="Ranked list of countries"
    )
    excluded: list[ExcludedCountry] = Field(
        default_factory=list, description="Countries with no data for ranking year"
    )
    metadata: dict[str, Any] | None = Field(None, description="Indicator metadata")
    unit_measure: str | None = Field(None, description="Unit of measurement")
    error: str | None = Field(None, description="Error message if request failed")

    def to_compact(self) -> dict[str, Any]:
        """Return a slimmed dict for LLM context.

        Key reductions vs. the full model:
        - rankings: claim_id and percentile dropped from each entry (PCN hash
          retained in the full RankedCountry model).
        - excluded: capped at 5 sample entries; full count is in excluded_count.
          This prevents 30+ excluded entries from flooding the context when
          ranking a large group like SSF (48 countries).
        """
        return {
            "year": self.year,
            "year_selection_note": self.year_selection_note,
            "order": self.order,
            "counts": {
                "with_data": self.total_with_data,
                "requested": self.total_requested,
            },
            "unit": self.unit_measure,
            "indicator": self.metadata.get("name") if self.metadata else None,
            "rankings": [r.to_compact() for r in self.rankings],
            "excluded_count": len(self.excluded),
            "excluded_sample": [
                {"code": e.ref_area, "name": e.country_name}
                for e in self.excluded[:5]
            ],
            "error": self.error,
        }


class ComparisonSnapshot(BaseModel):
    """Single-year comparison snapshot across countries."""

    year: str = Field(..., description="The comparison year")
    year_selection_note: str | None = Field(
        None,
        description="Explains how the comparison year was chosen. "
        "E.g. 'User-specified year: 2022' or 'Latest year with data for all compared countries: 2023'.",
    )
    rankings: list[RankedCountry] = Field(
        default_factory=list,
        description="Countries sorted by obs_value with rank and gap_to_leader",
    )
    spread: dict[str, float | None] = Field(
        default_factory=dict,
        description="Spread statistics: min, max, range, coefficient_of_variation",
    )

    def to_compact(self) -> dict[str, Any]:
        """Return a slimmed dict for LLM context.

        Delegates to RankedCountry.to_compact() for each ranked entry, which
        retains claim_id (PCN hash) and drops percentile. claim_id is preserved
        here so the UI can render per-country provenance attribution in the
        snapshot table.
        """
        return {
            "year": self.year,
            "year_selection_note": self.year_selection_note,
            "rankings": [r.to_compact() for r in self.rankings],
            "spread": self.spread,
        }


class ComparisonTimeSeries(BaseModel):
    """Time-series comparison across countries."""

    aligned_years: list[str] = Field(
        default_factory=list,
        description="Years where ALL compared countries have data",
    )
    series: dict[str, list[dict[str, Any]]] = Field(
        default_factory=dict,
        description="Per-country time series: {ref_area: [{time_period, obs_value, claim_id}]}",
    )
    convergence: str | None = Field(
        None,
        description="'converging', 'diverging', or 'parallel'. "
        "Based on coefficient of variation trend across aligned years.",
    )
    cagr: dict[str, float | None] = Field(
        default_factory=dict,
        description="Compound annual growth rate per country over aligned period",
    )

    def to_compact(self) -> dict[str, Any]:
        """Return a slimmed dict for LLM context.

        The per-year ``series`` dict is retained but restructured: each data
        point is encoded as a positional array ``[time_period, obs_value, claim_id]``
        instead of a named dict. This reduces per-point overhead from ~55 chars
        to ~24 chars (~56% reduction) while preserving the year→value→PCN
        association the UI needs for provenance attribution.

        A ``series_schema`` field documents the array positions so the UI
        decoder does not need to hard-code positional assumptions.

        ``aligned_years`` list is replaced by ``year_range`` + ``n_aligned_years``
        since the LLM only needs to know the span, not the individual years.
        """
        year_range = (
            f"{self.aligned_years[0]}-{self.aligned_years[-1]}"
            if self.aligned_years
            else None
        )
        compact_series = {
            country: [
                [pt["time_period"], pt["obs_value"], pt.get("claim_id")]
                for pt in points
            ]
            for country, points in self.series.items()
        }
        return {
            "year_range": year_range,
            "n_aligned_years": len(self.aligned_years),
            "convergence": self.convergence,
            "cagr": self.cagr,
            "series_schema": ["time_period", "obs_value", "claim_id"],
            "series": compact_series,
        }


class CountryComparisonResponse(BaseModel):
    """Response model for data360_compare_countries."""

    snapshot: ComparisonSnapshot | None = Field(
        None, description="Single-year ranked comparison"
    )
    time_series: ComparisonTimeSeries | None = Field(
        None,
        description="Aligned time-series comparison (when include_time_series=True)",
    )
    metadata: dict[str, Any] | None = Field(None, description="Indicator metadata")
    unit_measure: str | None = Field(None, description="Unit of measurement")
    error: str | None = Field(None, description="Error message if request failed")
    country_names: dict[str, str] | None = Field(None, description="Resolved names of compared countries")

    def to_compact(self) -> dict[str, Any]:
        """Return a slimmed dict for LLM context.

        Delegates to ComparisonSnapshot.to_compact() and
        ComparisonTimeSeries.to_compact(), which strip claim_ids (PCN hashes)
        and per-year series data respectively.
        """
        return {
            "indicator": self.metadata.get("name") if self.metadata else None,
            "unit": self.unit_measure,
            "snapshot": self.snapshot.to_compact() if self.snapshot else None,
            "time_series": self.time_series.to_compact() if self.time_series else None,
            "country_names": self.country_names,
            "error": self.error,
        }


# ---------------------------------------------------------------------------
# Data Aggregation Tool Models (Tier 2 — stubs for future implementation)
# ---------------------------------------------------------------------------


class DerivedDataResponse(BaseModel):
    """Response model for data360_compute_derived (stub — not yet implemented).

    Will contain derived/transformed values (growth rates, CAGR, moving averages,
    index rebasing) computed from raw indicator data.
    """

    computation: str | None = Field(None, description="Computation type applied")
    data: list[dict[str, Any]] = Field(
        default_factory=list, description="Computed values"
    )
    summary: str | None = Field(None, description="Human-readable one-line summary")
    metadata: dict[str, Any] | None = Field(None, description="Indicator metadata")
    unit_measure: str | None = Field(None, description="Original unit")
    derived_unit: str | None = Field(
        None, description="Unit for derived values (e.g. '%' for growth_rate)"
    )
    error: str | None = Field(None, description="Error message if request failed")


class PivotTableResponse(BaseModel):
    """Response model for data360_pivot_table (stub — not yet implemented).

    Will contain a cross-tabulation of multiple indicators and/or countries,
    organized as a structured table with row/column dimensions.
    """

    table: list[dict[str, Any]] = Field(default_factory=list, description="Table rows")
    column_metadata: list[dict[str, Any]] = Field(
        default_factory=list, description="Per-column metadata"
    )
    claim_map: dict[str, str] = Field(
        default_factory=dict,
        description="Mapping of cell keys to source claim_ids",
    )
    missing_cells: list[dict[str, str]] = Field(
        default_factory=list,
        description="Cells with no data: [{row, column, reason}]",
    )
    error: str | None = Field(None, description="Error message if request failed")


class DiagnosticIndicatorSummary(BaseModel):
    """Per-indicator summary within a diagnostic summary response (stub)."""

    indicator_id: str = Field(..., description="Indicator ID")
    database_id: str = Field(..., description="Database ID")
    name: str = Field(..., description="Indicator name")
    latest_value: float | None = Field(None, description="Most recent value")
    latest_year: str | None = Field(None, description="Year of latest value")
    trend_direction: str | None = Field(
        None, description="'increasing', 'decreasing', 'stable', 'volatile'"
    )
    pct_change: float | None = Field(None, description="Percent change over period")
    time_range: str | None = Field(None, description="Actual data range")
    claim_ids: list[str] = Field(default_factory=list, description="Source claim_ids")
    coverage_note: str | None = Field(None, description="Gaps or caveats")


class DiagnosticSummaryResponse(BaseModel):
    """Response model for data360_diagnostic_summary (stub — not yet implemented).

    Will contain a multi-indicator diagnostic summary for a topic and country,
    with per-indicator trend analysis and cross-indicator notes.
    """

    topic: str | None = Field(None, description="Diagnostic category used")
    country_code: str | None = Field(None, description="Resolved country code(s)")
    indicators: list[DiagnosticIndicatorSummary] = Field(
        default_factory=list, description="Per-indicator summaries"
    )
    gaps: list[str] = Field(
        default_factory=list,
        description="Topics searched but no indicator found",
    )
    metadata_sources: list[dict[str, str]] = Field(
        default_factory=list,
        description="List of {database_id, database_name} used",
    )
    error: str | None = Field(None, description="Error message if request failed")


class DiscoveredIndicator(BaseModel):
    """Model for a discovered and validated indicator."""

    indicator_id: str = Field(..., description="Indicator ID")
    database_id: str = Field(..., description="Database identifier")
    name: str = Field(..., description="Indicator name")
    truncated_definition: str = Field(
        ..., description="Short definition (max 100 chars)"
    )
    has_country: bool = Field(
        ..., description="Whether data exists for the requested country"
    )
    country_code: str | None = Field(
        default=None, description="Country code used for validation"
    )
    available_dimensions: list[str] = Field(
        default_factory=list, description="List of available disaggregation dimensions"
    )
    available_frequencies: list[str] = Field(
        default_factory=list, description="List of available frequencies"
    )
    periodicity: str | None = Field(
        default=None, description="Periodicity of the indicator"
    )
    has_required_dimensions: bool = Field(
        default=True, description="Whether the indicator has all required dimensions"
    )
    time_range: dict[str, str | None] | None = Field(
        default=None, description="Start and end years of data availability"
    )
    error: str | None = Field(
        default=None, description="Error message if validation failed"
    )


class DiscoveryResult(BaseModel):
    """Result of indicator discovery process."""

    indicators: list[DiscoveredIndicator] = Field(
        default_factory=list, description="List of discovered and validated indicators"
    )
    error: str | None = Field(
        default=None, description="Error message if discovery failed entirely"
    )


class DatasetSearchRequest(BaseModel):
    """Request model for dataset search queries. Includes V3 special character sanitization."""

    query: str = Field(
        ..., description="Search query string to find relevant datasets"
    )
    limit: int = Field(
        default=10,
        description="Number of results to return (default is 10)",
        ge=1,
        le=50,
    )
    offset: int = Field(default=0, description="Offset of the current page")

    @model_validator(mode="after")
    def validate_query(self) -> "DatasetSearchRequest":
        """Validate search query and sanitize unsafe characters."""
        self.query = sanitize_search_query(self.query)
        return self


class DatasetDescription(BaseModel):
    """Model for dataset description in search results."""

    idno: str = Field(..., description="Dataset identifier")
    name: str = Field(..., description="Dataset name")
    description: str | None = Field(None, description="Dataset description")
    data_classification: str | None = Field(None, description="Data classification (e.g. public)")
    data_last_updated: str | None = Field(None, description="Last updated timestamp")
    economies_count: int | None = Field(None, description="Number of economies covered")
    time_period: dict[str, Any] | None = Field(None, description="Time period range covered")


class DatasetSearchResponse(MCPPagedResponse):
    """Response model for data360 dataset search results."""

    items: list[DatasetDescription] = Field(
        default_factory=list, description="List of search results containing dataset information"
    )
    error: str | None = Field(
        default=None, description="Error message if search failed"
    )