PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.9.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.9.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 1.0.2 All 50 releases
← All changes | includes/ai/class-content-brief-generator.php +193 -18 1.32.0 → 2.9.0 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 /**
@@ -657,9 +701,9 @@
657 701 'title' => $json_data['title_suggestions'] ?? [],
658 702 'meta_description' => $json_data['meta_descriptions'][0] ?? '',
659 703 'meta_descriptions' => $json_data['meta_descriptions'] ?? [],
660 704 'url_slugs' => $json_data['url_slugs'] ?? [],
661 - 'outline' => $json_data['outline'] ?? [],
705 + 'outline' => self::strip_outline_level_labels($json_data['outline'] ?? []),
662 706 'seo_recommendations' => [
663 707 'title_suggestions' => $json_data['title_suggestions'] ?? [],
664 708 'meta_description' => $json_data['meta_descriptions'][0] ?? '',
665 709 'meta_descriptions' => $json_data['meta_descriptions'] ?? [],
@@ -685,9 +729,9 @@
685 729 ],
686 730 'competitor_gaps' => $json_data['competitor_analysis']['content_gaps'] ?? [],
687 731 'call_to_actions' => $json_data['call_to_actions'] ?? [],
688 732 'writing_guidelines' => $json_data['writing_guidelines'] ?? [],
689 - 'content_body' => $json_data['content_body'] ?? '',
733 + 'content_body' => self::strip_heading_level_labels((string) ($json_data['content_body'] ?? '')),
690 734 'estimated_word_count' => $this->get_word_count_estimate($original_params['content_length'] ?? 'medium'),
691 735 'raw_response' => '', // Will be retrieved from ai_usage table
692 736 'generation_params' => $original_params,
693 737 'parsing_status' => 'success',
@@ -695,8 +739,118 @@
695 739 ];
696 740 }
697 741
698 742 /**
743 + * Remove a leading level label from a heading string.
744 + *
745 + * The prompt's own JSON example labelled outline headings with their level
746 + * (`"heading": "H1: Main Title"` next to a separate `"level": 1`), so the
747 + * model often carried the convention into the drafted article and Pro's
748 + * "Insert into post" wrote `<h2>H2: Real Heading</h2>` into published
749 + * content. The prompt no longer does that, but a prompt change never fully
750 + * binds a model — so the label is stripped here too (#410).
751 + *
752 + * Covers the label forms a model actually emits: `H2:`, `h3:`, `H2 -`,
753 + * `H4.`, `H2)` and the en/em dash variants, optionally wrapped in markdown
754 + * emphasis (`**H2:**`). The delimiter is anchored directly after the digit
755 + * so `H10:` — a plausible heading in a numbered list — is left alone, and
756 + * only a leading label is matched so body copy that mentions a level
757 + * survives. Trailing emphasis is consumed only when the same marker opened
758 + * the label, so `H2: *emphasised start*` keeps its asterisks.
759 + *
760 + * @since 2.0.1
761 + *
762 + * @param string $heading Heading text.
763 + * @return string Heading without its level prefix.
764 + */
765 + public static function strip_level_label(string $heading): string {
766 + // En dash and em dash as raw UTF-8 bytes, so the pattern needs no /u
767 + // modifier and cannot blank a heading that is not valid UTF-8.
768 + $delimiter = '(?:[:.)\-]|\xe2\x80\x93|\xe2\x80\x94)';
769 + $emphasis = '(\*{1,3}|_{1,3})';
770 +
771 + $pattern = '/^\s*(?:'
772 + . $emphasis . '\s*[Hh][1-6]\s*' . $delimiter . '\s*\1'
773 + . '|[Hh][1-6]\s*' . $delimiter
774 + . ')\s*/';
775 +
776 + return (string) preg_replace($pattern, '', $heading);
777 + }
778 +
779 + /**
780 + * Strip a level label from a heading's inner HTML.
781 + *
782 + * A model drafting publish-ready HTML often wraps the heading text in an
783 + * inline tag (`<h2><strong>H2: Real Heading</strong></h2>`). That pushes a
784 + * `<` in front of the label, so the leading run of inline opening tags is
785 + * set aside and re-attached around the cleaned text.
786 + *
787 + * @since 2.0.1
788 + *
789 + * @param string $inner Heading inner HTML.
790 + * @return string Inner HTML without the level prefix.
791 + */
792 + private static function strip_inner_level_label(string $inner): string {
793 + $prefix = '';
794 +
795 + if (preg_match('/^(\s*(?:<(?:strong|em|b|i|span|mark|code|u)\b[^>]*>\s*)+)(.*)$/is', $inner, $parts)) {
796 + $prefix = $parts[1];
797 + $inner = $parts[2];
798 + }
799 +
800 + return $prefix . self::strip_level_label($inner);
801 + }
802 +
803 + /**
804 + * Strip level labels from every heading in an outline.
805 + *
806 + * @since 2.0.1
807 + *
808 + * @param mixed $outline Outline as returned by the model.
809 + * @return array Outline with clean headings.
810 + */
811 + public static function strip_outline_level_labels($outline): array {
812 + if (!is_array($outline)) {
813 + return [];
814 + }
815 +
816 + foreach ($outline as $index => $section) {
817 + if (is_array($section) && isset($section['heading']) && is_string($section['heading'])) {
818 + $outline[$index]['heading'] = self::strip_level_label($section['heading']);
819 + } elseif (is_string($section)) {
820 + $outline[$index] = self::strip_level_label($section);
821 + }
822 + }
823 +
824 + return $outline;
825 + }
826 +
827 + /**
828 + * Strip level labels from the heading text inside drafted HTML.
829 + *
830 + * This is the path that reaches published post content, so it is the one
831 + * that matters most. Only the text directly inside an <h1>-<h6> is touched.
832 + *
833 + * @since 2.0.1
834 + *
835 + * @param string $html Drafted article body.
836 + * @return string Body with clean headings.
837 + */
838 + public static function strip_heading_level_labels(string $html): string {
839 + if ('' === $html || false === stripos($html, '<h')) {
840 + return $html;
841 + }
842 +
843 + return (string) preg_replace_callback(
844 + '/(<h([1-6])\b[^>]*>)(.*?)(<\/h\2>)/is',
845 + static function (array $parts): string {
846 + return $parts[1] . self::strip_inner_level_label($parts[3]) . $parts[4];
847 + },
848 + $html
849 + );
850 + }
851 +
852 + /**
699 853 * Create error response when JSON parsing fails
700 854 *
701 855 * @param string $ai_response Raw AI response
702 856 * @param array $original_params Original generation parameters
@@ -1159,8 +1313,20 @@
1159 1313 if (false === $result) {
1160 1314 throw new \Exception('Failed to save content brief to database.');
1161 1315 }
1162 1316
1317 + /**
1318 + * Fires after a content brief is persisted.
1319 + *
1320 + * Analytics listens to drop its cached overview so the brief counts
1321 + * on the Usages page are not stale for a TTL.
1322 + *
1323 + * @since 2.2.1
1324 + *
1325 + * @param int $brief_id Row id of the stored brief.
1326 + */
1327 + do_action('thinkrank_content_brief_created', (int) $wpdb->insert_id);
1328 +
1163 1329 return $wpdb->insert_id;
1164 1330 }
1165 1331
1166 1332 /**
@@ -1882,9 +2048,9 @@
1882 2048 [
1883 2049 'user_id' => $user_id,
1884 2050 'action' => $action,
1885 2051 'tokens_used' => $tokens_used,
1886 - 'provider' => $this->settings->get('ai_provider', 'openai'),
2052 + 'provider' => $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE),
1887 2053 'post_id' => $post_id,
1888 2054 'metadata' => !empty($metadata) ? wp_json_encode($metadata) : null,
1889 2055 'created_at' => current_time('mysql'),
1890 2056 ],
@@ -1889,8 +2055,17 @@
1889 2055 'created_at' => current_time('mysql'),
1890 2056 ],
1891 2057 ['%d', '%s', '%d', '%s', '%d', '%s', '%s']
1892 2058 );
2059 +
2060 + /**
2061 + * Fires after an AI usage row is recorded.
2062 + *
2063 + * @since 2.2.1
2064 + *
2065 + * @param int $user_id User the usage was recorded against.
2066 + */
2067 + do_action('thinkrank_ai_usage_logged', $user_id);
1893 2068
1894 2069 return $wpdb->insert_id;
1895 2070 }
1896 2071