PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 1.27.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v1.27.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.27.0, at includes/ai/class-gemini-client.php

865 lines 34.7 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 = 'gemini-2.5-flash', 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 2.5 Flash / Flash-Lite 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
339 * gemini-2.5-flash model — thinking can consume most of the budget, leaving
340 * the visible answer truncated (finishReason=MAX_TOKENS) with incomplete
341 * JSON. Downstream parsers then fail with "parsing failed". Setting
342 * thinkingBudget to 0 disables thinking so the entire budget is spent on the
343 * JSON answer.
344 *
345 * Only 2.5 Flash models accept thinkingBudget=0; 2.5 Pro requires a minimum
346 * budget and pre-2.5 models reject thinkingConfig outright, so the override
347 * is scoped to Flash models to avoid 400 errors.
348 *
349 * @param array $config Caller-supplied generationConfig
350 * @return array generationConfig with thinking disabled where supported
351 */
352 private function build_generation_config(array $config): array {
353 if (strpos($this->model, 'gemini-2.5-flash') === 0) {
354 $config['thinkingConfig'] = ['thinkingBudget' => 0];
355 }
356
357 if (isset($config['maxOutputTokens'])) {
358 $config['maxOutputTokens'] = min((int) $config['maxOutputTokens'], $this->get_max_output_tokens());
359 }
360
361 return $config;
362 }
363
364 /**
365 * Maximum output tokens the current model accepts
366 *
367 * @since 1.21.0
368 *
369 * @return int Output token ceiling
370 */
371 private function get_max_output_tokens(): int {
372 // Gemini 2.5 and newer allow 64k output; 1.5/2.0 cap at 8k.
373 return preg_match('/^gemini-(2\.5|3)/', $this->model) ? 65536 : 8192;
374 }
375
376 /**
377 * Get recommended token limit for specific use cases
378 *
379 * Mirrors the OpenAI/OpenRouter clients so callers can size a request
380 * without knowing which provider is active. Without this method callers
381 * fall back to a 4000-token budget, which a full content brief overruns
382 * (the reply is then cut off mid-JSON and fails to parse).
383 *
384 * @since 1.21.0
385 *
386 * @param string $use_case Use case (e.g., 'content_brief', 'seo_metadata', 'analysis')
387 * @return int Recommended token limit
388 */
389 public function get_recommended_tokens(string $use_case): int {
390 $recommendations = [
391 // Higher budget: the brief now also returns a full article body,
392 // so the reply is much longer than the structured fields alone.
393 'content_brief' => 16384,
394 'seo_metadata' => 800,
395 'analysis' => 1500,
396 'llms_txt' => 2000,
397 'optimization' => 2000,
398 ];
399
400 return min($recommendations[$use_case] ?? 1000, $this->get_max_output_tokens());
401 }
402
403 /**
404 * Make request to Gemini API
405 *
406 * @param string $endpoint API endpoint
407 * @param array $data Request data
408 * @return array Response data
409 * @throws \Exception If request fails
410 */
411 private function make_request(string $endpoint, array $data): array {
412 // Send the API key in the x-goog-api-key header rather than the URL
413 // query string, which is logged by servers, proxies and referrers.
414 $url = "https://generativelanguage.googleapis.com/v1beta/models/{$this->model}:{$endpoint}";
415
416 $response = $this->request_with_retry($url, [
417 'timeout' => $this->timeout,
418 'headers' => [
419 'Content-Type' => 'application/json',
420 'x-goog-api-key' => $this->api_key,
421 ],
422 'method' => 'POST',
423 'body' => wp_json_encode($data),
424 ]);
425
426 if (is_wp_error($response)) {
427 throw new \Exception('Gemini API request failed: ' . esc_html($response->get_error_message()));
428 }
429
430 $status_code = wp_remote_retrieve_response_code($response);
431 $body = wp_remote_retrieve_body($response);
432
433 if ($status_code !== 200) {
434 $error_data = json_decode($body, true);
435 $error_message = $error_data['error']['message'] ?? 'Unknown error';
436 throw new \Exception('Gemini API error (' . esc_html($status_code) . '): ' . esc_html($error_message));
437 }
438
439 $decoded = json_decode($body, true);
440 if (json_last_error() !== JSON_ERROR_NONE) {
441 throw new \Exception('Invalid JSON response from Gemini API');
442 }
443
444 // A valid-but-scalar body (null/number/string from a proxy/gateway on a
445 // 2xx) would violate this method's : array return type; reject it here so
446 // it surfaces as a catchable \Exception, not an uncatchable TypeError.
447 if (!is_array($decoded)) {
448 throw new \Exception('Unexpected non-array response from Gemini API');
449 }
450
451 // Debug: Log token usage information
452 if (isset($decoded['usageMetadata'])) {
453 $usage = $decoded['usageMetadata'];
454 $prompt_tokens = $usage['promptTokenCount'] ?? 0;
455 $total_tokens = $usage['totalTokenCount'] ?? 0;
456 $output_tokens = $total_tokens - $prompt_tokens;
457
458
459 }
460
461 return $decoded;
462 }
463
464 /**
465 * Perform an HTTP request, retrying transient failures (429 / 5xx / network)
466 * per the plugin's retry settings, honoring a Retry-After header when given.
467 *
468 * @param string $url Request URL
469 * @param array $args wp_remote_request arguments
470 * @return array|\WP_Error Final response (or last error after retries)
471 */
472 private function request_with_retry(string $url, array $args) {
473 $settings = \ThinkRank\Core\Settings::instance();
474 $retry_enabled = (bool) $settings->get('retry_failed_requests', true);
475 $max_attempts = $retry_enabled ? max(1, (int) $settings->get('retry_attempts', 3)) : 1;
476
477 $response = null;
478 for ($attempt = 1; $attempt <= $max_attempts; $attempt++) {
479 // Keep PHP alive for the whole blocking call (see method docblock).
480 $this->raise_request_time_limit();
481
482 $response = wp_remote_request($url, $args);
483
484 $is_transient = false;
485 $retry_after = 0;
486 if (is_wp_error($response)) {
487 $is_transient = true;
488 } else {
489 $status = wp_remote_retrieve_response_code($response);
490 if (429 === $status || $status >= 500) {
491 $is_transient = true;
492 $retry_after = (int) wp_remote_retrieve_header($response, 'retry-after');
493 }
494 }
495
496 if (!$is_transient || $attempt === $max_attempts) {
497 break;
498 }
499
500 $delay = $retry_after > 0 ? min($retry_after, 30) : min(2 ** ($attempt - 1), 8);
501 sleep($delay);
502 }
503
504 return $response;
505 }
506
507 /**
508 * Give PHP enough execution time to outlive a blocking AI HTTP request.
509 *
510 * The provider call blocks for up to $this->timeout seconds, but the web
511 * SAPI's default max_execution_time (commonly 30s) is shorter — so PHP
512 * fatally terminates the script mid-request (inside the cURL transport),
513 * which the web server surfaces as a 502 Bad Gateway. Resetting the limit
514 * before each attempt keeps the script alive for the full call; PHP-FPM's
515 * request_terminate_timeout still caps the absolute maximum. No-op when
516 * set_time_limit() is disabled (e.g. via disable_functions or safe mode).
517 *
518 * @return void
519 */
520 private function raise_request_time_limit(): void {
521 if (function_exists('set_time_limit')) {
522 @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.
523 }
524 }
525
526 /**
527 * Parse SEO response from Gemini
528 *
529 * @param array $response Gemini response
530 * @return array Parsed metadata
531 * @throws \Exception If parsing fails
532 */
533 private function parse_seo_response(array $response): array {
534
535
536 if (!isset($response['candidates'][0]['content']['parts'][0]['text'])) {
537 throw new \Exception('Invalid response format from Gemini');
538 }
539
540 $content = trim($response['candidates'][0]['content']['parts'][0]['text']);
541 $ai_text = $content; // Store the raw AI-generated text (Content Brief pattern)
542
543 // Extract JSON from response
544 $json_start = strpos($content, '{');
545 $json_end = strrpos($content, '}');
546
547 if (false === $json_start || false === $json_end) {
548 throw new \Exception('No valid JSON found in Gemini response');
549 }
550
551 $json_content = substr($content, $json_start, $json_end - $json_start + 1);
552 $metadata = json_decode($json_content, true);
553
554 if (json_last_error() !== JSON_ERROR_NONE) {
555 throw new \Exception('Invalid JSON in Gemini response: ' . esc_html(json_last_error_msg()));
556 }
557
558 // Validate required fields
559 $required_fields = ['title', 'description', 'focus_keyword'];
560 foreach ($required_fields as $field) {
561 if (!isset($metadata[$field])) {
562 throw new \Exception('Missing required field: ' . esc_html($field));
563 }
564 }
565
566 return [
567 'title' => sanitize_text_field($metadata['title']),
568 'description' => sanitize_textarea_field($metadata['description']),
569 'focus_keyword' => sanitize_text_field($metadata['focus_keyword']),
570 'suggestions' => array_map('sanitize_text_field', $metadata['suggestions'] ?? []),
571 'generated_at' => current_time('mysql'),
572 'provider' => 'gemini',
573 'model' => $this->model,
574 'tokens_used' => $response['usageMetadata']['totalTokenCount'] ?? 0,
575 '_ai_text' => $ai_text, // Store the raw AI-generated text (Content Brief pattern)
576 ];
577 }
578
579 /**
580 * Parse analysis response from Gemini
581 *
582 * @param array $response Gemini response
583 * @return array Parsed analysis
584 * @throws \Exception If parsing fails
585 */
586 private function parse_analysis_response(array $response): array {
587 if (!isset($response['candidates'][0]['content']['parts'][0]['text'])) {
588 throw new \Exception('Invalid response format from Gemini');
589 }
590
591 $content = trim($response['candidates'][0]['content']['parts'][0]['text']);
592 $ai_text = $content; // Store the raw AI-generated text (Content Brief pattern)
593
594 // Extract JSON from response
595 $json_start = strpos($content, '{');
596 $json_end = strrpos($content, '}');
597
598 if (false === $json_start || false === $json_end) {
599 throw new \Exception('No valid JSON found in Gemini response');
600 }
601
602 $json_content = substr($content, $json_start, $json_end - $json_start + 1);
603 $analysis = json_decode($json_content, true);
604
605 if (json_last_error() !== JSON_ERROR_NONE) {
606 throw new \Exception('Invalid JSON in Gemini response: ' . esc_html(json_last_error_msg()));
607 }
608
609 return [
610 'seo_score' => absint($analysis['seo_score'] ?? 0),
611 'content_analysis' => $analysis['content_analysis'] ?? [],
612 'suggestions' => array_map('sanitize_text_field', $analysis['suggestions'] ?? []),
613 'strengths' => array_map('sanitize_text_field', $analysis['strengths'] ?? []),
614 'weaknesses' => array_map('sanitize_text_field', $analysis['weaknesses'] ?? []),
615 'generated_at' => current_time('mysql'),
616 'provider' => 'gemini',
617 'model' => $this->model,
618 'tokens_used' => $response['usageMetadata']['totalTokenCount'] ?? 0,
619 '_ai_text' => $ai_text, // Store the raw AI-generated text (Content Brief pattern)
620 ];
621 }
622
623 /**
624 * Parse site identity optimization response
625 *
626 * @param array $response Gemini API response
627 * @return array Parsed optimization data
628 * @throws \Exception If parsing fails
629 */
630 private function parse_site_identity_response(array $response): array {
631 if (!isset($response['candidates'][0]['content']['parts'][0]['text'])) {
632 // Check if content was blocked by safety filters
633 if (isset($response['candidates'][0]['finishReason']) && $response['candidates'][0]['finishReason'] === 'SAFETY') {
634 throw new \Exception('Content was blocked by Gemini safety filters. Please try rephrasing your request.');
635 }
636 // Check if response was truncated due to token limit
637 if (isset($response['candidates'][0]['finishReason']) && $response['candidates'][0]['finishReason'] === 'MAX_TOKENS') {
638 throw new \Exception('Gemini response was truncated due to token limit. Please try a shorter request or increase token limit.');
639 }
640 // Check if there are no candidates
641 if (!isset($response['candidates']) || empty($response['candidates'])) {
642 throw new \Exception('No response candidates from Gemini. The request may have been filtered.');
643 }
644 // Check if content exists but parts are missing
645 if (isset($response['candidates'][0]['content']) && !isset($response['candidates'][0]['content']['parts'])) {
646 throw new \Exception('Gemini response missing content parts. The response may be incomplete.');
647 }
648 throw new \Exception('Invalid response format from Gemini');
649 }
650
651 $content = trim($response['candidates'][0]['content']['parts'][0]['text']);
652 $ai_text = $content; // Store the raw AI-generated text (Content Brief pattern)
653
654 // Extract JSON from response
655 $json_start = strpos($content, '{');
656 $json_end = strrpos($content, '}');
657
658 if (false === $json_start || false === $json_end) {
659 throw new \Exception('No valid JSON found in Gemini response');
660 }
661
662 $json_content = substr($content, $json_start, $json_end - $json_start + 1);
663 $data = json_decode($json_content, true);
664
665 if (json_last_error() !== JSON_ERROR_NONE) {
666 throw new \Exception('Invalid JSON in Gemini response: ' . esc_html(json_last_error_msg()));
667 }
668
669 return [
670 'optimized_data' => $data['optimized_data'] ?? [],
671 'analysis' => sanitize_textarea_field($data['analysis'] ?? ''),
672 'suggestions' => array_map('sanitize_text_field', $data['suggestions'] ?? []),
673 'score' => absint($data['score'] ?? 0),
674 'generated_at' => current_time('mysql'),
675 'provider' => 'gemini',
676 'model' => $this->model,
677 'tokens_used' => $response['usageMetadata']['totalTokenCount'] ?? 0,
678 '_ai_text' => $ai_text, // Store the raw AI-generated text (Content Brief pattern)
679 ];
680 }
681
682 /**
683 * Parse homepage meta optimization response
684 *
685 * @param array $response Gemini API response
686 * @return array Parsed optimization data
687 * @throws \Exception If parsing fails
688 */
689 private function parse_homepage_meta_response(array $response): array {
690 if (!isset($response['candidates'][0]['content']['parts'][0]['text'])) {
691 // Check if content was blocked by safety filters
692 if (isset($response['candidates'][0]['finishReason']) && $response['candidates'][0]['finishReason'] === 'SAFETY') {
693 throw new \Exception('Content was blocked by Gemini safety filters. Please try rephrasing your request.');
694 }
695 // Check if response was truncated due to token limit
696 if (isset($response['candidates'][0]['finishReason']) && $response['candidates'][0]['finishReason'] === 'MAX_TOKENS') {
697 throw new \Exception('Gemini response was truncated due to token limit. Please try a shorter request or increase token limit.');
698 }
699 // Check if there are no candidates
700 if (!isset($response['candidates']) || empty($response['candidates'])) {
701 throw new \Exception('No response candidates from Gemini. The request may have been filtered.');
702 }
703 // Check if content exists but parts are missing
704 if (isset($response['candidates'][0]['content']) && !isset($response['candidates'][0]['content']['parts'])) {
705 throw new \Exception('Gemini response missing content parts. The response may be incomplete.');
706 }
707 throw new \Exception('Invalid response format from Gemini');
708 }
709
710 $content = trim($response['candidates'][0]['content']['parts'][0]['text']);
711 $ai_text = $content; // Store the raw AI-generated text (Content Brief pattern)
712
713 // Extract JSON from response
714 $json_start = strpos($content, '{');
715 $json_end = strrpos($content, '}');
716
717 if (false === $json_start || false === $json_end) {
718 throw new \Exception('No valid JSON found in Gemini response');
719 }
720
721 $json_content = substr($content, $json_start, $json_end - $json_start + 1);
722 $data = json_decode($json_content, true);
723
724 if (json_last_error() !== JSON_ERROR_NONE) {
725 throw new \Exception('Invalid JSON in Gemini response: ' . esc_html(json_last_error_msg()));
726 }
727
728 return [
729 'optimized_data' => $data['optimized_data'] ?? [],
730 'analysis' => sanitize_textarea_field($data['analysis'] ?? ''),
731 'suggestions' => array_map('sanitize_text_field', $data['suggestions'] ?? []),
732 'score' => absint($data['score'] ?? 0),
733 'generated_at' => current_time('mysql'),
734 'provider' => 'gemini',
735 'model' => $this->model,
736 'tokens_used' => $response['usageMetadata']['totalTokenCount'] ?? 0,
737 '_ai_text' => $ai_text, // Store the raw AI-generated text (Content Brief pattern)
738 ];
739 }
740
741 /**
742 * Parse homepage hero optimization response
743 *
744 * @param array $response Gemini API response
745 * @return array Parsed optimization data
746 * @throws \Exception If parsing fails
747 */
748 private function parse_homepage_hero_response(array $response): array {
749 if (!isset($response['candidates'][0]['content']['parts'][0]['text'])) {
750 // Check if content was blocked by safety filters
751 if (isset($response['candidates'][0]['finishReason']) && $response['candidates'][0]['finishReason'] === 'SAFETY') {
752 throw new \Exception('Content was blocked by Gemini safety filters. Please try rephrasing your request.');
753 }
754 // Check if response was truncated due to token limit
755 if (isset($response['candidates'][0]['finishReason']) && $response['candidates'][0]['finishReason'] === 'MAX_TOKENS') {
756 throw new \Exception('Gemini response was truncated due to token limit. Please try a shorter request or increase token limit.');
757 }
758 // Check if there are no candidates
759 if (!isset($response['candidates']) || empty($response['candidates'])) {
760 throw new \Exception('No response candidates from Gemini. The request may have been filtered.');
761 }
762 // Check if content exists but parts are missing
763 if (isset($response['candidates'][0]['content']) && !isset($response['candidates'][0]['content']['parts'])) {
764 throw new \Exception('Gemini response missing content parts. The response may be incomplete.');
765 }
766 throw new \Exception('Invalid response format from Gemini');
767 }
768
769 $content = trim($response['candidates'][0]['content']['parts'][0]['text']);
770 $ai_text = $content; // Store the raw AI-generated text (Content Brief pattern)
771
772 // Extract JSON from response
773 $json_start = strpos($content, '{');
774 $json_end = strrpos($content, '}');
775
776 if (false === $json_start || false === $json_end) {
777 throw new \Exception('No valid JSON found in Gemini response');
778 }
779
780 $json_content = substr($content, $json_start, $json_end - $json_start + 1);
781 $data = json_decode($json_content, true);
782
783 if (json_last_error() !== JSON_ERROR_NONE) {
784 throw new \Exception('Invalid JSON in Gemini response: ' . esc_html(json_last_error_msg()));
785 }
786
787 return [
788 'optimized_data' => $data['optimized_data'] ?? [],
789 'analysis' => sanitize_textarea_field($data['analysis'] ?? ''),
790 'suggestions' => array_map('sanitize_text_field', $data['suggestions'] ?? []),
791 'score' => absint($data['score'] ?? 0),
792 'generated_at' => current_time('mysql'),
793 'provider' => 'gemini',
794 'model' => $this->model,
795 'tokens_used' => $response['usageMetadata']['totalTokenCount'] ?? 0,
796 '_ai_text' => $ai_text, // Store the raw AI-generated text (Content Brief pattern)
797 ];
798 }
799
800 /**
801 * Parse LLMs.txt optimization response
802 *
803 * @param array $response Gemini API response
804 * @return array Parsed optimization data
805 * @throws \Exception If parsing fails
806 */
807 private function parse_llms_txt_response(array $response): array {
808 if (!isset($response['candidates'][0]['content']['parts'][0]['text'])) {
809 // Check if content was blocked by safety filters
810 if (isset($response['candidates'][0]['finishReason']) && $response['candidates'][0]['finishReason'] === 'SAFETY') {
811 throw new \Exception('Content was blocked by Gemini safety filters. Please try rephrasing your request.');
812 }
813 // Check if response was truncated due to token limit
814 if (isset($response['candidates'][0]['finishReason']) && $response['candidates'][0]['finishReason'] === 'MAX_TOKENS') {
815 throw new \Exception('Gemini response was truncated due to token limit. Please try a shorter request or increase token limit.');
816 }
817 // Check if there are no candidates
818 if (!isset($response['candidates']) || empty($response['candidates'])) {
819 throw new \Exception('No response candidates from Gemini. The request may have been filtered.');
820 }
821 // Check if content exists but parts are missing
822 if (isset($response['candidates'][0]['content']) && !isset($response['candidates'][0]['content']['parts'])) {
823 throw new \Exception('Gemini response missing content parts. The response may be incomplete.');
824 }
825 throw new \Exception('Invalid response format from Gemini');
826 }
827
828 $content = trim($response['candidates'][0]['content']['parts'][0]['text']);
829 $ai_text = $content; // Store the raw AI-generated text (Content Brief pattern)
830
831 // Extract JSON from response
832 $json_start = strpos($content, '{');
833 $json_end = strrpos($content, '}');
834
835 if (false === $json_start || false === $json_end) {
836 throw new \Exception('No valid JSON found in Gemini response');
837 }
838
839 $json_content = substr($content, $json_start, $json_end - $json_start + 1);
840 $data = json_decode($json_content, true);
841
842 if (json_last_error() !== JSON_ERROR_NONE) {
843 throw new \Exception('Invalid JSON in Gemini response: ' . esc_html(json_last_error_msg()));
844 }
845
846 // Match the structure expected by AI Manager (same as OpenAI/Claude)
847 return [
848 'optimized_data' => [
849 'site_name' => sanitize_text_field($data['optimized_data']['site_name'] ?? ''),
850 'project_overview' => sanitize_textarea_field($data['optimized_data']['project_overview'] ?? ''),
851 'key_features' => sanitize_textarea_field($data['optimized_data']['key_features'] ?? ''),
852 'architecture' => sanitize_textarea_field($data['optimized_data']['architecture'] ?? ''),
853 'development_guidelines' => sanitize_textarea_field($data['optimized_data']['development_guidelines'] ?? ''),
854 'ai_context' => sanitize_textarea_field($data['optimized_data']['ai_context'] ?? ''),
855 ],
856 'suggestions' => array_map('sanitize_text_field', $data['suggestions'] ?? []),
857 'generated_at' => current_time('mysql'),
858 'provider' => 'gemini',
859 'model' => $this->model,
860 'tokens_used' => $response['usageMetadata']['totalTokenCount'] ?? 0,
861 '_ai_text' => $ai_text, // Store the raw AI-generated text (Content Brief pattern)
862 ];
863 }
864 }
865