Bienvenida una vez por dia y por empresa, distinta por categoria (cat 1 no ve
ejemplos de descarga). Se manda aparte del retorno de process(), asi el usuario
recibe el saludo y acto seguido lo que pidio. __saludo sobrevive a
preservingReset para no repetirse tras cada informe.
Fix del leak: cuando la respuesta del modelo no parseaba como JSON se enviaba
cruda, y el usuario veia {"action":"chat","text":"... Ahora se rescata solo el
texto, incluso de JSON truncado, y si no hay nada legible se devuelve vacio
para caer al menu de fallback.
Ademas el prompt le prohibe preguntar por datos que una opcion ya pide: ante
"descargar informe de mantenimiento" debe rutear al selector de grupo en vez de
preguntar por chat.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
264 lines
11 KiB
PHP
264 lines
11 KiB
PHP
<?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 "\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";
|