PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.5.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.5.0
2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 All 48 releases
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.5.0, at includes/ai/class-manager.php

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