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.
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +8 -4
- package/dist/server.js.map +1 -1
- package/dist/services/period-resolver.d.ts +29 -0
- package/dist/services/period-resolver.d.ts.map +1 -0
- package/dist/services/period-resolver.js +132 -0
- package/dist/services/period-resolver.js.map +1 -0
- package/dist/services/region-series.d.ts +65 -0
- package/dist/services/region-series.d.ts.map +1 -0
- package/dist/services/region-series.js +396 -0
- package/dist/services/region-series.js.map +1 -0
- package/dist/services/variable-search.d.ts.map +1 -1
- package/dist/services/variable-search.js +2 -1
- package/dist/services/variable-search.js.map +1 -1
- package/dist/tools/analysis.tools.d.ts.map +1 -1
- package/dist/tools/analysis.tools.js +293 -277
- package/dist/tools/analysis.tools.js.map +1 -1
- package/dist/tools/dynamic-data.tools.d.ts.map +1 -1
- package/dist/tools/dynamic-data.tools.js +128 -27
- package/dist/tools/dynamic-data.tools.js.map +1 -1
- package/dist/tools/search.tools.d.ts.map +1 -1
- package/dist/tools/search.tools.js +14 -2
- package/dist/tools/search.tools.js.map +1 -1
- package/dist/tools/smart.tools.d.ts.map +1 -1
- package/dist/tools/smart.tools.js +37 -9
- package/dist/tools/smart.tools.js.map +1 -1
- package/package.json +1 -1
|
@@ -1,85 +1,259 @@
|
|
|
1
1
|
import { z } from "zod/v4";
|
|
2
2
|
import { appendAttribution } from "../services/attribution.js";
|
|
3
|
-
import {
|
|
4
|
-
import { resolveVariable as resolveVariableShared,
|
|
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 {
|
|
8
|
-
import {
|
|
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
|
-
|
|
17
|
-
|
|
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
|
-
- "
|
|
22
|
-
- "
|
|
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
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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: [{
|
|
36
|
-
|
|
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
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
59
|
-
const
|
|
60
|
-
const
|
|
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: ${
|
|
63
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
lines.push("");
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
116
|
-
const
|
|
117
|
-
|
|
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 (
|
|
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
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
maxCandidates: 20,
|
|
306
|
+
const series = await buildRegionSeries(client, store, query, region, domain, domainName, {
|
|
307
|
+
years: yearList,
|
|
308
|
+
maxCandidates: 6,
|
|
130
309
|
});
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(`
|
|
200
|
-
|
|
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
|
|
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 ${
|
|
231
|
-
`**Wilayah:** ${domainName} | **Periode:** ${
|
|
352
|
+
`## Tren ${series.varTitle}${unit ? ` (${unit})` : ""}`,
|
|
353
|
+
`**Wilayah:** ${domainName} | **Periode:** ${yearList[0]}–${yearList[yearList.length - 1]}`,
|
|
232
354
|
];
|
|
233
|
-
if (
|
|
234
|
-
lines.push(`**Seri:** ${
|
|
235
|
-
if (/januari|februari|maret|april|mei|juni|juli|agustus|september|oktober|november|desember/i.test(
|
|
236
|
-
lines.push(`_Catatan: variabel ini
|
|
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
|
-
|
|
240
|
-
|
|
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 <
|
|
245
|
-
const
|
|
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 =
|
|
249
|
-
const diff =
|
|
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(`| ${
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
const
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
const
|
|
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 ${
|
|
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, {
|