const $ = (s, r = document) => r.querySelector(s); const view = $('#app'); const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); const num = n => Number(n || 0).toLocaleString('id-ID'); const pct = n => Number(n || 0).toLocaleString('id-ID', { maximumFractionDigits: 1 }) + '%'; const tgl = iso => { if (!iso) return '-'; try { return new Date(iso).toLocaleString('id-ID', { dateStyle: 'medium', timeStyle: 'short' }); } catch (e) { return String(iso); } }; function pager(current, total) { current = Number(current) || 1; total = Number(total) || 1; if (total <= 1) return ''; let html = ''; if (current > 1) { html += ``; } const delta = 2; const start = Math.max(1, current - delta); const end = Math.min(total, current + delta); if (start > 1) { html += ``; if (start > 2) html += `...`; } for (let p = start; p <= end; p++) { html += ``; } if (end < total) { if (end < total - 1) html += `...`; html += ``; } if (current < total) { html += ``; } html += `Hal. ${current} / ${total}`; return html; } let user = null; let poll = null; // ========================================== // API HELPER // ========================================== async function api(path, { method = 'GET', body } = {}) { const isPredict = path === '/predict'; const url = isPredict ? '/predict' : '/api' + path; const res = await fetch(url, { method, credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : undefined, }); const data = await res.json().catch(() => ({})); if (!res.ok) { if (res.status === 401 && !path.startsWith('/log') && !path.startsWith('/reg')) { user = null; location.hash = '#/login'; } throw new Error(data.message || 'Terjadi kesalahan sistem.'); } return data; } // Periksa status database saat startup async function updateDbStatus() { try { const res = await api('/db-status'); const badge = $('#dbStatusText'); const pill = $('#dbStatusBadge'); if (badge && pill) { badge.textContent = res.info || 'Database Aktif'; pill.title = `Engine: ${res.engine} | Host: ${res.host} | DB: ${res.database}`; } } catch (e) { // Abaikan jika offline } } // ========================================== // ROUTER // ========================================== async function route() { clearInterval(poll); if (user === null) { try { user = (await api('/me')).user; } catch { user = false; } } const topbar = $('#topbar'); if (topbar) topbar.hidden = !user; const h = location.hash || '#/'; const authPage = h === '#/login' || h === '#/register'; if (!user && !authPage) { location.hash = '#/login'; return; } if (user && authPage) { location.hash = '#/'; return; } if (user) { const who = $('#whoami'); if (who) who.textContent = user.name || user.email; const av = $('#userAvatar'); if (av) { const initial = (user.name || user.email || 'A').charAt(0).toUpperCase(); av.textContent = initial; } updateDbStatus(); } else { const who = $('#whoami'); if (who) who.textContent = ''; const av = $('#userAvatar'); if (av) av.textContent = '-'; } // Update visibilitas menu admin di navbar const navEval = $('#navEvaluation'); if (navEval) { navEval.style.display = (user && (user.is_admin || user.email === 'admin@tiktok.com')) ? 'inline-flex' : 'none'; } // Update active state di navbar document.querySelectorAll('[data-nav]').forEach(a => { const navKey = a.dataset.nav; let isActive = false; if (navKey === 'dashboard' && h === '#/') isActive = true; else if (navKey === 'playground' && h === '#/playground') isActive = true; else if (navKey === 'history' && (h === '#/history' || h.startsWith('#/analysis/'))) isActive = true; else if (navKey === 'evaluation' && (h === '#/evaluation' || h === '#/model-test')) isActive = true; a.classList.toggle('on', isActive); }); const m = h.match(/^#\/analysis\/(\d+)$/); if (h === '#/login') return authView('login'); if (h === '#/register') return authView('register'); if (h === '#/playground') return playgroundView(); if (h === '#/history') return historyView(); if (h === '#/evaluation' || h === '#/model-test') return evaluationView(); if (m) return detailView(+m[1]); return dashboardView(); } window.addEventListener('hashchange', route); $('#btnLogout').onclick = async () => { try { await api('/logout', { method: 'POST' }); } catch (e) {} user = false; const topbar = $('#topbar'); if (topbar) topbar.hidden = true; const who = $('#whoami'); if (who) who.textContent = ''; const av = $('#userAvatar'); if (av) av.textContent = '-'; if (location.hash === '#/login') { route(); } else { location.hash = '#/login'; } }; // ========================================== // 1. AUTH VIEW (LOGIN & REGISTER) // ========================================== function authView(mode) { const reg = mode === 'register'; view.innerHTML = `

${reg ? 'Buat Akun Baru' : 'Selamat Datang'}

${reg ? 'Daftar untuk menyimpan riwayat analisis sentimen Anda.' : 'Masuk untuk mulai menganalisis komentar TikTok.'}

${reg ? ` ` : ''} ${reg ? ` ` : ''}
${!reg ? `
Akun Default: admin@tiktok.com (pw: admin123)
` : ''}

${reg ? 'Sudah memiliki akun? Masuk di sini' : 'Belum memiliki akun? Daftar sekarang'}

`; if (!reg) { $('#btnFillDemo').onclick = () => { $('#email').value = 'admin@tiktok.com'; $('#pw').value = 'admin123'; $('#authMsg').textContent = ''; }; } $('#fAuth').onsubmit = async e => { e.preventDefault(); const btn = $('#btnAuthSubmit'); const msg = $('#authMsg'); msg.textContent = ''; btn.disabled = true; const body = { email: $('#email').value.trim(), password: $('#pw').value }; if (reg) { body.name = $('#name').value.trim(); body.password_confirm = $('#pw2').value; } try { const res = await api(reg ? '/register' : '/login', { method: 'POST', body }); user = res.user; location.hash = '#/'; route(); } catch (err) { msg.textContent = err.message; btn.disabled = false; } }; } // ========================================== // 2. DASHBOARD VIEW // ========================================== async function dashboardView() { view.innerHTML = `

