Files
whatsapp/services/NLPService.php
2026-01-13 01:48:33 -05:00

378 lines
11 KiB
PHP

<?php
/**
* Servicio de Procesamiento de Lenguaje Natural (NLP)
* Detecta intenciones y palabras clave en los mensajes
*/
class NLPService
{
private $db;
public function __construct()
{
$this->db = Database::getInstance();
}
/**
* Procesa un mensaje y encuentra la mejor respuesta automática
* @param string $message
* @param string $phoneNumber
* @return array|null ['type' => 'autoresponse|menu|transfer', 'data' => array]
*/
public function processMessage(string $message, string $phoneNumber): ?array
{
// Normalizar mensaje
$normalizedMessage = $this->normalizeText($message);
// 1. Verificar si es una opción de menú numérica
if (is_numeric(trim($message))) {
return $this->processMenuOption($message, $phoneNumber);
}
// 2. Buscar respuesta automática por palabra clave
$response = $this->findAutoResponse($normalizedMessage);
if ($response) {
return $this->formatResponse($response);
}
// 3. Si no encuentra nada, devolver respuesta por defecto
return $this->getDefaultResponse();
}
/**
* Normaliza el texto para comparación
* @param string $text
* @return string
*/
private function normalizeText(string $text): string
{
// Convertir a minúsculas
$text = mb_strtolower($text, 'UTF-8');
// Remover acentos
$text = $this->removeAccents($text);
// Remover emojis y caracteres especiales (excepto espacios y comas)
$text = preg_replace('/[^\p{L}\p{N}\s,]/u', '', $text);
// Remover espacios extras
$text = preg_replace('/\s+/', ' ', $text);
return trim($text);
}
/**
* Remueve acentos de un texto
* @param string $text
* @return string
*/
private function removeAccents(string $text): string
{
$accents = [
'á' => 'a', 'é' => 'e', 'í' => 'i', 'ó' => 'o', 'ú' => 'u',
'Á' => 'a', 'É' => 'e', 'Í' => 'i', 'Ó' => 'o', 'Ú' => 'u',
'ñ' => 'n', 'Ñ' => 'n', 'ü' => 'u', 'Ü' => 'u'
];
return strtr($text, $accents);
}
/**
* Busca la mejor respuesta automática para el mensaje
* @param string $normalizedMessage
* @return array|null
*/
private function findAutoResponse(string $normalizedMessage): ?array
{
// Obtener todas las respuestas automáticas activas ordenadas por prioridad
$stmt = $this->db->prepare("
SELECT * FROM autoresponses
WHERE is_active = 1
AND trigger_type != 'welcome'
ORDER BY priority DESC, id ASC
");
$stmt->execute();
$autoResponses = $stmt->fetchAll(PDO::FETCH_ASSOC);
foreach ($autoResponses as $response) {
if ($this->matchesTrigger($normalizedMessage, $response)) {
return $response;
}
}
return null;
}
/**
* Verifica si un mensaje coincide con un trigger
* @param string $message
* @param array $response
* @return bool
*/
private function matchesTrigger(string $message, array $response): bool
{
$triggerType = $response['trigger_type'];
$triggerValue = $response['trigger_value'];
switch ($triggerType) {
case 'keyword':
return $this->matchesKeywords($message, $triggerValue);
case 'contains':
return $this->containsPhrase($message, $triggerValue);
case 'exact':
return $this->exactMatch($message, $triggerValue);
case 'default':
return true; // Siempre coincide (pero tiene prioridad baja)
default:
return false;
}
}
/**
* Verifica si el mensaje contiene alguna palabra clave
* @param string $message
* @param string $keywordsString Palabras separadas por comas
* @return bool
*/
private function matchesKeywords(string $message, string $keywordsString): bool
{
if (empty($keywordsString)) {
return false;
}
$keywords = array_map('trim', explode(',', $keywordsString));
$keywords = array_map([$this, 'normalizeText'], $keywords);
$messageWords = explode(' ', $message);
foreach ($keywords as $keyword) {
// Verificar si la palabra clave completa está en el mensaje
if (strpos($message, $keyword) !== false) {
return true;
}
// O si alguna palabra del mensaje coincide exactamente
if (in_array($keyword, $messageWords)) {
return true;
}
}
return false;
}
/**
* Verifica si el mensaje contiene una frase
* @param string $message
* @param string $phrase
* @return bool
*/
private function containsPhrase(string $message, string $phrase): bool
{
$normalizedPhrase = $this->normalizeText($phrase);
return strpos($message, $normalizedPhrase) !== false;
}
/**
* Verifica coincidencia exacta
* @param string $message
* @param string $exact
* @return bool
*/
private function exactMatch(string $message, string $exact): bool
{
$normalizedExact = $this->normalizeText($exact);
return $message === $normalizedExact;
}
/**
* Procesa una opción numérica de menú
* @param string $option
* @param string $phoneNumber
* @return array|null
*/
private function processMenuOption(string $option, string $phoneNumber): ?array
{
$stateService = new ConversationStateService();
$currentMenuId = $stateService->getCurrentMenuId($phoneNumber);
if (!$currentMenuId) {
// Si no hay menú actual, buscar el menú principal
$stmt = $this->db->prepare("
SELECT id FROM menus
WHERE menu_key = 'lab_menu_principal'
AND status = 'active'
LIMIT 1
");
$stmt->execute();
$menu = $stmt->fetch(PDO::FETCH_ASSOC);
$currentMenuId = $menu ? $menu['id'] : null;
}
if (!$currentMenuId) {
return null;
}
// Buscar la opción en el menú actual
$stmt = $this->db->prepare("
SELECT * FROM menu_options
WHERE menu_id = ?
AND option_key = ?
AND is_active = 1
LIMIT 1
");
$stmt->execute([$currentMenuId, trim($option)]);
$menuOption = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$menuOption) {
return null;
}
return $this->formatMenuOption($menuOption);
}
/**
* Formatea una respuesta automática
* @param array $response
* @return array
*/
private function formatResponse(array $response): array
{
$result = [
'type' => $response['response_type'],
'priority' => $response['priority']
];
switch ($response['response_type']) {
case 'text':
$result['message'] = $response['response_text'];
break;
case 'menu':
$result['menu_id'] = $response['menu_id'];
break;
case 'template':
$result['template_name'] = $response['template_name'] ?? null;
break;
case 'transfer':
$result['message'] = $response['response_text'];
$result['transfer'] = true;
break;
}
return $result;
}
/**
* Formatea una opción de menú
* @param array $menuOption
* @return array
*/
private function formatMenuOption(array $menuOption): array
{
$result = [
'type' => $menuOption['response_type']
];
switch ($menuOption['response_type']) {
case 'text':
$result['message'] = $menuOption['response_text'];
break;
case 'menu':
$result['menu_id'] = $menuOption['next_menu_id'];
break;
case 'transfer':
$result['message'] = $menuOption['response_text'];
$result['transfer'] = true;
break;
}
return $result;
}
/**
* Obtiene la respuesta por defecto
* @return array
*/
private function getDefaultResponse(): array
{
$stmt = $this->db->prepare("
SELECT * FROM autoresponses
WHERE trigger_type = 'default'
AND is_active = 1
ORDER BY priority DESC
LIMIT 1
");
$stmt->execute();
$response = $stmt->fetch(PDO::FETCH_ASSOC);
if ($response) {
return $this->formatResponse($response);
}
// Respuesta fallback hardcoded
return [
'type' => 'text',
'message' => "❓ No he comprendido tu mensaje.\n\nEscribe MENÚ para ver las opciones disponibles."
];
}
/**
* Detecta el sentimiento del mensaje (básico)
* @param string $message
* @return string positive|negative|neutral
*/
public function detectSentiment(string $message): string
{
$normalized = $this->normalizeText($message);
// Palabras positivas
$positiveWords = ['gracias', 'excelente', 'perfecto', 'bien', 'bueno', 'genial', 'ok', 'vale'];
// Palabras negativas / frustración
$negativeWords = ['no funciona', 'problema', 'error', 'mal', 'ayuda', 'no entiendo', 'dificil', 'complicado'];
$positiveCount = 0;
$negativeCount = 0;
foreach ($positiveWords as $word) {
if (strpos($normalized, $word) !== false) {
$positiveCount++;
}
}
foreach ($negativeWords as $word) {
if (strpos($normalized, $word) !== false) {
$negativeCount++;
}
}
if ($negativeCount > $positiveCount) {
return 'negative';
} elseif ($positiveCount > 0) {
return 'positive';
}
return 'neutral';
}
/**
* Determina si el mensaje indica frustración (para escalar a asesor)
* @param string $message
* @return bool
*/
public function detectsFrustration(string $message): bool
{
return $this->detectSentiment($message) === 'negative';
}
}