PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.9.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.9.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 1.0.2 All 50 releases
thinkrank / includes / ai / class-manager.php

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

1,845 lines 76.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * AI Manager Class
4 *
5 * Handles AI provider integration and management
6 *
7 * @package ThinkRank\AI
8 * @since 1.0.0
9 */
10
11 declare(strict_types=1);
12
13 namespace ThinkRank\AI;
14
15 use ThinkRank\Core\Settings;
16
17
18 // Prevent direct access
19 if (!defined('ABSPATH')) {
20 exit;
21 }
22
23 /**
24 * AI Manager Class
25 *
26 * Single Responsibility: Manage AI providers and requests
27 *
28 * @since 1.0.0
29 */
30 class Manager {
31
32 /**
33 * Settings instance
34 *
35 * @var Settings
36 */
37 private Settings $settings;
38
39
40
41 /**
42 * Cache manager instance
43 *
44 * @var Cache_Manager
45 */
46 private Cache_Manager $cache;
47
48 /**
49 * Current AI client
50 *
51 * @var OpenAI_Client|Claude_Client|null
52 */
53 private $client = null;
54
55
56 /**
57 * Constructor
58 *
59 * @param Settings|null $settings Settings instance
60 */
61 public function __construct(?Settings $settings = null) {
62 $this->settings = $settings ?? Settings::instance();
63 $this->cache = new Cache_Manager((int) $this->settings->get('cache_duration', 3600));
64 }
65
66 /**
67 * Initialize AI manager
68 *
69 * @return void
70 */
71 public function init(): void {
72 // Initialize AI client based on settings
73 add_action('init', [$this, 'initialize_client']);
74
75 // Schedule cache cleanup
76 add_action('thinkrank_daily_cleanup', [$this, 'cleanup_cache']);
77
78 // Add AJAX handlers for AI requests
79 add_action('wp_ajax_thinkrank_generate_metadata', [$this, 'ajax_generate_metadata']);
80 add_action('wp_ajax_thinkrank_test_api_connection', [$this, 'ajax_test_connection']);
81 }
82
83 /**
84 * Initialize AI client
85 *
86 * @return void
87 *
88 * @throws \Exception On failure.
89 */
90 public function initialize_client(): void {
91 $provider = $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
92
93 // No provider chosen yet (a fresh install, or the user cleared it). That
94 // is a normal unconfigured state, not a failure — leave $this->client
95 // null and let get_client_unavailable_message() explain it (#572).
96 if (Settings::AI_PROVIDER_NONE === $provider) {
97 return;
98 }
99
100 try {
101 switch ($provider) {
102 case 'openai':
103 $api_key = $this->settings->get('openai_api_key');
104 if ($api_key) {
105 // Allow any model id (incl. user-entered custom models);
106 // only fall back to the default when none is set.
107 $model = $this->settings->get('openai_model', Settings::DEFAULT_OPENAI_MODEL);
108 if (empty($model)) {
109 $model = Settings::DEFAULT_OPENAI_MODEL;
110 }
111 // OpenAI's reasoning models (GPT-5/o-series) spend a long
112 // time on reasoning tokens before emitting content, so
113 // large completions (content briefs) regularly outlive the
114 // 120s used for the other providers. Give them 300s.
115 $timeout = 300;
116 $this->client = new OpenAI_Client($api_key, $model, $timeout);
117
118 // OpenAI client created successfully
119 }
120 break;
121
122 case 'claude':
123 $api_key = $this->settings->get('claude_api_key');
124 if ($api_key) {
125 // Allow any model id (incl. user-entered custom models);
126 // only fall back to the default when none is set.
127 $model = $this->settings->get('claude_model', Settings::DEFAULT_CLAUDE_MODEL);
128 if (empty($model)) {
129 $model = Settings::DEFAULT_CLAUDE_MODEL;
130 }
131 // Use 120-second timeout for complex AI operations
132 $timeout = 120;
133 $this->client = new Claude_Client($api_key, $model, $timeout);
134
135 // Claude client created successfully
136 }
137 break;
138
139 case 'gemini':
140 $api_key = $this->settings->get('gemini_api_key');
141 if ($api_key) {
142 // Allow any model id (incl. user-entered custom models);
143 // only fall back to the default when none is set.
144 $model = $this->settings->get('gemini_model', Settings::DEFAULT_GEMINI_MODEL);
145 if (empty($model)) {
146 $model = Settings::DEFAULT_GEMINI_MODEL;
147 }
148 // Use 120-second timeout for complex AI operations
149 $timeout = 120;
150 $this->client = new Gemini_Client($api_key, $model, $timeout);
151 }
152 break;
153
154 case 'openrouter':
155 $api_key = $this->settings->get('openrouter_api_key');
156 if ($api_key) {
157 // Allow any model id (incl. user-entered custom models);
158 // only fall back to the default when none is set.
159 $model = $this->settings->get('openrouter_model', Settings::DEFAULT_OPENROUTER_MODEL);
160 if (empty($model)) {
161 $model = Settings::DEFAULT_OPENROUTER_MODEL;
162 }
163 // Use 120-second timeout for complex AI operations
164 $timeout = 120;
165 $this->client = new OpenRouter_Client($api_key, $model, $timeout);
166 }
167 break;
168
169 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
189 default:
190 throw new \Exception("Unsupported AI provider: {$provider}");
191 }
192 } catch (\Exception $e) {
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 }
200 }
201 }
202
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 /**
317 * Force re-initialization of client (useful after settings change)
318 *
319 * @return void
320 */
321 public function reinitialize_client(): void {
322 $this->client = null;
323 $this->initialize_client();
324 }
325
326 /**
327 * Get the AI client instance
328 *
329 * @return OpenAI_Client|Claude_Client|null AI client instance
330 * @throws \Exception If client cannot be initialized
331 */
332 public function get_client() {
333 // Initialize client if not already done
334 if (!$this->client) {
335 $this->initialize_client();
336 }
337
338 // If still not available, throw error
339 if (!$this->client) {
340 throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
341 }
342
343 return $this->client;
344 }
345
346 /**
347 * Generate SEO metadata for content
348 *
349 * @param string $content Content to analyze
350 * @param array $options Generation options
351 * @return array Generated metadata
352 * @throws \Exception If generation fails
353 */
354 public function generate_seo_metadata(string $content, array $options = []): array {
355 // Check if client is available, try to initialize if not
356 if (!$this->client) {
357 $this->initialize_client();
358
359 // If still not available, throw error
360 if (!$this->client) {
361 throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
362 }
363 }
364
365 // Check rate limits
366 if (!$this->check_rate_limit()) {
367 throw new \Exception('Rate limit exceeded. Please try again later.');
368 }
369
370 // Get current user for logging
371 $user_id = get_current_user_id();
372
373 // Generate cache key
374 $cache_key = $this->cache->generate_content_key($content, $options);
375
376 // Check cache first
377 $cached_result = $this->cache->get($cache_key);
378 if ($cached_result !== null) {
379 return $cached_result['data'];
380 }
381
382 try {
383 // Generate metadata using AI
384 $metadata = $this->client->generate_seo_metadata($content, $options);
385
386 // Ensure user has configured their API key
387 $user_has_api_key = $this->has_provider_credentials();
388
389 if (!$user_has_api_key) {
390 throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
391 }
392
393 // Cache the result
394 $this->cache->set($cache_key, $metadata);
395
396 // Log usage with actual model information and raw AI text (Content Brief pattern)
397 $actual_model = $this->client ? $this->client->get_model() : null;
398 $ai_text = $metadata['_ai_text'] ?? null;
399 $this->log_ai_usage($user_id, 'SEO Metadata', $metadata['tokens_used'] ?? 0, $actual_model, $ai_text);
400
401 // Remove AI text from returned data to keep it clean
402 unset($metadata['_ai_text']);
403
404 return $metadata;
405
406 } catch (\Exception $e) {
407 throw $e;
408 }
409 }
410
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 /**
1211 * Analyze content for SEO optimization
1212 *
1213 * @param string $content Content to analyze
1214 * @param array $metadata Existing metadata
1215 * @return array Analysis results
1216 * @throws \Exception If analysis fails
1217 */
1218 public function analyze_content(string $content, array $metadata = []): array {
1219 if (!$this->client) {
1220 throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1221 }
1222
1223 $user_id = get_current_user_id();
1224
1225 // Ensure user has configured their API key
1226 $user_has_api_key = $this->has_provider_credentials();
1227
1228 if (!$user_has_api_key) {
1229 throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1230 }
1231
1232 // Check rate limits
1233 if (!$this->check_rate_limit($user_id, 'content_analysis')) {
1234 throw new \Exception('Rate limit exceeded. Please try again later.');
1235 }
1236
1237 // Check cache first
1238 $cache_key = 'content_analysis_' . md5($content . wp_json_encode($metadata));
1239 $cached_result = $this->cache->get($cache_key);
1240 if ($cached_result) {
1241 return $cached_result['data'] ?? $cached_result;
1242 }
1243
1244 try {
1245 // Analyze content using AI
1246 $analysis = $this->client->analyze_content($content, $metadata);
1247
1248 // Cache the result
1249 $this->cache->set($cache_key, $analysis);
1250
1251 // Log usage with actual model information and raw AI text (Content Brief pattern)
1252 $actual_model = $this->client ? $this->client->get_model() : null;
1253 $ai_text = $analysis['_ai_text'] ?? null;
1254 $this->log_ai_usage($user_id, 'Content Analysis', $analysis['tokens_used'] ?? 0, $actual_model, $ai_text);
1255
1256 // Remove AI text from returned data to keep it clean
1257 unset($analysis['_ai_text']);
1258
1259 return $analysis;
1260
1261 } catch (\Exception $e) {
1262 throw $e;
1263 }
1264 }
1265
1266 /**
1267 * Test API connection
1268 *
1269 * @return array Test result
1270 */
1271 public function test_api_connection(): array {
1272 if (!$this->client) {
1273 return [
1274 'success' => false,
1275 'message' => $this->get_client_unavailable_message(),
1276 ];
1277 }
1278
1279 try {
1280 $success = $this->client->test_connection();
1281
1282 return [
1283 'success' => $success,
1284 'message' => $success
1285 ? 'API connection successful!'
1286 : 'API connection failed. Please check your API key.',
1287 ];
1288
1289 } catch (\Exception $e) {
1290 return [
1291 'success' => false,
1292 'message' => 'Connection test failed: ' . $e->getMessage(),
1293 ];
1294 }
1295 }
1296
1297 /**
1298 * Optimize site identity using AI
1299 *
1300 * @since 1.0.0
1301 *
1302 * @param array $site_data Site data to optimize
1303 * @param array $options Optimization options
1304 * @return array Optimization results
1305 * @throws \Exception If optimization fails
1306 */
1307 public function optimize_site_identity(array $site_data, array $options = []): array {
1308 // Validate input
1309 if (empty($site_data)) {
1310 throw new \Exception('Site data cannot be empty');
1311 }
1312
1313 $user_id = get_current_user_id();
1314
1315 // Ensure user has configured their API key
1316 $user_has_api_key = $this->has_provider_credentials();
1317
1318 if (!$user_has_api_key) {
1319 throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1320 }
1321
1322 // Generate cache key using existing pattern
1323 $cache_key = 'site_identity_' . md5(wp_json_encode($site_data) . wp_json_encode($options)) . '_' . $user_id;
1324
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.
1329 $cached_result = $this->cache->get($cache_key);
1330 $cached_result = $cached_result['data'] ?? $cached_result;
1331 if (!empty($cached_result['optimized_data'])) {
1332 return $cached_result;
1333 }
1334
1335 // Check rate limiting
1336 if (!$this->check_rate_limit()) {
1337 throw new \Exception('Rate limit exceeded for AI optimization requests.');
1338 }
1339
1340 // Get AI client
1341 $client = $this->get_client();
1342
1343 if (!$client) {
1344 throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1345 }
1346
1347 // Perform AI optimization
1348 $optimization_results = $client->optimize_site_identity($site_data, $options);
1349
1350 // Validate that we got meaningful results
1351 if (empty($optimization_results) || empty($optimization_results['optimized_data'])) {
1352 throw new \Exception('AI optimization returned empty results. Please try again.');
1353 }
1354
1355 // Add metadata
1356 $optimization_results['ai_model'] = $client->get_model();
1357 $optimization_results['provider'] = $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
1358 $optimization_results['generated_at'] = gmdate('Y-m-d H:i:s');
1359 $optimization_results['user_id'] = $user_id;
1360
1361 // Cache the results (24 hours)
1362 $this->cache->set($cache_key, $optimization_results, 86400);
1363
1364 // Record usage with actual model information and raw AI text (Content Brief pattern)
1365 $actual_model = $optimization_results['ai_model'] ?? ($client ? $client->get_model() : null);
1366 $ai_text = $optimization_results['_ai_text'] ?? null;
1367 $this->log_ai_usage($user_id, 'Site Identity Optimization', $optimization_results['tokens_used'] ?? 0, $actual_model, $ai_text);
1368
1369 // Remove AI text from returned data to keep it clean
1370 unset($optimization_results['_ai_text']);
1371
1372 return $optimization_results;
1373 }
1374
1375 /**
1376 * Optimize LLMs.txt content using AI
1377 *
1378 * @since 1.0.0
1379 *
1380 * @param array $website_data Website data to optimize
1381 * @param array $options Optimization options
1382 * @return array Optimization results
1383 * @throws \Exception If optimization fails
1384 */
1385 public function optimize_llms_txt(array $website_data, array $options = []): array {
1386 // Validate input
1387 if (empty($website_data)) {
1388 throw new \Exception('Website data cannot be empty');
1389 }
1390
1391 $user_id = get_current_user_id();
1392
1393 // Ensure user has configured their API key
1394 $user_has_api_key = $this->has_provider_credentials();
1395
1396 if (!$user_has_api_key) {
1397 throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1398 }
1399
1400 // Generate cache key
1401 $cache_key = 'llms_txt_' . md5(wp_json_encode($website_data) . wp_json_encode($options)) . '_' . $user_id;
1402
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.
1407 $cached_result = $this->cache->get($cache_key);
1408 $cached_result = $cached_result['data'] ?? $cached_result;
1409 if (!empty($cached_result['optimized_data'])) {
1410 return $cached_result;
1411 }
1412
1413 // Check rate limiting
1414 if (!$this->check_rate_limit()) {
1415 throw new \Exception('Rate limit exceeded for AI optimization requests.');
1416 }
1417
1418 // Get AI client
1419 $client = $this->get_client();
1420
1421 if (!$client) {
1422 throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1423 }
1424
1425 // Perform AI optimization
1426 $optimization_results = $client->optimize_llms_txt($website_data, $options);
1427
1428 // Validate that we got meaningful results
1429 if (empty($optimization_results) || empty($optimization_results['optimized_data'])) {
1430 throw new \Exception('AI optimization returned empty results. Please try again.');
1431 }
1432
1433 // Add metadata
1434 $optimization_results['ai_model'] = $client->get_model();
1435 $optimization_results['provider'] = $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
1436 $optimization_results['generated_at'] = gmdate('Y-m-d H:i:s');
1437 $optimization_results['user_id'] = $user_id;
1438
1439 // Cache the results (24 hours)
1440 $this->cache->set($cache_key, $optimization_results, 86400);
1441
1442 // Record usage with actual model information and raw AI text (Content Brief pattern)
1443 $actual_model = $optimization_results['ai_model'] ?? ($client ? $client->get_model() : null);
1444 $ai_text = $optimization_results['_ai_text'] ?? null;
1445 $this->log_ai_usage($user_id, 'LLMs.txt Optimization', $optimization_results['tokens_used'] ?? 0, $actual_model, $ai_text);
1446
1447 // Remove AI text from returned data to keep it clean
1448 unset($optimization_results['_ai_text']);
1449
1450 return $optimization_results;
1451 }
1452
1453 /**
1454 * Get available AI providers
1455 *
1456 * @return array Available providers
1457 */
1458 public function get_available_providers(): array {
1459 return [
1460 'openai' => [
1461 'name' => 'OpenAI',
1462 'description' => 'GPT‑5 series and GPT‑4o',
1463 'models' => ['gpt-5-nano', 'gpt-5-mini', 'gpt-5', 'gpt-4o'],
1464 'requires_key' => true,
1465 ],
1466 'claude' => [
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'],
1472 'requires_key' => true,
1473 ],
1474 'gemini' => [
1475 'name' => 'Google Gemini',
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'],
1481 'requires_key' => true,
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 ],
1501 ];
1502 }
1503
1504 /**
1505 * Get current provider status
1506 *
1507 * @return array Provider status
1508 */
1509 public function get_provider_status(): array {
1510 $provider = $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
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
1519 return [
1520 'provider' => $provider,
1521 'configured' => $configured,
1522 'connected' => $this->client !== null,
1523 ];
1524 }
1525
1526 /**
1527 * AJAX handler for generating metadata
1528 *
1529 * @return void
1530 */
1531 public function ajax_generate_metadata(): void {
1532 // Verify nonce
1533 $nonce = sanitize_text_field(wp_unslash($_POST['nonce'] ?? ''));
1534 if (!wp_verify_nonce($nonce, 'thinkrank_ai_nonce')) {
1535 wp_die('Security check failed');
1536 }
1537
1538 // Check permissions
1539 if (!current_user_can('edit_posts')) {
1540 wp_die('Insufficient permissions');
1541 }
1542
1543 $content = sanitize_textarea_field(wp_unslash($_POST['content'] ?? ''));
1544 $options = [
1545 'target_keyword' => sanitize_text_field(wp_unslash($_POST['target_keyword'] ?? '')),
1546 'content_type' => sanitize_text_field(wp_unslash($_POST['content_type'] ?? 'blog_post')),
1547 'tone' => sanitize_text_field(wp_unslash($_POST['tone'] ?? 'professional')),
1548 ];
1549
1550 try {
1551 $metadata = $this->generate_seo_metadata($content, $options);
1552
1553 wp_send_json_success([
1554 'metadata' => $metadata,
1555 'message' => __('SEO metadata generated successfully!', 'thinkrank'),
1556 ]);
1557
1558 } catch (\Exception $e) {
1559 wp_send_json_error([
1560 'message' => $e->getMessage(),
1561 ]);
1562 }
1563 }
1564
1565 /**
1566 * AJAX handler for testing API connection
1567 *
1568 * @return void
1569 */
1570 public function ajax_test_connection(): void {
1571 // Verify nonce
1572 $nonce = sanitize_text_field(wp_unslash($_POST['nonce'] ?? ''));
1573 if (!wp_verify_nonce($nonce, 'thinkrank_ai_nonce')) {
1574 wp_die('Security check failed');
1575 }
1576
1577 // Check permissions
1578 if (!current_user_can('manage_options')) {
1579 wp_die('Insufficient permissions');
1580 }
1581
1582 $result = $this->test_api_connection();
1583
1584 if ($result['success']) {
1585 wp_send_json_success($result);
1586 } else {
1587 wp_send_json_error($result);
1588 }
1589 }
1590
1591 /**
1592 * Check rate limits.
1593 *
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.
1603 */
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);
1607
1608 // A non-positive limit means "unlimited".
1609 if ($max_requests <= 0) {
1610 return true;
1611 }
1612
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);
1617
1618 if ($attempts >= $max_requests) {
1619 return false;
1620 }
1621
1622 set_transient($minute_key, $attempts + 1, MINUTE_IN_SECONDS);
1623
1624 return true;
1625 }
1626
1627
1628
1629 /**
1630 * Log AI usage with actual model information
1631 *
1632 * @param int $user_id User ID
1633 * @param string $action Action performed
1634 * @param int $tokens_used Tokens consumed
1635 * @param string|null $actual_model Actual model used (from AI response)
1636 * @param string|null $raw_response Raw AI response for debugging
1637 * @return void
1638 */
1639 private function log_ai_usage(int $user_id, string $action, int $tokens_used, ?string $actual_model = null, ?string $raw_response = null): void {
1640 global $wpdb;
1641
1642 $table_name = $wpdb->prefix . 'thinkrank_ai_usage';
1643
1644 // Prepare metadata with actual model information and raw response
1645 $metadata = [];
1646 if ($actual_model) {
1647 $metadata['actual_model'] = $actual_model;
1648 }
1649 if ($raw_response) {
1650 $metadata['raw_response'] = $raw_response;
1651 }
1652
1653 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- AI usage logging requires direct database access
1654 $wpdb->insert(
1655 $table_name,
1656 [
1657 'user_id' => $user_id,
1658 'action' => $action,
1659 'tokens_used' => $tokens_used,
1660 'provider' => $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE),
1661 'metadata' => !empty($metadata) ? wp_json_encode($metadata) : null,
1662 'created_at' => current_time('mysql'),
1663 ],
1664 ['%d', '%s', '%d', '%s', '%s', '%s']
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);
1678 }
1679
1680 /**
1681 * Cleanup expired cache entries
1682 *
1683 * @return void
1684 */
1685 public function cleanup_cache(): void {
1686 $this->cache->clean_expired();
1687 }
1688
1689 /**
1690 * Optimize homepage meta content using AI (copying Site Identity pattern exactly)
1691 *
1692 * @since 1.0.0
1693 *
1694 * @param array $content_data Meta content data to optimize
1695 * @param array $options Optimization options
1696 * @return array Optimization results
1697 * @throws \Exception If optimization fails
1698 */
1699 public function optimize_homepage_meta(array $content_data, array $options = []): array {
1700 // Validate input
1701 if (empty($content_data)) {
1702 throw new \Exception('Content data cannot be empty');
1703 }
1704
1705 $user_id = get_current_user_id();
1706
1707 // Ensure user has configured their API key (copying Site Identity pattern)
1708 $user_has_api_key = $this->has_provider_credentials();
1709
1710 if (!$user_has_api_key) {
1711 throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1712 }
1713
1714 // Generate cache key using existing pattern
1715 $cache_key = 'homepage_meta_' . md5(wp_json_encode($content_data) . wp_json_encode($options)) . '_' . $user_id;
1716
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.
1721 $cached_result = $this->cache->get($cache_key);
1722 $cached_result = $cached_result['data'] ?? $cached_result;
1723 if (!empty($cached_result['optimized_data'])) {
1724 return $cached_result;
1725 }
1726
1727 // Check rate limiting
1728 if (!$this->check_rate_limit()) {
1729 throw new \Exception('Rate limit exceeded for AI optimization requests.');
1730 }
1731
1732 // Get AI client
1733 $client = $this->get_client();
1734
1735 if (!$client) {
1736 throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1737 }
1738
1739 // Perform AI optimization
1740 $optimization_results = $client->optimize_homepage_meta($content_data, $options);
1741
1742 // Validate that we got meaningful results
1743 if (empty($optimization_results) || empty($optimization_results['optimized_data'])) {
1744 throw new \Exception('AI optimization returned empty results. Please try again.');
1745 }
1746
1747 // Add metadata
1748 $optimization_results['ai_model'] = $client->get_model();
1749 $optimization_results['provider'] = $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
1750 $optimization_results['generated_at'] = gmdate('Y-m-d H:i:s');
1751 $optimization_results['user_id'] = $user_id;
1752
1753 // Cache the results (24 hours)
1754 $this->cache->set($cache_key, $optimization_results, 86400);
1755
1756 // Record usage with actual model information and raw AI text (Content Brief pattern)
1757 $actual_model = $optimization_results['ai_model'] ?? ($client ? $client->get_model() : null);
1758 $ai_text = $optimization_results['_ai_text'] ?? null;
1759 $this->log_ai_usage($user_id, 'Homepage Meta Optimization', $optimization_results['tokens_used'] ?? 0, $actual_model, $ai_text);
1760
1761 // Remove AI text from returned data to keep it clean
1762 unset($optimization_results['_ai_text']);
1763
1764 return $optimization_results;
1765 }
1766
1767 /**
1768 * Optimize homepage hero content using AI (copying Site Identity pattern exactly)
1769 *
1770 * @since 1.0.0
1771 *
1772 * @param array $hero_data Hero content data to optimize
1773 * @param array $options Optimization options
1774 * @return array Optimization results
1775 * @throws \Exception If optimization fails
1776 */
1777 public function optimize_homepage_hero(array $hero_data, array $options = []): array {
1778 // Validate input
1779 if (empty($hero_data)) {
1780 throw new \Exception('Hero data cannot be empty');
1781 }
1782
1783 $user_id = get_current_user_id();
1784
1785 // Ensure user has configured their API key (copying Site Identity pattern)
1786 $user_has_api_key = $this->has_provider_credentials();
1787
1788 if (!$user_has_api_key) {
1789 throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1790 }
1791
1792 // Generate cache key using existing pattern
1793 $cache_key = 'homepage_hero_' . md5(wp_json_encode($hero_data) . wp_json_encode($options)) . '_' . $user_id;
1794
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.
1799 $cached_result = $this->cache->get($cache_key);
1800 $cached_result = $cached_result['data'] ?? $cached_result;
1801 if (!empty($cached_result['optimized_data'])) {
1802 return $cached_result;
1803 }
1804
1805 // Check rate limiting
1806 if (!$this->check_rate_limit()) {
1807 throw new \Exception('Rate limit exceeded for AI optimization requests.');
1808 }
1809
1810 // Get AI client
1811 $client = $this->get_client();
1812
1813 if (!$client) {
1814 throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1815 }
1816
1817 // Perform AI optimization
1818 $optimization_results = $client->optimize_homepage_hero($hero_data, $options);
1819
1820 // Validate that we got meaningful results
1821 if (empty($optimization_results) || empty($optimization_results['optimized_data'])) {
1822 throw new \Exception('AI optimization returned empty results. Please try again.');
1823 }
1824
1825 // Add metadata
1826 $optimization_results['ai_model'] = $client->get_model();
1827 $optimization_results['provider'] = $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
1828 $optimization_results['generated_at'] = gmdate('Y-m-d H:i:s');
1829 $optimization_results['user_id'] = $user_id;
1830
1831 // Cache the results (24 hours)
1832 $this->cache->set($cache_key, $optimization_results, 86400);
1833
1834 // Record usage with actual model information and raw AI text (Content Brief pattern)
1835 $actual_model = $optimization_results['ai_model'] ?? ($client ? $client->get_model() : null);
1836 $ai_text = $optimization_results['_ai_text'] ?? null;
1837 $this->log_ai_usage($user_id, 'Homepage Hero Optimization', $optimization_results['tokens_used'] ?? 0, $actual_model, $ai_text);
1838
1839 // Remove AI text from returned data to keep it clean
1840 unset($optimization_results['_ai_text']);
1841
1842 return $optimization_results;
1843 }
1844 }
1845