Spaces:
Running
Running
feat: add syntax checker and improve multilingual prompt analysis
Browse files- add Indonesian word-order syntax checker with optional IndoBERT scoring
- integrate syntax findings into pipeline status, analysis, hot reload, and sub-server startup
- add syntax issue highlighting and explanation in the web UI
- improve English/Indonesian handling across PII, filler, NER, profanity, responsible, and word quality detectors
- tune lexicons to reduce false positives for Indonesian terms
- .gitignore +1 -0
- resources/lexicons/profanity/whitelist.txt +0 -1
- resources/lexicons/word_quality/slang_id.tsv +0 -2
- src/core/pipeline_server.py +99 -20
- src/filler/filler_checker.py +273 -43
- src/ner/ner_model.py +43 -31
- src/pii/pii_detector.py +93 -6
- src/profanity/profanity_checker.py +45 -28
- src/responsible/responsible_checker.py +23 -8
- src/syntax/__init__.py +0 -0
- src/syntax/syntax_checker.py +425 -0
- src/syntax/syntax_server.py +142 -0
- src/word_quality/word_quality_detector.py +110 -34
- web/index.html +78 -65
.gitignore
CHANGED
|
@@ -2,6 +2,7 @@
|
|
| 2 |
GEMINI.md
|
| 3 |
AGENT.md
|
| 4 |
CLAUDE.md
|
|
|
|
| 5 |
|
| 6 |
# Python
|
| 7 |
__pycache__/
|
|
|
|
| 2 |
GEMINI.md
|
| 3 |
AGENT.md
|
| 4 |
CLAUDE.md
|
| 5 |
+
.claude
|
| 6 |
|
| 7 |
# Python
|
| 8 |
__pycache__/
|
resources/lexicons/profanity/whitelist.txt
CHANGED
|
@@ -3,7 +3,6 @@ kampung
|
|
| 3 |
buaya
|
| 4 |
berak
|
| 5 |
benar
|
| 6 |
-
bugil
|
| 7 |
bisu
|
| 8 |
buta
|
| 9 |
alay
|
|
|
|
| 3 |
buaya
|
| 4 |
berak
|
| 5 |
benar
|
|
|
|
| 6 |
bisu
|
| 7 |
buta
|
| 8 |
alay
|
resources/lexicons/word_quality/slang_id.tsv
CHANGED
|
@@ -700,7 +700,6 @@ btul betul
|
|
| 700 |
btulan betulan
|
| 701 |
buanget banget
|
| 702 |
buanyaaak banyak
|
| 703 |
-
buat untuk
|
| 704 |
buatin membuat
|
| 705 |
bujngn bujangan
|
| 706 |
bujukk bujuk
|
|
@@ -2208,7 +2207,6 @@ ladenin meladeni
|
|
| 2208 |
laen lain
|
| 2209 |
laenny lainnya
|
| 2210 |
lafazd lafaz
|
| 2211 |
-
lagi sedang
|
| 2212 |
lagihh lagi
|
| 2213 |
lagii lagi
|
| 2214 |
lagiii lagi
|
|
|
|
| 700 |
btulan betulan
|
| 701 |
buanget banget
|
| 702 |
buanyaaak banyak
|
|
|
|
| 703 |
buatin membuat
|
| 704 |
bujngn bujangan
|
| 705 |
bujukk bujuk
|
|
|
|
| 2207 |
laen lain
|
| 2208 |
laenny lainnya
|
| 2209 |
lafazd lafaz
|
|
|
|
| 2210 |
lagihh lagi
|
| 2211 |
lagii lagi
|
| 2212 |
lagiii lagi
|
src/core/pipeline_server.py
CHANGED
|
@@ -7,14 +7,17 @@
|
|
| 7 |
Pipeline Server β Orchestrator semua detektor NLP.
|
| 8 |
Port default: 8000
|
| 9 |
|
| 10 |
-
Menggabungkan
|
| 11 |
-
1. PII Detector β data pribadi (NIK, email, telepon, dll.)
|
| 12 |
2. Word Quality β kata slang, alay, dan typo
|
| 13 |
3. Responsible Checker β prompt injection, kecurangan akademik, konten berbahaya
|
| 14 |
4. NER β entitas bernama (orang, lokasi, organisasi, dll.)
|
| 15 |
5. Profanity Checker β kata kasar dan tidak pantas
|
| 16 |
6. Filler Checker β frasa basa-basi yang membuang token
|
| 17 |
7. Special Char Detector β karakter invisible dan tidak efektif (zero-width, full-width, dll.)
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
Endpoint HTTP:
|
| 20 |
GET /api/status β status tiap detektor (siap / tidak siap)
|
|
@@ -30,6 +33,7 @@ Sub-server (dijalankan otomatis sebagai proses latar belakang):
|
|
| 30 |
port 8005 β Profanity server (web/profanity-test.html)
|
| 31 |
port 8006 β Filler server (web/filler-test.html)
|
| 32 |
port 8007 β Special Char server (web/special-char-test.html)
|
|
|
|
| 33 |
|
| 34 |
Cara menjalankan:
|
| 35 |
python src/core/pipeline_server.py
|
|
@@ -107,6 +111,10 @@ _FIELDS: list[dict] = [
|
|
| 107 |
"color": "#7c2d12", "surface": "#fff7ed"},
|
| 108 |
]
|
| 109 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
|
| 111 |
# ββ State Global Detektor ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 112 |
#
|
|
@@ -120,11 +128,13 @@ _ner = None # IndonesianNER
|
|
| 120 |
_prof = None # ProfanityChecker
|
| 121 |
_filler = None # FillerChecker
|
| 122 |
_special_char = None # SpecialCharDetector
|
|
|
|
| 123 |
|
| 124 |
# Flag startup (disimpan agar reload menggunakan flag yang sama)
|
| 125 |
-
_no_resp_ml
|
| 126 |
-
_no_ner
|
| 127 |
-
_no_ner_ml
|
|
|
|
| 128 |
_detectors_loaded = threading.Event()
|
| 129 |
|
| 130 |
|
|
@@ -150,6 +160,7 @@ class DetectorWatcher:
|
|
| 150 |
("profanity/profanity_checker.py", "_reload_prof"),
|
| 151 |
("filler/filler_checker.py", "_reload_filler"),
|
| 152 |
("special_char/special_char_detector.py", "_reload_special_char"),
|
|
|
|
| 153 |
("thesaurus/thesaurus_id.py", "_reload_thesaurus"),
|
| 154 |
]
|
| 155 |
|
|
@@ -245,6 +256,14 @@ class DetectorWatcher:
|
|
| 245 |
importlib.reload(m)
|
| 246 |
_special_char = m.SpecialCharDetector()
|
| 247 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 248 |
def _reload_thesaurus(self) -> None:
|
| 249 |
# Reset singleton di word_quality_detector agar terbaca ulang saat request berikutnya
|
| 250 |
try:
|
|
@@ -343,7 +362,7 @@ def _resp_issue(finding, field: dict, text: str) -> dict:
|
|
| 343 |
"issue_type": finding.code,
|
| 344 |
"css_class": css,
|
| 345 |
"severity": finding.severity,
|
| 346 |
-
"word": finding.evidence
|
| 347 |
"start": start,
|
| 348 |
"end": end,
|
| 349 |
"label": finding.code.replace("_", " "),
|
|
@@ -444,6 +463,27 @@ def _special_char_issue(finding, field: dict) -> dict:
|
|
| 444 |
}
|
| 445 |
|
| 446 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 447 |
def _ner_issue(entity, field: dict) -> dict:
|
| 448 |
"""Konversi NEREntity β dict issue standar."""
|
| 449 |
return {
|
|
@@ -553,6 +593,15 @@ def _analyze_field(f: dict, text: str) -> list[dict]:
|
|
| 553 |
except Exception as e:
|
| 554 |
logger.warning("Special Char error [%s]: %s", f["id"], e)
|
| 555 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 556 |
return issues
|
| 557 |
|
| 558 |
|
|
@@ -641,6 +690,7 @@ def _evaluate(fields: dict[str, str]) -> dict:
|
|
| 641 |
"profanity": _prof is not None,
|
| 642 |
"filler": _filler is not None,
|
| 643 |
"special_char": _special_char is not None,
|
|
|
|
| 644 |
},
|
| 645 |
}
|
| 646 |
|
|
@@ -744,6 +794,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
| 744 |
"profanity": _prof is not None,
|
| 745 |
"filler": _filler is not None,
|
| 746 |
"special_char": _special_char is not None,
|
|
|
|
| 747 |
},
|
| 748 |
})
|
| 749 |
elif self.path.startswith("/api/"):
|
|
@@ -782,7 +833,8 @@ class Handler(BaseHTTPRequestHandler):
|
|
| 782 |
|
| 783 |
# ββ Entry Point ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 784 |
|
| 785 |
-
def _start_subservers(host: str, no_resp_ml: bool = False, no_ner: bool = False,
|
|
|
|
| 786 |
"""
|
| 787 |
Jalankan semua server test individual sebagai proses latar belakang.
|
| 788 |
|
|
@@ -794,10 +846,11 @@ def _start_subservers(host: str, no_resp_ml: bool = False, no_ner: bool = False,
|
|
| 794 |
error diabaikan dengan pesan peringatan.
|
| 795 |
|
| 796 |
Args:
|
| 797 |
-
host:
|
| 798 |
-
no_resp_ml:
|
| 799 |
-
no_ner:
|
| 800 |
-
no_ner_ml:
|
|
|
|
| 801 |
"""
|
| 802 |
global _sub_processes
|
| 803 |
|
|
@@ -815,6 +868,11 @@ def _start_subservers(host: str, no_resp_ml: bool = False, no_ner: bool = False,
|
|
| 815 |
else:
|
| 816 |
logger.info("Sub-server NER dilewati (mode cepat / NER ML nonaktif).")
|
| 817 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 818 |
logger.info("β" * 56)
|
| 819 |
logger.info("Memulai sub-server halaman testβ¦")
|
| 820 |
|
|
@@ -854,7 +912,8 @@ def main() -> None:
|
|
| 854 |
6. Filler Checker (ringan, berbasis regex)
|
| 855 |
7. Special Char Detector (ringan, berbasis karakter)
|
| 856 |
"""
|
| 857 |
-
global _pii, _wq, _resp, _ner, _prof, _filler, _special_char
|
|
|
|
| 858 |
|
| 859 |
# Railway (dan platform cloud lain) mengeset $PORT secara otomatis.
|
| 860 |
# Fallback ke 8000 untuk development lokal.
|
|
@@ -873,18 +932,22 @@ def main() -> None:
|
|
| 873 |
parser.add_argument("--no-ner-ml", action="store_true",
|
| 874 |
help="NER hanya rule-based (regex + nama), tanpa model transformer (~600 MB). "
|
| 875 |
"Lebih cepat; masih mendeteksi lembaga, tanggal, nama orang, dll.")
|
|
|
|
|
|
|
| 876 |
parser.add_argument("--fast", action="store_true",
|
| 877 |
-
help="Mode cepat: setara --no-resp-ml --no-ner-ml
|
|
|
|
| 878 |
args = parser.parse_args()
|
| 879 |
-
_no_resp_ml
|
| 880 |
-
_no_ner
|
| 881 |
-
_no_ner_ml
|
|
|
|
| 882 |
|
| 883 |
# ββ Load semua detektor di background thread ββββββββββββββββββββββββββββββ
|
| 884 |
# Server langsung mulai listen agar healthcheck Railway tidak timeout.
|
| 885 |
# /api/status mengembalikan ready:false selama loading, ready:true setelah selesai.
|
| 886 |
def _load_detectors():
|
| 887 |
-
global _pii, _wq, _resp, _ner, _prof, _filler, _special_char
|
| 888 |
|
| 889 |
# 1. PII Detector
|
| 890 |
logger.info("Memuat PII Detector...")
|
|
@@ -952,11 +1015,27 @@ def main() -> None:
|
|
| 952 |
_special_char = SpecialCharDetector()
|
| 953 |
logger.info("Special Char Detector siap.")
|
| 954 |
|
| 955 |
-
# 8.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 956 |
DetectorWatcher(_SRC_DIR).start()
|
| 957 |
|
| 958 |
-
#
|
| 959 |
-
_start_subservers(args.host, no_resp_ml=_no_resp_ml, no_ner=_no_ner,
|
|
|
|
| 960 |
|
| 961 |
logger.info("Semua detektor siap. Pipeline aktif penuh.")
|
| 962 |
_detectors_loaded.set()
|
|
|
|
| 7 |
Pipeline Server β Orchestrator semua detektor NLP.
|
| 8 |
Port default: 8000
|
| 9 |
|
| 10 |
+
Menggabungkan delapan detektor dalam satu endpoint untuk frontend index.html:
|
| 11 |
+
1. PII Detector β data pribadi (NIK, email, telepon, SSN, dll.)
|
| 12 |
2. Word Quality β kata slang, alay, dan typo
|
| 13 |
3. Responsible Checker β prompt injection, kecurangan akademik, konten berbahaya
|
| 14 |
4. NER β entitas bernama (orang, lokasi, organisasi, dll.)
|
| 15 |
5. Profanity Checker β kata kasar dan tidak pantas
|
| 16 |
6. Filler Checker β frasa basa-basi yang membuang token
|
| 17 |
7. Special Char Detector β karakter invisible dan tidak efektif (zero-width, full-width, dll.)
|
| 18 |
+
8. Syntax Checker β urutan kata janggal Bahasa Indonesia (perplexity IndoBERT)
|
| 19 |
+
|
| 20 |
+
PII, Filler, dan NER mendukung Bahasa Indonesia maupun Inggris (routing per bahasa).
|
| 21 |
|
| 22 |
Endpoint HTTP:
|
| 23 |
GET /api/status β status tiap detektor (siap / tidak siap)
|
|
|
|
| 33 |
port 8005 β Profanity server (web/profanity-test.html)
|
| 34 |
port 8006 β Filler server (web/filler-test.html)
|
| 35 |
port 8007 β Special Char server (web/special-char-test.html)
|
| 36 |
+
port 8008 β Syntax server (deteksi urutan kata)
|
| 37 |
|
| 38 |
Cara menjalankan:
|
| 39 |
python src/core/pipeline_server.py
|
|
|
|
| 111 |
"color": "#7c2d12", "surface": "#fff7ed"},
|
| 112 |
]
|
| 113 |
|
| 114 |
+
# Field yang diperiksa Syntax Checker (kalimat naratif utuh). Field lain seperti
|
| 115 |
+
# tone/outputFormat/example sengaja boleh berupa frasa pendek, jadi dilewati.
|
| 116 |
+
_SYNTAX_FIELDS: frozenset[str] = frozenset({"task", "context", "references"})
|
| 117 |
+
|
| 118 |
|
| 119 |
# ββ State Global Detektor ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 120 |
#
|
|
|
|
| 128 |
_prof = None # ProfanityChecker
|
| 129 |
_filler = None # FillerChecker
|
| 130 |
_special_char = None # SpecialCharDetector
|
| 131 |
+
_syntax = None # SyntaxChecker
|
| 132 |
|
| 133 |
# Flag startup (disimpan agar reload menggunakan flag yang sama)
|
| 134 |
+
_no_resp_ml = False
|
| 135 |
+
_no_ner = False
|
| 136 |
+
_no_ner_ml = False
|
| 137 |
+
_no_syntax_ml = False
|
| 138 |
_detectors_loaded = threading.Event()
|
| 139 |
|
| 140 |
|
|
|
|
| 160 |
("profanity/profanity_checker.py", "_reload_prof"),
|
| 161 |
("filler/filler_checker.py", "_reload_filler"),
|
| 162 |
("special_char/special_char_detector.py", "_reload_special_char"),
|
| 163 |
+
("syntax/syntax_checker.py", "_reload_syntax"),
|
| 164 |
("thesaurus/thesaurus_id.py", "_reload_thesaurus"),
|
| 165 |
]
|
| 166 |
|
|
|
|
| 256 |
importlib.reload(m)
|
| 257 |
_special_char = m.SpecialCharDetector()
|
| 258 |
|
| 259 |
+
def _reload_syntax(self) -> None:
|
| 260 |
+
global _syntax
|
| 261 |
+
import syntax.syntax_checker as m
|
| 262 |
+
importlib.reload(m)
|
| 263 |
+
checker = m.SyntaxChecker(use_ml=not _no_syntax_ml)
|
| 264 |
+
checker.load()
|
| 265 |
+
_syntax = checker
|
| 266 |
+
|
| 267 |
def _reload_thesaurus(self) -> None:
|
| 268 |
# Reset singleton di word_quality_detector agar terbaca ulang saat request berikutnya
|
| 269 |
try:
|
|
|
|
| 362 |
"issue_type": finding.code,
|
| 363 |
"css_class": css,
|
| 364 |
"severity": finding.severity,
|
| 365 |
+
"word": finding.evidence,
|
| 366 |
"start": start,
|
| 367 |
"end": end,
|
| 368 |
"label": finding.code.replace("_", " "),
|
|
|
|
| 463 |
}
|
| 464 |
|
| 465 |
|
| 466 |
+
def _syntax_issue(finding, field: dict) -> dict:
|
| 467 |
+
"""Konversi SyntaxFinding β dict issue standar."""
|
| 468 |
+
return {
|
| 469 |
+
"source": "syntax",
|
| 470 |
+
"field_id": field["id"],
|
| 471 |
+
"field_label": field["label"],
|
| 472 |
+
"issue_type": "UNUSUAL_WORD_ORDER",
|
| 473 |
+
"css_class": "syntax",
|
| 474 |
+
"severity": "LOW",
|
| 475 |
+
"word": finding.sentence,
|
| 476 |
+
"start": finding.start,
|
| 477 |
+
"end": finding.end,
|
| 478 |
+
"label": "Urutan Kata",
|
| 479 |
+
"suggestion": None,
|
| 480 |
+
"reason": finding.reason,
|
| 481 |
+
"action": "select",
|
| 482 |
+
"replacement": None,
|
| 483 |
+
"confidence": finding.confidence,
|
| 484 |
+
}
|
| 485 |
+
|
| 486 |
+
|
| 487 |
def _ner_issue(entity, field: dict) -> dict:
|
| 488 |
"""Konversi NEREntity β dict issue standar."""
|
| 489 |
return {
|
|
|
|
| 593 |
except Exception as e:
|
| 594 |
logger.warning("Special Char error [%s]: %s", f["id"], e)
|
| 595 |
|
| 596 |
+
# Syntax checker hanya untuk field naratif (kalimat utuh). Field seperti
|
| 597 |
+
# tone/outputFormat/example sengaja bisa berupa frasa, jadi dilewati.
|
| 598 |
+
if _syntax and f["id"] in _SYNTAX_FIELDS:
|
| 599 |
+
try:
|
| 600 |
+
for finding in _syntax.check(text, language=language):
|
| 601 |
+
issues.append(_syntax_issue(finding, f))
|
| 602 |
+
except Exception as e:
|
| 603 |
+
logger.warning("Syntax error [%s]: %s", f["id"], e)
|
| 604 |
+
|
| 605 |
return issues
|
| 606 |
|
| 607 |
|
|
|
|
| 690 |
"profanity": _prof is not None,
|
| 691 |
"filler": _filler is not None,
|
| 692 |
"special_char": _special_char is not None,
|
| 693 |
+
"syntax": _syntax is not None,
|
| 694 |
},
|
| 695 |
}
|
| 696 |
|
|
|
|
| 794 |
"profanity": _prof is not None,
|
| 795 |
"filler": _filler is not None,
|
| 796 |
"special_char": _special_char is not None,
|
| 797 |
+
"syntax": _syntax is not None,
|
| 798 |
},
|
| 799 |
})
|
| 800 |
elif self.path.startswith("/api/"):
|
|
|
|
| 833 |
|
| 834 |
# ββ Entry Point ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 835 |
|
| 836 |
+
def _start_subservers(host: str, no_resp_ml: bool = False, no_ner: bool = False,
|
| 837 |
+
no_ner_ml: bool = False, no_syntax_ml: bool = False) -> None:
|
| 838 |
"""
|
| 839 |
Jalankan semua server test individual sebagai proses latar belakang.
|
| 840 |
|
|
|
|
| 846 |
error diabaikan dengan pesan peringatan.
|
| 847 |
|
| 848 |
Args:
|
| 849 |
+
host: Host yang digunakan (sama dengan pipeline server).
|
| 850 |
+
no_resp_ml: Teruskan flag --no-resp-ml ke responsible_server.
|
| 851 |
+
no_ner: Jangan jalankan sub-server NER.
|
| 852 |
+
no_ner_ml: Jangan jalankan sub-server NER karena server ini selalu memuat model ML.
|
| 853 |
+
no_syntax_ml: Jangan jalankan sub-server Syntax karena server ini selalu memuat model ML.
|
| 854 |
"""
|
| 855 |
global _sub_processes
|
| 856 |
|
|
|
|
| 868 |
else:
|
| 869 |
logger.info("Sub-server NER dilewati (mode cepat / NER ML nonaktif).")
|
| 870 |
|
| 871 |
+
if not no_syntax_ml:
|
| 872 |
+
configs.append(("syntax/syntax_server.py", 8008, "Syntax", []))
|
| 873 |
+
else:
|
| 874 |
+
logger.info("Sub-server Syntax dilewati (mode cepat / Syntax ML nonaktif).")
|
| 875 |
+
|
| 876 |
logger.info("β" * 56)
|
| 877 |
logger.info("Memulai sub-server halaman testβ¦")
|
| 878 |
|
|
|
|
| 912 |
6. Filler Checker (ringan, berbasis regex)
|
| 913 |
7. Special Char Detector (ringan, berbasis karakter)
|
| 914 |
"""
|
| 915 |
+
global _pii, _wq, _resp, _ner, _prof, _filler, _special_char
|
| 916 |
+
global _no_resp_ml, _no_ner, _no_ner_ml, _no_syntax_ml
|
| 917 |
|
| 918 |
# Railway (dan platform cloud lain) mengeset $PORT secara otomatis.
|
| 919 |
# Fallback ke 8000 untuk development lokal.
|
|
|
|
| 932 |
parser.add_argument("--no-ner-ml", action="store_true",
|
| 933 |
help="NER hanya rule-based (regex + nama), tanpa model transformer (~600 MB). "
|
| 934 |
"Lebih cepat; masih mendeteksi lembaga, tanggal, nama orang, dll.")
|
| 935 |
+
parser.add_argument("--no-syntax-ml", action="store_true",
|
| 936 |
+
help="Nonaktifkan Syntax Checker (deteksi urutan kata, model IndoBERT ~420 MB).")
|
| 937 |
parser.add_argument("--fast", action="store_true",
|
| 938 |
+
help="Mode cepat: setara --no-resp-ml --no-ner-ml --no-syntax-ml. "
|
| 939 |
+
"Semua detektor ringan aktif, tanpa model berat.")
|
| 940 |
args = parser.parse_args()
|
| 941 |
+
_no_resp_ml = args.no_resp_ml or args.fast
|
| 942 |
+
_no_ner = args.no_ner
|
| 943 |
+
_no_ner_ml = args.no_ner_ml or args.fast
|
| 944 |
+
_no_syntax_ml = args.no_syntax_ml or args.fast
|
| 945 |
|
| 946 |
# ββ Load semua detektor di background thread ββββββββββββββββββββββββββββββ
|
| 947 |
# Server langsung mulai listen agar healthcheck Railway tidak timeout.
|
| 948 |
# /api/status mengembalikan ready:false selama loading, ready:true setelah selesai.
|
| 949 |
def _load_detectors():
|
| 950 |
+
global _pii, _wq, _resp, _ner, _prof, _filler, _special_char, _syntax
|
| 951 |
|
| 952 |
# 1. PII Detector
|
| 953 |
logger.info("Memuat PII Detector...")
|
|
|
|
| 1015 |
_special_char = SpecialCharDetector()
|
| 1016 |
logger.info("Special Char Detector siap.")
|
| 1017 |
|
| 1018 |
+
# 8. Syntax Checker (deteksi urutan kata, model IndoBERT ~420 MB)
|
| 1019 |
+
if _no_syntax_ml:
|
| 1020 |
+
logger.info("Syntax Checker dinonaktifkan (--no-syntax-ml / --fast).")
|
| 1021 |
+
_syntax = None
|
| 1022 |
+
else:
|
| 1023 |
+
logger.info("Memuat Syntax Checker (model IndoBERT, proses ini mungkin memerlukan waktu)...")
|
| 1024 |
+
try:
|
| 1025 |
+
from syntax.syntax_checker import SyntaxChecker
|
| 1026 |
+
_syntax = SyntaxChecker(use_ml=True)
|
| 1027 |
+
_syntax.load()
|
| 1028 |
+
logger.info("Syntax Checker siap (ML aktif: %s).", _syntax.ml_active)
|
| 1029 |
+
except Exception as e:
|
| 1030 |
+
logger.error("Syntax Checker gagal dimuat: %s β lanjut tanpa deteksi urutan kata.", e)
|
| 1031 |
+
_syntax = None
|
| 1032 |
+
|
| 1033 |
+
# 9. Hot-reload watcher
|
| 1034 |
DetectorWatcher(_SRC_DIR).start()
|
| 1035 |
|
| 1036 |
+
# 10. Sub-server halaman test
|
| 1037 |
+
_start_subservers(args.host, no_resp_ml=_no_resp_ml, no_ner=_no_ner,
|
| 1038 |
+
no_ner_ml=_no_ner_ml, no_syntax_ml=_no_syntax_ml)
|
| 1039 |
|
| 1040 |
logger.info("Semua detektor siap. Pipeline aktif penuh.")
|
| 1041 |
_detectors_loaded.set()
|
src/filler/filler_checker.py
CHANGED
|
@@ -26,6 +26,7 @@ from __future__ import annotations
|
|
| 26 |
|
| 27 |
import logging
|
| 28 |
import re
|
|
|
|
| 29 |
from dataclasses import dataclass
|
| 30 |
|
| 31 |
logger = logging.getLogger(__name__)
|
|
@@ -59,11 +60,26 @@ class FillerFinding:
|
|
| 59 |
|
| 60 |
_F = re.IGNORECASE | re.UNICODE
|
| 61 |
|
| 62 |
-
|
|
|
|
| 63 |
|
| 64 |
|
| 65 |
-
def _add(
|
| 66 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
|
| 68 |
|
| 69 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -78,6 +94,8 @@ _add(
|
|
| 78 |
"Sapaan langsung ke nama AI tidak diperlukan β AI memahami konteks tanpa "
|
| 79 |
"disapa. Menghapus frasa ini menghemat token dan membuat prompt lebih ringkas.",
|
| 80 |
0.95,
|
|
|
|
|
|
|
| 81 |
)
|
| 82 |
|
| 83 |
_add(
|
|
@@ -86,6 +104,7 @@ _add(
|
|
| 86 |
"GREETING_AI",
|
| 87 |
"Sapaan formal ke AI tidak diperlukan. Langsung tuliskan permintaan Anda.",
|
| 88 |
0.90,
|
|
|
|
| 89 |
)
|
| 90 |
|
| 91 |
_add(
|
|
@@ -94,6 +113,7 @@ _add(
|
|
| 94 |
"GREETING_AI",
|
| 95 |
"Kata sapaan ini tidak diperlukan. Langsung tuliskan permintaan Anda.",
|
| 96 |
0.87,
|
|
|
|
| 97 |
)
|
| 98 |
|
| 99 |
# AI baru: Grok, Perplexity, DeepSeek, Qwen, Falcon, Phi, dll.
|
|
@@ -105,6 +125,7 @@ _add(
|
|
| 105 |
"GREETING_AI",
|
| 106 |
"Sapaan langsung ke nama AI tidak diperlukan β langsung tuliskan permintaan Anda.",
|
| 107 |
0.93,
|
|
|
|
| 108 |
)
|
| 109 |
|
| 110 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -118,6 +139,8 @@ _add(
|
|
| 118 |
"Sapaan ini berdiri sendiri tanpa permintaan atau konteks apapun. "
|
| 119 |
"AI tidak memerlukan sapaan β langsung tuliskan pertanyaan atau instruksi Anda.",
|
| 120 |
0.93,
|
|
|
|
|
|
|
| 121 |
)
|
| 122 |
|
| 123 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -136,6 +159,8 @@ _add(
|
|
| 136 |
"Ucapan terima kasih tidak menambah informasi ke prompt. "
|
| 137 |
"AI tidak memiliki perasaan yang perlu dijaga β cukup tuliskan permintaan secara langsung.",
|
| 138 |
0.90,
|
|
|
|
|
|
|
| 139 |
)
|
| 140 |
|
| 141 |
# Terima kasih di tengah prompt (sebelum/sesudah koma)
|
|
@@ -159,6 +184,8 @@ _add(
|
|
| 159 |
"Permintaan maaf tidak perlu dalam prompt. AI tidak terganggu oleh pertanyaan "
|
| 160 |
"β justru itulah fungsinya. Langsung tuliskan permintaan Anda.",
|
| 161 |
0.91,
|
|
|
|
|
|
|
| 162 |
)
|
| 163 |
|
| 164 |
_add(
|
|
@@ -173,6 +200,17 @@ _add(
|
|
| 173 |
"APOLOGY",
|
| 174 |
"Permintaan maaf tidak diperlukan dalam prompt AI. Langsung tuliskan permintaan Anda.",
|
| 175 |
0.90,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
)
|
| 177 |
|
| 178 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -238,6 +276,7 @@ _add(
|
|
| 238 |
"EMPTY_OPENER",
|
| 239 |
"Pembuka permintaan bantuan ini tidak menambah konteks. Langsung tuliskan tugas yang ingin dikerjakan.",
|
| 240 |
0.86,
|
|
|
|
| 241 |
)
|
| 242 |
|
| 243 |
_add(
|
|
@@ -245,6 +284,52 @@ _add(
|
|
| 245 |
"EMPTY_OPENER",
|
| 246 |
"Frasa ini terlalu umum. Jelaskan tugas atau konteksnya secara langsung.",
|
| 247 |
0.84,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 248 |
)
|
| 249 |
|
| 250 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -292,6 +377,39 @@ _add(
|
|
| 292 |
"EMOTIONAL_FILLER",
|
| 293 |
"Frasa harapan ini tidak menambah informasi. Langsung tuliskan instruksi atau pertanyaan Anda.",
|
| 294 |
0.84,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 295 |
)
|
| 296 |
|
| 297 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -341,6 +459,27 @@ _add(
|
|
| 341 |
"Referensi 'ini/itu' tidak jelas tanpa konteks yang disebutkan sebelumnya. "
|
| 342 |
"Tuliskan secara eksplisit apa topik yang dimaksud.",
|
| 343 |
0.80,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 344 |
)
|
| 345 |
|
| 346 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -447,6 +586,7 @@ _add(
|
|
| 447 |
"UNNECESSARY_PREAMBLE",
|
| 448 |
"Preamble ini tidak diperlukan. Langsung tuliskan pertanyaan Anda.",
|
| 449 |
0.86,
|
|
|
|
| 450 |
)
|
| 451 |
|
| 452 |
_add(
|
|
@@ -454,6 +594,7 @@ _add(
|
|
| 454 |
"UNNECESSARY_PREAMBLE",
|
| 455 |
"Deklarasi bahwa pertanyaan singkat tidak menambah informasi. Langsung ajukan pertanyaannya.",
|
| 456 |
0.84,
|
|
|
|
| 457 |
)
|
| 458 |
|
| 459 |
_add(
|
|
@@ -461,6 +602,36 @@ _add(
|
|
| 461 |
"UNNECESSARY_PREAMBLE",
|
| 462 |
"Frasa pembuka ini dapat dipersingkat. Langsung tuliskan pertanyaan atau instruksi Anda.",
|
| 463 |
0.83,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 464 |
)
|
| 465 |
|
| 466 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -476,6 +647,8 @@ _add(
|
|
| 476 |
"Bunyi ragu 'hmm' tidak menambah informasi. Hilangkan dan langsung tuliskan "
|
| 477 |
"permintaan Anda.",
|
| 478 |
0.92,
|
|
|
|
|
|
|
| 479 |
)
|
| 480 |
|
| 481 |
_add(
|
|
@@ -483,6 +656,18 @@ _add(
|
|
| 483 |
"HESITATION",
|
| 484 |
"Bunyi ragu ini tidak diperlukan dalam prompt. Langsung tuliskan instruksi Anda.",
|
| 485 |
0.90,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 486 |
)
|
| 487 |
|
| 488 |
_add(
|
|
@@ -532,12 +717,33 @@ _ML_LABEL_TO_CAT: dict[str, str | None] = {
|
|
| 532 |
"instruksi yang spesifik dan bermakna": None, # safe
|
| 533 |
}
|
| 534 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 535 |
_ML_REASON: dict[str, str] = {
|
| 536 |
"GREETING_AI": "Sapaan atau basa-basi terdeteksi. AI tidak memerlukan pembuka semacam ini β langsung tuliskan permintaan Anda.",
|
| 537 |
"EMPTY_OPENER": "Permintaan bantuan terlalu umum. Jelaskan secara spesifik apa yang ingin dibantu.",
|
| 538 |
"VAGUE_REFERENCE": "Teks mengandung referensi yang ambigu. Jelaskan secara eksplisit apa yang dimaksud.",
|
| 539 |
}
|
| 540 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 541 |
|
| 542 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 543 |
# Kelas Utama
|
|
@@ -569,6 +775,9 @@ class FillerChecker:
|
|
| 569 |
"""
|
| 570 |
self._use_ml = use_ml and _TRANSFORMERS_OK
|
| 571 |
self._ml_pipe = None
|
|
|
|
|
|
|
|
|
|
| 572 |
self._loaded = False
|
| 573 |
|
| 574 |
def load(self) -> bool:
|
|
@@ -588,12 +797,15 @@ class FillerChecker:
|
|
| 588 |
if not self._use_ml:
|
| 589 |
return True
|
| 590 |
|
| 591 |
-
# Coba pinjam pipeline dari ResponsibleChecker (zero memory overhead)
|
|
|
|
|
|
|
| 592 |
try:
|
| 593 |
from responsible.responsible_checker import get_checker as _get_resp
|
| 594 |
resp = _get_resp(load=False)
|
| 595 |
if resp.ml_active:
|
| 596 |
-
self._ml_pipe = resp.
|
|
|
|
| 597 |
logger.info("Filler ML Layer 2: meminjam pipeline ResponsibleChecker.")
|
| 598 |
return True
|
| 599 |
except Exception:
|
|
@@ -638,9 +850,10 @@ class FillerChecker:
|
|
| 638 |
|
| 639 |
findings: list[FillerFinding] = []
|
| 640 |
seen_spans: set[tuple[int, int]] = set()
|
|
|
|
| 641 |
|
| 642 |
# ββ Layer 1: Regex ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 643 |
-
for pattern, category,
|
| 644 |
for m in pattern.finditer(text):
|
| 645 |
start, end = m.start(), m.end()
|
| 646 |
|
|
@@ -654,22 +867,28 @@ class FillerChecker:
|
|
| 654 |
|
| 655 |
seen_spans.add((start, end))
|
| 656 |
findings.append(FillerFinding(
|
| 657 |
-
word=word
|
| 658 |
start=start,
|
| 659 |
end=end,
|
| 660 |
category=category,
|
| 661 |
-
reason=
|
| 662 |
confidence=conf,
|
| 663 |
))
|
| 664 |
|
| 665 |
-
# ββ Layer 2: ML zero-shot
|
| 666 |
-
|
| 667 |
-
|
| 668 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 669 |
|
| 670 |
return sorted(findings, key=lambda f: f.start)
|
| 671 |
|
| 672 |
-
def _check_ml(self, text: str) -> list[FillerFinding]:
|
| 673 |
"""
|
| 674 |
Jalankan zero-shot classification pada teks penuh.
|
| 675 |
|
|
@@ -677,41 +896,52 @@ class FillerChecker:
|
|
| 677 |
bahasa campuran, atau ekspresi tidak lazim.
|
| 678 |
Hanya dipanggil ketika Layer 1 tidak menemukan apapun.
|
| 679 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 680 |
try:
|
| 681 |
-
|
| 682 |
-
|
| 683 |
-
|
| 684 |
-
|
| 685 |
-
|
| 686 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 687 |
except Exception as exc:
|
| 688 |
logger.debug("Filler ML gagal: %s", exc)
|
| 689 |
return []
|
| 690 |
|
| 691 |
-
|
| 692 |
-
|
| 693 |
-
|
| 694 |
-
|
| 695 |
-
|
| 696 |
-
|
| 697 |
-
|
| 698 |
-
|
| 699 |
-
|
| 700 |
-
|
| 701 |
-
|
| 702 |
-
|
| 703 |
-
|
| 704 |
-
|
| 705 |
-
|
| 706 |
-
|
| 707 |
-
|
| 708 |
-
|
| 709 |
-
|
| 710 |
-
|
| 711 |
-
)
|
| 712 |
-
|
| 713 |
-
|
| 714 |
-
|
|
|
|
| 715 |
|
| 716 |
@property
|
| 717 |
def pattern_count(self) -> int:
|
|
|
|
| 26 |
|
| 27 |
import logging
|
| 28 |
import re
|
| 29 |
+
import threading
|
| 30 |
from dataclasses import dataclass
|
| 31 |
|
| 32 |
logger = logging.getLogger(__name__)
|
|
|
|
| 60 |
|
| 61 |
_F = re.IGNORECASE | re.UNICODE
|
| 62 |
|
| 63 |
+
# Tuple: (pola, kategori, reason_id, confidence, reason_en)
|
| 64 |
+
_PATTERNS: list[tuple[re.Pattern[str], str, str, float, str]] = []
|
| 65 |
|
| 66 |
|
| 67 |
+
def _add(
|
| 68 |
+
pattern: str,
|
| 69 |
+
category: str,
|
| 70 |
+
reason: str,
|
| 71 |
+
conf: float = 0.88,
|
| 72 |
+
reason_en: str = "",
|
| 73 |
+
) -> None:
|
| 74 |
+
"""
|
| 75 |
+
Daftarkan satu pola filler.
|
| 76 |
+
|
| 77 |
+
Args:
|
| 78 |
+
reason: Penjelasan Bahasa Indonesia (dipakai untuk teks id/mixed/unknown).
|
| 79 |
+
reason_en: Penjelasan Bahasa Inggris (dipakai saat language == "en").
|
| 80 |
+
Jika kosong, fallback ke `reason`.
|
| 81 |
+
"""
|
| 82 |
+
_PATTERNS.append((re.compile(pattern, _F), category, reason, conf, reason_en or reason))
|
| 83 |
|
| 84 |
|
| 85 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 94 |
"Sapaan langsung ke nama AI tidak diperlukan β AI memahami konteks tanpa "
|
| 95 |
"disapa. Menghapus frasa ini menghemat token dan membuat prompt lebih ringkas.",
|
| 96 |
0.95,
|
| 97 |
+
reason_en="Greeting the AI by name is unnecessary β it understands context "
|
| 98 |
+
"without being addressed. Removing this phrase saves tokens and keeps the prompt concise.",
|
| 99 |
)
|
| 100 |
|
| 101 |
_add(
|
|
|
|
| 104 |
"GREETING_AI",
|
| 105 |
"Sapaan formal ke AI tidak diperlukan. Langsung tuliskan permintaan Anda.",
|
| 106 |
0.90,
|
| 107 |
+
reason_en="A formal salutation to the AI is unnecessary. State your request directly.",
|
| 108 |
)
|
| 109 |
|
| 110 |
_add(
|
|
|
|
| 113 |
"GREETING_AI",
|
| 114 |
"Kata sapaan ini tidak diperlukan. Langsung tuliskan permintaan Anda.",
|
| 115 |
0.87,
|
| 116 |
+
reason_en="This greeting is unnecessary. State your request directly.",
|
| 117 |
)
|
| 118 |
|
| 119 |
# AI baru: Grok, Perplexity, DeepSeek, Qwen, Falcon, Phi, dll.
|
|
|
|
| 125 |
"GREETING_AI",
|
| 126 |
"Sapaan langsung ke nama AI tidak diperlukan β langsung tuliskan permintaan Anda.",
|
| 127 |
0.93,
|
| 128 |
+
reason_en="Greeting the AI by name is unnecessary β state your request directly.",
|
| 129 |
)
|
| 130 |
|
| 131 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 139 |
"Sapaan ini berdiri sendiri tanpa permintaan atau konteks apapun. "
|
| 140 |
"AI tidak memerlukan sapaan β langsung tuliskan pertanyaan atau instruksi Anda.",
|
| 141 |
0.93,
|
| 142 |
+
reason_en="This greeting stands alone without any request or context. The AI "
|
| 143 |
+
"does not need a greeting β write your question or instruction directly.",
|
| 144 |
)
|
| 145 |
|
| 146 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 159 |
"Ucapan terima kasih tidak menambah informasi ke prompt. "
|
| 160 |
"AI tidak memiliki perasaan yang perlu dijaga β cukup tuliskan permintaan secara langsung.",
|
| 161 |
0.90,
|
| 162 |
+
reason_en="Thanking the AI adds no information to the prompt. The AI has no "
|
| 163 |
+
"feelings to spare β simply state your request directly.",
|
| 164 |
)
|
| 165 |
|
| 166 |
# Terima kasih di tengah prompt (sebelum/sesudah koma)
|
|
|
|
| 184 |
"Permintaan maaf tidak perlu dalam prompt. AI tidak terganggu oleh pertanyaan "
|
| 185 |
"β justru itulah fungsinya. Langsung tuliskan permintaan Anda.",
|
| 186 |
0.91,
|
| 187 |
+
reason_en="An apology is unnecessary in a prompt. The AI is not bothered by "
|
| 188 |
+
"questions β that is exactly its purpose. State your request directly.",
|
| 189 |
)
|
| 190 |
|
| 191 |
_add(
|
|
|
|
| 200 |
"APOLOGY",
|
| 201 |
"Permintaan maaf tidak diperlukan dalam prompt AI. Langsung tuliskan permintaan Anda.",
|
| 202 |
0.90,
|
| 203 |
+
reason_en="An apology is unnecessary in an AI prompt. State your request directly.",
|
| 204 |
+
)
|
| 205 |
+
|
| 206 |
+
# "I'm sorry / I apologize" β pembuka maaf Bahasa Inggris
|
| 207 |
+
_add(
|
| 208 |
+
r"\bi\s+(?:'m\s+|am\s+)?(?:sorry|apolog(?:ize|ise))\s+"
|
| 209 |
+
r'(?:for|to|about|if|that)\b',
|
| 210 |
+
"APOLOGY",
|
| 211 |
+
"Permintaan maaf tidak diperlukan dalam prompt AI. Langsung tuliskan permintaan Anda.",
|
| 212 |
+
0.85,
|
| 213 |
+
reason_en="An apology is unnecessary in an AI prompt. State your request directly.",
|
| 214 |
)
|
| 215 |
|
| 216 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 276 |
"EMPTY_OPENER",
|
| 277 |
"Pembuka permintaan bantuan ini tidak menambah konteks. Langsung tuliskan tugas yang ingin dikerjakan.",
|
| 278 |
0.86,
|
| 279 |
+
reason_en="This help-request opener adds no context. State the task you want done directly.",
|
| 280 |
)
|
| 281 |
|
| 282 |
_add(
|
|
|
|
| 284 |
"EMPTY_OPENER",
|
| 285 |
"Frasa ini terlalu umum. Jelaskan tugas atau konteksnya secara langsung.",
|
| 286 |
0.84,
|
| 287 |
+
reason_en="This phrase is too general. Describe the task or context directly.",
|
| 288 |
+
)
|
| 289 |
+
|
| 290 |
+
# "I was wondering if/whether ..." sebagai pembuka basa-basi
|
| 291 |
+
_add(
|
| 292 |
+
r"\bi\s+was\s+wondering\s+(?:if|whether)\b",
|
| 293 |
+
"EMPTY_OPENER",
|
| 294 |
+
"Pembuka ini dapat dipersingkat. Langsung tuliskan pertanyaan atau permintaan Anda.",
|
| 295 |
+
0.83,
|
| 296 |
+
reason_en="This opener can be trimmed. State your question or request directly.",
|
| 297 |
+
)
|
| 298 |
+
|
| 299 |
+
# "Would it be possible (for you) to ..."
|
| 300 |
+
_add(
|
| 301 |
+
r"\bwould\s+it\s+be\s+possible\s+(?:for\s+(?:you|me)\s+)?to\b",
|
| 302 |
+
"EMPTY_OPENER",
|
| 303 |
+
"Frasa pembuka ini membuang token. Langsung tuliskan apa yang Anda inginkan.",
|
| 304 |
+
0.82,
|
| 305 |
+
reason_en="This opener wastes tokens. State directly what you want.",
|
| 306 |
+
)
|
| 307 |
+
|
| 308 |
+
# "I'd like to ask (you) ..."
|
| 309 |
+
_add(
|
| 310 |
+
r"\bi(?:'d|\s+would)\s+like\s+to\s+ask\s+(?:you\s+)?\b",
|
| 311 |
+
"EMPTY_OPENER",
|
| 312 |
+
"Deklarasi 'ingin bertanya' tidak diperlukan. Langsung ajukan pertanyaannya.",
|
| 313 |
+
0.83,
|
| 314 |
+
reason_en="Declaring that you'd like to ask is unnecessary. Just ask the question.",
|
| 315 |
+
)
|
| 316 |
+
|
| 317 |
+
# "I'd appreciate it if you could/would ..."
|
| 318 |
+
_add(
|
| 319 |
+
r"\bi(?:'d|\s+would)\s+appreciate\s+it\s+if\s+you\s+(?:could|would)\b",
|
| 320 |
+
"EMPTY_OPENER",
|
| 321 |
+
"Frasa kesopanan ini membuang token. Langsung tuliskan permintaan Anda.",
|
| 322 |
+
0.82,
|
| 323 |
+
reason_en="This politeness phrase wastes tokens. State your request directly.",
|
| 324 |
+
)
|
| 325 |
+
|
| 326 |
+
# "If you could (please) ..." sebagai pembuka
|
| 327 |
+
_add(
|
| 328 |
+
r"(?:^|(?<=[.!?\n]))\s*if\s+you\s+(?:could|would|can)\s+(?:please\s+)?",
|
| 329 |
+
"EMPTY_OPENER",
|
| 330 |
+
"Pembuka kondisional ini tidak diperlukan. Langsung tuliskan instruksi Anda.",
|
| 331 |
+
0.80,
|
| 332 |
+
reason_en="This conditional opener is unnecessary. State your instruction directly.",
|
| 333 |
)
|
| 334 |
|
| 335 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 377 |
"EMOTIONAL_FILLER",
|
| 378 |
"Frasa harapan ini tidak menambah informasi. Langsung tuliskan instruksi atau pertanyaan Anda.",
|
| 379 |
0.84,
|
| 380 |
+
reason_en="This expression of hope adds no information. State your instruction or question directly.",
|
| 381 |
+
)
|
| 382 |
+
|
| 383 |
+
# "You're such a great/amazing AI" β sanjungan Bahasa Inggris
|
| 384 |
+
_add(
|
| 385 |
+
r"\byou(?:'re|\s+are)\s+(?:such\s+)?(?:a\s+|an\s+)?"
|
| 386 |
+
r"(?:great|amazing|wonderful|fantastic|brilliant|incredible|awesome|smart|intelligent|the\s+best)\s+"
|
| 387 |
+
r"(?:AI|assistant|model|chatbot|bot)\b",
|
| 388 |
+
"EMOTIONAL_FILLER",
|
| 389 |
+
"Sanjungan ke AI tidak mempengaruhi kualitas jawaban. "
|
| 390 |
+
"Gunakan ruang prompt untuk informasi yang benar-benar relevan.",
|
| 391 |
+
0.85,
|
| 392 |
+
reason_en="Flattering the AI does not affect answer quality. Use the prompt space "
|
| 393 |
+
"for information that actually matters.",
|
| 394 |
+
)
|
| 395 |
+
|
| 396 |
+
# "I'm sure/confident you can help/do this" β pernyataan kepercayaan Bahasa Inggris
|
| 397 |
+
_add(
|
| 398 |
+
r"\bi(?:'m|\s+am)\s+(?:sure|confident|certain)\s+(?:that\s+)?you\s+can\s+"
|
| 399 |
+
r"(?:help|do\s+this|handle\s+this|figure\s+this\s+out)\b",
|
| 400 |
+
"EMOTIONAL_FILLER",
|
| 401 |
+
"Frasa kepercayaan ini tidak menambah informasi ke prompt. Langsung tuliskan permintaan Anda.",
|
| 402 |
+
0.84,
|
| 403 |
+
reason_en="This statement of confidence adds no information to the prompt. State your request directly.",
|
| 404 |
+
)
|
| 405 |
+
|
| 406 |
+
# "I know you can do this/help" β penyemangat Bahasa Inggris
|
| 407 |
+
_add(
|
| 408 |
+
r"\bi\s+know\s+you\s+can\s+(?:do\s+this|help|figure\s+this\s+out|handle\s+(?:this|it))\b",
|
| 409 |
+
"EMOTIONAL_FILLER",
|
| 410 |
+
"Frasa penyemangat ini tidak menambah informasi. Langsung tuliskan permintaan Anda.",
|
| 411 |
+
0.83,
|
| 412 |
+
reason_en="This encouragement adds no information. State your request directly.",
|
| 413 |
)
|
| 414 |
|
| 415 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 459 |
"Referensi 'ini/itu' tidak jelas tanpa konteks yang disebutkan sebelumnya. "
|
| 460 |
"Tuliskan secara eksplisit apa topik yang dimaksud.",
|
| 461 |
0.80,
|
| 462 |
+
reason_en="The reference 'this/that' is unclear without prior context. "
|
| 463 |
+
"State explicitly what topic you mean.",
|
| 464 |
+
)
|
| 465 |
+
|
| 466 |
+
# "you know", "i mean", "like that", "kind of", "sort of" β pengisi vague Bahasa Inggris
|
| 467 |
+
_add(
|
| 468 |
+
r'\b(?:you\s+know|i\s+mean|like\s+(?:this|that)|kind\s+of|sort\s+of|'
|
| 469 |
+
r'that\s+(?:sort|kind|type)\s+of\s+thing)\b',
|
| 470 |
+
"VAGUE_REFERENCE",
|
| 471 |
+
"Frasa pengisi ini tidak jelas bagi AI. Jelaskan secara eksplisit apa yang dimaksud.",
|
| 472 |
+
0.80,
|
| 473 |
+
reason_en="This filler phrase is unclear to the AI. State explicitly what you mean.",
|
| 474 |
+
)
|
| 475 |
+
|
| 476 |
+
# "and stuff", "and things", "and so on", "etc." di akhir kalimat β penutup vague
|
| 477 |
+
_add(
|
| 478 |
+
r'\b(?:and\s+(?:stuff|things|whatnot|so\s+on|so\s+forth)|et\s+cetera|etc\.?)\s*[,!.]?\s*$',
|
| 479 |
+
"VAGUE_REFERENCE",
|
| 480 |
+
"Penutup terbuka ini membuat permintaan tidak jelas. Sebutkan secara lengkap apa yang Anda maksud.",
|
| 481 |
+
0.80,
|
| 482 |
+
reason_en="This open-ended ending makes the request vague. Spell out fully what you mean.",
|
| 483 |
)
|
| 484 |
|
| 485 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 586 |
"UNNECESSARY_PREAMBLE",
|
| 587 |
"Preamble ini tidak diperlukan. Langsung tuliskan pertanyaan Anda.",
|
| 588 |
0.86,
|
| 589 |
+
reason_en="This preamble is unnecessary. Just write your question directly.",
|
| 590 |
)
|
| 591 |
|
| 592 |
_add(
|
|
|
|
| 594 |
"UNNECESSARY_PREAMBLE",
|
| 595 |
"Deklarasi bahwa pertanyaan singkat tidak menambah informasi. Langsung ajukan pertanyaannya.",
|
| 596 |
0.84,
|
| 597 |
+
reason_en="Announcing that the question is short adds no information. Just ask it.",
|
| 598 |
)
|
| 599 |
|
| 600 |
_add(
|
|
|
|
| 602 |
"UNNECESSARY_PREAMBLE",
|
| 603 |
"Frasa pembuka ini dapat dipersingkat. Langsung tuliskan pertanyaan atau instruksi Anda.",
|
| 604 |
0.83,
|
| 605 |
+
reason_en="This opener can be trimmed. Write your question or instruction directly.",
|
| 606 |
+
)
|
| 607 |
+
|
| 608 |
+
# "Allow me to ask / let me ask" β preamble formal Bahasa Inggris
|
| 609 |
+
_add(
|
| 610 |
+
r'\b(?:allow|let)\s+me\s+(?:to\s+)?(?:ask|explain|clarify|say)\b',
|
| 611 |
+
"UNNECESSARY_PREAMBLE",
|
| 612 |
+
"Frasa formal ini tidak diperlukan. Langsung tuliskan pertanyaan atau instruksi Anda.",
|
| 613 |
+
0.83,
|
| 614 |
+
reason_en="This formal phrase is unnecessary. Write your question or instruction directly.",
|
| 615 |
+
)
|
| 616 |
+
|
| 617 |
+
# "I have a question/query/inquiry" β deklarasi punya pertanyaan
|
| 618 |
+
_add(
|
| 619 |
+
r'\bi\s+have\s+(?:a|one|just\s+a)\s+'
|
| 620 |
+
r'(?:quick\s+|small\s+|simple\s+|brief\s+)?(?:question|query|inquiry)\b',
|
| 621 |
+
"UNNECESSARY_PREAMBLE",
|
| 622 |
+
"Deklarasi 'saya punya pertanyaan' tidak diperlukan. Langsung ajukan pertanyaannya.",
|
| 623 |
+
0.84,
|
| 624 |
+
reason_en="Declaring 'I have a question' is unnecessary. Just ask it.",
|
| 625 |
+
)
|
| 626 |
+
|
| 627 |
+
# "What I'd like to know/ask/understand is ..." β preamble panjang
|
| 628 |
+
_add(
|
| 629 |
+
r"\b(?:the\s+question|what)\s+i(?:'d|\s+would)?\s+(?:like\s+to|want\s+to)\s+"
|
| 630 |
+
r"(?:know|ask|understand)\s+is\b",
|
| 631 |
+
"UNNECESSARY_PREAMBLE",
|
| 632 |
+
"Frasa preamble ini membuang token. Langsung tuliskan apa yang ingin Anda ketahui.",
|
| 633 |
+
0.84,
|
| 634 |
+
reason_en="This preamble wastes tokens. State directly what you want to know.",
|
| 635 |
)
|
| 636 |
|
| 637 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 647 |
"Bunyi ragu 'hmm' tidak menambah informasi. Hilangkan dan langsung tuliskan "
|
| 648 |
"permintaan Anda.",
|
| 649 |
0.92,
|
| 650 |
+
reason_en="The hesitation sound 'hmm' adds no information. Remove it and state "
|
| 651 |
+
"your request directly.",
|
| 652 |
)
|
| 653 |
|
| 654 |
_add(
|
|
|
|
| 656 |
"HESITATION",
|
| 657 |
"Bunyi ragu ini tidak diperlukan dalam prompt. Langsung tuliskan instruksi Anda.",
|
| 658 |
0.90,
|
| 659 |
+
reason_en="This hesitation sound is unnecessary in a prompt. State your instruction directly.",
|
| 660 |
+
)
|
| 661 |
+
|
| 662 |
+
# "Well, so / Okay, so / So basically" β transisi percakapan Bahasa Inggris
|
| 663 |
+
_add(
|
| 664 |
+
r'(?:^|(?<=[.!?\n]))\s*(?:well|okay|ok|so)\s*,?\s+(?:so|basically|anyway|um|uh)\b',
|
| 665 |
+
"HESITATION",
|
| 666 |
+
"Transisi percakapan ini tidak diperlukan dalam prompt tertulis. "
|
| 667 |
+
"Langsung tuliskan instruksi atau pertanyaan Anda.",
|
| 668 |
+
0.82,
|
| 669 |
+
reason_en="This conversational transition is unnecessary in a written prompt. "
|
| 670 |
+
"State your instruction or question directly.",
|
| 671 |
)
|
| 672 |
|
| 673 |
_add(
|
|
|
|
| 717 |
"instruksi yang spesifik dan bermakna": None, # safe
|
| 718 |
}
|
| 719 |
|
| 720 |
+
# Label Bahasa Inggris untuk teks Inggris (mDeBERTa bersifat multilingual).
|
| 721 |
+
_ML_LABELS_EN = [
|
| 722 |
+
"an unnecessary greeting or filler phrase", # β GREETING_AI / GREETING_ONLY
|
| 723 |
+
"a vague or overly general request for help", # β EMPTY_OPENER
|
| 724 |
+
"an ambiguous demonstrative reference", # β VAGUE_REFERENCE
|
| 725 |
+
"a specific and meaningful instruction", # β safe (tidak dilaporkan)
|
| 726 |
+
]
|
| 727 |
+
|
| 728 |
+
_ML_LABEL_TO_CAT_EN: dict[str, str | None] = {
|
| 729 |
+
"an unnecessary greeting or filler phrase": "GREETING_AI",
|
| 730 |
+
"a vague or overly general request for help": "EMPTY_OPENER",
|
| 731 |
+
"an ambiguous demonstrative reference": "VAGUE_REFERENCE",
|
| 732 |
+
"a specific and meaningful instruction": None, # safe
|
| 733 |
+
}
|
| 734 |
+
|
| 735 |
_ML_REASON: dict[str, str] = {
|
| 736 |
"GREETING_AI": "Sapaan atau basa-basi terdeteksi. AI tidak memerlukan pembuka semacam ini β langsung tuliskan permintaan Anda.",
|
| 737 |
"EMPTY_OPENER": "Permintaan bantuan terlalu umum. Jelaskan secara spesifik apa yang ingin dibantu.",
|
| 738 |
"VAGUE_REFERENCE": "Teks mengandung referensi yang ambigu. Jelaskan secara eksplisit apa yang dimaksud.",
|
| 739 |
}
|
| 740 |
|
| 741 |
+
_ML_REASON_EN: dict[str, str] = {
|
| 742 |
+
"GREETING_AI": "A greeting or filler phrase was detected. The AI does not need such an opener β state your request directly.",
|
| 743 |
+
"EMPTY_OPENER": "This help request is too general. Describe specifically what you need help with.",
|
| 744 |
+
"VAGUE_REFERENCE": "The text contains an ambiguous reference. State explicitly what you mean.",
|
| 745 |
+
}
|
| 746 |
+
|
| 747 |
|
| 748 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 749 |
# Kelas Utama
|
|
|
|
| 775 |
"""
|
| 776 |
self._use_ml = use_ml and _TRANSFORMERS_OK
|
| 777 |
self._ml_pipe = None
|
| 778 |
+
# Lock inferensi ML. Saat meminjam pipeline ResponsibleChecker, lock-nya
|
| 779 |
+
# ikut dipinjam agar inferensi pada model yang sama tetap terserialisasi.
|
| 780 |
+
self._ml_lock = threading.Lock()
|
| 781 |
self._loaded = False
|
| 782 |
|
| 783 |
def load(self) -> bool:
|
|
|
|
| 797 |
if not self._use_ml:
|
| 798 |
return True
|
| 799 |
|
| 800 |
+
# Coba pinjam pipeline dari ResponsibleChecker (zero memory overhead).
|
| 801 |
+
# Pinjam pula lock-nya: karena model torch sama, inferensi harus dilindungi
|
| 802 |
+
# lock yang sama agar tidak ada dua thread mengaksesnya bersamaan.
|
| 803 |
try:
|
| 804 |
from responsible.responsible_checker import get_checker as _get_resp
|
| 805 |
resp = _get_resp(load=False)
|
| 806 |
if resp.ml_active:
|
| 807 |
+
self._ml_pipe = resp.ml_pipe
|
| 808 |
+
self._ml_lock = resp.ml_lock
|
| 809 |
logger.info("Filler ML Layer 2: meminjam pipeline ResponsibleChecker.")
|
| 810 |
return True
|
| 811 |
except Exception:
|
|
|
|
| 850 |
|
| 851 |
findings: list[FillerFinding] = []
|
| 852 |
seen_spans: set[tuple[int, int]] = set()
|
| 853 |
+
use_en = language == "en"
|
| 854 |
|
| 855 |
# ββ Layer 1: Regex ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 856 |
+
for pattern, category, reason_id, conf, reason_en in _PATTERNS:
|
| 857 |
for m in pattern.finditer(text):
|
| 858 |
start, end = m.start(), m.end()
|
| 859 |
|
|
|
|
| 867 |
|
| 868 |
seen_spans.add((start, end))
|
| 869 |
findings.append(FillerFinding(
|
| 870 |
+
word=word,
|
| 871 |
start=start,
|
| 872 |
end=end,
|
| 873 |
category=category,
|
| 874 |
+
reason=reason_en if use_en else reason_id,
|
| 875 |
confidence=conf,
|
| 876 |
))
|
| 877 |
|
| 878 |
+
# ββ Layer 2: ML zero-shot β DINONAKTIFKAN ββββββββββββββββββββββββββββ
|
| 879 |
+
# Kategori "filler/vague" terlalu kabur secara semantik untuk zero-shot
|
| 880 |
+
# NLI: instruksi yang jelas dan konkret pun sering salah diklasifikasikan
|
| 881 |
+
# sebagai VAGUE_REFERENCE dengan skor tinggi (mis. "jelaskan perbedaan
|
| 882 |
+
# list dan tuple di python" β 0.95), tanpa ambang aman yang memisahkannya
|
| 883 |
+
# dari filler asli. Karena false positive lebih merugikan daripada
|
| 884 |
+
# melewatkan filler halus, Layer 2 dimatikan. Layer 1 (regex) yang presisi
|
| 885 |
+
# tetap menangani pola filler yang terdefinisi jelas.
|
| 886 |
+
# Metode _check_ml dipertahankan (dormant) agar mudah dihidupkan kembali
|
| 887 |
+
# bila tersedia model/label yang lebih sesuai.
|
| 888 |
|
| 889 |
return sorted(findings, key=lambda f: f.start)
|
| 890 |
|
| 891 |
+
def _check_ml(self, text: str, language: str = "id") -> list[FillerFinding]:
|
| 892 |
"""
|
| 893 |
Jalankan zero-shot classification pada teks penuh.
|
| 894 |
|
|
|
|
| 896 |
bahasa campuran, atau ekspresi tidak lazim.
|
| 897 |
Hanya dipanggil ketika Layer 1 tidak menemukan apapun.
|
| 898 |
"""
|
| 899 |
+
use_en = language == "en"
|
| 900 |
+
labels = _ML_LABELS_EN if use_en else _ML_LABELS
|
| 901 |
+
label_to = _ML_LABEL_TO_CAT_EN if use_en else _ML_LABEL_TO_CAT
|
| 902 |
+
reasons = _ML_REASON_EN if use_en else _ML_REASON
|
| 903 |
+
template = "This text contains {}." if use_en else "Teks ini mengandung {}."
|
| 904 |
+
|
| 905 |
try:
|
| 906 |
+
# multi_label=False β label saling berkompetisi (softmax). Label "safe"
|
| 907 |
+
# (instruksi spesifik) ikut bersaing, sehingga instruksi konkret tidak
|
| 908 |
+
# salah ditandai sebagai filler. Lock menyerialkan akses model torch.
|
| 909 |
+
with self._ml_lock:
|
| 910 |
+
result = self._ml_pipe(
|
| 911 |
+
text,
|
| 912 |
+
candidate_labels=labels,
|
| 913 |
+
hypothesis_template=template,
|
| 914 |
+
multi_label=False,
|
| 915 |
+
)
|
| 916 |
except Exception as exc:
|
| 917 |
logger.debug("Filler ML gagal: %s", exc)
|
| 918 |
return []
|
| 919 |
|
| 920 |
+
# Hanya pertimbangkan label pemenang (skor tertinggi).
|
| 921 |
+
top_label = result["labels"][0]
|
| 922 |
+
top_score = result["scores"][0]
|
| 923 |
+
cat = label_to.get(top_label)
|
| 924 |
+
if cat is None or top_score < _ML_THRESHOLD:
|
| 925 |
+
# Pemenang adalah "instruksi spesifik" (aman) atau keyakinan terlalu
|
| 926 |
+
# rendah β tidak ada temuan.
|
| 927 |
+
return []
|
| 928 |
+
|
| 929 |
+
if use_en:
|
| 930 |
+
reason = reasons.get(cat, "This phrase should be reconsidered.")
|
| 931 |
+
reason += f" (detected by ML model, score {top_score:.0%})"
|
| 932 |
+
else:
|
| 933 |
+
reason = reasons.get(cat, "Frasa ini perlu ditinjau kembali.")
|
| 934 |
+
reason += f" (terdeteksi oleh model ML, skor {top_score:.0%})"
|
| 935 |
+
|
| 936 |
+
# Laporkan sebagai temuan pada keseluruhan teks (start=0, end=len).
|
| 937 |
+
return [FillerFinding(
|
| 938 |
+
word=text,
|
| 939 |
+
start=0,
|
| 940 |
+
end=len(text),
|
| 941 |
+
category=cat,
|
| 942 |
+
reason=reason,
|
| 943 |
+
confidence=round(top_score, 3),
|
| 944 |
+
)]
|
| 945 |
|
| 946 |
@property
|
| 947 |
def pattern_count(self) -> int:
|
src/ner/ner_model.py
CHANGED
|
@@ -209,19 +209,22 @@ _PLACES_GLOBAL_PATTERN = regex_alternation(_PLACES_GLOBAL) or (
|
|
| 209 |
|
| 210 |
# ββ Rule-Based Booster βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 211 |
#
|
| 212 |
-
# Regex untuk mendeteksi entitas
|
| 213 |
-
# Format setiap rule: (pola, label_internal, skor_tetap)
|
|
|
|
|
|
|
|
|
|
| 214 |
# Diurutkan dari paling spesifik ke umum; hanya ditambahkan jika tidak tumpang-
|
| 215 |
# tindih dengan hasil ML.
|
| 216 |
|
| 217 |
-
_RULES: list[tuple[re.Pattern[str], str, float]] = [
|
| 218 |
|
| 219 |
-
# English/global company suffixes
|
| 220 |
(re.compile(
|
| 221 |
r'\b(?:[A-Z][A-Za-z0-9&.,\'-]+(?:\s+[A-Z][A-Za-z0-9&.,\'-]+){0,5})\s+'
|
| 222 |
rf'(?:{_ORG_SUFFIX_PATTERN})\b',
|
| 223 |
re.UNICODE,
|
| 224 |
-
), "ORGANISASI", 0.90),
|
| 225 |
|
| 226 |
# English institutions and government bodies
|
| 227 |
(re.compile(
|
|
@@ -229,12 +232,12 @@ _RULES: list[tuple[re.Pattern[str], str, float]] = [
|
|
| 229 |
r'(?:Department|Ministry|Office|Agency|Commission|Bureau)\s+of)\s+'
|
| 230 |
r'[A-Z][A-Za-z&.,\'\s-]{2,60}',
|
| 231 |
re.UNICODE,
|
| 232 |
-
), "ORGANISASI", 0.89),
|
| 233 |
|
| 234 |
-
# Well-known English acronyms
|
| 235 |
(re.compile(
|
| 236 |
rf'\b(?:{_ORG_ACRONYM_PATTERN})\b',
|
| 237 |
-
), "ORGANISASI", 0.94),
|
| 238 |
|
| 239 |
# Badan usaha Indonesia (PT, CV, UD, Firma, Koperasi, dll.)
|
| 240 |
# Setiap kata dalam nama perusahaan harus diawali huruf kapital agar tidak
|
|
@@ -243,14 +246,14 @@ _RULES: list[tuple[re.Pattern[str], str, float]] = [
|
|
| 243 |
r'\b(?:PT\.?\s+|CV\.?\s+|UD\.?\s+|Firma\s+|Koperasi\s+|Perum\s+|Persero\s+)'
|
| 244 |
r'(?:[A-Z][A-Za-z0-9&.,\'-]*(?:\s+[A-Z][A-Za-z0-9&.,\'-]*){0,7})',
|
| 245 |
re.UNICODE,
|
| 246 |
-
), "ORGANISASI", 0.92),
|
| 247 |
|
| 248 |
# Perusahaan terbuka (Tbk/Persero/Perum sebagai suffix)
|
| 249 |
(re.compile(
|
| 250 |
r'\b(?:[A-Z][A-Za-z0-9&.,\'-]*(?:\s+[A-Z][A-Za-z0-9&.,\'-]*){0,7})'
|
| 251 |
r'\s+(?:Tbk\.?|Persero|Perum)\b',
|
| 252 |
re.UNICODE,
|
| 253 |
-
), "ORGANISASI", 0.88),
|
| 254 |
|
| 255 |
# Lembaga pemerintah Indonesia
|
| 256 |
(re.compile(
|
|
@@ -258,41 +261,41 @@ _RULES: list[tuple[re.Pattern[str], str, float]] = [
|
|
| 258 |
r'Badan|Lembaga|Komisi|Direktorat\s+Jenderal?|Ditjen|Bappenas)\s+'
|
| 259 |
r'(?:[A-Z][A-Za-z\s]{2,50})',
|
| 260 |
re.UNICODE,
|
| 261 |
-
), "ORGANISASI", 0.90),
|
| 262 |
|
| 263 |
# Akronim lembaga negara RI yang dikenal luas
|
| 264 |
(re.compile(
|
| 265 |
r'\b(?:KPK|OJK|BI|BPK|BPS|BPOM|BNPB|BPJS|KPU|Bawaslu|MK|MA|'
|
| 266 |
r'DPR|DPD|MPR|Polri|TNI|Kejagung|BNN|PPATK|KemenPU|BRIN)\b',
|
| 267 |
-
), "ORGANISASI", 0.95),
|
| 268 |
|
| 269 |
# Institusi pendidikan
|
| 270 |
(re.compile(
|
| 271 |
r'\b(?:Universitas|Institut|Politeknik|Sekolah\s+Tinggi|'
|
| 272 |
r'STMIK|STIE|STIKES|STKIP|Akademi)\s+(?:[A-Z][A-Za-z\s]{2,50})',
|
| 273 |
re.UNICODE,
|
| 274 |
-
), "ORGANISASI", 0.90),
|
| 275 |
|
| 276 |
# Nama partai politik Indonesia
|
| 277 |
(re.compile(
|
| 278 |
r'\b(?:Partai\s+(?:Golkar|Gerindra|PDI-?P|Demokrat|PKS|PKB|PPP|Nasdem|Hanura|'
|
| 279 |
r'Berkarya|PKPI|Garuda)|PDIP|Golkar|Gerindra)\b',
|
| 280 |
re.IGNORECASE,
|
| 281 |
-
), "ORGANISASI", 0.92),
|
| 282 |
|
| 283 |
# Satuan wilayah administratif
|
| 284 |
(re.compile(
|
| 285 |
r'\b(?:Provinsi|Kabupaten|Kecamatan|Kelurahan|Desa)\s+'
|
| 286 |
r'(?:[A-Z][A-Za-z\s]{2,40})',
|
| 287 |
re.UNICODE,
|
| 288 |
-
), "LOKASI", 0.88),
|
| 289 |
|
| 290 |
# Kota besar Indonesia (dengan prefiks "Kota")
|
| 291 |
(re.compile(
|
| 292 |
r'\bKota\s+(?:Jakarta|Surabaya|Bandung|Medan|Semarang|Makassar|Palembang|'
|
| 293 |
r'Tangerang|Depok|Bekasi|Bogor|Malang|Yogyakarta|Denpasar|Balikpapan)\b',
|
| 294 |
re.IGNORECASE,
|
| 295 |
-
), "LOKASI", 0.95),
|
| 296 |
|
| 297 |
# Undang-undang dan peraturan resmi Indonesia
|
| 298 |
(re.compile(
|
|
@@ -301,13 +304,13 @@ _RULES: list[tuple[re.Pattern[str], str, float]] = [
|
|
| 301 |
r'Keputusan\s+Presiden|Keppres|Perda)\s+'
|
| 302 |
r'(?:No\.?\s*\d+\s*(?:/\s*\d{4})?|Nomor\s+\d+)',
|
| 303 |
re.IGNORECASE,
|
| 304 |
-
), "PERATURAN", 0.93),
|
| 305 |
|
| 306 |
# Mata uang dan nilai moneter Indonesia
|
| 307 |
(re.compile(
|
| 308 |
r'\bRp\.?\s*[\d.,]+(?:\s*(?:ribu|juta|miliar|triliun))?\b',
|
| 309 |
re.IGNORECASE,
|
| 310 |
-
), "UANG", 0.95),
|
| 311 |
|
| 312 |
# Tanggal format formal Indonesia: "15 Maret 2020"
|
| 313 |
(re.compile(
|
|
@@ -316,37 +319,38 @@ _RULES: list[tuple[re.Pattern[str], str, float]] = [
|
|
| 316 |
r'September|Oktober|November|Desember)\s+'
|
| 317 |
r'\d{4}\b',
|
| 318 |
re.IGNORECASE,
|
| 319 |
-
), "TANGGAL", 0.97),
|
| 320 |
|
| 321 |
-
# English date formats: "March 15, 2024"
|
| 322 |
(re.compile(
|
| 323 |
r'\b(?:Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|'
|
| 324 |
r'Jun(?:e)?|Jul(?:y)?|Aug(?:ust)?|Sep(?:tember)?|Oct(?:ober)?|'
|
| 325 |
r'Nov(?:ember)?|Dec(?:ember)?)\s+\d{1,2},?\s+\d{4}\b',
|
| 326 |
re.IGNORECASE,
|
| 327 |
-
), "TANGGAL", 0.96),
|
| 328 |
|
| 329 |
# English/global money values
|
| 330 |
(re.compile(
|
| 331 |
r'\b(?:USD|US\$|\$|EUR|β¬|GBP|Β£)\s*[\d,]+(?:\.\d{2})?'
|
| 332 |
r'(?:\s*(?:thousand|million|billion|trillion))?\b',
|
| 333 |
re.IGNORECASE,
|
| 334 |
-
), "UANG", 0.94),
|
| 335 |
|
| 336 |
# Major English place names often used in examples
|
| 337 |
(re.compile(
|
| 338 |
rf'\b(?:{_PLACES_GLOBAL_PATTERN})\b',
|
| 339 |
re.IGNORECASE,
|
| 340 |
-
), "LOKASI", 0.90),
|
| 341 |
|
| 342 |
-
# Gelar + nama orang: "Prof. Budi Raharjo", "Ibu Sari"
|
| 343 |
# Negative lookahead mencegah false positive pada idiom: "Ibu Kota", "Ibu Pertiwi", "Bapak Bangsa"
|
|
|
|
| 344 |
(re.compile(
|
| 345 |
r'\b(?:Bapak|Ibu|Pak|Bu|Dr\.?|Prof\.?|Professor|Mr\.?|Mrs\.?|Ms\.?|Miss|Ir\.?|Drs\.?|Hj\.?|H\.?)\s+'
|
| 346 |
r'(?!(?:Kota|Pertiwi|Bangsa|Pembangunan|Rumah|Susuan|Angkat|Tiri)\b)'
|
| 347 |
r'(?:[A-Z][a-z]+(?:\s+[A-Z][a-z]+){0,4})',
|
| 348 |
re.UNICODE,
|
| 349 |
-
), "ORANG", 0.85),
|
| 350 |
]
|
| 351 |
|
| 352 |
|
|
@@ -443,9 +447,9 @@ class IndonesianNER:
|
|
| 443 |
if not text:
|
| 444 |
return []
|
| 445 |
|
| 446 |
-
ml_entities = self._predict_ml(text)
|
| 447 |
-
rule_entities = self._predict_rules(text) if self._use_rules else []
|
| 448 |
-
name_entities = self._predict_names(text)
|
| 449 |
|
| 450 |
combined = _merge_entities(ml_entities, rule_entities + name_entities)
|
| 451 |
filtered = [e for e in combined if _is_plausible_entity(e.word)]
|
|
@@ -584,10 +588,18 @@ class IndonesianNER:
|
|
| 584 |
|
| 585 |
return entities
|
| 586 |
|
| 587 |
-
def _predict_rules(self, text: str) -> list[NEREntity]:
|
| 588 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 589 |
entities: list[NEREntity] = []
|
| 590 |
-
for pattern, label, score in _RULES:
|
|
|
|
|
|
|
| 591 |
for m in pattern.finditer(text):
|
| 592 |
word = m.group().strip()
|
| 593 |
if len(word) < 2:
|
|
|
|
| 209 |
|
| 210 |
# ββ Rule-Based Booster βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 211 |
#
|
| 212 |
+
# Regex untuk mendeteksi entitas yang sering terlewat model ML.
|
| 213 |
+
# Format setiap rule: (pola, label_internal, skor_tetap, bahasa)
|
| 214 |
+
# bahasa: "id" β hanya untuk teks Indonesia (id/mixed/unknown)
|
| 215 |
+
# "en" β hanya untuk teks Inggris (en/mixed/unknown)
|
| 216 |
+
# "any" β universal/global, selalu aktif di semua bahasa
|
| 217 |
# Diurutkan dari paling spesifik ke umum; hanya ditambahkan jika tidak tumpang-
|
| 218 |
# tindih dengan hasil ML.
|
| 219 |
|
| 220 |
+
_RULES: list[tuple[re.Pattern[str], str, float, str]] = [
|
| 221 |
|
| 222 |
+
# English/global company suffixes (Inc., LLC, GmbH, S.A. β universal)
|
| 223 |
(re.compile(
|
| 224 |
r'\b(?:[A-Z][A-Za-z0-9&.,\'-]+(?:\s+[A-Z][A-Za-z0-9&.,\'-]+){0,5})\s+'
|
| 225 |
rf'(?:{_ORG_SUFFIX_PATTERN})\b',
|
| 226 |
re.UNICODE,
|
| 227 |
+
), "ORGANISASI", 0.90, "any"),
|
| 228 |
|
| 229 |
# English institutions and government bodies
|
| 230 |
(re.compile(
|
|
|
|
| 232 |
r'(?:Department|Ministry|Office|Agency|Commission|Bureau)\s+of)\s+'
|
| 233 |
r'[A-Z][A-Za-z&.,\'\s-]{2,60}',
|
| 234 |
re.UNICODE,
|
| 235 |
+
), "ORGANISASI", 0.89, "any"),
|
| 236 |
|
| 237 |
+
# Well-known English/global acronyms (UN, WHO, NASA, dll.)
|
| 238 |
(re.compile(
|
| 239 |
rf'\b(?:{_ORG_ACRONYM_PATTERN})\b',
|
| 240 |
+
), "ORGANISASI", 0.94, "any"),
|
| 241 |
|
| 242 |
# Badan usaha Indonesia (PT, CV, UD, Firma, Koperasi, dll.)
|
| 243 |
# Setiap kata dalam nama perusahaan harus diawali huruf kapital agar tidak
|
|
|
|
| 246 |
r'\b(?:PT\.?\s+|CV\.?\s+|UD\.?\s+|Firma\s+|Koperasi\s+|Perum\s+|Persero\s+)'
|
| 247 |
r'(?:[A-Z][A-Za-z0-9&.,\'-]*(?:\s+[A-Z][A-Za-z0-9&.,\'-]*){0,7})',
|
| 248 |
re.UNICODE,
|
| 249 |
+
), "ORGANISASI", 0.92, "id"),
|
| 250 |
|
| 251 |
# Perusahaan terbuka (Tbk/Persero/Perum sebagai suffix)
|
| 252 |
(re.compile(
|
| 253 |
r'\b(?:[A-Z][A-Za-z0-9&.,\'-]*(?:\s+[A-Z][A-Za-z0-9&.,\'-]*){0,7})'
|
| 254 |
r'\s+(?:Tbk\.?|Persero|Perum)\b',
|
| 255 |
re.UNICODE,
|
| 256 |
+
), "ORGANISASI", 0.88, "id"),
|
| 257 |
|
| 258 |
# Lembaga pemerintah Indonesia
|
| 259 |
(re.compile(
|
|
|
|
| 261 |
r'Badan|Lembaga|Komisi|Direktorat\s+Jenderal?|Ditjen|Bappenas)\s+'
|
| 262 |
r'(?:[A-Z][A-Za-z\s]{2,50})',
|
| 263 |
re.UNICODE,
|
| 264 |
+
), "ORGANISASI", 0.90, "id"),
|
| 265 |
|
| 266 |
# Akronim lembaga negara RI yang dikenal luas
|
| 267 |
(re.compile(
|
| 268 |
r'\b(?:KPK|OJK|BI|BPK|BPS|BPOM|BNPB|BPJS|KPU|Bawaslu|MK|MA|'
|
| 269 |
r'DPR|DPD|MPR|Polri|TNI|Kejagung|BNN|PPATK|KemenPU|BRIN)\b',
|
| 270 |
+
), "ORGANISASI", 0.95, "id"),
|
| 271 |
|
| 272 |
# Institusi pendidikan
|
| 273 |
(re.compile(
|
| 274 |
r'\b(?:Universitas|Institut|Politeknik|Sekolah\s+Tinggi|'
|
| 275 |
r'STMIK|STIE|STIKES|STKIP|Akademi)\s+(?:[A-Z][A-Za-z\s]{2,50})',
|
| 276 |
re.UNICODE,
|
| 277 |
+
), "ORGANISASI", 0.90, "id"),
|
| 278 |
|
| 279 |
# Nama partai politik Indonesia
|
| 280 |
(re.compile(
|
| 281 |
r'\b(?:Partai\s+(?:Golkar|Gerindra|PDI-?P|Demokrat|PKS|PKB|PPP|Nasdem|Hanura|'
|
| 282 |
r'Berkarya|PKPI|Garuda)|PDIP|Golkar|Gerindra)\b',
|
| 283 |
re.IGNORECASE,
|
| 284 |
+
), "ORGANISASI", 0.92, "id"),
|
| 285 |
|
| 286 |
# Satuan wilayah administratif
|
| 287 |
(re.compile(
|
| 288 |
r'\b(?:Provinsi|Kabupaten|Kecamatan|Kelurahan|Desa)\s+'
|
| 289 |
r'(?:[A-Z][A-Za-z\s]{2,40})',
|
| 290 |
re.UNICODE,
|
| 291 |
+
), "LOKASI", 0.88, "id"),
|
| 292 |
|
| 293 |
# Kota besar Indonesia (dengan prefiks "Kota")
|
| 294 |
(re.compile(
|
| 295 |
r'\bKota\s+(?:Jakarta|Surabaya|Bandung|Medan|Semarang|Makassar|Palembang|'
|
| 296 |
r'Tangerang|Depok|Bekasi|Bogor|Malang|Yogyakarta|Denpasar|Balikpapan)\b',
|
| 297 |
re.IGNORECASE,
|
| 298 |
+
), "LOKASI", 0.95, "id"),
|
| 299 |
|
| 300 |
# Undang-undang dan peraturan resmi Indonesia
|
| 301 |
(re.compile(
|
|
|
|
| 304 |
r'Keputusan\s+Presiden|Keppres|Perda)\s+'
|
| 305 |
r'(?:No\.?\s*\d+\s*(?:/\s*\d{4})?|Nomor\s+\d+)',
|
| 306 |
re.IGNORECASE,
|
| 307 |
+
), "PERATURAN", 0.93, "id"),
|
| 308 |
|
| 309 |
# Mata uang dan nilai moneter Indonesia
|
| 310 |
(re.compile(
|
| 311 |
r'\bRp\.?\s*[\d.,]+(?:\s*(?:ribu|juta|miliar|triliun))?\b',
|
| 312 |
re.IGNORECASE,
|
| 313 |
+
), "UANG", 0.95, "id"),
|
| 314 |
|
| 315 |
# Tanggal format formal Indonesia: "15 Maret 2020"
|
| 316 |
(re.compile(
|
|
|
|
| 319 |
r'September|Oktober|November|Desember)\s+'
|
| 320 |
r'\d{4}\b',
|
| 321 |
re.IGNORECASE,
|
| 322 |
+
), "TANGGAL", 0.97, "id"),
|
| 323 |
|
| 324 |
+
# English date formats: "March 15, 2024" β format global, selalu aktif
|
| 325 |
(re.compile(
|
| 326 |
r'\b(?:Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|'
|
| 327 |
r'Jun(?:e)?|Jul(?:y)?|Aug(?:ust)?|Sep(?:tember)?|Oct(?:ober)?|'
|
| 328 |
r'Nov(?:ember)?|Dec(?:ember)?)\s+\d{1,2},?\s+\d{4}\b',
|
| 329 |
re.IGNORECASE,
|
| 330 |
+
), "TANGGAL", 0.96, "any"),
|
| 331 |
|
| 332 |
# English/global money values
|
| 333 |
(re.compile(
|
| 334 |
r'\b(?:USD|US\$|\$|EUR|β¬|GBP|Β£)\s*[\d,]+(?:\.\d{2})?'
|
| 335 |
r'(?:\s*(?:thousand|million|billion|trillion))?\b',
|
| 336 |
re.IGNORECASE,
|
| 337 |
+
), "UANG", 0.94, "any"),
|
| 338 |
|
| 339 |
# Major English place names often used in examples
|
| 340 |
(re.compile(
|
| 341 |
rf'\b(?:{_PLACES_GLOBAL_PATTERN})\b',
|
| 342 |
re.IGNORECASE,
|
| 343 |
+
), "LOKASI", 0.90, "any"),
|
| 344 |
|
| 345 |
+
# Gelar + nama orang: "Prof. Budi Raharjo", "Ibu Sari", "Mr. John Smith"
|
| 346 |
# Negative lookahead mencegah false positive pada idiom: "Ibu Kota", "Ibu Pertiwi", "Bapak Bangsa"
|
| 347 |
+
# Bilingual (gelar Indonesia + Inggris) β "any".
|
| 348 |
(re.compile(
|
| 349 |
r'\b(?:Bapak|Ibu|Pak|Bu|Dr\.?|Prof\.?|Professor|Mr\.?|Mrs\.?|Ms\.?|Miss|Ir\.?|Drs\.?|Hj\.?|H\.?)\s+'
|
| 350 |
r'(?!(?:Kota|Pertiwi|Bangsa|Pembangunan|Rumah|Susuan|Angkat|Tiri)\b)'
|
| 351 |
r'(?:[A-Z][a-z]+(?:\s+[A-Z][a-z]+){0,4})',
|
| 352 |
re.UNICODE,
|
| 353 |
+
), "ORANG", 0.85, "any"),
|
| 354 |
]
|
| 355 |
|
| 356 |
|
|
|
|
| 447 |
if not text:
|
| 448 |
return []
|
| 449 |
|
| 450 |
+
ml_entities = self._predict_ml(text) if self._pipeline else []
|
| 451 |
+
rule_entities = self._predict_rules(text, language) if self._use_rules else []
|
| 452 |
+
name_entities = self._predict_names(text) if self._use_rules else []
|
| 453 |
|
| 454 |
combined = _merge_entities(ml_entities, rule_entities + name_entities)
|
| 455 |
filtered = [e for e in combined if _is_plausible_entity(e.word)]
|
|
|
|
| 588 |
|
| 589 |
return entities
|
| 590 |
|
| 591 |
+
def _predict_rules(self, text: str, language: str = "id") -> list[NEREntity]:
|
| 592 |
+
"""
|
| 593 |
+
Jalankan pola regex yang sesuai bahasa dan kembalikan entitas yang cocok.
|
| 594 |
+
|
| 595 |
+
Rule khusus Indonesia (PT, Kementerian, Rupiah, dll.) dilewati pada teks
|
| 596 |
+
Inggris; rule universal/global (suffix perusahaan, tanggal, tempat dunia)
|
| 597 |
+
selalu aktif.
|
| 598 |
+
"""
|
| 599 |
entities: list[NEREntity] = []
|
| 600 |
+
for pattern, label, score, lang in _RULES:
|
| 601 |
+
if lang != "any" and language not in (lang, "mixed", "unknown"):
|
| 602 |
+
continue # rule tidak berlaku untuk bahasa ini
|
| 603 |
for m in pattern.finditer(text):
|
| 604 |
word = m.group().strip()
|
| 605 |
if len(word) < 2:
|
src/pii/pii_detector.py
CHANGED
|
@@ -4,10 +4,16 @@
|
|
| 4 |
# AUTHOR: Ariel Jonathan
|
| 5 |
# ============================================================
|
| 6 |
"""
|
| 7 |
-
PII (Personally Identifiable Information) Detector
|
| 8 |
|
| 9 |
-
Mendeteksi
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
NIK Nomor Induk Kependudukan (16 digit)
|
| 13 |
β validasi: kode provinsi (38) + encoding tanggal lahir + gender
|
|
@@ -27,9 +33,17 @@ dengan validasi struktural untuk mengurangi false positive:
|
|
| 27 |
KARTU_KREDIT Kartu kredit/debit (Luhn + pola BIN Visa/MC/Amex/JCB)
|
| 28 |
IP_V4 Alamat IPv4 dengan validasi rentang oktet 0β255
|
| 29 |
IP_V6 Alamat IPv6 (format penuh & terkompresi)
|
| 30 |
-
TANGGAL_LAHIR Tanggal lahir dalam berbagai format Indonesia
|
| 31 |
ALAMAT Pola alamat Indonesia (Jl., Jalan, Gg., Dusun, dsb.)
|
| 32 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
Referensi format:
|
| 34 |
NIK β Permendagri No. 72/2012 tentang Penomoran NIK
|
| 35 |
NPWP β PMK No. 112/PMK.03/2022 tentang format NPWP 16 digit
|
|
@@ -214,6 +228,32 @@ def _validate_plate(text: str) -> bool:
|
|
| 214 |
return m.group(1) in _PLATE_CODES
|
| 215 |
|
| 216 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 217 |
def _strip_digits(text: str) -> str:
|
| 218 |
"""Hapus semua karakter non-digit dari teks (untuk input ke validator numerik)."""
|
| 219 |
return re.sub(r"\D", "", text)
|
|
@@ -224,6 +264,14 @@ def _strip_digits(text: str) -> str:
|
|
| 224 |
# Setiap recognizer menggabungkan: pola regex + validator struktural + skor dasar.
|
| 225 |
# Diurutkan dari paling spesifik ke paling umum untuk mencegah false positive.
|
| 226 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 227 |
@dataclass
|
| 228 |
class _Recognizer:
|
| 229 |
"""Definisi satu tipe PII: pola regex + validator opsional."""
|
|
@@ -233,17 +281,22 @@ class _Recognizer:
|
|
| 233 |
confidence: float # kepercayaan dasar (sebelum validator)
|
| 234 |
validator: Callable[[str], bool | tuple] | None = None
|
| 235 |
raw_input: bool = False # True β kirim teks asli ke validator
|
|
|
|
|
|
|
| 236 |
|
| 237 |
|
| 238 |
_RECOGNIZERS: list[_Recognizer] = [
|
| 239 |
|
| 240 |
-
# Email
|
|
|
|
| 241 |
_Recognizer(
|
| 242 |
label="EMAIL",
|
| 243 |
pattern=re.compile(
|
| 244 |
r'\b[A-Za-z0-9._%+\-]{1,64}@[A-Za-z0-9.\-]{1,255}\.[A-Za-z]{2,10}\b',
|
| 245 |
),
|
| 246 |
-
confidence=0.
|
|
|
|
|
|
|
| 247 |
),
|
| 248 |
|
| 249 |
# IPv6 (harus sebelum IPv4 agar tidak tersaingi pola yang lebih pendek)
|
|
@@ -293,6 +346,19 @@ _RECOGNIZERS: list[_Recognizer] = [
|
|
| 293 |
r'(\d{3}[\s\-]?\d{2}[\s\-]?\d{4})'
|
| 294 |
),
|
| 295 |
confidence=0.91,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 296 |
),
|
| 297 |
|
| 298 |
# Nomor telepon internasional, hanya jika ada keyword agar tidak menangkap angka acak.
|
|
@@ -303,6 +369,7 @@ _RECOGNIZERS: list[_Recognizer] = [
|
|
| 303 |
r'(\+?[1-9]\d{0,2}[\s\-.(]*\d{2,4}[\s\-.)]*\d{3,4}[\s\-]*\d{3,5})'
|
| 304 |
),
|
| 305 |
confidence=0.84,
|
|
|
|
| 306 |
),
|
| 307 |
|
| 308 |
# Paspor global/generic, hanya jika ada konteks passport.
|
|
@@ -313,6 +380,7 @@ _RECOGNIZERS: list[_Recognizer] = [
|
|
| 313 |
r'([A-Z0-9]{6,12})'
|
| 314 |
),
|
| 315 |
confidence=0.84,
|
|
|
|
| 316 |
),
|
| 317 |
|
| 318 |
# NPWP format lama: XX.XXX.XXX.X-XXX.XXX
|
|
@@ -322,6 +390,7 @@ _RECOGNIZERS: list[_Recognizer] = [
|
|
| 322 |
confidence=0.90,
|
| 323 |
validator=lambda t: _validate_npwp_lama(_strip_digits(t)),
|
| 324 |
raw_input=True,
|
|
|
|
| 325 |
),
|
| 326 |
|
| 327 |
# NIK / NPWP baru (16 digit tanpa pemisah)
|
|
@@ -332,6 +401,7 @@ _RECOGNIZERS: list[_Recognizer] = [
|
|
| 332 |
confidence=0.75,
|
| 333 |
validator=lambda t: _validate_nik(t)[0],
|
| 334 |
raw_input=False,
|
|
|
|
| 335 |
),
|
| 336 |
|
| 337 |
# BPJS Kesehatan (13 digit, dengan konteks kata kunci)
|
|
@@ -343,6 +413,7 @@ _RECOGNIZERS: list[_Recognizer] = [
|
|
| 343 |
r'|\b(\d{13})\b(?=\s*(?:BPJS|bpjs))',
|
| 344 |
),
|
| 345 |
confidence=0.85,
|
|
|
|
| 346 |
),
|
| 347 |
|
| 348 |
# BPJS Ketenagakerjaan (11 digit, dengan konteks kata kunci)
|
|
@@ -354,6 +425,7 @@ _RECOGNIZERS: list[_Recognizer] = [
|
|
| 354 |
r'(\d{11})'
|
| 355 |
),
|
| 356 |
confidence=0.88,
|
|
|
|
| 357 |
),
|
| 358 |
|
| 359 |
# SIM (12 digit, dengan konteks kata kunci)
|
|
@@ -363,6 +435,7 @@ _RECOGNIZERS: list[_Recognizer] = [
|
|
| 363 |
r'(?i)(?:SIM\s+(?:No\.?\s*)?|No\.?\s*SIM\s*:?\s*)(\d{12})'
|
| 364 |
),
|
| 365 |
confidence=0.87,
|
|
|
|
| 366 |
),
|
| 367 |
|
| 368 |
# Paspor Indonesia (1 huruf kapital + 7 digit)
|
|
@@ -373,6 +446,7 @@ _RECOGNIZERS: list[_Recognizer] = [
|
|
| 373 |
r'\b([A-Z]\d{7})\b'
|
| 374 |
),
|
| 375 |
confidence=0.82,
|
|
|
|
| 376 |
),
|
| 377 |
|
| 378 |
# Plat nomor TNKB (mis. "B 1234 ABC")
|
|
@@ -382,6 +456,7 @@ _RECOGNIZERS: list[_Recognizer] = [
|
|
| 382 |
confidence=0.75,
|
| 383 |
validator=lambda t: _validate_plate(t),
|
| 384 |
raw_input=True,
|
|
|
|
| 385 |
),
|
| 386 |
|
| 387 |
# Nomor HP Indonesia
|
|
@@ -396,6 +471,7 @@ _RECOGNIZERS: list[_Recognizer] = [
|
|
| 396 |
confidence=0.80,
|
| 397 |
validator=lambda t: _validate_mobile(_strip_digits(t)),
|
| 398 |
raw_input=True,
|
|
|
|
| 399 |
),
|
| 400 |
|
| 401 |
# Telepon tetap / fixed-line
|
|
@@ -408,6 +484,7 @@ _RECOGNIZERS: list[_Recognizer] = [
|
|
| 408 |
r'(?!\d)'
|
| 409 |
),
|
| 410 |
confidence=0.78,
|
|
|
|
| 411 |
),
|
| 412 |
|
| 413 |
# Nomor rekening bank (dengan konteks kata kunci)
|
|
@@ -461,6 +538,7 @@ _RECOGNIZERS: list[_Recognizer] = [
|
|
| 461 |
r')',
|
| 462 |
),
|
| 463 |
confidence=0.72,
|
|
|
|
| 464 |
),
|
| 465 |
|
| 466 |
# Alamat fisik Inggris/global sederhana.
|
|
@@ -473,6 +551,7 @@ _RECOGNIZERS: list[_Recognizer] = [
|
|
| 473 |
r'Lane|Ln\.?|Drive|Dr\.?|Court|Ct\.?|Way|Place|Pl\.?))'
|
| 474 |
),
|
| 475 |
confidence=0.78,
|
|
|
|
| 476 |
),
|
| 477 |
]
|
| 478 |
|
|
@@ -508,6 +587,12 @@ class PIIDetector:
|
|
| 508 |
"""
|
| 509 |
Scan teks dan kembalikan semua PII yang terdeteksi.
|
| 510 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 511 |
Returns:
|
| 512 |
Daftar PIIEntity diurutkan berdasarkan posisi (start ascending).
|
| 513 |
Entitas yang tumpang-tindih diselesaikan: kepercayaan lebih tinggi menang.
|
|
@@ -518,6 +603,8 @@ class PIIDetector:
|
|
| 518 |
raw: list[PIIEntity] = []
|
| 519 |
|
| 520 |
for rec in _RECOGNIZERS:
|
|
|
|
|
|
|
| 521 |
for m in rec.pattern.finditer(text):
|
| 522 |
|
| 523 |
# Ambil nilai dari capturing group(1) jika ada
|
|
|
|
| 4 |
# AUTHOR: Ariel Jonathan
|
| 5 |
# ============================================================
|
| 6 |
"""
|
| 7 |
+
PII (Personally Identifiable Information) Detector β Indonesia & Inggris.
|
| 8 |
|
| 9 |
+
Mendeteksi data pribadi dalam teks bebas menggunakan pola regex dengan validasi
|
| 10 |
+
struktural untuk mengurangi false positive. Recognizer dirutekan berdasarkan
|
| 11 |
+
bahasa: identifier khusus Indonesia (NIK, NPWP, SIM, plat nomor, dll.) dilewati
|
| 12 |
+
pada teks Inggris, sedangkan identifier Inggris/global (SSN AS, UK NIN, paspor
|
| 13 |
+
generik) dilewati pada teks Indonesia. Identifier universal (email, IP, kartu
|
| 14 |
+
kredit, rekening, tanggal lahir) selalu aktif.
|
| 15 |
+
|
| 16 |
+
Tipe data Indonesia:
|
| 17 |
|
| 18 |
NIK Nomor Induk Kependudukan (16 digit)
|
| 19 |
β validasi: kode provinsi (38) + encoding tanggal lahir + gender
|
|
|
|
| 33 |
KARTU_KREDIT Kartu kredit/debit (Luhn + pola BIN Visa/MC/Amex/JCB)
|
| 34 |
IP_V4 Alamat IPv4 dengan validasi rentang oktet 0β255
|
| 35 |
IP_V6 Alamat IPv6 (format penuh & terkompresi)
|
| 36 |
+
TANGGAL_LAHIR Tanggal lahir dalam berbagai format Indonesia/Inggris
|
| 37 |
ALAMAT Pola alamat Indonesia (Jl., Jalan, Gg., Dusun, dsb.)
|
| 38 |
|
| 39 |
+
Tipe data Inggris/global (aktif pada teks Inggris):
|
| 40 |
+
|
| 41 |
+
SSN_US US Social Security Number (dengan kata kunci "SSN")
|
| 42 |
+
NIN_UK UK National Insurance Number (mis. "AB 12 34 56 C")
|
| 43 |
+
PASPOR Paspor generik (dengan kata kunci "passport")
|
| 44 |
+
TELEPON_INTERNASIONAL Nomor telepon internasional (dengan kata kunci)
|
| 45 |
+
ALAMAT Pola alamat Inggris ("123 Main Street", dengan kata kunci)
|
| 46 |
+
|
| 47 |
Referensi format:
|
| 48 |
NIK β Permendagri No. 72/2012 tentang Penomoran NIK
|
| 49 |
NPWP β PMK No. 112/PMK.03/2022 tentang format NPWP 16 digit
|
|
|
|
| 228 |
return m.group(1) in _PLATE_CODES
|
| 229 |
|
| 230 |
|
| 231 |
+
def _validate_email(text: str) -> bool:
|
| 232 |
+
"""
|
| 233 |
+
Validasi struktur dasar alamat email.
|
| 234 |
+
|
| 235 |
+
Regex pola email cukup permisif (mengizinkan titik berurutan & titik di tepi),
|
| 236 |
+
sehingga validator ini menolak bentuk tidak valid yang lolos pola:
|
| 237 |
+
user..name@contoh.com (titik ganda di local part)
|
| 238 |
+
user.@contoh.com (titik di akhir local part)
|
| 239 |
+
user@contoh..com (titik ganda di domain)
|
| 240 |
+
user@.contoh.com (titik di awal domain)
|
| 241 |
+
"""
|
| 242 |
+
text = text.strip()
|
| 243 |
+
if text.count("@") != 1:
|
| 244 |
+
return False
|
| 245 |
+
local, _, domain = text.partition("@")
|
| 246 |
+
if not local or not domain:
|
| 247 |
+
return False
|
| 248 |
+
if local.startswith(".") or local.endswith(".") or ".." in local:
|
| 249 |
+
return False
|
| 250 |
+
if domain.startswith((".", "-")) or domain.endswith((".", "-")) or ".." in domain:
|
| 251 |
+
return False
|
| 252 |
+
if "." not in domain:
|
| 253 |
+
return False
|
| 254 |
+
return True
|
| 255 |
+
|
| 256 |
+
|
| 257 |
def _strip_digits(text: str) -> str:
|
| 258 |
"""Hapus semua karakter non-digit dari teks (untuk input ke validator numerik)."""
|
| 259 |
return re.sub(r"\D", "", text)
|
|
|
|
| 264 |
# Setiap recognizer menggabungkan: pola regex + validator struktural + skor dasar.
|
| 265 |
# Diurutkan dari paling spesifik ke paling umum untuk mencegah false positive.
|
| 266 |
|
| 267 |
+
# Set bahasa default: recognizer berlaku untuk semua bahasa kecuali ditandai lain.
|
| 268 |
+
_ALL_LANGS: frozenset[str] = frozenset({"id", "en", "mixed", "unknown"})
|
| 269 |
+
# Bahasa Indonesia (termasuk teks campuran & tak dikenal β anggap mungkin Indonesia).
|
| 270 |
+
_ID_LANGS: frozenset[str] = frozenset({"id", "mixed", "unknown"})
|
| 271 |
+
# Bahasa Inggris (termasuk teks campuran).
|
| 272 |
+
_EN_LANGS: frozenset[str] = frozenset({"en", "mixed"})
|
| 273 |
+
|
| 274 |
+
|
| 275 |
@dataclass
|
| 276 |
class _Recognizer:
|
| 277 |
"""Definisi satu tipe PII: pola regex + validator opsional."""
|
|
|
|
| 281 |
confidence: float # kepercayaan dasar (sebelum validator)
|
| 282 |
validator: Callable[[str], bool | tuple] | None = None
|
| 283 |
raw_input: bool = False # True β kirim teks asli ke validator
|
| 284 |
+
# Bahasa tempat recognizer ini aktif. Default: semua bahasa (identifier universal).
|
| 285 |
+
languages: frozenset[str] = field(default_factory=lambda: _ALL_LANGS)
|
| 286 |
|
| 287 |
|
| 288 |
_RECOGNIZERS: list[_Recognizer] = [
|
| 289 |
|
| 290 |
+
# Email β pola permisif diperketat oleh _validate_email (tolak titik ganda/di tepi).
|
| 291 |
+
# Base 0.82 + bonus validator 0.15 β 0.97 untuk email yang valid.
|
| 292 |
_Recognizer(
|
| 293 |
label="EMAIL",
|
| 294 |
pattern=re.compile(
|
| 295 |
r'\b[A-Za-z0-9._%+\-]{1,64}@[A-Za-z0-9.\-]{1,255}\.[A-Za-z]{2,10}\b',
|
| 296 |
),
|
| 297 |
+
confidence=0.82,
|
| 298 |
+
validator=_validate_email,
|
| 299 |
+
raw_input=True,
|
| 300 |
),
|
| 301 |
|
| 302 |
# IPv6 (harus sebelum IPv4 agar tidak tersaingi pola yang lebih pendek)
|
|
|
|
| 346 |
r'(\d{3}[\s\-]?\d{2}[\s\-]?\d{4})'
|
| 347 |
),
|
| 348 |
confidence=0.91,
|
| 349 |
+
languages=_EN_LANGS,
|
| 350 |
+
),
|
| 351 |
+
|
| 352 |
+
# UK National Insurance Number (NIN): "AB 12 34 56 C", hanya dengan konteks.
|
| 353 |
+
_Recognizer(
|
| 354 |
+
label="NIN_UK",
|
| 355 |
+
pattern=re.compile(
|
| 356 |
+
r'(?i)(?:NIN[O]?|NI\s+(?:number|no\.?)|'
|
| 357 |
+
r'National\s+Insurance(?:\s+(?:number|no\.?))?)\s*:?#?\s*'
|
| 358 |
+
r'([A-CEGHJ-PR-TW-Z]{2}\s?\d{2}\s?\d{2}\s?\d{2}\s?[A-D])'
|
| 359 |
+
),
|
| 360 |
+
confidence=0.88,
|
| 361 |
+
languages=_EN_LANGS,
|
| 362 |
),
|
| 363 |
|
| 364 |
# Nomor telepon internasional, hanya jika ada keyword agar tidak menangkap angka acak.
|
|
|
|
| 369 |
r'(\+?[1-9]\d{0,2}[\s\-.(]*\d{2,4}[\s\-.)]*\d{3,4}[\s\-]*\d{3,5})'
|
| 370 |
),
|
| 371 |
confidence=0.84,
|
| 372 |
+
languages=_EN_LANGS,
|
| 373 |
),
|
| 374 |
|
| 375 |
# Paspor global/generic, hanya jika ada konteks passport.
|
|
|
|
| 380 |
r'([A-Z0-9]{6,12})'
|
| 381 |
),
|
| 382 |
confidence=0.84,
|
| 383 |
+
languages=_EN_LANGS,
|
| 384 |
),
|
| 385 |
|
| 386 |
# NPWP format lama: XX.XXX.XXX.X-XXX.XXX
|
|
|
|
| 390 |
confidence=0.90,
|
| 391 |
validator=lambda t: _validate_npwp_lama(_strip_digits(t)),
|
| 392 |
raw_input=True,
|
| 393 |
+
languages=_ID_LANGS,
|
| 394 |
),
|
| 395 |
|
| 396 |
# NIK / NPWP baru (16 digit tanpa pemisah)
|
|
|
|
| 401 |
confidence=0.75,
|
| 402 |
validator=lambda t: _validate_nik(t)[0],
|
| 403 |
raw_input=False,
|
| 404 |
+
languages=_ID_LANGS,
|
| 405 |
),
|
| 406 |
|
| 407 |
# BPJS Kesehatan (13 digit, dengan konteks kata kunci)
|
|
|
|
| 413 |
r'|\b(\d{13})\b(?=\s*(?:BPJS|bpjs))',
|
| 414 |
),
|
| 415 |
confidence=0.85,
|
| 416 |
+
languages=_ID_LANGS,
|
| 417 |
),
|
| 418 |
|
| 419 |
# BPJS Ketenagakerjaan (11 digit, dengan konteks kata kunci)
|
|
|
|
| 425 |
r'(\d{11})'
|
| 426 |
),
|
| 427 |
confidence=0.88,
|
| 428 |
+
languages=_ID_LANGS,
|
| 429 |
),
|
| 430 |
|
| 431 |
# SIM (12 digit, dengan konteks kata kunci)
|
|
|
|
| 435 |
r'(?i)(?:SIM\s+(?:No\.?\s*)?|No\.?\s*SIM\s*:?\s*)(\d{12})'
|
| 436 |
),
|
| 437 |
confidence=0.87,
|
| 438 |
+
languages=_ID_LANGS,
|
| 439 |
),
|
| 440 |
|
| 441 |
# Paspor Indonesia (1 huruf kapital + 7 digit)
|
|
|
|
| 446 |
r'\b([A-Z]\d{7})\b'
|
| 447 |
),
|
| 448 |
confidence=0.82,
|
| 449 |
+
languages=_ID_LANGS,
|
| 450 |
),
|
| 451 |
|
| 452 |
# Plat nomor TNKB (mis. "B 1234 ABC")
|
|
|
|
| 456 |
confidence=0.75,
|
| 457 |
validator=lambda t: _validate_plate(t),
|
| 458 |
raw_input=True,
|
| 459 |
+
languages=_ID_LANGS,
|
| 460 |
),
|
| 461 |
|
| 462 |
# Nomor HP Indonesia
|
|
|
|
| 471 |
confidence=0.80,
|
| 472 |
validator=lambda t: _validate_mobile(_strip_digits(t)),
|
| 473 |
raw_input=True,
|
| 474 |
+
languages=_ID_LANGS,
|
| 475 |
),
|
| 476 |
|
| 477 |
# Telepon tetap / fixed-line
|
|
|
|
| 484 |
r'(?!\d)'
|
| 485 |
),
|
| 486 |
confidence=0.78,
|
| 487 |
+
languages=_ID_LANGS,
|
| 488 |
),
|
| 489 |
|
| 490 |
# Nomor rekening bank (dengan konteks kata kunci)
|
|
|
|
| 538 |
r')',
|
| 539 |
),
|
| 540 |
confidence=0.72,
|
| 541 |
+
languages=_ID_LANGS,
|
| 542 |
),
|
| 543 |
|
| 544 |
# Alamat fisik Inggris/global sederhana.
|
|
|
|
| 551 |
r'Lane|Ln\.?|Drive|Dr\.?|Court|Ct\.?|Way|Place|Pl\.?))'
|
| 552 |
),
|
| 553 |
confidence=0.78,
|
| 554 |
+
languages=_EN_LANGS,
|
| 555 |
),
|
| 556 |
]
|
| 557 |
|
|
|
|
| 587 |
"""
|
| 588 |
Scan teks dan kembalikan semua PII yang terdeteksi.
|
| 589 |
|
| 590 |
+
Args:
|
| 591 |
+
text: Teks yang akan diperiksa.
|
| 592 |
+
language: "id", "en", "mixed", atau "unknown". Menentukan recognizer
|
| 593 |
+
mana yang dijalankan β identifier khusus Indonesia (NIK, NPWP,
|
| 594 |
+
SIM, dll.) dilewati pada teks Inggris, dan sebaliknya.
|
| 595 |
+
|
| 596 |
Returns:
|
| 597 |
Daftar PIIEntity diurutkan berdasarkan posisi (start ascending).
|
| 598 |
Entitas yang tumpang-tindih diselesaikan: kepercayaan lebih tinggi menang.
|
|
|
|
| 603 |
raw: list[PIIEntity] = []
|
| 604 |
|
| 605 |
for rec in _RECOGNIZERS:
|
| 606 |
+
if language not in rec.languages:
|
| 607 |
+
continue # recognizer tidak berlaku untuk bahasa ini
|
| 608 |
for m in rec.pattern.finditer(text):
|
| 609 |
|
| 610 |
# Ambil nilai dari capturing group(1) jika ada
|
src/profanity/profanity_checker.py
CHANGED
|
@@ -110,7 +110,7 @@ _BUILTIN_MEDIUM: set[str] = {
|
|
| 110 |
# Kata umum Indonesia yang TIDAK boleh ditandai meski mungkin ada di lexicon
|
| 111 |
# (daftar ini dibuat dari false positive yang terdeteksi saat pengujian)
|
| 112 |
_WHITELIST_FALLBACK: frozenset[str] = frozenset({
|
| 113 |
-
"tolong", "kampung", "buaya", "berak",
|
| 114 |
"bisu", "buta", "alay", "ampas", "celeng",
|
| 115 |
# False positive dari lexicon eksternal β kata umum Bahasa Indonesia
|
| 116 |
"jangan", "tepat", "tidak", "kurang", "rusak",
|
|
@@ -219,25 +219,43 @@ def _is_known_common_word(word: str, language: str = "id") -> bool:
|
|
| 219 |
return any(word_frequency(word, lang) >= _WORDFREQ_MIN for lang in langs)
|
| 220 |
|
| 221 |
|
| 222 |
-
def _build_skeleton_index(words: set[str]) -> dict[str, str]:
|
| 223 |
"""
|
| 224 |
-
Buat index skeleton konsonan β kata profanity untuk deteksi
|
| 225 |
|
| 226 |
Hanya kata dengan β₯ 4 huruf yang diindeks (mengurangi false positive pada
|
| 227 |
singkatan umum non-profanity yang kebetulan memiliki skeleton sama).
|
| 228 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 229 |
Returns:
|
| 230 |
-
dict {skeleton: kata_asli}
|
| 231 |
"""
|
| 232 |
-
idx: dict[str, str] = {}
|
| 233 |
for w in words:
|
| 234 |
if len(w) >= 4:
|
| 235 |
skel = _consonant_skeleton(w)
|
| 236 |
if skel and len(skel) >= 3:
|
| 237 |
-
idx
|
| 238 |
return idx
|
| 239 |
|
| 240 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 241 |
# ββ Kelas Utama ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 242 |
|
| 243 |
class ProfanityChecker:
|
|
@@ -256,10 +274,10 @@ class ProfanityChecker:
|
|
| 256 |
"""
|
| 257 |
|
| 258 |
def __init__(self) -> None:
|
| 259 |
-
self._high: set[str]
|
| 260 |
-
self._medium: set[str]
|
| 261 |
-
self._skel_high: dict[str, str] = {} # skeleton β kata HIGH
|
| 262 |
-
self._skel_medium: dict[str, str] = {} # skeleton β kata MEDIUM
|
| 263 |
self._loaded = False
|
| 264 |
|
| 265 |
# ββ Public API ββ
|
|
@@ -337,33 +355,32 @@ class ProfanityChecker:
|
|
| 337 |
continue
|
| 338 |
skel = _consonant_skeleton(norm)
|
| 339 |
if skel and len(skel) >= 3:
|
| 340 |
-
|
|
|
|
| 341 |
severity = "HIGH"
|
| 342 |
-
matched_as =
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
|
|
|
|
|
|
| 346 |
|
| 347 |
# Tahap 3: koreksi ejaan via SymSpell β cek apakah hasil koreksi adalah profanity.
|
| 348 |
# Menangkap typo-profanity seperti "ptolol" β "tolol", "tollol" β "tolol".
|
| 349 |
# Hanya dijalankan jika Tahap 1 & 2 tidak menemukan hasil.
|
|
|
|
| 350 |
if severity is None:
|
| 351 |
try:
|
| 352 |
from word_quality.word_quality_detector import get_detector
|
| 353 |
-
from symspellpy import Verbosity as _V
|
| 354 |
wq = get_detector(load=False)
|
| 355 |
-
if wq.is_loaded
|
| 356 |
-
norm2
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
corrected
|
| 362 |
-
|
| 363 |
-
if corrected in self._high:
|
| 364 |
-
severity = "HIGH"; matched_as = corrected
|
| 365 |
-
elif corrected in self._medium:
|
| 366 |
-
severity = "MEDIUM"; matched_as = corrected
|
| 367 |
except Exception:
|
| 368 |
pass # SymSpell tidak tersedia atau gagal β lewati tahap ini
|
| 369 |
|
|
|
|
| 110 |
# Kata umum Indonesia yang TIDAK boleh ditandai meski mungkin ada di lexicon
|
| 111 |
# (daftar ini dibuat dari false positive yang terdeteksi saat pengujian)
|
| 112 |
_WHITELIST_FALLBACK: frozenset[str] = frozenset({
|
| 113 |
+
"tolong", "kampung", "buaya", "berak",
|
| 114 |
"bisu", "buta", "alay", "ampas", "celeng",
|
| 115 |
# False positive dari lexicon eksternal β kata umum Bahasa Indonesia
|
| 116 |
"jangan", "tepat", "tidak", "kurang", "rusak",
|
|
|
|
| 219 |
return any(word_frequency(word, lang) >= _WORDFREQ_MIN for lang in langs)
|
| 220 |
|
| 221 |
|
| 222 |
+
def _build_skeleton_index(words: set[str]) -> dict[str, list[str]]:
|
| 223 |
"""
|
| 224 |
+
Buat index skeleton konsonan β daftar kata profanity untuk deteksi singkatan.
|
| 225 |
|
| 226 |
Hanya kata dengan β₯ 4 huruf yang diindeks (mengurangi false positive pada
|
| 227 |
singkatan umum non-profanity yang kebetulan memiliki skeleton sama).
|
| 228 |
|
| 229 |
+
Beberapa kata berbeda bisa berbagi skeleton yang sama (mis. "bangsat" dan
|
| 230 |
+
"bangset" β "bngst"). Karena itu nilainya berupa LIST, bukan satu kata β
|
| 231 |
+
mencegah entri saling menimpa yang dapat menyebabkan false negative.
|
| 232 |
+
|
| 233 |
Returns:
|
| 234 |
+
dict {skeleton: [kata_asli, ...]}
|
| 235 |
"""
|
| 236 |
+
idx: dict[str, list[str]] = {}
|
| 237 |
for w in words:
|
| 238 |
if len(w) >= 4:
|
| 239 |
skel = _consonant_skeleton(w)
|
| 240 |
if skel and len(skel) >= 3:
|
| 241 |
+
idx.setdefault(skel, []).append(w)
|
| 242 |
return idx
|
| 243 |
|
| 244 |
|
| 245 |
+
def _match_skeleton(candidates: list[str] | None, norm_len: int) -> str | None:
|
| 246 |
+
"""
|
| 247 |
+
Pilih kata profanity yang cocok untuk sebuah skeleton.
|
| 248 |
+
|
| 249 |
+
Mengembalikan kata terpanjang yang memenuhi syarat panjang (bentuk yang
|
| 250 |
+
dicek tidak lebih panjang dari kata asli di lexicon β menghindari false
|
| 251 |
+
positive pada kata panjang), atau None jika tidak ada yang memenuhi.
|
| 252 |
+
"""
|
| 253 |
+
if not candidates:
|
| 254 |
+
return None
|
| 255 |
+
best = max(candidates, key=len)
|
| 256 |
+
return best if norm_len <= len(best) else None
|
| 257 |
+
|
| 258 |
+
|
| 259 |
# ββ Kelas Utama ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 260 |
|
| 261 |
class ProfanityChecker:
|
|
|
|
| 274 |
"""
|
| 275 |
|
| 276 |
def __init__(self) -> None:
|
| 277 |
+
self._high: set[str] = set()
|
| 278 |
+
self._medium: set[str] = set()
|
| 279 |
+
self._skel_high: dict[str, list[str]] = {} # skeleton β daftar kata HIGH
|
| 280 |
+
self._skel_medium: dict[str, list[str]] = {} # skeleton β daftar kata MEDIUM
|
| 281 |
self._loaded = False
|
| 282 |
|
| 283 |
# ββ Public API ββ
|
|
|
|
| 355 |
continue
|
| 356 |
skel = _consonant_skeleton(norm)
|
| 357 |
if skel and len(skel) >= 3:
|
| 358 |
+
hi = _match_skeleton(self._skel_high.get(skel), len(norm))
|
| 359 |
+
if hi is not None:
|
| 360 |
severity = "HIGH"
|
| 361 |
+
matched_as = hi
|
| 362 |
+
else:
|
| 363 |
+
me = _match_skeleton(self._skel_medium.get(skel), len(norm))
|
| 364 |
+
if me is not None:
|
| 365 |
+
severity = "MEDIUM"
|
| 366 |
+
matched_as = me
|
| 367 |
|
| 368 |
# Tahap 3: koreksi ejaan via SymSpell β cek apakah hasil koreksi adalah profanity.
|
| 369 |
# Menangkap typo-profanity seperti "ptolol" β "tolol", "tollol" β "tolol".
|
| 370 |
# Hanya dijalankan jika Tahap 1 & 2 tidak menemukan hasil.
|
| 371 |
+
# Memakai API publik WordQualityDetector.correct_spelling (tanpa akses field privat).
|
| 372 |
if severity is None:
|
| 373 |
try:
|
| 374 |
from word_quality.word_quality_detector import get_detector
|
|
|
|
| 375 |
wq = get_detector(load=False)
|
| 376 |
+
if wq.is_loaded:
|
| 377 |
+
norm2 = _normalize(raw)
|
| 378 |
+
corrected = wq.correct_spelling(norm2, language=language)
|
| 379 |
+
if corrected:
|
| 380 |
+
if corrected in self._high:
|
| 381 |
+
severity = "HIGH"; matched_as = corrected
|
| 382 |
+
elif corrected in self._medium:
|
| 383 |
+
severity = "MEDIUM"; matched_as = corrected
|
|
|
|
|
|
|
|
|
|
|
|
|
| 384 |
except Exception:
|
| 385 |
pass # SymSpell tidak tersedia atau gagal β lewati tahap ini
|
| 386 |
|
src/responsible/responsible_checker.py
CHANGED
|
@@ -41,6 +41,7 @@ from __future__ import annotations
|
|
| 41 |
|
| 42 |
import logging
|
| 43 |
import re
|
|
|
|
| 44 |
from dataclasses import dataclass
|
| 45 |
from pathlib import Path
|
| 46 |
from typing import Literal
|
|
@@ -301,8 +302,8 @@ def _normalize_slang(text: str) -> str:
|
|
| 301 |
det = get_detector(load=False)
|
| 302 |
if not det.is_loaded or det.slang_dict_size == 0:
|
| 303 |
return text
|
| 304 |
-
|
| 305 |
-
return
|
| 306 |
except Exception:
|
| 307 |
return text
|
| 308 |
|
|
@@ -340,6 +341,9 @@ class ResponsibleChecker:
|
|
| 340 |
self._ml_model = ml_model
|
| 341 |
self._normalize_slang = normalize_slang
|
| 342 |
self._ml_pipe = None
|
|
|
|
|
|
|
|
|
|
| 343 |
self._loaded = False
|
| 344 |
|
| 345 |
# ββ Public API ββ
|
|
@@ -475,6 +479,16 @@ class ResponsibleChecker:
|
|
| 475 |
"""True jika model ML berhasil dimuat dan Layer 2 aktif."""
|
| 476 |
return self._ml_pipe is not None
|
| 477 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 478 |
# ββ Internal ββ
|
| 479 |
|
| 480 |
def _check_ml(
|
|
@@ -482,12 +496,13 @@ class ResponsibleChecker:
|
|
| 482 |
) -> list[ResponsibleFinding]:
|
| 483 |
"""Jalankan zero-shot classification dan kembalikan temuan baru."""
|
| 484 |
try:
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
|
|
|
| 491 |
except Exception as exc:
|
| 492 |
logger.warning("Zero-shot ML gagal: %s", exc)
|
| 493 |
return []
|
|
|
|
| 41 |
|
| 42 |
import logging
|
| 43 |
import re
|
| 44 |
+
import threading
|
| 45 |
from dataclasses import dataclass
|
| 46 |
from pathlib import Path
|
| 47 |
from typing import Literal
|
|
|
|
| 302 |
det = get_detector(load=False)
|
| 303 |
if not det.is_loaded or det.slang_dict_size == 0:
|
| 304 |
return text
|
| 305 |
+
# Pakai API publik (tanpa akses field privat _slang_dict)
|
| 306 |
+
return det.normalize_slang(text)
|
| 307 |
except Exception:
|
| 308 |
return text
|
| 309 |
|
|
|
|
| 341 |
self._ml_model = ml_model
|
| 342 |
self._normalize_slang = normalize_slang
|
| 343 |
self._ml_pipe = None
|
| 344 |
+
# Model torch tidak thread-safe; lock ini menyerialkan inferensi saat
|
| 345 |
+
# banyak field dievaluasi paralel (ThreadPoolExecutor di pipeline).
|
| 346 |
+
self._ml_lock = threading.Lock()
|
| 347 |
self._loaded = False
|
| 348 |
|
| 349 |
# ββ Public API ββ
|
|
|
|
| 479 |
"""True jika model ML berhasil dimuat dan Layer 2 aktif."""
|
| 480 |
return self._ml_pipe is not None
|
| 481 |
|
| 482 |
+
@property
|
| 483 |
+
def ml_pipe(self):
|
| 484 |
+
"""Pipeline zero-shot (agar FillerChecker bisa meminjam tanpa akses privat)."""
|
| 485 |
+
return self._ml_pipe
|
| 486 |
+
|
| 487 |
+
@property
|
| 488 |
+
def ml_lock(self) -> threading.Lock:
|
| 489 |
+
"""Lock inferensi ML β dibagikan ke peminjam pipeline agar serialisasi konsisten."""
|
| 490 |
+
return self._ml_lock
|
| 491 |
+
|
| 492 |
# ββ Internal ββ
|
| 493 |
|
| 494 |
def _check_ml(
|
|
|
|
| 496 |
) -> list[ResponsibleFinding]:
|
| 497 |
"""Jalankan zero-shot classification dan kembalikan temuan baru."""
|
| 498 |
try:
|
| 499 |
+
with self._ml_lock:
|
| 500 |
+
result = self._ml_pipe(
|
| 501 |
+
text,
|
| 502 |
+
candidate_labels=_ML_LABELS,
|
| 503 |
+
hypothesis_template="Teks ini mengandung {}.",
|
| 504 |
+
multi_label=False,
|
| 505 |
+
)
|
| 506 |
except Exception as exc:
|
| 507 |
logger.warning("Zero-shot ML gagal: %s", exc)
|
| 508 |
return []
|
src/syntax/__init__.py
ADDED
|
File without changes
|
src/syntax/syntax_checker.py
ADDED
|
@@ -0,0 +1,425 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ============================================================
|
| 2 |
+
# FILE : syntax_checker.py
|
| 3 |
+
# FUNGSI: Detektor urutan kata janggal Bahasa Indonesia
|
| 4 |
+
# AUTHOR: Ariel Jonathan
|
| 5 |
+
# ============================================================
|
| 6 |
+
"""
|
| 7 |
+
Syntax Checker β Detektor urutan kata tidak wajar dalam Bahasa Indonesia.
|
| 8 |
+
|
| 9 |
+
Mendeteksi kalimat yang susunan katanya janggal sehingga sulit dipahami AI:
|
| 10 |
+
|
| 11 |
+
"makan suka aku" β seharusnya "aku suka makan"
|
| 12 |
+
"laporan saya buat sudah" β seharusnya "saya sudah buat laporan"
|
| 13 |
+
|
| 14 |
+
Pendekatan: Pseudo-Log-Likelihood (PLL) + perbandingan permutasi, via IndoBERT.
|
| 15 |
+
|
| 16 |
+
Bahasa Indonesia bersifat fleksibel β urutan non-S-V-O sering tetap sah
|
| 17 |
+
(kalimat pasif "di-", topik-prominan, penekanan). Oleh karena itu deteksi
|
| 18 |
+
tidak memakai aturan tata bahasa kaku, melainkan mengukur seberapa "wajar"
|
| 19 |
+
sebuah kalimat secara statistik menurut model bahasa.
|
| 20 |
+
|
| 21 |
+
Untuk tiap kalimat dihitung rata-rata log-probabilitas per token (Salazar
|
| 22 |
+
et al. 2020, "Masked Language Model Scoring"): tiap token di-mask bergantian,
|
| 23 |
+
lalu model memprediksi token aslinya.
|
| 24 |
+
|
| 25 |
+
Skor PLL absolut bergantung pada panjang & kosakata kalimat, sehingga ambang
|
| 26 |
+
tetap rawan false positive. Solusinya: kalimat dibandingkan dengan beberapa
|
| 27 |
+
PERMUTASI ACAK kata-katanya sendiri. Kalimat gramatikal jauh mengungguli
|
| 28 |
+
acakannya (hampir tidak ada acakan yang lebih baik); kalimat janggal tidak.
|
| 29 |
+
Jika mayoritas permutasi acak justru lebih wajar daripada urutan aslinya,
|
| 30 |
+
kalimat ditandai UNUSUAL_WORD_ORDER. Metode ini self-normalizing β bebas dari
|
| 31 |
+
bias panjang/kosakata karena membandingkan kalimat dengan kata-katanya sendiri.
|
| 32 |
+
|
| 33 |
+
Karena ambiguitas tata bahasa Indonesia cukup tinggi, temuan bersifat SARAN
|
| 34 |
+
(severity rendah), bukan kesalahan pasti.
|
| 35 |
+
|
| 36 |
+
Model: indolem/indobert-base-uncased (~420 MB, masked language model)
|
| 37 |
+
|
| 38 |
+
Referensi:
|
| 39 |
+
Salazar et al. (2020). Masked Language Model Scoring. ACL 2020.
|
| 40 |
+
Koto et al. (2020). IndoLEM and IndoBERT. COLING 2020.
|
| 41 |
+
"""
|
| 42 |
+
|
| 43 |
+
from __future__ import annotations
|
| 44 |
+
|
| 45 |
+
import importlib.util
|
| 46 |
+
import logging
|
| 47 |
+
import random
|
| 48 |
+
import re
|
| 49 |
+
import threading
|
| 50 |
+
from dataclasses import dataclass
|
| 51 |
+
|
| 52 |
+
logger = logging.getLogger(__name__)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
# ββ Dependensi Opsional ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 56 |
+
#
|
| 57 |
+
# Cek ketersediaan transformers + torch TANPA mengimpornya (find_spec ~0 ms).
|
| 58 |
+
# Impor sebenarnya (~4 dtk) ditunda ke load() β hanya saat ML benar-benar dipakai.
|
| 59 |
+
|
| 60 |
+
_TRANSFORMERS_OK = (
|
| 61 |
+
importlib.util.find_spec("transformers") is not None
|
| 62 |
+
and importlib.util.find_spec("torch") is not None
|
| 63 |
+
)
|
| 64 |
+
if not _TRANSFORMERS_OK:
|
| 65 |
+
logger.info("transformers/torch tidak terinstal β Syntax Checker dinonaktifkan. "
|
| 66 |
+
"Jalankan: pip install transformers torch")
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
# ββ Konstanta & Konfigurasi ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 70 |
+
|
| 71 |
+
_MODEL_ID = "indolem/indobert-base-uncased"
|
| 72 |
+
|
| 73 |
+
# Panjang kalimat (dalam kata) yang diperiksa. Terlalu pendek β tidak cukup
|
| 74 |
+
# konteks; terlalu panjang β mahal & ruang permutasi terlalu besar.
|
| 75 |
+
_MIN_WORDS = 4
|
| 76 |
+
_MAX_WORDS = 18
|
| 77 |
+
|
| 78 |
+
# Token wordpiece maksimum per kalimat (batas keamanan komputasi)
|
| 79 |
+
_MAX_TOKENS = 48
|
| 80 |
+
|
| 81 |
+
# Jumlah kalimat maksimum yang diperiksa per pemanggilan (batas latency)
|
| 82 |
+
_MAX_SENTENCES = 6
|
| 83 |
+
|
| 84 |
+
# Jumlah permutasi acak per kalimat untuk pembanding self-normalizing.
|
| 85 |
+
_SHUFFLE_COUNT = 6
|
| 86 |
+
|
| 87 |
+
# Ambang rasio: tandai jika fraksi permutasi acak yang LEBIH WAJAR dari urutan
|
| 88 |
+
# asli mencapai nilai ini. 0.66 = mayoritas (β₯2/3) acakan mengalahkan urutan asli
|
| 89 |
+
# β indikasi kuat urutan kata bermasalah. Makin tinggi β makin konservatif.
|
| 90 |
+
_RATIO_THRESHOLD = 0.66
|
| 91 |
+
|
| 92 |
+
# Seed tetap agar hasil deterministik antar-pemanggilan.
|
| 93 |
+
_RNG_SEED = 1234
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
# ββ Tipe Data ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 97 |
+
|
| 98 |
+
@dataclass(frozen=True)
|
| 99 |
+
class SyntaxFinding:
|
| 100 |
+
"""Satu kalimat dengan urutan kata yang terindikasi janggal."""
|
| 101 |
+
|
| 102 |
+
sentence: str # kalimat yang ditandai (dipotong jika > 80 karakter di pipeline)
|
| 103 |
+
start: int # offset karakter awal dalam teks asli
|
| 104 |
+
end: int # offset karakter akhir (eksklusif)
|
| 105 |
+
score: float # rata-rata log-probabilitas per token (makin rendah makin janggal)
|
| 106 |
+
reason: str # penjelasan untuk pengguna
|
| 107 |
+
confidence: float # skor kepercayaan 0.0β1.0
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
# ββ Tokenisasi Kalimat ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 111 |
+
|
| 112 |
+
# Pisah teks menjadi kalimat berdasarkan tanda akhir (. ! ?) atau baris baru,
|
| 113 |
+
# sambil mempertahankan offset karakter di teks asli.
|
| 114 |
+
_SENTENCE_SPLIT = re.compile(r'[^.!?\n]+(?:[.!?]+|\n|$)')
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def _split_sentences(text: str) -> list[tuple[str, int, int]]:
|
| 118 |
+
"""
|
| 119 |
+
Pecah teks menjadi (kalimat, start, end) dengan offset di teks asli.
|
| 120 |
+
|
| 121 |
+
Hanya kalimat dengan jumlah kata dalam rentang [_MIN_WORDS, _MAX_WORDS]
|
| 122 |
+
yang dikembalikan.
|
| 123 |
+
"""
|
| 124 |
+
results: list[tuple[str, int, int]] = []
|
| 125 |
+
for m in _SENTENCE_SPLIT.finditer(text):
|
| 126 |
+
raw = m.group()
|
| 127 |
+
stripped = raw.strip()
|
| 128 |
+
if not stripped:
|
| 129 |
+
continue
|
| 130 |
+
word_count = len(stripped.split())
|
| 131 |
+
if word_count < _MIN_WORDS or word_count > _MAX_WORDS:
|
| 132 |
+
continue
|
| 133 |
+
# Sesuaikan offset agar menunjuk teks tanpa spasi pinggir
|
| 134 |
+
lead = len(raw) - len(raw.lstrip())
|
| 135 |
+
start = m.start() + lead
|
| 136 |
+
end = start + len(stripped)
|
| 137 |
+
results.append((stripped, start, end))
|
| 138 |
+
return results
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def _make_shuffles(words: list[str], count: int, rng: random.Random) -> list[list[str]]:
|
| 142 |
+
"""
|
| 143 |
+
Hasilkan hingga `count` permutasi acak unik dari `words`, semuanya berbeda
|
| 144 |
+
dari urutan asli. Mengembalikan list kosong jika kata terlalu sedikit.
|
| 145 |
+
"""
|
| 146 |
+
if len(words) < 3:
|
| 147 |
+
return []
|
| 148 |
+
original = tuple(words)
|
| 149 |
+
seen: set[tuple[str, ...]] = {original}
|
| 150 |
+
shuffles: list[list[str]] = []
|
| 151 |
+
# Batasi percobaan agar tidak loop selamanya pada kalimat dengan kata berulang.
|
| 152 |
+
for _ in range(count * 6):
|
| 153 |
+
if len(shuffles) >= count:
|
| 154 |
+
break
|
| 155 |
+
candidate = words[:]
|
| 156 |
+
rng.shuffle(candidate)
|
| 157 |
+
key = tuple(candidate)
|
| 158 |
+
if key in seen:
|
| 159 |
+
continue
|
| 160 |
+
seen.add(key)
|
| 161 |
+
shuffles.append(candidate)
|
| 162 |
+
return shuffles
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
# ββ Kelas Utama ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 166 |
+
|
| 167 |
+
class SyntaxChecker:
|
| 168 |
+
"""
|
| 169 |
+
Detektor urutan kata janggal Bahasa Indonesia berbasis perplexity IndoBERT.
|
| 170 |
+
|
| 171 |
+
Contoh penggunaan::
|
| 172 |
+
|
| 173 |
+
chk = SyntaxChecker()
|
| 174 |
+
chk.load()
|
| 175 |
+
for f in chk.check("makan suka aku nasi goreng"):
|
| 176 |
+
print(f.sentence, f.score, f.confidence)
|
| 177 |
+
"""
|
| 178 |
+
|
| 179 |
+
def __init__(self, use_ml: bool = True, model_id: str = _MODEL_ID) -> None:
|
| 180 |
+
"""
|
| 181 |
+
Args:
|
| 182 |
+
use_ml: Aktifkan deteksi (butuh transformers + torch). Jika False
|
| 183 |
+
atau dependensi tidak ada, check() selalu mengembalikan [].
|
| 184 |
+
model_id: ID model HuggingFace masked-LM untuk skoring.
|
| 185 |
+
"""
|
| 186 |
+
self._use_ml = use_ml and _TRANSFORMERS_OK
|
| 187 |
+
self._model_id = model_id
|
| 188 |
+
self._tokenizer = None
|
| 189 |
+
self._model = None
|
| 190 |
+
self._torch = None
|
| 191 |
+
# Model torch tidak thread-safe; lock menyerialkan inferensi saat beberapa
|
| 192 |
+
# field (task/context/references) dievaluasi paralel di pipeline.
|
| 193 |
+
self._ml_lock = threading.Lock()
|
| 194 |
+
self._loaded = False
|
| 195 |
+
|
| 196 |
+
# ββ Public API ββ
|
| 197 |
+
|
| 198 |
+
def load(self) -> bool:
|
| 199 |
+
"""
|
| 200 |
+
Muat tokenizer + model masked-LM.
|
| 201 |
+
|
| 202 |
+
Returns:
|
| 203 |
+
True jika model berhasil dimuat (deteksi aktif), False jika tidak.
|
| 204 |
+
"""
|
| 205 |
+
if self._loaded:
|
| 206 |
+
return self._model is not None
|
| 207 |
+
|
| 208 |
+
self._loaded = True
|
| 209 |
+
if not self._use_ml:
|
| 210 |
+
return False
|
| 211 |
+
|
| 212 |
+
try:
|
| 213 |
+
import torch
|
| 214 |
+
from transformers import AutoModelForMaskedLM, AutoTokenizer
|
| 215 |
+
|
| 216 |
+
logger.info("Memuat model Syntax Checker '%s'...", self._model_id)
|
| 217 |
+
self._torch = torch
|
| 218 |
+
self._tokenizer = AutoTokenizer.from_pretrained(self._model_id)
|
| 219 |
+
self._model = AutoModelForMaskedLM.from_pretrained(self._model_id)
|
| 220 |
+
self._model.eval()
|
| 221 |
+
logger.info("Syntax Checker siap (model IndoBERT aktif).")
|
| 222 |
+
return True
|
| 223 |
+
except Exception as exc:
|
| 224 |
+
logger.warning("Gagal memuat Syntax Checker: %s β deteksi dinonaktifkan.", exc)
|
| 225 |
+
self._model = None
|
| 226 |
+
return False
|
| 227 |
+
|
| 228 |
+
def check(self, text: str, language: str = "id") -> list[SyntaxFinding]:
|
| 229 |
+
"""
|
| 230 |
+
Periksa teks dan kembalikan kalimat dengan urutan kata janggal.
|
| 231 |
+
|
| 232 |
+
Args:
|
| 233 |
+
text: Teks yang akan diperiksa.
|
| 234 |
+
language: Hanya teks Indonesia ("id"/"mixed"/"unknown") yang diperiksa.
|
| 235 |
+
Teks Inggris ("en") dilewati β model dilatih untuk Indonesia.
|
| 236 |
+
|
| 237 |
+
Returns:
|
| 238 |
+
Daftar SyntaxFinding diurutkan berdasarkan posisi (start ascending).
|
| 239 |
+
"""
|
| 240 |
+
if not text or not text.strip():
|
| 241 |
+
return []
|
| 242 |
+
if language == "en":
|
| 243 |
+
return []
|
| 244 |
+
if not self._loaded:
|
| 245 |
+
self.load()
|
| 246 |
+
if self._model is None:
|
| 247 |
+
return []
|
| 248 |
+
|
| 249 |
+
sentences = _split_sentences(text)[:_MAX_SENTENCES]
|
| 250 |
+
if not sentences:
|
| 251 |
+
return []
|
| 252 |
+
|
| 253 |
+
rng = random.Random(_RNG_SEED)
|
| 254 |
+
findings: list[SyntaxFinding] = []
|
| 255 |
+
|
| 256 |
+
for sentence, start, end in sentences:
|
| 257 |
+
words = sentence.split()
|
| 258 |
+
if len(words) < _MIN_WORDS:
|
| 259 |
+
continue
|
| 260 |
+
|
| 261 |
+
# Bangun permutasi acak yang berbeda dari urutan asli.
|
| 262 |
+
shuffles = _make_shuffles(words, _SHUFFLE_COUNT, rng)
|
| 263 |
+
if not shuffles:
|
| 264 |
+
continue
|
| 265 |
+
|
| 266 |
+
# Skor urutan asli + semua permutasi dalam satu forward pass.
|
| 267 |
+
variants = [sentence] + [" ".join(p) for p in shuffles]
|
| 268 |
+
scores = self._pll_many(variants)
|
| 269 |
+
orig_score = scores[0]
|
| 270 |
+
shuffle_scores = [s for s in scores[1:] if s is not None]
|
| 271 |
+
if orig_score is None or not shuffle_scores:
|
| 272 |
+
continue
|
| 273 |
+
|
| 274 |
+
# Fraksi permutasi acak yang lebih wajar daripada urutan asli.
|
| 275 |
+
better = sum(1 for s in shuffle_scores if s > orig_score)
|
| 276 |
+
ratio = better / len(shuffle_scores)
|
| 277 |
+
if ratio < _RATIO_THRESHOLD:
|
| 278 |
+
continue
|
| 279 |
+
|
| 280 |
+
# Kepercayaan proporsional terhadap rasio (0.66β~0.55, 1.0β~0.85).
|
| 281 |
+
confidence = max(0.50, min(0.85, 0.30 + ratio * 0.55))
|
| 282 |
+
findings.append(SyntaxFinding(
|
| 283 |
+
sentence=sentence,
|
| 284 |
+
start=start,
|
| 285 |
+
end=end,
|
| 286 |
+
score=round(orig_score, 3),
|
| 287 |
+
reason="Susunan kata kalimat ini terasa tidak wajar dan mungkin sulit "
|
| 288 |
+
"dipahami AI. Periksa kembali urutan kata β pastikan mengikuti "
|
| 289 |
+
"pola yang lazim (mis. subjekβpredikatβobjek).",
|
| 290 |
+
confidence=round(confidence, 3),
|
| 291 |
+
))
|
| 292 |
+
|
| 293 |
+
return findings
|
| 294 |
+
|
| 295 |
+
# ββ Properties ββ
|
| 296 |
+
|
| 297 |
+
@property
|
| 298 |
+
def is_loaded(self) -> bool:
|
| 299 |
+
"""True jika checker sudah mencoba memuat model."""
|
| 300 |
+
return self._loaded
|
| 301 |
+
|
| 302 |
+
@property
|
| 303 |
+
def ml_active(self) -> bool:
|
| 304 |
+
"""True jika model berhasil dimuat dan deteksi aktif."""
|
| 305 |
+
return self._model is not None
|
| 306 |
+
|
| 307 |
+
# ββ Internal ββ
|
| 308 |
+
|
| 309 |
+
def _pll_many(self, variants: list[str]) -> list[float | None]:
|
| 310 |
+
"""
|
| 311 |
+
Hitung rata-rata pseudo-log-likelihood per token untuk beberapa kalimat
|
| 312 |
+
sekaligus dalam SATU forward pass.
|
| 313 |
+
|
| 314 |
+
Untuk setiap varian, tiap token (kecuali token spesial) di-mask bergantian;
|
| 315 |
+
model memprediksi token aslinya. Semua baris ter-mask dari semua varian
|
| 316 |
+
digabung ke satu batch agar efisien, lalu hasilnya dipisah kembali.
|
| 317 |
+
|
| 318 |
+
Returns:
|
| 319 |
+
Daftar skor rata-rata log-prob (sejajar dengan `variants`); elemen
|
| 320 |
+
None untuk varian yang terlalu pendek / tak punya token termaskable.
|
| 321 |
+
"""
|
| 322 |
+
torch = self._torch
|
| 323 |
+
tok = self._tokenizer
|
| 324 |
+
model = self._model
|
| 325 |
+
|
| 326 |
+
mask_id = tok.mask_token_id
|
| 327 |
+
pad_id = tok.pad_token_id if tok.pad_token_id is not None else 0
|
| 328 |
+
if mask_id is None:
|
| 329 |
+
return [None] * len(variants)
|
| 330 |
+
|
| 331 |
+
special_ids = set(tok.all_special_ids)
|
| 332 |
+
|
| 333 |
+
# Kumpulkan semua baris ter-mask dari semua varian.
|
| 334 |
+
# Tiap entri: (variant_idx, masked_ids, true_token_id, pos)
|
| 335 |
+
masked_rows: list[tuple[int, list[int], int, int]] = []
|
| 336 |
+
for vi, text in enumerate(variants):
|
| 337 |
+
ids = tok(text, truncation=True, max_length=_MAX_TOKENS)["input_ids"]
|
| 338 |
+
positions = [i for i, t in enumerate(ids) if t not in special_ids]
|
| 339 |
+
for pos in positions:
|
| 340 |
+
masked = list(ids)
|
| 341 |
+
true_tok = masked[pos]
|
| 342 |
+
masked[pos] = mask_id
|
| 343 |
+
masked_rows.append((vi, masked, true_tok, pos))
|
| 344 |
+
|
| 345 |
+
if not masked_rows:
|
| 346 |
+
return [None] * len(variants)
|
| 347 |
+
|
| 348 |
+
max_len = max(len(m[1]) for m in masked_rows)
|
| 349 |
+
n_rows = len(masked_rows)
|
| 350 |
+
|
| 351 |
+
input_ids = torch.full((n_rows, max_len), pad_id, dtype=torch.long)
|
| 352 |
+
attn = torch.zeros((n_rows, max_len), dtype=torch.long)
|
| 353 |
+
for r, (_vi, masked, _tok, _pos) in enumerate(masked_rows):
|
| 354 |
+
L = len(masked)
|
| 355 |
+
input_ids[r, :L] = torch.tensor(masked, dtype=torch.long)
|
| 356 |
+
attn[r, :L] = 1
|
| 357 |
+
|
| 358 |
+
with self._ml_lock, torch.no_grad():
|
| 359 |
+
logits = model(input_ids=input_ids, attention_mask=attn).logits
|
| 360 |
+
|
| 361 |
+
sums: list[float] = [0.0] * len(variants)
|
| 362 |
+
counts: list[int] = [0] * len(variants)
|
| 363 |
+
for r, (vi, _masked, true_tok, pos) in enumerate(masked_rows):
|
| 364 |
+
log_probs = torch.log_softmax(logits[r, pos], dim=-1)
|
| 365 |
+
sums[vi] += log_probs[true_tok].item()
|
| 366 |
+
counts[vi] += 1
|
| 367 |
+
|
| 368 |
+
return [
|
| 369 |
+
(sums[i] / counts[i]) if counts[i] else None
|
| 370 |
+
for i in range(len(variants))
|
| 371 |
+
]
|
| 372 |
+
|
| 373 |
+
|
| 374 |
+
# ββ Singleton ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 375 |
+
|
| 376 |
+
_default_checker: SyntaxChecker | None = None
|
| 377 |
+
|
| 378 |
+
|
| 379 |
+
def get_checker(use_ml: bool = True, load: bool = True) -> SyntaxChecker:
|
| 380 |
+
"""
|
| 381 |
+
Kembalikan instance SyntaxChecker singleton (lazy-initialized).
|
| 382 |
+
|
| 383 |
+
Args:
|
| 384 |
+
use_ml: Aktifkan deteksi berbasis ML.
|
| 385 |
+
load: Jika True, panggil load() otomatis sebelum dikembalikan.
|
| 386 |
+
"""
|
| 387 |
+
global _default_checker
|
| 388 |
+
if _default_checker is None:
|
| 389 |
+
_default_checker = SyntaxChecker(use_ml=use_ml)
|
| 390 |
+
if load and not _default_checker.is_loaded:
|
| 391 |
+
_default_checker.load()
|
| 392 |
+
return _default_checker
|
| 393 |
+
|
| 394 |
+
|
| 395 |
+
# ββ Demo CLI βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 396 |
+
|
| 397 |
+
if __name__ == "__main__":
|
| 398 |
+
import sys
|
| 399 |
+
|
| 400 |
+
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
|
| 401 |
+
|
| 402 |
+
SAMPLES = [
|
| 403 |
+
"Aku suka makan nasi goreng.", # normal
|
| 404 |
+
"Makan suka aku nasi goreng.", # janggal
|
| 405 |
+
"Saya sudah menyelesaikan laporan ini.", # normal
|
| 406 |
+
"Laporan ini saya sudah selesaikan.", # agak janggal
|
| 407 |
+
"Tolong buatkan ringkasan artikel ini.", # normal
|
| 408 |
+
"Ringkasan ini artikel buatkan tolong.", # janggal
|
| 409 |
+
]
|
| 410 |
+
|
| 411 |
+
texts = sys.argv[1:] or SAMPLES
|
| 412 |
+
chk = SyntaxChecker()
|
| 413 |
+
|
| 414 |
+
if not chk.load():
|
| 415 |
+
print("[WARN] Model tidak tersedia β deteksi nonaktif.")
|
| 416 |
+
sys.exit(0)
|
| 417 |
+
|
| 418 |
+
print(f"Permutasi/kalimat: {_SHUFFLE_COUNT} | ambang rasio: {_RATIO_THRESHOLD:.0%}\n{'-' * 60}")
|
| 419 |
+
for text in texts:
|
| 420 |
+
findings = chk.check(text)
|
| 421 |
+
print(f"\n> {text}")
|
| 422 |
+
if not findings:
|
| 423 |
+
print(" (urutan kata wajar)")
|
| 424 |
+
for f in findings:
|
| 425 |
+
print(f" [JANGGAL] skor={f.score:.2f} conf={f.confidence:.0%} {f.sentence!r}")
|
src/syntax/syntax_server.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ============================================================
|
| 2 |
+
# FILE : syntax_server.py
|
| 3 |
+
# FUNGSI: Server HTTP standalone untuk Syntax Checker (port 8008)
|
| 4 |
+
# AUTHOR: Ariel Jonathan
|
| 5 |
+
# ============================================================
|
| 6 |
+
"""
|
| 7 |
+
Server HTTP untuk Syntax Checker β http://127.0.0.1:8008
|
| 8 |
+
|
| 9 |
+
Endpoint:
|
| 10 |
+
GET /api/status β health-check
|
| 11 |
+
POST /api/syntax β {"text": "...", "language": "id"}
|
| 12 |
+
β {"findings": [...], "finding_count": int}
|
| 13 |
+
|
| 14 |
+
Jalankan:
|
| 15 |
+
python -m src.syntax.syntax_server
|
| 16 |
+
python src/syntax/syntax_server.py --no-ml # nonaktifkan model (deteksi mati)
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
import json
|
| 22 |
+
import logging
|
| 23 |
+
import sys
|
| 24 |
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
| 25 |
+
from pathlib import Path
|
| 26 |
+
|
| 27 |
+
sys.path.insert(0, str(Path(__file__).parent.parent))
|
| 28 |
+
from syntax.syntax_checker import SyntaxChecker
|
| 29 |
+
|
| 30 |
+
logging.basicConfig(
|
| 31 |
+
level=logging.INFO,
|
| 32 |
+
format="%(asctime)s %(levelname)-7s %(message)s",
|
| 33 |
+
datefmt="%H:%M:%S",
|
| 34 |
+
)
|
| 35 |
+
logger = logging.getLogger(__name__)
|
| 36 |
+
|
| 37 |
+
_checker: SyntaxChecker | None = None
|
| 38 |
+
_use_ml = True
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _get() -> SyntaxChecker:
|
| 42 |
+
global _checker
|
| 43 |
+
if _checker is None:
|
| 44 |
+
_checker = SyntaxChecker(use_ml=_use_ml)
|
| 45 |
+
_checker.load()
|
| 46 |
+
return _checker
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class Handler(BaseHTTPRequestHandler):
|
| 50 |
+
|
| 51 |
+
def _cors(self):
|
| 52 |
+
self.send_header("Access-Control-Allow-Origin", "*")
|
| 53 |
+
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
| 54 |
+
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
| 55 |
+
|
| 56 |
+
def do_OPTIONS(self):
|
| 57 |
+
self.send_response(204); self._cors(); self.end_headers()
|
| 58 |
+
|
| 59 |
+
def _json(self, status: int, body: object):
|
| 60 |
+
payload = json.dumps(body, ensure_ascii=False).encode()
|
| 61 |
+
try:
|
| 62 |
+
self.send_response(status)
|
| 63 |
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
| 64 |
+
self.send_header("Content-Length", str(len(payload)))
|
| 65 |
+
self._cors()
|
| 66 |
+
self.end_headers()
|
| 67 |
+
self.wfile.write(payload)
|
| 68 |
+
except (ConnectionAbortedError, BrokenPipeError, ConnectionResetError):
|
| 69 |
+
pass
|
| 70 |
+
|
| 71 |
+
def _body(self) -> dict | None:
|
| 72 |
+
n = int(self.headers.get("Content-Length", 0))
|
| 73 |
+
if not n:
|
| 74 |
+
return {}
|
| 75 |
+
try:
|
| 76 |
+
return json.loads(self.rfile.read(n))
|
| 77 |
+
except (json.JSONDecodeError, ValueError):
|
| 78 |
+
return None
|
| 79 |
+
|
| 80 |
+
def log_message(self, fmt, *args):
|
| 81 |
+
logger.info("%-6s %s", args[0] if args else "", args[1] if len(args) > 1 else "")
|
| 82 |
+
|
| 83 |
+
def do_GET(self):
|
| 84 |
+
if self.path == "/api/status":
|
| 85 |
+
chk = _get()
|
| 86 |
+
self._json(200, {
|
| 87 |
+
"ready": True,
|
| 88 |
+
"ml_active": chk.ml_active,
|
| 89 |
+
})
|
| 90 |
+
else:
|
| 91 |
+
self._json(404, {"error": "Not found"})
|
| 92 |
+
|
| 93 |
+
def do_POST(self):
|
| 94 |
+
if self.path != "/api/syntax":
|
| 95 |
+
self._json(404, {"error": "Not found"}); return
|
| 96 |
+
|
| 97 |
+
body = self._body()
|
| 98 |
+
if body is None:
|
| 99 |
+
self._json(400, {"error": "JSON tidak valid."}); return
|
| 100 |
+
|
| 101 |
+
text = str(body.get("text", "")).strip()
|
| 102 |
+
if not text:
|
| 103 |
+
self._json(400, {"error": "Field 'text' kosong."}); return
|
| 104 |
+
|
| 105 |
+
language = str(body.get("language", "id"))
|
| 106 |
+
findings = _get().check(text, language=language)
|
| 107 |
+
self._json(200, {
|
| 108 |
+
"text": text,
|
| 109 |
+
"finding_count": len(findings),
|
| 110 |
+
"findings": [
|
| 111 |
+
{
|
| 112 |
+
"sentence": f.sentence,
|
| 113 |
+
"start": f.start,
|
| 114 |
+
"end": f.end,
|
| 115 |
+
"score": f.score,
|
| 116 |
+
"reason": f.reason,
|
| 117 |
+
"confidence": f.confidence,
|
| 118 |
+
}
|
| 119 |
+
for f in findings
|
| 120 |
+
],
|
| 121 |
+
})
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
if __name__ == "__main__":
|
| 125 |
+
import argparse
|
| 126 |
+
parser = argparse.ArgumentParser()
|
| 127 |
+
parser.add_argument("--port", type=int, default=8008)
|
| 128 |
+
parser.add_argument("--host", default="127.0.0.1")
|
| 129 |
+
parser.add_argument("--no-ml", action="store_true",
|
| 130 |
+
help="Nonaktifkan model (deteksi mati, server tetap jalan).")
|
| 131 |
+
args = parser.parse_args()
|
| 132 |
+
|
| 133 |
+
_use_ml = not args.no_ml
|
| 134 |
+
logger.info("Memuat Syntax Checker (ML: %s)...", "aktif" if _use_ml else "nonaktif")
|
| 135 |
+
chk = _get()
|
| 136 |
+
logger.info("Syntax Checker siap (ML aktif: %s).", chk.ml_active)
|
| 137 |
+
server = HTTPServer((args.host, args.port), Handler)
|
| 138 |
+
logger.info("Syntax server berjalan di http://%s:%d", args.host, args.port)
|
| 139 |
+
try:
|
| 140 |
+
server.serve_forever()
|
| 141 |
+
except KeyboardInterrupt:
|
| 142 |
+
server.server_close()
|
src/word_quality/word_quality_detector.py
CHANGED
|
@@ -143,7 +143,9 @@ _BUILTIN_SLANG: dict[str, str] = {
|
|
| 143 |
# Kata kerja & partikel umum
|
| 144 |
"udah": "sudah", "udh": "sudah", "sdh": "sudah",
|
| 145 |
"blm": "belum", "blom": "belum",
|
| 146 |
-
"lagi"
|
|
|
|
|
|
|
| 147 |
"kalo": "kalau", "klo": "kalau", "klu": "kalau",
|
| 148 |
"sampe": "sampai", "ampe": "sampai",
|
| 149 |
"gimana": "bagaimana", "gmn": "bagaimana",
|
|
@@ -184,7 +186,7 @@ _BUILTIN_SLANG: dict[str, str] = {
|
|
| 184 |
"mau": "mau", # valid
|
| 185 |
"dong": "dong", # valid
|
| 186 |
"eh": "eh", # ekspresi, valid
|
| 187 |
-
"buat":
|
| 188 |
}
|
| 189 |
|
| 190 |
# Hapus entri yang tidak punya padanan formal (None) dan self-mapping
|
|
@@ -293,11 +295,26 @@ _NOISY_CASING = re.compile(r'\b[a-zA-Z]{3,}\b')
|
|
| 293 |
|
| 294 |
|
| 295 |
def _is_noisy_casing(word: str) -> bool:
|
| 296 |
-
"""Kembalikan True jika kata memiliki casing campur yang tidak wajar.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 297 |
if word == word.lower() or word == word.upper() or word == word.title():
|
| 298 |
return False
|
| 299 |
-
#
|
| 300 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 301 |
|
| 302 |
|
| 303 |
# ββ Loader Data ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -604,15 +621,65 @@ class WordQualityDetector:
|
|
| 604 |
"""True jika SymSpell berhasil dimuat (deteksi typo aktif)."""
|
| 605 |
return self._sym_spell is not None or self._sym_spell_en is not None
|
| 606 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 607 |
# ββ Sub-Detektor Internal ββ
|
| 608 |
|
| 609 |
def _check_slang(self, word: str, start: int, end: int, language: str = "id") -> WordIssue | None:
|
| 610 |
"""
|
| 611 |
Cek apakah kata ada di kamus slang dan memiliki padanan baku.
|
| 612 |
|
| 613 |
-
|
| 614 |
-
|
| 615 |
-
|
|
|
|
|
|
|
|
|
|
| 616 |
"""
|
| 617 |
if not self._use_slang:
|
| 618 |
return None
|
|
@@ -644,21 +711,6 @@ class WordQualityDetector:
|
|
| 644 |
reason=f"Kata informal; padanan baku: \"{formal}\".",
|
| 645 |
)
|
| 646 |
|
| 647 |
-
# Fallback: cari di tesaurus hanya untuk token Bahasa Indonesia.
|
| 648 |
-
if language == "en" or _language_for_token(key, language) == "en":
|
| 649 |
-
return None
|
| 650 |
-
thes_synonyms = _get_thesaurus().lookup(key, n=1)
|
| 651 |
-
if thes_synonyms:
|
| 652 |
-
suggestion = thes_synonyms[0]
|
| 653 |
-
if suggestion.lower() != key:
|
| 654 |
-
return WordIssue(
|
| 655 |
-
word=word, start=start, end=end,
|
| 656 |
-
issue_type="SLANG",
|
| 657 |
-
suggestion=suggestion,
|
| 658 |
-
confidence=0.75,
|
| 659 |
-
reason=f"Kata informal/tidak baku; sinonim formal: \"{suggestion}\".",
|
| 660 |
-
)
|
| 661 |
-
|
| 662 |
return None
|
| 663 |
|
| 664 |
def _check_alay(self, word: str, start: int, end: int, language: str = "id") -> WordIssue | None:
|
|
@@ -668,8 +720,10 @@ class WordQualityDetector:
|
|
| 668 |
w = word.lower()
|
| 669 |
|
| 670 |
# Pola 1: singkatan numerik reduplikasi β "kata2" β "kata-kata"
|
|
|
|
|
|
|
| 671 |
m = _NUMERIC_REDUPLICATION.fullmatch(w)
|
| 672 |
-
if m:
|
| 673 |
base = m.group(1)
|
| 674 |
suggestion = f"{base}-{base}"
|
| 675 |
return WordIssue(
|
|
@@ -698,20 +752,42 @@ class WordQualityDetector:
|
|
| 698 |
# Pola 3: substitusi angka-huruf (l33tspeak) β "k4mu" β "kamu"
|
| 699 |
# Hanya untuk kata pendek (β€ 15 karakter) agar token acak panjang tidak lolos
|
| 700 |
# sebelum sampai ke _check_typo.
|
| 701 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 702 |
candidate = w.translate(_NUM_TO_LETTER)
|
| 703 |
-
|
| 704 |
-
|
| 705 |
-
|
| 706 |
-
|
| 707 |
-
issue_type="ALAY",
|
| 708 |
-
suggestion=candidate,
|
| 709 |
-
confidence=0.85,
|
| 710 |
-
reason=f"Substitusi angka-huruf (l33tspeak); kemungkinan maksud: \"{candidate}\".",
|
| 711 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 712 |
|
| 713 |
# Pola 4: noisy casing campur β "hArUs" β "harus"
|
| 714 |
-
|
|
|
|
|
|
|
|
|
|
| 715 |
suggestion = word.lower()
|
| 716 |
return WordIssue(
|
| 717 |
word=word, start=start, end=end,
|
|
|
|
| 143 |
# Kata kerja & partikel umum
|
| 144 |
"udah": "sudah", "udh": "sudah", "sdh": "sudah",
|
| 145 |
"blm": "belum", "blom": "belum",
|
| 146 |
+
# "lagi" sengaja TIDAK dipetakan: ambigu (sedang vs. kembali/tambah).
|
| 147 |
+
# Hanya singkatan tak ambigu "lg" yang dipetakan.
|
| 148 |
+
"lg": "sedang",
|
| 149 |
"kalo": "kalau", "klo": "kalau", "klu": "kalau",
|
| 150 |
"sampe": "sampai", "ampe": "sampai",
|
| 151 |
"gimana": "bagaimana", "gmn": "bagaimana",
|
|
|
|
| 186 |
"mau": "mau", # valid
|
| 187 |
"dong": "dong", # valid
|
| 188 |
"eh": "eh", # ekspresi, valid
|
| 189 |
+
# "buat" sengaja TIDAK dipetakan: ambigu (untuk vs. membuat) β sering salah koreksi.
|
| 190 |
}
|
| 191 |
|
| 192 |
# Hapus entri yang tidak punya padanan formal (None) dan self-mapping
|
|
|
|
| 295 |
|
| 296 |
|
| 297 |
def _is_noisy_casing(word: str) -> bool:
|
| 298 |
+
"""Kembalikan True jika kata memiliki casing campur yang tidak wajar (ALAY).
|
| 299 |
+
|
| 300 |
+
Perbedaan ALAY vs CamelCase/akronim teknis:
|
| 301 |
+
- ALAY: bergantian acak β setidaknya 2 transisi huruf-kecil β huruf-besar
|
| 302 |
+
Contoh: hArUs (2 transisi), sUnGgUh (3), sEkArAnG (4)
|
| 303 |
+
- CamelCase: paling banyak 1 transisi kecilβbesar (1 kata baru internal)
|
| 304 |
+
Contoh: JavaScript (1 transisi: aβS), WiFi (1: iβF), iPhone (1: iβP)
|
| 305 |
+
"""
|
| 306 |
if word == word.lower() or word == word.upper() or word == word.title():
|
| 307 |
return False
|
| 308 |
+
# Kumpulkan hanya huruf (abaikan angka & simbol)
|
| 309 |
+
letters = [c for c in word if c.isalpha()]
|
| 310 |
+
if len(letters) < 2:
|
| 311 |
+
return False
|
| 312 |
+
# Hitung transisi huruf-kecil β huruf-besar di posisi 1 ke atas
|
| 313 |
+
transitions = sum(
|
| 314 |
+
1 for i in range(1, len(letters))
|
| 315 |
+
if letters[i].isupper() and letters[i - 1].islower()
|
| 316 |
+
)
|
| 317 |
+
return transitions >= 2
|
| 318 |
|
| 319 |
|
| 320 |
# ββ Loader Data ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 621 |
"""True jika SymSpell berhasil dimuat (deteksi typo aktif)."""
|
| 622 |
return self._sym_spell is not None or self._sym_spell_en is not None
|
| 623 |
|
| 624 |
+
# ββ API Publik untuk Modul Lain ββ
|
| 625 |
+
#
|
| 626 |
+
# Disediakan agar detektor lain (mis. ProfanityChecker, ResponsibleChecker)
|
| 627 |
+
# dapat memanfaatkan kamus slang & SymSpell TANPA mengakses field privat.
|
| 628 |
+
|
| 629 |
+
def correct_spelling(
|
| 630 |
+
self, word: str, max_edit_distance: int = _MAX_EDIT_DIST, language: str = "id"
|
| 631 |
+
) -> str | None:
|
| 632 |
+
"""
|
| 633 |
+
Kembalikan koreksi ejaan terbaik untuk `word` via SymSpell.
|
| 634 |
+
|
| 635 |
+
Args:
|
| 636 |
+
word: Kata (idealnya sudah lowercase/ternormalisasi).
|
| 637 |
+
max_edit_distance: Jarak edit maksimum kandidat koreksi.
|
| 638 |
+
language: "en" memakai kamus Inggris, selain itu Indonesia.
|
| 639 |
+
|
| 640 |
+
Returns:
|
| 641 |
+
Kata hasil koreksi, atau None jika SymSpell tidak tersedia, tidak ada
|
| 642 |
+
saran, atau kata sudah benar.
|
| 643 |
+
"""
|
| 644 |
+
sym = self._sym_spell_en if language == "en" else self._sym_spell
|
| 645 |
+
if sym is None or not _SYMSPELL_OK:
|
| 646 |
+
return None
|
| 647 |
+
try:
|
| 648 |
+
suggestions = sym.lookup(
|
| 649 |
+
word, Verbosity.CLOSEST,
|
| 650 |
+
max_edit_distance=max_edit_distance,
|
| 651 |
+
include_unknown=False,
|
| 652 |
+
)
|
| 653 |
+
except Exception:
|
| 654 |
+
return None
|
| 655 |
+
if not suggestions:
|
| 656 |
+
return None
|
| 657 |
+
best = suggestions[0].term
|
| 658 |
+
return best if best != word else None
|
| 659 |
+
|
| 660 |
+
def normalize_slang(self, text: str) -> str:
|
| 661 |
+
"""
|
| 662 |
+
Ganti tiap kata slang dalam `text` dengan padanan bakunya.
|
| 663 |
+
|
| 664 |
+
Berguna untuk pengecekan internal modul lain (mis. normalisasi sebelum
|
| 665 |
+
regex). Token yang tidak ada di kamus dibiarkan apa adanya.
|
| 666 |
+
"""
|
| 667 |
+
if not self._slang_dict:
|
| 668 |
+
return text
|
| 669 |
+
return " ".join(self._slang_dict.get(w.lower(), w) for w in text.split())
|
| 670 |
+
|
| 671 |
# ββ Sub-Detektor Internal ββ
|
| 672 |
|
| 673 |
def _check_slang(self, word: str, start: int, end: int, language: str = "id") -> WordIssue | None:
|
| 674 |
"""
|
| 675 |
Cek apakah kata ada di kamus slang dan memiliki padanan baku.
|
| 676 |
|
| 677 |
+
Hanya kata yang TERDAFTAR EKSPLISIT di kamus slang/alay yang ditandai.
|
| 678 |
+
Fallback ke tesaurus sengaja TIDAK dipakai di sini: tesaurus akan
|
| 679 |
+
mengembalikan sinonim untuk kata baku apa pun (mis. "menyatakan" β
|
| 680 |
+
"mengungkapkan"), sehingga memicu false positive pada kata yang sudah
|
| 681 |
+
benar. Tesaurus tetap dipakai sebagai fallback koreksi typo di
|
| 682 |
+
_check_typo, di mana kata memang sudah dipastikan tidak dikenal.
|
| 683 |
"""
|
| 684 |
if not self._use_slang:
|
| 685 |
return None
|
|
|
|
| 711 |
reason=f"Kata informal; padanan baku: \"{formal}\".",
|
| 712 |
)
|
| 713 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 714 |
return None
|
| 715 |
|
| 716 |
def _check_alay(self, word: str, start: int, end: int, language: str = "id") -> WordIssue | None:
|
|
|
|
| 720 |
w = word.lower()
|
| 721 |
|
| 722 |
# Pola 1: singkatan numerik reduplikasi β "kata2" β "kata-kata"
|
| 723 |
+
# Hanya berlaku untuk Bahasa Indonesia (dan mixed/unknown); Inggris tidak mengenal
|
| 724 |
+
# pola reduplikasi dengan angka sehingga "type2", "level2", dst. tidak perlu di-flag.
|
| 725 |
m = _NUMERIC_REDUPLICATION.fullmatch(w)
|
| 726 |
+
if m and language != "en":
|
| 727 |
base = m.group(1)
|
| 728 |
suggestion = f"{base}-{base}"
|
| 729 |
return WordIssue(
|
|
|
|
| 752 |
# Pola 3: substitusi angka-huruf (l33tspeak) β "k4mu" β "kamu"
|
| 753 |
# Hanya untuk kata pendek (β€ 15 karakter) agar token acak panjang tidak lolos
|
| 754 |
# sebelum sampai ke _check_typo.
|
| 755 |
+
#
|
| 756 |
+
# Pengecekan kewajaran hasil de-leet bersifat LINTAS-BAHASA: bila hasilnya
|
| 757 |
+
# kata nyata di bahasa mana pun (Indonesia ATAU Inggris), token itu hampir
|
| 758 |
+
# pasti leetspeak yang disengaja. Sebelumnya kewajaran hanya dicek pada
|
| 759 |
+
# bahasa field; akibatnya leetspeak Indonesia (mis. "m3l4kuk4n") lolos saat
|
| 760 |
+
# field terdeteksi berbahasa Inggris.
|
| 761 |
+
#
|
| 762 |
+
# Guard false-positive: kata teknis sering berakhir dengan angka (MP3, IPv4,
|
| 763 |
+
# level5, Win32, SHA256). Kata leetspeak sejati hampir selalu berakhir huruf
|
| 764 |
+
# karena kata dasar yang mendasarinya berakhir huruf. Oleh karena itu:
|
| 765 |
+
# β’ minimal 4 karakter (filter "3D", "C4", "MP3", dll. β€ 3 karakter)
|
| 766 |
+
# β’ karakter terakhir bukan digit (filter "level5", "IPv4", "type2", dll.)
|
| 767 |
+
if (4 <= len(w) <= 15
|
| 768 |
+
and not w[-1].isdigit()
|
| 769 |
+
and re.search(r'[0-9]', w)
|
| 770 |
+
and re.search(r'[a-zA-Z]', w)):
|
| 771 |
candidate = w.translate(_NUM_TO_LETTER)
|
| 772 |
+
if candidate != w and _WORDFREQ_OK:
|
| 773 |
+
best_freq = max(
|
| 774 |
+
word_frequency(candidate, "id"),
|
| 775 |
+
word_frequency(candidate, "en"),
|
|
|
|
|
|
|
|
|
|
|
|
|
| 776 |
)
|
| 777 |
+
if best_freq > _WORDFREQ_MIN:
|
| 778 |
+
return WordIssue(
|
| 779 |
+
word=word, start=start, end=end,
|
| 780 |
+
issue_type="ALAY",
|
| 781 |
+
suggestion=candidate,
|
| 782 |
+
confidence=0.85,
|
| 783 |
+
reason=f"Substitusi angka-huruf (l33tspeak); kemungkinan maksud: \"{candidate}\".",
|
| 784 |
+
)
|
| 785 |
|
| 786 |
# Pola 4: noisy casing campur β "hArUs" β "harus"
|
| 787 |
+
# Guard: kata yang mengandung digit sering berupa akronim teknis (IPv4, GPT4,
|
| 788 |
+
# Win32) yang punya huruf besar di prefix (IP, GP, W) β bukan ALAY.
|
| 789 |
+
# Kata alay dengan digit (mis. "m3l4kuk4n") sudah tertangkap lebih awal di Pola 3.
|
| 790 |
+
if _is_noisy_casing(word) and not any(c.isdigit() for c in word):
|
| 791 |
suggestion = word.lower()
|
| 792 |
return WordIssue(
|
| 793 |
word=word, start=start, end=end,
|
web/index.html
CHANGED
|
@@ -177,6 +177,10 @@
|
|
| 177 |
background: #c7dcff;
|
| 178 |
}
|
| 179 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
.quality {
|
| 181 |
background: #ffd6a5;
|
| 182 |
}
|
|
@@ -673,73 +677,79 @@
|
|
| 673 |
<!-- βββββββββββββββββββββββββββββββββββββββββ
|
| 674 |
Panel Kiri β Form Input
|
| 675 |
βββββββββββββββββββββββββββββββββββββββββ -->
|
| 676 |
-
<section class="min-h-0
|
| 677 |
bg-white border border-line rounded-lg box-border">
|
| 678 |
|
| 679 |
-
<
|
| 680 |
-
<
|
| 681 |
-
|
| 682 |
-
|
| 683 |
-
|
| 684 |
-
|
| 685 |
-
|
| 686 |
-
|
| 687 |
-
|
| 688 |
-
|
| 689 |
-
|
| 690 |
-
|
| 691 |
-
|
| 692 |
-
|
| 693 |
-
|
| 694 |
-
|
| 695 |
-
|
| 696 |
-
|
| 697 |
-
|
| 698 |
-
|
| 699 |
-
|
| 700 |
-
|
| 701 |
-
|
| 702 |
-
|
| 703 |
-
|
| 704 |
-
|
| 705 |
-
|
| 706 |
-
|
| 707 |
-
|
| 708 |
-
|
| 709 |
-
|
| 710 |
-
|
| 711 |
-
|
| 712 |
-
|
| 713 |
-
|
| 714 |
-
|
| 715 |
-
|
| 716 |
-
|
| 717 |
-
|
| 718 |
-
|
| 719 |
-
|
| 720 |
-
|
| 721 |
-
|
| 722 |
-
|
| 723 |
-
|
| 724 |
-
|
| 725 |
-
|
| 726 |
-
|
| 727 |
-
|
| 728 |
-
|
| 729 |
-
|
| 730 |
-
|
| 731 |
-
|
| 732 |
-
|
| 733 |
-
|
| 734 |
-
|
| 735 |
-
|
| 736 |
-
|
| 737 |
-
|
| 738 |
-
|
| 739 |
-
|
| 740 |
-
|
| 741 |
-
|
| 742 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 743 |
<button id="evaluateBtn" class="px-4 py-[9px] text-sm font-bold text-white bg-teal border border-teal
|
| 744 |
rounded-[6px] cursor-pointer hover:bg-teal-dark transition-colors duration-150
|
| 745 |
disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-teal" type="button"
|
|
@@ -850,6 +860,7 @@
|
|
| 850 |
{ key: "slang", label: "Kata Tidak Baku" },
|
| 851 |
{ key: "weak", label: "Penulisan Slang" },
|
| 852 |
{ key: "typo", label: "Kemungkinan Salah Ketik" },
|
|
|
|
| 853 |
{ key: "quality", label: "Topik Sensitif" },
|
| 854 |
{ key: "filler", label: "Frasa Tidak Efektif" },
|
| 855 |
{ key: "special_char", label: "Karakter Khusus" },
|
|
@@ -866,6 +877,7 @@
|
|
| 866 |
ner: "#bfe8c8",
|
| 867 |
slang: "#d7c7ff",
|
| 868 |
typo: "#c7dcff",
|
|
|
|
| 869 |
quality: "#ffd6a5",
|
| 870 |
responsible: "#f9c5d5",
|
| 871 |
profanity: "#fecaca",
|
|
@@ -882,6 +894,7 @@
|
|
| 882 |
slang: "Kata ini adalah kata gaul atau singkatan informal. AI mungkin salah memahami maksud Anda, sehingga jawabannya kurang tepat. Menggunakan kata yang lebih baku membuat permintaan Anda lebih mudah dimengerti.",
|
| 883 |
weak: "Penulisan seperti ini (huruf berulang, angka menggantikan huruf, atau singkatan tidak lazim) bisa membuat AI salah membaca kata. Gunakan ejaan normal agar hasilnya lebih akurat.",
|
| 884 |
typo: "Sepertinya ada kesalahan ketik di sini. Memperbaikinya membantu AI memahami maksud Anda dengan benar.",
|
|
|
|
| 885 |
quality: "Pertanyaan ini menyentuh topik yang sensitif dan bisa menghasilkan jawaban yang tidak berimbang atau bias. Coba ubah ke sudut pandang yang lebih netral, misalnya meminta perbandingan objektif, bukan penilaian mana yang 'terbaik'.",
|
| 886 |
missing: "Kolom ini belum diisi. Semakin lengkap informasi yang Anda berikan, semakin tepat dan berguna jawaban yang dihasilkan AI.",
|
| 887 |
filler: "Frasa ini tidak menambah informasi dan hanya membuang token. AI tidak memerlukan sapaan, ucapan terima kasih, permintaan maaf, atau basa-basi. Langsung tuliskan permintaan secara spesifik. Referensi samar seperti 'gini nih' atau 'kayak gitu' juga perlu diganti dengan penjelasan eksplisit, karena AI tidak dapat menebak apa yang Anda bayangkan. Prompt yang lebih ringkas dan konkret menghasilkan jawaban yang lebih tepat sasaran.",
|
|
|
|
| 177 |
background: #c7dcff;
|
| 178 |
}
|
| 179 |
|
| 180 |
+
.syntax {
|
| 181 |
+
background: #e0e7ff;
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
.quality {
|
| 185 |
background: #ffd6a5;
|
| 186 |
}
|
|
|
|
| 677 |
<!-- βββββββββββββββββββββββββββββββββββββββββ
|
| 678 |
Panel Kiri β Form Input
|
| 679 |
βββββββββββββββββββββββββββββββββββββββββ -->
|
| 680 |
+
<section class="min-h-0 flex flex-col
|
| 681 |
bg-white border border-line rounded-lg box-border">
|
| 682 |
|
| 683 |
+
<!-- Area scroll form β tombol tidak ikut scroll -->
|
| 684 |
+
<div class="flex-1 min-h-0 overflow-y-auto [scrollbar-gutter:stable] px-4 py-3">
|
| 685 |
+
|
| 686 |
+
<label class="block mt-0 mb-[6px] text-sm font-bold" for="task">Task</label>
|
| 687 |
+
<textarea class="w-full max-w-full box-border px-[10px] py-[10px] border border-input rounded-[6px]
|
| 688 |
+
font-[inherit] text-base min-h-[42px] overflow-hidden resize-none leading-[1.45]
|
| 689 |
+
focus:outline-none focus:border-teal focus:ring-[3px] focus:ring-teal/[.14]
|
| 690 |
+
mobile:min-h-[78px] mobile:px-[14px] mobile:py-[12px]" id="task" rows="1"
|
| 691 |
+
placeholder="Apa yang ingin Anda minta kepada AI? Tuliskan permintaan secara spesifik."></textarea>
|
| 692 |
+
|
| 693 |
+
<label class="block mt-4 mb-[6px] text-sm font-bold" for="context">Context</label>
|
| 694 |
+
<textarea class="w-full max-w-full box-border px-[10px] py-[10px] border border-input rounded-[6px]
|
| 695 |
+
font-[inherit] text-base min-h-[42px] overflow-hidden resize-none leading-[1.45]
|
| 696 |
+
focus:outline-none focus:border-teal focus:ring-[3px] focus:ring-teal/[.14]
|
| 697 |
+
mobile:min-h-[78px] mobile:px-[14px] mobile:py-[12px]" id="context" rows="1"
|
| 698 |
+
placeholder="Latar belakang situasi yang perlu diketahui AI, misalnya: tujuan, kondisi, atau batasan yang relevan."></textarea>
|
| 699 |
+
|
| 700 |
+
<label class="block mt-4 mb-[6px] text-sm font-bold" for="references">References</label>
|
| 701 |
+
<textarea class="w-full max-w-full box-border px-[10px] py-[10px] border border-input rounded-[6px]
|
| 702 |
+
font-[inherit] text-base min-h-[42px] overflow-hidden resize-none leading-[1.45]
|
| 703 |
+
focus:outline-none focus:border-teal focus:ring-[3px] focus:ring-teal/[.14]
|
| 704 |
+
mobile:min-h-[78px] mobile:px-[14px] mobile:py-[12px]" id="references" rows="1"
|
| 705 |
+
placeholder="Sumber, dokumen, atau data yang menjadi acuan jawaban AI."></textarea>
|
| 706 |
+
|
| 707 |
+
<label class="block mt-4 mb-[6px] text-sm font-bold" for="role">Role</label>
|
| 708 |
+
<textarea class="w-full max-w-full box-border px-[10px] py-[10px] border border-input rounded-[6px]
|
| 709 |
+
font-[inherit] text-base min-h-[42px] overflow-hidden resize-none leading-[1.45]
|
| 710 |
+
focus:outline-none focus:border-teal focus:ring-[3px] focus:ring-teal/[.14]
|
| 711 |
+
mobile:min-h-[78px] mobile:px-[14px] mobile:py-[12px]" id="role" rows="1"
|
| 712 |
+
placeholder="Peran atau keahlian yang diinginkan dari AI. Contoh: Analis Data, Penulis Konten, Konsultan Hukum."></textarea>
|
| 713 |
+
|
| 714 |
+
<label class="block mt-4 mb-[6px] text-sm font-bold" for="audience">Audience</label>
|
| 715 |
+
<textarea class="w-full max-w-full box-border px-[10px] py-[10px] border border-input rounded-[6px]
|
| 716 |
+
font-[inherit] text-base min-h-[42px] overflow-hidden resize-none leading-[1.45]
|
| 717 |
+
focus:outline-none focus:border-teal focus:ring-[3px] focus:ring-teal/[.14]
|
| 718 |
+
mobile:min-h-[78px] mobile:px-[14px] mobile:py-[12px]" id="audience" rows="1"
|
| 719 |
+
placeholder="Siapa target pembaca jawaban ini? Contoh: mahasiswa S1, tim pemasaran, pelanggan baru."></textarea>
|
| 720 |
+
|
| 721 |
+
<label class="block mt-4 mb-[6px] text-sm font-bold" for="tone">Tone</label>
|
| 722 |
+
<textarea class="w-full max-w-full box-border px-[10px] py-[10px] border border-input rounded-[6px]
|
| 723 |
+
font-[inherit] text-base min-h-[42px] overflow-hidden resize-none leading-[1.45]
|
| 724 |
+
focus:outline-none focus:border-teal focus:ring-[3px] focus:ring-teal/[.14]
|
| 725 |
+
mobile:min-h-[78px] mobile:px-[14px] mobile:py-[12px]" id="tone" rows="1"
|
| 726 |
+
placeholder="Contoh: formal dan profesional, santai tapi sopan, ringkas dan to-the-point."></textarea>
|
| 727 |
+
|
| 728 |
+
<label class="block mt-4 mb-[6px] text-sm font-bold" for="constraints">Constraints</label>
|
| 729 |
+
<textarea class="w-full max-w-full box-border px-[10px] py-[10px] border border-input rounded-[6px]
|
| 730 |
+
font-[inherit] text-base min-h-[42px] overflow-hidden resize-none leading-[1.45]
|
| 731 |
+
focus:outline-none focus:border-teal focus:ring-[3px] focus:ring-teal/[.14]
|
| 732 |
+
mobile:min-h-[78px] mobile:px-[14px] mobile:py-[12px]" id="constraints" rows="1"
|
| 733 |
+
placeholder="Hal yang tidak boleh dilakukan AI. Contoh: jangan gunakan istilah teknis, maksimal 200 kata."></textarea>
|
| 734 |
+
|
| 735 |
+
<label class="block mt-4 mb-[6px] text-sm font-bold" for="outputFormat">Output Format</label>
|
| 736 |
+
<textarea class="w-full max-w-full box-border px-[10px] py-[10px] border border-input rounded-[6px]
|
| 737 |
+
font-[inherit] text-base min-h-[42px] overflow-hidden resize-none leading-[1.45]
|
| 738 |
+
focus:outline-none focus:border-teal focus:ring-[3px] focus:ring-teal/[.14]
|
| 739 |
+
mobile:min-h-[78px] mobile:px-[14px] mobile:py-[12px]" id="outputFormat" rows="1"
|
| 740 |
+
placeholder="Contoh: daftar poin, tabel perbandingan, paragraf naratif, format JSON."></textarea>
|
| 741 |
+
|
| 742 |
+
<label class="block mt-4 mb-[6px] text-sm font-bold" for="example">Example</label>
|
| 743 |
+
<textarea class="w-full max-w-full box-border px-[10px] py-[10px] border border-input rounded-[6px]
|
| 744 |
+
font-[inherit] text-base min-h-[42px] overflow-hidden resize-none leading-[1.45]
|
| 745 |
+
focus:outline-none focus:border-teal focus:ring-[3px] focus:ring-teal/[.14]
|
| 746 |
+
mobile:min-h-[78px] mobile:px-[14px] mobile:py-[12px]" id="example" rows="1"
|
| 747 |
+
placeholder="Tunjukkan contoh output yang Anda harapkan agar AI dapat mengikuti formatnya."></textarea>
|
| 748 |
+
|
| 749 |
+
</div><!-- /scroll area -->
|
| 750 |
+
|
| 751 |
+
<!-- Tombol aksi β selalu terlihat di bawah panel -->
|
| 752 |
+
<div class="flex-none flex justify-end gap-2 px-5 py-3 border-t border-line bg-white rounded-b-lg">
|
| 753 |
<button id="evaluateBtn" class="px-4 py-[9px] text-sm font-bold text-white bg-teal border border-teal
|
| 754 |
rounded-[6px] cursor-pointer hover:bg-teal-dark transition-colors duration-150
|
| 755 |
disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-teal" type="button"
|
|
|
|
| 860 |
{ key: "slang", label: "Kata Tidak Baku" },
|
| 861 |
{ key: "weak", label: "Penulisan Slang" },
|
| 862 |
{ key: "typo", label: "Kemungkinan Salah Ketik" },
|
| 863 |
+
{ key: "syntax", label: "Urutan Kata" },
|
| 864 |
{ key: "quality", label: "Topik Sensitif" },
|
| 865 |
{ key: "filler", label: "Frasa Tidak Efektif" },
|
| 866 |
{ key: "special_char", label: "Karakter Khusus" },
|
|
|
|
| 877 |
ner: "#bfe8c8",
|
| 878 |
slang: "#d7c7ff",
|
| 879 |
typo: "#c7dcff",
|
| 880 |
+
syntax: "#e0e7ff",
|
| 881 |
quality: "#ffd6a5",
|
| 882 |
responsible: "#f9c5d5",
|
| 883 |
profanity: "#fecaca",
|
|
|
|
| 894 |
slang: "Kata ini adalah kata gaul atau singkatan informal. AI mungkin salah memahami maksud Anda, sehingga jawabannya kurang tepat. Menggunakan kata yang lebih baku membuat permintaan Anda lebih mudah dimengerti.",
|
| 895 |
weak: "Penulisan seperti ini (huruf berulang, angka menggantikan huruf, atau singkatan tidak lazim) bisa membuat AI salah membaca kata. Gunakan ejaan normal agar hasilnya lebih akurat.",
|
| 896 |
typo: "Sepertinya ada kesalahan ketik di sini. Memperbaikinya membantu AI memahami maksud Anda dengan benar.",
|
| 897 |
+
syntax: "Susunan kata pada kalimat ini terasa kurang lazim sehingga maksudnya bisa ambigu bagi AI. Coba periksa kembali urutan katanya β umumnya mengikuti pola subjekβpredikatβobjek (misalnya 'saya membuat laporan', bukan 'laporan membuat saya'). Ini hanya saran; jika kalimat memang sudah sesuai maksud Anda, abaikan saja.",
|
| 898 |
quality: "Pertanyaan ini menyentuh topik yang sensitif dan bisa menghasilkan jawaban yang tidak berimbang atau bias. Coba ubah ke sudut pandang yang lebih netral, misalnya meminta perbandingan objektif, bukan penilaian mana yang 'terbaik'.",
|
| 899 |
missing: "Kolom ini belum diisi. Semakin lengkap informasi yang Anda berikan, semakin tepat dan berguna jawaban yang dihasilkan AI.",
|
| 900 |
filler: "Frasa ini tidak menambah informasi dan hanya membuang token. AI tidak memerlukan sapaan, ucapan terima kasih, permintaan maaf, atau basa-basi. Langsung tuliskan permintaan secara spesifik. Referensi samar seperti 'gini nih' atau 'kayak gitu' juga perlu diganti dengan penjelasan eksplisit, karena AI tidak dapat menebak apa yang Anda bayangkan. Prompt yang lebih ringkas dan konkret menghasilkan jawaban yang lebih tepat sasaran.",
|