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