bps-mcp-server 0.16.3 → 0.17.0

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