diff --git a/admin/DashboardController.php b/admin/DashboardController.php index 4fd5d5e..31b1de7 100644 --- a/admin/DashboardController.php +++ b/admin/DashboardController.php @@ -5691,4 +5691,114 @@ HTML; } exit; } + + public static function aiLogs(): void + { + SessionAuth::require(); + $page = max(1, (int)($_GET['page'] ?? 1)); + $limit = 50; + $offset = ($page - 1) * $limit; + + $provider = $_GET['provider'] ?? ''; + $company = (int)($_GET['company'] ?? 0); + + $where = []; + $params = []; + if ($provider !== '') { $where[] = 'provider = ?'; $params[] = $provider; } + if ($company > 0) { $where[] = 'company_id = ?'; $params[] = $company; } + $wSql = $where ? 'WHERE ' . implode(' AND ', $where) : ''; + + $total = (int)db()->prepare("SELECT COUNT(*) FROM ai_logs $wSql")->execute($params) ? db()->prepare("SELECT COUNT(*) FROM ai_logs $wSql")->execute($params) && 0 : 0; + $stmt = db()->prepare("SELECT COUNT(*) FROM ai_logs $wSql"); + $stmt->execute($params); + $total = (int)$stmt->fetchColumn(); + + $stmt2 = db()->prepare("SELECT * FROM ai_logs $wSql ORDER BY id DESC LIMIT $limit OFFSET $offset"); + $stmt2->execute($params); + $rows = $stmt2->fetchAll(\PDO::FETCH_ASSOC); + + $companies = db()->query("SELECT id, name FROM companies ORDER BY name")->fetchAll(\PDO::FETCH_ASSOC); + $companyMap = []; + foreach ($companies as $c) $companyMap[(int)$c['id']] = self::h($c['name']); + + $pages = (int)ceil($total / $limit); + + $companyOptions = ''; + foreach ($companies as $c) { + $sel = ($company === (int)$c['id']) ? ' selected' : ''; + $companyOptions .= ''; + } + + $providerOptions = ''; + foreach (['', 'openai', 'gemini', 'claude'] as $p) { + $label = $p === '' ? 'Todos' : ucfirst($p); + $sel = $provider === $p ? ' selected' : ''; + $providerOptions .= ""; + } + + $rows_html = ''; + foreach ($rows as $r) { + $cid = (int)($r['company_id'] ?? 0); + $cname = $companyMap[$cid] ?? 'β€”'; + $badge = match($r['provider'] ?? '') { + 'openai' => 'background:#10a37f;color:#fff', + 'gemini' => 'background:#4285f4;color:#fff', + 'claude' => 'background:#d97706;color:#fff', + default => 'background:#6b7280;color:#fff', + }; + $tok = ($r['tokens_in'] ?? null) !== null + ? self::h($r['tokens_in']) . '+' . self::h($r['tokens_out']) + : 'β€”'; + $rows_html .= '' + . '' . self::h(substr($r['created_at'] ?? '', 0, 16)) . '' + . '' . $cname . '' + . '' . self::h($r['phone_number'] ?? '') . '' + . '' . self::h($r['provider']) . '' + . '' . self::h($r['model'] ?? '') . '' + . '' . self::h($r['call_type'] ?? '') . '' + . '' . self::h(mb_substr($r['input_preview'] ?? '', 0, 60)) . '' + . '' . self::h(mb_substr($r['output_preview'] ?? '', 0, 60)) . '' + . '' . $tok . '' + . '' . number_format((int)($r['duration_ms'] ?? 0)) . 'ms' + . ''; + } + + $pager = ''; + for ($i = 1; $i <= $pages; $i++) { + $active = $i === $page ? ' style="font-weight:bold"' : ''; + $url = '/admin/ai-logs?page=' . $i . ($provider ? '&provider=' . urlencode($provider) : '') . ($company ? '&company=' . $company : ''); + $pager .= "{$i} "; + } + + echo Layout::page('IA Logs', 'ai-logs', ' + +
+
+

πŸ€– IA Logs (' . number_format($total) . ' registros)

+
+
+ + +
+
+ + + + + + ' . ($rows_html ?: '') . ' +
FechaEmpresaTelΓ©fonoProveedorModeloTipoEntradaSalidaTokensDuraciΓ³n
Sin registros
+
+ ' . ($pages > 1 ? '
' . $pager . '
' : '') . ' +
+ '); + } } diff --git a/admin/Layout.php b/admin/Layout.php index 5ba64ea..a0e1b24 100644 --- a/admin/Layout.php +++ b/admin/Layout.php @@ -53,6 +53,7 @@ class Layout ['bot-config', '/admin/bot-config', 'bot-config', 'Bot'], ['conv-flow', '/admin/conversation-flow', 'conv-flow', 'Flujos'], ['settings', '/admin/settings', 'settings', 'Ajustes'], + ['ai-logs', '/admin/ai-logs', 'live', 'IA Logs'], ], ]; diff --git a/public/index.php b/public/index.php index eec0def..bbba291 100644 --- a/public/index.php +++ b/public/index.php @@ -37,6 +37,7 @@ require_once __DIR__ . '/../services/OutboundWorker.php'; require_once __DIR__ . '/../services/ErpSync.php'; require_once __DIR__ . '/../services/ConversationContext.php'; require_once __DIR__ . '/../services/NormalBot.php'; +require_once __DIR__ . '/../services/AiLogger.php'; require_once __DIR__ . '/../services/AiBot.php'; require_once __DIR__ . '/../services/MediaTranscriber.php'; require_once __DIR__ . '/../services/BotRouter.php'; @@ -674,6 +675,7 @@ $routes = [ // ─── Admin: configuraciΓ³n general ────────────────────────────────────── ['GET', '/admin/settings', fn() => DashboardController::settings()], + ['GET', '/admin/ai-logs', fn() => DashboardController::aiLogs()], ['POST', '/admin/settings/save', fn() => (function () { SessionAuth::require(); diff --git a/services/AiBot.php b/services/AiBot.php index 0ff71db..51daa33 100644 --- a/services/AiBot.php +++ b/services/AiBot.php @@ -3,6 +3,11 @@ declare(strict_types=1); class AiBot { + // Context forwarded to provider methods so AiLogger has phone + call type + private static string $logPhone = ''; + private static string $logType = 'chat'; + private static int $logCompany = 0; + public static function processMedia(array $company, array $context, string $mediaType, string $caption): ?array { $permType = (int)($context['permission_type'] ?? 1); @@ -36,7 +41,7 @@ class AiBot $ctxId = (int)$botCtx['id']; ConversationContext::addAiMessage($ctxId, ['role' => 'user', 'content' => $userMessage]); - $result = self::callLlm($systemPrompt, $ctxId, $company); + $result = self::callLlm($systemPrompt, $ctxId, $company, 'media_response', $context['from']); if ($result === null) { return null; } @@ -61,7 +66,7 @@ class AiBot ConversationContext::addAiMessage($ctxId, ['role' => 'user', 'content' => $input]); - $result = self::callLlm($systemPrompt, $ctxId, $company); + $result = self::callLlm($systemPrompt, $ctxId, $company, 'chat', $context['from']); if ($result === null) { return null; @@ -77,8 +82,12 @@ class AiBot ]; } - private static function callLlm(string $systemPrompt, int $ctxId, array $company): ?array + private static function callLlm(string $systemPrompt, int $ctxId, array $company, string $callType = 'chat', string $phone = ''): ?array { + self::$logType = $callType; + self::$logPhone = $phone; + self::$logCompany = (int)($company['id'] ?? 0); + $cfg = self::getConfig($company); // Per-company provider overrides global; empty = use global $provider = $cfg['ai_provider'] ?? env('AI_PROVIDER', 'openai'); @@ -121,6 +130,7 @@ class AiBot 'temperature' => 0.7, ]); + $t0 = (int)(microtime(true) * 1000); $ch = curl_init('https://api.openai.com/v1/chat/completions'); curl_setopt_array($ch, [ CURLOPT_POST => true, @@ -137,6 +147,7 @@ class AiBot $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $error = curl_error($ch); curl_close($ch); + $ms = (int)(microtime(true) * 1000) - $t0; if ($httpCode !== 200 || $response === false) { WpWebhook::log('ERROR', "OpenAI API error: {$error} HTTP:{$httpCode}"); @@ -146,6 +157,9 @@ class AiBot $data = json_decode($response, true); $text = $data['choices'][0]['message']['content'] ?? null; + $lastUser = end($history)['content'] ?? ''; + AiLogger::log(self::$logCompany, 'openai', $model, self::$logType, $lastUser, $text ?? '', $ms, self::$logPhone, $data['usage']['prompt_tokens'] ?? null, $data['usage']['completion_tokens'] ?? null); + return $text !== null ? ['content' => $text] : null; } @@ -175,6 +189,7 @@ class AiBot 'generationConfig' => ['maxOutputTokens' => $maxTokens, 'temperature' => 0.7], ]); + $t0 = (int)(microtime(true) * 1000); $url = "https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent?key={$apiKey}"; $ch = curl_init($url); curl_setopt_array($ch, [ @@ -189,6 +204,7 @@ class AiBot $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $error = curl_error($ch); curl_close($ch); + $ms = (int)(microtime(true) * 1000) - $t0; if ($httpCode !== 200 || $response === false) { WpWebhook::log('ERROR', "Gemini API error: {$error} HTTP:{$httpCode}"); @@ -198,6 +214,11 @@ class AiBot $data = json_decode($response, true); $text = $data['candidates'][0]['content']['parts'][0]['text'] ?? null; + $lastUser = end($history)['content'] ?? ''; + $tokIn = $data['usageMetadata']['promptTokenCount'] ?? null; + $tokOut = $data['usageMetadata']['candidatesTokenCount'] ?? null; + AiLogger::log(self::$logCompany, 'gemini', $model, self::$logType, $lastUser, $text ?? '', $ms, self::$logPhone, $tokIn, $tokOut); + return $text !== null ? ['content' => $text] : null; } @@ -225,6 +246,7 @@ class AiBot 'messages' => $messages, ]); + $t0 = (int)(microtime(true) * 1000); $ch = curl_init('https://api.anthropic.com/v1/messages'); curl_setopt_array($ch, [ CURLOPT_POST => true, @@ -242,6 +264,7 @@ class AiBot $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $error = curl_error($ch); curl_close($ch); + $ms = (int)(microtime(true) * 1000) - $t0; if ($httpCode !== 200 || $response === false) { WpWebhook::log('ERROR', "Claude API error: {$error} HTTP:{$httpCode}"); @@ -251,6 +274,9 @@ class AiBot $data = json_decode($response, true); $text = $data['content'][0]['text'] ?? null; + $lastUser = end($history)['content'] ?? ''; + AiLogger::log(self::$logCompany, 'claude', $model, self::$logType, $lastUser, $text ?? '', $ms, self::$logPhone, $data['usage']['input_tokens'] ?? null, $data['usage']['output_tokens'] ?? null); + return $text !== null ? ['content' => $text] : null; } @@ -324,7 +350,7 @@ PROMPT; $permType ); - $raw = self::callLlmOnce($systemPrompt, $input, $company); + $raw = self::callLlmOnce($systemPrompt, $input, $company, 'nlu', $context['from'] ?? ''); if ($raw === null) { return ['action' => 'chat', 'text' => '']; } @@ -420,8 +446,12 @@ PROMPT; } /** Llamada de 1 turno sin historial β€” para NLU routing. */ - private static function callLlmOnce(string $systemPrompt, string $userMessage, array $company): ?string + private static function callLlmOnce(string $systemPrompt, string $userMessage, array $company, string $callType = 'nlu', string $phone = ''): ?string { + self::$logType = $callType; + self::$logPhone = $phone; + self::$logCompany = (int)($company['id'] ?? 0); + $cfg = self::getConfig($company); $provider = $cfg['ai_provider'] ?? env('AI_PROVIDER', 'openai'); $history = [['role' => 'user', 'content' => $userMessage]]; diff --git a/services/AiLogger.php b/services/AiLogger.php new file mode 100644 index 0000000..49de699 --- /dev/null +++ b/services/AiLogger.php @@ -0,0 +1,40 @@ +prepare(" + INSERT INTO ai_logs + (company_id, phone_number, provider, model, call_type, + input_preview, output_preview, tokens_in, tokens_out, duration_ms) + VALUES (?,?,?,?,?,?,?,?,?,?) + ")->execute([ + $companyId ?: null, + $phone !== '' ? $phone : null, + $provider, + $model, + $callType, + mb_substr($inputPreview, 0, 500), + mb_substr($outputPreview, 0, 500), + $tokensIn, + $tokensOut, + $durationMs, + ]); + } catch (\Throwable) { + // Never break the bot for a log failure + } + } +} diff --git a/services/MediaTranscriber.php b/services/MediaTranscriber.php index 060932f..9ab2506 100644 --- a/services/MediaTranscriber.php +++ b/services/MediaTranscriber.php @@ -54,6 +54,7 @@ class MediaTranscriber public static function transcribeAudio(string $mediaId, array $company): ?string { $cfg = self::getConfig($company); + $cfg['__company_id'] = (int)($company['id'] ?? 0); $provider = $cfg['ai_provider'] ?? env('AI_PROVIDER', 'openai'); $media = self::downloadMedia($mediaId); @@ -75,6 +76,7 @@ class MediaTranscriber $tmpFile = tempnam(sys_get_temp_dir(), 'wa_audio_') . '.' . $ext; file_put_contents($tmpFile, $bytes); + $t0 = (int)(microtime(true) * 1000); $ch = curl_init('https://api.openai.com/v1/audio/transcriptions'); curl_setopt_array($ch, [ CURLOPT_POST => true, @@ -90,12 +92,17 @@ class MediaTranscriber $resp = curl_exec($ch); $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); + $ms = (int)(microtime(true) * 1000) - $t0; @unlink($tmpFile); if ($code !== 200 || !$resp) return null; $data = json_decode($resp, true); - return isset($data['text']) ? trim($data['text']) : null; + $text = isset($data['text']) ? trim($data['text']) : null; + if ($text !== null) { + AiLogger::log($cfg['__company_id'] ?? null, 'openai', 'whisper-1', 'transcribe', '[audio]', $text, $ms); + } + return $text; } private static function geminiAudio(string $bytes, string $mime, array $cfg): ?string @@ -115,6 +122,7 @@ class MediaTranscriber ]); $url = "https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent?key={$apiKey}"; + $t0 = (int)(microtime(true) * 1000); $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_POST => true, @@ -126,12 +134,17 @@ class MediaTranscriber $resp = curl_exec($ch); $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); + $ms = (int)(microtime(true) * 1000) - $t0; if ($code !== 200 || !$resp) return null; $data = json_decode($resp, true); $text = $data['candidates'][0]['content']['parts'][0]['text'] ?? null; - return $text !== null ? trim($text) : null; + $text = $text !== null ? trim($text) : null; + if ($text !== null) { + AiLogger::log($cfg['__company_id'] ?? null, 'gemini', $model, 'transcribe', '[audio]', $text, $ms); + } + return $text; } // ── Imagen β†’ intenciΓ³n/texto ────────────────────────────────────────────── @@ -139,6 +152,7 @@ class MediaTranscriber public static function extractFromImage(string $mediaId, array $company): ?string { $cfg = self::getConfig($company); + $cfg['__company_id'] = (int)($company['id'] ?? 0); $provider = $cfg['ai_provider'] ?? env('AI_PROVIDER', 'openai'); $media = self::downloadMedia($mediaId); @@ -178,6 +192,7 @@ class MediaTranscriber ]], ]); + $t0 = (int)(microtime(true) * 1000); $ch = curl_init('https://api.openai.com/v1/chat/completions'); curl_setopt_array($ch, [ CURLOPT_POST => true, @@ -189,12 +204,17 @@ class MediaTranscriber $resp = curl_exec($ch); $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); + $ms = (int)(microtime(true) * 1000) - $t0; if ($code !== 200 || !$resp) return null; $data = json_decode($resp, true); $text = $data['choices'][0]['message']['content'] ?? null; - return $text !== null ? trim($text) : null; + $text = $text !== null ? trim($text) : null; + if ($text !== null) { + AiLogger::log($cfg['__company_id'] ?? null, 'openai', $model, 'vision', '[image]', $text, $ms); + } + return $text; } private static function geminiVision(string $b64, string $mime, string $prompt, array $cfg): ?string @@ -213,6 +233,7 @@ class MediaTranscriber ]); $url = "https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent?key={$apiKey}"; + $t0 = (int)(microtime(true) * 1000); $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_POST => true, @@ -224,12 +245,17 @@ class MediaTranscriber $resp = curl_exec($ch); $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); + $ms = (int)(microtime(true) * 1000) - $t0; if ($code !== 200 || !$resp) return null; $data = json_decode($resp, true); $text = $data['candidates'][0]['content']['parts'][0]['text'] ?? null; - return $text !== null ? trim($text) : null; + $text = $text !== null ? trim($text) : null; + if ($text !== null) { + AiLogger::log($cfg['__company_id'] ?? null, 'gemini', $model, 'vision', '[image]', $text, $ms); + } + return $text; } private static function claudeVision(string $b64, string $mime, string $prompt, array $cfg): ?string @@ -250,6 +276,7 @@ class MediaTranscriber ]], ]); + $t0 = (int)(microtime(true) * 1000); $ch = curl_init('https://api.anthropic.com/v1/messages'); curl_setopt_array($ch, [ CURLOPT_POST => true, @@ -261,12 +288,17 @@ class MediaTranscriber $resp = curl_exec($ch); $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); + $ms = (int)(microtime(true) * 1000) - $t0; if ($code !== 200 || !$resp) return null; $data = json_decode($resp, true); $text = $data['content'][0]['text'] ?? null; - return $text !== null ? trim($text) : null; + $text = $text !== null ? trim($text) : null; + if ($text !== null) { + AiLogger::log($cfg['__company_id'] ?? null, 'claude', $model, 'vision', '[image]', $text, $ms); + } + return $text; } // ── Helpers ─────────────────────────────────────────────────────────────── diff --git a/setup/migrate.php b/setup/migrate.php index 8cc7b1b..4d239fb 100644 --- a/setup/migrate.php +++ b/setup/migrate.php @@ -257,6 +257,27 @@ $db->exec(" ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; "); +// ─── AI usage logs ─────────────────────────────────────────────────────────── +$db->exec(" + CREATE TABLE IF NOT EXISTS ai_logs ( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + company_id INT DEFAULT NULL, + phone_number VARCHAR(20) DEFAULT NULL, + provider VARCHAR(20) NOT NULL, + model VARCHAR(60) NOT NULL DEFAULT '', + call_type VARCHAR(30) NOT NULL DEFAULT 'chat', + input_preview VARCHAR(500) DEFAULT NULL, + output_preview VARCHAR(500) DEFAULT NULL, + tokens_in INT DEFAULT NULL, + tokens_out INT DEFAULT NULL, + duration_ms INT DEFAULT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_company (company_id), + INDEX idx_provider (provider), + INDEX idx_created (created_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +"); + // ─── Campo erp_active_users en companies ────────────────────────────────────── try { $db->exec("ALTER TABLE companies ADD COLUMN erp_active_users INT DEFAULT 0 AFTER is_active");