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.
Files changed (96) hide show
  1. package/README.md +19 -3
  2. package/dist/client/allstats-client.d.ts +2 -0
  3. package/dist/client/allstats-client.d.ts.map +1 -1
  4. package/dist/client/allstats-client.js +62 -34
  5. package/dist/client/allstats-client.js.map +1 -1
  6. package/dist/client/bps-client.d.ts +14 -5
  7. package/dist/client/bps-client.d.ts.map +1 -1
  8. package/dist/client/bps-client.js +215 -23
  9. package/dist/client/bps-client.js.map +1 -1
  10. package/dist/client/endpoints.d.ts +7 -0
  11. package/dist/client/endpoints.d.ts.map +1 -1
  12. package/dist/client/endpoints.js +43 -5
  13. package/dist/client/endpoints.js.map +1 -1
  14. package/dist/client/fields.d.ts +27 -0
  15. package/dist/client/fields.d.ts.map +1 -0
  16. package/dist/client/fields.js +105 -0
  17. package/dist/client/fields.js.map +1 -0
  18. package/dist/client/types.d.ts +46 -22
  19. package/dist/client/types.d.ts.map +1 -1
  20. package/dist/index.js +19 -0
  21. package/dist/index.js.map +1 -1
  22. package/dist/resources/domain.resources.js +2 -2
  23. package/dist/resources/domain.resources.js.map +1 -1
  24. package/dist/server.js +1 -1
  25. package/dist/server.js.map +1 -1
  26. package/dist/services/data-formatter.d.ts +5 -1
  27. package/dist/services/data-formatter.d.ts.map +1 -1
  28. package/dist/services/data-formatter.js +55 -38
  29. package/dist/services/data-formatter.js.map +1 -1
  30. package/dist/services/dynamic-data-utils.d.ts +35 -0
  31. package/dist/services/dynamic-data-utils.d.ts.map +1 -0
  32. package/dist/services/dynamic-data-utils.js +132 -0
  33. package/dist/services/dynamic-data-utils.js.map +1 -0
  34. package/dist/services/file-store.d.ts +5 -1
  35. package/dist/services/file-store.d.ts.map +1 -1
  36. package/dist/services/file-store.js +16 -5
  37. package/dist/services/file-store.js.map +1 -1
  38. package/dist/services/intent-detector.d.ts.map +1 -1
  39. package/dist/services/intent-detector.js +10 -10
  40. package/dist/services/intent-detector.js.map +1 -1
  41. package/dist/services/learning.d.ts +2 -0
  42. package/dist/services/learning.d.ts.map +1 -1
  43. package/dist/services/learning.js +30 -9
  44. package/dist/services/learning.js.map +1 -1
  45. package/dist/services/variable-search.d.ts +50 -0
  46. package/dist/services/variable-search.d.ts.map +1 -0
  47. package/dist/services/variable-search.js +482 -0
  48. package/dist/services/variable-search.js.map +1 -0
  49. package/dist/tools/allstats.tools.d.ts.map +1 -1
  50. package/dist/tools/allstats.tools.js +13 -3
  51. package/dist/tools/allstats.tools.js.map +1 -1
  52. package/dist/tools/analysis.tools.d.ts.map +1 -1
  53. package/dist/tools/analysis.tools.js +291 -533
  54. package/dist/tools/analysis.tools.js.map +1 -1
  55. package/dist/tools/csa.tools.d.ts.map +1 -1
  56. package/dist/tools/csa.tools.js +13 -4
  57. package/dist/tools/csa.tools.js.map +1 -1
  58. package/dist/tools/dynamic-data.tools.d.ts.map +1 -1
  59. package/dist/tools/dynamic-data.tools.js +66 -23
  60. package/dist/tools/dynamic-data.tools.js.map +1 -1
  61. package/dist/tools/glossary.tools.d.ts +2 -1
  62. package/dist/tools/glossary.tools.d.ts.map +1 -1
  63. package/dist/tools/glossary.tools.js +30 -1
  64. package/dist/tools/glossary.tools.js.map +1 -1
  65. package/dist/tools/infographic.tools.d.ts.map +1 -1
  66. package/dist/tools/infographic.tools.js +7 -9
  67. package/dist/tools/infographic.tools.js.map +1 -1
  68. package/dist/tools/news.tools.d.ts.map +1 -1
  69. package/dist/tools/news.tools.js +2 -7
  70. package/dist/tools/news.tools.js.map +1 -1
  71. package/dist/tools/publication.tools.d.ts.map +1 -1
  72. package/dist/tools/publication.tools.js +12 -4
  73. package/dist/tools/publication.tools.js.map +1 -1
  74. package/dist/tools/reference.tools.d.ts.map +1 -1
  75. package/dist/tools/reference.tools.js +3 -1
  76. package/dist/tools/reference.tools.js.map +1 -1
  77. package/dist/tools/search.tools.d.ts.map +1 -1
  78. package/dist/tools/search.tools.js +71 -7
  79. package/dist/tools/search.tools.js.map +1 -1
  80. package/dist/tools/smart.tools.d.ts.map +1 -1
  81. package/dist/tools/smart.tools.js +352 -414
  82. package/dist/tools/smart.tools.js.map +1 -1
  83. package/dist/tools/static-table.tools.d.ts.map +1 -1
  84. package/dist/tools/static-table.tools.js +4 -3
  85. package/dist/tools/static-table.tools.js.map +1 -1
  86. package/dist/tools/trade.tools.d.ts.map +1 -1
  87. package/dist/tools/trade.tools.js +53 -7
  88. package/dist/tools/trade.tools.js.map +1 -1
  89. package/dist/utils/error.d.ts.map +1 -1
  90. package/dist/utils/error.js +5 -0
  91. package/dist/utils/error.js.map +1 -1
  92. package/dist/utils/html.d.ts +9 -0
  93. package/dist/utils/html.d.ts.map +1 -0
  94. package/dist/utils/html.js +77 -0
  95. package/dist/utils/html.js.map +1 -0
  96. 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 { lookupVar, learnVar, invalidateVar, lookupPeriod, learnPeriod, invalidatePeriod, normalizeKeyword, } from "../services/learning.js";
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
- // Search within specific subject
29
- await searchVariablesInSubject(client, domain, subject, kw, allVars);
31
+ await searchVariablesInSubject(client, domain, subject, normalizeKeyword(keyword), allVars);
30
32
  }
