bps-mcp-server 0.16.4 → 0.18.0

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 (55) hide show
  1. package/README.md +13 -5
  2. package/dist/client/bps-client.d.ts +10 -1
  3. package/dist/client/bps-client.d.ts.map +1 -1
  4. package/dist/client/bps-client.js +66 -13
  5. package/dist/client/bps-client.js.map +1 -1
  6. package/dist/client/request-scheduler.d.ts +29 -0
  7. package/dist/client/request-scheduler.d.ts.map +1 -0
  8. package/dist/client/request-scheduler.js +75 -0
  9. package/dist/client/request-scheduler.js.map +1 -0
  10. package/dist/config/kabkota-indicators.d.ts +30 -0
  11. package/dist/config/kabkota-indicators.d.ts.map +1 -0
  12. package/dist/config/kabkota-indicators.js +49 -0
  13. package/dist/config/kabkota-indicators.js.map +1 -0
  14. package/dist/server.d.ts.map +1 -1
  15. package/dist/server.js +34 -0
  16. package/dist/server.js.map +1 -1
  17. package/dist/services/data-formatter.d.ts +28 -0
  18. package/dist/services/data-formatter.d.ts.map +1 -1
  19. package/dist/services/data-formatter.js +74 -0
  20. package/dist/services/data-formatter.js.map +1 -1
  21. package/dist/services/intent-detector.d.ts.map +1 -1
  22. package/dist/services/intent-detector.js +1 -0
  23. package/dist/services/intent-detector.js.map +1 -1
  24. package/dist/services/kabkota-ranking.d.ts +61 -0
  25. package/dist/services/kabkota-ranking.d.ts.map +1 -0
  26. package/dist/services/kabkota-ranking.js +378 -0
  27. package/dist/services/kabkota-ranking.js.map +1 -0
  28. package/dist/services/period-resolver.d.ts +29 -0
  29. package/dist/services/period-resolver.d.ts.map +1 -0
  30. package/dist/services/period-resolver.js +132 -0
  31. package/dist/services/period-resolver.js.map +1 -0
  32. package/dist/services/region-series.d.ts +65 -0
  33. package/dist/services/region-series.d.ts.map +1 -0
  34. package/dist/services/region-series.js +396 -0
  35. package/dist/services/region-series.js.map +1 -0
  36. package/dist/services/variable-search.d.ts.map +1 -1
  37. package/dist/services/variable-search.js +2 -1
  38. package/dist/services/variable-search.js.map +1 -1
  39. package/dist/tools/analysis.tools.d.ts.map +1 -1
  40. package/dist/tools/analysis.tools.js +370 -285
  41. package/dist/tools/analysis.tools.js.map +1 -1
  42. package/dist/tools/dynamic-data.tools.d.ts.map +1 -1
  43. package/dist/tools/dynamic-data.tools.js +145 -32
  44. package/dist/tools/dynamic-data.tools.js.map +1 -1
  45. package/dist/tools/search.tools.d.ts.map +1 -1
  46. package/dist/tools/search.tools.js +14 -2
  47. package/dist/tools/search.tools.js.map +1 -1
  48. package/dist/tools/smart.tools.d.ts.map +1 -1
  49. package/dist/tools/smart.tools.js +49 -11
  50. package/dist/tools/smart.tools.js.map +1 -1
  51. package/dist/utils/error.d.ts +11 -1
  52. package/dist/utils/error.d.ts.map +1 -1
  53. package/dist/utils/error.js +50 -2
  54. package/dist/utils/error.js.map +1 -1
  55. package/package.json +1 -1
@@ -1,83 +1,259 @@
1
1
  import { z } from "zod/v4";
2
2
  import { appendAttribution } from "../services/attribution.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";
3
+ import { normalizeKeyword } from "../services/learning.js";
4
+ import { resolveVariable as resolveVariableShared, searchVariables } from "../services/variable-search.js";
5
+ import { formatKabkotaRanking, rankKabkota } from "../services/kabkota-ranking.js";
6
+ import { findKabkotaIndicator } from "../config/kabkota-indicators.js";
7
+ import { buildNationalBreakdown, buildRegionSeries } from "../services/region-series.js";
8
+ import { parseYearSpec } from "../services/period-resolver.js";
9
+ import { extractVervarId, getVervarEntries } from "../services/dynamic-data-utils.js";
10
+ import { formatUnit, getPeriodId, getPeriodLabel, getVariableTitle, getVariableUnit, } from "../client/fields.js";
7
11
  /**
8
12
  * Multi-region comparison, trend, and ranking tools.
9
13
  * Each handles a common multi-step query pattern in a single tool call.
10
14
  */
