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
@@ -449,6 +449,13 @@ PROMPT;
|
|||||||
. "{\"action\":\"route\",\"key\":\"<key_exacto>\",\"entities\":{\"campo\":\"valor\",...}}\n\n"
|
. "{\"action\":\"route\",\"key\":\"<key_exacto>\",\"entities\":{\"campo\":\"valor\",...}}\n\n"
|
||||||
. "En 'entities' incluye los datos que el usuario ya mencionó: nombre de finca, grupo, filtro, fecha, valor numérico, etc. Usa el nombre tal como lo dijo el usuario.\n"
|
. "En 'entities' incluye los datos que el usuario ya mencionó: nombre de finca, grupo, filtro, fecha, valor numérico, etc. Usa el nombre tal como lo dijo el usuario.\n"
|
||||||
. "Si no mencionó datos extra, omite entities o usa {}.\n\n"
|
. "Si no mencionó datos extra, omite entities o usa {}.\n\n"
|
||||||
|
. "IMPORTANTE — elegí siempre la opción más específica que cubra lo que pidió.\n"
|
||||||
|
. "Si nombra el informe Y un filtro, ruteá al informe concreto y poné el filtro en entities; nunca al selector intermedio. El sistema resuelve el filtro solo.\n"
|
||||||
|
. "Ejemplos:\n"
|
||||||
|
. " \"quiero el informe de mantenimiento de plateo\" → {\"action\":\"route\",\"key\":\"ciclo_mantenimiento_todos\",\"entities\":{\"grupo\":\"plateo\"}}\n"
|
||||||
|
. " \"cuáles lotes llevan más tiempo sin corona\" → {\"action\":\"route\",\"key\":\"ciclo_mantenimiento_top\",\"entities\":{\"grupo\":\"corona\"}}\n"
|
||||||
|
. " \"quiero cambiar de finca\" → {\"action\":\"route\",\"key\":\"reset_finca\"}\n"
|
||||||
|
. " \"mantenimiento\" (sin grupo) → {\"action\":\"route\",\"key\":\"ciclo_mantenimiento\"}\n\n"
|
||||||
. "Si no está claro o no hay opción correspondiente → responde SOLO:\n"
|
. "Si no está claro o no hay opción correspondiente → responde SOLO:\n"
|
||||||
. "{\"action\":\"chat\",\"text\":\"<respuesta corta en español, máx 2 oraciones>\"}\n\n"
|
. "{\"action\":\"chat\",\"text\":\"<respuesta corta en español, máx 2 oraciones>\"}\n\n"
|
||||||
. "No inventes keys. Usa exactamente los keys de la lista.";
|
. "No inventes keys. Usa exactamente los keys de la lista.";
|
||||||
|
|||||||
+151
-65
@@ -30,6 +30,13 @@ class NormalBot
|
|||||||
}
|
}
|
||||||
if ($dirty) ConversationContext::updateMetadata($ctxId, $m);
|
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
|
// "menu/inicio/volver" → prefer per-type greeting_menu over global show_main_menu
|
||||||
$greetingMenuKey = $perType['greeting_menu'] ?? null;
|
$greetingMenuKey = $perType['greeting_menu'] ?? null;
|
||||||
if ($action === 'show_main_menu' && $greetingMenuKey && isset($menus[$greetingMenuKey])) {
|
if ($action === 'show_main_menu' && $greetingMenuKey && isset($menus[$greetingMenuKey])) {
|
||||||
@@ -39,7 +46,7 @@ class NormalBot
|
|||||||
ConversationContext::updateNode($ctxId, $action);
|
ConversationContext::updateNode($ctxId, $action);
|
||||||
// Commands that match a flow
|
// Commands that match a flow
|
||||||
if (isset($flows[$action])) {
|
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
|
// Commands that match a menu
|
||||||
if (isset($menus[$action])) {
|
if (isset($menus[$action])) {
|
||||||
@@ -76,7 +83,7 @@ class NormalBot
|
|||||||
if ($currentNode !== null && !in_array($currentNode, $sentinels, true) && isset($flows[$currentNode])) {
|
if ($currentNode !== null && !in_array($currentNode, $sentinels, true) && isset($flows[$currentNode])) {
|
||||||
$isMenuNode = ($flows[$currentNode]['type'] ?? '') === 'menu';
|
$isMenuNode = ($flows[$currentNode]['type'] ?? '') === 'menu';
|
||||||
if (!$isMenuNode) {
|
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';
|
$greetingFlowId = $perType['greeting_flow'] ?? 'greeting';
|
||||||
if (isset($flows[$greetingFlowId])) {
|
if (isset($flows[$greetingFlowId])) {
|
||||||
ConversationContext::updateNode($ctxId, $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);
|
$response = self::sendText($greeting, $context['from'], $company);
|
||||||
ConversationContext::updateNode($ctxId, null);
|
ConversationContext::updateNode($ctxId, null);
|
||||||
@@ -128,7 +135,7 @@ class NormalBot
|
|||||||
}
|
}
|
||||||
if (isset($flows[$key])) {
|
if (isset($flows[$key])) {
|
||||||
ConversationContext::updateNode($ctxId, $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])) {
|
if (isset($menus[$key])) {
|
||||||
ConversationContext::updateNode($ctxId, $key);
|
ConversationContext::updateNode($ctxId, $key);
|
||||||
@@ -146,14 +153,14 @@ class NormalBot
|
|||||||
|
|
||||||
// Re-show current menu when NLU didn't route anything
|
// Re-show current menu when NLU didn't route anything
|
||||||
if ($currentNode !== null && !in_array($currentNode, $sentinels, true) && isset($flows[$currentNode])) {
|
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
|
// 7. Fallback flow
|
||||||
$fallbackFlowId = $perType['fallback_flow'] ?? $config['fallback_flow'] ?? null;
|
$fallbackFlowId = $perType['fallback_flow'] ?? $config['fallback_flow'] ?? null;
|
||||||
if ($fallbackFlowId !== null && isset($flows[$fallbackFlowId])) {
|
if ($fallbackFlowId !== null && isset($flows[$fallbackFlowId])) {
|
||||||
ConversationContext::updateNode($ctxId, $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)
|
// 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::updateMetadata($ctxId, $meta);
|
||||||
ConversationContext::updateNode($ctxId, $resolved);
|
ConversationContext::updateNode($ctxId, $resolved);
|
||||||
if (isset($flows[$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;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -229,7 +236,7 @@ class NormalBot
|
|||||||
if ($nextNode !== null) {
|
if ($nextNode !== null) {
|
||||||
ConversationContext::updateNode($ctxId, $nextNode);
|
ConversationContext::updateNode($ctxId, $nextNode);
|
||||||
if (isset($flows[$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])) {
|
if (isset($menus[$nextNode])) {
|
||||||
return self::buildMenuResponse($menus[$nextNode], $context['from'], $company);
|
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
|
private static function handleDynamicList(array $flow, array $context, array $company, int $ctxId): ?array
|
||||||
{
|
{
|
||||||
$meta = ConversationContext::getMetadata($ctxId);
|
$meta = ConversationContext::getMetadata($ctxId);
|
||||||
$items = self::fetchDynamicList($flow['source_endpoint_key'] ?? '', $company, $meta);
|
$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) {
|
if ($items === null || count($items) === 0) {
|
||||||
ConversationContext::reset($ctxId);
|
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)
|
// Mapa label → id para match fuzzy (usado tanto en auto-select como en collecting)
|
||||||
$labelMap = [];
|
$labelMap = [];
|
||||||
foreach ($items as $item) {
|
foreach ($items as $item) {
|
||||||
@@ -282,36 +301,23 @@ class NormalBot
|
|||||||
|
|
||||||
// Auto-select cuando hay exactamente 1 opción — no mostrar lista
|
// Auto-select cuando hay exactamente 1 opción — no mostrar lista
|
||||||
if (count($items) === 1) {
|
if (count($items) === 1) {
|
||||||
$item = $items[0];
|
|
||||||
$formData = $meta[$group] ?? [];
|
$formData = $meta[$group] ?? [];
|
||||||
$formData[$key] = (string)($item[$valueField] ?? '');
|
$formData[$key] = (string)($items[0][$valueField] ?? '');
|
||||||
$meta[$group] = $formData;
|
$meta[$group] = $formData;
|
||||||
unset($meta['__nlu_entities']);
|
|
||||||
ConversationContext::updateMetadata($ctxId, $meta);
|
ConversationContext::updateMetadata($ctxId, $meta);
|
||||||
if ($nextNode !== null) {
|
return self::advanceAfterSelect($nextNode, $context, $company, $ctxId);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Si el NLU extrajo entities, intentar auto-seleccionar sin mostrar lista
|
// Si el NLU extrajo entities, intentar auto-seleccionar sin mostrar lista
|
||||||
$entities = $meta['__nlu_entities'] ?? [];
|
$entities = $meta['__nlu_entities'] ?? [];
|
||||||
unset($meta['__nlu_entities']);
|
|
||||||
if (!empty($entities)) {
|
if (!empty($entities)) {
|
||||||
$matchedId = null;
|
$matchedId = null;
|
||||||
foreach ($entities as $ev) {
|
foreach ($entities as $ev) {
|
||||||
$evNorm = self::normalize((string)$ev);
|
$evNorm = self::normalize((string)$ev);
|
||||||
|
if ($evNorm === '') continue;
|
||||||
foreach ($labelMap as $label => $id) {
|
foreach ($labelMap as $label => $id) {
|
||||||
$lNorm = self::normalize($label);
|
$lNorm = self::normalize($label);
|
||||||
|
if ($lNorm === '') continue;
|
||||||
if ($lNorm === $evNorm || str_contains($evNorm, $lNorm) || str_contains($lNorm, $evNorm)) {
|
if ($lNorm === $evNorm || str_contains($evNorm, $lNorm) || str_contains($lNorm, $evNorm)) {
|
||||||
$matchedId = $id;
|
$matchedId = $id;
|
||||||
break 2;
|
break 2;
|
||||||
@@ -323,19 +329,10 @@ class NormalBot
|
|||||||
$formData[$key] = $matchedId;
|
$formData[$key] = $matchedId;
|
||||||
$meta[$group] = $formData;
|
$meta[$group] = $formData;
|
||||||
ConversationContext::updateMetadata($ctxId, $meta);
|
ConversationContext::updateMetadata($ctxId, $meta);
|
||||||
if ($nextNode !== null) {
|
return self::advanceAfterSelect($nextNode, $context, $company, $ctxId);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
// 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);
|
ConversationContext::updateMetadata($ctxId, $meta);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -349,6 +346,9 @@ class NormalBot
|
|||||||
$redirectIds[] = $optId;
|
$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['collecting'] = [
|
||||||
'meta_group' => $group,
|
'meta_group' => $group,
|
||||||
'meta_key' => $key,
|
'meta_key' => $key,
|
||||||
@@ -363,6 +363,31 @@ class NormalBot
|
|||||||
return self::buildDynamicListResponse($items, $flow, $context['from'], $company);
|
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
|
// Reemplaza {variable} en la URL con valores del metadata acumulado
|
||||||
private static function substituteUrlVars(string $url, array $meta): string
|
private static function substituteUrlVars(string $url, array $meta): string
|
||||||
{
|
{
|
||||||
@@ -1096,10 +1121,69 @@ class NormalBot
|
|||||||
return is_array($menuRef) ? $menuRef : [];
|
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';
|
$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) {
|
return match ($type) {
|
||||||
'text' => self::sendText($flow['message'] ?? '', $context['from'], $company),
|
'text' => self::sendText($flow['message'] ?? '', $context['from'], $company),
|
||||||
'image' => self::sendImage($flow['media_id'] ?? '', $flow['caption'] ?? null, $context['from'], $company),
|
'image' => self::sendImage($flow['media_id'] ?? '', $flow['caption'] ?? null, $context['from'], $company),
|
||||||
@@ -1127,33 +1211,35 @@ class NormalBot
|
|||||||
ConversationContext::reset($ctxId);
|
ConversationContext::reset($ctxId);
|
||||||
return self::sendText("Hasta luego 👋\n\nEscribe *menu* cuando quieras volver.", $context['from'], $company);
|
return self::sendText("Hasta luego 👋\n\nEscribe *menu* cuando quieras volver.", $context['from'], $company);
|
||||||
})(),
|
})(),
|
||||||
'reset_finca' => (function () use ($ctxId, $context, $company) {
|
'reset_finca' => self::reAsk($ctxId, $context, $company, 'finca', 'ask_finca', '¿En qué finca trabajas?'),
|
||||||
$m = ConversationContext::getMetadata($ctxId);
|
'reset_grupo_mant' => self::reAsk($ctxId, $context, $company, 'grupo_mant', 'ciclo_mantenimiento', '¿Qué grupo de mantenimiento?'),
|
||||||
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);
|
|
||||||
})(),
|
|
||||||
'forward_to_ai' => null,
|
'forward_to_ai' => null,
|
||||||
'api_report' => self::executeApiReport($params, $context, $company, $ctxId),
|
'api_report' => self::executeApiReport($params, $context, $company, $ctxId),
|
||||||
default => null,
|
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
|
// Reset manteniendo grupos de sesión (finca, etc.) para que el usuario
|
||||||
// no tenga que re-seleccionar su finca después de cada reporte.
|
// no tenga que re-seleccionar su finca después de cada reporte.
|
||||||
private static function preservingReset(int $ctxId, array $meta): void
|
private static function preservingReset(int $ctxId, array $meta): void
|
||||||
{
|
{
|
||||||
$keep = [];
|
$keep = [];
|
||||||
// grupo_mant se conserva: el submenú de mantenimiento ofrece varios informes
|
// finca y grupo_mant se conservan: los submenús ofrecen varios informes sobre
|
||||||
// sobre el mismo grupo, y perderlo dejaba {grupo_id} sin resolver.
|
// la misma selección, y perderla dejaba {finca_id}/{grupo_id} sin resolver.
|
||||||
foreach (['finca', 'grupo_mant'] as $group) {
|
foreach (['finca', 'grupo_mant'] as $group) {
|
||||||
if (isset($meta[$group])) $keep[$group] = $meta[$group];
|
if (isset($meta[$group])) $keep[$group] = $meta[$group];
|
||||||
}
|
}
|
||||||
@@ -1515,7 +1601,7 @@ class NormalBot
|
|||||||
if (($row['id'] ?? '') === $input) {
|
if (($row['id'] ?? '') === $input) {
|
||||||
ConversationContext::updateNode($ctxId, $row['id']);
|
ConversationContext::updateNode($ctxId, $row['id']);
|
||||||
if (isset($flows[$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;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -1525,7 +1611,7 @@ class NormalBot
|
|||||||
if (($btn['id'] ?? '') === $input) {
|
if (($btn['id'] ?? '') === $input) {
|
||||||
ConversationContext::updateNode($ctxId, $btn['id']);
|
ConversationContext::updateNode($ctxId, $btn['id']);
|
||||||
if (isset($flows[$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;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -1541,7 +1627,7 @@ class NormalBot
|
|||||||
}
|
}
|
||||||
if (isset($flows[$resolvedInput])) {
|
if (isset($flows[$resolvedInput])) {
|
||||||
ConversationContext::updateNode($ctxId, $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])) {
|
if (isset($menus[$resolvedInput])) {
|
||||||
ConversationContext::updateNode($ctxId, $resolvedInput);
|
ConversationContext::updateNode($ctxId, $resolvedInput);
|
||||||
|
|||||||
+61
-27
@@ -13,15 +13,20 @@ $configJson = [
|
|||||||
'fallback' => 'No entendí. Escribe *menu* para ver las opciones disponibles.',
|
'fallback' => 'No entendí. Escribe *menu* para ver las opciones disponibles.',
|
||||||
'nlu_enabled' => true,
|
'nlu_enabled' => true,
|
||||||
'commands' => [
|
'commands' => [
|
||||||
'menu' => 'show_main_menu',
|
// Al menú principal
|
||||||
'informes' => 'descargar_informes',
|
'menu' => 'show_main_menu',
|
||||||
'info' => 'enviar_informacion',
|
'inicio' => 'show_main_menu',
|
||||||
'inicio' => 'show_main_menu',
|
'salir' => 'show_main_menu',
|
||||||
'volver' => 'show_main_menu',
|
'cancelar' => 'show_main_menu',
|
||||||
'salir' => 'show_main_menu',
|
// Un nivel arriba (__back lo resuelve con el mapa 'back' del nodo actual)
|
||||||
'atras' => 'show_main_menu',
|
'atras' => '__back',
|
||||||
'cancelar' => 'show_main_menu',
|
'volver' => '__back',
|
||||||
'regresar' => 'show_main_menu',
|
'regresar' => '__back',
|
||||||
|
// Atajos
|
||||||
|
'informes' => 'descargar_informes',
|
||||||
|
'info' => 'enviar_informacion',
|
||||||
|
'finca' => 'reset_finca',
|
||||||
|
'cambiar finca' => 'reset_finca',
|
||||||
],
|
],
|
||||||
// ── Menus globales ──────────────────────────────────────────────────────
|
// ── Menus globales ──────────────────────────────────────────────────────
|
||||||
'menus' => [
|
'menus' => [
|
||||||
@@ -135,6 +140,7 @@ $configJson = [
|
|||||||
'submenu_ciclo_mantenimiento' => ['type'=>'button','body'=>'Ciclos de Mantenimiento — ¿Qué deseas ver?','buttons'=>[
|
'submenu_ciclo_mantenimiento' => ['type'=>'button','body'=>'Ciclos de Mantenimiento — ¿Qué deseas ver?','buttons'=>[
|
||||||
['id'=>'ciclo_mantenimiento_top', 'title'=>'🏆 Más largos'],
|
['id'=>'ciclo_mantenimiento_top', 'title'=>'🏆 Más largos'],
|
||||||
['id'=>'ciclo_mantenimiento_todos', 'title'=>'📋 Todos los lotes'],
|
['id'=>'ciclo_mantenimiento_todos', 'title'=>'📋 Todos los lotes'],
|
||||||
|
['id'=>'reset_grupo_mant', 'title'=>'🔄 Otro grupo'],
|
||||||
]],
|
]],
|
||||||
|
|
||||||
// Submenú sanidad vegetal (orden: Censo, Plagas, Rincofurus, Tratamiento)
|
// Submenú sanidad vegetal (orden: Censo, Plagas, Rincofurus, Tratamiento)
|
||||||
@@ -208,6 +214,7 @@ $configJson = [
|
|||||||
'meta_group' => 'finca',
|
'meta_group' => 'finca',
|
||||||
'meta_key' => 'finca_id',
|
'meta_key' => 'finca_id',
|
||||||
'next_node' => 'show_main_menu',
|
'next_node' => 'show_main_menu',
|
||||||
|
'skip_if_set' => true,
|
||||||
'nlu_description' => 'Seleccionar o cambiar la finca de trabajo',
|
'nlu_description' => 'Seleccionar o cambiar la finca de trabajo',
|
||||||
],
|
],
|
||||||
|
|
||||||
@@ -218,6 +225,13 @@ $configJson = [
|
|||||||
'nlu_description' => 'Cambiar de finca o seleccionar una finca diferente',
|
'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)
|
// 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' => [
|
'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',
|
'endpoint_key' => 'produccion_total_bot_dn',
|
||||||
@@ -249,18 +263,19 @@ $configJson = [
|
|||||||
]],
|
]],
|
||||||
|
|
||||||
// Menus de sección (nlu_skip: navegación interna, no exponer al NLU)
|
// Menus de sección (nlu_skip: navegación interna, no exponer al NLU)
|
||||||
'submenu_produccion' => ['type' => 'menu', 'menu' => 'submenu_produccion', 'nlu_skip' => true],
|
// 'back' = a dónde sube el comando "atrás" desde cada nivel
|
||||||
'submenu_ciclos' => ['type' => 'menu', 'menu' => 'submenu_ciclos', 'nlu_skip' => true],
|
'submenu_produccion' => ['type' => 'menu', 'menu' => 'submenu_produccion', 'nlu_skip' => true, 'back' => 'show_main_menu'],
|
||||||
'submenu_ciclos_sanidad' => ['type' => 'menu', 'menu' => 'submenu_ciclos_sanidad', 'nlu_skip' => true],
|
'submenu_ciclos' => ['type' => 'menu', 'menu' => 'submenu_ciclos', 'nlu_skip' => true, 'back' => 'show_main_menu'],
|
||||||
'submenu_ausentismos' => ['type' => 'menu', 'menu' => 'submenu_ausentismos', 'nlu_skip' => true],
|
'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
|
// 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_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', 'nlu_description' => 'Ver ciclos de polinización'],
|
'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', 'nlu_description' => 'Ver ciclos de plagas'],
|
'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', 'nlu_description' => 'Ver ciclos de censo'],
|
'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', 'nlu_description' => 'Ver ciclos de Palmarum (Rhynchophorus)'],
|
'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', 'nlu_description' => 'Ver ciclos de tratamientos'],
|
'ciclo_tratamiento' => ['type' => 'menu', 'menu' => 'submenu_ciclo_tratamiento', 'back' => 'submenu_ciclos_sanidad', 'nlu_description' => 'Ver ciclos de tratamientos'],
|
||||||
|
|
||||||
// Sub-opciones: cosecha
|
// 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']],
|
'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' => [
|
'ciclo_mantenimiento' => [
|
||||||
'type' => 'dynamic_list',
|
'type' => 'dynamic_list',
|
||||||
'source_endpoint_key' => 'mantenimiento_grupos_top3_dn',
|
'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',
|
'value_field' => 'id',
|
||||||
'label_field' => 'label',
|
'label_field' => 'label',
|
||||||
'header' => '🔧 Mantenimiento',
|
'header' => '🔧 Mantenimiento',
|
||||||
@@ -321,17 +340,31 @@ $configJson = [
|
|||||||
'nlu_skip' => true,
|
'nlu_skip' => true,
|
||||||
],
|
],
|
||||||
|
|
||||||
// Sub-opciones: mantenimiento
|
// 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']],
|
// requires/resolver: si el grupo aún no está elegido, el bot lo pide primero
|
||||||
'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']],
|
// 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
|
// 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']],
|
'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)
|
// Alias globales (nlu_skip: son alias de navegación)
|
||||||
'show_main_menu' => ['type' => 'menu', 'menu' => 'show_menu_cat2', 'nlu_skip' => true],
|
'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 ─────────────────────────────────────────────────────
|
// ── Per-type config ─────────────────────────────────────────────────────
|
||||||
@@ -397,7 +430,7 @@ $configJson = [
|
|||||||
'flows' => [
|
'flows' => [
|
||||||
'show_menu_cat2' => ['type' => 'menu', 'menu' => 'show_menu_cat2'],
|
'show_menu_cat2' => ['type' => 'menu', 'menu' => 'show_menu_cat2'],
|
||||||
'show_main_menu' => ['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' => [],
|
'menus' => [],
|
||||||
'flows' => [
|
'flows' => [
|
||||||
'show_main_menu' => ['type' => 'menu', 'menu' => 'menu_modo_trabajo'],
|
'show_main_menu' => ['type' => 'menu', 'menu' => 'menu_modo_trabajo'],
|
||||||
'descargar_informes' => ['type' => 'menu', 'menu' => 'show_menu_cat2'],
|
'descargar_informes' => ['type' => 'menu', 'menu' => 'show_menu_cat2', 'back' => 'show_main_menu'],
|
||||||
'enviar_informacion' => ['type' => 'menu', 'menu' => 'submenu_subir_ciclos'],
|
'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' => '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_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_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_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_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}'],
|
['key' => 'mantenimiento_todos_bot_dn', 'dir' => 'download', 'url' => $BASE . '?peticion=mantenimiento_dn&grupo={grupo_id}&finca_id={finca_id}'],
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -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";
|
||||||
Reference in New Issue
Block a user