PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 1.28.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v1.28.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 1.28.0, at includes/ai/class-manager.php

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