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