Improve Gemini chat flow, add Python backend gateway, and stabilize tests

This commit is contained in:
KakaPatria 2026-04-21 02:22:19 +07:00
parent fcac0ac627
commit 3f0ce730a4
15 changed files with 1127 additions and 17 deletions

View File

@ -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=

View File

@ -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.

View File

@ -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.

View File

@ -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();

View File

@ -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;

View File

@ -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",

304
composer.lock generated
View File

@ -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",

View File

@ -33,6 +33,8 @@
'gemini' => [
'api_key' => env('GEMINI_API_KEY'),
'backend_url' => env('GEMINI_BACKEND_URL'),
'backend_token' => env('GEMINI_BACKEND_TOKEN'),
],
];

View File

@ -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');
});
}
};

View File

@ -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'");
}
}
};

View File

@ -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)

View File

@ -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."
}
]
}

View File

@ -0,0 +1,3 @@
flask==3.0.3
requests==2.32.3
python-dotenv==1.0.1

View File

@ -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();

View File

@ -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);
}
}