Prompt-Builder / src /profanity /profanity_detector.py
ArielJoe's picture
feat: cross-detector language policy + tidy structure & file naming
5ddfd1f
Raw
History Blame Contribute Delete
20.3 kB
"""
Profanity Checker — Deteksi kata kasar dan tidak pantas Bahasa Indonesia.
Mendeteksi dua tingkat keparahan:
HIGH Kata vulgar/seksual eksplisit yang hampir selalu tidak pantas
dalam konteks apapun (termasuk pendidikan).
Contoh: kontol, memek, entot
MEDIUM Umpatan, makian, atau kata merendahkan yang kontekstual namun
umumnya tidak pantas dalam teks formal atau pendidikan.
Contoh: anjing, bangsat, goblok
Strategi deteksi (berlapis untuk menangkap variasi penulisan):
1. Normalisasi: collapse karakter berulang (anjiiing → anjing),
konversi leet-speak (4→a, 3→e, 1→i, 0→o, $→s)
2. Lookup langsung di lexicon gabungan dari resources/lexicons/profanity/
3. Strip digit suffix: babi2 → babi (pola reduplikasi)
4. Skeleton konsonan: hapus vokal, cocokkan struktur konsonan
→ anjng (skeleton: njng) → cocok anjing (skeleton: njng)
→ bngst (skeleton: bngst) → cocok bangsat (skeleton: bngst)
Sumber lexicon curated/import:
okkyibrohim et al. — id-multi-label-hate-speech (abusive.csv)
LDNOOBW — multilingual profanity list
Update online dilakukan lewat scripts/import_lexicons.py, bukan saat startup.
Referensi:
okkyibrohim et al. (2019). Multi-label hate speech & abusive language
detection on Indonesian Twitter. EMNLP-IJCNLP 2019.
"""
from __future__ import annotations
import logging
import re
from dataclasses import dataclass
from typing import Literal
from core.lexicons import load_word_set
logger = logging.getLogger(__name__)
# Layer 2 — Classifier Toksisitas Pre-trained (opsional)
#
# Model fine-tuned Bahasa Indonesia (mis. IndoBERTweet hate-speech) sebagai jaring
# kedua untuk menangkap ujaran kasar/toksik yang lolos leksikon. Layer 1 (leksikon)
# tetap utama. Untuk mengganti model, ubah konstanta atau PROFANITY_ML_MODEL.
import threading
from pathlib import Path as _Path
try:
from core import config as _config
_ML_MODEL = getattr(_config, "PROFANITY_ML_MODEL", "Exqrch/IndoBERTweet-HateSpeech")
_ML_THRESHOLD = getattr(_config, "PROFANITY_ML_THRESHOLD", 0.85)
except Exception: # pragma: no cover
_ML_MODEL, _ML_THRESHOLD = "Exqrch/IndoBERTweet-HateSpeech", 0.85
_ML_DIR = _Path(__file__).parent.parent.parent / "cache" / "profanity-toxicity-id"
# Label keluaran classifier yang dianggap toksik (robust antar penamaan model).
_ML_TOXIC_LABELS = {"HATE", "HATESPEECH", "HATE_SPEECH", "TOXIC", "OFFENSIVE", "ABUSIVE", "LABEL_1"}
_ML_TOXIC_SUBSTR = ("HATE", "TOXIC", "ABUS", "OFFENS")
try:
from wordfreq import word_frequency
_WORDFREQ_OK = True
except ImportError:
_WORDFREQ_OK = False
# Konstanta & Konfigurasi
# Tabel konversi leet-speak → huruf normal
# Hanya karakter yang wajar muncul di dalam kata (bukan simbol matematis)
_LEET: dict[str, str] = {
"4": "a", "3": "e", "1": "i", "0": "o",
"@": "a", "$": "s", "5": "s", "7": "t",
"9": "g", "8": "b",
}
_LEET_TABLE = str.maketrans(_LEET)
_WORDFREQ_MIN = 1e-8
# Kata Bawaan (Fallback & Klasifikasi Severity)
#
# Digunakan jika file lexicon tidak tersedia, dan sebagai penentu severity
# untuk kata-kata yang ada di lexicon eksternal.
# HIGH — kata vulgar/seksual eksplisit (tidak pantas dalam konteks apapun)
_BUILTIN_HIGH: set[str] = load_word_set("profanity", "high_fallback.txt") or set()
# MEDIUM — umpatan, makian, kata merendahkan (tidak pantas di teks formal/pendidikan)
_BUILTIN_MEDIUM: set[str] = load_word_set("profanity", "medium_fallback.txt") or set()
# Tipe Data
SeverityLevel = Literal["HIGH", "MEDIUM"]
@dataclass(frozen=True)
class ProfanityFinding:
"""Satu temuan kata kasar dalam teks."""
word: str # kata asli dari teks input
normalized: str # bentuk setelah normalisasi (yang cocok di lexicon)
start: int # offset karakter awal
end: int # offset karakter akhir
severity: SeverityLevel # "HIGH" (vulgar) atau "MEDIUM" (umpatan)
reason: str # penjelasan singkat untuk pengguna
confidence: float # skor kepercayaan 0.0–1.0
layer: str = "lexicon" # "lexicon" (Layer 1) | "ml" (Layer 2 toksisitas)
# Loader & Normalisasi
def _load_lexicon() -> tuple[set[str], set[str]]:
"""
Muat lexicon profanity dan kembalikan (high_words, medium_words).
Urutan prioritas:
1. Baca file lokal di resources/lexicons/profanity/
2. Jika file tidak tersedia, gunakan fallback curated di modul ini
3. Gabungkan external_id sebagai MEDIUM kecuali ada di HIGH
Returns:
(high_words, medium_words) — dua set kata kasar Indonesia.
"""
high = load_word_set("profanity", "high.txt") or set(_BUILTIN_HIGH)
medium = load_word_set("profanity", "medium.txt") or set(_BUILTIN_MEDIUM)
external = load_word_set("profanity", "external_id.txt")
# Kata kasar Inggris (lintas bahasa) — PII/NER/Profanity berlaku untuk teks
# Inggris juga. Dicocokkan sebagai token utuh sehingga aman dari tabrakan.
high |= load_word_set("profanity", "high_en.txt")
medium |= load_word_set("profanity", "medium_en.txt")
all_words = external | high | medium
# Kata yang tidak ada di HIGH → masukkan ke MEDIUM
medium |= all_words - high
logger.info("Lexicon dimuat: HIGH=%d kata, MEDIUM=%d kata.", len(high), len(medium))
return high, medium
def _normalize(text: str) -> str:
"""
Normalisasi teks sebelum pencocokan:
1. Ubah ke huruf kecil
2. Konversi leet-speak: 4→a, 3→e, 1→i, 0→o, @→a, $→s
3. Collapse karakter berulang: anjiiing → anjing
"""
text = text.lower().translate(_LEET_TABLE)
text = re.sub(r"(.)\1{2,}", r"\1", text)
return text
def _all_variants(raw: str) -> list[str]:
"""
Kembalikan semua varian normalisasi yang perlu dicek.
Varian yang dibuat:
- Normalisasi dasar (leet + collapse repeat)
- Strip satu digit suffix: "babi2" → "babi" (pola reduplikasi alay)
"""
norm = _normalize(raw)
variants = [norm]
# Strip satu digit di akhir (mis. reduplikasi "babi2" → "babi")
if norm and norm[-1].isdigit():
variants.append(norm[:-1])
return variants
_SPACED_LETTERS = re.compile(r"\b(?:[a-zA-Z][\s.\-_]+){2,}[a-zA-Z]\b")
def _consonant_skeleton(word: str) -> str:
"""
Hapus semua vokal dari kata untuk menghasilkan skeleton konsonan.
Digunakan untuk mencocokkan bentuk singkatan: anjng → njng (cocok anjing: njng).
"""
return re.sub(r"[aeiou]", "", word)
def _is_known_common_word(word: str, language: str = "id") -> bool:
"""True jika token dikenal sebagai kata umum sehingga skeleton tidak dipakai."""
if not _WORDFREQ_OK:
return False
return word_frequency(word, "id") >= _WORDFREQ_MIN
def _build_skeleton_index(words: set[str]) -> dict[str, list[str]]:
"""
Buat index skeleton konsonan → daftar kata profanity untuk deteksi singkatan.
Hanya kata dengan ≥ 4 huruf yang diindeks (mengurangi false positive pada
singkatan umum non-profanity yang kebetulan memiliki skeleton sama).
Beberapa kata berbeda bisa berbagi skeleton yang sama (mis. "bangsat" dan
"bangset" → "bngst"). Karena itu nilainya berupa LIST, bukan satu kata —
mencegah entri saling menimpa yang dapat menyebabkan false negative.
Returns:
dict {skeleton: [kata_asli, ...]}
"""
idx: dict[str, list[str]] = {}
for w in words:
if len(w) >= 4:
skel = _consonant_skeleton(w)
if skel and len(skel) >= 3:
idx.setdefault(skel, []).append(w)
return idx
def _match_skeleton(candidates: list[str] | None, norm_len: int) -> str | None:
"""
Pilih kata profanity yang cocok untuk sebuah skeleton.
Mengembalikan kata terpanjang yang memenuhi syarat panjang (bentuk yang
dicek tidak lebih panjang dari kata asli di lexicon — menghindari false
positive pada kata panjang), atau None jika tidak ada yang memenuhi.
"""
if not candidates:
return None
best = max(candidates, key=len)
return best if norm_len <= len(best) else None
# Kelas Utama
class ProfanityChecker:
"""
Detektor kata kasar Bahasa Indonesia berbasis lexicon.
Mendeteksi berbagai variasi penulisan: bentuk normal, leet-speak,
karakter berulang, digit suffix, dan singkatan tanpa vokal.
Contoh penggunaan::
chk = ProfanityChecker()
chk.load()
for f in chk.check("anjing lu goblok"):
print(f.word, f.severity, f.normalized)
"""
def __init__(self, use_ml: bool = True) -> None:
self._high: set[str] = set()
self._medium: set[str] = set()
self._skel_high: dict[str, list[str]] = {} # skeleton → daftar kata HIGH
self._skel_medium: dict[str, list[str]] = {} # skeleton → daftar kata MEDIUM
self._use_ml = use_ml
self._ml_pipe = None
self._ml_lock = threading.Lock()
self._loaded = False
# Public API
def load(self) -> bool:
"""
Muat lexicon (Layer 1) dan, bila diaktifkan, model toksisitas (Layer 2).
Returns:
True setelah lexicon berhasil dimuat (Layer 1 selalu tersedia).
"""
if self._loaded:
return True
self._high, self._medium = _load_lexicon()
self._skel_high = _build_skeleton_index(self._high)
self._skel_medium = _build_skeleton_index(self._medium)
# Layer 2 ML opsional (classifier toksisitas pre-trained Indonesia).
if self._use_ml:
load_from = str(_ML_DIR) if _ML_DIR.is_dir() else _ML_MODEL
try:
from transformers import pipeline as _hf_pipeline # impor lazy
logger.info("Memuat model toksisitas '%s'...", load_from)
self._ml_pipe = _hf_pipeline(
"text-classification", model=load_from,
truncation=True, max_length=256,
)
logger.info("Model toksisitas berhasil dimuat.")
except Exception as exc:
logger.warning("Gagal memuat model toksisitas: %s — hanya leksikon aktif.", exc)
self._ml_pipe = None
self._loaded = True
return True
@property
def ml_active(self) -> bool:
"""True jika Layer 2 (model toksisitas) aktif."""
return self._ml_pipe is not None
def download_ml_model(self) -> bool:
"""Unduh model toksisitas ke cache lokal untuk penggunaan offline."""
try:
from huggingface_hub import snapshot_download
_ML_DIR.mkdir(parents=True, exist_ok=True)
snapshot_download(repo_id=_ML_MODEL, local_dir=str(_ML_DIR))
return True
except Exception as exc:
logger.error("Gagal mengunduh model toksisitas: %s", exc)
return False
def _check_ml(self, text: str) -> list[ProfanityFinding]:
"""Klasifikasi toksisitas pada keseluruhan teks (advice-only)."""
try:
with self._ml_lock:
result = self._ml_pipe(text, truncation=True, max_length=256)
except Exception as exc:
logger.warning("Klasifikasi toksisitas ML gagal: %s", exc)
return []
if isinstance(result, list):
result = result[0] if result else {}
label = str(result.get("label", "")).upper()
score = float(result.get("score", 0.0))
# Hormati label negatif/aman walau mengandung kata "HATE" (mis.
# "Non_HateSpeech", "NOT_OFFENSIVE", "NORMAL", "CLEAN", "LABEL_0").
is_safe = any(n in label for n in ("NON", "NOT", "NEG", "NORMAL", "CLEAN", "SAFE", "BUKAN"))
toxic = (not is_safe) and (
label in _ML_TOXIC_LABELS or any(s in label for s in _ML_TOXIC_SUBSTR)
)
if not toxic or score < _ML_THRESHOLD:
return []
return [ProfanityFinding(
word=text[:120] + ("..." if len(text) > 120 else ""),
normalized="",
start=0, end=min(len(text), 120),
severity="MEDIUM",
reason=("Teks ini terindikasi mengandung ujaran kasar atau toksik "
f"(model ML, skor {score:.0%}). Tinjau dan perhalus bila perlu."),
confidence=round(score, 3),
layer="ml",
)]
def _classify_token(
self,
raw: str,
language: str,
) -> tuple[SeverityLevel | None, str]:
"""Klasifikasikan satu token/hasil normalisasi sebagai profanity atau bukan."""
severity: SeverityLevel | None = None
matched_as = ""
# Tahap 1: cek semua varian normalisasi
for variant in _all_variants(raw):
if len(variant) < 2:
continue
if variant in self._high:
return "HIGH", variant
if variant in self._medium:
return "MEDIUM", variant
# Tahap 2: cek skeleton konsonan (untuk singkatan/typo vokal hilang)
norm = _normalize(raw)
if _is_known_common_word(norm, language):
return None, ""
skel = _consonant_skeleton(norm)
if skel and len(skel) >= 3:
hi = _match_skeleton(self._skel_high.get(skel), len(norm))
if hi is not None:
severity = "HIGH"
matched_as = hi
else:
me = _match_skeleton(self._skel_medium.get(skel), len(norm))
if me is not None:
severity = "MEDIUM"
matched_as = me
# Tahap 2.5: validasi skeleton — jika kata input bisa dikoreksi ke kata
# Indonesia yang umum (via SymSpell), batalkan skeleton match.
# Contoh false positive: "speda" (informal "sepeda") → skeleton "spd" bisa
# cocok lexicon eksternal, padahal SymSpell → "sepeda" (umum).
if severity is not None and _WORDFREQ_OK:
try:
from word_quality.word_quality_detector import get_detector
wq = get_detector(load=False)
if wq.is_loaded:
corrected = wq.correct_spelling(norm, language="id")
if corrected and corrected != norm:
id_freq = word_frequency(corrected, "id")
# Threshold 500× WORDFREQ_MIN = 5e-6 — harus jelas kata umum
if id_freq >= _WORDFREQ_MIN * 500:
return None, ""
except Exception:
pass
# Tahap 3: koreksi ejaan via SymSpell → cek apakah hasil koreksi adalah profanity.
if severity is None:
try:
from word_quality.word_quality_detector import get_detector
wq = get_detector(load=False)
if wq.is_loaded:
corrected = wq.correct_spelling(norm, language="id")
if corrected:
if corrected in self._high:
severity = "HIGH"; matched_as = corrected
elif corrected in self._medium:
severity = "MEDIUM"; matched_as = corrected
except Exception:
pass
return severity, matched_as
def check(self, text: str, language: str = "id") -> list[ProfanityFinding]:
"""
Periksa teks dan kembalikan semua temuan kata kasar.
Setiap token diperiksa dalam urutan:
1. Lookup varian normalisasi (leet + strip digit + collapse)
2. Skeleton konsonan untuk singkatan
Args:
text: Teks yang akan diperiksa.
Returns:
Daftar ProfanityFinding diurutkan berdasarkan posisi (start ascending).
"""
if not text.strip():
return []
if not self._loaded:
self.load()
findings: list[ProfanityFinding] = []
seen_spans: set[tuple[int, int]] = set()
# Pre-pass: tangkap profanity yang dieja per huruf, mis. "a n j i n g".
for m in _SPACED_LETTERS.finditer(text):
raw = m.group()
joined = re.sub(r"[^a-zA-Z0-9@$]", "", raw)
severity, matched_as = self._classify_token(joined, language)
if severity is None:
continue
reason = (
"Kata vulgar/seksual eksplisit yang tidak pantas dalam konteks pendidikan."
if severity == "HIGH"
else "Umpatan atau kata merendahkan yang tidak pantas dalam teks formal/pendidikan."
)
findings.append(ProfanityFinding(
word=raw,
normalized=matched_as or _normalize(joined),
start=m.start(),
end=m.end(),
severity=severity,
reason=reason,
confidence=0.90 if severity == "HIGH" else 0.80,
))
seen_spans.add((m.start(), m.end()))
for m in re.finditer(r"[a-zA-Z0-9@$]+", text):
raw = m.group()
start = m.start()
end = m.end()
span = (start, end)
if any(s <= start < e or s < end <= e for s, e in seen_spans):
continue
severity, matched_as = self._classify_token(raw, language)
if severity is None:
continue
seen_spans.add(span)
reason = (
"Kata vulgar/seksual eksplisit yang tidak pantas dalam konteks pendidikan."
if severity == "HIGH"
else "Umpatan atau kata merendahkan yang tidak pantas dalam teks formal/pendidikan."
)
findings.append(ProfanityFinding(
word=raw,
normalized=matched_as or _normalize(raw),
start=start,
end=end,
severity=severity,
reason=reason,
confidence=0.95 if severity == "HIGH" else 0.85,
))
# Layer 2: classifier toksisitas (opsional)
# Hanya dijalankan bila leksikon tidak menemukan apa pun, untuk menangkap
# ujaran kasar/toksik yang lolos leksikon tanpa menambah false positive
# pada teks yang sudah jelas ditandai Layer 1.
if not findings and self._ml_pipe is not None and len(text.split()) >= 3:
findings.extend(self._check_ml(text))
return sorted(findings, key=lambda f: f.start)
# Properties
@property
def is_loaded(self) -> bool:
"""True jika lexicon sudah dimuat."""
return self._loaded
@property
def lexicon_size(self) -> int:
"""Jumlah total kata dalam lexicon (medium ⊇ high)."""
return len(self._medium)
# Singleton
_checker: ProfanityChecker | None = None
def get_checker() -> ProfanityChecker:
"""
Kembalikan instance ProfanityChecker singleton (lazy-initialized).
Instance yang sama digunakan ulang di seluruh aplikasi sehingga lexicon
hanya dimuat sekali.
"""
global _checker
if _checker is None:
_checker = ProfanityChecker()
_checker.load()
return _checker
# Demo CLI
if __name__ == "__main__":
import sys
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
SAMPLES = [
"Tolong jelaskan materi fotosintesis",
"anjing lu goblok banget",
"bangsat! laporan ini berantakan",
"kontol memek itu kata vulgar",
"saya tidak suka cara kerjanya",
"bego banget si brengsek itu",
"Pak Andi adalah guru yang baik",
"4nj1ng lu anjng bangsat bngst", # leet + singkatan
]
texts = sys.argv[1:] or SAMPLES
chk = ProfanityChecker()
chk.load()
print(f"Lexicon: {chk.lexicon_size} kata\n{'-' * 60}")
for text in texts:
findings = chk.check(text)
print(f"\n> {text}")
if not findings:
print(" (bersih)")
for f in findings:
bar = "#" * int(f.confidence * 10) + "." * (10 - int(f.confidence * 10))
print(
f" [{f.severity:<6}] {f.word!r:<15} "
f"(norm={f.normalized!r}) {bar} {f.confidence:.0%}"
)