PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.10.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.10.0
2.10.0 2.9.0 2.8.0 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 All 51 releases
thinkrank / includes / ai / class-gemini-client.php

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

894 lines 36.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Google Gemini AI Client
4 *
5 * Handles communication with Google's Gemini API for AI-powered features.
6 * Integrates with centralized prompt system for consistent prompts across providers.
7 *
8 * @package ThinkRank
9 * @subpackage AI
10 * @since 1.0.0
11 */
12
13 declare(strict_types=1);
14
15 namespace ThinkRank\AI;
16
17 use ThinkRank\AI\Traits\Request_Timeout;
18
19 // Prevent direct access
20 if (!defined('ABSPATH')) {
21 exit;
22 }
23
24 require_once __DIR__ . '/traits/trait-request-timeout.php';
25
26 /**
27 * Gemini AI Client Class
28 *
29 * Provides interface to Google Gemini API for SEO optimization,
30 * content analysis, and other AI-powered features.
31 *
32 * @since 1.0.0
33 */
34 class Gemini_Client {
35
36 use Request_Timeout;
37
38
39 /**
40 * API key for Gemini
41 *
42 * @since 1.0.0
43 * @var string
44 */
45 private string $api_key;
46
47 /**
48 * Model to use for requests
49 *
50 * @since 1.0.0
51 * @var string
52 */
53 private string $model;
54
55 /**
56 * Request timeout in seconds
57 *
58 * @since 1.0.0
59 * @var int
60 */
61 private int $timeout;
62
63 /**
64 * Prompt Builder instance
65 *
66 * @since 1.0.0
67 * @var Prompt_Builder|null
68 */
69 private ?Prompt_Builder $prompt_builder = null;
70
71 /**
72 * Constructor
73 *
74 * @param string $api_key Gemini API key
75 * @param string $model Default model to use
76 * @param int $timeout Request timeout
77 */
78 public function __construct(string $api_key, string $model = \ThinkRank\Core\Settings::DEFAULT_GEMINI_MODEL, int $timeout = 30) {
79 $this->api_key = $api_key;
80 $this->model = $model;
81 $this->timeout = $timeout;
82 }
83
84 /**
85 * Get current model
86 *
87 * @since 1.0.0
88 *
89 * @return string Current model name
90 */
91 public function get_model(): string {
92 return $this->model;
93 }
94
95 /**
96 * Get Prompt Builder instance
97 *
98 * @since 1.0.0
99 *
100 * @return Prompt_Builder Prompt Builder instance
101 */
102 private function get_prompt_builder(): Prompt_Builder {
103 if (!$this->prompt_builder) {
104 // Ensure Prompt Builder is loaded
105 if (!class_exists('ThinkRank\\AI\\Prompt_Builder')) {
106 require_once THINKRANK_PLUGIN_DIR . 'includes/ai/class-prompt-builder.php';
107 }
108 $this->prompt_builder = new Prompt_Builder();
109 }
110 return $this->prompt_builder;
111 }
112
113 /**
114 * Generate SEO metadata for content
115 *
116 * @param string $content Content to optimize
117 * @param array $options Generation options
118 * @return array Generated metadata
119 * @throws \Exception If generation fails
120 */
121 public function generate_seo_metadata(string $content, array $options = []): array {
122 $target_keyword = $options['target_keyword'] ?? '';
123 $content_type = $options['content_type'] ?? 'blog_post';
124 $tone = $options['tone'] ?? 'professional';
125
126 $prompt_builder = $this->get_prompt_builder();
127 $language = is_string($options['language'] ?? null) ? $options['language'] : '';
128 $prompt = $prompt_builder->build_seo_prompt($content, $target_keyword, $content_type, $tone, 'gemini', $language);
129
130 $response = $this->generate_completion($prompt, [
131 'max_tokens' => 500,
132 'temperature' => 0.3,
133 ]);
134
135 return $this->parse_seo_response($response);
136 }
137
138 /**
139 * Analyze content for SEO optimization
140 *
141 * @param string $content Content to analyze
142 * @param array $metadata Existing metadata
143 * @return array Analysis results
144 * @throws \Exception If analysis fails
145 */
146 public function analyze_content(string $content, array $metadata = []): array {
147 $prompt_builder = $this->get_prompt_builder();
148 $prompt = $prompt_builder->build_analysis_prompt($content, $metadata, 'gemini');
149
150 $response = $this->generate_completion($prompt, [
151 'max_tokens' => 800,
152 'temperature' => 0.3,
153 ]);
154
155 return $this->parse_analysis_response($response);
156 }
157
158 /**
159 * Optimize site identity
160 *
161 * @since 1.0.0
162 *
163 * @param array $site_data Site data to optimize
164 * @param array $options Optimization options
165 * @return array Optimization results
166 * @throws \Exception If optimization fails
167 */
168 public function optimize_site_identity(array $site_data, array $options = []): array {
169 $business_type = $options['business_type'] ?? 'website';
170 $target_audience = $options['target_audience'] ?? 'general';
171 $tone = $options['tone'] ?? 'professional';
172
173 $prompt_builder = $this->get_prompt_builder();
174 $prompt = $prompt_builder->build_site_identity_prompt($site_data, $business_type, $target_audience, $tone, 'gemini');
175
176 $response = $this->make_request('generateContent', [
177 'contents' => [
178 [
179 'parts' => [
180 ['text' => $prompt]
181 ]
182 ]
183 ],
184 'systemInstruction' => [
185 'parts' => [
186 ['text' => 'You are an expert SEO consultant specializing in site identity optimization. Provide actionable, specific recommendations in JSON format.']
187 ]
188 ],
189 'generationConfig' => $this->build_generation_config([
190 'maxOutputTokens' => 2000, // Increased based on actual usage (1499 tokens used)
191 'temperature' => 0.4,
192 ])
193 ]);
194
195 return $this->parse_site_identity_response($response);
196 }
197
198 /**
199 * Optimize homepage meta content
200 *
201 * @since 1.0.0
202 *
203 * @param array $content_data Meta content data
204 * @param array $options Optimization options
205 * @return array Optimization results
206 * @throws \Exception If optimization fails
207 */
208 public function optimize_homepage_meta(array $content_data, array $options = []): array {
209 $business_type = $options['business_type'] ?? 'website';
210 $target_audience = $options['target_audience'] ?? 'general';
211 $tone = $options['tone'] ?? 'professional';
212 $context = $options['context'] ?? [];
213
214 $prompt_builder = $this->get_prompt_builder();
215 $prompt = $prompt_builder->build_homepage_meta_prompt($content_data, $business_type, $target_audience, $tone, $context, 'gemini');
216
217 $response = $this->make_request('generateContent', [
218 'contents' => [
219 [
220 'parts' => [
221 ['text' => $prompt]
222 ]
223 ]
224 ],
225 'systemInstruction' => [
226 'parts' => [
227 ['text' => 'You are an expert SEO consultant specializing in homepage meta optimization. Provide actionable, specific recommendations in JSON format.']
228 ]
229 ],
230 'generationConfig' => $this->build_generation_config([
231 'maxOutputTokens' => 1200, // Higher limit for Gemini homepage meta
232 'temperature' => 0.4,
233 ])
234 ]);
235
236 return $this->parse_homepage_meta_response($response);
237 }
238
239 /**
240 * Optimize homepage hero content
241 *
242 * @since 1.0.0
243 *
244 * @param array $hero_data Hero content data
245 * @param array $options Optimization options
246 * @return array Optimization results
247 * @throws \Exception If optimization fails
248 */
249 public function optimize_homepage_hero(array $hero_data, array $options = []): array {
250 $business_type = $options['business_type'] ?? 'website';
251 $target_audience = $options['target_audience'] ?? 'general';
252 $tone = $options['tone'] ?? 'professional';
253 $context = $options['context'] ?? [];
254
255 $prompt_builder = $this->get_prompt_builder();
256 $prompt = $prompt_builder->build_homepage_hero_prompt($hero_data, $business_type, $target_audience, $tone, $context, 'gemini');
257
258 $response = $this->make_request('generateContent', [
259 'contents' => [
260 [
261 'parts' => [
262 ['text' => $prompt]
263 ]
264 ]
265 ],
266 'systemInstruction' => [
267 'parts' => [
268 ['text' => 'You are an expert SEO consultant specializing in homepage hero optimization. Provide actionable, specific recommendations in JSON format.']
269 ]
270 ],
271 'generationConfig' => $this->build_generation_config([
272 'maxOutputTokens' => 1200, // Higher limit for Gemini homepage hero
273 'temperature' => 0.4,
274 ])
275 ]);
276
277 return $this->parse_homepage_hero_response($response);
278 }
279
280 /**
281 * Optimize LLMs.txt content
282 *
283 * @since 1.0.0
284 *
285 * @param array $website_data Website data to optimize
286 * @param array $options Optimization options
287 * @return array Optimization results
288 * @throws \Exception If optimization fails
289 */
290 public function optimize_llms_txt(array $website_data, array $options = []): array {
291 // Use shared prompt builder for consistent prompts across all AI providers
292 $prompt_builder = $this->get_prompt_builder();
293 $prompt = $prompt_builder->build_llms_txt_prompt($website_data, $options, 'gemini');
294
295 $response = $this->make_request('generateContent', [
296 'contents' => [
297 [
298 'parts' => [
299 ['text' => $prompt]
300 ]
301 ]
302 ],
303 'generationConfig' => $this->build_generation_config([
304 'maxOutputTokens' => 2000, // Increased for Gemini 2.5 Flash compatibility
305 'temperature' => 0.4,
306 ])
307 ]);
308
309 return $this->parse_llms_txt_response($response);
310 }
311
312 /**
313 * Generate completion using Gemini API
314 *
315 * @param string $prompt Prompt to send
316 * @param array $options Generation options
317 * @return array API response
318 * @throws \Exception If request fails
319 */
320 public function generate_completion(string $prompt, array $options = []): array {
321 $max_tokens = $options['max_tokens'] ?? 1000;
322 $temperature = $options['temperature'] ?? 0.7;
323
324 return $this->make_request('generateContent', [
325 'contents' => [
326 [
327 'parts' => [
328 ['text' => $prompt]
329 ]
330 ]
331 ],
332 'generationConfig' => $this->build_generation_config([
333 'maxOutputTokens' => $max_tokens,
334 'temperature' => $temperature,
335 ])
336 ]);
337 }
338
339 /**
340 * Build the generationConfig for a request, disabling "thinking" on
341 * Gemini 2.5 Flash models.
342 *
343 * Gemini Flash models (2.5 and 3.x) enable an internal "thinking" phase by
344 * default, and those thoughts are billed against maxOutputTokens. On smaller
345 * budgets — especially the free API tier used with the default Flash model —
346 * thinking can consume most of the budget, leaving the visible answer
347 * truncated (finishReason=MAX_TOKENS) with incomplete JSON. Downstream
348 * parsers then fail with "parsing failed". Setting thinkingBudget to 0
349 * disables thinking so the entire budget is spent on the JSON answer.
350 *
351 * Only Flash models accept thinkingBudget=0; Pro models require a minimum
352 * budget and pre-2.5 models reject thinkingConfig outright, so the override
353 * is scoped to 2.5/3.x Flash models to avoid 400 errors. This deliberately
354 * covers the current default (gemini-3.5-flash) as well as legacy
355 * gemini-2.5-flash installs.
356 *
357 * @param array $config Caller-supplied generationConfig
358 * @return array generationConfig with thinking disabled where supported
359 */
360 private function build_generation_config(array $config): array {
361 if (preg_match('/^gemini-(2\.5|3(?:\.\d+)?)-flash/', $this->model) === 1) {
362 $config['thinkingConfig'] = ['thinkingBudget' => 0];
363 }
364
365 if (isset($config['maxOutputTokens'])) {
366 $config['maxOutputTokens'] = min((int) $config['maxOutputTokens'], $this->get_max_output_tokens());
367 }
368
369 return $config;
370 }
371
372 /**
373 * Maximum output tokens the current model accepts
374 *
375 * @since 1.21.0
376 *
377 * @return int Output token ceiling
378 */
379 private function get_max_output_tokens(): int {
380 // Gemini 2.5 and newer allow 64k output; 1.5/2.0 cap at 8k.
381 return preg_match('/^gemini-(2\.5|3)/', $this->model) ? 65536 : 8192;
382 }
383
384 /**
385 * Get recommended token limit for specific use cases
386 *
387 * Mirrors the OpenAI/OpenRouter clients so callers can size a request
388 * without knowing which provider is active. Without this method callers
389 * fall back to a 4000-token budget, which a full content brief overruns
390 * (the reply is then cut off mid-JSON and fails to parse).
391 *
392 * @since 1.21.0
393 *
394 * @param string $use_case Use case (e.g., 'content_brief', 'seo_metadata', 'analysis')
395 * @return int Recommended token limit
396 */
397 public function get_recommended_tokens(string $use_case): int {
398 $recommendations = [
399 // Higher budget: the brief now also returns a full article body,
400 // so the reply is much longer than the structured fields alone.
401 'content_brief' => 16384,
402 'seo_metadata' => 800,
403 'analysis' => 1500,
404 'llms_txt' => 2000,
405 'optimization' => 2000,
406 ];
407
408 return min($recommendations[$use_case] ?? 1000, $this->get_max_output_tokens());
409 }
410
411 /**
412 * Make request to Gemini API
413 *
414 * @param string $endpoint API endpoint
415 * @param array $data Request data
416 * @return array Response data
417 * @throws \Exception If request fails
418 */
419 private function make_request(string $endpoint, array $data): array {
420 // The user's daily ceiling and kill switch are enforced here, at the
421 // one place every outbound Gemini call passes through, so no feature
422 // path can bypass them by forgetting to ask first (#448).
423 Spend_Guard::guard();
424 Spend_Guard::record();
425
426 // Send the API key in the x-goog-api-key header rather than the URL
427 // query string, which is logged by servers, proxies and referrers.
428 $url = "https://generativelanguage.googleapis.com/v1beta/models/{$this->model}:{$endpoint}";
429
430 $response = $this->request_with_retry($url, [
431 'timeout' => $this->timeout,
432 'headers' => [
433 'Content-Type' => 'application/json',
434 'x-goog-api-key' => $this->api_key,
435 ],
436 'method' => 'POST',
437 'body' => wp_json_encode($data),
438 ]);
439
440 if (is_wp_error($response)) {
441 throw new \Exception('Gemini API request failed: ' . esc_html($response->get_error_message()));
442 }
443
444 $status_code = wp_remote_retrieve_response_code($response);
445 $body = wp_remote_retrieve_body($response);
446
447 if ($status_code !== 200) {
448 $error_data = json_decode($body, true);
449 $error_message = $error_data['error']['message'] ?? 'Unknown error';
450 // A 404 almost always means the configured model has been retired or
451 // is not available to this API key. Surface an actionable message
452 // pointing at the model setting instead of the provider's raw error.
453 if ($status_code === 404) {
454 throw new \Exception(sprintf(
455 'The selected Gemini model "%s" is unavailable (404). Choose a different model in ThinkRank → Settings → AI. (Provider message: %s)',
456 esc_html($this->model),
457 esc_html($error_message)
458 ));
459 }
460 throw new \Exception('Gemini API error (' . esc_html($status_code) . '): ' . esc_html($error_message));
461 }
462
463 $decoded = json_decode($body, true);
464 if (json_last_error() !== JSON_ERROR_NONE) {
465 throw new \Exception('Invalid JSON response from Gemini API');
466 }
467
468 // A valid-but-scalar body (null/number/string from a proxy/gateway on a
469 // 2xx) would violate this method's : array return type; reject it here so
470 // it surfaces as a catchable \Exception, not an uncatchable TypeError.
471 if (!is_array($decoded)) {
472 throw new \Exception('Unexpected non-array response from Gemini API');
473 }
474
475 // Debug: Log token usage information
476 if (isset($decoded['usageMetadata'])) {
477 $usage = $decoded['usageMetadata'];
478 $prompt_tokens = $usage['promptTokenCount'] ?? 0;
479 $total_tokens = $usage['totalTokenCount'] ?? 0;
480 $output_tokens = $total_tokens - $prompt_tokens;
481
482
483 }
484
485 return $decoded;
486 }
487
488 /**
489 * Perform an HTTP request, retrying transient failures (429 / 5xx / network)
490 * per the plugin's retry settings, honoring a Retry-After header when given.
491 *
492 * @param string $url Request URL
493 * @param array $args wp_remote_request arguments
494 * @return array|\WP_Error Final response (or last error after retries)
495 */
496 private function request_with_retry(string $url, array $args) {
497 $settings = \ThinkRank\Core\Settings::instance();
498 $retry_enabled = (bool) $settings->get('retry_failed_requests', true);
499 $max_attempts = $retry_enabled ? max(1, (int) $settings->get('retry_attempts', 3)) : 1;
500
501 $response = null;
502 for ($attempt = 1; $attempt <= $max_attempts; $attempt++) {
503 // Keep PHP alive for the whole blocking call (see method docblock).
504 $this->raise_request_time_limit();
505
506 $response = wp_remote_request($url, $args);
507
508 $is_transient = false;
509 $retry_after = 0;
510 if (is_wp_error($response)) {
511 // A client-side timeout means the work genuinely needs longer
512 // than the budget we allowed; re-running the identical prompt,
513 // model and budget just times out again and multiplies the
514 // wait (issue #288). Do not retry a timeout. Other WP_Error
515 // results — DNS, connection refused, TLS — stay retryable.
516 $is_transient = !$this->is_timeout_error($response);
517 } else {
518 $status = wp_remote_retrieve_response_code($response);
519 if (429 === $status || $status >= 500) {
520 $is_transient = true;
521 $retry_after = (int) wp_remote_retrieve_header($response, 'retry-after');
522 }
523 }
524
525 if (!$is_transient || $attempt === $max_attempts) {
526 break;
527 }
528
529 $delay = $retry_after > 0 ? min($retry_after, 30) : min(2 ** ($attempt - 1), 8);
530 sleep($delay);
531 }
532
533 return $response;
534 }
535
536 /**
537 * Give PHP enough execution time to outlive a blocking AI HTTP request.
538 *
539 * The provider call blocks for up to $this->timeout seconds, but the web
540 * SAPI's default max_execution_time (commonly 30s) is shorter — so PHP
541 * fatally terminates the script mid-request (inside the cURL transport),
542 * which the web server surfaces as a 502 Bad Gateway. Resetting the limit
543 * before each attempt keeps the script alive for the full call; PHP-FPM's
544 * request_terminate_timeout still caps the absolute maximum. No-op when
545 * set_time_limit() is disabled (e.g. via disable_functions or safe mode).
546 *
547 * @return void
548 */
549 private function raise_request_time_limit(): void {
550 if (function_exists('set_time_limit')) {
551 @set_time_limit($this->timeout + 45); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- set_time_limit() warns when disabled by host policy; the guard is intentional.
552 }
553 }
554
555 /**
556 * Parse SEO response from Gemini
557 *
558 * @param array $response Gemini response
559 * @return array Parsed metadata
560 * @throws \Exception If parsing fails
561 */
562 private function parse_seo_response(array $response): array {
563
564
565 if (!isset($response['candidates'][0]['content']['parts'][0]['text'])) {
566 throw new \Exception('Invalid response format from Gemini');
567 }
568
569 $content = trim($response['candidates'][0]['content']['parts'][0]['text']);
570 $ai_text = $content; // Store the raw AI-generated text (Content Brief pattern)
571
572 // Extract JSON from response
573 $json_start = strpos($content, '{');
574 $json_end = strrpos($content, '}');
575
576 if (false === $json_start || false === $json_end) {
577 throw new \Exception('No valid JSON found in Gemini response');
578 }
579
580 $json_content = substr($content, $json_start, $json_end - $json_start + 1);
581 $metadata = json_decode($json_content, true);
582
583 if (json_last_error() !== JSON_ERROR_NONE) {
584 throw new \Exception('Invalid JSON in Gemini response: ' . esc_html(json_last_error_msg()));
585 }
586
587 // Validate required fields
588 $required_fields = ['title', 'description', 'focus_keyword'];
589 foreach ($required_fields as $field) {
590 if (!isset($metadata[$field])) {
591 throw new \Exception('Missing required field: ' . esc_html($field));
592 }
593 }
594
595 return [
596 'title' => sanitize_text_field($metadata['title']),
597 'description' => sanitize_textarea_field($metadata['description']),
598 'focus_keyword' => sanitize_text_field($metadata['focus_keyword']),
599 'suggestions' => array_map('sanitize_text_field', $metadata['suggestions'] ?? []),
600 'generated_at' => current_time('mysql'),
601 'provider' => 'gemini',
602 'model' => $this->model,
603 'tokens_used' => $response['usageMetadata']['totalTokenCount'] ?? 0,
604 '_ai_text' => $ai_text, // Store the raw AI-generated text (Content Brief pattern)
605 ];
606 }
607
608 /**
609 * Parse analysis response from Gemini
610 *
611 * @param array $response Gemini response
612 * @return array Parsed analysis
613 * @throws \Exception If parsing fails
614 */
615 private function parse_analysis_response(array $response): array {
616 if (!isset($response['candidates'][0]['content']['parts'][0]['text'])) {
617 throw new \Exception('Invalid response format from Gemini');
618 }
619
620 $content = trim($response['candidates'][0]['content']['parts'][0]['text']);
621 $ai_text = $content; // Store the raw AI-generated text (Content Brief pattern)
622
623 // Extract JSON from response
624 $json_start = strpos($content, '{');
625 $json_end = strrpos($content, '}');
626
627 if (false === $json_start || false === $json_end) {
628 throw new \Exception('No valid JSON found in Gemini response');
629 }
630
631 $json_content = substr($content, $json_start, $json_end - $json_start + 1);
632 $analysis = json_decode($json_content, true);
633
634 if (json_last_error() !== JSON_ERROR_NONE) {
635 throw new \Exception('Invalid JSON in Gemini response: ' . esc_html(json_last_error_msg()));
636 }
637
638 return [
639 'seo_score' => absint($analysis['seo_score'] ?? 0),
640 'content_analysis' => $analysis['content_analysis'] ?? [],
641 'suggestions' => array_map('sanitize_text_field', $analysis['suggestions'] ?? []),
642 'strengths' => array_map('sanitize_text_field', $analysis['strengths'] ?? []),
643 'weaknesses' => array_map('sanitize_text_field', $analysis['weaknesses'] ?? []),
644 'generated_at' => current_time('mysql'),
645 'provider' => 'gemini',
646 'model' => $this->model,
647 'tokens_used' => $response['usageMetadata']['totalTokenCount'] ?? 0,
648 '_ai_text' => $ai_text, // Store the raw AI-generated text (Content Brief pattern)
649 ];
650 }
651
652 /**
653 * Parse site identity optimization response
654 *
655 * @param array $response Gemini API response
656 * @return array Parsed optimization data
657 * @throws \Exception If parsing fails
658 */
659 private function parse_site_identity_response(array $response): array {
660 if (!isset($response['candidates'][0]['content']['parts'][0]['text'])) {
661 // Check if content was blocked by safety filters
662 if (isset($response['candidates'][0]['finishReason']) && $response['candidates'][0]['finishReason'] === 'SAFETY') {
663 throw new \Exception('Content was blocked by Gemini safety filters. Please try rephrasing your request.');
664 }
665 // Check if response was truncated due to token limit
666 if (isset($response['candidates'][0]['finishReason']) && $response['candidates'][0]['finishReason'] === 'MAX_TOKENS') {
667 throw new \Exception('Gemini response was truncated due to token limit. Please try a shorter request or increase token limit.');
668 }
669 // Check if there are no candidates
670 if (!isset($response['candidates']) || empty($response['candidates'])) {
671 throw new \Exception('No response candidates from Gemini. The request may have been filtered.');
672 }
673 // Check if content exists but parts are missing
674 if (isset($response['candidates'][0]['content']) && !isset($response['candidates'][0]['content']['parts'])) {
675 throw new \Exception('Gemini response missing content parts. The response may be incomplete.');
676 }
677 throw new \Exception('Invalid response format from Gemini');
678 }
679
680 $content = trim($response['candidates'][0]['content']['parts'][0]['text']);
681 $ai_text = $content; // Store the raw AI-generated text (Content Brief pattern)
682
683 // Extract JSON from response
684 $json_start = strpos($content, '{');
685 $json_end = strrpos($content, '}');
686
687 if (false === $json_start || false === $json_end) {
688 throw new \Exception('No valid JSON found in Gemini response');
689 }
690
691 $json_content = substr($content, $json_start, $json_end - $json_start + 1);
692 $data = json_decode($json_content, true);
693
694 if (json_last_error() !== JSON_ERROR_NONE) {
695 throw new \Exception('Invalid JSON in Gemini response: ' . esc_html(json_last_error_msg()));
696 }
697
698 return [
699 'optimized_data' => $data['optimized_data'] ?? [],
700 'analysis' => sanitize_textarea_field($data['analysis'] ?? ''),
701 'suggestions' => array_map('sanitize_text_field', $data['suggestions'] ?? []),
702 'score' => absint($data['score'] ?? 0),
703 'generated_at' => current_time('mysql'),
704 'provider' => 'gemini',
705 'model' => $this->model,
706 'tokens_used' => $response['usageMetadata']['totalTokenCount'] ?? 0,
707 '_ai_text' => $ai_text, // Store the raw AI-generated text (Content Brief pattern)
708 ];
709 }
710
711 /**
712 * Parse homepage meta optimization response
713 *
714 * @param array $response Gemini API response
715 * @return array Parsed optimization data
716 * @throws \Exception If parsing fails
717 */
718 private function parse_homepage_meta_response(array $response): array {
719 if (!isset($response['candidates'][0]['content']['parts'][0]['text'])) {
720 // Check if content was blocked by safety filters
721 if (isset($response['candidates'][0]['finishReason']) && $response['candidates'][0]['finishReason'] === 'SAFETY') {
722 throw new \Exception('Content was blocked by Gemini safety filters. Please try rephrasing your request.');
723 }
724 // Check if response was truncated due to token limit
725 if (isset($response['candidates'][0]['finishReason']) && $response['candidates'][0]['finishReason'] === 'MAX_TOKENS') {
726 throw new \Exception('Gemini response was truncated due to token limit. Please try a shorter request or increase token limit.');
727 }
728 // Check if there are no candidates
729 if (!isset($response['candidates']) || empty($response['candidates'])) {
730 throw new \Exception('No response candidates from Gemini. The request may have been filtered.');
731 }
732 // Check if content exists but parts are missing
733 if (isset($response['candidates'][0]['content']) && !isset($response['candidates'][0]['content']['parts'])) {
734 throw new \Exception('Gemini response missing content parts. The response may be incomplete.');
735 }
736 throw new \Exception('Invalid response format from Gemini');
737 }
738
739 $content = trim($response['candidates'][0]['content']['parts'][0]['text']);
740 $ai_text = $content; // Store the raw AI-generated text (Content Brief pattern)
741
742 // Extract JSON from response
743 $json_start = strpos($content, '{');
744 $json_end = strrpos($content, '}');
745
746 if (false === $json_start || false === $json_end) {
747 throw new \Exception('No valid JSON found in Gemini response');
748 }
749
750 $json_content = substr($content, $json_start, $json_end - $json_start + 1);
751 $data = json_decode($json_content, true);
752
753 if (json_last_error() !== JSON_ERROR_NONE) {
754 throw new \Exception('Invalid JSON in Gemini response: ' . esc_html(json_last_error_msg()));
755 }
756
757 return [
758 'optimized_data' => $data['optimized_data'] ?? [],
759 'analysis' => sanitize_textarea_field($data['analysis'] ?? ''),
760 'suggestions' => array_map('sanitize_text_field', $data['suggestions'] ?? []),
761 'score' => absint($data['score'] ?? 0),
762 'generated_at' => current_time('mysql'),
763 'provider' => 'gemini',
764 'model' => $this->model,
765 'tokens_used' => $response['usageMetadata']['totalTokenCount'] ?? 0,
766 '_ai_text' => $ai_text, // Store the raw AI-generated text (Content Brief pattern)
767 ];
768 }
769
770 /**
771 * Parse homepage hero optimization response
772 *
773 * @param array $response Gemini API response
774 * @return array Parsed optimization data
775 * @throws \Exception If parsing fails
776 */
777 private function parse_homepage_hero_response(array $response): array {
778 if (!isset($response['candidates'][0]['content']['parts'][0]['text'])) {
779 // Check if content was blocked by safety filters
780 if (isset($response['candidates'][0]['finishReason']) && $response['candidates'][0]['finishReason'] === 'SAFETY') {
781 throw new \Exception('Content was blocked by Gemini safety filters. Please try rephrasing your request.');
782 }
783 // Check if response was truncated due to token limit
784 if (isset($response['candidates'][0]['finishReason']) && $response['candidates'][0]['finishReason'] === 'MAX_TOKENS') {
785 throw new \Exception('Gemini response was truncated due to token limit. Please try a shorter request or increase token limit.');
786 }
787 // Check if there are no candidates
788 if (!isset($response['candidates']) || empty($response['candidates'])) {
789 throw new \Exception('No response candidates from Gemini. The request may have been filtered.');
790 }
791 // Check if content exists but parts are missing
792 if (isset($response['candidates'][0]['content']) && !isset($response['candidates'][0]['content']['parts'])) {
793 throw new \Exception('Gemini response missing content parts. The response may be incomplete.');
794 }
795 throw new \Exception('Invalid response format from Gemini');
796 }
797
798 $content = trim($response['candidates'][0]['content']['parts'][0]['text']);
799 $ai_text = $content; // Store the raw AI-generated text (Content Brief pattern)
800
801 // Extract JSON from response
802 $json_start = strpos($content, '{');
803 $json_end = strrpos($content, '}');
804
805 if (false === $json_start || false === $json_end) {
806 throw new \Exception('No valid JSON found in Gemini response');
807 }
808
809 $json_content = substr($content, $json_start, $json_end - $json_start + 1);
810 $data = json_decode($json_content, true);
811
812 if (json_last_error() !== JSON_ERROR_NONE) {
813 throw new \Exception('Invalid JSON in Gemini response: ' . esc_html(json_last_error_msg()));
814 }
815
816 return [
817 'optimized_data' => $data['optimized_data'] ?? [],
818 'analysis' => sanitize_textarea_field($data['analysis'] ?? ''),
819 'suggestions' => array_map('sanitize_text_field', $data['suggestions'] ?? []),
820 'score' => absint($data['score'] ?? 0),
821 'generated_at' => current_time('mysql'),
822 'provider' => 'gemini',
823 'model' => $this->model,
824 'tokens_used' => $response['usageMetadata']['totalTokenCount'] ?? 0,
825 '_ai_text' => $ai_text, // Store the raw AI-generated text (Content Brief pattern)
826 ];
827 }
828
829 /**
830 * Parse LLMs.txt optimization response
831 *
832 * @param array $response Gemini API response
833 * @return array Parsed optimization data
834 * @throws \Exception If parsing fails
835 */
836 private function parse_llms_txt_response(array $response): array {
837 if (!isset($response['candidates'][0]['content']['parts'][0]['text'])) {
838 // Check if content was blocked by safety filters
839 if (isset($response['candidates'][0]['finishReason']) && $response['candidates'][0]['finishReason'] === 'SAFETY') {
840 throw new \Exception('Content was blocked by Gemini safety filters. Please try rephrasing your request.');
841 }
842 // Check if response was truncated due to token limit
843 if (isset($response['candidates'][0]['finishReason']) && $response['candidates'][0]['finishReason'] === 'MAX_TOKENS') {
844 throw new \Exception('Gemini response was truncated due to token limit. Please try a shorter request or increase token limit.');
845 }
846 // Check if there are no candidates
847 if (!isset($response['candidates']) || empty($response['candidates'])) {
848 throw new \Exception('No response candidates from Gemini. The request may have been filtered.');
849 }
850 // Check if content exists but parts are missing
851 if (isset($response['candidates'][0]['content']) && !isset($response['candidates'][0]['content']['parts'])) {
852 throw new \Exception('Gemini response missing content parts. The response may be incomplete.');
853 }
854 throw new \Exception('Invalid response format from Gemini');
855 }
856
857 $content = trim($response['candidates'][0]['content']['parts'][0]['text']);
858 $ai_text = $content; // Store the raw AI-generated text (Content Brief pattern)
859
860 // Extract JSON from response
861 $json_start = strpos($content, '{');
862 $json_end = strrpos($content, '}');
863
864 if (false === $json_start || false === $json_end) {
865 throw new \Exception('No valid JSON found in Gemini response');
866 }
867
868 $json_content = substr($content, $json_start, $json_end - $json_start + 1);
869 $data = json_decode($json_content, true);
870
871 if (json_last_error() !== JSON_ERROR_NONE) {
872 throw new \Exception('Invalid JSON in Gemini response: ' . esc_html(json_last_error_msg()));
873 }
874
875 // Match the structure expected by AI Manager (same as OpenAI/Claude)
876 return [
877 'optimized_data' => [
878 'site_name' => sanitize_text_field($data['optimized_data']['site_name'] ?? ''),
879 'project_overview' => sanitize_textarea_field($data['optimized_data']['project_overview'] ?? ''),
880 'key_features' => \ThinkRank\SEO\LLMs_Txt_Manager::normalize_ai_key_features($data['optimized_data']['key_features'] ?? ''),
881 'architecture' => sanitize_textarea_field($data['optimized_data']['architecture'] ?? ''),
882 'development_guidelines' => sanitize_textarea_field($data['optimized_data']['development_guidelines'] ?? ''),
883 'ai_context' => sanitize_textarea_field($data['optimized_data']['ai_context'] ?? ''),
884 ],
885 'suggestions' => array_map('sanitize_text_field', $data['suggestions'] ?? []),
886 'generated_at' => current_time('mysql'),
887 'provider' => 'gemini',
888 'model' => $this->model,
889 'tokens_used' => $response['usageMetadata']['totalTokenCount'] ?? 0,
890 '_ai_text' => $ai_text, // Store the raw AI-generated text (Content Brief pattern)
891 ];
892 }
893 }
894