From ce3ee0d1a3079e755d30376172aafcd95b8b9908 Mon Sep 17 00:00:00 2001 From: mhilya Date: Mon, 25 May 2026 07:06:09 +0700 Subject: [PATCH] perbaikan --- .gitignore | 38 ++ fastapi/.env.example | 1 + fastapi/app/main.py | 30 +- fastapi/ml_assets/retrain_status.json | 10 + .../Controllers/ClassificationController.php | 371 ------------- .../ClassificationTaskController.php | 195 +++++++ .../Controllers/InternalDataController.php | 179 ++++++ .../Controllers/KemendikDataController.php | 107 ++++ sista_mif/app/Models/InternalRawData.php | 4 - .../app/Services/FastApiWorkerService.php | 79 ++- sista_mif/composer.json | 2 + sista_mif/composer.lock | 524 +++++++++++++++++- sista_mif/config/services.php | 6 + ...42_create_classification_results_table.php | 2 +- ..._133732_create_internal_raw_data_table.php | 31 +- sista_mif/package-lock.json | 28 +- sista_mif/resources/views/dashboard.blade.php | 53 ++ sista_mif/routes/web.php | 30 +- 18 files changed, 1252 insertions(+), 438 deletions(-) create mode 100644 .gitignore create mode 100644 fastapi/.env.example create mode 100644 fastapi/ml_assets/retrain_status.json delete mode 100644 sista_mif/app/Http/Controllers/ClassificationController.php create mode 100644 sista_mif/app/Http/Controllers/ClassificationTaskController.php create mode 100644 sista_mif/app/Http/Controllers/InternalDataController.php create mode 100644 sista_mif/app/Http/Controllers/KemendikDataController.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..695e0b2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +# Python / FastAPI Core +__pycache__/ +*.py[cod] +*$py.class +*.so + +# Python Environments +.venv/ +.env/ +env/ +venv/ +ENV/ +env.bak/ + +# Python Testing, Logs, & Coverage +.pytest_cache/ +.coverage +htmlcov/ +*.log +fastapi/ml_assets/tmp/ + +# Environments & Secrets (Global & FastAPI) +.env +.env.* +!.env.example + +# OS & Editor / IDE (Untuk level Root) +.vscode/ +.idea/ +.DS_Store +Thumbs.db +*.swp +*.bak + +# Model / Data (Jika di-generate/download lokal) +fastapi/ml_assets/*.pkl +fastapi/ml_assets/*.h5 +fastapi/ml_assets/*.pt diff --git a/fastapi/.env.example b/fastapi/.env.example new file mode 100644 index 0000000..910d639 --- /dev/null +++ b/fastapi/.env.example @@ -0,0 +1 @@ +FASTAPI_SECRET_KEY= \ No newline at end of file diff --git a/fastapi/app/main.py b/fastapi/app/main.py index 7780975..d19385a 100644 --- a/fastapi/app/main.py +++ b/fastapi/app/main.py @@ -1,4 +1,5 @@ -from fastapi import FastAPI, UploadFile, File, HTTPException, BackgroundTasks +from fastapi import FastAPI, UploadFile, File, HTTPException, BackgroundTasks, Depends, Security +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from fastapi.responses import JSONResponse import pandas as pd import numpy as np @@ -10,6 +11,7 @@ import uuid import shutil import subprocess import sys +import os import logging from contextlib import asynccontextmanager from pathlib import Path @@ -69,6 +71,18 @@ async def lifespan(app: FastAPI): app = FastAPI(title="Tracer Study Classification Worker", lifespan=lifespan) +# --- SECURITY SETUP --- +security = HTTPBearer() +EXPECTED_TOKEN = os.getenv("FASTAPI_SECRET_KEY", "") + +def verify_token(credentials: HTTPAuthorizationCredentials = Security(security)): + if not EXPECTED_TOKEN: + return credentials.credentials + if credentials.credentials != EXPECTED_TOKEN: + raise HTTPException(status_code=401, detail="Unauthorized - Invalid Token") + return credentials.credentials +# ---------------------- + def clean_text(text: str) -> str: if pd.isna(text) or not isinstance(text, str): return "" @@ -102,7 +116,7 @@ def safe_get(row: pd.Series, df_columns: list, code: str, default: str = "") -> def classify_rule(job_text: str) -> dict: clean = clean_text(job_text) if not clean or len(clean) < 3: - return {"profile": "Non-IT", "confidence": 0.65, "method": "rule_based_fallback"} + return {"profile": "Tidak Diketahui", "confidence": 0.65, "method": "rule_based_fallback"} for profile, keywords in KEYWORD_RULES.items(): if any(kw in clean for kw in keywords): @@ -112,7 +126,7 @@ def classify_rule(job_text: str) -> dict: def classify_ml(job_text: str, pipeline) -> dict: clean = clean_text(job_text) if not clean: - return {"profile": "Non-IT", "confidence": 0.0, "method": "ml_empty_input"} + return {"profile": "Tidak Diketahui", "confidence": 0.0, "method": "ml_empty_input"} try: proba = pipeline.predict_proba([clean])[0] max_conf = float(np.max(proba)) @@ -163,7 +177,7 @@ def health_check(): # RE-TRAINING ENDPOINTS -@app.post("/api/v1/retrain") +@app.post("/api/v1/retrain", dependencies=[Depends(verify_token)]) async def trigger_retrain(file: UploadFile = File(None)): if STATUS_PATH.exists(): try: @@ -214,7 +228,7 @@ async def trigger_retrain(file: UploadFile = File(None)): return JSONResponse(content={"status": "started", "message": "Re-training dimulai di background."}) -@app.get("/api/v1/retrain/status") +@app.get("/api/v1/retrain/status", dependencies=[Depends(verify_token)]) def retrain_status(): if not STATUS_PATH.exists(): return JSONResponse(content={"stage": "idle", "message": "Belum ada proses re-training."}) @@ -237,7 +251,7 @@ def retrain_status(): return JSONResponse(content=data) -@app.post("/api/v1/retrain/reload") +@app.post("/api/v1/retrain/reload", dependencies=[Depends(verify_token)]) def reload_model(): try: app.state.pipeline = joblib.load(PIPELINE_PATH) @@ -246,7 +260,7 @@ def reload_model(): except Exception as e: raise HTTPException(status_code=500, detail=f"Gagal reload model: {str(e)}") -@app.post("/api/v1/classify") +@app.post("/api/v1/classify", dependencies=[Depends(verify_token)]) async def classify_tracer(file: UploadFile = File(...)): if not file.filename: raise HTTPException(status_code=400, detail="No file provided") @@ -348,7 +362,7 @@ async def classify_tracer(file: UploadFile = File(...)): rule_res = classify_rule(job_text) res = rule_res or (classify_ml(job_text, pipeline) if pipeline else {"profile": "Non-IT", "confidence": 0.0, "method": "ml_unavailable"}) - status = "auto_classified" if res["method"] in ["rule_based", "ml_fallback"] else "needs_review" + status = "auto_classified" if res["method"] in ["rule_based", "ml_fallback"] or res["profile"] == "Tidak Diketahui" else "needs_review" raw_data_payload = {str(k): safe_str(v) for k, v in row.to_dict().items()} diff --git a/fastapi/ml_assets/retrain_status.json b/fastapi/ml_assets/retrain_status.json new file mode 100644 index 0000000..17bef75 --- /dev/null +++ b/fastapi/ml_assets/retrain_status.json @@ -0,0 +1,10 @@ +{ + "stage": "rolled_back", + "message": "Model lama dipertahankan. Model baru lebih buruk secara signifikan (-12.92% weighted F1)", + "timestamp": "2026-05-25T06:51:07.653672", + "result": "rolled_back", + "reason": "Model baru lebih buruk secara signifikan (-12.92% weighted F1)", + "old_f1": 0.7804040674617057, + "new_f1": 0.6512, + "_reloaded": true +} \ No newline at end of file diff --git a/sista_mif/app/Http/Controllers/ClassificationController.php b/sista_mif/app/Http/Controllers/ClassificationController.php deleted file mode 100644 index eb17b6a..0000000 --- a/sista_mif/app/Http/Controllers/ClassificationController.php +++ /dev/null @@ -1,371 +0,0 @@ -validate(['file' => 'required|file|mimes:csv,xlsx,xls|max:20480']); - - $result = $this->fastApi->classifyFile($request->file('file')->getRealPath(), $request->file('file')->getClientOriginalName()); - if (isset($result['error'])) return back()->with('error', $result['error']); - - DB::transaction(function () use ($result) { - foreach ($result['results'] as $row) { - if ($row['source_type'] === 'kemendik') { - $raw = $row['raw_data'] ?? []; - KemendikRawData::updateOrCreate( - ['nimhsmsmh' => $row['nim'], 'source_type' => 'kemendik'], - [ - 'nmmhsmsmh' => $row['nama'], 'tahun_lulus' => $row['tahun_lulus'] ?? null, - 'f5c_jabatan_kode' => $raw['F5c'] ?? null, 'f5c_jabatan_text' => $this->mapF5c($raw['F5c'] ?? null), - 'f1101_jenis_instansi_kode' => $raw['F1101'] ?? null, 'f1101_jenis_instansi_text' => $this->mapF1101($raw['F1101'] ?? null), - 'f5a1_provinsi' => $raw['F5a1'] ?? null, 'raw_data' => $raw - ] - ); - } else { - ClassificationResult::updateOrCreate( - ['nim' => $row['nim'], 'source_type' => 'internal_mif'], - [ - 'nama' => $row['nama'], 'tahun_lulus' => $row['tahun_lulus'] ?? null, - 'job_text_raw' => $row['job_text_raw'], 'predicted_profile' => $row['predicted_profile'], - 'confidence_score' => $row['confidence_score'], 'classification_method' => $row['classification_method'], - 'status' => $row['status'], 'error_detail' => $row['error_detail'] ?? null - ] - ); - - if (isset($row['raw_data'])) { - \App\Models\InternalRawData::updateOrCreate( - ['nim' => $row['nim']], - ['raw_data' => $row['raw_data']] - ); - } - } - } - }); - - if (empty($result['results'])) { - return back()->with('success', "Selesai. 0 data berhasil diproses."); - } - - $isKemendik = $result['results'][0]['source_type'] === 'kemendik'; - if ($isKemendik) { - $count = collect($result['results'])->where('source_type','kemendik')->count(); - return back()->with('success', "Selesai. {$result['processed_rows']} baris diproses. {$count} data Kemendik berhasil masuk."); - } else { - $classified = collect($result['results'])->where('source_type','internal_mif')->where('status','auto_classified')->count(); - return back()->with('success', "Selesai. {$result['processed_rows']} data diproses. {$classified} diklasifikasi otomatis (Internal MIF)."); - } - } - - private function mapF5c(?string $c): ?string { return ['1'=>'Founder','2'=>'Co-Founder','3'=>'Staff','4'=>'Freelance'][$c] ?? null; } - private function mapF1101(?string $c): ?string { return ['1'=>'Pemerintah','2'=>'Non-profit','3'=>'Swasta','4'=>'Wiraswasta','6'=>'BUMN','7'=>'Multilateral'][$c] ?? null; } - - public function dashboard(Request $request) - { - $stats = [ - 'total_internal' => \App\Models\ClassificationResult::where('source_type', 'internal_mif')->count(), - 'auto_classified' => \App\Models\ClassificationResult::where('source_type', 'internal_mif')->where('status', 'auto_classified')->count(), - 'needs_review' => \App\Models\ClassificationResult::where('source_type', 'internal_mif')->where('status', 'needs_review')->count(), - ]; - - $query = \App\Models\ClassificationResult::where('source_type', 'internal_mif'); - - // Always put needs_review first unless overridden by a very specific custom sort requirement (we'll just prepend it to orders) - $query->orderByRaw("FIELD(status, 'needs_review', 'manual_override', 'auto_classified', 'failed')"); - - if ($request->has('sort')) { - $allowedSorts = ['nim', 'nama', 'job_text_raw', 'predicted_profile', 'status']; - if (in_array($request->sort, $allowedSorts)) { - $query->orderBy($request->sort, $request->direction === 'desc' ? 'desc' : 'asc'); - } - } else { - $query->latest(); - } - - // Charts Data Aggregation - $allResults = \App\Models\ClassificationResult::where('source_type', 'internal_mif')->get(); - $allRaws = \App\Models\InternalRawData::all(); - - $chartProfile = $allResults->whereNotNull('predicted_profile')->groupBy('predicted_profile')->map->count(); - $chartMethod = $allResults->whereNotNull('classification_method')->groupBy('classification_method')->map->count(); - - $waktuTunggu = []; - foreach ($allRaws as $r) { - $tahun = $r->raw_data['tahun_lulus'] ?? null; - $tunggu = intval($r->raw_data['total_masa_tunggu'] ?? 0); - if (!empty($tahun) && $tunggu > 0) { - $waktuTunggu[$tahun][] = $tunggu; - } - } - $chartWaktuTunggu = collect($waktuTunggu)->map(fn($arr) => round(collect($arr)->average(), 1))->sortKeys(); - - $chartFunnel = [ - 'Lamaran Dikirim' => round((float)$allRaws->map(fn($r) => intval($r->raw_data['jumlah_lamaran_dikirim'] ?? 0))->average(), 1), - 'Respons' => round((float)$allRaws->map(fn($r) => intval($r->raw_data['jumlah_respons_lamaran'] ?? 0))->average(), 1), - 'Wawancara' => round((float)$allRaws->map(fn($r) => intval($r->raw_data['jumlah_undangan_wawancara'] ?? 0))->average(), 1), - ]; - - $chartLokasi = $allRaws->map(fn($r) => $r->raw_data['lokasi_perusahaan'] ?? null) - ->map(fn($i) => trim($i)) - ->filter(fn($i) => !empty($i)) - ->groupBy(fn($i) => $i)->map->count(); - - $chartTopLokasi = $chartLokasi->sortDesc()->take(5); - - $charts = [ - 'profile' => $chartProfile, - 'method' => $chartMethod, - 'waktu_tunggu' => $chartWaktuTunggu, - 'funnel' => $chartFunnel, - 'lokasi' => $chartTopLokasi, - 'map_lokasi' => $chartLokasi, - ]; - - // Load ML metrics untuk panel re-training - $metricsPath = base_path('../fastapi/ml_assets/metrics_internal_only.json'); - $mlMetrics = null; - if (file_exists($metricsPath)) { - $mlMetrics = json_decode(file_get_contents($metricsPath), true); - } - - $manualOverrideCount = \App\Models\ClassificationResult::where('source_type', 'internal_mif') - ->where('status', 'manual_override') - ->whereNotNull('job_text_raw') - ->count(); - - return view('dashboard', [ - 'stats' => $stats, - 'charts' => $charts, - 'internal_data' => $query->paginate(10)->appends($request->query()), - 'ml_metrics' => $mlMetrics, - 'manual_override_count' => $manualOverrideCount, - ]); - } - - - public function update(Request $request, $id) - { - $request->validate([ - 'predicted_profile' => 'required|in:Programmer,Data Analyst,Wirausaha Informatika,Non-IT', - ]); - - $record = \App\Models\ClassificationResult::findOrFail($id); - $record->update([ - 'predicted_profile' => $request->predicted_profile, - 'status' => 'manual_override' - ]); - - return back()->with('success', 'Data berhasil diperbarui!'); - } - - public function destroy($id) - { - $record = \App\Models\ClassificationResult::findOrFail($id); - $record->delete(); - - return back()->with('success', 'Data berhasil dihapus!'); - } - - public function bulkDestroy(Request $request) - { - $request->validate([ - 'ids' => 'required|array', - 'ids.*' => 'exists:classification_results,id', - ]); - - \App\Models\ClassificationResult::whereIn('id', $request->ids)->delete(); - - return back()->with('success', count($request->ids) . ' data berhasil dihapus!'); - } - - public function bulkUpdate(Request $request) - { - $request->validate([ - 'ids' => 'required|array', - 'ids.*' => 'exists:classification_results,id', - 'predicted_profile' => 'required|in:Programmer,Data Analyst,Wirausaha Informatika,Non-IT', - ]); - - \App\Models\ClassificationResult::whereIn('id', $request->ids)->update([ - 'predicted_profile' => $request->predicted_profile, - 'status' => 'manual_override' - ]); - - return back()->with('success', count($request->ids) . ' data berhasil diubah profilnya!'); - } - - public function kemendik() - { - $stats = [ - 'total_kemendik' => \App\Models\KemendikRawData::count(), - ]; - - $keteranganPath = base_path('../data/keterangan_ts_kemendiktisaintek.csv'); - $keterangan = []; - if (file_exists($keteranganPath)) { - $lines = file($keteranganPath); - if (count($lines) > 0) { - $rawHeaders = str_getcsv(trim($lines[0]), ';'); - $headers = array_map('trim', $rawHeaders); - - for ($i = 1; $i < count($lines); $i++) { - $row = str_getcsv(trim($lines[$i]), ';'); - foreach ($headers as $index => $header) { - $val = trim($row[$index] ?? ''); - if ($val !== '') { - if (preg_match('/^(\d+)\s*-\s*(.*)/', $val, $matches)) { - $keterangan[$header][$matches[1]] = $val; - } else { - $keterangan[$header][$val] = $val; - } - } - } - } - } - } - - $allRaws = \App\Models\KemendikRawData::all(); - - // Helper to count occurrences - $countBy = function($key) use ($allRaws, $keterangan) { - return $allRaws->map(function($r) use ($key, $keterangan) { - $val = trim($r->raw_data[$key] ?? ''); - return $val !== '' ? ($keterangan[$key][$val] ?? $val) : null; - })->filter()->groupBy(fn($i) => $i)->map->count(); - }; - - $chartStatus = $countBy('F8 (Jelaskan status Anda saat ini?)'); - $chartInstansi = $countBy('F1101 (Apa jenis perusahaan/instansi/institusi tempat anda bekerja sekarang?)'); - $chartKesesuaian = $countBy('F14 (Seberapa erat hubungan bidang studi dengan pekerjaan Anda?)'); - $chartLevel = $countBy('F5d (Apa tingkatan tempat kerja Anda?)'); - - // Pendapatan F505 - $chartPendapatanRaw = $allRaws->map(fn($r) => floatval($r->raw_data['F505 (Berapa rata-rata pendapatan Anda per bulan?)'] ?? 0))->filter(fn($v) => $v > 0); - $chartPendapatan = [ - '< 2 Juta' => $chartPendapatanRaw->filter(fn($v) => $v < 2000000)->count(), - '2 - 4 Juta' => $chartPendapatanRaw->filter(fn($v) => $v >= 2000000 && $v <= 4000000)->count(), - '4 - 6 Juta' => $chartPendapatanRaw->filter(fn($v) => $v > 4000000 && $v <= 6000000)->count(), - '> 6 Juta' => $chartPendapatanRaw->filter(fn($v) => $v > 6000000)->count(), - ]; - - // Map Lokasi - $mapLokasi = $allRaws->map(fn($r) => $r->raw_data['F5a2 (Dimana lokasi kabupaten/kota tempat Anda bekerja?)'] ?? null)->filter()->groupBy(fn($i) => $i)->map->count(); - - $coordsPath = base_path('../data/kabupaten_coords.json'); - $kabupatenCoords = file_exists($coordsPath) ? json_decode(file_get_contents($coordsPath), true) : []; - - $charts = [ - 'status' => $chartStatus, - 'instansi' => $chartInstansi, - 'kesesuaian' => $chartKesesuaian, - 'pendapatan' => array_filter($chartPendapatan), // remove zero values - 'level' => $chartLevel, - 'map_lokasi' => $mapLokasi, - 'coords' => $kabupatenCoords - ]; - - return view('kemendik', [ - 'stats' => $stats, - 'charts' => $charts, - 'kemendik_data' => \App\Models\KemendikRawData::orderBy('imported_at', 'desc')->paginate(10), - 'keterangan' => $keterangan - ]); - } - - public function downloadPdf(Request $request) - { - $query = \App\Models\ClassificationResult::where('source_type', 'internal_mif'); - - $query->orderByRaw("FIELD(status, 'needs_review', 'manual_override', 'auto_classified', 'failed')"); - - if ($request->has('sort')) { - $allowedSorts = ['nim', 'nama', 'job_text_raw', 'predicted_profile', 'status']; - if (in_array($request->sort, $allowedSorts)) { - $query->orderBy($request->sort, $request->direction === 'desc' ? 'desc' : 'asc'); - } - } else { - $query->latest(); - } - - $internal_data = $query->get(); - - $pdf = \Barryvdh\DomPDF\Facade\Pdf::loadView('pdf.classification_results', compact('internal_data')); - - return $pdf->download('Hasil_Klasifikasi_Internal_MIF.pdf'); - } - - /** - * Ekspor data manual_override ke CSV sementara, lalu trigger re-training via FastAPI. - * Returns JSON untuk AJAX. - */ - public function retrain(Request $request) - { - // Ambil semua data manual_override yang memiliki job_text_raw - $overrideData = ClassificationResult::where('source_type', 'internal_mif') - ->where('status', 'manual_override') - ->whereNotNull('job_text_raw') - ->whereNotNull('predicted_profile') - ->get(['job_text_raw', 'predicted_profile']); - - $csvPath = null; - - if ($overrideData->count() > 0) { - // Buat CSV sementara di storage/app/temp/ - $csvLines = ['job_text_raw;label']; - foreach ($overrideData as $row) { - $text = str_replace([';', "\n", "\r"], [',', ' ', ' '], $row->job_text_raw); - $label = $row->predicted_profile; - $csvLines[] = "{$text};{$label}"; - } - - $tmpDir = storage_path('app/temp'); - if (!is_dir($tmpDir)) { - mkdir($tmpDir, 0755, true); - } - $csvPath = $tmpDir . '/manual_override_' . time() . '.csv'; - file_put_contents($csvPath, implode("\n", $csvLines)); - - Log::info("Retrain: Exported {$overrideData->count()} manual_override rows to {$csvPath}"); - } else { - Log::info('Retrain: Tidak ada data manual_override, training hanya dengan corpus asli.'); - } - - // Trigger FastAPI - $result = $this->fastApi->triggerRetrain($csvPath); - - // Bersihkan CSV temp - if ($csvPath && file_exists($csvPath)) { - @unlink($csvPath); - } - - if (isset($result['error'])) { - return response()->json(['success' => false, 'message' => $result['error']], 422); - } - - return response()->json([ - 'success' => true, - 'message' => 'Re-training dimulai di background.', - 'override_count' => $overrideData->count(), - ]); - } - - /** - * Proxy polling status re-training dari FastAPI ke frontend. - */ - public function retrainStatus(Request $request) - { - $status = $this->fastApi->getRetrainStatus(); - return response()->json($status); - } -} diff --git a/sista_mif/app/Http/Controllers/ClassificationTaskController.php b/sista_mif/app/Http/Controllers/ClassificationTaskController.php new file mode 100644 index 0000000..df4c71b --- /dev/null +++ b/sista_mif/app/Http/Controllers/ClassificationTaskController.php @@ -0,0 +1,195 @@ +validate(['file' => 'required|file|mimes:csv,xlsx,xls|max:20480']); + + $result = $this->fastApi->classifyFile($request->file('file')->getRealPath(), $request->file('file')->getClientOriginalName()); + + if (!isset($result['results']) && isset($result['error'])) { + return back()->with('error', $result['error']); + } + + if (empty($result['results'])) { + return back()->with('success', "Selesai. 0 data berhasil diproses."); + } + + DB::transaction(function () use ($result) { + $kemendikUpsertData = []; + $internalResultsUpsertData = []; + $internalRawUpsertData = []; + + foreach ($result['results'] as $row) { + if ($row['source_type'] === 'kemendik') { + $raw = $row['raw_data'] ?? []; + $kemendikUpsertData[] = [ + 'nimhsmsmh' => $row['nim'], + 'source_type' => 'kemendik', + 'nmmhsmsmh' => $row['nama'], + 'tahun_lulus' => $row['tahun_lulus'] ?? null, + 'f5c_jabatan_kode' => $raw['F5c'] ?? null, + 'f5c_jabatan_text' => $this->mapF5c($raw['F5c'] ?? null), + 'f1101_jenis_instansi_kode' => $raw['F1101'] ?? null, + 'f1101_jenis_instansi_text' => $this->mapF1101($raw['F1101'] ?? null), + 'f5a1_provinsi' => $raw['F5a1'] ?? null, + 'raw_data' => json_encode($raw) + ]; + } else { + $internalResultsUpsertData[] = [ + 'nim' => $row['nim'], + 'source_type' => 'internal_mif', + 'nama' => $row['nama'], + 'tahun_lulus' => $row['tahun_lulus'] ?? null, + 'job_text_raw' => $row['job_text_raw'] ?? '', + 'predicted_profile' => $row['predicted_profile'], + 'confidence_score' => $row['confidence_score'] ?? 0, + 'classification_method' => $row['classification_method'], + 'status' => $row['status'], + 'error_detail' => $row['error_detail'] ?? null + ]; + + if (isset($row['raw_data'])) { + $mapped = ['nim' => $row['nim']]; + $cols = [ + 'nama_lengkap', 'email', 'no_telepon', 'alamat_domisili', 'jurusan', + 'program_studi', 'tahun_masuk', 'tahun_lulus', 'status_pekerjaan', + 'klasifikasi_pekerjaan', 'nama_perusahaan', 'jenis_perusahaan', 'jabatan', + 'lokasi_perusahaan', 'alamat_perusahaan', 'deskripsi_pekerjaan', + 'tahun_mulai_kerja', 'masa_kerja_bulan', 'jumlah_lamaran_dikirim', + 'jumlah_respons_lamaran', 'jumlah_undangan_wawancara', 'masa_tunggu_pra_lulus', + 'masa_tunggu_pasca_lulus', 'total_masa_tunggu', 'instansi_terupdate', + 'jabatan_terupdate', 'tahun_bekerja', 'status_pekerjaan_terupdate', + 'linkedin_profile', 'sosmed_ig' + ]; + foreach ($cols as $col) { + $val = $row['raw_data'][$col] ?? null; + $mapped[$col] = $val === '' ? null : $val; + } + $internalRawUpsertData[] = $mapped; + } + } + } + + if (!empty($kemendikUpsertData)) { + foreach (array_chunk($kemendikUpsertData, 500) as $chunk) { + KemendikRawData::upsert( + $chunk, + ['nimhsmsmh', 'source_type'], + ['nmmhsmsmh', 'tahun_lulus', 'f5c_jabatan_kode', 'f5c_jabatan_text', 'f1101_jenis_instansi_kode', 'f1101_jenis_instansi_text', 'f5a1_provinsi', 'raw_data'] + ); + } + } + + if (!empty($internalResultsUpsertData)) { + foreach (array_chunk($internalResultsUpsertData, 500) as $chunk) { + ClassificationResult::upsert( + $chunk, + ['nim', 'source_type'], + ['nama', 'tahun_lulus', 'job_text_raw', 'predicted_profile', 'confidence_score', 'classification_method', 'status', 'error_detail'] + ); + } + } + + if (!empty($internalRawUpsertData)) { + $cols = [ + 'nama_lengkap', 'email', 'no_telepon', 'alamat_domisili', 'jurusan', + 'program_studi', 'tahun_masuk', 'tahun_lulus', 'status_pekerjaan', + 'klasifikasi_pekerjaan', 'nama_perusahaan', 'jenis_perusahaan', 'jabatan', + 'lokasi_perusahaan', 'alamat_perusahaan', 'deskripsi_pekerjaan', + 'tahun_mulai_kerja', 'masa_kerja_bulan', 'jumlah_lamaran_dikirim', + 'jumlah_respons_lamaran', 'jumlah_undangan_wawancara', 'masa_tunggu_pra_lulus', + 'masa_tunggu_pasca_lulus', 'total_masa_tunggu', 'instansi_terupdate', + 'jabatan_terupdate', 'tahun_bekerja', 'status_pekerjaan_terupdate', + 'linkedin_profile', 'sosmed_ig' + ]; + foreach (array_chunk($internalRawUpsertData, 500) as $chunk) { + InternalRawData::upsert($chunk, ['nim'], $cols); + } + } + }); + + $isKemendik = $result['results'][0]['source_type'] === 'kemendik'; + if ($isKemendik) { + $count = collect($result['results'])->where('source_type','kemendik')->count(); + return back()->with('success', "Selesai. {$result['processed_rows']} baris diproses. {$count} data Kemendik berhasil masuk."); + } else { + $classified = collect($result['results'])->where('source_type','internal_mif')->where('status','auto_classified')->count(); + return back()->with('success', "Selesai. {$result['processed_rows']} data diproses. {$classified} diklasifikasi otomatis (Internal MIF)."); + } + } + + private function mapF5c(?string $c): ?string { return ['1'=>'Founder','2'=>'Co-Founder','3'=>'Staff','4'=>'Freelance'][$c] ?? null; } + private function mapF1101(?string $c): ?string { return ['1'=>'Pemerintah','2'=>'Non-profit','3'=>'Swasta','4'=>'Wiraswasta','6'=>'BUMN','7'=>'Multilateral'][$c] ?? null; } + + public function retrain(Request $request) + { + $overrideData = ClassificationResult::where('source_type', 'internal_mif') + ->where('status', 'manual_override') + ->whereNotNull('job_text_raw') + ->whereNotNull('predicted_profile') + ->get(['job_text_raw', 'predicted_profile']); + + $csvPath = null; + + if ($overrideData->count() > 0) { + $csvLines = ['job_text_raw;label']; + foreach ($overrideData as $row) { + $text = str_replace([';', "\n", "\r"], [',', ' ', ' '], $row->job_text_raw); + $label = $row->predicted_profile; + $csvLines[] = "{$text};{$label}"; + } + + $tmpDir = storage_path('app/temp'); + if (!is_dir($tmpDir)) { + mkdir($tmpDir, 0755, true); + } + + $csvPath = $tmpDir . '/manual_override_' . Str::uuid() . '.csv'; + file_put_contents($csvPath, implode("\n", $csvLines)); + + Log::info("Retrain: Exported {$overrideData->count()} manual_override rows to {$csvPath}"); + } else { + Log::info('Retrain: Tidak ada data manual_override, training hanya dengan corpus asli.'); + } + + $result = $this->fastApi->triggerRetrain($csvPath); + + if ($csvPath && file_exists($csvPath)) { + try { + unlink($csvPath); + } catch (\Exception $e) { + Log::warning("Gagal menghapus temp CSV: " . $e->getMessage()); + } + } + + if (isset($result['error'])) { + return response()->json(['success' => false, 'message' => $result['error']], 422); + } + + return response()->json([ + 'success' => true, + 'message' => 'Re-training dimulai di background.', + 'override_count' => $overrideData->count(), + ]); + } + + public function retrainStatus(Request $request) + { + $status = $this->fastApi->getRetrainStatus(); + return response()->json($status); + } +} diff --git a/sista_mif/app/Http/Controllers/InternalDataController.php b/sista_mif/app/Http/Controllers/InternalDataController.php new file mode 100644 index 0000000..8e8880d --- /dev/null +++ b/sista_mif/app/Http/Controllers/InternalDataController.php @@ -0,0 +1,179 @@ + ClassificationResult::where('source_type', 'internal_mif')->count(), + 'auto_classified' => ClassificationResult::where('source_type', 'internal_mif')->where('status', 'auto_classified')->count(), + 'needs_review' => ClassificationResult::where('source_type', 'internal_mif')->where('status', 'needs_review')->count(), + ]; + + $query = ClassificationResult::where('source_type', 'internal_mif'); + + $query->orderByRaw("FIELD(status, 'needs_review', 'manual_override', 'auto_classified', 'failed')"); + + if ($request->has('sort')) { + $allowedSorts = ['nim', 'nama', 'job_text_raw', 'predicted_profile', 'status']; + if (in_array($request->sort, $allowedSorts)) { + $query->orderBy($request->sort, $request->direction === 'desc' ? 'desc' : 'asc'); + } + } else { + $query->latest(); + } + + // --- Optimasi OOM: Hitung menggunakan database level, bukan php level mapping --- + $chartProfile = ClassificationResult::where('source_type', 'internal_mif') + ->whereNotNull('predicted_profile') + ->select('predicted_profile', DB::raw('count(*) as total')) + ->groupBy('predicted_profile') + ->pluck('total', 'predicted_profile'); + + $chartMethod = ClassificationResult::where('source_type', 'internal_mif') + ->whereNotNull('classification_method') + ->select('classification_method', DB::raw('count(*) as total')) + ->groupBy('classification_method') + ->pluck('total', 'classification_method'); + + // Memakai cursor untuk menghemat memori + $waktuTunggu = []; + $lamaranDikirim = []; + $respons = []; + $wawancara = []; + $lokasiCounts = []; + + foreach (InternalRawData::cursor() as $r) { + $tahun = $r->tahun_lulus; + $tunggu = intval($r->total_masa_tunggu ?? 0); + if (!empty($tahun) && $tunggu > 0) { + $waktuTunggu[$tahun][] = $tunggu; + } + + $lamaranDikirim[] = intval($r->jumlah_lamaran_dikirim ?? 0); + $respons[] = intval($r->jumlah_respons_lamaran ?? 0); + $wawancara[] = intval($r->jumlah_undangan_wawancara ?? 0); + + $lokasi = trim($r->lokasi_perusahaan ?? ''); + if (!empty($lokasi)) { + $lokasiCounts[$lokasi] = ($lokasiCounts[$lokasi] ?? 0) + 1; + } + } + + $chartWaktuTunggu = collect($waktuTunggu)->map(fn($arr) => round(collect($arr)->average(), 1))->sortKeys(); + + $chartFunnel = [ + 'Lamaran Dikirim' => count($lamaranDikirim) > 0 ? round(collect($lamaranDikirim)->average(), 1) : 0, + 'Respons' => count($respons) > 0 ? round(collect($respons)->average(), 1) : 0, + 'Wawancara' => count($wawancara) > 0 ? round(collect($wawancara)->average(), 1) : 0, + ]; + + $chartLokasi = collect($lokasiCounts); + $chartTopLokasi = $chartLokasi->sortDesc()->take(5); + + $charts = [ + 'profile' => $chartProfile, + 'method' => $chartMethod, + 'waktu_tunggu' => $chartWaktuTunggu, + 'funnel' => $chartFunnel, + 'lokasi' => $chartTopLokasi, + 'map_lokasi' => $chartLokasi, + ]; + + $metricsPath = base_path('../fastapi/ml_assets/metrics_internal_only.json'); + $mlMetrics = null; + if (file_exists($metricsPath)) { + $mlMetrics = json_decode(file_get_contents($metricsPath), true); + } + + $manualOverrideCount = ClassificationResult::where('source_type', 'internal_mif') + ->where('status', 'manual_override') + ->whereNotNull('job_text_raw') + ->count(); + + return view('dashboard', [ + 'stats' => $stats, + 'charts' => $charts, + 'internal_data' => $query->paginate(10)->appends($request->query()), + 'ml_metrics' => $mlMetrics, + 'manual_override_count' => $manualOverrideCount, + ]); + } + + public function update(Request $request, $id) + { + $request->validate([ + 'predicted_profile' => 'required|in:Programmer,Data Analyst,Wirausaha Informatika,Non-IT,Tidak Diketahui', + ]); + + $record = ClassificationResult::findOrFail($id); + $record->update([ + 'predicted_profile' => $request->predicted_profile, + 'status' => 'manual_override' + ]); + + return back()->with('success', 'Data berhasil diperbarui!'); + } + + public function destroy($id) + { + $record = ClassificationResult::findOrFail($id); + $record->delete(); + + return back()->with('success', 'Data berhasil dihapus!'); + } + + public function bulkDestroy(Request $request) + { + $request->validate([ + 'ids' => 'required|array', + 'ids.*' => 'exists:classification_results,id', + ]); + + ClassificationResult::whereIn('id', $request->ids)->delete(); + + return back()->with('success', count($request->ids) . ' data berhasil dihapus!'); + } + + public function bulkUpdate(Request $request) + { + $request->validate([ + 'ids' => 'required|array', + 'ids.*' => 'exists:classification_results,id', + 'predicted_profile' => 'required|in:Programmer,Data Analyst,Wirausaha Informatika,Non-IT,Tidak Diketahui', + ]); + + ClassificationResult::whereIn('id', $request->ids)->update([ + 'predicted_profile' => $request->predicted_profile, + 'status' => 'manual_override' + ]); + + return back()->with('success', count($request->ids) . ' data berhasil diubah profilnya!'); + } + + public function exportPdf(Request $request) + { + $query = ClassificationResult::where('source_type', 'internal_mif'); + $query->orderByRaw("FIELD(status, 'needs_review', 'manual_override', 'auto_classified', 'failed')"); + + if ($request->has('sort')) { + $allowedSorts = ['nim', 'nama', 'job_text_raw', 'predicted_profile', 'status']; + if (in_array($request->sort, $allowedSorts)) { + $query->orderBy($request->sort, $request->direction === 'desc' ? 'desc' : 'asc'); + } + } else { + $query->latest(); + } + + $internal_data = $query->get(); + $pdf = \Barryvdh\DomPDF\Facade\Pdf::loadView('pdf.classification_results', compact('internal_data')); + return $pdf->download('Hasil_Klasifikasi_Internal_MIF.pdf'); + } +} diff --git a/sista_mif/app/Http/Controllers/KemendikDataController.php b/sista_mif/app/Http/Controllers/KemendikDataController.php new file mode 100644 index 0000000..1f78c8d --- /dev/null +++ b/sista_mif/app/Http/Controllers/KemendikDataController.php @@ -0,0 +1,107 @@ + KemendikRawData::count(), + ]; + + $keteranganPath = base_path('../data/keterangan_ts_kemendiktisaintek.csv'); + $keterangan = []; + if (file_exists($keteranganPath)) { + $lines = file($keteranganPath); + if (count($lines) > 0) { + $rawHeaders = str_getcsv(trim($lines[0]), ';'); + $headers = array_map('trim', $rawHeaders); + + for ($i = 1; $i < count($lines); $i++) { + $row = str_getcsv(trim($lines[$i]), ';'); + foreach ($headers as $index => $header) { + $val = trim($row[$index] ?? ''); + if ($val !== '') { + if (preg_match('/^(\d+)\s*-\s*(.*)/', $val, $matches)) { + $keterangan[$header][$matches[1]] = $val; + } else { + $keterangan[$header][$val] = $val; + } + } + } + } + } + } + + // Optimasi OOM: Gunakan cursor() alih-alih all() + $statusCounts = []; + $instansiCounts = []; + $kesesuaianCounts = []; + $levelCounts = []; + $pendapatanRaw = []; + $mapLokasi = []; + + foreach (KemendikRawData::cursor() as $r) { + $raw = $r->raw_data; + if (!$raw) continue; + + $this->incrementCount($statusCounts, $raw, 'F8 (Jelaskan status Anda saat ini?)', $keterangan); + $this->incrementCount($instansiCounts, $raw, 'F1101 (Apa jenis perusahaan/instansi/institusi tempat anda bekerja sekarang?)', $keterangan); + $this->incrementCount($kesesuaianCounts, $raw, 'F14 (Seberapa erat hubungan bidang studi dengan pekerjaan Anda?)', $keterangan); + $this->incrementCount($levelCounts, $raw, 'F5d (Apa tingkatan tempat kerja Anda?)', $keterangan); + + $pendapatanVal = floatval($raw['F505 (Berapa rata-rata pendapatan Anda per bulan?)'] ?? 0); + if ($pendapatanVal > 0) { + $pendapatanRaw[] = $pendapatanVal; + } + + $lokasi = trim($raw['F5a2 (Dimana lokasi kabupaten/kota tempat Anda bekerja?)'] ?? ''); + if ($lokasi) { + $mapLokasi[$lokasi] = ($mapLokasi[$lokasi] ?? 0) + 1; + } + } + + $chartPendapatan = collect($pendapatanRaw)->reduce(function($carry, $v) { + if ($v < 2000000) $carry['< 2 Juta']++; + elseif ($v <= 4000000) $carry['2 - 4 Juta']++; + elseif ($v <= 6000000) $carry['4 - 6 Juta']++; + else $carry['> 6 Juta']++; + return $carry; + }, ['< 2 Juta' => 0, '2 - 4 Juta' => 0, '4 - 6 Juta' => 0, '> 6 Juta' => 0]); + $chartPendapatan = array_filter($chartPendapatan); + + $coordsPath = base_path('../data/kabupaten_coords.json'); + $kabupatenCoords = file_exists($coordsPath) ? json_decode(file_get_contents($coordsPath), true) : []; + + $charts = [ + 'status' => collect($statusCounts), + 'instansi' => collect($instansiCounts), + 'kesesuaian' => collect($kesesuaianCounts), + 'pendapatan' => $chartPendapatan, + 'level' => collect($levelCounts), + 'map_lokasi' => collect($mapLokasi), + 'coords' => $kabupatenCoords + ]; + + return view('kemendik', [ + 'stats' => $stats, + 'charts' => $charts, + 'kemendik_data' => KemendikRawData::orderBy('imported_at', 'desc')->paginate(10), + 'keterangan' => $keterangan + ]); + } + + private function incrementCount(&$countsArray, $raw, $key, $keterangan) + { + $val = trim($raw[$key] ?? ''); + if ($val !== '') { + $mapped = $keterangan[$key][$val] ?? $val; + $countsArray[$mapped] = ($countsArray[$mapped] ?? 0) + 1; + } + } +} diff --git a/sista_mif/app/Models/InternalRawData.php b/sista_mif/app/Models/InternalRawData.php index 976dc60..7541821 100644 --- a/sista_mif/app/Models/InternalRawData.php +++ b/sista_mif/app/Models/InternalRawData.php @@ -7,9 +7,5 @@ class InternalRawData extends Model { protected $guarded = ['id']; - - protected $casts = [ - 'raw_data' => 'array', - ]; } diff --git a/sista_mif/app/Services/FastApiWorkerService.php b/sista_mif/app/Services/FastApiWorkerService.php index 33a7451..e0ce0b9 100644 --- a/sista_mif/app/Services/FastApiWorkerService.php +++ b/sista_mif/app/Services/FastApiWorkerService.php @@ -4,21 +4,43 @@ use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; +use Illuminate\Http\Client\ConnectionException; class FastApiWorkerService { private string $baseUrl; + private string $secretKey; public function __construct() { $this->baseUrl = config('services.fastapi.worker_url', 'http://127.0.0.1:8001'); + $this->secretKey = config('services.fastapi.secret_key', ''); + } + + /** + * Helper to get HTTP client with common config + */ + private function getClient(int $timeout = 15) + { + $client = Http::timeout($timeout); + if (!empty($this->secretKey)) { + $client = $client->withToken($this->secretKey); + } + return $client; } public function classifyFile(string $filePath, string $filename): array { + $fileStream = null; try { - $response = Http::timeout(config('services.fastapi.timeout', 120)) - ->attach('file', file_get_contents($filePath), $filename) + // Fix OOM: Gunakan fopen untuk stream file, bukan file_get_contents + $fileStream = fopen($filePath, 'r'); + if (!$fileStream) { + throw new \Exception("Tidak dapat membaca file: {$filePath}"); + } + + $response = $this->getClient(config('services.fastapi.timeout', 120)) + ->attach('file', $fileStream, $filename) ->post($this->baseUrl . '/api/v1/classify'); if ($response->failed()) { @@ -26,9 +48,16 @@ public function classifyFile(string $filePath, string $filename): array return ['error' => "Gagal menghubungi FastAPI: {$response->status()}"]; } return $response->json(); + } catch (ConnectionException $e) { + Log::error("FastAPI Connection Timeout/Refused: {$e->getMessage()}"); + return ['error' => 'Server AI sedang tidak dapat dijangkau (Timeout/Refused).']; } catch (\Exception $e) { - Log::error("FastAPI Connection Failed: {$e->getMessage()}"); - return ['error' => "Koneksi ke FastAPI gagal: {$e->getMessage()}"]; + Log::error("FastAPI Internal System Error: {$e->getMessage()}"); + return ['error' => "Kesalahan sistem internal: {$e->getMessage()}"]; + } finally { + if (is_resource($fileStream)) { + fclose($fileStream); + } } } @@ -38,15 +67,16 @@ public function classifyFile(string $filePath, string $filename): array */ public function triggerRetrain(?string $csvPath = null): array { + $fileStream = null; try { - $request = Http::timeout(30); + $request = $this->getClient(30); if ($csvPath && file_exists($csvPath)) { - $request = $request->attach( - 'file', - file_get_contents($csvPath), - 'manual_override.csv' - ); + $fileStream = fopen($csvPath, 'r'); + if (!$fileStream) { + throw new \Exception("Tidak dapat membaca temp CSV: {$csvPath}"); + } + $request = $request->attach('file', $fileStream, 'manual_override.csv'); } $response = $request->post($this->baseUrl . '/api/v1/retrain'); @@ -61,9 +91,16 @@ public function triggerRetrain(?string $csvPath = null): array } return $response->json(); + } catch (ConnectionException $e) { + Log::error("FastAPI Retrain Connection Timeout/Refused: {$e->getMessage()}"); + return ['error' => 'Server AI sedang tidak dapat dijangkau (Timeout/Refused).']; } catch (\Exception $e) { - Log::error("FastAPI Retrain Failed: {$e->getMessage()}"); - return ['error' => "Koneksi ke FastAPI gagal: {$e->getMessage()}"]; + Log::error("FastAPI Retrain Internal System Error: {$e->getMessage()}"); + return ['error' => "Kesalahan sistem internal: {$e->getMessage()}"]; + } finally { + if (is_resource($fileStream)) { + fclose($fileStream); + } } } @@ -73,16 +110,19 @@ public function triggerRetrain(?string $csvPath = null): array public function getRetrainStatus(): array { try { - $response = Http::timeout(10)->get($this->baseUrl . '/api/v1/retrain/status'); + $response = $this->getClient(10)->get($this->baseUrl . '/api/v1/retrain/status'); if ($response->failed()) { return ['stage' => 'unknown', 'message' => 'Tidak dapat membaca status dari FastAPI.']; } return $response->json(); + } catch (ConnectionException $e) { + Log::warning("FastAPI Status Check Connection Refused/Timeout: {$e->getMessage()}"); + return ['stage' => 'unknown', 'message' => 'Server AI tidak merespons (Timeout).']; } catch (\Exception $e) { - Log::warning("FastAPI Status Check Failed: {$e->getMessage()}"); - return ['stage' => 'unknown', 'message' => "Koneksi gagal: {$e->getMessage()}"]; + Log::warning("FastAPI Status Check System Error: {$e->getMessage()}"); + return ['stage' => 'unknown', 'message' => "Kesalahan sistem internal: {$e->getMessage()}"]; } } @@ -92,16 +132,19 @@ public function getRetrainStatus(): array public function reloadModel(): array { try { - $response = Http::timeout(15)->post($this->baseUrl . '/api/v1/retrain/reload'); + $response = $this->getClient(15)->post($this->baseUrl . '/api/v1/retrain/reload'); if ($response->failed()) { return ['error' => "Gagal reload model: HTTP {$response->status()}"]; } return $response->json(); + } catch (ConnectionException $e) { + Log::error("FastAPI Reload Connection Timeout/Refused: {$e->getMessage()}"); + return ['error' => 'Server AI sedang tidak dapat dijangkau (Timeout/Refused).']; } catch (\Exception $e) { - Log::error("FastAPI Reload Failed: {$e->getMessage()}"); - return ['error' => "Koneksi ke FastAPI gagal: {$e->getMessage()}"]; + Log::error("FastAPI Reload System Error: {$e->getMessage()}"); + return ['error' => "Kesalahan sistem internal: {$e->getMessage()}"]; } } } \ No newline at end of file diff --git a/sista_mif/composer.json b/sista_mif/composer.json index f6e95a8..57c92c8 100644 --- a/sista_mif/composer.json +++ b/sista_mif/composer.json @@ -7,6 +7,8 @@ "license": "MIT", "require": { "php": "^8.3", + "barryvdh/laravel-dompdf": "^3.1", + "guzzlehttp/guzzle": "^7.10", "laravel/framework": "^13.8", "laravel/tinker": "^3.0" }, diff --git a/sista_mif/composer.lock b/sista_mif/composer.lock index e16d637..3c7b273 100644 --- a/sista_mif/composer.lock +++ b/sista_mif/composer.lock @@ -4,8 +4,85 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "efc013d7b1ca50dea035fa69d50022c4", + "content-hash": "dd902983f1193a2e136efdb6349c690e", "packages": [ + { + "name": "barryvdh/laravel-dompdf", + "version": "v3.1.2", + "source": { + "type": "git", + "url": "https://github.com/barryvdh/laravel-dompdf.git", + "reference": "ee3b72b19ccdf57d0243116ecb2b90261344dedc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/barryvdh/laravel-dompdf/zipball/ee3b72b19ccdf57d0243116ecb2b90261344dedc", + "reference": "ee3b72b19ccdf57d0243116ecb2b90261344dedc", + "shasum": "" + }, + "require": { + "dompdf/dompdf": "^3.0", + "illuminate/support": "^9|^10|^11|^12|^13.0", + "php": "^8.1" + }, + "require-dev": { + "larastan/larastan": "^2.7|^3.0", + "orchestra/testbench": "^7|^8|^9.16|^10|^11.0", + "phpro/grumphp": "^2.5", + "squizlabs/php_codesniffer": "^3.5" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "PDF": "Barryvdh\\DomPDF\\Facade\\Pdf", + "Pdf": "Barryvdh\\DomPDF\\Facade\\Pdf" + }, + "providers": [ + "Barryvdh\\DomPDF\\ServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "psr-4": { + "Barryvdh\\DomPDF\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Barry vd. Heuvel", + "email": "barryvdh@gmail.com" + } + ], + "description": "A DOMPDF Wrapper for Laravel", + "keywords": [ + "dompdf", + "laravel", + "pdf" + ], + "support": { + "issues": "https://github.com/barryvdh/laravel-dompdf/issues", + "source": "https://github.com/barryvdh/laravel-dompdf/tree/v3.1.2" + }, + "funding": [ + { + "url": "https://fruitcake.nl", + "type": "custom" + }, + { + "url": "https://github.com/barryvdh", + "type": "github" + } + ], + "time": "2026-02-21T08:51:10+00:00" + }, { "name": "brick/math", "version": "0.14.8", @@ -377,6 +454,161 @@ ], "time": "2024-02-05T11:56:58+00:00" }, + { + "name": "dompdf/dompdf", + "version": "v3.1.5", + "source": { + "type": "git", + "url": "https://github.com/dompdf/dompdf.git", + "reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/dompdf/zipball/f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496", + "reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496", + "shasum": "" + }, + "require": { + "dompdf/php-font-lib": "^1.0.0", + "dompdf/php-svg-lib": "^1.0.0", + "ext-dom": "*", + "ext-mbstring": "*", + "masterminds/html5": "^2.0", + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "ext-gd": "*", + "ext-json": "*", + "ext-zip": "*", + "mockery/mockery": "^1.3", + "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11", + "squizlabs/php_codesniffer": "^3.5", + "symfony/process": "^4.4 || ^5.4 || ^6.2 || ^7.0" + }, + "suggest": { + "ext-gd": "Needed to process images", + "ext-gmagick": "Improves image processing performance", + "ext-imagick": "Improves image processing performance", + "ext-zlib": "Needed for pdf stream compression" + }, + "type": "library", + "autoload": { + "psr-4": { + "Dompdf\\": "src/" + }, + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1" + ], + "authors": [ + { + "name": "The Dompdf Community", + "homepage": "https://github.com/dompdf/dompdf/blob/master/AUTHORS.md" + } + ], + "description": "DOMPDF is a CSS 2.1 compliant HTML to PDF converter", + "homepage": "https://github.com/dompdf/dompdf", + "support": { + "issues": "https://github.com/dompdf/dompdf/issues", + "source": "https://github.com/dompdf/dompdf/tree/v3.1.5" + }, + "time": "2026-03-03T13:54:37+00:00" + }, + { + "name": "dompdf/php-font-lib", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/dompdf/php-font-lib.git", + "reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/php-font-lib/zipball/a6e9a688a2a80016ac080b97be73d3e10c444c9a", + "reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11 || ^12" + }, + "type": "library", + "autoload": { + "psr-4": { + "FontLib\\": "src/FontLib" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-or-later" + ], + "authors": [ + { + "name": "The FontLib Community", + "homepage": "https://github.com/dompdf/php-font-lib/blob/master/AUTHORS.md" + } + ], + "description": "A library to read, parse, export and make subsets of different types of font files.", + "homepage": "https://github.com/dompdf/php-font-lib", + "support": { + "issues": "https://github.com/dompdf/php-font-lib/issues", + "source": "https://github.com/dompdf/php-font-lib/tree/1.0.2" + }, + "time": "2026-01-20T14:10:26+00:00" + }, + { + "name": "dompdf/php-svg-lib", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/dompdf/php-svg-lib.git", + "reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/php-svg-lib/zipball/8259ffb930817e72b1ff1caef5d226501f3dfeb1", + "reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^7.1 || ^8.0", + "sabberworm/php-css-parser": "^8.4 || ^9.0" + }, + "require-dev": { + "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11" + }, + "type": "library", + "autoload": { + "psr-4": { + "Svg\\": "src/Svg" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "The SvgLib Community", + "homepage": "https://github.com/dompdf/php-svg-lib/blob/master/AUTHORS.md" + } + ], + "description": "A library to read, parse and export to PDF SVG files.", + "homepage": "https://github.com/dompdf/php-svg-lib", + "support": { + "issues": "https://github.com/dompdf/php-svg-lib/issues", + "source": "https://github.com/dompdf/php-svg-lib/tree/1.0.2" + }, + "time": "2026-01-02T16:01:13+00:00" + }, { "name": "dragonmantank/cron-expression", "version": "v3.6.0", @@ -2026,6 +2258,73 @@ ], "time": "2026-03-08T20:05:35+00:00" }, + { + "name": "masterminds/html5", + "version": "2.10.0", + "source": { + "type": "git", + "url": "https://github.com/Masterminds/html5-php.git", + "reference": "fcf91eb64359852f00d921887b219479b4f21251" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fcf91eb64359852f00d921887b219479b4f21251", + "reference": "fcf91eb64359852f00d921887b219479b4f21251", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Masterminds\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Matt Butcher", + "email": "technosophos@gmail.com" + }, + { + "name": "Matt Farina", + "email": "matt@mattfarina.com" + }, + { + "name": "Asmir Mustafic", + "email": "goetas@gmail.com" + } + ], + "description": "An HTML5 parser and serializer.", + "homepage": "http://masterminds.github.io/html5-php", + "keywords": [ + "HTML5", + "dom", + "html", + "parser", + "querypath", + "serializer", + "xml" + ], + "support": { + "issues": "https://github.com/Masterminds/html5-php/issues", + "source": "https://github.com/Masterminds/html5-php/tree/2.10.0" + }, + "time": "2025-07-25T09:04:22+00:00" + }, { "name": "monolog/monolog", "version": "3.10.0", @@ -3301,6 +3600,86 @@ }, "time": "2025-12-14T04:43:48+00:00" }, + { + "name": "sabberworm/php-css-parser", + "version": "v9.3.0", + "source": { + "type": "git", + "url": "https://github.com/MyIntervals/PHP-CSS-Parser.git", + "reference": "88dbd0f7f91abbfe4402d0a3071e9ff4d81ed949" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/88dbd0f7f91abbfe4402d0a3071e9ff4d81ed949", + "reference": "88dbd0f7f91abbfe4402d0a3071e9ff4d81ed949", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": "^7.2.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "thecodingmachine/safe": "^1.3 || ^2.5 || ^3.4" + }, + "require-dev": { + "php-parallel-lint/php-parallel-lint": "1.4.0", + "phpstan/extension-installer": "1.4.3", + "phpstan/phpstan": "1.12.32 || 2.1.32", + "phpstan/phpstan-phpunit": "1.4.2 || 2.0.8", + "phpstan/phpstan-strict-rules": "1.6.2 || 2.0.7", + "phpunit/phpunit": "8.5.52", + "rawr/phpunit-data-provider": "3.3.1", + "rector/rector": "1.2.10 || 2.2.8", + "rector/type-perfect": "1.0.0 || 2.1.0", + "squizlabs/php_codesniffer": "4.0.1", + "thecodingmachine/phpstan-safe-rule": "1.2.0 || 1.4.1" + }, + "suggest": { + "ext-mbstring": "for parsing UTF-8 CSS" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.4.x-dev" + } + }, + "autoload": { + "files": [ + "src/Rule/Rule.php", + "src/RuleSet/RuleContainer.php" + ], + "psr-4": { + "Sabberworm\\CSS\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Raphael Schweikert" + }, + { + "name": "Oliver Klee", + "email": "github@oliverklee.de" + }, + { + "name": "Jake Hotson", + "email": "jake.github@qzdesign.co.uk" + } + ], + "description": "Parser for CSS Files written in PHP", + "homepage": "https://www.sabberworm.com/blog/2010/6/10/php-css-parser", + "keywords": [ + "css", + "parser", + "stylesheet" + ], + "support": { + "issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues", + "source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.3.0" + }, + "time": "2026-03-03T17:31:43+00:00" + }, { "name": "symfony/clock", "version": "v7.4.8", @@ -5890,6 +6269,149 @@ ], "time": "2026-03-30T13:44:50+00:00" }, + { + "name": "thecodingmachine/safe", + "version": "v3.4.0", + "source": { + "type": "git", + "url": "https://github.com/thecodingmachine/safe.git", + "reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thecodingmachine/safe/zipball/705683a25bacf0d4860c7dea4d7947bfd09eea19", + "reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpstan/phpstan": "^2", + "phpunit/phpunit": "^10", + "squizlabs/php_codesniffer": "^3.2" + }, + "type": "library", + "autoload": { + "files": [ + "lib/special_cases.php", + "generated/apache.php", + "generated/apcu.php", + "generated/array.php", + "generated/bzip2.php", + "generated/calendar.php", + "generated/classobj.php", + "generated/com.php", + "generated/cubrid.php", + "generated/curl.php", + "generated/datetime.php", + "generated/dir.php", + "generated/eio.php", + "generated/errorfunc.php", + "generated/exec.php", + "generated/fileinfo.php", + "generated/filesystem.php", + "generated/filter.php", + "generated/fpm.php", + "generated/ftp.php", + "generated/funchand.php", + "generated/gettext.php", + "generated/gmp.php", + "generated/gnupg.php", + "generated/hash.php", + "generated/ibase.php", + "generated/ibmDb2.php", + "generated/iconv.php", + "generated/image.php", + "generated/imap.php", + "generated/info.php", + "generated/inotify.php", + "generated/json.php", + "generated/ldap.php", + "generated/libxml.php", + "generated/lzf.php", + "generated/mailparse.php", + "generated/mbstring.php", + "generated/misc.php", + "generated/mysql.php", + "generated/mysqli.php", + "generated/network.php", + "generated/oci8.php", + "generated/opcache.php", + "generated/openssl.php", + "generated/outcontrol.php", + "generated/pcntl.php", + "generated/pcre.php", + "generated/pgsql.php", + "generated/posix.php", + "generated/ps.php", + "generated/pspell.php", + "generated/readline.php", + "generated/rnp.php", + "generated/rpminfo.php", + "generated/rrd.php", + "generated/sem.php", + "generated/session.php", + "generated/shmop.php", + "generated/sockets.php", + "generated/sodium.php", + "generated/solr.php", + "generated/spl.php", + "generated/sqlsrv.php", + "generated/ssdeep.php", + "generated/ssh2.php", + "generated/stream.php", + "generated/strings.php", + "generated/swoole.php", + "generated/uodbc.php", + "generated/uopz.php", + "generated/url.php", + "generated/var.php", + "generated/xdiff.php", + "generated/xml.php", + "generated/xmlrpc.php", + "generated/yaml.php", + "generated/yaz.php", + "generated/zip.php", + "generated/zlib.php" + ], + "classmap": [ + "lib/DateTime.php", + "lib/DateTimeImmutable.php", + "lib/Exceptions/", + "generated/Exceptions/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHP core functions that throw exceptions instead of returning FALSE on error", + "support": { + "issues": "https://github.com/thecodingmachine/safe/issues", + "source": "https://github.com/thecodingmachine/safe/tree/v3.4.0" + }, + "funding": [ + { + "url": "https://github.com/OskarStark", + "type": "github" + }, + { + "url": "https://github.com/shish", + "type": "github" + }, + { + "url": "https://github.com/silasjoisten", + "type": "github" + }, + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2026-02-04T18:08:13+00:00" + }, { "name": "tijsverkoyen/css-to-inline-styles", "version": "v2.4.0", diff --git a/sista_mif/config/services.php b/sista_mif/config/services.php index 6a90eb8..3464151 100644 --- a/sista_mif/config/services.php +++ b/sista_mif/config/services.php @@ -35,4 +35,10 @@ ], ], + 'fastapi' => [ + 'worker_url' => env('FASTAPI_WORKER_URL', 'http://127.0.0.1:8001'), + 'secret_key' => env('FASTAPI_SECRET_KEY'), + 'timeout' => env('FASTAPI_TIMEOUT', 120), + ], + ]; diff --git a/sista_mif/database/migrations/2026_05_23_133342_create_classification_results_table.php b/sista_mif/database/migrations/2026_05_23_133342_create_classification_results_table.php index 5f7e37b..efbdbec 100644 --- a/sista_mif/database/migrations/2026_05_23_133342_create_classification_results_table.php +++ b/sista_mif/database/migrations/2026_05_23_133342_create_classification_results_table.php @@ -18,7 +18,7 @@ public function up(): void $table->string('tahun_lulus')->nullable(); $table->text('job_text_raw'); $table->enum('source_type', ['internal_mif', 'kemendik'])->default('internal_mif'); - $table->enum('predicted_profile', ['Programmer', 'Data Analyst', 'Wirausaha Informatika', 'Non-IT'])->nullable(); + $table->string('predicted_profile')->nullable(); $table->decimal('confidence_score', 5, 4)->nullable(); $table->string('classification_method')->nullable(); $table->enum('status', ['auto_classified', 'needs_review', 'manual_override', 'failed'])->default('needs_review'); diff --git a/sista_mif/database/migrations/2026_05_23_133732_create_internal_raw_data_table.php b/sista_mif/database/migrations/2026_05_23_133732_create_internal_raw_data_table.php index 7624da5..d257e54 100644 --- a/sista_mif/database/migrations/2026_05_23_133732_create_internal_raw_data_table.php +++ b/sista_mif/database/migrations/2026_05_23_133732_create_internal_raw_data_table.php @@ -13,8 +13,37 @@ public function up(): void { Schema::create('internal_raw_data', function (Blueprint $table) { $table->id(); + $table->string('nama_lengkap')->nullable(); $table->string('nim')->index(); - $table->json('raw_data')->nullable(); + $table->string('email')->nullable(); + $table->string('no_telepon')->nullable(); + $table->text('alamat_domisili')->nullable(); + $table->string('jurusan')->nullable(); + $table->string('program_studi')->nullable(); + $table->string('tahun_masuk')->nullable(); + $table->string('tahun_lulus')->nullable(); + $table->string('status_pekerjaan')->nullable(); + $table->string('klasifikasi_pekerjaan')->nullable(); + $table->string('nama_perusahaan')->nullable(); + $table->string('jenis_perusahaan')->nullable(); + $table->string('jabatan')->nullable(); + $table->string('lokasi_perusahaan')->nullable(); + $table->text('alamat_perusahaan')->nullable(); + $table->text('deskripsi_pekerjaan')->nullable(); + $table->string('tahun_mulai_kerja')->nullable(); + $table->string('masa_kerja_bulan')->nullable(); + $table->string('jumlah_lamaran_dikirim')->nullable(); + $table->string('jumlah_respons_lamaran')->nullable(); + $table->string('jumlah_undangan_wawancara')->nullable(); + $table->string('masa_tunggu_pra_lulus')->nullable(); + $table->string('masa_tunggu_pasca_lulus')->nullable(); + $table->string('total_masa_tunggu')->nullable(); + $table->string('instansi_terupdate')->nullable(); + $table->string('jabatan_terupdate')->nullable(); + $table->string('tahun_bekerja')->nullable(); + $table->string('status_pekerjaan_terupdate')->nullable(); + $table->string('linkedin_profile')->nullable(); + $table->string('sosmed_ig')->nullable(); $table->timestamps(); }); } diff --git a/sista_mif/package-lock.json b/sista_mif/package-lock.json index fa12313..1a675b5 100644 --- a/sista_mif/package-lock.json +++ b/sista_mif/package-lock.json @@ -29,29 +29,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", @@ -931,6 +908,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -1916,6 +1894,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", @@ -2323,6 +2302,7 @@ "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -2443,6 +2423,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -2531,6 +2512,7 @@ "integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", diff --git a/sista_mif/resources/views/dashboard.blade.php b/sista_mif/resources/views/dashboard.blade.php index f1b1c88..6968d2f 100644 --- a/sista_mif/resources/views/dashboard.blade.php +++ b/sista_mif/resources/views/dashboard.blade.php @@ -421,6 +421,7 @@ class="inline-flex items-center gap-2 px-6 py-3 rounded-xl text-sm font-semibold @php $colorClass = match($row->predicted_profile) { 'Programmer' => 'bg-emerald-100 text-emerald-700 border-emerald-200', + 'Tidak Diketahui' => 'bg-slate-100 text-slate-500 border-slate-200', 'Non-IT' => 'bg-slate-100 text-slate-700 border-slate-200', default => 'bg-blue-100 text-blue-700 border-blue-200', }; @@ -459,6 +460,57 @@ class="inline-flex items-center gap-2 px-6 py-3 rounded-xl text-sm font-semibold @method('DELETE') +
+ + + + +
@empty @@ -508,6 +560,7 @@ class="inline-flex items-center gap-2 px-6 py-3 rounded-xl text-sm font-semibold +