Dashboard Analisis

Ringkasan statistik data dan klasifikasi komentar TikTok berbasis Machine Learning.

🔬 Uji Cepat Kata / Kalimat
Total Analisis
📊
0
Total Komentar
💬
0
Sentimen Positif
👍
0
Sentimen Negatif
👎
0

Mulai Analisis Sentimen Baru

Web Scraping Otomatis: Komentar Asli Ditarik Langsung dari TikTok
komentar
Opsi Cepat:

Analisis Terbaru

Lihat Semua
`; // Logika Switch Tab Input let activeTab = 'tab-url'; const tabs = document.querySelectorAll('#analysisTabs .tab-btn'); const panelUrl = $('#panelUrl'); const panelManual = $('#panelManual'); tabs.forEach(tab => { tab.onclick = () => { tabs.forEach(t => t.classList.remove('active')); tab.classList.add('active'); activeTab = tab.dataset.tab; if (activeTab === 'tab-url') { panelUrl.style.display = 'block'; panelManual.style.display = 'none'; $('#btnStartAnalysis span').textContent = 'Mulai Analisis Video'; } else { panelUrl.style.display = 'none'; panelManual.style.display = 'block'; $('#btnStartAnalysis span').textContent = 'Analisis Komentar Mandiri'; } $('#analysisMsg').textContent = ''; }; }); // Target Komentar Preset & Input Bebas document.querySelectorAll('.target-preset').forEach(btn => { btn.onclick = () => { document.querySelectorAll('.target-preset').forEach(b => b.classList.remove('active')); btn.classList.add('active'); const input = $('#targetComments'); if (input) input.value = btn.dataset.val; }; }); $('#targetComments')?.addEventListener('input', e => { const v = e.target.value; document.querySelectorAll('.target-preset').forEach(b => { b.classList.toggle('active', b.dataset.val === v); }); }); // Live counter pada manual textarea const manualInput = $('#manualInput'); const lineCounter = $('#lineCounter'); const updateLineCounter = () => { const text = manualInput.value.trim(); if (!text) { lineCounter.textContent = '0 komentar terdeteksi'; return; } const lines = text.split('\n').filter(l => l.trim().length > 0); lineCounter.textContent = `${lines.length} komentar terdeteksi`; }; manualInput.oninput = updateLineCounter; // Preset Buttons untuk Input Mandiri $('#btnPresetPos').onclick = () => { manualInput.value = 'cantik banget kak videonya, sangat bermanfaat dan mengedukasi!\nmantap betul ilmunya, makasih banyak ya sukses selalu kak'; updateLineCounter(); }; $('#btnPresetNeg').onclick = () => { manualInput.value = 'jelek banget penjelasannya ga jelas muter-muter bikin pusing\nkonten ga bermutu buang-buang kuota doang nontonnya'; updateLineCounter(); }; $('#btnPresetAlay').onclick = () => { manualInput.value = 'bgt bgt parahh beneran wkwk, tp tetep oke kok makasih kk\ngak bener ini infonya hoax bgt parah sih'; updateLineCounter(); }; // Load Data Statistik & Riwayat Terbaru try { const [stats, analyses] = await Promise.all([api('/stats'), api('/analyses')]); // Render Stats $('#statsGrid').innerHTML = `
Total Analisis
📊
${num(stats.total_analyses)}
Total Komentar
💬
${num(stats.total_comments)}
Sentimen Positif
👍
${num(stats.positive)}
Sentimen Negatif
👎
${num(stats.negative)}
`; // Render Recent const recents = analyses.data || []; $('#recentList').innerHTML = recents.length ? recents.slice(0, 5).map(a => `
${esc(shortUrl(a))}
${tgl(a.created_at)}
${pct(a.positive_percentage)} / ${pct(a.negative_percentage)}
${a.negative_count > a.positive_count ? '🚨 Cyberbullying' : (a.positive_count > a.negative_count ? '✅ Aman' : '⚖️ Seimbang')}
${num(a.total_comments)} komen
`).join('') : '
Belum ada riwayat analisis. Silakan masukkan data di samping untuk memulai.
'; } catch (e) { // 401 ditangani otomatis oleh api() } // Handle Submit Form $('#fAnalysis').onsubmit = async e => { e.preventDefault(); const btn = $('#btnStartAnalysis'); const msg = $('#analysisMsg'); msg.textContent = ''; btn.disabled = true; let tiktok_url = ''; let manual_comments = ''; const customTargetVal = parseInt($('#targetComments')?.value, 10); const max_comments = (!isNaN(customTargetVal) && customTargetVal > 0) ? customTargetVal : 100; if (activeTab === 'tab-url') { tiktok_url = ($('#url')?.value || '').trim(); manual_comments = ''; if (!tiktok_url) { msg.textContent = 'Harap isi link video TikTok terlebih dahulu.'; btn.disabled = false; return; } btn.innerHTML = `
Sedang men-scrape ${max_comments} komentar asli TikTok & menganalisis sentimen... `; } else { // Tab Input Mandiri manual_comments = $('#manualInput').value.trim(); if (!manual_comments) { msg.textContent = 'Ketik minimal satu komentar pada kotak teks di atas.'; btn.disabled = false; return; } btn.innerHTML = `
Sedang memproses ${lineCounter.textContent}... `; } try { const res = await api('/analyses', { method: 'POST', body: { tiktok_url, manual_comments, max_comments } }); location.hash = '#/analysis/' + res.data.id; } catch (err) { msg.textContent = err.message; btn.disabled = false; btn.innerHTML = ` ${activeTab === 'tab-url' ? 'Mulai Analisis & Scraping Video' : 'Analisis Komentar Mandiri'} `; } }; } const shortUrl = a => { if (a.tiktok_url && a.tiktok_url.startsWith('Uji') || a.tiktok_url.startsWith('Input')) { return a.tiktok_url; } return a.video_id ? 'Video TikTok #' + a.video_id : a.tiktok_url.replace(/^https?:\/\/(www\.)?/, ''); }; const statusText = s => s === 'failed' ? 'Gagal' : 'Memproses…'; // ========================================== // 3. LIVE NLP PLAYGROUND (UJI CEPAT KATA/KALIMAT) // ========================================== function playgroundView() { view.innerHTML = `

