PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 1.27.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v1.27.0
2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 All 48 releases
thinkrank / includes / ai / class-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.27.0, at includes/ai/class-openai-client.php

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