From 8288fd99bdbf2ea7ecffe491ca5119097a69f04b Mon Sep 17 00:00:00 2001 From: adistya13 Date: Tue, 19 May 2026 19:30:36 +0700 Subject: [PATCH] revisi tampilan beberapa tampilan ada yang dihapus --- page_modules/preprocessing_page.py | 455 +++++---- page_modules/sentiment_page.py | 1481 ++++++++-------------------- tweets-data/hasil_latest.csv | 1 + tweets-data/hasil_latest.old.csv | 3 - tweets-data/hasil_top.csv | 12 + tweets-data/hasil_top.old.csv | 55 +- 6 files changed, 682 insertions(+), 1325 deletions(-) create mode 100644 tweets-data/hasil_latest.csv create mode 100644 tweets-data/hasil_top.csv diff --git a/page_modules/preprocessing_page.py b/page_modules/preprocessing_page.py index 5db8215..f9a8ff8 100644 --- a/page_modules/preprocessing_page.py +++ b/page_modules/preprocessing_page.py @@ -1,3 +1,17 @@ +""" +preprocessing_page.py +===================== +Halaman Bersihkan Data — NLP Pipeline 6 Tahap. + +PIPELINE 6 TAHAP (selaras dengan sentiment_service.py): + 1. Case Folding — ubah semua huruf jadi lowercase + 2. Cleaning — hapus URL, mention, hashtag, angka, emoji, tanda baca + 3. Normalisasi — singkatan/slang → kata baku + 4. Tokenizing — pecah kalimat → list token + 5. Stopword Removal — hapus kata umum; JAGA kata sentimen penting + 6. Stemming — bentuk dasar kata via Sastrawi ECS +""" + import streamlit as st import pandas as pd import os @@ -78,69 +92,64 @@ def _sync_dynamic_period(): # ═══════════════════════════════════════════════════════════ # PREPROCESSING PIPELINE — 6 TAHAP +# PENTING: Pipeline ini harus IDENTIK dengan sentiment_service.py +# agar token yang dihasilkan konsisten dengan training model. # -# Urutan tahap: -# 1. Cleaning — hapus noise (URL, mention, hashtag, angka, -# tanda baca, emoji, karakter non-latin) -# 2. Case Folding — ubah semua huruf jadi huruf kecil (lowercase) -# 3. Normalisasi — ganti singkatan/slang → kata baku -# 4. Tokenizing — pecah kalimat menjadi list token/kata -# 5. Stopword Removal — buang kata umum yang tidak bermakna -# 6. Stemming — bentuk dasar kata via Sastrawi -# -# Catatan desain: -# • Case Folding dilakukan SETELAH cleaning awal supaya regex pendeteksi -# emoji/karakter khusus tetap bekerja dengan baik, lalu baru dilanjut -# ke langkah selanjutnya yang semuanya butuh lowercase. -# • Tokenizing menghasilkan list; untuk keperluan tabel & analisis kata -# hasil tokenizing dikembalikan sebagai string (join spasi) agar mudah -# disimpan dan diproses step berikutnya. -# • Normalisasi berjalan pada level token sehingga pencocokan kata tepat -# (tidak partial match di tengah kata). +# URUTAN: +# 1. Case Folding → lowercase dulu sebelum cleaning +# 2. Cleaning → hapus noise setelah lowercase +# 3. Normalisasi → singkatan/slang → kata baku +# 4. Tokenizing → split menjadi list token +# 5. Stopword Removal → buang kata umum, jaga kata sentimen +# 6. Stemming → bentuk dasar via Sastrawi ECS # ═══════════════════════════════════════════════════════════ +KATA_SENTIMEN_PENTING = { + # Negasi + "tidak", "bukan", "jangan", "kurang", "belum", "tanpa", + # Positif + "keren", "bagus", "mantap", "setuju", "dukung", "mendukung", + "andal", "handal", "gercep", "bangga", "senang", "suka", + "baik", "benar", "tepat", "oke", + "sejahtera", "berkembang", "maju", "inovatif", + "tegas", "sigap", "tanggap", "adil", "bijak", "bermanfaat", + "untung", "berhasil", "sukses", "solusi", "manfaat", + "berguna", "membantu", "bantu", "pro", "lanjut", + "sangat", "banget", "sekali", "paling", "amat", "luar", "biasa", + # Negatif + "kecewa", "buruk", "jelek", "parah", "gagal", "hancur", + "rusak", "bohong", "tipu", "korupsi", + # Emosi + "marah", "sedih", "khawatir", +} + + def _load_stopwords(): - """ - Load stopword dari file indonesian-stopwords-complete.txt. - Kata-kata negasi penting (tidak, bukan, jangan, kurang, belum) - dikeluarkan dari stopword agar tidak hilang saat filtering — - keberadaannya krusial untuk analisis sentimen. - """ stopword_file = "indonesian-stopwords-complete.txt" base = set() try: with open(stopword_file, "r", encoding="utf-8") as f: base = set(f.read().splitlines()) - # Pertahankan kata negasi — penting untuk sentimen - for kata in ["tidak", "bukan", "jangan", "kurang", "belum", "tanpa"]: - base.discard(kata) except FileNotFoundError: - # Fallback minimal jika file tidak ada base = { "yang", "dan", "di", "ke", "dari", "ini", "itu", "dengan", "untuk", "pada", "adalah", "oleh", "ada", "ya", "akan", "atau", "juga", "sama", "karena", "jika", "sudah", "telah", } - # Tambahan stopword domain-spesifik & informal + for kata in KATA_SENTIMEN_PENTING: + base.discard(kata) base.update({ "rt", "amp", "https", "http", "co", "t", "wkwk", "wkwkwk", "haha", "hehe", "xixi", "yg", "dgn", "utk", "dr", "krn", "tp", "jd", "sdh", - "aja", "doang", "banget", "bgt", "nih", "sih", "dong", "deh", - "loh", "lah", "tuh", "dong", "kak", "gan", + "aja", "doang", "nih", "sih", "dong", "deh", + "loh", "lah", "tuh", "kak", "gan", }) return base def _load_stemmer(): - """ - Load stemmer Sastrawi jika tersedia. - Sastrawi adalah library stemming Bahasa Indonesia berbasis algoritma - Enhanced Confix Stripping (ECS) yang mampu menghapus awalan dan akhiran - secara bertahap (berlari → lari, makanan → makan, dst). - Mengembalikan None jika Sastrawi tidak terinstall — stemming di-skip. - """ try: from Sastrawi.Stemmer.StemmerFactory import StemmerFactory return StemmerFactory().create_stemmer() @@ -148,28 +157,54 @@ def _load_stemmer(): return None -# Kamus normalisasi: kata informal/singkatan → kata baku -# Mencakup: singkatan SMS/chat, kata gaul, singkatan domain crawling NORMALISASI = { # Negasi "gk": "tidak", "ga": "tidak", "gak": "tidak", "nggak": "tidak", "ngga": "tidak", "tdk": "tidak", - "tak": "tidak", "enggak": "tidak", - # Kata ganti & preposisi + "tak": "tidak", "enggak": "tidak", "engga": "tidak", + "kagak": "tidak", "kaga": "tidak", "ndak": "tidak", + "gkk": "tidak", "ngak": "tidak", + # Kata ganti "yg": "yang", "dgn": "dengan", "utk": "untuk", "org": "orang", "krn": "karena", "dr": "dari", "sm": "sama", "pd": "pada", "dlm": "dalam", - # Verba & adjektiva + "bwt": "buat", "trm": "terima", + # Verba "tp": "tapi", "tpi": "tapi", "jd": "jadi", "sdh": "sudah", "blm": "belum", "emg": "memang", "emang": "memang", "gimana": "bagaimana", - "gitu": "begitu", "gini": "begini", "bgt": "banget", - "udah": "sudah", "udh": "sudah", "mau": "mau", - "bngt": "banget", "bener": "benar", "beneran": "benar", - "mantep": "mantap", "keren": "keren", - # Domain e-commerce & topik crawling + "gitu": "begitu", "gini": "begini", + "udah": "sudah", "udh": "sudah", + "mau": "mau", + # Intensitas + "bgt": "banget", "bngt": "banget", "bget": "banget", "bgtt": "banget", + # Positif informal + "bener": "benar", "beneran": "benar", + "mantep": "mantap", "mntap": "mantap", + "kece": "keren", "kece bgt": "keren banget", + "cucok": "cocok", "cucuk": "cocok", + "cakep": "bagus", "oke bgt": "oke banget", + "sip": "baik", "siipp": "baik", + "top": "terbaik", "topbgt": "terbaik banget", + "jos": "bagus", "josss": "bagus", + "goks": "luar biasa", + "setujuu": "setuju", "stuju": "setuju", + "dukung": "dukung", + "proud": "bangga", + "mantul": "mantap betul", + # Negatif informal + "ancur": "hancur", "ancrr": "hancur", + "parahh": "parah", "parahhh": "parah", + "gagall": "gagal", + "ngaco": "tidak benar", + "ngasal": "tidak benar", + "receh": "tidak penting", + "gaje": "tidak jelas", + "asal": "sembarangan", + # Domain "ongkir": "ongkos kirim", "freeongkir": "gratis ongkos kirim", + "gratisongkir": "gratis ongkos kirim", "free": "gratis", "ecommerce": "e commerce", "komdigi": "komdigi", @@ -185,69 +220,51 @@ NORMALISASI = { } -# ── Tahap 1: Cleaning ──────────────────────────────────────────────────────── -# Menghapus semua elemen yang bukan teks bermakna: -# URL, mention (@user), hashtag (#), angka, tanda baca standar, -# emoji & simbol unicode, dan karakter non-latin (arab, kanji, dll). -# Teks masih bisa uppercase/mixed-case di tahap ini. -def step1_cleaning(text: str) -> str: - text = str(text) - text = re.sub(r"http\S+|www\S+|https\S+", "", text) # hapus URL - text = re.sub(r"@\w+", "", text) # hapus mention - text = re.sub(r"#\w+", "", text) # hapus hashtag (beserta kata) - text = re.sub(r"\d+", "", text) # hapus angka - # Hapus emoji & simbol unicode (range karakter di luar Basic Latin) +# ── Tahap 1: Case Folding ─────────────────────────────────────────────────── +def step1_case_folding(text: str) -> str: + return str(text).lower() + + +# ── Tahap 2: Cleaning ─────────────────────────────────────────────────────── +def step2_cleaning(text: str) -> str: + text = re.sub(r"http\S+|www\S+|https\S+", "", text) + text = re.sub(r"@\w+", "", text) + text = re.sub(r"#\w+", "", text) + text = re.sub(r"\d+", "", text) text = re.sub( - r"[\U00010000-\U0010ffff" # supplementary planes (emoji berwarna) - r"\U0001F600-\U0001F64F" # emoticons - r"\U0001F300-\U0001F5FF" # symbols & pictographs - r"\U0001F680-\U0001F6FF" # transport & map - r"\U0001F1E0-\U0001F1FF" # flags - r"\u2600-\u26FF\u2700-\u27BF" # misc symbols & dingbats + r"[\U00010000-\U0010ffff" + r"\U0001F600-\U0001F64F" + r"\U0001F300-\U0001F5FF" + r"\U0001F680-\U0001F6FF" + r"\U0001F1E0-\U0001F1FF" + r"\u2600-\u26FF\u2700-\u27BF" r"]+", "", text, flags=re.UNICODE ) - text = text.translate(str.maketrans("", "", string.punctuation)) # hapus tanda baca - text = re.sub(r"[^a-zA-Z\s]", "", text) # hapus karakter non-latin + text = text.translate(str.maketrans("", "", string.punctuation)) + text = re.sub(r"[^a-zA-Z\s]", "", text) text = re.sub(r"\s+", " ", text).strip() return text -# ── Tahap 2: Case Folding ──────────────────────────────────────────────────── -# Mengubah seluruh teks menjadi huruf kecil (lowercase). -# Dipisah dari cleaning agar jelas sebagai tahap tersendiri dalam pipeline. -def step2_case_folding(text: str) -> str: - return text.lower() - - -# ── Tahap 3: Normalisasi ───────────────────────────────────────────────────── -# Mengganti kata tidak baku / singkatan / slang dengan kata baku. -# Pencocokan dilakukan per-token (bukan substring) agar "dr" di tengah kata -# tidak ikut diganti. +# ── Tahap 3: Normalisasi ──────────────────────────────────────────────────── def step3_normalization(text: str) -> str: return " ".join(NORMALISASI.get(word, word) for word in text.split()) -# ── Tahap 4: Tokenizing ────────────────────────────────────────────────────── -# Memecah kalimat menjadi daftar token (kata). -# Hasilnya dikembalikan sebagai list untuk proses berikutnya, -# tapi juga disimpan sebagai string (join) untuk tampilan tabel. +# ── Tahap 4: Tokenizing ───────────────────────────────────────────────────── def step4_tokenizing(text: str) -> list: return text.split() -# ── Tahap 5: Stopword Removal ──────────────────────────────────────────────── -# Membuang kata-kata yang tidak bermakna dari daftar token. -# Juga membuang token dengan panjang ≤ 2 karakter. -# Kata negasi (tidak, bukan, jangan, dll) TIDAK dihapus. +# ── Tahap 5: Stopword Removal ─────────────────────────────────────────────── def step5_stopword_removal(tokens: list, stopwords: set) -> list: - return [w for w in tokens if w not in stopwords and len(w) > 2] + return [ + w for w in tokens + if (w not in stopwords or w in KATA_SENTIMEN_PENTING) and len(w) > 2 + ] -# ── Tahap 6: Stemming ──────────────────────────────────────────────────────── -# Mengubah kata ke bentuk dasar menggunakan algoritma Enhanced Confix -# Stripping (ECS) dari library Sastrawi. -# Contoh: "berlari" → "lari", "makanan" → "makan", "pembatasan" → "batas" -# Jika Sastrawi tidak tersedia, token dikembalikan apa adanya. +# ── Tahap 6: Stemming ─────────────────────────────────────────────────────── def step6_stemming(tokens: list, stemmer) -> list: if stemmer is None: return tokens @@ -256,25 +273,32 @@ def step6_stemming(tokens: list, stemmer) -> list: def full_preprocessing(text: str, stopwords: set, stemmer): """ - Menjalankan seluruh pipeline 6 tahap dan mengembalikan dict berisi - hasil di setiap tahap untuk keperluan tampilan tabel & debugging. + Jalankan 6 tahap preprocessing dan kembalikan dict hasil setiap tahap. + + URUTAN TAHAP: + 1. Case Folding → lowercase + 2. Cleaning → hapus noise + 3. Normalisasi → normalisasi kata + 4. Tokenizing → split token + 5. Stopword Removal → buang stopword + 6. Stemming → bentuk dasar """ - s1_clean = step1_cleaning(text) - s2_fold = step2_case_folding(s1_clean) - s3_norm = step3_normalization(s2_fold) + s1_fold = step1_case_folding(text) + s2_clean = step2_cleaning(s1_fold) + s3_norm = step3_normalization(s2_clean) s4_tokens = step4_tokenizing(s3_norm) s5_filtered = step5_stopword_removal(s4_tokens, stopwords) s6_stemmed = step6_stemming(s5_filtered, stemmer) return { - "setelah_cleaning": s1_clean, - "setelah_casefolding": s2_fold, - "setelah_normalisasi": s3_norm, - "setelah_tokenizing": " | ".join(s4_tokens), # tampilkan token dengan separator - "setelah_stopword": " ".join(s5_filtered), - "clean_text": " ".join(s6_stemmed), # hasil akhir untuk analisis sentimen - "_tokens_raw": s4_tokens, # list mentah (untuk statistik) - "_tokens_clean": s6_stemmed, # list bersih (untuk word freq) + "setelah_casefolding": s1_fold, + "setelah_cleaning": s2_clean, + "setelah_normalisasi": s3_norm, + "setelah_tokenizing": " | ".join(s4_tokens), + "setelah_stopword": " ".join(s5_filtered), + "clean_text": " ".join(s6_stemmed), + "_tokens_raw": s4_tokens, + "_tokens_clean": s6_stemmed, } @@ -302,16 +326,6 @@ def _gap(size="md"): st.markdown(f'
', unsafe_allow_html=True) -def _card_wrap(content_fn, *args, **kwargs): - """Wrapper card tanpa key — kompatibel semua versi Streamlit.""" - st.markdown(""" -
-
-""", unsafe_allow_html=True) - content_fn(*args, **kwargs) - - # ═══════════════════════════════════════════════════════════ # STYLES # ═══════════════════════════════════════════════════════════ @@ -382,6 +396,19 @@ header[data-testid="stHeader"] { height: 0 !important; min-height: 0 !important; margin: 2px 2px 0 0; line-height: 1.6; } + +.fix-badge { + display: inline-block; + background: #fef9c3; + color: #854d0e; + border: 1px solid #fde68a; + border-radius: 6px; + padding: 2px 8px; + font-size: 0.65rem; + font-weight: 700; + margin-left: 6px; + vertical-align: middle; +} """, unsafe_allow_html=True) @@ -416,7 +443,7 @@ def _render_page_header(): Bersihkan Data