Uji Cepat Sentimen (NLP Playground)

Uji kata atau kalimat langsung untuk melihat proses pembersihan teks (preprocessing), prediksi sentimen, dan bobot fitur TF-IDF secara instan.

Masukkan Teks Komentar

Uji dengan Contoh Cepat:

Hasil Analisis Inferensi

Ketik teks di sebelah kiri lalu klik tombol analisis untuk melihat hasil pembacaan model.
`; // Quick Preset Click document.querySelectorAll('[data-test]').forEach(btn => { btn.onclick = () => { $('#playText').value = btn.dataset.test; $('#fPlayground').dispatchEvent(new Event('submit')); }; }); // Handle Predict Submit $('#fPlayground').onsubmit = async e => { e.preventDefault(); const text = $('#playText').value.trim(); const msg = $('#playMsg'); const btn = $('#btnRunPredict'); msg.textContent = ''; if (!text) { msg.textContent = 'Harap ketik kata atau kalimat terlebih dahulu.'; return; } btn.disabled = true; try { const res = await api('/predict', { method: 'POST', body: { komentar: text } }); $('#playPlaceholder').style.display = 'none'; $('#playResultContent').style.display = 'block'; // Badge Verdict const badge = $('#resBadge'); badge.textContent = res.sentimen; const sub = $('#resConclusionSub'); if (res.sentimen === 'GAGAL DIANALISIS') { if (sub) sub.style.display = 'none'; badge.className = 'verdict-badge GAGAL'; $('#resConfidence').textContent = '0.0% (Gagal Dianalisis)'; $('#barPos').style.width = '0%'; $('#barNeg').style.width = '0%'; $('#lblPos').textContent = '0%'; $('#lblNeg').textContent = '0%'; $('#resCleaned').innerHTML = '⚠️ (Hasil preprocessing kosong - teks hanya berisi emotikon, angka, simbol, atau kata henti tanpa makna sentimen)'; } else if (res.sentimen === 'FITUR TIDAK DITEMUKAN' || res.fitur_ditemukan === false) { if (sub) sub.style.display = 'none'; badge.className = 'verdict-badge FITUR'; $('#resConfidence').textContent = '0.0% (Fitur Tidak Ditemukan)'; $('#barPos').style.width = '0%'; $('#barNeg').style.width = '0%'; $('#lblPos').textContent = '0%'; $('#lblNeg').textContent = '0%'; $('#resCleaned').textContent = res.preprocessing || '-'; } else { badge.className = 'verdict-badge ' + res.sentimen; $('#resConfidence').textContent = pct(res.keyakinan); const posPct = res.probabilitas.positif * 100; const negPct = res.probabilitas.negatif * 100; if (sub) { sub.style.display = 'inline-flex'; if (res.sentimen === 'NEGATIF') { sub.className = 'play-conclusion-sub neg'; sub.innerHTML = '🚨 Kesimpulan: Terindikasi Cyberbullying'; } else { sub.className = 'play-conclusion-sub pos'; sub.innerHTML = '✅ Kesimpulan: Tidak Terindikasi Cyberbullying'; } } $('#barPos').style.width = posPct + '%'; $('#barNeg').style.width = negPct + '%'; $('#lblPos').textContent = pct(posPct); $('#lblNeg').textContent = pct(negPct); $('#resCleaned').textContent = res.preprocessing || '(Kosong / emote & simbol terhapus)'; } // Fitur Dominan const featsWrap = $('#resFeatures'); if (res.sentimen === 'GAGAL DIANALISIS') { featsWrap.innerHTML = '⚠️ Tidak ada fitur kata yang tersisa untuk dihitung TF-IDF karena seluruh token terhapus pada tahap preprocessing.'; } else if (res.sentimen === 'FITUR TIDAK DITEMUKAN' || res.fitur_ditemukan === false || (!res.fitur_dominan || !res.fitur_dominan.length)) { featsWrap.innerHTML = `
ℹ️
Keterangan: Fitur Tidak Ditemukan
Seluruh kata pada teks ini tidak terdapat dalam kosakata TF-IDF model (Out-of-Vocabulary), sehingga tidak dipaksakan ke sentimen POSITIF/NEGATIF dan ditandai sebagai FITUR TIDAK DITEMUKAN.
`; } else if (res.fitur_dominan && res.fitur_dominan.length) { featsWrap.innerHTML = res.fitur_dominan.map(f => { const isPos = f.weight > 0; return ` ${esc(f.token)} TF-IDF: ${f.tfidf} | W: ${f.weight} `; }).join(''); } else { featsWrap.innerHTML = 'Tidak ada n-gram dominan pada teks ini.'; } $('#resElapsed').textContent = `Waktu Inferensi: ${res.elapsed_ms} ms`; } catch (err) { msg.textContent = err.message; } finally { btn.disabled = false; } }; } // ========================================== // 4. RIWAYAT ANALISIS (HISTORY) // ========================================== async function historyView() { view.innerHTML = `

