bps-mcp-server 0.17.0 → 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.
@@ -1,85 +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";
3
+ import { normalizeKeyword } from "../services/learning.js";
4
+ import { resolveVariable as resolveVariableShared, searchVariables } from "../services/variable-search.js";
5
5
  import { formatKabkotaRanking, rankKabkota } from "../services/kabkota-ranking.js";
6
6
  import { findKabkotaIndicator } from "../config/kabkota-indicators.js";
7
- import { extractVervarId, getAggregateValue, getVervarEntries, selectAggregateVervar, } from "../services/dynamic-data-utils.js";
8
- import { formatUnit, getPeriodId, getPeriodLabel, getTurthId, getTurthLabel, getVariableTitle, getVariableUnit, isNeutralPeriodLabel, } from "../client/fields.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";
9
11
  /**
10
12
  * Multi-region comparison, trend, and ranking tools.
11
13
  * Each handles a common multi-step query pattern in a single tool call.
12
14
  */
13
15
  export function registerAnalysisTools(server, client, resolver, config, store) {
14
16
  // ---------- compare_data ----------
15
- server.tool("compare_data", `Bandingkan data statistik antar wilayah dalam 1 langkah.
16
- Gunakan tool ini ketika user ingin membandingkan data antara 2 atau lebih wilayah.
17
- 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.
18
24
 
19
25
  Contoh query user yang cocok untuk tool ini:
20
26
  - "bandingkan kemiskinan Jawa Timur dan Jawa Barat"
21
- - "IPM DKI Jakarta vs Banten vs Jawa Barat"
22
- - "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"
23
29
  - "mana yang lebih tinggi kemiskinan Jatim atau Jabar?"`, {
24
30
  query: z.string().describe("Indikator yang dibandingkan (misal: kemiskinan, pengangguran, IPM, penduduk)"),
25
- regions: z.string().describe("Nama wilayah dipisah koma (misal: 'Jawa Timur, Jawa Barat, Jawa Tengah')"),
26
- year: z.string().optional().describe("Tahun data (misal: '2023'). Kosongkan untuk data terbaru."),
27
- }, 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 }) => {
28
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
+ }
29
44
  const regionList = regions.split(",").map(r => r.trim()).filter(Boolean);
30
45
  if (regionList.length < 2) {
31
46
  return { content: [{ type: "text", text: "Minimal 2 wilayah untuk perbandingan." }], isError: true };
32
47
  }
33
- 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"}`);
34
105
  return {
35
- content: [{ type: "text", text: "compare_data hanya mendukung 1 tahun. Gunakan get_trend per wilayah untuk multi-tahun." }],
36
- 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
+ }],
37
111
  };
38
112
  }
39
- const results = [];
40
- for (const regionName of regionList) {
41
- const resolved = await resolver.resolve(regionName);
42
- if (!resolved) {
43
- results.push({ region: regionName, domain: "?", value: "Wilayah tidak ditemukan", rawValue: null, varTitle: "" });
44
- continue;
45
- }
46
- const { domainId, domainName } = resolved;
47
- const data = await fetchDataForDomain(client, store, query, domainId, year);
48
- results.push({
49
- region: domainName,
50
- domain: domainId,
51
- value: data.value,
52
- rawValue: data.rawValue,
53
- unit: data.unit,
54
- varTitle: data.varTitle,
55
- 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";
56
140
  });
141
+ if (originalUnits.length > 1) {
142
+ unitNote = `Satuan diseragamkan ke Ribu Jiwa (asli: ${originalUnits.join(", ")}).`;
143
+ }
57
144
  }
58
- // Format output
59
- const varTitle = results.find(r => r.varTitle)?.varTitle || query;
60
- 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;
61
155
  const lines = [
62
- `## Perbandingan: ${varTitle}`,
63
- year ? `**Tahun:** ${year}` : "**Tahun:** Terbaru",
64
- "",
65
- showPeriod ? "| Wilayah | Nilai | Periode |" : "| Wilayah | Nilai |",
66
- showPeriod ? "| --- | --- | --- |" : "| --- | --- |",
156
+ `## Perbandingan: ${displayTitle}${unitLabel && !unitInTitle ? ` (${unitLabel})` : ""}`,
157
+ `**Wilayah:** ${withData.map((series) => series.domainName).join(", ")}`,
67
158
  ];
