@cyanheads/eurostat-mcp-server 0.11.1 → 0.11.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/AGENTS.md +6 -4
  2. package/CLAUDE.md +6 -4
  3. package/README.md +20 -17
  4. package/changelog/0.11.x/0.11.2.md +27 -0
  5. package/changelog/0.11.x/0.11.3.md +45 -0
  6. package/changelog/template.md +10 -3
  7. package/dist/config/server-config.js +1 -1
  8. package/dist/config/server-config.js.map +1 -1
  9. package/dist/mcp-server/resources/definitions/eurostat-dataset.resource.d.ts +14 -1
  10. package/dist/mcp-server/resources/definitions/eurostat-dataset.resource.d.ts.map +1 -1
  11. package/dist/mcp-server/resources/definitions/eurostat-dataset.resource.js +17 -0
  12. package/dist/mcp-server/resources/definitions/eurostat-dataset.resource.js.map +1 -1
  13. package/dist/mcp-server/tools/definitions/eurostat-browse-themes.tool.d.ts +1 -1
  14. package/dist/mcp-server/tools/definitions/eurostat-browse-themes.tool.js +2 -2
  15. package/dist/mcp-server/tools/definitions/eurostat-browse-themes.tool.js.map +1 -1
  16. package/dist/mcp-server/tools/definitions/eurostat-dataframe-describe.tool.js +2 -2
  17. package/dist/mcp-server/tools/definitions/eurostat-dataframe-describe.tool.js.map +1 -1
  18. package/dist/mcp-server/tools/definitions/eurostat-dataframe-drop.tool.js +1 -1
  19. package/dist/mcp-server/tools/definitions/eurostat-dataframe-drop.tool.js.map +1 -1
  20. package/dist/mcp-server/tools/definitions/eurostat-dataframe-query.tool.js +1 -1
  21. package/dist/mcp-server/tools/definitions/eurostat-dataframe-query.tool.js.map +1 -1
  22. package/dist/mcp-server/tools/definitions/eurostat-download-dataset.tool.d.ts +26 -12
  23. package/dist/mcp-server/tools/definitions/eurostat-download-dataset.tool.d.ts.map +1 -1
  24. package/dist/mcp-server/tools/definitions/eurostat-download-dataset.tool.js +181 -47
  25. package/dist/mcp-server/tools/definitions/eurostat-download-dataset.tool.js.map +1 -1
  26. package/dist/mcp-server/tools/definitions/eurostat-get-dataset-info.tool.d.ts +1 -1
  27. package/dist/mcp-server/tools/definitions/eurostat-get-dataset-info.tool.d.ts.map +1 -1
  28. package/dist/mcp-server/tools/definitions/eurostat-get-dataset-info.tool.js +11 -2
  29. package/dist/mcp-server/tools/definitions/eurostat-get-dataset-info.tool.js.map +1 -1
  30. package/dist/mcp-server/tools/definitions/eurostat-get-dimension-values.tool.d.ts +8 -6
  31. package/dist/mcp-server/tools/definitions/eurostat-get-dimension-values.tool.d.ts.map +1 -1
  32. package/dist/mcp-server/tools/definitions/eurostat-get-dimension-values.tool.js +149 -54
  33. package/dist/mcp-server/tools/definitions/eurostat-get-dimension-values.tool.js.map +1 -1
  34. package/dist/mcp-server/tools/definitions/eurostat-query-dataset.tool.d.ts +22 -8
  35. package/dist/mcp-server/tools/definitions/eurostat-query-dataset.tool.d.ts.map +1 -1
  36. package/dist/mcp-server/tools/definitions/eurostat-query-dataset.tool.js +215 -43
  37. package/dist/mcp-server/tools/definitions/eurostat-query-dataset.tool.js.map +1 -1
  38. package/dist/mcp-server/tools/definitions/eurostat-search-datasets.tool.d.ts +16 -6
  39. package/dist/mcp-server/tools/definitions/eurostat-search-datasets.tool.d.ts.map +1 -1
  40. package/dist/mcp-server/tools/definitions/eurostat-search-datasets.tool.js +67 -16
  41. package/dist/mcp-server/tools/definitions/eurostat-search-datasets.tool.js.map +1 -1
  42. package/dist/mcp-server/tools/narrowing-advice.d.ts +64 -3
  43. package/dist/mcp-server/tools/narrowing-advice.d.ts.map +1 -1
  44. package/dist/mcp-server/tools/narrowing-advice.js +160 -4
  45. package/dist/mcp-server/tools/narrowing-advice.js.map +1 -1
  46. package/dist/mcp-server/tools/notice-text.d.ts +18 -0
  47. package/dist/mcp-server/tools/notice-text.d.ts.map +1 -0
  48. package/dist/mcp-server/tools/notice-text.js +40 -0
  49. package/dist/mcp-server/tools/notice-text.js.map +1 -0
  50. package/dist/mcp-server/tools/value-list-paging.d.ts +11 -1
  51. package/dist/mcp-server/tools/value-list-paging.d.ts.map +1 -1
  52. package/dist/mcp-server/tools/value-list-paging.js +13 -1
  53. package/dist/mcp-server/tools/value-list-paging.js.map +1 -1
  54. package/dist/services/eurostat-bulk/eurostat-bulk-service.d.ts +7 -5
  55. package/dist/services/eurostat-bulk/eurostat-bulk-service.d.ts.map +1 -1
  56. package/dist/services/eurostat-bulk/eurostat-bulk-service.js +145 -40
  57. package/dist/services/eurostat-bulk/eurostat-bulk-service.js.map +1 -1
  58. package/dist/services/eurostat-bulk/types.d.ts +44 -4
  59. package/dist/services/eurostat-bulk/types.d.ts.map +1 -1
  60. package/dist/services/eurostat-bulk/types.js.map +1 -1
  61. package/dist/services/eurostat-catalogue/dimension-value-index.d.ts +81 -0
  62. package/dist/services/eurostat-catalogue/dimension-value-index.d.ts.map +1 -0
  63. package/dist/services/eurostat-catalogue/dimension-value-index.js +410 -0
  64. package/dist/services/eurostat-catalogue/dimension-value-index.js.map +1 -0
  65. package/dist/services/eurostat-catalogue/eurostat-catalogue-service.d.ts +100 -14
  66. package/dist/services/eurostat-catalogue/eurostat-catalogue-service.d.ts.map +1 -1
  67. package/dist/services/eurostat-catalogue/eurostat-catalogue-service.js +359 -90
  68. package/dist/services/eurostat-catalogue/eurostat-catalogue-service.js.map +1 -1
  69. package/dist/services/eurostat-catalogue/search-text.d.ts +12 -0
  70. package/dist/services/eurostat-catalogue/search-text.d.ts.map +1 -0
  71. package/dist/services/eurostat-catalogue/search-text.js +31 -0
  72. package/dist/services/eurostat-catalogue/search-text.js.map +1 -0
  73. package/dist/services/eurostat-catalogue/types.d.ts +11 -0
  74. package/dist/services/eurostat-catalogue/types.d.ts.map +1 -1
  75. package/dist/services/eurostat-codelists.d.ts +2 -1
  76. package/dist/services/eurostat-codelists.d.ts.map +1 -1
  77. package/dist/services/eurostat-codelists.js +2 -1
  78. package/dist/services/eurostat-codelists.js.map +1 -1
  79. package/dist/services/eurostat-data/eurostat-data-service.d.ts +30 -2
  80. package/dist/services/eurostat-data/eurostat-data-service.d.ts.map +1 -1
  81. package/dist/services/eurostat-data/eurostat-data-service.js +209 -19
  82. package/dist/services/eurostat-data/eurostat-data-service.js.map +1 -1
  83. package/dist/services/eurostat-data/sdmx-metadata.d.ts.map +1 -1
  84. package/dist/services/eurostat-data/sdmx-metadata.js +4 -20
  85. package/dist/services/eurostat-data/sdmx-metadata.js.map +1 -1
  86. package/dist/services/eurostat-data/types.d.ts +108 -4
  87. package/dist/services/eurostat-data/types.d.ts.map +1 -1
  88. package/dist/services/eurostat-data/types.js.map +1 -1
  89. package/dist/services/eurostat-filters.d.ts +18 -6
  90. package/dist/services/eurostat-filters.d.ts.map +1 -1
  91. package/dist/services/eurostat-filters.js +24 -7
  92. package/dist/services/eurostat-filters.js.map +1 -1
  93. package/dist/services/eurostat-periods.d.ts +54 -1
  94. package/dist/services/eurostat-periods.d.ts.map +1 -1
  95. package/dist/services/eurostat-periods.js +101 -1
  96. package/dist/services/eurostat-periods.js.map +1 -1
  97. package/dist/services/xml-entities.d.ts +13 -0
  98. package/dist/services/xml-entities.d.ts.map +1 -0
  99. package/dist/services/xml-entities.js +31 -0
  100. package/dist/services/xml-entities.js.map +1 -0
  101. package/package.json +7 -7
  102. package/server.json +5 -5
  103. package/skills/eurostat-guide/SKILL.md +6 -5
  104. package/skills/eurostat-guide/references/sql-analysis.md +21 -5
