PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.7.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.7.0
2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 All 48 releases
← All changes | includes/ai/class-manager.php +1028 -94 1.0.12.7.0 View file →
@@ -51,14 +51,8 @@
51 51 * @var OpenAI_Client|Claude_Client|null
52 52 */
53 53 private $client = null;
54 54
55 - /**
56 - * Rate limiter
57 - *
58 - * @var array
59 - */
60 - private array $rate_limits = [];
61 55
62 56 /**
63 57 * Constructor
64 58 *
@@ -64,9 +58,9 @@
64 58 *
65 59 * @param Settings|null $settings Settings instance
66 60 */
67 61 public function __construct(?Settings $settings = null) {
68 - $this->settings = $settings ?? new Settings();
62 + $this->settings = $settings ?? Settings::instance();
69 63 $this->cache = new Cache_Manager((int) $this->settings->get('cache_duration', 3600));
70 64 }
71 65
72 66 /**
@@ -89,25 +83,37 @@
89 83 /**
90 84 * Initialize AI client
91 85 *
92 86 * @return void
87 + *
88 + * @throws \Exception On failure.
93 89 */
94 90 public function initialize_client(): void {
95 - $provider = $this->settings->get('ai_provider', 'openai');
91 + $provider = $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
96 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 +
97 100 try {
98 101 switch ($provider) {
99 102 case 'openai':
100 103 $api_key = $this->settings->get('openai_api_key');
101 104 if ($api_key) {
102 - $model = $this->settings->get('openai_model', 'gpt-5-nano');
103 -
104 - $available_models = $this->get_available_providers()['openai']['models'];
105 - if (!in_array($model, $available_models, true)) {
106 - $model = 'gpt-5-nano';
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;
107 110 }
108 - // Use 120-second timeout for complex AI operations
109 - $timeout = 120;
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;
110 116 $this->client = new OpenAI_Client($api_key, $model, $timeout);
111 117
112 118 // OpenAI client created successfully
113 119 }
@@ -115,13 +121,13 @@
115 121
116 122 case 'claude':
117 123 $api_key = $this->settings->get('claude_api_key');
118 124 if ($api_key) {
119 - $model = $this->settings->get('claude_model', 'claude-3-7-sonnet-latest');
120 -
121 - $available_models = $this->get_available_providers()['claude']['models'];
122 - if (!in_array($model, $available_models, true)) {
123 - $model = 'claude-3-7-sonnet-latest';
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;
124 130 }
125 131 // Use 120-second timeout for complex AI operations
126 132 $timeout = 120;
127 133 $this->client = new Claude_Client($api_key, $model, $timeout);
@@ -132,17 +138,32 @@
132 138
133 139 case 'gemini':
134 140 $api_key = $this->settings->get('gemini_api_key');
135 141 if ($api_key) {
136 - $model = $this->settings->get('gemini_model', 'gemini-2.5-flash');
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;
137 153
138 - $available_models = $this->get_available_providers()['gemini']['models'];
139 - if (!in_array($model, $available_models, true)) {
140 - $model = 'gemini-2.5-flash';
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;
141 162 }
142 163 // Use 120-second timeout for complex AI operations
143 164 $timeout = 120;
144 - $this->client = new Gemini_Client($api_key, $model, $timeout);
165 + $this->client = new OpenRouter_Client($api_key, $model, $timeout);
145 166 }
146 167 break;
147 168
148 169 default:
@@ -148,13 +169,83 @@
148 169 default:
149 170 throw new \Exception("Unsupported AI provider: {$provider}");
150 171 }
151 172 } catch (\Exception $e) {
152 - // AI client initialization failed, will be handled later
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 + }
153 180 }
154 181 }
155 182
156 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 + /**
157 248 * Force re-initialization of client (useful after settings change)
158 249 *
159 250 * @return void
160 251 */
@@ -176,9 +267,9 @@
176 267 }
177 268
178 269 // If still not available, throw error
179 270 if (!$this->client) {
180 - throw new \Exception('AI client not initialized. Please configure your API key.');
271 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
181 272 }
182 273
183 274 return $this->client;
184 275 }
@@ -197,9 +288,9 @@
197 288 $this->initialize_client();
198 289
199 290 // If still not available, throw error
200 291 if (!$this->client) {
201 - throw new \Exception('AI client not initialized. Please configure your API key.');
292 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
202 293 }
203 294 }
204 295
205 296 // Check rate limits
@@ -223,12 +314,12 @@
223 314 // Generate metadata using AI
224 315 $metadata = $this->client->generate_seo_metadata($content, $options);
225 316
226 317 // Ensure user has configured their API key
227 - $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'));
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'));
228 319
229 320 if (!$user_has_api_key) {
230 - throw new \Exception('Please configure your OpenAI, Claude, or Gemini API key in ThinkRank settings to use AI features.');
321 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
231 322 }
232 323
233 324 // Cache the result
234 325 $this->cache->set($cache_key, $metadata);
@@ -248,8 +339,807 @@
248 339 }
249 340 }
250 341
251 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 + /**
252 1142 * Analyze content for SEO optimization
253 1143 *
254 1144 * @param string $content Content to analyze
255 1145 * @param array $metadata Existing metadata
@@ -257,18 +1147,18 @@
257 1147 * @throws \Exception If analysis fails
258 1148 */
259 1149 public function analyze_content(string $content, array $metadata = []): array {
260 1150 if (!$this->client) {
261 - throw new \Exception('AI client not initialized');
1151 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
262 1152 }
263 1153
264 1154 $user_id = get_current_user_id();
265 1155
266 1156 // Ensure user has configured their API key
267 - $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'));
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'));
268 1158
269 1159 if (!$user_has_api_key) {
270 - throw new \Exception('Please configure your OpenAI, Claude, or Gemini API key in ThinkRank settings to use AI features.');
1160 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
271 1161 }
272 1162
273 1163 // Check rate limits
274 1164 if (!$this->check_rate_limit($user_id, 'content_analysis')) {
@@ -275,12 +1165,12 @@
275 1165 throw new \Exception('Rate limit exceeded. Please try again later.');
276 1166 }
277 1167
278 1168 // Check cache first
279 - $cache_key = 'content_analysis_' . md5($content . serialize($metadata));
1169 + $cache_key = 'content_analysis_' . md5($content . wp_json_encode($metadata));
280 1170 $cached_result = $this->cache->get($cache_key);
281 1171 if ($cached_result) {
282 - return $cached_result;
1172 + return $cached_result['data'] ?? $cached_result;
283 1173 }
284 1174
285 1175 try {
286 1176 // Analyze content using AI
@@ -312,9 +1202,9 @@
312 1202 public function test_api_connection(): array {
313 1203 if (!$this->client) {
314 1204 return [
315 1205 'success' => false,
316 - 'message' => 'AI client not initialized. Please configure your API key.',
1206 + 'message' => $this->get_client_unavailable_message(),
317 1207 ];
318 1208 }
319 1209
320 1210 try {
@@ -353,20 +1243,24 @@
353 1243
354 1244 $user_id = get_current_user_id();
355 1245
356 1246 // Ensure user has configured their API key
357 - $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'));
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'));
358 1248
359 1249 if (!$user_has_api_key) {
360 - throw new \Exception('Please configure your OpenAI, Claude, or Gemini API key in ThinkRank settings to use AI features.');
1250 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
361 1251 }
362 1252
363 1253 // Generate cache key using existing pattern
364 - $cache_key = 'site_identity_' . md5(serialize($site_data) . serialize($options)) . '_' . $user_id;
1254 + $cache_key = 'site_identity_' . md5(wp_json_encode($site_data) . wp_json_encode($options)) . '_' . $user_id;
365 1255
366 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.
367 1260 $cached_result = $this->cache->get($cache_key);
368 - if ($cached_result !== null && !empty($cached_result['optimized_data'])) {
1261 + $cached_result = $cached_result['data'] ?? $cached_result;
1262 + if (!empty($cached_result['optimized_data'])) {
369 1263 return $cached_result;
370 1264 }
371 1265
372 1266 // Check rate limiting
@@ -377,9 +1271,9 @@
377 1271 // Get AI client
378 1272 $client = $this->get_client();
379 1273
380 1274 if (!$client) {
381 - throw new \Exception('AI client not initialized. Please check your API key configuration.');
1275 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
382 1276 }
383 1277
384 1278 // Perform AI optimization
385 1279 $optimization_results = $client->optimize_site_identity($site_data, $options);
@@ -390,9 +1284,9 @@
390 1284 }
391 1285
392 1286 // Add metadata
393 1287 $optimization_results['ai_model'] = $client->get_model();
394 - $optimization_results['provider'] = $this->settings->get('ai_provider', 'openai');
1288 + $optimization_results['provider'] = $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
395 1289 $optimization_results['generated_at'] = gmdate('Y-m-d H:i:s');
396 1290 $optimization_results['user_id'] = $user_id;
397 1291
398 1292 // Cache the results (24 hours)
@@ -427,20 +1321,24 @@
427 1321
428 1322 $user_id = get_current_user_id();
429 1323
430 1324 // Ensure user has configured their API key
431 - $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'));
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'));
432 1326
433 1327 if (!$user_has_api_key) {
434 - throw new \Exception('Please configure your OpenAI, Claude, or Gemini API key in ThinkRank settings to use AI features.');
1328 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
435 1329 }
436 1330
437 1331 // Generate cache key
438 - $cache_key = 'llms_txt_' . md5(serialize($website_data) . serialize($options)) . '_' . $user_id;
1332 + $cache_key = 'llms_txt_' . md5(wp_json_encode($website_data) . wp_json_encode($options)) . '_' . $user_id;
439 1333
440 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.
441 1338 $cached_result = $this->cache->get($cache_key);
442 - if ($cached_result !== null && !empty($cached_result['optimized_data'])) {
1339 + $cached_result = $cached_result['data'] ?? $cached_result;
1340 + if (!empty($cached_result['optimized_data'])) {
443 1341 return $cached_result;
444 1342 }
445 1343
446 1344 // Check rate limiting
@@ -451,9 +1349,9 @@
451 1349 // Get AI client
452 1350 $client = $this->get_client();
453 1351
454 1352 if (!$client) {
455 - throw new \Exception('AI client not initialized. Please check your API key configuration.');
1353 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
456 1354 }
457 1355
458 1356 // Perform AI optimization
459 1357 $optimization_results = $client->optimize_llms_txt($website_data, $options);
@@ -464,9 +1362,9 @@
464 1362 }
465 1363
466 1364 // Add metadata
467 1365 $optimization_results['ai_model'] = $client->get_model();
468 - $optimization_results['provider'] = $this->settings->get('ai_provider', 'openai');
1366 + $optimization_results['provider'] = $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
469 1367 $optimization_results['generated_at'] = gmdate('Y-m-d H:i:s');
470 1368 $optimization_results['user_id'] = $user_id;
471 1369
472 1370 // Cache the results (24 hours)
@@ -496,19 +1394,33 @@
496 1394 'models' => ['gpt-5-nano', 'gpt-5-mini', 'gpt-5', 'gpt-4o'],
497 1395 'requires_key' => true,
498 1396 ],
499 1397 'claude' => [
500 - 'name' => 'Claude (Anthropic)',
501 - 'description' => 'Claude 4 and 3.7 models',
502 - 'models' => ['claude-sonnet-4-0', 'claude-opus-4-0', 'claude-3-7-sonnet-latest', 'claude-3-5-sonnet-latest', 'claude-3-5-haiku-latest'],
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'],
503 1403 'requires_key' => true,
504 1404 ],
505 1405 'gemini' => [
506 1406 'name' => 'Google Gemini',
507 - 'description' => 'Gemini 2.5 and 2.0 models',
508 - 'models' => ['gemini-2.5-flash', 'gemini-2.5-flash-lite', 'gemini-2.5-pro', 'gemini-2.0-flash', 'gemini-1.5-flash'],
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'],
509 1412 'requires_key' => true,
510 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 + ],
511 1423 ];
512 1424 }
513 1425
514 1426 /**
@@ -516,10 +1428,14 @@
516 1428 *
517 1429 * @return array Provider status
518 1430 */
519 1431 public function get_provider_status(): array {
520 - $provider = $this->settings->get('ai_provider', 'openai');
521 - $api_key = $this->settings->get($provider . '_api_key');
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');
522 1438
523 1439 return [
524 1440 'provider' => $provider,
525 1441 'configured' => !empty($api_key),
@@ -592,41 +1508,39 @@
592 1508 }
593 1509 }
594 1510
595 1511 /**
596 - * Check rate limits
1512 + * Check rate limits.
597 1513 *
598 - * @return bool True if within limits
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.
599 1523 */
600 - private function check_rate_limit(): bool {
601 - $user_id = get_current_user_id();
602 - $max_requests = $this->settings->get('max_requests_per_minute', 10);
603 - $current_time = time();
604 - $window_start = $current_time - 60; // 1 minute window
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);
605 1527
606 - // Clean old entries
607 - $this->rate_limits = array_filter(
608 - $this->rate_limits,
609 - function($timestamp) use ($window_start) {
610 - return $timestamp > $window_start;
611 - }
612 - );
1528 + // A non-positive limit means "unlimited".
1529 + if ($max_requests <= 0) {
1530 + return true;
1531 + }
613 1532
614 - // Count requests for this user
615 - $user_requests = array_filter(
616 - $this->rate_limits,
617 - function($timestamp, $key) use ($user_id) {
618 - return strpos($key, "user_{$user_id}_") === 0;
619 - },
620 - ARRAY_FILTER_USE_BOTH
621 - );
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);
622 1537
623 - if (count($user_requests) >= $max_requests) {
1538 + if ($attempts >= $max_requests) {
624 1539 return false;
625 1540 }
626 1541
627 - // Add current request
628 - $this->rate_limits["user_{$user_id}_{$current_time}"] = $current_time;
1542 + set_transient($minute_key, $attempts + 1, MINUTE_IN_SECONDS);
629 1543
630 1544 return true;
631 1545 }
632 1546
@@ -655,9 +1569,9 @@
655 1569 if ($raw_response) {
656 1570 $metadata['raw_response'] = $raw_response;
657 1571 }
658 1572
659 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- AI usage logging requires direct database access
1573 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- AI usage logging requires direct database access
660 1574 $wpdb->insert(
661 1575 $table_name,
662 1576 [
663 1577 'user_id' => $user_id,
@@ -662,14 +1576,26 @@
662 1576 [
663 1577 'user_id' => $user_id,
664 1578 'action' => $action,
665 1579 'tokens_used' => $tokens_used,
666 - 'provider' => $this->settings->get('ai_provider', 'openai'),
1580 + 'provider' => $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE),
667 1581 'metadata' => !empty($metadata) ? wp_json_encode($metadata) : null,
668 1582 'created_at' => current_time('mysql'),
669 1583 ],
670 1584 ['%d', '%s', '%d', '%s', '%s', '%s']
671 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);
672 1598 }
673 1599
674 1600 /**
675 1601 * Cleanup expired cache entries
@@ -698,20 +1624,24 @@
698 1624
699 1625 $user_id = get_current_user_id();
700 1626
701 1627 // Ensure user has configured their API key (copying Site Identity pattern)
702 - $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'));
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'));
703 1629
704 1630 if (!$user_has_api_key) {
705 - throw new \Exception('Please configure your OpenAI, Claude, or Gemini API key in ThinkRank settings to use AI features.');
1631 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
706 1632 }
707 1633
708 1634 // Generate cache key using existing pattern
709 - $cache_key = 'homepage_meta_' . md5(serialize($content_data) . serialize($options)) . '_' . $user_id;
1635 + $cache_key = 'homepage_meta_' . md5(wp_json_encode($content_data) . wp_json_encode($options)) . '_' . $user_id;
710 1636
711 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.
712 1641 $cached_result = $this->cache->get($cache_key);
713 - if ($cached_result !== null && !empty($cached_result['optimized_data'])) {
1642 + $cached_result = $cached_result['data'] ?? $cached_result;
1643 + if (!empty($cached_result['optimized_data'])) {
714 1644 return $cached_result;
715 1645 }
716 1646
717 1647 // Check rate limiting
@@ -722,9 +1652,9 @@
722 1652 // Get AI client
723 1653 $client = $this->get_client();
724 1654
725 1655 if (!$client) {
726 - throw new \Exception('AI client not initialized. Please check your API key configuration.');
1656 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
727 1657 }
728 1658
729 1659 // Perform AI optimization
730 1660 $optimization_results = $client->optimize_homepage_meta($content_data, $options);
@@ -735,9 +1665,9 @@
735 1665 }
736 1666
737 1667 // Add metadata
738 1668 $optimization_results['ai_model'] = $client->get_model();
739 - $optimization_results['provider'] = $this->settings->get('ai_provider', 'openai');
1669 + $optimization_results['provider'] = $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
740 1670 $optimization_results['generated_at'] = gmdate('Y-m-d H:i:s');
741 1671 $optimization_results['user_id'] = $user_id;
742 1672
743 1673 // Cache the results (24 hours)
@@ -772,20 +1702,24 @@
772 1702
773 1703 $user_id = get_current_user_id();
774 1704
775 1705 // Ensure user has configured their API key (copying Site Identity pattern)
776 - $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'));
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'));
777 1707
778 1708 if (!$user_has_api_key) {
779 - throw new \Exception('Please configure your OpenAI, Claude, or Gemini API key in ThinkRank settings to use AI features.');
1709 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
780 1710 }
781 1711
782 1712 // Generate cache key using existing pattern
783 - $cache_key = 'homepage_hero_' . md5(serialize($hero_data) . serialize($options)) . '_' . $user_id;
1713 + $cache_key = 'homepage_hero_' . md5(wp_json_encode($hero_data) . wp_json_encode($options)) . '_' . $user_id;
784 1714
785 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.
786 1719 $cached_result = $this->cache->get($cache_key);
787 - if ($cached_result !== null && !empty($cached_result['optimized_data'])) {
1720 + $cached_result = $cached_result['data'] ?? $cached_result;
1721 + if (!empty($cached_result['optimized_data'])) {
788 1722 return $cached_result;
789 1723 }
790 1724
791 1725 // Check rate limiting
@@ -796,9 +1730,9 @@
796 1730 // Get AI client
797 1731 $client = $this->get_client();
798 1732
799 1733 if (!$client) {
800 - throw new \Exception('AI client not initialized. Please check your API key configuration.');
1734 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
801 1735 }
802 1736
803 1737 // Perform AI optimization
804 1738 $optimization_results = $client->optimize_homepage_hero($hero_data, $options);
@@ -809,9 +1743,9 @@
809 1743 }
810 1744
811 1745 // Add metadata
812 1746 $optimization_results['ai_model'] = $client->get_model();
813 - $optimization_results['provider'] = $this->settings->get('ai_provider', 'openai');
1747 + $optimization_results['provider'] = $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
814 1748 $optimization_results['generated_at'] = gmdate('Y-m-d H:i:s');
815 1749 $optimization_results['user_id'] = $user_id;
816 1750
817 1751 // Cache the results (24 hours)