365 lines
13 KiB
Python
365 lines
13 KiB
Python
"""
|
|
Router untuk Clustering Endpoint
|
|
"""
|
|
|
|
import math
|
|
from datetime import datetime
|
|
from fastapi import APIRouter, UploadFile, File, HTTPException, Depends, Query, Body
|
|
from fastapi.responses import JSONResponse, StreamingResponse
|
|
from typing import List, Optional, Dict, Any
|
|
from sqlalchemy.orm import Session
|
|
from sqlalchemy import or_
|
|
import pandas as pd
|
|
import io
|
|
|
|
from app.config.database import get_db
|
|
from app.models.models import ClusteringResult, ModelEvaluation
|
|
from app.models.alumni import Alumni
|
|
from app.schemas.clustering import (
|
|
ClusteringRequest,
|
|
ClusteringResponse,
|
|
ClusterResult,
|
|
AlumniInput,
|
|
StandardizeRequest,
|
|
StandardizeResponse
|
|
)
|
|
from app.services.ml_service import get_ml_service
|
|
from app.utils.standardization import ColumnStandardizer, merge_datasets, clean_data
|
|
|
|
router = APIRouter(tags=["Clustering ML"])
|
|
|
|
|
|
@router.post("/process", summary="Jalankan Pipeline Clustering dari Database MySQL")
|
|
@router.post("/run-db", summary="Alias Jalankan Pipeline Clustering dari Database")
|
|
def process_clustering_db(
|
|
tahun_lulus: Optional[int] = Query(None, description="Filter tahun kelulusan / angkatan (opsional)"),
|
|
n_clusters: int = Query(4, ge=2, le=10, description="Jumlah cluster K-Means"),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""
|
|
Menjalankan proses Machine Learning Clustering (TF-IDF + K-Means K=4 + Interpretasi C0-C3)
|
|
langsung dari data alumni & pekerjaan yang ada di database MySQL.
|
|
Hasil otomatis disimpan ke tabel clustering_result dan model_evaluation.
|
|
"""
|
|
ml_service = get_ml_service()
|
|
result = ml_service.process_clustering_from_db(
|
|
db=db,
|
|
tahun_lulus=tahun_lulus,
|
|
n_clusters=n_clusters
|
|
)
|
|
|
|
if result.get("status") == "error":
|
|
raise HTTPException(status_code=400, detail=result.get("message"))
|
|
|
|
return result
|
|
|
|
|
|
@router.get("/results", summary="Ambil Hasil Clustering dengan Filter & Pagination")
|
|
def get_clustering_results(
|
|
skip: int = Query(0, ge=0),
|
|
limit: int = Query(10, ge=1, le=500),
|
|
search: Optional[str] = Query(None, description="Cari nama atau NIM"),
|
|
kategori: Optional[str] = Query(None, description="Filter kategori C0, C1, C2, C3"),
|
|
kode_prodi: Optional[str] = Query(None, description="Filter kode prodi"),
|
|
tahun_lulus: Optional[int] = Query(None, description="Filter tahun lulus"),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""
|
|
Mengambil data hasil clustering alumni dari database lengkap dengan filter kategori C0-C3,
|
|
prodi, tahun, dan pencarian nama/NIM.
|
|
"""
|
|
from app.models.models import Pekerjaan
|
|
|
|
query = db.query(
|
|
ClusteringResult,
|
|
Alumni.nim,
|
|
Alumni.nama,
|
|
Alumni.kode_prodi,
|
|
Alumni.tahun_lulus,
|
|
Alumni.pekerjaan,
|
|
Alumni.instansi,
|
|
Alumni.status_kerja,
|
|
Pekerjaan.status_pekerjaan,
|
|
Pekerjaan.jenis_perusahaan,
|
|
Pekerjaan.nama_perusahaan,
|
|
Pekerjaan.jabatan,
|
|
Pekerjaan.teks_pekerjaan
|
|
).join(Alumni, ClusteringResult.alumni_id == Alumni.id)\
|
|
.outerjoin(Pekerjaan, Alumni.id == Pekerjaan.alumni_id)
|
|
|
|
if search:
|
|
s = f"%{search.strip()}%"
|
|
query = query.filter(or_(Alumni.nama.ilike(s), Alumni.nim.ilike(s), Alumni.pekerjaan.ilike(s), Pekerjaan.teks_pekerjaan.ilike(s)))
|
|
|
|
if kategori:
|
|
query = query.filter(ClusteringResult.kategori == kategori.strip().upper())
|
|
|
|
if kode_prodi:
|
|
query = query.filter(Alumni.kode_prodi == str(kode_prodi).strip())
|
|
|
|
if tahun_lulus:
|
|
query = query.filter(Alumni.tahun_lulus == int(tahun_lulus))
|
|
|
|
total = query.count()
|
|
rows = query.order_by(ClusteringResult.id.desc()).offset(skip).limit(limit).all()
|
|
|
|
items = []
|
|
for r in rows:
|
|
cr = r[0]
|
|
pek_nama = r[10] if (r[10] and r[10].strip() not in ['', 'nan', 'None']) else (r[5] or "-")
|
|
pek_jab = r[11] if (r[11] and r[11].strip() not in ['', 'nan', 'None']) else (r[6] or "-")
|
|
pek_status = r[8] if (r[8] and r[8].strip() not in ['', 'nan', 'None']) else (r[7] or "-")
|
|
pek_jenis = r[9] if (r[9] and r[9].strip() not in ['', 'nan', 'None']) else "-"
|
|
|
|
pek_teks = r[12] if (r[12] and r[12].strip() not in ['', 'nan', 'None']) else f"{pek_status} {pek_jenis} {pek_nama} {pek_jab}".replace('-', '').strip()
|
|
if not pek_teks:
|
|
pek_teks = "-"
|
|
|
|
items.append({
|
|
"id": cr.id,
|
|
"alumni_id": cr.alumni_id,
|
|
"nim": r[1],
|
|
"nama": r[2],
|
|
"kode_prodi": r[3],
|
|
"tahun_lulus": r[4],
|
|
"status_pekerjaan": pek_status,
|
|
"jenis_perusahaan": pek_jenis,
|
|
"nama_perusahaan": pek_nama,
|
|
"jabatan": pek_jab,
|
|
"teks_pekerjaan": pek_teks,
|
|
"cluster": cr.cluster,
|
|
"kategori": cr.kategori,
|
|
"profile_match": cr.profile_match,
|
|
"matched_keyword": cr.matched_keyword or "",
|
|
"model_version": cr.model_version,
|
|
"created_at": cr.created_at
|
|
})
|
|
|
|
page = (skip // limit) + 1 if limit > 0 else 1
|
|
total_pages = math.ceil(total / limit) if limit > 0 else 1
|
|
|
|
return {
|
|
"success": True,
|
|
"data": {
|
|
"items": items,
|
|
"total": total,
|
|
"page": page,
|
|
"limit": limit,
|
|
"total_pages": total_pages
|
|
}
|
|
}
|
|
|
|
|
|
@router.get("/summary", summary="Ringkasan Statistik Hasil Clustering Terkini")
|
|
def get_clustering_summary(
|
|
tahun_lulus: Optional[int] = Query(None, description="Filter tahun kelulusan / angkatan (opsional)"),
|
|
kode_prodi: Optional[str] = Query(None, description="Filter kode prodi (opsional)"),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Mengambil ringkasan statistik distribusi C0-C3 dan evaluasi model terkini."""
|
|
query = db.query(ClusteringResult).join(Alumni, Alumni.id == ClusteringResult.alumni_id)
|
|
if tahun_lulus:
|
|
query = query.filter(Alumni.tahun_lulus == int(tahun_lulus))
|
|
if kode_prodi:
|
|
query = query.filter(Alumni.kode_prodi == str(kode_prodi).strip())
|
|
|
|
from sqlalchemy import func
|
|
|
|
# Eksekusi 1 single query agregasi GROUP BY kategori
|
|
cat_counts = query.with_entities(
|
|
ClusteringResult.kategori,
|
|
func.count(ClusteringResult.id)
|
|
).group_by(ClusteringResult.kategori).all()
|
|
|
|
dist = {'C0': 0, 'C1': 0, 'C2': 0, 'C3': 0}
|
|
total = 0
|
|
for kat, cnt in cat_counts:
|
|
k_str = str(kat).upper().strip() if kat else 'C3'
|
|
if k_str in dist:
|
|
dist[k_str] = int(cnt)
|
|
total += int(cnt)
|
|
|
|
latest_eval = db.query(ModelEvaluation).order_by(ModelEvaluation.created_at.desc()).first()
|
|
|
|
return {
|
|
"total_clustered": total,
|
|
"distribution": dist,
|
|
"silhouette_score": latest_eval.silhouette_score if latest_eval else None,
|
|
"model_version": latest_eval.model_version if latest_eval else None,
|
|
"k": latest_eval.k if latest_eval else 4,
|
|
"tahun_lulus": tahun_lulus,
|
|
"kode_prodi": kode_prodi
|
|
}
|
|
|
|
|
|
@router.get("/iteration-details", summary="Ambil Detail Riwayat Iterasi & Perhitungan Matematis K-Means")
|
|
def get_iteration_details(
|
|
tahun_lulus: Optional[int] = Query(None, description="Filter tahun kelulusan / angkatan (opsional)"),
|
|
alumni_id: Optional[int] = Query(None, description="ID alumni spesifik untuk inspeksi perhitungan detail"),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""
|
|
Mengambil log lengkap iterasi perulangan K-Means, karakteristik centroid,
|
|
dan alur transformasi 5 tahap (Kalimat -> Preprocessing -> TF-IDF -> K-Means -> Klaster)
|
|
berdasarkan dataset tahun kelulusan yang dipilih.
|
|
"""
|
|
ml_service = get_ml_service()
|
|
details = ml_service.get_latest_iteration_details(db=db, tahun_lulus=tahun_lulus, target_alumni_id=alumni_id)
|
|
return {
|
|
"success": True,
|
|
"data": details
|
|
}
|
|
|
|
|
|
def _build_export_dataframe(
|
|
db: Session,
|
|
search: Optional[str] = None,
|
|
kategori: Optional[str] = None,
|
|
kode_prodi: Optional[str] = None,
|
|
tahun_lulus: Optional[int] = None
|
|
) -> pd.DataFrame:
|
|
from app.models.models import Pekerjaan
|
|
|
|
query = db.query(
|
|
ClusteringResult,
|
|
Alumni.nim,
|
|
Alumni.nama,
|
|
Alumni.kode_prodi,
|
|
Alumni.tahun_lulus,
|
|
Alumni.pekerjaan,
|
|
Alumni.instansi,
|
|
Alumni.status_kerja,
|
|
Pekerjaan.status_pekerjaan,
|
|
Pekerjaan.jenis_perusahaan,
|
|
Pekerjaan.nama_perusahaan,
|
|
Pekerjaan.jabatan,
|
|
Pekerjaan.teks_pekerjaan
|
|
).join(Alumni, ClusteringResult.alumni_id == Alumni.id)\
|
|
.outerjoin(Pekerjaan, Alumni.id == Pekerjaan.alumni_id)
|
|
|
|
if search:
|
|
s = f"%{search.strip()}%"
|
|
query = query.filter(or_(
|
|
Alumni.nama.ilike(s),
|
|
Alumni.nim.ilike(s),
|
|
Alumni.pekerjaan.ilike(s),
|
|
Pekerjaan.teks_pekerjaan.ilike(s)
|
|
))
|
|
|
|
if kategori:
|
|
query = query.filter(ClusteringResult.kategori == kategori.strip().upper())
|
|
|
|
if kode_prodi:
|
|
query = query.filter(Alumni.kode_prodi == str(kode_prodi).strip())
|
|
|
|
if tahun_lulus:
|
|
query = query.filter(Alumni.tahun_lulus == int(tahun_lulus))
|
|
|
|
rows = query.order_by(ClusteringResult.id.asc()).all()
|
|
|
|
prodi_map = {
|
|
'55301': 'D4 Teknik Informatika',
|
|
'57401': 'D3 Manajemen Informatika',
|
|
'56401': 'D3 Teknik Komputer',
|
|
'55302': 'D4 TIF Kampus Nganjuk',
|
|
'55304': 'D4 TIF Kampus Sidoarjo',
|
|
}
|
|
|
|
kategori_labels = {
|
|
'C0': 'Sesuai Profil Lulusan',
|
|
'C1': 'Bekerja, Bidang Diketahui Formal',
|
|
'C2': 'Bekerja, Kurang Teridentifikasi',
|
|
'C3': 'Belum / Tidak Bekerja'
|
|
}
|
|
|
|
export_records = []
|
|
for idx, r in enumerate(rows, start=1):
|
|
cr = r[0]
|
|
kp = str(r[3] or '').strip()
|
|
prodi_name = prodi_map.get(kp, kp)
|
|
kat = cr.kategori or '-'
|
|
kat_label = kategori_labels.get(kat, '-')
|
|
|
|
pek_nama = r[10] if (r[10] and r[10].strip() not in ['', 'nan', 'None']) else (r[5] or "-")
|
|
pek_jab = r[11] if (r[11] and r[11].strip() not in ['', 'nan', 'None']) else (r[6] or "-")
|
|
pek_status = r[8] if (r[8] and r[8].strip() not in ['', 'nan', 'None']) else (r[7] or "-")
|
|
pek_jenis = r[9] if (r[9] and r[9].strip() not in ['', 'nan', 'None']) else "-"
|
|
pek_teks = r[12] if (r[12] and r[12].strip() not in ['', 'nan', 'None']) else f"{pek_status} {pek_jenis} {pek_nama} {pek_jab}".replace('-', '').strip()
|
|
|
|
export_records.append({
|
|
"No": idx,
|
|
"NIM": r[1],
|
|
"Nama Alumni": r[2],
|
|
"Program Studi": prodi_name,
|
|
"Tahun Lulus": r[4],
|
|
"Status Kerja": pek_status,
|
|
"Nama Perusahaan / Instansi": pek_nama,
|
|
"Jabatan / Posisi": pek_jab,
|
|
"Jenis Perusahaan": pek_jenis,
|
|
"Teks Pekerjaan": pek_teks if pek_teks else "-",
|
|
"Klaster": f"Cluster {cr.cluster}",
|
|
"Kategori": kat,
|
|
"Interpretasi Relevansi": kat_label,
|
|
"Kata Kunci Cocok": cr.matched_keyword or "-"
|
|
})
|
|
|
|
return pd.DataFrame(export_records)
|
|
|
|
|
|
@router.get("/export/excel", summary="Unduh Hasil Clustering ke File Excel (.xlsx)")
|
|
def export_clustering_excel(
|
|
search: Optional[str] = Query(None, description="Cari nama atau NIM"),
|
|
kategori: Optional[str] = Query(None, description="Filter kategori C0, C1, C2, C3"),
|
|
kode_prodi: Optional[str] = Query(None, description="Filter kode prodi"),
|
|
tahun_lulus: Optional[int] = Query(None, description="Filter tahun lulus"),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""
|
|
Ekspor hasil clustering alumni yang tersimpan di MySQL ke berkas spreadsheet Microsoft Excel (.xlsx).
|
|
Mendukung filter tahun kelulusan, program studi, kategori relevansi C0-C3, dan kata kunci pencarian.
|
|
"""
|
|
df = _build_export_dataframe(db, search, kategori, kode_prodi, tahun_lulus)
|
|
output = io.BytesIO()
|
|
with pd.ExcelWriter(output, engine='openpyxl') as writer:
|
|
df.to_excel(writer, index=False, sheet_name='Hasil Klastering')
|
|
output.seek(0)
|
|
|
|
filename = f"Hasil_Clustering_Alumni_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
|
|
headers = {
|
|
'Content-Disposition': f'attachment; filename="{filename}"'
|
|
}
|
|
return StreamingResponse(
|
|
output,
|
|
media_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
headers=headers
|
|
)
|
|
|
|
|
|
@router.get("/export/csv", summary="Unduh Hasil Clustering ke File CSV (.csv)")
|
|
def export_clustering_csv(
|
|
search: Optional[str] = Query(None, description="Cari nama atau NIM"),
|
|
kategori: Optional[str] = Query(None, description="Filter kategori C0, C1, C2, C3"),
|
|
kode_prodi: Optional[str] = Query(None, description="Filter kode prodi"),
|
|
tahun_lulus: Optional[int] = Query(None, description="Filter tahun lulus"),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""
|
|
Ekspor hasil clustering alumni yang tersimpan di MySQL ke berkas CSV dengan encoding UTF-8.
|
|
"""
|
|
df = _build_export_dataframe(db, search, kategori, kode_prodi, tahun_lulus)
|
|
output = io.BytesIO()
|
|
df.to_csv(output, index=False, encoding='utf-8-sig')
|
|
output.seek(0)
|
|
|
|
filename = f"Hasil_Clustering_Alumni_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
|
|
headers = {
|
|
'Content-Disposition': f'attachment; filename="{filename}"'
|
|
}
|
|
return StreamingResponse(
|
|
output,
|
|
media_type='text/csv; charset=utf-8',
|
|
headers=headers
|
|
)
|
|
|
|
|