PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.0.2
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.0.2
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-manager.php

class-manager.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.0.2, at includes/ai/class-manager.php

1,739 lines 71.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * AI Manager Class
4 *
5 * Handles AI provider integration and management
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\Core\Settings;
16
17
18 // Prevent direct access
19 if (!defined('ABSPATH')) {
20 exit;
21 }
22
23 /**
24 * AI Manager Class
25 *
26 * Single Responsibility: Manage AI providers and requests
27 *
28 * @since 1.0.0
29 */
30 class Manager {
31
32 /**
33 * Settings instance
34 *
35 * @var Settings
36 */
37 private Settings $settings;
38
39
40
41 /**
42 * Cache manager instance
43 *
44 * @var Cache_Manager
45 */
46 private Cache_Manager $cache;
47
48 /**
49 * Current AI client
50 *
51 * @var OpenAI_Client|Claude_Client|null
52 */
53 private $client = null;
54
55
56 /**
57 * Constructor
58 *
59 * @param Settings|null $settings Settings instance
60 */
61 public function __construct(?Settings $settings = null) {
62 $this->settings = $settings ?? Settings::instance();
63 $this->cache = new Cache_Manager((int) $this->settings->get('cache_duration', 3600));
64 }
65
66 /**
67 * Initialize AI manager
68 *
69 * @return void
70 */
71 public function init(): void {
72 // Initialize AI client based on settings
73 add_action('init', [$this, 'initialize_client']);
74
75 // Schedule cache cleanup
76 add_action('thinkrank_daily_cleanup', [$this, 'cleanup_cache']);
77
78 // Add AJAX handlers for AI requests
79 add_action('wp_ajax_thinkrank_generate_metadata', [$this, 'ajax_generate_metadata']);
80 add_action('wp_ajax_thinkrank_test_api_connection', [$this, 'ajax_test_connection']);
81 }
82
83 /**
84 * Initialize AI client
85 *
86 * @return void
87 *
88 * @throws \Exception On failure.
89 */
90 public function initialize_client(): void {
91 $provider = $this->settings->get('ai_provider', 'openai');
92
93 try {
94 switch ($provider) {
95 case 'openai':
96 $api_key = $this->settings->get('openai_api_key');
97 if ($api_key) {
98 // Allow any model id (incl. user-entered custom models);
99 // only fall back to the default when none is set.
100 $model = $this->settings->get('openai_model', Settings::DEFAULT_OPENAI_MODEL);
101 if (empty($model)) {
102 $model = Settings::DEFAULT_OPENAI_MODEL;
103 }
104 // OpenAI's reasoning models (GPT-5/o-series) spend a long
105 // time on reasoning tokens before emitting content, so
106 // large completions (content briefs) regularly outlive the
107 // 120s used for the other providers. Give them 300s.
108 $timeout = 300;
109 $this->client = new OpenAI_Client($api_key, $model, $timeout);
110
111 // OpenAI client created successfully
112 }
113 break;
114
115 case 'claude':
116 $api_key = $this->settings->get('claude_api_key');
117 if ($api_key) {
118 // Allow any model id (incl. user-entered custom models);
119 // only fall back to the default when none is set.
120 $model = $this->settings->get('claude_model', Settings::DEFAULT_CLAUDE_MODEL);
121 if (empty($model)) {
122 $model = Settings::DEFAULT_CLAUDE_MODEL;
123 }
124 // Use 120-second timeout for complex AI operations
125 $timeout = 120;
126 $this->client = new Claude_Client($api_key, $model, $timeout);
127
128 // Claude client created successfully
129 }
130 break;
131
132 case 'gemini':
133 $api_key = $this->settings->get('gemini_api_key');
134 if ($api_key) {
135 // Allow any model id (incl. user-entered custom models);
136 // only fall back to the default when none is set.
137 $model = $this->settings->get('gemini_model', Settings::DEFAULT_GEMINI_MODEL);
138 if (empty($model)) {
139 $model = Settings::DEFAULT_GEMINI_MODEL;
140 }
141 // Use 120-second timeout for complex AI operations
142 $timeout = 120;
143 $this->client = new Gemini_Client($api_key, $model, $timeout);
144 }
145 break;
146
147 case 'openrouter':
148 $api_key = $this->settings->get('openrouter_api_key');
149 if ($api_key) {
150 // Allow any model id (incl. user-entered custom models);
151 // only fall back to the default when none is set.
152 $model = $this->settings->get('openrouter_model', Settings::DEFAULT_OPENROUTER_MODEL);
153 if (empty($model)) {
154 $model = Settings::DEFAULT_OPENROUTER_MODEL;
155 }
156 // Use 120-second timeout for complex AI operations
157 $timeout = 120;
158 $this->client = new OpenRouter_Client($api_key, $model, $timeout);
159 }
160 break;
161
162 default:
163 throw new \Exception("Unsupported AI provider: {$provider}");
164 }
165 } catch (\Exception $e) {
166 // AI client initialization failed, will be handled later
167 }
168 }
169
170 /**
171 * Get the display name of the currently selected AI provider
172 *
173 * @return string Provider display name (e.g. "OpenAI")
174 */
175 private function get_provider_label(): string {
176 $labels = [
177 'openai' => 'OpenAI',
178 'claude' => 'Claude',
179 'gemini' => 'Gemini',
180 'openrouter' => 'OpenRouter',
181 ];
182
183 $provider = (string) $this->settings->get('ai_provider', 'openai');
184
185 return $labels[$provider] ?? ucfirst($provider);
186 }
187
188 /**
189 * Build a user-friendly message explaining why AI features are unavailable
190 *
191 * Provider-aware: tells the user exactly which API key is missing and where
192 * to add it, instead of a generic "client not initialized" error.
193 *
194 * @return string Actionable error message for end users
195 */
196 private function get_client_unavailable_message(): string {
197 $provider = (string) $this->settings->get('ai_provider', 'openai');
198
199 // The React admin renders this anchor as a real link via linkifyMessage().
200 $settings_link = sprintf(
201 '<a href="%s" target="_blank" rel="noopener noreferrer">%s</a>',
202 esc_url(admin_url('admin.php?page=thinkrank-settings')),
203 __('ThinkRank → Settings', 'thinkrank')
204 );
205
206 if (empty($this->settings->get("{$provider}_api_key"))) {
207 return sprintf(
208 /* translators: %s: link to the ThinkRank settings page. */
209 __('AI features are not set up yet. To enable them, add your API key under %s.', 'thinkrank'),
210 $settings_link
211 );
212 }
213
214 return sprintf(
215 /* translators: 1: AI provider name (e.g. OpenAI), 2: link to the ThinkRank settings page. */
216 __('ThinkRank could not connect to %1$s. Please verify your API key and model under %2$s, then try again.', 'thinkrank'),
217 $this->get_provider_label(),
218 $settings_link
219 );
220 }
221
222 /**
223 * Force re-initialization of client (useful after settings change)
224 *
225 * @return void
226 */
227 public function reinitialize_client(): void {
228 $this->client = null;
229 $this->initialize_client();
230 }
231
232 /**
233 * Get the AI client instance
234 *
235 * @return OpenAI_Client|Claude_Client|null AI client instance
236 * @throws \Exception If client cannot be initialized
237 */
238 public function get_client() {
239 // Initialize client if not already done
240 if (!$this->client) {
241 $this->initialize_client();
242 }
243
244 // If still not available, throw error
245 if (!$this->client) {
246 throw new \Exception($this->get_client_unavailable_message());
247 }
248
249 return $this->client;
250 }
251
252 /**
253 * Generate SEO metadata for content
254 *
255 * @param string $content Content to analyze
256 * @param array $options Generation options
257 * @return array Generated metadata
258 * @throws \Exception If generation fails
259 */
260 public function generate_seo_metadata(string $content, array $options = []): array {
261 // Check if client is available, try to initialize if not
262 if (!$this->client) {
263 $this->initialize_client();
264
265 // If still not available, throw error
266 if (!$this->client) {
267 throw new \Exception($this->get_client_unavailable_message());
268 }
269 }
270
271 // Check rate limits
272 if (!$this->check_rate_limit()) {
273 throw new \Exception('Rate limit exceeded. Please try again later.');
274 }
275
276 // Get current user for logging
277 $user_id = get_current_user_id();
278
279 // Generate cache key
280 $cache_key = $this->cache->generate_content_key($content, $options);
281
282 // Check cache first
283 $cached_result = $this->cache->get($cache_key);
284 if ($cached_result !== null) {
285 return $cached_result['data'];
286 }
287
288 try {
289 // Generate metadata using AI
290 $metadata = $this->client->generate_seo_metadata($content, $options);
291
292 // Ensure user has configured their API key
293 $user_has_api_key = !empty($this->settings->get('openai_api_key')) || !empty($this->settings->get('claude_api_key')) || !empty($this->settings->get('gemini_api_key')) || !empty($this->settings->get('openrouter_api_key'));
294
295 if (!$user_has_api_key) {
296 throw new \Exception($this->get_client_unavailable_message());
297 }
298
299 // Cache the result
300 $this->cache->set($cache_key, $metadata);
301
302 // Log usage with actual model information and raw AI text (Content Brief pattern)
303 $actual_model = $this->client ? $this->client->get_model() : null;
304 $ai_text = $metadata['_ai_text'] ?? null;
305 $this->log_ai_usage($user_id, 'SEO Metadata', $metadata['tokens_used'] ?? 0, $actual_model, $ai_text);
306
307 // Remove AI text from returned data to keep it clean
308 unset($metadata['_ai_text']);
309
310 return $metadata;
311
312 } catch (\Exception $e) {
313 throw $e;
314 }
315 }
316
317 /**
318 * Generate an improved SEO title that addresses a specific suggestion.
319 *
320 * Used by the "Apply" action on title-related SEO score suggestions. Builds a
321 * focused, best-practice title prompt and runs it through the configured
322 * provider, reusing the same completion/extraction path as the content brief
323 * generator so OpenAI, Claude and Gemini all parse consistently.
324 *
325 * @since 1.14.0
326 *
327 * @param string $content Post content for context.
328 * @param array $options {
329 * @type string $current_title Current SEO title.
330 * @type string $target_keyword Focus keyword.
331 * @type string $content_type Content type (blog_post, page, …).
332 * @type string $tone Desired tone.
333 * @type string $suggestion The suggestion the title must address.
334 * }
335 * @return array{title:string} The improved SEO title.
336 * @throws \Exception If the AI client is unavailable or returns no title.
337 */
338 public function improve_seo_title(string $content, array $options = []): array {
339 if (!$this->client) {
340 $this->initialize_client();
341 if (!$this->client) {
342 throw new \Exception($this->get_client_unavailable_message());
343 }
344 }
345
346 // Ensure user has configured their API key.
347 $user_has_api_key = !empty($this->settings->get('openai_api_key')) || !empty($this->settings->get('claude_api_key')) || !empty($this->settings->get('gemini_api_key')) || !empty($this->settings->get('openrouter_api_key'));
348 if (!$user_has_api_key) {
349 throw new \Exception($this->get_client_unavailable_message());
350 }
351
352 // Check rate limits.
353 if (!$this->check_rate_limit()) {
354 throw new \Exception('Rate limit exceeded. Please try again later.');
355 }
356
357 $current_title = (string) ($options['current_title'] ?? '');
358 $target_keyword = (string) ($options['target_keyword'] ?? '');
359 $content_type = (string) ($options['content_type'] ?? 'blog_post');
360 $tone = (string) ($options['tone'] ?? 'professional');
361 $suggestion = (string) ($options['suggestion'] ?? '');
362 $language = (string) ($options['language'] ?? '');
363
364 // Cache identical requests (same content + inputs) to avoid duplicate calls.
365 // Cap content server-side (mirror the frontend 5000-char trim) so a
366 // direct REST caller can't force oversized prompt/cache/AI work.
367 $content = mb_substr($content, 0, 5000);
368
369 $cache_key = 'improve_title_' . md5($content . '|' . $current_title . '|' . $target_keyword . '|' . $content_type . '|' . $tone . '|' . $suggestion);
370 $cached_result = $this->cache->get($cache_key);
371 if ($cached_result !== null) {
372 return $cached_result['data'] ?? $cached_result;
373 }
374
375 $user_id = get_current_user_id();
376 $provider = method_exists($this->client, 'get_provider') ? $this->client->get_provider() : 'openai';
377
378 // Use ThinkRank's own validation word lists so the generated title passes
379 // the same emotion/sentiment and power-word checks the scorer applies.
380 $sentiment_words = SEOScoreCalculator::get_title_sentiment_words();
381 $power_words = SEOScoreCalculator::get_title_power_words();
382
383 // When the suggestion explicitly asks for an emotional/sentiment word we
384 // strictly validate the result (and retry once) to guarantee it passes.
385 $needs_sentiment = stripos($suggestion, 'sentiment') !== false || stripos($suggestion, 'emotional') !== false;
386
387 $prompt = (new Prompt_Builder())->build_title_improvement_prompt(
388 $content,
389 $current_title,
390 $target_keyword,
391 $content_type,
392 $tone,
393 $suggestion,
394 $provider,
395 $sentiment_words,
396 $power_words,
397 $language
398 );
399
400 $generated = $this->request_title($prompt);
401 $title = $generated['title'];
402 $total_tokens = $generated['tokens'];
403 $ai_text = $generated['ai_text'];
404 $finish_reason = $generated['finish_reason'];
405
406 // A reasoning model can still return an empty/truncated title on the
407 // first pass; retry once before giving up so the "Apply" action reliably
408 // produces a title.
409 if ($title === '') {
410 $retry = $this->request_title($prompt);
411 $total_tokens += $retry['tokens'];
412 if ($retry['ai_text'] !== '') {
413 $ai_text = $retry['ai_text'];
414 }
415 $finish_reason = $retry['finish_reason'];
416 if ($retry['title'] !== '') {
417 $title = $retry['title'];
418 }
419 }
420
421 // Guarantee the emotion/sentiment check passes: if it was required but the
422 // title still lacks a listed word, retry once with a non-negotiable
423 // instruction. If the retry also fails we keep the best title we have.
424 if ($needs_sentiment && !$this->title_contains_word($title, $sentiment_words)) {
425 $retry_prompt = $prompt . "\n\nIMPORTANT: Your previous attempt was rejected because the title did not contain a required word. The new title MUST include at least one of these exact words verbatim: " . implode(', ', $sentiment_words) . '.';
426 $retry = $this->request_title($retry_prompt);
427 $total_tokens += $retry['tokens'];
428 if ($retry['ai_text'] !== '') {
429 $ai_text = $retry['ai_text'];
430 }
431 $finish_reason = $retry['finish_reason'];
432 if ($retry['title'] !== '' && $this->title_contains_word($retry['title'], $sentiment_words)) {
433 $title = $retry['title'];
434 }
435 }
436
437 if ($title === '') {
438 // Nothing about a raw JSON-parse failure is visible to support
439 // otherwise — log_ai_usage() below only runs on success, so a
440 // failed attempt left no trace of what the model actually sent
441 // back or why generation stopped.
442 if (defined('WP_DEBUG') && WP_DEBUG) {
443 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Debug logging only when WP_DEBUG is enabled.
444 error_log(sprintf(
445 '[ThinkRank] Title improvement failed to extract a title. finish_reason=%s ai_text=%s',
446 $finish_reason !== '' ? $finish_reason : '(none)',
447 mb_substr($ai_text, 0, 500)
448 ));
449 }
450 throw new \Exception('The AI did not return a usable title. Please try again.');
451 }
452
453 // Log usage.
454 $actual_model = $this->client ? $this->client->get_model() : null;
455 $this->log_ai_usage($user_id, 'SEO Title Improvement', (int) $total_tokens, $actual_model, $ai_text);
456
457 $result = ['title' => $title];
458 $this->cache->set($cache_key, $result);
459
460 return $result;
461 }
462
463 /**
464 * Run a single title-generation request: call the provider, extract the
465 * title text across provider response shapes, and clamp it to 60 characters.
466 *
467 * @param string $prompt The prompt to send.
468 * @return array{title:string,ai_text:string,tokens:int}
469 */
470 private function request_title(string $prompt): array {
471 // Larger budget so reasoning models (e.g. gpt-5-nano) don't spend the
472 // whole allowance "thinking" and truncate the JSON before the title.
473 $completion = $this->request_completion($prompt, 4096);
474 $title = $this->extract_json_field($completion['ai_text'], 'title');
475
476 // Safety net: enforce the 60-character maximum even if the model overruns.
477 if (mb_strlen($title) > 60) {
478 $title = rtrim(mb_substr($title, 0, 60));
479 }
480
481 return [
482 'title' => $title,
483 'ai_text' => $completion['ai_text'],
484 'tokens' => $completion['tokens'],
485 'finish_reason' => $completion['finish_reason'],
486 ];
487 }
488
489 /**
490 * Generate an improved meta description that addresses a specific suggestion.
491 *
492 * Guarantees ThinkRank's technical check passes (120-160 characters) and,
493 * when the suggestion is about the focus keyword, that the keyword is present
494 * — retrying once if the first attempt falls outside the constraints.
495 *
496 * @since 1.14.0
497 *
498 * @param string $content Post content for context.
499 * @param array $options {
500 * @type string $current_description Current meta description.
501 * @type string $target_keyword Focus keyword.
502 * @type string $content_type Content type.
503 * @type string $tone Desired tone.
504 * @type string $suggestion The suggestion to address.
505 * }
506 * @return array{description:string} The improved meta description.
507 * @throws \Exception If the AI client is unavailable or returns nothing usable.
508 */
509 public function improve_meta_description(string $content, array $options = []): array {
510 if (!$this->client) {
511 $this->initialize_client();
512 if (!$this->client) {
513 throw new \Exception($this->get_client_unavailable_message());
514 }
515 }
516
517 $user_has_api_key = !empty($this->settings->get('openai_api_key')) || !empty($this->settings->get('claude_api_key')) || !empty($this->settings->get('gemini_api_key')) || !empty($this->settings->get('openrouter_api_key'));
518 if (!$user_has_api_key) {
519 throw new \Exception($this->get_client_unavailable_message());
520 }
521
522 if (!$this->check_rate_limit()) {
523 throw new \Exception('Rate limit exceeded. Please try again later.');
524 }
525
526 $current_desc = (string) ($options['current_description'] ?? '');
527 $target_keyword = (string) ($options['target_keyword'] ?? '');
528 $content_type = (string) ($options['content_type'] ?? 'blog_post');
529 $tone = (string) ($options['tone'] ?? 'professional');
530 $suggestion = (string) ($options['suggestion'] ?? '');
531 $language = (string) ($options['language'] ?? '');
532
533 // Cap content server-side (mirror the frontend 5000-char trim) so a
534 // direct REST caller can't force oversized prompt/cache/AI work.
535 $content = mb_substr($content, 0, 5000);
536
537 $cache_key = 'improve_meta_' . md5($content . '|' . $current_desc . '|' . $target_keyword . '|' . $content_type . '|' . $tone . '|' . $suggestion);
538 $cached_result = $this->cache->get($cache_key);
539 if ($cached_result !== null) {
540 return $cached_result['data'] ?? $cached_result;
541 }
542
543 $user_id = get_current_user_id();
544 $provider = method_exists($this->client, 'get_provider') ? $this->client->get_provider() : 'openai';
545
546 // The keyword must appear when the suggestion is keyword-specific, or
547 // whenever a focus keyword exists (the scorer rewards it either way).
548 $needs_keyword = $target_keyword !== '';
549
550 $build_prompt = fn() => (new Prompt_Builder())->build_meta_description_improvement_prompt(
551 $content,
552 $current_desc,
553 $target_keyword,
554 $content_type,
555 $tone,
556 $suggestion,
557 $provider,
558 $language
559 );
560
561 $valid = function (string $desc) use ($needs_keyword, $target_keyword): bool {
562 $len = mb_strlen($desc);
563 if ($len < 120 || $len > 160) {
564 return false;
565 }
566 if ($needs_keyword && strpos(strtolower($desc), strtolower($target_keyword)) === false) {
567 return false;
568 }
569 return true;
570 };
571
572 $prompt = $build_prompt();
573 // Larger budget so reasoning models don't truncate the JSON before the
574 // description (which surfaced as "could not produce a 120-160 character
575 // meta description" on gpt-5-nano).
576 $completion = $this->request_completion($prompt, 4096);
577 $description = $this->extract_json_field($completion['ai_text'], 'description');
578 $total_tokens = $completion['tokens'];
579 $ai_text = $completion['ai_text'];
580
581 // Retry once with explicit, measurable constraints if the first attempt
582 // misses the mandatory length window or the required keyword.
583 if (!$valid($description)) {
584 $extra = "\n\nIMPORTANT: Your previous attempt did not meet the requirements. The description MUST be between 120 and 160 characters";
585 if ($needs_keyword) {
586 $extra .= " and MUST contain the exact phrase \"{$target_keyword}\"";
587 }
588 $extra .= '. Count the characters before answering.';
589 $retry = $this->request_completion($prompt . $extra, 4096);
590 $retry_desc = $this->extract_json_field($retry['ai_text'], 'description');
591 $total_tokens += $retry['tokens'];
592 if ($retry['ai_text'] !== '') {
593 $ai_text = $retry['ai_text'];
594 }
595 // Prefer a valid candidate; otherwise keep the longer non-empty one so
596 // the clamp below can bring an over-long description into range.
597 if ($valid($retry_desc)) {
598 $description = $retry_desc;
599 } elseif ($description === '') {
600 $description = $retry_desc;
601 } elseif (!$valid($description) && mb_strlen($retry_desc) > mb_strlen($description)) {
602 $description = $retry_desc;
603 }
604 }
605
606 // Hard safety net: guarantee the 160-character ceiling by trimming at a
607 // word boundary, so the scorer's 120-160 technical check passes even if a
608 // "thinking" model overran the limit.
609 $description = $this->clamp_meta_description($description);
610
611 if ($description === '' || mb_strlen($description) < 120) {
612 throw new \Exception('The AI could not produce a 120-160 character meta description. Please try again.');
613 }
614
615 $actual_model = $this->client ? $this->client->get_model() : null;
616 $this->log_ai_usage($user_id, 'SEO Meta Description', (int) $total_tokens, $actual_model, $ai_text);
617
618 $result = ['description' => $description];
619 $this->cache->set($cache_key, $result);
620
621 return $result;
622 }
623
624 /**
625 * Explain a single SEO score suggestion in plain, post-specific language.
626 *
627 * Powers the "Explain with AI" copilot action on each suggestion. Unlike the
628 * improve_* methods this does not modify content — it returns a short,
629 * context-aware explanation of why the suggestion matters for this post and
630 * how to resolve it, so the author understands the fix before applying it.
631 *
632 * @since 1.18.0
633 *
634 * @param string $content Post content for context.
635 * @param array $options {
636 * @type string $suggestion The suggestion to explain (required).
637 * @type string $title Post/SEO title for context.
638 * @type string $target_keyword Focus keyword.
639 * @type string $content_type Content type (blog_post, page, …).
640 * }
641 * @return array{explanation:string} The plain-language explanation.
642 * @throws \Exception If the AI client is unavailable or returns nothing usable.
643 */
644 public function explain_seo_suggestion(string $content, array $options = []): array {
645 $this->ensure_ready_for_ai();
646
647 // Cap content server-side so a direct REST caller cannot bypass the
648 // frontend's 5000-character trim and force oversized prompt building,
649 // cache hashing, and expensive AI calls/retries.
650 $content = mb_substr($content, 0, 5000);
651
652 $suggestion = trim((string) ($options['suggestion'] ?? ''));
653 if ($suggestion === '') {
654 throw new \Exception('A suggestion is required to generate an explanation.');
655 }
656 $title = (string) ($options['title'] ?? '');
657 $target_keyword = (string) ($options['target_keyword'] ?? '');
658 $content_type = (string) ($options['content_type'] ?? 'blog_post');
659
660 $cache_key = 'explain_' . md5($suggestion . '|' . $content . '|' . $title . '|' . $target_keyword . '|' . $content_type);
661 $cached = $this->cache->get($cache_key);
662 if ($cached !== null) {
663 return $cached['data'] ?? $cached;
664 }
665
666 $provider = method_exists($this->client, 'get_provider') ? $this->client->get_provider() : 'openai';
667 $prompt = (new Prompt_Builder())->build_suggestion_explanation_prompt($suggestion, $content, $title, $target_keyword, $content_type, $provider);
668
669 // Give reasoning models (e.g. gpt-5-nano) enough headroom that they don't
670 // burn the whole budget "thinking" and truncate the JSON before the
671 // closing brace, and retry once if the first attempt yields nothing
672 // parseable — mirrors the resilience of the keyword-paragraph path.
673 $explanation = '';
674 $tokens_used = 0;
675 $ai_text = '';
676 for ($attempt = 0; $attempt < 2; $attempt++) {
677 $completion = $this->request_completion($prompt, 4096);
678 $tokens_used += (int) $completion['tokens'];
679 $ai_text = $completion['ai_text'];
680 $candidate = $this->extract_json_field($completion['ai_text'], 'explanation');
681 if ($candidate !== '') {
682 $explanation = $candidate;
683 break;
684 }
685 }
686
687 if ($explanation === '') {
688 throw new \Exception('The AI did not return an explanation. Please try again.');
689 }
690
691 $actual_model = $this->client ? $this->client->get_model() : null;
692 $this->log_ai_usage(get_current_user_id(), 'SEO Suggestion Explanation', (int) $tokens_used, $actual_model, $ai_text);
693
694 $result = ['explanation' => $explanation];
695 $this->cache->set($cache_key, $result);
696
697 return $result;
698 }
699
700 /**
701 * Generate a targeted content fragment that adds one authoritative external
702 * dofollow link, so the scorer's external-dofollow-link check passes.
703 *
704 * @since 1.14.0
705 *
706 * @param string $content Post content for context.
707 * @param array $options { @type string $target_keyword; @type string $content_type; }
708 * @return array{html:string,url:string,anchor:string} HTML paragraph to append.
709 * @throws \Exception If the AI client is unavailable or returns no valid link.
710 */
711 public function generate_dofollow_link(string $content, array $options = []): array {
712 $this->ensure_ready_for_ai();
713
714 $target_keyword = (string) ($options['target_keyword'] ?? '');
715 $content_type = (string) ($options['content_type'] ?? 'blog_post');
716
717 // Cap content server-side (mirror the frontend 5000-char trim) so a
718 // direct REST caller can't force oversized prompt/cache/AI work.
719 $content = mb_substr($content, 0, 5000);
720
721 $cache_key = 'dofollow_' . md5($content . '|' . $target_keyword . '|' . $content_type);
722 $cached = $this->cache->get($cache_key);
723 if ($cached !== null) {
724 return $cached['data'] ?? $cached;
725 }
726
727 $provider = method_exists($this->client, 'get_provider') ? $this->client->get_provider() : 'openai';
728 $prompt = (new Prompt_Builder())->build_dofollow_link_prompt($content, $target_keyword, $content_type, $provider);
729
730 // Larger budget + one retry: reasoning models can truncate the JSON and
731 // yield no URL, which surfaced as "did not return a valid external
732 // source" on the first attempt.
733 $data = [];
734 $tokens_used = 0;
735 $ai_text = '';
736 for ($attempt = 0; $attempt < 2; $attempt++) {
737 $completion = $this->request_completion($prompt, 4096);
738 $tokens_used += (int) $completion['tokens'];
739 $ai_text = $completion['ai_text'];
740 $candidate = $this->extract_json_object($completion['ai_text']);
741 if (is_array($candidate) && !empty($candidate['url'])) {
742 $data = $candidate;
743 break;
744 }
745 }
746
747 $url = isset($data['url']) ? esc_url_raw(trim((string) $data['url'])) : '';
748 $anchor = isset($data['anchor']) ? sanitize_text_field((string) $data['anchor']) : '';
749 $sentence = isset($data['sentence']) ? sanitize_text_field((string) $data['sentence']) : '';
750
751 // Validate: must be a real external http(s) URL pointing off-site.
752 $site_host = wp_parse_url(get_site_url(), PHP_URL_HOST);
753 $link_host = $url !== '' ? wp_parse_url($url, PHP_URL_HOST) : '';
754 $is_external = $url !== '' && preg_match('#^https?://#i', $url) && $link_host && strcasecmp($link_host, (string) $site_host) !== 0;
755 if (!$is_external) {
756 throw new \Exception('The AI did not return a valid external source. Please try again.');
757 }
758 if ($anchor === '') {
759 $anchor = $link_host;
760 }
761 if ($sentence === '') {
762 $sentence = sprintf('For more on this topic, see %s.', $anchor);
763 }
764
765 // Build a dofollow anchor (no rel=nofollow) and weave it into the
766 // sentence by linking the anchor text; append it if the anchor phrase is
767 // not present.
768 $link = sprintf('<a href="%s">%s</a>', esc_url($url), esc_html($anchor));
769 if (stripos($sentence, $anchor) !== false) {
770 $linked = preg_replace('/' . preg_quote($anchor, '/') . '/i', $link, $sentence, 1);
771 } else {
772 $linked = rtrim($sentence, '.') . ' (' . $link . ').';
773 }
774 $html = '<p>' . $linked . '</p>';
775
776 $actual_model = $this->client ? $this->client->get_model() : null;
777 $this->log_ai_usage(get_current_user_id(), 'SEO Dofollow Link', (int) $tokens_used, $actual_model, $ai_text);
778
779 $result = ['html' => $html, 'url' => $url, 'anchor' => $anchor];
780 $this->cache->set($cache_key, $result);
781
782 return $result;
783 }
784
785 /**
786 * Generate a short, relevant closing paragraph that uses the focus keyword
787 * enough times to lift keyword density into the scorer's healthy band
788 * (0.5%-2.5%), returned as an HTML paragraph to append to the content.
789 *
790 * @since 1.14.0
791 *
792 * @param string $content Post content for context.
793 * @param array $options {
794 * @type string $target_keyword;
795 * @type string $content_type;
796 * @type string $tone;
797 * @type int $word_count Current document word count.
798 * @type int $keyword_count Current focus-keyword occurrences.
799 * }
800 * @return array{html:string,mentions:int} HTML paragraph to append.
801 * @throws \Exception If the AI client is unavailable or returns nothing usable.
802 */
803 public function generate_keyword_paragraph(string $content, array $options = []): array {
804 $this->ensure_ready_for_ai();
805
806 $target_keyword = trim((string) ($options['target_keyword'] ?? ''));
807 if ($target_keyword === '') {
808 throw new \Exception('A focus keyword is required to improve keyword density.');
809 }
810 $content_type = (string) ($options['content_type'] ?? 'blog_post');
811 $tone = (string) ($options['tone'] ?? 'professional');
812 $word_count = max(0, (int) ($options['word_count'] ?? 0));
813 $keyword_count = max(0, (int) ($options['keyword_count'] ?? 0));
814
815 // Size the closing section to land just above the 0.5% floor. Solving
816 // (kw + m) / (words + W) >= target for a section that uses ~14 words per
817 // keyword mention (W = 14m) keeps the writing readable rather than
818 // stuffed. Cap mentions so a very long, sparse article doesn't demand an
819 // absurd block — in that case one pass improves density without fully
820 // resolving it, which the caller surfaces honestly.
821 // Target a bit above the 0.5% floor and assume a tight ~11 words per
822 // mention when sizing the request, because models tend to under-deliver
823 // mentions and over-write length — both of which dilute density. The cap
824 // keeps very long, sparse posts from demanding an absurd block; those may
825 // still need a second pass, which the caller surfaces honestly.
826 $target_density = 0.0065;
827 $words_per_mention = 11;
828 $denom_factor = 1 - ($target_density * $words_per_mention); // ~0.928
829 $needed = $denom_factor > 0
830 ? ($target_density * $word_count - $keyword_count) / $denom_factor
831 : 4;
832 $mentions = (int) max(3, min(24, ceil($needed)));
833 $para_words = max(90, $mentions * $words_per_mention);
834
835 // Cap content server-side (mirror the frontend 5000-char trim) so a
836 // direct REST caller can't force oversized prompt/cache/AI work.
837 $content = mb_substr($content, 0, 5000);
838
839 $cache_key = 'kw_para_' . md5($content . '|' . $target_keyword . '|' . $content_type . '|' . $tone . '|' . $mentions . '|' . $para_words);
840 $cached = $this->cache->get($cache_key);
841 if ($cached !== null) {
842 return $cached['data'] ?? $cached;
843 }
844
845 $provider = method_exists($this->client, 'get_provider') ? $this->client->get_provider() : 'openai';
846 $prompt = (new Prompt_Builder())->build_keyword_paragraph_prompt($content, $target_keyword, $content_type, $tone, $mentions, $provider, $para_words);
847
848 // Bigger token budget: the section is long and thinking models burn
849 // output tokens reasoning before writing the JSON. Generation can be
850 // truncated intermittently, yielding a stub — validate and retry once so
851 // we never apply (or cache) a degenerate paragraph.
852 $paragraph = '';
853 $tokens_used = 0;
854 $ai_text = '';
855 for ($attempt = 0; $attempt < 2; $attempt++) {
856 $completion = $this->request_completion($prompt, 4096);
857 $tokens_used += (int) $completion['tokens'];
858 $ai_text = $completion['ai_text'];
859 $candidate = $this->extract_json_field($completion['ai_text'], 'paragraph');
860 if (str_word_count(wp_strip_all_tags($candidate)) >= 40) {
861 $paragraph = $candidate;
862 break;
863 }
864 }
865 if ($paragraph === '') {
866 throw new \Exception('The AI did not return a usable paragraph. Please try again.');
867 }
868
869 // wp_kses keeps it to safe inline markup; wrap as a paragraph block.
870 $paragraph = wp_kses($paragraph, ['a' => ['href' => [], 'title' => []], 'strong' => [], 'em' => []]);
871 $html = '<p>' . $paragraph . '</p>';
872
873 // Report the density this addition achieves so the UI can tell the user
874 // whether the check is now satisfied or needs another pass.
875 $added_words = str_word_count(wp_strip_all_tags($paragraph));
876 $added_mentions = substr_count(strtolower(wp_strip_all_tags($paragraph)), strtolower($target_keyword));
877 $new_density = ($word_count + $added_words) > 0
878 ? (($keyword_count + $added_mentions) / ($word_count + $added_words)) * 100
879 : 0.0;
880 $resolves = $new_density >= 0.5 && $new_density <= 2.5;
881
882 $actual_model = $this->client ? $this->client->get_model() : null;
883 $this->log_ai_usage(get_current_user_id(), 'SEO Keyword Paragraph', $tokens_used, $actual_model, $ai_text);
884
885 $result = [
886 'html' => $html,
887 'mentions' => $added_mentions,
888 'new_density' => round($new_density, 2),
889 'resolves' => $resolves,
890 ];
891 $this->cache->set($cache_key, $result);
892
893 return $result;
894 }
895
896 /**
897 * Shared guard for the lightweight AI helpers: ensure a client is available,
898 * the user has an API key, and the per-minute rate limit is not exceeded.
899 *
900 * @throws \Exception When any precondition fails.
901 */
902 private function ensure_ready_for_ai(): void {
903 if (!$this->client) {
904 $this->initialize_client();
905 if (!$this->client) {
906 throw new \Exception($this->get_client_unavailable_message());
907 }
908 }
909 $user_has_api_key = !empty($this->settings->get('openai_api_key')) || !empty($this->settings->get('claude_api_key')) || !empty($this->settings->get('gemini_api_key')) || !empty($this->settings->get('openrouter_api_key'));
910 if (!$user_has_api_key) {
911 throw new \Exception($this->get_client_unavailable_message());
912 }
913 if (!$this->check_rate_limit()) {
914 throw new \Exception('Rate limit exceeded. Please try again later.');
915 }
916 }
917
918 /**
919 * Decode the first JSON object found in an AI response.
920 *
921 * @param string $ai_text Raw AI text.
922 * @return array|null Decoded object, or null if none parses.
923 */
924 private function extract_json_object(string $ai_text): ?array {
925 $json_start = strpos($ai_text, '{');
926 $json_end = strrpos($ai_text, '}');
927 if ($json_start === false || $json_end === false || $json_end <= $json_start) {
928 return null;
929 }
930 $decoded = json_decode(substr($ai_text, $json_start, $json_end - $json_start + 1), true);
931 return is_array($decoded) ? $decoded : null;
932 }
933
934 /**
935 * Send one prompt to the configured provider and return the raw text plus
936 * token usage, normalising across provider response shapes (mirrors the
937 * content brief generator's multi-provider handling).
938 *
939 * @param string $prompt The prompt to send.
940 * @return array{ai_text:string,tokens:int}
941 */
942 /**
943 * Public plain-text completion against the configured provider.
944 *
945 * Thin gate over request_completion() for callers that need a free-form
946 * answer rather than a structured SEO artifact (e.g. the brand-visibility
947 * checker, which asks the model a user-style question and inspects the
948 * reply). Provider differences are already normalized inside.
949 *
950 * @since 1.27.0
951 *
952 * @param string $prompt Prompt to send.
953 * @param int $max_tokens Output token ceiling.
954 * @return array{ai_text:string,tokens:int}
955 * @throws \Exception When no AI client is configured/available.
956 */
957 public function answer_prompt(string $prompt, int $max_tokens = 1024, array $options = []): array {
958 if (!$this->client) {
959 throw new \Exception(esc_html($this->get_client_unavailable_message()));
960 }
961
962 return $this->request_completion($prompt, $max_tokens, $options);
963 }
964
965 /**
966 * Detect a provider-side refusal or content-policy block and fail with
967 * the real reason. Each provider signals these differently, and none of
968 * the signals set the content field the extraction chain looks for — left
969 * unchecked they read as an empty/unusable result with no explanation of
970 * why, and every caller here retries an empty result once, which just
971 * repeats the same refusal at the cost of more tokens.
972 *
973 * @param array $response Raw response from the AI client.
974 * @throws \Exception If the response is a refusal or policy block.
975 */
976 private function guard_against_refusal(array $response): void {
977 // --- OpenAI (Chat Completions) ---
978 // A structured refusal is HTTP 200 with message.content=null and the
979 // stated reason carried in message.refusal.
980 if (isset($response['choices'][0]['message'])) {
981 $message = $response['choices'][0]['message'];
982 $finish = (string) ($response['choices'][0]['finish_reason'] ?? '');
983
984 if (!empty($message['refusal'])) {
985 throw new \Exception(esc_html('The AI declined this request: ' . (string) $message['refusal']));
986 }
987 if ('content_filter' === $finish) {
988 throw new \Exception('The AI blocked this request under its content policy. Try different wording.');
989 }
990 }
991
992 // --- Claude (Messages) ---
993 if (isset($response['stop_reason']) && 'refusal' === (string) $response['stop_reason']) {
994 throw new \Exception('The AI declined this request. Try different wording.');
995 }
996
997 // --- Gemini ---
998 // A prompt rejected outright returns no candidate at all, only
999 // promptFeedback.blockReason; a candidate can also finish on SAFETY or
1000 // PROHIBITED_CONTENT.
1001 $block_reason = (string) ($response['promptFeedback']['blockReason'] ?? '');
1002 if ('' !== $block_reason) {
1003 throw new \Exception(esc_html(sprintf('The AI blocked this request under its content policy (%s). Try different wording.', $block_reason)));
1004 }
1005 $gemini_finish = (string) ($response['candidates'][0]['finishReason'] ?? '');
1006 if (in_array($gemini_finish, ['SAFETY', 'PROHIBITED_CONTENT'], true)) {
1007 throw new \Exception('The AI blocked this request under its content policy. Try different wording.');
1008 }
1009 }
1010
1011 private function request_completion(string $prompt, int $max_tokens = 2048, array $options = []): array {
1012 // "Thinking" providers (e.g. Gemini 2.5) spend output tokens on reasoning
1013 // before emitting text, so the cap must cover both the reasoning and the
1014 // visible JSON. Longer outputs (paragraphs) need a bigger budget. It's
1015 // only a ceiling — short replies cost no more.
1016 // Extra options (e.g. reasoning_effort) pass through; every client
1017 // cherry-picks the keys it understands and ignores the rest.
1018 $response = $this->client->generate_completion($prompt, array_merge($options, [
1019 'max_tokens' => $max_tokens,
1020 'temperature' => 0.4,
1021 ]));
1022
1023 // Fail fast on a genuine refusal/policy block instead of retrying the
1024 // same prompt (every caller retries on an empty result) and burning
1025 // more tokens on a request the model has already declined. Truncation
1026 // (finish_reason length/max_tokens) is deliberately NOT treated as a
1027 // hard failure here — callers' existing empty-result retries already
1028 // recover from that, and a retry can succeed where the first attempt
1029 // spent its budget on hidden reasoning.
1030 $this->guard_against_refusal($response);
1031
1032 $ai_text = '';
1033 if (isset($response['choices'][0]['message']['content'])) {
1034 $ai_text = is_array($response['choices'][0]['message']['content'])
1035 ? implode(' ', array_map(static fn($part) => is_array($part) ? ($part['text'] ?? '') : (string) $part, $response['choices'][0]['message']['content']))
1036 : (string) $response['choices'][0]['message']['content'];
1037 } elseif (isset($response['content'][0]['text'])) {
1038 $ai_text = (string) $response['content'][0]['text'];
1039 } elseif (isset($response['candidates'][0]['content']['parts'][0]['text'])) {
1040 $ai_text = (string) $response['candidates'][0]['content']['parts'][0]['text'];
1041 } elseif (isset($response['content']) && is_string($response['content'])) {
1042 $ai_text = $response['content'];
1043 }
1044
1045 $tokens = $response['usage']['total_tokens']
1046 ?? $response['usage']['output_tokens']
1047 ?? ($response['usageMetadata']['totalTokenCount'] ?? 0);
1048
1049 // Diagnostics for callers that must explain an empty answer (e.g. the
1050 // brand-visibility probe): why generation stopped, and how much of the
1051 // completion budget hidden reasoning consumed (OpenAI reasoning models).
1052 // All three provider shapes are read — Gemini reports the stop reason
1053 // per candidate, so without that arm the diagnostic was always blank
1054 // for exactly the provider whose truncation it exists to explain.
1055 $finish_reason = (string) ($response['choices'][0]['finish_reason']
1056 ?? ($response['stop_reason']
1057 ?? ($response['candidates'][0]['finishReason'] ?? '')));
1058 $reasoning_tokens = (int) ($response['usage']['completion_tokens_details']['reasoning_tokens'] ?? 0);
1059
1060 return [
1061 'ai_text' => $ai_text,
1062 'tokens' => (int) $tokens,
1063 'finish_reason' => $finish_reason,
1064 'reasoning_tokens' => $reasoning_tokens,
1065 ];
1066 }
1067
1068 /**
1069 * Trim a meta description to at most 160 characters at a word boundary,
1070 * preserving sentence-ish endings and avoiding broken words. Descriptions of
1071 * 160 characters or fewer are returned unchanged.
1072 *
1073 * @param string $desc Meta description.
1074 * @return string Description clamped to <= 160 characters.
1075 */
1076 private function clamp_meta_description(string $desc): string {
1077 $desc = trim($desc);
1078 if (mb_strlen($desc) <= 160) {
1079 return $desc;
1080 }
1081
1082 $cut = mb_substr($desc, 0, 160);
1083 $last_space = mb_strrpos($cut, ' ');
1084 // Only back off to the last space when doing so keeps us at/above 120.
1085 if ($last_space !== false && $last_space >= 120) {
1086 $cut = mb_substr($cut, 0, $last_space);
1087 }
1088
1089 return rtrim($cut, " \t\n\r\0\x0B,;:-");
1090 }
1091
1092 /**
1093 * Whether a title contains any of the given words, using the same
1094 * case-insensitive substring match the scorer's title checks use.
1095 *
1096 * @param string $title Title to test.
1097 * @param string[] $words Words to look for.
1098 * @return bool
1099 */
1100 private function title_contains_word(string $title, array $words): bool {
1101 $title_lower = strtolower($title);
1102 foreach ($words as $word) {
1103 if ($word !== '' && strpos($title_lower, strtolower($word)) !== false) {
1104 return true;
1105 }
1106 }
1107 return false;
1108 }
1109
1110 /**
1111 * Pull a named string field out of an AI response, tolerating both JSON and
1112 * plain-text replies.
1113 *
1114 * @param string $ai_text Raw AI text.
1115 * @param string $field JSON field to read (e.g. 'title', 'description').
1116 * @return string Sanitized value (without surrounding quotes), or '' on failure.
1117 */
1118 private function extract_json_field(string $ai_text, string $field): string {
1119 $ai_text = trim($ai_text);
1120 if ($ai_text === '') {
1121 return '';
1122 }
1123
1124 // Prefer a JSON object with the requested field.
1125 $json_start = strpos($ai_text, '{');
1126 $json_end = strrpos($ai_text, '}');
1127 if ($json_start !== false && $json_end !== false && $json_end > $json_start) {
1128 $decoded = json_decode(substr($ai_text, $json_start, $json_end - $json_start + 1), true);
1129 if (is_array($decoded) && !empty($decoded[$field])) {
1130 return sanitize_text_field(trim((string) $decoded[$field], " \t\n\r\0\x0B\"'"));
1131 }
1132 }
1133
1134 // Fall back to the first non-empty line, stripping wrapping quotes.
1135 $first_line = strtok($ai_text, "\n");
1136 return sanitize_text_field(trim((string) $first_line, " \t\n\r\0\x0B\"'"));
1137 }
1138
1139 /**
1140 * Analyze content for SEO optimization
1141 *
1142 * @param string $content Content to analyze
1143 * @param array $metadata Existing metadata
1144 * @return array Analysis results
1145 * @throws \Exception If analysis fails
1146 */
1147 public function analyze_content(string $content, array $metadata = []): array {
1148 if (!$this->client) {
1149 throw new \Exception($this->get_client_unavailable_message());
1150 }
1151
1152 $user_id = get_current_user_id();
1153
1154 // Ensure user has configured their API key
1155 $user_has_api_key = !empty($this->settings->get('openai_api_key')) || !empty($this->settings->get('claude_api_key')) || !empty($this->settings->get('gemini_api_key')) || !empty($this->settings->get('openrouter_api_key'));
1156
1157 if (!$user_has_api_key) {
1158 throw new \Exception($this->get_client_unavailable_message());
1159 }
1160
1161 // Check rate limits
1162 if (!$this->check_rate_limit($user_id, 'content_analysis')) {
1163 throw new \Exception('Rate limit exceeded. Please try again later.');
1164 }
1165
1166 // Check cache first
1167 $cache_key = 'content_analysis_' . md5($content . wp_json_encode($metadata));
1168 $cached_result = $this->cache->get($cache_key);
1169 if ($cached_result) {
1170 return $cached_result['data'] ?? $cached_result;
1171 }
1172
1173 try {
1174 // Analyze content using AI
1175 $analysis = $this->client->analyze_content($content, $metadata);
1176
1177 // Cache the result
1178 $this->cache->set($cache_key, $analysis);
1179
1180 // Log usage with actual model information and raw AI text (Content Brief pattern)
1181 $actual_model = $this->client ? $this->client->get_model() : null;
1182 $ai_text = $analysis['_ai_text'] ?? null;
1183 $this->log_ai_usage($user_id, 'Content Analysis', $analysis['tokens_used'] ?? 0, $actual_model, $ai_text);
1184
1185 // Remove AI text from returned data to keep it clean
1186 unset($analysis['_ai_text']);
1187
1188 return $analysis;
1189
1190 } catch (\Exception $e) {
1191 throw $e;
1192 }
1193 }
1194
1195 /**
1196 * Test API connection
1197 *
1198 * @return array Test result
1199 */
1200 public function test_api_connection(): array {
1201 if (!$this->client) {
1202 return [
1203 'success' => false,
1204 'message' => $this->get_client_unavailable_message(),
1205 ];
1206 }
1207
1208 try {
1209 $success = $this->client->test_connection();
1210
1211 return [
1212 'success' => $success,
1213 'message' => $success
1214 ? 'API connection successful!'
1215 : 'API connection failed. Please check your API key.',
1216 ];
1217
1218 } catch (\Exception $e) {
1219 return [
1220 'success' => false,
1221 'message' => 'Connection test failed: ' . $e->getMessage(),
1222 ];
1223 }
1224 }
1225
1226 /**
1227 * Optimize site identity using AI
1228 *
1229 * @since 1.0.0
1230 *
1231 * @param array $site_data Site data to optimize
1232 * @param array $options Optimization options
1233 * @return array Optimization results
1234 * @throws \Exception If optimization fails
1235 */
1236 public function optimize_site_identity(array $site_data, array $options = []): array {
1237 // Validate input
1238 if (empty($site_data)) {
1239 throw new \Exception('Site data cannot be empty');
1240 }
1241
1242 $user_id = get_current_user_id();
1243
1244 // Ensure user has configured their API key
1245 $user_has_api_key = !empty($this->settings->get('openai_api_key')) || !empty($this->settings->get('claude_api_key')) || !empty($this->settings->get('gemini_api_key')) || !empty($this->settings->get('openrouter_api_key'));
1246
1247 if (!$user_has_api_key) {
1248 throw new \Exception($this->get_client_unavailable_message());
1249 }
1250
1251 // Generate cache key using existing pattern
1252 $cache_key = 'site_identity_' . md5(wp_json_encode($site_data) . wp_json_encode($options)) . '_' . $user_id;
1253
1254 // Check existing cache infrastructure
1255 // Cache_Manager::set() wraps entries as ['data' => …], so unwrap
1256 // before inspecting — checking optimized_data on the wrapped array
1257 // never matches and the cache would never hit.
1258 $cached_result = $this->cache->get($cache_key);
1259 $cached_result = $cached_result['data'] ?? $cached_result;
1260 if (!empty($cached_result['optimized_data'])) {
1261 return $cached_result;
1262 }
1263
1264 // Check rate limiting
1265 if (!$this->check_rate_limit()) {
1266 throw new \Exception('Rate limit exceeded for AI optimization requests.');
1267 }
1268
1269 // Get AI client
1270 $client = $this->get_client();
1271
1272 if (!$client) {
1273 throw new \Exception($this->get_client_unavailable_message());
1274 }
1275
1276 // Perform AI optimization
1277 $optimization_results = $client->optimize_site_identity($site_data, $options);
1278
1279 // Validate that we got meaningful results
1280 if (empty($optimization_results) || empty($optimization_results['optimized_data'])) {
1281 throw new \Exception('AI optimization returned empty results. Please try again.');
1282 }
1283
1284 // Add metadata
1285 $optimization_results['ai_model'] = $client->get_model();
1286 $optimization_results['provider'] = $this->settings->get('ai_provider', 'openai');
1287 $optimization_results['generated_at'] = gmdate('Y-m-d H:i:s');
1288 $optimization_results['user_id'] = $user_id;
1289
1290 // Cache the results (24 hours)
1291 $this->cache->set($cache_key, $optimization_results, 86400);
1292
1293 // Record usage with actual model information and raw AI text (Content Brief pattern)
1294 $actual_model = $optimization_results['ai_model'] ?? ($client ? $client->get_model() : null);
1295 $ai_text = $optimization_results['_ai_text'] ?? null;
1296 $this->log_ai_usage($user_id, 'Site Identity Optimization', $optimization_results['tokens_used'] ?? 0, $actual_model, $ai_text);
1297
1298 // Remove AI text from returned data to keep it clean
1299 unset($optimization_results['_ai_text']);
1300
1301 return $optimization_results;
1302 }
1303
1304 /**
1305 * Optimize LLMs.txt content using AI
1306 *
1307 * @since 1.0.0
1308 *
1309 * @param array $website_data Website data to optimize
1310 * @param array $options Optimization options
1311 * @return array Optimization results
1312 * @throws \Exception If optimization fails
1313 */
1314 public function optimize_llms_txt(array $website_data, array $options = []): array {
1315 // Validate input
1316 if (empty($website_data)) {
1317 throw new \Exception('Website data cannot be empty');
1318 }
1319
1320 $user_id = get_current_user_id();
1321
1322 // Ensure user has configured their API key
1323 $user_has_api_key = !empty($this->settings->get('openai_api_key')) || !empty($this->settings->get('claude_api_key')) || !empty($this->settings->get('gemini_api_key')) || !empty($this->settings->get('openrouter_api_key'));
1324
1325 if (!$user_has_api_key) {
1326 throw new \Exception($this->get_client_unavailable_message());
1327 }
1328
1329 // Generate cache key
1330 $cache_key = 'llms_txt_' . md5(wp_json_encode($website_data) . wp_json_encode($options)) . '_' . $user_id;
1331
1332 // Check cache first
1333 // Cache_Manager::set() wraps entries as ['data' => …], so unwrap
1334 // before inspecting — checking optimized_data on the wrapped array
1335 // never matches and the cache would never hit.
1336 $cached_result = $this->cache->get($cache_key);
1337 $cached_result = $cached_result['data'] ?? $cached_result;
1338 if (!empty($cached_result['optimized_data'])) {
1339 return $cached_result;
1340 }
1341
1342 // Check rate limiting
1343 if (!$this->check_rate_limit()) {
1344 throw new \Exception('Rate limit exceeded for AI optimization requests.');
1345 }
1346
1347 // Get AI client
1348 $client = $this->get_client();
1349
1350 if (!$client) {
1351 throw new \Exception($this->get_client_unavailable_message());
1352 }
1353
1354 // Perform AI optimization
1355 $optimization_results = $client->optimize_llms_txt($website_data, $options);
1356
1357 // Validate that we got meaningful results
1358 if (empty($optimization_results) || empty($optimization_results['optimized_data'])) {
1359 throw new \Exception('AI optimization returned empty results. Please try again.');
1360 }
1361
1362 // Add metadata
1363 $optimization_results['ai_model'] = $client->get_model();
1364 $optimization_results['provider'] = $this->settings->get('ai_provider', 'openai');
1365 $optimization_results['generated_at'] = gmdate('Y-m-d H:i:s');
1366 $optimization_results['user_id'] = $user_id;
1367
1368 // Cache the results (24 hours)
1369 $this->cache->set($cache_key, $optimization_results, 86400);
1370
1371 // Record usage with actual model information and raw AI text (Content Brief pattern)
1372 $actual_model = $optimization_results['ai_model'] ?? ($client ? $client->get_model() : null);
1373 $ai_text = $optimization_results['_ai_text'] ?? null;
1374 $this->log_ai_usage($user_id, 'LLMs.txt Optimization', $optimization_results['tokens_used'] ?? 0, $actual_model, $ai_text);
1375
1376 // Remove AI text from returned data to keep it clean
1377 unset($optimization_results['_ai_text']);
1378
1379 return $optimization_results;
1380 }
1381
1382 /**
1383 * Get available AI providers
1384 *
1385 * @return array Available providers
1386 */
1387 public function get_available_providers(): array {
1388 return [
1389 'openai' => [
1390 'name' => 'OpenAI',
1391 'description' => 'GPT‑5 series and GPT‑4o',
1392 'models' => ['gpt-5-nano', 'gpt-5-mini', 'gpt-5', 'gpt-4o'],
1393 'requires_key' => true,
1394 ],
1395 'claude' => [
1396 'name' => 'Claude (Anthropic)',
1397 'description' => 'Claude Opus 4.8, Sonnet 5, and Haiku 4.5',
1398 'models' => ['claude-opus-4-8', 'claude-sonnet-5', 'claude-haiku-4-5'],
1399 'requires_key' => true,
1400 ],
1401 'gemini' => [
1402 'name' => 'Google Gemini',
1403 'description' => 'Gemini 3.x and 2.x models',
1404 'models' => ['gemini-3.1-pro', 'gemini-3.5-flash', 'gemini-3.1-flash-lite', 'gemini-2.5-flash-lite', 'gemini-2.5-pro'],
1405 'requires_key' => true,
1406 ],
1407 'openrouter' => [
1408 'name' => 'OpenRouter',
1409 'description' => 'Unified access to many models via one key',
1410 'models' => ['openai/gpt-4o-mini', 'anthropic/claude-3.5-sonnet', 'google/gemini-2.0-flash-001', 'meta-llama/llama-3.3-70b-instruct', 'deepseek/deepseek-chat'],
1411 'requires_key' => true,
1412 ],
1413 ];
1414 }
1415
1416 /**
1417 * Get current provider status
1418 *
1419 * @return array Provider status
1420 */
1421 public function get_provider_status(): array {
1422 $provider = $this->settings->get('ai_provider', 'openai');
1423 $api_key = $this->settings->get($provider . '_api_key');
1424
1425 return [
1426 'provider' => $provider,
1427 'configured' => !empty($api_key),
1428 'connected' => $this->client !== null,
1429 ];
1430 }
1431
1432 /**
1433 * AJAX handler for generating metadata
1434 *
1435 * @return void
1436 */
1437 public function ajax_generate_metadata(): void {
1438 // Verify nonce
1439 $nonce = sanitize_text_field(wp_unslash($_POST['nonce'] ?? ''));
1440 if (!wp_verify_nonce($nonce, 'thinkrank_ai_nonce')) {
1441 wp_die('Security check failed');
1442 }
1443
1444 // Check permissions
1445 if (!current_user_can('edit_posts')) {
1446 wp_die('Insufficient permissions');
1447 }
1448
1449 $content = sanitize_textarea_field(wp_unslash($_POST['content'] ?? ''));
1450 $options = [
1451 'target_keyword' => sanitize_text_field(wp_unslash($_POST['target_keyword'] ?? '')),
1452 'content_type' => sanitize_text_field(wp_unslash($_POST['content_type'] ?? 'blog_post')),
1453 'tone' => sanitize_text_field(wp_unslash($_POST['tone'] ?? 'professional')),
1454 ];
1455
1456 try {
1457 $metadata = $this->generate_seo_metadata($content, $options);
1458
1459 wp_send_json_success([
1460 'metadata' => $metadata,
1461 'message' => __('SEO metadata generated successfully!', 'thinkrank'),
1462 ]);
1463
1464 } catch (\Exception $e) {
1465 wp_send_json_error([
1466 'message' => $e->getMessage(),
1467 ]);
1468 }
1469 }
1470
1471 /**
1472 * AJAX handler for testing API connection
1473 *
1474 * @return void
1475 */
1476 public function ajax_test_connection(): void {
1477 // Verify nonce
1478 $nonce = sanitize_text_field(wp_unslash($_POST['nonce'] ?? ''));
1479 if (!wp_verify_nonce($nonce, 'thinkrank_ai_nonce')) {
1480 wp_die('Security check failed');
1481 }
1482
1483 // Check permissions
1484 if (!current_user_can('manage_options')) {
1485 wp_die('Insufficient permissions');
1486 }
1487
1488 $result = $this->test_api_connection();
1489
1490 if ($result['success']) {
1491 wp_send_json_success($result);
1492 } else {
1493 wp_send_json_error($result);
1494 }
1495 }
1496
1497 /**
1498 * Check rate limits.
1499 *
1500 * Backed by a per-minute transient counter so the limit is enforced across
1501 * requests. A fresh Manager is constructed on every AJAX/REST call, so the
1502 * previous in-memory array always started empty and never limited anything —
1503 * letting an edit_posts user loop the metadata AJAX and drive unbounded paid
1504 * AI-provider spend.
1505 *
1506 * @param int|null $user_id Optional user id (defaults to the current user).
1507 * @param string $context Rate-limit bucket (keeps distinct flows separate).
1508 * @return bool True if within limits.
1509 */
1510 private function check_rate_limit(?int $user_id = null, string $context = 'ai'): bool {
1511 $user_id = $user_id ?? get_current_user_id();
1512 $max_requests = (int) $this->settings->get('max_requests_per_minute', 10);
1513
1514 // A non-positive limit means "unlimited".
1515 if ($max_requests <= 0) {
1516 return true;
1517 }
1518
1519 // Counter is keyed to the current wall-clock minute; the transient TTL
1520 // lets the window roll over on its own.
1521 $minute_key = "thinkrank_ai_rate_{$context}_{$user_id}_" . floor(time() / MINUTE_IN_SECONDS);
1522 $attempts = (int) get_transient($minute_key);
1523
1524 if ($attempts >= $max_requests) {
1525 return false;
1526 }
1527
1528 set_transient($minute_key, $attempts + 1, MINUTE_IN_SECONDS);
1529
1530 return true;
1531 }
1532
1533
1534
1535 /**
1536 * Log AI usage with actual model information
1537 *
1538 * @param int $user_id User ID
1539 * @param string $action Action performed
1540 * @param int $tokens_used Tokens consumed
1541 * @param string|null $actual_model Actual model used (from AI response)
1542 * @param string|null $raw_response Raw AI response for debugging
1543 * @return void
1544 */
1545 private function log_ai_usage(int $user_id, string $action, int $tokens_used, ?string $actual_model = null, ?string $raw_response = null): void {
1546 global $wpdb;
1547
1548 $table_name = $wpdb->prefix . 'thinkrank_ai_usage';
1549
1550 // Prepare metadata with actual model information and raw response
1551 $metadata = [];
1552 if ($actual_model) {
1553 $metadata['actual_model'] = $actual_model;
1554 }
1555 if ($raw_response) {
1556 $metadata['raw_response'] = $raw_response;
1557 }
1558
1559 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- AI usage logging requires direct database access
1560 $wpdb->insert(
1561 $table_name,
1562 [
1563 'user_id' => $user_id,
1564 'action' => $action,
1565 'tokens_used' => $tokens_used,
1566 'provider' => $this->settings->get('ai_provider', 'openai'),
1567 'metadata' => !empty($metadata) ? wp_json_encode($metadata) : null,
1568 'created_at' => current_time('mysql'),
1569 ],
1570 ['%d', '%s', '%d', '%s', '%s', '%s']
1571 );
1572 }
1573
1574 /**
1575 * Cleanup expired cache entries
1576 *
1577 * @return void
1578 */
1579 public function cleanup_cache(): void {
1580 $this->cache->clean_expired();
1581 }
1582
1583 /**
1584 * Optimize homepage meta content using AI (copying Site Identity pattern exactly)
1585 *
1586 * @since 1.0.0
1587 *
1588 * @param array $content_data Meta content data to optimize
1589 * @param array $options Optimization options
1590 * @return array Optimization results
1591 * @throws \Exception If optimization fails
1592 */
1593 public function optimize_homepage_meta(array $content_data, array $options = []): array {
1594 // Validate input
1595 if (empty($content_data)) {
1596 throw new \Exception('Content data cannot be empty');
1597 }
1598
1599 $user_id = get_current_user_id();
1600
1601 // Ensure user has configured their API key (copying Site Identity pattern)
1602 $user_has_api_key = !empty($this->settings->get('openai_api_key')) || !empty($this->settings->get('claude_api_key')) || !empty($this->settings->get('gemini_api_key')) || !empty($this->settings->get('openrouter_api_key'));
1603
1604 if (!$user_has_api_key) {
1605 throw new \Exception($this->get_client_unavailable_message());
1606 }
1607
1608 // Generate cache key using existing pattern
1609 $cache_key = 'homepage_meta_' . md5(wp_json_encode($content_data) . wp_json_encode($options)) . '_' . $user_id;
1610
1611 // Check existing cache infrastructure
1612 // Cache_Manager::set() wraps entries as ['data' => …], so unwrap
1613 // before inspecting — checking optimized_data on the wrapped array
1614 // never matches and the cache would never hit.
1615 $cached_result = $this->cache->get($cache_key);
1616 $cached_result = $cached_result['data'] ?? $cached_result;
1617 if (!empty($cached_result['optimized_data'])) {
1618 return $cached_result;
1619 }
1620
1621 // Check rate limiting
1622 if (!$this->check_rate_limit()) {
1623 throw new \Exception('Rate limit exceeded for AI optimization requests.');
1624 }
1625
1626 // Get AI client
1627 $client = $this->get_client();
1628
1629 if (!$client) {
1630 throw new \Exception($this->get_client_unavailable_message());
1631 }
1632
1633 // Perform AI optimization
1634 $optimization_results = $client->optimize_homepage_meta($content_data, $options);
1635
1636 // Validate that we got meaningful results
1637 if (empty($optimization_results) || empty($optimization_results['optimized_data'])) {
1638 throw new \Exception('AI optimization returned empty results. Please try again.');
1639 }
1640
1641 // Add metadata
1642 $optimization_results['ai_model'] = $client->get_model();
1643 $optimization_results['provider'] = $this->settings->get('ai_provider', 'openai');
1644 $optimization_results['generated_at'] = gmdate('Y-m-d H:i:s');
1645 $optimization_results['user_id'] = $user_id;
1646
1647 // Cache the results (24 hours)
1648 $this->cache->set($cache_key, $optimization_results, 86400);
1649
1650 // Record usage with actual model information and raw AI text (Content Brief pattern)
1651 $actual_model = $optimization_results['ai_model'] ?? ($client ? $client->get_model() : null);
1652 $ai_text = $optimization_results['_ai_text'] ?? null;
1653 $this->log_ai_usage($user_id, 'Homepage Meta Optimization', $optimization_results['tokens_used'] ?? 0, $actual_model, $ai_text);
1654
1655 // Remove AI text from returned data to keep it clean
1656 unset($optimization_results['_ai_text']);
1657
1658 return $optimization_results;
1659 }
1660
1661 /**
1662 * Optimize homepage hero content using AI (copying Site Identity pattern exactly)
1663 *
1664 * @since 1.0.0
1665 *
1666 * @param array $hero_data Hero content data to optimize
1667 * @param array $options Optimization options
1668 * @return array Optimization results
1669 * @throws \Exception If optimization fails
1670 */
1671 public function optimize_homepage_hero(array $hero_data, array $options = []): array {
1672 // Validate input
1673 if (empty($hero_data)) {
1674 throw new \Exception('Hero data cannot be empty');
1675 }
1676
1677 $user_id = get_current_user_id();
1678
1679 // Ensure user has configured their API key (copying Site Identity pattern)
1680 $user_has_api_key = !empty($this->settings->get('openai_api_key')) || !empty($this->settings->get('claude_api_key')) || !empty($this->settings->get('gemini_api_key')) || !empty($this->settings->get('openrouter_api_key'));
1681
1682 if (!$user_has_api_key) {
1683 throw new \Exception($this->get_client_unavailable_message());
1684 }
1685
1686 // Generate cache key using existing pattern
1687 $cache_key = 'homepage_hero_' . md5(wp_json_encode($hero_data) . wp_json_encode($options)) . '_' . $user_id;
1688
1689 // Check existing cache infrastructure
1690 // Cache_Manager::set() wraps entries as ['data' => …], so unwrap
1691 // before inspecting — checking optimized_data on the wrapped array
1692 // never matches and the cache would never hit.
1693 $cached_result = $this->cache->get($cache_key);
1694 $cached_result = $cached_result['data'] ?? $cached_result;
1695 if (!empty($cached_result['optimized_data'])) {
1696 return $cached_result;
1697 }
1698
1699 // Check rate limiting
1700 if (!$this->check_rate_limit()) {
1701 throw new \Exception('Rate limit exceeded for AI optimization requests.');
1702 }
1703
1704 // Get AI client
1705 $client = $this->get_client();
1706
1707 if (!$client) {
1708 throw new \Exception($this->get_client_unavailable_message());
1709 }
1710
1711 // Perform AI optimization
1712 $optimization_results = $client->optimize_homepage_hero($hero_data, $options);
1713
1714 // Validate that we got meaningful results
1715 if (empty($optimization_results) || empty($optimization_results['optimized_data'])) {
1716 throw new \Exception('AI optimization returned empty results. Please try again.');
1717 }
1718
1719 // Add metadata
1720 $optimization_results['ai_model'] = $client->get_model();
1721 $optimization_results['provider'] = $this->settings->get('ai_provider', 'openai');
1722 $optimization_results['generated_at'] = gmdate('Y-m-d H:i:s');
1723 $optimization_results['user_id'] = $user_id;
1724
1725 // Cache the results (24 hours)
1726 $this->cache->set($cache_key, $optimization_results, 86400);
1727
1728 // Record usage with actual model information and raw AI text (Content Brief pattern)
1729 $actual_model = $optimization_results['ai_model'] ?? ($client ? $client->get_model() : null);
1730 $ai_text = $optimization_results['_ai_text'] ?? null;
1731 $this->log_ai_usage($user_id, 'Homepage Hero Optimization', $optimization_results['tokens_used'] ?? 0, $actual_model, $ai_text);
1732
1733 // Remove AI text from returned data to keep it clean
1734 unset($optimization_results['_ai_text']);
1735
1736 return $optimization_results;
1737 }
1738 }
1739