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
if (isset($menus[$action])) {
return self::buildMenuResponse($menus[$action], $context['from'], $company);
return self::buildMenuResponse($menus[$action], $context['from'], $company, $ctxId);
}
return null;
}
@@ -91,14 +91,14 @@ class NormalBot
$welcomeMenuKey = $config['welcome_menu'] ?? null;
if ($welcomeMenuKey && $currentNode === null && isset($menus[$welcomeMenuKey])) {
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)
$greetingMenuKey = $perType['greeting_menu'] ?? null;
if ($greetingMenuKey !== null && $currentNode === null && isset($menus[$greetingMenuKey])) {
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
@@ -127,19 +127,22 @@ class NormalBot
ConversationContext::updateMetadata($ctxId, $m);
$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'])) {
$m = ConversationContext::getMetadata($ctxId);
$m['__nlu_entities'] = array_values($route['entities']);
ConversationContext::updateMetadata($ctxId, $m);
$m['__nlu_entities'] = $route['entities'];
}
ConversationContext::updateMetadata($ctxId, $m);
if (isset($flows[$key])) {
ConversationContext::updateNode($ctxId, $key);
return self::handleFlow($flows[$key], $context, $company, $ctxId, $menus, $key);
}
if (isset($menus[$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'] ?? '') !== '') {
@@ -166,7 +169,7 @@ class NormalBot
// 8. Greeting menu as fallback (texto no reconocido después de estar en __greeted)
if (!$suppressFallback && $greetingMenuKey !== null && isset($menus[$greetingMenuKey])) {
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
@@ -227,9 +230,12 @@ class NormalBot
return null;
}
$formData = $meta[$group] ?? [];
$formData[$key] = $resolved;
$meta[$group] = $formData;
// Se recupera la etiqueta del id elegido para poder mostrarla después
$etiqueta = '';
foreach ($labelMap as $label => $id) {
if ((string)$id === $resolved) { $etiqueta = (string)$label; break; }
}
$meta = self::guardarSeleccion($meta, $group, $key, $resolved, $etiqueta);
unset($meta['collecting']);
ConversationContext::updateMetadata($ctxId, $meta);
@@ -239,7 +245,7 @@ class NormalBot
return self::handleFlow($flows[$nextNode], $context, $company, $ctxId, $menus, $nextNode);
}
if (isset($menus[$nextNode])) {
return self::buildMenuResponse($menus[$nextNode], $context['from'], $company);
return self::buildMenuResponse($menus[$nextNode], $context['from'], $company, $ctxId);
}
} else {
ConversationContext::updateNode($ctxId, null);
@@ -301,39 +307,53 @@ class NormalBot
// Auto-select cuando hay exactamente 1 opción — no mostrar lista
if (count($items) === 1) {
$formData = $meta[$group] ?? [];
$formData[$key] = (string)($items[0][$valueField] ?? '');
$meta[$group] = $formData;
$meta = self::guardarSeleccion($meta, $group, $key, (string)($items[0][$valueField] ?? ''), (string)($items[0][$labelField] ?? ''));
ConversationContext::updateMetadata($ctxId, $meta);
return self::advanceAfterSelect($nextNode, $context, $company, $ctxId);
}
// Si el NLU extrajo entities, intentar auto-seleccionar sin mostrar lista
$entities = $meta['__nlu_entities'] ?? [];
// Si el NLU extrajo entities, intentar auto-seleccionar sin mostrar lista.
// 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)) {
$matchedId = null;
foreach ($entities as $ev) {
$propias = [];
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);
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;
$matchedId = $id; $matchedLabel = $label; $matchedEk = $ek;
break 2;
}
}
}
if ($matchedId !== null) {
$formData = $meta[$group] ?? [];
$formData[$key] = $matchedId;
$meta[$group] = $formData;
$meta = self::guardarSeleccion($meta, $group, $key, $matchedId, (string)$matchedLabel);
unset($meta['__nlu_entities'][$matchedEk]); // solo la consumida
if (empty($meta['__nlu_entities'])) unset($meta['__nlu_entities']);
ConversationContext::updateMetadata($ctxId, $meta);
return self::advanceAfterSelect($nextNode, $context, $company, $ctxId);
}
// Sin match: se descartan las entities y se muestra la lista normal
unset($meta['__nlu_entities']);
ConversationContext::updateMetadata($ctxId, $meta);
// Sin match: las entities de otro campo siguen sirviendo más adelante
if ($entityKey === '') {
unset($meta['__nlu_entities']);
ConversationContext::updateMetadata($ctxId, $meta);
}
}
// 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);
}
/**
* 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
* las entities, y ejecuta el destino.
*/
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);
unset($meta['__after_select'], $meta['__nlu_entities']);
unset($meta['__after_select']);
ConversationContext::updateMetadata($ctxId, $meta);
if ($nextNode !== null) {
@@ -380,7 +416,7 @@ class NormalBot
return self::handleFlow($flows[$nextNode], $context, $company, $ctxId, $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'];
$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
$body = [];
@@ -1039,7 +1075,7 @@ class NormalBot
{
$cap = $meta['__cap'];
$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']);
ConversationContext::updateMetadata($ctxId, $meta);
@@ -1187,7 +1223,7 @@ class NormalBot
return match ($type) {
'text' => self::sendText($flow['message'] ?? '', $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),
'collect_input' => self::handleCollectInput($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 ───
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'];
$pending = $state['pending'] ?? [];
@@ -1306,7 +1342,7 @@ class NormalBot
private static function executeApiReport(array $params, array $context, array $company, int $ctxId): ?array
{
$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 === '') {
self::preservingReset($ctxId, ConversationContext::getMetadata($ctxId));
@@ -1634,7 +1670,7 @@ class NormalBot
}
if (isset($menus[$resolvedInput])) {
ConversationContext::updateNode($ctxId, $resolvedInput);
return self::buildMenuResponse($menus[$resolvedInput], $context['from'], $company);
return self::buildMenuResponse($menus[$resolvedInput], $context['from'], $company, $ctxId);
}
return null;
@@ -1642,8 +1678,33 @@ class NormalBot
// ── 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';
if ($menuType === 'list') {
@@ -1696,6 +1757,10 @@ class NormalBot
'body' => ['text' => mb_substr($menu['body'] ?? 'Selecciona:', 0, 1024)],
'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);
}
+2
View File
@@ -213,6 +213,7 @@ $configJson = [
'section_title' => 'Fincas disponibles',
'meta_group' => 'finca',
'meta_key' => 'finca_id',
'entity_key' => 'finca',
'next_node' => 'show_main_menu',
'skip_if_set' => true,
'nlu_description' => 'Seleccionar o cambiar la finca de trabajo',
@@ -322,6 +323,7 @@ $configJson = [
'section_title' => 'Grupos frecuentes',
'meta_group' => 'grupo_mant',
'meta_key' => 'grupo_id',
'entity_key' => 'grupo',
'next_node' => 'submenu_ciclo_mantenimiento',
'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',
+63
View File
@@ -145,6 +145,69 @@ check('parado en un menú el NLU atiende',
check('en medio de una lista no interfiere',
$atiende('collecting', '', null, $flows), 'nlu');
echo "\nEntities por campo\n";
/** Espejo del matching de handleDynamicList con entity_key */
$matchear = function (array $flow, array $entities, array $etiquetas): array {
$ek = $flow['entity_key'] ?? '';
$propias = [];
if ($ek !== '') {
foreach ($entities as $k => $v) if (mb_strtolower($k) === mb_strtolower($ek)) $propias[$k] = $v;
} else {
$propias = $entities;
}
foreach ($propias as $k => $v) {
foreach ($etiquetas as $etiqueta) {
$a = mb_strtolower($v); $b = mb_strtolower($etiqueta);
if ($a === $b || str_contains($a, $b) || str_contains($b, $a)) {
unset($entities[$k]);
return ['match' => $etiqueta, 'sobran' => $entities];
}
}
}
return ['match' => null, 'sobran' => $entities];
};
$dichas = ['finca' => 'reposo', 'grupo' => 'plateo'];
$fincas = ['ROSA BLANCA', 'REPOSO', '🌐 Todas las fincas'];
$grupos = ['PLATEO', 'CORONA', 'PODA'];
// Cada lista toma lo suyo y deja el resto para la siguiente
$r1 = $matchear($flows['ask_finca'], $dichas, $fincas);
check('ask_finca toma la finca', $r1['match'], 'REPOSO');
check('y deja el grupo para después', $r1['sobran'], ['grupo' => 'plateo']);
$r2 = $matchear($flows['ciclo_mantenimiento'], $r1['sobran'], $grupos);
check('ciclo_mantenimiento toma el grupo', $r2['match'], 'PLATEO');
check('no queda nada sin consumir', $r2['sobran'], []);
// El bug que motivó todo: una finca no debe matchear contra grupos
$r3 = $matchear($flows['ciclo_mantenimiento'], ['finca' => 'reposo'], $grupos);
check('una finca no se consume como grupo', $r3['match'], null);
check('y sobrevive para ask_finca', $r3['sobran'], ['finca' => 'reposo']);
echo "\nFinca visible en el menú\n";
/** Espejo de aplicarVarsMenu() */
$footer = function (array $menu, string $finca): string {
$f = $menu['footer'] ?? '';
$f = str_replace('{finca}', $finca, $f);
if ($finca !== '' && !str_contains($f, $finca)) $f = '📍 ' . $finca;
return $f;
};
$menus = $config['menus'];
check('lista muestra la finca activa',
$footer($menus['show_menu_cat2'], 'ROSA BLANCA'), '📍 ROSA BLANCA');
check('"todas" se lee natural',
$footer($menus['show_menu_cat2'], '🌐 Todas las fincas'), '📍 🌐 Todas las fincas');
check('sin finca queda el footer de siempre',
$footer($menus['show_menu_cat2'], ''), 'Palmas360');
check('submenú de botones también la lleva',
$footer($menus['submenu_ciclo_mantenimiento'], 'REPOSO'), '📍 REPOSO');
check('entra en los 60 caracteres del footer',
mb_strlen($footer($menus['show_menu_cat2'], 'ROSA BLANCA')) <= 60, true);
echo "\nask_finca\n";
$af = $flows['ask_finca'];
check('no repregunta si ya hay finca', $af['skip_if_set'] ?? false, true);