Files
manjapro_v6/whapi_webhook/ai/ResponAI.php
T
2025-10-07 21:01:15 +07:00

821 lines
37 KiB
PHP

<?php
include_once __DIR__ . "/ResponAI_Helper.php";
class ResponAI {
private $pdo;
private $helper;
public function __construct(PDO $pdo) { // Terima PDO di sini juga
$this->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:
1. Selalu panggil pengguna dengan kata 'Kak' di setiap jawaban.
- Jika ada nama pada hasil query, sesekali sapa juga dengan menyebut nama.
2. Gunakan kata 'kami' untuk menjawab, jangan pernah gunakan kata saya, aku, gue, gua.
3. Hanya berikan informasi yang tersedia dari database atau pengetahuan yang kamu miliki.
4. Jangan menambahkan saran atau pendapat pribadi, kecuali jika topiknya umum.
5. Jika informasi tidak tersedia / tidak ada data:
- Sampaikan dengan sopan bahwa akan kami cek terlebih dahulu.
- WAJIB tambahkan teks #manualcheck# di akhir jawaban.
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);
}
}