Riwayat Analisis

Daftar seluruh riwayat video dan input komentar yang pernah dianalisis.

+ Analisis Baru
`; try { const { data } = await api('/analyses'); const list = $('#histList'); if (!data || !data.length) { list.innerHTML = '
Belum ada riwayat analisis tersimpan. Mulai dari Dashboard dengan memasukkan link video atau komentar mandiri.
'; return; } list.innerHTML = '
' + data.map(a => `

${esc(shortUrl(a))}

${tgl(a.created_at)}
${a.status === 'done' || a.status === 'scraped' || a.status === 'manual' ? `
Total Komentar${num(a.total_comments)}
Sentimen Positif${num(a.positive_count)} (${pct(a.positive_percentage)})
Sentimen Negatif${num(a.negative_count)} (${pct(a.negative_percentage)})
${(a.total_comments - a.positive_count - a.negative_count > 0) ? `
Gagal Dianalisis${num(a.total_comments - a.positive_count - a.negative_count)} (${pct((a.total_comments - a.positive_count - a.negative_count) / a.total_comments * 100)})
` : ''}
${(a.total_comments - a.positive_count - a.negative_count > 0) ? ` ` : ''}
Kesimpulan: ${a.negative_count > a.positive_count ? 'Terindikasi Cyberbullying' : (a.positive_count > a.negative_count ? 'Tidak Cyberbullying' : 'Seimbang')}
` : `

${statusText(a.status)} ${esc(a.error || '')}

`}
Lihat Detail
`).join('') + '
'; document.querySelectorAll('[data-del]').forEach(btn => { btn.onclick = async () => { if (!confirm('Apakah Anda yakin ingin menghapus riwayat analisis ini beserta seluruh datanya?')) return; try { await api('/analyses/' + btn.dataset.del, { method: 'DELETE' }); historyView(); } catch (e) { alert(e.message); } }; }); } catch (e) { // Handled } } // ========================================== // 5. DETAIL ANALISIS + PAGINATION + FILTER // ========================================== function donutSvg(p, n) { const C = 2 * Math.PI * 54; const pa = C * (p / 100); const na = C * (n / 100); return ` `; } async function detailView(id) { let a; try { a = (await api('/analyses/' + id)).data; } catch (e) { view.innerHTML = `
${esc(e.message)}
Kembali ke Riwayat
`; return; } const posCount = Number(a.positive_count) || 0; const negCount = Number(a.negative_count) || 0; const totalComments = Number(a.total_comments) || 0; const isBullying = negCount > posCount; const isBalanced = posCount === negCount && totalComments > 0; let verdictStatus = ''; let verdictBadgeClass = ''; let verdictIcon = ''; let dominantSentimen = ''; let dominantPct = 0; let narrativeText = ''; if (totalComments === 0 || (posCount === 0 && negCount === 0)) { verdictStatus = 'TIDAK DAPAT DISIMPULKAN'; verdictBadgeClass = 'neutral'; verdictIcon = '⚠️'; dominantSentimen = '-'; dominantPct = 0; narrativeText = 'Data komentar belum memiliki fitur yang cukup untuk menarik kesimpulan sentimen.'; } else if (isBullying) { verdictStatus = 'TERINDIKASI CYBERBULLYING'; verdictBadgeClass = 'bullying'; verdictIcon = '🚨'; dominantSentimen = 'NEGATIF'; dominantPct = a.negative_percentage; narrativeText = `Berdasarkan hasil analisis sentimen terhadap ${num(totalComments)} komentar pada video TikTok ini menggunakan representasi fitur TF-IDF dan model Logistic Regression, diperoleh sebanyak ${num(negCount)} komentar (${pct(a.negative_percentage)}) bersentimen Negatif dan ${num(posCount)} komentar (${pct(a.positive_percentage)}) bersentimen Positif. Mengingat sentimen negatif lebih dominan dibandingkan sentimen positif (> 50%), maka dapat ditarik kesimpulan bahwa kolom komentar video ini TERINDIKASI CYBERBULLYING.`; } else if (isBalanced) { verdictStatus = 'SEIMBANG (NETRAL)'; verdictBadgeClass = 'neutral'; verdictIcon = '⚖️'; dominantSentimen = 'SEIMBANG'; dominantPct = 50.0; narrativeText = `Berdasarkan hasil analisis sentimen terhadap ${num(totalComments)} komentar pada video TikTok ini, diperoleh proporsi yang seimbang antara sentimen Positif (${num(posCount)} komentar / ${pct(a.positive_percentage)}) dan sentimen Negatif (${num(negCount)} komentar / ${pct(a.negative_percentage)}). Dengan demikian, kolom komentar video ini belum menunjukkan dominasi cyberbullying.`; } else { verdictStatus = 'TIDAK TERINDIKASI CYBERBULLYING'; verdictBadgeClass = 'safe'; verdictIcon = '✅'; dominantSentimen = 'POSITIF'; dominantPct = a.positive_percentage; narrativeText = `Berdasarkan hasil analisis sentimen terhadap ${num(totalComments)} komentar pada video TikTok ini menggunakan representasi fitur TF-IDF dan model Logistic Regression, diperoleh sebanyak ${num(posCount)} komentar (${pct(a.positive_percentage)}) bersentimen Positif dan ${num(negCount)} komentar (${pct(a.negative_percentage)}) bersentimen Negatif. Mengingat sentimen positif lebih dominan dibandingkan sentimen negatif (> 50%), maka dapat ditarik kesimpulan bahwa kolom komentar video ini TIDAK TERINDIKASI CYBERBULLYING (Kondusif dan Bebas Perundungan Siber).`; } view.innerHTML = `
← Kembali ke Riwayat
${donutSvg(a.positive_percentage, a.negative_percentage)}
${num(a.total_comments)} Komentar
${esc(a.tiktok_url)} ${a.status === 'scraped' ? ` 🌐 Scraping Komentar Asli TikTok ` : a.video_id === 'manual' || a.status === 'manual' ? ` 📝 Komentar Mandiri / Upload ` : ` 🧪 Dataset Sampel `}
Sentimen Positif: ${num(a.positive_count)} (${pct(a.positive_percentage)})
Sentimen Negatif: ${num(a.negative_count)} (${pct(a.negative_percentage)})
${(a.total_comments - a.positive_count - a.negative_count > 0) ? `
Gagal Dianalisis: ${num(a.total_comments - a.positive_count - a.negative_count)} (${pct((a.total_comments - a.positive_count - a.negative_count) / a.total_comments * 100)})
` : ''}
Dianalisis pada: ${tgl(a.created_at)}
${a.error ? `
⚠️ Catatan: ${esc(a.error)}
` : ''}
${verdictIcon} KESIMPULAN: ${verdictStatus}
Sentimen Dominan ${dominantSentimen} ${dominantPct ? `(${pct(dominantPct)})` : ''}
Rasio Positif : Negatif ${num(posCount)} Positif : ${num(negCount)} Negatif
Status Kolom Komentar ${verdictStatus}
📝 Ringkasan Penarikan Kesimpulan (Berdasarkan Hasil Analisis):

