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:
co-authored by
Claude Sonnet 4.6
parent
693623e10c
commit
a64421c527
+101
-36
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user