445 lines
16 KiB
PHP
445 lines
16 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers;
|
|
|
|
use App\Services\GeminiService;
|
|
use App\Models\ChatHistory;
|
|
use App\Models\Recommendation;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Illuminate\Support\Str;
|
|
|
|
class ChatbotController extends Controller
|
|
{
|
|
protected $geminiService;
|
|
|
|
public function __construct(GeminiService $geminiService)
|
|
{
|
|
$this->middleware('auth');
|
|
$this->geminiService = $geminiService;
|
|
}
|
|
|
|
/**
|
|
* Tampilkan halaman chatbot.
|
|
* Jika ada ?session=xxx, lanjutkan sesi lama (pakai rekomendasi saat itu).
|
|
* Jika ada ?rec=xxx, buat sesi baru terkait rekomendasi tertentu.
|
|
* Jika tidak ada parameter, buat sesi baru dengan rekomendasi terbaru.
|
|
*/
|
|
public function index(Request $request)
|
|
{
|
|
$user = Auth::user();
|
|
$sessionId = $request->query('session');
|
|
$recId = $request->query('rec');
|
|
$isNew = $request->query('new'); // Deteksi jika langsung buka chatbot tanpa rekomendasi
|
|
$previousMessages = [];
|
|
$recommendationId = null;
|
|
|
|
// Jika ?new=1, abaikan session dan rekomendasi lama - buat fresh session
|
|
if ($isNew) {
|
|
$sessionId = Str::uuid()->toString();
|
|
$recommendationId = null;
|
|
$previousMessages = [];
|
|
// Clear session lama
|
|
session()->forget('recomendation_data');
|
|
} else if ($sessionId) {
|
|
// Lanjutkan sesi lama — ambil semua chat dari sesi ini
|
|
$chats = ChatHistory::where('user_id', $user->id)
|
|
->where('id_sesi', $sessionId)
|
|
->orderBy('created_at', 'asc')
|
|
->get();
|
|
|
|
if ($chats->isEmpty()) {
|
|
// Session tidak valid, buat baru
|
|
$sessionId = Str::uuid()->toString();
|
|
} else {
|
|
// Ambil recommendation_id dari sesi ini
|
|
$recommendationId = $chats->first()->recommendation_id;
|
|
|
|
foreach ($chats as $chat) {
|
|
$previousMessages[] = [
|
|
'role' => 'user',
|
|
'text' => $chat->prompt,
|
|
];
|
|
$previousMessages[] = [
|
|
'role' => 'ai',
|
|
'text' => $this->stripMarkdown($chat->response),
|
|
];
|
|
}
|
|
}
|
|
} else {
|
|
// Sesi baru
|
|
$sessionId = Str::uuid()->toString();
|
|
}
|
|
|
|
// Tentukan recommendation_id (kecuali sudah diset oleh ?new=1 atau session lama):
|
|
if (!$recommendationId && $recId) {
|
|
$rec = Recommendation::where('id', $recId)
|
|
->where('user_id', $user->id)
|
|
->first();
|
|
$recommendationId = $rec ? $rec->id : null;
|
|
}
|
|
|
|
// Load rekomendasi terbaru jika tidak ada kondisi di atas
|
|
if (!$recommendationId && !$isNew) {
|
|
$latestRec = Recommendation::where('user_id', $user->id)->latest()->first();
|
|
$recommendationId = $latestRec ? $latestRec->id : null;
|
|
}
|
|
|
|
// Ambil konteks rekomendasi berdasarkan ID spesifik
|
|
$recentRecommendation = $this->getRecommendationContext($user, $recommendationId, $isNew);
|
|
|
|
return view('chatbot.index', [
|
|
'recommendation' => $recentRecommendation,
|
|
'sessionId' => $sessionId,
|
|
'previousMessages' => $previousMessages,
|
|
'recommendationId' => $recommendationId,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Kirim pesan dan terima respons AI
|
|
*/
|
|
public function send(Request $request)
|
|
{
|
|
$request->validate([
|
|
'message' => 'required|string|max:1000',
|
|
'sessionId' => 'required|string|max:36',
|
|
'recommendationId' => 'nullable|integer',
|
|
'chatHistory' => 'nullable|array|max:20',
|
|
'chatHistory.*.role' => 'required|string|in:user,ai',
|
|
'chatHistory.*.text' => 'required|string|max:2000',
|
|
]);
|
|
|
|
$message = $request->input('message');
|
|
$sessionId = $request->input('sessionId');
|
|
$recommendationId = $request->input('recommendationId');
|
|
$chatHistory = $request->input('chatHistory', []);
|
|
$user = Auth::user();
|
|
|
|
// Ambil konteks rekomendasi berdasarkan ID spesifik sesi ini
|
|
$recentRecommendation = $this->getRecommendationContext($user, $recommendationId) ?? [];
|
|
|
|
// Siapkan context untuk Gemini
|
|
$context = [
|
|
'recommendation' => $recentRecommendation['jurusan'] ?? null,
|
|
'score' => isset($recentRecommendation['skor']) ? number_format(($recentRecommendation['skor'] > 1 ? $recentRecommendation['skor'] : $recentRecommendation['skor'] * 100), 1) : null,
|
|
'top3' => $recentRecommendation['top3'] ?? [],
|
|
'intent' => $this->detectIntent($message),
|
|
'profile' => [
|
|
'nama' => $user->name,
|
|
'kelompok' => $user->kelompok_asal ?? null,
|
|
'nilai' => $recentRecommendation['nilai'] ?? null,
|
|
'rata_rata' => $recentRecommendation['rata_rata'] ?? null,
|
|
'minat' => $recentRecommendation['minat'] ?? null,
|
|
'pref' => $recentRecommendation['pref_studi'] ?? null,
|
|
'cita_cita' => $recentRecommendation['cita_cita'] ?? null,
|
|
'prestasi' => $recentRecommendation['prestasi'] ?? null,
|
|
]
|
|
];
|
|
|
|
// Cari pertanyaan serupa dari riwayat semua siswa
|
|
$similarQA = $this->findSimilarQuestions($message, $user->id);
|
|
if (!empty($similarQA)) {
|
|
$context['similar_qa'] = $similarQA;
|
|
}
|
|
|
|
// Panggil Gemini API dengan conversation history
|
|
$response = $this->geminiService->chat($message, $context, $chatHistory);
|
|
|
|
// Normalisasi respons agar error tetap memiliki pesan yang konsisten.
|
|
$isSuccess = (bool) ($response['success'] ?? false);
|
|
$errorCode = (string) ($response['error_code'] ?? 'CHAT_SERVICE_ERROR');
|
|
$responseMessage = trim((string) ($response['message'] ?? ''));
|
|
|
|
if ($responseMessage === '') {
|
|
$responseMessage = $isSuccess
|
|
? 'Respons berhasil diproses.'
|
|
: 'Maaf, layanan chatbot sedang mengalami gangguan. Silakan coba kembali beberapa saat lagi.';
|
|
}
|
|
|
|
if (!$isSuccess) {
|
|
$responseMessage = "[ERROR:{$errorCode}] {$responseMessage}";
|
|
}
|
|
|
|
$response['message'] = $responseMessage;
|
|
$response['error_code'] = $isSuccess ? null : $errorCode;
|
|
|
|
// Simpan chat ke database dengan session_id dan recommendation_id
|
|
if ($user) {
|
|
ChatHistory::create([
|
|
'user_id' => $user->id,
|
|
'id_sesi' => $sessionId,
|
|
'id_rekomendasi' => $recommendationId,
|
|
'pertanyaan' => $message,
|
|
'jawaban' => $responseMessage,
|
|
]);
|
|
}
|
|
|
|
return response()->json($response);
|
|
}
|
|
|
|
/**
|
|
* Tampilkan history chat, dikelompokkan per sesi
|
|
*/
|
|
public function historyChat()
|
|
{
|
|
$user = Auth::user();
|
|
|
|
// Ambil semua chat user dengan relasi recommendation
|
|
$chatHistories = ChatHistory::where('user_id', $user->id)
|
|
->with('recommendation')
|
|
->orderBy('created_at', 'desc')
|
|
->get();
|
|
|
|
// Kelompokkan per id_sesi
|
|
$sessions = $chatHistories->groupBy('id_sesi')->map(function ($chats, $sessionId) {
|
|
$first = $chats->last(); // oldest in group (karena desc)
|
|
$last = $chats->first(); // newest in group
|
|
$rec = $first->recommendation;
|
|
$recInfo = null;
|
|
if ($rec) {
|
|
// Safely decode hasil_rekomendasi
|
|
$hasil = [];
|
|
if (!empty($rec->hasil_rekomendasi)) {
|
|
$hasil = is_array($rec->hasil_rekomendasi)
|
|
? $rec->hasil_rekomendasi
|
|
: json_decode($rec->hasil_rekomendasi, true);
|
|
|
|
// Validate hasil is array
|
|
if (!is_array($hasil)) {
|
|
$hasil = [];
|
|
}
|
|
}
|
|
|
|
$topJurusan = $hasil[0] ?? null;
|
|
$recInfo = [
|
|
'id' => $rec->id,
|
|
'jurusan' => is_array($topJurusan) ? ($topJurusan['jurusan'] ?? '-') : '-',
|
|
'skor' => is_array($topJurusan) ? ($topJurusan['skor'] ?? 0) : 0,
|
|
'tanggal' => $rec->created_at,
|
|
];
|
|
}
|
|
return [
|
|
'session_id' => $sessionId,
|
|
'chats' => $chats->reverse()->values(),
|
|
'first_message' => Str::limit($first->prompt, 80),
|
|
'message_count' => $chats->count(),
|
|
'started_at' => $first->created_at,
|
|
'last_at' => $last->created_at,
|
|
'recommendation' => $recInfo,
|
|
];
|
|
});
|
|
|
|
return view('history.chat', compact('sessions'));
|
|
}
|
|
|
|
/**
|
|
* Ambil konteks rekomendasi berdasarkan ID spesifik.
|
|
* Jika ID tidak ada, coba dari session, lalu dari DB (terbaru).
|
|
*/
|
|
private function getRecommendationContext($user, $recommendationId = null, $isNew = false)
|
|
{
|
|
// Jika ada recommendation_id spesifik, ambil langsung dari DB dan JANGAN fallback
|
|
if ($recommendationId) {
|
|
$lastRec = Recommendation::where('id', $recommendationId)
|
|
->where('user_id', $user->id)
|
|
->first();
|
|
|
|
if (!$lastRec) {
|
|
// Jika rec ID tidak ditemukan, jangan fallback - return null
|
|
return null;
|
|
}
|
|
} else if ($isNew) {
|
|
// Jika ?new=1, jangan load apapun dari session atau DB
|
|
return null;
|
|
} else {
|
|
// Fallback: dari session (saat baru selesai rekomendasi)
|
|
$sessionData = session('recomendation_data', null);
|
|
if ($sessionData) {
|
|
return $sessionData;
|
|
}
|
|
|
|
// Fallback: rekomendasi terbaru dari DB
|
|
$lastRec = Recommendation::where('user_id', $user->id)
|
|
->latest()
|
|
->first();
|
|
|
|
if (!$lastRec) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Safely decode hasil_rekomendasi
|
|
$hasil = [];
|
|
if (!empty($lastRec->hasil_rekomendasi)) {
|
|
$hasil = is_array($lastRec->hasil_rekomendasi)
|
|
? $lastRec->hasil_rekomendasi
|
|
: json_decode($lastRec->hasil_rekomendasi, true);
|
|
|
|
// Validate hasil is array
|
|
if (!is_array($hasil)) {
|
|
$hasil = [];
|
|
}
|
|
}
|
|
|
|
$topJurusan = $hasil[0] ?? null;
|
|
$top3 = array_slice($hasil ?? [], 0, 3);
|
|
|
|
// Hitung rata-rata dari kolom nilai dengan safe access
|
|
$nilaiCols = ['mtk', 'fisika', 'kimia', 'biologi', 'ekonomi', 'geografi', 'sosiologi', 'sejarah'];
|
|
$validVals = [];
|
|
foreach ($nilaiCols as $col) {
|
|
$val = $lastRec->getAttribute($col);
|
|
if ($val !== null && is_numeric($val)) {
|
|
$validVals[] = $val;
|
|
}
|
|
}
|
|
$rataRata = count($validVals) > 0 ? round(array_sum($validVals) / count($validVals), 1) : null;
|
|
|
|
// Kategorisasi nilai
|
|
$katNilai = 'Rendah';
|
|
if ($rataRata !== null) {
|
|
if ($rataRata >= 85) {
|
|
$katNilai = 'Tinggi';
|
|
} elseif ($rataRata >= 70) {
|
|
$katNilai = 'Sedang';
|
|
}
|
|
}
|
|
|
|
return [
|
|
'jurusan' => $topJurusan['jurusan'] ?? null,
|
|
'skor' => $topJurusan['skor'] ?? null,
|
|
'detail' => is_array($topJurusan['detail'] ?? null) ? $topJurusan['detail'] : [],
|
|
'nilai' => $katNilai,
|
|
'rata_rata' => $rataRata,
|
|
'minat' => $lastRec->minat,
|
|
'pref_studi' => $lastRec->preferensi_studi,
|
|
'cita_cita' => $lastRec->cita_cita,
|
|
'prestasi' => $lastRec->prestasi,
|
|
'top3' => array_map(fn($r) => [
|
|
'jurusan' => is_array($r) ? ($r['jurusan'] ?? '') : '',
|
|
'skor' => is_array($r) ? ($r['skor'] ?? 0) : 0,
|
|
], $top3),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Cari pertanyaan serupa dari riwayat chat semua user
|
|
* Menggunakan keyword matching sederhana
|
|
*/
|
|
private function findSimilarQuestions(string $message, int $currentUserId): array
|
|
{
|
|
$messageLower = strtolower($message);
|
|
|
|
// Ekstrak kata kunci penting (min 4 huruf, bukan stopwords)
|
|
$stopwords = ['yang', 'dari', 'untuk', 'dengan', 'dalam', 'pada', 'akan', 'bisa', 'juga', 'saya', 'kamu', 'anda', 'tidak', 'sudah', 'belum', 'mau', 'ingin', 'apa', 'bagaimana', 'kenapa', 'mengapa', 'apakah', 'tolong', 'dong', 'aku', 'ini', 'itu'];
|
|
|
|
$words = preg_split('/[\s,;.!?\-\/]+/', $messageLower);
|
|
$keywords = array_filter($words, function ($w) use ($stopwords) {
|
|
return strlen($w) >= 4 && !in_array($w, $stopwords);
|
|
});
|
|
|
|
if (empty($keywords)) {
|
|
return [];
|
|
}
|
|
|
|
// Cari chat history yang mengandung kata kunci serupa
|
|
$query = ChatHistory::select('pertanyaan', 'jawaban', 'created_at')
|
|
->where('user_id', $currentUserId);
|
|
|
|
$query->where(function ($q) use ($keywords) {
|
|
foreach ($keywords as $keyword) {
|
|
$q->orWhere('pertanyaan', 'like', "%{$keyword}%");
|
|
}
|
|
});
|
|
|
|
$candidates = $query->orderBy('created_at', 'desc')
|
|
->limit(20)
|
|
->get();
|
|
|
|
if ($candidates->isEmpty()) {
|
|
return [];
|
|
}
|
|
|
|
// Scoring: hitung berapa keyword yang cocok
|
|
$scored = [];
|
|
foreach ($candidates as $chat) {
|
|
$promptLower = strtolower($chat->pertanyaan);
|
|
$matchCount = 0;
|
|
foreach ($keywords as $kw) {
|
|
if (stripos($promptLower, $kw) !== false) {
|
|
$matchCount++;
|
|
}
|
|
}
|
|
$ratio = $matchCount / count($keywords);
|
|
if ($ratio >= 0.4) { // minimal 40% keyword cocok
|
|
$scored[] = [
|
|
'prompt' => $chat->pertanyaan,
|
|
'response' => Str::limit($chat->jawaban, 300),
|
|
'score' => $ratio,
|
|
];
|
|
}
|
|
}
|
|
|
|
// Sort by score desc, ambil top 3
|
|
usort($scored, fn($a, $b) => $b['score'] <=> $a['score']);
|
|
return array_slice($scored, 0, 3);
|
|
}
|
|
|
|
/**
|
|
* Strip markdown formatting
|
|
*/
|
|
private function stripMarkdown(string $text): string
|
|
{
|
|
$text = preg_replace('/\*\*(.*?)\*\*/s', '$1', $text);
|
|
$text = preg_replace('/\*(.*?)\*/s', '$1', $text);
|
|
$text = preg_replace('/^#{1,6}\s+/m', '', $text);
|
|
$text = preg_replace('/`(.*?)`/s', '$1', $text);
|
|
return $text;
|
|
}
|
|
|
|
private function detectIntent(string $message): string
|
|
{
|
|
$message = strtolower($message);
|
|
|
|
if (
|
|
str_contains($message, 'banding') ||
|
|
str_contains($message, 'beda') ||
|
|
str_contains($message, 'vs') ||
|
|
str_contains($message, 'dibanding')
|
|
) {
|
|
return 'compare_majors';
|
|
}
|
|
|
|
if (
|
|
str_contains($message, 'jelaskan semua') ||
|
|
str_contains($message, 'semua jurusan')
|
|
) {
|
|
return 'explain_all_majors';
|
|
}
|
|
|
|
if (
|
|
str_contains($message, 'lanjut') ||
|
|
str_contains($message, 'yang tadi') ||
|
|
str_contains($message, 'yang sebelumnya') ||
|
|
str_contains($message, 'maksudnya')
|
|
) {
|
|
return 'follow_up';
|
|
}
|
|
|
|
if (str_contains($message, 'kenapa') || str_contains($message, 'mengapa')) {
|
|
return 'ask_reason';
|
|
}
|
|
|
|
if (
|
|
str_contains($message, 'prospek') ||
|
|
str_contains($message, 'karir') ||
|
|
str_contains($message, 'kerja')
|
|
) {
|
|
return 'ask_career';
|
|
}
|
|
|
|
return 'general';
|
|
}
|
|
}
|