31
33
  else {
32
- // Use keyword → subject mapping + subject title matching
33
- const mappedIds = getSubjectIdsForKeyword(kw);
34
- const subjects = await client.listSubjects(domain);
35
- const matchedIds = subjects.data
36
- .filter(s => {
37
- const t = s.title.toLowerCase();
38
- return kw.split(/\s+/).some(w => w.length > 2 && t.includes(w)) || t.includes(kw);
39
- })
40
- .map(s => s.sub_id);
41
- const subjectIds = [...new Set([...mappedIds, ...matchedIds])];
42
- // Search in relevant subjects first
43
- for (const subId of subjectIds.slice(0, 5)) {
44
- await searchVariablesInSubject(client, domain, subId, kw, allVars);
45
- if (allVars.length >= 15)
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
- lines.push(`- **${v.title}** (var_id: \`${v.var_id}\`) — Subjek: ${v.sub_name}${v.unit ? ` — Satuan: ${v.unit}` : ""}`);
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
- | Pengangguran (jumlah) | get_dynamic_data(var="674") | 674 |
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 | 1706 |
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="1452") | 1452 |
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
- if (intent.intent !== "single_value" && intent.intent !== "table" && intent.intent !== "unknown" && intent.confidence >= 0.6) {
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": "${region}"\n` +
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
- // Step 2: Find variable via 3-layer lookup
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
- // Skip cached lookup if query explicitly asks for breakdown (kab/kota)
223
- // because cached var is likely the aggregate, not the breakdown
224
- const asksBreakdown = /\b(kabupaten|kab[/\s]kota|per\s*kab)\b/.test(kw);
225
- let bestVar = asksBreakdown ? null : await lookupVar(query, domain, store);
226
- let fromLearning = !!bestVar;
227
- const candidates = [];
228
- if (!bestVar) {
229
- bestVar = await fullSearchVar(client, kw, domain, candidates);
230
- fromLearning = false;
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 multiple keyword strategies
256
+ // Fallback: try static tables with keyword strategies
238
257
  try {
239
- // Strategy 1: Try with normalized keyword
240
- let tables = await safeListStaticTables(client, domain, kw);
241
- // Strategy 2: If empty and keyword has multiple words, try splitting or using broader parts
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 full search
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
- // Retry with full search
337
- const retryCandidates = [];
338
- const retryVar = await fullSearchVar(client, kw, domain, retryCandidates);
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 = retryVar;
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
- const formatted = formatDynamicData(result, domain, config.defaultLang);
348
- const header = `**Pencarian:** "${query}" di ${domainName}${year ? ` (${year})` : ""}\n**Variabel:** ${bestVar.title} (ID: ${bestVar.var_id})\n\n`;
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
- const parentVar = await lookupVar(query, parentDomain, store) || await fullSearchVar(client, kw, parentDomain, parentCandidates);
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 parentFormatted = formatDynamicData(parentResult, parentDomain, config.defaultLang);
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
- // Strategy 1: Try with normalized keyword
370
- let tables = await safeListStaticTables(client, domain, kw);
371
- // Strategy 2: If empty and keyword has multiple words, try splitting or using broader parts
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
- if (candidates.length > 1) {
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 candidates.slice(0, 5)) {
433
- altLines.push(`- ${c.title} (var_id: \`${c.var_id}\`)${c.unit ? ` — ${c.unit}` : ""}`);
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
- // Generate result hints
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
- // Common keyword → subject ID mapping (BPS subject IDs are stable across domains)
454
- const KEYWORD_SUBJECTS = {
455
- pengangguran: [6], tenaga: [6], kerja: [6], tpak: [6], angkatan: [6],
456
- miskin: [23], kemiskinan: [23], gini: [23], ketimpangan: [23],
457
- penduduk: [12], kependudukan: [12], fertilitas: [12], migrasi: [12],
458
- inflasi: [3], harga: [3], ihk: [3],
459
- pdrb: [52], ekonomi: [52, 35], pertumbuhan: [52],
460
- ipm: [26], pembangunan: [26],
461
- ekspor: [8], impor: [8], perdagangan: [8],
462
- pertanian: [55], pangan: [55],
463
- industri: [9], manufaktur: [9],
464
- kesehatan: [30],
465
- pendidikan: [28],
466
- pariwisata: [16],
467
- agama: [12], religi: [12], keagamaan: [12],
468
- };
469
- function getSubjectIdsForKeyword(kw) {
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 = await client.listPeriods(domain, varId);
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
- const latest = periods[0];
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(",").map(y => y.trim());
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 = await client.listPeriods(domain, varId);
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 pAny = p;
570
- const thName = String(pAny.th_name || pAny.th || "");
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 pAny = p;
579
- const thName = String(pAny.th_name || pAny.th || pAny.val || "");
580
- const matchedYear = yearNums.find(y => thName.includes(y));
581
- if (matchedYear) {
582
- await learnPeriod(varId, domain, matchedYear, String(p.th_id), store);
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 => String(p.th_id)).join(",");
467
+ return matchingPeriods.map(p => getPeriodId(p)).filter(Boolean).join(",");
586
468
  }
587
469
  }
588
470
  }
589
471
  catch {
590
- // If period lookup fails, use raw year value
472
+ // If period lookup fails, no usable period.
591
473
  }
592
- return year;
474
+ return undefined;
593
475
  }
594
- /** Try strategic indicators as fallback. */
595
- async function tryStrategicIndicators(client, kw, domain, domainName, year) {
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
- for (const ind of indicators.data) {
600
- const titleLower = ind.title.toLowerCase();
601
- if (titleLower.includes(kw) || kw.split(/\s+/).some(w => titleLower.includes(w))) {
602
- if (year && !ind.periode.includes(year)) {
603
- continue;
604
- }
605
- const val = typeof ind.value === "number" ? ind.value.toLocaleString("id-ID") : String(ind.value);
606
- const lines = [
607
- `## ${ind.title}`,
608
- `**Wilayah:** ${domainName} (${domain})`,
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
- return null;
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
- const kwWords = kw.split(/\s+/).filter(w => w.length > 2);
627
- for (let page = 1; page <= 3; page++) {
628
- const result = await client.listVariables(domain, subject, undefined, page, 100);
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 v of result.data) {
632
- const titleLower = v.title.toLowerCase();
633
- const defLower = (v.def || "").toLowerCase();
634
- // Match if full keyword or any word matches
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 (results.length >= 15)
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
- * Compute relevance score for a variable title against a search query.
658
- * Higher score = more relevant.
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 computeRelevanceScore(query, title, subName) {
661
- let score = 0;
662
- const queryWords = query.split(/\s+/).filter(w => w.length > 2);
663
- // Exact phrase match in title
664
- if (title.includes(query))
665
- score += 100;
666
- // Word-level matches in title
667
- let wordMatches = 0;
668
- for (const word of queryWords) {
669
- if (title.includes(word)) {
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
- // Bonus for matching all query words
677
- if (queryWords.length > 1 && wordMatches === queryWords.length)
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
- // Prefer "persentase" or "jumlah penduduk miskin" over "garis kemiskinan" or "indeks"
702
- if (query.includes("miskin") || query.includes("kemiskinan")) {
703
- // If user explicitly asks for "jumlah", prefer jumlah variant
704
- if (query.includes("jumlah")) {
705
- if (title.includes("jumlah"))
706
- score += 40;
707
- if (title.includes("persentase"))
708
- score -= 10;
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
- else {
711
- if (title.includes("persentase"))
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
- // Prefer "jumlah penduduk menurut agama" for religion queries
722
- if (query.includes("agama") || query.includes("religi")) {
723
- if (title.includes("jumlah") || title.includes("penduduk"))
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("menurut agama"))
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 score;
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
- try {
737
- const res = await client.listStaticTables(domain, keyword, undefined, undefined, 1);
738
- return res.data || [];
739
- }
740
- catch (err) {
741
- logger.debug(`safeListStaticTables failed for keyword="${keyword}": ${err instanceof Error ? err.message : String(err)}`);
742
- return [];
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