diff --git a/admin/DashboardController.php b/admin/DashboardController.php index 08ac264..82a5618 100644 --- a/admin/DashboardController.php +++ b/admin/DashboardController.php @@ -3142,6 +3142,7 @@ HTML; $fallbackVal = self::h($config['fallback'] ?? ''); $aiPromptVal = self::h($config['ai_prompt'] ?? ''); $aiForMediaChecked = ($config['ai_for_media'] ?? true) ? 'checked' : ''; + $nluChecked = !empty($config['nlu']) ? 'checked' : ''; $aiModelVal = self::h($config['ai_model'] ?? 'gpt-4o-mini'); $aiTempVal = self::h((string)($config['ai_temperature'] ?? 0.7)); $aiProviderVal = self::h($config['ai_provider'] ?? ''); @@ -3731,6 +3732,18 @@ HTML; Si estΓ‘ desactivado, el bot muestra el menΓΊ de categorΓ­a directamente. +
+ +
+ Cuando el usuario escribe algo que el bot no reconoce, la IA lo ubica en el menΓΊ o flujo correcto segΓΊn sus permisos.
+ Audio β†’ transcripciΓ³n automΓ‘tica (Whisper/Gemini) β†’ enrutado.
+ Imagen β†’ la IA extrae el contexto β†’ enrutado.
+ Requiere bot_type = hybrid y proveedor de IA configurado. +
+
diff --git a/public/index.php b/public/index.php index 5cb5ed3..b2e10d4 100644 --- a/public/index.php +++ b/public/index.php @@ -38,6 +38,7 @@ require_once __DIR__ . '/../services/ErpSync.php'; require_once __DIR__ . '/../services/ConversationContext.php'; require_once __DIR__ . '/../services/NormalBot.php'; require_once __DIR__ . '/../services/AiBot.php'; +require_once __DIR__ . '/../services/MediaTranscriber.php'; require_once __DIR__ . '/../services/BotRouter.php'; require_once __DIR__ . '/../services/PendingApproval.php'; require_once __DIR__ . '/../services/ErpMonitor.php'; @@ -534,6 +535,7 @@ $routes = [ $aiProvider = trim($_POST['ai_provider'] ?? ''); if ($aiProvider !== '') $config['ai_provider'] = $aiProvider; else unset($config['ai_provider']); $config['ai_for_media'] = isset($_POST['ai_for_media']); + $config['nlu'] = isset($_POST['nlu']); $openaiKey = trim($_POST['openai_api_key'] ?? ''); if ($openaiKey !== '') $config['openai_api_key'] = $openaiKey; $geminiKey = trim($_POST['gemini_api_key'] ?? ''); @@ -568,7 +570,7 @@ $routes = [ $formManagedKeys = ['commands', 'menus', 'flows', 'ai_prompt', 'ai_model', 'ai_temperature', 'ai_provider', 'openai_api_key', 'gemini_api_key', 'gemini_model', 'claude_api_key', 'claude_model', - 'ai_for_media', 'greeting', 'fallback', 'approval_webhook', + 'ai_for_media', 'nlu', 'greeting', 'fallback', 'approval_webhook', 'ignore_prefixes', 'welcome_menu', 'per_type']; foreach ($existingConfig as $k => $v) { if (!in_array($k, $formManagedKeys, true) && !isset($config[$k])) { diff --git a/services/AiBot.php b/services/AiBot.php index 2a751cd..f79b550 100644 --- a/services/AiBot.php +++ b/services/AiBot.php @@ -302,6 +302,141 @@ PROMPT; . "Responde de forma breve y apropiada segΓΊn los permisos del usuario. Sin saludos largos."; } + // ── NLU: enrutamiento inteligente ──────────────────────────────────────── + + /** + * Dado un mensaje de texto, decide si enrutar a un flow existente o responder en chat libre. + * Devuelve: ['action'=>'route','key'=>'flow_key'] | ['action'=>'chat','text'=>'...'] + */ + public static function routeOrChat(array $company, array $context, string $input): array + { + $config = self::getConfig($company); + $permType = (int)($context['permission_type'] ?? 1); + $catalog = self::buildFlowCatalog($config, $permType); + + if (empty($catalog)) { + return ['action' => 'chat', 'text' => '']; + } + + $systemPrompt = self::buildNluPrompt( + $company['display_name'] ?? $company['name'] ?? 'la empresa', + $catalog, + $permType + ); + + $raw = self::callLlmOnce($systemPrompt, $input, $company); + if ($raw === null) { + return ['action' => 'chat', 'text' => '']; + } + + // Strip markdown fences if AI wrapped JSON + $clean = trim(preg_replace('/^```(?:json)?\s*/i', '', preg_replace('/\s*```$/m', '', $raw))); + $json = json_decode($clean, true); + + if (!is_array($json) || !isset($json['action'])) { + return ['action' => 'chat', 'text' => $clean]; + } + + 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' => 'chat', 'text' => $json['text'] ?? $clean]; + } + + private static function buildFlowCatalog(array $config, int $permType): array + { + $flows = $config['flows'] ?? []; + $menus = $config['menus'] ?? []; + + // Build human labels from menu rows/buttons + $labels = []; + foreach ($menus as $menu) { + foreach ($menu['sections'] ?? [] as $section) { + foreach ($section['rows'] ?? [] as $row) { + if (($row['id'] ?? '') !== '') $labels[$row['id']] = $row['title'] ?? $row['id']; + } + } + foreach ($menu['buttons'] ?? [] as $btn) { + if (($btn['id'] ?? '') !== '') $labels[$btn['id']] = $btn['title'] ?? $btn['id']; + } + } + + $catalog = []; + foreach ($flows as $key => $flow) { + $type = $flow['type'] ?? 'text'; + $fn = $flow['function'] ?? ''; + + $isUpload = in_array($type, ['collect_and_post', 'collect_for_each', 'submit_form'], true); + $isDownload = ($type === 'function' && $fn === 'api_report'); + + // Permission filter: + // type 1 = solo reporta (upload), type 2 = solo recibe (download), type 3 = ambos + if ($isUpload && !in_array($permType, [1, 3], true)) continue; + if ($isDownload && !in_array($permType, [2, 3], true)) continue; + + // Skip purely internal/navigation flows + if (in_array($type, ['text', 'image'], true) && !$isUpload && !$isDownload) continue; + + $label = $flow['nlu_description'] ?? $labels[$key] ?? $key; + $dir = $isUpload ? 'subida' : ($isDownload ? 'descarga' : ''); + + $catalog[] = ['key' => $key, 'label' => $label, 'dir' => $dir]; + } + + return $catalog; + } + + private static function buildNluPrompt(string $company, array $catalog, int $permType): string + { + $permLabel = match ($permType) { + 1 => 'puede subir/reportar datos al sistema', + 2 => 'puede descargar reportes e informes', + 3 => 'puede subir datos y descargar reportes', + default => 'acceso bΓ‘sico', + }; + + $lines = array_map(fn($item) => + '- ' . $item['key'] . ': "' . $item['label'] . '"' . ($item['dir'] !== '' ? ' [' . $item['dir'] . ']' : ''), + $catalog + ); + + return "Eres el asistente de {$company}. El usuario {$permLabel}.\n\n" + . "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" + . "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."; + } + + /** Llamada de 1 turno sin historial β€” para NLU routing. */ + private static function callLlmOnce(string $systemPrompt, string $userMessage, array $company): ?string + { + $cfg = self::getConfig($company); + $provider = $cfg['ai_provider'] ?? env('AI_PROVIDER', 'openai'); + $history = [['role' => 'user', 'content' => $userMessage]]; + + $result = match ($provider) { + 'openai' => self::callOpenAI($systemPrompt, $history, $company), + 'gemini' => self::callGemini($systemPrompt, $history, $company), + 'claude' => self::callClaude($systemPrompt, $history, $company), + default => null, + }; + + return $result['content'] ?? null; + } + + // ── Shared helpers ──────────────────────────────────────────────────────── + private static function getConfig(array $company): array { $json = $company['config_json'] ?? ''; diff --git a/services/BotRouter.php b/services/BotRouter.php index b7f8bab..a390b91 100644 --- a/services/BotRouter.php +++ b/services/BotRouter.php @@ -107,24 +107,75 @@ class BotRouter private static function runHybridBot(array $company, array $context, string $input, string $inputType): ?array { - $config = self::getConfig($company); + $config = self::getConfig($company); + $nluEnabled = (bool)($config['nlu'] ?? false); + $aiEnabled = (bool)($config['ai_for_media'] ?? true); - // Media (image/audio/video/document) β†’ AI handles it if enabled + // ── Media (audio/imagen) ────────────────────────────────────────────── if (self::isMediaType($inputType)) { - $aiEnabled = (bool)($config['ai_for_media'] ?? true); if (!$aiEnabled) { return self::categoryMenuFallback($company, $context, $config); } + + if ($nluEnabled && in_array($inputType, ['audio', 'image'], true)) { + $text = $inputType === 'audio' + ? MediaTranscriber::transcribeAudio($input, $company) + : MediaTranscriber::extractFromImage($input, $company); + + if ($text !== null && trim($text) !== '') { + self::log("NLU media [{$inputType}]: texto extraΓ­do β†’ \"{$text}\""); + // Confirmar al usuario lo que entendiΓ³ + $prefix = $inputType === 'audio' ? '🎀 TranscripciΓ³n' : 'πŸ–ΌοΈ Imagen analizada'; + WhatsAppSender::sendText( + $context['from'], + "{$prefix}: _{$text}_", + $context['phone_number_id'] ?? '' + ); + return self::runNluOnText($company, $context, $text, $config); + } + } + return AiBot::processMedia($company, $context, $inputType, $input); } - // Text / interactive / button β†’ NormalBot only, never AI + // ── Texto / interactivo: NormalBot primero ──────────────────────────── $response = self::runNormalBot($company, $context, $input, $inputType); if ($response !== null) { return $response; } - // NormalBot returned null β†’ show the category greeting menu as fallback + // NormalBot no reconociΓ³ β†’ NLU si estΓ‘ activo, si no menΓΊ categorΓ­a + if ($nluEnabled && $inputType === 'text') { + return self::runNluOnText($company, $context, $input, $config); + } + + return self::categoryMenuFallback($company, $context, $config); + } + + private static function runNluOnText(array $company, array $context, string $input, array $config): ?array + { + $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); + self::log("NLU: {$context['from']} β†’ flow [{$key}]"); + // Ejecutar con input vacΓ­o β€” NormalBot retoma current_node + return NormalBot::process($company, $context, ''); + } + + $text = trim($result['text'] ?? ''); + if ($text !== '') { + self::log("NLU: {$context['from']} β†’ chat libre"); + return [ + 'action' => 'send', + 'type' => 'text', + 'to' => $context['from'], + 'payload' => json_encode(['text' => $text]), + ]; + } + return self::categoryMenuFallback($company, $context, $config); } diff --git a/services/MediaTranscriber.php b/services/MediaTranscriber.php new file mode 100644 index 0000000..060932f --- /dev/null +++ b/services/MediaTranscriber.php @@ -0,0 +1,281 @@ + ["Authorization: Bearer {$token}", "User-Agent: bot-palmas360/1.0"], + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 15, + ]); + $resp = curl_exec($ch); + $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($code !== 200 || !$resp) return null; + + $data = json_decode($resp, true); + $url = $data['url'] ?? ''; + $mime = $data['mime_type'] ?? 'application/octet-stream'; + if ($url === '') return null; + + // 2) Descargar binario + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_HTTPHEADER => ["Authorization: Bearer {$token}", "User-Agent: bot-palmas360/1.0"], + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_TIMEOUT => 45, + ]); + $bytes = curl_exec($ch); + $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($code !== 200 || !$bytes || strlen($bytes) < 100) return null; + + return ['bytes' => $bytes, 'mime' => $mime]; + } + + // ── Audio β†’ texto ───────────────────────────────────────────────────────── + + public static function transcribeAudio(string $mediaId, array $company): ?string + { + $cfg = self::getConfig($company); + $provider = $cfg['ai_provider'] ?? env('AI_PROVIDER', 'openai'); + + $media = self::downloadMedia($mediaId); + if ($media === null) return null; + + return match ($provider) { + 'openai' => self::whisper($media['bytes'], $media['mime'], $cfg), + 'gemini' => self::geminiAudio($media['bytes'], $media['mime'], $cfg), + default => null, // Claude no soporta audio + }; + } + + private static function whisper(string $bytes, string $mime, array $cfg): ?string + { + $apiKey = $cfg['openai_api_key'] ?? env('OPENAI_API_KEY', ''); + if ($apiKey === '') return null; + + $ext = str_contains($mime, 'ogg') ? 'ogg' : (str_contains($mime, 'mp4') ? 'mp4' : 'ogg'); + $tmpFile = tempnam(sys_get_temp_dir(), 'wa_audio_') . '.' . $ext; + file_put_contents($tmpFile, $bytes); + + $ch = curl_init('https://api.openai.com/v1/audio/transcriptions'); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => [ + 'file' => new CURLFile($tmpFile, $mime, 'audio.' . $ext), + 'model' => 'whisper-1', + 'language' => 'es', + ], + CURLOPT_HTTPHEADER => ["Authorization: Bearer {$apiKey}"], + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 60, + ]); + $resp = curl_exec($ch); + $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + @unlink($tmpFile); + + if ($code !== 200 || !$resp) return null; + + $data = json_decode($resp, true); + return isset($data['text']) ? trim($data['text']) : null; + } + + private static function geminiAudio(string $bytes, string $mime, array $cfg): ?string + { + $apiKey = $cfg['gemini_api_key'] ?? env('GEMINI_API_KEY', ''); + $model = $cfg['gemini_model'] ?? env('GEMINI_MODEL', 'gemini-2.5-flash'); + if ($apiKey === '') return null; + + // Gemini acepta audio inline como base64 + $payload = json_encode([ + 'contents' => [[ + 'parts' => [ + ['inline_data' => ['mime_type' => $mime, 'data' => base64_encode($bytes)]], + ['text' => 'Transcribe este audio de WhatsApp en espaΓ±ol. Devuelve SOLO el texto transcrito, sin explicaciones ni puntuaciΓ³n extra.'], + ], + ]], + ]); + + $url = "https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent?key={$apiKey}"; + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $payload, + CURLOPT_HTTPHEADER => ['Content-Type: application/json'], + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 60, + ]); + $resp = curl_exec($ch); + $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($code !== 200 || !$resp) return null; + + $data = json_decode($resp, true); + $text = $data['candidates'][0]['content']['parts'][0]['text'] ?? null; + return $text !== null ? trim($text) : null; + } + + // ── Imagen β†’ intenciΓ³n/texto ────────────────────────────────────────────── + + public static function extractFromImage(string $mediaId, array $company): ?string + { + $cfg = self::getConfig($company); + $provider = $cfg['ai_provider'] ?? env('AI_PROVIDER', 'openai'); + + $media = self::downloadMedia($mediaId); + if ($media === null) return null; + + $b64 = base64_encode($media['bytes']); + $mime = $media['mime']; + $prompt = 'Analiza esta imagen de WhatsApp. Si contiene texto, extrΓ‘elo. ' + . 'Describe en una oraciΓ³n quΓ© quiere hacer el usuario o quΓ© informaciΓ³n contiene. ' + . 'Responde en espaΓ±ol, mΓ‘ximo 2 oraciones.'; + + return match ($provider) { + 'openai' => self::openaiVision($b64, $mime, $prompt, $cfg), + 'gemini' => self::geminiVision($b64, $mime, $prompt, $cfg), + 'claude' => self::claudeVision($b64, $mime, $prompt, $cfg), + default => null, + }; + } + + private static function openaiVision(string $b64, string $mime, string $prompt, array $cfg): ?string + { + $apiKey = $cfg['openai_api_key'] ?? env('OPENAI_API_KEY', ''); + $model = $cfg['ai_model'] ?? env('OPENAI_MODEL', 'gpt-4o'); + // Vision requires gpt-4o or gpt-4.1 β€” downgrade if mini is selected + if (str_contains($model, 'mini') || str_contains($model, '3.5')) $model = 'gpt-4o'; + if ($apiKey === '') return null; + + $payload = json_encode([ + 'model' => $model, + 'max_tokens' => 200, + 'messages' => [[ + 'role' => 'user', + 'content' => [ + ['type' => 'image_url', 'image_url' => ['url' => "data:{$mime};base64,{$b64}"]], + ['type' => 'text', 'text' => $prompt], + ], + ]], + ]); + + $ch = curl_init('https://api.openai.com/v1/chat/completions'); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $payload, + CURLOPT_HTTPHEADER => ['Content-Type: application/json', "Authorization: Bearer {$apiKey}"], + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 30, + ]); + $resp = curl_exec($ch); + $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($code !== 200 || !$resp) return null; + + $data = json_decode($resp, true); + $text = $data['choices'][0]['message']['content'] ?? null; + return $text !== null ? trim($text) : null; + } + + private static function geminiVision(string $b64, string $mime, string $prompt, array $cfg): ?string + { + $apiKey = $cfg['gemini_api_key'] ?? env('GEMINI_API_KEY', ''); + $model = $cfg['gemini_model'] ?? env('GEMINI_MODEL', 'gemini-2.5-flash'); + if ($apiKey === '') return null; + + $payload = json_encode([ + 'contents' => [[ + 'parts' => [ + ['inline_data' => ['mime_type' => $mime, 'data' => $b64]], + ['text' => $prompt], + ], + ]], + ]); + + $url = "https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent?key={$apiKey}"; + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $payload, + CURLOPT_HTTPHEADER => ['Content-Type: application/json'], + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 30, + ]); + $resp = curl_exec($ch); + $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($code !== 200 || !$resp) return null; + + $data = json_decode($resp, true); + $text = $data['candidates'][0]['content']['parts'][0]['text'] ?? null; + return $text !== null ? trim($text) : null; + } + + private static function claudeVision(string $b64, string $mime, string $prompt, array $cfg): ?string + { + $apiKey = $cfg['claude_api_key'] ?? env('CLAUDE_API_KEY', ''); + $model = $cfg['claude_model'] ?? env('CLAUDE_MODEL', 'claude-haiku-4-5'); + if ($apiKey === '') return null; + + $payload = json_encode([ + 'model' => $model, + 'max_tokens' => 200, + 'messages' => [[ + 'role' => 'user', + 'content' => [ + ['type' => 'image', 'source' => ['type' => 'base64', 'media_type' => $mime, 'data' => $b64]], + ['type' => 'text', 'text' => $prompt], + ], + ]], + ]); + + $ch = curl_init('https://api.anthropic.com/v1/messages'); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $payload, + CURLOPT_HTTPHEADER => ['Content-Type: application/json', "x-api-key: {$apiKey}", 'anthropic-version: 2023-06-01'], + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 30, + ]); + $resp = curl_exec($ch); + $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($code !== 200 || !$resp) return null; + + $data = json_decode($resp, true); + $text = $data['content'][0]['text'] ?? null; + return $text !== null ? trim($text) : null; + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private static function getConfig(array $company): array + { + $json = $company['config_json'] ?? ''; + if ($json === '') return []; + $cfg = json_decode($json, true); + return is_array($cfg) ? $cfg : []; + } +}