PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 1.29.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v1.29.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.29.0, at includes/ai/class-content-brief-generator.php

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