@@ -53,7 +53,9 @@ export declare const createEurostatQueryDataset: (clampLimits: boolean) => impor
53
53
  end: z.ZodOptional<z.ZodString>;
54
54
  }, z.core.$strip>;
55
55
  missingObsCount: z.ZodNumber;
56
+ absentCellCount: z.ZodNumber;
56
57
  unmatchedValues: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodArray<z.ZodString>>>;
58
+ emptyValues: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodArray<z.ZodString>>>;
57
59
  }, z.core.$strip>, readonly [{
58
60
  readonly reason: "not_found";
59
61
  readonly code: JsonRpcErrorCode.NotFound;
@@ -62,8 +64,8 @@ export declare const createEurostatQueryDataset: (clampLimits: boolean) => impor
62
64
  }, {
63
65
  readonly reason: "no_results";
64
66
  readonly code: JsonRpcErrorCode.NotFound;
65
- readonly when: "The query matched no observation cells — including Eurostat HTTP-200 error id 100. The dataset is valid, but no cell carries a value or a status flag for that filter combination and period range. When Eurostat returns the empty table, the error names the filter values that matched nothing (data.unmatchedValues), the selected periods that carry no value (data.matchedPeriods, the newest 24, with data.matchedPeriodCount counting all of them), and, for a range that selected no period, whether it misses the coverage, holds no whole period, or holds no published one. When error id 100 answers a query that set geo_level, the error names geo_level as the likely cause, and lists the dataset's dimensions when it has no \"geo\" dimension.";
66
- readonly recovery: "Verify dimension values with eurostat_get_dimension_values and keep the period range inside the dataset coverage, holding a whole period; an unmatched value, an out-of-coverage range and a range inside one period all return no data rather than an error. When the error names geo_level, drop it instead, and filter the \"geo\" codes the query then returns, or the dimension that holds geography.";
67
+ readonly when: "The query matched no observation cells — including Eurostat HTTP-200 error id 100. The dataset is valid, but no cell carries a value or a status flag for that filter combination and period range. When Eurostat returns the empty table, the error names the filter values that matched nothing (data.unmatchedValues) and, when the values of the dataset are read within 5 s, the codes and labels of each such dimension holding at most 25, or the total and a value search for each value sent otherwise (data.validValues), the selected periods that carry no value (data.matchedPeriods, the newest 24, with data.matchedPeriodCount counting all of them), and, for a range that selected no period, whether it misses the coverage, holds no whole period, or holds no published one. When error id 100 answers a query that set geo_level, the error names geo_level as the likely cause, and lists the dataset's dimensions when it has no \"geo\" dimension.";
68
+ readonly recovery: "Replace an unmatched value with a code the error lists, or verify dimension values with eurostat_get_dimension_values, and keep the period range inside the dataset coverage, holding a whole period; an unmatched value, an out-of-coverage range and a range inside one period all return no data rather than an error. When the error names geo_level, drop it instead, and filter the \"geo\" codes the query then returns, or the dimension that holds geography.";
67
69
  }, {
68
70
  readonly reason: "invalid_period";
69
71
  readonly code: JsonRpcErrorCode.ValidationError;
@@ -78,8 +80,8 @@ export declare const createEurostatQueryDataset: (clampLimits: boolean) => impor
78
80
  }, {
79
81
  readonly reason: "invalid_dimension";
80
82
  readonly code: JsonRpcErrorCode.ValidationError;
81
- readonly when: "A dimension code in filters does not exist in this dataset (HTTP 400, Eurostat error id 150), or a filter key names a request control (lang, format, geoLevel, sinceTimePeriod, untilTimePeriod, lastTimePeriod, in any case) rather than a dimension — rejected before any request, naming the input that sets it.";
82
- readonly recovery: "Use eurostat_get_dataset_info to see valid dimension codes for this dataset; set a control through its own input (lang, geo_level, since_period, until_period, last_n_periods), not filters.";
83
+ readonly when: "A filter key is not a dimension of this dataset; the error names every such key and lists the dataset's dimensions in key order (HTTP 400, Eurostat error id 150), or only the one key Eurostat names when the dataset's structure cannot be read. A filter key that names a request control (lang, format, geoLevel, sinceTimePeriod, untilTimePeriod, lastTimePeriod, in any case) rather than a dimension fails here too, rejected before any request and naming the input that sets it.";
84
+ readonly recovery: "Rename each filter key the error names to one of the dimensions it lists. When it lists none, eurostat_get_dataset_info lists them. Set a request control through its own input (lang, geo_level, since_period, until_period, last_n_periods), not filters.";
83
85
  }, {
84
86
  readonly reason: "conflicting_params";
85
87
  readonly code: JsonRpcErrorCode.ValidationError;
@@ -95,6 +97,11 @@ export declare const createEurostatQueryDataset: (clampLimits: boolean) => impor
95
97
  readonly code: JsonRpcErrorCode.ServiceUnavailable;
96
98
  readonly when: "The Statistics API answered HTTP 5xx or could not be reached on every attempt of the retry loop. The error is marked retryable, except for HTTP 501, which is marked not retryable.";
97
99
  readonly recovery: "The failure is on Eurostat's side, not in the request: when the error is marked retryable, call again in a few minutes.";
100
+ }, {
101
+ readonly reason: "retry_deadline_exceeded";
102
+ readonly code: JsonRpcErrorCode.Timeout;
103
+ readonly when: "The Statistics API did not complete the query within the server's request deadline (30 s by default), every attempt and backoff included: Eurostat can take longer than that to prepare an uncached extraction, or to send a large answer.";
104
+ readonly recovery: "Repeat the call in a minute or two when Eurostat had not started answering, read the same slice with eurostat_download_dataset on a deployment with a dataframe canvas, or filter the dimensions the query left open.";
98
105
  }], {
99
106
  readonly appliedFilters: z.ZodObject<{
100
107
  filters: z.ZodRecord<z.ZodString, z.ZodArray<z.ZodString>>;
@@ -154,7 +161,9 @@ export declare const eurostatQueryDataset: import("@cyanheads/mcp-ts-core").Tool
154
161
  end: z.ZodOptional<z.ZodString>;
155
162
  }, z.core.$strip>;
156
163
  missingObsCount: z.ZodNumber;
164
+ absentCellCount: z.ZodNumber;
157
165
  unmatchedValues: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodArray<z.ZodString>>>;
166
+ emptyValues: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodArray<z.ZodString>>>;
158
167
  }, z.core.$strip>, readonly [{
159
168
  readonly reason: "not_found";
160
169
  readonly code: JsonRpcErrorCode.NotFound;
@@ -163,8 +172,8 @@ export declare const eurostatQueryDataset: import("@cyanheads/mcp-ts-core").Tool
163
172
  }, {
164
173
  readonly reason: "no_results";
165
174
  readonly code: JsonRpcErrorCode.NotFound;
166
- readonly when: "The query matched no observation cells — including Eurostat HTTP-200 error id 100. The dataset is valid, but no cell carries a value or a status flag for that filter combination and period range. When Eurostat returns the empty table, the error names the filter values that matched nothing (data.unmatchedValues), the selected periods that carry no value (data.matchedPeriods, the newest 24, with data.matchedPeriodCount counting all of them), and, for a range that selected no period, whether it misses the coverage, holds no whole period, or holds no published one. When error id 100 answers a query that set geo_level, the error names geo_level as the likely cause, and lists the dataset's dimensions when it has no \"geo\" dimension.";
167
- readonly recovery: "Verify dimension values with eurostat_get_dimension_values and keep the period range inside the dataset coverage, holding a whole period; an unmatched value, an out-of-coverage range and a range inside one period all return no data rather than an error. When the error names geo_level, drop it instead, and filter the \"geo\" codes the query then returns, or the dimension that holds geography.";
175
+ readonly when: "The query matched no observation cells — including Eurostat HTTP-200 error id 100. The dataset is valid, but no cell carries a value or a status flag for that filter combination and period range. When Eurostat returns the empty table, the error names the filter values that matched nothing (data.unmatchedValues) and, when the values of the dataset are read within 5 s, the codes and labels of each such dimension holding at most 25, or the total and a value search for each value sent otherwise (data.validValues), the selected periods that carry no value (data.matchedPeriods, the newest 24, with data.matchedPeriodCount counting all of them), and, for a range that selected no period, whether it misses the coverage, holds no whole period, or holds no published one. When error id 100 answers a query that set geo_level, the error names geo_level as the likely cause, and lists the dataset's dimensions when it has no \"geo\" dimension.";
176
+ readonly recovery: "Replace an unmatched value with a code the error lists, or verify dimension values with eurostat_get_dimension_values, and keep the period range inside the dataset coverage, holding a whole period; an unmatched value, an out-of-coverage range and a range inside one period all return no data rather than an error. When the error names geo_level, drop it instead, and filter the \"geo\" codes the query then returns, or the dimension that holds geography.";
168
177
  }, {
169
178
  readonly reason: "invalid_period";
170
179
  readonly code: JsonRpcErrorCode.ValidationError;
@@ -179,8 +188,8 @@ export declare const eurostatQueryDataset: import("@cyanheads/mcp-ts-core").Tool
179
188
  }, {
180
189
  readonly reason: "invalid_dimension";
181
190
  readonly code: JsonRpcErrorCode.ValidationError;
182
- readonly when: "A dimension code in filters does not exist in this dataset (HTTP 400, Eurostat error id 150), or a filter key names a request control (lang, format, geoLevel, sinceTimePeriod, untilTimePeriod, lastTimePeriod, in any case) rather than a dimension — rejected before any request, naming the input that sets it.";
183
- readonly recovery: "Use eurostat_get_dataset_info to see valid dimension codes for this dataset; set a control through its own input (lang, geo_level, since_period, until_period, last_n_periods), not filters.";
191
+ readonly when: "A filter key is not a dimension of this dataset; the error names every such key and lists the dataset's dimensions in key order (HTTP 400, Eurostat error id 150), or only the one key Eurostat names when the dataset's structure cannot be read. A filter key that names a request control (lang, format, geoLevel, sinceTimePeriod, untilTimePeriod, lastTimePeriod, in any case) rather than a dimension fails here too, rejected before any request and naming the input that sets it.";
192
+ readonly recovery: "Rename each filter key the error names to one of the dimensions it lists. When it lists none, eurostat_get_dataset_info lists them. Set a request control through its own input (lang, geo_level, since_period, until_period, last_n_periods), not filters.";
184
193
  }, {
185
194
  readonly reason: "conflicting_params";
186
195
  readonly code: JsonRpcErrorCode.ValidationError;
@@ -196,6 +205,11 @@ export declare const eurostatQueryDataset: import("@cyanheads/mcp-ts-core").Tool
196
205
  readonly code: JsonRpcErrorCode.ServiceUnavailable;
197
206
  readonly when: "The Statistics API answered HTTP 5xx or could not be reached on every attempt of the retry loop. The error is marked retryable, except for HTTP 501, which is marked not retryable.";
198
207
  readonly recovery: "The failure is on Eurostat's side, not in the request: when the error is marked retryable, call again in a few minutes.";
208
+ }, {
209
+ readonly reason: "retry_deadline_exceeded";
210
+ readonly code: JsonRpcErrorCode.Timeout;
211
+ readonly when: "The Statistics API did not complete the query within the server's request deadline (30 s by default), every attempt and backoff included: Eurostat can take longer than that to prepare an uncached extraction, or to send a large answer.";
212
+ readonly recovery: "Repeat the call in a minute or two when Eurostat had not started answering, read the same slice with eurostat_download_dataset on a deployment with a dataframe canvas, or filter the dimensions the query left open.";
199
213
  }], {
200
214
  readonly appliedFilters: z.ZodObject<{
201
215
  filters: z.ZodRecord<z.ZodString, z.ZodArray<z.ZodString>>;
@@ -1 +1 @@
1
- {"version":3,"file":"eurostat-query-dataset.tool.d.ts","sourceRoot":"","sources":["../../../../src/mcp-server/tools/definitions/eurostat-query-dataset.tool.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAsB,CAAC,EAAE,MAAM,wBAAwB,CAAC;AAE/D,OAAO,EAAE,gBAAgB,EAAiB,MAAM,+BAA+B,CAAC;AAuChF,+DAA+D;AAC/D,eAAO,MAAM,0BAA0B,gBAAiB,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAujB3D,CAAC;AAEL,sFAAsF;AACtF,eAAO,MAAM,oBAAoB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAAmC,CAAC"}
1
+ {"version":3,"file":"eurostat-query-dataset.tool.d.ts","sourceRoot":"","sources":["../../../../src/mcp-server/tools/definitions/eurostat-query-dataset.tool.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAsB,CAAC,EAAE,MAAM,wBAAwB,CAAC;AAE/D,OAAO,EAAE,gBAAgB,EAAiB,MAAM,+BAA+B,CAAC;AAyDhF,+DAA+D;AAC/D,eAAO,MAAM,0BAA0B,gBAAiB,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAwpB3D,CAAC;AAEL,sFAAsF;AACtF,eAAO,MAAM,oBAAoB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAAmC,CAAC"}
@@ -5,16 +5,18 @@
5
5
  import { tool, z } from '@cyanheads/mcp-ts-core';
6
6
  import { CanvasIdSchema } from '@cyanheads/mcp-ts-core/canvas';
7
7
  import { JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors';
8
+ import { escapeMarkdownInline } from '@cyanheads/mcp-ts-core/utils';
8
9
  import { clampReadLimit } from '../clamp-read-limit.js';
9
10
  import { hoistDimensions } from '../hoist-dimensions.js';
10
- import { dimensionsToNarrow, narrowingAdvice } from '../narrowing-advice.js';
11
+ import { dimensionsToNarrow, dimensionValuesForHint, formatValues, narrowingAdvice, RENAME_FILTER_KEYS_HINT, UNKNOWN_FILTER_KEY_WHEN, unmatchedValuesAdvice, } from '../narrowing-advice.js';
12
+ import { countOf, listEmptyValues, spanOf } from '../notice-text.js';
11
13
  import { acquireCanvas, getCanvas, isCanvasFailure, newTableName, } from '../../../services/canvas-accessor.js';
12
- import { getEurostatDataService, observationRowSchema, } from '../../../services/eurostat-data/eurostat-data-service.js';
14
+ import { getEurostatDataService, observationRowSchema, TIME_DIMENSION_KEYS, } from '../../../services/eurostat-data/eurostat-data-service.js';
13
15
  import { GEO_LEVEL_VALUES, OBS_CAP, } from '../../../services/eurostat-data/types.js';
14
- import { dropUnsetFilters } from '../../../services/eurostat-filters.js';
16
+ import { normalizeFilters, unknownDimensionsMessage, unknownFilterKeys, } from '../../../services/eurostat-filters.js';
15
17
  import { isComextDataset } from '../../../services/eurostat-hosts.js';
16
18
  import { outageAnswer } from '../../../services/eurostat-outage.js';
17
- import { cutNotice, cutSelector, PERIOD_FORMS, resolvePeriods, } from '../../../services/eurostat-periods.js';
19
+ import { cutNotice, cutSelector, normalizePeriod, PERIOD_FORMS, resolvePeriods, } from '../../../services/eurostat-periods.js';
18
20
  /** Upper bound on the deterministic observation prefix returned inline. */
19
21
  const PREVIEW_MAX = 500;
20
22
  /**
@@ -35,11 +37,11 @@ export const createEurostatQueryDataset = (clampLimits) => tool('eurostat_query_
35
37
  filters: z
36
38
  .record(z.string(), z.array(z.string()))
37
39
  .default({})
38
- .describe('Dimension filters as a map of dimension code → array of valid values. Example: {"unit": ["CP_MEUR"], "na_item": ["B1GQ"], "geo": ["DE", "FR"]}. An empty array is treated as no filter for that dimension and is dropped from the request, and so is a blank or whitespace-only value ([""] is no filter, ["", "DE"] is ["DE"]). Dimension codes match in any case ("GEO" is geo). Keys are dimensions only: one naming a request control (lang, format, geoLevel, sinceTimePeriod, untilTimePeriod, lastTimePeriod, in any case) is rejected as invalid_dimension — use the lang, geo_level, since_period, until_period and last_n_periods inputs. Do not include "geo" here if using geo_level, nor "time"/"time_period" if using since_period, until_period or last_n_periods. A value that matches nothing contributes no rows rather than an error; the response names it in unmatchedValues, or in the no_results error when nothing matched at all. eurostat_get_dimension_values lists the valid values.'),
40
+ .describe('Dimension filters as a map of dimension code → array of valid values. Example: {"unit": ["CP_MEUR"], "na_item": ["B1GQ"], "geo": ["DE", "FR"]}. An empty array is treated as no filter for that dimension and is dropped from the request, and so is a blank or whitespace-only value ([""] is no filter, ["", "DE"] is ["DE"]). No Eurostat code contains "+" or ".": a value holding "+" is split into separate values, read as OR (["DE+FR"] is ["DE", "FR"]), and a value holding "." stays one value that matches nothing. Dimension codes match in any case ("GEO" is geo). Keys are dimensions only: one naming a request control (lang, format, geoLevel, sinceTimePeriod, untilTimePeriod, lastTimePeriod, in any case) is rejected as invalid_dimension — use the lang, geo_level, since_period, until_period and last_n_periods inputs. Do not include "geo" here if using geo_level, nor "time"/"time_period" if using since_period, until_period or last_n_periods. A value that matches nothing contributes no rows rather than an error; the response names it in unmatchedValues, or in the no_results error when nothing matched at all. A valid value with no cell in the selected periods contributes no rows either; the response names it in emptyValues. eurostat_get_dimension_values lists the valid values.'),
39
41
  geo_level: z
40
42
  .enum(GEO_LEVEL_VALUES)
41
43
  .optional()
42
- .describe('Filter by NUTS hierarchy level. Mutually exclusive with a "geo" key in filters. Options: "aggregate" (EU/EA totals), "country" (41 member/candidate states), "nuts1" (127 major regions), "nuts2" (309 basic regions), "nuts3" (1,343 small regions).'),
44
+ .describe('Filter by NUTS hierarchy level. Mutually exclusive with a "geo" key in filters. Options: "aggregate" (EU/EA totals), "country" (member and candidate states), "nuts1" (major regions), "nuts2" (basic regions), "nuts3" (small regions). How many codes a level holds varies by dataset; a code of the level with no cell in the match is named in emptyValues.'),
43
45
  since_period: z
44
46
  .string()
45
47
  .optional()
@@ -53,7 +55,7 @@ export const createEurostatQueryDataset = (clampLimits) => tool('eurostat_query_
53
55
  .multipleOf(1)
54
56
  .min(1)
55
57
  .optional()
56
- .describe("Return only the N most recent dataset time positions. Selected periods without populated cells for this slice are disclosed in a notice on successful queries. N counts back from the dataset's latest period, not from the latest period published for this slice, so a slice that lags can come back empty — increase N, or replace last_n_periods with until_period ending at a period the slice has published. Mutually exclusive with since_period and until_period."),
58
+ .describe("Return only the N most recent dataset time positions. Selected periods without populated cells for this slice are disclosed in a notice on successful queries, which advises a larger N only when every such period follows the slice's latest period with a cell. N counts back from the dataset's latest period, not from the latest period published for this slice, so a slice that lags can come back empty — increase N, or replace last_n_periods with a since_period/until_period range ending at a period the slice has published (until_period alone selects every period up to it). Mutually exclusive with since_period and until_period."),
57
59
  preview_limit: (clampLimits
58
60
  ? z.number().multipleOf(1).min(1)
59
61
  : z.number().int().min(1).max(PREVIEW_MAX))
@@ -144,11 +146,18 @@ export const createEurostatQueryDataset = (clampLimits) => tool('eurostat_query_
144
146
  .describe('Time coverage of everything matched — the same set obsCount counts, so it can reach periods absent from observations when truncated is true. Each bound is omitted when neither the match nor Eurostat report it — an omitted bound is unknown, not empty.'),
145
147
  missingObsCount: z
146
148
  .number()
147
- .describe('Number of matched observations carrying no numeric value, counted across everything matched rather than only the returned rows. Covers both unavailable and withheld cells — a slice can be wholly confidential, so this equalling obsCount does not mean the data is absent.'),
149
+ .describe('Number of matched observations carrying no numeric value, counted across everything matched rather than only the returned rows. Covers both unavailable and withheld cells — a slice can be wholly confidential, so this equalling obsCount does not mean the data is absent. A missing observation is a cell Eurostat publishes without a number (with a flag, a confidentiality marker, or text); a period of a frequency the series carries in which it has no cell at all is an absent cell, counted in absentCellCount instead.'),
150
+ absentCellCount: z
151
+ .number()
152
+ .describe("Periods the matched series lack: for each series (one combination of the non-time dimensions) with at least one cell in the match, the periods of a frequency it carries in which Eurostat publishes no cell for it, with no value and no flag. Only periods from that frequency's first to its latest period with a cell in any series count; a period before or after that span, which no series has a cell in, is named in the notice instead. Absent cells are not observations, so obsCount, missingObsCount, observations and staged rows leave them out. A series lacks no period of a frequency it does not carry: an annual series beside a monthly one lacks no months. 0 when every series has a cell in every period of those spans."),
148
153
  unmatchedValues: z
149
154
  .record(z.string(), z.array(z.string()))
150
155
  .optional()
151
156
  .describe('Filter values that matched nothing in the dataset, keyed by dimension code and spelled as sent (matching ignores case). The observations cover only the values that did match. Omitted when every filter value matched.'),
157
+ emptyValues: z
158
+ .record(z.string(), z.array(z.string()))
159
+ .optional()
160
+ .describe('Values the query selects that the dataset has but this match carries no cell for, with no value and no flag in the selected periods, so they contribute no rows. Keyed by dimension code: filter values spelled as sent (matching ignores case), and under geo_level every "geo" code of that level with no cell. Never a value unmatchedValues holds, a time value, or a value of a dimension that neither filters nor geo_level selects. Such a value can carry data in other periods or beside other filter values. Omitted when every selected value has a cell.'),
152
161
  }),
153
162
  enrichment: {
154
163
  appliedFilters: z
@@ -165,15 +174,14 @@ export const createEurostatQueryDataset = (clampLimits) => tool('eurostat_query_
165
174
  notice: z
166
175
  .string()
167
176
  .optional()
168
- .describe('Composed guidance for a period a bound falls inside, which Eurostat excludes, unmatched filter values, selected last_n_periods positions with no populated cells, inline previews, staging and unavailable explicit staging. Period gaps list up to the newest 24 periods with an omitted count; staged matches give the describe-then-query sequence. Omitted when none of these disclosures applies.'),
177
+ .describe("Composed guidance for a period a bound falls inside, which Eurostat excludes, unmatched filter values, values with no cell (emptyValues), selected last_n_periods positions with no populated cells, periods no series has a cell in before or after a frequency's span with cells, absent cells (periods a series lacks inside that span, with how many series lack any), inline previews, staging and unavailable explicit staging. Period gaps list up to the newest 24 periods and values with no cell up to 24, each with an omitted count; staged matches give the describe-then-query sequence. Omitted when none of these disclosures applies."),
169
178
  },
170
179
  enrichmentTrailer: {
171
180
  appliedFilters: {
172
181
  render: (f) => {
173
182
  const parts = [];
174
- const filterKeys = Object.keys(f.filters);
175
- if (filterKeys.length > 0) {
176
- parts.push(`- **Filters:** ${filterKeys.map((k) => `${k}=[${(f.filters[k] ?? []).join(', ')}]`).join('; ')}`);
183
+ if (Object.keys(f.filters).length > 0) {
184
+ parts.push(`- **Filters:** ${formatValues(f.filters, escapeMarkdownInline)}`);
177
185
  }
178
186
  if (f.geoLevel)
179
187
  parts.push(`- **Geo level:** ${f.geoLevel}`);
@@ -198,8 +206,8 @@ export const createEurostatQueryDataset = (clampLimits) => tool('eurostat_query_
198
206
  {
199
207
  reason: 'no_results',
200
208
  code: JsonRpcErrorCode.NotFound,
201
- when: 'The query matched no observation cells — including Eurostat HTTP-200 error id 100. The dataset is valid, but no cell carries a value or a status flag for that filter combination and period range. When Eurostat returns the empty table, the error names the filter values that matched nothing (data.unmatchedValues), the selected periods that carry no value (data.matchedPeriods, the newest 24, with data.matchedPeriodCount counting all of them), and, for a range that selected no period, whether it misses the coverage, holds no whole period, or holds no published one. When error id 100 answers a query that set geo_level, the error names geo_level as the likely cause, and lists the dataset\'s dimensions when it has no "geo" dimension.',
202
- recovery: 'Verify dimension values with eurostat_get_dimension_values and keep the period range inside the dataset coverage, holding a whole period; an unmatched value, an out-of-coverage range and a range inside one period all return no data rather than an error. When the error names geo_level, drop it instead, and filter the "geo" codes the query then returns, or the dimension that holds geography.',
209
+ when: 'The query matched no observation cells — including Eurostat HTTP-200 error id 100. The dataset is valid, but no cell carries a value or a status flag for that filter combination and period range. When Eurostat returns the empty table, the error names the filter values that matched nothing (data.unmatchedValues) and, when the values of the dataset are read within 5 s, the codes and labels of each such dimension holding at most 25, or the total and a value search for each value sent otherwise (data.validValues), the selected periods that carry no value (data.matchedPeriods, the newest 24, with data.matchedPeriodCount counting all of them), and, for a range that selected no period, whether it misses the coverage, holds no whole period, or holds no published one. When error id 100 answers a query that set geo_level, the error names geo_level as the likely cause, and lists the dataset\'s dimensions when it has no "geo" dimension.',
210
+ recovery: 'Replace an unmatched value with a code the error lists, or verify dimension values with eurostat_get_dimension_values, and keep the period range inside the dataset coverage, holding a whole period; an unmatched value, an out-of-coverage range and a range inside one period all return no data rather than an error. When the error names geo_level, drop it instead, and filter the "geo" codes the query then returns, or the dimension that holds geography.',
203
211
  },
204
212
  {
205
213
  reason: 'invalid_period',
@@ -217,8 +225,8 @@ export const createEurostatQueryDataset = (clampLimits) => tool('eurostat_query_
217
225
  {
218
226
  reason: 'invalid_dimension',
219
227
  code: JsonRpcErrorCode.ValidationError,
220
- when: 'A dimension code in filters does not exist in this dataset (HTTP 400, Eurostat error id 150), or a filter key names a request control (lang, format, geoLevel, sinceTimePeriod, untilTimePeriod, lastTimePeriod, in any case) rather than a dimension — rejected before any request, naming the input that sets it.',
221
- recovery: 'Use eurostat_get_dataset_info to see valid dimension codes for this dataset; set a control through its own input (lang, geo_level, since_period, until_period, last_n_periods), not filters.',
228
+ when: `${UNKNOWN_FILTER_KEY_WHEN} (HTTP 400, Eurostat error id 150), or only the one key Eurostat names when the dataset's structure cannot be read. A filter key that names a request control (lang, format, geoLevel, sinceTimePeriod, untilTimePeriod, lastTimePeriod, in any case) rather than a dimension fails here too, rejected before any request and naming the input that sets it.`,
229
+ recovery: `${RENAME_FILTER_KEYS_HINT} When it lists none, eurostat_get_dataset_info lists them. Set a request control through its own input (lang, geo_level, since_period, until_period, last_n_periods), not filters.`,
222
230
  },
223
231
  {
224
232
  reason: 'conflicting_params',
@@ -238,6 +246,12 @@ export const createEurostatQueryDataset = (clampLimits) => tool('eurostat_query_
238
246
  when: 'The Statistics API answered HTTP 5xx or could not be reached on every attempt of the retry loop. The error is marked retryable, except for HTTP 501, which is marked not retryable.',
239
247
  recovery: "The failure is on Eurostat's side, not in the request: when the error is marked retryable, call again in a few minutes.",
240
248
  },
249
+ {
250
+ reason: 'retry_deadline_exceeded',
251
+ code: JsonRpcErrorCode.Timeout,
252
+ when: "The Statistics API did not complete the query within the server's request deadline (30 s by default), every attempt and backoff included: Eurostat can take longer than that to prepare an uncached extraction, or to send a large answer.",
253
+ recovery: 'Repeat the call in a minute or two when Eurostat had not started answering, read the same slice with eurostat_download_dataset on a deployment with a dataframe canvas, or filter the dimensions the query left open.',
254
+ },
241
255
  ],
242
256
  async handler(request, ctx) {
243
257
  const preview = clampReadLimit(request.preview_limit, PREVIEW_MAX, 'preview_limit', 'eurostat_query_dataset', ctx);
@@ -265,15 +279,22 @@ export const createEurostatQueryDataset = (clampLimits) => tool('eurostat_query_
265
279
  // refusals carry none and take the generic one below.
266
280
  const serviceHint = data?.recovery?.hint;
267
281
  if (data && reason === 'no_results') {
268
- const explained = explainNoResults(input.dataset_code, data, {
269
- lastN: input.last_n_periods,
270
- ranged: Boolean(sinceP || untilP),
271
- }) ??
282
+ // The one extra read this path makes: an empty reply with an envelope names the
283
+ // unmatched values, and a bare one (geo_level's read below) names none.
284
+ const metadata = data.unmatchedValues && (await dimensionValuesForHint(input.dataset_code, ctx));
285
+ const valueAdvice = data.unmatchedValues && metadata
286
+ ? unmatchedValuesAdvice(input.dataset_code, data.unmatchedValues, metadata)
287
+ : undefined;
288
+ const explained = explainNoResults(input.dataset_code, data, { lastN: input.last_n_periods, ranged: Boolean(sinceP || untilP) }, valueAdvice) ??
272
289
  (input.geo_level
273
290
  ? await explainGeoLevel(input.dataset_code, input.geo_level, ctx)
274
291
  : undefined);
275
292
  throw ctx.fail('no_results', explained?.message ?? err.message, {
276
293
  ...(data.unmatchedValues && { unmatchedValues: data.unmatchedValues }),
294
+ ...(valueAdvice &&
295
+ Object.keys(valueAdvice.validValues).length > 0 && {
296
+ validValues: valueAdvice.validValues,
297
+ }),
277
298
  ...(data.matchedPeriods && {
278
299
  matchedPeriods: data.matchedPeriods.slice(-MATCHED_PERIODS_LISTED),
279
300
  matchedPeriodCount: data.matchedPeriods.length,
@@ -282,7 +303,7 @@ export const createEurostatQueryDataset = (clampLimits) => tool('eurostat_query_
282
303
  hint: explained?.hint ??
283
304
  `No observation cells matched. Verify dimension values for "${input.dataset_code}" using eurostat_get_dimension_values, and check the period range is inside the dataset's coverage — an unmatched value and an out-of-coverage range both return no data rather than an error.`,
284
305
  },
285
- });
306
+ }, { literals: valueAdvice?.literals ?? [] });
286
307
  }
287
308
  if (reason === 'invalid_period') {
288
309
  throw ctx.fail('invalid_period', err.message);
@@ -290,7 +311,7 @@ export const createEurostatQueryDataset = (clampLimits) => tool('eurostat_query_
290
311
  if (reason === 'async_response') {
291
312
  const dimensions = await dimensionsToNarrow(input.dataset_code, [], ctx);
292
313
  const filtered = [
293
- ...Object.keys(dropUnsetFilters(input.filters)),
314
+ ...Object.keys(normalizeFilters(input.filters)),
294
315
  ...(input.geo_level ? ['geo'] : []),
295
316
  ];
296
317
  throw ctx.fail('async_response', err.message, {
@@ -307,6 +328,20 @@ export const createEurostatQueryDataset = (clampLimits) => tool('eurostat_query_
307
328
  });
308
329
  }
309
330
  if (reason === 'invalid_dimension') {
331
+ // Eurostat's id 150 names one undefined key per reply, so the structure names them all.
332
+ const order = serviceHint
333
+ ? undefined
334
+ : await dimensionsToNarrow(input.dataset_code, [], ctx);
335
+ const unknown = order
336
+ ? unknownFilterKeys(order, Object.keys(normalizeFilters(input.filters)).filter((key) => !TIME_DIMENSION_KEYS.has(key.toLowerCase())))
337
+ : [];
338
+ if (order && unknown.length > 0) {
339
+ throw ctx.fail('invalid_dimension', unknownDimensionsMessage(unknown, order), {
340
+ unknownDimensions: unknown,
341
+ dimensionOrder: order,
342
+ recovery: { hint: RENAME_FILTER_KEYS_HINT },
343
+ }, { cause: err, literals: order });
344
+ }
310
345
  throw ctx.fail('invalid_dimension', err.message, {
311
346
  recovery: {
312
347
  hint: serviceHint ??
@@ -330,11 +365,20 @@ export const createEurostatQueryDataset = (clampLimits) => tool('eurostat_query_
330
365
  },
331
366
  });
332
367
  }
368
+ if (data && reason === 'retry_deadline_exceeded') {
369
+ const { deadlineMs, elapsedMs, retryAttempts, headersReceived } = data;
370
+ const dimensions = await dimensionsToNarrow(input.dataset_code, [], ctx);
371
+ const advice = deadlineAdvice(input, { sinceP, untilP }, dimensions, {
372
+ headersReceived,
373
+ canvas: getCanvas() !== undefined,
374
+ });
375
+ throw ctx.fail('retry_deadline_exceeded', err.message, { deadlineMs, elapsedMs, retryAttempts, recovery: { hint: advice.hint } }, { cause: err, literals: advice.literals });
376
+ }
333
377
  throw err;
334
378
  }
335
379
  // The service bounds `observations` to the requested preview, so its length says nothing
336
380
  // about match size. `obsCount` is the full total and is what truncation turns on.
337
- const { appliedFilters, periodCuts, rows, unpopulatedPeriods, ...queryResult } = result;
381
+ const { absentSeries, appliedFilters, latestWithCell, outsideSpans, periodCuts, rows, unpopulatedPeriods, ...queryResult } = result;
338
382
  const truncated = result.obsCount > OBS_CAP;
339
383
  /**
340
384
  * Stage the whole match on explicit request or above the independent
@@ -370,6 +414,7 @@ export const createEurostatQueryDataset = (clampLimits) => tool('eurostat_query_
370
414
  obsCount: result.obsCount,
371
415
  truncated,
372
416
  missingObsCount: result.missingObsCount,
417
+ absentCellCount: result.absentCellCount,
373
418
  ...(staged && { canvasId: staged.canvasId, tableName: staged.tableName }),
374
419
  });
375
420
  // Sourced from the service's applied set, not raw input: blank values and zero-length
@@ -391,23 +436,30 @@ export const createEurostatQueryDataset = (clampLimits) => tool('eurostat_query_
391
436
  if (input.last_n_periods && unpopulatedPeriods?.length) {
392
437
  const listed = unpopulatedPeriods.slice(-MATCHED_PERIODS_LISTED);
393
438
  const omitted = unpopulatedPeriods.length - listed.length;
394
- notices.push(`${countOf(unpopulatedPeriods.length, 'selected period')} (${listed.join(', ')}${omitted ? `; ${countOf(omitted, 'earlier period')} omitted` : ''}) ${unpopulatedPeriods.length === 1 ? 'carries' : 'carry'} no populated cells anywhere in this match. last_n_periods counts dataset time positions, not periods with populated cells for this slice; preview_limit does not select periods. Increase last_n_periods, or replace last_n_periods with until_period ending at a period this slice has published.`);
439
+ notices.push(`${countOf(unpopulatedPeriods.length, 'selected period')} (${listed.join(', ')}${omitted ? `; ${countOf(omitted, 'earlier period')} omitted` : ''}) ${unpopulatedPeriods.length === 1 ? 'carries' : 'carry'} no populated cells anywhere in this match. last_n_periods counts dataset time positions, not periods with populated cells for this slice; preview_limit does not select periods.${lastNAdvice(input.last_n_periods, unpopulatedPeriods, outsideSpans, latestWithCell)}`);
440
+ }
441
+ if (outsideSpans)
442
+ notices.push(outsideSpanNotice(outsideSpans));
443
+ if (absentSeries) {
444
+ notices.push(`${countOf(result.absentCellCount, 'absent cell')} in ${absentSeries.affected.toLocaleString('en-US')} of ${absentSeries.total.toLocaleString('en-US')} series: periods a series lacks at a frequency it carries (no value, no flag). They are not observations: obsCount, missingObsCount, the rows and any staged table leave them out, so a per-period total across series covers only the series present.`);
395
445
  }
396
446
  if (input.stage && !availableCanvas) {
397
447
  notices.push('Dataframe staging is unavailable on this deployment, so stage=true did not stage any rows. Use dimension filters or period controls to reduce the match.');
398
448
  }
399
449
  if (result.unmatchedValues) {
400
- notices.push(`These filter values matched nothing and contribute no rows: ${formatValues(result.unmatchedValues)}. Check the valid codes for ${joinAnd(Object.keys(result.unmatchedValues))} with eurostat_get_dimension_values.`);
450
+ notices.push(`These filter values matched nothing and contribute no rows: ${formatValues(result.unmatchedValues, escapeMarkdownInline)}. Check the valid codes for ${joinAnd(Object.keys(result.unmatchedValues).map((dim) => escapeMarkdownInline(dim)))} with eurostat_get_dimension_values.`);
401
451
  }
452
+ if (result.emptyValues)
453
+ notices.push(emptyValuesNotice(result.emptyValues));
402
454
  if (result.obsCount > result.observations.length) {
403
455
  // This dataset's own dimensions: a DS-* flow has no geo, unit, or na_item to name.
404
456
  const filterOn = `dimension filters (${result.dimensionsUsed.filter((dim) => dim !== 'time').join(', ')})`;
405
457
  notices.push(staged
406
- ? `preview_limit=${input.preview_limit.toLocaleString()} returns the first ${result.observations.length.toLocaleString()} of ${result.obsCount.toLocaleString()} matched rows inline; it does not reduce the match. All ${staged.stagedRowCount.toLocaleString()} matched rows are staged as table "${staged.tableName}" on canvas "${staged.canvasId}". Call eurostat_dataframe_describe with that canvas_id first to confirm the table and columns, then call eurostat_dataframe_query. Use ${filterOn} or a period range when the match itself should be smaller.`
407
- : `preview_limit=${input.preview_limit.toLocaleString()} returns the first ${result.observations.length.toLocaleString()} of ${result.obsCount.toLocaleString()} matched rows inline; it does not reduce the match. ${availableCanvas ? 'Set stage=true to retain every matched row on a dataframe canvas. ' : ''}Use ${filterOn} or a period range to reduce the match itself.`);
458
+ ? `preview_limit=${input.preview_limit.toLocaleString('en-US')} returns the first ${result.observations.length.toLocaleString('en-US')} of ${result.obsCount.toLocaleString('en-US')} matched rows inline; it does not reduce the match. All ${staged.stagedRowCount.toLocaleString('en-US')} matched rows are staged as table \`${staged.tableName}\` on canvas \`${staged.canvasId}\`. Call eurostat_dataframe_describe with that canvas_id first to confirm the table and columns, then call eurostat_dataframe_query. Use ${filterOn} or a period range when the match itself should be smaller.`
459
+ : `preview_limit=${input.preview_limit.toLocaleString('en-US')} returns the first ${result.observations.length.toLocaleString('en-US')} of ${result.obsCount.toLocaleString('en-US')} matched rows inline; it does not reduce the match. ${availableCanvas ? 'Set stage=true to retain every matched row on a dataframe canvas. ' : ''}Use ${filterOn} or a period range to reduce the match itself.`);
408
460
  }
409
461
  if (staged && result.obsCount <= result.observations.length) {
410
- notices.push(`All ${staged.stagedRowCount.toLocaleString()} matched rows are staged as table "${staged.tableName}" on canvas "${staged.canvasId}". Call eurostat_dataframe_describe with that canvas_id first to confirm the table and columns, then call eurostat_dataframe_query.`);
462
+ notices.push(`All ${staged.stagedRowCount.toLocaleString('en-US')} matched rows are staged as table \`${staged.tableName}\` on canvas \`${staged.canvasId}\`. Call eurostat_dataframe_describe with that canvas_id first to confirm the table and columns, then call eurostat_dataframe_query.`);
411
463
  }
412
464
  if (notices.length > 0)
413
465
  ctx.enrich.notice(notices.join(' '));
@@ -426,12 +478,15 @@ export const createEurostatQueryDataset = (clampLimits) => tool('eurostat_query_
426
478
  : ' — the match crossed the 5,000-row staging threshold. Add dimension filters or a period range to reduce the match.';
427
479
  const lines = [
428
480
  `# ${result.datasetLabel} (\`${result.datasetCode}\`)`,
429
- `**Observations:** ${result.obsCount} (${result.missingObsCount} missing) | **Period:** ${period}`,
481
+ `**Observations:** ${result.obsCount} (${result.missingObsCount} missing) | **Period:** ${period} | **Absent cells:** ${result.absentCellCount}`,
430
482
  `**Truncated:** ${result.truncated}${truncationNote}`,
431
483
  `**Dimensions:** ${result.dimensionsUsed.join(', ')}`,
432
484
  ];
433
485
  if (result.unmatchedValues) {
434
- lines.push(`**Unmatched filter values:** ${formatValues(result.unmatchedValues)} — these matched nothing, so the rows cover only the other values; check them with eurostat_get_dimension_values`);
486
+ lines.push(`**Unmatched filter values:** ${formatValues(result.unmatchedValues, escapeMarkdownInline)} — these matched nothing, so the rows cover only the other values; check them with eurostat_get_dimension_values`);
487
+ }
488
+ if (result.emptyValues) {
489
+ lines.push(`**Values with no cell:** ${formatValues(result.emptyValues, escapeMarkdownInline)} — in the dataset but with no value or flag in this match, so the rows cover only the other values`);
435
490
  }
436
491
  if (result.obsCount > result.observations.length) {
437
492
  lines.push(`**Inline preview:** first ${result.observations.length} of ${result.obsCount} matched rows — preview_limit changes only this prefix; filters and period controls reduce the match itself`);
@@ -466,22 +521,61 @@ export const createEurostatQueryDataset = (clampLimits) => tool('eurostat_query_
466
521
  });
467
522
  /** Default-on read-limit policy; startup selects the deployment policy explicitly. */
468
523
  export const eurostatQueryDataset = createEurostatQueryDataset(true);
469
- /** `geo=[DE, XX]; age=[ZZZ]` — the same rendering the applied-filters trailer uses. */
470
- function formatValues(values) {
471
- return Object.entries(values)
472
- .map(([dim, codes]) => `${dim}=[${codes.join(', ')}]`)
473
- .join('; ');
524
+ /** The values a query selects with no cell in the match. */
525
+ function emptyValuesNotice(values) {
526
+ const { list, total } = listEmptyValues(values);
527
+ const one = total === 1;
528
+ return `${countOf(total, 'value')} the query selects ${one ? 'exists' : 'exist'} in the dataset but ${one ? 'has' : 'have'} no cell in this match, so ${one ? 'it contributes' : 'they contribute'} no rows: ${list}. A value can still carry data in other periods or beside other filter values.`;
529
+ }
530
+ /**
531
+ * Name each frequency's periods that no series has a cell in, outside the span from its first
532
+ * to its latest period with one: those `absentCellCount` leaves out.
533
+ */
534
+ function outsideSpanNotice(spans) {
535
+ const carry = (periods) => `${spanOf(periods)} ${periods.length === 1 ? 'carries' : 'carry'} no cell in any series`;
536
+ const clauses = spans.flatMap((span) => {
537
+ if (span.kind === 'empty') {
538
+ return [
539
+ `${carry(span.periods)}, the only ${span.periods.length === 1 ? 'period' : 'periods'} of its frequency in this match`,
540
+ ];
541
+ }
542
+ return [
543
+ ...(span.before.length > 0
544
+ ? [`${carry(span.before)}, before the first period with one (${span.first})`]
545
+ : []),
546
+ ...(span.after.length > 0
547
+ ? [`${carry(span.after)}, after the latest period with one (${span.last})`]
548
+ : []),
549
+ ];
550
+ });
551
+ return `${clauses.join('; ')}. absentCellCount leaves such periods out: it counts only periods from a frequency's first to its latest period with a cell.`;
552
+ }
553
+ /**
554
+ * The closing advice of the last-N notice. A larger N reaches only earlier periods, so it is
555
+ * advised only when every empty selected period follows its frequency's span (or belongs to a
556
+ * frequency with no cell in the window); when the slice starts inside the window, the notice
557
+ * says so instead, and an empty period inside a span gets no advice. With one frequency the
558
+ * empty tail is exactly `trailing` positions, so N + trailing ends the window at `latest`.
559
+ * The alternative is a range: `until_period` excludes `last_n_periods`, so alone it would
560
+ * select every period up to `latest`.
561
+ */
562
+ function lastNAdvice(lastN, unpopulated, outside, latest) {
563
+ if (outside?.some((span) => span.kind === 'edges' && span.before.length > 0)) {
564
+ return ' This slice starts later than the selection, so a larger last_n_periods only reaches earlier periods.';
565
+ }
566
+ const trailing = (outside ?? []).reduce((n, span) => n + (span.kind === 'empty' ? span.periods.length : span.after.length), 0);
567
+ if (!latest || trailing !== unpopulated.length)
568
+ return '';
569
+ const raise = outside?.length === 1 && outside[0]?.kind === 'edges'
570
+ ? `Set last_n_periods to ${lastN + trailing} for the ${lastN} periods ending at ${latest}`
571
+ : 'Increase last_n_periods';
572
+ return ` ${raise}, or set a since_period/until_period range ending at ${latest}, the latest period with a cell in this match; until_period alone selects every period up to it.`;
474
573
  }
475
574
  function joinAnd(items) {
476
575
  return items.length <= 1
477
576
  ? (items[0] ?? '')
478
577
  : `${items.slice(0, -1).join(', ')} and ${items.at(-1)}`;
479
578
  }
480
- /** `2026-08`, or `1975 – 1980` for several periods. */
481
- function spanOf(periods) {
482
- return periods.length === 1 ? (periods[0] ?? '') : `${periods[0]} – ${periods.at(-1)}`;
483
- }
484
- const countOf = (n, noun) => `${n.toLocaleString()} ${noun}${n === 1 ? '' : 's'}`;
485
579
  /**
486
580
  * Turn the diagnosis an empty JSON-stat match carries into a message and a
487
581
  * recovery hint, or `undefined` when the reply offered none (the HTTP-200
@@ -491,8 +585,10 @@ const countOf = (n, noun) => `${n.toLocaleString()} ${noun}${n === 1 ? '' : 's'}
491
585
  * by how the caller chose them: `last_n_periods` counts back from the dataset's
492
586
  * latest period rather than the slice's, an explicit range can fall outside a
493
587
  * slice's narrower coverage, and with neither the combination itself is empty.
588
+ * `valueAdvice`, when the dataset's values were read, replaces the generic pointer for
589
+ * every unmatched dimension but those it left `unresolved`.
494
590
  */
495
- function explainNoResults(datasetCode, diagnosis, periods) {
591
+ function explainNoResults(datasetCode, diagnosis, periods, valueAdvice) {
496
592
  const { unmatchedValues, matchedPeriods, emptyRange } = diagnosis;
497
593
  if (!unmatchedValues && !matchedPeriods && !emptyRange)
498
594
  return;
@@ -500,7 +596,11 @@ function explainNoResults(datasetCode, diagnosis, periods) {
500
596
  const next = [];
501
597
  if (unmatchedValues) {
502
598
  found.push(`These filter values match nothing in "${datasetCode}": ${formatValues(unmatchedValues)}.`);
503
- next.push(`Check the valid codes for ${joinAnd(Object.keys(unmatchedValues))} with eurostat_get_dimension_values.`);
599
+ const unchecked = valueAdvice?.unresolved ?? Object.keys(unmatchedValues);
600
+ next.push(...(valueAdvice?.sentences ?? []));
601
+ if (unchecked.length > 0) {
602
+ next.push(`Check the valid codes for ${joinAnd(unchecked)} with eurostat_get_dimension_values.`);
603
+ }
504
604
  }
505
605
  if (emptyRange) {
506
606
  const { oldest, latest } = emptyRange.coverage;
@@ -533,7 +633,7 @@ function explainNoResults(datasetCode, diagnosis, periods) {
533
633
  ? `The last period (${selected}) carries no value`
534
634
  : `None of the last ${periods.lastN} periods (${selected}) carries a value`;
535
635
  found.push(`${empty} for this slice: last_n_periods counts back from the dataset's latest period, not from the latest period published for this slice.`);
536
- next.push('Raise last_n_periods, or replace it with an until_period ending at a period this slice has published.');
636
+ next.push('Raise last_n_periods, or replace it with a since_period/until_period range ending at a period this slice has published; until_period alone selects every period up to it.');
537
637
  }
538
638
  else if (periods.ranged) {
539
639
  found.push(`The requested range selects ${countOf(matchedPeriods.length, 'period')} (${selected}), none carrying a value for this slice.`);
@@ -575,4 +675,76 @@ async function explainGeoLevel(datasetCode, geoLevel, ctx) {
575
675
  hint: `Drop geo_level. To select geography, filter whichever of ${listed} holds it, with codes from eurostat_get_dimension_values.`,
576
676
  };
577
677
  }
678
+ /**
679
+ * The recovery for a query that outlasted the Statistics API deadline, built from its input,
680
+ * with each route offered only where it works.
681
+ *
682
+ * A repeat helps only a deadline that cut Eurostat off before it answered: it goes on
683
+ * preparing the extraction and caches it, while an answer cut mid-transfer takes as long
684
+ * again. The bulk endpoint serves the same slice, but without a canvas the download keeps
685
+ * only its preview, so the route needs one, and it takes neither `last_n_periods`,
686
+ * `geo_level` nor a time filter: each becomes the period inputs or `filters.geo`, and with no
687
+ * `geo` dimension (or none known) `geo_level` has no download equivalent. Narrowing names the
688
+ * dimensions the query left open, as a too-large refusal does. `literals` holds the values
689
+ * the hint quotes for sending back.
690
+ */
691
+ function deadlineAdvice(input, periods, dimensions, state) {
692
+ const code = input.dataset_code;
693
+ const applied = normalizeFilters(input.filters);
694
+ const keys = Object.keys(applied);
695
+ const timeKey = keys.find((key) => TIME_DIMENSION_KEYS.has(key.toLowerCase()));
696
+ const literals = [code];
697
+ const sentences = [];
698
+ if (state.headersReceived === false) {
699
+ sentences.push('Eurostat goes on preparing the extraction after the request is dropped and caches it, so repeat this call in a minute or two.');
700
+ }
701
+ if (state.canvas && (!input.geo_level || dimensions?.includes('geo'))) {
702
+ const quoted = (value) => {
703
+ literals.push(value);
704
+ return `"${value}"`;
705
+ };
706
+ const parts = [`dataset_code "${code}"`];
707
+ if (keys.some((key) => key !== timeKey))
708
+ parts.push('the same filters');
709
+ if (input.geo_level) {
710
+ parts.push(`filters.geo set to the codes eurostat_get_dimension_values lists for dimension "geo" at geo_level "${input.geo_level}"`);
711
+ }
712
+ if (periods.sinceP)
713
+ parts.push(`since_period ${quoted(periods.sinceP)}`);
714
+ if (periods.untilP)
715
+ parts.push(`until_period ${quoted(periods.untilP)}`);
716
+ if (input.last_n_periods) {
717
+ parts.push(input.last_n_periods === 1
718
+ ? 'since_period set to the latest period eurostat_get_dimension_values lists for dimension "time"'
719
+ : `since_period set to the earliest of the last ${input.last_n_periods} periods eurostat_get_dimension_values lists for dimension "time"`);
720
+ }
721
+ if (timeKey) {
722
+ const span = periodSpan(applied[timeKey] ?? []);
723
+ parts.push(span
724
+ ? `since_period ${quoted(span.since)} and until_period ${quoted(span.until)} in place of filters.${timeKey}`
725
+ : `since_period/until_period in place of filters.${timeKey}`);
726
+ }
727
+ sentences.push(`The bulk endpoint serves the same slice: call eurostat_download_dataset with ${joinAnd(parts)}.`);
728
+ }
729
+ sentences.push(narrowingAdvice(code, dimensions, [...keys, ...(input.geo_level ? ['geo'] : [])]));
730
+ return { hint: sentences.join(' '), literals };
731
+ }
732
+ /**
733
+ * The range spanning a time filter's periods, as the bounds to send, or `undefined` when one
734
+ * is not a period literal this server reads.
735
+ */
736
+ function periodSpan(values) {
737
+ let first;
738
+ let last;
739
+ for (const value of values) {
740
+ const period = normalizePeriod(value.trim());
741
+ if ('problem' in period)
742
+ return;
743
+ if (!first || period.firstDay < first.firstDay)
744
+ first = period;
745
+ if (!last || period.lastDay > last.lastDay)
746
+ last = period;
747
+ }
748
+ return first && last ? { since: first.period, until: last.period } : undefined;
749
+ }
578
750
  //# sourceMappingURL=eurostat-query-dataset.tool.js.map