${narrativeText}

Daftar Komentar (${num(a.total_comments)})

${(a.total_comments - a.positive_count - a.negative_count > 0) ? ` ` : ''}
`; let filter = ''; let page = 1; async function loadTable() { try { const res = await api(`/analyses/${id}/comments?page=${page}&limit=50${filter ? '&sentiment=' + filter : ''}`); const off = (res.page - 1) * res.limit; if (!res.data || !res.data.length) { $('#tblContent').innerHTML = '
Tidak ada komentar yang cocok dengan filter ini.
'; $('#pagerContent').innerHTML = ''; return; } $('#tblContent').innerHTML = ` ${res.data.map((c, i) => ` `).join('')}
# Komentar Asli Hasil Preprocessing Sentimen
${off + i + 1} ${esc(c.original_comment)} ${c.cleaned_comment && c.cleaned_comment.trim() ? `
${esc(c.cleaned_comment)}
${c.keterangan === 'Fitur tidak ditemukan' || c.fitur_ditemukan === false ? `
⚠️ Keterangan: Fitur tidak ditemukan
` : ''} ` : '⚠️ (Preprocessing kosong / hanya simbol atau emotikon)'}
${c.sentiment} ${(c.sentiment === 'GAGAL DIANALISIS' || c.sentiment === 'FITUR TIDAK DITEMUKAN') ? '-' : pct(c.probability * 100)}
`; $('#pagerContent').innerHTML = pager(res.page, res.total_pages); $('#pagerContent').querySelectorAll('[data-p]').forEach(b => { b.onclick = () => { page = +b.dataset.p; loadTable(); $('#tblContent').scrollIntoView({ behavior: 'smooth', block: 'start' }); }; }); } catch (e) { $('#tblContent').innerHTML = `
${esc(e.message)}
`; } } $('#filters').onclick = e => { const btn = e.target.closest('[data-f]'); if (!btn) return; filter = btn.dataset.f; page = 1; document.querySelectorAll('#filters .chip').forEach(b => b.classList.toggle('on', b === btn)); loadTable(); }; loadTable(); // Listener Salin Kesimpulan untuk Skripsi const btnCopy = $('#btnCopyConclusion'); if (btnCopy) { btnCopy.onclick = () => { navigator.clipboard.writeText(narrativeText).then(() => { const txt = $('#copyBtnText'); if (txt) { const old = txt.textContent; txt.textContent = '✅ Berhasil Disalin!'; setTimeout(() => { txt.textContent = old; }, 2500); } }).catch(() => { alert('Gagal menyalin otomatis. Silakan salin teks secara manual.'); }); }; } } // ========================================== // 6. PENGUJIAN MODEL & CONFUSION MATRIX (ADMIN) // ========================================== // ========================================== // 6. PENGUJIAN MODEL & CONFUSION MATRIX (ADMIN) // ========================================== let evalCachedResult = null; let currentScrapedComments = []; let currentVideoSourceTitle = ""; function downloadEvaluationResultsCsv(details, summary) { let csv = "No,Komentar Asli,Hasil Preprocessing,Label Sebenarnya,Prediksi Model,Status,Kategori Matriks,Keyakinan (%)\n"; details.forEach(d => { const orig = '"' + d.original_comment.replace(/"/g, '""') + '"'; const clean = '"' + (d.cleaned_comment || '').replace(/"/g, '""') + '"'; const status = d.is_correct ? 'BENAR' : 'SALAH'; csv += `${d.index},${orig},${clean},${d.actual_label},${d.predicted_label},${status},${d.matrix_type},${d.confidence}%\n`; }); const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `laporan_pengujian_confusion_matrix_${Date.now()}.csv`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } async function evaluationView() { view.innerHTML = `
Admin Model Testing & Evaluation

Pengujian Model (Confusion Matrix)

Uji akurasi, presisi, recall, dan matriks kebingungan (Confusion Matrix) model Logistic Regression & TF-IDF langsung menggunakan tautan video TikTok.

