Skip to content

Commit 2ba1c7d

Browse files
aborrusoclaude
andcommitted
feat: add get_territorial_codes tool with DuckDB backend
Lookup REF_AREA codes by level, name, region, province, capoluogo. 9142 entries (comuni, province, regioni, ripartizioni) stored in resources/istat_lookup.duckdb. Includes build script to regenerate from ISTAT sources. 12 unit tests, skill docs updated. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 96a7675 commit 2ba1c7d

10 files changed

Lines changed: 693 additions & 2 deletions

File tree

LOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# Log
2+
3+
## 2026-03-29
4+
5+
- Add `get_territorial_codes` tool (8th tool) with DuckDB backend
6+
- Store territorial data in `resources/istat_lookup.duckdb` (reusable for future lookup tables)
7+
- Include `resources/build_territorial_subdivisions.py` script to rebuild the DB from ISTAT sources
8+
- Add `duckdb` dependency to `pyproject.toml`
9+
- 12 unit tests for territorial codes (level, name, region, province, capoluogo filters)
10+
- Update skill docs with territorial codes workflow note

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ dependencies = [
1616
"tenacity>=8.2.0",
1717
"pydantic>=2.0.0",
1818
"python-dotenv>=1.0.0",
19+
"duckdb>=1.0.0",
1920
]
2021

