From 54a93adea9c570eaa8631d192cdbebd71fd23c56 Mon Sep 17 00:00:00 2001 From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com> Date: Sun, 5 Jul 2026 21:48:48 -0500 Subject: [PATCH] feat: one-shot audio entity extraction y pre-fill de formularios MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AiBot: NLU ahora extrae entities (finca, valor, etc.) del audio - BotRouter: guarda __nlu_entities en metadata antes de enrutar - NormalBot: handleCollectAndPost pre-rellena campos desde entities y capAskNext salta los ya completados - NormalBot: handleCollectForEach registra ítem directo si entities trae finca+valor coincidente, sin iterar todo el listado - BotRouter: mid-flow audio va a NormalBot directamente (no NLU) Co-Authored-By: Claude Sonnet 4.6 --- services/AiBot.php | 11 +++-- services/BotRouter.php | 15 ++++-- services/NormalBot.php | 108 ++++++++++++++++++++++++++++++++++++++++- 3 files changed, 125 insertions(+), 9 deletions(-) diff --git a/services/AiBot.php b/services/AiBot.php index ba2da35..86016ef 100644 --- a/services/AiBot.php +++ b/services/AiBot.php @@ -364,13 +364,16 @@ PROMPT; } if ($json['action'] === 'route' && isset($json['key'])) { - // Validate key exists in flows $allFlows = $config['flows'] ?? []; foreach ($config['per_type'] ?? [] as $pt) { $allFlows = array_merge($allFlows, $pt['flows'] ?? []); } if (isset($allFlows[$json['key']])) { - return ['action' => 'route', 'key' => $json['key']]; + return [ + 'action' => 'route', + 'key' => $json['key'], + 'entities' => is_array($json['entities'] ?? null) ? $json['entities'] : [], + ]; } } @@ -442,7 +445,9 @@ PROMPT; . "Opciones disponibles:\n" . implode("\n", $lines) . "\n\n" . "Analiza el mensaje y determina si se refiere claramente a una opción.\n\n" . "Si sí → responde SOLO este JSON (sin markdown):\n" - . "{\"action\":\"route\",\"key\":\"\"}\n\n" + . "{\"action\":\"route\",\"key\":\"\",\"entities\":{\"campo\":\"valor\",...}}\n\n" + . "En 'entities' incluye los datos que el usuario ya mencionó (nombre de finca, fecha, valor numérico, etc.).\n" + . "Si no mencionó datos extra, omite entities o usa {}.\n\n" . "Si no está claro o no hay opción correspondiente → responde SOLO:\n" . "{\"action\":\"chat\",\"text\":\"\"}\n\n" . "No inventes keys. Usa exactamente los keys de la lista."; diff --git a/services/BotRouter.php b/services/BotRouter.php index 67d36a2..7c65ab0 100644 --- a/services/BotRouter.php +++ b/services/BotRouter.php @@ -174,11 +174,18 @@ class BotRouter $result = AiBot::routeOrChat($company, $context, $input); if ($result['action'] === 'route') { - $key = $result['key']; - $botCtx = ConversationContext::getOrCreate((int)$company['id'], $context['from'], 'normal'); - ConversationContext::updateNode((int)$botCtx['id'], $key); + $key = $result['key']; + $botCtx = ConversationContext::getOrCreate((int)$company['id'], $context['from'], 'normal'); + $ctxId = (int)$botCtx['id']; + $entities = $result['entities'] ?? []; + if (!empty($entities)) { + $m = ConversationContext::getMetadata($ctxId); + $m['__nlu_entities'] = $entities; + ConversationContext::updateMetadata($ctxId, $m); + self::log("NLU: entities extraídas → " . json_encode($entities)); + } + ConversationContext::updateNode($ctxId, $key); self::log("NLU: {$context['from']} → flow [{$key}]"); - // Ejecutar con input vacío — NormalBot retoma current_node return NormalBot::process($company, $context, ''); } diff --git a/services/NormalBot.php b/services/NormalBot.php index cef0967..a797e18 100644 --- a/services/NormalBot.php +++ b/services/NormalBot.php @@ -317,6 +317,69 @@ class NormalBot ); } + // Si el NLU extrajo finca+valor, registrar solo ese ítem directamente + $entities = $meta['__nlu_entities'] ?? []; + unset($meta['__nlu_entities']); + if (!empty($entities)) { + $valueField = $flow['value_field'] ?? 'id'; + $labelField = $flow['label_field'] ?? 'name'; + $entityVal = null; + $matchedId = null; + $matchedLabel = null; + + // Buscar valor numérico en entities + foreach ($entities as $ek => $ev) { + if (in_array(self::normalize($ek), ['valor','value','mm','cantidad','qty'], true) && is_numeric(str_replace(',', '.', (string)$ev))) { + $entityVal = str_replace(',', '.', (string)$ev); + } + } + + // Buscar finca que coincida en entities + foreach ($entities as $ek => $ev) { + if (in_array(self::normalize($ek), ['valor','value','mm','cantidad','qty'], true)) continue; + $evNorm = self::normalize((string)$ev); + foreach ($items as $item) { + if (self::normalize((string)($item[$labelField] ?? '')) === $evNorm) { + $matchedId = (string)($item[$valueField] ?? ''); + $matchedLabel = (string)($item[$labelField] ?? ''); + break 2; + } + } + } + + if ($matchedId !== null && $entityVal !== null) { + ConversationContext::updateMetadata($ctxId, $meta); + ConversationContext::reset($ctxId); + // POST directo para ese ítem + $epKey = $flow['endpoint_key'] ?? ''; + $stmt = db()->prepare("SELECT url, method 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'])) { + $url = self::buildUrl($ep['url'], $company); + $apiKey = $company['api_key'] ?? ''; + $body = json_encode([$valueField => $matchedId, 'valor' => $entityVal]); + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $body, + CURLOPT_HTTPHEADER => ['Content-Type: application/json', "Authorization: Bearer {$apiKey}", "X-API-Key: {$apiKey}"], + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 15, + ]); + $resp = curl_exec($ch); + $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + if ($code >= 200 && $code < 300) { + $successText = $flow['success_text'] ?? '✅ Datos registrados correctamente.'; + return self::sendText("✅ *{$matchedLabel}*: {$entityVal} registrado.\n{$successText}", $context['from'], $company); + } + } + return self::sendText('⚠️ No se pudo registrar. Intenta de nuevo o escribe *menu*.', $context['from'], $company); + } + ConversationContext::updateMetadata($ctxId, $meta); + } + $meta['__foreach'] = [ 'items' => $items, 'index' => 0, @@ -474,10 +537,34 @@ class NormalBot } $meta = ConversationContext::getMetadata($ctxId); + $entities = $meta['__nlu_entities'] ?? []; + unset($meta['__nlu_entities']); + + $collected = []; + $startIdx = 0; + if (!empty($entities)) { + foreach ($fields as $i => $field) { + $fKey = $field['key'] ?? ''; + $fLabel = $field['label'] ?? ''; + foreach ($entities as $ek => $ev) { + if (self::normalize($ek) === self::normalize($fKey) || + self::normalize($ek) === self::normalize($fLabel)) { + $collected[$fKey] = (string)$ev; + break; + } + } + } + // Advance index past consecutive pre-filled leading fields + while ($startIdx < count($fields) && isset($collected[$fields[$startIdx]['key'] ?? ''])) { + $startIdx++; + } + } + $meta['__cap'] = [ 'fields' => $fields, - 'index' => 0, - 'collected' => [], + 'index' => $startIdx, + 'collected' => $collected, + 'prefilled' => $collected, 'endpoint_key' => $flow['endpoint_key'] ?? '', 'success_text' => $flow['success_text'] ?? '✅ Datos registrados correctamente.', 'confirm' => (bool)($flow['confirm'] ?? true), @@ -629,6 +716,23 @@ class NormalBot private static function capAskNext(array $cap, string $to, array $company, int $ctxId = 0): ?array { + // Saltar campos ya pre-llenados (no contiguos) + $prefilled = $cap['prefilled'] ?? []; + while ((int)$cap['index'] < count($cap['fields'])) { + $f = $cap['fields'][(int)$cap['index']]; + if (isset($prefilled[$f['key'] ?? '']) && !isset($cap['collected'][$f['key'] ?? ''])) { + $cap['collected'][$f['key']] = $prefilled[$f['key']]; + $cap['index']++; + if ($ctxId > 0) { + $m = ConversationContext::getMetadata($ctxId); + $m['__cap'] = $cap; + ConversationContext::updateMetadata($ctxId, $m); + } + } else { + break; + } + } + $idx = (int)$cap['index']; $total = count($cap['fields']); $field = $cap['fields'][$idx];