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