68
- for (const r of results) {
69
- lines.push(showPeriod ? `| ${r.region} | ${r.value} | ${r.period ?? "-"} |` : `| ${r.region} | ${r.value} |`);
70
- }
71
- // Add analysis hint (use the raw numeric value, not the localized string)
72
- const numericResults = results.filter((result) => result.rawValue !== null);
73
- if (numericResults.length >= 2) {
74
- const values = [...numericResults].sort((a, b) => b.rawValue - a.rawValue);
75
- lines.push("");
76
- lines.push(`**Tertinggi:** ${values[0].region} (${values[0].value})`);
77
- lines.push(`**Terendah:** ${values[values.length - 1].region} (${values[values.length - 1].value})`);
78
- 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
+ }
79
182
  if (units.size > 1) {
80
183
  lines.push("", `_Catatan: satuan berbeda antar wilayah (${[...units].join(", ")}); bandingkan dengan hati-hati._`);
81
184
  }
82
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
+ }
83
257
  return { content: [{ type: "text", text: appendAttribution(lines.join("\n")) }] };
84
258
  }
85
259
  catch (error) {
@@ -88,7 +262,9 @@ Contoh query user yang cocok untuk tool ini:
88
262
  });
89
263
  // ---------- get_trend ----------
90
264
  server.tool("get_trend", `Ambil data time-series (tren multi-tahun) dalam 1 langkah.
91
- 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.
92
268
 
93
269
  Contoh query user yang cocok untuk tool ini:
94
270
  - "tren kemiskinan Indonesia 2019-2023"
@@ -97,9 +273,12 @@ Contoh query user yang cocok untuk tool ini:
97
273
  - "data kemiskinan Jawa Barat dari tahun ke tahun"`, {
98
274
  query: z.string().describe("Indikator yang dianalisis (misal: kemiskinan, pengangguran, IPM)"),
99
275
  region: z.string().default("Indonesia").describe("Nama wilayah"),
276
+ wilayah: z.string().optional().describe("Alias untuk 'region'."),
100
277
  start_year: z.string().default("2019").describe("Tahun awal"),
101
278
  end_year: z.string().default("2024").describe("Tahun akhir"),
102
- }, 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";
103
282
  try {
104
283
  // Resolve domain
105
284
  let domain = "0000";
@@ -112,157 +291,98 @@ Contoh query user yang cocok untuk tool ini:
112
291
  domain = resolved.domainId;
113
292
  domainName = resolved.domainName;
114
293
  }
115
- const startNum = parseInt(start_year, 10);
116
- const endNum = parseInt(end_year, 10);
117
- 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) {
118
298
  return { content: [{ type: "text", text: "Rentang tahun tidak valid." }], isError: true };
119
299
  }
