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-manager.php

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

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