- Cat 2 y cat 3: greeting '' (no null) para que greeting_flow: ask_finca se dispare en el primer mensaje y el usuario seleccione su finca - nlu_enabled: true en config root activa el routing inteligente - NormalBot: step 6 llama AiBot::routeOrChat() antes del fallback_flow; si retorna 'route' ejecuta el flow; si 'chat' responde en texto libre - reset_finca: nuevo flow+función que limpia el meta de finca y relanza ask_finca; el NLU puede enrutar "quiero cambiar de finca" aquí - nlu_description en todos los flows consultables por el NLU; nlu_skip en menus de navegación interna para no exponerlos al modelo - AiBot::buildFlowCatalog: agrega upload_ciclo a isUpload para que cat 2 no vea flujos de subida en el catálogo del NLU Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
488 lines
20 KiB
PHP
488 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)
|
|
|| ($type === 'function' && $fn === 'upload_ciclo');
|
|
$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 : [];
|
|
}
|
|
}
|