From 497743ceb1af70e8d501713e585d4aca1e79ea53 Mon Sep 17 00:00:00 2001 From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com> Date: Sun, 5 Jul 2026 09:09:49 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20NLU=20fallback=20=E2=80=94=20route=20un?= =?UTF-8?q?recognized=20text=20to=20AI=20in=20hybrid+NLU=20mode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- services/BotRouter.php | 8 +- services/NormalBot.php | 301 +++++++++++++++++++++++++++++++++++------ 2 files changed, 264 insertions(+), 45 deletions(-) diff --git a/services/BotRouter.php b/services/BotRouter.php index 9ac3e4c..126f0aa 100644 --- a/services/BotRouter.php +++ b/services/BotRouter.php @@ -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; } diff --git a/services/NormalBot.php b/services/NormalBot.php index 155af89..90cf2a8 100644 --- a/services/NormalBot.php +++ b/services/NormalBot.php @@ -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)