120
- if (endNum - startNum > 10) {
300
+ if (yearList.length > 11) {
121
301
  return {
122
302
  content: [{ type: "text", text: "Rentang maksimal 10 tahun per panggilan. Pecah menjadi beberapa rentang." }],
123
303
  isError: true,
124
304
  };
125
305
  }
126
- const years = Array.from({ length: endNum - startNum + 1 }, (_, index) => String(startNum + index));
127
- // Resolve variable that actually has the requested years.
128
- const resolved = await resolveVariableForYears(client, store, query, domain, years, {
129
- maxCandidates: 20,
306
+ const series = await buildRegionSeries(client, store, query, region, domain, domainName, {
307
+ years: yearList,
308
+ maxCandidates: 6,
130
309
  });
131
- let varData = resolved?.varData ?? null;
132
- const matchedYears = resolved?.matchedYears ?? [];
133
- if (!varData || (years.length > 0 && matchedYears.length === 0)) {
134
- // Fallback: strategic indicators (headline only)
135
- const indicators = await client.listStrategicIndicators(domain);
136
- const kw2 = normalizeKeyword(query);
137
- for (const ind of indicators.data ?? []) {
138
- const title = ind.title.toLowerCase();
139
- if (title.includes(kw2) || kw2.split(/\s+/).some((word) => word.length > 2 && title.includes(word))) {
140
- const val = typeof ind.value === "number" ? ind.value.toLocaleString("id-ID") : ind.value;
141
- const unit = formatUnit(ind.unit);
142
- const lines = [
143
- `## Tren/Headline ${ind.title}`,
144
- `**Wilayah:** ${domainName} (${domain})`,
145
- "",
146
- `*Catatan: Menggunakan data headline indikator strategis karena variabel time-series lengkap tidak ditemukan.*`,
147
- "",
148
- `| Periode | Nilai |`,
149
- `| --- | --- |`,
150
- `| ${ind.periode} | ${val}${unit ? ` ${unit}` : ""} |`,
151
- ];
152
- if (ind.name)
153
- lines.push("", `_${ind.name}_`);
154
- 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
+ }
155
336
  }
156
337
  }
157
- return { content: [{ type: "text", text: appendAttribution(`Tidak ditemukan variabel "${query}" untuk ${domainName}.`) }] };
158
- }
159
- let periods = resolved?.periods ?? [];
160
- if (periods.length === 0) {
161
- await invalidateVar(query, domain, store);
162
- const retry = await searchVariables(client, domain, query, { maxCandidates: 10 });
163
- const retryVar = retry[0];
164
- if (!retryVar) {
165
- return { content: [{ type: "text", text: appendAttribution(`Tidak ditemukan variabel "${query}" untuk ${domainName}.`) }] };
338
+ catch {
339
+ // fall through to the diagnostic message
166
340
  }
167
- varData = retryVar;
168
- periods = await listAllPeriodsSafe(client, domain, varData.var_id);
169
- }
170
- const yearToPeriod = new Map();
171
- for (const period of periods) {
172
- const label = getPeriodLabel(period);
173
- const periodYear = label.match(/\d{4}/)?.[0];
174
- const id = getPeriodId(period);
175
- if (!periodYear || !id || !years.includes(periodYear))
176
- continue;
177
- if (!yearToPeriod.has(periodYear))
178
- yearToPeriod.set(periodYear, id);
179
- }
180
- const periodIds = years.map((year) => yearToPeriod.get(year)).filter((id) => !!id);
181
- if (periodIds.length === 0) {
182
- return { content: [{ type: "text", text: appendAttribution(`Tidak ada data periode ${start_year}-${end_year} untuk variabel ini.`) }] };
183
- }
184
- const result = await client.getDynamicData(domain, String(varData.var_id), periodIds.join(","));
185
- if (!result.datacontent || Object.keys(result.datacontent).length === 0) {
186
- return { content: [{ type: "text", text: appendAttribution(`Data tidak tersedia untuk periode ${start_year}-${end_year}.`) }] };
187
- }
188
- const periodLabels = new Map();
189
- for (const period of result.tahun ?? []) {
190
- periodLabels.set(getPeriodId(period), getPeriodLabel(period));
191
- }
192
- const aggregateVervar = selectAggregateVervar(result, domain, domainName);
193
- const selectedTurth = selectTrendTurth(result.turtahun ?? [], Object.keys(result.datacontent ?? {}), periodIds);
194
- const vervarEntries = getVervarEntries(result);
195
- if (!aggregateVervar && vervarEntries.length > 10) {
341
+ const notes = series.notes.length > 0 ? `\n\n${series.notes.join("\n")}` : "";
196
342
  return {
197
343
  content: [{
198
344
  type: "text",
199
- text: appendAttribution(`Variabel "${getVariableTitle(varData) || varData.title}" tidak memiliki baris agregat nasional ` +
200
- `(hanya ${vervarEntries.length} provinsi/kategori). Gunakan get_trend dengan region provinsi, ` +
201
- `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.`),
202
347
  }],
203
348
  };
204
349
  }
205
- const sortedPeriodIds = [...periodIds].sort((a, b) => b.length - a.length);
206
- const trendData = [];
207
- for (const [key, value] of Object.entries(result.datacontent)) {
208
- if (typeof value !== "number")
209
- continue;
210
- if (aggregateVervar && extractVervarId(key, result) !== aggregateVervar)
211
- continue;
212
- if (selectedTurth?.id && !key.endsWith(selectedTurth.id))
213
- continue;
214
- const periodId = sortedPeriodIds.find((id) => key.includes(id));
215
- if (!periodId)
216
- continue;
217
- const label = periodLabels.get(periodId) ?? periodId;
218
- if (trendData.some((item) => item.year === label))
219
- continue;
220
- trendData.push({ year: label, value });
221
- }
222
- trendData.sort((a, b) => a.year.localeCompare(b.year));
223
- if (trendData.length === 0) {
224
- return { content: [{ type: "text", text: appendAttribution(`Data tren tidak dapat di-parse.`) }] };
225
- }
226
- // Format output
227
- const unit = formatUnit(getVariableUnit(varData) ?? varData.unit);
228
- const title = getVariableTitle(varData) || varData.title;
350
+ const unit = formatUnit(series.unit);
229
351
  const lines = [
230
- `## Tren ${title}${unit ? ` (${unit})` : ""}`,
231
- `**Wilayah:** ${domainName} | **Periode:** ${start_year}–${end_year}`,
352
+ `## Tren ${series.varTitle}${unit ? ` (${unit})` : ""}`,
353
+ `**Wilayah:** ${domainName} | **Periode:** ${yearList[0]}–${yearList[yearList.length - 1]}`,
232
354
  ];
233
- if (selectedTurth && !isNeutralPeriodLabel(selectedTurth.label)) {
234
- lines.push(`**Seri:** ${selectedTurth.label}`);
235
- if (/januari|februari|maret|april|mei|juni|juli|agustus|september|oktober|november|desember/i.test(selectedTurth.label)) {
236
- 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._`);
237
359
  }
238
360
  }
239
- const missingYears = years.filter((year) => !matchedYears.includes(year));
240
- if (missingYears.length > 0 && matchedYears.length > 0) {
241
- 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.`);
242
363
  }
243
364
  lines.push("", "| Periode | Nilai | Perubahan |", "| --- | --- | --- |");
244
- for (let i = 0; i < trendData.length; i++) {
245
- const d = trendData[i];
365
+ for (let i = 0; i < series.points.length; i++) {
366
+ const point = series.points[i];
246
367
  let change = "-";
247
368
  if (i > 0) {
248
- const previous = trendData[i - 1].value;
249
- const diff = d.value - previous;
369
+ const previous = series.points[i - 1].value;
370
+ const diff = point.value - previous;
250
371
  if (previous !== 0) {
251
372
  const pct = ((diff / previous) * 100).toFixed(1);
252
373
  change = `${diff > 0 ? "+" : ""}${pct}%`;
253
374
  }
254
375
  }
255
- lines.push(`| ${d.year} | ${d.value.toLocaleString("id-ID")} | ${change} |`);
256
- }
257
- // Summary
258
- if (trendData.length >= 2) {
259
- const first = trendData[0].value;
260
- const last = trendData[trendData.length - 1].value;
261
- if (first !== 0) {
262
- const totalChange = ((last - first) / first * 100).toFixed(1);
263
- const trend = last > first ? "naik" : last < first ? "turun" : "stabil";
376
+ lines.push(`| ${point.year} | ${point.value.toLocaleString("id-ID")} | ${change} |`);
377
+ }
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";
264
384
  lines.push("");
265
- 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}`);
266
386
  }
267
387
  }
268
388
  return { content: [{ type: "text", text: appendAttribution(lines.join("\n")) }] };
@@ -433,110 +553,6 @@ async function listAllPeriodsSafe(client, domain, varId) {
433
553
  }
434
554
  return client.listPeriods(domain, varId);
435
555
  }
436
- function selectTrendTurth(turthList, datacontentKeys, periodIds) {
437
- const entries = turthList
438
- .map((item) => ({ id: getTurthId(item), label: getTurthLabel(item) }))
439
- .filter((entry) => entry.id !== "");
440
- if (entries.length === 0)
441
- return null;
442
- // BPS often lists series (annual, monthly, semester) that are not present for
443
- // every requested year. Count actual coverage and pick the best one instead
444
- // of selecting "Tahunan" and silently dropping years where it is missing.
445
- const scored = entries
446
- .map((entry) => {
447
- const coverage = periodIds.filter((periodId) => datacontentKeys.some((key) => key.includes(periodId) && key.endsWith(entry.id))).length;
448
- return { entry, coverage };
449
- })
450
- .filter((candidate) => candidate.coverage > 0)
451
- .sort((a, b) => b.coverage - a.coverage);
452
- if (scored.length === 0)
453
- return null;
454
- const bestCoverage = scored[0].coverage;
455
- const best = scored.filter((candidate) => candidate.coverage === bestCoverage).map((candidate) => candidate.entry);
456
- const annual = best.find((entry) => isNeutralPeriodLabel(entry.label));
457
- if (annual)
458
- return annual;
459
- const firstSemester = best.find((entry) => /semester\s*1/i.test(entry.label));
460
- if (firstSemester)
461
- return firstSemester;
462
- const december = best.find((entry) => /desember/i.test(entry.label));
463
- if (december)
464
- return december;
465
- return best[0];
466
- }
467
- /** Fetch a single aggregate value for a domain + query + year. */
468
- async function fetchDataForDomain(client, store, query, domain, year) {
469
- const yearList = year ? [year] : [];
470
- const resolved = await resolveVariableForYears(client, store, query, domain, yearList, {
471
- maxCandidates: 20,
472
- });
473
- let varData = resolved?.varData ?? null;
474
- if (yearList.length > 0 && (!resolved || resolved.matchedYears.length === 0)) {
475
- varData = null;
476
- }
477
- if (!varData) {
478
- const indicators = await client.listStrategicIndicators(domain);
479
- const kw = normalizeKeyword(query);
480
- for (const indicator of indicators.data ?? []) {
481
- const title = indicator.title.toLowerCase();
482
- if (title.includes(kw) || kw.split(/\s+/).some((word) => word.length > 2 && title.includes(word))) {
483
- if (year && !indicator.periode.includes(year))
484
- continue;
485
- const unit = formatUnit(indicator.unit);
486
- const rawValue = typeof indicator.value === "number" ? indicator.value : null;
487
- const formatted = typeof indicator.value === "number"
488
- ? indicator.value.toLocaleString("id-ID")
489
- : String(indicator.value);
490
- return {
491
- value: `${formatted}${unit ? ` ${unit}` : ""} (${indicator.periode})`,
492
- rawValue,
493
- unit,
494
- varTitle: indicator.title,
495
- period: indicator.periode,
496
- };
497
- }
498
- }
499
- return { value: "N/A", rawValue: null, varTitle: "" };
500
- }
501
- let periods = resolved?.periods ?? [];
502
- if (periods.length === 0) {
503
- await invalidateVar(query, domain, store);
504
- const retry = await searchVariables(client, domain, query, { maxCandidates: 10 });
505
- const retryVar = retry[0];
506
- if (!retryVar)
507
- return { value: "N/A", rawValue: null, varTitle: "" };
508
- varData = retryVar;
509
- periods = await listAllPeriodsSafe(client, domain, varData.var_id);
510
- }
511
- let selectedPeriod = year
512
- ? periods.find((period) => getPeriodLabel(period).includes(year))
513
- : periods[0];
514
- // With an explicit year, never silently fall back to a different year.
515
- if (!selectedPeriod && year) {
516
- return {
517
- value: "N/A",
518
- rawValue: null,
519
- varTitle: getVariableTitle(varData) || varData.title,
520
- };
521
- }
522
- if (!selectedPeriod)
523
- selectedPeriod = periods[0];
524
- const periodId = selectedPeriod ? getPeriodId(selectedPeriod) : "";
525
- const periodLabel = selectedPeriod ? getPeriodLabel(selectedPeriod) : "";
526
- const result = await client.getDynamicData(domain, String(varData.var_id), periodId || undefined);
527
- const rawValue = getAggregateValue(result, domain);
528
- const unit = formatUnit(getVariableUnit(varData) ?? varData.unit);
529
- if (rawValue === null) {
530
- return { value: "N/A", rawValue: null, unit, varTitle: getVariableTitle(varData) || varData.title, period: periodLabel };
531
- }
532
- return {
533
- value: `${rawValue.toLocaleString("id-ID")}${unit ? ` ${unit}` : ""}`,
534
- rawValue,
535
- unit,
536
- varTitle: getVariableTitle(varData) || varData.title,
537
- period: periodLabel,
538
- };
539
- }
540
556
  /** Resolve a national variable that breaks down by province, for rankings. */
541
557
  async function resolveVariableForRanking(client, store, query) {
542
558
  const candidates = await searchVariables(client, "0000", query, {