From 0e89258f67befb5071b3f7d7b7ddb33bb3359bb7 Mon Sep 17 00:00:00 2001 From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com> Date: Sat, 1 Aug 2026 09:59:30 -0500 Subject: [PATCH] feat: navegacion por niveles, intencion pendiente y NLU que aterriza en el informe - "atras"/"volver"/"regresar" suben un nivel via mapa 'back' declarativo en el seed, en vez de saltar siempre al menu principal. - "finca"/"cambiar finca" como comandos directos a reset_finca. - requires/resolver: si el NLU rutea a un informe sin el dato que necesita, el bot lo pide y vuelve al informe original, no al submenu. Asi "informe de mantenimiento de plateo" entrega el PDF sin pasos intermedios. - ciclo_mantenimiento consulta el catalogo completo cuando hay entities: los grupos fuera del top 3 tambien matchean. - ask_finca con skip_if_set: deja de repreguntar la finca despues de cada informe; para cambiarla esta reset_finca. - Boton "Otro grupo" en el submenu de mantenimiento. - Fix: el auto-select por entities no mergeaba per_type y mandaba a cat 3 al menu equivocado al nombrar una finca. validate_config.php verifica el grafo (back/resolver/botones/endpoints) y test_navegacion.php recorre en seco los escenarios de ruteo. Co-Authored-By: Claude Sonnet 4.6 --- services/AiBot.php | 7 ++ services/NormalBot.php | 216 ++++++++++++++++++++++++++------------ setup/seed_palmas.php | 88 +++++++++++----- setup/test_navegacion.php | 128 ++++++++++++++++++++++ setup/validate_config.php | 91 ++++++++++++++++ 5 files changed, 438 insertions(+), 92 deletions(-) create mode 100644 setup/test_navegacion.php create mode 100644 setup/validate_config.php diff --git a/services/AiBot.php b/services/AiBot.php index bd12d21..216f565 100644 --- a/services/AiBot.php +++ b/services/AiBot.php @@ -449,6 +449,13 @@ PROMPT; . "{\"action\":\"route\",\"key\":\"\",\"entities\":{\"campo\":\"valor\",...}}\n\n" . "En 'entities' incluye los datos que el usuario ya mencionó: nombre de finca, grupo, filtro, fecha, valor numérico, etc. Usa el nombre tal como lo dijo el usuario.\n" . "Si no mencionó datos extra, omite entities o usa {}.\n\n" + . "IMPORTANTE — elegí siempre la opción más específica que cubra lo que pidió.\n" + . "Si nombra el informe Y un filtro, ruteá al informe concreto y poné el filtro en entities; nunca al selector intermedio. El sistema resuelve el filtro solo.\n" + . "Ejemplos:\n" + . " \"quiero el informe de mantenimiento de plateo\" → {\"action\":\"route\",\"key\":\"ciclo_mantenimiento_todos\",\"entities\":{\"grupo\":\"plateo\"}}\n" + . " \"cuáles lotes llevan más tiempo sin corona\" → {\"action\":\"route\",\"key\":\"ciclo_mantenimiento_top\",\"entities\":{\"grupo\":\"corona\"}}\n" + . " \"quiero cambiar de finca\" → {\"action\":\"route\",\"key\":\"reset_finca\"}\n" + . " \"mantenimiento\" (sin grupo) → {\"action\":\"route\",\"key\":\"ciclo_mantenimiento\"}\n\n" . "Si no está claro o no hay opción correspondiente → responde SOLO:\n" . "{\"action\":\"chat\",\"text\":\"\"}\n\n" . "No inventes keys. Usa exactamente los keys de la lista."; diff --git a/services/NormalBot.php b/services/NormalBot.php index acd2787..6de1b81 100644 --- a/services/NormalBot.php +++ b/services/NormalBot.php @@ -30,6 +30,13 @@ class NormalBot } if ($dirty) ConversationContext::updateMetadata($ctxId, $m); + // "atrás" sube un nivel según el mapa 'back' del nodo actual. + // Sin nodo o sin mapa (p.ej. tras un informe) cae al menú principal. + if ($action === '__back') { + $action = ($currentNode !== null ? ($flows[$currentNode]['back'] ?? null) : null) + ?? 'show_main_menu'; + } + // "menu/inicio/volver" → prefer per-type greeting_menu over global show_main_menu $greetingMenuKey = $perType['greeting_menu'] ?? null; if ($action === 'show_main_menu' && $greetingMenuKey && isset($menus[$greetingMenuKey])) { @@ -39,7 +46,7 @@ class NormalBot ConversationContext::updateNode($ctxId, $action); // Commands that match a flow if (isset($flows[$action])) { - return self::handleFlow($flows[$action], $context, $company, $ctxId, $menus); + return self::handleFlow($flows[$action], $context, $company, $ctxId, $menus, $action); } // Commands that match a menu if (isset($menus[$action])) { @@ -76,7 +83,7 @@ class NormalBot if ($currentNode !== null && !in_array($currentNode, $sentinels, true) && isset($flows[$currentNode])) { $isMenuNode = ($flows[$currentNode]['type'] ?? '') === 'menu'; if (!$isMenuNode) { - return self::handleFlow($flows[$currentNode], $context, $company, $ctxId, $menus); + return self::handleFlow($flows[$currentNode], $context, $company, $ctxId, $menus, $currentNode); } } @@ -100,7 +107,7 @@ class NormalBot $greetingFlowId = $perType['greeting_flow'] ?? 'greeting'; if (isset($flows[$greetingFlowId])) { ConversationContext::updateNode($ctxId, $greetingFlowId); - return self::handleFlow($flows[$greetingFlowId], $context, $company, $ctxId, $menus); + return self::handleFlow($flows[$greetingFlowId], $context, $company, $ctxId, $menus, $greetingFlowId); } $response = self::sendText($greeting, $context['from'], $company); ConversationContext::updateNode($ctxId, null); @@ -128,7 +135,7 @@ class NormalBot } if (isset($flows[$key])) { ConversationContext::updateNode($ctxId, $key); - return self::handleFlow($flows[$key], $context, $company, $ctxId, $menus); + return self::handleFlow($flows[$key], $context, $company, $ctxId, $menus, $key); } if (isset($menus[$key])) { ConversationContext::updateNode($ctxId, $key); @@ -146,14 +153,14 @@ class NormalBot // Re-show current menu when NLU didn't route anything if ($currentNode !== null && !in_array($currentNode, $sentinels, true) && isset($flows[$currentNode])) { - return self::handleFlow($flows[$currentNode], $context, $company, $ctxId, $menus); + return self::handleFlow($flows[$currentNode], $context, $company, $ctxId, $menus, $currentNode); } // 7. Fallback flow $fallbackFlowId = $perType['fallback_flow'] ?? $config['fallback_flow'] ?? null; if ($fallbackFlowId !== null && isset($flows[$fallbackFlowId])) { ConversationContext::updateNode($ctxId, $fallbackFlowId); - return self::handleFlow($flows[$fallbackFlowId], $context, $company, $ctxId, $menus); + return self::handleFlow($flows[$fallbackFlowId], $context, $company, $ctxId, $menus, $fallbackFlowId); } // 8. Greeting menu as fallback (texto no reconocido después de estar en __greeted) @@ -215,7 +222,7 @@ class NormalBot ConversationContext::updateMetadata($ctxId, $meta); ConversationContext::updateNode($ctxId, $resolved); if (isset($flows[$resolved])) { - return self::handleFlow($flows[$resolved], $context, $company, $ctxId, $menus); + return self::handleFlow($flows[$resolved], $context, $company, $ctxId, $menus, $resolved); } return null; } @@ -229,7 +236,7 @@ class NormalBot if ($nextNode !== null) { ConversationContext::updateNode($ctxId, $nextNode); if (isset($flows[$nextNode])) { - return self::handleFlow($flows[$nextNode], $context, $company, $ctxId, $menus); + return self::handleFlow($flows[$nextNode], $context, $company, $ctxId, $menus, $nextNode); } if (isset($menus[$nextNode])) { return self::buildMenuResponse($menus[$nextNode], $context['from'], $company); @@ -257,8 +264,26 @@ class NormalBot private static function handleDynamicList(array $flow, array $context, array $company, int $ctxId): ?array { - $meta = ConversationContext::getMetadata($ctxId); - $items = self::fetchDynamicList($flow['source_endpoint_key'] ?? '', $company, $meta); + $meta = ConversationContext::getMetadata($ctxId); + $valueField = $flow['value_field'] ?? 'id'; + $labelField = $flow['label_field'] ?? 'name'; + $group = $flow['meta_group'] ?? ''; + $key = $flow['meta_key'] ?? ''; + // La intención pendiente manda sobre next_node: el usuario ya dijo a dónde iba. + $nextNode = $meta['__after_select'] ?? $flow['next_node'] ?? null; + + // Ya está elegido → no volver a preguntar (para cambiarlo está reset_finca). + if (!empty($flow['skip_if_set']) && $group !== '' && ($meta[$group][$key] ?? '') !== '') { + return self::advanceAfterSelect($nextNode, $context, $company, $ctxId); + } + + // Con entities del NLU se consulta el catálogo completo, no el top recortado: + // "plateo" puede no estar entre los grupos más frecuentes. + $srcKey = $flow['source_endpoint_key'] ?? ''; + if (!empty($meta['__nlu_entities']) && !empty($flow['source_endpoint_key_all'])) { + $srcKey = $flow['source_endpoint_key_all']; + } + $items = self::fetchDynamicList($srcKey, $company, $meta); if ($items === null || count($items) === 0) { ConversationContext::reset($ctxId); @@ -268,12 +293,6 @@ class NormalBot ); } - $valueField = $flow['value_field'] ?? 'id'; - $labelField = $flow['label_field'] ?? 'name'; - $group = $flow['meta_group'] ?? ''; - $key = $flow['meta_key'] ?? ''; - $nextNode = $flow['next_node'] ?? null; - // Mapa label → id para match fuzzy (usado tanto en auto-select como en collecting) $labelMap = []; foreach ($items as $item) { @@ -282,36 +301,23 @@ class NormalBot // Auto-select cuando hay exactamente 1 opción — no mostrar lista if (count($items) === 1) { - $item = $items[0]; $formData = $meta[$group] ?? []; - $formData[$key] = (string)($item[$valueField] ?? ''); + $formData[$key] = (string)($items[0][$valueField] ?? ''); $meta[$group] = $formData; - unset($meta['__nlu_entities']); ConversationContext::updateMetadata($ctxId, $meta); - if ($nextNode !== null) { - ConversationContext::updateNode($ctxId, $nextNode); - $cfg = self::getConfig($company); - $permType = (string)($company['_permission_type'] ?? 1); - $pt = $cfg['per_type'][$permType] ?? []; - $allFlows = array_merge($cfg['flows'] ?? [], $pt['flows'] ?? []); - $allMenus = array_merge($cfg['menus'] ?? [], $pt['menus'] ?? []); - if (isset($allFlows[$nextNode])) { - return self::handleFlow($allFlows[$nextNode], $context, $company, $ctxId, $allMenus); - } - } - ConversationContext::updateNode($ctxId, null); - return null; + return self::advanceAfterSelect($nextNode, $context, $company, $ctxId); } // Si el NLU extrajo entities, intentar auto-seleccionar sin mostrar lista $entities = $meta['__nlu_entities'] ?? []; - unset($meta['__nlu_entities']); if (!empty($entities)) { $matchedId = null; foreach ($entities as $ev) { $evNorm = self::normalize((string)$ev); + if ($evNorm === '') continue; foreach ($labelMap as $label => $id) { $lNorm = self::normalize($label); + if ($lNorm === '') continue; if ($lNorm === $evNorm || str_contains($evNorm, $lNorm) || str_contains($lNorm, $evNorm)) { $matchedId = $id; break 2; @@ -323,19 +329,10 @@ class NormalBot $formData[$key] = $matchedId; $meta[$group] = $formData; ConversationContext::updateMetadata($ctxId, $meta); - if ($nextNode !== null) { - ConversationContext::updateNode($ctxId, $nextNode); - $config = json_decode($company['config_json'] ?? '{}', true); - $flows = $config['flows'] ?? []; - $menus = $config['menus'] ?? []; - if (isset($flows[$nextNode])) { - return self::handleFlow($flows[$nextNode], $context, $company, $ctxId, $menus); - } - } - ConversationContext::updateNode($ctxId, null); - return null; + return self::advanceAfterSelect($nextNode, $context, $company, $ctxId); } - // Sin match: continuar mostrando la lista normal + // Sin match: se descartan las entities y se muestra la lista normal + unset($meta['__nlu_entities']); ConversationContext::updateMetadata($ctxId, $meta); } @@ -349,6 +346,9 @@ class NormalBot $redirectIds[] = $optId; } + // El destino ya quedó capturado en collecting.next_node; la intención se + // consume acá para que no secuestre la siguiente lista. + unset($meta['__after_select']); $meta['collecting'] = [ 'meta_group' => $group, 'meta_key' => $key, @@ -363,6 +363,31 @@ class NormalBot return self::buildDynamicListResponse($items, $flow, $context['from'], $company); } + /** + * Continúa tras resolver una selección: consume la intención pendiente y + * las entities, y ejecuta el destino. + */ + private static function advanceAfterSelect(?string $nextNode, array $context, array $company, int $ctxId): ?array + { + $meta = ConversationContext::getMetadata($ctxId); + unset($meta['__after_select'], $meta['__nlu_entities']); + ConversationContext::updateMetadata($ctxId, $meta); + + if ($nextNode !== null) { + [$flows, $menus] = self::resolveFlowsMenus($company); + ConversationContext::updateNode($ctxId, $nextNode); + if (isset($flows[$nextNode])) { + return self::handleFlow($flows[$nextNode], $context, $company, $ctxId, $menus, $nextNode); + } + if (isset($menus[$nextNode])) { + return self::buildMenuResponse($menus[$nextNode], $context['from'], $company); + } + } + + ConversationContext::updateNode($ctxId, null); + return null; + } + // Reemplaza {variable} en la URL con valores del metadata acumulado private static function substituteUrlVars(string $url, array $meta): string { @@ -1096,10 +1121,69 @@ class NormalBot return is_array($menuRef) ? $menuRef : []; } - private static function handleFlow(array $flow, array $context, array $company, int $ctxId, array $menus = []): ?array + /** + * Flows y menús efectivos del usuario (globales + overrides de su categoría). + * Centraliza el merge que antes se repetía —y en un caso se olvidaba— en varios puntos. + */ + private static function resolveFlowsMenus(array $company): array + { + $cfg = self::getConfig($company); + $permType = (string)($company['_permission_type'] ?? 1); + $pt = $cfg['per_type'][$permType] ?? []; + return [ + array_merge($cfg['flows'] ?? [], $pt['flows'] ?? []), + array_merge($cfg['menus'] ?? [], $pt['menus'] ?? []), + ]; + } + + /** + * Si el flow necesita un dato que aún no está en meta, guarda la intención y + * devuelve el selector que lo resuelve. Al elegir, handleDynamicList retoma + * el destino original en vez de su next_node. + */ + private static function divertToResolver(array $flow, ?string $flowKey, array $context, array $company, int $ctxId, array $flows, array $menus): ?array + { + $requires = $flow['requires'] ?? []; + $resolver = $flow['resolver'] ?? ''; + if (!$requires || $resolver === '' || $flowKey === null || !isset($flows[$resolver])) return null; + + $meta = ConversationContext::getMetadata($ctxId); + + foreach ($requires as $group => $key) { + if (($meta[$group][$key] ?? '') !== '') continue; + + // Un solo intento por destino: si el resolver ya corrió y aun así falta + // el dato (mal configurado), seguir de largo en vez de rebotar sin fin. + if (($meta['__resolve_attempt'] ?? '') === $flowKey) { + self::log("Resolver '{$resolver}' no dejó {$group}.{$key} para '{$flowKey}'"); + return null; + } + + $meta['__after_select'] = $flowKey; + $meta['__resolve_attempt'] = $flowKey; + ConversationContext::updateMetadata($ctxId, $meta); + ConversationContext::updateNode($ctxId, $resolver); + return self::handleFlow($flows[$resolver], $context, $company, $ctxId, $menus, $resolver); + } + + // Requisitos cumplidos: se limpia la marca para el próximo informe + if (isset($meta['__resolve_attempt'])) { + unset($meta['__resolve_attempt']); + ConversationContext::updateMetadata($ctxId, $meta); + } + return null; + } + + private static function handleFlow(array $flow, array $context, array $company, int $ctxId, array $menus = [], ?string $flowKey = null): ?array { $type = $flow['type'] ?? 'text'; + if (!empty($flow['requires'])) { + [$allFlows, $allMenus] = self::resolveFlowsMenus($company); + $diverted = self::divertToResolver($flow, $flowKey, $context, $company, $ctxId, $allFlows, $allMenus); + if ($diverted !== null) return $diverted; + } + return match ($type) { 'text' => self::sendText($flow['message'] ?? '', $context['from'], $company), 'image' => self::sendImage($flow['media_id'] ?? '', $flow['caption'] ?? null, $context['from'], $company), @@ -1127,33 +1211,35 @@ class NormalBot ConversationContext::reset($ctxId); return self::sendText("Hasta luego 👋\n\nEscribe *menu* cuando quieras volver.", $context['from'], $company); })(), - 'reset_finca' => (function () use ($ctxId, $context, $company) { - $m = ConversationContext::getMetadata($ctxId); - unset($m['finca']); - ConversationContext::updateMetadata($ctxId, $m); - $cfg = self::getConfig($company); - $permType = (string)($company['_permission_type'] ?? 1); - $pt = $cfg['per_type'][$permType] ?? []; - $allFlows = array_merge($cfg['flows'] ?? [], $pt['flows'] ?? []); - $allMenus = array_merge($cfg['menus'] ?? [], $pt['menus'] ?? []); - ConversationContext::updateNode($ctxId, 'ask_finca'); - return isset($allFlows['ask_finca']) - ? self::handleFlow($allFlows['ask_finca'], $context, $company, $ctxId, $allMenus) - : self::sendText('¿En qué finca trabajas?', $context['from'], $company); - })(), + 'reset_finca' => self::reAsk($ctxId, $context, $company, 'finca', 'ask_finca', '¿En qué finca trabajas?'), + 'reset_grupo_mant' => self::reAsk($ctxId, $context, $company, 'grupo_mant', 'ciclo_mantenimiento', '¿Qué grupo de mantenimiento?'), 'forward_to_ai' => null, 'api_report' => self::executeApiReport($params, $context, $company, $ctxId), default => null, }; } + /** Olvida un valor ya elegido y vuelve a preguntarlo ("cambiar finca", "otro grupo"). */ + private static function reAsk(int $ctxId, array $context, array $company, string $group, string $flowKey, string $fallbackText): ?array + { + $meta = ConversationContext::getMetadata($ctxId); + unset($meta[$group], $meta['__after_select']); + ConversationContext::updateMetadata($ctxId, $meta); + + [$flows, $menus] = self::resolveFlowsMenus($company); + ConversationContext::updateNode($ctxId, $flowKey); + return isset($flows[$flowKey]) + ? self::handleFlow($flows[$flowKey], $context, $company, $ctxId, $menus, $flowKey) + : self::sendText($fallbackText, $context['from'], $company); + } + // Reset manteniendo grupos de sesión (finca, etc.) para que el usuario // no tenga que re-seleccionar su finca después de cada reporte. private static function preservingReset(int $ctxId, array $meta): void { $keep = []; - // grupo_mant se conserva: el submenú de mantenimiento ofrece varios informes - // sobre el mismo grupo, y perderlo dejaba {grupo_id} sin resolver. + // finca y grupo_mant se conservan: los submenús ofrecen varios informes sobre + // la misma selección, y perderla dejaba {finca_id}/{grupo_id} sin resolver. foreach (['finca', 'grupo_mant'] as $group) { if (isset($meta[$group])) $keep[$group] = $meta[$group]; } @@ -1515,7 +1601,7 @@ class NormalBot if (($row['id'] ?? '') === $input) { ConversationContext::updateNode($ctxId, $row['id']); if (isset($flows[$row['id']])) { - return self::handleFlow($flows[$row['id']], $context, $company, $ctxId, $menus); + return self::handleFlow($flows[$row['id']], $context, $company, $ctxId, $menus, $row['id']); } return null; } @@ -1525,7 +1611,7 @@ class NormalBot if (($btn['id'] ?? '') === $input) { ConversationContext::updateNode($ctxId, $btn['id']); if (isset($flows[$btn['id']])) { - return self::handleFlow($flows[$btn['id']], $context, $company, $ctxId, $menus); + return self::handleFlow($flows[$btn['id']], $context, $company, $ctxId, $menus, $btn['id']); } return null; } @@ -1541,7 +1627,7 @@ class NormalBot } if (isset($flows[$resolvedInput])) { ConversationContext::updateNode($ctxId, $resolvedInput); - return self::handleFlow($flows[$resolvedInput], $context, $company, $ctxId, $menus); + return self::handleFlow($flows[$resolvedInput], $context, $company, $ctxId, $menus, $resolvedInput); } if (isset($menus[$resolvedInput])) { ConversationContext::updateNode($ctxId, $resolvedInput); diff --git a/setup/seed_palmas.php b/setup/seed_palmas.php index 883e653..612892c 100644 --- a/setup/seed_palmas.php +++ b/setup/seed_palmas.php @@ -13,15 +13,20 @@ $configJson = [ 'fallback' => 'No entendí. Escribe *menu* para ver las opciones disponibles.', 'nlu_enabled' => true, 'commands' => [ - 'menu' => 'show_main_menu', - 'informes' => 'descargar_informes', - 'info' => 'enviar_informacion', - 'inicio' => 'show_main_menu', - 'volver' => 'show_main_menu', - 'salir' => 'show_main_menu', - 'atras' => 'show_main_menu', - 'cancelar' => 'show_main_menu', - 'regresar' => 'show_main_menu', + // Al menú principal + 'menu' => 'show_main_menu', + 'inicio' => 'show_main_menu', + 'salir' => 'show_main_menu', + 'cancelar' => 'show_main_menu', + // Un nivel arriba (__back lo resuelve con el mapa 'back' del nodo actual) + 'atras' => '__back', + 'volver' => '__back', + 'regresar' => '__back', + // Atajos + 'informes' => 'descargar_informes', + 'info' => 'enviar_informacion', + 'finca' => 'reset_finca', + 'cambiar finca' => 'reset_finca', ], // ── Menus globales ────────────────────────────────────────────────────── 'menus' => [ @@ -135,6 +140,7 @@ $configJson = [ 'submenu_ciclo_mantenimiento' => ['type'=>'button','body'=>'Ciclos de Mantenimiento — ¿Qué deseas ver?','buttons'=>[ ['id'=>'ciclo_mantenimiento_top', 'title'=>'🏆 Más largos'], ['id'=>'ciclo_mantenimiento_todos', 'title'=>'📋 Todos los lotes'], + ['id'=>'reset_grupo_mant', 'title'=>'🔄 Otro grupo'], ]], // Submenú sanidad vegetal (orden: Censo, Plagas, Rincofurus, Tratamiento) @@ -208,6 +214,7 @@ $configJson = [ 'meta_group' => 'finca', 'meta_key' => 'finca_id', 'next_node' => 'show_main_menu', + 'skip_if_set' => true, 'nlu_description' => 'Seleccionar o cambiar la finca de trabajo', ], @@ -218,6 +225,13 @@ $configJson = [ 'nlu_description' => 'Cambiar de finca o seleccionar una finca diferente', ], + // Vuelve a pedir el grupo de mantenimiento (el grupo persiste entre informes) + 'reset_grupo_mant' => [ + 'type' => 'function', + 'function' => 'reset_grupo_mant', + 'nlu_description' => 'Cambiar el grupo de mantenimiento (plateo, corona, poda, etc.)', + ], + // Producción (texto del mes + PDF encadenado) 'produccion_total' => ['type' => 'function', 'function' => 'api_report', 'nlu_description' => 'Producción total facturada del mes actual con PDF del año', 'params' => [ 'endpoint_key' => 'produccion_total_bot_dn', @@ -249,18 +263,19 @@ $configJson = [ ]], // Menus de sección (nlu_skip: navegación interna, no exponer al NLU) - 'submenu_produccion' => ['type' => 'menu', 'menu' => 'submenu_produccion', 'nlu_skip' => true], - 'submenu_ciclos' => ['type' => 'menu', 'menu' => 'submenu_ciclos', 'nlu_skip' => true], - 'submenu_ciclos_sanidad' => ['type' => 'menu', 'menu' => 'submenu_ciclos_sanidad', 'nlu_skip' => true], - 'submenu_ausentismos' => ['type' => 'menu', 'menu' => 'submenu_ausentismos', 'nlu_skip' => true], + // 'back' = a dónde sube el comando "atrás" desde cada nivel + 'submenu_produccion' => ['type' => 'menu', 'menu' => 'submenu_produccion', 'nlu_skip' => true, 'back' => 'show_main_menu'], + 'submenu_ciclos' => ['type' => 'menu', 'menu' => 'submenu_ciclos', 'nlu_skip' => true, 'back' => 'show_main_menu'], + 'submenu_ciclos_sanidad' => ['type' => 'menu', 'menu' => 'submenu_ciclos_sanidad', 'nlu_skip' => true, 'back' => 'submenu_ciclos'], + 'submenu_ausentismos' => ['type' => 'menu', 'menu' => 'submenu_ausentismos', 'nlu_skip' => true, 'back' => 'show_main_menu'], // Ciclos — cada uno abre un sub-menú de 3 opciones - 'ciclo_cosecha' => ['type' => 'menu', 'menu' => 'submenu_ciclo_cosecha', 'nlu_description' => 'Ver ciclos de cosecha (lotes más largos o todos)'], - 'ciclo_polinizacion' => ['type' => 'menu', 'menu' => 'submenu_ciclo_polinizacion', 'nlu_description' => 'Ver ciclos de polinización'], - 'ciclo_plagas' => ['type' => 'menu', 'menu' => 'submenu_ciclo_plagas', 'nlu_description' => 'Ver ciclos de plagas'], - 'ciclo_censo' => ['type' => 'menu', 'menu' => 'submenu_ciclo_censo', 'nlu_description' => 'Ver ciclos de censo'], - 'ciclo_palm' => ['type' => 'menu', 'menu' => 'submenu_ciclo_palm', 'nlu_description' => 'Ver ciclos de Palmarum (Rhynchophorus)'], - 'ciclo_tratamiento' => ['type' => 'menu', 'menu' => 'submenu_ciclo_tratamiento', 'nlu_description' => 'Ver ciclos de tratamientos'], + 'ciclo_cosecha' => ['type' => 'menu', 'menu' => 'submenu_ciclo_cosecha', 'back' => 'submenu_ciclos', 'nlu_description' => 'Ver ciclos de cosecha (lotes más largos o todos)'], + 'ciclo_polinizacion' => ['type' => 'menu', 'menu' => 'submenu_ciclo_polinizacion', 'back' => 'submenu_ciclos', 'nlu_description' => 'Ver ciclos de polinización'], + 'ciclo_plagas' => ['type' => 'menu', 'menu' => 'submenu_ciclo_plagas', 'back' => 'submenu_ciclos_sanidad', 'nlu_description' => 'Ver ciclos de plagas'], + 'ciclo_censo' => ['type' => 'menu', 'menu' => 'submenu_ciclo_censo', 'back' => 'submenu_ciclos_sanidad', 'nlu_description' => 'Ver ciclos de censo'], + 'ciclo_palm' => ['type' => 'menu', 'menu' => 'submenu_ciclo_palm', 'back' => 'submenu_ciclos_sanidad', 'nlu_description' => 'Ver ciclos de Palmarum (Rhynchophorus)'], + 'ciclo_tratamiento' => ['type' => 'menu', 'menu' => 'submenu_ciclo_tratamiento', 'back' => 'submenu_ciclos_sanidad', 'nlu_description' => 'Ver ciclos de tratamientos'], // Sub-opciones: cosecha 'ciclo_cosecha_top' => ['type'=>'function','function'=>'api_report','nlu_description'=>'Lotes de cosecha con ciclos más largos','params'=>['endpoint_key'=>'cosecha_top_texto_bot_dn','date_mode'=>'last_30']], @@ -296,6 +311,10 @@ $configJson = [ 'ciclo_mantenimiento' => [ 'type' => 'dynamic_list', 'source_endpoint_key' => 'mantenimiento_grupos_top3_dn', + // Con entities del NLU se busca en el catálogo completo: "plateo" puede + // no estar entre los 3 grupos más frecuentes. + 'source_endpoint_key_all' => 'mantenimiento_grupos_all_dn', + 'back' => 'submenu_ciclos', 'value_field' => 'id', 'label_field' => 'label', 'header' => '🔧 Mantenimiento', @@ -321,17 +340,31 @@ $configJson = [ 'nlu_skip' => true, ], - // Sub-opciones: mantenimiento - 'ciclo_mantenimiento_top' => ['type'=>'function','function'=>'api_report','nlu_description'=>'Lotes de mantenimiento con ciclos más largos (texto)','params'=>['endpoint_key'=>'mantenimiento_top_texto_bot_dn','date_mode'=>'today']], - 'ciclo_mantenimiento_todos' => ['type'=>'function','function'=>'api_report','nlu_description'=>'PDF de mantenimiento todos los lotes del grupo seleccionado','params'=>['endpoint_key'=>'mantenimiento_todos_bot_dn','date_mode'=>'today','filename'=>'mantenimiento_todos.pdf','caption'=>'Mantenimiento — todos los lotes']], + // Sub-opciones: mantenimiento. + // requires/resolver: si el grupo aún no está elegido, el bot lo pide primero + // y al resolverlo vuelve a este informe (no al submenú). + 'ciclo_mantenimiento_top' => [ + 'type'=>'function','function'=>'api_report', + 'requires'=>['grupo_mant'=>'grupo_id'], 'resolver'=>'ciclo_mantenimiento', + 'back'=>'submenu_ciclo_mantenimiento', + 'nlu_description'=>'Lotes de mantenimiento con los ciclos más largos, por grupo (plateo, corona, poda...)', + 'params'=>['endpoint_key'=>'mantenimiento_top_texto_bot_dn','date_mode'=>'today'], + ], + 'ciclo_mantenimiento_todos' => [ + 'type'=>'function','function'=>'api_report', + 'requires'=>['grupo_mant'=>'grupo_id'], 'resolver'=>'ciclo_mantenimiento', + 'back'=>'submenu_ciclo_mantenimiento', + 'nlu_description'=>'Informe PDF de mantenimiento de todos los lotes de un grupo (plateo, corona, poda...)', + 'params'=>['endpoint_key'=>'mantenimiento_todos_bot_dn','date_mode'=>'today','filename'=>'mantenimiento_todos.pdf','caption'=>'Mantenimiento — todos los lotes'], + ], // Upload - 'enviar_informacion' => ['type' => 'menu', 'menu' => 'submenu_subir_ciclos', 'nlu_skip' => true], + 'enviar_informacion' => ['type' => 'menu', 'menu' => 'submenu_subir_ciclos', 'nlu_skip' => true, 'back' => 'show_main_menu'], 'subir_ciclo_cosecha' => ['type' => 'function', 'function' => 'upload_ciclo', 'nlu_description' => 'Subir o enviar un ciclo de cosecha', 'params' => ['ciclo' => 'cosecha']], // Alias globales (nlu_skip: son alias de navegación) 'show_main_menu' => ['type' => 'menu', 'menu' => 'show_menu_cat2', 'nlu_skip' => true], - 'descargar_informes' => ['type' => 'menu', 'menu' => 'show_menu_cat2', 'nlu_skip' => true], + 'descargar_informes' => ['type' => 'menu', 'menu' => 'show_menu_cat2', 'nlu_skip' => true, 'back' => 'show_main_menu'], ], // ── Per-type config ───────────────────────────────────────────────────── @@ -397,7 +430,7 @@ $configJson = [ 'flows' => [ 'show_menu_cat2' => ['type' => 'menu', 'menu' => 'show_menu_cat2'], 'show_main_menu' => ['type' => 'menu', 'menu' => 'show_menu_cat2'], - 'descargar_informes' => ['type' => 'menu', 'menu' => 'show_menu_cat2'], + 'descargar_informes' => ['type' => 'menu', 'menu' => 'show_menu_cat2', 'back' => 'show_main_menu'], ], ], @@ -411,8 +444,8 @@ $configJson = [ 'menus' => [], 'flows' => [ 'show_main_menu' => ['type' => 'menu', 'menu' => 'menu_modo_trabajo'], - 'descargar_informes' => ['type' => 'menu', 'menu' => 'show_menu_cat2'], - 'enviar_informacion' => ['type' => 'menu', 'menu' => 'submenu_subir_ciclos'], + 'descargar_informes' => ['type' => 'menu', 'menu' => 'show_menu_cat2', 'back' => 'show_main_menu'], + 'enviar_informacion' => ['type' => 'menu', 'menu' => 'submenu_subir_ciclos', 'back' => 'show_main_menu'], ], ], ], @@ -451,6 +484,7 @@ $endpoints = [ ['key' => 'tratamiento_todos_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=ciclos_tratamientos_dn&finca_id={finca_id}'], ['key' => 'mantenimiento_grupos_top3_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=grupos_mantenimiento&limit=3'], ['key' => 'mantenimiento_grupos_otros_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=grupos_mantenimiento&limit=10&offset=3'], + ['key' => 'mantenimiento_grupos_all_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=grupos_mantenimiento&limit=200'], ['key' => 'mantenimiento_top_bot_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=mantenimiento_dn&top_lotes=10&grupo={grupo_id}&finca_id={finca_id}'], ['key' => 'mantenimiento_top_texto_bot_dn','dir' => 'download', 'url' => $BASE . '?peticion=mantenimiento_top_texto_bot&grupo={grupo_id}&finca_id={finca_id}'], ['key' => 'mantenimiento_todos_bot_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=mantenimiento_dn&grupo={grupo_id}&finca_id={finca_id}'], diff --git a/setup/test_navegacion.php b/setup/test_navegacion.php new file mode 100644 index 0000000..219ac9d --- /dev/null +++ b/setup/test_navegacion.php @@ -0,0 +1,128 @@ + $k) { + if (($meta[$g][$k] ?? '') === '') { $falta = [$g, $k]; break; } + } + if ($falta === null) break; + + if (($meta['__resolve_attempt'] ?? '') === $key) { $pasos[] = '*sin-resolver*'; break; } + $meta['__after_select'] = $key; + $meta['__resolve_attempt'] = $key; + + $resolver = $flow['resolver']; + $pasos[] = $resolver; + + // El resolver resuelve si tenemos con qué (entity o tap); si no, muestra lista + [$g, $k] = $falta; + if (!isset($seleccionaria[$g])) { $pasos[] = '*muestra-lista*'; break; } + $meta[$g][$k] = $seleccionaria[$g]; + $key = $meta['__after_select']; + unset($meta['__after_select']); + } + return [$pasos, $meta]; +} + +$fallas = 0; +function check(string $nombre, $obtenido, $esperado): void { + global $fallas; + $ok = $obtenido === $esperado; + if (!$ok) $fallas++; + printf("%s %s\n", $ok ? ' ok ' : ' FALLA', $nombre); + if (!$ok) { + echo " esperaba: " . json_encode($esperado, JSON_UNESCAPED_UNICODE) . "\n"; + echo " obtuvo: " . json_encode($obtenido, JSON_UNESCAPED_UNICODE) . "\n"; + } +} + +$flows = flowsDe($config, '2'); + +echo "\nIntención pendiente\n"; + +// "quiero informe de mantenimiento de plateo": el NLU rutea al informe y la +// entity resuelve el grupo, así que el submenú nunca se muestra. +[$pasos] = ejecutar($flows, 'ciclo_mantenimiento_todos', [], ['grupo_mant' => '7']); +check('con grupo por entity llega al informe', + $pasos, ['ciclo_mantenimiento_todos', 'ciclo_mantenimiento', 'ciclo_mantenimiento_todos']); + +// Sin poder resolver el grupo, muestra la lista y espera al usuario +[$pasos] = ejecutar($flows, 'ciclo_mantenimiento_todos', []); +check('sin grupo pide la lista', + $pasos, ['ciclo_mantenimiento_todos', 'ciclo_mantenimiento', '*muestra-lista*']); + +// Grupo ya elegido (viene del submenú): va directo, sin volver a preguntar +[$pasos] = ejecutar($flows, 'ciclo_mantenimiento_todos', ['grupo_mant' => ['grupo_id' => '7']]); +check('con grupo ya elegido no repregunta', $pasos, ['ciclo_mantenimiento_todos']); + +// Un resolver que no deja el dato no puede rebotar para siempre +$roto = $flows; +$roto['resolver_roto'] = ['type' => 'menu', 'menu' => 'submenu_ciclos']; +$roto['informe_roto'] = ['type' => 'function', 'requires' => ['nada' => 'x'], 'resolver' => 'resolver_roto']; +[$pasos] = ejecutar($roto, 'informe_roto', [], ['otra_cosa' => '1']); +check('resolver mal configurado corta el bucle', + $pasos, ['informe_roto', 'resolver_roto', '*muestra-lista*']); + +echo "\nComando atrás\n"; + +$atras = function (array $flows, ?string $nodo): string { + return ($nodo !== null ? ($flows[$nodo]['back'] ?? null) : null) ?? 'show_main_menu'; +}; +check('desde un ciclo de sanidad sube a sanidad', $atras($flows, 'ciclo_plagas'), 'submenu_ciclos_sanidad'); +check('desde sanidad sube a ciclos', $atras($flows, 'submenu_ciclos_sanidad'), 'submenu_ciclos'); +check('desde ciclos sube a la raíz', $atras($flows, 'submenu_ciclos'), 'show_main_menu'); +check('desde un informe de mantenimiento al submenú', + $atras($flows, 'ciclo_mantenimiento_todos'), 'submenu_ciclo_mantenimiento'); +check('sin nodo activo cae a la raíz', $atras($flows, null), 'show_main_menu'); + +echo "\nComandos\n"; + +foreach (['2', '3'] as $cat) { + $cmds = comandosDe($config, $cat); + check("cat {$cat}: 'atras' resuelve por mapa", $cmds['atras'] ?? null, '__back'); + check("cat {$cat}: 'cambiar finca' existe", $cmds['cambiar finca'] ?? null, 'reset_finca'); + check("cat {$cat}: 'salir' va a la raíz", $cmds['salir'] ?? null, 'show_main_menu'); +} + +echo "\nask_finca\n"; +$af = $flows['ask_finca']; +check('no repregunta si ya hay finca', $af['skip_if_set'] ?? false, true); +check('reset_finca la vuelve a pedir', isset($flows['reset_finca']), true); + +echo "\n"; +if ($fallas) { echo "{$fallas} falla(s)\n"; exit(1); } +echo "OK — navegación correcta\n"; diff --git a/setup/validate_config.php b/setup/validate_config.php new file mode 100644 index 0000000..ab73419 --- /dev/null +++ b/setup/validate_config.php @@ -0,0 +1,91 @@ + $k === 'show_main_menu' || str_starts_with($k, 'show_menu_'); + +$categorias = ['global' => []] + ($config['per_type'] ?? []); +foreach ($categorias as $cat => $pt) { + $flows = array_merge($config['flows'] ?? [], $pt['flows'] ?? []); + $menus = array_merge($config['menus'] ?? [], $pt['menus'] ?? []); + $cmds = array_merge($config['commands'] ?? [], $pt['commands'] ?? []); + $donde = "cat {$cat}"; + + $existe = fn(string $k): bool => isset($flows[$k]) || isset($menus[$k]); + + foreach ($cmds as $palabra => $destino) { + if ($destino === '__back') continue; // se resuelve en runtime + if (!$existe($destino)) $errores[] = "{$donde}: comando '{$palabra}' → '{$destino}' no existe"; + } + + foreach ($flows as $key => $flow) { + foreach (['back', 'resolver', 'next_node'] as $campo) { + $destino = $flow[$campo] ?? null; + if ($destino !== null && !$existe($destino)) { + $errores[] = "{$donde}: flow '{$key}'.{$campo} → '{$destino}' no existe"; + } + } + if (!empty($flow['requires']) && empty($flow['resolver'])) { + $errores[] = "{$donde}: flow '{$key}' declara requires sin resolver"; + } + if (($flow['type'] ?? '') === 'menu' && !isset($menus[$flow['menu'] ?? ''])) { + $errores[] = "{$donde}: flow '{$key}' apunta al menú '{$flow['menu']}' que no existe"; + } + if (($flow['type'] ?? '') === 'menu' && !isset($flow['back']) && !$esRaiz($key)) { + $avisos[] = "{$donde}: flow '{$key}' es menú y no define 'back'"; + } + } + + // Cada id de botón/fila debe resolver a un flow + foreach ($menus as $mKey => $menu) { + $ids = array_column($menu['buttons'] ?? [], 'id'); + foreach ($menu['sections'] ?? [] as $sec) { + $ids = array_merge($ids, array_column($sec['rows'] ?? [], 'id')); + } + foreach ($ids as $id) { + if (!$existe($id)) $errores[] = "{$donde}: menú '{$mKey}' tiene opción '{$id}' sin flow"; + } + } +} + +// Endpoints referenciados por los flows +preg_match_all("/'key'\s*=>\s*'([^']+)'/", substr($seed, $end), $m); +$endpoints = array_flip($m[1]); +foreach ($config['flows'] ?? [] as $key => $flow) { + foreach (['source_endpoint_key', 'source_endpoint_key_all'] as $campo) { + $ep = $flow[$campo] ?? null; + if ($ep !== null && !isset($endpoints[$ep])) $errores[] = "flow '{$key}'.{$campo} → endpoint '{$ep}' no registrado"; + } + $ep = $flow['params']['endpoint_key'] ?? null; + if ($ep !== null && !isset($endpoints[$ep])) $errores[] = "flow '{$key}' → endpoint '{$ep}' no registrado"; +} + +foreach (array_unique($avisos) as $a) echo "aviso: {$a}\n"; +foreach (array_unique($errores) as $e) echo "ERROR: {$e}\n"; + +if ($errores) { echo "\n" . count(array_unique($errores)) . " error(es)\n"; exit(1); } +echo "\nOK — grafo de navegación consistente\n";