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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Confusion Matrix
+
+
+
+
+
+
+
+ Prediksi Positif
+
+
+ Prediksi Negatif
+
+
+
+ Aktual Positif
+
+
+ 123
+
+
+ 39
+
+
+
+ Aktual Negatif
+
+
+ 113
+
+
+ 37
+
+
+
+
+
+
+
+
+
75%
+
Precision Positif
+
+
+
+
+
+
+
+
75%
+
F1-Score Positif
+
+
+
+
+
76%
+
Precision Negatif
+
+
+
+
+
+
+
+
76%
+
F1-Score Negatif
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 0 karakter
+
+
+
+
+
+
+
+
Hasil prediksi akan muncul di sini
+
+ Ketik teks dan klik tombol analisis
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+ No
+ |
+
+ Teks
+ |
+
+ Sentimen
+ |
+
+
+
+
+
+
+
+
+
+
+
+ Menampilkan 1 -
+ 10 dari
+ 0 data
+
+
+
+
+
+
+
+
+