Preprocessing teks 6 tahap otomatis: - Cleaning → Case Folding → Normalisasi → Tokenizing → Stopword Removal → Stemming + Case Folding → Cleaning → Normalisasi → Tokenizing → Stopword Removal → Stemming

✦ Diperluas', + "desc": "Mengubah kata tidak baku, singkatan, dan slang menjadi kata baku.", "items": [ - "gk / ga / gak → tidak", + "gk/ga/gak/kagak → tidak", "ongkir → ongkos kirim", - "bgt → banget", - "gimana → bagaimana", + "mantep → mantap", + "kece → keren", + "jos/josss → bagus", "free → gratis", - "seller → penjual", ], }, { @@ -491,27 +515,29 @@ def _render_pipeline_steps(stemmer_ok): "icon": "✂️", "color": "#7c3aed", "dark": "#3b0764", "bg": "linear-gradient(135deg,#f5f3ff,#ede9fe)", "border": "#c4b5fd", "title": "Tokenizing", - "desc": "Memecah kalimat menjadi unit kata (token) yang dapat diproses secara individual.", + "desc": "Memecah kalimat menjadi unit kata (token) individual.", "items": [ "Pisahkan berdasarkan spasi", - "\"gratis ongkos kirim\" → [gratis, ongkos, kirim]", + '"keren banget" → [keren, banget]', "Setiap token diproses mandiri", "Hasil: daftar kata terpisah", + "Input untuk stopword removal", + "Ditampilkan sebagai chip per token", ], }, { "num": "05", "anim": "pipe-5", "icon": "🚫", "color": "#ea580c", "dark": "#7c2d12", "bg": "linear-gradient(135deg,#fff7ed,#ffedd5)", "border": "#fed7aa", - "title": "Stopword Removal", - "desc": "Membuang kata-kata umum yang tidak berkontribusi pada makna atau sentimen teks.", + "title": 'Stopword Removal ✦ Diperbaiki', + "desc": "Membuang kata umum; kata sentimen penting DIJAGA.", "items": [ - "Hapus kata umum (dan, di, ke, dari…)", - "Hapus kata < 3 karakter", - "PERTAHANKAN: tidak, bukan, jangan", - "PERTAHANKAN: belum, kurang, tanpa", - f"Sumber: indonesian-stopwords-complete.txt", - "Kata negasi dijaga untuk sentimen", + "Hapus kata umum (dan, di, ke...)", + "Hapus token < 3 karakter", + "JAGA negasi: tidak, bukan, jangan", + "JAGA positif: keren, bagus, mantap", + "JAGA evaluatif: setuju, dukung, bijak", + "JAGA intensitas: banget, sangat, sekali", ], }, { @@ -519,26 +545,24 @@ def _render_pipeline_steps(stemmer_ok): "icon": "🌱", "color": "#ca8a04", "dark": "#713f12", "bg": "linear-gradient(135deg,#fefce8,#fef9c3)", "border": "#fde68a", "title": "Stemming", - "desc": "Mengubah kata ke bentuk dasarnya menggunakan algoritma Enhanced Confix Stripping (ECS) Sastrawi.", + "desc": "Mengubah kata ke bentuk dasar via ECS Sastrawi.", "items": [ "berlari → lari", "makanan → makan", "pembatasan → batas", "pengiriman → kirim", - f"Status: {'✅ Sastrawi aktif' if stemmer_ok else '⚠️ Sastrawi tidak terinstall — skip'}", + f"Status: {'✅ Sastrawi aktif' if stemmer_ok else '⚠️ Sastrawi tidak tersedia'}", "Algoritma: Enhanced Confix Stripping", ], }, ] - # Baris pertama: tahap 1–3 row1 = st.columns(3, gap="medium") for col, step in zip(row1, steps[:3]): _render_step_card(col, step) _gap("sm") - # Baris kedua: tahap 4–6 row2 = st.columns(3, gap="medium") for col, step in zip(row2, steps[3:]): _render_step_card(col, step) @@ -589,18 +613,18 @@ def _render_step_card(col, step): # ═══════════════════════════════════════════════════════════ -# FLOW ARROW — pipeline visual +# FLOW ARROW # ═══════════════════════════════════════════════════════════ def _render_flow_arrow(): nodes = [ - ("📄 Teks Asli", "#94a3b8", "#f8fafc", "#e2e8f0"), - ("① Cleaning", "#3b6cf7", "#eef2ff", "#c7d2fe"), - ("② Case Folding", "#0284c7", "#eff6ff", "#bfdbfe"), - ("③ Normalisasi", "#16a34a", "#f0fdf4", "#86efac"), - ("④ Tokenizing", "#7c3aed", "#f5f3ff", "#c4b5fd"), - ("⑤ Stopword", "#ea580c", "#fff7ed", "#fed7aa"), - ("⑥ Stemming", "#ca8a04", "#fefce8", "#fde68a"), + ("📄 Teks Asli", "#94a3b8", "#f8fafc", "#e2e8f0"), + ("① Case Folding", "#0284c7", "#eff6ff", "#bfdbfe"), + ("② Cleaning", "#3b6cf7", "#eef2ff", "#c7d2fe"), + ("③ Normalisasi", "#16a34a", "#f0fdf4", "#86efac"), + ("④ Tokenizing", "#7c3aed", "#f5f3ff", "#c4b5fd"), + ("⑤ Stopword", "#ea580c", "#fff7ed", "#fed7aa"), + ("⑥ Stemming", "#ca8a04", "#fefce8", "#fde68a"), ("✅ Teks Bersih", "#0f172a", "#0f172a", "#334155"), ] @@ -637,13 +661,6 @@ def _render_flow_arrow(): # ═══════════════════════════════════════════════════════════ def _render_stat_pills(total_raw, total_clean, avg_tokens_before, avg_tokens_after, removed): - """ - Menampilkan 4 kartu ringkasan hasil preprocessing: - 1. Total tweet yang masuk pipeline - 2. Tweet yang lolos semua tahap (clean_text tidak kosong) - 3. Tweet yang dibuang (clean_text kosong setelah semua tahap) - 4. Rata-rata jumlah token sebelum vs sesudah stopword removal - """ c1, c2, c3, c4 = st.columns(4, gap="medium") cards = [ @@ -685,7 +702,7 @@ def _render_stat_pills(total_raw, total_clean, avg_tokens_before, avg_tokens_aft # ═══════════════════════════════════════════════════════════ -# LIVE EXAMPLE — contoh hasil tiap tahap +# LIVE EXAMPLE # ═══════════════════════════════════════════════════════════ def _render_live_example(df_c): @@ -699,21 +716,21 @@ def _render_live_example(df_c): "Contoh tweet acak dari dataset — refresh halaman untuk contoh berbeda" ) + # Urutan tampilan sesuai pipeline: CF → Clean → Norm → Token → Stop → Stem steps_ex = [ - ("📄 Teks Asli", "text_asli", "#0f172a", "#f8fafc", "#e2e8f0", False), - ("① Setelah Cleaning", "setelah_cleaning", "#1e3a8a", "#eef2ff", "#c7d2fe", False), - ("② Setelah Case Folding","setelah_casefolding", "#0c4a6e", "#eff6ff", "#bfdbfe", False), - ("③ Setelah Normalisasi", "setelah_normalisasi", "#14532d", "#f0fdf4", "#86efac", False), - ("④ Setelah Tokenizing", "setelah_tokenizing", "#3b0764", "#f5f3ff", "#c4b5fd", True), # tampilkan sebagai chip - ("⑤ Setelah Stopword", "setelah_stopword", "#7c2d12", "#fff7ed", "#fed7aa", False), - ("⑥ Hasil Akhir (Stem)", "clean_text", "#713f12", "#fefce8", "#fde68a", False), + ("📄 Teks Asli", "text_asli", "#0f172a", "#f8fafc", "#e2e8f0", False), + ("① Setelah Case Folding", "setelah_casefolding", "#0c4a6e", "#eff6ff", "#bfdbfe", False), + ("② Setelah Cleaning", "setelah_cleaning", "#1e3a8a", "#eef2ff", "#c7d2fe", False), + ("③ Setelah Normalisasi", "setelah_normalisasi", "#14532d", "#f0fdf4", "#86efac", False), + ("④ Setelah Tokenizing", "setelah_tokenizing", "#3b0764", "#f5f3ff", "#c4b5fd", True), + ("⑤ Setelah Stopword", "setelah_stopword", "#7c2d12", "#fff7ed", "#fed7aa", False), + ("⑥ Hasil Akhir (Stem)", "clean_text", "#713f12", "#fefce8", "#fde68a", False), ] for label, col_key, text_color, bg, border, is_token in steps_ex: raw = sample.get(col_key, "-") text_display = str(raw) if raw and str(raw).strip() else "—" - # Untuk tokenizing, tampilkan sebagai chip/badge per kata if is_token and text_display != "—": words = text_display.split(" | ") chips = "".join( @@ -721,8 +738,7 @@ def _render_live_example(df_c): for w in words if w.strip() ) content_html = f'
{chips}
' - word_count = len(words) - char_info = f"{word_count} token" + char_info = f"{len(words)} token" else: content_html = ( f'
@@ -906,7 +912,6 @@ def show(): st.session_state.analysis_mode, ("#3b6cf7", "📊") ) - # ── Active Period Banner ────────────────────────────────── st.markdown( f'
0].copy() - st.session_state[cache_key] = df_c + st.session_state[cache_key] = df_c st.session_state[cache_key + "_stemmer_ok"] = stemmer is not None st.session_state["_pp_last_data_marker"] = data_marker df_c = st.session_state[cache_key] stemmer_ok = st.session_state.get(cache_key + "_stemmer_ok", False) - # ── Hitung statistik ringkasan ──────────────────────────── - # Token sebelum stopword = jumlah kata di tahap tokenizing + # ── Statistik ───────────────────────────────────────────── avg_tok_before = df_c["setelah_tokenizing"].apply( lambda x: len(str(x).split(" | ")) if str(x).strip() else 0 ).mean() - # Token sesudah stopword = jumlah kata di clean_text avg_tok_after = df_c["clean_text"].apply( lambda x: len(str(x).split()) if str(x).strip() else 0 ).mean() removed = len(df) - len(df_c) - # ── Ringkasan ───────────────────────────────────────────── _section_header( "📌 Ringkasan Hasil Preprocessing", f"Berdasarkan tanggal asli tweet · {filter_label}" @@ -1018,11 +1018,10 @@ def show(): _render_stat_pills(len(df), len(df_c), avg_tok_before, avg_tok_after, removed) _gap("lg") - # ── Live example ────────────────────────────────────────── _render_live_example(df_c) _gap("lg") - # ── Tabel perbandingan tahap ────────────────────────────── + # ── Tabel ───────────────────────────────────────────────── _section_header( "📋 Tabel Perbandingan Teks per Tahap", f"{len(df_c):,} tweet · {filter_label} — scroll horizontal untuk lihat semua kolom" @@ -1032,11 +1031,12 @@ def show(): df_c = df_c.copy() df_c["crawled_at"] = pd.NaT + # Kolom ditampilkan sesuai urutan pipeline: CF → Clean → Norm → Token → Stop → Stem disp = df_c[[ "tweet_id", "created_at", "crawled_at", "text_asli", - "setelah_cleaning", "setelah_casefolding", + "setelah_cleaning", "setelah_normalisasi", "setelah_tokenizing", "setelah_stopword", @@ -1046,8 +1046,8 @@ def show(): disp.columns = [ "ID Tweet", "Tanggal Tweet", "Masuk Database", "Teks Asli", - "① Cleaning", - "② Case Folding", + "① Case Folding", + "② Cleaning", "③ Normalisasi", "④ Tokenizing", "⑤ Stopword", @@ -1063,36 +1063,34 @@ def show(): min_width=2200, nowrap=["ID Tweet", "Tanggal Tweet", "Masuk Database"], wide_columns=[ - "Teks Asli", "① Cleaning", "② Case Folding", + "Teks Asli", "① Case Folding", "② Cleaning", "③ Normalisasi", "④ Tokenizing", "⑤ Stopword", "⑥ Hasil Akhir", ], column_widths={ - "ID Tweet": "155px", - "Tanggal Tweet": "165px", - "Masuk Database": "165px", - "Teks Asli": "280px", - "① Cleaning": "240px", - "② Case Folding": "220px", - "③ Normalisasi": "220px", - "④ Tokenizing": "240px", - "⑤ Stopword": "220px", - "⑥ Hasil Akhir": "220px", + "ID Tweet": "155px", + "Tanggal Tweet": "165px", + "Masuk Database": "165px", + "Teks Asli": "280px", + "① Case Folding": "220px", + "② Cleaning": "240px", + "③ Normalisasi": "220px", + "④ Tokenizing": "240px", + "⑤ Stopword": "220px", + "⑥ Hasil Akhir": "220px", }, ) _gap("lg") - # ── Chart panjang teks ──────────────────────────────────── _render_length_comparison_chart(df, df_c) _gap("lg") - # ── Top words ───────────────────────────────────────────── _render_top_words_chart(df_c) _gap("lg") - # ── Simpan ke session state untuk halaman sentimen ──────── + # ── Simpan ke session state ─────────────────────────────── st.session_state["preprocessed_df"] = df_c - # ── Download buttons ────────────────────────────────────── + # ── Download ────────────────────────────────────────────── st.markdown("""
", unsafe_allow_html=True) _gap("sm") - # ── Navigasi ────────────────────────────────────────────── if st.button( "📈 Lanjut ke Analisis Sentimen →", type="primary", diff --git a/page_modules/sentiment_page.py b/page_modules/sentiment_page.py index 7212f7f..c6af26d 100644 --- a/page_modules/sentiment_page.py +++ b/page_modules/sentiment_page.py @@ -1,127 +1,194 @@ -import streamlit as st -import pandas as pd -import numpy as np -import os +""" +sentiment_service.py +==================== +Hybrid Classifier untuk analisis sentimen tweet Bahasa Indonesia. + +PIPELINE PREPROCESSING — 6 TAHAP (selaras dengan preprocessing_page.py): + 1. Case Folding → lowercase dulu sebelum cleaning + 2. Cleaning → hapus URL, mention, hashtag, angka, emoji, tanda baca + 3. Normalisasi → singkatan/slang → kata baku + 4. Tokenizing → split menjadi list token + 5. Stopword Removal → buang kata umum, jaga kata sentimen penting + 6. Stemming → bentuk dasar via Sastrawi ECS + +ARSITEKTUR HYBRID: + Teks Asli + ├── preprocess_untuk_lexicon() ──→ _hitung_skor_lexicon() ──┐ + └── preprocess_for_model() ──→ TF-IDF → NB Model ───────┘ + ↓ + _klasifikasi_hybrid() → Label + Confidence +""" + import re import string -from collections import Counter -from datetime import datetime, timedelta -from html import escape -from database import engine, get_tweet_count, get_latest_crawl_time -from timezone_utils import ( - parse_dt_with_tz, - parse_dt_with_source_tz, - get_timezone_label, - get_timezone_name, -) -import plotly.graph_objects as go import joblib -from page_modules.table_utils import render_standard_table +# ═══════════════════════════════════════════════════════════ +# KATA SENTIMEN PENTING +# Tidak boleh dihapus di tahap stopword removal +# ═══════════════════════════════════════════════════════════ -model = joblib.load("model_naive_bayes.pkl") -tfidf = joblib.load("tfidf_vectorizer.pkl") +KATA_SENTIMEN_PENTING = { + # ── Negasi ────────────────────────────────────────────── + "tidak", "bukan", "jangan", "kurang", "belum", "tanpa", + # ── Sentimen POSITIF ──────────────────────────────────── + "keren", "bagus", "mantap", "setuju", "dukung", "mendukung", + "andal", "handal", "gercep", "bangga", "senang", "suka", + "baik", "benar", "tepat", "oke", + "sejahtera", "berkembang", "maju", "inovatif", + "tegas", "sigap", "tanggap", "adil", "bijak", "bermanfaat", + "untung", "berhasil", "sukses", "solusi", "manfaat", + "berguna", "membantu", "bantu", "pro", "lanjut", + "sangat", "banget", "sekali", "paling", "amat", "luar", "biasa", -# ───────────────────────────────────────────────────────────── -# Timezone & formatting helpers -# ───────────────────────────────────────────────────────────── + # ── Sentimen NEGATIF evaluatif ────────────────────────── + "kecewa", "buruk", "jelek", "parah", "gagal", "hancur", + "rusak", "bohong", "tipu", "korupsi", -def parse_dt(series): - return parse_dt_with_tz(series, st.session_state.get("user_timezone", "WIB (UTC+7)")) - - -def parse_crawled_dt(series): - return parse_dt_with_source_tz( - series, - st.session_state.get("user_timezone", "WIB (UTC+7)"), - os.getenv("APP_TIMEZONE", "Asia/Makassar"), - ) - - -def format_dt(value): - if value is None or pd.isna(value): - return "Belum ada" - try: - tz_label = get_timezone_label(st.session_state.get("user_timezone", "WIB (UTC+7)")) - return f"{value.strftime('%d/%m/%Y %H:%M')} {tz_label}" - except Exception: - return "Belum ada" - - -def format_now(): - now = parse_dt(pd.Series([datetime.utcnow().isoformat()])).iloc[0] - return format_dt(now) - - -def user_today(): - timezone_choice = st.session_state.get("user_timezone", "WIB (UTC+7)") - return pd.Timestamp.now(tz=get_timezone_name(timezone_choice)).date() - - -def _sync_dynamic_period(): - mode = st.session_state.get("analysis_mode") - today = user_today() - configs = { - "realtime": (today - timedelta(days=6), today, "Tweet Terkini — 7 Hari Terakhir"), - "30days": (today - timedelta(days=29), today, "30 Hari Terakhir"), - "captured": (today, today, "Tweet Hari Ini"), - } - if mode not in configs: - return - start_day, end_day, mode_display = configs[mode] - start_day = pd.Timestamp(start_day).date() - end_day = pd.Timestamp(end_day).date() - dt_start = datetime.combine(start_day, datetime.min.time()) - dt_end = datetime.combine(end_day + timedelta(days=1), datetime.min.time()) - st.session_state.filter_start_date = dt_start - st.session_state.filter_end_date = dt_end - st.session_state.filter_label = f"{dt_start.strftime('%d/%m/%Y')} s/d {end_day.strftime('%d/%m/%Y')}" - st.session_state.mode_display = mode_display - st.session_state.filter_date_column = "created_at" - - -# ───────────────────────────────────────────────────────────── -# Preprocessing helpers (same as original) -# ───────────────────────────────────────────────────────────── - -NORMALISASI = { - "gk": "tidak", "ga": "tidak", "gak": "tidak", "nggak": "tidak", - "ngga": "tidak", "tdk": "tidak", "tak": "tidak", "yg": "yang", - "dgn": "dengan", "utk": "untuk", "org": "orang", "krn": "karena", - "dr": "dari", "tp": "tapi", "tpi": "tapi", "sm": "sama", - "jd": "jadi", "sdh": "sudah", "blm": "belum", "emg": "memang", - "emang": "memang", "gimana": "bagaimana", "gitu": "begitu", - "gini": "begini", "bgt": "banget", "ongkir": "ongkos kirim", - "freeongkir": "gratis ongkir", "free": "gratis", "ecommerce": "e commerce", + # ── Emosi ─────────────────────────────────────────────── + "marah", "sedih", "khawatir", } -def _load_stopwords(): +# ═══════════════════════════════════════════════════════════ +# NORMALISASI (selaras dengan preprocessing_page.py) +# ═══════════════════════════════════════════════════════════ + +NORMALISASI = { + # Negasi + "gk": "tidak", "ga": "tidak", "gak": "tidak", + "nggak": "tidak", "ngga": "tidak", "tdk": "tidak", + "tak": "tidak", "enggak": "tidak", "engga": "tidak", + "kagak": "tidak", "kaga": "tidak", "ndak": "tidak", + "gkk": "tidak", "ngak": "tidak", + # Kata ganti + "yg": "yang", "dgn": "dengan", "utk": "untuk", + "org": "orang", "krn": "karena", "dr": "dari", + "sm": "sama", "pd": "pada", "dlm": "dalam", + "bwt": "buat", "trm": "terima", + # Verba + "tp": "tapi", "tpi": "tapi", "jd": "jadi", + "sdh": "sudah", "blm": "belum", "emg": "memang", + "emang": "memang", "gimana": "bagaimana", + "gitu": "begitu", "gini": "begini", + "udah": "sudah", "udh": "sudah", + # Intensitas + "bgt": "banget", "bngt": "banget", "bget": "banget", "bgtt": "banget", + # Positif informal + "bener": "benar", "beneran": "benar", + "mantep": "mantap", "mntap": "mantap", + "kece": "keren", + "cucok": "cocok", "cucuk": "cocok", + "cakep": "bagus", + "sip": "baik", "siipp": "baik", + "top": "terbaik", + "jos": "bagus", "josss": "bagus", + "goks": "luar biasa", + "setujuu": "setuju", "stuju": "setuju", + "proud": "bangga", + "mantul": "mantap betul", + # Negatif informal + "ancur": "hancur", "ancrr": "hancur", + "parahh": "parah", "parahhh": "parah", + "gagall": "gagal", + "ngaco": "tidak benar", + "ngasal": "tidak benar", + "gaje": "tidak jelas", + # Domain + "ongkir": "ongkos kirim", + "freeongkir": "gratis ongkos kirim", + "gratisongkir": "gratis ongkos kirim", + "free": "gratis", + "ecommerce": "e commerce", + "seller": "penjual", + "buyer": "pembeli", +} + + +# ═══════════════════════════════════════════════════════════ +# LEXICON SENTIMEN +# ═══════════════════════════════════════════════════════════ + +LEXICON_POSITIF = { + "bagus", "baik", "keren", "mantap", "mantep", "hebat", + "oke", "sip", "top", "jos", "goks", "kece", "mantul", + "setuju", "dukung", "mendukung", "pro", "lanjut", "sepakat", + "bangga", "senang", "suka", "puas", "gembira", "bahagia", + "berhasil", "sukses", "berjaya", "prestasi", "pencapaian", + "andal", "handal", "gercep", "sigap", "tanggap", "tegas", + "adil", "bijak", "bermanfaat", "berguna", "membantu", + "inovatif", "maju", "berkembang", "sejahtera", + "benar", "tepat", "jelas", "transparan", "amanah", "terpercaya", + "untung", "gratis", "murah", "hemat", "terjangkau", + "terbaik", "luar biasa", + "cakep", "cucok", "gaskeun", "kuy", + "dukung", "bantu", "solusi", "manfaat", + "memuaskan", "membanggakan", "mengagumkan", +} + +LEXICON_NEGATIF = { + "buruk", "jelek", "parah", "rusak", "hancur", "ancur", + "gagal", "gagall", "ambruk", "terpuruk", "bangkrut", + "bohong", "tipu", "curang", "manipulasi", "korupsi", "penipuan", + "kebohongan", + "kecewa", "marah", "sedih", "khawatir", "takut", "benci", + "jijik", "muak", "kesal", "frustrasi", + "mahal", "rugi", "merugikan", + "lambat", "lemot", "ribet", "susah", "sulit", "bermasalah", + "ngaco", "ngasal", "gaje", "receh", + "tidak benar", "tidak jelas", "tidak adil", "tidak berguna", + "mengecewakan", "menyebalkan", "menyusahkan", +} + +KATA_NEGASI = { + "tidak", "bukan", "jangan", "belum", "tanpa", "kurang", + "anti", "non", +} + + +# ═══════════════════════════════════════════════════════════ +# STOPWORDS +# ═══════════════════════════════════════════════════════════ + +def _load_stopwords() -> set: + """Load stopword dengan penjagaan kata sentimen penting.""" stopword_file = "indonesian-stopwords-complete.txt" base = set() try: with open(stopword_file, "r", encoding="utf-8") as f: base = set(f.read().splitlines()) - for kata in ["tidak", "bukan", "jangan", "kurang", "lebih"]: - base.discard(kata) except FileNotFoundError: base = { "yang", "dan", "di", "ke", "dari", "ini", "itu", "dengan", "untuk", "pada", "adalah", "oleh", "ada", - "ya", "akan", "atau", "juga", + "ya", "akan", "atau", "juga", "sama", "karena", + "jika", "sudah", "telah", "saat", "agar", "maka", + "lagi", "bila", "bisa", "pun", "nya", } + + # Jangan hapus kata sentimen penting + for kata in KATA_SENTIMEN_PENTING: + base.discard(kata) + + # Tambahan stopword domain-spesifik base.update({ "rt", "amp", "https", "http", "co", "t", - "wkwk", "wkwkwk", "haha", "hehe", + "wkwk", "wkwkwk", "haha", "hehe", "xixi", "hahaha", "yg", "dgn", "utk", "dr", "krn", "tp", "jd", "sdh", - "aja", "doang", "banget", "bgt", "nih", "sih", "dong", "deh", + "aja", "doang", "nih", "sih", "dong", "deh", + "loh", "lah", "tuh", "kak", "gan", "bro", "sis", }) return base -def _get_stemmer(): +# ═══════════════════════════════════════════════════════════ +# STEMMER +# ═══════════════════════════════════════════════════════════ + +def _load_stemmer(): + """Load Sastrawi stemmer. Return None jika tidak terinstall.""" try: from Sastrawi.Stemmer.StemmerFactory import StemmerFactory return StemmerFactory().create_stemmer() @@ -129,1013 +196,317 @@ def _get_stemmer(): return None -def preprocess(text, stopwords, stemmer): - text = str(text).lower() +# ── Inisialisasi global ───────────────────────────────────────────────────── +_STOPWORDS = _load_stopwords() +_STEMMER = _load_stemmer() + + +# ═══════════════════════════════════════════════════════════ +# MODEL LOADING (lazy) +# ═══════════════════════════════════════════════════════════ + +_model = None +_tfidf = None + + +def _load_model(): + """ + Lazy load NB model + TF-IDF vectorizer. + Return: (model, tfidf) — keduanya bisa None. + """ + global _model, _tfidf + if _model is None: + try: + _model = joblib.load("model_naive_bayes.pkl") + except Exception as e: + print(f"[WARNING] Gagal load model_naive_bayes.pkl: {e}") + if _tfidf is None: + try: + _tfidf = joblib.load("tfidf_vectorizer.pkl") + except Exception as e: + print(f"[WARNING] Gagal load tfidf_vectorizer.pkl: {e}") + return _model, _tfidf + + +# ═══════════════════════════════════════════════════════════ +# PREPROCESSING PIPELINE — 6 TAHAP +# +# URUTAN (selaras dengan preprocessing_page.py): +# 1. Case Folding → lowercase +# 2. Cleaning → hapus noise +# 3. Normalisasi → normalisasi kata +# 4. Tokenizing → split token +# 5. Stopword Removal → buang stopword +# 6. Stemming → bentuk dasar +# ═══════════════════════════════════════════════════════════ + +def _case_folding(text: str) -> str: + """Tahap 1: Lowercase seluruh teks.""" + return str(text).lower() + + +def _cleaning(text: str) -> str: + """Tahap 2: Hapus noise (URL, mention, hashtag, angka, emoji, tanda baca).""" text = re.sub(r"http\S+|www\S+|https\S+", "", text) text = re.sub(r"@\w+", "", text) - text = re.sub(r"#", "", text) + text = re.sub(r"#\w+", "", text) text = re.sub(r"\d+", "", text) + text = re.sub( + r"[\U00010000-\U0010ffff" + r"\U0001F600-\U0001F64F" + r"\U0001F300-\U0001F5FF" + r"\U0001F680-\U0001F6FF" + r"\U0001F1E0-\U0001F1FF" + r"\u2600-\u26FF\u2700-\u27BF" + r"]+", "", text, flags=re.UNICODE, + ) text = text.translate(str.maketrans("", "", string.punctuation)) text = re.sub(r"[^a-zA-Z\s]", "", text) text = re.sub(r"\s+", " ", text).strip() - text = " ".join(NORMALISASI.get(w, w) for w in text.split()) - text = " ".join(w for w in text.split() if w not in stopwords and len(w) > 2) - if stemmer: - text = stemmer.stem(text) return text -def predict_batch(texts): - vectors = tfidf.transform(texts) - preds = model.predict(vectors) - confidences = ( - model.predict_proba(vectors).max(axis=1) - if hasattr(model, "predict_proba") - else np.ones(len(preds)) - ) - final = [] - for pred, conf in zip(preds, confidences): - p = str(pred).lower() - if p == "positif": - sentiment = "Positif" - elif p == "negatif": - sentiment = "Negatif" - elif p == "netral": - sentiment = "Netral" - else: - sentiment = str(pred).capitalize() - final.append((sentiment, float(conf))) - return final +def _normalisasi(text: str) -> str: + """Tahap 3: Normalisasi singkatan dan kata tidak baku.""" + return " ".join(NORMALISASI.get(word, word) for word in text.split()) -# ───────────────────────────────────────────────────────────── -# Shared UI helpers -# ───────────────────────────────────────────────────────────── - -def _section_header(title, subtitle=""): - sub_html = ( - f'
{subtitle}
' - if subtitle else "" - ) - st.markdown( - f'
' - f'
{title}
' - f'{sub_html}
', - unsafe_allow_html=True, - ) +def _tokenize(text: str) -> list: + """Tahap 4: Tokenizing — split ke list token.""" + return text.split() -def _section_gap(size="md"): - heights = {"sm": "1rem", "md": "1.45rem", "lg": "1.9rem"} - st.markdown(f'
', unsafe_allow_html=True) - - -def _render_sentiment_styles(): - st.markdown(""" - -""", unsafe_allow_html=True) - - -# ───────────────────────────────────────────────────────────── -# Page Header -# ───────────────────────────────────────────────────────────── - -def _render_page_header(): - st.markdown(""" -
-
📈
-
-

