PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.10.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.10.0
2.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 All 51 releases
← All changes | includes/ai/class-openai-client.php +388 -25 1.27.0 → 2.10.0 View file →
@@ -11,28 +11,64 @@
11 11 declare(strict_types=1);
12 12
13 13 namespace ThinkRank\AI;
14 14
15 +use ThinkRank\AI\Traits\Request_Timeout;
16 +
15 17 // Prevent direct access
16 18 if (!defined('ABSPATH')) {
17 19 exit;
18 20 }
19 21
22 +require_once __DIR__ . '/traits/trait-request-timeout.php';
23 +require_once __DIR__ . '/class-endpoint-url-validator.php';
24 +
20 25 /**
21 26 * OpenAI Client Class
22 - *
27 + *
23 28 * Single Responsibility: Handle OpenAI API communication
24 - *
29 + *
25 30 * @since 1.0.0
26 31 */
27 32 class OpenAI_Client {
33 +
34 + use Request_Timeout;
35 +
28 36
29 37 /**
30 - * OpenAI API base URL
38 + * OpenAI's own API base URL — the default when no other is given.
31 39 */
32 - private const API_BASE_URL = 'https://api.openai.com/v1';
40 + public const API_BASE_URL = 'https://api.openai.com/v1';
41 +
42 + /**
43 + * Base URL every request is built on.
44 + *
45 + * Not always OpenAI's: the `openai_compatible` provider (#721) points this
46 + * at any server speaking the Chat Completions API — Ollama, LM Studio,
47 + * vLLM, Azure OpenAI, a company gateway — so the rest of this client, the
48 + * retry policy and the response parsing are shared verbatim.
49 + *
50 + * @since 2.8.0
51 + * @var string
52 + */
53 + private string $base_url;
33 54
34 55 /**
56 + * Ceiling for a response body from a user-named endpoint.
57 + *
58 + * Mirrors Endpoint_URL_Validator::MAX_RESPONSE_BYTES; kept here too so the
59 + * truncation message and the transport limit cannot drift apart.
60 + */
61 + private const MAX_RESPONSE_BYTES = 2097152; // 2 MB.
62 +
63 + /**
64 + * Output-token ceiling for an unrecognised model on a user-named endpoint.
65 + *
66 + * See get_max_completion_tokens().
67 + */
68 + private const COMPATIBLE_DEFAULT_MAX_TOKENS = 8192;
69 +
70 + /**
35 71 * API key
36 72 *
37 73 * @var string
38 74 */
@@ -52,8 +88,19 @@
52 88 */
53 89 private int $timeout;
54 90
55 91 /**
92 + * Ask a custom endpoint to constrain JSON answers with response_format.
93 + *
94 + * Off by default because not every compatible server takes it (LM Studio
95 + * accepts only json_schema). See set_json_mode().
96 + *
97 + * @since 2.8.0
98 + * @var bool
99 + */
100 + private bool $json_mode = false;
101 +
102 + /**
56 103 * Prompt Builder instance
57 104 *
58 105 * @since 1.0.0
59 106 * @var Prompt_Builder|null
@@ -62,19 +109,83 @@
62 109
63 110 /**
64 111 * Constructor
65 112 *
66 - * @param string $api_key OpenAI API key
67 - * @param string $model Default model to use
68 - * @param int $timeout Request timeout
113 + * @param string $api_key OpenAI API key (may be empty for a local server that wants none).
114 + * @param string $model Default model to use
115 + * @param int $timeout Request timeout
116 + * @param string $base_url API base URL without a trailing slash; defaults to OpenAI's.
69 117 */
70 - public function __construct(string $api_key, string $model = 'gpt-5-nano', int $timeout = 30) {
118 + public function __construct(string $api_key, string $model = \ThinkRank\Core\Settings::DEFAULT_OPENAI_MODEL, int $timeout = 30, string $base_url = self::API_BASE_URL) {
71 119 $this->api_key = $api_key;
72 120 $this->model = $model;
73 121 $this->timeout = $timeout;
122 + $base_url = rtrim(trim($base_url), '/');
123 + $this->base_url = '' !== $base_url ? $base_url : self::API_BASE_URL;
74 124 }
75 125
76 126 /**
127 + * Turn on response_format: json_object for calls that want a JSON object.
128 + *
129 + * ThinkRank otherwise enforces JSON through the prompt alone, and a small
130 + * local model then writes an unescaped quote inside a string (DeepSeek-R1
131 + * 7B quoting a phrase in the brief's HTML body) and the whole brief fails
132 + * to parse. Ollama, vLLM and llama.cpp turn this field into a grammar, so
133 + * the reply cannot be malformed JSON. Only calls passing the `json_object`
134 + * option get it: a plain-text caller would be forced into JSON too.
135 + *
136 + * @since 2.8.0
137 + *
138 + * @param bool $enabled Whether the endpoint accepts response_format json_object.
139 + * @return void
140 + */
141 + public function set_json_mode(bool $enabled): void {
142 + $this->json_mode = $enabled;
143 + }
144 +
145 + /**
146 + * Get the base URL this client talks to.
147 + *
148 + * @since 2.8.0
149 + *
150 + * @return string Base URL without a trailing slash.
151 + */
152 + public function get_base_url(): string {
153 + return $this->base_url;
154 + }
155 +
156 + /**
157 + * Is this client pointed at a server other than OpenAI's?
158 + *
159 + * Used for error copy: naming "OpenAI" in a failure from someone's local
160 + * Ollama box sends them to the wrong place to debug it.
161 + *
162 + * @since 2.8.0
163 + *
164 + * @return bool
165 + */
166 + private function is_custom_endpoint(): bool {
167 + return self::API_BASE_URL !== $this->base_url;
168 + }
169 +
170 + /**
171 + * Name to use for this endpoint in user-facing messages.
172 + *
173 + * @since 2.8.0
174 + *
175 + * @return string
176 + */
177 + private function get_endpoint_label(): string {
178 + if (!$this->is_custom_endpoint()) {
179 + return 'OpenAI';
180 + }
181 +
182 + $host = wp_parse_url($this->base_url, PHP_URL_HOST);
183 +
184 + return is_string($host) && '' !== $host ? $host : __('the AI endpoint', 'thinkrank');
185 + }
186 +
187 + /**
77 188 * Get Prompt Builder instance
78 189 *
79 190 * @since 1.0.0
80 191 *
@@ -109,9 +220,30 @@
109 220 'presence_penalty' => 0,
110 221 ];
111 222
112 223 $options = array_merge($default_options, $options);
113 -
224 +
225 + $body = $this->build_chat_completion_body($prompt, $options);
226 +
227 + return $this->make_request('chat/completions', $body);
228 + }
229 +
230 + /**
231 + * Build the chat/completions request body for the given (merged) options.
232 + *
233 + * Extracted so the per-model-family parameter handling is unit-testable:
234 + * reasoning models take max_completion_tokens (and only the GPT-5 family
235 + * accepts reasoning_effort — o1/o3 reject it), while standard models take
236 + * temperature/top_p/penalties/max_tokens. Keeping this in one place stops a
237 + * future refactor from silently regressing the GPT-5-only guard (issue #286).
238 + *
239 + * @param string $prompt User prompt.
240 + * @param array $options Merged options (must include model, max_tokens, and
241 + * the sampling defaults; reasoning_effort and
242 + * json_object optional).
243 + * @return array Request body for the chat/completions endpoint.
244 + */
245 + private function build_chat_completion_body(string $prompt, array $options): array {
114 246 $body = [
115 247 'model' => $options['model'],
116 248 'messages' => [
117 249 [
@@ -128,8 +260,17 @@
128 260 if ($this->is_reasoning_model($options['model'])) {
129 261 // Reasoning models (o1/o3) have fixed parameters and restricted support
130 262 // temperature, top_p, frequency_penalty, presence_penalty are not supported
131 263 $body['max_completion_tokens'] = $safe_tokens;
264 +
265 + // GPT-5 models accept reasoning_effort ('minimal'…'high'). Callers
266 + // wanting a quick answer pass a low level so hidden reasoning
267 + // can't consume the whole completion budget and return empty
268 + // text. Only the GPT-5
269 + // family gets it: o1 rejects the parameter outright.
270 + if (isset($options['reasoning_effort']) && str_starts_with($options['model'], 'gpt-5')) {
271 + $body['reasoning_effort'] = (string) $options['reasoning_effort'];
272 + }
132 273 } else {
133 274 // Standard models support all parameters
134 275 $body['temperature'] = $options['temperature'];
135 276 $body['top_p'] = $options['top_p'];
@@ -137,9 +278,13 @@
137 278 $body['presence_penalty'] = $options['presence_penalty'];
138 279 $body['max_tokens'] = $safe_tokens;
139 280 }
140 281
141 - return $this->make_request('chat/completions', $body);
282 + if ($this->json_mode && $this->is_custom_endpoint() && !empty($options['json_object'])) {
283 + $body['response_format'] = ['type' => 'json_object'];
284 + }
285 +
286 + return $body;
142 287 }
143 288
144 289 /**
145 290 * Generate SEO metadata
@@ -160,8 +305,9 @@
160 305
161 306 $response = $this->generate_completion($prompt, [
162 307 'max_tokens' => $this->get_recommended_tokens('seo_metadata'),
163 308 'temperature' => 0.3, // Lower temperature for more consistent SEO output
309 + 'json_object' => true,
164 310 ]);
165 311
166 312 return $this->parse_seo_response($response);
167 313 }
@@ -180,8 +326,9 @@
180 326
181 327 $response = $this->generate_completion($prompt, [
182 328 'max_tokens' => $this->get_recommended_tokens('analysis'),
183 329 'temperature' => 0.3, // Lower temperature for more consistent analysis
330 + 'json_object' => true,
184 331 ]);
185 332
186 333 return $this->parse_analysis_response($response);
187 334 }
@@ -247,8 +394,31 @@
247 394 return $limit;
248 395 }
249 396 }
250 397
398 + // A model we have never heard of on OpenAI's own API gets a
399 + // conservative ceiling. On a user-named server nearly every model is
400 + // unknown, and 4096 left a brief ~2.9K tokens after length scaling,
401 + // which a reasoning model (DeepSeek-R1, Qwen3) spends half of thinking
402 + // before the JSON starts. max_tokens is a cap, not a reservation, so a
403 + // higher one costs nothing on a model that finishes early.
404 + if ($this->is_custom_endpoint()) {
405 + /**
406 + * Filters the output-token ceiling for an unrecognised model on an
407 + * OpenAI-compatible endpoint.
408 + *
409 + * Lower it for a server that rejects max_tokens beyond its context
410 + * window (vLLM does); raise it for a large-context local model.
411 + *
412 + * @since 2.8.0
413 + *
414 + * @param int $limit Ceiling in tokens. Default 8192.
415 + * @param string $model Model id sent to the endpoint.
416 + */
417 + $limit = (int) apply_filters('thinkrank_openai_compatible_max_output_tokens', self::COMPATIBLE_DEFAULT_MAX_TOKENS, $model);
418 + return $limit > 0 ? $limit : self::COMPATIBLE_DEFAULT_MAX_TOKENS;
419 + }
420 +
251 421 // Default fallback for unknown models
252 422 return 4096;
253 423 }
254 424
@@ -381,21 +551,53 @@
381 551 * @return array Response data
382 552 * @throws \Exception If request fails
383 553 */
384 554 private function make_request(string $endpoint, array $body = []): array {
385 - $url = self::API_BASE_URL . '/' . ltrim($endpoint, '/');
386 -
555 + // The user's daily ceiling and kill switch are enforced here, at the
556 + // one place every outbound OpenAI call passes through, so no feature
557 + // path can bypass them by forgetting to ask first (#448).
558 + Spend_Guard::guard();
559 + Spend_Guard::record();
560 +
561 + // Not string concatenation: a base URL may carry a query string (Azure
562 + // requires ?api-version=…), and the route has to land before it (#721).
563 + $url = Endpoint_URL_Validator::route($this->base_url, $endpoint);
564 +
565 + $headers = [
566 + 'Content-Type' => 'application/json',
567 + 'User-Agent' => 'ThinkRank/' . THINKRANK_VERSION,
568 + ];
569 +
570 + // A local Ollama or LM Studio server wants no key at all; sending
571 + // "Bearer " with nothing after it makes some gateways 401.
572 + if ('' !== $this->api_key) {
573 + $headers['Authorization'] = 'Bearer ' . $this->api_key;
574 + // Azure OpenAI reads the key from its own header and ignores
575 + // Authorization. Sending both costs nothing and makes an Azure
576 + // deployment URL work without a separate provider.
577 + $headers['api-key'] = $this->api_key;
578 + }
579 +
387 580 $args = [
388 581 'timeout' => $this->timeout,
389 - 'headers' => [
390 - 'Authorization' => 'Bearer ' . $this->api_key,
391 - 'Content-Type' => 'application/json',
392 - 'User-Agent' => 'ThinkRank/' . THINKRANK_VERSION,
393 - ],
582 + 'headers' => $headers,
583 + // Only for an endpoint we do not control: a ceiling generous enough
584 + // for the largest thing we ask for (a content brief as JSON), so a
585 + // server that ignores its token limit still cannot spend the
586 + // worker's memory. Truncation is reported below, not parsed (#721).
587 + 'limit_response_size' => $this->is_custom_endpoint() ? self::MAX_RESPONSE_BYTES : null,
588 + // The key travels in these headers. A redirect to another host
589 + // would hand it to whoever controls that host, so never follow one
590 + // (#721) — a moved endpoint is the administrator's URL to fix.
591 + 'redirection' => 0,
394 592 ];
395 593
594 + if (null === $args['limit_response_size']) {
595 + unset($args['limit_response_size']);
596 + }
597 +
598 + $args['method'] = empty($body) ? 'GET' : 'POST';
396 599 if (!empty($body)) {
397 - $args['method'] = 'POST';
398 600 $args['body'] = wp_json_encode($body);
399 601 }
400 602
401 603 $response = $this->request_with_retry($url, $args);
@@ -406,25 +608,108 @@
406 608
407 609 $status_code = wp_remote_retrieve_response_code($response);
408 610 $response_body = wp_remote_retrieve_body($response);
409 611
612 + // JSON mode is the administrator's claim that this server takes
613 + // response_format. A server that does not (LM Studio wants json_schema)
614 + // answers 400 and names the field; drop it and ask once more, so a
615 + // wrong toggle costs a round trip rather than the feature. The rest of
616 + // this request's calls skip the field instead of failing first.
617 + if ($this->should_retry_without_response_format($body, (int) $status_code, (string) $response_body)) {
618 + $this->json_mode = false;
619 + unset($body['response_format']);
620 +
621 + if (defined('WP_DEBUG') && WP_DEBUG) {
622 + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- diagnostic, WP_DEBUG only.
623 + error_log('[ThinkRank] ' . $this->get_endpoint_label() . ' rejected response_format; retrying without JSON mode.');
624 + }
625 +
626 + $args['body'] = wp_json_encode($body);
627 + $response = $this->request_with_retry($url, $args);
628 +
629 + if (is_wp_error($response)) {
630 + throw new \Exception('API request failed: ' . esc_html($response->get_error_message()));
631 + }
632 +
633 + $status_code = wp_remote_retrieve_response_code($response);
634 + $response_body = wp_remote_retrieve_body($response);
635 + }
636 +
637 + // A body that reached the ceiling was cut mid-JSON. Say so, rather than
638 + // letting it fail as "invalid JSON" — the cause and the fix are
639 + // different things.
640 + if ($this->is_custom_endpoint() && strlen($response_body) >= self::MAX_RESPONSE_BYTES) {
641 + throw new \Exception(sprintf(
642 + /* translators: %s: endpoint name or host. */
643 + esc_html__('%s sent more than ThinkRank will read (2 MB). The endpoint is misconfigured or is not answering with a chat completion.', 'thinkrank'),
644 + esc_html($this->get_endpoint_label())
645 + ));
646 + }
647 +
410 648 if ($status_code >= 400) {
411 649 $error_data = json_decode($response_body, true);
412 - $error_message = $error_data['error']['message'] ?? 'Unknown API error';
413 - throw new \Exception(sprintf('OpenAI API error (%d): %s', (int) $status_code, esc_html($error_message)));
650 + $error_message = $error_data['error']['message'] ?? ($error_data['message'] ?? 'Unknown API error');
651 + throw new \Exception(sprintf(
652 + /* translators: 1: endpoint name or host, 2: HTTP status code, 3: error message from the server. */
653 + esc_html__('%1$s API error (%2$d): %3$s', 'thinkrank'),
654 + esc_html($this->get_endpoint_label()),
655 + (int) $status_code,
656 + esc_html((string) $error_message)
657 + ));
414 658 }
415 659
416 660 $data = json_decode($response_body, true);
417 661
418 662 if (json_last_error() !== JSON_ERROR_NONE) {
419 - throw new \Exception('Invalid JSON response from OpenAI API');
663 + throw new \Exception(sprintf(
664 + /* translators: %s: endpoint name or host. */
665 + esc_html__('Invalid JSON response from %s', 'thinkrank'),
666 + esc_html($this->get_endpoint_label())
667 + ));
420 668 }
421 669
670 + // A local reasoning model (deepseek-r1, a qwen3 thinking build) spends
671 + // its token budget on hidden reasoning before writing anything, and
672 + // Ollama and vLLM bill that against max_tokens — so the budget runs out
673 + // mid-thought and the reply comes back 200 with empty content and
674 + // finish_reason "length". Every generator then fails with "no valid
675 + // JSON", which blames the model for a budget problem. Ask once more
676 + // with reasoning turned off, which those servers accept as
677 + // reasoning_effort: none (#721).
678 + if (is_array($data) && $this->should_retry_without_reasoning($endpoint, $body, $data)) {
679 + $retry_body = $body;
680 + $retry_body['reasoning_effort'] = 'none';
681 +
682 + $retry_args = $args;
683 + $retry_args['body'] = wp_json_encode($retry_body);
684 + $retry = $this->request_with_retry($url, $retry_args);
685 +
686 + if (!is_wp_error($retry) && wp_remote_retrieve_response_code($retry) < 400) {
687 + $retry_data = json_decode(wp_remote_retrieve_body($retry), true);
688 + // Keep the first answer when the retry is no better — a server
689 + // that ignores the parameter answers exactly the same way, and
690 + // the error below is then the honest one.
691 + if (is_array($retry_data) && '' !== trim((string) ($retry_data['choices'][0]['message']['content'] ?? ''))) {
692 + return $retry_data;
693 + }
694 + }
695 +
696 + throw new \Exception(sprintf(
697 + /* translators: %s: endpoint name or host. */
698 + esc_html__('%s answered with no text: the model spent its whole token budget on hidden reasoning. Use a non-reasoning model, or raise the token budget for this endpoint.', 'thinkrank'),
699 + esc_html($this->get_endpoint_label())
700 + ));
701 + }
702 +
422 703 // A valid-but-scalar body (null/number/string from a proxy/gateway on a
423 704 // 2xx) would violate this method's : array return type; reject it here so
424 705 // it surfaces as a catchable \Exception, not an uncatchable TypeError.
425 706 if (!is_array($data)) {
426 - throw new \Exception('Unexpected non-array response from OpenAI API');
707 + throw new \Exception(sprintf(
708 + /* translators: %s: endpoint name or host. */
709 + esc_html__('Unexpected non-array response from %s', 'thinkrank'),
710 + esc_html($this->get_endpoint_label())
711 + ));
427 712 }
428 713
429 714 return $data;
430 715 }
@@ -429,8 +714,76 @@
429 714 return $data;
430 715 }
431 716
432 717 /**
718 + * Did the server refuse the request because of response_format?
719 + *
720 + * Only when we sent the field, the server answered 400 or 422 (vLLM and
721 + * FastAPI-based servers use 422 for a schema error), and the error text
722 + * names the field or a JSON-mode type. Any other 400 (wrong model id, a
723 + * prompt over the context window) is a real error, and resending without
724 + * the field would only repeat it.
725 + *
726 + * @since 2.8.0
727 + *
728 + * @param array $body Request body that was sent.
729 + * @param int $status_code HTTP status of the answer.
730 + * @param string $response_body Raw answer body.
731 + * @return bool
732 + */
733 + private function should_retry_without_response_format(array $body, int $status_code, string $response_body): bool {
734 + if (!isset($body['response_format']) || !in_array($status_code, [400, 422], true)) {
735 + return false;
736 + }
737 +
738 + $error = strtolower($response_body);
739 + foreach (['response_format', 'json_object', 'json_schema'] as $needle) {
740 + if (false !== strpos($error, $needle)) {
741 + return true;
742 + }
743 + }
744 +
745 + return false;
746 + }
747 +
748 + /**
749 + * Did a chat completion come back empty because the model was still thinking?
750 + *
751 + * Three things have to be true: this is a chat completion against a custom
752 + * endpoint (OpenAI's own reasoning models manage their own budget through
753 + * max_completion_tokens), the content is empty, and the server stopped on
754 + * "length" or reported reasoning it never got to use.
755 + *
756 + * @since 2.8.0
757 + *
758 + * @param string $endpoint Endpoint path that was called.
759 + * @param array $body Request body that was sent.
760 + * @param array $data Decoded response.
761 + * @return bool
762 + */
763 + private function should_retry_without_reasoning(string $endpoint, array $body, array $data): bool {
764 + if (!$this->is_custom_endpoint() || 'chat/completions' !== ltrim($endpoint, '/')) {
765 + return false;
766 + }
767 +
768 + // Already asked without reasoning — a second identical attempt would
769 + // only cost the user another slow generation.
770 + if (isset($body['reasoning_effort'])) {
771 + return false;
772 + }
773 +
774 + $message = is_array($data['choices'][0]['message'] ?? null) ? $data['choices'][0]['message'] : null;
775 + if (null === $message || '' !== trim((string) ($message['content'] ?? ''))) {
776 + return false;
777 + }
778 +
779 + $finish = (string) ($data['choices'][0]['finish_reason'] ?? '');
780 + $reasoning = trim((string) ($message['reasoning'] ?? ($message['reasoning_content'] ?? '')));
781 +
782 + return 'length' === $finish || '' !== $reasoning;
783 + }
784 +
785 + /**
433 786 * Perform an HTTP request, retrying transient failures (429 / 5xx / network)
434 787 * per the plugin's retry settings, honoring a Retry-After header when given.
435 788 *
436 789 * @param string $url Request URL
@@ -446,14 +799,24 @@
446 799 for ($attempt = 1; $attempt <= $max_attempts; $attempt++) {
447 800 // Keep PHP alive for the whole blocking call (see method docblock).
448 801 $this->raise_request_time_limit();
449 802
450 - $response = wp_remote_request($url, $args);
803 + // A custom endpoint is a host the site owner named, not one of
804 + // ours: check where it actually resolves, pin the connection there
805 + // and cap the body before any of it is buffered (#721).
806 + $response = $this->is_custom_endpoint()
807 + ? Endpoint_URL_Validator::guarded_request($url, $args)
808 + : wp_remote_request($url, $args);
451 809
452 810 $is_transient = false;
453 811 $retry_after = 0;
454 812 if (is_wp_error($response)) {
455 - $is_transient = true;
813 + // A client-side timeout means the work genuinely needs longer
814 + // than the budget we allowed; re-running the identical prompt,
815 + // model and budget just times out again and multiplies the
816 + // wait (issue #288). Do not retry a timeout. Other WP_Error
817 + // results — DNS, connection refused, TLS — stay retryable.
818 + $is_transient = !$this->is_timeout_error($response);
456 819 } else {
457 820 $status = wp_remote_retrieve_response_code($response);
458 821 if (429 === $status || $status >= 500) {
459 822 $is_transient = true;
@@ -790,9 +1153,9 @@
790 1153 return [
791 1154 'optimized_data' => [
792 1155 'site_name' => sanitize_text_field($optimization['optimized_data']['site_name'] ?? ''),
793 1156 'project_overview' => sanitize_textarea_field($optimization['optimized_data']['project_overview'] ?? ''),
794 - 'key_features' => sanitize_textarea_field($optimization['optimized_data']['key_features'] ?? ''),
1157 + 'key_features' => \ThinkRank\SEO\LLMs_Txt_Manager::normalize_ai_key_features($optimization['optimized_data']['key_features'] ?? ''),
795 1158 'architecture' => sanitize_textarea_field($optimization['optimized_data']['architecture'] ?? ''),
796 1159 'development_guidelines' => sanitize_textarea_field($optimization['optimized_data']['development_guidelines'] ?? ''),
797 1160 'ai_context' => sanitize_textarea_field($optimization['optimized_data']['ai_context'] ?? ''),
798 1161 ],