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

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