pdo = $pdo; $this->helper = new ResponAI_Helper($this->pdo); // Teruskan PDO ke helper } // Tambahan properti untuk whitelist & alias agar tidak undefined private $ALLOWED_COLUMNS = [ 'v_tiket_gangguan' => [ 'id','id_pelanggan','permasalahan','status','jenis_tiket','start_time' ], 'v_pelanggan' => [ 'akun','nama','status_koneksi','alamat','status','nama_paket','harga_paket', 'nomor_whatsapp' ], 'v_tagihan' => [ 'id','id_pelanggan','akun','nomor_whatsapp', 'total_tagihan','limit_pembayaran','status' ], 'rekening_bank' => [ 'id_rekening','nama_bank','no_rekening','pemilik' ], 'v_order_list' => [ 'nama','jenis_layanan','start_time','alamat','status','nomor_whatsapp' ], 'profile_paket' => [ 'jenis_profile','nama','harga' ], 'promo' => [ 'id_promo','nama_promo','deskripsi','tanggal_mulai','tanggal_akhir' ], 'setting_mikrotik' => [ 'nama','status' ], 'cs_contact' => [ 'id_contact','nama','nomor_wa','email','jabatan' ], 'teknisi' => [ 'id_teknisi','nama','nomor_wa','area' ], 'v_jumlah_transaksi_bulanan' => [ 'bulan','pemasukan','pengeluaran','saldo' ] ]; private $ALIASES = [ 'v_tiket_gangguan' => [ 'ticket'=>'id','id_tiket'=>'id', 'pelanggan_id'=>'id_pelanggan', 'problem'=>'permasalahan','issue'=>'permasalahan', 'jenis'=>'jenis_tiket','type'=>'jenis_tiket', 'mulai'=>'start_time','waktu'=>'start_time' ], 'v_pelanggan' => [ 'id'=>'akun','pppoe'=>'akun','username'=>'akun','customer_id'=>'akun','id_pelanggan'=>'akun', 'name'=>'nama','customer'=>'nama','pelanggan'=>'nama', 'paket'=>'nama_paket','package'=>'nama_paket','plan'=>'nama_paket','layanan'=>'nama_paket', 'harga'=>'harga_paket','biaya'=>'harga_paket', 'koneksi'=>'status_koneksi','status_layanan'=>'status_koneksi','status_internet'=>'status_koneksi', 'aktif'=>'status','nonaktif'=>'status','isolir'=>'status', 'alamat_pelanggan'=>'alamat','alamat_lengkap'=>'alamat' ], 'v_tagihan' => [ 'id'=>'id_tagihan','invoice'=>'id_tagihan', 'pppoe'=>'akun','username'=>'akun','customer_id'=>'akun', 'wa'=>'nomor_whatsapp','no_wa'=>'nomor_whatsapp','hp'=>'nomor_whatsapp', 'amount'=>'total_tagihan','tagihan'=>'total_tagihan','nominal'=>'total_tagihan','total'=>'total_tagihan', 'due'=>'limit_pembayaran','jatuh_tempo'=>'limit_pembayaran','tempo'=>'limit_pembayaran', 'status_tagihan'=>'status','pending'=>'status','lunas'=>'status','belum'=>'status' ], 'rekening_bank' => [ 'bank'=>'nama_bank','rekening'=>'no_rekening','account_number'=>'no_rekening','atas_nama'=>'pemilik' ], 'v_order_list' => [ 'customer'=>'nama','layanan'=>'jenis_layanan', 'tanggal'=>'start_time','mulai'=>'start_time', 'alamat_pemasangan'=>'alamat','lokasi'=>'alamat', 'wa'=>'nomor_whatsapp','nohp'=>'nomor_whatsapp' ], 'profile_paket' => [ 'nama_profile'=>'nama','jenis'=>'jenis_profile', 'harga_paket'=>'harga','biaya'=>'harga' ], 'promo' => [ 'promo_id'=>'id_promo','kode'=>'id_promo', 'nama_promo'=>'nama_promo','promo'=>'nama_promo', 'desc'=>'deskripsi','keterangan'=>'deskripsi', 'mulai'=>'tanggal_mulai','start'=>'tanggal_mulai', 'akhir'=>'tanggal_akhir','selesai'=>'tanggal_akhir' ], 'setting_mikrotik' => [ 'device'=>'nama','router'=>'nama', 'status_router'=>'status','aktif'=>'status','nonaktif'=>'status' ], 'cs_contact' => [ 'contact_id'=>'id_contact','id_cs'=>'id_contact', 'cs'=>'nama','customer_service'=>'nama','admin'=>'nama', 'wa'=>'nomor_wa','nohp'=>'nomor_wa', 'posisi'=>'jabatan','jabatan_cs'=>'jabatan' ], 'teknisi' => [ 'id'=>'id_teknisi','id_tech'=>'id_teknisi', 'nama_teknisi'=>'nama','technician'=>'nama', 'wa'=>'nomor_wa','nohp'=>'nomor_wa', 'daerah'=>'area','wilayah'=>'area' ], 'v_jumlah_transaksi_bulanan' => [ 'month'=>'bulan','periode'=>'bulan', 'income'=>'pemasukan','debit'=>'pemasukan', 'expense'=>'pengeluaran','credit'=>'pengeluaran', 'balance'=>'saldo','cash'=>'saldo' ] ]; private $DB_KEYWORDS = [ 'pelanggan' => ['nama','akun','id','username','status','paket','alamat','koneksi','harga'], 'tagihan' => ['tagihan','invoice','pembayaran','bayar','periode','tempo','tanggal','total','status'], 'paket' => ['paket','profile_paket','layanan','harga','jenis_profile','upgrade','downgrade'], 'promo' => ['promo','diskon','bonus','voucher','penawaran','potongan','gratis'], 'gangguan' => ['gangguan','trouble','tiket','keluhan','permasalahan','lemot','putus','down'], 'pemasangan'=> ['pemasangan','registrasi','order','survey','baru','langganan','daftar'], 'info_jaringan'=> ['jaringan','mikrotik','status','router','perangkat','down area','maintenance'], 'keuangan' => ['keuangan','saldo','pemasukan','pengeluaran','kas','laporan','transaksi','bulan'] ]; private function getTableMappingByTopic(string $topic): array { $mapping = [ 'gangguan' => [ 'tables' => [ 'v_tiket_gangguan' => [ 'columns' => ['id', 'id_pelanggan', 'permasalahan', 'status', 'jenis_tiket', 'start_time'], 'where' => [ 'id_pelanggan' => 'From Value: id_pelanggan_db', 'jenis_tiket' => 'Value List DB: Pelanggan, Jalur Utama, Lainnya' ] ], 'v_pelanggan' => [ 'columns' => ['akun', 'nama', 'status_koneksi', 'alamat', 'status'], 'where' => [ 'akun' => 'From Value: id_pelanggan', 'status_koneksi' => 'Value List DB: On, Off', 'status' => 'Value List DB: register, aktif, tidak aktif, sampah', 'nama' ] ] ] ], 'tagihan' => [ 'tables' => [ 'v_tagihan' => [ 'columns' => ['id', 'akun', 'nomor_whatsapp', 'verifikasi_tagihan', 'limit_pembayaran', 'status', 'transaction_link'], 'where' => [ 'akun' => 'From Value: id_pelanggan', 'limit_pembayaran' => 'Format Y-m-d', 'nomor_whatsapp', 'verifikasi_tagihan' => 'Sama dengan Jumlah Tagihan', ] ] // , // 'v_rekening_bank' => [ // 'columns' => ['nama_bank', 'no_rekening', 'pemilik'], // 'where' => [], // ] ] ], 'pemasangan' => [ 'tables' => [ 'v_order_list' => [ 'columns' => ['nama', 'jenis_layanan', 'start_time', 'alamat', 'status', 'nomor_whatsapp'], 'where' => [ 'nama' ] ], 'master_desa' => [ 'columns' => ['nama'], 'where' => [] ], 'v_master_topologi' => [ 'columns' => ['nama', 'titik_koordinat', 'sisa_port'], 'where' => [ 'id_data_server' ] ] ] ], 'paket' => [ 'tables' => [ 'profile_paket' => [ 'columns' => ['jenis_profile', 'nama', 'harga_paket'], 'where' => [] ] ] ], 'promo' => [ 'tables' => [ 'master_promo' => [ 'columns' => ['nama', 'deskripsi', 'tanggal_mulai', 'tanggal_akhir', 'gambar', 'status'], 'where' => [ 'status' ] ] ] ], 'info_jaringan' => [ 'tables' => [ 'setting_mikrotik' => [ 'columns' => ['nama', 'status'], 'where' => [ 'id_data_server' ] ] ] ], 'keuangan' => [ 'tables' => [ 'v_jumlah_transaksi_bulanan' => [ 'columns' => ['bulan', 'pemasukan', 'pengeluaran', 'saldo'], 'where' => [ 'id_data_server' => 'Wajib ambil dari id jika Owner', 'bulan' => 'Format Y-m-d' ] ] ] ], 'umum' => [ 'tables' => [] ] ]; return $mapping[$topic] ?? $mapping['umum']; } private function deteksi_topik(string $message): string { $rules = [ 'gangguan' => [ 'lambat', 'tidak konek', 'jaringan', 'trouble', 'gangguan', 'internet mati', 'lemot', 'putus', 'disconnect', 'down', 'error', 'tidak stabil', 'signal hilang', 'tidak bisa akses', 'modem rusak', 'router error', 'kabel putus', 'wifi putus' ], 'tagihan' => [ 'tagihan', 'bayar', 'invoice', 'pembayaran', 'rekening', 'saldo', 'jatuhtempo', 'denda', 'transfer', 'virtual account', 'lunas', 'belum bayar', 'auto debit', 'cicilan', 'konfirmasi pembayaran' ], 'pemasangan' => [ 'pasang', 'berlangganan', 'pemasangan', 'registrasi', 'daftar', 'masang', 'order', 'baru', 'paket baru', 'apply', 'subscribe', 'survey lokasi', 'coverage', 'jangkauan', 'titik koordinat' ], 'paket' => [ 'paket', 'upgrade', 'downgrade', 'speed', 'kecepatan', 'ganti paket', 'ubah paket', 'langganan paket', 'upgrade speed', 'add-on', 'kuota tambahan' ], 'promo' => [ 'promo', 'diskon', 'gratis', 'voucher', 'bonus', 'penawaran', 'paket murah', 'potongan harga' ], 'info_jaringan' => [ 'pemeliharaan', 'maintenance', 'perbaikan', 'upgrade jaringan', 'down area' ], 'cs' => [ 'hubungi admin', 'nomor wa', 'teknisi', 'kontak', 'call center', 'customer service' ], 'keuangan' => [ 'keuangan', 'laporan', 'saldo', 'kas', 'arus kas', 'transaksi', 'pemasukan', 'pengeluaran', 'neraca', 'pembukuan', 'rekap bulanan', 'omzet', 'omset', 'laba' ], 'umum' => [] ]; $message = strtolower($message); foreach ($rules as $topik => $keywords) { foreach ($keywords as $keyword) { if (strpos($message, $keyword) !== false) { return $topik; } } } return 'umum'; } private function simpan_sesi(string $sender, string $topik): void { $stmt = $this->pdo->prepare("INSERT INTO chat_session (nomor_whatsapp, topik_terakhir) VALUES (?, ?) ON DUPLICATE KEY UPDATE topik_terakhir = VALUES(topik_terakhir)"); $stmt->execute([$sender, $topik]); } private function ambil_sesi(string $sender): ?string { $stmt = $this->pdo->prepare("SELECT topik_terakhir FROM chat_session WHERE nomor_whatsapp = ?"); $stmt->execute([$sender]); $row = $stmt->fetch(PDO::FETCH_ASSOC); return $row['topik_terakhir'] ?? null; } private function simpan_log(string $sender, string $role, string $pesan, $id = null): void { $stmt = $this->pdo->prepare("INSERT INTO chat_log (nomor_whatsapp, role, pesan, id_data_server) VALUES (?, ?, ?, ?)"); $stmt->execute([$sender, $role, $pesan, $id]); } private function get_topik($id_servers, string $topik) { // Pastikan topik huruf pertama kapital tetap dicocokkan case-insensitive $topik_final = trim($topik); // jika $id_servers berupa string "3,4,5", ubah ke array if (is_string($id_servers)) { $id_servers = array_map('trim', explode(',', $id_servers)); } // jika $id_servers cuma satu angka, tetap bikin array if (!is_array($id_servers)) { $id_servers = [$id_servers]; } // buat placeholders sesuai jumlah ID $placeholders = implode(',', array_fill(0, count($id_servers), '?')); $stmt = $this->pdo->prepare(" SELECT metadata FROM chat_topik WHERE id_data_server IN ($placeholders) AND LOWER(topik) = LOWER(?) ORDER BY id DESC LIMIT 1 "); $stmt->execute(array_merge($id_servers, [$topik_final])); $result = $stmt->fetch(PDO::FETCH_ASSOC); return $result ? $result['metadata'] : null; } /** * Whitelist tabel+kolom berdasarkan ROLE (global), nanti masih akan dipersempit oleh TOPIK */ private function getAllowedMappingByRole(string $role): array { // Daftar izin setiap role. Owner = semua (gunakan '*' sebagai wildcard). $roleMapping = [ 'umum' => [ 'profile_paket' => ['jenis_profile', 'nama', 'harga'], 'promo' => ['id_promo', 'nama_promo', 'deskripsi', 'tanggal_mulai', 'tanggal_akhir'], 'v_order_list' => ['nama', 'jenis_layanan', 'start_time', 'alamat', 'status', 'nomor_whatsapp'], // 'master_desa' bisa ditambahkan jika ada di DB & ingin diekspos ], 'pelanggan' => [ 'profile_paket' => ['jenis_profile', 'nama', 'harga'], 'promo' => ['id_promo', 'nama_promo', 'deskripsi', 'tanggal_mulai', 'tanggal_akhir'], 'v_order_list' => ['nama', 'jenis_layanan', 'start_time', 'alamat', 'status', 'nomor_whatsapp'], 'v_pelanggan' => ['akun', 'nama', 'status_koneksi', 'alamat', 'status', 'nama_paket', 'harga_paket'], 'v_tagihan' => ['id', 'id_pelanggan', 'akun', 'nomor_whatsapp', 'total_tagihan', 'limit_pembayaran', 'status'], 'v_tiket_gangguan' => ['id', 'id_pelanggan', 'permasalahan', 'status', 'jenis_tiket', 'start_time'], 'rekening_bank' => ['id_rekening', 'nama_bank', 'no_rekening', 'pemilik'] // jika memang boleh dilihat pelanggan ], 'karyawan' => [ 'profile_paket' => ['jenis_profile', 'nama', 'harga'], 'promo' => ['id_promo', 'nama_promo', 'deskripsi', 'tanggal_mulai', 'tanggal_akhir'], 'v_order_list' => ['nama', 'jenis_layanan', 'start_time', 'alamat', 'status', 'nomor_whatsapp'], 'v_pelanggan' => ['akun', 'nama', 'status_koneksi', 'alamat', 'status', 'nama_paket', 'harga_paket'], 'v_tagihan' => ['id', 'id_pelanggan', 'akun', 'nomor_whatsapp', 'total_tagihan', 'limit_pembayaran', 'status'], 'v_tiket_gangguan' => ['id', 'id_pelanggan', 'permasalahan', 'status', 'jenis_tiket', 'start_time'], 'setting_mikrotik' => ['nama', 'status'], 'rekening_bank' => ['id_rekening', 'nama_bank', 'no_rekening', 'pemilik'] ], 'owner' => ['*'] // akses penuh ]; return $roleMapping[$role] ?? $roleMapping['umum']; } /** * Persempit mapping topik dengan role (tabel+kolom) */ private function narrowMappingByTopicAndRole(array $topicMapping, array $roleMapping): array { // Owner full pass if ($roleMapping === ['*']) { return $topicMapping; } $filtered = ['tables' => []]; foreach ($topicMapping['tables'] as $table => $cols) { if (!isset($roleMapping[$table])) { continue; // tabel ini tidak diizinkan role } $allowedCols = $roleMapping[$table]; $finalCols = array_values(array_intersect($cols, $allowedCols)); if (!empty($finalCols)) { $filtered['tables'][$table] = $finalCols; } } return $filtered; } /** * Format mapping (tabel+kolom) untuk dikirim ke AI Stage 1 (string JSON yang rapi) */ private function mappingToString(array $mapping): string { // Misal: {"v_tagihan":["id_tagihan","akun"],"promo":["id_promo","nama_promo"]} return json_encode($mapping['tables'], JSON_UNESCAPED_UNICODE); } /** * Set $this->ALLOWED_COLUMNS dari mapping final agar validator DB bisa pakai */ private function hydrateAllowedColumns(array $finalMapping): void { $this->ALLOWED_COLUMNS = []; foreach ($finalMapping['tables'] as $table => $cols) { $this->ALLOWED_COLUMNS[strtolower($table)] = array_map('strtolower', $cols); } } public function respon_chat(string $body, string $sender, string $apikey): array { $this->log_to_file($sender, "DEBUG", "=== Mulai respon_chat ==="); $this->log_to_file($sender, "DEBUG", "Body pesan: $body"); $this->log_to_file($sender, "DEBUG", "API Key: $apikey"); $idServers = $this->helper->get_id_server($apikey); $this->log_to_file($sender, "DEBUG", "ID Server hasil lookup: " . json_encode($idServers)); $this->log_to_file($sender, "Terima Pesan", "dari $sender: $body"); $roless = $this->helper->cek_role($sender); $this->log_to_file($sender, "DEBUG", "Role hasil cek: " . json_encode($roless)); $role = $roless['role']; $sesi_lama = $this->ambil_sesi($sender); $topik_baru = $this->deteksi_topik($body); $topik_final = $topik_baru ?: $sesi_lama ?: 'umum'; $this->log_to_file($sender, "DEBUG", "Topik lama: " . ($sesi_lama ?: 'null')); $this->log_to_file($sender, "DEBUG", "Topik baru: $topik_baru"); $this->log_to_file($sender, "DEBUG", "Topik final: $topik_final"); $this->simpan_sesi($sender, $topik_final); $roleInstructions = [ "umum" => "Role umum hanya boleh mengakses informasi publik: daftar paket internet (profile_paket) dan informasi wilayah (master_desa). Role ini tidak boleh mengakses data pelanggan (v_pelanggan) maupun data tagihan (v_tagihan).", "pelanggan" => "Role pelanggan mewarisi hak akses role umum. Selain itu, pelanggan boleh mengakses data tagihan miliknya sendiri di v_tagihan (difilter berdasarkan nomor_whatsapp). Jika pelanggan menanyakan informasi terkait dirinya (nama, alamat, server, paket, dll), ambil dari Database Statis, bukan langsung dari tabel dinamis.", "karyawan" => "Role karyawan mewarisi akses role pelanggan dan umum. Selain itu, karyawan boleh mengakses data pelanggan (v_pelanggan) dan data tagihan (v_tagihan) tanpa batasan nomor_whatsapp. Cocok untuk kebutuhan pelayanan atau pengecekan teknis.", "owner" => "Role owner memiliki akses penuh ke semua tabel (profile_paket, master_desa, v_pelanggan, v_tagihan) dan tambahan hak untuk menjalankan query agregasi, misalnya menghitung total omset, jumlah pelanggan, serta statistik penggunaan layanan." ]; $roleRule = $roleInstructions[$role] ?? "Default: hanya boleh menjawab pertanyaan umum tanpa akses database."; $waktu_batas = date('Y-m-d H:i:s', strtotime('-15 minutes')); $stmt = $this->pdo->prepare("SELECT role, pesan FROM chat_log WHERE nomor_whatsapp = ? AND waktu > ? ORDER BY id DESC LIMIT 5"); $stmt->execute([$sender, $waktu_batas]); $history = $stmt->fetchAll(PDO::FETCH_ASSOC); // --- PEMBATASAN TABEL+KOLOM UNTUK STAGE 1 (TOPIK + ROLE) --- $topicMapping = $this->getTableMappingByTopic($topik_final); // tabel/kolom sesuai topik $roleMapping = $this->getAllowedMappingByRole($role); // whitelisting global per role $finalMapping = $this->narrowMappingByTopicAndRole($topicMapping, $roleMapping); // gabungan topik+role $this->log_to_file($sender, "DEBUG", "TopicMapping: " . json_encode($topicMapping)); $this->log_to_file($sender, "DEBUG", "RoleMapping: " . json_encode($roleMapping)); $this->log_to_file($sender, "DEBUG", "FinalMapping: " . json_encode($finalMapping)); // Fallback: kalau hasil kosong, pakai topik 'umum' lalu persempit lagi if (empty($finalMapping['tables'])) { $topicMapping = $this->getTableMappingByTopic('umum'); $finalMapping = $this->narrowMappingByTopicAndRole($topicMapping, $roleMapping); } // Set allowed columns untuk validator query di tahap eksekusi $this->hydrateAllowedColumns($finalMapping); // String tabel yang boleh diakses untuk dimasukkan ke prompt Stage 1 $tableMappingString = $this->mappingToString($finalMapping); $this->log_to_file($sender, "DEBUG", "TableMapping string (Stage 1): $tableMappingString"); // ========== BLOK ROLE LAMA DIPERTAHANKAN (dibetulkan operatornya) ========== // Catatan: sekarang variabel $tableMapping diisi string JSON whitelist agar AI tidak ngarang. if ($role == 'umum') { $mapping = $this->getTableMappingByTopic($topik_final); $tableMapping = $tableMappingString; // hanya boleh pemasangan, paket, promo sesuai finalMapping } elseif ($role == 'pelanggan') { $mapping = $this->getTableMappingByTopic($topik_final); $tableMapping = $tableMappingString; // pemasangan, paket, promo, pelanggan, tagihan, gangguan (tergantung topik) } elseif ($role == 'karyawan') { $mapping = $this->getTableMappingByTopic($topik_final); $tableMapping = $tableMappingString; // + info_jaringan bila topiknya sesuai } elseif ($role == 'owner') { $mapping = $this->getTableMappingByTopic($topik_final); $tableMapping = $tableMappingString; // semua (karena owner di roleMapping = '*') } // ========================================================================== $userContext = json_encode($roless['data'], JSON_PRETTY_PRINT); $messages_stage1 = [ [ "role" => "system", "content" => "Tugas: ubah pertanyaan user menjadi instruksi query database dalam format JSON. Gunakan hanya tabel & kolom berikut (strict whitelist): $tableMapping 📌 Data user yang tersedia untuk WHERE: $userContext 📌 Aturan pembuatan WHERE: - Ikuti aturan WHERE di strict whitelist - Gunakan kolom dari data user di atas (contoh: nomor_whatsapp, akun, id_pelanggan). - Jangan menebak isi kolom. Jika field tidak ada di data user, kosongkan WHERE. - Jika pertanyaan terkait data pribadi, gunakan nomor_whatsapp dari data user. - Jika query umum (tanpa filter), kosongkan WHERE: {} 📌 Contoh format JSON: [ {\"action\": \"GET\", \"table\": \"v_pelanggan\", \"where\": {\"nomor_whatsapp\": \"{{sender}}\"}, \"columns\": [\"nama\", \"nama_paket\", \"status_koneksi\"]}, {\"action\": \"COUNT\", \"table\": \"v_tagihan\", \"where\": {\"status\": \"belum bayar\"}}, {\"action\": \"GET\", \"table\": \"profile_paket\", \"where\": {}, \"columns\": [\"nama\", \"jenis_profile\", \"harga\"]} ] 📌 Jika tidak ada query DB diperlukan: - Kembalikan JSON kosong: {} - atau {\"action\": \"NONE\"}." ] ]; // foreach (array_reverse($history) as $h) { // $messages_stage1[] = ["role" => $h['role'], "content" => $h['pesan']]; // } // $messages_stage1[] = ["role" => "user", "content" => $body]; file_put_contents(__DIR__ . "/result_stage1.log", json_encode($messages_stage1, JSON_PRETTY_PRINT) . "\n", FILE_APPEND); $payload_stage1 = [ "model" => "gpt-4.1-nano", "messages" => $messages_stage1, "temperature" => 0.3, "max_tokens" => 500 // Cukup untuk JSON ]; $this->log_to_file($sender, "DEBUG", "Payload Stage 1: " . json_encode($payload_stage1)); $reply_stage1 = $this->call_openai($payload_stage1); $this->log_to_file($sender, "Proses AI Tahap 1 (JSON)", $reply_stage1); $this->simpan_log($sender, "user", $body, $idServers); // Log pesan user $db_results = []; $needsDB = false; $replySanitized = $this->sanitize_json($reply_stage1); $this->log_to_file($sender, "DEBUG", "Reply Stage 1 (sanitized): $replySanitized"); // Coba decode JSON dari tahap 1 $jsonAI = json_decode($replySanitized, true); $this->log_to_file($sender, "DEBUG", "Parsed JSON Stage 1: " . json_encode($jsonAI)); if (json_last_error() === JSON_ERROR_NONE && is_array($jsonAI) && !empty($jsonAI)) { // Cek apakah ini array of objects atau single object $queries = isset($jsonAI[0]) ? $jsonAI : [$jsonAI]; // Filter out "NONE" action if it's the only one if (count($queries) === 1 && isset($queries[0]['action']) && strtoupper($queries[0]['action']) === 'NONE') { $needsDB = false; } else { $needsDB = true; foreach ($queries as $q) { $this->log_to_file($sender, "DEBUG", "Eksekusi DB Query: " . json_encode($q)); if (!isset($q['action'], $q['table'])) { $db_results[] = ["error" => "Format query tidak valid: " . json_encode($q)]; continue; } // Validasi tabel $table = strtolower(trim($q['table'])); if (!isset($this->ALLOWED_COLUMNS[$table])) { $db_results[] = ["error" => "Tabel tidak diizinkan atau tidak valid: {$q['table']}"]; continue; } // Validasi columns: jika tidak diset, izinkan semua kolom yang di-whitelist untuk tabel tsb if (isset($q['columns'])) { if (!is_array($q['columns'])) { $db_results[] = ["error" => "Field 'columns' harus array untuk tabel {$q['table']}"]; continue; } $requestedCols = array_map('strtolower', $q['columns']); $diff = array_diff($requestedCols, $this->ALLOWED_COLUMNS[$table]); if (!empty($diff)) { $db_results[] = ["error" => "Kolom tidak diizinkan pada tabel {$q['table']}: " . implode(', ', $diff)]; continue; } } else { // Isi default: semua kolom yang diizinkan $q['columns'] = $this->ALLOWED_COLUMNS[$table]; } // Mapping alias untuk where (opsional) if (isset($q['where']) && is_array($q['where'])) { $q['where'] = $this->map_aliases($table, $q['where']); // Validasi key 'where' agar hanya kolom yang diizinkan foreach ($q['where'] as $k => $v) { if (!in_array(strtolower($k), $this->ALLOWED_COLUMNS[$table], true)) { $db_results[] = ["error" => "WHERE mengandung kolom tidak diizinkan pada {$q['table']}: {$k}"]; continue 2; // skip query ini } } } $this->log_to_file($sender, "Cek DB Query", json_encode($q, JSON_UNESCAPED_UNICODE)); // Eksekusi aman via helper $dbResult = $this->helper->process_db_action($q, $this->ALLOWED_COLUMNS, $idServers); $db_results[] = ["query" => $q, "result" => $dbResult]; $this->log_to_file($sender, "DEBUG", "Hasil DB Query: " . json_encode($dbResult)); } } } else { // Jika bukan JSON valid atau JSON kosong, berarti tidak perlu query DB $needsDB = false; } $this->log_to_file($sender, "DEBUG", "ID Servers sebelum get_topik: " . json_encode($idServers)); $this->log_to_file($sender, "DEBUG", "Topik final sebelum get_topik: " . $topik_final); $data_topik = $this->get_topik($idServers, $topik_final); $this->log_to_file($sender, "DEBUG", "Hasil get_topik: " . json_encode($data_topik)); // --- Proses AI Tahap 2: Menghasilkan respons natural --- $messages_stage2 = [ ["role" => "system", "content" => "Kamu adalah AI Customer Service yang membantu menjawab pertanyaan pelanggan. Berikan respons yang natural, ramah, dan informatif berdasarkan pesan pengguna dan hasil query database (jika ada). Hal yang WAJIB DIPATUHI: - selalu manggil 'Kak' Contoh Maaf Kak, Baik Kak - sesekali sapa nama nya jika ada nama di hasil query - selalu menggunakan kata 'kami' jangan menggunakan kata saya,aku,gue,gua - Jika informasi tidak tersedia, katakan dengan sopan akan kami cek terlebih dahulu dan wajib membuat kalimat di akhir '#manualcheck#' - Fokus hanya memberikan informasi yang tersedia dari database dan pengetahuan yang kamu miliki. - Jangan berikan saran apapun kecuali topik nya umum. Aturan akses database untuk role '$role': $roleRule Topik percakapan saat ini: $topik_final Data dan atauran atau langkah-langkah: $data_topik "] ]; foreach (array_reverse($history) as $h) { $messages_stage2[] = ["role" => $h['role'], "content" => $h['pesan']]; } $messages_stage2[] = ["role" => "user", "content" => $body]; // Gabungkan data statis dan query database ke satu entry untuk AI $databaseInfo = []; if (!empty($roless['data'])) { $databaseInfo[] = "Database Statis: " . json_encode($roless['data'], JSON_UNESCAPED_UNICODE); } else { $databaseInfo[] = "Database Statis: Tidak ada query database statis yang dilakukan atau hasil query kosong."; } if (!empty($needsDB) && !empty($db_results)) { $databaseInfo[] = "Database Dinamis: " . json_encode($db_results, JSON_UNESCAPED_UNICODE); } else { $databaseInfo[] = "Database Dinamis: Tidak ada query database yang dilakukan atau hasil query kosong."; } // Masukkan ke messages_stage2 sebagai satu sistem content $messages_stage2[] = [ "role" => "system", "content" => implode("\n", $databaseInfo) ]; $maxTokens = strlen($body) < 30 ? 100 : (strlen($body) < 100 ? 200 : 300); file_put_contents(__DIR__ . "/result_stage2.log", json_encode($messages_stage2, JSON_PRETTY_PRINT) . "\n", FILE_APPEND); $payload_stage2 = [ "model" => "gpt-4.1-nano", "messages" => $messages_stage2, "temperature" => 0.7, // Suhu lebih tinggi untuk respons natural "max_tokens" => $maxTokens ]; $this->log_to_file($sender, "DEBUG", "Payload Stage 2: " . json_encode($payload_stage2)); $reply_final = $this->call_openai($payload_stage2); $this->log_to_file($sender, "Proses AI Tahap 2 (Natural Response)", $reply_final); // Log hasil akhir ke DB $this->simpan_log($sender, "assistant", $reply_final, $idServers); $this->log_to_file($sender, "DEBUG", "=== Selesai respon_chat ==="); return $this->make_response($sender, $reply_final); } private function should_query_db(string $message): bool { // Fungsi ini tidak lagi digunakan secara langsung untuk decision making utama, // karena keputusan query DB sekarang ada di AI Tahap 1. // Namun, tetap dipertahankan jika ada kebutuhan lain. $message = strtolower($message); foreach ($this->DB_KEYWORDS as $group => $keywords) { foreach ($keywords as $k) { if (strpos($message, $k) !== false) { return true; } } } return false; } private function call_openai(array $payload): string { $ch = curl_init("https://api.openai.com/v1/chat/completions"); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_HTTPHEADER, [ "Content-Type: application/json", "Authorization: " . "Bearer " . OPENAI_API_KEY ]); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload)); $response = curl_exec($ch); if (curl_errno($ch)) { $error_msg = curl_error($ch); curl_close($ch); return "⚠️ Gagal menghubungi AI: $error_msg"; } curl_close($ch); $data = json_decode($response, true); if (json_last_error() !== JSON_ERROR_NONE) { return "⚠️ Respon AI tidak valid JSON."; } return $data['choices'][0]['message']['content'] ?? "⚠️ Tidak ada respon dari AI."; } private function sanitize_json(string $reply): string { $clean = trim($reply); // Menghapus markdown code block jika ada if (preg_match('/^```(?:json)?\s*(.*?)\s*```$/s', $clean, $matches)) { $clean = trim($matches[1]); } return $clean; } private function map_aliases(string $table, array $data): array { $result = []; foreach ($data as $key => $val) { $keyLower = strtolower($key); if (isset($this->ALIASES[$table][$keyLower])) { $result[$this->ALIASES[$table][$keyLower]] = $val; } else { $result[$keyLower] = $val; } } return $result; } private function make_response(string $sender, string $text): array { return [ "id" => 78, "nomor" => $sender, "respon" => trim($text) ]; } public function get_replied(string $sender): array { $stmt = $this->pdo->prepare("SELECT update_time, replied FROM chat_replied WHERE nomor_whatsapp = ?"); $stmt->execute([$sender]); $row = $stmt->fetch(PDO::FETCH_ASSOC); return $row ?: []; } public function simpan_replied(string $sender, string $replied): void { $update_time = date('Y-m-d H:i:s', strtotime('+5 minutes')); $stmt = $this->pdo->prepare(" INSERT INTO chat_replied (nomor_whatsapp, update_time, replied) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE update_time = VALUES(update_time), replied = VALUES(replied) "); $stmt->execute([$sender, $update_time, $replied]); } private function write_flow_log($step, $detail) { // Fungsi ini tidak lagi digunakan secara langsung, diganti dengan log_to_file // $sender = $this->currentSender !== null ? $this->currentSender : '-'; // $this->log_to_file($sender, $step, $detail); } private function log_to_file(string $sender, string $step, $data): void { $logFile = __DIR__ . "/percakapan_log.log"; $time = date('Y-m-d H:i:s'); $entry = "[$time][$sender][$step] " . (is_array($data) ? json_encode($data, JSON_UNESCAPED_UNICODE) : $data) . PHP_EOL; file_put_contents($logFile, $entry, FILE_APPEND); } }