hate-speech-v2 / app.py
rendyputrak's picture
update
14523c8 verified
Raw
History Blame Contribute Delete
33.2 kB
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 &mdash; Lisensi Apache 2.0
</div>
</div>
</div>
</div>
<div class="footer">Powered by <span>Hate Speech Shield</span> &middot; 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
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' : ''}>&laquo;</button>`;
html += `<button class="page-btn" onclick="goPage(${currentPage - 1})" ${currentPage === 1 ? 'disabled' : ''}>&lsaquo;</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' : ''}>&rsaquo;</button>`;
html += `<button class="page-btn" onclick="goPage(${totalPages})" ${currentPage === totalPages ? 'disabled' : ''}>&raquo;</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>
"""
@app.route("/")
def home():
return render_template_string(WEB_UI_HTML, threshold=threshold)
@app.route("/api/dataset")
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
@app.route("/predict", methods=["POST"])
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)