Add S-Learn frontend backend and model

This commit is contained in:
tanskwacu 2026-11-03 11:37:39 +07:00
parent 3dcf524104
commit 7de0f819e2
73 changed files with 26544 additions and 0 deletions

16
.gitignore vendored Normal file
View File

@ -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/

View File

@ -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.

View File

@ -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"
}
}

File diff suppressed because it is too large Load Diff

View File

@ -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])

View File

@ -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.

1021
api_slearn/database.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -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."
)

487
api_slearn/main.py Normal file
View File

@ -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)}"
)
)

View File

@ -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()

View File

@ -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))

274
api_slearn/model_service.py Normal file
View File

@ -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"]
}

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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.

193
api_slearn/preprocessing.py Normal file
View File

@ -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
}

View File

@ -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))

View File

@ -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

View File

@ -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()

View File

@ -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()

24
web_slearn/.gitignore vendored Normal file
View File

@ -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?

16
web_slearn/README.md Normal file
View File

@ -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

View File

@ -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 } },
},
},
])

13
web_slearn/index.html Normal file
View File

@ -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>

2512
web_slearn/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

29
web_slearn/package.json Normal file
View File

@ -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

View File

@ -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

184
web_slearn/src/App.css Normal file
View File

@ -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);
}
}

27
web_slearn/src/App.jsx Normal file
View File

@ -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

View File

@ -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

View File

@ -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>;
}

View File

@ -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>;
}

View File

@ -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>;
}

View File

@ -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;

View File

@ -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>
);
}

18
web_slearn/src/index.css Normal file
View File

@ -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;
}

View File

@ -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;

13
web_slearn/src/main.jsx Normal file
View File

@ -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>
);

View File

@ -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;

View File

@ -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 &amp; 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

View File

@ -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

View File

@ -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 &amp; 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 &amp; 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 &amp; 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>
);
}

View File

@ -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);
}

View File

@ -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%;
}
}

View File

@ -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; }
}

View File

@ -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

View File

@ -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;
}
}

View File

@ -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; }
}

View File

@ -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

View File

@ -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;
}
}

View File

@ -0,0 +1,7 @@
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})