diff --git a/app/Http/Controllers/ChatController.php b/app/Http/Controllers/ChatController.php
index 52fdb9e..862df83 100644
--- a/app/Http/Controllers/ChatController.php
+++ b/app/Http/Controllers/ChatController.php
@@ -23,4 +23,9 @@ class ChatController extends Controller
{
return view('chat.configuracion');
}
+
+ public function iaLogs()
+ {
+ return view('chat.ia-logs');
+ }
}
diff --git a/app/Http/Controllers/TelegramWebhookController.php b/app/Http/Controllers/TelegramWebhookController.php
index e0b848d..3d7c7dd 100644
--- a/app/Http/Controllers/TelegramWebhookController.php
+++ b/app/Http/Controllers/TelegramWebhookController.php
@@ -31,13 +31,14 @@ class TelegramWebhookController extends Controller
// Voice / audio messages
if (isset($update['message']['voice']) || isset($update['message']['audio'])) {
- $chatId = (string) $update['message']['chat']['id'];
- $fileId = $update['message']['voice']['file_id'] ?? $update['message']['audio']['file_id'];
- $nombre = trim(
+ $chatId = (string) $update['message']['chat']['id'];
+ $fileId = $update['message']['voice']['file_id'] ?? $update['message']['audio']['file_id'];
+ $duration = $update['message']['voice']['duration'] ?? $update['message']['audio']['duration'] ?? null;
+ $nombre = trim(
($update['message']['from']['first_name'] ?? '') . ' ' .
($update['message']['from']['last_name'] ?? '')
);
- $bot->handleVoice($chatId, $fileId, $nombre);
+ $bot->handleVoice($chatId, $fileId, $nombre, (int) $duration);
return response()->json(['ok' => true]);
}
diff --git a/app/Http/Livewire/Chat/ShowAiLogs.php b/app/Http/Livewire/Chat/ShowAiLogs.php
new file mode 100644
index 0000000..17ab2dd
--- /dev/null
+++ b/app/Http/Livewire/Chat/ShowAiLogs.php
@@ -0,0 +1,64 @@
+resetPage(); }
+ public function updatingFiltroCanal(): void { $this->resetPage(); }
+ public function updatingFiltroFechaDesde(): void { $this->resetPage(); }
+ public function updatingFiltroFechaHasta(): void { $this->resetPage(); }
+
+ public function render()
+ {
+ $query = AiUsageLog::query()->latest();
+
+ if ($this->filtroServicio) {
+ $query->where('servicio', $this->filtroServicio);
+ }
+ if ($this->filtroCanal) {
+ $query->where('canal', $this->filtroCanal);
+ }
+ if ($this->filtroFechaDesde) {
+ $query->whereDate('created_at', '>=', $this->filtroFechaDesde);
+ }
+ if ($this->filtroFechaHasta) {
+ $query->whereDate('created_at', '<=', $this->filtroFechaHasta);
+ }
+
+ $logs = $query->paginate(30);
+
+ // Resumen total (aplicando mismos filtros sin paginación)
+ $resumenQuery = AiUsageLog::query();
+ if ($this->filtroServicio) $resumenQuery->where('servicio', $this->filtroServicio);
+ if ($this->filtroCanal) $resumenQuery->where('canal', $this->filtroCanal);
+ if ($this->filtroFechaDesde) $resumenQuery->whereDate('created_at', '>=', $this->filtroFechaDesde);
+ if ($this->filtroFechaHasta) $resumenQuery->whereDate('created_at', '<=', $this->filtroFechaHasta);
+
+ $resumen = $resumenQuery->selectRaw('
+ COUNT(*) as total_llamadas,
+ SUM(CASE WHEN resultado = "ok" THEN 1 ELSE 0 END) as total_ok,
+ SUM(CASE WHEN resultado = "error" THEN 1 ELSE 0 END) as total_errores,
+ SUM(input_tokens) as total_input_tokens,
+ SUM(output_tokens) as total_output_tokens,
+ SUM(audio_segundos) as total_segundos_whisper,
+ SUM(CASE WHEN servicio = "whisper" THEN costo ELSE 0 END) as costo_whisper,
+ AVG(tiempo_ms) as avg_tiempo_ms
+ ')->first();
+
+ return view('livewire.chat.show-ai-logs', compact('logs', 'resumen'));
+ }
+}
diff --git a/app/Http/Livewire/Chat/ShowConfiguracionChat.php b/app/Http/Livewire/Chat/ShowConfiguracionChat.php
old mode 100644
new mode 100755
index 0ceda8a..3443975
--- a/app/Http/Livewire/Chat/ShowConfiguracionChat.php
+++ b/app/Http/Livewire/Chat/ShowConfiguracionChat.php
@@ -16,6 +16,7 @@ class ShowConfiguracionChat extends Component
public string $mensaje_bienvenida = '';
public string $mensaje_transferencia = '';
public string $webhookResult = '';
+ public string $whisperResult = '';
// ── Gemini IA ─────────────────────────────────────────────
public string $gemini_api_key = '';
@@ -85,6 +86,29 @@ class ShowConfiguracionChat extends Component
$this->alert('success', 'Configuracion guardada correctamente.');
}
+ public function resetearWhisper(): void
+ {
+ $url = trim($this->whisper_url ?: ChatConfig::get('whisper_url'));
+ $token = trim($this->whisper_token ?: ChatConfig::get('whisper_token'));
+
+ if (! $url) {
+ $this->whisperResult = '⚠️ La URL de Whisper está vacía.';
+ return;
+ }
+
+ try {
+ $response = Http::timeout(10)
+ ->withBasicAuth('whisper', $token)
+ ->get(rtrim($url, '/asr')); // ping al servidor
+
+ $this->whisperResult = $response->successful()
+ ? '✅ Conexión con Whisper establecida correctamente.'
+ : '❌ Whisper respondió con error ' . $response->status() . '.';
+ } catch (\Throwable $e) {
+ $this->whisperResult = '❌ No se pudo conectar con Whisper: ' . $e->getMessage();
+ }
+ }
+
public function registrarWebhook(): void
{
$token = trim($this->telegram_token);
diff --git a/app/Models/AiUsageLog.php b/app/Models/AiUsageLog.php
new file mode 100644
index 0000000..62d175b
--- /dev/null
+++ b/app/Models/AiUsageLog.php
@@ -0,0 +1,27 @@
+ 'array'];
+
+ public static function registrar(array $data): void
+ {
+ try {
+ static::create($data);
+ } catch (\Throwable) {
+ // No interrumpir el flujo principal si el log falla
+ }
+ }
+}
diff --git a/app/Services/GeminiIntentService.php b/app/Services/GeminiIntentService.php
index 36dfa04..0a48a97 100644
--- a/app/Services/GeminiIntentService.php
+++ b/app/Services/GeminiIntentService.php
@@ -2,6 +2,7 @@
namespace App\Services;
+use App\Models\AiUsageLog;
use App\Models\ChatConfig;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
@@ -20,13 +21,14 @@ class GeminiIntentService
* Detecta la intención del texto del usuario y la mapea a una acción del sistema.
* Retorna ['action' => string, 'data' => array] o null si no se puede detectar.
*/
- public function detectar(string $texto): ?array
+ public function detectar(string $texto, ?int $usuarioId = null, string $canal = 'web'): ?array
{
if (! $this->apiKey || ChatConfig::get('gemini_habilitado', '0') !== '1') {
return null;
}
$prompt = $this->buildPrompt($texto);
+ $inicio = microtime(true);
try {
$response = Http::withHeaders(['Content-Type' => 'application/json'])
@@ -39,16 +41,50 @@ class GeminiIntentService
],
]);
+ $tiempoMs = (int) ((microtime(true) - $inicio) * 1000);
+
if (! $response->successful()) {
Log::warning('[Gemini Intent] API error: ' . $response->body());
+ AiUsageLog::registrar([
+ 'servicio' => 'gemini_intent',
+ 'canal' => $canal,
+ 'usuario_id'=> $usuarioId,
+ 'tiempo_ms' => $tiempoMs,
+ 'resultado' => 'error',
+ 'detalle' => ['error' => substr($response->body(), 0, 300), 'texto' => $texto],
+ ]);
return null;
}
- $text = $response->json('candidates.0.content.parts.0.text', '');
- return $this->parseRespuesta($text);
+ $inputTokens = $response->json('usageMetadata.promptTokenCount', 0);
+ $outputTokens = $response->json('usageMetadata.candidatesTokenCount', 0);
+ $text = $response->json('candidates.0.content.parts.0.text', '');
+ $resultado = $this->parseRespuesta($text);
+
+ AiUsageLog::registrar([
+ 'servicio' => 'gemini_intent',
+ 'canal' => $canal,
+ 'usuario_id' => $usuarioId,
+ 'input_tokens' => $inputTokens,
+ 'output_tokens'=> $outputTokens,
+ 'tiempo_ms' => $tiempoMs,
+ 'resultado' => 'ok',
+ 'detalle' => ['texto' => $texto, 'accion' => $resultado['action'] ?? null],
+ ]);
+
+ return $resultado;
} catch (\Throwable $e) {
+ $tiempoMs = (int) ((microtime(true) - $inicio) * 1000);
Log::warning('[Gemini Intent] Excepcion: ' . $e->getMessage());
+ AiUsageLog::registrar([
+ 'servicio' => 'gemini_intent',
+ 'canal' => $canal,
+ 'usuario_id'=> $usuarioId,
+ 'tiempo_ms' => $tiempoMs,
+ 'resultado' => 'error',
+ 'detalle' => ['error' => $e->getMessage(), 'texto' => $texto],
+ ]);
return null;
}
}
diff --git a/app/Services/GeminiVisionService.php b/app/Services/GeminiVisionService.php
index df6d7a5..fb504a0 100644
--- a/app/Services/GeminiVisionService.php
+++ b/app/Services/GeminiVisionService.php
@@ -2,6 +2,7 @@
namespace App\Services;
+use App\Models\AiUsageLog;
use App\Models\ChatConfig;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
@@ -23,7 +24,7 @@ class GeminiVisionService
* @param string $mimeType MIME type (image/jpeg, image/png, etc.)
* @return array|null ['banco', 'valor', 'referencia', 'fecha', 'hora', 'remitente'] o null
*/
- public function extraerPago(string $base64, string $mimeType): ?array
+ public function extraerPago(string $base64, string $mimeType, ?int $usuarioId = null, string $canal = 'telegram'): ?array
{
if (! $this->apiKey) {
Log::warning('[Gemini Vision] No hay API key configurada.');
@@ -31,6 +32,7 @@ class GeminiVisionService
}
$prompt = $this->buildPrompt();
+ $inicio = microtime(true);
try {
$response = Http::withHeaders(['Content-Type' => 'application/json'])
@@ -48,16 +50,50 @@ class GeminiVisionService
],
]);
+ $tiempoMs = (int) ((microtime(true) - $inicio) * 1000);
+
if (! $response->successful()) {
Log::warning('[Gemini Vision] API error: ' . $response->body());
+ AiUsageLog::registrar([
+ 'servicio' => 'gemini_vision',
+ 'canal' => $canal,
+ 'usuario_id'=> $usuarioId,
+ 'tiempo_ms' => $tiempoMs,
+ 'resultado' => 'error',
+ 'detalle' => ['error' => substr($response->body(), 0, 300)],
+ ]);
return null;
}
- $text = $response->json('candidates.0.content.parts.0.text', '');
- return $this->parseRespuesta($text);
+ $inputTokens = $response->json('usageMetadata.promptTokenCount', 0);
+ $outputTokens = $response->json('usageMetadata.candidatesTokenCount', 0);
+ $text = $response->json('candidates.0.content.parts.0.text', '');
+ $resultado = $this->parseRespuesta($text);
+
+ AiUsageLog::registrar([
+ 'servicio' => 'gemini_vision',
+ 'canal' => $canal,
+ 'usuario_id' => $usuarioId,
+ 'input_tokens' => $inputTokens,
+ 'output_tokens'=> $outputTokens,
+ 'tiempo_ms' => $tiempoMs,
+ 'resultado' => $resultado ? 'ok' : 'error',
+ 'detalle' => $resultado ? ['banco' => $resultado['banco'], 'valor' => $resultado['valor']] : ['raw' => substr($text, 0, 200)],
+ ]);
+
+ return $resultado;
} catch (\Throwable $e) {
+ $tiempoMs = (int) ((microtime(true) - $inicio) * 1000);
Log::error('[Gemini Vision] Excepcion: ' . $e->getMessage());
+ AiUsageLog::registrar([
+ 'servicio' => 'gemini_vision',
+ 'canal' => $canal,
+ 'usuario_id'=> $usuarioId,
+ 'tiempo_ms' => $tiempoMs,
+ 'resultado' => 'error',
+ 'detalle' => ['error' => $e->getMessage()],
+ ]);
return null;
}
}
diff --git a/app/Services/TelegramBotService.php b/app/Services/TelegramBotService.php
index 20b356b..29afc5d 100644
--- a/app/Services/TelegramBotService.php
+++ b/app/Services/TelegramBotService.php
@@ -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
diff --git a/database/migrations/2026_07_14_000001_create_ai_usage_logs_table.php b/database/migrations/2026_07_14_000001_create_ai_usage_logs_table.php
new file mode 100644
index 0000000..47538fe
--- /dev/null
+++ b/database/migrations/2026_07_14_000001_create_ai_usage_logs_table.php
@@ -0,0 +1,34 @@
+id();
+ $table->string('servicio', 30);
+ $table->string('canal', 20)->default('telegram');
+ $table->unsignedBigInteger('usuario_id')->nullable();
+ $table->integer('input_tokens')->nullable();
+ $table->integer('output_tokens')->nullable();
+ $table->integer('audio_segundos')->nullable();
+ $table->integer('tiempo_ms')->default(0);
+ $table->decimal('costo', 10, 2)->default(0);
+ $table->string('resultado', 10)->default('ok');
+ $table->text('detalle')->nullable();
+ $table->timestamps();
+
+ $table->index(['servicio', 'created_at']);
+ $table->index('usuario_id');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('ai_usage_logs');
+ }
+};
diff --git a/resources/views/chat/ia-logs.blade.php b/resources/views/chat/ia-logs.blade.php
new file mode 100644
index 0000000..c5af7de
--- /dev/null
+++ b/resources/views/chat/ia-logs.blade.php
@@ -0,0 +1,5 @@
+@extends('layouts.app')
+
+@section('content')
+
Historial de llamadas a Gemini y Whisper con tokens, tiempos y costos.
+Total llamadas
+{{ number_format($resumen->total_llamadas ?? 0) }}
++ {{ number_format($resumen->total_ok ?? 0) }} ok + · + {{ number_format($resumen->total_errores ?? 0) }} errores +
+Tokens Gemini
+{{ number_format(($resumen->total_input_tokens ?? 0) + ($resumen->total_output_tokens ?? 0)) }}
++ Entrada: {{ number_format($resumen->total_input_tokens ?? 0) }} · Salida: {{ number_format($resumen->total_output_tokens ?? 0) }} +
+Audio Whisper
+{{ number_format($resumen->total_segundos_whisper ?? 0) }}s
+segundos transcritos
+Costo Whisper
+${{ number_format($resumen->costo_whisper ?? 0) }}
+$1 por segundo · Tiempo prom: {{ number_format($resumen->avg_tiempo_ms ?? 0) }}ms
+| Fecha | +Servicio | +Canal | +Usuario | +Tokens E/S | +Audio | +Tiempo | +Costo | +Estado | +Detalle | +
|---|---|---|---|---|---|---|---|---|---|
|
+ {{ $log->created_at->format('d/m/Y') }} + {{ $log->created_at->format('H:i:s') }} + |
+ + {{ $labelServicio }} + | +{{ $log->canal }} | ++ {{ $log->usuario_id ? ('ID '.$log->usuario_id) : '—' }} + | ++ @if($log->input_tokens || $log->output_tokens) + {{ number_format($log->input_tokens ?? 0) }} / {{ number_format($log->output_tokens ?? 0) }} + @else + — + @endif + | ++ @if($log->audio_segundos) + {{ $log->audio_segundos }}s + @else + — + @endif + | +{{ number_format($log->tiempo_ms) }}ms | ++ @if($log->costo > 0) + ${{ number_format($log->costo) }} + @else + — + @endif + | ++ @if($log->resultado === 'ok') + OK + @else + Error + @endif + | ++ @if(isset($detalle['texto']) && $detalle['texto']) + "{{ Str::limit($detalle['texto'], 60) }}" + @elseif(isset($detalle['accion']) && $detalle['accion']) + → {{ $detalle['accion'] }} + @elseif(isset($detalle['banco'])) + {{ $detalle['banco'] }} · ${{ number_format($detalle['valor'] ?? 0) }} + @elseif(isset($detalle['error'])) + {{ Str::limit($detalle['error'], 60) }} + @else + — + @endif + | +
| + No hay registros con los filtros seleccionados. + | +|||||||||
+ {{ $whisperResult }} +
+ @endif +