| 1 |
<?php |
| 2 |
/** |
| 3 |
* Brand Visibility v2 — per-platform provider resolution. |
| 4 |
* |
| 5 |
* Measuring visibility means asking SEVERAL assistants the same question, so |
| 6 |
* this feature can't be chained to the single site-wide AI provider the rest |
| 7 |
* of ThinkRank uses. Each platform therefore gets its own optional key |
| 8 |
* (`bv_key_<platform>`), falling back to the main provider key when the two |
| 9 |
* happen to be the same vendor. A platform with no usable key is simply not |
| 10 |
* offered in the wizard. |
| 11 |
* |
| 12 |
* Grounding honesty: plain Chat Completions answers reflect the model's |
| 13 |
* training data, while the consumer apps people actually use often browse the |
| 14 |
* live web. Perplexity's API is search-grounded; the others are not, and the |
| 15 |
* UI labels them accordingly rather than implying all four measure the same |
| 16 |
* thing. |
| 17 |
* |
| 18 |
* @package ThinkRank\AI |
| 19 |
* @since 1.28.0 |
| 20 |
*/ |
| 21 |
|
| 22 |
declare(strict_types=1); |
| 23 |
|
| 24 |
namespace ThinkRank\AI; |
| 25 |
|
| 26 |
use ThinkRank\Core\Settings; |
| 27 |
|
| 28 |
if (!defined('ABSPATH')) { |
| 29 |
exit; |
| 30 |
} |
| 31 |
|
| 32 |
/** |
| 33 |
* Resolves platform slugs to configured AI clients. |
| 34 |
* |
| 35 |
* Not final: Brand_Visibility_Runner type-hints this class for its provider |
| 36 |
* resolution, so a test that needs to drive the runner without reaching a real |
| 37 |
* provider substitutes a subclass whose client_for() returns a canned client. |
| 38 |
*/ |
| 39 |
class Brand_Visibility_Providers { |
| 40 |
|
| 41 |
/** |
| 42 |
* Supported platforms. |
| 43 |
* |
| 44 |
* `grounded` drives the UI's honesty label. `main_provider` names the |
| 45 |
* site-wide provider whose key this platform can borrow. |
| 46 |
*/ |
| 47 |
public const PLATFORMS = [ |
| 48 |
'chatgpt' => [ |
| 49 |
'label' => 'ChatGPT', |
| 50 |
'vendor' => 'openai', |
| 51 |
'main_provider' => 'openai', |
| 52 |
'grounded' => false, |
| 53 |
'default_model' => 'gpt-5-nano', |
| 54 |
], |
| 55 |
'gemini' => [ |
| 56 |
'label' => 'Gemini', |
| 57 |
'vendor' => 'gemini', |
| 58 |
'main_provider' => 'gemini', |
| 59 |
'grounded' => false, |
| 60 |
'default_model' => Settings::DEFAULT_GEMINI_MODEL, |
| 61 |
], |
| 62 |
'claude' => [ |
| 63 |
'label' => 'Claude', |
| 64 |
'vendor' => 'claude', |
| 65 |
'main_provider' => 'claude', |
| 66 |
'grounded' => false, |
| 67 |
'default_model' => Settings::DEFAULT_CLAUDE_MODEL, |
| 68 |
], |
| 69 |
'perplexity' => [ |
| 70 |
'label' => 'Perplexity', |
| 71 |
'vendor' => 'perplexity', |
| 72 |
'main_provider' => '', // no site-wide equivalent — needs its own key |
| 73 |
'grounded' => true, |
| 74 |
'default_model' => Perplexity_Client::DEFAULT_MODEL, |
| 75 |
], |
| 76 |
]; |
| 77 |
|
| 78 |
/** |
| 79 |
* Selectable models per platform. |
| 80 |
* |
| 81 |
* Deliberately a short, curated list rather than the vendor's full catalogue: |
| 82 |
* Brand Visibility fires queries × platforms × samples calls per run, so the |
| 83 |
* cheap/fast tiers are listed first and are the defaults. The whitelist also |
| 84 |
* doubles as save-time validation — an unknown id is rejected rather than |
| 85 |
* stored and failing later inside a queued run. |
| 86 |
* |
| 87 |
* @var array<string, array<int, array{value: string, label: string}>> |
| 88 |
*/ |
| 89 |
public const MODEL_CHOICES = [ |
| 90 |
'chatgpt' => [ |
| 91 |
['value' => 'gpt-5-nano', 'label' => 'GPT-5 nano (fastest, lowest cost)'], |
| 92 |
['value' => 'gpt-5-mini', 'label' => 'GPT-5 mini (balanced)'], |
| 93 |
['value' => 'gpt-5', 'label' => 'GPT-5 (highest quality)'], |
| 94 |
['value' => 'gpt-4o', 'label' => 'GPT-4o (compatibility)'], |
| 95 |
], |
| 96 |
'gemini' => [ |
| 97 |
['value' => 'gemini-3.5-flash', 'label' => 'Gemini 3.5 Flash (recommended)'], |
| 98 |
['value' => 'gemini-3.1-flash-lite', 'label' => 'Gemini 3.1 Flash Lite (fastest)'], |
| 99 |
['value' => 'gemini-3.1-pro-preview', 'label' => 'Gemini 3.1 Pro (preview)'], |
| 100 |
], |
| 101 |
'claude' => [ |
| 102 |
['value' => 'claude-haiku-4-5', 'label' => 'Claude Haiku 4.5 (fastest)'], |
| 103 |
['value' => 'claude-sonnet-5', 'label' => 'Claude Sonnet 5 (recommended)'], |
| 104 |
['value' => 'claude-opus-4-8', 'label' => 'Claude Opus 4.8'], |
| 105 |
['value' => 'claude-opus-5', 'label' => 'Claude Opus 5 (most capable)'], |
| 106 |
], |
| 107 |
'perplexity' => [ |
| 108 |
['value' => 'sonar', 'label' => 'Sonar (search-grounded)'], |
| 109 |
['value' => 'sonar-pro', 'label' => 'Sonar Pro (deeper search)'], |
| 110 |
['value' => 'sonar-reasoning', 'label' => 'Sonar Reasoning'], |
| 111 |
], |
| 112 |
]; |
| 113 |
|
| 114 |
/** |
| 115 |
* Settings accessor. |
| 116 |
* |
| 117 |
* @var Settings |
| 118 |
*/ |
| 119 |
private Settings $settings; |
| 120 |
|
| 121 |
/** |
| 122 |
* Constructor. |
| 123 |
* |
| 124 |
* @param Settings|null $settings Settings instance. |
| 125 |
*/ |
| 126 |
public function __construct(?Settings $settings = null) { |
| 127 |
$this->settings = $settings ?? Settings::instance(); |
| 128 |
} |
| 129 |
|
| 130 |
/** |
| 131 |
* Option key holding a platform's dedicated API key. |
| 132 |
* |
| 133 |
* @param string $platform Platform slug. |
| 134 |
* @return string |
| 135 |
*/ |
| 136 |
public static function key_option(string $platform): string { |
| 137 |
return 'bv_key_' . $platform; |
| 138 |
} |
| 139 |
|
| 140 |
/** |
| 141 |
* Option key holding a platform's chosen model. |
| 142 |
* |
| 143 |
* @param string $platform Platform slug. |
| 144 |
* @return string |
| 145 |
*/ |
| 146 |
public static function model_option(string $platform): string { |
| 147 |
return 'bv_model_' . $platform; |
| 148 |
} |
| 149 |
|
| 150 |
/** |
| 151 |
* Whether a model id is offered for a platform. |
| 152 |
* |
| 153 |
* @param string $platform Platform slug. |
| 154 |
* @param string $model Model id. |
| 155 |
* @return bool |
| 156 |
*/ |
| 157 |
public static function is_valid_model(string $platform, string $model): bool { |
| 158 |
foreach (self::MODEL_CHOICES[$platform] ?? [] as $choice) { |
| 159 |
if ($choice['value'] === $model) { |
| 160 |
return true; |
| 161 |
} |
| 162 |
} |
| 163 |
|
| 164 |
return false; |
| 165 |
} |
| 166 |
|
| 167 |
/** |
| 168 |
* The model a platform will actually use — its saved choice, else the |
| 169 |
* platform default. A stored id that is no longer offered falls back to the |
| 170 |
* default rather than failing mid-run. |
| 171 |
* |
| 172 |
* @param string $platform Platform slug. |
| 173 |
* @return string Empty when the platform is unknown. |
| 174 |
*/ |
| 175 |
public function model_for(string $platform): string { |
| 176 |
$config = self::PLATFORMS[$platform] ?? null; |
| 177 |
if (null === $config) { |
| 178 |
return ''; |
| 179 |
} |
| 180 |
|
| 181 |
$saved = trim((string) $this->settings->get(self::model_option($platform), '')); |
| 182 |
|
| 183 |
return self::is_valid_model($platform, $saved) ? $saved : (string) $config['default_model']; |
| 184 |
} |
| 185 |
|
| 186 |
/** |
| 187 |
* Effective model for every platform, for the wizard's model pickers. |
| 188 |
* |
| 189 |
* @return array<string, string> |
| 190 |
*/ |
| 191 |
public function models(): array { |
| 192 |
$out = []; |
| 193 |
|
| 194 |
foreach (array_keys(self::PLATFORMS) as $slug) { |
| 195 |
$out[$slug] = $this->model_for($slug); |
| 196 |
} |
| 197 |
|
| 198 |
return $out; |
| 199 |
} |
| 200 |
|
| 201 |
/** |
| 202 |
* The API key to use for a platform: its own, else the site-wide key when |
| 203 |
* the main provider is the same vendor. |
| 204 |
* |
| 205 |
* @param string $platform Platform slug. |
| 206 |
* @return string Empty when the platform is unusable. |
| 207 |
*/ |
| 208 |
public function api_key_for(string $platform): string { |
| 209 |
$config = self::PLATFORMS[$platform] ?? null; |
| 210 |
if (null === $config) { |
| 211 |
return ''; |
| 212 |
} |
| 213 |
|
| 214 |
$own = trim((string) $this->settings->get(self::key_option($platform), '')); |
| 215 |
if ('' !== $own) { |
| 216 |
return $own; |
| 217 |
} |
| 218 |
|
| 219 |
// Borrow the site-wide key only when it belongs to the same vendor — |
| 220 |
// an OpenAI key cannot talk to Anthropic. |
| 221 |
$main_provider = (string) $this->settings->get('ai_provider', ''); |
| 222 |
if ('' !== $config['main_provider'] && $main_provider === $config['main_provider']) { |
| 223 |
return trim((string) $this->settings->get('ai_api_key', '')); |
| 224 |
} |
| 225 |
|
| 226 |
return ''; |
| 227 |
} |
| 228 |
|
| 229 |
/** |
| 230 |
* Platforms with a usable key, for the wizard's platform picker. |
| 231 |
* |
| 232 |
* @return array<int, array{slug: string, label: string, grounded: bool, available: bool, uses_shared_key: bool}> |
| 233 |
*/ |
| 234 |
public function available_platforms(): array { |
| 235 |
$out = []; |
| 236 |
|
| 237 |
foreach (self::PLATFORMS as $slug => $config) { |
| 238 |
$own_key = '' !== trim((string) $this->settings->get(self::key_option($slug), '')); |
| 239 |
|
| 240 |
$out[] = [ |
| 241 |
'slug' => $slug, |
| 242 |
'label' => $config['label'], |
| 243 |
'grounded' => (bool) $config['grounded'], |
| 244 |
'available' => '' !== $this->api_key_for($slug), |
| 245 |
'uses_shared_key' => !$own_key && '' !== $this->api_key_for($slug), |
| 246 |
]; |
| 247 |
} |
| 248 |
|
| 249 |
return $out; |
| 250 |
} |
| 251 |
|
| 252 |
/** |
| 253 |
* Build a client for a platform. |
| 254 |
* |
| 255 |
* @param string $platform Platform slug. |
| 256 |
* @return object A client exposing generate_completion(string, array): array. |
| 257 |
* @throws \Exception When the platform is unknown or has no key. |
| 258 |
*/ |
| 259 |
public function client_for(string $platform) { |
| 260 |
$config = self::PLATFORMS[$platform] ?? null; |
| 261 |
if (null === $config) { |
| 262 |
throw new \Exception(sprintf('Unknown Brand Visibility platform: %s', esc_html($platform))); |
| 263 |
} |
| 264 |
|
| 265 |
$api_key = $this->api_key_for($platform); |
| 266 |
if ('' === $api_key) { |
| 267 |
throw new \Exception(sprintf( |
| 268 |
/* translators: %s: platform label, e.g. Claude. */ |
| 269 |
esc_html__('No API key configured for %s. Add one under Brand Visibility settings.', 'thinkrank'), |
| 270 |
esc_html($config['label']) |
| 271 |
)); |
| 272 |
} |
| 273 |
|
| 274 |
$model = $this->model_for($platform); |
| 275 |
|
| 276 |
switch ($config['vendor']) { |
| 277 |
case 'openai': |
| 278 |
return '' !== $model |
| 279 |
? new OpenAI_Client($api_key, $model) |
| 280 |
: new OpenAI_Client($api_key); |
| 281 |
case 'claude': |
| 282 |
return '' !== $model |
| 283 |
? new Claude_Client($api_key, $model) |
| 284 |
: new Claude_Client($api_key); |
| 285 |
case 'gemini': |
| 286 |
return '' !== $model |
| 287 |
? new Gemini_Client($api_key, $model) |
| 288 |
: new Gemini_Client($api_key); |
| 289 |
case 'perplexity': |
| 290 |
return new Perplexity_Client($api_key, $model); |
| 291 |
} |
| 292 |
|
| 293 |
throw new \Exception(sprintf('Unsupported vendor for platform: %s', esc_html($platform))); |
| 294 |
} |
| 295 |
} |
| 296 |
|