diff --git a/app/Services/GeminiAgentService.php b/app/Services/GeminiAgentService.php new file mode 100644 index 0000000..9dce97e --- /dev/null +++ b/app/Services/GeminiAgentService.php @@ -0,0 +1,244 @@ +apiKey = ChatConfig::get('gemini_api_key', ''); + $this->model = ChatConfig::get('gemini_model', 'gemini-2.0-flash'); + } + + public function responder( + string $texto, + array $contexto, + ?int $usuarioId = null, + string $canal = 'telegram' + ): array { + $vacio = ['respuesta' => null, 'accion' => null, 'accion_data' => []]; + + if (! $this->apiKey) { + return $vacio; + } + + $body = [ + 'contents' => [[ + 'parts' => [['text' => $this->buildPrompt($texto, $contexto)]], + ]], + 'generationConfig' => [ + 'temperature' => 0.5, + 'maxOutputTokens' => 512, + 'thinkingConfig' => ['thinkingBudget' => 0], + ], + ]; + + $inicio = microtime(true); + $response = null; + $lastError = ''; + + foreach (['v1', 'v1beta'] as $ver) { + $url = "https://generativelanguage.googleapis.com/{$ver}/models/{$this->model}:generateContent"; + try { + $resp = Http::withHeaders(['Content-Type' => 'application/json']) + ->timeout(12) + ->post("{$url}?key={$this->apiKey}", $body); + + if ($resp->successful()) { + $response = $resp; + break; + } + $lastError = "[{$ver}] " . ($resp->json('error.message') ?? substr($resp->body(), 0, 200)); + } catch (\Throwable $e) { + $lastError = "[{$ver}] " . $e->getMessage(); + } + } + + $tiempoMs = (int) ((microtime(true) - $inicio) * 1000); + + if (! $response) { + Log::warning('[GeminiAgent] API failed: ' . $lastError); + return $vacio; + } + + $parts = $response->json('candidates.0.content.parts', []); + $text = implode("\n", array_column( + array_filter($parts, fn ($p) => isset($p['text']) && empty($p['thought'])), + 'text' + )); + + $result = $this->parseRespuesta($text); + + AiUsageLog::registrar([ + 'servicio' => 'gemini_agent', + 'canal' => $canal, + 'usuario_id' => $usuarioId, + 'input_tokens' => $response->json('usageMetadata.promptTokenCount', 0), + 'output_tokens' => $response->json('usageMetadata.candidatesTokenCount', 0), + 'tiempo_ms' => $tiempoMs, + 'resultado' => $result['respuesta'] ? 'ok' : 'error', + 'detalle' => ['accion' => $result['accion'] ?? null, 'raw' => substr($text, 0, 200)], + ]); + + Log::info('[GeminiAgent] ' . json_encode($result)); + + return $result; + } + + // ─── Context builder ──────────────────────────────────────────────────── + + public static function buildContexto(array $state, ?int $userId): array + { + // Balance + $balance = 0; + if ($userId) { + $user = \App\Models\User::with('saldo')->find($userId); + $balance = $user?->saldo?->valor ?? 0; + } + + // Catálogo resumido (cacheado 5 min por rol) + $rolId = $state['rol_id'] ?? null; + $catalogo = Cache::remember("agent_catalogo_{$rolId}", 300, fn () => self::buildCatalogo($rolId, $userId)); + + // Últimos mensajes del chat (contexto conversacional) + $historial = ''; + if ($convId = $state['conv_id'] ?? null) { + $msgs = ChatMessage::where('conversation_id', $convId) + ->orderBy('id', 'desc') + ->limit(8) + ->get() + ->reverse(); + + if ($msgs->count() > 1) { + $lines = $msgs->map(fn ($m) => ($m->tipo === 'usuario' ? 'Usuario' : 'Bot') . ': ' . $m->contenido); + $historial = "CONVERSACIÓN RECIENTE:\n" . $lines->implode("\n"); + } + } + + return [ + 'nombre' => $state['nombre'] ?? ($state['data']['nombre'] ?? 'Cliente'), + 'balance' => number_format($balance), + 'catalogo' => $catalogo, + 'historial' => $historial, + ]; + } + + private static function buildCatalogo(?int $rolId, ?int $userId): string + { + $servicios = Servicio::with(['tarifas' => fn ($q) => $q->where('estado', 'activo') + ->when($rolId, fn ($q2) => $q2->where('rol_id', $rolId)) + ])->where('estado', 'activo')->limit(12)->get(); + + $lines = []; + foreach ($servicios as $srv) { + if ($srv->tarifas->isEmpty()) { + continue; + } + $tarifa = $srv->tarifas->first(); + $precio = CuentasHelper::precio($tarifa->id, $userId, $tarifa->valor); + $disp = CuentasHelper::contar($tarifa) > 0 ? '✅' : '❌'; + $lines[] = " {$disp} {$srv->nombre} — \$" . number_format($precio) . " / {$tarifa->dias} días"; + } + + return $lines ? implode("\n", $lines) : 'Sin servicios disponibles'; + } + + // ─── Prompt ───────────────────────────────────────────────────────────── + + private function buildPrompt(string $texto, array $contexto): string + { + $nombre = $contexto['nombre'] ?? 'Cliente'; + $balance = $contexto['balance'] ?? '0'; + $catalogo = $contexto['catalogo'] ?? ''; + $hist = $contexto['historial'] ? "\n" . $contexto['historial'] . "\n" : ''; + + return << null, 'accion' => null, 'accion_data' => []]; + + $text = preg_replace('/```json\s*|\s*```/', '', $text); + if (preg_match('/\{.*\}/s', $text, $m)) { + $text = $m[0]; + } + $json = json_decode(trim($text), true); + + if (! is_array($json) || empty($json['respuesta'])) { + return $vacio; + } + + $accionesValidas = ['recarga.iniciar', 'servicios.listar', 'promo.listar', + 'credenciales.listar', 'historial.ver', 'perfil.ver']; + + $accion = in_array($json['accion'] ?? '', $accionesValidas) ? $json['accion'] : null; + $accionData = is_array($json['accion_data'] ?? null) ? $json['accion_data'] : []; + + return [ + 'respuesta' => trim($json['respuesta']), + 'accion' => $accion, + 'accion_data' => $accionData, + ]; + } +} diff --git a/app/Services/TelegramBotService.php b/app/Services/TelegramBotService.php index d280fe2..0848688 100755 --- a/app/Services/TelegramBotService.php +++ b/app/Services/TelegramBotService.php @@ -30,6 +30,7 @@ use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Mail; use Illuminate\Support\Str; +use App\Services\GeminiAgentService; class TelegramBotService { @@ -1279,13 +1280,14 @@ class TelegramBotService private function detectarPorKeywords(string $texto): ?string { + // Solo intents genéricos sin parámetros — los específicos (con nombre de servicio o monto) + // los maneja GeminiAgentService para extraer parámetros y responder conversacionalmente. $patrones = [ - 'recarga.iniciar' => '/recargar|recarg[ao]|cargar\s*saldo|poner\s*plata|depositar|agregar\s*plata|quiero\s*saldo|meter\s*plata/i', - 'servicios.listar' => '/servicios?|planes?|cat[aá]logo|qu[eé]\s*tienen|qu[eé]\s*hay|netflix|disney|hbo|max|prime|spotify|crunchyroll|paramount|precio|cu[aá]nto\s*vale|cu[aá]nto\s*cuesta/i', - 'promo.listar' => '/promo|oferta|combo|descuento|especial|barato|econom/i', - 'credenciales.listar' => '/credencial|contrase[ñn]a|usuario|no\s*puedo\s*entrar|no\s*accedo|mis\s*datos|c[oó]mo\s*(entro|accedo|me\s*conecto)|mi\s*(cuenta|netflix|spotify)/i', - 'historial.ver' => '/historial|compras?|pedidos?|servicios?\s*activos?|qu[eé]\s*compr[eé]|mis\s*servicios/i', - 'perfil.ver' => '/perfil|mis\s*datos|mi\s*informaci[oó]n|cambiar\s*datos/i', + 'recarga.iniciar' => '/\b(recargar?|recarg[ao]|cargar\s+saldo|poner\s+plata|depositar|agregar\s+plata|meter\s+plata)\b/i', + 'promo.listar' => '/\b(promo|oferta|combo|descuento)\b/i', + 'credenciales.listar' => '/\b(credencial|contrase[ñn]a)\b|no\s+puedo\s+entrar|mis\s+datos\s+de\s+\w/i', + 'historial.ver' => '/\b(historial|mis\s+compras?|mis\s+pedidos?)\b/i', + 'perfil.ver' => '/\bmi\s+perfil\b/i', ]; foreach ($patrones as $accion => $patron) { @@ -1306,7 +1308,7 @@ class TelegramBotService $texto = $state['data']['last_text'] ?? ''; - // Detección rápida por palabras clave (más fiable que IA para frases coloquiales) + // Detección rápida para intents genéricos sin parámetros if ($texto) { $accionLocal = $this->detectarPorKeywords($texto); if ($accionLocal) { @@ -1315,24 +1317,40 @@ class TelegramBotService } } - // Fallback: Gemini para frases ambiguas + // Agente conversacional: entiende lenguaje natural, extrae parámetros y responde 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); + $contexto = GeminiAgentService::buildContexto($state, $state['user_id'] ?? null); + $resultado = app(GeminiAgentService::class)->responder( + $texto, $contexto, $state['user_id'] ?? null, 'telegram' + ); + + if ($resultado['respuesta']) { + $this->send($chatId, $resultado['respuesta']); + } + + if ($resultado['accion']) { + $this->dispatchAction($chatId, $resultado['accion'], $state, $resultado['accion_data']); return; } + + if ($resultado['respuesta']) { + return; // El agente respondió la pregunta, sin acción que ejecutar + } } $this->showMainMenu($chatId); } - private function dispatchAction(string $chatId, string $action, array $state): void + private function dispatchAction(string $chatId, string $action, array $state, array $accionData = []): void { match ($action) { - 'servicios.listar' => $this->listServices($chatId), + 'servicios.listar' => isset($accionData['servicio']) + ? $this->viewServiceByName($chatId, (string) $accionData['servicio']) + : $this->listServices($chatId), + 'recarga.iniciar' => isset($accionData['monto']) && (int) $accionData['monto'] > 0 + ? $this->chooseRechargeMethod($chatId, (int) $accionData['monto']) + : $this->showRechargeAmounts($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), @@ -1341,6 +1359,17 @@ class TelegramBotService }; } + private function viewServiceByName(string $chatId, string $nombre): void + { + $servicio = Servicio::whereRaw('LOWER(nombre) LIKE ?', ['%' . strtolower($nombre) . '%']) + ->where('estado', 'activo') + ->first(); + + $servicio + ? $this->viewService($chatId, $servicio->id) + : $this->listServices($chatId); + } + // ─── State management ───────────────────────────────────── private function getState(string $chatId): array