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:
Lizandro Guarnizo
2026-08-01 11:06:35 -05:00
co-authored by Claude Sonnet 4.6
parent 8825f4e821
commit 0bb1d3447e
4 changed files with 164 additions and 4 deletions
+53 -1
View File
@@ -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);