ArielJoe commited on
Commit
c85625f
Β·
1 Parent(s): 5b02fa7

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 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 tujuh detektor dalam satu endpoint untuk frontend index.html:
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 = False
126
- _no_ner = False
127
- _no_ner_ml = False
 
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[:60] + ("..." if len(finding.evidence) > 60 else ""),
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, no_ner_ml: bool = False) -> None:
 
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: Host yang digunakan (sama dengan pipeline server).
798
- no_resp_ml: Teruskan flag --no-resp-ml ke responsible_server.
799
- no_ner: Jangan jalankan sub-server NER.
800
- no_ner_ml: Jangan jalankan sub-server NER karena server ini selalu memuat model 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, _no_resp_ml, _no_ner, _no_ner_ml
 
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. Semua detektor aktif, tanpa model berat.")
 
878
  args = parser.parse_args()
879
- _no_resp_ml = args.no_resp_ml or args.fast
880
- _no_ner = args.no_ner
881
- _no_ner_ml = args.no_ner_ml or args.fast
 
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. Hot-reload watcher
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
956
  DetectorWatcher(_SRC_DIR).start()
957
 
958
- # 9. Sub-server halaman test
959
- _start_subservers(args.host, no_resp_ml=_no_resp_ml, no_ner=_no_ner, no_ner_ml=_no_ner_ml)
 
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
- _PATTERNS: list[tuple[re.Pattern[str], str, str, float]] = []
 
63
 
64
 
65
- def _add(pattern: str, category: str, reason: str, conf: float = 0.88) -> None:
66
- _PATTERNS.append((re.compile(pattern, _F), category, reason, conf))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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._ml_pipe # noqa: SLF001
 
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, reason, conf in _PATTERNS:
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[:60] + ("…" if len(word) > 60 else ""),
658
  start=start,
659
  end=end,
660
  category=category,
661
- reason=reason,
662
  confidence=conf,
663
  ))
664
 
665
- # ── Layer 2: ML zero-shot (hanya jika Layer 1 kosong) ────────────────
666
- if self._ml_pipe and not findings:
667
- ml_result = self._check_ml(text)
668
- findings.extend(ml_result)
 
 
 
 
 
 
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
- result = self._ml_pipe(
682
- text,
683
- candidate_labels=_ML_LABELS,
684
- hypothesis_template="Teks ini mengandung {}.",
685
- multi_label=True,
686
- )
 
 
 
 
687
  except Exception as exc:
688
  logger.debug("Filler ML gagal: %s", exc)
689
  return []
690
 
691
- ml_findings: list[FillerFinding] = []
692
- for label, score in zip(result["labels"], result["scores"]):
693
- if score < _ML_THRESHOLD:
694
- continue
695
- cat = _ML_LABEL_TO_CAT.get(label)
696
- if cat is None:
697
- continue # label "safe", tidak perlu dilaporkan
698
-
699
- reason = _ML_REASON.get(cat, "Frasa ini perlu ditinjau kembali.")
700
- reason += f" (terdeteksi oleh model ML, skor {score:.0%})"
701
-
702
- # Laporkan sebagai temuan pada keseluruhan teks (start=0, end=len)
703
- word = text[:60] + ("…" if len(text) > 60 else "")
704
- ml_findings.append(FillerFinding(
705
- word=word,
706
- start=0,
707
- end=len(text),
708
- category=cat,
709
- reason=reason,
710
- confidence=round(score, 3),
711
- ))
712
- break # satu temuan ML per teks sudah cukup
713
-
714
- return ml_findings
 
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 khas Indonesia yang sering terlewat model ML.
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) if self._pipeline else []
447
- rule_entities = self._predict_rules(text) if self._use_rules else []
448
- name_entities = self._predict_names(text) if self._use_rules else []
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
- """Jalankan semua pola regex dan kembalikan entitas yang cocok."""
 
 
 
 
 
 
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 Bahasa Indonesia.
8
 
9
- Mendeteksi 17 tipe data pribadi dalam teks bebas menggunakan pola regex
10
- dengan validasi struktural untuk mengurangi false positive:
 
 
 
 
 
 
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.97,
 
 
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", "bugil",
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 bentuk 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
  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[skel] = w
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] = set()
260
- self._medium: set[str] = set()
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
- if skel in self._skel_high and len(norm) <= len(self._skel_high[skel]):
 
341
  severity = "HIGH"
