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 +650 -167 1.0.0 → 2.9.0 View file →
@@ -13,9 +13,15 @@
13 13
14 14 use ThinkRank\Core\Settings;
15 15 use ThinkRank\AI\OpenAI_Client;
16 16 use ThinkRank\AI\Claude_Client;
17 +use ThinkRank\AI\OpenRouter_Client;
17 18
19 +// Prevent direct access
20 +if (!defined('ABSPATH')) {
21 + exit;
22 +}
23 +
18 24 /**
19 25 * Content Brief Generator class
20 26 */
21 27 class Content_Brief_Generator {
@@ -20,8 +26,56 @@
20 26 */
21 27 class Content_Brief_Generator {
22 28
23 29 /**
30 + * AI request timeout (seconds) for brief generation.
31 + *
32 + * Content briefs request a very large completion (~0.9–0.95 of the model's
33 + * max tokens) from reasoning models, which routinely take 40–90s — far
34 + * longer than the AI clients' 30s default. Without this the HTTP call is
35 + * aborted with cURL error 28 and the brief never completes. PHP execution
36 + * time is covered by each client's raise_request_time_limit() (timeout+45).
37 + */
38 + private const AI_REQUEST_TIMEOUT = 120;
39 +
40 + /**
41 + * AI request timeout (seconds) for OpenAI specifically.
42 + *
43 + * OpenAI's reasoning models (GPT-5 / o-series) burn reasoning tokens before
44 + * emitting any content, and briefs request ~95% of the model's completion
45 + * limit — so the call frequently runs past the 120s the other providers
46 + * need. PHP execution time is covered by raise_request_time_limit()
47 + * (timeout+45); the web server's own read timeout still caps the maximum.
48 + */
49 + private const OPENAI_REQUEST_TIMEOUT = 300;
50 +
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 + /**
24 78 * Settings instance
25 79 *
26 80 * @var Settings
27 81 */
@@ -29,9 +83,11 @@
29 83
30 84 /**
31 85 * AI client instance
32 86 *
33 - * @var OpenAI_Client|Claude_Client
87 + * Null when the generator was built for storage-only work.
88 + *
89 + * @var OpenAI_Client|Claude_Client|null
34 90 */
35 91 private $ai_client;
36 92
37 93 /**
@@ -38,18 +94,31 @@
38 94 * Constructor
39 95 *
40 96 * @param Settings|null $settings Settings instance
41 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.
42 101 */
43 - public function __construct(?Settings $settings = null, $ai_client = null) {
44 - $this->settings = $settings ?? new Settings();
102 + public function __construct(?Settings $settings = null, $ai_client = null, bool $require_ai_client = true) {
103 + $this->settings = $settings ?? Settings::instance();
45 104
46 105 if ($ai_client) {
47 106 $this->ai_client = $ai_client;
48 - } else {
49 - // Fallback to creating own client for backward compatibility
50 - $this->init_ai_client();
107 +
108 + return;
51 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();
52 121 }
53 122
54 123 /**
55 124 * Initialize AI client based on available API keys
@@ -54,33 +123,61 @@
54 123 /**
55 124 * Initialize AI client based on available API keys
56 125 *
57 126 * @return void
127 + *
128 + * @throws \Exception On failure.
58 129 */
59 130 private function init_ai_client(): void {
60 - $provider = $this->settings->get('ai_provider', 'openai');
131 + $provider = $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
61 132
62 133 if ($provider === 'openai') {
63 134 $api_key = $this->settings->get('openai_api_key');
64 135 if ($api_key) {
65 - $model = $this->settings->get('openai_model', 'gpt-5-nano');
66 - $this->ai_client = new OpenAI_Client($api_key, $model);
136 + $model = $this->settings->get('openai_model', Settings::DEFAULT_OPENAI_MODEL);
137 + $this->ai_client = new OpenAI_Client($api_key, $model, self::OPENAI_REQUEST_TIMEOUT);
67 138 }
68 139 } elseif ($provider === 'claude') {
69 140 $api_key = $this->settings->get('claude_api_key');
70 141 if ($api_key) {
71 - $model = $this->settings->get('claude_model', 'claude-3.7-sonnet');
72 - $this->ai_client = new Claude_Client($api_key, $model);
142 + $model = $this->settings->get('claude_model', Settings::DEFAULT_CLAUDE_MODEL);
143 + $this->ai_client = new Claude_Client($api_key, $model, self::AI_REQUEST_TIMEOUT);
73 144 }
74 145 } elseif ($provider === 'gemini') {
75 146 $api_key = $this->settings->get('gemini_api_key');
76 147 if ($api_key) {
77 - $model = $this->settings->get('gemini_model', 'gemini-2.5-flash');
78 - $this->ai_client = new Gemini_Client($api_key, $model);
148 + $model = $this->settings->get('gemini_model', Settings::DEFAULT_GEMINI_MODEL);
149 + $this->ai_client = new Gemini_Client($api_key, $model, self::AI_REQUEST_TIMEOUT);
79 150 }
151 + } elseif ($provider === 'openrouter') {
152 + $api_key = $this->settings->get('openrouter_api_key');
153 + if ($api_key) {
154 + $model = $this->settings->get('openrouter_model', Settings::DEFAULT_OPENROUTER_MODEL);
155 + $this->ai_client = new OpenRouter_Client($api_key, $model, self::AI_REQUEST_TIMEOUT);
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 + }
80 173 }
81 174
82 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 +
83 180 throw new \Exception('Please configure your AI provider API key in ThinkRank settings.');
84 181 }
85 182 }
86 183
@@ -95,25 +192,72 @@
95 192 return $this->ai_client->get_model();
96 193 }
97 194
98 195 // Fallback to settings
99 - $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 + }
100 203 if ($provider === 'claude') {
101 - return $this->settings->get('claude_model', 'claude-3-7-sonnet-latest');
204 + return $this->settings->get('claude_model', Settings::DEFAULT_CLAUDE_MODEL);
102 205 } elseif ($provider === 'gemini') {
103 - return $this->settings->get('gemini_model', 'gemini-2.5-flash');
206 + return $this->settings->get('gemini_model', Settings::DEFAULT_GEMINI_MODEL);
207 + } elseif ($provider === 'openrouter') {
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', '');
104 211 } else {
105 - return $this->settings->get('openai_model', 'gpt-5-nano');
212 + return $this->settings->get('openai_model', Settings::DEFAULT_OPENAI_MODEL);
106 213 }
107 214 }
108 215
109 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 + /**
110 254 * Get current AI provider
111 255 *
112 256 * @return string Current provider name
113 257 */
114 258 private function get_current_provider(): string {
115 - return $this->settings->get('ai_provider', 'openai');
259 + return $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
116 260 }
117 261
118 262 /**
119 263 * Extract token usage from AI response
@@ -123,10 +267,10 @@
123 267 */
124 268 private function extract_token_usage(array $ai_response): int {
125 269 $provider = $this->get_current_provider();
126 270
127 - if ($provider === 'openai') {
128 - // OpenAI format: response['usage']['total_tokens']
271 + if ($provider === 'openai' || $provider === 'openrouter' || $provider === 'openai_compatible') {
272 + // OpenAI-compatible format: response['usage']['total_tokens']
129 273 return (int) ($ai_response['usage']['total_tokens'] ?? 0);
130 274 } elseif ($provider === 'claude') {
131 275 // Claude format: response['usage']['input_tokens'] + response['usage']['output_tokens']
132 276 $input_tokens = (int) ($ai_response['usage']['input_tokens'] ?? 0);
@@ -180,8 +324,11 @@
180 324 $content_length = $params['content_length'] ?? 'medium';
181 325 $tone = $params['tone'] ?? 'professional';
182 326 $competitor_urls = $params['competitor_urls'] ?? [];
183 327 $additional_context = $params['additional_context'] ?? '';
328 + // Write the brief in the site (or related post's) language rather than
329 + // defaulting to English on non-English sites (issue #234).
330 + $language = \ThinkRank\AI\Language_Resolver::resolve((int) ($params['post_id'] ?? 0));
184 331
185 332 // Analyze competitor URLs if provided
186 333 $competitor_analysis = '';
187 334 if (!empty($competitor_urls)) {
@@ -197,25 +344,50 @@
197 344 $content_length,
198 345 $tone,
199 346 $competitor_analysis,
200 347 $additional_context,
201 - $this->get_current_provider()
348 + $this->get_current_provider(),
349 + $language
202 350 );
203 351
204 352 try {
205 - // Get recommended token limit for content briefs (model-specific)
206 - $max_tokens = method_exists($this->ai_client, 'get_recommended_tokens')
207 - ? $this->ai_client->get_recommended_tokens('content_brief')
208 - : 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);
209 360
210 - // Generate brief using AI
211 - $ai_response = $this->ai_client->generate_completion($prompt, [
212 - // For GPT‑5 family the client will translate to max_completion_tokens internally
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.
213 372 'max_tokens' => $max_tokens,
214 - 'temperature' => 0.7,
215 - ]);
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 + }
216 380
381 + // Generate brief using AI
382 + $ai_response = $this->ai_client->generate_completion($prompt, $completion_options);
217 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);
218 390
219 391 // Extract text content from AI response
220 392 $ai_text = '';
221 393
@@ -220,12 +392,12 @@
220 392 $ai_text = '';
221 393
222 394 // Handle OpenAI response format
223 395 if (isset($ai_response['choices'][0]['message']['content'])) {
224 - $contentField = $ai_response['choices'][0]['message']['content'];
225 - if (is_string($contentField)) {
226 - $ai_text = $contentField;
227 - } 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)) {
228 400 // Concatenate text parts from array-based content (Chat Completions multimodal)
229 401 $parts = array_map(function($part) {
230 402 if (is_array($part)) {
231 403 return $part['text'] ?? '';
@@ -230,9 +402,9 @@
230 402 if (is_array($part)) {
231 403 return $part['text'] ?? '';
232 404 }
233 405 return is_string($part) ? $part : '';
234 - }, $contentField);
406 + }, $content_field);
235 407 $ai_text = trim(implode("\n", array_filter($parts)));
236 408 }
237 409 }
238 410 // Handle Claude response format
@@ -241,15 +413,8 @@
241 413 }
242 414 // Handle Gemini response format
243 415 elseif (isset($ai_response['candidates'][0]['content']['parts'][0]['text'])) {
244 416 $ai_text = $ai_response['candidates'][0]['content']['parts'][0]['text'];
245 -
246 - // Check if Gemini response was truncated due to token limit
247 - if (isset($ai_response['candidates'][0]['finishReason']) &&
248 - $ai_response['candidates'][0]['finishReason'] === 'MAX_TOKENS') {
249 - // Add a note about truncation
250 - $ai_text .= "\n\n[Note: Response was truncated due to length limits. The content above provides a comprehensive brief.]";
251 - }
252 417 }
253 418 // Handle direct content field
254 419 elseif (isset($ai_response['content']) && is_string($ai_response['content'])) {
255 420 $ai_text = $ai_response['content'];
@@ -257,12 +422,19 @@
257 422 // Handle direct string response
258 423 elseif (is_string($ai_response)) {
259 424 $ai_text = $ai_response;
260 425 }
261 - // 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.
262 430 else {
263 - // As a last resort, stringify the response for visibility (prevents empty content error)
264 - $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.');
265 437 }
266 438
267 439 // Ensure we have actual text content
268 440 if (empty(trim($ai_text))) {
@@ -303,8 +475,18 @@
303 475
304 476 } catch (\Exception $e) {
305 477 // Provide more specific error messages
306 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 +
307 489 if (strpos($error_message, 'API key') !== false) {
308 490 throw new \Exception('API key configuration error. Please check your AI provider settings.');
309 491 } elseif (strpos($error_message, 'Invalid AI response format') !== false) {
310 492 throw new \Exception('AI service returned an unexpected response format. Please try again.');
@@ -316,8 +498,120 @@
316 498 }
317 499 }
318 500
319 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 + /**
320 614 * Validate brief generation parameters
321 615 *
322 616 * @param array $params Parameters to validate
323 617 * @throws \Exception If validation fails
@@ -327,19 +621,19 @@
327 621 throw new \Exception('Target keywords are required and must be an array.');
328 622 }
329 623
330 624 $valid_content_types = ['blog_post', 'product_page', 'landing_page', 'tutorial'];
331 - 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)) {
332 626 throw new \Exception('Invalid content type specified.');
333 627 }
334 628
335 629 $valid_lengths = ['short', 'medium', 'long'];
336 - 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)) {
337 631 throw new \Exception('Invalid content length specified.');
338 632 }
339 633
340 634 $valid_tones = ['professional', 'casual', 'technical', 'friendly'];
341 - if (!empty($params['tone']) && !in_array($params['tone'], $valid_tones)) {
635 + if (!empty($params['tone']) && !in_array($params['tone'], $valid_tones, true)) {
342 636 throw new \Exception('Invalid tone specified.');
343 637 }
344 638 }
345 639
@@ -407,9 +701,9 @@
407 701 'title' => $json_data['title_suggestions'] ?? [],
408 702 'meta_description' => $json_data['meta_descriptions'][0] ?? '',
409 703 'meta_descriptions' => $json_data['meta_descriptions'] ?? [],
410 704 'url_slugs' => $json_data['url_slugs'] ?? [],
411 - 'outline' => $json_data['outline'] ?? [],
705 + 'outline' => self::strip_outline_level_labels($json_data['outline'] ?? []),
412 706 'seo_recommendations' => [
413 707 'title_suggestions' => $json_data['title_suggestions'] ?? [],
414 708 'meta_description' => $json_data['meta_descriptions'][0] ?? '',
415 709 'meta_descriptions' => $json_data['meta_descriptions'] ?? [],
@@ -435,8 +729,9 @@
435 729 ],
436 730 'competitor_gaps' => $json_data['competitor_analysis']['content_gaps'] ?? [],
437 731 'call_to_actions' => $json_data['call_to_actions'] ?? [],
438 732 'writing_guidelines' => $json_data['writing_guidelines'] ?? [],
733 + 'content_body' => self::strip_heading_level_labels((string) ($json_data['content_body'] ?? '')),
439 734 'estimated_word_count' => $this->get_word_count_estimate($original_params['content_length'] ?? 'medium'),
440 735 'raw_response' => '', // Will be retrieved from ai_usage table
441 736 'generation_params' => $original_params,
442 737 'parsing_status' => 'success',
@@ -444,8 +739,118 @@
444 739 ];
445 740 }
446 741
447 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 + /**
448 853 * Create error response when JSON parsing fails
449 854 *
450 855 * @param string $ai_response Raw AI response
451 856 * @param array $original_params Original generation parameters
@@ -488,8 +893,9 @@
488 893 ],
489 894 'competitor_gaps' => [],
490 895 'call_to_actions' => [],
491 896 'writing_guidelines' => [],
897 + 'content_body' => '',
492 898 'estimated_word_count' => $this->get_word_count_estimate($original_params['content_length'] ?? 'medium'),
493 899 'raw_response' => $ai_response, // Store raw response in error case
494 900 'generation_params' => $original_params,
495 901 'parsing_status' => 'failed',
@@ -553,30 +959,8 @@
553 959 }
554 960
555 961 return $normalized;
556 962 }
557 -
558 - /**
559 - * Generate title suggestions based on primary keyword
560 - *
561 - * @param string $primary_keyword Primary keyword
562 - * @return array Title suggestions
563 - */
564 - private function generate_title_suggestions(string $primary_keyword): array {
565 - return [
566 - "How to Master {$primary_keyword}: A Complete Guide",
567 - "The Ultimate {$primary_keyword} Guide for Beginners",
568 - "{$primary_keyword}: Everything You Need to Know",
569 - "Complete {$primary_keyword} Tutorial: Step-by-Step Guide"
570 - ];
571 - }
572 -
573 - /**
574 - * Analyze competitor URLs and extract content insights
575 - *
576 - * @param array $urls Array of competitor URLs
577 - * @return string Formatted competitor analysis for AI prompt
578 - */
579 963 private function analyze_competitor_urls(array $urls): string {
580 964 $analysis_results = [];
581 965 $failed_urls = [];
582 966
@@ -589,8 +973,16 @@
589 973 $failed_urls[] = $url . " (invalid URL)";
590 974 continue;
591 975 }
592 976
977 + // SSRF guard: only fetch public http/https hosts. Blocks loopback,
978 + // link-local (cloud metadata), private and reserved ranges before any
979 + // request is made.
980 + if (!$this->is_safe_public_url($url)) {
981 + $failed_urls[] = $url . " (blocked: non-public host)";
982 + continue;
983 + }
984 +
593 985 $content_data = $this->scrape_competitor_content($url);
594 986 if ($content_data) {
595 987 $analysis_results[] = $this->format_competitor_analysis($url, $content_data);
596 988 } else {
@@ -616,8 +1008,25 @@
616 1008 return $result;
617 1009 }
618 1010
619 1011 /**
1012 + * Whether a competitor URL is safe to fetch: an http/https URL whose host
1013 + * resolves only to public IP addresses.
1014 + *
1015 + * Prevents SSRF — a low-privilege user could otherwise point competitor
1016 + * scraping at loopback, link-local (e.g. 169.254.169.254 cloud metadata),
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.
1020 + *
1021 + * @param string $url URL to validate.
1022 + * @return bool True when safe to fetch.
1023 + */
1024 + private function is_safe_public_url(string $url): bool {
1025 + return \ThinkRank\Core\Url_Safety::is_safe_public_url($url);
1026 + }
1027 +
1028 + /**
620 1029 * Scrape content from a competitor URL
621 1030 *
622 1031 * @param string $url The URL to scrape
623 1032 * @return array|null Content data or null if failed
@@ -622,10 +1031,13 @@
622 1031 * @param string $url The URL to scrape
623 1032 * @return array|null Content data or null if failed
624 1033 */
625 1034 private function scrape_competitor_content(string $url): ?array {
626 - // Use WordPress HTTP API for scraping with shorter timeout
627 - $response = wp_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, [
628 1040 'timeout' => 8, // Reduced from 15 to 8 seconds
629 1041 'user-agent' => 'Mozilla/5.0 (compatible; ThinkRank SEO Bot)',
630 1042 'headers' => [
631 1043 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
@@ -868,68 +1280,8 @@
868 1280 ];
869 1281
870 1282 return $estimates[$content_length] ?? 1250;
871 1283 }
872 -
873 - /**
874 - * Parse heading line from AI response
875 - *
876 - * @param string $line Heading line
877 - * @return array Parsed heading data
878 - */
879 - private function parse_heading_line(string $line): array {
880 - $level = 1;
881 - $heading = $line;
882 -
883 - // Remove bold markdown if present
884 - $heading = preg_replace('/^\*\*(.*?)\*\*$/', '$1', $heading);
885 -
886 - // Detect heading level from various formats
887 - if (preg_match('/^H1:/i', $heading)) {
888 - $level = 1;
889 - $heading = preg_replace('/^H1:\s*/i', '', $heading);
890 - } elseif (preg_match('/^H2:/i', $heading)) {
891 - $level = 2;
892 - $heading = preg_replace('/^H2:\s*/i', '', $heading);
893 - } elseif (preg_match('/^H3:/i', $heading)) {
894 - $level = 3;
895 - $heading = preg_replace('/^H3:\s*/i', '', $heading);
896 - } elseif (preg_match('/^###\s*/', $heading)) {
897 - $level = 3;
898 - $heading = preg_replace('/^###\s*/', '', $heading);
899 - } elseif (preg_match('/^##\s*/', $heading)) {
900 - $level = 2;
901 - $heading = preg_replace('/^##\s*/', '', $heading);
902 - } elseif (preg_match('/^#\s*/', $heading)) {
903 - $level = 1;
904 - $heading = preg_replace('/^#\s*/', '', $heading);
905 - }
906 -
907 - // Extract word count if present (various formats)
908 - $word_count = 0;
909 - if (preg_match('/\*Estimated Word Count:\s*(\d+)-?(\d+)?\s*words?\*/i', $heading, $matches)) {
910 - $word_count = isset($matches[2]) ? intval($matches[2]) : intval($matches[1]);
911 - $heading = preg_replace('/\s*\*Estimated Word Count:.*?\*/i', '', $heading);
912 - } elseif (preg_match('/\((\d+)-?(\d+)?\s*words?\)/i', $heading, $matches)) {
913 - $word_count = isset($matches[2]) ? intval($matches[2]) : intval($matches[1]);
914 - $heading = preg_replace('/\s*\(\d+.*?\)/i', '', $heading);
915 - }
916 -
917 - return [
918 - 'heading' => trim($heading),
919 - 'level' => $level,
920 - 'word_count' => $word_count,
921 - 'key_points' => [],
922 - 'keywords' => []
923 - ];
924 - }
925 -
926 - /**
927 - * Save brief to database
928 - *
929 - * @param array $brief_data Brief data to save
930 - * @return int Brief ID
931 - */
932 1284 private function save_brief(array $brief_data): int {
933 1285 global $wpdb;
934 1286
935 1287 $table_name = $wpdb->prefix . 'thinkrank_content_briefs';
@@ -954,9 +1306,9 @@
954 1306 '%s', // created_at
955 1307 '%s' // updated_at
956 1308 ];
957 1309
958 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Content brief storage requires direct database access
1310 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Content brief storage requires direct database access
959 1311 $result = $wpdb->insert($table_name, $insert_data, $insert_format);
960 1312
961 1313 if (false === $result) {
962 1314 throw new \Exception('Failed to save content brief to database.');
@@ -961,8 +1313,20 @@
961 1313 if (false === $result) {
962 1314 throw new \Exception('Failed to save content brief to database.');
963 1315 }
964 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 +
965 1329 return $wpdb->insert_id;
966 1330 }
967 1331
968 1332 /**
@@ -992,11 +1356,14 @@
992 1356 if (isset($brief_data['visual_content']['image_recommendations']) && is_array($brief_data['visual_content']['image_recommendations'])) {
993 1357 $brief_data['visual_content']['image_recommendations'] = array_map(function($rec) {
994 1358 if (is_array($rec)) {
995 1359 $text = '';
996 - if (isset($rec['type'])) $text .= $rec['type'] . ': ';
997 - if (isset($rec['description'])) $text .= $rec['description'];
998 - 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 + }
999 1366 return $text ?: 'Image recommendation';
1000 1367 }
1001 1368 return is_string($rec) ? $rec : 'Image recommendation';
1002 1369 }, $brief_data['visual_content']['image_recommendations']);
@@ -1001,8 +1368,54 @@
1001 1368 return is_string($rec) ? $rec : 'Image recommendation';
1002 1369 }, $brief_data['visual_content']['image_recommendations']);
1003 1370 }
1004 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 +
1005 1418 return $brief_data;
1006 1419 }
1007 1420
1008 1421 /**
@@ -1018,12 +1431,12 @@
1018 1431 // Get table name and escape it properly (table names cannot be parameterized)
1019 1432 $table_name = esc_sql($wpdb->prefix . 'thinkrank_content_briefs');
1020 1433 $user_id = get_current_user_id();
1021 1434
1022 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Content brief retrieval requires direct database access
1435 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Content brief retrieval requires direct database access
1023 1436 $results = $wpdb->get_results(
1024 1437 $wpdb->prepare(
1025 - // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is properly escaped using esc_sql()
1438 + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is properly escaped using esc_sql()
1026 1439 "SELECT * FROM `{$table_name}` WHERE user_id = %d ORDER BY created_at DESC LIMIT %d OFFSET %d",
1027 1440 $user_id,
1028 1441 $limit,
1029 1442 $offset
@@ -1030,30 +1443,85 @@
1030 1443 ),
1031 1444 ARRAY_A
1032 1445 );
1033 1446
1447 + // $wpdb->get_results() returns null on a DB error; this method's return
1448 + // type is : array, so normalize before iterating/returning.
1449 + if (!is_array($results)) {
1450 + return [];
1451 + }
1452 +
1034 1453 // Decode JSON data and normalize for React compatibility
1035 1454 foreach ($results as &$brief) {
1036 - $brief['target_keywords'] = json_decode($brief['target_keywords'], true);
1037 - $brief['brief_data'] = json_decode($brief['brief_data'], true);
1455 + $brief = $this->hydrate_brief_row($brief);
1456 + }
1457 + unset($brief);
1038 1458
1039 - // Retrieve raw response from ai_usage table
1040 - $brief['brief_data']['raw_response'] = $this->get_raw_response_for_brief($brief['id']);
1459 + return $results;
1460 + }
1041 1461
1042 - // Update model with actual model used (if available in ai_usage table)
1043 - $actual_model = $this->get_actual_model_for_brief($brief['id']);
1044 - if ($actual_model && isset($brief['brief_data']['generation_meta'])) {
1045 - $brief['brief_data']['generation_meta']['model'] = $actual_model;
1046 - }
1462 + /**
1463 + * Get a single saved brief by id, scoped to the current user.
1464 + *
1465 + * @param int $brief_id Brief ID.
1466 + * @return array|null Hydrated brief, or null if it doesn't exist or does not
1467 + * belong to the current user.
1468 + */
1469 + public function get_brief(int $brief_id): ?array {
1470 + global $wpdb;
1047 1471
1048 - // Apply normalization to existing briefs to ensure React compatibility
1049 - $brief['brief_data'] = $this->normalize_brief_data($brief['brief_data']);
1472 + // Table names cannot be parameterized; escape it.
1473 + $table_name = esc_sql($wpdb->prefix . 'thinkrank_content_briefs');
1474 + $user_id = get_current_user_id();
1475 +
1476 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Content brief retrieval requires direct database access
1477 + $brief = $wpdb->get_row(
1478 + $wpdb->prepare(
1479 + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is properly escaped using esc_sql()
1480 + "SELECT * FROM `{$table_name}` WHERE id = %d AND user_id = %d LIMIT 1",
1481 + $brief_id,
1482 + $user_id
1483 + ),
1484 + ARRAY_A
1485 + );
1486 +
1487 + if (!$brief) {
1488 + return null;
1050 1489 }
1051 1490
1052 - return $results;
1491 + return $this->hydrate_brief_row($brief);
1053 1492 }
1054 1493
1055 1494 /**
1495 + * Decode + normalize a raw content-brief DB row for API/React consumption.
1496 + *
1497 + * @param array $brief Raw database row.
1498 + * @return array Hydrated brief.
1499 + */
1500 + private function hydrate_brief_row(array $brief): array {
1501 + $brief['target_keywords'] = json_decode($brief['target_keywords'], true);
1502 + $brief['brief_data'] = json_decode($brief['brief_data'], true);
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 +
1508 + // Retrieve raw response from ai_usage table
1509 + $brief['brief_data']['raw_response'] = $this->get_raw_response_for_brief($brief_id);
1510 +
1511 + // Update model with actual model used (if available in ai_usage table)
1512 + $actual_model = $this->get_actual_model_for_brief($brief_id);
1513 + if ($actual_model && isset($brief['brief_data']['generation_meta'])) {
1514 + $brief['brief_data']['generation_meta']['model'] = $actual_model;
1515 + }
1516 +
1517 + // Apply normalization to existing briefs to ensure React compatibility
1518 + $brief['brief_data'] = $this->normalize_brief_data($brief['brief_data']);
1519 +
1520 + return $brief;
1521 + }
1522 +
1523 + /**
1056 1524 * Delete brief
1057 1525 *
1058 1526 * @param int $brief_id Brief ID to delete
1059 1527 * @return bool Success status
@@ -1063,9 +1531,9 @@
1063 1531
1064 1532 $table_name = $wpdb->prefix . 'thinkrank_content_briefs';
1065 1533 $user_id = get_current_user_id();
1066 1534
1067 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Content brief deletion requires direct database access
1535 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Content brief deletion requires direct database access
1068 1536 $result = $wpdb->delete(
1069 1537 $table_name,
1070 1538 [
1071 1539 'id' => $brief_id,
@@ -1089,9 +1557,10 @@
1089 1557 }
1090 1558
1091 1559 // Count sentences (approximate)
1092 1560 $sentences = preg_split('/[.!?]+/', $text);
1093 - $sentence_count = count(array_filter($sentences, function($s) { return trim($s) !== ''; }));
1561 + $sentence_count = count(array_filter($sentences, function($s) { return trim($s) !== '';
1562 +}));
1094 1563
1095 1564 // Count words
1096 1565 $word_count = str_word_count($text);
1097 1566
@@ -1168,9 +1637,9 @@
1168 1637 $vowels = 'aeiouy';
1169 1638 $syllable_count = 0;
1170 1639 $previous_was_vowel = false;
1171 1640
1172 - for ($i = 0; $i < strlen($word); $i++) {
1641 + for ($i = 0, $len = strlen($word); $i < $len; $i++) {
1173 1642 $is_vowel = strpos($vowels, $word[$i]) !== false;
1174 1643 if ($is_vowel && !$previous_was_vowel) {
1175 1644 $syllable_count++;
1176 1645 }
@@ -1441,9 +1910,10 @@
1441 1910 }
1442 1911
1443 1912 // Readability check
1444 1913 $sentences = preg_split('/[.!?]+/', $meta_desc);
1445 - $sentence_count = count(array_filter($sentences, function($s) { return trim($s) !== ''; }));
1914 + $sentence_count = count(array_filter($sentences, function($s) { return trim($s) !== '';
1915 +}));
1446 1916 if ($sentence_count >= 1 && $sentence_count <= 3) {
1447 1917 $score += 20;
1448 1918 $feedback[] = 'Good sentence structure';
1449 1919 } else {
@@ -1536,12 +2006,16 @@
1536 2006 * @param int $score Numeric score
1537 2007 * @return string Letter grade
1538 2008 */
1539 2009 private function get_grade_from_score(int $score): string {
1540 - if ($score >= 90) return 'A';
1541 - if ($score >= 80) return 'B';
1542 - if ($score >= 70) return 'C';
1543 - 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 + }
1544 2018 return 'F';
1545 2019 }
1546 2020
1547 2021 /**
@@ -1567,9 +2041,9 @@
1567 2041 if ($actual_model) {
1568 2042 $metadata['actual_model'] = $actual_model;
1569 2043 }
1570 2044
1571 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- AI usage logging requires direct database access
2045 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- AI usage logging requires direct database access
1572 2046 $wpdb->insert(
1573 2047 $table_name,
1574 2048 [
1575 2049 'user_id' => $user_id,
@@ -1574,9 +2048,9 @@
1574 2048 [
1575 2049 'user_id' => $user_id,
1576 2050 'action' => $action,
1577 2051 'tokens_used' => $tokens_used,
1578 - 'provider' => $this->settings->get('ai_provider', 'openai'),
2052 + 'provider' => $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE),
1579 2053 'post_id' => $post_id,
1580 2054 'metadata' => !empty($metadata) ? wp_json_encode($metadata) : null,
1581 2055 'created_at' => current_time('mysql'),
1582 2056 ],
@@ -1582,8 +2056,17 @@
1582 2056 ],
1583 2057 ['%d', '%s', '%d', '%s', '%d', '%s', '%s']
1584 2058 );
1585 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);
2068 +
1586 2069 return $wpdb->insert_id;
1587 2070 }
1588 2071
1589 2072 /**
@@ -1596,13 +2079,13 @@
1596 2079 global $wpdb;
1597 2080
1598 2081 $table_name = $wpdb->prefix . 'thinkrank_ai_usage';
1599 2082
1600 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- AI usage retrieval requires direct database access
2083 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- AI usage retrieval requires direct database access
1601 2084 $result = $wpdb->get_var(
1602 - // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is validated with WordPress prefix
2085 + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is validated with WordPress prefix
1603 2086 $wpdb->prepare(
1604 - // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is validated with WordPress prefix
2087 + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is validated with WordPress prefix
1605 2088 "SELECT metadata FROM `{$table_name}` WHERE post_id = %d AND action = 'content_brief' ORDER BY created_at DESC LIMIT 1",
1606 2089 $brief_id
1607 2090 )
1608 2091 );
@@ -1625,13 +2108,13 @@
1625 2108 global $wpdb;
1626 2109
1627 2110 $table_name = $wpdb->prefix . 'thinkrank_ai_usage';
1628 2111
1629 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- AI usage retrieval requires direct database access
2112 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- AI usage retrieval requires direct database access
1630 2113 $result = $wpdb->get_var(
1631 - // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is validated with WordPress prefix
2114 + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is validated with WordPress prefix
1632 2115 $wpdb->prepare(
1633 - // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is validated with WordPress prefix
2116 + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is validated with WordPress prefix
1634 2117 "SELECT metadata FROM `{$table_name}` WHERE post_id = %d AND action = 'content_brief' ORDER BY created_at DESC LIMIT 1",
1635 2118 $brief_id
1636 2119 )
1637 2120 );