diff --git a/services/AiBot.php b/services/AiBot.php index 800d755..50cf3f5 100644 --- a/services/AiBot.php +++ b/services/AiBot.php @@ -334,11 +334,11 @@ PROMPT; * Dado un mensaje de texto, decide si enrutar a un flow existente o responder en chat libre. * Devuelve: ['action'=>'route','key'=>'flow_key'] | ['action'=>'chat','text'=>'...'] */ - public static function routeOrChat(array $company, array $context, string $input): array + public static function routeOrChat(array $company, array $context, string $input, array $modulosPermitidos = []): array { $config = self::getConfig($company); $permType = (int)($context['permission_type'] ?? 1); - $catalog = self::buildFlowCatalog($config, $permType); + $catalog = self::buildFlowCatalog($config, $permType, $modulosPermitidos); if (empty($catalog)) { return ['action' => 'chat', 'text' => '']; @@ -405,7 +405,7 @@ PROMPT; return $crudo; } - private static function buildFlowCatalog(array $config, int $permType): array + private static function buildFlowCatalog(array $config, int $permType, array $modulosPermitidos = []): array { $flows = $config['flows'] ?? []; $menus = $config['menus'] ?? []; @@ -446,6 +446,13 @@ PROMPT; // Flujos marcados como internos — no exponer al NLU if (!empty($flow['nlu_skip'])) continue; + // El perfil del número acota qué módulos puede usar: lo que el menú + // no muestra, el NLU tampoco lo ofrece + if ($modulosPermitidos) { + $mod = NormalBot::moduloDeFlow($key, $flow); + if ($mod !== null && !in_array($mod, $modulosPermitidos, true)) continue; + } + // Skip internal/navigation flows unless they have an explicit NLU description $hasNluDesc = isset($flow['nlu_description']) && $flow['nlu_description'] !== ''; if (in_array($type, ['text', 'image'], true) && !$isUpload && !$isDownload && !$hasNluDesc) continue; diff --git a/services/NormalBot.php b/services/NormalBot.php index 0b7d343..8fc9931 100644 --- a/services/NormalBot.php +++ b/services/NormalBot.php @@ -124,7 +124,8 @@ class NormalBot // 6. NLU routing — si está habilitado, intenta mapear texto libre a un flow antes del fallback if (!$suppressFallback && ($config['nlu_enabled'] ?? false)) { $context['permission_type'] ??= (int)$permType; - $route = AiBot::routeOrChat($company, $context, $input); + $permitidosNlu = self::modulosPermitidos($company, $context['from'] ?? ''); + $route = AiBot::routeOrChat($company, $context, $input, $permitidosNlu); if ($route['action'] === 'route' && isset($route['key'])) { $key = $route['key']; // reset_finca: limpiar finca del meta y relanzar ask_finca @@ -143,7 +144,12 @@ class NormalBot $m['__nlu_entities'] = $route['entities']; } ConversationContext::updateMetadata($ctxId, $m); - if (isset($flows[$key])) { + // El catálogo ya viene filtrado, pero el modelo puede devolver + // una clave que no le ofrecieron: se verifica igual acá. + $moduloRuta = isset($flows[$key]) ? self::moduloDeFlow($key, $flows[$key]) : null; + $vetado = $permitidosNlu && $moduloRuta !== null && !in_array($moduloRuta, $permitidosNlu, true); + + if (!$vetado && isset($flows[$key])) { ConversationContext::updateNode($ctxId, $key); return self::handleFlow($flows[$key], $context, $company, $ctxId, $menus, $key); } @@ -479,6 +485,41 @@ class NormalBot ]; } + /** + * A qué módulo pertenece un flow. Por convención de nombre para no tener + * que etiquetar los ~40 informes uno por uno; 'modulo' explícito manda. + * Null = sin módulo: navegación, finca, etc. — nunca se restringe. + */ + public static function moduloDeFlow(string $key, array $flow): ?string + { + if (!empty($flow['modulo'])) return $flow['modulo']; + if (str_starts_with($key, 'registrar_labor')) return 'labores'; + $mapa = [ + 'registrar_pluviometria' => 'pluviometria', + 'registrar_ausentismo' => 'ausentismos', + 'registrar_ciclo' => 'ciclos', + 'registrar_mantenimiento'=> 'mantenimiento', + 'submenu_produccion' => 'produccion', + 'submenu_ausentismos' => 'ausentismos', + ]; + if (isset($mapa[$key])) return $mapa[$key]; + if (str_starts_with($key, 'produccion_')) return 'produccion'; + if (str_starts_with($key, 'ausentismos_')) return 'ausentismos'; + if (str_starts_with($key, 'ciclo_') || str_starts_with($key, 'submenu_ciclo')) return 'ciclos'; + return null; + } + + /** Módulos habilitados del número, aplanados. Vacío = sin restricción. */ + private static function modulosPermitidos(array $company, string $waNumber): array + { + $perfil = self::perfilDelNumero($company, $waNumber); + if (!$perfil['modulos']) return []; + return array_merge( + (array)($perfil['modulos']['descarga'] ?? []), + (array)($perfil['modulos']['carga'] ?? []) + ); + } + /** * Quita del menu las filas de modulos que este numero no tiene habilitados. * Sin modulos declarados no se filtra nada: la ausencia significa "todos", diff --git a/setup/tests/bootstrap.php b/setup/tests/bootstrap.php index ac4b035..10c05db 100644 --- a/setup/tests/bootstrap.php +++ b/setup/tests/bootstrap.php @@ -153,8 +153,11 @@ class AiBot /** Cola de respuestas para routeOrChat; vacía = chat vacío (cae al fallback). */ public static array $respuestas = []; - public static function routeOrChat(array $company, array $context, string $input): array + public static array $modulosRecibidos = []; + + public static function routeOrChat(array $company, array $context, string $input, array $modulosPermitidos = []): array { + self::$modulosRecibidos = $modulosPermitidos; return array_shift(self::$respuestas) ?? ['action' => 'chat', 'text' => '']; } } diff --git a/setup/tests/test_flujos.php b/setup/tests/test_flujos.php index 5cdbeda..2040388 100644 --- a/setup/tests/test_flujos.php +++ b/setup/tests/test_flujos.php @@ -212,6 +212,44 @@ $get = array_values(array_filter(capturas(), fn($c) => str_contains($c['peticion check('la entity resolvió el grupo en la URL', $get['query']['grupo'] ?? null, '5'); check('y la consulta dice quién pregunta', $get['query']['wa'] ?? null, '57300NLU'); +// ════ 8. NLU coherente con los módulos del perfil ═════════════════════════════ +echo "\nNLU — respeta los módulos habilitados\n"; + +// Este número solo puede cargar pluviometría +fakePhone('57300SOLOPLUVIO', null, 0); +FakeDb::$phones['57300SOLOPLUVIO']['modulos_json'] = json_encode(['carga' => ['pluviometria']]); + +$ctx = contexto('57300SOLOPLUVIO'); +$id = ConversationContext::getOrCreate(1, '57300SOLOPLUVIO')['id']; +ConversationContext::updateMetadata($id, ['finca' => ['finca_id' => '4', 'finca_label' => 'REPOSO']]); +ConversationContext::updateNode($id, '__greeted'); + +// "subir pluviometría" → directo a la fecha, un solo mensaje +AiBot::$respuestas = [['action' => 'route', 'key' => 'registrar_pluviometria']]; +$r = NormalBot::process($co, $ctx, 'subir pluviometría'); +check('"subir pluviometría" va de una a la fecha', str_contains(textoDe($r), 'De qué fecha')); +check('el NLU recibió su alcance de módulos', AiBot::$modulosRecibidos, ['pluviometria']); + +// El modelo devuelve una clave fuera del alcance: el guard la corta +ConversationContext::updateNode($id, '__greeted'); +ConversationContext::updateMetadata($id, ['finca' => ['finca_id' => '4', 'finca_label' => 'REPOSO']]); +WhatsAppSender::$enviados = []; +AiBot::$respuestas = [['action' => 'route', 'key' => 'registrar_ausentismo']]; +$r = NormalBot::process($co, $ctx, 'registrar ausentismo'); +check('un módulo vetado no ejecuta el flujo', !str_contains(textoDe($r), 'nombre o documento')); +check('avisa que no entendió', + str_contains(WhatsAppSender::$enviados[0]['text'] ?? '', 'No estoy seguro')); + +// Sin restricción, "subir labores diarias" también entra directo +$ctx = contexto('57300LABORNLU'); +$id = ConversationContext::getOrCreate(1, '57300LABORNLU')['id']; +ConversationContext::updateMetadata($id, ['finca' => ['finca_id' => '4', 'finca_label' => 'REPOSO']]); +ConversationContext::updateNode($id, '__greeted'); +AiBot::$respuestas = [['action' => 'route', 'key' => 'registrar_labor']]; +$r = NormalBot::process($co, $ctx, 'subir labores diarias'); +check('"subir labores diarias" arranca el flujo directo', str_contains(textoDe($r), 'De qué fecha es la labor')); +capturas(); + // ════ Resultado ═══════════════════════════════════════════════════════════════ echo "\n" . ($GLOBALS['fallas'] ? "{$GLOBALS['fallas']} falla(s)\n" : "Todos los flujos ejecutan de punta a punta\n"); exit($GLOBALS['fallas'] ? 1 : 0);