378 lines
11 KiB
PHP
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';
|
|
}
|
|
}
|