2122
[project.optional-dependencies]
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
"""Build resources/istat_lookup.duckdb (table territorial_subdivisions) from CL_ITTER107 and ISTAT data.
2+
3+
Sources:
4+
- CL_ITTER107: fetched via mcp__istat__get_codelist_description (cached in cache/cache.db)
5+
- ISTAT municipalities CSV: https://www.istat.it/storage/codici-unita-amministrative/Elenco-comuni-italiani.csv
6+
- ISTAT capoluogo JSON: https://situas-servizi.istat.it/publish/reportspooljson?pfun=61&pdata=01/01/2048
7+
8+
Output columns (table: territorial_subdivisions):
9+
- code: ISTAT territorial code (CL_ITTER107 for all levels; 6-digit numeric for comuni)
10+
- name_it: Italian name
11+
- level: italia | ripartizione | regione | provincia | comune
12+
- nuts_level: 0 | 1 | 2 | 3 | 4
13+
- parent_code: code of the parent territorial unit (NULL for Italia)
14+
- capoluogo_provincia: True if the comune is a provincial/UTS capital (NULL for non-comuni)
15+
- capoluogo_regione: True if the comune is a regional capital (NULL for non-comuni)
16+
17+
Usage:
18+
python3 resources/build_territorial_subdivisions.py <itter107_json> [comuni_csv]
19+
"""
20+
21+
import csv
22+
import json
23+
import re
24+
import sys
25+
import tempfile
26+
import urllib.request
27+
from pathlib import Path
28+
29+
import duckdb
30+
31+
# NUTS 2021 codes that DON'T follow simple ITH->ITD / ITI->ITE substitution
32+
# Maps NUTS2021 NUTS3 code -> CL_ITTER107 code
33+
NUTS2021_TO_ITTER_OVERRIDE = {
34+
'ITC4C': 'ITC45', # Metro Milano -> Milano
35+
'ITC4D': 'IT108', # Monza e della Brianza
36+
'ITI35': 'IT109', # Fermo (must be before ITI->ITE rule)
37+
'ITF46': 'ITF41', # Foggia
38+
'ITF47': 'ITF42', # Bari / Citta Metropolitana
39+
'ITF48': 'IT110', # Barletta-Andria-Trani
40+
'ITG2D': 'ITG25', # Sassari
41+
'ITG2E': 'ITG26', # Nuoro
42+
'ITG2F': 'ITG27', # Cagliari
43+
'ITG2G': 'ITG28', # Oristano
44+
'ITG2H': 'IT111', # Sud Sardegna
45+
}
46+
47+
# Special IT1XX province codes -> parent NUTS2 region code
48+
IT1XX_PARENTS = {
49+
'IT108': 'ITC4', # Monza e della Brianza -> Lombardia
50+
'IT109': 'ITE3', # Fermo -> Marche
51+
'IT110': 'ITF4', # Barletta-Andria-Trani -> Puglia
52+
'IT111': 'ITG2', # Sud Sardegna -> Sardegna
53+
'IT113': 'ITG2', # Gallura Nord-Est Sardegna -> Sardegna
54+
'IT119': 'ITG2', # Sulcis Iglesiente -> Sardegna
55+
}
56+
57+
COMUNI_CSV_URL = 'https://www.istat.it/storage/codici-unita-amministrative/Elenco-comuni-italiani.csv'
58+
CAPOLUOGO_JSON_URL = 'https://situas-servizi.istat.it/publish/reportspooljson?pfun=61&pdata=01/01/2048'
59+
60+
OUTPUT_PATH = Path(__file__).parent / 'istat_lookup.duckdb'
61+
62+
63+
def nuts2021_to_itter(code: str) -> str:
64+
"""Convert NUTS 2021 NUTS3 code to CL_ITTER107 equivalent."""
65+
if code in NUTS2021_TO_ITTER_OVERRIDE:
66+
return NUTS2021_TO_ITTER_OVERRIDE[code]
67+
if code.startswith('ITH'):
68+
return 'ITD' + code[3:]
69+
if code.startswith('ITI'):
70+
return 'ITE' + code[3:]
71+
return code
72+
73+
74+
def load_itter107(path: str) -> dict[str, str]:
75+
"""Load CL_ITTER107 codelist from a JSON file (MCP tool result format)."""
76+
with open(path, encoding='utf-8') as f:
77+
data = json.load(f)
78+
text = data[0]['text']
79+
entries = re.findall(r'\{[^{}]+\}', text)
80+
codes = {}
81+
for e in entries:
82+
cm = re.search(r'"code":\s*"([^"]+)"', e)
83+
di = re.search(r'"description_it":\s*"([^"]+)"', e)
84+
if cm and di:
85+
codes[cm.group(1)] = di.group(1)
86+
return codes
87+
88+
89+
def download_comuni_csv() -> str:
90+
"""Download ISTAT municipalities CSV to a temp file, return path."""
91+
tmp = tempfile.NamedTemporaryFile(suffix='_comuni_istat.csv', delete=False)
92+
print(f'Downloading {COMUNI_CSV_URL}...')
93+
urllib.request.urlretrieve(COMUNI_CSV_URL, tmp.name)
94+
utf8_path = tmp.name + '_utf8.csv'
95+
with open(tmp.name, encoding='latin1') as f:
96+
content = f.read()
97+
with open(utf8_path, 'w', encoding='utf-8') as f:
98+
f.write(content)
99+
return utf8_path
100+
101+
102+
def download_capoluogo_json() -> dict[str, tuple[bool, bool]]:
103+
"""Download capoluogo flags from ISTAT JSON endpoint.
104+
105+
Returns:
106+
Dict mapping 6-digit comune code -> (capoluogo_provincia, capoluogo_regione)
107+
"""
108+
print(f'Downloading {CAPOLUOGO_JSON_URL}...')
109+
with urllib.request.urlopen(CAPOLUOGO_JSON_URL) as resp:
110+
data = json.loads(resp.read().decode('utf-8'))
111+
result = {}
112+
for rec in data.get('resultset', []):
113+
code = str(rec.get('PRO_COM_T', '')).strip().zfill(6)
114+
if code:
115+
result[code] = (bool(rec.get('CC_UTS', 0)), bool(rec.get('CC_REG', 0)))
116+
print(f' Loaded capoluogo flags for {len(result)} comuni')
117+
return result
118+
119+
120+
def build_comuni_mappings(csv_path: str) -> tuple[dict, dict]:
121+
"""Build comune_code->ITTER_nuts3 and storico->ITTER_nuts3 mappings."""
122+
comune_to_nuts3 = {}
123+
storico_to_nuts3 = {}
124+
with open(csv_path, encoding='utf-8') as f:
125+
reader = csv.reader(f, delimiter=';')
126+
next(reader)
127+
for row in reader:
128+
cod6 = row[15].strip().zfill(6)
129+
storico = row[2].strip().zfill(3)
130+
nuts3_2021 = row[22].strip()
131+
if nuts3_2021:
132+
itter = nuts2021_to_itter(nuts3_2021)
133+
if cod6:
134+
comune_to_nuts3[cod6] = itter
135+
if storico:
136+
storico_to_nuts3[storico] = itter
137+
return comune_to_nuts3, storico_to_nuts3
138+
139+
140+
def build_duckdb(codes: dict, comune_to_nuts3: dict, storico_to_nuts3: dict, capoluogo: dict[str, tuple[bool, bool]] | None = None) -> None:
141+
"""Build and write the DuckDB database."""
142+
# Province: standard NUTS3 + letter-suffix + IT1XX
143+
nuts3_codes = {k: v for k, v in codes.items() if re.match(r'^IT[A-Z][0-9]{2}$', k)}
144+
letter_suffix = {k: v for k, v in codes.items() if re.match(r'^IT[A-Z][0-9][A-Z]$', k)}
145+
it1xx = {k: v for k, v in codes.items() if re.match(r'^IT1[0-9]{2}$', k)}
146+
all_province_codes = {**nuts3_codes, **letter_suffix, **it1xx}
147+
148+
rows = []
149+
150+
rows.append(('IT', 'Italia', 'italia', 0, None, None, None))
151+
152+
for k, v in {'ITC': 'Nord-ovest', 'ITD': 'Nord-est', 'ITE': 'Centro', 'ITF': 'Sud', 'ITG': 'Isole'}.items():
153+
rows.append((k, v, 'ripartizione', 1, 'IT', None, None))
154+
155+
for k, v in codes.items():
156+
if re.match(r'^IT[A-Z][0-9]$', k):
157+
rows.append((k, v, 'regione', 2, k[:3], None, None))
158+
159+
for k, v in all_province_codes.items():
160+
parent = IT1XX_PARENTS.get(k, k[:4])
161+
rows.append((k, v, 'provincia', 3, parent, None, None))
162+
163+
no_parent = 0
164+
for k, v in codes.items():
165+
if re.match(r'^[0-9]{6}$', k):
166+
parent = comune_to_nuts3.get(k) or storico_to_nuts3.get(k[:3])
167+
if not parent:
168+
no_parent += 1
169+
cap_prov, cap_reg = capoluogo.get(k, (False, False)) if capoluogo else (False, False)
170+
rows.append((k, v, 'comune', 4, parent, cap_prov, cap_reg))
171+
172+
# Remove existing db if present
173+
if OUTPUT_PATH.exists():
174+
OUTPUT_PATH.unlink()
175+
176+
conn = duckdb.connect(str(OUTPUT_PATH))
177+
conn.execute('''
178+
CREATE TABLE territorial_subdivisions (
179+
code VARCHAR NOT NULL,
180+
name_it VARCHAR NOT NULL,
181+
level VARCHAR NOT NULL,
182+
nuts_level TINYINT,
183+
parent_code VARCHAR,
184+
capoluogo_provincia BOOLEAN,
185+
capoluogo_regione BOOLEAN
186+
)
187+
''')
188+
conn.executemany(
189+
'INSERT INTO territorial_subdivisions VALUES (?, ?, ?, ?, ?, ?, ?)',
190+
rows,
191+
)
192+
conn.execute('CREATE INDEX idx_level ON territorial_subdivisions(level)')
193+
conn.execute('CREATE INDEX idx_parent ON territorial_subdivisions(parent_code)')
194+
conn.execute('CREATE INDEX idx_code ON territorial_subdivisions(code)')
195+
conn.close()
196+
197+
size = OUTPUT_PATH.stat().st_size
198+
print(f'Written: {OUTPUT_PATH}')
199+
print(f'Rows: {len(rows)} | Size: {size:,} bytes ({size // 1024} KB)')
200+
print(f'Province: {len(all_province_codes)} | Comuni senza parent: {no_parent}')
201+
202+
203+
if __name__ == '__main__':
204+
itter107_path = sys.argv[1] if len(sys.argv) > 1 else None
205+
comuni_csv_path = sys.argv[2] if len(sys.argv) > 2 else None
206+
207+
if not itter107_path:
208+
print('Usage: python3 build_territorial_subdivisions.py <itter107_json> [comuni_csv]')
209+
print(' itter107_json: MCP tool result JSON for CL_ITTER107 codelist')
210+
print(' comuni_csv: ISTAT municipalities CSV (latin1); downloaded if omitted')
211+
sys.exit(1)
212+
213+
print('Loading CL_ITTER107...')
214+
codes = load_itter107(itter107_path)
215+
print(f' Loaded {len(codes)} codes')
216+
217+
if comuni_csv_path:
218+
utf8_path = comuni_csv_path + '_utf8.csv'
219+
with open(comuni_csv_path, encoding='latin1') as f:
220+
content = f.read()
221+
with open(utf8_path, 'w', encoding='utf-8') as f:
222+
f.write(content)
223+
comuni_csv_path = utf8_path
224+
else:
225+
comuni_csv_path = download_comuni_csv()
226+
227+
print('Building comuni mappings...')
228+
comune_to_nuts3, storico_to_nuts3 = build_comuni_mappings(comuni_csv_path)
229+
print(f' comuni: {len(comune_to_nuts3)} | storico: {len(storico_to_nuts3)}')
230+
231+
print('Downloading capoluogo flags...')
232+
capoluogo = download_capoluogo_json()
233+
234+
print('Building DuckDB...')
235+
build_duckdb(codes, comune_to_nuts3, storico_to_nuts3, capoluogo)

