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:
co-authored by
Claude Sonnet 4.6
parent
671fccd6b2
commit
0e89258f67
+151
-65
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user