Files
bot_palmas/services/AiBot.php
T
Lizandro GuarnizoandClaude Sonnet 4.6 3d04be14bc feat: auto-selección en dynamic_list cuando NLU extrae el grupo/filtro
- handleDynamicList: si __nlu_entities contiene un valor que coincide
  con el label de un ítem, lo auto-selecciona y navega al next_node
  sin mostrar la lista interactiva
- AiBot: NLU prompt ahora pide explícitamente grupos y filtros en entities

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-05 21:57:22 -05:00

487 lines
20 KiB
PHP

<?php
declare(strict_types=1);
class AiBot
{
// Context forwarded to provider methods so AiLogger has phone + call type
private static string $logPhone = '';
private static string $logType = 'chat';
private static int $logCompany = 0;
public static function processMedia(array $company, array $context, string $mediaType, string $caption): ?array
{
$permType = (int)($context['permission_type'] ?? 1);
$config = self::getConfig($company);
$mediaLabel = match ($mediaType) {
'image' => '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 <<<PROMPT
Eres un asistente virtual de {$name}. Tu rol es:
1. Responder preguntas sobre los servicios y productos de {$name}.
2. Ayudar a los clientes con información general.
3. Ser amable, profesional y responder siempre en español.
4. Si no sabes la respuesta, indica que un asesor se comunicará.
5. No inventes información. Si no sabes algo, dilo honestamente.
Mantén las respuestas concisas (máximo 3 párrafos).
PROMPT;
}
private static function defaultMediaPrompt(array $company): string
{
$name = $company['display_name'] ?? $company['name'] ?? 'la empresa';
return "Eres el asistente virtual de {$name}. El usuario te acaba de enviar un archivo multimedia. "
. "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, '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'])) {
return ['action' => 'chat', 'text' => $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' => $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;
// 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\":\"<key_exacto>\",\"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"
. "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.";
}
/** 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 : [];
}
}