bps-mcp-server 0.16.3 → 0.16.4

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 (96) hide show
  1. package/README.md +19 -3
  2. package/dist/client/allstats-client.d.ts +2 -0
  3. package/dist/client/allstats-client.d.ts.map +1 -1
  4. package/dist/client/allstats-client.js +62 -34
  5. package/dist/client/allstats-client.js.map +1 -1
  6. package/dist/client/bps-client.d.ts +14 -5
  7. package/dist/client/bps-client.d.ts.map +1 -1
  8. package/dist/client/bps-client.js +215 -23
  9. package/dist/client/bps-client.js.map +1 -1
  10. package/dist/client/endpoints.d.ts +7 -0
  11. package/dist/client/endpoints.d.ts.map +1 -1
  12. package/dist/client/endpoints.js +43 -5
  13. package/dist/client/endpoints.js.map +1 -1
  14. package/dist/client/fields.d.ts +27 -0
  15. package/dist/client/fields.d.ts.map +1 -0
  16. package/dist/client/fields.js +105 -0
  17. package/dist/client/fields.js.map +1 -0
  18. package/dist/client/types.d.ts +46 -22
  19. package/dist/client/types.d.ts.map +1 -1
  20. package/dist/index.js +19 -0
  21. package/dist/index.js.map +1 -1
  22. package/dist/resources/domain.resources.js +2 -2
  23. package/dist/resources/domain.resources.js.map +1 -1
  24. package/dist/server.js +1 -1
  25. package/dist/server.js.map +1 -1
  26. package/dist/services/data-formatter.d.ts +5 -1
  27. package/dist/services/data-formatter.d.ts.map +1 -1
  28. package/dist/services/data-formatter.js +55 -38
  29. package/dist/services/data-formatter.js.map +1 -1
  30. package/dist/services/dynamic-data-utils.d.ts +35 -0
  31. package/dist/services/dynamic-data-utils.d.ts.map +1 -0
  32. package/dist/services/dynamic-data-utils.js +132 -0
  33. package/dist/services/dynamic-data-utils.js.map +1 -0
  34. package/dist/services/file-store.d.ts +5 -1
  35. package/dist/services/file-store.d.ts.map +1 -1
  36. package/dist/services/file-store.js +16 -5
  37. package/dist/services/file-store.js.map +1 -1
  38. package/dist/services/intent-detector.d.ts.map +1 -1
  39. package/dist/services/intent-detector.js +10 -10
  40. package/dist/services/intent-detector.js.map +1 -1
  41. package/dist/services/learning.d.ts +2 -0
  42. package/dist/services/learning.d.ts.map +1 -1
  43. package/dist/services/learning.js +30 -9
  44. package/dist/services/learning.js.map +1 -1
  45. package/dist/services/variable-search.d.ts +50 -0
  46. package/dist/services/variable-search.d.ts.map +1 -0
  47. package/dist/services/variable-search.js +482 -0
  48. package/dist/services/variable-search.js.map +1 -0
  49. package/dist/tools/allstats.tools.d.ts.map +1 -1
  50. package/dist/tools/allstats.tools.js +13 -3
  51. package/dist/tools/allstats.tools.js.map +1 -1
  52. package/dist/tools/analysis.tools.d.ts.map +1 -1
  53. package/dist/tools/analysis.tools.js +291 -533
  54. package/dist/tools/analysis.tools.js.map +1 -1
  55. package/dist/tools/csa.tools.d.ts.map +1 -1
  56. package/dist/tools/csa.tools.js +13 -4
  57. package/dist/tools/csa.tools.js.map +1 -1
  58. package/dist/tools/dynamic-data.tools.d.ts.map +1 -1
  59. package/dist/tools/dynamic-data.tools.js +66 -23
  60. package/dist/tools/dynamic-data.tools.js.map +1 -1
  61. package/dist/tools/glossary.tools.d.ts +2 -1
  62. package/dist/tools/glossary.tools.d.ts.map +1 -1
  63. package/dist/tools/glossary.tools.js +30 -1
  64. package/dist/tools/glossary.tools.js.map +1 -1
  65. package/dist/tools/infographic.tools.d.ts.map +1 -1
  66. package/dist/tools/infographic.tools.js +7 -9
  67. package/dist/tools/infographic.tools.js.map +1 -1
  68. package/dist/tools/news.tools.d.ts.map +1 -1
  69. package/dist/tools/news.tools.js +2 -7
  70. package/dist/tools/news.tools.js.map +1 -1
  71. package/dist/tools/publication.tools.d.ts.map +1 -1
  72. package/dist/tools/publication.tools.js +12 -4
  73. package/dist/tools/publication.tools.js.map +1 -1
  74. package/dist/tools/reference.tools.d.ts.map +1 -1
  75. package/dist/tools/reference.tools.js +3 -1
  76. package/dist/tools/reference.tools.js.map +1 -1
  77. package/dist/tools/search.tools.d.ts.map +1 -1
  78. package/dist/tools/search.tools.js +71 -7
  79. package/dist/tools/search.tools.js.map +1 -1
  80. package/dist/tools/smart.tools.d.ts.map +1 -1
  81. package/dist/tools/smart.tools.js +352 -414
  82. package/dist/tools/smart.tools.js.map +1 -1
  83. package/dist/tools/static-table.tools.d.ts.map +1 -1
  84. package/dist/tools/static-table.tools.js +4 -3
  85. package/dist/tools/static-table.tools.js.map +1 -1
  86. package/dist/tools/trade.tools.d.ts.map +1 -1
  87. package/dist/tools/trade.tools.js +53 -7
  88. package/dist/tools/trade.tools.js.map +1 -1
  89. package/dist/utils/error.d.ts.map +1 -1
  90. package/dist/utils/error.js +5 -0
  91. package/dist/utils/error.js.map +1 -1
  92. package/dist/utils/html.d.ts +9 -0
  93. package/dist/utils/html.d.ts.map +1 -0
  94. package/dist/utils/html.js +77 -0
  95. package/dist/utils/html.js.map +1 -0
  96. package/package.json +10 -10
@@ -1,6 +1,9 @@
1
1
  import { z } from "zod/v4";
2
2
  import { appendAttribution } from "../services/attribution.js";
