'una imagen', 'audio' => 'un mensaje de audio', 'video' => 'un video', 'document' => 'un documento', 'sticker' => 'un sticker', default => 'un archivo', }; $canUpload = in_array($permType, [1, 3], true); // 1=solo reporta, 3=ambos $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, 'media_response', $context['from']); 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, 'chat', $context['from']); 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, string $callType = 'chat', string $phone = ''): ?array { self::$logType = $callType; self::$logPhone = $phone; self::$logCompany = (int)($company['id'] ?? 0); $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, ]); $t0 = (int)(microtime(true) * 1000); $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); $ms = (int)(microtime(true) * 1000) - $t0; 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; $lastUser = end($history)['content'] ?? ''; AiLogger::log(self::$logCompany, 'openai', $model, self::$logType, $lastUser, $text ?? '', $ms, self::$logPhone, $data['usage']['prompt_tokens'] ?? null, $data['usage']['completion_tokens'] ?? 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], ]); $t0 = (int)(microtime(true) * 1000); $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); $ms = (int)(microtime(true) * 1000) - $t0; 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; $lastUser = end($history)['content'] ?? ''; $tokIn = $data['usageMetadata']['promptTokenCount'] ?? null; $tokOut = $data['usageMetadata']['candidatesTokenCount'] ?? null; AiLogger::log(self::$logCompany, 'gemini', $model, self::$logType, $lastUser, $text ?? '', $ms, self::$logPhone, $tokIn, $tokOut); 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, ]); $t0 = (int)(microtime(true) * 1000); $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); $ms = (int)(microtime(true) * 1000) - $t0; 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; $lastUser = end($history)['content'] ?? ''; AiLogger::log(self::$logCompany, 'claude', $model, self::$logType, $lastUser, $text ?? '', $ms, self::$logPhone, $data['usage']['input_tokens'] ?? null, $data['usage']['output_tokens'] ?? 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, 'nlu', $context['from'] ?? ''); 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'])) { // JSON inválido o truncado. Mandar $clean tal cual le escupía el // {"action":"chat",...} al usuario, así que se rescata solo el texto. self::log("NLU: respuesta no parseable → " . mb_substr($clean, 0, 200)); return ['action' => 'chat', 'text' => self::rescatarTexto($clean)]; } if ($json['action'] === 'route' && isset($json['key'])) { $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'], 'entities' => is_array($json['entities'] ?? null) ? $json['entities'] : [], ]; } } return ['action' => 'chat', 'text' => self::rescatarTexto((string)($json['text'] ?? $clean))]; } /** * Extrae el texto legible de una respuesta que no parseó (típicamente JSON * truncado por límite de tokens). Si no hay nada rescatable devuelve '', * y NormalBot cae al menú de fallback — mejor eso que mostrarle JSON. */ private static function rescatarTexto(string $crudo): string { $crudo = trim($crudo); if ($crudo === '') return ''; // "text":"lo que sirve → rescatar aunque falte el cierre if (preg_match('/"text"\s*:\s*"(.*?)(?:"\s*[,}]|$)/s', $crudo, $m)) { $texto = stripcslashes($m[1]); return str_contains($texto, '{"action"') ? '' : trim($texto); } // Cualquier resto con pinta de JSON o de key interna no se muestra if (str_contains($crudo, '{"') || str_contains($crudo, '"action"')) return ''; return $crudo; } 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'] ?? ''; // nlu_dir deja que un menú de categoría declare su dirección: por tipo // no es ni subida ni descarga, y sin eso el filtro de permisos no lo // tocaba y se le ofrecían informes a quien solo reporta. $dirDeclarada = $flow['nlu_dir'] ?? ''; $isUpload = in_array($type, ['collect_and_post', 'collect_for_each', 'submit_form'], true) || ($type === 'function' && $fn === 'upload_ciclo') || $dirDeclarada === 'subida'; $isDownload = ($type === 'function' && $fn === 'api_report') || $dirDeclarada === 'descarga'; // 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; // Flujos marcados como internos — no exponer al NLU if (!empty($flow['nlu_skip'])) continue; // Skip internal/navigation flows unless they have an explicit NLU description $hasNluDesc = isset($flow['nlu_description']) && $flow['nlu_description'] !== ''; if (in_array($type, ['text', 'image'], true) && !$isUpload && !$isDownload && !$hasNluDesc) 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\":\"\",\"entities\":{\"campo\":\"valor\",...}}\n\n" . "En 'entities' incluye los datos que el usuario ya mencionó: nombre de finca, grupo, filtro, fecha, valor numérico, etc. Usa el nombre tal como lo dijo el usuario.\n" . "Si no mencionó datos extra, omite entities o usa {}.\n\n" . "IMPORTANTE — no adivines cuál informe quiere.\n" . "Poné siempre el filtro (grupo, finca) en entities: el sistema lo resuelve solo y se saltea la lista.\n" . "Pero elegí el key así:\n" . " · Dijo QUÉ informe (\"todos los lotes\", \"más atrasados\", \"histórico\") → ruteá a ese informe.\n" . " · Solo nombró el tema y el filtro → ruteá al menú del tema, para que él elija el informe.\n" . " · Nombró solo una categoría (\"ausentismo\", \"producción\", \"sanidad\", \"ciclos\") → ruteá al\n" . " menú de esa categoría. NUNCA respondas por chat pidiendo que aclare: el menú ya pregunta.\n" . "Ejemplos:\n" . " \"quiero el informe de mantenimiento de plateo\" → {\"action\":\"route\",\"key\":\"ciclo_mantenimiento\",\"entities\":{\"grupo\":\"plateo\"}}\n" . " \"mantenimiento de plateo, todos los lotes\" → {\"action\":\"route\",\"key\":\"ciclo_mantenimiento_todos\",\"entities\":{\"grupo\":\"plateo\"}}\n" . " \"qué lotes llevan más tiempo sin corona\" → {\"action\":\"route\",\"key\":\"ciclo_mantenimiento_top\",\"entities\":{\"grupo\":\"corona\"}}\n" . " \"mantenimiento\" (sin grupo) → {\"action\":\"route\",\"key\":\"ciclo_mantenimiento\"}\n" . " \"quiero cambiar de finca\" → {\"action\":\"route\",\"key\":\"reset_finca\"}\n\n" . "NUNCA preguntes por un dato que una opción ya pide (grupo, finca, período):\n" . "ruteá a esa opción y el bot lo pregunta con su propia lista.\n" . " \"descargar informe de mantenimiento\" → route a ciclo_mantenimiento, NO preguntar el grupo.\n\n" . "Usá chat solo si el mensaje no corresponde a ninguna opción → 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 $callType = 'nlu', string $phone = ''): ?string { self::$logType = $callType; self::$logPhone = $phone; self::$logCompany = (int)($company['id'] ?? 0); $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 : []; } }