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

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