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
+151 -65
View File
@@ -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);