Status akan otomatis berubah menjadi Manual setelah diperbarui.

diff --git a/sista_mif/routes/web.php b/sista_mif/routes/web.php index 14068f3..83336ac 100644 --- a/sista_mif/routes/web.php +++ b/sista_mif/routes/web.php @@ -1,22 +1,30 @@ group(function () { - Route::get('/dashboard', [ClassificationController::class, 'dashboard'])->name('dashboard'); - Route::get('/kemendik', [ClassificationController::class, 'kemendik'])->name('kemendik'); - Route::post('/upload', [ClassificationController::class, 'upload'])->name('upload.process'); - Route::put('/internal/{id}', [ClassificationController::class, 'update'])->name('internal.update'); - Route::delete('/internal/{id}', [ClassificationController::class, 'destroy'])->name('internal.destroy'); - Route::post('/internal/bulk-delete', [ClassificationController::class, 'bulkDestroy'])->name('internal.bulk_destroy'); - Route::post('/internal/bulk-update', [ClassificationController::class, 'bulkUpdate'])->name('internal.bulk_update'); - Route::get('/internal/download-pdf', [ClassificationController::class, 'downloadPdf'])->name('internal.download_pdf'); - Route::post('/retrain', [ClassificationController::class, 'retrain'])->name('retrain.trigger'); - Route::get('/retrain/status', [ClassificationController::class, 'retrainStatus'])->name('retrain.status'); + Route::get('/dashboard', [InternalDataController::class, 'index'])->name('dashboard'); + Route::get('/kemendik', [KemendikDataController::class, 'index'])->name('kemendik'); + + // Import Data + Route::post('/upload', [ClassificationTaskController::class, 'upload'])->name('upload.process'); + + // Internal Data Management + Route::put('/internal/{id}', [InternalDataController::class, 'update'])->name('internal.update'); + Route::delete('/internal/{id}', [InternalDataController::class, 'destroy'])->name('internal.destroy'); + Route::post('/internal/bulk-delete', [InternalDataController::class, 'bulkDestroy'])->name('internal.bulk_destroy'); + Route::post('/internal/bulk-update', [InternalDataController::class, 'bulkUpdate'])->name('internal.bulk_update'); + Route::get('/internal/download-pdf', [InternalDataController::class, 'exportPdf'])->name('internal.download_pdf'); + + // ML Task + Route::post('/retrain', [ClassificationTaskController::class, 'retrain'])->name('retrain.trigger'); + Route::get('/retrain/status', [ClassificationTaskController::class, 'retrainStatus'])->name('retrain.status'); Route::prefix('profile')->group(function () {