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

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