diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..20fa2d4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +node_modules/ +dist/ +__pycache__/ +*.pyc +.venv/ +venv/ +.env +.env.* +!.env.example +*.db +*.sqlite +*.sqlite3 +*.csv +web_slearn/src/data/ +web_slearn/public/data/ +api_slearn/model_store_backup_sebelum_perbaikan/ diff --git a/api_slearn/artifacts_slearn/README.txt b/api_slearn/artifacts_slearn/README.txt new file mode 100644 index 0000000..9a81b12 --- /dev/null +++ b/api_slearn/artifacts_slearn/README.txt @@ -0,0 +1,46 @@ +ARTEFAK FINAL ANALISIS SENTIMEN S-LEARN +======================================= + +1. Model yang digunakan: + Logistic Regression. + +2. Ekstraksi fitur: + TF-IDF yang sudah di-fit pada data training penelitian. + +3. Website TIDAK BOLEH melakukan: + - model.fit() + - tfidf.fit() + - tfidf.fit_transform() + +4. Untuk data baru: + preprocessing_slearn.preprocessing_kalimat_baru(teks) + -> tfidf.transform() + -> model.predict() + +5. Confidence: + Gunakan model.predict_proba(). + + Probabilitas kelas 0 = Negatif. + Probabilitas kelas 1 = Positif. + + Confidence adalah probabilitas kelas + yang dipilih sebagai hasil prediksi. + + Confidence BUKAN akurasi dan bukan + jaminan bahwa prediksi pasti benar. + +6. Untuk analisis CSV: + Seluruh komentar menggunakan preprocessing + yang sama, kemudian tfidf.transform(), + model.predict(), dan model.predict_proba(). + +7. Jangan melakukan training ulang pada website. + +8. Gunakan versi library pada requirements.txt. + +9. Siapkan resource NLTK: + - punkt + - punkt_tab + +10. preprocessing_config.json merupakan + konfigurasi preprocessing final penelitian. diff --git a/api_slearn/artifacts_slearn/kamus_normalisasi.joblib b/api_slearn/artifacts_slearn/kamus_normalisasi.joblib new file mode 100644 index 0000000..634512f Binary files /dev/null and b/api_slearn/artifacts_slearn/kamus_normalisasi.joblib differ diff --git a/api_slearn/artifacts_slearn/metadata_model.json b/api_slearn/artifacts_slearn/metadata_model.json new file mode 100644 index 0000000..ea905b8 --- /dev/null +++ b/api_slearn/artifacts_slearn/metadata_model.json @@ -0,0 +1,145 @@ +{ + "nama_model": "Logistic Regression", + "total_data": 571, + "jumlah_sentimen_negatif": 379, + "jumlah_sentimen_positif": 192, + "jumlah_data_training": 456, + "jumlah_data_testing": 115, + "jumlah_fitur_tfidf": 675, + "akurasi": 0.9304347826086956, + "label_mapping": { + "0": "NEGATIF", + "1": "POSITIF" + }, + "confusion_matrix": [ + [ + 70, + 6 + ], + [ + 2, + 37 + ] + ], + "classification_report": { + "0": { + "precision": 0.9722222222222222, + "recall": 0.9210526315789473, + "f1-score": 0.9459459459459459, + "support": 76.0 + }, + "1": { + "precision": 0.8604651162790697, + "recall": 0.9487179487179487, + "f1-score": 0.9024390243902439, + "support": 39.0 + }, + "accuracy": 0.9304347826086956, + "macro avg": { + "precision": 0.9163436692506459, + "recall": 0.934885290148448, + "f1-score": 0.9241924851680949, + "support": 115.0 + }, + "weighted avg": { + "precision": 0.9343219862936748, + "recall": 0.9304347826086956, + "f1-score": 0.9311914247227079, + "support": 115.0 + } + }, + "split": { + "test_size": 0.2, + "random_state": 42, + "stratify": "label" + }, + "preprocessing_order": [ + "cleaning", + "case_folding", + "normalisasi", + "koreksi_normalisasi_validator", + "tokenisasi", + "hapus_stopword", + "stemming" + ], + "tfidf_parameters": { + "analyzer": "'word'", + "binary": "False", + "decode_error": "'strict'", + "dtype": "", + "encoding": "'utf-8'", + "input": "'content'", + "lowercase": "True", + "max_df": "1.0", + "max_features": "None", + "min_df": "1", + "ngram_range": "(1, 1)", + "norm": "None", + "preprocessor": "None", + "smooth_idf": "True", + "stop_words": "None", + "strip_accents": "None", + "sublinear_tf": "False", + "token_pattern": "'(?u)\\\\b\\\\w\\\\w+\\\\b'", + "tokenizer": "None", + "use_idf": "True", + "vocabulary": "None" + }, + "logistic_regression_parameters": { + "C": 1.0, + "class_weight": null, + "dual": false, + "fit_intercept": true, + "intercept_scaling": 1, + "l1_ratio": null, + "max_iter": 1000, + "multi_class": "deprecated", + "n_jobs": null, + "penalty": "l2", + "random_state": null, + "solver": "lbfgs", + "tol": 0.0001, + "verbose": 0, + "warm_start": false + }, + "confidence": { + "tersedia": true, + "sumber": "LogisticRegression.predict_proba", + "definisi": "Confidence adalah probabilitas pada kelas yang dipilih sebagai hasil prediksi model.", + "bukan": "Confidence bukan tingkat akurasi atau jaminan prediksi benar.", + "kelas": { + "0": "Probabilitas Negatif", + "1": "Probabilitas Positif" + } + }, + "website": { + "training_ulang": false, + "tfidf_fit_ulang": false, + "gunakan_tfidf_transform": true, + "mendukung_prediksi_batch_csv": true, + "mendukung_predict_proba": true + }, + "python_version": "3.13.16", + "library_versions": { + "scikit-learn": "1.6.1", + "numpy": "2.1.3", + "scipy": "1.16.3", + "pandas": "2.2.3", + "nltk": "3.9.1", + "Sastrawi": "1.0.1", + "joblib": "1.6.0" + }, + "dibuat_pada_utc": "2026-10-08T23:04:35.996842+00:00", + "kamus_sumber_sha256": "ff82815802208fefc93f5ec2582b8b9d0137efb85719da559a6bd4be06f2638f", + "sha256_files": { + "requirements.txt": "669c113634d1c30c04e3a859d2cdfe4da162c1b6926d7248e44bab29608d3adb", + "README.txt": "95a299ee72388f64f2002c91536d770b090aa414b4d771b13d4b8bae2954a0ef", + "stopwords_final.joblib": "86fde7096c19c3e139e3afaecc97d558211001520ee2daef6c72a60df7d58bb0", + "preprocessing_slearn.py": "6797aef8198520720fe8db837346c97304aee2bc1f3870bbf4ac1fdb8b77ec44", + "kamus_alay_sumber.csv": "ff82815802208fefc93f5ec2582b8b9d0137efb85719da559a6bd4be06f2638f", + "tfidf_vectorizer.joblib": "59669b82757eca691cd48d33d39dd52c2ede5bc17724b882bd242782bdbca1d1", + "preprocessing_config.json": "704fa2d507aa2fc9ed5382dc675210dca80c816af921bd16d8ae9b4d4ba18ad0", + "kamus_normalisasi.joblib": "60b7e79bab04401cbd3cbbf2b0920512a37f6e5066aee729710157d16e1203e2", + "model_logistic_regression.joblib": "a7813f5b31ed2aa5e5b413a38bed0ec76e75dde714af93a0f7d1b9741c47f1fc" + } +} \ No newline at end of file diff --git a/api_slearn/artifacts_slearn/model_logistic_regression.joblib b/api_slearn/artifacts_slearn/model_logistic_regression.joblib new file mode 100644 index 0000000..62aefd4 Binary files /dev/null and b/api_slearn/artifacts_slearn/model_logistic_regression.joblib differ diff --git a/api_slearn/artifacts_slearn/preprocessing_config.json b/api_slearn/artifacts_slearn/preprocessing_config.json new file mode 100644 index 0000000..69dd0de --- /dev/null +++ b/api_slearn/artifacts_slearn/preprocessing_config.json @@ -0,0 +1,5419 @@ +{ + "kamus_normalisasi": { + "woww": "wow", + "aminn": "amin", + "met": "selamat", + "netaas": "menetas", + "keberpa": "keberapa", + "eeeehhhh": "eh", + "kata2nyaaa": "kata-katanya", + "hallo": "halo", + "kaka": "kakak", + "ka": "kak", + "daah": "dah", + "aaaaahhhh": "ah", + "yaa": "ya", + "smga": "semoga", + "slalu": "selalu", + "amiin": "amin", + "kk": "kakak", + "trus": "terus", + "sii": "sih", + "nyenengin": "menyenangkan", + "bgt": "banget", + "gemess": "gemas", + "akuuu": "aku", + "jgn": "jangan", + "udah": "sudah", + "gitu": "begitu", + "aja": "saja", + "gemesiin": "menggemaskan", + "menyenangkn": "menyenangkan", + "rb": "ribu", + "akau": "aku", + "saranin": "menyarankan", + "nemuin": "menemukan", + "yah": "ya", + "mnis2nya": "manis-manisnya", + "gtu": "begitu", + "tu": "itu", + "idolaa": "idola", + "bangett": "banget", + "kaakk": "kak", + "baaiikkk": "baik", + "telaatt": "telat", + "kaak": "kak", + "yg": "yang", + "lg": "lagi", + "skarang": "sekarang", + "sempet": "sempat", + "karna": "karena", + "ngga": "tidak", + "mantep": "mantap", + "loohhh": "loh", + "yeee": "ya", + "baguuuuus": "bagus", + "sampe": "sampai", + "ngikutin": "mengikuti", + "gayany": "gayanya", + "bnk": "bang", + "awaas": "awas", + "siip": "sip", + "abizzzz": "habis", + "kerrrrennnn": "keren", + "sayaa": "saya", + "pengen": "ingin", + "kpan": "kapan", + "sma": "sama", + "sdkit": "sedikit", + "bngt": "banget", + "lgu": "lagu", + "bener2": "benar-benar", + "kharismanya": "karismanya", + "aq": "aku", + "jdi": "jadi", + "lgi": "lagi", + "gk": "tidak", + "kek": "seperti", + "lgunya": "lagunya", + "kereeeeennnn": "keren", + "nyamain": "menyamakan", + "lakik": "laki", + "siippp": "sip", + "bkn": "bukan", + "nyangka": "menyangka", + "bangettt": "banget", + "kereeen": "keren", + "wihhhhhh": "wih", + "tp": "tapi", + "inget": "ingat", + "bingitsss": "banget", + "dmn": "dimana", + "bird": "biru", + "kaloo": "kalo", + "gimna": "bagaimana", + "tuuh": "tuh", + "nyebur": "mencebur", + "yng": "yang", + "dbuat": "dibuat", + "kn": "kan", + "org": "orang", + "pingin": "pengin", + "bangcat": "bangsat", + "yha": "ya", + "waoohhh": "wah", + "pngn": "pengin", + "tmpt": "tempat", + "dhe": "deh", + "indahhh": "indah", + "bgttt": "banget", + "tuhh": "tuh", + "berarto": "berarti", + "ga": "tidak", + "aamiin": "amin", + "amiiinn": "amin", + "dgn": "dengan", + "aminnnn2": "amin-amin", + "amiiiin": "amin", + "aminnn": "amin", + "amiend": "amien", + "yaaa": "ya", + "ameen": "amin", + "aminnnn": "amin", + "amiiinnn": "amin", + "ngomong": "mengomong", + "mamer": "memamerkan", + "keles": "kali", + "jelase": "jelas", + "liat": "lihat", + "uwakk": "uwak", + "ntar": "entar", + "bs": "bisa", + "gini": "begini", + "salfok": "salah fokus", + "njir": "anjir", + "piiink": "pink", + "astgfrullah": "astagfirullah", + "ko": "kok", + "cd": "celana dalam", + "gbs": "enggak bisa", + "bhs": "bahasa", + "yaaaaaa": "ya", + "gak": "tidak", + "bangshad": "bangsat", + "stagahhhh": "astaga", + "kpn": "kapan", + "iooo": "lo", + "dy": "dia", + "d": "di", + "mslh": "masalah", + "qt": "kita", + "plampiasannn": "pelampiasan", + "masalahh": "masalah", + "tohh": "toh", + "inghinn": "ingin", + "dhi": "di", + "mengherthii": "mengerti", + "yanckh": "yang", + "chelalyyu": "selalu", + "therchakitty": "tersakiti", + "mauuuuu": "mau", + "kaliii": "kali", + "bangat": "banget", + "bkin": "bikin", + "dongg": "dong", + "yokkk": "yuk", + "yu": "yuk", + "sweger": "segar", + "gantengg": "ganteng", + "bingiit": "banget", + "kebukaaa": "terbuka", + "tpi": "tetapi", + "keliatan": "terlihat", + "yakk": "ya", + "kayakk": "kayak", + "asliii": "asli", + "kyk": "seperti", + "cntiknya": "cantiknya", + "bat": "banget", + "ngeliatnya": "melihatnya", + "sll": "selalu", + "bet": "banget", + "njerr": "anjir", + "bingiiittttzzzz": "banget", + "k": "ke", + "klo": "kalau", + "dooong": "dong", + "masi": "masih", + "gni": "begini", + "hdk": "hendak", + "jugaa": "juga", + "mksud": "maksud", + "aj": "saja", + "tetep": "tetap", + "mbakx": "mbaknya", + "syantik": "cantik", + "kite": "kita", + "pa": "apa", + "mksd": "maksud", + "bejilbbnya": "berjilbabnya", + "masio": "masih", + "yahhh": "ya", + "makasih": "terima kasih", + "leh": "boleh", + "ugaaa": "juga", + "jerewi": "jerawat", + "ilang": "hilang", + "jilbabnyaah": "jilbabnya", + "yuuuk": "yuk", + "warnaaa": "warna", + "punyaaa": "punya", + "cantiik": "cantik", + "sukaaak": "suka", + "kebayanyaa": "kebayanya", + "mantes": "memantas", + "merekaaaa": "mereka", + "jugaaa": "juga", + "gilaaaa": "gila", + "tiga2nya": "tiga-tiganya", + "lucuu": "lucu", + "emessss": "gemas", + "ucullllll": "lucu", + "males": "malas", + "bangeettt": "banget", + "gemesshh": "gemas", + "apaaa": "apa", + "ampuunn": "ampun", + "ciuuuummmm": "cium", + "mbil": "sambil", + "ngmng": "mengomong", + "ngemeng": "mengomong", + "epe": "apa", + "seehh": "sih", + "yank": "sayang", + "ucul": "lucu", + "yahh": "ya", + "gw": "saya", + "ngakak": "mengakak", + "astagfirrullah": "astagfirullah", + "suerrr": "suer", + "mnta": "minta", + "syg": "sayang", + "ntik": "cantik", + "pny": "punya", + "dapet": "dapat", + "suru": "suruh", + "lucuuuu": "lucu", + "ketawakkkk": "ketawa", + "kyanya": "kayanya", + "bnget": "banget", + "nakutin": "menakutkan", + "malingg": "maling", + "cocokk": "cocok", + "niihh": "nih", + "imutttttt": "imut", + "satuuuuu": "satu", + "sesekk": "sesak", + "nafass": "nafas", + "bgtt": "banget", + "cintaaaaaaa": "cinta", + "lucuk": "lucu", + "ma": "sama", + "duluu": "dulu", + "megang": "memegang", + "kawinin": "mengawini", + "liatin": "perlihatkan", + "ny": "nya", + "bdan": "badan", + "nyaaa": "nya", + "gemesin": "menggemaskan", + "fotoin": "memfoto", + "ngaku": "mengaku", + "kaaa": "kak", + "ganteng2": "ganteng-ganteng", + "pcarny": "pacarnya", + "kri": "kiri", + "knpa": "kenapa", + "pda": "pada", + "cmn": "cuman", + "gara2": "gara-gara", + "garuk2": "garuk-garuk", + "tembokk": "tembok", + "namax": "namanya", + "kiriii": "kiri", + "bungkusin": "bungkuskan", + "atu": "satu", + "dehhhh": "deh", + "kakk": "kak", + "ngegantiin": "menggantikan", + "alloh": "allah", + "smpe": "sampai", + "msh": "masih", + "trsa": "terasa", + "khadirannya": "kehadirannya", + "fikiran": "pikiran", + "hayalanku": "khayalanku", + "bru": "baru", + "nton": "menonton", + "ngapain": "mengapai", + "surgaa": "surga", + "kangenn": "kangen", + "kgn": "kangen", + "kakkkkk": "kak", + "rinduuuuuuuuu": "rindu", + "ninggalin": "meninggalkan", + "smg": "semoga", + "terangkhn": "terangkan", + "bgtttt": "banget", + "knp": "kenapa", + "tuk": "untuk", + "slama": "selama", + "orng": "orang", + "bngunlah": "bangunlah", + "bngun": "bangun", + "ea": "ya", + "qu": "ku", + "hiri": "hari", + "teman\"ny": "teman-temannya", + "aza": "saja", + "dr": "dari", + "alm": "almarhum", + "skrg": "sekarang", + "sie": "sih", + "benet": "benar", + "bangetttt": "banget", + "tuu": "tuh", + "skrang": "sekarang", + "gda": "enggak ada", + "gantiin": "menggantikan", + "kta": "kita", + "kklau": "kalau", + "acra": "acara", + "kasian": "kasihan", + "yaaa ": "ya", + "bnerr": "benar", + "emk": "emak", + "temenn": "teman", + "pngenn": "pengin", + "terkenall": "terkenal", + "ajhh": "saja", + "sungguh2": "sungguh-sungguh", + "mnjadi": "menjadi", + "shbt": "sahabat", + "bner": "benar", + "ktny": "katanya", + "dimusiumin": "dimuseumkan", + "bntu": "bantu", + "dlu": "dulu", + "udh": "sudah", + "kangem": "kangen", + "amiiin": "amin", + "smua": "semua", + "slmt": "selamat", + "jln": "jalan", + "mudh\"an": "mudah-mudahan", + "tenny": "tenang", + "sdh": "sudah", + "tnya": "tanya", + "blm": "belum", + "td": "tadi", + "sblm": "sebelum", + "bole": "boleh", + "lahhh": "lah", + "maap": "maaf", + "cm": "cuma", + "kmrn": "kemarin", + "wkt": "waktu", + "ama": "sama", + "ak": "aku", + "lht": "lihat", + "biasa2": "biasa-biasa", + "mnrtkuu": "menurutku", + "trs": "terus", + "ikutin": "mengikuti", + "jdwl": "jadwal", + "wajiiib": "wajib", + "aaaammmiiiinnnn": "amin", + "dtnggu": "ditunggu", + "jan": "jangan", + "kunjungin": "kunjungi", + "lagiiii": "lagi", + "waktunyaaa": "waktunya", + "diluangin": "diluangkan", + "jga": "juga", + "mimin": "admin", + "istimewaaa": "istimewa", + "blom": "belum", + "nangis": "menangis", + "ngaku\"": "mengaku-ngaku", + "diaa": "dia", + "donkk": "dong", + "kejawab": "terjawab", + "yaaaa": "ya", + "seru2an": "seru-seruan", + "mohom": "mohon", + "kebalas": "terbalas", + "nyempetin": "menyempatkan", + "wktunya": "waktunya", + "kaaaa": "kak", + "ngobrol": "mengobrol", + "namain": "namai", + "bls": "balas", + "ijap": "ijab", + "kobul": "kabul", + "co": "cowok", + "pst": "pasti", + "inggat": "ingat", + "brdua": "berdua", + "masing2": "masing-masing", + "sanggat": "sangat", + "siipp": "sip", + "laa": "lah", + "dlm": "dalam", + "hdup": "hidup", + "hdp": "hidup", + "kedpan": "kedepan", + "smoga": "semoga", + "mndaptkan": "mendapatkan", + "nutup\"in": "menutup-nutupi", + "iyah": "iya", + "sja": "saja", + "kakkk": "kak", + "za": "saja", + "mulut2": "mulut-mulut", + "peengn": "pengin", + "tuch": "tuh", + "muantteepp": "mantap", + "cpt": "cepat", + "mrit": "merit", + "y": "ya", + "ngapasih": "mengapa sih", + "ni": "nih", + "segitunya": "sebegitunya", + "fto": "foto", + "pke": "pakai", + "sisain": "menyisakan", + "slamt": "selamat", + "nantiii": "nanti", + "edaaan": "edan", + "emg": "memang", + "tunggu2": "tunggu-tunggu", + "sehat2": "sehat-sehat", + "ciyeeeeeee": "cie", + "pantes": "pantas", + "lairannya": "kelahirannya", + "jmnya": "jamnya", + "ajha": "saja", + "nyaaaa": "nya", + "nyari": "mencari", + "amiien": "amien", + "terbaikkk": "terbaik", + "ayahh": "ayah", + "ae": "saja", + "ndak": "indak", + "ngerti": "mengerti", + "mrekaaaa": "mereka", + "yaampuuuunnnn": "ya ampun", + "nyanyiin": "menyanyikan", + "ahhh": "ah", + "pgen": "pengin", + "tempt": "tempat", + "kh": "kah", + "bgini": "begini", + "brani": "berani", + "km": "kamu", + "jalan2": "jalan-jalan", + "asyikkkkk": "asyik", + "iriiiii": "iri", + "ngajak": "mengajak", + "naaaaahh": "nah", + "kka": "kakak", + "yak": "ya", + "ttep": "tetap", + "sperti": "seperti", + "jrang": "jarang", + "msuk": "masuk", + "gossip2.an": "gosip-gosipan", + "makkk": "mak", + "guee": "gue", + "pengennn": "pengin", + "gilzz": "gila", + "nyaru": "menyaru", + "gilaaa": "gila", + "jd": "jadi", + "kebarat2an": "kebarat-baratan", + "ohh": "oh", + "mantabs": "mantab", + "jgan": "jangan", + "mlu": "melulu", + "payahh": "payah", + "ntaf": "mantap", + "beneran": "benar", + "mudah\"n": "mudah-mudahan", + "gada": "enggak ada", + "milikin": "memiliki", + "kamo": "kamu", + "inih": "ini", + "sukaaaa": "suka", + "sukaaaaaa": "suka", + "kirain": "mengira", + "mehoonggggg": "mahal", + "apikkkk": "apik", + "yach": "ya", + "kereenn": "keren", + "ginii": "begini", + "ayoook": "ayo", + "yoiii": "yoi", + "gituuu": "begitu", + "iyaaa": "iya", + "pgn": "pengin", + "liptsk": "lipstik", + "nyeplak": "menjiplak", + "cw": "cewek", + "kmarin": "kemarin", + "mengncang": "mengencang", + "tauu": "tau", + "ksh": "kasih", + "skrng": "sekarang", + "mksh": "terima kasih", + "ank": "anak", + "skitar": "sekitar", + "ktmu": "ketemu", + "tmen": "teman", + "ats": "atas", + "donk": "dong", + "heii": "hai", + "diputusin": "diputuskan", + "pcrnya": "pacarnya", + "mnjual": "menjual", + "ngadain": "mengadakan", + "kkak": "kakak", + "rekomendasiin": "rekomendasikan", + "sihhh": "sih", + "cooocok": "cocok", + "mrip": "mirip", + "mama.nyy": "mamanya", + "bosenin": "membosankan", + "kmu": "kamu", + "bukn": "bukan", + "sey": "sih", + "cantikkk": "cantik", + "banger": "banget", + "kaa": "kak", + "eakyu": "waktu", + "cntik": "cantik", + "kakanya": "kakaknya", + "doeloe": "dulu", + "cantikkkk": "cantik", + "bngtt": "banget", + "iyak": "iya", + "malh": "malah", + "mlah": "malah", + "ngak": "tidak", + "laki2": "laki-laki", + "gag": "tidak", + "fhotonya": "fotonya", + "bleh": "boleh", + "brp": "berapa", + "tnggl": "tanggal", + "tanteny": "tantenya", + "ky": "kaya", + "sklinyaa": "sekalinya", + "ayoo": "ayo", + "w": "gue", + "dramaa": "drama", + "niiih": "nih", + "bisaa": "bisa", + "lha": "lah", + "dunk": "dong", + "lihaat": "lihat", + "yuwkkk": "yuk", + "nnti": "nanti", + "editannn": "editan", + "mw": "mau", + "thu": "itu", + "mai": "mau", + "kamuu": "kamu", + "pastii": "pasti", + "bisaaa": "bisa", + "kejuu": "keju", + "kereen": "keren", + "nama.a": "namanya", + "kmauan": "kemauan", + "ahhhh": "ah", + "yuuuuk": "yuk", + "dmana": "dimana", + "disiniii": "disini", + "cobaa": "coba", + "bayangin": "bayangkan", + "yaudh": "ya sudah", + "dl": "dulu", + "ajaaa": "saja", + "tuhhh": "tuh", + "amploppp": "amplop", + "mo": "mau", + "sayangg''": "sayang-sayang", + "nantii": "nanti", + "aminnnnn": "amin", + "gakuat": "enggak kuat", + "yukkkk": "yuk", + "nged": "banget", + "nantik": "nanti", + "ksitu": "kesitu", + "bangeett": "banget", + "liad": "lihat", + "kluarga": "keluarga", + "biasahhh": "biasa", + "tutorialx": "tutorialnya", + "dpke": "dipakai", + "fhoto": "foto", + "kerenn": "keren", + "binggo": "banget", + "rekomndd": "rekomendasi", + "bli2n": "beli-belikan", + "bwt": "buat", + "wado": "waduh", + "guaa": "gua", + "yaakkk": "ya", + "bangetttttyt": "banget", + "bangettttt": "banget", + "bangg": "bang", + "kangeeeeeeeeennnnnnnn": "kangen", + "kaliannnnnn": "kalian", + "rinduuuuuuuuuu": "rindu", + "kangennnnnmnnnnnnnnnnnnnnnnn": "kangen", + "kaliannnnnm": "kalian", + "kangeeeeeeeeeeennnnn": "kangen", + "kangennnnnnnnnnnnnnnn": "kangen", + "kangennn": "kangen", + "kaliannnnnnn": "kalian", + "th2": "tahun-tahun", + "smpt": "sempat", + "blg": "bilang", + "trserah": "terserah", + "ad": "ada", + "cwo": "cowok", + "prnah": "pernah", + "wlwpun": "walaupun", + "trpsah": "terpisah", + "klw": "kalau", + "mmg": "memang", + "prsatukn": "persatukan", + "g": "tidak", + "slh": "salah", + "ngomng": "mengomong", + "mdh2an": "mudah-mudahan", + "sm": "sama", + "skedar": "sekadar", + "kangeeerrn": "kangen", + "pulak": "pula", + "terrrrbaik": "terbaik", + "jorookknya": "joroknya", + "ihhhhh": "ih", + "padahall": "padahal", + "jorokkkk": "jorok", + "mualll": "mual", + "klow": "kalau", + "muke": "muka", + "ajh": "saja", + "taiiii": "tahi", + "seneeeeeng": "senang", + "nyesel": "menyesal", + "besarin": "membesarkan", + "tanyang": "tayang", + "ber2": "berdua", + "jngan": "jangan", + "ngelarang": "melarang", + "kerreeen": "keren", + "pantess": "pantas", + "bngettt": "banget", + "baperr": "bawa perasaan", + "sudh": "sudah", + "sbr": "sabar", + "pngen": "pengin", + "nntn": "menonton", + "ht": "hati", + "pemainy": "pemainnya", + "jg": "juga", + "aqu": "aku", + "ngeliat": "melihat", + "baper": "bawa perasaan", + "akn": "akan", + "nyampe": "sampai", + "kbawa": "terbawa", + "bradegan": "beradegan", + "amiiinnnn": "amin", + "mlm": "malam", + "nikahin": "menikahi", + "hadeuh": "aduh", + "nyambung": "menyambung", + "liyat": "lihat", + "krn": "karena", + "sk": "suka", + "adengan": "adegan", + "engga": "tidak", + "bingit": "banget", + "dapetin": "mendapatkan", + "model2": "model-model", + "sitayngin": "ditayangkan", + "cocokkk": "cocok", + "mahhhh": "mah", + "cocokny": "cocoknya", + "iniw": "ini", + "q": "ku", + "setju": "setuju", + "bnern": "benaran", + "pcrn": "pacaran", + "klian": "kalian", + "cocokkkk": "cocok", + "bngett": "banget", + "abes": "habis", + "br": "baru", + "mksdny": "maksudnya", + "brpa": "berapa", + "ihh": "ih", + "kesampean": "kesampaian", + "beginiiii": "begini", + "ntr": "entar", + "beliin": "belikan", + "kya": "kaya", + "keq": "kayak", + "ngerasa": "merasa", + "sygkuu": "sayangku", + "ngeselin": "menyebalkan", + "baju'a": "bajunya", + "gara''": "gara-gara", + "teruss": "terus", + "pokok'a": "pokoknya", + "bg": "bang", + "ubur2": "ubur-ubur", + "kuyy": "yuk", + "bentarr": "bentar", + "dureen": "duren", + "ajibb": "ajib", + "mahall": "mahal", + "yukk": "yuk", + "niiihh": "nih", + "imuuttt": "imut", + "imuttzz": "imut", + "nha": "nya", + "konten2": "konten-konten", + "rasain": "merasai", + "daaaah": "deh", + "asliiii": "asli", + "cantiknnnnyaaaaaaa": "cantiknya", + "yuuk": "yuk", + "ngelihatnya": "melihatnya", + "bdn": "badan", + "na": "nya", + "ibu2": "ibu-ibu", + "lau": "kau", + "cantikk": "cantik", + "sexxi": "seksi", + "bulet": "bulat", + "ituu": "itu", + "ngocok": "mengocok", + "nyoba": "mencoba", + "gn": "begini", + "yaahhh": "ya", + "kencqng": "kencang", + "kra": "kira", + "diginikan": "dibeginikan", + "coret\"": "coret-coret", + "lahh": "lah", + "gambarin": "gambarkan", + "ngerii": "ngeri", + "digambarin": "digambarkan", + "klen": "kalian", + "bangke": "bangkai", + "kog": "kok", + "liatnya": "lihatnya", + "bedanyaaa": "bedanya", + "negri": "negeri", + "mbk": "mbak", + "pgin": "pengin", + "krudungan": "kerudungan", + "ajaa": "saja", + "maksutku": "maksudku", + "kerennnnn": "keren", + "njirr": "anjir", + "jd'y": "jadinya", + "manisnyaaaa": "manisnya", + "thun": "tahun", + "mbg": "mbak", + "bangeet": "banget", + "ngaco": "mengaco", + "ngomen": "mengomentari", + "kaaakk": "kak", + "gituh": "begitu", + "tenggah": "tengah", + "uuuuuaaaaaaaaaappppppppppiiiiiiiiiiiiiiiiiikkkkkkkkkkkkkkkk": "apik", + "kocakkkkkkkkkkkkkk": "kocak", + "ksukaanmuuu": "kesukaanmu", + "bagiii": "bagi", + "ayooo": "ayo", + "nontonx": "menontonnya", + "dpn": "depan", + "pi": "tapi", + "penasarannnn": "penasaran", + "gilakkkkkk": "gila", + "mauuu": "mau", + "nontoonnn": "menonton", + "cepet2": "cepat-cepat", + "kepingin": "pengin", + "nontonnnn": "menonton", + "deeek": "dek", + "ntappp": "mantap", + "deg2an": "deg-degan", + "cyiiin": "cinta", + "iyha": "iya", + "kureeeen": "keren", + "muantaap": "mantap", + "pake": "menggunakan", + "ngantri": "mengantri", + "bli": "beli", + "nraktir": "mentraktir", + "nonton": "menonton", + "sbnyak": "sebanyak", + "penasaraaann": "penasaran", + "ayoooo": "ayo", + "nii": "ini", + "nontonya": "menontonnya", + "kl": "kalo", + "nunggu": "menunggu", + "dpt": "dapat", + "qta": "kita", + "peen": "pengin", + "lw": "kalau", + "gga": "tidak", + "yaah": "ya", + "asikkk": "asik", + "gilak": "gila", + "mcm": "macam", + "ceritax": "ceritanya", + "yoooiii": "yoi", + "kuy": "yuk", + "udahh": "sudah", + "tauuuuu": "tau", + "sabarrrrr": "sabar", + "kqmu": "kamu", + "banggain": "banggakan", + "thn": "tahun", + "pengan": "pengin", + "sendeng": "senang", + "simpen": "simpan", + "gakk": "tidak", + "bagiin": "membagikan", + "nanggepin": "menanggapi", + "kapn": "kapan", + "barang2": "barang-barang", + "kirimin": "mengirimi", + "senengin": "senangi", + "ngasih": "memberikan", + "ampunnnn": "ampun", + "bln": "bulan", + "senengg": "senang", + "teruzzz": "terus", + "mantabb": "mantab", + "sumpahh": "sumpah", + "diluarann": "diluaran", + "trbaik": "terbaik", + "dlam": "dalam", + "anjirrrr": "anjir", + "jendess": "janda", + "dh": "dah", + "sichh": "sih", + "namnya": "namanya", + "geh": "gih", + "nggak": "tidak", + "22nya": "dua-duanya", + "aduuhhh": "aduh", + "miripp": "mirip", + "silauuu": "silau", + "waww": "wow", + "uga": "juga", + "njiirr": "anjir", + "bgs": "bagus", + "kesinii": "kesini", + "gemay": "gemas", + "dstu": "disitu", + "wiiiihhhh": "wih", + "samoga": "semoga", + "kereennn": "keren", + "wahh": "wah", + "nyangkaa": "menyangka", + "hasilnyaaa": "hasilnya", + "dahhhh": "dah", + "mudah2": "mudah-mudah", + "jugaakk": "juga", + "prnh": "pernah", + "diapa2in": "diapa-apai", + "yaak": "ya", + "lhoo": "loh", + "donx": "dong", + "gthu": "begitu", + "nda": "indak", + "maauuu": "mau", + "pacarrr": "pacar", + "idamann": "idaman", + "naturalllll": "natural", + "bnyak": "banyak", + "berntung": "beruntung", + "merka": "mereka", + "jln2": "jalan-jalan", + "romntis": "romantis", + "slamat": "selamat", + "ulan": "ulang", + "th": "tahu", + "mngkin": "mungkin", + "klau": "kalau", + "diksi": "dikasih", + "sipp": "sip", + "taun": "tahun", + "jugak": "juga", + "nungguin": "menunggu", + "ngeluarin": "mengeluarkan", + "keciill": "kecil", + "ughaa": "juga", + "tante\"": "tante-tante", + "banged": "banget", + "abeees": "habis", + "kapan2": "kapan-kapan", + "adain": "adakan", + "bsa": "bisa", + "bosen2": "bosan-bosan", + "sempurnaaaaa": "sempurna", + "kaan": "kan", + "kece2": "kece-kece", + "badaaiii": "badai", + "beneraan": "benaran", + "cantiiik": "cantik", + "kuatttt": "kuat", + "cantiiiiiiik": "cantik", + "cantiknyaaa": "cantiknya", + "bae2": "baik-baik", + "bgd": "banget", + "mb": "mbak", + "catiiiiik": "cantik", + "subahanallah": "subhanallah", + "jaman2nya": "jaman-jamannya", + "padet": "padat", + "buktiin": "membuktikan", + "cantiq": "cantik", + "pasien\"nya": "pasien-pasiennya", + "lohh": "loh", + "utk": "untuk", + "keeeerrrreeeennnnnnnnn": "keren", + "woow": "wow", + "choker2": "choker-choker", + "hadirrr": "hadir", + "hadirr": "hadir", + "jelashh": "jelas", + "sekali2": "sekali-kali", + "mbaaa": "mbak", + "temen": "teman", + "hidupppp": "hidup", + "nyebelin": "menyebalkan", + "diliatnya": "dilihatnya", + "abis2": "habis-habis", + "gpp": "enggak apa-apa", + "para2": "parah-parah", + "bangeeet": "banget", + "allahhhhhhh": "allah", + "gemesnya": "gemasnya", + "ilang2": "hilang-hilang", + "kmi": "kami", + "ngilangin": "menghilangkan", + "tbuh": "tubuh", + "itungan": "hitungan", + "ledekin": "ledeki", + "tmn2": "teman-teman", + "repot2": "repot-repot", + "oles2": "oles-oles", + "ckup": "cukup", + "dptkan": "dapatkan", + "artis2": "artis-artis", + "kalii": "kali", + "ninggiin": "meninggikan", + "baget": "banget", + "dri": "dari", + "kira2": "kira-kira", + "lgsg": "langsung", + "ngk": "tidak", + "ahh": "ah", + "pas2an": "pas-pasan", + "balesin": "membalas", + "gausah": "enggak usah", + "sukaaa": "suka", + "lutuna": "lucunya", + "ciapa": "siapa", + "gemesss": "gemas", + "duhh": "duh", + "terharuuu": "terharu", + "alamii": "alami", + "sampek": "sampai", + "ckrng": "sekarang", + "ngegemesin": "menggemaskan", + "imoet": "imut", + "imut2": "imut-imut", + "jagain": "menjaga", + "yahbh": "ya", + "msih": "masih", + "macem2": "macam-macam", + "neko2": "neko-neko", + "sktika": "seketika", + "lutu": "lucu", + "wowwww": "wow", + "massss": "mas", + "kesiniii": "kesini", + "keren\"": "keren-keren", + "jalan\"nya": "jalan-jalannya", + "masyaallahhh": "masyaallah", + "kerennn": "keren", + "ksni": "kesini", + "begok": "bego", + "bkan": "bukan", + "jgk": "juga", + "bawak": "bawa", + "it": "itu", + "nti": "nanti", + "pgi": "pagi", + "keeeeeren": "keren", + "baguusss": "bagus", + "mna": "mana", + "nahannnn": "tahan", + "bagaatt": "banget", + "gayannxa": "gayanya", + "kereeeen": "keren", + "uda": "sudah", + "ngekeh": "kekeh", + "xlian": "kalian", + "b3": "bertiga", + "ihhh": "ih", + "lcu": "lucu", + "pait": "pahit", + "koplakkk": "koplak", + "kerass": "keras", + "yachhh": "ya", + "gokil": "gila", + "ngelihat": "melihat", + "ngelawak": "melawak", + "iiihhhhh": "ih", + "nyolotinnnn": "menyolot", + "beratttttttt": "berat", + "cantiikkkk": "cantik", + "hijaabbb": "hijab", + "sekalii": "sekali", + "smnagatin": "menyemangati", + "pakek": "pakai", + "kanapa": "kenapa", + "cans": "cantik", + "banhet": "banget", + "bgttttttt": "banget", + "blng": "bilang", + "mngasi": "mengasih", + "kmbang": "kembang", + "pg": "pagi", + "nie": "ini", + "pla": "pula", + "kw": "kau", + "blakon": "berlakon", + "sy": "saya", + "nmpk": "nampak", + "bngtzt": "banget", + "lucukkkkk": "lucu", + "sbelah": "sebelah", + "baek": "baik", + "ciusss": "serius", + "palinf": "paling", + "fav": "favorit", + "maak": "mak", + "ngakaaaakkk": "mengakak", + "gokill": "gila", + "kocakkkkkk": "kocak", + "ngekek": "mengekek", + "capee": "cape", + "pegangin": "memegang", + "nyender": "menyandar", + "bener": "benar", + "manjain": "memanjakan", + "peleotin": "meleot", + "bng": "bang", + "termurahhhh": "termurah", + "nyatakan": "menyatakan", + "graaatiiiis": "gratis", + "meriahh": "meriah", + "cantikkkm": "cantik", + "parahh": "parah", + "bingits": "banget", + "yuppss": "ya", + "nyindir": "menyindir", + "onoh": "sono", + "bnyk": "banyak", + "anakny": "anaknya", + "staf2nya": "staf-stafnya", + "begete": "banget", + "aduhh": "aduh", + "manteppp": "mantap", + "hati2": "hati-hati", + "jangan2": "jangan-jangan", + "kata2nya": "kata-katanya", + "ynk": "sayang", + "nangis\"": "menangis-nangis", + "hati\"": "hati-hati", + "jangan\"": "jangan-jangan", + "emank": "memang", + "cr": "cara", + "diemin": "diamkan", + "hancurin": "menghancurkan", + "ckp": "cukup", + "mgkn": "mungkin", + "ato": "atau", + "diapain": "diapakan", + "ms": "masih", + "haragain": "menghargai", + "cb": "coba", + "nyakitin": "menyakiti", + "jgn2": "jangan-jangan", + "sbaiknya": "sebaiknya", + "mngerti": "mengerti", + "blajar": "belajar", + "fakta2": "fakta-fakta", + "spt": "seperti", + "mnding": "mending", + "nilai2": "nilai-nilai", + "buka2": "buka-buka", + "nyinyirin": "nyinyir", + "pdhl": "padahal", + "pd": "pada", + "nyerang": "menyerang", + "hrs": "harus", + "mrk": "mereka", + "trima": "terima", + "ngomomg": "mengomong", + "mang": "memang", + "kbetulan": "kebetulan", + "orgtuanya": "orang tuanya", + "sbelum": "sebelum", + "ngamatin": "mengamati", + "ngintip": "mengintip", + "stelah": "setelah", + "sndiri": "sendiri", + "ngintilin": "mengintil", + "smnjak": "semenjak", + "trbukti": "terbukti", + "nyambet": "menyambat", + "msk": "masuk", + "krmh": "ke rumah", + "rmh": "rumah", + "bo'ong": "bohong", + "eloo": "lo", + "apa2nya": "apa-apanya", + "goblokkk": "goblok", + "sesatt": "sesat", + "ibliss": "iblis", + "elo": "lo", + "kbiasaan": "kebiasaan", + "hdpnya": "hidupnya", + "lbh": "lebih", + "byk": "banyak", + "anknya": "anaknya", + "tdr": "tidur", + "sbuah": "sebuah", + "anak2nya": "anak-anaknya", + "tdnya": "tadinya", + "ank2nya": "anak-anaknya", + "milih": "memilih", + "branjak": "beranjak", + "ank2": "anak-anak", + "masyarakay": "masyarakat", + "mudah2an": "mudah-mudahan", + "menegerti": "mengerti", + "apa2": "apa-apa", + "sok2": "sok-sok", + "nasehatin": "menasihati", + "ngaca": "mengaca", + "ajah": "saja", + "kakkkk": "kak", + "slamet": "selamat", + "ampe": "sampai", + "nari": "menari", + "ehh": "eh", + "mf": "maaf", + "ngcpin": "mengucapkan", + "woowwww": "wow", + "iaa": "iya", + "kdg": "kadang", + "kebagi": "terbagi", + "nerusin": "meneruskan", + "indo": "indonesia", + "univ": "universitas", + "maen": "main", + "wihh": "wih", + "tetapppp": "tetap", + "gantenggg": "ganteng", + "jakety": "jaketnya", + "nggk": "tidak", + "skli": "sekali", + "kli": "kali", + "ttp": "tetap", + "pala": "kepala", + "pusingggg": "pusing", + "pucing": "pusing", + "ihhhh": "ih", + "maho": "manusia homo", + "gantng": "ganteng", + "alak": "kepala", + "hilangv": "hilang", + "bungggg": "bung", + "gapap": "enggak apa-apa", + "kyak": "kayak", + "pling": "paling", + "gokiiill": "gila", + "busedh": "buset", + "gokilnya": "gilanya", + "duluuu": "dulu", + "keceee": "kece", + "nyaa": "nya", + "klu": "kalau", + "gilaa": "gila", + "duuhhh": "duh", + "amiiiiinnnnn": "amin", + "dg": "dengan", + "tullus": "tulus", + "tw": "tau", + "pluk": "peluk", + "durhaka2": "durhaka-durhaka", + "kpada": "kepada", + "seburuk\"": "seburuk-buruk", + "ortu": "orang tua", + "ktaa": "kita", + "yup": "ya", + "tdk": "tidak", + "tetaplaah": "tetaplah", + "ajja": "saja", + "dech": "deh", + "oprasi": "operasi", + "gya": "gaya", + "kndung": "kandung", + "sbg": "sebagai", + "u": "lu", + "ksenangan": "kesenangan", + "ddpt": "didapat", + "jwab": "jawab", + "mmbodohkan": "membodohkan", + "orlaen": "orang lain", + "bgtu": "begitu", + "drpd": "daripada", + "malem": "malam", + "aplikasiin": "mengaplikasikan", + "berangat": "berangkat", + "praktekin": "mempraktikkan", + "dahh": "dah", + "gmn": "bagaimana", + "omdo": "omong doang", + "kate": "kata", + "cabe2": "cabe-cabe", + "prjuangan": "perjuangan", + "kmewahan": "kemewahan", + "stimpal": "setimpal", + "ddpat": "didapat", + "sprt": "seperti", + "bc": "baca", + "mnilai": "menilai", + "kata2": "kata-kata", + "usahh": "usah", + "segala2nya": "segala-galanya", + "sekarng": "sekarang", + "sprti": "seperti", + "mkanya": "makanya", + "hay": "hai", + "mreka": "mereka", + "bhagia": "bahagia", + "korek2": "korek-korek", + "ngebales": "membalas", + "krna": "karena", + "sihh": "sih", + "sasama": "sesama", + "dehh": "deh", + "cabe2an": "cabe-cabean", + "terong2an": "terong-terongan", + "seklian": "sekalian", + "kluar": "keluar", + "tuuu": "itu", + "ngurusan": "mengurusi", + "campurin": "mencampuri", + "emnknya": "memangnya", + "py": "punya", + "unyuu": "unyu", + "mrangkul": "merangkul", + "adlh": "adalah", + "wlo": "walau", + "lh": "boleh", + "manusiy": "manusia", + "tx": "tak", + "samara": "sakinah mawadah rahman", + "teringt": "teringat", + "trmsuk": "termasuk", + "dn": "dan", + "pilihnmu": "pilihanmu", + "swmntara": "sementara", + "trmasuk": "termasuk", + "orang2": "orang-orang", + "sgt": "sangat", + "liht": "lihat", + "suaminy": "suaminya", + "jadulnya": "jaman dulunya", + "mantaff": "mantap", + "ngata2in": "mengatai-ngatai", + "bagaimna": "bagaimana", + "mulusssss": "mulus", + "bgus": "bagus", + "gmana": "bagaimana", + "kpleset": "kepleset", + "yawlah": "ya allah", + "kepeleset": "kepleset", + "kayanye": "kayanya", + "amiinnnn": "amin", + "komen2an": "komen-komenan", + "wewww": "wow", + "dumsss": "dong", + "tar": "entar", + "perbaikin": "perbaiki", + "permasalhkan": "permasalahkan", + "betul2": "betul-betul", + "jauhin": "jauhi", + "dateng": "datang", + "adiik": "adik", + "manisss": "manis", + "besar\"": "besar-besar", + "masing\"": "masing-masing", + "ddlm": "di dalam", + "kmbali": "kembali", + "dkuburnya": "dikuburnya", + "fi": "di", + "ttg": "tentang", + "rata2": "rata-rata", + "lbih": "lebih", + "critamu": "ceritamu", + "ja": "saja", + "bres": "beres", + "cmkn": "camkan", + "diurusin": "diurusi", + "biarin": "biarkan", + "kale": "kali", + "donks": "dong", + "membanding\"": "membanding-banding", + "mengperbolehkan": "memperbolehkan", + "krena": "karena", + "ngri": "negeri", + "dingin\"": "dingin-dingin", + "lma": "lama", + "dibangga'in": "dibangga-banggakan", + "ngebolehin": "membolehkan", + "denger": "dengar", + "jelek\"in": "menjelek-jelekkan", + "stiap": "setiap", + "gausa": "enggak usah", + "maksain": "memaksakan", + "smog": "semoga", + "mnerima": "menerima", + "baikxa": "baiknya", + "aaamin": "amin", + "amienn": "amien", + "sekangen2": "sekangen-kangen", + "kt": "kita", + "amiiiiiiiiiinnnnnnn": "amin", + "nolongin": "menolong", + "amiiiiiiiiiiin": "amin", + "seluru": "seluruh", + "amiennn": "amien", + "tebir": "ribet", + "doangam": "doang", + "tebir\"": "ribet-ribet", + "amad": "amat", + "mkst": "maksud", + "wong2": "wong-wong", + "rempong": "repot", + "ngurusin": "mengurusi", + "kerjaan": "pekerjaan", + "bingittzz": "banget", + "bangets": "banget", + "ikh": "ih", + "dwh": "deh", + "nengok": "menengok", + "bante": "bantai", + "la": "lah", + "komenannya": "komentarnya", + "hadapin": "menghadapi", + "dengarin": "mendengarkan", + "ush": "usah", + "cock": "cocok", + "widihh": "idih", + "mantepp": "mantap", + "cita2": "cita-cita", + "temen-temen": "teman-teman", + "stu": "satu", + "syratnya": "syaratnya", + "pnjang": "panjang", + "shat": "sehat", + "inginkn": "inginkan", + "belanjaa": "belanja", + "burem": "buram", + "tlp": "telepon", + "dngan": "dengan", + "ngerasain": "merasai", + "kancang": "kencang", + "tmbh": "tambah", + "mansyoallah": "masyaallah", + "skali": "sekali", + "canttttikkk": "cantik", + "subhanalloh": "subhanallah", + "lkuin": "lakukan", + "langgenh": "langgeng", + "foto2nya": "foto-fotonya", + "tanggung2": "tanggung-tanggung", + "salluutt": "salut", + "pkai": "pakai", + "org2": "orang-orang", + "mkn": "makin", + "seneng": "senang", + "pdng": "padang", + "warbiyasah": "luar biasa", + "pk": "pakai", + "keedaannya": "keadaannya", + "lagu2": "lagu-lagu", + "saya2": "saya-saya", + "sekrng": "sekarang", + "lagu2nya": "lagu-lagunya", + "ttap": "tetap", + "spertinya": "sepertinya", + "mrah": "marah", + "gra2": "gara-gara", + "sngaja": "sengaja", + "jlek": "jelek", + "udah2": "sudah-sudah", + "ungkit2": "ungkit-ungkit", + "pura2": "pura-pura", + "gatau": "tidak tahu", + "curhat2": "curhat-curhat", + "matany": "matanya", + "dteng": "datang", + "mata'y": "matanya", + "baju2": "baju-baju", + "buruannn": "buruan", + "emng": "memang", + "perubut": "perebut", + "sumai": "suami", + "tk": "tak", + "urusin": "urus", + "apah": "apa", + "bagus2": "bagus-bagus", + "say": "sayang", + "mmbela": "membela", + "mslhnya": "masalahnya", + "hnya": "hanya", + "sndri": "sendiri", + "sj": "saja", + "llki": "lelaki", + "dluar": "diluar", + "sna": "sana", + "boleh2": "boleh-boleh", + "dya": "dia", + "maluuuu": "malu", + "beda2": "beda-beda", + "nyaranin": "menyarankan", + "x": "kali", + "tongolin": "menongol", + "cabol": "cabul", + "ape": "apa", + "nyet": "monyet", + "muna": "munafik", + "jing": "anjing", + "tot": "entot", + "nihh": "nih", + "hidiyah": "hidayah", + "percya": "percaya", + "bgi": "bagi", + "tlg": "tolong", + "sya": "saya", + "sdri": "sendiri", + "tdi": "tadi", + "mgkin": "mungkin", + "bhwa": "bahwa", + "skaligus": "sekaligus", + "cek2": "cek-cek", + "spti": "seperti", + "smakin": "semakin", + "sbgai": "sebagai", + "mgk": "mungkin", + "bgmn": "bagaimana", + "sapa2": "sapa-sapa", + "sama2": "sama-sama", + "kyknya": "kayaknya", + "pnya": "punya", + "dng": "dengan", + "termurahhh": "termurah", + "bbrp": "beberapa", + "bhw": "bahwa", + "banyaaaaaak": "banyak", + "baik2nya": "baik-baiknya", + "tunjukan": "tunjukkan", + "anak2mu": "anak-anakmu", + "anak2": "anak-anak", + "ngasih2": "mengasih-ngasih", + "berfikir": "berpikir", + "amit2": "amit-amit", + "mba": "mbak", + "elu": "lu", + "ngajuin": "mengajukan", + "tiba2": "tiba-tiba", + "jkt": "jakarta", + "terbaeq": "terbaik", + "namany": "namanya", + "jels": "jelas", + "diakibtkn": "diakibatkan", + "suk": "suka", + "jelek2": "jelek-jelek", + "dsrny": "dasarnya", + "jkrta": "jakarta", + "mantaap": "mantap", + "benar2": "benar-benar", + "main2": "main-main", + "lanjutkaaaaan": "lanjutkan", + "hiduppp": "hidup", + "ncek": "encek", + "majuuu": "maju", + "teruuss": "terus", + "kdg2": "kadang-kadang", + "looohh": "loh", + "sebelumbya": "sebelumnya", + "tunjukin": "tunjukkan", + "tahun\"": "tahun-tahun", + "koment": "komentar", + "nyampein": "menyampaikan", + "hokiii": "hoki", + "naksir": "menaksir", + "bangt\"": "banget-banget", + "nafkahin": "menafkahi", + "ngitem": "menghitam", + "banyaaaak": "banyak", + "sama\"": "sama-sama", + "gx": "tidak", + "lhat": "lihat", + "cantika": "cantik", + "cwek": "cewek", + "kngen": "kangen", + "fillm": "film", + "sinet": "sinetron", + "jendral": "jenderal", + "nanya": "tanya", + "maenya": "mainnya", + "cantiks": "cantik", + "wktu": "waktu", + "pilm": "film", + "taon": "tahun", + "nd": "indak", + "slah": "salah", + "cini": "sini", + "jaaangan": "jangan", + "nambah": "menambah", + "naek": "naik", + "terimaksih": "terima kasih", + "inii": "ini", + "kaannnn": "kan", + "tidakkkk": "tidak", + "kemuh": "kamu", + "da": "dah", + "keren2": "keren-keren", + "sby": "surabaya", + "tgl": "tanggal", + "pinginnn": "pengin", + "buanget": "banget", + "ketemuuu": "ketemu", + "kebawa": "terbawa", + "mimpiii": "mimpi", + "wanjerrr": "anjir", + "satupunn": "satupun", + "motret": "memotret", + "temen2": "teman-teman", + "lapakk": "lapak", + "nyeniii": "menyeni", + "bget": "banget", + "seeh": "sih", + "abis": "habis", + "deuhh": "duh", + "b2an": "berduaan", + "dihargain": "dihargai", + "engga\"": "enggak-enggak", + "tahun2": "tahun-tahun", + "lebayy": "lebay", + "gilsss": "gila", + "nganbek": "mengambek", + "sukaa": "suka", + "enk": "enak", + "mendam": "memendam", + "manta": "mantan", + "relaaaa": "rela", + "bgun": "bangun", + "tdur": "tidur", + "cntk": "cantik", + "knl": "kenal", + "anugrah": "anugerah", + "slm": "salam", + "kliatan": "kelihatan", + "awoohhh": "allah", + "cantiikk": "cantik", + "gt": "begitu", + "sbbyak": "sebanyak", + "apun": "apapun", + "nyangkutnya": "menyangkutnya", + "untk": "untuk", + "ampunnnnn": "ampun", + "hy": "hai", + "wadaaw": "waduh", + "paraah": "parah", + "sampw": "sampai", + "dijelekin": "dijelekkan", + "napa": "kenapa", + "azeeeek": "asik", + "ntikk": "cantik", + "uhh": "uh", + "mayan": "lumayan", + "mnisss": "manis", + "tambahim": "tambahkan", + "tambahin": "tambahkan", + "bener\"": "benar-benar", + "make": "menggunakan", + "canttikkk": "cantik", + "baanggeettt": "banget", + "ap": "apa", + "siiihh": "sih", + "krpoooo": "kepo", + "pengn": "pengin", + "nyusul": "menyusul", + "nyok": "yuk", + "dekkk": "dek", + "ngiri": "mengiri", + "mmang": "memang", + "nentuin": "menentukan", + "mnjmput": "menjemput", + "ygs": "sayang", + "mblooo": "jomblo", + "ber'expresi": "berekspresi", + "jdulnya": "judulnya", + "ciumman": "ciuman", + "aurot": "aurat", + "laginy": "laginya", + "nyaaaaaaaaaaaaa": "nya", + "alkhamdulilah": "alhamdulillah", + "ktnya": "katanya", + "selaluu": "selalu", + "anjirrr": "anjir", + "ngeh": "engah", + "amen": "amin", + "qm": "kamu", + "nahhh": "nah", + "ngmngnya": "mengomongnya", + "sampai2": "sampai-sampai", + "hal2": "hal-hal", + "komunitas2": "komunitas-komunitas", + "fotony": "fotonya", + "halllo": "halo", + "ngiritya": "mengiritnya", + "hematt": "hemat", + "ciyeeee": "cie", + "yeeee": "ya", + "nget": "banget", + "kususnya": "khususnya", + "krm": "kirim", + "sam": "sama", + "ajaaahhhhh": "saja", + "syediiihhh": "sedih", + "malangg": "malang", + "belooomm": "belum", + "yaakk": "ya", + "kebukti": "terbukti", + "anak2e": "anak-anaknya", + "tuwanya": "tuanya", + "comen2nya": "komen-komennya", + "pedesssss": "pedas", + "jelas2": "jelas-jelas", + "skg": "sekarang", + "nyebut2": "menyebut-nyebut", + "plg": "pulang", + "smbil": "sambil", + "hellooooooo": "halo", + "ngrebut": "merebut", + "mnjdi": "menjadi", + "taek": "tahi", + "lupain": "melupakan", + "bangkret": "bangkrut", + "taekk": "tahi", + "lahhhhhh": "lah", + "kedokk": "kedok", + "mati\"an": "mati-matian", + "nyimak": "menyimak", + "komenx": "komennya", + "serem2": "seram-seram", + "ngumpet\"": "mengumpet-ngumpet", + "blim": "belum", + "sekrang": "sekarang", + "ngerebut": "merebut", + "mnjijikaaaaaan": "menjijikkan", + "astgfrlh": "astagfirullah", + "sahabatt": "sahabat", + "gnyngka": "enggak menyangka", + "sendirir": "sendiri", + "brengsekk": "brengsek", + "ngomentnya": "mengomentarinya", + "nusuk": "menusuk", + "skr": "sekarang", + "dpotong": "dipotong", + "helo": "halo", + "kmrin": "kemarin", + "ngmgin": "mengomongkan", + "bilangg": "bilang", + "gkhianati": "mengkhianati", + "kpd": "kepada", + "hr": "hari", + "sp": "sapa", + "pljaran": "pelajaran", + "spy": "supaya", + "mngikuti": "mengikuti", + "klkuan": "kelakuan", + "dibikin2": "dibikin-bikin", + "gimana": "bagaimana", + "nnt": "nanti", + "cew": "cewek", + "cow": "cowok", + "kasiannn": "kasihan", + "syekaliii": "sekali", + "hgga": "hingga", + "cieeeee": "cie", + "sholihah": "salihah", + "adeuh": "aduh", + "halahh": "alah", + "luu": "lu", + "yuuks": "yuk", + "yoo": "ya", + "ginian": "beginian", + "judulnyaa": "judulnya", + "ayoookkkkkk": "ayo", + "artix": "artinya", + "cewe": "cewek", + "cowo": "cowok", + "lt": "lantai", + "nama\"": "nama-nama", + "cwe": "cewek", + "serem": "seram", + "kepoin": "kepo", + "keluarin": "keluarkan", + "gajelas": "tidak jelas", + "mbak2": "mbak-mbak", + "pedes": "pedas", + "komentnya": "komentarnya", + "omongin": "omongkan", + "ngalirrrrr": "mengalir", + "idup": "hidup", + "nmbah": "menambah", + "terossss": "terus", + "ngehujat": "menghujat", + "dosaa": "dosa", + "ngebela": "membela", + "seindonesiyah": "seindonesia", + "seh": "sih", + "senyumin": "senyumi", + "mh": "mah", + "ngalir": "mengalir", + "ngrusak": "merusak", + "px": "punya", + "kslhn": "kesalahan", + "skrngnya": "sekarangnya", + "emang": "memang", + "kalik": "kali", + "bilangin": "bilang", + "nyambungin": "menyambung", + "sllu": "selalu", + "scra": "secara", + "nggali": "menggali", + "cmpur": "campur", + "dgr": "dengar", + "kurng": "kurang", + "nangiiiiis": "menangis", + "nangiiis": "menangis", + "ngomentarin": "mengomentari", + "idupnya": "hidupnya", + "ortunya": "orang tuanya", + "woey": "woy", + "bloghhh": "goblok", + "ngomongin": "mengomongkan", + "doank": "doang", + "abal\"": "abal-abal", + "dikata2in": "dikata-katai", + "ngehina": "menghina", + "gmna": "bagaimana", + "kebanggan": "kebanggaan", + "dah": "deh", + "jngn": "jangan", + "mnjawab": "menjawab", + "woooiiiiiiii": "woy", + "dosaaaaaaaaaa": "dosa", + "ingt": "ingat", + "karmaaa": "karma", + "woooiiiii": "woy", + "ngkkkkkk": "mengakak", + "makny": "maknya", + "wdooohhh": "waduh", + "kasihn": "kasihan", + "lhirr": "lahir", + "hujatann": "hujatan", + "palgi": "apalagi", + "wihhh": "wih", + "kakakkk": "kakak", + "bagusss": "bagus", + "jt": "juta", + "ngets": "banget", + "stp": "setiap", + "penbelian": "pembelian", + "cuaantik": "cantik", + "sukakkk": "suka", + "m": "sama", + "cucokkk": "cocok", + "lyt": "lihat", + "expresi": "ekspresi", + "natap": "menatap", + "ccok": "cocok", + "helloo": "halo", + "dripada": "daripada", + "difotoin": "difoto", + "psangan": "pasangan", + "cakepp": "cakep", + "ngiriii": "mengiri", + "coxok": "cocok", + "buatin": "membuat", + "nihhh": "nih", + "statusx": "statusnya", + "cem": "macam", + "yee": "ya", + "sunggu": "sungguh", + "lagiii": "lagi", + "ammiiin": "amin", + "haddeh": "aduh", + "maaasss": "mas", + "segerrrr": "segar", + "kemn": "kemana", + "suipit": "sipit", + "sipiit": "sipit", + "kedengeran": "kedengaran", + "semangaatt": "semangat", + "promil": "program hamil", + "lancr": "lancar", + "keputihn": "keputihan", + "gratisss": "gratis", + "kebayang": "terbayang", + "mdh\"": "mudah-mudah", + "bz": "bisa", + "amiinn": "amin", + "ammin": "amin", + "mendapatkn": "mendapatkan", + "lucuuuk": "lucu", + "amaattt": "amat", + "aje": "saja", + "yaoloh": "yaallah", + "ngerayain": "merayakan", + "sotoy": "sok tau", + "yok": "yuk", + "d.bikinin": "dibikin", + "mmz": "mas", + "bikinin": "membikin", + "bapeeeer": "bawa perasaan", + "keliatannya": "kelihatannya", + "siih": "sih", + "diginiin": "dibeginikan", + "hadeehh": "aduh", + "pu": "punya", + "suamiiiii": "suami", + "krenn": "keren", + "kebykan": "kebanyakan", + "tingal": "tinggal", + "pernikahn": "pernikahan", + "ngtain": "mengatai", + "macem": "macam", + "bagusnyeee": "bagusnya", + "kemaren": "kemarin", + "hmpir": "hampir", + "liatnyaaa": "lihatnya", + "waw": "wow", + "mbuh": "embuh", + "bsk": "besok", + "waaa": "wah", + "berbiiiiiihhh": "barbie", + "canteek": "cantik", + "teru": "terus", + "nyatok": "mencatok", + "nyampenya": "sampainya", + "goncengin": "boncengkan", + "kasi": "kasih", + "dianterin": "diantar", + "gebleg": "geblek", + "bosen": "bosan", + "anterin": "antarkan", + "ngantar": "mengantar", + "nganterin": "mengantarkan", + "mz": "mas", + "nerima": "menerima", + "ati2": "hati-hati", + "yeuh": "ya", + "kmn": "kemana", + "dianter": "diantar", + "disupirin": "disopiri", + "jidad": "jidat", + "ciee": "cie", + "novembr": "november", + "hri": "hari", + "bedain": "membedakan", + "genduut": "gendut", + "ehhh": "eh", + "bertatto": "bertato", + "brrt": "berarti", + "kana": "kanan", + "dtg": "datang", + "doong": "dong", + "sblah": "sebelah", + "tauuu": "tau", + "tengaj": "tengah", + "ps": "pas", + "gmpg": "gampang", + "engk": "tidak", + "apal": "hapal", + "dak": "indak", + "rupa2nya": "rupa-rupanya", + "kembaq": "kembar", + "tngh": "tengah", + "mknya": "makanya", + "prmpuan": "perempuan", + "ketuker": "tertukar", + "drimnanya": "darimananya", + "cntikan": "cantikan", + "edaaaan": "edan", + "syp": "siapa", + "bcnda": "bercanda", + "uhhh": "uh", + "bazengg": "bajingan", + "haiii": "hai", + "bosaan": "bosan", + "gitu2": "begitu-gitu", + "kmana2": "kemana-mana", + "hrsnya": "harusnya", + "sdg": "sedang", + "komentar2": "komentar-komentar", + "kenyataan2": "kenyataan-kenyataan", + "silahkan": "silakan", + "lwt": "lewat", + "sukur2": "sukur-sukur", + "baek2": "baik-baik", + "cma": "cuma", + "berbeda2": "berbeda-beda", + "dibkatakan": "dikatakan", + "doonk": "dong", + "pengikuy": "pengikut", + "ngmngin": "mengomongkan", + "sblmnya": "sebelumnya", + "maksd": "maksud", + "adkah": "adakah", + "menbelah": "membelah", + "ayuk": "ayo", + "mngira": "mengira", + "bertolernsi": "bertoleransi", + "slahkan": "salahkan", + "tolernsi": "toleransi", + "sbnarnya": "sebenarnya", + "ingi": "ingin", + "oalaaaa": "walah", + "menjelekan": "menjelekkan", + "hadeuuh": "aduh", + "dikemanain": "dikemanakan", + "aneh2": "aneh-aneh", + "bpknya": "bapaknya", + "baca2": "baca-baca", + "jwb": "jawab", + "komen2": "komen-komen", + "sebelum2nya": "sebelum-sebelumnya", + "sbagai": "sebagai", + "sedihh": "sedih", + "kangennnn": "kangen", + "gndakan": "gandakan", + "kangeeeeeeeenya": "kangen", + "gaa": "tidak", + "ganti'kn": "menggantikan", + "tnang": "tenang", + "amiiiiiiiiiiiiiiiiin": "amin", + "kgeeeen": "kangen", + "kangeenn": "kangen", + "bangeeeet": "banget", + "bsok": "besok", + "hbis": "habis", + "mti": "mati", + "bngetttt": "banget", + "kangeen": "kangen", + "bgect": "banget", + "kebaikan\"y": "kebaikannya", + "dssi": "disisi", + "kngenn": "kangen", + "kangeeeeennn": "kangen", + "lagii": "lagi", + "dasyat": "dahsyat", + "aer": "air", + "bgni": "begini", + "skalii": "sekali", + "gg": "tidak", + "dtinggl": "ditinggal", + "terbaiiikkkkk": "terbaik", + "murahhh": "murah", + "pecahhg": "pecah", + "kakakgh": "kakak", + "terbaeeeek": "terbaik", + "prempuan": "perempuan", + "manteep": "mantap", + "nggeh": "engah", + "indonesiaaa": "indonesia", + "inia": "ini", + "maluu": "malu", + "cobaaaa": "coba", + "dehhh": "deh", + "cepetan": "cepatan", + "ntapsss": "mantap", + "kamiiii": "kami", + "smangat": "semangat", + "qmu": "kamu", + "sdkit2": "sedikit-sedikit", + "tntg": "tentang", + "penyakitny": "penyakitnya", + "ngajarin": "mengajari", + "laenny": "lainnya", + "amiinnn": "amin", + "scpatny": "secepatnya", + "sampein": "sampaikan", + "tuany": "tuanya", + "doa2": "doa-doa", + "pjg": "panjang", + "adeeek": "adek", + "bca": "baca", + "netes": "menetes", + "mataq": "mataku", + "byangin": "membayangkan", + "trjadi": "terjadi", + "dposisi": "diposisi", + "tanda2nya": "tanda-tandanya", + "dijwb": "dijawab", + "tmn2nya": "teman-temannya", + "sayy": "sayang", + "salaam": "salam", + "ngajar": "mengajar", + "fto2": "foto-foto", + "laen": "lain", + "dallam": "dalam", + "tentanh": "tentang", + "nangiss": "menangis", + "nnton": "menonton", + "kejadianny": "kejadiannya", + "klg": "keluarga", + "sayaang": "sayang", + "msg2": "masing-masing", + "pgnnya": "penginnya", + "meluk": "memeluk", + "siiihhh": "sih", + "mupeng": "muka pengin", + "caya": "percaya", + "aduhhhhhhhhh": "aduh", + "buseett": "buset", + "mblo": "jomblo", + "lucuuu": "lucu", + "gagahx": "gagahnya", + "miriiiip": "mirip", + "kaliiii": "kali", + "niru": "meniru", + "bpkmu": "bapakmu", + "sangt": "sangat", + "baang": "bang", + "gasih": "enggak sih", + "yuu": "yuk", + "yuuu": "yuk", + "samaa": "sama", + "ngademin": "mengademkan", + "aahh": "ah", + "makasi": "terima kasih", + "looo": "loh", + "yokk": "yuk", + "gils": "gila", + "ono": "sono", + "weeeh": "wih", + "suksea": "sukses", + "temenan": "berteman", + "sehh": "sih", + "deket": "dekat", + "dekt": "dekat", + "genggg": "geng", + "yawloh": "ya allah", + "bhnya": "bahannya", + "loo": "lo", + "samaaaa": "sama", + "abanggku": "abangku", + "sayanggg": "sayang", + "dsni": "disini", + "ciieeeeeee": "cie", + "kokohh": "engkoh", + "pdhal": "padahal", + "takuttt": "takut", + "yuks": "yuk", + "pnjual": "penjual", + "yh": "ya", + "dung": "dong", + "latian": "latihan", + "aaaaaaakkk": "ah", + "mauuuu": "mau", + "soga": "moga", + "tteh": "teteh", + "sintron": "sinetron", + "astagaa": "astaga", + "ancurrr": "hancur", + "dblkang": "dibelakang", + "yayang": "sayang", + "gokillllll": "gila", + "aplks": "aplikasi", + "ska": "suka", + "ktawa": "ketawa", + "baguuus": "bagus", + "kaaan": "kan", + "ngepoin": "mengepoi", + "uuhh": "uh", + "beut": "banget", + "njirrr": "anjir", + "pengeeenn": "pengin", + "dongggg": "dong", + "hatii": "hati", + "besook": "besok", + "mauu": "mau", + "gtuu": "begitu", + "njayyy": "anjay", + "maacih": "terima kasih", + "tokayyy": "tahi", + "to": "tapi", + "mksdku": "maksudku", + "gituin": "begitukan", + "lgsung": "langsung", + "cba": "coba", + "nh": "nih", + "ilang\"": "hilang-hilang", + "giniin": "beginikan", + "ksini": "kesini", + "kubarikan": "kuberikan", + "ayank": "sayang", + "anyeng": "anjing", + "yankkk": "sayang", + "cukurrr": "cukur", + "gilaaaaaa": "gila", + "yhaaa": "ya", + "qaqaaaa": "kakak", + "sayangkuuuuu": "sayangku", + "kakakuuu": "kakakku", + "banx": "bang", + "edannn": "edan", + "bape": "bawa perasaan", + "artis\"": "artis-artis", + "ngelahirin": "melahirkan", + "paksain": "memaksa", + "biasaa": "biasa", + "hub": "hubungan", + "hubunggi": "hubungi", + "loooh": "loh", + "telimakacih": "terima kasih", + "nerawang": "menerawang", + "cantiiiik": "cantik", + "didapet": "didapat", + "tanya2": "tanya-tanya", + "pede": "percaya diri", + "kuningggg": "kuning", + "nyolot": "menyolot", + "elahh": "alah", + "pnting": "penting", + "bela2in": "membela-bela", + "marah2an": "marah-marahan", + "wlaupun": "walaupun", + "syantiiiiikkkkkkkkkkkk": "cantik", + "bwa\"": "bawa-bawa", + "agma": "agama", + "hijabb": "hijab", + "kerrennn": "keren", + "ngetik": "mengetik", + "ntah": "entah", + "pan": "kapan", + "mateeeek": "mati", + "geetoo": "begitu", + "sewain": "menyewa", + "kokkk": "kok", + "muluuu": "melulu", + "yaaang": "yang", + "iyaa": "iya", + "gituu": "begitu", + "yappp": "ya", + "uwahhhhh": "wah", + "bnr2": "benar-benar", + "psgn": "pasangan", + "ujg": "ujung", + "rmbut": "rambut", + "smp": "sampai", + "kki": "kaki", + "hayukk": "hayo", + "ntab": "mantab", + "atuhhh": "atuh", + "yukss": "yuk", + "lihaatttt": "lihat", + "niiiihh": "nih", + "liattt": "lihat", + "cuyyy": "cuy", + "gapernah": "enggak pernah", + "liatiii": "lihat", + "jombsss": "jomblo", + "emesshhh": "gemas", + "gemeessss": "gemas", + "poollll": "pol", + "gemesyak": "gemas ya", + "bngttt": "banget", + "ngegmesin": "menggemaskan", + "hndak": "hendak", + "hndk": "hendak", + "iiih": "ih", + "lutunaa": "lucunya", + "abiz": "habis", + "gemes": "gemas", + "ngareppppp": "mengharap", + "aamiinin": "amin", + "imuutt": "imut", + "abizzz": "habis", + "tay": "tahi", + "toketnya": "teteknya", + "tumpe2": "tumpah-tumpah", + "lenje": "lenjeh", + "tuuuuh": "tuh", + "cantinya": "cantiknya", + "macam2": "macam-macam", + "mbak\"": "mbak-mbak", + "ngatain": "mengatai", + "mbaknyaahh": "mbaknya", + "pyudara": "payudara", + "ditutupin": "ditutupi", + "bgitu": "begitu", + "ngomongnya": "mengomongnya", + "sich": "sih", + "cantiik2": "cantik-cantik", + "hati.a": "hatinya", + "mk.a": "makanya", + "mlt.a": "mulutnya", + "nyerocooooos": "mencerocos", + "kyk.a": "kayaknya", + "sklh": "sekolah", + "pljrn": "pelajaran", + "srng": "sering", + "ngilang": "menghilang", + "dikatain": "dikatai", + "maluuu": "malu", + "ngalahin": "mengalahkan", + "brisi": "berisi", + "pkoknya": "pokoknya", + "canti": "cantik", + "astagfirullh": "astagfirullah", + "nutupin": "menutupi", + "ori": "orisinal", + "kakakaaaaaaaaaaaaaa": "kakak", + "cantikkkkkkkk": "cantik", + "batwc": "banget", + "kamyuhhh": "kamu", + "dbwh": "dibawah", + "bukti2": "bukti-bukti", + "senenggg": "senang", + "mao": "mau", + "murhan": "murahan", + "prwtn": "perawatan", + "wjh": "wajah", + "bgettt": "banget", + "mereka2": "mereka-mereka", + "pakke": "pakai", + "dipake": "dipakai", + "ktergantungan": "ketergantungan", + "prwatn": "perawatan", + "ngebuktiin": "membuktikan", + "hslnya": "hasilnya", + "awal2": "awal-awal", + "setabil": "stabil", + "maksih": "terima kasih", + "gratiss": "gratis", + "bbrpa": "beberapa", + "gara\"": "gara-gara", + "benar\"": "benar-benar", + "ajaibb": "ajaib", + "pokonya": "pokoknya", + "setujuuu": "setuju", + "digituin": "dibegitukan", + "sekolahin": "sekolahkan", + "ajarin": "ajarkan", + "sendirii": "sendiri", + "mrnghadapi": "menghadapi", + "apalg": "apalagi", + "naka": "nakal", + "sdkt": "sedikit", + "besar2in": "besar-besarkan", + "bgmana": "bagaimana", + "s*inetr*n": "sinetron", + "mrusak": "merusak", + "mndpt": "mendapat", + "skt": "sakit", + "sebnyak": "sebanyak", + "terburu2": "terburu-buru", + "baik2": "baik-baik", + "ngumpat2": "mengumpat-ngumpat", + "nantangin": "menantang", + "ngegampar": "menggampar", + "ngelaporin": "melaporkan", + "salahin": "salahkan", + "gabisa": "tidak bisa", + "konsekwensi": "konsekuensi", + "muliany": "mulianya", + "mungki": "mungkin", + "lngsung": "langsung", + "tsb": "tersebut", + "t4": "tempat", + "bacahalah": "bacalah", + "dikerasin": "dikerasi", + "gkkan": "enggak akan", + "ngulangin": "mengulangi", + "bedaa": "beda", + "pnyaa": "punya", + "bol3h": "boleh", + "ngadu": "mengadu", + "ketauan": "ketahuan", + "dimarahin": "dimarahi", + "nyelesain": "menyelesaikan", + "troma": "trauma", + "kerasin": "kerasi", + "kamu2": "kamu-kamu", + "cewer": "jewer", + "bilanh": "bilang", + "jwban": "jawaban", + "nuduh": "menuduh", + "ngatur": "mengatur", + "rajin2": "rajin-rajin", + "bacaaaa": "baca", + "kekk": "kayak", + "jungkiir": "jungkir", + "gunaa": "guna", + "bales2in": "balas-membalas", + "orangg": "orang", + "tauuuu": "tau", + "byak": "banyak", + "kepad": "kepada", + "bantuanny": "bantuannya", + "ttinggi": "tinggi", + "perna": "pernah", + "grgr": "gara-gara", + "jari2": "jari-jari", + "pukulin": "pukuli", + "bengkak2": "bengkak-bengkak", + "komentarin": "mengomentari", + "bijak2": "bijak-bijak", + "lama2": "lama-lama", + "menurur": "menurut", + "tipe2": "tipe-tipe", + "diliat": "dilihat", + "liatlah": "lihatlah", + "teguran2": "teguran-teguran", + "gr2": "gara-gara", + "gamau": "enggak mau", + "skolah": "sekolah", + "pdahal": "padahal", + "klas": "kelas", + "tkut": "takut", + "grunya": "gurunya", + "pndah": "pindah", + "liat2": "lihat-lihat", + "masukin": "memasukkan", + "dilindungin": "dilindungi", + "kasiaannn": "kasihan", + "miring2": "miring-miring", + "tinggalin": "tinggalkan", + "kaitannha": "kaitannya", + "sud": "sudah", + "pinter": "pintar", + "rana": "ranah", + "thdp": "terhadap", + "scr": "secara", + "bhkn": "bahkan", + "justeru": "justru", + "ngatai": "mengatai", + "kln": "kalian", + "sukak": "suka", + "termuraaahhhh": "termurah", + "brkunjun": "berkunjung", + "suarranya": "suaranya", + "bner\"": "benar-benar", + "iu": "itu", + "bgussss": "bagus", + "kmaren": "kemarin", + "ambilin": "mengambil", + "biasaaaa": "biasa", + "luuuaar": "luar", + "redho": "ridho", + "rezki": "rezeki", + "kepd": "kepada", + "luaarrr": "luar", + "biasaaa": "biasa", + "jadilh": "jadilah", + "bnr": "benar", + "terhaaru": "terharu", + "orrunya": "orang tuanya", + "flm": "film", + "kalok": "kalo", + "adek2": "adek-adek", + "loncatt": "loncat", + "hampasss": "hempas", + "tehh": "teh", + "bunga2": "bunga-bunga", + "apaaan": "apaan", + "ampuuunn": "ampun", + "daaah": "dah", + "bangetttttt": "banget", + "ituh": "itu", + "cantiiikk": "cantik", + "suaranyaaaaaa": "suaranya", + "idoolaaa": "idola", + "pulaa": "pula", + "penyanyi2": "penyanyi-penyanyi", + "wiih": "wih", + "apaa": "apa", + "miriip": "mirip", + "allaahh": "allah", + "ajahhh": "saja", + "trims": "terima kasih", + "dehat": "sehat", + "salammmm": "salam", + "hari2": "hari-hari", + "sihat": "sehat", + "buruann": "buruan", + "rekomendain": "rekomendasikan", + "trussss": "terus", + "bangt": "banget", + "cepet": "cepat", + "pesen": "pesan", + "boong": "bohong", + "prawatan": "perawatan", + "mkin": "makin", + "dimao": "dimau", + "dijadiin": "dijadikan", + "silahkn": "silakan", + "kasiatnya": "khasiatnya", + "minyak\"an": "minyak-minyakan", + "suksess": "sukses", + "prodak": "produk", + "trasa": "terasa", + "minpi": "mimpi", + "bner2": "benar-benar", + "mutihin": "memutihkan", + "smster": "semester", + "kmren": "kemarin", + "ucull": "lucu", + "ngomong\"": "ngomong-ngomong", + "udehh": "sudah", + "syngnya": "sayangnya", + "dipermainin": "dipermainkan", + "kocakkk": "kocak", + "dasarrr": "dasar", + "waaaaw": "wow", + "cannnnntikkkkkkk": "cantik", + "mukax": "mukanya", + "cantikx": "cantiknya", + "fatanik": "fanatik", + "protestin": "protestan", + "kakau": "kalau", + "marh": "marah", + "emngnya": "memangnya", + "cantiknyaa": "cantiknya", + "darpada": "daripada", + "cooooooocok": "cocok", + "pkk": "pakai", + "sling": "saling", + "agama'ny": "agamanya", + "bilng": "bilang", + "prcy": "percaya", + "lurusin": "meluruskan", + "biri": "biru", + "luco": "lucu", + "ultha": "ultah", + "batikk": "batik", + "arab2": "arab-arab", + "luchu": "lucu", + "baguss": "bagus", + "sedihhh": "sedih", + "syang": "sayang", + "ngasi": "mengasih", + "naaaak": "nak", + "astafirullah": "astagfirullah", + "peragain": "peragakan", + "shayyy": "say", + "anjaayy": "anjay", + "shay": "say", + "ngilerrrr": "mengiler", + "woyyy": "woy", + "shaaaay": "say", + "hempaass": "hempas", + "sayyy": "say", + "shayy": "say", + "syantiek": "cantik", + "njay": "anjay", + "hehh": "eh", + "cantik2": "cantik-cantik", + "kam": "kamu", + "ingeet": "ingat", + "sblh": "sebelah", + "nawarin": "menawari", + "amiiiiin": "amin", + "jauhhh": "jauh", + "waaahhh": "wah", + "ajakin": "mengajak", + "mntn": "mantan", + "ade": "adek", + "luhhh": "lu", + "yooookkk": "yuk", + "jrnk": "jarang", + "jelas\"": "jelas-jelas", + "kayax": "kayak", + "dblkng": "dibelakang", + "anjirr": "anjir", + "mirippp": "mirip", + "sangattt": "sangat", + "iyap": "iya", + "mayann": "lumayan", + "autuhh": "tau tuh", + "atutt": "takut", + "ketara": "tertara", + "giniii": "begini", + "mbaaass": "mbak", + "bund": "bunda", + "bangeeettt": "banget", + "almh": "almarhumah", + "temenkuuh": "temanku", + "suami'y": "suaminya", + "bngeeeetttt": "banget", + "entr": "entar", + "kyax": "kayak", + "cuantikkk": "cantik", + "mksdnya": "maksudnya", + "kaaaaaaaaaakkkkkkkkkkk": "kak", + "kucing'y": "kucingnya", + "nama'in": "namai", + "panggilan'ya": "panggilannya", + "panggilan'y": "panggilannya", + "z": "saja", + "kalw": "kalau", + "tips2": "tips-tips", + "alu": "aku", + "ngerrrriiii": "ngeri", + "ngriii": "mengiri", + "amatt": "amat", + "smpai": "sampai", + "kox": "kok", + "tmbah": "tambah", + "cntikk": "cantik", + "pkee": "pakai", + "smkin": "semakin", + "amiiiiinnn": "amin", + "bgmna": "bagaimana", + "ssorg": "seseorang", + "hdpn": "hadapan", + "foto2": "foto-foto", + "mnambh": "menambah", + "ktaatan": "ketaatan", + "stju": "setuju", + "cantikkkkkk": "cantik", + "dilepas2": "dilepas-lepas", + "tehhh": "teh", + "istiqomahhh": "istikamah", + "tehhhh": "teh", + "prodaknya": "produknya", + "imutt": "imut", + "amu": "kamu", + "nahh": "nah", + "mauuuuuuu": "mau", + "jilbb": "jilbab", + "tmn": "teman", + "ndut": "gendut", + "ngecek": "mengecek", + "psti": "pasti", + "penasarn": "penasaran", + "cuyy": "cuy", + "astauviruloh": "astagfirullah", + "paaak": "pak", + "diajarin": "diajari", + "gawl": "gaul", + "kecr": "kece", + "iih": "ih", + "laaaah": "lah", + "aned": "banget", + "gingsungny": "gingsulnya", + "blh": "boleh", + "cariin": "carikan", + "kenceng": "kencang", + "cuanti": "cantik", + "mbanya": "mbaknya", + "knlan": "kenalan", + "mbaakk": "mbak", + "mrka": "mereka", + "nyusahhin": "menyusahkan", + "byaran": "bayaran", + "wlawpun": "walaupun", + "sedangkn": "sedangkan", + "tnpa": "tanpa", + "bnggain": "banggakan", + "nyusahin": "menyusahkan", + "malu2in": "malu-malui", + "dibanggain": "dibanggakan", + "wlau": "walau", + "komenan": "komentar", + "bangga\"in": "membangga-banggakan", + "krya": "karya", + "bilang\"": "bilang-bilang", + "tinggall": "tinggal", + "dmna": "dimana", + "dripda": "daripada", + "bangga2in": "membangga-banggakan", + "dibnggain": "dibanggakan", + "karya2": "karya-karya", + "drimana": "darimana", + "ngepake'": "memakai", + "gksalah": "enggak salah", + "nuntut": "menuntut", + "ngebiarin": "membiarkan", + "bhas": "bahas", + "lagu'y": "lagunya", + "jadiin": "jadikan", + "diem": "diam", + "nyampah": "menyampah", + "mba\"": "mbak-mbak", + "bukan'a": "bukannya", + "tappi": "tapi", + "kalli": "kali", + "pacara": "pacaran", + "ngerebut\"": "merebut-rebut", + "lluu": "lu", + "mbaa": "mbak", + "ngerussak": "merusak", + "bela\"in": "membela-bela", + "bellain": "membela", + "kallo": "kalo", + "coment": "komentar", + "dewassa": "dewasa", + "comnet": "komen", + "pikir\"": "pikir-pikir", + "kaya'a": "kayanya", + "kuyyy": "yuk", + "kitaa": "kita", + "sgtu'y": "sebegitunya", + "ngebelain": "membela", + "tpii": "tapi", + "karya\"": "karya-karya", + "do": "di", + "cali": "caci", + "penghrgaan": "penghargaan", + "ajjh": "saja", + "lbihh": "lebih", + "baikk": "baik", + "drii": "dari", + "artist\"": "artis-artis", + "maki\"": "maki-maki", + "sendirinnya": "sendirinya", + "stidaknya": "setidaknya", + "may": "mau", + "trbaikk": "terbaik", + "punn": "pun", + "mengikutii": "mengikuti", + "atassan": "atasan", + "klauu": "kalau", + "jngnn": "jangan", + "so": "sok", + "sendirinyya": "sendirinya", + "frestasiii": "prestasi", + "mst": "mesti", + "add": "ada", + "see": "sih", + "sibukx": "sibuknya", + "in": "ini", + "katanyaa": "katanya", + "lawaaaas": "lawas", + "kocaaaak": "kocak", + "syng": "sayang", + "adenya": "adeknya", + "pkoknyaa": "pokoknya", + "dsana": "disana", + "dua2nya": "dua-duanya", + "sichhhhhhhhhh": "sih", + "nampak": "menampak", + "tetehh": "teteh", + "wooowww": "wow", + "cobain": "mencoba", + "muga": "moga", + "kenalin": "memperkenalkan", + "janka": "jangka", + "wawwwww": "wow", + "waaaww": "wow", + "waaawwww": "wow", + "mantapp": "mantap", + "cobaaa": "coba", + "kageet": "kaget", + "bangettttttt": "banget", + "oplas": "operasi plastik", + "nambahh": "bertambah", + "paripurnahh": "paripurna", + "wanitah": "wanita", + "indonesiaah": "indonesia", + "cumn": "cuman", + "gegra": "gegara", + "brita": "berita", + "fotbar": "foto bareng", + "wajarrr": "wajar", + "sngt": "sangat", + "wk": "waktu", + "bingiittt": "banget", + "nampar": "menampar", + "tukan": "tukang", + "jatoh": "jatuh", + "muka\"": "muka-muka", + "cantik2nya": "cantik-cantiknya", + "sdgkn": "sedangkan", + "apa\"": "apa-apa", + "mancungin": "memancungkan", + "idung": "hidung", + "kusem": "kusam", + "drpada": "daripada", + "kmar": "kamar", + "diemm": "diam", + "tnggu": "tunggu", + "ketawain": "mentertawakan", + "yaelahhh": "alah", + "sikap\"": "sikap-sikap", + "bales": "balas", + "ampuun": "ampun", + "ngejatuhin": "manjatuhkan", + "ngelakuin": "melakukan", + "gedeg": "gedek", + "muka2": "muka-muka", + "eey": "eh", + "kslahan": "kesalahan", + "perhatiam": "perhatian", + "dibiarin": "dibiarkan", + "sbnrny": "sebenarnya", + "trjd": "terjadi", + "mkir": "memikir", + "hny": "hanya", + "apalai": "apalagi", + "bkal": "bakal", + "saat2": "saat-saat", + "nyadar": "menyadar", + "bka": "buka", + "berani2": "berani-berani", + "lucuuuuu": "lucu", + "pipi'y": "pipinya", + "gemmeeezzz": "gemas", + "sukam": "suka", + "menghilangkn": "menghilangkan", + "mnjdkan": "menjadikan", + "mudah2n": "mudah-mudahan", + "stlh": "setelah", + "soalx": "soalnya", + "keche": "kece", + "badaii": "badai", + "btuh": "butuh", + "ktanya": "katanya", + "bebrp": "beberapa", + "sblum": "sebelum", + "santab": "santap", + "salehah": "salihah", + "mikirin": "memikirkan", + "subhannallah": "subhanallah", + "konsul": "konsultasi", + "pengalam": "pengalaman", + "poko": "pokok", + "trun": "turun", + "kuninggg": "kuning", + "pinkkkk": "pink", + "warnany": "warnanya", + "seger": "segar", + "senangat": "semangat", + "msknya": "masaknya", + "kerennnn": "keren", + "ciptain": "menciptakan", + "bagussss": "bagus", + "sayangkuh": "sayangku", + "gans": "ganteng", + "kren": "keren", + "babget": "banget", + "dihh": "dih", + "jadu": "jadi", + "ksyngn": "kesayangan", + "haru": "harus", + "ngingetin": "mengingatkan", + "narik": "menarik", + "orang\"": "orang-orang", + "gokilll": "gila", + "astagaaaa": "astaga", + "hancuuuuuy": "hancur", + "mattihhh": "mati", + "beudd": "banget", + "termiribkan": "termiripkan", + "dngerin": "mendengarkan", + "brrti": "berarti", + "wenakk": "enak", + "nganteng": "ganteng", + "abanggg": "abang", + "seksii": "seksi", + "gtuh": "begitu", + "jadii": "jadi", + "emeezzz": "gemas", + "amohhh": "kamu", + "maau": "mau", + "eyke": "aku", + "gilakkkkk": "gila", + "awk": "awak", + "duhhhhh": "duh", + "wowww": "wow", + "liaatt": "lihat", + "iniiiii": "ini", + "aloh": "allah", + "ngettt": "banget", + "maso": "masa", + "brubah": "berubah", + "pelastik": "plastik", + "keak": "kayak", + "dengerin": "mendengarkan", + "beud": "banget", + "pahamin": "pahami", + "takuuuuut": "takut", + "ahahahanjirrr": "anjir", + "jogetin": "berjoget", + "shaay": "say", + "boker": "berak", + "pacarnyaa": "pacarnya", + "siapaa": "siapa", + "matii": "mati", + "ketawaa": "ketawa", + "wanjiir": "anjir", + "bw": "bawa", + "minta": "meminta", + "bnran": "benaran", + "sgla": "segala", + "uitnya": "duitnya", + "beliiin": "belikan", + "yaaah": "ya", + "sekaeang": "sekarang", + "tgk": "tengok", + "tanyain": "tanyakan", + "jatuhnyaa": "jatuhnya", + "banyakin": "membanyaki", + "tipu2nya": "tipu-tipunya", + "doaang": "doang", + "gituan": "begituan", + "kaj": "kak", + "wahhhhhh": "wah", + "iiiiiiiii": "ih", + "kaliiiiiii": "kali", + "melebihin": "melebihi", + "gakpapa": "enggak apa-apa", + "kalogak": "kalo enggak", + "sm2": "sama-sama", + "ndukung": "mendukung", + "kaaaaa": "kak", + "aamiinnn": "amin", + "skluarga": "sekeluarga", + "suaminga": "suaminya", + "berhijap": "berhijab", + "smgaa": "semoga", + "istiqamah": "istikamah", + "cuaaantikk": "cantik", + "muraaaaah": "murah", + "baraang": "barang", + "aamiiin": "amin", + "bets": "banget", + "alhamdulillaaaaah": "alhamdulillah", + "sembuhin": "menyembuhkan", + "pengrusak": "perusak", + "ngoceh": "mengoceh", + "aduhhh": "aduh", + "hpus": "hapus", + "pjang": "pajang", + "bdampingan": "berdampingan", + "kaga": "kagak", + "uih": "uh", + "serem\"": "seram-seram", + "happi\"": "hepi-hepi", + "bgtuu": "begitu", + "mff": "maaf", + "ktwa": "ketawa", + "trlalu": "terlalu", + "jjur": "jujur", + "ngmg": "mengomong", + "semogaa": "semoga", + "kepelesett": "terpeleset", + "geeelaakk": "gila", + "bolang": "bocah petualang", + "pkek": "pakai", + "lagihh": "lagi", + "hedeuuhh": "aduh", + "stress": "stres", + "saltummmm": "salah kostum", + "mboizzz": "bois", + "suda": "sudah", + "kayaq": "kayak", + "ema": "emak", + "dirumahny": "dirumahnya", + "yuh": "tuh", + "rmbt": "rambut", + "apus": "hapus", + "siapayahh": "siapa ya", + "kostom": "kostum", + "gnii": "begini", + "atuu": "satu", + "pipinyaaa": "pipinya", + "bungkuuuuuuuuusss": "bungkus", + "iiiihhhhhj": "ih", + "emeessss": "gemas", + "pipine": "pipinya", + "glembeeeer": "gelambir", + "lucunaaa": "lucunya", + "cubanget": "lucu banget", + "pengenn": "pengin", + "unyuuk": "unyu", + "dedek": "adek", + "lucunyah": "lucunya", + "mantaaap": "mantap", + "keduaa": "kedua", + "trsrh": "terserah", + "ksih": "kasih", + "seterong": "setrong", + "teyuss": "terus", + "dimasukin": "dimasukkan", + "jelekin": "menjelekkan", + "jahatin": "menjahati", + "tai": "tahi", + "gtw": "enggak tau", + "qo": "kok", + "nulis": "menulis", + "nyebutin": "menyebutkan", + "ngertiin": "mengerti", + "rasaiin": "rasai", + "gakusah": "enggak usah", + "disni": "disini", + "sabarrr": "sabar", + "semangaatttt": "semangat", + "nyalahin": "menyalahkan", + "bedainnya": "membedakannya", + "keknya": "kayaknya", + "krus": "kurus", + "elaaaahhhh": "alah", + "cogan": "cowok ganteng", + "tap8": "tapi", + "pandanginya": "memandanginya", + "kenaliiin": "kenalkan", + "banyak2": "banyak-banyak", + "yo": "ya", + "suko": "suka", + "niih": "nih", + "sihhhh": "sih", + "sebaik\"": "sebaik-baik", + "segtu": "sebegitu", + "ngebenci": "membenci", + "bakar2in": "bakar-bakari", + "padhe": "pakde", + "yya": "ya", + "atw": "atau", + "dosa2mu": "dosa-dosamu", + "maafin": "memaafi", + "gile": "gila", + "ujung2": "ujung-ujung", + "dalem2": "dalam-dalam", + "kgak": "kagak", + "bahas2": "bahas-bahas", + "ud": "sudah", + "woles": "selow", + "hianatin": "khianati", + "halangin": "halangi", + "iyuh": "ih", + "iyuuuhhhh": "ih", + "gaje": "enggak jelas", + "gabakal": "enggak bakal", + "adapin": "menghadapi", + "ngehakimi": "menghakimi", + "ngejuangin": "memperjuangkan", + "dipojokin": "dipojokkan", + "bacotin": "bacoti", + "berati": "berarti", + "kebangetak": "kebangetan", + "tangkep": "tangkap", + "bt": "buat", + "sadis2": "sadis-sadis", + "maksir": "menaksir", + "hebatt": "hebat", + "pndang": "pandang", + "pmbelajaran": "pembelajaran", + "ngejelek": "menjelekkan", + "maslah": "masalah", + "mmperbaiki": "memperbaiki", + "nahan": "menahan", + "kafeh": "kafe", + "remang2": "remang-remang", + "mlh": "malah", + "tinggl": "tinggal", + "dirmh": "dirumah", + "iyuuhhh": "ih", + "ktika": "ketika", + "dipuja2": "dipuja-puja", + "ngrasain": "merasai", + "ityl": "itil", + "hbs": "habis", + "marah2": "marah-marah", + "wenak": "enak", + "ngarang": "mengarang", + "kelas'a": "kelasnya", + "hadeeehhh": "aduh", + "kecil\"": "kecil-kecil", + "gtu\"an": "begitu-gituan", + "gmanapun": "bagaimanapun", + "kr": "kira", + "nenek2": "nenek-nenek", + "taux": "taunya", + "urat2nya": "urat-uratnya", + "ketarik": "tertarik", + "ngorbanin": "mengorbankan", + "eksprsi": "ekspresi", + "gitulah": "begitulah", + "foto\"": "foto-foto", + "mamas": "mas", + "bandingin": "bandingkan", + "tua2": "tua-tua", + "terus2an": "terus-terusan", + "waduhh": "waduh", + "bawa\"": "bawa-bawa", + "badingkan": "membandingkan", + "ngurus": "mengurus", + "lanjutin": "lanjutkan", + "ributin": "meributkan", + "brantem": "berantem", + "belain": "membela", + "lipetan": "lipatan", + "nenek\"": "nenek-nenek", + "bneraan": "benaran", + "kageeet": "kaget", + "sangaaattt": "sangat", + "rmbuut": "rambut", + "nt": "nanti", + "walik": "balik", + "oma2": "oma-oma", + "kellen": "kalian", + "nyerocos": "mencerocos", + "ibuk2": "ibu-ibu", + "ngomenin": "mengomentari", + "iseng2": "iseng-iseng", + "niyyyyhhhh": "nih", + "sexi2": "seksi-seksi", + "blik": "balik", + "kenape": "kenapa", + "ladenin": "meladeni", + "skit": "sakit", + "hat": "hati", + "jilbabnye": "jilbabnya", + "ledkin": "meledeki", + "digabtikan": "digantikan", + "jng": "jangan", + "ngumung": "mengomong", + "ilang-in": "hilangkan", + "prinadi": "pribadi", + "luh": "lu", + "yep": "ya", + "helooooo": "halo", + "loe": "lo", + "palingann": "palingan", + "berjinah": "berzina", + "munaafikk": "munafik", + "urusn": "urusan", + "saudra": "saudara", + "trganggu": "terganggu", + "bhijab": "berhijab", + "lepasin": "melepas", + "smentara": "sementara", + "waduhhh": "waduh", + "ursan": "urusan", + "sudab": "sudah", + "dwsa": "dewasa", + "rpt2": "repot-repot", + "ngaji": "mengaji", + "nmr": "nomor", + "romantiz": "romantis", + "murmer": "murah meriah", + "pensaran": "penasaran", + "baru2": "baru-baru", + "yuukk": "yuk", + "kitaaa": "kita", + "lainnyaa": "lainnya", + "perbulannn": "perbulan", + "penyebab2": "penyebab-penyebab", + "nyupir": "menyopir", + "steess": "stres", + "kwatir": "kawatir", + "tengah2": "tengah-tengah", + "duetin": "duet", + "mudaa": "muda", + "sebenernya": "sebenarnya", + "silsturahmi": "silaturahmi", + "dijkt": "dijakarta", + "cums": "cuma", + "undangan2": "undangan-undangan", + "past": "pasti", + "ayukk": "yuk", + "ngucapin": "mengucapkan", + "filmny": "filmnya", + "ntn": "menonton", + "nobar": "menonton bareng", + "kemariin": "kemarin", + "knpp": "kenapa", + "lggi": "lagi", + "gaprgi": "enggak pergi", + "aaahh": "ah", + "pa2": "apa-apa", + "dtunggu": "ditunggu", + "masa2": "masa-masa", + "kepikir": "terpikir", + "seruuu": "seru", + "emeshh": "gemas", + "kaya": "seperti", + "astagaaaaaa": "astaga", + "pacarn": "pacaran", + "ktg": "kita", + "pacaranya": "pacarannya", + "mbaak": "mbak", + "ngalah": "mengalah", + "de": "deh", + "pacarane": "pacarannya", + "rusakkk": "rusak", + "yaaampunnn": "ya ampun", + "tuwek": "tua", + "mukae": "mukanya", + "ultahe": "ultahnya", + "kliatanya": "kelihatannya", + "pcaranya": "pacarannya", + "bermaksut": "bermaksud", + "tampialannya": "tampilannya", + "nikaah": "nikah", + "anak\"": "anak-anak", + "pankapan": "kapan-kapan", + "ngumbar": "mengumbar", + "dhpusin": "dihapuskan", + "istiqomah": "istikamah", + "mnutup": "menutup", + "bngatt": "banget", + "dii": "di", + "ooh": "oh", + "hapusin": "hapuskan", + "lohhh": "loh", + "ngeuh": "engah", + "sneng": "senang", + "mutusin": "memutuskan", + "ngapus": "menghapus", + "awl": "awal", + "hrus": "harus", + "bngkit": "bangkit", + "mncoba": "mencoba", + "wahhh": "wah", + "mutuain": "memutuskan", + "smw": "semua", + "geuliiiiiiis": "geulis", + "euyyyyy": "euy", + "uuh": "uh", + "ngirim": "mengirim", + "ngidam": "mengidam", + "waah": "wah", + "sampis": "sampah habis", + "maapin": "maafi", + "ngebala": "bala", + "mampuussss": "mampus", + "diihhhh": "dih", + "hrusnya": "harusnya", + "sabal": "sabar", + "udahlah": "sudahlah", + "kauuuu": "kau", + "sabaro": "sabar", + "eehhh": "eh", + "iiii": "ih", + "kreative": "kreatif", + "bangrt": "banget", + "azekkkk": "asik", + "kuaaatt": "kuat", + "bhet": "banget", + "inggrisny": "inggrisnya", + "dsna": "disana", + "iyaap": "iya", + "mksih": "terima kasih", + "nga": "tidak", + "canyk": "cantik", + "prodac": "produk", + "nyesell": "menyesal", + "ciyusss": "serius", + "cyin": "cinta", + "bangetttttttt": "banget", + "sexi": "seksi", + "bundaaaa": "bunda", + "bangget": "banget", + "bettt": "banget", + "cantiiillx": "cantik", + "brapa": "berapa", + "alaami": "alami", + "sabaarrr": "sabar", + "cantiiiikk": "cantik", + "bangeeeeetttt": "banget", + "cannntikk": "cantik", + "banggett": "banget", + "thnan": "tahunan", + "cuantik": "cantik", + "segerr": "segar", + "wouww": "wow", + "cantiiikkk": "cantik", + "pen": "pengin", + "catokin": "catok", + "ikut2an": "ikut-ikutan", + "comment2nya": "komentar-komentarnya", + "teman2": "teman-teman", + "besar2an": "besar-besaran", + "knsul": "konsultasi", + "brhasil": "berhasil", + "hilangin": "menghilangkan", + "kontak2": "kontak-kontak", + "dptnya": "dapatnya", + "applikasi": "aplikasi", + "kereeeeen": "keren", + "bngeet": "banget", + "gemesinnnnn": "menggemaskan", + "ongkiir": "ongkir", + "ajaibbbbb": "ajaib", + "saayyyyy": "say", + "lohhhh": "loh", + "ganteung": "ganteng", + "bhgia": "bahagia", + "iiiiiiiiii": "ih", + "lucuukk": "lucu", + "keciil": "kecil", + "yakinn": "yakin", + "pentass": "pentas", + "ngarep": "mengharap", + "lucu2": "lucu-lucu", + "testinya": "testimoninya", + "mengirusi": "mengurusi", + "khdpn": "kehidupan", + "ngelawan": "melawan", + "betuuuuulllllllllllllllll": "betul", + "bisax": "bisanya", + "klmbutan": "kelembutan", + "seburuk2": "seburuk-buruk", + "diingetin": "diingatkan", + "ngeriiiiii": "ngeri", + "nich": "nih", + "akhlah": "akhlak", + "elo2": "lo-lo", + "mandiin": "memandikan", + "nguburin": "menguburkan", + "ngebedain": "membedakan", + "urusanny": "urusannya", + "tuhanny": "tuhannya", + "brtngkar": "bertengkar", + "pndapat": "pendapat", + "looh": "loh", + "sharusnya": "seharusnya", + "mngkannya": "mangkanya", + "nyia2in": "menyia-nyiakan", + "brarti": "berarti", + "mndpat": "mendapat", + "kpuasan": "kepuasan", + "sgampang": "segampang", + "mlepaskan": "melepaskan", + "trll": "terlalu", + "sndr": "sendiri", + "sellow": "selow", + "sbbrnya": "sebenarnya", + "kasar2": "kasar-kasar", + "semangattt": "semangat", + "testi": "testimoni", + "banyaak": "banyak", + "bujukk": "bujuk", + "d.apa": "diapa", + "apain": "apai", + "dsar": "dasar", + "benetaran": "benaran", + "bneran": "benaran", + "si": "sih", + "kraya": "karya", + "brg": "bareng", + "ngebantu": "membantu", + "janjutin": "lanjutkan", + "kepppoin": "kepoi", + "recommendasi": "rekomendasi", + "btulan": "betulan", + "kha": "kah", + "s": "si", + "tutupin": "menutupi", + "wik": "wig", + "ithu": "itu", + "botakin": "botaki", + "botakkkk": "botak", + "bnrn": "benaran", + "week": "wig", + "gawul": "gaul", + "modes": "modis", + "brayyy": "bray", + "kcantikan": "kecantikan", + "bronis": "brondong manis", + "gapunya": "enggak punya", + "bkln": "bakalan", + "kecantikanga": "kecantikannya", + "hiii": "hai", + "piiiilih": "pilih", + "deeh": "deh", + "zama": "zaman", + "gnti": "ganti", + "nmnya": "namanya", + "tapo": "tapi", + "gkk": "tidak", + "petinggi2": "petinggi-petinggi", + "wota2": "wota-wota", + "indefendet": "independen", + "tempat2": "tempat-tempat", + "taik": "tahi", + "yuuuukkk": "yuk", + "dituutup": "ditutup", + "ngayal": "mengkhayal", + "smvk": "sempak", + "knyataan": "kenyataan", + "njrittt": "anjir", + "selmt": "selamat", + "taz": "atas", + "klhran": "kelahiran", + "pertma": "pertama", + "kelahuran": "kelahiran", + "pertamany": "pertamanya", + "mamax": "mamanya", + "ankx": "anaknya", + "cantip": "cantik", + "klahiran": "kelahiran", + "slmat": "selamat", + "mmbanggakan": "membanggakan", + "ortuny": "orang tuanya", + "selmat": "selamat", + "bnda": "bunda", + "nyah": "nya", + "prtamax": "pertamanya", + "moqa": "moga", + "yq": "yang", + "brbakti": "berbakti", + "gewlaaaaa": "gila", + "basodaraa": "bersaudara", + "hargs": "harga", + "perhatiin": "perhatikan", + "abal2": "abal-abal", + "bantuim": "bantu", + "adl": "adalah", + "pelwak": "pelawak", + "suces": "sukses", + "sucses": "sukses", + "garemin": "garami", + "gapake": "enggak pakai", + "ngiringan": "mengiringi", + "karir.a": "karirnya", + "aamin": "amin", + "teruz": "terus", + "kemarn": "kemarin", + "recomendasinya": "rekomendasinya", + "promoo": "promo", + "ongkirr": "ongkir", + "mskasih": "terima kasih", + "wowwwww": "wow", + "seeeemua": "semua", + "batesin": "batasi", + "bijin": "bikin", + "ngeidealin": "mengidealkan", + "bhn": "bahan", + "gasalah": "enggak salah", + "brsma": "bersama", + "le": "boleh", + "ugha": "juga", + "semuaaa": "semua", + "heeee": "hai", + "rame2": "rame-rame", + "yam": "ya", + "kartunin": "kartun", + "kyaknya": "kayaknya", + "abez": "habis", + "baaaaaang": "bang", + "ganteeeeng": "ganteng", + "entahhh": "entah", + "gimanaaaa": "bagaimana", + "duuhh": "duh", + "gantengnyaaaa": "gantengnya", + "kpn2": "kapan-kapan", + "pans": "fan", + "kuliyh": "kuliah", + "gantengkuuuuuu": "gantengku", + "atassss": "atas", + "orgny": "orangnya", + "owalaah": "walah", + "aslinyo": "aslinya", + "alissnyaa": "alisnya", + "masaaaa": "masa", + "komuk": "muka", + "kalahnye": "kalahnya", + "iyaaaa": "iya", + "fakultass": "fakultas", + "smrg": "semarang", + "unyu2": "unyu-unyu", + "koq": "kok", + "yyaaa": "ya", + "insonniaaa": "insomnia", + "adekx": "adeknya", + "azaab": "azab", + "gksi": "enggak sih", + "greged": "greget", + "alamiii": "alami", + "ktemu": "ketemu", + "ditayangin": "ditayangkan", + "ms\"": "masa-masa", + "meranin": "memerankan", + "nontonny": "menontonnya", + "rekomendasiiin": "merekomendasikan", + "sista2": "sista-sista", + "agan2": "agan-agan", + "ngg": "tidak", + "nanget": "banget", + "ngeditnya": "mengeditnya", + "himana": "bagaimana", + "kekgini": "kayak begini", + "subhanaallahh": "subhanallah", + "sangat2": "sangat-sangat", + "terharuuuu": "terharu", + "kata\"": "kata-kata", + "subhanallahhh": "subhanallah", + "tlsn": "tulisan", + "nagiss": "menangis", + "aneut": "banget", + "trrharu": "terharu", + "subhnallah": "subhanallah", + "allahh": "allah", + "suamiii": "suami", + "dcintai": "dicintai", + "klrga": "keluarga", + "liat'y": "lihatnya", + "lggeng": "langgeng", + "romantisny": "romatisnya", + "duhhh": "duh", + "pcar": "pacar", + "kakanga": "kakaknya", + "slw": "selow", + "cpa": "siapa", + "akuu": "aku", + "aelah": "alah", + "orabg": "orang", + "mukanyaa": "mukanya", + "kakakny": "kakaknya", + "woooyy": "woy", + "ngelucu": "melucu", + "bukak": "buka", + "mato": "mata", + "kareno": "karena", + "cino": "cina", + "munafikk": "munafik", + "meker": "mikir", + "indoneaia": "indonesia", + "benarin": "membenarkan", + "jaadii": "jadi", + "apo": "apa", + "nyo": "nya", + "miara": "memiara", + "jalanani": "menjalani", + "dalil2": "dalil-dalil", + "sunah2": "sunah-sunah", + "sdah": "sudah", + "benda2": "benda-benda", + "bilang2": "bilang-bilang", + "emangnya": "memangnya", + "rumpiin": "rumpi", + "hotak": "botak", + "ikut2": "ikut-ikut", + "jawabanny": "jawabannya", + "ajj": "saja", + "mikir2": "memikir-mikir", + "melihara": "memelihara", + "oooh": "oh", + "hidupx": "hidupnya", + "ngefans": "fan", + "lsg": "langsung", + "uztad": "ustad", + "anjingnyaa": "anjingnya", + "pegang2": "pegang-pegang", + "ilmh": "ilmu", + "menasehati": "menasihati", + "suka2": "suka-suka", + "kelus": "kali", + "hubngan": "hubungan", + "koclak": "kocak", + "bju": "baju", + "cinggirnyaaaa": "cinggirnya", + "yahhhh": "ya", + "miripppp": "mirip", + "sedikitlaahh": "sedikitlah", + "miriplahhhhhh": "miriplah", + "syediih": "sedih", + "mirp": "mirip", + "baju.a": "bajunya", + "wiiiihhhhhhhhh": "wih", + "gantheng": "ganteng", + "synk": "sayang", + "ngfans": "fan", + "miriiippp": "mirip", + "idungnya": "hidungnya", + "wooow": "wow", + "panglingg": "pangling", + "artisnyaa": "artisnya", + "dtggu": "ditunggu", + "trnyata": "ternyata", + "bgtttttttt": "banget", + "rapih": "rapi", + "buuun": "bun", + "iyaappps": "iya", + "sukse": "sukses", + "nih2": "nih-nih", + "baruu": "baru", + "kecptan": "kecepatan", + "rmbutnya": "rambutnya", + "giti": "begitu", + "penganten": "pengantin", + "wrn": "warna", + "ne": "ini", + "cantikkkkk": "cantik", + "fans": "fan", + "loksyut": "lokasi syuting", + "amfun": "ampun", + "k'": "kak", + "asuuu": "asu", + "kuu": "ku", + "dsini": "disini", + "dibls": "dibalas", + "ceklist": "ceklis", + "terusss": "terus", + "tlvn": "telepon", + "trkrim": "terkirim", + "kelaluan": "kelakuan", + "guanteng": "ganteng", + "cucok": "cocok", + "c": "sih", + "gueee": "gue", + "semangatt": "semangat", + "haduh": "aduh", + "mlz": "malas", + "emak2": "emak-emak", + "tante2": "tante-tante", + "kepoh": "kepo", + "jalanin": "menjalani", + "repong": "repot", + "ngrusin": "mengurusi", + "iy": "iya", + "dnk": "dong", + "senyumna": "senyumnya", + "ngejelek2in": "menjelek-jelekkan", + "hotx": "hotnya", + "cwnya": "ceweknya", + "mbaaahhh": "mbah", + "pasangn": "pasangan", + "sttus": "status", + "aama": "sama", + "hadeuuhhhh": "aduh", + "lebar2": "lebar-lebar", + "sstu": "sesuatu", + "serasiii": "serasi", + "cocokx": "cocoknya", + "emakx": "emaknya", + "iniiiiiiiiiiiiiii": "ini", + "cocookk": "cocok", + "kll": "kalo", + "arghhh": "ah", + "ungkapin": "ungkapkan", + "merrid": "merit", + "neh": "nih", + "cocokkkkk": "cocok", + "yaolohh": "ya allah", + "aee": "saja", + "naggung": "menanggung", + "battt": "banget", + "am": "sama", + "tah": "teh", + "luthuna": "lucunya", + "astagaaa": "astaga", + "suaraynyaa": "suaranya", + "duanyaa": "duanya", + "anjayyyy": "anjay", + "coeggg": "coeg", + "ancur": "hancur", + "koplak": "kocak", + "uaaasssiiiik": "asik", + "boss": "bos", + "nyubit": "mencubit", + "dede": "adek", + "gemez": "gemas", + "tium": "cium", + "gemeeezzzz": "gemas", + "ppa": "papa", + "papahhhnya": "papahnya", + "duuuhh": "duh", + "ginting": "ganteng", + "bingiiittzz": "banget", + "iiihh": "ih", + "dedeknya": "adeknya", + "hadeeuh": "aduh", + "geneeee": "begini", + "yooow": "ya", + "gemezz": "gemas", + "gayax": "gayanya", + "infpyunnnn": "ampun", + "paraaah": "parah", + "papiii": "papi", + "kamunyaa": "kamunya", + "ngambek": "mengambek", + "haduuuh": "aduh", + "kapaan": "kapan", + "nginjek": "menginjak", + "putihnyaa": "putihnya", + "ituuuu": "itu", + "omaaaaaaakk": "oma", + "loncattt": "loncat", + "didatengin": "didatangi", + "abg": "abang", + "kmariiin": "kemarin", + "ayoklaa": "ayolah", + "jelasssss": "jelas", + "kaliiin": "kalian", + "ujunggg": "ujung", + "kaakaaaa": "kakak", + "srruu": "seru", + "kannn": "kan", + "jmpaaaa": "jumpa", + "yukkk": "yuk", + "haii": "hai", + "syukaaakkk": "suka", + "dengeeriin": "dengarkan", + "yakaliiiii": "yakali", + "atuhh": "atuh", + "ngertii": "mengerti", + "apalgi": "apalagi", + "akuh": "aku", + "mesrany": "mesranya", + "jendes": "janda", + "ngggk": "tidak", + "coccokk": "cocok", + "msa": "masa", + "jendesss": "janda", + "klepek2": "klepek-klepek", + "bngtttt": "banget", + "ponakn": "ponakan", + "tantee": "tante", + "gadiss": "gadis", + "tpy": "tapi", + "jnda": "janda", + "bngy": "banget", + "bujngn": "bujangan", + "ngp": "mengapa", + "padetan": "padatan", + "bawa2": "bawa-bawa", + "nyobain": "mencoba", + "pcaran": "pacaran", + "or": "orang", + "tdak": "tidak", + "prcuma": "percuma", + "langgengggg": "langgeng", + "sekaliiiii": "sekali", + "anehhh": "aneh", + "hayal": "khayal", + "bedua": "berdua", + "bapeerr": "bawa perasaan", + "keinget": "teringat", + "merekak": "mereka", + "kreeenn": "keren", + "abisss": "habis", + "kocaaaakkk": "kocak", + "abiiiss": "habis", + "pass": "pas", + "personill": "personil", + "lengkapp": "lengkap", + "ampuuun": "ampun", + "iiihhhh": "ih", + "flim.a": "filmnya", + "trusan.a": "terusannya", + "tdung": "tudung", + "gangguin": "mengganggu", + "lgkah2": "langkah-langkah", + "taw": "tau", + "cntiiikkk": "cantik", + "tegaar": "tegar", + "sktlrang": "sekarang", + "kangeeeennnnn": "kangen", + "sjk": "sejak", + "tambh": "tambah", + "nangiis": "menangis", + "nyaaaaaaaaa": "nya", + "bundaa": "bunda", + "duuhhhhh": "duh", + "gaada": "enggak ada", + "malsudnya": "maksudnya", + "lluuccuu": "lucu", + "siappa": "siapa", + "martabakk": "martabak", + "skali2": "sekali-kali", + "kueren2": "keren-keren", + "abgnya": "abangnya", + "warkop": "warung kopi", + "hayuu": "hayo", + "kenapaa": "kenapa", + "ilang2an": "hilang-hilangan", + "ayooook": "ayo", + "astagaaaaaaaa": "astaga", + "inihhh": "ini", + "oom": "om", + "mansetan": "bermanset", + "cadasss": "cadas", + "kereeeeennn": "keren", + "temen\"": "teman-teman", + "telefisi": "televisi", + "nikmatin": "menikmati", + "mantappp": "mantap", + "mbahh": "mbah", + "giitu": "begitu", + "mtanya": "matanya", + "cpit": "sipit", + "dee": "dek", + "sumvahh": "sumpah", + "tamvann": "tampan", + "yap": "ya", + "guaaaaaaa": "gua", + "capa": "sapa", + "ngakakkk": "mengakak", + "liaat": "lihat", + "gantweng": "ganteng", + "anjjjjjjjj": "anjing", + "gakwad": "enggak kuat", + "seru2": "seru-seru", + "iniii": "ini", + "favoriteeee": "favorit", + "adaa": "ada", + "dinyanyiin": "dinyanyikan", + "seruu": "seru", + "nyengir": "menyengir", + "temennya": "temannya", + "klk": "kelak", + "brjdh": "berjodoh", + "akhrt": "akhirat", + "kangennnnn": "kangen", + "kanggen": "kangen", + "kangennnnnnn": "kangen", + "ysh": "ya", + "sypa": "siapa", + "nangisss": "menangis", + "brjodoh": "berjodoh", + "kaliann": "kalian", + "barengg": "bareng", + "kpnpun": "kapanpun", + "mendj": "menjadi", + "allahhhh": "allah", + "merindinggg": "merinding", + "amiennnn": "amien", + "iihh": "ih", + "lhir": "lahir", + "udeh": "sudah", + "menjdi": "menjadi", + "debay": "adek bayi", + "bangeer": "banget", + "lafazd": "lafaz", + "pmbyran": "pembayaran", + "plsa": "pulsa", + "dimana\"": "dimana-mana", + "sokk": "sok", + "ngambekan": "ambekan", + "sma2": "sama-sama", + "ngom": "mengomong", + "toloollll": "tolol", + "bodhooo": "bodoh", + "peliharaaaa": "pelihara", + "makanyaa": "makanya", + "ati\"": "hati-hati", + "kemakan": "termakan", + "bodhoo": "bodoh", + "siihhh": "sih", + "andaaa": "anda", + "buktinyaaa": "buktinya", + "smuaaa": "semua", + "ngambeknya": "mengambeknya", + "kebongkar": "terbongkar", + "tapii": "tapi", + "kasiian": "kasihan", + "diladenin": "diladeni", + "comen": "komen", + "ngtif": "negatif", + "comntr": "komentar", + "kaliannn": "kalian", + "brdiri": "berdiri", + "mulutny": "mulutnya", + "msalah": "masalah", + "trserh": "terserah", + "waduuuuh": "waduh", + "ngedukung": "mendukung", + "dihpus": "dihapus", + "mrekaa": "mereka", + "cakeeeeppp": "cakep", + "benerr": "benar", + "brg\"": "bareng-bareng", + "baanget": "banget", + "anjaaaayyyyyy": "anjay", + "ngetsz": "banget", + "utkmu": "untukmu", + "anj": "anjing", + "merekaa": "mereka", + "kann": "kan", + "dieee": "dia", + "yaahh": "ya", + "hijap": "hijab", + "ternodain": "ternodai", + "ngeri2": "ngeri-ngeri", + "mn": "mana", + "kt2": "kata-kata", + "agk": "agak", + "slhkan": "salahkan", + "jilbabny": "jilbabnya", + "perilakuny": "perilakunya", + "ketaatanny": "ketaatannya", + "kepda": "kepada", + "sblmny": "sebelumnya", + "bljr": "belajar", + "pedulilaaaah": "pedulilah", + "ingetin": "mengingatkan", + "terjrumus": "terjerumus", + "stuju": "setuju", + "sbb": "sebab", + "igtlah": "ingatlah", + "dtgkan": "datangkan", + "benahin": "membenahi", + "ohhhh": "oh", + "yapp": "ya", + "jauhh": "jauh", + "bingitt": "banget", + "buka2an": "buka-bukaan", + "ngembali": "kembali", + "semngat": "semangat", + "lekong": "laki", + "kacauuuu": "kacau", + "ngimpi": "mimpi", + "owh": "oh", + "ngerampas": "merampas", + "ngehancurin": "menghancurkan", + "ngungkit2": "mengungkit-ungkit", + "kaleee": "kali", + "caranga": "caranya", + "usa": "usah", + "dipikirin": "dipikirkan", + "nunduk": "menunduk", + "tembemmm": "tembem", + "lucuuuuuuuuuu": "lucu", + "bundaaa": "bunda", + "nyubitt": "mencubit", + "jls2": "jelas-jelas", + "supeer": "super", + "inni": "ini", + "appa": "apa", + "hayooo": "hayo", + "adyem": "adem", + "mitnah": "memfitnah", + "cantiknyaaaaa": "cantiknya", + "tmbm": "tembam", + "cantix": "cantik", + "kriput": "keriput", + "jelex": "jelek", + "drmn": "darimana", + "laku\"": "laku-laku", + "orang\"nya": "orang-orangnya", + "kacian": "kasihan", + "cape": "capek", + "mancing2": "memancing-mancing", + "eeeeh": "eh", + "kpancing": "terpancing", + "lakuin": "melakukan", + "cri": "cari", + "sbenernya": "sebenarnya", + "mikirinnn": "memikirkan", + "diam\"": "diam-diam", + "mengalahkn": "mengalahkan", + "nyindirin": "menyindir", + "nee": "ini", + "klakuan": "kelakuan", + "mksdx": "maksudnya", + "shabat": "sahabat", + "jk": "jika", + "brbuat": "berbuat", + "mmberi": "memberi", + "nm": "nama", + "sbhabat": "sahabat", + "pnjerumus": "penjerumus", + "tman": "teman", + "shbat": "sahabat", + "melakukn": "melakukan", + "seorg": "seorang", + "berkluarga": "berkeluarga", + "siapa2": "siapa-siapa", + "ketipu": "tertipu", + "laki\"": "laki-laki", + "mengait2kan": "mengait-ngaitkan", + "nyir2": "nyinyir", + "gosipin": "menggosipkan", + "sendri": "sendiri", + "bntar": "bentar", + "duuuhhh": "duh", + "nmbk": "menembak", + "gakkk": "tidak", + "abiiiiisssss": "habis", + "tikung2": "tikung-tikung", + "cwok": "cowok", + "bangedd": "banget", + "wuastagah": "astaga", + "bedaaa": "beda", + "jaoooh": "jauh", + "mateeeeeeeeeeee": "mati", + "parsis": "persis", + "sumpeh": "sumpah", + "iyh": "iya", + "kmana": "kemana", + "cantiqs": "cantik", + "kecik": "kecil", + "brasa": "berasa", + "trlihat": "terlihat", + "mmberikan": "memberikan", + "khidupan": "kehidupan", + "srpti": "seperti", + "dinamakn": "dinamakan", + "sgtu": "sebegitu", + "paati": "pasti", + "kutuu": "kutu", + "setelur2nya": "setelur-telurnya", + "khadiran": "kehadiran", + "kel": "keluarga", + "srta": "serta", + "kcil": "kecil", + "kberkahn": "keberkahan", + "kbahagiaan": "kebahagiaan", + "tntram": "tentram", + "brsama": "bersama", + "hngga": "hingga", + "brpndapat": "berpendapat", + "trutama": "terutama", + "hiraukn": "hiraukan", + "diamkn": "diamkan", + "hrsny": "harusny", + "brsyukur": "bersyukur", + "mmberikn": "memberikan", + "rasakn": "rasakan", + "cari2": "cari-cari", + "kesalahanny": "kesalahannya", + "kekuranganny": "kekurangannya", + "seandainy": "seandainya", + "perasaanny": "perasaannya", + "diindonesis": "diindonesia", + "liyatnya": "lihatnya", + "kayahhh": "kaya", + "ala2": "ala-ala", + "yook": "yuk", + "yoookkkk": "yuk", + "siihh": "sih", + "elus2": "elus-elus", + "asliiiii": "asli", + "aduuhhhh": "aduh", + "belom": "belum", + "padahl": "padahal", + "btul": "betul", + "kejahatn": "kejahatan", + "slam": "salam", + "ngelewati": "melewati", + "kalu": "kalau", + "ngerendahin": "merendahkan", + "apaalahh": "apalah", + "tnp": "tanpa", + "menyebut2": "menyebut-nyebut", + "mnt": "minta", + "kiya": "kita", + "miriiiiip": "mirip", + "dirimuuu": "dirimu", + "yoiiu": "yoi", + "semuanyaaaa": "semuanya", + "suka.a": "sukanya", + "jdii": "jadi", + "blnja": "belanja", + "brangnya": "barangnya", + "ada2": "ada-ada", + "nimpuk": "menimpuk", + "abiss": "habis", + "prlu": "perlu", + "coyyy": "coy", + "suaranyaa": "suaranya", + "jnagn": "jangan", + "kcwa": "kecewa", + "jaman2": "jaman-jaman", + "sok2an": "sok-sokan", + "dket2in": "dekat-dekati", + "cobak": "coba", + "britanya": "beritanya", + "tau2": "tau-tau", + "mahh": "mah", + "jamam": "jaman", + "paraa": "para", + "seharusny": "seharusnya", + "dasaar": "dasar", + "bergunaa": "berguna", + "mulutnyaaa": "mulutnya", + "blkng": "belakang", + "kcewa": "kecewa", + "mnusuk": "menusuk", + "blkang": "belakang", + "nembak": "menembak", + "trmanis": "termanis", + "krng": "kurang", + "kaciiaann": "kasihan", + "nyela": "mencela", + "ngejilat": "menjilat", + "hallooo": "halo", + "deketin": "dekati", + "tayik": "tahi", + "halooo": "halo", + "ustzah": "ustazah", + "halow": "halo", + "poooolllll": "pol", + "tahannn": "tahan", + "aduuh": "aduh", + "nyimpen": "menyimpan", + "ngarsipin": "mengarsipkan", + "cman": "cuman", + "gituuuuu": "begitu", + "kyy": "kaya", + "kngn": "kangen", + "kq": "kok", + "nyesek": "menyesak", + "rinduuuuuuu": "rindu", + "b2": "berdua", + "kangeeeennn": "kangen", + "mg": "moga", + "aamiiiiinnn": "amin", + "gemezzzz": "gemas", + "kangennnnnn": "kangen", + "prh": "parah", + "bakattt": "bakat", + "napsuu": "napsu", + "gmau": "enggak mau", + "luccu": "lucu", + "prasaan": "perasaan", + "lahirr": "lahir", + "setujuuuu": "setuju", + "sering2": "sering-sering", + "ceemen": "cemen", + "sembunyi\"\"": "sembunyi-sembunyi", + "skrnh": "sekarang", + "cerdiik": "cerdik", + "jeniuss": "jenius", + "kurangin": "kurangi", + "kreen": "keren", + "s'lalu": "selalu", + "mauuuuuu": "mau", + "mintk": "minta", + "nyolong": "mencolong", + "pnjng": "panjang", + "slaluuuuu": "selalu", + "kanggggg": "kang", + "rezeqinya": "rezekinya", + "pintr": "pintar", + "dekk": "dek", + "maw": "mau", + "kura2": "kura-kura", + "edaaann": "edan", + "nempel": "menempel", + "hatiiii": "hati", + "sukkaa": "suka", + "luuuucuuu": "lucu", + "caantiiik": "cantik", + "luucuuu": "lucu", + "putihh": "putih", + "terus\"an": "terus-terusan", + "siiih": "sih", + "ternyataa": "ternyata", + "pollll": "pol", + "sekaliiii": "sekali", + "peduliin": "mempedulikan", + "komntr": "komentar", + "smngat": "semangat", + "dosax": "dosanya", + "pahalax": "pahalanya", + "akhirt": "akhirat", + "tinggalkn": "tinggalkan", + "rumh": "rumah", + "mk": "maka", + "trhtung": "terhitung", + "naaaa": "nya", + "ademmm": "adem", + "cerdass": "cerdas", + "tambahh": "tambah", + "polll": "pol", + "didengerin": "didengarkan", + "mantapppppp": "mantap", + "ngerespon": "merespons", + "dulurrr": "dulur", + "brusaha": "berusaha", + "lebh": "lebih", + "amin2": "amin-amin", + "positippp": "positif", + "jauuhhh": "jauh", + "brhjb": "berhijab", + "mdh2n": "mudah-mudahan", + "slmnya": "salamnya", + "jlbb": "jilbab", + "dempga": "semoga", + "selmnya": "selamanya", + "tth": "teteh", + "otot2": "otot-otot", + "lengkeeett": "lengket", + "caaannnttiikkk": "cantik", + "trusss": "terus", + "ademm": "adem", + "apalagiii": "apalagi", + "passs": "pas", + "beginii": "begini", + "satuu": "satu", + "cuantek": "cantik", + "akuuuur": "akur", + "iniehh": "ini", + "sayangx": "sayangnya", + "jodohx": "jodohnya", + "kaliaan": "kalian", + "haslnya": "hasilnya", + "kencng": "kencang", + "pmbantu": "pembantu", + "kmbl": "kembali", + "cuchook": "cocok", + "kloop": "klop", + "sempurnaa": "sempurna", + "klihatan": "kelihatan", + "cintax": "cintanya", + "pnuh": "penuh", + "ngrti": "mengerti", + "mudah\"an": "mudah-mudahan", + "segala\"ny": "segala-galanya", + "mantanny": "mantannya", + "jodohny": "jodohnya", + "cantk": "cantik", + "ituuu": "itu", + "kaasian": "kasihan", + "padhl": "padahal", + "ank\"": "anak-anak", + "pkrin": "pikirkan", + "anakx": "anaknya", + "sia2kan": "sia-siakan", + "naaahhh": "nah", + "beluumm": "belum", + "sayangggq": "sayangku", + "kreean": "keren", + "ciiieee": "cie", + "cieee": "cie", + "cieeee": "cie", + "berenanh": "berenang", + "pacarr": "pacar", + "bingitss": "banget", + "besokkk": "besok", + "annnnjjjjjjrrrr": "anjir", + "ngeliatin": "melihati", + "ngumbar2": "mengumbar-umbar", + "romantiss": "romantis", + "cocook": "cocok", + "lucuuuuuuuu": "lucu", + "kaaaaaann": "kan", + "nyaaaaaaa": "nya", + "lgs": "langsung", + "yaang": "yang", + "bangedt": "banget", + "gde": "gede", + "lutunaaa": "lucunya", + "bikinnn": "bikin", + "keee": "ke", + "bungkuuussss": "bungkus", + "lucux": "lucunya", + "bntr": "bentar", + "buanyaaak": "banyak", + "aaamiiinnn": "amin", + "aaamiiin": "amin", + "ntap": "mantap", + "abgnyaah": "abangnya", + "mungkiin": "mungkin", + "akuhh": "aku", + "manteb": "mantap", + "hiduupppp": "hidup", + "tebel": "tebal", + "nganggepin": "menanggapi", + "mentang\"": "mentang-mentang", + "najiss": "najis", + "perjabatt": "pejabat", + "makany": "makanya", + "benneeeeeerr": "benar", + "krjaan": "pekerjaan", + "kdng": "kadang", + "kdang": "kadang", + "knan": "kanan", + "anak'y": "anaknya", + "trknal": "terkenal", + "gtu\"": "begitu-gitu", + "klok": "kalo", + "aamiiiinnnnn": "amin", + "pengennnn": "pengin", + "guw": "gue", + "siiiiiiippppppppp": "sip", + "selaloe": "selalu", + "donggg": "dong", + "beruntungnyaaaa": "beruntungnya", + "soal'y": "soalnya", + "prhatian": "perhatian", + "hebaat": "hebat", + "ampunn": "ampun", + "gueeeee": "gue", + "liatt": "lihat", + "gehh": "gih", + "ankknyaaa": "anaknya", + "centilnyaaaaaa": "centilnya", + "gemesshzzzzhzzhzhzhsjssjs": "gemas", + "ngacaaa": "mengaca", + "diaaa": "dia", + "jirr": "anjir", + "kayaa": "kaya", + "cempreeng": "cempreng", + "centillll": "centil", + "pcran": "pacaran", + "anedh": "banget", + "ampyunnnn": "ampun", + "boro2": "boro-boro", + "nyta": "nyata", + "kampungn": "kampungan", + "j": "saja", + "mnghina": "menghina", + "ngolokin": "mengolok", + "blum": "belum", + "syirik\"": "syirik-syirik", + "than": "tahan", + "dsr": "dasar", + "siriik": "sirik", + "mampuuu": "mampu", + "khan": "kan", + "aduuhh": "aduh", + "cemburuu": "cemburu", + "bingitzzzz": "banget", + "tlog": "tolong", + "apasihhh": "apasih", + "sirikk": "sirik", + "dbnding": "dibanding", + "nrka": "neraka", + "s3ndiri": "sendiri", + "bhgiain": "membahagiakan", + "kdua": "kedua", + "prcya": "percaya", + "bhagaia": "bahagia", + "cuco": "cocok", + "genittt": "genit", + "kx": "kak", + "mlikk": "milik", + "mentah\"": "mentah-mentah", + "canci": "cantik", + "aneed": "banget", + "gimanaa": "bagaimana", + "lainnn": "lain", + "pentingg": "penting", + "bagaimanaaa": "bagaimana", + "krudung": "kerudung", + "enakkkkk": "enak", + "poool": "pol", + "jirrrr": "anjir", + "oprasiya": "operasinya", + "uwee": "gue", + "pilihannny": "pilihannya", + "itumahh": "itumah", + "muuuu": "mu", + "uuhhh": "uh", + "berbakiti": "berbakti", + "alhamd": "alhamdulillah", + "slmttt": "selamat", + "ucapin": "mengucapkan", + "tetp": "tetap", + "ditengah2": "ditengah-tengah", + "semuanyaa": "semuanya", + "semuannyaaa": "semuanya", + "aminn2": "amin-amin", + "abs": "habis", + "kebnyak'an": "kebanyakan", + "melahirin": "melahirkan", + "aduuuh": "aduh", + "cakeep": "cakep", + "lhooo": "loh", + "kamuuuu": "kamu", + "bagi2": "bagi-bagi", + "gimn": "bagaimana", + "smogaaa": "semoga", + "biza": "bisa", + "aamien": "amien", + "lngsing": "langsing", + "wowoooww": "wow", + "smulla": "semula", + "pdhall": "padahal", + "blln": "bulan", + "senang2": "senang-senang", + "jh": "saja", + "kereeennnn": "keren", + "gilakkk": "gila", + "gtu2": "begitu-gitu", + "cpet": "cepat", + "gataunya": "enggak taunya", + "gtau": "enggak tau", + "fans2": "fan-fan", + "gaharus": "enggak harus", + "sleran": "slearn", + "mobileny": "mobile", + "eror": "error", + "erorr": "error", + "sekala": "skala", + "perkulian": "perkuliahan", + "membuma": "membuka", + "kegitan": "kegiatan", + "tamopilan": "tampilan", + "menrutu": "menurut", + "menggangu": "mengganggu", + "aada": "ada", + "firur": "fitur", + "memudahakan": "memudahkan", + "infomatif": "informatif", + "melaui": "melalui", + "nilainnya": "nilainya", + "sederhan": "sederhana", + "adahal": "padahal", + "selaluuu": "selalu", + "tolongg": "tolong", + "perwakilaln": "perwakilan", + "mengumpukan": "mengumpulkan", + "yangg": "yang", + "lemott": "lambat", + "aaaaa": "", + "ditambahkam": "ditambahkan", + "kedepanya": "ke depannya", + "dann": "dan", + "digunkan": "digunakan", + "perkuliahann": "perkuliahan", + "yabg": "yang", + "lodingnya": "loadingnya", + "download": "unduh", + "didownload": "diunduh", + "mendownload": "mengunduh", + "upload": "unggah", + "diupload": "diunggah", + "mengupload": "mengunggah", + "scan": "pindai", + "discan": "dipindai", + "userfriendly": "ramah pengguna", + "apps": "aplikasi", + "simple": "sederhana", + "simpel": "sederhana", + "matkul": "mata kuliah", + "spek": "spesifikasi", + "aktifitas": "aktivitas", + "ngebaca": "membaca", + "dibenerin": "diperbaiki", + "ngalami": "mengalami", + "bikin": "membuat", + "dibikin": "dibuat", + "ngumpulin": "mengumpulkan", + "kumpulin": "kumpulkan", + "kumpulinnya": "mengumpulkan", + "dikumpulin": "dikumpulkan", + "kekumpul": "terkumpul", + "ngerjain": "mengerjakan", + "kerjain": "mengerjakan", + "kayak": "seperti", + "ribet": "rumit", + "lemot": "lambat", + "nemu": "menemukan", + "nyarinya": "mencari", + "nambahin": "menambahkan", + "mulu": "terus", + "doang": "saja", + "capek": "lelah", + "kelar": "selesai", + "telat": "terlambat", + "kesel": "kesal", + "bukanya": "bukanya", + "kepotong": "terpotong", + "kehambat": "terhambat", + "mepet": "dekat", + "ngekompres": "mengompres", + "apaan": "apa", + "diapus": "dihapus", + "numpang": "menumpang", + "walalupun": "walaupun", + "yaudah": "ya sudah", + "nugas": "mengerjakan tugas", + "ngelag": "lag", + "ngebug": "error", + "kekirim": "terkirim", + "lampirin": "melampirkan", + "muter": "memutar", + "kerasa": "terasa", + "dipelajarin": "dipelajari", + "plis": "tolong", + "khawatirin": "khawatirkan", + "ngebingungin": "membingungkan", + "kebangetan": "keterlaluan", + "ngumpul": "mengumpulkan", + "ngalamin": "mengalami", + "ditambahin": "ditambahkan", + "mampus": "sekali", + "pas": "saat", + "kasih": "beri", + "slear": "slearn", + "absen": "presensi", + "absensi": "presensi", + "scroll": "gulir", + "discroll": "digulir", + "mengscroll": "menggulir", + "rekomend": "direkomendasikan", + "dikalau": "kalau", + "temenku": "temanku", + "kedepannya": "ke depannya", + "raport": "rapor", + "web": "website", + "downloaad": "unduh", + "dikasih": "diberikan", + "mhs": "mahasiswa", + "design": "desain", + "lumanyan": "lumayan", + "fiturpadaa": "fitur pada", + "hutuh": "butuh", + "samain": "disamakan", + "meteri": "materi", + "ser": "geser", + "uxnya": "ux nya", + "tuh": "itu", + "kudu": "harus", + "gapapa": "tidak apa apa", + "dikit": "sedikit", + "dibeberapa": "di beberapa", + "mahasiwa": "mahasiswa", + "update": "pembaruan", + "diupdate": "pembaruan", + "slrean": "slearn", + "diperbaikan": "diperbaiki", + "cuman": "hanya", + "trouble": "masalah", + "webstie": "website", + "notify": "notifikasi", + "tapi": "tetapi", + "tau": "tahu", + "bawa": "bawa", + "kalo": "kalau", + "dikasi": "diberikan", + "pengelompokkan": "pengelompokan", + "dsb": "dan sebagainya", + "client": "klien", + "device": "perangkat", + "barcode": "kode batang", + "wesite": "website", + "notif": "notifikasi", + "mengumpulan": "mengumpulkan", + "mengumpulakn": "mengumpulkan", + "diatas": "di atas", + "transparasi": "transparansi", + "reminder": "pengingat", + "deadline": "tenggat", + "deadlinenya": "tenggatnya", + "megunduh": "mengunduh", + "gampang": "mudah", + "harusnya": "seharusnya", + "dipahamin": "dipahami", + "keseringan": "sering", + "buat": "buat", + "ganggu": "mengganggu", + "tiap": "tiap", + "jadinya": "sehingga", + "dibanding": "dibandingkan", + "aku": "saya", + "nih": "ini", + "resiko": "risiko", + "hp": "ponsel", + "down": "bermasalah", + "diantara": "di antara", + "ngembangin": "mengembangkan", + "keupload": "terunggah", + "menururt": "menurut", + "balikin": "kembalikan", + "dipaksain": "dipaksakan", + "hostingan": "hosting", + "jelek": "buruk", + "bareng": "bersama", + "soalnya": "karena", + "dijalanin": "dijalankan", + "berasa": "terasa", + "jadul": "kuno", + "gampangin": "mempermudah", + "kosongan": "kosong", + "mengasih": "memberi", + "apk": "aplikasi", + "app": "aplikasi", + "enak": "nyaman", + "dikerjain": "dikerjakan", + "kecatat": "tercatat", + "menurutku": "menurut saya", + "makin": "semakin", + "terimakasih": "terima kasih", + "dang": "dan", + "dimasukan": "dimasukkan", + "please": "tolong", + "pliss": "tolong", + "susah": "sulit", + "timingnya": "waktunya", + "worth": "layak", + "seringggggg": "sering", + "susaahhhh": "sulit", + "ngeganggu": "mengganggu", + "keburu": "keburu", + "benerin": "perbaiki", + "ngeleg": "lag", + "mau": "mau", + "thanks": "terima kasih", + "quiz": "kuis", + "rame": "ramai", + "nyelesaikan": "menyelesaikan", + "jatohnya": "jatuhnya", + "kepake": "terpakai", + "ngandelin": "mengandalkan", + "ngulang": "mengulang", + "lamaa": "lama", + "pribadiku": "pribadi saya", + "nangkep": "menangkap", + "ketemu": "temu", + "terkadang": "kadang", + "memuaskan": "puas", + "ditambahkan": "tambah", + "setiap": "tiap", + "nungguinnya": "tunggu", + "lamanya": "lama", + "mereset": "reset", + "ngenalin": "kenal", + "negnalin": "kenal", + "dipublish": "publikasi", + "pelajarinnya": "ajar", + "mengecek": "periksa", + "its": "its", + "okey": "okey" + }, + "frasa_awal": { + "\\bapaan dah\\b": "apa sih", + "\\bterima kasih\\b": "terimakasih", + "\\bngasih tahu\\b": "memberitahukan", + "\\bngasih tau\\b": "memberitahukan", + "\\bdi samain\\b": "disamakan", + "\\bpengeluaran akun\\b": "logout akun", + "\\bs learn\\b": "slearn", + "\\babsen nya\\b": "presensinya", + "\\bdi scroll\\b": "digulir", + "\\bdi kerjakan\\b": "dikerjakan", + "\\bdi berikan\\b": "diberikan", + "\\bdi atas namakan\\b": "diatasnamakan", + "\\bdi perbarui\\b": "diperbarui", + "\\be learning\\b": "elearning", + "\\bweb nya\\b": "websitenya", + "\\bfitur nya\\b": "fiturnya", + "\\bter update\\b": "terbarui", + "\\bdi update\\b": "pembaruan", + "\\bupdate nya\\b": "pembaruannya", + "\\bwebsite nya\\b": "websitenya", + "\\bdeadline nya\\b": "tenggatnya", + "\\bproses nya\\b": "prosesnya", + "\\btampilan nya\\b": "tampilannya", + "\\bsebelum nya\\b": "sebelumnya", + "\\bburuk nya\\b": "buruknya", + "\\bdi perbaiki\\b": "diperbaiki", + "\\blambat nya\\b": "lambatnya", + "\\bdi pindai\\b": "dipindai", + "\\brumit nya\\b": "rumitnya", + "\\bdari pada\\b": "daripada", + "\\bkesan nya\\b": "kesannya", + "\\blemot nya\\b": "lambatnya", + "\\bdi buka\\b": "dibuka", + "\\bdi ganti\\b": "diganti", + "\\bmahasiswa nya\\b": "mahasiswanya", + "\\bmakan waktu\\b": "menghabiskan waktu", + "\\bmau ngumpulin\\b": "mau mengumpulkan", + "\\bke logout\\b": "logout", + "\\bmuter balik\\b": "kembali", + "\\bke submit\\b": "disubmit", + "\\bsuka sama\\b": "suka dengan", + "\\bgara gara\\b": "karena", + "\\bke mahasiswa\\b": "bagi mahasiswa", + "\\bmutet muter\\b": "berputar putar", + "\\bbener bener\\b": "benar benar", + "\\bpengalaman ku\\b": "pengalamanku", + "\\bpribadi ku\\b": "pribadi saya", + "\\bkampus ku\\b": "kampusku", + "\\bjelek nya\\b": "jeleknya", + "\\bkurang nya\\b": "kurangnya", + "\\bwarna nya\\b": "warnanya", + "\\bsistem nya\\b": "sistemnya", + "\\bloading nya\\b": "loadingnya", + "\\bdashboard nya\\b": "dashboardnya", + "\\btugas nya\\b": "tugasnya", + "\\bkode nya\\b": "kodenya", + "\\bnegatif nya\\b": "negatifnya", + "\\baplikasi nya\\b": "aplikasinya", + "\\bbarcode nya\\b": "kode batangnya", + "\\bdi pahami\\b": "dipahami", + "\\bkelar sudah\\b": "sudah berakhir", + "\\bnge\\s+lag\\b": "lag", + "\\bits\\s+okey\\b": "its okey" + }, + "frasa_akhir": { + "\\bdi gunakan\\b": "digunakan", + "\\bdi pakai\\b": "dipakai", + "\\bdi akses\\b": "diakses", + "\\bdi unduh\\b": "diunduh", + "\\bdi download\\b": "diunduh", + "\\bdi upload\\b": "diunggah", + "\\bdi unggah\\b": "diunggah", + "\\bgak bisa\\b": "tidak bisa", + "\\bga bisa\\b": "tidak bisa", + "\\bnggak bisa\\b": "tidak bisa", + "\\buser friendly\\b": "ramah pengguna", + "\\bmemudah kan\\b": "memudahkan", + "\\bdidalam\\b": "di dalam", + "\\bdiluar\\b": "di luar", + "\\bdiatas\\b": "di atas", + "\\bdibawah\\b": "di bawah", + "\\bdisini\\b": "di sini", + "\\bits\\s+okey\\b": "its okey" + }, + "koreksi_validator": { + "ketemu": "temu", + "terkadang": "kadang", + "memuaskan": "puas", + "ditambahkan": "tambah", + "setiap": "tiap", + "nungguinnya": "tunggu", + "lamanya": "lama", + "mereset": "reset", + "ngenalin": "kenal", + "negnalin": "kenal", + "dipublish": "publikasi", + "pelajarinnya": "ajar", + "nanya": "tanya", + "mengecek": "periksa" + }, + "list_stopwords": [ + "adalah", + "adanya", + "adapun", + "agak", + "agaknya", + "agar", + "akan", + "akankah", + "akhir", + "akhiri", + "akhirnya", + "aku", + "akulah", + "amat", + "amatlah", + "anda", + "andalah", + "antar", + "antara", + "antaranya", + "apa", + "apaan", + "apabila", + "apakah", + "apalagi", + "apatah", + "artinya", + "asal", + "asalkan", + "atas", + "atau", + "ataukah", + "ataupun", + "awal", + "awalnya", + "bagai", + "bagaikan", + "bagaimana", + "bagaimanakah", + "bagaimanapun", + "bagi", + "bagian", + "bahkan", + "bahwa", + "bahwasanya", + "bakal", + "bakalan", + "balik", + "bang", + "bapak", + "baru", + "bawah", + "beberapa", + "begini", + "beginian", + "beginikah", + "beginilah", + "begitu", + "begitukah", + "begitulah", + "begitupun", + "bekerja", + "belakang", + "belakangan", + "belumlah", + "benarkah", + "benarlah", + "berada", + "berakhir", + "berakhirlah", + "berakhirnya", + "berapa", + "berapakah", + "berapalah", + "berapapun", + "berarti", + "berawal", + "berbagai", + "berdatangan", + "beri", + "berikan", + "berikut", + "berikutnya", + "berjumlah", + "berkali-kali", + "berkata", + "berkehendak", + "berkeinginan", + "berkenaan", + "berlainan", + "berlalu", + "berlangsung", + "berlebihan", + "bermacam", + "bermacam-macam", + "bermaksud", + "bermula", + "bersama", + "bersama-sama", + "bersiap", + "bersiap-siap", + "bertanya", + "bertanya-tanya", + "berturut", + "berturut-turut", + "bertutur", + "berujar", + "berupa", + "besar", + "betul", + "betulkah", + "biasa", + "biasanya", + "bila", + "bilakah", + "bisakah", + "boleh", + "bolehkah", + "bolehlah", + "buat", + "bukankah", + "bukanlah", + "bulan", + "bung", + "cara", + "caranya", + "cukupkah", + "cukuplah", + "cuma", + "dahulu", + "dalam", + "dari", + "daripada", + "datang", + "deh", + "demi", + "demikian", + "demikianlah", + "dengan", + "depan", + "di", + "dia", + "diakhiri", + "diakhirinya", + "dialah", + "diantara", + "diantaranya", + "diberi", + "diberikan", + "diberikannya", + "dibuat", + "dibuatnya", + "didapat", + "didatangkan", + "digunakan", + "diibaratkan", + "diibaratkannya", + "diingat", + "diingatkan", + "diinginkan", + "dijawab", + "dijelaskan", + "dijelaskannya", + "dikarenakan", + "dikatakan", + "dikatakannya", + "dikerjakan", + "diketahui", + "diketahuinya", + "dikira", + "dilakukan", + "dilalui", + "dilihat", + "dimaksud", + "dimaksudkan", + "dimaksudkannya", + "dimaksudnya", + "diminta", + "dimintai", + "dimisalkan", + "dimulai", + "dimulailah", + "dimulainya", + "dimungkinkan", + "dini", + "dipastikan", + "diperbuat", + "diperbuatnya", + "dipergunakan", + "diperkirakan", + "diperlihatkan", + "diperlukan", + "diperlukannya", + "dipersoalkan", + "dipertanyakan", + "dipunyai", + "diri", + "dirinya", + "disampaikan", + "disebut", + "disebutkan", + "disebutkannya", + "disini", + "disinilah", + "ditambahkan", + "ditandaskan", + "ditanya", + "ditanyai", + "ditanyakan", + "ditegaskan", + "ditujukan", + "ditunjuk", + "ditunjuki", + "ditunjukkan", + "ditunjukkannya", + "ditunjuknya", + "dituturkan", + "dituturkannya", + "diucapkan", + "diucapkannya", + "diungkapkan", + "dong", + "donk", + "dua", + "empat", + "enggak", + "enggaknya", + "entah", + "entahlah", + "gunakan", + "hal", + "hampir", + "hanya", + "hanyalah", + "hari", + "harus", + "haruslah", + "harusnya", + "hendak", + "hendaklah", + "hendaknya", + "hingga", + "ia", + "ialah", + "ibarat", + "ibaratkan", + "ibaratnya", + "ibu", + "ikut", + "ingat", + "ingat-ingat", + "ingin", + "inginkah", + "inginkan", + "ini", + "inikah", + "inilah", + "itu", + "itukah", + "itulah", + "jadi", + "jadilah", + "jadinya", + "jangankan", + "janganlah", + "jauh", + "jawab", + "jawaban", + "jawabnya", + "jelaskan", + "jelaslah", + "jelasnya", + "jika", + "jikalau", + "juga", + "jumlah", + "jumlahnya", + "justru", + "kah", + "kala", + "kalau", + "kalaulah", + "kalaupun", + "kalian", + "kami", + "kamilah", + "kamu", + "kamulah", + "kan", + "kapan", + "kapankah", + "kapanpun", + "karena", + "karenanya", + "kasus", + "kata", + "katakan", + "katakanlah", + "katanya", + "ke", + "keadaan", + "kebetulan", + "kedua", + "keduanya", + "keinginan", + "kelamaan", + "kelihatan", + "kelihatannya", + "kelima", + "kembali", + "kemudian", + "kemungkinan", + "kemungkinannya", + "kenapa", + "kepada", + "kepadanya", + "kesampaian", + "keseluruhan", + "keseluruhannya", + "keterlaluan", + "ketika", + "khususnya", + "kini", + "kinilah", + "kira", + "kira-kira", + "kiranya", + "kita", + "kitalah", + "kok", + "lagi", + "lagian", + "lah", + "lain", + "lainnya", + "lalu", + "lamanya", + "lanjut", + "lanjutnya", + "lewat", + "lima", + "luar", + "macam", + "maka", + "makanya", + "makin", + "malahan", + "mampu", + "mampukah", + "mana", + "manakala", + "manalagi", + "masa", + "masalahnya", + "masihkah", + "masing", + "masing-masing", + "maupun", + "melainkan", + "melakukan", + "melalui", + "melihat", + "melihatnya", + "memang", + "memastikan", + "memberi", + "memberikan", + "membuat", + "memerlukan", + "memihak", + "meminta", + "memintakan", + "memisalkan", + "memperbuat", + "mempergunakan", + "memperkirakan", + "memperlihatkan", + "mempersiapkan", + "mempersoalkan", + "mempertanyakan", + "mempunyai", + "memulai", + "memungkinkan", + "menaiki", + "menambahkan", + "menandaskan", + "menanti", + "menanti-nanti", + "menantikan", + "menanya", + "menanyai", + "menanyakan", + "mendapat", + "mendapatkan", + "mendatang", + "mendatangi", + "mendatangkan", + "menegaskan", + "mengakhiri", + "mengapa", + "mengatakan", + "mengatakannya", + "mengenai", + "mengetahui", + "menggunakan", + "menghendaki", + "mengibaratkan", + "mengibaratkannya", + "mengingat", + "mengingatkan", + "menginginkan", + "mengira", + "mengucapkan", + "mengucapkannya", + "mengungkapkan", + "menjadi", + "menjawab", + "menjelaskan", + "menuju", + "menunjuk", + "menunjuki", + "menunjukkan", + "menunjuknya", + "menurut", + "menuturkan", + "menyampaikan", + "menyangkut", + "menyatakan", + "menyebutkan", + "menyeluruh", + "menyiapkan", + "merasa", + "mereka", + "merekalah", + "merupakan", + "meski", + "meskipun", + "meyakini", + "meyakinkan", + "min", + "minta", + "mirip", + "misal", + "misalkan", + "misalnya", + "mula", + "mulai", + "mulailah", + "mulanya", + "mungkin", + "mungkinkah", + "nah", + "naik", + "namun", + "nanti", + "nantinya", + "nyaris", + "nyatanya", + "oleh", + "olehnya", + "pada", + "padanya", + "pak", + "paling", + "panjang", + "pantas", + "para", + "pasti", + "pastilah", + "penting", + "pentingnya", + "per", + "percuma", + "perlukah", + "perlunya", + "pernah", + "persoalan", + "pertama", + "pertama-tama", + "pertanyaan", + "pertanyakan", + "pihak", + "pihaknya", + "pukul", + "pula", + "pun", + "punya", + "rasa", + "rasanya", + "rata", + "rupanya", + "saat", + "saatnya", + "saja", + "sajalah", + "saling", + "sama", + "sama-sama", + "sambil", + "sampai", + "sampai-sampai", + "sampaikan", + "sana", + "sangatlah", + "satu", + "saya", + "sayalah", + "se", + "sebab", + "sebabnya", + "sebagai", + "sebagaimana", + "sebagainya", + "sebagian", + "sebaik", + "sebaik-baiknya", + "sebaiknya", + "sebaliknya", + "sebanyak", + "sebegini", + "sebegitu", + "sebenarnya", + "seberapa", + "sebesar", + "sebetulnya", + "sebisanya", + "sebuah", + "sebut", + "sebutlah", + "sebutnya", + "secara", + "secukupnya", + "sedang", + "sedangkan", + "sedemikian", + "sedikit", + "sedikitnya", + "seenaknya", + "segala", + "segalanya", + "segera", + "seharusnya", + "sehingga", + "seingat", + "sejak", + "sejauh", + "sejenak", + "sejumlah", + "sekadar", + "sekadarnya", + "sekali-kali", + "sekalian", + "sekaligus", + "sekalipun", + "sekarang", + "sekecil", + "seketika", + "sekiranya", + "sekitar", + "sekitarnya", + "sekurang-kurangnya", + "sekurangnya", + "sela", + "selain", + "selaku", + "selama", + "selama-lamanya", + "selamanya", + "selanjutnya", + "seluruh", + "seluruhnya", + "semacam", + "semakin", + "semampu", + "semampunya", + "semasa", + "semasih", + "semata", + "semata-mata", + "semaunya", + "sementara", + "semisal", + "semisalnya", + "sempat", + "semua", + "semuanya", + "semula", + "sendirian", + "sendirinya", + "seolah", + "seolah-olah", + "seorang", + "sepanjang", + "sepantasnya", + "sepantasnyalah", + "seperlunya", + "seperti", + "sepertinya", + "sepihak", + "seringnya", + "serta", + "serupa", + "sesaat", + "sesama", + "sesampai", + "sesegera", + "sesekali", + "seseorang", + "sesuatu", + "sesuatunya", + "sesudah", + "sesudahnya", + "setelah", + "setempat", + "setengah", + "seterusnya", + "setiap", + "setiba", + "setibanya", + "setidak-tidaknya", + "setidaknya", + "setinggi", + "seusai", + "sewaktu", + "si", + "siapa", + "siapakah", + "siapapun", + "sih", + "sini", + "sinilah", + "soal", + "soalnya", + "suatu", + "sudahkah", + "sudahlah", + "supaya", + "tadi", + "tadinya", + "tahu", + "tahun", + "tak", + "tambahnya", + "tampak", + "tampaknya", + "tandas", + "tandasnya", + "tanya", + "tanyakan", + "tanyanya", + "tapi", + "tegas", + "tegasnya", + "telah", + "tempat", + "tengah", + "tentang", + "tentu", + "tentulah", + "tentunya", + "tepat", + "terakhir", + "terasa", + "terbanyak", + "terdahulu", + "terdapat", + "terdiri", + "terhadap", + "terhadapnya", + "teringat", + "teringat-ingat", + "terjadi", + "terjadilah", + "terjadinya", + "terkira", + "terlebih", + "termasuk", + "ternyata", + "tersampaikan", + "tersebut", + "tersebutlah", + "tertentu", + "tertuju", + "terus", + "terutama", + "tetap", + "tetapi", + "tiba-tiba", + "tidakkah", + "tiga", + "tinggi", + "toh", + "tunjuk", + "turut", + "tutur", + "tuturnya", + "ucap", + "ucapnya", + "ujar", + "ujarnya", + "umum", + "umumnya", + "ungkap", + "ungkapnya", + "untuk", + "usah", + "usai", + "waduh", + "wah", + "wahai", + "waktunya", + "walau", + "walaupun", + "wong", + "ya", + "yaitu", + "yakin", + "yakni", + "yang" + ], + "kata_dilindungi_stemming": [ + "ada", + "ajar", + "banyak", + "belum", + "berantakan", + "dan", + "dekat", + "dulu", + "kadang", + "kecil", + "kenal", + "keterangan", + "lag", + "lama", + "puas", + "publikasi", + "reset", + "sebelumnya", + "semoga", + "tambah", + "temu", + "terlihat", + "tiap", + "tunggu", + "waktu" + ] +} \ No newline at end of file diff --git a/api_slearn/artifacts_slearn/preprocessing_slearn.py b/api_slearn/artifacts_slearn/preprocessing_slearn.py new file mode 100644 index 0000000..ef42ca5 --- /dev/null +++ b/api_slearn/artifacts_slearn/preprocessing_slearn.py @@ -0,0 +1,124 @@ +import re, json +from pathlib import Path +from nltk.tokenize import word_tokenize +from Sastrawi.Stemmer.StemmerFactory import StemmerFactory + +config = json.loads( + (Path(__file__).parent / 'preprocessing_config.json') + .read_text(encoding='utf-8') +) + +kamus_normalisasi = config['kamus_normalisasi'] +frasa_awal = config['frasa_awal'] +frasa_akhir = config['frasa_akhir'] +koreksi_validator = config['koreksi_validator'] +list_stopwords = set(config['list_stopwords']) +kata_dilindungi_stemming = set( + config['kata_dilindungi_stemming'] +) + +stemmer = StemmerFactory().create_stemmer() + +def cleaning(text): + text = str(text) + + # 1. Menghapus URL/Link (tidak peka huruf besar/kecil) + text = re.sub(r"https?://\S+|www\.\S+", " ", text, flags=re.IGNORECASE) + + # 2. Menghapus mention (@), pertahankan isi hashtag (#) + text = re.sub(r"@\w+", " ", text) + text = re.sub(r"#(\w+)", r"\1", text) + + # 3. Menghapus angka + text = re.sub(r"\d+", " ", text) + + # 4. Menghapus karakter selain huruf + text = re.sub(r"[^a-zA-Z\s]", " ", text) + + # 5. Merapikan spasi berlebih + text = re.sub(r"\s+", " ", text).strip() + + return text + + +def case_folding(text): + # Mengubah seluruh teks menjadi huruf kecil + return str(text).lower() + + +def ganti_frasa(text, kamus_frasa): + for pola, ganti in kamus_frasa.items(): + text = re.sub(pola, ganti, text) + return text + + +def normalisasi(text): + text = ganti_frasa(str(text), frasa_awal) + text = re.sub(r"\b\w+\b", lambda m: kamus_normalisasi.get(m.group(), m.group()), text) + return ganti_frasa(text, frasa_akhir) + + +def koreksi_normalisasi_validator(text): + text = str(text) + text = re.sub(r"\bnge\s+lag\b", "lag", text) # koreksi frasa hasil validasi + text = re.sub(r"\bits\s+okey\b", "its okey", text) # "its okey" dipertahankan + return re.sub(r"\b\w+\b", lambda x: koreksi_validator.get(x.group(), x.group()), text) + + +def tokenisasi(text): + return word_tokenize(str(text)) + + +def hapus_stopword(tokens): + return " ".join( + kata for kata in tokens + if kata not in list_stopwords and len(kata) > 1 + ) + + +def stemming(text): + hasil = [] + + for kata in str(text).split(): + if kata in kata_dilindungi_stemming: + hasil.append(kata) + else: + hasil.append(stemmer.stem(kata)) + + return " ".join(hasil) + + +def preprocessing_kalimat_baru(text): + + # 1. Cleaning + text = cleaning(text) + + # 2. Case Folding + text = case_folding(text) + + # 3. Normalisasi + text = normalisasi(text) + text = koreksi_normalisasi_validator(text) + + # 4. Tokenisasi + tokens = tokenisasi(text) + + # 5. Stopword Removal + text = hapus_stopword(tokens) + + # 6. Stemming + text = stemming(text) + + return text + + +def prediksi_sentimen(teks, model, vectorizer): + if not isinstance(teks, str) or not teks.strip(): + raise ValueError("Masukkan komentar terlebih dahulu.") + hasil = preprocessing_kalimat_baru(teks) + if not hasil.strip(): + raise ValueError("Komentar tidak memiliki kata yang dapat diproses.") + fitur = vectorizer.transform([hasil]) + if fitur.nnz == 0: + raise ValueError("Kata dalam komentar belum dikenali model.") + return int(model.predict(fitur)[0]) diff --git a/api_slearn/artifacts_slearn/requirements.txt b/api_slearn/artifacts_slearn/requirements.txt new file mode 100644 index 0000000..acac512 --- /dev/null +++ b/api_slearn/artifacts_slearn/requirements.txt @@ -0,0 +1,7 @@ +scikit-learn==1.6.1 +numpy==2.1.3 +scipy==1.16.3 +pandas==2.2.3 +nltk==3.9.1 +Sastrawi==1.0.1 +joblib==1.6.0 diff --git a/api_slearn/artifacts_slearn/stopwords_final.joblib b/api_slearn/artifacts_slearn/stopwords_final.joblib new file mode 100644 index 0000000..f632b99 Binary files /dev/null and b/api_slearn/artifacts_slearn/stopwords_final.joblib differ diff --git a/api_slearn/artifacts_slearn/tfidf_vectorizer.joblib b/api_slearn/artifacts_slearn/tfidf_vectorizer.joblib new file mode 100644 index 0000000..7149e8a Binary files /dev/null and b/api_slearn/artifacts_slearn/tfidf_vectorizer.joblib differ diff --git a/api_slearn/database.py b/api_slearn/database.py new file mode 100644 index 0000000..4129ad2 --- /dev/null +++ b/api_slearn/database.py @@ -0,0 +1,1021 @@ +import json +import os + +import mysql.connector +from mysql.connector import Error + + +# ============================================================ +# KONFIGURASI DATABASE MYSQL +# ============================================================ + +DB_CONFIG = { + "host": os.getenv("DB_HOST", "127.0.0.1"), + "port": int(os.getenv("DB_PORT", "3306")), + "user": os.getenv("DB_USER", "root"), + "password": os.getenv("DB_PASSWORD", ""), + "database": os.getenv("DB_NAME", "slearn_web_db"), + "charset": "utf8mb4" +} + + +# ============================================================ +# HELPER JSON +# ============================================================ + +def _to_json(data): + """ + Mengubah list/dict menjadi JSON string + sebelum disimpan ke database. + """ + + if data is None: + return None + + if isinstance(data, str): + return data + + return json.dumps( + data, + ensure_ascii=False + ) + + +def _from_json(data, default=None): + """ + Membaca JSON string dari database. + """ + + if default is None: + default = [] + + if data is None: + return default + + if isinstance(data, (list, dict)): + return data + + try: + return json.loads(data) + + except (TypeError, json.JSONDecodeError): + return default + + +def _format_datetime(value): + """ + Mengubah datetime MySQL menjadi string ISO. + """ + + if value is None: + return None + + return value.isoformat( + timespec="seconds" + ) + + +# ============================================================ +# MEMBUAT KONEKSI DATABASE +# ============================================================ + +def get_db_connection(): + + try: + connection = mysql.connector.connect( + **DB_CONFIG + ) + + if not connection.is_connected(): + raise ConnectionError( + "Koneksi ke database MySQL tidak aktif." + ) + + return connection + + except Error as error: + raise ConnectionError( + f"Gagal terhubung ke MySQL: {error}" + ) from error + + +# ============================================================ +# TEST KONEKSI DATABASE +# ============================================================ + +def test_db_connection(): + + connection = None + cursor = None + + try: + connection = get_db_connection() + cursor = connection.cursor() + + cursor.execute( + "SELECT DATABASE();" + ) + + database_name = ( + cursor.fetchone()[0] + ) + + cursor.execute( + "SHOW TABLES;" + ) + + tables = [ + row[0] + for row in cursor.fetchall() + ] + + return { + "status": "success", + "database": database_name, + "tables": tables + } + + finally: + if cursor is not None: + cursor.close() + + if ( + connection is not None + and connection.is_connected() + ): + connection.close() + + +# ============================================================ +# RINGKASAN DATABASE +# ============================================================ + +def get_database_summary(): + + connection = None + cursor = None + + try: + connection = get_db_connection() + cursor = connection.cursor() + + cursor.execute( + "SELECT COUNT(*) FROM analysis_batches" + ) + + total_batches = ( + cursor.fetchone()[0] + ) + + cursor.execute( + "SELECT COUNT(*) FROM analysis_results" + ) + + total_results = ( + cursor.fetchone()[0] + ) + + cursor.execute( + """ + SELECT COUNT(*) + FROM single_prediction_history + """ + ) + + total_single_predictions = ( + cursor.fetchone()[0] + ) + + return { + "total_batches": int( + total_batches + ), + "total_results": int( + total_results + ), + "total_single_predictions": int( + total_single_predictions + ) + } + + finally: + if cursor is not None: + cursor.close() + + if ( + connection is not None + and connection.is_connected() + ): + connection.close() + + +# ============================================================ +# SIMPAN SATU BATCH ANALISIS CSV +# ============================================================ + +def save_analysis_batch( + batch_id, + file_name, + selected_column, + results +): + + connection = None + cursor = None + + versions = {item.get("model_version") for item in results} + if len(versions) != 1 or None in versions: + raise ValueError("Satu batch harus menggunakan satu versi model yang tercatat.") + model_version = versions.pop() + total_data = len(results) + + total_positive = sum( + 1 + for item in results + if item["label"] == 1 + ) + + total_negative = sum( + 1 + for item in results + if item["label"] == 0 + ) + + try: + connection = get_db_connection() + cursor = connection.cursor() + + connection.start_transaction() + + query_batch = """ + INSERT INTO analysis_batches ( + batch_id, + file_name, + selected_column, + total_data, + total_positive, + total_negative, model_version + ) + VALUES ( + %s, %s, %s, + %s, %s, %s, %s + ) + """ + + cursor.execute( + query_batch, + ( + batch_id, + file_name, + selected_column, + total_data, + total_positive, + total_negative, model_version + ) + ) + + query_result = """ + INSERT INTO analysis_results ( + batch_id, + source_row_number, + original_text, + cleaning, + case_folding, + normalization, + tokenizing, + stopword_removal, + stemming, + label, + sentiment, + tfidf_top + ) + VALUES ( + %s, %s, %s, %s, + %s, %s, %s, %s, + %s, %s, %s, %s + ) + """ + + data_results = [] + + for item in results: + + preprocessing = ( + item["preprocessing"] + ) + + data_results.append( + ( + batch_id, + item["source_row_number"], + item["teks"], + preprocessing["cleaning"], + preprocessing["case_folding"], + preprocessing["normalization"], + _to_json( + preprocessing["tokenizing"] + ), + preprocessing["stopword_removal"], + preprocessing["stemming"], + item["label"], + item["sentimen"], + _to_json( + item["tfidf_top"] + ) + ) + ) + + cursor.executemany( + query_result, + data_results + ) + + connection.commit() + + return { + "batch_id": batch_id, + "file_name": file_name, + "selected_column": selected_column, + "total_data": total_data, + "total_positive": total_positive, + "total_negative": total_negative, "model_version": model_version + } + + except Exception: + + if connection is not None: + connection.rollback() + + raise + + finally: + if cursor is not None: + cursor.close() + + if ( + connection is not None + and connection.is_connected() + ): + connection.close() + + +# ============================================================ +# MEMBACA DETAIL SATU BATCH CSV +# ============================================================ + +def get_analysis_batch( + batch_id +): + + connection = None + cursor = None + + try: + connection = get_db_connection() + + cursor = connection.cursor( + dictionary=True + ) + + cursor.execute( + """ + SELECT + batch_id, + file_name, + selected_column, + total_data, + total_positive, + total_negative, + model_version, + created_at + FROM analysis_batches + WHERE batch_id = %s + LIMIT 1 + """, + ( + batch_id, + ) + ) + + batch = cursor.fetchone() + + if batch is None: + return None + + cursor.execute( + """ + SELECT + id, + source_row_number, + original_text, + cleaning, + case_folding, + normalization, + tokenizing, + stopword_removal, + stemming, + label, + sentiment, + tfidf_top, + created_at + FROM analysis_results + WHERE batch_id = %s + ORDER BY + source_row_number ASC, + id ASC + """, + ( + batch_id, + ) + ) + + rows = cursor.fetchall() + + results = [] + + for row in rows: + + tokenizing = _from_json( + row["tokenizing"], + [] + ) + + tfidf_top = _from_json( + row["tfidf_top"], + [] + ) + + results.append( + { + "id": int( + row["id"] + ), + + "source_row_number": int( + row["source_row_number"] + ), + + "teks": ( + row["original_text"] + ), + + "label": int( + row["label"] + ), + + "sentimen": ( + row["sentiment"] + ), + + "preprocessing": { + "cleaning": + row["cleaning"] + or "", + + "case_folding": + row["case_folding"] + or "", + + "normalization": + row["normalization"] + or "", + + "tokenizing": + tokenizing, + + "stopword_removal": + row["stopword_removal"] + or "", + + "stemming": + row["stemming"] + or "" + }, + + "tfidf_top": + tfidf_top + } + ) + + return { + "batch_id": + batch["batch_id"], + "model_version": batch.get("model_version"), + + "file_name": + batch["file_name"], + + "selected_column": + batch["selected_column"], + + "total_data": int( + batch["total_data"] + ), + + "total_positive": int( + batch["total_positive"] + ), + + "total_negative": int( + batch["total_negative"] + ), + + "created_at": + _format_datetime( + batch["created_at"] + ), + + "results": + results + } + + finally: + if cursor is not None: + cursor.close() + + if ( + connection is not None + and connection.is_connected() + ): + connection.close() + + +# ============================================================ +# MEMBACA SEMUA RIWAYAT BATCH CSV +# ============================================================ + +def get_analysis_history(): + + connection = None + cursor = None + + try: + connection = get_db_connection() + + cursor = connection.cursor( + dictionary=True + ) + + cursor.execute( + """ + SELECT + batch_id, + file_name, + selected_column, + total_data, + total_positive, + total_negative, + model_version, + created_at + FROM analysis_batches + ORDER BY + created_at DESC, + id DESC + """ + ) + + rows = cursor.fetchall() + + history = [] + + for row in rows: + + history.append( + { + "batch_id": + row["batch_id"], + "model_version": row.get("model_version"), + + "file_name": + row["file_name"], + + "selected_column": + row["selected_column"], + + "total_data": int( + row["total_data"] + ), + + "total_positive": int( + row["total_positive"] + ), + + "total_negative": int( + row["total_negative"] + ), + + "created_at": + _format_datetime( + row["created_at"] + ) + } + ) + + return history + + finally: + if cursor is not None: + cursor.close() + + if ( + connection is not None + and connection.is_connected() + ): + connection.close() + + +# ============================================================ +# SIMPAN PREDIKSI SATU ULASAN +# ============================================================ + +def save_single_prediction( + result +): + """ + Menyimpan satu hasil dari fungsi analisis_teks() + ke tabel single_prediction_history. + """ + + connection = None + cursor = None + + preprocessing = ( + result.get( + "preprocessing", + {} + ) + ) + + try: + connection = get_db_connection() + cursor = connection.cursor() + + query = """ + INSERT INTO single_prediction_history ( + original_text, + cleaning, + case_folding, + normalization, + tokenizing, + stopword_removal, + stemming, + jumlah_fitur_dikenali, + label, + sentiment, + tfidf_top, model_version + ) + VALUES ( + %s, %s, %s, %s, + %s, %s, %s, %s, + %s, %s, %s, %s + ) + """ + + values = ( + result.get( + "teks", + "" + ), + + preprocessing.get( + "cleaning", + "" + ), + + preprocessing.get( + "case_folding", + "" + ), + + preprocessing.get( + "normalization", + "" + ), + + _to_json( + preprocessing.get( + "tokenizing", + [] + ) + ), + + preprocessing.get( + "stopword_removal", + "" + ), + + preprocessing.get( + "stemming", + "" + ), + + int( + result.get( + "jumlah_fitur_dikenali", + 0 + ) + ), + + int( + result["label"] + ), + + result["sentimen"], + + _to_json( + result.get( + "tfidf_top", + [] + ) + ) + ) + + values = (*values, result["model_version"]) + + cursor.execute( + query, + values + ) + + prediction_id = ( + cursor.lastrowid + ) + + connection.commit() + + return int( + prediction_id + ) + + except Exception: + + if connection is not None: + connection.rollback() + + raise + + finally: + if cursor is not None: + cursor.close() + + if ( + connection is not None + and connection.is_connected() + ): + connection.close() + + +# ============================================================ +# MEMBACA SEMUA RIWAYAT PREDIKSI SATU ULASAN +# ============================================================ + +def get_single_prediction_history(): + + connection = None + cursor = None + + try: + connection = get_db_connection() + + cursor = connection.cursor( + dictionary=True + ) + + cursor.execute( + """ + SELECT + id, + original_text, + jumlah_fitur_dikenali, + label, + sentiment, + model_version, + created_at + FROM single_prediction_history + ORDER BY + created_at DESC, + id DESC + """ + ) + + rows = cursor.fetchall() + + history = [] + + for row in rows: + + history.append( + { + "id": int( + row["id"] + ), + + "teks": + row["original_text"], + "model_version": row.get("model_version"), + + "jumlah_fitur_dikenali": + int( + row[ + "jumlah_fitur_dikenali" + ] + or 0 + ), + + "label": int( + row["label"] + ), + + "sentimen": + row["sentiment"], + + "created_at": + _format_datetime( + row["created_at"] + ) + } + ) + + return history + + finally: + if cursor is not None: + cursor.close() + + if ( + connection is not None + and connection.is_connected() + ): + connection.close() + + +# ============================================================ +# MEMBACA DETAIL SATU PREDIKSI +# ============================================================ + +def get_single_prediction( + prediction_id +): + + connection = None + cursor = None + + try: + connection = get_db_connection() + + cursor = connection.cursor( + dictionary=True + ) + + cursor.execute( + """ + SELECT + id, + original_text, + cleaning, + case_folding, + normalization, + tokenizing, + stopword_removal, + stemming, + jumlah_fitur_dikenali, + label, + sentiment, + tfidf_top, + model_version, + created_at + FROM single_prediction_history + WHERE id = %s + LIMIT 1 + """, + ( + prediction_id, + ) + ) + + row = cursor.fetchone() + + if row is None: + return None + + tokenizing = _from_json( + row["tokenizing"], + [] + ) + + tfidf_top = _from_json( + row["tfidf_top"], + [] + ) + + return { + "id": int( + row["id"] + ), + + "teks": + row["original_text"], + "model_version": row.get("model_version"), + + "label": int( + row["label"] + ), + + "sentimen": + row["sentiment"], + + "jumlah_fitur_dikenali": + int( + row[ + "jumlah_fitur_dikenali" + ] + or 0 + ), + + "preprocessing": { + "cleaning": + row["cleaning"] + or "", + + "case_folding": + row["case_folding"] + or "", + + "normalization": + row["normalization"] + or "", + + "tokenizing": + tokenizing, + + "stopword_removal": + row["stopword_removal"] + or "", + + "stemming": + row["stemming"] + or "" + }, + + "tfidf_top": + tfidf_top, + + "created_at": + _format_datetime( + row["created_at"] + ) + } + + finally: + if cursor is not None: + cursor.close() + + if ( + connection is not None + and connection.is_connected() + ): + connection.close() + + +# ============================================================ +# TEST LANGSUNG DARI TERMINAL +# ============================================================ + +if __name__ == "__main__": + + hasil = test_db_connection() + + summary = ( + get_database_summary() + ) + + print( + "KONEKSI MYSQL BERHASIL" + ) + + print( + "Database :", + hasil["database"] + ) + + print( + "Tabel :", + hasil["tables"] + ) + + print( + "Batch :", + summary["total_batches"] + ) + + print( + "Hasil CSV:", + summary["total_results"] + ) + + print( + "Prediksi :", + summary[ + "total_single_predictions" + ] + ) \ No newline at end of file diff --git a/api_slearn/export_research_tfidf.py b/api_slearn/export_research_tfidf.py new file mode 100644 index 0000000..cb70f2e --- /dev/null +++ b/api_slearn/export_research_tfidf.py @@ -0,0 +1,681 @@ +# ============================================================ +# EXPORT DATA PENELITIAN TF-IDF UNTUK WEBSITE S-LEARN +# VERSI FINAL — TF-IDF TANPA NORMALISASI L2 (norm=None) +# ============================================================ + +from pathlib import Path +from collections import Counter +from datetime import datetime +import json +import shutil + +import joblib +import numpy as np +import pandas as pd + +from sklearn.model_selection import train_test_split + + +# ============================================================ +# 1. PATH PROJECT +# ============================================================ + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent + +ARTIFACT_DIR = BASE_DIR / "artifacts_slearn" + +VECTORIZER_PATH = ( + ARTIFACT_DIR / "tfidf_vectorizer.joblib" +) + +PUBLIC_DATA_DIR = ( + PROJECT_DIR + / "web_slearn" + / "public" + / "data" +) + +CSV_PATH = ( + PUBLIC_DATA_DIR + / "hasil_preprocessing_slearn.csv" +) + +OUTPUT_PATH = ( + PUBLIC_DATA_DIR + / "tfidf_research.json" +) + + +# ============================================================ +# 2. CEK FILE YANG DIBUTUHKAN +# ============================================================ + +print("=" * 80) +print("EXPORT DATA TF-IDF PENELITIAN S-LEARN") +print("=" * 80) + +if not VECTORIZER_PATH.exists(): + raise FileNotFoundError( + f"Vectorizer tidak ditemukan:\n{VECTORIZER_PATH}" + ) + +if not CSV_PATH.exists(): + raise FileNotFoundError( + f"CSV preprocessing tidak ditemukan:\n{CSV_PATH}" + ) + +PUBLIC_DATA_DIR.mkdir( + parents=True, + exist_ok=True +) + + +# ============================================================ +# 3. MEMBACA DATA PREPROCESSING +# ============================================================ + +print("\nMembaca data preprocessing...") + +df = pd.read_csv( + CSV_PATH, + sep=None, + engine="python", + encoding="utf-8-sig" +) + +kolom_wajib = { + "komentar", + "label", + "stemmed_text" +} + +kolom_hilang = kolom_wajib - set(df.columns) + +if kolom_hilang: + raise ValueError( + "Kolom berikut tidak ditemukan pada CSV: " + + ", ".join(sorted(kolom_hilang)) + ) + +df["komentar"] = ( + df["komentar"] + .fillna("") + .astype(str) +) + +df["stemmed_text"] = ( + df["stemmed_text"] + .fillna("") + .astype(str) +) + +df["label"] = pd.to_numeric( + df["label"], + errors="raise" +).astype(int) + +if not df["label"].isin([0, 1]).all(): + raise ValueError( + "Label harus hanya berisi 0 atau 1." + ) + +if len(df) != 571: + raise ValueError( + f"Jumlah data seharusnya 571, " + f"tetapi ditemukan {len(df)}." + ) + +print(f"✓ Jumlah data: {len(df)}") + + +# ============================================================ +# 4. MEMUAT TF-IDF VECTORIZER FINAL +# ============================================================ + +print("\nMemuat TF-IDF vectorizer final...") + +tfidf = joblib.load( + VECTORIZER_PATH +) + +norm_tfidf = tfidf.get_params().get("norm") + +print( + f"Norm TF-IDF: {norm_tfidf}" +) + +if norm_tfidf is not None: + raise ValueError( + "Vectorizer yang digunakan masih memakai " + f"normalisasi TF-IDF: {norm_tfidf}. " + "Vectorizer final seharusnya norm=None." + ) + +kata_unik = tfidf.get_feature_names_out() + +print( + f"✓ Jumlah fitur TF-IDF: " + f"{len(kata_unik)}" +) + +if len(kata_unik) != 675: + raise ValueError( + f"Jumlah fitur seharusnya 675, " + f"tetapi ditemukan {len(kata_unik)}." + ) + + +# ============================================================ +# 5. MEMBENTUK ULANG SPLIT 80:20 +# SESUAI PENELITIAN +# ============================================================ + +print("\nMembentuk ulang split penelitian...") + +semua_index = df.index.to_numpy() + +train_index, test_index = train_test_split( + semua_index, + test_size=0.2, + random_state=42, + stratify=df["label"].to_numpy() +) + +print( + f"✓ Data training: {len(train_index)}" +) + +print( + f"✓ Data testing : {len(test_index)}" +) + +if len(train_index) != 456: + raise ValueError( + "Jumlah training tidak sama dengan 456." + ) + +if len(test_index) != 115: + raise ValueError( + "Jumlah testing tidak sama dengan 115." + ) + + +# ============================================================ +# 6. TRANSFORM TF-IDF +# TIDAK MELAKUKAN FIT ULANG +# ============================================================ + +print( + "\nMengubah teks menjadi bobot TF-IDF " + "menggunakan vectorizer final..." +) + +X_train_text = ( + df.loc[train_index, "stemmed_text"] + .astype(str) +) + +X_test_text = ( + df.loc[test_index, "stemmed_text"] + .astype(str) +) + +# HANYA TRANSFORM. +# Vectorizer sudah difit pada penelitian final. +X_train = tfidf.transform( + X_train_text +) + +X_test = tfidf.transform( + X_test_text +) + +print( + f"✓ Matriks training: {X_train.shape}" +) + +print( + f"✓ Matriks testing : {X_test.shape}" +) + +if X_train.shape != (456, 675): + raise ValueError( + "Ukuran matriks training tidak sesuai." + ) + +if X_test.shape != (115, 675): + raise ValueError( + "Ukuran matriks testing tidak sesuai." + ) + + +# ============================================================ +# 7. MENGHITUNG DF DARI DATA TRAINING +# ============================================================ + +print( + "\nMenghitung Document Frequency (DF) " + "dari data training..." +) + +df_per_fitur = np.asarray( + (X_train > 0).sum(axis=0) +).ravel().astype(int) + +idf_per_fitur = np.asarray( + tfidf.idf_ +) + +if len(df_per_fitur) != len(kata_unik): + raise ValueError( + "Jumlah DF tidak sama dengan jumlah fitur." + ) + +if len(idf_per_fitur) != len(kata_unik): + raise ValueError( + "Jumlah IDF tidak sama dengan jumlah fitur." + ) + +print("✓ DF dan IDF berhasil disiapkan.") + + +# ============================================================ +# 8. MEMBUAT POSISI TRAINING DAN TESTING +# ============================================================ + +posisi_training = { + int(index): urutan + for urutan, index + in enumerate( + train_index, + start=1 + ) +} + +posisi_testing = { + int(index): urutan + for urutan, index + in enumerate( + test_index, + start=1 + ) +} + + +# ============================================================ +# 9. MEMBENTUK DETAIL TF-IDF PER DATA +# ============================================================ + +def buat_data_tfidf( + index_array, + matrix, + jenis_data +): + hasil = [] + + for posisi_matrix, index_asli in enumerate( + index_array + ): + index_asli = int(index_asli) + + baris_matrix = matrix.getrow( + posisi_matrix + ) + + stemmed_text = str( + df.at[ + index_asli, + "stemmed_text" + ] + ).strip() + + hitung_kata = Counter( + stemmed_text.split() + ) + + bobot_tfidf = [] + + for kolom, bobot in zip( + baris_matrix.indices, + baris_matrix.data + ): + kata = str( + kata_unik[kolom] + ) + + tf = int( + hitung_kata.get( + kata, + 0 + ) + ) + + df_kata = int( + df_per_fitur[kolom] + ) + + idf = float( + idf_per_fitur[kolom] + ) + + bobot = float( + bobot + ) + + bobot_tfidf.append({ + "kata": kata, + "tf": tf, + "df": df_kata, + "idf": idf, + "bobot": bobot + }) + + # Bobot terbesar ditampilkan lebih dulu. + bobot_tfidf.sort( + key=lambda item: ( + -item["bobot"], + item["kata"] + ) + ) + + if jenis_data == "Training": + urutan_split = posisi_training[ + index_asli + ] + else: + urutan_split = posisi_testing[ + index_asli + ] + + hasil.append({ + "no_data": index_asli + 1, + "ulasan_asli": str( + df.at[ + index_asli, + "komentar" + ] + ), + "stemmed_text": stemmed_text, + "label": int( + df.at[ + index_asli, + "label" + ] + ), + "jenis_data": jenis_data, + "urutan_split": int( + urutan_split + ), + "jumlah_fitur_nonzero": int( + baris_matrix.nnz + ), + "bobot_tfidf": bobot_tfidf + }) + + return hasil + + +print( + "\nMenyusun detail TF-IDF " + "seluruh 571 data..." +) + +data_training = buat_data_tfidf( + train_index, + X_train, + "Training" +) + +data_testing = buat_data_tfidf( + test_index, + X_test, + "Testing" +) + +data_semua = ( + data_training + + data_testing +) + +# Agar website menampilkan +# Data ke-1 sampai Data ke-571. +data_semua.sort( + key=lambda item: item["no_data"] +) + +if len(data_semua) != 571: + raise ValueError( + "Jumlah hasil TF-IDF bukan 571 data." + ) + +print( + "✓ Detail TF-IDF 571 data berhasil dibuat." +) + + +# ============================================================ +# 10. MEMBENTUK JSON FINAL +# ============================================================ + +hasil_json = { + "summary": { + "jumlah_data": 571, + "jumlah_fitur": int( + len(kata_unik) + ), + "data_training": int( + len(train_index) + ), + "data_testing": int( + len(test_index) + ), + "ukuran_matriks_training": [ + int(X_train.shape[0]), + int(X_train.shape[1]) + ], + "ukuran_matriks_testing": [ + int(X_test.shape[0]), + int(X_test.shape[1]) + ], + "test_size": 0.2, + "random_state": 42, + "stratify": "label", + "norm": None, + "smooth_idf": True, + "use_idf": True, + "sublinear_tf": False, + "keterangan": ( + "TF-IDF dihitung tanpa normalisasi L2 " + "(norm=None). Bobot akhir merupakan " + "hasil TF × IDF." + ) + }, + "data": data_semua +} + + +# ============================================================ +# 11. BACKUP JSON LAMA +# ============================================================ + +if OUTPUT_PATH.exists(): + timestamp = datetime.now().strftime( + "%Y%m%d_%H%M%S" + ) + + backup_path = ( + PUBLIC_DATA_DIR + / f"tfidf_research_backup_{timestamp}.json" + ) + + shutil.copy2( + OUTPUT_PATH, + backup_path + ) + + print( + "\n✓ JSON lama dibackup:" + ) + + print( + f" {backup_path.name}" + ) + + +# ============================================================ +# 12. MENYIMPAN JSON BARU +# ============================================================ + +with open( + OUTPUT_PATH, + "w", + encoding="utf-8" +) as file: + json.dump( + hasil_json, + file, + ensure_ascii=False, + indent=2 + ) + +print( + "\n✓ JSON TF-IDF baru berhasil disimpan:" +) + +print( + f" {OUTPUT_PATH}" +) + + +# ============================================================ +# 13. VERIFIKASI FILE HASIL +# ============================================================ + +with open( + OUTPUT_PATH, + "r", + encoding="utf-8" +) as file: + verifikasi = json.load( + file + ) + +if len( + verifikasi.get( + "data", + [] + ) +) != 571: + raise ValueError( + "Verifikasi gagal: jumlah data JSON bukan 571." + ) + +summary_verifikasi = verifikasi.get( + "summary", + {} +) + +if summary_verifikasi.get( + "jumlah_fitur" +) != 675: + raise ValueError( + "Verifikasi gagal: jumlah fitur bukan 675." + ) + +if summary_verifikasi.get( + "norm", + "SALAH" +) is not None: + raise ValueError( + "Verifikasi gagal: norm bukan None." + ) + + +# ============================================================ +# 14. MENCARI BOBOT TF-IDF TERBESAR +# ============================================================ + +bobot_terbesar = 0.0 +kata_bobot_terbesar = "-" +data_bobot_terbesar = None + +for item in data_semua: + for fitur in item["bobot_tfidf"]: + if fitur["bobot"] > bobot_terbesar: + bobot_terbesar = fitur["bobot"] + kata_bobot_terbesar = fitur["kata"] + data_bobot_terbesar = item["no_data"] + + +# ============================================================ +# 15. OUTPUT FINAL +# ============================================================ + +print("\n" + "=" * 80) +print("EXPORT TF-IDF PENELITIAN BERHASIL") +print("=" * 80) + +print( + f"Jumlah data : " + f"{len(data_semua)}" +) + +print( + f"Data training : " + f"{len(train_index)}" +) + +print( + f"Data testing : " + f"{len(test_index)}" +) + +print( + f"Jumlah fitur : " + f"{len(kata_unik)}" +) + +print( + f"Ukuran matriks training : " + f"{X_train.shape}" +) + +print( + f"Ukuran matriks testing : " + f"{X_test.shape}" +) + +print( + f"Normalisasi TF-IDF : " + f"{norm_tfidf}" +) + +print( + "Bobot akhir : " + "TF × IDF" +) + +print( + f"Bobot terbesar : " + f"{bobot_terbesar:.6f}" +) + +print( + f"Kata bobot terbesar : " + f"{kata_bobot_terbesar}" +) + +print( + f"Berada pada Data ke : " + f"{data_bobot_terbesar}" +) + +print( + f"\nFile output:\n{OUTPUT_PATH}" +) + +print( + "\n✓ TF-IDF website sekarang menggunakan " + "hasil penelitian TANPA normalisasi L2." +) \ No newline at end of file diff --git a/api_slearn/main.py b/api_slearn/main.py new file mode 100644 index 0000000..d2dbb2d --- /dev/null +++ b/api_slearn/main.py @@ -0,0 +1,487 @@ +from uuid import uuid4 + +from fastapi import FastAPI, HTTPException, BackgroundTasks +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel, Field +from typing import Literal +from training_input import validate_column +from model_registry import bundle, history, activate, queue, train_job + +from model_service import ( + analisis_teks, + preprocessing_detail, + get_model_info +) + +from database import ( + test_db_connection, + get_database_summary, + save_analysis_batch, + get_analysis_batch, + get_analysis_history, + save_single_prediction, + get_single_prediction_history, + get_single_prediction +) + + +# ============================================================ +# FASTAPI +# ============================================================ + +app = FastAPI( + title="S-Learn Sentiment Analysis API", + description=( + "Backend API analisis sentimen S-Learn " + "menggunakan TF-IDF dan Logistic Regression." + ), + version="1.0.0" +) + + +# ============================================================ +# CORS +# ============================================================ + +app.add_middleware( + CORSMiddleware, + allow_origin_regex=r"http://(localhost|127\.0\.0\.1):\d+", + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"] +) + + +# ============================================================ +# REQUEST MODEL +# ============================================================ + +class TextRequest(BaseModel): + text: str + + +class CSVReviewItem(BaseModel): + source_row_number: int + text: str + + +class CSVAnalysisRequest(BaseModel): + file_name: str + selected_column: str + reviews: list[CSVReviewItem] + + +class TrainingRequest(BaseModel): + selected_column: str = Field(min_length=1, max_length=200) + confirmed_review_column: Literal[True] + file_name: str = Field(min_length=1, max_length=200) + reviews: list[str] = Field(min_length=1, max_length=5000) + + +@app.get('/models') +def model_history(): + return {'status': 'success', 'data': history()} + + +@app.get('/model-info/original') +def original_model_info(): + return {'status': 'success', 'data': bundle('v1')[2]} + + +@app.post('/models/{version}/activate') +def activate_model(version: str): + try: + return {'status': 'success', 'data': activate(version)} + except ValueError as error: + raise HTTPException(status_code=400, detail=str(error)) from error + + +@app.post('/training', status_code=202) +def training(request: TrainingRequest, background: BackgroundTasks): + try: + validate_column(request.selected_column) + except ValueError as error: + raise HTTPException(status_code=400, detail=str(error)) from error + if any(len(text) > 10000 for text in request.reviews): + raise HTTPException(status_code=400, detail='Panjang ulasan maksimal 10.000 karakter.') + if not any(text.strip() for text in request.reviews): + raise HTTPException(status_code=400, detail='CSV tidak berisi ulasan.') + try: + job_id = queue(request.file_name) + except ValueError as error: + raise HTTPException(status_code=409, detail=str(error)) from error + background.add_task(train_job, job_id, request.reviews, request.selected_column) + return {'status': 'success', 'job_id': job_id} + + +# ============================================================ +# ROOT +# ============================================================ + +@app.get("/") +def root(): + return { + "message": "S-Learn Sentiment Analysis API", + "status": "running" + } + + +# ============================================================ +# HEALTH CHECK BACKEND +# ============================================================ + +@app.get("/health") +def health_check(): + return { + "status": "ok", + "service": "api_slearn", + "model": "loaded" + } + + +# ============================================================ +# HEALTH CHECK DATABASE +# ============================================================ + +@app.get("/database/health") +def database_health(): + try: + koneksi = test_db_connection() + summary = get_database_summary() + + return { + "status": "success", + "message": "Koneksi MySQL berhasil.", + "database": koneksi["database"], + "tables": koneksi["tables"], + "summary": summary + } + + except ConnectionError as error: + raise HTTPException( + status_code=500, + detail=str(error) + ) + + +# ============================================================ +# INFORMASI MODEL +# ============================================================ + +@app.get("/model-info") +def model_info(): + return { + "status": "success", + "data": get_model_info() + } + + +# ============================================================ +# PREPROCESSING SATU ULASAN +# ============================================================ + +@app.post("/preprocess") +def preprocess(request: TextRequest): + try: + hasil = preprocessing_detail( + request.text + ) + + return { + "status": "success", + "data": hasil + } + + except ValueError as error: + raise HTTPException( + status_code=400, + detail=str(error) + ) + + +# ============================================================ +# PREDIKSI SATU ULASAN +# ============================================================ + +@app.post("/predict") +def predict(request: TextRequest): + try: + # ---------------------------------------------------- + # 1. ANALISIS TEKS + # ---------------------------------------------------- + + hasil = analisis_teks( + request.text + ) + + # ---------------------------------------------------- + # 2. SIMPAN HASIL KE MYSQL + # ---------------------------------------------------- + + try: + prediction_id = save_single_prediction( + hasil + ) + + except Exception as error: + raise HTTPException( + status_code=500, + detail=( + "Prediksi berhasil dilakukan, " + "tetapi hasil gagal disimpan " + "ke database. " + f"{str(error)}" + ) + ) + + # ---------------------------------------------------- + # 3. KEMBALIKAN HASIL + # ---------------------------------------------------- + + return { + "status": "success", + "message": ( + "Prediksi berhasil dilakukan " + "dan disimpan ke riwayat." + ), + "data": { + **hasil, + "prediction_id": prediction_id + } + } + + except HTTPException: + raise + + except ValueError as error: + raise HTTPException( + status_code=400, + detail=str(error) + ) + + +# ============================================================ +# RIWAYAT PREDIKSI SATU ULASAN +# ============================================================ + +@app.get("/predictions") +def prediction_history(): + try: + history = get_single_prediction_history() + + return { + "status": "success", + "data": history, + "total": len(history) + } + + except Exception as error: + raise HTTPException( + status_code=500, + detail=( + "Gagal mengambil riwayat " + "Prediksi Satu Ulasan. " + f"{str(error)}" + ) + ) + + +# ============================================================ +# DETAIL SATU PREDIKSI +# ============================================================ + +@app.get("/predictions/{prediction_id}") +def prediction_detail( + prediction_id: int +): + if prediction_id < 1: + raise HTTPException( + status_code=400, + detail="ID prediksi tidak valid." + ) + + try: + hasil = get_single_prediction( + prediction_id + ) + + if hasil is None: + raise HTTPException( + status_code=404, + detail="Riwayat prediksi tidak ditemukan." + ) + + return { + "status": "success", + "data": hasil + } + + except HTTPException: + raise + + except Exception as error: + raise HTTPException( + status_code=500, + detail=( + "Gagal mengambil detail prediksi. " + f"{str(error)}" + ) + ) + + +# ============================================================ +# ANALISIS CSV +# ============================================================ + +@app.post("/analysis/csv") +def analysis_csv( + request: CSVAnalysisRequest +): + if not request.file_name.strip(): + raise HTTPException( + status_code=400, + detail="Nama file CSV tidak boleh kosong." + ) + + if not request.selected_column.strip(): + raise HTTPException( + status_code=400, + detail="Kolom ulasan belum dipilih." + ) + + if not request.reviews: + raise HTTPException( + status_code=400, + detail="Tidak ada ulasan yang dapat dianalisis." + ) + + batch_id = ( + "BATCH-" + + uuid4().hex[:12].upper() + ) + + hasil_analisis = [] + snapshot = bundle() + + for review in request.reviews: + + if review.source_row_number < 1: + raise HTTPException( + status_code=400, + detail="Nomor baris CSV tidak valid." + ) + + try: + hasil = analisis_teks( + review.text, snapshot=snapshot + ) + + except ValueError as error: + raise HTTPException( + status_code=400, + detail=( + f"Baris {review.source_row_number}: " + f"{str(error)}" + ) + ) + + hasil[ + "source_row_number" + ] = review.source_row_number + + hasil_analisis.append( + hasil + ) + + try: + batch = save_analysis_batch( + batch_id=batch_id, + file_name=request.file_name.strip(), + selected_column=request.selected_column.strip(), + results=hasil_analisis + ) + + except Exception as error: + raise HTTPException( + status_code=500, + detail=( + "Analisis berhasil dilakukan, " + "tetapi hasil gagal disimpan " + "ke database. " + f"{str(error)}" + ) + ) + + return { + "status": "success", + "message": ( + "CSV berhasil dianalisis dan " + "disimpan ke database." + ), + "data": { + **batch, + "results": hasil_analisis + } + } + + +# ============================================================ +# RIWAYAT SEMUA BATCH CSV +# ============================================================ + +@app.get("/analysis") +def analysis_history(): + try: + history = get_analysis_history() + + return { + "status": "success", + "data": history, + "total": len(history) + } + + except Exception as error: + raise HTTPException( + status_code=500, + detail=( + "Gagal mengambil riwayat analisis. " + f"{str(error)}" + ) + ) + + +# ============================================================ +# DETAIL SATU BATCH CSV +# ============================================================ + +@app.get("/analysis/{batch_id}") +def analysis_detail( + batch_id: str +): + try: + hasil = get_analysis_batch( + batch_id.strip() + ) + + if hasil is None: + raise HTTPException( + status_code=404, + detail="Batch analisis tidak ditemukan." + ) + + return { + "status": "success", + "data": hasil + } + + except HTTPException: + raise + + except Exception as error: + raise HTTPException( + status_code=500, + detail=( + "Gagal mengambil hasil analisis. " + f"{str(error)}" + ) + ) diff --git a/api_slearn/migrate_model_versions.py b/api_slearn/migrate_model_versions.py new file mode 100644 index 0000000..c71825d --- /dev/null +++ b/api_slearn/migrate_model_versions.py @@ -0,0 +1,24 @@ +"""Add nullable version columns without modifying historical data. Safe to rerun.""" +from database import get_db_connection + +def migrate(connection): + cursor = connection.cursor() + try: + for table in ('analysis_batches', 'single_prediction_history'): + cursor.execute('SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=%s AND COLUMN_NAME=%s', (table, 'model_version')) + if not cursor.fetchone()[0]: + cursor.execute(f'ALTER TABLE `{table}` ADD COLUMN `model_version` VARCHAR(64) NULL DEFAULT NULL') + print(f'{table}: model_version ditambahkan.') + else: + print(f'{table}: sudah diperbarui.') + connection.commit() + finally: + cursor.close() + +if __name__ == '__main__': + connection = get_db_connection() + try: + migrate(connection) + print('Selesai. Riwayat lama tetap tersimpan dengan versi tidak tercatat.') + finally: + connection.close() diff --git a/api_slearn/model_registry.py b/api_slearn/model_registry.py new file mode 100644 index 0000000..0d7b71a --- /dev/null +++ b/api_slearn/model_registry.py @@ -0,0 +1,259 @@ +"""Versioned local model store. Run one Uvicorn worker (see PANDUAN_REVISI.md).""" +import hashlib +import json +import logging +import os +import sqlite3 +import threading +import unicodedata +from datetime import datetime, timezone +from contextlib import contextmanager +from pathlib import Path +from uuid import uuid4 + +import joblib +import pandas as pd +from sklearn.base import clone +from sklearn.metrics import accuracy_score, classification_report, confusion_matrix +from sklearn.model_selection import train_test_split + +BASE = Path(__file__).resolve().parent +ARTIFACTS = BASE / 'artifacts_slearn' +STORE = Path(os.getenv('SLEARN_MODEL_STORE', str(BASE / 'model_store'))) +STORE.mkdir(parents=True, exist_ok=True) +DB = STORE / 'registry.sqlite3' +_cache = {} +_cache_lock = threading.RLock() +_seed_cache = None + + +def now(): + return datetime.now(timezone.utc).isoformat() + + +@contextmanager +def connect(): + db = sqlite3.connect(DB, timeout=30) + db.row_factory = sqlite3.Row + try: + with db: + yield db + finally: + db.close() + + +def key(text): + return ' '.join(unicodedata.normalize('NFKC', str(text)).casefold().split()) + + +def initialize(): + with connect() as db: + db.executescript(''' + CREATE TABLE IF NOT EXISTS versions ( + id TEXT PRIMARY KEY, created_at TEXT NOT NULL, metadata TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS additions ( + text_key TEXT PRIMARY KEY, text TEXT NOT NULL, stem TEXT NOT NULL, + label INTEGER NOT NULL, confidence REAL NOT NULL, + teacher TEXT NOT NULL, version TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS jobs ( + id TEXT PRIMARY KEY, created_at TEXT NOT NULL, status TEXT NOT NULL, + message TEXT NOT NULL, version TEXT, file_name TEXT NOT NULL); + CREATE UNIQUE INDEX IF NOT EXISTS one_training ON jobs ((1)) + WHERE status IN ('queued', 'running'); + ''') + columns = {r['name'] for r in db.execute('PRAGMA table_info(jobs)')} + if 'audit' not in columns: + db.execute('ALTER TABLE jobs ADD COLUMN audit TEXT') + meta = json.loads((ARTIFACTS / 'metadata_model.json').read_text(encoding='utf-8')) + meta.update(version='v1', label_source='Dataset penelitian berlabel', added_rows=0) + db.execute('INSERT OR IGNORE INTO versions VALUES (?, ?, ?)', + ('v1', meta.get('dibuat_pada_utc', now()), json.dumps(meta))) + db.execute("INSERT OR IGNORE INTO settings VALUES ('active', 'v1')") + # A stopped process cannot finish its queued job. No partial data are committed. + db.execute("UPDATE jobs SET status='failed', message=? WHERE status IN ('queued','running')", + ('Training terhenti saat server dimatikan. Silakan unggah kembali.',)) + + +initialize() + + +def bundle(version=None): + with connect() as db: + version = version or db.execute("SELECT value FROM settings WHERE key='active'").fetchone()[0] + row = db.execute('SELECT metadata FROM versions WHERE id=?', (version,)).fetchone() + if row is None: + raise ValueError('Versi model tidak ditemukan.') + if json.loads(row[0]).get('invalidated'): + raise ValueError('Model ini tidak valid karena input training keliru dan tidak boleh digunakan.') + with _cache_lock: + if version not in _cache: + folder = ARTIFACTS if version == 'v1' else STORE / version + model = joblib.load(folder / 'model_logistic_regression.joblib') + vectorizer = joblib.load(folder / 'tfidf_vectorizer.joblib') + meta = json.loads(row[0]) + if model.n_features_in_ != len(vectorizer.get_feature_names_out()) or list(model.classes_) != [0, 1]: + raise ValueError('Pasangan model dan TF-IDF tidak valid.') + _cache[version] = (model, vectorizer, meta) + return _cache[version] + + +def seed(): + global _seed_cache + if _seed_cache is not None: + return _seed_cache + frame = pd.read_csv(ARTIFACTS / 'training_seed.csv', keep_default_na=False) + model, vec, meta = bundle('v1') + if len(frame) != meta['total_data'] or not set(frame.label).issubset({0, 1}): + raise ValueError('Dataset awal tidak sesuai metadata penelitian.') + train, test = train_test_split(frame, test_size=meta['split']['test_size'], + random_state=meta['split']['random_state'], stratify=frame.label) + predictions = model.predict(vec.transform(test.stemmed_text)) + if confusion_matrix(test.label, predictions, labels=[0, 1]).tolist() != meta['confusion_matrix']: + raise ValueError('Evaluasi model awal tidak cocok. Training dibatalkan agar data uji tidak berubah.') + _seed_cache = (frame, train, test) + return _seed_cache + + +def history(): + with connect() as db: + active = db.execute("SELECT value FROM settings WHERE key='active'").fetchone()[0] + versions = [dict(json.loads(r['metadata']), created_at=r['created_at'], active=r['id'] == active) + for r in db.execute('SELECT * FROM versions ORDER BY rowid DESC')] + jobs = [dict(r) for r in db.execute('SELECT * FROM jobs ORDER BY rowid DESC LIMIT 20')] + for job in jobs: + job['audit'] = json.loads(job['audit']) if job.get('audit') else None + return dict(active_version=active, versions=versions, jobs=jobs) + + +def activate(version): + bundle(version) # Load and validate before changing the active pointer. + with connect() as db: + db.execute("UPDATE settings SET value=? WHERE key='active'", (version,)) + return history() + + +def queue(file_name): + job_id = uuid4().hex + try: + with connect() as db: + db.execute('INSERT INTO jobs (id, created_at, status, message, version, file_name) VALUES (?, ?, ?, ?, ?, ?)', + (job_id, now(), 'queued', 'Menunggu proses training.', None, file_name[:200])) + except sqlite3.IntegrityError: + raise ValueError('Training lain masih berjalan. Tunggu hingga selesai.') from None + return job_id + + +def train_job(job_id, reviews, selected_column=None): + try: + with connect() as db: + db.execute("UPDATE jobs SET status='running', message=? WHERE id=?", + ('Memeriksa data dan membuat label otomatis...', job_id)) + frame, initial_train, test = seed() + teacher_model, teacher_vec, teacher_meta = bundle() + from model_service import preprocessing_detail + with connect() as db: + old = [dict(r) for r in db.execute('SELECT * FROM additions ORDER BY rowid')] + count = db.execute('SELECT COUNT(*) FROM versions').fetchone()[0] + test_text = {key(x) for x in test.komentar} + test_stem = {key(x) for x in test.stemmed_text} + initial_text = {key(x) for x in initial_train.komentar} + old_text = {r['text_key'] for r in old} + seen_stem = {key(x) for x in frame.stemmed_text} | {key(r['stem']) for r in old} + uploaded_text = set() + additions, skipped = [], dict(duplicate=0, empty=0, unknown=0) + skipped_rows = [] + def skip(row_number, text, category, reason): + skipped[category] += 1 + skipped_rows.append(dict(source_row_number=row_number, text=text, reason=reason)) + for index, raw in enumerate(reviews): + text = raw.strip() + row_number = index + 2 # CSV record number, including the header. + if not text: + skip(row_number, text, 'empty', 'Ulasan kosong') + continue + text_key = key(text) + if text_key in test_text: + skip(row_number, text, 'duplicate', 'Cocok dengan data uji yang dilindungi') + continue + if text_key in initial_text: + skip(row_number, text, 'duplicate', 'Sudah ada dalam data latih awal') + continue + if text_key in old_text: + skip(row_number, text, 'duplicate', 'Sudah ada dalam data tambahan sebelumnya') + continue + if text_key in uploaded_text: + skip(row_number, text, 'duplicate', 'Duplikat ulasan dalam file unggahan') + continue + uploaded_text.add(text_key) + stem = preprocessing_detail(text)['stemming'] + if not stem.strip(): + skip(row_number, text, 'empty', 'Tidak ada kata setelah preprocessing') + continue + if key(stem) in test_stem: + skip(row_number, text, 'duplicate', 'Hasil preprocessing cocok dengan data uji yang dilindungi') + continue + if key(stem) in seen_stem: + skip(row_number, text, 'duplicate', 'Hasil preprocessing sama dengan data yang sudah ada') + continue + features = teacher_vec.transform([stem]) + if not features.nnz: + skip(row_number, text, 'unknown', 'Tidak ada fitur yang dikenali model pemberi label') + continue + label = int(teacher_model.predict(features)[0]) + confidence = float(teacher_model.predict_proba(features)[0].max()) + additions.append(dict(text_key=text_key, text=text, stem=stem, label=label, + confidence=confidence, teacher=teacher_meta['version'])) + seen_stem.add(key(stem)) + audit = dict(uploaded_rows=len(reviews), accepted_rows=len(additions), + skipped_rows=skipped_rows, training_before=len(initial_train)+len(old), selected_column=selected_column) + with connect() as db: + db.execute('UPDATE jobs SET audit=? WHERE id=?', (json.dumps(audit), job_id)) + if not additions: + with connect() as db: + db.execute("UPDATE jobs SET status='skipped', message=? WHERE id=?", + ('Tidak ada data baru yang dapat dilatih. Data latih tetap.', job_id)) + return + texts = initial_train.stemmed_text.tolist() + [r['stem'] for r in old + additions] + labels = initial_train.label.tolist() + [r['label'] for r in old + additions] + with connect() as db: + db.execute('UPDATE jobs SET message=? WHERE id=?', + ('Melatih TF-IDF dan Logistic Regression, lalu mengevaluasi model...', job_id)) + base_model, base_vec, base_meta = bundle('v1') + vec, model = clone(base_vec), clone(base_model) + model.fit(vec.fit_transform(texts), labels) + predicted = model.predict(vec.transform(test.stemmed_text)) + report = classification_report(test.label, predicted, labels=[0, 1], output_dict=True, zero_division=0) + version = f'v{count + 1}' + meta = {k: base_meta[k] for k in ('nama_model', 'label_mapping', 'split', 'preprocessing_order')} + meta.update(version=version, total_data=len(labels) + len(test), + jumlah_data_training=len(labels), jumlah_data_testing=len(test), + jumlah_sentimen_negatif=int(labels.count(0) + (test.label == 0).sum()), + jumlah_sentimen_positif=int(labels.count(1) + (test.label == 1).sum()), + jumlah_fitur_tfidf=len(vec.get_feature_names_out()), + akurasi=float(accuracy_score(test.label, predicted)), + confusion_matrix=confusion_matrix(test.label, predicted, labels=[0, 1]).tolist(), + classification_report=report, teacher_version=teacher_meta['version'], + label_source='Pseudo-labeling (label otomatis dari model aktif)', + added_rows=len(additions), cumulative_added_rows=len(old) + len(additions), + audit=audit, skipped=skipped, test_seed_sha256=hashlib.sha256( + (ARTIFACTS / 'training_seed.csv').read_bytes()).hexdigest(), + training_parameters=model.get_params()) + # Write artifacts first; publish the version and corpus together in one transaction. + folder = STORE / version + folder.mkdir(exist_ok=True) + joblib.dump(model, folder / 'model_logistic_regression.joblib') + joblib.dump(vec, folder / 'tfidf_vectorizer.joblib') + (folder / 'metadata_model.json').write_text(json.dumps(meta, indent=2), encoding='utf-8') + with connect() as db: + db.execute('INSERT INTO versions VALUES (?, ?, ?)', (version, now(), json.dumps(meta))) + db.executemany('INSERT INTO additions VALUES (?, ?, ?, ?, ?, ?, ?)', + [(r['text_key'], r['text'], r['stem'], r['label'], r['confidence'], + r['teacher'], version) for r in additions]) + db.execute("UPDATE jobs SET status='completed', message=?, version=? WHERE id=?", + (f'{len(additions)} ulasan ditambahkan. Model siap ditinjau; model aktif tetap.', version, job_id)) + except Exception as error: + logging.exception('Training failed: %s', job_id) + message = str(error) if isinstance(error, ValueError) else 'Training gagal. Periksa log backend lalu coba kembali.' + with connect() as db: + db.execute("UPDATE jobs SET status='failed', message=? WHERE id=?", (message, job_id)) diff --git a/api_slearn/model_service.py b/api_slearn/model_service.py new file mode 100644 index 0000000..52dfff2 --- /dev/null +++ b/api_slearn/model_service.py @@ -0,0 +1,274 @@ +import json +import importlib.util +from pathlib import Path + +import joblib + +# ============================================================ +# PATH +# ============================================================ + +BASE_DIR = Path(__file__).resolve().parent +ARTIFACT_DIR = BASE_DIR / "artifacts_slearn" + +MODEL_PATH = ARTIFACT_DIR / "model_logistic_regression.joblib" +VECTORIZER_PATH = ARTIFACT_DIR / "tfidf_vectorizer.joblib" +METADATA_PATH = ARTIFACT_DIR / "metadata_model.json" +PREPROCESSING_PATH = ARTIFACT_DIR / "preprocessing_slearn.py" + +# ============================================================ +# CEK FILE ARTEFAK +# ============================================================ + +required_files = [ + MODEL_PATH, + VECTORIZER_PATH, + METADATA_PATH, + PREPROCESSING_PATH +] + +for file_path in required_files: + if not file_path.exists(): + raise FileNotFoundError( + f"File artefak tidak ditemukan: {file_path}" + ) + +# ============================================================ +# LOAD PREPROCESSING ASLI PENELITIAN +# ============================================================ + +def load_preprocessing_module(): + spec = importlib.util.spec_from_file_location( + "preprocessing_slearn_artifact", + PREPROCESSING_PATH + ) + + if spec is None or spec.loader is None: + raise ImportError( + "preprocessing_slearn.py tidak dapat dimuat." + ) + + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + return module + + +preprocessing = load_preprocessing_module() + +# ============================================================ +# LOAD MODEL, TF-IDF, METADATA +# ============================================================ + +model = joblib.load(MODEL_PATH) +vectorizer = joblib.load(VECTORIZER_PATH) + +metadata = json.loads( + METADATA_PATH.read_text(encoding="utf-8") +) + +feature_names = vectorizer.get_feature_names_out() + +# ============================================================ +# VERIFIKASI ARTEFAK +# ============================================================ + +jumlah_fitur_metadata = int( + metadata["jumlah_fitur_tfidf"] +) + +if len(feature_names) != jumlah_fitur_metadata: + raise RuntimeError( + "Jumlah fitur TF-IDF tidak sesuai dengan metadata." + ) + +if model.n_features_in_ != len(feature_names): + raise RuntimeError( + "Jumlah fitur model tidak sesuai dengan TF-IDF." + ) + +if model.classes_.tolist() != [0, 1]: + raise RuntimeError( + "Urutan kelas model harus [0, 1]." + ) + +# ============================================================ +# DETAIL PREPROCESSING +# Menggunakan fungsi yang SAMA dari artefak penelitian +# ============================================================ + +def preprocessing_detail(teks): + if not isinstance(teks, str) or not teks.strip(): + raise ValueError( + "Masukkan ulasan terlebih dahulu." + ) + + original_text = teks.strip() + + # 1. Cleaning + cleaned_text = preprocessing.cleaning( + original_text + ) + + # 2. Case Folding + case_folded_text = preprocessing.case_folding( + cleaned_text + ) + + # 3. Normalisasi + normalized_text = preprocessing.normalisasi( + case_folded_text + ) + + normalized_text = ( + preprocessing.koreksi_normalisasi_validator( + normalized_text + ) + ) + + # 4. Tokenisasi + tokenized_text = preprocessing.tokenisasi( + normalized_text + ) + + # 5. Stopword Removal + stopword_text = preprocessing.hapus_stopword( + tokenized_text + ) + + # 6. Stemming + stemmed_text = preprocessing.stemming( + stopword_text + ) + + return { + "original_text": original_text, + "cleaning": cleaned_text, + "case_folding": case_folded_text, + "normalization": normalized_text, + "tokenizing": tokenized_text, + "stopword_removal": stopword_text, + "stemming": stemmed_text + } + +# ============================================================ +# TF-IDF TERATAS +# ============================================================ + +def get_top_tfidf(fitur, top_n=10, names=None): + names = feature_names if names is None else names + if fitur.nnz == 0: + return [] + + row = fitur.getrow(0) + + data = [] + + for index, bobot in zip( + row.indices, + row.data + ): + data.append({ + "kata": str(names[index]), + "bobot": float(bobot) + }) + + data.sort( + key=lambda item: item["bobot"], + reverse=True + ) + + return data[:top_n] + +# ============================================================ +# ANALISIS SATU TEKS +# ============================================================ + +def analisis_teks(teks, snapshot=None): + from model_registry import bundle + model, vectorizer, metadata = snapshot or bundle() + proses = preprocessing_detail(teks) + + stemmed_text = proses["stemming"] + + if not stemmed_text.strip(): + raise ValueError( + "Ulasan tidak memiliki kata yang dapat diproses." + ) + + # PENTING: + # hanya transform(), TIDAK fit ulang + fitur = vectorizer.transform( + [stemmed_text] + ) + + # Jika tidak ada fitur penelitian yang dikenali, + # Logistic Regression TIDAK dijalankan + if fitur.nnz == 0: + raise ValueError( + "Kata dalam ulasan belum dikenali model." + ) + + label = int( + model.predict(fitur)[0] + ) + + sentiment = metadata[ + "label_mapping" + ][str(label)].capitalize() + + tfidf_top = get_top_tfidf( + fitur, + top_n=10, + names=vectorizer.get_feature_names_out() + ) + + return { + "model_version": metadata['version'], + "teks": proses["original_text"], + "label": label, + "sentimen": sentiment, + "preprocessing": { + "cleaning": proses["cleaning"], + "case_folding": proses["case_folding"], + "normalization": proses["normalization"], + "tokenizing": proses["tokenizing"], + "stopword_removal": proses["stopword_removal"], + "stemming": proses["stemming"] + }, + "jumlah_fitur_dikenali": int( + fitur.nnz + ), + "tfidf_top": tfidf_top + } + +# ============================================================ +# INFORMASI MODEL +# ============================================================ + +def get_model_info(): + from model_registry import bundle + _, _, metadata = bundle() + return { + "version": metadata['version'], + "nama_model": metadata["nama_model"], + "total_data": metadata["total_data"], + "jumlah_sentimen_negatif": + metadata["jumlah_sentimen_negatif"], + "jumlah_sentimen_positif": + metadata["jumlah_sentimen_positif"], + "jumlah_data_training": + metadata["jumlah_data_training"], + "jumlah_data_testing": + metadata["jumlah_data_testing"], + "jumlah_fitur_tfidf": + metadata["jumlah_fitur_tfidf"], + "akurasi": + metadata["akurasi"], + "label_mapping": + metadata["label_mapping"], + "confusion_matrix": + metadata["confusion_matrix"], + "classification_report": + metadata["classification_report"] + } diff --git a/api_slearn/model_store/v2/metadata_model.json b/api_slearn/model_store/v2/metadata_model.json new file mode 100644 index 0000000..e01b2c3 --- /dev/null +++ b/api_slearn/model_store/v2/metadata_model.json @@ -0,0 +1,93 @@ +{ + "nama_model": "Logistic Regression", + "label_mapping": { + "0": "NEGATIF", + "1": "POSITIF" + }, + "split": { + "test_size": 0.2, + "random_state": 42, + "stratify": "label" + }, + "preprocessing_order": [ + "cleaning", + "case_folding", + "normalisasi", + "koreksi_normalisasi_validator", + "tokenisasi", + "hapus_stopword", + "stemming" + ], + "version": "v2", + "total_data": 620, + "jumlah_data_training": 505, + "jumlah_data_testing": 115, + "jumlah_sentimen_negatif": 402, + "jumlah_sentimen_positif": 218, + "jumlah_fitur_tfidf": 675, + "akurasi": 0.9304347826086956, + "confusion_matrix": [ + [ + 71, + 5 + ], + [ + 3, + 36 + ] + ], + "classification_report": { + "0": { + "precision": 0.9594594594594594, + "recall": 0.9342105263157895, + "f1-score": 0.9466666666666667, + "support": 76.0 + }, + "1": { + "precision": 0.8780487804878049, + "recall": 0.9230769230769231, + "f1-score": 0.9, + "support": 39.0 + }, + "accuracy": 0.9304347826086956, + "macro avg": { + "precision": 0.9187541199736322, + "recall": 0.9286437246963564, + "f1-score": 0.9233333333333333, + "support": 115.0 + }, + "weighted avg": { + "precision": 0.9318506205038548, + "recall": 0.9304347826086956, + "f1-score": 0.930840579710145, + "support": 115.0 + } + }, + "teacher_version": "v1", + "label_source": "Pseudo-labeling (label otomatis dari model aktif)", + "added_rows": 49, + "cumulative_added_rows": 49, + "skipped": { + "duplicate": 11, + "empty": 0, + "unknown": 0 + }, + "test_seed_sha256": "79ba16d91783a5652de77c6299ac17313188afe5761645c4895516aa8ae9f105", + "training_parameters": { + "C": 1.0, + "class_weight": null, + "dual": false, + "fit_intercept": true, + "intercept_scaling": 1, + "l1_ratio": null, + "max_iter": 1000, + "multi_class": "deprecated", + "n_jobs": null, + "penalty": "l2", + "random_state": null, + "solver": "lbfgs", + "tol": 0.0001, + "verbose": 0, + "warm_start": false + } +} \ No newline at end of file diff --git a/api_slearn/model_store/v2/model_logistic_regression.joblib b/api_slearn/model_store/v2/model_logistic_regression.joblib new file mode 100644 index 0000000..086759f Binary files /dev/null and b/api_slearn/model_store/v2/model_logistic_regression.joblib differ diff --git a/api_slearn/model_store/v2/tfidf_vectorizer.joblib b/api_slearn/model_store/v2/tfidf_vectorizer.joblib new file mode 100644 index 0000000..4c3d756 Binary files /dev/null and b/api_slearn/model_store/v2/tfidf_vectorizer.joblib differ diff --git a/api_slearn/model_store/v3/metadata_model.json b/api_slearn/model_store/v3/metadata_model.json new file mode 100644 index 0000000..a84bea1 --- /dev/null +++ b/api_slearn/model_store/v3/metadata_model.json @@ -0,0 +1,100 @@ +{ + "nama_model": "Logistic Regression", + "label_mapping": { + "0": "NEGATIF", + "1": "POSITIF" + }, + "split": { + "test_size": 0.2, + "random_state": 42, + "stratify": "label" + }, + "preprocessing_order": [ + "cleaning", + "case_folding", + "normalisasi", + "koreksi_normalisasi_validator", + "tokenisasi", + "hapus_stopword", + "stemming" + ], + "version": "v3", + "total_data": 670, + "jumlah_data_training": 555, + "jumlah_data_testing": 115, + "jumlah_sentimen_negatif": 427, + "jumlah_sentimen_positif": 243, + "jumlah_fitur_tfidf": 754, + "akurasi": 0.9478260869565217, + "confusion_matrix": [ + [ + 73, + 3 + ], + [ + 3, + 36 + ] + ], + "classification_report": { + "0": { + "precision": 0.9605263157894737, + "recall": 0.9605263157894737, + "f1-score": 0.9605263157894737, + "support": 76.0 + }, + "1": { + "precision": 0.9230769230769231, + "recall": 0.9230769230769231, + "f1-score": 0.9230769230769231, + "support": 39.0 + }, + "accuracy": 0.9478260869565217, + "macro avg": { + "precision": 0.9418016194331984, + "recall": 0.9418016194331984, + "f1-score": 0.9418016194331984, + "support": 115.0 + }, + "weighted avg": { + "precision": 0.9478260869565217, + "recall": 0.9478260869565217, + "f1-score": 0.9478260869565217, + "support": 115.0 + } + }, + "teacher_version": "v2", + "label_source": "Pseudo-labeling (label otomatis dari model aktif)", + "added_rows": 50, + "cumulative_added_rows": 99, + "audit": { + "uploaded_rows": 50, + "accepted_rows": 50, + "skipped_rows": [], + "training_before": 505, + "selected_column": "ulasan" + }, + "skipped": { + "duplicate": 0, + "empty": 0, + "unknown": 0 + }, + "test_seed_sha256": "79ba16d91783a5652de77c6299ac17313188afe5761645c4895516aa8ae9f105", + "training_parameters": { + "C": 1.0, + "class_weight": null, + "dual": false, + "fit_intercept": true, + "intercept_scaling": 1, + "l1_ratio": null, + "max_iter": 1000, + "multi_class": "deprecated", + "n_jobs": null, + "penalty": "l2", + "random_state": null, + "solver": "lbfgs", + "tol": 0.0001, + "verbose": 0, + "warm_start": false + } +} \ No newline at end of file diff --git a/api_slearn/model_store/v3/model_logistic_regression.joblib b/api_slearn/model_store/v3/model_logistic_regression.joblib new file mode 100644 index 0000000..6f9697b Binary files /dev/null and b/api_slearn/model_store/v3/model_logistic_regression.joblib differ diff --git a/api_slearn/model_store/v3/tfidf_vectorizer.joblib b/api_slearn/model_store/v3/tfidf_vectorizer.joblib new file mode 100644 index 0000000..747a3f9 Binary files /dev/null and b/api_slearn/model_store/v3/tfidf_vectorizer.joblib differ diff --git a/api_slearn/model_store/v4/metadata_model.json b/api_slearn/model_store/v4/metadata_model.json new file mode 100644 index 0000000..a490b32 --- /dev/null +++ b/api_slearn/model_store/v4/metadata_model.json @@ -0,0 +1,100 @@ +{ + "nama_model": "Logistic Regression", + "label_mapping": { + "0": "NEGATIF", + "1": "POSITIF" + }, + "split": { + "test_size": 0.2, + "random_state": 42, + "stratify": "label" + }, + "preprocessing_order": [ + "cleaning", + "case_folding", + "normalisasi", + "koreksi_normalisasi_validator", + "tokenisasi", + "hapus_stopword", + "stemming" + ], + "version": "v4", + "total_data": 720, + "jumlah_data_training": 605, + "jumlah_data_testing": 115, + "jumlah_sentimen_negatif": 457, + "jumlah_sentimen_positif": 263, + "jumlah_fitur_tfidf": 796, + "akurasi": 0.9304347826086956, + "confusion_matrix": [ + [ + 72, + 4 + ], + [ + 4, + 35 + ] + ], + "classification_report": { + "0": { + "precision": 0.9473684210526315, + "recall": 0.9473684210526315, + "f1-score": 0.9473684210526315, + "support": 76.0 + }, + "1": { + "precision": 0.8974358974358975, + "recall": 0.8974358974358975, + "f1-score": 0.8974358974358975, + "support": 39.0 + }, + "accuracy": 0.9304347826086956, + "macro avg": { + "precision": 0.9224021592442645, + "recall": 0.9224021592442645, + "f1-score": 0.9224021592442645, + "support": 115.0 + }, + "weighted avg": { + "precision": 0.9304347826086956, + "recall": 0.9304347826086956, + "f1-score": 0.9304347826086956, + "support": 115.0 + } + }, + "teacher_version": "v2", + "label_source": "Pseudo-labeling (label otomatis dari model aktif)", + "added_rows": 50, + "cumulative_added_rows": 149, + "audit": { + "uploaded_rows": 50, + "accepted_rows": 50, + "skipped_rows": [], + "training_before": 555, + "selected_column": "ulasan" + }, + "skipped": { + "duplicate": 0, + "empty": 0, + "unknown": 0 + }, + "test_seed_sha256": "79ba16d91783a5652de77c6299ac17313188afe5761645c4895516aa8ae9f105", + "training_parameters": { + "C": 1.0, + "class_weight": null, + "dual": false, + "fit_intercept": true, + "intercept_scaling": 1, + "l1_ratio": null, + "max_iter": 1000, + "multi_class": "deprecated", + "n_jobs": null, + "penalty": "l2", + "random_state": null, + "solver": "lbfgs", + "tol": 0.0001, + "verbose": 0, + "warm_start": false + } +} \ No newline at end of file diff --git a/api_slearn/model_store/v4/model_logistic_regression.joblib b/api_slearn/model_store/v4/model_logistic_regression.joblib new file mode 100644 index 0000000..8371ed1 Binary files /dev/null and b/api_slearn/model_store/v4/model_logistic_regression.joblib differ diff --git a/api_slearn/model_store/v4/tfidf_vectorizer.joblib b/api_slearn/model_store/v4/tfidf_vectorizer.joblib new file mode 100644 index 0000000..9de0b25 Binary files /dev/null and b/api_slearn/model_store/v4/tfidf_vectorizer.joblib differ diff --git a/api_slearn/model_store/v5/metadata_model.json b/api_slearn/model_store/v5/metadata_model.json new file mode 100644 index 0000000..9ae575f --- /dev/null +++ b/api_slearn/model_store/v5/metadata_model.json @@ -0,0 +1,100 @@ +{ + "nama_model": "Logistic Regression", + "label_mapping": { + "0": "NEGATIF", + "1": "POSITIF" + }, + "split": { + "test_size": 0.2, + "random_state": 42, + "stratify": "label" + }, + "preprocessing_order": [ + "cleaning", + "case_folding", + "normalisasi", + "koreksi_normalisasi_validator", + "tokenisasi", + "hapus_stopword", + "stemming" + ], + "version": "v5", + "total_data": 770, + "jumlah_data_training": 655, + "jumlah_data_testing": 115, + "jumlah_sentimen_negatif": 482, + "jumlah_sentimen_positif": 288, + "jumlah_fitur_tfidf": 796, + "akurasi": 0.9478260869565217, + "confusion_matrix": [ + [ + 73, + 3 + ], + [ + 3, + 36 + ] + ], + "classification_report": { + "0": { + "precision": 0.9605263157894737, + "recall": 0.9605263157894737, + "f1-score": 0.9605263157894737, + "support": 76.0 + }, + "1": { + "precision": 0.9230769230769231, + "recall": 0.9230769230769231, + "f1-score": 0.9230769230769231, + "support": 39.0 + }, + "accuracy": 0.9478260869565217, + "macro avg": { + "precision": 0.9418016194331984, + "recall": 0.9418016194331984, + "f1-score": 0.9418016194331984, + "support": 115.0 + }, + "weighted avg": { + "precision": 0.9478260869565217, + "recall": 0.9478260869565217, + "f1-score": 0.9478260869565217, + "support": 115.0 + } + }, + "teacher_version": "v4", + "label_source": "Pseudo-labeling (label otomatis dari model aktif)", + "added_rows": 50, + "cumulative_added_rows": 199, + "audit": { + "uploaded_rows": 50, + "accepted_rows": 50, + "skipped_rows": [], + "training_before": 605, + "selected_column": "ulasan" + }, + "skipped": { + "duplicate": 0, + "empty": 0, + "unknown": 0 + }, + "test_seed_sha256": "79ba16d91783a5652de77c6299ac17313188afe5761645c4895516aa8ae9f105", + "training_parameters": { + "C": 1.0, + "class_weight": null, + "dual": false, + "fit_intercept": true, + "intercept_scaling": 1, + "l1_ratio": null, + "max_iter": 1000, + "multi_class": "deprecated", + "n_jobs": null, + "penalty": "l2", + "random_state": null, + "solver": "lbfgs", + "tol": 0.0001, + "verbose": 0, + "warm_start": false + } +} \ No newline at end of file diff --git a/api_slearn/model_store/v5/model_logistic_regression.joblib b/api_slearn/model_store/v5/model_logistic_regression.joblib new file mode 100644 index 0000000..2029aa9 Binary files /dev/null and b/api_slearn/model_store/v5/model_logistic_regression.joblib differ diff --git a/api_slearn/model_store/v5/tfidf_vectorizer.joblib b/api_slearn/model_store/v5/tfidf_vectorizer.joblib new file mode 100644 index 0000000..68c5248 Binary files /dev/null and b/api_slearn/model_store/v5/tfidf_vectorizer.joblib differ diff --git a/api_slearn/preprocessing.py b/api_slearn/preprocessing.py new file mode 100644 index 0000000..f15e73c --- /dev/null +++ b/api_slearn/preprocessing.py @@ -0,0 +1,193 @@ +import re +from nltk.tokenize import word_tokenize +from nltk.corpus import stopwords +from Sastrawi.Stemmer.StemmerFactory import StemmerFactory + +# ============================================================ +# STEMMER SASTRAWI +# ============================================================ + +stemmer_factory = StemmerFactory() +stemmer = stemmer_factory.create_stemmer() + +# ============================================================ +# STOPWORD +# ============================================================ + +partikel_tambahan = { + "sih", + "si", + "kan", + "ya", + "bang", + "dong", + "lah", + "deh", + "kah", + "kok" +} + +kata_negasi = { + "tidak", + "bukan", + "belum", + "jangan", + "tanpa", + "kurang", + "malah" +} + +list_stopwords = set(stopwords.words("indonesian")) +list_stopwords = list_stopwords | partikel_tambahan + +# Kata negasi harus tetap dipertahankan +list_stopwords = list_stopwords - kata_negasi + +# ============================================================ +# KAMUS NORMALISASI +# ============================================================ +# +# Ini baru struktur awal backend. +# Seluruh kamus final dari notebook penelitian nanti +# dimasukkan ke bagian ini agar hasil preprocessing web +# identik dengan penelitian. +# ============================================================ + +normalization_map = { + "download": "unduh", + "upload": "unggah", + "scan": "pindai", + "matkul": "mata kuliah", + "gabisa": "tidak bisa", + "gakbisa": "tidak bisa", + "nggakbisa": "tidak bisa", + "lemot": "lambat", + "submit": "unggah", + "userfriendly": "ramah pengguna", + "gak": "tidak", + "ga": "tidak", + "nggak": "tidak" +} + +phrase_normalization = { + "di gunakan": "digunakan", + "didalam": "di dalam", + "diluar": "di luar" +} + +# ============================================================ +# 1. CASE FOLDING +# ============================================================ + +def case_folding(text): + return str(text).lower() + +# ============================================================ +# 2. CLEANING +# ============================================================ + +def cleaning(text): + text = str(text) + + # Hapus URL + text = re.sub(r"https?://\S+|www\.\S+", " ", text) + + # Hapus mention + text = re.sub(r"@\w+", " ", text) + + # Hapus hashtag beserta simbol # + text = re.sub(r"#\w+", " ", text) + + # Hapus angka + text = re.sub(r"\d+", " ", text) + + # Hanya pertahankan huruf dan spasi + text = re.sub(r"[^a-zA-Z\s]", " ", text) + + # Rapikan spasi + text = re.sub(r"\s+", " ", text).strip() + + return text + +# ============================================================ +# 3. NORMALISASI +# ============================================================ + +def normalization(text): + text = str(text) + + # Normalisasi bentuk/frasa tertentu terlebih dahulu + for source, target in phrase_normalization.items(): + text = re.sub( + rf"\b{re.escape(source)}\b", + target, + text + ) + + words = text.split() + normalized_words = [] + + for word in words: + replacement = normalization_map.get(word, word) + + # Replacement boleh menghasilkan lebih dari satu kata + normalized_words.extend(replacement.split()) + + return " ".join(normalized_words) + +# ============================================================ +# 4. TOKENIZING +# ============================================================ + +def tokenizing(text): + return word_tokenize(str(text)) + +# ============================================================ +# 5. STOPWORD REMOVAL +# ============================================================ + +def stopword_removal(tokens): + return [ + token + for token in tokens + if token not in list_stopwords + ] + +# ============================================================ +# 6. STEMMING +# ============================================================ + +def stemming(tokens): + text = " ".join(tokens) + return stemmer.stem(text) + +# ============================================================ +# PIPELINE PREPROCESSING +# ============================================================ + +def preprocess_text(text): + original_text = str(text) + + casefolded_text = case_folding(original_text) + + cleaned_text = cleaning(casefolded_text) + + normalized_text = normalization(cleaned_text) + + tokenized_text = tokenizing(normalized_text) + + stopword_tokens = stopword_removal(tokenized_text) + + stopword_text = " ".join(stopword_tokens) + + stemmed_text = stemming(stopword_tokens) + + return { + "original_text": original_text, + "case_folding": casefolded_text, + "cleaning": cleaned_text, + "normalization": normalized_text, + "tokenizing": tokenized_text, + "stopword_removal": stopword_text, + "stemming": stemmed_text + } \ No newline at end of file diff --git a/api_slearn/pulihkan_data_prodi.py b/api_slearn/pulihkan_data_prodi.py new file mode 100644 index 0000000..ee18c78 --- /dev/null +++ b/api_slearn/pulihkan_data_prodi.py @@ -0,0 +1,73 @@ +"""Targeted recovery. Stop backend first. Default is read-only inspection.""" +import argparse +import json +import sqlite3 +from datetime import datetime, timezone +from pathlib import Path + +EXPECTED = {'Manajemen Informatika', 'Manajemen Informatika Internasional', + 'Teknik Komputer Internasional', 'Teknik Informatika Internasional'} +REASON = 'Input keliru: empat nama prodi digunakan sebagai ulasan. Tidak boleh diaktifkan.' + +def repair(store, apply=False): + path = store / 'registry.sqlite3' + if not path.is_file(): + raise ValueError('Registry tidak ditemukan.') + db = sqlite3.connect(path.as_uri() + ('?mode=rw' if apply else '?mode=ro'), uri=True) + db.row_factory = sqlite3.Row + try: + if apply: + db.execute('BEGIN IMMEDIATE') + versions = {r['id']: json.loads(r['metadata']) for r in db.execute('SELECT * FROM versions')} + if set(versions) != {'v1', 'v2', 'v3'}: + raise ValueError('Versi berbeda dari kondisi yang diperiksa. Pemulihan dibatalkan.') + if db.execute("SELECT COUNT(*) FROM jobs WHERE status IN ('queued','running')").fetchone()[0]: + raise ValueError('Masih ada training berjalan. Hentikan dan periksa dahulu.') + rows = [dict(r) for r in db.execute("SELECT * FROM additions WHERE version='v3'")] + count = db.execute('SELECT COUNT(*) FROM additions').fetchone()[0] + if not rows and versions['v3'].get('invalidated') and count == 49: + print('Sudah dipulihkan. Kumpulan data latih 505; v3 tidak valid.') + return + if len(rows) != 4 or {r['text'] for r in rows} != EXPECTED or count != 53: + raise ValueError('Data tidak cocok dengan empat prodi yang diperiksa. Tidak ada perubahan.') + if versions['v2']['jumlah_data_training'] != 505: + raise ValueError('Metadata v2 tidak sesuai.') + for name in ('model_logistic_regression.joblib', 'tfidf_vectorizer.joblib'): + if not (store / 'v2' / name).is_file(): + raise ValueError('Berkas model v2 tidak lengkap.') + print('Terverifikasi: 4 nama prodi pada v3; 509 -> 505 data latih.') + if not apply: + print('Pemeriksaan saja. Belum ada data yang diubah. Jalankan dengan --apply untuk memulihkan.') + return + # Make an SQLite-consistent recovery copy before changing any rows. + backup = store.parent / ('registry_sebelum_pemulihan_' + datetime.now().strftime('%Y%m%d_%H%M%S_%f') + '.sqlite3') + source = sqlite3.connect(path.as_uri() + '?mode=ro', uri=True) + dest = sqlite3.connect(backup) + try: + source.backup(dest) + finally: + source.close(); dest.close() + db.execute('CREATE TABLE IF NOT EXISTS quarantined_additions (text_key TEXT PRIMARY KEY, record_json TEXT NOT NULL, reason TEXT NOT NULL, quarantined_at TEXT NOT NULL)') + stamp = datetime.now(timezone.utc).isoformat() + for row in rows: + db.execute('INSERT INTO quarantined_additions VALUES (?,?,?,?)', (row['text_key'], json.dumps(row), REASON, stamp)) + db.execute('DELETE FROM additions WHERE text_key=? AND version=?', (row['text_key'], 'v3')) + versions['v3'].update(invalidated=True, invalid_reason=REASON, invalidated_at=stamp) + db.execute("UPDATE versions SET metadata=? WHERE id='v3'", (json.dumps(versions['v3']),)) + db.execute("UPDATE settings SET value='v2' WHERE key='active'") + db.execute("UPDATE jobs SET message=? WHERE version='v3'", ('Model v3 tidak valid: input prodi. Empat data telah dikarantina; kumpulan training dipulihkan.',)) + db.commit() + print('Selesai: 4 data dikarantina, kumpulan data latih 505, v2 aktif, v3 ditandai tidak valid.') + print('Berkas model dan riwayat analisis tidak dihapus. Cadangan tambahan:', backup) + finally: + db.close() + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument('--store', type=Path, default=Path(__file__).resolve().parent / 'model_store') + parser.add_argument('--apply', action='store_true') + args = parser.parse_args() + try: + repair(args.store.resolve(), args.apply) + except (ValueError, sqlite3.Error) as error: + raise SystemExit(str(error)) diff --git a/api_slearn/requirements.txt b/api_slearn/requirements.txt new file mode 100644 index 0000000..ed64fd3 --- /dev/null +++ b/api_slearn/requirements.txt @@ -0,0 +1,11 @@ +fastapi +uvicorn[standard] +python-multipart +mysql-connector-python +scikit-learn==1.6.1 +numpy==2.1.3 +scipy==1.16.3 +pandas==2.2.3 +nltk==3.9.1 +Sastrawi==1.0.1 +joblib==1.6.0 \ No newline at end of file diff --git a/api_slearn/tests/test_retraining.py b/api_slearn/tests/test_retraining.py new file mode 100644 index 0000000..18084dc --- /dev/null +++ b/api_slearn/tests/test_retraining.py @@ -0,0 +1,87 @@ +"""Run: python -B -m unittest discover -s tests -v (uses a temporary model store).""" +import hashlib +import os +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +_temporary = tempfile.TemporaryDirectory() +os.environ['SLEARN_MODEL_STORE'] = _temporary.name + +import model_registry as registry +from model_service import analisis_teks +from main import TrainingRequest, training +from fastapi import BackgroundTasks, HTTPException + + +class RetrainingTests(unittest.TestCase): + def test_full_lifecycle(self): + baseline = registry.bundle('v1') + frame, train, test = registry.seed() + self.assertEqual((len(train), len(test)), (456, 115)) + self.assertAlmostEqual(baseline[2]['akurasi'], 0.9304347826086956) + original_hash = hashlib.sha256((registry.ARTIFACTS / 'model_logistic_regression.joblib').read_bytes()).hexdigest() + text = 'Aplikasi bagus sangat membantu belajar dengan materi menarik terbaru sekali' + before = analisis_teks(text) + queued = registry.queue('contoh.csv') + with self.assertRaises(ValueError): + registry.queue('duplikat-proses.csv') + registry.train_job(queued, [text, text, test.iloc[0].komentar, '', '123 !!!', 'zzqqxxvv']) + state = registry.history() + self.assertEqual(state['jobs'][0]['status'], 'completed', state['jobs'][0]['message']) + self.assertEqual(state['active_version'], 'v1') + self.assertEqual(state['versions'][0]['jumlah_data_training'], 457) + self.assertEqual(state['versions'][0]['skipped'], {'duplicate': 2, 'empty': 2, 'unknown': 1}) + self.assertEqual(state['versions'][0]['teacher_version'], 'v1') + self.assertEqual(state['versions'][0]['jumlah_data_testing'], 115) + registry.activate('v2') + self.assertEqual(analisis_teks(text)['model_version'], 'v2') + # Old snapshots remain internally consistent during activation. + self.assertEqual(analisis_teks(text, snapshot=baseline), before) + registry._cache.clear() + self.assertEqual(registry.bundle()[2]['version'], 'v2') + registry.activate('v1') + self.assertEqual(analisis_teks(text), before) + self.assertEqual(original_hash, hashlib.sha256((registry.ARTIFACTS / 'model_logistic_regression.joblib').read_bytes()).hexdigest()) + # Training after rollback still includes all previously committed additions. + job = registry.queue('tambahan.csv') + registry.train_job(job, ['Website lambat sering error saat mengumpulkan tugas malam terbaru']) + self.assertEqual(registry.history()['jobs'][0]['status'], 'completed') + self.assertEqual(registry.history()['versions'][0]['jumlah_data_training'], 458) + with self.assertRaises(ValueError): + registry.activate('../../invalid') + self.assertEqual(registry.history()['active_version'], 'v1') + failed = registry.queue('hanya-data-lama.csv') + registry.train_job(failed, [text, test.iloc[1].komentar]) + state = registry.history() + self.assertEqual(state['jobs'][0]['status'], 'skipped') + self.assertEqual(len(state['versions']), 3) + with registry.connect() as db: + self.assertEqual(db.execute('SELECT COUNT(*) FROM additions').fetchone()[0], 2) + failed_fit = registry.queue('gagal-fit.csv') + with patch('sklearn.linear_model.LogisticRegression.fit', side_effect=RuntimeError('simulated failure')): + registry.train_job(failed_fit, ['Materi pembelajaran bagus membantu mahasiswa mengerjakan latihan unik']) + self.assertEqual(registry.history()['jobs'][0]['status'], 'failed') + self.assertEqual(len(registry.history()['versions']), 3) + with registry.connect() as db: + self.assertEqual(db.execute('SELECT COUNT(*) FROM additions').fetchone()[0], 2) + registry.queue('terputus.csv') + registry.initialize() + self.assertEqual(registry.history()['jobs'][0]['status'], 'failed') + + def test_request_validation(self): + for reviews in [[' '], ['x' * 10001]]: + with self.assertRaises(HTTPException): + training(TrainingRequest(file_name='invalid.csv', reviews=reviews, selected_column='ulasan', confirmed_review_column=True), BackgroundTasks()) + + def test_predict_route_uses_active_model(self): + from main import predict, TextRequest + with patch('main.save_single_prediction', return_value=123): + response = predict(TextRequest(text='Aplikasi bagus membantu belajar')) + self.assertEqual(response['data']['prediction_id'], 123) + self.assertEqual(response['data']['model_version'], registry.history()['active_version']) + + +if __name__ == '__main__': + unittest.main() diff --git a/api_slearn/training_input.py b/api_slearn/training_input.py new file mode 100644 index 0000000..94c43d5 --- /dev/null +++ b/api_slearn/training_input.py @@ -0,0 +1,14 @@ +"""Column metadata checks; content must still be reviewed by the administrator.""" +import re + +IDENTITY = {'no', 'nomor', 'id', 'nama', 'name', 'nim', 'nis', 'nip', 'nik', + 'prodi', 'jurusan', 'fakultas', 'gender', 'kelamin', 'email', + 'telepon', 'phone', 'alamat', 'address', 'angkatan', 'semester', + 'label', 'sentimen', 'sentiment'} + +def validate_column(column): + words = set(re.sub(r'[^a-z0-9]+', ' ', column.casefold()).split()) + compact = ''.join(sorted(words)) + if not words or words & IDENTITY or {'program', 'studi'} <= words or compact in {'jeniskelamin', 'programstudi'}: + raise ValueError('Pilih kolom berisi ulasan, bukan kolom identitas atau label.') + return column.strip() diff --git a/web_slearn/.gitignore b/web_slearn/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/web_slearn/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/web_slearn/README.md b/web_slearn/README.md new file mode 100644 index 0000000..a36934d --- /dev/null +++ b/web_slearn/README.md @@ -0,0 +1,16 @@ +# React + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project. diff --git a/web_slearn/backup-pembanding-20261028-175022-231/src/pages/PenelitianModel.jsx b/web_slearn/backup-pembanding-20261028-175022-231/src/pages/PenelitianModel.jsx new file mode 100644 index 0000000..820b279 --- /dev/null +++ b/web_slearn/backup-pembanding-20261028-175022-231/src/pages/PenelitianModel.jsx @@ -0,0 +1,1821 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import Papa from "papaparse"; +import { ambilModelPenelitian } from "../services/api"; +import "../styles/penelitian-model.css"; + +const tabs = [ + "Dataset & Pembagian Data", + "Preprocessing", + "Pembobotan TF-IDF", + "Model & Evaluasi" +]; + +const ITEMS_PER_PAGE = 10; + + +function PenelitianModel() { + const [activeTab, setActiveTab] = useState("Dataset & Pembagian Data"); + + return ( +
+
+
+

Penelitian & Model Awal

+ +

+ Dokumentasi dataset, preprocessing, TF-IDF, dan evaluasi model awal (v1). + Model aktif dan hasil training baru tersedia di menu Training & Riwayat Model.{" "} + +

+
+
+ +
+ {tabs.map((tab) => ( + + ))} +
+ + {activeTab === "Dataset & Pembagian Data" && } + {activeTab === "Preprocessing" && } + {activeTab === "Pembobotan TF-IDF" && } + {activeTab === "Model & Evaluasi" && } +
+ ); +} + +/* ============================================================ + DATASET + ============================================================ */ + +function DatasetTab() { + const [classDistribution, setClassDistribution] = useState({ + total: 571, + negative: 379, + positive: 192 + }); + + const [distributionLoading, setDistributionLoading] = useState(true); + const [distributionError, setDistributionError] = useState(""); + + useEffect(() => { + let aktif = true; + + Papa.parse("/data/hasil_preprocessing_slearn.csv", { + download: true, + header: true, + skipEmptyLines: true, + + transformHeader: (header) => + String(header).replace(/^\uFEFF/, "").trim(), + + complete: (results) => { + if (!aktif) return; + + const rows = (results.data || []).filter( + (row) => String(row.komentar || "").trim() !== "" + ); + + const negative = rows.filter( + (row) => String(row.label ?? "").trim() === "0" + ).length; + + const positive = rows.filter( + (row) => String(row.label ?? "").trim() === "1" + ).length; + + setClassDistribution({ + total: rows.length, + negative, + positive + }); + + setDistributionLoading(false); + }, + + error: (err) => { + if (!aktif) return; + + setDistributionError( + err?.message || "Distribusi kelas tidak dapat dimuat." + ); + + setDistributionLoading(false); + } + }); + + return () => { + aktif = false; + }; + }, []); + + const negativePercentage = + classDistribution.total > 0 + ? (classDistribution.negative / classDistribution.total) * 100 + : 0; + + const positivePercentage = + classDistribution.total > 0 + ? (classDistribution.positive / classDistribution.total) * 100 + : 0; + + const formatPercentage = (value) => + Number(value).toFixed(1).replace(".", ","); + + return ( +
+
+ + + + + +
+ +
+
+
+

Distribusi Kelas Dataset

+

Distribusi label pada 571 ulasan penelitian.

+
+ + {distributionLoading ? ( +
+ Memuat distribusi kelas... +
+ ) : ( + <> +
+
+
+
+ + +
+ Negatif + Label 0 +
+
+ +
+ {classDistribution.negative} + {formatPercentage(negativePercentage)}% +
+
+ +
+ +
+
+ +
+
+
+ + +
+ Positif + Label 1 +
+
+ +
+ {classDistribution.positive} + {formatPercentage(positivePercentage)}% +
+
+ +
+ +
+
+
+ +
+ 0 = Negatif + 1 = Positif +
+ + {distributionError && ( +

+ {distributionError} +

+ )} + + )} +
+ +
+
+

Pembagian Training dan Testing

+ +

+ Dataset dibagi menjadi 80% data training dan 20% data testing. +

+
+ +
+
+ 80% + 20% +
+ +

+ Data training digunakan untuk membentuk fitur TF-IDF dan melatih + model, sedangkan data testing digunakan untuk evaluasi model. +

+
+
+
+ +
+
+

Alur Pengolahan Data Penelitian

+
+ +
+ + + + + + + + + + + + + + + + + + + + + +
+
+
+ ); +} + +/* ============================================================ + PREPROCESSING + ============================================================ */ + +function formatTokenizedText(value) { + const text = String(value || "").trim(); + + if (!text) return "-"; + + try { + const parsed = JSON.parse(text.replace(/'/g, '"')); + + if (Array.isArray(parsed)) { + return parsed.join(" | "); + } + } catch { + // Format fallback. + } + + return text + .replace(/^\[/, "") + .replace(/\]$/, "") + .split(",") + .map((token) => + token + .trim() + .replace(/^["']|["']$/g, "") + ) + .filter(Boolean) + .join(" | "); +} + +function PreprocessingTab() { + const [data, setData] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [search, setSearch] = useState(""); + const [filterLabel, setFilterLabel] = useState("Semua"); + const [currentPage, setCurrentPage] = useState(1); + const [detail, setDetail] = useState(null); + + const preprocessingResultRef = useRef(null); + + useEffect(() => { + let aktif = true; + + Papa.parse("/data/hasil_preprocessing_slearn.csv", { + download: true, + header: true, + skipEmptyLines: true, + + transformHeader: (header) => + String(header).replace(/^\uFEFF/, "").trim(), + + complete: (results) => { + if (!aktif) return; + + const rows = (results.data || []) + .filter( + (row) => String(row.komentar || "").trim() !== "" + ) + .map((row, index) => ({ + id: index + 1, + komentar: String(row.komentar || ""), + label: String(row.label ?? "").trim(), + clean_text: String(row.clean_text || ""), + case_folded_text: String(row.case_folded_text || ""), + normalized_text: String(row.normalized_text || ""), + tokenized_text: String(row.tokenized_text || ""), + stopword_text: String(row.stopword_text || ""), + stemmed_text: String(row.stemmed_text || "") + })); + + setData(rows); + setLoading(false); + }, + + error: (err) => { + if (!aktif) return; + + setError( + err?.message || "Hasil preprocessing tidak dapat dibaca." + ); + + setLoading(false); + } + }); + + return () => { + aktif = false; + }; + }, []); + + useEffect(() => { + setCurrentPage(1); + }, [search, filterLabel]); + + useEscapeModal(detail, () => setDetail(null)); + + const filteredData = useMemo(() => { + const keyword = search.trim().toLowerCase(); + + return data.filter((item) => { + const cocokSearch = + !keyword || + item.komentar.toLowerCase().includes(keyword) || + item.clean_text.toLowerCase().includes(keyword) || + item.case_folded_text.toLowerCase().includes(keyword) || + item.normalized_text.toLowerCase().includes(keyword) || + item.tokenized_text.toLowerCase().includes(keyword) || + item.stopword_text.toLowerCase().includes(keyword) || + item.stemmed_text.toLowerCase().includes(keyword) || + String(item.id).includes(keyword); + + const cocokLabel = + filterLabel === "Semua" || + (filterLabel === "Positif" && item.label === "1") || + (filterLabel === "Negatif" && item.label === "0"); + + return cocokSearch && cocokLabel; + }); + }, [data, search, filterLabel]); + + const totalPages = Math.max( + 1, + Math.ceil(filteredData.length / ITEMS_PER_PAGE) + ); + + const safePage = Math.min(currentPage, totalPages); + const startIndex = (safePage - 1) * ITEMS_PER_PAGE; + + const paginatedData = filteredData.slice( + startIndex, + startIndex + ITEMS_PER_PAGE + ); + + const scrollKeAtasPreprocessing = () => { + window.requestAnimationFrame(() => { + if (!preprocessingResultRef.current) return; + + const posisi = + preprocessingResultRef.current.getBoundingClientRect().top + + window.scrollY - + 18; + + window.scrollTo({ + top: posisi, + behavior: "smooth" + }); + }); + }; + + const halamanSebelumnyaPreprocessing = () => { + setCurrentPage((page) => Math.max(1, page - 1)); + scrollKeAtasPreprocessing(); + }; + + const halamanBerikutnyaPreprocessing = () => { + setCurrentPage((page) => Math.min(totalPages, page + 1)); + scrollKeAtasPreprocessing(); + }; + + const kataPenting = [ + "tidak", + "kurang", + "belum", + "bukan", + "bukannya", + "jangan", + "tanpa", + "tidaklah", + "malah", + "bisa", + "sangat", + "sering", + "sudah", + "masih", + "terlalu", + "baik", + "lebih", + "lama", + "jelas", + "masalah", + "selalu", + "siap", + "dapat", + "guna", + "benar", + "cukup", + "sekali", + "padahal", + "perlu", + "banyak", + "dan", + "terlihat", + "waktu", + "tambah", + "sebelum", + "sebelumnya", + "dekat", + "tiap", + "ada", + "kecil", + "dulu" + ]; + + return ( +
+
+
+
+

Contoh Kamus Normalisasi

+ +

+ Contoh perubahan kata yang digunakan pada proses normalisasi + data penelitian. +

+
+ +
+ + + +
+
+ +
+
+

Kata Penting yang Dipertahankan

+ +

+ Stopword final dibentuk dari stopword Bahasa Indonesia NLTK dan + partikel tambahan. Kata-kata berikut dikeluarkan dari daftar + stopword sehingga tetap dipertahankan selama preprocessing. +

+
+ +
+ {kataPenting.map((kata) => ( + {kata} + ))} +
+
+
+ +
+
+

Preprocessing 571 Ulasan

+ +

+ Hasil preprocessing ditampilkan sesuai enam tahapan penelitian: + Cleaning, Case Folding, Normalisasi, Tokenizing, Stopword Removal, + dan Stemming. +

+
+ +
+ setSearch(event.target.value)} + /> + + +
+ + {loading && ( +
+ Memuat hasil preprocessing penelitian... +
+ )} + + {!loading && error && ( +
+ {error} +
+ )} + + {!loading && !error && ( + <> +
+ {paginatedData.map((item) => ( +
+
+
+ Data ke-{item.id} + +
+ +

{item.komentar}

+ +
+
+ Hasil Akhir Stemming + {item.stemmed_text} +
+ + +
+
+
+ ))} + + {paginatedData.length === 0 && ( +
+ Tidak ada data yang sesuai. +
+ )} +
+ + + + )} +
+ + {detail && ( +
+ event.target === event.currentTarget && + setDetail(null) + } + > +
+
+
+
+

Data ke-{detail.id}

+
+ + +
+ +
+
+ Ulasan Asli + +
+ +

{detail.komentar}

+
+ +
+ + + + + + + + + + + +
+ +
+ Sentimen Penelitian + + + {detail.label === "1" ? "Positif" : "Negatif"} + +
+
+
+
+ )} +
+ ); +} + +/* ============================================================ + TF-IDF + ============================================================ */ + +function TFIDFTab() { + const [summary, setSummary] = useState(null); + const [data, setData] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [search, setSearch] = useState(""); + const [filterJenis, setFilterJenis] = useState("Semua"); + const [filterLabel, setFilterLabel] = useState("Semua"); + const [currentPage, setCurrentPage] = useState(1); + const [detail, setDetail] = useState(null); + + const tfidfResultRef = useRef(null); + + useEffect(() => { + const controller = new AbortController(); + + async function loadTFIDF() { + try { + setLoading(true); + setError(""); + + const response = await fetch("/data/tfidf_research.json", { + signal: controller.signal + }); + + if (!response.ok) { + throw new Error( + "Data pembobotan TF-IDF tidak dapat dibaca." + ); + } + + const json = await response.json(); + + if (!json || !Array.isArray(json.data)) { + throw new Error("Format data TF-IDF tidak sesuai."); + } + + setSummary(json.summary || null); + setData(json.data); + } catch (err) { + if (err.name !== "AbortError") { + setError( + err.message || "Data TF-IDF tidak dapat dimuat." + ); + } + } finally { + if (!controller.signal.aborted) { + setLoading(false); + } + } + } + + loadTFIDF(); + + return () => controller.abort(); + }, []); + + useEffect(() => { + setCurrentPage(1); + }, [search, filterJenis, filterLabel]); + + useEscapeModal(detail, () => setDetail(null)); + + const filteredData = useMemo(() => { + const keyword = search.trim().toLowerCase(); + + return data.filter((item) => { + const bobotKata = (item.bobot_tfidf || []) + .map((x) => x.kata) + .join(" ") + .toLowerCase(); + + const cocokSearch = + !keyword || + String(item.no_data).includes(keyword) || + String(item.ulasan_asli || "") + .toLowerCase() + .includes(keyword) || + String(item.stemmed_text || "") + .toLowerCase() + .includes(keyword) || + bobotKata.includes(keyword); + + const cocokJenis = + filterJenis === "Semua" || + item.jenis_data === filterJenis; + + const cocokLabel = + filterLabel === "Semua" || + (filterLabel === "Positif" && + Number(item.label) === 1) || + (filterLabel === "Negatif" && + Number(item.label) === 0); + + return cocokSearch && cocokJenis && cocokLabel; + }); + }, [data, search, filterJenis, filterLabel]); + + const totalPages = Math.max( + 1, + Math.ceil(filteredData.length / ITEMS_PER_PAGE) + ); + + const safePage = Math.min(currentPage, totalPages); + const startIndex = (safePage - 1) * ITEMS_PER_PAGE; + + const paginatedData = filteredData.slice( + startIndex, + startIndex + ITEMS_PER_PAGE + ); + + const scrollKeAtasHasilTFIDF = () => { + window.requestAnimationFrame(() => { + if (!tfidfResultRef.current) return; + + const posisi = + tfidfResultRef.current.getBoundingClientRect().top + + window.scrollY - + 18; + + window.scrollTo({ + top: posisi, + behavior: "smooth" + }); + }); + }; + + const halamanSebelumnyaTFIDF = () => { + setCurrentPage((page) => Math.max(1, page - 1)); + scrollKeAtasHasilTFIDF(); + }; + + const halamanBerikutnyaTFIDF = () => { + setCurrentPage((page) => Math.min(totalPages, page + 1)); + scrollKeAtasHasilTFIDF(); + }; + + return ( +
+
+ + + + + +
+ +
+
+

Hasil Pembobotan TF-IDF 571 Ulasan

+ +

+ Bobot TF-IDF dihitung menggunakan fitur yang dibentuk dari + data training. Data testing ditransformasikan menggunakan + vectorizer yang sama tanpa melakukan fit ulang. +

+
+ +
+ setSearch(event.target.value)} + /> + + + + +
+ + {loading && ( +
+ Memuat hasil pembobotan TF-IDF penelitian... +
+ )} + + {!loading && error && ( +
+ {error} +
+ )} + + {!loading && !error && ( + <> +
+ {paginatedData.map((item) => ( + setDetail(item)} + /> + ))} + + {paginatedData.length === 0 && ( +
+ Tidak ada data TF-IDF yang sesuai dengan pencarian atau + filter. +
+ )} +
+ + + + )} +
+ +
+
+

Cara Pembentukan Fitur TF-IDF

+ +

+ TF-IDF dibentuk menggunakan 456 data training dan menghasilkan + 675 fitur. Bobot akhir merupakan hasil TF × IDF, sedangkan data + testing ditransformasikan menggunakan vectorizer dari data training. +

+
+ +
+
+ Data Training + 456 Ulasan +
+ +
→
+ +
+ Pembobotan + TF × IDF +
+ +
→
+ +
+ Hasil + 675 Fitur +
+
+
+ + {detail && ( + setDetail(null)} + /> + )} +
+ ); +} + +function TFIDFResearchCard({ item, onDetail }) { + const topWeights = (item.bobot_tfidf || []).slice(0, 5); + + const maxWeight = + topWeights.reduce( + (maksimum, weight) => + Math.max( + maksimum, + Number(weight.bobot) || 0 + ), + 0 + ) || 1; + + return ( +
+
+ + Data ke-{item.no_data} + + +
+ + {item.jenis_data} + + + +
+
+ +
+ Hasil Stemming + {item.stemmed_text} +
+ +
+
+ Bobot TF-IDF Tertinggi + + + {item.jumlah_fitur_nonzero} fitur bernilai > 0 + +
+ + {topWeights.length > 0 ? ( + topWeights.map((weight) => { + const nilaiBobot = Number(weight.bobot) || 0; + + const width = Math.min( + 100, + Math.max( + 0, + (nilaiBobot / maxWeight) * 100 + ) + ); + + return ( +
+ {weight.kata} + +
+ +
+ + + {formatDecimal(weight.bobot)} + +
+ ); + }) + ) : ( +
+ Tidak ada fitur yang dikenali vectorizer. +
+ )} +
+ +
+ +
+
+ ); +} + +function TFIDFDetailModal({ item, onClose }) { + return ( +
+ event.target === event.currentTarget && + onClose() + } + > +
+
+
+
+

Data ke-{item.no_data}

+
+ + +
+ +
+
+ Ulasan Asli +
+ +

{item.ulasan_asli}

+
+ +
+
+ Jenis Data + {item.jenis_data} +
+ +
+ Posisi pada Split + + {item.jenis_data} ke-{item.urutan_split} + +
+ +
+ Fitur Bobot > 0 + {item.jumlah_fitur_nonzero} +
+ +
+ Sentimen + + + {Number(item.label) === 1 + ? "Positif" + : "Negatif"} + +
+
+ +
+ Hasil Stemming + {item.stemmed_text} +
+ +
+

+ Seluruh Fitur dengan Bobot TF-IDF > 0 +

+
+ +
+ + + + + + + + + + + + + + {(item.bobot_tfidf || []).map( + (row, index) => ( + + + + + + + + + + + + + ) + )} + + {(item.bobot_tfidf || []).length === 0 && ( + + + + )} + +
No.KataTFDFIDFBobot TF-IDF
{index + 1} + {row.kata} + {row.tf}{row.df} + {formatDecimal(row.idf)} + + {formatDecimal(row.bobot)} +
+ Tidak ada fitur dengan bobot TF-IDF > 0. +
+
+ +
+ i + +
+ Cara Membaca Bobot + +

+ TF menunjukkan frekuensi term pada satu dokumen, DF menunjukkan + jumlah dokumen training yang mengandung term, sedangkan IDF + menunjukkan bobot berdasarkan kemunculan term pada dokumen + training. Bobot TF-IDF pada tabel merupakan hasil TF × IDF. +

+
+
+
+
+
+ ); +} + +/* ============================================================ + MODEL & EVALUASI + ============================================================ */ + +function ModelTab() { + const [modelInfo, setModelInfo] = useState(null); + const [error, setError] = useState(""); + + useEffect(() => { + let active = true; + async function loadModel() { + try { + const response = await ambilModelPenelitian(); + const data = response.data; + const report = data?.classification_report; + const cm = data?.confusion_matrix; + const validRate = value => typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 1; + if (!data || !validRate(data.akurasi) || + !Number.isInteger(data.jumlah_data_testing) || data.jumlah_data_testing <= 0 || + !Array.isArray(cm) || cm.length !== 2 || + !cm.every(row => Array.isArray(row) && row.length === 2 && row.every(value => Number.isInteger(value) && value >= 0)) || + cm.flat().reduce((sum, value) => sum + value, 0) !== data.jumlah_data_testing || + !["0", "1", "weighted avg"].every(key => + report?.[key] && ["precision", "recall", "f1-score"].every(metric => validRate(report[key][metric])) && + Number.isFinite(report[key].support))) { + throw new Error("Metadata evaluasi dari backend tidak lengkap atau tidak valid."); + } + if (active) setModelInfo(data); + } catch (err) { + if (active) setError(err.message || "Gagal memuat evaluasi model."); + } + } + loadModel(); + return () => { active = false; }; + }, []); + + if (error) return ( +
+
+

Evaluasi model 80:20 belum dapat ditampilkan

+

{error}

+

Pastikan backend berjalan. Buka tab lain lalu kembali ke Model & Evaluasi untuk mencoba lagi.

+
+ +
+ ); + if (!modelInfo) return
Memuat evaluasi model dari backend...
; + + const report = modelInfo.classification_report; + const weighted = report["weighted avg"]; + const [[tn, fp], [fn, tp]] = modelInfo.confusion_matrix; + const percent = (value, digits = 2) => (value * 100).toLocaleString("id-ID", { + minimumFractionDigits: digits, maximumFractionDigits: digits + }) + "%"; + const evaluationMetrics = [ + { name: "Accuracy", value: percent(modelInfo.akurasi), description: "Ketepatan prediksi pada " + modelInfo.jumlah_data_testing + " data testing" }, + { name: "Precision", value: percent(weighted.precision), description: "Weighted average seluruh kelas" }, + { name: "Recall", value: percent(weighted.recall), description: "Weighted average seluruh kelas" }, + { name: "F1-Score", value: percent(weighted["f1-score"]), description: "Weighted average seluruh kelas" } + ]; + return ( +
+
+
+

Evaluasi Model Awal — v1

+ +

+ Evaluasi dilakukan terhadap {modelInfo.jumlah_data_testing} data testing. Nilai Precision, + Recall, dan F1-Score berikut menggunakan weighted average. +

+
+ +
+ {evaluationMetrics.map((metric) => ( + + ))} +
+
+ +
+
+
+

Confusion Matrix

+ +

+ Perbandingan antara label aktual dengan hasil prediksi model + pada data testing. +

+
+ +
+
+ Prediksi Model +
+ +
+
+ +
+ Negatif +
+ +
+ Positif +
+ +
+ Negatif Aktual +
+ + + + + +
+ Positif Aktual +
+ + + + +
+ +
+ + + Prediksi benar + + + + + Prediksi salah + +
+
+
+ +
+
+

Evaluasi per Kelas

+ +

+ Precision, Recall, dan F1-Score masing-masing kelas pada + {modelInfo.jumlah_data_testing} data testing. +

+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KelasPrecisionRecallF1-ScoreJumlah
+ + Negatif + + {percent(report["0"].precision, 1)}{percent(report["0"].recall, 1)}{percent(report["0"]["f1-score"], 1)}{report["0"].support}
+ + Positif + + {percent(report["1"].precision, 1)}{percent(report["1"].recall, 1)}{percent(report["1"]["f1-score"], 1)}{report["1"].support}
+
+ +
+ i + +

+ Recall kelas positif sebesar {percent(report["1"].recall, 1)} menunjukkan bahwa {tp} dari{" "} + {report["1"].support} ulasan positif pada data testing berhasil diprediksi sebagai + positif, sedangkan {fn} lainnya diprediksi sebagai negatif. +

+
+
+
+ +
+ ); +} + +/* ============================================================ + PERBANDINGAN TIGA RASIO PENELITIAN + Sumber: public/data/perbandingan_rasio_slearn.json + ============================================================ */ + +function PerbandinganRasio() { + const [skenario, setSkenario] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + + useEffect(() => { + const controller = new AbortController(); + async function loadPerbandingan() { + try { + const response = await fetch("/data/perbandingan_rasio_slearn.json", { signal: controller.signal }); + if (!response.ok) throw new Error("File perbandingan_rasio_slearn.json tidak ditemukan."); + const json = await response.json(); + const daftar = json?.skenario; + const rasioWajib = ["70:30", "80:20", "90:10"]; + const valid = Array.isArray(daftar) && daftar.length === 3 && + rasioWajib.every(rasio => daftar.some(item => item.rasio === rasio)) && + daftar.every(item => + Number.isInteger(item.data_latih) && Number.isInteger(item.data_uji) && + Number.isInteger(item.jumlah_fitur) && item.data_latih + item.data_uji === 571 && + item.jumlah_fitur > 0 && + ["accuracy", "precision", "recall", "f1_score"].every(k => + typeof item[k] === "number" && Number.isFinite(item[k]) && item[k] >= 0 && item[k] <= 100) && + Array.isArray(item.confusion_matrix) && item.confusion_matrix.length === 2 && + item.confusion_matrix.every(row => Array.isArray(row) && row.length === 2 && + row.every(n => Number.isInteger(n) && n >= 0)) && + item.confusion_matrix.flat().reduce((total, n) => total + n, 0) === item.data_uji + ); + if (!valid) throw new Error("Struktur atau nilai data perbandingan tiga rasio tidak sesuai."); + if (!controller.signal.aborted) { + setSkenario(rasioWajib.map(rasio => daftar.find(item => item.rasio === rasio))); + setError(""); + } + } catch (err) { + if (err.name !== "AbortError") setError(err.message || "Gagal membaca perbandingan rasio."); + } finally { + if (!controller.signal.aborted) setLoading(false); + } + } + loadPerbandingan(); + return () => controller.abort(); + }, []); + + const persen = angka => Number(angka).toLocaleString("id-ID", { + minimumFractionDigits: 2, maximumFractionDigits: 2 + }) + "%"; + + return ( +
+
+

Perbandingan Tiga Rasio Data Training dan Testing

+

Hasil eksperimen 70:30, 80:20, dan 90:10 menggunakan Logistic Regression dan TF-IDF.

+
+ + {loading &&

Memuat hasil perbandingan rasio...

} + {!loading && error &&

{error}

} + + {!loading && !error && ( + <> +
+ + + + + + + + + {skenario.map(item => ( + + + + + + + ))} + +
RasioData LatihData UjiFitur TF-IDFAccuracyPrecisionRecallF1-Score
{item.rasio}{item.rasio === "80:20" &&
Model utama
}
{item.data_latih}{item.data_uji}{item.jumlah_fitur}{persen(item.accuracy)}{persen(item.precision)}{persen(item.recall)}{persen(item.f1_score)}
+
+ +
+

Grafik Perbandingan Akurasi

+
+
+
+ {[100, 75, 50, 25, 0].map(nilai => {nilai}%)} +
+
+
+ {[0, 25, 50, 75, 100].map(nilai => ( +
+ ))} +
+ {skenario.map(item => ( +
+ {persen(item.accuracy)} +
+ ))} +
+
+
+ {skenario.map(item => ( + {item.rasio} + ))} +
+
+
+
+
+ +

Confusion Matrix per Rasio

+
+ {skenario.map(item => { + const [[tn, fp], [fn, tp]] = item.confusion_matrix; + return ( +
+ Rasio {item.rasio} + + + + + + +
Aktual / PrediksiNegatifPositif
Negatif{tn} (TN){fp} (FP)
Positif{fn} (FN){tp} (TP)
+
+ ); + })} +
+ +
+ i +
+ Catatan Evaluasi +

Precision, Recall, dan F1-Score menggunakan weighted average. Setiap rasio memiliki jumlah data uji berbeda sehingga hasilnya tidak langsung menunjukkan satu rasio selalu lebih baik. Model utama untuk prediksi website tetap rasio 80:20 dengan akurasi 93,04%; eksperimen rasio lain hanya ditampilkan sebagai pembanding.

+
+
+ + )} +
+ ); +} + +/* ============================================================ + COMPONENT + ============================================================ */ + +function useEscapeModal(value, close) { + useEffect(() => { + if (!value) return; + + const handleEscape = (event) => { + if (event.key === "Escape") { + close(); + } + }; + + document.addEventListener("keydown", handleEscape); + + return () => + document.removeEventListener("keydown", handleEscape); + }, [value, close]); +} + +function formatDecimal(value) { + const number = Number(value); + + if (!Number.isFinite(number)) { + return "-"; + } + + return number + .toFixed(6) + .replace(".", ","); +} + +function StatCard({ title, value, info }) { + return ( +
+ {title} + {value} + {info} +
+ ); +} + +function FlowBox({ title, subtitle }) { + return ( +
+ {title} + {subtitle} +
+ ); +} + +function FlowArrow() { + return ( +
+ → +
+ ); +} + +function MetricCard({ + name, + value, + description +}) { + return ( +
+ {name} + {value} + {description} +
+ ); +} + +function SentimentBadge({ label }) { + const positive = Number(label) === 1; + + return ( + + {positive ? "Positif" : "Negatif"} + + ); +} + +function NormalizationRow({ from, to }) { + return ( +
+ {from} + + + → + + + {to} +
+ ); +} + +function ResearchProcessStep({ + number, + title, + value +}) { + return ( +
+
+ {number} +
+ +
+ {title} +

{value || "-"}

+
+
+ ); +} + +function ResearchPagination({ + total, + startIndex, + currentPage, + totalPages, + onPrev, + onNext +}) { + return ( +
+ + Menampilkan{" "} + {total === 0 ? 0 : startIndex + 1} + {" - "} + {Math.min( + startIndex + ITEMS_PER_PAGE, + total + )}{" "} + dari {total} data + + +
+ + + + {currentPage} / {totalPages} + + + +
+
+ ); +} + +function ConfusionValue({ + value, + label, + tone +}) { + return ( +
+ {value} + {label} +
+ ); +} + +export default PenelitianModel; diff --git a/web_slearn/eslint.config.js b/web_slearn/eslint.config.js new file mode 100644 index 0000000..ea36dd3 --- /dev/null +++ b/web_slearn/eslint.config.js @@ -0,0 +1,21 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{js,jsx}'], + extends: [ + js.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + globals: globals.browser, + parserOptions: { ecmaFeatures: { jsx: true } }, + }, + }, +]) diff --git a/web_slearn/index.html b/web_slearn/index.html new file mode 100644 index 0000000..a485092 --- /dev/null +++ b/web_slearn/index.html @@ -0,0 +1,13 @@ + + + + + + + web_slearn + + +
+ + + diff --git a/web_slearn/package-lock.json b/web_slearn/package-lock.json new file mode 100644 index 0000000..6427fb7 --- /dev/null +++ b/web_slearn/package-lock.json @@ -0,0 +1,2512 @@ +{ + "name": "web_slearn", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "web_slearn", + "version": "0.0.0", + "dependencies": { + "papaparse": "^5.7.0", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "react-router-dom": "^7.18.4" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.7", + "@vitejs/plugin-react": "^6.1.1", + "eslint": "^10.10.0", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.6", + "globals": "^17.12.0", + "vite": "^8.3.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@cacheable/memory": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", + "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/utils": "^2.5.0", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz", + "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz", + "integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@oxc-project/types": { + "version": "0.151.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.151.0.tgz", + "integrity": "sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.11.tgz", + "integrity": "sha512-A5kXfGKvKWWZE0TtPrfsvT+q4Y5d1QG8gGUzpYjGydM+fARM9MuX90PrXYXe0XbsDVgyxxNzHo6giCj90bsFNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.11.tgz", + "integrity": "sha512-z6cTycz+iJ4PVkuL4HHW4DfTfoeU/2nqYYuSOrTmH7yHK5Y0LCOnA03V4ZNxavyVaU1oOqUgIg2klN/s+USGOA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.11.tgz", + "integrity": "sha512-jShvqNtP6vDC6/A5JOAzbVV+DkgHqhl/ScVCJEbt+TUY6QYz7YnXcrg3sLtFBniro0f/Ld50ZwCWA6f7KYD1nQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.11.tgz", + "integrity": "sha512-f2i2xiNWq1Z1l2++q2fuhZRdLAT3aqxD6vRNm1RAxpUoBcdqNB3C0s1Bt+K+PbEx2F5F4gQp6hqKkphCY/xF9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.11.tgz", + "integrity": "sha512-4Ir5FSOKIAMr4r0kExpt1s3bMgzJU3rA45AYOHtQpls0oNeqcYBKrWMlckrYH4KCfGLfkfn1tN1dmZPMVsdXow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.11.tgz", + "integrity": "sha512-/gnRDM+39BROzAN/k1OZjDPnDMcZxB/0EUxKjONO5yVkNEvlsoMDrxGNKgZi/ttFriS2gwlDNzB65pvNbFOXIQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.11.tgz", + "integrity": "sha512-PFaK8HwvAHbaKbBcDNQihjMKYvFnA5hiENx/l5tphTDz1E0WFp32l0A7aq7lyUwGsRw/xSrNIy/gIK4thrSCrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.11.tgz", + "integrity": "sha512-AskzJUIKRLPxkruR1wLKewGbOw+EYfU/9lOrBFj4AFrEA8hPpKFnODWNu2WLaNs0QNkEb9QIJufmVZZIL/bJlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.11.tgz", + "integrity": "sha512-qlUGAheh2yh8afH7QBgx0PrRHN85hKnNd78x8MeMhXivuevgd8vgf6/CstOzmNKY/lLTHvNTrPy98cLnAugzJw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.11.tgz", + "integrity": "sha512-secpEad+0vCbSfn8upFySkDskv+bGPk3THSDS9Y89yc4rb4kzqHp8Dmyd9BkQW4SnhNXBZCl/6CrO//hZahNJQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.11.tgz", + "integrity": "sha512-mOVBT3dPpkWm8XBWPmU4bf+U6dYDLeMo/9ojUmis4N0L5uu10qra5vOyngZ7/PSdoE4G9KvRt4bloRxNjLas7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.11.tgz", + "integrity": "sha512-Is78i9A8Ui4SqcxUwFJ9uMmjDn58IbVTjFWYdQestFEgeuEmHMLGNriXnVJKkwG2YiZjw8cP0zCTyDMdDGtOOg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.11.tgz", + "integrity": "sha512-dUCXneZ87INUMyQ0D+C0HrEBNUPNXHaPmU5GTjyKTJEiussw9Kaj5Ln8UztPe4epV/ffvgNBEadksdYhmW6xJA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.11.tgz", + "integrity": "sha512-jByxb6qfd+bH1xUd0qnfFnb17i9sWBPY2tOavJ0l3tdr3OTu+Kvtm8cd/JV5nFt657b1VqGltxg9olOEfofXWw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.11.tgz", + "integrity": "sha512-/PzKqzAJ03i19oy2ItPvyvaVjOjBCNnfaJs8yvUdGBKmiESgnrJSQ2awd81QzFbbnAmu7YO9ZnJrDCb9VSJPRA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", + "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.145.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.26", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.26.tgz", + "integrity": "sha512-GLQdD3y6UF8iVuMJl5fHgE4jdn/ua7n+toKfLgNlg3BqQtOZjpy68T8Tup8/wGWZCDlm7KMg7tPb4MPn7oN0TQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/browserslist": { + "version": "4.29.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz", + "integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.26", + "caniuse-lite": "^1.0.30001813", + "electron-to-chromium": "^1.5.439", + "node-releases": "^2.0.57", + "update-browserslist-db": "^1.3.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/cacheable": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", + "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/memory": "^2.2.0", + "@cacheable/utils": "^2.5.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.10.1" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001814", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001814.tgz", + "integrity": "sha512-/Uaf1lAzr59XcMpW0o96WoEfr+VXK2OX4U9AgFoiSHsVJ4HppnIFUjtYzsyDH2+tgANaQb2/oxYGwCPapN1FpA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.442", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.442.tgz", + "integrity": "sha512-najZYZ3+ZpjN1z3VOsrBiv5ej18vQgfV2lvEmxWzfmKrk4bdm3Owseyud4yGU6DfFn9pyunoAHDDyM4KmP78Ew==", + "dev": true, + "license": "ISC" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.11.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.11.0.tgz", + "integrity": "sha512-P7a6UEEqb9G95MYAtqkmsTbVXIYyzIfl6NGOIJk162PaahFxFyeGcrlXYFSiagECg4sEm8IseJdZBKR3rx6MsQ==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.3", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "11.1.5 || >11.1.6 <12", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.7.tgz", + "integrity": "sha512-XhJSzLljuYD4UjNuFGJu2v7aD3sqNVK12w5/DCUfrNfHWegZo2+TsavNx8MuxMwEquuFAzNbNfXKU0WoZ+YUIg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": "^9 || ^10" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", + "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^6.1.23" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "6.1.23", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz", + "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cacheable": "^2.5.0", + "flatted": "^3.4.2", + "hookified": "^1.15.0" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.12.0.tgz", + "integrity": "sha512-cezEd/DTyyht9cvSSURyygXPfy04GtWO/5e6ZPvH7fCtjKz9PYOmuawphw1Ctd1f6C+5JypXfGD7ahNMXvevBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^1.15.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.57", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.57.tgz", + "integrity": "sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/papaparse": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.7.0.tgz", + "integrity": "sha512-qBGxg/7Q3Kl9Wfhrz2Z74UnvnHTXLNG6jmKJFeBvP2+y4lV7So+7SR62+Zd47JvdrCkX+nDcnr0ObPzek/+6RA==", + "license": "MIT" + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qified": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^2.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/qified/node_modules/hookified": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", + "dev": true, + "license": "MIT" + }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, + "node_modules/react-router": { + "version": "7.18.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz", + "integrity": "sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.4.tgz", + "integrity": "sha512-yrfmJHIpDG7taCpqKjT1G5B6q3O2K+RN8/fgNf0lTjCwiPbQ0ei6vXX9ZjQR+7ld8Tr7Z5xmyMnZ8YJrphWQUw==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.4" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/rolldown": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.11.tgz", + "integrity": "sha512-qpSwIyz0jHQq5qXBTNxFmE6664rJ7O+4TvPFOiOaBSrz8IOHc1koKKSqTM2H6u1UG1+TveuC6vaDHKXFOvb1Kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.151.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.11", + "@rolldown/binding-android-arm64": "1.2.11", + "@rolldown/binding-darwin-arm64": "1.2.11", + "@rolldown/binding-darwin-x64": "1.2.11", + "@rolldown/binding-freebsd-x64": "1.2.11", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.11", + "@rolldown/binding-linux-arm64-gnu": "1.2.11", + "@rolldown/binding-linux-arm64-musl": "1.2.11", + "@rolldown/binding-linux-ppc64-gnu": "1.2.11", + "@rolldown/binding-linux-s390x-gnu": "1.2.11", + "@rolldown/binding-linux-x64-gnu": "1.2.11", + "@rolldown/binding-linux-x64-musl": "1.2.11", + "@rolldown/binding-openharmony-arm64": "1.2.11", + "@rolldown/binding-win32-arm64-msvc": "1.2.11", + "@rolldown/binding-win32-x64-msvc": "1.2.11" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", + "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.1.tgz", + "integrity": "sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.9", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.6.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", + "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/web_slearn/package.json b/web_slearn/package.json new file mode 100644 index 0000000..41077d8 --- /dev/null +++ b/web_slearn/package.json @@ -0,0 +1,29 @@ +{ + "name": "web_slearn", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "papaparse": "^5.7.0", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "react-router-dom": "^7.18.4" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.7", + "@vitejs/plugin-react": "^6.1.1", + "eslint": "^10.10.0", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.6", + "globals": "^17.12.0", + "vite": "^8.3.0" + } +} diff --git a/web_slearn/public/favicon.svg b/web_slearn/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/web_slearn/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web_slearn/public/icons.svg b/web_slearn/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/web_slearn/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web_slearn/src/App.css b/web_slearn/src/App.css new file mode 100644 index 0000000..f90339d --- /dev/null +++ b/web_slearn/src/App.css @@ -0,0 +1,184 @@ +.counter { + font-size: 16px; + padding: 5px 10px; + border-radius: 5px; + color: var(--accent); + background: var(--accent-bg); + border: 2px solid transparent; + transition: border-color 0.3s; + margin-bottom: 24px; + + &:hover { + border-color: var(--accent-border); + } + &:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + } +} + +.hero { + position: relative; + + .base, + .framework, + .vite { + inset-inline: 0; + margin: 0 auto; + } + + .base { + width: 170px; + position: relative; + z-index: 0; + } + + .framework, + .vite { + position: absolute; + } + + .framework { + z-index: 1; + top: 34px; + height: 28px; + transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) + scale(1.4); + } + + .vite { + z-index: 0; + top: 107px; + height: 26px; + width: auto; + transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) + scale(0.8); + } +} + +#center { + display: flex; + flex-direction: column; + gap: 25px; + place-content: center; + place-items: center; + flex-grow: 1; + + @media (max-width: 1024px) { + padding: 32px 20px 24px; + gap: 18px; + } +} + +#next-steps { + display: flex; + border-top: 1px solid var(--border); + text-align: left; + + & > div { + flex: 1 1 0; + padding: 32px; + @media (max-width: 1024px) { + padding: 24px 20px; + } + } + + .icon { + margin-bottom: 16px; + width: 22px; + height: 22px; + } + + @media (max-width: 1024px) { + flex-direction: column; + text-align: center; + } +} + +#docs { + border-right: 1px solid var(--border); + + @media (max-width: 1024px) { + border-right: none; + border-bottom: 1px solid var(--border); + } +} + +#next-steps ul { + list-style: none; + padding: 0; + display: flex; + gap: 8px; + margin: 32px 0 0; + + .logo { + height: 18px; + } + + a { + color: var(--text-h); + font-size: 16px; + border-radius: 6px; + background: var(--social-bg); + display: flex; + padding: 6px 12px; + align-items: center; + gap: 8px; + text-decoration: none; + transition: box-shadow 0.3s; + + &:hover { + box-shadow: var(--shadow); + } + .button-icon { + height: 18px; + width: 18px; + } + } + + @media (max-width: 1024px) { + margin-top: 20px; + flex-wrap: wrap; + justify-content: center; + + li { + flex: 1 1 calc(50% - 8px); + } + + a { + width: 100%; + justify-content: center; + box-sizing: border-box; + } + } +} + +#spacer { + height: 88px; + border-top: 1px solid var(--border); + @media (max-width: 1024px) { + height: 48px; + } +} + +.ticks { + position: relative; + width: 100%; + + &::before, + &::after { + content: ''; + position: absolute; + top: -4.5px; + border: 5px solid transparent; + } + + &::before { + left: 0; + border-left-color: var(--border); + } + &::after { + right: 0; + border-right-color: var(--border); + } +} diff --git a/web_slearn/src/App.jsx b/web_slearn/src/App.jsx new file mode 100644 index 0000000..8480d29 --- /dev/null +++ b/web_slearn/src/App.jsx @@ -0,0 +1,27 @@ +import { Routes, Route } from "react-router-dom"; +import MainLayout from "./layouts/MainLayout"; +import Dashboard from "./pages/Dashboard"; +import AnalisisCSV from "./pages/AnalisisCSV"; +import HasilAnalisis from "./pages/HasilAnalisis"; +import PrediksiUlasan from "./pages/PrediksiUlasan"; +import RiwayatAnalisis from "./pages/RiwayatAnalisis"; +import PenelitianModel from "./pages/PenelitianModel"; +import RiwayatModel from "./pages/RiwayatModel"; + +function App() { + return ( + + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + ); +} + +export default App; diff --git a/web_slearn/src/assets/hero.png b/web_slearn/src/assets/hero.png new file mode 100644 index 0000000..02251f4 Binary files /dev/null and b/web_slearn/src/assets/hero.png differ diff --git a/web_slearn/src/assets/react.svg b/web_slearn/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/web_slearn/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web_slearn/src/assets/vite.svg b/web_slearn/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/web_slearn/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/web_slearn/src/components/EvaluasiPembanding.jsx b/web_slearn/src/components/EvaluasiPembanding.jsx new file mode 100644 index 0000000..7575fcb --- /dev/null +++ b/web_slearn/src/components/EvaluasiPembanding.jsx @@ -0,0 +1,89 @@ +import { useRef, useState } from 'react'; +import data from '../data/pengujian-pembanding.json'; +import '../styles/evaluasi-pembanding.css'; + +const pct = (v) => new Intl.NumberFormat('id-ID', {style:'percent', minimumFractionDigits:2, maximumFractionDigits:2}).format(v); +function evaluate(rows) { + const cm = [[0,0],[0,0]]; + rows.forEach(r => { cm[r.label_acuan][r.label_prediksi]++; }); + const n = rows.length; + const classes = [0,1].map(k => { + const tp = cm[k][k], support = cm[k][0]+cm[k][1], predicted = cm[0][k]+cm[1][k]; + const precision = predicted ? tp/predicted : 0, recall = support ? tp/support : 0; + return {precision, recall, f1: precision+recall ? 2*precision*recall/(precision+recall) : 0, support}; + }); + return {cm, classes, n, correct:cm[0][0]+cm[1][1], accuracy:(cm[0][0]+cm[1][1])/n}; +} +const result = evaluate(data.rows); +const initial = {accuracy:(data.initialMatrix[0][0]+data.initialMatrix[1][1])/data.initialMatrix.flat().reduce((a,b)=>a+b,0)}; +const cells = [ + {x:260,y:170,value:result.cm[0][0],label:'TN',correct:true}, + {x:500,y:170,value:result.cm[0][1],label:'FP',correct:false}, + {x:260,y:350,value:result.cm[1][0],label:'FN',correct:false}, + {x:500,y:350,value:result.cm[1][1],label:'TP',correct:true}, +]; +function download(blob, name) { + const url = URL.createObjectURL(blob), a = document.createElement('a'); + a.href=url; a.download=name; a.click(); setTimeout(()=>URL.revokeObjectURL(url), 1000); +} + +export default function EvaluasiPembanding() { + const svgRef = useRef(null); + const [busy,setBusy] = useState(false), [error,setError] = useState(''); + async function savePNG() { + setBusy(true); setError(''); let url; + try { + const source = new XMLSerializer().serializeToString(svgRef.current); + url = URL.createObjectURL(new Blob([source],{type:'image/svg+xml;charset=utf-8'})); + const img = new Image(); + await new Promise((resolve,reject)=>{img.onload=resolve;img.onerror=()=>reject(new Error('Gambar gagal dibuat. Silakan coba lagi.'));img.src=url;}); + const canvas = document.createElement('canvas'); canvas.width=2700; canvas.height=1680; + const ctx=canvas.getContext('2d'); if(!ctx) throw new Error('Browser tidak mendukung ekspor gambar.'); + ctx.drawImage(img,0,0,2700,1680); + const blob=await new Promise(resolve=>canvas.toBlob(resolve,'image/png')); + if(!blob) throw new Error('Unduhan gambar gagal dibuat.'); + download(blob,'confusion_matrix_pembanding_v1.png'); + } catch(e) {setError(e.message);} finally {if(url) URL.revokeObjectURL(url);setBusy(false);} + } + function saveCSV() { + const headers=['id','ulasan','label_acuan','label_prediksi','model_version','prediction_id','kondisi']; + const quote=v=>'"'+String(v??'').replaceAll('"','""')+'"'; + const csv=[headers.join(','),...data.rows.map(r=>headers.map(h=>quote(r[h])).join(','))].join('\r\n'); + download(new Blob(['\uFEFF'+csv],{type:'text/csv;charset=utf-8'}),'hasil_115_pengujian_pembanding.csv'); + } + return
+
+
PENGUJIAN TAMBAHAN · MODEL v1

Pengujian Pembanding

115 ulasan baru
+

Hasil tersimpan dari fitur Prediksi Satu Ulasan. Model awal tetap dilatih dengan 456 ulasan; pengujian ini tidak melakukan training ulang.

+

Data pembanding berupa ulasan sintetis dengan label acuan usulan AI. Hasil ini terpisah dari evaluasi penelitian awal dan belum menggunakan label hasil validasi independen manusia.

+
+
Akurasi awal{pct(initial.accuracy)}115 data uji penelitian
+
Akurasi pembanding{pct(result.accuracy)}115 ulasan sintetis baru
+
Sesuai label acuan{result.correct} / {result.n}{result.n-result.correct} prediksi berbeda
+
+
+
+

Confusion Matrix

Baris menunjukkan label acuan; kolom menunjukkan hasil prediksi.

+ {error &&

{error}

} + + Confusion Matrix Pengujian Pembanding + Model v1. TN 73, FP 3, FN 10, TP 29. Akurasi 88,70 persen pada 115 ulasan sintetis. + + + Confusion Matrix Pengujian Pembanding + Model v1 · 115 ulasan baru · Akurasi {pct(result.accuracy)} + Hasil prediksi + Negatif (0) + Positif (1) + Label acuan + Negatif (0) + Positif (1) + {cells.map(c=>{c.value}{c.label})} + + +
+

Metrik per kelas

{result.classes.map((c,k)=>)}
KelasPrecisionRecallF1-scoreJumlah
{k===0?'Negatif':'Positif'}{pct(c.precision)}{pct(c.recall)}{pct(c.f1)}{c.support}

Akurasi = (TN + TP) / seluruh ulasan = (73 + 29) / 115 = {pct(result.accuracy)}.

+

Perbandingan dengan pengujian awal

KomponenAwal penelitianPengujian pembanding
Modelv1v1 yang sama
Jumlah data uji115115 ulasan baru
TN / FP / FN / TP70 / 6 / 2 / 3773 / 3 / 10 / 29
Akurasi{pct(initial.accuracy)}{pct(result.accuracy)}

Selisih {new Intl.NumberFormat('id-ID',{maximumFractionDigits:2}).format((initial.accuracy-result.accuracy)*100)} poin persentase dihitung sebelum pembulatan. Kedua pengujian memakai kumpulan data berbeda; hasil pembanding tidak menggantikan hasil awal.

+

Hasil 115 ulasan

Arsip pengujian model v1. Prediksi baru di web tidak otomatis mengubah evaluasi ini.

Lihat 13 prediksi yang berbeda
{data.rows.filter(r=>r.label_acuan!==r.label_prediksi).map(r=>)}
IDUlasanAcuanPrediksi
{r.id}{r.ulasan}{r.label_acuan===0?'Negatif':'Positif'}{r.label_prediksi===0?'Negatif':'Positif'}
+
; +} diff --git a/web_slearn/src/components/EvaluasiPembanding.jsx.backup-20261028-180124-636 b/web_slearn/src/components/EvaluasiPembanding.jsx.backup-20261028-180124-636 new file mode 100644 index 0000000..ace1f3f --- /dev/null +++ b/web_slearn/src/components/EvaluasiPembanding.jsx.backup-20261028-180124-636 @@ -0,0 +1,91 @@ +import { useRef, useState } from 'react'; +import data from '../data/pengujian-pembanding.json'; +import '../styles/evaluasi-pembanding.css'; + +const pct = (v) => new Intl.NumberFormat('id-ID', {style:'percent', minimumFractionDigits:2, maximumFractionDigits:2}).format(v); +function evaluate(rows) { + const cm = [[0,0],[0,0]]; + rows.forEach(r => { cm[r.label_acuan][r.label_prediksi]++; }); + const n = rows.length; + const classes = [0,1].map(k => { + const tp = cm[k][k], support = cm[k][0]+cm[k][1], predicted = cm[0][k]+cm[1][k]; + const precision = predicted ? tp/predicted : 0, recall = support ? tp/support : 0; + return {precision, recall, f1: precision+recall ? 2*precision*recall/(precision+recall) : 0, support}; + }); + return {cm, classes, n, correct:cm[0][0]+cm[1][1], accuracy:(cm[0][0]+cm[1][1])/n}; +} +const result = evaluate(data.rows); +const initial = {accuracy:(data.initialMatrix[0][0]+data.initialMatrix[1][1])/data.initialMatrix.flat().reduce((a,b)=>a+b,0)}; +const cells = [ + {x:260,y:170,value:result.cm[0][0],label:'TN',correct:true}, + {x:500,y:170,value:result.cm[0][1],label:'FP',correct:false}, + {x:260,y:350,value:result.cm[1][0],label:'FN',correct:false}, + {x:500,y:350,value:result.cm[1][1],label:'TP',correct:true}, +]; +function download(blob, name) { + const url = URL.createObjectURL(blob), a = document.createElement('a'); + a.href=url; a.download=name; a.click(); setTimeout(()=>URL.revokeObjectURL(url), 1000); +} + +export default function EvaluasiPembanding() { + const svgRef = useRef(null); + const [busy,setBusy] = useState(false), [error,setError] = useState(''); + async function savePNG() { + setBusy(true); setError(''); let url; + try { + const source = new XMLSerializer().serializeToString(svgRef.current); + url = URL.createObjectURL(new Blob([source],{type:'image/svg+xml;charset=utf-8'})); + const img = new Image(); + await new Promise((resolve,reject)=>{img.onload=resolve;img.onerror=()=>reject(new Error('Gambar gagal dibuat. Silakan coba lagi.'));img.src=url;}); + const canvas = document.createElement('canvas'); canvas.width=2700; canvas.height=1920; + const ctx=canvas.getContext('2d'); if(!ctx) throw new Error('Browser tidak mendukung ekspor gambar.'); + ctx.drawImage(img,0,0,2700,1920); + const blob=await new Promise(resolve=>canvas.toBlob(resolve,'image/png')); + if(!blob) throw new Error('Unduhan gambar gagal dibuat.'); + download(blob,'confusion_matrix_pembanding_v1.png'); + } catch(e) {setError(e.message);} finally {if(url) URL.revokeObjectURL(url);setBusy(false);} + } + function saveCSV() { + const headers=['id','ulasan','label_acuan','label_prediksi','model_version','prediction_id','kondisi']; + const quote=v=>'"'+String(v??'').replaceAll('"','""')+'"'; + const csv=[headers.join(','),...data.rows.map(r=>headers.map(h=>quote(r[h])).join(','))].join('\r\n'); + download(new Blob(['\uFEFF'+csv],{type:'text/csv;charset=utf-8'}),'hasil_115_pengujian_pembanding.csv'); + } + return
+
+
PENGUJIAN TAMBAHAN · MODEL v1

Pengujian Pembanding

115 ulasan baru
+

Hasil tersimpan dari fitur Prediksi Satu Ulasan. Model awal tetap dilatih dengan 456 ulasan; pengujian ini tidak melakukan training ulang.

+

Data pembanding berupa ulasan sintetis dengan label acuan usulan AI. Hasil ini terpisah dari evaluasi penelitian awal dan belum menggunakan label hasil validasi independen manusia.

+
+
Akurasi awal{pct(initial.accuracy)}115 data uji penelitian
+
Akurasi pembanding{pct(result.accuracy)}115 ulasan sintetis baru
+
Sesuai label acuan{result.correct} / {result.n}{result.n-result.correct} prediksi berbeda
+
+
+
+

Confusion Matrix

Baris menunjukkan label acuan; kolom menunjukkan hasil prediksi.

+ {error &&

{error}

} + + Confusion Matrix Pengujian Pembanding + Model v1. TN 73, FP 3, FN 10, TP 29. Akurasi 88,70 persen pada 115 ulasan sintetis. + + + Confusion Matrix Pengujian Pembanding + Model v1 · 115 ulasan baru · Akurasi {pct(result.accuracy)} + Hasil prediksi + Negatif (0) + Positif (1) + Label acuan + Negatif (0) + Positif (1) + {cells.map(c=>{c.value}{c.label})} + 102 prediksi sesuai label acuan · 13 prediksi berbeda + Data sintetis · Label acuan usulan AI · Evaluasi tambahan + + +
+

Metrik per kelas

{result.classes.map((c,k)=>)}
KelasPrecisionRecallF1-scoreJumlah
{k===0?'Negatif':'Positif'}{pct(c.precision)}{pct(c.recall)}{pct(c.f1)}{c.support}

Akurasi = (TN + TP) / seluruh ulasan = (73 + 29) / 115 = {pct(result.accuracy)}.

+

Perbandingan dengan pengujian awal

KomponenAwal penelitianPengujian pembanding
Modelv1v1 yang sama
Jumlah data uji115115 ulasan baru
TN / FP / FN / TP70 / 6 / 2 / 3773 / 3 / 10 / 29
Akurasi{pct(initial.accuracy)}{pct(result.accuracy)}

Selisih {new Intl.NumberFormat('id-ID',{maximumFractionDigits:2}).format((initial.accuracy-result.accuracy)*100)} poin persentase dihitung sebelum pembulatan. Kedua pengujian memakai kumpulan data berbeda; hasil pembanding tidak menggantikan hasil awal.

+

Hasil 115 ulasan

Arsip pengujian model v1. Prediksi baru di web tidak otomatis mengubah evaluasi ini.

Lihat 13 prediksi yang berbeda
{data.rows.filter(r=>r.label_acuan!==r.label_prediksi).map(r=>)}
IDUlasanAcuanPrediksi
{r.id}{r.ulasan}{r.label_acuan===0?'Negatif':'Positif'}{r.label_prediksi===0?'Negatif':'Positif'}
+
; +} diff --git a/web_slearn/src/components/ModelContext.jsx b/web_slearn/src/components/ModelContext.jsx new file mode 100644 index 0000000..a1f8e70 --- /dev/null +++ b/web_slearn/src/components/ModelContext.jsx @@ -0,0 +1,6 @@ +export function ModelVersion({ value }) { + return Model saat analisis: {value || "Tidak tercatat"}; +} +export function TfidfNote() { + return

TF-IDF menunjukkan bobot kata dalam ulasan, bukan ukuran langsung pengaruh kata terhadap sentimen positif atau negatif.

; +} diff --git a/web_slearn/src/components/Sidebar.jsx b/web_slearn/src/components/Sidebar.jsx new file mode 100644 index 0000000..f8c25e8 --- /dev/null +++ b/web_slearn/src/components/Sidebar.jsx @@ -0,0 +1,141 @@ +import { + NavLink, + useLocation +} from "react-router-dom"; + + +const menus = [ + {name: "Dashboard", path: "/"}, + {name: "Prediksi Satu Ulasan", path: "/prediksi"}, + {name: "Analisis CSV", path: "/analisis-csv"}, + {name: "Riwayat Analisis", path: "/riwayat"}, + {name: "Training & Riwayat Model", path: "/riwayat-model"}, + {name: "Penelitian & Model Awal", path: "/penelitian-model"} +]; + + +function Sidebar() { + const location = + useLocation(); + + + /* ========================================================== + MENENTUKAN MENU AKTIF + ========================================================== */ + + const isMenuActive = + (menuPath) => { + const pathname = + location.pathname; + + + if (menuPath === "/") { + return pathname === "/"; + } + + + /* + Halaman detail hasil analisis merupakan + bagian dari Riwayat Analisis. + */ + + if ( + menuPath === "/riwayat" + ) { + return ( + pathname === + "/riwayat" || + pathname.startsWith( + "/hasil-analisis/" + ) + ); + } + + + return ( + pathname === menuPath + ); + }; + + + /* ========================================================== + TAMPILAN + ========================================================== */ + + return ( + + ); +} + + +export default Sidebar; diff --git a/web_slearn/src/components/TrainingDetails.jsx b/web_slearn/src/components/TrainingDetails.jsx new file mode 100644 index 0000000..6efb675 --- /dev/null +++ b/web_slearn/src/components/TrainingDetails.jsx @@ -0,0 +1,137 @@ + +const rate = (v) => `${(v * 100).toFixed(2).replace(".", ",")}%`; + +export function SkippedRows({ audit, legacy = false, status = "completed" }) { + if (!audit) { + return legacy + ?

Rincian baris yang dilewati belum dicatat untuk versi lama ini.

+ : null; + } + + return ( +
+

+ + {audit.uploaded_rows} diunggah → {audit.accepted_rows} diterima →{" "} + {audit.skipped_rows.length} dilewati. + +

+ + {audit.selected_column && ( +

+ Kolom yang digunakan: {audit.selected_column} +

+ )} + +

+ {status === "completed" && audit.accepted_rows > 0 + ? `Data latih: ${audit.training_before} sebelum penambahan → ${audit.training_before + audit.accepted_rows} setelah training berhasil.` + : `Data latih tetap ${audit.training_before}.`} +

+ + {!!audit.skipped_rows.length && ( +
+ + Lihat Data yang Dilewati ({audit.skipped_rows.length}) + + +

+ Nomor baris adalah urutan record CSV, termasuk judul kolom + sebagai baris 1. Ulasan dengan baris baru di dalam tanda + kutip dihitung satu record. +

+ +
+ + + + + + + + + + + {audit.skipped_rows.map((r) => ( + + + + + + + + ))} + +
Baris CSVUlasanAlasan dilewati
{r.source_row_number} + {r.text || "(kosong)"} + + + {r.reason} + +
+
+
+ )} +
+ ); +} + +export function VersionComparison({ selected, original }) { + if (!original || selected.version === "v1") { + return ( +

+ Model penelitian awal: {selected.jumlah_data_training} data latih + dan {selected.jumlah_data_testing} data uji. +

+ ); + } + + const metrics = [ + ["Akurasi", m => m.akurasi], + ["Precision", m => m.classification_report["weighted avg"].precision], + ["Recall", m => m.classification_report["weighted avg"].recall], + ["F1-Score", m => m.classification_report["weighted avg"]["f1-score"]] + ]; + + return ( +
+

Perbandingan dengan model awal v1

+ +

+ {selected.jumlah_data_training} data latih. Evaluasi pada{" "} + {selected.jumlah_data_testing} data uji tetap yang sama. +

+ +
+ + + + + + + + + + + + {metrics.map(([name, get]) => ( + + + + + + + ))} + +
Metrikv1{selected.version}Selisih (poin persentase)
{name}{rate(get(original))}{rate(get(selected))} + {((get(selected) - get(original)) * 100).toFixed(2)} +
+
+ +

+ Precision, Recall, dan F1-Score menggunakan weighted average, + yaitu rata-rata yang memperhitungkan jumlah data pada setiap kelas. +

+
+ ); +} diff --git a/web_slearn/src/index.css b/web_slearn/src/index.css new file mode 100644 index 0000000..97ff932 --- /dev/null +++ b/web_slearn/src/index.css @@ -0,0 +1,18 @@ +html { + font-family: Inter, "Segoe UI", Arial, sans-serif; + background: #f5f7fb; +} + +html, body, #root { + width: 100%; + min-height: 100%; + margin: 0; +} + +body { + min-width: 320px; +} + +button, input, select, textarea { + font: inherit; +} \ No newline at end of file diff --git a/web_slearn/src/layouts/MainLayout.jsx b/web_slearn/src/layouts/MainLayout.jsx new file mode 100644 index 0000000..a9270fb --- /dev/null +++ b/web_slearn/src/layouts/MainLayout.jsx @@ -0,0 +1,18 @@ +import { Outlet } from "react-router-dom"; +import Sidebar from "../components/Sidebar"; +import "../styles/layout.css"; + +function MainLayout() { + return ( +
+ +
+
+ +
+
+
+ ); +} + +export default MainLayout; \ No newline at end of file diff --git a/web_slearn/src/main.jsx b/web_slearn/src/main.jsx new file mode 100644 index 0000000..466b579 --- /dev/null +++ b/web_slearn/src/main.jsx @@ -0,0 +1,13 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { BrowserRouter } from "react-router-dom"; +import App from "./App"; +import "./index.css"; + +createRoot(document.getElementById("root")).render( + + + + + +); \ No newline at end of file diff --git a/web_slearn/src/pages/AnalisisCSV.jsx b/web_slearn/src/pages/AnalisisCSV.jsx new file mode 100644 index 0000000..5c6c15b --- /dev/null +++ b/web_slearn/src/pages/AnalisisCSV.jsx @@ -0,0 +1,917 @@ + +import { useEffect, useMemo, useRef, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import Papa from "papaparse"; + +import { analisisCSV, ambilRiwayatModel } from "../services/api"; +import "../styles/analisis-csv.css"; + +const kandidatKolomUlasan = [ + "komentar/ulasan mahasiswa terhadap aplikasi s-learn", + "komentar", + "ulasan", + "review", + "comment", + "teks", + "text", + "feedback", + "kritik", + "saran", + "pendapat" +]; + +const kandidatKolomNonUlasan = [ + "no", + "nomor", + "id", + "nim", + "nama", + "nama mahasiswa", + "jenis kelamin", + "gender", + "prodi", + "program studi", + "semester", + "angkatan", + "golongan", + "tanggal", + "timestamp", + "email", + "telepon", + "no hp", + "nomor hp" +]; + +function normalisasiNamaKolom(nama) { + return String(nama || "") + .trim() + .toLowerCase() + .replace(/[_-]+/g, " ") + .replace(/\s+/g, " "); +} + +function deteksiKolomUlasan(columns) { + const normalized = columns.map((column) => ({ + original: column, + normalized: normalisasiNamaKolom(column) + })); + + for (const kandidat of kandidatKolomUlasan) { + const target = normalisasiNamaKolom(kandidat); + + const exact = normalized.find( + (item) => item.normalized === target + ); + + if (exact) return exact.original; + } + + const sebagian = normalized.find((item) => + [ + "komentar", + "ulasan", + "review", + "comment", + "feedback", + "kritik", + "saran", + "pendapat" + ].some((kata) => item.normalized.includes(kata)) + ); + + return sebagian?.original || ""; +} + +function formatUkuranFile(size) { + const bytes = Number(size || 0); + + if (bytes < 1024) return `${bytes} B`; + + const kb = bytes / 1024; + + if (kb < 1024) { + return `${kb.toFixed(1)} KB`; + } + + const mb = kb / 1024; + + if (mb < 1024) { + return `${mb.toFixed(2)} MB`; + } + + return `${(mb / 1024).toFixed(2)} GB`; +} + +function tampakSebagaiAngka(value) { + const text = String(value || "").trim(); + + if (!text) return false; + + const punyaAngka = /\d/.test(text); + const punyaHuruf = /[a-zA-Z]/.test(text); + + return punyaAngka && !punyaHuruf; +} + +function evaluasiKesesuaianKolom(column, rows) { + if (!column) { + return { + cocok: false, + jenis: "empty", + pesan: "Pilih kolom yang berisi teks ulasan." + }; + } + + const namaKolom = normalisasiNamaKolom(column); + + const values = rows + .map((row) => String(row[column] ?? "").trim()) + .filter(Boolean); + + if (values.length === 0) { + return { + cocok: false, + jenis: "warning", + pesan: "Kolom ini tidak memiliki teks yang dapat dianalisis." + }; + } + + const namaTampakUlasan = kandidatKolomUlasan.some((kandidat) => { + const target = normalisasiNamaKolom(kandidat); + + return ( + namaKolom === target || + namaKolom.includes(target) + ); + }); + + if (namaTampakUlasan) { + return { + cocok: true, + jenis: "success", + pesan: "" + }; + } + + const namaTampakMetadata = kandidatKolomNonUlasan.some( + (kandidat) => namaKolom === normalisasiNamaKolom(kandidat) + ); + + if (namaTampakMetadata) { + return { + cocok: false, + jenis: "warning", + pesan: `Kolom "${column}" tampaknya bukan kolom teks ulasan. Pilih kolom yang berisi komentar atau ulasan.` + }; + } + + const jumlahNumerik = values.filter(tampakSebagaiAngka).length; + + const rasioNumerik = + values.length > 0 + ? jumlahNumerik / values.length + : 0; + + const rataPanjang = + values.reduce( + (total, value) => total + value.length, + 0 + ) / values.length; + + const rataKata = + values.reduce((total, value) => { + const jumlahKata = value + .split(/\s+/) + .filter(Boolean).length; + + return total + jumlahKata; + }, 0) / values.length; + + if (rasioNumerik >= 0.5) { + return { + cocok: false, + jenis: "warning", + pesan: + "Isi kolom ini didominasi angka sehingga tampaknya bukan teks ulasan." + }; + } + + if (rataPanjang < 8 || (rataKata < 2 && rataPanjang < 18)) { + return { + cocok: false, + jenis: "warning", + pesan: + "Isi kolom ini tampak terlalu pendek untuk digunakan sebagai teks ulasan." + }; + } + + return { + cocok: true, + jenis: "success", + pesan: "" + }; +} + +function kolomPerluDisamarkan(column) { + const nama = normalisasiNamaKolom(column); + + return [ + "nim", + "nama", + "nama mahasiswa", + "email", + "telepon", + "no hp", + "nomor hp" + ].includes(nama); +} + +function samarkanNama(value) { + return String(value || "") + .trim() + .split(/\s+/) + .map((kata) => { + if (!kata) return ""; + if (kata.length === 1) return `${kata}•`; + + return `${kata.charAt(0)}${"•".repeat( + Math.min(kata.length - 1, 5) + )}`; + }) + .join(" "); +} + +function formatPreviewValue(column, value) { + const text = String(value ?? ""); + const nama = normalisasiNamaKolom(column); + + if (!text.trim()) return "-"; + + if (nama === "nama" || nama === "nama mahasiswa") { + return samarkanNama(text); + } + + if (nama === "nim") { + const clean = text.trim(); + + if (clean.length <= 4) { + return "••••"; + } + + return `${clean.slice(0, 2)}••••${clean.slice(-2)}`; + } + + if ( + nama === "email" || + nama === "telepon" || + nama === "no hp" || + nama === "nomor hp" + ) { + return "••••••••"; + } + + return text; +} + +function AnalisisCSV() { + const navigate = useNavigate(); + const fileInputRef = useRef(null); + const submitLockRef = useRef(false); + + const [step, setStep] = useState(1); + const [file, setFile] = useState(null); + const [columns, setColumns] = useState([]); + const [rows, setRows] = useState([]); + const [selectedColumn, setSelectedColumn] = useState(""); + const [detectedColumn, setDetectedColumn] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + // =================================================== + // TAMBAHAN: INFORMASI VERSI MODEL YANG SEDANG AKTIF + // =================================================== + const [modelAktif, setModelAktif] = useState(null); + const [loadingModel, setLoadingModel] = useState(true); + + useEffect(() => { + let mounted = true; + + async function muatModelAktif() { + try { + const response = await ambilRiwayatModel(); + const versions = response?.data?.versions || []; + const active = versions.find((model) => model.active); + + if (mounted) { + setModelAktif(active?.version || null); + } + } catch (err) { + console.error("Gagal mengambil model aktif:", err); + + if (mounted) { + setModelAktif(null); + } + } finally { + if (mounted) { + setLoadingModel(false); + } + } + } + + muatModelAktif(); + + return () => { + mounted = false; + }; + }, []); + + // =================================================== + // PREVIEW DAN VALIDASI DATA CSV + // =================================================== + const previewRows = rows.slice(0, 5); + + const jumlahUlasanValid = selectedColumn + ? rows.filter((row) => { + const value = row[selectedColumn]; + + return ( + value !== undefined && + value !== null && + String(value).trim() !== "" + ); + }).length + : 0; + + const jumlahBarisKosong = selectedColumn + ? Math.max(0, rows.length - jumlahUlasanValid) + : 0; + + const evaluasiKolom = useMemo( + () => evaluasiKesesuaianKolom(selectedColumn, rows), + [selectedColumn, rows] + ); + + const adaKolomDisamarkan = useMemo( + () => columns.some(kolomPerluDisamarkan), + [columns] + ); + + const pilihFile = () => { + fileInputRef.current?.click(); + }; + + // =================================================== + // MEMBACA FILE CSV + // =================================================== + const parseCSV = (selectedFile) => { + setError(""); + + Papa.parse(selectedFile, { + header: true, + skipEmptyLines: "greedy", + transformHeader: (header) => header.trim(), + + complete: (result) => { + const fields = result.meta?.fields || []; + + if (fields.length === 0) { + setError( + "CSV tidak memiliki header kolom yang dapat dibaca." + ); + return; + } + + const headerKosong = fields.some( + (field) => !String(field).trim() + ); + + if (headerKosong) { + setError( + "CSV memiliki nama kolom yang kosong. Periksa kembali header file." + ); + return; + } + + if (result.data.length === 0) { + setError( + "CSV tidak memiliki data untuk dianalisis." + ); + return; + } + + const detected = deteksiKolomUlasan(fields); + + setFile(selectedFile); + setColumns(fields); + setRows(result.data); + setDetectedColumn(detected); + setSelectedColumn(detected); + setStep(2); + }, + + error: () => { + setError( + "File CSV tidak dapat dibaca. Periksa kembali format file." + ); + } + }); + }; + + const handleFile = (selectedFile) => { + if (!selectedFile) return; + + setError(""); + + if (!selectedFile.name.toLowerCase().endsWith(".csv")) { + setError("File harus menggunakan format .csv."); + return; + } + + parseCSV(selectedFile); + }; + + const handleInputFile = (event) => { + handleFile(event.target.files?.[0]); + }; + + const handleDrop = (event) => { + event.preventDefault(); + + handleFile(event.dataTransfer.files?.[0]); + }; + + // =================================================== + // PROSES ANALISIS SENTIMEN CSV + // =================================================== + const mulaiAnalisis = async () => { + if (submitLockRef.current || loading) { + return; + } + + if (!file) { + setError( + "File CSV belum tersedia. Silakan unggah file terlebih dahulu." + ); + return; + } + + if (!selectedColumn) { + setError( + "Pilih kolom yang berisi ulasan sebelum memulai analisis." + ); + return; + } + + if (!evaluasiKolom.cocok) { + setError( + evaluasiKolom.pesan || + "Kolom yang dipilih tampaknya bukan teks ulasan." + ); + return; + } + + const reviews = rows + .map((row, index) => ({ + source_row_number: index + 1, + text: String(row[selectedColumn] ?? "").trim() + })) + .filter((item) => item.text !== ""); + + if (reviews.length === 0) { + setError( + "Kolom yang dipilih tidak memiliki ulasan yang dapat dianalisis." + ); + return; + } + + submitLockRef.current = true; + setError(""); + setLoading(true); + + try { + const hasil = await analisisCSV( + file.name, + selectedColumn, + reviews + ); + + if ( + hasil.status !== "success" || + !hasil.data?.batch_id + ) { + throw new Error( + "Hasil analisis dari server tidak valid." + ); + } + + setStep(3); + + navigate( + `/hasil-analisis/${hasil.data.batch_id}`, + { + state: { + hasilAnalisis: hasil.data, + sumberHalaman: "analisis-csv", + analisisBaruSelesai: true + } + } + ); + } catch (err) { + setError( + err.message || + "Terjadi kesalahan saat menganalisis file CSV." + ); + } finally { + submitLockRef.current = false; + setLoading(false); + } + }; + + // =================================================== + // RESET ANALISIS CSV + // =================================================== + const resetAnalisis = () => { + if (loading) return; + + submitLockRef.current = false; + + setStep(1); + setFile(null); + setColumns([]); + setRows([]); + setSelectedColumn(""); + setDetectedColumn(""); + setLoading(false); + setError(""); + + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + }; + + // =================================================== + // TAMPILAN HALAMAN ANALISIS CSV + // =================================================== + return ( +
+ + {/* HEADING DAN INFORMASI MODEL AKTIF */} +
+

Analisis CSV

+ +

+ Unggah file CSV berisi ulasan S-Learn, lalu pilih + kolom yang akan dianalisis. +

+ + {/* INFORMASI VERSI MODEL AKTIF */} +
+ + + + Model yang sedang digunakan: + + + + {loadingModel + ? "Memuat..." + : modelAktif || "Tidak tersedia"} + +
+
+ + {/* STEPPER — DIPERTAHANKAN */} +
+
= 1 ? "active" : "" + }`} + > + 1 +

Unggah File

+
+ +
= 2 ? "active" : "" + }`} + /> + +
= 2 ? "active" : "" + }`} + > + 2 +

Periksa Data

+
+ +
= 3 ? "active" : "" + }`} + /> + +
= 3 ? "active" : "" + }`} + > + 3 +

Hasil

+
+
+ + {error && ( +
+ {error} +
+ )} + + {/* STEP 1 — UNGGAH FILE CSV */} + {step === 1 && ( +
+
event.preventDefault()} + onDrop={handleDrop} + > +
+ ↑ +
+ +

Unggah File CSV

+ +

+ Pilih file CSV atau tarik file ke area ini + untuk memulai analisis. +

+ + + + + + + Format .csv · Header wajib tersedia + +
+
+ )} + + {/* STEP 2 — PREVIEW DAN PEMILIHAN KOLOM */} + {step === 2 && ( + <> +
+
+
+

{file?.name}

+ +

+ {formatUkuranFile(file?.size)} + · + {rows.length} baris + · + {columns.length} kolom +

+
+ + +
+ +
+

5 Baris Pertama

+ + {adaKolomDisamarkan && ( +

+ +

+ )} +
+ +
+ + + + + + {columns.map((column) => ( + + ))} + + + + + {previewRows.map((row, rowIndex) => ( + + + + {columns.map((column) => ( + + ))} + + ))} + +
+ # + + {column} +
+ {rowIndex + 1} + + {formatPreviewValue( + column, + row[column] + )} +
+
+
+ + {/* PILIH KOLOM ULASAN */} +
+
+
+
+ + + {detectedColumn && + selectedColumn === detectedColumn && ( + + Terdeteksi otomatis + + )} +
+
+ + +
+ + {/* STATUS KOLOM VALID */} + {selectedColumn && evaluasiKolom.cocok && ( +
+ + ✓ + + +
+ + {jumlahBarisKosong === 0 + ? `${jumlahUlasanValid} ulasan siap dianalisis.` + : `${jumlahUlasanValid} dari ${rows.length} baris berisi teks.`} + + + {jumlahBarisKosong > 0 && ( +

+ {jumlahBarisKosong} baris kosong akan + dilewati saat analisis. +

+ )} +
+
+ )} + + {/* STATUS KOLOM TIDAK SESUAI */} + {selectedColumn && !evaluasiKolom.cocok && ( +
+ + ! + + +
+ + Kolom belum sesuai + + +

+ {evaluasiKolom.pesan} +

+
+
+ )} + + {/* INFORMASI PENYIMPANAN */} +
+ i + +

+ Hanya isi kolom yang dipilih yang diproses dan + disimpan sebagai data analisis. +

+
+ + {/* TOMBOL MULAI ANALISIS */} +
+ +
+
+ + )} +
+ ); +} + +export default AnalisisCSV; diff --git a/web_slearn/src/pages/Dashboard.jsx b/web_slearn/src/pages/Dashboard.jsx new file mode 100644 index 0000000..f5cbc36 --- /dev/null +++ b/web_slearn/src/pages/Dashboard.jsx @@ -0,0 +1,50 @@ +import { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import { ambilRiwayatAnalisis, ambilRiwayatPrediksi, ambilRiwayatModel } from "../services/api"; +import { ModelVersion } from "../components/ModelContext"; +import "../styles/dashboard.css"; +import "../styles/riwayat-model.css"; +const percent = (v) => `${(v * 100).toFixed(2).replace('.', ',')}%`; +const date = (v) => v ? new Date(v).toLocaleString('id-ID') : 'Tidak tercatat'; +export default function Dashboard() { + const [state,setState]=useState({}); + useEffect(()=>{ + let alive=true; + async function load() { + const results=await Promise.allSettled([ambilRiwayatModel(),ambilRiwayatAnalisis(),ambilRiwayatPrediksi()]); + if(alive) setState(Object.fromEntries(['models','csv','single'].map((k,i)=>[k,results[i].status==='fulfilled'?{data:results[i].value.data}:{error:results[i].reason.message}]))); + } + load(); + window.addEventListener('focus',load); + return ()=>{alive=false;window.removeEventListener('focus',load);}; + },[]); + const active=state.models?.data?.versions.find(v=>v.active); + const latest=state.csv?.data?.[0]; + const total=latest?.total_data || 0; + const positive=total ? latest.total_positive/total*100 : 0; + const card=(label,value,note)=>
{label}{value}{note}
; + return
+

Dashboard

Model yang digunakan, aktivitas prediksi, dan hasil analisis terakhir.

Prediksi Ulasan Baru
+

Model yang sedang digunakan

Model ini digunakan untuk prediksi satu ulasan dan analisis CSV berikutnya.

+ {state.models?.error ?

Informasi model tidak dapat dimuat: {state.models.error}

: !active ?

Memuat model aktif...

: <> +
Versi aktif{active.version}
Akurasi pada data uji{percent(active.akurasi)}
Data latih model{active.jumlah_data_training}
Data uji tetap{active.jumlah_data_testing}
+

Selesai dilatih: {date(active.created_at)}. Akurasi adalah hasil evaluasi pada data uji, bukan jaminan setiap prediksi benar.

Kelola model dan lihat evaluasi →} +
+

Aktivitas analisis

Ulasan yang dianalisis tidak otomatis ditambahkan ke data latih. Analisis berulang tetap dihitung sebagai aktivitas terpisah.

+
+ {card('Prediksi satu ulasan',state.single?.data?.length ?? '—','Hasil tersimpan dari menu Prediksi Satu Ulasan')} + {card('Analisis CSV selesai',state.csv?.data?.length ?? '—','Jumlah batch tersimpan')} + {card('Ulasan dalam seluruh batch CSV',state.csv?.data?.reduce((n,r)=>n+r.total_data,0) ?? '—','Jumlah baris yang diprediksi, bukan data latih')} +
+ {[state.csv?.error,state.single?.error].filter(Boolean).map((e,i)=>

Data aktivitas tidak dapat dimuat: {e}

)} +
+

Hasil analisis CSV terakhir

Distribusi hasil prediksi pada file terakhir, bukan distribusi dataset training.

+ {state.csv?.error ?

Hasil terakhir tidak tersedia karena riwayat gagal dimuat.

: !state.csv ?

Memuat hasil...

: !latest ?

Belum ada analisis CSV. Analisis CSV baru

: <> +

{latest.file_name} · {date(latest.created_at)}

+
{total}Ulasan
+

Positif: {latest.total_positive} ({positive.toFixed(1)}%)

Negatif: {latest.total_negative} ({(total?latest.total_negative/total*100:0).toFixed(1)}%)

Lihat Hasil
+ } +
+

Penelitian awal: 571 ulasan (456 data latih dan 115 data uji). Lihat Penelitian & Model Awal →

+
; +} diff --git a/web_slearn/src/pages/HasilAnalisis.jsx b/web_slearn/src/pages/HasilAnalisis.jsx new file mode 100644 index 0000000..a785b13 --- /dev/null +++ b/web_slearn/src/pages/HasilAnalisis.jsx @@ -0,0 +1,1204 @@ +import { ModelVersion, TfidfNote } from "../components/ModelContext"; +import { + useEffect, + useMemo, + useRef, + useState +} from "react"; + +import { + useNavigate, + useParams +} from "react-router-dom"; + +import { + ambilHasilAnalisis, + ambilRiwayatAnalisis +} from "../services/api"; + +import "../styles/hasil-analisis.css"; + +const ITEMS_PER_PAGE = 10; + +const NAMA_BULAN = [ + "Januari", + "Februari", + "Maret", + "April", + "Mei", + "Juni", + "Juli", + "Agustus", + "September", + "Oktober", + "November", + "Desember" +]; + +/* ============================================================ + FORMAT WAKTU + ============================================================ */ + +function formatWaktu(value) { + if (!value) { + return "-"; + } + + const [tanggalRaw, waktuRaw = ""] = + String(value).split("T"); + + const bagianTanggal = + tanggalRaw.split("-"); + + if (bagianTanggal.length !== 3) { + return value; + } + + const [year, month, day] = + bagianTanggal.map(Number); + + if ( + !year || + !month || + !day || + !NAMA_BULAN[month - 1] + ) { + return value; + } + + const jam = waktuRaw + ? waktuRaw + .slice(0, 5) + .replace(":", ".") + : "-"; + + return `${day} ${NAMA_BULAN[month - 1]} ${year}, ${jam}`; +} + +/* ============================================================ + CSV + ============================================================ */ + +function escapeCSV(value) { + const text = String(value ?? ""); + + return `"${text.replaceAll( + '"', + '""' + )}"`; +} + +/* ============================================================ + HALAMAN + ============================================================ */ + +function HasilAnalisis() { + const navigate = useNavigate(); + + const { + batchId + } = useParams(); + + const [ + dataAnalisis, + setDataAnalisis + ] = useState(null); + + const [ + batchNumber, + setBatchNumber + ] = useState(null); + + const [ + loading, + setLoading + ] = useState(true); + + const [ + error, + setError + ] = useState(""); + + const [ + search, + setSearch + ] = useState(""); + + const [ + filter, + setFilter + ] = useState("Semua"); + + const [ + detail, + setDetail + ] = useState(null); + + const [ + currentPage, + setCurrentPage + ] = useState(1); + + /* + Ref untuk posisi awal + Detail Hasil Analisis. + */ + const hasilDetailRef = useRef(null); + + /* ========================================================== + AMBIL DATA + ========================================================== */ + + useEffect(() => { + let aktif = true; + + async function loadData() { + if (!batchId) { + setError( + "ID batch analisis tidak tersedia." + ); + + setLoading(false); + + return; + } + + try { + setLoading(true); + setError(""); + + const response = + await ambilHasilAnalisis( + batchId + ); + + if ( + aktif && + response.status === "success" + ) { + setDataAnalisis( + response.data + ); + + try { + const historyResponse = + await ambilRiwayatAnalisis(); + + if ( + aktif && + historyResponse.status === + "success" + ) { + const urutanBatch = [ + ...(historyResponse.data || []) + ].sort( + (a, b) => { + const waktuA = + String( + a.created_at || "" + ); + + const waktuB = + String( + b.created_at || "" + ); + + const bandingWaktu = + waktuA.localeCompare( + waktuB + ); + + if ( + bandingWaktu !== 0 + ) { + return bandingWaktu; + } + + return String( + a.batch_id || "" + ).localeCompare( + String( + b.batch_id || "" + ) + ); + } + ); + + const posisiBatch = + urutanBatch.findIndex( + (item) => + item.batch_id === + batchId + ); + + setBatchNumber( + posisiBatch >= 0 + ? posisiBatch + 1 + : null + ); + } + } catch { + if (aktif) { + setBatchNumber(null); + } + } + } + } catch (err) { + if (aktif) { + setError( + err.message || + "Hasil analisis tidak dapat dimuat." + ); + } + } finally { + if (aktif) { + setLoading(false); + } + } + } + + loadData(); + + return () => { + aktif = false; + }; + }, [batchId]); + + /* ========================================================== + RESET HALAMAN SAAT SEARCH / FILTER BERUBAH + ========================================================== */ + + function changeSearch(value) { setSearch(value); setCurrentPage(1); } + function changeFilter(value) { setFilter(value); setCurrentPage(1); } + + /* ========================================================== + DATA + ========================================================== */ + + const hasil = useMemo(() => dataAnalisis?.results || [], [dataAnalisis]); + + const total = + Number( + dataAnalisis?.total_data || + 0 + ); + + const positif = + Number( + dataAnalisis + ?.total_positive || + 0 + ); + + const negatif = + Number( + dataAnalisis + ?.total_negative || + 0 + ); + + const persentasePositif = + total > 0 + ? ( + positif / + total + ) * 100 + : 0; + + /* ========================================================== + SEARCH + FILTER + ========================================================== */ + + const filteredData = + useMemo(() => { + const keyword = + search + .trim() + .toLowerCase(); + + return hasil.filter( + (item) => { + const cocokSearch = + !keyword || + String( + item.teks || "" + ) + .toLowerCase() + .includes( + keyword + ); + + const cocokFilter = + filter === "Semua" || + item.sentimen === + filter; + + return ( + cocokSearch && + cocokFilter + ); + } + ); + }, [ + hasil, + search, + filter + ]); + + /* ========================================================== + PAGINATION + ========================================================== */ + + const totalPages = + Math.max( + 1, + Math.ceil( + filteredData.length / + ITEMS_PER_PAGE + ) + ); + + const safeCurrentPage = + Math.min( + currentPage, + totalPages + ); + + const startIndex = + ( + safeCurrentPage - 1 + ) * ITEMS_PER_PAGE; + + const paginatedData = + filteredData.slice( + startIndex, + startIndex + + ITEMS_PER_PAGE + ); + + /* + Setelah halaman berubah, + otomatis naik ke awal tabel. + */ + const scrollKeAtasDetail = () => { + window.requestAnimationFrame( + () => { + if ( + !hasilDetailRef.current + ) { + return; + } + + const posisi = + hasilDetailRef.current + .getBoundingClientRect() + .top + + window.scrollY - + 18; + + window.scrollTo({ + top: posisi, + behavior: "smooth" + }); + } + ); + }; + + const halamanSebelumnya = + () => { + setCurrentPage( + (page) => + Math.max( + 1, + page - 1 + ) + ); + + scrollKeAtasDetail(); + }; + + const halamanBerikutnya = + () => { + setCurrentPage( + (page) => + Math.min( + totalPages, + page + 1 + ) + ); + + scrollKeAtasDetail(); + }; + + /* ========================================================== + DOWNLOAD + ========================================================== */ + + const unduhHasil = + () => { + if (!dataAnalisis) { + return; + } + + const header = [ + "ID Batch", "File Sumber", "Waktu Analisis", "Versi Model", + "No", + "Ulasan", + "Cleaning", + "Case Folding", + "Normalisasi", + "Tokenizing", + "Stopword Removal", + "Stemming", + "Label", + "Sentimen" + ]; + + const rowsCSV = + hasil.map( + (item) => [ + dataAnalisis.batch_id, dataAnalisis.file_name, dataAnalisis.created_at, dataAnalisis.model_version || "Tidak tercatat", + item.source_row_number, + item.teks, + item.preprocessing + ?.cleaning, + item.preprocessing + ?.case_folding, + item.preprocessing + ?.normalization, + Array.isArray( + item.preprocessing + ?.tokenizing + ) + ? JSON.stringify(item.preprocessing.tokenizing) + : "", + item.preprocessing + ?.stopword_removal, + item.preprocessing + ?.stemming, + item.label, + item.sentimen + ] + ); + + const csvContent = [ + header + .map( + escapeCSV + ) + .join(","), + + ...rowsCSV.map( + (row) => + row + .map( + escapeCSV + ) + .join(",") + ) + ].join("\n"); + + const blob = + new Blob( + [ + "\uFEFF" + + csvContent + ], + { + type: + "text/csv;charset=utf-8;" + } + ); + + const url = + URL.createObjectURL( + blob + ); + + const link = + document.createElement( + "a" + ); + + link.href = + url; + + link.download = + `hasil_${dataAnalisis.batch_id}.csv`; + + document.body.appendChild( + link + ); + + link.click(); + + link.remove(); + + URL.revokeObjectURL( + url + ); + }; + + /* ========================================================== + LOADING + ========================================================== */ + + if (loading) { + return ( +
+
+

+ Mengambil Hasil Analisis +

+ +

+ Data analisis sedang dimuat. +

+
+
+ ); + } + + /* ========================================================== + ERROR + ========================================================== */ + + if ( + error || + !dataAnalisis + ) { + return ( +
+
+

+ Batch tidak ditemukan +

+ +

+ {error || + "Batch analisis tidak ditemukan."} +

+ +
+ + + +
+
+
+ ); + } + + return ( +
+ {/* ====================================================== + BACK + ====================================================== */} + + + + {/* ====================================================== + HEADING + ====================================================== */} + +
+
+

+ Hasil Analisis +

+ +

+ {dataAnalisis.file_name} + {" · "} + {formatWaktu( + dataAnalisis.created_at + )} +

+
+ +
+ + + +
+
+ + {/* ====================================================== + STATISTIK + ====================================================== */} + +
+
+ + Ulasan Dianalisis + + + + {total} + +
+ +
+ + Hasil prediksi positif + + + + {positif} + +
+ +
+ + Hasil prediksi negatif + + + + {negatif} + +
+
+ + {/* ====================================================== + DISTRIBUSI + ====================================================== */} + +
+
+

+ Distribusi Sentimen Hasil Prediksi +

+ +

+ Distribusi sentimen pada file CSV yang dianalisis. +

+
+ +
+
+ + Kolom Ulasan + + + + {dataAnalisis.selected_column} + +
+ +
+ + Batch + + + + {batchNumber + ? `Batch ${batchNumber}` + : "Batch -"} + +
+
+ +
+
0 + ? ( + `conic-gradient(` + + `#1f9d68 0% ${persentasePositif}%, ` + + `#db4e58 ${persentasePositif}% 100%)` + ) + : "#e9edf3" + }} + > +
+ + {total} + + + + Ulasan + +
+
+ +
+
+ + + + Positif: {dataAnalisis.total_positive} ({persentasePositif.toFixed(1)}%) + +
+ +
+ + + + Negatif: {dataAnalisis.total_negative} ({(100 - persentasePositif).toFixed(1)}%) + +
+
+
+
+ + {/* ====================================================== + DETAIL HASIL + ====================================================== */} + +
+
+
+

+ Detail Hasil Analisis +

+ +

+ Daftar ulasan dan hasil prediksi sentimen. +

+
+ +
+ + changeSearch( + event.target.value + ) + } + /> + + +
+
+ +
+ + + + + + + + + + + + {paginatedData.map( + (item) => ( + + + + + + + + + + ) + )} + + {paginatedData.length === 0 && ( + + + + )} + +
NoUlasanSentimenAksi
+ { + item.source_row_number + } + + {item.teks} + + + {item.sentimen} + + + +
+ Tidak ada ulasan yang sesuai. +
+
+ +
+ + Menampilkan{" "} + + {filteredData.length === 0 + ? 0 + : startIndex + 1} + + {" - "} + + {Math.min( + startIndex + + ITEMS_PER_PAGE, + filteredData.length + )} + + {" dari "} + + {filteredData.length} + + {" ulasan"} + + +
+ + + + {safeCurrentPage} + {" / "} + {totalPages} + + + +
+
+
+ + {/* ====================================================== + MODAL + ====================================================== */} + + {detail && ( +
+ setDetail(null) + } + > +
+ event.stopPropagation() + } + > +
+
+ + DETAIL ANALISIS + + +

+ Proses Analisis Ulasan +

+
+ + +
+ +
+ + Ulasan Asli + + +

+ {detail.teks} +

+
+ +
+ {[ + [ + "Cleaning", + detail.preprocessing + ?.cleaning + ], + [ + "Case Folding", + detail.preprocessing + ?.case_folding + ], + [ + "Normalisasi", + detail.preprocessing + ?.normalization + ], + [ + "Tokenizing", + Array.isArray( + detail.preprocessing + ?.tokenizing + ) + ? JSON.stringify( + detail + .preprocessing + .tokenizing + ) + : detail.preprocessing + ?.tokenizing + ], + [ + "Stopword Removal", + detail.preprocessing + ?.stopword_removal + ], + [ + "Stemming", + detail.preprocessing + ?.stemming + ] + ].map( + ( + [nama, proses], + index + ) => ( +
+ + {index + 1} + + +
+ + {nama} + + +

+ {proses || "-"} +

+
+
+ ) + )} +
+ +
+
+

+ Bobot TF-IDF Teratas +

+
+ + {detail.tfidf_top?.length > 0 ? ( + detail.tfidf_top.map( + (item, index) => { + const maxBobot = + Number( + detail + .tfidf_top[0] + ?.bobot || 1 + ); + + const width = + Math.min( + Number( + item.bobot + ) / + maxBobot, + 1 + ) * 100; + + return ( +
+ + {item.kata} + + +
+ +
+ + + {Number( + item.bobot + ) + .toFixed(6) + .replace( + ".", + "," + )} + +
+ ); + } + ) + ) : ( +

+ Tidak ada bobot TF-IDF yang tersedia. +

+ )} +
+ +
+ + Prediksi Sentimen + + + + {detail.sentimen} + +
+
+
+ )} +
+ ); +} + +export default HasilAnalisis; \ No newline at end of file diff --git a/web_slearn/src/pages/PenelitianModel.jsx b/web_slearn/src/pages/PenelitianModel.jsx new file mode 100644 index 0000000..2fa3134 --- /dev/null +++ b/web_slearn/src/pages/PenelitianModel.jsx @@ -0,0 +1,1824 @@ +import EvaluasiPembanding from "../components/EvaluasiPembanding"; +import { useEffect, useMemo, useRef, useState } from "react"; +import Papa from "papaparse"; +import { ambilModelPenelitian } from "../services/api"; +import "../styles/penelitian-model.css"; + +const tabs = [ + "Dataset & Pembagian Data", + "Preprocessing", + "Pembobotan TF-IDF", + "Model & Evaluasi", + "Pengujian Pembanding" +]; + +const ITEMS_PER_PAGE = 10; + + +function PenelitianModel() { + const [activeTab, setActiveTab] = useState("Dataset & Pembagian Data"); + + return ( +
+
+
+

Penelitian & Model Awal

+ +

+ Dokumentasi dataset, preprocessing, TF-IDF, dan evaluasi model awal (v1). + Model aktif dan hasil training baru tersedia di menu Training & Riwayat Model.{" "} + +

+
+
+ +
+ {tabs.map((tab) => ( + + ))} +
+ + {activeTab === "Dataset & Pembagian Data" && } + {activeTab === "Preprocessing" && } + {activeTab === "Pembobotan TF-IDF" && } + {activeTab === "Model & Evaluasi" && } + {activeTab === "Pengujian Pembanding" && } +
+ ); +} + +/* ============================================================ + DATASET + ============================================================ */ + +function DatasetTab() { + const [classDistribution, setClassDistribution] = useState({ + total: 571, + negative: 379, + positive: 192 + }); + + const [distributionLoading, setDistributionLoading] = useState(true); + const [distributionError, setDistributionError] = useState(""); + + useEffect(() => { + let aktif = true; + + Papa.parse("/data/hasil_preprocessing_slearn.csv", { + download: true, + header: true, + skipEmptyLines: true, + + transformHeader: (header) => + String(header).replace(/^\uFEFF/, "").trim(), + + complete: (results) => { + if (!aktif) return; + + const rows = (results.data || []).filter( + (row) => String(row.komentar || "").trim() !== "" + ); + + const negative = rows.filter( + (row) => String(row.label ?? "").trim() === "0" + ).length; + + const positive = rows.filter( + (row) => String(row.label ?? "").trim() === "1" + ).length; + + setClassDistribution({ + total: rows.length, + negative, + positive + }); + + setDistributionLoading(false); + }, + + error: (err) => { + if (!aktif) return; + + setDistributionError( + err?.message || "Distribusi kelas tidak dapat dimuat." + ); + + setDistributionLoading(false); + } + }); + + return () => { + aktif = false; + }; + }, []); + + const negativePercentage = + classDistribution.total > 0 + ? (classDistribution.negative / classDistribution.total) * 100 + : 0; + + const positivePercentage = + classDistribution.total > 0 + ? (classDistribution.positive / classDistribution.total) * 100 + : 0; + + const formatPercentage = (value) => + Number(value).toFixed(1).replace(".", ","); + + return ( +
+
+ + + + + +
+ +
+
+
+

Distribusi Kelas Dataset

+

Distribusi label pada 571 ulasan penelitian.

+
+ + {distributionLoading ? ( +
+ Memuat distribusi kelas... +
+ ) : ( + <> +
+
+
+
+ + +
+ Negatif + Label 0 +
+
+ +
+ {classDistribution.negative} + {formatPercentage(negativePercentage)}% +
+
+ +
+ +
+
+ +
+
+
+ + +
+ Positif + Label 1 +
+
+ +
+ {classDistribution.positive} + {formatPercentage(positivePercentage)}% +
+
+ +
+ +
+
+
+ +
+ 0 = Negatif + 1 = Positif +
+ + {distributionError && ( +

+ {distributionError} +

+ )} + + )} +
+ +
+
+

Pembagian Training dan Testing

+ +

+ Dataset dibagi menjadi 80% data training dan 20% data testing. +

+
+ +
+
+ 80% + 20% +
+ +

+ Data training digunakan untuk membentuk fitur TF-IDF dan melatih + model, sedangkan data testing digunakan untuk evaluasi model. +

+
+
+
+ +
+
+

Alur Pengolahan Data Penelitian

+
+ +
+ + + + + + + + + + + + + + + + + + + + + +
+
+
+ ); +} + +/* ============================================================ + PREPROCESSING + ============================================================ */ + +function formatTokenizedText(value) { + const text = String(value || "").trim(); + + if (!text) return "-"; + + try { + const parsed = JSON.parse(text.replace(/'/g, '"')); + + if (Array.isArray(parsed)) { + return parsed.join(" | "); + } + } catch { + // Format fallback. + } + + return text + .replace(/^\[/, "") + .replace(/\]$/, "") + .split(",") + .map((token) => + token + .trim() + .replace(/^["']|["']$/g, "") + ) + .filter(Boolean) + .join(" | "); +} + +function PreprocessingTab() { + const [data, setData] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [search, setSearch] = useState(""); + const [filterLabel, setFilterLabel] = useState("Semua"); + const [currentPage, setCurrentPage] = useState(1); + const [detail, setDetail] = useState(null); + + const preprocessingResultRef = useRef(null); + + useEffect(() => { + let aktif = true; + + Papa.parse("/data/hasil_preprocessing_slearn.csv", { + download: true, + header: true, + skipEmptyLines: true, + + transformHeader: (header) => + String(header).replace(/^\uFEFF/, "").trim(), + + complete: (results) => { + if (!aktif) return; + + const rows = (results.data || []) + .filter( + (row) => String(row.komentar || "").trim() !== "" + ) + .map((row, index) => ({ + id: index + 1, + komentar: String(row.komentar || ""), + label: String(row.label ?? "").trim(), + clean_text: String(row.clean_text || ""), + case_folded_text: String(row.case_folded_text || ""), + normalized_text: String(row.normalized_text || ""), + tokenized_text: String(row.tokenized_text || ""), + stopword_text: String(row.stopword_text || ""), + stemmed_text: String(row.stemmed_text || "") + })); + + setData(rows); + setLoading(false); + }, + + error: (err) => { + if (!aktif) return; + + setError( + err?.message || "Hasil preprocessing tidak dapat dibaca." + ); + + setLoading(false); + } + }); + + return () => { + aktif = false; + }; + }, []); + + useEffect(() => { + setCurrentPage(1); + }, [search, filterLabel]); + + useEscapeModal(detail, () => setDetail(null)); + + const filteredData = useMemo(() => { + const keyword = search.trim().toLowerCase(); + + return data.filter((item) => { + const cocokSearch = + !keyword || + item.komentar.toLowerCase().includes(keyword) || + item.clean_text.toLowerCase().includes(keyword) || + item.case_folded_text.toLowerCase().includes(keyword) || + item.normalized_text.toLowerCase().includes(keyword) || + item.tokenized_text.toLowerCase().includes(keyword) || + item.stopword_text.toLowerCase().includes(keyword) || + item.stemmed_text.toLowerCase().includes(keyword) || + String(item.id).includes(keyword); + + const cocokLabel = + filterLabel === "Semua" || + (filterLabel === "Positif" && item.label === "1") || + (filterLabel === "Negatif" && item.label === "0"); + + return cocokSearch && cocokLabel; + }); + }, [data, search, filterLabel]); + + const totalPages = Math.max( + 1, + Math.ceil(filteredData.length / ITEMS_PER_PAGE) + ); + + const safePage = Math.min(currentPage, totalPages); + const startIndex = (safePage - 1) * ITEMS_PER_PAGE; + + const paginatedData = filteredData.slice( + startIndex, + startIndex + ITEMS_PER_PAGE + ); + + const scrollKeAtasPreprocessing = () => { + window.requestAnimationFrame(() => { + if (!preprocessingResultRef.current) return; + + const posisi = + preprocessingResultRef.current.getBoundingClientRect().top + + window.scrollY - + 18; + + window.scrollTo({ + top: posisi, + behavior: "smooth" + }); + }); + }; + + const halamanSebelumnyaPreprocessing = () => { + setCurrentPage((page) => Math.max(1, page - 1)); + scrollKeAtasPreprocessing(); + }; + + const halamanBerikutnyaPreprocessing = () => { + setCurrentPage((page) => Math.min(totalPages, page + 1)); + scrollKeAtasPreprocessing(); + }; + + const kataPenting = [ + "tidak", + "kurang", + "belum", + "bukan", + "bukannya", + "jangan", + "tanpa", + "tidaklah", + "malah", + "bisa", + "sangat", + "sering", + "sudah", + "masih", + "terlalu", + "baik", + "lebih", + "lama", + "jelas", + "masalah", + "selalu", + "siap", + "dapat", + "guna", + "benar", + "cukup", + "sekali", + "padahal", + "perlu", + "banyak", + "dan", + "terlihat", + "waktu", + "tambah", + "sebelum", + "sebelumnya", + "dekat", + "tiap", + "ada", + "kecil", + "dulu" + ]; + + return ( +
+
+
+
+

Contoh Kamus Normalisasi

+ +

+ Contoh perubahan kata yang digunakan pada proses normalisasi + data penelitian. +

+
+ +
+ + + +
+
+ +
+
+

Kata Penting yang Dipertahankan

+ +

+ Stopword final dibentuk dari stopword Bahasa Indonesia NLTK dan + partikel tambahan. Kata-kata berikut dikeluarkan dari daftar + stopword sehingga tetap dipertahankan selama preprocessing. +

+
+ +
+ {kataPenting.map((kata) => ( + {kata} + ))} +
+
+
+ +
+
+

Preprocessing 571 Ulasan

+ +

+ Hasil preprocessing ditampilkan sesuai enam tahapan penelitian: + Cleaning, Case Folding, Normalisasi, Tokenizing, Stopword Removal, + dan Stemming. +

+
+ +
+ setSearch(event.target.value)} + /> + + +
+ + {loading && ( +
+ Memuat hasil preprocessing penelitian... +
+ )} + + {!loading && error && ( +
+ {error} +
+ )} + + {!loading && !error && ( + <> +
+ {paginatedData.map((item) => ( +
+
+
+ Data ke-{item.id} + +
+ +

{item.komentar}

+ +
+
+ Hasil Akhir Stemming + {item.stemmed_text} +
+ + +
+
+
+ ))} + + {paginatedData.length === 0 && ( +
+ Tidak ada data yang sesuai. +
+ )} +
+ + + + )} +
+ + {detail && ( +
+ event.target === event.currentTarget && + setDetail(null) + } + > +
+
+
+
+

Data ke-{detail.id}

+
+ + +
+ +
+
+ Ulasan Asli + +
+ +

{detail.komentar}

+
+ +
+ + + + + + + + + + + +
+ +
+ Sentimen Penelitian + + + {detail.label === "1" ? "Positif" : "Negatif"} + +
+
+
+
+ )} +
+ ); +} + +/* ============================================================ + TF-IDF + ============================================================ */ + +function TFIDFTab() { + const [summary, setSummary] = useState(null); + const [data, setData] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [search, setSearch] = useState(""); + const [filterJenis, setFilterJenis] = useState("Semua"); + const [filterLabel, setFilterLabel] = useState("Semua"); + const [currentPage, setCurrentPage] = useState(1); + const [detail, setDetail] = useState(null); + + const tfidfResultRef = useRef(null); + + useEffect(() => { + const controller = new AbortController(); + + async function loadTFIDF() { + try { + setLoading(true); + setError(""); + + const response = await fetch("/data/tfidf_research.json", { + signal: controller.signal + }); + + if (!response.ok) { + throw new Error( + "Data pembobotan TF-IDF tidak dapat dibaca." + ); + } + + const json = await response.json(); + + if (!json || !Array.isArray(json.data)) { + throw new Error("Format data TF-IDF tidak sesuai."); + } + + setSummary(json.summary || null); + setData(json.data); + } catch (err) { + if (err.name !== "AbortError") { + setError( + err.message || "Data TF-IDF tidak dapat dimuat." + ); + } + } finally { + if (!controller.signal.aborted) { + setLoading(false); + } + } + } + + loadTFIDF(); + + return () => controller.abort(); + }, []); + + useEffect(() => { + setCurrentPage(1); + }, [search, filterJenis, filterLabel]); + + useEscapeModal(detail, () => setDetail(null)); + + const filteredData = useMemo(() => { + const keyword = search.trim().toLowerCase(); + + return data.filter((item) => { + const bobotKata = (item.bobot_tfidf || []) + .map((x) => x.kata) + .join(" ") + .toLowerCase(); + + const cocokSearch = + !keyword || + String(item.no_data).includes(keyword) || + String(item.ulasan_asli || "") + .toLowerCase() + .includes(keyword) || + String(item.stemmed_text || "") + .toLowerCase() + .includes(keyword) || + bobotKata.includes(keyword); + + const cocokJenis = + filterJenis === "Semua" || + item.jenis_data === filterJenis; + + const cocokLabel = + filterLabel === "Semua" || + (filterLabel === "Positif" && + Number(item.label) === 1) || + (filterLabel === "Negatif" && + Number(item.label) === 0); + + return cocokSearch && cocokJenis && cocokLabel; + }); + }, [data, search, filterJenis, filterLabel]); + + const totalPages = Math.max( + 1, + Math.ceil(filteredData.length / ITEMS_PER_PAGE) + ); + + const safePage = Math.min(currentPage, totalPages); + const startIndex = (safePage - 1) * ITEMS_PER_PAGE; + + const paginatedData = filteredData.slice( + startIndex, + startIndex + ITEMS_PER_PAGE + ); + + const scrollKeAtasHasilTFIDF = () => { + window.requestAnimationFrame(() => { + if (!tfidfResultRef.current) return; + + const posisi = + tfidfResultRef.current.getBoundingClientRect().top + + window.scrollY - + 18; + + window.scrollTo({ + top: posisi, + behavior: "smooth" + }); + }); + }; + + const halamanSebelumnyaTFIDF = () => { + setCurrentPage((page) => Math.max(1, page - 1)); + scrollKeAtasHasilTFIDF(); + }; + + const halamanBerikutnyaTFIDF = () => { + setCurrentPage((page) => Math.min(totalPages, page + 1)); + scrollKeAtasHasilTFIDF(); + }; + + return ( +
+
+ + + + + +
+ +
+
+

Hasil Pembobotan TF-IDF 571 Ulasan

+ +

+ Bobot TF-IDF dihitung menggunakan fitur yang dibentuk dari + data training. Data testing ditransformasikan menggunakan + vectorizer yang sama tanpa melakukan fit ulang. +

+
+ +
+ setSearch(event.target.value)} + /> + + + + +
+ + {loading && ( +
+ Memuat hasil pembobotan TF-IDF penelitian... +
+ )} + + {!loading && error && ( +
+ {error} +
+ )} + + {!loading && !error && ( + <> +
+ {paginatedData.map((item) => ( + setDetail(item)} + /> + ))} + + {paginatedData.length === 0 && ( +
+ Tidak ada data TF-IDF yang sesuai dengan pencarian atau + filter. +
+ )} +
+ + + + )} +
+ +
+
+

Cara Pembentukan Fitur TF-IDF

+ +

+ TF-IDF dibentuk menggunakan 456 data training dan menghasilkan + 675 fitur. Bobot akhir merupakan hasil TF × IDF, sedangkan data + testing ditransformasikan menggunakan vectorizer dari data training. +

+
+ +
+
+ Data Training + 456 Ulasan +
+ +
→
+ +
+ Pembobotan + TF × IDF +
+ +
→
+ +
+ Hasil + 675 Fitur +
+
+
+ + {detail && ( + setDetail(null)} + /> + )} +
+ ); +} + +function TFIDFResearchCard({ item, onDetail }) { + const topWeights = (item.bobot_tfidf || []).slice(0, 5); + + const maxWeight = + topWeights.reduce( + (maksimum, weight) => + Math.max( + maksimum, + Number(weight.bobot) || 0 + ), + 0 + ) || 1; + + return ( +
+
+ + Data ke-{item.no_data} + + +
+ + {item.jenis_data} + + + +
+
+ +
+ Hasil Stemming + {item.stemmed_text} +
+ +
+
+ Bobot TF-IDF Tertinggi + + + {item.jumlah_fitur_nonzero} fitur bernilai > 0 + +
+ + {topWeights.length > 0 ? ( + topWeights.map((weight) => { + const nilaiBobot = Number(weight.bobot) || 0; + + const width = Math.min( + 100, + Math.max( + 0, + (nilaiBobot / maxWeight) * 100 + ) + ); + + return ( +
+ {weight.kata} + +
+ +
+ + + {formatDecimal(weight.bobot)} + +
+ ); + }) + ) : ( +
+ Tidak ada fitur yang dikenali vectorizer. +
+ )} +
+ +
+ +
+
+ ); +} + +function TFIDFDetailModal({ item, onClose }) { + return ( +
+ event.target === event.currentTarget && + onClose() + } + > +
+
+
+
+

Data ke-{item.no_data}

+
+ + +
+ +
+
+ Ulasan Asli +
+ +

{item.ulasan_asli}

+
+ +
+
+ Jenis Data + {item.jenis_data} +
+ +
+ Posisi pada Split + + {item.jenis_data} ke-{item.urutan_split} + +
+ +
+ Fitur Bobot > 0 + {item.jumlah_fitur_nonzero} +
+ +
+ Sentimen + + + {Number(item.label) === 1 + ? "Positif" + : "Negatif"} + +
+
+ +
+ Hasil Stemming + {item.stemmed_text} +
+ +
+

+ Seluruh Fitur dengan Bobot TF-IDF > 0 +

+
+ +
+ + + + + + + + + + + + + + {(item.bobot_tfidf || []).map( + (row, index) => ( + + + + + + + + + + + + + ) + )} + + {(item.bobot_tfidf || []).length === 0 && ( + + + + )} + +
No.KataTFDFIDFBobot TF-IDF
{index + 1} + {row.kata} + {row.tf}{row.df} + {formatDecimal(row.idf)} + + {formatDecimal(row.bobot)} +
+ Tidak ada fitur dengan bobot TF-IDF > 0. +
+
+ +
+ i + +
+ Cara Membaca Bobot + +

+ TF menunjukkan frekuensi term pada satu dokumen, DF menunjukkan + jumlah dokumen training yang mengandung term, sedangkan IDF + menunjukkan bobot berdasarkan kemunculan term pada dokumen + training. Bobot TF-IDF pada tabel merupakan hasil TF × IDF. +

+
+
+
+
+
+ ); +} + +/* ============================================================ + MODEL & EVALUASI + ============================================================ */ + +function ModelTab() { + const [modelInfo, setModelInfo] = useState(null); + const [error, setError] = useState(""); + + useEffect(() => { + let active = true; + async function loadModel() { + try { + const response = await ambilModelPenelitian(); + const data = response.data; + const report = data?.classification_report; + const cm = data?.confusion_matrix; + const validRate = value => typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 1; + if (!data || !validRate(data.akurasi) || + !Number.isInteger(data.jumlah_data_testing) || data.jumlah_data_testing <= 0 || + !Array.isArray(cm) || cm.length !== 2 || + !cm.every(row => Array.isArray(row) && row.length === 2 && row.every(value => Number.isInteger(value) && value >= 0)) || + cm.flat().reduce((sum, value) => sum + value, 0) !== data.jumlah_data_testing || + !["0", "1", "weighted avg"].every(key => + report?.[key] && ["precision", "recall", "f1-score"].every(metric => validRate(report[key][metric])) && + Number.isFinite(report[key].support))) { + throw new Error("Metadata evaluasi dari backend tidak lengkap atau tidak valid."); + } + if (active) setModelInfo(data); + } catch (err) { + if (active) setError(err.message || "Gagal memuat evaluasi model."); + } + } + loadModel(); + return () => { active = false; }; + }, []); + + if (error) return ( +
+
+

Evaluasi model 80:20 belum dapat ditampilkan

+

{error}

+

Pastikan backend berjalan. Buka tab lain lalu kembali ke Model & Evaluasi untuk mencoba lagi.

+
+ +
+ ); + if (!modelInfo) return
Memuat evaluasi model dari backend...
; + + const report = modelInfo.classification_report; + const weighted = report["weighted avg"]; + const [[tn, fp], [fn, tp]] = modelInfo.confusion_matrix; + const percent = (value, digits = 2) => (value * 100).toLocaleString("id-ID", { + minimumFractionDigits: digits, maximumFractionDigits: digits + }) + "%"; + const evaluationMetrics = [ + { name: "Accuracy", value: percent(modelInfo.akurasi), description: "Ketepatan prediksi pada " + modelInfo.jumlah_data_testing + " data testing" }, + { name: "Precision", value: percent(weighted.precision), description: "Weighted average seluruh kelas" }, + { name: "Recall", value: percent(weighted.recall), description: "Weighted average seluruh kelas" }, + { name: "F1-Score", value: percent(weighted["f1-score"]), description: "Weighted average seluruh kelas" } + ]; + return ( +
+
+
+

Evaluasi Model Awal — v1

+ +

+ Evaluasi dilakukan terhadap {modelInfo.jumlah_data_testing} data testing. Nilai Precision, + Recall, dan F1-Score berikut menggunakan weighted average. +

+
+ +
+ {evaluationMetrics.map((metric) => ( + + ))} +
+
+ +
+
+
+

Confusion Matrix

+ +

+ Perbandingan antara label aktual dengan hasil prediksi model + pada data testing. +

+
+ +
+
+ Prediksi Model +
+ +
+
+ +
+ Negatif +
+ +
+ Positif +
+ +
+ Negatif Aktual +
+ + + + + +
+ Positif Aktual +
+ + + + +
+ +
+ + + Prediksi benar + + + + + Prediksi salah + +
+
+
+ +
+
+

Evaluasi per Kelas

+ +

+ Precision, Recall, dan F1-Score masing-masing kelas pada + {modelInfo.jumlah_data_testing} data testing. +

+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KelasPrecisionRecallF1-ScoreJumlah
+ + Negatif + + {percent(report["0"].precision, 1)}{percent(report["0"].recall, 1)}{percent(report["0"]["f1-score"], 1)}{report["0"].support}
+ + Positif + + {percent(report["1"].precision, 1)}{percent(report["1"].recall, 1)}{percent(report["1"]["f1-score"], 1)}{report["1"].support}
+
+ +
+ i + +

+ Recall kelas positif sebesar {percent(report["1"].recall, 1)} menunjukkan bahwa {tp} dari{" "} + {report["1"].support} ulasan positif pada data testing berhasil diprediksi sebagai + positif, sedangkan {fn} lainnya diprediksi sebagai negatif. +

+
+
+
+ +
+ ); +} + +/* ============================================================ + PERBANDINGAN TIGA RASIO PENELITIAN + Sumber: public/data/perbandingan_rasio_slearn.json + ============================================================ */ + +function PerbandinganRasio() { + const [skenario, setSkenario] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + + useEffect(() => { + const controller = new AbortController(); + async function loadPerbandingan() { + try { + const response = await fetch("/data/perbandingan_rasio_slearn.json", { signal: controller.signal }); + if (!response.ok) throw new Error("File perbandingan_rasio_slearn.json tidak ditemukan."); + const json = await response.json(); + const daftar = json?.skenario; + const rasioWajib = ["70:30", "80:20", "90:10"]; + const valid = Array.isArray(daftar) && daftar.length === 3 && + rasioWajib.every(rasio => daftar.some(item => item.rasio === rasio)) && + daftar.every(item => + Number.isInteger(item.data_latih) && Number.isInteger(item.data_uji) && + Number.isInteger(item.jumlah_fitur) && item.data_latih + item.data_uji === 571 && + item.jumlah_fitur > 0 && + ["accuracy", "precision", "recall", "f1_score"].every(k => + typeof item[k] === "number" && Number.isFinite(item[k]) && item[k] >= 0 && item[k] <= 100) && + Array.isArray(item.confusion_matrix) && item.confusion_matrix.length === 2 && + item.confusion_matrix.every(row => Array.isArray(row) && row.length === 2 && + row.every(n => Number.isInteger(n) && n >= 0)) && + item.confusion_matrix.flat().reduce((total, n) => total + n, 0) === item.data_uji + ); + if (!valid) throw new Error("Struktur atau nilai data perbandingan tiga rasio tidak sesuai."); + if (!controller.signal.aborted) { + setSkenario(rasioWajib.map(rasio => daftar.find(item => item.rasio === rasio))); + setError(""); + } + } catch (err) { + if (err.name !== "AbortError") setError(err.message || "Gagal membaca perbandingan rasio."); + } finally { + if (!controller.signal.aborted) setLoading(false); + } + } + loadPerbandingan(); + return () => controller.abort(); + }, []); + + const persen = angka => Number(angka).toLocaleString("id-ID", { + minimumFractionDigits: 2, maximumFractionDigits: 2 + }) + "%"; + + return ( +
+
+

Perbandingan Tiga Rasio Data Training dan Testing

+

Hasil eksperimen 70:30, 80:20, dan 90:10 menggunakan Logistic Regression dan TF-IDF.

+
+ + {loading &&

Memuat hasil perbandingan rasio...

} + {!loading && error &&

{error}

} + + {!loading && !error && ( + <> +
+ + + + + + + + + {skenario.map(item => ( + + + + + + + ))} + +
RasioData LatihData UjiFitur TF-IDFAccuracyPrecisionRecallF1-Score
{item.rasio}{item.rasio === "80:20" &&
Model utama
}
{item.data_latih}{item.data_uji}{item.jumlah_fitur}{persen(item.accuracy)}{persen(item.precision)}{persen(item.recall)}{persen(item.f1_score)}
+
+ +
+

Grafik Perbandingan Akurasi

+
+
+
+ {[100, 75, 50, 25, 0].map(nilai => {nilai}%)} +
+
+
+ {[0, 25, 50, 75, 100].map(nilai => ( +
+ ))} +
+ {skenario.map(item => ( +
+ {persen(item.accuracy)} +
+ ))} +
+
+
+ {skenario.map(item => ( + {item.rasio} + ))} +
+
+
+
+
+ +

Confusion Matrix per Rasio

+
+ {skenario.map(item => { + const [[tn, fp], [fn, tp]] = item.confusion_matrix; + return ( +
+ Rasio {item.rasio} + + + + + + +
Aktual / PrediksiNegatifPositif
Negatif{tn} (TN){fp} (FP)
Positif{fn} (FN){tp} (TP)
+
+ ); + })} +
+ +
+ i +
+ Catatan Evaluasi +

Precision, Recall, dan F1-Score menggunakan weighted average. Setiap rasio memiliki jumlah data uji berbeda sehingga hasilnya tidak langsung menunjukkan satu rasio selalu lebih baik. Model utama untuk prediksi website tetap rasio 80:20 dengan akurasi 93,04%; eksperimen rasio lain hanya ditampilkan sebagai pembanding.

+
+
+ + )} +
+ ); +} + +/* ============================================================ + COMPONENT + ============================================================ */ + +function useEscapeModal(value, close) { + useEffect(() => { + if (!value) return; + + const handleEscape = (event) => { + if (event.key === "Escape") { + close(); + } + }; + + document.addEventListener("keydown", handleEscape); + + return () => + document.removeEventListener("keydown", handleEscape); + }, [value, close]); +} + +function formatDecimal(value) { + const number = Number(value); + + if (!Number.isFinite(number)) { + return "-"; + } + + return number + .toFixed(6) + .replace(".", ","); +} + +function StatCard({ title, value, info }) { + return ( +
+ {title} + {value} + {info} +
+ ); +} + +function FlowBox({ title, subtitle }) { + return ( +
+ {title} + {subtitle} +
+ ); +} + +function FlowArrow() { + return ( +
+ → +
+ ); +} + +function MetricCard({ + name, + value, + description +}) { + return ( +
+ {name} + {value} + {description} +
+ ); +} + +function SentimentBadge({ label }) { + const positive = Number(label) === 1; + + return ( + + {positive ? "Positif" : "Negatif"} + + ); +} + +function NormalizationRow({ from, to }) { + return ( +
+ {from} + + + → + + + {to} +
+ ); +} + +function ResearchProcessStep({ + number, + title, + value +}) { + return ( +
+
+ {number} +
+ +
+ {title} +

{value || "-"}

+
+
+ ); +} + +function ResearchPagination({ + total, + startIndex, + currentPage, + totalPages, + onPrev, + onNext +}) { + return ( +
+ + Menampilkan{" "} + {total === 0 ? 0 : startIndex + 1} + {" - "} + {Math.min( + startIndex + ITEMS_PER_PAGE, + total + )}{" "} + dari {total} data + + +
+ + + + {currentPage} / {totalPages} + + + +
+
+ ); +} + +function ConfusionValue({ + value, + label, + tone +}) { + return ( +
+ {value} + {label} +
+ ); +} + +export default PenelitianModel; diff --git a/web_slearn/src/pages/PrediksiUlasan.jsx b/web_slearn/src/pages/PrediksiUlasan.jsx new file mode 100644 index 0000000..f7c83a2 --- /dev/null +++ b/web_slearn/src/pages/PrediksiUlasan.jsx @@ -0,0 +1,567 @@ +import { ModelVersion, TfidfNote } from "../components/ModelContext"; +import { useState } from "react"; +import { prediksiUlasan } from "../services/api"; +import "../styles/prediksi-ulasan.css"; + + +function PrediksiUlasan() { + const [teks, setTeks] = useState(""); + const [hasil, setHasil] = useState(null); + const [loading, setLoading] = useState(false); + const [detailTerbuka, setDetailTerbuka] = useState(false); + const [error, setError] = useState(""); + + + /* ============================================================ + INPUT ULASAN + ============================================================ */ + + const handleChange = (event) => { + setTeks(event.target.value); + setError(""); + + if (hasil) { + setHasil(null); + setDetailTerbuka(false); + } + }; + + + /* ============================================================ + MULAI ANALISIS + ============================================================ */ + + const mulaiAnalisis = async () => { + const input = teks.trim(); + + if (!input) { + setError( + "Masukkan ulasan terlebih dahulu." + ); + + setHasil(null); + + return; + } + + setLoading(true); + setError(""); + setHasil(null); + + try { + const response = + await prediksiUlasan(input); + + setHasil( + response.data + ); + } catch (err) { + setError( + err.message || + "Ulasan tidak dapat dianalisis." + ); + } finally { + setLoading(false); + } + }; + + + /* ============================================================ + RESET + ============================================================ */ + + const resetPrediksi = () => { + setTeks(""); + setHasil(null); + setError(""); + setDetailTerbuka(false); + }; + + + /* ============================================================ + DETAIL PREPROCESSING + ============================================================ */ + + const prosesPreprocessing = + hasil + ? [ + [ + "Cleaning", + hasil.preprocessing.cleaning + ], + [ + "Case Folding", + hasil.preprocessing.case_folding + ], + [ + "Normalisasi", + hasil.preprocessing.normalization + ], + [ + "Tokenizing", + Array.isArray( + hasil.preprocessing.tokenizing + ) + ? JSON.stringify( + hasil.preprocessing.tokenizing + ) + : hasil.preprocessing.tokenizing + ], + [ + "Stopword Removal", + hasil.preprocessing.stopword_removal + ], + [ + "Stemming", + hasil.preprocessing.stemming + ] + ] + : []; + + + const hasilPositif = + hasil?.sentimen === "Positif"; + + + /* ============================================================ + TAMPILAN + ============================================================ */ + + return ( +
+ + {/* ======================================================== + HEADING + ======================================================== */} + +
+ +

+ Prediksi Satu Ulasan +

+ +

+ Masukkan satu ulasan S-Learn untuk memperoleh hasil prediksi sentimen. +

+ +
+ + + {/* ======================================================== + INPUT & HASIL + ======================================================== */} + +
+ + {/* ====================================================== + INPUT + ====================================================== */} + +
+ +
+

+ Masukkan Ulasan +

+
+ + +