From 0bb1d3447e5dff3fda4ffda4f4a2b9e56b601319 Mon Sep 17 00:00:00 2001 From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com> Date: Sat, 1 Aug 2026 11:06:35 -0500 Subject: [PATCH] feat: bienvenida diaria; fix: el NLU filtraba JSON crudo al usuario Bienvenida una vez por dia y por empresa, distinta por categoria (cat 1 no ve ejemplos de descarga). Se manda aparte del retorno de process(), asi el usuario recibe el saludo y acto seguido lo que pidio. __saludo sobrevive a preservingReset para no repetirse tras cada informe. Fix del leak: cuando la respuesta del modelo no parseaba como JSON se enviaba cruda, y el usuario veia {"action":"chat","text":"... Ahora se rescata solo el texto, incluso de JSON truncado, y si no hay nada legible se devuelve vacio para caer al menu de fallback. Ademas el prompt le prohibe preguntar por datos que una opcion ya pide: ante "descargar informe de mantenimiento" debe rutear al selector de grupo en vez de preguntar por chat. Co-Authored-By: Claude Sonnet 4.6 --- services/AiBot.php | 34 +++++++++++++++++++++--- services/NormalBot.php | 54 ++++++++++++++++++++++++++++++++++++++- setup/seed_palmas.php | 35 +++++++++++++++++++++++++ setup/test_navegacion.php | 45 ++++++++++++++++++++++++++++++++ 4 files changed, 164 insertions(+), 4 deletions(-) diff --git a/services/AiBot.php b/services/AiBot.php index d38e0a0..b051741 100644 --- a/services/AiBot.php +++ b/services/AiBot.php @@ -360,7 +360,10 @@ PROMPT; $json = json_decode($clean, true); if (!is_array($json) || !isset($json['action'])) { - return ['action' => 'chat', 'text' => $clean]; + // JSON inválido o truncado. Mandar $clean tal cual le escupía el + // {"action":"chat",...} al usuario, así que se rescata solo el texto. + self::log("NLU: respuesta no parseable → " . mb_substr($clean, 0, 200)); + return ['action' => 'chat', 'text' => self::rescatarTexto($clean)]; } if ($json['action'] === 'route' && isset($json['key'])) { @@ -377,7 +380,29 @@ PROMPT; } } - return ['action' => 'chat', 'text' => $json['text'] ?? $clean]; + return ['action' => 'chat', 'text' => self::rescatarTexto((string)($json['text'] ?? $clean))]; + } + + /** + * Extrae el texto legible de una respuesta que no parseó (típicamente JSON + * truncado por límite de tokens). Si no hay nada rescatable devuelve '', + * y NormalBot cae al menú de fallback — mejor eso que mostrarle JSON. + */ + private static function rescatarTexto(string $crudo): string + { + $crudo = trim($crudo); + if ($crudo === '') return ''; + + // "text":"lo que sirve → rescatar aunque falte el cierre + if (preg_match('/"text"\s*:\s*"(.*?)(?:"\s*[,}]|$)/s', $crudo, $m)) { + $texto = stripcslashes($m[1]); + return str_contains($texto, '{"action"') ? '' : trim($texto); + } + + // Cualquier resto con pinta de JSON o de key interna no se muestra + if (str_contains($crudo, '{"') || str_contains($crudo, '"action"')) return ''; + + return $crudo; } private static function buildFlowCatalog(array $config, int $permType): array @@ -460,7 +485,10 @@ PROMPT; . " \"qué lotes llevan más tiempo sin corona\" → {\"action\":\"route\",\"key\":\"ciclo_mantenimiento_top\",\"entities\":{\"grupo\":\"corona\"}}\n" . " \"mantenimiento\" (sin grupo) → {\"action\":\"route\",\"key\":\"ciclo_mantenimiento\"}\n" . " \"quiero cambiar de finca\" → {\"action\":\"route\",\"key\":\"reset_finca\"}\n\n" - . "Si no está claro o no hay opción correspondiente → responde SOLO:\n" + . "NUNCA preguntes por un dato que una opción ya pide (grupo, finca, período):\n" + . "ruteá a esa opción y el bot lo pregunta con su propia lista.\n" + . " \"descargar informe de mantenimiento\" → route a ciclo_mantenimiento, NO preguntar el grupo.\n\n" + . "Usá chat solo si el mensaje no corresponde a ninguna opción → responde SOLO:\n" . "{\"action\":\"chat\",\"text\":\"\"}\n\n" . "No inventes keys. Usa exactamente los keys de la lista."; } diff --git a/services/NormalBot.php b/services/NormalBot.php index 94d0582..73d3e55 100644 --- a/services/NormalBot.php +++ b/services/NormalBot.php @@ -19,6 +19,10 @@ class NormalBot $currentNode = $botCtx['current_node']; $normalized = self::normalize($input); + // 0. Saludo del día — va aparte y el flujo sigue igual, así el usuario + // recibe la bienvenida y acto seguido lo que haya pedido. + self::saludarUnaVezAlDia($config, $context, $company, $ctxId, $permType); + // 1. Commands always win — escape from any state (salir, menu, etc.) foreach ($commands as $keyword => $action) { if ($normalized === self::normalize((string)$keyword)) { @@ -383,6 +387,53 @@ class NormalBot return self::buildDynamicListResponse($items, $flow, $context['from'], $company); } + /** + * Bienvenida una vez por día y por empresa. Se envía por fuera del retorno + * de process() —igual que hace el selector multi-empresa— para no reemplazar + * la respuesta que el usuario vino a buscar. + */ + private static function saludarUnaVezAlDia(array $config, array $context, array $company, int $ctxId, string $permType): void + { + $welcome = $config['welcome'] ?? []; + if (empty($welcome['enabled'])) return; + + $texto = (string)($welcome['text'][$permType] ?? ''); + if (trim($texto) === '') return; + + $meta = ConversationContext::getMetadata($ctxId); + $hoy = date('Y-m-d'); // el punto de entrada fija America/Bogota + if (($meta['__saludo'] ?? '') === $hoy) return; + + $meta['__saludo'] = $hoy; + ConversationContext::updateMetadata($ctxId, $meta); + + $nombre = trim((string)($context['name'] ?? '')); + $texto = strtr($texto, [ + '{saludo}' => $nombre !== '' ? "Hola *{$nombre}*" : 'Hola', + '{empresa}' => (string)($company['display_name'] ?: $company['name'] ?? ''), + '{finca}' => (string)($meta['finca']['finca_label'] ?? ''), + '{empresas}' => self::esMultiEmpresa($context['from'] ?? '') + ? "\n *cambiar empresa* para consultar otra empresa" + : '', + ]); + + WhatsAppSender::sendText($context['from'], $texto, (string)($context['phone_number_id'] ?? '')); + } + + /** Solo tiene sentido ofrecer "cambiar empresa" a quien pertenece a varias. */ + private static function esMultiEmpresa(string $waNumber): bool + { + if ($waNumber === '') return false; + try { + $stmt = db()->prepare("SELECT companies_json FROM multi_company_sessions WHERE wa_number = ? LIMIT 1"); + $stmt->execute([$waNumber]); + $json = $stmt->fetchColumn(); + return $json !== false && count(json_decode((string)$json, true) ?: []) > 1; + } catch (\PDOException $e) { + return false; + } + } + /** * 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. @@ -1276,7 +1327,8 @@ class NormalBot $keep = []; // finca y grupo_mant se conservan: los submenús ofrecen varios informes sobre // la misma selección, y perderla dejaba {finca_id}/{grupo_id} sin resolver. - foreach (['finca', 'grupo_mant'] as $group) { + // __saludo también, si no se re-saluda después de cada informe. + foreach (['finca', 'grupo_mant', '__saludo'] as $group) { if (isset($meta[$group])) $keep[$group] = $meta[$group]; } ConversationContext::reset($ctxId); diff --git a/setup/seed_palmas.php b/setup/seed_palmas.php index 02fd794..1d93f3d 100644 --- a/setup/seed_palmas.php +++ b/setup/seed_palmas.php @@ -12,6 +12,41 @@ $configJson = [ 'greeting' => '¡Bienvenido a Palmas360! Escribe *menu* para ver las opciones disponibles.', 'fallback' => 'No entendí. Escribe *menu* para ver las opciones disponibles.', 'nlu_enabled' => true, + + // ── Bienvenida: una vez por día y por empresa ─────────────────────────── + // Tokens: {saludo} {empresa} {finca} {empresas} + // No menciona la finca porque llega antes de elegirla; la finca activa se + // ve en el footer de cada menú. + 'welcome' => [ + 'enabled' => true, + 'text' => [ + // Cat 1 — solo reporta: sin ejemplos de descarga, que no puede usar + '1' => "{saludo}, bienvenido a {empresa}.\n\n" + . "Desde acá enviás los ciclos de campo.\n" + . " • _\"subir ciclo de cosecha\"_\n\n" + . "Cuando quieras:\n" + . " *menú* ver las opciones · *salir* terminar{empresas}", + + '2' => "{saludo}, bienvenido a {empresa}.\n\n" + . "Pedime lo que necesites con tus palabras:\n" + . " • _\"informe de mantenimiento de plateo\"_\n" + . " • _\"quién faltó hoy\"_\n" + . " • _\"producción de este mes\"_\n\n" + . "O navegá con los menús. Cuando quieras:\n" + . " *menú* volver al inicio · *atrás* subir un nivel · *salir* terminar\n" + . " *cambiar finca* para consultar otra finca{empresas}", + + '3' => "{saludo}, bienvenido a {empresa}.\n\n" + . "Pedime lo que necesites con tus palabras:\n" + . " • _\"informe de mantenimiento de plateo\"_\n" + . " • _\"quién faltó hoy\"_\n" + . " • _\"subir ciclo de cosecha\"_\n\n" + . "O navegá con los menús. Cuando quieras:\n" + . " *menú* volver al inicio · *atrás* subir un nivel · *salir* terminar\n" + . " *cambiar finca* para consultar otra finca{empresas}", + ], + ], + 'commands' => [ // Al menú principal 'menu' => 'show_main_menu', diff --git a/setup/test_navegacion.php b/setup/test_navegacion.php index 5a0b8ee..cb18d6a 100644 --- a/setup/test_navegacion.php +++ b/setup/test_navegacion.php @@ -208,6 +208,51 @@ check('submenú de botones también la lleva', check('entra en los 60 caracteres del footer', mb_strlen($footer($menus['show_menu_cat2'], 'ROSA BLANCA')) <= 60, true); +echo "\nBienvenida diaria\n"; + +/** Espejo de saludarUnaVezAlDia(): devuelve el texto o '' si no toca saludar */ +$saludar = function (array $config, string $cat, array &$meta, string $hoy, string $nombre = 'Usite'): string { + $w = $config['welcome'] ?? []; + if (empty($w['enabled'])) return ''; + $texto = (string)($w['text'][$cat] ?? ''); + if (trim($texto) === '') return ''; + if (($meta['__saludo'] ?? '') === $hoy) return ''; + $meta['__saludo'] = $hoy; + return strtr($texto, [ + '{saludo}' => $nombre !== '' ? "Hola *{$nombre}*" : 'Hola', + '{empresa}' => 'Rosa Blanca', + '{finca}' => '', + '{empresas}' => '', + ]); +}; + +$meta = []; +$primero = $saludar($config, '3', $meta, '2026-08-01'); +check('saluda en el primer mensaje del día', $primero !== '', true); +check('personaliza con el nombre', str_contains($primero, 'Hola *Usite*'), true); +check('nombra la empresa', str_contains($primero, 'Rosa Blanca'), true); +check('informa los comandos', str_contains($primero, '*atrás*') && str_contains($primero, '*salir*'), true); +check('no deja tokens sin resolver', preg_match('/\{[a-z]+\}/', $primero), 0); + +check('no vuelve a saludar el mismo día', $saludar($config, '3', $meta, '2026-08-01'), ''); + +// preservingReset conserva __saludo, así que un informe no dispara otro saludo +$trasInforme = array_intersect_key($meta, array_flip(['finca', 'grupo_mant', '__saludo'])); +check('sobrevive a un informe', $saludar($config, '3', $trasInforme, '2026-08-01'), ''); + +check('al día siguiente saluda de nuevo', $saludar($config, '3', $meta, '2026-08-02') !== '', true); + +// Cat 1 no descarga informes: no debe ofrecerle cosas que no puede usar +$m1 = []; +$cat1 = $saludar($config, '1', $m1, '2026-08-01'); +check('cat 1 no ofrece descargas', str_contains($cat1, 'faltó hoy'), false); +check('cat 1 sí ofrece subir', str_contains($cat1, 'subir ciclo'), true); + +// Sin nombre no queda "Hola **," +$m2 = []; +check('sin nombre no rompe el saludo', + str_starts_with($saludar($config, '2', $m2, '2026-08-01', ''), 'Hola,'), true); + echo "\nask_finca\n"; $af = $flows['ask_finca']; check('no repregunta si ya hay finca', $af['skip_if_set'] ?? false, true);