PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.10.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.10.0
2.11.0 2.10.0 2.9.0 2.8.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 All 52 releases
thinkrank / includes / ai / class-claude-client.php

class-claude-client.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.10.0, at includes/ai/class-claude-client.php

936 lines 35.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Claude API Client
4 *
5 * Handles communication with Anthropic Claude API
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\AI\Traits\Request_Timeout;
16
17 // Prevent direct access
18 if (!defined('ABSPATH')) {
19 exit;
20 }
21
22 require_once __DIR__ . '/traits/trait-request-timeout.php';
23
24 /**
25 * Claude Client Class
26 *
27 * Single Responsibility: Handle Claude API communication
28 *
29 * @since 1.0.0
30 */
31 class Claude_Client {
32
33 use Request_Timeout;
34
35
36 /**
37 * Claude API base URL
38 */
39 private const API_BASE_URL = 'https://api.anthropic.com/v1';
40
41 /**
42 * API key
43 *
44 * @var string
45 */
46 private string $api_key;
47
48 /**
49 * Default model
50 *
51 * @var string
52 */
53 private string $model;
54
55 /**
56 * Request timeout in seconds
57 *
58 * @var int
59 */
60 private int $timeout;
61
62 /**
63 * Prompt Builder instance
64 *
65 * @since 1.0.0
66 * @var Prompt_Builder|null
67 */
68 private ?Prompt_Builder $prompt_builder = null;
69
70 /**
71 * Constructor
72 *
73 * @param string $api_key Claude API key
74 * @param string $model Default model to use
75 * @param int $timeout Request timeout
76 */
77 public function __construct(string $api_key, string $model = \ThinkRank\Core\Settings::DEFAULT_CLAUDE_MODEL, int $timeout = 30) {
78 $this->api_key = $api_key;
79 $this->model = self::normalize_model($model);
80 $this->timeout = $timeout;
81 }
82
83 /**
84 * Get Prompt Builder instance
85 *
86 * @since 1.0.0
87 *
88 * @return Prompt_Builder Prompt Builder instance
89 */
90 private function get_prompt_builder(): Prompt_Builder {
91 if (!$this->prompt_builder) {
92 // Ensure Prompt Builder is loaded
93 if (!class_exists('ThinkRank\\AI\\Prompt_Builder')) {
94 require_once THINKRANK_PLUGIN_DIR . 'includes/ai/class-prompt-builder.php';
95 }
96 $this->prompt_builder = new Prompt_Builder();
97 }
98 return $this->prompt_builder;
99 }
100
101 /**
102 * Generate completion using Claude
103 *
104 * @param string $prompt The prompt to send
105 * @param array $options Additional options
106 * @return array Response data
107 * @throws \Exception If API request fails
108 */
109 public function generate_completion(string $prompt, array $options = []): array {
110 $default_options = [
111 'model' => $this->model,
112 'max_tokens' => 1000,
113 'temperature' => 0.7,
114 ];
115
116 $options = array_merge($default_options, $options);
117
118 // Callers may override the model via $options; self-heal retired IDs here too.
119 $options['model'] = self::normalize_model((string) $options['model']);
120
121 $body = [
122 'model' => $options['model'],
123 'max_tokens' => $options['max_tokens'],
124 'messages' => [
125 [
126 'role' => 'user',
127 'content' => $prompt,
128 ]
129 ],
130 ];
131
132 // Newer Claude models (Opus 4.7/4.8, Sonnet 5, Fable 5) reject non-default
133 // sampling params with a 400. Only send `temperature` to models that accept it.
134 if (!$this->model_rejects_sampling_params($options['model'])) {
135 $body['temperature'] = $options['temperature'];
136 }
137
138 return $this->make_request('messages', $body);
139 }
140
141 /**
142 * Remap retired / unavailable Claude model IDs to the current default.
143 *
144 * Existing installs may have a stored `claude_model` that Anthropic has since
145 * retired (all `claude-3-*`) or deprecated to the point of returning 404
146 * (the `claude-*-4-0` / dated 4.0 aliases). Those IDs are self-healed to the
147 * recommended default so saved settings don't break API calls. A model not in
148 * this list — including a valid current model or a user-entered custom ID — is
149 * returned unchanged.
150 *
151 * @param string $model Model ID from settings
152 * @return string A usable model ID
153 */
154 public static function normalize_model(string $model): string {
155 $retired = [
156 'claude-3-7-sonnet-latest', 'claude-3-7-sonnet-20250219',
157 'claude-3-5-sonnet-latest', 'claude-3-5-sonnet-20241022', 'claude-3-5-sonnet-20240620',
158 'claude-3-5-haiku-latest', 'claude-3-5-haiku-20241022',
159 'claude-3-opus-latest', 'claude-3-opus-20240229',
160 'claude-3-sonnet-20240229', 'claude-3-haiku-20240307',
161 'claude-sonnet-4-0', 'claude-sonnet-4-20250514',
162 'claude-opus-4-0', 'claude-opus-4-20250514',
163 ];
164
165 return in_array($model, $retired, true) ? 'claude-sonnet-5' : $model;
166 }
167
168 /**
169 * Whether the given model rejects sampling params (temperature/top_p/top_k).
170 *
171 * Anthropic removed these on Opus 4.7+, Opus 5, Sonnet 5, and Fable 5 —
172 * including any date-suffixed or "-latest" alias of them — so they must be
173 * omitted from the request body or the API returns a 400.
174 *
175 * Every generate_* method below sends a temperature, so a model missing from
176 * this list fails on its first real call rather than at save time. `claude-opus-5`
177 * was absent while being offered in the UI, which made the flagship model
178 * unusable (#572).
179 *
180 * @param string $model Model ID
181 * @return bool
182 */
183 private function model_rejects_sampling_params(string $model): bool {
184 foreach (['claude-opus-4-7', 'claude-opus-4-8', 'claude-opus-5', 'claude-sonnet-5', 'claude-fable-5', 'claude-mythos-5'] as $prefix) {
185 if (strpos($model, $prefix) === 0) {
186 return true;
187 }
188 }
189 return false;
190 }
191
192 /**
193 * Generate SEO metadata
194 *
195 * @param string $content Content to analyze
196 * @param array $options Generation options
197 * @return array Generated metadata
198 * @throws \Exception If generation fails
199 */
200 public function generate_seo_metadata(string $content, array $options = []): array {
201 $target_keyword = $options['target_keyword'] ?? '';
202 $content_type = $options['content_type'] ?? 'blog_post';
203 $tone = $options['tone'] ?? 'professional';
204
205 $prompt_builder = $this->get_prompt_builder();
206 $language = is_string($options['language'] ?? null) ? $options['language'] : '';
207 $prompt = $prompt_builder->build_seo_prompt($content, $target_keyword, $content_type, $tone, 'claude', $language);
208
209 $response = $this->generate_completion($prompt, [
210 'max_tokens' => 500,
211 'temperature' => 0.3,
212 ]);
213
214 return $this->parse_seo_response($response);
215 }
216
217 /**
218 * Analyze content for SEO optimization
219 *
220 * @param string $content Content to analyze
221 * @param array $metadata Existing metadata
222 * @return array Analysis results
223 * @throws \Exception If analysis fails
224 */
225 public function analyze_content(string $content, array $metadata = []): array {
226 $prompt_builder = $this->get_prompt_builder();
227 $prompt = $prompt_builder->build_analysis_prompt($content, $metadata, 'claude');
228
229 $response = $this->generate_completion($prompt, [
230 'max_tokens' => 800,
231 'temperature' => 0.3,
232 ]);
233
234 return $this->parse_analysis_response($response);
235 }
236
237 /**
238 * Get current model
239 *
240 * @return string Current model name
241 */
242 public function get_model(): string {
243 return $this->model;
244 }
245
246 /**
247 * Output-token ceiling per model family, longest prefix wins.
248 *
249 * Matched by prefix so a dated snapshot (`claude-haiku-4-5-20251001`) and a
250 * point release (`claude-fable-5-1`) resolve to their family. Order matters
251 * only in that lookup walks longest-first, which is what keeps
252 * `claude-fable-5-1` from matching `claude-fable-5`.
253 *
254 * @since 2.7.0
255 * @var array<string, int>
256 */
257 private const MODEL_OUTPUT_LIMITS = [
258 // 128K output.
259 'claude-fable-5-1' => 128000,
260 'claude-fable-5' => 128000,
261 'claude-mythos-5-1' => 128000,
262 'claude-mythos-5' => 128000,
263 'claude-opus-5' => 128000,
264 'claude-opus-4-8' => 128000,
265 'claude-opus-4-7' => 128000,
266 'claude-opus-4-6' => 128000,
267 'claude-sonnet-5' => 128000,
268 'claude-sonnet-4-6' => 128000,
269 // 64K output.
270 'claude-haiku-4-5' => 64000,
271 ];
272
273 /**
274 * Upper bound per use case, applied after the percentage.
275 *
276 * Two reasons these exist rather than letting the percentage run against a
277 * 128K ceiling.
278 *
279 * Requests here are a single blocking HTTP call with a 120s timeout and no
280 * streaming, so 0.9 x 128000 would risk running past the timeout instead of
281 * returning — trading a truncation failure for a timeout failure. 16000
282 * leaves room for the brief's JSON plus reasoning tokens while staying
283 * answerable; raise it only alongside streaming.
284 *
285 * And correcting the ceiling would otherwise inflate every other use case
286 * as a side effect — seo_metadata would jump from ~1,229 tokens to ~19,200
287 * purely because this bug was fixed. Metadata generation already works, so
288 * it keeps its cost profile (#665).
289 *
290 * @since 2.7.0
291 * @var array<string, int>
292 */
293 private const USE_CASE_TOKEN_CAPS = [
294 'content_brief' => 16000,
295 'llms_txt' => 16000,
296 'analysis' => 8000,
297 'seo_metadata' => 4000,
298 'optimization' => 4000,
299 'default' => 4000,
300 ];
301
302 /**
303 * Ceiling for a model this table does not know.
304 *
305 * The previous behaviour for every model, kept for older and unrecognised
306 * ones: 8192 is accepted without an extended-output beta header, so it is
307 * the safe answer when we cannot identify the family.
308 *
309 * @since 2.7.0
310 * @var int
311 */
312 private const FALLBACK_OUTPUT_LIMIT = 8192;
313
314 /**
315 * Maximum completion (output) tokens accepted for a single Claude request.
316 *
317 * This returned a flat 8192 for every model and ignored $model entirely, so
318 * Content Brief was capped at a fraction of the available budget and
319 * truncated before its structured JSON completed — on every Claude model,
320 * every time. Current models also emit reasoning tokens from the same
321 * output budget, which is why it failed so reliably rather than
322 * intermittently (#665).
323 *
324 * @param string $model Model ID.
325 * @return int Maximum output tokens.
326 */
327 private function get_max_completion_tokens(string $model): int {
328 $model = strtolower(trim($model));
329
330 if ('' === $model) {
331 return self::FALLBACK_OUTPUT_LIMIT;
332 }
333
334 $limits = self::MODEL_OUTPUT_LIMITS;
335
336 // Longest prefix first, so a point release never matches the shorter
337 // family id that is a prefix of it.
338 uksort(
339 $limits,
340 static function (string $a, string $b): int {
341 return strlen($b) <=> strlen($a);
342 }
343 );
344
345 foreach ($limits as $prefix => $limit) {
346 if (0 === strpos($model, $prefix)) {
347 return $limit;
348 }
349 }
350
351 return self::FALLBACK_OUTPUT_LIMIT;
352 }
353
354 /**
355 * Recommended output-token budget for a given use case.
356 *
357 * Mirrors the other clients so the Content Brief generator no longer falls
358 * back to a hardcoded, model-blind budget for Claude (issue #287). Each
359 * value is a fraction of the model's completion ceiling.
360 *
361 * @param string $use_case e.g. 'content_brief', 'seo_metadata', 'analysis'.
362 * @return int Recommended max output tokens.
363 */
364 public function get_recommended_tokens(string $use_case): int {
365 $max_tokens = $this->get_max_completion_tokens($this->model);
366
367 $recommendations = [
368 'content_brief' => 0.9, // Comprehensive brief incl. a full article body.
369 'seo_metadata' => 0.15,
370 'analysis' => 0.25,
371 'llms_txt' => 0.5,
372 'optimization' => 0.15,
373 ];
374 $percentage = $recommendations[$use_case] ?? 0.15;
375
376 $budget = (int) ($max_tokens * $percentage);
377
378 $cap = self::USE_CASE_TOKEN_CAPS[$use_case] ?? self::USE_CASE_TOKEN_CAPS['default'];
379
380 return max(1, min($budget, $cap));
381 }
382
383 /**
384 * Test API connection
385 *
386 * @return bool True if connection successful
387 */
388 public function test_connection(): bool {
389 try {
390 // Claude doesn't have a models endpoint, so we'll test with a simple message
391 $response = $this->generate_completion('Hello', ['max_tokens' => 10]);
392 return isset($response['content']) && is_array($response['content']);
393 } catch (\Exception $e) {
394 return false;
395 }
396 }
397
398 /**
399 * Make API request to Claude
400 *
401 * @param string $endpoint API endpoint
402 * @param array $body Request body
403 * @return array Response data
404 * @throws \Exception If request fails
405 */
406 private function make_request(string $endpoint, array $body = []): array {
407 // The user's daily ceiling and kill switch are enforced here, at the
408 // one place every outbound Claude call passes through, so no feature
409 // path can bypass them by forgetting to ask first (#448).
410 Spend_Guard::guard();
411 Spend_Guard::record();
412
413 $url = self::API_BASE_URL . '/' . ltrim($endpoint, '/');
414
415 $args = [
416 'timeout' => $this->timeout,
417 'headers' => [
418 'x-api-key' => $this->api_key,
419 'Content-Type' => 'application/json',
420 'anthropic-version' => '2023-06-01',
421 'User-Agent' => 'ThinkRank/' . THINKRANK_VERSION,
422 ],
423 'method' => 'POST',
424 'body' => wp_json_encode($body),
425 ];
426
427 $response = $this->request_with_retry($url, $args);
428
429 if (is_wp_error($response)) {
430 throw new \Exception('API request failed: ' . esc_html($response->get_error_message()));
431 }
432
433 $status_code = wp_remote_retrieve_response_code($response);
434 $response_body = wp_remote_retrieve_body($response);
435
436 if ($status_code >= 400) {
437 $error_data = json_decode($response_body, true);
438 $error_message = $error_data['error']['message'] ?? 'Unknown API error';
439 throw new \Exception(sprintf('Claude API error (%d): %s', (int) $status_code, esc_html($error_message)));
440 }
441
442 $data = json_decode($response_body, true);
443
444 if (json_last_error() !== JSON_ERROR_NONE) {
445 throw new \Exception('Invalid JSON response from Claude API');
446 }
447
448 // A valid-but-scalar body (null/number/string from a proxy/gateway on a
449 // 2xx) would violate this method's : array return type; reject it here so
450 // it surfaces as a catchable \Exception, not an uncatchable TypeError.
451 if (!is_array($data)) {
452 throw new \Exception('Unexpected non-array response from Claude API');
453 }
454
455 return $data;
456 }
457
458 /**
459 * Perform an HTTP request, retrying transient failures (429 / 5xx / network)
460 * per the plugin's retry settings, honoring a Retry-After header when given.
461 *
462 * @param string $url Request URL
463 * @param array $args wp_remote_request arguments
464 * @return array|\WP_Error Final response (or last error after retries)
465 */
466 private function request_with_retry(string $url, array $args) {
467 $settings = \ThinkRank\Core\Settings::instance();
468 $retry_enabled = (bool) $settings->get('retry_failed_requests', true);
469 $max_attempts = $retry_enabled ? max(1, (int) $settings->get('retry_attempts', 3)) : 1;
470
471 $response = null;
472 for ($attempt = 1; $attempt <= $max_attempts; $attempt++) {
473 // Keep PHP alive for the whole blocking call (see method docblock).
474 $this->raise_request_time_limit();
475
476 $response = wp_remote_request($url, $args);
477
478 $is_transient = false;
479 $retry_after = 0;
480 if (is_wp_error($response)) {
481 // A client-side timeout means the work genuinely needs longer
482 // than the budget we allowed; re-running the identical prompt,
483 // model and budget just times out again and multiplies the
484 // wait (issue #288). Do not retry a timeout. Other WP_Error
485 // results — DNS, connection refused, TLS — stay retryable.
486 $is_transient = !$this->is_timeout_error($response);
487 } else {
488 $status = wp_remote_retrieve_response_code($response);
489 if (429 === $status || $status >= 500) {
490 $is_transient = true;
491 $retry_after = (int) wp_remote_retrieve_header($response, 'retry-after');
492 }
493 }
494
495 if (!$is_transient || $attempt === $max_attempts) {
496 break;
497 }
498
499 $delay = $retry_after > 0 ? min($retry_after, 30) : min(2 ** ($attempt - 1), 8);
500 sleep($delay);
501 }
502
503 return $response;
504 }
505
506 /**
507 * Give PHP enough execution time to outlive a blocking AI HTTP request.
508 *
509 * The provider call blocks for up to $this->timeout seconds, but the web
510 * SAPI's default max_execution_time (commonly 30s) is shorter — so PHP
511 * fatally terminates the script mid-request (inside the cURL transport),
512 * which the web server surfaces as a 502 Bad Gateway. Resetting the limit
513 * before each attempt keeps the script alive for the full call; PHP-FPM's
514 * request_terminate_timeout still caps the absolute maximum. No-op when
515 * set_time_limit() is disabled (e.g. via disable_functions or safe mode).
516 *
517 * @return void
518 */
519 private function raise_request_time_limit(): void {
520 if (function_exists('set_time_limit')) {
521 @set_time_limit($this->timeout + 45); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- set_time_limit() warns when disabled by host policy; the guard is intentional.
522 }
523 }
524
525 /**
526 * Parse SEO response from Claude
527 *
528 * @param array $response Claude response
529 * @return array Parsed metadata
530 * @throws \Exception If parsing fails
531 */
532 private function parse_seo_response(array $response): array {
533 if (!isset($response['content'][0]['text'])) {
534 throw new \Exception('Invalid response format from Claude');
535 }
536
537 $content = $response['content'][0]['text'];
538 $ai_text = $content; // Store the raw AI-generated text (Content Brief pattern)
539
540 // Try to extract JSON from the response
541 $json_start = strpos($content, '{');
542 $json_end = strrpos($content, '}');
543
544 if (false === $json_start || false === $json_end) {
545 throw new \Exception('No valid JSON found in Claude response');
546 }
547
548 $json_content = substr($content, $json_start, $json_end - $json_start + 1);
549 $metadata = json_decode($json_content, true);
550
551 if (json_last_error() !== JSON_ERROR_NONE) {
552 throw new \Exception('Failed to parse JSON from Claude response');
553 }
554
555 // Validate required fields
556 $required_fields = ['title', 'description', 'focus_keyword'];
557 foreach ($required_fields as $field) {
558 if (!isset($metadata[$field])) {
559 throw new \Exception(sprintf('Missing required field: %s', esc_html($field)));
560 }
561 }
562
563 return [
564 'title' => sanitize_text_field($metadata['title']),
565 'description' => sanitize_text_field($metadata['description']),
566 'focus_keyword' => sanitize_text_field($metadata['focus_keyword']),
567 'suggestions' => array_map('sanitize_text_field', $metadata['suggestions'] ?? []),
568 'generated_at' => current_time('mysql'),
569 'tokens_used' => ($response['usage']['input_tokens'] ?? 0) + ($response['usage']['output_tokens'] ?? 0),
570 '_ai_text' => $ai_text, // Store the raw AI-generated text (Content Brief pattern)
571 ];
572 }
573
574 /**
575 * Parse analysis response from Claude
576 *
577 * @param array $response Claude API response
578 * @return array Parsed analysis data
579 * @throws \Exception If parsing fails
580 */
581 private function parse_analysis_response(array $response): array {
582 if (!isset($response['content'][0]['text'])) {
583 throw new \Exception('Invalid response format from Claude');
584 }
585
586 $content = trim($response['content'][0]['text']);
587 $ai_text = $content; // Store the raw AI-generated text (Content Brief pattern)
588
589 // Extract JSON from response
590 $json_start = strpos($content, '{');
591 $json_end = strrpos($content, '}');
592
593 if (false === $json_start || false === $json_end) {
594 throw new \Exception('No valid JSON found in response');
595 }
596
597 $json_content = substr($content, $json_start, $json_end - $json_start + 1);
598 $analysis = json_decode($json_content, true);
599
600 if (json_last_error() !== JSON_ERROR_NONE) {
601 throw new \Exception('Failed to parse JSON response: ' . esc_html(json_last_error_msg()));
602 }
603
604 // Validate and sanitize response
605 return [
606 'seo_score' => min(100, max(0, (int) ($analysis['seo_score'] ?? 0))),
607 'content_analysis' => [
608 'word_count' => (int) ($analysis['content_analysis']['word_count'] ?? 0),
609 'readability' => sanitize_text_field($analysis['content_analysis']['readability'] ?? 'unknown'),
610 'keyword_density' => sanitize_text_field($analysis['content_analysis']['keyword_density'] ?? 'unknown'),
611 'structure' => sanitize_text_field($analysis['content_analysis']['structure'] ?? 'unknown'),
612 ],
613 'suggestions' => array_map('sanitize_text_field', $analysis['suggestions'] ?? []),
614 'strengths' => array_map('sanitize_text_field', $analysis['strengths'] ?? []),
615 'weaknesses' => array_map('sanitize_text_field', $analysis['weaknesses'] ?? []),
616 'analyzed_at' => current_time('mysql'),
617 'tokens_used' => ($response['usage']['input_tokens'] ?? 0) + ($response['usage']['output_tokens'] ?? 0),
618 '_ai_text' => $ai_text, // Store the raw AI-generated text (Content Brief pattern)
619 ];
620 }
621
622 /**
623 * Optimize site identity using Claude
624 *
625 * @since 1.0.0
626 *
627 * @param array $site_data Site data to optimize
628 * @param array $options Optimization options
629 * @return array Optimization results
630 * @throws \Exception If optimization fails
631 */
632 public function optimize_site_identity(array $site_data, array $options = []): array {
633 $business_type = $options['business_type'] ?? 'website';
634 $target_audience = $options['target_audience'] ?? 'general';
635 $tone = $options['tone'] ?? 'professional';
636
637 $prompt_builder = $this->get_prompt_builder();
638 $prompt = $prompt_builder->build_site_identity_prompt($site_data, $business_type, $target_audience, $tone, 'claude');
639
640 $response = $this->make_request('messages', [
641 'model' => $this->model,
642 'max_tokens' => 600,
643 'temperature' => 0.4,
644 'messages' => [
645 [
646 'role' => 'user',
647 'content' => $prompt
648 ]
649 ]
650 ]);
651
652 return $this->parse_site_identity_response($response);
653 }
654
655 /**
656 * Parse site identity optimization response
657 *
658 * @param array $response Claude API response
659 * @return array Parsed optimization data
660 * @throws \Exception If parsing fails
661 */
662 private function parse_site_identity_response(array $response): array {
663 if (!isset($response['content'][0]['text'])) {
664 throw new \Exception('Invalid response format from Claude');
665 }
666
667 $content = trim($response['content'][0]['text']);
668 $ai_text = $content; // Store the raw AI-generated text (Content Brief pattern)
669
670 // Extract JSON from response
671 $json_start = strpos($content, '{');
672 $json_end = strrpos($content, '}');
673
674 if (false === $json_start || false === $json_end) {
675 throw new \Exception('No valid JSON found in response');
676 }
677
678 $json_content = substr($content, $json_start, $json_end - $json_start + 1);
679 $optimization = json_decode($json_content, true);
680
681 if (json_last_error() !== JSON_ERROR_NONE) {
682 throw new \Exception('Failed to parse JSON response: ' . esc_html(json_last_error_msg()));
683 }
684
685 // Validate and sanitize response
686 return [
687 'optimized_data' => [
688 'site_name' => sanitize_text_field($optimization['optimized_data']['site_name'] ?? ''),
689 'site_description' => sanitize_text_field($optimization['optimized_data']['site_description'] ?? ''),
690 'tagline' => sanitize_text_field($optimization['optimized_data']['tagline'] ?? ''),
691 'default_meta_description' => sanitize_text_field($optimization['optimized_data']['default_meta_description'] ?? ''),
692 ],
693 'analysis' => sanitize_textarea_field($optimization['analysis'] ?? ''),
694 'suggestions' => array_map('sanitize_text_field', $optimization['suggestions'] ?? []),
695 'score' => min(100, max(0, (int) ($optimization['score'] ?? 0))),
696 'tokens_used' => ($response['usage']['input_tokens'] ?? 0) + ($response['usage']['output_tokens'] ?? 0),
697 '_ai_text' => $ai_text, // Store the raw AI-generated text (Content Brief pattern)
698 ];
699 }
700
701 /**
702 * Optimize homepage meta content using AI (copying Site Identity pattern exactly)
703 *
704 * @since 1.0.0
705 *
706 * @param array $content_data Meta content data to optimize
707 * @param array $options Optimization options
708 * @return array Optimization results
709 * @throws \Exception If optimization fails
710 */
711 public function optimize_homepage_meta(array $content_data, array $options = []): array {
712 $business_type = $options['business_type'] ?? 'website';
713 $target_audience = $options['target_audience'] ?? 'general';
714 $tone = $options['tone'] ?? 'professional';
715 $context = $options['context'] ?? [];
716
717 $prompt_builder = $this->get_prompt_builder();
718 $prompt = $prompt_builder->build_homepage_meta_prompt($content_data, $business_type, $target_audience, $tone, $context, 'claude');
719
720 $response = $this->make_request('messages', [
721 'model' => $this->model,
722 'max_tokens' => 600,
723 'temperature' => 0.4,
724 'messages' => [
725 [
726 'role' => 'user',
727 'content' => $prompt
728 ]
729 ]
730 ]);
731
732 return $this->parse_homepage_meta_response($response);
733 }
734
735 /**
736 * Optimize homepage hero content using AI (copying Site Identity pattern exactly)
737 *
738 * @since 1.0.0
739 *
740 * @param array $hero_data Hero content data to optimize
741 * @param array $options Optimization options
742 * @return array Optimization results
743 * @throws \Exception If optimization fails
744 */
745 public function optimize_homepage_hero(array $hero_data, array $options = []): array {
746 $business_type = $options['business_type'] ?? 'website';
747 $target_audience = $options['target_audience'] ?? 'general';
748 $tone = $options['tone'] ?? 'professional';
749 $context = $options['context'] ?? [];
750
751 $prompt_builder = $this->get_prompt_builder();
752 $prompt = $prompt_builder->build_homepage_hero_prompt($hero_data, $business_type, $target_audience, $tone, $context, 'claude');
753
754 $response = $this->make_request('messages', [
755 'model' => $this->model,
756 'max_tokens' => 600,
757 'temperature' => 0.4,
758 'messages' => [
759 [
760 'role' => 'user',
761 'content' => $prompt
762 ]
763 ]
764 ]);
765
766 return $this->parse_homepage_hero_response($response);
767 }
768
769 /**
770 * Optimize LLMs.txt content using Claude
771 *
772 * @since 1.0.0
773 *
774 * @param array $website_data Website data to optimize
775 * @param array $options Optimization options
776 * @return array Optimization results
777 * @throws \Exception If optimization fails
778 */
779 public function optimize_llms_txt(array $website_data, array $options = []): array {
780 // Use shared prompt builder for consistent prompts across all AI providers
781 $prompt_builder = $this->get_prompt_builder();
782 $prompt = $prompt_builder->build_llms_txt_prompt($website_data, $options, 'claude');
783
784 $response = $this->make_request('messages', [
785 'model' => $this->model,
786 'max_tokens' => 2000, // Increased for consistency with other providers
787 'temperature' => 0.4,
788 'messages' => [
789 [
790 'role' => 'user',
791 'content' => $prompt
792 ]
793 ]
794 ]);
795
796 return $this->parse_llms_txt_response($response);
797 }
798
799
800
801 /**
802 * Parse LLMs.txt optimization response
803 *
804 * @param array $response Claude API response
805 * @return array Parsed optimization data
806 * @throws \Exception If parsing fails
807 */
808 private function parse_llms_txt_response(array $response): array {
809 if (!isset($response['content'][0]['text'])) {
810 throw new \Exception('Invalid response format from Claude');
811 }
812
813 $content = trim($response['content'][0]['text']);
814 $ai_text = $content; // Store the raw AI-generated text (Content Brief pattern)
815
816 // Extract JSON from response
817 $json_start = strpos($content, '{');
818 $json_end = strrpos($content, '}');
819
820 if (false === $json_start || false === $json_end) {
821 throw new \Exception('No valid JSON found in response');
822 }
823
824 $json_content = substr($content, $json_start, $json_end - $json_start + 1);
825 $optimization = json_decode($json_content, true);
826
827 if (json_last_error() !== JSON_ERROR_NONE) {
828 throw new \Exception('Invalid JSON in response: ' . esc_html(json_last_error_msg()));
829 }
830
831 // Validate and sanitize response
832 return [
833 'optimized_data' => [
834 'site_name' => sanitize_text_field($optimization['optimized_data']['site_name'] ?? ''),
835 'project_overview' => sanitize_textarea_field($optimization['optimized_data']['project_overview'] ?? ''),
836 'key_features' => \ThinkRank\SEO\LLMs_Txt_Manager::normalize_ai_key_features($optimization['optimized_data']['key_features'] ?? ''),
837 'architecture' => sanitize_textarea_field($optimization['optimized_data']['architecture'] ?? ''),
838 'development_guidelines' => sanitize_textarea_field($optimization['optimized_data']['development_guidelines'] ?? ''),
839 'ai_context' => sanitize_textarea_field($optimization['optimized_data']['ai_context'] ?? ''),
840 ],
841 'suggestions' => array_map('sanitize_text_field', $optimization['suggestions'] ?? []),
842 'tokens_used' => ($response['usage']['input_tokens'] ?? 0) + ($response['usage']['output_tokens'] ?? 0),
843 '_ai_text' => $ai_text, // Store the raw AI-generated text (Content Brief pattern)
844 ];
845 }
846
847 /**
848 * Parse homepage meta optimization response
849 *
850 * @param array $response Claude API response
851 * @return array Parsed optimization data
852 * @throws \Exception If parsing fails
853 */
854 private function parse_homepage_meta_response(array $response): array {
855 if (!isset($response['content'][0]['text'])) {
856 throw new \Exception('Invalid response format from Claude');
857 }
858
859 $content = trim($response['content'][0]['text']);
860 $ai_text = $content; // Store the raw AI-generated text (Content Brief pattern)
861
862 // Extract JSON from response
863 $json_start = strpos($content, '{');
864 $json_end = strrpos($content, '}');
865
866 if (false === $json_start || false === $json_end) {
867 throw new \Exception('No valid JSON found in response');
868 }
869
870 $json_content = substr($content, $json_start, $json_end - $json_start + 1);
871 $optimization = json_decode($json_content, true);
872
873 if (json_last_error() !== JSON_ERROR_NONE) {
874 throw new \Exception('Failed to parse JSON response: ' . esc_html(json_last_error_msg()));
875 }
876
877 // Validate and sanitize response
878 return [
879 'optimized_data' => [
880 'title' => sanitize_text_field($optimization['optimized_data']['title'] ?? ''),
881 'meta_description' => sanitize_text_field($optimization['optimized_data']['meta_description'] ?? ''),
882 ],
883 'analysis' => sanitize_textarea_field($optimization['analysis'] ?? ''),
884 'suggestions' => array_map('sanitize_text_field', $optimization['suggestions'] ?? []),
885 'score' => min(100, max(0, (int) ($optimization['score'] ?? 0))),
886 'tokens_used' => ($response['usage']['input_tokens'] ?? 0) + ($response['usage']['output_tokens'] ?? 0),
887 '_ai_text' => $ai_text, // Store the raw AI-generated text (Content Brief pattern)
888 ];
889 }
890
891 /**
892 * Parse homepage hero optimization response
893 *
894 * @param array $response Claude API response
895 * @return array Parsed optimization data
896 * @throws \Exception If parsing fails
897 */
898 private function parse_homepage_hero_response(array $response): array {
899 if (!isset($response['content'][0]['text'])) {
900 throw new \Exception('Invalid response format from Claude');
901 }
902
903 $content = trim($response['content'][0]['text']);
904 $ai_text = $content; // Store the raw AI-generated text (Content Brief pattern)
905
906 // Extract JSON from response
907 $json_start = strpos($content, '{');
908 $json_end = strrpos($content, '}');
909
910 if (false === $json_start || false === $json_end) {
911 throw new \Exception('No valid JSON found in response');
912 }
913
914 $json_content = substr($content, $json_start, $json_end - $json_start + 1);
915 $optimization = json_decode($json_content, true);
916
917 if (json_last_error() !== JSON_ERROR_NONE) {
918 throw new \Exception('Failed to parse JSON response: ' . esc_html(json_last_error_msg()));
919 }
920
921 // Validate and sanitize response
922 return [
923 'optimized_data' => [
924 'hero_title' => sanitize_text_field($optimization['optimized_data']['hero_title'] ?? ''),
925 'hero_subtitle' => sanitize_text_field($optimization['optimized_data']['hero_subtitle'] ?? ''),
926 'hero_cta_text' => sanitize_text_field($optimization['optimized_data']['hero_cta_text'] ?? '')
927 ],
928 'analysis' => sanitize_textarea_field($optimization['analysis'] ?? ''),
929 'suggestions' => array_map('sanitize_text_field', $optimization['suggestions'] ?? []),
930 'score' => min(100, max(0, (int) ($optimization['score'] ?? 0))),
931 'tokens_used' => ($response['usage']['input_tokens'] ?? 0) + ($response['usage']['output_tokens'] ?? 0),
932 '_ai_text' => $ai_text, // Store the raw AI-generated text (Content Brief pattern)
933 ];
934 }
935 }
936