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:
co-authored by
Claude Sonnet 4.6
parent
54d778783e
commit
497743ceb1
+259
-42
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user