Algoritma: Logistic Regression Fitur: TF-IDF Sumber: Link Video TikTok

Masukkan Link Video TikTok untuk Pengujian

Sistem akan mengambil komentar nyata dari video TikTok secara otomatis, menjalankan pra-pemrosesan NLP dan prediksi sentimen, lalu mengevaluasi Confusion Matrix.

komentar
Opsi Cepat:

Menunggu Input Video TikTok

Masukkan tautan video TikTok di atas untuk menarik komentar nyata dan mengevaluasi matriks kebingungan (Confusion Matrix) model secara otomatis.

`; // --- POPULATE RECENT VIDEOS DROPDOWN --- try { const recentRes = await api('/model-test/analyses-list'); const list = recentRes.data || []; const select = $('#evalSelectRecent'); if (list.length && select) { list.forEach(a => { const opt = document.createElement('option'); opt.value = a.id; const shortTitle = a.tiktok_url.length > 36 ? a.tiktok_url.substring(0, 36) + '...' : a.tiktok_url; opt.textContent = `${shortTitle} (${a.total_comments} komen)`; opt.dataset.url = a.tiktok_url; opt.dataset.total = a.total_comments; opt.dataset.videoId = a.video_id || ''; select.appendChild(opt); }); select.onchange = () => { if (select.value) { const opt = select.selectedOptions[0]; if (opt && opt.dataset.url) { $('#evalTiktokUrl').value = opt.dataset.url; } } }; $('#evalTiktokUrl').oninput = () => { if (select) select.value = ''; }; } } catch (e) {} // Preset & Input Bebas Target Komentar Pengujian document.querySelectorAll('.eval-target-preset').forEach(btn => { btn.onclick = () => { document.querySelectorAll('.eval-target-preset').forEach(b => b.classList.remove('active')); btn.classList.add('active'); const input = $('#evalTargetComments'); if (input) input.value = btn.dataset.val; }; }); $('#evalTargetComments')?.addEventListener('input', e => { const v = e.target.value; document.querySelectorAll('.eval-target-preset').forEach(b => { b.classList.toggle('active', b.dataset.val === v); }); }); // --- SUBMIT TIKTOK URL FOR SCRAPING & PREDICTION --- $('#fEvalTiktok').onsubmit = async e => { e.preventDefault(); const url = $('#evalTiktokUrl').value.trim(); const msg = $('#evalTiktokMsg'); const btn = $('#btnScrapeAndTest'); const select = $('#evalSelectRecent'); msg.textContent = ''; msg.style.display = 'none'; if (!url) { msg.textContent = 'Harap masukkan tautan video TikTok.'; msg.style.display = 'block'; return; } const customTargetVal = parseInt($('#evalTargetComments')?.value, 10); const maxComments = (!isNaN(customTargetVal) && customTargetVal > 0) ? customTargetVal : 100; btn.disabled = true; btn.innerHTML = `
Sedang mengambil ${maxComments} komentar asli TikTok & menjalankan prediksi NLP... `; try { let res; // Jika dipilih dari analisis database yang sama dan jumlah komentar database mencukupi target const selectedDbTotal = +(select?.selectedOptions[0]?.dataset?.total || 0); if (select && select.value && select.selectedOptions[0]?.dataset.url === url && selectedDbTotal >= maxComments) { res = await api(`/model-test/load-analysis/${select.value}`); } else { // Scrape langsung ke TikTok untuk mendapatkan jumlah komentar sesuai target (misal: 100) res = await api('/model-test/scrape-tiktok', { method: 'POST', body: { tiktok_url: url, max_comments: maxComments } }); } currentScrapedComments = res.comments || []; const author = res.meta?.author ? `@${res.meta.author}` : ''; currentVideoSourceTitle = `${url} ${author ? '(' + author + ')' : ''}`; renderVerificationTable(res); $('#evalVerificationSection').style.display = 'block'; $('#evalVerificationSection').scrollIntoView({ behavior: 'smooth', block: 'start' }); } catch (err) { msg.textContent = err.message; msg.style.display = 'block'; } finally { btn.disabled = false; btn.innerHTML = ` Tarik Komentar TikTok & Mulai Pengujian `; } }; function renderVerificationTable(res) { $('#scrapedCountBadge').textContent = `${currentScrapedComments.length} Komentar Berhasil Di-scrape`; const title = res.meta?.title || currentVideoSourceTitle; $('#scrapedVideoTitle').textContent = title; const tbody = $('#verifyTableBody'); tbody.innerHTML = currentScrapedComments.map((c, idx) => ` ${idx + 1} ${esc(c.original_comment)} ${c.cleaned_comment && c.cleaned_comment.trim() ? `
${esc(c.cleaned_comment)}
${c.keterangan === 'Fitur tidak ditemukan' || c.fitur_ditemukan === false ? `
⚠️ Keterangan: Fitur tidak ditemukan
` : ''} ` : '⚠️ (Preprocessing kosong)'} ${c.predicted_label}
${(c.predicted_label === 'GAGAL DIANALISIS' || c.predicted_label === 'FITUR TIDAK DITEMUKAN') ? '-' : c.confidence + '%'}
`).join(''); // Listener saat Admin mengubah ground truth tbody.querySelectorAll('.gt-select').forEach(sel => { sel.onchange = () => { const idx = +sel.dataset.idx; currentScrapedComments[idx].actual_label = sel.value; }; }); // Reset button $('#btnResetGroundTruth').onclick = () => { currentScrapedComments.forEach(c => { c.actual_label = c.predicted_label; }); tbody.querySelectorAll('.gt-select').forEach(sel => { const idx = +sel.dataset.idx; sel.value = currentScrapedComments[idx].predicted_label; }); }; // Trigger hitung confusion matrix $('#btnCalculateMatrix').onclick = async () => { const btn = $('#btnCalculateMatrix'); btn.disabled = true; btn.innerHTML = `
Menghitung...`; try { const payload = currentScrapedComments.map(c => ({ comment: c.original_comment, label: c.actual_label })); const evalRes = await api('/model-test/evaluate', { method: 'POST', body: { dataset: payload } }); evalCachedResult = evalRes; $('#evalPlaceholderCard').style.display = 'none'; $('#evalActiveContent').style.display = 'block'; renderEvaluationResults(evalRes, currentVideoSourceTitle); $('#evalResultSection').scrollIntoView({ behavior: 'smooth', block: 'start' }); } catch (err) { alert('Gagal mengevaluasi model: ' + err.message); } finally { btn.disabled = false; btn.innerHTML = ` Hitung Confusion Matrix `; } }; } // Jika sebelumnya sudah ada hasil yang tersimpan, tampilkan langsung if (evalCachedResult) { $('#evalPlaceholderCard').style.display = 'none'; $('#evalActiveContent').style.display = 'block'; renderEvaluationResults(evalCachedResult, currentVideoSourceTitle || 'Pengujian Terakhir'); } } // ========================================== // RENDER EVALUATION RESULTS & CONFUSION MATRIX // ========================================== function renderEvaluationResults(res, sourceTitle) { const activeBox = $('#evalActiveContent'); if (!activeBox) return; const s = res?.summary || {}; const cm = res?.confusion_matrix || {}; const rep = res?.classification_report || {}; const details = res?.details || []; const pctObj = cm.percentages || {}; const repPos = rep.positif || {}; const repNeg = rep.negatif || {}; const repMacro = rep.macro_avg || {}; const repWeighted = rep.weighted_avg || {}; activeBox.innerHTML = `

