- New provider
istat_hvd(#98): ISTAT's High Value Datasets endpointesploradati.istat.it/HVD/rest, 17 consolidated dataflows with IDs of their own. Flags from probes, not copied fromistat:constraintsoff (contentconstraint404,availableconstraint500),tree/last_n/filteredgeton, no annotations. Answered whileSDMXWSdata calls failed. rate_limit_groupinportals.json: providers in one group share timestamp and lock files, soistatandistat_hvd(same host, one per-IP limit) are paced together. Cache directories stay per provider. Inside a group there is one lock (nodata_rate_limitsplit) and every member waits the strictest interval of the group. Names are letters, digits,_,.,-; anything path-like is dropped with a warning.guideno longer crashes when constraints are unavailable or time out (seen onistathidden dataflows and on everyistat_hvddataflow): the codes proposed by the AI pass without validation, as for any other constraints failure.
- ISTAT
getremoved from the latency monitor: it never succeeded since the monitor started on 2026-05-09 (514 runs: 322 errors 404/500, 192 timeouts at 300s), so it measured nothing. ISTATinfoand both Eurostat calls stay.
- User-level provider file (#94): a
portals.jsonof your own is merged over the bundled one, read once at import from$OPENSDMX_PORTALS, else$XDG_CONFIG_HOME/opensdmx/portals.json, else~/.config/opensdmx/portals.json. A new key adds a provider (usable by name or alias, listed byopensdmx providers); an existing key is merged field by field, so{"istat": {"rate_limit": 30}}keeps ISTAT'sbase_url, aliases and quirks. - A broken file does not break the library: unreadable or invalid JSON, non-list
aliases, non-numericrate_limit/data_rate_limitand entries withoutbase_urlare reported on stderr (portals: ...) and skipped; the bundled providers are used unchanged. - Provider name wins over an alias of the same spelling, in the CLI and in saved queries; an alias that shadows another provider is reported when the file loads.
categories.supported_providers()now readsPROVIDERS, so user overrides count there too. - Tests pin the user file (
tests/conftest.py), so a developer's own config cannot change the suite. Skill updated: user-declared providers show?capabilities and are probed, overrides keep the bundled ones. - ESMS metadata link (#95), details below. Skill updated: relay the
Metadata:link and cite it in the README template.
- The link is published, but not where the catalogue looks: Eurostat carries the ESMS report as an
ESMS_HTMLannotation with anAnnotationURL, only atdetail=full. The catalogue is fetched atdetail=allstubs(portals.json), which strips annotations altogether (checked live:TOUR_OCC_ARManswers 1 KB with 0 annotations at allstubs, 4 KB with the two ESMS annotations at full). infonow reads it: a provider that declaresdataflow_metadata_annotationpays one targeteddataflow/{agency}/{id}?detail=full&references=nonerequest, shown as aMetadata:line (asmetadata_urlin JSON). The request fails fast (30 s, one attempt) and a failure leaves the rest of the record intact, since the line is decoration.- Why a key of its own: ISTAT already declares
metadata_urlinannotations, where it is the offline description harvester's channel (docs/descriptions.md), and it is the provider with the 15 s data rate limit. Keyinginfoon that name would give one annotation a second consumer with a live network cost on everyinfo;dataflow_metadata_annotationis theinfochannel, and a provider that does not declare it is never fetched. - Checked live:
info TOUR_OCC_ARMshowshttps://ec.europa.eu/eurostat/cache/metadata/en/tour_occ_esms.htm(the page the issue names), 0.7 s warm;--explainlists the new request and stops.
- Hub answers with empty dimensions: on
150_875_DF_DCCV_OCCUPATIMENS1_2the hub bulk endpoint lists all 9 dims butPOSIZ_PROFandPERM_TEMP_EMPLOYEESwithvalues: [], though the data holds9for both. The parser dropped them, so the coverage check failed, 7 per-dim calls ran (~0.5 s each) and the log said "hub returned no values ... aborting hub discovery". - Now: bulk keeps empty dims, no per-dim calls;
get_available_valuesruns the REST chain (moved into_get_available_values_rest()) for the complete set, and if it times out, fails (HTTP 500) or returns nothing, returns the dims the hub answered, never cached. Before, both cases gave nothing. - Checked live: REST answers complete (9 dims) in ~31 s against a 30 s
constraint_fallback_timeoutper call, which explains the intermittent timeout reported; with the timeout forced to 3 s,constraintsshows 7 dims, 2missing, cache untouched.
- Reported "latest edition missing" was a sort error:
geton150_875_DF_DCCV_OCCUPATIMENS1_2withEDITIONempty returns all 57 editions,2026M10G1(data to 2026-08) included. Edition codes do not sort as text:2026M9G1sorts after2026M10G1. No code change. references/istat-flow.md: new section on editions - pick the latest by the release date parsed from the code, pin--EDITIONin the final query (values are revised between editions). One-line pointer inSKILL.md. SQL snippet checked on the downloaded data.
- No more
Provider ''orprovider 'unknown': a provider set withset_provider(url)has noname, so four messages showed an empty string,unknownorProvider(theunsupported_paramswarning, dataflow not found, availableconstraint timeout, first constraints call). A newprovider_label()inbase.pyreturns the name, or the base URL when there is none. Built-in providers unchanged: all declarename. - Checked live on the branch with the ECB URL as a custom provider: the warning reads
Provider 'https://data-api.ecb.europa.eu/service' does not support: lastNObservations, and a missing dataflow names the same URL.
unsupported_paramsno longer needsdata_accept: the drop and its warning ran only inside thedata_acceptbranch ofsdmx_request_csv()(World Bank), so a custom provider set withset_provider(url, unsupported_params=[...])still sent the parameter. The filter now runs before the branch. Built-in providers unchanged: onlyworldbankdeclares the field, and it hasdata_accept.- README and
set_provider()docstring: the custom-provider example usedconstraints_supported=False, which is only a capability flag shown byopensdmx providersand changes no request. The example now usesuser_agentandunsupported_params, and the README says whatconstraints_supporteddoes.
- Custom endpoints get the full provider config (#90 by @paoValle, part 1 of #39):
set_provider(url, ...)takes anyportals.jsonfield as a keyword argument (constraints_supported,user_agent,data_format_param,unsupported_params, ...), merged over the defaults. Named arguments (base_url,agency_id,rate_limit,language) still win. - Warnings instead of silent drops: a field not in
_DEFAULTS, not used by any portal, and notconstraint_timeout/constraint_max_retriesis stored but logged as unknown; extra fields passed to a preset are ignored and logged. - Python API only: the CLI (
--provider,resolve_provider()) cannot pass these fields yet. The user-levelportals.jsonoverride (part 2 of #39) is still open.
2026-10-16 - v0.27.1 - fix: unicef declares no category tree; provider notes for bfs, ilo, unicef, derzhstat
- The
bfsdescription no longer claims economy and prices:searchreturns nothing for unemployment, prices, consumer price, GDP, and the TOPICS tree has no dataflows under Prices or National economy. The description now lists the topics that do have dataflows (population 41, mobility and transport 27, crime and criminal justice 26, then employment, housing, tourism, social security, health, education), so an agent does not look for CPI or GDP there. docs/providers.md: thebfsquirks also explain the two constraint keys.- Skill
sdmx-explorernow knows four providers it never listed:bfs,ilo,unicef,derzhstatare in thetreeandsearchlists and in the capability matrix ofreferences/providers.md, each checked live withsearch,constraintsandget --last-n 1. Notes per provider: ILO codes carry the dimension as prefix (SEX_T); UNICEF packs many indicators in one dataflow (CME, 34 inINDICATOR); Derzhstat filters client-side, so period and--last-nare the only server-side narrowing; BFS has no prices or GDP. - Found:
tree -p uniceffails with 404 oncategorisation/UNICEF/ALL/latest, althoughportals.jsondeclaredcategories_supported: true(categoryschemeanswers 200). The flag is nowfalse, soopensdmx providersshows ✗ and the skill sends the agent tosearch.
- New built-in provider
bfs(aliasfso, #88 by @alexanderguentert):https://disseminate.stats.swiss/rest, a .Stat Suite endpoint. Configuration only, no code change:agency_id: "all", defaultAccept: text/csv, categories on. constraintsworks through two keys: BFS answers 500 toavailableconstraint/{df}?references=none(the default) and times out without the key, sobfsdeclaresconstraint_params: {}andconstraint_path_suffix: "/all".lastNObservationsis supported: the first version of the PR declared it unsupported; tested live,--last-n 1returns the latest year.- Checked live:
search,info,tree(schemes INQUIRY and TOPICS),values,get,get --last-nandconstraintsonDF_STATPOP_REGLING_PERMandDF_VOTATION; the author also checkedconstraintsonDF_AHBB_BEITRAGandDF_AREA_IMPV. - Issue #41 closed as overtaken: the provider quirks it named are already declarative keys in
portals.json. Hooks for custom providers move to #39.
opensdmx values <df> <DIM> -p bundesbankno longer returns 404 (#87, closes #85). Codelist paths now go through_codelist_path(), which appliesmetadata_prefixand reads a newcodelist_agencykey fromportals.json(defaultALL). Bundesbank declaresBBK; every other provider keepscodelist/ALL/.... Thedescriptioncolumn ofinfois filled for the same reason.- Checked live: Bundesbank
values BBEE1 BBK_STD_FREQreturns the six frequency codes and--explainshowsmetadata/codelist/BBK/CL_BBK_STD_FREQ; Eurostatvalues UNE_RT_A geounchanged,--explainshowscodelist/ALL/GEO. - Release note: a Bundesbank
descriptioncached empty by the old 404 stays empty untilMETADATA_CACHE_TTLexpires;valueswas never cached on failure. - Follow-up: the Bundesbank config test restores the active provider via
monkeypatchinstead of forcingeurostat.
- «One dataflow that answers the question is the answer — stop there»: no sibling dataflows, extra breakdowns or cross-checks the user did not ask for; other options listed as one-liners at the end.
- «One question, several readings — name them before choosing»: when the dataflow carries more than one reading of the question (annual average vs Dec-on-Dec, seasonally adjusted vs raw), say so before the figure, pick one with a reason, give the other when it comes from the same query.
2026-10-10 - v0.26.1 - fix: constraints <df> <DIM> -o json no longer leaves stdout empty; skill keeps code lists out of the agent context
- Single-dimension
constraintsin json/csv mode now emits{"dimension_id", "n_values": null, "codes": [], "source": "missing", "hint"}when the dimension exists but the constraint endpoint does not expose it. Before, it exited 0 with an empty stdout, sojqread nothing and the case looked like an empty result. Same shape as a missing dimension in summary mode. Table output unchanged. - Errors in json mode keep the Unix contract: exit 1, message on stderr, stdout empty. Checked on Eurostat for a missing dataflow, a missing dimension on
constraintsandvalues, andinfo. No{"error": ...}on stdout: it would change the contract of every command. - Skill: write the JSON to a file, then read a count or a slice with
jqinstead of printing whole code lists into the context, and check the exit code before reading the file. Everyjqexample now runs on the saved file; tested againstPRC_HICP_MANR. Pattern borrowed from ondata/istat_mcp_server#58. The error-check example is anifblock, so a failed fetch keeps exit 1; CSV output of the missing-dim case has its own test (#86).
- Breaking for semantic search users: the Ollama Python client is no longer installed by default (#83, closes #75 for the
ollamapart). It moves to a new[semantic]extra and stays in[guide], sincerun_guidefalls back tosemantic_search. After upgrading,search --semanticandembedfail until the extra is installed:uv tool install "opensdmx[semantic]"orpip install "opensdmx[semantic]". The error names both commands.from opensdmx import semantic_searchstill imports: the client is loaded only when the function is called. - numpy stays a hard dependency, contrary to what #75 proposed: the default keyword ranking (BM25 in
ranking.py) is built on it. - Rich was eating
[semantic]from the install hint. The CLI printed the error through markup, so the hint readpip install "opensdmx". Found on a clean wheel install without the extra; both handlers now escape the message, and twoCliRunnertests check the printed output. - New
--explainon the nine network commands (#84, closes #74): dry run that prints the plan to stderr and stops before the first request. Cache hits print as[cached]with the request they replaced, the first uncached request as[would fetch], and the size probegetsends without--last-n/--first-n/--yesas[would fetch] (size probe). stdout stays empty in every output mode; no chart, no--query-file, no provider data written. Hooked inbase.sdmx_requestand in the hub's own client, no per-provider branching. - Verified with the network pointed at a dead proxy, so no request could leave: cold cache on ISTAT, Eurostat, INPS and OECD exits 0 with only the plan; on a warm cache the cache directory is byte-identical before and after.
siblingsreads the catalogue once instead of twice, and on a catalogue outage no longer retries the load in resolution and in the description table.- Known limit: on a warm
dataflows.parquetwith nocache.db, a dry run creates the emptycache.dbschema, as any command does on first use. - Found during the #84 review and opened separately:
valuesreturns 404 on Bundesbank, because the codelist URL missesmetadata_prefixand theBBKagency (#85).
2026-09-26 - v0.25.0 - feat: read the dataflow <common:Description> and surface provider notes in info
- Origin: a comparison with the Gramscii open-data catalogue (Hugging Face
Gramscii-IT/european-open-data-catalogue, 4,526 ISTAT records). Of everything it collects per ISTAT dataflow, two things were missing on our side: the SDMX<common:Description>and the time coverage of the constraints. Both sit inside responses we already download. Coverage, categories, territorial level and harvested prose are wider on our side. <common:Description>measured on the full ISTAT catalogue (4,910 dataflows): 28 filled, none equal to the name. 27 are release or discontinuity notices ("Da gennaio 2025 i dati degli alloggi in affitto includono anche…", ATECO 2022 reclassification, NIC rebase 2025), 1 is real prose (DF_BULK_CAG2000_1). The per-dataflow endpoint returns the same text as the catalogue. Overlap withDATAFLOW_NOTES: 1 dataflow. Gramscii stores it asdescription; it is a caveat, not a summary.- Read for every provider, no extra call: new catalogue column
df_sdmx_description(null where absent, backfilled null on older caches like the annotation columns).load_datasetexposesnotes=DATAFLOW_NOTESthen the Description, only those present;infoprints them asNote:lines (notesin JSON) andembedfolds the text into the document likedf_notes. - Time coverage probed on all 15 providers, one call each (
tmp/ac_*.xml): three positive shapes —TimeRange(istatavailableconstraint, oecd, abs), Start/End pair in the hubTIME_PERIODcriterion (istat, inps), explicit value list (eurostatcontentconstraint) — and five ways of not answering (200 withoutTIME_PERIOD: bis, imf, unicef, derzhstat; 405 comext; 404 ecb, bundesbank; 400 insee, worldbank; 500 ilo). Todayhub.pydrops theTIME_PERIODcriterion and the bulk parser ignoresTimeRange. Plan intasks/todo-description-timerange.md, phase 2. - ISTAT IP block reproduced during the session: the SDMX host stopped answering at TCP level (connect timeout, while
www.istat.itand Eurostat answered) after the main session and two subagents queried it in the same minutes, each within the 13 s limiter. A router restart (new IP) cleared it. Lesson intasks/lessons.md.
- 548 of the "datasets" Eurostat publishes are saved Data Browser views. Measured on the full catalogue: 548 of 8,152 entries carry a
$DV_suffix, are annotatedDISSEMINATION_OBJECT_TYPE=EXTRACTION/EXTRACTION_TYPE=BOOKMARK, point at their parent's DSD and repeat its title verbatim (547 of 548).Individuals - internet activitiesappeared 6 times in a search. The id pattern is an exact proxy for the annotation across all 8,152 entries — 548$DV_, 548EXTRACTION, no entry in one set and not the other, and no other id contains$. - Effect: ambiguous titles drop from 1,114 (13.7%) to 138 (1.8%), catalogue 8,152 → 7,604. Eurostat's apparent title problem was almost entirely these entries.
- Filtered on read, never before writing the Parquet. The bookmark ids do serve data (
data/LFST_HHEREDCH$DV_1343→ HTTP 200, an 8,136-row sub-cube of a 102 MB parent), and the Data Browser hands them out in links. Dropping them at build time would make them permanently unreachable;resolve_dataflowsearches the unfiltered catalogue when nothing else matches, so an id typed verbatim still resolves in the catalogue.geton one still fails with HTTP 404 until the DSD reference lands (part 3 of the plan): underdetail=allstubsEurostat sends no structure reference, so the DSD is guessed from the dataflow id — true for datasets, false for bookmarks. - The catalogue filter alone was not enough. Eurostat categorises the bookmarks like any dataflow, so
tree, the per-scheme counts andsiblingskept showing 113 of them inpopulalone.load_categoriesnow applies the same filter on read. The pattern is anchored on the$: real dataflows such asGBV_DV_AGEcontainDV_and must survive. - Declared as
catalog_hidden_id_patterninportals.json, read with.get()— the other 15 providers are untouched. - Semantic search no longer truncates its result list.
semantic_searchsliced the topn * 2candidates and only then dropped ids missing from the catalogue, so it returned fewer thannresults when more than half that window was missing. The window was sized for invalid datasets; hiding the bookmarks makes the case reachable, since an index built before this release still carries all 548. It now ranks the full array and stops atnvisible hits — exact instead of heuristic, and free, since theargsortalready ran over every row. - Measurements and the remaining parts (Description, ESMS URL, missing DSD reference) in #54 and
tasks/todo-eurostat-catalogue.md.
-
The default search path was the worst one, measurably.
search unemploymenton Eurostat putUNE_RT_M— the monthly unemployment rate — at position 65 of 146, because the old scorer summed raw token occurrences with no length denominator and weighed every query word the same. A long title beat a relevant one; "di" counted like "disoccupazione". Ranking is now BM25 (src/opensdmx/ranking.py): idf down-weights common terms,bnormalises for document length. The field boosts survive, rescaled to the BM25 unit. -
Measured on two blind gold sets, not one. A second gold set was written for Eurostat with the same protocol as the ISTAT one — 25 needs written with
gold: nullsaved to disk before looking at the catalogue, gold filled in a second pass, one need discarded as undecidable → 24 needs, 48 queries. Eurostat embeddings were built for the run, the first time the semantic arm has been measured on a provider without harvested prose.MRR Eurostat Eurostat enISTAT ISTAT itbefore 0.086 0.172 0.073 0.133 after 0.161 0.320 0.169 0.325 semantic 0.241 0.308 0.327 0.343 -
In the provider's own language the keyword path now matches the semantic one — Eurostat 0.320 vs 0.308, ISTAT 0.325 vs 0.343 — with no model, no server and no index build. Cross-language is untouched and unfixable lexically: Italian queries on Eurostat score 0.000 across 24 queries before and 0.001 after; only the semantic arm scores at all (0.174).
-
The candidate set is chosen exactly as before — AND over every token, OR only when that is empty. BM25 alone would score any document sharing one token, so "tasso di disoccupazione" would report 2405 matches on ISTAT instead of 28: correct at the top, useless as a total. Only the ordering changed, so result counts, pagination and
--allbehave as they did. -
Two things had to be got right or the change would have been silently worse. Tokenisation splits on the underscore:
\wswallows it and would turnUNE_RT_Minto a single term, so no query could ever match an id exactly — a pre-existing test caught this. And a query token with no exact match expands to the terms it prefixes, at half weight, because whole-token BM25 kills prefix search outright (search comun -p istat: 605 results before, 0 with plain BM25). Prefix, not arbitrary substring:comunreaches "comuni" and "comunali", not "incomunicabile". -
The index is built per call over the whole catalogue, never over the candidate set — idf is a property of the corpus, and a candidate set selected by the query contains its terms in every row, which would flatten the signal BM25 exists to provide. Cost measured: 0.14 s on the largest catalogue (Eurostat, 8150 dataflows), 28 ms on OECD, queries 1-18 ms, against a command whose Python import alone is ~1.9 s. No disk index, so nothing to invalidate when the dataflow or category cache refreshes.
-
scoreis now a float on the BM25 scale, formatted to 2 decimals in the table, JSON and CSV._score_resultsis off the search path but kept:eval/retrieval.pyuses it as the baseline arm, and deleting it would make the measurement unreproducible. -
Multi-provider: no provider-specific branch. Length normalisation is relative to each corpus, so the 5x spread in title length across providers (unicef averages 2.6 words, abs 13.7) needs no tuning. Verified live on eurostat, istat, oecd, ecb, ilo, abs — including the 9 of 14 cached providers with no category cache, where the corpus is title + id only.
-
Gate: ruff clean, mypy strict clean on 16 source files, 394 tests green (9 new).
docs/search.mdrecords the measurement, its three declared limitations and a before/after on ten real queries; README andskills/sdmx-explorer/updated.
- The state, measured, not assumed.
search --semanticships and works: Ollama withnomic-embed-text-v2-moe, a hardcoded constant inembed.py, no flag and no fallback backend. The index is built on demand, per provider, byopensdmx embedinto<cache>/<provider>/embeddings.parquet— nothing is precomputed and nothing ships. Latency on ISTAT (4,896 dataflows): ~10 s on the first query of a session while Ollama loads the model, ~1 s after. What differs per provider is the corpus, not the capability:df_prosecomes from adata/descriptions/<provider>.parquetinside the wheel and onlyistat.parquetexists, so elsewhere the embedded document is id + title + category context. - Why the docs had drifted. July and August improved the corpus (category context v0.17.0,
df_keywordsv0.19.0, harvested prose v0.20.0,df_notes) while the engine stayed on Ollama. Each document written along the way described a different intended future in the present tense and none was amended when that future changed. - The self-contained-backend plan is a direction, not an approved plan, and now says so. Its premise is falsified: the 2026-07-16 plan named
google/embeddinggemma-300mwithmultilingual-e5-smallas fallback and fastembed 0.8.0 exposes neither; the plan document is gone; and fastembed had already been tried and reverted on 2026-03-31 with a different model (nomic-embed-text-v1.5-Q) for poor Italian quality.docs/future-ideas.mdnow states which model each result used, so the "reverted" and "validated" entries stop reading as a contradiction. No candidate has been scored against the targetsdocs/search.mdsets (MRR 0.135 to beat BM25, ~0.327 to replace nomic) — issue #77. - Corrections to public claims. README: keyword default 20 → 50; "exact substring match on dataset title" → the scored token match on title, ID and category name; semantic "10 most similar" → 50; "The model is fixed" → the model is fixed within a release, and the index does not record which model built it (#57); the 3.4× figure now names its metric (S@10, 57% vs 17%).
docs/PRD.mdandreferences/istat-flow.mdstill said search matches "descriptions" — the same wordingLOG.mdrecords fixing on 2026-07-16; dataflows expose an SDMXName. --semanticno longer drops flags in silence.--category,--pageand--allwere accepted and ignored — the semantic branch returns before the code that reads them — so a user who restricted a search to a category read results drawn from the whole catalogue with no signal. They now fail with an explicit error.--nand--grepare unaffected. The "cache not found" message now says the index is per-provider and suggests the right--provider. Ollama-unreachable was already reported distinctly, since_check_ollama()runs first.- The skill was routing agents to the weaker arm.
SKILL.mdtold the agent to offer--semanticonly after keyword returned fewer than 3 results, and priced it at "10-30 s". Measured on ISTAT: on naturally-phrased questions the keyword scorer reaches MRR 0.041 against 0.252 for semantic, and on English queries against Italian metadata keyword search finds essentially nothing. Routing is now conditional — where an index exists and the question is in natural language, semantic goes first; otherwise thetree-first default stands. Latency and the top-N figure corrected. - Removed from the repository:
docs/evaluation.md,docs/evaluation-v0.14.0.md,docs/evaluation-v0.2.6.md. Dated audits of superseded versions, kept public by an accident of.gitignoreordering;evaluation.mdstill told readers thatsearch --semanticneeds a Google API key, removed on 2026-04-03. Untracked, not deleted. - Issue #53 (ISTAT descriptions from METADATA_API) closed — shipped in v0.20.0 and refreshed monthly since. Opened #75 (
[semantic]extra), #76 (BM25 onsearch_dataset), #77 (static retriever). - Four review findings on the PR, all valid, all fixed before merge.
--page 1slipped through the new check — the value equals the default, so onlyctx.get_parameter_source()can tell "not given" from "given as 1" (compared viaParameterSource.name, sinceclickis not a declared dependency and a test enforces that). The "no index" hint named the provider only when--providerwas passed on the command line, leavingOPENSDMX_PROVIDERand the default with an unusable message; it now uses_provider_cache_key(), the value that determines the index path. The README's "rebuilding reproduces the same index" was overstated: only the model is fixed within a release, whilebuild_embeddingsre-reads the catalog, category tree and descriptions as they are at that moment. AndSKILL.mdsuggested probing for an index withsearch --semantic, which hits_check_ollama()first — a stopped server and a missing index were indistinguishable to the agent, so the guidance now names both errors and routes back to the tree-first path either way. - Gate: ruff clean, mypy strict clean on 15 files, 385 tests green (6 new, covering the rejected flag combinations including the explicit
--page 1).skills/sdmx-explorer/updated (SKILL.md +references/thematic-tree.md+references/istat-flow.md).
- A provider given as a URL (
--provider https://.../rest) gotActive provider does not expose /categoryschemeeven when it does:categories_supportedis a declared capability of the 15 providers inportals.json, and a custom dict never carries the key, so the falsy default was read as a denial. The gate now fires only when a listed provider declaresfalse; an undeclared provider is probed live and anhttpx.HTTPStatusErroris translated intoCategoriesNotSupportednaming the URL and the status code. - Second cause on the same path:
catalog_agencyfell back toagency_id, empty for a custom URL, producingcategoryscheme//ALL/latest→ 404. A single_catalog_agency()helper falls back to theALLwildcard for both structure calls. The cross-agencydf_idprefixing still compares the configuredcatalog_agency, so no listed provider changes behaviour. - Verified live:
opensdmx tree --provider https://nsidisseminate-stat.nbb.be/rest --depth 1renders the 9 NBB schemes (ECO50 dataflows,INT_ECO53, …).--provider worldbankstill refuses with the provider list, and--provider https://api.worldbank.org/v2/sdmx/restreports the HTTP 403 with its URL. category_context()(used opportunistically bysearchandembed) is cache-only, so no discovery path can now trigger a 1-2 minute build.- Gate: ruff clean, mypy strict clean on 15 files, 379 tests green.
skills/sdmx-explorer/updated (SKILL.md +references/thematic-tree.md).
- Reproduced the cause on
https://nsidisseminate-stat.nbb.be/rest: with the implicitreferences=none,constraints EXRgets HTTP 200 plus a malformed HTML error page; without the parameter it gets 57,735 bytes of valid SDMX-XML. A parse error onavailableconstraintnow triggers a single retry that dropsreferencesand nothing else — no URL-based special cases, and the first request is unchanged. - The retry is limited to
lxml.etree.XMLSyntaxError: no retry on success, oncontentconstraint, or for providers withconstraint_params={}. If the second parse fails too, the flow ends after two calls and keeps the cause in the logs. The CLI no longer invents the claim that the endpoint may be unsupported when it gets an empty result. - Live before/after check with isolated caches: Eurostat
PRC_HICP_MANR, ABSCPI, BISWS_LONG_CPIand IMFIMF.RES,WEOall stay exit 0 with identical dimension counts. NBBEXRgoes from exit 1 to exit 0 and returnsDATA_DOMAIN=27,REF_AREA=1,INDICATOR=945,COUNTERPART_AREA=3,FREQ=4. - Gate: ruff clean on
src/andtests/, mypy strict clean on 15 files, 376 tests green. The historical 404 ofget EXR --REF_AREA BEremains non-reproducible and out of scope.
- fix(cli):
from click.core import ParameterSource(added withOPENSDMX_OUTPUTin PR #70) crashed every fresh install of v0.22.0 withModuleNotFoundError: No module named 'click'. typer 0.27 dropped its click dependency, andclickwas never declared inpyproject.toml— it only ever arrived transitively. The origin of the--outputvalue is now read by comparing the parameter source by name, so both typer's own enum and click's work and neither is imported. - The bug shipped because
uv.lockstill pinned typer 0.24.1, which pulls click in: the whole suite passed against a package no user could install. The lock now tracks typer 0.27.1, so the tests exercise what people actually get, andtests/test_cli.pyno longer importsclick.exceptions.Exiteither — it usestyper.Exit. - Added a guard test asserting
src/opensdmx/cli.pyimports noclick. - Verified on a clean venv with typer 0.27.1 and no click installed: the CLI imports, and an invalid value still names its source (
invalid OPENSDMX_OUTPUT value 'jsn'from the environment,invalid --output value 'jsn'from the flag).
- feat(providers):
opensdmx providersgains acoveragecolumn — the share of a provider's cached dataflow catalog that has a category assigned.categories_supportedonly said whether a tree exists; it never said whether the tree reaches everything, and a dataflow with noCategorisationis invisible totreewhile being perfectly reachable bysearch. OECD sits around 89%, so tree-only exploration there silently misses one dataflow in ten. Contributed by @deepusnath. - Reads only the on-disk
dataflows.parquet/categorisation.parquetof each provider and never fetches:?when nothing is cached yet,-where there is no tree. Categorised ids the catalog no longer lists are ignored — the two caches expire independently and counting them pushed the ratio past 100%. - The table rounds down.
{pct:.0f}printed Eurostat's 99.90% as100%, hiding the ~9 uncategorised dataflows behind the very column meant to reveal them; "almost complete" is the misleading case, not the harmless one. - docs: documented in
README.md,docs/providers.md,skills/sdmx-explorer/SKILL.md(Step 1b, where the tree-vs-search decision is actually made) and theprovidersdocstring, so the three states are explained where they are read. - Known cosmetic limit, not introduced here: below ~100 terminal columns the table is too dense and truncates values (
100%→10…), asagencyandcategoriesalready did onmain. - v0.22.0 ships both of today's features: this column and the
OPENSDMX_OUTPUTenv var below (issue #8, PR #70), which had not been released yet.
- feat(cli):
--outputnow readsOPENSDMX_OUTPUT, so a user who always wants JSON canexport OPENSDMX_OUTPUT=jsoninstead of typing-o jsonon every call — the ask in issue #8. Resolution order is explicit flag > env var >table. The project default is unchanged:tablestays the default for everyone, and flipping it project-wide remains a separate decision (the reporter never answered whether the table output caused real friction). - fix(cli): an invalid value now names the source that supplied it —
invalid OPENSDMX_OUTPUT value 'jsn'when it came from the environment,invalid --output value 'jsn'when it came from the flag. Blaming a flag the user never typed would have been the natural regression here. - docs: env var documented in
README.md,.env.example, the--helpbanner andskills/sdmx-explorer/SKILL.md. - Pre-existing, untouched:
ruff checkreports 4 findings inscripts/(monitor_latency.py,test_istat_endpoints.py) — unrelated to this change.
- Local map in
tasks/wayfinder/istat-territorial-capability-map.md: either a verdict verified against the archive, or an explicitly labelled metadata estimate with no live calls; never an inference about the coverage of a given query. - Verified what already exists: the
Constraints Archiveworkflow refreshes the ISTAT archive daily, and the derived view holds 3,275 rows, 180 of themREF_AREAdataflows whose maximum level iscomune, checked between 2026-07-16 and 2026-08-10. A live check of101_1039took ~38 s over 5,632REF_AREAcodes, so it cannot sit in a synchronous request. - Research on metadata fallbacks: with no archive entry, only an ID matching
_COM_/_COM<digit>or a title containingcom.supports a defensible municipal estimate (26/26 among the verified dataflows). Every other signal staysunknown— never "regional only".
- fix(siblings):
siblings une_rt_mfalliva con "not categorized" mentresiblings UNE_RT_Mfunzionava —siblings_of()filtravadf_ida match esatto, mentreload_dataset()normalizza il case ovunque. Ora il match è case-insensitive, marcatore→compreso, e un ID inesistente dà l'errore standard con exit 1 invece di un exit 0 silenzioso indistinguibile da "esiste ma non è categorizzato". - fix(guide): l'hint stampava
pip install opensdmxsenza[guide]. La stringa nel sorgente era giusta: Rich leggeva le parentesi quadre come markup e scartava il tag senza controparte. Chi seguiva l'hint installava senzaquestionarye tornava allo stesso messaggio, in loop. Escapato in entrambi i call site. - refactor(discovery): nuova
resolve_dataflow()— match sul catalogo dataflow e nient'altro.siblingsla usa perché ha bisogno del solodf_idcanonico;load_dataset()ci si appoggia e aggiunge le dimensioni, così le regole di matching restano in un punto solo. Dai rilievi di review: risolvere viaload_dataset()scaricava la datastructure per dimensioni mai usate, aggiungendo una richiesta a ogni lookup e facendo fallire un dataflow categorizzato quando l'endpoint DSD era giù. - La risoluzione dell'ID in
siblingsè best-effort:ValueError(catalogo caricato, ID davvero assente) mantiene l'errore standard; qualunque altro errore prosegue con l'ID grezzo, perchésiblings_oftollera di proposito un catalogo dataflow irraggiungibile e degrada a descrizioni vuote. Anche questo da un rilievo di review, sul primo fix. - Due guardie di regressione:
siblingsnon deve toccare la datastructure (tutti i test girano con_get_dimensionsche solleva) e non deve arrendersi seall_available()fallisce. - Il test dell'hint
guidesostituivaerr_consoleglobalmente senza ripristinarlo, lasciando una Console bufferizzata per tutta la sessione: orapatch.object. - Suite 352 → 354, ruff e mypy puliti.
- Nuovo eval mirato al solo layer di ricerca (nessun agente, nessun giudice LLM):
eval/retrieval.py+ gold seteval/goldset/retrieval.yaml, 27 bisogni informativi scritti alla cieca × 2 lingue, gold come famiglia di df_id. - Cinque bracci: keyword attuale, stesso scorer su testo esteso, BM25 su testo esteso, semantico nomic, hybrid RRF non pesato.
- Risultato: il semantico vince nettamente (MRR 0,327 vs 0,073; Success@10 57% vs 17%). Il testo in più aiuta il lessicale solo con lo scorer giusto: allo scorer attuale non serve (0,075,
_score_resultspremia i documenti lunghi), a un BM25 vale +85% (0,135) — ma il semantico resta 2,4× il BM25 sullo stesso testo. In inglese su metadati italiani ogni braccio lessicale è a terra (0,012-0,054) e il semantico tiene (0,310); sulla query naive BM25 non aiuta affatto. L'RRF non pesato peggiora. - Ricaduta pratica non prevista: sostituire lo scorer di
search_datasetcon BM25 sul testo esteso vale +85% di MRR senza modelli né dipendenze nuove. - Ricostruita la cache embedding ISTAT, ferma al 24 luglio e quindi antecedente al cambio di ricetta del testo embeddato (
d8f7dcc). - Report:
eval/results/2026-08-10/retrieval.md. Impianto e passi aperti:tasks/todo-retrieval-eval.md. - Nuovo
docs/search.mdtracciato: documento di riferimento sulla ricerca — i due percorsi, il testo che ciascuno indicizza (non è lo stesso), il registro delle misure (#52 del 2026-07-20 e l'eval di oggi) e cosa resta aperto. Serviva perchétasks/,eval/edocs/eval.mdsono gitignored: fin qui nel repo non c'era traccia del perché la ricerca è fatta così. Linkato dal README accanto adocs/descriptions.md. tasks/todo-search-context.mddiceva ancora "not started" mentre #52 è chiusa e in produzione dalla v0.17.0: corretto in DONE, con il rimando adocs/search.mdcome documento di riferimento.
- Aggiunta sezione per l'utente umano dopo il CLI quick start: tre flussi reali da analista (disoccupazione giovanile con
--labels, PIL pro capite riproducibile via--query-file/run, HICP conplot) e le tre regole che salvano tempo (dimensioni a valore singolo esplicite,--labelssempre, salvare le query riusabili). - Tutti i comandi citati verificati end-to-end contro Eurostat prima di pubblicarli (nessun esempio inventato).
- Niente codice: solo README.
- Sintomo:
opensdmx guidedicevaRun: pip install opensdmx(senza[guide]) anche se il sorgente conteneva la stringa giusta — un fix che non installaquestionarye va in loop. - Causa radice: il messaggio passava per Rich, che interpreta
[guide]come markup e scarta il tag senza controparte. Non era una stringa sbagliata: era renderizzata male. - Fix: escape
\[guide]nei due call site (guide.pyrun_guide ecli.pyblacklist). - Test di regressione:
test_guide_missing_extra_hint_shows_bracket(blocca l'import di questionary, cattura la Console su buffer, verifica che[guide]sopravviva al rendering). 352 passed, ruff/mypy puliti.
siblings une_rt_mfalliva con "not categorized" mentresiblings UNE_RT_Mfunzionava:siblings_of()filtravadf_ida match esatto, mentreload_dataset()normalizza il case ovunque. Viluppo: match case-insensitive insiblings_of(str.to_uppercase()) e comparazioneis_targetnormalizzata, così il marker→colpisce la riga giusta anche con input minuscolo.siblingsora risolve l'ID viaload_dataset()prima della lookup categorie: distingue "ID inesistente" (errore standardCould not find dataset, exit 1) da "esiste ma non categorizzato" (exit 1, non più 0 silenzioso).- Test:
test_siblings_of_case_insensitive(library) + 4 CLI test (lowercase, json, not-found, not-categorized); 351 passed, ruff/mypy puliti.
- Nuovo doc
docs/evaluation-v0.21.0.md: valutazione hands-on della CLI da utente analista Eurostat (disoccupazione, PIL pro capite, HICP, NUTS2, popolazione), con tempi misurati e casi d'errore verificati. - Punti forti: sub-second su cache calda (0.3-0.8 s),
constraintsfedele alla realtà di campo (verificato: l'API rifiutaY15-24su UNE_RT_M esattamente come il CLI), errori autoesplicativi,--labels, riproducibilità YAML (--query-file/run),plotout-of-the-box. - Problemi aperti:
search "unemployment"seppellisce UNE_RT_M al #58/146 (ranking per occorrenze, non per importanza);siblingscase-sensitive con exit code 0 sul fallimento; hint d'installazioneguideerrato (servepip install "opensdmx[guide]"); nessun intervallo temporale ininfo; niente stima righe nel guard diget; warning plotnine su stderr. - Proposte R1-R6 (senza cambiare architettura): boost ranking senza LLM, fix
siblings, fix hintguide,infocon periodo quando noto, stima~N righenel guard, silenziare plotnine. R1 (ranking) a maggior ritorno per l'utente umano.
- Returning the whole codelist was never wrong — a codelist is a shared definition, not one dataflow's inventory. What was missing is the link to
constraints: of the 759CL_UNITcodes listed forPRC_HICP_MANR, exactly one (RCH_A) is usable as a filter there, with no signal in the output. - feat(values): the output now carries that link. When the dataflow's constraints are already cached, each row gets an
in_dataflowflag (true/false,nullwhen unknown) plus a coverage footer; otherwise a pointer toopensdmx constraints. Both paths are a local cache read — zero extra network calls, so the cheap command stays cheap. in_dataflowis a real field in JSON and CSV, and the CSV header no longer changes shape with cache state (caught in review: the column used to disappear when membership was unknown).- No filtering added to
values:constraints <df> <dim>already returns exactly the codes present, byte-identically.valueskeeps showing the whole codelist with the valid ones marked — the viewconstraintscannot give. - feat(get/plot): the 400/404 hint now also names the dimensions left unfiltered instead of only blaming filter values (#66).
- #66 as filed does not reproduce:
contentconstraint/ESTAT/LFSQ_URGANreturnscitizentoday and the reprogetexits 0 with data — most likely a stale partial constraint cache entry (7-day TTL). Closed as not reproducible. - New
discovery.constrained_codes()(pure, case/dash-insensitive,Nonewhen the dimension is unknown) + 17 tests.
- New
eval/package (not in core deps;deepevalbehind the[eval]extra) that scores the agent loop — an LLM orchestrating the opensdmx CLI with thesdmx-explorerskill — end-to-end. Plan intasks/todo-deepeval-agent-loop.md. - Gold set
eval/goldset/cases.yaml: 10 cases across eurostat/oecd/imf/ecb/istat, tagged byfailure_mode(discovery / filters / provenance / value-rounding). Every canonical_query validated live (returns rows). - Deterministic core (no LLM judge — the point of opensdmx):
QueryConvergenceMetric(agent's declared provider+dataset == gold) +ValueCorrectnessMetric(re-run the agent's declared query AND the gold canonical query live, compare cells within tolerance). Judge metrics (Faithfulness,GEval) on GLM-5.2/ZAI behind--judge. - Routing: agents Claude+Gemini via OpenRouter (
OPENROUTER_API_KEY); judge GLM-5.2 via ZAI (ZAI_API_KEY, neutral). FreemiumGEMINI_API_KEYonly an optional fallback (GEMINI_RPMthrottle). - Agent harness (
eval/agent.py) runs the skill as system prompt, exposes arun_opensdmxtool, and asks for a final structuredANSWER={...}block;eval/shims/is a PATH shim that tees every opensdmx call to JSONL (retrieval context + convergence analysis). - Verified end-to-end: Claude seed case (StatGPT IMF WEO) → real discovery 23 steps →
QueryConvergence 1.0 / ValueCorrectness 1.0, matching the StatGPT figures. - Filed 2 CLI issues found in validation: #66
constraintsomits dimensions thatinfoshows (lfsq_urgancitizen) → misleading "check filter values"; #67valuesreturns codelist codes not present in the dataflow (HICPunit) → invalid filters. - Run:
uv run python -m eval.run --model all, report ineval/results/<date>/.
- Prototype
tmp/semantic_route_test.py: fastembed (multilingual MiniLM, ONNX/CPU) + cosine over the 3,949 harvested descriptions. No LLM, no API. - 16–18 ms per query after a one-time ~155 s corpus embedding (~6 MB cache). Birth/inflation/road-accident queries nail top-5; out-of-domain query correctly rejected by a 0.45 threshold.
- Validates the approved fastembed self-contained semantics plan; findings + current bibliography in
docs/future-ideas.md.
- feat(discovery): capture
GEO_IDinto a newdf_geo_dimcatalog column — the territorial dimension ISTAT names on a dataflow when it is not the defaultITTER107/REF_AREA(e.g.RESIDENCE_TERR). - feat(constraints_archive): the nightly territorial classifier now treats as
territorial the default dimensions plus the distinct
df_geo_dimnames discovered across the catalog, applying each name wherever it occurs. Gated on the dimension name only — never on code shape (ECOICOP_2has 6-digit codes but is not geography). Recovers 77 previously-invisible dataflows (incl. births25_74) while correctly excluding the 31ECOICOPdataflows. - Verified in a dry run over
data/constraints/istat.parquet: classifiable dataflows 1,749 → 1,826;RESIDENCE_TERRdiscovered fromGEO_ID;ECOICOPnot included.
- feat(discovery): three new nullable catalog columns read from ISTAT's
annotationsblock at zero extra network cost —df_last_update(LAST_UPDATE, 4,511 dataflows),df_notes(DATAFLOW_NOTES, 144),df_bulk_files(ATTACHED_DATA_FILES, 365). Stored as published (LAST_UPDATE mixes ISO 8601 and MM/dd/yyyy; bulk files areURL|LABEL). - feat(embed): fold
df_notesinto the embedded document text so semantic search can match a dataset by its methodological caveats (e.g. province-boundary changes that break time-series comparability).df_last_update/df_bulk_filesare captured, not embedded. - infra: optional catalog columns backfilled via one
_ensure_catalog_columns()helper (legacy cache + hub-only INPS), replacing the growing per-column if-chain. - Verified: coverage 4,511 / 144 / 365; a notes-only query now reaches the dataflow.
Follow-ups (out of scope): normalise
df_last_update, surfacedf_bulk_filesininfo/get, and the GEO_ID territorial change.
- refactor(discovery): replace the text-only
_keyword_annotationwalker with_annotations(node, language)→dict[type, Annotation(title, text, url)], one pass over the node. Fixes the latent bug that the old reader saw onlyAnnotationText, while most ISTAT annotations (LAST_UPDATE, LAYOUT_*, GEO_ID, …) carry their value inAnnotationTitle— pointing the old reader at them returnedNonesilently. Presence-only annotations (e.g. READY_FOR_PRODUCTION) map to an all-NoneAnnotationso membership tests work; type strings match literally (LINkEDDATAFLOWNODE, lowercase k)._keyword_annotationkept as a thin wrapper. - refactor(portals): declare consumed annotations in one
annotationsblock ({stable_key: {type, value: text|title|presence}}) instead of per-annotation top-level keys; ISTAT migrated (keywords,metadata_url). The METADATA_API service keys stay separate. Noif provider ==. - refactor(scripts): the descriptions harvester drops its
_annotation_textcopy and reads METADATA_URL through the shared reader. - Enabling change only: no new annotation is consumed and behaviour is unchanged —
verified end-to-end (ISTAT
df_keywordsstill 144/4899, identical) and by the untouched keyword tests. Unlocks LAST_UPDATE, DATAFLOW_NOTES, ATTACHED_DATA_FILES, hidden-flag labelling (seedocs/istat/databrowser-annotations.md) at zero net cost.
- docs: add
docs/istat/databrowser-annotations.md— census of the SDMX annotations ISTAT publishes on its dataflows, from exploring the SDMX Istat Toolkit site. One catalog call: 4,899 dataflows, 29 distinct annotation types in use, of which opensdmx reads 2 (LAYOUT_DATAFLOW_KEYWORDS,METADATA_URL). - finding: esploradati runs the StatKit Data Browser, not
.Stat Suite— the manual (§3.7, p. 36) namesapp/databrowserhub/api/core, ourhub_base_url.docs/istat/hub-api.mdanddocs/inps/middleware-api.mdcarry the wrong platform. - finding: 643 dataflows (13.1%) are flagged non-production or hidden by ISTAT and we
expose them all — 14.1% of real search hits. Probed
CPI(flagged, returns current data): the flag means catalog visibility, not data availability, so the remedy is to label and deprioritize, never to filter. - unused signals worth a pass:
LAST_UPDATE(92.1%, mixed ISO /dd/mm/yyyy),LAYOUT_ROW/COLUMN/FILTER(~80%, the publisher's own pivot),DATAFLOW_NOTES(145 methodological caveats),ATTACHED_DATA_FILES(366 bulk CSV/zip downloads). - no code change in this pass.
- feat(descriptions): harvest the authentic dataflow descriptions ISTAT shows on
EsploraDati — never present in the SDMX structure — via the
METADATA_URLannotation →METADATA_API(DATA_SOURCE). Newscripts/descriptions_archive.pyreads the annotation for the whole catalog in one call, de-duplicates by the sharedreportId(3,974 linked dataflows → 622 unique reports, 6.3×), fetches each report once, HTML-cleans the prose, and writes a bundled resourcesrc/opensdmx/data/descriptions/istat.parquet(3,949 dataflows described, ~81%). - feat(embed): fold the harvested description into the embedded document text when the provider resource is present; guarded, so providers/checkouts without it are byte-identical to before. Read locally — no metadata request at embedding time.
- feat(portals): ISTAT declares the channel (
metadata_annotation,metadata_api_path,metadata_description_attribute); generic, no per-providerif.metadataSetId/reportId/BaseUrlMDAare derived from the live annotation. - infra: monthly GitHub Action refreshes the resource; the metadata API is a service distinct from the rate-limited SDMX endpoint.
- Context: ISTAT declined to add
<common:Description>to the DSD (2026-07-23), but the prose was already reachable through this separate channel. This is the primary description lever (81%); deterministic DSD composition (the ~19% tail + other providers) and optional LLM prose are declared follow-ups. No runtime LLM.
- feat(discovery): parse an optional per-dataflow keyword annotation into a new
nullable
df_keywordscolumn on the dataflows cache. Providers opt in by declaringkeyword_annotationinportals.json(only ISTAT:LAYOUT_DATAFLOW_KEYWORDS); others never call the parser. New helper_keyword_annotation()matches on theAnnotationTypechild text (namespace-agnostic), returns the text for the provider language with en fallback. Zero extra network cost — sameGET /dataflow/IT1already made. - feat(embed): append
df_keywordsto the embedded document text when present (guarded on column existence for backward-compat with older caches). - Where present, the keyword layer is rich — expanded dimensions, periodicity,
and territorial granularity (
provincia, comune…) — so it also helps the REF_AREA granularity question. But coverage is thin: 144/4899 ISTAT dataflows (2.9%). Deliberately not wired into keyword search (low impact). - Context: ISTAT declined (2026-07-23) to populate
<common:Description>on the DSD, so descriptive text must come from within opensdmx.cat_descriptionis effectively empty everywhere (0.1% ISTAT, 0% Eurostat); the real broad lever remains offline synthetic descriptions (separate task). - Verified: full suite green (307), ruff clean, live refresh confirmed the column populates (2.9%) and the embedded text carries the keywords.
- feat(provider): added INPS — the first hub-only provider. Its classic SDMX-REST endpoint is WAF-blocked, so opensdmx talks exclusively to the
.Stat SuiteDataBrowser middleware (https://opendata.inps.it/databrowser/api/core, JSON over GET+POST). All INPS logic lives in a dedicatedinps.pyadapter; core functions delegate via a singleif provider.get("hub_only")branch — no scatteredif provider ==checks. - The middleware is split into four nodes (observatories: pensioni, dipendenti, imprese, politiche occupazionali);
hub_nodesinportals.jsonholds thecode→nodeIdmap, and adf_id→nodeindex is built once from the four catalogs and cached as Parquet. providers/tree/search/info/constraints/getall work. Discovery reads catalog+structure+PartialCodelists;getdownloads the full dataflow as SDMX-CSV (the middleware has no server-side filter) and filters client-side (dimensions + a by-year period window), mirroring Derzhstat'sdata_key_format:"empty".last_n/first_nunavailable.- infra:
base.sdmx_requestgained_method/_json_body/_base_urlso hub POST calls inherit rate-limit/retry/file-lock.dimensions_infonow prefers a description carried on the dimension (the hub's per-dimension label), falling back to the codelist description for the other providers. - Verified live end-to-end: RAL media Lombardia 2021 = 27,285 € (matches a manual cross-check),
--labelsresolves Italian names. Full test suite green, ruff clean, zero changes to any other provider's path. - docs:
docs/inps/middleware-api.md(endpoint reference),docs/providers.md+ skillreferences/providers.mdupdated.
- feat(search): keyword search now also matches the category name a dataflow belongs to. ISTAT titles are often leaf labels —
Sesso,Età,Lazio— and 1,622 of 4,879 dataflows (33%) share a title with another.search "disoccupati mensili" --provider istatwent from 2 results to 6, the four new ones being the dataflows that actually hold the data. - feat(search): results print the category as context (
Disoccupati - dati mensili › Sesso, età), suppressed when the title already contains it.infogained aCategory:line.-o json|csvcarry a separatecategoryfield, so a machine gets the parts and a human the composed line. - Not ISTAT-specific. The context comes from the category cache, and
categories_supportedholds for 9 of 14 providers. Eurostatsearch "consumer prices"went from 33 to 66 results. Providers without categories, and users who never ranopensdmx tree, keep the previous behaviour exactly — verified on IMF. - refactor:
_category_context_for_embed()moved tocategories.category_context(), gaining aninclude_schemeflag and acat_primarycolumn. Embeddings keep scheme+category as before; search uses the category alone, since a scheme like "Lavoro e retribuzioni" would match nearly everything under it. New sharedutils.compose_title(). - Rejected on measurement. The issue proposed deriving a parent title from the ISTAT id pattern
{parent}_DF_{dsd}_{n}. Measured first: on the 3,460 dataflows having both, the parent title is the category name in 51% of cases, and contained in it in another 4% — an ISTAT-specific id parser would have rebuilt data the catalogue already carries and already feeds to the embeddings. The 45% where the parent says something else, typically the vintage, stays uncovered and is documented in #52. --grepdeliberately still applies to title and ID only, so documented recipes return the same rows.treeandsiblingsare untouched: both already group by category, so a per-row prefix would be pure duplication.- test: 4 new cases, including a regression guard that an absent category cache leaves results byte-identical. Suite 276 → 280.
- fix:
str.concat→str.join, silencing a polars DeprecationWarning that the wider use of this code path made frequent. - Review follow-up:
compose_titleused a substring test, so the categoryAgewas silently dropped from the titleAverage earnings; now a casefolded prefix match, pinned by tests. CSV output regained its typed frame — without itscorewas stringified and the column set depended on whether the category cache existed, making the schema a function of local state. - Filed while planning this: #57 (the embeddings index records neither the model nor the text that built it, so it cannot report staleness) and #58 (the 45% of ISTAT dataflows whose parent title carries a vintage the category does not).
- BREAKING fix(cli):
tree --category Xandtree --show-dataflowswithout--schemenow exit 1 instead of silently printing the scheme list with exit 0. Thescheme is Nonebranch returned before any tree-shaping flag was read, so three flags were accepted and discarded —--category's own help text already claimed "requires --scheme", an intent never enforced. - feat(cli):
--depthnow acts without--scheme. The provider is the root of the tree and the scheme list is level 1, so--depth Nuniformly means "N levels below the current root" and the special case disappears.--depth 2renders one tree per scheme with its top-level categories; ISTAT goes from 45 to 314 lines.--depth 1and the no-flag default keep printing the same scheme table — deliberate, sinceskills/sdmx-explorerdocumentstree --depth 1as the entry point of the guided workflow. - refactor(cli): the ASCII render block moved into
_render_tree_block()so the no-scheme path reuses it per scheme rather than reimplementing it. - Rendering shifts with depth: level 1 is the
n_dftable, level 2+ the ASCII tree. Accepted, no row-count warning — whoever types--depth 3asked for the big tree. - test: 5 new cases in
tests/test_cli.py, including a regression guard that no-scheme--depth 1output stays identical to the no-flag output, and a dedicated two-scheme fixture covering the one-tree-per-scheme loop the single-scheme fixture could not reach. Suite 271 → 276. - docs:
treedocstring andskills/sdmx-explorer/SKILL.mdstate the root/level model; plan intasks/todo-tree-depth.md.
- fix(discovery):
set_filtersnow treats-and_as equivalent when matching dimension names, so--na-itemreaches theNA_ITEMdimension. CLI convention is dashes, SDMX ids use underscores; matching was already case-insensitive, this extends the same leniency. - BREAKING fix(discovery): an unknown filter name now raises
ValueErrorinstead of being dropped with a log line. The old behaviour ran the query unfiltered and put the only signal on stderr — for a tool whose primary consumer reads stdout, that returned wrong data with no indication. Verified:opensdmx get NAMA_10_GDP --geo IT --na-item B1GQ ...used to emit 20+na_itemvalues when one was asked for. The error carries adifflibsuggestion and the available dimension list. - Correction to the Phase 2 record. That refactor claimed
plothad degraded by not capturingset_filterswarnings. It had not.catch_warningsgenuinely appeared inget/runand notplot, but nothing in the package callswarnings.warn— the messages come fromlogger.warningvia logging, independently. Confirmed by runningplotat the v0.14.1 tag: same output. The claim was verified only in the after state, never the before. - refactor(cli): removed the dead
catch_warningsblocks from_load_and_filterandrun. They captured nothing, and Phase 2 had preserved them faithfully rather than deleting them — the opposite of that phase's purpose. - test: replaced
test_load_and_filter_surfaces_set_filters_warnings, which passed only because its own mock raised a warning the real code never raises. 5 new tests on dash matching, the raise, the suggestion and the available list. Suite 264 → 268. - docs:
docs/evaluation-v0.14.0.mdfinding 6 carries the correction inline; new finding 20 documents the filter bug.
- refactor(retrieval, cli):
opensdmx runreimplementedretrieval.run_query()— public API, exported in__all__— and the copies had diverged. The CLI honouredOPENSDMX_PROVIDERand accepted--provider; the library did neither, so the same YAML file behaved differently depending on the caller.run_query()gains aproviderargument and the env fallback; the CLI now calls it. Behaviour change for library users: a query file with noproviderfield now respectsOPENSDMX_PROVIDERinstead of silently staying on the default. - feat(base):
set_provider_from_env()— resolvesOPENSDMX_PROVIDERincluding aliases, so library entry points and the CLI agree.set_provider()alone never resolved aliases. - refactor(cli): extracted
_load_and_filter()and_fetch_frame(). Theload_dataset → set_filters → get_data → enrich_with_labelspipeline was written three times, and theplotcopy had already degraded — it did not captureset_filterswarnings, so a suspicious filter value was reported bygetand silently ignored byplot. Verified:plotnow surfaces it. - refactor(cli): extracted
_write_output(), duplicated betweengetandrundown to a one-word difference in the error string (unsupported output formatvsunsupported format).runnow uses the former wording. - perf(discovery):
parse_bulk_constraints()builds the tree once._extract_bulk_long_idsand_parse_bulk_constraint_xmleach calledxml_parse()on the same bulk contentconstraint bytes — the largest XML the library fetches — for two full tree builds and two namespace scans. - chore(db_cache): deleted
bulk_constraint_fetchandbulk_constraint_index. Written on every catalog rebuild, never read: their only reader had no callers. The signal travels in thehas_constraintcolumn ofdataflows.parquet. - test: 15 regressions in
tests/test_phase2_dedup.py. Suite 243 → 258, ruff and mypy strict green, real-CLI smoke onget --query-file,runandplot.
- release: patch shipping the four Phase 1 bug fixes below. Most user-visible is
-o csv, which emitted corrupt CSV whenever a field contained a comma —whichandprovidersboth do. No API change.
- docs: full architecture review in
docs/evaluation-v0.14.0.md— data flow, 19 findings ranked by severity, refactoring strategy in 4 phases, plus an explicit "verified correct, do not change" list. Plan intasks/todo-architecture-review.md. - fix(cli):
-o csvemitted corrupt CSV —_emitjoined fields with","and no quoting, so any description containing a comma split into extra columns (which,providers). Now delegates to Polarswrite_csv();str()semantics preserved. - fix(cli):
-o csvon a nested payload (info,constraintssummary,siblings) fell back to JSON silently. Still JSON — there is no faithful flat form — but now says so on stderr. Two deliberate behaviour additions, not restorations: that stderr warning is new (an empty list is exempt — it is empty, not formless), and a present-but-Nonevalue now renders as an empty cell instead of the literalNone. stdout is byte-identical to before in every other case. - fix(cli): the large-dataset guard raised
typer.Exit(1)inside atrywhoseexcept Exceptioncaught it (typer.ExitsubclassesRuntimeError), appending a spuriousError: 1after a correct warning. Handler now re-raisestyper.Exit. - fix(cli): that warning said "no filters set" unconditionally, even when filters were passed.
- fix(utils, ai): codelist values are cached under
{codelist_id}:{lang}but were read with the bare id, so_get_code_labeland the AI label map returned nothing on every provider — verified against the live cache (zero rows under a bare key). The AI context was listing raw codes with no labels. - chore(deps): declared
pandas(imported atcli.py:1456,1473, previously resolved only transitively via plotnine); removedduckdb, declared since v0.2.6 and never imported. - test: 14 regressions in
tests/test_phase1_regressions.py. Suite 229 → 243, ruff and mypy strict green, real-CLI smoke onsearchandget.
- release: export types to consumers via the
py.typedmarker (PEP 561) and fullmypy --strictcoverage across the package. No runtime or behaviour change.
- ci:
cliconverted to strict and the gradual opt-out override removed —strict = truenow covers all 14 modules with no exemptions. Typer commands get-> None, helpers typed (_status_ctx -> Iterator[None],_filter_by_grep/_emitoverpl.DataFrame), dicts parametrised. - plotnine ships
py.typedbut leaves its public API (aes,geom_*,theme) unannotated → dedicatedfollow_imports = "skip"override so its calls aren't flagged;chatlaskept typed (used for the_ai_structuredTypeVar). - feat: added the
py.typedmarker (PEP 561), verified present in the built wheel — downstream users importingopensdmxnow get the exported types. - Verified: mypy strict green (14 files), ruff clean, 229 tests, real CLI smoke (
providers,get --output csv).
- ci: switched
[tool.mypy]tostrict = truewith a per-module opt-out list so strict is adopted one module at a time. Converted the small/pure modules now —ai,embed,which,hub,retrieval(plus the already-cleancache_config,guide,__init__) are strict-clean;categories,utils,db_cache,base,discovery,clistay exempted with the specific strict flags relaxed (tracked TODO in the config comment). - fix(types): precise generics (
dict[str, Any],list[dict[str, str]]), missing param/return annotations, and Any-return coercions via typed locals; no new# type: ignore. mypy,ruff, and the 229-test suite all green; runtime smoke (which, package import) ok.
- ci: added mypy to the dev tooling.
[tool.mypy]inpyproject.toml(pragmatic baseline: default checks +warn_unused_ignores/warn_redundant_casts), dev depsmypy,types-PyYAML,lxml-stubs,pandas-stubs;uv run mypystep inci.ymlnext to ruff. - fix(types): resolved the real type errors surfaced —
ai._ai_structurednow generic (TypeVarbound toBaseModel), dropping 11 stale# type: ignore;discoverydataflow records typed via aTypedDict(df_id alwaysstr);params/ns/constraint_timeoutannotations;which.rank_whichscored list typed. mypy,ruff, and the 229-test suite all green.
- feat(cli):
searchgains--grep, the case-insensitive regex post-filter already available onvaluesandconstraints. Closes the gap that forced a shell pipe for whole-word matching (searchingcomunion ISTAT also matched "comunitari", "comunicazione"). - fix(cli): a malformed
--greppattern now reports a readable error instead of raising a regex traceback — applies tosearch,valuesandconstraintsvia the shared_filter_by_grephelper. - fix(cli):
searchhelp said "dataset descriptions"; it matches titles and IDs, and dataflows expose a title (SDMXName), not a description. - docs: README command table + example,
skills/sdmx-explorersearch step. - test: 4 regressions covering whole-word filtering, ID matching, invalid pattern, no-match exit.
- feat(data): public per-provider constraints archive in
data/constraints/(parquet + status CSV), grown incrementally byscripts/constraints_archive.py— budgeted daily probes instead of bulk sweeps. - ISTAT probed via the databrowser hub bulk endpoint (~1 s/dataflow, no DSD call, outside the 15 s SDMX rate limiter); other providers via
load_dataset()+get_available_values()with built-in pacing. - Derived view
istat_territorial.csv: territorial granularity per dataflow (nazionale → comune) classified from ITTER107/REF_AREA codes — answers "which ISTAT datasets have municipal detail?" offline. - ci:
constraints-archive.ymldaily workflow (istat + eurostat budgets, opensdmx cache restore, commits on change). - chore: deleted unused bulk scaffolding (
schema_export.py,update_istat_schema.py,update_all_schemas.py,cache_maintenance.py,rate_limit_helper.py,example_cron.sh); rewrotescripts/README.md.
- fix(search): OR fallback coverage treats a null description token match as false, preserving coverage from tokens that match the dataset ID.
- test: added a null-description regression to ensure a two-token ID match ranks above a one-token match.
- ci: removed duplicate automatic Claude Code PR review; Greptile and Copilot remain the automatic reviewers.
- ci: Claude on-demand now triggers only from issue and normal PR conversation comments containing
@claude, avoiding skipped runs for every inline review reply or review submission.
- fix(search): treat search tokens as literal text, preventing malformed or broad regular-expression matches from user input.
- fix(search): in OR fallback results, rank datasets by the number of distinct matched tokens before the existing relevance score; repeated occurrences of one token no longer outrank broader matches.
- test: added regression coverage for literal regex characters, helper case normalization, and token-coverage ranking.
224 passed; ruff clean.
- fix:
search_dataset()(discovery.py) combined all keyword tokens with AND and returned nothing when a single token was unmatched; now falls back to OR (any token) when AND is empty, so one stray token no longer wipes the result set. Relevance scoring (_score_results) keeps full-token matches on top. Extracted_token_match_expr()helper. - test: added AND/OR fallback cases to
tests/test_discovery.py(mocked catalog, no network). Full suite 221 passed, ruff clean. - verified on real Eurostat cache:
"unemployment youth aardvark"went from 0 → 123 results, top hits still the youth-unemployment dataflows.
- docs: exported the requested Google Doc to Markdown with
gwsb drive files export; saved astmp/google-doc.md - docs: reviewed the ISTAT conference abstract against the local
istat_mcp_serverimplementation; drafted a minimally edited 1,109-character revision intmp/abstract-revised.md
- fix: dependency
httpx>=0.28.1→httpx[socks]>=0.28.1, bundlingsocksio. opensdmx now works out of the box inside sandboxes that route all traffic through a SOCKS proxy (e.g. the Claude Cowork sandbox); previously any network call raisedImportError - docs: README Installation note +
sdmx-explorerSKILL.md setup note documenting the SOCKS/sandbox case and the one-shot manual patch (uv pip install ... socksio "httpx[socks]") for installs older than 0.12.1 - skill: bumped
sdmx-explorerto v1.3
- feat: the
codelist_valuescache now stores each code'scode_parent(<Parent>ref) andcode_order(ORDERannotation); populated at parse time, no extra request - feat: new
get_codelist_hierarchy(ds, dim)accessor returns(id, name, parent, order), exported from the package;get_dimension_valuesoutput is unchanged (still(id, name)) - cache: in-place migration adds the two columns to pre-existing
codelist_valuestables (no wipe);save_codelist_valuesnow inserts by explicit columns. Note: codelists cached before this upgrade keep null parent/order until re-fetched on TTL expiry — deletecache.dbto refresh immediately - cross-provider: providers with flat/unordered codelists (e.g. Eurostat) yield null parent/order; verified ISTAT
CL_ITTER107(12,471 codes → 12,465 parents, 12,471 orders) and Eurostat geo (4,292 codes, all null) - test: 4 new tests in
test_codelist_hierarchy.py(parsing, regression guard, save/read round-trip, in-place migration)
- feat:
opensdmx get --labelsappends a<dim>_labelcolumn with the human-readable name for each dimension code (resolved from the codelist cache, in the provider's language); codes are preserved, label column mirrors the data column case +_label. Inspired by rsdmx'sas.data.frame(labels=TRUE) - feat: the
--labelschoice is serialized to the YAML query file and honored byopensdmx run - core: new
enrich_with_labels(dataset, data)helper inretrieval.py, exported from the package - docs: document
--labelsin README and thesdmx-explorerskill (SKILL.md, visualization.md Rule 5, duckdb-setup.md) - test: 5 new tests in
test_labels.py(enrichment, no-codelist, unmapped→null, query-file round-trip)
- fix: SQLite schema is now initialized per DB file path instead of via a single module-level
_DB_INITIALIZEDflag (#42); switching provider (e.g.eurostat→oecd) no longer crashes withno such table: invalid_datasets - fix: harden the schema guard — key on the resolved absolute path (avoids relative-path/CWD aliasing) and re-create the schema when the DB file did not exist before connecting (covers
cache.dbdeleted during a long-lived process) - test: add
test_schema_init_per_db_pathregression covering the two-provider scenario
- docs: align operational documentation with current code — provider count/list, ISTAT 15s rate limit, 7d/30d cache TTLs, provider-key cache paths, rate-limit lock behavior, and
guideextra dependency split - docs: add current project evaluation for v0.10.3 in
docs/evaluation-v0.10.3.md; verification:207 passed,ruff check src/passed, full-repo ruff has minor test/script lint issues - note: strongest next priorities are documentation drift cleanup, dependency slimming, full-repo lint hygiene, and closing stale OpenSpec state
- feat: add
whichcommand — natural-language capability lookup; resolves a query to the best matching command from a curated index; exit code 2 on no confident match (agent-friendly); supports-o jsonfor pipeline use - feat: add ILO (ILOSTAT) and UNICEF portals — ILO uses standard
Accept: text/csv, UNICEF usesformat=csvdata; both support lastN and categories; constraints disabled for ILO (endpoint returns 500/413)
- fix: patch
_check_api_reachablein all 9test_tree_*tests — missing mock caused real HTTP requests to ISTAT in CI, where the provider is unreachable, making every tree test fail before the data mock could take effect
- feat: add
has_constraintboolean column to dataflow catalog (dataflows.parquet) — populated at catalog-build time via a single bulkcontentconstraintcall for providers that support it (currently ISTAT);get_available_values()skips the per-dataflow CC_ call when the catalog confirms no static constraint exists, avoiding repeated 60s+ timeout attempts on large dataflows - fix: pre-existing key-mismatch bug in bulk constraint parser —
_DF_-truncation produced short ids (e.g.41_269) that didn't match full-form catalog entries (e.g.41_269_DF_DCIS_INCIDENTISTR1_1); new_extract_bulk_long_idshelper and_match_catalog_idresolver handle both catalog id formats correctly - fix: constraint values for full-form catalog ids now correctly cached under the right key
- chore: document Eurostat bulk CC 405 via
constraint_bulk_supported: falseinportals.json
- fix: bulk CSV download no longer crashes when REF_AREA contains string codes like
IT—infer_schema_length=0andnull_values=[]prevent Polars from misinterpreting dimension codes as numeric or null; closes #32 - fix:
"Could not find dataset"error now names the active provider and suggests--provideralternatives; closes #33 - fix: large-dataset warning now explains that
--yestriggers a single wildcard REST request, not chunked calls; closes #35 - fix:
Saved:andQuery saved:messages moved from stdout to stderr for clean pipeline use; partial #34
- fix:
opensdmx valuesnow returns labels in the provider's configured language (was always returning English); closes #31 - fix: codelist cache is now language-aware — same codelist cached separately per language to prevent cross-language contamination
- feat:
--provider estatnow accepted as alias foreurostat— aliases are declared inportals.jsonunder the"aliases"field and resolved automatically at CLI startup
Empirical verification on 2026-05-10 against esploradati.istat.it shows that the
"hub fast path" introduced in v0.9.0 does not solve the territorial-dimension
discovery problem on ISTAT. The release notes implied a generic fix; the fix is real
only for small coded dimensions.
What was tested (two datasets: 22_289_DF_DCIS_POPRES1_24 "Tutti i comuni per
singola età" — 6 dimensions, and 41_269_DF_DCIS_INCIDENTISTR1_1 "Incidenti
stradali" — 10 dimensions):
- SDMX REST
availableconstraint/{df}— HTTP 000, 60 s timeout (as documented). - SDMX REST
contentconstraint/IT1/BL_…— HTTP 404 in 1.2 s. - SDMX REST
dataflow/?references=actualconstraint— HTTP 200 in ~5 s, butREF_AREAis absent from theCubeRegion(explicit<NOT_DISPLAYED title="NOTE_REF_AREA">annotation on the population dataflow; silently absent on the incidents one). - SDMX REST
dataflow/?references=all&detail=referencepartial— HTTP 200 in 34 s, 9.4 MB. The returnedCL_ITTER107codelist contains 12,471 codes — identical to the full codelist (codelist/IT1/CL_ITTER107/?references=none→ 12,471 codes too). - Hub
column/REF_AREA/partial/values— HTTP 200 in ~6 s, 12,471 codes, response body byte-identical between the population and incidents datasets (1,209,463 B). TheisSelectable: trueandisDefault: falseflags are uniform across all 12,471 codes and carry no discovery information.
Implications:
- For dimensions where ISTAT populates an actual content constraint (FREQ, DATA_TYPE, SEX, AGE, MARITAL_STATUS, MONTH, …) the hub fast path remains correct: it returns the codes actually used.
- For
REF_AREA(and likely for any large coded dimension) the hub returns the full codelist regardless of which territorial granularity the dataset actually exposes.opensdmx constraints <df> REF_AREA --provider istattherefore reports values that are not all queryable, while presenting them as if they were. - There is currently no documented or undocumented endpoint on ISTAT that returns
"the territorial codes actually used in dataset X". Discovery must rely on
out-of-band signals (dataflow naming, category tree) plus a probe
GET /data/{key}?lastNObservations=1at the candidate granularity.
Documentation: docs/istat/hub-api.md updated today — caveats added to the bulk
and per-dimension endpoint sections, two new sections added: "Officially
documented SDMX REST constraint endpoints" (references=actualconstraint,
references=all&detail=referencepartial) and "Critical limitation: REF_AREA
discovery on ISTAT" with the full comparison table.
Follow-ups (not yet done):
- Print a runtime warning from
opensdmx constraints <df> REF_AREAon ISTAT when the response is the full codelist, so users do not mistake it for filtered data. - Open a tracking issue mirroring this errata.
- Consider amending the v0.9.0 release notes on GitHub with a link back to this entry.
- fix(hub): replace 10 sequential
column/{dim}/partial/valuescalls with a singlecolumns/partial/values(plural) request that returns all dimensions at once (~2 s vs 10–15 s). Discovered via Playwright network inspection: the Data Browser UI has always used the bulk endpoint; opensdmx was using the slower per-dimension variant. - fix(hub): add
_get_all_dimension_values_via_hub_bulk()— falls back to sequential per-dimension calls only when the bulk response is incomplete. - test(hub): 7 new tests covering bulk parsing, TIME_PERIOD exclusion, error paths, and the bulk-first / sequential-fallback dispatch logic. Suite: 195 tests, all pass.
- docs(istat):
docs/istat/hub-api.mdupdated — bulk endpoint documented as preferred path, single-dim endpoint moved to fallback section.
- feat(hub): new
src/opensdmx/hub.pymodule wrapping the.Stat Suitedatabrowser hub API (/databrowserhub/api/core/nodes/{node}/datasets/{id}/column/{dim}/partial/values). Per-dimension JSON lookups in ~500 ms, never go through the slowavailableconstraint/serieskeysonlycross-join. Reference:docs/istat/hub-api.md. - feat(discovery):
get_available_values()tries the hub right after the SQLite cache and before the existing bulk + availableconstraint + serieskeysonly chain. Hub usage is opt-in per provider viahub_base_urlinportals.json; on any hub failure (HTTP error, parse error, partial result) the original chain runs unchanged. - feat(portals): ISTAT gains
hub_base_url,hub_node_id,hub_dataset_agency,hub_timeout. No other provider is touched — Eurostat, OECD, ECB, ABS, BIS, IMF, Bundesbank, INSEE, World Bank, Comext, Derzhstat all keep their existing path byte-for-byte.OPENSDMX_DISABLE_HUB=1opts out at runtime. - fix(istat):
41_270_DF_DCIS_MORTIFERITISTR1_1(11 dimensions, ~573 k observations) now resolves all dimension values in ~7 s end-to-end. Previously: timeout onavailableconstraintafter 30 s, timeout onserieskeysonlyafter 30 s, no usable output. Verified live on 2026-05-10. - test: 21 new tests in
tests/test_hub.pycovering enable/disable, URL/identifier build, JSON parsing, error fallback (HTTP error, timeout, malformed JSON, partial failure), TIME_PERIOD exclusion, graceful contract for direct calls on non-hub providers, and discovery integration on both ISTAT-like and non-hub providers. Two existing ISTAT regression tests intest_http.pyopt out of the hub explicitly so they keep guarding the SDMX-REST availableconstraint and bulk contentconstraint codepaths. Total: 186 tests pass.
- feat(base):
_use_split_rate_limit(is_data)— returns True only whenis_data=Trueand the provider definesdata_rate_limit. Keeps the unified timer for all providers without that key, preserving identical behavior for ISTAT, Eurostat, Derzhstat, and all others. - feat(base):
_rate_limit_file/_rate_limit_lock_file/_rate_limit_checkacceptis_data=False. When split is active, data calls use{provider}.data.log+{provider}.data.lock; structure calls keep{provider}.log+{provider}.lock— fully independent clocks and locks. - feat(base):
sdmx_requestgains_is_data=Falsekwarg, propagated to the rate-limit machinery inside_do_request. - feat(base): all three data paths in
sdmx_request_csvpass_is_data=True(World Bank JSON, Eurostat SDMX-CSV, generic CSV). - feat(portals): OECD gains
"data_rate_limit": 60(1 data download/min, per official OECD docs). Structure calls (search, info, constraints) are unthrottled. - test: 6 new tests in
TestSplitRateLimitcovering split/no-split detection, lock file naming, and correct lock selection per call type. Updated two existing_rate_limit_checkmocks to accept the newis_datakwarg. Total: 165 tests pass.
- fix(portals): added
constraint_params: {}to ABS entry — the server returns 500 whenreferences=noneis sent as query parameter (default for all providers); omitting all params gives 200 and correct constraint data. - probe(issue #27): BIS, Derzhstat, IMF already work via
availableconstraintwithout any portals.json change; the original probe only testedcontentconstraint, which those providers don't implement. ABS was the only broken case. - Updated
tmp/contentconstraint-probe.mdwith current findings.
- feat(discovery):
_parse_serieskeys_xml()parses GenericDatadetail=serieskeysonlyresponses into{dim_id: [unique sorted values]}. - feat(discovery):
_fallback_serieskeysonly()sends an all-wildcard data request withdetail=serieskeysonlywhenavailableconstrainttimes out. Results are cached in SQLite identically to normal constraint data. - feat(discovery): both
ConstraintsTimeoutcatch-points inget_available_values()now chain to_fallback_serieskeysonlybefore re-raising — covers both the bulk path and the contentconstraint-404 path. - feat(cli): updated
ConstraintsTimeouterror message to mention that both fallbacks were attempted. - test: added
test_parse_serieskeys_xml,test_get_available_values_serieskeysonly_fallback, updated existing timeout test to reflect double-fallback chain. All 159 tests pass.
- feat(discovery):
_fetch_and_cache_bulk_constraintsfetchescontentconstraint/{agency_id}in one call, parses all 73 constraints (43 unique short df_ids), and populatesavailable_constraintsfor each covered dataflow. Multiple constraints for the same df_id are merged by union per dimension. - feat(discovery):
get_available_valuesnow uses a bulk index for providers withconstraint_bulk_supported: true. For covered df_ids: returns cached bulk data instantly. For uncovered df_ids (~99 % of ISTAT catalog): skips the 404 roundtrip entirely and goes straight to_fallback_availableconstraint. - feat(db_cache): two new SQLite tables —
bulk_constraint_fetch(records last bulk fetch per agency, TTL 7 days) andbulk_constraint_index(set of short df_ids covered per agency). - feat(portals): added
constraint_bulk_supported: trueto ISTAT entry. - docs: added P-ISTAT-02 to
docs/provider-proposals.md— request to extendcontentconstraintcoverage from 43 to all 4,836 ISTAT dataflows. - Bonus: bulk merge reveals
REF_AREA(139 regional/provincial codes) forDCIS_POPRES1— previously reported as absent. Now cached automatically.
- fix(discovery):
get_available_valuesnow falls back toavailableconstraintwhencontentconstraintreturns 404. Previously, a 404 was silently swallowed (logged as a warning, returned{}), leaving the user with no constraint data and no recovery path. Now: if the provider usescontentconstraintand gets 404,_fallback_availableconstraintconstructs the all-wildcard SDMX key (N-1dots forNdimensions) and retries against theavailableconstraintendpoint. - feat(portals): added
constraint_fallback_timeout: 30to the ISTAT entry. The fallback uses a shorter 30 s timeout (vs. the normal constraint timeout) to fail fast on datasets whereavailableconstraintis also slow (e.g. large municipal datasets like road accident data). On timeout,ConstraintsTimeoutis raised — the same exception already handled by the CLI. - refactor(discovery): extracted
_parse_constraint_xml(content)helper, reused by both the primary path and the fallback. Eliminates duplicated KeyValue parsing logic. - test: 3 new unit tests in
tests/test_discovery.pycovering contentconstraint-200, contentconstraint-404→fallback-200, and contentconstraint-404→fallback-timeout — all mocked, no network calls.
Note: the road-accident dataset (41_270_DF_DCIS_MORTIFERITISTR1_1) that motivated this fix will still raise ConstraintsTimeout on the fallback (its availableconstraint endpoint is slow regardless of timeout). For that dataset the only reliable path remains Fallback B: probe GET with a narrow time range + duckdb DISTINCT REF_AREA (documented in skills/sdmx-explorer/references/istat-flow.md).
-
feat(istat): switch ISTAT to
contentconstraint—opensdmx constraintson ISTAT goes from always timing out on large municipal datasets (e.g.22_289_DF_DCIS_POPRES1_24~77 s before timeout) to sub-second response. Probed 6 providers (Eurostat baseline + ISTAT + ABS + BIS + IMF + Derzhstat) and only ISTAT gains from the switch in this iteration; the other 4 either return 404/401/204 oncontentconstraint. Probe details intmp/contentconstraint-probe.md. Removed from the ISTAT entry the obsoleteconstraint_path_suffix,constraint_params,constraint_timeoutandconstraint_max_retries(those were specific toavailableconstraint). -
feat(cli): inline hint for dimensions absent from
contentconstraint. ISTAT'scontentconstraintdoes not exposeREF_AREA(8k+ comuni would inflate the payload). Theconstraintssummary table now shows missing dimensions with–and a hint pointing at the codelist endpoint:REF_AREA – not in contentconstraint — use: opensdmx values 22_289_DF_DCIS_POPRES1_24 REF_AREAIn
--output jsonmode each dimension entry carries asourcefield ("constraint"or"missing") and missing entries include ahintfield with the exact follow-up command. Single-dim mode (opensdmx constraints <df> REF_AREA) emits the same hint instead of a hard error and exits 0. -
docs(skill): rewrite
sdmx-explorer/references/istat-flow.mdfor thecontentconstraintflow (no more 30 s timeout warnings). UpdateSKILL.mdconstraints section with the missing-dim pattern,source/hintJSON fields, and ajqrecipe to list dims that need avaluesfollow-up.
- refactor(discovery): per-provider
constraint_timeout/constraint_max_retriesinportals.jsoninstead of hardcoded values indiscovery.py. Previously the 30 s + 1-attempt fast-fail was applied to every provider withconstraints_supported: true(Eurostat, ABS, BIS, IMF, Derzhstat), silently disabling the 3-retry policy for transient failures (5xx, NetworkError, TimeoutException) on backends that aren't slow. Now only ISTAT carries the override (constraint_timeout: 30,constraint_max_retries: 1); all other providers keep the module timeout (300 s) and 3 retries. Precedence:OPENSDMX_AVAILCONSTRAINT_TIMEOUTenv var > provider config > module default. CLI advisory generalized: gating onprovider.constraint_timeoutinstead ofagency_id == "IT1". - feat(discovery): dedicated short timeout for the
availableconstraintendpoint, configurable viaOPENSDMX_AVAILCONSTRAINT_TIMEOUT. When the provider's availability backend is slow or unresponsive (documented case: ISTAT, TTFB > 180 s with zero bytes received),opensdmx constraints/valuesnow fail fast instead of waiting up to 3 × 300 s on the global timeout. NewConstraintsTimeoutexception parallel toConstraintsUnavailable, exported from the package root. CLI prints a yellow advisory with the exact env var override. - feat(http):
sdmx_request/sdmx_request_xmlaccept optional_timeoutand_max_retrieskeyword overrides for per-call control of the HTTP timeout and retry attempts. - ux: ISTAT pre-warning before the first uncached constraints call now reflects the actual per-call timeout and the no-retry behavior (was a stale "30–120 s" estimate inherited from the old global-timeout regime).
- docs: add
docs/provider-proposals.md— empirically verified provider-side issues (P-ISTAT-01:availableconstraintkey filter accepted but ignored,contentconstraintavailable at 0.66 s; see issue #24). - docs(skill): update
sdmx-explorer/references/istat-flow.md— expand Step 3 with timeout behavior,OPENSDMX_AVAILCONSTRAINT_TIMEOUTenv var, empirical findings on key filter, and concrete fallback flow for municipal-level datasets.
- fix(http): retry only on transient failures in
sdmx_request— the previous@retrydecorator had noretry=predicate, so tenacity retried on any exception, includinghttpx.HTTPStatusErrorfor 4xx codes. On rate-limit-by-IP providers (e.g. ISTAT) this could turn a single 429 into three back-to-back hits and amplify the risk of a multi-day ban. Now retries are restricted tohttpx.TimeoutException,httpx.NetworkError,httpx.RemoteProtocolError, and 5xx server errors (excluding 501 Not Implemented). Predicate exposed as_is_retryable_exceptionfor testing.
- ux:
constraintssummary table now shows a tip to use--output jsonfor the full list of values
- fix: force
OBS_VALUEtopl.Float64in CSV parsing — Polars inferredi64from the first 10 000 rows and crashed on the first float value past the inference window (reported on DerzhstatDF_POPULATION_BIRTH) - fix: apply dimension filters client-side for portals with
data_key_format: "empty"(e.g. Derzhstat) — previously--DIM VALUEflags were silently ignored, causing full-dataset downloads instead of filtered results - fix: force UTF-8 on stdout/stderr on Windows (
sys.platform == "win32") to preventUnicodeEncodeErrorin PowerShell/cmd when Rich tables emit non-ASCII characters (Cyrillic labels, box-drawing) - fix(portals): update Derzhstat description — wildcard dots in key-filter path return 404; dimension filters are now applied client-side after download
- feat: add
--header "Name: Value"option (repeatable) toget,constraints,values,info,treecommands — allows passing custom HTTP headers per invocation (e.g. API keys required in headers, not query string) - feat: expose
set_extra_headers()/get_extra_headers()in Python public API (opensdmx.base, re-exported fromopensdmx) - Extra headers merge over defaults;
User-Agentis protected and cannot be overridden via--header
- fix: always parse
OBS_FLAG,OBS_STATUS,CONF_STATUSas strings in CSV responses — Polars was inferring these SDMX observation attribute columns asi64, crashing when a provider returned flag codes such asc(confidential); confirmed on DerzhstatDF_SALARY_PAYMENT_STATUS
- feat: add
OPENSDMX_USER_AGENTenv var to override the HTTPUser-Agentheader globally - feat: add
user_agentfield to portal entries inportals.json— per-portal default User-Agent - feat: add
data_key_formatfield to portal entries ("dots"default |"empty") — controls whether the SDMX dimension key segment is appended to data URLs; providers that reject the dot notation (e.g. Derzhstat) use"empty" - feat: add
derzhstatpreset portal (State Statistics Service of Ukraine, agency SSSU) — 112+ dataflows including consumer prices and wages, accessible without authentication
Named after Nadiia Babynska-Virna, whose work on building a local MCP server for Ukrainian statistics identified these blockers and inspired this release.
- fix(tree):
opensdmx treeno longer downloads the full dataflow catalog during the stale-categorisation warning path on first run; the warning now uses cached dataflows only, soLoading category tree...does not stay active for unrelated network work - docs: add an explicit subrelease section to
docs/release.md - tests: add regression coverage to ensure
load_categories()does not callall_available()when no dataflow cache is present
- feat: parse
Category.descriptionalongsideCategory.nameand expose it ascat_descriptionin both the cache schema and--output json|csv - feat: ASCII tree appends the description dimmed+italic after the cat_id marker when populated — helps disambiguate opaque cat_ids when the provider fills the field
- note: ISTAT populates
Descriptionon ~1/1200 categories; Eurostat and OECD usage varies by scheme. For categories withoutDescription, rendering is unchanged - cache: the loader invalidates caches missing the new
cat_descriptioncolumn, forcing a fresh fetch on first use after upgrade - tests: 1 new pytest; 21/21 passing
- feat:
opensdmx tree --show-dataflows(alias-l) inlines dataflow leaves under each category in the ASCII rendering, labelled[df:ID]. Usesall_available()for dataflow descriptions — all data come from cache, no extra HTTP fetch. Stderr warning if output exceeds 200 dfs. - feat: the flag shortcuts the
tree --category X+search "" --category Xsequence when the user has picked a terminal branch — one command instead of two. JSON/CSV outputs remain categories-only (warning to stderr if--show-dataflowsis combined with--output json/csv). - skill: documents the shortcut and advises against using it on broad branches where it floods the output
- tests: 2 new pytest cases (df leaf rendering + regression safety); 20/20 passing
- feat: ASCII
treerendering now prefixes category IDs as[cat:ID](instead of[ID]) to disambiguate them from dataflow IDs - feat:
tree --categoryerror messages label dataflow IDs as[df:ID]and category IDs as[cat:ID], so the kind travels with the identifier when copied between terminals - skill:
sdmx-explorerdocuments the[cat:]/[df:]convention - tests: 1 new pytest verifies the
[cat:...]prefix in ASCII output; existing tests unchanged (18/18 passing) - CSV/JSON outputs unchanged — raw
cat_id/df_idcolumns preserved; the prefix lives only in ASCII rendering and error messages
- fix:
opensdmx tree --categorynow resolves categories bycat_path(previously only matched root-level cat_ids whosecat_id == cat_path; nested categories silently returned an empty subtree) - fix:
--depthis relative to--categorywhen set —--depth 1always means "node + direct children", fixing theOutOfBoundsErrorcrash when the chosen category sat deeper than the absolute depth limit - fix: passing a dataflow ID to
--categorynow yields a clear message ("is a dataflow, not a category") with suggestedinfo,search --category, andtree --categorycommands using the parent category. When the dataflow is categorised only under a different scheme, the hint surfaces the scheme mismatch instead of pointing at an unrelated cat_id. - fix:
[cat_id]labels in tree rendering now survive Rich markup — escaping the opening bracket makes them visible on Eurostat (where cat_ids liket_bop_6were previously consumed as style tags) - fix: empty subtree guard prints a descriptive message instead of crashing at
.row(0)on an empty polars frame - skill:
sdmx-explorerpromotestreeas primary discovery path (Step 1b); keywordsearchdemoted to Step 1c fallback; new hierarchical drill-down protocol mandates--depth 1at every level to avoid dumping large trees; new "cat_id vs df_id" guidance - tests: 4 new pytest cases on
treeerror paths and relative-depth semantics
- fix: semantic search now uses
search_document:/search_query:prefixes required bynomic-embed-text-v2-moe(without prefixes the model returned essentially random rankings on short queries) - feat: embedding text now includes
df_idand (when the category cache is populated) the dataflow's scheme/category names — dramatically improves recall on short titles (e.g.Prezzi al consumo→CPI Prezzi al consumo Prezzi) - docs: README documents richer embeddings via
opensdmx treeand adds tips on writing better semantic queries
- feat:
tree --category <cat_id>— browse subtree rooted at a category (requires--scheme) - fix:
tree --schemenow shows hint when a category ID is passed instead of a scheme ID (suggests correct--scheme --category) - fix:
load_datasetnow case-insensitive fordf_idanddf_structure_id—opensdmx info cpiandopensdmx info CPIboth work - docs: add
### Thematic treesection in README with real Eurostat 3-step tutorial (schemes → tree browse → category filter); shows cat_id retrieval via CSV output andsearch --categorydelta (502 → 1 result)
- fix: use SDMX 2.1 structure Accept header in
sdmx_request_xml; fixes OECD returning JSON on/dataflow(closes #15); unblocksall_available/searchon OECD and fillsdf_descriptioninsiblingsoutput - feat: add thematic category tree (SDMX categoryscheme + categorisation) via new
opensdmx treecommand (ASCII in table mode, flat JSON/CSV otherwise);--schemerenders tree,--depthlimits nesting - feat: add
--categoryfilter toopensdmx search(leaf id or dotted path); matches dataflow through categorisation - feat: add
opensdmx siblings <df_id>— shows dataflow siblings in each category (one group per membership); surfaces related variants that text search misses - feat: new
src/opensdmx/categories.pywith two-parquet cache (categories.parquet,categorisation.parquet), lazy fetch on firsttreecall, 7-day TTL viaCATEGORIES_CACHE_TTL, stale-df warning to stderr - feat: add
categories_supportedflag toportals.json(true: eurostat, istat, ecb, oecd, insee, abs, bis; false: comext, bundesbank, worldbank, imf) + new column inopensdmx providers - chore: add read-only MCP tool allowlist to
.claude/settings.local.json(mempalace search/get/list, playwright network/snapshot/screenshot, chrome-devtools network tools)
- chore: bump version to v0.3.34
- perf: memoize
_resolve_cache_base()viafunctools.lru_cache, keyed onOPENSDMX_CACHE_DIR, so the write-probe runs at most once per process instead of on every HTTP request - test: isolate
TestRateLimitLockviamonkeypatch+tmp_path(OPENSDMX_CACHE_DIRoverride) so it no longer writes under the real user cache dir - docs: fix
_rate_limit_check()docstring to reference the per-user rate-limit dir instead of/tmp - chore: bump version to v0.3.33
- fix: move rate-limit lock and timestamp files from the shared system tempdir to the per-user cache base (via
platformdirs), avoiding cross-user interference on multi-tenant hosts; cross-OS by construction - fix: derive provider cache/lock key from base URL hash when custom provider has no
agency_id, so two such providers don't share the same lock and get serialized together - test: cover cross-process flock — assert
portalocker.Lockis called with the per-provider lock path - chore: bump version to v0.3.32
- fix: serialize HTTP calls per provider with
portalockerflock, so the rate limit holds across concurrent processes (parallel timestamp-file readers could previously defeat it) - chore: bump ISTAT rate_limit 13s → 15s for a safer margin
- chore: bump version to v0.3.31
- fix(istat): use
/all/all?mode=availablefor constraints endpoint, returning only codes actually present in each dataflow (removes ambiguity between multiple "total" codes likeTvs9for SEX) - feat(cli): warn before first (slow) ISTAT constraints call; warning suppressed on cache hits
- fix: rate limiter records timestamp at HTTP call start (not end), so the 13s interval is measured from request sent, not response received
- feat: add Eurostat Comext as named provider (
--provider comext) for DS-prefixed datasets - fix: cache and rate-limit keys now use provider alias instead of agency_id, preventing collision between eurostat and comext (both ESTAT)
- chore: bump version to v0.3.27
- fix: replace warnings.warn with logging.warning in discovery.py and base.py
- chore: bump version to v0.3.26
- feat(search): add spinner during semantic search
- feat(plot): add xkcd theme support via --theme xkcd
- chore: bump version to v0.3.25
- feat: expose
__version__viaimportlib.metadata - fix: replace
assertwith proper error handling in guide.py - chore: add PyPI classifiers and keywords to pyproject.toml
- chore: bump version to v0.3.24
- docs(skill): update sdmx-explorer with providers capability columns
- chore: bump version to v0.3.23
- feat(providers): fill all capability columns from probe run; fix probe_providers.sh (IMF filters, OECD dataset)
- chore: bump version to v0.3.22
- feat(scripts): add probe_providers.sh — tests constraints and last_n for all built-in providers, saves report to tmp/
- feat(providers): add constraints and last_n capability columns; mark known support in portals.json
- chore: bump version to v0.3.21
- fix(discovery): resolve codelist from concept scheme when DSD lacks LocalRepresentation (fixes
valuesfor IMF provider)
- chore: bump version to v0.3.20
- feat(cli): add --grep flag to
valuesandconstraintscommands — filters codelist/constraint results by regex (case-insensitive, matches id or name)
- docs(validation): update validation-statgpt.md — add Round 2 (IMF WEO, 3 agents, 42/42 match); now covers both OECD and WEO convergence tests
- chore: bump version to v0.3.19
- feat(portals): add IMF as built-in provider (
--provider imf) — base URLhttps://api.imf.org/external/sdmx/2.1, agencyIMF.RES,catalog_agency=all; WEO dataflow confirmed working (3 dimensions: COUNTRY, INDICATOR, FREQUENCY) - docs(readme): add framing paragraph on AI+statistics accuracy problem, citing IMF StatGPT paper (2026)
- docs(validation): add
docs/validation-statgpt.md— public validation doc inspired by StatGPT paper; 3-agent convergence test (42/42 identical values), cross-source accuracy, repeatability with provenance metadata; linked from README - docs(validation): add
tmp/statgpt-tests/REPORT.md— full technical report with all 8 tests
- fix(worldbank): handle missing observation values (
[,0]→[null,0]) in SDMX-JSON responses — World Bank API returns invalid JSON for null observations
- feat(plot): add
--themeoption — supportsminimal(default),bw,classic,538,tufte,void,dark,light,gray - docs(skill): add
--themeto visualization.md options table - chore: bump version to v0.3.17
- feat(plot): add
--geom heatmapusinggeom_tile—--x= columns,--color= rows,--y= fill intensity - docs(skill): add heatmap to visualization.md geom table
- chore: bump version to v0.3.16
- feat(plot): add
--x-allflag to force all x-axis tick labels on discrete axes (e.g. quarterly labels) — usesscale_x_discrete(limits=...), works with--facetand--color - docs(skill): update visualization.md with
--x-all,--rotate-x,--colorsoptions and "missing x-axis labels" fix - chore: bump version to v0.3.15
- fix(cache): switch SQLite journal mode from WAL to DELETE — WAL creates
-wal/-shmfiles that cause "database is locked" errors when accessing the cache across WSL/Windows filesystem boundaries (closes #7) - chore: bump version to v0.3.14
- fix(cache): SQLite connections were never closed — replaced raw
sqlite3.connectcontext manager with proper open/commit/close pattern, added WAL mode and timeout to prevent lock contention - chore: bump version to v0.3.13
- fix(plot):
--geom barhnow renders correctly — fixed axis swap (--x= value,--y= category), prevented numeric value column from being cast to string, and corrected axis labels aftercoord_flip - docs(skill): update visualization.md — document
barhaxis convention (--x= value,--y= category)
- docs(skill): human-readable labels required in charts (Rule 5 in visualization.md)
- docs(skill): generalize Rule 3 — never mix individual units with aggregates
- docs(skill): add filter coherence principle to SKILL.md (individual vs. aggregate codes)
- chore: bump version to v0.3.10
- feat(cache): flexible cache directory —
OPENSDMX_CACHE_DIRenv var,platformdirs(XDG on Linux, OS-native on macOS/Windows),/tmp/opensdmx-{user}fallback if nothing is writable (closes #6)
- fix(cache): all SQLite
save_*calls wrapped in isolated try/except — cache failures no longer discard successfully fetched API data - fix(values):
opensdmx valuesno longer fails with "readonly database" when cache write fails; returns data anyway - fix(constraints):
opensdmx constraintswarning message now distinguishes cache errors from API errors
- feat(plot): add
--rotate-x Nflag to rotate x-axis labels by N degrees - feat(plot): add
--colors 'hex1,hex2,...'flag for custom categorical color palettes - fix(plot): bar/barh charts now display integer years (e.g. 2022) correctly instead of as dates (2022-01-01)
- docs(skill): update sdmx-explorer skill — fix facet note in SKILL.md, add x-axis label overlap fix in visualization.md
- docs(skill): add
references/color-guide.mdwith Okabe-Ito and Set2 palettes, colorblind rules, and plotnine snippets - docs(skill): add
references/cli-templates.mdwith ready-to-use parameter combinations for common chart scenarios
- feat(info): show Eurostat dataflow page URL in
opensdmx infooutput (table and JSON mode) - feat(portals): add
dataflow_page_urlfield to Eurostat entry in portals.json
- feat(plot):
--facet/--ncoloptions forfacet_wrap(small multiples) - feat(plot):
--timealias for--x(more intuitive column name override) - fix(plot):
schema_overridesnow uses the actual--xcolumn name instead of hardcodedTIME_PERIOD - fix(plot): error message now lists only the missing column(s) instead of both
- docs(skill): update visualization.md with facet options and small-multiples guidance
- feat: machine-to-machine output — global
--output table|json|csvflag on all metadata commands (search,info,values,constraints,providers); stdout = pure structured data, stderr = errors/warnings; spinners suppressed in non-table mode - feat: relevance ranking for standard search — multi-token AND filter on
df_description+df_id, synthetic score (id match ×3, start-of-desc ×2, occurrence count ×1), results sorted by score - feat: search default page size 20→50
- feat: search table now shows
scorecolumn
- docs: expand semantic search section in README with comparison table, 3 real examples, and sample output
- feat: add --all and --page to search command (paginate cache results)
- chore: bump version to v0.3.2
- feat: expose
run_query(),semantic_search(),build_embeddings()in public Python API - docs: add
sdmx-explorerskill installation guide (docs/skill/README.md) with screenshots - docs: add missing functions to Python API table in README
- docs: improve docstrings for
semantic_searchandbuild_embeddings
- test: add test_cli.py with 12 tests for _parse_extra_filters, _apply_provider, CLI commands
- fix: World Bank provider now works for data requests (closes #5)
- add
data_acceptfield in portals.json → sends correct Accept header for SDMX-JSON - add
data_path_suffix: "/"→ trailing slash required by WB API to avoid 307 redirect - add
follow_redirects=Trueto httpx.Client (global fix) - add
_parse_sdmx_json()parser for SDMX-JSON 1.0 responses - handle World Bank non-standard key order (dimensions sorted by keyPosition descending)
- add
- chore: bump version to v0.3.0
- feat: add
--query-fileoption togetcommand — saves query as YAML for later reuse - feat: add
runcommand — executes a query from a YAML file, output to stdout or--out - feat: YAML captures provider alias, provider_url, agency_id, filters with labels from cache
- feat:
runresolves provider via alias → URL+agency_id fallback chain - feat: add
build_query_dict()helper inutils.py - dep: add
pyyamldependency - docs: update README with query file workflow and
runcommand - docs: update sdmx-explorer skill to suggest saving queries as YAML templates
- chore: bump version to v0.2.9
- feat: add BIS (Bank for International Settlements) as built-in provider
- chore: bump version to v0.2.8
- feat: add
providerscommand listing curated SDMX providers with alias, name, description, agency - feat: add
descriptionfield to all 8 providers in portals.json
- chore: bump version to v0.2.7
- refactor: extract guide() logic to guide.py; cli.py -358 lines
- feat: add [guide] optional extras (chatlas, questionary); remove dead deps (duckdb, inquirerpy)
- test: add 11 mocked HTTP tests (sdmx_request_csv, all_available, get_data)
- fix: replace silent except Exception with specific exception types in discovery, embed, cli, guide
- chore: bump version to v0.2.6
- fix(bundesbank): add datastructure_agency=BBK to portals.json (ALL not accepted by Bundesbank API)
- fix(utils): handle default xmlns (prefix=None) in xml_parse() for known canonical namespaces — fixes World Bank WDI discovery
- fix(utils): xml_parse() now collects namespaces from all elements (not just root) — fixes World Bank support where
structurens is declared on a child element — closes #4
- fix(bundesbank): add
metadata_prefix: "metadata"to portals.json; add_struct_path()helper in discovery.py to prepend prefix to dataflow/datastructure/codelist paths — fixes #3
- Test: full workflow tested for
oecd,bundesbank,worldbankproviders - OECD: all 5 steps work (search, info, values, constraints, get)
- OECD: official rate limit is 60 data downloads/hour — applies to
getonly, not structure/metadata calls (search, info, values, constraints); currentrate_limit: 0.5in portals.json is fine - ECB, Eurostat: no rate limit officially documented
- Bundesbank: all steps fail —
/rest/dataflow/BBKreturns 404 ("Unknown path"); API routing broken - World Bank: all steps fail —
xml_parse()misses namespaces declared on child elements (not root);WDIdataflow exists but never discovered
- Feat:
getwarns when dataset has >5,000 series and no filters/limits are set - Feat:
get --yes/-yflag to bypass large-dataset confirmation - Probe request (
lastNObservations=1) used to count series before full download
- Fix: Polars dtype crash on mixed TIME_PERIOD (e.g. ECB ICP) — force Utf8 in CSV read
- Fix:
--outwith unsupported extension (e.g..xlsx) now raises clear error instead of silent wrong write - Fix:
--geom scatteraccepted as alias forpointinplot - Fix: invalid
--providername shows clean error with list of valid providers - Fix: dimension ID in
valuesandgetis now case-insensitive (e.g.FREQ=freq) - Fix:
UserWarningfor unknown dimension replaced with rich console warning - Fix:
blacklist --removereports "Not found" when entry doesn't exist (was always "Removed") - Fix:
search --nnow works in keyword mode too (was semantic-only) - Fix: default plot filename now uses dataset ID/stem instead of
chart.png - Fix: local CSV plot: TIME_PERIOD parsed as date so
scale_x_dateapplies correctly - Feat:
OPENSDMX_PROVIDERandOPENSDMX_AGENCYenv vars for session-wide provider config - Feat: custom SDMX URL accepted as
--providervalue (agency_idnow optional) - Feat:
searchshows total match count in table title (e.g. "10 of 8037") - Feat:
search --ndefault raised from 10 to 20 - Tests: 57 → 64 tests (new:
test_base.py, extendedtest_db_cache,test_discovery) - Docs: README updated — providers, env vars, output formats, examples
- ECB deep test: P1 URL bug (get without filters) NOT reproduced — resolved or was dataset-specific
- ECB deep test: P1 duplicates in
valuesNOT reproduced — resolved - New bug found:
get ICP --provider ecbcrashes with Polars dtype inference error on YYYY-MM TIME_PERIOD - Results documented in
tmp/ecb-test-results.md, evaluation updated intmp/cli-evaluation.md
- Fix: CI workflow — add ruff to dev dependencies, fix f-string and F401 lint errors
- Fix: configure ruff to ignore E402 (intentional import patterns)
- Feat:
plotcommand accepts local files (.csv, .tsv, .parquet) as input
- Translate all remaining Italian UI strings in
ai.pyandcli.pyto English - Remove Gemini-based query expansion from semantic search — now Ollama-only
- Remove
--no-expandand--verboseCLI flags fromsearch --semantic - Add synonym tip to
searchhelp and README - Document
GOOGLE_API_KEYrequirement in.env.example(forguideonly) - Add 22 tests:
test_discovery.py(set/reset_filters),test_db_cache.py(all SQLite cache ops)
- Fix: removed production debug log (
/tmp/guide_debug.log) fromai.py - Fix:
get_name_by_langcrashed on XML without "common" namespace - Feat: test suite from 4 to 35 tests — coverage on
utils.py,parse_time_period(),make_url_key() - Feat: CI with GitHub Actions (pytest + ruff on push/PR)
- Production-readiness evaluation in
docs/evaluation.md
- Feat: cache TTL configurabile via env vars (
OPENSDMX_DATAFLOWS_CACHE_TTL,OPENSDMX_METADATA_CACHE_TTL,OPENSDMX_CONSTRAINTS_CACHE_TTL); default sensati,.env.exampleaggiunto - Feat: setup testing con pytest, primi test su
cache_config - Feat: CLI agent-friendly —
guide --yes --dataset(non-interactive, auto-download),blacklist --remove, fail-fast for missing embeddings, examples with default provider note in all--help
- Fix
guide: removedlookup_dimension_valuesfrom AI model — now uses only constraint values - Fix
guide: "tutti"/"all" → selects TOTAL if available, otherwise no filter (never all individual codes) - Feat
guide: new flow — AI reads all constraints on first turn and proposes 2-3 tested scenarios; user chooses or customises dimension by dimension
- Fix:
_rate_limit_checknow writes tosys.stderrinstead ofprint()— avoids polluting stdout in pipes - Fix: HTTP errors now show status code and URL; suggest
opensdmx constraintson 400/404; usereraise=Truein tenacity to avoid wrapping inRetryError - Refactor: extracted
_parse_extra_filters()— removes duplication betweengetandplot - Feat:
get/plotsupport multiple values per dimension:--geo IT --geo FR→IT+FRin URL - Fix: removed hardcoded
curlfromguideresults panel
-
opensdmxwith no arguments shows version + help (instead of doing nothing) -
Fix OECD provider support:
search,info,constraints,getnow workportals.json: addedcatalog_agency: "all"(endpoint/dataflow/all) andconstraint_params: {}(noreferences=nonewhich returned 500)discovery.py: usescatalog_agencyfor catalog path; savesdf_idas{agencyID},{id}(e.g.OECD.SDD.STES,DSD_STES@DF_CLI) to correctly build data URLs- Eurostat and ISTAT providers unchanged
- New
opensdmx constraints <dataflow_id> [dimension]CLI command- No dimension: summary table (
dimension_id,n_values,samplefirst 3 codes) - With dimension: full
id/nametable of codes actually present, labels from codelist --providerflag supported; empty result surfaces as explicit error- Reuses existing
get_available_values()+ 7-day SQLite cache — zero new infrastructure
- No dimension: summary table (
- ISTAT CLI tests completed:
search,info,values,get,get --out,get --last-n,getmultiple values,plot,plotwith options — all ok - Reference dataset:
168_2withDATA_TYPE=13,MEASURE=4,REF_AREA=IT,COICOP_REV_ISTAT=00 - Fix bug:
plotignored--start-period/--end-period(treated as dimensions); added as typer options and passed toget_data()— #1 - Updated
docs/cli-test-examples.mdwith correct ISTAT example
portals.json: addeddata_format_paramfor Eurostat (SDMX-CSV); fixesget/plotcommands that returned 406base.py:sdmx_request_csvnow usesformat=query param when provider hasdata_format_param, elseAccept: text/csv(ISTAT and others)embed.py: query expansion via Gemini before embedding (_expand_query); translates to English + adds synonyms; default on,--no-expandto skip,--verboseto show expanded query- Tried replacing Ollama with
fastembed(nomic-ai/nomic-embed-text-v1.5-Q) — reverted: quality inferior especially for Italian queries;nomic-embed-text-v2-moevia Ollama remains the embedding backend - Added
docs/cli-test-examples.mdwith all non-AI CLI examples (Eurostat tested, ISTAT pending)
- New
portals.jsonbundled with 8 SDMX portals (eurostat, istat, ecb, oecd, insee, bundesbank, worldbank, abs) base.py:PROVIDERSloaded from JSON with_DEFAULTSmerge; custom providers get defaults toodiscovery.py:dataflow_params,constraint_endpoint,datastructure_agencyread from provider config- Fixed XML namespace normalization (
s/c/m→structure/common/message) for cross-portal compat - Fixed
_check_api_reachable: catches all exceptions, uses GET not HEAD - Added spinner to CLI commands (
search,info,values,get) guide/search --semantic: prompts to build embeddings if cache missingembed.py: guards against empty catalog and corrupted cache- Renamed package
istatpy→opensdmx; CLI entry pointistatpy→opensdmx - New provider system:
set_provider("eurostat"|"istat"|url),get_provider(); default is Eurostat (ESTAT) - Cache namespaced per provider:
~/.cache/opensdmx/{AGENCY_ID}/dataflows.parquet+cache.db - Rate limit per-provider (Eurostat: 0.5s, ISTAT: 13s); temp file
/tmp/opensdmx_{agency_id}_rate_limit.log istat_dataset→load_dataset;istat_get→fetch;istat_timeout→set_timeout- Removed
df_description_itcolumn; description language driven by provider config - All CLI commands accept
--provider/-pflag ai.pysystem prompt is now provider-aware (referencesagency_id, language-agnostic rules)embed.pyupdated: cache path dynamic per provider, removeddf_description_itREADME.mdrewritten foropensdmxwith Eurostat as default
cli.py guide: final result also showsistatpy getcommand alongside URL and curldb_cache.py: addeddescriptioncolumn toinvalid_datasets(auto migration);save_invalid_datasetnow accepts description; addedlist_invalid_datasets()anddelete_invalid_dataset()cli.py: newblacklistcommand — lists blacklisted datasets and allows removal via interactive checkboxdocs/database.md: new file with schema of all cache files (parquet + SQLite) and Mermaid ER diagramREADME.md: link todocs/database.mdin Caching section; addedblacklistto commands table
db_cache.py: newavailable_constraintstable; get/save cached for 7 daysdiscovery.py:get_available_values()now uses SQLite cache; simplified endpoint (references=none)ai.py:lookup_actual_valuesusesget_available_values()(cached) instead of raw sample; removed initial sample fetchcli.py guidestep 6b: code validation againstavailableconstraint(more reliable than codelist)cli.py guidestep 6c: filter combination validation with real sample (lastNObservations=1+ active filters); warn if 404
ai.py: addedlookup_actual_values(dimension_id)tool — samples real data withlastNObservations=1at session start; returns actual values (e.g.UNEMP,1,2) instead of theoretical codelist codes (e.g.UNEM_TI,M,F)lookup_dimension_valueskept for text descriptions; system prompt specifies filters use onlylookup_actual_values- Sample downloaded once at session start (single extra API call)
db_cache.py: addedinvalid_datasetstable;save_invalid_dataset(),get_invalid_dataset_ids()discovery.py:all_available()automatically filters invalid datasetscli.pyguide: API availability check moved BEFORE AI session (after dataset confirmation); if unavailable → mark as invalid, return to selection without wasting AI time
get_data/istat_get: addedfirst_n_observations(firstNObservationsSDMX) alongsidelast_n_observations- CLI
get: added--start-period,--end-period,--last-n,--first-noptions ai.py: system prompt hardened — AI MUST calllookup_dimension_valuesbefore proposing any code, no invented codes
- Fix
make_url_key: "." values now mapped to empty string → correct SDMX URL (A....instead ofA........) guide_session: chatlas warnings suppressed;_chat()helper handlesget_last_turn()None-safeFilterItem.codes: list[str]: multi-value support for dimensions (e.g. SEX = M+F)- Filter validation in
guide: checks each code individually against actual values lookup_dimension_valuestool: AI verifies codes autonomously without delegating to user- Cache moved to
~/.cache/istatpy/;df_description_itadded to catalog
- Added
df_description_itto dataflows (Italian name from SDMXName xml:lang="it") embed.py: embedding text ="en / it"for better semantic search in Italiansemantic_search(): also returnsdf_description_itguide: shows Italian description in dataset selection list
- Cache moved from
/tmp/to~/.cache/istatpy/(persistent across reboots) base.py: addedCACHE_DIR = Path.home() / ".cache" / "istatpy"withmkdirdb_cache.py,discovery.py,embed.py: useCACHE_DIRinstead oftempfile.gettempdir()
- Replaced
wizardandaskwithguide: semantic search + multi-turn AI conversation for filters ai.py: removedfind_dataset/find_filters; addedguide_session(ds, objective)— chatlas multi-turn with Gemini, extracts filters with user confirmationcli.py: removedwizardandaskcommands; addedguide [query]— paginated dataset selection + interactive AI session + final SDMX URLdocs/PRD.md: created PRD for guide flow
istatpy ask [objective]: full AI flow — finds dataset via semantic search, then filters; step-by-step confirmation (dataset → filters → download);--outoptionai.py:find_dataset()(usessearch_datasetstool) +find_filters()(usesget_values_for_dimensiontool); two separate calls for progressive confirmation- deps: added
chatlas[google]>=0.7topyproject.toml
-
embed.py: vector embeddings via ollamanomic-embed-text-v2-moe(768 dim), cached in/tmp/istatpy_embeddings.parquet(~11MB) -
istatpy embed: builds embeddings cache;istatpy search --semantic: cross-language semantic search -
db_cache.py: SQLite cache/tmp/istatpy_cache.db(TTL 7d) for dimensions and codelist values — cold 52s → cached 0.0s -
Fix: use
ALLinstead ofIT1as agency ondatastructureandcodelistendpoints (fixes 404 on cross-agency datasets) -
istatpy wizard: interactive dataset discovery, paginated results, fuzzy value filtering (InquirerPy), auto-select DATA_DOMAIN, SDMX URL output -
Rate limit: countdown timer (updates every 0.2s), interval raised to 13s
- Translated all user-facing messages to English (CLI + rate limiter)
- Translated
tasks/todo.mdto English
- CLI
istatpywith 4 commands:search,info,values,get(Typer + Rich) getaccepts dynamic filters--DIM VALUEviatyper.Contextgetoutput: CSV to stdout or file (csv/parquet/json) with--out- API reachability check at startup: lightweight HEAD request if no rate-limit log exists
- Rate limiter: minimum 12s between API calls, log in OS temp dir
- Dataflow cache:
istatpy_dataflows.parquetin OS temp dir, TTL 24h (avoids repeated heavy call inistat_dataset()) - Added
pyarrowdependency (required forpolars.to_pandas())
- Created
istatpyproject withuv init --package - Added
httpx,tenacity,lxml,polars,duckdb,plotnine - Implemented modules:
base.py,utils.py,discovery.py,retrieval.py - Public API exported in
__init__.py - All functions mirror
istatR: same names, same signaturesall_available(),search_dataset(),istat_dataset()dimensions_info(),get_dimension_values(),get_available_values()set_filters(),reset_filters()get_data(),istat_get(),istat_timeout()
- DataFrame: Polars (not pandas)
- Charts: plotnine (unemployment example in README)