3
- import { lookupVar, learnVar, invalidateVar, normalizeKeyword } from "../services/learning.js";
3
+ import { invalidateVar, normalizeKeyword } from "../services/learning.js";
4
+ import { resolveVariable as resolveVariableShared, resolveVariableForYears, searchVariables, } from "../services/variable-search.js";
5
+ import { extractVervarId, getAggregateValue, getVervarEntries, selectAggregateVervar, } from "../services/dynamic-data-utils.js";
6
+ import { formatUnit, getPeriodId, getPeriodLabel, getTurthId, getTurthLabel, getVariableTitle, getVariableUnit, isNeutralPeriodLabel, } from "../client/fields.js";
4
7
  /**
5
8
  * Multi-region comparison, trend, and ranking tools.
6
9
  * Each handles a common multi-step query pattern in a single tool call.
@@ -25,11 +28,17 @@ Contoh query user yang cocok untuk tool ini:
25
28
  if (regionList.length < 2) {
26
29
  return { content: [{ type: "text", text: "Minimal 2 wilayah untuk perbandingan." }], isError: true };
27
30
  }
31
+ if (year && /[,;]/.test(year)) {
32
+ return {
33
+ content: [{ type: "text", text: "compare_data hanya mendukung 1 tahun. Gunakan get_trend per wilayah untuk multi-tahun." }],
34
+ isError: true,
35
+ };
36
+ }
28
37
  const results = [];
29
38
  for (const regionName of regionList) {
30
39
  const resolved = await resolver.resolve(regionName);
31
40
  if (!resolved) {
32
- results.push({ region: regionName, domain: "?", value: "Wilayah tidak ditemukan", varTitle: "" });
41
+ results.push({ region: regionName, domain: "?", value: "Wilayah tidak ditemukan", rawValue: null, varTitle: "" });
33
42
  continue;
34
43
  }
35
44
  const { domainId, domainName } = resolved;
@@ -38,29 +47,36 @@ Contoh query user yang cocok untuk tool ini:
38
47
  region: domainName,
39
48
  domain: domainId,
40
49
  value: data.value,
50
+ rawValue: data.rawValue,
51
+ unit: data.unit,
41
52
  varTitle: data.varTitle,
53
+ period: data.period,
42
54
  });
43
55
  }
44
56
  // Format output
45
57
  const varTitle = results.find(r => r.varTitle)?.varTitle || query;
58
+ const showPeriod = !year;
46
59
  const lines = [
47
60
  `## Perbandingan: ${varTitle}`,
48
61
  year ? `**Tahun:** ${year}` : "**Tahun:** Terbaru",
49
62
  "",
50
- "| Wilayah | Nilai |",
51
- "| --- | --- |",
63
+ showPeriod ? "| Wilayah | Nilai | Periode |" : "| Wilayah | Nilai |",
64
+ showPeriod ? "| --- | --- | --- |" : "| --- | --- |",
52
65
  ];
53
66
  for (const r of results) {
54
- lines.push(`| ${r.region} | ${r.value} |`);
67
+ lines.push(showPeriod ? `| ${r.region} | ${r.value} | ${r.period ?? "-"} |` : `| ${r.region} | ${r.value} |`);
55
68
  }
56
- // Add analysis hint
57
- const numericResults = results.filter(r => !isNaN(parseFloat(r.value.replace(",", "."))));
69
+ // Add analysis hint (use the raw numeric value, not the localized string)
70
+ const numericResults = results.filter((result) => result.rawValue !== null);
58
71
  if (numericResults.length >= 2) {
59
- const values = numericResults.map(r => ({ region: r.region, val: parseFloat(r.value.replace(",", ".")) }));
60
- values.sort((a, b) => b.val - a.val);
72
+ const values = [...numericResults].sort((a, b) => b.rawValue - a.rawValue);
61
73
  lines.push("");
62
- lines.push(`**Tertinggi:** ${values[0].region} (${values[0].val})`);
63
- lines.push(`**Terendah:** ${values[values.length - 1].region} (${values[values.length - 1].val})`);
74
+ lines.push(`**Tertinggi:** ${values[0].region} (${values[0].value})`);
75
+ lines.push(`**Terendah:** ${values[values.length - 1].region} (${values[values.length - 1].value})`);
76
+ const units = new Set(numericResults.map((result) => result.unit).filter(Boolean));
77
+ if (units.size > 1) {
78
+ lines.push("", `_Catatan: satuan berbeda antar wilayah (${[...units].join(", ")}); bandingkan dengan hati-hati._`);
79
+ }
64
80
  }
65
81
  return { content: [{ type: "text", text: appendAttribution(lines.join("\n")) }] };
66
82
  }
@@ -94,195 +110,145 @@ Contoh query user yang cocok untuk tool ini:
94
110
  domain = resolved.domainId;
95
111
  domainName = resolved.domainName;
96
112
  }
97
- // Find variable
98
- let varData = await resolveVariable(client, store, query, domain);
99
- if (!varData) {
100
- // Fallback: try strategic indicators for inflasi/PDRB/etc.
113
+ const startNum = parseInt(start_year, 10);
114
+ const endNum = parseInt(end_year, 10);
115
+ if (!Number.isFinite(startNum) || !Number.isFinite(endNum) || startNum > endNum) {
116
+ return { content: [{ type: "text", text: "Rentang tahun tidak valid." }], isError: true };
117
+ }
118
+ if (endNum - startNum > 10) {
119
+ return {
120
+ content: [{ type: "text", text: "Rentang maksimal 10 tahun per panggilan. Pecah menjadi beberapa rentang." }],
121
+ isError: true,
122
+ };
123
+ }
124
+ const years = Array.from({ length: endNum - startNum + 1 }, (_, index) => String(startNum + index));
125
+ // Resolve variable that actually has the requested years.
126
+ const resolved = await resolveVariableForYears(client, store, query, domain, years, {
127
+ maxCandidates: 20,
128
+ });
129
+ let varData = resolved?.varData ?? null;
130
+ const matchedYears = resolved?.matchedYears ?? [];
131
+ if (!varData || (years.length > 0 && matchedYears.length === 0)) {
132
+ // Fallback: strategic indicators (headline only)
101
133
  const indicators = await client.listStrategicIndicators(domain);
102
- if (indicators.data) {
103
- const kw2 = normalizeKeyword(query);
104
- for (const ind of indicators.data) {
105
- const t = ind.title.toLowerCase();
106
- if (t.includes(kw2) || kw2.split(/\s+/).some(w => w.length > 2 && t.includes(w))) {
107
- const val = typeof ind.value === "number" ? ind.value.toLocaleString("id-ID") : ind.value;
108
- const lines = [
109
- `## Tren/Headline ${ind.title}`,
110
- `**Wilayah:** ${domainName} (${domain})`,
111
- "",
112
- `*Catatan: Menggunakan data headline indikator strategis karena variabel time-series lengkap tidak ditemukan.*`,
113
- "",
114
- `| Periode | Nilai |`,
115
- `| --- | --- |`,
116
- `| ${ind.periode} | ${val} ${ind.unit} |`,
117
- ];
118
- if (ind.name) {
119
- lines.push("", `_${ind.name}_`);
120
- }
121
- return { content: [{ type: "text", text: appendAttribution(lines.join("\n")) }] };
122
- }
134
+ const kw2 = normalizeKeyword(query);
135
+ for (const ind of indicators.data ?? []) {
136
+ const title = ind.title.toLowerCase();
137
+ if (title.includes(kw2) || kw2.split(/\s+/).some((word) => word.length > 2 && title.includes(word))) {
138
+ const val = typeof ind.value === "number" ? ind.value.toLocaleString("id-ID") : ind.value;
139
+ const unit = formatUnit(ind.unit);
140
+ const lines = [
141
+ `## Tren/Headline ${ind.title}`,
142
+ `**Wilayah:** ${domainName} (${domain})`,
143
+ "",
144
+ `*Catatan: Menggunakan data headline indikator strategis karena variabel time-series lengkap tidak ditemukan.*`,
145
+ "",
146
+ `| Periode | Nilai |`,
147
+ `| --- | --- |`,
148
+ `| ${ind.periode} | ${val}${unit ? ` ${unit}` : ""} |`,
149
+ ];
150
+ if (ind.name)
151
+ lines.push("", `_${ind.name}_`);
152
+ return { content: [{ type: "text", text: appendAttribution(lines.join("\n")) }] };
123
153
  }
124
154
  }
125
155
  return { content: [{ type: "text", text: appendAttribution(`Tidak ditemukan variabel "${query}" untuk ${domainName}.`) }] };
126
156
  }
127
- // Build year range
128
- const startNum = parseInt(start_year);
129
- const endNum = parseInt(end_year);
130
- const years = [];
131
- for (let y = startNum; y <= endNum; y++)
132
- years.push(String(y));
133
- // Get periods for all years
134
- let periods = await client.listPeriods(domain, varData.var_id);
135
- // Fallback: if no periods found, invalidate cached var and do full search
157
+ let periods = resolved?.periods ?? [];
136
158
  if (periods.length === 0) {
137
159
  await invalidateVar(query, domain, store);
138
- varData = await resolveVariableFullSearch(client, store, query, domain);
139
- if (!varData) {
160
+ const retry = await searchVariables(client, domain, query, { maxCandidates: 10 });
161
+ const retryVar = retry[0];
162
+ if (!retryVar) {
140
163
  return { content: [{ type: "text", text: appendAttribution(`Tidak ditemukan variabel "${query}" untuk ${domainName}.`) }] };
141
164
  }
142
- periods = await client.listPeriods(domain, varData.var_id);
143
- }
144
- const yearToPeriod = {};
145
- for (const p of periods) {
146
- const pAny = p;
147
- const label = String(pAny.th_name || pAny.th || pAny.label || "");
148
- const id = String(p.th_id ?? pAny.val);
149
- for (const y of years) {
150
- if (label.includes(y)) {
151
- // Prefer annual period (label is just the year) over semester (Maret/September)
152
- const isAnnual = label.trim() === y;
153
- const existingIsAnnual = yearToPeriod[y] ? !yearToPeriod[y + "_sem"] : false;
154
- if (!yearToPeriod[y] || (isAnnual && !existingIsAnnual)) {
155
- yearToPeriod[y] = id;
156
- if (!isAnnual)
157
- yearToPeriod[y + "_sem"] = "1"; // marker
158
- }
159
- }
160
- }
161
- }
162
- // Clean up semester markers
163
- for (const k of Object.keys(yearToPeriod)) {
164
- if (k.endsWith("_sem"))
165
- delete yearToPeriod[k];
165
+ varData = retryVar;
166
+ periods = await listAllPeriodsSafe(client, domain, varData.var_id);
167
+ }
168
+ const yearToPeriod = new Map();
169
+ for (const period of periods) {
170
+ const label = getPeriodLabel(period);
171
+ const periodYear = label.match(/\d{4}/)?.[0];
172
+ const id = getPeriodId(period);
173
+ if (!periodYear || !id || !years.includes(periodYear))
174
+ continue;
175
+ if (!yearToPeriod.has(periodYear))
176
+ yearToPeriod.set(periodYear, id);
166
177
  }
167
- const periodIds = years.map(y => yearToPeriod[y]).filter(Boolean);
178
+ const periodIds = years.map((year) => yearToPeriod.get(year)).filter((id) => !!id);
168
179
  if (periodIds.length === 0) {
169
180
  return { content: [{ type: "text", text: appendAttribution(`Tidak ada data periode ${start_year}-${end_year} untuk variabel ini.`) }] };
170
181
  }
171
- // Fetch data
172
182
  const result = await client.getDynamicData(domain, String(varData.var_id), periodIds.join(","));
173
183
  if (!result.datacontent || Object.keys(result.datacontent).length === 0) {
174
184
  return { content: [{ type: "text", text: appendAttribution(`Data tidak tersedia untuk periode ${start_year}-${end_year}.`) }] };
175
185
  }
176
- // Parse datacontent — match period IDs to values
177
- // Build period label map from response
178
- const periodLabels = {};
179
- const responsePeriodIds = [];
180
- if (result.tahun) {
181
- for (const t of result.tahun) {
182
- const tAny = t;
183
- const id = String(t.th_id ?? tAny.val);
184
- const label = String(t.th_name ?? tAny.label ?? id);
185
- periodLabels[id] = label;
186
- responsePeriodIds.push(id);
187
- }
188
- }
189
- // Find the aggregate vervar (national = 9999, provincial = domain pattern or bold/last entry)
190
- let aggregateVervar = null;
191
- if (result.vervar) {
192
- for (const v of result.vervar) {
193
- const vAny = v;
194
- const vLabel = String(v.label_vervar ?? vAny.label ?? "");
195
- const vId = String(v.kode_vervar ?? vAny.val);
196
- if (vId === "9999" || vLabel.toLowerCase().includes("indonesia")) {
197
- aggregateVervar = vId;
198
- break;
199
- }
200
- }
201
- // For provincial domains: look for domain-based patterns
202
- if (!aggregateVervar && domain !== "0000") {
203
- for (const v of result.vervar) {
204
- const vAny = v;
205
- const vLabel = String(v.label_vervar ?? vAny.label ?? "");
206
- const vId = String(v.kode_vervar ?? vAny.val);
207
- // Pattern: domain prefix + "99" (e.g., 3599 for domain 3500)
208
- if (vId === domain.slice(0, 2) + "99" || vId === domain.slice(0, 4) + "0") {
209
- aggregateVervar = vId;
210
- break;
211
- }
212
- if (vLabel.startsWith("<b>") || vLabel.toLowerCase().includes("provinsi") || vLabel.toLowerCase().includes("jawa timur") || vLabel === domainName) {
213
- aggregateVervar = vId;
214
- break;
215
- }
216
- }
217
- // Last resort: if vervar has a "Jumlah" or last entry is typically aggregate
218
- if (!aggregateVervar) {
219
- for (const v of result.vervar) {
220
- const vAny = v;
221
- const vLabel = String(v.label_vervar ?? vAny.label ?? "").toLowerCase();
222
- if (vLabel === "jumlah" || vLabel === "total") {
223
- aggregateVervar = String(v.kode_vervar ?? vAny.val);
224
- break;
225
- }
226
- }
227
- }
228
- }
229
- // For national domain: try bold labels
230
- if (!aggregateVervar && domain === "0000") {
231
- for (const v of result.vervar) {
232
- const vAny = v;
233
- const vLabel = String(v.label_vervar ?? vAny.label ?? "");
234
- if (vLabel.startsWith("<b>")) {
235
- aggregateVervar = String(v.kode_vervar ?? vAny.val);
236
- break;
237
- }
238
- }
239
- }
186
+ const periodLabels = new Map();
187
+ for (const period of result.tahun ?? []) {
188
+ periodLabels.set(getPeriodId(period), getPeriodLabel(period));
189
+ }
190
+ const aggregateVervar = selectAggregateVervar(result, domain, domainName);
191
+ const selectedTurth = selectTrendTurth(result.turtahun ?? [], Object.keys(result.datacontent ?? {}), periodIds);
192
+ const vervarEntries = getVervarEntries(result);
193
+ if (!aggregateVervar && vervarEntries.length > 10) {
194
+ return {
195
+ content: [{
196
+ type: "text",
197
+ text: appendAttribution(`Variabel "${getVariableTitle(varData) || varData.title}" tidak memiliki baris agregat nasional ` +
198
+ `(hanya ${vervarEntries.length} provinsi/kategori). Gunakan get_trend dengan region provinsi, ` +
199
+ `atau get_ranking untuk membandingkan seluruh provinsi.`),
200
+ }],
201
+ };
240
202
  }
241
- // Extract values using proper key matching
242
- // Sort period IDs longest-first to avoid substring collisions
243
- const sortedPeriodIds = responsePeriodIds.sort((a, b) => b.length - a.length);
203
+ const sortedPeriodIds = [...periodIds].sort((a, b) => b.length - a.length);
244
204
  const trendData = [];
245
205
  for (const [key, value] of Object.entries(result.datacontent)) {
246
206
  if (typeof value !== "number")
247
207
  continue;
248
- // If we have an aggregate vervar, only match keys containing it
249
- if (aggregateVervar && !key.includes(aggregateVervar))
208
+ if (aggregateVervar && extractVervarId(key, result) !== aggregateVervar)
250
209
  continue;
251
- // Find which period this key belongs to (longest match first)
252
- for (const pid of sortedPeriodIds) {
253
- if (key.includes(pid)) {
254
- const label = periodLabels[pid] || pid;
255
- // Only keep periods in our requested year range
256
- const yearNum = parseInt(label);
257
- if (yearNum >= parseInt(start_year) && yearNum <= parseInt(end_year)) {
258
- if (!trendData.some(d => d.year === label)) {
259
- trendData.push({ year: label, value });
260
- }
261
- }
262
- break;
263
- }
264
- }
210
+ if (selectedTurth?.id && !key.endsWith(selectedTurth.id))
211
+ continue;
212
+ const periodId = sortedPeriodIds.find((id) => key.includes(id));
213
+ if (!periodId)
214
+ continue;
215
+ const label = periodLabels.get(periodId) ?? periodId;
216
+ if (trendData.some((item) => item.year === label))
217
+ continue;
218
+ trendData.push({ year: label, value });
265
219
  }
266
220
  trendData.sort((a, b) => a.year.localeCompare(b.year));
267
221
  if (trendData.length === 0) {
268
222
  return { content: [{ type: "text", text: appendAttribution(`Data tren tidak dapat di-parse.`) }] };
269
223
  }
270
224
  // Format output
271
- const unit = varData.unit && !varData.unit.toLowerCase().includes("tidak ada") ? ` (${varData.unit})` : "";
225
+ const unit = formatUnit(getVariableUnit(varData) ?? varData.unit);
226
+ const title = getVariableTitle(varData) || varData.title;
272
227
  const lines = [
273
- `## Tren ${varData.title}${unit}`,
228
+ `## Tren ${title}${unit ? ` (${unit})` : ""}`,
274
229
  `**Wilayah:** ${domainName} | **Periode:** ${start_year}–${end_year}`,
275
- "",
276
- "| Tahun | Nilai | Perubahan |",
277
- "| --- | --- | --- |",
278
230
  ];
231
+ if (selectedTurth && !isNeutralPeriodLabel(selectedTurth.label)) {
232
+ lines.push(`**Seri:** ${selectedTurth.label}`);
233
+ if (/januari|februari|maret|april|mei|juni|juli|agustus|september|oktober|november|desember/i.test(selectedTurth.label)) {
234
+ lines.push(`_Catatan: variabel ini bulanan; nilai yang ditampilkan adalah bulan ${selectedTurth.label} pada setiap tahun._`);
235
+ }
236
+ }
237
+ const missingYears = years.filter((year) => !matchedYears.includes(year));
238
+ if (missingYears.length > 0 && matchedYears.length > 0) {
239
+ lines.push(`**Catatan:** tahun ${missingYears.join(", ")} tidak tersedia untuk variabel ini.`);
240
+ }
241
+ lines.push("", "| Periode | Nilai | Perubahan |", "| --- | --- | --- |");
279
242
  for (let i = 0; i < trendData.length; i++) {
280
243
  const d = trendData[i];
281
244
  let change = "-";
282
245
  if (i > 0) {
283
- const diff = d.value - trendData[i - 1].value;
284
- const pct = ((diff / trendData[i - 1].value) * 100).toFixed(1);
285
- change = `${diff > 0 ? "+" : ""}${pct}%`;
246
+ const previous = trendData[i - 1].value;
247
+ const diff = d.value - previous;
248
+ if (previous !== 0) {
249
+ const pct = ((diff / previous) * 100).toFixed(1);
250
+ change = `${diff > 0 ? "+" : ""}${pct}%`;
251
+ }
286
252
  }
287
253
  lines.push(`| ${d.year} | ${d.value.toLocaleString("id-ID")} | ${change} |`);
288
254
  }
@@ -290,10 +256,12 @@ Contoh query user yang cocok untuk tool ini:
290
256
  if (trendData.length >= 2) {
291
257
  const first = trendData[0].value;
292
258
  const last = trendData[trendData.length - 1].value;
293
- const totalChange = ((last - first) / first * 100).toFixed(1);
294
- const trend = last > first ? "naik" : last < first ? "turun" : "stabil";
295
- lines.push("");
296
- lines.push(`**Tren:** ${trend} ${totalChange}% dari ${trendData[0].year} ke ${trendData[trendData.length - 1].year}`);
259
+ if (first !== 0) {
260
+ const totalChange = ((last - first) / first * 100).toFixed(1);
261
+ const trend = last > first ? "naik" : last < first ? "turun" : "stabil";
262
+ lines.push("");
263
+ lines.push(`**Tren:** ${trend} ${totalChange}% dari ${trendData[0].year} ke ${trendData[trendData.length - 1].year}`);
264
+ }
297
265
  }
298
266
  return { content: [{ type: "text", text: appendAttribution(lines.join("\n")) }] };
299
267
  }
@@ -312,7 +280,7 @@ Contoh query user yang cocok untuk tool ini:
312
280
  - "ranking kemiskinan per provinsi 2023"
313
281
  - "5 provinsi dengan penduduk terbanyak"`, {
314
282
  query: z.string().describe("Indikator untuk ranking (misal: kemiskinan, pengangguran, IPM, penduduk)"),
315
- top_n: z.number().default(10).describe("Jumlah data yang ditampilkan (default 10, max 34 untuk semua provinsi)"),
283
+ top_n: z.number().int().min(1).max(40).default(10).describe("Jumlah data yang ditampilkan (default 10, max 40 untuk semua provinsi)"),
316
284
  order: z.enum(["highest", "lowest"]).default("highest").describe("Urutan: 'highest' (tertinggi dulu) atau 'lowest' (terendah dulu)"),
317
285
  year: z.string().optional().describe("Tahun data. Kosongkan untuk terbaru."),
318
286
  }, async ({ query, top_n, order, year }) => {
@@ -323,62 +291,42 @@ Contoh query user yang cocok untuk tool ini:
323
291
  if (!varData) {
324
292
  return { content: [{ type: "text", text: appendAttribution(`Tidak ditemukan variabel "${query}" untuk ranking nasional.`) }] };
325
293
  }
326
- // Get latest period
327
- let periodParam;
328
- if (year) {
329
- const periods = await client.listPeriods("0000", varData.var_id);
330
- for (const p of periods) {
331
- const pAny = p;
332
- const label = String(pAny.th_name || pAny.th || pAny.label || "");
333
- if (label.includes(year)) {
334
- periodParam = String(p.th_id ?? pAny.val);
335
- break;
336
- }
337
- }
338
- }
339
- if (!periodParam) {
340
- const periods = await client.listPeriods("0000", varData.var_id);
341
- if (periods.length > 0) {
342
- const pAny = periods[0];
343
- periodParam = String(periods[0].th_id ?? pAny.val);
344
- }
345
- }
346
- const result = await client.getDynamicData("0000", String(varData.var_id), periodParam);
294
+ const periods = await listAllPeriodsSafe(client, "0000", varData.var_id);
295
+ let selectedPeriod = year
296
+ ? periods.find((period) => getPeriodLabel(period).includes(year))
297
+ : periods[0];
298
+ if (!selectedPeriod)
299
+ selectedPeriod = periods[0];
300
+ const selectedPeriodId = selectedPeriod ? getPeriodId(selectedPeriod) : "";
301
+ const periodLabel = selectedPeriod ? getPeriodLabel(selectedPeriod) : "";
302
+ const result = await client.getDynamicData("0000", String(varData.var_id), selectedPeriodId || undefined);
347
303
  if (!result.datacontent || Object.keys(result.datacontent).length === 0) {
348
304
  return { content: [{ type: "text", text: appendAttribution(`Data ranking tidak tersedia.`) }] };
349
305
  }
350
- // Build vervar (province) label map — prefer provincial level only
351
- const vervarLabels = {};
352
- const allVervarLabels = {};
353
- if (result.vervar) {
354
- for (const v of result.vervar) {
355
- const vAny = v;
356
- const id = String(v.kode_vervar ?? vAny.val);
357
- const label = String(v.label_vervar ?? vAny.label ?? id);
358
- allVervarLabels[id] = label;
359
- // Provincial entries have bold labels or are 4-digit codes ending in 00
360
- if (label.startsWith("<b>") && !label.toLowerCase().includes("indonesia")) {
361
- vervarLabels[id] = label.replace(/<\/?b>/g, "");
362
- }
363
- }
306
+ const entries = getVervarEntries(result).filter((entry) => !/^(indonesia|nasional|total|jumlah)$/i.test(entry.label.trim()));
307
+ if (entries.length === 0) {
308
+ return { content: [{ type: "text", text: appendAttribution(`Data ranking tidak dapat di-parse. Coba gunakan find_data untuk masing-masing wilayah.`) }] };
309
+ }
310
+ if (entries.length > 40) {
311
+ return {
312
+ content: [{
313
+ type: "text",
314
+ text: appendAttribution(`Variabel "${getVariableTitle(varData)}" memiliki ${entries.length} kategori (bukan level provinsi). ` +
315
+ `Gunakan find_data untuk wilayah tertentu atau find_variable untuk memilih variabel provinsi.`),
316
+ }],
317
+ };
364
318
  }
365
- // If no bold entries found, use all (the variable is already at province level)
366
- const useLabels = Object.keys(vervarLabels).length >= 10 ? vervarLabels : allVervarLabels;
367
- // Extract province-level data
368
- const rankings = [];
369
- // Sort vervar IDs longest-first to avoid substring collisions
370
- const vervarIds = Object.keys(useLabels).sort((a, b) => b.length - a.length);
319
+ const valuesByVervar = new Map();
371
320
  for (const [key, value] of Object.entries(result.datacontent)) {
372
321
  if (typeof value !== "number")
373
322
  continue;
374
- for (const vId of vervarIds) {
375
- if (key.includes(vId) && !rankings.some(r => r.province === useLabels[vId])) {
376
- const label = useLabels[vId].replace(/<\/?b>/g, "");
377
- rankings.push({ province: label, value });
378
- break;
379
- }
380
- }
323
+ const vervarId = extractVervarId(key, result);
324
+ if (vervarId && !valuesByVervar.has(vervarId))
325
+ valuesByVervar.set(vervarId, value);
381
326
  }
327
+ const rankings = entries
328
+ .filter((entry) => valuesByVervar.has(entry.id))
329
+ .map((entry) => ({ province: entry.label, value: valuesByVervar.get(entry.id) }));
382
330
  if (rankings.length === 0) {
383
331
  return { content: [{ type: "text", text: appendAttribution(`Data ranking tidak dapat di-parse. Coba gunakan find_data untuk masing-masing wilayah.`) }] };
384
332
  }
@@ -386,10 +334,10 @@ Contoh query user yang cocok untuk tool ini:
386
334
  rankings.sort((a, b) => order === "highest" ? b.value - a.value : a.value - b.value);
387
335
  const display = rankings.slice(0, Math.min(top_n, rankings.length));
388
336
  // Format
389
- const unit = varData.unit && !varData.unit.toLowerCase().includes("tidak ada") ? ` (${varData.unit})` : "";
337
+ const unit = formatUnit(getVariableUnit(varData) ?? varData.unit);
390
338
  const lines = [
391
- `## Ranking: ${varData.title}${unit}`,
392
- `**Urutan:** ${order === "highest" ? "Tertinggi" : "Terendah"} | **Tahun:** ${year || "Terbaru"}`,
339
+ `## Ranking: ${getVariableTitle(varData) || varData.title}${unit ? ` (${unit})` : ""}`,
340
+ `**Urutan:** ${order === "highest" ? "Tertinggi" : "Terendah"} | **Periode:** ${periodLabel || year || "Terbaru"}`,
393
341
  "",
394
342
  "| # | Wilayah | Nilai |",
395
343
  "| --- | --- | --- |",
@@ -409,336 +357,146 @@ Contoh query user yang cocok untuk tool ini:
409
357
  });
410
358
  }
411
359
  // --- Helper functions ---
412
- /** Resolve variable for a domain using learning store + full search fallback. */
413
- async function resolveVariable(client, store, query, domain) {
414
- // Try learning store first
415
- const learned = await lookupVar(query, domain, store);
416
- if (learned)
417
- return learned;
418
- // Full search
419
- const kw = normalizeKeyword(query);
420
- const KEYWORD_SUBJECTS = {
421
- pengangguran: [6], tenaga: [6], kerja: [6], tpt: [6],
422
- miskin: [23], kemiskinan: [23], gini: [23], ketimpangan: [23],
423
- penduduk: [12], kependudukan: [12],
424
- inflasi: [3], harga: [3], ihk: [3],
425
- pdrb: [52], ekonomi: [52, 35], pertumbuhan: [52],
426
- ipm: [26], pembangunan: [26],
427
- ekspor: [8], impor: [8],
428
- };
429
- const subjectIds = [];
430
- for (const [keyword, ids] of Object.entries(KEYWORD_SUBJECTS)) {
431
- if (kw.includes(keyword))
432
- subjectIds.push(...ids);
360
+ async function listAllPeriodsSafe(client, domain, varId) {
361
+ const maybeAll = client.listAllPeriods;
362
+ if (typeof maybeAll === "function") {
363
+ return maybeAll.call(client, domain, varId);
433
364
  }
434
- // Also match from subject titles
435
- const subjects = await client.listSubjects(domain);
436
- for (const s of subjects.data) {
437
- if (kw.split(/\s+/).some(w => w.length > 2 && s.title.toLowerCase().includes(w))) {
438
- if (!subjectIds.includes(s.sub_id))
439
- subjectIds.push(s.sub_id);
440
- }
441
- }
442
- // Expand search terms with synonyms
443
- const SEARCH_SYNONYMS = {
444
- ipm: ["ipm", "pembangunan manusia", "indeks pembangunan"],
445
- tpt: ["tpt", "pengangguran terbuka"],
446
- gini: ["gini", "gini rasio"],
447
- };
448
- const searchTerms = SEARCH_SYNONYMS[kw] || [kw];
449
- for (const subId of subjectIds.slice(0, 3)) {
450
- const result = await client.listVariables(domain, subId, undefined, 1, 100);
451
- if (!result.data)
452
- continue;
453
- const candidates = [];
454
- for (const v of result.data) {
455
- const titleLower = v.title.toLowerCase();
456
- const matches = searchTerms.some(term => titleLower.includes(term)) ||
457
- kw.split(/\s+/).some(w => w.length > 2 && titleLower.includes(w));
458
- if (matches) {
459
- candidates.push({ var_id: v.var_id, title: v.title, sub_name: v.sub_name, unit: v.unit });
460
- }
461
- }
462
- if (candidates.length > 0) {
463
- // Score candidates: prefer non-lama, prefer kabupaten/provinsi, deprioritize disaggregated
464
- candidates.sort((a, b) => {
465
- const t = (x) => x.title.toLowerCase();
466
- const score = (x) => {
467
- let s = 0;
468
- if (t(x).includes("metode lama"))
469
- s += 10;
470
- if (t(x).includes("metode baru"))
471
- s -= 2;
472
- // Prefer variables with kabupaten/kota or provinsi (have aggregate)
473
- if (t(x).includes("kabupaten") || t(x).includes("provinsi"))
474
- s -= 3;
475
- // Deprioritize disaggregated variants
476
- if (t(x).includes("golongan umur") || t(x).includes("lapangan usaha") || t(x).includes("klasifikasi") || t(x).includes("pendidikan tertinggi"))
477
- s += 5;
478
- return s;
479
- };
480
- return score(a) - score(b);
481
- });
482
- const found = candidates[0];
483
- await learnVar(query, domain, found, store);
484
- return found;
485
- }
486
- }
487
- return null;
365
+ return client.listPeriods(domain, varId);
366
+ }
367
+ function selectTrendTurth(turthList, datacontentKeys, periodIds) {
368
+ const entries = turthList
369
+ .map((item) => ({ id: getTurthId(item), label: getTurthLabel(item) }))
370
+ .filter((entry) => entry.id !== "");
371
+ if (entries.length === 0)
372
+ return null;
373
+ // BPS often lists series (annual, monthly, semester) that are not present for
374
+ // every requested year. Count actual coverage and pick the best one instead
375
+ // of selecting "Tahunan" and silently dropping years where it is missing.
376
+ const scored = entries
377
+ .map((entry) => {
378
+ const coverage = periodIds.filter((periodId) => datacontentKeys.some((key) => key.includes(periodId) && key.endsWith(entry.id))).length;
379
+ return { entry, coverage };
380
+ })
381
+ .filter((candidate) => candidate.coverage > 0)
382
+ .sort((a, b) => b.coverage - a.coverage);
383
+ if (scored.length === 0)
384
+ return null;
385
+ const bestCoverage = scored[0].coverage;
386
+ const best = scored.filter((candidate) => candidate.coverage === bestCoverage).map((candidate) => candidate.entry);
387
+ const annual = best.find((entry) => isNeutralPeriodLabel(entry.label));
388
+ if (annual)
389
+ return annual;
390
+ const firstSemester = best.find((entry) => /semester\s*1/i.test(entry.label));
391
+ if (firstSemester)
392
+ return firstSemester;
393
+ const december = best.find((entry) => /desember/i.test(entry.label));
394
+ if (december)
395
+ return december;
396
+ return best[0];
488
397
  }
489
- /** Fetch a single data value for a domain+query+year. */
398
+ /** Fetch a single aggregate value for a domain + query + year. */
490
399
  async function fetchDataForDomain(client, store, query, domain, year) {
491
- let varData = await resolveVariable(client, store, query, domain);
400
+ const yearList = year ? [year] : [];
401
+ const resolved = await resolveVariableForYears(client, store, query, domain, yearList, {
402
+ maxCandidates: 20,
403
+ });
404
+ let varData = resolved?.varData ?? null;
405
+ if (yearList.length > 0 && (!resolved || resolved.matchedYears.length === 0)) {
406
+ varData = null;
407
+ }
492
408
  if (!varData) {
493
- // Fallback: try strategic indicators (inflasi, PDRB, etc.)
494
409
  const indicators = await client.listStrategicIndicators(domain);
495
- if (indicators.data) {
496
- const kw2 = normalizeKeyword(query);
497
- for (const ind of indicators.data) {
498
- const t = ind.title.toLowerCase();
499
- if (t.includes(kw2) || kw2.split(/\s+/).some(w => w.length > 2 && t.includes(w))) {
500
- if (year && !ind.periode.includes(year)) {
501
- continue;
502
- }
503
- const val = typeof ind.value === "number" ? ind.value.toLocaleString("id-ID") : String(ind.value);
504
- const unit = ind.unit && !ind.unit.toLowerCase().includes("tidak ada") ? ` ${ind.unit}` : "";
505
- return { value: `${val}${unit} (${ind.periode})`, varTitle: ind.title };
506
- }
410
+ const kw = normalizeKeyword(query);
411
+ for (const indicator of indicators.data ?? []) {
412
+ const title = indicator.title.toLowerCase();
413
+ if (title.includes(kw) || kw.split(/\s+/).some((word) => word.length > 2 && title.includes(word))) {
414
+ if (year && !indicator.periode.includes(year))
415
+ continue;
416
+ const unit = formatUnit(indicator.unit);
417
+ const rawValue = typeof indicator.value === "number" ? indicator.value : null;
418
+ const formatted = typeof indicator.value === "number"
419
+ ? indicator.value.toLocaleString("id-ID")
420
+ : String(indicator.value);
421
+ return {
422
+ value: `${formatted}${unit ? ` ${unit}` : ""} (${indicator.periode})`,
423
+ rawValue,
424
+ unit,
425
+ varTitle: indicator.title,
426
+ period: indicator.periode,
427
+ };
507
428
  }
508
429
  }
509
- return { value: "N/A", varTitle: "" };
430
+ return { value: "N/A", rawValue: null, varTitle: "" };
510
431
  }
511
- // Resolve period
512
- let periodParam;
513
- let periods = await client.listPeriods(domain, varData.var_id);
514
- // Fallback: if no periods, invalidate and do full search
432
+ let periods = resolved?.periods ?? [];
515
433
  if (periods.length === 0) {
516
434
  await invalidateVar(query, domain, store);
517
- varData = await resolveVariableFullSearch(client, store, query, domain);
518
- if (!varData)
519
- return { value: "N/A", varTitle: "" };
520
- periods = await client.listPeriods(domain, varData.var_id);
435
+ const retry = await searchVariables(client, domain, query, { maxCandidates: 10 });
436
+ const retryVar = retry[0];
437
+ if (!retryVar)
438
+ return { value: "N/A", rawValue: null, varTitle: "" };
439
+ varData = retryVar;
440
+ periods = await listAllPeriodsSafe(client, domain, varData.var_id);
521
441
  }
522
- if (year && periods.length > 0) {
523
- for (const p of periods) {
524
- const pAny = p;
525
- const label = String(pAny.th_name || pAny.th || pAny.label || "");
526
- if (label.includes(year)) {
527
- periodParam = String(p.th_id ?? pAny.val);
528
- break;
529
- }
530
- }
531
- // If requested year not found, try full search for a variable that has it
532
- if (!periodParam) {
533
- await invalidateVar(query, domain, store);
534
- varData = await resolveVariableFullSearch(client, store, query, domain);
535
- if (!varData)
536
- return { value: "N/A", varTitle: "" };
537
- periods = await client.listPeriods(domain, varData.var_id);
538
- for (const p of periods) {
539
- const pAny = p;
540
- const label = String(pAny.th_name || pAny.th || pAny.label || "");
541
- if (label.includes(year)) {
542
- periodParam = String(p.th_id ?? pAny.val);
543
- break;
544
- }
545
- }
546
- }
547
- }
548
- if (!periodParam && periods.length > 0) {
549
- const pAny = periods[0];
550
- periodParam = String(periods[0].th_id ?? pAny.val);
551
- }
552
- const result = await client.getDynamicData(domain, String(varData.var_id), periodParam);
553
- if (!result.datacontent || Object.keys(result.datacontent).length === 0) {
554
- return { value: "N/A", varTitle: varData.title };
555
- }
556
- // Find the aggregate vervar for this domain
557
- let aggregateVervar = null;
558
- if (result.vervar) {
559
- for (const v of result.vervar) {
560
- const vAny = v;
561
- const vId = String(v.kode_vervar ?? vAny.val);
562
- const vLabel = String(v.label_vervar ?? vAny.label ?? "");
563
- // National: 9999 = INDONESIA
564
- if (vId === "9999" || vLabel.toLowerCase().includes("indonesia")) {
565
- aggregateVervar = vId;
566
- break;
567
- }
568
- // Provincial: domain + "99" pattern (e.g., 3699 for domain 3600)
569
- if (domain !== "0000" && (vId === domain.slice(0, 2) + "99" || vId === domain.slice(0, 4) + "0" || vLabel.startsWith("<b>") || vLabel.toLowerCase().includes("provinsi"))) {
570
- aggregateVervar = vId;
571
- break;
572
- }
573
- }
574
- }
575
- // Extract the aggregate value
576
- let val = null;
577
- if (aggregateVervar) {
578
- for (const [key, value] of Object.entries(result.datacontent)) {
579
- if (typeof value === "number" && key.includes(aggregateVervar)) {
580
- val = value;
581
- break;
582
- }
583
- }
442
+ let selectedPeriod = year
443
+ ? periods.find((period) => getPeriodLabel(period).includes(year))
444
+ : periods[0];
445
+ // With an explicit year, never silently fall back to a different year.
446
+ if (!selectedPeriod && year) {
447
+ return {
448
+ value: "N/A",
449
+ rawValue: null,
450
+ varTitle: getVariableTitle(varData) || varData.title,
451
+ };
584
452
  }
585
- // Fallback: first numeric value
586
- if (val === null) {
587
- const values = Object.values(result.datacontent).filter(v => typeof v === "number");
588
- if (values.length === 0)
589
- return { value: "N/A", varTitle: varData.title };
590
- val = values[0];
453
+ if (!selectedPeriod)
454
+ selectedPeriod = periods[0];
455
+ const periodId = selectedPeriod ? getPeriodId(selectedPeriod) : "";
456
+ const periodLabel = selectedPeriod ? getPeriodLabel(selectedPeriod) : "";
457
+ const result = await client.getDynamicData(domain, String(varData.var_id), periodId || undefined);
458
+ const rawValue = getAggregateValue(result, domain);
459
+ const unit = formatUnit(getVariableUnit(varData) ?? varData.unit);
460
+ if (rawValue === null) {
461
+ return { value: "N/A", rawValue: null, unit, varTitle: getVariableTitle(varData) || varData.title, period: periodLabel };
591
462
  }
592
- const unit = varData.unit && !varData.unit.toLowerCase().includes("tidak ada") ? ` ${varData.unit}` : "";
593
- return { value: `${val.toLocaleString("id-ID")}${unit}`, varTitle: varData.title };
594
- }
595
- /** Resolve variable bypassing KNOWN_VARS/store — full API search only. */
596
- async function resolveVariableFullSearch(client, store, query, domain) {
597
- const kw = normalizeKeyword(query);
598
- const KEYWORD_SUBJECTS = {
599
- pengangguran: [6], tenaga: [6], kerja: [6], tpt: [6],
600
- miskin: [23], kemiskinan: [23], gini: [23], ketimpangan: [23],
601
- penduduk: [12], kependudukan: [12],
602
- inflasi: [3], harga: [3], ihk: [3],
603
- pdrb: [52], ekonomi: [52, 35], pertumbuhan: [52],
604
- ipm: [26], pembangunan: [26],
605
- ekspor: [8], impor: [8],
463
+ return {
464
+ value: `${rawValue.toLocaleString("id-ID")}${unit ? ` ${unit}` : ""}`,
465
+ rawValue,
466
+ unit,
467
+ varTitle: getVariableTitle(varData) || varData.title,
468
+ period: periodLabel,
606
469
  };
607
- const subjectIds = [];
608
- for (const [keyword, ids] of Object.entries(KEYWORD_SUBJECTS)) {
609
- if (kw.includes(keyword))
610
- subjectIds.push(...ids);
611
- }
612
- const subjects = await client.listSubjects(domain);
613
- for (const s of subjects.data) {
614
- if (kw.split(/\s+/).some(w => w.length > 2 && s.title.toLowerCase().includes(w))) {
615
- if (!subjectIds.includes(s.sub_id))
616
- subjectIds.push(s.sub_id);
617
- }
618
- }
619
- for (const subId of subjectIds.slice(0, 3)) {
620
- const result = await client.listVariables(domain, subId, undefined, 1, 100);
621
- if (!result.data)
622
- continue;
623
- const candidates = [];
624
- const SEARCH_SYNONYMS = {
625
- ipm: ["ipm", "pembangunan manusia", "indeks pembangunan"],
626
- tpt: ["tpt", "pengangguran terbuka"],
627
- gini: ["gini", "gini rasio"],
628
- };
629
- const searchTerms = SEARCH_SYNONYMS[kw] || [kw];
630
- for (const v of result.data) {
631
- const titleLower = v.title.toLowerCase();
632
- const matches = searchTerms.some(term => titleLower.includes(term)) ||
633
- kw.split(/\s+/).some(w => w.length > 2 && titleLower.includes(w));
634
- if (matches && !titleLower.includes("metode lama")) {
635
- candidates.push({ var_id: v.var_id, title: v.title, sub_name: v.sub_name, unit: v.unit });
636
- }
637
- }
638
- // Pick the first candidate that has periods
639
- for (const c of candidates) {
640
- const periods = await client.listPeriods(domain, c.var_id);
641
- if (periods.length === 0)
642
- continue;
643
- await learnVar(query, domain, c, store);
644
- return c;
645
- }
646
- }
647
- return null;
648
470
  }
649
- /** Resolve variable for ranking — prefer "Menurut Provinsi" variants at national level. */
471
+ /** Resolve a national variable that breaks down by province, for rankings. */
650
472
  async function resolveVariableForRanking(client, store, query) {
651
- const kw = normalizeKeyword(query);
652
- const kwWords = kw.split(/\s+/).filter(w => w.length > 2);
653
- // Add root words for common Indonesian affixes: ke-...-an → root
654
- const roots = [...kwWords];
655
- for (const w of kwWords) {
656
- if (w.startsWith("ke") && w.endsWith("an") && w.length > 6)
657
- roots.push(w.slice(2, -2));
658
- if (w.startsWith("pe") && w.endsWith("an") && w.length > 6)
659
- roots.push(w.slice(2, -2));
660
- if (w.endsWith("an") && w.length > 5)
661
- roots.push(w.slice(0, -2));
662
- }
663
- // Add synonym expansions
664
- const RANKING_SYNONYMS = {
665
- ipm: ["ipm", "pembangunan manusia", "indeks pembangunan"],
666
- tpt: ["tpt", "pengangguran terbuka", "tingkat pengangguran"],
667
- gini: ["gini", "gini rasio", "ketimpangan"],
668
- pdrb: ["pdrb", "produk domestik", "pertumbuhan ekonomi"],
669
- "harapan hidup": ["harapan hidup", "angka harapan hidup", "umur harapan"],
670
- pendidikan: ["rata-rata lama sekolah", "harapan lama sekolah", "melek huruf"],
671
- penduduk: ["jumlah penduduk", "populasi"],
672
- };
673
- const synonyms = RANKING_SYNONYMS[kw] || [];
674
- // Also check if any synonym key is contained in the query
675
- for (const [key, syns] of Object.entries(RANKING_SYNONYMS)) {
676
- if (kw.includes(key) && !synonyms.length) {
677
- synonyms.push(...syns);
678
- break;
679
- }
680
- }
681
- const matchesTitle = (t) => roots.some(r => t.includes(r)) || synonyms.some(s => t.includes(s));
682
- const KEYWORD_SUBJECTS = {
683
- miskin: [23], kemiskinan: [23], gini: [23],
684
- pengangguran: [6], tpt: [6],
685
- penduduk: [12], ipm: [26],
686
- pdrb: [52], ekonomi: [52], pertumbuhan: [52],
687
- harapan: [26, 30], pendidikan: [26, 28],
688
- sekolah: [26, 28],
689
- };
690
- const subjectIds = [];
691
- for (const [keyword, ids] of Object.entries(KEYWORD_SUBJECTS)) {
692
- if (kw.includes(keyword))
693
- subjectIds.push(...ids);
473
+ const candidates = await searchVariables(client, "0000", query, {
474
+ maxSubjects: 8,
475
+ maxPagesPerSubject: 6,
476
+ maxCandidates: 40,
477
+ });
478
+ if (candidates.length === 0) {
479
+ return resolveVariableShared(client, store, query, "0000");
694
480
  }
695
- if (subjectIds.length === 0)
696
- subjectIds.push(23, 6, 12, 26);
697
- // Search for variables with "Provinsi" in title
698
- for (const subId of subjectIds.slice(0, 3)) {
699
- const result = await client.listVariables("0000", subId, undefined, 1, 100);
700
- if (!result.data)
701
- continue;
702
- // First pass: find one with "Provinsi" AND matching keyword AND recent data
703
- for (const v of result.data) {
704
- const t = v.title.toLowerCase();
705
- if (t.includes("provinsi") && matchesTitle(t) && !t.includes("metode lama")) {
706
- if (t.includes("persentase") || t.includes("tingkat") || t.includes("indeks")) {
707
- // Validate has recent data (at least 2020+)
708
- const periods = await client.listPeriods("0000", v.var_id);
709
- const hasRecent = periods.some(p => {
710
- const pAny = p;
711
- const label = String(pAny.th_name || pAny.th || pAny.label || "");
712
- return parseInt(label) >= 2020;
713
- });
714
- if (hasRecent)
715
- return { var_id: v.var_id, title: v.title, sub_name: v.sub_name, unit: v.unit };
716
- }
717
- }
718
- }
719
- // Second pass: "Provinsi" + keyword match (any) with recent data
720
- for (const v of result.data) {
721
- const t = v.title.toLowerCase();
722
- if (t.includes("provinsi") && matchesTitle(t) && !t.includes("metode lama")) {
723
- const periods = await client.listPeriods("0000", v.var_id);
724
- const hasRecent = periods.some(p => {
725
- const pAny = p;
726
- const label = String(pAny.th_name || pAny.th || pAny.label || "");
727
- return parseInt(label) >= 2020;
728
- });
729
- if (hasRecent)
730
- return { var_id: v.var_id, title: v.title, sub_name: v.sub_name, unit: v.unit };
731
- }
732
- }
733
- // Second pass: any match without "Provinsi" requirement
734
- for (const v of result.data) {
735
- const t = v.title.toLowerCase();
736
- if (t.includes(kw) || kw.split(/\s+/).some(w => w.length > 2 && t.includes(w))) {
737
- return { var_id: v.var_id, title: v.title, sub_name: v.sub_name, unit: v.unit };
738
- }
739
- }
481
+ const provinceCandidates = candidates.filter((candidate) => /provinsi/i.test(candidate.title) && !/metode lama/i.test(candidate.title));
482
+ const pool = provinceCandidates.length > 0 ? provinceCandidates : candidates;
483
+ const scored = pool
484
+ .map((candidate) => ({
485
+ candidate,
486
+ score: candidate.score +
487
+ (/persentase|tingkat|indeks|rasio/i.test(candidate.title) ? 10 : 0) -
488
+ (/kabupaten|kecamatan/i.test(candidate.title) ? 20 : 0),
489
+ }))
490
+ .sort((a, b) => b.score - a.score);
491
+ for (const { candidate } of scored.slice(0, 5)) {
492
+ const periods = await listAllPeriodsSafe(client, "0000", candidate.var_id);
493
+ const hasRecent = periods.some((period) => {
494
+ const year = Number(getPeriodLabel(period).match(/\d{4}/)?.[0] ?? 0);
495
+ return year >= 2020;
496
+ });
497
+ if (hasRecent)
498
+ return candidate;
740
499
  }
741
- // Fallback to general resolver
742
- return resolveVariable(client, store, query, "0000");
500
+ return scored[0]?.candidate ?? null;
743
501
  }
744
502
  //# sourceMappingURL=analysis.tools.js.map