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 +506 -105 1.28.0 → 2.9.0 View file →
@@ -48,8 +48,34 @@
48 48 */
49 49 private const OPENAI_REQUEST_TIMEOUT = 300;
50 50
51 51 /**
52 + * Minimum output-token budget for a content brief.
53 + *
54 + * A shorter length tier must never starve the structured JSON — plus any
55 + * reasoning/thinking tokens, which are drawn from the same budget — to the
56 + * point of truncating mid-response (the failure #165 fixed on Gemini). This
57 + * floor is only a safety net for small-ceiling models; it never exceeds the
58 + * model-aware base budget. See scale_tokens_for_length().
59 + */
60 + private const MIN_BRIEF_TOKENS = 2000;
61 +
62 + /**
63 + * Content-length → budget multipliers, applied to the model-aware base.
64 + *
65 + * NOTE: provisional starting points (issue #287). They make Short/Medium/
66 + * Long request measurably different budgets, but the exact figures should
67 + * be validated against recorded completion-token usage for a real brief on
68 + * each provider before being treated as final. Unknown lengths fall back to
69 + * the 'medium' tier (see scale_tokens_for_length()).
70 + */
71 + private const LENGTH_TOKEN_MULTIPLIERS = [
72 + 'short' => 0.6,
73 + 'medium' => 0.8,
74 + 'long' => 1.0,
75 + ];
76 +
77 + /**
52 78 * Settings instance
53 79 *
54 80 * @var Settings
55 81 */
@@ -57,9 +83,11 @@
57 83
58 84 /**
59 85 * AI client instance
60 86 *
61 - * @var OpenAI_Client|Claude_Client
87 + * Null when the generator was built for storage-only work.
88 + *
89 + * @var OpenAI_Client|Claude_Client|null
62 90 */
63 91 private $ai_client;
64 92
65 93 /**
@@ -66,18 +94,31 @@
66 94 * Constructor
67 95 *
68 96 * @param Settings|null $settings Settings instance
69 97 * @param OpenAI_Client|Claude_Client|null $ai_client AI client instance
98 + * @param bool $require_ai_client Whether a provider client is required. Pass
99 + * false for storage-only use (list/export/
100 + * delete), which never calls a provider.
70 101 */
71 - public function __construct(?Settings $settings = null, $ai_client = null) {
102 + public function __construct(?Settings $settings = null, $ai_client = null, bool $require_ai_client = true) {
72 103 $this->settings = $settings ?? Settings::instance();
73 104
74 105 if ($ai_client) {
75 106 $this->ai_client = $ai_client;
76 - } else {
77 - // Fallback to creating own client for backward compatibility
78 - $this->init_ai_client();
107 +
108 + return;
79 109 }
110 +
111 + // Read-only callers (listing, exporting and deleting saved briefs) only
112 + // touch the database and never reach a provider. Constructing a client
113 + // for them turns "no API key configured" — the default state of a fresh
114 + // install — into a hard failure, so let them opt out.
115 + if (!$require_ai_client) {
116 + return;
117 + }
118 +
119 + // Fallback to creating own client for backward compatibility
120 + $this->init_ai_client();
80 121 }
81 122
82 123 /**
83 124 * Initialize AI client based on available API keys
@@ -82,11 +123,13 @@
82 123 /**
83 124 * Initialize AI client based on available API keys
84 125 *
85 126 * @return void
127 + *
128 + * @throws \Exception On failure.
86 129 */
87 130 private function init_ai_client(): void {
88 - $provider = $this->settings->get('ai_provider', 'openai');
131 + $provider = $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
89 132
90 133 if ($provider === 'openai') {
91 134 $api_key = $this->settings->get('openai_api_key');
92 135 if ($api_key) {
@@ -110,11 +153,31 @@
110 153 if ($api_key) {
111 154 $model = $this->settings->get('openrouter_model', Settings::DEFAULT_OPENROUTER_MODEL);
112 155 $this->ai_client = new OpenRouter_Client($api_key, $model, self::AI_REQUEST_TIMEOUT);
113 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 + }
114 173 }
115 174
116 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 +
117 180 throw new \Exception('Please configure your AI provider API key in ThinkRank settings.');
118 181 }
119 182 }
120 183
@@ -129,9 +192,15 @@
129 192 return $this->ai_client->get_model();
130 193 }
131 194
132 195 // Fallback to settings
133 - $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 + }
134 203 if ($provider === 'claude') {
135 204 return $this->settings->get('claude_model', Settings::DEFAULT_CLAUDE_MODEL);
136 205 } elseif ($provider === 'gemini') {
137 206 return $this->settings->get('gemini_model', Settings::DEFAULT_GEMINI_MODEL);
@@ -136,8 +205,10 @@
136 205 } elseif ($provider === 'gemini') {
137 206 return $this->settings->get('gemini_model', Settings::DEFAULT_GEMINI_MODEL);
138 207 } elseif ($provider === 'openrouter') {
139 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', '');
140 211 } else {
141 212 return $this->settings->get('openai_model', Settings::DEFAULT_OPENAI_MODEL);
142 213 }
143 214 }
@@ -142,14 +213,51 @@
142 213 }
143 214 }
144 215
145 216 /**
217 + * Resolve the reasoning-effort level for a content-brief request.
218 + *
219 + * Without an explicit level, GPT-5 models run at their default (maximum)
220 + * reasoning effort against a ~95% completion budget — the slowest and
221 + * costliest configuration, where billed reasoning tokens (drawn from the
222 + * same budget) are spent before any visible output (issue #286).
223 + *
224 + * A brief is a structured planning task, so 'low' is a provisional middle
225 + * ground between 'minimal' and the model's default.
226 + * The level is filterable so a site can trade latency for more reasoning;
227 + * returning '' opts out entirely and lets the model use its default effort.
228 + * Only the GPT-5 family consumes this — o1/o3, gpt-4o and the non-OpenAI
229 + * clients ignore an unrecognised option key.
230 + *
231 + * @param string $model The resolved model ID (passed to the filter).
232 + * @param array $params The brief generation parameters (passed to the filter).
233 + * @return string One of 'minimal' | 'low' | 'medium' | 'high', or '' to opt out.
234 + */
235 + private function resolve_reasoning_effort(string $model, array $params): string {
236 + /**
237 + * Filter the reasoning-effort level used for content-brief generation.
238 + *
239 + * @param string $effort The default level ('low'). Return '' to opt out.
240 + * @param string $model The resolved model ID for this request.
241 + * @param array $params The brief generation parameters.
242 + */
243 + $effort = (string) apply_filters('thinkrank_content_brief_reasoning_effort', 'low', $model, $params);
244 +
245 + // Only values OpenAI accepts may reach the request body ('' opts out).
246 + // An unrecognised filter return (e.g. 'turbo') would otherwise be sent
247 + // verbatim and fail the whole brief with a 400, so degrade to the
248 + // documented default instead.
249 + $allowed = ['', 'minimal', 'low', 'medium', 'high'];
250 + return in_array($effort, $allowed, true) ? $effort : 'low';
251 + }
252 +
253 + /**
146 254 * Get current AI provider
147 255 *
148 256 * @return string Current provider name
149 257 */
150 258 private function get_current_provider(): string {
151 - return $this->settings->get('ai_provider', 'openai');
259 + return $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
152 260 }
153 261
154 262 /**
155 263 * Extract token usage from AI response
@@ -159,9 +267,9 @@
159 267 */
160 268 private function extract_token_usage(array $ai_response): int {
161 269 $provider = $this->get_current_provider();
162 270
163 - if ($provider === 'openai' || $provider === 'openrouter') {
271 + if ($provider === 'openai' || $provider === 'openrouter' || $provider === 'openai_compatible') {
164 272 // OpenAI-compatible format: response['usage']['total_tokens']
165 273 return (int) ($ai_response['usage']['total_tokens'] ?? 0);
166 274 } elseif ($provider === 'claude') {
167 275 // Claude format: response['usage']['input_tokens'] + response['usage']['output_tokens']
@@ -241,29 +349,55 @@
241 349 $language
242 350 );
243 351
244 352 try {
245 - // Get recommended token limit for content briefs (model-specific)
246 - $max_tokens = method_exists($this->ai_client, 'get_recommended_tokens')
247 - ? $this->ai_client->get_recommended_tokens('content_brief')
248 - : 4000; // Fallback for non-OpenAI clients
353 + // Get the model-aware budget for a comprehensive brief, then scale
354 + // it to the requested content length so Short/Medium/Long actually
355 + // request different budgets (issue #287). Every client (OpenAI,
356 + // Claude, Gemini, OpenRouter) implements get_recommended_tokens(),
357 + // so there is no model-blind fallback.
358 + $base_tokens = (int) $this->ai_client->get_recommended_tokens('content_brief');
359 + $max_tokens = $this->scale_tokens_for_length($base_tokens, $content_length);
249 360
361 + // Bound hidden reasoning on the GPT-5 family (issue #286). See
362 + // resolve_reasoning_effort(). Only the GPT-5 family reads this;
363 + // o1/o3, gpt-4o and the non-OpenAI clients ignore the option, and
364 + // an empty string opts out (model default effort).
365 + $reasoning_effort = $this->resolve_reasoning_effort($this->get_current_model(), $params);
366 +
367 + $completion_options = [
368 + // For GPT‑5 family the client translates max_tokens to
369 + // max_completion_tokens internally. Temperature is intentionally
370 + // omitted: every client defaults it to 0.7, and reasoning models
371 + // reject it outright, so passing it here was misleading no-op.
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,
376 + ];
377 + if ('' !== $reasoning_effort) {
378 + $completion_options['reasoning_effort'] = $reasoning_effort;
379 + }
380 +
250 381 // Generate brief using AI
251 - $ai_response = $this->ai_client->generate_completion($prompt, [
252 - // For GPT‑5 family the client will translate to max_completion_tokens internally
253 - 'max_tokens' => $max_tokens,
254 - 'temperature' => 0.7,
255 - ]);
382 + $ai_response = $this->ai_client->generate_completion($prompt, $completion_options);
256 383
384 + // Detect a provider-side non-answer (refusal, policy block, or
385 + // truncation) BEFORE attempting text extraction. Otherwise a
386 + // refusal — which OpenAI returns as HTTP 200 with content=null —
387 + // slips past every isset() branch and gets serialized into the
388 + // brief body instead of being reported to the user.
389 + $this->guard_against_non_answer($ai_response, $max_tokens);
390 +
257 391 // Extract text content from AI response
258 392 $ai_text = '';
259 393
260 394 // Handle OpenAI response format
261 395 if (isset($ai_response['choices'][0]['message']['content'])) {
262 - $contentField = $ai_response['choices'][0]['message']['content'];
263 - if (is_string($contentField)) {
264 - $ai_text = $contentField;
265 - } elseif (is_array($contentField)) {
396 + $content_field = $ai_response['choices'][0]['message']['content'];
397 + if (is_string($content_field)) {
398 + $ai_text = $content_field;
399 + } elseif (is_array($content_field)) {
266 400 // Concatenate text parts from array-based content (Chat Completions multimodal)
267 401 $parts = array_map(function($part) {
268 402 if (is_array($part)) {
269 403 return $part['text'] ?? '';
@@ -268,9 +402,9 @@
268 402 if (is_array($part)) {
269 403 return $part['text'] ?? '';
270 404 }
271 405 return is_string($part) ? $part : '';
272 - }, $contentField);
406 + }, $content_field);
273 407 $ai_text = trim(implode("\n", array_filter($parts)));
274 408 }
275 409 }
276 410 // Handle Claude response format
@@ -279,15 +413,8 @@
279 413 }
280 414 // Handle Gemini response format
281 415 elseif (isset($ai_response['candidates'][0]['content']['parts'][0]['text'])) {
282 416 $ai_text = $ai_response['candidates'][0]['content']['parts'][0]['text'];
283 -
284 - // A reply cut off at the token limit is incomplete JSON, so it
285 - // can never be parsed. Fail with the real reason instead of
286 - // saving a brief titled "Unable to parse AI response".
287 - if (($ai_response['candidates'][0]['finishReason'] ?? '') === 'MAX_TOKENS') {
288 - throw new \Exception('The AI stopped at its output token limit before finishing the brief. Try a shorter content length or fewer competitor URLs.');
289 - }
290 417 }
291 418 // Handle direct content field
292 419 elseif (isset($ai_response['content']) && is_string($ai_response['content'])) {
293 420 $ai_text = $ai_response['content'];
@@ -295,12 +422,19 @@
295 422 // Handle direct string response
296 423 elseif (is_string($ai_response)) {
297 424 $ai_text = $ai_response;
298 425 }
299 - // If we still don't have text, log the response structure for debugging
426 + // No known provider shape matched and guard_against_non_answer()
427 + // found nothing it recognised. Never serialize the raw envelope
428 + // into the brief body — that turns a clear failure into a saved,
429 + // meaningless brief. Log the shape for diagnostics and fail.
300 430 else {
301 - // As a last resort, stringify the response for visibility (prevents empty content error)
302 - $ai_text = is_array($ai_response) ? wp_json_encode($ai_response) : (string) $ai_response;
431 + if (defined('WP_DEBUG') && WP_DEBUG) {
432 + $shape = is_array($ai_response) ? implode(', ', array_keys($ai_response)) : gettype($ai_response);
433 + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Debug logging only when WP_DEBUG is enabled.
434 + error_log('[ThinkRank] Content brief: unrecognised AI response shape. Top-level keys: ' . $shape);
435 + }
436 + throw new \Exception('The AI returned a response in an unexpected format. Please try again.');
303 437 }
304 438
305 439 // Ensure we have actual text content
306 440 if (empty(trim($ai_text))) {
@@ -341,8 +475,18 @@
341 475
342 476 } catch (\Exception $e) {
343 477 // Provide more specific error messages
344 478 $error_message = $e->getMessage();
479 +
480 + // Messages we authored for the user (refusals, policy blocks,
481 + // token-limit truncation, unexpected shape) all start with "The AI "
482 + // and are already actionable. Pass them through verbatim instead of
483 + // flattening them via the substring matching below — e.g. so a
484 + // refusal is not rewritten into generic "empty content" advice.
485 + if (strpos($error_message, 'The AI ') === 0) {
486 + throw new \Exception(esc_html($error_message));
487 + }
488 +
345 489 if (strpos($error_message, 'API key') !== false) {
346 490 throw new \Exception('API key configuration error. Please check your AI provider settings.');
347 491 } elseif (strpos($error_message, 'Invalid AI response format') !== false) {
348 492 throw new \Exception('AI service returned an unexpected response format. Please try again.');
@@ -354,8 +498,120 @@
354 498 }
355 499 }
356 500
357 501 /**
502 + * Scale the model-aware brief budget to the requested content length.
503 + *
504 + * get_recommended_tokens('content_brief') returns the budget for a full,
505 + * comprehensive (Long) brief, already capped at the model's completion
506 + * ceiling. Shorter tiers request proportionally less so that choosing Short
507 + * is genuinely faster and cheaper (issue #287), while every tier stays at or
508 + * below the base and at or above MIN_BRIEF_TOKENS so it cannot truncate.
509 + *
510 + * @param int $base_tokens Model-aware budget for a comprehensive brief.
511 + * @param string $content_length One of 'short' | 'medium' | 'long'.
512 + * @return int Scaled max_tokens, clamped to [floor, base_tokens].
513 + */
514 + private function scale_tokens_for_length(int $base_tokens, string $content_length): int {
515 + // Unknown/missing length falls back to the medium tier — never to 0 or
516 + // to the raw ceiling.
517 + $multiplier = self::LENGTH_TOKEN_MULTIPLIERS[$content_length]
518 + ?? self::LENGTH_TOKEN_MULTIPLIERS['medium'];
519 +
520 + $scaled = (int) round($base_tokens * $multiplier);
521 +
522 + // The floor can never exceed the base itself, so a model with a tiny
523 + // ceiling still yields a sane, in-range value.
524 + $floor = (int) min($base_tokens, self::MIN_BRIEF_TOKENS);
525 +
526 + return max($floor, min($scaled, $base_tokens));
527 + }
528 +
529 + /**
530 + * Detect a provider-side non-answer and fail with the real reason.
531 + *
532 + * A refusal, content-policy block, or token-limit truncation is not a
533 + * usable brief. Each provider signals these differently, and none of the
534 + * signals set the content field the extraction chain looks for — so if we
535 + * don't catch them here they fall through to the "unexpected format" path
536 + * (or, historically, were serialized into the brief body). All messages
537 + * start with "The AI " so the outer catch passes them through unchanged.
538 + *
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.
541 + * @throws \Exception If the response is a refusal, policy block, or truncation.
542 + */
543 + private function guard_against_non_answer($ai_response, int $requested_tokens = 0): void {
544 + if (!is_array($ai_response)) {
545 + return;
546 + }
547 +
548 + // --- OpenAI (Chat Completions) ---
549 + // A structured refusal is HTTP 200 with message.content=null and the
550 + // stated reason carried in message.refusal. finish_reason distinguishes
551 + // a policy block from a truncated completion.
552 + if (isset($ai_response['choices'][0]['message'])) {
553 + $message = $ai_response['choices'][0]['message'];
554 + $finish = (string) ($ai_response['choices'][0]['finish_reason'] ?? '');
555 +
556 + if (!empty($message['refusal'])) {
557 + throw new \Exception(esc_html(sprintf(
558 + 'The AI declined to generate this brief: %s',
559 + (string) $message['refusal']
560 + )));
561 + }
562 + if ('content_filter' === $finish) {
563 + throw new \Exception('The AI blocked this request under its content policy. Try a different topic or less sensitive keywords.');
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 + }
577 + if ('length' === $finish) {
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.');
579 + }
580 + }
581 +
582 + // --- Claude (Messages) ---
583 + if (isset($ai_response['stop_reason'])) {
584 + $stop_reason = (string) $ai_response['stop_reason'];
585 + if ('refusal' === $stop_reason) {
586 + throw new \Exception('The AI declined to generate this brief for this topic. Try a different topic or less sensitive keywords.');
587 + }
588 + if ('max_tokens' === $stop_reason) {
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.');
590 + }
591 + }
592 +
593 + // --- Gemini ---
594 + // A prompt rejected outright returns no candidate at all, only
595 + // promptFeedback.blockReason; a candidate can also finish on SAFETY or
596 + // PROHIBITED_CONTENT, or be truncated at MAX_TOKENS.
597 + $block_reason = (string) ($ai_response['promptFeedback']['blockReason'] ?? '');
598 + if ('' !== $block_reason) {
599 + throw new \Exception(esc_html(sprintf(
600 + 'The AI blocked this request under its content policy (%s). Try a different topic or less sensitive keywords.',
601 + $block_reason
602 + )));
603 + }
604 + $gemini_finish = (string) ($ai_response['candidates'][0]['finishReason'] ?? '');
605 + if (in_array($gemini_finish, ['SAFETY', 'PROHIBITED_CONTENT'], true)) {
606 + throw new \Exception('The AI blocked this request under its content policy. Try a different topic or less sensitive keywords.');
607 + }
608 + if ('MAX_TOKENS' === $gemini_finish) {
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.');
610 + }
611 + }
612 +
613 + /**
358 614 * Validate brief generation parameters
359 615 *
360 616 * @param array $params Parameters to validate
361 617 * @throws \Exception If validation fails
@@ -365,19 +621,19 @@
365 621 throw new \Exception('Target keywords are required and must be an array.');
366 622 }
367 623
368 624 $valid_content_types = ['blog_post', 'product_page', 'landing_page', 'tutorial'];
369 - if (!empty($params['content_type']) && !in_array($params['content_type'], $valid_content_types)) {
625 + if (!empty($params['content_type']) && !in_array($params['content_type'], $valid_content_types, true)) {
370 626 throw new \Exception('Invalid content type specified.');
371 627 }
372 628
373 629 $valid_lengths = ['short', 'medium', 'long'];
374 - if (!empty($params['content_length']) && !in_array($params['content_length'], $valid_lengths)) {
630 + if (!empty($params['content_length']) && !in_array($params['content_length'], $valid_lengths, true)) {
375 631 throw new \Exception('Invalid content length specified.');
376 632 }
377 633
378 634 $valid_tones = ['professional', 'casual', 'technical', 'friendly'];
379 - if (!empty($params['tone']) && !in_array($params['tone'], $valid_tones)) {
635 + if (!empty($params['tone']) && !in_array($params['tone'], $valid_tones, true)) {
380 636 throw new \Exception('Invalid tone specified.');
381 637 }
382 638 }
383 639
@@ -445,9 +701,9 @@
445 701 'title' => $json_data['title_suggestions'] ?? [],
446 702 'meta_description' => $json_data['meta_descriptions'][0] ?? '',
447 703 'meta_descriptions' => $json_data['meta_descriptions'] ?? [],
448 704 'url_slugs' => $json_data['url_slugs'] ?? [],
449 - 'outline' => $json_data['outline'] ?? [],
705 + 'outline' => self::strip_outline_level_labels($json_data['outline'] ?? []),
450 706 'seo_recommendations' => [
451 707 'title_suggestions' => $json_data['title_suggestions'] ?? [],
452 708 'meta_description' => $json_data['meta_descriptions'][0] ?? '',
453 709 'meta_descriptions' => $json_data['meta_descriptions'] ?? [],
@@ -473,9 +729,9 @@
473 729 ],
474 730 'competitor_gaps' => $json_data['competitor_analysis']['content_gaps'] ?? [],
475 731 'call_to_actions' => $json_data['call_to_actions'] ?? [],
476 732 'writing_guidelines' => $json_data['writing_guidelines'] ?? [],
477 - 'content_body' => $json_data['content_body'] ?? '',
733 + 'content_body' => self::strip_heading_level_labels((string) ($json_data['content_body'] ?? '')),
478 734 'estimated_word_count' => $this->get_word_count_estimate($original_params['content_length'] ?? 'medium'),
479 735 'raw_response' => '', // Will be retrieved from ai_usage table
480 736 'generation_params' => $original_params,
481 737 'parsing_status' => 'success',
@@ -483,8 +739,118 @@
483 739 ];
484 740 }
485 741
486 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 + /**
487 853 * Create error response when JSON parsing fails
488 854 *
489 855 * @param string $ai_response Raw AI response
490 856 * @param array $original_params Original generation parameters
@@ -647,63 +1013,17 @@
647 1013 * resolves only to public IP addresses.
648 1014 *
649 1015 * Prevents SSRF — a low-privilege user could otherwise point competitor
650 1016 * scraping at loopback, link-local (e.g. 169.254.169.254 cloud metadata),
651 - * private or reserved addresses to probe internal services.
1017 + * CGNAT, private or reserved addresses to probe internal services. The
1018 + * block list lives in {@see \ThinkRank\Core\Url_Safety} so this and the
1019 + * schema importer can never drift apart.
652 1020 *
653 1021 * @param string $url URL to validate.
654 1022 * @return bool True when safe to fetch.
655 1023 */
656 1024 private function is_safe_public_url(string $url): bool {
657 - $parts = wp_parse_url($url);
658 - if (empty($parts['scheme']) || empty($parts['host'])) {
659 - return false;
660 - }
661 -
662 - // Only ever fetch over http/https.
663 - if (!in_array(strtolower($parts['scheme']), ['http', 'https'], true)) {
664 - return false;
665 - }
666 -
667 - $host = $parts['host'];
668 -
669 - // Resolve the host to the IP(s) it points at (literal IPs pass through).
670 - $ips = [];
671 - if (filter_var($host, FILTER_VALIDATE_IP)) {
672 - $ips[] = $host;
673 - } else {
674 - $records = @dns_get_record($host, DNS_A + DNS_AAAA);
675 - if (is_array($records)) {
676 - foreach ($records as $record) {
677 - if (!empty($record['ip'])) {
678 - $ips[] = $record['ip'];
679 - } elseif (!empty($record['ipv6'])) {
680 - $ips[] = $record['ipv6'];
681 - }
682 - }
683 - }
684 - // Fallback for hosts dns_get_record can't resolve.
685 - if (empty($ips)) {
686 - $resolved = gethostbyname($host);
687 - if ($resolved !== $host) {
688 - $ips[] = $resolved;
689 - }
690 - }
691 - }
692 -
693 - // Unresolvable host → don't fetch.
694 - if (empty($ips)) {
695 - return false;
696 - }
697 -
698 - // Reject if any resolved address is private, reserved, loopback or link-local.
699 - foreach ($ips as $ip) {
700 - if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
701 - return false;
702 - }
703 - }
704 -
705 - return true;
1025 + return \ThinkRank\Core\Url_Safety::is_safe_public_url($url);
706 1026 }
707 1027
708 1028 /**
709 1029 * Scrape content from a competitor URL
@@ -711,12 +1031,13 @@
711 1031 * @param string $url The URL to scrape
712 1032 * @return array|null Content data or null if failed
713 1033 */
714 1034 private function scrape_competitor_content(string $url): ?array {
715 - // wp_safe_remote_get() (reject_unsafe_urls) re-validates the URL and, unlike
716 - // wp_remote_get(), rejects redirects to internal/private hosts — closing the
717 - // redirect-based SSRF bypass on top of the pre-flight is_safe_public_url() check.
718 - $response = wp_safe_remote_get($url, [
1035 + // Url_Safety::safe_remote_get() follows redirects manually and re-checks
1036 + // the resolved host on every hop, so a target that redirects to (or
1037 + // rebinds onto) an internal address after the pre-flight check is
1038 + // refused rather than fetched.
1039 + $response = \ThinkRank\Core\Url_Safety::safe_remote_get($url, [
719 1040 'timeout' => 8, // Reduced from 15 to 8 seconds
720 1041 'user-agent' => 'Mozilla/5.0 (compatible; ThinkRank SEO Bot)',
721 1042 'headers' => [
722 1043 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
@@ -992,8 +1313,20 @@
992 1313 if (false === $result) {
993 1314 throw new \Exception('Failed to save content brief to database.');
994 1315 }
995 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 +
996 1329 return $wpdb->insert_id;
997 1330 }
998 1331
999 1332 /**
@@ -1023,11 +1356,14 @@
1023 1356 if (isset($brief_data['visual_content']['image_recommendations']) && is_array($brief_data['visual_content']['image_recommendations'])) {
1024 1357 $brief_data['visual_content']['image_recommendations'] = array_map(function($rec) {
1025 1358 if (is_array($rec)) {
1026 1359 $text = '';
1027 - if (isset($rec['type'])) $text .= $rec['type'] . ': ';
1028 - if (isset($rec['description'])) $text .= $rec['description'];
1029 - if (isset($rec['alt_text'])) $text .= ' (Alt: ' . $rec['alt_text'] . ')';
1360 + if (isset($rec['type'])) { $text .= $rec['type'] . ': ';
1361 + }
1362 + if (isset($rec['description'])) { $text .= $rec['description'];
1363 + }
1364 + if (isset($rec['alt_text'])) { $text .= ' (Alt: ' . $rec['alt_text'] . ')';
1365 + }
1030 1366 return $text ?: 'Image recommendation';
1031 1367 }
1032 1368 return is_string($rec) ? $rec : 'Image recommendation';
1033 1369 }, $brief_data['visual_content']['image_recommendations']);
@@ -1032,8 +1368,54 @@
1032 1368 return is_string($rec) ? $rec : 'Image recommendation';
1033 1369 }, $brief_data['visual_content']['image_recommendations']);
1034 1370 }
1035 1371
1372 + return $this->sanitize_brief_output($brief_data);
1373 + }
1374 +
1375 + /**
1376 + * Strip untrusted markup out of brief fields before they leave the server.
1377 + *
1378 + * Brief content crosses a trust boundary: it is assembled by an external AI
1379 + * provider from prompts that can include text fetched from competitor URLs.
1380 + * It was previously copied out of the decoded JSON verbatim and rendered in
1381 + * the admin SPA through dangerouslySetInnerHTML, so a malicious or
1382 + * prompt-injected response could execute script in the admin origin (#365).
1383 + *
1384 + * Runs on the read path as well as generation, so briefs stored before this
1385 + * fix are sanitized when they are loaded.
1386 + *
1387 + * @since 1.32.0
1388 + *
1389 + * @param array $brief_data Brief data to sanitize.
1390 + * @return array Sanitized brief data.
1391 + */
1392 + private function sanitize_brief_output(array $brief_data): array {
1393 + foreach ($brief_data as $key => $value) {
1394 + // The raw provider response is debug output shown as plain text, and
1395 + // the generation params are our own values — leave both intact.
1396 + if ('raw_response' === $key || 'generation_params' === $key) {
1397 + continue;
1398 + }
1399 +
1400 + if ('content_body' === $key && is_string($value)) {
1401 + // Deliberately HTML: it is the drafted article and is rendered as
1402 + // markup. wp_kses_post() keeps normal post formatting while
1403 + // dropping script/style/iframe, event-handler attributes and
1404 + // javascript: URLs.
1405 + $brief_data[$key] = wp_kses_post($value);
1406 + continue;
1407 + }
1408 +
1409 + if (is_array($value)) {
1410 + $brief_data[$key] = $this->sanitize_brief_output($value);
1411 + } elseif (is_string($value)) {
1412 + // Every other field is plain text (headings, keywords, guidance).
1413 + // Markdown emphasis markers are preserved; HTML tags are not.
1414 + $brief_data[$key] = wp_strip_all_tags($value);
1415 + }
1416 + }
1417 +
1036 1418 return $brief_data;
1037 1419 }
1038 1420
1039 1421 /**
@@ -1118,13 +1500,17 @@
1118 1500 private function hydrate_brief_row(array $brief): array {
1119 1501 $brief['target_keywords'] = json_decode($brief['target_keywords'], true);
1120 1502 $brief['brief_data'] = json_decode($brief['brief_data'], true);
1121 1503
1504 + // Cast: the row comes from $wpdb, which returns every column as a
1505 + // string, and both helpers declare an int parameter.
1506 + $brief_id = (int) $brief['id'];
1507 +
1122 1508 // Retrieve raw response from ai_usage table
1123 - $brief['brief_data']['raw_response'] = $this->get_raw_response_for_brief($brief['id']);
1509 + $brief['brief_data']['raw_response'] = $this->get_raw_response_for_brief($brief_id);
1124 1510
1125 1511 // Update model with actual model used (if available in ai_usage table)
1126 - $actual_model = $this->get_actual_model_for_brief($brief['id']);
1512 + $actual_model = $this->get_actual_model_for_brief($brief_id);
1127 1513 if ($actual_model && isset($brief['brief_data']['generation_meta'])) {
1128 1514 $brief['brief_data']['generation_meta']['model'] = $actual_model;
1129 1515 }
1130 1516
@@ -1171,9 +1557,10 @@
1171 1557 }
1172 1558
1173 1559 // Count sentences (approximate)
1174 1560 $sentences = preg_split('/[.!?]+/', $text);
1175 - $sentence_count = count(array_filter($sentences, function($s) { return trim($s) !== ''; }));
1561 + $sentence_count = count(array_filter($sentences, function($s) { return trim($s) !== '';
1562 +}));
1176 1563
1177 1564 // Count words
1178 1565 $word_count = str_word_count($text);
1179 1566
@@ -1250,9 +1637,9 @@
1250 1637 $vowels = 'aeiouy';
1251 1638 $syllable_count = 0;
1252 1639 $previous_was_vowel = false;
1253 1640
1254 - for ($i = 0; $i < strlen($word); $i++) {
1641 + for ($i = 0, $len = strlen($word); $i < $len; $i++) {
1255 1642 $is_vowel = strpos($vowels, $word[$i]) !== false;
1256 1643 if ($is_vowel && !$previous_was_vowel) {
1257 1644 $syllable_count++;
1258 1645 }
@@ -1523,9 +1910,10 @@
1523 1910 }
1524 1911
1525 1912 // Readability check
1526 1913 $sentences = preg_split('/[.!?]+/', $meta_desc);
1527 - $sentence_count = count(array_filter($sentences, function($s) { return trim($s) !== ''; }));
1914 + $sentence_count = count(array_filter($sentences, function($s) { return trim($s) !== '';
1915 +}));
1528 1916 if ($sentence_count >= 1 && $sentence_count <= 3) {
1529 1917 $score += 20;
1530 1918 $feedback[] = 'Good sentence structure';
1531 1919 } else {
@@ -1618,12 +2006,16 @@
1618 2006 * @param int $score Numeric score
1619 2007 * @return string Letter grade
1620 2008 */
1621 2009 private function get_grade_from_score(int $score): string {
1622 - if ($score >= 90) return 'A';
1623 - if ($score >= 80) return 'B';
1624 - if ($score >= 70) return 'C';
1625 - if ($score >= 60) return 'D';
2010 + if ($score >= 90) { return 'A';
2011 + }
2012 + if ($score >= 80) { return 'B';
2013 + }
2014 + if ($score >= 70) { return 'C';
2015 + }
2016 + if ($score >= 60) { return 'D';
2017 + }
1626 2018 return 'F';
1627 2019 }
1628 2020
1629 2021 /**
@@ -1656,9 +2048,9 @@
1656 2048 [
1657 2049 'user_id' => $user_id,
1658 2050 'action' => $action,
1659 2051 'tokens_used' => $tokens_used,
1660 - 'provider' => $this->settings->get('ai_provider', 'openai'),
2052 + 'provider' => $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE),
1661 2053 'post_id' => $post_id,
1662 2054 'metadata' => !empty($metadata) ? wp_json_encode($metadata) : null,
1663 2055 'created_at' => current_time('mysql'),
1664 2056 ],
@@ -1663,8 +2055,17 @@
1663 2055 'created_at' => current_time('mysql'),
1664 2056 ],
1665 2057 ['%d', '%s', '%d', '%s', '%d', '%s', '%s']
1666 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);
1667 2068
1668 2069 return $wpdb->insert_id;
1669 2070 }
1670 2071