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

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