fix: el NLU respeta los modulos del perfil
Los menus se filtraban por modulo pero el texto libre no: un perfil limitado a pluviometria podia escribir "registrar ausentismo" y el NLU lo llevaba igual. El mismo agujero que ya cerramos con las fincas. moduloDeFlow() decide por convencion de nombre a que modulo pertenece cada flow —'modulo' explicito manda— y se aplica en dos puntos: el catalogo que ve el modelo llega filtrado, y el ruteo verifica igual la clave devuelta por si el modelo alucina una que no le ofrecieron. Lo permitido sigue directo: "subir pluviometria" cae en la fecha con un solo mensaje, "subir labores diarias" arranca el flujo. Cinco casos nuevos. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
9ae4b0dee3
commit
6c524a39ed
+10
-3
@@ -334,11 +334,11 @@ PROMPT;
|
||||
* Dado un mensaje de texto, decide si enrutar a un flow existente o responder en chat libre.
|
||||
* Devuelve: ['action'=>'route','key'=>'flow_key'] | ['action'=>'chat','text'=>'...']
|
||||
*/
|
||||
public static function routeOrChat(array $company, array $context, string $input): array
|
||||
public static function routeOrChat(array $company, array $context, string $input, array $modulosPermitidos = []): array
|
||||
{
|
||||
$config = self::getConfig($company);
|
||||
$permType = (int)($context['permission_type'] ?? 1);
|
||||
$catalog = self::buildFlowCatalog($config, $permType);
|
||||
$catalog = self::buildFlowCatalog($config, $permType, $modulosPermitidos);
|
||||
|
||||
if (empty($catalog)) {
|
||||
return ['action' => 'chat', 'text' => ''];
|
||||
@@ -405,7 +405,7 @@ PROMPT;
|
||||
return $crudo;
|
||||
}
|
||||
|
||||
private static function buildFlowCatalog(array $config, int $permType): array
|
||||
private static function buildFlowCatalog(array $config, int $permType, array $modulosPermitidos = []): array
|
||||
{
|
||||
$flows = $config['flows'] ?? [];
|
||||
$menus = $config['menus'] ?? [];
|
||||
@@ -446,6 +446,13 @@ PROMPT;
|
||||
// Flujos marcados como internos — no exponer al NLU
|
||||
if (!empty($flow['nlu_skip'])) continue;
|
||||
|
||||
// El perfil del número acota qué módulos puede usar: lo que el menú
|
||||
// no muestra, el NLU tampoco lo ofrece
|
||||
if ($modulosPermitidos) {
|
||||
$mod = NormalBot::moduloDeFlow($key, $flow);
|
||||
if ($mod !== null && !in_array($mod, $modulosPermitidos, true)) continue;
|
||||
}
|
||||
|
||||
// Skip internal/navigation flows unless they have an explicit NLU description
|
||||
$hasNluDesc = isset($flow['nlu_description']) && $flow['nlu_description'] !== '';
|
||||
if (in_array($type, ['text', 'image'], true) && !$isUpload && !$isDownload && !$hasNluDesc) continue;
|
||||
|
||||
+43
-2
@@ -124,7 +124,8 @@ class NormalBot
|
||||
// 6. NLU routing — si está habilitado, intenta mapear texto libre a un flow antes del fallback
|
||||
if (!$suppressFallback && ($config['nlu_enabled'] ?? false)) {
|
||||
$context['permission_type'] ??= (int)$permType;
|
||||
$route = AiBot::routeOrChat($company, $context, $input);
|
||||
$permitidosNlu = self::modulosPermitidos($company, $context['from'] ?? '');
|
||||
$route = AiBot::routeOrChat($company, $context, $input, $permitidosNlu);
|
||||
if ($route['action'] === 'route' && isset($route['key'])) {
|
||||
$key = $route['key'];
|
||||
// reset_finca: limpiar finca del meta y relanzar ask_finca
|
||||
@@ -143,7 +144,12 @@ class NormalBot
|
||||
$m['__nlu_entities'] = $route['entities'];
|
||||
}
|
||||
ConversationContext::updateMetadata($ctxId, $m);
|
||||
if (isset($flows[$key])) {
|
||||
// El catálogo ya viene filtrado, pero el modelo puede devolver
|
||||
// una clave que no le ofrecieron: se verifica igual acá.
|
||||
$moduloRuta = isset($flows[$key]) ? self::moduloDeFlow($key, $flows[$key]) : null;
|
||||
$vetado = $permitidosNlu && $moduloRuta !== null && !in_array($moduloRuta, $permitidosNlu, true);
|
||||
|
||||
if (!$vetado && isset($flows[$key])) {
|
||||
ConversationContext::updateNode($ctxId, $key);
|
||||
return self::handleFlow($flows[$key], $context, $company, $ctxId, $menus, $key);
|
||||
}
|
||||
@@ -479,6 +485,41 @@ class NormalBot
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* A qué módulo pertenece un flow. Por convención de nombre para no tener
|
||||
* que etiquetar los ~40 informes uno por uno; 'modulo' explícito manda.
|
||||
* Null = sin módulo: navegación, finca, etc. — nunca se restringe.
|
||||
*/
|
||||
public static function moduloDeFlow(string $key, array $flow): ?string
|
||||
{
|
||||
if (!empty($flow['modulo'])) return $flow['modulo'];
|
||||
if (str_starts_with($key, 'registrar_labor')) return 'labores';
|
||||
$mapa = [
|
||||
'registrar_pluviometria' => 'pluviometria',
|
||||
'registrar_ausentismo' => 'ausentismos',
|
||||
'registrar_ciclo' => 'ciclos',
|
||||
'registrar_mantenimiento'=> 'mantenimiento',
|
||||
'submenu_produccion' => 'produccion',
|
||||
'submenu_ausentismos' => 'ausentismos',
|
||||
];
|
||||
if (isset($mapa[$key])) return $mapa[$key];
|
||||
if (str_starts_with($key, 'produccion_')) return 'produccion';
|
||||
if (str_starts_with($key, 'ausentismos_')) return 'ausentismos';
|
||||
if (str_starts_with($key, 'ciclo_') || str_starts_with($key, 'submenu_ciclo')) return 'ciclos';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Módulos habilitados del número, aplanados. Vacío = sin restricción. */
|
||||
private static function modulosPermitidos(array $company, string $waNumber): array
|
||||
{
|
||||
$perfil = self::perfilDelNumero($company, $waNumber);
|
||||
if (!$perfil['modulos']) return [];
|
||||
return array_merge(
|
||||
(array)($perfil['modulos']['descarga'] ?? []),
|
||||
(array)($perfil['modulos']['carga'] ?? [])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Quita del menu las filas de modulos que este numero no tiene habilitados.
|
||||
* Sin modulos declarados no se filtra nada: la ausencia significa "todos",
|
||||
|
||||
@@ -153,8 +153,11 @@ class AiBot
|
||||
/** Cola de respuestas para routeOrChat; vacía = chat vacío (cae al fallback). */
|
||||
public static array $respuestas = [];
|
||||
|
||||
public static function routeOrChat(array $company, array $context, string $input): array
|
||||
public static array $modulosRecibidos = [];
|
||||
|
||||
public static function routeOrChat(array $company, array $context, string $input, array $modulosPermitidos = []): array
|
||||
{
|
||||
self::$modulosRecibidos = $modulosPermitidos;
|
||||
return array_shift(self::$respuestas) ?? ['action' => 'chat', 'text' => ''];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,6 +212,44 @@ $get = array_values(array_filter(capturas(), fn($c) => str_contains($c['peticion
|
||||
check('la entity resolvió el grupo en la URL', $get['query']['grupo'] ?? null, '5');
|
||||
check('y la consulta dice quién pregunta', $get['query']['wa'] ?? null, '57300NLU');
|
||||
|
||||
// ════ 8. NLU coherente con los módulos del perfil ═════════════════════════════
|
||||
echo "\nNLU — respeta los módulos habilitados\n";
|
||||
|
||||
// Este número solo puede cargar pluviometría
|
||||
fakePhone('57300SOLOPLUVIO', null, 0);
|
||||
FakeDb::$phones['57300SOLOPLUVIO']['modulos_json'] = json_encode(['carga' => ['pluviometria']]);
|
||||
|
||||
$ctx = contexto('57300SOLOPLUVIO');
|
||||
$id = ConversationContext::getOrCreate(1, '57300SOLOPLUVIO')['id'];
|
||||
ConversationContext::updateMetadata($id, ['finca' => ['finca_id' => '4', 'finca_label' => 'REPOSO']]);
|
||||
ConversationContext::updateNode($id, '__greeted');
|
||||
|
||||
// "subir pluviometría" → directo a la fecha, un solo mensaje
|
||||
AiBot::$respuestas = [['action' => 'route', 'key' => 'registrar_pluviometria']];
|
||||
$r = NormalBot::process($co, $ctx, 'subir pluviometría');
|
||||
check('"subir pluviometría" va de una a la fecha', str_contains(textoDe($r), 'De qué fecha'));
|
||||
check('el NLU recibió su alcance de módulos', AiBot::$modulosRecibidos, ['pluviometria']);
|
||||
|
||||
// El modelo devuelve una clave fuera del alcance: el guard la corta
|
||||
ConversationContext::updateNode($id, '__greeted');
|
||||
ConversationContext::updateMetadata($id, ['finca' => ['finca_id' => '4', 'finca_label' => 'REPOSO']]);
|
||||
WhatsAppSender::$enviados = [];
|
||||
AiBot::$respuestas = [['action' => 'route', 'key' => 'registrar_ausentismo']];
|
||||
$r = NormalBot::process($co, $ctx, 'registrar ausentismo');
|
||||
check('un módulo vetado no ejecuta el flujo', !str_contains(textoDe($r), 'nombre o documento'));
|
||||
check('avisa que no entendió',
|
||||
str_contains(WhatsAppSender::$enviados[0]['text'] ?? '', 'No estoy seguro'));
|
||||
|
||||
// Sin restricción, "subir labores diarias" también entra directo
|
||||
$ctx = contexto('57300LABORNLU');
|
||||
$id = ConversationContext::getOrCreate(1, '57300LABORNLU')['id'];
|
||||
ConversationContext::updateMetadata($id, ['finca' => ['finca_id' => '4', 'finca_label' => 'REPOSO']]);
|
||||
ConversationContext::updateNode($id, '__greeted');
|
||||
AiBot::$respuestas = [['action' => 'route', 'key' => 'registrar_labor']];
|
||||
$r = NormalBot::process($co, $ctx, 'subir labores diarias');
|
||||
check('"subir labores diarias" arranca el flujo directo', str_contains(textoDe($r), 'De qué fecha es la labor'));
|
||||
capturas();
|
||||
|
||||
// ════ Resultado ═══════════════════════════════════════════════════════════════
|
||||
echo "\n" . ($GLOBALS['fallas'] ? "{$GLOBALS['fallas']} falla(s)\n" : "Todos los flujos ejecutan de punta a punta\n");
|
||||
exit($GLOBALS['fallas'] ? 1 : 0);
|
||||
|
||||
Reference in New Issue
Block a user