Files
bot_palmas/services/AiBot.php
T
Lizandro GuarnizoandClaude Sonnet 4.6 1d9484a1e0 Fix NLU flow: media_id, upload perm, catalog filter, nlu_description field
- WpWebhook: inject media_id into context before calling BotRouter so
  MediaTranscriber can download audio/image (was passing caption='')
- BotRouter: use context['media_id'] for transcription (non-breaking fallback)
- AiBot::processMedia: fix canUpload to include permType 1 (solo reporta)
- AiBot::buildFlowCatalog: include text flows that have nlu_description set
- DashboardController + index.php: add nlu_description field per flow card

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-05 08:51:13 -05:00

449 lines
18 KiB
PHP

<?php
declare(strict_types=1);
class AiBot
{
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);
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 <<<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);
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 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>\"}\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
{
$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 : [];
}
}