From 3f0ce730a4ff207623b4a2d2dfff2267ec7914b9 Mon Sep 17 00:00:00 2001 From: KakaPatria Date: Tue, 21 Apr 2026 02:22:19 +0700 Subject: [PATCH] Improve Gemini chat flow, add Python backend gateway, and stabilize tests --- .env.example | 4 + Panduan_Chatbot_API_Gemini_Python.txt | 183 +++++++++++ Penjelasan_SPK_Naive_Bayes.txt | 240 ++++++++++++++ app/Http/Controllers/AdminController.php | 2 +- app/Services/GeminiService.php | 65 ++++ composer.json | 1 + composer.lock | 304 +++++++++++++++++- config/services.php | 2 + ...6_01_28_143443_add_role_to_users_table.php | 5 +- .../2026_02_12_add_bk_role_to_users.php | 15 +- public/python_backend/app.py | 229 +++++++++++++ public/python_backend/majors_data.json | 79 +++++ public/python_backend/requirements.txt | 3 + tests/Feature/Auth/RegistrationTest.php | 2 + tests/Feature/CrudValidationTest.php | 10 +- 15 files changed, 1127 insertions(+), 17 deletions(-) create mode 100644 Panduan_Chatbot_API_Gemini_Python.txt create mode 100644 Penjelasan_SPK_Naive_Bayes.txt create mode 100644 public/python_backend/app.py create mode 100644 public/python_backend/majors_data.json create mode 100644 public/python_backend/requirements.txt diff --git a/.env.example b/.env.example index 478972c..ab1597d 100644 --- a/.env.example +++ b/.env.example @@ -56,3 +56,7 @@ VITE_PUSHER_HOST="${PUSHER_HOST}" VITE_PUSHER_PORT="${PUSHER_PORT}" VITE_PUSHER_SCHEME="${PUSHER_SCHEME}" VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}" + +GEMINI_API_KEY= +GEMINI_BACKEND_URL=http://127.0.0.1:8001 +GEMINI_BACKEND_TOKEN= diff --git a/Panduan_Chatbot_API_Gemini_Python.txt b/Panduan_Chatbot_API_Gemini_Python.txt new file mode 100644 index 0000000..a26afa6 --- /dev/null +++ b/Panduan_Chatbot_API_Gemini_Python.txt @@ -0,0 +1,183 @@ +PANDUAN GABUNGAN: CHATBOT + API GEMINI + BACKEND PYTHON (app.py) + +============================================================ +A. KENAPA SEBELUMNYA TIDAK ADA PENJELASAN PYTHON app.py? +============================================================ + +Sebelumnya arsitektur chatbot di project ini langsung memanggil Gemini dari Laravel (PHP) +melalui file app/Services/GeminiService.php. + +Artinya: +- Tidak ada service Python terpisah. +- API key Gemini dibaca langsung dari env Laravel. + +Sekarang sudah disiapkan backend Python (app.py) agar alur lebih fleksibel: +- Laravel -> Python backend -> Gemini API +- Kunci API dapat difokuskan di backend Python. + + +============================================================ +B. STRUKTUR BARU YANG DITAMBAHKAN +============================================================ + +Folder baru: +- public/python_backend/app.py +- public/python_backend/requirements.txt + +Perubahan konfigurasi: +- config/services.php + tambah: + - services.gemini.backend_url + - services.gemini.backend_token + +- .env.example + tambah: + GEMINI_API_KEY= + GEMINI_BACKEND_URL=http://127.0.0.1:8001 + GEMINI_BACKEND_TOKEN= + +Perubahan service Laravel: +- app/Services/GeminiService.php + - jika GEMINI_BACKEND_URL terisi, Laravel coba kirim request ke Python backend dulu + - jika backend Python gagal, otomatis fallback ke pemanggilan Gemini langsung (mode lama) + + +============================================================ +C. CARA KERJA ARSITEKTUR CHATBOT SEKARANG +============================================================ + +1) User kirim chat dari UI Laravel. +2) ChatbotController memanggil GeminiService::chat(...). +3) GeminiService menyiapkan payload chat (contents + generationConfig). +4) Jika GEMINI_BACKEND_URL terisi: + - request dikirim ke Python: POST /api/chat +5) Python backend mencoba model berurutan: + - gemini-2.5-flash + - gemini-2.0-flash + - gemini-2.0-flash-lite +6) Jika Python sukses -> balasan dikembalikan ke Laravel. +7) Jika Python gagal -> Laravel fallback ke direct Gemini API. +8) Jika semua gagal -> fallback response lokal di GeminiService. + + +============================================================ +D. ENDPOINT PYTHON BACKEND +============================================================ + +1) GET /health + Fungsi: cek backend hidup + status key + +2) POST /api/chat + Body JSON: + { + "payload": { ... Gemini payload ... }, + "models": ["gemini-2.5-flash", "gemini-2.0-flash", "gemini-2.0-flash-lite"] + } + + Response sukses: + { + "success": true, + "message": "...jawaban model...", + "model": "gemini-2.5-flash" + } + + Response gagal: + { + "success": false, + "message": "Semua model gagal", + "error": "...detail..." + } + + +============================================================ +E. BATASAN / LIMIT CHATBOT (REKOMENDASI PRAKTIS) +============================================================ + +Saat ini yang sudah ada: +- history chat dari frontend dibatasi maksimal 20 item. +- text history dipotong maksimal 1500 karakter per item sebelum dikirim. + +Batasan yang disarankan agar stabil dan hemat biaya: +1) Batasi input user per pesan (contoh: 500-1000 karakter). +2) Batasi requests per menit per user (rate limit). +3) Batasi token output (sudah ada maxOutputTokens=4096, bisa diturunkan jika perlu). +4) Simpan cooldown jika terkena 429 berulang. +5) Tetapkan timeout request (sudah ada timeout di PHP dan Python). +6) Log usage untuk audit (siapa, kapan, berapa panjang pesan). + + +============================================================ +F. LANGKAH MENJALANKAN BACKEND PYTHON +============================================================ + +1) Masuk ke folder python backend + cd public/python_backend + +2) Buat virtual environment (opsional tapi disarankan) + python -m venv .venv + .venv\Scripts\activate + +3) Install dependency + pip install -r requirements.txt + +4) Set environment variable untuk backend Python + (PowerShell contoh) + $env:GEMINI_API_KEY="ISI_API_KEY_GEMINI" + $env:BACKEND_TOKEN="TOKEN_RAHASIA_BACKEND" # opsional tapi disarankan + $env:PY_BACKEND_HOST="127.0.0.1" + $env:PY_BACKEND_PORT="8001" + +5) Jalankan server Python + python app.py + +6) Test health endpoint + buka: http://127.0.0.1:8001/health + + +============================================================ +G. KONFIGURASI LARAVEL AGAR PAKAI BACKEND PYTHON +============================================================ + +Di .env Laravel, isi: +- GEMINI_BACKEND_URL=http://127.0.0.1:8001 +- GEMINI_BACKEND_TOKEN=TOKEN_RAHASIA_BACKEND + +Opsional: +- GEMINI_API_KEY=... (tetap boleh ada untuk fallback direct mode) + +Setelah ubah env: +- php artisan optimize:clear + + +============================================================ +H. KEAMANAN API KEY GEMINI +============================================================ + +Praktik aman: +1) Jangan hardcode API key di kode sumber. +2) Simpan API key di env backend Python. +3) Gunakan BACKEND_TOKEN agar endpoint Python tidak bisa dipakai bebas. +4) Jalankan backend Python di localhost/internal network. +5) Jangan commit file .env ke GitHub. + + +============================================================ +I. SATU FILE RINGKAS UNTUK SIDANG (NARASI SINGKAT) +============================================================ + +Arsitektur chatbot awal memanggil Gemini langsung dari Laravel. Agar manajemen API key +lebih aman dan fleksibel, ditambahkan backend Python app.py sebagai gateway. Laravel +mengirim payload ke Python endpoint /api/chat, lalu Python meneruskan ke Gemini dengan +mekanisme pemilihan model bertahap. Jika backend Python gagal, Laravel tetap memiliki +mekanisme fallback ke direct Gemini sehingga layanan chatbot lebih robust. Sistem juga +menerapkan pembatasan history dan panjang pesan, dan dapat ditingkatkan dengan rate limit +per pengguna agar performa tetap stabil. + + +============================================================ +J. CATATAN PENTING +============================================================ + +- Jika GEMINI_BACKEND_URL tidak diisi, sistem tetap berjalan seperti mode lama. +- Jika GEMINI_BACKEND_URL diisi tapi backend Python mati, sistem otomatis fallback direct. +- Untuk mode produksi, disarankan Python backend aktif + token + monitoring log. diff --git a/Penjelasan_SPK_Naive_Bayes.txt b/Penjelasan_SPK_Naive_Bayes.txt new file mode 100644 index 0000000..39b1a08 --- /dev/null +++ b/Penjelasan_SPK_Naive_Bayes.txt @@ -0,0 +1,240 @@ +PENJELASAN SISTEM REKOMENDASI JURUSAN POLIJE +(Weighted Naive Bayes) + +============================================================ +1) BOBOT KRITERIA +============================================================ + +Sistem menggunakan 5 kriteria utama: +1. Nilai akademik +2. Minat +3. Preferensi studi lanjutan +4. Prestasi +5. Cita-cita + +Bobot per jurusan berbeda-beda (disesuaikan karakter jurusan): + +- Produksi Pertanian: + nilai=0.40, minat=0.35, pref=0.15, prestasi=0.05, cita_cita=0.05 + +- Teknologi Pertanian: + nilai=0.50, minat=0.25, pref=0.15, prestasi=0.05, cita_cita=0.05 + +- Peternakan: + nilai=0.40, minat=0.40, pref=0.10, prestasi=0.05, cita_cita=0.05 + +- Manajemen Agribisnis: + nilai=0.35, minat=0.40, pref=0.15, prestasi=0.05, cita_cita=0.05 + +- Teknologi Informasi: + nilai=0.45, minat=0.35, pref=0.12, prestasi=0.05, cita_cita=0.03 + +- Teknik: + nilai=0.42, minat=0.38, pref=0.12, prestasi=0.05, cita_cita=0.03 + +- Kesehatan: + nilai=0.45, minat=0.35, pref=0.10, prestasi=0.05, cita_cita=0.05 + +- Bahasa, Komunikasi, dan Pariwisata: + nilai=0.30, minat=0.40, pref=0.15, prestasi=0.08, cita_cita=0.07 + +- Bisnis: + nilai=0.35, minat=0.40, pref=0.15, prestasi=0.05, cita_cita=0.05 + +Catatan penting: +Jika prestasi tidak diisi, bobot prestasi diubah menjadi 0.00, +lalu bobot kriteria lain dinormalisasi agar total bobot tetap 1.00. + + +============================================================ +2) CARA PERHITUNGAN NAIVE BAYES BERBOBOT +============================================================ + +2.1 Rumus dasar weighted Naive Bayes + +P(Hj|X) proporsional P(Hj) * produk_i( P(xi|Hj) ^ wi ) + +Keterangan: +- Hj = jurusan ke-j +- X = data siswa +- wi = bobot kriteria ke-i + + +2.2 Bentuk log (dipakai di sistem untuk stabilitas numerik) + +logScore(Hj) = log(P(Hj)) + + w_nilai * log(P(nilai|Hj)) + + w_minat * log(P(minat|Hj)) + + w_pref * log(P(pref|Hj)) + + w_cita * log(P(cita|Hj)) + + w_prestasi * log(P(prestasi|Hj)) + +Jika prestasi kosong, suku prestasi tidak dihitung. + + +2.3 Prior + +Prior dibuat seragam untuk semua jurusan: +P(Hj) = 1 / N, dengan N = jumlah jurusan (9) + + +2.4 Likelihood tiap kriteria + +A) Nilai akademik +- Sistem gabungkan dua komponen: + 1) Kecocokan kategori nilai (Tinggi/Sedang/Rendah) + 2) Kecocokan bobot mapel jurusan + +- Rumus gabungan: + p_nilai = 0.6 * p_nilai_category + 0.4 * p_nilai_subject + + +B) Minat +- Minat teks dipetakan + dihitung keyword coverage. +- Rumus: + combined = 0.6 * coverage + 0.4 * categoryMatch + p_minat = 0.20 + combined * (matchProb_minat - 0.20) + + +C) Preferensi studi +- Jika pref siswa termasuk pref jurusan -> pakai matchProb_pref +- Jika tidak cocok -> pakai (1 - matchProb_pref) + + +D) Cita-cita +- Berdasarkan keyword coverage cita-cita terhadap keyword jurusan: + p_cita = 0.20 + coverage * (matchProb_cita - 0.20) + + +E) Prestasi +- Prestasi diklasifikasi level: + tinggi / sedang / cukup / minimal +- Skor dasar prestasi (baseScore) digabung relevansi keyword jurusan: + combined = 0.75 * baseScore + 0.25 * relevance + p_prestasi = 0.20 + combined * (matchProb_prestasi - 0.20) + + +2.5 Batas likelihood + +Setiap likelihood dibatasi agar stabil: +0.05 <= p <= 0.98 + + +2.6 Softmax (normalisasi akhir) + +Setelah semua logScore dihitung: + +score_j = exp(logScore_j - maxLog) +prob_j = score_j / sum(score_k) + +prob_j adalah skor kecocokan akhir jurusan ke-j. +Hasil diurutkan dari skor terbesar ke terkecil. + + +============================================================ +3) LOGIKA REKOMENDASI (IMPLEMENTASI) +============================================================ + +3.1 Validasi input + +- Untuk siswa IPA, nilai wajib: + mtk, fisika, kimia, biologi + +- Untuk siswa IPS, nilai wajib: + ekonomi, geografi, sosiologi, sejarah + +- minat, pref_studi, cita_cita wajib diisi +- prestasi boleh kosong + + +3.2 Preprocessing + +A) Hitung rata-rata nilai +- Dari mata pelajaran yang diisi. + +B) Kategorisasi nilai +- Tinggi: 85-100 +- Sedang: 70-84 +- Rendah: 0-69 + +C) Pemetaan minat teks +- Logika dan Komputer +- Alam dan Tanaman +- Pelayanan dan Kesehatan +- Manajemen dan Bisnis +- Mesin dan Listrik +- Umum + +D) Cita-cita +- Diproses sebagai teks, lalu dicocokkan dengan keyword jurusan. + +E) Prestasi +- Jika ada kata seperti juara/menang/gold -> level tinggi +- Jika finalis/medali/peringkat -> level sedang +- Jika sertifikat/workshop/kursus -> level cukup +- Selain itu -> level minimal +- Jika kosong -> tidak dihitung dalam bobot akhir + + +3.3 Scoring per jurusan + +Untuk setiap jurusan, sistem: +1. Ambil bobot dan match_prob jurusan +2. Hitung p_nilai, p_minat, p_pref, p_cita, p_prestasi +3. Hitung logScore weighted Naive Bayes +4. Simpan detail kontribusi tiap kriteria + + +3.4 Ranking output + +1. LogScore semua jurusan dinormalisasi dengan softmax +2. Dibentuk daftar hasil akhir (jurusan + skor) +3. Diurutkan menurun berdasarkan skor +4. Jurusan tertinggi jadi rekomendasi utama + + +3.5 Penjelasan hasil + +Sistem juga menghasilkan explanation text untuk tiap kriteria: +- alasan berdasarkan nilai +- alasan berdasarkan minat +- alasan berdasarkan preferensi +- alasan berdasarkan cita-cita +- alasan berdasarkan prestasi (atau not counted jika kosong) + + +============================================================ +4) ALUR SISTEM END-TO-END +============================================================ + +1. Siswa login ke dashboard. +2. Siswa buka halaman Analisis Rekomendasi. +3. Siswa isi data: + - nilai mapel sesuai kelompok + - minat + - preferensi studi + - cita-cita + - prestasi (opsional) +4. Sistem validasi input. +5. Sistem lakukan preprocessing. +6. Sistem hitung skor semua jurusan dengan weighted Naive Bayes. +7. Sistem normalisasi skor (softmax). +8. Sistem tampilkan ranking 9 jurusan + detail alasan. +9. Sistem simpan hasil rekomendasi ke database. +10. Data hasil teratas disimpan ke session untuk konteks chatbot. +11. Siswa bisa lanjut konsultasi chatbot berbasis hasil rekomendasi. + + +============================================================ +5) RINGKASAN UNTUK SIDANG (SIAP BACA) +============================================================ + +Sistem ini adalah SPK rekomendasi jurusan Polije berbasis weighted Naive Bayes +menggunakan 5 kriteria utama: nilai, minat, preferensi studi, prestasi, dan +cita-cita. Bobot kriteria disesuaikan per jurusan agar lebih representatif. +Perhitungan dilakukan di domain log untuk stabilitas numerik, lalu dinormalisasi +menggunakan softmax agar menghasilkan probabilitas akhir. Jika prestasi kosong, +atribut prestasi dikeluarkan dari perhitungan dan bobot atribut lain +dinormalisasi ulang. Output sistem berupa ranking 9 jurusan, nilai skor, serta +penjelasan alasan kecocokan per kriteria sehingga hasil lebih transparan dan +mudah dipertanggungjawabkan. diff --git a/app/Http/Controllers/AdminController.php b/app/Http/Controllers/AdminController.php index e616845..8c868c4 100644 --- a/app/Http/Controllers/AdminController.php +++ b/app/Http/Controllers/AdminController.php @@ -86,7 +86,7 @@ public function students(Request $request) public function studentDetail($id) { - $student = User::findOrFail($id); + $student = User::where('role', 'siswa')->findOrFail($id); $recommendations = Recommendation::where('user_id', $id) ->orderBy('created_at', 'desc') ->get(); diff --git a/app/Services/GeminiService.php b/app/Services/GeminiService.php index c5b390f..49603af 100644 --- a/app/Services/GeminiService.php +++ b/app/Services/GeminiService.php @@ -9,6 +9,8 @@ class GeminiService { protected $apiKey; + protected $backendUrl; + protected $backendToken; protected $baseUrl = 'https://generativelanguage.googleapis.com/v1beta/models/'; // Model priority list - try each if previous fails @@ -21,6 +23,8 @@ class GeminiService public function __construct() { $this->apiKey = config('services.gemini.api_key'); + $this->backendUrl = rtrim((string) config('services.gemini.backend_url', ''), '/'); + $this->backendToken = (string) config('services.gemini.backend_token', ''); } public function chat($message, $context = [], $chatHistory = []) @@ -79,6 +83,18 @@ public function chat($message, $context = [], $chatHistory = []) ] ]; + // Jika backend Python dikonfigurasi, gunakan sebagai gateway Gemini terlebih dahulu. + if (!empty($this->backendUrl)) { + $proxyResponse = $this->sendViaPythonBackend($payload); + if (($proxyResponse['success'] ?? false) === true) { + return $proxyResponse; + } + + Log::warning('Python Gemini backend failed, fallback to direct API', [ + 'error' => $proxyResponse['message'] ?? 'unknown', + ]); + } + // Try each model until one works foreach ($this->models as $model) { $url = $this->baseUrl . $model . ':generateContent?key=' . $this->apiKey; @@ -126,6 +142,55 @@ public function chat($message, $context = [], $chatHistory = []) } } + protected function sendViaPythonBackend(array $payload): array + { + try { + $url = $this->backendUrl . '/api/chat'; + $request = Http::timeout(35)->withHeaders([ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + ]); + + if (!empty($this->backendToken)) { + $request = $request->withToken($this->backendToken); + } + + $response = $request->post($url, [ + 'payload' => $payload, + 'models' => $this->models, + ]); + + if (!$response->successful()) { + return [ + 'success' => false, + 'message' => 'Python backend error HTTP ' . $response->status(), + ]; + } + + $data = $response->json(); + if (($data['success'] ?? false) === true && !empty($data['message'])) { + return [ + 'success' => true, + 'message' => $data['message'], + ]; + } + + return [ + 'success' => false, + 'message' => $data['message'] ?? 'Python backend response invalid', + ]; + } catch (\Exception $e) { + Log::warning('Python backend exception', [ + 'error' => $e->getMessage(), + ]); + + return [ + 'success' => false, + 'message' => 'Python backend exception', + ]; + } + } + protected function getFallbackResponse($message, $context = [], $chatHistory = []) { $jurusan = $context['recommendation'] ?? null; diff --git a/composer.json b/composer.json index a5a7dab..dbceacf 100644 --- a/composer.json +++ b/composer.json @@ -12,6 +12,7 @@ "laravel/tinker": "^2.8" }, "require-dev": { + "doctrine/dbal": "^3.10", "fakerphp/faker": "^1.9.1", "laravel/breeze": "^1.29", "laravel/pint": "^1.0", diff --git a/composer.lock b/composer.lock index f07862b..23ddf63 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "85309d4524da1baae35ac151622b248f", + "content-hash": "c9424317cf092c186df062db573db2bf", "packages": [ { "name": "brick/math", @@ -5634,6 +5634,259 @@ } ], "packages-dev": [ + { + "name": "doctrine/dbal", + "version": "3.10.5", + "source": { + "type": "git", + "url": "https://github.com/doctrine/dbal.git", + "reference": "95d84866bf3c04b2ddca1df7c049714660959aef" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/dbal/zipball/95d84866bf3c04b2ddca1df7c049714660959aef", + "reference": "95d84866bf3c04b2ddca1df7c049714660959aef", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2", + "doctrine/deprecations": "^0.5.3|^1", + "doctrine/event-manager": "^1|^2", + "php": "^7.4 || ^8.0", + "psr/cache": "^1|^2|^3", + "psr/log": "^1|^2|^3" + }, + "conflict": { + "doctrine/cache": "< 1.11" + }, + "require-dev": { + "doctrine/cache": "^1.11|^2.0", + "doctrine/coding-standard": "14.0.0", + "fig/log-test": "^1", + "jetbrains/phpstorm-stubs": "2023.1", + "phpstan/phpstan": "2.1.30", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "9.6.34", + "slevomat/coding-standard": "8.27.1", + "squizlabs/php_codesniffer": "4.0.1", + "symfony/cache": "^5.4|^6.0|^7.0|^8.0", + "symfony/console": "^4.4|^5.4|^6.0|^7.0|^8.0" + }, + "suggest": { + "symfony/console": "For helpful console commands such as SQL execution and import of files." + }, + "bin": [ + "bin/doctrine-dbal" + ], + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\DBAL\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + } + ], + "description": "Powerful PHP database abstraction layer (DBAL) with many features for database schema introspection and management.", + "homepage": "https://www.doctrine-project.org/projects/dbal.html", + "keywords": [ + "abstraction", + "database", + "db2", + "dbal", + "mariadb", + "mssql", + "mysql", + "oci8", + "oracle", + "pdo", + "pgsql", + "postgresql", + "queryobject", + "sasql", + "sql", + "sqlite", + "sqlserver", + "sqlsrv" + ], + "support": { + "issues": "https://github.com/doctrine/dbal/issues", + "source": "https://github.com/doctrine/dbal/tree/3.10.5" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fdbal", + "type": "tidelift" + } + ], + "time": "2026-02-24T08:03:57+00:00" + }, + { + "name": "doctrine/deprecations", + "version": "1.1.6", + "source": { + "type": "git", + "url": "https://github.com/doctrine/deprecations.git", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "phpunit/phpunit": "<=7.5 || >=14" + }, + "require-dev": { + "doctrine/coding-standard": "^9 || ^12 || ^14", + "phpstan/phpstan": "1.4.10 || 2.1.30", + "phpstan/phpstan-phpunit": "^1.0 || ^2", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0", + "psr/log": "^1 || ^2 || ^3" + }, + "suggest": { + "psr/log": "Allows logging deprecations via PSR-3 logger implementation" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Deprecations\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.", + "homepage": "https://www.doctrine-project.org/", + "support": { + "issues": "https://github.com/doctrine/deprecations/issues", + "source": "https://github.com/doctrine/deprecations/tree/1.1.6" + }, + "time": "2026-02-07T07:09:04+00:00" + }, + { + "name": "doctrine/event-manager", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/doctrine/event-manager.git", + "reference": "dda33921b198841ca8dbad2eaa5d4d34769d18cf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/event-manager/zipball/dda33921b198841ca8dbad2eaa5d4d34769d18cf", + "reference": "dda33921b198841ca8dbad2eaa5d4d34769d18cf", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "conflict": { + "doctrine/common": "<2.9" + }, + "require-dev": { + "doctrine/coding-standard": "^14", + "phpdocumentor/guides-cli": "^1.4", + "phpstan/phpstan": "^2.1.32", + "phpunit/phpunit": "^10.5.58" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Common\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + }, + { + "name": "Marco Pivetta", + "email": "ocramius@gmail.com" + } + ], + "description": "The Doctrine Event Manager is a simple PHP event system that was built to be used with the various Doctrine projects.", + "homepage": "https://www.doctrine-project.org/projects/event-manager.html", + "keywords": [ + "event", + "event dispatcher", + "event manager", + "event system", + "events" + ], + "support": { + "issues": "https://github.com/doctrine/event-manager/issues", + "source": "https://github.com/doctrine/event-manager/tree/2.1.1" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fevent-manager", + "type": "tidelift" + } + ], + "time": "2026-01-29T07:11:08+00:00" + }, { "name": "fakerphp/faker", "version": "v1.24.1", @@ -6798,6 +7051,55 @@ ], "time": "2026-01-27T05:48:37+00:00" }, + { + "name": "psr/cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/cache.git", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for caching libraries", + "keywords": [ + "cache", + "psr", + "psr-6" + ], + "support": { + "source": "https://github.com/php-fig/cache/tree/3.0.0" + }, + "time": "2021-02-03T23:26:27+00:00" + }, { "name": "sebastian/cli-parser", "version": "2.0.1", diff --git a/config/services.php b/config/services.php index fb7cb00..8974c5e 100644 --- a/config/services.php +++ b/config/services.php @@ -33,6 +33,8 @@ 'gemini' => [ 'api_key' => env('GEMINI_API_KEY'), + 'backend_url' => env('GEMINI_BACKEND_URL'), + 'backend_token' => env('GEMINI_BACKEND_TOKEN'), ], ]; diff --git a/database/migrations/2026_01_28_143443_add_role_to_users_table.php b/database/migrations/2026_01_28_143443_add_role_to_users_table.php index 60490cb..8a7a279 100644 --- a/database/migrations/2026_01_28_143443_add_role_to_users_table.php +++ b/database/migrations/2026_01_28_143443_add_role_to_users_table.php @@ -12,8 +12,7 @@ public function up(): void { Schema::table('users', function (Blueprint $table) { - // - $table->enum('role', ['admin', 'guru', 'siswa'])->default('siswa')->after('password'); + $table->enum('role', ['admin', 'guru', 'bk', 'siswa'])->default('siswa')->after('password'); }); } @@ -23,7 +22,7 @@ public function up(): void public function down(): void { Schema::table('users', function (Blueprint $table) { - // + $table->dropColumn('role'); }); } }; diff --git a/database/migrations/2026_02_12_add_bk_role_to_users.php b/database/migrations/2026_02_12_add_bk_role_to_users.php index a03dae4..82166bb 100644 --- a/database/migrations/2026_02_12_add_bk_role_to_users.php +++ b/database/migrations/2026_02_12_add_bk_role_to_users.php @@ -2,6 +2,7 @@ use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; return new class extends Migration @@ -11,10 +12,10 @@ */ public function up(): void { - // Modify role enum to include 'bk' - Schema::table('users', function (Blueprint $table) { - $table->enum('role', ['admin', 'guru', 'bk', 'siswa'])->default('siswa')->change(); - }); + // Use raw SQL for MySQL to avoid Doctrine enum introspection issues in tests. + if (DB::getDriverName() === 'mysql') { + DB::statement("ALTER TABLE users MODIFY role ENUM('admin','guru','bk','siswa') NOT NULL DEFAULT 'siswa'"); + } } /** @@ -22,8 +23,8 @@ public function up(): void */ public function down(): void { - Schema::table('users', function (Blueprint $table) { - $table->enum('role', ['admin', 'guru', 'siswa'])->default('siswa')->change(); - }); + if (DB::getDriverName() === 'mysql') { + DB::statement("ALTER TABLE users MODIFY role ENUM('admin','guru','siswa') NOT NULL DEFAULT 'siswa'"); + } } }; diff --git a/public/python_backend/app.py b/public/python_backend/app.py new file mode 100644 index 0000000..b527143 --- /dev/null +++ b/public/python_backend/app.py @@ -0,0 +1,229 @@ +import os +import json +import time +import logging +from typing import Any, Dict, List + +import requests +from flask import Flask, jsonify, request +from dotenv import load_dotenv + +BASE_DIR = os.path.dirname(__file__) +ROOT_ENV = os.path.abspath(os.path.join(BASE_DIR, "..", "..", ".env")) +load_dotenv(ROOT_ENV) +load_dotenv(os.path.join(BASE_DIR, ".env")) + +app = Flask(__name__) + +GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "") +BACKEND_TOKEN = os.getenv("BACKEND_TOKEN", "") +GEMINI_BASE_URL = os.getenv("GEMINI_BASE_URL", "https://generativelanguage.googleapis.com/v1beta/models") +TIMEOUT_SECONDS = int(os.getenv("GEMINI_TIMEOUT", "30")) +MAJORS_FILE_PATH = os.getenv("MAJORS_FILE_PATH", os.path.join(BASE_DIR, "majors_data.json")) +LOG_FILE_PATH = os.getenv("PY_BACKEND_LOG_FILE", os.path.join(BASE_DIR, "backend.log")) + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(message)s", + handlers=[ + logging.StreamHandler(), + logging.FileHandler(LOG_FILE_PATH, encoding="utf-8"), + ], +) +logger = logging.getLogger("python_backend") + + +def _log(message: str) -> None: + logger.info(message) + + +def _is_authorized() -> bool: + if not BACKEND_TOKEN: + return True + + auth_header = request.headers.get("Authorization", "") + if not auth_header.startswith("Bearer "): + return False + + token = auth_header.replace("Bearer ", "", 1).strip() + return token == BACKEND_TOKEN + + +def _extract_text(data: Dict[str, Any]) -> str: + return ( + data.get("candidates", [{}])[0] + .get("content", {}) + .get("parts", [{}])[0] + .get("text", "") + ) + + +def _load_majors_file() -> Dict[str, Any]: + try: + with open(MAJORS_FILE_PATH, "r", encoding="utf-8") as handle: + data = json.load(handle) + except FileNotFoundError: + return { + "ok": False, + "message": f"File data jurusan tidak ditemukan: {MAJORS_FILE_PATH}", + "data": {"majors": []}, + } + except json.JSONDecodeError as exc: + return { + "ok": False, + "message": f"Format JSON tidak valid: {exc}", + "data": {"majors": []}, + } + + majors = data.get("majors") if isinstance(data, dict) else [] + if not isinstance(majors, list): + majors = [] + + return { + "ok": True, + "message": "ok", + "data": { + "schema_version": data.get("schema_version", "unknown") if isinstance(data, dict) else "unknown", + "last_updated": data.get("last_updated", "unknown") if isinstance(data, dict) else "unknown", + "notes": data.get("notes", "") if isinstance(data, dict) else "", + "majors": majors, + }, + } + + +def _build_majors_context_text(majors_data: Dict[str, Any]) -> str: + majors = majors_data.get("majors", []) + if not majors: + return "" + + lines = [ + "DATA JURUSAN RESMI (WAJIB JADI ACUAN UTAMA)", + f"schema_version: {majors_data.get('schema_version', 'unknown')}", + f"last_updated: {majors_data.get('last_updated', 'unknown')}", + "Gunakan data berikut untuk menjawab informasi jurusan secara konsisten.", + "Jika ada konflik dengan asumsi model, utamakan data ini.", + ] + + for index, major in enumerate(majors, start=1): + name = major.get("name", "-") + description = major.get("description", "-") + prospects = major.get("career_prospects", "-") + preferences = ", ".join(major.get("study_preferences", [])) or "-" + keywords = ", ".join(major.get("keywords", [])) or "-" + + lines.append( + f"{index}. {name} | deskripsi: {description} | preferensi studi: {preferences} | prospek: {prospects} | kata kunci: {keywords}" + ) + + return "\n".join(lines) + + +def _inject_majors_context(payload: Dict[str, Any], majors_context: str) -> Dict[str, Any]: + if not majors_context: + return payload + + contents = payload.get("contents", []) + if not isinstance(contents, list) or not contents: + return payload + + first = contents[0] + if isinstance(first, dict) and first.get("role") == "user": + parts = first.get("parts", []) + if isinstance(parts, list) and parts and isinstance(parts[0], dict): + original_text = str(parts[0].get("text", "")) + parts[0]["text"] = f"{majors_context}\n\n{original_text}" + return payload + + contents.insert(0, {"role": "user", "parts": [{"text": majors_context}]}) + return payload + + +@app.get("/health") +def health() -> Any: + majors_result = _load_majors_file() + majors_count = len(majors_result["data"].get("majors", [])) + _log("[PY-BACKEND] GET /health") + return jsonify( + { + "ok": True, + "gemini_key_configured": bool(GEMINI_API_KEY), + "majors_file": MAJORS_FILE_PATH, + "majors_loaded": majors_count, + "majors_valid": majors_result["ok"], + } + ) + + +@app.get("/api/majors") +def majors() -> Any: + result = _load_majors_file() + status = 200 if result["ok"] else 500 + return jsonify(result), status + + +@app.post("/api/chat") +def chat() -> Any: + _log("[PY-BACKEND] POST /api/chat") + if not _is_authorized(): + _log("[PY-BACKEND] Unauthorized request") + return jsonify({"success": False, "message": "Unauthorized"}), 401 + + if not GEMINI_API_KEY: + return jsonify({"success": False, "message": "GEMINI_API_KEY belum diset di backend Python"}), 500 + + body = request.get_json(silent=True) or {} + payload = body.get("payload") + models = body.get("models", ["gemini-2.5-flash", "gemini-2.0-flash", "gemini-2.0-flash-lite"]) + + if not isinstance(payload, dict) or not payload.get("contents"): + _log("[PY-BACKEND] Invalid payload") + return jsonify({"success": False, "message": "payload tidak valid"}), 422 + + majors_result = _load_majors_file() + if majors_result["ok"]: + majors_context = _build_majors_context_text(majors_result["data"]) + payload = _inject_majors_context(payload, majors_context) + _log(f"[PY-BACKEND] Majors context loaded: {len(majors_result['data'].get('majors', []))} jurusan") + else: + _log(f"[PY-BACKEND] Warning: {majors_result['message']}") + + last_error = "" + for model in models: + url = f"{GEMINI_BASE_URL}/{model}:generateContent?key={GEMINI_API_KEY}" + try: + resp = requests.post( + url, + json=payload, + headers={"Content-Type": "application/json"}, + timeout=TIMEOUT_SECONDS, + ) + except requests.RequestException as exc: + last_error = str(exc) + continue + + if resp.ok: + data = resp.json() + text = _extract_text(data) + if text: + _log(f"[PY-BACKEND] Success using model: {model}") + return jsonify({"success": True, "message": text, "model": model}) + last_error = "Respons model tidak berisi teks" + continue + + if resp.status_code in (404, 429): + if resp.status_code == 429: + time.sleep(1) + last_error = f"Model {model} gagal dengan status {resp.status_code}" + continue + + last_error = f"Gemini error {resp.status_code}: {resp.text[:300]}" + + _log(f"[PY-BACKEND] Failed all models: {last_error}") + return jsonify({"success": False, "message": "Semua model gagal", "error": last_error}), 502 + + +if __name__ == "__main__": + host = os.getenv("PY_BACKEND_HOST", "0.0.0.0") + port = int(os.getenv("PY_BACKEND_PORT", "5000")) + debug = os.getenv("PY_BACKEND_DEBUG", "true").lower() == "true" + app.run(host=host, port=port, debug=debug) diff --git a/public/python_backend/majors_data.json b/public/python_backend/majors_data.json new file mode 100644 index 0000000..b3bc61a --- /dev/null +++ b/public/python_backend/majors_data.json @@ -0,0 +1,79 @@ +{ + "schema_version": "1.0", + "last_updated": "2026-04-21", + "notes": "Edit file ini jika ada perubahan data jurusan. Backend Python akan membaca file ini untuk konteks chatbot.", + "majors": [ + { + "id": "produksi_pertanian", + "name": "Produksi Pertanian", + "description": "Mempelajari budidaya tanaman, pengelolaan lahan, dan produksi hasil pertanian modern.", + "keywords": ["pertanian", "budidaya", "tanaman", "agronomi", "pangan"], + "study_preferences": ["Pertanian & Lingkungan"], + "career_prospects": "Petani modern, konsultan pertanian, pengelola perkebunan, peneliti pertanian, agronomis." + }, + { + "id": "teknologi_pertanian", + "name": "Teknologi Pertanian", + "description": "Mengintegrasikan teknologi dengan pertanian, termasuk mekanisasi, otomasi, dan pengolahan hasil pangan.", + "keywords": ["teknologi pertanian", "mekanisasi", "otomasi", "smart farming", "teknologi pangan"], + "study_preferences": ["Sains & Teknologi", "Pertanian & Lingkungan"], + "career_prospects": "Teknisi pertanian, ahli mekanisasi, quality control pangan, peneliti teknologi pangan." + }, + { + "id": "peternakan", + "name": "Peternakan", + "description": "Mempelajari pengelolaan ternak, nutrisi hewan, reproduksi, dan pengolahan produk peternakan.", + "keywords": ["peternakan", "ternak", "nutrisi hewan", "farm management", "kesehatan hewan"], + "study_preferences": ["Pertanian & Lingkungan", "Kesehatan & Ilmu Hayat"], + "career_prospects": "Peternak profesional, konsultan peternakan, manajer peternakan, ahli nutrisi hewan." + }, + { + "id": "manajemen_agribisnis", + "name": "Manajemen Agribisnis", + "description": "Menggabungkan ilmu pertanian dan bisnis, termasuk manajemen usaha, pemasaran, dan kewirausahaan agribisnis.", + "keywords": ["agribisnis", "manajemen", "pemasaran", "wirausaha", "supply chain"], + "study_preferences": ["Bisnis & Manajemen", "Pertanian & Lingkungan"], + "career_prospects": "Manajer agribisnis, entrepreneur pertanian, konsultan pemasaran pertanian, analis pasar komoditas." + }, + { + "id": "teknologi_informasi", + "name": "Teknologi Informasi", + "description": "Mempelajari pengembangan software, data, jaringan, keamanan siber, dan teknologi digital terapan.", + "keywords": ["programming", "software", "web", "data", "cybersecurity"], + "study_preferences": ["Sains & Teknologi"], + "career_prospects": "Software developer, web developer, network engineer, data analyst, cybersecurity specialist." + }, + { + "id": "teknik", + "name": "Teknik", + "description": "Mempelajari mesin, kelistrikan, elektronika, otomasi, dan sistem teknik industri.", + "keywords": ["mesin", "listrik", "otomasi", "manufaktur", "mekatronika"], + "study_preferences": ["Sains & Teknologi"], + "career_prospects": "Teknisi mesin, ahli listrik, engineer industri, maintenance engineer, kontraktor." + }, + { + "id": "kesehatan", + "name": "Kesehatan", + "description": "Mempelajari ilmu kesehatan terapan, gizi, rekam medis, dan pelayanan kesehatan masyarakat.", + "keywords": ["kesehatan", "gizi", "rekam medis", "farmasi", "kesehatan masyarakat"], + "study_preferences": ["Kesehatan & Ilmu Hayat"], + "career_prospects": "Ahli gizi, perekam medis, tenaga kesehatan, asisten apoteker, sanitarian." + }, + { + "id": "bahasa_komunikasi_pariwisata", + "name": "Bahasa, Komunikasi, dan Pariwisata", + "description": "Mempelajari bahasa, komunikasi profesional, perhotelan, layanan publik, dan industri pariwisata.", + "keywords": ["bahasa", "komunikasi", "pariwisata", "hospitality", "media"], + "study_preferences": ["Sosial & Humaniora", "Bisnis & Manajemen"], + "career_prospects": "Tour guide, staf perhotelan, jurnalis, public relation, penerjemah, staf maskapai." + }, + { + "id": "bisnis", + "name": "Bisnis", + "description": "Mempelajari akuntansi, manajemen bisnis, perbankan, keuangan, dan administrasi niaga.", + "keywords": ["akuntansi", "manajemen", "perbankan", "keuangan", "analisis bisnis"], + "study_preferences": ["Bisnis & Manajemen"], + "career_prospects": "Akuntan, staf perbankan, manajer bisnis, marketing executive, analis keuangan." + } + ] +} diff --git a/public/python_backend/requirements.txt b/public/python_backend/requirements.txt new file mode 100644 index 0000000..9d1abcf --- /dev/null +++ b/public/python_backend/requirements.txt @@ -0,0 +1,3 @@ +flask==3.0.3 +requests==2.32.3 +python-dotenv==1.0.1 diff --git a/tests/Feature/Auth/RegistrationTest.php b/tests/Feature/Auth/RegistrationTest.php index 30829b1..6dbe3da 100644 --- a/tests/Feature/Auth/RegistrationTest.php +++ b/tests/Feature/Auth/RegistrationTest.php @@ -24,6 +24,8 @@ public function test_new_users_can_register(): void 'email' => 'test@example.com', 'password' => 'password', 'password_confirmation' => 'password', + 'nis' => '1234567890', + 'kelompok_asal' => 'IPA', ]); $this->assertAuthenticated(); diff --git a/tests/Feature/CrudValidationTest.php b/tests/Feature/CrudValidationTest.php index 8d0718e..26f8079 100644 --- a/tests/Feature/CrudValidationTest.php +++ b/tests/Feature/CrudValidationTest.php @@ -18,7 +18,7 @@ public function test_admin_can_add_jurusan_data() { $admin = User::factory()->create(['role' => 'admin']); - $response = $this->actingAs($admin)->post(route('jurusan.store'), [ + $response = $this->actingAs($admin)->post(route('admin.jurusan.store'), [ 'nama_jurusan' => 'Informatika', 'singkatan' => 'IF', 'tujuan_kompetensi' => 'Profesional IT sejati', @@ -41,7 +41,7 @@ public function test_bk_can_add_jurusan_data() { $bk = User::factory()->create(['role' => 'bk']); - $response = $this->actingAs($bk)->post(route('jurusan.store'), [ + $response = $this->actingAs($bk)->post(route('bk.jurusan.store'), [ 'nama_jurusan' => 'Akuntansi', 'singkatan' => 'AK', 'tujuan_kompetensi' => 'Profesional akuntansi', @@ -65,7 +65,7 @@ public function test_admin_guru_bk_store_validates_email_and_password() $admin = User::factory()->create(['role' => 'admin']); // Invalid email format - $response = $this->actingAs($admin)->post(route('admin.store'), [ + $response = $this->actingAs($admin)->post(route('admin.guru-bk.store'), [ 'email' => 'invalid-email', 'password' => 'password123', 'password_confirmation' => 'password123', @@ -74,7 +74,7 @@ public function test_admin_guru_bk_store_validates_email_and_password() $response->assertSessionHasErrors('email'); // Password too short - $response = $this->actingAs($admin)->post(route('admin.store'), [ + $response = $this->actingAs($admin)->post(route('admin.guru-bk.store'), [ 'email' => 'valid@example.com', 'password' => 'pass', 'password_confirmation' => 'pass', @@ -113,7 +113,7 @@ public function test_admin_student_detail_only_accepts_siswa_id() $admin = User::factory()->create(['role' => 'admin']); $bk = User::factory()->create(['role' => 'bk']); - $response = $this->actingAs($admin)->get(route('admin.studentDetail', $bk->id)); + $response = $this->actingAs($admin)->get(route('admin.student.detail', $bk->id)); $response->assertStatus(404); } }