PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.10.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.10.0
2.10.0 2.9.0 2.8.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 All 51 releases
← All changes | includes/ai/class-manager.php +1109 -95 1.0.1 → 2.10.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,29 +138,183 @@
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
169 + case 'openai_compatible':
170 + // Any server speaking the OpenAI Chat Completions API:
171 + // Ollama, LM Studio, vLLM, Azure OpenAI, Groq, a company
172 + // gateway (#721). The key is optional — a local server
173 + // usually wants none — so the URL and the model id are what
174 + // decide whether this provider is configured.
175 + $base_url = (string) $this->settings->get('openai_compatible_base_url', '');
176 + $model = trim((string) $this->settings->get('openai_compatible_model', ''));
177 + if ('' !== $base_url && '' !== $model) {
178 + $compatible_client = new OpenAI_Client(
179 + (string) $this->settings->get('openai_compatible_api_key', ''),
180 + $model,
181 + (int) $this->settings->get('openai_compatible_timeout', Settings::DEFAULT_OPENAI_COMPATIBLE_TIMEOUT),
182 + $base_url
183 + );
184 + $compatible_client->set_json_mode((bool) $this->settings->get('openai_compatible_json_mode', false));
185 + $this->client = $compatible_client;
186 + }
187 + break;
188 +
148 189 default:
149 190 throw new \Exception("Unsupported AI provider: {$provider}");
150 191 }
151 192 } catch (\Exception $e) {
152 - // AI client initialization failed, will be handled later
193 + // Leave a trace. Swallowing this meant a misconfigured provider
194 + // produced a NULL client and every AI feature became a silent
195 + // no-op with nothing to diagnose from.
196 + if (defined('WP_DEBUG') && WP_DEBUG) {
197 + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- diagnostic, WP_DEBUG only.
198 + error_log('ThinkRank [ai]: client initialization failed — ' . $e->getMessage());
199 + }
153 200 }
154 201 }
155 202
156 203 /**
204 + * Has the user configured enough for the selected provider to run?
205 + *
206 + * Every generator re-checks this before spending a request, because an
207 + * initialised client is not proof of configuration — the client is built
208 + * from whatever was stored. It used to be an inline OR over the four API
209 + * key settings, repeated at nine call sites; the OpenAI-compatible
210 + * provider broke that shape, since a local Ollama or LM Studio server
211 + * legitimately has no key and is configured by URL + model instead (#721).
212 + * Settings::has_ai_provider_configured() is now the single answer, shared
213 + * with the admin menu notice and the metabox.
214 + *
215 + * @since 2.8.0
216 + *
217 + * @return bool True when the selected provider has what it needs.
218 + */
219 + private function has_provider_credentials(): bool {
220 + return $this->settings->has_ai_provider_configured();
221 + }
222 +
223 + /**
224 + * Get the display name of the currently selected AI provider
225 + *
226 + * @return string Provider display name (e.g. "OpenAI")
227 + */
228 + private function get_provider_label(): string {
229 + $labels = [
230 + 'openai' => 'OpenAI',
231 + // The vendor, not the model family — matches the settings UI (#572).
232 + 'claude' => 'Anthropic',
233 + 'gemini' => 'Gemini',
234 + 'openrouter' => 'OpenRouter',
235 + // Named by what it is, not by OpenAI — the host is the customer's.
236 + 'openai_compatible' => 'OpenAI-compatible endpoint',
237 + ];
238 +
239 + $provider = (string) $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
240 +
241 + return $labels[$provider] ?? ucfirst($provider);
242 + }
243 +
244 + /**
245 + * Build a user-friendly message explaining why AI features are unavailable
246 + *
247 + * Provider-aware: tells the user exactly which API key is missing and where
248 + * to add it, instead of a generic "client not initialized" error.
249 + *
250 + * @return string Actionable error message for end users
251 + */
252 + private function get_client_unavailable_message(): string {
253 + $provider = (string) $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
254 +
255 + // The React admin renders this anchor as a real link via linkifyMessage().
256 + $settings_link = sprintf(
257 + '<a href="%s" target="_blank" rel="noopener noreferrer">%s</a>',
258 + esc_url(admin_url('admin.php?page=thinkrank-settings')),
259 + __('ThinkRank → Settings', 'thinkrank')
260 + );
261 +
262 + // No provider chosen at all — asking for a key would put the cart before
263 + // the horse, so name the actual first step (#572).
264 + if (Settings::AI_PROVIDER_NONE === $provider) {
265 + return sprintf(
266 + /* translators: %s: link to the ThinkRank settings page. */
267 + __('AI features are not set up yet. Choose an AI provider and add its API key under %s.', 'thinkrank'),
268 + $settings_link
269 + );
270 + }
271 +
272 + // The OpenAI-compatible provider has no key requirement — a local
273 + // server usually wants none — so the generic "add your API key" copy
274 + // below would send the user looking for the wrong field (#721).
275 + if ('openai_compatible' === $provider) {
276 + if (empty($this->settings->get('openai_compatible_base_url'))) {
277 + return sprintf(
278 + /* translators: %s: link to the ThinkRank settings page. */
279 + __('AI features are not set up yet. Add the base URL of your OpenAI-compatible endpoint under %s.', 'thinkrank'),
280 + $settings_link
281 + );
282 + }
283 +
284 + if (empty(trim((string) $this->settings->get('openai_compatible_model', '')))) {
285 + return sprintf(
286 + /* translators: %s: link to the ThinkRank settings page. */
287 + __('AI features are not set up yet. Enter the model id your endpoint should use under %s.', 'thinkrank'),
288 + $settings_link
289 + );
290 + }
291 +
292 + return sprintf(
293 + /* translators: %s: link to the ThinkRank settings page. */
294 + __('ThinkRank could not reach your OpenAI-compatible endpoint. Check the base URL, model id and that the server is running under %s, then try again.', 'thinkrank'),
295 + $settings_link
296 + );
297 + }
298 +
299 + if (empty($this->settings->get("{$provider}_api_key"))) {
300 + return sprintf(
301 + /* translators: 1: AI provider name (e.g. OpenAI), 2: link to the ThinkRank settings page. */
302 + __('AI features are not set up yet. To enable them, add your %1$s API key under %2$s.', 'thinkrank'),
303 + $this->get_provider_label(),
304 + $settings_link
305 + );
306 + }
307 +
308 + return sprintf(
309 + /* translators: 1: AI provider name (e.g. OpenAI), 2: link to the ThinkRank settings page. */
310 + __('ThinkRank could not connect to %1$s. Please verify your API key and model under %2$s, then try again.', 'thinkrank'),
311 + $this->get_provider_label(),
312 + $settings_link
313 + );
314 + }
315 +
316 + /**
157 317 * Force re-initialization of client (useful after settings change)
158 318 *
159 319 * @return void
160 320 */
@@ -176,9 +336,9 @@
176 336 }
177 337
178 338 // If still not available, throw error
179 339 if (!$this->client) {
180 - throw new \Exception('AI client not initialized. Please configure your API key.');
340 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
181 341 }
182 342
183 343 return $this->client;
184 344 }
@@ -197,9 +357,9 @@
197 357 $this->initialize_client();
198 358
199 359 // If still not available, throw error
200 360 if (!$this->client) {
201 - throw new \Exception('AI client not initialized. Please configure your API key.');
361 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
202 362 }
203 363 }
204 364
205 365 // Check rate limits
@@ -223,12 +383,12 @@
223 383 // Generate metadata using AI
224 384 $metadata = $this->client->generate_seo_metadata($content, $options);
225 385
226 386 // 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'));
387 + $user_has_api_key = $this->has_provider_credentials();
228 388
229 389 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.');
390 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
231 391 }
232 392
233 393 // Cache the result
234 394 $this->cache->set($cache_key, $metadata);
@@ -248,8 +408,807 @@
248 408 }
249 409 }
250 410
251 411 /**
412 + * Generate an improved SEO title that addresses a specific suggestion.
413 + *
414 + * Used by the "Apply" action on title-related SEO score suggestions. Builds a
415 + * focused, best-practice title prompt and runs it through the configured
416 + * provider, reusing the same completion/extraction path as the content brief
417 + * generator so OpenAI, Claude and Gemini all parse consistently.
418 + *
419 + * @since 1.14.0
420 + *
421 + * @param string $content Post content for context.
422 + * @param array $options {
423 + * @type string $current_title Current SEO title.
424 + * @type string $target_keyword Focus keyword.
425 + * @type string $content_type Content type (blog_post, page, …).
426 + * @type string $tone Desired tone.
427 + * @type string $suggestion The suggestion the title must address.
428 + * }
429 + * @return array{title:string} The improved SEO title.
430 + * @throws \Exception If the AI client is unavailable or returns no title.
431 + */
432 + public function improve_seo_title(string $content, array $options = []): array {
433 + if (!$this->client) {
434 + $this->initialize_client();
435 + if (!$this->client) {
436 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
437 + }
438 + }
439 +
440 + // Ensure user has configured their API key.
441 + $user_has_api_key = $this->has_provider_credentials();
442 + if (!$user_has_api_key) {
443 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
444 + }
445 +
446 + // Check rate limits.
447 + if (!$this->check_rate_limit()) {
448 + throw new \Exception('Rate limit exceeded. Please try again later.');
449 + }
450 +
451 + $current_title = (string) ($options['current_title'] ?? '');
452 + $target_keyword = (string) ($options['target_keyword'] ?? '');
453 + $content_type = (string) ($options['content_type'] ?? 'blog_post');
454 + $tone = (string) ($options['tone'] ?? 'professional');
455 + $suggestion = (string) ($options['suggestion'] ?? '');
456 + $language = (string) ($options['language'] ?? '');
457 +
458 + // Cache identical requests (same content + inputs) to avoid duplicate calls.
459 + // Cap content server-side (mirror the frontend 5000-char trim) so a
460 + // direct REST caller can't force oversized prompt/cache/AI work.
461 + $content = mb_substr($content, 0, 5000);
462 +
463 + $cache_key = 'improve_title_' . md5($content . '|' . $current_title . '|' . $target_keyword . '|' . $content_type . '|' . $tone . '|' . $suggestion);
464 + $cached_result = $this->cache->get($cache_key);
465 + if ($cached_result !== null) {
466 + return $cached_result['data'] ?? $cached_result;
467 + }
468 +
469 + $user_id = get_current_user_id();
470 + $provider = method_exists($this->client, 'get_provider') ? $this->client->get_provider() : 'openai';
471 +
472 + // Use ThinkRank's own validation word lists so the generated title passes
473 + // the same emotion/sentiment and power-word checks the scorer applies.
474 + $sentiment_words = SEOScoreCalculator::get_title_sentiment_words();
475 + $power_words = SEOScoreCalculator::get_title_power_words();
476 +
477 + // When the suggestion explicitly asks for an emotional/sentiment word we
478 + // strictly validate the result (and retry once) to guarantee it passes.
479 + $needs_sentiment = stripos($suggestion, 'sentiment') !== false || stripos($suggestion, 'emotional') !== false;
480 +
481 + $prompt = (new Prompt_Builder())->build_title_improvement_prompt(
482 + $content,
483 + $current_title,
484 + $target_keyword,
485 + $content_type,
486 + $tone,
487 + $suggestion,
488 + $provider,
489 + $sentiment_words,
490 + $power_words,
491 + $language
492 + );
493 +
494 + $generated = $this->request_title($prompt);
495 + $title = $generated['title'];
496 + $total_tokens = $generated['tokens'];
497 + $ai_text = $generated['ai_text'];
498 + $finish_reason = $generated['finish_reason'];
499 +
500 + // A reasoning model can still return an empty/truncated title on the
501 + // first pass; retry once before giving up so the "Apply" action reliably
502 + // produces a title.
503 + if ($title === '') {
504 + $retry = $this->request_title($prompt);
505 + $total_tokens += $retry['tokens'];
506 + if ($retry['ai_text'] !== '') {
507 + $ai_text = $retry['ai_text'];
508 + }
509 + $finish_reason = $retry['finish_reason'];
510 + if ($retry['title'] !== '') {
511 + $title = $retry['title'];
512 + }
513 + }
514 +
515 + // Guarantee the emotion/sentiment check passes: if it was required but the
516 + // title still lacks a listed word, retry once with a non-negotiable
517 + // instruction. If the retry also fails we keep the best title we have.
518 + if ($needs_sentiment && !$this->title_contains_word($title, $sentiment_words)) {
519 + $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) . '.';
520 + $retry = $this->request_title($retry_prompt);
521 + $total_tokens += $retry['tokens'];
522 + if ($retry['ai_text'] !== '') {
523 + $ai_text = $retry['ai_text'];
524 + }
525 + $finish_reason = $retry['finish_reason'];
526 + if ($retry['title'] !== '' && $this->title_contains_word($retry['title'], $sentiment_words)) {
527 + $title = $retry['title'];
528 + }
529 + }
530 +
531 + if ($title === '') {
532 + // Nothing about a raw JSON-parse failure is visible to support
533 + // otherwise — log_ai_usage() below only runs on success, so a
534 + // failed attempt left no trace of what the model actually sent
535 + // back or why generation stopped.
536 + if (defined('WP_DEBUG') && WP_DEBUG) {
537 + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Debug logging only when WP_DEBUG is enabled.
538 + error_log(sprintf(
539 + '[ThinkRank] Title improvement failed to extract a title. finish_reason=%s ai_text=%s',
540 + $finish_reason !== '' ? $finish_reason : '(none)',
541 + mb_substr($ai_text, 0, 500)
542 + ));
543 + }
544 + throw new \Exception('The AI did not return a usable title. Please try again.');
545 + }
546 +
547 + // Log usage.
548 + $actual_model = $this->client ? $this->client->get_model() : null;
549 + $this->log_ai_usage($user_id, 'SEO Title Improvement', (int) $total_tokens, $actual_model, $ai_text);
550 +
551 + $result = ['title' => $title];
552 + $this->cache->set($cache_key, $result);
553 +
554 + return $result;
555 + }
556 +
557 + /**
558 + * Run a single title-generation request: call the provider, extract the
559 + * title text across provider response shapes, and clamp it to 60 characters.
560 + *
561 + * @param string $prompt The prompt to send.
562 + * @return array{title:string,ai_text:string,tokens:int}
563 + */
564 + private function request_title(string $prompt): array {
565 + // Larger budget so reasoning models (e.g. gpt-5-nano) don't spend the
566 + // whole allowance "thinking" and truncate the JSON before the title.
567 + $completion = $this->request_completion($prompt, 4096);
568 + $title = $this->extract_json_field($completion['ai_text'], 'title');
569 +
570 + // Safety net: enforce the 60-character maximum even if the model overruns.
571 + if (mb_strlen($title) > 60) {
572 + $title = rtrim(mb_substr($title, 0, 60));
573 + }
574 +
575 + return [
576 + 'title' => $title,
577 + 'ai_text' => $completion['ai_text'],
578 + 'tokens' => $completion['tokens'],
579 + 'finish_reason' => $completion['finish_reason'],
580 + ];
581 + }
582 +
583 + /**
584 + * Generate an improved meta description that addresses a specific suggestion.
585 + *
586 + * Guarantees ThinkRank's technical check passes (120-160 characters) and,
587 + * when the suggestion is about the focus keyword, that the keyword is present
588 + * — retrying once if the first attempt falls outside the constraints.
589 + *
590 + * @since 1.14.0
591 + *
592 + * @param string $content Post content for context.
593 + * @param array $options {
594 + * @type string $current_description Current meta description.
595 + * @type string $target_keyword Focus keyword.
596 + * @type string $content_type Content type.
597 + * @type string $tone Desired tone.
598 + * @type string $suggestion The suggestion to address.
599 + * }
600 + * @return array{description:string} The improved meta description.
601 + * @throws \Exception If the AI client is unavailable or returns nothing usable.
602 + */
603 + public function improve_meta_description(string $content, array $options = []): array {
604 + if (!$this->client) {
605 + $this->initialize_client();
606 + if (!$this->client) {
607 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
608 + }
609 + }
610 +
611 + $user_has_api_key = $this->has_provider_credentials();
612 + if (!$user_has_api_key) {
613 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
614 + }
615 +
616 + if (!$this->check_rate_limit()) {
617 + throw new \Exception('Rate limit exceeded. Please try again later.');
618 + }
619 +
620 + $current_desc = (string) ($options['current_description'] ?? '');
621 + $target_keyword = (string) ($options['target_keyword'] ?? '');
622 + $content_type = (string) ($options['content_type'] ?? 'blog_post');
623 + $tone = (string) ($options['tone'] ?? 'professional');
624 + $suggestion = (string) ($options['suggestion'] ?? '');
625 + $language = (string) ($options['language'] ?? '');
626 +
627 + // Cap content server-side (mirror the frontend 5000-char trim) so a
628 + // direct REST caller can't force oversized prompt/cache/AI work.
629 + $content = mb_substr($content, 0, 5000);
630 +
631 + $cache_key = 'improve_meta_' . md5($content . '|' . $current_desc . '|' . $target_keyword . '|' . $content_type . '|' . $tone . '|' . $suggestion);
632 + $cached_result = $this->cache->get($cache_key);
633 + if ($cached_result !== null) {
634 + return $cached_result['data'] ?? $cached_result;
635 + }
636 +
637 + $user_id = get_current_user_id();
638 + $provider = method_exists($this->client, 'get_provider') ? $this->client->get_provider() : 'openai';
639 +
640 + // The keyword must appear when the suggestion is keyword-specific, or
641 + // whenever a focus keyword exists (the scorer rewards it either way).
642 + $needs_keyword = $target_keyword !== '';
643 +
644 + $build_prompt = fn() => (new Prompt_Builder())->build_meta_description_improvement_prompt(
645 + $content,
646 + $current_desc,
647 + $target_keyword,
648 + $content_type,
649 + $tone,
650 + $suggestion,
651 + $provider,
652 + $language
653 + );
654 +
655 + $valid = function (string $desc) use ($needs_keyword, $target_keyword): bool {
656 + $len = mb_strlen($desc);
657 + if ($len < 120 || $len > 160) {
658 + return false;
659 + }
660 + if ($needs_keyword && strpos(strtolower($desc), strtolower($target_keyword)) === false) {
661 + return false;
662 + }
663 + return true;
664 + };
665 +
666 + $prompt = $build_prompt();
667 + // Larger budget so reasoning models don't truncate the JSON before the
668 + // description (which surfaced as "could not produce a 120-160 character
669 + // meta description" on gpt-5-nano).
670 + $completion = $this->request_completion($prompt, 4096);
671 + $description = $this->extract_json_field($completion['ai_text'], 'description');
672 + $total_tokens = $completion['tokens'];
673 + $ai_text = $completion['ai_text'];
674 +
675 + // Retry once with explicit, measurable constraints if the first attempt
676 + // misses the mandatory length window or the required keyword.
677 + if (!$valid($description)) {
678 + $extra = "\n\nIMPORTANT: Your previous attempt did not meet the requirements. The description MUST be between 120 and 160 characters";
679 + if ($needs_keyword) {
680 + $extra .= " and MUST contain the exact phrase \"{$target_keyword}\"";
681 + }
682 + $extra .= '. Count the characters before answering.';
683 + $retry = $this->request_completion($prompt . $extra, 4096);
684 + $retry_desc = $this->extract_json_field($retry['ai_text'], 'description');
685 + $total_tokens += $retry['tokens'];
686 + if ($retry['ai_text'] !== '') {
687 + $ai_text = $retry['ai_text'];
688 + }
689 + // Prefer a valid candidate; otherwise keep the longer non-empty one so
690 + // the clamp below can bring an over-long description into range.
691 + if ($valid($retry_desc)) {
692 + $description = $retry_desc;
693 + } elseif ($description === '') {
694 + $description = $retry_desc;
695 + } elseif (!$valid($description) && mb_strlen($retry_desc) > mb_strlen($description)) {
696 + $description = $retry_desc;
697 + }
698 + }
699 +
700 + // Hard safety net: guarantee the 160-character ceiling by trimming at a
701 + // word boundary, so the scorer's 120-160 technical check passes even if a
702 + // "thinking" model overran the limit.
703 + $description = $this->clamp_meta_description($description);
704 +
705 + if ($description === '' || mb_strlen($description) < 120) {
706 + throw new \Exception('The AI could not produce a 120-160 character meta description. Please try again.');
707 + }
708 +
709 + $actual_model = $this->client ? $this->client->get_model() : null;
710 + $this->log_ai_usage($user_id, 'SEO Meta Description', (int) $total_tokens, $actual_model, $ai_text);
711 +
712 + $result = ['description' => $description];
713 + $this->cache->set($cache_key, $result);
714 +
715 + return $result;
716 + }
717 +
718 + /**
719 + * Explain a single SEO score suggestion in plain, post-specific language.
720 + *
721 + * Powers the "Explain with AI" copilot action on each suggestion. Unlike the
722 + * improve_* methods this does not modify content — it returns a short,
723 + * context-aware explanation of why the suggestion matters for this post and
724 + * how to resolve it, so the author understands the fix before applying it.
725 + *
726 + * @since 1.18.0
727 + *
728 + * @param string $content Post content for context.
729 + * @param array $options {
730 + * @type string $suggestion The suggestion to explain (required).
731 + * @type string $title Post/SEO title for context.
732 + * @type string $target_keyword Focus keyword.
733 + * @type string $content_type Content type (blog_post, page, …).
734 + * }
735 + * @return array{explanation:string} The plain-language explanation.
736 + * @throws \Exception If the AI client is unavailable or returns nothing usable.
737 + */
738 + public function explain_seo_suggestion(string $content, array $options = []): array {
739 + $this->ensure_ready_for_ai();
740 +
741 + // Cap content server-side so a direct REST caller cannot bypass the
742 + // frontend's 5000-character trim and force oversized prompt building,
743 + // cache hashing, and expensive AI calls/retries.
744 + $content = mb_substr($content, 0, 5000);
745 +
746 + $suggestion = trim((string) ($options['suggestion'] ?? ''));
747 + if ($suggestion === '') {
748 + throw new \Exception('A suggestion is required to generate an explanation.');
749 + }
750 + $title = (string) ($options['title'] ?? '');
751 + $target_keyword = (string) ($options['target_keyword'] ?? '');
752 + $content_type = (string) ($options['content_type'] ?? 'blog_post');
753 +
754 + $cache_key = 'explain_' . md5($suggestion . '|' . $content . '|' . $title . '|' . $target_keyword . '|' . $content_type);
755 + $cached = $this->cache->get($cache_key);
756 + if ($cached !== null) {
757 + return $cached['data'] ?? $cached;
758 + }
759 +
760 + $provider = method_exists($this->client, 'get_provider') ? $this->client->get_provider() : 'openai';
761 + $prompt = (new Prompt_Builder())->build_suggestion_explanation_prompt($suggestion, $content, $title, $target_keyword, $content_type, $provider);
762 +
763 + // Give reasoning models (e.g. gpt-5-nano) enough headroom that they don't
764 + // burn the whole budget "thinking" and truncate the JSON before the
765 + // closing brace, and retry once if the first attempt yields nothing
766 + // parseable — mirrors the resilience of the keyword-paragraph path.
767 + $explanation = '';
768 + $tokens_used = 0;
769 + $ai_text = '';
770 + for ($attempt = 0; $attempt < 2; $attempt++) {
771 + $completion = $this->request_completion($prompt, 4096);
772 + $tokens_used += (int) $completion['tokens'];
773 + $ai_text = $completion['ai_text'];
774 + $candidate = $this->extract_json_field($completion['ai_text'], 'explanation');
775 + if ($candidate !== '') {
776 + $explanation = $candidate;
777 + break;
778 + }
779 + }
780 +
781 + if ($explanation === '') {
782 + throw new \Exception('The AI did not return an explanation. Please try again.');
783 + }
784 +
785 + $actual_model = $this->client ? $this->client->get_model() : null;
786 + $this->log_ai_usage(get_current_user_id(), 'SEO Suggestion Explanation', (int) $tokens_used, $actual_model, $ai_text);
787 +
788 + $result = ['explanation' => $explanation];
789 + $this->cache->set($cache_key, $result);
790 +
791 + return $result;
792 + }
793 +
794 + /**
795 + * Generate a targeted content fragment that adds one authoritative external
796 + * dofollow link, so the scorer's external-dofollow-link check passes.
797 + *
798 + * @since 1.14.0
799 + *
800 + * @param string $content Post content for context.
801 + * @param array $options { @type string $target_keyword; @type string $content_type; }
802 + * @return array{html:string,url:string,anchor:string} HTML paragraph to append.
803 + * @throws \Exception If the AI client is unavailable or returns no valid link.
804 + */
805 + public function generate_dofollow_link(string $content, array $options = []): array {
806 + $this->ensure_ready_for_ai();
807 +
808 + $target_keyword = (string) ($options['target_keyword'] ?? '');
809 + $content_type = (string) ($options['content_type'] ?? 'blog_post');
810 +
811 + // Cap content server-side (mirror the frontend 5000-char trim) so a
812 + // direct REST caller can't force oversized prompt/cache/AI work.
813 + $content = mb_substr($content, 0, 5000);
814 +
815 + $cache_key = 'dofollow_' . md5($content . '|' . $target_keyword . '|' . $content_type);
816 + $cached = $this->cache->get($cache_key);
817 + if ($cached !== null) {
818 + return $cached['data'] ?? $cached;
819 + }
820 +
821 + $provider = method_exists($this->client, 'get_provider') ? $this->client->get_provider() : 'openai';
822 + $prompt = (new Prompt_Builder())->build_dofollow_link_prompt($content, $target_keyword, $content_type, $provider);
823 +
824 + // Larger budget + one retry: reasoning models can truncate the JSON and
825 + // yield no URL, which surfaced as "did not return a valid external
826 + // source" on the first attempt.
827 + $data = [];
828 + $tokens_used = 0;
829 + $ai_text = '';
830 + for ($attempt = 0; $attempt < 2; $attempt++) {
831 + $completion = $this->request_completion($prompt, 4096);
832 + $tokens_used += (int) $completion['tokens'];
833 + $ai_text = $completion['ai_text'];
834 + $candidate = $this->extract_json_object($completion['ai_text']);
835 + if (is_array($candidate) && !empty($candidate['url'])) {
836 + $data = $candidate;
837 + break;
838 + }
839 + }
840 +
841 + $url = isset($data['url']) ? esc_url_raw(trim((string) $data['url'])) : '';
842 + $anchor = isset($data['anchor']) ? sanitize_text_field((string) $data['anchor']) : '';
843 + $sentence = isset($data['sentence']) ? sanitize_text_field((string) $data['sentence']) : '';
844 +
845 + // Validate: must be a real external http(s) URL pointing off-site.
846 + $site_host = wp_parse_url(get_site_url(), PHP_URL_HOST);
847 + $link_host = $url !== '' ? wp_parse_url($url, PHP_URL_HOST) : '';
848 + $is_external = $url !== '' && preg_match('#^https?://#i', $url) && $link_host && strcasecmp($link_host, (string) $site_host) !== 0;
849 + if (!$is_external) {
850 + throw new \Exception('The AI did not return a valid external source. Please try again.');
851 + }
852 + if ($anchor === '') {
853 + $anchor = $link_host;
854 + }
855 + if ($sentence === '') {
856 + $sentence = sprintf('For more on this topic, see %s.', $anchor);
857 + }
858 +
859 + // Build a dofollow anchor (no rel=nofollow) and weave it into the
860 + // sentence by linking the anchor text; append it if the anchor phrase is
861 + // not present.
862 + $link = sprintf('<a href="%s">%s</a>', esc_url($url), esc_html($anchor));
863 + if (stripos($sentence, $anchor) !== false) {
864 + $linked = preg_replace('/' . preg_quote($anchor, '/') . '/i', $link, $sentence, 1);
865 + } else {
866 + $linked = rtrim($sentence, '.') . ' (' . $link . ').';
867 + }
868 + $html = '<p>' . $linked . '</p>';
869 +
870 + $actual_model = $this->client ? $this->client->get_model() : null;
871 + $this->log_ai_usage(get_current_user_id(), 'SEO Dofollow Link', (int) $tokens_used, $actual_model, $ai_text);
872 +
873 + $result = ['html' => $html, 'url' => $url, 'anchor' => $anchor];
874 + $this->cache->set($cache_key, $result);
875 +
876 + return $result;
877 + }
878 +
879 + /**
880 + * Generate a short, relevant closing paragraph that uses the focus keyword
881 + * enough times to lift keyword density into the scorer's healthy band
882 + * (0.5%-2.5%), returned as an HTML paragraph to append to the content.
883 + *
884 + * @since 1.14.0
885 + *
886 + * @param string $content Post content for context.
887 + * @param array $options {
888 + * @type string $target_keyword;
889 + * @type string $content_type;
890 + * @type string $tone;
891 + * @type int $word_count Current document word count.
892 + * @type int $keyword_count Current focus-keyword occurrences.
893 + * }
894 + * @return array{html:string,mentions:int} HTML paragraph to append.
895 + * @throws \Exception If the AI client is unavailable or returns nothing usable.
896 + */
897 + public function generate_keyword_paragraph(string $content, array $options = []): array {
898 + $this->ensure_ready_for_ai();
899 +
900 + $target_keyword = trim((string) ($options['target_keyword'] ?? ''));
901 + if ($target_keyword === '') {
902 + throw new \Exception('A focus keyword is required to improve keyword density.');
903 + }
904 + $content_type = (string) ($options['content_type'] ?? 'blog_post');
905 + $tone = (string) ($options['tone'] ?? 'professional');
906 + $word_count = max(0, (int) ($options['word_count'] ?? 0));
907 + $keyword_count = max(0, (int) ($options['keyword_count'] ?? 0));
908 +
909 + // Size the closing section to land just above the 0.5% floor. Solving
910 + // (kw + m) / (words + W) >= target for a section that uses ~14 words per
911 + // keyword mention (W = 14m) keeps the writing readable rather than
912 + // stuffed. Cap mentions so a very long, sparse article doesn't demand an
913 + // absurd block — in that case one pass improves density without fully
914 + // resolving it, which the caller surfaces honestly.
915 + // Target a bit above the 0.5% floor and assume a tight ~11 words per
916 + // mention when sizing the request, because models tend to under-deliver
917 + // mentions and over-write length — both of which dilute density. The cap
918 + // keeps very long, sparse posts from demanding an absurd block; those may
919 + // still need a second pass, which the caller surfaces honestly.
920 + $target_density = 0.0065;
921 + $words_per_mention = 11;
922 + $denom_factor = 1 - ($target_density * $words_per_mention); // ~0.928
923 + $needed = $denom_factor > 0
924 + ? ($target_density * $word_count - $keyword_count) / $denom_factor
925 + : 4;
926 + $mentions = (int) max(3, min(24, ceil($needed)));
927 + $para_words = max(90, $mentions * $words_per_mention);
928 +
929 + // Cap content server-side (mirror the frontend 5000-char trim) so a
930 + // direct REST caller can't force oversized prompt/cache/AI work.
931 + $content = mb_substr($content, 0, 5000);
932 +
933 + $cache_key = 'kw_para_' . md5($content . '|' . $target_keyword . '|' . $content_type . '|' . $tone . '|' . $mentions . '|' . $para_words);
934 + $cached = $this->cache->get($cache_key);
935 + if ($cached !== null) {
936 + return $cached['data'] ?? $cached;
937 + }
938 +
939 + $provider = method_exists($this->client, 'get_provider') ? $this->client->get_provider() : 'openai';
940 + $prompt = (new Prompt_Builder())->build_keyword_paragraph_prompt($content, $target_keyword, $content_type, $tone, $mentions, $provider, $para_words);
941 +
942 + // Bigger token budget: the section is long and thinking models burn
943 + // output tokens reasoning before writing the JSON. Generation can be
944 + // truncated intermittently, yielding a stub — validate and retry once so
945 + // we never apply (or cache) a degenerate paragraph.
946 + $paragraph = '';
947 + $tokens_used = 0;
948 + $ai_text = '';
949 + for ($attempt = 0; $attempt < 2; $attempt++) {
950 + $completion = $this->request_completion($prompt, 4096);
951 + $tokens_used += (int) $completion['tokens'];
952 + $ai_text = $completion['ai_text'];
953 + $candidate = $this->extract_json_field($completion['ai_text'], 'paragraph');
954 + if (str_word_count(wp_strip_all_tags($candidate)) >= 40) {
955 + $paragraph = $candidate;
956 + break;
957 + }
958 + }
959 + if ($paragraph === '') {
960 + throw new \Exception('The AI did not return a usable paragraph. Please try again.');
961 + }
962 +
963 + // wp_kses keeps it to safe inline markup; wrap as a paragraph block.
964 + $paragraph = wp_kses($paragraph, ['a' => ['href' => [], 'title' => []], 'strong' => [], 'em' => []]);
965 + $html = '<p>' . $paragraph . '</p>';
966 +
967 + // Report the density this addition achieves so the UI can tell the user
968 + // whether the check is now satisfied or needs another pass.
969 + $added_words = str_word_count(wp_strip_all_tags($paragraph));
970 + $added_mentions = substr_count(strtolower(wp_strip_all_tags($paragraph)), strtolower($target_keyword));
971 + $new_density = ($word_count + $added_words) > 0
972 + ? (($keyword_count + $added_mentions) / ($word_count + $added_words)) * 100
973 + : 0.0;
974 + $resolves = $new_density >= 0.5 && $new_density <= 2.5;
975 +
976 + $actual_model = $this->client ? $this->client->get_model() : null;
977 + $this->log_ai_usage(get_current_user_id(), 'SEO Keyword Paragraph', $tokens_used, $actual_model, $ai_text);
978 +
979 + $result = [
980 + 'html' => $html,
981 + 'mentions' => $added_mentions,
982 + 'new_density' => round($new_density, 2),
983 + 'resolves' => $resolves,
984 + ];
985 + $this->cache->set($cache_key, $result);
986 +
987 + return $result;
988 + }
989 +
990 + /**
991 + * Shared guard for the lightweight AI helpers: ensure a client is available,
992 + * the user has an API key, and the per-minute rate limit is not exceeded.
993 + *
994 + * @throws \Exception When any precondition fails.
995 + */
996 + private function ensure_ready_for_ai(): void {
997 + if (!$this->client) {
998 + $this->initialize_client();
999 + if (!$this->client) {
1000 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1001 + }
1002 + }
1003 + $user_has_api_key = $this->has_provider_credentials();
1004 + if (!$user_has_api_key) {
1005 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1006 + }
1007 + if (!$this->check_rate_limit()) {
1008 + throw new \Exception('Rate limit exceeded. Please try again later.');
1009 + }
1010 + }
1011 +
1012 + /**
1013 + * Decode the first JSON object found in an AI response.
1014 + *
1015 + * @param string $ai_text Raw AI text.
1016 + * @return array|null Decoded object, or null if none parses.
1017 + */
1018 + private function extract_json_object(string $ai_text): ?array {
1019 + $json_start = strpos($ai_text, '{');
1020 + $json_end = strrpos($ai_text, '}');
1021 + if ($json_start === false || $json_end === false || $json_end <= $json_start) {
1022 + return null;
1023 + }
1024 + $decoded = json_decode(substr($ai_text, $json_start, $json_end - $json_start + 1), true);
1025 + return is_array($decoded) ? $decoded : null;
1026 + }
1027 +
1028 + /**
1029 + * Send one prompt to the configured provider and return the raw text plus
1030 + * token usage, normalising across provider response shapes (mirrors the
1031 + * content brief generator's multi-provider handling).
1032 + *
1033 + * @param string $prompt The prompt to send.
1034 + * @return array{ai_text:string,tokens:int}
1035 + */
1036 + /**
1037 + * Detect a provider-side refusal or content-policy block and fail with
1038 + * the real reason. Each provider signals these differently, and none of
1039 + * the signals set the content field the extraction chain looks for — left
1040 + * unchecked they read as an empty/unusable result with no explanation of
1041 + * why, and every caller here retries an empty result once, which just
1042 + * repeats the same refusal at the cost of more tokens.
1043 + *
1044 + * @param array $response Raw response from the AI client.
1045 + * @throws \Exception If the response is a refusal or policy block.
1046 + */
1047 + private function guard_against_refusal(array $response): void {
1048 + // --- OpenAI (Chat Completions) ---
1049 + // A structured refusal is HTTP 200 with message.content=null and the
1050 + // stated reason carried in message.refusal.
1051 + if (isset($response['choices'][0]['message'])) {
1052 + $message = $response['choices'][0]['message'];
1053 + $finish = (string) ($response['choices'][0]['finish_reason'] ?? '');
1054 +
1055 + if (!empty($message['refusal'])) {
1056 + throw new \Exception(esc_html('The AI declined this request: ' . (string) $message['refusal']));
1057 + }
1058 + if ('content_filter' === $finish) {
1059 + throw new \Exception('The AI blocked this request under its content policy. Try different wording.');
1060 + }
1061 + }
1062 +
1063 + // --- Claude (Messages) ---
1064 + if (isset($response['stop_reason']) && 'refusal' === (string) $response['stop_reason']) {
1065 + throw new \Exception('The AI declined this request. Try different wording.');
1066 + }
1067 +
1068 + // --- Gemini ---
1069 + // A prompt rejected outright returns no candidate at all, only
1070 + // promptFeedback.blockReason; a candidate can also finish on SAFETY or
1071 + // PROHIBITED_CONTENT.
1072 + $block_reason = (string) ($response['promptFeedback']['blockReason'] ?? '');
1073 + if ('' !== $block_reason) {
1074 + throw new \Exception(esc_html(sprintf('The AI blocked this request under its content policy (%s). Try different wording.', $block_reason)));
1075 + }
1076 + $gemini_finish = (string) ($response['candidates'][0]['finishReason'] ?? '');
1077 + if (in_array($gemini_finish, ['SAFETY', 'PROHIBITED_CONTENT'], true)) {
1078 + throw new \Exception('The AI blocked this request under its content policy. Try different wording.');
1079 + }
1080 + }
1081 +
1082 + private function request_completion(string $prompt, int $max_tokens = 2048, array $options = []): array {
1083 + // "Thinking" providers (e.g. Gemini 2.5) spend output tokens on reasoning
1084 + // before emitting text, so the cap must cover both the reasoning and the
1085 + // visible JSON. Longer outputs (paragraphs) need a bigger budget. It's
1086 + // only a ceiling — short replies cost no more.
1087 + // Extra options (e.g. reasoning_effort) pass through; every client
1088 + // cherry-picks the keys it understands and ignores the rest.
1089 + $response = $this->client->generate_completion($prompt, array_merge($options, [
1090 + 'max_tokens' => $max_tokens,
1091 + 'temperature' => 0.4,
1092 + ]));
1093 +
1094 + // Fail fast on a genuine refusal/policy block instead of retrying the
1095 + // same prompt (every caller retries on an empty result) and burning
1096 + // more tokens on a request the model has already declined. Truncation
1097 + // (finish_reason length/max_tokens) is deliberately NOT treated as a
1098 + // hard failure here — callers' existing empty-result retries already
1099 + // recover from that, and a retry can succeed where the first attempt
1100 + // spent its budget on hidden reasoning.
1101 + $this->guard_against_refusal($response);
1102 +
1103 + $ai_text = '';
1104 + if (isset($response['choices'][0]['message']['content'])) {
1105 + $ai_text = is_array($response['choices'][0]['message']['content'])
1106 + ? implode(' ', array_map(static fn($part) => is_array($part) ? ($part['text'] ?? '') : (string) $part, $response['choices'][0]['message']['content']))
1107 + : (string) $response['choices'][0]['message']['content'];
1108 + } elseif (isset($response['content'][0]['text'])) {
1109 + $ai_text = (string) $response['content'][0]['text'];
1110 + } elseif (isset($response['candidates'][0]['content']['parts'][0]['text'])) {
1111 + $ai_text = (string) $response['candidates'][0]['content']['parts'][0]['text'];
1112 + } elseif (isset($response['content']) && is_string($response['content'])) {
1113 + $ai_text = $response['content'];
1114 + }
1115 +
1116 + $tokens = $response['usage']['total_tokens']
1117 + ?? $response['usage']['output_tokens']
1118 + ?? ($response['usageMetadata']['totalTokenCount'] ?? 0);
1119 +
1120 + // Diagnostics for callers that must explain an empty answer: why
1121 + // generation stopped, and how much of the
1122 + // completion budget hidden reasoning consumed (OpenAI reasoning models).
1123 + // All three provider shapes are read — Gemini reports the stop reason
1124 + // per candidate, so without that arm the diagnostic was always blank
1125 + // for exactly the provider whose truncation it exists to explain.
1126 + $finish_reason = (string) ($response['choices'][0]['finish_reason']
1127 + ?? ($response['stop_reason']
1128 + ?? ($response['candidates'][0]['finishReason'] ?? '')));
1129 + $reasoning_tokens = (int) ($response['usage']['completion_tokens_details']['reasoning_tokens'] ?? 0);
1130 +
1131 + return [
1132 + 'ai_text' => $ai_text,
1133 + 'tokens' => (int) $tokens,
1134 + 'finish_reason' => $finish_reason,
1135 + 'reasoning_tokens' => $reasoning_tokens,
1136 + ];
1137 + }
1138 +
1139 + /**
1140 + * Trim a meta description to at most 160 characters at a word boundary,
1141 + * preserving sentence-ish endings and avoiding broken words. Descriptions of
1142 + * 160 characters or fewer are returned unchanged.
1143 + *
1144 + * @param string $desc Meta description.
1145 + * @return string Description clamped to <= 160 characters.
1146 + */
1147 + private function clamp_meta_description(string $desc): string {
1148 + $desc = trim($desc);
1149 + if (mb_strlen($desc) <= 160) {
1150 + return $desc;
1151 + }
1152 +
1153 + $cut = mb_substr($desc, 0, 160);
1154 + $last_space = mb_strrpos($cut, ' ');
1155 + // Only back off to the last space when doing so keeps us at/above 120.
1156 + if ($last_space !== false && $last_space >= 120) {
1157 + $cut = mb_substr($cut, 0, $last_space);
1158 + }
1159 +
1160 + return rtrim($cut, " \t\n\r\0\x0B,;:-");
1161 + }
1162 +
1163 + /**
1164 + * Whether a title contains any of the given words, using the same
1165 + * case-insensitive substring match the scorer's title checks use.
1166 + *
1167 + * @param string $title Title to test.
1168 + * @param string[] $words Words to look for.
1169 + * @return bool
1170 + */
1171 + private function title_contains_word(string $title, array $words): bool {
1172 + $title_lower = strtolower($title);
1173 + foreach ($words as $word) {
1174 + if ($word !== '' && strpos($title_lower, strtolower($word)) !== false) {
1175 + return true;
1176 + }
1177 + }
1178 + return false;
1179 + }
1180 +
1181 + /**
1182 + * Pull a named string field out of an AI response, tolerating both JSON and
1183 + * plain-text replies.
1184 + *
1185 + * @param string $ai_text Raw AI text.
1186 + * @param string $field JSON field to read (e.g. 'title', 'description').
1187 + * @return string Sanitized value (without surrounding quotes), or '' on failure.
1188 + */
1189 + private function extract_json_field(string $ai_text, string $field): string {
1190 + $ai_text = trim($ai_text);
1191 + if ($ai_text === '') {
1192 + return '';
1193 + }
1194 +
1195 + // Prefer a JSON object with the requested field.
1196 + $json_start = strpos($ai_text, '{');
1197 + $json_end = strrpos($ai_text, '}');
1198 + if ($json_start !== false && $json_end !== false && $json_end > $json_start) {
1199 + $decoded = json_decode(substr($ai_text, $json_start, $json_end - $json_start + 1), true);
1200 + if (is_array($decoded) && !empty($decoded[$field])) {
1201 + return sanitize_text_field(trim((string) $decoded[$field], " \t\n\r\0\x0B\"'"));
1202 + }
1203 + }
1204 +
1205 + // Fall back to the first non-empty line, stripping wrapping quotes.
1206 + $first_line = strtok($ai_text, "\n");
1207 + return sanitize_text_field(trim((string) $first_line, " \t\n\r\0\x0B\"'"));
1208 + }
1209 +
1210 + /**
252 1211 * Analyze content for SEO optimization
253 1212 *
254 1213 * @param string $content Content to analyze
255 1214 * @param array $metadata Existing metadata
@@ -257,18 +1216,18 @@
257 1216 * @throws \Exception If analysis fails
258 1217 */
259 1218 public function analyze_content(string $content, array $metadata = []): array {
260 1219 if (!$this->client) {
261 - throw new \Exception('AI client not initialized');
1220 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
262 1221 }
263 1222
264 1223 $user_id = get_current_user_id();
265 1224
266 1225 // 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'));
1226 + $user_has_api_key = $this->has_provider_credentials();
268 1227
269 1228 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.');
1229 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
271 1230 }
272 1231
273 1232 // Check rate limits
274 1233 if (!$this->check_rate_limit($user_id, 'content_analysis')) {
@@ -275,12 +1234,12 @@
275 1234 throw new \Exception('Rate limit exceeded. Please try again later.');
276 1235 }
277 1236
278 1237 // Check cache first
279 - $cache_key = 'content_analysis_' . md5($content . serialize($metadata));
1238 + $cache_key = 'content_analysis_' . md5($content . wp_json_encode($metadata));
280 1239 $cached_result = $this->cache->get($cache_key);
281 1240 if ($cached_result) {
282 - return $cached_result;
1241 + return $cached_result['data'] ?? $cached_result;
283 1242 }
284 1243
285 1244 try {
286 1245 // Analyze content using AI
@@ -312,9 +1271,9 @@
312 1271 public function test_api_connection(): array {
313 1272 if (!$this->client) {
314 1273 return [
315 1274 'success' => false,
316 - 'message' => 'AI client not initialized. Please configure your API key.',
1275 + 'message' => $this->get_client_unavailable_message(),
317 1276 ];
318 1277 }
319 1278
320 1279 try {
@@ -353,20 +1312,24 @@
353 1312
354 1313 $user_id = get_current_user_id();
355 1314
356 1315 // 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'));
1316 + $user_has_api_key = $this->has_provider_credentials();
358 1317
359 1318 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.');
1319 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
361 1320 }
362 1321
363 1322 // Generate cache key using existing pattern
364 - $cache_key = 'site_identity_' . md5(serialize($site_data) . serialize($options)) . '_' . $user_id;
1323 + $cache_key = 'site_identity_' . md5(wp_json_encode($site_data) . wp_json_encode($options)) . '_' . $user_id;
365 1324
366 1325 // Check existing cache infrastructure
1326 + // Cache_Manager::set() wraps entries as ['data' => …], so unwrap
1327 + // before inspecting — checking optimized_data on the wrapped array
1328 + // never matches and the cache would never hit.
367 1329 $cached_result = $this->cache->get($cache_key);
368 - if ($cached_result !== null && !empty($cached_result['optimized_data'])) {
1330 + $cached_result = $cached_result['data'] ?? $cached_result;
1331 + if (!empty($cached_result['optimized_data'])) {
369 1332 return $cached_result;
370 1333 }
371 1334
372 1335 // Check rate limiting
@@ -377,9 +1340,9 @@
377 1340 // Get AI client
378 1341 $client = $this->get_client();
379 1342
380 1343 if (!$client) {
381 - throw new \Exception('AI client not initialized. Please check your API key configuration.');
1344 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
382 1345 }
383 1346
384 1347 // Perform AI optimization
385 1348 $optimization_results = $client->optimize_site_identity($site_data, $options);
@@ -390,9 +1353,9 @@
390 1353 }
391 1354
392 1355 // Add metadata
393 1356 $optimization_results['ai_model'] = $client->get_model();
394 - $optimization_results['provider'] = $this->settings->get('ai_provider', 'openai');
1357 + $optimization_results['provider'] = $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
395 1358 $optimization_results['generated_at'] = gmdate('Y-m-d H:i:s');
396 1359 $optimization_results['user_id'] = $user_id;
397 1360
398 1361 // Cache the results (24 hours)
@@ -427,20 +1390,24 @@
427 1390
428 1391 $user_id = get_current_user_id();
429 1392
430 1393 // 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'));
1394 + $user_has_api_key = $this->has_provider_credentials();
432 1395
433 1396 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.');
1397 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
435 1398 }
436 1399
437 1400 // Generate cache key
438 - $cache_key = 'llms_txt_' . md5(serialize($website_data) . serialize($options)) . '_' . $user_id;
1401 + $cache_key = 'llms_txt_' . md5(wp_json_encode($website_data) . wp_json_encode($options)) . '_' . $user_id;
439 1402
440 1403 // Check cache first
1404 + // Cache_Manager::set() wraps entries as ['data' => …], so unwrap
1405 + // before inspecting — checking optimized_data on the wrapped array
1406 + // never matches and the cache would never hit.
441 1407 $cached_result = $this->cache->get($cache_key);
442 - if ($cached_result !== null && !empty($cached_result['optimized_data'])) {
1408 + $cached_result = $cached_result['data'] ?? $cached_result;
1409 + if (!empty($cached_result['optimized_data'])) {
443 1410 return $cached_result;
444 1411 }
445 1412
446 1413 // Check rate limiting
@@ -451,9 +1418,9 @@
451 1418 // Get AI client
452 1419 $client = $this->get_client();
453 1420
454 1421 if (!$client) {
455 - throw new \Exception('AI client not initialized. Please check your API key configuration.');
1422 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
456 1423 }
457 1424
458 1425 // Perform AI optimization
459 1426 $optimization_results = $client->optimize_llms_txt($website_data, $options);
@@ -464,9 +1431,9 @@
464 1431 }
465 1432
466 1433 // Add metadata
467 1434 $optimization_results['ai_model'] = $client->get_model();
468 - $optimization_results['provider'] = $this->settings->get('ai_provider', 'openai');
1435 + $optimization_results['provider'] = $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
469 1436 $optimization_results['generated_at'] = gmdate('Y-m-d H:i:s');
470 1437 $optimization_results['user_id'] = $user_id;
471 1438
472 1439 // Cache the results (24 hours)
@@ -496,19 +1463,42 @@
496 1463 'models' => ['gpt-5-nano', 'gpt-5-mini', 'gpt-5', 'gpt-4o'],
497 1464 'requires_key' => true,
498 1465 ],
499 1466 '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'],
1467 + // The vendor, not the model family: the other three entries name
1468 + // vendors, and a family name goes stale on every rename (#572).
1469 + 'name' => 'Anthropic',
1470 + 'description' => 'Claude Opus 5, Opus 4.8, Sonnet 5, and Haiku 4.5',
1471 + 'models' => ['claude-opus-5', 'claude-opus-4-8', 'claude-sonnet-5', 'claude-haiku-4-5'],
503 1472 'requires_key' => true,
504 1473 ],
505 1474 'gemini' => [
506 1475 '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'],
1476 + 'description' => 'Gemini 3.x models',
1477 + // 2.5 Pro / 2.5 Flash-Lite retire in Oct 2026 and 3.1 Pro only
1478 + // ships under its -preview id, so none of the three belong in a
1479 + // list users pick from (#572).
1480 + 'models' => ['gemini-3.5-flash', 'gemini-3.1-flash-lite', 'gemini-3.1-pro-preview'],
509 1481 'requires_key' => true,
510 1482 ],
1483 + 'openrouter' => [
1484 + 'name' => 'OpenRouter',
1485 + 'description' => 'Unified access to many models via one key',
1486 + // claude-3.5-sonnet is retired (Claude_Client::normalize_model
1487 + // already self-heals it on the direct path) and
1488 + // gemini-2.0-flash-001 was shut down on 1 Jun 2026 (#572).
1489 + 'models' => ['openai/gpt-4o-mini', 'anthropic/claude-sonnet-5', 'google/gemini-3.5-flash', 'meta-llama/llama-3.3-70b-instruct', 'deepseek/deepseek-chat'],
1490 + 'requires_key' => true,
1491 + ],
1492 + 'openai_compatible' => [
1493 + 'name' => 'OpenAI-compatible endpoint',
1494 + 'description' => 'Any server speaking the OpenAI Chat Completions API: Ollama, LM Studio, vLLM, Azure OpenAI, Groq, Together, DeepSeek or your own gateway',
1495 + // Deliberately empty: the model list belongs to the server the
1496 + // user names, and is read from GET {base}/models at runtime.
1497 + 'models' => [],
1498 + 'requires_key' => false,
1499 + 'requires_base_url' => true,
1500 + ],
511 1501 ];
512 1502 }
513 1503
514 1504 /**
@@ -516,14 +1506,20 @@
516 1506 *
517 1507 * @return array Provider status
518 1508 */
519 1509 public function get_provider_status(): array {
520 - $provider = $this->settings->get('ai_provider', 'openai');
521 - $api_key = $this->settings->get($provider . '_api_key');
1510 + $provider = $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
522 1511
1512 + // "Configured" is per-provider: a key for the hosted three, a base URL
1513 + // plus a model id for an OpenAI-compatible endpoint, whose key is
1514 + // optional (#721).
1515 + $configured = Settings::AI_PROVIDER_NONE === $provider
1516 + ? false
1517 + : $this->has_provider_credentials();
1518 +
523 1519 return [
524 1520 'provider' => $provider,
525 - 'configured' => !empty($api_key),
1521 + 'configured' => $configured,
526 1522 'connected' => $this->client !== null,
527 1523 ];
528 1524 }
529 1525
@@ -592,41 +1588,39 @@
592 1588 }
593 1589 }
594 1590
595 1591 /**
596 - * Check rate limits
1592 + * Check rate limits.
597 1593 *
598 - * @return bool True if within limits
1594 + * Backed by a per-minute transient counter so the limit is enforced across
1595 + * requests. A fresh Manager is constructed on every AJAX/REST call, so the
1596 + * previous in-memory array always started empty and never limited anything —
1597 + * letting an edit_posts user loop the metadata AJAX and drive unbounded paid
1598 + * AI-provider spend.
1599 + *
1600 + * @param int|null $user_id Optional user id (defaults to the current user).
1601 + * @param string $context Rate-limit bucket (keeps distinct flows separate).
1602 + * @return bool True if within limits.
599 1603 */
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
1604 + private function check_rate_limit(?int $user_id = null, string $context = 'ai'): bool {
1605 + $user_id = $user_id ?? get_current_user_id();
1606 + $max_requests = (int) $this->settings->get('max_requests_per_minute', 0);
605 1607
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 - );
1608 + // A non-positive limit means "unlimited".
1609 + if ($max_requests <= 0) {
1610 + return true;
1611 + }
613 1612
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 - );
1613 + // Counter is keyed to the current wall-clock minute; the transient TTL
1614 + // lets the window roll over on its own.
1615 + $minute_key = "thinkrank_ai_rate_{$context}_{$user_id}_" . floor(time() / MINUTE_IN_SECONDS);
1616 + $attempts = (int) get_transient($minute_key);
622 1617
623 - if (count($user_requests) >= $max_requests) {
1618 + if ($attempts >= $max_requests) {
624 1619 return false;
625 1620 }
626 1621
627 - // Add current request
628 - $this->rate_limits["user_{$user_id}_{$current_time}"] = $current_time;
1622 + set_transient($minute_key, $attempts + 1, MINUTE_IN_SECONDS);
629 1623
630 1624 return true;
631 1625 }
632 1626
@@ -655,9 +1649,9 @@
655 1649 if ($raw_response) {
656 1650 $metadata['raw_response'] = $raw_response;
657 1651 }
658 1652
659 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- AI usage logging requires direct database access
1653 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- AI usage logging requires direct database access
660 1654 $wpdb->insert(
661 1655 $table_name,
662 1656 [
663 1657 'user_id' => $user_id,
@@ -662,14 +1656,26 @@
662 1656 [
663 1657 'user_id' => $user_id,
664 1658 'action' => $action,
665 1659 'tokens_used' => $tokens_used,
666 - 'provider' => $this->settings->get('ai_provider', 'openai'),
1660 + 'provider' => $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE),
667 1661 'metadata' => !empty($metadata) ? wp_json_encode($metadata) : null,
668 1662 'created_at' => current_time('mysql'),
669 1663 ],
670 1664 ['%d', '%s', '%d', '%s', '%s', '%s']
671 1665 );
1666 +
1667 + /**
1668 + * Fires after an AI usage row is recorded.
1669 + *
1670 + * Analytics listens to drop its cached overview, so the Usages page
1671 + * reflects this action immediately instead of after the 600s TTL.
1672 + *
1673 + * @since 2.2.1
1674 + *
1675 + * @param int $user_id User the usage was recorded against.
1676 + */
1677 + do_action('thinkrank_ai_usage_logged', $user_id);
672 1678 }
673 1679
674 1680 /**
675 1681 * Cleanup expired cache entries
@@ -698,20 +1704,24 @@
698 1704
699 1705 $user_id = get_current_user_id();
700 1706
701 1707 // 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'));
1708 + $user_has_api_key = $this->has_provider_credentials();
703 1709
704 1710 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.');
1711 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
706 1712 }
707 1713
708 1714 // Generate cache key using existing pattern
709 - $cache_key = 'homepage_meta_' . md5(serialize($content_data) . serialize($options)) . '_' . $user_id;
1715 + $cache_key = 'homepage_meta_' . md5(wp_json_encode($content_data) . wp_json_encode($options)) . '_' . $user_id;
710 1716
711 1717 // Check existing cache infrastructure
1718 + // Cache_Manager::set() wraps entries as ['data' => …], so unwrap
1719 + // before inspecting — checking optimized_data on the wrapped array
1720 + // never matches and the cache would never hit.
712 1721 $cached_result = $this->cache->get($cache_key);
713 - if ($cached_result !== null && !empty($cached_result['optimized_data'])) {
1722 + $cached_result = $cached_result['data'] ?? $cached_result;
1723 + if (!empty($cached_result['optimized_data'])) {
714 1724 return $cached_result;
715 1725 }
716 1726
717 1727 // Check rate limiting
@@ -722,9 +1732,9 @@
722 1732 // Get AI client
723 1733 $client = $this->get_client();
724 1734
725 1735 if (!$client) {
726 - throw new \Exception('AI client not initialized. Please check your API key configuration.');
1736 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
727 1737 }
728 1738
729 1739 // Perform AI optimization
730 1740 $optimization_results = $client->optimize_homepage_meta($content_data, $options);
@@ -735,9 +1745,9 @@
735 1745 }
736 1746
737 1747 // Add metadata
738 1748 $optimization_results['ai_model'] = $client->get_model();
739 - $optimization_results['provider'] = $this->settings->get('ai_provider', 'openai');
1749 + $optimization_results['provider'] = $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
740 1750 $optimization_results['generated_at'] = gmdate('Y-m-d H:i:s');
741 1751 $optimization_results['user_id'] = $user_id;
742 1752
743 1753 // Cache the results (24 hours)
@@ -772,20 +1782,24 @@
772 1782
773 1783 $user_id = get_current_user_id();
774 1784
775 1785 // 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'));
1786 + $user_has_api_key = $this->has_provider_credentials();
777 1787
778 1788 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.');
1789 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
780 1790 }
781 1791
782 1792 // Generate cache key using existing pattern
783 - $cache_key = 'homepage_hero_' . md5(serialize($hero_data) . serialize($options)) . '_' . $user_id;
1793 + $cache_key = 'homepage_hero_' . md5(wp_json_encode($hero_data) . wp_json_encode($options)) . '_' . $user_id;
784 1794
785 1795 // Check existing cache infrastructure
1796 + // Cache_Manager::set() wraps entries as ['data' => …], so unwrap
1797 + // before inspecting — checking optimized_data on the wrapped array
1798 + // never matches and the cache would never hit.
786 1799 $cached_result = $this->cache->get($cache_key);
787 - if ($cached_result !== null && !empty($cached_result['optimized_data'])) {
1800 + $cached_result = $cached_result['data'] ?? $cached_result;
1801 + if (!empty($cached_result['optimized_data'])) {
788 1802 return $cached_result;
789 1803 }
790 1804
791 1805 // Check rate limiting
@@ -796,9 +1810,9 @@
796 1810 // Get AI client
797 1811 $client = $this->get_client();
798 1812
799 1813 if (!$client) {
800 - throw new \Exception('AI client not initialized. Please check your API key configuration.');
1814 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
801 1815 }
802 1816
803 1817 // Perform AI optimization
804 1818 $optimization_results = $client->optimize_homepage_hero($hero_data, $options);
@@ -809,9 +1823,9 @@
809 1823 }
810 1824
811 1825 // Add metadata
812 1826 $optimization_results['ai_model'] = $client->get_model();
813 - $optimization_results['provider'] = $this->settings->get('ai_provider', 'openai');
1827 + $optimization_results['provider'] = $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
814 1828 $optimization_results['generated_at'] = gmdate('Y-m-d H:i:s');
815 1829 $optimization_results['user_id'] = $user_id;
816 1830
817 1831 // Cache the results (24 hours)