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

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