Hasil Pengujian & Confusion Matrix

Sumber: ${esc(sourceTitle || 'Tautan TikTok')} • Waktu Komputasi: ${res.elapsed_ms || 0} ms

Akurasi (Accuracy) 🎯
${pct(s.accuracy)}
${num(s.correct_count)} dari ${num(s.total_samples)} prediksi tepat
Presisi (Macro) 🔍
${pct(s.macro_precision)}
Positif: ${pct(repPos.precision)} | Negatif: ${pct(repNeg.precision)}
Recall (Sensitivitas) 📡
${pct(s.macro_recall)}
Positif: ${pct(repPos.recall)} | Negatif: ${pct(repNeg.recall)}
F1-Score (Harmonis) ⚖️
${pct(s.macro_f1)}
Positif: ${pct(repPos.f1_score)} | Negatif: ${pct(repNeg.f1_score)}
Total Data Uji 📦
${num(s.total_samples)}
${num(s.correct_count)} Sesuai / ${num(s.error_count)} Galat

Matriks Kebingungan (Confusion Matrix)

2 × 2 Grid
Kelas Prediksi (Predicted Class) →
POSITIF
NEGATIF
AKTUAL POSITIF
True Positive (TP)
${num(cm.tp)}
${pct(pctObj.tp || 0)}
Aktual Positif diprediksi Positif
False Negative (FN)
${num(cm.fn)}
${pct(pctObj.fn || 0)}
Aktual Positif diprediksi Negatif (Galat II)
AKTUAL NEGATIF
False Positive (FP)
${num(cm.fp)}
${pct(pctObj.fp || 0)}
Aktual Negatif diprediksi Positif (Galat I)
True Negative (TN)
${num(cm.tn)}
${pct(pctObj.tn || 0)}
Aktual Negatif diprediksi Negatif

Interpretasi & Rumus Matematis

Akurasi (Accuracy) ${pct(s.accuracy)}
Acc = (TP + TN) / (TP + TN + FP + FN) = (${cm.tp || 0} + ${cm.tn || 0}) / ${s.total_samples || 0}
Persentase total prediksi benar secara keseluruhan terhadap semua sampel uji.
Presisi Kelas Positif (Precision) ${pct(repPos.precision)}
Prec = TP / (TP + FP) = ${cm.tp || 0} / (${cm.tp || 0} + ${cm.fp || 0})
Tingkat ketepatan saat model memprediksi kelas Positif.
Recall / Sensitivitas Positif ${pct(repPos.recall)}
Rec = TP / (TP + FN) = ${cm.tp || 0} / (${cm.tp || 0} + ${cm.fn || 0})
Kemampuan model dalam menjaring seluruh komentar yang sebenarnya Positif.
F1-Score Positif ${pct(repPos.f1_score)}
F1 = 2 × (Prec × Rec) / (Prec + Rec)
Rata-rata harmonis antara nilai Presisi dan Recall.

Tabel Classification Report (Standar Scikit-Learn)

Evaluasi Formal
Kelas Evaluasi Precision Recall F1-Score Support (Sampel)
POSITIF ${pct(repPos.precision)} ${pct(repPos.recall)} ${pct(repPos.f1_score)} ${num(repPos.support)}
NEGATIF ${pct(repNeg.precision)} ${pct(repNeg.recall)} ${pct(repNeg.f1_score)} ${num(repNeg.support)}
Accuracy (Akurasi Keseluruhan) - - ${pct(rep.accuracy || s.accuracy)} ${num(s.total_samples)}
Macro Average ${pct(repMacro.precision)} ${pct(repMacro.recall)} ${pct(repMacro.f1_score)} ${num(repMacro.support)}

Rincian Hasil Prediksi Sampel (${num(details.length)})

Telusuri hasil prediksi per komentar untuk menganalisis misklasifikasi dan kata dominan.

