Spaces:
Sleeping
Sleeping
| import os | |
| import json | |
| import re | |
| import csv | |
| import io | |
| from flask import Flask, request, jsonify, render_template_string | |
| from transformers import AutoTokenizer, AutoModelForSequenceClassification | |
| import torch | |
| app = Flask(__name__) | |
| MODEL_PATH = "./hate_speech_model" | |
| tokenizer = AutoTokenizer.from_pretrained(MODEL_PATH) | |
| model = AutoModelForSequenceClassification.from_pretrained(MODEL_PATH) | |
| threshold = 0.5 | |
| if os.path.exists(os.path.join(MODEL_PATH, "training_config.json")): | |
| try: | |
| with open(os.path.join(MODEL_PATH, "training_config.json"), "r") as f: | |
| config = json.load(f) | |
| threshold = config.get("optimal_threshold", 0.5) | |
| print(f"Loaded optimal threshold from config: {threshold:.4f}") | |
| except Exception as e: | |
| print(f"Gagal memuat training_config.json: {e}. Menggunakan threshold default 0.5") | |
| dataset_cache = {"data": None, "loaded": False} | |
| def preprocess_text(text): | |
| text = re.sub(r'@[^\s]+', '@USER', text) | |
| text = re.sub(r'https?://[^\s]+', 'HTTPURL', text) | |
| text = re.sub(r'\s+', ' ', text).strip() | |
| return text | |
| WEB_UI_HTML = r""" | |
| <!DOCTYPE html> | |
| <html lang="id"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <meta name="description" content="Hate Speech Detection - Klasifikasi teks ujaran kebencian menggunakan AI"> | |
| <title>Hate Speech Detector</title> | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet"> | |
| <style> | |
| *, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; } | |
| :root { | |
| --bg-primary: #0a0e1a; | |
| --bg-card: #111827; | |
| --bg-input: #1a2035; | |
| --border-subtle: rgba(255,255,255,0.06); | |
| --border-focus: #6366f1; | |
| --text-primary: #f1f5f9; | |
| --text-secondary: #94a3b8; | |
| --text-muted: #64748b; | |
| --accent-indigo: #6366f1; | |
| --accent-emerald: #10b981; | |
| --accent-red: #ef4444; | |
| --accent-amber: #f59e0b; | |
| --radius: 12px; | |
| --radius-lg: 16px; | |
| } | |
| body { | |
| font-family: 'Inter', -apple-system, sans-serif; | |
| background: var(--bg-primary); | |
| color: var(--text-primary); | |
| min-height: 100vh; | |
| display: flex; | |
| align-items: flex-start; | |
| justify-content: center; | |
| padding: 24px; | |
| position: relative; | |
| overflow-x: hidden; | |
| } | |
| body::before { | |
| content: ''; | |
| position: fixed; | |
| top: -40%; left: -20%; | |
| width: 600px; height: 600px; | |
| background: radial-gradient(circle, rgba(99,102,241,0.08) 0%, transparent 70%); | |
| pointer-events: none; | |
| z-index: 0; | |
| } | |
| body::after { | |
| content: ''; | |
| position: fixed; | |
| bottom: -30%; right: -10%; | |
| width: 500px; height: 500px; | |
| background: radial-gradient(circle, rgba(16,185,129,0.06) 0%, transparent 70%); | |
| pointer-events: none; | |
| z-index: 0; | |
| } | |
| .container { | |
| width: 100%; | |
| max-width: 960px; | |
| position: relative; | |
| z-index: 1; | |
| margin-top: 40px; | |
| } | |
| .header { | |
| text-align: center; | |
| margin-bottom: 24px; | |
| } | |
| .header h1 { | |
| font-size: 1.75rem; | |
| font-weight: 800; | |
| letter-spacing: -0.02em; | |
| background: linear-gradient(135deg, #f1f5f9, #94a3b8); | |
| -webkit-background-clip: text; | |
| -webkit-text-fill-color: transparent; | |
| } | |
| .header p { | |
| color: var(--text-muted); | |
| font-size: 0.875rem; | |
| margin-top: 6px; | |
| } | |
| /* ---- Tabs ---- */ | |
| .tabs { | |
| display: flex; | |
| gap: 4px; | |
| margin-bottom: 24px; | |
| background: var(--bg-card); | |
| border-radius: var(--radius); | |
| padding: 4px; | |
| border: 1px solid var(--border-subtle); | |
| } | |
| .tab-btn { | |
| flex: 1; | |
| padding: 12px 20px; | |
| background: transparent; | |
| color: var(--text-muted); | |
| font-family: 'Inter', sans-serif; | |
| font-size: 0.875rem; | |
| font-weight: 600; | |
| border: none; | |
| border-radius: 10px; | |
| cursor: pointer; | |
| transition: all 0.25s ease; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 8px; | |
| } | |
| .tab-btn:hover { | |
| color: var(--text-secondary); | |
| background: rgba(255,255,255,0.03); | |
| } | |
| .tab-btn.active { | |
| background: linear-gradient(135deg, rgba(99,102,241,0.15), rgba(124,58,237,0.1)); | |
| color: #a5b4fc; | |
| box-shadow: 0 2px 8px rgba(99,102,241,0.1); | |
| } | |
| .tab-btn .tab-icon { font-size: 1.1rem; } | |
| .tab-content { display: none; } | |
| .tab-content.active { display: block; } | |
| .card { | |
| background: var(--bg-card); | |
| border: 1px solid var(--border-subtle); | |
| border-radius: var(--radius-lg); | |
| padding: 28px; | |
| box-shadow: 0 4px 24px rgba(0,0,0,0.2); | |
| } | |
| label { | |
| display: block; | |
| font-size: 0.8rem; | |
| font-weight: 600; | |
| color: var(--text-secondary); | |
| text-transform: uppercase; | |
| letter-spacing: 0.05em; | |
| margin-bottom: 8px; | |
| } | |
| textarea { | |
| width: 100%; | |
| min-height: 130px; | |
| padding: 14px 16px; | |
| background: var(--bg-input); | |
| border: 1.5px solid var(--border-subtle); | |
| border-radius: var(--radius); | |
| color: var(--text-primary); | |
| font-family: 'Inter', sans-serif; | |
| font-size: 0.95rem; | |
| line-height: 1.6; | |
| resize: vertical; | |
| transition: border-color 0.2s, box-shadow 0.2s; | |
| outline: none; | |
| } | |
| textarea::placeholder { color: var(--text-muted); } | |
| textarea:focus { | |
| border-color: var(--border-focus); | |
| box-shadow: 0 0 0 3px rgba(99,102,241,0.15); | |
| } | |
| .char-count { | |
| text-align: right; | |
| font-size: 0.75rem; | |
| color: var(--text-muted); | |
| margin-top: 6px; | |
| } | |
| .btn { | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 8px; | |
| width: 100%; | |
| padding: 14px; | |
| margin-top: 18px; | |
| background: linear-gradient(135deg, #6366f1, #7c3aed); | |
| color: #fff; | |
| font-family: 'Inter', sans-serif; | |
| font-size: 0.95rem; | |
| font-weight: 600; | |
| border: none; | |
| border-radius: var(--radius); | |
| cursor: pointer; | |
| transition: transform 0.15s, box-shadow 0.2s, opacity 0.2s; | |
| box-shadow: 0 4px 16px rgba(99,102,241,0.3); | |
| } | |
| .btn:hover:not(:disabled) { | |
| transform: translateY(-1px); | |
| box-shadow: 0 6px 24px rgba(99,102,241,0.4); | |
| } | |
| .btn:active:not(:disabled) { transform: translateY(0); } | |
| .btn:disabled { | |
| opacity: 0.6; | |
| cursor: not-allowed; | |
| } | |
| .btn .spinner { | |
| width: 18px; height: 18px; | |
| border: 2.5px solid rgba(255,255,255,0.3); | |
| border-top-color: #fff; | |
| border-radius: 50%; | |
| animation: spin 0.7s linear infinite; | |
| display: none; | |
| } | |
| .btn.loading .spinner { display: block; } | |
| .btn.loading .btn-label { display: none; } | |
| @keyframes spin { to { transform: rotate(360deg); } } | |
| /* ---- Result Panel ---- */ | |
| .result { | |
| margin-top: 24px; | |
| opacity: 0; | |
| transform: translateY(12px); | |
| transition: opacity 0.4s ease, transform 0.4s ease; | |
| pointer-events: none; | |
| } | |
| .result.visible { | |
| opacity: 1; | |
| transform: translateY(0); | |
| pointer-events: auto; | |
| } | |
| .result-header { | |
| display: flex; | |
| align-items: center; | |
| gap: 12px; | |
| margin-bottom: 18px; | |
| } | |
| .result-badge { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 6px; | |
| padding: 6px 14px; | |
| border-radius: 999px; | |
| font-size: 0.8rem; | |
| font-weight: 700; | |
| text-transform: uppercase; | |
| letter-spacing: 0.04em; | |
| } | |
| .result-badge.hate { | |
| background: rgba(239,68,68,0.12); | |
| color: #f87171; | |
| border: 1px solid rgba(239,68,68,0.2); | |
| } | |
| .result-badge.safe { | |
| background: rgba(16,185,129,0.12); | |
| color: #34d399; | |
| border: 1px solid rgba(16,185,129,0.2); | |
| } | |
| .result-confidence { | |
| font-size: 0.85rem; | |
| color: var(--text-secondary); | |
| font-weight: 500; | |
| } | |
| .prob-bar-wrap { margin-bottom: 20px; } | |
| .prob-bar-labels { | |
| display: flex; | |
| justify-content: space-between; | |
| font-size: 0.75rem; | |
| color: var(--text-muted); | |
| margin-bottom: 6px; | |
| } | |
| .prob-bar { | |
| height: 8px; | |
| background: var(--bg-input); | |
| border-radius: 999px; | |
| overflow: hidden; | |
| } | |
| .prob-bar-fill { | |
| height: 100%; | |
| border-radius: 999px; | |
| transition: width 0.6s cubic-bezier(0.22,1,0.36,1), background 0.3s; | |
| width: 0%; | |
| } | |
| .json-block { | |
| background: var(--bg-input); | |
| border: 1px solid var(--border-subtle); | |
| border-radius: var(--radius); | |
| padding: 16px; | |
| overflow-x: auto; | |
| } | |
| .json-block pre { | |
| font-family: 'JetBrains Mono', 'Fira Code', monospace; | |
| font-size: 0.8rem; | |
| line-height: 1.65; | |
| color: var(--text-secondary); | |
| white-space: pre-wrap; | |
| word-break: break-word; | |
| } | |
| .json-block .json-key { color: #818cf8; } | |
| .json-block .json-string { color: #34d399; } | |
| .json-block .json-number { color: #fbbf24; } | |
| .json-block .json-bool { color: #f472b6; } | |
| .error-msg { | |
| margin-top: 16px; | |
| padding: 12px 16px; | |
| background: rgba(239,68,68,0.08); | |
| border: 1px solid rgba(239,68,68,0.15); | |
| border-radius: var(--radius); | |
| color: #f87171; | |
| font-size: 0.85rem; | |
| display: none; | |
| } | |
| .error-msg.visible { display: block; } | |
| .footer { | |
| text-align: center; | |
| margin-top: 24px; | |
| font-size: 0.75rem; | |
| color: var(--text-muted); | |
| } | |
| .footer span { color: var(--accent-indigo); font-weight: 600; } | |
| /* ---- Dataset Tab ---- */ | |
| .dataset-info { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); | |
| gap: 12px; | |
| margin-bottom: 24px; | |
| } | |
| .info-card { | |
| background: var(--bg-input); | |
| border: 1px solid var(--border-subtle); | |
| border-radius: var(--radius); | |
| padding: 16px; | |
| text-align: center; | |
| } | |
| .info-card .info-value { | |
| font-size: 1.5rem; | |
| font-weight: 800; | |
| background: linear-gradient(135deg, #818cf8, #6366f1); | |
| -webkit-background-clip: text; | |
| -webkit-text-fill-color: transparent; | |
| } | |
| .info-card .info-value.hate-val { | |
| background: linear-gradient(135deg, #f87171, #ef4444); | |
| -webkit-background-clip: text; | |
| } | |
| .info-card .info-value.safe-val { | |
| background: linear-gradient(135deg, #34d399, #10b981); | |
| -webkit-background-clip: text; | |
| } | |
| .info-card .info-label { | |
| font-size: 0.7rem; | |
| color: var(--text-muted); | |
| text-transform: uppercase; | |
| letter-spacing: 0.05em; | |
| margin-top: 4px; | |
| } | |
| .dataset-controls { | |
| display: flex; | |
| gap: 12px; | |
| margin-bottom: 16px; | |
| flex-wrap: wrap; | |
| } | |
| .search-input { | |
| flex: 1; | |
| min-width: 200px; | |
| padding: 10px 14px; | |
| background: var(--bg-input); | |
| border: 1.5px solid var(--border-subtle); | |
| border-radius: var(--radius); | |
| color: var(--text-primary); | |
| font-family: 'Inter', sans-serif; | |
| font-size: 0.85rem; | |
| outline: none; | |
| transition: border-color 0.2s, box-shadow 0.2s; | |
| } | |
| .search-input:focus { | |
| border-color: var(--border-focus); | |
| box-shadow: 0 0 0 3px rgba(99,102,241,0.15); | |
| } | |
| .search-input::placeholder { color: var(--text-muted); } | |
| .filter-select { | |
| padding: 10px 14px; | |
| background: var(--bg-input); | |
| border: 1.5px solid var(--border-subtle); | |
| border-radius: var(--radius); | |
| color: var(--text-primary); | |
| font-family: 'Inter', sans-serif; | |
| font-size: 0.85rem; | |
| outline: none; | |
| cursor: pointer; | |
| min-width: 140px; | |
| } | |
| .filter-select option { background: var(--bg-card); } | |
| .dataset-table-wrap { | |
| overflow-x: auto; | |
| border-radius: var(--radius); | |
| border: 1px solid var(--border-subtle); | |
| } | |
| .dataset-table { | |
| width: 100%; | |
| border-collapse: collapse; | |
| font-size: 0.82rem; | |
| } | |
| .dataset-table th { | |
| background: rgba(99,102,241,0.08); | |
| padding: 12px 14px; | |
| text-align: left; | |
| font-weight: 700; | |
| color: #a5b4fc; | |
| font-size: 0.72rem; | |
| text-transform: uppercase; | |
| letter-spacing: 0.05em; | |
| border-bottom: 1px solid var(--border-subtle); | |
| position: sticky; | |
| top: 0; | |
| z-index: 2; | |
| white-space: nowrap; | |
| } | |
| .dataset-table td { | |
| padding: 10px 14px; | |
| border-bottom: 1px solid var(--border-subtle); | |
| color: var(--text-secondary); | |
| vertical-align: top; | |
| } | |
| .dataset-table tr:hover td { | |
| background: rgba(255,255,255,0.02); | |
| } | |
| .dataset-table .row-num { | |
| color: var(--text-muted); | |
| font-size: 0.75rem; | |
| width: 50px; | |
| text-align: center; | |
| } | |
| .dataset-table .text-cell { | |
| max-width: 420px; | |
| line-height: 1.5; | |
| cursor: pointer; | |
| } | |
| .dataset-table .text-cell.truncated { | |
| display: -webkit-box; | |
| -webkit-line-clamp: 2; | |
| -webkit-box-orient: vertical; | |
| overflow: hidden; | |
| } | |
| .dataset-table .text-cell.expanded { | |
| -webkit-line-clamp: unset; | |
| } | |
| .label-pill { | |
| display: inline-block; | |
| padding: 3px 10px; | |
| border-radius: 999px; | |
| font-size: 0.72rem; | |
| font-weight: 700; | |
| text-transform: uppercase; | |
| letter-spacing: 0.03em; | |
| white-space: nowrap; | |
| } | |
| .label-pill.hate { | |
| background: rgba(239,68,68,0.12); | |
| color: #f87171; | |
| } | |
| .label-pill.non-hate { | |
| background: rgba(16,185,129,0.12); | |
| color: #34d399; | |
| } | |
| .pagination { | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 8px; | |
| margin-top: 16px; | |
| flex-wrap: wrap; | |
| } | |
| .page-btn { | |
| padding: 8px 14px; | |
| background: var(--bg-input); | |
| border: 1px solid var(--border-subtle); | |
| border-radius: 8px; | |
| color: var(--text-secondary); | |
| font-family: 'Inter', sans-serif; | |
| font-size: 0.8rem; | |
| cursor: pointer; | |
| transition: all 0.2s; | |
| } | |
| .page-btn:hover:not(:disabled) { | |
| border-color: var(--accent-indigo); | |
| color: #a5b4fc; | |
| } | |
| .page-btn:disabled { | |
| opacity: 0.35; | |
| cursor: not-allowed; | |
| } | |
| .page-btn.active { | |
| background: rgba(99,102,241,0.15); | |
| border-color: var(--accent-indigo); | |
| color: #a5b4fc; | |
| } | |
| .page-info { | |
| font-size: 0.8rem; | |
| color: var(--text-muted); | |
| padding: 0 8px; | |
| } | |
| .dataset-loading { | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| padding: 60px 20px; | |
| gap: 16px; | |
| } | |
| .dataset-loading .load-spinner { | |
| width: 36px; height: 36px; | |
| border: 3px solid rgba(99,102,241,0.2); | |
| border-top-color: #6366f1; | |
| border-radius: 50%; | |
| animation: spin 0.8s linear infinite; | |
| } | |
| .dataset-loading p { | |
| color: var(--text-muted); | |
| font-size: 0.85rem; | |
| } | |
| .dataset-source { | |
| margin-top: 16px; | |
| padding: 12px 16px; | |
| background: var(--bg-input); | |
| border: 1px solid var(--border-subtle); | |
| border-radius: var(--radius); | |
| font-size: 0.78rem; | |
| color: var(--text-muted); | |
| } | |
| .dataset-source a { | |
| color: #818cf8; | |
| text-decoration: none; | |
| } | |
| .dataset-source a:hover { text-decoration: underline; } | |
| .text-expand-hint { | |
| font-size: 0.7rem; | |
| color: var(--text-muted); | |
| margin-top: -4px; | |
| margin-bottom: 12px; | |
| font-style: italic; | |
| } | |
| @media (max-width: 640px) { | |
| .container { margin-top: 16px; } | |
| .card { padding: 20px; } | |
| .header h1 { font-size: 1.4rem; } | |
| .dataset-info { grid-template-columns: repeat(2, 1fr); } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <div class="header"> | |
| <h1>Hate Speech Detector</h1> | |
| <p>Klasifikasi teks ujaran kebencian</p> | |
| </div> | |
| <div class="tabs"> | |
| <button class="tab-btn active" onclick="switchTab('detector')" id="tab-detector-btn"> | |
| Detector | |
| </button> | |
| <button class="tab-btn" onclick="switchTab('dataset')" id="tab-dataset-btn"> | |
| Dataset IndoDiscourse | |
| </button> | |
| </div> | |
| <!-- ===== DETECTOR TAB ===== --> | |
| <div class="tab-content active" id="tab-detector"> | |
| <div class="card"> | |
| <label for="input-text">Masukkan Teks</label> | |
| <textarea id="input-text" placeholder="Ketik atau tempelkan teks"></textarea> | |
| <div class="char-count"><span id="char-count">0</span> karakter</div> | |
| <button class="btn" id="analyze-btn" onclick="analyze()"> | |
| <div class="spinner"></div> | |
| <span class="btn-label">Analisis Teks</span> | |
| </button> | |
| <div class="error-msg" id="error-msg"></div> | |
| <div class="result" id="result-panel"> | |
| <div class="result-header"> | |
| <div class="result-confidence" id="result-confidence"></div> | |
| </div> | |
| <div class="prob-bar-wrap"> | |
| <div class="prob-bar-labels"> | |
| <span>Non-Hate</span> | |
| <span id="hate-prob-label">0%</span> | |
| <span>Hate</span> | |
| </div> | |
| <div class="prob-bar"> | |
| <div class="prob-bar-fill" id="prob-bar-fill"></div> | |
| </div> | |
| </div> | |
| <label>Response JSON</label> | |
| <div class="json-block"> | |
| <pre id="json-output"></pre> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- ===== DATASET TAB ===== --> | |
| <div class="tab-content" id="tab-dataset"> | |
| <div class="card"> | |
| <div id="dataset-loading" class="dataset-loading"> | |
| <div class="load-spinner"></div> | |
| <p>Memuat dataset IndoDiscourse dari HuggingFace...</p> | |
| </div> | |
| <div id="dataset-content" style="display:none;"> | |
| <div class="dataset-info" id="dataset-stats"></div> | |
| <div class="dataset-controls"> | |
| <input type="text" class="search-input" id="dataset-search" | |
| placeholder="Cari teks dalam dataset..."> | |
| </div> | |
| <p class="text-expand-hint">Klik pada teks untuk melihat selengkapnya</p> | |
| <div class="dataset-table-wrap"> | |
| <table class="dataset-table"> | |
| <thead> | |
| <tr> | |
| <th>#</th> | |
| <th>Teks</th> | |
| </tr> | |
| </thead> | |
| <tbody id="dataset-tbody"></tbody> | |
| </table> | |
| </div> | |
| <div class="pagination" id="pagination"></div> | |
| <div class="dataset-source"> | |
| 📦 Sumber: <a href="https://huggingface.co/datasets/Exqrch/IndoDiscourse" target="_blank" rel="noopener"> | |
| Exqrch/IndoDiscourse | |
| </a> di HuggingFace — Lisensi Apache 2.0 | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="footer">Powered by <span>Hate Speech Shield</span> · Threshold: {{ threshold }}</div> | |
| </div> | |
| <script> | |
| /* ---- Tab Switching ---- */ | |
| let datasetLoaded = false; | |
| function switchTab(tabName) { | |
| document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active')); | |
| document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active')); | |
| document.getElementById('tab-' + tabName).classList.add('active'); | |
| document.getElementById('tab-' + tabName + '-btn').classList.add('active'); | |
| if (tabName === 'dataset' && !datasetLoaded) { | |
| loadDataset(); | |
| } | |
| } | |
| /* ---- Detector Logic ---- */ | |
| const textarea = document.getElementById('input-text'); | |
| const charCount = document.getElementById('char-count'); | |
| textarea.addEventListener('input', () => { charCount.textContent = textarea.value.length; }); | |
| async function analyze() { | |
| const text = textarea.value.trim(); | |
| const btn = document.getElementById('analyze-btn'); | |
| const errorEl = document.getElementById('error-msg'); | |
| const resultPanel = document.getElementById('result-panel'); | |
| errorEl.className = 'error-msg'; | |
| resultPanel.className = 'result'; | |
| if (!text) { errorEl.textContent = 'Silakan masukkan teks terlebih dahulu.'; errorEl.className = 'error-msg visible'; return; } | |
| if (text.length < 5) { errorEl.textContent = 'Teks terlalu pendek. Minimal 5 karakter.'; errorEl.className = 'error-msg visible'; return; } | |
| btn.classList.add('loading'); | |
| btn.disabled = true; | |
| try { | |
| const resp = await fetch('/predict', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json', 'X-API-Key': 'sh1eld-hate-speech-key-2026' }, | |
| body: JSON.stringify({ text }) | |
| }); | |
| if (!resp.ok) { const e = await resp.json().catch(() => ({})); throw new Error(e.error || 'HTTP ' + resp.status); } | |
| displayResult(await resp.json()); | |
| } catch (err) { | |
| errorEl.textContent = 'Gagal menganalisis: ' + err.message; | |
| errorEl.className = 'error-msg visible'; | |
| } finally { | |
| btn.classList.remove('loading'); | |
| btn.disabled = false; | |
| } | |
| } | |
| function displayResult(data) { | |
| const panel = document.getElementById('result-panel'); | |
| const hateProb = data.hate_probability * 100; | |
| document.getElementById('result-confidence').textContent = 'Confidence: ' + (data.confidence * 100).toFixed(1) + '%'; | |
| document.getElementById('hate-prob-label').textContent = hateProb.toFixed(1) + '%'; | |
| requestAnimationFrame(() => { | |
| const fill = document.getElementById('prob-bar-fill'); | |
| fill.style.width = hateProb + '%'; | |
| fill.style.background = hateProb >= 70 ? 'linear-gradient(90deg, #f59e0b, #ef4444)' | |
| : hateProb >= 40 ? 'linear-gradient(90deg, #fbbf24, #f59e0b)' | |
| : 'linear-gradient(90deg, #10b981, #3b82f6)'; | |
| }); | |
| document.getElementById('json-output').innerHTML = syntaxHighlight(JSON.stringify(data, null, 2)); | |
| panel.className = 'result visible'; | |
| } | |
| function syntaxHighlight(json) { | |
| return json.replace(/("(\\u[\da-fA-F]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g, function(m) { | |
| let c = 'json-number'; | |
| if (/^"/.test(m)) c = /:$/.test(m) ? 'json-key' : 'json-string'; | |
| else if (/true|false/.test(m)) c = 'json-bool'; | |
| return '<span class="' + c + '">' + m + '</span>'; | |
| }); | |
| } | |
| textarea.addEventListener('keydown', (e) => { | |
| if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); analyze(); } | |
| }); | |
| /* ---- Dataset Logic ---- */ | |
| let allData = []; | |
| let filteredData = []; | |
| let currentPage = 1; | |
| const perPage = 50; | |
| async function loadDataset() { | |
| try { | |
| const resp = await fetch('/api/dataset'); | |
| if (!resp.ok) throw new Error('HTTP ' + resp.status); | |
| const result = await resp.json(); | |
| allData = result.data || []; | |
| datasetLoaded = true; | |
| showDatasetStats(allData); | |
| applyFilters(); | |
| document.getElementById('dataset-loading').style.display = 'none'; | |
| document.getElementById('dataset-content').style.display = 'block'; | |
| } catch (err) { | |
| document.getElementById('dataset-loading').innerHTML = | |
| '<p style="color:#f87171;">Gagal memuat dataset: ' + err.message + '</p>'; | |
| } | |
| } | |
| function showDatasetStats(data) { | |
| const total = data.length; | |
| document.getElementById('dataset-stats').innerHTML = ` | |
| <div class="info-card"> | |
| <div class="info-value">${total.toLocaleString()}</div> | |
| <div class="info-label">Total Data</div> | |
| </div> | |
| `; | |
| } | |
| function applyFilters() { | |
| const query = document.getElementById('dataset-search').value.toLowerCase().trim(); | |
| filteredData = allData.filter(d => { | |
| if (query && !d.text.toLowerCase().includes(query)) return false; | |
| return true; | |
| }); | |
| currentPage = 1; | |
| renderTable(); | |
| renderPagination(); | |
| } | |
| function renderTable() { | |
| const tbody = document.getElementById('dataset-tbody'); | |
| const start = (currentPage - 1) * perPage; | |
| const end = Math.min(start + perPage, filteredData.length); | |
| const slice = filteredData.slice(start, end); | |
| if (slice.length === 0) { | |
| tbody.innerHTML = '<tr><td colspan="2" style="text-align:center;padding:40px;color:var(--text-muted);">Tidak ada data ditemukan</td></tr>'; | |
| return; | |
| } | |
| tbody.innerHTML = slice.map((row, i) => { | |
| const num = start + i + 1; | |
| const escapedText = row.text.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); | |
| return `<tr> | |
| <td class="row-num">${num}</td> | |
| <td class="text-cell truncated" onclick="this.classList.toggle('truncated');this.classList.toggle('expanded')" title="Klik untuk expand/collapse">${escapedText}</td> | |
| </tr>`; | |
| }).join(''); | |
| } | |
| function renderPagination() { | |
| const totalPages = Math.ceil(filteredData.length / perPage); | |
| const pg = document.getElementById('pagination'); | |
| if (totalPages <= 1) { pg.innerHTML = ''; return; } | |
| let html = `<button class="page-btn" onclick="goPage(1)" ${currentPage === 1 ? 'disabled' : ''}>«</button>`; | |
| html += `<button class="page-btn" onclick="goPage(${currentPage - 1})" ${currentPage === 1 ? 'disabled' : ''}>‹</button>`; | |
| const maxBtns = 5; | |
| let startP = Math.max(1, currentPage - Math.floor(maxBtns / 2)); | |
| let endP = Math.min(totalPages, startP + maxBtns - 1); | |
| if (endP - startP < maxBtns - 1) startP = Math.max(1, endP - maxBtns + 1); | |
| for (let p = startP; p <= endP; p++) { | |
| html += `<button class="page-btn ${p === currentPage ? 'active' : ''}" onclick="goPage(${p})">${p}</button>`; | |
| } | |
| html += `<span class="page-info">${filteredData.length.toLocaleString()} data</span>`; | |
| html += `<button class="page-btn" onclick="goPage(${currentPage + 1})" ${currentPage === totalPages ? 'disabled' : ''}>›</button>`; | |
| html += `<button class="page-btn" onclick="goPage(${totalPages})" ${currentPage === totalPages ? 'disabled' : ''}>»</button>`; | |
| pg.innerHTML = html; | |
| } | |
| function goPage(p) { | |
| const totalPages = Math.ceil(filteredData.length / perPage); | |
| if (p < 1 || p > totalPages) return; | |
| currentPage = p; | |
| renderTable(); | |
| renderPagination(); | |
| document.querySelector('.dataset-table-wrap').scrollTop = 0; | |
| } | |
| let searchTimeout; | |
| document.getElementById('dataset-search').addEventListener('input', () => { | |
| clearTimeout(searchTimeout); | |
| searchTimeout = setTimeout(applyFilters, 300); | |
| }); | |
| </script> | |
| </body> | |
| </html> | |
| """ | |
| def home(): | |
| return render_template_string(WEB_UI_HTML, threshold=threshold) | |
| def get_dataset(): | |
| if dataset_cache["loaded"]: | |
| return jsonify({"data": dataset_cache["data"]}) | |
| try: | |
| import urllib.request | |
| url = "https://huggingface.co/datasets/Exqrch/IndoDiscourse/resolve/main/indotoxic2024_annotated_data_v2_final.csv" | |
| req = urllib.request.Request(url, headers={"User-Agent": "HateSpeechDetector/1.0"}) | |
| with urllib.request.urlopen(req, timeout=30) as response: | |
| raw = response.read().decode("utf-8") | |
| reader = csv.DictReader(io.StringIO(raw)) | |
| rows = [] | |
| for row in reader: | |
| text = row.get("text", "").strip() | |
| if not text: | |
| continue | |
| rows.append({"text": text}) | |
| dataset_cache["data"] = rows | |
| dataset_cache["loaded"] = True | |
| return jsonify({"data": rows}) | |
| except Exception as e: | |
| return jsonify({"error": f"Gagal memuat dataset: {str(e)}"}), 500 | |
| def predict(): | |
| api_key = request.headers.get("X-API-Key") | |
| if api_key != "sh1eld-hate-speech-key-2026": | |
| return jsonify({"error": "Akses ditolak: API Key tidak valid atau tidak disediakan"}), 401 | |
| data = request.get_json() | |
| if not data or "text" not in data: | |
| return jsonify({"error": "Silakan kirimkan parameter 'text'"}), 400 | |
| text = data["text"] | |
| text_clean = preprocess_text(text) | |
| inputs = tokenizer(text_clean, return_tensors="pt", truncation=True, padding=True) | |
| with torch.no_grad(): | |
| outputs = model(**inputs) | |
| probs = torch.nn.functional.softmax(outputs.logits, dim=1) | |
| hate_prob = probs[0][1].item() | |
| clean_text_for_len = text.strip() | |
| if len(clean_text_for_len) > 500: | |
| clean_text_for_len = clean_text_for_len[:500] | |
| text_len = len(clean_text_for_len) | |
| if text_len < 80: | |
| effective_threshold = 0.92 | |
| elif text_len < 200: | |
| effective_threshold = 0.82 | |
| else: | |
| effective_threshold = threshold | |
| pred_label = 1 if hate_prob >= effective_threshold else 0 | |
| label = "Hate" if pred_label == 1 else "Non-Hate" | |
| confidence = hate_prob if pred_label == 1 else (1 - hate_prob) | |
| return jsonify({ | |
| "label": label, | |
| "confidence": confidence, | |
| "hate_probability": float(hate_prob), | |
| "text_length": len(text), | |
| "threshold_used": effective_threshold, | |
| "probabilities": { | |
| "Non-Hate": float(probs[0][0]), | |
| "Hate": float(probs[0][1]) | |
| } | |
| }) | |
| if __name__ == "__main__": | |
| app.run(host="0.0.0.0", port=7860) | |