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:
co-authored by
Claude Sonnet 4.6
parent
ff109a5608
commit
44297aa0bb
@@ -23,4 +23,9 @@ class ChatController extends Controller
|
||||
{
|
||||
return view('chat.configuracion');
|
||||
}
|
||||
|
||||
public function iaLogs()
|
||||
{
|
||||
return view('chat.ia-logs');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire\Chat;
|
||||
|
||||
use App\Models\AiUsageLog;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithPagination;
|
||||
|
||||
class ShowAiLogs extends Component
|
||||
{
|
||||
use WithPagination;
|
||||
|
||||
public string $filtroServicio = '';
|
||||
public string $filtroCanal = '';
|
||||
public string $filtroFechaDesde = '';
|
||||
public string $filtroFechaHasta = '';
|
||||
|
||||
protected $queryString = ['filtroServicio', 'filtroCanal', 'filtroFechaDesde', 'filtroFechaHasta'];
|
||||
|
||||
public function updatingFiltroServicio(): void { $this->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'));
|
||||
}
|
||||
}
|
||||
Regular → Executable
+24
@@ -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);
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class AiUsageLog extends Model
|
||||
{
|
||||
protected $table = 'ai_usage_logs';
|
||||
|
||||
protected $fillable = [
|
||||
'servicio', 'canal', 'usuario_id',
|
||||
'input_tokens', 'output_tokens', 'audio_segundos',
|
||||
'tiempo_ms', 'costo', 'resultado', 'detalle',
|
||||
];
|
||||
|
||||
protected $casts = ['detalle' => 'array'];
|
||||
|
||||
public static function registrar(array $data): void
|
||||
{
|
||||
try {
|
||||
static::create($data);
|
||||
} catch (\Throwable) {
|
||||
// No interrumpir el flujo principal si el log falla
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('ai_usage_logs', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('content')
|
||||
<livewire:chat.show-ai-logs />
|
||||
@endsection
|
||||
@@ -288,6 +288,10 @@
|
||||
<svg class="w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75" /></svg>
|
||||
<span>Correo / IMAP</span>
|
||||
</a>
|
||||
<a href="{{ route('chat.ia-logs') }}" class="flex items-center gap-2 py-2 px-3 rounded text-white text-left {{ request()->routeIs('chat.ia-logs') ? 'bg-white/20' : 'hover:bg-white/10' }}">
|
||||
<svg class="w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9.75 9.75l4.5 4.5m0-4.5-4.5 4.5M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9h-7.5M15.75 12h-7.5M15.75 15h-4.5" /></svg>
|
||||
<span>Logs IA</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
<div class="space-y-6">
|
||||
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-gray-800">Logs de uso de IA</h2>
|
||||
<p class="text-gray-500 text-sm">Historial de llamadas a Gemini y Whisper con tokens, tiempos y costos.</p>
|
||||
</div>
|
||||
|
||||
{{-- Cards resumen --}}
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div class="bg-white rounded-2xl border border-gray-100 shadow p-4">
|
||||
<p class="text-xs text-gray-400 mb-1">Total llamadas</p>
|
||||
<p class="text-2xl font-bold text-gray-800">{{ number_format($resumen->total_llamadas ?? 0) }}</p>
|
||||
<p class="text-xs mt-1">
|
||||
<span class="text-emerald-600">{{ number_format($resumen->total_ok ?? 0) }} ok</span>
|
||||
·
|
||||
<span class="text-red-500">{{ number_format($resumen->total_errores ?? 0) }} errores</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-2xl border border-gray-100 shadow p-4">
|
||||
<p class="text-xs text-gray-400 mb-1">Tokens Gemini</p>
|
||||
<p class="text-2xl font-bold text-blue-700">{{ number_format(($resumen->total_input_tokens ?? 0) + ($resumen->total_output_tokens ?? 0)) }}</p>
|
||||
<p class="text-xs text-gray-400 mt-1">
|
||||
Entrada: {{ number_format($resumen->total_input_tokens ?? 0) }} · Salida: {{ number_format($resumen->total_output_tokens ?? 0) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-2xl border border-gray-100 shadow p-4">
|
||||
<p class="text-xs text-gray-400 mb-1">Audio Whisper</p>
|
||||
<p class="text-2xl font-bold text-purple-700">{{ number_format($resumen->total_segundos_whisper ?? 0) }}s</p>
|
||||
<p class="text-xs text-gray-400 mt-1">segundos transcritos</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-2xl border border-gray-100 shadow p-4">
|
||||
<p class="text-xs text-gray-400 mb-1">Costo Whisper</p>
|
||||
<p class="text-2xl font-bold text-emerald-700">${{ number_format($resumen->costo_whisper ?? 0) }}</p>
|
||||
<p class="text-xs text-gray-400 mt-1">$1 por segundo · Tiempo prom: {{ number_format($resumen->avg_tiempo_ms ?? 0) }}ms</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Filtros --}}
|
||||
<div class="bg-white rounded-2xl border border-gray-100 shadow p-4">
|
||||
<div class="flex flex-wrap gap-3 items-end">
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-gray-600 mb-1">Servicio</label>
|
||||
<select wire:model.live="filtroServicio" class="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-emerald-500 outline-none">
|
||||
<option value="">Todos</option>
|
||||
<option value="gemini_intent">Gemini Intent</option>
|
||||
<option value="gemini_vision">Gemini Vision</option>
|
||||
<option value="whisper">Whisper</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-gray-600 mb-1">Canal</label>
|
||||
<select wire:model.live="filtroCanal" class="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-emerald-500 outline-none">
|
||||
<option value="">Todos</option>
|
||||
<option value="web">Web</option>
|
||||
<option value="telegram">Telegram</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-gray-600 mb-1">Desde</label>
|
||||
<input wire:model.live="filtroFechaDesde" type="date" class="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-emerald-500 outline-none">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-gray-600 mb-1">Hasta</label>
|
||||
<input wire:model.live="filtroFechaHasta" type="date" class="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-emerald-500 outline-none">
|
||||
</div>
|
||||
<button wire:click="$set('filtroServicio','');$set('filtroCanal','');$set('filtroFechaDesde','');$set('filtroFechaHasta','')"
|
||||
class="px-4 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 text-gray-600">
|
||||
Limpiar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Tabla --}}
|
||||
<div class="bg-white rounded-2xl border border-gray-100 shadow overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead class="bg-gray-50 border-b border-gray-100">
|
||||
<tr>
|
||||
<th class="text-left px-4 py-3 text-xs font-semibold text-gray-500">Fecha</th>
|
||||
<th class="text-left px-4 py-3 text-xs font-semibold text-gray-500">Servicio</th>
|
||||
<th class="text-left px-4 py-3 text-xs font-semibold text-gray-500">Canal</th>
|
||||
<th class="text-left px-4 py-3 text-xs font-semibold text-gray-500">Usuario</th>
|
||||
<th class="text-right px-4 py-3 text-xs font-semibold text-gray-500">Tokens E/S</th>
|
||||
<th class="text-right px-4 py-3 text-xs font-semibold text-gray-500">Audio</th>
|
||||
<th class="text-right px-4 py-3 text-xs font-semibold text-gray-500">Tiempo</th>
|
||||
<th class="text-right px-4 py-3 text-xs font-semibold text-gray-500">Costo</th>
|
||||
<th class="text-center px-4 py-3 text-xs font-semibold text-gray-500">Estado</th>
|
||||
<th class="text-left px-4 py-3 text-xs font-semibold text-gray-500">Detalle</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-50">
|
||||
@forelse($logs as $log)
|
||||
@php
|
||||
$detalle = is_array($log->detalle) ? $log->detalle : [];
|
||||
$badgeServicio = match($log->servicio) {
|
||||
'gemini_intent' => 'bg-blue-100 text-blue-700',
|
||||
'gemini_vision' => 'bg-indigo-100 text-indigo-700',
|
||||
'whisper' => 'bg-purple-100 text-purple-700',
|
||||
default => 'bg-gray-100 text-gray-600',
|
||||
};
|
||||
$labelServicio = match($log->servicio) {
|
||||
'gemini_intent' => 'Gemini Intent',
|
||||
'gemini_vision' => 'Gemini Vision',
|
||||
'whisper' => 'Whisper',
|
||||
default => $log->servicio,
|
||||
};
|
||||
@endphp
|
||||
<tr class="hover:bg-gray-50/50">
|
||||
<td class="px-4 py-3 text-gray-500 whitespace-nowrap text-xs">
|
||||
{{ $log->created_at->format('d/m/Y') }}<br>
|
||||
<span class="text-gray-400">{{ $log->created_at->format('H:i:s') }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="px-2 py-0.5 rounded-full text-xs font-semibold {{ $badgeServicio }}">{{ $labelServicio }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-600 capitalize text-xs">{{ $log->canal }}</td>
|
||||
<td class="px-4 py-3 text-gray-600 text-xs">
|
||||
{{ $log->usuario_id ? ('ID '.$log->usuario_id) : '—' }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right text-xs text-gray-600">
|
||||
@if($log->input_tokens || $log->output_tokens)
|
||||
{{ number_format($log->input_tokens ?? 0) }} / {{ number_format($log->output_tokens ?? 0) }}
|
||||
@else
|
||||
—
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right text-xs text-gray-600">
|
||||
@if($log->audio_segundos)
|
||||
{{ $log->audio_segundos }}s
|
||||
@else
|
||||
—
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right text-xs text-gray-600">{{ number_format($log->tiempo_ms) }}ms</td>
|
||||
<td class="px-4 py-3 text-right text-xs font-semibold {{ $log->costo > 0 ? 'text-emerald-700' : 'text-gray-400' }}">
|
||||
@if($log->costo > 0)
|
||||
${{ number_format($log->costo) }}
|
||||
@else
|
||||
—
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
@if($log->resultado === 'ok')
|
||||
<span class="px-2 py-0.5 rounded-full text-xs font-semibold bg-emerald-100 text-emerald-700">OK</span>
|
||||
@else
|
||||
<span class="px-2 py-0.5 rounded-full text-xs font-semibold bg-red-100 text-red-700">Error</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-3 text-xs text-gray-500 max-w-xs">
|
||||
@if(isset($detalle['texto']) && $detalle['texto'])
|
||||
<span class="italic">"{{ Str::limit($detalle['texto'], 60) }}"</span>
|
||||
@elseif(isset($detalle['accion']) && $detalle['accion'])
|
||||
→ {{ $detalle['accion'] }}
|
||||
@elseif(isset($detalle['banco']))
|
||||
{{ $detalle['banco'] }} · ${{ number_format($detalle['valor'] ?? 0) }}
|
||||
@elseif(isset($detalle['error']))
|
||||
<span class="text-red-500">{{ Str::limit($detalle['error'], 60) }}</span>
|
||||
@else
|
||||
—
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="10" class="px-4 py-12 text-center text-gray-400 text-sm">
|
||||
No hay registros con los filtros seleccionados.
|
||||
</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@if($logs->hasPages())
|
||||
<div class="px-4 py-3 border-t border-gray-100">
|
||||
{{ $logs->links() }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
</div>
|
||||
Regular → Executable
+15
@@ -109,6 +109,21 @@
|
||||
Habilitar transcripción de audios en Telegram
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<button wire:click="resetearWhisper"
|
||||
class="flex items-center gap-2 px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white text-sm font-semibold rounded-lg transition">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" />
|
||||
</svg>
|
||||
<span wire:loading.remove wire:target="resetearWhisper">Probar conexión Whisper</span>
|
||||
<span wire:loading wire:target="resetearWhisper">Probando...</span>
|
||||
</button>
|
||||
@if($whisperResult)
|
||||
<p class="mt-2 text-sm {{ str_contains($whisperResult, '✅') ? 'text-emerald-600' : 'text-red-600' }}">
|
||||
{{ $whisperResult }}
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Executable → Regular
+1
@@ -99,6 +99,7 @@ Route::middleware(["auth", "solo_usuario_administrador"])->group(function () {
|
||||
Route::get('/conversaciones', [ChatController::class, 'conversaciones'])->name('conversaciones');
|
||||
Route::get('/menus', [ChatController::class, 'menus'])->name('menus');
|
||||
Route::get('/configuracion', [ChatController::class, 'configuracion'])->name('configuracion');
|
||||
Route::get('/ia-logs', [ChatController::class, 'iaLogs'])->name('ia-logs');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user