PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.14.2
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.14.2
2.14.2 2.14.1 2.14.0 2.13.0 2.12.0 2.11.0 2.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 All 57 releases
← All changes | includes/ai/class-content-brief-generator.php +110 -20 2.0.2 → 2.14.2 View file →
@@ -127,9 +127,9 @@
127 127 *
128 128 * @throws \Exception On failure.
129 129 */
130 130 private function init_ai_client(): void {
131 - $provider = $this->settings->get('ai_provider', 'openai');
131 + $provider = $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
132 132
133 133 if ($provider === 'openai') {
134 134 $api_key = $this->settings->get('openai_api_key');
135 135 if ($api_key) {
@@ -153,11 +153,31 @@
153 153 if ($api_key) {
154 154 $model = $this->settings->get('openrouter_model', Settings::DEFAULT_OPENROUTER_MODEL);
155 155 $this->ai_client = new OpenRouter_Client($api_key, $model, self::AI_REQUEST_TIMEOUT);
156 156 }
157 + } elseif ($provider === 'openai_compatible') {
158 + // Same client as OpenAI, different host — and the key is optional,
159 + // so the URL and model id are what gate it (#721). The user's own
160 + // timeout applies: a local model writing a brief on CPU is slow,
161 + // and the setting exists for exactly that.
162 + $base_url = (string) $this->settings->get('openai_compatible_base_url', '');
163 + $model = trim((string) $this->settings->get('openai_compatible_model', ''));
164 + if ('' !== $base_url && '' !== $model) {
165 + $this->ai_client = new OpenAI_Client(
166 + (string) $this->settings->get('openai_compatible_api_key', ''),
167 + $model,
168 + (int) $this->settings->get('openai_compatible_timeout', Settings::DEFAULT_OPENAI_COMPATIBLE_TIMEOUT),
169 + $base_url
170 + );
171 + $this->ai_client->set_json_mode((bool) $this->settings->get('openai_compatible_json_mode', false));
172 + }
157 173 }
158 174
159 175 if (!$this->ai_client) {
176 + if ('openai_compatible' === $provider) {
177 + throw new \Exception('Please set the base URL and model id for your OpenAI-compatible endpoint in ThinkRank settings.');
178 + }
179 +
160 180 throw new \Exception('Please configure your AI provider API key in ThinkRank settings.');
161 181 }
162 182 }
163 183
@@ -172,9 +192,15 @@
172 192 return $this->ai_client->get_model();
173 193 }
174 194
175 195 // Fallback to settings
176 - $provider = $this->settings->get('ai_provider', 'openai');
196 + $provider = $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
197 + if (Settings::AI_PROVIDER_NONE === $provider) {
198 + // No provider chosen, so there is no model to name. Reporting the
199 + // OpenAI default here would attribute output to a provider the site
200 + // never selected (#572).
201 + return '';
202 + }
177 203 if ($provider === 'claude') {
178 204 return $this->settings->get('claude_model', Settings::DEFAULT_CLAUDE_MODEL);
179 205 } elseif ($provider === 'gemini') {
180 206 return $this->settings->get('gemini_model', Settings::DEFAULT_GEMINI_MODEL);
@@ -179,8 +205,10 @@
179 205 } elseif ($provider === 'gemini') {
180 206 return $this->settings->get('gemini_model', Settings::DEFAULT_GEMINI_MODEL);
181 207 } elseif ($provider === 'openrouter') {
182 208 return $this->settings->get('openrouter_model', Settings::DEFAULT_OPENROUTER_MODEL);
209 + } elseif ($provider === 'openai_compatible') {
210 + return (string) $this->settings->get('openai_compatible_model', '');
183 211 } else {
184 212 return $this->settings->get('openai_model', Settings::DEFAULT_OPENAI_MODEL);
185 213 }
186 214 }
@@ -193,9 +221,9 @@
193 221 * costliest configuration, where billed reasoning tokens (drawn from the
194 222 * same budget) are spent before any visible output (issue #286).
195 223 *
196 224 * A brief is a structured planning task, so 'low' is a provisional middle
197 - * ground — Brand Visibility uses 'minimal' for quick consumer-style answers.
225 + * ground between 'minimal' and the model's default.
198 226 * The level is filterable so a site can trade latency for more reasoning;
199 227 * returning '' opts out entirely and lets the model use its default effort.
200 228 * Only the GPT-5 family consumes this — o1/o3, gpt-4o and the non-OpenAI
201 229 * clients ignore an unrecognised option key.
@@ -227,9 +255,9 @@
227 255 *
228 256 * @return string Current provider name
229 257 */
230 258 private function get_current_provider(): string {
231 - return $this->settings->get('ai_provider', 'openai');
259 + return $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
232 260 }
233 261
234 262 /**
235 263 * Extract token usage from AI response
@@ -239,9 +267,9 @@
239 267 */
240 268 private function extract_token_usage(array $ai_response): int {
241 269 $provider = $this->get_current_provider();
242 270
243 - if ($provider === 'openai' || $provider === 'openrouter') {
271 + if ($provider === 'openai' || $provider === 'openrouter' || $provider === 'openai_compatible') {
244 272 // OpenAI-compatible format: response['usage']['total_tokens']
245 273 return (int) ($ai_response['usage']['total_tokens'] ?? 0);
246 274 } elseif ($provider === 'claude') {
247 275 // Claude format: response['usage']['input_tokens'] + response['usage']['output_tokens']
@@ -341,8 +369,11 @@
341 369 // max_completion_tokens internally. Temperature is intentionally
342 370 // omitted: every client defaults it to 0.7, and reasoning models
343 371 // reject it outright, so passing it here was misleading no-op.
344 372 'max_tokens' => $max_tokens,
373 + // The brief is one JSON object. Only a compatible endpoint
374 + // with JSON mode on reads this; every other client ignores it.
375 + 'json_object' => true,
345 376 ];
346 377 if ('' !== $reasoning_effort) {
347 378 $completion_options['reasoning_effort'] = $reasoning_effort;
348 379 }
@@ -354,9 +385,9 @@
354 385 // truncation) BEFORE attempting text extraction. Otherwise a
355 386 // refusal — which OpenAI returns as HTTP 200 with content=null —
356 387 // slips past every isset() branch and gets serialized into the
357 388 // brief body instead of being reported to the user.
358 - $this->guard_against_non_answer($ai_response);
389 + $this->guard_against_non_answer($ai_response, $max_tokens);
359 390
360 391 // Extract text content from AI response
361 392 $ai_text = '';
362 393
@@ -504,12 +535,13 @@
504 535 * don't catch them here they fall through to the "unexpected format" path
505 536 * (or, historically, were serialized into the brief body). All messages
506 537 * start with "The AI " so the outer catch passes them through unchanged.
507 538 *
508 - * @param mixed $ai_response Raw response from the AI client.
539 + * @param mixed $ai_response Raw response from the AI client.
540 + * @param int $requested_tokens The max_tokens this request asked for; 0 when unknown.
509 541 * @throws \Exception If the response is a refusal, policy block, or truncation.
510 542 */
511 - private function guard_against_non_answer($ai_response): void {
543 + private function guard_against_non_answer($ai_response, int $requested_tokens = 0): void {
512 544 if (!is_array($ai_response)) {
513 545 return;
514 546 }
515 547
@@ -521,18 +553,30 @@
521 553 $message = $ai_response['choices'][0]['message'];
522 554 $finish = (string) ($ai_response['choices'][0]['finish_reason'] ?? '');
523 555
524 556 if (!empty($message['refusal'])) {
525 - throw new \Exception(sprintf(
557 + throw new \Exception(esc_html(sprintf(
526 558 'The AI declined to generate this brief: %s',
527 559 (string) $message['refusal']
528 - ));
560 + )));
529 561 }
530 562 if ('content_filter' === $finish) {
531 563 throw new \Exception('The AI blocked this request under its content policy. Try a different topic or less sensitive keywords.');
532 564 }
565 + // A self-hosted server can stop short of max_tokens because the
566 + // prompt and the answer together filled its context window
567 + // (Ollama loads models at 4096 by default). A bigger output budget
568 + // cannot fix that, so say what can.
569 + $completion_tokens = (int) ($ai_response['usage']['completion_tokens'] ?? 0);
570 + if ('length' === $finish && $requested_tokens > 0 && $completion_tokens > 0 && $completion_tokens < $requested_tokens) {
571 + throw new \Exception(esc_html(sprintf(
572 + 'The AI stopped after %1$d tokens, short of the %2$d allowed, because the server ran out of context window before finishing the brief. Raise the context length on your AI server (for Ollama, set OLLAMA_CONTEXT_LENGTH to 16384 or more) and try again.',
573 + $completion_tokens,
574 + $requested_tokens
575 + )));
576 + }
533 577 if ('length' === $finish) {
534 - throw new \Exception('The AI stopped at its output token limit before finishing the brief. Try a shorter content length or fewer competitor URLs.');
578 + throw new \Exception('The AI stopped at its output token limit before finishing the brief. Try fewer competitor URLs, or a model with a larger output limit. A shorter content length will not help: it asks for a smaller budget, not a smaller answer.');
535 579 }
536 580 }
537 581
538 582 // --- Claude (Messages) ---
@@ -541,9 +585,9 @@
541 585 if ('refusal' === $stop_reason) {
542 586 throw new \Exception('The AI declined to generate this brief for this topic. Try a different topic or less sensitive keywords.');
543 587 }
544 588 if ('max_tokens' === $stop_reason) {
545 - throw new \Exception('The AI stopped at its output token limit before finishing the brief. Try a shorter content length or fewer competitor URLs.');
589 + throw new \Exception('The AI stopped at its output token limit before finishing the brief. Try fewer competitor URLs, or a model with a larger output limit. A shorter content length will not help: it asks for a smaller budget, not a smaller answer.');
546 590 }
547 591 }
548 592
549 593 // --- Gemini ---
@@ -551,12 +595,12 @@
551 595 // promptFeedback.blockReason; a candidate can also finish on SAFETY or
552 596 // PROHIBITED_CONTENT, or be truncated at MAX_TOKENS.
553 597 $block_reason = (string) ($ai_response['promptFeedback']['blockReason'] ?? '');
554 598 if ('' !== $block_reason) {
555 - throw new \Exception(sprintf(
599 + throw new \Exception(esc_html(sprintf(
556 600 'The AI blocked this request under its content policy (%s). Try a different topic or less sensitive keywords.',
557 601 $block_reason
558 - ));
602 + )));
559 603 }
560 604 $gemini_finish = (string) ($ai_response['candidates'][0]['finishReason'] ?? '');
561 605 if (in_array($gemini_finish, ['SAFETY', 'PROHIBITED_CONTENT'], true)) {
562 606 throw new \Exception('The AI blocked this request under its content policy. Try a different topic or less sensitive keywords.');
@@ -561,9 +605,9 @@
561 605 if (in_array($gemini_finish, ['SAFETY', 'PROHIBITED_CONTENT'], true)) {
562 606 throw new \Exception('The AI blocked this request under its content policy. Try a different topic or less sensitive keywords.');
563 607 }
564 608 if ('MAX_TOKENS' === $gemini_finish) {
565 - throw new \Exception('The AI stopped at its output token limit before finishing the brief. Try a shorter content length or fewer competitor URLs.');
609 + throw new \Exception('The AI stopped at its output token limit before finishing the brief. Try fewer competitor URLs, or a model with a larger output limit. A shorter content length will not help: it asks for a smaller budget, not a smaller answer.');
566 610 }
567 611 }
568 612
569 613 /**
@@ -915,8 +959,32 @@
915 959 }
916 960
917 961 return $normalized;
918 962 }
963 + /**
964 + * The URL to check and fetch for a competitor URL the user entered, or
965 + * null when it is not a valid URL.
966 + *
967 + * A competitor page on an internationalised domain, or with a Bengali or
968 + * Arabic slug, is a valid URL, so the syntax check is Url_Validator's.
969 + * What comes back is the ASCII form (punycode host, percent-encoded path),
970 + * and both the SSRF guard and the fetch use it: the guard cannot resolve
971 + * a Unicode host name, and it must check exactly the URL that is then
972 + * requested.
973 + *
974 + * @since 2.14.2
975 + *
976 + * @param string $url Trimmed URL as entered.
977 + * @return string|null ASCII URL, or null when invalid.
978 + */
979 + private function competitor_fetch_url(string $url): ?string {
980 + if ('' === $url || !\ThinkRank\Core\Url_Validator::is_valid($url)) {
981 + return null;
982 + }
983 +
984 + return \ThinkRank\Core\Url_Validator::to_ascii($url);
985 + }
986 +
919 987 private function analyze_competitor_urls(array $urls): string {
920 988 $analysis_results = [];
921 989 $failed_urls = [];
922 990
@@ -924,9 +992,10 @@
924 992 $urls = array_slice($urls, 0, 3);
925 993
926 994 foreach ($urls as $url) {
927 995 $url = trim($url);
928 - if (empty($url) || !filter_var($url, FILTER_VALIDATE_URL)) {
996 + $fetch_url = $this->competitor_fetch_url($url);
997 + if (null === $fetch_url) {
929 998 $failed_urls[] = $url . " (invalid URL)";
930 999 continue;
931 1000 }
932 1001
@@ -932,14 +1001,14 @@
932 1001
933 1002 // SSRF guard: only fetch public http/https hosts. Blocks loopback,
934 1003 // link-local (cloud metadata), private and reserved ranges before any
935 1004 // request is made.
936 - if (!$this->is_safe_public_url($url)) {
1005 + if (!$this->is_safe_public_url($fetch_url)) {
937 1006 $failed_urls[] = $url . " (blocked: non-public host)";
938 1007 continue;
939 1008 }
940 1009
941 - $content_data = $this->scrape_competitor_content($url);
1010 + $content_data = $this->scrape_competitor_content($fetch_url);
942 1011 if ($content_data) {
943 1012 $analysis_results[] = $this->format_competitor_analysis($url, $content_data);
944 1013 } else {
945 1014 $failed_urls[] = $url . " (failed to scrape)";
@@ -1185,9 +1254,9 @@
1185 1254 // Add heading structure
1186 1255 if (!empty($content_data['headings'])) {
1187 1256 $analysis .= "\nCONTENT STRUCTURE:\n";
1188 1257 foreach ($content_data['headings'] as $level => $headings) {
1189 - $analysis .= "- " . strtoupper($level) . " ({count}): " . implode(', ', array_slice($headings, 0, 3));
1258 + $analysis .= "- " . strtoupper($level) . " (" . count($headings) . "): " . implode(', ', array_slice($headings, 0, 3));
1190 1259 if (count($headings) > 3) {
1191 1260 $analysis .= "... (+" . (count($headings) - 3) . " more)";
1192 1261 }
1193 1262 $analysis .= "\n";
@@ -1269,8 +1338,20 @@
1269 1338 if (false === $result) {
1270 1339 throw new \Exception('Failed to save content brief to database.');
1271 1340 }
1272 1341
1342 + /**
1343 + * Fires after a content brief is persisted.
1344 + *
1345 + * Analytics listens to drop its cached overview so the brief counts
1346 + * on the Usages page are not stale for a TTL.
1347 + *
1348 + * @since 2.2.1
1349 + *
1350 + * @param int $brief_id Row id of the stored brief.
1351 + */
1352 + do_action('thinkrank_content_brief_created', (int) $wpdb->insert_id);
1353 +
1273 1354 return $wpdb->insert_id;
1274 1355 }
1275 1356
1276 1357 /**
@@ -1992,9 +2073,9 @@
1992 2073 [
1993 2074 'user_id' => $user_id,
1994 2075 'action' => $action,
1995 2076 'tokens_used' => $tokens_used,
1996 - 'provider' => $this->settings->get('ai_provider', 'openai'),
2077 + 'provider' => $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE),
1997 2078 'post_id' => $post_id,
1998 2079 'metadata' => !empty($metadata) ? wp_json_encode($metadata) : null,
1999 2080 'created_at' => current_time('mysql'),
2000 2081 ],
@@ -1999,8 +2080,17 @@
1999 2080 'created_at' => current_time('mysql'),
2000 2081 ],
2001 2082 ['%d', '%s', '%d', '%s', '%d', '%s', '%s']
2002 2083 );
2084 +
2085 + /**
2086 + * Fires after an AI usage row is recorded.
2087 + *
2088 + * @since 2.2.1
2089 + *
2090 + * @param int $user_id User the usage was recorded against.
2091 + */
2092 + do_action('thinkrank_ai_usage_logged', $user_id);
2003 2093
2004 2094 return $wpdb->insert_id;
2005 2095 }
2006 2096