feat: agente conversacional con extracción de parámetros (GeminiAgentService)
- Nuevo GeminiAgentService: entiende lenguaje natural, responde conversacionalmente y extrae acción + parámetros (ej: servicio:"Netflix", monto:50000) - handleFreeText usa GeminiAgentService en lugar de GeminiIntentService - dispatchAction acepta accion_data: va directo a Netflix sin pasar por el listado - viewServiceByName: busca servicio por nombre (ILIKE) y abre sus planes - Keywords simplificadas: solo intents genéricos sin parámetros Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
1adfffd907
commit
656b32c706
@@ -0,0 +1,244 @@
|
||||
<?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 asistente de SirPremium, tienda colombiana de streaming.
|
||||
Habla en español colombiano informal. Sé directo y amable. Máximo 2 oraciones en "respuesta".
|
||||
|
||||
USUARIO: {$nombre} | Saldo: \${$balance}
|
||||
|
||||
CATÁLOGO:
|
||||
{$catalogo}
|
||||
{$hist}
|
||||
MENSAJE: "{$texto}"
|
||||
|
||||
Responde naturalmente y, si el usuario quiere hacer algo concreto, indica la acción y parámetros.
|
||||
|
||||
ACCIONES DISPONIBLES:
|
||||
- recarga.iniciar → monto (int, opcional si lo mencionó)
|
||||
- servicios.listar → servicio (nombre del servicio si lo mencionó, ej: "Netflix")
|
||||
- promo.listar → (sin parámetros)
|
||||
- credenciales.listar → servicio (nombre si lo mencionó)
|
||||
- historial.ver → (sin parámetros)
|
||||
- perfil.ver → (sin parámetros)
|
||||
|
||||
Responde SOLO con JSON válido:
|
||||
{"respuesta": "...", "accion": null, "accion_data": {}}
|
||||
|
||||
Ejemplo — "quiero comprar Netflix":
|
||||
{"respuesta": "¡Claro! Tenemos Netflix individual y compartido. Te muestro las opciones 👇", "accion": "servicios.listar", "accion_data": {"servicio": "Netflix"}}
|
||||
|
||||
Ejemplo — "recargar 50 mil":
|
||||
{"respuesta": "Perfecto, voy a iniciar una recarga de \$50.000 para ti 💰", "accion": "recarga.iniciar", "accion_data": {"monto": 50000}}
|
||||
|
||||
Ejemplo — "¿cuánto vale Spotify?":
|
||||
{"respuesta": "Spotify vale \$X por 30 días. ¿Quieres verlo?", "accion": "servicios.listar", "accion_data": {"servicio": "Spotify"}}
|
||||
|
||||
Si no hay acción clara, "accion": null y solo responde la pregunta.
|
||||
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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user