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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
8825f4e821
commit
0bb1d3447e
+31
-3
@@ -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\":\"<respuesta corta en español, máx 2 oraciones>\"}\n\n"
|
||||
. "No inventes keys. Usa exactamente los keys de la lista.";
|
||||
}
|
||||
|
||||
+53
-1
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user