Files
Lizandro GuarnizoandClaude Sonnet 4.6 6a6f2ef44a fix: decir una categoria a secas devolvia el menu principal
Los submenus estaban marcados nlu_skip para que la IA no ruteara a navegacion,
pero eso dejo solo las hojas en el catalogo: "ausentismo" tenia cuatro
candidatos y ninguno se llamaba asi, "sanidad" ninguno, "produccion" dos. Como
no podia elegir, caia al fallback y aparecia el menu principal, que se siente
como que ignoro el mensaje.

Las cuatro categorias vuelven a ser ruteables. Para que el filtro de permisos
las alcance —por tipo un menu no es ni subida ni descarga— se agrega nlu_dir,
con lo que cat 1 solo ve registrar y cat 2 solo consultar, y la ambiguedad se
resuelve sola. Cat 3 ve las dos, asi que tiene un menu que pregunta cual.

Cuando aun asi no rutea, ahora avisa antes de mostrar el menu.

Se quita el aprendizaje por usuario: el orden por uso lo resuelve Palmas360
sobre los registros reales, y dos criterios compitiendo sobraba.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-05 23:20:25 -05:00

535 lines
23 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'])) {
// 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\":\"<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"
. "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\":\"<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 : [];
}
}