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
|
||||
|
||||
Reference in New Issue
Block a user