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

1,625 lines 65.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * AI Manager Class
4 *
5 * Handles AI provider integration and management
6 *
7 * @package ThinkRank\AI
8 * @since 1.0.0
9 */
10
11 declare(strict_types=1);
12
13 namespace ThinkRank\AI;
14
15 use ThinkRank\Core\Settings;
16
17
18 // Prevent direct access
19 if (!defined('ABSPATH')) {
20 exit;
21 }
22
23 /**
24 * AI Manager Class
25 *
26 * Single Responsibility: Manage AI providers and requests
27 *
28 * @since 1.0.0
29 */
30 class Manager {
31
32 /**
33 * Settings instance
34 *
35 * @var Settings
36 */
37 private Settings $settings;
38
39
40
41 /**
42 * Cache manager instance
43 *
44 * @var Cache_Manager
45 */
46 private Cache_Manager $cache;
47
48 /**
49 * Current AI client
50 *
51 * @var OpenAI_Client|Claude_Client|null
52 */
53 private $client = null;
54
55
56 /**
57 * Constructor
58 *
59 * @param Settings|null $settings Settings instance
60 */
61 public function __construct(?Settings $settings = null) {
62 $this->settings = $settings ?? Settings::instance();
63 $this->cache = new Cache_Manager((int) $this->settings->get('cache_duration', 3600));
64 }
65
66 /**
67 * Initialize AI manager
68 *
69 * @return void
70 */
71 public function init(): void {
72 // Initialize AI client based on settings
73 add_action('init', [$this, 'initialize_client']);
74
75 // Schedule cache cleanup
76 add_action('thinkrank_daily_cleanup', [$this, 'cleanup_cache']);
77
78 // Add AJAX handlers for AI requests
79 add_action('wp_ajax_thinkrank_generate_metadata', [$this, 'ajax_generate_metadata']);
80 add_action('wp_ajax_thinkrank_test_api_connection', [$this, 'ajax_test_connection']);
81 }
82
83 /**
84 * Initialize AI client
85 *
86 * @return void
87 */
88 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', 'gpt-5-nano');
99 if (empty($model)) {
100 $model = 'gpt-5-nano';
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', 'claude-sonnet-5');
119 if (empty($model)) {
120 $model = 'claude-sonnet-5';
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', 'gemini-2.5-flash');
136 if (empty($model)) {
137 $model = 'gemini-2.5-flash';
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', 'openai/gpt-4o-mini');
151 if (empty($model)) {
152 $model = 'openai/gpt-4o-mini';
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 private function request_completion(string $prompt, int $max_tokens = 2048): array {
925 // "Thinking" providers (e.g. Gemini 2.5) spend output tokens on reasoning
926 // before emitting text, so the cap must cover both the reasoning and the
927 // visible JSON. Longer outputs (paragraphs) need a bigger budget. It's
928 // only a ceiling — short replies cost no more.
929 $response = $this->client->generate_completion($prompt, [
930 'max_tokens' => $max_tokens,
931 'temperature' => 0.4,
932 ]);
933
934 $ai_text = '';
935 if (isset($response['choices'][0]['message']['content'])) {
936 $ai_text = is_array($response['choices'][0]['message']['content'])
937 ? implode(' ', array_map(static fn($part) => is_array($part) ? ($part['text'] ?? '') : (string) $part, $response['choices'][0]['message']['content']))
938 : (string) $response['choices'][0]['message']['content'];
939 } elseif (isset($response['content'][0]['text'])) {
940 $ai_text = (string) $response['content'][0]['text'];
941 } elseif (isset($response['candidates'][0]['content']['parts'][0]['text'])) {
942 $ai_text = (string) $response['candidates'][0]['content']['parts'][0]['text'];
943 } elseif (isset($response['content']) && is_string($response['content'])) {
944 $ai_text = $response['content'];
945 }
946
947 $tokens = $response['usage']['total_tokens']
948 ?? $response['usage']['output_tokens']
949 ?? ($response['usageMetadata']['totalTokenCount'] ?? 0);
950
951 return ['ai_text' => $ai_text, 'tokens' => (int) $tokens];
952 }
953
954 /**
955 * Trim a meta description to at most 160 characters at a word boundary,
956 * preserving sentence-ish endings and avoiding broken words. Descriptions of
957 * 160 characters or fewer are returned unchanged.
958 *
959 * @param string $desc Meta description.
960 * @return string Description clamped to <= 160 characters.
961 */
962 private function clamp_meta_description(string $desc): string {
963 $desc = trim($desc);
964 if (mb_strlen($desc) <= 160) {
965 return $desc;
966 }
967
968 $cut = mb_substr($desc, 0, 160);
969 $last_space = mb_strrpos($cut, ' ');
970 // Only back off to the last space when doing so keeps us at/above 120.
971 if ($last_space !== false && $last_space >= 120) {
972 $cut = mb_substr($cut, 0, $last_space);
973 }
974
975 return rtrim($cut, " \t\n\r\0\x0B,;:-");
976 }
977
978 /**
979 * Whether a title contains any of the given words, using the same
980 * case-insensitive substring match the scorer's title checks use.
981 *
982 * @param string $title Title to test.
983 * @param string[] $words Words to look for.
984 * @return bool
985 */
986 private function title_contains_word(string $title, array $words): bool {
987 $title_lower = strtolower($title);
988 foreach ($words as $word) {
989 if ($word !== '' && strpos($title_lower, strtolower($word)) !== false) {
990 return true;
991 }
992 }
993 return false;
994 }
995
996 /**
997 * Pull a named string field out of an AI response, tolerating both JSON and
998 * plain-text replies.
999 *
1000 * @param string $ai_text Raw AI text.
1001 * @param string $field JSON field to read (e.g. 'title', 'description').
1002 * @return string Sanitized value (without surrounding quotes), or '' on failure.
1003 */
1004 private function extract_json_field(string $ai_text, string $field): string {
1005 $ai_text = trim($ai_text);
1006 if ($ai_text === '') {
1007 return '';
1008 }
1009
1010 // Prefer a JSON object with the requested field.
1011 $json_start = strpos($ai_text, '{');
1012 $json_end = strrpos($ai_text, '}');
1013 if ($json_start !== false && $json_end !== false && $json_end > $json_start) {
1014 $decoded = json_decode(substr($ai_text, $json_start, $json_end - $json_start + 1), true);
1015 if (is_array($decoded) && !empty($decoded[$field])) {
1016 return sanitize_text_field(trim((string) $decoded[$field], " \t\n\r\0\x0B\"'"));
1017 }
1018 }
1019
1020 // Fall back to the first non-empty line, stripping wrapping quotes.
1021 $first_line = strtok($ai_text, "\n");
1022 return sanitize_text_field(trim((string) $first_line, " \t\n\r\0\x0B\"'"));
1023 }
1024
1025 /**
1026 * Analyze content for SEO optimization
1027 *
1028 * @param string $content Content to analyze
1029 * @param array $metadata Existing metadata
1030 * @return array Analysis results
1031 * @throws \Exception If analysis fails
1032 */
1033 public function analyze_content(string $content, array $metadata = []): array {
1034 if (!$this->client) {
1035 throw new \Exception($this->get_client_unavailable_message());
1036 }
1037
1038 $user_id = get_current_user_id();
1039
1040 // Ensure user has configured their API key
1041 $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'));
1042
1043 if (!$user_has_api_key) {
1044 throw new \Exception($this->get_client_unavailable_message());
1045 }
1046
1047 // Check rate limits
1048 if (!$this->check_rate_limit($user_id, 'content_analysis')) {
1049 throw new \Exception('Rate limit exceeded. Please try again later.');
1050 }
1051
1052 // Check cache first
1053 $cache_key = 'content_analysis_' . md5($content . serialize($metadata));
1054 $cached_result = $this->cache->get($cache_key);
1055 if ($cached_result) {
1056 return $cached_result['data'] ?? $cached_result;
1057 }
1058
1059 try {
1060 // Analyze content using AI
1061 $analysis = $this->client->analyze_content($content, $metadata);
1062
1063 // Cache the result
1064 $this->cache->set($cache_key, $analysis);
1065
1066 // Log usage with actual model information and raw AI text (Content Brief pattern)
1067 $actual_model = $this->client ? $this->client->get_model() : null;
1068 $ai_text = $analysis['_ai_text'] ?? null;
1069 $this->log_ai_usage($user_id, 'Content Analysis', $analysis['tokens_used'] ?? 0, $actual_model, $ai_text);
1070
1071 // Remove AI text from returned data to keep it clean
1072 unset($analysis['_ai_text']);
1073
1074 return $analysis;
1075
1076 } catch (\Exception $e) {
1077 throw $e;
1078 }
1079 }
1080
1081 /**
1082 * Test API connection
1083 *
1084 * @return array Test result
1085 */
1086 public function test_api_connection(): array {
1087 if (!$this->client) {
1088 return [
1089 'success' => false,
1090 'message' => $this->get_client_unavailable_message(),
1091 ];
1092 }
1093
1094 try {
1095 $success = $this->client->test_connection();
1096
1097 return [
1098 'success' => $success,
1099 'message' => $success
1100 ? 'API connection successful!'
1101 : 'API connection failed. Please check your API key.',
1102 ];
1103
1104 } catch (\Exception $e) {
1105 return [
1106 'success' => false,
1107 'message' => 'Connection test failed: ' . $e->getMessage(),
1108 ];
1109 }
1110 }
1111
1112 /**
1113 * Optimize site identity using AI
1114 *
1115 * @since 1.0.0
1116 *
1117 * @param array $site_data Site data to optimize
1118 * @param array $options Optimization options
1119 * @return array Optimization results
1120 * @throws \Exception If optimization fails
1121 */
1122 public function optimize_site_identity(array $site_data, array $options = []): array {
1123 // Validate input
1124 if (empty($site_data)) {
1125 throw new \Exception('Site data cannot be empty');
1126 }
1127
1128 $user_id = get_current_user_id();
1129
1130 // Ensure user has configured their API key
1131 $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'));
1132
1133 if (!$user_has_api_key) {
1134 throw new \Exception($this->get_client_unavailable_message());
1135 }
1136
1137 // Generate cache key using existing pattern
1138 $cache_key = 'site_identity_' . md5(serialize($site_data) . serialize($options)) . '_' . $user_id;
1139
1140 // Check existing cache infrastructure
1141 // Cache_Manager::set() wraps entries as ['data' => …], so unwrap
1142 // before inspecting — checking optimized_data on the wrapped array
1143 // never matches and the cache would never hit.
1144 $cached_result = $this->cache->get($cache_key);
1145 $cached_result = $cached_result['data'] ?? $cached_result;
1146 if (!empty($cached_result['optimized_data'])) {
1147 return $cached_result;
1148 }
1149
1150 // Check rate limiting
1151 if (!$this->check_rate_limit()) {
1152 throw new \Exception('Rate limit exceeded for AI optimization requests.');
1153 }
1154
1155 // Get AI client
1156 $client = $this->get_client();
1157
1158 if (!$client) {
1159 throw new \Exception($this->get_client_unavailable_message());
1160 }
1161
1162 // Perform AI optimization
1163 $optimization_results = $client->optimize_site_identity($site_data, $options);
1164
1165 // Validate that we got meaningful results
1166 if (empty($optimization_results) || empty($optimization_results['optimized_data'])) {
1167 throw new \Exception('AI optimization returned empty results. Please try again.');
1168 }
1169
1170 // Add metadata
1171 $optimization_results['ai_model'] = $client->get_model();
1172 $optimization_results['provider'] = $this->settings->get('ai_provider', 'openai');
1173 $optimization_results['generated_at'] = gmdate('Y-m-d H:i:s');
1174 $optimization_results['user_id'] = $user_id;
1175
1176 // Cache the results (24 hours)
1177 $this->cache->set($cache_key, $optimization_results, 86400);
1178
1179 // Record usage with actual model information and raw AI text (Content Brief pattern)
1180 $actual_model = $optimization_results['ai_model'] ?? ($client ? $client->get_model() : null);
1181 $ai_text = $optimization_results['_ai_text'] ?? null;
1182 $this->log_ai_usage($user_id, 'Site Identity Optimization', $optimization_results['tokens_used'] ?? 0, $actual_model, $ai_text);
1183
1184 // Remove AI text from returned data to keep it clean
1185 unset($optimization_results['_ai_text']);
1186
1187 return $optimization_results;
1188 }
1189
1190 /**
1191 * Optimize LLMs.txt content using AI
1192 *
1193 * @since 1.0.0
1194 *
1195 * @param array $website_data Website data to optimize
1196 * @param array $options Optimization options
1197 * @return array Optimization results
1198 * @throws \Exception If optimization fails
1199 */
1200 public function optimize_llms_txt(array $website_data, array $options = []): array {
1201 // Validate input
1202 if (empty($website_data)) {
1203 throw new \Exception('Website data cannot be empty');
1204 }
1205
1206 $user_id = get_current_user_id();
1207
1208 // Ensure user has configured their API key
1209 $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'));
1210
1211 if (!$user_has_api_key) {
1212 throw new \Exception($this->get_client_unavailable_message());
1213 }
1214
1215 // Generate cache key
1216 $cache_key = 'llms_txt_' . md5(serialize($website_data) . serialize($options)) . '_' . $user_id;
1217
1218 // Check cache first
1219 // Cache_Manager::set() wraps entries as ['data' => …], so unwrap
1220 // before inspecting — checking optimized_data on the wrapped array
1221 // never matches and the cache would never hit.
1222 $cached_result = $this->cache->get($cache_key);
1223 $cached_result = $cached_result['data'] ?? $cached_result;
1224 if (!empty($cached_result['optimized_data'])) {
1225 return $cached_result;
1226 }
1227
1228 // Check rate limiting
1229 if (!$this->check_rate_limit()) {
1230 throw new \Exception('Rate limit exceeded for AI optimization requests.');
1231 }
1232
1233 // Get AI client
1234 $client = $this->get_client();
1235
1236 if (!$client) {
1237 throw new \Exception($this->get_client_unavailable_message());
1238 }
1239
1240 // Perform AI optimization
1241 $optimization_results = $client->optimize_llms_txt($website_data, $options);
1242
1243 // Validate that we got meaningful results
1244 if (empty($optimization_results) || empty($optimization_results['optimized_data'])) {
1245 throw new \Exception('AI optimization returned empty results. Please try again.');
1246 }
1247
1248 // Add metadata
1249 $optimization_results['ai_model'] = $client->get_model();
1250 $optimization_results['provider'] = $this->settings->get('ai_provider', 'openai');
1251 $optimization_results['generated_at'] = gmdate('Y-m-d H:i:s');
1252 $optimization_results['user_id'] = $user_id;
1253
1254 // Cache the results (24 hours)
1255 $this->cache->set($cache_key, $optimization_results, 86400);
1256
1257 // Record usage with actual model information and raw AI text (Content Brief pattern)
1258 $actual_model = $optimization_results['ai_model'] ?? ($client ? $client->get_model() : null);
1259 $ai_text = $optimization_results['_ai_text'] ?? null;
1260 $this->log_ai_usage($user_id, 'LLMs.txt Optimization', $optimization_results['tokens_used'] ?? 0, $actual_model, $ai_text);
1261
1262 // Remove AI text from returned data to keep it clean
1263 unset($optimization_results['_ai_text']);
1264
1265 return $optimization_results;
1266 }
1267
1268 /**
1269 * Get available AI providers
1270 *
1271 * @return array Available providers
1272 */
1273 public function get_available_providers(): array {
1274 return [
1275 'openai' => [
1276 'name' => 'OpenAI',
1277 'description' => 'GPT‑5 series and GPT‑4o',
1278 'models' => ['gpt-5-nano', 'gpt-5-mini', 'gpt-5', 'gpt-4o'],
1279 'requires_key' => true,
1280 ],
1281 'claude' => [
1282 'name' => 'Claude (Anthropic)',
1283 'description' => 'Claude Opus 4.8, Sonnet 5, and Haiku 4.5',
1284 'models' => ['claude-opus-4-8', 'claude-sonnet-5', 'claude-haiku-4-5'],
1285 'requires_key' => true,
1286 ],
1287 'gemini' => [
1288 'name' => 'Google Gemini',
1289 'description' => 'Gemini 3.x and 2.x models',
1290 'models' => ['gemini-3.1-pro', 'gemini-3.5-flash', 'gemini-3.1-flash-lite', 'gemini-2.5-flash', 'gemini-2.5-flash-lite', 'gemini-2.5-pro', 'gemini-2.0-flash', 'gemini-1.5-flash'],
1291 'requires_key' => true,
1292 ],
1293 'openrouter' => [
1294 'name' => 'OpenRouter',
1295 'description' => 'Unified access to many models via one key',
1296 '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'],
1297 'requires_key' => true,
1298 ],
1299 ];
1300 }
1301
1302 /**
1303 * Get current provider status
1304 *
1305 * @return array Provider status
1306 */
1307 public function get_provider_status(): array {
1308 $provider = $this->settings->get('ai_provider', 'openai');
1309 $api_key = $this->settings->get($provider . '_api_key');
1310
1311 return [
1312 'provider' => $provider,
1313 'configured' => !empty($api_key),
1314 'connected' => $this->client !== null,
1315 ];
1316 }
1317
1318 /**
1319 * AJAX handler for generating metadata
1320 *
1321 * @return void
1322 */
1323 public function ajax_generate_metadata(): void {
1324 // Verify nonce
1325 $nonce = sanitize_text_field(wp_unslash($_POST['nonce'] ?? ''));
1326 if (!wp_verify_nonce($nonce, 'thinkrank_ai_nonce')) {
1327 wp_die('Security check failed');
1328 }
1329
1330 // Check permissions
1331 if (!current_user_can('edit_posts')) {
1332 wp_die('Insufficient permissions');
1333 }
1334
1335 $content = sanitize_textarea_field(wp_unslash($_POST['content'] ?? ''));
1336 $options = [
1337 'target_keyword' => sanitize_text_field(wp_unslash($_POST['target_keyword'] ?? '')),
1338 'content_type' => sanitize_text_field(wp_unslash($_POST['content_type'] ?? 'blog_post')),
1339 'tone' => sanitize_text_field(wp_unslash($_POST['tone'] ?? 'professional')),
1340 ];
1341
1342 try {
1343 $metadata = $this->generate_seo_metadata($content, $options);
1344
1345 wp_send_json_success([
1346 'metadata' => $metadata,
1347 'message' => __('SEO metadata generated successfully!', 'thinkrank'),
1348 ]);
1349
1350 } catch (\Exception $e) {
1351 wp_send_json_error([
1352 'message' => $e->getMessage(),
1353 ]);
1354 }
1355 }
1356
1357 /**
1358 * AJAX handler for testing API connection
1359 *
1360 * @return void
1361 */
1362 public function ajax_test_connection(): void {
1363 // Verify nonce
1364 $nonce = sanitize_text_field(wp_unslash($_POST['nonce'] ?? ''));
1365 if (!wp_verify_nonce($nonce, 'thinkrank_ai_nonce')) {
1366 wp_die('Security check failed');
1367 }
1368
1369 // Check permissions
1370 if (!current_user_can('manage_options')) {
1371 wp_die('Insufficient permissions');
1372 }
1373
1374 $result = $this->test_api_connection();
1375
1376 if ($result['success']) {
1377 wp_send_json_success($result);
1378 } else {
1379 wp_send_json_error($result);
1380 }
1381 }
1382
1383 /**
1384 * Check rate limits.
1385 *
1386 * Backed by a per-minute transient counter so the limit is enforced across
1387 * requests. A fresh Manager is constructed on every AJAX/REST call, so the
1388 * previous in-memory array always started empty and never limited anything —
1389 * letting an edit_posts user loop the metadata AJAX and drive unbounded paid
1390 * AI-provider spend.
1391 *
1392 * @param int|null $user_id Optional user id (defaults to the current user).
1393 * @param string $context Rate-limit bucket (keeps distinct flows separate).
1394 * @return bool True if within limits.
1395 */
1396 private function check_rate_limit(?int $user_id = null, string $context = 'ai'): bool {
1397 $user_id = $user_id ?? get_current_user_id();
1398 $max_requests = (int) $this->settings->get('max_requests_per_minute', 10);
1399
1400 // A non-positive limit means "unlimited".
1401 if ($max_requests <= 0) {
1402 return true;
1403 }
1404
1405 // Counter is keyed to the current wall-clock minute; the transient TTL
1406 // lets the window roll over on its own.
1407 $minute_key = "thinkrank_ai_rate_{$context}_{$user_id}_" . floor(time() / MINUTE_IN_SECONDS);
1408 $attempts = (int) get_transient($minute_key);
1409
1410 if ($attempts >= $max_requests) {
1411 return false;
1412 }
1413
1414 set_transient($minute_key, $attempts + 1, MINUTE_IN_SECONDS);
1415
1416 return true;
1417 }
1418
1419
1420
1421 /**
1422 * Log AI usage with actual model information
1423 *
1424 * @param int $user_id User ID
1425 * @param string $action Action performed
1426 * @param int $tokens_used Tokens consumed
1427 * @param string|null $actual_model Actual model used (from AI response)
1428 * @param string|null $raw_response Raw AI response for debugging
1429 * @return void
1430 */
1431 private function log_ai_usage(int $user_id, string $action, int $tokens_used, ?string $actual_model = null, ?string $raw_response = null): void {
1432 global $wpdb;
1433
1434 $table_name = $wpdb->prefix . 'thinkrank_ai_usage';
1435
1436 // Prepare metadata with actual model information and raw response
1437 $metadata = [];
1438 if ($actual_model) {
1439 $metadata['actual_model'] = $actual_model;
1440 }
1441 if ($raw_response) {
1442 $metadata['raw_response'] = $raw_response;
1443 }
1444
1445 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- AI usage logging requires direct database access
1446 $wpdb->insert(
1447 $table_name,
1448 [
1449 'user_id' => $user_id,
1450 'action' => $action,
1451 'tokens_used' => $tokens_used,
1452 'provider' => $this->settings->get('ai_provider', 'openai'),
1453 'metadata' => !empty($metadata) ? wp_json_encode($metadata) : null,
1454 'created_at' => current_time('mysql'),
1455 ],
1456 ['%d', '%s', '%d', '%s', '%s', '%s']
1457 );
1458 }
1459
1460 /**
1461 * Cleanup expired cache entries
1462 *
1463 * @return void
1464 */
1465 public function cleanup_cache(): void {
1466 $this->cache->clean_expired();
1467 }
1468
1469 /**
1470 * Optimize homepage meta content using AI (copying Site Identity pattern exactly)
1471 *
1472 * @since 1.0.0
1473 *
1474 * @param array $content_data Meta content data to optimize
1475 * @param array $options Optimization options
1476 * @return array Optimization results
1477 * @throws \Exception If optimization fails
1478 */
1479 public function optimize_homepage_meta(array $content_data, array $options = []): array {
1480 // Validate input
1481 if (empty($content_data)) {
1482 throw new \Exception('Content data cannot be empty');
1483 }
1484
1485 $user_id = get_current_user_id();
1486
1487 // Ensure user has configured their API key (copying Site Identity pattern)
1488 $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'));
1489
1490 if (!$user_has_api_key) {
1491 throw new \Exception($this->get_client_unavailable_message());
1492 }
1493
1494 // Generate cache key using existing pattern
1495 $cache_key = 'homepage_meta_' . md5(serialize($content_data) . serialize($options)) . '_' . $user_id;
1496
1497 // Check existing cache infrastructure
1498 // Cache_Manager::set() wraps entries as ['data' => …], so unwrap
1499 // before inspecting — checking optimized_data on the wrapped array
1500 // never matches and the cache would never hit.
1501 $cached_result = $this->cache->get($cache_key);
1502 $cached_result = $cached_result['data'] ?? $cached_result;
1503 if (!empty($cached_result['optimized_data'])) {
1504 return $cached_result;
1505 }
1506
1507 // Check rate limiting
1508 if (!$this->check_rate_limit()) {
1509 throw new \Exception('Rate limit exceeded for AI optimization requests.');
1510 }
1511
1512 // Get AI client
1513 $client = $this->get_client();
1514
1515 if (!$client) {
1516 throw new \Exception($this->get_client_unavailable_message());
1517 }
1518
1519 // Perform AI optimization
1520 $optimization_results = $client->optimize_homepage_meta($content_data, $options);
1521
1522 // Validate that we got meaningful results
1523 if (empty($optimization_results) || empty($optimization_results['optimized_data'])) {
1524 throw new \Exception('AI optimization returned empty results. Please try again.');
1525 }
1526
1527 // Add metadata
1528 $optimization_results['ai_model'] = $client->get_model();
1529 $optimization_results['provider'] = $this->settings->get('ai_provider', 'openai');
1530 $optimization_results['generated_at'] = gmdate('Y-m-d H:i:s');
1531 $optimization_results['user_id'] = $user_id;
1532
1533 // Cache the results (24 hours)
1534 $this->cache->set($cache_key, $optimization_results, 86400);
1535
1536 // Record usage with actual model information and raw AI text (Content Brief pattern)
1537 $actual_model = $optimization_results['ai_model'] ?? ($client ? $client->get_model() : null);
1538 $ai_text = $optimization_results['_ai_text'] ?? null;
1539 $this->log_ai_usage($user_id, 'Homepage Meta Optimization', $optimization_results['tokens_used'] ?? 0, $actual_model, $ai_text);
1540
1541 // Remove AI text from returned data to keep it clean
1542 unset($optimization_results['_ai_text']);
1543
1544 return $optimization_results;
1545 }
1546
1547 /**
1548 * Optimize homepage hero content using AI (copying Site Identity pattern exactly)
1549 *
1550 * @since 1.0.0
1551 *
1552 * @param array $hero_data Hero content data to optimize
1553 * @param array $options Optimization options
1554 * @return array Optimization results
1555 * @throws \Exception If optimization fails
1556 */
1557 public function optimize_homepage_hero(array $hero_data, array $options = []): array {
1558 // Validate input
1559 if (empty($hero_data)) {
1560 throw new \Exception('Hero data cannot be empty');
1561 }
1562
1563 $user_id = get_current_user_id();
1564
1565 // Ensure user has configured their API key (copying Site Identity pattern)
1566 $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'));
1567
1568 if (!$user_has_api_key) {
1569 throw new \Exception($this->get_client_unavailable_message());
1570 }
1571
1572 // Generate cache key using existing pattern
1573 $cache_key = 'homepage_hero_' . md5(serialize($hero_data) . serialize($options)) . '_' . $user_id;
1574
1575 // Check existing cache infrastructure
1576 // Cache_Manager::set() wraps entries as ['data' => …], so unwrap
1577 // before inspecting — checking optimized_data on the wrapped array
1578 // never matches and the cache would never hit.
1579 $cached_result = $this->cache->get($cache_key);
1580 $cached_result = $cached_result['data'] ?? $cached_result;
1581 if (!empty($cached_result['optimized_data'])) {
1582 return $cached_result;
1583 }
1584
1585 // Check rate limiting
1586 if (!$this->check_rate_limit()) {
1587 throw new \Exception('Rate limit exceeded for AI optimization requests.');
1588 }
1589
1590 // Get AI client
1591 $client = $this->get_client();
1592
1593 if (!$client) {
1594 throw new \Exception($this->get_client_unavailable_message());
1595 }
1596
1597 // Perform AI optimization
1598 $optimization_results = $client->optimize_homepage_hero($hero_data, $options);
1599
1600 // Validate that we got meaningful results
1601 if (empty($optimization_results) || empty($optimization_results['optimized_data'])) {
1602 throw new \Exception('AI optimization returned empty results. Please try again.');
1603 }
1604
1605 // Add metadata
1606 $optimization_results['ai_model'] = $client->get_model();
1607 $optimization_results['provider'] = $this->settings->get('ai_provider', 'openai');
1608 $optimization_results['generated_at'] = gmdate('Y-m-d H:i:s');
1609 $optimization_results['user_id'] = $user_id;
1610
1611 // Cache the results (24 hours)
1612 $this->cache->set($cache_key, $optimization_results, 86400);
1613
1614 // Record usage with actual model information and raw AI text (Content Brief pattern)
1615 $actual_model = $optimization_results['ai_model'] ?? ($client ? $client->get_model() : null);
1616 $ai_text = $optimization_results['_ai_text'] ?? null;
1617 $this->log_ai_usage($user_id, 'Homepage Hero Optimization', $optimization_results['tokens_used'] ?? 0, $actual_model, $ai_text);
1618
1619 // Remove AI text from returned data to keep it clean
1620 unset($optimization_results['_ai_text']);
1621
1622 return $optimization_results;
1623 }
1624 }
1625