resources/istat_lookup.duckdb

1.51 MB
Binary file not shown.

skills/istat-mcp/SKILL.md

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ description: >
66
regional/provincial data, unemployment, population, GDP, agriculture, or any other
77
ISTAT dataset. Guides the discover -> constraints -> data workflow step by step.
88
license: MIT
9-
compatibility: Requires the istat MCP server to be running (provides 7 tools for ISTAT SDMX API access).
9+
compatibility: Requires the istat MCP server to be running (provides 8 tools for ISTAT SDMX API access).
1010
metadata:
1111
author: ondata
1212
version: "1.0"
@@ -34,6 +34,7 @@ Always follow this 3-step workflow:
3434
| 5 | `get_concepts` | Retrieve semantic definitions of SDMX concepts |
3535
| 6 | `get_data` | Retrieve statistical observations |
3636
| 7 | `get_cache_diagnostics` | Debug tool to inspect cache status |
37+
| 8 | `get_territorial_codes` | Lookup REF_AREA codes by level, name, region, province, or capoluogo |
3738

3839
## Detailed Workflow
3940

@@ -265,6 +266,33 @@ Analyze employment in Italian manufacturing sectors from 2020 to 2023.
265266

266267
---
267268

269+
## Territorial Codes Lookup
270+
271+
The `get_territorial_codes` tool resolves place names and territorial levels to ISTAT REF_AREA codes. Use it when you need to build dimension filters for `REF_AREA`.
272+
273+
**Parameters:**
274+
- `level`: one of `italia`, `ripartizione`, `regione`, `provincia`, `comune`
275+
- `name`: substring search (case-insensitive) across all levels
276+
- `region`: filter comuni by region name or code
277+
- `province`: filter comuni by province name or code
278+
- `capoluogo`: if `true`, return only capoluoghi di provincia
279+
280+
**Examples:**
281+
282+
```json
283+
{"level": "regione"}
284+
{"name": "Milano"}
285+
{"level": "comune", "region": "Lombardia", "capoluogo": true}
286+
```
287+
288+
The data is stored in a local DuckDB database (`resources/istat_lookup.duckdb`). To rebuild it after ISTAT updates, run:
289+
290+
```bash
291+
python3 resources/build_territorial_subdivisions.py <itter107_json> [comuni_csv]
292+
```
293+
294+
---
295+
268296
## API Reference
269297

