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

717 lines 28.4 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 /**
18 * Gemini AI Client Class
19 *
20 * Provides interface to Google Gemini API for SEO optimization,
21 * content analysis, and other AI-powered features.
22 *
23 * @since 1.0.0
24 */
25 class Gemini_Client {
26
27 /**
28 * API key for Gemini
29 *
30 * @since 1.0.0
31 * @var string
32 */
33 private string $api_key;
34
35 /**
36 * Model to use for requests
37 *
38 * @since 1.0.0
39 * @var string
40 */
41 private string $model;
42
43 /**
44 * Request timeout in seconds
45 *
46 * @since 1.0.0
47 * @var int
48 */
49 private int $timeout;
50
51 /**
52 * Prompt Builder instance
53 *
54 * @since 1.0.0
55 * @var Prompt_Builder|null
56 */
57 private ?Prompt_Builder $prompt_builder = null;
58
59 /**
60 * Constructor
61 *
62 * @param string $api_key Gemini API key
63 * @param string $model Default model to use
64 * @param int $timeout Request timeout
65 */
66 public function __construct(string $api_key, string $model = 'gemini-2.5-flash', int $timeout = 30) {
67 $this->api_key = $api_key;
68 $this->model = $model;
69 $this->timeout = $timeout;
70 }
71
72 /**
73 * Get current model
74 *
75 * @since 1.0.0
76 *
77 * @return string Current model name
78 */
79 public function get_model(): string {
80 return $this->model;
81 }
82
83 /**
84 * Get Prompt Builder instance
85 *
86 * @since 1.0.0
87 *
88 * @return Prompt_Builder Prompt Builder instance
89 */
90 private function get_prompt_builder(): Prompt_Builder {
91 if (!$this->prompt_builder) {
92 // Ensure Prompt Builder is loaded
93 if (!class_exists('ThinkRank\\AI\\Prompt_Builder')) {
94 require_once THINKRANK_PLUGIN_DIR . 'includes/ai/class-prompt-builder.php';
95 }
96 $this->prompt_builder = new Prompt_Builder();
97 }
98 return $this->prompt_builder;
99 }
100
101 /**
102 * Generate SEO metadata for content
103 *
104 * @param string $content Content to optimize
105 * @param array $options Generation options
106 * @return array Generated metadata
107 * @throws \Exception If generation fails
108 */
109 public function generate_seo_metadata(string $content, array $options = []): array {
110 $target_keyword = $options['target_keyword'] ?? '';
111 $content_type = $options['content_type'] ?? 'blog_post';
112 $tone = $options['tone'] ?? 'professional';
113
114 $prompt_builder = $this->get_prompt_builder();
115 $prompt = $prompt_builder->build_seo_prompt($content, $target_keyword, $content_type, $tone, 'gemini');
116
117 $response = $this->generate_completion($prompt, [
118 'max_tokens' => 500,
119 'temperature' => 0.3,
120 ]);
121
122 return $this->parse_seo_response($response);
123 }
124
125 /**
126 * Analyze content for SEO optimization
127 *
128 * @param string $content Content to analyze
129 * @param array $metadata Existing metadata
130 * @return array Analysis results
131 * @throws \Exception If analysis fails
132 */
133 public function analyze_content(string $content, array $metadata = []): array {
134 $prompt_builder = $this->get_prompt_builder();
135 $prompt = $prompt_builder->build_analysis_prompt($content, $metadata, 'gemini');
136
137 $response = $this->generate_completion($prompt, [
138 'max_tokens' => 800,
139 'temperature' => 0.3,
140 ]);
141
142 return $this->parse_analysis_response($response);
143 }
144
145 /**
146 * Optimize site identity
147 *
148 * @since 1.0.0
149 *
150 * @param array $site_data Site data to optimize
151 * @param array $options Optimization options
152 * @return array Optimization results
153 * @throws \Exception If optimization fails
154 */
155 public function optimize_site_identity(array $site_data, array $options = []): array {
156 $business_type = $options['business_type'] ?? 'website';
157 $target_audience = $options['target_audience'] ?? 'general';
158 $tone = $options['tone'] ?? 'professional';
159
160 $prompt_builder = $this->get_prompt_builder();
161 $prompt = $prompt_builder->build_site_identity_prompt($site_data, $business_type, $target_audience, $tone, 'gemini');
162
163 $response = $this->make_request('generateContent', [
164 'contents' => [
165 [
166 'parts' => [
167 ['text' => $prompt]
168 ]
169 ]
170 ],
171 'systemInstruction' => [
172 'parts' => [
173 ['text' => 'You are an expert SEO consultant specializing in site identity optimization. Provide actionable, specific recommendations in JSON format.']
174 ]
175 ],
176 'generationConfig' => [
177 'maxOutputTokens' => 2000, // Increased based on actual usage (1499 tokens used)
178 'temperature' => 0.4,
179 ]
180 ]);
181
182 return $this->parse_site_identity_response($response);
183 }
184
185 /**
186 * Optimize homepage meta content
187 *
188 * @since 1.0.0
189 *
190 * @param array $content_data Meta content data
191 * @param array $options Optimization options
192 * @return array Optimization results
193 * @throws \Exception If optimization fails
194 */
195 public function optimize_homepage_meta(array $content_data, array $options = []): array {
196 $business_type = $options['business_type'] ?? 'website';
197 $target_audience = $options['target_audience'] ?? 'general';
198 $tone = $options['tone'] ?? 'professional';
199 $context = $options['context'] ?? [];
200
201 $prompt_builder = $this->get_prompt_builder();
202 $prompt = $prompt_builder->build_homepage_meta_prompt($content_data, $business_type, $target_audience, $tone, $context, 'gemini');
203
204 $response = $this->make_request('generateContent', [
205 'contents' => [
206 [
207 'parts' => [
208 ['text' => $prompt]
209 ]
210 ]
211 ],
212 'systemInstruction' => [
213 'parts' => [
214 ['text' => 'You are an expert SEO consultant specializing in homepage meta optimization. Provide actionable, specific recommendations in JSON format.']
215 ]
216 ],
217 'generationConfig' => [
218 'maxOutputTokens' => 1200, // Higher limit for Gemini homepage meta
219 'temperature' => 0.4,
220 ]
221 ]);
222
223 return $this->parse_homepage_meta_response($response);
224 }
225
226 /**
227 * Optimize homepage hero content
228 *
229 * @since 1.0.0
230 *
231 * @param array $hero_data Hero content data
232 * @param array $options Optimization options
233 * @return array Optimization results
234 * @throws \Exception If optimization fails
235 */
236 public function optimize_homepage_hero(array $hero_data, array $options = []): array {
237 $business_type = $options['business_type'] ?? 'website';
238 $target_audience = $options['target_audience'] ?? 'general';
239 $tone = $options['tone'] ?? 'professional';
240 $context = $options['context'] ?? [];
241
242 $prompt_builder = $this->get_prompt_builder();
243 $prompt = $prompt_builder->build_homepage_hero_prompt($hero_data, $business_type, $target_audience, $tone, $context, 'gemini');
244
245 $response = $this->make_request('generateContent', [
246 'contents' => [
247 [
248 'parts' => [
249 ['text' => $prompt]
250 ]
251 ]
252 ],
253 'systemInstruction' => [
254 'parts' => [
255 ['text' => 'You are an expert SEO consultant specializing in homepage hero optimization. Provide actionable, specific recommendations in JSON format.']
256 ]
257 ],
258 'generationConfig' => [
259 'maxOutputTokens' => 1200, // Higher limit for Gemini homepage hero
260 'temperature' => 0.4,
261 ]
262 ]);
263
264 return $this->parse_homepage_hero_response($response);
265 }
266
267 /**
268 * Optimize LLMs.txt content
269 *
270 * @since 1.0.0
271 *
272 * @param array $website_data Website data to optimize
273 * @param array $options Optimization options
274 * @return array Optimization results
275 * @throws \Exception If optimization fails
276 */
277 public function optimize_llms_txt(array $website_data, array $options = []): array {
278 // Use shared prompt builder for consistent prompts across all AI providers
279 $prompt_builder = $this->get_prompt_builder();
280 $prompt = $prompt_builder->build_llms_txt_prompt($website_data, $options, 'gemini');
281
282 $response = $this->make_request('generateContent', [
283 'contents' => [
284 [
285 'parts' => [
286 ['text' => $prompt]
287 ]
288 ]
289 ],
290 'generationConfig' => [
291 'maxOutputTokens' => 2000, // Increased for Gemini 2.5 Flash compatibility
292 'temperature' => 0.4,
293 ]
294 ]);
295
296 return $this->parse_llms_txt_response($response);
297 }
298
299 /**
300 * Generate completion using Gemini API
301 *
302 * @param string $prompt Prompt to send
303 * @param array $options Generation options
304 * @return array API response
305 * @throws \Exception If request fails
306 */
307 public function generate_completion(string $prompt, array $options = []): array {
308 $max_tokens = $options['max_tokens'] ?? 1000;
309 $temperature = $options['temperature'] ?? 0.7;
310
311 return $this->make_request('generateContent', [
312 'contents' => [
313 [
314 'parts' => [
315 ['text' => $prompt]
316 ]
317 ]
318 ],
319 'generationConfig' => [
320 'maxOutputTokens' => $max_tokens,
321 'temperature' => $temperature,
322 ]
323 ]);
324 }
325
326 /**
327 * Make request to Gemini API
328 *
329 * @param string $endpoint API endpoint
330 * @param array $data Request data
331 * @return array Response data
332 * @throws \Exception If request fails
333 */
334 private function make_request(string $endpoint, array $data): array {
335 $url = "https://generativelanguage.googleapis.com/v1beta/models/{$this->model}:{$endpoint}?key={$this->api_key}";
336
337
338
339 $response = wp_remote_post($url, [
340 'timeout' => $this->timeout,
341 'headers' => [
342 'Content-Type' => 'application/json',
343 ],
344 'body' => wp_json_encode($data),
345 ]);
346
347 if (is_wp_error($response)) {
348 throw new \Exception('Gemini API request failed: ' . esc_html($response->get_error_message()));
349 }
350
351 $status_code = wp_remote_retrieve_response_code($response);
352 $body = wp_remote_retrieve_body($response);
353
354 if ($status_code !== 200) {
355 $error_data = json_decode($body, true);
356 $error_message = $error_data['error']['message'] ?? 'Unknown error';
357 throw new \Exception('Gemini API error (' . esc_html($status_code) . '): ' . esc_html($error_message));
358 }
359
360 $decoded = json_decode($body, true);
361 if (json_last_error() !== JSON_ERROR_NONE) {
362 throw new \Exception('Invalid JSON response from Gemini API');
363 }
364
365 // Debug: Log token usage information
366 if (isset($decoded['usageMetadata'])) {
367 $usage = $decoded['usageMetadata'];
368 $prompt_tokens = $usage['promptTokenCount'] ?? 0;
369 $total_tokens = $usage['totalTokenCount'] ?? 0;
370 $output_tokens = $total_tokens - $prompt_tokens;
371
372
373 }
374
375 return $decoded;
376 }
377
378 /**
379 * Parse SEO response from Gemini
380 *
381 * @param array $response Gemini response
382 * @return array Parsed metadata
383 * @throws \Exception If parsing fails
384 */
385 private function parse_seo_response(array $response): array {
386
387
388 if (!isset($response['candidates'][0]['content']['parts'][0]['text'])) {
389 throw new \Exception('Invalid response format from Gemini');
390 }
391
392 $content = trim($response['candidates'][0]['content']['parts'][0]['text']);
393 $ai_text = $content; // Store the raw AI-generated text (Content Brief pattern)
394
395 // Extract JSON from response
396 $json_start = strpos($content, '{');
397 $json_end = strrpos($content, '}');
398
399 if (false === $json_start || false === $json_end) {
400 throw new \Exception('No valid JSON found in Gemini response');
401 }
402
403 $json_content = substr($content, $json_start, $json_end - $json_start + 1);
404 $metadata = json_decode($json_content, true);
405
406 if (json_last_error() !== JSON_ERROR_NONE) {
407 throw new \Exception('Invalid JSON in Gemini response: ' . esc_html(json_last_error_msg()));
408 }
409
410 // Validate required fields
411 $required_fields = ['title', 'description', 'focus_keyword'];
412 foreach ($required_fields as $field) {
413 if (!isset($metadata[$field])) {
414 throw new \Exception('Missing required field: ' . esc_html($field));
415 }
416 }
417
418 return [
419 'title' => sanitize_text_field($metadata['title']),
420 'description' => sanitize_textarea_field($metadata['description']),
421 'focus_keyword' => sanitize_text_field($metadata['focus_keyword']),
422 'suggestions' => array_map('sanitize_text_field', $metadata['suggestions'] ?? []),
423 'generated_at' => current_time('mysql'),
424 'provider' => 'gemini',
425 'model' => $this->model,
426 'tokens_used' => $response['usageMetadata']['totalTokenCount'] ?? 0,
427 '_ai_text' => $ai_text, // Store the raw AI-generated text (Content Brief pattern)
428 ];
429 }
430
431 /**
432 * Parse analysis response from Gemini
433 *
434 * @param array $response Gemini response
435 * @return array Parsed analysis
436 * @throws \Exception If parsing fails
437 */
438 private function parse_analysis_response(array $response): array {
439 if (!isset($response['candidates'][0]['content']['parts'][0]['text'])) {
440 throw new \Exception('Invalid response format from Gemini');
441 }
442
443 $content = trim($response['candidates'][0]['content']['parts'][0]['text']);
444 $ai_text = $content; // Store the raw AI-generated text (Content Brief pattern)
445
446 // Extract JSON from response
447 $json_start = strpos($content, '{');
448 $json_end = strrpos($content, '}');
449
450 if (false === $json_start || false === $json_end) {
451 throw new \Exception('No valid JSON found in Gemini response');
452 }
453
454 $json_content = substr($content, $json_start, $json_end - $json_start + 1);
455 $analysis = json_decode($json_content, true);
456
457 if (json_last_error() !== JSON_ERROR_NONE) {
458 throw new \Exception('Invalid JSON in Gemini response: ' . esc_html(json_last_error_msg()));
459 }
460
461 return [
462 'seo_score' => absint($analysis['seo_score'] ?? 0),
463 'content_analysis' => $analysis['content_analysis'] ?? [],
464 'suggestions' => array_map('sanitize_text_field', $analysis['suggestions'] ?? []),
465 'strengths' => array_map('sanitize_text_field', $analysis['strengths'] ?? []),
466 'weaknesses' => array_map('sanitize_text_field', $analysis['weaknesses'] ?? []),
467 'generated_at' => current_time('mysql'),
468 'provider' => 'gemini',
469 'model' => $this->model,
470 'tokens_used' => $response['usageMetadata']['totalTokenCount'] ?? 0,
471 '_ai_text' => $ai_text, // Store the raw AI-generated text (Content Brief pattern)
472 ];
473 }
474
475 /**
476 * Parse site identity optimization response
477 *
478 * @param array $response Gemini API response
479 * @return array Parsed optimization data
480 * @throws \Exception If parsing fails
481 */
482 private function parse_site_identity_response(array $response): array {
483 if (!isset($response['candidates'][0]['content']['parts'][0]['text'])) {
484 // Check if content was blocked by safety filters
485 if (isset($response['candidates'][0]['finishReason']) && $response['candidates'][0]['finishReason'] === 'SAFETY') {
486 throw new \Exception('Content was blocked by Gemini safety filters. Please try rephrasing your request.');
487 }
488 // Check if response was truncated due to token limit
489 if (isset($response['candidates'][0]['finishReason']) && $response['candidates'][0]['finishReason'] === 'MAX_TOKENS') {
490 throw new \Exception('Gemini response was truncated due to token limit. Please try a shorter request or increase token limit.');
491 }
492 // Check if there are no candidates
493 if (!isset($response['candidates']) || empty($response['candidates'])) {
494 throw new \Exception('No response candidates from Gemini. The request may have been filtered.');
495 }
496 // Check if content exists but parts are missing
497 if (isset($response['candidates'][0]['content']) && !isset($response['candidates'][0]['content']['parts'])) {
498 throw new \Exception('Gemini response missing content parts. The response may be incomplete.');
499 }
500 throw new \Exception('Invalid response format from Gemini');
501 }
502
503 $content = trim($response['candidates'][0]['content']['parts'][0]['text']);
504 $ai_text = $content; // Store the raw AI-generated text (Content Brief pattern)
505
506 // Extract JSON from response
507 $json_start = strpos($content, '{');
508 $json_end = strrpos($content, '}');
509
510 if (false === $json_start || false === $json_end) {
511 throw new \Exception('No valid JSON found in Gemini response');
512 }
513
514 $json_content = substr($content, $json_start, $json_end - $json_start + 1);
515 $data = json_decode($json_content, true);
516
517 if (json_last_error() !== JSON_ERROR_NONE) {
518 throw new \Exception('Invalid JSON in Gemini response: ' . esc_html(json_last_error_msg()));
519 }
520
521 return [
522 'optimized_data' => $data['optimized_data'] ?? [],
523 'analysis' => sanitize_textarea_field($data['analysis'] ?? ''),
524 'suggestions' => array_map('sanitize_text_field', $data['suggestions'] ?? []),
525 'score' => absint($data['score'] ?? 0),
526 'generated_at' => current_time('mysql'),
527 'provider' => 'gemini',
528 'model' => $this->model,
529 'tokens_used' => $response['usageMetadata']['totalTokenCount'] ?? 0,
530 '_ai_text' => $ai_text, // Store the raw AI-generated text (Content Brief pattern)
531 ];
532 }
533
534 /**
535 * Parse homepage meta optimization response
536 *
537 * @param array $response Gemini API response
538 * @return array Parsed optimization data
539 * @throws \Exception If parsing fails
540 */
541 private function parse_homepage_meta_response(array $response): array {
542 if (!isset($response['candidates'][0]['content']['parts'][0]['text'])) {
543 // Check if content was blocked by safety filters
544 if (isset($response['candidates'][0]['finishReason']) && $response['candidates'][0]['finishReason'] === 'SAFETY') {
545 throw new \Exception('Content was blocked by Gemini safety filters. Please try rephrasing your request.');
546 }
547 // Check if response was truncated due to token limit
548 if (isset($response['candidates'][0]['finishReason']) && $response['candidates'][0]['finishReason'] === 'MAX_TOKENS') {
549 throw new \Exception('Gemini response was truncated due to token limit. Please try a shorter request or increase token limit.');
550 }
551 // Check if there are no candidates
552 if (!isset($response['candidates']) || empty($response['candidates'])) {
553 throw new \Exception('No response candidates from Gemini. The request may have been filtered.');
554 }
555 // Check if content exists but parts are missing
556 if (isset($response['candidates'][0]['content']) && !isset($response['candidates'][0]['content']['parts'])) {
557 throw new \Exception('Gemini response missing content parts. The response may be incomplete.');
558 }
559 throw new \Exception('Invalid response format from Gemini');
560 }
561
562 $content = trim($response['candidates'][0]['content']['parts'][0]['text']);
563 $ai_text = $content; // Store the raw AI-generated text (Content Brief pattern)
564
565 // Extract JSON from response
566 $json_start = strpos($content, '{');
567 $json_end = strrpos($content, '}');
568
569 if (false === $json_start || false === $json_end) {
570 throw new \Exception('No valid JSON found in Gemini response');
571 }
572
573 $json_content = substr($content, $json_start, $json_end - $json_start + 1);
574 $data = json_decode($json_content, true);
575
576 if (json_last_error() !== JSON_ERROR_NONE) {
577 throw new \Exception('Invalid JSON in Gemini response: ' . esc_html(json_last_error_msg()));
578 }
579
580 return [
581 'optimized_data' => $data['optimized_data'] ?? [],
582 'analysis' => sanitize_textarea_field($data['analysis'] ?? ''),
583 'suggestions' => array_map('sanitize_text_field', $data['suggestions'] ?? []),
584 'score' => absint($data['score'] ?? 0),
585 'generated_at' => current_time('mysql'),
586 'provider' => 'gemini',
587 'model' => $this->model,
588 'tokens_used' => $response['usageMetadata']['totalTokenCount'] ?? 0,
589 '_ai_text' => $ai_text, // Store the raw AI-generated text (Content Brief pattern)
590 ];
591 }
592
593 /**
594 * Parse homepage hero optimization response
595 *
596 * @param array $response Gemini API response
597 * @return array Parsed optimization data
598 * @throws \Exception If parsing fails
599 */
600 private function parse_homepage_hero_response(array $response): array {
601 if (!isset($response['candidates'][0]['content']['parts'][0]['text'])) {
602 // Check if content was blocked by safety filters
603 if (isset($response['candidates'][0]['finishReason']) && $response['candidates'][0]['finishReason'] === 'SAFETY') {
604 throw new \Exception('Content was blocked by Gemini safety filters. Please try rephrasing your request.');
605 }
606 // Check if response was truncated due to token limit
607 if (isset($response['candidates'][0]['finishReason']) && $response['candidates'][0]['finishReason'] === 'MAX_TOKENS') {
608 throw new \Exception('Gemini response was truncated due to token limit. Please try a shorter request or increase token limit.');
609 }
610 // Check if there are no candidates
611 if (!isset($response['candidates']) || empty($response['candidates'])) {
612 throw new \Exception('No response candidates from Gemini. The request may have been filtered.');
613 }
614 // Check if content exists but parts are missing
615 if (isset($response['candidates'][0]['content']) && !isset($response['candidates'][0]['content']['parts'])) {
616 throw new \Exception('Gemini response missing content parts. The response may be incomplete.');
617 }
618 throw new \Exception('Invalid response format from Gemini');
619 }
620
621 $content = trim($response['candidates'][0]['content']['parts'][0]['text']);
622 $ai_text = $content; // Store the raw AI-generated text (Content Brief pattern)
623
624 // Extract JSON from response
625 $json_start = strpos($content, '{');
626 $json_end = strrpos($content, '}');
627
628 if (false === $json_start || false === $json_end) {
629 throw new \Exception('No valid JSON found in Gemini response');
630 }
631
632 $json_content = substr($content, $json_start, $json_end - $json_start + 1);
633 $data = json_decode($json_content, true);
634
635 if (json_last_error() !== JSON_ERROR_NONE) {
636 throw new \Exception('Invalid JSON in Gemini response: ' . esc_html(json_last_error_msg()));
637 }
638
639 return [
640 'optimized_data' => $data['optimized_data'] ?? [],
641 'analysis' => sanitize_textarea_field($data['analysis'] ?? ''),
642 'suggestions' => array_map('sanitize_text_field', $data['suggestions'] ?? []),
643 'score' => absint($data['score'] ?? 0),
644 'generated_at' => current_time('mysql'),
645 'provider' => 'gemini',
646 'model' => $this->model,
647 'tokens_used' => $response['usageMetadata']['totalTokenCount'] ?? 0,
648 '_ai_text' => $ai_text, // Store the raw AI-generated text (Content Brief pattern)
649 ];
650 }
651
652 /**
653 * Parse LLMs.txt optimization response
654 *
655 * @param array $response Gemini API response
656 * @return array Parsed optimization data
657 * @throws \Exception If parsing fails
658 */
659 private function parse_llms_txt_response(array $response): array {
660 if (!isset($response['candidates'][0]['content']['parts'][0]['text'])) {
661 // Check if content was blocked by safety filters
662 if (isset($response['candidates'][0]['finishReason']) && $response['candidates'][0]['finishReason'] === 'SAFETY') {
663 throw new \Exception('Content was blocked by Gemini safety filters. Please try rephrasing your request.');
664 }
665 // Check if response was truncated due to token limit
666 if (isset($response['candidates'][0]['finishReason']) && $response['candidates'][0]['finishReason'] === 'MAX_TOKENS') {
667 throw new \Exception('Gemini response was truncated due to token limit. Please try a shorter request or increase token limit.');
668 }
669 // Check if there are no candidates
670 if (!isset($response['candidates']) || empty($response['candidates'])) {
671 throw new \Exception('No response candidates from Gemini. The request may have been filtered.');
672 }
673 // Check if content exists but parts are missing
674 if (isset($response['candidates'][0]['content']) && !isset($response['candidates'][0]['content']['parts'])) {
675 throw new \Exception('Gemini response missing content parts. The response may be incomplete.');
676 }
677 throw new \Exception('Invalid response format from Gemini');
678 }
679
680 $content = trim($response['candidates'][0]['content']['parts'][0]['text']);
681 $ai_text = $content; // Store the raw AI-generated text (Content Brief pattern)
682
683 // Extract JSON from response
684 $json_start = strpos($content, '{');
685 $json_end = strrpos($content, '}');
686
687 if (false === $json_start || false === $json_end) {
688 throw new \Exception('No valid JSON found in Gemini response');
689 }
690
691 $json_content = substr($content, $json_start, $json_end - $json_start + 1);
692 $data = json_decode($json_content, true);
693
694 if (json_last_error() !== JSON_ERROR_NONE) {
695 throw new \Exception('Invalid JSON in Gemini response: ' . esc_html(json_last_error_msg()));
696 }
697
698 // Match the structure expected by AI Manager (same as OpenAI/Claude)
699 return [
700 'optimized_data' => [
701 'site_name' => sanitize_text_field($data['optimized_data']['site_name'] ?? ''),
702 'project_overview' => sanitize_textarea_field($data['optimized_data']['project_overview'] ?? ''),
703 'key_features' => sanitize_textarea_field($data['optimized_data']['key_features'] ?? ''),
704 'architecture' => sanitize_textarea_field($data['optimized_data']['architecture'] ?? ''),
705 'development_guidelines' => sanitize_textarea_field($data['optimized_data']['development_guidelines'] ?? ''),
706 'ai_context' => sanitize_textarea_field($data['optimized_data']['ai_context'] ?? ''),
707 ],
708 'suggestions' => array_map('sanitize_text_field', $data['suggestions'] ?? []),
709 'generated_at' => current_time('mysql'),
710 'provider' => 'gemini',
711 'model' => $this->model,
712 'tokens_used' => $response['usageMetadata']['totalTokenCount'] ?? 0,
713 '_ai_text' => $ai_text, // Store the raw AI-generated text (Content Brief pattern)
714 ];
715 }
716 }
717