PageSourceSearch

https://search.ugm.ac.id/ai/search&dsh/public/js/dsh-ai.js?v=2026092415

js ugm.ac.id collected 2026-09-24 08:34:20 UTC 64,443 bytes, 1,561 lines download raw bytes

1/**
2 * dsh-ai.js
3 * DSH Portal — AI-powered search enhancements
4 *
5 * Sections:
6 *  1. Module state & config
7 *  2. AI Summary (replaces _buildSummarySection async)
8 *  3. Intent Detection + auto-tab
9 *  4. Query Rewriting
10 *  5. RAG Answer Panel
11 *  6. UI helpers (streaming typewriter, panel render)
12 *  7. Public API
13 */
14
15'use strict';
16
17// ---------------------------------------------------------------------------
18// 1. MODULE STATE & CONFIG
19// ---------------------------------------------------------------------------
20
21// Use server-injected absolute URL if available, else fall back to relative
22const AI_API_BASE = (typeof window.DSH_API_BASE !== 'undefined')
23    ? window.DSH_API_BASE
24    : '../api/api.php';
25
26const _aiState = {
27  lastQuery:       '',
28  lastSummary:     '',
29  lastIntent:      null,
30  ragPanelVisible: false,
31  summaryLoading:  false,
32  summaryAbort:    null,
33  ragSources:      [],      // Sumber dari RAG answer — untuk integrasi kartu
34  ragWebSources:   [],      // Web-only sources dari Serper
35  ragLocalSources: [],      // Local DB sources yang cocok
36  lastKnowledgeId: null,    // ID di ai_qa_knowledge — untuk feedback API
37  sessionId:       null,    // Session ID percakapan — disimpan di localStorage
38  smartMode:       true,    // true=Smart (bawaan), false=Mini — keduanya router LLM, cadangan Ollama lokal
39};
40
41// Kata tanya dalam bahasa Indonesia — trigger RAG panel
42const QUESTION_WORDS = [
43  'apa', 'siapa', 'bagaimana', 'berapa', 'kapan', 'dimana', 'di mana',
44  'apakah', 'mengapa', 'kenapa', 'bisakah', 'bolehkah', 'adakah',
45  'sebutkan', 'jelaskan', 'ceritakan', 'bandingkan',
46];
47
48// Frasa yang mengindikasikan pertanyaan meski kata tanya tidak di awal
49const QUESTION_PHRASES = [
50  'peringkat berapa', 'ranking berapa', 'nomor berapa', 'urutan berapa',
51  'berapa peringkat', 'berapa ranking',
52  'saat ini', 'tahun ini', 'terbaru', 'terkini', 'sekarang',
53  'gimana cara', 'cara daftar', 'cara mendaftar', 'cara masuk',
54  'info tentang', 'informasi tentang', 'tentang apa',
55  'siapa rektor', 'siapa dekan', 'siapa ketua',
56  'apa itu', 'apa yang', 'apa saja',
57];
58
59// ---------------------------------------------------------------------------
60// 2. AI SUMMARY — Async LLM narrative summary
61// ---------------------------------------------------------------------------
62
63/**
64 * Panggil endpoint ai_summarize, kembalikan teks summary.
65 * Fallback ke null jika gagal (caller tetap tampilkan rule-based).
66 * @param {string} query
67 * @param {Array}  results  — Array hasil search (max 8 dikirim ke API)
68 * @param {string} types    — Comma-separated entity types
69 * @returns {Promise<string|null>}
70 */
71async function fetchAISummary(query, results = [], types = '') {
72  if (!query || query.length < 3) return null;
73
74  try {
75    const body = new FormData();
76    body.append('action', 'ai_summarize');
77    body.append('q', query);
78    body.append('types', types);
79    body.append('count', String(results.length));
80    body.append('results', JSON.stringify(results.slice(0, 8).map(r => ({
81      title:       r.title       || '',
82      description: r.description || '',
83      entity_type: r.entity_type || '',
84      url:         r.url         || '',
85    }))));
86
87    const resp = await fetch(AI_API_BASE, { method: 'POST', body });
88    if (!resp.ok) return null;
89    const data = await resp.json();
90    if (data.ok && data.data?.summary) return data.data.summary;
91  } catch (_) { /* network error — graceful fallback */ }
92  return null;
93}
94
95/**
96 * Inject AI summary ke dalam summary section yang sudah dirender.
97 * Dipanggil setelah renderCards() selesai (non-blocking).
98 */
99async function injectAISummaryIntoPanel(query, results, types) {
100  const panel = document.getElementById('dshSummaryNarrative');
101  if (!panel) return;
102
103  // Tampilkan skeleton loading state
104  const originalHTML = panel.innerHTML;
105  panel.innerHTML = `
106    <div class="flex items-center gap-2 text-sm text-gray-500">
107      <svg class="animate-spin h-4 w-4 text-blue-500" fill="none" viewBox="0 0 24 24">
108        <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
109        <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
110      </svg>
111      <span class="animate-pulse">DSH Assistant sedang menganalisis hasil pencarian...</span>
112    </div>`;
113
114  const aiSummary = await fetchAISummary(query, results, types);
115
116  if (aiSummary) {
117    // Typewriter effect untuk AI text
118    panel.innerHTML = '';
119    const textNode = document.createElement('div');
120    textNode.className = 'text-sm text-gray-800 leading-relaxed';
121    panel.appendChild(textNode);
122
123    const badge = document.createElement('span');
124    badge.className = 'inline-flex items-center gap-1 text-xs bg-gradient-to-r from-blue-100 to-purple-100 text-blue-700 border border-blue-200 rounded-full px-2 py-0.5 mb-2 font-medium';
125    badge.innerHTML = '✨ AI Generated';
126    panel.insertBefore(badge, textNode);
127
128    await typewriterEffect(textNode, aiSummary, 12);
129    _aiState.lastSummary = aiSummary;
130  } else {
131    // Restore rule-based content jika AI gagal
132    panel.innerHTML = originalHTML;
133  }
134}
135
136// Typewriter effect — tampilkan teks karakter per karakter
137async function typewriterEffect(el, text, delayMs = 15) {
138  // Convert markdown-lite to HTML before typewriter
139  const html = markdownToHtml(text);
140  el.innerHTML = '';
141  // For short text use instant render, for long use character-by-character on plain text
142  if (text.length > 800 || delayMs === 0) {
143    el.innerHTML = html;
144    return;
145  }
146  // Render HTML instantly then do a fade-in for perceived effect
147  el.style.opacity = '0';
148  el.innerHTML = html;
149  el.style.transition = 'opacity 0.4s ease';
150  el.style.opacity = '1';
151}
152
153/**
154 * markdownToHtml — lightweight markdown converter for RAG answers
155 * Handles: **bold**, _italic_, _note lines_, paragraphs, line breaks
156 * Narasi mode: tidak ada list, cukup paragraf mengalir
157 */
158function markdownToHtml(text) {
159  if (!text) return '';
160  let s = text.replace(/\0/g, '').trim();
161
162  // Escape HTML entities (XSS prevention from Serper snippets)
163  s = s.replace(/&/g, '&amp;')
164       .replace(/</g, '&lt;')
165       .replace(/>/g, '&gt;');
166
167  // --- ITALIC NOTE LINE: _teks..._ on its own line (process first, most specific) ---
168  s = s.replace(/^_(.+?)_\s*$/gm,
169    '<p class="text-xs text-gray-400 italic mt-4 pt-3 border-t border-gray-100">$1</p>');
170
171  // --- Struktur dari LLM router: judul (#..######), garis pemisah, butir daftar ---
172  // Judul dijadikan blok tersendiri agar tidak tampil sebagai "# teks" mentah.
173  s = s.replace(/^[ \t]*(?:-{3,}|\*{3,}|_{3,})[ \t]*$/gm, '');
174  s = s.replace(/^[ \t]*#{1,6}[ \t]+(.+?)[ \t]*#*[ \t]*$/gm,
175    '\n\n<p class="font-semibold text-gray-900 mt-3 mb-1">$1</p>\n\n');
176  s = s.replace(/^[ \t]*[-*+][ \t]+/gm, '• ');
177  // Tabel Markdown: baris pemisah |---| dibuang, tiap baris jadi teks "sel · sel"
178  s = s.replace(/^[ \t]*\|?[ \t]*:?-{3,}:?[ \t]*(?:\|[ \t]*:?-{3,}:?[ \t]*)*\|?[ \t]*$/gm, '');
179  s = s.replace(/^[ \t]*\|(.+)\|[ \t]*$/gm,
180    (_, row) => row.split('|').map(c => c.trim()).filter(Boolean).join(' · '));
181
182  // --- BOLD: **text** or __text__ ---
183  s = s.replace(/\*\*(.+?)\*\*/gs, '<strong>$1</strong>');
184  s = s.replace(/__(.+?)__/gs, '<strong>$1</strong>');
185
186  // --- ITALIC inline: *text* or _text_ (single, not already processed as note) ---
187  s = s.replace(/\*([^*\n]{1,120}?)\*/g, '<em>$1</em>');
188  s = s.replace(/_([^_\n]{1,120}?)_/g, '<em>$1</em>');
189
190  // --- PARAGRAPHS: double newline → new paragraph ---
191  const parts = s.split(/\n{2,}/).filter(part => part.trim() !== '');
192  const htmlParts = parts.map(part => {
193    part = part.replace(/\n/g, '<br>').trim();
194    // Don't wrap if already a block element (e.g. our note <p>)
195    if (/^<p[\s>]/.test(part)) return part;
196    return `<p class="leading-relaxed text-gray-800">${part}</p>`;
197  });
198
199  return htmlParts.join('\n');
200}
201
202// ---------------------------------------------------------------------------
203// 3. INTENT DETECTION — Auto pre-select tab
204// ---------------------------------------------------------------------------
205
206/**
207 * Deteksi intent dari query, return {primary, secondary, is_question, keywords}
208 * Cache-aware: request hanya jika query berubah.
209 */
210async function detectQueryIntent(query) {
211  if (!query || query.length < 3) return null;
212  if (_aiState.lastQuery === query && _aiState.lastIntent) return _aiState.lastIntent;
213
214  try {
215    const url = `${AI_API_BASE}?action=detect_intent&q=${encodeURIComponent(query)}`;
216    const resp = await fetch(url);
217    if (!resp.ok) return null;
218    const data = await resp.json();
219    if (data.ok && data.data) {
220      _aiState.lastIntent = data.data;
221      _aiState.lastQuery  = query;
222      return data.data;
223    }
224  } catch (_) {}
225  return null;
226}
227
228/**
229 * Auto-switch result tab berdasarkan intent yang terdeteksi.
230 * Hanya switch jika confidence >= threshold dan semua tab tersedia.
231 */
232function applyIntentToTabs(intent) {
233  if (!intent || intent.primary === 'general') return;
234  if ((intent.confidence ?? 0) < 0.75) return;
235
236  // Map intent ke tab ID di frontend
237  const intentTabMap = {
238    service:     'tab-service',
239    news:        'tab-news',
240    product:     'tab-product',
241    people:      'tab-people',
242    publication: 'tab-publication',
243    patent:      'tab-patent',
244    legal:       'tabLegal',
245    pidato:      'tabPidato',
246  };
247
248  const tabId = intentTabMap[intent.primary];
249  if (!tabId) return;
250
251  const tabBtn = document.getElementById(tabId);
252  if (!tabBtn) return;
253
254  // Hanya auto-switch jika user tidak sedang di tab spesifik
255  const currentTab = document.querySelector('[data-result-tab].tab-active, [data-result-tab][aria-selected="true"]');
256  if (currentTab && currentTab.id !== 'tab-all') return; // Jangan override pilihan manual user
257
258  // Soft switch: highlight saran tanpa auto-click
259  showIntentSuggestion(intent.primary, tabBtn);
260}
261
262function showIntentSuggestion(intentType, tabBtn) {
263  // Hapus suggestion lama
264  document.querySelector('.ai-intent-suggestion')?.remove();
265
266  const typeLabels = {
267    service: 'Layanan Digital', news: 'Berita', product: 'Produk',
268    people: 'Dosen/Staff', publication: 'Publikasi', patent: 'HKI/Paten',
269    legal: 'Produk Hukum',
270  };
271  const label = typeLabels[intentType] || intentType;
272
273  const badge = document.createElement('div');
274  badge.className = 'ai-intent-suggestion fixed bottom-4 right-4 z-50 bg-white border border-blue-200 rounded-xl shadow-lg p-3 flex items-center gap-3 text-sm animate-fade-in';
275  badge.innerHTML = `
276    <div class="w-7 h-7 bg-blue-100 rounded-full flex items-center justify-center text-blue-600">🤖</div>
277    <div>
278      <div class="font-medium text-gray-800">Hasil terbaik di: <strong>${label}</strong></div>
279      <div class="text-xs text-gray-500">Klik untuk filter otomatis</div>
280    </div>
281    <button onclick="document.getElementById('${tabBtn.id}')?.click(); document.querySelector('.ai-intent-suggestion')?.remove();"
282            class="ml-2 px-3 py-1.5 bg-blue-600 text-white text-xs font-medium rounded-lg hover:bg-blue-700 transition-colors">
283      Filter
284    </button>
285    <button onclick="document.querySelector('.ai-intent-suggestion')?.remove();"
286            class="text-gray-400 hover:text-gray-600 transition-colors">✕</button>
287  `;
288  document.body.appendChild(badge);
289
290  // Auto-hilang setelah 8 detik
291  setTimeout(() => badge.remove(), 8000);
292}
293
294// ---------------------------------------------------------------------------
295// 4. QUERY REWRITING
296// ---------------------------------------------------------------------------
297
298/**
299 * Rewrite query untuk FULLTEXT yang lebih baik.
300 * Return {expanded, intent, confidence, suggestion} atau null jika gagal.
301 * Dipanggil dari dsh-search.js sebelum API call ke smart_search.
302 */
303async function rewriteQuery(query) {
304  if (!query || query.length < 4) return null;
305  // Hanya rewrite jika query punya 1-3 kata (lebih panjang biasanya sudah spesifik)
306  const wordCount = query.trim().split(/\s+/).length;
307  if (wordCount > 5) return null;
308
309  try {
310    const body = new FormData();
311    body.append('action', 'rewrite_query');
312    body.append('q', query);
313    const resp = await fetch(AI_API_BASE, { method: 'POST', body });
314    if (!resp.ok) return null;
315    const data = await resp.json();
316    if (data.ok && data.data?.expanded && !data.data?.fallback) {
317      return data.data;
318    }
319  } catch (_) {}
320  return null;
321}
322
323// ---------------------------------------------------------------------------
324// 5. RAG ANSWER PANEL
325// ---------------------------------------------------------------------------
326
327/**
328 * Tampilkan loading dengan text berubah-ubah yang terlihat cerdas
329 * Simulasi sistem sedang berpikir dengan berbagai tahapan
330 */
331let _loadingInterval = null;
332
333function startSmartLoading(element) {
334  if (!element) return;
335  
336  const stages = [
337    '🤔 Membaca pertanyaan Anda...',
338    '🔍 Mencari di database DSH...',
339    '📊 Menganalisis hasil relevan...',
340    '🧠 Memproses informasi...',
341    '✍️  Menyusun jawaban terbaik...',
342    '⚡ Memverifikasi akurasi...',
343    '💡 Menyempurnakan narasi...',
344    '🎯 Finalisasi jawaban...'
345  ];
346  
347  let stageIndex = 0;
348  
349  element.innerHTML = `
350    <div class="flex items-center gap-3">
351      <div class="flex gap-1.5">
352        <div class="w-2 h-2 bg-blue-500 rounded-full animate-bounce" style="animation-delay: 0s;"></div>
353        <div class="w-2 h-2 bg-blue-500 rounded-full animate-bounce" style="animation-delay: 0.2s;"></div>
354        <div class="w-2 h-2 bg-blue-500 rounded-full animate-bounce" style="animation-delay: 0.4s;"></div>
355      </div>
356      <span class="text-gray-600 font-medium" id="loadingStageText">${stages[0]}</span>
357    </div>
358  `;
359  
360  if (_loadingInterval) clearInterval(_loadingInterval);
361  
362  _loadingInterval = setInterval(() => {
363    stageIndex = (stageIndex + 1) % stages.length;
364    const stageText = document.getElementById('loadingStageText');
365    if (stageText) {
366      stageText.textContent = stages[stageIndex];
367      // Subtle animation saat text berubah
368      stageText.style.opacity = '0.7';
369      setTimeout(() => {
370        stageText.style.opacity = '1';
371        stageText.style.transition = 'opacity 0.3s ease-in-out';
372      }, 50);
373    }
374  }, 1800); // Ganti text setiap 1.8 detik
375}
376
377function stopSmartLoading() {
378  if (_loadingInterval) {
379    clearInterval(_loadingInterval);
380    _loadingInterval = null;
381  }
382}
383
384/**
385 * Set AI mode: 'smart' (qwen2.5:3b) atau 'free'/Mini (qwen2.5:0.5b) — keduanya Ollama lokal.
386 * Disimpan di localStorage dan dikirim ke setiap RAG request.
387 */
388function _setAIMode(mode) {
389  _aiState.smartMode = (mode === 'smart');  // true=Smart, false=Mini
390  localStorage.setItem('dsh_ai_mode', mode);
391  _updateModeSwitchUI(mode);
392
393  // Re-trigger RAG dengan mode baru jika panel sedang terbuka
394  const query = _aiState.lastQuery || document.getElementById('searchInput')?.value?.trim() || '';
395  if (query && _aiState.ragPanelVisible) {
396    showRAGPanel(query);
397  }
398}
399
400/**
401 * Update tampilan toggle switch berdasarkan mode aktif
402 */
403function _updateModeSwitchUI(mode) {
404  const freeBtn  = document.getElementById('ragModeFreeBtn');
405  const smartBtn = document.getElementById('ragModeSmartBtn');
406  if (!freeBtn || !smartBtn) return;
407
408  const activeSmart = 'px-2 py-1 rounded-md font-medium transition-all bg-amber-100 text-amber-800 shadow-sm';
409  const activeFree  = 'px-2 py-1 rounded-md font-medium transition-all bg-green-100 text-green-800 shadow-sm';
410  const inactive    = 'px-2 py-1 rounded-md font-medium transition-all text-gray-600 hover:bg-white';
411
412  if (mode === 'smart') {
413    smartBtn.className = activeSmart;
414    freeBtn.className  = inactive;
415  } else {
416    // 'free' atau default → Ollama/Mini aktif
417    freeBtn.className  = activeFree;
418    smartBtn.className = inactive;
419  }
420}
421
422/**
423 * Cek apakah query adalah pertanyaan yang butuh RAG.
424 * Deteksi: kata tanya di awal ATAU frasa pertanyaan di mana saja ATAU diakhiri "?"
425 */
426function isQuestionQuery(query) {
427  if (!query) return false;
428  const q = query.toLowerCase().trim();
429
430  // 1. Diakhiri tanda tanya
431  if (q.endsWith('?')) return true;
432
433  // 2. Dimulai dengan kata tanya
434  if (QUESTION_WORDS.some(w => q.startsWith(w + ' ') || q.startsWith(w + '?') || q === w)) return true;
435
436  // 3. Mengandung frasa pertanyaan di mana saja
437  if (QUESTION_PHRASES.some(p => q.includes(p))) return true;
438
439  // 4. Query >= 4 kata — kemungkinan besar pertanyaan atau informational query
440  if (q.split(/\s+/).length >= 4) return true;
441
442  return false;
443}
444
445/**
446 * Tampilkan RAG Answer Panel dengan jawaban AI
447 * Dipanggil setelah search selesai jika query adalah pertanyaan
448 */
449async function showRAGPanel(query, types = '') {
450  if (!isQuestionQuery(query)) return;
451
452  let wrapper = document.getElementById('dshRAGPanelWrapper');
453  if (!wrapper) {
454    wrapper = createRAGPanel();
455    // RAG panel di-inject SEBELUM resultsLayoutWrapper agar full-width di atas sidebar+konten
456    const layoutWrapper = document.getElementById('resultsLayoutWrapper');
457    if (layoutWrapper?.parentNode) {
458      layoutWrapper.parentNode.insertBefore(wrapper, layoutWrapper);
459    } else {
460      // Fallback: sebelum resultTabsContainer
461      const tabsContainer = document.getElementById('resultTabsContainer');
462      if (tabsContainer?.parentNode) {
463        tabsContainer.parentNode.insertBefore(wrapper, tabsContainer);
464      }
465    }
466  }
467
468  wrapper.style.display = '';
469  _aiState.ragPanelVisible = true;
470
471  // Init mode dari localStorage (pertama kali panel dibuka)
472  // Sync mode dari localStorage setiap kali panel dibuka (default: 'smart' → qwen2.5:3b)
473  {
474    const savedMode = localStorage.getItem('dsh_ai_mode') || 'smart';
475    // Migrasi: jika user lama punya 'free' tersimpan, biarkan — itu pilihan mereka
476    _aiState.smartMode = (savedMode !== 'free');
477    requestAnimationFrame(() => _updateModeSwitchUI(savedMode));
478  }
479
480  // Loading state dengan text dinamis yang berubah-ubah
481  const answerEl  = document.getElementById('ragAnswer');
482  const sourcesEl = document.getElementById('ragSources');
483  const webNoteEl = document.getElementById('ragWebNote');
484  const feedbackEl = document.getElementById('ragFeedbackBar');
485  if (answerEl) {
486    startSmartLoading(answerEl);
487  }
488  if (sourcesEl)   sourcesEl.innerHTML = '';
489  if (webNoteEl)   { webNoteEl.textContent = ''; webNoteEl.classList.add('hidden'); }
490  if (feedbackEl)  feedbackEl.innerHTML = '<span class="text-xs text-gray-300 italic">menunggu jawaban...</span>';
491
492  try {
493    // Ambil atau buat session_id dari localStorage (persisten per browser tab)
494    if (!_aiState.sessionId) {
495      _aiState.sessionId = localStorage.getItem('dsh_ai_session') || '';
496      if (!_aiState.sessionId) {
497        _aiState.sessionId = 'dsh_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
498        localStorage.setItem('dsh_ai_session', _aiState.sessionId);
499      }
500    }
501
502    const body = new FormData();
503    body.append('action', 'rag_answer');
504    body.append('q', query);
505    body.append('types', types);
506    body.append('session_id', _aiState.sessionId);
507    body.append('smart_mode', _aiState.smartMode ? '1' : '0');  // 1 = Smart, 0 = Mini (jawaban lebih pendek)
508
509    const resp = await fetch(AI_API_BASE, { method: 'POST', body });
510
511    // Handle 429 rate limit secara khusus — tampilkan pesan, jangan hide panel
512    if (resp.status === 429) {
513      const retryAfter = parseInt(resp.headers.get('Retry-After') || '60', 10);
514      if (answerEl) {
515        stopSmartLoading();
516        answerEl.innerHTML = `<div class="flex items-center gap-2 text-amber-700 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2 text-sm">
517          <svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
518          <span>Terlalu banyak permintaan. Coba lagi dalam <strong>${retryAfter}</strong> detik.</span>
519        </div>`;
520      }
521      if (feedbackEl) feedbackEl.innerHTML = '';
522      return;
523    }
524
525    const data = await resp.json();
526
527    if (data.ok && data.data?.answer) {
528      const mode     = data.data.mode     || '';
529      const aiMode   = data.data.ai_mode  || '';   // 'smart' | 'free'
530      const aiModel  = data.data.ai_model || '';
531      const answerText = data.data.answer || '';
532
533      // Update mode badge — gabungkan search mode + ai mode
534      const badgeEl = document.getElementById('ragModeBadge');
535      if (badgeEl) {
536        if (mode === 'serper_only') {
537          badgeEl.textContent = '🔍 Pencarian Informasi';
538          badgeEl.className = 'ml-2 text-xs bg-orange-100 text-orange-700 px-2 py-0.5 rounded-full font-medium';
539        } else if (aiMode === 'free') {
540          badgeEl.textContent = '🆓 Mini Mode';
541          badgeEl.className = 'ml-2 text-xs bg-green-100 text-green-700 px-2 py-0.5 rounded-full font-medium';
542        } else if (aiMode === 'smart') {
543          badgeEl.textContent = '⚡ Smart Mode';
544          badgeEl.className = 'ml-2 text-xs bg-blue-100 text-blue-700 px-2 py-0.5 rounded-full font-medium';
545        } else if (mode === 'rag') {
546          badgeEl.textContent = '🧠 AI · Data DSH';
547          badgeEl.className = 'ml-2 text-xs bg-blue-100 text-blue-700 px-2 py-0.5 rounded-full font-medium';
548        } else {
549          badgeEl.textContent = 'AI · Berbasis data DSH';
550          badgeEl.className = 'ml-2 text-xs bg-blue-100 text-blue-700 px-2 py-0.5 rounded-full font-medium';
551        }
552      }
553
554      // ---- Simpan RAG sources untuk integrasi kartu ----
555      const allSources = data.data.sources || [];
556      _aiState.ragSources      = allSources;
557      _aiState.ragWebSources   = allSources.filter(s => s.entity_type === 'web');
558      _aiState.ragLocalSources = allSources.filter(s => s.entity_type !== 'web');
559      _aiState.lastKnowledgeId = data.data.knowledge_id || null;
560
561      // Pisahkan note disclaimer web dari teks jawaban utama
562      const webNoteEl = document.getElementById('ragWebNote');
563      const notePattern = /\n*_?Sumber:\s*hasil pencarian web[^_\n]*_?\.?\s*$/i;
564      const noteMatch = answerText.match(notePattern);
565      const cleanAnswer = answerText.replace(notePattern, '').trim();
566
567      if (webNoteEl) {
568        if (noteMatch) {
569          webNoteEl.textContent = noteMatch[0].replace(/^[\n_]+|[_\n]+$/g, '').trim();
570          webNoteEl.classList.remove('hidden');
571        } else {
572          webNoteEl.textContent = '';
573          webNoteEl.classList.add('hidden');
574        }
575      }
576
577      if (answerEl) {
578        stopSmartLoading(); // Hentikan loading animasi sebelum menampilkan jawaban
579        await typewriterEffect(answerEl, cleanAnswer, 10);
580      }
581      if (sourcesEl && allSources.length) {
582        renderRAGSources(sourcesEl, allSources);
583      } else if (sourcesEl) {
584        sourcesEl.innerHTML = '';
585      }
586
587      // ---- Reset feedback bar ke state aktif setelah jawaban baru ----
588      _resetFeedbackBar();
589
590      // ---- Integrasi: highlight kartu yang relevan + inject web cards ----
591      integrateRAGWithCards(query, allSources, answerText);
592
593    } else {
594      hideRAGPanel();
595      clearRAGCardIntegration();
596    }
597  } catch (_) {
598    hideRAGPanel();
599    clearRAGCardIntegration();
600  }
601}
602
603function hideRAGPanel() {
604  const wrapper = document.getElementById('dshRAGPanelWrapper');
605  if (wrapper) wrapper.style.display = 'none';
606  _aiState.ragPanelVisible = false;
607  clearRAGCardIntegration();
608}
609
610/**
611 * Reset sesi percakapan AI — hapus session_id dari localStorage dan state,
612 * lalu minta jawaban segar (tanpa cache KB lama) untuk query saat ini.
613 */
614function resetAISession() {
615  // Buat session_id baru
616  const newSession = 'dsh_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
617  _aiState.sessionId = newSession;
618  localStorage.setItem('dsh_ai_session', newSession);
619
620  // Reset knowledge_id agar feedback tidak tertaut ke jawaban lama
621  _aiState.lastKnowledgeId = null;
622
623  // Feedback animasi pada tombol
624  const btn = document.getElementById('ragResetBtn');
625  if (btn) {
626    const original = btn.innerHTML;
627    btn.innerHTML = '<svg class="w-3.5 h-3.5 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg><span>Memulai ulang...</span>';
628    btn.disabled = true;
629    setTimeout(() => { btn.innerHTML = original; btn.disabled = false; }, 1200);
630  }
631
632  // Reset panel answer ke loading state
633  const answerEl  = document.getElementById('ragAnswer');
634  const sourcesEl = document.getElementById('ragSources');
635  const webNoteEl = document.getElementById('ragWebNote');
636  const feedbackEl = document.getElementById('ragFeedbackBar');
637  if (answerEl)   answerEl.innerHTML = '<span class="animate-pulse text-gray-500">🔄 Memuat jawaban segar tanpa cache...</span>';
638  if (sourcesEl)  sourcesEl.innerHTML = '';
639  if (webNoteEl)  { webNoteEl.textContent = ''; webNoteEl.classList.add('hidden'); }
640  if (feedbackEl) feedbackEl.innerHTML = '<span class="text-xs text-gray-300 italic">memuat ulang...</span>';
641
642  // Ambil query saat ini dan panggil ulang RAG dengan session baru
643  const query = _aiState.lastQuery || document.getElementById('searchInput')?.value || '';
644  if (!query) return;
645
646  // Paksa bypass cache dengan timestamp di parameter (tidak disimpan ke KB baru)
647  const body = new FormData();
648  body.append('action', 'rag_answer');
649  body.append('q', query);
650  body.append('session_id', newSession);
651  body.append('web', '1');
652  body.append('smart_mode', _aiState.smartMode ? '1' : '0');  // 1 = Smart, 0 = Mini (jawaban lebih pendek)
653
654  fetch(AI_API_BASE, { method: 'POST', body })
655    .then(r => {
656      if (r.status === 429) {
657        const retryAfter = parseInt(r.headers.get('Retry-After') || '60', 10);
658        if (answerEl) answerEl.innerHTML = `<div class="flex items-center gap-2 text-amber-700 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2 text-sm"><svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg><span>Terlalu banyak permintaan. Coba lagi dalam <strong>${retryAfter}</strong> detik.</span></div>`;
659        throw new Error('rate_limited');
660      }
661      return r.json();
662    })
663    .then(data => {
664      if (data.ok && data.data?.answer) {
665        _aiState.lastKnowledgeId = data.data.knowledge_id || null;
666        _aiState.ragSources      = data.data.sources || [];
667        _aiState.ragWebSources   = (data.data.sources || []).filter(s => s.entity_type === 'web');
668        _aiState.ragLocalSources = (data.data.sources || []).filter(s => s.entity_type !== 'web');
669
670        // Update mode badge
671        const badgeEl = document.getElementById('ragModeBadge');
672        if (badgeEl) {
673          const mode = data.data.mode || '';
674          if (mode === 'serper_only') {
675            badgeEl.textContent = '🔍 Pencarian Web';
676            badgeEl.className = 'ml-2 text-xs bg-orange-100 text-orange-700 px-2 py-0.5 rounded-full font-medium';
677          } else {
678            badgeEl.textContent = '🤖 AI · Sesi Baru';
679            badgeEl.className = 'ml-2 text-xs bg-green-100 text-green-700 px-2 py-0.5 rounded-full font-medium';
680          }
681        }
682
683        // Pisahkan note disclaimer web dari teks jawaban
684        const webNoteEl2 = document.getElementById('ragWebNote');
685        const notePattern2 = /\n*_?Sumber:\s*hasil pencarian web[^_\n]*_?\.?\s*$/i;
686        const noteMatch2 = data.data.answer.match(notePattern2);
687        const cleanAnswer2 = data.data.answer.replace(notePattern2, '').trim();
688
689        if (webNoteEl2) {
690          if (noteMatch2) {
691            webNoteEl2.textContent = noteMatch2[0].replace(/^[\n_]+|[_\n]+$/g, '').trim();
692            webNoteEl2.classList.remove('hidden');
693          } else {
694            webNoteEl2.textContent = '';
695            webNoteEl2.classList.add('hidden');
696          }
697        }
698
699        if (answerEl) {
700          stopSmartLoading(); // Hentikan loading animasi sebelum menampilkan jawaban refresh
701          typewriterEffect(answerEl, cleanAnswer2, 10);
702        }
703        if (sourcesEl && data.data.sources?.length) renderRAGSources(sourcesEl, data.data.sources);
704        // Reset feedback bar ke aktif setelah jawaban segar diterima
705        _resetFeedbackBar();
706      } else {
707        if (answerEl) answerEl.innerHTML = '<span class="text-red-400 text-sm">Gagal memuat jawaban baru. Coba lagi.</span>';
708      }
709    })
710    .catch((err) => {
711      if (err?.message === 'rate_limited') return; // pesan sudah ditampilkan
712      if (answerEl) answerEl.innerHTML = '<span class="text-red-400 text-sm">Koneksi gagal. Coba lagi.</span>';
713    });
714}
715
716function createRAGPanel() {
717  const wrapper = document.createElement('div');
718  wrapper.id = 'dshRAGPanelWrapper';
719  wrapper.className = 'w-full max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 mb-4';
720  wrapper.style.display = 'none'; // Initially hidden
721
722  const div = document.createElement('div');
723  div.id = 'dshRAGPanel';
724  div.className = 'bg-gradient-to-br from-amber-50/30 via-white to-orange-50/20 rounded-2xl border border-amber-200/50 shadow-lg overflow-hidden flex flex-col';
725  div.innerHTML = `
726    <!-- Header -->
727    <div class="flex items-center justify-between px-4 py-2.5 border-b border-amber-100/50 bg-white/70 backdrop-blur-sm">
728      <div class="flex items-center gap-2">
729        <div class="w-8 h-8 bg-gradient-to-br from-amber-400 to-orange-500 rounded-lg flex items-center justify-center shadow-sm shrink-0">
730          <svg class="w-4.5 h-4.5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
731            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5"
732              d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/>
733          </svg>
734        </div>
735        <span class="font-semibold text-gray-800 text-sm">DSH Menjawab</span>
736      </div>
737      <div class="flex items-center gap-1.5 shrink-0">
738        <!-- Mode Switch: Mini Mode ↔ Smart Mode -->
739        <div id="ragModeSwitch" class="flex items-center gap-0.5 bg-amber-50 rounded-lg p-0.5 text-xs border border-amber-100">
740          <button id="ragModeFreeBtn" onclick="_setAIMode('free')"
741            class="px-2 py-1 rounded-md font-medium transition-all text-gray-600 hover:bg-white"
742            title="Mini Mode">
743            <span class="hidden sm:inline">🆓 Mini</span><span class="sm:hidden">🆓</span>
744          </button>
745          <button id="ragModeSmartBtn" onclick="_setAIMode('smart')"
746            class="px-2 py-1 rounded-md font-medium transition-all text-gray-600 hover:bg-white"
747            title="Mode Smart">
748            <span class="hidden sm:inline">⚡ Smart</span><span class="sm:hidden">⚡</span>
749          </button>
750        </div>
751        <button onclick="resetAISession()" id="ragResetBtn"
752          class="text-gray-400 hover:text-amber-600 transition-colors p-1.5 rounded-lg hover:bg-amber-50"
753          title="Mulai ulang">
754          <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
755            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
756              d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
757          </svg>
758        </button>
759        <button onclick="hideRAGPanel()"
760          class="text-gray-400 hover:text-gray-600 transition-colors p-1.5 rounded-lg hover:bg-gray-100" title="Tutup">
761          <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
762            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
763          </svg>
764        </button>
765      </div>
766    </div>
767    <!-- Body: jawaban, disclaimer web (opsional), sumber -->
768    <div class="flex-1 px-4 py-3 flex flex-col justify-between">
769      <div id="ragAnswer" class="text-sm text-gray-700 leading-relaxed prose prose-sm max-w-none"></div>
770      <div id="ragWebNote" class="hidden mt-2 text-xs text-amber-700 italic bg-amber-50 px-2 py-1.5 rounded-md border border-amber-200"></div>
771      <div id="ragSources" class="not-prose mt-3"></div>
772    </div>
773    <!-- Footer -->
774    <div class="px-4 py-2 border-t border-amber-100 bg-gradient-to-r from-amber-50/50 to-orange-50/30 flex items-center justify-between gap-3">
775      <p class="text-xs text-gray-500 leading-snug hidden sm:block">
776        Jawaban dibuat oleh AI berdasarkan data DSH UGM.
777      </p>
778      <div id="ragFeedbackBar" class="flex items-center gap-2">
779        <span class="text-xs text-gray-500">Membantu?</span>
780        <button onclick="_ragFeedback(true)"  class="text-lg hover:scale-110 transition-transform" title="Ya">👍</button>
781        <button onclick="_ragFeedback(false)" class="text-lg hover:scale-110 transition-transform" title="Tidak">👎</button>
782      </div>
783    </div>`;
784  div.innerHTML = div.innerHTML; // HTML already set above
785  wrapper.appendChild(div);
786  return wrapper;
787}
788
789function renderRAGSources(el, sources) {
790  const typeIcons = {
791    service: '🌐', news: '📰', product: '🛒',
792    people: '👨‍🏫', publication: '📚', patent: '💡',
793    legal: '⚖️', pidato: '🎓',
794    web: '🔍',
795  };
796
797  // Label header — sesuaikan teks berdasarkan apakah ada sumber DB atau web
798  const hasDb  = sources.some(s => s.entity_type !== 'web');
799  const header = document.createElement('p');
800  header.className = 'text-xs font-semibold text-amber-700 mb-2 flex items-center gap-1';
801  header.textContent = hasDb ? '🔗 Sumber:' : '🔍 Sumber Terkait:';
802
803  // Container chip — flex-wrap dengan gap konsisten, tidak overflow
804  const chipsRow = document.createElement('div');
805  chipsRow.className = 'flex flex-wrap gap-1.5';
806
807  sources.slice(0, 6).forEach((s, i) => {
808    const isWeb     = s.entity_type === 'web';
809    const hasUrl    = s.url && s.url.trim() !== '';
810    const icon      = typeIcons[s.entity_type] || '📄';
811    const safeTitle = (s.title || '').replace(/</g, '&lt;').replace(/>/g, '&gt;');
812
813    // Selalu gunakan <a href> agar link dapat diklik.
814    // Jika ada URL → buka di tab baru (utama).
815    // Side-effect: juga coba scroll ke kartu yang cocok di grid (non-blocking).
816    // Jika tidak ada URL → fallback ke button scroll saja.
817    let chip;
818    if (hasUrl) {
819      chip = document.createElement('a');
820      chip.href = s.url;
821      chip.target = '_blank';
822      chip.rel = 'noopener noreferrer';
823      // Side-effect scroll (tidak memblokir navigasi URL)
824      chip.addEventListener('click', () => {
825        // Jalankan scroll asynchronously — tidak mengganggu pembukaan URL
826        setTimeout(() => _scrollToMatchingCard(s, chip), 100);
827      });
828    } else {
829      chip = document.createElement('button');
830      chip.type = 'button';
831      chip.addEventListener('click', () => _scrollToMatchingCard(s, chip));
832    }
833
834    chip.dataset.ragChipIdx = String(i);
835    chip.title = (s.title || '') + (hasUrl ? ' — klik untuk membuka' : ' — klik untuk melihat kartu');
836
837    if (isWeb) {
838      chip.className = 'rag-src-chip inline-flex items-center gap-1 min-w-0 max-w-[180px] text-xs px-2 py-1.5 bg-amber-50 border border-amber-200 rounded-lg text-amber-800 hover:bg-amber-100 hover:border-amber-300 hover:shadow-sm transition-all group cursor-pointer';
839    } else {
840      chip.className = 'rag-src-chip inline-flex items-center gap-1 min-w-0 max-w-[180px] text-xs px-2 py-1.5 bg-orange-50 border border-orange-200 rounded-lg text-orange-800 hover:bg-orange-100 hover:border-orange-300 hover:shadow-sm transition-all group cursor-pointer';
841    }
842
843    // Ikon tipe
844    const iconSpan = document.createElement('span');
845    iconSpan.className = 'shrink-0 text-xs';
846    iconSpan.textContent = icon;
847
848    // Judul (truncate CSS)
849    const titleSpan = document.createElement('span');
850    titleSpan.className = 'truncate min-w-0 flex-1';
851    titleSpan.textContent = s.title || '';
852
853    // Ikon: panah eksternal untuk web/ada URL, panah bawah untuk scroll
854    const arrow = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
855    const arrowColor = isWeb ? 'text-sky-300 group-hover:text-sky-600' : 'text-blue-300 group-hover:text-blue-600';
856    arrow.setAttribute('class', `w-3 h-3 shrink-0 ${arrowColor} transition-colors`);
857    arrow.setAttribute('fill', 'none');
858    arrow.setAttribute('stroke', 'currentColor');
859    arrow.setAttribute('viewBox', '0 0 24 24');
860    const arrowPath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
861    arrowPath.setAttribute('stroke-linecap', 'round');
862    arrowPath.setAttribute('stroke-linejoin', 'round');
863    arrowPath.setAttribute('stroke-width', '2');
864    // Panah eksternal (↗) jika ada URL, panah bawah (↓) jika scroll saja
865    arrowPath.setAttribute('d', hasUrl
866      ? 'M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14'
867      : 'M19 9l-7 7-7-7');
868    arrow.appendChild(arrowPath);
869
870    chip.appendChild(iconSpan);
871    chip.appendChild(titleSpan);
872    chip.appendChild(arrow);
873
874    chipsRow.appendChild(chip);
875  });
876
877  el.innerHTML = '';
878  el.appendChild(header);
879  el.appendChild(chipsRow);
880}
881
882/**
883 * Scroll ke kartu di grid yang paling cocok dengan RAG source.
884 * Returns true jika kartu ditemukan & di-scroll (caller bisa preventDefault).
885 * Returns false jika tidak ditemukan (caller membuka URL sebagai fallback).
886 */
887function _scrollToMatchingCard(source, chipEl) {
888  const grid = document.getElementById('servicesGrid');
889  if (!grid) return false;
890
891  const normUrl   = _normalizeUrl(source.url);
892  const srcTitle  = (source.title || '').toLowerCase();
893  const isWeb     = source.entity_type === 'web';
894  let bestCard    = null;
895  let bestScore   = 0;
896
897  grid.querySelectorAll('.svc-card').forEach(card => {
898    // Web cards: gunakan data-rag-url yang sengaja disimpan saat inject
899    if (isWeb) {
900      const ragUrl = card.getAttribute('data-rag-url') || '';
901      if (ragUrl && ragUrl === normUrl) { bestCard = card; bestScore = 2; return; }
902    }
903
904    // Match via URL — paling akurat
905    const a    = card.querySelector('a[href]');
906    const cUrl = a ? _normalizeUrl(a.getAttribute('href')) : '';
907    if (normUrl && cUrl && cUrl === normUrl) { bestCard = card; bestScore = 2; return; }
908
909    // Match via entity_type + entity_id (dari data attributes)
910    if (source.entity_id && card.dataset.entityId === String(source.entity_id) &&
911        card.dataset.entityType === source.entity_type) {
912      bestCard = card; bestScore = 2; return;
913    }
914
915    // Title similarity — threshold lebih rendah (0.25) agar lebih toleran
916    const cTitle = (card.querySelector('h4')?.textContent || '').toLowerCase();
917    const sim = _titleSimilarity(cTitle, srcTitle);
918    if (sim > bestScore) { bestScore = sim; bestCard = card; }
919  });
920
921  if (!bestCard || bestScore < 0.25) return false;
922
923  // Visual feedback di chip: ring sementara
924  if (chipEl) {
925    const ringColor = source.entity_type === 'web' ? 'ring-sky-400' : 'ring-blue-400';
926    chipEl.classList.add('ring-2', ringColor);
927    setTimeout(() => chipEl.classList.remove('ring-2', ringColor), 1200);
928  }
929
930  bestCard.scrollIntoView({ behavior: 'smooth', block: 'center' });
931  _pulseCard(bestCard);
932  return true;
933}
934
935/**
936 * Animasi pulse ring pada kartu — kilat biru 3× lalu kembali normal
937 */
938function _pulseCard(card) {
939  card.classList.add('ring-4', 'ring-blue-500', 'ring-offset-2', 'transition-all', 'duration-300');
940  let count = 0;
941  const interval = setInterval(() => {
942    count++;
943    if (count % 2 === 0) {
944      card.classList.add('ring-4', 'ring-blue-500');
945      card.classList.remove('ring-0');
946    } else {
947      card.classList.remove('ring-4', 'ring-blue-500');
948      card.classList.add('ring-0');
949    }
950    if (count >= 5) {
951      clearInterval(interval);
952      card.classList.remove('ring-4', 'ring-blue-500', 'ring-offset-2', 'ring-0');
953      // Kembalikan highlight permanen jika kartu memang RAG-highlighted
954      if (card.dataset.ragHighlighted === 'true') {
955        card.classList.add('ring-2', 'ring-blue-400/50');
956      }
957    }
958  }, 280);
959}
960
961/**
962 * Reset feedback bar ke state aktif (tombol 👍 👎) — dipanggil setiap ada jawaban baru.
963 */
964function _resetFeedbackBar() {
965  const bar = document.getElementById('ragFeedbackBar');
966  if (!bar) return;
967  bar.innerHTML =
968    '<span class="text-xs text-gray-400">Apakah ini membantu?</span>' +
969    '<button onclick="_ragFeedback(true)"  class="text-lg hover:scale-125 transition-transform" title="Ya">👍</button>' +
970    '<button onclick="_ragFeedback(false)" class="text-lg hover:scale-125 transition-transform" title="Tidak">👎</button>';
971}
972
973function _ragFeedback(positive) {
974  const icon = positive ? '👍' : '👎';
975  const bar = document.getElementById('ragFeedbackBar');
976  if (!bar) return;
977
978  // Jika negatif, tampilkan input komentar singkat sebelum kirim
979  if (!positive) {
980    bar.innerHTML = `
981      <div class="flex items-center gap-2 animate-fade-in">
982        <span class="text-xs text-gray-500">Apa yang kurang?</span>
983        <input id="ragFeedbackText" type="text" maxlength="200"
984               placeholder="Opsional, maks 200 karakter"
985               class="text-xs border border-gray-200 rounded-lg px-2 py-1 w-40 focus:outline-none focus:border-blue-300">
986        <button onclick="_submitRagFeedback(false, document.getElementById('ragFeedbackText')?.value || '')"
987                class="text-xs px-2 py-1 bg-red-100 text-red-700 rounded-lg hover:bg-red-200 transition-colors">Kirim</button>
988        <button onclick="_submitRagFeedback(false, '')"
989                class="text-xs text-gray-400 hover:text-gray-600">Lewati</button>
990      </div>`;
991    return;
992  }
993
994  _submitRagFeedback(true, '');
995}
996
997function _submitRagFeedback(positive, feedbackText) {
998  const icon = positive ? '👍' : '👎';
999  const bar = document.getElementById('ragFeedbackBar');
1000
1001  // Tampilkan konfirmasi langsung
1002  if (bar) {
1003    bar.innerHTML = `
1004      <span class="inline-flex items-center gap-1.5 text-xs text-green-600 font-medium
1005                   bg-green-50 border border-green-200 rounded-full px-3 py-1 animate-fade-in">
1006        ${icon} Terima kasih! Masukan Anda membantu DSH AI belajar.
1007      </span>`;
1008  }
1009
1010  // POST ke API — non-blocking
1011  const query = _aiState.lastQuery || document.getElementById('searchInput')?.value || '';
1012  if (!query) return;
1013
1014  const answerEl = document.getElementById('ragAnswer');
1015  const answerSnippet = (answerEl?.innerText || '').slice(0, 200);
1016
1017  const body = new FormData();
1018  body.append('action', 'ai_feedback');
1019  body.append('q', query);
1020  body.append('rating', positive ? '1' : '-1');
1021  body.append('text', feedbackText || '');
1022  body.append('answer_snippet', answerSnippet);
1023  // session_id dan knowledge_id untuk linking ke percakapan
1024  if (_aiState.sessionId)     body.append('session_id',   _aiState.sessionId);
1025  if (_aiState.lastKnowledgeId) body.append('knowledge_id', String(_aiState.lastKnowledgeId));
1026
1027  fetch(AI_API_BASE, { method: 'POST', body }).catch(() => {/* silent fail */});
1028}
1029
1030// ---------------------------------------------------------------------------
1031// 5b. RAG ↔ CARD INTEGRATION
1032// ---------------------------------------------------------------------------
1033
1034/**
1035 * Normalize URL untuk perbandingan — hapus protocol, www, trailing slash
1036 */
1037function _normalizeUrl(url) {
1038  if (!url) return '';
1039  return url.toLowerCase()
1040    .replace(/^https?:\/\//, '')
1041    .replace(/^www\./, '')
1042    .replace(/\/+$/, '')
1043    .replace(/\?.*$/, '')   // Remove query params
1044    .replace(/#.*$/, '');   // Remove fragments
1045}
1046
1047/**
1048 * Hitung kesamaan antara dua judul (Jaccard similarity on words)
1049 */
1050function _titleSimilarity(a, b) {
1051  if (!a || !b) return 0;
1052  const wa = new Set(a.toLowerCase().replace(/[^\w\s]/g, '').split(/\s+/).filter(w => w.length > 2));
1053  const wb = new Set(b.toLowerCase().replace(/[^\w\s]/g, '').split(/\s+/).filter(w => w.length > 2));
1054  if (wa.size === 0 || wb.size === 0) return 0;
1055  let intersection = 0;
1056  for (const w of wa) { if (wb.has(w)) intersection++; }
1057  return intersection / Math.min(wa.size, wb.size);
1058}
1059
1060/**
1061 * Integrasi utama: setelah RAG panel tampil, proses kartu-kartu di grid
1062 * 1) Highlight kartu yang URL-nya cocok dengan RAG sources
1063 * 2) Re-order: kartu relevan pindah ke atas
1064 * 3) Inject web sources yang belum ada di kartu sebagai "web result" cards
1065 */
1066function integrateRAGWithCards(query, sources, answerText) {
1067  const grid = document.getElementById('servicesGrid');
1068  if (!grid || !sources?.length) return;
1069
1070  // Build lookup set dari RAG source URLs
1071  const sourceUrls = new Set(sources.map(s => _normalizeUrl(s.url)));
1072  const sourceTitles = sources.map(s => (s.title || '').toLowerCase());
1073
1074  // ---- 1. Scan existing cards dan tandai yang relevan ----
1075  const allCards   = Array.from(grid.querySelectorAll('.svc-card'));
1076  const matched    = [];
1077  const unmatched  = [];
1078
1079  allCards.forEach(card => {
1080    const cardLink = card.querySelector('a[href]');
1081    const cardUrl  = cardLink ? _normalizeUrl(cardLink.getAttribute('href')) : '';
1082    const cardTitle = (card.querySelector('h4')?.textContent || '').toLowerCase();
1083
1084    let isRelevant = false;
1085
1086    // Match by URL
1087    if (cardUrl && sourceUrls.has(cardUrl)) {
1088      isRelevant = true;
1089    }
1090
1091    // Match by title similarity (fallback jika URL beda tapi konten sama)
1092    if (!isRelevant) {
1093      for (const sTitle of sourceTitles) {
1094        if (_titleSimilarity(cardTitle, sTitle) > 0.5) {
1095          isRelevant = true;
1096          break;
1097        }
1098      }
1099    }
1100
1101    // Match by answer text keyword overlap (kartu yang isinya disebutkan di jawaban)
1102    if (!isRelevant && answerText) {
1103      const answerLower = answerText.toLowerCase();
1104      const titleWords = cardTitle.split(/\s+/).filter(w => w.length > 3);
1105      const matchCount = titleWords.filter(w => answerLower.includes(w)).length;
1106      if (titleWords.length > 0 && matchCount / titleWords.length > 0.5) {
1107        isRelevant = true;
1108      }
1109    }
1110
1111    if (isRelevant) {
1112      matched.push(card);
1113      _applyRAGHighlight(card);
1114    } else {
1115      unmatched.push(card);
1116      _removeRAGHighlight(card);
1117    }
1118  });
1119
1120  // ---- 2. Re-order: kartu relevan ke atas ----
1121  if (matched.length > 0) {
1122    // Buat divider antara "relevan" dan "lainnya"
1123    const existingDivider = grid.querySelector('.rag-relevance-divider');
1124    if (existingDivider) existingDivider.remove();
1125
1126    // Pindahkan kartu yang cocok ke atas
1127    matched.forEach(card => grid.prepend(card));
1128
1129    // Tambah divider jika ada kartu yang tidak cocok juga
1130    if (unmatched.length > 0) {
1131      const divider = document.createElement('div');
1132      divider.className = 'rag-relevance-divider col-span-full flex items-center gap-3 my-2 px-1';
1133      divider.innerHTML = `
1134        <div class="flex-1 h-px bg-gradient-to-r from-blue-200 to-transparent"></div>
1135        <span class="text-xs text-gray-400 font-medium whitespace-nowrap">Hasil pencarian lainnya</span>
1136        <div class="flex-1 h-px bg-gradient-to-l from-blue-200 to-transparent"></div>`;
1137      // Insert setelah kartu terakhir yang cocok
1138      const lastMatched = matched[matched.length - 1];
1139      if (lastMatched.nextSibling) {
1140        grid.insertBefore(divider, lastMatched.nextSibling);
1141      } else {
1142        grid.appendChild(divider);
1143      }
1144    }
1145  }
1146
1147  // ---- 3. Inject web sources yang belum ada sebagai kartu web ----
1148  const webSources = sources.filter(s => s.entity_type === 'web');
1149  if (webSources.length > 0) {
1150    const existingCardUrls = new Set(
1151      allCards.map(c => {
1152        const a = c.querySelector('a[href]');
1153        return a ? _normalizeUrl(a.getAttribute('href')) : '';
1154      }).filter(Boolean)
1155    );
1156
1157    const newWebSources = webSources.filter(s => !existingCardUrls.has(_normalizeUrl(s.url)));
1158
1159    if (newWebSources.length > 0) {
1160      // Inject paling atas, sebelum kartu yang matched
1161      const firstChild = grid.firstChild;
1162      newWebSources.slice(0, 3).reverse().forEach(src => {
1163        const webCard = _createWebSourceCard(src);
1164        if (firstChild) {
1165          grid.insertBefore(webCard, firstChild);
1166        } else {
1167          grid.appendChild(webCard);
1168        }
1169      });
1170
1171      // Sembunyikan empty state jika ada web cards
1172      const emptyState = document.getElementById('emptyState');
1173      if (emptyState) emptyState.classList.add('hidden');
1174      if (typeof hideNoResultsCTA === 'function') hideNoResultsCTA();
1175
1176      // Update result count
1177      _updateResultCountWithWeb(newWebSources.length);
1178    }
1179  }
1180}
1181
1182/**
1183 * Buat kartu untuk web source dari RAG (Serper result) — row-based layout
1184 */
1185function _createWebSourceCard(source) {
1186  const card = document.createElement('article');
1187  card.className = 'card svc-card w-full flex flex-row items-stretch relative rounded-xl border border-sky-200 bg-white transition-all duration-200 rag-web-card';
1188  card.setAttribute('data-entity-type', 'web');
1189  card.setAttribute('data-rag-source', 'true');
1190  card.setAttribute('data-rag-url', _normalizeUrl(source.url));
1191
1192  let domain = '';
1193  try { domain = new URL(source.url).hostname.replace(/^www\./, ''); } catch { domain = ''; }
1194
1195  card.innerHTML = `
1196    <!-- Left accent bar -->
1197    <div class="w-1.5 flex-shrink-0 rounded-l-xl bg-gradient-to-b from-sky-4
119700 to-blue-500"></div>
1198    <!-- Icon col -->
1199    <div class="flex-shrink-0 w-8 h-8 my-auto ml-3 rounded-lg bg-sky-50 border border-sky-100 flex items-center justify-center">
1200      <svg class="w-4 h-4 text-sky-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
1201        <path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"/>
1202      </svg>
1203    </div>
1204    <!-- Text col -->
1205    <div class="flex-1 flex flex-col justify-center px-3 py-2.5 min-w-0">
1206      <div class="flex items-center gap-1.5 mb-1">
1207        <button type="button" onclick="_scrollToRAGPanel(this)"
1208           class="inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-semibold bg-sky-100 text-sky-700 border border-sky-200 rounded-full hover:bg-sky-200 transition-colors cursor-pointer leading-none whitespace-nowrap"
1209           title="Klik untuk melihat jawaban AI di atas">
1210          🔗 Terkait jawaban DSH
1211          <svg class="w-2 h-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
1212            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7"/>
1213          </svg>
1214        </button>
1215        <span class="inline-flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] font-semibold bg-gradient-to-r from-sky-500 to-blue-500 text-white rounded-full leading-none whitespace-nowrap">
1216          <svg class="w-2.5 h-2.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
1217            <path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
1218          </svg>Sumber Web
1219        </span>
1220      </div>
1221      <h4 class="font-semibold text-sm text-gray-900 leading-snug line-clamp-1 mb-0.5">${source.title || 'Sumber Web'}</h4>
1222      ${domain ? `<p class="text-[11px] text-gray-400 truncate">🌐 ${domain}</p>` : ''}
1223    </div>
1224    <!-- CTA col -->
1225    <div class="flex-shrink-0 flex items-center pr-3">
1226      <a class="inline-flex items-center gap-1 bg-gradient-to-r from-sky-500 to-blue-500 hover:from-sky-600 hover:to-blue-600 text-white px-2.5 py-1.5 rounded-lg font-semibold text-xs transition-all duration-200 whitespace-nowrap"
1227         href="${source.url || '#'}" target="_blank" rel="noopener">
1228        Buka
1229        <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
1230          <path stroke-linecap="round" stroke-linejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>
1231        </svg>
1232      </a>
1233    </div>`;
1234
1235  // Tandai bagiannya seperti kartu hasil biasa, supaya CSS mode grid ikut
1236  // menatanya. Tanpa ini kartu Sumber Web tetap memakai tata letak list dan
1237  // tampil beda sendiri di antara kartu lain saat mode grid aktif.
1238  if (typeof window.tagCardParts === 'function') window.tagCardParts(card);
1239
1240  return card;
1241}
1242
1243/**
1244 * Apply highlight visual ke kartu yang relevan dengan jawaban RAG
1245 */
1246function _applyRAGHighlight(card) {
1247  // Hindari highlight ganda
1248  if (card.dataset.ragHighlighted === 'true') return;
1249  card.dataset.ragHighlighted = 'true';
1250
1251  // Simpan class asli
1252  card.dataset.originalClasses = card.className;
1253
1254  // Tambah ring glow
1255  card.classList.add('ring-2', 'ring-blue-400/50', 'shadow-blue-100', 'shadow-lg');
1256
1257  // Inject label "Terkait jawaban DSH" ke dalam kolom teks card (bukan di root card)
1258  const label = document.createElement('div');
1259  label.className = 'rag-relevance-label mb-1';
1260  label.innerHTML = `
1261    <button type="button" onclick="_scrollToRAGPanel(this)"
1262       class="inline-flex items-center gap-1 px-2 py-0.5 text-[10px] font-semibold bg-blue-100 text-blue-700 border border-blue-200 rounded-full animate-fade-in hover:bg-blue-200 hover:border-blue-400 transition-colors cursor-pointer"
1263       title="Klik untuk melihat jawaban AI di atas">
1264      <svg class="w-3 h-3 shrink-0" fill="currentColor" viewBox="0 0 20 20">
1265        <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
1266      </svg>
1267      <span>Terkait jawaban DSH</span>
1268      <svg class="w-2.5 h-2.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
1269        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7"/>
1270      </svg>
1271    </button>`;
1272
1273  // Pada row-based card, kolom teks adalah div.flex-col.min-w-0 (bukan root)
1274  // Cari text column: flex-col yang mengandung h4
1275  const textCol = card.querySelector('div.flex-col') || card.querySelector('div[class*="min-w-0"]');
1276  if (textCol) {
1277    // Sisipkan label sebelum elemen pertama text column
1278    textCol.insertBefore(label, textCol.firstChild);
1279  } else {
1280    // Fallback: cari elemen pertama non-absolute di root
1281    const absoluteEls = Array.from(card.children).filter(el =>
1282      el.classList.contains('absolute') || getComputedStyle(el).position === 'absolute'
1283    );
1284    const firstFlowEl = Array.from(card.children).find(el => !absoluteEls.includes(el));
1285    if (firstFlowEl) card.insertBefore(label, firstFlowEl);
1286    else card.appendChild(label);
1287  }
1288}
1289
1290/**
1291 * Scroll ke RAG panel dan pulsekan border-nya — dipanggil dari label kartu
1292 */
1293function _scrollToRAGPanel(triggerEl) {
1294  const wrapper = document.getElementById('dshRAGPanelWrapper');
1295  if (!wrapper) return;
1296
1297  wrapper.scrollIntoView({ behavior: 'smooth', block: 'start' });
1298
1299  // Pulsekan border panel supaya user tahu
1300  const panel = document.getElementById('dshRAGPanel');
1301  if (!panel) return;
1302  panel.classList.add('ring-2', 'ring-blue-400', 'ring-offset-2', 'transition-all');
1303  setTimeout(() => panel.classList.remove('ring-2', 'ring-blue-400', 'ring-offset-2'), 1400);
1304
1305  // Visual feedback di label yang diklik
1306  if (triggerEl) {
1307    triggerEl.classList.add('bg-blue-600', 'text-white', 'border-blue-600');
1308    setTimeout(() => triggerEl.classList.remove('bg-blue-600', 'text-white', 'border-blue-600'), 800);
1309  }
1310}
1311
1312/**
1313 * Hapus highlight RAG dari kartu
1314 */
1315function _removeRAGHighlight(card) {
1316  if (card.dataset.ragHighlighted !== 'true') return;
1317  card.dataset.ragHighlighted = 'false';
1318
1319  card.classList.remove('ring-2', 'ring-blue-400/50', 'shadow-blue-100', 'shadow-lg');
1320
1321  const label = card.querySelector('.rag-relevance-label');
1322  if (label) label.remove();
1323}
1324
1325/**
1326 * Bersihkan semua integrasi RAG dari grid (saat RAG panel ditutup)
1327 */
1328function clearRAGCardIntegration() {
1329  const grid = document.getElementById('servicesGrid');
1330  if (!grid) return;
1331
1332  // Hapus web cards yang diinject
1333  grid.querySelectorAll('.rag-web-card').forEach(c => c.remove());
1334
1335  // Hapus divider
1336  grid.querySelectorAll('.rag-relevance-divider').forEach(d => d.remove());
1337
1338  // Hapus semua highlight
1339  grid.querySelectorAll('[data-rag-highlighted="true"]').forEach(card => {
1340    _removeRAGHighlight(card);
1341  });
1342
1343  // Reset state
1344  _aiState.ragSources      = [];
1345  _aiState.ragWebSources   = [];
1346  _aiState.ragLocalSources = [];
1347}
1348
1349/**
1350 * Update jumlah hasil pencarian jika ada web cards yang ditambahkan
1351 */
1352function _updateResultCountWithWeb(webCount) {
1353  const info = document.getElementById('searchResultInfo');
1354  if (!info) return;
1355  const html = info.innerHTML || '';
1356  // Match pattern: <strong>N hasil</strong>
1357  const match = html.match(/<strong>(\d[\d.,]*)\s*hasil<\/strong>/i);
1358  if (match) {
1359    const oldCount = parseInt(match[1].replace(/[.,]/g, ''));
1360    const newCount = oldCount + webCount;
1361    info.innerHTML = html.replace(
1362      /<strong>\d[\d.,]*\s*hasil<\/strong>/i,
1363      `<strong>${newCount.toLocaleString('id-ID')} hasil</strong>`
1364    );
1365  } else if (!html && webCount > 0) {
1366    // Jika belum ada info result (misalnya 0 hasil lokal), buat baru
1367    info.innerHTML = `🎯 Ditemukan <strong>${webCount} hasil dari web</strong>`;
1368    info.classList.remove('hidden');
1369  }
1370}
1371
1372// ---------------------------------------------------------------------------
1373// 6. PUBLIC API — dipanggil dari dsh-search.js & dsh-cards.js
1374// ---------------------------------------------------------------------------
1375
1376/**
1377 * onSearchComplete — dipanggil setelah renderCards() selesai
1378 * Trigger AI summary injection + RAG panel (jika query = pertanyaan)
1379 */
1380async function onSearchComplete(query, results, types) {
1381  if (!query) {
1382    hideRAGPanel();
1383    return;
1384  }
1385
1386  // Sembunyikan typing hint — akan digantikan oleh RAG panel / AI summary
1387  hideAITypingHint();
1388  clearTimeout(window._aiTypingDebounce);
1389
1390  // Jika tidak ada hasil lokal DAN bukan pertanyaan, sembunyikan RAG panel
1391  if (results.length === 0 && !isQuestionQuery(query)) {
1392    hideRAGPanel();
1393    return;
1394  }
1395
1396  // Jalankan keduanya secara paralel
1397  const tasks = [];
1398
1399  // AI Summary hanya jika ada hasil
1400  if (results.length > 0) {
1401    tasks.push(injectAISummaryIntoPanel(query, results, types));
1402  }
1403
1404  if (isQuestionQuery(query)) {
1405    tasks.push(showRAGPanel(query, types));
1406  } else {
1407    hideRAGPanel();
1408    // Soft intent suggestion (non-blocking)
1409    detectQueryIntent(query).then(intent => {
1410      if (intent) applyIntentToTabs(intent);
1411    });
1412  }
1413
1414  await Promise.allSettled(tasks);
1415}
1416
1417/**
1418 * getRewrittenQuery — dipanggil dari dsh-search.js sebelum API call
1419 * Return expanded query string atau query asli jika rewrite gagal
1420 */
1421async function getRewrittenQuery(query) {
1422  const rewritten = await rewriteQuery(query);
1423  if (rewritten?.expanded && rewritten.expanded !== query) {
1424    console.debug('[DSH AI] Query rewritten:', query, '→', rewritten.expanded);
1425    return rewritten.expanded;
1426  }
1427  return query;
1428}
1429
1430// ---------------------------------------------------------------------------
1431// 7. AI TYPING HINT — Aktif saat user mengetik di search input
1432// ---------------------------------------------------------------------------
1433
1434/**
1435 * State untuk typing hint — simpan query terakhir agar tidak re-fetch
1436 * jika user kembali ke query yang sama.
1437 */
1438const _hintCache = new Map(); // query → { emoji, msg, badge }
1439
1440/**
1441 * triggerAITypingHint — dipanggil dari index.php setelah debounce 600ms
1442 *
1443 * Alur:
1444 * 1. Cek apakah query adalah pertanyaan (isQuestionQuery) → tampilkan hint "AI siap jawab"
1445 * 2. Jika bukan pertanyaan, deteksi intent (detect_intent API, ringan)
1446 *    → tampilkan hint tipe konten yang cocok
1447 * 3. Gunakan cache agar tidak re-fetch untuk query yang sama
1448 *
1449 * @param {string} query — nilai searchInput.value.trim() saat ini
1450 */
1451async function triggerAITypingHint(query) {
1452  if (!query || query.length < 3) { hideAITypingHint(); return; }
1453
1454  const bar     = document.getElementById('aiTypingHint');
1455  const emojiEl = document.getElementById('aiTypingHintEmoji');
1456  const msgEl   = document.getElementById('aiTypingHintMsg');
1457  const badgeEl = document.getElementById('aiTypingHintBadge');
1458  if (!bar || !msgEl) return;
1459
1460  // Tampilkan skeleton "thinking" segera
1461  bar.classList.remove('hidden');
1462  if (emojiEl) emojiEl.textContent = '✨';
1463  msgEl.innerHTML = '<span class="animate-pulse text-white/50">DSH AI menganalisis…</span>';
1464  if (badgeEl) badgeEl.classList.add('hidden');
1465
1466  // Gunakan cache jika ada
1467  if (_hintCache.has(query)) {
1468    _applyHintResult(bar, emojiEl, msgEl, badgeEl, _hintCache.get(query));
1469    return;
1470  }
1471
1472  let result = null;
1473
1474  // --- Kasus 1: Query berupa pertanyaan → hint "AI siap jawab" ---
1475  if (isQuestionQuery(query)) {
1476    result = {
1477      emoji: '🤖',
1478      msg:   'Pertanyaan terdeteksi — <strong>AI DSH siap memberikan jawaban langsung</strong>
1478 setelah Anda klik Cari.',
1479      badge: 'RAG Answer',
1480    };
1481  } else {
1482    // --- Kasus 2: Bukan pertanyaan → deteksi intent tipe konten ---
1483    try {
1484      const intent = await detectQueryIntent(query);
1485      if (intent && intent.primary && intent.primary !== 'general' && (intent.confidence ?? 0) >= 0.65) {
1486        const typeConfig = {
1487          service:     { emoji: '🔧', label: 'Layanan Digital' },
1488          news:        { emoji: '📰', label: 'Berita UGM' },
1489          product:     { emoji: '🛒', label: 'Produk/MOOC' },
1490          people:      { emoji: '👨‍🏫', label: 'Dosen / Staff' },
1491          publication: { emoji: '📚', label: 'Publikasi Ilmiah' },
1492          patent:      { emoji: '💡', label: 'HKI / Paten' },
1493          legal:       { emoji: '⚖️', label: 'Produk Hukum' },
1494          pidato:      { emoji: '🎓', label: 'Pidato / Laporan Rektor' },
1495        };
1496        const cfg = typeConfig[intent.primary] || { emoji: '🔍', label: intent.primary };
1497        const pct = Math.round((intent.confidence ?? 0) * 100);
1498        result = {
1499          emoji: cfg.emoji,
1500          msg:   `Terdeteksi sebagai pencarian <strong>${cfg.label}</strong> — hasil terbaik ada di kategori ini.`,
1501          badge: `${pct}% yakin`,
1502        };
1503      } else if (intent && intent.is_question) {
1504        // intent.is_question true meski primary = general
1505        result = {
1506          emoji: '🤖',
1507          msg:   'Sepertinya Anda memiliki pertanyaan — <strong>AI DSH siap membantu</strong> setelah Anda klik Cari.',
1508          badge: 'RAG Answer',
1509        };
1510      } else {
1511        // Query biasa tanpa intent kuat
1512        const wordCount = query.trim().split(/\s+/).length;
1513        if (wordCount >= 3) {
1514          result = {
1515            emoji: '🔍',
1516            msg:   'Pencarian multi-kata terdeteksi — AI akan <strong>memperluas kata kunci</strong> untuk hasil lebih lengkap.',
1517            badge: 'Query Rewrite',
1518          };
1519        } else {
1520          // Query pendek — sembunyikan hint, tidak ada info berguna
1521          hideAITypingHint();
1522          return;
1523        }
1524      }
1525    } catch (_) {
1526      // Intent API gagal — sembunyikan hint daripada tampilkan error
1527      hideAITypingHint();
1528      return;
1529    }
1530  }
1531
1532  // Simpan ke cache (max 30 entry)
1533  if (_hintCache.size >= 30) _hintCache.delete(_hintCache.keys().next().value);
1534  _hintCache.set(query, result);
1535
1536  _applyHintResult(bar, emojiEl, msgEl, badgeEl, result);
1537}
1538
1539/** Terapkan data hint ke elemen DOM */
1540function _applyHintResult(bar, emojiEl, msgEl, badgeEl, result) {
1541  if (!result) { hideAITypingHint(); return; }
1542  bar.classList.remove('hidden');
1543  if (emojiEl) emojiEl.textContent = result.emoji;
1544  if (msgEl)   msgEl.innerHTML     = result.msg;
1545  if (badgeEl) {
1546    if (result.badge) {
1547      badgeEl.textContent = result.badge;
1548      badgeEl.classList.remove('hidden');
1549    } else {
1550      badgeEl.classList.add('hidden');
1551    }
1552  }
1553}
1554
1555/**
1556 * hideAITypingHint — sembunyikan bar dan bersihkan cache query sementara
1557 */
1558function hideAITypingHint() {
1559  const bar = document.getElementById('aiTypingHint');
1560  if (bar) bar.classList.add('hidden');
1561}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.