feat: media via AI, text via NormalBot only, ai_for_media toggle
- Text/interactive/button messages → NormalBot only, never escalates to AI - Image/audio/video/document → AiBot.processMedia() if ai_for_media enabled, else falls back to category greeting menu - AiBot.processMedia() uses permission_type context so AI acknowledges uploads for perm 3 and redirects others to their menu - WpWebhook.handleMedia now calls BotRouter so the bot responds to media - Admin UI: checkbox toggle "Procesar imágenes y archivos con IA" in AI tab - categoryMenuFallback() extracted in BotRouter as shared helper Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
4d7dd0f306
commit
6f799c3829
@@ -2362,6 +2362,7 @@ HTML;
|
||||
$greetingVal = self::h($config['greeting'] ?? '');
|
||||
$fallbackVal = self::h($config['fallback'] ?? '');
|
||||
$aiPromptVal = self::h($config['ai_prompt'] ?? '');
|
||||
$aiForMediaChecked = ($config['ai_for_media'] ?? true) ? 'checked' : '';
|
||||
$aiModelVal = self::h($config['ai_model'] ?? 'gpt-4o-mini');
|
||||
$aiTempVal = self::h((string)($config['ai_temperature'] ?? 0.7));
|
||||
$aiProviderVal = self::h($config['ai_provider'] ?? '');
|
||||
@@ -2618,6 +2619,16 @@ HTML;
|
||||
<textarea name="ai_prompt" rows="4" placeholder="Eres un asistente virtual de {$cName}...">{$aiPromptVal}</textarea>
|
||||
<div style="font-size:11px;color:#7a8291;margin-top:3px">Instrucciones que define el comportamiento de la IA</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label style="display:flex;align-items:center;gap:10px;cursor:pointer">
|
||||
<input type="checkbox" name="ai_for_media" value="1" {$aiForMediaChecked}>
|
||||
<span>Procesar imágenes y archivos con IA</span>
|
||||
</label>
|
||||
<div style="font-size:11px;color:#7a8291;margin-top:3px">
|
||||
Cuando el usuario envía imagen, audio o documento, la IA analiza el contenido según los permisos del usuario.
|
||||
Si está desactivado, el bot muestra el menú de categoría directamente.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ─── GENERAL ──────────────────────────────────────────────────────── -->
|
||||
|
||||
@@ -351,6 +351,9 @@ class WpWebhook
|
||||
self::saveWebhookLog('messages', $ctx['from'], $ctx['name'], $type, $preview);
|
||||
self::saveConversation($ctx, $preview, $mediaId);
|
||||
self::forwardToCompany($ctx, $preview, $mediaId);
|
||||
if (self::$currentCompany !== null) {
|
||||
BotRouter::route(self::$currentCompany, $ctx, $caption, $type);
|
||||
}
|
||||
}
|
||||
|
||||
private static function handleLocation(array $msg, array $ctx): void
|
||||
|
||||
@@ -409,6 +409,7 @@ $routes = [
|
||||
if ($aiTemp !== '') $config['ai_temperature'] = (float)$aiTemp;
|
||||
$aiProvider = trim($_POST['ai_provider'] ?? '');
|
||||
if ($aiProvider !== '') $config['ai_provider'] = $aiProvider;
|
||||
$config['ai_for_media'] = isset($_POST['ai_for_media']);
|
||||
|
||||
// Per-category menus
|
||||
$perTypeMenus = $_POST['per_type_menu'] ?? [];
|
||||
|
||||
@@ -3,6 +3,54 @@ 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 = $permType === 3;
|
||||
$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');
|
||||
@@ -189,6 +237,13 @@ 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.";
|
||||
}
|
||||
|
||||
private static function getConfig(array $company): array
|
||||
{
|
||||
$json = $company['config_json'] ?? '';
|
||||
|
||||
+25
-10
@@ -100,31 +100,46 @@ class BotRouter
|
||||
return AiBot::process($company, $context, $input);
|
||||
}
|
||||
|
||||
private static function isMediaType(string $inputType): bool
|
||||
{
|
||||
return in_array($inputType, ['image', 'audio', 'video', 'document', 'sticker'], true);
|
||||
}
|
||||
|
||||
private static function runHybridBot(array $company, array $context, string $input, string $inputType): ?array
|
||||
{
|
||||
$response = self::runNormalBot($company, $context, $input, $inputType);
|
||||
$config = self::getConfig($company);
|
||||
|
||||
// Media (image/audio/video/document) → AI handles it if enabled
|
||||
if (self::isMediaType($inputType)) {
|
||||
$aiEnabled = (bool)($config['ai_for_media'] ?? true);
|
||||
if (!$aiEnabled) {
|
||||
return self::categoryMenuFallback($company, $context, $config);
|
||||
}
|
||||
return AiBot::processMedia($company, $context, $inputType, $input);
|
||||
}
|
||||
|
||||
// Text / interactive / button → NormalBot only, never AI
|
||||
$response = self::runNormalBot($company, $context, $input, $inputType);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
// Only escalate to AI if bot type truly needs it; avoid AI for menu-driven bots
|
||||
$config = self::getConfig($company);
|
||||
$permType = (string)($context['permission_type'] ?? 1);
|
||||
$perType = $config['per_type'][$permType] ?? [];
|
||||
// NormalBot returned null → show the category greeting menu as fallback
|
||||
return self::categoryMenuFallback($company, $context, $config);
|
||||
}
|
||||
|
||||
// If a greeting_menu is configured for this category, use it as fallback instead of AI
|
||||
private static function categoryMenuFallback(array $company, array $context, array $config): ?array
|
||||
{
|
||||
$permType = (string)($context['permission_type'] ?? 1);
|
||||
$perType = $config['per_type'][$permType] ?? [];
|
||||
$greetingMenuKey = $perType['greeting_menu'] ?? null;
|
||||
if ($greetingMenuKey !== null) {
|
||||
$menus = array_merge($config['menus'] ?? [], $perType['menus'] ?? []);
|
||||
if (isset($menus[$greetingMenuKey])) {
|
||||
self::log("NormalBot no manejó '{$input}' — mostrando menú de categoría en lugar de IA");
|
||||
return NormalBot::buildGreetingMenu($menus[$greetingMenuKey], $context['from'], $company);
|
||||
}
|
||||
}
|
||||
|
||||
self::log("NormalBot no manejó '{$input}', escalando a IA");
|
||||
return self::runAiBot($company, $context, $input);
|
||||
return null;
|
||||
}
|
||||
|
||||
private static function shouldSkipBot(array $company, array $context): bool
|
||||
|
||||
Reference in New Issue
Block a user