feat: one-shot audio entity extraction y pre-fill de formularios

- 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 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-07-05 21:48:48 -05:00
co-authored by Claude Sonnet 4.6
parent d0c1b1d3be
commit 54a93adea9
3 changed files with 125 additions and 9 deletions
+106 -2
View File
@@ -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];