feat: finca visible en cada menu, entities por campo y aviso de comandos

Entities:
- Se conserva el mapa {campo: valor} en vez de aplanarlo. El pre-llenado de
  formularios y el auto-registro de valores comparan la clave contra el nombre
  del campo, asi que con la lista plana nunca se disparaban.
- entity_key acota cada dynamic_list a su propio campo: antes una finca llamada
  "reposo" se comparaba contra grupos de mantenimiento y se descartaba ahi.
- Cada lista consume solo lo suyo, asi "finca reposo, grupo plateo" resuelve
  las dos cosas en una frase.

Finca visible:
- Se guarda la etiqueta ademas del id al elegir una opcion.
- El footer de cada menu muestra la finca activa; sin finca queda el nombre de
  la empresa, asi cat 1 no cambia. Los menus de botones ahora emiten footer.
- {finca} disponible en header, body y footer.

Aviso de comandos: los textos tras cada informe nombran menu, atras y salir.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-08-01 10:55:55 -05:00
co-authored by Claude Sonnet 4.6
parent 693623e10c
commit a64421c527
3 changed files with 166 additions and 36 deletions
+101 -36
View File
@@ -50,7 +50,7 @@ class NormalBot
} }
// Commands that match a menu // Commands that match a menu
if (isset($menus[$action])) { if (isset($menus[$action])) {
return self::buildMenuResponse($menus[$action], $context['from'], $company); return self::buildMenuResponse($menus[$action], $context['from'], $company, $ctxId);
} }
return null; return null;
} }
@@ -91,14 +91,14 @@ class NormalBot
$welcomeMenuKey = $config['welcome_menu'] ?? null; $welcomeMenuKey = $config['welcome_menu'] ?? null;
if ($welcomeMenuKey && $currentNode === null && isset($menus[$welcomeMenuKey])) { if ($welcomeMenuKey && $currentNode === null && isset($menus[$welcomeMenuKey])) {
ConversationContext::updateNode($ctxId, '__greeted'); ConversationContext::updateNode($ctxId, '__greeted');
return self::buildMenuResponse($menus[$welcomeMenuKey], $context['from'], $company); return self::buildMenuResponse($menus[$welcomeMenuKey], $context['from'], $company, $ctxId);
} }
// 4b. Per-type greeting menu — only for brand-new sessions (currentNode === null) // 4b. Per-type greeting menu — only for brand-new sessions (currentNode === null)
$greetingMenuKey = $perType['greeting_menu'] ?? null; $greetingMenuKey = $perType['greeting_menu'] ?? null;
if ($greetingMenuKey !== null && $currentNode === null && isset($menus[$greetingMenuKey])) { if ($greetingMenuKey !== null && $currentNode === null && isset($menus[$greetingMenuKey])) {
ConversationContext::updateNode($ctxId, '__greeted'); // sentinel: evita re-envío del menú ConversationContext::updateNode($ctxId, '__greeted'); // sentinel: evita re-envío del menú
return self::buildMenuResponse($menus[$greetingMenuKey], $context['from'], $company); return self::buildMenuResponse($menus[$greetingMenuKey], $context['from'], $company, $ctxId);
} }
// 5. Greeting text // 5. Greeting text
@@ -127,19 +127,22 @@ class NormalBot
ConversationContext::updateMetadata($ctxId, $m); ConversationContext::updateMetadata($ctxId, $m);
$key = 'ask_finca'; $key = 'ask_finca';
} }
// Inyectar entities para auto-select en dynamic_list // Inyectar entities para auto-select en dynamic_list.
// Se guarda el mapa completo: los formularios las buscan por nombre
// de campo, y aplanarlas dejaba ese pre-llenado sin funcionar.
$m = ConversationContext::getMetadata($ctxId);
unset($m['__nlu_entities']); // descartar sobras de un ruteo anterior
if (!empty($route['entities'])) { if (!empty($route['entities'])) {
$m = ConversationContext::getMetadata($ctxId); $m['__nlu_entities'] = $route['entities'];
$m['__nlu_entities'] = array_values($route['entities']);
ConversationContext::updateMetadata($ctxId, $m);
} }
ConversationContext::updateMetadata($ctxId, $m);
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, $key); 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);
return self::buildMenuResponse($menus[$key], $context['from'], $company); return self::buildMenuResponse($menus[$key], $context['from'], $company, $ctxId);
} }
} }
if ($route['action'] === 'chat' && ($route['text'] ?? '') !== '') { if ($route['action'] === 'chat' && ($route['text'] ?? '') !== '') {
@@ -166,7 +169,7 @@ class NormalBot
// 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)
if (!$suppressFallback && $greetingMenuKey !== null && isset($menus[$greetingMenuKey])) { if (!$suppressFallback && $greetingMenuKey !== null && isset($menus[$greetingMenuKey])) {
ConversationContext::updateNode($ctxId, '__greeted'); ConversationContext::updateNode($ctxId, '__greeted');
return self::buildMenuResponse($menus[$greetingMenuKey], $context['from'], $company); return self::buildMenuResponse($menus[$greetingMenuKey], $context['from'], $company, $ctxId);
} }
// 9. Static fallback text // 9. Static fallback text
@@ -227,9 +230,12 @@ class NormalBot
return null; return null;
} }
$formData = $meta[$group] ?? []; // Se recupera la etiqueta del id elegido para poder mostrarla después
$formData[$key] = $resolved; $etiqueta = '';
$meta[$group] = $formData; foreach ($labelMap as $label => $id) {
if ((string)$id === $resolved) { $etiqueta = (string)$label; break; }
}
$meta = self::guardarSeleccion($meta, $group, $key, $resolved, $etiqueta);
unset($meta['collecting']); unset($meta['collecting']);
ConversationContext::updateMetadata($ctxId, $meta); ConversationContext::updateMetadata($ctxId, $meta);
@@ -239,7 +245,7 @@ class NormalBot
return self::handleFlow($flows[$nextNode], $context, $company, $ctxId, $menus, $nextNode); 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, $ctxId);
} }
} else { } else {
ConversationContext::updateNode($ctxId, null); ConversationContext::updateNode($ctxId, null);
@@ -301,39 +307,53 @@ 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) {
$formData = $meta[$group] ?? []; $meta = self::guardarSeleccion($meta, $group, $key, (string)($items[0][$valueField] ?? ''), (string)($items[0][$labelField] ?? ''));
$formData[$key] = (string)($items[0][$valueField] ?? '');
$meta[$group] = $formData;
ConversationContext::updateMetadata($ctxId, $meta); ConversationContext::updateMetadata($ctxId, $meta);
return self::advanceAfterSelect($nextNode, $context, $company, $ctxId); return self::advanceAfterSelect($nextNode, $context, $company, $ctxId);
} }
// 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'] ?? []; // entity_key acota la búsqueda al campo propio: sin eso, una finca llamada
// "reposo" se compara contra grupos de mantenimiento y se consume al pedo.
$entities = $meta['__nlu_entities'] ?? [];
$entityKey = $flow['entity_key'] ?? '';
if (!empty($entities)) { if (!empty($entities)) {
$matchedId = null; $propias = [];
foreach ($entities as $ev) { if ($entityKey !== '') {
foreach ($entities as $ek => $ev) {
if (self::normalize((string)$ek) === self::normalize($entityKey)) $propias[$ek] = $ev;
}
} else {
$propias = $entities; // flow sin entity_key: comportamiento histórico
}
$matchedId = $matchedLabel = $matchedEk = null;
foreach ($propias as $ek => $ev) {
$evNorm = self::normalize((string)$ev); $evNorm = self::normalize((string)$ev);
if ($evNorm === '') continue; 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 === '') 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; $matchedLabel = $label; $matchedEk = $ek;
break 2; break 2;
} }
} }
} }
if ($matchedId !== null) { if ($matchedId !== null) {
$formData = $meta[$group] ?? []; $meta = self::guardarSeleccion($meta, $group, $key, $matchedId, (string)$matchedLabel);
$formData[$key] = $matchedId; unset($meta['__nlu_entities'][$matchedEk]); // solo la consumida
$meta[$group] = $formData; if (empty($meta['__nlu_entities'])) unset($meta['__nlu_entities']);
ConversationContext::updateMetadata($ctxId, $meta); ConversationContext::updateMetadata($ctxId, $meta);
return self::advanceAfterSelect($nextNode, $context, $company, $ctxId); return self::advanceAfterSelect($nextNode, $context, $company, $ctxId);
} }
// Sin match: se descartan las entities y se muestra la lista normal
unset($meta['__nlu_entities']); // Sin match: las entities de otro campo siguen sirviendo más adelante
ConversationContext::updateMetadata($ctxId, $meta); if ($entityKey === '') {
unset($meta['__nlu_entities']);
ConversationContext::updateMetadata($ctxId, $meta);
}
} }
// Opciones fijas que redirigen a otro flow en vez de guardar el valor // Opciones fijas que redirigen a otro flow en vez de guardar el valor
@@ -363,14 +383,30 @@ class NormalBot
return self::buildDynamicListResponse($items, $flow, $context['from'], $company); return self::buildDynamicListResponse($items, $flow, $context['from'], $company);
} }
/**
* Guarda la opción elegida con su etiqueta, para poder mostrarle al usuario
* en qué finca/grupo está parado sin tener que volver a consultar la API.
*/
private static function guardarSeleccion(array $meta, string $group, string $key, string $id, string $label): array
{
if ($group === '') return $meta;
$datos = $meta[$group] ?? [];
$datos[$key] = $id;
if ($label !== '') $datos[$group . '_label'] = $label;
$meta[$group] = $datos;
return $meta;
}
/** /**
* Continúa tras resolver una selección: consume la intención pendiente y * Continúa tras resolver una selección: consume la intención pendiente y
* las entities, y ejecuta el destino. * las entities, y ejecuta el destino.
*/ */
private static function advanceAfterSelect(?string $nextNode, array $context, array $company, int $ctxId): ?array private static function advanceAfterSelect(?string $nextNode, array $context, array $company, int $ctxId): ?array
{ {
// Las entities que no consumió esta lista pueden ser de la siguiente
// ("finca reposo, grupo plateo" resuelve las dos), así que no se borran acá.
$meta = ConversationContext::getMetadata($ctxId); $meta = ConversationContext::getMetadata($ctxId);
unset($meta['__after_select'], $meta['__nlu_entities']); unset($meta['__after_select']);
ConversationContext::updateMetadata($ctxId, $meta); ConversationContext::updateMetadata($ctxId, $meta);
if ($nextNode !== null) { if ($nextNode !== null) {
@@ -380,7 +416,7 @@ class NormalBot
return self::handleFlow($flows[$nextNode], $context, $company, $ctxId, $menus, $nextNode); 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, $ctxId);
} }
} }
@@ -659,7 +695,7 @@ class NormalBot
{ {
$fe = $meta['__foreach']; $fe = $meta['__foreach'];
$endpointKey = $fe['endpoint_key'] ?? ''; $endpointKey = $fe['endpoint_key'] ?? '';
$nav = "\n\nEscribe *menu* para ver más opciones."; $nav = "\n\nEscribe *menú* para volver al inicio, *atrás* para subir un nivel o *salir* para terminar.";
// Build POST body: array of {id, valor} objects // Build POST body: array of {id, valor} objects
$body = []; $body = [];
@@ -1039,7 +1075,7 @@ class NormalBot
{ {
$cap = $meta['__cap']; $cap = $meta['__cap'];
$endpointKey = $cap['endpoint_key'] ?? ''; $endpointKey = $cap['endpoint_key'] ?? '';
$nav = "\n\nEscribe *menu* para ver más opciones."; $nav = "\n\nEscribe *menú* para volver al inicio, *atrás* para subir un nivel o *salir* para terminar.";
unset($meta['__cap']); unset($meta['__cap']);
ConversationContext::updateMetadata($ctxId, $meta); ConversationContext::updateMetadata($ctxId, $meta);
@@ -1187,7 +1223,7 @@ class NormalBot
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),
'menu' => self::buildMenuResponse(self::resolveMenu($flow['menu'] ?? [], $company, $menus), $context['from'], $company), 'menu' => self::buildMenuResponse(self::resolveMenu($flow['menu'] ?? [], $company, $menus), $context['from'], $company, $ctxId),
'function' => self::executeFunction($flow['function'] ?? '', $flow['params'] ?? [], $context, $company, $ctxId), 'function' => self::executeFunction($flow['function'] ?? '', $flow['params'] ?? [], $context, $company, $ctxId),
'collect_input' => self::handleCollectInput($flow, $context, $company, $ctxId), 'collect_input' => self::handleCollectInput($flow, $context, $company, $ctxId),
'dynamic_list' => self::handleDynamicList($flow, $context, $company, $ctxId), 'dynamic_list' => self::handleDynamicList($flow, $context, $company, $ctxId),
@@ -1269,7 +1305,7 @@ class NormalBot
// ── Handles sequential collection of URL vars before calling endpoint ─── // ── Handles sequential collection of URL vars before calling endpoint ───
private static function handleApiVarsInput(array $meta, string $input, array $context, array $company, int $ctxId, array $flows, array $menus): ?array private static function handleApiVarsInput(array $meta, string $input, array $context, array $company, int $ctxId, array $flows, array $menus): ?array
{ {
$nav = "\n\nEscribe *menu* para ver opciones o *salir* para terminar."; $nav = "\n\nEscribe *menú* para volver al inicio, *atrás* para subir un nivel o *salir* para terminar.";
$state = $meta['__api_vars']; $state = $meta['__api_vars'];
$pending = $state['pending'] ?? []; $pending = $state['pending'] ?? [];
@@ -1306,7 +1342,7 @@ class NormalBot
private static function executeApiReport(array $params, array $context, array $company, int $ctxId): ?array private static function executeApiReport(array $params, array $context, array $company, int $ctxId): ?array
{ {
$endpointKey = $params['endpoint_key'] ?? ''; $endpointKey = $params['endpoint_key'] ?? '';
$nav = "\n\nEscribe *menu* para ver más opciones o *salir* para terminar."; $nav = "\n\nEscribe *menú* para volver al inicio, *atrás* para subir un nivel o *salir* para terminar.";
if ($endpointKey === '') { if ($endpointKey === '') {
self::preservingReset($ctxId, ConversationContext::getMetadata($ctxId)); self::preservingReset($ctxId, ConversationContext::getMetadata($ctxId));
@@ -1634,7 +1670,7 @@ class NormalBot
} }
if (isset($menus[$resolvedInput])) { if (isset($menus[$resolvedInput])) {
ConversationContext::updateNode($ctxId, $resolvedInput); ConversationContext::updateNode($ctxId, $resolvedInput);
return self::buildMenuResponse($menus[$resolvedInput], $context['from'], $company); return self::buildMenuResponse($menus[$resolvedInput], $context['from'], $company, $ctxId);
} }
return null; return null;
@@ -1642,8 +1678,33 @@ class NormalBot
// ── WhatsApp senders ───────────────────────────────────────────────────── // ── WhatsApp senders ─────────────────────────────────────────────────────
private static function buildMenuResponse(array $menu, string $to, array $company): ?array /**
* Resuelve {finca} en los textos del menú y deja la finca activa en el footer,
* para que el usuario sepa siempre sobre qué está consultando.
*/
private static function aplicarVarsMenu(array $menu, ?int $ctxId, array $company): array
{ {
if ($ctxId === null) return $menu;
$meta = ConversationContext::getMetadata($ctxId);
$finca = (string)($meta['finca']['finca_label'] ?? '');
foreach (['header', 'body', 'footer'] as $campo) {
if (isset($menu[$campo]) && is_string($menu[$campo])) {
$menu[$campo] = str_replace('{finca}', $finca, $menu[$campo]);
}
}
// Sin finca (cat 1 solo reporta) queda el footer de siempre
if ($finca !== '' && !str_contains((string)($menu['footer'] ?? ''), $finca)) {
$menu['footer'] = '📍 ' . $finca;
}
return $menu;
}
private static function buildMenuResponse(array $menu, string $to, array $company, ?int $ctxId = null): ?array
{
$menu = self::aplicarVarsMenu($menu, $ctxId, $company);
$menuType = $menu['type'] ?? 'list'; $menuType = $menu['type'] ?? 'list';
if ($menuType === 'list') { if ($menuType === 'list') {
@@ -1696,6 +1757,10 @@ class NormalBot
'body' => ['text' => mb_substr($menu['body'] ?? 'Selecciona:', 0, 1024)], 'body' => ['text' => mb_substr($menu['body'] ?? 'Selecciona:', 0, 1024)],
'action' => ['buttons' => $buttons], 'action' => ['buttons' => $buttons],
]; ];
// Los submenús de ciclos son de botones: sin footer no se veía la finca
if (($menu['footer'] ?? '') !== '') {
$interactive['footer'] = ['text' => mb_substr($menu['footer'], 0, 60)];
}
return self::enqueueInteractive($to, $interactive, $company); return self::enqueueInteractive($to, $interactive, $company);
} }
+2
View File
@@ -213,6 +213,7 @@ $configJson = [
'section_title' => 'Fincas disponibles', 'section_title' => 'Fincas disponibles',
'meta_group' => 'finca', 'meta_group' => 'finca',
'meta_key' => 'finca_id', 'meta_key' => 'finca_id',
'entity_key' => 'finca',
'next_node' => 'show_main_menu', 'next_node' => 'show_main_menu',
'skip_if_set' => true, 'skip_if_set' => true,
'nlu_description' => 'Seleccionar o cambiar la finca de trabajo', 'nlu_description' => 'Seleccionar o cambiar la finca de trabajo',
@@ -322,6 +323,7 @@ $configJson = [
'section_title' => 'Grupos frecuentes', 'section_title' => 'Grupos frecuentes',
'meta_group' => 'grupo_mant', 'meta_group' => 'grupo_mant',
'meta_key' => 'grupo_id', 'meta_key' => 'grupo_id',
'entity_key' => 'grupo',
'next_node' => 'submenu_ciclo_mantenimiento', 'next_node' => 'submenu_ciclo_mantenimiento',
'append_options' => [['id' => 'select_grupo_mant_otros', 'title' => ' Otro grupo']], 'append_options' => [['id' => 'select_grupo_mant_otros', 'title' => ' Otro grupo']],
'nlu_description' => 'Informes de mantenimiento de un grupo (plateo, corona, poda...) — usar cuando NO dice qué informe quiere', 'nlu_description' => 'Informes de mantenimiento de un grupo (plateo, corona, poda...) — usar cuando NO dice qué informe quiere',
+63
View File
@@ -145,6 +145,69 @@ check('parado en un menú el NLU atiende',
check('en medio de una lista no interfiere', check('en medio de una lista no interfiere',
$atiende('collecting', '', null, $flows), 'nlu'); $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 "\nask_finca\n"; echo "\nask_finca\n";
$af = $flows['ask_finca']; $af = $flows['ask_finca'];
check('no repregunta si ya hay finca', $af['skip_if_set'] ?? false, true); check('no repregunta si ya hay finca', $af['skip_if_set'] ?? false, true);