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
+8 -3
View File
@@ -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\":\"<key_exacto>\"}\n\n"
. "{\"action\":\"route\",\"key\":\"<key_exacto>\",\"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\":\"<respuesta corta en español, máx 2 oraciones>\"}\n\n"
. "No inventes keys. Usa exactamente los keys de la lista.";
+11 -4
View File
@@ -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, '');
}
+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];