270298
- **Base URL**: `https://esploradati.istat.it/SDMXWS/rest`

src/istat_mcp_server/server.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
handle_get_constraints,
2323
handle_get_data,
2424
handle_get_structure,
25+
handle_get_territorial_codes,
2526
)
2627
from .utils.tool_helpers import configure_cache_ttls
2728
from .utils.logging import setup_logging
@@ -222,6 +223,36 @@ async def list_tools() -> list[Tool]:
222223
'properties': {},
223224
},
224225
),
226+
Tool(
227+
name='get_territorial_codes',
228+
description='Get ISTAT REF_AREA territorial codes by level (italia, ripartizione, regione, provincia, comune) or place name search. Supports filtering by region, province, and capoluogo status. Contains the full territorial hierarchy (9142 entries) with parent-child relationships. Use cases: find REF_AREA codes for get_data queries (e.g. all comuni capoluogo for accident statistics, all provinces in Southern regions for olive oil production, all comuni in a specific province for demographic analysis).',
229+
inputSchema={
230+
'type': 'object',
231+
'properties': {
232+
'level': {
233+
'type': 'string',
234+
'description': "Territorial level: 'italia', 'ripartizione', 'regione', 'provincia', 'comune'",
235+
},
236+
'name': {
237+
'type': 'string',
238+
'description': 'Place name to search (substring, case-insensitive)',
239+
},
240+
'region': {
241+
'type': 'string',
242+
'description': "Filter by region name or code (e.g. 'Lombardia', 'ITC4')",
243+
},
244+
'province': {
245+
'type': 'string',
246+
'description': "Filter by province name or code (e.g. 'Milano', 'ITC45')",
247+
},
248+
'capoluogo': {
249+
'type': 'boolean',
250+
'description': 'If true, return only comuni that are capoluogo di provincia',
251+
'default': False,
252+
},
253+
},
254+
},
255+
),
225256
]
226257

227258
@server.call_tool()
@@ -254,6 +285,8 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> list[Any]:
254285
result = await handle_get_data(arguments, cache_manager, api_client, blacklist)
255286
elif name == 'get_cache_diagnostics':
256287
result = await get_cache_diagnostics_handler()
288+
elif name == 'get_territorial_codes':
289+
result = await handle_get_territorial_codes(arguments)
257290
else:
258291
raise ValueError(f'Unknown tool: {name}')
259292

@@ -278,5 +311,5 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> list[Any]:
278311
logger.info('=' * 80)
279312
raise
280313

281-
logger.info('MCP server configured with 7 tools')
314+
logger.info('MCP server configured with 8 tools')
282315
return server

src/istat_mcp_server/tools/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from .get_constraints import handle_get_constraints
88
from .get_data import handle_get_data
99
from .get_structure import handle_get_structure
10+
from .get_territorial_codes import handle_get_territorial_codes
1011

1112
__all__ = [
1213
'handle_discover_dataflows',
@@ -16,4 +17,5 @@
1617
'handle_get_data',
1718
'handle_get_constraints',
1819
'get_cache_diagnostics_handler',
20+
'handle_get_territorial_codes',
1921
]

0 commit comments

Comments
 (0)