feat: AI usage logging (AiLogger, ai_logs table, admin view)

- AiLogger.php: new service, inserts into ai_logs on every AI call
- ai_logs table: added to migrate.php (provider, model, call_type, tokens, duration_ms)
- AiBot: logs chat, media_response, nlu calls with timing and token counts
- MediaTranscriber: logs whisper, geminiAudio, openaiVision, geminiVision, claudeVision
- /admin/ai-logs: paginated table with provider/company filter
- Layout.php: added IA Logs nav link
- index.php: require AiLogger before AiBot; added GET /admin/ai-logs route

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-07-05 09:03:21 -05:00
co-authored by Claude Sonnet 4.6
parent 1d9484a1e0
commit 5506ebd2af
7 changed files with 246 additions and 10 deletions
+35 -5
View File
@@ -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]];
+40
View File
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
class AiLogger
{
public static function log(
?int $companyId,
string $provider,
string $model,
string $callType,
string $inputPreview,
string $outputPreview,
int $durationMs,
string $phone = '',
?int $tokensIn = null,
?int $tokensOut = null
): void {
try {
db()->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
}
}
}
+37 -5
View File
@@ -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 ───────────────────────────────────────────────────────────────