- Keywords: captura "no inicia sesión Netflix", "no abre", "error de acceso" → credenciales.listar - GeminiAgent prompt: bot no pide pantallazos ni promete revisiones; problemas de acceso → credenciales.listar Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
247 lines
9.4 KiB
PHP
247 lines
9.4 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\AiUsageLog;
|
|
use App\Models\ChatConfig;
|
|
use App\Models\ChatMessage;
|
|
use App\Models\Servicio;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
/**
|
|
* Agente conversacional con contexto: entiende lenguaje natural y extrae
|
|
* acción + parámetros para ir directamente al flujo correcto.
|
|
*
|
|
* Respuesta: {"respuesta": "...", "accion": null|string, "accion_data": {}}
|
|
* Acciones:
|
|
* recarga.iniciar → accion_data: {monto?: int}
|
|
* servicios.listar → accion_data: {servicio?: "Netflix"}
|
|
* promo.listar → accion_data: {}
|
|
* credenciales.listar → accion_data: {servicio?: "Netflix"}
|
|
* historial.ver → accion_data: {}
|
|
* perfil.ver → accion_data: {}
|
|
*/
|
|
class GeminiAgentService
|
|
{
|
|
private string $apiKey;
|
|
private string $model;
|
|
|
|
public function __construct()
|
|
{
|
|
$this->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 <<<PROMPT
|
|
Eres el bot de SirPremium, tienda colombiana de streaming. NO eres humano ni agente de soporte.
|
|
Solo puedes ejecutar las acciones del menú — NUNCA pidas pantallazos, correos ni información adicional.
|
|
Habla en español colombiano informal. Máximo 2 oraciones en "respuesta".
|
|
|
|
IMPORTANTE:
|
|
- Si el usuario tiene un problema de acceso, contraseña o no puede entrar a un servicio → credenciales.listar
|
|
- Si preguntan precio o quieren comprar → servicios.listar
|
|
- Si quieren recargar → recarga.iniciar
|
|
- Para todo lo demás que no tenga acción: responde brevemente que solo puedes ayudar con lo del menú
|
|
|
|
NUNCA digas "mándame pantallazo", "escríbenos al soporte", "revisamos", "espera" ni prometas cosas que no puedes hacer.
|
|
|
|
USUARIO: {$nombre} | Saldo: \${$balance}
|
|
|
|
CATÁLOGO:
|
|
{$catalogo}
|
|
{$hist}
|
|
MENSAJE: "{$texto}"
|
|
|
|
ACCIONES:
|
|
- recarga.iniciar → monto (int, opcional)
|
|
- servicios.listar → servicio (nombre, opcional)
|
|
- promo.listar
|
|
- credenciales.listar → servicio (nombre, opcional)
|
|
- historial.ver
|
|
- perfil.ver
|
|
|
|
Responde SOLO JSON válido:
|
|
{"respuesta": "...", "accion": null, "accion_data": {}}
|
|
|
|
Ejemplos:
|
|
"quiero comprar Netflix" → {"respuesta": "¡Claro! Te muestro los planes de Netflix 👇", "accion": "servicios.listar", "accion_data": {"servicio": "Netflix"}}
|
|
"no inicia sesión Netflix" → {"respuesta": "Te paso tus datos de acceso de Netflix ahora mismo 🔑", "accion": "credenciales.listar", "accion_data": {"servicio": "Netflix"}}
|
|
"recargar 50 mil" → {"respuesta": "Listo, iniciando recarga de \$50.000 💰", "accion": "recarga.iniciar", "accion_data": {"monto": 50000}}
|
|
"¿cuánto vale Spotify?" → {"respuesta": "Te muestro los planes de Spotify con precios 👇", "accion": "servicios.listar", "accion_data": {"servicio": "Spotify"}}
|
|
PROMPT;
|
|
}
|
|
|
|
// ─── Parser ─────────────────────────────────────────────────────────────
|
|
|
|
private function parseRespuesta(string $text): array
|
|
{
|
|
$vacio = ['respuesta' => 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,
|
|
];
|
|
}
|
|
}
|