feat: NLU routing + audio/image transcription via AI
- 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>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
2b859e3dc0
commit
9e36a965c9
@@ -302,6 +302,141 @@ PROMPT;
|
||||
. "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 purely internal/navigation flows
|
||||
if (in_array($type, ['text', 'image'], true) && !$isUpload && !$isDownload) 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'] ?? '';
|
||||
|
||||
+56
-5
@@ -107,24 +107,75 @@ class BotRouter
|
||||
|
||||
private static function runHybridBot(array $company, array $context, string $input, string $inputType): ?array
|
||||
{
|
||||
$config = self::getConfig($company);
|
||||
$config = self::getConfig($company);
|
||||
$nluEnabled = (bool)($config['nlu'] ?? false);
|
||||
$aiEnabled = (bool)($config['ai_for_media'] ?? true);
|
||||
|
||||
// Media (image/audio/video/document) → AI handles it if enabled
|
||||
// ── Media (audio/imagen) ──────────────────────────────────────────────
|
||||
if (self::isMediaType($inputType)) {
|
||||
$aiEnabled = (bool)($config['ai_for_media'] ?? true);
|
||||
if (!$aiEnabled) {
|
||||
return self::categoryMenuFallback($company, $context, $config);
|
||||
}
|
||||
|
||||
if ($nluEnabled && in_array($inputType, ['audio', 'image'], true)) {
|
||||
$text = $inputType === 'audio'
|
||||
? MediaTranscriber::transcribeAudio($input, $company)
|
||||
: MediaTranscriber::extractFromImage($input, $company);
|
||||
|
||||
if ($text !== null && trim($text) !== '') {
|
||||
self::log("NLU media [{$inputType}]: texto extraído → \"{$text}\"");
|
||||
// Confirmar al usuario lo que entendió
|
||||
$prefix = $inputType === 'audio' ? '🎤 Transcripción' : '🖼️ Imagen analizada';
|
||||
WhatsAppSender::sendText(
|
||||
$context['from'],
|
||||
"{$prefix}: _{$text}_",
|
||||
$context['phone_number_id'] ?? ''
|
||||
);
|
||||
return self::runNluOnText($company, $context, $text, $config);
|
||||
}
|
||||
}
|
||||
|
||||
return AiBot::processMedia($company, $context, $inputType, $input);
|
||||
}
|
||||
|
||||
// Text / interactive / button → NormalBot only, never AI
|
||||
// ── Texto / interactivo: NormalBot primero ────────────────────────────
|
||||
$response = self::runNormalBot($company, $context, $input, $inputType);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
// NormalBot returned null → show the category greeting menu as fallback
|
||||
// NormalBot no reconoció → NLU si está activo, si no menú categoría
|
||||
if ($nluEnabled && $inputType === 'text') {
|
||||
return self::runNluOnText($company, $context, $input, $config);
|
||||
}
|
||||
|
||||
return self::categoryMenuFallback($company, $context, $config);
|
||||
}
|
||||
|
||||
private static function runNluOnText(array $company, array $context, string $input, array $config): ?array
|
||||
{
|
||||
$result = AiBot::routeOrChat($company, $context, $input);
|
||||
|
||||
if ($result['action'] === 'route') {
|
||||
$key = $result['key'];
|
||||
$botCtx = ConversationContext::getOrCreate((int)$company['id'], $context['from'], 'normal');
|
||||
ConversationContext::updateNode((int)$botCtx['id'], $key);
|
||||
self::log("NLU: {$context['from']} → flow [{$key}]");
|
||||
// Ejecutar con input vacío — NormalBot retoma current_node
|
||||
return NormalBot::process($company, $context, '');
|
||||
}
|
||||
|
||||
$text = trim($result['text'] ?? '');
|
||||
if ($text !== '') {
|
||||
self::log("NLU: {$context['from']} → chat libre");
|
||||
return [
|
||||
'action' => 'send',
|
||||
'type' => 'text',
|
||||
'to' => $context['from'],
|
||||
'payload' => json_encode(['text' => $text]),
|
||||
];
|
||||
}
|
||||
|
||||
return self::categoryMenuFallback($company, $context, $config);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
<?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 : [];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user