Add S-Learn frontend backend and model
This commit is contained in:
parent
3dcf524104
commit
7de0f819e2
|
|
@ -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/
|
||||
|
|
@ -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.
|
||||
Binary file not shown.
|
|
@ -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": "<class 'numpy.float64'>",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
|
|
@ -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])
|
||||
|
|
@ -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
|
||||
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
|
|
@ -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."
|
||||
)
|
||||
|
|
@ -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)}"
|
||||
)
|
||||
)
|
||||
|
|
@ -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()
|
||||
|
|
@ -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))
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
|
|
@ -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
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
|
|
@ -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
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
|
|
@ -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
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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))
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
|
@ -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?
|
||||
|
|
@ -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.
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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 } },
|
||||
},
|
||||
},
|
||||
])
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>web_slearn</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
|
|
@ -0,0 +1,24 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<Routes>
|
||||
<Route element={<MainLayout />}>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/analisis-csv" element={<AnalisisCSV />} />
|
||||
<Route path="/hasil-analisis/:batchId" element={<HasilAnalisis />} />
|
||||
<Route path="/prediksi" element={<PrediksiUlasan />} />
|
||||
<Route path="/riwayat" element={<RiwayatAnalisis />} />
|
||||
<Route path="/penelitian-model" element={<PenelitianModel />} />
|
||||
<Route path="/riwayat-model" element={<RiwayatModel />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
|
|
@ -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 <div className="comparison-eval">
|
||||
<section className="research-card">
|
||||
<div className="comparison-heading"><div><span className="comparison-eyebrow">PENGUJIAN TAMBAHAN · MODEL v1</span><h2>Pengujian Pembanding</h2></div><span className="comparison-tag">115 ulasan baru</span></div>
|
||||
<p>Hasil tersimpan dari fitur Prediksi Satu Ulasan. Model awal tetap dilatih dengan 456 ulasan; pengujian ini tidak melakukan training ulang.</p>
|
||||
<p className="comparison-note">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.</p>
|
||||
<div className="comparison-stats">
|
||||
<div><span>Akurasi awal</span><strong>{pct(initial.accuracy)}</strong><small>115 data uji penelitian</small></div>
|
||||
<div><span>Akurasi pembanding</span><strong>{pct(result.accuracy)}</strong><small>115 ulasan sintetis baru</small></div>
|
||||
<div><span>Sesuai label acuan</span><strong>{result.correct} / {result.n}</strong><small>{result.n-result.correct} prediksi berbeda</small></div>
|
||||
</div>
|
||||
</section>
|
||||
<section className="research-card">
|
||||
<div className="comparison-heading"><div><h2>Confusion Matrix</h2><p>Baris menunjukkan label acuan; kolom menunjukkan hasil prediksi.</p></div><button type="button" className="comparison-button" disabled={busy} onClick={savePNG}>{busy?'Menyiapkan gambar…':'Unduh gambar PNG'}</button></div>
|
||||
{error && <p role="alert">{error}</p>}
|
||||
<svg ref={svgRef} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 900 560" width="900" height="560" className="comparison-matrix" role="img" aria-labelledby="comparison-matrix-title comparison-matrix-desc">
|
||||
<title id="comparison-matrix-title">Confusion Matrix Pengujian Pembanding</title>
|
||||
<desc id="comparison-matrix-desc">Model v1. TN 73, FP 3, FN 10, TP 29. Akurasi 88,70 persen pada 115 ulasan sintetis.</desc>
|
||||
<rect width="900" height="640" fill="#ffffff"/>
|
||||
<g fontFamily="Arial, sans-serif" fill="#172b4d">
|
||||
<text x="450" y="45" textAnchor="middle" fontSize="26" fontWeight="700">Confusion Matrix Pengujian Pembanding</text>
|
||||
<text x="450" y="78" textAnchor="middle" fontSize="18">Model v1 · 115 ulasan baru · Akurasi {pct(result.accuracy)}</text>
|
||||
<text x="500" y="120" textAnchor="middle" fontSize="20" fontWeight="700">Hasil prediksi</text>
|
||||
<text x="380" y="153" textAnchor="middle" fontSize="18">Negatif (0)</text>
|
||||
<text x="620" y="153" textAnchor="middle" fontSize="18">Positif (1)</text>
|
||||
<text x="60" y="350" transform="rotate(-90 60 350)" textAnchor="middle" fontSize="20" fontWeight="700">Label acuan</text>
|
||||
<text x="235" y="267" textAnchor="end" fontSize="18">Negatif (0)</text>
|
||||
<text x="235" y="447" textAnchor="end" fontSize="18">Positif (1)</text>
|
||||
{cells.map(c=><g key={c.label}><rect x={c.x} y={c.y} width="240" height="180" fill={c.correct?'#2454a6':'#edf2fa'} stroke="#ffffff" strokeWidth="3"/><text x={c.x+120} y={c.y+87} textAnchor="middle" fontSize="52" fontWeight="700" fill={c.correct?'#ffffff':'#172b4d'}>{c.value}</text><text x={c.x+120} y={c.y+127} textAnchor="middle" fontSize="21" fill={c.correct?'#ffffff':'#172b4d'}>{c.label}</text></g>)}
|
||||
</g>
|
||||
</svg>
|
||||
</section>
|
||||
<section className="research-card"><h2>Metrik per kelas</h2><div className="comparison-scroll"><table><thead><tr><th>Kelas</th><th>Precision</th><th>Recall</th><th>F1-score</th><th>Jumlah</th></tr></thead><tbody>{result.classes.map((c,k)=><tr key={k}><th>{k===0?'Negatif':'Positif'}</th><td>{pct(c.precision)}</td><td>{pct(c.recall)}</td><td>{pct(c.f1)}</td><td>{c.support}</td></tr>)}</tbody></table></div><p>Akurasi = (TN + TP) / seluruh ulasan = (73 + 29) / 115 = {pct(result.accuracy)}.</p></section>
|
||||
<section className="research-card"><h2>Perbandingan dengan pengujian awal</h2><div className="comparison-scroll"><table><thead><tr><th>Komponen</th><th>Awal penelitian</th><th>Pengujian pembanding</th></tr></thead><tbody><tr><th>Model</th><td>v1</td><td>v1 yang sama</td></tr><tr><th>Jumlah data uji</th><td>115</td><td>115 ulasan baru</td></tr><tr><th>TN / FP / FN / TP</th><td>70 / 6 / 2 / 37</td><td>73 / 3 / 10 / 29</td></tr><tr><th>Akurasi</th><td>{pct(initial.accuracy)}</td><td>{pct(result.accuracy)}</td></tr></tbody></table></div><p>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.</p></section>
|
||||
<section className="research-card"><div className="comparison-heading"><div><h2>Hasil 115 ulasan</h2><p>Arsip pengujian model v1. Prediksi baru di web tidak otomatis mengubah evaluasi ini.</p></div><button type="button" className="comparison-button" onClick={saveCSV}>Unduh hasil CSV</button></div><details><summary>Lihat 13 prediksi yang berbeda</summary><div className="comparison-scroll"><table><thead><tr><th>ID</th><th>Ulasan</th><th>Acuan</th><th>Prediksi</th></tr></thead><tbody>{data.rows.filter(r=>r.label_acuan!==r.label_prediksi).map(r=><tr key={r.id}><td>{r.id}</td><td>{r.ulasan}</td><td>{r.label_acuan===0?'Negatif':'Positif'}</td><td>{r.label_prediksi===0?'Negatif':'Positif'}</td></tr>)}</tbody></table></div></details></section>
|
||||
</div>;
|
||||
}
|
||||
|
|
@ -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 <div className="comparison-eval">
|
||||
<section className="research-card">
|
||||
<div className="comparison-heading"><div><span className="comparison-eyebrow">PENGUJIAN TAMBAHAN · MODEL v1</span><h2>Pengujian Pembanding</h2></div><span className="comparison-tag">115 ulasan baru</span></div>
|
||||
<p>Hasil tersimpan dari fitur Prediksi Satu Ulasan. Model awal tetap dilatih dengan 456 ulasan; pengujian ini tidak melakukan training ulang.</p>
|
||||
<p className="comparison-note">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.</p>
|
||||
<div className="comparison-stats">
|
||||
<div><span>Akurasi awal</span><strong>{pct(initial.accuracy)}</strong><small>115 data uji penelitian</small></div>
|
||||
<div><span>Akurasi pembanding</span><strong>{pct(result.accuracy)}</strong><small>115 ulasan sintetis baru</small></div>
|
||||
<div><span>Sesuai label acuan</span><strong>{result.correct} / {result.n}</strong><small>{result.n-result.correct} prediksi berbeda</small></div>
|
||||
</div>
|
||||
</section>
|
||||
<section className="research-card">
|
||||
<div className="comparison-heading"><div><h2>Confusion Matrix</h2><p>Baris menunjukkan label acuan; kolom menunjukkan hasil prediksi.</p></div><button type="button" className="comparison-button" disabled={busy} onClick={savePNG}>{busy?'Menyiapkan gambar…':'Unduh gambar PNG'}</button></div>
|
||||
{error && <p role="alert">{error}</p>}
|
||||
<svg ref={svgRef} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 900 640" width="900" height="640" className="comparison-matrix" role="img" aria-labelledby="comparison-matrix-title comparison-matrix-desc">
|
||||
<title id="comparison-matrix-title">Confusion Matrix Pengujian Pembanding</title>
|
||||
<desc id="comparison-matrix-desc">Model v1. TN 73, FP 3, FN 10, TP 29. Akurasi 88,70 persen pada 115 ulasan sintetis.</desc>
|
||||
<rect width="900" height="640" fill="#ffffff"/>
|
||||
<g fontFamily="Arial, sans-serif" fill="#172b4d">
|
||||
<text x="450" y="45" textAnchor="middle" fontSize="26" fontWeight="700">Confusion Matrix Pengujian Pembanding</text>
|
||||
<text x="450" y="78" textAnchor="middle" fontSize="18">Model v1 · 115 ulasan baru · Akurasi {pct(result.accuracy)}</text>
|
||||
<text x="500" y="120" textAnchor="middle" fontSize="20" fontWeight="700">Hasil prediksi</text>
|
||||
<text x="380" y="153" textAnchor="middle" fontSize="18">Negatif (0)</text>
|
||||
<text x="620" y="153" textAnchor="middle" fontSize="18">Positif (1)</text>
|
||||
<text x="60" y="350" transform="rotate(-90 60 350)" textAnchor="middle" fontSize="20" fontWeight="700">Label acuan</text>
|
||||
<text x="235" y="267" textAnchor="end" fontSize="18">Negatif (0)</text>
|
||||
<text x="235" y="447" textAnchor="end" fontSize="18">Positif (1)</text>
|
||||
{cells.map(c=><g key={c.label}><rect x={c.x} y={c.y} width="240" height="180" fill={c.correct?'#2454a6':'#edf2fa'} stroke="#ffffff" strokeWidth="3"/><text x={c.x+120} y={c.y+87} textAnchor="middle" fontSize="52" fontWeight="700" fill={c.correct?'#ffffff':'#172b4d'}>{c.value}</text><text x={c.x+120} y={c.y+127} textAnchor="middle" fontSize="21" fill={c.correct?'#ffffff':'#172b4d'}>{c.label}</text></g>)}
|
||||
<text x="450" y="570" textAnchor="middle" fontSize="17">102 prediksi sesuai label acuan · 13 prediksi berbeda</text>
|
||||
<text x="450" y="605" textAnchor="middle" fontSize="15" fill="#53627a">Data sintetis · Label acuan usulan AI · Evaluasi tambahan</text>
|
||||
</g>
|
||||
</svg>
|
||||
</section>
|
||||
<section className="research-card"><h2>Metrik per kelas</h2><div className="comparison-scroll"><table><thead><tr><th>Kelas</th><th>Precision</th><th>Recall</th><th>F1-score</th><th>Jumlah</th></tr></thead><tbody>{result.classes.map((c,k)=><tr key={k}><th>{k===0?'Negatif':'Positif'}</th><td>{pct(c.precision)}</td><td>{pct(c.recall)}</td><td>{pct(c.f1)}</td><td>{c.support}</td></tr>)}</tbody></table></div><p>Akurasi = (TN + TP) / seluruh ulasan = (73 + 29) / 115 = {pct(result.accuracy)}.</p></section>
|
||||
<section className="research-card"><h2>Perbandingan dengan pengujian awal</h2><div className="comparison-scroll"><table><thead><tr><th>Komponen</th><th>Awal penelitian</th><th>Pengujian pembanding</th></tr></thead><tbody><tr><th>Model</th><td>v1</td><td>v1 yang sama</td></tr><tr><th>Jumlah data uji</th><td>115</td><td>115 ulasan baru</td></tr><tr><th>TN / FP / FN / TP</th><td>70 / 6 / 2 / 37</td><td>73 / 3 / 10 / 29</td></tr><tr><th>Akurasi</th><td>{pct(initial.accuracy)}</td><td>{pct(result.accuracy)}</td></tr></tbody></table></div><p>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.</p></section>
|
||||
<section className="research-card"><div className="comparison-heading"><div><h2>Hasil 115 ulasan</h2><p>Arsip pengujian model v1. Prediksi baru di web tidak otomatis mengubah evaluasi ini.</p></div><button type="button" className="comparison-button" onClick={saveCSV}>Unduh hasil CSV</button></div><details><summary>Lihat 13 prediksi yang berbeda</summary><div className="comparison-scroll"><table><thead><tr><th>ID</th><th>Ulasan</th><th>Acuan</th><th>Prediksi</th></tr></thead><tbody>{data.rows.filter(r=>r.label_acuan!==r.label_prediksi).map(r=><tr key={r.id}><td>{r.id}</td><td>{r.ulasan}</td><td>{r.label_acuan===0?'Negatif':'Positif'}</td><td>{r.label_prediksi===0?'Negatif':'Positif'}</td></tr>)}</tbody></table></div></details></section>
|
||||
</div>;
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
export function ModelVersion({ value }) {
|
||||
return <span style={{display:"block", fontSize:13, fontWeight:500, color:"#425b7e", margin:"8px 0"}}>Model saat analisis: <b>{value || "Tidak tercatat"}</b></span>;
|
||||
}
|
||||
export function TfidfNote() {
|
||||
return <p style={{fontSize:13, color:"#566177", lineHeight:1.6}}>TF-IDF menunjukkan bobot kata dalam ulasan, bukan ukuran langsung pengaruh kata terhadap sentimen positif atau negatif.</p>;
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<aside className="sidebar">
|
||||
|
||||
{/* ======================================================
|
||||
BRAND
|
||||
====================================================== */}
|
||||
|
||||
<div className="sidebar-brand">
|
||||
|
||||
<div className="brand-logo">
|
||||
S
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2>
|
||||
S-Learn
|
||||
</h2>
|
||||
|
||||
<p>
|
||||
Analisis Sentimen
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
{/* ======================================================
|
||||
NAVIGASI
|
||||
====================================================== */}
|
||||
|
||||
<nav className="sidebar-menu">
|
||||
|
||||
{menus.map(
|
||||
(menu) => {
|
||||
const aktif =
|
||||
isMenuActive(
|
||||
menu.path
|
||||
);
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
key={
|
||||
menu.path
|
||||
}
|
||||
to={
|
||||
menu.path
|
||||
}
|
||||
end={
|
||||
menu.path === "/"
|
||||
}
|
||||
aria-current={
|
||||
aktif
|
||||
? "page"
|
||||
: undefined
|
||||
}
|
||||
className={
|
||||
`sidebar-link ${
|
||||
aktif
|
||||
? "active"
|
||||
: ""
|
||||
}`
|
||||
}
|
||||
>
|
||||
{menu.name}
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
)}
|
||||
|
||||
</nav>
|
||||
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export default Sidebar;
|
||||
|
|
@ -0,0 +1,137 @@
|
|||
|
||||
const rate = (v) => `${(v * 100).toFixed(2).replace(".", ",")}%`;
|
||||
|
||||
export function SkippedRows({ audit, legacy = false, status = "completed" }) {
|
||||
if (!audit) {
|
||||
return legacy
|
||||
? <p>Rincian baris yang dilewati belum dicatat untuk versi lama ini.</p>
|
||||
: null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="models-audit">
|
||||
<p>
|
||||
<b>
|
||||
{audit.uploaded_rows} diunggah → {audit.accepted_rows} diterima →{" "}
|
||||
{audit.skipped_rows.length} dilewati.
|
||||
</b>
|
||||
</p>
|
||||
|
||||
{audit.selected_column && (
|
||||
<p>
|
||||
Kolom yang digunakan: <b>{audit.selected_column}</b>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p>
|
||||
{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}.`}
|
||||
</p>
|
||||
|
||||
{!!audit.skipped_rows.length && (
|
||||
<details className="models-skipped-details">
|
||||
<summary>
|
||||
Lihat Data yang Dilewati ({audit.skipped_rows.length})
|
||||
</summary>
|
||||
|
||||
<p className="models-skip-note">
|
||||
Nomor baris adalah urutan record CSV, termasuk judul kolom
|
||||
sebagai baris 1. Ulasan dengan baris baru di dalam tanda
|
||||
kutip dihitung satu record.
|
||||
</p>
|
||||
|
||||
<div className="models-table models-skipped-scroll">
|
||||
<table className="models-skipped-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Baris CSV</th>
|
||||
<th>Ulasan</th>
|
||||
<th>Alasan dilewati</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{audit.skipped_rows.map((r) => (
|
||||
<tr key={r.source_row_number}>
|
||||
<td>{r.source_row_number}</td>
|
||||
|
||||
<td className="models-skipped-text">
|
||||
{r.text || "(kosong)"}
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<span className="models-skip-reason">
|
||||
{r.reason}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function VersionComparison({ selected, original }) {
|
||||
if (!original || selected.version === "v1") {
|
||||
return (
|
||||
<p>
|
||||
Model penelitian awal: {selected.jumlah_data_training} data latih
|
||||
dan {selected.jumlah_data_testing} data uji.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div>
|
||||
<h3>Perbandingan dengan model awal v1</h3>
|
||||
|
||||
<p>
|
||||
{selected.jumlah_data_training} data latih. Evaluasi pada{" "}
|
||||
{selected.jumlah_data_testing} data uji tetap yang sama.
|
||||
</p>
|
||||
|
||||
<div className="models-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Metrik</th>
|
||||
<th>v1</th>
|
||||
<th>{selected.version}</th>
|
||||
<th>Selisih (poin persentase)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{metrics.map(([name, get]) => (
|
||||
<tr key={name}>
|
||||
<th>{name}</th>
|
||||
<td>{rate(get(original))}</td>
|
||||
<td>{rate(get(selected))}</td>
|
||||
<td>
|
||||
{((get(selected) - get(original)) * 100).toFixed(2)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
Precision, Recall, dan F1-Score menggunakan weighted average,
|
||||
yaitu rata-rata yang memperhitungkan jumlah data pada setiap kelas.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
import { Outlet } from "react-router-dom";
|
||||
import Sidebar from "../components/Sidebar";
|
||||
import "../styles/layout.css";
|
||||
|
||||
function MainLayout() {
|
||||
return (
|
||||
<div className="app-layout">
|
||||
<Sidebar />
|
||||
<main className="main-content">
|
||||
<div className="content-wrapper">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MainLayout;
|
||||
|
|
@ -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(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</StrictMode>
|
||||
);
|
||||
|
|
@ -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 (
|
||||
<div className="csv-page">
|
||||
|
||||
{/* HEADING DAN INFORMASI MODEL AKTIF */}
|
||||
<div className="csv-heading">
|
||||
<h1>Analisis CSV</h1>
|
||||
|
||||
<p>
|
||||
Unggah file CSV berisi ulasan S-Learn, lalu pilih
|
||||
kolom yang akan dianalisis.
|
||||
</p>
|
||||
|
||||
{/* INFORMASI VERSI MODEL AKTIF */}
|
||||
<div
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
flexWrap: "wrap",
|
||||
gap: "9px",
|
||||
marginTop: "16px",
|
||||
padding: "10px 15px",
|
||||
background: "#edf3ff",
|
||||
border: "1px solid #d6e3fb",
|
||||
borderRadius: "9px",
|
||||
color: "#294263",
|
||||
fontSize: "13px"
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: "8px",
|
||||
height: "8px",
|
||||
borderRadius: "50%",
|
||||
background: "#259b72",
|
||||
flexShrink: 0
|
||||
}}
|
||||
/>
|
||||
|
||||
<span>
|
||||
Model yang sedang digunakan:
|
||||
</span>
|
||||
|
||||
<strong
|
||||
style={{
|
||||
color: "#2458c7",
|
||||
fontWeight: 700
|
||||
}}
|
||||
>
|
||||
{loadingModel
|
||||
? "Memuat..."
|
||||
: modelAktif || "Tidak tersedia"}
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* STEPPER — DIPERTAHANKAN */}
|
||||
<div className="csv-steps">
|
||||
<div
|
||||
className={`csv-step ${
|
||||
step >= 1 ? "active" : ""
|
||||
}`}
|
||||
>
|
||||
<span>1</span>
|
||||
<p>Unggah File</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`step-line ${
|
||||
step >= 2 ? "active" : ""
|
||||
}`}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={`csv-step ${
|
||||
step >= 2 ? "active" : ""
|
||||
}`}
|
||||
>
|
||||
<span>2</span>
|
||||
<p>Periksa Data</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`step-line ${
|
||||
step >= 3 ? "active" : ""
|
||||
}`}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={`csv-step ${
|
||||
step >= 3 ? "active" : ""
|
||||
}`}
|
||||
>
|
||||
<span>3</span>
|
||||
<p>Hasil</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="csv-error">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* STEP 1 — UNGGAH FILE CSV */}
|
||||
{step === 1 && (
|
||||
<section className="csv-card upload-section">
|
||||
<div
|
||||
className="drop-zone"
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<div className="upload-icon">
|
||||
↑
|
||||
</div>
|
||||
|
||||
<h2>Unggah File CSV</h2>
|
||||
|
||||
<p>
|
||||
Pilih file CSV atau tarik file ke area ini
|
||||
untuk memulai analisis.
|
||||
</p>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="csv-primary-button"
|
||||
onClick={pilihFile}
|
||||
>
|
||||
Pilih File CSV
|
||||
</button>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".csv,text/csv"
|
||||
onChange={handleInputFile}
|
||||
hidden
|
||||
/>
|
||||
|
||||
<span>
|
||||
Format .csv · Header wajib tersedia
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* STEP 2 — PREVIEW DAN PEMILIHAN KOLOM */}
|
||||
{step === 2 && (
|
||||
<>
|
||||
<section className="csv-card csv-file-preview-card">
|
||||
<div className="csv-file-header">
|
||||
<div className="csv-file-main">
|
||||
<h2>{file?.name}</h2>
|
||||
|
||||
<p>
|
||||
{formatUkuranFile(file?.size)}
|
||||
<span>·</span>
|
||||
{rows.length} baris
|
||||
<span>·</span>
|
||||
{columns.length} kolom
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="change-file-button"
|
||||
onClick={resetAnalisis}
|
||||
disabled={loading}
|
||||
>
|
||||
Ganti File
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="csv-preview-header">
|
||||
<h3>5 Baris Pertama</h3>
|
||||
|
||||
{adaKolomDisamarkan && (
|
||||
<p>
|
||||
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="table-wrapper">
|
||||
<table className="preview-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="preview-index">
|
||||
#
|
||||
</th>
|
||||
|
||||
{columns.map((column) => (
|
||||
<th
|
||||
key={column}
|
||||
className={
|
||||
selectedColumn === column
|
||||
? "selected-column"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{column}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{previewRows.map((row, rowIndex) => (
|
||||
<tr key={rowIndex}>
|
||||
<td className="preview-index">
|
||||
{rowIndex + 1}
|
||||
</td>
|
||||
|
||||
{columns.map((column) => (
|
||||
<td
|
||||
key={`${rowIndex}-${column}`}
|
||||
className={
|
||||
selectedColumn === column
|
||||
? "selected-column"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{formatPreviewValue(
|
||||
column,
|
||||
row[column]
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* PILIH KOLOM ULASAN */}
|
||||
<section className="csv-card column-card">
|
||||
<div className="column-selection">
|
||||
<div className="column-description">
|
||||
<div className="column-title-row">
|
||||
<label htmlFor="kolom-ulasan">
|
||||
Kolom Ulasan
|
||||
</label>
|
||||
|
||||
{detectedColumn &&
|
||||
selectedColumn === detectedColumn && (
|
||||
<span className="detected-badge">
|
||||
Terdeteksi otomatis
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<select
|
||||
id="kolom-ulasan"
|
||||
value={selectedColumn}
|
||||
disabled={loading}
|
||||
onChange={(event) => {
|
||||
setSelectedColumn(event.target.value);
|
||||
setError("");
|
||||
}}
|
||||
>
|
||||
<option value="">
|
||||
Pilih kolom ulasan
|
||||
</option>
|
||||
|
||||
{columns.map((column) => (
|
||||
<option
|
||||
key={column}
|
||||
value={column}
|
||||
>
|
||||
{column}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* STATUS KOLOM VALID */}
|
||||
{selectedColumn && evaluasiKolom.cocok && (
|
||||
<div className="column-status success">
|
||||
<span className="column-status-icon">
|
||||
✓
|
||||
</span>
|
||||
|
||||
<div>
|
||||
<strong>
|
||||
{jumlahBarisKosong === 0
|
||||
? `${jumlahUlasanValid} ulasan siap dianalisis.`
|
||||
: `${jumlahUlasanValid} dari ${rows.length} baris berisi teks.`}
|
||||
</strong>
|
||||
|
||||
{jumlahBarisKosong > 0 && (
|
||||
<p>
|
||||
{jumlahBarisKosong} baris kosong akan
|
||||
dilewati saat analisis.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* STATUS KOLOM TIDAK SESUAI */}
|
||||
{selectedColumn && !evaluasiKolom.cocok && (
|
||||
<div className="column-status warning">
|
||||
<span className="column-status-icon">
|
||||
!
|
||||
</span>
|
||||
|
||||
<div>
|
||||
<strong>
|
||||
Kolom belum sesuai
|
||||
</strong>
|
||||
|
||||
<p>
|
||||
{evaluasiKolom.pesan}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* INFORMASI PENYIMPANAN */}
|
||||
<div className="csv-storage-note">
|
||||
<span>i</span>
|
||||
|
||||
<p>
|
||||
Hanya isi kolom yang dipilih yang diproses dan
|
||||
disimpan sebagai data analisis.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* TOMBOL MULAI ANALISIS */}
|
||||
<div className="preview-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="csv-primary-button"
|
||||
onClick={mulaiAnalisis}
|
||||
disabled={
|
||||
!selectedColumn ||
|
||||
jumlahUlasanValid === 0 ||
|
||||
!evaluasiKolom.cocok ||
|
||||
loading
|
||||
}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<span
|
||||
className="csv-button-spinner"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
Menganalisis...
|
||||
</>
|
||||
) : (
|
||||
"Mulai Analisis"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AnalisisCSV;
|
||||
|
|
@ -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)=><article className="stat-card"><span className="stat-label">{label}</span><strong>{value}</strong><small>{note}</small></article>;
|
||||
return <div className="dashboard-page">
|
||||
<div className="page-heading"><div><h1>Dashboard</h1><p>Model yang digunakan, aktivitas prediksi, dan hasil analisis terakhir.</p></div><Link className="primary-button" to="/prediksi">Prediksi Ulasan Baru</Link></div>
|
||||
<section className="dashboard-panel"><h2>Model yang sedang digunakan</h2><p>Model ini digunakan untuk prediksi satu ulasan dan analisis CSV berikutnya.</p>
|
||||
{state.models?.error ? <p role="alert">Informasi model tidak dapat dimuat: {state.models.error}</p> : !active ? <p>Memuat model aktif...</p> : <>
|
||||
<div className="models-summary"><div><small>Versi aktif</small><strong>{active.version}</strong></div><div><small>Akurasi pada data uji</small><strong>{percent(active.akurasi)}</strong></div><div><small>Data latih model</small><strong>{active.jumlah_data_training}</strong></div><div><small>Data uji tetap</small><strong>{active.jumlah_data_testing}</strong></div></div>
|
||||
<p>Selesai dilatih: {date(active.created_at)}. Akurasi adalah hasil evaluasi pada data uji, bukan jaminan setiap prediksi benar.</p><Link to="/riwayat-model">Kelola model dan lihat evaluasi →</Link></>}
|
||||
</section>
|
||||
<section className="dashboard-panel"><h2>Aktivitas analisis</h2><p>Ulasan yang dianalisis tidak otomatis ditambahkan ke data latih. Analisis berulang tetap dihitung sebagai aktivitas terpisah.</p>
|
||||
<div className="stats-grid">
|
||||
{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')}
|
||||
</div>
|
||||
{[state.csv?.error,state.single?.error].filter(Boolean).map((e,i)=><p role="alert" key={i}>Data aktivitas tidak dapat dimuat: {e}</p>)}
|
||||
</section>
|
||||
<section className="dashboard-panel"><h2>Hasil analisis CSV terakhir</h2><p>Distribusi hasil prediksi pada file terakhir, bukan distribusi dataset training.</p>
|
||||
{state.csv?.error ? <p role="alert">Hasil terakhir tidak tersedia karena riwayat gagal dimuat.</p> : !state.csv ? <p>Memuat hasil...</p> : !latest ? <p>Belum ada analisis CSV. <Link to="/analisis-csv">Analisis CSV baru</Link></p> : <>
|
||||
<p><b>{latest.file_name}</b> · {date(latest.created_at)}</p><ModelVersion value={latest.model_version}/>
|
||||
<div className="sentiment-content"><div className="donut-chart" role="img" aria-label={`${latest.total_positive} positif dan ${latest.total_negative} negatif`} style={{background:total?`conic-gradient(#1f9d68 0% ${positive}%,#db4e58 ${positive}% 100%)`:'#e9edf3'}}><div className="donut-center"><strong>{total}</strong><span>Ulasan</span></div></div>
|
||||
<div className="sentiment-summary"><p><b>Positif: {latest.total_positive} ({positive.toFixed(1)}%)</b></p><p><b>Negatif: {latest.total_negative} ({(total?latest.total_negative/total*100:0).toFixed(1)}%)</b></p><Link className="secondary-button" to={`/hasil-analisis/${encodeURIComponent(latest.batch_id)}`}>Lihat Hasil</Link></div></div>
|
||||
</>}
|
||||
</section>
|
||||
<p>Penelitian awal: <b>571 ulasan</b> (456 data latih dan 115 data uji). <Link to="/penelitian-model">Lihat Penelitian & Model Awal →</Link></p>
|
||||
</div>;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -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 (
|
||||
<div className="prediksi-page">
|
||||
|
||||
{/* ========================================================
|
||||
HEADING
|
||||
======================================================== */}
|
||||
|
||||
<div className="prediksi-heading">
|
||||
|
||||
<h1>
|
||||
Prediksi Satu Ulasan
|
||||
</h1>
|
||||
|
||||
<p>
|
||||
Masukkan satu ulasan S-Learn untuk memperoleh hasil prediksi sentimen.
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
{/* ========================================================
|
||||
INPUT & HASIL
|
||||
======================================================== */}
|
||||
|
||||
<section className="prediksi-grid">
|
||||
|
||||
{/* ======================================================
|
||||
INPUT
|
||||
====================================================== */}
|
||||
|
||||
<article className="prediksi-card input-card">
|
||||
|
||||
<div className="prediksi-section-heading">
|
||||
<h2>
|
||||
Masukkan Ulasan
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
|
||||
<textarea
|
||||
id="ulasan-prediksi"
|
||||
aria-label="Ulasan S-Learn"
|
||||
rows="7"
|
||||
value={teks}
|
||||
onChange={handleChange}
|
||||
placeholder="Contoh: S-Learn sering error saat digunakan untuk mengumpulkan tugas..."
|
||||
/>
|
||||
|
||||
|
||||
<div className="input-information">
|
||||
<span>
|
||||
{teks.length} karakter
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
{error && (
|
||||
<div className="prediksi-error">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
<div className="prediksi-actions">
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="prediksi-secondary-button"
|
||||
onClick={resetPrediksi}
|
||||
disabled={!teks && !hasil}
|
||||
>
|
||||
Bersihkan
|
||||
</button>
|
||||
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="prediksi-primary-button"
|
||||
onClick={mulaiAnalisis}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading
|
||||
? "Menganalisis..."
|
||||
: "Mulai Analisis"}
|
||||
</button>
|
||||
|
||||
</div>
|
||||
|
||||
</article>
|
||||
|
||||
|
||||
{/* ======================================================
|
||||
HASIL
|
||||
====================================================== */}
|
||||
|
||||
<article className="prediksi-card result-card">
|
||||
{hasil && <ModelVersion value={hasil.model_version} />}
|
||||
|
||||
<div className="prediksi-section-heading">
|
||||
<h2>
|
||||
Sentimen Ulasan
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
|
||||
{!hasil && (
|
||||
<div className="empty-prediction">
|
||||
|
||||
<div className="empty-icon">
|
||||
S
|
||||
</div>
|
||||
|
||||
<strong>
|
||||
Hasil prediksi akan muncul di sini
|
||||
</strong>
|
||||
|
||||
<p>
|
||||
Setelah ulasan dianalisis, hasil sentimen akan ditampilkan pada bagian ini.
|
||||
</p>
|
||||
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{hasil && (
|
||||
<div className="prediction-result">
|
||||
|
||||
<div className="analyzed-text">
|
||||
|
||||
<span>
|
||||
Ulasan yang dianalisis
|
||||
</span>
|
||||
|
||||
<p>
|
||||
{hasil.teks}
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<div
|
||||
className={
|
||||
`prediction-sentiment-box ${
|
||||
hasilPositif
|
||||
? "positive-state"
|
||||
: "negative-state"
|
||||
}`
|
||||
}
|
||||
>
|
||||
|
||||
<span>
|
||||
Sentimen
|
||||
</span>
|
||||
|
||||
<strong
|
||||
className={
|
||||
`sentiment-result-badge ${
|
||||
hasilPositif
|
||||
? "prediction-positive"
|
||||
: "prediction-negative"
|
||||
}`
|
||||
}
|
||||
>
|
||||
{hasil.sentimen}
|
||||
</strong>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="lihat-proses-button"
|
||||
onClick={() =>
|
||||
setDetailTerbuka(true)
|
||||
}
|
||||
>
|
||||
Lihat Proses
|
||||
</button>
|
||||
|
||||
</div>
|
||||
)}
|
||||
|
||||
</article>
|
||||
|
||||
</section>
|
||||
|
||||
|
||||
{/* ========================================================
|
||||
INFORMASI PIPELINE
|
||||
======================================================== */}
|
||||
|
||||
<section className="prediksi-info">
|
||||
|
||||
<div className="prediksi-info-icon">
|
||||
i
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
<strong>
|
||||
Pipeline Analisis
|
||||
</strong>
|
||||
|
||||
<p>
|
||||
Ulasan diproses otomatis dengan tahapan preprocessing yang sama,
|
||||
ditransformasikan menggunakan vectorizer TF-IDF yang telah dibentuk
|
||||
dari data training, lalu diprediksi oleh model Logistic Regression
|
||||
tanpa pelatihan ulang.
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
|
||||
{/* ========================================================
|
||||
MODAL DETAIL
|
||||
======================================================== */}
|
||||
|
||||
{detailTerbuka && hasil && (
|
||||
|
||||
<div
|
||||
className="prediksi-overlay"
|
||||
onClick={() =>
|
||||
setDetailTerbuka(false)
|
||||
}
|
||||
>
|
||||
|
||||
<div
|
||||
className="prediksi-modal"
|
||||
onClick={(event) =>
|
||||
event.stopPropagation()
|
||||
}
|
||||
>
|
||||
|
||||
{/* ==================================================
|
||||
HEADER MODAL
|
||||
================================================== */}
|
||||
|
||||
<div className="prediksi-modal-header">
|
||||
|
||||
<div>
|
||||
|
||||
<span>
|
||||
DETAIL ANALISIS
|
||||
</span>
|
||||
|
||||
<h2>
|
||||
Proses Analisis Ulasan
|
||||
</h2>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Tutup detail"
|
||||
onClick={() =>
|
||||
setDetailTerbuka(false)
|
||||
}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
{/* ==================================================
|
||||
ULASAN ASLI
|
||||
================================================== */}
|
||||
|
||||
<div className="prediksi-original">
|
||||
|
||||
<span>
|
||||
Ulasan Asli
|
||||
</span>
|
||||
|
||||
<p>
|
||||
{hasil.teks}
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
{/* ==================================================
|
||||
PREPROCESSING
|
||||
================================================== */}
|
||||
|
||||
<div className="prediksi-step-list">
|
||||
|
||||
{prosesPreprocessing.map(
|
||||
(
|
||||
[nama, proses],
|
||||
index
|
||||
) => (
|
||||
|
||||
<div
|
||||
className="prediksi-step"
|
||||
key={nama}
|
||||
>
|
||||
|
||||
<span>
|
||||
{index + 1}
|
||||
</span>
|
||||
|
||||
<div>
|
||||
|
||||
<strong>
|
||||
{nama}
|
||||
</strong>
|
||||
|
||||
<p>
|
||||
{proses}
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
)
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
{/* ==================================================
|
||||
TF-IDF
|
||||
================================================== */}
|
||||
|
||||
<div className="prediksi-tfidf">
|
||||
|
||||
<div className="prediksi-modal-subheading">
|
||||
|
||||
<h3>
|
||||
Bobot TF-IDF
|
||||
</h3><TfidfNote />
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
{hasil.tfidf_top.length > 0 ? (
|
||||
|
||||
hasil.tfidf_top.map(
|
||||
(item) => (
|
||||
|
||||
<div
|
||||
className="prediksi-tfidf-row"
|
||||
key={item.kata}
|
||||
>
|
||||
|
||||
<span>
|
||||
{item.kata}
|
||||
</span>
|
||||
|
||||
|
||||
<div>
|
||||
|
||||
<i
|
||||
style={{
|
||||
width: `${
|
||||
Math.min(
|
||||
item.bobot /
|
||||
hasil.tfidf_top[0].bobot,
|
||||
1
|
||||
) * 100
|
||||
}%`
|
||||
}}
|
||||
></i>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<em>
|
||||
{item.bobot
|
||||
.toFixed(6)
|
||||
.replace(".", ",")}
|
||||
</em>
|
||||
|
||||
</div>
|
||||
|
||||
)
|
||||
)
|
||||
|
||||
) : (
|
||||
|
||||
<p className="prediksi-no-tfidf">
|
||||
Tidak ada fitur TF-IDF yang dikenali.
|
||||
</p>
|
||||
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
{/* ==================================================
|
||||
HASIL SENTIMEN MODAL
|
||||
================================================== */}
|
||||
|
||||
<div
|
||||
className={
|
||||
`prediksi-modal-result ${
|
||||
hasilPositif
|
||||
? "positive-state"
|
||||
: "negative-state"
|
||||
}`
|
||||
}
|
||||
>
|
||||
|
||||
<span>
|
||||
Prediksi Sentimen
|
||||
</span>
|
||||
|
||||
<strong
|
||||
className={
|
||||
`sentiment-result-badge ${
|
||||
hasilPositif
|
||||
? "prediction-positive"
|
||||
: "prediction-negative"
|
||||
}`
|
||||
}
|
||||
>
|
||||
{hasil.sentimen}
|
||||
</strong>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export default PrediksiUlasan;
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,413 @@
|
|||
|
||||
import { SkippedRows, VersionComparison } from "../components/TrainingDetails";
|
||||
import { useEffect, useState } from "react";
|
||||
import Papa from "papaparse";
|
||||
import { ambilRiwayatModel, trainingModel, aktifkanModel } from "../services/api";
|
||||
import "../styles/riwayat-model.css";
|
||||
|
||||
const percent = (n) => Number.isFinite(n) ? `${(n * 100).toFixed(2).replace(".", ",")}%` : "—";
|
||||
const date = (s) => new Date(s).toLocaleString("id-ID");
|
||||
const statusLabel = { queued: "Menunggu", running: "Berjalan", completed: "Selesai", failed: "Gagal", skipped: "Tidak diproses" };
|
||||
|
||||
const identityWords = new Set(['no','nomor','id','nama','name','nim','nis','nip','nik','prodi','jurusan','fakultas','gender','kelamin','email','telepon','phone','alamat','address','angkatan','semester','label','sentimen','sentiment']);
|
||||
|
||||
const isIdentity = (name) => {
|
||||
const words = name.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim().split(/\s+/);
|
||||
return !name.trim() || words.some(w => identityWords.has(w)) ||
|
||||
(words.includes('program') && words.includes('studi')) ||
|
||||
['jeniskelamin','programstudi'].includes(words.slice().sort().join(''));
|
||||
};
|
||||
|
||||
export default function RiwayatModel() {
|
||||
const [data, setData] = useState(null);
|
||||
const [error, setError] = useState("");
|
||||
const [connectionError, setConnectionError] = useState("");
|
||||
const [notice, setNotice] = useState("");
|
||||
const [rows, setRows] = useState([]);
|
||||
const [columns, setColumns] = useState([]);
|
||||
const [column, setColumn] = useState("");
|
||||
const [fileName, setFileName] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [reviewConfirmed, setReviewConfirmed] = useState(false);
|
||||
const [parsing, setParsing] = useState(false);
|
||||
const [selected, setSelected] = useState(null);
|
||||
const [confirmation, setConfirmation] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
async function refresh() {
|
||||
try {
|
||||
const result = await ambilRiwayatModel();
|
||||
if (alive) {
|
||||
setData(result.data);
|
||||
setConnectionError("");
|
||||
}
|
||||
} catch (e) {
|
||||
if (alive) setConnectionError(e.message);
|
||||
}
|
||||
}
|
||||
refresh();
|
||||
const timer = setInterval(refresh, 3000);
|
||||
return () => {
|
||||
alive = false;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const active = data?.versions.find((v) => v.active);
|
||||
const running = data?.jobs.some((j) => ["queued", "running"].includes(j.status));
|
||||
const reviews = column ? rows.map((r) => String(r[column] ?? "")) : [];
|
||||
|
||||
function readFile(event) {
|
||||
const file = event.target.files[0];
|
||||
setReviewConfirmed(false);
|
||||
setRows([]); setColumns([]); setColumn(""); setFileName(""); setError(""); setNotice("");
|
||||
|
||||
if (!file) return;
|
||||
if (!file.name.toLowerCase().endsWith(".csv") || file.size > 5 * 1024 * 1024) {
|
||||
setError("Pilih file CSV berukuran maksimal 5 MB.");
|
||||
return;
|
||||
}
|
||||
|
||||
setParsing(true);
|
||||
Papa.parse(file, {
|
||||
header: true,
|
||||
skipEmptyLines: false,
|
||||
transformHeader: (s) => s.replace(/^\uFEFF/, "").trim(),
|
||||
complete: (result) => {
|
||||
setParsing(false);
|
||||
|
||||
// Pertahankan logika pembacaan CSV asli
|
||||
if (result.data.length && Object.values(result.data.at(-1)).every((v) => !String(v).trim())) result.data.pop();
|
||||
|
||||
const errors = result.errors.filter((e) => e.code !== "UndetectableDelimiter" &&
|
||||
!(e.type === "FieldMismatch" && e.row >= result.data.length));
|
||||
|
||||
if (errors.length || !result.data.length || result.data.length > 5000 ||
|
||||
!result.meta.fields?.length || result.meta.fields.some(s => !s.trim()) ||
|
||||
Object.keys(result.meta.renamedHeaders || {}).length) {
|
||||
setError("CSV harus memiliki judul kolom, 1–5.000 baris data, dan jumlah kolom yang konsisten.");
|
||||
return;
|
||||
}
|
||||
|
||||
setFileName(file.name);
|
||||
setRows(result.data);
|
||||
setColumns(result.meta.fields);
|
||||
|
||||
const eligible = result.meta.fields.filter(s => !isIdentity(s));
|
||||
setColumn(eligible.find(s => /ulasan|komentar|review|text|feedback|tanggapan/i.test(s)) || '');
|
||||
|
||||
if (!eligible.length) setError('Tidak ada kolom ulasan yang dapat dipilih. Kolom identitas atau label tidak boleh digunakan.');
|
||||
},
|
||||
error: () => {
|
||||
setParsing(false);
|
||||
setError("File CSV tidak dapat dibaca.");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function submit(event) {
|
||||
event.preventDefault();
|
||||
|
||||
if (!column || isIdentity(column) || !reviewConfirmed) {
|
||||
setError('Pilih kolom ulasan dan konfirmasi contoh isinya terlebih dahulu.');
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
setNotice("");
|
||||
setError("");
|
||||
|
||||
try {
|
||||
await trainingModel(fileName, reviews, column);
|
||||
setReviewConfirmed(false);
|
||||
setRows([]); setColumns([]); setColumn(""); setFileName("");
|
||||
setNotice("Data diterima. Training berjalan otomatis; Anda dapat memantau status di bawah.");
|
||||
setData((await ambilRiwayatModel()).data);
|
||||
} catch (e) {
|
||||
setNotice(e.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function activate() {
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = await aktifkanModel(confirmation);
|
||||
setData(result.data);
|
||||
setNotice(`${confirmation} sekarang aktif untuk prediksi baru.`);
|
||||
setConfirmation(null);
|
||||
} catch (e) {
|
||||
setNotice(e.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="models-page">
|
||||
<header>
|
||||
<span className="models-eyebrow">PENGELOLAAN MODEL</span>
|
||||
<h1>Training & Riwayat Model</h1>
|
||||
<p>Tambahkan ulasan baru, pantau hasil training, dan pilih model untuk prediksi.</p>
|
||||
</header>
|
||||
|
||||
{error && <div className="models-error" role="alert">{error}</div>}
|
||||
{connectionError && <div className="models-error" role="alert">Koneksi backend: {connectionError}</div>}
|
||||
{notice && <div className="models-notice" role="status">{notice}</div>}
|
||||
{!data && !connectionError && <p role="status">Memuat riwayat model...</p>}
|
||||
|
||||
{/* RINGKASAN MODEL AKTIF */}
|
||||
{active && (
|
||||
<section className="models-summary" aria-label="Model aktif">
|
||||
<div><small>Model aktif</small><strong>{active.version}<span className="model-badge">Aktif</span></strong></div>
|
||||
<div><small>Akurasi model aktif</small><strong>{percent(active.akurasi)}</strong></div>
|
||||
<div><small>Data latih</small><strong>{active.jumlah_data_training}</strong></div>
|
||||
<div><small>Data uji tetap</small><strong>{active.jumlah_data_testing}</strong></div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* FORM TRAINING - LOGIKA ASLI DIPERTAHANKAN */}
|
||||
<section className="models-card">
|
||||
<h2>Tambah data & training ulang</h2>
|
||||
<p>
|
||||
Unggah CSV berisi ulasan tanpa label. Model aktif memberi label otomatis (pseudo-labeling),
|
||||
lalu sistem melatih ulang menggunakan data latih lama dan ulasan baru yang valid.
|
||||
</p>
|
||||
|
||||
<form onSubmit={submit}>
|
||||
<label htmlFor="training-file">
|
||||
File CSV <small>• maksimal 5 MB / 5.000 baris</small>
|
||||
</label>
|
||||
<input id="training-file" type="file" accept=".csv" onChange={readFile} disabled={busy || running || parsing} />
|
||||
|
||||
{parsing && <p role="status">Membaca CSV...</p>}
|
||||
|
||||
{!!columns.length && (
|
||||
<>
|
||||
<label htmlFor="review-column">Kolom ulasan</label>
|
||||
<select id="review-column" value={column}
|
||||
onChange={(e) => { setColumn(e.target.value); setReviewConfirmed(false); }}
|
||||
disabled={busy || running}>
|
||||
<option value="">Pilih kolom berisi ulasan</option>
|
||||
{columns.map((c) => (
|
||||
<option key={c} value={c} disabled={isIdentity(c)}>
|
||||
{c}{isIdentity(c) ? " (identitas/label — tidak dapat dipilih)" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<p><b>{reviews.length}</b> baris data terbaca dari {fileName}. Kolom label, bila ada, tidak digunakan.</p>
|
||||
<ul className="models-preview">
|
||||
{reviews.slice(0, 3).map((r, i) => <li key={i}>{r || '(kosong)'}</li>)}
|
||||
</ul>
|
||||
|
||||
{column && (
|
||||
<label style={{ display: 'flex', gap: 10, alignItems: 'flex-start' }}>
|
||||
<input type="checkbox" style={{ width: 'auto' }}
|
||||
checked={reviewConfirmed} disabled={busy || running}
|
||||
onChange={e => setReviewConfirmed(e.target.checked)} />
|
||||
Saya sudah memeriksa contoh isi kolom "{column}" dan memastikan isinya ulasan, bukan data identitas.
|
||||
</label>
|
||||
)}
|
||||
<p>Nama kolom boleh berbeda, misalnya komentar atau review. Periksa isi kolom sebelum melanjutkan.</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
<p className="models-help">
|
||||
Duplikat, data uji, dan ulasan tanpa kata yang dikenali akan dilewati.
|
||||
Label otomatis bisa keliru; akurasi model baru belum tentu meningkat.
|
||||
Model aktif tetap dipakai hingga Anda menggantinya.
|
||||
</p>
|
||||
|
||||
<button className="models-primary"
|
||||
disabled={busy || running || parsing || !data || !reviews.length || !reviewConfirmed || isIdentity(column)}>
|
||||
{running ? "Training sedang berjalan..." : busy ? "Mengirim..." : "Tambahkan Data & Training Otomatis"}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{/* STATUS TRAINING DAN RIWAYAT PROSES - TAMPILAN BARU */}
|
||||
{!!data?.jobs.length && (
|
||||
<section className="models-card models-training-status" aria-live="polite">
|
||||
<h2>Status training terakhir</h2>
|
||||
|
||||
<div className="models-job">
|
||||
<div className="models-job-heading">
|
||||
<span className={`models-status models-status-${data.jobs[0].status}`}>
|
||||
{statusLabel[data.jobs[0].status] || data.jobs[0].status}
|
||||
</span>
|
||||
<strong className="models-job-file">{data.jobs[0].file_name}</strong>
|
||||
</div>
|
||||
|
||||
<p className="models-job-message">{data.jobs[0].message}</p>
|
||||
<SkippedRows audit={data.jobs[0].audit} status={data.jobs[0].status} />
|
||||
</div>
|
||||
|
||||
<details className="models-history-details">
|
||||
<summary>
|
||||
<span>Riwayat proses</span>
|
||||
<span className="models-history-count">{data.jobs.length} proses</span>
|
||||
</summary>
|
||||
|
||||
<div className="models-history-list">
|
||||
{data.jobs.map((j) => (
|
||||
<article className="models-history-item" key={j.id}>
|
||||
<div className="models-history-item-head">
|
||||
<strong className="models-history-filename">{j.file_name}</strong>
|
||||
<span className={`models-status models-status-${j.status}`}>
|
||||
{statusLabel[j.status] || j.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<time className="models-history-date" dateTime={j.created_at}>
|
||||
{date(j.created_at)}
|
||||
</time>
|
||||
|
||||
<p className="models-history-message">{j.message}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* RIWAYAT MODEL - FUNGSI ASLI DIPERTAHANKAN */}
|
||||
<section className="models-card">
|
||||
<h2>Riwayat model</h2>
|
||||
<p>
|
||||
Semua versi dievaluasi pada 115 data uji penelitian yang sama.
|
||||
F1-Score menggunakan weighted average, yaitu rata-rata yang memperhitungkan
|
||||
jumlah data pada setiap kelas.
|
||||
</p>
|
||||
|
||||
<div className="models-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Versi</th>
|
||||
<th>Waktu training</th>
|
||||
<th>Data latih</th>
|
||||
<th>Akurasi</th>
|
||||
<th>F1-Score</th>
|
||||
<th>Status & tindakan</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data?.versions.map((v) => (
|
||||
<tr key={v.version}>
|
||||
<td>
|
||||
<b>{v.version}</b>
|
||||
<small>{v.version === "v1" ? "Model penelitian awal" : `+${v.added_rows} ulasan`}</small>
|
||||
</td>
|
||||
<td>{date(v.created_at)}</td>
|
||||
<td>{v.jumlah_data_training}</td>
|
||||
<td>{percent(v.akurasi)}</td>
|
||||
<td>{percent(v.classification_report["weighted avg"]["f1-score"])}</td>
|
||||
<td>
|
||||
<div className="models-actions">
|
||||
{v.active && <span className="model-badge">Aktif</span>}
|
||||
<button onClick={() => setSelected(v)}>Detail</button>
|
||||
{v.invalidated && <span className="models-error">Tidak valid — input keliru</span>}
|
||||
{!v.active && !v.invalidated && (
|
||||
<button disabled={busy} onClick={() => setConfirmation(v.version)}>Aktifkan</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* DETAIL EVALUASI MODEL */}
|
||||
{selected && (
|
||||
<section className="models-card">
|
||||
<div className="models-actions">
|
||||
<h2>Detail {selected.version}</h2>
|
||||
<button onClick={() => setSelected(null)}>Tutup detail</button>
|
||||
</div>
|
||||
|
||||
{selected.invalidated && (
|
||||
<p role="alert" className="models-error">
|
||||
{selected.invalid_reason} Angka evaluasi berikut adalah catatan historis model yang tidak valid.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<VersionComparison selected={selected} original={data.versions.find((v) => v.version === "v1")} />
|
||||
<SkippedRows audit={selected.audit} legacy={selected.version !== "v1"} />
|
||||
|
||||
<p>
|
||||
Sumber label: {selected.label_source}.{" "}
|
||||
{selected.teacher_version && `Model pemberi label: ${selected.teacher_version}.`}
|
||||
</p>
|
||||
<p>Fitur TF-IDF: {selected.jumlah_fitur_tfidf}. Data uji: {selected.jumlah_data_testing}.</p>
|
||||
|
||||
{selected.skipped && (
|
||||
<p>
|
||||
Dilewati: {selected.skipped.duplicate} duplikat/data lama, {selected.skipped.empty} kosong,
|
||||
{" "}{selected.skipped.unknown} tanpa fitur dikenali.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<h3>Confusion matrix</h3>
|
||||
<p>Baris: label sebenarnya. Kolom: prediksi.</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Aktual / Prediksi</th><th>Negatif</th><th>Positif</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{selected.confusion_matrix.map((row, i) => (
|
||||
<tr key={i}>
|
||||
<th>{i === 0 ? "Negatif" : "Positif"}</th>
|
||||
{row.map((n, j) => (
|
||||
<td key={j}>{n} <small>{[["TN", "FP"], ["FN", "TP"]][i][j]}</small></td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>Evaluasi per kelas</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Kelas</th><th>Precision</th><th>Recall</th><th>F1-Score</th><th>Jumlah Data Uji</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{["0", "1"].map((k) => {
|
||||
const r = selected.classification_report[k];
|
||||
return (
|
||||
<tr key={k}>
|
||||
<th>{k === "0" ? "Negatif" : "Positif"}</th>
|
||||
<td>{percent(r.precision)}</td>
|
||||
<td>{percent(r.recall)}</td>
|
||||
<td>{percent(r["f1-score"])}</td>
|
||||
<td>{r.support}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* KONFIRMASI AKTIVASI MODEL */}
|
||||
{confirmation && (
|
||||
<div className="models-overlay">
|
||||
<section className="models-dialog" role="dialog" aria-modal="true" aria-labelledby="activate-title">
|
||||
<h2 id="activate-title">Aktifkan {confirmation}?</h2>
|
||||
<p>Prediksi baru akan memakai versi ini. Riwayat hasil prediksi sebelumnya tetap tersimpan.</p>
|
||||
<div className="models-actions">
|
||||
<button disabled={busy} onClick={() => setConfirmation(null)}>Batal</button>
|
||||
<button className="models-primary" disabled={busy} onClick={activate}>Ya, aktifkan</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,191 @@
|
|||
const API_BASE_URL = import.meta.env.VITE_API_URL || "http://127.0.0.1:8000";
|
||||
|
||||
export async function ambilRiwayatModel() {
|
||||
return handleResponse(await fetch(`${API_BASE_URL}/models`));
|
||||
}
|
||||
|
||||
export async function trainingModel(fileName, reviews, selectedColumn) {
|
||||
return handleResponse(await fetch(`${API_BASE_URL}/training`, {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ file_name: fileName, reviews, selected_column: selectedColumn, confirmed_review_column: true })
|
||||
}));
|
||||
}
|
||||
|
||||
export async function aktifkanModel(version) {
|
||||
return handleResponse(await fetch(`${API_BASE_URL}/models/${encodeURIComponent(version)}/activate`, { method: "POST" }));
|
||||
}
|
||||
|
||||
export async function ambilModelPenelitian() {
|
||||
return handleResponse(await fetch(`${API_BASE_URL}/model-info/original`));
|
||||
}
|
||||
|
||||
|
||||
async function handleResponse(response) {
|
||||
let data;
|
||||
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch {
|
||||
throw new Error(
|
||||
"Respons dari server tidak dapat dibaca."
|
||||
);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
(Array.isArray(data.detail) ? "Data permintaan tidak valid. Periksa isi dan jumlah ulasan." : data.detail) ||
|
||||
"Terjadi kesalahan saat menghubungi server."
|
||||
);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
|
||||
// ============================================================
|
||||
// PREDIKSI SATU ULASAN
|
||||
// ============================================================
|
||||
|
||||
export async function prediksiUlasan(text) {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/predict`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
text
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
return handleResponse(response);
|
||||
}
|
||||
|
||||
|
||||
// ============================================================
|
||||
// RIWAYAT PREDIKSI SATU ULASAN
|
||||
// ============================================================
|
||||
|
||||
export async function ambilRiwayatPrediksi() {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/predictions`
|
||||
);
|
||||
|
||||
return handleResponse(response);
|
||||
}
|
||||
|
||||
|
||||
// ============================================================
|
||||
// DETAIL SATU PREDIKSI
|
||||
// ============================================================
|
||||
|
||||
export async function ambilDetailPrediksi(
|
||||
predictionId
|
||||
) {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/predictions/${encodeURIComponent(
|
||||
predictionId
|
||||
)}`
|
||||
);
|
||||
|
||||
return handleResponse(response);
|
||||
}
|
||||
|
||||
|
||||
// ============================================================
|
||||
// ANALISIS CSV
|
||||
// ============================================================
|
||||
|
||||
export async function analisisCSV(
|
||||
fileName,
|
||||
selectedColumn,
|
||||
reviews
|
||||
) {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/analysis/csv`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
file_name: fileName,
|
||||
selected_column: selectedColumn,
|
||||
reviews
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
return handleResponse(response);
|
||||
}
|
||||
|
||||
|
||||
// ============================================================
|
||||
// DETAIL HASIL ANALISIS CSV
|
||||
// ============================================================
|
||||
|
||||
export async function ambilHasilAnalisis(
|
||||
batchId
|
||||
) {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/analysis/${encodeURIComponent(
|
||||
batchId
|
||||
)}`
|
||||
);
|
||||
|
||||
return handleResponse(response);
|
||||
}
|
||||
|
||||
|
||||
// ============================================================
|
||||
// RIWAYAT ANALISIS CSV
|
||||
// ============================================================
|
||||
|
||||
export async function ambilRiwayatAnalisis() {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/analysis`
|
||||
);
|
||||
|
||||
return handleResponse(response);
|
||||
}
|
||||
|
||||
|
||||
// ============================================================
|
||||
// HEALTH CHECK BACKEND
|
||||
// ============================================================
|
||||
|
||||
export async function cekBackend() {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/health`
|
||||
);
|
||||
|
||||
return handleResponse(response);
|
||||
}
|
||||
|
||||
|
||||
// ============================================================
|
||||
// HEALTH CHECK DATABASE
|
||||
// ============================================================
|
||||
|
||||
export async function cekDatabase() {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/database/health`
|
||||
);
|
||||
|
||||
return handleResponse(response);
|
||||
}
|
||||
|
||||
|
||||
// ============================================================
|
||||
// INFORMASI MODEL
|
||||
// ============================================================
|
||||
|
||||
export async function ambilInformasiModel() {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/model-info`
|
||||
);
|
||||
|
||||
return handleResponse(response);
|
||||
}
|
||||
|
|
@ -0,0 +1,661 @@
|
|||
/* ============================================================
|
||||
PAGE
|
||||
============================================================ */
|
||||
|
||||
.csv-page {
|
||||
width: 100%;
|
||||
max-width: 1450px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.csv-heading {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.csv-heading h1 {
|
||||
margin: 0;
|
||||
color: #142641;
|
||||
font-size: 25px;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.csv-heading p {
|
||||
margin: 5px 0 0;
|
||||
color: #6b7485;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
STEPPER
|
||||
============================================================ */
|
||||
|
||||
.csv-steps {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
margin-bottom: 14px;
|
||||
padding: 13px 18px;
|
||||
border: 1px solid #dfe5ee;
|
||||
border-radius: 10px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.csv-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.csv-step span {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid #dbe2ed;
|
||||
border-radius: 50%;
|
||||
background: #f7f9fc;
|
||||
color: #8793a5;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.csv-step p {
|
||||
margin: 0;
|
||||
color: #8a95a7;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.csv-step.active span {
|
||||
border-color: #295bcc;
|
||||
background: #295bcc;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.csv-step.active p {
|
||||
color: #295bcc;
|
||||
}
|
||||
|
||||
.step-line {
|
||||
height: 1px;
|
||||
flex: 1;
|
||||
min-width: 30px;
|
||||
margin: 0 12px;
|
||||
background: #dfe5ee;
|
||||
}
|
||||
|
||||
.step-line.active {
|
||||
background: #8eabe8;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
GENERAL CARD
|
||||
============================================================ */
|
||||
|
||||
.csv-card {
|
||||
width: 100%;
|
||||
margin-bottom: 12px;
|
||||
padding: 18px;
|
||||
border: 1px solid #dfe5ee;
|
||||
border-radius: 10px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.csv-error {
|
||||
margin-bottom: 12px;
|
||||
padding: 11px 13px;
|
||||
border: 1px solid #f1c5ca;
|
||||
border-radius: 8px;
|
||||
background: #fff5f6;
|
||||
color: #b63f49;
|
||||
font-size: 10px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
UPLOAD
|
||||
============================================================ */
|
||||
|
||||
.upload-section {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.drop-zone {
|
||||
min-height: 330px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px;
|
||||
border: 1.5px dashed #c9d7ed;
|
||||
border-radius: 10px;
|
||||
background:
|
||||
linear-gradient(
|
||||
180deg,
|
||||
#fbfdff 0%,
|
||||
#f6f9fe 100%
|
||||
);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
margin-bottom: 12px;
|
||||
border-radius: 12px;
|
||||
background: #eaf1ff;
|
||||
color: #295bcc;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.drop-zone h2 {
|
||||
margin: 0;
|
||||
color: #142641;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.drop-zone p {
|
||||
max-width: 430px;
|
||||
margin: 7px 0 17px;
|
||||
color: #748196;
|
||||
font-size: 10.5px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.drop-zone > span {
|
||||
margin-top: 11px;
|
||||
color: #7e899b;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
BUTTON
|
||||
============================================================ */
|
||||
|
||||
.csv-primary-button,
|
||||
.change-file-button {
|
||||
min-height: 36px;
|
||||
padding: 8px 14px;
|
||||
border-radius: 7px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.2s ease,
|
||||
border-color 0.2s ease,
|
||||
box-shadow 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
}
|
||||
|
||||
.csv-primary-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
border: 1px solid #295bcc;
|
||||
background: #295bcc;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.csv-primary-button:hover:not(:disabled) {
|
||||
background: #234fab;
|
||||
border-color: #234fab;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 5px 12px rgba(41, 91, 204, 0.15);
|
||||
}
|
||||
|
||||
.change-file-button {
|
||||
border: 1px solid #cbd9ee;
|
||||
background: #eef4ff;
|
||||
color: #295bcc;
|
||||
}
|
||||
|
||||
.change-file-button:hover:not(:disabled) {
|
||||
border-color: #a9c0e9;
|
||||
background: #e3edff;
|
||||
}
|
||||
|
||||
.csv-primary-button:disabled,
|
||||
.change-file-button:disabled {
|
||||
opacity: 0.52;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
FILE + PREVIEW CARD
|
||||
============================================================ */
|
||||
|
||||
.csv-file-preview-card {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.csv-file-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
padding: 16px 18px;
|
||||
border-bottom: 1px solid #e8ecf2;
|
||||
background:
|
||||
linear-gradient(
|
||||
180deg,
|
||||
#ffffff 0%,
|
||||
#fbfcff 100%
|
||||
);
|
||||
}
|
||||
|
||||
.csv-file-main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.csv-file-main h2 {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
color: #142641;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.csv-file-main p {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 7px;
|
||||
margin: 5px 0 0;
|
||||
color: #64748b;
|
||||
font-size: 9.5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.csv-file-main p span {
|
||||
color: #a4adbb;
|
||||
}
|
||||
|
||||
.csv-preview-header {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
padding: 14px 18px 10px;
|
||||
}
|
||||
|
||||
.csv-preview-header h3 {
|
||||
margin: 0;
|
||||
color: #263b58;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.csv-preview-header p {
|
||||
margin: 0;
|
||||
color: #718095;
|
||||
font-size: 8.8px;
|
||||
line-height: 1.4;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
PREVIEW TABLE
|
||||
============================================================ */
|
||||
|
||||
.table-wrapper {
|
||||
width: calc(100% - 36px);
|
||||
margin: 0 18px 18px;
|
||||
overflow-x: auto;
|
||||
border: 1px solid #e1e6ee;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.preview-table {
|
||||
width: 100%;
|
||||
min-width: max-content;
|
||||
border-collapse: collapse;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.preview-table th,
|
||||
.preview-table td {
|
||||
max-width: 290px;
|
||||
padding: 9px 11px;
|
||||
border-right: 1px solid #edf0f4;
|
||||
border-bottom: 1px solid #edf0f4;
|
||||
color: #4d5d73;
|
||||
text-align: left;
|
||||
font-size: 9px;
|
||||
line-height: 1.45;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.preview-table th:last-child,
|
||||
.preview-table td:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.preview-table tbody tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.preview-table th {
|
||||
background: #f5f7fa;
|
||||
color: #56667d;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.preview-table tbody tr:hover td:not(.selected-column) {
|
||||
background: #fafcff;
|
||||
}
|
||||
|
||||
.preview-table .preview-index {
|
||||
width: 44px;
|
||||
min-width: 44px;
|
||||
max-width: 44px;
|
||||
color: #79869a;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.preview-table th.selected-column {
|
||||
border-color: #c4d5f3;
|
||||
background: #e9f1ff;
|
||||
color: #234fae;
|
||||
}
|
||||
|
||||
.preview-table td.selected-column {
|
||||
border-color: #d5e1f5;
|
||||
background: #f3f7ff;
|
||||
color: #344a69;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
COLUMN SELECTION
|
||||
============================================================ */
|
||||
|
||||
.column-card {
|
||||
padding: 17px 18px;
|
||||
}
|
||||
|
||||
.column-selection {
|
||||
display: grid;
|
||||
grid-template-columns:
|
||||
minmax(240px, 1fr)
|
||||
minmax(240px, 420px);
|
||||
align-items: end;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.column-description {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.column-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.column-title-row label {
|
||||
color: #263b58;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.detected-badge {
|
||||
padding: 4px 7px;
|
||||
border: 1px solid #c8d9f5;
|
||||
border-radius: 999px;
|
||||
background: #eef4ff;
|
||||
color: #295bcc;
|
||||
font-size: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.column-selection select {
|
||||
width: 100%;
|
||||
height: 38px;
|
||||
padding: 0 34px 0 11px;
|
||||
border: 1px solid #d5dde9;
|
||||
border-radius: 7px;
|
||||
outline: none;
|
||||
background: #ffffff;
|
||||
color: #394b63;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.column-selection select:focus {
|
||||
border-color: #94b2eb;
|
||||
box-shadow: 0 0 0 3px rgba(41, 91, 204, 0.07);
|
||||
}
|
||||
|
||||
.column-selection select:disabled {
|
||||
background: #f4f6f9;
|
||||
color: #929baa;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
COLUMN STATUS
|
||||
============================================================ */
|
||||
|
||||
.column-status {
|
||||
display: grid;
|
||||
grid-template-columns: 30px 1fr;
|
||||
gap: 9px;
|
||||
margin-top: 13px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.column-status.success {
|
||||
border: 1px solid #cce8db;
|
||||
background: #f1faf6;
|
||||
}
|
||||
|
||||
.column-status.warning {
|
||||
border: 1px solid #f0d6a8;
|
||||
background: #fff9ee;
|
||||
}
|
||||
|
||||
.column-status-icon {
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.column-status.success .column-status-icon {
|
||||
background: #def2e8;
|
||||
color: #168052;
|
||||
}
|
||||
|
||||
.column-status.warning .column-status-icon {
|
||||
background: #f8e8c9;
|
||||
color: #9b6818;
|
||||
}
|
||||
|
||||
.column-status strong {
|
||||
display: block;
|
||||
margin-top: 1px;
|
||||
color: #304259;
|
||||
font-size: 9.8px;
|
||||
}
|
||||
|
||||
.column-status p {
|
||||
margin: 3px 0 0;
|
||||
color: #66758a;
|
||||
font-size: 9px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.column-status.warning strong {
|
||||
color: #855b1a;
|
||||
}
|
||||
|
||||
.column-status.warning p {
|
||||
color: #80652f;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
STORAGE / PRIVACY NOTE
|
||||
============================================================ */
|
||||
|
||||
.csv-storage-note {
|
||||
display: grid;
|
||||
grid-template-columns: 25px 1fr;
|
||||
gap: 9px;
|
||||
margin-top: 10px;
|
||||
padding: 9px 11px;
|
||||
border: 1px solid #dce5f3;
|
||||
border-radius: 7px;
|
||||
background: #f6f9fe;
|
||||
}
|
||||
|
||||
.csv-storage-note > span {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: #eaf1ff;
|
||||
color: #295bcc;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.csv-storage-note p {
|
||||
margin: 2px 0 0;
|
||||
color: #607087;
|
||||
font-size: 9px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
ACTION
|
||||
============================================================ */
|
||||
|
||||
.preview-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 13px;
|
||||
padding-top: 13px;
|
||||
border-top: 1px solid #edf0f4;
|
||||
}
|
||||
|
||||
.preview-actions .csv-primary-button {
|
||||
min-width: 145px;
|
||||
}
|
||||
|
||||
.csv-button-spinner {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
flex: 0 0 13px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.4);
|
||||
border-top-color: #ffffff;
|
||||
border-radius: 50%;
|
||||
animation: csv-spin 0.7s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes csv-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
RESPONSIVE
|
||||
============================================================ */
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.column-selection {
|
||||
grid-template-columns: 1fr;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.csv-preview-header {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.csv-preview-header p {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.csv-heading h1 {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.csv-steps {
|
||||
padding: 11px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.csv-step p {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.step-line {
|
||||
min-width: 38px;
|
||||
}
|
||||
|
||||
.csv-file-header {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.change-file-button {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.table-wrapper {
|
||||
width: calc(100% - 24px);
|
||||
margin: 0 12px 12px;
|
||||
}
|
||||
|
||||
.csv-preview-header {
|
||||
padding-left: 12px;
|
||||
padding-right: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.csv-card {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.csv-file-preview-card {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.drop-zone {
|
||||
min-height: 280px;
|
||||
padding: 24px 16px;
|
||||
}
|
||||
|
||||
.preview-actions {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.preview-actions .csv-primary-button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,210 @@
|
|||
|
||||
/* DASHBOARD - CSS LENGKAP + PERBAIKAN TAMPILAN */
|
||||
|
||||
.dashboard-page { width:100%; max-width:1450px; margin:0 auto; }
|
||||
.page-heading { display:flex; align-items:flex-start; justify-content:space-between; gap:20px; margin-bottom:22px; }
|
||||
.page-heading h1 { margin:0; color:#142641; font-size:25px; font-weight:700; line-height:1.25; }
|
||||
.page-heading p { margin:6px 0 0; color:#56657a; font-size:13px; line-height:1.5; }
|
||||
|
||||
.primary-button, .secondary-button {
|
||||
padding:9px 14px; border-radius:7px; font-family:inherit;
|
||||
font-size:12px; font-weight:600; cursor:pointer;
|
||||
transition:background .2s ease,border-color .2s ease,transform .2s ease;
|
||||
}
|
||||
.primary-button { border:1px solid #295bcc; background:#295bcc; color:#fff; }
|
||||
.primary-button:hover { border-color:#214daf; background:#214daf; }
|
||||
.primary-button:active { transform:translateY(1px); }
|
||||
.secondary-button { border:1px solid #ccd9ef; background:#fff; color:#295bcc; }
|
||||
.secondary-button:hover { border-color:#aac0e6; background:#f5f8fd; }
|
||||
|
||||
.dashboard-error { margin-bottom:14px; padding:12px 14px; border:1px solid #f0c8cd; border-radius:8px; background:#fff4f4; color:#b4232d; font-size:12px; }
|
||||
|
||||
/* Card statistik */
|
||||
.stats-grid { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:12px; margin-bottom:12px; }
|
||||
.stat-card {
|
||||
position:relative; min-width:0; min-height:125px;
|
||||
display:flex; flex-direction:column; justify-content:center;
|
||||
padding:18px 20px; overflow:hidden; border:1px solid transparent;
|
||||
border-radius:11px; cursor:default;
|
||||
transition:transform .28s cubic-bezier(.22,1,.36,1),
|
||||
box-shadow .28s ease,border-color .28s ease;
|
||||
}
|
||||
.stat-card-blue { border-color:#d9e5fb; background:#f7faff; }
|
||||
.stat-card-blue::before { background:#295bcc; }
|
||||
.stat-card-purple { border-color:#e5dcf7; background:#fbf9ff; }
|
||||
.stat-card-purple::before { background:#7654c7; }
|
||||
.stat-card-teal { border-color:#d4e9e5; background:#f5fbfa; }
|
||||
.stat-card-teal::before { background:#16877a; }
|
||||
.stat-card-amber { border-color:#eddfc7; background:#fffaf3; }
|
||||
.stat-card-amber::before { background:#c88627; }
|
||||
.stat-card::before {
|
||||
content:""; position:absolute; top:0; left:50%;
|
||||
width:0; height:4px; border-radius:999px;
|
||||
transform:translateX(-50%); transition:width .3s ease;
|
||||
}
|
||||
.stat-card:hover { transform:translateY(-6px) scale(1.012); box-shadow:0 15px 30px rgba(20,38,65,.11); }
|
||||
.stat-card:hover::before { width:82%; }
|
||||
.stat-card-blue:hover { border-color:#a9c4f2; }
|
||||
.stat-card-purple:hover { border-color:#c6b4e9; }
|
||||
.stat-card-teal:hover { border-color:#a9d4cc; }
|
||||
.stat-card-amber:hover { border-color:#dfc28f; }
|
||||
.stat-label { display:block; margin-bottom:6px; color:#526176; font-size:13px; font-weight:600; }
|
||||
.stat-card strong { display:block; color:#142641; font-size:24px; font-weight:700; line-height:1.25; }
|
||||
.stat-card small { display:block; margin-top:7px; color:#647287; font-size:12.5px; line-height:1.45; }
|
||||
.stat-card .latest-file { max-width:100%; overflow:hidden; color:#142641; font-size:15px; line-height:1.4; text-overflow:ellipsis; white-space:nowrap; }
|
||||
|
||||
/* Panel utama */
|
||||
.dashboard-panel { width:100%; min-height:335px; padding:22px; border:1px solid #e1e6ef; border-radius:11px; background:#fff; }
|
||||
.panel-heading { margin-bottom:24px; }
|
||||
.panel-heading h2 { margin:0; color:#142641; font-size:17px; font-weight:700; }
|
||||
.panel-heading p { margin:5px 0 0; color:#5d6b7f; font-size:12.5px; line-height:1.5; }
|
||||
|
||||
/* Grafik donat - bentuk lingkaran dipertahankan */
|
||||
.sentiment-content {
|
||||
display:grid; grid-template-columns:minmax(240px,.8fr) minmax(340px,1.2fr);
|
||||
align-items:center; gap:55px; max-width:860px;
|
||||
min-height:225px; margin:0 auto;
|
||||
}
|
||||
.donut-chart {
|
||||
position:relative; width:175px; height:175px;
|
||||
margin:0 auto; border-radius:50%;
|
||||
background:conic-gradient(#1f9d68 0 50%,#db4e58 50% 100%);
|
||||
}
|
||||
.donut-chart::after {
|
||||
content:""; position:absolute; inset:26px;
|
||||
border-radius:50%; background:#fff;
|
||||
}
|
||||
.donut-center {
|
||||
position:absolute; inset:0; z-index:1;
|
||||
display:grid; place-content:center; text-align:center;
|
||||
}
|
||||
.donut-center strong { display:block; color:#142641; font-size:27px; font-weight:700; line-height:1; }
|
||||
.donut-center span { display:block; margin-top:7px; color:#5f6d81; font-size:12.5px; }
|
||||
|
||||
/* Ringkasan sentimen */
|
||||
.sentiment-summary { display:flex; flex-direction:column; gap:13px; }
|
||||
.sentiment-legend-item { display:grid; grid-template-columns:11px minmax(0,1fr); align-items:center; gap:11px; padding:14px 15px; border:1px solid #e5eaf1; border-radius:8px; background:#fafbfd; }
|
||||
.sentiment-dot { width:8px; height:8px; border-radius:50%; }
|
||||
.sentiment-dot.positive { background:#1f9d68; }
|
||||
.sentiment-dot.negative { background:#db4e58; }
|
||||
.sentiment-legend-item div { display:flex; align-items:center; justify-content:space-between; gap:15px; }
|
||||
.sentiment-legend-item span:not(.sentiment-dot) { color:#4f5e73; font-size:13px; font-weight:500; }
|
||||
.sentiment-legend-item strong { color:#142641; font-size:13px; font-weight:700; white-space:nowrap; }
|
||||
.dashboard-result-button { width:100%; margin-top:3px; font-size:12.5px; }
|
||||
.dashboard-empty-state { min-height:235px; display:flex; align-items:center; justify-content:center; flex-direction:column; padding:30px 20px; text-align:center; }
|
||||
.dashboard-empty-icon { width:44px; height:44px; display:grid; place-items:center; margin-bottom:12px; border-radius:12px; background:#edf3ff; color:#295bcc; font-size:15px; font-weight:700; }
|
||||
.dashboard-empty-state strong { color:#142641; font-size:15px; }
|
||||
.dashboard-empty-state p { max-width:430px; margin:7px 0 16px; color:#5d6b7f; font-size:12.5px; line-height:1.6; }
|
||||
.dashboard-loading-state { min-height:235px; display:grid; place-items:center; color:#5d6b7f; font-size:13px; }
|
||||
|
||||
/* Responsif bawaan */
|
||||
@media(max-width:1050px) {
|
||||
.stats-grid { grid-template-columns:repeat(2,minmax(0,1fr)); }
|
||||
.sentiment-content { gap:30px; }
|
||||
}
|
||||
@media(max-width:760px) {
|
||||
.page-heading { display:block; }
|
||||
.page-heading .primary-button { margin-top:14px; }
|
||||
.sentiment-content { grid-template-columns:1fr; gap:24px; }
|
||||
}
|
||||
@media(max-width:620px) {
|
||||
.stats-grid { grid-template-columns:1fr; }
|
||||
.dashboard-panel { padding:17px; }
|
||||
.sentiment-legend-item div { align-items:flex-start; flex-direction:column; gap:4px; }
|
||||
}
|
||||
@media(max-width:480px) {
|
||||
.page-heading h1 { font-size:22px; }
|
||||
.stat-card { min-height:112px; }
|
||||
.donut-chart { width:150px; height:150px; }
|
||||
.donut-chart::after { inset:23px; }
|
||||
}
|
||||
@media(prefers-reduced-motion:reduce) {
|
||||
.stat-card,.stat-card::before { transition:none; }
|
||||
.stat-card:hover { transform:none; }
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
PERBAIKAN SPACING DAN LAYOUT DASHBOARD
|
||||
============================================ */
|
||||
.dashboard-page { display:flex; flex-direction:column; gap:22px; }
|
||||
.dashboard-page > .page-heading { margin-bottom:2px; }
|
||||
.dashboard-page > .dashboard-panel {
|
||||
min-height:0; margin:0; padding:28px 30px;
|
||||
border-radius:15px;
|
||||
box-shadow:0 4px 18px rgba(20,38,65,.035);
|
||||
}
|
||||
.dashboard-page > .dashboard-panel > h2 {
|
||||
margin:0 0 10px; color:#142641;
|
||||
font-size:21px; line-height:1.4;
|
||||
}
|
||||
.dashboard-page > .dashboard-panel > p {
|
||||
margin:0 0 18px; color:#596980;
|
||||
font-size:14px; line-height:1.65;
|
||||
}
|
||||
.dashboard-page .models-summary { margin:20px 0; gap:22px; }
|
||||
.dashboard-page .stats-grid {
|
||||
grid-template-columns:repeat(3,minmax(0,1fr));
|
||||
gap:18px; margin:20px 0 0;
|
||||
}
|
||||
.dashboard-page .stat-card {
|
||||
min-height:150px; padding:20px 22px;
|
||||
border:1px solid #dfe7f2; border-radius:12px;
|
||||
background:#f8faff;
|
||||
}
|
||||
.dashboard-page .stat-card:nth-child(2) { background:#fbf9ff; border-color:#e9e0f5; }
|
||||
.dashboard-page .stat-card:nth-child(3) { background:#f6fbfa; border-color:#d8e9e4; }
|
||||
.dashboard-page .stat-card:hover { transform:translateY(-2px); }
|
||||
|
||||
/* Perbaikan area grafik */
|
||||
.dashboard-page .sentiment-content {
|
||||
grid-template-columns:minmax(180px,.8fr) minmax(0,1.2fr);
|
||||
gap:38px; width:100%; max-width:800px;
|
||||
min-height:230px; margin:24px auto 8px;
|
||||
}
|
||||
.dashboard-page .donut-chart {
|
||||
position:relative; width:190px; height:190px; border-radius:50%;
|
||||
}
|
||||
.dashboard-page .donut-chart::after {
|
||||
content:""; position:absolute; inset:28px;
|
||||
border-radius:50%; background:#fff;
|
||||
}
|
||||
.dashboard-page .donut-center {
|
||||
position:absolute; inset:0; z-index:1;
|
||||
display:grid; place-content:center; text-align:center;
|
||||
}
|
||||
.dashboard-page .sentiment-summary > p {
|
||||
display:flex; align-items:center; gap:11px;
|
||||
margin:0; padding:15px 17px;
|
||||
border:1px solid #e0e7f0; border-radius:10px;
|
||||
background:#fafbfd; font-size:14px;
|
||||
}
|
||||
.dashboard-page .sentiment-summary > p::before {
|
||||
content:""; width:10px; height:10px; border-radius:50%;
|
||||
flex-shrink:0; background:#1f9d68;
|
||||
}
|
||||
.dashboard-page .sentiment-summary > p:nth-child(2)::before { background:#db4e58; }
|
||||
.dashboard-page .sentiment-summary > .secondary-button {
|
||||
display:inline-flex; align-items:center; justify-content:center;
|
||||
align-self:flex-start; min-width:150px;
|
||||
padding:10px 18px; text-decoration:none;
|
||||
}
|
||||
.dashboard-page > p:last-child {
|
||||
margin:0; padding:17px 20px;
|
||||
border:1px solid #dfe7f3; border-radius:12px;
|
||||
background:#f9fbff; color:#53627b;
|
||||
font-size:13px; line-height:1.6;
|
||||
}
|
||||
@media(max-width:1100px) {
|
||||
.dashboard-page .stats-grid { grid-template-columns:repeat(2,minmax(0,1fr)); }
|
||||
.dashboard-page .stat-card:last-child { grid-column:1/-1; }
|
||||
.dashboard-page .models-summary { grid-template-columns:repeat(2,minmax(0,1fr)); }
|
||||
}
|
||||
@media(max-width:760px) {
|
||||
.dashboard-page .sentiment-content { grid-template-columns:1fr; gap:24px; }
|
||||
}
|
||||
@media(max-width:620px) {
|
||||
.dashboard-page { gap:16px; }
|
||||
.dashboard-page > .dashboard-panel { padding:22px 18px; }
|
||||
.dashboard-page .stats-grid { grid-template-columns:1fr; gap:12px; }
|
||||
.dashboard-page .stat-card:last-child { grid-column:auto; }
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
.comparison-eval {display:grid;gap:24px;color:#172b4d}
|
||||
.comparison-eval h2 {margin:0 0 10px;font-size:22px}
|
||||
.comparison-eval p {line-height:1.7;color:#53627a}
|
||||
.comparison-heading {display:flex;justify-content:space-between;align-items:center;gap:20px;flex-wrap:wrap}
|
||||
.comparison-eyebrow {display:block;color:#2454a6;font-size:12px;font-weight:700;letter-spacing:1px;margin-bottom:10px}
|
||||
.comparison-tag {background:#edf2fa;border-radius:20px;padding:9px 16px;color:#2454a6;font-weight:600}
|
||||
.comparison-eval .comparison-note {font-size:13px;border-left:3px solid #8da9db;padding-left:14px}
|
||||
.comparison-stats {display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px;margin-top:24px}
|
||||
.comparison-stats>div {padding:20px;border:1px solid #dfe6f1;border-radius:12px;display:grid;gap:8px}
|
||||
.comparison-stats strong {font-size:32px;color:#2454a6}
|
||||
.comparison-stats small {color:#53627a}
|
||||
.comparison-button {background:#2454a6;color:white;border:0;border-radius:8px;padding:12px 18px;font:inherit;cursor:pointer}
|
||||
.comparison-button:disabled {opacity:.6;cursor:wait}
|
||||
.comparison-button:focus-visible,.comparison-eval summary:focus-visible {outline:3px solid #e5a626;outline-offset:3px}
|
||||
.comparison-matrix {display:block;width:100%;max-width:900px;height:auto;margin:18px auto 0}
|
||||
.comparison-scroll {overflow-x:auto;margin:16px 0}
|
||||
.comparison-eval table {border-collapse:collapse;width:100%;font-size:14px}
|
||||
.comparison-eval th,.comparison-eval td {padding:13px 15px;border-bottom:1px solid #dfe6f1;text-align:left;line-height:1.6}
|
||||
.comparison-eval thead {background:#edf2fa}
|
||||
.comparison-eval summary {cursor:pointer;font-weight:600;padding:12px 0}
|
||||
@media(max-width:650px){.comparison-stats{grid-template-columns:1fr}.comparison-eval .research-card{padding:18px}.comparison-eval table{min-width:520px}.comparison-stats strong{font-size:28px}}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,171 @@
|
|||
:root {
|
||||
--navy: #142641;
|
||||
--navy-light: #1c3558;
|
||||
--sapphire: #295bcc;
|
||||
--background: #f5f7fb;
|
||||
--white: #ffffff;
|
||||
--text: #172033;
|
||||
--muted: #6b7485;
|
||||
--border: #e3e8f0;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--background);
|
||||
color: var(--text);
|
||||
font-family: Inter, "Segoe UI", Arial, sans-serif;
|
||||
}
|
||||
|
||||
.app-layout {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 230px;
|
||||
height: 100vh;
|
||||
padding: 22px 16px;
|
||||
background: var(--navy);
|
||||
color: var(--white);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
padding: 0 8px 22px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
background: var(--sapphire);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.sidebar-brand h2 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sidebar-brand p {
|
||||
margin: 2px 0 0;
|
||||
color: #aebbd0;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.sidebar-menu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
margin-top: 22px;
|
||||
}
|
||||
|
||||
.sidebar-link {
|
||||
padding: 10px 12px;
|
||||
border-radius: 7px;
|
||||
color: #c9d3e2;
|
||||
text-decoration: none;
|
||||
font-size: 13.5px;
|
||||
font-weight: 500;
|
||||
transition: 0.2s;
|
||||
}
|
||||
|
||||
.sidebar-link:hover {
|
||||
background: var(--navy-light);
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
.sidebar-link.active {
|
||||
background: var(--sapphire);
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
margin-top: auto;
|
||||
padding: 14px 8px 2px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.sidebar-footer p {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
color: #c6d0df;
|
||||
}
|
||||
|
||||
.sidebar-footer span {
|
||||
font-size: 10px;
|
||||
color: #8797af;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
width: calc(100% - 230px);
|
||||
margin-left: 230px;
|
||||
min-height: 100vh;
|
||||
padding: 26px 30px 40px;
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
width: 100%;
|
||||
max-width: 1450px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.app-layout {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: static;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.sidebar-brand {
|
||||
padding: 0 4px 12px;
|
||||
}
|
||||
|
||||
.sidebar-menu {
|
||||
flex-direction: row;
|
||||
overflow-x: auto;
|
||||
margin-top: 12px;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.sidebar-link {
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
padding: 8px 11px;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
padding: 20px 16px 32px;
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,370 @@
|
|||
|
||||
/* PENELITIAN & MODEL AWAL - CSS LENGKAP */
|
||||
|
||||
/* Halaman dan navigasi */
|
||||
.research-page { width:100%; }
|
||||
.research-heading { display:flex; align-items:flex-start; justify-content:space-between; gap:20px; margin-bottom:18px; }
|
||||
.research-heading h1 { margin:0; color:#142641; font-size:25px; font-weight:700; line-height:1.25; }
|
||||
.research-heading p { max-width:900px; margin:5px 0 0; color:#6b7485; font-size:13.5px; line-height:1.5; }
|
||||
.nowrap-text { white-space:nowrap; }
|
||||
.research-tabs { display:flex; width:100%; margin-bottom:12px; padding:4px; overflow-x:auto; border:1px solid #e0e6ef; border-radius:9px; background:#fff; }
|
||||
.research-tabs button { flex:1; min-width:175px; padding:9px 12px; border:0; border-radius:6px; background:transparent; color:#728097; font-size:10.5px; font-weight:600; white-space:nowrap; cursor:pointer; }
|
||||
.research-tabs button:hover { background:#f5f7fb; }
|
||||
.research-tabs button.active { background:#295bcc; color:#fff; }
|
||||
.research-tab-content { width:100%; }
|
||||
|
||||
/* Statistik penelitian */
|
||||
.research-stat-grid { display:grid; grid-template-columns:repeat(4,1fr); gap:10px; margin-bottom:12px; }
|
||||
.research-stat-grid.three { grid-template-columns:repeat(3,1fr); }
|
||||
.research-stat-card { padding:14px 16px; border:1px solid #e1e6ef; border-radius:9px; background:#fff; }
|
||||
.research-stat-card span,.research-stat-card small { display:block; color:#8792a3; font-size:10px; }
|
||||
.research-stat-card strong { display:block; margin:3px 0; color:#142641; font-size:21px; }
|
||||
.research-two-column { display:grid; grid-template-columns:1fr 1fr; gap:12px; margin-bottom:12px; }
|
||||
.research-card { width:100%; margin-bottom:12px; padding:16px; border:1px solid #e1e6ef; border-radius:10px; background:#fff; }
|
||||
.research-two-column .research-card { margin-bottom:0; }
|
||||
.research-section-heading { margin-bottom:14px; }
|
||||
.research-section-heading > span { color:#295bcc; font-size:9px; font-weight:700; letter-spacing:.08em; }
|
||||
.research-section-heading h2 { margin:2px 0 0; color:#142641; font-size:15px; }
|
||||
.research-section-heading p { margin:5px 0 0; color:#8490a2; font-size:10px; line-height:1.5; }
|
||||
|
||||
/* Informasi dataset dan model */
|
||||
.dataset-information,.model-information,.evaluation-information { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
|
||||
.dataset-information div,.model-information div,.evaluation-information div { min-width:0; padding:10px 11px; border:1px solid #edf0f5; border-radius:7px; background:#f8f9fc; }
|
||||
.dataset-information span,.model-information span,.evaluation-information span { display:block; color:#909aaa; font-size:9.5px; }
|
||||
.dataset-information strong,.model-information strong,.evaluation-information strong { display:block; margin-top:3px; color:#293a53; font-size:11px; line-height:1.4; }
|
||||
|
||||
/* Pembagian data training dan testing */
|
||||
.split-visual { padding-top:7px; }
|
||||
.split-bar { display:flex; width:100%; height:32px; overflow:hidden; border-radius:7px; background:#edf1f6; }
|
||||
.split-bar span { display:grid; place-items:center; color:#fff; font-size:10px; font-weight:700; }
|
||||
.training-bar { width:80%; background:#295bcc; }
|
||||
.testing-bar { width:20%; background:#91a9d9; }
|
||||
.split-explanation { margin:14px 0 0; padding:10px 11px; border:1px solid #e6ebf2; border-radius:7px; background:#f8f9fc; color:#5f6e83; font-size:9.5px; line-height:1.6; }
|
||||
|
||||
/* Distribusi kelas dataset */
|
||||
.dataset-overview-grid { align-items:stretch; }
|
||||
.dataset-distribution-card { min-height:100%; }
|
||||
.dataset-class-list { display:flex; flex-direction:column; gap:16px; margin-top:4px; }
|
||||
.dataset-class-item { width:100%; }
|
||||
.dataset-class-heading { display:flex; align-items:center; justify-content:space-between; gap:16px; margin-bottom:7px; }
|
||||
.dataset-class-name { display:flex; align-items:center; gap:9px; min-width:0; }
|
||||
.dataset-class-name > div { min-width:0; }
|
||||
.dataset-class-name strong { display:block; color:#33445d; font-size:11px; font-weight:700; }
|
||||
.dataset-class-name small { display:block; margin-top:2px; color:#7b8799; font-size:9px; }
|
||||
.dataset-class-dot { width:8px; height:8px; flex:0 0 8px; border-radius:50%; }
|
||||
.dataset-class-dot.negative { background:#db4e58; }
|
||||
.dataset-class-dot.positive { background:#1f9d68; }
|
||||
.dataset-class-value { display:flex; align-items:baseline; gap:8px; flex:0 0 auto; }
|
||||
.dataset-class-value strong { font-size:17px; font-weight:700; }
|
||||
.dataset-class-value span { font-size:9.5px; font-weight:600; }
|
||||
.dataset-class-value.negative strong,.dataset-class-value.negative span { color:#c7424d; }
|
||||
.dataset-class-value.positive strong,.dataset-class-value.positive span { color:#168052; }
|
||||
.dataset-class-bar { width:100%; height:8px; overflow:hidden; border-radius:999px; background:#edf0f4; }
|
||||
.dataset-class-fill { display:block; height:100%; border-radius:inherit; }
|
||||
.dataset-class-fill.negative { background:#db4e58; }
|
||||
.dataset-class-fill.positive { background:#1f9d68; }
|
||||
.dataset-label-note { display:flex; flex-wrap:wrap; gap:7px; margin-top:15px; }
|
||||
.dataset-label-note span { padding:5px 8px; border:1px solid #dce4ef; border-radius:6px; background:#f7f9fc; color:#59687d; font-size:9px; font-weight:600; }
|
||||
.dataset-distribution-state { min-height:104px; display:grid; place-items:center; color:#7b8799; font-size:10px; }
|
||||
.dataset-distribution-warning { margin:10px 0 0; color:#b84650; font-size:9px; }
|
||||
|
||||
/* Alur penelitian */
|
||||
.research-flow { display:flex; align-items:center; width:100%; overflow-x:auto; }
|
||||
.flow-box { min-width:145px; padding:12px 10px; border:1px solid #dfe5ee; border-radius:8px; background:#f8fafd; text-align:center; }
|
||||
.flow-box strong { display:block; color:#293b55; font-size:10.5px; }
|
||||
.flow-box span { display:block; margin-top:3px; color:#8a95a7; font-size:8.8px; }
|
||||
.flow-arrow { padding:0 9px; color:#8da1c8; font-size:16px; }
|
||||
.research-note { display:grid; grid-template-columns:30px 1fr; gap:10px; padding:12px 14px; border:1px solid #dce5f4; border-radius:8px; background:#f2f6fc; }
|
||||
.research-note > span { width:25px; height:25px; display:grid; place-items:center; border-radius:50%; background:#fff; color:#295bcc; font-size:10px; font-weight:700; }
|
||||
.research-note strong { display:block; margin-bottom:2px; color:#34445d; font-size:10.5px; }
|
||||
.research-note p { margin:0; color:#748196; font-size:9.5px; line-height:1.55; }
|
||||
|
||||
/* Preprocessing */
|
||||
.preprocessing-toolbar { display:grid; grid-template-columns:1fr 180px; gap:10px; margin-bottom:14px; }
|
||||
.preprocessing-toolbar input,.preprocessing-toolbar select,.tfidf-research-toolbar input,.tfidf-research-toolbar select { width:100%; height:38px; padding:0 11px; border:1px solid #dfe5ee; border-radius:7px; outline:none; background:#fff; color:#44536a; font-size:10px; }
|
||||
.preprocessing-toolbar input:focus,.preprocessing-toolbar select:focus,.tfidf-research-toolbar input:focus,.tfidf-research-toolbar select:focus { border-color:#9bb7ef; box-shadow:0 0 0 3px rgba(41,91,204,.07); }
|
||||
.preprocessing-state { padding:30px 16px; text-align:center; color:#7f8a9c; font-size:10.5px; }
|
||||
.preprocessing-state.error { color:#c33f4a; }
|
||||
.preprocessing-method-grid { grid-template-columns:minmax(0,.78fr) minmax(0,1.22fr); align-items:stretch; }
|
||||
.preprocessing-method-grid .research-method-card { min-height:0; margin-bottom:0; }
|
||||
.stopword-method-card { min-width:0; }
|
||||
.research-preprocessing-data-list { display:flex; flex-direction:column; gap:8px; }
|
||||
.research-preprocessing-data-card { width:100%; padding:13px 14px; border:1px solid #e5eaf1; border-radius:8px; background:#fff; }
|
||||
.research-preprocessing-data-card:hover { border-color:#cbd9f1; background:#fcfdff; }
|
||||
.research-preprocessing-data-content { min-width:0; width:100%; }
|
||||
.research-preprocessing-data-header { display:flex; align-items:center; justify-content:space-between; gap:12px; }
|
||||
.research-preprocessing-data-header > strong { color:#2d3f59; font-size:11px; }
|
||||
.research-preprocessing-data-content > p { margin:9px 0 11px; color:#536177; font-size:10px; line-height:1.6; }
|
||||
.research-label-badge { display:inline-flex; padding:5px 9px; border-radius:999px; font-size:9px; font-weight:700; white-space:nowrap; }
|
||||
.research-label-badge.positive { background:#e6f5ef; color:#16845c; }
|
||||
.research-label-badge.negative { background:#fdebed; color:#d33f4d; }
|
||||
.research-preprocessing-card-footer { display:flex; align-items:flex-end; justify-content:space-between; gap:12px; padding-top:9px; border-top:1px solid #eef1f5; }
|
||||
.research-preprocessing-card-footer > div { min-width:0; flex:1; }
|
||||
.research-preprocessing-card-footer span { display:block; margin-bottom:4px; color:#929cab; font-size:8.5px; }
|
||||
.research-preprocessing-card-footer code { display:block; overflow:hidden; color:#5c6a80; font-size:9px; white-space:nowrap; text-overflow:ellipsis; }
|
||||
.research-preprocessing-card-footer button,.tfidf-card-footer button { padding:7px 10px; border:1px solid #cddbf2; border-radius:6px; background:#fff; color:#295bcc; font-size:9.5px; font-weight:600; cursor:pointer; }
|
||||
.research-preprocessing-card-footer button:hover,.tfidf-card-footer button:hover { border-color:#a9bfeb; background:#f3f7ff; }
|
||||
.normalization-list { display:flex; flex-direction:column; gap:7px; }
|
||||
.normalization-list div { display:grid; grid-template-columns:1fr 76px 1fr; align-items:center; min-height:48px; padding:9px 14px; border:1px solid #edf0f4; border-radius:7px; background:#fbfcfe; }
|
||||
.normalization-list code { justify-self:start; color:#c04751; font-size:9.5px; }
|
||||
.normalization-arrow { display:flex !important; align-items:center; justify-content:center; justify-self:center; width:76px; color:#99a4b5 !important; font-size:12px; transform:scaleX(1.45); }
|
||||
.normalization-list strong { justify-self:end; color:#25825b; font-size:9.5px; }
|
||||
.important-word-tags { display:flex; flex-wrap:wrap; gap:6px; }
|
||||
.important-word-tags span { padding:5px 8px; border:1px solid #d8e3f5; border-radius:999px; background:#f5f8ff; color:#355eaa; font-size:8.8px; font-weight:600; }
|
||||
.research-data-pagination { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-top:14px; padding-top:12px; border-top:1px solid #edf0f4; color:#8591a3; font-size:9.5px; }
|
||||
.research-data-pagination > div { display:flex; align-items:center; gap:9px; }
|
||||
.research-data-pagination button { width:31px; height:31px; border:1px solid #e0e5ed; border-radius:7px; background:#fff; color:#52627a; cursor:pointer; }
|
||||
.research-data-pagination button:disabled { opacity:.4; cursor:not-allowed; }
|
||||
.research-data-pagination strong { color:#34445c; font-size:9.5px; }
|
||||
|
||||
/* TF-IDF */
|
||||
.tfidf-research-toolbar { display:grid; grid-template-columns:minmax(260px,1fr) 160px 160px; gap:10px; margin-bottom:14px; }
|
||||
.tfidf-research-list { display:flex; flex-direction:column; gap:10px; }
|
||||
.tfidf-data-card { width:100%; padding:14px; border:1px solid #e3e8f0; border-radius:9px; background:#fff; }
|
||||
.tfidf-data-card:hover { border-color:#c9d7ef; background:#fcfdff; }
|
||||
.tfidf-data-header { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-bottom:10px; }
|
||||
.tfidf-data-title { color:#2d3f59; font-size:11px; }
|
||||
.tfidf-data-badges { display:flex; align-items:center; gap:6px; }
|
||||
.research-type-badge { display:inline-flex; padding:5px 9px; border-radius:999px; font-size:9px; font-weight:700; }
|
||||
.research-type-badge.training { border:1px solid #d4e0f5; background:#edf3ff; color:#295bcc; }
|
||||
.research-type-badge.testing { border:1px solid #dfe3eb; background:#f3f5f8; color:#657289; }
|
||||
.tfidf-stemming-preview { padding:10px 11px; border:1px solid #edf0f5; border-radius:7px; background:#f8f9fc; }
|
||||
.tfidf-stemming-preview > span { display:block; margin-bottom:5px; color:#909aaa; font-size:8.5px; }
|
||||
.tfidf-stemming-preview code { color:#4e5e74; font-size:9.5px; line-height:1.6; }
|
||||
.tfidf-weight-preview { margin-top:10px; padding:11px; border:1px solid #e9edf3; border-radius:8px; background:#fbfcfe; }
|
||||
.tfidf-weight-preview-title { display:flex; justify-content:space-between; gap:10px; margin-bottom:9px; }
|
||||
.tfidf-weight-preview-title span { color:#34445c; font-size:9.5px; font-weight:700; }
|
||||
.tfidf-weight-preview-title small { color:#929cab; font-size:8.5px; }
|
||||
.tfidf-weight-row { display:grid; grid-template-columns:110px minmax(100px,1fr) 68px; align-items:center; gap:10px; margin-bottom:7px; }
|
||||
.tfidf-weight-row code { overflow:hidden; color:#43536b; font-size:9px; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.tfidf-weight-row strong { color:#34455e; text-align:right; font-size:9px; }
|
||||
.tfidf-weight-bar { width:100%; height:7px; overflow:hidden; border-radius:999px; background:#e9eef6; }
|
||||
.tfidf-weight-bar span { display:block; height:100%; border-radius:inherit; background:#295bcc; }
|
||||
.tfidf-no-weight { padding:8px; color:#8b96a7; text-align:center; font-size:9px; }
|
||||
.tfidf-card-footer { display:flex; justify-content:flex-end; margin-top:10px; padding-top:10px; border-top:1px solid #edf0f4; }
|
||||
.tfidf-simple-method { padding:18px; }
|
||||
.tfidf-simple-flow { display:grid; grid-template-columns:1fr 44px 1fr 44px 1fr; align-items:center; max-width:900px; margin:22px auto; }
|
||||
.tfidf-simple-box { min-height:90px; display:flex; flex-direction:column; justify-content:center; padding:14px 18px; border:1px solid #dfe5ee; border-radius:9px; background:#f8fafd; text-align:center; }
|
||||
.tfidf-simple-box.highlight { border-color:#cad9f3; background:#eef4ff; }
|
||||
.tfidf-simple-box span { color:#8490a2; font-size:9.5px; }
|
||||
.tfidf-simple-box strong { display:block; margin-top:5px; color:#142641; font-size:17px; }
|
||||
.tfidf-simple-box.highlight strong { color:#295bcc; }
|
||||
.tfidf-simple-arrow { color:#89a0cc; text-align:center; font-size:20px; }
|
||||
.tfidf-simple-note { display:flex; align-items:flex-start; gap:9px; max-width:900px; margin:0 auto; padding:10px 12px; border:1px solid #e0e7f2; border-radius:7px; background:#f7f9fc; }
|
||||
.tfidf-simple-note > span { width:22px; height:22px; flex:0 0 22px; display:grid; place-items:center; border-radius:50%; background:#edf3ff; color:#295bcc; font-size:9px; font-weight:700; }
|
||||
.tfidf-simple-note p { margin:2px 0 0; color:#748196; font-size:9px; line-height:1.55; }
|
||||
|
||||
/* Modal preprocessing */
|
||||
.research-modal-overlay { position:fixed; inset:0; z-index:9999; display:flex; align-items:center; justify-content:center; padding:24px; background:rgba(20,38,65,.55); }
|
||||
.research-modal { width:min(820px,100%); max-height:88vh; overflow:hidden; border:1px solid #dce2eb; border-radius:16px; background:#fff; box-shadow:0 20px 60px rgba(20,38,65,.2); }
|
||||
.research-modal-scroll { max-height:88vh; overflow-y:auto; padding:18px; }
|
||||
.research-modal-header { display:flex; justify-content:space-between; gap:15px; padding-bottom:13px; border-bottom:1px solid #e7ebf1; }
|
||||
.research-modal-header h2 { margin:0; color:#142641; font-size:16px; }
|
||||
.research-modal-close { width:30px; height:30px; border:0; border-radius:6px; background:transparent; color:#68768a; font-size:21px; cursor:pointer; }
|
||||
.research-modal-close:hover { background:#f3f5f8; }
|
||||
.research-modal-original { margin-top:14px; padding:12px; border:1px solid #e3e8ef; border-radius:8px; background:#f7f9fc; }
|
||||
.research-modal-original-heading { display:flex; justify-content:space-between; }
|
||||
.research-modal-original-heading > span:first-child { color:#7f8998; font-size:9px; }
|
||||
.research-modal-original p { margin:7px 0 0; color:#34445c; font-size:10.5px; line-height:1.65; }
|
||||
.research-modal-steps { display:flex; flex-direction:column; gap:8px; margin-top:13px; }
|
||||
.research-modal-step { display:grid; grid-template-columns:32px 1fr; gap:10px; padding:11px; border:1px solid #e5e9ef; border-radius:8px; }
|
||||
.research-modal-step-number { width:28px; height:28px; display:grid; place-items:center; border-radius:50%; background:#edf3ff; color:#295bcc; font-size:9.5px; font-weight:700; }
|
||||
.research-modal-step strong { color:#33445d; font-size:10.5px; }
|
||||
.research-modal-step p { margin:5px 0 0; padding:8px; border-radius:6px; background:#f6f8fb; color:#56657a; font-family:monospace; font-size:9px; line-height:1.6; overflow-wrap:anywhere; }
|
||||
.research-modal-result { display:flex; justify-content:space-between; margin-top:13px; padding:12px; border:1px solid #d8e3f5; border-radius:8px; background:#f1f5fd; }
|
||||
.research-modal-result span { color:#708097; font-size:9.5px; }
|
||||
.research-modal-result strong { color:#295bcc; font-size:11px; }
|
||||
|
||||
/* Modal TF-IDF */
|
||||
.tfidf-detail-modal { width:min(980px,100%); }
|
||||
.tfidf-modal-summary { display:grid; grid-template-columns:repeat(4,1fr); gap:8px; margin-top:10px; }
|
||||
.tfidf-modal-summary > div { padding:9px 10px; border:1px solid #e8ecf2; border-radius:7px; background:#f8f9fc; }
|
||||
.tfidf-modal-summary span { display:block; color:#909aaa; font-size:8.5px; }
|
||||
.tfidf-modal-summary strong { display:block; margin-top:3px; color:#30415a; font-size:9.5px; }
|
||||
.tfidf-modal-stemming { margin-top:10px; padding:11px; border:1px solid #e4e9f0; border-radius:8px; background:#fbfcfe; }
|
||||
.tfidf-modal-stemming > span { display:block; margin-bottom:5px; color:#8793a4; font-size:9px; }
|
||||
.tfidf-modal-stemming code { color:#4e5e74; font-size:9px; line-height:1.6; }
|
||||
.tfidf-table-heading { margin:15px 0 8px; }
|
||||
.tfidf-table-heading h3 { margin:0; color:#142641; font-size:13px; }
|
||||
.tfidf-table-wrapper { width:100%; overflow-x:auto; border:1px solid #e2e7ef; border-radius:8px; }
|
||||
.tfidf-detail-table { width:100%; min-width:680px; border-collapse:collapse; }
|
||||
.tfidf-detail-table th { padding:9px 10px; background:#f5f7fa; color:#617087; text-align:left; font-size:8.5px; }
|
||||
.tfidf-detail-table td { padding:8px 10px; border-top:1px solid #edf0f4; color:#536177; font-size:9px; }
|
||||
.tfidf-detail-table td code { color:#295bcc; }
|
||||
.tfidf-bobot-cell { color:#142641 !important; font-weight:700; }
|
||||
.tfidf-empty-table { padding:20px !important; text-align:center; }
|
||||
.tfidf-modal-note { margin-top:12px; }
|
||||
|
||||
/* Model Logistic Regression */
|
||||
.model-top-grid { align-items:stretch; }
|
||||
.model-summary-card { display:flex; flex-direction:column; }
|
||||
.model-summary-card .model-information { margin-top:auto; }
|
||||
.model-information { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
|
||||
.model-information > div { padding:11px 12px; border:1px solid #e5eaf1; border-radius:7px; background:#f8f9fc; }
|
||||
.model-information span { display:block; color:#909aaa; font-size:9px; }
|
||||
.model-information strong { display:block; margin-top:4px; color:#293a53; font-size:11px; }
|
||||
.website-model-flow { display:flex; flex-direction:column; align-items:center; gap:4px; }
|
||||
.website-model-flow div { width:min(330px,100%); padding:8px 10px; border:1px solid #e2e7ef; border-radius:6px; background:#f8fafd; color:#40516a; text-align:center; font-size:9.5px; font-weight:600; }
|
||||
.website-model-flow div:last-child { border-color:#d2def4; background:#edf3ff; color:#295bcc; }
|
||||
.website-model-flow span { color:#9caac0; font-size:11px; }
|
||||
|
||||
/* Evaluasi dan metrik model */
|
||||
.metric-grid { display:grid; grid-template-columns:repeat(4,1fr); gap:8px; }
|
||||
.metric-card { padding:13px 12px; border:1px solid #e5eaf1; border-radius:8px; background:linear-gradient(180deg,#fff 0%,#f8faff 100%); transition:transform .2s ease,box-shadow .2s ease,border-color .2s ease; }
|
||||
.metric-card:hover { transform:translateY(-3px); border-color:#ccdaf1; box-shadow:0 7px 18px rgba(20,38,65,.06); }
|
||||
.metric-card span,.metric-card small { display:block; color:#8792a3; font-size:9px; }
|
||||
.metric-card strong { display:block; margin:5px 0; color:#142641; font-size:20px; }
|
||||
.model-evaluation-grid { align-items:stretch; }
|
||||
.model-evaluation-grid .research-card { min-width:0; }
|
||||
|
||||
/* Confusion matrix */
|
||||
.confusion-placeholder { text-align:center; }
|
||||
.confusion-axis-label { margin-bottom:7px; color:#8995a7; font-size:8.8px; }
|
||||
.confusion-grid { display:grid; grid-template-columns:115px 1fr 1fr; max-width:470px; margin:0 auto; overflow:hidden; border:1px solid #e3e8ef; border-radius:8px; }
|
||||
.confusion-grid > div { min-height:62px; display:grid; place-items:center; padding:7px; border-right:1px solid #e6eaf0; border-bottom:1px solid #e6eaf0; }
|
||||
.confusion-grid > div:nth-child(3n) { border-right:0; }
|
||||
.confusion-grid > div:nth-last-child(-n + 3) { border-bottom:0; }
|
||||
.confusion-empty,.confusion-head { background:#f5f7fa; }
|
||||
.confusion-head { color:#617087; font-size:9px; font-weight:600; }
|
||||
.confusion-value { font-weight:700; }
|
||||
.confusion-value.correct { background:#eaf7f0; color:#157852; }
|
||||
.confusion-value.error { background:#fdeff0; color:#c8434e; }
|
||||
.confusion-value strong { display:block; font-size:19px; }
|
||||
.confusion-value small { display:block; margin-top:2px; color:inherit; font-size:8.5px; opacity:.8; }
|
||||
.confusion-legend { display:flex; align-items:center; justify-content:center; flex-wrap:wrap; gap:14px; margin-top:10px; }
|
||||
.confusion-legend span { display:flex; align-items:center; gap:5px; color:#7d899b; font-size:8.5px; }
|
||||
.confusion-legend i { width:8px; height:8px; display:inline-block; border-radius:50%; }
|
||||
.confusion-legend i.correct { background:#39a778; }
|
||||
.confusion-legend i.error { background:#df6670; }
|
||||
|
||||
/* Evaluasi per kelas */
|
||||
.class-evaluation-card { min-width:0; }
|
||||
.class-metric-table-wrapper { width:100%; overflow-x:auto; border:1px solid #e3e8ef; border-radius:8px; }
|
||||
.class-metric-table { width:100%; min-width:520px; border-collapse:collapse; background:#fff; }
|
||||
.class-metric-table th { padding:10px 11px; border-bottom:1px solid #dfe5ed; background:#f5f7fa; color:#617087; text-align:left; font-size:8.5px; font-weight:700; }
|
||||
.class-metric-table td { padding:11px; border-bottom:1px solid #edf0f4; color:#46566d; font-size:9.5px; font-variant-numeric:tabular-nums; }
|
||||
.class-metric-table tbody tr:last-child td { border-bottom:0; }
|
||||
.class-metric-table tbody tr:hover { background:#fbfcfe; }
|
||||
.class-table-badge { display:inline-flex; padding:5px 8px; border-radius:999px; font-size:8.5px; font-weight:700; }
|
||||
.class-table-badge.negative { background:#fdebed; color:#c83f4a; }
|
||||
.class-table-badge.positive { background:#e6f5ef; color:#16845c; }
|
||||
.class-evaluation-note { display:grid; grid-template-columns:25px 1fr; gap:9px; margin-top:11px; padding:10px 11px; border:1px solid #e2e8f1; border-radius:7px; background:#f8fafd; }
|
||||
.class-evaluation-note > span { width:22px; height:22px; display:grid; place-items:center; border-radius:50%; background:#edf3ff; color:#295bcc; font-size:9px; font-weight:700; }
|
||||
.class-evaluation-note p { margin:2px 0 0; color:#718095; font-size:8.8px; line-height:1.55; }
|
||||
|
||||
/* Responsif bawaan */
|
||||
@media(max-width:900px) {
|
||||
.preprocessing-method-grid { grid-template-columns:1fr; }
|
||||
.tfidf-research-toolbar { grid-template-columns:1fr 1fr; }
|
||||
.tfidf-research-toolbar input { grid-column:1/-1; }
|
||||
.tfidf-simple-flow { grid-template-columns:1fr; gap:7px; }
|
||||
.tfidf-simple-arrow { transform:rotate(90deg); }
|
||||
}
|
||||
@media(max-width:800px) {
|
||||
.research-two-column { grid-template-columns:1fr; }
|
||||
.research-flow { flex-direction:column; }
|
||||
.flow-box { width:100%; }
|
||||
.flow-arrow { transform:rotate(90deg); }
|
||||
.preprocessing-toolbar { grid-template-columns:1fr; }
|
||||
}
|
||||
@media(max-width:700px) {
|
||||
.research-stat-grid,.research-stat-grid.three { grid-template-columns:1fr; }
|
||||
.metric-grid { grid-template-columns:1fr 1fr; }
|
||||
.tfidf-modal-summary { grid-template-columns:1fr 1fr; }
|
||||
.research-modal-overlay { padding:12px; }
|
||||
.model-information { grid-template-columns:1fr 1fr; }
|
||||
}
|
||||
@media(max-width:560px) {
|
||||
.tfidf-research-toolbar { grid-template-columns:1fr; }
|
||||
.tfidf-research-toolbar input { grid-column:auto; }
|
||||
.tfidf-data-header { align-items:flex-start; flex-direction:column; }
|
||||
.tfidf-modal-summary { grid-template-columns:1fr; }
|
||||
.model-information { grid-template-columns:1fr; }
|
||||
.confusion-grid { grid-template-columns:90px 1fr 1fr; }
|
||||
}
|
||||
@media(max-width:520px) {
|
||||
.metric-grid { grid-template-columns:1fr; }
|
||||
.tfidf-weight-row { grid-template-columns:70px 1fr; }
|
||||
.tfidf-weight-row strong { grid-column:2; text-align:left; }
|
||||
}
|
||||
|
||||
/* ================================================
|
||||
PERBAIKAN SPACING HALAMAN PENELITIAN
|
||||
================================================ */
|
||||
.research-page .research-heading { margin-bottom:22px; }
|
||||
.research-page .research-tabs { gap:6px; margin-bottom:22px; padding:6px; }
|
||||
.research-page .research-tabs button { min-height:42px; font-size:12px; }
|
||||
.research-page .research-stat-grid { gap:16px; margin-bottom:20px; }
|
||||
.research-page .research-stat-card { padding:19px 21px; border-radius:12px; }
|
||||
.research-page .research-stat-card span,
|
||||
.research-page .research-stat-card small { font-size:11px; }
|
||||
.research-page .research-stat-card strong { margin:6px 0; font-size:24px; }
|
||||
.research-page .research-two-column { gap:20px; margin-bottom:20px; }
|
||||
.research-page .research-card { margin-bottom:20px; padding:24px; border-radius:13px; }
|
||||
.research-page .research-two-column .research-card { margin-bottom:0; }
|
||||
.research-page .research-section-heading { margin-bottom:18px; }
|
||||
.research-page .research-section-heading h2 { font-size:17px; }
|
||||
.research-page .research-section-heading p { font-size:12px; }
|
||||
|
||||
/* Susunan alur penelitian - 7 tahap */
|
||||
.research-page .research-flow {
|
||||
display:grid;
|
||||
grid-template-columns:
|
||||
repeat(6,minmax(110px,1fr) 22px)
|
||||
minmax(110px,1fr);
|
||||
align-items:stretch;
|
||||
gap:0; width:100%;
|
||||
flex-wrap:nowrap;
|
||||
overflow-x:auto;
|
||||
padding:6px 0 12px;
|
||||
scrollbar-width:thin;
|
||||
}
|
||||
.research-page .research-flow .flow-box {
|
||||
display:flex; flex:none;
|
||||
flex-direction:column;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
box-sizing:border-box;
|
||||
min-width:0; min-height:100px;
|
||||
padding:16px 10px;
|
||||
border-radius:10px;
|
||||
}
|
||||
.research-page .research-flow .flow-box strong {
|
||||
font-size:12px;
|
||||
line-height:1.4;
|
||||
overflow-wrap:anywhere;
|
||||
}
|
||||
.research-page .research-flow .flow-box span {
|
||||
margin-top:6px; font-size:10px; line-height:1.5;
|
||||
}
|
||||
.research-page .research-flow .flow-arrow {
|
||||
display:grid;
|
||||
place-items:center;
|
||||
min-width:0; min-height:100px;
|
||||
padding:0; font-size:19px;
|
||||
transform:none;
|
||||
}
|
||||
|
||||
/* Tablet dan smartphone */
|
||||
@media(max-width:800px) {
|
||||
.research-page .research-flow {
|
||||
grid-template-columns:1fr;
|
||||
gap:6px;
|
||||
overflow-x:visible;
|
||||
}
|
||||
.research-page .research-flow .flow-box {
|
||||
min-height:80px;
|
||||
}
|
||||
.research-page .research-flow .flow-arrow {
|
||||
justify-self:center;
|
||||
align-self:center;
|
||||
width:24px;
|
||||
height:24px;
|
||||
min-height:0;
|
||||
transform:rotate(90deg);
|
||||
}
|
||||
}
|
||||
@media(max-width:620px) {
|
||||
.research-page .research-card { padding:18px; }
|
||||
.research-page .research-stat-grid.three { grid-template-columns:1fr; }
|
||||
}
|
||||
|
|
@ -0,0 +1,897 @@
|
|||
/* ============================================================
|
||||
PAGE
|
||||
============================================================ */
|
||||
|
||||
.prediksi-page {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
HEADING
|
||||
============================================================ */
|
||||
|
||||
.prediksi-heading {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.prediksi-heading h1 {
|
||||
margin: 0;
|
||||
color: #142641;
|
||||
font-size: 25px;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.prediksi-heading p {
|
||||
margin: 5px 0 0;
|
||||
color: #6b7485;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
GRID
|
||||
============================================================ */
|
||||
|
||||
.prediksi-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
CARD
|
||||
============================================================ */
|
||||
|
||||
.prediksi-card {
|
||||
min-height: 360px;
|
||||
padding: 18px;
|
||||
border: 1px solid #e1e6ef;
|
||||
border-radius: 10px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.prediksi-section-heading {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.prediksi-section-heading h2 {
|
||||
margin: 0;
|
||||
color: #142641;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
INPUT
|
||||
============================================================ */
|
||||
|
||||
.input-card textarea {
|
||||
width: 100%;
|
||||
min-height: 175px;
|
||||
padding: 12px;
|
||||
resize: vertical;
|
||||
|
||||
border: 1px solid #d9e0eb;
|
||||
border-radius: 8px;
|
||||
|
||||
background: #ffffff;
|
||||
color: #28384f;
|
||||
|
||||
font: inherit;
|
||||
font-size: 11.5px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.input-card textarea::placeholder {
|
||||
color: #a2aab7;
|
||||
}
|
||||
|
||||
.input-card textarea:focus {
|
||||
border-color: #295bcc;
|
||||
|
||||
outline:
|
||||
2px solid
|
||||
rgba(
|
||||
41,
|
||||
91,
|
||||
204,
|
||||
0.1
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
COUNTER
|
||||
============================================================ */
|
||||
|
||||
.input-information {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
|
||||
margin-top: 7px;
|
||||
|
||||
color: #758196;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
ERROR
|
||||
============================================================ */
|
||||
|
||||
.prediksi-error {
|
||||
margin-top: 10px;
|
||||
padding: 9px 10px;
|
||||
|
||||
border: 1px solid #f1c9cd;
|
||||
border-radius: 7px;
|
||||
|
||||
background: #fff5f6;
|
||||
color: #b63e49;
|
||||
|
||||
font-size: 10.5px;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
ACTION BUTTONS
|
||||
============================================================ */
|
||||
|
||||
.prediksi-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.prediksi-primary-button,
|
||||
.prediksi-secondary-button,
|
||||
.lihat-proses-button {
|
||||
padding: 9px 14px;
|
||||
|
||||
border-radius: 7px;
|
||||
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
transition:
|
||||
background 0.2s ease,
|
||||
border-color 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
}
|
||||
|
||||
.prediksi-primary-button {
|
||||
border: 1px solid #295bcc;
|
||||
background: #295bcc;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.prediksi-primary-button:hover:not(:disabled) {
|
||||
border-color: #214eaf;
|
||||
background: #214eaf;
|
||||
}
|
||||
|
||||
.prediksi-primary-button:active:not(:disabled) {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.prediksi-primary-button:disabled,
|
||||
.prediksi-secondary-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.prediksi-secondary-button {
|
||||
border: 1px solid #d9e0eb;
|
||||
background: #ffffff;
|
||||
color: #435169;
|
||||
}
|
||||
|
||||
.prediksi-secondary-button:hover:not(:disabled) {
|
||||
background: #f6f8fb;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
RESULT CARD
|
||||
============================================================ */
|
||||
|
||||
.result-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
EMPTY STATE
|
||||
============================================================ */
|
||||
|
||||
.empty-prediction {
|
||||
flex: 1;
|
||||
|
||||
display: grid;
|
||||
place-content: center;
|
||||
|
||||
max-width: 340px;
|
||||
margin: auto;
|
||||
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
|
||||
display: grid;
|
||||
place-items: center;
|
||||
|
||||
margin: 0 auto 11px;
|
||||
|
||||
border-radius: 9px;
|
||||
|
||||
background: #edf3ff;
|
||||
color: #295bcc;
|
||||
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.empty-prediction strong {
|
||||
color: #34445d;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.empty-prediction p {
|
||||
margin: 6px 0 0;
|
||||
|
||||
color: #748196;
|
||||
|
||||
font-size: 10.5px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
RESULT
|
||||
============================================================ */
|
||||
|
||||
.prediction-result {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
ANALYZED TEXT
|
||||
============================================================ */
|
||||
|
||||
.analyzed-text {
|
||||
padding: 12px 13px;
|
||||
|
||||
border: 1px solid #e6eaf1;
|
||||
border-radius: 8px;
|
||||
|
||||
background: #f8f9fc;
|
||||
}
|
||||
|
||||
.analyzed-text span {
|
||||
color: #69778c;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.analyzed-text p {
|
||||
margin: 5px 0 0;
|
||||
|
||||
color: #293a53;
|
||||
|
||||
font-size: 11.5px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
SENTIMENT BOX
|
||||
============================================================ */
|
||||
|
||||
.prediction-sentiment-box {
|
||||
margin-top: 12px;
|
||||
padding: 16px 14px;
|
||||
|
||||
border: 1px solid #e6eaf1;
|
||||
border-radius: 9px;
|
||||
|
||||
transition:
|
||||
background 0.2s ease,
|
||||
border-color 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.prediction-sentiment-box > span {
|
||||
display: block;
|
||||
|
||||
margin-bottom: 8px;
|
||||
|
||||
color: #59677b;
|
||||
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
POSITIVE STATE
|
||||
============================================================ */
|
||||
|
||||
.prediction-sentiment-box.positive-state {
|
||||
border-color: #addfc6;
|
||||
background: #f0faf5;
|
||||
|
||||
box-shadow:
|
||||
inset 4px 0 0
|
||||
#1f9d68;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
NEGATIVE STATE
|
||||
============================================================ */
|
||||
|
||||
.prediction-sentiment-box.negative-state {
|
||||
border-color: #efbdc2;
|
||||
background: #fff3f4;
|
||||
|
||||
box-shadow:
|
||||
inset 4px 0 0
|
||||
#db4e58;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
SENTIMENT BADGE
|
||||
============================================================ */
|
||||
|
||||
.sentiment-result-badge {
|
||||
width: fit-content;
|
||||
|
||||
display: inline-flex !important;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
margin-top: 0 !important;
|
||||
padding: 7px 13px;
|
||||
|
||||
border-radius: 999px;
|
||||
|
||||
font-size: 15px !important;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.prediction-positive {
|
||||
border: 1px solid #a8ddc2;
|
||||
|
||||
background: #dff4e9;
|
||||
|
||||
color: #13784d !important;
|
||||
}
|
||||
|
||||
.prediction-negative {
|
||||
border: 1px solid #edb6bc;
|
||||
|
||||
background: #fde1e4;
|
||||
|
||||
color: #bf3541 !important;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
LIHAT PROSES
|
||||
============================================================ */
|
||||
|
||||
.lihat-proses-button {
|
||||
align-self: flex-start;
|
||||
|
||||
margin-top: 14px;
|
||||
|
||||
border: 1px solid #ccd9ef;
|
||||
|
||||
background: #ffffff;
|
||||
color: #295bcc;
|
||||
}
|
||||
|
||||
.lihat-proses-button:hover {
|
||||
border-color: #abc0e5;
|
||||
background: #f4f7fd;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
PIPELINE INFO
|
||||
============================================================ */
|
||||
|
||||
.prediksi-info {
|
||||
display: grid;
|
||||
grid-template-columns: 30px 1fr;
|
||||
gap: 10px;
|
||||
|
||||
margin-top: 12px;
|
||||
padding: 13px 14px;
|
||||
|
||||
border: 1px solid #dce5f4;
|
||||
border-radius: 8px;
|
||||
|
||||
background: #f2f6fc;
|
||||
}
|
||||
|
||||
.prediksi-info-icon {
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
|
||||
display: grid;
|
||||
place-items: center;
|
||||
|
||||
border-radius: 50%;
|
||||
|
||||
background: #ffffff;
|
||||
color: #295bcc;
|
||||
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.prediksi-info strong {
|
||||
display: block;
|
||||
|
||||
margin-bottom: 3px;
|
||||
|
||||
color: #34445d;
|
||||
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.prediksi-info p {
|
||||
margin: 0;
|
||||
|
||||
color: #647287;
|
||||
|
||||
font-size: 10px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
MODAL OVERLAY
|
||||
============================================================ */
|
||||
|
||||
.prediksi-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
padding: 24px;
|
||||
|
||||
background:
|
||||
rgba(
|
||||
13,
|
||||
27,
|
||||
48,
|
||||
0.46
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
MODAL
|
||||
============================================================ */
|
||||
|
||||
.prediksi-modal {
|
||||
width: min(
|
||||
720px,
|
||||
100%
|
||||
);
|
||||
|
||||
max-height:
|
||||
calc(
|
||||
100vh - 48px
|
||||
);
|
||||
|
||||
padding: 18px;
|
||||
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
|
||||
border: 1px solid #e3e8f0;
|
||||
|
||||
/*
|
||||
Semua sudut modal tetap tumpul.
|
||||
*/
|
||||
border-radius: 14px;
|
||||
|
||||
background: #ffffff;
|
||||
|
||||
box-shadow:
|
||||
0 18px 55px
|
||||
rgba(
|
||||
15,
|
||||
31,
|
||||
55,
|
||||
0.2
|
||||
);
|
||||
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
SCROLLBAR MODAL
|
||||
============================================================ */
|
||||
|
||||
.prediksi-modal::-webkit-scrollbar {
|
||||
width: 9px;
|
||||
}
|
||||
|
||||
.prediksi-modal::-webkit-scrollbar-track {
|
||||
margin-block: 14px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.prediksi-modal::-webkit-scrollbar-thumb {
|
||||
border: 2px solid #ffffff;
|
||||
border-radius: 999px;
|
||||
background: #929ba7;
|
||||
}
|
||||
|
||||
.prediksi-modal::-webkit-scrollbar-thumb:hover {
|
||||
background: #737e8b;
|
||||
}
|
||||
|
||||
.prediksi-modal {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color:
|
||||
#929ba7
|
||||
transparent;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
HEADER MODAL
|
||||
============================================================ */
|
||||
|
||||
.prediksi-modal-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
|
||||
padding-bottom: 12px;
|
||||
|
||||
border-bottom:
|
||||
1px solid #e9edf3;
|
||||
}
|
||||
|
||||
.prediksi-modal-header span {
|
||||
color: #295bcc;
|
||||
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.prediksi-modal-header h2 {
|
||||
margin: 2px 0 0;
|
||||
|
||||
color: #142641;
|
||||
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.prediksi-modal-header button {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
|
||||
display: grid;
|
||||
place-items: center;
|
||||
|
||||
padding: 0;
|
||||
|
||||
border: 0;
|
||||
border-radius: 7px;
|
||||
|
||||
background: transparent;
|
||||
color: #69758a;
|
||||
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
transition:
|
||||
background 0.2s ease,
|
||||
color 0.2s ease;
|
||||
}
|
||||
|
||||
.prediksi-modal-header button:hover {
|
||||
background: #f2f5f9;
|
||||
color: #142641;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
ORIGINAL REVIEW
|
||||
============================================================ */
|
||||
|
||||
.prediksi-original {
|
||||
margin: 13px 0;
|
||||
padding: 11px;
|
||||
|
||||
border: 1px solid #e8edf4;
|
||||
border-radius: 10px;
|
||||
|
||||
background: #f5f8fd;
|
||||
}
|
||||
|
||||
.prediksi-original span {
|
||||
color: #66758b;
|
||||
font-size: 9.5px;
|
||||
}
|
||||
|
||||
.prediksi-original p {
|
||||
margin: 4px 0 0;
|
||||
|
||||
color: #273952;
|
||||
|
||||
font-size: 11px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
PREPROCESSING STEPS
|
||||
============================================================ */
|
||||
|
||||
.prediksi-step-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.prediksi-step {
|
||||
display: grid;
|
||||
grid-template-columns: 24px 1fr;
|
||||
gap: 8px;
|
||||
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.prediksi-step > span {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
|
||||
display: grid;
|
||||
place-items: center;
|
||||
|
||||
border-radius: 50%;
|
||||
|
||||
background: #edf3ff;
|
||||
color: #295bcc;
|
||||
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.prediksi-step > div {
|
||||
padding: 9px 11px;
|
||||
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.prediksi-step strong {
|
||||
display: block;
|
||||
|
||||
color: #36465e;
|
||||
|
||||
font-size: 9.5px;
|
||||
}
|
||||
|
||||
.prediksi-step p {
|
||||
margin: 3px 0 0;
|
||||
|
||||
color: #69778d;
|
||||
|
||||
font-size: 9.5px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
TF-IDF
|
||||
============================================================ */
|
||||
|
||||
.prediksi-tfidf {
|
||||
margin-top: 13px;
|
||||
padding: 12px;
|
||||
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.prediksi-modal-subheading h3 {
|
||||
margin: 0 0 11px;
|
||||
|
||||
color: #36465e;
|
||||
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.prediksi-tfidf-row {
|
||||
display: grid;
|
||||
grid-template-columns:
|
||||
65px
|
||||
1fr
|
||||
65px;
|
||||
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
|
||||
margin: 7px 0;
|
||||
|
||||
font-size: 9.5px;
|
||||
}
|
||||
|
||||
.prediksi-tfidf-row > span {
|
||||
color: #36465e;
|
||||
}
|
||||
|
||||
.prediksi-tfidf-row > div {
|
||||
height: 6px;
|
||||
|
||||
overflow: hidden;
|
||||
|
||||
border-radius: 999px;
|
||||
|
||||
background: #edf0f4;
|
||||
}
|
||||
|
||||
.prediksi-tfidf-row i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
|
||||
border-radius: inherit;
|
||||
|
||||
background: #295bcc;
|
||||
}
|
||||
|
||||
.prediksi-tfidf-row em {
|
||||
color: #6f7c90;
|
||||
|
||||
text-align: right;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.prediksi-no-tfidf {
|
||||
margin: 0;
|
||||
|
||||
color: #748196;
|
||||
|
||||
font-size: 9.5px;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
HASIL MODAL
|
||||
============================================================ */
|
||||
|
||||
.prediksi-modal-result {
|
||||
margin-top: 12px;
|
||||
padding: 12px;
|
||||
|
||||
border: 1px solid #e8edf4;
|
||||
border-radius: 10px;
|
||||
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
background 0.2s ease;
|
||||
}
|
||||
|
||||
.prediksi-modal-result.positive-state {
|
||||
border-color: #addfc6;
|
||||
background: #f0faf5;
|
||||
|
||||
box-shadow:
|
||||
inset 4px 0 0
|
||||
#1f9d68;
|
||||
}
|
||||
|
||||
.prediksi-modal-result.negative-state {
|
||||
border-color: #efbdc2;
|
||||
background: #fff3f4;
|
||||
|
||||
box-shadow:
|
||||
inset 4px 0 0
|
||||
#db4e58;
|
||||
}
|
||||
|
||||
.prediksi-modal-result > span {
|
||||
display: block;
|
||||
|
||||
margin-bottom: 7px;
|
||||
|
||||
color: #59677b;
|
||||
|
||||
font-size: 9.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
RESPONSIVE
|
||||
============================================================ */
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.prediksi-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.prediksi-card {
|
||||
min-height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.prediksi-heading h1 {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.prediksi-overlay {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.prediksi-modal {
|
||||
width: 100%;
|
||||
|
||||
max-height:
|
||||
calc(
|
||||
100vh - 24px
|
||||
);
|
||||
|
||||
border-radius: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.prediksi-actions {
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
.prediksi-primary-button,
|
||||
.prediksi-secondary-button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.prediksi-tfidf-row {
|
||||
grid-template-columns:
|
||||
55px
|
||||
1fr
|
||||
58px;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,413 @@
|
|||
.models-page { max-width: 1200px; margin: 0 auto; padding: 24px; color: #172033; }
|
||||
.models-page h1 { font-size: 29px; margin: 8px 0; }
|
||||
.models-page h2 { font-size: 19px; margin: 0 0 14px; }
|
||||
.models-page p { line-height: 1.65; color: #566177; }
|
||||
.models-eyebrow { color: #295bcc; font-size: 12px; font-weight: 700; letter-spacing: 1.4px; }
|
||||
.models-card { padding: 24px; background: white; border: 1px solid #e3e8f0; border-radius: 14px; margin: 22px 0; }
|
||||
.models-summary { display: grid; grid-template-columns: repeat(4, 1fr); gap: 18px; background: #142641; color: white; border-radius: 14px; padding: 25px; margin-top: 25px; }
|
||||
.models-summary small { display: block; color: #c4d0e6; margin-bottom: 10px; }
|
||||
.models-summary strong { display: flex; align-items: center; gap: 12px; font-size: 25px; }
|
||||
.model-badge { font-size: 12px; border-radius: 20px; color: #116247; background: #d8f6e9; padding: 5px 10px; }
|
||||
.models-page button { cursor: pointer; border: 1px solid #cbd5e5; padding: 10px 14px; border-radius: 8px; background: white; color: #234576; font: inherit; font-size: 14px; }
|
||||
.models-page button.models-primary { background: #295bcc; border-color: #295bcc; color: white; }
|
||||
.models-page button:disabled { opacity: .55; cursor: not-allowed; }
|
||||
.models-page button:focus-visible, .models-page input:focus-visible, .models-page select:focus-visible { outline: 3px solid #8db2ff; outline-offset: 3px; }
|
||||
.models-page label { display: block; font-weight: 600; margin: 16px 0 9px; }
|
||||
.models-page input, .models-page select { display: block; width: 100%; max-width: 650px; padding: 13px; border: 1px solid #cbd5e5; border-radius: 8px; background: #f8faff; font: inherit; }
|
||||
.models-page label small { color: #6b7485; font-weight: 400; }
|
||||
.models-help { font-size: 13px; padding: 12px 15px; border-left: 3px solid #9bb6e9; background: #f5f8ff; }
|
||||
.models-preview { padding: 15px 15px 15px 35px; background: #f5f7fb; border-radius: 8px; max-height: 160px; overflow: auto; }
|
||||
.models-preview li { margin: 8px 0; overflow-wrap: anywhere; }
|
||||
.models-table { overflow-x: auto; }
|
||||
.models-page table { border-collapse: collapse; width: 100%; font-size: 14px; }
|
||||
.models-page th, .models-page td { padding: 14px 12px; border-bottom: 1px solid #e3e8f0; text-align: left; }
|
||||
.models-page th { background: #f5f7fb; }
|
||||
.models-page td small { display: block; margin-top: 6px; color: #6b7485; }
|
||||
.models-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||
.models-actions h2 { margin: 0 auto 0 0; }
|
||||
.models-job { background: #f5f7fb; padding: 16px; border-radius: 8px; margin-bottom: 14px; }
|
||||
.models-job p { margin-bottom: 0; }
|
||||
.models-page details li { margin-bottom: 10px; }
|
||||
.models-error, .models-notice { border-radius: 8px; padding: 14px 18px; margin: 16px 0; line-height: 1.5; }
|
||||
.models-error { background: #fff0ee; color: #99392c; }
|
||||
.models-notice { background: #eaf2ff; color: #234576; }
|
||||
.models-overlay { position: fixed; inset: 0; background: #14264188; display: grid; place-items: center; z-index: 100; padding: 20px; }
|
||||
.models-dialog { background: white; border-radius: 14px; padding: 28px; max-width: 460px; }
|
||||
@media (max-width: 800px) { .models-summary { grid-template-columns: repeat(2, 1fr); } .models-page { padding: 12px; } .models-card { padding: 17px; } }
|
||||
|
||||
/* =====================================================
|
||||
PERBAIKAN UI TRAINING & RIWAYAT MODEL
|
||||
Fokus: data dilewati dan riwayat proses
|
||||
===================================================== */
|
||||
|
||||
/* STATUS TRAINING TERAKHIR */
|
||||
|
||||
.models-page .models-training-status {
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.models-page .models-training-status > h2 {
|
||||
margin: 0 0 20px;
|
||||
color: #142641;
|
||||
font-size: 21px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.models-page .models-training-status .models-job {
|
||||
margin: 0;
|
||||
padding: 22px;
|
||||
border: 1px solid #e1e8f2;
|
||||
border-radius: 12px;
|
||||
background: #f8faff;
|
||||
}
|
||||
|
||||
.models-job-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 13px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 13px;
|
||||
}
|
||||
|
||||
.models-job-file {
|
||||
color: #142641;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.models-page .models-job-message {
|
||||
margin: 0 0 16px;
|
||||
color: #53637a;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
/* BADGE STATUS */
|
||||
|
||||
.models-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: fit-content;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.models-status-completed {
|
||||
color: #187650;
|
||||
background: #e9f7ef;
|
||||
border-color: #c8e9d7;
|
||||
}
|
||||
|
||||
.models-status-failed {
|
||||
color: #bf4551;
|
||||
background: #fff0f1;
|
||||
border-color: #f5d2d6;
|
||||
}
|
||||
|
||||
.models-status-skipped {
|
||||
color: #64748b;
|
||||
background: #eef2f7;
|
||||
border-color: #dce3ec;
|
||||
}
|
||||
|
||||
.models-status-running {
|
||||
color: #295bcc;
|
||||
background: #edf3ff;
|
||||
border-color: #d4e1fa;
|
||||
}
|
||||
|
||||
.models-status-queued {
|
||||
color: #936312;
|
||||
background: #fff7e5;
|
||||
border-color: #f4e4bf;
|
||||
}
|
||||
|
||||
/* RINGKASAN DATA AUDIT */
|
||||
|
||||
.models-audit {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.models-page .models-audit > p {
|
||||
margin: 8px 0;
|
||||
color: #586981;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.models-audit > p b {
|
||||
color: #243b5d;
|
||||
}
|
||||
|
||||
/* DATA YANG DILEWATI */
|
||||
|
||||
.models-page .models-skipped-details {
|
||||
margin-top: 20px;
|
||||
padding: 18px 20px;
|
||||
border: 1px solid #dfe7f3;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.models-page .models-skipped-details > summary {
|
||||
padding: 2px 0;
|
||||
color: #253b5c;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
line-height: 1.5;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.models-page .models-skipped-details > summary:hover {
|
||||
color: #295bcc;
|
||||
}
|
||||
|
||||
.models-page .models-skip-note {
|
||||
margin: 17px 0 14px;
|
||||
padding: 12px 15px;
|
||||
border: 1px solid #e2eaf5;
|
||||
border-radius: 9px;
|
||||
background: #f7faff;
|
||||
color: #65748b;
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
/* Area tabel dengan scroll */
|
||||
|
||||
.models-page .models-skipped-scroll {
|
||||
max-height: 355px;
|
||||
overflow: auto;
|
||||
border: 1px solid #e1e7f0;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #bdcbe0 #f4f7fb;
|
||||
}
|
||||
|
||||
/* Tabel data dilewati */
|
||||
|
||||
.models-page .models-skipped-table {
|
||||
width: 100%;
|
||||
min-width: 720px;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
table-layout: fixed;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.models-page .models-skipped-table th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
padding: 15px 17px;
|
||||
border-bottom: 1px solid #dce5f1;
|
||||
background: #edf3fc;
|
||||
color: #294263;
|
||||
text-align: left;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.models-page .models-skipped-table th:first-child {
|
||||
width: 110px;
|
||||
}
|
||||
|
||||
.models-page .models-skipped-table th:nth-child(2) {
|
||||
width: 48%;
|
||||
}
|
||||
|
||||
.models-page .models-skipped-table td {
|
||||
padding: 15px 17px;
|
||||
border-bottom: 1px solid #e8edf4;
|
||||
background: #fff;
|
||||
color: #35455e;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.65;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.models-page .models-skipped-table tr:nth-child(even) td {
|
||||
background: #fbfcff;
|
||||
}
|
||||
|
||||
.models-page .models-skipped-table tbody tr:hover td {
|
||||
background: #f3f7ff;
|
||||
}
|
||||
|
||||
.models-page .models-skipped-table tbody tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.models-skipped-text {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* Badge alasan dilewati */
|
||||
|
||||
.models-skip-reason {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
padding: 8px 11px;
|
||||
border: 1px solid #e0e6ef;
|
||||
border-radius: 8px;
|
||||
background: #f3f5f9;
|
||||
color: #586780;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 1.5;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* RIWAYAT PROSES */
|
||||
|
||||
.models-page .models-history-details {
|
||||
margin-top: 24px;
|
||||
padding: 19px 20px;
|
||||
border: 1px solid #e0e7f1;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.models-page .models-history-details > summary {
|
||||
color: #142641;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.models-history-details > summary span:first-child {
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.models-history-count {
|
||||
display: inline-flex;
|
||||
padding: 4px 9px;
|
||||
border-radius: 6px;
|
||||
background: #edf3ff;
|
||||
color: #295bcc;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.models-history-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
max-height: 510px;
|
||||
margin-top: 20px;
|
||||
padding: 3px 8px 3px 0;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #bfcee2 #f4f7fb;
|
||||
}
|
||||
|
||||
/* Card setiap riwayat */
|
||||
|
||||
.models-history-item {
|
||||
min-width: 0;
|
||||
padding: 18px 20px;
|
||||
border: 1px solid #e1e7f0;
|
||||
border-radius: 11px;
|
||||
background: #fcfdff;
|
||||
transition: border-color 0.2s ease,
|
||||
background 0.2s ease;
|
||||
}
|
||||
|
||||
.models-history-item:hover {
|
||||
border-color: #c4d6f2;
|
||||
background: #f8fbff;
|
||||
}
|
||||
|
||||
.models-history-item-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-bottom: 9px;
|
||||
}
|
||||
|
||||
.models-history-filename {
|
||||
min-width: 0;
|
||||
color: #213855;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.models-history-date {
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
color: #8793a5;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.models-page .models-history-message {
|
||||
margin: 0;
|
||||
color: #54647b;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.65;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* RESPONSIVE */
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.models-page .models-training-status {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.models-page .models-training-status .models-job {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.models-page .models-skipped-details {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.models-page .models-history-details {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.models-history-item {
|
||||
padding: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.models-job-heading,
|
||||
.models-history-item-head {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.models-page .models-history-details > summary {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.models-page .models-skipped-table {
|
||||
min-width: 650px;
|
||||
}
|
||||
|
||||
.models-history-list {
|
||||
max-height: 450px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.models-history-item {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import react from '@vitejs/plugin-react'
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||
Loading…
Reference in New Issue