PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 1.31.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v1.31.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 1.1.0 1.10.0 All 48 releases
thinkrank / includes / ai / class-content-brief-generator.php

class-content-brief-generator.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 1.31.0, at includes/ai/class-content-brief-generator.php

1,923 lines 76.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Content Brief Generator
4 *
5 * Handles AI-powered content brief generation with competitor analysis
6 *
7 * @package ThinkRank
8 * @subpackage AI
9 * @since 1.0.0
10 */
11
12 namespace ThinkRank\AI;
13
14 use ThinkRank\Core\Settings;
15 use ThinkRank\AI\OpenAI_Client;
16 use ThinkRank\AI\Claude_Client;
17 use ThinkRank\AI\OpenRouter_Client;
18
19 // Prevent direct access
20 if (!defined('ABSPATH')) {
21 exit;
22 }
23
24 /**
25 * Content Brief Generator class
26 */
27 class Content_Brief_Generator {
28
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 /**
78 * Settings instance
79 *
80 * @var Settings
81 */
82 private Settings $settings;
83
84 /**
85 * AI client instance
86 *
87 * Null when the generator was built for storage-only work.
88 *
89 * @var OpenAI_Client|Claude_Client|null
90 */
91 private $ai_client;
92
93 /**
94 * Constructor
95 *
96 * @param Settings|null $settings Settings instance
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.
101 */
102 public function __construct(?Settings $settings = null, $ai_client = null, bool $require_ai_client = true) {
103 $this->settings = $settings ?? Settings::instance();
104
105 if ($ai_client) {
106 $this->ai_client = $ai_client;
107
108 return;
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();
121 }
122
123 /**
124 * Initialize AI client based on available API keys
125 *
126 * @return void
127 *
128 * @throws \Exception On failure.
129 */
130 private function init_ai_client(): void {
131 $provider = $this->settings->get('ai_provider', 'openai');
132
133 if ($provider === 'openai') {
134 $api_key = $this->settings->get('openai_api_key');
135 if ($api_key) {
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);
138 }
139 } elseif ($provider === 'claude') {
140 $api_key = $this->settings->get('claude_api_key');
141 if ($api_key) {
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);
144 }
145 } elseif ($provider === 'gemini') {
146 $api_key = $this->settings->get('gemini_api_key');
147 if ($api_key) {
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);
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 }
158
159 if (!$this->ai_client) {
160 throw new \Exception('Please configure your AI provider API key in ThinkRank settings.');
161 }
162 }
163
164 /**
165 * Get current AI model being used
166 *
167 * @return string Current model name
168 */
169 private function get_current_model(): string {
170 // Try to get model from the actual AI client if available
171 if ($this->ai_client && method_exists($this->ai_client, 'get_model')) {
172 return $this->ai_client->get_model();
173 }
174
175 // Fallback to settings
176 $provider = $this->settings->get('ai_provider', 'openai');
177 if ($provider === 'claude') {
178 return $this->settings->get('claude_model', Settings::DEFAULT_CLAUDE_MODEL);
179 } elseif ($provider === 'gemini') {
180 return $this->settings->get('gemini_model', Settings::DEFAULT_GEMINI_MODEL);
181 } elseif ($provider === 'openrouter') {
182 return $this->settings->get('openrouter_model', Settings::DEFAULT_OPENROUTER_MODEL);
183 } else {
184 return $this->settings->get('openai_model', Settings::DEFAULT_OPENAI_MODEL);
185 }
186 }
187
188 /**
189 * Resolve the reasoning-effort level for a content-brief request.
190 *
191 * Without an explicit level, GPT-5 models run at their default (maximum)
192 * reasoning effort against a ~95% completion budget — the slowest and
193 * costliest configuration, where billed reasoning tokens (drawn from the
194 * same budget) are spent before any visible output (issue #286).
195 *
196 * A brief is a structured planning task, so 'low' is a provisional middle
197 * ground — Brand Visibility uses 'minimal' for quick consumer-style answers.
198 * The level is filterable so a site can trade latency for more reasoning;
199 * returning '' opts out entirely and lets the model use its default effort.
200 * Only the GPT-5 family consumes this — o1/o3, gpt-4o and the non-OpenAI
201 * clients ignore an unrecognised option key.
202 *
203 * @param string $model The resolved model ID (passed to the filter).
204 * @param array $params The brief generation parameters (passed to the filter).
205 * @return string One of 'minimal' | 'low' | 'medium' | 'high', or '' to opt out.
206 */
207 private function resolve_reasoning_effort(string $model, array $params): string {
208 /**
209 * Filter the reasoning-effort level used for content-brief generation.
210 *
211 * @param string $effort The default level ('low'). Return '' to opt out.
212 * @param string $model The resolved model ID for this request.
213 * @param array $params The brief generation parameters.
214 */
215 $effort = (string) apply_filters('thinkrank_content_brief_reasoning_effort', 'low', $model, $params);
216
217 // Only values OpenAI accepts may reach the request body ('' opts out).
218 // An unrecognised filter return (e.g. 'turbo') would otherwise be sent
219 // verbatim and fail the whole brief with a 400, so degrade to the
220 // documented default instead.
221 $allowed = ['', 'minimal', 'low', 'medium', 'high'];
222 return in_array($effort, $allowed, true) ? $effort : 'low';
223 }
224
225 /**
226 * Get current AI provider
227 *
228 * @return string Current provider name
229 */
230 private function get_current_provider(): string {
231 return $this->settings->get('ai_provider', 'openai');
232 }
233
234 /**
235 * Extract token usage from AI response
236 *
237 * @param array $ai_response AI response data
238 * @return int Number of tokens used
239 */
240 private function extract_token_usage(array $ai_response): int {
241 $provider = $this->get_current_provider();
242
243 if ($provider === 'openai' || $provider === 'openrouter') {
244 // OpenAI-compatible format: response['usage']['total_tokens']
245 return (int) ($ai_response['usage']['total_tokens'] ?? 0);
246 } elseif ($provider === 'claude') {
247 // Claude format: response['usage']['input_tokens'] + response['usage']['output_tokens']
248 $input_tokens = (int) ($ai_response['usage']['input_tokens'] ?? 0);
249 $output_tokens = (int) ($ai_response['usage']['output_tokens'] ?? 0);
250 return $input_tokens + $output_tokens;
251 } elseif ($provider === 'gemini') {
252 // Gemini format: response['usageMetadata']['totalTokenCount']
253 return (int) ($ai_response['usageMetadata']['totalTokenCount'] ?? 0);
254 }
255
256 // Fallback: return 0 if provider not recognized or no usage data
257 return 0;
258 }
259
260 /**
261 * Extract actual model used from AI response
262 *
263 * @param array $ai_response AI response data
264 * @return string|null Actual model used or null if not found
265 */
266 private function extract_model_from_response(array $ai_response): ?string {
267 // OpenAI format: response['model']
268 if (isset($ai_response['model'])) {
269 return $ai_response['model'];
270 }
271
272 // Claude format: response['model']
273 if (isset($ai_response['model'])) {
274 return $ai_response['model'];
275 }
276
277 // Gemini doesn't include model in response, fallback to client model
278 return null;
279 }
280
281 /**
282 * Generate content brief
283 *
284 * @param array $params Brief generation parameters
285 * @return array Generated brief data
286 * @throws \Exception If generation fails
287 */
288 public function generate_brief(array $params): array {
289 // Validate required parameters
290 $this->validate_brief_params($params);
291
292 // Extract parameters
293 $target_keywords = $params['target_keywords'] ?? [];
294 $content_type = $params['content_type'] ?? 'blog_post';
295 $target_audience = $params['target_audience'] ?? 'general';
296 $content_length = $params['content_length'] ?? 'medium';
297 $tone = $params['tone'] ?? 'professional';
298 $competitor_urls = $params['competitor_urls'] ?? [];
299 $additional_context = $params['additional_context'] ?? '';
300 // Write the brief in the site (or related post's) language rather than
301 // defaulting to English on non-English sites (issue #234).
302 $language = \ThinkRank\AI\Language_Resolver::resolve((int) ($params['post_id'] ?? 0));
303
304 // Analyze competitor URLs if provided
305 $competitor_analysis = '';
306 if (!empty($competitor_urls)) {
307 $competitor_analysis = $this->analyze_competitor_urls($competitor_urls);
308 }
309
310 // Build AI prompt using shared Prompt Builder
311 $prompt_builder = $this->get_prompt_builder();
312 $prompt = $prompt_builder->build_content_brief_prompt(
313 $target_keywords,
314 $content_type,
315 $target_audience,
316 $content_length,
317 $tone,
318 $competitor_analysis,
319 $additional_context,
320 $this->get_current_provider(),
321 $language
322 );
323
324 try {
325 // Get the model-aware budget for a comprehensive brief, then scale
326 // it to the requested content length so Short/Medium/Long actually
327 // request different budgets (issue #287). Every client (OpenAI,
328 // Claude, Gemini, OpenRouter) implements get_recommended_tokens(),
329 // so there is no model-blind fallback.
330 $base_tokens = (int) $this->ai_client->get_recommended_tokens('content_brief');
331 $max_tokens = $this->scale_tokens_for_length($base_tokens, $content_length);
332
333 // Bound hidden reasoning on the GPT-5 family (issue #286). See
334 // resolve_reasoning_effort(). Only the GPT-5 family reads this;
335 // o1/o3, gpt-4o and the non-OpenAI clients ignore the option, and
336 // an empty string opts out (model default effort).
337 $reasoning_effort = $this->resolve_reasoning_effort($this->get_current_model(), $params);
338
339 $completion_options = [
340 // For GPT‑5 family the client translates max_tokens to
341 // max_completion_tokens internally. Temperature is intentionally
342 // omitted: every client defaults it to 0.7, and reasoning models
343 // reject it outright, so passing it here was misleading no-op.
344 'max_tokens' => $max_tokens,
345 ];
346 if ('' !== $reasoning_effort) {
347 $completion_options['reasoning_effort'] = $reasoning_effort;
348 }
349
350 // Generate brief using AI
351 $ai_response = $this->ai_client->generate_completion($prompt, $completion_options);
352
353 // Detect a provider-side non-answer (refusal, policy block, or
354 // truncation) BEFORE attempting text extraction. Otherwise a
355 // refusal — which OpenAI returns as HTTP 200 with content=null —
356 // slips past every isset() branch and gets serialized into the
357 // brief body instead of being reported to the user.
358 $this->guard_against_non_answer($ai_response);
359
360 // Extract text content from AI response
361 $ai_text = '';
362
363 // Handle OpenAI response format
364 if (isset($ai_response['choices'][0]['message']['content'])) {
365 $content_field = $ai_response['choices'][0]['message']['content'];
366 if (is_string($content_field)) {
367 $ai_text = $content_field;
368 } elseif (is_array($content_field)) {
369 // Concatenate text parts from array-based content (Chat Completions multimodal)
370 $parts = array_map(function($part) {
371 if (is_array($part)) {
372 return $part['text'] ?? '';
373 }
374 return is_string($part) ? $part : '';
375 }, $content_field);
376 $ai_text = trim(implode("\n", array_filter($parts)));
377 }
378 }
379 // Handle Claude response format
380 elseif (isset($ai_response['content'][0]['text'])) {
381 $ai_text = $ai_response['content'][0]['text'];
382 }
383 // Handle Gemini response format
384 elseif (isset($ai_response['candidates'][0]['content']['parts'][0]['text'])) {
385 $ai_text = $ai_response['candidates'][0]['content']['parts'][0]['text'];
386 }
387 // Handle direct content field
388 elseif (isset($ai_response['content']) && is_string($ai_response['content'])) {
389 $ai_text = $ai_response['content'];
390 }
391 // Handle direct string response
392 elseif (is_string($ai_response)) {
393 $ai_text = $ai_response;
394 }
395 // No known provider shape matched and guard_against_non_answer()
396 // found nothing it recognised. Never serialize the raw envelope
397 // into the brief body — that turns a clear failure into a saved,
398 // meaningless brief. Log the shape for diagnostics and fail.
399 else {
400 if (defined('WP_DEBUG') && WP_DEBUG) {
401 $shape = is_array($ai_response) ? implode(', ', array_keys($ai_response)) : gettype($ai_response);
402 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Debug logging only when WP_DEBUG is enabled.
403 error_log('[ThinkRank] Content brief: unrecognised AI response shape. Top-level keys: ' . $shape);
404 }
405 throw new \Exception('The AI returned a response in an unexpected format. Please try again.');
406 }
407
408 // Ensure we have actual text content
409 if (empty(trim($ai_text))) {
410 throw new \Exception('AI response was empty or contained no text content.');
411 }
412
413 // Extract token usage for analytics tracking
414 $tokens_used = $this->extract_token_usage($ai_response);
415
416 // Parse and structure the response
417 $brief_data = $this->parse_ai_response($ai_text, $params);
418
419 // Extract actual model from response before using it
420 $actual_model = $this->extract_model_from_response($ai_response);
421
422 // Add generation metadata (use actual model from response if available)
423 $brief_data['generation_meta'] = [
424 'provider' => $this->get_current_provider(),
425 'model' => $actual_model ?: $this->get_current_model(),
426 'generated_at' => current_time('mysql'),
427 'version' => '1.0'
428 ];
429
430 // Save brief to database
431 $brief_id = $this->save_brief($brief_data);
432 $brief_data['id'] = $brief_id;
433
434 // Log AI usage for analytics tracking (including raw response and actual model used)
435 $usage_id = $this->log_ai_usage(get_current_user_id(), 'Content Brief', $tokens_used, $brief_id, $ai_text, $actual_model);
436
437 // Set raw response for immediate display
438 $brief_data['raw_response'] = $ai_text;
439
440 // Apply normalization for React compatibility
441 $brief_data = $this->normalize_brief_data($brief_data);
442
443 return $brief_data;
444
445 } catch (\Exception $e) {
446 // Provide more specific error messages
447 $error_message = $e->getMessage();
448
449 // Messages we authored for the user (refusals, policy blocks,
450 // token-limit truncation, unexpected shape) all start with "The AI "
451 // and are already actionable. Pass them through verbatim instead of
452 // flattening them via the substring matching below — e.g. so a
453 // refusal is not rewritten into generic "empty content" advice.
454 if (strpos($error_message, 'The AI ') === 0) {
455 throw new \Exception(esc_html($error_message));
456 }
457
458 if (strpos($error_message, 'API key') !== false) {
459 throw new \Exception('API key configuration error. Please check your AI provider settings.');
460 } elseif (strpos($error_message, 'Invalid AI response format') !== false) {
461 throw new \Exception('AI service returned an unexpected response format. Please try again.');
462 } elseif (strpos($error_message, 'empty') !== false) {
463 throw new \Exception('AI service returned empty content. Please try again with different parameters.');
464 } else {
465 throw new \Exception('Failed to generate content brief: ' . esc_html($error_message));
466 }
467 }
468 }
469
470 /**
471 * Scale the model-aware brief budget to the requested content length.
472 *
473 * get_recommended_tokens('content_brief') returns the budget for a full,
474 * comprehensive (Long) brief, already capped at the model's completion
475 * ceiling. Shorter tiers request proportionally less so that choosing Short
476 * is genuinely faster and cheaper (issue #287), while every tier stays at or
477 * below the base and at or above MIN_BRIEF_TOKENS so it cannot truncate.
478 *
479 * @param int $base_tokens Model-aware budget for a comprehensive brief.
480 * @param string $content_length One of 'short' | 'medium' | 'long'.
481 * @return int Scaled max_tokens, clamped to [floor, base_tokens].
482 */
483 private function scale_tokens_for_length(int $base_tokens, string $content_length): int {
484 // Unknown/missing length falls back to the medium tier — never to 0 or
485 // to the raw ceiling.
486 $multiplier = self::LENGTH_TOKEN_MULTIPLIERS[$content_length]
487 ?? self::LENGTH_TOKEN_MULTIPLIERS['medium'];
488
489 $scaled = (int) round($base_tokens * $multiplier);
490
491 // The floor can never exceed the base itself, so a model with a tiny
492 // ceiling still yields a sane, in-range value.
493 $floor = (int) min($base_tokens, self::MIN_BRIEF_TOKENS);
494
495 return max($floor, min($scaled, $base_tokens));
496 }
497
498 /**
499 * Detect a provider-side non-answer and fail with the real reason.
500 *
501 * A refusal, content-policy block, or token-limit truncation is not a
502 * usable brief. Each provider signals these differently, and none of the
503 * signals set the content field the extraction chain looks for — so if we
504 * don't catch them here they fall through to the "unexpected format" path
505 * (or, historically, were serialized into the brief body). All messages
506 * start with "The AI " so the outer catch passes them through unchanged.
507 *
508 * @param mixed $ai_response Raw response from the AI client.
509 * @throws \Exception If the response is a refusal, policy block, or truncation.
510 */
511 private function guard_against_non_answer($ai_response): void {
512 if (!is_array($ai_response)) {
513 return;
514 }
515
516 // --- OpenAI (Chat Completions) ---
517 // A structured refusal is HTTP 200 with message.content=null and the
518 // stated reason carried in message.refusal. finish_reason distinguishes
519 // a policy block from a truncated completion.
520 if (isset($ai_response['choices'][0]['message'])) {
521 $message = $ai_response['choices'][0]['message'];
522 $finish = (string) ($ai_response['choices'][0]['finish_reason'] ?? '');
523
524 if (!empty($message['refusal'])) {
525 throw new \Exception(sprintf(
526 'The AI declined to generate this brief: %s',
527 (string) $message['refusal']
528 ));
529 }
530 if ('content_filter' === $finish) {
531 throw new \Exception('The AI blocked this request under its content policy. Try a different topic or less sensitive keywords.');
532 }
533 if ('length' === $finish) {
534 throw new \Exception('The AI stopped at its output token limit before finishing the brief. Try a shorter content length or fewer competitor URLs.');
535 }
536 }
537
538 // --- Claude (Messages) ---
539 if (isset($ai_response['stop_reason'])) {
540 $stop_reason = (string) $ai_response['stop_reason'];
541 if ('refusal' === $stop_reason) {
542 throw new \Exception('The AI declined to generate this brief for this topic. Try a different topic or less sensitive keywords.');
543 }
544 if ('max_tokens' === $stop_reason) {
545 throw new \Exception('The AI stopped at its output token limit before finishing the brief. Try a shorter content length or fewer competitor URLs.');
546 }
547 }
548
549 // --- Gemini ---
550 // A prompt rejected outright returns no candidate at all, only
551 // promptFeedback.blockReason; a candidate can also finish on SAFETY or
552 // PROHIBITED_CONTENT, or be truncated at MAX_TOKENS.
553 $block_reason = (string) ($ai_response['promptFeedback']['blockReason'] ?? '');
554 if ('' !== $block_reason) {
555 throw new \Exception(sprintf(
556 'The AI blocked this request under its content policy (%s). Try a different topic or less sensitive keywords.',
557 $block_reason
558 ));
559 }
560 $gemini_finish = (string) ($ai_response['candidates'][0]['finishReason'] ?? '');
561 if (in_array($gemini_finish, ['SAFETY', 'PROHIBITED_CONTENT'], true)) {
562 throw new \Exception('The AI blocked this request under its content policy. Try a different topic or less sensitive keywords.');
563 }
564 if ('MAX_TOKENS' === $gemini_finish) {
565 throw new \Exception('The AI stopped at its output token limit before finishing the brief. Try a shorter content length or fewer competitor URLs.');
566 }
567 }
568
569 /**
570 * Validate brief generation parameters
571 *
572 * @param array $params Parameters to validate
573 * @throws \Exception If validation fails
574 */
575 private function validate_brief_params(array $params): void {
576 if (empty($params['target_keywords']) || !is_array($params['target_keywords'])) {
577 throw new \Exception('Target keywords are required and must be an array.');
578 }
579
580 $valid_content_types = ['blog_post', 'product_page', 'landing_page', 'tutorial'];
581 if (!empty($params['content_type']) && !in_array($params['content_type'], $valid_content_types, true)) {
582 throw new \Exception('Invalid content type specified.');
583 }
584
585 $valid_lengths = ['short', 'medium', 'long'];
586 if (!empty($params['content_length']) && !in_array($params['content_length'], $valid_lengths, true)) {
587 throw new \Exception('Invalid content length specified.');
588 }
589
590 $valid_tones = ['professional', 'casual', 'technical', 'friendly'];
591 if (!empty($params['tone']) && !in_array($params['tone'], $valid_tones, true)) {
592 throw new \Exception('Invalid tone specified.');
593 }
594 }
595
596 /**
597 * Parse AI response into structured data
598 *
599 * @param string $ai_response Raw AI response
600 * @param array $original_params Original generation parameters
601 * @return array Structured brief data
602 */
603 private function parse_ai_response(string $ai_response, array $original_params): array {
604 $json_data = $this->parse_json_response($ai_response);
605
606 if (null === $json_data) {
607 // JSON parsing failed - return error structure
608 return $this->create_parsing_error_response($ai_response, $original_params);
609 }
610
611 return $this->structure_json_data($json_data, $original_params);
612 }
613
614 /**
615 * Parse JSON response from AI
616 *
617 * @param string $ai_response Raw AI response
618 * @return array|null Parsed JSON data or null if parsing fails
619 */
620 private function parse_json_response(string $ai_response): ?array {
621 // Clean the response - remove any text before/after JSON
622 $ai_response = trim($ai_response);
623
624 // Handle markdown code blocks (```json ... ```)
625 if (preg_match('/```(?:json)?\s*\n?(.*?)\n?```/s', $ai_response, $matches)) {
626 $json_string = trim($matches[1]);
627 } else {
628 // Find JSON object boundaries
629 $start = strpos($ai_response, '{');
630 $end = strrpos($ai_response, '}');
631
632 if (false === $start || false === $end || $start >= $end) {
633 return null;
634 }
635
636 $json_string = substr($ai_response, $start, $end - $start + 1);
637 }
638
639 $json_data = json_decode($json_string, true);
640
641 if (json_last_error() !== JSON_ERROR_NONE) {
642 return null;
643 }
644
645 return $json_data;
646 }
647
648 /**
649 * Structure JSON data into expected format
650 *
651 * @param array $json_data Parsed JSON data
652 * @param array $original_params Original generation parameters
653 * @return array Structured brief data
654 */
655 private function structure_json_data(array $json_data, array $original_params): array {
656 return [
657 'title' => $json_data['title_suggestions'] ?? [],
658 'meta_description' => $json_data['meta_descriptions'][0] ?? '',
659 'meta_descriptions' => $json_data['meta_descriptions'] ?? [],
660 'url_slugs' => $json_data['url_slugs'] ?? [],
661 'outline' => $json_data['outline'] ?? [],
662 'seo_recommendations' => [
663 'title_suggestions' => $json_data['title_suggestions'] ?? [],
664 'meta_description' => $json_data['meta_descriptions'][0] ?? '',
665 'meta_descriptions' => $json_data['meta_descriptions'] ?? [],
666 'url_slugs' => $json_data['url_slugs'] ?? [],
667 'focus_keyword_analysis' => $this->normalize_focus_keyword_analysis($json_data['focus_keyword_analysis'] ?? []),
668 'internal_links' => $json_data['internal_linking'] ?? [],
669 'related_keywords' => $json_data['related_keywords'] ?? [],
670 'long_tail_keywords' => []
671 ],
672 'social_media' => $json_data['social_media'] ?? [
673 'open_graph' => ['title' => '', 'description' => ''],
674 'twitter_card' => ['title' => '', 'description' => '']
675 ],
676 'schema_markup' => $json_data['schema_markup'] ?? [
677 'recommended_types' => [],
678 'key_properties' => [],
679 'faq_questions' => []
680 ],
681 'visual_content' => $json_data['visual_content'] ?? [
682 'image_recommendations' => [],
683 'alt_text_suggestions' => [],
684 'infographic_opportunities' => []
685 ],
686 'competitor_gaps' => $json_data['competitor_analysis']['content_gaps'] ?? [],
687 'call_to_actions' => $json_data['call_to_actions'] ?? [],
688 'writing_guidelines' => $json_data['writing_guidelines'] ?? [],
689 'content_body' => $json_data['content_body'] ?? '',
690 'estimated_word_count' => $this->get_word_count_estimate($original_params['content_length'] ?? 'medium'),
691 'raw_response' => '', // Will be retrieved from ai_usage table
692 'generation_params' => $original_params,
693 'parsing_status' => 'success',
694 'created_at' => current_time('mysql')
695 ];
696 }
697
698 /**
699 * Create error response when JSON parsing fails
700 *
701 * @param string $ai_response Raw AI response
702 * @param array $original_params Original generation parameters
703 * @return array Error response structure
704 */
705 private function create_parsing_error_response(string $ai_response, array $original_params): array {
706 return [
707 'title' => ['Error: Unable to parse AI response'],
708 'meta_description' => 'AI response could not be parsed as valid JSON.',
709 'meta_descriptions' => ['AI response could not be parsed as valid JSON.'],
710 'url_slugs' => ['error-parsing-response'],
711 'outline' => [],
712 'seo_recommendations' => [
713 'title_suggestions' => ['Error: Unable to parse AI response'],
714 'meta_description' => 'AI response could not be parsed as valid JSON.',
715 'meta_descriptions' => ['AI response could not be parsed as valid JSON.'],
716 'url_slugs' => ['error-parsing-response'],
717 'focus_keyword_analysis' => [
718 'primary_placement' => [],
719 'secondary_integration' => [],
720 'density_guidelines' => []
721 ],
722 'internal_links' => [],
723 'related_keywords' => [],
724 'long_tail_keywords' => []
725 ],
726 'social_media' => [
727 'open_graph' => ['title' => 'Error', 'description' => 'Parsing failed'],
728 'twitter_card' => ['title' => 'Error', 'description' => 'Parsing failed']
729 ],
730 'schema_markup' => [
731 'recommended_types' => [],
732 'key_properties' => [],
733 'faq_questions' => []
734 ],
735 'visual_content' => [
736 'image_recommendations' => [],
737 'alt_text_suggestions' => [],
738 'infographic_opportunities' => []
739 ],
740 'competitor_gaps' => [],
741 'call_to_actions' => [],
742 'writing_guidelines' => [],
743 'content_body' => '',
744 'estimated_word_count' => $this->get_word_count_estimate($original_params['content_length'] ?? 'medium'),
745 'raw_response' => $ai_response, // Store raw response in error case
746 'generation_params' => $original_params,
747 'parsing_status' => 'failed',
748 'created_at' => current_time('mysql')
749 ];
750 }
751
752 /**
753 * Normalize focus keyword analysis to ensure proper array structure
754 *
755 * @param array $focus_keyword_analysis Raw focus keyword analysis data
756 * @return array Normalized focus keyword analysis
757 */
758 private function normalize_focus_keyword_analysis(array $focus_keyword_analysis): array {
759 $normalized = [
760 'primary_placement' => [],
761 'secondary_integration' => [],
762 'density_guidelines' => []
763 ];
764
765 // Normalize primary_placement
766 if (isset($focus_keyword_analysis['primary_placement'])) {
767 if (is_array($focus_keyword_analysis['primary_placement'])) {
768 $normalized['primary_placement'] = $focus_keyword_analysis['primary_placement'];
769 } elseif (is_string($focus_keyword_analysis['primary_placement'])) {
770 // Convert string to array by splitting on common delimiters
771 $normalized['primary_placement'] = array_filter(array_map('trim', preg_split('/[,;]/', $focus_keyword_analysis['primary_placement'])));
772 }
773 }
774
775 // Normalize secondary_integration - this is the problematic field
776 if (isset($focus_keyword_analysis['secondary_integration'])) {
777 if (is_array($focus_keyword_analysis['secondary_integration'])) {
778 $normalized['secondary_integration'] = $focus_keyword_analysis['secondary_integration'];
779 } elseif (is_string($focus_keyword_analysis['secondary_integration'])) {
780 // Convert string to array - split by sentences or use as single item
781 $text = trim($focus_keyword_analysis['secondary_integration']);
782 if (!empty($text)) {
783 // Split by sentences if it contains periods, otherwise use as single item
784 if (strpos($text, '.') !== false) {
785 $sentences = array_filter(array_map('trim', explode('.', $text)));
786 $normalized['secondary_integration'] = array_map(function($sentence) {
787 return $sentence . (substr($sentence, -1) !== '.' ? '.' : '');
788 }, $sentences);
789 } else {
790 $normalized['secondary_integration'] = [$text];
791 }
792 }
793 }
794 }
795
796 // Normalize density_guidelines
797 if (isset($focus_keyword_analysis['density_guidelines'])) {
798 if (is_array($focus_keyword_analysis['density_guidelines'])) {
799 $normalized['density_guidelines'] = $focus_keyword_analysis['density_guidelines'];
800 } elseif (is_string($focus_keyword_analysis['density_guidelines'])) {
801 // Convert string to array by splitting on common delimiters
802 $guidelines = array_filter(array_map('trim', preg_split('/[,;]/', $focus_keyword_analysis['density_guidelines'])));
803 $normalized['density_guidelines'] = $guidelines ?: [$focus_keyword_analysis['density_guidelines']];
804 }
805 }
806
807 return $normalized;
808 }
809 private function analyze_competitor_urls(array $urls): string {
810 $analysis_results = [];
811 $failed_urls = [];
812
813 // Limit to first 3 URLs to prevent timeout
814 $urls = array_slice($urls, 0, 3);
815
816 foreach ($urls as $url) {
817 $url = trim($url);
818 if (empty($url) || !filter_var($url, FILTER_VALIDATE_URL)) {
819 $failed_urls[] = $url . " (invalid URL)";
820 continue;
821 }
822
823 // SSRF guard: only fetch public http/https hosts. Blocks loopback,
824 // link-local (cloud metadata), private and reserved ranges before any
825 // request is made.
826 if (!$this->is_safe_public_url($url)) {
827 $failed_urls[] = $url . " (blocked: non-public host)";
828 continue;
829 }
830
831 $content_data = $this->scrape_competitor_content($url);
832 if ($content_data) {
833 $analysis_results[] = $this->format_competitor_analysis($url, $content_data);
834 } else {
835 $failed_urls[] = $url . " (failed to scrape)";
836 }
837 }
838
839 $result = "";
840
841 if (!empty($analysis_results)) {
842 $result .= implode("\n\n", $analysis_results);
843 }
844
845 if (!empty($failed_urls)) {
846 $result .= "\n\nNote: The following URLs could not be analyzed:\n";
847 $result .= "- " . implode("\n- ", $failed_urls);
848 }
849
850 if (empty($analysis_results)) {
851 return "No competitor URLs could be successfully analyzed. Please ensure URLs are accessible and valid.";
852 }
853
854 return $result;
855 }
856
857 /**
858 * Whether a competitor URL is safe to fetch: an http/https URL whose host
859 * resolves only to public IP addresses.
860 *
861 * Prevents SSRF — a low-privilege user could otherwise point competitor
862 * scraping at loopback, link-local (e.g. 169.254.169.254 cloud metadata),
863 * CGNAT, private or reserved addresses to probe internal services. The
864 * block list lives in {@see \ThinkRank\Core\Url_Safety} so this and the
865 * schema importer can never drift apart.
866 *
867 * @param string $url URL to validate.
868 * @return bool True when safe to fetch.
869 */
870 private function is_safe_public_url(string $url): bool {
871 return \ThinkRank\Core\Url_Safety::is_safe_public_url($url);
872 }
873
874 /**
875 * Scrape content from a competitor URL
876 *
877 * @param string $url The URL to scrape
878 * @return array|null Content data or null if failed
879 */
880 private function scrape_competitor_content(string $url): ?array {
881 // Url_Safety::safe_remote_get() follows redirects manually and re-checks
882 // the resolved host on every hop, so a target that redirects to (or
883 // rebinds onto) an internal address after the pre-flight check is
884 // refused rather than fetched.
885 $response = \ThinkRank\Core\Url_Safety::safe_remote_get($url, [
886 'timeout' => 8, // Reduced from 15 to 8 seconds
887 'user-agent' => 'Mozilla/5.0 (compatible; ThinkRank SEO Bot)',
888 'headers' => [
889 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
890 'Accept-Language' => 'en-US,en;q=0.5',
891 ]
892 ]);
893
894 if (is_wp_error($response)) {
895 return null;
896 }
897
898 $status_code = wp_remote_retrieve_response_code($response);
899 if ($status_code !== 200) {
900 return null;
901 }
902
903 $html = wp_remote_retrieve_body($response);
904 if (empty($html)) {
905 return null;
906 }
907
908 return $this->parse_html_content($html, $url);
909 }
910
911 /**
912 * Parse HTML content and extract key SEO elements
913 *
914 * @param string $html HTML content
915 * @param string $url Original URL for context
916 * @return array Parsed content data
917 */
918 private function parse_html_content(string $html, string $url): array {
919 // Create DOMDocument to parse HTML
920 $dom = new \DOMDocument();
921
922 // Suppress warnings for malformed HTML
923 libxml_use_internal_errors(true);
924 $dom->loadHTML('<?xml encoding="UTF-8">' . $html);
925 libxml_clear_errors();
926
927 $xpath = new \DOMXPath($dom);
928
929 // Extract title
930 $title_nodes = $xpath->query('//title');
931 $title = $title_nodes->length > 0 ? trim($title_nodes->item(0)->textContent) : '';
932
933 // Extract meta description
934 $meta_desc_nodes = $xpath->query('//meta[@name="description"]/@content');
935 $meta_description = $meta_desc_nodes->length > 0 ? trim($meta_desc_nodes->item(0)->textContent) : '';
936
937 // Extract headings (H1-H6)
938 $headings = [];
939 for ($i = 1; $i <= 6; $i++) {
940 $heading_nodes = $xpath->query("//h{$i}");
941 foreach ($heading_nodes as $node) {
942 $text = trim($node->textContent);
943 if (!empty($text)) {
944 $headings["h{$i}"][] = $text;
945 }
946 }
947 }
948
949 // Extract body text and calculate word count
950 $body_nodes = $xpath->query('//body');
951 $body_text = '';
952 if ($body_nodes->length > 0) {
953 $body_text = $this->extract_clean_text($body_nodes->item(0));
954 }
955
956 $word_count = str_word_count($body_text);
957
958 // Extract meta keywords if present
959 $meta_keywords_nodes = $xpath->query('//meta[@name="keywords"]/@content');
960 $meta_keywords = $meta_keywords_nodes->length > 0 ? trim($meta_keywords_nodes->item(0)->textContent) : '';
961
962 // Extract internal links count
963 $internal_links = $xpath->query('//a[starts-with(@href, "/") or contains(@href, "' . wp_parse_url($url, PHP_URL_HOST) . '")]');
964 $internal_link_count = $internal_links->length;
965
966 // Extract external links count
967 $external_links = $xpath->query('//a[starts-with(@href, "http") and not(contains(@href, "' . wp_parse_url($url, PHP_URL_HOST) . '"))]');
968 $external_link_count = $external_links->length;
969
970 // Extract images count and alt text analysis
971 $images = $xpath->query('//img');
972 $image_count = $images->length;
973 $images_with_alt = $xpath->query('//img[@alt and @alt!=""]');
974 $images_with_alt_count = $images_with_alt->length;
975
976 // Extract schema markup
977 $schema_scripts = $xpath->query('//script[@type="application/ld+json"]');
978 $has_schema = $schema_scripts->length > 0;
979
980 // Extract last modified date if available
981 $last_modified_nodes = $xpath->query('//meta[@name="last-modified"]/@content | //meta[@property="article:modified_time"]/@content');
982 $last_modified = $last_modified_nodes->length > 0 ? $last_modified_nodes->item(0)->textContent : '';
983
984 // Calculate readability metrics
985 $readability_score = $this->calculate_readability_score($body_text);
986
987 // Extract keyword density for target keywords (if provided)
988 $keyword_density = $this->analyze_keyword_density($body_text, $title);
989
990 // Detect content freshness indicators
991 $freshness_indicators = $this->detect_freshness_indicators($html, $body_text);
992
993 return [
994 'url' => $url,
995 'title' => $title,
996 'meta_description' => $meta_description,
997 'meta_keywords' => $meta_keywords,
998 'headings' => $headings,
999 'word_count' => $word_count,
1000 'internal_links' => $internal_link_count,
1001 'external_links' => $external_link_count,
1002 'images' => [
1003 'total' => $image_count,
1004 'with_alt' => $images_with_alt_count,
1005 'alt_ratio' => $image_count > 0 ? round(($images_with_alt_count / $image_count) * 100, 1) : 0
1006 ],
1007 'seo' => [
1008 'has_schema' => $has_schema,
1009 'title_length' => strlen($title),
1010 'meta_desc_length' => strlen($meta_description),
1011 'title_score' => $this->score_title_seo($title),
1012 'meta_desc_score' => $this->score_meta_description($meta_description)
1013 ],
1014 'content_quality' => [
1015 'readability_score' => $readability_score,
1016 'keyword_density' => $keyword_density,
1017 'freshness_indicators' => $freshness_indicators,
1018 'content_depth' => $this->assess_content_depth($headings, $word_count)
1019 ],
1020 'last_modified' => $last_modified,
1021 'content_preview' => substr($body_text, 0, 500) . '...',
1022 'analysis_timestamp' => current_time('mysql')
1023 ];
1024 }
1025
1026 /**
1027 * Extract clean text from DOM node, removing scripts and styles
1028 *
1029 * @param \DOMNode $node DOM node to extract text from
1030 * @return string Clean text content
1031 */
1032 private function extract_clean_text(\DOMNode $node): string {
1033 // Remove script and style elements
1034 $xpath = new \DOMXPath($node->ownerDocument);
1035 $scripts = $xpath->query('.//script | .//style', $node);
1036
1037 foreach ($scripts as $script) {
1038 $script->parentNode->removeChild($script);
1039 }
1040
1041 // Get text content and clean it up
1042 $text = $node->textContent;
1043
1044 // Remove extra whitespace and normalize
1045 $text = preg_replace('/\s+/', ' ', $text);
1046 $text = trim($text);
1047
1048 return $text;
1049 }
1050
1051 /**
1052 * Format competitor analysis for AI prompt
1053 *
1054 * @param string $url Competitor URL
1055 * @param array $content_data Parsed content data
1056 * @return string Formatted analysis
1057 */
1058 private function format_competitor_analysis(string $url, array $content_data): string {
1059 $analysis = "=== COMPETITOR ANALYSIS ===\n";
1060 $analysis .= "URL: {$url}\n";
1061 $analysis .= "Title: {$content_data['title']} (Length: {$content_data['seo']['title_length']} chars, Score: {$content_data['seo']['title_score']['grade']})\n";
1062
1063 if (!empty($content_data['meta_description'])) {
1064 $analysis .= "Meta Description: {$content_data['meta_description']} (Length: {$content_data['seo']['meta_desc_length']} chars, Score: {$content_data['seo']['meta_desc_score']['grade']})\n";
1065 }
1066
1067 $analysis .= "\nCONTENT METRICS:\n";
1068 $analysis .= "- Word Count: {$content_data['word_count']} words\n";
1069 $analysis .= "- Content Depth: {$content_data['content_quality']['content_depth']['level']} (Score: {$content_data['content_quality']['content_depth']['score']}/100)\n";
1070 $analysis .= "- Readability: {$content_data['content_quality']['readability_score']['level']} (Score: {$content_data['content_quality']['readability_score']['score']}/100)\n";
1071 $analysis .= "- Internal Links: {$content_data['internal_links']}\n";
1072 $analysis .= "- External Links: {$content_data['external_links']}\n";
1073 $analysis .= "- Images: {$content_data['images']['total']} total, {$content_data['images']['with_alt']} with alt text ({$content_data['images']['alt_ratio']}%)\n";
1074
1075 // Add heading structure
1076 if (!empty($content_data['headings'])) {
1077 $analysis .= "\nCONTENT STRUCTURE:\n";
1078 foreach ($content_data['headings'] as $level => $headings) {
1079 $analysis .= "- " . strtoupper($level) . " ({count}): " . implode(', ', array_slice($headings, 0, 3));
1080 if (count($headings) > 3) {
1081 $analysis .= "... (+" . (count($headings) - 3) . " more)";
1082 }
1083 $analysis .= "\n";
1084 }
1085 }
1086
1087 // Add SEO features
1088 $analysis .= "\nSEO FEATURES:\n";
1089 $analysis .= "- Schema Markup: " . ($content_data['seo']['has_schema'] ? 'Yes' : 'No') . "\n";
1090 if (!empty($content_data['meta_keywords'])) {
1091 $analysis .= "- Meta Keywords: {$content_data['meta_keywords']}\n";
1092 }
1093
1094 // Add content quality insights
1095 if (!empty($content_data['content_quality']['keyword_density']['top_keywords'])) {
1096 $analysis .= "\nTOP KEYWORDS:\n";
1097 foreach (array_slice($content_data['content_quality']['keyword_density']['top_keywords'], 0, 5) as $kw) {
1098 $analysis .= "- {$kw['keyword']}: {$kw['count']} times ({$kw['density']}%)\n";
1099 }
1100 }
1101
1102 // Add freshness indicators
1103 if (!empty($content_data['content_quality']['freshness_indicators'])) {
1104 $analysis .= "\nCONTENT FRESHNESS:\n";
1105 foreach ($content_data['content_quality']['freshness_indicators'] as $indicator) {
1106 $analysis .= "- {$indicator}\n";
1107 }
1108 }
1109
1110 $analysis .= "\n" . str_repeat("=", 50) . "\n";
1111
1112 return $analysis;
1113 }
1114
1115 /**
1116 * Get word count estimate based on content length
1117 *
1118 * @param string $content_length Content length setting
1119 * @return int Estimated word count
1120 */
1121 private function get_word_count_estimate(string $content_length): int {
1122 $estimates = [
1123 'short' => 650,
1124 'medium' => 1250,
1125 'long' => 2500
1126 ];
1127
1128 return $estimates[$content_length] ?? 1250;
1129 }
1130 private function save_brief(array $brief_data): int {
1131 global $wpdb;
1132
1133 $table_name = $wpdb->prefix . 'thinkrank_content_briefs';
1134
1135 // Prepare data for insertion
1136 $insert_data = [
1137 'user_id' => get_current_user_id(),
1138 'title' => $brief_data['title'][0] ?? 'Untitled Brief',
1139 'target_keywords' => wp_json_encode($brief_data['generation_params']['target_keywords'] ?? []),
1140 'content_type' => $brief_data['generation_params']['content_type'] ?? 'blog_post',
1141 'brief_data' => wp_json_encode($brief_data),
1142 'created_at' => current_time('mysql'),
1143 'updated_at' => current_time('mysql')
1144 ];
1145
1146 $insert_format = [
1147 '%d', // user_id
1148 '%s', // title
1149 '%s', // target_keywords
1150 '%s', // content_type
1151 '%s', // brief_data
1152 '%s', // created_at
1153 '%s' // updated_at
1154 ];
1155
1156 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Content brief storage requires direct database access
1157 $result = $wpdb->insert($table_name, $insert_data, $insert_format);
1158
1159 if (false === $result) {
1160 throw new \Exception('Failed to save content brief to database.');
1161 }
1162
1163 return $wpdb->insert_id;
1164 }
1165
1166 /**
1167 * Normalize brief data for React compatibility
1168 *
1169 * @param array $brief_data Brief data to normalize
1170 * @return array Normalized brief data
1171 */
1172 private function normalize_brief_data(array $brief_data): array {
1173 // Normalize focus_keyword_analysis
1174 if (isset($brief_data['seo_recommendations']['focus_keyword_analysis'])) {
1175 $brief_data['seo_recommendations']['focus_keyword_analysis'] =
1176 $this->normalize_focus_keyword_analysis($brief_data['seo_recommendations']['focus_keyword_analysis']);
1177 }
1178
1179 // Normalize call_to_actions (convert objects to strings)
1180 if (isset($brief_data['call_to_actions']) && is_array($brief_data['call_to_actions'])) {
1181 $brief_data['call_to_actions'] = array_map(function($cta) {
1182 if (is_array($cta) && isset($cta['text'])) {
1183 return $cta['text'] . (isset($cta['placement']) ? ' (' . $cta['placement'] . ')' : '');
1184 }
1185 return is_string($cta) ? $cta : '';
1186 }, $brief_data['call_to_actions']);
1187 }
1188
1189 // Normalize visual content image_recommendations (convert objects to strings)
1190 if (isset($brief_data['visual_content']['image_recommendations']) && is_array($brief_data['visual_content']['image_recommendations'])) {
1191 $brief_data['visual_content']['image_recommendations'] = array_map(function($rec) {
1192 if (is_array($rec)) {
1193 $text = '';
1194 if (isset($rec['type'])) { $text .= $rec['type'] . ': ';
1195 }
1196 if (isset($rec['description'])) { $text .= $rec['description'];
1197 }
1198 if (isset($rec['alt_text'])) { $text .= ' (Alt: ' . $rec['alt_text'] . ')';
1199 }
1200 return $text ?: 'Image recommendation';
1201 }
1202 return is_string($rec) ? $rec : 'Image recommendation';
1203 }, $brief_data['visual_content']['image_recommendations']);
1204 }
1205
1206 return $brief_data;
1207 }
1208
1209 /**
1210 * Get saved briefs for current user
1211 *
1212 * @param int $limit Number of briefs to retrieve
1213 * @param int $offset Offset for pagination
1214 * @return array Array of saved briefs
1215 */
1216 public function get_user_briefs(int $limit = 10, int $offset = 0): array {
1217 global $wpdb;
1218
1219 // Get table name and escape it properly (table names cannot be parameterized)
1220 $table_name = esc_sql($wpdb->prefix . 'thinkrank_content_briefs');
1221 $user_id = get_current_user_id();
1222
1223 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Content brief retrieval requires direct database access
1224 $results = $wpdb->get_results(
1225 $wpdb->prepare(
1226 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is properly escaped using esc_sql()
1227 "SELECT * FROM `{$table_name}` WHERE user_id = %d ORDER BY created_at DESC LIMIT %d OFFSET %d",
1228 $user_id,
1229 $limit,
1230 $offset
1231 ),
1232 ARRAY_A
1233 );
1234
1235 // $wpdb->get_results() returns null on a DB error; this method's return
1236 // type is : array, so normalize before iterating/returning.
1237 if (!is_array($results)) {
1238 return [];
1239 }
1240
1241 // Decode JSON data and normalize for React compatibility
1242 foreach ($results as &$brief) {
1243 $brief = $this->hydrate_brief_row($brief);
1244 }
1245 unset($brief);
1246
1247 return $results;
1248 }
1249
1250 /**
1251 * Get a single saved brief by id, scoped to the current user.
1252 *
1253 * @param int $brief_id Brief ID.
1254 * @return array|null Hydrated brief, or null if it doesn't exist or does not
1255 * belong to the current user.
1256 */
1257 public function get_brief(int $brief_id): ?array {
1258 global $wpdb;
1259
1260 // Table names cannot be parameterized; escape it.
1261 $table_name = esc_sql($wpdb->prefix . 'thinkrank_content_briefs');
1262 $user_id = get_current_user_id();
1263
1264 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Content brief retrieval requires direct database access
1265 $brief = $wpdb->get_row(
1266 $wpdb->prepare(
1267 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is properly escaped using esc_sql()
1268 "SELECT * FROM `{$table_name}` WHERE id = %d AND user_id = %d LIMIT 1",
1269 $brief_id,
1270 $user_id
1271 ),
1272 ARRAY_A
1273 );
1274
1275 if (!$brief) {
1276 return null;
1277 }
1278
1279 return $this->hydrate_brief_row($brief);
1280 }
1281
1282 /**
1283 * Decode + normalize a raw content-brief DB row for API/React consumption.
1284 *
1285 * @param array $brief Raw database row.
1286 * @return array Hydrated brief.
1287 */
1288 private function hydrate_brief_row(array $brief): array {
1289 $brief['target_keywords'] = json_decode($brief['target_keywords'], true);
1290 $brief['brief_data'] = json_decode($brief['brief_data'], true);
1291
1292 // Cast: the row comes from $wpdb, which returns every column as a
1293 // string, and both helpers declare an int parameter.
1294 $brief_id = (int) $brief['id'];
1295
1296 // Retrieve raw response from ai_usage table
1297 $brief['brief_data']['raw_response'] = $this->get_raw_response_for_brief($brief_id);
1298
1299 // Update model with actual model used (if available in ai_usage table)
1300 $actual_model = $this->get_actual_model_for_brief($brief_id);
1301 if ($actual_model && isset($brief['brief_data']['generation_meta'])) {
1302 $brief['brief_data']['generation_meta']['model'] = $actual_model;
1303 }
1304
1305 // Apply normalization to existing briefs to ensure React compatibility
1306 $brief['brief_data'] = $this->normalize_brief_data($brief['brief_data']);
1307
1308 return $brief;
1309 }
1310
1311 /**
1312 * Delete brief
1313 *
1314 * @param int $brief_id Brief ID to delete
1315 * @return bool Success status
1316 */
1317 public function delete_brief(int $brief_id): bool {
1318 global $wpdb;
1319
1320 $table_name = $wpdb->prefix . 'thinkrank_content_briefs';
1321 $user_id = get_current_user_id();
1322
1323 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Content brief deletion requires direct database access
1324 $result = $wpdb->delete(
1325 $table_name,
1326 [
1327 'id' => $brief_id,
1328 'user_id' => $user_id
1329 ],
1330 ['%d', '%d']
1331 );
1332
1333 return $result !== false;
1334 }
1335
1336 /**
1337 * Calculate readability score using Flesch Reading Ease
1338 *
1339 * @param string $text Text to analyze
1340 * @return array Readability metrics
1341 */
1342 private function calculate_readability_score(string $text): array {
1343 if (empty($text)) {
1344 return ['score' => 0, 'level' => 'Unknown', 'grade' => 'N/A'];
1345 }
1346
1347 // Count sentences (approximate)
1348 $sentences = preg_split('/[.!?]+/', $text);
1349 $sentence_count = count(array_filter($sentences, function($s) { return trim($s) !== '';
1350 }));
1351
1352 // Count words
1353 $word_count = str_word_count($text);
1354
1355 // Count syllables (approximate)
1356 $syllable_count = $this->count_syllables($text);
1357
1358 if ($sentence_count === 0 || $word_count === 0) {
1359 return ['score' => 0, 'level' => 'Unknown', 'grade' => 'N/A'];
1360 }
1361
1362 // Flesch Reading Ease formula
1363 $avg_sentence_length = $word_count / $sentence_count;
1364 $avg_syllables_per_word = $syllable_count / $word_count;
1365
1366 $flesch_score = 206.835 - (1.015 * $avg_sentence_length) - (84.6 * $avg_syllables_per_word);
1367 $flesch_score = max(0, min(100, $flesch_score)); // Clamp between 0-100
1368
1369 // Determine reading level
1370 if ($flesch_score >= 90) {
1371 $level = 'Very Easy';
1372 $grade = '5th grade';
1373 } elseif ($flesch_score >= 80) {
1374 $level = 'Easy';
1375 $grade = '6th grade';
1376 } elseif ($flesch_score >= 70) {
1377 $level = 'Fairly Easy';
1378 $grade = '7th grade';
1379 } elseif ($flesch_score >= 60) {
1380 $level = 'Standard';
1381 $grade = '8th-9th grade';
1382 } elseif ($flesch_score >= 50) {
1383 $level = 'Fairly Difficult';
1384 $grade = '10th-12th grade';
1385 } elseif ($flesch_score >= 30) {
1386 $level = 'Difficult';
1387 $grade = 'College level';
1388 } else {
1389 $level = 'Very Difficult';
1390 $grade = 'Graduate level';
1391 }
1392
1393 return [
1394 'score' => round($flesch_score, 1),
1395 'level' => $level,
1396 'grade' => $grade
1397 ];
1398 }
1399
1400 /**
1401 * Count syllables in text (approximate)
1402 *
1403 * @param string $text Text to analyze
1404 * @return int Syllable count
1405 */
1406 private function count_syllables(string $text): int {
1407 $words = str_word_count(strtolower($text), 1);
1408 $syllable_count = 0;
1409
1410 foreach ($words as $word) {
1411 $syllable_count += $this->count_word_syllables($word);
1412 }
1413
1414 return max(1, $syllable_count); // At least 1 syllable
1415 }
1416
1417 /**
1418 * Count syllables in a single word
1419 *
1420 * @param string $word Word to analyze
1421 * @return int Syllable count
1422 */
1423 private function count_word_syllables(string $word): int {
1424 $word = strtolower($word);
1425 $vowels = 'aeiouy';
1426 $syllable_count = 0;
1427 $previous_was_vowel = false;
1428
1429 for ($i = 0, $len = strlen($word); $i < $len; $i++) {
1430 $is_vowel = strpos($vowels, $word[$i]) !== false;
1431 if ($is_vowel && !$previous_was_vowel) {
1432 $syllable_count++;
1433 }
1434 $previous_was_vowel = $is_vowel;
1435 }
1436
1437 // Handle silent 'e'
1438 if (substr($word, -1) === 'e' && $syllable_count > 1) {
1439 $syllable_count--;
1440 }
1441
1442 return max(1, $syllable_count);
1443 }
1444
1445 /**
1446 * Analyze keyword density in content
1447 *
1448 * @param string $text Content text
1449 * @param string $title Page title
1450 * @return array Keyword analysis
1451 */
1452 private function analyze_keyword_density(string $text, string $title): array {
1453 $combined_text = strtolower($title . ' ' . $text);
1454 $words = str_word_count($combined_text, 1);
1455 $total_words = count($words);
1456
1457 if ($total_words === 0) {
1458 return ['top_keywords' => [], 'total_words' => 0];
1459 }
1460
1461 // Count word frequency
1462 $word_counts = array_count_values($words);
1463
1464 // Filter out common stop words
1465 $stop_words = ['the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', 'of', 'with', 'by', 'is', 'are', 'was', 'were', 'be', 'been', 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could', 'should', 'may', 'might', 'must', 'can', 'this', 'that', 'these', 'those', 'i', 'you', 'he', 'she', 'it', 'we', 'they', 'me', 'him', 'her', 'us', 'them'];
1466
1467 foreach ($stop_words as $stop_word) {
1468 unset($word_counts[$stop_word]);
1469 }
1470
1471 // Filter out single characters and numbers
1472 $word_counts = array_filter($word_counts, function($count, $word) {
1473 return strlen($word) > 2 && !is_numeric($word) && $count > 1;
1474 }, ARRAY_FILTER_USE_BOTH);
1475
1476 // Sort by frequency
1477 arsort($word_counts);
1478
1479 // Calculate density and format results
1480 $top_keywords = [];
1481 foreach (array_slice($word_counts, 0, 10, true) as $word => $count) {
1482 $density = round(($count / $total_words) * 100, 2);
1483 $top_keywords[] = [
1484 'keyword' => $word,
1485 'count' => $count,
1486 'density' => $density
1487 ];
1488 }
1489
1490 return [
1491 'top_keywords' => $top_keywords,
1492 'total_words' => $total_words
1493 ];
1494 }
1495
1496 /**
1497 * Detect content freshness indicators
1498 *
1499 * @param string $html Full HTML content
1500 * @param string $text Body text
1501 * @return array Freshness indicators
1502 */
1503 private function detect_freshness_indicators(string $html, string $text): array {
1504 $indicators = [];
1505
1506 // Check for date patterns in content
1507 if (preg_match('/\b(updated|revised|modified|published).*?(\d{4}|\d{1,2}\/\d{1,2}\/\d{2,4})/i', $text)) {
1508 $indicators[] = 'Contains recent update dates';
1509 }
1510
1511 // Check for current year references
1512 $current_year = gmdate('Y');
1513 if (strpos($text, $current_year) !== false) {
1514 $indicators[] = "References current year ({$current_year})";
1515 }
1516
1517 // Check for "latest", "new", "recent" keywords
1518 if (preg_match('/\b(latest|newest|recent|updated|current|modern|today)\b/i', $text)) {
1519 $indicators[] = 'Uses freshness keywords';
1520 }
1521
1522 // Check for structured data with dates
1523 if (preg_match('/"dateModified"|"datePublished"/i', $html)) {
1524 $indicators[] = 'Has structured date metadata';
1525 }
1526
1527 return $indicators;
1528 }
1529
1530 /**
1531 * Score title for SEO effectiveness
1532 *
1533 * @param string $title Page title
1534 * @return array Title scoring
1535 */
1536 private function score_title_seo(string $title): array {
1537 $score = 0;
1538 $max_score = 100;
1539 $feedback = [];
1540
1541 // Length check (optimal: 50-60 characters)
1542 $length = strlen($title);
1543 if ($length >= 50 && $length <= 60) {
1544 $score += 25;
1545 $feedback[] = 'Good length (50-60 chars)';
1546 } elseif ($length >= 40 && $length <= 70) {
1547 $score += 15;
1548 $feedback[] = 'Acceptable length';
1549 } else {
1550 $feedback[] = $length < 40 ? 'Too short (under 40 chars)' : 'Too long (over 70 chars)';
1551 }
1552
1553 // Word count (optimal: 5-9 words)
1554 $word_count = str_word_count($title);
1555 if ($word_count >= 5 && $word_count <= 9) {
1556 $score += 20;
1557 $feedback[] = 'Good word count';
1558 } elseif ($word_count >= 3 && $word_count <= 12) {
1559 $score += 10;
1560 $feedback[] = 'Acceptable word count';
1561 } else {
1562 $feedback[] = $word_count < 3 ? 'Too few words' : 'Too many words';
1563 }
1564
1565 // Check for power words
1566 $power_words = ['ultimate', 'complete', 'guide', 'best', 'top', 'essential', 'proven', 'expert', 'advanced', 'beginner'];
1567 $has_power_words = false;
1568 foreach ($power_words as $power_word) {
1569 if (stripos($title, $power_word) !== false) {
1570 $has_power_words = true;
1571 break;
1572 }
1573 }
1574 if ($has_power_words) {
1575 $score += 15;
1576 $feedback[] = 'Contains power words';
1577 }
1578
1579 // Check for numbers
1580 if (preg_match('/\d+/', $title)) {
1581 $score += 10;
1582 $feedback[] = 'Contains numbers';
1583 }
1584
1585 // Check for emotional triggers
1586 $emotional_words = ['amazing', 'incredible', 'shocking', 'secret', 'revealed', 'proven', 'guaranteed'];
1587 $has_emotional_words = false;
1588 foreach ($emotional_words as $emotional_word) {
1589 if (stripos($title, $emotional_word) !== false) {
1590 $has_emotional_words = true;
1591 break;
1592 }
1593 }
1594 if ($has_emotional_words) {
1595 $score += 10;
1596 $feedback[] = 'Contains emotional triggers';
1597 }
1598
1599 // Uniqueness check (avoid generic titles)
1600 $generic_patterns = ['untitled', 'new page', 'home', 'welcome'];
1601 $is_generic = false;
1602 foreach ($generic_patterns as $pattern) {
1603 if (stripos($title, $pattern) !== false) {
1604 $is_generic = true;
1605 break;
1606 }
1607 }
1608 if (!$is_generic) {
1609 $score += 20;
1610 $feedback[] = 'Appears unique';
1611 } else {
1612 $feedback[] = 'Appears generic';
1613 }
1614
1615 return [
1616 'score' => min($score, $max_score),
1617 'max_score' => $max_score,
1618 'grade' => $this->get_grade_from_score($score),
1619 'feedback' => $feedback
1620 ];
1621 }
1622
1623 /**
1624 * Score meta description for SEO effectiveness
1625 *
1626 * @param string $meta_desc Meta description
1627 * @return array Meta description scoring
1628 */
1629 private function score_meta_description(string $meta_desc): array {
1630 $score = 0;
1631 $max_score = 100;
1632 $feedback = [];
1633
1634 if (empty($meta_desc)) {
1635 return [
1636 'score' => 0,
1637 'max_score' => $max_score,
1638 'grade' => 'F',
1639 'feedback' => ['No meta description found']
1640 ];
1641 }
1642
1643 // Length check (optimal: 150-160 characters)
1644 $length = strlen($meta_desc);
1645 if ($length >= 150 && $length <= 160) {
1646 $score += 30;
1647 $feedback[] = 'Optimal length (150-160 chars)';
1648 } elseif ($length >= 120 && $length <= 170) {
1649 $score += 20;
1650 $feedback[] = 'Good length';
1651 } elseif ($length >= 100 && $length <= 180) {
1652 $score += 10;
1653 $feedback[] = 'Acceptable length';
1654 } else {
1655 $feedback[] = $length < 100 ? 'Too short (under 100 chars)' : 'Too long (over 180 chars)';
1656 }
1657
1658 // Check for call-to-action
1659 $cta_words = ['learn', 'discover', 'find out', 'get', 'download', 'try', 'start', 'join', 'sign up', 'contact', 'buy', 'shop'];
1660 $has_cta = false;
1661 foreach ($cta_words as $cta_word) {
1662 if (stripos($meta_desc, $cta_word) !== false) {
1663 $has_cta = true;
1664 break;
1665 }
1666 }
1667 if ($has_cta) {
1668 $score += 20;
1669 $feedback[] = 'Contains call-to-action';
1670 }
1671
1672 // Check for unique selling proposition
1673 $usp_words = ['best', 'top', 'leading', 'expert', 'professional', 'trusted', 'proven', 'award-winning'];
1674 $has_usp = false;
1675 foreach ($usp_words as $usp_word) {
1676 if (stripos($meta_desc, $usp_word) !== false) {
1677 $has_usp = true;
1678 break;
1679 }
1680 }
1681 if ($has_usp) {
1682 $score += 15;
1683 $feedback[] = 'Contains unique selling proposition';
1684 }
1685
1686 // Check for benefits/value proposition
1687 $benefit_words = ['save', 'improve', 'increase', 'boost', 'enhance', 'optimize', 'maximize', 'reduce', 'eliminate'];
1688 $has_benefits = false;
1689 foreach ($benefit_words as $benefit_word) {
1690 if (stripos($meta_desc, $benefit_word) !== false) {
1691 $has_benefits = true;
1692 break;
1693 }
1694 }
1695 if ($has_benefits) {
1696 $score += 15;
1697 $feedback[] = 'Highlights benefits';
1698 }
1699
1700 // Readability check
1701 $sentences = preg_split('/[.!?]+/', $meta_desc);
1702 $sentence_count = count(array_filter($sentences, function($s) { return trim($s) !== '';
1703 }));
1704 if ($sentence_count >= 1 && $sentence_count <= 3) {
1705 $score += 20;
1706 $feedback[] = 'Good sentence structure';
1707 } else {
1708 $feedback[] = $sentence_count === 0 ? 'No clear sentences' : 'Too many sentences';
1709 }
1710
1711 return [
1712 'score' => min($score, $max_score),
1713 'max_score' => $max_score,
1714 'grade' => $this->get_grade_from_score($score),
1715 'feedback' => $feedback
1716 ];
1717 }
1718
1719 /**
1720 * Assess content depth based on structure and length
1721 *
1722 * @param array $headings Heading structure
1723 * @param int $word_count Word count
1724 * @return array Content depth assessment
1725 */
1726 private function assess_content_depth(array $headings, int $word_count): array {
1727 $depth_score = 0;
1728 $max_score = 100;
1729
1730 // Word count scoring (more words = more depth)
1731 if ($word_count >= 2000) {
1732 $depth_score += 40;
1733 } elseif ($word_count >= 1000) {
1734 $depth_score += 30;
1735 } elseif ($word_count >= 500) {
1736 $depth_score += 20;
1737 } elseif ($word_count >= 300) {
1738 $depth_score += 10;
1739 }
1740
1741 // Heading structure scoring
1742 $total_headings = 0;
1743 $heading_levels = 0;
1744 foreach ($headings as $level => $level_headings) {
1745 $total_headings += count($level_headings);
1746 $heading_levels++;
1747 }
1748
1749 if ($total_headings >= 10) {
1750 $depth_score += 25;
1751 } elseif ($total_headings >= 5) {
1752 $depth_score += 15;
1753 } elseif ($total_headings >= 3) {
1754 $depth_score += 10;
1755 }
1756
1757 // Heading hierarchy scoring
1758 if ($heading_levels >= 3) {
1759 $depth_score += 20;
1760 } elseif ($heading_levels >= 2) {
1761 $depth_score += 15;
1762 }
1763
1764 // Content structure bonus
1765 if (isset($headings['h1']) && isset($headings['h2'])) {
1766 $depth_score += 15;
1767 }
1768
1769 // Determine depth level
1770 if ($depth_score >= 80) {
1771 $level = 'Comprehensive';
1772 } elseif ($depth_score >= 60) {
1773 $level = 'Detailed';
1774 } elseif ($depth_score >= 40) {
1775 $level = 'Moderate';
1776 } elseif ($depth_score >= 20) {
1777 $level = 'Basic';
1778 } else {
1779 $level = 'Shallow';
1780 }
1781
1782 return [
1783 'score' => min($depth_score, $max_score),
1784 'level' => $level,
1785 'word_count' => $word_count,
1786 'total_headings' => $total_headings,
1787 'heading_levels' => $heading_levels
1788 ];
1789 }
1790
1791 /**
1792 * Convert numeric score to letter grade
1793 *
1794 * @param int $score Numeric score
1795 * @return string Letter grade
1796 */
1797 private function get_grade_from_score(int $score): string {
1798 if ($score >= 90) { return 'A';
1799 }
1800 if ($score >= 80) { return 'B';
1801 }
1802 if ($score >= 70) { return 'C';
1803 }
1804 if ($score >= 60) { return 'D';
1805 }
1806 return 'F';
1807 }
1808
1809 /**
1810 * Log AI usage for analytics
1811 *
1812 * @param int $user_id User ID
1813 * @param string $action Action performed
1814 * @param int $tokens_used Tokens consumed
1815 * @param int|null $post_id Related post/brief ID
1816 * @param string|null $raw_response Raw AI response for debugging
1817 * @param string|null $actual_model Actual model used (from response)
1818 * @return int Usage record ID
1819 */
1820 private function log_ai_usage(int $user_id, string $action, int $tokens_used, ?int $post_id = null, ?string $raw_response = null, ?string $actual_model = null): int {
1821 global $wpdb;
1822
1823 $table_name = $wpdb->prefix . 'thinkrank_ai_usage';
1824
1825 $metadata = [];
1826 if ($raw_response) {
1827 $metadata['raw_response'] = $raw_response;
1828 }
1829 if ($actual_model) {
1830 $metadata['actual_model'] = $actual_model;
1831 }
1832
1833 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- AI usage logging requires direct database access
1834 $wpdb->insert(
1835 $table_name,
1836 [
1837 'user_id' => $user_id,
1838 'action' => $action,
1839 'tokens_used' => $tokens_used,
1840 'provider' => $this->settings->get('ai_provider', 'openai'),
1841 'post_id' => $post_id,
1842 'metadata' => !empty($metadata) ? wp_json_encode($metadata) : null,
1843 'created_at' => current_time('mysql'),
1844 ],
1845 ['%d', '%s', '%d', '%s', '%d', '%s', '%s']
1846 );
1847
1848 return $wpdb->insert_id;
1849 }
1850
1851 /**
1852 * Get raw AI response for a brief from ai_usage table
1853 *
1854 * @param int $brief_id Brief ID
1855 * @return string Raw AI response or empty string if not found
1856 */
1857 private function get_raw_response_for_brief(int $brief_id): string {
1858 global $wpdb;
1859
1860 $table_name = $wpdb->prefix . 'thinkrank_ai_usage';
1861
1862 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- AI usage retrieval requires direct database access
1863 $result = $wpdb->get_var(
1864 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is validated with WordPress prefix
1865 $wpdb->prepare(
1866 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is validated with WordPress prefix
1867 "SELECT metadata FROM `{$table_name}` WHERE post_id = %d AND action = 'content_brief' ORDER BY created_at DESC LIMIT 1",
1868 $brief_id
1869 )
1870 );
1871
1872 if ($result) {
1873 $metadata = json_decode($result, true);
1874 return $metadata['raw_response'] ?? '';
1875 }
1876
1877 return '';
1878 }
1879
1880 /**
1881 * Get actual model used for a brief from ai_usage table
1882 *
1883 * @param int $brief_id Brief ID
1884 * @return string|null Actual model used or null if not found
1885 */
1886 private function get_actual_model_for_brief(int $brief_id): ?string {
1887 global $wpdb;
1888
1889 $table_name = $wpdb->prefix . 'thinkrank_ai_usage';
1890
1891 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- AI usage retrieval requires direct database access
1892 $result = $wpdb->get_var(
1893 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is validated with WordPress prefix
1894 $wpdb->prepare(
1895 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is validated with WordPress prefix
1896 "SELECT metadata FROM `{$table_name}` WHERE post_id = %d AND action = 'content_brief' ORDER BY created_at DESC LIMIT 1",
1897 $brief_id
1898 )
1899 );
1900
1901 if ($result) {
1902 $metadata = json_decode($result, true);
1903 return $metadata['actual_model'] ?? null;
1904 }
1905
1906 return null;
1907 }
1908
1909 /**
1910 * Get Prompt Builder instance
1911 *
1912 * @since 1.0.0
1913 *
1914 * @return \ThinkRank\AI\Prompt_Builder Prompt Builder instance
1915 */
1916 private function get_prompt_builder(): \ThinkRank\AI\Prompt_Builder {
1917 if (!class_exists('ThinkRank\\AI\\Prompt_Builder')) {
1918 require_once THINKRANK_PLUGIN_DIR . 'includes/ai/class-prompt-builder.php';
1919 }
1920 return new \ThinkRank\AI\Prompt_Builder();
1921 }
1922 }
1923