Prompt-Builder / src /ner /ner_detector.py
ArielJoe's picture
feat: cross-detector language policy + tidy structure & file naming
5ddfd1f
Raw
History Blame Contribute Delete
37.4 kB
"""
Pengenalan entitas bernama (NER) untuk teks Bahasa Indonesia.
Bekerja dua lapis: model transformer (XLM-RoBERTa Indonesia dari cahya, dengan versi
yang lebih ringan sebagai cadangan) ditambah sejumlah pola regex khas Indonesia untuk
menangkap entitas yang sering terlewat model. Hasil keduanya disatukan, lalu
tumpang-tindih dan duplikat dibereskan.
Label yang dipakai antara lain ORANG, ORGANISASI, LOKASI, FASILITAS, TANGGAL, WAKTU,
UANG, dan AGAMA. Entitas numerik generik (angka, persentase, kuantitas) sengaja
diabaikan karena tidak relevan untuk evaluasi prompt.
"""
from __future__ import annotations
import logging
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Sequence
from core.lexicons import load_word_set, regex_alternation
# Direktori cache lokal: <project_root>/cache/
# Model diunduh ke sini agar tidak perlu internet saat runtime berikutnya.
_CACHE_DIR = Path(__file__).parent.parent.parent / "cache"
logger = logging.getLogger(__name__)
# Tipe Data
@dataclass(frozen=True)
class NEREntity:
"""Satu entitas bernama yang terdeteksi dalam teks."""
word: str # teks asli dari input (bukan artefak tokenizer)
label: str # label internal, mis. "ORANG", "LOKASI"
score: float # skor kepercayaan 0.0–1.0
start: int # offset karakter awal (inklusif)
end: int # offset karakter akhir (eksklusif)
source: str # "ml" (model transformer) atau "rule" (pola regex)
# Konfigurasi Model
# Model diurutkan dari paling akurat; yang pertama berhasil dimuat yang dipakai.
_MODEL_CASCADE: list[str] = [
"cahya/xlm-roberta-large-indonesian-NER", # XLM-R large, 18 tipe, ~600 MB
"cahya/xlm-roberta-base-indonesian-NER", # XLM-R base, 18 tipe, ~278 MB
"cahya/bert-base-indonesian-NER", # BERT base, 3 tipe, ~400 MB
]
# Pemetaan label NerGrit → label internal yang digunakan sistem.
# None berarti abaikan (numerik generik yang tidak berguna untuk pipeline).
_LABEL_MAP: dict[str, str | None] = {
"PER": "ORANG",
"ORG": "ORGANISASI",
"NOR": "ORGANISASI", # political org → digabung ke ORGANISASI
"LOC": "LOKASI",
"GPE": "LOKASI", # geo-political entity → digabung ke LOKASI
"FAC": "FASILITAS",
"EVT": "KEJADIAN",
"PRD": "PRODUK",
"WOA": "KARYA",
"LAW": "PERATURAN",
"DAT": "TANGGAL",
"TIM": "WAKTU",
"MON": "UANG",
"REG": "AGAMA",
"LAN": "BAHASA",
# Dikecualikan — numerik generik, tidak relevan untuk pipeline prompt
"CRD": None,
"ORD": None,
"PRC": None,
"QTY": None,
}
# Ambang kepercayaan minimum untuk hasil model ML.
# Disetel 0.60 untuk aggregation_strategy="average" yang menghasilkan skor lebih
# rendah namun lebih andal dibanding "simple" (tidak memecah kata menjadi fragmen).
_MIN_CONFIDENCE: float = 0.60
_ENTITY_EDGE_CHARS = " \t\r\n,;:!?."
# Cadangan nama depan Indonesia untuk membantu mengenali nama yang berdiri sendiri
# tanpa konteks (mis. "Bagas" di satu field). Daftar utama dimuat dari
# resources/lexicons/ner/names_id.txt; set di bawah hanya dipakai bila berkas itu
# tidak ada.
_INDONESIAN_NAMES: frozenset[str] = frozenset({
# Laki-laki
"adi", "adit", "aditya", "agung", "agus", "ahmad", "aji",
"akbar", "amir", "andi", "andika", "anggara", "ardian",
"arief", "arif", "arkan", "arman", "arya", "asep", "aziz",
"bagas", "bagus", "baim", "bambang", "bima",
"cahyo", "dani", "danu", "dedi", "dedy", "deni",
"dicky", "didi", "dimas", "dion", "doni", "dwi",
"eko", "erwin", "evan", "fadli", "fajar", "fandi", "farhan",
"faris", "fatah", "fatur", "fikri", "fiqri", "firman",
"galang", "galih", "gilang", "gunawan", "hadi", "hafidz",
"hendra", "hendy", "ilham", "imam", "indra", "iqbal",
"irfan", "irvan", "ivan", "januar", "joni", "junaedi",
"karim", "lukman", "lutfi", "made", "mahendra", "mahfud",
"malik", "maman", "maulana", "muhamad", "muhammad", "mulyadi",
"nanda", "naufal", "novan", "oki", "pranata", "prasetyo", "putra",
"raka", "rangga", "reza", "rifki", "ricky", "rio",
"rizky", "rizki", "rudi", "saiful", "satria", "satrio",
"sena", "sigit", "sofyan", "solihin", "subhan",
"sugiarto", "suharto", "suryadi", "teguh", "toni", "tri",
"umar", "vino", "wahyu", "wawan", "wibowo", "wisnu",
"yogi", "yudha", "yunus", "zaki", "zulkarnaen",
# Perempuan
"aini", "ajeng", "alya", "ambar", "amelia", "ami",
"andini", "anita", "anny", "arum", "ayu",
"baiq", "bella", "bunga", "cinta", "cut",
"delia", "desi", "dina", "dinda",
"elsa", "eneng", "eva",
"farah", "fatimah", "fika", "fitri",
"hana", "indah", "intan",
"kartika", "laila", "liana", "lilis", "linda",
"maharani", "mariana", "maya", "melati", "melinda",
"mira", "mita", "nadia", "nisa", "nita",
"novia", "novi", "nurhaliza", "nurul",
"okta", "olivia", "prita", "putri",
"rahma", "rahmawati", "reni", "rina", "rini",
"risma", "rita", "sari", "sella",
"septy", "shinta", "silmi", "sinta", "siti", "suci",
"tari", "tiara", "tina",
"ulfa", "ulfah", "vera", "vina",
"winda", "wulan", "yanti", "yasmin", "yuli", "yuliana",
})
_INDONESIAN_NAMES = frozenset(
load_word_set("ner", "names_id.txt") or _INDONESIAN_NAMES
)
# Akronim organisasi (lembaga RI maupun global seperti UNESCO, WHO) dan sufiks
# badan usaha asing (Inc., LLC, Ltd) dipakai untuk mendeteksi entitas Inggris
# pada mode rule-only. Daftar tempat global tidak dipakai karena terlalu besar
# dan rawan false positive; entitas tempat asing ditangani model ML saat aktif.
_ORG_ACRONYMS = load_word_set("ner", "org_acronyms.txt", lower=False)
_ORG_SUFFIXES = load_word_set("ner", "org_suffixes_global.txt", lower=False)
_ORG_ACRONYM_PATTERN = regex_alternation(_ORG_ACRONYMS) or (
r'UN|UNESCO|UNICEF|WHO|WTO|IMF|World\s+Bank|NASA|FBI|CIA|NSA|'
r'FDA|CDC|EU|NATO|OECD'
)
_ORG_SUFFIX_PATTERN = regex_alternation(_ORG_SUFFIXES, phrase_spaces=False) or (
r'Inc\.?|LLC|Ltd\.?|Limited|Corp\.?|Corporation|Company|Co\.|PLC|GmbH|S\.A\.'
)
# Rule-Based Booster
#
# Regex untuk mendeteksi entitas yang sering terlewat model ML.
# Format setiap rule: (pola, label_internal, skor_tetap)
# Diurutkan dari paling spesifik ke umum; hanya ditambahkan jika tidak tumpang-
# tindih dengan hasil ML.
_RULES: list[tuple[re.Pattern[str], str, float]] = [
# Badan usaha Indonesia (PT, CV, UD, Firma, Koperasi, dll.)
# Setiap kata dalam nama perusahaan harus diawali huruf kapital agar tidak
# menyedot kata-kata biasa setelah nama perusahaan (mis. "adalah sebuah ...").
(re.compile(
r'\b(?:PT\.?\s+|CV\.?\s+|UD\.?\s+|Firma\s+|Koperasi\s+|Perum\s+|Persero\s+)'
r'(?:[A-Z][A-Za-z0-9&.,\'-]*(?:\s+[A-Z][A-Za-z0-9&.,\'-]*){0,7})',
re.UNICODE,
), "ORGANISASI", 0.92),
# Perusahaan terbuka (Tbk/Persero/Perum sebagai suffix)
(re.compile(
r'\b(?:[A-Z][A-Za-z0-9&.,\'-]*(?:\s+[A-Z][A-Za-z0-9&.,\'-]*){0,7})'
r'\s+(?:Tbk\.?|Persero|Perum)\b',
re.UNICODE,
), "ORGANISASI", 0.88),
# Lembaga pemerintah Indonesia
(re.compile(
r'\b(?:Kementerian|Kemendag|Kemendikbud|Kemenkeu|Kemenkumham|'
r'Badan|Lembaga|Komisi|Direktorat\s+Jenderal?|Ditjen|Bappenas)\s+'
r'(?:[A-Za-z][A-Za-z\s]{2,50})',
re.UNICODE | re.IGNORECASE,
), "ORGANISASI", 0.90),
# Akronim lembaga negara RI yang dikenal luas
(re.compile(
r'\b(?:KPK|OJK|BI|BPK|BPS|BPOM|BNPB|BPJS|KPU|Bawaslu|MK|MA|'
r'DPR|DPD|MPR|Polri|TNI|Kejagung|BNN|PPATK|KemenPU|BRIN)\b',
), "ORGANISASI", 0.95),
# Institusi pendidikan (case-insensitive agar teks all-lowercase pun terdeteksi).
# Nama institusi dicocokkan kata demi kata (1–6 kata) dan BERHENTI sebelum kata
# administratif wilayah (kota, kabupaten, ...) atau konjungsi/preposisi umum,
# agar "universitas kristen maranatha kota Bandung" tidak menelan "kota Bandung".
(re.compile(
r'\b(?:Universitas|Institut|Politeknik|Sekolah\s+Tinggi|'
r'STMIK|STIE|STIKES|STKIP|Akademi)'
r'(?:\s+(?!(?:kota|kabupaten|kab|provinsi|prov|kecamatan|kelurahan|desa|'
r'di|ke|dari|dan|atau|yang|untuk|pada|sejak)\b)[A-Za-z][A-Za-z\'.-]*){1,6}',
re.UNICODE | re.IGNORECASE,
), "ORGANISASI", 0.90),
# Nama partai politik Indonesia
(re.compile(
r'\b(?:Partai\s+(?:Golkar|Gerindra|PDI-?P|Demokrat|PKS|PKB|PPP|Nasdem|Hanura|'
r'Berkarya|PKPI|Garuda)|PDIP|Golkar|Gerindra)\b',
re.IGNORECASE,
), "ORGANISASI", 0.92),
# Satuan wilayah administratif
(re.compile(
r'\b(?:Provinsi|Kabupaten|Kecamatan|Kelurahan|Desa)\s+'
r'(?:[A-Za-z][A-Za-z\s]{2,40})',
re.UNICODE | re.IGNORECASE,
), "LOKASI", 0.88),
# Kota besar Indonesia (dengan prefiks "Kota")
(re.compile(
r'\bKota\s+(?:Jakarta|Surabaya|Bandung|Medan|Semarang|Makassar|Palembang|'
r'Tangerang|Depok|Bekasi|Bogor|Malang|Yogyakarta|Denpasar|Balikpapan)\b',
re.IGNORECASE,
), "LOKASI", 0.95),
# Undang-undang dan peraturan resmi Indonesia
(re.compile(
r'\b(?:Undang-Undang|UU|Peraturan\s+Pemerintah|PP|'
r'Peraturan\s+Presiden|Perpres|Peraturan\s+Menteri|Permen|'
r'Keputusan\s+Presiden|Keppres|Perda)\s+'
r'(?:No\.?\s*\d+\s*(?:/\s*\d{4})?|Nomor\s+\d+)',
re.IGNORECASE,
), "PERATURAN", 0.93),
# Mata uang dan nilai moneter Indonesia
(re.compile(
r'\bRp\.?\s*[\d.,]+(?:\s*(?:ribu|juta|miliar|triliun))?\b',
re.IGNORECASE,
), "UANG", 0.95),
# Tanggal format formal Indonesia: "15 Maret 2020"
(re.compile(
r'\b\d{1,2}\s+'
r'(?:Januari|Februari|Maret|April|Mei|Juni|Juli|Agustus|'
r'September|Oktober|November|Desember)\s+'
r'\d{4}\b',
re.IGNORECASE,
), "TANGGAL", 0.97),
# Gelar + nama orang: "Prof. Budi Raharjo", "Ibu Sari"
# Negative lookahead mencegah false positive pada idiom: "Ibu Kota", "Ibu Pertiwi", "Bapak Bangsa"
(re.compile(
r'\b(?:Bapak|Ibu|Pak|Bu|Dr\.?|Prof\.?|Ir\.?|Drs\.?|Hj\.?|H\.?|'
r'Mr\.?|Mrs\.?|Ms\.?|Miss|Sir|Madam|Mister)\s+'
r'(?:(?:Dr\.?|Prof\.?|Ir\.?|Drs\.?|Hj\.?|H\.?|Mr\.?|Mrs\.?|Ms\.?)\s+)?'
r'(?!(?:Kota|Pertiwi|Bangsa|Pembangunan|Rumah|Susuan|Angkat|Tiri)\b)'
r'(?:[A-Z][a-z]+(?:\s+[A-Z][a-z]+){0,4})',
re.UNICODE,
), "ORANG", 0.85),
# Nama orang setelah frasa pengenalan diri ("nama saya ariel", "saya bernama
# Budi", "panggil saya Tono"). Hanya bagian nama (capturing group) yang ditandai,
# bukan frasa pemicunya. Tidak butuh huruf kapital → menangkap nama huruf kecil
# yang dilewatkan model ML dan tidak ada di lexicon. Negative lookahead mencegah
# menandai kata umum yang kadang mengikuti frasa ini.
(re.compile(
r'\b(?:nama(?:\s+lengkap)?\s+saya(?:\s+adalah)?|saya\s+bernama|'
r'panggil\s+saya|perkenalkan\s+saya)\s+'
r'(?!(?:adalah|seorang|seperti|ini|itu|yang|dari)\b)'
r'([A-Za-z][A-Za-z\'.-]*(?:\s+[A-Za-z][A-Za-z\'.-]*){0,2})',
re.UNICODE | re.IGNORECASE,
), "ORANG", 0.82),
# Akronim organisasi global (UNESCO, WHO, NASA) dan lembaga RI yang dikenal.
(re.compile(rf'\b(?:{_ORG_ACRONYM_PATTERN})\b', re.UNICODE), "ORGANISASI", 0.90),
# Badan usaha asing dengan sufiks resmi: "OpenAI Inc.", "Acme Corp.".
# Minimal satu kata berhuruf kapital di depan sufiks agar tidak menyedot
# sufiks yang berdiri sendiri.
(re.compile(
rf'\b(?:[A-Z][A-Za-z0-9&.\'-]*\s+){{1,4}}(?:{_ORG_SUFFIX_PATTERN})\b',
re.UNICODE,
), "ORGANISASI", 0.88),
]
# Kelas Utama
class IndonesianNER:
"""
Named Entity Recognition Bahasa Indonesia.
Memuat model transformer terbaik yang tersedia (cascade XLM-RoBERTa Large →
Base → BERT), diperkuat dengan rule-based booster untuk pola khas Indonesia.
Contoh penggunaan::
ner = IndonesianNER()
ner.load() # unduh model sekali, lalu di-cache secara lokal
entities = ner.predict("Ahmad bekerja di PT Maju Bersama di Jakarta")
for e in entities:
print(f"[{e.label}] {e.word!r} (skor={e.score:.2f}, sumber={e.source})")
"""
def __init__(
self,
model_name: str | None = None,
min_confidence: float = _MIN_CONFIDENCE,
use_rules: bool = True,
load_on_init: bool = False,
) -> None:
"""
Args:
model_name: Nama model HuggingFace. None = cascade otomatis.
min_confidence: Ambang skor minimum hasil ML (0.0–1.0).
use_rules: Aktifkan rule-based booster.
load_on_init: Muat model langsung saat inisialisasi (default lazy).
"""
self._model_name = model_name
self._min_confidence = min_confidence
self._use_rules = use_rules
self._pipeline = None
self._loaded_model_name: str | None = None
self._load_error: str | None = None
if load_on_init:
self.load()
# Public API
def load(self) -> bool:
"""
Muat model dari HuggingFace Hub (atau cache lokal ~/.cache/huggingface/).
Pemuatan pertama memerlukan koneksi internet dan waktu cukup lama (~600 MB).
Setelah di-cache, berjalan offline tanpa unduhan ulang.
Returns:
True jika model berhasil dimuat, False jika semua cascade gagal.
"""
if self._pipeline is not None:
return True
candidates = [self._model_name] if self._model_name else _MODEL_CASCADE
for name in candidates:
if self._try_load(name):
return True
self._load_error = (
f"Semua model NER gagal dimuat: {candidates}. "
"Periksa koneksi internet dan dependensi."
)
logger.error(self._load_error)
return False
def predict(self, text: str, language: str = "id") -> list[NEREntity]:
"""
Deteksi entitas bernama dalam teks Bahasa Indonesia.
Args:
text: Teks yang akan dianalisis.
language: "id" atau "unknown" (sistem Indonesia-only).
Returns:
Daftar NEREntity diurutkan berdasarkan posisi (start ascending).
List kosong jika model belum dimuat atau teks kosong.
"""
text = text.strip()
if not text:
return []
ml_entities = self._predict_ml(text) if self._pipeline else []
rule_entities = self._predict_rules(text, language) if self._use_rules else []
name_entities = self._predict_names(text) if self._use_rules else []
# Jika teks dominan huruf kecil, coba juga rule + nama pada versi title-case.
# str.title() hanya mengubah kasus huruf pertama tiap kata — panjang dan
# offset karakter tidak berubah sehingga entitas yang ditemukan langsung
# berlaku untuk teks asli.
if self._use_rules:
titled = text.title()
if titled != text:
for e in self._predict_rules(titled, language) + self._predict_names(titled):
rule_entities.append(NEREntity(
word=text[e.start:e.end],
label=e.label,
score=e.score,
start=e.start,
end=e.end,
source=e.source,
))
combined = _merge_entities(ml_entities, rule_entities + name_entities)
combined = [_trim_entity_context_words(e, text) for e in combined]
filtered = [
e for e in combined
if _is_plausible_entity(e.word)
and _has_valid_entity_context(e, text, language)
and not _is_ethnic_label_context(e, text)
]
return sorted(filtered, key=lambda e: e.start)
def predict_batch(self, texts: Sequence[str]) -> list[list[NEREntity]]:
"""Proses beberapa teks sekaligus (lebih efisien dari loop manual)."""
return [self.predict(t) for t in texts]
def download(self, model_name: str | None = None) -> Path:
"""
Unduh model ke direktori lokal <project_root>/cache/ untuk digunakan offline.
Args:
model_name: ID model HuggingFace. None = pakai kandidat pertama dari cascade.
Returns:
Path direktori model yang telah diunduh.
Contoh::
ner = IndonesianNER()
ner.download() # unduh xlm-roberta-large ke cache/
ner.load() # muat dari lokal, tanpa internet
"""
from huggingface_hub import snapshot_download
target = model_name or (self._model_name or _MODEL_CASCADE[0])
local_dir = _CACHE_DIR / target.split("/")[-1]
local_dir.mkdir(parents=True, exist_ok=True)
logger.info("Mengunduh model '%s' ke '%s'...", target, local_dir)
snapshot_download(repo_id=target, local_dir=str(local_dir))
logger.info("Model berhasil diunduh ke '%s'.", local_dir)
return local_dir
# Properties
@property
def is_loaded(self) -> bool:
"""True jika model ML sudah dimuat dan siap digunakan."""
return self._pipeline is not None
@property
def loaded_model(self) -> str | None:
"""Nama model yang berhasil dimuat, atau None jika belum dimuat."""
return self._loaded_model_name
@property
def load_error(self) -> str | None:
"""Pesan error pemuatan terakhir, atau None jika tidak ada error."""
return self._load_error
# Internal
def _try_load(self, model_name: str) -> bool:
"""
Coba muat satu model. Prioritaskan direktori lokal sebelum HuggingFace Hub.
Urutan resolusi:
1. <project_root>/cache/<model_slug>/ (hasil download())
2. HuggingFace Hub (memerlukan internet saat pertama kali)
"""
local_dir = _CACHE_DIR / model_name.split("/")[-1]
load_from: str = str(local_dir) if local_dir.is_dir() else model_name
if local_dir.is_dir():
logger.info("Memuat model NER dari lokal: '%s'", local_dir)
else:
logger.info("Memuat model NER dari HuggingFace: '%s'", model_name)
try:
from transformers import pipeline as _hf_pipeline # impor lazy (~4 dtk)
self._pipeline = _hf_pipeline(
"ner",
model=load_from,
# aggregation_strategy="average": skor entitas = rata-rata skor semua
# sub-token penyusunnya. Lebih baik dari "simple" karena:
# 1. Kata tidak terpecah menjadi fragmen ("Bambang" tetap utuh)
# 2. Meredam false-positive pada teks acak/gibberish
aggregation_strategy="average",
)
self._loaded_model_name = model_name
logger.info("Model NER '%s' berhasil dimuat.", model_name)
return True
except Exception as exc:
logger.warning("Gagal memuat '%s': %s", model_name, exc)
self._pipeline = None
return False
def _predict_ml(self, text: str) -> list[NEREntity]:
"""
Jalankan inferensi model ML dan kembalikan entitas yang lolos threshold.
Teks panjang dipecah menjadi beberapa potongan karena model transformer
membatasi panjang input (umumnya 512 token). Tiap potongan diproses
terpisah, lalu offset hasilnya disesuaikan kembali ke teks asli.
"""
entities: list[NEREntity] = []
for chunk_text, chunk_offset in _chunk_text(text):
entities.extend(self._predict_ml_chunk(chunk_text, chunk_offset))
return entities
def _predict_ml_chunk(self, text: str, offset: int) -> list[NEREntity]:
"""Inferensi satu potongan teks. `offset` adalah posisi potongan di teks asli."""
try:
raw = self._pipeline(text)
except Exception as exc:
logger.warning("Inferensi NER gagal: %s", exc)
return []
entities: list[NEREntity] = []
for result in raw:
score = float(result.get("score", 0.0))
if score < self._min_confidence:
continue
raw_label = result.get("entity_group", result.get("entity", ""))
raw_label = raw_label.lstrip("BI-") # hapus prefix B-/I- jika ada
internal = _LABEL_MAP.get(raw_label)
if internal is None:
continue # tipe numerik generik, abaikan
start = int(result.get("start", 0))
end = int(result.get("end", 0))
start, end = _trim_entity_span(text, start, end)
# Ekstrak kata dari teks potongan menggunakan offset karakter — lebih
# andal daripada field "word" pipeline yang bisa kehilangan spasi atau
# menyisakan artefak sentencepiece (▁).
word = text[start:end].strip() if end > start else ""
if not word:
word = re.sub(r'[▁\s]+', ' ', result.get("word", "")).strip()
if len(word) < 2:
continue
# Sesuaikan offset ke teks asli dengan menambah posisi potongan.
entities.append(NEREntity(
word=word,
label=internal,
score=round(score, 4),
start=start + offset,
end=end + offset,
source="ml",
))
return entities
def _predict_rules(self, text: str, language: str = "id") -> list[NEREntity]:
"""Jalankan pola regex dan kembalikan entitas yang cocok."""
entities: list[NEREntity] = []
for pattern, label, score in _RULES:
for m in pattern.finditer(text):
# Bila pola punya capturing group (mis. nama setelah "nama saya"),
# tandai isi group itu saja, bukan seluruh match termasuk pemicunya.
if m.lastindex:
span_start, span_end = m.start(m.lastindex), m.end(m.lastindex)
else:
span_start, span_end = m.start(), m.end()
start, end = _trim_entity_span(text, span_start, span_end)
word = text[start:end]
if len(word) < 2:
continue
entities.append(NEREntity(
word=word,
label=label,
score=score,
start=start,
end=end,
source="rule",
))
return entities
def _predict_names(self, text: str) -> list[NEREntity]:
"""
Deteksi nama orang menggunakan lexicon nama Indonesia.
Fallback untuk kasus nama terisolasi yang tidak terdeteksi model ML karena
kurangnya konteks kalimat. Hanya token yang diawali huruf kapital dan cocok
dengan lexicon yang dilaporkan — meminimalkan false positive pada kata biasa.
Beberapa nama juga merupakan kata umum (mis. "Bunga", "Cinta", "Tri"). Pada
awal kalimat kata umum selalu berhuruf kapital, sehingga nama satu kata di
posisi ini hanya dilaporkan bila didahului gelar atau sapaan. Nama dua kata
atau lebih tetap dilaporkan karena pola itu jarang muncul secara kebetulan.
Skor tetap 0.75 (di atas ambang 0.60, di bawah hasil ML yang berhasil).
"""
entities: list[NEREntity] = []
# Cocokkan urutan 1–4 kata kapital
for m in re.finditer(r'\b[A-Z][a-z]{1,20}(?:\s+[A-Z][a-z]{1,20}){0,3}\b', text):
phrase = m.group()
parts = phrase.split()
# Pangkas kata di depan span yang masuk stopword, mis. "Saya" dalam
# "Saya Budi Santoso" — kata ganti/umum bukan bagian nama.
span_start = m.start()
while parts and parts[0].lower() in _COMMON_NON_ENTITIES:
span_start += len(parts[0])
while span_start < m.end() and text[span_start] == " ":
span_start += 1
parts = parts[1:]
if not parts:
continue
if not any(p.lower() in _INDONESIAN_NAMES for p in parts):
continue
if len(parts) == 1 and self._is_sentence_initial(text, span_start):
# Nama satu kata di awal kalimat: hanya valid bila ada gelar
# atau sapaan sebelum nama, mis. "Pak Tri", "Ibu Bunga".
if not _has_preceding_title(text, span_start):
continue
start, end = _trim_entity_span(text, span_start, m.end())
phrase = text[start:end]
entities.append(NEREntity(
word=phrase,
label="ORANG",
score=0.75,
start=start,
end=end,
source="rule",
))
return entities
@staticmethod
def _is_sentence_initial(text: str, start: int) -> bool:
"""True jika posisi start berada di awal teks atau setelah tanda akhir kalimat."""
i = start - 1
while i >= 0 and text[i] in " \t":
i -= 1
return i < 0 or text[i] in ".!?\n"
# Panjang karakter maksimum satu potongan teks untuk inferensi model.
# Dibatasi agar input tidak melebihi kapasitas token model transformer.
_CHUNK_MAX_CHARS = 1200
def _chunk_text(text: str) -> list[tuple[str, int]]:
"""
Pecah teks panjang menjadi potongan beserta offset awalnya di teks asli.
Pemotongan dilakukan pada batas spasi terdekat agar kata tidak terpotong.
Teks pendek dikembalikan utuh sebagai satu potongan.
Returns:
Daftar (potongan_teks, offset_awal).
"""
if len(text) <= _CHUNK_MAX_CHARS:
return [(text, 0)]
chunks: list[tuple[str, int]] = []
pos = 0
n = len(text)
while pos < n:
end = min(pos + _CHUNK_MAX_CHARS, n)
if end < n:
space = text.rfind(" ", pos, end)
if space > pos:
end = space
chunks.append((text[pos:end], pos))
pos = end + 1 if end < n and text[end] == " " else end
return chunks
def _trim_entity_span(text: str, start: int, end: int) -> tuple[int, int]:
"""Hapus spasi/tanda baca di tepi entitas tanpa mengubah isi teks asli."""
while start < end and text[start] in _ENTITY_EDGE_CHARS:
start += 1
while end > start and text[end - 1] in _ENTITY_EDGE_CHARS:
end -= 1
return start, end
# Set vokal untuk dipakai dalam filter plausibilitas
_VOWELS = frozenset("aeiouAEIOU")
# Daftar kata penanda dimuat dari resources/lexicons/ner/ agar mudah dirawat
# tanpa menyentuh kode (lihat masing-masing berkas .txt).
# Filter plausibilitas memakai stopword Indonesia LENGKAP (stopwords-iso, ~758 kata,
# diimpor via scripts/import_lexicons.py --stopwords-id) — bukan daftar buatan tangan.
# Fallback inline ringkas hanya untuk ketahanan bila berkas stopword belum diimpor.
_COMMON_NON_ENTITIES_FALLBACK = frozenset({
"bukan", "tidak", "tak", "jangan", "dan", "atau", "ini", "itu",
"saya", "aku", "kamu", "anda", "kami", "mereka", "dia", "yang",
"untuk", "dengan", "dari", "ke", "di", "pada", "sebagai",
})
_COMMON_NON_ENTITIES = frozenset(
load_word_set("language", "stopwords_id.txt") or _COMMON_NON_ENTITIES_FALLBACK
)
_LOCATION_LEFT_CUES = frozenset(load_word_set("ner", "location_left_cues.txt"))
_LOCATION_RIGHT_CUES = frozenset(load_word_set("ner", "location_right_cues.txt"))
_TOKEN_RE = re.compile(r"[A-Za-zÀ-ÿ]+")
# Gelar dan sapaan yang menandai kata sesudahnya sebagai nama orang.
_NAME_TITLE_CUES = frozenset(load_word_set("ner", "name_title_cues.txt"))
# Kata pemicu konteks etnis/ras ("suku Jawa") — entitas sesudahnya bukan nama diri.
_ETHNIC_CUE_WORDS = frozenset(
load_word_set("ner", "ethnic_cue_words.txt") or {"suku", "ras", "etnis", "bangsa"}
)
def _has_preceding_title(text: str, start: int) -> bool:
"""True jika tepat sebelum posisi start terdapat gelar atau sapaan."""
prefix = text[:start].rstrip()
m = re.search(r"([A-Za-z]+)\.?\s*$", prefix)
return bool(m) and m.group(1).lower() in _NAME_TITLE_CUES
def _is_plausible_entity(word: str) -> bool:
"""
Saring entitas yang kemungkinan besar bukan entitas nyata (gibberish filter).
Menolak teks yang:
- Token tunggal (tanpa spasi) sepanjang > 25 karakter
- Memiliki kluster konsonan berturut-turut ≥ 5 karakter
- Tidak memiliki vokal sama sekali (untuk kata non-kapital > 4 huruf)
Selalu mengizinkan:
- Singkatan huruf kapital pendek ≤ 10 karakter (UNESCO, WHO, NPWP)
- Frasa multi-kata dengan kluster konsonan wajar
"""
if not word or not word.strip():
return False
letters = [c for c in word if c.isalpha()]
# Singkatan huruf kapital pendek → selalu valid
if letters and all(c.isupper() for c in letters) and len(word) <= 10:
return True
# Token tunggal terlalu panjang → kemungkinan gibberish
if " " not in word and len(word) > 25:
return False
# Hitung kluster konsonan terpanjang
max_cons = cur = 0
for ch in word:
if ch.isalpha():
cur = 0 if ch in _VOWELS else cur + 1
max_cons = max(max_cons, cur)
else:
cur = 0
if max_cons >= 5:
return False
# Tidak ada vokal sama sekali untuk kata yang cukup panjang → tidak wajar
has_vowel = any(ch in _VOWELS for ch in word if ch.isalpha())
if not has_vowel and len(letters) > 4:
return False
return True
# Kata pengantar konteks yang menempel di ekor entitas (bukan bagian nama):
# "Kemendikbud kelas VIII bab" → "Kemendikbud". Sengaja TIDAK memuat preposisi
# pendek (di/ke/dari) karena sering berada di tengah nama lembaga yang sah.
_TRAILING_CONTEXT_WORDS = frozenset({
"kelas", "bab", "hal", "halaman", "nomor", "no", "tentang", "tahun",
})
# Kata huruf-kecil yang LAZIM menjadi penghubung internal nama diri sehingga
# tidak boleh memecah span: "Bank of America", "University of California",
# "Ahmad bin Hasan", "Vincent van Gogh". Kata fungsi lain (from, dan, dari, di,
# the, leads, ...) bukan penghubung nama → menjadi titik pemecah span.
_NAME_CONNECTORS = frozenset({
"of", "de", "van", "der", "den", "bin", "binti", "al", "da", "dos",
"du", "la", "le", "el", "ter",
})
def _trim_entity_context_words(entity: NEREntity, text: str) -> NEREntity:
"""
Pangkas kata bukan-entitas yang ikut tersapu ke dalam span (umumnya karena
pass title-case menjadikan kata fungsi tampak seperti nama).
Strategi: setelah memotong kata-pengantar konteks ("kelas", "bab", ...),
span dipecah pada kata huruf-kecil yang BUKAN penghubung nama, lalu diambil
rangkaian kata-berkapital terpanjang. Ini menangani kata fungsi di tengah
("Mr. John Smith from Apple Inc" → "Mr. John Smith") maupun entitas yang
berada di ekor ("Dia bekerja di OpenAI Inc" → "OpenAI Inc"), sambil menjaga
nama ber-penghubung sah ("Bank of America") tetap utuh.
"""
parts = entity.word.split()
if len(parts) <= 1:
return entity
# 1. Potong mulai dari kata-pengantar konteks.
for i, p in enumerate(parts):
if i > 0 and p.lower() in _TRAILING_CONTEXT_WORDS:
parts = parts[:i]
break
# 2. Pecah menjadi rangkaian kata-berkapital (kata diri/akronim/angka),
# penghubung nama dipertahankan di dalam rangkaian yang sedang berjalan.
def _is_cap(tok: str) -> bool:
return (not tok[:1].isalpha()) or tok[:1].isupper()
runs: list[list[str]] = []
cur: list[str] = []
for p in parts:
if _is_cap(p) or (p.lower() in _NAME_CONNECTORS and cur):
cur.append(p)
else:
if cur:
runs.append(cur)
cur = []
if cur:
runs.append(cur)
if not runs:
return entity
# 3. Ambil rangkaian terpanjang, lalu buang penghubung yang menggantung di tepi.
best = max(runs, key=len)
while best and best[0].lower() in _NAME_CONNECTORS:
best = best[1:]
while best and best[-1].lower() in _NAME_CONNECTORS:
best = best[:-1]
word = " ".join(best).strip()
if not word or word == entity.word:
return entity
idx = text.find(word, entity.start)
if idx == -1 or idx >= entity.end:
return entity
return NEREntity(
word=word, label=entity.label, score=entity.score,
start=idx, end=idx + len(word), source=entity.source,
)
def _is_ethnic_label_context(entity: NEREntity, text: str) -> bool:
"""True jika entitas satu kata yang muncul tepat setelah kata suku/ras/etnis/bangsa."""
if len(_TOKEN_RE.findall(entity.word)) != 1:
return False
prev, _ = _context_tokens(text, entity.start, entity.end)
return prev in _ETHNIC_CUE_WORDS
def _context_tokens(text: str, start: int, end: int) -> tuple[str | None, str | None]:
"""Ambil token sebelum dan sesudah span entitas."""
prev = None
for m in _TOKEN_RE.finditer(text[:start]):
prev = m.group().lower()
nxt = None
m_next = _TOKEN_RE.search(text[end:])
if m_next:
nxt = m_next.group().lower()
return prev, nxt
def _has_valid_entity_context(entity: NEREntity, text: str, language: str) -> bool:
"""
Cegah kata umum terbaca sebagai entitas hanya karena ada di lexicon global.
Contoh kasus: "Bukan" ada di daftar tempat global, tetapi dalam prompt
Indonesia kata "bukan" hampir selalu negasi. Ia hanya dianggap lokasi jika
ditulis seperti nama tempat dan ada cue lokasi di sekitarnya.
"""
norm = entity.word.strip().lower()
is_single_token = len(_TOKEN_RE.findall(entity.word)) == 1
if norm not in _COMMON_NON_ENTITIES:
return True
if entity.label != "LOKASI" or not is_single_token:
return False
prev, nxt = _context_tokens(text, entity.start, entity.end)
has_location_cue = (
(prev in _LOCATION_LEFT_CUES)
or (nxt in _LOCATION_RIGHT_CUES)
)
looks_like_place_name = entity.word[:1].isupper()
return looks_like_place_name and has_location_cue
def _spans_overlap(a_start: int, a_end: int, b_start: int, b_end: int) -> bool:
"""Kembalikan True jika dua rentang karakter saling tumpang-tindih."""
return a_start < b_end and b_start < a_end
def _merge_entities(
ml: list[NEREntity],
rules: list[NEREntity],
) -> list[NEREntity]:
"""
Gabungkan entitas ML dan rule-based dengan resolusi tumpang-tindih.
Strategi:
- Entitas rule-based dimasukkan lebih dulu karena polanya lebih presisi untuk
konteks Indonesia dan sering memperbaiki fragmen ML seperti "Universitas"
menjadi "Universitas Indonesia".
- Entitas ML ditambahkan setelah itu hanya jika tidak tumpang-tindih.
"""
accepted: list[NEREntity] = []
for entity in sorted(rules, key=lambda e: (e.start, -(e.end - e.start), -e.score)):
if not any(
_spans_overlap(entity.start, entity.end, kept.start, kept.end)
for kept in accepted
):
accepted.append(entity)
for entity in sorted(ml, key=lambda e: -e.score):
if not any(
_spans_overlap(entity.start, entity.end, kept.start, kept.end)
for kept in accepted
):
accepted.append(entity)
return accepted
# Singleton
_default_ner: IndonesianNER | None = None
def get_ner(load: bool = True) -> IndonesianNER:
"""
Kembalikan instance IndonesianNER singleton (lazy-initialized).
Instance yang sama digunakan ulang di seluruh aplikasi sehingga model
hanya dimuat sekali.
Args:
load: Jika True, panggil load() otomatis sebelum dikembalikan.
"""
global _default_ner
if _default_ner is None:
_default_ner = IndonesianNER()
if load and not _default_ner.is_loaded:
_default_ner.load()
return _default_ner
# Demo CLI
if __name__ == "__main__":
import sys
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
SAMPLE_TEXTS = [
"Ahmad Santoso bekerja di PT Teknologi Maju Indonesia di Jakarta sejak 15 Maret 2020.",
"Kementerian Keuangan menerbitkan Peraturan Pemerintah No. 23/2024 tentang pajak UMKM.",
"Prof. Budi Raharjo dari Universitas Gadjah Mada memenangkan penghargaan Rp 500 juta.",
"KPK memeriksa pejabat OJK terkait kasus di Provinsi Jawa Barat pada bulan Oktober.",
"Partai Golkar dan PDIP bersepakat dalam sidang MPR membahas konstitusi.",
]
texts = sys.argv[1:] or SAMPLE_TEXTS
ner = IndonesianNER()
if not ner.load():
print(f"[ERROR] {ner.load_error}")
sys.exit(1)
print(f"Model dimuat: {ner.loaded_model}\n{'-' * 60}")
for text in texts:
print(f"\n> {text}")
entities = ner.predict(text)
if not entities:
print(" (tidak ada entitas terdeteksi)")
for e in entities:
bar = "#" * int(e.score * 10) + "." * (10 - int(e.score * 10))
print(f" [{e.label:<12}] {e.word!r:<40} {bar} {e.score:.2f} ({e.source})")