- Analisis Sentimen

-

- Klasifikasi otomatis tweet menggunakan Naive Bayes — Positif · Netral · Negatif

-
-
🤖 Naive Bayes
-
-""", unsafe_allow_html=True) - - -# ───────────────────────────────────────────────────────────── -# Summary Stat Pills (4 cards) -# ───────────────────────────────────────────────────────────── - -def _render_summary_pills(total, pos_n, neu_n, neg_n, pos_p, neu_p, neg_p, filter_label): - pills = [ - ("stat-1", "📊", "linear-gradient(135deg,#eef2ff,#e0e7ff)", "#3b6cf7", "#1e3a8a", "#c7d2fe", - "Total Dianalisis", f"{total:,}", f"Periode {filter_label}"), - ("stat-2", "😊", "linear-gradient(135deg,#f0fdf4,#dcfce7)", "#16a34a", "#14532d", "#86efac", - "Positif", f"{pos_n:,}", f"{pos_p:.1f}% dari total"), - ("stat-3", "😐", "linear-gradient(135deg,#f8fafc,#f1f5f9)", "#64748b", "#334155", "#cbd5e1", - "Netral", f"{neu_n:,}", f"{neu_p:.1f}% dari total"), - ("stat-4", "😞", "linear-gradient(135deg,#fef2f2,#fee2e2)", "#ef4444", "#7f1d1d", "#fca5a5", - "Negatif", f"{neg_n:,}", f"{neg_p:.1f}% dari total"), +def _remove_stopwords(tokens: list) -> list: + """Tahap 5: Stopword removal dengan penjagaan kata sentimen.""" + return [ + w for w in tokens + if (w not in _STOPWORDS or w in KATA_SENTIMEN_PENTING) and len(w) > 2 ] - cols = st.columns(4, gap="medium") - for col, (anim, icon, bg, color, dark, border, label, val, sub) in zip(cols, pills): - fs = "1.6rem" if len(str(val)) <= 6 else "1.2rem" - with col: - st.markdown( - f'
' - f'
{icon}
' - f'
{label}
' - f'
{val}
' - f'
{sub}
' - f'
', - unsafe_allow_html=True, - ) + +def _stemming(tokens: list) -> list: + """Tahap 6: Stemming ke bentuk dasar via Sastrawi ECS.""" + if _STEMMER is None: + return tokens + return [_STEMMER.stem(w) for w in tokens] -# ───────────────────────────────────────────────────────────── -# Sentiment proportion bar (horizontal stacked) -# ───────────────────────────────────────────────────────────── +def preprocess_for_model(text: str) -> str: + """ + Full 6-tahap preprocessing → string teks bersih siap TF-IDF. -def _render_proportion_bar(pos_p, neu_p, neg_p): - pos_p = round(pos_p, 1) - neu_p = round(neu_p, 1) - neg_p = round(neg_p, 1) + URUTAN: Case Folding → Cleaning → Normalisasi → Tokenizing + → Stopword Removal → Stemming - st.markdown( - f'
' - f'
Proporsi Sentimen Keseluruhan
' - f'
' - f'
' - f'{"😊 " + str(pos_p) + "%" if pos_p >= 8 else ""}
' - f'
' - f'{"😐 " + str(neu_p) + "%" if neu_p >= 8 else ""}
' - f'
' - f'{"😞 " + str(neg_p) + "%" if neg_p >= 8 else ""}
' - f'
' - f'
' - f'● Positif {pos_p}%' - f'● Netral {neu_p}%' - f'● Negatif {neg_p}%' - f'
', - unsafe_allow_html=True, - ) + Pipeline HARUS sama persis dengan yang dipakai saat training model. + """ + s1 = _case_folding(text) # Tahap 1 + s2 = _cleaning(s1) # Tahap 2 + s3 = _normalisasi(s2) # Tahap 3 + s4 = _tokenize(s3) # Tahap 4 + s5 = _remove_stopwords(s4) # Tahap 5 + s6 = _stemming(s5) # Tahap 6 + return " ".join(s6) -# ───────────────────────────────────────────────────────────── -# Donut chart + Bar chart (side by side) -# ───────────────────────────────────────────────────────────── - -def _render_donut_chart(pos_n, neu_n, neg_n, total, filter_label): - labels, values, colors = [], [], [] - color_map = {"Positif": "#16a34a", "Netral": "#94a3b8", "Negatif": "#ef4444"} - for label, val in [("Positif", pos_n), ("Netral", neu_n), ("Negatif", neg_n)]: - if val > 0: - labels.append(label) - values.append(val) - colors.append(color_map[label]) - - dominant = max([("Positif", pos_n), ("Netral", neu_n), ("Negatif", neg_n)], key=lambda x: x[1]) - dom_pct = round(dominant[1] / total * 100) if total > 0 else 0 - dom_emoji = {"Positif": "😊", "Netral": "😐", "Negatif": "😞"}.get(dominant[0], "📊") - - fig = go.Figure(data=[go.Pie( - labels=labels, values=values, hole=0.62, - marker=dict(colors=colors, line=dict(color="white", width=4)), - textinfo="label+percent", textfont=dict(size=12), - hovertemplate="%{label}
%{value:,} tweet — %{percent}", - direction="clockwise", sort=False, - )]) - fig.add_annotation( - text=f"{dom_pct}%
{dom_emoji} {dominant[0]}", - x=0.5, y=0.5, showarrow=False, align="center", font=dict(size=18, color="#0f172a"), - ) - fig.update_layout( - height=290, margin=dict(l=10, r=10, t=10, b=10), - showlegend=True, - legend=dict(orientation="h", y=-0.1, x=0.5, xanchor="center", font=dict(size=11)), - paper_bgcolor="rgba(0,0,0,0)", - ) - st.plotly_chart(fig, width="stretch", config={"displayModeBar": False}) - return dominant +def preprocess_untuk_lexicon(text: str) -> str: + """ + Preprocessing RINGAN untuk lexicon matching. + Tidak di-stem → kata asli bisa dicocokkan dengan lexicon. + Pipeline: Case Folding → Cleaning → Normalisasi saja. + """ + s1 = _case_folding(text) + s2 = _cleaning(s1) + s3 = _normalisasi(s2) + return s3 -def _render_bar_chart(pos_n, neu_n, neg_n, total): - categories = ["Positif 😊", "Netral 😐", "Negatif 😞"] - values = [pos_n, neu_n, neg_n] - colors = ["#16a34a", "#94a3b8", "#ef4444"] - percentages = [(v / total * 100) if total > 0 else 0 for v in values] +# ═══════════════════════════════════════════════════════════ +# LEXICON SCORER +# ═══════════════════════════════════════════════════════════ - fig = go.Figure() - for cat, val, color, pct in zip(categories, values, colors, percentages): - fig.add_trace(go.Bar( - x=[cat], y=[val], - marker=dict(color=color, opacity=0.88, cornerradius=8), - text=[f"{val:,}"], textposition="outside", - textfont=dict(size=13, color="#0f172a"), width=0.5, - hovertemplate=f"{cat}
{val:,} tweet ({pct:.1f}%)", - )) - fig.update_layout( - height=290, margin=dict(l=0, r=0, t=30, b=0), - paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(0,0,0,0)", - showlegend=False, - xaxis=dict(showgrid=False, tickfont=dict(size=11, color="#64748b")), - yaxis=dict(showgrid=True, gridcolor="rgba(226,232,240,0.8)", griddash="dot", - tickfont=dict(size=10, color="#94a3b8")), - ) - st.plotly_chart(fig, width="stretch", config={"displayModeBar": False}) +def _hitung_skor_lexicon(teks_lexicon: str) -> dict: + """ + Hitung skor positif dan negatif dari teks via lexicon. + NEGATION HANDLING: + Kata negasi dalam window 2 kata sebelum kata sentimen → polaritas dibalik. + Contoh: "tidak bagus" → ada "tidak" sebelum "bagus" (POSITIF) + → skor_neg += 1 (bukan skor_pos) -# ───────────────────────────────────────────────────────────── -# Trend line chart -# ───────────────────────────────────────────────────────────── + Return: {"positif": int, "negatif": int, "net": int} + """ + tokens = teks_lexicon.split() + skor_pos = 0 + skor_neg = 0 -def _render_trend_chart(fdf, filter_label, dt_start, dt_end): - _section_header( - "📅 Tren Sentimen dari Hari ke Hari", - f"Periode {filter_label} · Diupdate: {format_now()}" - ) - - df_tl = fdf.copy() - df_tl["date"] = pd.to_datetime(df_tl["created_at"], errors="coerce").dt.date - actual_tl = df_tl.groupby(["date", "sentiment"]).size().reset_index(name="count") - - date_range = pd.date_range(pd.Timestamp(dt_start).date(), pd.Timestamp(dt_end).date(), freq="D") - base_dates = pd.DataFrame({"date": date_range.date}) - - fig = go.Figure() - config_lines = [ - ("Positif", "#16a34a", "rgba(22,163,74,0.08)"), - ("Netral", "#94a3b8", "rgba(148,163,184,0.06)"), - ("Negatif", "#ef4444", "rgba(239,68,68,0.08)"), - ] - for sent, color, fill in config_lines: - data = base_dates.merge( - actual_tl[actual_tl["sentiment"] == sent][["date", "count"]], - on="date", how="left", - ) - data["count"] = data["count"].fillna(0).astype(int) - fig.add_trace(go.Scatter( - x=data["date"], y=data["count"], name=sent, - mode="lines+markers", - line=dict(color=color, width=2.5, shape="spline", smoothing=0.8), - marker=dict(size=6, color="white", line=dict(color=color, width=2.5)), - fill="tozeroy", fillcolor=fill, - hovertemplate=f"{sent}
%{{x}}: %{{y}} tweet", - )) - fig.update_layout( - height=290, margin=dict(l=0, r=0, t=10, b=0), - paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(0,0,0,0)", - legend=dict(orientation="h", y=1.1, x=0, font=dict(size=11)), - xaxis=dict(showgrid=True, gridcolor="rgba(226,232,240,0.6)", - tickfont=dict(size=10, color="#94a3b8")), - yaxis=dict(showgrid=True, gridcolor="rgba(226,232,240,0.6)", griddash="dot", - tickfont=dict(size=10, color="#94a3b8")), - hovermode="x unified", - ) - with st.container(border=True, key="sentiment_trend_panel"): - st.plotly_chart(fig, width="stretch", config={"displayModeBar": False}) - - -# ───────────────────────────────────────────────────────────── -# Confidence distribution histogram -# ───────────────────────────────────────────────────────────── - -def _render_confidence_chart(fdf): - _section_header( - "🎯 Distribusi Keyakinan Model", - "Seberapa yakin model dalam mengklasifikasikan setiap tweet" - ) - - fig = go.Figure() - sent_cfg = [ - ("Positif", "#16a34a", "rgba(22,163,74,0.7)"), - ("Netral", "#94a3b8", "rgba(148,163,184,0.7)"), - ("Negatif", "#ef4444", "rgba(239,68,68,0.7)"), - ] - for sent, color, fill_color in sent_cfg: - sub = fdf[fdf["sentiment"] == sent]["confidence"] - if sub.empty: - continue - fig.add_trace(go.Histogram( - x=sub, name=sent, nbinsx=20, - marker=dict(color=fill_color, line=dict(color=color, width=1)), - opacity=0.85, - hovertemplate=f"{sent}
Keyakinan: %{{x:.0%}}
Jumlah: %{{y}}", - )) - fig.update_layout( - height=260, margin=dict(l=0, r=0, t=10, b=0), barmode="overlay", - paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(0,0,0,0)", - legend=dict(orientation="h", y=1.1, x=0, font=dict(size=11)), - xaxis=dict( - tickformat=".0%", title="Tingkat Keyakinan", - tickfont=dict(size=10, color="#94a3b8"), - showgrid=True, gridcolor="rgba(226,232,240,0.6)", - ), - yaxis=dict( - title="Jumlah Tweet", - tickfont=dict(size=10, color="#94a3b8"), - showgrid=True, gridcolor="rgba(226,232,240,0.6)", griddash="dot", - ), - ) - with st.container(border=True, key="sentiment_conf_panel"): - st.plotly_chart(fig, width="stretch", config={"displayModeBar": False}) - - -# ───────────────────────────────────────────────────────────── -# Word frequency (Top 15 per sentimen, side by side) -# ───────────────────────────────────────────────────────────── - -def _render_word_freq_per_sentiment(fdf): - _section_header( - "📊 Kata Dominan per Sentimen", - "Top 15 kata paling sering muncul di masing-masing kelompok sentimen" - ) - - stop_extra = {"ongkos", "kirim", "gratis", "komdigi", "ongkir"} - sent_cfg = [ - ("Positif", "#16a34a", "#1e3a8a"), - ("Netral", "#64748b", "#334155"), - ("Negatif", "#ef4444", "#7f1d1d"), - ] - - cols = st.columns(3, gap="medium") - for col, (sent, color, dark) in zip(cols, sent_cfg): - with col: - sub = fdf[fdf["sentiment"] == sent] - all_words = " ".join(sub["clean_text"].fillna("")).split() - filtered = [w for w in all_words if len(w) > 2 and w not in stop_extra] - wf = Counter(filtered).most_common(15) - - st.markdown( - f'
' - f'
' - f'
' - f'{"😊" if sent=="Positif" else "😐" if sent=="Netral" else "😞"}
' - f'
{sent}
' - f'
' - f'{len(sub):,} tweet
' - f'
', - unsafe_allow_html=True, - ) - - if not wf: - st.info("Belum ada data") - st.markdown('
', unsafe_allow_html=True) - continue - - words_list = [w[0] for w in wf] - counts_list = [w[1] for w in wf] - max_c = max(counts_list) if counts_list else 1 - - fig = go.Figure(data=[go.Bar( - y=words_list[::-1], x=counts_list[::-1], orientation="h", - marker=dict( - color=[f"rgba({int(color[1:3],16)},{int(color[3:5],16)},{int(color[5:7],16)},{0.35+0.65*(c/max_c):.2f})" - for c in counts_list[::-1]], - line=dict(width=0), cornerradius=4, - ), - text=[str(c) for c in counts_list[::-1]], - textposition="outside", - textfont=dict(size=9, color="#475569"), - hovertemplate="%{y}
%{x} kali", - )]) - fig.update_layout( - height=360, margin=dict(l=0, r=40, t=4, b=4), - paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(0,0,0,0)", - showlegend=False, - xaxis=dict(showgrid=True, gridcolor="rgba(203,213,225,0.6)", - tickfont=dict(size=8, color="#94a3b8"), fixedrange=True), - yaxis=dict(showgrid=False, tickfont=dict(size=9, color="#334155"), fixedrange=True), - ) - st.plotly_chart(fig, width="stretch", config={"displayModeBar": False}) - st.markdown('
', unsafe_allow_html=True) - - -# ───────────────────────────────────────────────────────────── -# Word cloud -# ───────────────────────────────────────────────────────────── - -def _render_wordcloud(fdf): - _section_header( - "☁️ Word Cloud per Sentimen", - "Kata-kata populer dari tweet yang sudah melalui preprocessing" - ) - try: - from wordcloud import WordCloud - import matplotlib.pyplot as plt - - stop_wc = {"ongkos", "kirim", "gratis", "komdigi", "ongkir"} - wc1, wc2, wc3 = st.columns(3, gap="medium") - cfg = [ - (wc1, "Positif", "Greens", "😊 Positif", "#f0fdf4", "#14532d"), - (wc2, "Netral", "Blues", "😐 Netral", "#f8fafc", "#334155"), - (wc3, "Negatif", "Reds", "😞 Negatif", "#fef2f2", "#7f1d1d"), - ] - for col, sent, cmap, title, bg, tc in cfg: - with col: - st.markdown( - f'
' - f'{title}
', - unsafe_allow_html=True, - ) - sub = fdf[fdf["sentiment"] == sent] - words = [w for w in " ".join(sub["clean_text"].fillna("")).split() - if len(w) > 2 and w not in stop_wc] - if words: - wc = WordCloud( - width=420, height=260, background_color="white", - colormap=cmap, max_words=60, - relative_scaling=0.5, collocations=False, - ).generate(" ".join(words)) - fig, ax = plt.subplots(figsize=(5, 3.1)) - ax.imshow(wc, interpolation="bilinear") - ax.axis("off") - plt.tight_layout(pad=0) - st.pyplot(fig, clear_figure=True) - else: - st.info("Data kata tidak cukup") - except ImportError: - st.warning("Install `wordcloud` dan `matplotlib` terlebih dahulu.") - - -# ───────────────────────────────────────────────────────────── -# Tweet table with search / filter / sort -# ───────────────────────────────────────────────────────────── - -def _render_tweet_table(fdf, filter_label): - _section_header( - "📋 Tabel Analisis Sentimen", - f"Total {len(fdf):,} tweet · {filter_label}" - ) - - with st.container(border=True, key="sentiment_table_controls"): - tf1, tf2, tf3 = st.columns([3, 1.6, 1.7], gap="medium", vertical_alignment="bottom") - with tf1: - search = st.text_input( - "Search tweet", placeholder="Ketik kata kunci di isi tweet...", - key="sentiment_table_search", - ) - with tf2: - sf = st.selectbox( - "Filter sentimen", - ["Semua", "Positif 😊", "Netral 😐", "Negatif 😞"], - key="sentiment_table_filter", - ) - with tf3: - sort_by = st.selectbox( - "Urutkan", - ["Terbaru dulu", "Terlama dulu", "Keyakinan tertinggi"], - key="sentiment_table_sort", - ) - - _section_gap("sm") - - tdf = fdf.copy() - if "crawled_at" not in tdf.columns: - tdf["crawled_at"] = pd.NaT - - if search: - tdf = tdf[tdf["text"].str.contains(search, case=False, na=False)] - - sf_map = {"Positif 😊": "Positif", "Netral 😐": "Netral", "Negatif 😞": "Negatif"} - if sf != "Semua": - tdf = tdf[tdf["sentiment"] == sf_map.get(sf, sf)] - - if sort_by == "Terbaru dulu": - tdf = tdf.sort_values("created_at", ascending=False) - elif sort_by == "Terlama dulu": - tdf = tdf.sort_values("created_at", ascending=True) - else: - tdf = tdf.sort_values("confidence", ascending=False) - - out = tdf[["created_at", "crawled_at", "text", "clean_text", "sentiment", "confidence"]].copy() - out["created_at"] = out["created_at"].apply(format_dt) - out["crawled_at"] = out["crawled_at"].apply(format_dt) - out["confidence"] = out["confidence"].apply(lambda x: f"{x:.0%}") - out["sentiment"] = out["sentiment"].map({ - "Positif": "😊 Positif", "Netral": "😐 Netral", "Negatif": "😞 Negatif", - }).fillna(out["sentiment"]) - out.columns = ["Tanggal Tweet", "Masuk Database", "Tweet Asli", "Tweet Bersih", "Sentimen", "Keyakinan"] - - render_standard_table( - out, height=400, min_width=1220, - badge_columns=["Sentimen"], - nowrap=["Tanggal Tweet", "Masuk Database", "Sentimen", "Keyakinan"], - wide_columns=["Tweet Asli", "Tweet Bersih"], - column_widths={ - "Tanggal Tweet": "170px", "Masuk Database": "170px", - "Tweet Asli": "360px", "Tweet Bersih": "360px", - "Sentimen": "130px", "Keyakinan": "110px", - }, - ) - st.caption(f"Menampilkan {len(tdf):,} tweet") - return tdf - - -# ───────────────────────────────────────────────────────────── -# Insight & Recommendation panel -# ───────────────────────────────────────────────────────────── - -def _render_insight_panel(dominant, pos_n, neu_n, neg_n, total, filter_label): - dom_name = dominant[0] - dom_pct = dominant[1] / total * 100 if total else 0 - neg_pct = neg_n / total * 100 if total else 0 - pos_pct = pos_n / total * 100 if total else 0 - neu_pct = neu_n / total * 100 if total else 0 - - _section_header( - "💡 Insight & Rekomendasi Tindakan", - f"Berdasarkan analisis {total:,} tweet · {filter_label}" - ) - - # ── Top insight bar ─────────────────────────────────────── - dom_color = {"Positif": "#16a34a", "Netral": "#64748b", "Negatif": "#ef4444"}.get(dom_name, "#3b6cf7") - dom_bg = {"Positif": "#f0fdf4", "Netral": "#f8fafc", "Negatif": "#fef2f2"}.get(dom_name, "#eef2ff") - dom_emoji = {"Positif": "😊", "Netral": "😐", "Negatif": "😞"}.get(dom_name, "📊") - - st.markdown( - f'
' - f'
' - f'
{dom_emoji}
' - f'
' - f'
Sentimen Dominan
' - f'
' - f'{dom_name} · {dom_pct:.1f}%
' - f'
' - f'
' - f'Positif: {pos_pct:.1f}% · ' - f'Netral: {neu_pct:.1f}% · ' - f'Negatif: {neg_pct:.1f}%' - f'
', - unsafe_allow_html=True, - ) - - # ── Recommendations ─────────────────────────────────────── - rows = [] - if neg_pct >= 40: - rows.append(("🔴 URGENT", "#fef2f2", "#7f1d1d", "#ef4444", - "Tanggapi Keluhan Publik", - "Sentimen negatif tinggi menunjukkan ketidakpuasan signifikan", - "Buat klarifikasi resmi dan buka ruang dialog publik", - "Humas / Tim Kebijakan")) - if neg_pct >= 20: - rows.append(("🟠 TINGGI", "#fff7ed", "#7c2d12", "#ea580c", - "Tinjau Ulang Kebijakan", - f"Sentimen negatif mencapai {neg_pct:.1f}%", - "Evaluasi poin kebijakan yang paling banyak dikeluhkan", - "Tim Kebijakan")) - if neu_pct >= 30: - rows.append(("🟡 SEDANG", "#fefce8", "#713f12", "#ca8a04", - "Tingkatkan Sosialisasi", - f"Sentimen netral {neu_pct:.1f}% — banyak publik belum berpihak", - "Perbanyak konten edukatif dan FAQ resmi", - "Tim Komunikasi")) - if pos_pct >= 40: - rows.append(("🟢 INFO", "#f0fdf4", "#14532d", "#16a34a", - "Pertahankan Momentum Positif", - f"Sentimen positif {pos_pct:.1f}%", - "Perkuat narasi positif via kanal resmi secara konsisten", - "Tim Media Sosial")) - rows.append(("🔵 RUTIN", "#eff6ff", "#1e3a8a", "#3b6cf7", - "Pemantauan Berkelanjutan", - "Opini publik dapat berubah sewaktu-waktu", - "Pantau sentimen harian dan buat laporan berkala", - "Tim Analis Data")) - - df_rek = pd.DataFrame(rows, columns=["Prioritas", "bg", "tc", "bc", - "Tindakan", "Dasar Analisis", - "Rekomendasi", "Penanggung Jawab"]) - - for _, row in df_rek.iterrows(): - st.markdown( - f'
' - f'
' - f'
' - f'{row.Prioritas}
' - f'
' - f'
' - f'{row.Tindakan}
' - f'
' - f'📌 {row["Dasar Analisis"]}
' - f'
' - f'✅ {row.Rekomendasi}
' - f'
' - f'
' - f'' - f'👤 {row["Penanggung Jawab"]}
' - f'
', - unsafe_allow_html=True, + for i, token in enumerate(tokens): + ada_negasi = any( + tokens[i - j] in KATA_NEGASI + for j in range(1, 3) + if i - j >= 0 ) - export_df = df_rek[["Prioritas", "Tindakan", "Dasar Analisis", "Rekomendasi", "Penanggung Jawab"]] - return export_df - - -# ───────────────────────────────────────────────────────────── -# Main show() -# ───────────────────────────────────────────────────────────── - -def show(): - _render_sentiment_styles() - _render_page_header() - - if "analysis_mode" not in st.session_state: - st.warning("⚠️ Silakan pilih mode tampilan di halaman Ambil Data Twitter terlebih dahulu.") - return - - _sync_dynamic_period() - - start_date = st.session_state.get("filter_start_date") - end_date = st.session_state.get("filter_end_date") - filter_label = st.session_state.get("filter_label", "-") - mode_display = st.session_state.get("mode_display", "-") - - if start_date is None or end_date is None: - st.warning("⚠️ Silakan buka halaman Ambil Data Twitter terlebih dahulu.") - return - - mode_meta = { - "realtime": ("#16a34a", "📡"), - "30days": ("#3b6cf7", "📅"), - "captured": ("#0284c7", "📆"), - "custom": ("#d97706", "🔍"), - } - mode_color, mode_icon = mode_meta.get(st.session_state.analysis_mode, ("#3b6cf7", "📊")) - - # ── Active filter banner ────────────────────────────────── - st.markdown( - f'
' - f'{mode_icon}' - f'
' - f'
{mode_display}
' - f'
' - f'Periode: {filter_label}
' - f'
', - unsafe_allow_html=True, - ) - - # ── Load data ───────────────────────────────────────────── - try: - df_all = pd.read_sql("SELECT * FROM tweets ORDER BY created_at DESC", engine) - if df_all.empty: - st.warning("⚠️ Belum ada data tweet.") - return - df_all["created_at"] = parse_dt(df_all["created_at"]) - if "crawled_at" in df_all.columns: - df_all["crawled_at"] = parse_crawled_dt(df_all["crawled_at"]) - except Exception as e: - st.error(f"❌ Gagal membaca database: {e}") - return - - s_dt = pd.Timestamp(start_date) - e_dt = pd.Timestamp(end_date) - df = df_all[(df_all["created_at"] >= s_dt) & (df_all["created_at"] < e_dt)].copy() - - if df.empty: - st.warning(f"⚠️ Tidak ada tweet dengan tanggal asli dalam periode {filter_label}.") - return - - # ── Caching ─────────────────────────────────────────────── - total_tweets_in_db = get_tweet_count() - latest_crawl_marker = get_latest_crawl_time() or "no-crawl" - data_marker = (total_tweets_in_db, latest_crawl_marker) - cache_key = ( - f"sent_{st.session_state.analysis_mode}_" - f"{start_date}_{end_date}_{total_tweets_in_db}_{latest_crawl_marker}" - ) - - for old_key in list(st.session_state.keys()): - if old_key.startswith("sent_") and old_key != cache_key: - del st.session_state[old_key] - - force_refresh = data_marker != st.session_state.get("_last_sentiment_data_marker") - - if cache_key not in st.session_state or force_refresh: - if force_refresh: - st.info("🔄 Menyegarkan prediksi sentimen dengan data terbaru...") - with st.spinner("🔍 Preprocessing & prediksi sentimen seluruh tweet..."): - stopwords = _load_stopwords() - stemmer = _get_stemmer() - df["clean_text"] = df["text"].apply(lambda t: preprocess(t, stopwords, stemmer)) - dfc = df[df["clean_text"].str.strip().str.len() > 0].copy() - results = predict_batch(dfc["clean_text"].tolist()) - if results: - sentiments, confidences = zip(*results) + if token in LEXICON_POSITIF: + if ada_negasi: + skor_neg += 1 else: - sentiments, confidences = [], [] - dfc["sentiment"] = list(sentiments) - dfc["confidence"] = list(confidences) - st.session_state[cache_key] = dfc - st.session_state["_last_sentiment_data_marker"] = data_marker + skor_pos += 1 + elif token in LEXICON_NEGATIF: + if ada_negasi: + skor_pos += 1 + else: + skor_neg += 1 - df_s = st.session_state[cache_key] + return { + "positif": skor_pos, + "negatif": skor_neg, + "net": skor_pos - skor_neg, + } - # ── Keyword filter panel ────────────────────────────────── - _section_header("🎯 Filter Kata Kunci", "Kosongkan untuk melihat semua tweet") - with st.container(border=True, key="sentiment_keyword_panel"): - kw = st.text_input( - "Kata kunci", placeholder="Contoh: ongkir, kurir, komdigi...", - key="sentiment_keyword_search", - ) - st.markdown( - f'
' - f'' - f'Mode {escape(str(mode_display))}' - f'' - f'Periode {escape(str(filter_label))}' - f'
', - unsafe_allow_html=True, - ) +# ═══════════════════════════════════════════════════════════ +# HYBRID CLASSIFIER +# ═══════════════════════════════════════════════════════════ - _section_gap("sm") +# Normalisasi label dari berbagai format yang mungkin dipakai model +_LABEL_MAP = { + "positif": "Positif", "Positif": "Positif", "positive": "Positif", "pos": "Positif", + "negatif": "Negatif", "Negatif": "Negatif", "negative": "Negatif", "neg": "Negatif", + "netral": "Netral", "Netral": "Netral", "neutral": "Netral", "net": "Netral", +} - fdf = ( - df_s[df_s["text"].str.contains(kw, case=False, na=False)].copy() - if kw else df_s.copy() - ) - if fdf.empty: - st.warning("⚠️ Tidak ada tweet yang cocok dengan kata kunci tersebut.") - return +def _prediksi_model(teks_model: str): + """ + Dapatkan prediksi dari NB model. + Return: (label_norm, confidence, proba_dict) atau None. + """ + model, tfidf = _load_model() + if model is None or tfidf is None or not teks_model.strip(): + return None + try: + vec = tfidf.transform([teks_model]) + pred_raw = model.predict(vec)[0] + proba = model.predict_proba(vec)[0] + classes = list(model.classes_) - sc = fdf["sentiment"].value_counts() - total = len(fdf) - pos_n = int(sc.get("Positif", 0)) - neu_n = int(sc.get("Netral", 0)) - neg_n = int(sc.get("Negatif", 0)) - pos_p = pos_n / total * 100 - neu_p = neu_n / total * 100 - neg_p = neg_n / total * 100 + label_norm = _LABEL_MAP.get(str(pred_raw), "Netral") + pred_idx = classes.index(pred_raw) if pred_raw in classes else 0 + confidence = float(proba[pred_idx]) - # ── Summary pills ───────────────────────────────────────── - _section_header( - "📌 Ringkasan Sentimen", - f"Berdasarkan tanggal asli tweet · {filter_label}" - ) - _section_gap("sm") - _render_summary_pills(total, pos_n, neu_n, neg_n, pos_p, neu_p, neg_p, filter_label) - _section_gap("sm") - _render_proportion_bar(pos_p, neu_p, neg_p) - _section_gap("lg") + proba_dict = { + _LABEL_MAP.get(str(cls), str(cls)): float(p) + for cls, p in zip(classes, proba) + } + return label_norm, confidence, proba_dict + except Exception as e: + print(f"[WARNING] Prediksi model gagal: {e}") + return None - # ── Donut + Bar side by side ────────────────────────────── - col_left, col_right = st.columns(2, gap="medium") - with col_left: - _section_header( - "🔵 Sebaran Sentimen", - f"Periode {filter_label} · {format_now()}" - ) - with st.container(border=True, key="sentiment_chart_panel"): - dominant = _render_donut_chart(pos_n, neu_n, neg_n, total, filter_label) - with col_right: - _section_header( - "📊 Perbandingan Jumlah per Sentimen", - f"Periode {filter_label} · {format_now()}" - ) - with st.container(border=True, key="sentiment_bar_panel"): - _render_bar_chart(pos_n, neu_n, neg_n, total) +def _klasifikasi_hybrid( + teks_model: str, + skor: dict, + teks_lower: str, +) -> tuple: + """ + Klasifikasi hybrid: sinyal lexicon + model NB. - _section_gap("lg") + LOGIKA KEPUTUSAN (berurutan): - # ── Trend chart ─────────────────────────────────────────── - _render_trend_chart(fdf, filter_label, start_date, end_date) - _section_gap("lg") + Layer 1 — Override Positif KUAT (net >= 2): + → Positif, confidence 60–92% - # ── Confidence distribution ─────────────────────────────── - _render_confidence_chart(fdf) - _section_gap("lg") + Layer 2 — Override Positif LEMAH (net == 1): + → Cek model; jika model < 65% yakin Negatif → Positif + → Jika model sangat yakin Negatif → tetap Positif (confidence rendah) - # ── Word freq per sentiment ─────────────────────────────── - _render_word_freq_per_sentiment(fdf) - _section_gap("lg") + Layer 3 — Override Negatif KUAT (net <= -2): + → Negatif, confidence 60–90% - # ── Word cloud ──────────────────────────────────────────── - _render_wordcloud(fdf) - _section_gap("lg") + Layer 4 — Fallback Model NB: + → Prediksi model dipakai, TAPI: + • Jika model = Negatif AND net >= 0 AND confidence < 75% + → downgrade ke Netral (koreksi bias model) + • Kasus lainnya → percaya model - # ── Tweet table ─────────────────────────────────────────── - tdf = _render_tweet_table(fdf, filter_label) - _section_gap("lg") + Layer 5 — Ultimate Fallback (model tidak tersedia): + → Gunakan skor lexicon saja - # ── Insight & Recommendations ───────────────────────────── - df_rek = _render_insight_panel(dominant, pos_n, neu_n, neg_n, total, filter_label) - _section_gap("lg") + Return: (label: str, confidence: float) + """ + net = skor["net"] + pos = skor["positif"] - # ── Download section ────────────────────────────────────── - _section_header("📥 Unduh Hasil Analisis") + # ── Layer 1: Positif KUAT ───────────────────────────────────────────────── + if net >= 2: + conf = min(0.60 + (net * 0.07), 0.92) + return ("Positif", round(conf, 3)) - with st.container(border=True, key="sentiment_download_panel"): - d1, d2, d3 = st.columns(3, gap="medium", vertical_alignment="bottom") + # ── Layer 2: Positif LEMAH ──────────────────────────────────────────────── + if net == 1 and pos >= 1: + model_result = _prediksi_model(teks_model) + if model_result is not None: + _, _, proba_dict = model_result + neg_prob = proba_dict.get("Negatif", 0.0) + if neg_prob < 0.65: + conf = round(0.55 + (0.65 - neg_prob) * 0.3, 3) + return ("Positif", min(conf, 0.80)) + else: + return ("Positif", 0.55) + else: + return ("Positif", 0.58) - with d1: - st.download_button( - "📥 Semua Hasil Prediksi", - fdf.to_csv(index=False).encode("utf-8"), - f"hasil_prediksi_{datetime.now().strftime('%Y%m%d_%H%M')}.csv", - "text/csv", width="stretch", - ) - with d2: - summary = pd.DataFrame({ - "Sentimen": ["Positif", "Netral", "Negatif"], - "Jumlah": [pos_n, neu_n, neg_n], - "Persen": [f"{pos_p:.2f}%", f"{neu_p:.2f}%", f"{neg_p:.2f}%"], - }) - st.download_button( - "📈 Ringkasan Sentimen", - summary.to_csv(index=False).encode("utf-8"), - f"ringkasan_{datetime.now().strftime('%Y%m%d_%H%M')}.csv", - "text/csv", width="stretch", - ) - with d3: - st.download_button( - "🎯 Rekomendasi Tindakan", - df_rek.to_csv(index=False).encode("utf-8"), - f"rekomendasi_{datetime.now().strftime('%Y%m%d_%H%M')}.csv", - "text/csv", width="stretch", - ) \ No newline at end of file + # ── Layer 3: Negatif KUAT ───────────────────────────────────────────────── + if net <= -2: + conf = min(0.60 + (abs(net) * 0.06), 0.90) + return ("Negatif", round(conf, 3)) + + # ── Layer 4: Fallback Model NB ──────────────────────────────────────────── + model_result = _prediksi_model(teks_model) + if model_result is not None: + label_norm, confidence, proba_dict = model_result + + # Anti-bias correction: + # Model prediksi Negatif tapi tidak ada sinyal negatif dari lexicon + # dan confidence < 75% → kemungkinan bias → turunkan ke Netral + if label_norm == "Negatif" and net >= 0 and confidence < 0.75: + corrected_conf = round(0.50 + max(0, confidence - 0.50) * 0.2, 3) + return ("Netral", corrected_conf) + + return (label_norm, round(confidence, 3)) + + # ── Layer 5: Ultimate Fallback ──────────────────────────────────────────── + if net > 0: + return ("Positif", 0.55) + elif net < 0: + return ("Negatif", 0.55) + else: + return ("Netral", 0.50) + + +# ═══════════════════════════════════════════════════════════ +# BACKWARD COMPATIBILITY +# ═══════════════════════════════════════════════════════════ + +def bersihkan_teks(text: str) -> str: + """[LEGACY] Gunakan preprocess_for_model() untuk pipeline lengkap.""" + return preprocess_for_model(text) + + +def prediksi_sentimen(list_text: list): + """ + [LEGACY] Prediksi batch dengan hybrid classifier. + Return: (list clean_texts, list labels) + """ + clean_texts = [preprocess_for_model(t) for t in list_text] + labels = [] + for text in list_text: + teks_model = preprocess_for_model(text) + teks_lexicon = preprocess_untuk_lexicon(text) + teks_lower = str(text).lower() + skor = _hitung_skor_lexicon(teks_lexicon) + label, _ = _klasifikasi_hybrid(teks_model, skor, teks_lower) + labels.append(label) + return clean_texts, labels \ No newline at end of file diff --git a/tweets-data/hasil_latest.csv b/tweets-data/hasil_latest.csv new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tweets-data/hasil_latest.csv @@ -0,0 +1 @@ + diff --git a/tweets-data/hasil_latest.old.csv b/tweets-data/hasil_latest.old.csv index 6120b37..d3f5a12 100644 --- a/tweets-data/hasil_latest.old.csv +++ b/tweets-data/hasil_latest.old.csv @@ -1,4 +1 @@ - - - diff --git a/tweets-data/hasil_top.csv b/tweets-data/hasil_top.csv new file mode 100644 index 0000000..d82e1ec --- /dev/null +++ b/tweets-data/hasil_top.csv @@ -0,0 +1,12 @@ +"conversation_id_str","created_at","favorite_count","full_text","id_str","image_url","in_reply_to_screen_name","lang","location","quote_count","reply_count","retweet_count","tweet_url","user_id_str","username" +"2054502530504364380","Wed May 13 10:02:28 +0000 2026","0","GRATIS ONGKIR DIBATASI??? PARAH ","2054502530504364380","","","in","","0","0","0","https://x.com/undefined/status/2054502530504364380","1814143201718247424", +"2054816635131994490","Thu May 14 06:50:37 +0000 2026","0","keren sih ada kebijakan dari komdigi ongkir jadi berasa lebih merataa","2054816635131994490","","","in","","0","0","0","https://x.com/undefined/status/2054816635131994490","1814143201718247424", +"2054839912529350807","Thu May 14 08:23:06 +0000 2026","0","komdigi mending ngurusin lainnya deh dsripada ngurusin ongkir giniii","2054839912529350807","","","in","","0","0","0","https://x.com/undefined/status/2054839912529350807","1814143201718247424", +"2054502757869199625","Wed May 13 10:03:22 +0000 2026","0","Komdigi mengeluarkan kebijakan terkait gratis ongkir.","2054502757869199625","","","in","","0","0","0","https://x.com/undefined/status/2054502757869199625","1814143201718247424", +"2054996678391091685","Thu May 14 18:46:02 +0000 2026","0","komdigi ongkir mending sampingkan dulu","2054996678391091685","","","in","","0","0","0","https://x.com/undefined/status/2054996678391091685","1814143201718247424", +"2055002136027767200","Thu May 14 19:07:43 +0000 2026","0","komdigi ongkir apa gunanyaa??222","2055002136027767200","","","in","","0","0","0","https://x.com/undefined/status/2055002136027767200","2043979880086339584", +"2054998005359751518","Thu May 14 18:51:19 +0000 2026","0","komdigi ongkir apa gunanyaa??","2054998005359751518","","","in","","0","0","0","https://x.com/undefined/status/2054998005359751518","2043979880086339584", +"2055002941288001599","Thu May 14 19:10:55 +0000 2026","0","yakin komdigi bikin kebijakan pembatasan gratis ongkir ini bakal bisa ngimbangin pemerataan umkm??","2055002941288001599","","","in","","0","0","0","https://x.com/undefined/status/2055002941288001599","2043979880086339584", +"2055269612334555468","Fri May 15 12:50:35 +0000 2026","0","komdigi + ongkir = chaos wkwkwk","2055269612334555468","","","in","","0","0","0","https://x.com/undefined/status/2055269612334555468","2043979880086339584", +"2055001478004367487","Thu May 14 19:05:07 +0000 2026","0","maksud dari komdigi bikin kebijakan gratis ongkir ini apa yaaa?","2055001478004367487","","","in","","0","0","0","https://x.com/undefined/status/2055001478004367487","2043979880086339584", +"2055013912555475243","Thu May 14 19:54:31 +0000 2026","0","komdigi haloo?? masa iya ngurusin ongkir dulu dibanding permasalahan sebelah??","2055013912555475243","","","in","","0","0","0","https://x.com/undefined/status/2055013912555475243","2043979880086339584", diff --git a/tweets-data/hasil_top.old.csv b/tweets-data/hasil_top.old.csv index 551a34e..f8b749f 100644 --- a/tweets-data/hasil_top.old.csv +++ b/tweets-data/hasil_top.old.csv @@ -1,42 +1,21 @@ "conversation_id_str","created_at","favorite_count","full_text","id_str","image_url","in_reply_to_screen_name","lang","location","quote_count","reply_count","retweet_count","tweet_url","user_id_str","username" -"2054791169775964388","Thu May 14 05:09:25 +0000 2026","0","kebijakan komdigi gratis ongkir kerenn","2054791169775964388","","","in","","0","0","0","https://x.com/undefined/status/2054791169775964388","1814143201718247424", -"2055002136027767200","Thu May 14 19:07:43 +0000 2026","0","komdigi ongkir apa gunanyaa??222","2055002136027767200","","","in","","0","0","0","https://x.com/undefined/status/2055002136027767200","2043979880086339584", -"2054861268750901484","Thu May 14 09:47:58 +0000 2026","0","komdigi ongkir?","2054861268750901484","","","in","","0","0","0","https://x.com/undefined/status/2054861268750901484","1814143201718247424", -"2054816992662790146","Thu May 14 06:52:02 +0000 2026","0","komdigi??? ongkir??? marketplace???","2054816992662790146","","","in","","0","0","0","https://x.com/undefined/status/2054816992662790146","1814143201718247424", -"2054823109291852155","Thu May 14 07:16:20 +0000 2026","0","komdigi bikin aturan ongkir baru = langkah yang cukup bijak","2054823109291852155","","","in","","0","0","0","https://x.com/undefined/status/2054823109291852155","1814143201718247424", -"2055269612334555468","Fri May 15 12:50:35 +0000 2026","0","komdigi + ongkir = chaos wkwkwk","2055269612334555468","","","in","","0","0","0","https://x.com/undefined/status/2055269612334555468","2043979880086339584", -"2054998005359751518","Thu May 14 18:51:19 +0000 2026","0","komdigi ongkir apa gunanyaa??","2054998005359751518","","","in","","0","0","0","https://x.com/undefined/status/2054998005359751518","2043979880086339584", -"2054816818741862819","Thu May 14 06:51:20 +0000 2026","0","Saya melihat berita tentang komdigi dan ongkir tadi pagi.","2054816818741862819","","","in","","0","0","0","https://x.com/undefined/status/2054816818741862819","1814143201718247424", -"2054502430956769299","Wed May 13 10:02:05 +0000 2026","0","Pembatasan gratis ongkir sangat mengecewakan.","2054502430956769299","","","in","","0","0","0","https://x.com/undefined/status/2054502430956769299","1814143201718247424", -"2054816635131994490","Thu May 14 06:50:37 +0000 2026","0","keren sih ada kebijakan dari komdigi ongkir jadi berasa lebih merataa","2054816635131994490","","","in","","0","0","0","https://x.com/undefined/status/2054816635131994490","1814143201718247424", "2054502269576728775","Wed May 13 10:01:26 +0000 2026","0","Kebijakan komdigi soal ongkir sangat merugikan konsumen.","2054502269576728775","","","in","","0","0","0","https://x.com/undefined/status/2054502269576728775","1814143201718247424", -"2055001478004367487","Thu May 14 19:05:07 +0000 2026","0","maksud dari komdigi bikin kebijakan gratis ongkir ini apa yaaa?","2055001478004367487","","","in","","0","0","0","https://x.com/undefined/status/2055001478004367487","2043979880086339584", -"2054502958315020394","Wed May 13 10:04:10 +0000 2026","0","Komdigi disebut dalam diskusi mengenai free ongkir.","2054502958315020394","","","in","","0","0","0","https://x.com/undefined/status/2054502958315020394","1814143201718247424", -"2054502618484134392","Wed May 13 10:02:49 +0000 2026","0","@komdigi kenapa sih harus ngurusin ongkir segala???","2054502618484134392","","","in","","0","0","0","https://x.com/undefined/status/2054502618484134392","1814143201718247424", -"2054996678391091685","Thu May 14 18:46:02 +0000 2026","0","komdigi ongkir mending sampingkan dulu","2054996678391091685","","","in","","0","0","0","https://x.com/undefined/status/2054996678391091685","1814143201718247424", -"2054502657201733861","Wed May 13 10:02:58 +0000 2026","0","Gratis ongkir dibatasi? keputusan paling aneh tahun ini.","2054502657201733861","","","in","","0","0","0","https://x.com/undefined/status/2054502657201733861","1814143201718247424", -"2054501981369352445","Wed May 13 10:00:17 +0000 2026","0","Gratis ongkir dibatasi malah bikin marketplace lebih fair.","2054501981369352445","","","in","","0","0","0","https://x.com/undefined/status/2054501981369352445","1814143201718247424", -"2054839912529350807","Thu May 14 08:23:06 +0000 2026","0","komdigi mending ngurusin lainnya deh dsripada ngurusin ongkir giniii","2054839912529350807","","","in","","0","0","0","https://x.com/undefined/status/2054839912529350807","1814143201718247424", -"2054502817910706448","Wed May 13 10:03:37 +0000 2026","0","Saya membaca berita tentang gratis ongkir dibatasi.","2054502817910706448","","","in","","0","0","0","https://x.com/undefined/status/2054502817910706448","1814143201718247424", -"2054502131672236531","Wed May 13 10:00:53 +0000 2026","0","kebijakan komdigi tentang ongkir patut diapresiasi.","2054502131672236531","","","in","","0","0","0","https://x.com/undefined/status/2054502131672236531","1814143201718247424", -"conversation_id_str","created_at","favorite_count","full_text","id_str","image_url","in_reply_to_screen_name","lang","location","quote_count","reply_count","retweet_count","tweet_url","user_id_str","username" -"2054791169775964388","Thu May 14 05:09:25 +0000 2026","0","kebijakan komdigi gratis ongkir kerenn","2054791169775964388","","","in","","0","0","0","https://x.com/undefined/status/2054791169775964388","1814143201718247424", -"2055002136027767200","Thu May 14 19:07:43 +0000 2026","0","komdigi ongkir apa gunanyaa??222","2055002136027767200","","","in","","0","0","0","https://x.com/undefined/status/2055002136027767200","2043979880086339584", -"2054861268750901484","Thu May 14 09:47:58 +0000 2026","0","komdigi ongkir?","2054861268750901484","","","in","","0","0","0","https://x.com/undefined/status/2054861268750901484","1814143201718247424", -"2054816992662790146","Thu May 14 06:52:02 +0000 2026","0","komdigi??? ongkir??? marketplace???","2054816992662790146","","","in","","0","0","0","https://x.com/undefined/status/2054816992662790146","1814143201718247424", "2054823109291852155","Thu May 14 07:16:20 +0000 2026","0","komdigi bikin aturan ongkir baru = langkah yang cukup bijak","2054823109291852155","","","in","","0","0","0","https://x.com/undefined/status/2054823109291852155","1814143201718247424", -"2055269612334555468","Fri May 15 12:50:35 +0000 2026","0","komdigi + ongkir = chaos wkwkwk","2055269612334555468","","","in","","0","0","0","https://x.com/undefined/status/2055269612334555468","2043979880086339584", -"2054998005359751518","Thu May 14 18:51:19 +0000 2026","0","komdigi ongkir apa gunanyaa??","2054998005359751518","","","in","","0","0","0","https://x.com/undefined/status/2054998005359751518","2043979880086339584", -"2054816818741862819","Thu May 14 06:51:20 +0000 2026","0","Saya melihat berita tentang komdigi dan ongkir tadi pagi.","2054816818741862819","","","in","","0","0","0","https://x.com/undefined/status/2054816818741862819","1814143201718247424", -"2054502430956769299","Wed May 13 10:02:05 +0000 2026","0","Pembatasan gratis ongkir sangat mengecewakan.","2054502430956769299","","","in","","0","0","0","https://x.com/undefined/status/2054502430956769299","1814143201718247424", -"2054816635131994490","Thu May 14 06:50:37 +0000 2026","0","keren sih ada kebijakan dari komdigi ongkir jadi berasa lebih merataa","2054816635131994490","","","in","","0","0","0","https://x.com/undefined/status/2054816635131994490","1814143201718247424", -"2054502269576728775","Wed May 13 10:01:26 +0000 2026","0","Kebijakan komdigi soal ongkir sangat merugikan konsumen.","2054502269576728775","","","in","","0","0","0","https://x.com/undefined/status/2054502269576728775","1814143201718247424", -"2055001478004367487","Thu May 14 19:05:07 +0000 2026","0","maksud dari komdigi bikin kebijakan gratis ongkir ini apa yaaa?","2055001478004367487","","","in","","0","0","0","https://x.com/undefined/status/2055001478004367487","2043979880086339584", -"2054502958315020394","Wed May 13 10:04:10 +0000 2026","0","Komdigi disebut dalam diskusi mengenai free ongkir.","2054502958315020394","","","in","","0","0","0","https://x.com/undefined/status/2054502958315020394","1814143201718247424", -"2054502618484134392","Wed May 13 10:02:49 +0000 2026","0","@komdigi kenapa sih harus ngurusin ongkir segala???","2054502618484134392","","","in","","0","0","0","https://x.com/undefined/status/2054502618484134392","1814143201718247424", -"2054996678391091685","Thu May 14 18:46:02 +0000 2026","0","komdigi ongkir mending sampingkan dulu","2054996678391091685","","","in","","0","0","0","https://x.com/undefined/status/2054996678391091685","1814143201718247424", -"2054502657201733861","Wed May 13 10:02:58 +0000 2026","0","Gratis ongkir dibatasi? keputusan paling aneh tahun ini.","2054502657201733861","","","in","","0","0","0","https://x.com/undefined/status/2054502657201733861","1814143201718247424", -"2054501981369352445","Wed May 13 10:00:17 +0000 2026","0","Gratis ongkir dibatasi malah bikin marketplace lebih fair.","2054501981369352445","","","in","","0","0","0","https://x.com/undefined/status/2054501981369352445","1814143201718247424", -"2054839912529350807","Thu May 14 08:23:06 +0000 2026","0","komdigi mending ngurusin lainnya deh dsripada ngurusin ongkir giniii","2054839912529350807","","","in","","0","0","0","https://x.com/undefined/status/2054839912529350807","1814143201718247424", -"2054502817910706448","Wed May 13 10:03:37 +0000 2026","0","Saya membaca berita tentang gratis ongkir dibatasi.","2054502817910706448","","","in","","0","0","0","https://x.com/undefined/status/2054502817910706448","1814143201718247424", +"2054502892468662645","Wed May 13 10:03:55 +0000 2026","0","Komdigi dan gratis ongkir sedang ramai dibahas.","2054502892468662645","","","in","","0","0","0","https://x.com/undefined/status/2054502892468662645","1814143201718247424", +"2054501918895202348","Wed May 13 10:00:02 +0000 2026","0","Mantap sih @komdigi akhirnya free ongkir diatur juga ","2054501918895202348","","","in","","0","0","0","https://x.com/undefined/status/2054501918895202348","1814143201718247424", +"2054861268750901484","Thu May 14 09:47:58 +0000 2026","0","komdigi ongkir?","2054861268750901484","","","in","","0","0","0","https://x.com/undefined/status/2054861268750901484","1814143201718247424", "2054502131672236531","Wed May 13 10:00:53 +0000 2026","0","kebijakan komdigi tentang ongkir patut diapresiasi.","2054502131672236531","","","in","","0","0","0","https://x.com/undefined/status/2054502131672236531","1814143201718247424", +"2054478880933855563","Wed May 13 08:28:30 +0000 2026","0","KOMDIGI KNP NGURUSIN ONGKIR SIH??","2054478880933855563","","","in","","0","0","0","https://x.com/undefined/status/2054478880933855563","1814143201718247424", +"2054502618484134392","Wed May 13 10:02:49 +0000 2026","0","@komdigi kenapa sih harus ngurusin ongkir segala???","2054502618484134392","","","in","","0","0","0","https://x.com/undefined/status/2054502618484134392","1814143201718247424", +"2054816992662790146","Thu May 14 06:52:02 +0000 2026","0","komdigi??? ongkir??? marketplace???","2054816992662790146","","","in","","0","0","0","https://x.com/undefined/status/2054816992662790146","1814143201718247424", +"2054502958315020394","Wed May 13 10:04:10 +0000 2026","0","Komdigi disebut dalam diskusi mengenai free ongkir.","2054502958315020394","","","in","","0","0","0","https://x.com/undefined/status/2054502958315020394","1814143201718247424", +"2054501981369352445","Wed May 13 10:00:17 +0000 2026","0","Gratis ongkir dibatasi malah bikin marketplace lebih fair.","2054501981369352445","","","in","","0","0","0","https://x.com/undefined/status/2054501981369352445","1814143201718247424", +"2054502181446078852","Wed May 13 10:01:05 +0000 2026","0","Menurut saya pembatasan gratis ongkir adalah keputusan tepat","2054502181446078852","","","in","","0","0","0","https://x.com/undefined/status/2054502181446078852","1814143201718247424", +"2054816880318427497","Thu May 14 06:51:35 +0000 2026","0","Ada diskusi publik mengenai kebijakan ongkir dari Komdigi.","2054816880318427497","","","in","","0","0","0","https://x.com/undefined/status/2054816880318427497","1814143201718247424", +"2054816818741862819","Thu May 14 06:51:20 +0000 2026","0","Saya melihat berita tentang komdigi dan ongkir tadi pagi.","2054816818741862819","","","in","","0","0","0","https://x.com/undefined/status/2054816818741862819","1814143201718247424", +"2054501862066524547","Wed May 13 09:59:49 +0000 2026","0","Saya setuju kebijakan Komdigi soal gratis ongkir dibatasi biar persaingan lebih sehat.","2054501862066524547","","","in","","0","0","0","https://x.com/undefined/status/2054501862066524547","1814143201718247424", +"2054502430956769299","Wed May 13 10:02:05 +0000 2026","0","Pembatasan gratis ongkir sangat mengecewakan.","2054502430956769299","","","in","","0","0","0","https://x.com/undefined/status/2054502430956769299","1814143201718247424", +"2054502817910706448","Wed May 13 10:03:37 +0000 2026","0","Saya membaca berita tentang gratis ongkir dibatasi.","2054502817910706448","","","in","","0","0","0","https://x.com/undefined/status/2054502817910706448","1814143201718247424", +"2054791169775964388","Thu May 14 05:09:25 +0000 2026","0","kebijakan komdigi gratis ongkir kerenn","2054791169775964388","","","in","","0","0","0","https://x.com/undefined/status/2054791169775964388","1814143201718247424", +"2054502657201733861","Wed May 13 10:02:58 +0000 2026","0","Gratis ongkir dibatasi? keputusan paling aneh tahun ini.","2054502657201733861","","","in","","0","0","0","https://x.com/undefined/status/2054502657201733861","1814143201718247424", +"2054502713959014566","Wed May 13 10:03:12 +0000 2026","0","Gratis ongkir dibatasi = belanja online makin mahal ","2054502713959014566","","","in","","0","0","0","https://x.com/undefined/status/2054502713959014566","1814143201718247424",