- NluRouter in AiBot: builds permission-filtered flow catalog, asks AI to route user intent to exact flow key or return chat response - MediaTranscriber: downloads WA media, transcribes audio (Whisper/Gemini), extracts image intent (OpenAI/Gemini/Claude vision) - BotRouter hybrid: text → NormalBot → NLU fallback if no match; audio/image → transcribe → NLU route; sends transcript preview first - Admin IA tab: NLU checkbox with description - Per-company nlu flag saved in config_json Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
282 lines
11 KiB
PHP
282 lines
11 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
/**
|
|
* Descarga media de WhatsApp y la transcribe/analiza con el proveedor de IA activo.
|
|
* Retorna siempre texto plano para pasar al NluRouter.
|
|
*/
|
|
class MediaTranscriber
|
|
{
|
|
// ── WhatsApp media download ───────────────────────────────────────────────
|
|
|
|
private static function downloadMedia(string $mediaId): ?array
|
|
{
|
|
$token = env('WHATSAPP_ACCESS_TOKEN', '');
|
|
if ($token === '') return null;
|
|
|
|
// 1) Obtener URL del archivo
|
|
$ch = curl_init("https://graph.facebook.com/v18.0/{$mediaId}");
|
|
curl_setopt_array($ch, [
|
|
CURLOPT_HTTPHEADER => ["Authorization: Bearer {$token}", "User-Agent: bot-palmas360/1.0"],
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_TIMEOUT => 15,
|
|
]);
|
|
$resp = curl_exec($ch);
|
|
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
curl_close($ch);
|
|
|
|
if ($code !== 200 || !$resp) return null;
|
|
|
|
$data = json_decode($resp, true);
|
|
$url = $data['url'] ?? '';
|
|
$mime = $data['mime_type'] ?? 'application/octet-stream';
|
|
if ($url === '') return null;
|
|
|
|
// 2) Descargar binario
|
|
$ch = curl_init($url);
|
|
curl_setopt_array($ch, [
|
|
CURLOPT_HTTPHEADER => ["Authorization: Bearer {$token}", "User-Agent: bot-palmas360/1.0"],
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_FOLLOWLOCATION => true,
|
|
CURLOPT_TIMEOUT => 45,
|
|
]);
|
|
$bytes = curl_exec($ch);
|
|
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
curl_close($ch);
|
|
|
|
if ($code !== 200 || !$bytes || strlen($bytes) < 100) return null;
|
|
|
|
return ['bytes' => $bytes, 'mime' => $mime];
|
|
}
|
|
|
|
// ── Audio → texto ─────────────────────────────────────────────────────────
|
|
|
|
public static function transcribeAudio(string $mediaId, array $company): ?string
|
|
{
|
|
$cfg = self::getConfig($company);
|
|
$provider = $cfg['ai_provider'] ?? env('AI_PROVIDER', 'openai');
|
|
|
|
$media = self::downloadMedia($mediaId);
|
|
if ($media === null) return null;
|
|
|
|
return match ($provider) {
|
|
'openai' => self::whisper($media['bytes'], $media['mime'], $cfg),
|
|
'gemini' => self::geminiAudio($media['bytes'], $media['mime'], $cfg),
|
|
default => null, // Claude no soporta audio
|
|
};
|
|
}
|
|
|
|
private static function whisper(string $bytes, string $mime, array $cfg): ?string
|
|
{
|
|
$apiKey = $cfg['openai_api_key'] ?? env('OPENAI_API_KEY', '');
|
|
if ($apiKey === '') return null;
|
|
|
|
$ext = str_contains($mime, 'ogg') ? 'ogg' : (str_contains($mime, 'mp4') ? 'mp4' : 'ogg');
|
|
$tmpFile = tempnam(sys_get_temp_dir(), 'wa_audio_') . '.' . $ext;
|
|
file_put_contents($tmpFile, $bytes);
|
|
|
|
$ch = curl_init('https://api.openai.com/v1/audio/transcriptions');
|
|
curl_setopt_array($ch, [
|
|
CURLOPT_POST => true,
|
|
CURLOPT_POSTFIELDS => [
|
|
'file' => new CURLFile($tmpFile, $mime, 'audio.' . $ext),
|
|
'model' => 'whisper-1',
|
|
'language' => 'es',
|
|
],
|
|
CURLOPT_HTTPHEADER => ["Authorization: Bearer {$apiKey}"],
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_TIMEOUT => 60,
|
|
]);
|
|
$resp = curl_exec($ch);
|
|
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
curl_close($ch);
|
|
@unlink($tmpFile);
|
|
|
|
if ($code !== 200 || !$resp) return null;
|
|
|
|
$data = json_decode($resp, true);
|
|
return isset($data['text']) ? trim($data['text']) : null;
|
|
}
|
|
|
|
private static function geminiAudio(string $bytes, string $mime, array $cfg): ?string
|
|
{
|
|
$apiKey = $cfg['gemini_api_key'] ?? env('GEMINI_API_KEY', '');
|
|
$model = $cfg['gemini_model'] ?? env('GEMINI_MODEL', 'gemini-2.5-flash');
|
|
if ($apiKey === '') return null;
|
|
|
|
// Gemini acepta audio inline como base64
|
|
$payload = json_encode([
|
|
'contents' => [[
|
|
'parts' => [
|
|
['inline_data' => ['mime_type' => $mime, 'data' => base64_encode($bytes)]],
|
|
['text' => 'Transcribe este audio de WhatsApp en español. Devuelve SOLO el texto transcrito, sin explicaciones ni puntuación extra.'],
|
|
],
|
|
]],
|
|
]);
|
|
|
|
$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 => 60,
|
|
]);
|
|
$resp = curl_exec($ch);
|
|
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
curl_close($ch);
|
|
|
|
if ($code !== 200 || !$resp) return null;
|
|
|
|
$data = json_decode($resp, true);
|
|
$text = $data['candidates'][0]['content']['parts'][0]['text'] ?? null;
|
|
return $text !== null ? trim($text) : null;
|
|
}
|
|
|
|
// ── Imagen → intención/texto ──────────────────────────────────────────────
|
|
|
|
public static function extractFromImage(string $mediaId, array $company): ?string
|
|
{
|
|
$cfg = self::getConfig($company);
|
|
$provider = $cfg['ai_provider'] ?? env('AI_PROVIDER', 'openai');
|
|
|
|
$media = self::downloadMedia($mediaId);
|
|
if ($media === null) return null;
|
|
|
|
$b64 = base64_encode($media['bytes']);
|
|
$mime = $media['mime'];
|
|
$prompt = 'Analiza esta imagen de WhatsApp. Si contiene texto, extráelo. '
|
|
. 'Describe en una oración qué quiere hacer el usuario o qué información contiene. '
|
|
. 'Responde en español, máximo 2 oraciones.';
|
|
|
|
return match ($provider) {
|
|
'openai' => self::openaiVision($b64, $mime, $prompt, $cfg),
|
|
'gemini' => self::geminiVision($b64, $mime, $prompt, $cfg),
|
|
'claude' => self::claudeVision($b64, $mime, $prompt, $cfg),
|
|
default => null,
|
|
};
|
|
}
|
|
|
|
private static function openaiVision(string $b64, string $mime, string $prompt, array $cfg): ?string
|
|
{
|
|
$apiKey = $cfg['openai_api_key'] ?? env('OPENAI_API_KEY', '');
|
|
$model = $cfg['ai_model'] ?? env('OPENAI_MODEL', 'gpt-4o');
|
|
// Vision requires gpt-4o or gpt-4.1 — downgrade if mini is selected
|
|
if (str_contains($model, 'mini') || str_contains($model, '3.5')) $model = 'gpt-4o';
|
|
if ($apiKey === '') return null;
|
|
|
|
$payload = json_encode([
|
|
'model' => $model,
|
|
'max_tokens' => 200,
|
|
'messages' => [[
|
|
'role' => 'user',
|
|
'content' => [
|
|
['type' => 'image_url', 'image_url' => ['url' => "data:{$mime};base64,{$b64}"]],
|
|
['type' => 'text', 'text' => $prompt],
|
|
],
|
|
]],
|
|
]);
|
|
|
|
$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,
|
|
]);
|
|
$resp = curl_exec($ch);
|
|
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
curl_close($ch);
|
|
|
|
if ($code !== 200 || !$resp) return null;
|
|
|
|
$data = json_decode($resp, true);
|
|
$text = $data['choices'][0]['message']['content'] ?? null;
|
|
return $text !== null ? trim($text) : null;
|
|
}
|
|
|
|
private static function geminiVision(string $b64, string $mime, string $prompt, array $cfg): ?string
|
|
{
|
|
$apiKey = $cfg['gemini_api_key'] ?? env('GEMINI_API_KEY', '');
|
|
$model = $cfg['gemini_model'] ?? env('GEMINI_MODEL', 'gemini-2.5-flash');
|
|
if ($apiKey === '') return null;
|
|
|
|
$payload = json_encode([
|
|
'contents' => [[
|
|
'parts' => [
|
|
['inline_data' => ['mime_type' => $mime, 'data' => $b64]],
|
|
['text' => $prompt],
|
|
],
|
|
]],
|
|
]);
|
|
|
|
$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,
|
|
]);
|
|
$resp = curl_exec($ch);
|
|
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
curl_close($ch);
|
|
|
|
if ($code !== 200 || !$resp) return null;
|
|
|
|
$data = json_decode($resp, true);
|
|
$text = $data['candidates'][0]['content']['parts'][0]['text'] ?? null;
|
|
return $text !== null ? trim($text) : null;
|
|
}
|
|
|
|
private static function claudeVision(string $b64, string $mime, string $prompt, array $cfg): ?string
|
|
{
|
|
$apiKey = $cfg['claude_api_key'] ?? env('CLAUDE_API_KEY', '');
|
|
$model = $cfg['claude_model'] ?? env('CLAUDE_MODEL', 'claude-haiku-4-5');
|
|
if ($apiKey === '') return null;
|
|
|
|
$payload = json_encode([
|
|
'model' => $model,
|
|
'max_tokens' => 200,
|
|
'messages' => [[
|
|
'role' => 'user',
|
|
'content' => [
|
|
['type' => 'image', 'source' => ['type' => 'base64', 'media_type' => $mime, 'data' => $b64]],
|
|
['type' => 'text', 'text' => $prompt],
|
|
],
|
|
]],
|
|
]);
|
|
|
|
$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,
|
|
]);
|
|
$resp = curl_exec($ch);
|
|
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
curl_close($ch);
|
|
|
|
if ($code !== 200 || !$resp) return null;
|
|
|
|
$data = json_decode($resp, true);
|
|
$text = $data['content'][0]['text'] ?? null;
|
|
return $text !== null ? trim($text) : null;
|
|
}
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
|
|
private static function getConfig(array $company): array
|
|
{
|
|
$json = $company['config_json'] ?? '';
|
|
if ($json === '') return [];
|
|
$cfg = json_decode($json, true);
|
|
return is_array($cfg) ? $cfg : [];
|
|
}
|
|
}
|