Add AI usage logs for Gemini and Whisper

- Migration + AiUsageLog model with tokens, duration, time, cost fields
- GeminiIntentService and GeminiVisionService log every API call
  with input/output token counts from usageMetadata and response time
- TelegramBotService logs Whisper calls with audio duration (from Telegram)
  and calculates cost at $1/second
- Whisper voice duration now passed from TelegramWebhookController
- Free text in Telegram now tries Gemini intent detection before showing menu
- /chat/ia-logs: Livewire component with summary cards + filterable table
- Whisper connection test button in config panel
- "Logs IA" link added to Chat/Bot nav section

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lizandro
2026-07-14 19:13:59 +00:00
co-authored by Claude Sonnet 4.6
parent ff109a5608
commit 44297aa0bb
14 changed files with 519 additions and 26 deletions
+77 -16
View File
@@ -3,6 +3,7 @@
namespace App\Services;
use App\Mail\ChatOtpMail;
use App\Models\AiUsageLog;
use App\Models\ChatConfig;
use App\Models\ChatContact;
use App\Models\ChatConversation;
@@ -85,6 +86,12 @@ class TelegramBotService
$step = $state['step'] ?? 'await_email';
// Guardar último texto para que handleFreeText pueda pasarlo a Gemini
if (! in_array($step, ['await_email', 'await_otp', 'await_nombre', 'await_celular', 'await_amount'])) {
$state['data']['last_text'] = $text;
$this->setState($chatId, $state);
}
match ($step) {
'await_email' => $this->processEmail($chatId, $text, $nombre),
'await_otp' => $this->processOtp($chatId, $text),
@@ -161,7 +168,7 @@ class TelegramBotService
$base64 = base64_encode($imageData['content']);
$mime = $imageData['mime'];
$datosPago = app(GeminiVisionService::class)->extraerPago($base64, $mime);
$datosPago = app(GeminiVisionService::class)->extraerPago($base64, $mime, $state['user_id'] ?? null, 'telegram');
if (! $datosPago) {
$this->send($chatId, "No pude leer el comprobante. Intenta con una foto más clara.");
@@ -196,10 +203,11 @@ class TelegramBotService
}
}
public function handleVoice(string $chatId, string $fileId, string $nombre = ''): void
public function handleVoice(string $chatId, string $fileId, string $nombre = '', int $duracionSegundos = 0): void
{
$state = $this->getState($chatId);
$this->convId = $state['conv_id'] ?? null;
$usuarioId = $state['user_id'] ?? null;
if (ChatConfig::get('whisper_habilitado', '0') !== '1') {
$this->send($chatId, "🎤 Recibí tu audio, pero la transcripción de voz no está habilitada. Por favor escribe tu mensaje.");
@@ -215,15 +223,15 @@ class TelegramBotService
return;
}
$texto = $this->transcribeAudio($audioData['content']);
if (! $texto) {
$resultado = $this->transcribeAudio($audioData['content'], $duracionSegundos, $usuarioId);
if (! $resultado['texto']) {
$this->send($chatId, "No pude entender el audio. Intenta de nuevo o escribe tu mensaje.");
return;
}
// Confirmar lo que se escuchó y procesar como texto normal
$this->send($chatId, "🎙️ Escuché: _\"{$texto}\"_");
$this->handleText($chatId, $texto, $nombre);
$this->send($chatId, "🎙️ Escuché: _\"{$resultado['texto']}\"_");
$this->handleText($chatId, $resultado['texto'], $nombre);
} catch (\Throwable $e) {
Log::warning('[TelegramBot] handleVoice error: ' . $e->getMessage());
@@ -231,33 +239,62 @@ class TelegramBotService
}
}
private function transcribeAudio(string $audioContent): ?string
private function transcribeAudio(string $audioContent, int $duracionSegundos = 0, ?int $usuarioId = null): array
{
$whisperUrl = ChatConfig::get('whisper_url');
$whisperToken = ChatConfig::get('whisper_token');
$inicio = microtime(true);
if (! $whisperUrl) {
return null;
return ['texto' => null];
}
try {
$response = Http::timeout(30)
$response = Http::timeout(60)
->withBasicAuth('whisper', $whisperToken)
->attach('audio_file', $audioContent, 'audio.ogg')
->post($whisperUrl, ['response_format' => 'json']);
if (! $response->successful()) {
$tiempoMs = (int) ((microtime(true) - $inicio) * 1000);
$json = $response->json();
$texto = trim($json['text'] ?? $json['transcription'] ?? '');
$ok = $response->successful() && $texto !== '';
AiUsageLog::registrar([
'servicio' => 'whisper',
'canal' => 'telegram',
'usuario_id' => $usuarioId,
'audio_segundos' => $duracionSegundos ?: null,
'tiempo_ms' => $tiempoMs,
'costo' => $duracionSegundos, // $1 por segundo
'resultado' => $ok ? 'ok' : 'error',
'detalle' => [
'texto' => $ok ? substr($texto, 0, 200) : null,
'error' => $ok ? null : substr($response->body(), 0, 300),
'segundos' => $duracionSegundos,
],
]);
if (! $ok) {
Log::warning('[TelegramBot] Whisper error: ' . $response->body());
return null;
return ['texto' => null];
}
// Whisper devuelve {"text": "..."} o {"transcription": "..."}
$json = $response->json();
return trim($json['text'] ?? $json['transcription'] ?? '');
return ['texto' => $texto];
} catch (\Throwable $e) {
$tiempoMs = (int) ((microtime(true) - $inicio) * 1000);
Log::warning('[TelegramBot] Whisper exception: ' . $e->getMessage());
return null;
AiUsageLog::registrar([
'servicio' => 'whisper',
'canal' => 'telegram',
'usuario_id' => $usuarioId,
'audio_segundos' => $duracionSegundos ?: null,
'tiempo_ms' => $tiempoMs,
'resultado' => 'error',
'detalle' => ['error' => $e->getMessage()],
]);
return ['texto' => null];
}
}
@@ -1112,9 +1149,33 @@ class TelegramBotService
}
}
// Intentar detectar intención con Gemini antes de mostrar menú
$texto = $state['data']['last_text'] ?? '';
if ($texto) {
$accion = app(GeminiIntentService::class)->detectar($texto, $state['user_id'] ?? null, 'telegram');
if ($accion && $accion['action'] !== 'menu.principal') {
$this->dispatchAction($chatId, $accion['action'], $state);
return;
}
}
$this->showMainMenu($chatId);
}
private function dispatchAction(string $chatId, string $action, array $state): void
{
match ($action) {
'servicios.listar' => $this->listServices($chatId),
'promo.listar' => $this->listPromos($chatId),
'recarga.iniciar' => $this->showRechargeAmounts($chatId),
'credenciales.listar' => $this->showCredentials($chatId),
'historial.ver' => $this->showHistory($chatId),
'perfil.ver' => $this->showProfile($chatId),
'asesor.solicitar' => $this->transferToAgent($chatId),
default => $this->showMainMenu($chatId),
};
}
// ─── State management ─────────────────────────────────────
private function getState(string $chatId): array