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)
+
+
+
+
+
+ | Fecha | Empresa | TelΓ©fono | Proveedor | Modelo |
+ Tipo | Entrada | Salida | Tokens | DuraciΓ³n |
+
+ ' . ($rows_html ?: '| 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");