diff --git a/app/Http/Controllers/BKController.php b/app/Http/Controllers/BKController.php new file mode 100644 index 0000000..79d9f68 --- /dev/null +++ b/app/Http/Controllers/BKController.php @@ -0,0 +1,319 @@ +count(); + $totalRekomendasi = Recommendation::count(); + $totalChatHistory = ChatHistory::count(); + $totalJurusan = PolijeMajor::count(); + + $recentStudents = User::where('role', 'siswa') + ->orderBy('created_at', 'desc') + ->take(5) + ->get(); + + $recentRecommendations = Recommendation::with('user') + ->orderBy('created_at', 'desc') + ->take(5) + ->get(); + + $kelompokStats = User::where('role', 'siswa') + ->selectRaw('kelompok_asal, COUNT(*) as count') + ->groupBy('kelompok_asal') + ->get(); + + $topMajors = Recommendation::selectRaw(" + JSON_EXTRACT(hasil_rekomendasi, '$[0].jurusan') as major_name, + COUNT(*) as count + ") + ->groupByRaw("JSON_EXTRACT(hasil_rekomendasi, '$[0].jurusan')") + ->orderBy('count', 'desc') + ->take(5) + ->get(); + + return view('bk.dashboard', compact( + 'totalSiswa', + 'totalRekomendasi', + 'totalChatHistory', + 'totalJurusan', + 'recentStudents', + 'recentRecommendations', + 'kelompokStats', + 'topMajors' + )); + } + + // ============================================ + // 2. DATA SISWA (Read + Update) + // ============================================ + public function students(Request $request) + { + $query = User::where('role', 'siswa') + ->withCount('recommendations', 'chatHistories'); + + if ($request->filled('search')) { + $search = $request->search; + $query->where(function ($q) use ($search) { + $q->where('name', 'like', "%{$search}%") + ->orWhere('nis', 'like', "%{$search}%"); + }); + } + + if ($request->filled('kelompok')) { + $query->where('kelompok_asal', $request->kelompok); + } + + $students = $query->orderBy('created_at', 'desc')->paginate(20); + + return view('bk.students.index', compact('students')); + } + + public function studentDetail($id) + { + $student = User::findOrFail($id); + $recommendations = Recommendation::where('user_id', $id) + ->orderBy('created_at', 'desc') + ->get(); + $chatHistories = ChatHistory::where('user_id', $id) + ->orderBy('created_at', 'desc') + ->get(); + + return view('bk.students.detail', compact('student', 'recommendations', 'chatHistories')); + } + + public function chatHistory($id) + { + $user = User::findOrFail($id); + $chatHistories = ChatHistory::where('user_id', $id) + ->orderBy('created_at', 'asc') + ->get(); + + return view('bk.chat-history', compact('user', 'chatHistories')); + } + + // ============================================ + // 3. HASIL REKOMENDASI JURUSAN + // ============================================ + public function riwayatRekomendasi(Request $request) + { + $query = Recommendation::with('user'); + + if ($request->filled('search')) { + $search = $request->search; + $query->whereHas('user', function ($q) use ($search) { + $q->where('name', 'like', "%{$search}%"); + }); + } + + $recommendations = $query->orderBy('created_at', 'desc')->paginate(20); + + $uniqueStudents = Recommendation::distinct('user_id')->count('user_id'); + + $topMajorRow = Recommendation::selectRaw(" + JSON_EXTRACT(hasil_rekomendasi, '$[0].jurusan') as major_name, + COUNT(*) as count + ") + ->groupByRaw("JSON_EXTRACT(hasil_rekomendasi, '$[0].jurusan')") + ->orderBy('count', 'desc') + ->first(); + + $topMajor = $topMajorRow ? trim($topMajorRow->major_name, '"') : null; + + return view('bk.riwayat-rekomendasi.index', compact('recommendations', 'uniqueStudents', 'topMajor')); + } + + // ============================================ + // 4. RIWAYAT KONSULTASI CHATBOT + // ============================================ + public function riwayatChatbot(Request $request) + { + $query = ChatHistory::with('user'); + + if ($request->filled('search')) { + $search = $request->search; + $query->where(function ($q) use ($search) { + $q->where('prompt', 'like', "%{$search}%") + ->orWhere('response', 'like', "%{$search}%") + ->orWhereHas('user', function ($q2) use ($search) { + $q2->where('name', 'like', "%{$search}%"); + }); + }); + } + + $chatHistories = $query->orderBy('created_at', 'desc')->paginate(20); + + $uniqueStudents = ChatHistory::distinct('user_id')->count('user_id'); + $todayCount = ChatHistory::whereDate('created_at', today())->count(); + + return view('bk.riwayat-chatbot.index', compact('chatHistories', 'uniqueStudents', 'todayCount')); + } + + // ============================================ + // 5. MANAJEMEN JURUSAN (CRUD) + // ============================================ + public function jurusan() + { + $jurusanList = PolijeMajor::orderBy('nama_jurusan')->get(); + return view('bk.jurusan.index', compact('jurusanList')); + } + + public function jurusanCreate() + { + return view('bk.jurusan.create'); + } + + public function jurusanStore(Request $request) + { + $request->validate([ + 'nama_jurusan' => 'required|string|max:255|unique:polije_majors,nama_jurusan', + 'deskripsi' => 'nullable|string|max:1000', + 'keywords' => 'nullable|string', + 'preferensi_studi' => 'nullable|string', + 'prospek_kerja' => 'nullable|string|max:1000', + 'bobot_mtk' => 'nullable|numeric|min:0|max:1', + 'bobot_fisika' => 'nullable|numeric|min:0|max:1', + 'bobot_kimia' => 'nullable|numeric|min:0|max:1', + 'bobot_biologi' => 'nullable|numeric|min:0|max:1', + 'bobot_ekonomi' => 'nullable|numeric|min:0|max:1', + 'bobot_geografi' => 'nullable|numeric|min:0|max:1', + 'bobot_sosiologi' => 'nullable|numeric|min:0|max:1', + 'bobot_sejarah' => 'nullable|numeric|min:0|max:1', + ]); + + PolijeMajor::create([ + 'nama_jurusan' => $request->nama_jurusan, + 'deskripsi' => $request->deskripsi, + 'keywords' => $this->parseTagInput($request->keywords), + 'preferensi_studi' => $this->parseTagInput($request->preferensi_studi), + 'prospek_kerja' => $request->prospek_kerja, + 'bobot_mapel' => $this->parseBobotMapel($request), + ]); + + return redirect()->route('bk.jurusan')->with('success', 'Jurusan berhasil ditambahkan!'); + } + + public function jurusanEdit($id) + { + $jurusan = PolijeMajor::findOrFail($id); + return view('bk.jurusan.edit', compact('jurusan')); + } + + public function jurusanUpdate(Request $request, $id) + { + $jurusan = PolijeMajor::findOrFail($id); + + $request->validate([ + 'nama_jurusan' => ['required', 'string', 'max:255', Rule::unique('polije_majors', 'nama_jurusan')->ignore($jurusan->id)], + 'deskripsi' => 'nullable|string|max:1000', + 'keywords' => 'nullable|string', + 'preferensi_studi' => 'nullable|string', + 'prospek_kerja' => 'nullable|string|max:1000', + 'bobot_mtk' => 'nullable|numeric|min:0|max:1', + 'bobot_fisika' => 'nullable|numeric|min:0|max:1', + 'bobot_kimia' => 'nullable|numeric|min:0|max:1', + 'bobot_biologi' => 'nullable|numeric|min:0|max:1', + 'bobot_ekonomi' => 'nullable|numeric|min:0|max:1', + 'bobot_geografi' => 'nullable|numeric|min:0|max:1', + 'bobot_sosiologi' => 'nullable|numeric|min:0|max:1', + 'bobot_sejarah' => 'nullable|numeric|min:0|max:1', + ]); + + $jurusan->update([ + 'nama_jurusan' => $request->nama_jurusan, + 'deskripsi' => $request->deskripsi, + 'keywords' => $this->parseTagInput($request->keywords), + 'preferensi_studi' => $this->parseTagInput($request->preferensi_studi), + 'prospek_kerja' => $request->prospek_kerja, + 'bobot_mapel' => $this->parseBobotMapel($request), + ]); + + return redirect()->route('bk.jurusan')->with('success', 'Jurusan berhasil diperbarui!'); + } + + public function jurusanDestroy($id) + { + $jurusan = PolijeMajor::findOrFail($id); + $jurusan->delete(); + + return redirect()->route('bk.jurusan')->with('success', 'Jurusan berhasil dihapus!'); + } + + private function parseTagInput(?string $input): array + { + if (empty($input)) return []; + return array_values(array_filter(array_map('trim', explode(',', $input)))); + } + + private function parseBobotMapel(Request $request): array + { + $mapelList = ['mtk', 'fisika', 'kimia', 'biologi', 'ekonomi', 'geografi', 'sosiologi', 'sejarah']; + $bobot = []; + foreach ($mapelList as $mapel) { + $value = $request->input("bobot_{$mapel}"); + if (!is_null($value) && $value !== '') { + $bobot[$mapel] = floatval($value); + } + } + return $bobot; + } + + // ============================================ + // 6. PROFIL GURU BK + // ============================================ + public function profil() + { + $guru = Auth::user(); + return view('bk.profil.index', compact('guru')); + } + + public function updateProfil(Request $request) + { + $guru = Auth::user(); + + $request->validate([ + 'name' => 'required|string|max:255', + 'email' => ['required', 'email', Rule::unique('users')->ignore($guru->id)], + ]); + + $guru->name = $request->name; + $guru->email = $request->email; + $guru->save(); + + return redirect()->route('bk.profil')->with('success', 'Profil berhasil diperbarui!'); + } + + public function updatePassword(Request $request) + { + $request->validate([ + 'current_password' => 'required', + 'password' => 'required|string|min:8|confirmed', + ]); + + $guru = Auth::user(); + + if (!Hash::check($request->current_password, $guru->password)) { + return back()->withErrors(['current_password' => 'Password lama salah.']); + } + + $guru->password = Hash::make($request->password); + $guru->save(); + + return redirect()->route('bk.profil')->with('success', 'Password berhasil diubah!'); + } +} diff --git a/app/Http/Controllers/ChatbotController.php b/app/Http/Controllers/ChatbotController.php index 9c814bb..30f3316 100644 --- a/app/Http/Controllers/ChatbotController.php +++ b/app/Http/Controllers/ChatbotController.php @@ -4,8 +4,10 @@ use App\Services\GeminiService; use App\Models\ChatHistory; +use App\Models\Recommendation; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Str; class ChatbotController extends Controller { @@ -17,88 +19,132 @@ public function __construct(GeminiService $geminiService) $this->geminiService = $geminiService; } - public function index() + /** + * Tampilkan halaman chatbot. + * Jika ada ?session=xxx, lanjutkan sesi lama (pakai rekomendasi saat itu). + * Jika ada ?rec=xxx, buat sesi baru terkait rekomendasi tertentu. + * Jika tidak ada parameter, buat sesi baru dengan rekomendasi terbaru. + */ + public function index(Request $request) { $user = Auth::user(); - $recentRecommendation = session('recomendation_data', null); + $sessionId = $request->query('session'); + $recId = $request->query('rec'); + $previousMessages = []; + $recommendationId = null; - // Jika session kosong, ambil rekomendasi terakhir dari database - if (!$recentRecommendation) { - $lastRec = \App\Models\Recommendation::where('user_id', $user->id) - ->latest() - ->first(); + if ($sessionId) { + // Lanjutkan sesi lama — ambil semua chat dari sesi ini + $chats = ChatHistory::where('user_id', $user->id) + ->where('session_id', $sessionId) + ->orderBy('created_at', 'asc') + ->get(); - if ($lastRec) { - $hasil = json_decode($lastRec->hasil_rekomendasi, true); - $topJurusan = $hasil[0] ?? null; - $recentRecommendation = [ - 'jurusan' => $topJurusan['jurusan'] ?? null, - 'skor' => $topJurusan['skor'] ?? null, - 'nilai' => $lastRec->nilai_akademik, - 'minat' => $lastRec->minat, - 'pref_studi' => $lastRec->preferensi_studi, - ]; + if ($chats->isEmpty()) { + // Session tidak valid, buat baru + $sessionId = Str::uuid()->toString(); + } else { + // Ambil recommendation_id dari sesi ini + $recommendationId = $chats->first()->recommendation_id; + + foreach ($chats as $chat) { + $previousMessages[] = [ + 'role' => 'user', + 'text' => $chat->prompt, + ]; + $previousMessages[] = [ + 'role' => 'ai', + 'text' => $this->stripMarkdown($chat->response), + ]; + } } + } else { + // Sesi baru + $sessionId = Str::uuid()->toString(); } + // Tentukan recommendation_id: + // 1. Dari sesi lama (sudah diset di atas) + // 2. Dari parameter ?rec= (klik dari hasil rekomendasi) + // 3. Dari rekomendasi terbaru user + if (!$recommendationId && $recId) { + $rec = Recommendation::where('id', $recId) + ->where('user_id', $user->id) + ->first(); + $recommendationId = $rec ? $rec->id : null; + } + + if (!$recommendationId) { + $latestRec = Recommendation::where('user_id', $user->id)->latest()->first(); + $recommendationId = $latestRec ? $latestRec->id : null; + } + + // Ambil konteks rekomendasi berdasarkan ID spesifik + $recentRecommendation = $this->getRecommendationContext($user, $recommendationId); + return view('chatbot.index', [ - 'recommendation' => $recentRecommendation + 'recommendation' => $recentRecommendation, + 'sessionId' => $sessionId, + 'previousMessages' => $previousMessages, + 'recommendationId' => $recommendationId, ]); } + /** + * Kirim pesan dan terima respons AI + */ public function send(Request $request) { $request->validate([ 'message' => 'required|string|max:1000', + 'sessionId' => 'required|string|max:36', + 'recommendationId' => 'nullable|integer', 'chatHistory' => 'nullable|array|max:20', 'chatHistory.*.role' => 'required|string|in:user,ai', 'chatHistory.*.text' => 'required|string|max:2000', ]); $message = $request->input('message'); + $sessionId = $request->input('sessionId'); + $recommendationId = $request->input('recommendationId'); $chatHistory = $request->input('chatHistory', []); $user = Auth::user(); - $recentRecommendation = session('recomendation_data', []); - // Jika session kosong, ambil rekomendasi terakhir dari database - if (empty($recentRecommendation)) { - $lastRec = \App\Models\Recommendation::where('user_id', $user->id) - ->latest() - ->first(); - - if ($lastRec) { - $hasil = json_decode($lastRec->hasil_rekomendasi, true); - $topJurusan = $hasil[0] ?? null; - $recentRecommendation = [ - 'jurusan' => $topJurusan['jurusan'] ?? null, - 'skor' => $topJurusan['skor'] ?? null, - 'nilai' => $lastRec->nilai_akademik, - 'minat' => $lastRec->minat, - 'pref_studi' => $lastRec->preferensi_studi, - ]; - } - } + // Ambil konteks rekomendasi berdasarkan ID spesifik sesi ini + $recentRecommendation = $this->getRecommendationContext($user, $recommendationId) ?? []; // Siapkan context untuk Gemini $context = [ 'recommendation' => $recentRecommendation['jurusan'] ?? null, - 'score' => isset($recentRecommendation['skor']) ? number_format($recentRecommendation['skor'] * 100, 1) : null, + 'score' => isset($recentRecommendation['skor']) ? number_format(($recentRecommendation['skor'] > 1 ? $recentRecommendation['skor'] : $recentRecommendation['skor'] * 100), 1) : null, + 'top3' => $recentRecommendation['top3'] ?? [], 'profile' => [ 'nama' => $user->name, - 'kelompok' => $user->kelompok_asal, + 'kelompok' => $user->kelompok_asal ?? null, 'nilai' => $recentRecommendation['nilai'] ?? null, + 'rata_rata' => $recentRecommendation['rata_rata'] ?? null, 'minat' => $recentRecommendation['minat'] ?? null, 'pref' => $recentRecommendation['pref_studi'] ?? null, + 'cita_cita' => $recentRecommendation['cita_cita'] ?? null, + 'prestasi' => $recentRecommendation['prestasi'] ?? null, ] ]; + // Cari pertanyaan serupa dari riwayat semua siswa + $similarQA = $this->findSimilarQuestions($message, $user->id); + if (!empty($similarQA)) { + $context['similar_qa'] = $similarQA; + } + // Panggil Gemini API dengan conversation history $response = $this->geminiService->chat($message, $context, $chatHistory); - // Simpan chat ke database + // Simpan chat ke database dengan session_id dan recommendation_id if ($user && isset($response['message'])) { ChatHistory::create([ 'user_id' => $user->id, + 'session_id' => $sessionId, + 'recommendation_id' => $recommendationId, 'prompt' => $message, 'response' => $response['message'], ]); @@ -108,15 +154,189 @@ public function send(Request $request) } /** - * Tampilkan history chat + * Tampilkan history chat, dikelompokkan per sesi */ public function historyChat() { $user = Auth::user(); + + // Ambil semua chat user dengan relasi recommendation $chatHistories = ChatHistory::where('user_id', $user->id) + ->with('recommendation') ->orderBy('created_at', 'desc') ->get(); - return view('history.chat', compact('chatHistories')); + // Kelompokkan per session_id + $sessions = $chatHistories->groupBy('session_id')->map(function ($chats, $sessionId) { + $first = $chats->last(); // oldest in group (karena desc) + $last = $chats->first(); // newest in group + $rec = $first->recommendation; + $recInfo = null; + if ($rec) { + $hasil = is_array($rec->hasil_rekomendasi) + ? $rec->hasil_rekomendasi + : json_decode($rec->hasil_rekomendasi, true); + $topJurusan = $hasil[0] ?? null; + $recInfo = [ + 'id' => $rec->id, + 'jurusan' => $topJurusan['jurusan'] ?? '-', + 'skor' => $topJurusan['skor'] ?? 0, + 'tanggal' => $rec->created_at, + ]; + } + return [ + 'session_id' => $sessionId, + 'chats' => $chats->reverse()->values(), + 'first_message' => Str::limit($first->prompt, 80), + 'message_count' => $chats->count(), + 'started_at' => $first->created_at, + 'last_at' => $last->created_at, + 'recommendation' => $recInfo, + ]; + }); + + return view('history.chat', compact('sessions')); + } + + /** + * Ambil konteks rekomendasi berdasarkan ID spesifik. + * Jika ID tidak ada, coba dari session, lalu dari DB (terbaru). + */ + private function getRecommendationContext($user, $recommendationId = null) + { + // Jika ada recommendation_id spesifik, ambil langsung dari DB + $lastRec = null; + + if ($recommendationId) { + $lastRec = Recommendation::where('id', $recommendationId) + ->where('user_id', $user->id) + ->first(); + } + + // Fallback: dari session (saat baru selesai rekomendasi) + if (!$lastRec) { + $sessionData = session('recomendation_data', null); + if ($sessionData) { + return $sessionData; + } + } + + // Fallback: rekomendasi terbaru dari DB + if (!$lastRec) { + $lastRec = Recommendation::where('user_id', $user->id) + ->latest() + ->first(); + } + + if (!$lastRec) { + return null; + } + + $hasil = is_array($lastRec->hasil_rekomendasi) + ? $lastRec->hasil_rekomendasi + : json_decode($lastRec->hasil_rekomendasi, true); + $topJurusan = $hasil[0] ?? null; + $top3 = array_slice($hasil ?? [], 0, 3); + + // Hitung rata-rata dari kolom nilai + $nilaiCols = ['mtk', 'fisika', 'kimia', 'biologi', 'ekonomi', 'geografi', 'sosiologi', 'sejarah']; + $validVals = array_filter(array_map(fn($c) => $lastRec->$c, $nilaiCols), fn($v) => $v !== null); + $rataRata = count($validVals) > 0 ? round(array_sum($validVals) / count($validVals), 1) : null; + + // Kategorisasi + $katNilai = 'Rendah'; + if ($rataRata >= 85) $katNilai = 'Tinggi'; + elseif ($rataRata >= 70) $katNilai = 'Sedang'; + + return [ + 'jurusan' => $topJurusan['jurusan'] ?? null, + 'skor' => $topJurusan['skor'] ?? null, + 'detail' => $topJurusan['detail'] ?? [], + 'nilai' => $katNilai, + 'rata_rata' => $rataRata, + 'minat' => $lastRec->minat, + 'pref_studi' => $lastRec->preferensi_studi, + 'cita_cita' => $lastRec->cita_cita, + 'prestasi' => $lastRec->prestasi, + 'top3' => array_map(fn($r) => [ + 'jurusan' => $r['jurusan'] ?? '', + 'skor' => $r['skor'] ?? 0, + ], $top3), + ]; + } + + /** + * Cari pertanyaan serupa dari riwayat chat semua user + * Menggunakan keyword matching sederhana + */ + private function findSimilarQuestions(string $message, int $currentUserId): array + { + $messageLower = strtolower($message); + + // Ekstrak kata kunci penting (min 4 huruf, bukan stopwords) + $stopwords = ['yang', 'dari', 'untuk', 'dengan', 'dalam', 'pada', 'akan', 'bisa', 'juga', 'saya', 'kamu', 'anda', 'tidak', 'sudah', 'belum', 'mau', 'ingin', 'apa', 'bagaimana', 'kenapa', 'mengapa', 'apakah', 'tolong', 'dong', 'aku', 'ini', 'itu']; + + $words = preg_split('/[\s,;.!?\-\/]+/', $messageLower); + $keywords = array_filter($words, function ($w) use ($stopwords) { + return strlen($w) >= 4 && !in_array($w, $stopwords); + }); + + if (empty($keywords)) { + return []; + } + + // Cari chat history yang mengandung kata kunci serupa + $query = ChatHistory::select('prompt', 'response', 'created_at') + ->where('user_id', $currentUserId); + + $query->where(function ($q) use ($keywords) { + foreach ($keywords as $keyword) { + $q->orWhere('prompt', 'like', "%{$keyword}%"); + } + }); + + $candidates = $query->orderBy('created_at', 'desc') + ->limit(20) + ->get(); + + if ($candidates->isEmpty()) { + return []; + } + + // Scoring: hitung berapa keyword yang cocok + $scored = []; + foreach ($candidates as $chat) { + $promptLower = strtolower($chat->prompt); + $matchCount = 0; + foreach ($keywords as $kw) { + if (stripos($promptLower, $kw) !== false) { + $matchCount++; + } + } + $ratio = $matchCount / count($keywords); + if ($ratio >= 0.4) { // minimal 40% keyword cocok + $scored[] = [ + 'prompt' => $chat->prompt, + 'response' => Str::limit($chat->response, 300), + 'score' => $ratio, + ]; + } + } + + // Sort by score desc, ambil top 3 + usort($scored, fn($a, $b) => $b['score'] <=> $a['score']); + return array_slice($scored, 0, 3); + } + + /** + * Strip markdown formatting + */ + private function stripMarkdown(string $text): string + { + $text = preg_replace('/\*\*(.*?)\*\*/s', '$1', $text); + $text = preg_replace('/\*(.*?)\*/s', '$1', $text); + $text = preg_replace('/^#{1,6}\s+/m', '', $text); + $text = preg_replace('/`(.*?)`/s', '$1', $text); + return $text; } } diff --git a/app/Http/Controllers/RekomendasiController.php b/app/Http/Controllers/RekomendasiController.php index cdb4432..afc41ff 100644 --- a/app/Http/Controllers/RekomendasiController.php +++ b/app/Http/Controllers/RekomendasiController.php @@ -12,9 +12,7 @@ class RekomendasiController extends Controller { public function index() { - // Ambil data siswa dari akun (kolom `nis`, `kelompok_asal` di tabel `users`) $user = Auth::user(); - // Jika masih ada model Student di beberapa kode lama, abaikan; gunakan properti di User $student = null; if ($user) { $student = (object) [ @@ -28,49 +26,42 @@ public function index() return view('rekomendasi.input', compact('student')); } + /** + * ============================================================ + * ALGORITMA NAIVE BAYES UNTUK REKOMENDASI JURUSAN + * Sesuai flowchart: + * 1. Input Data + * 2. Preprocessing Data + * 3. Tentukan Hipotesis (H) + * 4. Hitung Probabilitas Awal (Prior) P(H) + * 5. Hitung Likelihood P(X|H) per fitur + * 6. Hitung Probabilitas Gabungan (Rumus Naive Bayes) + * P(H|X) ∝ P(H) × P(X1|H) × P(X2|H) × ... × P(Xn|H) + * 7. Klasifikasi (Hasil Rekomendasi) + * ============================================================ + */ public function proses(Request $request) { - // --- VALIDATION --- - // Tentukan kelompok asal siswa - $user = Auth::user(); - $kelompok = $user->kelompok_asal ?? 'IPS'; + $epsilon = 1e-9; - // Validasi berbeda untuk IPA dan IPS - $baseRules = [ - 'minat' => 'required|string|max:255', - 'cita_cita' => 'required|string|max:255', - 'pref_studi' => 'required|in:Sains & Teknologi,Pertanian & Lingkungan,Kesehatan & Ilmu Hayat,Bisnis & Manajemen,Sosial & Humaniora', - 'prestasi' => 'nullable|string|max:255', - ]; + // ================================================================ + // LANGKAH 1: INPUT DATA + // ================================================================ + $scores = $request->only(['mtk', 'fisika', 'kimia', 'biologi', 'ekonomi', 'geografi', 'sosiologi', 'sejarah']); + $minatRaw = strtolower(trim($request->minat ?? '')); + $prefStudi = $request->pref_studi ?? 'Sains & Teknologi'; + $citaRaw = strtolower(trim($request->cita_cita ?? '')); + $prestasiRaw = strtolower(trim($request->prestasi ?? '')); - if ($kelompok === 'IPA') { - $nilaiRules = [ - 'mtk' => 'required|numeric|between:0,100', - 'fisika' => 'required|numeric|between:0,100', - 'kimia' => 'required|numeric|between:0,100', - 'biologi' => 'required|numeric|between:0,100', - ]; - } else { - $nilaiRules = [ - 'ekonomi' => 'required|numeric|between:0,100', - 'geografi' => 'required|numeric|between:0,100', - 'sosiologi' => 'required|numeric|between:0,100', - 'sejarah' => 'required|numeric|between:0,100', - ]; - } + // ================================================================ + // LANGKAH 2: PREPROCESSING DATA + // ================================================================ - $request->validate(array_merge($baseRules, $nilaiRules)); - - // --- 1. SKOR NILAI AKADEMIK (40%) - dikumpulkan dulu, dihitung per jurusan --- - if ($kelompok === 'IPA') { - $scores = $request->only(['mtk', 'fisika', 'kimia', 'biologi']); - } else { - $scores = $request->only(['ekonomi', 'geografi', 'sosiologi', 'sejarah']); - } - $validScores = array_filter($scores, fn($v) => !is_null($v) && $v !== ''); + // 2a. Hitung rata-rata nilai + $validScores = array_filter($scores, fn($v) => $v !== null && $v !== ''); $average = count($validScores) > 0 ? array_sum($validScores) / count($validScores) : 0; - // Label nilai untuk tampilan + // 2b. Kategorisasi nilai if ($average >= 85) { $katNilai = 'Tinggi'; } elseif ($average >= 70) { @@ -79,211 +70,326 @@ public function proses(Request $request) $katNilai = 'Rendah'; } - // --- 2. INPUT SISWA --- - $minatRaw = strtolower(trim($request->minat ?? '')); - $citaRaw = strtolower(trim($request->cita_cita ?? '')); - $prefStudi = $request->pref_studi ?? 'Sains & Teknologi'; - $prestasiRaw = strtolower(trim($request->prestasi ?? '')); - $prestasiScore = $this->scorePrestasiScore($prestasiRaw); + // 2c. Skor prestasi + $prestasiScore = $this->hitungSkorPrestasi($prestasiRaw); - // --- 3. GRADUATED SCORING PER JURUSAN --- + // ================================================================ + // LANGKAH 3: TENTUKAN HIPOTESIS (H) + // H = {Jurusan1, Jurusan2, ..., JurusanN} dari database + // ================================================================ $jurusanList = PolijeMajor::all(); - $hasilAkhir = []; - // Bobot kriteria - $W_NILAI = 0.40; - $W_MINAT = 0.35; - $W_PREF = 0.15; - $W_CITA = 0.05; - $W_PRESTASI = 0.05; + if ($jurusanList->isEmpty()) { + return back()->with('error', 'Data jurusan belum tersedia di database.'); + } + + $jumlahJurusan = $jurusanList->count(); + + // ================================================================ + // LANGKAH 4: HITUNG PROBABILITAS AWAL (PRIOR) P(H) + // Prior uniform: P(H) = 1 / jumlah_jurusan + // ================================================================ + $prior = 1 / $jumlahJurusan; + + // ================================================================ + // LANGKAH 5 & 6: HITUNG LIKELIHOOD DAN PROBABILITAS GABUNGAN + // Rumus Naive Bayes: + // P(H|X) ∝ P(H) × P(X1|H) × P(X2|H) × P(X3|H) × P(X4|H) × P(X5|H) + // + // Fitur (Xi): + // X1 = Nilai Akademik → P(nilai|H) + // X2 = Minat → P(minat|H) + // X3 = Preferensi Studi → P(pref|H) + // X4 = Cita-cita → P(cita|H) + // X5 = Prestasi → P(prestasi|H) + // + // Weighted Naive Bayes (log-space): + // log P(H|X) = log P(H) + Σ wi × log P(Xi|H) + // + // Bobot (wi): + // w1 = 0.40 (Nilai), w2 = 0.35 (Minat), w3 = 0.15 (Pref), + // w4 = 0.05 (Cita-cita), w5 = 0.05 (Prestasi) + // ================================================================ + $weights = [ + 'nilai' => 0.40, + 'minat' => 0.35, + 'pref' => 0.15, + 'cita' => 0.05, + 'prestasi' => 0.05, + ]; + + $logPosteriors = []; + $detailPerJurusan = []; foreach ($jurusanList as $jurusan) { - $keywords = $jurusan->keywords ?? []; - $prefList = $jurusan->preferensi_studi ?? []; - $bobotMapel = $jurusan->bobot_mapel ?? []; + // --- Log Prior --- + $logPrior = log(max($prior, $epsilon)); - // --- Skor Nilai: per-jurusan weighted --- - $skorNilai = $this->hitungSkorNilaiPerJurusan($scores, $bobotMapel, $average); + // --- X1: Likelihood Nilai Akademik P(nilai|H) --- + $pNilai = $this->hitungLikelihoodNilai($scores, $jurusan->bobot_mapel); - // --- Skor Minat: partial keyword matching --- - $skorMinat = $this->hitungKecocokanKeyword($minatRaw, $keywords); + // --- X2: Likelihood Minat P(minat|H) --- + $pMinat = $this->hitungLikelihoodMinat($minatRaw, $jurusan->keywords); - // --- Skor Cita-cita: partial keyword matching --- - $skorCita = $this->hitungKecocokanKeyword($citaRaw, $keywords); + // --- X3: Likelihood Preferensi Studi P(pref|H) --- + $pPref = $this->hitungLikelihoodPref($prefStudi, $jurusan->preferensi_studi); - // --- Skor Preferensi Studi --- - if (in_array($prefStudi, $prefList)) { - $skorPref = 1.0; - } elseif (!empty($prefList)) { - $skorPref = 0.3; // Tidak cocok tapi jurusan punya preferensi - } else { - $skorPref = 0.5; // Jurusan tidak mendefinisikan preferensi - } + // --- X4: Likelihood Cita-cita P(cita|H) --- + $pCita = $this->hitungLikelihoodCitaCita($citaRaw, $jurusan->keywords); - // --- Skor Prestasi (sama untuk semua jurusan) --- - $skorPrestasi = $prestasiScore; + // --- X5: Likelihood Prestasi P(prestasi|H) --- + $pPrestasi = $this->hitungLikelihoodPrestasi($prestasiScore); - // --- Hitung skor akhir --- - $skorAkhir = ($W_NILAI * $skorNilai) + - ($W_MINAT * $skorMinat) + - ($W_PREF * $skorPref) + - ($W_CITA * $skorCita) + - ($W_PRESTASI * $skorPrestasi); + // --- Probabilitas Gabungan (Weighted Naive Bayes) --- + // log P(H|X) = log P(H) + w1·log P(X1|H) + w2·log P(X2|H) + ... + w5·log P(X5|H) + $logPosterior = $logPrior + + $weights['nilai'] * log(max($pNilai, $epsilon)) + + $weights['minat'] * log(max($pMinat, $epsilon)) + + $weights['pref'] * log(max($pPref, $epsilon)) + + $weights['cita'] * log(max($pCita, $epsilon)) + + $weights['prestasi'] * log(max($pPrestasi, $epsilon)); - $hasilAkhir[] = [ - 'jurusan' => $jurusan->nama_jurusan, - 'skor' => round($skorAkhir, 4), - 'detail' => [ - 'nilai' => round($skorNilai, 4), - 'minat' => round($skorMinat, 4), - 'pref' => round($skorPref, 4), - 'cita' => round($skorCita, 4), - 'prestasi' => round($skorPrestasi, 4), - ], + $logPosteriors[$jurusan->nama_jurusan] = $logPosterior; + + // Simpan detail per kriteria untuk tampilan + $detailPerJurusan[$jurusan->nama_jurusan] = [ + 'nilai' => round($pNilai, 4), + 'minat' => round($pMinat, 4), + 'pref' => round($pPref, 4), + 'cita' => round($pCita, 4), + 'prestasi' => round($pPrestasi, 4), ]; } - // Sort berdasarkan skor tertinggi + // ================================================================ + // LANGKAH 7: KLASIFIKASI (HASIL REKOMENDASI) + // Konversi log-posterior ke probabilitas menggunakan softmax + // P(Hk|X) = exp(log Pk) / Σ exp(log Pi) + // ================================================================ + $maxLog = max($logPosteriors); + $expVals = []; + $sumExp = 0.0; + + foreach ($logPosteriors as $namaJurusan => $lv) { + $expVals[$namaJurusan] = exp($lv - $maxLog); + $sumExp += $expVals[$namaJurusan]; + } + + $hasilAkhir = []; + foreach ($expVals as $namaJurusan => $val) { + $prob = $val / max($sumExp, $epsilon); + $hasilAkhir[] = [ + 'jurusan' => $namaJurusan, + 'skor' => round($prob, 4), + 'detail' => $detailPerJurusan[$namaJurusan], + 'kecocokan_nilai' => $katNilai, + 'kecocokan_minat' => $minatRaw, + 'kecocokan_pref' => $prefStudi, + ]; + } + + // Urutkan berdasarkan skor tertinggi usort($hasilAkhir, fn($a, $b) => $b['skor'] <=> $a['skor']); + // Ambil data jurusan teratas untuk detail view + $topJurusan = !empty($hasilAkhir) ? PolijeMajor::where('nama_jurusan', $hasilAkhir[0]['jurusan'] ?? '')->first() : null; + // Simpan ke database + $user = Auth::user(); + $savedRec = null; if ($user) { - Recommendation::create([ - 'user_id' => $user->id, - 'mtk' => $request->mtk ?? null, - 'fisika' => $request->fisika ?? null, - 'kimia' => $request->kimia ?? null, - 'biologi' => $request->biologi ?? null, - 'ekonomi' => $request->ekonomi ?? null, - 'geografi' => $request->geografi ?? null, - 'sosiologi' => $request->sosiologi ?? null, - 'sejarah' => $request->sejarah ?? null, - 'minat' => $request->minat ?? null, - 'preferensi_studi' => $request->pref_studi ?? null, - 'cita_cita' => $request->cita_cita ?? null, - 'prestasi' => $request->prestasi ?? null, + $savedRec = Recommendation::create([ + 'user_id' => $user->id, + 'mtk' => $request->mtk ?? null, + 'fisika' => $request->fisika ?? null, + 'kimia' => $request->kimia ?? null, + 'biologi' => $request->biologi ?? null, + 'ekonomi' => $request->ekonomi ?? null, + 'geografi' => $request->geografi ?? null, + 'sosiologi' => $request->sosiologi ?? null, + 'sejarah' => $request->sejarah ?? null, + 'minat' => $request->minat ?? null, + 'preferensi_studi' => $request->pref_studi ?? null, + 'cita_cita' => $request->cita_cita ?? null, + 'prestasi' => $request->prestasi ?? null, 'hasil_rekomendasi' => $hasilAkhir, ]); } + // Simpan recommendation_id ke session agar bisa dipakai link chatbot + $recId = $savedRec ? $savedRec->id : null; + session(['last_recommendation_id' => $recId]); + // Simpan ke session untuk chatbot if (count($hasilAkhir) > 0) { $topResult = $hasilAkhir[0]; + // Ambil top 3 untuk konteks chatbot + $top3 = array_slice($hasilAkhir, 0, 3); session([ 'recomendation_data' => [ - 'jurusan' => $topResult['jurusan'], - 'skor' => $topResult['skor'], - 'nilai' => $katNilai, - 'minat' => $request->minat, + 'jurusan' => $topResult['jurusan'], + 'skor' => $topResult['skor'], // Sudah 0-1, jangan ×100 + 'detail' => $topResult['detail'] ?? [], + 'nilai' => $katNilai, + 'rata_rata' => round($average, 1), + 'minat' => $minatRaw, 'pref_studi' => $prefStudi, + 'cita_cita' => $citaRaw, + 'prestasi' => $prestasiRaw, + 'top3' => array_map(fn($r) => [ + 'jurusan' => $r['jurusan'], + 'skor' => $r['skor'], + ], $top3), ] ]); } - // Load top jurusan from DB for deskripsi & prospek_kerja - $topJurusan = null; - if (count($hasilAkhir) > 0) { - $topJurusan = PolijeMajor::where('nama_jurusan', $hasilAkhir[0]['jurusan'])->first(); - } - - return view('rekomendasi.hasil', compact('hasilAkhir', 'katNilai', 'average', 'prefStudi', 'prestasiScore', 'topJurusan')); + return view('rekomendasi.hasil', compact( + 'hasilAkhir', 'katNilai', 'average', 'prefStudi', 'prestasiScore', 'topJurusan' + )); } + // ================================================================== + // FUNGSI LIKELIHOOD — P(Xi | H) + // ================================================================== + /** - * Hitung skor nilai akademik per jurusan dengan bobot mapel - * Jika jurusan punya bobot_mapel, hitung weighted average - * Jika tidak, gunakan rata-rata biasa + * P(nilai | H) — Likelihood nilai akademik terhadap jurusan + * Menggunakan bobot_mapel dari database untuk menghitung + * weighted average yang dinormalisasi ke range probabilitas. */ - private function hitungSkorNilaiPerJurusan(array $scores, array $bobotMapel, float $averageFallback): float + private function hitungLikelihoodNilai(array $scores, ?array $bobotMapel): float { - // Jika tidak ada bobot khusus, pakai rata-rata biasa + // Jika tidak ada bobot, gunakan rata-rata biasa if (empty($bobotMapel)) { - return min($averageFallback / 100, 1.0); + $valid = array_filter($scores, fn($v) => $v !== null && $v !== ''); + if (empty($valid)) return 0.3; + $avg = array_sum($valid) / count($valid); + return $this->normalisasiProbabilitas($avg / 100, 0.10, 0.95); } $weightedSum = 0; $totalWeight = 0; - foreach ($bobotMapel as $mapel => $bobot) { - $nilai = floatval($scores[$mapel] ?? 0); - $weightedSum += $nilai * $bobot; - $totalWeight += $bobot; - } - - // Untuk mapel yang ada di scores tapi tidak di bobot, beri bobot kecil - foreach ($scores as $mapel => $nilai) { - if (!isset($bobotMapel[$mapel]) && !is_null($nilai) && $nilai !== '') { - $weightedSum += floatval($nilai) * 0.1; - $totalWeight += 0.1; + foreach ($bobotMapel as $subject => $weight) { + $nilai = floatval($scores[$subject] ?? 0); + if ($nilai > 0 && $weight > 0) { + $weightedSum += $weight * ($nilai / 100); + $totalWeight += $weight; } } - if ($totalWeight <= 0) { - return min($averageFallback / 100, 1.0); - } + if ($totalWeight == 0) return 0.3; $weightedAvg = $weightedSum / $totalWeight; - return min($weightedAvg / 100, 1.0); + return $this->normalisasiProbabilitas($weightedAvg, 0.10, 0.95); } /** - * Hitung kecocokan teks input dengan array keywords jurusan (graduated) - * Returns 0.0 - 1.0 + * P(minat | H) — Likelihood minat terhadap jurusan + * Menggunakan keyword matching terhadap keywords jurusan dari database. */ - private function hitungKecocokanKeyword(string $inputText, array $keywords): float + private function hitungLikelihoodMinat(string $minatRaw, ?array $keywords): float { - if (empty($keywords) || empty($inputText)) { - return 0.0; + if (empty($keywords) || empty($minatRaw)) { + return 0.20; // probabilitas dasar jika tidak ada data } $matchCount = 0; - $inputWords = preg_split('/[\s,;.\/\-]+/', $inputText); - foreach ($keywords as $keyword) { - $kw = strtolower(trim($keyword)); - if (empty($kw)) continue; - - // Check if keyword appears in any input word (partial match) - foreach ($inputWords as $word) { - if (empty($word)) continue; - // Match if input word contains keyword or keyword contains input word (min 3 chars) - if (stripos($inputText, $kw) !== false || - (strlen($word) >= 3 && stripos($kw, $word) !== false)) { - $matchCount++; - break; - } + if (stripos($minatRaw, strtolower($keyword)) !== false) { + $matchCount++; } } - // Graduated score: ratio of matched keywords - // Use sqrt to give more credit for partial matches - $ratio = $matchCount / count($keywords); - return min(sqrt($ratio) * 0.9 + ($matchCount > 0 ? 0.1 : 0), 1.0); + // Rasio kecocokan keyword + $matchRatio = $matchCount / count($keywords); + + // Konversi ke range probabilitas: 0 match → 0.10, full match → 0.95 + return $this->normalisasiProbabilitas($matchRatio, 0.10, 0.95); } /** - * Scoring prestasi berdasarkan keyword + * P(pref | H) — Likelihood preferensi studi terhadap jurusan + * Membandingkan preferensi siswa dengan preferensi_studi jurusan dari database. */ - private function scorePrestasiScore(string $prestasiRaw): float + private function hitungLikelihoodPref(string $prefStudi, ?array $jurusanPref): float + { + if (empty($jurusanPref)) { + return 0.40; // probabilitas netral + } + + // Cek apakah preferensi siswa ada di list preferensi jurusan + if (in_array($prefStudi, $jurusanPref)) { + return 0.85; // cocok + } + + return 0.15; // tidak cocok + } + + /** + * P(cita_cita | H) — Likelihood cita-cita terhadap jurusan + * Menggunakan keyword matching dari cita-cita siswa terhadap keywords jurusan. + */ + private function hitungLikelihoodCitaCita(string $citaRaw, ?array $keywords): float + { + if (empty($keywords) || empty($citaRaw)) { + return 0.25; // probabilitas dasar + } + + $matchCount = 0; + foreach ($keywords as $keyword) { + if (stripos($citaRaw, strtolower($keyword)) !== false) { + $matchCount++; + } + } + + $matchRatio = $matchCount / count($keywords); + return $this->normalisasiProbabilitas($matchRatio, 0.10, 0.90); + } + + /** + * P(prestasi | H) — Likelihood prestasi + * Prestasi bersifat umum (tidak spesifik per jurusan), sehingga + * memberikan boost yang sama untuk semua jurusan. + */ + private function hitungLikelihoodPrestasi(float $prestasiScore): float + { + // Konversi skor prestasi (0-1) ke range probabilitas + return $this->normalisasiProbabilitas($prestasiScore, 0.20, 0.90); + } + + // ================================================================== + // FUNGSI HELPER + // ================================================================== + + /** + * Normalisasi nilai (0-1) ke range probabilitas [min, max] + * Agar tidak ada likelihood 0 atau 1 (menghindari dominasi) + */ + private function normalisasiProbabilitas(float $value, float $min = 0.10, float $max = 0.95): float + { + return $min + ($value * ($max - $min)); + } + + /** + * Hitung skor prestasi berdasarkan keyword + */ + private function hitungSkorPrestasi(string $prestasiRaw): float { if (empty($prestasiRaw)) { return 0.0; } - $prestasiScore = 0.0; - - // Berbagai tingkat prestasi if (preg_match('/(juara|menang|champion|first|gold|emas|terbaik)/', $prestasiRaw)) { - $prestasiScore = 0.90; // Prestasi tinggi - } elseif (preg_match('/(finalis|semifinal|peringkat|ranking|podium|medali|silver|silver|perak)/', $prestasiRaw)) { - $prestasiScore = 0.75; // Prestasi sedang + return 0.90; + } elseif (preg_match('/(finalis|semifinal|peringkat|ranking|podium|medali|silver|perak)/', $prestasiRaw)) { + return 0.75; } elseif (preg_match('/(sertifikat|training|kursus|workshop|peserta|mengikuti)/', $prestasiRaw)) { - $prestasiScore = 0.60; // Prestasi cukup - } else { - $prestasiScore = 0.30; // Prestasi minimal + return 0.60; } - return $prestasiScore; + return 0.30; } /** diff --git a/app/Models/ChatHistory.php b/app/Models/ChatHistory.php index d0c96ec..0dcf8a5 100644 --- a/app/Models/ChatHistory.php +++ b/app/Models/ChatHistory.php @@ -13,6 +13,8 @@ class ChatHistory extends Model protected $fillable = [ 'user_id', + 'session_id', + 'recommendation_id', 'prompt', 'response', ]; @@ -21,4 +23,9 @@ public function user() { return $this->belongsTo(User::class); } + + public function recommendation() + { + return $this->belongsTo(Recommendation::class); + } } diff --git a/app/Services/GeminiService.php b/app/Services/GeminiService.php index bd3f62a..e71d76b 100644 --- a/app/Services/GeminiService.php +++ b/app/Services/GeminiService.php @@ -125,7 +125,17 @@ protected function getFallbackResponse($message, $context = []) $messageLower = strtolower($message); if (strpos($messageLower, 'halo') !== false || strpos($messageLower, 'hai') !== false || strpos($messageLower, 'hallo') !== false || strpos($messageLower, 'hi') !== false) { - $greeting = "Selamat datang. Saya adalah konselor BK virtual SMA Bima Ambulu yang siap membantu Anda dalam pemilihan jurusan kuliah. "; + $hour = (int) now()->format('H'); + if ($hour >= 3 && $hour < 11) { + $sapaan = 'Selamat pagi'; + } elseif ($hour >= 11 && $hour < 15) { + $sapaan = 'Selamat siang'; + } elseif ($hour >= 15 && $hour < 18) { + $sapaan = 'Selamat sore'; + } else { + $sapaan = 'Selamat malam'; + } + $greeting = "{$sapaan}. Saya adalah konselor BK virtual SMA Bima Ambulu yang siap membantu Anda dalam pemilihan jurusan kuliah. "; if ($hasRecommendation) { $greeting .= "Berdasarkan data yang tersedia, Anda telah memperoleh rekomendasi jurusan \"{$jurusan}\" dengan skor kesesuaian {$score}%. Apakah Anda ingin membahas lebih lanjut mengenai jurusan tersebut, atau ada pertanyaan lain yang ingin disampaikan?"; } else { @@ -151,7 +161,7 @@ protected function getFallbackResponse($message, $context = []) if ($hasRecommendation) { return [ 'success' => true, - 'message' => "Jurusan \"{$jurusan}\" memiliki prospek karier yang menjanjikan. Lulusan dari jurusan ini dapat bekerja di berbagai sektor industri yang relevan dengan bidang keahliannya. Setiap program studi di perguruan tinggi dirancang untuk membekali lulusannya dengan kompetensi praktis yang dibutuhkan oleh dunia kerja. Apakah Anda ingin mengetahui lebih detail mengenai posisi pekerjaan spesifik yang dapat ditempuh?" + 'message' => "Jurusan \"{$jurusan}\" memiliki prospek karier yang menjanjikan. Lulusan dari jurusan ini dapat bekerja di berbagai sektor industri yang relevan dengan bidang keahliannya. Setiap jurusan di POLIJE dirancang untuk membekali lulusannya dengan kompetensi praktis yang dibutuhkan oleh dunia kerja. Apakah Anda ingin mengetahui lebih detail mengenai posisi pekerjaan spesifik yang dapat ditempuh?" ]; } return [ @@ -183,7 +193,7 @@ protected function getFallbackResponse($message, $context = []) if (strpos($messageLower, 'ipa') !== false || strpos($messageLower, 'ips') !== false) { return [ 'success' => true, - 'message' => "Perlu dipahami bahwa kelompok IPA dan IPS bukan merupakan batasan mutlak dalam memilih jurusan kuliah. Banyak program studi yang dapat dimasuki oleh siswa dari kedua kelompok tersebut. Faktor yang lebih menentukan adalah minat, kemampuan, dan kompetensi yang Anda miliki. Siswa IPA dapat memilih bidang bisnis, dan sebaliknya siswa IPS dapat menempuh bidang teknologi informasi. Silakan manfaatkan fitur Analisis Rekomendasi untuk melihat jurusan yang paling sesuai berdasarkan profil lengkap Anda." + 'message' => "Perlu dipahami bahwa kelompok IPA dan IPS bukan merupakan batasan mutlak dalam memilih jurusan kuliah. Banyak jurusan di POLIJE dapat dimasuki oleh siswa dari kedua kelompok tersebut. Faktor yang lebih menentukan adalah minat, kemampuan, dan kompetensi yang Anda miliki. Siswa IPA dapat memilih bidang bisnis, dan sebaliknya siswa IPS dapat menempuh bidang teknologi informasi. Silakan manfaatkan fitur Analisis Rekomendasi untuk melihat jurusan yang paling sesuai berdasarkan profil lengkap Anda." ]; } @@ -209,14 +219,54 @@ protected function buildSystemPrompt($context) $prompt .= "Kamu juga bisa menjawab pertanyaan umum di luar topik jurusan (seperti pengetahuan umum, tokoh, dll) secara singkat, lalu arahkan kembali ke topik konseling. "; $prompt .= "Gunakan bahasa Indonesia yang FORMAL, AKADEMIK, dan SOPAN — seperti seorang konselor profesional berbicara dengan siswa. "; $prompt .= "DILARANG menggunakan bahasa gaul, slang, atau terlalu santai. Gunakan kalimat yang baku dan terstruktur. "; + + // PENTING: Konteks POLIJE + $prompt .= "\n\nKONTEKS PENTING POLIJE (Politeknik Negeri Jember):"; + $prompt .= "\n- Di POLIJE, unit akademik disebut JURUSAN, BUKAN 'program studi' atau 'prodi'. Selalu gunakan istilah 'jurusan'."; + $prompt .= "\n- Contoh benar: 'Jurusan Teknologi Informasi', 'Jurusan Kesehatan', 'Jurusan Manajemen Agribisnis'."; + $prompt .= "\n- Contoh SALAH (JANGAN digunakan): 'program studi Teknologi Informasi', 'prodi TI'."; + $prompt .= "\n- Saat menjelaskan jurusan POLIJE, PRIORITASKAN data dari DAFTAR JURUSAN di bawah (deskripsi, prospek kerja, kata kunci)."; + $prompt .= "\n- JANGAN gunakan informasi umum dari internet yang bisa berbeda dengan konteks POLIJE."; + $prompt .= "\n- Jika siswa bertanya tentang suatu jurusan, jawab berdasarkan data POLIJE yang tersedia, bukan pengetahuan umum."; + + // Tambahkan informasi waktu saat ini + $hour = (int) now()->format('H'); + if ($hour >= 3 && $hour < 11) { + $sapaan = 'Selamat pagi'; + $waktu = 'pagi'; + } elseif ($hour >= 11 && $hour < 15) { + $sapaan = 'Selamat siang'; + $waktu = 'siang'; + } elseif ($hour >= 15 && $hour < 18) { + $sapaan = 'Selamat sore'; + $waktu = 'sore'; + } else { + $sapaan = 'Selamat malam'; + $waktu = 'malam'; + } + $prompt .= "\n\nWAKTU SAAT INI: " . now()->format('H:i') . " WIB (" . $waktu . "). "; + $prompt .= "Jika perlu menyapa, gunakan sapaan yang sesuai waktu: '{$sapaan}'. JANGAN gunakan sapaan waktu yang tidak sesuai (misalnya jangan ucapkan 'Selamat pagi' jika saat ini malam hari)."; // Tambahkan konteks rekomendasi jika ada if (!empty($context['recommendation'])) { - $prompt .= "\n\nDATA REKOMENDASI SISWA (dari sistem analisis): "; + $prompt .= "\n\nDATA REKOMENDASI SISWA (dari sistem analisis Naive Bayes): "; $prompt .= "Jurusan paling cocok: {$context['recommendation']}. "; if (!empty($context['score'])) { $prompt .= "Skor kesesuaian: {$context['score']}%. "; } + + // Tambahkan top 3 rekomendasi + if (!empty($context['top3'])) { + $prompt .= "\nPeringkat 3 besar rekomendasi: "; + foreach ($context['top3'] as $i => $t) { + $num = $i + 1; + $skorVal = $t['skor'] ?? 0; + $pct = number_format(($skorVal > 1 ? $skorVal : $skorVal * 100), 1); + $prompt .= "\n {$num}. {$t['jurusan']} ({$pct}%)"; + } + } + + $prompt .= "\nGunakan data rekomendasi ini untuk menjelaskan MENGAPA jurusan tersebut direkomendasikan. Hubungkan dengan profil siswa di bawah."; } // Tambahkan profil siswa jika ada @@ -229,29 +279,41 @@ protected function buildSystemPrompt($context) $prompt .= "Kelompok asal: {$context['profile']['kelompok']}. "; } if (!empty($context['profile']['nilai'])) { - $prompt .= "Nilai akademik: {$context['profile']['nilai']}. "; + $prompt .= "Kategori nilai akademik: {$context['profile']['nilai']}. "; + } + if (!empty($context['profile']['rata_rata'])) { + $prompt .= "Rata-rata nilai: {$context['profile']['rata_rata']}. "; } if (!empty($context['profile']['minat'])) { $prompt .= "Minat: {$context['profile']['minat']}. "; } if (!empty($context['profile']['pref'])) { - $prompt .= "Preferensi pembelajaran: {$context['profile']['pref']}. "; + $prompt .= "Preferensi rumpun studi: {$context['profile']['pref']}. "; + } + if (!empty($context['profile']['cita_cita'])) { + $prompt .= "Cita-cita: {$context['profile']['cita_cita']}. "; + } + if (!empty($context['profile']['prestasi'])) { + $prompt .= "Prestasi: {$context['profile']['prestasi']}. "; } } $jurusanList = PolijeMajor::all(); if ($jurusanList->isNotEmpty()) { - $prompt .= "\n\nDAFTAR JURUSAN POLIJE ({$jurusanList->count()} jurusan):"; + $prompt .= "\n\nDAFTAR JURUSAN POLIJE ({$jurusanList->count()} jurusan) — INI ADALAH SUMBER DATA UTAMA, gunakan informasi ini saat menjelaskan jurusan:"; foreach ($jurusanList as $j) { - $prompt .= "\n- {$j->nama_jurusan}"; + $prompt .= "\n- JURUSAN {$j->nama_jurusan}"; if (!empty($j->deskripsi)) { $prompt .= ": {$j->deskripsi}"; } if (!empty($j->prospek_kerja)) { - $prompt .= " Prospek kerja: {$j->prospek_kerja}."; + $prompt .= " | Prospek kerja: {$j->prospek_kerja}."; } if (!empty($j->keywords) && is_array($j->keywords)) { - $prompt .= " Kata kunci: " . implode(', ', array_slice($j->keywords, 0, 10)) . "."; + $prompt .= " | Kata kunci: " . implode(', ', array_slice($j->keywords, 0, 10)) . "."; + } + if (!empty($j->preferensi_studi) && is_array($j->preferensi_studi)) { + $prompt .= " | Rumpun: " . implode(', ', $j->preferensi_studi) . "."; } } } @@ -265,7 +327,17 @@ protected function buildSystemPrompt($context) $prompt .= "\n6. Jawab RINGKAS (2-3 paragraf). Jangan terlalu panjang kecuali diminta detail."; $prompt .= "\n7. Boleh menjawab pertanyaan di luar topik jurusan secara singkat, lalu kembalikan ke konseling."; $prompt .= "\n8. JANGAN awali setiap respons dengan 'Halo' atau salam — langsung ke inti jawaban (kecuali percakapan baru dimulai)."; - $prompt .= "\n9. DILARANG KERAS menggunakan format markdown seperti **, *, #, ##, atau simbol formatting lainnya. Tulis teks biasa (plain text) saja tanpa formatting markdown."; + + // Tambahkan referensi Q&A serupa dari riwayat + if (!empty($context['similar_qa'])) { + $prompt .= "\n\nREFERENSI JAWABAN SEBELUMNYA (pertanyaan serupa yang pernah dijawab — gunakan sebagai referensi untuk konsistensi, tapi sesuaikan dengan konteks siswa saat ini):"; + foreach ($context['similar_qa'] as $i => $qa) { + $num = $i + 1; + $prompt .= "\n{$num}. Pertanyaan: \"{$qa['prompt']}\""; + $prompt .= "\n Jawaban sebelumnya: \"{$qa['response']}\""; + } + $prompt .= "\nGunakan referensi di atas untuk menjaga konsistensi jawaban, namun tetap sesuaikan dengan profil dan konteks percakapan siswa saat ini."; + } $prompt .= "\n9. DILARANG KERAS menggunakan format markdown seperti **, *, #, ##, atau simbol formatting lainnya. Tulis teks biasa (plain text) saja tanpa formatting markdown."; $prompt .= "\n10. Gunakan bahasa Indonesia baku dan akademik. Hindari bahasa gaul seperti 'kek', 'banget', 'ngobrol', 'ngomongin', 'gampangnya'. Gunakan padanan formal seperti 'sangat', 'berbincang', 'membahas', 'secara sederhana'."; return $prompt; diff --git a/config/app.php b/config/app.php index 6de4729..a2499b5 100644 --- a/config/app.php +++ b/config/app.php @@ -69,7 +69,7 @@ | */ - 'timezone' => 'UTC', + 'timezone' => 'Asia/Jakarta', /* |-------------------------------------------------------------------------- diff --git a/database/migrations/2026_02_26_200000_add_session_id_to_chat_histories_table.php b/database/migrations/2026_02_26_200000_add_session_id_to_chat_histories_table.php new file mode 100644 index 0000000..8d63640 --- /dev/null +++ b/database/migrations/2026_02_26_200000_add_session_id_to_chat_histories_table.php @@ -0,0 +1,22 @@ +string('session_id', 36)->nullable()->after('user_id')->index(); + }); + } + + public function down(): void + { + Schema::table('chat_histories', function (Blueprint $table) { + $table->dropColumn('session_id'); + }); + } +}; diff --git a/database/migrations/2026_02_26_210000_backfill_session_id_on_chat_histories.php b/database/migrations/2026_02_26_210000_backfill_session_id_on_chat_histories.php new file mode 100644 index 0000000..95047f2 --- /dev/null +++ b/database/migrations/2026_02_26_210000_backfill_session_id_on_chat_histories.php @@ -0,0 +1,44 @@ +whereNull('session_id') + ->orderBy('user_id') + ->orderBy('created_at') + ->get(); + + // Kelompokkan per user_id + tanggal + $groups = $records->groupBy(function ($record) { + return $record->user_id . '_' . substr($record->created_at, 0, 10); + }); + + foreach ($groups as $key => $chats) { + $uuid = Str::uuid()->toString(); + $ids = $chats->pluck('id')->toArray(); + + DB::table('chat_histories') + ->whereIn('id', $ids) + ->update(['session_id' => $uuid]); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + // Tidak bisa di-reverse secara akurat + } +}; diff --git a/database/migrations/2026_03_06_100000_add_recommendation_id_to_chat_histories_table.php b/database/migrations/2026_03_06_100000_add_recommendation_id_to_chat_histories_table.php new file mode 100644 index 0000000..4c988e3 --- /dev/null +++ b/database/migrations/2026_03_06_100000_add_recommendation_id_to_chat_histories_table.php @@ -0,0 +1,48 @@ +unsignedBigInteger('recommendation_id')->nullable()->after('session_id'); + $table->foreign('recommendation_id')->references('id')->on('recommendations')->onDelete('set null'); + }); + + // Backfill: untuk setiap session, cari rekomendasi terdekat sebelum chat pertama user tersebut + $sessions = DB::table('chat_histories') + ->select('session_id', 'user_id', DB::raw('MIN(created_at) as first_chat_at')) + ->whereNotNull('session_id') + ->whereNull('recommendation_id') + ->groupBy('session_id', 'user_id') + ->get(); + + foreach ($sessions as $session) { + // Cari rekomendasi terakhir user sebelum atau tepat saat sesi dimulai + $rec = DB::table('recommendations') + ->where('user_id', $session->user_id) + ->where('created_at', '<=', $session->first_chat_at) + ->orderBy('created_at', 'desc') + ->first(); + + if ($rec) { + DB::table('chat_histories') + ->where('session_id', $session->session_id) + ->update(['recommendation_id' => $rec->id]); + } + } + } + + public function down(): void + { + Schema::table('chat_histories', function (Blueprint $table) { + $table->dropForeign(['recommendation_id']); + $table->dropColumn('recommendation_id'); + }); + } +}; diff --git a/database/seeders/AdminSeeder.php b/database/seeders/AdminSeeder.php index 8a65d61..2ccc757 100644 --- a/database/seeders/AdminSeeder.php +++ b/database/seeders/AdminSeeder.php @@ -16,10 +16,10 @@ public function run(): void { // Create Admin User User::firstOrCreate( - ['email' => 'admin@gmail.com'], + ['email' => 'admin@polije.ac.id'], [ 'name' => 'Admin Polije', - 'password' => Hash::make('admin123'), + 'password' => Hash::make('admin1234'), 'role' => 'admin', 'email_verified_at' => now(), ] @@ -27,17 +27,17 @@ public function run(): void // Create BK (Konselor) User User::firstOrCreate( - ['email' => 'bk@gmail.com'], + ['email' => 'gurubk@polije.ac.id'], [ 'name' => 'Konselor BK', - 'password' => Hash::make('bk123'), + 'password' => Hash::make('gurubk1234'), 'role' => 'bk', 'email_verified_at' => now(), ] ); echo "✅ Admin & BK users created successfully!\n"; - echo "Admin: admin@gmail.com / admin123\n"; - echo "BK: bk@gmail.com / bk123\n"; + echo "Admin: admin@polije.ac.id / admin1234\n"; + echo "BK: gurubk@polije.ac.id / gurubk1234\n"; } } diff --git a/database/seeders/PolijeMajorSeeder.php b/database/seeders/PolijeMajorSeeder.php index 343719d..e0924d3 100644 --- a/database/seeders/PolijeMajorSeeder.php +++ b/database/seeders/PolijeMajorSeeder.php @@ -12,7 +12,7 @@ public function run(): void $jurusans = [ [ 'nama_jurusan' => 'Produksi Pertanian', - 'deskripsi' => 'Program studi yang mempelajari teknik budidaya tanaman, pengelolaan lahan pertanian, dan produksi hasil pertanian secara modern.', + 'deskripsi' => 'Jurusan yang mempelajari teknik budidaya tanaman, pengelolaan lahan pertanian, dan produksi hasil pertanian secara modern.', 'keywords' => ['pertanian', 'petani', 'kebun', 'sawah', 'panen', 'tanaman', 'budidaya', 'agronomi', 'tanam', 'bercocok tanam', 'alam', 'hortikultura', 'pupuk', 'bibit'], 'preferensi_studi' => ['Pertanian & Lingkungan'], 'bobot_mapel' => [ @@ -23,7 +23,7 @@ public function run(): void ], [ 'nama_jurusan' => 'Teknologi Pertanian', - 'deskripsi' => 'Program studi yang mengintegrasikan teknologi dengan pertanian, meliputi mekanisasi pertanian, pengolahan hasil pertanian, dan inovasi teknologi pangan.', + 'deskripsi' => 'Jurusan yang mengintegrasikan teknologi dengan pertanian, meliputi mekanisasi pertanian, pengolahan hasil pertanian, dan inovasi teknologi pangan.', 'keywords' => ['teknologi pertanian', 'mesin pertanian', 'inovasi', 'otomasi', 'pengolahan pangan', 'pangan', 'mekanisasi', 'teknologi pangan', 'alat pertanian', 'rekayasa'], 'preferensi_studi' => ['Sains & Teknologi', 'Pertanian & Lingkungan'], 'bobot_mapel' => [ @@ -34,7 +34,7 @@ public function run(): void ], [ 'nama_jurusan' => 'Peternakan', - 'deskripsi' => 'Program studi yang mempelajari pengelolaan dan pemeliharaan ternak, nutrisi hewan, reproduksi, dan pengolahan produk peternakan.', + 'deskripsi' => 'Jurusan yang mempelajari pengelolaan dan pemeliharaan ternak, nutrisi hewan, reproduksi, dan pengolahan produk peternakan.', 'keywords' => ['ternak', 'hewan', 'peternakan', 'peternak', 'sapi', 'ayam', 'unggas', 'kambing', 'susu', 'pakan', 'nutrisi hewan', 'veteriner', 'ikan', 'aquaculture'], 'preferensi_studi' => ['Pertanian & Lingkungan', 'Kesehatan & Ilmu Hayat'], 'bobot_mapel' => [ @@ -45,7 +45,7 @@ public function run(): void ], [ 'nama_jurusan' => 'Manajemen Agribisnis', - 'deskripsi' => 'Program studi yang menggabungkan ilmu pertanian dan bisnis, meliputi pemasaran hasil pertanian, manajemen usaha tani, dan kewirausahaan agribisnis.', + 'deskripsi' => 'Jurusan yang menggabungkan ilmu pertanian dan bisnis, meliputi pemasaran hasil pertanian, manajemen usaha tani, dan kewirausahaan agribisnis.', 'keywords' => ['bisnis', 'agribisnis', 'usaha', 'entrepreneur', 'pengusaha', 'dagang', 'jual', 'pemasaran', 'kewirausahaan', 'manajemen', 'ekonomi pertanian', 'pasar'], 'preferensi_studi' => ['Bisnis & Manajemen', 'Pertanian & Lingkungan'], 'bobot_mapel' => [ @@ -56,7 +56,7 @@ public function run(): void ], [ 'nama_jurusan' => 'Teknologi Informasi', - 'deskripsi' => 'Program studi yang mempelajari pengembangan perangkat lunak, jaringan komputer, keamanan siber, dan teknologi digital.', + 'deskripsi' => 'Jurusan yang mempelajari pengembangan perangkat lunak, jaringan komputer, keamanan siber, dan teknologi digital.', 'keywords' => ['programmer', 'developer', 'coding', 'software', 'web', 'aplikasi', 'komputer', 'it', 'jaringan', 'hacker', 'game', 'data', 'ai', 'robot', 'ngoding', 'laptop', 'teknologi', 'digital', 'internet', 'programming', 'desain grafis'], 'preferensi_studi' => ['Sains & Teknologi'], 'bobot_mapel' => [ @@ -67,7 +67,7 @@ public function run(): void ], [ 'nama_jurusan' => 'Teknik', - 'deskripsi' => 'Program studi yang mempelajari mesin, kelistrikan, elektronika, dan otomasi industri.', + 'deskripsi' => 'Jurusan yang mempelajari mesin, kelistrikan, elektronika, dan otomasi industri.', 'keywords' => ['mesin', 'bengkel', 'listrik', 'las', 'robot', 'motor', 'teknik', 'otomasi', 'elektronik', 'instalasi', 'panel', 'mekanik', 'industri', 'manufaktur', 'pabrik', 'bangunan', 'konstruksi', 'sipil', 'energi'], 'preferensi_studi' => ['Sains & Teknologi'], 'bobot_mapel' => [ @@ -78,7 +78,7 @@ public function run(): void ], [ 'nama_jurusan' => 'Kesehatan', - 'deskripsi' => 'Program studi yang mempelajari ilmu kesehatan, gizi, rekam medis, dan pelayanan kesehatan masyarakat.', + 'deskripsi' => 'Jurusan yang mempelajari ilmu kesehatan, gizi, rekam medis, dan pelayanan kesehatan masyarakat.', 'keywords' => ['dokter', 'perawat', 'medis', 'gizi', 'kesehatan', 'pelayanan', 'terapis', 'obat', 'rumah sakit', 'klinik', 'farmasi', 'nutrisi', 'sanitasi', 'rawat', 'sehat'], 'preferensi_studi' => ['Kesehatan & Ilmu Hayat'], 'bobot_mapel' => [ @@ -89,7 +89,7 @@ public function run(): void ], [ 'nama_jurusan' => 'Bahasa, Komunikasi, dan Pariwisata', - 'deskripsi' => 'Program studi yang mempelajari bahasa asing, komunikasi, perhotelan, dan industri pariwisata.', + 'deskripsi' => 'Jurusan yang mempelajari bahasa asing, komunikasi, perhotelan, dan industri pariwisata.', 'keywords' => ['bahasa', 'komunikasi', 'pariwisata', 'tour guide', 'hotel', 'jurnalis', 'marketing', 'inggris', 'penerjemah', 'travel', 'wisata', 'hospitality', 'public speaking', 'media', 'broadcasting'], 'preferensi_studi' => ['Sosial & Humaniora', 'Bisnis & Manajemen'], 'bobot_mapel' => [ @@ -100,7 +100,7 @@ public function run(): void ], [ 'nama_jurusan' => 'Bisnis', - 'deskripsi' => 'Program studi yang mempelajari akuntansi, manajemen bisnis, perbankan, dan administrasi niaga.', + 'deskripsi' => 'Jurusan yang mempelajari akuntansi, manajemen bisnis, perbankan, dan administrasi niaga.', 'keywords' => ['manager', 'pimpinan', 'bisnis', 'accounting', 'marketing', 'sales', 'kantor', 'keuangan', 'bank', 'akuntansi', 'hitung', 'administrasi', 'perbankan', 'ekonomi', 'uang', 'investasi', 'pajak'], 'preferensi_studi' => ['Bisnis & Manajemen'], 'bobot_mapel' => [ diff --git a/resources/views/admin/dashboard.blade.php b/resources/views/admin/dashboard.blade.php index dad1093..7962ba4 100644 --- a/resources/views/admin/dashboard.blade.php +++ b/resources/views/admin/dashboard.blade.php @@ -33,7 +33,7 @@ {{ $stat->kelompok_asal ?? 'Tidak Ada' }}