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

1,969 lines 78.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 $this->sanitize_brief_output($brief_data);
1207 }
1208
1209 /**
1210 * Strip untrusted markup out of brief fields before they leave the server.
1211 *
1212 * Brief content crosses a trust boundary: it is assembled by an external AI
1213 * provider from prompts that can include text fetched from competitor URLs.
1214 * It was previously copied out of the decoded JSON verbatim and rendered in
1215 * the admin SPA through dangerouslySetInnerHTML, so a malicious or
1216 * prompt-injected response could execute script in the admin origin (#365).
1217 *
1218 * Runs on the read path as well as generation, so briefs stored before this
1219 * fix are sanitized when they are loaded.
1220 *
1221 * @since 1.32.0
1222 *
1223 * @param array $brief_data Brief data to sanitize.
1224 * @return array Sanitized brief data.
1225 */
1226 private function sanitize_brief_output(array $brief_data): array {
1227 foreach ($brief_data as $key => $value) {
1228 // The raw provider response is debug output shown as plain text, and
1229 // the generation params are our own values — leave both intact.
1230 if ('raw_response' === $key || 'generation_params' === $key) {
1231 continue;
1232 }
1233
1234 if ('content_body' === $key && is_string($value)) {
1235 // Deliberately HTML: it is the drafted article and is rendered as
1236 // markup. wp_kses_post() keeps normal post formatting while
1237 // dropping script/style/iframe, event-handler attributes and
1238 // javascript: URLs.
1239 $brief_data[$key] = wp_kses_post($value);
1240 continue;
1241 }
1242
1243 if (is_array($value)) {
1244 $brief_data[$key] = $this->sanitize_brief_output($value);
1245 } elseif (is_string($value)) {
1246 // Every other field is plain text (headings, keywords, guidance).
1247 // Markdown emphasis markers are preserved; HTML tags are not.
1248 $brief_data[$key] = wp_strip_all_tags($value);
1249 }
1250 }
1251
1252 return $brief_data;
1253 }
1254
1255 /**
1256 * Get saved briefs for current user
1257 *
1258 * @param int $limit Number of briefs to retrieve
1259 * @param int $offset Offset for pagination
1260 * @return array Array of saved briefs
1261 */
1262 public function get_user_briefs(int $limit = 10, int $offset = 0): array {
1263 global $wpdb;
1264
1265 // Get table name and escape it properly (table names cannot be parameterized)
1266 $table_name = esc_sql($wpdb->prefix . 'thinkrank_content_briefs');
1267 $user_id = get_current_user_id();
1268
1269 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Content brief retrieval requires direct database access
1270 $results = $wpdb->get_results(
1271 $wpdb->prepare(
1272 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is properly escaped using esc_sql()
1273 "SELECT * FROM `{$table_name}` WHERE user_id = %d ORDER BY created_at DESC LIMIT %d OFFSET %d",
1274 $user_id,
1275 $limit,
1276 $offset
1277 ),
1278 ARRAY_A
1279 );
1280
1281 // $wpdb->get_results() returns null on a DB error; this method's return
1282 // type is : array, so normalize before iterating/returning.
1283 if (!is_array($results)) {
1284 return [];
1285 }
1286
1287 // Decode JSON data and normalize for React compatibility
1288 foreach ($results as &$brief) {
1289 $brief = $this->hydrate_brief_row($brief);
1290 }
1291 unset($brief);
1292
1293 return $results;
1294 }
1295
1296 /**
1297 * Get a single saved brief by id, scoped to the current user.
1298 *
1299 * @param int $brief_id Brief ID.
1300 * @return array|null Hydrated brief, or null if it doesn't exist or does not
1301 * belong to the current user.
1302 */
1303 public function get_brief(int $brief_id): ?array {
1304 global $wpdb;
1305
1306 // Table names cannot be parameterized; escape it.
1307 $table_name = esc_sql($wpdb->prefix . 'thinkrank_content_briefs');
1308 $user_id = get_current_user_id();
1309
1310 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Content brief retrieval requires direct database access
1311 $brief = $wpdb->get_row(
1312 $wpdb->prepare(
1313 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is properly escaped using esc_sql()
1314 "SELECT * FROM `{$table_name}` WHERE id = %d AND user_id = %d LIMIT 1",
1315 $brief_id,
1316 $user_id
1317 ),
1318 ARRAY_A
1319 );
1320
1321 if (!$brief) {
1322 return null;
1323 }
1324
1325 return $this->hydrate_brief_row($brief);
1326 }
1327
1328 /**
1329 * Decode + normalize a raw content-brief DB row for API/React consumption.
1330 *
1331 * @param array $brief Raw database row.
1332 * @return array Hydrated brief.
1333 */
1334 private function hydrate_brief_row(array $brief): array {
1335 $brief['target_keywords'] = json_decode($brief['target_keywords'], true);
1336 $brief['brief_data'] = json_decode($brief['brief_data'], true);
1337
1338 // Cast: the row comes from $wpdb, which returns every column as a
1339 // string, and both helpers declare an int parameter.
1340 $brief_id = (int) $brief['id'];
1341
1342 // Retrieve raw response from ai_usage table
1343 $brief['brief_data']['raw_response'] = $this->get_raw_response_for_brief($brief_id);
1344
1345 // Update model with actual model used (if available in ai_usage table)
1346 $actual_model = $this->get_actual_model_for_brief($brief_id);
1347 if ($actual_model && isset($brief['brief_data']['generation_meta'])) {
1348 $brief['brief_data']['generation_meta']['model'] = $actual_model;
1349 }
1350
1351 // Apply normalization to existing briefs to ensure React compatibility
1352 $brief['brief_data'] = $this->normalize_brief_data($brief['brief_data']);
1353
1354 return $brief;
1355 }
1356
1357 /**
1358 * Delete brief
1359 *
1360 * @param int $brief_id Brief ID to delete
1361 * @return bool Success status
1362 */
1363 public function delete_brief(int $brief_id): bool {
1364 global $wpdb;
1365
1366 $table_name = $wpdb->prefix . 'thinkrank_content_briefs';
1367 $user_id = get_current_user_id();
1368
1369 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Content brief deletion requires direct database access
1370 $result = $wpdb->delete(
1371 $table_name,
1372 [
1373 'id' => $brief_id,
1374 'user_id' => $user_id
1375 ],
1376 ['%d', '%d']
1377 );
1378
1379 return $result !== false;
1380 }
1381
1382 /**
1383 * Calculate readability score using Flesch Reading Ease
1384 *
1385 * @param string $text Text to analyze
1386 * @return array Readability metrics
1387 */
1388 private function calculate_readability_score(string $text): array {
1389 if (empty($text)) {
1390 return ['score' => 0, 'level' => 'Unknown', 'grade' => 'N/A'];
1391 }
1392
1393 // Count sentences (approximate)
1394 $sentences = preg_split('/[.!?]+/', $text);
1395 $sentence_count = count(array_filter($sentences, function($s) { return trim($s) !== '';
1396 }));
1397
1398 // Count words
1399 $word_count = str_word_count($text);
1400
1401 // Count syllables (approximate)
1402 $syllable_count = $this->count_syllables($text);
1403
1404 if ($sentence_count === 0 || $word_count === 0) {
1405 return ['score' => 0, 'level' => 'Unknown', 'grade' => 'N/A'];
1406 }
1407
1408 // Flesch Reading Ease formula
1409 $avg_sentence_length = $word_count / $sentence_count;
1410 $avg_syllables_per_word = $syllable_count / $word_count;
1411
1412 $flesch_score = 206.835 - (1.015 * $avg_sentence_length) - (84.6 * $avg_syllables_per_word);
1413 $flesch_score = max(0, min(100, $flesch_score)); // Clamp between 0-100
1414
1415 // Determine reading level
1416 if ($flesch_score >= 90) {
1417 $level = 'Very Easy';
1418 $grade = '5th grade';
1419 } elseif ($flesch_score >= 80) {
1420 $level = 'Easy';
1421 $grade = '6th grade';
1422 } elseif ($flesch_score >= 70) {
1423 $level = 'Fairly Easy';
1424 $grade = '7th grade';
1425 } elseif ($flesch_score >= 60) {
1426 $level = 'Standard';
1427 $grade = '8th-9th grade';
1428 } elseif ($flesch_score >= 50) {
1429 $level = 'Fairly Difficult';
1430 $grade = '10th-12th grade';
1431 } elseif ($flesch_score >= 30) {
1432 $level = 'Difficult';
1433 $grade = 'College level';
1434 } else {
1435 $level = 'Very Difficult';
1436 $grade = 'Graduate level';
1437 }
1438
1439 return [
1440 'score' => round($flesch_score, 1),
1441 'level' => $level,
1442 'grade' => $grade
1443 ];
1444 }
1445
1446 /**
1447 * Count syllables in text (approximate)
1448 *
1449 * @param string $text Text to analyze
1450 * @return int Syllable count
1451 */
1452 private function count_syllables(string $text): int {
1453 $words = str_word_count(strtolower($text), 1);
1454 $syllable_count = 0;
1455
1456 foreach ($words as $word) {
1457 $syllable_count += $this->count_word_syllables($word);
1458 }
1459
1460 return max(1, $syllable_count); // At least 1 syllable
1461 }
1462
1463 /**
1464 * Count syllables in a single word
1465 *
1466 * @param string $word Word to analyze
1467 * @return int Syllable count
1468 */
1469 private function count_word_syllables(string $word): int {
1470 $word = strtolower($word);
1471 $vowels = 'aeiouy';
1472 $syllable_count = 0;
1473 $previous_was_vowel = false;
1474
1475 for ($i = 0, $len = strlen($word); $i < $len; $i++) {
1476 $is_vowel = strpos($vowels, $word[$i]) !== false;
1477 if ($is_vowel && !$previous_was_vowel) {
1478 $syllable_count++;
1479 }
1480 $previous_was_vowel = $is_vowel;
1481 }
1482
1483 // Handle silent 'e'
1484 if (substr($word, -1) === 'e' && $syllable_count > 1) {
1485 $syllable_count--;
1486 }
1487
1488 return max(1, $syllable_count);
1489 }
1490
1491 /**
1492 * Analyze keyword density in content
1493 *
1494 * @param string $text Content text
1495 * @param string $title Page title
1496 * @return array Keyword analysis
1497 */
1498 private function analyze_keyword_density(string $text, string $title): array {
1499 $combined_text = strtolower($title . ' ' . $text);
1500 $words = str_word_count($combined_text, 1);
1501 $total_words = count($words);
1502
1503 if ($total_words === 0) {
1504 return ['top_keywords' => [], 'total_words' => 0];
1505 }
1506
1507 // Count word frequency
1508 $word_counts = array_count_values($words);
1509
1510 // Filter out common stop words
1511 $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'];
1512
1513 foreach ($stop_words as $stop_word) {
1514 unset($word_counts[$stop_word]);
1515 }
1516
1517 // Filter out single characters and numbers
1518 $word_counts = array_filter($word_counts, function($count, $word) {
1519 return strlen($word) > 2 && !is_numeric($word) && $count > 1;
1520 }, ARRAY_FILTER_USE_BOTH);
1521
1522 // Sort by frequency
1523 arsort($word_counts);
1524
1525 // Calculate density and format results
1526 $top_keywords = [];
1527 foreach (array_slice($word_counts, 0, 10, true) as $word => $count) {
1528 $density = round(($count / $total_words) * 100, 2);
1529 $top_keywords[] = [
1530 'keyword' => $word,
1531 'count' => $count,
1532 'density' => $density
1533 ];
1534 }
1535
1536 return [
1537 'top_keywords' => $top_keywords,
1538 'total_words' => $total_words
1539 ];
1540 }
1541
1542 /**
1543 * Detect content freshness indicators
1544 *
1545 * @param string $html Full HTML content
1546 * @param string $text Body text
1547 * @return array Freshness indicators
1548 */
1549 private function detect_freshness_indicators(string $html, string $text): array {
1550 $indicators = [];
1551
1552 // Check for date patterns in content
1553 if (preg_match('/\b(updated|revised|modified|published).*?(\d{4}|\d{1,2}\/\d{1,2}\/\d{2,4})/i', $text)) {
1554 $indicators[] = 'Contains recent update dates';
1555 }
1556
1557 // Check for current year references
1558 $current_year = gmdate('Y');
1559 if (strpos($text, $current_year) !== false) {
1560 $indicators[] = "References current year ({$current_year})";
1561 }
1562
1563 // Check for "latest", "new", "recent" keywords
1564 if (preg_match('/\b(latest|newest|recent|updated|current|modern|today)\b/i', $text)) {
1565 $indicators[] = 'Uses freshness keywords';
1566 }
1567
1568 // Check for structured data with dates
1569 if (preg_match('/"dateModified"|"datePublished"/i', $html)) {
1570 $indicators[] = 'Has structured date metadata';
1571 }
1572
1573 return $indicators;
1574 }
1575
1576 /**
1577 * Score title for SEO effectiveness
1578 *
1579 * @param string $title Page title
1580 * @return array Title scoring
1581 */
1582 private function score_title_seo(string $title): array {
1583 $score = 0;
1584 $max_score = 100;
1585 $feedback = [];
1586
1587 // Length check (optimal: 50-60 characters)
1588 $length = strlen($title);
1589 if ($length >= 50 && $length <= 60) {
1590 $score += 25;
1591 $feedback[] = 'Good length (50-60 chars)';
1592 } elseif ($length >= 40 && $length <= 70) {
1593 $score += 15;
1594 $feedback[] = 'Acceptable length';
1595 } else {
1596 $feedback[] = $length < 40 ? 'Too short (under 40 chars)' : 'Too long (over 70 chars)';
1597 }
1598
1599 // Word count (optimal: 5-9 words)
1600 $word_count = str_word_count($title);
1601 if ($word_count >= 5 && $word_count <= 9) {
1602 $score += 20;
1603 $feedback[] = 'Good word count';
1604 } elseif ($word_count >= 3 && $word_count <= 12) {
1605 $score += 10;
1606 $feedback[] = 'Acceptable word count';
1607 } else {
1608 $feedback[] = $word_count < 3 ? 'Too few words' : 'Too many words';
1609 }
1610
1611 // Check for power words
1612 $power_words = ['ultimate', 'complete', 'guide', 'best', 'top', 'essential', 'proven', 'expert', 'advanced', 'beginner'];
1613 $has_power_words = false;
1614 foreach ($power_words as $power_word) {
1615 if (stripos($title, $power_word) !== false) {
1616 $has_power_words = true;
1617 break;
1618 }
1619 }
1620 if ($has_power_words) {
1621 $score += 15;
1622 $feedback[] = 'Contains power words';
1623 }
1624
1625 // Check for numbers
1626 if (preg_match('/\d+/', $title)) {
1627 $score += 10;
1628 $feedback[] = 'Contains numbers';
1629 }
1630
1631 // Check for emotional triggers
1632 $emotional_words = ['amazing', 'incredible', 'shocking', 'secret', 'revealed', 'proven', 'guaranteed'];
1633 $has_emotional_words = false;
1634 foreach ($emotional_words as $emotional_word) {
1635 if (stripos($title, $emotional_word) !== false) {
1636 $has_emotional_words = true;
1637 break;
1638 }
1639 }
1640 if ($has_emotional_words) {
1641 $score += 10;
1642 $feedback[] = 'Contains emotional triggers';
1643 }
1644
1645 // Uniqueness check (avoid generic titles)
1646 $generic_patterns = ['untitled', 'new page', 'home', 'welcome'];
1647 $is_generic = false;
1648 foreach ($generic_patterns as $pattern) {
1649 if (stripos($title, $pattern) !== false) {
1650 $is_generic = true;
1651 break;
1652 }
1653 }
1654 if (!$is_generic) {
1655 $score += 20;
1656 $feedback[] = 'Appears unique';
1657 } else {
1658 $feedback[] = 'Appears generic';
1659 }
1660
1661 return [
1662 'score' => min($score, $max_score),
1663 'max_score' => $max_score,
1664 'grade' => $this->get_grade_from_score($score),
1665 'feedback' => $feedback
1666 ];
1667 }
1668
1669 /**
1670 * Score meta description for SEO effectiveness
1671 *
1672 * @param string $meta_desc Meta description
1673 * @return array Meta description scoring
1674 */
1675 private function score_meta_description(string $meta_desc): array {
1676 $score = 0;
1677 $max_score = 100;
1678 $feedback = [];
1679
1680 if (empty($meta_desc)) {
1681 return [
1682 'score' => 0,
1683 'max_score' => $max_score,
1684 'grade' => 'F',
1685 'feedback' => ['No meta description found']
1686 ];
1687 }
1688
1689 // Length check (optimal: 150-160 characters)
1690 $length = strlen($meta_desc);
1691 if ($length >= 150 && $length <= 160) {
1692 $score += 30;
1693 $feedback[] = 'Optimal length (150-160 chars)';
1694 } elseif ($length >= 120 && $length <= 170) {
1695 $score += 20;
1696 $feedback[] = 'Good length';
1697 } elseif ($length >= 100 && $length <= 180) {
1698 $score += 10;
1699 $feedback[] = 'Acceptable length';
1700 } else {
1701 $feedback[] = $length < 100 ? 'Too short (under 100 chars)' : 'Too long (over 180 chars)';
1702 }
1703
1704 // Check for call-to-action
1705 $cta_words = ['learn', 'discover', 'find out', 'get', 'download', 'try', 'start', 'join', 'sign up', 'contact', 'buy', 'shop'];
1706 $has_cta = false;
1707 foreach ($cta_words as $cta_word) {
1708 if (stripos($meta_desc, $cta_word) !== false) {
1709 $has_cta = true;
1710 break;
1711 }
1712 }
1713 if ($has_cta) {
1714 $score += 20;
1715 $feedback[] = 'Contains call-to-action';
1716 }
1717
1718 // Check for unique selling proposition
1719 $usp_words = ['best', 'top', 'leading', 'expert', 'professional', 'trusted', 'proven', 'award-winning'];
1720 $has_usp = false;
1721 foreach ($usp_words as $usp_word) {
1722 if (stripos($meta_desc, $usp_word) !== false) {
1723 $has_usp = true;
1724 break;
1725 }
1726 }
1727 if ($has_usp) {
1728 $score += 15;
1729 $feedback[] = 'Contains unique selling proposition';
1730 }
1731
1732 // Check for benefits/value proposition
1733 $benefit_words = ['save', 'improve', 'increase', 'boost', 'enhance', 'optimize', 'maximize', 'reduce', 'eliminate'];
1734 $has_benefits = false;
1735 foreach ($benefit_words as $benefit_word) {
1736 if (stripos($meta_desc, $benefit_word) !== false) {
1737 $has_benefits = true;
1738 break;
1739 }
1740 }
1741 if ($has_benefits) {
1742 $score += 15;
1743 $feedback[] = 'Highlights benefits';
1744 }
1745
1746 // Readability check
1747 $sentences = preg_split('/[.!?]+/', $meta_desc);
1748 $sentence_count = count(array_filter($sentences, function($s) { return trim($s) !== '';
1749 }));
1750 if ($sentence_count >= 1 && $sentence_count <= 3) {
1751 $score += 20;
1752 $feedback[] = 'Good sentence structure';
1753 } else {
1754 $feedback[] = $sentence_count === 0 ? 'No clear sentences' : 'Too many sentences';
1755 }
1756
1757 return [
1758 'score' => min($score, $max_score),
1759 'max_score' => $max_score,
1760 'grade' => $this->get_grade_from_score($score),
1761 'feedback' => $feedback
1762 ];
1763 }
1764
1765 /**
1766 * Assess content depth based on structure and length
1767 *
1768 * @param array $headings Heading structure
1769 * @param int $word_count Word count
1770 * @return array Content depth assessment
1771 */
1772 private function assess_content_depth(array $headings, int $word_count): array {
1773 $depth_score = 0;
1774 $max_score = 100;
1775
1776 // Word count scoring (more words = more depth)
1777 if ($word_count >= 2000) {
1778 $depth_score += 40;
1779 } elseif ($word_count >= 1000) {
1780 $depth_score += 30;
1781 } elseif ($word_count >= 500) {
1782 $depth_score += 20;
1783 } elseif ($word_count >= 300) {
1784 $depth_score += 10;
1785 }
1786
1787 // Heading structure scoring
1788 $total_headings = 0;
1789 $heading_levels = 0;
1790 foreach ($headings as $level => $level_headings) {
1791 $total_headings += count($level_headings);
1792 $heading_levels++;
1793 }
1794
1795 if ($total_headings >= 10) {
1796 $depth_score += 25;
1797 } elseif ($total_headings >= 5) {
1798 $depth_score += 15;
1799 } elseif ($total_headings >= 3) {
1800 $depth_score += 10;
1801 }
1802
1803 // Heading hierarchy scoring
1804 if ($heading_levels >= 3) {
1805 $depth_score += 20;
1806 } elseif ($heading_levels >= 2) {
1807 $depth_score += 15;
1808 }
1809
1810 // Content structure bonus
1811 if (isset($headings['h1']) && isset($headings['h2'])) {
1812 $depth_score += 15;
1813 }
1814
1815 // Determine depth level
1816 if ($depth_score >= 80) {
1817 $level = 'Comprehensive';
1818 } elseif ($depth_score >= 60) {
1819 $level = 'Detailed';
1820 } elseif ($depth_score >= 40) {
1821 $level = 'Moderate';
1822 } elseif ($depth_score >= 20) {
1823 $level = 'Basic';
1824 } else {
1825 $level = 'Shallow';
1826 }
1827
1828 return [
1829 'score' => min($depth_score, $max_score),
1830 'level' => $level,
1831 'word_count' => $word_count,
1832 'total_headings' => $total_headings,
1833 'heading_levels' => $heading_levels
1834 ];
1835 }
1836
1837 /**
1838 * Convert numeric score to letter grade
1839 *
1840 * @param int $score Numeric score
1841 * @return string Letter grade
1842 */
1843 private function get_grade_from_score(int $score): string {
1844 if ($score >= 90) { return 'A';
1845 }
1846 if ($score >= 80) { return 'B';
1847 }
1848 if ($score >= 70) { return 'C';
1849 }
1850 if ($score >= 60) { return 'D';
1851 }
1852 return 'F';
1853 }
1854
1855 /**
1856 * Log AI usage for analytics
1857 *
1858 * @param int $user_id User ID
1859 * @param string $action Action performed
1860 * @param int $tokens_used Tokens consumed
1861 * @param int|null $post_id Related post/brief ID
1862 * @param string|null $raw_response Raw AI response for debugging
1863 * @param string|null $actual_model Actual model used (from response)
1864 * @return int Usage record ID
1865 */
1866 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 {
1867 global $wpdb;
1868
1869 $table_name = $wpdb->prefix . 'thinkrank_ai_usage';
1870
1871 $metadata = [];
1872 if ($raw_response) {
1873 $metadata['raw_response'] = $raw_response;
1874 }
1875 if ($actual_model) {
1876 $metadata['actual_model'] = $actual_model;
1877 }
1878
1879 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- AI usage logging requires direct database access
1880 $wpdb->insert(
1881 $table_name,
1882 [
1883 'user_id' => $user_id,
1884 'action' => $action,
1885 'tokens_used' => $tokens_used,
1886 'provider' => $this->settings->get('ai_provider', 'openai'),
1887 'post_id' => $post_id,
1888 'metadata' => !empty($metadata) ? wp_json_encode($metadata) : null,
1889 'created_at' => current_time('mysql'),
1890 ],
1891 ['%d', '%s', '%d', '%s', '%d', '%s', '%s']
1892 );
1893
1894 return $wpdb->insert_id;
1895 }
1896
1897 /**
1898 * Get raw AI response for a brief from ai_usage table
1899 *
1900 * @param int $brief_id Brief ID
1901 * @return string Raw AI response or empty string if not found
1902 */
1903 private function get_raw_response_for_brief(int $brief_id): string {
1904 global $wpdb;
1905
1906 $table_name = $wpdb->prefix . 'thinkrank_ai_usage';
1907
1908 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- AI usage retrieval requires direct database access
1909 $result = $wpdb->get_var(
1910 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is validated with WordPress prefix
1911 $wpdb->prepare(
1912 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is validated with WordPress prefix
1913 "SELECT metadata FROM `{$table_name}` WHERE post_id = %d AND action = 'content_brief' ORDER BY created_at DESC LIMIT 1",
1914 $brief_id
1915 )
1916 );
1917
1918 if ($result) {
1919 $metadata = json_decode($result, true);
1920 return $metadata['raw_response'] ?? '';
1921 }
1922
1923 return '';
1924 }
1925
1926 /**
1927 * Get actual model used for a brief from ai_usage table
1928 *
1929 * @param int $brief_id Brief ID
1930 * @return string|null Actual model used or null if not found
1931 */
1932 private function get_actual_model_for_brief(int $brief_id): ?string {
1933 global $wpdb;
1934
1935 $table_name = $wpdb->prefix . 'thinkrank_ai_usage';
1936
1937 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- AI usage retrieval requires direct database access
1938 $result = $wpdb->get_var(
1939 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is validated with WordPress prefix
1940 $wpdb->prepare(
1941 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is validated with WordPress prefix
1942 "SELECT metadata FROM `{$table_name}` WHERE post_id = %d AND action = 'content_brief' ORDER BY created_at DESC LIMIT 1",
1943 $brief_id
1944 )
1945 );
1946
1947 if ($result) {
1948 $metadata = json_decode($result, true);
1949 return $metadata['actual_model'] ?? null;
1950 }
1951
1952 return null;
1953 }
1954
1955 /**
1956 * Get Prompt Builder instance
1957 *
1958 * @since 1.0.0
1959 *
1960 * @return \ThinkRank\AI\Prompt_Builder Prompt Builder instance
1961 */
1962 private function get_prompt_builder(): \ThinkRank\AI\Prompt_Builder {
1963 if (!class_exists('ThinkRank\\AI\\Prompt_Builder')) {
1964 require_once THINKRANK_PLUGIN_DIR . 'includes/ai/class-prompt-builder.php';
1965 }
1966 return new \ThinkRank\AI\Prompt_Builder();
1967 }
1968 }
1969