`; // Print & Export Handlers $('#btnPrintReport').onclick = () => window.print(); $('#btnExportCsv').onclick = () => downloadEvaluationResultsCsv(details, s); // Table Filtering, Display Mode & Pagination let activeFilter = 'ALL'; let searchTerm = ''; let currentPage = 1; let currentDisplayMode = 'table'; const pageSize = 25; const btnModeTable = $('#btnModeTable'); const btnModeCards = $('#btnModeCards'); if (btnModeTable && btnModeCards) { btnModeTable.onclick = () => { currentDisplayMode = 'table'; btnModeTable.classList.add('on'); btnModeCards.classList.remove('on'); renderDetailsTable(); }; btnModeCards.onclick = () => { currentDisplayMode = 'cards'; btnModeCards.classList.add('on'); btnModeTable.classList.remove('on'); renderDetailsTable(); }; } function filterDetails() { return details.filter(d => { // Filter category let matchCat = true; if (activeFilter === 'CORRECT') matchCat = d.is_correct; else if (activeFilter === 'ERROR') matchCat = !d.is_correct; else if (activeFilter === 'TP') matchCat = d.matrix_type === 'TP'; else if (activeFilter === 'TN') matchCat = d.matrix_type === 'TN'; else if (activeFilter === 'FP') matchCat = d.matrix_type === 'FP'; else if (activeFilter === 'FN') matchCat = d.matrix_type === 'FN'; if (!matchCat) return false; // Filter search if (searchTerm) { const text = (d.original_comment + ' ' + (d.cleaned_comment || '')).toLowerCase(); if (!text.includes(searchTerm.toLowerCase())) return false; } return true; }); } function renderDetailsTable() { const filtered = filterDetails(); const totalFiltered = filtered.length; const totalPages = Math.max(1, Math.ceil(totalFiltered / pageSize)); if (currentPage > totalPages) currentPage = totalPages; const startIdx = (currentPage - 1) * pageSize; const pageItems = filtered.slice(startIdx, startIdx + pageSize); const tblContent = $('#evalDetailTableContent'); if (!pageItems.length) { tblContent.innerHTML = '
Tidak ada sampel komentar yang cocok dengan filter atau kata pencarian.
'; $('#evalDetailPager').innerHTML = ''; return; } if (currentDisplayMode === 'cards') { tblContent.innerHTML = `
${pageItems.map((d, i) => `
#${startIdx + i + 1} ${d.predicted_label === 'FITUR TIDAK DITEMUKAN' ? 'ℹ️ Fitur Tidak Ditemukan' : (d.predicted_label === 'GAGAL DIANALISIS' ? '⚠️ Gagal Dianalisis' : (d.is_correct ? '✅ Sesuai' : '❌ Galat'))} ${d.matrix_type}
Aktual: ${d.actual_label} → Prediksi: ${d.predicted_label} Keyakinan: ${(d.predicted_label === 'GAGAL DIANALISIS' || d.predicted_label === 'FITUR TIDAK DITEMUKAN') ? '-' : d.confidence + '%'}
Komentar Asli TikTok:
${esc(d.original_comment)}
Hasil Preprocessing NLP:
${d.cleaned_comment && d.cleaned_comment.trim() ? `
${esc(d.cleaned_comment)}
${d.keterangan === 'Fitur tidak ditemukan' || d.fitur_ditemukan === false ? `
⚠️ Keterangan: Fitur tidak ditemukan
` : ''} ` : '⚠️ (Hasil preprocessing kosong)'}
`).join('')}
`; } else { tblContent.innerHTML = ` ${pageItems.map((d, i) => ` `).join('')}
# Komentar Asli Hasil Preprocessing NLP Aktual Prediksi Status Evaluasi Keyakinan
${startIdx + i + 1} ${esc(d.original_comment)} ${d.cleaned_comment && d.cleaned_comment.trim() ? `
${esc(d.cleaned_comment)}
${d.keterangan === 'Fitur tidak ditemukan' || d.fitur_ditemukan === false ? `
⚠️ Keterangan: Fitur tidak ditemukan
` : ''} ` : '⚠️ (Preprocessing kosong)'}
${d.actual_label} ${d.predicted_label} ${d.predicted_label === 'FITUR TIDAK DITEMUKAN' ? 'ℹ️ Fitur Tdk Ditemukan' : (d.predicted_label === 'GAGAL DIANALISIS' ? '⚠️ Gagal' : (d.is_correct ? '✅ Sesuai' : '❌ Galat'))} ${d.matrix_type} ${(d.predicted_label === 'GAGAL DIANALISIS' || d.predicted_label === 'FITUR TIDAK DITEMUKAN') ? '-' : d.confidence + '%'}
`; } $('#evalDetailPager').innerHTML = pager(currentPage, totalPages); $('#evalDetailPager').querySelectorAll('[data-p]').forEach(btn => { btn.onclick = () => { currentPage = +btn.dataset.p; renderDetailsTable(); tblContent.scrollIntoView({ behavior: 'smooth', block: 'start' }); }; }); } // Filter Buttons Event $('#evalDetailFilters').onclick = e => { const btn = e.target.closest('[data-filter]'); if (!btn) return; activeFilter = btn.dataset.filter; currentPage = 1; document.querySelectorAll('#evalDetailFilters .chip').forEach(b => b.classList.toggle('on', b === btn)); renderDetailsTable(); }; // Search Input Event $('#searchEvalInput').oninput = e => { searchTerm = e.target.value.trim(); currentPage = 1; renderDetailsTable(); }; renderDetailsTable(); } // Inisialisasi Routing saat aplikasi dimuat route();