Files
sirpremiumv2/app/Services/GeminiIntentService.php
T
LizandroandClaude Sonnet 4.6 807472f07d fix: indicador de subida colgado y apertura directa de servicio por nombre
1) "Subiendo comprobante..." no se quitaba nunca.

   La miniatura solo se limpiaba con livewire-upload-finish, que cubre la
   subida del archivo pero no el procesado posterior. Y updatedFotoComprobante
   tenía tres rutas de salida tempranas (sin convId, análisis deshabilitado,
   validación fallida) que ni siquiera llegaban al finally.

   Ahora todo el cuerpo va dentro de un try/finally que emite siempre
   'comprobante-listo', y el cliente limpia con ese evento del servidor en vez
   de depender de los eventos internos de Livewire. Se añade además un timeout
   de 45s como red de seguridad.

2) "cuenta de hbo" abría el listado completo en vez de HBO.

   El regex de servicios.listar tenía las marcas quemadas (netflix|hbo|disney…),
   así que capturaba la frase y devolvía data vacía antes de que la IA pudiera
   extraer el nombre. Telegram no falla porque sus keywords son solo intents
   genéricos y deja los específicos al agente.

   - Se sacan las marcas del regex genérico.
   - Nuevo detectarServicioPorNombre(): resuelve contra los nombres reales de
     la tabla servicios (nada quemado en código, sin llamada a la IA) y abre
     el servicio pedido.
   - clickBoton('servicios.listar') ahora honra data.servicio, como hace
     dispatchAction en Telegram.
   - El prompt de GeminiIntentService pide data.servicio. Este servicio lo usa
     solo el chat web; Telegram usa GeminiAgentService y no se toca.

   El patrón de credenciales se refuerza y pasa a evaluarse primero, para que
   "no puedo entrar a netflix" siga siendo soporte y no una intención de compra.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-01 14:19:34 +00:00

176 lines
6.9 KiB
PHP
Executable File

<?php
namespace App\Services;
use App\Models\AiUsageLog;
use App\Models\ChatConfig;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class GeminiIntentService
{
private string $apiKey;
private string $apiUrl;
private string $model;
public function __construct()
{
$this->apiKey = ChatConfig::get('gemini_api_key', '');
$this->model = ChatConfig::get('gemini_model', 'gemini-3.5-flash');
// v1 for newer models, v1beta as fallback
$ver = preg_match('/gemini-(2\.5|3[\.\d]*)/', $this->model) ? 'v1' : 'v1beta';
$this->apiUrl = "https://generativelanguage.googleapis.com/{$ver}/models/{$this->model}:generateContent";
}
private function buildRequest(array $contents, array $genConfig = []): array
{
return [
'contents' => $contents,
'generationConfig' => array_merge([
'temperature' => 0.1,
'maxOutputTokens' => 2048,
'thinkingConfig' => ['thinkingBudget' => 0],
], $genConfig),
];
}
/**
* 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, ?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'])
->timeout(8)
->post("{$this->apiUrl}?key={$this->apiKey}",
$this->buildRequest([['parts' => [['text' => $prompt]]]])
);
$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;
}
$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;
}
}
private function buildPrompt(string $texto): string
{
$promptExtra = ChatConfig::get('gemini_prompt_extra', '');
return <<<PROMPT
Eres un clasificador de intenciones para SirPremium, una tienda colombiana de streaming (Netflix, Disney+, HBO, Spotify, etc.).
Los usuarios escriben en español latinoamericano informal, con jerga colombiana y errores ortográficos.
{$promptExtra}
Acciones válidas — devuelve EXACTAMENTE una de estas:
- menu.principal → saludo, "hola", "inicio", "volver", "menú", pedir ayuda general
- servicios.listar → ver planes, catálogo, qué servicios hay, "quiero Netflix", "qué tienen", "cuánto vale", preguntar por precios
Si el usuario nombra un servicio concreto (Netflix, HBO, Disney, Spotify…),
devuélvelo en data.servicio para abrir ese servicio directamente.
- promo.listar → ver promociones, ofertas, combos, descuentos, "hay algo especial"
- recarga.iniciar → recargar saldo, cargar dinero, "quiero poner saldo", "cómo recargo", "agregar plata", "depositar"
- credenciales.listar → ver contraseña, cómo entrar, "no puedo acceder", "dame el usuario", "mis datos de Netflix", "cómo me conecto"
- historial.ver → historial de compras, "qué he comprado", "mis pedidos", "mis servicios activos"
- perfil.ver → ver perfil, mis datos personales, cambiar información
Ejemplos de clasificación:
"hacer recarga" → recarga.iniciar
"quiero recargar" → recarga.iniciar
"cargar saldo" → recarga.iniciar
"poner plata" → recarga.iniciar
"ver planes" → servicios.listar, data {}
"qué tienen de Netflix" → servicios.listar, data {"servicio": "Netflix"}
"cuenta de hbo" → servicios.listar, data {"servicio": "HBO"}
"cuánto vale disney" → servicios.listar, data {"servicio": "Disney"}
"hay promociones" → promo.listar
"mis contraseñas" → credenciales.listar
"no puedo entrar a mi cuenta" → credenciales.listar
Texto del usuario: "{$texto}"
Responde ÚNICAMENTE con JSON válido, sin markdown, sin explicación:
{"action": "la_accion", "data": {}}
Si el texto es completamente ambiguo o irrelevante, usa: {"action": "menu.principal", "data": {}}
PROMPT;
}
private function parseRespuesta(string $text): ?array
{
$text = trim($text);
// Limpiar markdown si Gemini lo incluye
$text = preg_replace('/```json\s*|\s*```/', '', $text);
$text = trim($text);
$json = json_decode($text, true);
if (! is_array($json) || empty($json['action'])) {
return null;
}
$accionesValidas = [
'menu.principal', 'servicios.listar', 'promo.listar',
'recarga.iniciar', 'credenciales.listar', 'historial.ver',
'perfil.ver', 'asesor.solicitar',
];
if (! in_array($json['action'], $accionesValidas)) {
return ['action' => 'menu.principal', 'data' => []];
}
return [
'action' => $json['action'],
'data' => is_array($json['data'] ?? null) ? $json['data'] : [],
];
}
}