11
15
  export function registerAnalysisTools(server, client, resolver, config, store) {
12
16
  // ---------- compare_data ----------
13
- server.tool("compare_data", `Bandingkan data statistik antar wilayah dalam 1 langkah.
14
- Gunakan tool ini ketika user ingin membandingkan data antara 2 atau lebih wilayah.
15
- Catatan: hanya mendukung perbandingan untuk 1 tahun. Untuk perbandingan multi-tahun, gunakan get_trend per wilayah.
17
+ server.tool("compare_data", `Bandingkan data statistik antar wilayah dalam 1 langkah — termasuk multi-tahun.
18
+
19
+ Untuk 1 tahun: isi \`year\` (atau kosongkan untuk data terbaru).
20
+ Untuk beberapa tahun sekaligus (mis. "5 tahun terakhir"): isi \`years\`
21
+ ("2021-2025", "2021,2022,2023") atau \`start_year\`/\`end_year\`. Hasilnya berupa
22
+ tabel wilayah × tahun + selisih + tren, dalam SATU panggilan. Tidak perlu
23
+ memanggil get_trend per wilayah.
16
24
 
17
25
  Contoh query user yang cocok untuk tool ini:
18
26
  - "bandingkan kemiskinan Jawa Timur dan Jawa Barat"
19
- - "IPM DKI Jakarta vs Banten vs Jawa Barat"
20
- - "perbandingan pengangguran antar provinsi di Jawa"
27
+ - "perbandingan jumlah penduduk miskin Jatim vs Jabar 5 tahun terakhir"
28
+ - "IPM DKI Jakarta vs Banten vs Jawa Barat 2020-2024"
21
29
  - "mana yang lebih tinggi kemiskinan Jatim atau Jabar?"`, {
22
30
  query: z.string().describe("Indikator yang dibandingkan (misal: kemiskinan, pengangguran, IPM, penduduk)"),
23
- regions: z.string().describe("Nama wilayah dipisah koma (misal: 'Jawa Timur, Jawa Barat, Jawa Tengah')"),
24
- year: z.string().optional().describe("Tahun data (misal: '2023'). Kosongkan untuk data terbaru."),
25
- }, async ({ query, regions, year }) => {
31
+ regions: z.string().optional().describe("Nama wilayah dipisah koma (misal: 'Jawa Timur, Jawa Barat, Jawa Tengah')"),
32
+ wilayah: z.string().optional().describe("Alias untuk 'regions'."),
33
+ provinces: z.string().optional().describe("Alias untuk 'regions'."),
34
+ year: z.string().optional().describe("Satu tahun (misal: '2023'). Kosongkan untuk data terbaru."),
35
+ years: z.string().optional().describe("Beberapa tahun: '2021-2025' atau '2021,2022,2023'. Mengalahkan parameter year."),
36
+ start_year: z.string().optional().describe("Tahun awal untuk perbandingan multi-tahun"),
37
+ end_year: z.string().optional().describe("Tahun akhir untuk perbandingan multi-tahun"),
38
+ }, async ({ query, regions: regionsInput, wilayah, provinces, year, years, start_year, end_year }) => {
26
39
  try {
40
+ const regions = regionsInput ?? wilayah ?? provinces ?? "";
41
+ if (!regions.trim()) {
42
+ return { content: [{ type: "text", text: "Parameter 'regions' wajib diisi, misal regions=\"Jawa Timur, Jawa Barat\"." }], isError: true };
43
+ }
27
44
  const regionList = regions.split(",").map(r => r.trim()).filter(Boolean);
28
45
  if (regionList.length < 2) {
29
46
  return { content: [{ type: "text", text: "Minimal 2 wilayah untuk perbandingan." }], isError: true };
30
47
  }
31
- if (year && /[,;]/.test(year)) {
48
+ const spec = years ?? (start_year ? `${start_year}-${end_year ?? start_year}` : year);
49
+ const parsed = parseYearSpec(spec);
50
+ const multiYear = parsed.years.length > 1 || parsed.periodIds.length > 1;
51
+ const resolvedRegions = await Promise.all(regionList.map(async (regionName) => ({
52
+ regionName,
53
+ resolved: await resolver.resolve(regionName),
54
+ })));
55
+ const seriesList = [];
56
+ let usedNationalTable = false;
57
+ // Fast path: all regions are provinces and one national table already
58
+ // lists them all. One request, one unit, full precision.
59
+ const provinceOnly = resolvedRegions.length > 1 &&
60
+ resolvedRegions.every((item) => item.resolved !== null &&
61
+ /^\d{4}$/.test(item.resolved.domainId) &&
62
+ item.resolved.domainId.endsWith("00"));
63
+ if (provinceOnly) {
64
+ const breakdown = await buildNationalBreakdown(client, query, resolvedRegions.map((item) => ({
65
+ name: item.regionName,
66
+ domainName: item.resolved.domainName,
67
+ })), parsed.years);
68
+ if (breakdown) {
69
+ usedNationalTable = true;
70
+ for (const item of resolvedRegions) {
71
+ const points = breakdown.byRegion.get(item.resolved.domainName) ?? [];
72
+ seriesList.push({
73
+ region: item.regionName,
74
+ domain: "0000",
75
+ domainName: item.resolved.domainName,
76
+ varId: breakdown.varId,
77
+ varTitle: breakdown.varTitle,
78
+ unit: breakdown.unit,
79
+ seriesLabel: breakdown.seriesLabel,
80
+ points,
81
+ missingYears: parsed.years.filter((year) => !points.some((point) => point.year === year)),
82
+ availableYears: [],
83
+ notes: [],
84
+ });
85
+ }
86
+ }
87
+ }
88
+ if (!usedNationalTable) {
89
+ for (const item of resolvedRegions) {
90
+ if (!item.resolved) {
91
+ seriesList.push({
92
+ region: item.regionName, domain: "?", domainName: item.regionName, varId: null,
93
+ varTitle: query, points: [], missingYears: parsed.years, availableYears: [],
94
+ notes: ["Wilayah tidak ditemukan."],
95
+ });
96
+ continue;
97
+ }
98
+ const series = await buildRegionSeries(client, store, query, item.regionName, item.resolved.domainId, item.resolved.domainName, { years: parsed.years, maxCandidates: 6 });
99
+ seriesList.push(series);
100
+ }
101
+ }
102
+ const withData = seriesList.filter((series) => series.points.length > 0);
103
+ if (withData.length === 0) {
104
+ const detail = seriesList.map((series) => `- ${series.region}: ${series.notes.join(" ") || "tidak ada data"}`);
32
105
  return {
33
- content: [{ type: "text", text: "compare_data hanya mendukung 1 tahun. Gunakan get_trend per wilayah untuk multi-tahun." }],
34
- isError: true,
106
+ content: [{
107
+ type: "text",
108
+ text: appendAttribution(`Tidak ada data "${query}"${spec ? ` untuk ${spec}` : ""} di ${regionList.join(", ")}.\n\n${detail.join("\n")}\n\n` +
109
+ `**Saran:** pakai find_variable(keyword="${query}", domain="<kode provinsi>") untuk melihat variabel yang tersedia, atau get_ranking untuk membandingkan seluruh provinsi.`),
110
+ }],
35
111
  };
36
112
  }
37
- const results = [];
38
- for (const regionName of regionList) {
39
- const resolved = await resolver.resolve(regionName);
40
- if (!resolved) {
41
- results.push({ region: regionName, domain: "?", value: "Wilayah tidak ditemukan", rawValue: null, varTitle: "" });
42
- continue;
43
- }
44
- const { domainId, domainName } = resolved;
45
- const data = await fetchDataForDomain(client, store, query, domainId, year);
46
- results.push({
47
- region: domainName,
48
- domain: domainId,
49
- value: data.value,
50
- rawValue: data.rawValue,
51
- unit: data.unit,
52
- varTitle: data.varTitle,
53
- period: data.period,
113
+ const varTitle = withData[0].varTitle || query;
114
+ const sameTitle = new Set(withData.map((series) => series.varTitle)).size === 1;
115
+ const displayTitle = sameTitle
116
+ ? varTitle
117
+ : query.charAt(0).toUpperCase() + query.slice(1);
118
+ // Provinces sometimes expose the same count in different units
119
+ // ("Ribu Jiwa" vs "Juta Jiwa"). Normalize before diffing, otherwise
120
+ // the "Selisih" column is nonsense.
121
+ const originalUnits = [...new Set(withData.map((series) => series.unit).filter(Boolean))];
122
+ const massScale = (unit) => {
123
+ const normalized = (unit ?? "").toLowerCase().trim();
124
+ if (normalized === "juta jiwa" || normalized === "juta orang")
125
+ return 1000;
126
+ if (normalized === "ribu jiwa" || normalized === "ribu orang")
127
+ return 1;
128
+ if (normalized === "jiwa" || normalized === "orang")
129
+ return 0.001;
130
+ return null;
131
+ };
132
+ const scales = withData.map((series) => massScale(series.unit));
133
+ const canNormalize = withData.length > 1 && scales.every((scale) => scale !== null);
134
+ let unitNote = "";
135
+ if (canNormalize) {
136
+ withData.forEach((series, index) => {
137
+ const scale = scales[index];
138
+ series.points = series.points.map((point) => ({ ...point, value: point.value * scale }));
139
+ series.unit = "Ribu Jiwa";
54
140
  });
141
+ if (originalUnits.length > 1) {
142
+ unitNote = `Satuan diseragamkan ke Ribu Jiwa (asli: ${originalUnits.join(", ")}).`;
143
+ }
55
144
  }
56
- // Format output
57
- const varTitle = results.find(r => r.varTitle)?.varTitle || query;
58
- const showPeriod = !year;
145
+ const units = new Set(withData.map((series) => series.unit).filter(Boolean));
146
+ const unitLabel = units.size === 1 ? formatUnit([...units][0]) : "";
147
+ const seriesLabel = withData.find((series) => series.seriesLabel)?.seriesLabel;
148
+ const formatValue = (series, value) => {
149
+ const unit = formatUnit(series.unit);
150
+ return `${value.toLocaleString("id-ID", { maximumFractionDigits: 2 })}${unit ? ` ${unit}` : ""}`;
151
+ };
152
+ const unitInTitle = unitLabel
153
+ ? new RegExp(`\\(${unitLabel.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\)`, "i").test(displayTitle)
154
+ : true;
59
155
  const lines = [
60
- `## Perbandingan: ${varTitle}`,
61
- year ? `**Tahun:** ${year}` : "**Tahun:** Terbaru",
62
- "",
63
- showPeriod ? "| Wilayah | Nilai | Periode |" : "| Wilayah | Nilai |",
64
- showPeriod ? "| --- | --- | --- |" : "| --- | --- |",
156
+ `## Perbandingan: ${displayTitle}${unitLabel && !unitInTitle ? ` (${unitLabel})` : ""}`,
157
+ `**Wilayah:** ${withData.map((series) => series.domainName).join(", ")}`,
65
158
  ];
66
- for (const r of results) {
67
- lines.push(showPeriod ? `| ${r.region} | ${r.value} | ${r.period ?? "-"} |` : `| ${r.region} | ${r.value} |`);
68
- }
69
- // Add analysis hint (use the raw numeric value, not the localized string)
70
- const numericResults = results.filter((result) => result.rawValue !== null);
71
- if (numericResults.length >= 2) {
72
- const values = [...numericResults].sort((a, b) => b.rawValue - a.rawValue);
73
- lines.push("");
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));
159
+ if (!multiYear) {
160
+ const first = withData[0];
161
+ const periodLabel = first.points[0]?.label ?? "";
162
+ lines.push(`**Periode:** ${periodLabel || "Terbaru"}${seriesLabel ? ` | **Seri:** ${seriesLabel}` : ""}`, "");
163
+ if (parsed.hasSpec || (spec && !year)) {
164
+ // fall through to table
165
+ }
166
+ lines.push("| Wilayah | Nilai | Periode |", "| --- | --- | --- |");
167
+ for (const series of seriesList) {
168
+ const point = series.points[0];
169
+ lines.push(point
170
+ ? `| ${series.region} | ${formatValue(series, point.value)} | ${point.label} |`
171
+ : `| ${series.region} | N/A | - |`);
172
+ }
173
+ const numeric = seriesList
174
+ .filter((series) => series.points.length > 0)
175
+ .map((series) => ({ region: series.region, value: series.points[0].value, text: formatValue(series, series.points[0].value) }));
176
+ if (numeric.length >= 2) {
177
+ const sorted = [...numeric].sort((a, b) => b.value - a.value);
178
+ lines.push("");
179
+ lines.push(`**Tertinggi:** ${sorted[0].region} (${sorted[0].text})`);
180
+ lines.push(`**Terendah:** ${sorted[sorted.length - 1].region} (${sorted[sorted.length - 1].text})`);
181
+ }
77
182
  if (units.size > 1) {
78
183
  lines.push("", `_Catatan: satuan berbeda antar wilayah (${[...units].join(", ")}); bandingkan dengan hati-hati._`);
79
184
  }
80
185
  }
186
+ else {
187
+ const yearKeys = [...new Set(withData.flatMap((series) => series.points.map((point) => point.year)))].sort();
188
+ const yearRange = yearKeys.length > 1 ? `${yearKeys[0]}–${yearKeys[yearKeys.length - 1]}` : yearKeys[0];
189
+ lines.push(`**Periode:** ${yearRange}${seriesLabel ? ` | **Seri:** ${seriesLabel}` : ""}`, "");
190
+ const header = ["Tahun", ...withData.map((series) => series.region)];
191
+ if (withData.length === 2)
192
+ header.push("Selisih");
193
+ lines.push(`| ${header.join(" | ")} |`);
194
+ lines.push(`| ${header.map(() => "---").join(" | ")} |`);
195
+ for (const key of yearKeys) {
196
+ const cells = withData.map((series) => {
197
+ const point = series.points.find((candidate) => candidate.year === key);
198
+ return point ? formatValue(series, point.value) : "-";
199
+ });
200
+ const row = [key, ...cells];
201
+ if (withData.length === 2) {
202
+ const left = withData[0].points.find((point) => point.year === key)?.value;
203
+ const right = withData[1].points.find((point) => point.year === key)?.value;
204
+ const diff = left !== undefined && right !== undefined
205
+ ? (left - right).toLocaleString("id-ID", { maximumFractionDigits: 2 })
206
+ : "-";
207
+ row.push(diff);
208
+ }
209
+ lines.push(`| ${row.join(" | ")} |`);
210
+ }
211
+ lines.push("");
212
+ for (const series of withData) {
213
+ const points = series.points;
214
+ if (points.length < 2) {
215
+ lines.push(`**${series.region}:** hanya 1 tahun data (${points[0]?.year}).`);
216
+ continue;
217
+ }
218
+ const first = points[0];
219
+ const last = points[points.length - 1];
220
+ const diff = last.value - first.value;
221
+ const pct = first.value !== 0 ? ((diff / first.value) * 100).toFixed(1) : null;
222
+ const direction = diff > 0 ? "naik" : diff < 0 ? "turun" : "stabil";
223
+ lines.push(`**${series.region}:** ${direction} ${pct !== null ? `${pct}% ` : ""}dari ` +
224
+ `${first.value.toLocaleString("id-ID")} (${first.year}) ke ${last.value.toLocaleString("id-ID")} (${last.year})`);
225
+ }
226
+ }
227
+ const extraNotes = seriesList.flatMap((series) => series.notes.map((note) => `${series.region}: ${note}`));
228
+ if (unitNote)
229
+ extraNotes.unshift(unitNote);
230
+ if (usedNationalTable) {
231
+ extraNotes.unshift("Satu tabel nasional dipakai untuk semua wilayah, sehingga satuan dan presisinya konsisten.");
232
+ }
233
+ if (extraNotes.length > 0) {
234
+ lines.push("", "_Catatan:_");
235
+ for (const note of [...new Set(extraNotes)])
236
+ lines.push(`- ${note}`);
237
+ }
238
+ const sourceKeys = new Set();
239
+ const sources = seriesList
240
+ .filter((series) => series.varId !== null)
241
+ .filter((series) => {
242
+ const key = `${series.varId}:${series.domain}`;
243
+ if (sourceKeys.has(key))
244
+ return false;
245
+ sourceKeys.add(key);
246
+ return true;
247
+ })
248
+ .map((series) => {
249
+ const shared = seriesList.filter((other) => other.varId === series.varId && other.domain === series.domain);
250
+ const who = shared.length > 1 ? "semua wilayah" : series.region;
251
+ return `${who}: ${series.varTitle} (var ${series.varId}, domain ${series.domain})`;
252
+ });
253
+ if (sources.length > 0) {
254
+ lines.push("", `_Sumber variabel — ${sources.join("; ")}._`);
255
+ lines.push("_Angka di atas diambil dari baris agregat resmi tiap tabel; jangan menjumlahkan baris kabupaten/kota._");
256
+ }
81
257
  return { content: [{ type: "text", text: appendAttribution(lines.join("\n")) }] };
82
258
  }
83
259
  catch (error) {
@@ -86,7 +262,9 @@ Contoh query user yang cocok untuk tool ini:
86
262
  });
87
263
  // ---------- get_trend ----------
88
264
  server.tool("get_trend", `Ambil data time-series (tren multi-tahun) dalam 1 langkah.
89
- Gunakan tool ini ketika user ingin melihat perkembangan/tren data dari tahun ke tahun.
265
+ Gunakan tool ini ketika user ingin melihat perkembangan/tren data dari SATU wilayah.
266
+ Untuk membandingkan beberapa wilayah sekaligus (termasuk multi-tahun), pakai
267
+ compare_data dengan \`years\`/\`start_year\`/\`end_year\` — satu panggilan untuk semuanya.
90
268
 
91
269
  Contoh query user yang cocok untuk tool ini:
92
270
  - "tren kemiskinan Indonesia 2019-2023"
@@ -95,9 +273,12 @@ Contoh query user yang cocok untuk tool ini:
95
273
  - "data kemiskinan Jawa Barat dari tahun ke tahun"`, {
96
274
  query: z.string().describe("Indikator yang dianalisis (misal: kemiskinan, pengangguran, IPM)"),
97
275
  region: z.string().default("Indonesia").describe("Nama wilayah"),
276
+ wilayah: z.string().optional().describe("Alias untuk 'region'."),
98
277
  start_year: z.string().default("2019").describe("Tahun awal"),
99
278
  end_year: z.string().default("2024").describe("Tahun akhir"),
100
- }, async ({ query, region, start_year, end_year }) => {
279
+ years: z.string().optional().describe("Alternatif rentang: '2021-2025' atau '2021,2022,2023' (mengalahkan start/end)."),
280
+ }, async ({ query, region: regionInput, wilayah, start_year, end_year, years }) => {
281
+ const region = regionInput || wilayah || "Indonesia";
101
282
  try {
102
283
  // Resolve domain
103
284
  let domain = "0000";
@@ -110,157 +291,98 @@ Contoh query user yang cocok untuk tool ini:
110
291
  domain = resolved.domainId;
111
292
  domainName = resolved.domainName;
112
293
  }
113
- const startNum = parseInt(start_year, 10);
114
- const endNum = parseInt(end_year, 10);
115
- if (!Number.isFinite(startNum) || !Number.isFinite(endNum) || startNum > endNum) {
294
+ const spec = years ?? `${start_year}-${end_year}`;
295
+ const parsedYears = parseYearSpec(spec);
296
+ const yearList = parsedYears.years;
297
+ if (yearList.length === 0) {
116
298
  return { content: [{ type: "text", text: "Rentang tahun tidak valid." }], isError: true };
117
299
  }
118
- if (endNum - startNum > 10) {
300
+ if (yearList.length > 11) {
119
301
  return {
120
302
  content: [{ type: "text", text: "Rentang maksimal 10 tahun per panggilan. Pecah menjadi beberapa rentang." }],
121
303
  isError: true,
122
304
  };
123
305
  }
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,
306
+ const series = await buildRegionSeries(client, store, query, region, domain, domainName, {
307
+ years: yearList,
308
+ maxCandidates: 6,
128
309
  });
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)
133
- const indicators = await client.listStrategicIndicators(domain);
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")) }] };
310
+ if (series.points.length === 0) {
311
+ // Fallback: headline strategic indicators are often the only series
312
+ // BPS exposes for topics like inflasi (monthly, latest month only).
313
+ try {
314
+ const indicators = await client.listStrategicIndicators(domain);
315
+ const kw = normalizeKeyword(query);
316
+ for (const ind of indicators.data ?? []) {
317
+ const title = ind.title.toLowerCase();
318
+ if (title.includes(kw) ||
319
+ kw.split(/\s+/).some((word) => word.length > 2 && title.includes(word))) {
320
+ const val = typeof ind.value === "number" ? ind.value.toLocaleString("id-ID") : ind.value;
321
+ const unit = formatUnit(ind.unit);
322
+ const headline = [
323
+ `## Tren/Headline ${ind.title}`,
324
+ `**Wilayah:** ${domainName} (${domain})`,
325
+ "",
326
+ "*Catatan: menggunakan data headline indikator strategis karena variabel time-series tidak ditemukan.*",
327
+ "",
328
+ "| Periode | Nilai |",
329
+ "| --- | --- |",
330
+ `| ${ind.periode} | ${val}${unit ? ` ${unit}` : ""} |`,
331
+ ];
332
+ if (ind.name)
333
+ headline.push("", `_${ind.name}_`);
334
+ return { content: [{ type: "text", text: appendAttribution(headline.join("\n")) }] };
335
+ }
153
336
  }
154
337
  }
155
- return { content: [{ type: "text", text: appendAttribution(`Tidak ditemukan variabel "${query}" untuk ${domainName}.`) }] };
156
- }
157
- let periods = resolved?.periods ?? [];
158
- if (periods.length === 0) {
159
- await invalidateVar(query, domain, store);
160
- const retry = await searchVariables(client, domain, query, { maxCandidates: 10 });
161
- const retryVar = retry[0];
162
- if (!retryVar) {
163
- return { content: [{ type: "text", text: appendAttribution(`Tidak ditemukan variabel "${query}" untuk ${domainName}.`) }] };
338
+ catch {
339
+ // fall through to the diagnostic message
164
340
  }
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);
177
- }
178
- const periodIds = years.map((year) => yearToPeriod.get(year)).filter((id) => !!id);
179
- if (periodIds.length === 0) {
180
- return { content: [{ type: "text", text: appendAttribution(`Tidak ada data periode ${start_year}-${end_year} untuk variabel ini.`) }] };
181
- }
182
- const result = await client.getDynamicData(domain, String(varData.var_id), periodIds.join(","));
183
- if (!result.datacontent || Object.keys(result.datacontent).length === 0) {
184
- return { content: [{ type: "text", text: appendAttribution(`Data tidak tersedia untuk periode ${start_year}-${end_year}.`) }] };
185
- }
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) {
341
+ const notes = series.notes.length > 0 ? `\n\n${series.notes.join("\n")}` : "";
194
342
  return {
195
343
  content: [{
196
344
  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.`),
345
+ text: appendAttribution(`Tidak ada data "${query}" untuk ${domainName} pada ${spec}.${notes}\n\n` +
346
+ `**Saran:** find_variable(keyword="${query}", domain="${domain}") untuk melihat variabel dan cakupan tahunnya.`),
200
347
  }],
201
348
  };
202
349
  }
203
- const sortedPeriodIds = [...periodIds].sort((a, b) => b.length - a.length);
204
- const trendData = [];
205
- for (const [key, value] of Object.entries(result.datacontent)) {
206
- if (typeof value !== "number")
207
- continue;
208
- if (aggregateVervar && extractVervarId(key, result) !== aggregateVervar)
209
- continue;
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 });
219
- }
220
- trendData.sort((a, b) => a.year.localeCompare(b.year));
221
- if (trendData.length === 0) {
222
- return { content: [{ type: "text", text: appendAttribution(`Data tren tidak dapat di-parse.`) }] };
223
- }
224
- // Format output
225
- const unit = formatUnit(getVariableUnit(varData) ?? varData.unit);
226
- const title = getVariableTitle(varData) || varData.title;
350
+ const unit = formatUnit(series.unit);
227
351
  const lines = [
228
- `## Tren ${title}${unit ? ` (${unit})` : ""}`,
229
- `**Wilayah:** ${domainName} | **Periode:** ${start_year}–${end_year}`,
352
+ `## Tren ${series.varTitle}${unit ? ` (${unit})` : ""}`,
353
+ `**Wilayah:** ${domainName} | **Periode:** ${yearList[0]}–${yearList[yearList.length - 1]}`,
230
354
  ];
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._`);
355
+ if (series.seriesLabel) {
356
+ lines.push(`**Seri:** ${series.seriesLabel}`);
357
+ if (/januari|februari|maret|april|mei|juni|juli|agustus|september|oktober|november|desember|semester/i.test(series.seriesLabel)) {
358
+ lines.push(`_Catatan: variabel ini punya beberapa sub-periode; nilai yang ditampilkan adalah seri ${series.seriesLabel} pada setiap tahun._`);
235
359
  }
236
360
  }
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.`);
361
+ if (series.missingYears.length > 0 && series.points.length > 0) {
362
+ lines.push(`**Catatan:** tahun ${series.missingYears.join(", ")} tidak tersedia untuk variabel ini.`);
240
363
  }
241
364
  lines.push("", "| Periode | Nilai | Perubahan |", "| --- | --- | --- |");
242
- for (let i = 0; i < trendData.length; i++) {
243
- const d = trendData[i];
365
+ for (let i = 0; i < series.points.length; i++) {
366
+ const point = series.points[i];
244
367
  let change = "-";
245
368
  if (i > 0) {
246
- const previous = trendData[i - 1].value;
247
- const diff = d.value - previous;
369
+ const previous = series.points[i - 1].value;
370
+ const diff = point.value - previous;
248
371
  if (previous !== 0) {
249
372
  const pct = ((diff / previous) * 100).toFixed(1);
250
373
  change = `${diff > 0 ? "+" : ""}${pct}%`;
251
374
  }
252
375
  }
253
- lines.push(`| ${d.year} | ${d.value.toLocaleString("id-ID")} | ${change} |`);
376
+ lines.push(`| ${point.year} | ${point.value.toLocaleString("id-ID")} | ${change} |`);
254
377
  }
255
- // Summary
256
- if (trendData.length >= 2) {
257
- const first = trendData[0].value;
258
- const last = trendData[trendData.length - 1].value;
259
- if (first !== 0) {
260
- const totalChange = ((last - first) / first * 100).toFixed(1);
261
- const trend = last > first ? "naik" : last < first ? "turun" : "stabil";
378
+ if (series.points.length >= 2) {
379
+ const first = series.points[0];
380
+ const last = series.points[series.points.length - 1];
381
+ if (first.value !== 0) {
382
+ const totalChange = ((last.value - first.value) / first.value * 100).toFixed(1);
383
+ const trend = last.value > first.value ? "naik" : last.value < first.value ? "turun" : "stabil";
262
384
  lines.push("");
263
- lines.push(`**Tren:** ${trend} ${totalChange}% dari ${trendData[0].year} ke ${trendData[trendData.length - 1].year}`);
385
+ lines.push(`**Tren:** ${trend} ${totalChange}% dari ${first.year} ke ${last.year}`);
264
386
  }
265
387
  }
266
388
  return { content: [{ type: "text", text: appendAttribution(lines.join("\n")) }] };
@@ -270,21 +392,80 @@ Contoh query user yang cocok untuk tool ini:
270
392
  }
271
393
  });
272
394
  // ---------- get_ranking ----------
273
- server.tool("get_ranking", `Ambil peringkat/ranking provinsi berdasarkan indikator tertentu dalam 1 langkah.
274
- Gunakan tool ini ketika user ingin melihat peringkat, top-N, atau perbandingan seluruh provinsi.
395
+ server.tool("get_ranking", `Ambil peringkat/ranking wilayah berdasarkan indikator tertentu dalam 1 langkah.
275
396
 
276
- Contoh query user yang cocok untuk tool ini:
397
+ Mendukung dua level wilayah:
398
+ - **level="kabkota"** — semua kabupaten/kota se-Indonesia (BPS tidak punya tabel
399
+ nasional untuk ini; server menyapu tabel tiap provinsi sekaligus).
400
+ - **level="provinsi"** — 34–38 provinsi dari tabel nasional.
401
+
402
+ level="auto" (default) memilih kabkota bila query menyebut kabupaten/kota/daerah,
403
+ selain itu provinsi. Untuk ranking kabupaten/kota, sebutkan level secara eksplisit
404
+ agar tidak ambigu.
405
+
406
+ Contoh query user yang cocok:
407
+ - "10 kabupaten/kota dengan tingkat pengangguran tertinggi" → level="kabkota"
277
408
  - "10 provinsi termiskin di Indonesia"
278
409
  - "peringkat IPM seluruh provinsi"
279
410
  - "provinsi dengan pengangguran tertinggi"
280
- - "ranking kemiskinan per provinsi 2023"
281
- - "5 provinsi dengan penduduk terbanyak"`, {
282
- query: z.string().describe("Indikator untuk ranking (misal: kemiskinan, pengangguran, IPM, penduduk)"),
283
- top_n: z.number().int().min(1).max(40).default(10).describe("Jumlah data yang ditampilkan (default 10, max 40 untuk semua provinsi)"),
411
+ - "5 kota dengan pengangguran tertinggi di Jawa Timur" → level="kabkota", provinces="Jawa Timur"`, {
412
+ query: z.string().describe("Indikator untuk ranking (misal: kemiskinan, pengangguran/TPT, IPM, penduduk)"),
413
+ top_n: z.number().int().min(1).max(200).default(10).describe("Jumlah data yang ditampilkan (default 10, max 200 untuk kabupaten/kota, 40 untuk provinsi)"),
284
414
  order: z.enum(["highest", "lowest"]).default("highest").describe("Urutan: 'highest' (tertinggi dulu) atau 'lowest' (terendah dulu)"),
285
- year: z.string().optional().describe("Tahun data. Kosongkan untuk terbaru."),
286
- }, async ({ query, top_n, order, year }) => {
415
+ year: z.string().optional().describe("Tahun data. Kosongkan untuk tahun bersama terbaru (default) agar semua wilayah sebanding."),
416
+ period_mode: z.enum(["common", "latest"]).default("common").describe("'common' (default): seragamkan ke tahun bersama terbaru; 'latest': pakai periode terbaru tiap provinsi (tahun bisa campur)"),
417
+ level: z.enum(["provinsi", "kabkota"]).describe("WAJIB dipilih. 'kabkota' bila jawaban perlu kabupaten/kota (satu panggilan, ~380 wilayah, tahun diseragamkan); 'provinsi' untuk peringkat 34-38 provinsi"),
418
+ provinces: z.string().optional().describe("Batasi ke provinsi tertentu (nama dipisah koma, misal 'Jawa Timur, Jawa Tengah'). Hanya untuk level=kabkota."),
419
+ format: z.enum(["markdown", "json"]).default("markdown").describe("Format keluaran: 'markdown' untuk dibaca, 'json' untuk diproses lebih lanjut"),
420
+ }, async ({ query, top_n, order, year, level, provinces, format, period_mode }) => {
287
421
  try {
422
+ const useKabkota = level === "kabkota";
423
+ if (useKabkota) {
424
+ const provinceList = provinces
425
+ ? provinces.split(",").map((name) => name.trim()).filter(Boolean)
426
+ : undefined;
427
+ const ranking = await rankKabkota(client, resolver, {
428
+ query,
429
+ year,
430
+ topN: Math.min(top_n, 200),
431
+ order,
432
+ provinces: provinceList,
433
+ periodMode: period_mode,
434
+ });
435
+ if (ranking.rows.length === 0) {
436
+ return {
437
+ content: [{
438
+ type: "text",
439
+ text: appendAttribution(`Tidak ada data kabupaten/kota untuk "${query}".\n\n` +
440
+ `**Saran:**\n` +
441
+ `1. Panggil get_ranking(level="provinsi") untuk angka provinsi\n` +
442
+ `2. Gunakan find_variable(keyword="${query}", domain="<kode provinsi>") lalu get_dynamic_data\n` +
443
+ `3. Gunakan allstats_search(query="${query} kabupaten", content="table")`),
444
+ }],
445
+ };
446
+ }
447
+ if (format === "json") {
448
+ return {
449
+ content: [{
450
+ type: "text",
451
+ text: JSON.stringify({
452
+ level: "kabkota",
453
+ indicator: ranking.indicatorLabel,
454
+ order,
455
+ rows: ranking.rows,
456
+ totalRegions: ranking.totalRegions,
457
+ provincesCovered: ranking.provincesCovered,
458
+ periods: ranking.periods,
459
+ targetYear: ranking.targetYear,
460
+ missingProvinces: ranking.missing,
461
+ }, null, 2),
462
+ }],
463
+ };
464
+ }
465
+ return {
466
+ content: [{ type: "text", text: appendAttribution(formatKabkotaRanking(ranking, { order, query })) }],
467
+ };
468
+ }
288
469
  // For ranking, we need national-level data that breaks down by province
289
470
  // Prefer variables with "Provinsi" in title for proper provincial breakdown
290
471
  const varData = await resolveVariableForRanking(client, store, query);
@@ -349,6 +530,14 @@ Contoh query user yang cocok untuk tool ini:
349
530
  lines.push("");
350
531
  lines.push(`_Menampilkan ${display.length} dari ${rankings.length} wilayah._`);
351
532
  }
533
+ // Point agents at the kab/kota equivalent so they do not sweep the
534
+ // provinces by hand after getting a province-only answer.
535
+ const kabkotaIndicator = findKabkotaIndicator(query);
536
+ if (kabkotaIndicator) {
537
+ lines.push("");
538
+ lines.push(`_Butuh level kabupaten/kota? Panggil \`get_ranking(query="${query}", level="kabkota")\` — ` +
539
+ `satu panggilan, semua kab/kota se-Indonesia, tahun diseragamkan._`);
540
+ }
352
541
  return { content: [{ type: "text", text: appendAttribution(lines.join("\n")) }] };
353
542
  }
354
543
  catch (error) {
@@ -364,110 +553,6 @@ async function listAllPeriodsSafe(client, domain, varId) {
364
553
  }
365
554
  return client.listPeriods(domain, varId);
366
555
  }
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];
397
- }
398
- /** Fetch a single aggregate value for a domain + query + year. */
399
- async function fetchDataForDomain(client, store, query, domain, year) {
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
- }
408
- if (!varData) {
409
- const indicators = await client.listStrategicIndicators(domain);
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
- };
428
- }
429
- }
430
- return { value: "N/A", rawValue: null, varTitle: "" };
431
- }
432
- let periods = resolved?.periods ?? [];
433
- if (periods.length === 0) {
434
- await invalidateVar(query, domain, store);
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);
441
- }
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
- };
452
- }
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 };
462
- }
463
- return {
464
- value: `${rawValue.toLocaleString("id-ID")}${unit ? ` ${unit}` : ""}`,
465
- rawValue,
466
- unit,
467
- varTitle: getVariableTitle(varData) || varData.title,
468
- period: periodLabel,
469
- };
470
- }
471
556
  /** Resolve a national variable that breaks down by province, for rankings. */
472
557
  async function resolveVariableForRanking(client, store, query) {
473
558
  const candidates = await searchVariables(client, "0000", query, {