PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / trunk
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO vtrunk
2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 1.11.0 All 47 releases
thinkrank / includes / ai / class-vision-client.php

class-vision-client.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO trunk, at includes/ai/class-vision-client.php

433 lines 15.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Vision client — describes an image with a multimodal model.
4 *
5 * Kept separate from the text clients on purpose. Every provider expresses
6 * image input differently (OpenAI nests typed content parts, Anthropic wants a
7 * base64 `source` block, Gemini wants `inline_data`), and folding three
8 * incompatible shapes into `generate_completion()` would make the text path
9 * harder to read for no benefit. This class owns that divergence and returns
10 * one thing: a sentence describing the picture.
11 *
12 * Images are sent as base64 rather than by URL because a WordPress upload is
13 * frequently unreachable from the provider — local sites, staging behind auth,
14 * intranets, or a CDN that blocks bots (see the ChatGPT/User-Agent block in the
15 * MCP threads). A URL that works for us often 403s for them.
16 *
17 * @package ThinkRank\AI
18 * @since 1.28.0
19 */
20
21 declare(strict_types=1);
22
23 namespace ThinkRank\AI;
24
25 use ThinkRank\Core\Settings;
26
27 if (!defined('ABSPATH')) {
28 exit;
29 }
30
31 /**
32 * Multimodal image description.
33 */
34 class Vision_Client {
35
36 /**
37 * Largest image we will upload, in bytes.
38 *
39 * Providers cap request size (Anthropic ~5MB per image) and a photo
40 * straight off a phone routinely exceeds it. WordPress already generates
41 * scaled sizes, so we prefer one of those and only fall back to the
42 * original when nothing smaller exists.
43 */
44 private const MAX_IMAGE_BYTES = 3500000;
45
46 /**
47 * Preferred registered image sizes, smallest adequate first. A description
48 * does not need a 4000px original — 'medium_large' is plenty and keeps the
49 * request (and the bill) small.
50 *
51 * @var string[]
52 */
53 private const PREFERRED_SIZES = ['medium_large', 'large', 'medium'];
54
55 /**
56 * Settings accessor.
57 *
58 * @var Settings
59 */
60 private Settings $settings;
61
62 /**
63 * Constructor.
64 *
65 * @param Settings|null $settings Settings instance.
66 */
67 public function __construct(?Settings $settings = null) {
68 $this->settings = $settings ?? Settings::instance();
69 }
70
71 /**
72 * Providers that can accept an image, mapped to the models we send.
73 *
74 * @return array<string, string> provider => default vision model.
75 */
76 public static function capable_providers(): array {
77 return [
78 'openai' => 'gpt-5-mini',
79 'claude' => 'claude-sonnet-5',
80 'gemini' => Settings::DEFAULT_GEMINI_MODEL,
81 ];
82 }
83
84 /**
85 * Whether the configured provider can describe images.
86 *
87 * @return bool
88 */
89 public function is_available(): bool {
90 $provider = (string) $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
91
92 return isset(self::capable_providers()[$provider])
93 && '' !== $this->api_key_for($provider);
94 }
95
96 /**
97 * The stored key for a provider.
98 *
99 * Keys are held per provider (`openai_api_key`, `claude_api_key`,
100 * `gemini_api_key`) — the same names AI_Manager reads. There is no
101 * provider-agnostic `ai_api_key` setting, so asking for one always
102 * returned '' and made every vision call fall back to the filename
103 * template without ever reaching the provider.
104 *
105 * @param string $provider Provider slug.
106 * @return string
107 */
108 private function api_key_for(string $provider): string {
109 return trim((string) $this->settings->get($provider . '_api_key', ''));
110 }
111
112 /**
113 * Describe an attachment in one short sentence suitable for alt text.
114 *
115 * @param int $attachment_id Attachment to describe.
116 * @param string $context Optional context (post title) to disambiguate.
117 * @return string Alt text, or '' when it could not be produced.
118 * @throws \Exception When the provider is unusable or the call fails.
119 */
120 public function describe_attachment(int $attachment_id, string $context = ''): string {
121 $provider = (string) $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
122 $models = self::capable_providers();
123
124 // Distinguish "no provider chosen" from "this provider can't do vision":
125 // interpolating an empty provider name reads as a broken string (#572).
126 if (Settings::AI_PROVIDER_NONE === $provider) {
127 throw new \Exception(esc_html__(
128 'No AI provider is selected. Choose OpenAI, Anthropic or Gemini in Settings → AI Provider.',
129 'thinkrank'
130 ));
131 }
132
133 if (!isset($models[$provider])) {
134 throw new \Exception(sprintf(
135 /* translators: %s: AI provider name. */
136 esc_html__('The %s provider cannot describe images. Switch to OpenAI, Anthropic or Gemini in Settings → AI Provider.', 'thinkrank'),
137 esc_html($provider)
138 ));
139 }
140
141 $api_key = $this->api_key_for($provider);
142 if ('' === $api_key) {
143 throw new \Exception(esc_html__('No AI API key configured.', 'thinkrank'));
144 }
145
146 $image = $this->read_image($attachment_id);
147 if (null === $image) {
148 throw new \Exception(esc_html__('The image file could not be read, or is larger than the provider allows.', 'thinkrank'));
149 }
150
151 $model = (string) $this->settings->get('ai_vision_model', $models[$provider]);
152 $prompt = $this->build_prompt($context);
153
154 switch ($provider) {
155 case 'openai':
156 $text = $this->call_openai($api_key, $model, $prompt, $image);
157 break;
158 case 'claude':
159 $text = $this->call_claude($api_key, $model, $prompt, $image);
160 break;
161 default:
162 $text = $this->call_gemini($api_key, $model, $prompt, $image);
163 break;
164 }
165
166 return $this->clean($text);
167 }
168
169 /**
170 * The instruction. Alt text has rules — screen readers already announce
171 * "image", length is capped by convention, and a description that opens
172 * with "an image of" wastes the listener's time.
173 *
174 * @param string $context Surrounding context, if any.
175 * @return string
176 */
177 private function build_prompt(string $context): string {
178 $prompt = "Write alt text for this image.\n\n"
179 . "Rules:\n"
180 . "- One sentence, under 125 characters.\n"
181 . "- Describe what is visibly in the image, factually.\n"
182 . "- Do NOT start with \"image of\", \"picture of\" or \"photo of\".\n"
183 . "- No quotes, no trailing period, no markdown.\n"
184 . "- If the image contains meaningful text, include it.\n"
185 . "- Reply with the alt text only.";
186
187 if ('' !== $context) {
188 $prompt .= "\n\nThe image appears in content about: " . $context;
189 }
190
191 return $prompt;
192 }
193
194 /**
195 * Load an attachment as base64, preferring a scaled size.
196 *
197 * @param int $attachment_id Attachment ID.
198 * @return array{data: string, mime: string}|null
199 */
200 private function read_image(int $attachment_id): ?array {
201 $path = $this->resolve_path($attachment_id);
202 if (null === $path) {
203 return null;
204 }
205
206 if (!is_readable($path)) {
207 return null;
208 }
209
210 $bytes = filesize($path);
211 if (false === $bytes || $bytes > self::MAX_IMAGE_BYTES) {
212 return null;
213 }
214
215 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- reading a local upload, not a remote fetch.
216 $contents = file_get_contents($path);
217 if (false === $contents || '' === $contents) {
218 return null;
219 }
220
221 $mime = (string) get_post_mime_type($attachment_id);
222 if (!in_array($mime, ['image/jpeg', 'image/png', 'image/gif', 'image/webp'], true)) {
223 return null;
224 }
225
226 // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- the multimodal request bodies of OpenAI, Anthropic and Gemini all carry image bytes as base64.
227 return ['data' => base64_encode($contents), 'mime' => $mime];
228 }
229
230 /**
231 * Absolute path to the smallest adequate version of an attachment.
232 *
233 * @param int $attachment_id Attachment ID.
234 * @return string|null
235 */
236 private function resolve_path(int $attachment_id): ?string {
237 $meta = wp_get_attachment_metadata($attachment_id);
238 $original = get_attached_file($attachment_id);
239
240 if (is_array($meta) && !empty($meta['sizes']) && is_string($original)) {
241 $dir = dirname($original);
242 foreach (self::PREFERRED_SIZES as $size) {
243 if (!empty($meta['sizes'][$size]['file'])) {
244 $candidate = $dir . '/' . $meta['sizes'][$size]['file'];
245 if (file_exists($candidate)) {
246 return $candidate;
247 }
248 }
249 }
250 }
251
252 return (is_string($original) && file_exists($original)) ? $original : null;
253 }
254
255 /**
256 * OpenAI: typed content parts with a data: URL.
257 *
258 * @param string $api_key API key.
259 * @param string $model Model id.
260 * @param string $prompt Instruction.
261 * @param array $image ['data' => base64, 'mime' => string].
262 * @return string
263 * @throws \Exception On API failure.
264 */
265 private function call_openai(string $api_key, string $model, string $prompt, array $image): string {
266 $body = [
267 'model' => $model,
268 'messages' => [[
269 'role' => 'user',
270 'content' => [
271 ['type' => 'text', 'text' => $prompt],
272 [
273 'type' => 'image_url',
274 'image_url' => ['url' => 'data:' . $image['mime'] . ';base64,' . $image['data']],
275 ],
276 ],
277 ]],
278 // Reasoning-safe ceiling, and minimal effort: alt text needs
279 // observation, not deliberation (see 6094d9d).
280 'max_completion_tokens' => 4000,
281 ];
282
283 if (0 === strpos($model, 'gpt-5')) {
284 $body['reasoning_effort'] = 'minimal';
285 }
286
287 $response = $this->post('https://api.openai.com/v1/chat/completions', [
288 'Authorization' => 'Bearer ' . $api_key,
289 'Content-Type' => 'application/json',
290 ], $body);
291
292 return (string) ($response['choices'][0]['message']['content'] ?? '');
293 }
294
295 /**
296 * Anthropic: base64 `source` block, and the API version header.
297 *
298 * @param string $api_key API key.
299 * @param string $model Model id.
300 * @param string $prompt Instruction.
301 * @param array $image ['data' => base64, 'mime' => string].
302 * @return string
303 * @throws \Exception On API failure.
304 */
305 private function call_claude(string $api_key, string $model, string $prompt, array $image): string {
306 $response = $this->post('https://api.anthropic.com/v1/messages', [
307 'x-api-key' => $api_key,
308 'anthropic-version' => '2023-06-01',
309 'Content-Type' => 'application/json',
310 ], [
311 'model' => $model,
312 'max_tokens' => 300,
313 'messages' => [[
314 'role' => 'user',
315 'content' => [
316 [
317 'type' => 'image',
318 'source' => [
319 'type' => 'base64',
320 'media_type' => $image['mime'],
321 'data' => $image['data'],
322 ],
323 ],
324 ['type' => 'text', 'text' => $prompt],
325 ],
326 ]],
327 ]);
328
329 return (string) ($response['content'][0]['text'] ?? '');
330 }
331
332 /**
333 * Gemini: `inline_data` part, key on the query string.
334 *
335 * @param string $api_key API key.
336 * @param string $model Model id.
337 * @param string $prompt Instruction.
338 * @param array $image ['data' => base64, 'mime' => string].
339 * @return string
340 * @throws \Exception On API failure.
341 */
342 private function call_gemini(string $api_key, string $model, string $prompt, array $image): string {
343 $url = sprintf(
344 'https://generativelanguage.googleapis.com/v1beta/models/%s:generateContent?key=%s',
345 rawurlencode($model),
346 rawurlencode($api_key)
347 );
348
349 $response = $this->post($url, ['Content-Type' => 'application/json'], [
350 'contents' => [[
351 'parts' => [
352 ['text' => $prompt],
353 ['inline_data' => ['mime_type' => $image['mime'], 'data' => $image['data']]],
354 ],
355 ]],
356 // Gemini 2.5 spends output tokens on reasoning before emitting
357 // text, so a tight cap returns an empty candidate.
358 'generationConfig' => ['maxOutputTokens' => 2000, 'temperature' => 0.2],
359 ]);
360
361 return (string) ($response['candidates'][0]['content']['parts'][0]['text'] ?? '');
362 }
363
364 /**
365 * Shared POST + error mapping.
366 *
367 * @param string $url Endpoint.
368 * @param array $headers Headers.
369 * @param array $body Payload.
370 * @return array Decoded response.
371 * @throws \Exception On transport or API error.
372 */
373 private function post(string $url, array $headers, array $body): array {
374 $response = wp_remote_post($url, [
375 // Vision calls carry a payload and think for a moment; the 30s
376 // default is too tight for a large image on a slow link.
377 'timeout' => 60,
378 'headers' => $headers,
379 'body' => wp_json_encode($body),
380 ]);
381
382 if (is_wp_error($response)) {
383 throw new \Exception(esc_html($response->get_error_message()));
384 }
385
386 $status = (int) wp_remote_retrieve_response_code($response);
387 $raw = wp_remote_retrieve_body($response);
388 $data = json_decode($raw, true);
389
390 if ($status >= 400) {
391 $message = $data['error']['message'] ?? ($data['error']['status'] ?? 'Unknown API error');
392 throw new \Exception(esc_html(sprintf('Vision API error (%d): %s', $status, (string) $message)));
393 }
394
395 return is_array($data) ? $data : [];
396 }
397
398 /**
399 * Normalize a model's reply into usable alt text.
400 *
401 * Models still add wrappers despite instructions, so strip them here rather
402 * than storing "Image of a red bicycle." on the attachment forever.
403 *
404 * @param string $text Raw model output.
405 * @return string
406 */
407 private function clean(string $text): string {
408 $text = trim(wp_strip_all_tags($text));
409 $text = trim($text, "\"' \t\n\r");
410 $text = (string) preg_replace('/^(an?\s+)?(image|picture|photo|photograph|screenshot)\s+(of|showing|depicting)\s+/i', '', $text);
411 $text = (string) preg_replace('/\s+/', ' ', $text);
412 $text = rtrim($text, '.');
413
414 if ('' === $text) {
415 return '';
416 }
417
418 // Alt text longer than ~125 chars is read as noise by screen readers;
419 // cut on a word boundary rather than mid-syllable.
420 if (mb_strlen($text) > 125) {
421 $text = mb_substr($text, 0, 125);
422 $space = mb_strrpos($text, ' ');
423 if (false !== $space && $space > 60) {
424 $text = mb_substr($text, 0, $space);
425 }
426 }
427
428 // ucfirst() is not multibyte-safe: on alt text starting with an
429 // accented character ("Éclair…") it corrupts the leading byte.
430 return mb_strtoupper(mb_substr($text, 0, 1)) . mb_substr($text, 1);
431 }
432 }
433