Improve Gemini chat flow, add Python backend gateway, and stabilize tests
This commit is contained in:
parent
fcac0ac627
commit
3f0ce730a4
|
|
@ -56,3 +56,7 @@ VITE_PUSHER_HOST="${PUSHER_HOST}"
|
||||||
VITE_PUSHER_PORT="${PUSHER_PORT}"
|
VITE_PUSHER_PORT="${PUSHER_PORT}"
|
||||||
VITE_PUSHER_SCHEME="${PUSHER_SCHEME}"
|
VITE_PUSHER_SCHEME="${PUSHER_SCHEME}"
|
||||||
VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"
|
VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"
|
||||||
|
|
||||||
|
GEMINI_API_KEY=
|
||||||
|
GEMINI_BACKEND_URL=http://127.0.0.1:8001
|
||||||
|
GEMINI_BACKEND_TOKEN=
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -86,7 +86,7 @@ public function students(Request $request)
|
||||||
|
|
||||||
public function studentDetail($id)
|
public function studentDetail($id)
|
||||||
{
|
{
|
||||||
$student = User::findOrFail($id);
|
$student = User::where('role', 'siswa')->findOrFail($id);
|
||||||
$recommendations = Recommendation::where('user_id', $id)
|
$recommendations = Recommendation::where('user_id', $id)
|
||||||
->orderBy('created_at', 'desc')
|
->orderBy('created_at', 'desc')
|
||||||
->get();
|
->get();
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@
|
||||||
class GeminiService
|
class GeminiService
|
||||||
{
|
{
|
||||||
protected $apiKey;
|
protected $apiKey;
|
||||||
|
protected $backendUrl;
|
||||||
|
protected $backendToken;
|
||||||
protected $baseUrl = 'https://generativelanguage.googleapis.com/v1beta/models/';
|
protected $baseUrl = 'https://generativelanguage.googleapis.com/v1beta/models/';
|
||||||
|
|
||||||
// Model priority list - try each if previous fails
|
// Model priority list - try each if previous fails
|
||||||
|
|
@ -21,6 +23,8 @@ class GeminiService
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->apiKey = config('services.gemini.api_key');
|
$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 = [])
|
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
|
// Try each model until one works
|
||||||
foreach ($this->models as $model) {
|
foreach ($this->models as $model) {
|
||||||
$url = $this->baseUrl . $model . ':generateContent?key=' . $this->apiKey;
|
$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 = [])
|
protected function getFallbackResponse($message, $context = [], $chatHistory = [])
|
||||||
{
|
{
|
||||||
$jurusan = $context['recommendation'] ?? null;
|
$jurusan = $context['recommendation'] ?? null;
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
"laravel/tinker": "^2.8"
|
"laravel/tinker": "^2.8"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
|
"doctrine/dbal": "^3.10",
|
||||||
"fakerphp/faker": "^1.9.1",
|
"fakerphp/faker": "^1.9.1",
|
||||||
"laravel/breeze": "^1.29",
|
"laravel/breeze": "^1.29",
|
||||||
"laravel/pint": "^1.0",
|
"laravel/pint": "^1.0",
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "85309d4524da1baae35ac151622b248f",
|
"content-hash": "c9424317cf092c186df062db573db2bf",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "brick/math",
|
"name": "brick/math",
|
||||||
|
|
@ -5634,6 +5634,259 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"packages-dev": [
|
"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",
|
"name": "fakerphp/faker",
|
||||||
"version": "v1.24.1",
|
"version": "v1.24.1",
|
||||||
|
|
@ -6798,6 +7051,55 @@
|
||||||
],
|
],
|
||||||
"time": "2026-01-27T05:48:37+00:00"
|
"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",
|
"name": "sebastian/cli-parser",
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,8 @@
|
||||||
|
|
||||||
'gemini' => [
|
'gemini' => [
|
||||||
'api_key' => env('GEMINI_API_KEY'),
|
'api_key' => env('GEMINI_API_KEY'),
|
||||||
|
'backend_url' => env('GEMINI_BACKEND_URL'),
|
||||||
|
'backend_token' => env('GEMINI_BACKEND_TOKEN'),
|
||||||
],
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,7 @@
|
||||||
public function up(): void
|
public function up(): void
|
||||||
{
|
{
|
||||||
Schema::table('users', function (Blueprint $table) {
|
Schema::table('users', function (Blueprint $table) {
|
||||||
//
|
$table->enum('role', ['admin', 'guru', 'bk', 'siswa'])->default('siswa')->after('password');
|
||||||
$table->enum('role', ['admin', 'guru', 'siswa'])->default('siswa')->after('password');
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -23,7 +22,7 @@ public function up(): void
|
||||||
public function down(): void
|
public function down(): void
|
||||||
{
|
{
|
||||||
Schema::table('users', function (Blueprint $table) {
|
Schema::table('users', function (Blueprint $table) {
|
||||||
//
|
$table->dropColumn('role');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
use Illuminate\Database\Migrations\Migration;
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Schema;
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
return new class extends Migration
|
return new class extends Migration
|
||||||
|
|
@ -11,10 +12,10 @@
|
||||||
*/
|
*/
|
||||||
public function up(): void
|
public function up(): void
|
||||||
{
|
{
|
||||||
// Modify role enum to include 'bk'
|
// Use raw SQL for MySQL to avoid Doctrine enum introspection issues in tests.
|
||||||
Schema::table('users', function (Blueprint $table) {
|
if (DB::getDriverName() === 'mysql') {
|
||||||
$table->enum('role', ['admin', 'guru', 'bk', 'siswa'])->default('siswa')->change();
|
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
|
public function down(): void
|
||||||
{
|
{
|
||||||
Schema::table('users', function (Blueprint $table) {
|
if (DB::getDriverName() === 'mysql') {
|
||||||
$table->enum('role', ['admin', 'guru', 'siswa'])->default('siswa')->change();
|
DB::statement("ALTER TABLE users MODIFY role ENUM('admin','guru','siswa') NOT NULL DEFAULT 'siswa'");
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
flask==3.0.3
|
||||||
|
requests==2.32.3
|
||||||
|
python-dotenv==1.0.1
|
||||||
|
|
@ -24,6 +24,8 @@ public function test_new_users_can_register(): void
|
||||||
'email' => 'test@example.com',
|
'email' => 'test@example.com',
|
||||||
'password' => 'password',
|
'password' => 'password',
|
||||||
'password_confirmation' => 'password',
|
'password_confirmation' => 'password',
|
||||||
|
'nis' => '1234567890',
|
||||||
|
'kelompok_asal' => 'IPA',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->assertAuthenticated();
|
$this->assertAuthenticated();
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ public function test_admin_can_add_jurusan_data()
|
||||||
{
|
{
|
||||||
$admin = User::factory()->create(['role' => 'admin']);
|
$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',
|
'nama_jurusan' => 'Informatika',
|
||||||
'singkatan' => 'IF',
|
'singkatan' => 'IF',
|
||||||
'tujuan_kompetensi' => 'Profesional IT sejati',
|
'tujuan_kompetensi' => 'Profesional IT sejati',
|
||||||
|
|
@ -41,7 +41,7 @@ public function test_bk_can_add_jurusan_data()
|
||||||
{
|
{
|
||||||
$bk = User::factory()->create(['role' => 'bk']);
|
$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',
|
'nama_jurusan' => 'Akuntansi',
|
||||||
'singkatan' => 'AK',
|
'singkatan' => 'AK',
|
||||||
'tujuan_kompetensi' => 'Profesional akuntansi',
|
'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']);
|
$admin = User::factory()->create(['role' => 'admin']);
|
||||||
|
|
||||||
// Invalid email format
|
// Invalid email format
|
||||||
$response = $this->actingAs($admin)->post(route('admin.store'), [
|
$response = $this->actingAs($admin)->post(route('admin.guru-bk.store'), [
|
||||||
'email' => 'invalid-email',
|
'email' => 'invalid-email',
|
||||||
'password' => 'password123',
|
'password' => 'password123',
|
||||||
'password_confirmation' => 'password123',
|
'password_confirmation' => 'password123',
|
||||||
|
|
@ -74,7 +74,7 @@ public function test_admin_guru_bk_store_validates_email_and_password()
|
||||||
$response->assertSessionHasErrors('email');
|
$response->assertSessionHasErrors('email');
|
||||||
|
|
||||||
// Password too short
|
// 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',
|
'email' => 'valid@example.com',
|
||||||
'password' => 'pass',
|
'password' => 'pass',
|
||||||
'password_confirmation' => 'pass',
|
'password_confirmation' => 'pass',
|
||||||
|
|
@ -113,7 +113,7 @@ public function test_admin_student_detail_only_accepts_siswa_id()
|
||||||
$admin = User::factory()->create(['role' => 'admin']);
|
$admin = User::factory()->create(['role' => 'admin']);
|
||||||
$bk = User::factory()->create(['role' => 'bk']);
|
$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);
|
$response->assertStatus(404);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue