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

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