feat: NLU fallback — route unrecognized text to AI in hybrid+NLU mode

NormalBot.process() gains suppressFallback param. When true, steps 7-8
(greeting menu fallback, static fallback text) return null instead of
responding, letting BotRouter hand off to NLU/AI.

BotRouter.runHybridBot() passes suppressFallback=$nluEnabled so that:
- NLU on: unrecognized text → NLU → route to flow or AI chat
- NLU off: existing fallback behavior unchanged
- Commands, active flows, first-session greeting: always unaffected

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-07-05 09:09:49 -05:00
co-authored by Claude Sonnet 4.6
parent 54d778783e
commit 497743ceb1
2 changed files with 264 additions and 45 deletions
+5 -3
View File
@@ -87,12 +87,12 @@ class BotRouter
};
}
private static function runNormalBot(array $company, array $context, string $input, string $inputType): ?array
private static function runNormalBot(array $company, array $context, string $input, string $inputType, bool $suppressFallback = false): ?array
{
if ($inputType === 'interactive' || $inputType === 'button') {
return NormalBot::processInteractive($company, $context, $input);
}
return NormalBot::process($company, $context, $input);
return NormalBot::process($company, $context, $input, $suppressFallback);
}
private static function runAiBot(array $company, array $context, string $input): ?array
@@ -140,7 +140,9 @@ class BotRouter
}
// ── Texto / interactivo: NormalBot primero ────────────────────────────
$response = self::runNormalBot($company, $context, $input, $inputType);
// suppressFallback=true cuando NLU activo: si NormalBot no reconoce, retorna null
// y dejamos que NLU decida en vez de mostrar el menú de bienvenida como fallback
$response = self::runNormalBot($company, $context, $input, $inputType, $nluEnabled);
if ($response !== null) {
return $response;
}
+259 -42
View File
@@ -3,7 +3,7 @@ declare(strict_types=1);
class NormalBot
{
public static function process(array $company, array $context, string $input): ?array
public static function process(array $company, array $context, string $input, bool $suppressFallback = false): ?array
{
$config = self::getConfig($company);
$permType = (string)($company['_permission_type'] ?? 1);
@@ -111,16 +111,18 @@ class NormalBot
}
// 7. Greeting menu as fallback (texto no reconocido después de estar en __greeted)
if ($greetingMenuKey !== null && isset($menus[$greetingMenuKey])) {
if (!$suppressFallback && $greetingMenuKey !== null && isset($menus[$greetingMenuKey])) {
ConversationContext::updateNode($ctxId, '__greeted');
return self::buildMenuResponse($menus[$greetingMenuKey], $context['from'], $company);
}
// 8. Static fallback text
$fallback = $perType['fallback'] ?? $config['fallback'] ?? null;
if ($fallback !== null) {
ConversationContext::updateNode($ctxId, null);
return self::sendText($fallback, $context['from'], $company);
if (!$suppressFallback) {
$fallback = $perType['fallback'] ?? $config['fallback'] ?? null;
if ($fallback !== null) {
ConversationContext::updateNode($ctxId, null);
return self::sendText($fallback, $context['from'], $company);
}
}
return null;
@@ -258,34 +260,46 @@ class NormalBot
private static function buildDynamicListResponse(array $items, array $flow, string $to, array $company): ?array
{
$valueField = $flow['value_field'] ?? 'id';
$labelField = $flow['label_field'] ?? 'name';
$valueField = $flow['value_field'] ?? 'id';
$labelField = $flow['label_field'] ?? 'name';
// Auto-detect multi-section if items carry a 'section' key
$sectionField = $flow['section_field'] ?? (isset($items[0]['section']) ? 'section' : null);
$rows = [];
foreach (array_slice($items, 0, 10) as $item) {
$id = (string)($item[$valueField] ?? '');
$title = mb_substr((string)($item[$labelField] ?? $id), 0, 24);
if ($id === '') continue;
$rows[] = ['id' => $id, 'title' => $title];
if ($sectionField !== null) {
$grouped = [];
foreach ($items as $item) {
$id = (string)($item[$valueField] ?? '');
if ($id === '') continue;
$title = mb_substr((string)($item[$labelField] ?? $id), 0, 24);
$sec = mb_substr((string)($item[$sectionField] ?? 'Opciones'), 0, 24);
$grouped[$sec][] = ['id' => $id, 'title' => $title];
}
$sections = [];
foreach (array_slice($grouped, 0, 10, true) as $secTitle => $rows) {
$sections[] = ['title' => $secTitle, 'rows' => array_slice($rows, 0, 10)];
}
} else {
$rows = [];
foreach (array_slice($items, 0, 10) as $item) {
$id = (string)($item[$valueField] ?? '');
if ($id === '') continue;
$rows[] = ['id' => $id, 'title' => mb_substr((string)($item[$labelField] ?? $id), 0, 24)];
}
$sections = [['title' => mb_substr($flow['section_title'] ?? 'Opciones', 0, 24), 'rows' => $rows]];
}
if (empty($rows)) return null;
if (empty($sections)) return null;
$interactive = [
return self::enqueueInteractive($to, [
'type' => 'list',
'header' => ['type' => 'text', 'text' => mb_substr($flow['header'] ?? 'Selecciona', 0, 60)],
'body' => ['text' => mb_substr($flow['body'] ?: 'Elige una opción:', 0, 1024)],
'footer' => ['text' => mb_substr($company['display_name'] ?? '', 0, 60)],
'action' => [
'button' => mb_substr($flow['button'] ?? 'Ver opciones', 0, 20),
'sections' => [[
'title' => mb_substr($flow['section_title'] ?? 'Opciones', 0, 24),
'rows' => $rows,
]],
'sections' => $sections,
],
];
return self::enqueueInteractive($to, $interactive, $company);
], $company);
}
// ── collect_for_each — pide un valor por cada ítem de una lista dinámica ──
@@ -471,7 +485,7 @@ class NormalBot
];
ConversationContext::updateMetadata($ctxId, $meta);
return self::capAskNext($meta['__cap'], $context['from'], $company);
return self::capAskNext($meta['__cap'], $context['from'], $company, $ctxId);
}
private static function handleCapInput(array $meta, string $input, array $context, array $company, int $ctxId): ?array
@@ -480,48 +494,222 @@ class NormalBot
$fields = $cap['fields'];
$idx = (int)$cap['index'];
$field = $fields[$idx];
$value = trim($input);
// ── 1. Awaiting manual text after user picked "Otra opción" ──────────
if (!empty($cap['__awaiting_other'])) {
if (($field['other_validate'] ?? '') === 'date'
&& !preg_match('/^\d{4}-\d{2}-\d{2}$/', $value)) {
return self::sendText(
'⚠️ Formato inválido. ' . ($field['other_prompt'] ?? 'Ingresa la fecha como YYYY-MM-DD (ej: ' . date('Y-m-d') . '):'),
$context['from'], $company
);
}
$cap['collected'][$field['key']] = $value;
$cap['collected_labels'][$field['key']] = $value;
unset($cap['__awaiting_other']);
$cap['index'] = $idx + 1;
$meta['__cap'] = $cap;
ConversationContext::updateMetadata($ctxId, $meta);
if ($cap['index'] < count($fields)) {
return self::capAskNext($cap, $context['from'], $company, $ctxId);
}
return self::capShowConfirmOrSubmit($cap, $meta, $context, $company, $ctxId);
}
// ── 2. User chose "Otra opción" — ask for manual text ────────────────
if ($value === '__cap_other') {
$cap['__awaiting_other'] = true;
$meta['__cap'] = $cap;
ConversationContext::updateMetadata($ctxId, $meta);
$prompt = $field['other_prompt'] ?? '✏️ Ingresa el valor manualmente:';
return self::sendText("*{$cap['header']}*\n\n{$prompt}", $context['from'], $company);
}
// ── 3. Lookup field — search entity by entered value (e.g. cédula) ───
if (($field['type'] ?? 'text') === 'lookup') {
$result = self::callLookupEndpoint(
$field['lookup_endpoint_key'] ?? '',
$value,
$field['lookup_param'] ?? 'q',
$company
);
if ($result === null) {
$notFound = $field['not_found_text'] ?? '⚠️ No encontré resultados. Intenta de nuevo:';
return self::sendText("*{$cap['header']}*\n\n{$notFound}", $context['from'], $company);
}
$displayName = (string)($result[$field['display_field'] ?? 'nombre'] ?? 'Desconocido');
$entityId = (string)($result[$field['value_field'] ?? 'id'] ?? '');
$cap['__lookup_pending'] = ['id' => $entityId, 'label' => $displayName];
$meta['__cap'] = $cap;
ConversationContext::updateMetadata($ctxId, $meta);
return self::enqueueInteractive($context['from'], [
'type' => 'button',
'body' => ['text' => "¿Es *{$displayName}*?"],
'action' => ['buttons' => [
['type' => 'reply', 'reply' => ['id' => '__cap_lookup_yes', 'title' => '✅ Sí']],
['type' => 'reply', 'reply' => ['id' => '__cap_lookup_no', 'title' => '❌ No, reintentar']],
]],
], $company);
}
// ── 4. Normal save ────────────────────────────────────────────────────
$cap['collected'][$field['key']] = $value;
if (($field['type'] ?? 'text') === 'select') {
$cap['collected_labels'][$field['key']] =
$cap['__selects'][$field['key']][$value] ?? $value;
}
// Save answer
$cap['collected'][$field['key']] = trim($input);
$cap['index'] = $idx + 1;
$meta['__cap'] = $cap;
ConversationContext::updateMetadata($ctxId, $meta);
// More fields?
if ($cap['index'] < count($fields)) {
return self::capAskNext($cap, $context['from'], $company);
return self::capAskNext($cap, $context['from'], $company, $ctxId);
}
return self::capShowConfirmOrSubmit($cap, $meta, $context, $company, $ctxId);
}
// All done — confirm or submit directly
private static function capShowConfirmOrSubmit(array $cap, array $meta, array $context, array $company, int $ctxId): ?array
{
$fields = $cap['fields'];
if ($cap['confirm']) {
$summary = "*{$cap['header']}*\n\n";
foreach ($cap['collected'] as $k => $v) {
$label = self::capFieldLabel($fields, $k);
$summary .= "{$label}: *{$v}*\n";
$label = self::capFieldLabel($fields, $k);
$display = $cap['collected_labels'][$k] ?? $v;
$summary .= "{$label}: *{$display}*\n";
}
$summary .= "\n¿Confirmas el registro?";
return self::enqueueInteractive($context['from'], [
'type' => 'button',
'body' => ['text' => $summary],
'type' => 'button',
'body' => ['text' => $summary],
'action' => ['buttons' => [
['type' => 'reply', 'reply' => ['id' => '__cap_confirm', 'title' => '✅ Confirmar']],
['type' => 'reply', 'reply' => ['id' => '__cap_cancel', 'title' => '❌ Cancelar']],
]],
], $company);
}
return self::submitCapPost($meta, $context, $company, $ctxId);
}
private static function capAskNext(array $cap, string $to, array $company): ?array
private static function callLookupEndpoint(string $epKey, string $value, string $param, array $company): ?array
{
$idx = (int)$cap['index'];
$total = count($cap['fields']);
$field = $cap['fields'][$idx];
$prompt = $field['prompt'] ?? ('Campo: ' . $field['key']);
if ($epKey === '' || $value === '') return null;
$stmt = db()->prepare('SELECT url FROM company_endpoints WHERE company_id=? AND endpoint_key=? AND is_active=1 LIMIT 1');
$stmt->execute([(int)$company['id'], $epKey]);
$ep = $stmt->fetch();
if (!$ep || empty($ep['url'])) return null;
$text = "*{$cap['header']}* ({$idx}/{$total})\n\n{$prompt}";
$url = self::buildUrl($ep['url'], $company) . '&' . urlencode($param) . '=' . urlencode($value);
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . ($company['api_key'] ?? ''),
'X-API-Key: ' . ($company['api_key'] ?? ''),
],
]);
$body = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code !== 200 || !$body) return null;
$data = json_decode($body, true);
return (is_array($data) && ($data['status'] ?? '') === '1') ? ($data['datos'] ?? null) : null;
}
private static function capAskNext(array $cap, string $to, array $company, int $ctxId = 0): ?array
{
$idx = (int)$cap['index'];
$total = count($cap['fields']);
$field = $cap['fields'][$idx];
$type = $field['type'] ?? 'text';
// ── select: fetch from API ───────────────────────────────────────────
if ($type === 'select') {
$vf = $field['value_field'] ?? 'id';
$lf = $field['label_field'] ?? 'label';
$items = self::fetchDynamicList($field['source_endpoint_key'] ?? '', $company);
if (!empty($items)) {
if ($ctxId > 0) {
$cap['__selects'][$field['key']] = array_column($items, $lf, $vf);
$m = ConversationContext::getMetadata($ctxId);
$m['__cap'] = $cap;
ConversationContext::updateMetadata($ctxId, $m);
}
return self::buildDynamicListResponse($items, [
'value_field' => $vf,
'label_field' => $lf,
'header' => $cap['header'],
'body' => $field['prompt'] ?? ('Selecciona ' . ($field['label'] ?? $field['key'])),
'button' => 'Ver opciones',
'section_title' => $field['label'] ?? $field['key'],
], $to, $company);
}
}
// ── date_quick: Hoy / Ayer / Anteayer / Otra ────────────────────────
if ($type === 'date_quick') {
$hoy = date('Y-m-d');
$ayer = date('Y-m-d', strtotime('-1 day'));
$ant = date('Y-m-d', strtotime('-2 days'));
return self::enqueueInteractive($to, [
'type' => 'list',
'header' => ['type' => 'text', 'text' => mb_substr($cap['header'], 0, 60)],
'body' => ['text' => $field['prompt'] ?? '📅 ¿Cuál es la fecha?'],
'footer' => ['text' => ''],
'action' => ['button' => 'Ver fechas', 'sections' => [[
'title' => 'Fecha',
'rows' => [
['id' => $hoy, 'title' => '📅 Hoy', 'description' => $hoy],
['id' => $ayer, 'title' => '📅 Ayer', 'description' => $ayer],
['id' => $ant, 'title' => '📅 Anteayer', 'description' => $ant],
['id' => '__cap_other', 'title' => '✏️ Otra fecha', 'description' => 'Escribe la fecha'],
],
]]],
], $company);
}
// ── static_select: inline options ───────────────────────────────────
if ($type === 'static_select') {
$options = $field['options'] ?? [];
$rows = array_map(fn($o) => [
'id' => (string)$o['id'],
'title' => mb_substr((string)$o['label'], 0, 24),
], $options);
if (count($rows) <= 3) {
return self::enqueueInteractive($to, [
'type' => 'button',
'body' => ['text' => "*{$cap['header']}*\n\n" . ($field['prompt'] ?? 'Selecciona:')],
'action' => ['buttons' => array_map(fn($r) => [
'type' => 'reply', 'reply' => ['id' => $r['id'], 'title' => $r['title']],
], $rows)],
], $company);
}
return self::enqueueInteractive($to, [
'type' => 'list',
'header' => ['type' => 'text', 'text' => mb_substr($cap['header'], 0, 60)],
'body' => ['text' => $field['prompt'] ?? 'Selecciona una opción:'],
'footer' => ['text' => ''],
'action' => ['button' => 'Ver opciones', 'sections' => [[
'title' => $field['label'] ?? 'Opciones',
'rows' => $rows,
]]],
], $company);
}
// ── lookup / text: plain text prompt ────────────────────────────────
$prompt = $field['prompt'] ?? ('Campo: ' . $field['key']);
$text = "*{$cap['header']}* ({$idx}/{$total})\n\n{$prompt}";
return self::sendText($text, $to, $company);
}
@@ -916,7 +1104,7 @@ class NormalBot
return self::handleCollectingInput($meta, $input, 'interactive', $context, $company, $ctxId, $flows, $menus);
}
// collect_and_post confirmation (confirm/cancel button)
// collect_and_post: confirm/cancel, lookup confirm/reject, or list/button field pick
if (!empty($meta['__cap'])) {
if ($input === '__cap_confirm') {
return self::submitCapPost($meta, $context, $company, $ctxId);
@@ -928,6 +1116,35 @@ class NormalBot
ConversationContext::reset($ctxId);
return self::sendText("❌ Registro cancelado.\n\nEscribe *menu* para volver.", $context['from'], $company);
}
// Lookup confirmation buttons
if (!empty($meta['__cap']['__lookup_pending'])) {
if ($input === '__cap_lookup_yes') {
$cap = $meta['__cap'];
$fields = $cap['fields'];
$idx = (int)$cap['index'];
$field = $fields[$idx];
$pending = $cap['__lookup_pending'];
$cap['collected'][$field['key']] = $pending['id'];
$cap['collected_labels'][$field['key']] = $pending['label'];
unset($cap['__lookup_pending']);
$cap['index'] = $idx + 1;
$meta['__cap'] = $cap;
ConversationContext::updateMetadata($ctxId, $meta);
if ($cap['index'] < count($fields)) {
return self::capAskNext($cap, $context['from'], $company, $ctxId);
}
return self::capShowConfirmOrSubmit($cap, $meta, $context, $company, $ctxId);
}
if ($input === '__cap_lookup_no') {
$cap = $meta['__cap'];
unset($cap['__lookup_pending']);
$meta['__cap'] = $cap;
ConversationContext::updateMetadata($ctxId, $meta);
return self::capAskNext($cap, $context['from'], $company, $ctxId);
}
}
// Interactive list/button response for select, date_quick, static_select fields
return self::handleCapInput($meta, $input, $context, $company, $ctxId);
}
// collect_for_each confirmation (confirm/cancel button)