PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 1.28.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v1.28.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 1.0.2 1.1.0 1.10.0 All 48 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 1.28.0, at includes/ai/class-gemini-client.php

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