$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 "\nQuién atiende el texto libre\n"; /** * Espejo del orden de process(): qué paso agarra un mensaje de texto que no es * comando. Lo que importa es que el NLU llegue a opinar cuando la sesión ya * empezó — si no, "quiero informe de plateo" rebota al menú. */ $atiende = function (?string $nodo, $greeting, ?string $greetingMenu, array $flows): string { $centinelas = ['collecting', '__greeted']; if ($nodo !== null && !in_array($nodo, $centinelas, true) && isset($flows[$nodo])) { if (($flows[$nodo]['type'] ?? '') !== 'menu') return 'nodo-activo'; } if ($greetingMenu !== null && $nodo === null) return 'menu-bienvenida'; if ($greeting !== null && $nodo === null) return 'flujo-bienvenida'; return 'nlu'; }; // Cat 2/3 usan greeting = '' para disparar ask_finca en el primer mensaje check('sesión nueva pide la finca', $atiende(null, '', null, $flows), 'flujo-bienvenida'); check('tras un informe el NLU atiende', $atiende('__greeted', '', null, $flows), 'nlu'); check('parado en un menú el NLU atiende', $atiende('submenu_ciclos', '', null, $flows), 'nlu'); check('en medio de una lista no interfiere', $atiende('collecting', '', null, $flows), 'nlu'); echo "\nEntities por campo\n"; /** Espejo del matching de handleDynamicList con entity_key */ $matchear = function (array $flow, array $entities, array $etiquetas): array { $ek = $flow['entity_key'] ?? ''; $propias = []; if ($ek !== '') { foreach ($entities as $k => $v) if (mb_strtolower($k) === mb_strtolower($ek)) $propias[$k] = $v; } else { $propias = $entities; } foreach ($propias as $k => $v) { foreach ($etiquetas as $etiqueta) { $a = mb_strtolower($v); $b = mb_strtolower($etiqueta); if ($a === $b || str_contains($a, $b) || str_contains($b, $a)) { unset($entities[$k]); return ['match' => $etiqueta, 'sobran' => $entities]; } } } return ['match' => null, 'sobran' => $entities]; }; $dichas = ['finca' => 'reposo', 'grupo' => 'plateo']; $fincas = ['ROSA BLANCA', 'REPOSO', '🌐 Todas las fincas']; $grupos = ['PLATEO', 'CORONA', 'PODA']; // Cada lista toma lo suyo y deja el resto para la siguiente $r1 = $matchear($flows['ask_finca'], $dichas, $fincas); check('ask_finca toma la finca', $r1['match'], 'REPOSO'); check('y deja el grupo para después', $r1['sobran'], ['grupo' => 'plateo']); $r2 = $matchear($flows['ciclo_mantenimiento'], $r1['sobran'], $grupos); check('ciclo_mantenimiento toma el grupo', $r2['match'], 'PLATEO'); check('no queda nada sin consumir', $r2['sobran'], []); // El bug que motivó todo: una finca no debe matchear contra grupos $r3 = $matchear($flows['ciclo_mantenimiento'], ['finca' => 'reposo'], $grupos); check('una finca no se consume como grupo', $r3['match'], null); check('y sobrevive para ask_finca', $r3['sobran'], ['finca' => 'reposo']); echo "\nFinca visible en el menú\n"; /** Espejo de aplicarVarsMenu() */ $footer = function (array $menu, string $finca): string { $f = $menu['footer'] ?? ''; $f = str_replace('{finca}', $finca, $f); if ($finca !== '' && !str_contains($f, $finca)) $f = '📍 ' . $finca; return $f; }; $menus = $config['menus']; check('lista muestra la finca activa', $footer($menus['show_menu_cat2'], 'ROSA BLANCA'), '📍 ROSA BLANCA'); check('"todas" se lee natural', $footer($menus['show_menu_cat2'], '🌐 Todas las fincas'), '📍 🌐 Todas las fincas'); check('sin finca queda el footer de siempre', $footer($menus['show_menu_cat2'], ''), 'Palmas360'); check('submenú de botones también la lleva', $footer($menus['submenu_ciclo_mantenimiento'], 'REPOSO'), '📍 REPOSO'); check('entra en los 60 caracteres del footer', mb_strlen($footer($menus['show_menu_cat2'], 'ROSA BLANCA')) <= 60, true); echo "\nBienvenida diaria\n"; /** Espejo de saludarUnaVezAlDia(): devuelve el texto o '' si no toca saludar */ $saludar = function (array $config, string $cat, array &$meta, string $hoy, string $nombre = 'Usite'): string { $w = $config['welcome'] ?? []; if (empty($w['enabled'])) return ''; $texto = (string)($w['text'][$cat] ?? ''); if (trim($texto) === '') return ''; if (($meta['__saludo'] ?? '') === $hoy) return ''; $meta['__saludo'] = $hoy; return strtr($texto, [ '{saludo}' => $nombre !== '' ? "Hola *{$nombre}*" : 'Hola', '{empresa}' => 'Rosa Blanca', '{finca}' => '', '{empresas}' => '', ]); }; $meta = []; $primero = $saludar($config, '3', $meta, '2026-08-01'); check('saluda en el primer mensaje del día', $primero !== '', true); check('personaliza con el nombre', str_contains($primero, 'Hola *Usite*'), true); check('nombra la empresa', str_contains($primero, 'Rosa Blanca'), true); check('informa los comandos', str_contains($primero, '*atrás*') && str_contains($primero, '*salir*'), true); check('no deja tokens sin resolver', preg_match('/\{[a-z]+\}/', $primero), 0); check('no vuelve a saludar el mismo día', $saludar($config, '3', $meta, '2026-08-01'), ''); // preservingReset conserva __saludo, así que un informe no dispara otro saludo $trasInforme = array_intersect_key($meta, array_flip(['finca', 'grupo_mant', '__saludo'])); check('sobrevive a un informe', $saludar($config, '3', $trasInforme, '2026-08-01'), ''); check('al día siguiente saluda de nuevo', $saludar($config, '3', $meta, '2026-08-02') !== '', true); // Cat 1 no descarga informes: no debe ofrecerle cosas que no puede usar $m1 = []; $cat1 = $saludar($config, '1', $m1, '2026-08-01'); check('cat 1 no ofrece descargas', str_contains($cat1, 'faltó hoy'), false); check('cat 1 sí ofrece subir', str_contains($cat1, 'subir ciclo'), true); // Sin nombre no queda "Hola **," $m2 = []; check('sin nombre no rompe el saludo', str_starts_with($saludar($config, '2', $m2, '2026-08-01', ''), 'Hola,'), true); 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";