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 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-08-01 09:59:30 -05:00
co-authored by Claude Sonnet 4.6
parent 671fccd6b2
commit 0e89258f67
5 changed files with 438 additions and 92 deletions
+61 -27
View File
@@ -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}'],
+128
View File
@@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
/**
* Recorre en seco la máquina de navegación: intención pendiente, comando "atrás"
* y skip_if_set. Replica la lógica de NormalBot sin BD ni WhatsApp; si alguna
* regla cambia acá se rompe la prueba y no el bot en producción.
*
* Uso: php setup/test_navegacion.php
*/
$seed = file_get_contents(__DIR__ . '/seed_palmas.php');
$open = strpos($seed, '[', strpos($seed, '$configJson = ['));
$depth = 0; $end = $open;
for ($i = $open, $n = strlen($seed); $i < $n; $i++) {
if ($seed[$i] === '[') $depth++;
elseif ($seed[$i] === ']') { $depth--; if ($depth === 0) { $end = $i; break; } }
}
$config = eval('return ' . substr($seed, $open, $end - $open + 1) . ';');
function flowsDe(array $config, string $cat): array {
$pt = $config['per_type'][$cat] ?? [];
return array_merge($config['flows'] ?? [], $pt['flows'] ?? []);
}
function comandosDe(array $config, string $cat): array {
$pt = $config['per_type'][$cat] ?? [];
return array_merge($config['commands'] ?? [], $pt['commands'] ?? []);
}
/** Espejo de divertToResolver() + advanceAfterSelect() */
function ejecutar(array $flows, string $key, array $meta, array $seleccionaria = []): array {
$pasos = [];
for ($i = 0; $i < 5; $i++) {
$pasos[] = $key;
$flow = $flows[$key] ?? null;
if ($flow === null) break;
$falta = null;
foreach ($flow['requires'] ?? [] as $g => $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";
+91
View File
@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
/**
* Valida el grafo de navegación de seed_palmas.php sin tocar la base de datos.
* Un 'back', 'resolver' o id de botón que apunte a un flow inexistente deja al
* usuario en un callejón sin salida, y eso no se nota hasta que alguien lo pisa.
*
* Uso: php setup/validate_config.php
*/
$seed = file_get_contents(__DIR__ . '/seed_palmas.php');
$ini = strpos($seed, '$configJson = [');
if ($ini === false) { fwrite(STDERR, "No se encontró \$configJson\n"); exit(1); }
// Recorte por balance de corchetes desde la apertura del array
$open = strpos($seed, '[', $ini);
$depth = 0; $end = $open;
for ($i = $open, $n = strlen($seed); $i < $n; $i++) {
if ($seed[$i] === '[') $depth++;
elseif ($seed[$i] === ']') { $depth--; if ($depth === 0) { $end = $i; break; } }
}
$config = eval('return ' . substr($seed, $open, $end - $open + 1) . ';');
$errores = [];
$avisos = [];
// Menús raíz: no suben a ningún lado, es correcto que no tengan 'back'
$esRaiz = fn(string $k): bool => $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";