PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.0.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.0.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-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.0.0, at includes/ai/class-claude-client.php

827 lines 31.4 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+, Sonnet 5, and Fable 5 — including any
172 * date-suffixed or "-latest" alias of them — so they must be omitted from the
173 * request body or the API returns a 400.
174 *
175 * @param string $model Model ID
176 * @return bool
177 */
178 private function model_rejects_sampling_params(string $model): bool {
179 foreach (['claude-opus-4-7', 'claude-opus-4-8', 'claude-sonnet-5', 'claude-fable-5', 'claude-mythos-5'] as $prefix) {
180 if (strpos($model, $prefix) === 0) {
181 return true;
182 }
183 }
184 return false;
185 }
186
187 /**
188 * Generate SEO metadata
189 *
190 * @param string $content Content to analyze
191 * @param array $options Generation options
192 * @return array Generated metadata
193 * @throws \Exception If generation fails
194 */
195 public function generate_seo_metadata(string $content, array $options = []): array {
196 $target_keyword = $options['target_keyword'] ?? '';
197 $content_type = $options['content_type'] ?? 'blog_post';
198 $tone = $options['tone'] ?? 'professional';
199
200 $prompt_builder = $this->get_prompt_builder();
201 $language = is_string($options['language'] ?? null) ? $options['language'] : '';
202 $prompt = $prompt_builder->build_seo_prompt($content, $target_keyword, $content_type, $tone, 'claude', $language);
203
204 $response = $this->generate_completion($prompt, [
205 'max_tokens' => 500,
206 'temperature' => 0.3,
207 ]);
208
209 return $this->parse_seo_response($response);
210 }
211
212 /**
213 * Analyze content for SEO optimization
214 *
215 * @param string $content Content to analyze
216 * @param array $metadata Existing metadata
217 * @return array Analysis results
218 * @throws \Exception If analysis fails
219 */
220 public function analyze_content(string $content, array $metadata = []): array {
221 $prompt_builder = $this->get_prompt_builder();
222 $prompt = $prompt_builder->build_analysis_prompt($content, $metadata, 'claude');
223
224 $response = $this->generate_completion($prompt, [
225 'max_tokens' => 800,
226 'temperature' => 0.3,
227 ]);
228
229 return $this->parse_analysis_response($response);
230 }
231
232 /**
233 * Get current model
234 *
235 * @return string Current model name
236 */
237 public function get_model(): string {
238 return $this->model;
239 }
240
241 /**
242 * Maximum completion (output) tokens accepted for a single Claude request.
243 *
244 * 8192 is accepted by every current Claude model without the extended-output
245 * beta header, so it is a safe per-request ceiling. Kept as a method (rather
246 * than a constant) to mirror the other clients and allow per-model tuning.
247 *
248 * @param string $model Model ID (reserved for future per-model limits).
249 * @return int Maximum output tokens.
250 */
251 private function get_max_completion_tokens(string $model): int {
252 return 8192;
253 }
254
255 /**
256 * Recommended output-token budget for a given use case.
257 *
258 * Mirrors the other clients so the Content Brief generator no longer falls
259 * back to a hardcoded, model-blind budget for Claude (issue #287). Each
260 * value is a fraction of the model's completion ceiling.
261 *
262 * @param string $use_case e.g. 'content_brief', 'seo_metadata', 'analysis'.
263 * @return int Recommended max output tokens.
264 */
265 public function get_recommended_tokens(string $use_case): int {
266 $max_tokens = $this->get_max_completion_tokens($this->model);
267
268 $recommendations = [
269 'content_brief' => 0.9, // Comprehensive brief incl. a full article body.
270 'seo_metadata' => 0.15,
271 'analysis' => 0.25,
272 'llms_txt' => 0.5,
273 'optimization' => 0.15,
274 ];
275 $percentage = $recommendations[$use_case] ?? 0.15;
276
277 return (int) ($max_tokens * $percentage);
278 }
279
280 /**
281 * Test API connection
282 *
283 * @return bool True if connection successful
284 */
285 public function test_connection(): bool {
286 try {
287 // Claude doesn't have a models endpoint, so we'll test with a simple message
288 $response = $this->generate_completion('Hello', ['max_tokens' => 10]);
289 return isset($response['content']) && is_array($response['content']);
290 } catch (\Exception $e) {
291 return false;
292 }
293 }
294
295 /**
296 * Make API request to Claude
297 *
298 * @param string $endpoint API endpoint
299 * @param array $body Request body
300 * @return array Response data
301 * @throws \Exception If request fails
302 */
303 private function make_request(string $endpoint, array $body = []): array {
304 $url = self::API_BASE_URL . '/' . ltrim($endpoint, '/');
305
306 $args = [
307 'timeout' => $this->timeout,
308 'headers' => [
309 'x-api-key' => $this->api_key,
310 'Content-Type' => 'application/json',
311 'anthropic-version' => '2023-06-01',
312 'User-Agent' => 'ThinkRank/' . THINKRANK_VERSION,
313 ],
314 'method' => 'POST',
315 'body' => wp_json_encode($body),
316 ];
317
318 $response = $this->request_with_retry($url, $args);
319
320 if (is_wp_error($response)) {
321 throw new \Exception('API request failed: ' . esc_html($response->get_error_message()));
322 }
323
324 $status_code = wp_remote_retrieve_response_code($response);
325 $response_body = wp_remote_retrieve_body($response);
326
327 if ($status_code >= 400) {
328 $error_data = json_decode($response_body, true);
329 $error_message = $error_data['error']['message'] ?? 'Unknown API error';
330 throw new \Exception(sprintf('Claude API error (%d): %s', (int) $status_code, esc_html($error_message)));
331 }
332
333 $data = json_decode($response_body, true);
334
335 if (json_last_error() !== JSON_ERROR_NONE) {
336 throw new \Exception('Invalid JSON response from Claude API');
337 }
338
339 // A valid-but-scalar body (null/number/string from a proxy/gateway on a
340 // 2xx) would violate this method's : array return type; reject it here so
341 // it surfaces as a catchable \Exception, not an uncatchable TypeError.
342 if (!is_array($data)) {
343 throw new \Exception('Unexpected non-array response from Claude API');
344 }
345
346 return $data;
347 }
348
349 /**
350 * Perform an HTTP request, retrying transient failures (429 / 5xx / network)
351 * per the plugin's retry settings, honoring a Retry-After header when given.
352 *
353 * @param string $url Request URL
354 * @param array $args wp_remote_request arguments
355 * @return array|\WP_Error Final response (or last error after retries)
356 */
357 private function request_with_retry(string $url, array $args) {
358 $settings = \ThinkRank\Core\Settings::instance();
359 $retry_enabled = (bool) $settings->get('retry_failed_requests', true);
360 $max_attempts = $retry_enabled ? max(1, (int) $settings->get('retry_attempts', 3)) : 1;
361
362 $response = null;
363 for ($attempt = 1; $attempt <= $max_attempts; $attempt++) {
364 // Keep PHP alive for the whole blocking call (see method docblock).
365 $this->raise_request_time_limit();
366
367 $response = wp_remote_request($url, $args);
368
369 $is_transient = false;
370 $retry_after = 0;
371 if (is_wp_error($response)) {
372 // A client-side timeout means the work genuinely needs longer
373 // than the budget we allowed; re-running the identical prompt,
374 // model and budget just times out again and multiplies the
375 // wait (issue #288). Do not retry a timeout. Other WP_Error
376 // results — DNS, connection refused, TLS — stay retryable.
377 $is_transient = !$this->is_timeout_error($response);
378 } else {
379 $status = wp_remote_retrieve_response_code($response);
380 if (429 === $status || $status >= 500) {
381 $is_transient = true;
382 $retry_after = (int) wp_remote_retrieve_header($response, 'retry-after');
383 }
384 }
385
386 if (!$is_transient || $attempt === $max_attempts) {
387 break;
388 }
389
390 $delay = $retry_after > 0 ? min($retry_after, 30) : min(2 ** ($attempt - 1), 8);
391 sleep($delay);
392 }
393
394 return $response;
395 }
396
397 /**
398 * Give PHP enough execution time to outlive a blocking AI HTTP request.
399 *
400 * The provider call blocks for up to $this->timeout seconds, but the web
401 * SAPI's default max_execution_time (commonly 30s) is shorter — so PHP
402 * fatally terminates the script mid-request (inside the cURL transport),
403 * which the web server surfaces as a 502 Bad Gateway. Resetting the limit
404 * before each attempt keeps the script alive for the full call; PHP-FPM's
405 * request_terminate_timeout still caps the absolute maximum. No-op when
406 * set_time_limit() is disabled (e.g. via disable_functions or safe mode).
407 *
408 * @return void
409 */
410 private function raise_request_time_limit(): void {
411 if (function_exists('set_time_limit')) {
412 @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.
413 }
414 }
415
416 /**
417 * Parse SEO response from Claude
418 *
419 * @param array $response Claude response
420 * @return array Parsed metadata
421 * @throws \Exception If parsing fails
422 */
423 private function parse_seo_response(array $response): array {
424 if (!isset($response['content'][0]['text'])) {
425 throw new \Exception('Invalid response format from Claude');
426 }
427
428 $content = $response['content'][0]['text'];
429 $ai_text = $content; // Store the raw AI-generated text (Content Brief pattern)
430
431 // Try to extract JSON from the response
432 $json_start = strpos($content, '{');
433 $json_end = strrpos($content, '}');
434
435 if (false === $json_start || false === $json_end) {
436 throw new \Exception('No valid JSON found in Claude response');
437 }
438
439 $json_content = substr($content, $json_start, $json_end - $json_start + 1);
440 $metadata = json_decode($json_content, true);
441
442 if (json_last_error() !== JSON_ERROR_NONE) {
443 throw new \Exception('Failed to parse JSON from Claude response');
444 }
445
446 // Validate required fields
447 $required_fields = ['title', 'description', 'focus_keyword'];
448 foreach ($required_fields as $field) {
449 if (!isset($metadata[$field])) {
450 throw new \Exception(sprintf('Missing required field: %s', esc_html($field)));
451 }
452 }
453
454 return [
455 'title' => sanitize_text_field($metadata['title']),
456 'description' => sanitize_text_field($metadata['description']),
457 'focus_keyword' => sanitize_text_field($metadata['focus_keyword']),
458 'suggestions' => array_map('sanitize_text_field', $metadata['suggestions'] ?? []),
459 'generated_at' => current_time('mysql'),
460 'tokens_used' => ($response['usage']['input_tokens'] ?? 0) + ($response['usage']['output_tokens'] ?? 0),
461 '_ai_text' => $ai_text, // Store the raw AI-generated text (Content Brief pattern)
462 ];
463 }
464
465 /**
466 * Parse analysis response from Claude
467 *
468 * @param array $response Claude API response
469 * @return array Parsed analysis data
470 * @throws \Exception If parsing fails
471 */
472 private function parse_analysis_response(array $response): array {
473 if (!isset($response['content'][0]['text'])) {
474 throw new \Exception('Invalid response format from Claude');
475 }
476
477 $content = trim($response['content'][0]['text']);
478 $ai_text = $content; // Store the raw AI-generated text (Content Brief pattern)
479
480 // Extract JSON from response
481 $json_start = strpos($content, '{');
482 $json_end = strrpos($content, '}');
483
484 if (false === $json_start || false === $json_end) {
485 throw new \Exception('No valid JSON found in response');
486 }
487
488 $json_content = substr($content, $json_start, $json_end - $json_start + 1);
489 $analysis = json_decode($json_content, true);
490
491 if (json_last_error() !== JSON_ERROR_NONE) {
492 throw new \Exception('Failed to parse JSON response: ' . esc_html(json_last_error_msg()));
493 }
494
495 // Validate and sanitize response
496 return [
497 'seo_score' => min(100, max(0, (int) ($analysis['seo_score'] ?? 0))),
498 'content_analysis' => [
499 'word_count' => (int) ($analysis['content_analysis']['word_count'] ?? 0),
500 'readability' => sanitize_text_field($analysis['content_analysis']['readability'] ?? 'unknown'),
501 'keyword_density' => sanitize_text_field($analysis['content_analysis']['keyword_density'] ?? 'unknown'),
502 'structure' => sanitize_text_field($analysis['content_analysis']['structure'] ?? 'unknown'),
503 ],
504 'suggestions' => array_map('sanitize_text_field', $analysis['suggestions'] ?? []),
505 'strengths' => array_map('sanitize_text_field', $analysis['strengths'] ?? []),
506 'weaknesses' => array_map('sanitize_text_field', $analysis['weaknesses'] ?? []),
507 'analyzed_at' => current_time('mysql'),
508 'tokens_used' => ($response['usage']['input_tokens'] ?? 0) + ($response['usage']['output_tokens'] ?? 0),
509 '_ai_text' => $ai_text, // Store the raw AI-generated text (Content Brief pattern)
510 ];
511 }
512
513 /**
514 * Optimize site identity using Claude
515 *
516 * @since 1.0.0
517 *
518 * @param array $site_data Site data to optimize
519 * @param array $options Optimization options
520 * @return array Optimization results
521 * @throws \Exception If optimization fails
522 */
523 public function optimize_site_identity(array $site_data, array $options = []): array {
524 $business_type = $options['business_type'] ?? 'website';
525 $target_audience = $options['target_audience'] ?? 'general';
526 $tone = $options['tone'] ?? 'professional';
527
528 $prompt_builder = $this->get_prompt_builder();
529 $prompt = $prompt_builder->build_site_identity_prompt($site_data, $business_type, $target_audience, $tone, 'claude');
530
531 $response = $this->make_request('messages', [
532 'model' => $this->model,
533 'max_tokens' => 600,
534 'temperature' => 0.4,
535 'messages' => [
536 [
537 'role' => 'user',
538 'content' => $prompt
539 ]
540 ]
541 ]);
542
543 return $this->parse_site_identity_response($response);
544 }
545
546 /**
547 * Parse site identity optimization response
548 *
549 * @param array $response Claude API response
550 * @return array Parsed optimization data
551 * @throws \Exception If parsing fails
552 */
553 private function parse_site_identity_response(array $response): array {
554 if (!isset($response['content'][0]['text'])) {
555 throw new \Exception('Invalid response format from Claude');
556 }
557
558 $content = trim($response['content'][0]['text']);
559 $ai_text = $content; // Store the raw AI-generated text (Content Brief pattern)
560
561 // Extract JSON from response
562 $json_start = strpos($content, '{');
563 $json_end = strrpos($content, '}');
564
565 if (false === $json_start || false === $json_end) {
566 throw new \Exception('No valid JSON found in response');
567 }
568
569 $json_content = substr($content, $json_start, $json_end - $json_start + 1);
570 $optimization = json_decode($json_content, true);
571
572 if (json_last_error() !== JSON_ERROR_NONE) {
573 throw new \Exception('Failed to parse JSON response: ' . esc_html(json_last_error_msg()));
574 }
575
576 // Validate and sanitize response
577 return [
578 'optimized_data' => [
579 'site_name' => sanitize_text_field($optimization['optimized_data']['site_name'] ?? ''),
580 'site_description' => sanitize_text_field($optimization['optimized_data']['site_description'] ?? ''),
581 'tagline' => sanitize_text_field($optimization['optimized_data']['tagline'] ?? ''),
582 'default_meta_description' => sanitize_text_field($optimization['optimized_data']['default_meta_description'] ?? ''),
583 ],
584 'analysis' => sanitize_textarea_field($optimization['analysis'] ?? ''),
585 'suggestions' => array_map('sanitize_text_field', $optimization['suggestions'] ?? []),
586 'score' => min(100, max(0, (int) ($optimization['score'] ?? 0))),
587 'tokens_used' => ($response['usage']['input_tokens'] ?? 0) + ($response['usage']['output_tokens'] ?? 0),
588 '_ai_text' => $ai_text, // Store the raw AI-generated text (Content Brief pattern)
589 ];
590 }
591
592 /**
593 * Optimize homepage meta content using AI (copying Site Identity pattern exactly)
594 *
595 * @since 1.0.0
596 *
597 * @param array $content_data Meta content data to optimize
598 * @param array $options Optimization options
599 * @return array Optimization results
600 * @throws \Exception If optimization fails
601 */
602 public function optimize_homepage_meta(array $content_data, array $options = []): array {
603 $business_type = $options['business_type'] ?? 'website';
604 $target_audience = $options['target_audience'] ?? 'general';
605 $tone = $options['tone'] ?? 'professional';
606 $context = $options['context'] ?? [];
607
608 $prompt_builder = $this->get_prompt_builder();
609 $prompt = $prompt_builder->build_homepage_meta_prompt($content_data, $business_type, $target_audience, $tone, $context, 'claude');
610
611 $response = $this->make_request('messages', [
612 'model' => $this->model,
613 'max_tokens' => 600,
614 'temperature' => 0.4,
615 'messages' => [
616 [
617 'role' => 'user',
618 'content' => $prompt
619 ]
620 ]
621 ]);
622
623 return $this->parse_homepage_meta_response($response);
624 }
625
626 /**
627 * Optimize homepage hero content using AI (copying Site Identity pattern exactly)
628 *
629 * @since 1.0.0
630 *
631 * @param array $hero_data Hero content data to optimize
632 * @param array $options Optimization options
633 * @return array Optimization results
634 * @throws \Exception If optimization fails
635 */
636 public function optimize_homepage_hero(array $hero_data, array $options = []): array {
637 $business_type = $options['business_type'] ?? 'website';
638 $target_audience = $options['target_audience'] ?? 'general';
639 $tone = $options['tone'] ?? 'professional';
640 $context = $options['context'] ?? [];
641
642 $prompt_builder = $this->get_prompt_builder();
643 $prompt = $prompt_builder->build_homepage_hero_prompt($hero_data, $business_type, $target_audience, $tone, $context, 'claude');
644
645 $response = $this->make_request('messages', [
646 'model' => $this->model,
647 'max_tokens' => 600,
648 'temperature' => 0.4,
649 'messages' => [
650 [
651 'role' => 'user',
652 'content' => $prompt
653 ]
654 ]
655 ]);
656
657 return $this->parse_homepage_hero_response($response);
658 }
659
660 /**
661 * Optimize LLMs.txt content using Claude
662 *
663 * @since 1.0.0
664 *
665 * @param array $website_data Website data to optimize
666 * @param array $options Optimization options
667 * @return array Optimization results
668 * @throws \Exception If optimization fails
669 */
670 public function optimize_llms_txt(array $website_data, array $options = []): array {
671 // Use shared prompt builder for consistent prompts across all AI providers
672 $prompt_builder = $this->get_prompt_builder();
673 $prompt = $prompt_builder->build_llms_txt_prompt($website_data, $options, 'claude');
674
675 $response = $this->make_request('messages', [
676 'model' => $this->model,
677 'max_tokens' => 2000, // Increased for consistency with other providers
678 'temperature' => 0.4,
679 'messages' => [
680 [
681 'role' => 'user',
682 'content' => $prompt
683 ]
684 ]
685 ]);
686
687 return $this->parse_llms_txt_response($response);
688 }
689
690
691
692 /**
693 * Parse LLMs.txt optimization response
694 *
695 * @param array $response Claude API response
696 * @return array Parsed optimization data
697 * @throws \Exception If parsing fails
698 */
699 private function parse_llms_txt_response(array $response): array {
700 if (!isset($response['content'][0]['text'])) {
701 throw new \Exception('Invalid response format from Claude');
702 }
703
704 $content = trim($response['content'][0]['text']);
705 $ai_text = $content; // Store the raw AI-generated text (Content Brief pattern)
706
707 // Extract JSON from response
708 $json_start = strpos($content, '{');
709 $json_end = strrpos($content, '}');
710
711 if (false === $json_start || false === $json_end) {
712 throw new \Exception('No valid JSON found in response');
713 }
714
715 $json_content = substr($content, $json_start, $json_end - $json_start + 1);
716 $optimization = json_decode($json_content, true);
717
718 if (json_last_error() !== JSON_ERROR_NONE) {
719 throw new \Exception('Invalid JSON in response: ' . esc_html(json_last_error_msg()));
720 }
721
722 // Validate and sanitize response
723 return [
724 'optimized_data' => [
725 'site_name' => sanitize_text_field($optimization['optimized_data']['site_name'] ?? ''),
726 'project_overview' => sanitize_textarea_field($optimization['optimized_data']['project_overview'] ?? ''),
727 'key_features' => sanitize_textarea_field($optimization['optimized_data']['key_features'] ?? ''),
728 'architecture' => sanitize_textarea_field($optimization['optimized_data']['architecture'] ?? ''),
729 'development_guidelines' => sanitize_textarea_field($optimization['optimized_data']['development_guidelines'] ?? ''),
730 'ai_context' => sanitize_textarea_field($optimization['optimized_data']['ai_context'] ?? ''),
731 ],
732 'suggestions' => array_map('sanitize_text_field', $optimization['suggestions'] ?? []),
733 'tokens_used' => ($response['usage']['input_tokens'] ?? 0) + ($response['usage']['output_tokens'] ?? 0),
734 '_ai_text' => $ai_text, // Store the raw AI-generated text (Content Brief pattern)
735 ];
736 }
737
738 /**
739 * Parse homepage meta optimization response
740 *
741 * @param array $response Claude API response
742 * @return array Parsed optimization data
743 * @throws \Exception If parsing fails
744 */
745 private function parse_homepage_meta_response(array $response): array {
746 if (!isset($response['content'][0]['text'])) {
747 throw new \Exception('Invalid response format from Claude');
748 }
749
750 $content = trim($response['content'][0]['text']);
751 $ai_text = $content; // Store the raw AI-generated text (Content Brief pattern)
752
753 // Extract JSON from response
754 $json_start = strpos($content, '{');
755 $json_end = strrpos($content, '}');
756
757 if (false === $json_start || false === $json_end) {
758 throw new \Exception('No valid JSON found in response');
759 }
760
761 $json_content = substr($content, $json_start, $json_end - $json_start + 1);
762 $optimization = json_decode($json_content, true);
763
764 if (json_last_error() !== JSON_ERROR_NONE) {
765 throw new \Exception('Failed to parse JSON response: ' . esc_html(json_last_error_msg()));
766 }
767
768 // Validate and sanitize response
769 return [
770 'optimized_data' => [
771 'title' => sanitize_text_field($optimization['optimized_data']['title'] ?? ''),
772 'meta_description' => sanitize_text_field($optimization['optimized_data']['meta_description'] ?? ''),
773 ],
774 'analysis' => sanitize_textarea_field($optimization['analysis'] ?? ''),
775 'suggestions' => array_map('sanitize_text_field', $optimization['suggestions'] ?? []),
776 'score' => min(100, max(0, (int) ($optimization['score'] ?? 0))),
777 'tokens_used' => ($response['usage']['input_tokens'] ?? 0) + ($response['usage']['output_tokens'] ?? 0),
778 '_ai_text' => $ai_text, // Store the raw AI-generated text (Content Brief pattern)
779 ];
780 }
781
782 /**
783 * Parse homepage hero optimization response
784 *
785 * @param array $response Claude API response
786 * @return array Parsed optimization data
787 * @throws \Exception If parsing fails
788 */
789 private function parse_homepage_hero_response(array $response): array {
790 if (!isset($response['content'][0]['text'])) {
791 throw new \Exception('Invalid response format from Claude');
792 }
793
794 $content = trim($response['content'][0]['text']);
795 $ai_text = $content; // Store the raw AI-generated text (Content Brief pattern)
796
797 // Extract JSON from response
798 $json_start = strpos($content, '{');
799 $json_end = strrpos($content, '}');
800
801 if (false === $json_start || false === $json_end) {
802 throw new \Exception('No valid JSON found in response');
803 }
804
805 $json_content = substr($content, $json_start, $json_end - $json_start + 1);
806 $optimization = json_decode($json_content, true);
807
808 if (json_last_error() !== JSON_ERROR_NONE) {
809 throw new \Exception('Failed to parse JSON response: ' . esc_html(json_last_error_msg()));
810 }
811
812 // Validate and sanitize response
813 return [
814 'optimized_data' => [
815 'hero_title' => sanitize_text_field($optimization['optimized_data']['hero_title'] ?? ''),
816 'hero_subtitle' => sanitize_text_field($optimization['optimized_data']['hero_subtitle'] ?? ''),
817 'hero_cta_text' => sanitize_text_field($optimization['optimized_data']['hero_cta_text'] ?? '')
818 ],
819 'analysis' => sanitize_textarea_field($optimization['analysis'] ?? ''),
820 'suggestions' => array_map('sanitize_text_field', $optimization['suggestions'] ?? []),
821 'score' => min(100, max(0, (int) ($optimization['score'] ?? 0))),
822 'tokens_used' => ($response['usage']['input_tokens'] ?? 0) + ($response['usage']['output_tokens'] ?? 0),
823 '_ai_text' => $ai_text, // Store the raw AI-generated text (Content Brief pattern)
824 ];
825 }
826 }
827