fix: decir una categoria a secas devolvia el menu principal

Los submenus estaban marcados nlu_skip para que la IA no ruteara a navegacion,
pero eso dejo solo las hojas en el catalogo: "ausentismo" tenia cuatro
candidatos y ninguno se llamaba asi, "sanidad" ninguno, "produccion" dos. Como
no podia elegir, caia al fallback y aparecia el menu principal, que se siente
como que ignoro el mensaje.

Las cuatro categorias vuelven a ser ruteables. Para que el filtro de permisos
las alcance —por tipo un menu no es ni subida ni descarga— se agrega nlu_dir,
con lo que cat 1 solo ve registrar y cat 2 solo consultar, y la ambiguedad se
resuelve sola. Cat 3 ve las dos, asi que tiene un menu que pregunta cual.

Cuando aun asi no rutea, ahora avisa antes de mostrar el menu.

Se quita el aprendizaje por usuario: el orden por uso lo resuelve Palmas360
sobre los registros reales, y dos criterios compitiendo sobraba.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-08-05 23:20:25 -05:00
co-authored by Claude Sonnet 4.6
parent 2d92867431
commit 6a6f2ef44a
4 changed files with 82 additions and 68 deletions
+10 -2
View File
@@ -428,9 +428,15 @@ PROMPT;
$type = $flow['type'] ?? 'text';
$fn = $flow['function'] ?? '';
// nlu_dir deja que un menú de categoría declare su dirección: por tipo
// no es ni subida ni descarga, y sin eso el filtro de permisos no lo
// tocaba y se le ofrecían informes a quien solo reporta.
$dirDeclarada = $flow['nlu_dir'] ?? '';
$isUpload = in_array($type, ['collect_and_post', 'collect_for_each', 'submit_form'], true)
|| ($type === 'function' && $fn === 'upload_ciclo');
$isDownload = ($type === 'function' && $fn === 'api_report');
|| ($type === 'function' && $fn === 'upload_ciclo')
|| $dirDeclarada === 'subida';
$isDownload = ($type === 'function' && $fn === 'api_report')
|| $dirDeclarada === 'descarga';
// Permission filter:
// type 1 = solo reporta (upload), type 2 = solo recibe (download), type 3 = ambos
@@ -479,6 +485,8 @@ PROMPT;
. "Pero elegí el key así:\n"
. " · Dijo QUÉ informe (\"todos los lotes\", \"más atrasados\", \"histórico\") → ruteá a ese informe.\n"
. " · Solo nombró el tema y el filtro → ruteá al menú del tema, para que él elija el informe.\n"
. " · Nombró solo una categoría (\"ausentismo\", \"producción\", \"sanidad\", \"ciclos\") → ruteá al\n"
. " menú de esa categoría. NUNCA respondas por chat pidiendo que aclare: el menú ya pregunta.\n"
. "Ejemplos:\n"
. " \"quiero el informe de mantenimiento de plateo\"{\"action\":\"route\",\"key\":\"ciclo_mantenimiento\",\"entities\":{\"grupo\":\"plateo\"}}\n"
. " \"mantenimiento de plateo, todos los lotes\"{\"action\":\"route\",\"key\":\"ciclo_mantenimiento_todos\",\"entities\":{\"grupo\":\"plateo\"}}\n"
+12 -37
View File
@@ -159,6 +159,17 @@ class NormalBot
if (!$inMenu) ConversationContext::updateNode($ctxId, null);
return self::sendText($route['text'], $context['from'], $company);
}
// El NLU no supo qué hacer. Sin este aviso, abajo aparece el menú
// principal sin explicación y se siente como que ignoró el mensaje.
$noEntendi = true;
}
if (!empty($noEntendi)) {
WhatsAppSender::sendText(
$context['from'],
'No estoy seguro de qué necesitas. ¿Alguna de estas opciones?',
(string)($context['phone_number_id'] ?? '')
);
}
// Re-show current menu when NLU didn't route anything
@@ -765,34 +776,6 @@ class NormalBot
return self::foreachAskNext($meta['__foreach'], $context['from'], $company);
}
/**
* Sube al principio lo que este usuario elige más seguido. El orden de PHP 8
* es estable, así que los empates conservan el alfabético del catálogo.
*
* Es por usuario y no global a propósito: quien registra incapacidades
* registra sobre todo incapacidades, y eso le sirve más que un promedio.
*/
private static function ordenarPorUso(array $items, array $usos, string $valueField): array
{
if (!$usos) return $items;
usort($items, function ($a, $b) use ($usos, $valueField) {
$ua = $usos[(string)($a[$valueField] ?? '')] ?? 0;
$ub = $usos[(string)($b[$valueField] ?? '')] ?? 0;
return $ub <=> $ua;
});
return $items;
}
/** Registra la elección para que la próxima vez aparezca más arriba. */
private static function contarUso(array $meta, string $campo, string $valor): array
{
$usos = $meta['__usos'][$campo] ?? [];
$usos[$valor] = ($usos[$valor] ?? 0) + 1;
arsort($usos);
$meta['__usos'][$campo] = array_slice($usos, 0, 20, true);
return $meta;
}
/**
* Reemplaza {campo} con lo ya recolectado. Permite que una clave de endpoint
* o una URL dependan de lo que el usuario eligió antes: los lotes a abrir
@@ -1207,9 +1190,6 @@ class NormalBot
if (($field['type'] ?? 'text') === 'select') {
$cap['collected_labels'][$field['key']] =
$cap['__selects'][$field['key']][$value] ?? $value;
if (!empty($field['frecuentes'])) {
$meta = self::contarUso($meta, $field['key'], $value);
}
}
$cap['index'] = $idx + 1;
@@ -1346,11 +1326,6 @@ class NormalBot
$lf = $field['label_field'] ?? 'label';
$items = self::fetchDynamicList($field['source_endpoint_key'] ?? '', $company, ['__ep_vars_combined' => $cap['collected'] ?? []]);
// Los que este usuario elige más seguido, primero
if (!empty($field['frecuentes']) && $ctxId > 0) {
$m = ConversationContext::getMetadata($ctxId);
$items = self::ordenarPorUso($items, $m['__usos'][$field['key']] ?? [], $vf);
}
if (!empty($items)) {
if ($ctxId > 0) {
@@ -1671,7 +1646,7 @@ class NormalBot
// 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.
// __saludo también, si no se re-saluda después de cada informe.
foreach (['finca', 'grupo_mant', '__saludo', '__usos'] as $group) {
foreach (['finca', 'grupo_mant', '__saludo'] as $group) {
if (isset($meta[$group])) $keep[$group] = $meta[$group];
}
ConversationContext::reset($ctxId);