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

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