PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.0.2
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.0.2
2.7.0 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 All 48 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 2.0.2, at includes/ai/class-vision-client.php

424 lines 15.0 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' => 'gemini-2.5-flash',
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', 'openai');
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', 'openai');
122 $models = self::capable_providers();
123
124 if (!isset($models[$provider])) {
125 throw new \Exception(sprintf(
126 /* translators: %s: AI provider name. */
127 esc_html__('The %s provider cannot describe images. Switch to OpenAI, Claude or Gemini in Settings → AI Provider.', 'thinkrank'),
128 esc_html($provider)
129 ));
130 }
131
132 $api_key = $this->api_key_for($provider);
133 if ('' === $api_key) {
134 throw new \Exception(esc_html__('No AI API key configured.', 'thinkrank'));
135 }
136
137 $image = $this->read_image($attachment_id);
138 if (null === $image) {
139 throw new \Exception(esc_html__('The image file could not be read, or is larger than the provider allows.', 'thinkrank'));
140 }
141
142 $model = (string) $this->settings->get('ai_vision_model', $models[$provider]);
143 $prompt = $this->build_prompt($context);
144
145 switch ($provider) {
146 case 'openai':
147 $text = $this->call_openai($api_key, $model, $prompt, $image);
148 break;
149 case 'claude':
150 $text = $this->call_claude($api_key, $model, $prompt, $image);
151 break;
152 default:
153 $text = $this->call_gemini($api_key, $model, $prompt, $image);
154 break;
155 }
156
157 return $this->clean($text);
158 }
159
160 /**
161 * The instruction. Alt text has rules — screen readers already announce
162 * "image", length is capped by convention, and a description that opens
163 * with "an image of" wastes the listener's time.
164 *
165 * @param string $context Surrounding context, if any.
166 * @return string
167 */
168 private function build_prompt(string $context): string {
169 $prompt = "Write alt text for this image.\n\n"
170 . "Rules:\n"
171 . "- One sentence, under 125 characters.\n"
172 . "- Describe what is visibly in the image, factually.\n"
173 . "- Do NOT start with \"image of\", \"picture of\" or \"photo of\".\n"
174 . "- No quotes, no trailing period, no markdown.\n"
175 . "- If the image contains meaningful text, include it.\n"
176 . "- Reply with the alt text only.";
177
178 if ('' !== $context) {
179 $prompt .= "\n\nThe image appears in content about: " . $context;
180 }
181
182 return $prompt;
183 }
184
185 /**
186 * Load an attachment as base64, preferring a scaled size.
187 *
188 * @param int $attachment_id Attachment ID.
189 * @return array{data: string, mime: string}|null
190 */
191 private function read_image(int $attachment_id): ?array {
192 $path = $this->resolve_path($attachment_id);
193 if (null === $path) {
194 return null;
195 }
196
197 if (!is_readable($path)) {
198 return null;
199 }
200
201 $bytes = filesize($path);
202 if (false === $bytes || $bytes > self::MAX_IMAGE_BYTES) {
203 return null;
204 }
205
206 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- reading a local upload, not a remote fetch.
207 $contents = file_get_contents($path);
208 if (false === $contents || '' === $contents) {
209 return null;
210 }
211
212 $mime = (string) get_post_mime_type($attachment_id);
213 if (!in_array($mime, ['image/jpeg', 'image/png', 'image/gif', 'image/webp'], true)) {
214 return null;
215 }
216
217 // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- the multimodal request bodies of OpenAI, Anthropic and Gemini all carry image bytes as base64.
218 return ['data' => base64_encode($contents), 'mime' => $mime];
219 }
220
221 /**
222 * Absolute path to the smallest adequate version of an attachment.
223 *
224 * @param int $attachment_id Attachment ID.
225 * @return string|null
226 */
227 private function resolve_path(int $attachment_id): ?string {
228 $meta = wp_get_attachment_metadata($attachment_id);
229 $original = get_attached_file($attachment_id);
230
231 if (is_array($meta) && !empty($meta['sizes']) && is_string($original)) {
232 $dir = dirname($original);
233 foreach (self::PREFERRED_SIZES as $size) {
234 if (!empty($meta['sizes'][$size]['file'])) {
235 $candidate = $dir . '/' . $meta['sizes'][$size]['file'];
236 if (file_exists($candidate)) {
237 return $candidate;
238 }
239 }
240 }
241 }
242
243 return (is_string($original) && file_exists($original)) ? $original : null;
244 }
245
246 /**
247 * OpenAI: typed content parts with a data: URL.
248 *
249 * @param string $api_key API key.
250 * @param string $model Model id.
251 * @param string $prompt Instruction.
252 * @param array $image ['data' => base64, 'mime' => string].
253 * @return string
254 * @throws \Exception On API failure.
255 */
256 private function call_openai(string $api_key, string $model, string $prompt, array $image): string {
257 $body = [
258 'model' => $model,
259 'messages' => [[
260 'role' => 'user',
261 'content' => [
262 ['type' => 'text', 'text' => $prompt],
263 [
264 'type' => 'image_url',
265 'image_url' => ['url' => 'data:' . $image['mime'] . ';base64,' . $image['data']],
266 ],
267 ],
268 ]],
269 // Reasoning-safe ceiling, and minimal effort: alt text needs
270 // observation, not deliberation (see 6094d9d).
271 'max_completion_tokens' => 4000,
272 ];
273
274 if (0 === strpos($model, 'gpt-5')) {
275 $body['reasoning_effort'] = 'minimal';
276 }
277
278 $response = $this->post('https://api.openai.com/v1/chat/completions', [
279 'Authorization' => 'Bearer ' . $api_key,
280 'Content-Type' => 'application/json',
281 ], $body);
282
283 return (string) ($response['choices'][0]['message']['content'] ?? '');
284 }
285
286 /**
287 * Anthropic: base64 `source` block, and the API version header.
288 *
289 * @param string $api_key API key.
290 * @param string $model Model id.
291 * @param string $prompt Instruction.
292 * @param array $image ['data' => base64, 'mime' => string].
293 * @return string
294 * @throws \Exception On API failure.
295 */
296 private function call_claude(string $api_key, string $model, string $prompt, array $image): string {
297 $response = $this->post('https://api.anthropic.com/v1/messages', [
298 'x-api-key' => $api_key,
299 'anthropic-version' => '2023-06-01',
300 'Content-Type' => 'application/json',
301 ], [
302 'model' => $model,
303 'max_tokens' => 300,
304 'messages' => [[
305 'role' => 'user',
306 'content' => [
307 [
308 'type' => 'image',
309 'source' => [
310 'type' => 'base64',
311 'media_type' => $image['mime'],
312 'data' => $image['data'],
313 ],
314 ],
315 ['type' => 'text', 'text' => $prompt],
316 ],
317 ]],
318 ]);
319
320 return (string) ($response['content'][0]['text'] ?? '');
321 }
322
323 /**
324 * Gemini: `inline_data` part, key on the query string.
325 *
326 * @param string $api_key API key.
327 * @param string $model Model id.
328 * @param string $prompt Instruction.
329 * @param array $image ['data' => base64, 'mime' => string].
330 * @return string
331 * @throws \Exception On API failure.
332 */
333 private function call_gemini(string $api_key, string $model, string $prompt, array $image): string {
334 $url = sprintf(
335 'https://generativelanguage.googleapis.com/v1beta/models/%s:generateContent?key=%s',
336 rawurlencode($model),
337 rawurlencode($api_key)
338 );
339
340 $response = $this->post($url, ['Content-Type' => 'application/json'], [
341 'contents' => [[
342 'parts' => [
343 ['text' => $prompt],
344 ['inline_data' => ['mime_type' => $image['mime'], 'data' => $image['data']]],
345 ],
346 ]],
347 // Gemini 2.5 spends output tokens on reasoning before emitting
348 // text, so a tight cap returns an empty candidate.
349 'generationConfig' => ['maxOutputTokens' => 2000, 'temperature' => 0.2],
350 ]);
351
352 return (string) ($response['candidates'][0]['content']['parts'][0]['text'] ?? '');
353 }
354
355 /**
356 * Shared POST + error mapping.
357 *
358 * @param string $url Endpoint.
359 * @param array $headers Headers.
360 * @param array $body Payload.
361 * @return array Decoded response.
362 * @throws \Exception On transport or API error.
363 */
364 private function post(string $url, array $headers, array $body): array {
365 $response = wp_remote_post($url, [
366 // Vision calls carry a payload and think for a moment; the 30s
367 // default is too tight for a large image on a slow link.
368 'timeout' => 60,
369 'headers' => $headers,
370 'body' => wp_json_encode($body),
371 ]);
372
373 if (is_wp_error($response)) {
374 throw new \Exception(esc_html($response->get_error_message()));
375 }
376
377 $status = (int) wp_remote_retrieve_response_code($response);
378 $raw = wp_remote_retrieve_body($response);
379 $data = json_decode($raw, true);
380
381 if ($status >= 400) {
382 $message = $data['error']['message'] ?? ($data['error']['status'] ?? 'Unknown API error');
383 throw new \Exception(sprintf('Vision API error (%d): %s', $status, esc_html((string) $message)));
384 }
385
386 return is_array($data) ? $data : [];
387 }
388
389 /**
390 * Normalize a model's reply into usable alt text.
391 *
392 * Models still add wrappers despite instructions, so strip them here rather
393 * than storing "Image of a red bicycle." on the attachment forever.
394 *
395 * @param string $text Raw model output.
396 * @return string
397 */
398 private function clean(string $text): string {
399 $text = trim(wp_strip_all_tags($text));
400 $text = trim($text, "\"' \t\n\r");
401 $text = (string) preg_replace('/^(an?\s+)?(image|picture|photo|photograph|screenshot)\s+(of|showing|depicting)\s+/i', '', $text);
402 $text = (string) preg_replace('/\s+/', ' ', $text);
403 $text = rtrim($text, '.');
404
405 if ('' === $text) {
406 return '';
407 }
408
409 // Alt text longer than ~125 chars is read as noise by screen readers;
410 // cut on a word boundary rather than mid-syllable.
411 if (mb_strlen($text) > 125) {
412 $text = mb_substr($text, 0, 125);
413 $space = mb_strrpos($text, ' ');
414 if (false !== $space && $space > 60) {
415 $text = mb_substr($text, 0, $space);
416 }
417 }
418
419 // ucfirst() is not multibyte-safe: on alt text starting with an
420 // accented character ("Éclair…") it corrupts the leading byte.
421 return mb_strtoupper(mb_substr($text, 0, 1)) . mb_substr($text, 1);
422 }
423 }
424