'una imagen', 'audio' => 'un mensaje de audio', 'video' => 'un video', 'document' => 'un documento', 'sticker' => 'un sticker', default => 'un archivo', }; $canUpload = $permType === 3; $permDesc = $canUpload ? 'El usuario tiene permisos para subir información y reportes.' : 'El usuario solo puede recibir información o descargar informes, NO puede subir archivos.'; $captionNote = $caption !== '' && !str_starts_with($caption, '[') ? " con el texto: \"{$caption}\"" : ''; $basePrompt = $config['ai_prompt'] ?? self::defaultMediaPrompt($company); $systemPrompt = $basePrompt . "\n\n{$permDesc}\n\nReglas:\n" . "- Si el usuario puede subir: confirma recepción de {$mediaLabel} e indica que será procesado.\n" . "- Si NO puede subir: explica brevemente y dile que use el menú de opciones.\n" . "- Respuesta máx 2 oraciones. Sin saludo largo."; $userMessage = "El usuario envió {$mediaLabel}{$captionNote}."; $botCtx = ConversationContext::getOrCreate((int)$company['id'], $context['from'], 'ai'); $ctxId = (int)$botCtx['id']; ConversationContext::addAiMessage($ctxId, ['role' => 'user', 'content' => $userMessage]); $result = self::callLlm($systemPrompt, $ctxId, $company); if ($result === null) { return null; } ConversationContext::addAiMessage($ctxId, ['role' => 'assistant', 'content' => $result['content'] ?? '']); return [ 'action' => 'send', 'type' => 'text', 'to' => $context['from'], 'payload' => json_encode(['text' => $result['content'] ?? '']), ]; } public static function process(array $company, array $context, string $input): ?array { $botCtx = ConversationContext::getOrCreate((int)$company['id'], $context['from'], 'ai'); $ctxId = (int)$botCtx['id']; $config = self::getConfig($company); $systemPrompt = $config['ai_prompt'] ?? self::defaultPrompt($company); ConversationContext::addAiMessage($ctxId, ['role' => 'user', 'content' => $input]); $result = self::callLlm($systemPrompt, $ctxId, $company); if ($result === null) { return null; } ConversationContext::addAiMessage($ctxId, ['role' => 'assistant', 'content' => $result['content'] ?? '']); return [ 'action' => 'send', 'type' => 'text', 'to' => $context['from'], 'payload' => json_encode(['text' => $result['content'] ?? '']), ]; } private static function callLlm(string $systemPrompt, int $ctxId, array $company): ?array { $cfg = self::getConfig($company); // Per-company provider overrides global; empty = use global $provider = $cfg['ai_provider'] ?? env('AI_PROVIDER', 'openai'); $history = ConversationContext::getAiHistory($ctxId); return match ($provider) { 'openai' => self::callOpenAI($systemPrompt, $history, $company), 'gemini' => self::callGemini($systemPrompt, $history, $company), 'claude' => self::callClaude($systemPrompt, $history, $company), 'mock' => self::mockResponse($history), default => self::callOpenAI($systemPrompt, $history, $company), }; } private static function callOpenAI(string $systemPrompt, array $history, array $company): ?array { $cfg = self::getConfig($company); $apiKey = $cfg['openai_api_key'] ?? env('OPENAI_API_KEY', ''); if ($apiKey === '') { return null; } $model = $cfg['ai_model'] ?? env('OPENAI_MODEL', 'gpt-4o-mini'); $messages = [ ['role' => 'system', 'content' => $systemPrompt], ]; foreach ($history as $msg) { $messages[] = [ 'role' => $msg['role'] ?? 'user', 'content' => $msg['content'] ?? '', ]; } $payload = json_encode([ 'model' => $model, 'messages' => $messages, 'max_tokens' => (int)env('AI_MAX_TOKENS', '500'), 'temperature' => 0.7, ]); $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, ]); $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $error = curl_error($ch); curl_close($ch); if ($httpCode !== 200 || $response === false) { WpWebhook::log('ERROR', "OpenAI API error: {$error} HTTP:{$httpCode}"); return null; } $data = json_decode($response, true); $text = $data['choices'][0]['message']['content'] ?? null; return $text !== null ? ['content' => $text] : null; } private static function callGemini(string $systemPrompt, array $history, array $company = []): ?array { $cfg = self::getConfig($company); $apiKey = $cfg['gemini_api_key'] ?? env('GEMINI_API_KEY', ''); if ($apiKey === '') return null; $model = $cfg['gemini_model'] ?? env('GEMINI_MODEL', 'gemini-2.5-flash'); $maxTokens = (int)env('AI_MAX_TOKENS', '500'); // Gemini usa "contents" con roles "user"/"model" $contents = []; foreach ($history as $msg) { $role = ($msg['role'] ?? 'user') === 'assistant' ? 'model' : 'user'; $contents[] = ['role' => $role, 'parts' => [['text' => $msg['content'] ?? '']]]; } // Si el historial está vacío o el último es del model, Gemini requiere que el último sea user if (empty($contents)) { $contents[] = ['role' => 'user', 'parts' => [['text' => '']]]; } $payload = json_encode([ 'system_instruction' => ['parts' => [['text' => $systemPrompt]]], 'contents' => $contents, 'generationConfig' => ['maxOutputTokens' => $maxTokens, 'temperature' => 0.7], ]); $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, ]); $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $error = curl_error($ch); curl_close($ch); if ($httpCode !== 200 || $response === false) { WpWebhook::log('ERROR', "Gemini API error: {$error} HTTP:{$httpCode}"); return null; } $data = json_decode($response, true); $text = $data['candidates'][0]['content']['parts'][0]['text'] ?? null; return $text !== null ? ['content' => $text] : null; } private static function callClaude(string $systemPrompt, array $history, array $company = []): ?array { $cfg = self::getConfig($company); $apiKey = $cfg['claude_api_key'] ?? env('CLAUDE_API_KEY', ''); if ($apiKey === '') return null; $model = $cfg['claude_model'] ?? env('CLAUDE_MODEL', 'claude-haiku-4-5'); $maxTokens = (int)env('AI_MAX_TOKENS', '500'); $messages = []; foreach ($history as $msg) { $messages[] = ['role' => $msg['role'] ?? 'user', 'content' => $msg['content'] ?? '']; } if (empty($messages)) { $messages[] = ['role' => 'user', 'content' => '']; } $payload = json_encode([ 'model' => $model, 'max_tokens' => $maxTokens, 'system' => $systemPrompt, 'messages' => $messages, ]); $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, ]); $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $error = curl_error($ch); curl_close($ch); if ($httpCode !== 200 || $response === false) { WpWebhook::log('ERROR', "Claude API error: {$error} HTTP:{$httpCode}"); return null; } $data = json_decode($response, true); $text = $data['content'][0]['text'] ?? null; return $text !== null ? ['content' => $text] : null; } private static function mockResponse(array $history): array { $last = end($history); $input = $last['content'] ?? ''; $responses = [ 'hola' => '¡Hola! ¿En qué puedo ayudarte hoy?', 'gracias' => '¡De nada! Si necesitas algo más, estoy aquí.', 'adios' => '¡Hasta luego! Que tengas un excelente día.', 'default' => "Gracias por tu mensaje. He recibido: \"{$input}\". Un asesor se pondrá en contacto contigo pronto.", ]; $normalized = mb_strtolower(trim($input)); $found = $responses['default']; foreach ($responses as $keyword => $response) { if (str_contains($normalized, $keyword)) { $found = $response; break; } } return ['content' => $found]; } private static function defaultPrompt(array $company): string { $name = $company['display_name'] ?? $company['name'] ?? 'la empresa'; return <<'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'] ?? ''; if ($json === '') return []; $config = json_decode($json, true); return is_array($config) ? $config : []; } }