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-openai-client.php

class-openai-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-openai-client.php

893 lines 34.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * OpenAI API Client
4 *
5 * Handles communication with OpenAI API
6 *
7 * @package ThinkRank\AI
8 * @since 1.0.0
9 */
10
11 declare(strict_types=1);
12
13 namespace ThinkRank\AI;
14
15 // Prevent direct access
16 if (!defined('ABSPATH')) {
17 exit;
18 }
19
20 /**
21 * OpenAI Client Class
22 *
23 * Single Responsibility: Handle OpenAI API communication
24 *
25 * @since 1.0.0
26 */
27 class OpenAI_Client {
28
29 /**
30 * OpenAI API base URL
31 */
32 private const API_BASE_URL = 'https://api.openai.com/v1';
33
34 /**
35 * API key
36 *
37 * @var string
38 */
39 private string $api_key;
40
41 /**
42 * Default model
43 *
44 * @var string
45 */
46 private string $model;
47
48 /**
49 * Request timeout in seconds
50 *
51 * @var int
52 */
53 private int $timeout;
54
55 /**
56 * Prompt Builder instance
57 *
58 * @since 1.0.0
59 * @var Prompt_Builder|null
60 */
61 private ?Prompt_Builder $prompt_builder = null;
62
63 /**
64 * Constructor
65 *
66 * @param string $api_key OpenAI API key
67 * @param string $model Default model to use
68 * @param int $timeout Request timeout
69 */
70 public function __construct(string $api_key, string $model = 'gpt-5-nano', int $timeout = 30) {
71 $this->api_key = $api_key;
72 $this->model = $model;
73 $this->timeout = $timeout;
74 }
75
76 /**
77 * Get Prompt Builder instance
78 *
79 * @since 1.0.0
80 *
81 * @return Prompt_Builder Prompt Builder instance
82 */
83 private function get_prompt_builder(): Prompt_Builder {
84 if (!$this->prompt_builder) {
85 // Ensure Prompt Builder is loaded
86 if (!class_exists('ThinkRank\\AI\\Prompt_Builder')) {
87 require_once THINKRANK_PLUGIN_DIR . 'includes/ai/class-prompt-builder.php';
88 }
89 $this->prompt_builder = new Prompt_Builder();
90 }
91 return $this->prompt_builder;
92 }
93
94 /**
95 * Generate completion using OpenAI
96 *
97 * @param string $prompt The prompt to send
98 * @param array $options Additional options
99 * @return array Response data
100 * @throws \Exception If API request fails
101 */
102 public function generate_completion(string $prompt, array $options = []): array {
103 $default_options = [
104 'model' => $this->model,
105 'max_tokens' => 1000,
106 'temperature' => 0.7,
107 'top_p' => 1,
108 'frequency_penalty' => 0,
109 'presence_penalty' => 0,
110 ];
111
112 $options = array_merge($default_options, $options);
113
114 $body = [
115 'model' => $options['model'],
116 'messages' => [
117 [
118 'role' => 'user',
119 'content' => $prompt,
120 ]
121 ],
122 ];
123
124 // Get safe token limit for this model
125 $safe_tokens = $this->get_safe_token_limit($options['model'], $options['max_tokens']);
126
127 // Add parameters based on model type
128 if ($this->is_reasoning_model($options['model'])) {
129 // Reasoning models (o1/o3) have fixed parameters and restricted support
130 // temperature, top_p, frequency_penalty, presence_penalty are not supported
131 $body['max_completion_tokens'] = $safe_tokens;
132 } else {
133 // Standard models support all parameters
134 $body['temperature'] = $options['temperature'];
135 $body['top_p'] = $options['top_p'];
136 $body['frequency_penalty'] = $options['frequency_penalty'];
137 $body['presence_penalty'] = $options['presence_penalty'];
138 $body['max_tokens'] = $safe_tokens;
139 }
140
141 return $this->make_request('chat/completions', $body);
142 }
143
144 /**
145 * Generate SEO metadata
146 *
147 * @param string $content Content to analyze
148 * @param array $options Generation options
149 * @return array Generated metadata
150 * @throws \Exception If generation fails
151 */
152 public function generate_seo_metadata(string $content, array $options = []): array {
153 $target_keyword = $options['target_keyword'] ?? '';
154 $content_type = $options['content_type'] ?? 'blog_post';
155 $tone = $options['tone'] ?? 'professional';
156
157 $prompt_builder = $this->get_prompt_builder();
158 $prompt = $prompt_builder->build_seo_prompt($content, $target_keyword, $content_type, $tone, 'openai');
159
160 $response = $this->generate_completion($prompt, [
161 'max_tokens' => $this->get_recommended_tokens('seo_metadata'),
162 'temperature' => 0.3, // Lower temperature for more consistent SEO output
163 ]);
164
165 return $this->parse_seo_response($response);
166 }
167
168 /**
169 * Analyze content for SEO optimization
170 *
171 * @param string $content Content to analyze
172 * @param array $metadata Existing metadata
173 * @return array Analysis results
174 * @throws \Exception If analysis fails
175 */
176 public function analyze_content(string $content, array $metadata = []): array {
177 $prompt_builder = $this->get_prompt_builder();
178 $prompt = $prompt_builder->build_analysis_prompt($content, $metadata, 'openai');
179
180 $response = $this->generate_completion($prompt, [
181 'max_tokens' => $this->get_recommended_tokens('analysis'),
182 'temperature' => 0.3, // Lower temperature for more consistent analysis
183 ]);
184
185 return $this->parse_analysis_response($response);
186 }
187 private function is_reasoning_model(string $model): bool {
188 // Models that require max_completion_tokens and restrict parameters (no temperature/top_p)
189 // Includes OpenAI o1/o3 series and GPT-5 family
190 $reasoning_models = [
191 'o1-preview',
192 'o1-mini',
193 'o3-mini',
194 'o3-2024-12-17',
195 'gpt-5',
196 'gpt-5-mini',
197 'gpt-5-nano',
198 ];
199
200 // Check for exact matches or model prefixes
201 foreach ($reasoning_models as $reasoning_model) {
202 if ($model === $reasoning_model || strpos($model, $reasoning_model) === 0) {
203 return true;
204 }
205 }
206
207 return false;
208 }
209
210 /**
211 * Get maximum completion tokens for a model
212 *
213 * @param string $model Model name
214 * @return int Maximum completion tokens
215 */
216 private function get_max_completion_tokens(string $model): int {
217 // Model-specific token limits (completion tokens, not context)
218 $token_limits = [
219 // GPT-5 series (optimized for reasoning + content tokens based on usage data)
220 'gpt-5' => 20480, // 20K tokens for full GPT-5
221 'gpt-5-mini' => 15360, // 15K tokens for mini variant (increased)
222 'gpt-5-nano' => 12288, // 12K tokens for nano (increased from 10K for better buffer)
223
224 // GPT-4o series
225 'gpt-4o' => 4096,
226 'gpt-4o-2024-08-06' => 4096,
227 'gpt-4o-2024-05-13' => 4096,
228 'gpt-4o-mini' => 16384,
229 'gpt-4o-mini-2024-07-18' => 16384,
230
231 // o1/o3 reasoning models (higher limits)
232 'o1-preview' => 32768,
233 'o1-mini' => 65536,
234 'o3-mini' => 65536,
235 'o3-2024-12-17' => 65536,
236 ];
237
238 // Check for exact match first
239 if (isset($token_limits[$model])) {
240 return $token_limits[$model];
241 }
242
243 // Check for partial matches (for versioned models)
244 foreach ($token_limits as $known_model => $limit) {
245 if (strpos($model, $known_model) === 0) {
246 return $limit;
247 }
248 }
249
250 // Default fallback for unknown models
251 return 4096;
252 }
253
254 /**
255 * Get safe token limit for a request
256 *
257 * @param string $model Model name
258 * @param int $requested_tokens Requested token count
259 * @return int Safe token count (capped at model limit)
260 */
261 public function get_safe_token_limit(string $model, int $requested_tokens): int {
262 $max_tokens = $this->get_max_completion_tokens($model);
263 return min($requested_tokens, $max_tokens);
264 }
265
266 /**
267 * Get recommended token limit for specific use cases
268 *
269 * @param string $use_case Use case (e.g., 'content_brief', 'seo_metadata', 'analysis')
270 * @return int Recommended token limit
271 */
272 public function get_recommended_tokens(string $use_case): int {
273 $max_tokens = $this->get_max_completion_tokens($this->model);
274
275 // For reasoning models (GPT-5, o1, o3), we need much higher token limits
276 // because they use reasoning tokens + content tokens
277 if ($this->is_reasoning_model($this->model)) {
278 $reasoning_recommendations = [
279 'content_brief' => 0.95, // 95% of max tokens (reasoning + content)
280 'seo_metadata' => 0.8, // 80% for reasoning models (safe buffer)
281 'analysis' => 0.85, // 85% for reasoning models (safe buffer)
282 'llms_txt' => 0.8, // 80% for reasoning models (safe buffer)
283 'optimization' => 0.9, // 90% for reasoning models (based on 5.9K usage + 50% buffer)
284 ];
285 $percentage = $reasoning_recommendations[$use_case] ?? 0.3;
286 } else {
287 // Standard models - original percentages
288 $standard_recommendations = [
289 'content_brief' => 0.9, // 90% of max tokens for comprehensive briefs
290 'seo_metadata' => 0.15, // 15% of max tokens for metadata
291 'analysis' => 0.25, // 25% of max tokens for analysis
292 'llms_txt' => 0.5, // 50% of max tokens for llms.txt
293 'optimization' => 0.15, // 15% of max tokens for optimization
294 ];
295 $percentage = $standard_recommendations[$use_case] ?? 0.15;
296 }
297
298 return (int) ($max_tokens * $percentage);
299 }
300
301 /**
302 * Build request body for chat completions with model-specific parameters
303 *
304 * @param string $user_prompt User prompt
305 * @param string|null $system_prompt System prompt (ignored for reasoning models)
306 * @param int $max_tokens Maximum tokens
307 * @param float $temperature Temperature (ignored for reasoning models)
308 * @return array Request body
309 */
310 private function build_chat_request(string $user_prompt, ?string $system_prompt = null, int $max_tokens = 600, float $temperature = 0.4): array {
311 $body = [
312 'model' => $this->model,
313 ];
314
315 // Get safe token limit for this model
316 $safe_tokens = $this->get_safe_token_limit($this->model, $max_tokens);
317
318 // Build messages based on model type
319 if ($this->is_reasoning_model($this->model)) {
320 // Reasoning/GPT‑5 family: use max_completion_tokens and omit temperature
321 $content = $user_prompt;
322 // Some GPT‑5 responses may expect array content parts; we'll send string
323 $body['messages'] = [
324 [
325 'role' => 'user',
326 'content' => $content
327 ]
328 ];
329 $body['max_completion_tokens'] = $safe_tokens;
330 } else {
331 // Standard models support system messages and temperature
332 $messages = [];
333 if ($system_prompt) {
334 $messages[] = [
335 'role' => 'system',
336 'content' => $system_prompt
337 ];
338 }
339 $messages[] = [
340 'role' => 'user',
341 'content' => $user_prompt
342 ];
343
344 $body['messages'] = $messages;
345 $body['temperature'] = $temperature;
346 $body['max_tokens'] = $safe_tokens;
347 }
348
349 return $body;
350 }
351
352 /**
353 * Get current model
354 *
355 * @return string Current model name
356 */
357 public function get_model(): string {
358 return $this->model;
359 }
360
361 /**
362 * Test API connection
363 *
364 * @return bool True if connection successful
365 */
366 public function test_connection(): bool {
367 try {
368 $response = $this->make_request('models');
369 return isset($response['data']) && is_array($response['data']);
370 } catch (\Exception $e) {
371 return false;
372 }
373 }
374
375 /**
376 * Make API request to OpenAI
377 *
378 * @param string $endpoint API endpoint
379 * @param array $body Request body
380 * @return array Response data
381 * @throws \Exception If request fails
382 */
383 private function make_request(string $endpoint, array $body = []): array {
384 $url = self::API_BASE_URL . '/' . ltrim($endpoint, '/');
385
386 $args = [
387 'timeout' => $this->timeout,
388 'headers' => [
389 'Authorization' => 'Bearer ' . $this->api_key,
390 'Content-Type' => 'application/json',
391 'User-Agent' => 'ThinkRank/' . THINKRANK_VERSION,
392 ],
393 ];
394
395 if (!empty($body)) {
396 $args['method'] = 'POST';
397 $args['body'] = wp_json_encode($body);
398 }
399
400 $response = $this->request_with_retry($url, $args);
401
402 if (is_wp_error($response)) {
403 throw new \Exception('API request failed: ' . esc_html($response->get_error_message()));
404 }
405
406 $status_code = wp_remote_retrieve_response_code($response);
407 $response_body = wp_remote_retrieve_body($response);
408
409 if ($status_code >= 400) {
410 $error_data = json_decode($response_body, true);
411 $error_message = $error_data['error']['message'] ?? 'Unknown API error';
412 throw new \Exception(sprintf('OpenAI API error (%d): %s', (int) $status_code, esc_html($error_message)));
413 }
414
415 $data = json_decode($response_body, true);
416
417 if (json_last_error() !== JSON_ERROR_NONE) {
418 throw new \Exception('Invalid JSON response from OpenAI API');
419 }
420
421 // A valid-but-scalar body (null/number/string from a proxy/gateway on a
422 // 2xx) would violate this method's : array return type; reject it here so
423 // it surfaces as a catchable \Exception, not an uncatchable TypeError.
424 if (!is_array($data)) {
425 throw new \Exception('Unexpected non-array response from OpenAI API');
426 }
427
428 return $data;
429 }
430
431 /**
432 * Perform an HTTP request, retrying transient failures (429 / 5xx / network)
433 * per the plugin's retry settings, honoring a Retry-After header when given.
434 *
435 * @param string $url Request URL
436 * @param array $args wp_remote_request arguments
437 * @return array|\WP_Error Final response (or last error after retries)
438 */
439 private function request_with_retry(string $url, array $args) {
440 $settings = \ThinkRank\Core\Settings::instance();
441 $retry_enabled = (bool) $settings->get('retry_failed_requests', true);
442 $max_attempts = $retry_enabled ? max(1, (int) $settings->get('retry_attempts', 3)) : 1;
443
444 $response = null;
445 for ($attempt = 1; $attempt <= $max_attempts; $attempt++) {
446 // Keep PHP alive for the whole blocking call (see method docblock).
447 $this->raise_request_time_limit();
448
449 $response = wp_remote_request($url, $args);
450
451 $is_transient = false;
452 $retry_after = 0;
453 if (is_wp_error($response)) {
454 $is_transient = true;
455 } else {
456 $status = wp_remote_retrieve_response_code($response);
457 if (429 === $status || $status >= 500) {
458 $is_transient = true;
459 $retry_after = (int) wp_remote_retrieve_header($response, 'retry-after');
460 }
461 }
462
463 if (!$is_transient || $attempt === $max_attempts) {
464 break;
465 }
466
467 // Honor Retry-After, else exponential backoff (1s, 2s, 4s…), capped.
468 $delay = $retry_after > 0 ? min($retry_after, 30) : min(2 ** ($attempt - 1), 8);
469 sleep($delay);
470 }
471
472 return $response;
473 }
474
475 /**
476 * Give PHP enough execution time to outlive a blocking AI HTTP request.
477 *
478 * The provider call blocks for up to $this->timeout seconds, but the web
479 * SAPI's default max_execution_time (commonly 30s) is shorter — so PHP
480 * fatally terminates the script mid-request (inside the cURL transport),
481 * which the web server surfaces as a 502 Bad Gateway. Resetting the limit
482 * before each attempt keeps the script alive for the full call; PHP-FPM's
483 * request_terminate_timeout still caps the absolute maximum. No-op when
484 * set_time_limit() is disabled (e.g. via disable_functions or safe mode).
485 *
486 * @return void
487 */
488 private function raise_request_time_limit(): void {
489 if (function_exists('set_time_limit')) {
490 // Cover the request timeout plus a small buffer for connection
491 // setup and response handling.
492 @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.
493 }
494 }
495
496 /**
497 * Parse SEO response from OpenAI
498 *
499 * @param array $response OpenAI response
500 * @return array Parsed metadata
501 * @throws \Exception If parsing fails
502 */
503 private function parse_seo_response(array $response): array {
504 if (!isset($response['choices'][0]['message']['content'])) {
505 throw new \Exception('Invalid response format from OpenAI');
506 }
507
508 $content = $response['choices'][0]['message']['content'];
509 $ai_text = $content; // Store the raw AI-generated text (Content Brief pattern)
510
511 // Try to extract JSON from the response
512 $json_start = strpos($content, '{');
513 $json_end = strrpos($content, '}');
514
515 if (false === $json_start || false === $json_end) {
516 throw new \Exception('No valid JSON found in OpenAI response');
517 }
518
519 $json_content = substr($content, $json_start, $json_end - $json_start + 1);
520 $metadata = json_decode($json_content, true);
521
522 if (json_last_error() !== JSON_ERROR_NONE) {
523 throw new \Exception('Failed to parse JSON from OpenAI response');
524 }
525
526 // Validate required fields
527 $required_fields = ['title', 'description', 'focus_keyword'];
528 foreach ($required_fields as $field) {
529 if (!isset($metadata[$field])) {
530 throw new \Exception(sprintf('Missing required field: %s', esc_html($field)));
531 }
532 }
533
534 return [
535 'title' => sanitize_text_field($metadata['title']),
536 'description' => sanitize_text_field($metadata['description']),
537 'focus_keyword' => sanitize_text_field($metadata['focus_keyword']),
538 'suggestions' => array_map('sanitize_text_field', $metadata['suggestions'] ?? []),
539 'generated_at' => current_time('mysql'),
540 'tokens_used' => $response['usage']['total_tokens'] ?? 0,
541 '_ai_text' => $ai_text, // Store the raw AI-generated text (Content Brief pattern)
542 ];
543 }
544
545 /**
546 * Parse analysis response from OpenAI
547 *
548 * @param array $response OpenAI API response
549 * @return array Parsed analysis data
550 * @throws \Exception If parsing fails
551 */
552 private function parse_analysis_response(array $response): array {
553 if (!isset($response['choices'][0]['message']['content'])) {
554 throw new \Exception('Invalid response format from OpenAI');
555 }
556
557 $content = trim($response['choices'][0]['message']['content']);
558 $ai_text = $content; // Store the raw AI-generated text (Content Brief pattern)
559
560 // Extract JSON from response
561 $json_start = strpos($content, '{');
562 $json_end = strrpos($content, '}');
563
564 if (false === $json_start || false === $json_end) {
565 throw new \Exception('No valid JSON found in response');
566 }
567
568 $json_content = substr($content, $json_start, $json_end - $json_start + 1);
569 $analysis = json_decode($json_content, true);
570
571 if (json_last_error() !== JSON_ERROR_NONE) {
572 throw new \Exception('Failed to parse JSON response: ' . esc_html(json_last_error_msg()));
573 }
574
575 // Validate and sanitize response
576 return [
577 'seo_score' => min(100, max(0, (int) ($analysis['seo_score'] ?? 0))),
578 'content_analysis' => [
579 'word_count' => (int) ($analysis['content_analysis']['word_count'] ?? 0),
580 'readability' => sanitize_text_field($analysis['content_analysis']['readability'] ?? 'unknown'),
581 'keyword_density' => sanitize_text_field($analysis['content_analysis']['keyword_density'] ?? 'unknown'),
582 'structure' => sanitize_text_field($analysis['content_analysis']['structure'] ?? 'unknown'),
583 ],
584 'suggestions' => array_map('sanitize_text_field', $analysis['suggestions'] ?? []),
585 'strengths' => array_map('sanitize_text_field', $analysis['strengths'] ?? []),
586 'weaknesses' => array_map('sanitize_text_field', $analysis['weaknesses'] ?? []),
587 'analyzed_at' => current_time('mysql'),
588 'tokens_used' => $response['usage']['total_tokens'] ?? 0,
589 '_ai_text' => $ai_text, // Store the raw AI-generated text (Content Brief pattern)
590 ];
591 }
592
593 /**
594 * Optimize site identity using OpenAI
595 *
596 * @since 1.0.0
597 *
598 * @param array $site_data Site data to optimize
599 * @param array $options Optimization options
600 * @return array Optimization results
601 * @throws \Exception If optimization fails
602 */
603 public function optimize_site_identity(array $site_data, array $options = []): array {
604 $business_type = $options['business_type'] ?? 'website';
605 $target_audience = $options['target_audience'] ?? 'general';
606 $tone = $options['tone'] ?? 'professional';
607
608 $prompt_builder = $this->get_prompt_builder();
609 $prompt = $prompt_builder->build_site_identity_prompt($site_data, $business_type, $target_audience, $tone, 'openai');
610
611 $body = $this->build_chat_request(
612 $prompt,
613 'You are an expert SEO consultant specializing in site identity optimization. Provide actionable, specific recommendations in JSON format.',
614 $this->get_recommended_tokens('optimization'),
615 0.4
616 );
617
618 $response = $this->make_request('chat/completions', $body);
619
620 return $this->parse_site_identity_response($response);
621 }
622
623 /**
624 * Parse site identity optimization response
625 *
626 * @param array $response OpenAI API response
627 * @return array Parsed optimization data
628 * @throws \Exception If parsing fails
629 */
630 private function parse_site_identity_response(array $response): array {
631 if (!isset($response['choices'][0]['message']['content'])) {
632 throw new \Exception('Invalid response format from OpenAI');
633 }
634
635 $content = trim($response['choices'][0]['message']['content']);
636 $ai_text = $content; // Store the raw AI-generated text (Content Brief pattern)
637
638 // Extract JSON from response
639 $json_start = strpos($content, '{');
640 $json_end = strrpos($content, '}');
641
642 if (false === $json_start || false === $json_end) {
643 throw new \Exception('No valid JSON found in response');
644 }
645
646 $json_content = substr($content, $json_start, $json_end - $json_start + 1);
647 $optimization = json_decode($json_content, true);
648
649 if (json_last_error() !== JSON_ERROR_NONE) {
650 throw new \Exception('Failed to parse JSON response: ' . esc_html(json_last_error_msg()));
651 }
652
653 // Validate and sanitize response
654 return [
655 'optimized_data' => [
656 'site_name' => sanitize_text_field($optimization['optimized_data']['site_name'] ?? ''),
657 'site_description' => sanitize_text_field($optimization['optimized_data']['site_description'] ?? ''),
658 'tagline' => sanitize_text_field($optimization['optimized_data']['tagline'] ?? ''),
659 'default_meta_description' => sanitize_text_field($optimization['optimized_data']['default_meta_description'] ?? ''),
660 ],
661 'analysis' => sanitize_textarea_field($optimization['analysis'] ?? ''),
662 'suggestions' => array_map('sanitize_text_field', $optimization['suggestions'] ?? []),
663 'score' => min(100, max(0, (int) ($optimization['score'] ?? 0))),
664 'tokens_used' => $response['usage']['total_tokens'] ?? 0,
665 '_ai_text' => $ai_text, // Store the raw AI-generated text (Content Brief pattern)
666 ];
667 }
668
669 /**
670 * Optimize homepage meta content using AI (copying Site Identity pattern exactly)
671 *
672 * @since 1.0.0
673 *
674 * @param array $content_data Meta content data to optimize
675 * @param array $options Optimization options
676 * @return array Optimization results
677 * @throws \Exception If optimization fails
678 */
679 public function optimize_homepage_meta(array $content_data, array $options = []): array {
680 $business_type = $options['business_type'] ?? 'website';
681 $target_audience = $options['target_audience'] ?? 'general';
682 $tone = $options['tone'] ?? 'professional';
683 $context = $options['context'] ?? [];
684
685 $prompt_builder = $this->get_prompt_builder();
686 $prompt = $prompt_builder->build_homepage_meta_prompt($content_data, $business_type, $target_audience, $tone, $context, 'openai');
687
688 $body = $this->build_chat_request(
689 $prompt,
690 'You are an expert SEO consultant specializing in homepage meta optimization. Provide actionable, specific recommendations in JSON format.',
691 $this->get_recommended_tokens('optimization'),
692 0.4
693 );
694
695 $response = $this->make_request('chat/completions', $body);
696
697 return $this->parse_homepage_meta_response($response);
698 }
699
700 /**
701 * Optimize homepage hero content using AI (copying Site Identity pattern exactly)
702 *
703 * @since 1.0.0
704 *
705 * @param array $hero_data Hero content data to optimize
706 * @param array $options Optimization options
707 * @return array Optimization results
708 * @throws \Exception If optimization fails
709 */
710 public function optimize_homepage_hero(array $hero_data, array $options = []): array {
711 $business_type = $options['business_type'] ?? 'website';
712 $target_audience = $options['target_audience'] ?? 'general';
713 $tone = $options['tone'] ?? 'professional';
714 $context = $options['context'] ?? [];
715
716 $prompt_builder = $this->get_prompt_builder();
717 $prompt = $prompt_builder->build_homepage_hero_prompt($hero_data, $business_type, $target_audience, $tone, $context, 'openai');
718
719 $body = $this->build_chat_request(
720 $prompt,
721 'You are an expert conversion optimization specialist specializing in homepage hero sections. Provide actionable, specific recommendations in JSON format.',
722 $this->get_recommended_tokens('optimization'),
723 0.4
724 );
725
726 $response = $this->make_request('chat/completions', $body);
727
728 return $this->parse_homepage_hero_response($response);
729 }
730
731 /**
732 * Optimize LLMs.txt content using OpenAI
733 *
734 * @since 1.0.0
735 *
736 * @param array $website_data Website data to optimize
737 * @param array $options Optimization options
738 * @return array Optimization results
739 * @throws \Exception If optimization fails
740 */
741 public function optimize_llms_txt(array $website_data, array $options = []): array {
742 // Use shared prompt builder for consistent prompts across all AI providers
743 $prompt_builder = $this->get_prompt_builder();
744 $prompt = $prompt_builder->build_llms_txt_prompt($website_data, $options, 'openai');
745
746 $body = $this->build_chat_request(
747 $prompt,
748 'You are an expert technical writer specializing in creating llms.txt files for AI assistants. Provide structured, comprehensive content in JSON format.',
749 $this->get_recommended_tokens('llms_txt'),
750 0.4
751 );
752
753 $response = $this->make_request('chat/completions', $body);
754
755 return $this->parse_llms_txt_response($response);
756 }
757
758 /**
759 * Parse LLMs.txt optimization response
760 *
761 * @param array $response OpenAI API response
762 * @return array Parsed optimization data
763 * @throws \Exception If parsing fails
764 */
765 private function parse_llms_txt_response(array $response): array {
766 if (!isset($response['choices'][0]['message']['content'])) {
767 throw new \Exception('Invalid response format from OpenAI');
768 }
769
770 $content = trim($response['choices'][0]['message']['content']);
771 $ai_text = $content; // Store the raw AI-generated text (Content Brief pattern)
772
773 // Extract JSON from response
774 $json_start = strpos($content, '{');
775 $json_end = strrpos($content, '}');
776
777 if (false === $json_start || false === $json_end) {
778 throw new \Exception('No valid JSON found in response');
779 }
780
781 $json_content = substr($content, $json_start, $json_end - $json_start + 1);
782 $optimization = json_decode($json_content, true);
783
784 if (json_last_error() !== JSON_ERROR_NONE) {
785 throw new \Exception('Invalid JSON in response: ' . esc_html(json_last_error_msg()));
786 }
787
788 // Validate and sanitize response
789 return [
790 'optimized_data' => [
791 'site_name' => sanitize_text_field($optimization['optimized_data']['site_name'] ?? ''),
792 'project_overview' => sanitize_textarea_field($optimization['optimized_data']['project_overview'] ?? ''),
793 'key_features' => sanitize_textarea_field($optimization['optimized_data']['key_features'] ?? ''),
794 'architecture' => sanitize_textarea_field($optimization['optimized_data']['architecture'] ?? ''),
795 'development_guidelines' => sanitize_textarea_field($optimization['optimized_data']['development_guidelines'] ?? ''),
796 'ai_context' => sanitize_textarea_field($optimization['optimized_data']['ai_context'] ?? ''),
797 ],
798 'suggestions' => array_map('sanitize_text_field', $optimization['suggestions'] ?? []),
799 'tokens_used' => $response['usage']['total_tokens'] ?? 0,
800 '_ai_text' => $ai_text, // Store the raw AI-generated text (Content Brief pattern)
801 ];
802 }
803
804 /**
805 * Parse homepage meta optimization response
806 *
807 * @param array $response OpenAI API response
808 * @return array Parsed optimization data
809 * @throws \Exception If parsing fails
810 */
811 private function parse_homepage_meta_response(array $response): array {
812 if (!isset($response['choices'][0]['message']['content'])) {
813 throw new \Exception('Invalid response format from OpenAI');
814 }
815
816 $content = trim($response['choices'][0]['message']['content']);
817 $ai_text = $content; // Store the raw AI-generated text (Content Brief pattern)
818
819 // Extract JSON from response
820 $json_start = strpos($content, '{');
821 $json_end = strrpos($content, '}');
822
823 if (false === $json_start || false === $json_end) {
824 throw new \Exception('No valid JSON found in response');
825 }
826
827 $json_content = substr($content, $json_start, $json_end - $json_start + 1);
828 $optimization = json_decode($json_content, true);
829
830 if (json_last_error() !== JSON_ERROR_NONE) {
831 throw new \Exception('Failed to parse JSON response: ' . esc_html(json_last_error_msg()));
832 }
833
834 // Validate and sanitize response
835 return [
836 'optimized_data' => [
837 'title' => sanitize_text_field($optimization['optimized_data']['title'] ?? ''),
838 'meta_description' => sanitize_text_field($optimization['optimized_data']['meta_description'] ?? ''),
839 ],
840 'analysis' => sanitize_textarea_field($optimization['analysis'] ?? ''),
841 'suggestions' => array_map('sanitize_text_field', $optimization['suggestions'] ?? []),
842 'score' => min(100, max(0, (int) ($optimization['score'] ?? 0))),
843 'tokens_used' => $response['usage']['total_tokens'] ?? 0,
844 '_ai_text' => $ai_text, // Store the raw AI-generated text (Content Brief pattern)
845 ];
846 }
847
848 /**
849 * Parse homepage hero optimization response
850 *
851 * @param array $response OpenAI API response
852 * @return array Parsed optimization data
853 * @throws \Exception If parsing fails
854 */
855 private function parse_homepage_hero_response(array $response): array {
856 if (!isset($response['choices'][0]['message']['content'])) {
857 throw new \Exception('Invalid response format from OpenAI');
858 }
859
860 $content = trim($response['choices'][0]['message']['content']);
861 $ai_text = $content; // Store the raw AI-generated text (Content Brief pattern)
862
863 // Extract JSON from response
864 $json_start = strpos($content, '{');
865 $json_end = strrpos($content, '}');
866
867 if (false === $json_start || false === $json_end) {
868 throw new \Exception('No valid JSON found in response');
869 }
870
871 $json_content = substr($content, $json_start, $json_end - $json_start + 1);
872 $optimization = json_decode($json_content, true);
873
874 if (json_last_error() !== JSON_ERROR_NONE) {
875 throw new \Exception('Failed to parse JSON response: ' . esc_html(json_last_error_msg()));
876 }
877
878 // Validate and sanitize response
879 return [
880 'optimized_data' => [
881 'hero_title' => sanitize_text_field($optimization['optimized_data']['hero_title'] ?? ''),
882 'hero_subtitle' => sanitize_text_field($optimization['optimized_data']['hero_subtitle'] ?? ''),
883 'hero_cta_text' => sanitize_text_field($optimization['optimized_data']['hero_cta_text'] ?? '')
884 ],
885 'analysis' => sanitize_textarea_field($optimization['analysis'] ?? ''),
886 'suggestions' => array_map('sanitize_text_field', $optimization['suggestions'] ?? []),
887 'score' => min(100, max(0, (int) ($optimization['score'] ?? 0))),
888 'tokens_used' => $response['usage']['total_tokens'] ?? 0,
889 '_ai_text' => $ai_text, // Store the raw AI-generated text (Content Brief pattern)
890 ];
891 }
892 }
893