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

930 lines 35.0 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 $url = self::API_BASE_URL . '/' . ltrim($endpoint, '/');
408
409 $args = [
410 'timeout' => $this->timeout,
411 'headers' => [
412 'x-api-key' => $this->api_key,
413 'Content-Type' => 'application/json',
414 'anthropic-version' => '2023-06-01',
415 'User-Agent' => 'ThinkRank/' . THINKRANK_VERSION,
416 ],
417 'method' => 'POST',
418 'body' => wp_json_encode($body),
419 ];
420
421 $response = $this->request_with_retry($url, $args);
422
423 if (is_wp_error($response)) {
424 throw new \Exception('API request failed: ' . esc_html($response->get_error_message()));
425 }
426
427 $status_code = wp_remote_retrieve_response_code($response);
428 $response_body = wp_remote_retrieve_body($response);
429
430 if ($status_code >= 400) {
431 $error_data = json_decode($response_body, true);
432 $error_message = $error_data['error']['message'] ?? 'Unknown API error';
433 throw new \Exception(sprintf('Claude API error (%d): %s', (int) $status_code, esc_html($error_message)));
434 }
435
436 $data = json_decode($response_body, true);
437
438 if (json_last_error() !== JSON_ERROR_NONE) {
439 throw new \Exception('Invalid JSON response from Claude API');
440 }
441
442 // A valid-but-scalar body (null/number/string from a proxy/gateway on a
443 // 2xx) would violate this method's : array return type; reject it here so
444 // it surfaces as a catchable \Exception, not an uncatchable TypeError.
445 if (!is_array($data)) {
446 throw new \Exception('Unexpected non-array response from Claude API');
447 }
448
449 return $data;
450 }
451
452 /**
453 * Perform an HTTP request, retrying transient failures (429 / 5xx / network)
454 * per the plugin's retry settings, honoring a Retry-After header when given.
455 *
456 * @param string $url Request URL
457 * @param array $args wp_remote_request arguments
458 * @return array|\WP_Error Final response (or last error after retries)
459 */
460 private function request_with_retry(string $url, array $args) {
461 $settings = \ThinkRank\Core\Settings::instance();
462 $retry_enabled = (bool) $settings->get('retry_failed_requests', true);
463 $max_attempts = $retry_enabled ? max(1, (int) $settings->get('retry_attempts', 3)) : 1;
464
465 $response = null;
466 for ($attempt = 1; $attempt <= $max_attempts; $attempt++) {
467 // Keep PHP alive for the whole blocking call (see method docblock).
468 $this->raise_request_time_limit();
469
470 $response = wp_remote_request($url, $args);
471
472 $is_transient = false;
473 $retry_after = 0;
474 if (is_wp_error($response)) {
475 // A client-side timeout means the work genuinely needs longer
476 // than the budget we allowed; re-running the identical prompt,
477 // model and budget just times out again and multiplies the
478 // wait (issue #288). Do not retry a timeout. Other WP_Error
479 // results — DNS, connection refused, TLS — stay retryable.
480 $is_transient = !$this->is_timeout_error($response);
481 } else {
482 $status = wp_remote_retrieve_response_code($response);
483 if (429 === $status || $status >= 500) {
484 $is_transient = true;
485 $retry_after = (int) wp_remote_retrieve_header($response, 'retry-after');
486 }
487 }
488
489 if (!$is_transient || $attempt === $max_attempts) {
490 break;
491 }
492
493 $delay = $retry_after > 0 ? min($retry_after, 30) : min(2 ** ($attempt - 1), 8);
494 sleep($delay);
495 }
496
497 return $response;
498 }
499
500 /**
501 * Give PHP enough execution time to outlive a blocking AI HTTP request.
502 *
503 * The provider call blocks for up to $this->timeout seconds, but the web
504 * SAPI's default max_execution_time (commonly 30s) is shorter — so PHP
505 * fatally terminates the script mid-request (inside the cURL transport),
506 * which the web server surfaces as a 502 Bad Gateway. Resetting the limit
507 * before each attempt keeps the script alive for the full call; PHP-FPM's
508 * request_terminate_timeout still caps the absolute maximum. No-op when
509 * set_time_limit() is disabled (e.g. via disable_functions or safe mode).
510 *
511 * @return void
512 */
513 private function raise_request_time_limit(): void {
514 if (function_exists('set_time_limit')) {
515 @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.
516 }
517 }
518
519 /**
520 * Parse SEO response from Claude
521 *
522 * @param array $response Claude response
523 * @return array Parsed metadata
524 * @throws \Exception If parsing fails
525 */
526 private function parse_seo_response(array $response): array {
527 if (!isset($response['content'][0]['text'])) {
528 throw new \Exception('Invalid response format from Claude');
529 }
530
531 $content = $response['content'][0]['text'];
532 $ai_text = $content; // Store the raw AI-generated text (Content Brief pattern)
533
534 // Try to extract JSON from the response
535 $json_start = strpos($content, '{');
536 $json_end = strrpos($content, '}');
537
538 if (false === $json_start || false === $json_end) {
539 throw new \Exception('No valid JSON found in Claude response');
540 }
541
542 $json_content = substr($content, $json_start, $json_end - $json_start + 1);
543 $metadata = json_decode($json_content, true);
544
545 if (json_last_error() !== JSON_ERROR_NONE) {
546 throw new \Exception('Failed to parse JSON from Claude response');
547 }
548
549 // Validate required fields
550 $required_fields = ['title', 'description', 'focus_keyword'];
551 foreach ($required_fields as $field) {
552 if (!isset($metadata[$field])) {
553 throw new \Exception(sprintf('Missing required field: %s', esc_html($field)));
554 }
555 }
556
557 return [
558 'title' => sanitize_text_field($metadata['title']),
559 'description' => sanitize_text_field($metadata['description']),
560 'focus_keyword' => sanitize_text_field($metadata['focus_keyword']),
561 'suggestions' => array_map('sanitize_text_field', $metadata['suggestions'] ?? []),
562 'generated_at' => current_time('mysql'),
563 'tokens_used' => ($response['usage']['input_tokens'] ?? 0) + ($response['usage']['output_tokens'] ?? 0),
564 '_ai_text' => $ai_text, // Store the raw AI-generated text (Content Brief pattern)
565 ];
566 }
567
568 /**
569 * Parse analysis response from Claude
570 *
571 * @param array $response Claude API response
572 * @return array Parsed analysis data
573 * @throws \Exception If parsing fails
574 */
575 private function parse_analysis_response(array $response): array {
576 if (!isset($response['content'][0]['text'])) {
577 throw new \Exception('Invalid response format from Claude');
578 }
579
580 $content = trim($response['content'][0]['text']);
581 $ai_text = $content; // Store the raw AI-generated text (Content Brief pattern)
582
583 // Extract JSON from response
584 $json_start = strpos($content, '{');
585 $json_end = strrpos($content, '}');
586
587 if (false === $json_start || false === $json_end) {
588 throw new \Exception('No valid JSON found in response');
589 }
590
591 $json_content = substr($content, $json_start, $json_end - $json_start + 1);
592 $analysis = json_decode($json_content, true);
593
594 if (json_last_error() !== JSON_ERROR_NONE) {
595 throw new \Exception('Failed to parse JSON response: ' . esc_html(json_last_error_msg()));
596 }
597
598 // Validate and sanitize response
599 return [
600 'seo_score' => min(100, max(0, (int) ($analysis['seo_score'] ?? 0))),
601 'content_analysis' => [
602 'word_count' => (int) ($analysis['content_analysis']['word_count'] ?? 0),
603 'readability' => sanitize_text_field($analysis['content_analysis']['readability'] ?? 'unknown'),
604 'keyword_density' => sanitize_text_field($analysis['content_analysis']['keyword_density'] ?? 'unknown'),
605 'structure' => sanitize_text_field($analysis['content_analysis']['structure'] ?? 'unknown'),
606 ],
607 'suggestions' => array_map('sanitize_text_field', $analysis['suggestions'] ?? []),
608 'strengths' => array_map('sanitize_text_field', $analysis['strengths'] ?? []),
609 'weaknesses' => array_map('sanitize_text_field', $analysis['weaknesses'] ?? []),
610 'analyzed_at' => current_time('mysql'),
611 'tokens_used' => ($response['usage']['input_tokens'] ?? 0) + ($response['usage']['output_tokens'] ?? 0),
612 '_ai_text' => $ai_text, // Store the raw AI-generated text (Content Brief pattern)
613 ];
614 }
615
616 /**
617 * Optimize site identity using Claude
618 *
619 * @since 1.0.0
620 *
621 * @param array $site_data Site data to optimize
622 * @param array $options Optimization options
623 * @return array Optimization results
624 * @throws \Exception If optimization fails
625 */
626 public function optimize_site_identity(array $site_data, array $options = []): array {
627 $business_type = $options['business_type'] ?? 'website';
628 $target_audience = $options['target_audience'] ?? 'general';
629 $tone = $options['tone'] ?? 'professional';
630
631 $prompt_builder = $this->get_prompt_builder();
632 $prompt = $prompt_builder->build_site_identity_prompt($site_data, $business_type, $target_audience, $tone, 'claude');
633
634 $response = $this->make_request('messages', [
635 'model' => $this->model,
636 'max_tokens' => 600,
637 'temperature' => 0.4,
638 'messages' => [
639 [
640 'role' => 'user',
641 'content' => $prompt
642 ]
643 ]
644 ]);
645
646 return $this->parse_site_identity_response($response);
647 }
648
649 /**
650 * Parse site identity optimization response
651 *
652 * @param array $response Claude API response
653 * @return array Parsed optimization data
654 * @throws \Exception If parsing fails
655 */
656 private function parse_site_identity_response(array $response): array {
657 if (!isset($response['content'][0]['text'])) {
658 throw new \Exception('Invalid response format from Claude');
659 }
660
661 $content = trim($response['content'][0]['text']);
662 $ai_text = $content; // Store the raw AI-generated text (Content Brief pattern)
663
664 // Extract JSON from response
665 $json_start = strpos($content, '{');
666 $json_end = strrpos($content, '}');
667
668 if (false === $json_start || false === $json_end) {
669 throw new \Exception('No valid JSON found in response');
670 }
671
672 $json_content = substr($content, $json_start, $json_end - $json_start + 1);
673 $optimization = json_decode($json_content, true);
674
675 if (json_last_error() !== JSON_ERROR_NONE) {
676 throw new \Exception('Failed to parse JSON response: ' . esc_html(json_last_error_msg()));
677 }
678
679 // Validate and sanitize response
680 return [
681 'optimized_data' => [
682 'site_name' => sanitize_text_field($optimization['optimized_data']['site_name'] ?? ''),
683 'site_description' => sanitize_text_field($optimization['optimized_data']['site_description'] ?? ''),
684 'tagline' => sanitize_text_field($optimization['optimized_data']['tagline'] ?? ''),
685 'default_meta_description' => sanitize_text_field($optimization['optimized_data']['default_meta_description'] ?? ''),
686 ],
687 'analysis' => sanitize_textarea_field($optimization['analysis'] ?? ''),
688 'suggestions' => array_map('sanitize_text_field', $optimization['suggestions'] ?? []),
689 'score' => min(100, max(0, (int) ($optimization['score'] ?? 0))),
690 'tokens_used' => ($response['usage']['input_tokens'] ?? 0) + ($response['usage']['output_tokens'] ?? 0),
691 '_ai_text' => $ai_text, // Store the raw AI-generated text (Content Brief pattern)
692 ];
693 }
694
695 /**
696 * Optimize homepage meta content using AI (copying Site Identity pattern exactly)
697 *
698 * @since 1.0.0
699 *
700 * @param array $content_data Meta content data to optimize
701 * @param array $options Optimization options
702 * @return array Optimization results
703 * @throws \Exception If optimization fails
704 */
705 public function optimize_homepage_meta(array $content_data, array $options = []): array {
706 $business_type = $options['business_type'] ?? 'website';
707 $target_audience = $options['target_audience'] ?? 'general';
708 $tone = $options['tone'] ?? 'professional';
709 $context = $options['context'] ?? [];
710
711 $prompt_builder = $this->get_prompt_builder();
712 $prompt = $prompt_builder->build_homepage_meta_prompt($content_data, $business_type, $target_audience, $tone, $context, 'claude');
713
714 $response = $this->make_request('messages', [
715 'model' => $this->model,
716 'max_tokens' => 600,
717 'temperature' => 0.4,
718 'messages' => [
719 [
720 'role' => 'user',
721 'content' => $prompt
722 ]
723 ]
724 ]);
725
726 return $this->parse_homepage_meta_response($response);
727 }
728
729 /**
730 * Optimize homepage hero content using AI (copying Site Identity pattern exactly)
731 *
732 * @since 1.0.0
733 *
734 * @param array $hero_data Hero content data to optimize
735 * @param array $options Optimization options
736 * @return array Optimization results
737 * @throws \Exception If optimization fails
738 */
739 public function optimize_homepage_hero(array $hero_data, array $options = []): array {
740 $business_type = $options['business_type'] ?? 'website';
741 $target_audience = $options['target_audience'] ?? 'general';
742 $tone = $options['tone'] ?? 'professional';
743 $context = $options['context'] ?? [];
744
745 $prompt_builder = $this->get_prompt_builder();
746 $prompt = $prompt_builder->build_homepage_hero_prompt($hero_data, $business_type, $target_audience, $tone, $context, 'claude');
747
748 $response = $this->make_request('messages', [
749 'model' => $this->model,
750 'max_tokens' => 600,
751 'temperature' => 0.4,
752 'messages' => [
753 [
754 'role' => 'user',
755 'content' => $prompt
756 ]
757 ]
758 ]);
759
760 return $this->parse_homepage_hero_response($response);
761 }
762
763 /**
764 * Optimize LLMs.txt content using Claude
765 *
766 * @since 1.0.0
767 *
768 * @param array $website_data Website data to optimize
769 * @param array $options Optimization options
770 * @return array Optimization results
771 * @throws \Exception If optimization fails
772 */
773 public function optimize_llms_txt(array $website_data, array $options = []): array {
774 // Use shared prompt builder for consistent prompts across all AI providers
775 $prompt_builder = $this->get_prompt_builder();
776 $prompt = $prompt_builder->build_llms_txt_prompt($website_data, $options, 'claude');
777
778 $response = $this->make_request('messages', [
779 'model' => $this->model,
780 'max_tokens' => 2000, // Increased for consistency with other providers
781 'temperature' => 0.4,
782 'messages' => [
783 [
784 'role' => 'user',
785 'content' => $prompt
786 ]
787 ]
788 ]);
789
790 return $this->parse_llms_txt_response($response);
791 }
792
793
794
795 /**
796 * Parse LLMs.txt optimization response
797 *
798 * @param array $response Claude API response
799 * @return array Parsed optimization data
800 * @throws \Exception If parsing fails
801 */
802 private function parse_llms_txt_response(array $response): array {
803 if (!isset($response['content'][0]['text'])) {
804 throw new \Exception('Invalid response format from Claude');
805 }
806
807 $content = trim($response['content'][0]['text']);
808 $ai_text = $content; // Store the raw AI-generated text (Content Brief pattern)
809
810 // Extract JSON from response
811 $json_start = strpos($content, '{');
812 $json_end = strrpos($content, '}');
813
814 if (false === $json_start || false === $json_end) {
815 throw new \Exception('No valid JSON found in response');
816 }
817
818 $json_content = substr($content, $json_start, $json_end - $json_start + 1);
819 $optimization = json_decode($json_content, true);
820
821 if (json_last_error() !== JSON_ERROR_NONE) {
822 throw new \Exception('Invalid JSON in response: ' . esc_html(json_last_error_msg()));
823 }
824
825 // Validate and sanitize response
826 return [
827 'optimized_data' => [
828 'site_name' => sanitize_text_field($optimization['optimized_data']['site_name'] ?? ''),
829 'project_overview' => sanitize_textarea_field($optimization['optimized_data']['project_overview'] ?? ''),
830 'key_features' => sanitize_textarea_field($optimization['optimized_data']['key_features'] ?? ''),
831 'architecture' => sanitize_textarea_field($optimization['optimized_data']['architecture'] ?? ''),
832 'development_guidelines' => sanitize_textarea_field($optimization['optimized_data']['development_guidelines'] ?? ''),
833 'ai_context' => sanitize_textarea_field($optimization['optimized_data']['ai_context'] ?? ''),
834 ],
835 'suggestions' => array_map('sanitize_text_field', $optimization['suggestions'] ?? []),
836 'tokens_used' => ($response['usage']['input_tokens'] ?? 0) + ($response['usage']['output_tokens'] ?? 0),
837 '_ai_text' => $ai_text, // Store the raw AI-generated text (Content Brief pattern)
838 ];
839 }
840
841 /**
842 * Parse homepage meta optimization response
843 *
844 * @param array $response Claude API response
845 * @return array Parsed optimization data
846 * @throws \Exception If parsing fails
847 */
848 private function parse_homepage_meta_response(array $response): array {
849 if (!isset($response['content'][0]['text'])) {
850 throw new \Exception('Invalid response format from Claude');
851 }
852
853 $content = trim($response['content'][0]['text']);
854 $ai_text = $content; // Store the raw AI-generated text (Content Brief pattern)
855
856 // Extract JSON from response
857 $json_start = strpos($content, '{');
858 $json_end = strrpos($content, '}');
859
860 if (false === $json_start || false === $json_end) {
861 throw new \Exception('No valid JSON found in response');
862 }
863
864 $json_content = substr($content, $json_start, $json_end - $json_start + 1);
865 $optimization = json_decode($json_content, true);
866
867 if (json_last_error() !== JSON_ERROR_NONE) {
868 throw new \Exception('Failed to parse JSON response: ' . esc_html(json_last_error_msg()));
869 }
870
871 // Validate and sanitize response
872 return [
873 'optimized_data' => [
874 'title' => sanitize_text_field($optimization['optimized_data']['title'] ?? ''),
875 'meta_description' => sanitize_text_field($optimization['optimized_data']['meta_description'] ?? ''),
876 ],
877 'analysis' => sanitize_textarea_field($optimization['analysis'] ?? ''),
878 'suggestions' => array_map('sanitize_text_field', $optimization['suggestions'] ?? []),
879 'score' => min(100, max(0, (int) ($optimization['score'] ?? 0))),
880 'tokens_used' => ($response['usage']['input_tokens'] ?? 0) + ($response['usage']['output_tokens'] ?? 0),
881 '_ai_text' => $ai_text, // Store the raw AI-generated text (Content Brief pattern)
882 ];
883 }
884
885 /**
886 * Parse homepage hero optimization response
887 *
888 * @param array $response Claude API response
889 * @return array Parsed optimization data
890 * @throws \Exception If parsing fails
891 */
892 private function parse_homepage_hero_response(array $response): array {
893 if (!isset($response['content'][0]['text'])) {
894 throw new \Exception('Invalid response format from Claude');
895 }
896
897 $content = trim($response['content'][0]['text']);
898 $ai_text = $content; // Store the raw AI-generated text (Content Brief pattern)
899
900 // Extract JSON from response
901 $json_start = strpos($content, '{');
902 $json_end = strrpos($content, '}');
903
904 if (false === $json_start || false === $json_end) {
905 throw new \Exception('No valid JSON found in response');
906 }
907
908 $json_content = substr($content, $json_start, $json_end - $json_start + 1);
909 $optimization = json_decode($json_content, true);
910
911 if (json_last_error() !== JSON_ERROR_NONE) {
912 throw new \Exception('Failed to parse JSON response: ' . esc_html(json_last_error_msg()));
913 }
914
915 // Validate and sanitize response
916 return [
917 'optimized_data' => [
918 'hero_title' => sanitize_text_field($optimization['optimized_data']['hero_title'] ?? ''),
919 'hero_subtitle' => sanitize_text_field($optimization['optimized_data']['hero_subtitle'] ?? ''),
920 'hero_cta_text' => sanitize_text_field($optimization['optimized_data']['hero_cta_text'] ?? '')
921 ],
922 'analysis' => sanitize_textarea_field($optimization['analysis'] ?? ''),
923 'suggestions' => array_map('sanitize_text_field', $optimization['suggestions'] ?? []),
924 'score' => min(100, max(0, (int) ($optimization['score'] ?? 0))),
925 'tokens_used' => ($response['usage']['input_tokens'] ?? 0) + ($response['usage']['output_tokens'] ?? 0),
926 '_ai_text' => $ai_text, // Store the raw AI-generated text (Content Brief pattern)
927 ];
928 }
929 }
930