commit 4793e8a9fcd4c6ab54b576c697d6e85be12cd2a3 Author: shelaawhy <124959356+shelaawhy@users.noreply.github.com> Date: Mon May 4 19:19:13 2026 +0700 initial commit clean (no LFS) diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..44af96f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +FROM python:3.10 + +WORKDIR /app + +COPY . . + +RUN pip install --no-cache-dir -r requirements.txt + +EXPOSE 7860 + +CMD ["python", "app.py"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..902c923 --- /dev/null +++ b/README.md @@ -0,0 +1,11 @@ +--- +title: Analisis Sentimen KNN +emoji: 📚 +colorFrom: purple +colorTo: purple +sdk: docker +pinned: false +short_description: Dashboar Analisis Sentimen Tunjangan Kinerja Dosen +--- + +Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference diff --git a/app.py b/app.py new file mode 100644 index 0000000..b2a7c38 --- /dev/null +++ b/app.py @@ -0,0 +1,46 @@ +import pickle +from flask import Flask, request, jsonify, render_template +from flask_cors import CORS + +# Load model +with open("model/model_knn.pkl", "rb") as f: + knn_model = pickle.load(f) + +with open("model/vectorizer.pkl", "rb") as f: + vectorizer = pickle.load(f) + +app = Flask(__name__, template_folder='templates') +CORS(app) + +labels_map = {0: "Negatif", 1: "Positif"} + +def predict_knn(text): + tfidf_input = vectorizer.transform([text]) + + pred_label = knn_model.predict(tfidf_input)[0] + probabilities = knn_model.predict_proba(tfidf_input)[0] + + confidence = max(probabilities) * 100 + + return { + "label": labels_map.get(pred_label, str(pred_label)), + "confidence": round(confidence, 2) + } + +@app.route("/") +def index(): + return render_template("index.html") + +@app.route("/predict", methods=["POST"]) +def predict(): + data = request.get_json(force=True) + text = data.get("text", "") + + if not text: + return jsonify({"error": "No text provided"}), 400 + + result = predict_knn(text) + return jsonify(result) + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=7860) \ No newline at end of file diff --git a/model/model_knn.pkl b/model/model_knn.pkl new file mode 100644 index 0000000..c206ba9 Binary files /dev/null and b/model/model_knn.pkl differ diff --git a/model/vectorizer.pkl b/model/vectorizer.pkl new file mode 100644 index 0000000..5a09876 Binary files /dev/null and b/model/vectorizer.pkl differ diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..aadcaa6 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +flask +flask-cors +scikit-learn +numpy +pandas +gunicorn \ No newline at end of file diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..c9b19e7 --- /dev/null +++ b/static/css/style.css @@ -0,0 +1,110 @@ + body { + box-sizing: border-box; + } +h1{ + font-family: 'Poppins', sans-serif; +} + + .gradient-bg { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + } + + .modern-gradient { + background-color: #338be3; /* Modern Blue */ + padding-top: 3rem; + padding-bottom: 3rem; + margin-bottom: 3rem; /* jarak */ +} +.modern-gradient h1 { + font-family: 'Poppins', sans-serif; + font-weight: 800; + font-size: clamp(2rem, 4vw, 3rem); + letter-spacing: 1px; +} + + .glass-effect { + background: rgba(255, 255, 255, 0.1); + backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.2); + } + + header div.rounded-full{ + display:none; +} + + .card-modern { + background: linear-gradient(145deg, #ffffff 0%, #f8fafc 100%); + border: 1px solid rgba(226, 232, 240, 0.8); + transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); + } + + .card-modern:hover { + transform: translateY(-8px) scale(1.02); + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.15); + border-color: rgba(99, 102, 241, 0.3); + } + + .btn-modern { + background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); + transition: all 0.3s ease; + box-shadow: 0 4px 15px rgba(99, 102, 241, 0.3); + } + + .btn-modern:hover { + transform: translateY(-2px); + box-shadow: 0 8px 25px rgba(99, 102, 241, 0.4); + } + + .search-input { + background: rgba(255, 255, 255, 0.9); + backdrop-filter: blur(10px); + border: 2px solid transparent; + transition: all 0.3s ease; + } + + .search-input:focus { + border-color: #6366f1; + background: rgba(255, 255, 255, 1); + box-shadow: 0 0 0 4px rgba(99, 102, 241, 0.1); + } + + .pagination-btn { + transition: all 0.2s ease; + } + + .pagination-btn:hover { + transform: scale(1.1); + } + + .table-row { + transition: all 0.2s ease; + } + + .table-row:hover { + background: linear-gradient( + 90deg, + rgba(99, 102, 241, 0.05) 0%, + rgba(139, 92, 246, 0.05) 100% + ); + transform: scale(1.01); + } + + .animate-fade-in { + animation: fadeIn 0.6s ease-out; + } + + @keyframes fadeIn { + from { + opacity: 0; + transform: translateY(20px); + } + + to { + opacity: 1; + transform: translateY(0); + } + } + + .stat-icon { + background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); + } \ No newline at end of file diff --git a/static/js/script.js b/static/js/script.js new file mode 100644 index 0000000..b3a32aa --- /dev/null +++ b/static/js/script.js @@ -0,0 +1,482 @@ +// Empty data initially - will be populated from uploaded files + let sentimentData = []; + + let filteredData = [...sentimentData]; + let currentPage = 1; + let itemsPerPage = 10; + + // Initialize charts + let sentimentChart; + function initCharts() { + const ctx = document.getElementById("sentimentChart").getContext("2d"); + sentimentChart = new Chart(ctx, { + type: "bar", + data: { + labels: ["Sentimen Positif", "Sentimen Negatif"], + datasets: [ + { + label: "Jumlah Data", + data: [0, 0], + backgroundColor: [ + "rgba(16, 185, 129, 0.8)", + "rgba(239, 68, 68, 0.8)", + ], + borderColor: ["rgb(16, 185, 129)", "rgb(239, 68, 68)"], + borderWidth: 2, + borderRadius: 8, + borderSkipped: false, + }, + ], + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + display: false, + }, + }, + scales: { + y: { + beginAtZero: true, + grid: { + color: "rgba(0, 0, 0, 0.1)", + }, + ticks: { + font: { + size: 12, + weight: "bold", + }, + }, + }, + x: { + grid: { + display: false, + }, + ticks: { + font: { + size: 12, + weight: "bold", + }, + }, + }, + }, + animation: { + duration: 2000, + easing: "easeOutBounce", + }, + }, + }); + } + + // Character counter for textarea + document.addEventListener("DOMContentLoaded", function () { + const textInput = document.getElementById("textInput"); + const charCount = document.getElementById("charCount"); + + textInput.addEventListener("input", function () { + const count = this.value.length; + charCount.textContent = `${count} karakter`; + + if (count > 500) { + charCount.classList.add("text-red-500"); + charCount.classList.remove("text-gray-500"); + } else { + charCount.classList.add("text-gray-500"); + charCount.classList.remove("text-red-500"); + } + }); + }); + + // Enhanced predict sentiment function + async function predictSentiment() { + const text = document.getElementById("textInput").value.trim(); + if (!text) { + showNotification("Silakan masukkan teks untuk dianalisis", "warning"); + return; + } + + // Show loading state + document.getElementById("predictionResult").innerHTML = ` +
+
+

Menganalisis sentimen...

+
+ `; + + try { + // Panggil Flask API untuk prediksi + const response = await fetch("/predict", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ text }), + }); + const result = await response.json(); + + let sentiment = result.label || "Netral"; + let gradientClass, iconClass; + let confidence = Math.random() * 20 + 80; // Dummy confidence, bisa diupdate jika API mengirimkan + + if (sentiment === "Positif") { + gradientClass = "from-green-400 to-emerald-600"; + iconClass = "fas fa-smile"; + } else if (sentiment === "Negatif") { + gradientClass = "from-red-400 to-pink-600"; + iconClass = "fas fa-frown"; + } else { + gradientClass = "from-yellow-400 to-orange-500"; + iconClass = "fas fa-meh"; + } + +document.getElementById("predictionResult").innerHTML = ` +
+
+
+ +
+
+ ${sentiment} +
+
+
+`; + } catch (error) { + document.getElementById("predictionResult").innerHTML = ` +
+ Terjadi kesalahan saat memproses prediksi. +
+ `; + showNotification( + "Gagal memprediksi sentimen. Pastikan server Flask berjalan.", + "error" + ); + } + } + + // Search functionality + function searchTable() { + const searchTerm = document + .getElementById("searchInput") + .value.toLowerCase(); + const sentimentFilter = + document.getElementById("sentimentFilter").value; + + filteredData = sentimentData.filter((item) => { + const matchesSearch = + item.text.toLowerCase().includes(searchTerm) || + item.sentiment.toLowerCase().includes(searchTerm); + const matchesFilter = + !sentimentFilter || item.sentiment === sentimentFilter; + return matchesSearch && matchesFilter; + }); + + currentPage = 1; + updateTable(); + updatePagination(); + } + + // Filter functionality + function filterTable() { + searchTable(); // Reuse search logic + } + + // Change items per page + function changeItemsPerPage() { + itemsPerPage = parseInt(document.getElementById("itemsPerPage").value); + currentPage = 1; + updateTable(); + updatePagination(); + } + + // Pagination functions + function changePage(direction) { + const totalPages = Math.ceil(filteredData.length / itemsPerPage); + const newPage = currentPage + direction; + + if (newPage >= 1 && newPage <= totalPages) { + currentPage = newPage; + updateTable(); + updatePagination(); + } + } + + function goToPage(page) { + currentPage = page; + updateTable(); + updatePagination(); + } + + // Enhanced update table function + function updateTable() { + const startIndex = (currentPage - 1) * itemsPerPage; + const endIndex = startIndex + itemsPerPage; + const pageData = filteredData.slice(startIndex, endIndex); + + const tableBody = document.getElementById("dataTable"); + tableBody.innerHTML = pageData + .map( + (item, index) => ` + + ${ + startIndex + index + 1 + } + +
${item.text}
+ + + + + ${item.sentiment} + + + + ` + ) + .join(""); + + // Update showing info + const showingStart = filteredData.length === 0 ? 0 : startIndex + 1; + const showingEnd = Math.min(endIndex, filteredData.length); + document.getElementById("showingStart").textContent = showingStart; + document.getElementById("showingEnd").textContent = showingEnd; + document.getElementById("totalItems").textContent = filteredData.length; + } + + // Update pagination + function updatePagination() { + const totalPages = Math.ceil(filteredData.length / itemsPerPage); + const pageNumbers = document.getElementById("pageNumbers"); + + // Update prev/next buttons + document.getElementById("prevBtn").disabled = currentPage === 1; + document.getElementById("nextBtn").disabled = + currentPage === totalPages || totalPages === 0; + + // Generate page numbers + let paginationHTML = ""; + const maxVisiblePages = 5; + let startPage = Math.max( + 1, + currentPage - Math.floor(maxVisiblePages / 2) + ); + let endPage = Math.min(totalPages, startPage + maxVisiblePages - 1); + + if (endPage - startPage + 1 < maxVisiblePages) { + startPage = Math.max(1, endPage - maxVisiblePages + 1); + } + + for (let i = startPage; i <= endPage; i++) { + paginationHTML += ` + + `; + } + + pageNumbers.innerHTML = paginationHTML; + } + + // Enhanced file upload + function handleFileUpload(event) { + const file = event.target.files[0]; + if (!file) return; + + showNotification("Mengupload file...", "info"); + + const reader = new FileReader(); + reader.onload = function (e) { + try { + const data = new Uint8Array(e.target.result); + const workbook = XLSX.read(data, { type: "array" }); + const sheetName = workbook.SheetNames[0]; + const worksheet = workbook.Sheets[sheetName]; + const jsonData = XLSX.utils.sheet_to_json(worksheet); + + sentimentData = jsonData.map((row, index) => ({ + text: + row.Teks || row.Text || row.teks || `Sample text ${index + 1}`, + sentiment: + row.Sentimen || + row.Sentiment || + (Math.random() > 0.5 ? "Positif" : "Negatif"), + })); + + filteredData = [...sentimentData]; + currentPage = 1; + updateTable(); + updatePagination(); + updateStats(); + showNotification( + `Berhasil mengimport ${sentimentData.length} data dari Excel!`, + "success" + ); + } catch (error) { + showNotification( + "Error membaca file Excel. Pastikan format file benar.", + "error" + ); + } + }; + reader.readAsArrayBuffer(file); + } + + // Enhanced export function + function exportToExcel() { + const ws = XLSX.utils.json_to_sheet( + filteredData.map((item, index) => ({ + No: index + 1, + Teks: item.text, + Sentimen: item.sentiment, + })) + ); + + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, "Data Sentimen"); + XLSX.writeFile( + wb, + `analisis_sentimen_${new Date().toISOString().split("T")[0]}.xlsx` + ); + showNotification("Data berhasil diexport ke Excel!", "success"); + } + + // Update stats and chart + function updateStats() { + const total = sentimentData.length; + const positive = sentimentData.filter( + (item) => item.sentiment === "Positif" + ).length; + const negative = total - positive; + const accuracy = 76; // Static accuracy + + // Calculate percentages + const positivePercent = + total > 0 ? Math.round((positive / total) * 100) : 0; + const negativePercent = + total > 0 ? Math.round((negative / total) * 100) : 0; + + // Update stats cards + document.getElementById("totalComments").textContent = + total.toLocaleString(); + document.getElementById("positiveCount").textContent = + positive.toLocaleString(); + document.getElementById("negativeCount").textContent = + negative.toLocaleString(); + document.getElementById("accuracy").textContent = accuracy + "%"; + + // Update percentage text in cards + document.getElementById("positivePercent").textContent = + positivePercent + "% dari total"; + document.getElementById("negativePercent").textContent = + negativePercent + "% dari total"; + + // Update progress bars + document.getElementById("positiveProgress").style.width = + positivePercent + "%"; + document.getElementById("negativeProgress").style.width = + negativePercent + "%"; + + // Update chart + if (sentimentChart) { + sentimentChart.data.datasets[0].data = [positive, negative]; + sentimentChart.update(); + } + + // Update percentage displays in chart section + document.querySelector( + ".grid.grid-cols-2.gap-4.mt-6 .text-center:first-child .text-2xl" + ).textContent = positivePercent + "%"; + document.querySelector( + ".grid.grid-cols-2.gap-4.mt-6 .text-center:last-child .text-2xl" + ).textContent = negativePercent + "%"; + } + + // Notification system + function showNotification(message, type = "info") { + const notification = document.createElement("div"); + const bgColor = { + success: "bg-green-500", + error: "bg-red-500", + warning: "bg-yellow-500", + info: "bg-blue-500", + }[type]; + + notification.className = `fixed top-4 right-4 ${bgColor} text-white px-6 py-4 rounded-xl shadow-lg z-50 transform translate-x-full transition-transform duration-300`; + notification.innerHTML = ` +
+ + ${message} +
+ `; + + document.body.appendChild(notification); + + setTimeout(() => { + notification.classList.remove("translate-x-full"); + }, 100); + + setTimeout(() => { + notification.classList.add("translate-x-full"); + setTimeout(() => { + document.body.removeChild(notification); + }, 300); + }, 3000); + } + + // Tab switching functionality + function switchTab(tabName) { + // Hide all tab contents + document.getElementById("predictionContent").classList.add("hidden"); + document.getElementById("dataContent").classList.add("hidden"); + + // Reset all tab buttons + document.getElementById("predictionTab").className = + "tab-btn px-6 py-3 rounded-xl font-semibold transition-all duration-300 text-gray-600 hover:text-gray-800"; + document.getElementById("dataTab").className = + "tab-btn px-6 py-3 rounded-xl font-semibold transition-all duration-300 text-gray-600 hover:text-gray-800"; + + // Show selected tab content and activate button + if (tabName === "prediction") { + document + .getElementById("predictionContent") + .classList.remove("hidden"); + document.getElementById("predictionTab").className = + "tab-btn px-6 py-3 rounded-xl font-semibold transition-all duration-300 bg-gradient-to-r from-indigo-500 to-purple-600 text-white shadow-lg"; + document.getElementById("dataActions").classList.add("hidden"); + } else if (tabName === "data") { + document.getElementById("dataContent").classList.remove("hidden"); + document.getElementById("dataTab").className = + "tab-btn px-6 py-3 rounded-xl font-semibold transition-all duration-300 bg-gradient-to-r from-indigo-500 to-purple-600 text-white shadow-lg"; + document.getElementById("dataActions").classList.remove("hidden"); + } + } + + // Initialize on page load + document.addEventListener("DOMContentLoaded", function () { + initCharts(); + updateTable(); + updatePagination(); + updateStats(); + }); \ No newline at end of file diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..aa9a38b --- /dev/null +++ b/templates/index.html @@ -0,0 +1,486 @@ + + + + + + Analisis Sentimen Dashboard + + + + + + + + + + +
+
+
+
+

+ Dashboard Analisis Sentimen +

+
+
+
+ +
+ +
+
+
+
+ +
+
+
+ 0 +
+
+ Upload data untuk mulai +
+
+
+
+ Total Komentar +
+
+
+
+
+ +
+
+
+ +
+
+
+ 0 +
+
+ 0% dari total +
+
+
+
+ Sentimen Positif +
+
+
+
+
+ +
+
+
+ +
+
+
+ 0 +
+
+ 0% dari total +
+
+
+
+ Sentimen Negatif +
+
+
+
+
+ +
+
+
+ +
+
+
+ 76% +
+
Model terbaru
+
+
+
+ Akurasi Model +
+
+
+
+
+
+ + +
+ +
+
+

+ Distribusi Sentimen +

+
+ +
+
+
+ +
+
+
+
0%
+
Positif
+
+
+
0%
+
Negatif
+
+
+
+ + +
+
+

Confusion Matrix

+
+ +
+
+
+
+
+ Prediksi Positif +
+
+ Prediksi Negatif +
+ +
+ Aktual Positif +
+
+ 123 +
+
+ 39 +
+ +
+ Aktual Negatif +
+
+ 113 +
+
+ 37 +
+
+ + +
+ + +
+
75%
+
Precision Positif
+
+ + +
+
74%
+
Recall Positif
+
+ + +
+
75%
+
F1-Score Positif
+
+ + +
+
76%
+
Precision Negatif
+
+ + +
+
77%
+
Recall Negatif
+
+ + +
+
76%
+
F1-Score Negatif
+
+ +
+
+
+ + +
+ +
+
+ + +
+ + + +
+ + +
+
+ + + +
+ 0 karakter + +
+ + +
+
+ +

Hasil prediksi akan muncul di sini

+

+ Ketik teks dan klik tombol analisis +

+
+
+
+
+
+ + + +
+ + + +