perbaikan
This commit is contained in:
parent
51fc725c41
commit
ce3ee0d1a3
|
|
@ -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
|
||||
|
|
@ -0,0 +1 @@
|
|||
FASTAPI_SECRET_KEY=
|
||||
|
|
@ -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()}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -1,371 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use App\Models\ClassificationResult;
|
||||
use App\Models\KemendikRawData;
|
||||
use App\Services\FastApiWorkerService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ClassificationController extends Controller
|
||||
{
|
||||
public function __construct(protected FastApiWorkerService $fastApi) {}
|
||||
|
||||
public function upload(Request $request) {
|
||||
$request->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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,195 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use App\Models\ClassificationResult;
|
||||
use App\Models\KemendikRawData;
|
||||
use App\Models\InternalRawData;
|
||||
use App\Services\FastApiWorkerService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ClassificationTaskController extends Controller
|
||||
{
|
||||
public function __construct(protected FastApiWorkerService $fastApi) {}
|
||||
|
||||
public function upload(Request $request) {
|
||||
$request->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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,179 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use App\Models\ClassificationResult;
|
||||
use App\Models\InternalRawData;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class InternalDataController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$stats = [
|
||||
'total_internal' => 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');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use App\Models\KemendikRawData;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class KemendikDataController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$stats = [
|
||||
'total_kemendik' => 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -7,9 +7,5 @@
|
|||
class InternalRawData extends Model
|
||||
{
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected $casts = [
|
||||
'raw_data' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()}"];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
],
|
||||
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
<button type="submit" class="text-rose-600 hover:text-rose-900 transition-colors">Hapus</button>
|
||||
</form>
|
||||
<div x-data="{ openDetail: false }" class="inline-block text-left ml-2">
|
||||
<button @click="openDetail = true" class="text-purple-600 hover:text-purple-900 transition-colors">Detail</button>
|
||||
|
||||
<!-- Modal Detail -->
|
||||
<div x-show="openDetail" style="display: none;" class="fixed inset-0 z-[70] overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
|
||||
<div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div x-show="openDetail" @click="openDetail = false" x-transition.opacity class="fixed inset-0 bg-slate-900 bg-opacity-50 transition-opacity" aria-hidden="true"></div>
|
||||
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">​</span>
|
||||
<div x-show="openDetail" x-transition class="inline-block align-bottom bg-white rounded-2xl text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-3xl w-full">
|
||||
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4 border-b border-slate-100">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg leading-6 font-bold text-slate-800" id="modal-title">
|
||||
Detail Data Internal - {{ $row->nama }}
|
||||
</h3>
|
||||
<button @click="openDetail = false" class="text-slate-400 hover:text-slate-600 focus:outline-none">
|
||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="max-h-[60vh] overflow-y-auto">
|
||||
<table class="min-w-full divide-y divide-slate-200">
|
||||
<tbody class="bg-white divide-y divide-slate-100">
|
||||
@php
|
||||
$raw = \App\Models\InternalRawData::where('nim', $row->nim)->first();
|
||||
@endphp
|
||||
@if($raw)
|
||||
@foreach($raw->getAttributes() as $key => $val)
|
||||
@if(!in_array($key, ['id', 'created_at', 'updated_at']) && $val !== null && $val !== '')
|
||||
<tr>
|
||||
<td class="px-4 py-3 text-sm font-medium text-slate-700 w-1/3 capitalize whitespace-normal">{{ str_replace('_', ' ', $key) }}</td>
|
||||
<td class="px-4 py-3 text-sm text-slate-600 w-2/3 whitespace-normal">{{ $val }}</td>
|
||||
</tr>
|
||||
@endif
|
||||
@endforeach
|
||||
@else
|
||||
<tr>
|
||||
<td colspan="2" class="px-4 py-3 text-sm text-slate-500 text-center">Data detail tidak tersedia.</td>
|
||||
</tr>
|
||||
@endif
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-slate-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse border-t border-slate-100">
|
||||
<button type="button" @click="openDetail = false" class="mt-3 w-full inline-flex justify-center rounded-xl border border-slate-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-slate-700 hover:bg-slate-50 focus:outline-none sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm">
|
||||
Tutup
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
|
|
@ -508,6 +560,7 @@ class="inline-flex items-center gap-2 px-6 py-3 rounded-xl text-sm font-semibold
|
|||
<option value="Data Analyst">Data Analyst</option>
|
||||
<option value="Wirausaha Informatika">Wirausaha Informatika</option>
|
||||
<option value="Non-IT">Non-IT</option>
|
||||
<option value="Tidak Diketahui">Tidak Diketahui</option>
|
||||
</select>
|
||||
<p class="mt-2 text-xs text-slate-400">Status akan otomatis berubah menjadi <span class="font-semibold text-blue-600">Manual</span> setelah diperbarui.</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,22 +1,30 @@
|
|||
<?php
|
||||
|
||||
use App\Http\Controllers\ProfileController;
|
||||
use App\Http\Controllers\ClassificationController;
|
||||
use App\Http\Controllers\InternalDataController;
|
||||
use App\Http\Controllers\KemendikDataController;
|
||||
use App\Http\Controllers\ClassificationTaskController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::get('/', function () { return view('auth.login'); });
|
||||
|
||||
Route::middleware(['auth'])->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 () {
|
||||
|
|
|
|||
Loading…
Reference in New Issue