342
- matched_as = self._skel_high[skel]
343
- elif skel in self._skel_medium and len(norm) <= len(self._skel_medium[skel]):
344
- severity = "MEDIUM"
345
- matched_as = self._skel_medium[skel]
 
 
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 and wq._sym_spell is not None: # noqa: SLF001
356
- norm2 = _normalize(raw)
357
- suggs = wq._sym_spell.lookup( # noqa: SLF001
358
- norm2, _V.CLOSEST, max_edit_distance=2
359
- )
360
- if suggs:
361
- corrected = suggs[0].term
362
- if corrected != norm2:
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
- words = text.split()
305
- return " ".join(det._slang_dict.get(w.lower(), w) for w in words) # noqa: SLF001
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
- result = self._ml_pipe(
486
- text,
487
- candidate_labels=_ML_LABELS,
488
- hypothesis_template="Teks ini mengandung {}.",
489
- multi_label=False,
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": "sedang", "lg": "sedang",
 
 
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": "untuk", # kontekstual, tapi sering salah β†’ diarahkan ke "untuk"
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
- # Ada huruf besar di tengah kata (setelah posisi 0) DAN ada huruf kecil
300
- return any(c.isupper() for c in word[1:]) and any(c.islower() for c in word)
 
 
 
 
 
 
 
 
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
- Urutan pencarian:
614
- 1. Kamus-alay (kamus utama, ~2400+ entri)
615
- 2. Tesaurus Bahasa Indonesia (fallback jika tidak ada di kamus)
 
 
 
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
- if re.search(r'[0-9]', w) and re.search(r'[a-zA-Z]', w) and len(w) <= 15:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
702
  candidate = w.translate(_NUM_TO_LETTER)
703
- lang = _language_for_token(candidate, language)
704
- if _WORDFREQ_OK and word_frequency(candidate, lang) > _WORDFREQ_MIN:
705
- return WordIssue(
706
- word=word, start=start, end=end,
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
- if _is_noisy_casing(word):
 
 
 
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 overflow-y-auto [scrollbar-gutter:stable] p-5
677
  bg-white border border-line rounded-lg box-border">
678
 
679
- <label class="block mt-0 mb-[6px] text-sm font-bold" for="task">Task</label>
680
- <textarea class="w-full max-w-full box-border px-[10px] py-[10px] border border-input rounded-[6px]
681
- font-[inherit] text-base min-h-[42px] overflow-hidden resize-none leading-[1.45]
682
- focus:outline-none focus:border-teal focus:ring-[3px] focus:ring-teal/[.14]
683
- mobile:min-h-[78px] mobile:px-[14px] mobile:py-[12px]" id="task" rows="1"
684
- placeholder="Apa yang ingin Anda minta kepada AI? Tuliskan permintaan secara spesifik."></textarea>
685
-
686
- <label class="block mt-4 mb-[6px] text-sm font-bold" for="context">Context</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="context" rows="1"
691
- placeholder="Latar belakang situasi yang perlu diketahui AI, misalnya: tujuan, kondisi, atau batasan yang relevan."></textarea>
692
-
693
- <label class="block mt-4 mb-[6px] text-sm font-bold" for="references">References</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="references" rows="1"
698
- placeholder="Sumber, dokumen, atau data yang menjadi acuan jawaban AI."></textarea>
699
-
700
- <label class="block mt-4 mb-[6px] text-sm font-bold" for="role">Role</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="role" rows="1"
705
- placeholder="Peran atau keahlian yang diinginkan dari AI. Contoh: Analis Data, Penulis Konten, Konsultan Hukum."></textarea>
706
-
707
- <label class="block mt-4 mb-[6px] text-sm font-bold" for="audience">Audience</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="audience" rows="1"
712
- placeholder="Siapa target pembaca jawaban ini? Contoh: mahasiswa S1, tim pemasaran, pelanggan baru."></textarea>
713
-
714
- <label class="block mt-4 mb-[6px] text-sm font-bold" for="tone">Tone</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="tone" rows="1"
719
- placeholder="Contoh: formal dan profesional, santai tapi sopan, ringkas dan to-the-point."></textarea>
720
-
721
- <label class="block mt-4 mb-[6px] text-sm font-bold" for="constraints">Constraints</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="constraints" rows="1"
726
- placeholder="Hal yang tidak boleh dilakukan AI. Contoh: jangan gunakan jargon teknis, maksimal 200 kata."></textarea>
727
-
728
- <label class="block mt-4 mb-[6px] text-sm font-bold" for="outputFormat">Output Format</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="outputFormat" rows="1"
733
- placeholder="Contoh: daftar poin, tabel perbandingan, paragraf naratif, format JSON."></textarea>
734
-
735
- <label class="block mt-4 mb-[6px] text-sm font-bold" for="example">Example</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="example" rows="1"
740
- placeholder="Tunjukkan contoh output yang Anda harapkan agar AI dapat mengikuti formatnya."></textarea>
741
-
742
- <div class="flex justify-end gap-2 mt-4 pt-3 border-t border-line">
 
 
 
 
 
 
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.",