Spaces:
Running
Running
| """ | |
| 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 | |
| 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 | |
| def is_loaded(self) -> bool: | |
| """True jika model ML sudah dimuat dan siap digunakan.""" | |
| return self._pipeline is not None | |
| def loaded_model(self) -> str | None: | |
| """Nama model yang berhasil dimuat, atau None jika belum dimuat.""" | |
| return self._loaded_model_name | |
| 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 | |
| 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})") | |