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
|
@@ -2,8 +2,12 @@ import { z } from "zod/v4";
|
|
|
2
2
|
import { formatDynamicData } from "../services/data-formatter.js";
|
|
3
3
|
import { appendAttribution } from "../services/attribution.js";
|
|
4
4
|
import { logger } from "../utils/logger.js";
|
|
5
|
-
import {
|
|
5
|
+
import { htmlTableToMarkdown } from "../utils/html.js";
|
|
6
|
+
import { lookupVar, learnVar, invalidateVar, learnPeriod, invalidatePeriod, normalizeKeyword, } from "../services/learning.js";
|
|
6
7
|
import { detectIntent, generateResultHints } from "../services/intent-detector.js";
|
|
8
|
+
import { computeRelevanceScore, expandQueryTerms, resolveVariableForYears, searchVariables, } from "../services/variable-search.js";
|
|
9
|
+
import { filterRelevantVervar } from "../services/dynamic-data-utils.js";
|
|
10
|
+
import { formatUnit, getPeriodId, getPeriodLabel, } from "../client/fields.js";
|
|
7
11
|
/**
|
|
8
12
|
* AI-friendly shortcut tools that reduce multi-step workflows to single calls.
|
|
9
13
|
*/
|
|
@@ -11,6 +15,9 @@ export function registerSmartTools(server, client, resolver, config, store) {
|
|
|
11
15
|
// ---------- find_variable ----------
|
|
12
16
|
server.tool("find_variable", `Cari variabel data BPS berdasarkan kata kunci. Mencari di semua subjek yang relevan.
|
|
13
17
|
|
|
18
|
+
Bukan untuk ranking: bila user minta "10 kabupaten/kota ... tertinggi", panggil
|
|
19
|
+
\`get_ranking(level="kabkota")\` — tool itu sudah tahu var_id tiap provinsi.
|
|
20
|
+
|
|
14
21
|
Contoh penggunaan:
|
|
15
22
|
- find_variable(keyword="penduduk") → variabel terkait jumlah penduduk
|
|
16
23
|
- find_variable(keyword="kemiskinan", domain="3500") → variabel kemiskinan di Jawa Timur
|
|
@@ -23,31 +30,24 @@ Setelah mendapat var_id dari tool ini, gunakan get_dynamic_data untuk mengambil
|
|
|
23
30
|
}, async ({ keyword, domain, subject }) => {
|
|
24
31
|
try {
|
|
25
32
|
const allVars = [];
|
|
26
|
-
const kw = keyword.toLowerCase();
|
|
27
33
|
if (subject) {
|
|
28
|
-
|
|
29
|
-
await searchVariablesInSubject(client, domain, subject, kw, allVars);
|
|
34
|
+
await searchVariablesInSubject(client, domain, subject, normalizeKeyword(keyword), allVars);
|
|
30
35
|
}
|
|
31
36
|
else {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
.
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
break;
|
|
47
|
-
}
|
|
48
|
-
// If no results from relevant subjects, try without subject filter
|
|
49
|
-
if (allVars.length === 0) {
|
|
50
|
-
await searchVariablesInSubject(client, domain, undefined, kw, allVars);
|
|
37
|
+
const candidates = await searchVariables(client, domain, keyword, {
|
|
38
|
+
maxSubjects: 8,
|
|
39
|
+
maxPagesPerSubject: 6,
|
|
40
|
+
maxCandidates: 20,
|
|
41
|
+
});
|
|
42
|
+
for (const candidate of candidates) {
|
|
43
|
+
if (allVars.some((item) => item.var_id === candidate.var_id))
|
|
44
|
+
continue;
|
|
45
|
+
allVars.push({
|
|
46
|
+
var_id: candidate.var_id,
|
|
47
|
+
title: candidate.title,
|
|
48
|
+
sub_name: candidate.sub_name,
|
|
49
|
+
unit: candidate.unit,
|
|
50
|
+
});
|
|
51
51
|
}
|
|
52
52
|
}
|
|
53
53
|
if (allVars.length === 0) {
|
|
@@ -65,7 +65,8 @@ Setelah mendapat var_id dari tool ini, gunakan get_dynamic_data untuk mengambil
|
|
|
65
65
|
"",
|
|
66
66
|
];
|
|
67
67
|
for (const v of allVars.slice(0, 15)) {
|
|
68
|
-
|
|
68
|
+
const unit = formatUnit(v.unit);
|
|
69
|
+
lines.push(`- **${v.title}** (var_id: \`${v.var_id}\`) — Subjek: ${v.sub_name}${unit ? ` — Satuan: ${unit}` : ""}`);
|
|
69
70
|
if (v.def)
|
|
70
71
|
lines.push(` _${v.def.substring(0, 150)}_`);
|
|
71
72
|
}
|
|
@@ -82,6 +83,12 @@ Setelah mendapat var_id dari tool ini, gunakan get_dynamic_data untuk mengambil
|
|
|
82
83
|
server.tool("find_data", `Tool utama untuk AI: cari dan ambil data BPS dalam satu langkah.
|
|
83
84
|
Secara otomatis: detect intent → resolve wilayah → cari variabel → ambil data.
|
|
84
85
|
|
|
86
|
+
⚠️ JANGAN pakai tool ini untuk PERINGKAT/top-N wilayah ("10 kabupaten/kota
|
|
87
|
+
tertinggi", "5 provinsi termiskin", "daerah dengan ... terbesar"). Panggil
|
|
88
|
+
\`get_ranking\` langsung — untuk kabupaten/kota pakai level="kabkota". Menyapu
|
|
89
|
+
sendiri daftar provinsi + variabel akan memakan 100+ panggilan dan tetap
|
|
90
|
+
kehilangan sebagian wilayah. find_data hanya untuk SATU wilayah.
|
|
91
|
+
|
|
85
92
|
## Intent Detection (otomatis):
|
|
86
93
|
- **Single value** (angka spesifik) → find_data
|
|
87
94
|
- **Comparison** ("bandingkan X dan Y") → delegate ke compare_data
|
|
@@ -97,12 +104,11 @@ Secara otomatis: detect intent → resolve wilayah → cari variabel → ambil d
|
|
|
97
104
|
| Kemiskinan (jumlah) | find_data atau get_dynamic_data | 183, 185 |
|
|
98
105
|
| Kemiskinan (%) | find_data atau get_dynamic_data | 184, 192 |
|
|
99
106
|
| Pengangguran (TPT %) | get_dynamic_data(var="543") | 543 |
|
|
100
|
-
|
|
|
101
|
-
| Inflasi (YoY) | list_strategic_indicators | - |
|
|
107
|
+
| Inflasi (YoY) | list_strategic_indicators atau get_dynamic_data | 2263 |
|
|
102
108
|
| Pertumbuhan ekonomi | list_strategic_indicators | - |
|
|
103
|
-
| IPM | list_strategic_indicators atau get_dynamic_data |
|
|
109
|
+
| IPM | list_strategic_indicators atau get_dynamic_data | 413 |
|
|
104
110
|
| Gini Rasio | get_dynamic_data(var="98") | 98 |
|
|
105
|
-
| Jumlah Penduduk | get_dynamic_data(var="
|
|
111
|
+
| Jumlah Penduduk | get_dynamic_data(var="2781") | 2781 |
|
|
106
112
|
| PDRB | list_strategic_indicators(domain=kode_prov) | - |
|
|
107
113
|
| Ekspor/Impor | list_strategic_indicators atau get_trade_data | - |
|
|
108
114
|
| Agama/Religi | find_data atau list_static_tables | - |
|
|
@@ -129,9 +135,13 @@ Contoh:
|
|
|
129
135
|
const intent = detectIntent(query, region, year);
|
|
130
136
|
logger.debug(`find_data: detected intent="${intent.intent}" confidence=${intent.confidence.toFixed(2)} tool="${intent.suggestedTool}"`);
|
|
131
137
|
// Prompt Redirection for specialized intents
|
|
132
|
-
|
|
138
|
+
const compareRegion1 = intent.extractedParams.region1;
|
|
139
|
+
const compareRegion2 = intent.extractedParams.region2;
|
|
140
|
+
const canRedirect = intent.intent !== "comparison" || (!!compareRegion1 && !!compareRegion2);
|
|
141
|
+
if (canRedirect && intent.intent !== "single_value" && intent.intent !== "table" && intent.intent !== "unknown" && intent.confidence >= 0.6) {
|
|
133
142
|
let redirectionText = "";
|
|
134
143
|
if (intent.intent === "comparison") {
|
|
144
|
+
const regions = `${compareRegion1}, ${compareRegion2}`;
|
|
135
145
|
redirectionText = `**Menyalin intensi pencarian: Perbandingan Wilayah**\n` +
|
|
136
146
|
`Query Anda tampaknya merupakan perbandingan data antar wilayah. Silakan panggil tool \`compare_data\` untuk hasil terbaik.\n\n` +
|
|
137
147
|
`**Rekomendasi pemanggilan tool:**\n` +
|
|
@@ -140,7 +150,7 @@ Contoh:
|
|
|
140
150
|
` "name": "compare_data",\n` +
|
|
141
151
|
` "arguments": {\n` +
|
|
142
152
|
` "query": "${query}",\n` +
|
|
143
|
-
` "regions": "${
|
|
153
|
+
` "regions": "${regions}"\n` +
|
|
144
154
|
(year ? ` ,"year": "${year}"\n` : "") +
|
|
145
155
|
` }\n` +
|
|
146
156
|
`}\n` +
|
|
@@ -161,6 +171,7 @@ Contoh:
|
|
|
161
171
|
`\`\`\``;
|
|
162
172
|
}
|
|
163
173
|
else if (intent.intent === "ranking") {
|
|
174
|
+
const kabkotaLevel = /\b(kabupaten|kabkota|kab\/kota|kabupaten\/kota)\b/i.test(query);
|
|
164
175
|
redirectionText = `**Menyalin intensi pencarian: Peringkat/Ranking**\n` +
|
|
165
176
|
`Query Anda tampaknya meminta peringkat data wilayah. Silakan panggil tool \`get_ranking\` untuk hasil terbaik.\n\n` +
|
|
166
177
|
`**Rekomendasi pemanggilan tool:**\n` +
|
|
@@ -170,8 +181,8 @@ Contoh:
|
|
|
170
181
|
` "arguments": {\n` +
|
|
171
182
|
` "query": "${query}",\n` +
|
|
172
183
|
` "top_n": 10,\n` +
|
|
173
|
-
` "order": "highest"
|
|
174
|
-
|
|
184
|
+
` "order": "highest",\n "level": "${kabkotaLevel ? "kabkota" : "provinsi"}"` +
|
|
185
|
+
`\n }\n` +
|
|
175
186
|
`}\n` +
|
|
176
187
|
`\`\`\``;
|
|
177
188
|
}
|
|
@@ -217,98 +228,52 @@ Contoh:
|
|
|
217
228
|
};
|
|
218
229
|
}
|
|
219
230
|
}
|
|
220
|
-
//
|
|
231
|
+
// Headline topics without a year are best answered by strategic indicators
|
|
232
|
+
// (e.g. "inflasi" → the latest national YoY figure, not 150 kabupaten rows).
|
|
221
233
|
const kw = normalizeKeyword(query);
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
234
|
+
if (!year && isHeadlineQuery(kw)) {
|
|
235
|
+
const headline = await tryStrategicIndicators(client, kw, domain, domainName, undefined, true);
|
|
236
|
+
if (headline)
|
|
237
|
+
return headline;
|
|
238
|
+
}
|
|
239
|
+
// Step 2: Find variable via learned mapping → relevance search.
|
|
240
|
+
// When a year is requested, only accept a variable that actually has
|
|
241
|
+
// that year (otherwise BPS may silently return an older series).
|
|
242
|
+
const asksBreakdown = /\b(kabupaten|kebupaten|kab[/\s]kota|kecamatan|per\s*kab|per\s*kec|desa|kelurahan)\b/.test(query.toLowerCase());
|
|
243
|
+
const yearList = year
|
|
244
|
+
? year.split(/[,;]/).map((value) => value.trim()).filter(Boolean)
|
|
245
|
+
: [];
|
|
246
|
+
const learned = asksBreakdown ? null : await lookupVar(query, domain, store);
|
|
247
|
+
const resolved = await resolveVariableForYears(client, store, query, domain, yearList, {
|
|
248
|
+
skipLearned: asksBreakdown,
|
|
249
|
+
maxCandidates: 20,
|
|
250
|
+
});
|
|
251
|
+
let bestVar = resolved?.varData ?? null;
|
|
252
|
+
let resolvedPeriods = resolved?.periods ?? [];
|
|
253
|
+
const candidates = resolved?.candidates ?? [];
|
|
254
|
+
let fromLearning = !!learned && !!bestVar && learned.var_id === bestVar.var_id;
|
|
255
|
+
const matchedYears = resolved?.matchedYears ?? [];
|
|
256
|
+
const missingYears = yearList.filter((value) => !matchedYears.includes(value));
|
|
257
|
+
if (yearList.length > 0 && matchedYears.length === 0) {
|
|
258
|
+
bestVar = null;
|
|
259
|
+
resolvedPeriods = [];
|
|
231
260
|
}
|
|
232
261
|
if (!bestVar) {
|
|
233
262
|
// Fallback: try strategic indicators
|
|
234
263
|
const indResult = await tryStrategicIndicators(client, kw, domain, domainName, year);
|
|
235
264
|
if (indResult)
|
|
236
265
|
return indResult;
|
|
237
|
-
// Fallback: try static tables with
|
|
266
|
+
// Fallback: try static tables with keyword strategies
|
|
238
267
|
try {
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
if (tables.length === 0 && kw.split(/\s+/).length > 1) {
|
|
243
|
-
const words = kw.split(/\s+/);
|
|
244
|
-
// Try the last 2 words (e.g. "sensus ekonomi")
|
|
245
|
-
const lastTwo = words.slice(-2).join(" ");
|
|
246
|
-
tables = await safeListStaticTables(client, domain, lastTwo);
|
|
247
|
-
if (tables.length === 0) {
|
|
248
|
-
// Try the first 2 words (e.g. "jumlah usaha")
|
|
249
|
-
const firstTwo = words.slice(0, 2).join(" ");
|
|
250
|
-
tables = await safeListStaticTables(client, domain, firstTwo);
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
// Strategy 3: If empty and keyword is short, try broader keyword "penduduk"
|
|
254
|
-
if (tables.length === 0 && kw.split(/\s+/).length <= 2 && kw !== "penduduk") {
|
|
255
|
-
tables = await safeListStaticTables(client, domain, "penduduk");
|
|
256
|
-
}
|
|
257
|
-
// Strategy 4: If still empty, try without keyword filter
|
|
258
|
-
if (tables.length === 0) {
|
|
259
|
-
tables = await safeListStaticTables(client, domain, undefined);
|
|
260
|
-
}
|
|
261
|
-
// Strategy 5: If kab/kota and still empty, try parent province
|
|
262
|
-
if (tables.length === 0 && domain.length === 4 && !domain.endsWith("00")) {
|
|
263
|
-
const parentDomain = domain.slice(0, 2) + "00";
|
|
264
|
-
tables = await safeListStaticTables(client, parentDomain, kw);
|
|
265
|
-
if (tables.length === 0 && kw.split(/\s+/).length > 1) {
|
|
266
|
-
const words = kw.split(/\s+/);
|
|
267
|
-
const lastTwo = words.slice(-2).join(" ");
|
|
268
|
-
tables = await safeListStaticTables(client, parentDomain, lastTwo);
|
|
269
|
-
}
|
|
270
|
-
if (tables.length === 0) {
|
|
271
|
-
tables = await safeListStaticTables(client, parentDomain, "penduduk");
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
if (tables.length > 0) {
|
|
275
|
-
// Find best matching table
|
|
276
|
-
const searchTerms = [kw, "penduduk", ...kw.split(/\s+/).filter(w => w.length > 2)];
|
|
277
|
-
const bestTable = tables.find(t => {
|
|
278
|
-
const titleLower = t.title.toLowerCase();
|
|
279
|
-
return searchTerms.some(term => titleLower.includes(term));
|
|
280
|
-
}) || tables[0];
|
|
281
|
-
const tableDetail = await client.getStaticTable(domain, bestTable.table_id);
|
|
282
|
-
const tableLines = [
|
|
283
|
-
`**Pencarian:** "${query}" di ${domainName}`,
|
|
284
|
-
`**Sumber:** Tabel Statis — ${tableDetail.title}`,
|
|
285
|
-
"",
|
|
286
|
-
tableDetail.table,
|
|
287
|
-
];
|
|
288
|
-
if (tableDetail.excel) {
|
|
289
|
-
tableLines.push("", `**Download Excel:** ${tableDetail.excel}`);
|
|
290
|
-
}
|
|
291
|
-
return { content: [{ type: "text", text: appendAttribution(tableLines.join("\n")) }] };
|
|
268
|
+
const staticText = await staticTableFallback(client, domain, domainName, kw, query);
|
|
269
|
+
if (staticText) {
|
|
270
|
+
return { content: [{ type: "text", text: appendAttribution(staticText) }] };
|
|
292
271
|
}
|
|
293
|
-
// Static tables empty — suggest AllStats search with optimized query
|
|
294
|
-
const allStatsQuery = `jumlah penduduk menurut agama ${domainName}`;
|
|
295
|
-
return {
|
|
296
|
-
content: [{
|
|
297
|
-
type: "text",
|
|
298
|
-
text: appendAttribution(`**Pencarian:** "${query}" di ${domainName}\n\n` +
|
|
299
|
-
`Data untuk topik ini tidak tersedia sebagai dynamic data atau static table di BPS WebAPI.\n` +
|
|
300
|
-
`Data kemungkinan tersedia di website BPS daerah atau publikasi.\n\n` +
|
|
301
|
-
`**Langkah selanjutnya yang disarankan:**\n` +
|
|
302
|
-
`1. Gunakan \`allstats_search(query="${allStatsQuery}", domain="${domain}", content="table")\` untuk mencari di AllStats Search Engine\n` +
|
|
303
|
-
`2. Gunakan \`allstats_deep_search(query="${allStatsQuery}", domain="${domain}")\` untuk mencari di dalam PDF publikasi\n` +
|
|
304
|
-
`3. Cek langsung website BPS daerah (format URL bervariasi per wilayah)\n\n` +
|
|
305
|
-
`**Catatan:** Beberapa data (seperti agama) hanya tersedia di website BPS daerah, tidak di WebAPI.`),
|
|
306
|
-
}],
|
|
307
|
-
};
|
|
308
272
|
}
|
|
309
273
|
catch (staticTableError) {
|
|
310
274
|
logger.debug(`find_data: static table fallback failed: ${staticTableError instanceof Error ? staticTableError.message : "unknown"}`);
|
|
311
275
|
}
|
|
276
|
+
const allStatsQuery = `${query} ${domainName}`.trim();
|
|
312
277
|
return {
|
|
313
278
|
content: [{
|
|
314
279
|
type: "text",
|
|
@@ -316,16 +281,19 @@ Contoh:
|
|
|
316
281
|
`**Saran:**\n` +
|
|
317
282
|
`1. Gunakan \`find_variable(keyword="${query}", domain="${domain}")\` untuk mencari variabel yang lebih spesifik\n` +
|
|
318
283
|
`2. Gunakan \`search(keyword="${query}")\` untuk pencarian lebih luas\n` +
|
|
319
|
-
`3. Gunakan \`list_strategic_indicators(domain="${domain}")\` untuk indikator utama`
|
|
284
|
+
`3. Gunakan \`list_strategic_indicators(domain="${domain}")\` untuk indikator utama\n` +
|
|
285
|
+
`4. Gunakan \`allstats_search(query="${allStatsQuery}", content="table")\` untuk mencari tabel di AllStats\n` +
|
|
286
|
+
`5. Gunakan \`allstats_deep_search(query="${allStatsQuery}")\` untuk mencari teks di dalam PDF publikasi\n\n` +
|
|
287
|
+
`**Catatan:** Beberapa data (seperti agama) hanya tersedia di website BPS daerah, tidak di WebAPI.`),
|
|
320
288
|
}],
|
|
321
289
|
};
|
|
322
290
|
}
|
|
323
291
|
// Step 3: Resolve period
|
|
324
292
|
logger.debug(`find_data: using var_id=${bestVar.var_id} (${bestVar.title}) for query="${query}"`);
|
|
325
|
-
const periodParam = await resolvePeriod(client, store, bestVar.var_id, domain, year);
|
|
293
|
+
const periodParam = await resolvePeriod(client, store, bestVar.var_id, domain, year, resolvedPeriods);
|
|
326
294
|
// Step 4: Get data
|
|
327
295
|
let result = await client.getDynamicData(domain, String(bestVar.var_id), periodParam);
|
|
328
|
-
// Self-healing: if data empty and var came from learning, invalidate and retry
|
|
296
|
+
// Self-healing: if data empty and var came from learning, invalidate and retry search
|
|
329
297
|
if ((!result.datacontent || Object.keys(result.datacontent).length === 0) && fromLearning) {
|
|
330
298
|
logger.debug(`find_data: self-healing — invalidating learned var_id=${bestVar.var_id}`);
|
|
331
299
|
await invalidateVar(query, domain, store);
|
|
@@ -333,91 +301,81 @@ Contoh:
|
|
|
333
301
|
for (const y of year.split(","))
|
|
334
302
|
await invalidatePeriod(bestVar.var_id, domain, y.trim(), store);
|
|
335
303
|
}
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
304
|
+
const retryCandidates = await searchVariables(client, domain, kw, {
|
|
305
|
+
maxSubjects: 6,
|
|
306
|
+
maxPagesPerSubject: 6,
|
|
307
|
+
maxCandidates: 20,
|
|
308
|
+
});
|
|
309
|
+
const retryVar = retryCandidates[0];
|
|
339
310
|
if (retryVar) {
|
|
340
|
-
bestVar =
|
|
311
|
+
bestVar = {
|
|
312
|
+
var_id: retryVar.var_id,
|
|
313
|
+
title: retryVar.title,
|
|
314
|
+
sub_name: retryVar.sub_name,
|
|
315
|
+
unit: retryVar.unit,
|
|
316
|
+
};
|
|
341
317
|
fromLearning = false;
|
|
342
318
|
const retryPeriod = await resolvePeriod(client, store, bestVar.var_id, domain, year);
|
|
343
319
|
result = await client.getDynamicData(domain, String(bestVar.var_id), retryPeriod);
|
|
344
320
|
candidates.push(...retryCandidates);
|
|
345
321
|
}
|
|
346
322
|
}
|
|
347
|
-
|
|
348
|
-
|
|
323
|
+
// If the best variable has no data, try the next candidates before
|
|
324
|
+
// falling back to static tables.
|
|
325
|
+
if ((!result.datacontent || Object.keys(result.datacontent).length === 0) && candidates.length > 1) {
|
|
326
|
+
for (const candidate of candidates.slice(1, 4)) {
|
|
327
|
+
if (candidate.var_id === bestVar.var_id)
|
|
328
|
+
continue;
|
|
329
|
+
try {
|
|
330
|
+
const candidatePeriod = await resolvePeriod(client, store, candidate.var_id, domain, year);
|
|
331
|
+
const candidateResult = await client.getDynamicData(domain, String(candidate.var_id), candidatePeriod);
|
|
332
|
+
if (candidateResult.datacontent && Object.keys(candidateResult.datacontent).length > 0) {
|
|
333
|
+
bestVar = {
|
|
334
|
+
var_id: candidate.var_id,
|
|
335
|
+
title: candidate.title,
|
|
336
|
+
sub_name: candidate.sub_name,
|
|
337
|
+
unit: candidate.unit,
|
|
338
|
+
};
|
|
339
|
+
result = candidateResult;
|
|
340
|
+
fromLearning = false;
|
|
341
|
+
logger.debug(`find_data: candidate fallback → var_id=${bestVar.var_id}`);
|
|
342
|
+
break;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
catch (candidateError) {
|
|
346
|
+
logger.debug(`find_data: candidate var_id=${candidate.var_id} failed: ${candidateError instanceof Error ? candidateError.message : "unknown"}`);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
349
350
|
// Fallback for kab/kota domains: try parent province if no data
|
|
350
351
|
if ((!result.datacontent || Object.keys(result.datacontent).length === 0) &&
|
|
351
352
|
domain !== "0000" && domain.length === 4 && !domain.endsWith("00")) {
|
|
352
353
|
const parentDomain = domain.slice(0, 2) + "00";
|
|
353
|
-
const parentCandidates =
|
|
354
|
-
|
|
354
|
+
const parentCandidates = await searchVariables(client, parentDomain, kw, {
|
|
355
|
+
maxSubjects: 6,
|
|
356
|
+
maxPagesPerSubject: 6,
|
|
357
|
+
maxCandidates: 10,
|
|
358
|
+
});
|
|
359
|
+
const parentVar = (await lookupVar(query, parentDomain, store)) || parentCandidates[0] || null;
|
|
355
360
|
if (parentVar) {
|
|
356
361
|
const parentPeriod = await resolvePeriod(client, store, parentVar.var_id, parentDomain, year);
|
|
357
362
|
const parentResult = await client.getDynamicData(parentDomain, String(parentVar.var_id), parentPeriod);
|
|
358
363
|
if (parentResult.datacontent && Object.keys(parentResult.datacontent).length > 0) {
|
|
359
|
-
const
|
|
364
|
+
const relevantParent = filterRelevantVervar(parentResult, parentDomain);
|
|
365
|
+
const parentFormatted = formatDynamicData(relevantParent, parentDomain, config.defaultLang, {
|
|
366
|
+
includeAttribution: false,
|
|
367
|
+
});
|
|
360
368
|
const parentHeader = `**Pencarian:** "${query}" di ${domainName}${year ? ` (${year})` : ""}\n**Variabel:** ${parentVar.title} (ID: ${parentVar.var_id})\n_Data diambil dari domain provinsi induk (${parentDomain})_\n\n`;
|
|
361
|
-
return { content: [{ type: "text", text: parentHeader + parentFormatted }] };
|
|
369
|
+
return { content: [{ type: "text", text: appendAttribution(parentHeader + parentFormatted) }] };
|
|
362
370
|
}
|
|
363
371
|
}
|
|
364
372
|
}
|
|
365
373
|
// Still no data after retry — try static tables as fallback
|
|
366
374
|
if (!result.datacontent || Object.keys(result.datacontent).length === 0) {
|
|
367
|
-
// Try static tables fallback with multiple strategies
|
|
368
375
|
try {
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
if (tables.length === 0 && kw.split(/\s+/).length > 1) {
|
|
373
|
-
const words = kw.split(/\s+/);
|
|
374
|
-
// Try the last 2 words (e.g. "sensus ekonomi")
|
|
375
|
-
const lastTwo = words.slice(-2).join(" ");
|
|
376
|
-
tables = await safeListStaticTables(client, domain, lastTwo);
|
|
377
|
-
if (tables.length === 0) {
|
|
378
|
-
// Try the first 2 words (e.g. "jumlah usaha")
|
|
379
|
-
const firstTwo = words.slice(0, 2).join(" ");
|
|
380
|
-
tables = await safeListStaticTables(client, domain, firstTwo);
|
|
381
|
-
}
|
|
382
|
-
}
|
|
383
|
-
// Strategy 3: If empty and keyword is short, try broader keyword "penduduk"
|
|
384
|
-
if (tables.length === 0 && kw.split(/\s+/).length <= 2 && kw !== "penduduk") {
|
|
385
|
-
tables = await safeListStaticTables(client, domain, "penduduk");
|
|
386
|
-
}
|
|
387
|
-
// Strategy 4: If still empty, try without keyword
|
|
388
|
-
if (tables.length === 0) {
|
|
389
|
-
tables = await safeListStaticTables(client, domain, undefined);
|
|
390
|
-
}
|
|
391
|
-
// Strategy 5: If kab/kota, try parent province
|
|
392
|
-
if (tables.length === 0 && domain.length === 4 && !domain.endsWith("00")) {
|
|
393
|
-
const parentDomain = domain.slice(0, 2) + "00";
|
|
394
|
-
tables = await safeListStaticTables(client, parentDomain, kw);
|
|
395
|
-
if (tables.length === 0 && kw.split(/\s+/).length > 1) {
|
|
396
|
-
const words = kw.split(/\s+/);
|
|
397
|
-
const lastTwo = words.slice(-2).join(" ");
|
|
398
|
-
tables = await safeListStaticTables(client, parentDomain, lastTwo);
|
|
399
|
-
}
|
|
400
|
-
if (tables.length === 0) {
|
|
401
|
-
tables = await safeListStaticTables(client, parentDomain, "penduduk");
|
|
402
|
-
}
|
|
403
|
-
}
|
|
404
|
-
if (tables.length > 0) {
|
|
405
|
-
const searchTerms = [kw, "penduduk", ...kw.split(/\s+/).filter(w => w.length > 2)];
|
|
406
|
-
const bestTable = tables.find(t => {
|
|
407
|
-
const titleLower = t.title.toLowerCase();
|
|
408
|
-
return searchTerms.some(term => titleLower.includes(term));
|
|
409
|
-
}) || tables[0];
|
|
410
|
-
const tableDetail = await client.getStaticTable(domain, bestTable.table_id);
|
|
411
|
-
const tableLines = [
|
|
412
|
-
`**Pencarian:** "${query}" di ${domainName}`,
|
|
413
|
-
`**Sumber:** Tabel Statis — ${tableDetail.title}`,
|
|
414
|
-
"",
|
|
415
|
-
tableDetail.table,
|
|
416
|
-
];
|
|
417
|
-
if (tableDetail.excel) {
|
|
418
|
-
tableLines.push("", `**Download Excel:** ${tableDetail.excel}`);
|
|
419
|
-
}
|
|
420
|
-
return { content: [{ type: "text", text: appendAttribution(tableLines.join("\n")) }] };
|
|
376
|
+
const staticText = await staticTableFallback(client, domain, domainName, kw, query);
|
|
377
|
+
if (staticText) {
|
|
378
|
+
return { content: [{ type: "text", text: appendAttribution(staticText) }] };
|
|
421
379
|
}
|
|
422
380
|
}
|
|
423
381
|
catch (staticTableError) {
|
|
@@ -427,22 +385,35 @@ Contoh:
|
|
|
427
385
|
`Data untuk variabel "${bestVar.title}" tidak tersedia${year ? ` untuk tahun ${year}` : ""} di ${domainName}.`,
|
|
428
386
|
"",
|
|
429
387
|
];
|
|
430
|
-
|
|
388
|
+
const alternatives = candidates.filter((candidate) => candidate.var_id !== bestVar.var_id);
|
|
389
|
+
if (alternatives.length > 0) {
|
|
431
390
|
altLines.push("**Variabel alternatif yang ditemukan:**");
|
|
432
|
-
for (const c of
|
|
433
|
-
|
|
391
|
+
for (const c of alternatives.slice(0, 5)) {
|
|
392
|
+
const unit = formatUnit(c.unit);
|
|
393
|
+
altLines.push(`- ${c.title} (var_id: \`${c.var_id}\`)${unit ? ` — ${unit}` : ""}`);
|
|
434
394
|
}
|
|
435
395
|
altLines.push("");
|
|
436
396
|
altLines.push("Gunakan `get_dynamic_data` dengan var_id di atas untuk mencoba variabel lain.");
|
|
437
397
|
}
|
|
438
398
|
return { content: [{ type: "text", text: appendAttribution(altLines.join("\n")) }] };
|
|
439
399
|
}
|
|
440
|
-
// Success — learn the variable mapping
|
|
400
|
+
// Success — learn the variable mapping and show the aggregate for large
|
|
401
|
+
// region breakdowns so the output stays useful for an LLM.
|
|
441
402
|
await learnVar(query, domain, bestVar, store);
|
|
442
|
-
|
|
403
|
+
const relevantResult = filterRelevantVervar(result, domain, domainName, asksBreakdown ? 10_000 : 10);
|
|
404
|
+
const filteredNote = relevantResult === result
|
|
405
|
+
? ""
|
|
406
|
+
: "\n_Menampilkan agregat wilayah. Gunakan get_dynamic_data untuk rincian lengkap._\n";
|
|
407
|
+
const formatted = formatDynamicData(relevantResult, domain, config.defaultLang, {
|
|
408
|
+
includeAttribution: false,
|
|
409
|
+
});
|
|
410
|
+
const missingNote = missingYears.length > 0 && matchedYears.length > 0
|
|
411
|
+
? `\n_Catatan: tahun ${missingYears.join(", ")} tidak tersedia untuk variabel ini; data yang ditampilkan hanya tahun ${matchedYears.join(", ")}._\n`
|
|
412
|
+
: "";
|
|
413
|
+
const header = `**Pencarian:** "${query}" di ${domainName}${year ? ` (${year})` : ""}\n**Variabel:** ${bestVar.title} (ID: ${bestVar.var_id})\n${filteredNote}${missingNote}\n`;
|
|
443
414
|
const hints = generateResultHints(query, domain, domainName, bestVar.var_id, bestVar.title);
|
|
444
415
|
const hintsText = hints.length > 0 ? "\n\n**💡 Tips Lanjutan:**\n" + hints.join("\n") : "";
|
|
445
|
-
return { content: [{ type: "text", text: header + formatted + hintsText }] };
|
|
416
|
+
return { content: [{ type: "text", text: appendAttribution(header + formatted + hintsText) }] };
|
|
446
417
|
}
|
|
447
418
|
catch (error) {
|
|
448
419
|
const msg = error instanceof Error ? error.message : "Gagal mengambil data";
|
|
@@ -450,296 +421,273 @@ Contoh:
|
|
|
450
421
|
}
|
|
451
422
|
});
|
|
452
423
|
}
|
|
453
|
-
|
|
454
|
-
const
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
const ids = [];
|
|
471
|
-
for (const [keyword, subIds] of Object.entries(KEYWORD_SUBJECTS)) {
|
|
472
|
-
if (kw.includes(keyword))
|
|
473
|
-
ids.push(...subIds);
|
|
474
|
-
}
|
|
475
|
-
return [...new Set(ids)];
|
|
476
|
-
}
|
|
477
|
-
/** Full search flow (Layer 3): search subjects → variables → score → return best. */
|
|
478
|
-
async function fullSearchVar(client, kw, domain, candidates) {
|
|
479
|
-
const mappedSubjectIds = getSubjectIdsForKeyword(kw);
|
|
480
|
-
const subjects = await client.listSubjects(domain);
|
|
481
|
-
const relevantSubjects = subjects.data.filter(s => {
|
|
482
|
-
const titleLower = s.title.toLowerCase();
|
|
483
|
-
return kw.split(/\s+/).some(w => w.length > 2 && titleLower.includes(w)) || titleLower.includes(kw);
|
|
484
|
-
});
|
|
485
|
-
const subjectIdsToSearch = [
|
|
486
|
-
...new Set([...mappedSubjectIds, ...relevantSubjects.map(s => s.sub_id)])
|
|
487
|
-
];
|
|
488
|
-
for (const subId of subjectIdsToSearch.slice(0, 5)) {
|
|
489
|
-
const result = await client.listVariables(domain, subId, undefined, 1, 100);
|
|
490
|
-
if (!result.data || result.data.length === 0)
|
|
491
|
-
continue;
|
|
492
|
-
for (const v of result.data) {
|
|
493
|
-
const score = computeRelevanceScore(kw, v.title.toLowerCase(), v.sub_name?.toLowerCase() || "");
|
|
494
|
-
if (score > 0)
|
|
495
|
-
candidates.push({ var_id: v.var_id, title: v.title, sub_name: v.sub_name, unit: v.unit, score });
|
|
496
|
-
}
|
|
497
|
-
// BPS API caps at 10 per page regardless of perpage param — fetch more pages
|
|
498
|
-
const totalPages = result.page?.pages || 1;
|
|
499
|
-
for (let page = 2; page <= Math.min(totalPages, 5); page++) {
|
|
500
|
-
const nextPage = await client.listVariables(domain, subId, undefined, page, 100);
|
|
501
|
-
if (!nextPage.data || nextPage.data.length === 0)
|
|
502
|
-
break;
|
|
503
|
-
for (const v of nextPage.data) {
|
|
504
|
-
const score = computeRelevanceScore(kw, v.title.toLowerCase(), v.sub_name?.toLowerCase() || "");
|
|
505
|
-
if (score > 0)
|
|
506
|
-
candidates.push({ var_id: v.var_id, title: v.title, sub_name: v.sub_name, unit: v.unit, score });
|
|
507
|
-
}
|
|
508
|
-
}
|
|
509
|
-
if (candidates.length >= 10)
|
|
510
|
-
break;
|
|
511
|
-
}
|
|
512
|
-
if (candidates.length === 0) {
|
|
513
|
-
const domainsToSearch = domain === "0000" ? ["0000"] : [domain, "0000"];
|
|
514
|
-
for (const searchDomain of domainsToSearch) {
|
|
515
|
-
for (let page = 1; page <= 2; page++) {
|
|
516
|
-
const result = await client.listVariables(searchDomain, undefined, undefined, page, 100);
|
|
517
|
-
if (!result.data || result.data.length === 0)
|
|
518
|
-
break;
|
|
519
|
-
for (const v of result.data) {
|
|
520
|
-
const score = computeRelevanceScore(kw, v.title.toLowerCase(), v.sub_name?.toLowerCase() || "");
|
|
521
|
-
if (score > 0)
|
|
522
|
-
candidates.push({ var_id: v.var_id, title: v.title, sub_name: v.sub_name, unit: v.unit, score });
|
|
523
|
-
}
|
|
524
|
-
if (candidates.length >= 5)
|
|
525
|
-
break;
|
|
526
|
-
if (result.data.length < 100)
|
|
527
|
-
break;
|
|
528
|
-
}
|
|
529
|
-
if (candidates.length >= 5)
|
|
530
|
-
break;
|
|
531
|
-
}
|
|
532
|
-
}
|
|
533
|
-
candidates.sort((a, b) => b.score - a.score);
|
|
534
|
-
if (candidates[0]) {
|
|
535
|
-
return { var_id: candidates[0].var_id, title: candidates[0].title, sub_name: candidates[0].sub_name, unit: candidates[0].unit };
|
|
536
|
-
}
|
|
537
|
-
return null;
|
|
424
|
+
/** Headline topics that are best answered by strategic indicators when no year is given. */
|
|
425
|
+
const HEADLINE_PATTERNS = [
|
|
426
|
+
/\binflasi\b/,
|
|
427
|
+
/\bpertumbuhan ekonomi\b/,
|
|
428
|
+
/\bpengangguran\b/,
|
|
429
|
+
/\bkemiskinan\b/,
|
|
430
|
+
/\bmiskin\b/,
|
|
431
|
+
/\bgini\b/,
|
|
432
|
+
/\bketimpangan\b/,
|
|
433
|
+
/\bipm\b/,
|
|
434
|
+
/\bindeks pembangunan manusia\b/,
|
|
435
|
+
/\bekspor\b/,
|
|
436
|
+
/\bimpor\b/,
|
|
437
|
+
/\bneraca perdagangan\b/,
|
|
438
|
+
];
|
|
439
|
+
function isHeadlineQuery(normalizedQuery) {
|
|
440
|
+
return HEADLINE_PATTERNS.some((pattern) => pattern.test(normalizedQuery));
|
|
538
441
|
}
|
|
539
442
|
/** Resolve year to period IDs, using learning store first. */
|
|
540
|
-
async function resolvePeriod(client, store, varId, domain, year) {
|
|
443
|
+
async function resolvePeriod(client, store, varId, domain, year, knownPeriods) {
|
|
541
444
|
if (!year) {
|
|
542
445
|
// No year specified — get latest available period
|
|
543
446
|
try {
|
|
544
|
-
const periods =
|
|
447
|
+
const periods = knownPeriods && knownPeriods.length > 0
|
|
448
|
+
? knownPeriods
|
|
449
|
+
: await client.listPeriods(domain, varId, 1);
|
|
545
450
|
if (periods.length > 0) {
|
|
546
451
|
// periods are typically sorted descending; take the first (latest)
|
|
547
|
-
|
|
548
|
-
return String(latest.th_id);
|
|
452
|
+
return getPeriodId(periods[0]) || undefined;
|
|
549
453
|
}
|
|
550
454
|
}
|
|
551
455
|
catch { /* fall through */ }
|
|
552
456
|
return undefined;
|
|
553
457
|
}
|
|
554
|
-
const yearNums = year.split(
|
|
555
|
-
const learnedPeriods = [];
|
|
556
|
-
for (const y of yearNums) {
|
|
557
|
-
const learned = await lookupPeriod(varId, domain, y, store);
|
|
558
|
-
if (learned)
|
|
559
|
-
learnedPeriods.push(learned);
|
|
560
|
-
}
|
|
561
|
-
if (learnedPeriods.length === yearNums.length) {
|
|
562
|
-
return learnedPeriods.join(",");
|
|
563
|
-
}
|
|
564
|
-
// Fallback: call list_periods API
|
|
458
|
+
const yearNums = year.split(/[,;]/).map(y => y.trim()).filter(Boolean);
|
|
565
459
|
try {
|
|
566
|
-
const periods =
|
|
460
|
+
const periods = knownPeriods && knownPeriods.length > 0
|
|
461
|
+
? knownPeriods
|
|
462
|
+
: await listAllPeriodsSafe(client, domain, varId);
|
|
567
463
|
if (periods.length > 0) {
|
|
568
464
|
const matchingPeriods = periods.filter(p => {
|
|
569
|
-
const
|
|
570
|
-
|
|
571
|
-
const thVal = String(pAny.val || "");
|
|
572
|
-
const thId = String(p.th_id);
|
|
573
|
-
return yearNums.some(y => thName.includes(y) || thVal.includes(y) || thId === y);
|
|
465
|
+
const label = getPeriodLabel(p);
|
|
466
|
+
return yearNums.some(y => label.includes(y));
|
|
574
467
|
});
|
|
575
468
|
if (matchingPeriods.length > 0) {
|
|
576
|
-
// Learn period mappings
|
|
577
469
|
for (const p of matchingPeriods) {
|
|
578
|
-
const
|
|
579
|
-
const
|
|
580
|
-
const matchedYear = yearNums.find(y =>
|
|
581
|
-
if (matchedYear) {
|
|
582
|
-
await learnPeriod(varId, domain, matchedYear,
|
|
470
|
+
const label = getPeriodLabel(p);
|
|
471
|
+
const id = getPeriodId(p);
|
|
472
|
+
const matchedYear = yearNums.find(y => label.includes(y));
|
|
473
|
+
if (matchedYear && id) {
|
|
474
|
+
await learnPeriod(varId, domain, matchedYear, id, store);
|
|
583
475
|
}
|
|
584
476
|
}
|
|
585
|
-
return matchingPeriods.map(p =>
|
|
477
|
+
return matchingPeriods.map(p => getPeriodId(p)).filter(Boolean).join(",");
|
|
586
478
|
}
|
|
587
479
|
}
|
|
588
480
|
}
|
|
589
481
|
catch {
|
|
590
|
-
// If period lookup fails,
|
|
482
|
+
// If period lookup fails, no usable period.
|
|
483
|
+
}
|
|
484
|
+
return undefined;
|
|
485
|
+
}
|
|
486
|
+
async function listAllPeriodsSafe(client, domain, varId) {
|
|
487
|
+
const maybeAll = client.listAllPeriods;
|
|
488
|
+
if (typeof maybeAll === "function") {
|
|
489
|
+
return maybeAll.call(client, domain, varId);
|
|
591
490
|
}
|
|
592
|
-
return
|
|
491
|
+
return client.listPeriods(domain, varId);
|
|
593
492
|
}
|
|
594
|
-
/** Try strategic indicators as fallback. */
|
|
595
|
-
async function tryStrategicIndicators(client, kw, domain, domainName, year) {
|
|
493
|
+
/** Try strategic indicators as fallback (also used as a headline shortcut). */
|
|
494
|
+
async function tryStrategicIndicators(client, kw, domain, domainName, year, headlineNote = false) {
|
|
596
495
|
const indicators = await client.listStrategicIndicators(domain);
|
|
597
496
|
if (!indicators.data || indicators.data.length === 0)
|
|
598
497
|
return null;
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
"",
|
|
610
|
-
`| Periode | Nilai |`,
|
|
611
|
-
`| --- | --- |`,
|
|
612
|
-
`| ${ind.periode} | ${val} ${ind.unit} |`,
|
|
613
|
-
];
|
|
614
|
-
if (ind.name) {
|
|
615
|
-
lines.push("", `_${ind.name}_`);
|
|
616
|
-
}
|
|
617
|
-
return { content: [{ type: "text", text: appendAttribution(lines.join("\n")) }] };
|
|
498
|
+
const terms = expandQueryTerms(kw);
|
|
499
|
+
let best = null;
|
|
500
|
+
for (const indicator of indicators.data) {
|
|
501
|
+
const titleLower = indicator.title.toLowerCase();
|
|
502
|
+
let score = 0;
|
|
503
|
+
if (titleLower.includes(kw))
|
|
504
|
+
score += 40;
|
|
505
|
+
for (const term of terms) {
|
|
506
|
+
if (term.length > 2 && titleLower.includes(term))
|
|
507
|
+
score += 10;
|
|
618
508
|
}
|
|
509
|
+
if (score === 0)
|
|
510
|
+
continue;
|
|
511
|
+
if (year && !indicator.periode.includes(year))
|
|
512
|
+
continue;
|
|
513
|
+
if (!best || score > best.score)
|
|
514
|
+
best = { indicator, score };
|
|
619
515
|
}
|
|
620
|
-
|
|
516
|
+
if (!best)
|
|
517
|
+
return null;
|
|
518
|
+
const indicator = best.indicator;
|
|
519
|
+
const val = typeof indicator.value === "number"
|
|
520
|
+
? indicator.value.toLocaleString("id-ID")
|
|
521
|
+
: String(indicator.value);
|
|
522
|
+
const unit = formatUnit(indicator.unit);
|
|
523
|
+
const lines = [
|
|
524
|
+
`## ${indicator.title}`,
|
|
525
|
+
`**Wilayah:** ${domainName} (${domain})`,
|
|
526
|
+
"",
|
|
527
|
+
`| Periode | Nilai |`,
|
|
528
|
+
`| --- | --- |`,
|
|
529
|
+
`| ${indicator.periode} | ${val}${unit ? ` ${unit}` : ""} |`,
|
|
530
|
+
];
|
|
531
|
+
if (indicator.name) {
|
|
532
|
+
lines.push("", `_${indicator.name}_`);
|
|
533
|
+
}
|
|
534
|
+
if (headlineNote) {
|
|
535
|
+
lines.push("", "_Data headline terbaru. Untuk data historis, gunakan `get_trend` atau `find_data` dengan parameter `year`._");
|
|
536
|
+
}
|
|
537
|
+
return { content: [{ type: "text", text: appendAttribution(lines.join("\n")) }] };
|
|
621
538
|
}
|
|
622
539
|
/**
|
|
623
540
|
* Search variables within a subject (or all if undefined) and add matches to results array.
|
|
624
541
|
*/
|
|
625
542
|
async function searchVariablesInSubject(client, domain, subject, kw, results) {
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
543
|
+
let firstPage;
|
|
544
|
+
try {
|
|
545
|
+
firstPage = await client.listVariables(domain, subject, undefined, 1, 100);
|
|
546
|
+
}
|
|
547
|
+
catch (error) {
|
|
548
|
+
logger.debug(`searchVariablesInSubject failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
if (!firstPage.data || firstPage.data.length === 0)
|
|
552
|
+
return;
|
|
553
|
+
const pages = Math.min(firstPage.page?.pages ?? 1, 6);
|
|
554
|
+
const scored = [];
|
|
555
|
+
for (let page = 1; page <= pages; page++) {
|
|
556
|
+
const result = page === 1
|
|
557
|
+
? firstPage
|
|
558
|
+
: await client.listVariables(domain, subject, undefined, page, 100);
|
|
629
559
|
if (!result.data || result.data.length === 0)
|
|
630
560
|
break;
|
|
631
|
-
for (const
|
|
632
|
-
const
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
const matches = titleLower.includes(kw) || defLower.includes(kw) ||
|
|
636
|
-
kwWords.some(w => titleLower.includes(w));
|
|
637
|
-
if (matches) {
|
|
638
|
-
// Avoid duplicates
|
|
639
|
-
if (!results.some(r => r.var_id === v.var_id)) {
|
|
640
|
-
results.push({
|
|
641
|
-
var_id: v.var_id,
|
|
642
|
-
title: v.title,
|
|
643
|
-
sub_name: v.sub_name,
|
|
644
|
-
unit: v.unit,
|
|
645
|
-
def: v.def,
|
|
646
|
-
});
|
|
647
|
-
}
|
|
648
|
-
}
|
|
561
|
+
for (const variable of result.data) {
|
|
562
|
+
const score = computeRelevanceScore(kw, variable.title, variable.sub_name, variable.def);
|
|
563
|
+
if (score > 0)
|
|
564
|
+
scored.push({ variable, score });
|
|
649
565
|
}
|
|
650
|
-
if (
|
|
651
|
-
break;
|
|
652
|
-
if (result.data.length < 100)
|
|
566
|
+
if (scored.length >= 30)
|
|
653
567
|
break;
|
|
654
568
|
}
|
|
569
|
+
scored.sort((a, b) => b.score - a.score);
|
|
570
|
+
for (const { variable } of scored.slice(0, 15)) {
|
|
571
|
+
if (results.some((existing) => existing.var_id === variable.var_id))
|
|
572
|
+
continue;
|
|
573
|
+
results.push({
|
|
574
|
+
var_id: variable.var_id,
|
|
575
|
+
title: variable.title,
|
|
576
|
+
sub_name: variable.sub_name,
|
|
577
|
+
unit: variable.unit,
|
|
578
|
+
def: variable.def,
|
|
579
|
+
});
|
|
580
|
+
}
|
|
655
581
|
}
|
|
656
582
|
/**
|
|
657
|
-
*
|
|
658
|
-
*
|
|
583
|
+
* Search static tables, prefer a title match, and return a compact markdown
|
|
584
|
+
* table. Returns null when no relevant table is found (rather than showing an
|
|
585
|
+
* unrelated first result).
|
|
659
586
|
*/
|
|
660
|
-
function
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
if (
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
score += 30;
|
|
671
|
-
wordMatches++;
|
|
587
|
+
async function staticTableFallback(client, domain, domainName, kw, query) {
|
|
588
|
+
const words = kw.split(/\s+/).filter(Boolean);
|
|
589
|
+
let tableDomain = domain;
|
|
590
|
+
let tables = await safeListStaticTables(client, domain, kw);
|
|
591
|
+
if (tables.length === 0 && words.length > 1) {
|
|
592
|
+
const lastTwo = words.slice(-2).join(" ");
|
|
593
|
+
tables = await safeListStaticTables(client, domain, lastTwo);
|
|
594
|
+
if (tables.length === 0) {
|
|
595
|
+
const firstTwo = words.slice(0, 2).join(" ");
|
|
596
|
+
tables = await safeListStaticTables(client, domain, firstTwo);
|
|
672
597
|
}
|
|
673
|
-
if (subName.includes(word))
|
|
674
|
-
score += 15;
|
|
675
598
|
}
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
score += 50;
|
|
682
|
-
// Prefer "tingkat" or "persentase" variants (main indicators)
|
|
683
|
-
if (title.includes("tingkat") || title.includes("persentase") || title.includes("jumlah"))
|
|
684
|
-
score += 20;
|
|
685
|
-
// Prefer shorter titles (more general/main indicators)
|
|
686
|
-
if (title.length < 60)
|
|
687
|
-
score += 15;
|
|
688
|
-
if (title.length > 100)
|
|
689
|
-
score -= 20;
|
|
690
|
-
// Penalize titles with "menurut" (breakdowns are less useful as primary)
|
|
691
|
-
// UNLESS query explicitly asks for breakdown (kabupaten, kab, kota)
|
|
692
|
-
const queryAsksBreakdown = query.includes("kabupaten") || query.includes("kab") ||
|
|
693
|
-
(query.includes("kota") && !query.includes("perkotaan"));
|
|
694
|
-
const menurutCount = (title.match(/menurut/g) || []).length;
|
|
695
|
-
if (menurutCount > 1 && !queryAsksBreakdown)
|
|
696
|
-
score -= 15;
|
|
697
|
-
// Boost if query asks for kab/kota breakdown and title has it
|
|
698
|
-
if (queryAsksBreakdown && (title.includes("kabupaten") || title.includes("kab/"))) {
|
|
699
|
-
score += 60;
|
|
599
|
+
if (tables.length === 0 && words.length <= 2 && kw !== "penduduk") {
|
|
600
|
+
tables = await safeListStaticTables(client, domain, "penduduk");
|
|
601
|
+
}
|
|
602
|
+
if (tables.length === 0) {
|
|
603
|
+
tables = await safeListStaticTables(client, domain, undefined);
|
|
700
604
|
}
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
if (title.includes("persentase"))
|
|
708
|
-
score -= 10;
|
|
605
|
+
if (tables.length === 0 && domain.length === 4 && !domain.endsWith("00")) {
|
|
606
|
+
const parentDomain = `${domain.slice(0, 2)}00`;
|
|
607
|
+
tableDomain = parentDomain;
|
|
608
|
+
tables = await safeListStaticTables(client, parentDomain, kw);
|
|
609
|
+
if (tables.length === 0 && words.length > 1) {
|
|
610
|
+
tables = await safeListStaticTables(client, parentDomain, words.slice(-2).join(" "));
|
|
709
611
|
}
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
score += 40;
|
|
713
|
-
else if (title.includes("jumlah penduduk miskin"))
|
|
714
|
-
score += 20;
|
|
612
|
+
if (tables.length === 0) {
|
|
613
|
+
tables = await safeListStaticTables(client, parentDomain, "penduduk");
|
|
715
614
|
}
|
|
716
|
-
if (title.includes("garis kemiskinan") && !query.includes("garis"))
|
|
717
|
-
score -= 30;
|
|
718
|
-
if (title.includes("indeks kedalaman") || title.includes("indeks keparahan"))
|
|
719
|
-
score -= 20;
|
|
720
615
|
}
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
616
|
+
const asksBreakdown = /\b(kabupaten|kecamatan|desa|kelurahan|per\s*kab|per\s*kec)\b/i.test(query);
|
|
617
|
+
const searchTerms = [kw, ...words.filter((word) => word.length > 2)];
|
|
618
|
+
const scoredTables = tables
|
|
619
|
+
.map((table) => {
|
|
620
|
+
const title = table.title.toLowerCase();
|
|
621
|
+
let score = 0;
|
|
622
|
+
if (!searchTerms.some((term) => title.includes(term)))
|
|
623
|
+
return { table, score: 0 };
|
|
624
|
+
score += 20;
|
|
625
|
+
if (domainName && title.includes(domainName.toLowerCase()))
|
|
724
626
|
score += 30;
|
|
725
|
-
if (title.includes("
|
|
726
|
-
score += 50;
|
|
727
|
-
if (title.includes("kepercayaan"))
|
|
627
|
+
if (title.includes("kabupaten"))
|
|
728
628
|
score += 10;
|
|
629
|
+
if (title.includes("jumlah") || title.includes("penduduk"))
|
|
630
|
+
score += 5;
|
|
631
|
+
if (/desa|kelurahan|kecamatan/.test(title)) {
|
|
632
|
+
score += asksBreakdown ? 15 : -25;
|
|
633
|
+
}
|
|
634
|
+
return { table, score };
|
|
635
|
+
})
|
|
636
|
+
.filter((entry) => entry.score > 0)
|
|
637
|
+
.sort((a, b) => b.score - a.score);
|
|
638
|
+
if (scoredTables.length === 0)
|
|
639
|
+
return null;
|
|
640
|
+
let framesetFallback = null;
|
|
641
|
+
for (const { table } of scoredTables.slice(0, 5)) {
|
|
642
|
+
let detail;
|
|
643
|
+
try {
|
|
644
|
+
detail = await client.getStaticTable(tableDomain, table.table_id);
|
|
645
|
+
}
|
|
646
|
+
catch {
|
|
647
|
+
continue;
|
|
648
|
+
}
|
|
649
|
+
const tableText = htmlTableToMarkdown(detail.table);
|
|
650
|
+
const lines = [
|
|
651
|
+
`**Pencarian:** "${query}" di ${domainName}`,
|
|
652
|
+
`**Sumber:** Tabel Statis — ${detail.title}`,
|
|
653
|
+
];
|
|
654
|
+
if (tableDomain !== domain) {
|
|
655
|
+
lines.push(`_Tabel berasal dari domain provinsi induk (${tableDomain})._`);
|
|
656
|
+
}
|
|
657
|
+
lines.push("", tableText);
|
|
658
|
+
lines.push("", `**Detail/Excel:** get_static_table(domain="${tableDomain}", id=${detail.table_id})`);
|
|
659
|
+
if (tableText.startsWith("_Tabel ini disajikan BPS sebagai file Excel")) {
|
|
660
|
+
framesetFallback = framesetFallback ?? lines.join("\n");
|
|
661
|
+
continue;
|
|
662
|
+
}
|
|
663
|
+
return lines.join("\n");
|
|
729
664
|
}
|
|
730
|
-
return
|
|
665
|
+
return framesetFallback;
|
|
731
666
|
}
|
|
732
667
|
/**
|
|
733
668
|
* Safely lists static tables without throwing on BpsNotFoundError.
|
|
734
669
|
*/
|
|
735
|
-
async function safeListStaticTables(client, domain, keyword) {
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
670
|
+
async function safeListStaticTables(client, domain, keyword, maxPages = 3) {
|
|
671
|
+
const results = [];
|
|
672
|
+
const seen = new Set();
|
|
673
|
+
for (let page = 1; page <= maxPages; page++) {
|
|
674
|
+
try {
|
|
675
|
+
const res = await client.listStaticTables(domain, keyword, undefined, undefined, page);
|
|
676
|
+
for (const table of res.data || []) {
|
|
677
|
+
if (seen.has(table.table_id))
|
|
678
|
+
continue;
|
|
679
|
+
seen.add(table.table_id);
|
|
680
|
+
results.push(table);
|
|
681
|
+
}
|
|
682
|
+
const pages = res.page?.pages ?? 1;
|
|
683
|
+
if (page >= Math.min(pages, maxPages) || (res.data?.length ?? 0) === 0)
|
|
684
|
+
break;
|
|
685
|
+
}
|
|
686
|
+
catch (err) {
|
|
687
|
+
logger.debug(`safeListStaticTables failed for keyword="${keyword}" page=${page}: ${err instanceof Error ? err.message : String(err)}`);
|
|
688
|
+
break;
|
|
689
|
+
}
|
|
743
690
|
}
|
|
691
|
+
return results;
|
|
744
692
|
}
|
|
745
693
|
//# sourceMappingURL=smart.tools.js.map
|