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

2,079 lines 83.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 * 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' => self::strip_outline_level_labels($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' => self::strip_heading_level_labels((string) ($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 * Remove a leading level label from a heading string.
700 *
701 * The prompt's own JSON example labelled outline headings with their level
702 * (`"heading": "H1: Main Title"` next to a separate `"level": 1`), so the
703 * model often carried the convention into the drafted article and Pro's
704 * "Insert into post" wrote `<h2>H2: Real Heading</h2>` into published
705 * content. The prompt no longer does that, but a prompt change never fully
706 * binds a model — so the label is stripped here too (#410).
707 *
708 * Covers the label forms a model actually emits: `H2:`, `h3:`, `H2 -`,
709 * `H4.`, `H2)` and the en/em dash variants, optionally wrapped in markdown
710 * emphasis (`**H2:**`). The delimiter is anchored directly after the digit
711 * so `H10:` — a plausible heading in a numbered list — is left alone, and
712 * only a leading label is matched so body copy that mentions a level
713 * survives. Trailing emphasis is consumed only when the same marker opened
714 * the label, so `H2: *emphasised start*` keeps its asterisks.
715 *
716 * @since 2.0.1
717 *
718 * @param string $heading Heading text.
719 * @return string Heading without its level prefix.
720 */
721 public static function strip_level_label(string $heading): string {
722 // En dash and em dash as raw UTF-8 bytes, so the pattern needs no /u
723 // modifier and cannot blank a heading that is not valid UTF-8.
724 $delimiter = '(?:[:.)\-]|\xe2\x80\x93|\xe2\x80\x94)';
725 $emphasis = '(\*{1,3}|_{1,3})';
726
727 $pattern = '/^\s*(?:'
728 . $emphasis . '\s*[Hh][1-6]\s*' . $delimiter . '\s*\1'
729 . '|[Hh][1-6]\s*' . $delimiter
730 . ')\s*/';
731
732 return (string) preg_replace($pattern, '', $heading);
733 }
734
735 /**
736 * Strip a level label from a heading's inner HTML.
737 *
738 * A model drafting publish-ready HTML often wraps the heading text in an
739 * inline tag (`<h2><strong>H2: Real Heading</strong></h2>`). That pushes a
740 * `<` in front of the label, so the leading run of inline opening tags is
741 * set aside and re-attached around the cleaned text.
742 *
743 * @since 2.0.1
744 *
745 * @param string $inner Heading inner HTML.
746 * @return string Inner HTML without the level prefix.
747 */
748 private static function strip_inner_level_label(string $inner): string {
749 $prefix = '';
750
751 if (preg_match('/^(\s*(?:<(?:strong|em|b|i|span|mark|code|u)\b[^>]*>\s*)+)(.*)$/is', $inner, $parts)) {
752 $prefix = $parts[1];
753 $inner = $parts[2];
754 }
755
756 return $prefix . self::strip_level_label($inner);
757 }
758
759 /**
760 * Strip level labels from every heading in an outline.
761 *
762 * @since 2.0.1
763 *
764 * @param mixed $outline Outline as returned by the model.
765 * @return array Outline with clean headings.
766 */
767 public static function strip_outline_level_labels($outline): array {
768 if (!is_array($outline)) {
769 return [];
770 }
771
772 foreach ($outline as $index => $section) {
773 if (is_array($section) && isset($section['heading']) && is_string($section['heading'])) {
774 $outline[$index]['heading'] = self::strip_level_label($section['heading']);
775 } elseif (is_string($section)) {
776 $outline[$index] = self::strip_level_label($section);
777 }
778 }
779
780 return $outline;
781 }
782
783 /**
784 * Strip level labels from the heading text inside drafted HTML.
785 *
786 * This is the path that reaches published post content, so it is the one
787 * that matters most. Only the text directly inside an <h1>-<h6> is touched.
788 *
789 * @since 2.0.1
790 *
791 * @param string $html Drafted article body.
792 * @return string Body with clean headings.
793 */
794 public static function strip_heading_level_labels(string $html): string {
795 if ('' === $html || false === stripos($html, '<h')) {
796 return $html;
797 }
798
799 return (string) preg_replace_callback(
800 '/(<h([1-6])\b[^>]*>)(.*?)(<\/h\2>)/is',
801 static function (array $parts): string {
802 return $parts[1] . self::strip_inner_level_label($parts[3]) . $parts[4];
803 },
804 $html
805 );
806 }
807
808 /**
809 * Create error response when JSON parsing fails
810 *
811 * @param string $ai_response Raw AI response
812 * @param array $original_params Original generation parameters
813 * @return array Error response structure
814 */
815 private function create_parsing_error_response(string $ai_response, array $original_params): array {
816 return [
817 'title' => ['Error: Unable to parse AI response'],
818 'meta_description' => 'AI response could not be parsed as valid JSON.',
819 'meta_descriptions' => ['AI response could not be parsed as valid JSON.'],
820 'url_slugs' => ['error-parsing-response'],
821 'outline' => [],
822 'seo_recommendations' => [
823 'title_suggestions' => ['Error: Unable to parse AI response'],
824 'meta_description' => 'AI response could not be parsed as valid JSON.',
825 'meta_descriptions' => ['AI response could not be parsed as valid JSON.'],
826 'url_slugs' => ['error-parsing-response'],
827 'focus_keyword_analysis' => [
828 'primary_placement' => [],
829 'secondary_integration' => [],
830 'density_guidelines' => []
831 ],
832 'internal_links' => [],
833 'related_keywords' => [],
834 'long_tail_keywords' => []
835 ],
836 'social_media' => [
837 'open_graph' => ['title' => 'Error', 'description' => 'Parsing failed'],
838 'twitter_card' => ['title' => 'Error', 'description' => 'Parsing failed']
839 ],
840 'schema_markup' => [
841 'recommended_types' => [],
842 'key_properties' => [],
843 'faq_questions' => []
844 ],
845 'visual_content' => [
846 'image_recommendations' => [],
847 'alt_text_suggestions' => [],
848 'infographic_opportunities' => []
849 ],
850 'competitor_gaps' => [],
851 'call_to_actions' => [],
852 'writing_guidelines' => [],
853 'content_body' => '',
854 'estimated_word_count' => $this->get_word_count_estimate($original_params['content_length'] ?? 'medium'),
855 'raw_response' => $ai_response, // Store raw response in error case
856 'generation_params' => $original_params,
857 'parsing_status' => 'failed',
858 'created_at' => current_time('mysql')
859 ];
860 }
861
862 /**
863 * Normalize focus keyword analysis to ensure proper array structure
864 *
865 * @param array $focus_keyword_analysis Raw focus keyword analysis data
866 * @return array Normalized focus keyword analysis
867 */
868 private function normalize_focus_keyword_analysis(array $focus_keyword_analysis): array {
869 $normalized = [
870 'primary_placement' => [],
871 'secondary_integration' => [],
872 'density_guidelines' => []
873 ];
874
875 // Normalize primary_placement
876 if (isset($focus_keyword_analysis['primary_placement'])) {
877 if (is_array($focus_keyword_analysis['primary_placement'])) {
878 $normalized['primary_placement'] = $focus_keyword_analysis['primary_placement'];
879 } elseif (is_string($focus_keyword_analysis['primary_placement'])) {
880 // Convert string to array by splitting on common delimiters
881 $normalized['primary_placement'] = array_filter(array_map('trim', preg_split('/[,;]/', $focus_keyword_analysis['primary_placement'])));
882 }
883 }
884
885 // Normalize secondary_integration - this is the problematic field
886 if (isset($focus_keyword_analysis['secondary_integration'])) {
887 if (is_array($focus_keyword_analysis['secondary_integration'])) {
888 $normalized['secondary_integration'] = $focus_keyword_analysis['secondary_integration'];
889 } elseif (is_string($focus_keyword_analysis['secondary_integration'])) {
890 // Convert string to array - split by sentences or use as single item
891 $text = trim($focus_keyword_analysis['secondary_integration']);
892 if (!empty($text)) {
893 // Split by sentences if it contains periods, otherwise use as single item
894 if (strpos($text, '.') !== false) {
895 $sentences = array_filter(array_map('trim', explode('.', $text)));
896 $normalized['secondary_integration'] = array_map(function($sentence) {
897 return $sentence . (substr($sentence, -1) !== '.' ? '.' : '');
898 }, $sentences);
899 } else {
900 $normalized['secondary_integration'] = [$text];
901 }
902 }
903 }
904 }
905
906 // Normalize density_guidelines
907 if (isset($focus_keyword_analysis['density_guidelines'])) {
908 if (is_array($focus_keyword_analysis['density_guidelines'])) {
909 $normalized['density_guidelines'] = $focus_keyword_analysis['density_guidelines'];
910 } elseif (is_string($focus_keyword_analysis['density_guidelines'])) {
911 // Convert string to array by splitting on common delimiters
912 $guidelines = array_filter(array_map('trim', preg_split('/[,;]/', $focus_keyword_analysis['density_guidelines'])));
913 $normalized['density_guidelines'] = $guidelines ?: [$focus_keyword_analysis['density_guidelines']];
914 }
915 }
916
917 return $normalized;
918 }
919 private function analyze_competitor_urls(array $urls): string {
920 $analysis_results = [];
921 $failed_urls = [];
922
923 // Limit to first 3 URLs to prevent timeout
924 $urls = array_slice($urls, 0, 3);
925
926 foreach ($urls as $url) {
927 $url = trim($url);
928 if (empty($url) || !filter_var($url, FILTER_VALIDATE_URL)) {
929 $failed_urls[] = $url . " (invalid URL)";
930 continue;
931 }
932
933 // SSRF guard: only fetch public http/https hosts. Blocks loopback,
934 // link-local (cloud metadata), private and reserved ranges before any
935 // request is made.
936 if (!$this->is_safe_public_url($url)) {
937 $failed_urls[] = $url . " (blocked: non-public host)";
938 continue;
939 }
940
941 $content_data = $this->scrape_competitor_content($url);
942 if ($content_data) {
943 $analysis_results[] = $this->format_competitor_analysis($url, $content_data);
944 } else {
945 $failed_urls[] = $url . " (failed to scrape)";
946 }
947 }
948
949 $result = "";
950
951 if (!empty($analysis_results)) {
952 $result .= implode("\n\n", $analysis_results);
953 }
954
955 if (!empty($failed_urls)) {
956 $result .= "\n\nNote: The following URLs could not be analyzed:\n";
957 $result .= "- " . implode("\n- ", $failed_urls);
958 }
959
960 if (empty($analysis_results)) {
961 return "No competitor URLs could be successfully analyzed. Please ensure URLs are accessible and valid.";
962 }
963
964 return $result;
965 }
966
967 /**
968 * Whether a competitor URL is safe to fetch: an http/https URL whose host
969 * resolves only to public IP addresses.
970 *
971 * Prevents SSRF — a low-privilege user could otherwise point competitor
972 * scraping at loopback, link-local (e.g. 169.254.169.254 cloud metadata),
973 * CGNAT, private or reserved addresses to probe internal services. The
974 * block list lives in {@see \ThinkRank\Core\Url_Safety} so this and the
975 * schema importer can never drift apart.
976 *
977 * @param string $url URL to validate.
978 * @return bool True when safe to fetch.
979 */
980 private function is_safe_public_url(string $url): bool {
981 return \ThinkRank\Core\Url_Safety::is_safe_public_url($url);
982 }
983
984 /**
985 * Scrape content from a competitor URL
986 *
987 * @param string $url The URL to scrape
988 * @return array|null Content data or null if failed
989 */
990 private function scrape_competitor_content(string $url): ?array {
991 // Url_Safety::safe_remote_get() follows redirects manually and re-checks
992 // the resolved host on every hop, so a target that redirects to (or
993 // rebinds onto) an internal address after the pre-flight check is
994 // refused rather than fetched.
995 $response = \ThinkRank\Core\Url_Safety::safe_remote_get($url, [
996 'timeout' => 8, // Reduced from 15 to 8 seconds
997 'user-agent' => 'Mozilla/5.0 (compatible; ThinkRank SEO Bot)',
998 'headers' => [
999 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
1000 'Accept-Language' => 'en-US,en;q=0.5',
1001 ]
1002 ]);
1003
1004 if (is_wp_error($response)) {
1005 return null;
1006 }
1007
1008 $status_code = wp_remote_retrieve_response_code($response);
1009 if ($status_code !== 200) {
1010 return null;
1011 }
1012
1013 $html = wp_remote_retrieve_body($response);
1014 if (empty($html)) {
1015 return null;
1016 }
1017
1018 return $this->parse_html_content($html, $url);
1019 }
1020
1021 /**
1022 * Parse HTML content and extract key SEO elements
1023 *
1024 * @param string $html HTML content
1025 * @param string $url Original URL for context
1026 * @return array Parsed content data
1027 */
1028 private function parse_html_content(string $html, string $url): array {
1029 // Create DOMDocument to parse HTML
1030 $dom = new \DOMDocument();
1031
1032 // Suppress warnings for malformed HTML
1033 libxml_use_internal_errors(true);
1034 $dom->loadHTML('<?xml encoding="UTF-8">' . $html);
1035 libxml_clear_errors();
1036
1037 $xpath = new \DOMXPath($dom);
1038
1039 // Extract title
1040 $title_nodes = $xpath->query('//title');
1041 $title = $title_nodes->length > 0 ? trim($title_nodes->item(0)->textContent) : '';
1042
1043 // Extract meta description
1044 $meta_desc_nodes = $xpath->query('//meta[@name="description"]/@content');
1045 $meta_description = $meta_desc_nodes->length > 0 ? trim($meta_desc_nodes->item(0)->textContent) : '';
1046
1047 // Extract headings (H1-H6)
1048 $headings = [];
1049 for ($i = 1; $i <= 6; $i++) {
1050 $heading_nodes = $xpath->query("//h{$i}");
1051 foreach ($heading_nodes as $node) {
1052 $text = trim($node->textContent);
1053 if (!empty($text)) {
1054 $headings["h{$i}"][] = $text;
1055 }
1056 }
1057 }
1058
1059 // Extract body text and calculate word count
1060 $body_nodes = $xpath->query('//body');
1061 $body_text = '';
1062 if ($body_nodes->length > 0) {
1063 $body_text = $this->extract_clean_text($body_nodes->item(0));
1064 }
1065
1066 $word_count = str_word_count($body_text);
1067
1068 // Extract meta keywords if present
1069 $meta_keywords_nodes = $xpath->query('//meta[@name="keywords"]/@content');
1070 $meta_keywords = $meta_keywords_nodes->length > 0 ? trim($meta_keywords_nodes->item(0)->textContent) : '';
1071
1072 // Extract internal links count
1073 $internal_links = $xpath->query('//a[starts-with(@href, "/") or contains(@href, "' . wp_parse_url($url, PHP_URL_HOST) . '")]');
1074 $internal_link_count = $internal_links->length;
1075
1076 // Extract external links count
1077 $external_links = $xpath->query('//a[starts-with(@href, "http") and not(contains(@href, "' . wp_parse_url($url, PHP_URL_HOST) . '"))]');
1078 $external_link_count = $external_links->length;
1079
1080 // Extract images count and alt text analysis
1081 $images = $xpath->query('//img');
1082 $image_count = $images->length;
1083 $images_with_alt = $xpath->query('//img[@alt and @alt!=""]');
1084 $images_with_alt_count = $images_with_alt->length;
1085
1086 // Extract schema markup
1087 $schema_scripts = $xpath->query('//script[@type="application/ld+json"]');
1088 $has_schema = $schema_scripts->length > 0;
1089
1090 // Extract last modified date if available
1091 $last_modified_nodes = $xpath->query('//meta[@name="last-modified"]/@content | //meta[@property="article:modified_time"]/@content');
1092 $last_modified = $last_modified_nodes->length > 0 ? $last_modified_nodes->item(0)->textContent : '';
1093
1094 // Calculate readability metrics
1095 $readability_score = $this->calculate_readability_score($body_text);
1096
1097 // Extract keyword density for target keywords (if provided)
1098 $keyword_density = $this->analyze_keyword_density($body_text, $title);
1099
1100 // Detect content freshness indicators
1101 $freshness_indicators = $this->detect_freshness_indicators($html, $body_text);
1102
1103 return [
1104 'url' => $url,
1105 'title' => $title,
1106 'meta_description' => $meta_description,
1107 'meta_keywords' => $meta_keywords,
1108 'headings' => $headings,
1109 'word_count' => $word_count,
1110 'internal_links' => $internal_link_count,
1111 'external_links' => $external_link_count,
1112 'images' => [
1113 'total' => $image_count,
1114 'with_alt' => $images_with_alt_count,
1115 'alt_ratio' => $image_count > 0 ? round(($images_with_alt_count / $image_count) * 100, 1) : 0
1116 ],
1117 'seo' => [
1118 'has_schema' => $has_schema,
1119 'title_length' => strlen($title),
1120 'meta_desc_length' => strlen($meta_description),
1121 'title_score' => $this->score_title_seo($title),
1122 'meta_desc_score' => $this->score_meta_description($meta_description)
1123 ],
1124 'content_quality' => [
1125 'readability_score' => $readability_score,
1126 'keyword_density' => $keyword_density,
1127 'freshness_indicators' => $freshness_indicators,
1128 'content_depth' => $this->assess_content_depth($headings, $word_count)
1129 ],
1130 'last_modified' => $last_modified,
1131 'content_preview' => substr($body_text, 0, 500) . '...',
1132 'analysis_timestamp' => current_time('mysql')
1133 ];
1134 }
1135
1136 /**
1137 * Extract clean text from DOM node, removing scripts and styles
1138 *
1139 * @param \DOMNode $node DOM node to extract text from
1140 * @return string Clean text content
1141 */
1142 private function extract_clean_text(\DOMNode $node): string {
1143 // Remove script and style elements
1144 $xpath = new \DOMXPath($node->ownerDocument);
1145 $scripts = $xpath->query('.//script | .//style', $node);
1146
1147 foreach ($scripts as $script) {
1148 $script->parentNode->removeChild($script);
1149 }
1150
1151 // Get text content and clean it up
1152 $text = $node->textContent;
1153
1154 // Remove extra whitespace and normalize
1155 $text = preg_replace('/\s+/', ' ', $text);
1156 $text = trim($text);
1157
1158 return $text;
1159 }
1160
1161 /**
1162 * Format competitor analysis for AI prompt
1163 *
1164 * @param string $url Competitor URL
1165 * @param array $content_data Parsed content data
1166 * @return string Formatted analysis
1167 */
1168 private function format_competitor_analysis(string $url, array $content_data): string {
1169 $analysis = "=== COMPETITOR ANALYSIS ===\n";
1170 $analysis .= "URL: {$url}\n";
1171 $analysis .= "Title: {$content_data['title']} (Length: {$content_data['seo']['title_length']} chars, Score: {$content_data['seo']['title_score']['grade']})\n";
1172
1173 if (!empty($content_data['meta_description'])) {
1174 $analysis .= "Meta Description: {$content_data['meta_description']} (Length: {$content_data['seo']['meta_desc_length']} chars, Score: {$content_data['seo']['meta_desc_score']['grade']})\n";
1175 }
1176
1177 $analysis .= "\nCONTENT METRICS:\n";
1178 $analysis .= "- Word Count: {$content_data['word_count']} words\n";
1179 $analysis .= "- Content Depth: {$content_data['content_quality']['content_depth']['level']} (Score: {$content_data['content_quality']['content_depth']['score']}/100)\n";
1180 $analysis .= "- Readability: {$content_data['content_quality']['readability_score']['level']} (Score: {$content_data['content_quality']['readability_score']['score']}/100)\n";
1181 $analysis .= "- Internal Links: {$content_data['internal_links']}\n";
1182 $analysis .= "- External Links: {$content_data['external_links']}\n";
1183 $analysis .= "- Images: {$content_data['images']['total']} total, {$content_data['images']['with_alt']} with alt text ({$content_data['images']['alt_ratio']}%)\n";
1184
1185 // Add heading structure
1186 if (!empty($content_data['headings'])) {
1187 $analysis .= "\nCONTENT STRUCTURE:\n";
1188 foreach ($content_data['headings'] as $level => $headings) {
1189 $analysis .= "- " . strtoupper($level) . " ({count}): " . implode(', ', array_slice($headings, 0, 3));
1190 if (count($headings) > 3) {
1191 $analysis .= "... (+" . (count($headings) - 3) . " more)";
1192 }
1193 $analysis .= "\n";
1194 }
1195 }
1196
1197 // Add SEO features
1198 $analysis .= "\nSEO FEATURES:\n";
1199 $analysis .= "- Schema Markup: " . ($content_data['seo']['has_schema'] ? 'Yes' : 'No') . "\n";
1200 if (!empty($content_data['meta_keywords'])) {
1201 $analysis .= "- Meta Keywords: {$content_data['meta_keywords']}\n";
1202 }
1203
1204 // Add content quality insights
1205 if (!empty($content_data['content_quality']['keyword_density']['top_keywords'])) {
1206 $analysis .= "\nTOP KEYWORDS:\n";
1207 foreach (array_slice($content_data['content_quality']['keyword_density']['top_keywords'], 0, 5) as $kw) {
1208 $analysis .= "- {$kw['keyword']}: {$kw['count']} times ({$kw['density']}%)\n";
1209 }
1210 }
1211
1212 // Add freshness indicators
1213 if (!empty($content_data['content_quality']['freshness_indicators'])) {
1214 $analysis .= "\nCONTENT FRESHNESS:\n";
1215 foreach ($content_data['content_quality']['freshness_indicators'] as $indicator) {
1216 $analysis .= "- {$indicator}\n";
1217 }
1218 }
1219
1220 $analysis .= "\n" . str_repeat("=", 50) . "\n";
1221
1222 return $analysis;
1223 }
1224
1225 /**
1226 * Get word count estimate based on content length
1227 *
1228 * @param string $content_length Content length setting
1229 * @return int Estimated word count
1230 */
1231 private function get_word_count_estimate(string $content_length): int {
1232 $estimates = [
1233 'short' => 650,
1234 'medium' => 1250,
1235 'long' => 2500
1236 ];
1237
1238 return $estimates[$content_length] ?? 1250;
1239 }
1240 private function save_brief(array $brief_data): int {
1241 global $wpdb;
1242
1243 $table_name = $wpdb->prefix . 'thinkrank_content_briefs';
1244
1245 // Prepare data for insertion
1246 $insert_data = [
1247 'user_id' => get_current_user_id(),
1248 'title' => $brief_data['title'][0] ?? 'Untitled Brief',
1249 'target_keywords' => wp_json_encode($brief_data['generation_params']['target_keywords'] ?? []),
1250 'content_type' => $brief_data['generation_params']['content_type'] ?? 'blog_post',
1251 'brief_data' => wp_json_encode($brief_data),
1252 'created_at' => current_time('mysql'),
1253 'updated_at' => current_time('mysql')
1254 ];
1255
1256 $insert_format = [
1257 '%d', // user_id
1258 '%s', // title
1259 '%s', // target_keywords
1260 '%s', // content_type
1261 '%s', // brief_data
1262 '%s', // created_at
1263 '%s' // updated_at
1264 ];
1265
1266 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Content brief storage requires direct database access
1267 $result = $wpdb->insert($table_name, $insert_data, $insert_format);
1268
1269 if (false === $result) {
1270 throw new \Exception('Failed to save content brief to database.');
1271 }
1272
1273 return $wpdb->insert_id;
1274 }
1275
1276 /**
1277 * Normalize brief data for React compatibility
1278 *
1279 * @param array $brief_data Brief data to normalize
1280 * @return array Normalized brief data
1281 */
1282 private function normalize_brief_data(array $brief_data): array {
1283 // Normalize focus_keyword_analysis
1284 if (isset($brief_data['seo_recommendations']['focus_keyword_analysis'])) {
1285 $brief_data['seo_recommendations']['focus_keyword_analysis'] =
1286 $this->normalize_focus_keyword_analysis($brief_data['seo_recommendations']['focus_keyword_analysis']);
1287 }
1288
1289 // Normalize call_to_actions (convert objects to strings)
1290 if (isset($brief_data['call_to_actions']) && is_array($brief_data['call_to_actions'])) {
1291 $brief_data['call_to_actions'] = array_map(function($cta) {
1292 if (is_array($cta) && isset($cta['text'])) {
1293 return $cta['text'] . (isset($cta['placement']) ? ' (' . $cta['placement'] . ')' : '');
1294 }
1295 return is_string($cta) ? $cta : '';
1296 }, $brief_data['call_to_actions']);
1297 }
1298
1299 // Normalize visual content image_recommendations (convert objects to strings)
1300 if (isset($brief_data['visual_content']['image_recommendations']) && is_array($brief_data['visual_content']['image_recommendations'])) {
1301 $brief_data['visual_content']['image_recommendations'] = array_map(function($rec) {
1302 if (is_array($rec)) {
1303 $text = '';
1304 if (isset($rec['type'])) { $text .= $rec['type'] . ': ';
1305 }
1306 if (isset($rec['description'])) { $text .= $rec['description'];
1307 }
1308 if (isset($rec['alt_text'])) { $text .= ' (Alt: ' . $rec['alt_text'] . ')';
1309 }
1310 return $text ?: 'Image recommendation';
1311 }
1312 return is_string($rec) ? $rec : 'Image recommendation';
1313 }, $brief_data['visual_content']['image_recommendations']);
1314 }
1315
1316 return $this->sanitize_brief_output($brief_data);
1317 }
1318
1319 /**
1320 * Strip untrusted markup out of brief fields before they leave the server.
1321 *
1322 * Brief content crosses a trust boundary: it is assembled by an external AI
1323 * provider from prompts that can include text fetched from competitor URLs.
1324 * It was previously copied out of the decoded JSON verbatim and rendered in
1325 * the admin SPA through dangerouslySetInnerHTML, so a malicious or
1326 * prompt-injected response could execute script in the admin origin (#365).
1327 *
1328 * Runs on the read path as well as generation, so briefs stored before this
1329 * fix are sanitized when they are loaded.
1330 *
1331 * @since 1.32.0
1332 *
1333 * @param array $brief_data Brief data to sanitize.
1334 * @return array Sanitized brief data.
1335 */
1336 private function sanitize_brief_output(array $brief_data): array {
1337 foreach ($brief_data as $key => $value) {
1338 // The raw provider response is debug output shown as plain text, and
1339 // the generation params are our own values — leave both intact.
1340 if ('raw_response' === $key || 'generation_params' === $key) {
1341 continue;
1342 }
1343
1344 if ('content_body' === $key && is_string($value)) {
1345 // Deliberately HTML: it is the drafted article and is rendered as
1346 // markup. wp_kses_post() keeps normal post formatting while
1347 // dropping script/style/iframe, event-handler attributes and
1348 // javascript: URLs.
1349 $brief_data[$key] = wp_kses_post($value);
1350 continue;
1351 }
1352
1353 if (is_array($value)) {
1354 $brief_data[$key] = $this->sanitize_brief_output($value);
1355 } elseif (is_string($value)) {
1356 // Every other field is plain text (headings, keywords, guidance).
1357 // Markdown emphasis markers are preserved; HTML tags are not.
1358 $brief_data[$key] = wp_strip_all_tags($value);
1359 }
1360 }
1361
1362 return $brief_data;
1363 }
1364
1365 /**
1366 * Get saved briefs for current user
1367 *
1368 * @param int $limit Number of briefs to retrieve
1369 * @param int $offset Offset for pagination
1370 * @return array Array of saved briefs
1371 */
1372 public function get_user_briefs(int $limit = 10, int $offset = 0): array {
1373 global $wpdb;
1374
1375 // Get table name and escape it properly (table names cannot be parameterized)
1376 $table_name = esc_sql($wpdb->prefix . 'thinkrank_content_briefs');
1377 $user_id = get_current_user_id();
1378
1379 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Content brief retrieval requires direct database access
1380 $results = $wpdb->get_results(
1381 $wpdb->prepare(
1382 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is properly escaped using esc_sql()
1383 "SELECT * FROM `{$table_name}` WHERE user_id = %d ORDER BY created_at DESC LIMIT %d OFFSET %d",
1384 $user_id,
1385 $limit,
1386 $offset
1387 ),
1388 ARRAY_A
1389 );
1390
1391 // $wpdb->get_results() returns null on a DB error; this method's return
1392 // type is : array, so normalize before iterating/returning.
1393 if (!is_array($results)) {
1394 return [];
1395 }
1396
1397 // Decode JSON data and normalize for React compatibility
1398 foreach ($results as &$brief) {
1399 $brief = $this->hydrate_brief_row($brief);
1400 }
1401 unset($brief);
1402
1403 return $results;
1404 }
1405
1406 /**
1407 * Get a single saved brief by id, scoped to the current user.
1408 *
1409 * @param int $brief_id Brief ID.
1410 * @return array|null Hydrated brief, or null if it doesn't exist or does not
1411 * belong to the current user.
1412 */
1413 public function get_brief(int $brief_id): ?array {
1414 global $wpdb;
1415
1416 // Table names cannot be parameterized; escape it.
1417 $table_name = esc_sql($wpdb->prefix . 'thinkrank_content_briefs');
1418 $user_id = get_current_user_id();
1419
1420 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Content brief retrieval requires direct database access
1421 $brief = $wpdb->get_row(
1422 $wpdb->prepare(
1423 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is properly escaped using esc_sql()
1424 "SELECT * FROM `{$table_name}` WHERE id = %d AND user_id = %d LIMIT 1",
1425 $brief_id,
1426 $user_id
1427 ),
1428 ARRAY_A
1429 );
1430
1431 if (!$brief) {
1432 return null;
1433 }
1434
1435 return $this->hydrate_brief_row($brief);
1436 }
1437
1438 /**
1439 * Decode + normalize a raw content-brief DB row for API/React consumption.
1440 *
1441 * @param array $brief Raw database row.
1442 * @return array Hydrated brief.
1443 */
1444 private function hydrate_brief_row(array $brief): array {
1445 $brief['target_keywords'] = json_decode($brief['target_keywords'], true);
1446 $brief['brief_data'] = json_decode($brief['brief_data'], true);
1447
1448 // Cast: the row comes from $wpdb, which returns every column as a
1449 // string, and both helpers declare an int parameter.
1450 $brief_id = (int) $brief['id'];
1451
1452 // Retrieve raw response from ai_usage table
1453 $brief['brief_data']['raw_response'] = $this->get_raw_response_for_brief($brief_id);
1454
1455 // Update model with actual model used (if available in ai_usage table)
1456 $actual_model = $this->get_actual_model_for_brief($brief_id);
1457 if ($actual_model && isset($brief['brief_data']['generation_meta'])) {
1458 $brief['brief_data']['generation_meta']['model'] = $actual_model;
1459 }
1460
1461 // Apply normalization to existing briefs to ensure React compatibility
1462 $brief['brief_data'] = $this->normalize_brief_data($brief['brief_data']);
1463
1464 return $brief;
1465 }
1466
1467 /**
1468 * Delete brief
1469 *
1470 * @param int $brief_id Brief ID to delete
1471 * @return bool Success status
1472 */
1473 public function delete_brief(int $brief_id): bool {
1474 global $wpdb;
1475
1476 $table_name = $wpdb->prefix . 'thinkrank_content_briefs';
1477 $user_id = get_current_user_id();
1478
1479 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Content brief deletion requires direct database access
1480 $result = $wpdb->delete(
1481 $table_name,
1482 [
1483 'id' => $brief_id,
1484 'user_id' => $user_id
1485 ],
1486 ['%d', '%d']
1487 );
1488
1489 return $result !== false;
1490 }
1491
1492 /**
1493 * Calculate readability score using Flesch Reading Ease
1494 *
1495 * @param string $text Text to analyze
1496 * @return array Readability metrics
1497 */
1498 private function calculate_readability_score(string $text): array {
1499 if (empty($text)) {
1500 return ['score' => 0, 'level' => 'Unknown', 'grade' => 'N/A'];
1501 }
1502
1503 // Count sentences (approximate)
1504 $sentences = preg_split('/[.!?]+/', $text);
1505 $sentence_count = count(array_filter($sentences, function($s) { return trim($s) !== '';
1506 }));
1507
1508 // Count words
1509 $word_count = str_word_count($text);
1510
1511 // Count syllables (approximate)
1512 $syllable_count = $this->count_syllables($text);
1513
1514 if ($sentence_count === 0 || $word_count === 0) {
1515 return ['score' => 0, 'level' => 'Unknown', 'grade' => 'N/A'];
1516 }
1517
1518 // Flesch Reading Ease formula
1519 $avg_sentence_length = $word_count / $sentence_count;
1520 $avg_syllables_per_word = $syllable_count / $word_count;
1521
1522 $flesch_score = 206.835 - (1.015 * $avg_sentence_length) - (84.6 * $avg_syllables_per_word);
1523 $flesch_score = max(0, min(100, $flesch_score)); // Clamp between 0-100
1524
1525 // Determine reading level
1526 if ($flesch_score >= 90) {
1527 $level = 'Very Easy';
1528 $grade = '5th grade';
1529 } elseif ($flesch_score >= 80) {
1530 $level = 'Easy';
1531 $grade = '6th grade';
1532 } elseif ($flesch_score >= 70) {
1533 $level = 'Fairly Easy';
1534 $grade = '7th grade';
1535 } elseif ($flesch_score >= 60) {
1536 $level = 'Standard';
1537 $grade = '8th-9th grade';
1538 } elseif ($flesch_score >= 50) {
1539 $level = 'Fairly Difficult';
1540 $grade = '10th-12th grade';
1541 } elseif ($flesch_score >= 30) {
1542 $level = 'Difficult';
1543 $grade = 'College level';
1544 } else {
1545 $level = 'Very Difficult';
1546 $grade = 'Graduate level';
1547 }
1548
1549 return [
1550 'score' => round($flesch_score, 1),
1551 'level' => $level,
1552 'grade' => $grade
1553 ];
1554 }
1555
1556 /**
1557 * Count syllables in text (approximate)
1558 *
1559 * @param string $text Text to analyze
1560 * @return int Syllable count
1561 */
1562 private function count_syllables(string $text): int {
1563 $words = str_word_count(strtolower($text), 1);
1564 $syllable_count = 0;
1565
1566 foreach ($words as $word) {
1567 $syllable_count += $this->count_word_syllables($word);
1568 }
1569
1570 return max(1, $syllable_count); // At least 1 syllable
1571 }
1572
1573 /**
1574 * Count syllables in a single word
1575 *
1576 * @param string $word Word to analyze
1577 * @return int Syllable count
1578 */
1579 private function count_word_syllables(string $word): int {
1580 $word = strtolower($word);
1581 $vowels = 'aeiouy';
1582 $syllable_count = 0;
1583 $previous_was_vowel = false;
1584
1585 for ($i = 0, $len = strlen($word); $i < $len; $i++) {
1586 $is_vowel = strpos($vowels, $word[$i]) !== false;
1587 if ($is_vowel && !$previous_was_vowel) {
1588 $syllable_count++;
1589 }
1590 $previous_was_vowel = $is_vowel;
1591 }
1592
1593 // Handle silent 'e'
1594 if (substr($word, -1) === 'e' && $syllable_count > 1) {
1595 $syllable_count--;
1596 }
1597
1598 return max(1, $syllable_count);
1599 }
1600
1601 /**
1602 * Analyze keyword density in content
1603 *
1604 * @param string $text Content text
1605 * @param string $title Page title
1606 * @return array Keyword analysis
1607 */
1608 private function analyze_keyword_density(string $text, string $title): array {
1609 $combined_text = strtolower($title . ' ' . $text);
1610 $words = str_word_count($combined_text, 1);
1611 $total_words = count($words);
1612
1613 if ($total_words === 0) {
1614 return ['top_keywords' => [], 'total_words' => 0];
1615 }
1616
1617 // Count word frequency
1618 $word_counts = array_count_values($words);
1619
1620 // Filter out common stop words
1621 $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'];
1622
1623 foreach ($stop_words as $stop_word) {
1624 unset($word_counts[$stop_word]);
1625 }
1626
1627 // Filter out single characters and numbers
1628 $word_counts = array_filter($word_counts, function($count, $word) {
1629 return strlen($word) > 2 && !is_numeric($word) && $count > 1;
1630 }, ARRAY_FILTER_USE_BOTH);
1631
1632 // Sort by frequency
1633 arsort($word_counts);
1634
1635 // Calculate density and format results
1636 $top_keywords = [];
1637 foreach (array_slice($word_counts, 0, 10, true) as $word => $count) {
1638 $density = round(($count / $total_words) * 100, 2);
1639 $top_keywords[] = [
1640 'keyword' => $word,
1641 'count' => $count,
1642 'density' => $density
1643 ];
1644 }
1645
1646 return [
1647 'top_keywords' => $top_keywords,
1648 'total_words' => $total_words
1649 ];
1650 }
1651
1652 /**
1653 * Detect content freshness indicators
1654 *
1655 * @param string $html Full HTML content
1656 * @param string $text Body text
1657 * @return array Freshness indicators
1658 */
1659 private function detect_freshness_indicators(string $html, string $text): array {
1660 $indicators = [];
1661
1662 // Check for date patterns in content
1663 if (preg_match('/\b(updated|revised|modified|published).*?(\d{4}|\d{1,2}\/\d{1,2}\/\d{2,4})/i', $text)) {
1664 $indicators[] = 'Contains recent update dates';
1665 }
1666
1667 // Check for current year references
1668 $current_year = gmdate('Y');
1669 if (strpos($text, $current_year) !== false) {
1670 $indicators[] = "References current year ({$current_year})";
1671 }
1672
1673 // Check for "latest", "new", "recent" keywords
1674 if (preg_match('/\b(latest|newest|recent|updated|current|modern|today)\b/i', $text)) {
1675 $indicators[] = 'Uses freshness keywords';
1676 }
1677
1678 // Check for structured data with dates
1679 if (preg_match('/"dateModified"|"datePublished"/i', $html)) {
1680 $indicators[] = 'Has structured date metadata';
1681 }
1682
1683 return $indicators;
1684 }
1685
1686 /**
1687 * Score title for SEO effectiveness
1688 *
1689 * @param string $title Page title
1690 * @return array Title scoring
1691 */
1692 private function score_title_seo(string $title): array {
1693 $score = 0;
1694 $max_score = 100;
1695 $feedback = [];
1696
1697 // Length check (optimal: 50-60 characters)
1698 $length = strlen($title);
1699 if ($length >= 50 && $length <= 60) {
1700 $score += 25;
1701 $feedback[] = 'Good length (50-60 chars)';
1702 } elseif ($length >= 40 && $length <= 70) {
1703 $score += 15;
1704 $feedback[] = 'Acceptable length';
1705 } else {
1706 $feedback[] = $length < 40 ? 'Too short (under 40 chars)' : 'Too long (over 70 chars)';
1707 }
1708
1709 // Word count (optimal: 5-9 words)
1710 $word_count = str_word_count($title);
1711 if ($word_count >= 5 && $word_count <= 9) {
1712 $score += 20;
1713 $feedback[] = 'Good word count';
1714 } elseif ($word_count >= 3 && $word_count <= 12) {
1715 $score += 10;
1716 $feedback[] = 'Acceptable word count';
1717 } else {
1718 $feedback[] = $word_count < 3 ? 'Too few words' : 'Too many words';
1719 }
1720
1721 // Check for power words
1722 $power_words = ['ultimate', 'complete', 'guide', 'best', 'top', 'essential', 'proven', 'expert', 'advanced', 'beginner'];
1723 $has_power_words = false;
1724 foreach ($power_words as $power_word) {
1725 if (stripos($title, $power_word) !== false) {
1726 $has_power_words = true;
1727 break;
1728 }
1729 }
1730 if ($has_power_words) {
1731 $score += 15;
1732 $feedback[] = 'Contains power words';
1733 }
1734
1735 // Check for numbers
1736 if (preg_match('/\d+/', $title)) {
1737 $score += 10;
1738 $feedback[] = 'Contains numbers';
1739 }
1740
1741 // Check for emotional triggers
1742 $emotional_words = ['amazing', 'incredible', 'shocking', 'secret', 'revealed', 'proven', 'guaranteed'];
1743 $has_emotional_words = false;
1744 foreach ($emotional_words as $emotional_word) {
1745 if (stripos($title, $emotional_word) !== false) {
1746 $has_emotional_words = true;
1747 break;
1748 }
1749 }
1750 if ($has_emotional_words) {
1751 $score += 10;
1752 $feedback[] = 'Contains emotional triggers';
1753 }
1754
1755 // Uniqueness check (avoid generic titles)
1756 $generic_patterns = ['untitled', 'new page', 'home', 'welcome'];
1757 $is_generic = false;
1758 foreach ($generic_patterns as $pattern) {
1759 if (stripos($title, $pattern) !== false) {
1760 $is_generic = true;
1761 break;
1762 }
1763 }
1764 if (!$is_generic) {
1765 $score += 20;
1766 $feedback[] = 'Appears unique';
1767 } else {
1768 $feedback[] = 'Appears generic';
1769 }
1770
1771 return [
1772 'score' => min($score, $max_score),
1773 'max_score' => $max_score,
1774 'grade' => $this->get_grade_from_score($score),
1775 'feedback' => $feedback
1776 ];
1777 }
1778
1779 /**
1780 * Score meta description for SEO effectiveness
1781 *
1782 * @param string $meta_desc Meta description
1783 * @return array Meta description scoring
1784 */
1785 private function score_meta_description(string $meta_desc): array {
1786 $score = 0;
1787 $max_score = 100;
1788 $feedback = [];
1789
1790 if (empty($meta_desc)) {
1791 return [
1792 'score' => 0,
1793 'max_score' => $max_score,
1794 'grade' => 'F',
1795 'feedback' => ['No meta description found']
1796 ];
1797 }
1798
1799 // Length check (optimal: 150-160 characters)
1800 $length = strlen($meta_desc);
1801 if ($length >= 150 && $length <= 160) {
1802 $score += 30;
1803 $feedback[] = 'Optimal length (150-160 chars)';
1804 } elseif ($length >= 120 && $length <= 170) {
1805 $score += 20;
1806 $feedback[] = 'Good length';
1807 } elseif ($length >= 100 && $length <= 180) {
1808 $score += 10;
1809 $feedback[] = 'Acceptable length';
1810 } else {
1811 $feedback[] = $length < 100 ? 'Too short (under 100 chars)' : 'Too long (over 180 chars)';
1812 }
1813
1814 // Check for call-to-action
1815 $cta_words = ['learn', 'discover', 'find out', 'get', 'download', 'try', 'start', 'join', 'sign up', 'contact', 'buy', 'shop'];
1816 $has_cta = false;
1817 foreach ($cta_words as $cta_word) {
1818 if (stripos($meta_desc, $cta_word) !== false) {
1819 $has_cta = true;
1820 break;
1821 }
1822 }
1823 if ($has_cta) {
1824 $score += 20;
1825 $feedback[] = 'Contains call-to-action';
1826 }
1827
1828 // Check for unique selling proposition
1829 $usp_words = ['best', 'top', 'leading', 'expert', 'professional', 'trusted', 'proven', 'award-winning'];
1830 $has_usp = false;
1831 foreach ($usp_words as $usp_word) {
1832 if (stripos($meta_desc, $usp_word) !== false) {
1833 $has_usp = true;
1834 break;
1835 }
1836 }
1837 if ($has_usp) {
1838 $score += 15;
1839 $feedback[] = 'Contains unique selling proposition';
1840 }
1841
1842 // Check for benefits/value proposition
1843 $benefit_words = ['save', 'improve', 'increase', 'boost', 'enhance', 'optimize', 'maximize', 'reduce', 'eliminate'];
1844 $has_benefits = false;
1845 foreach ($benefit_words as $benefit_word) {
1846 if (stripos($meta_desc, $benefit_word) !== false) {
1847 $has_benefits = true;
1848 break;
1849 }
1850 }
1851 if ($has_benefits) {
1852 $score += 15;
1853 $feedback[] = 'Highlights benefits';
1854 }
1855
1856 // Readability check
1857 $sentences = preg_split('/[.!?]+/', $meta_desc);
1858 $sentence_count = count(array_filter($sentences, function($s) { return trim($s) !== '';
1859 }));
1860 if ($sentence_count >= 1 && $sentence_count <= 3) {
1861 $score += 20;
1862 $feedback[] = 'Good sentence structure';
1863 } else {
1864 $feedback[] = $sentence_count === 0 ? 'No clear sentences' : 'Too many sentences';
1865 }
1866
1867 return [
1868 'score' => min($score, $max_score),
1869 'max_score' => $max_score,
1870 'grade' => $this->get_grade_from_score($score),
1871 'feedback' => $feedback
1872 ];
1873 }
1874
1875 /**
1876 * Assess content depth based on structure and length
1877 *
1878 * @param array $headings Heading structure
1879 * @param int $word_count Word count
1880 * @return array Content depth assessment
1881 */
1882 private function assess_content_depth(array $headings, int $word_count): array {
1883 $depth_score = 0;
1884 $max_score = 100;
1885
1886 // Word count scoring (more words = more depth)
1887 if ($word_count >= 2000) {
1888 $depth_score += 40;
1889 } elseif ($word_count >= 1000) {
1890 $depth_score += 30;
1891 } elseif ($word_count >= 500) {
1892 $depth_score += 20;
1893 } elseif ($word_count >= 300) {
1894 $depth_score += 10;
1895 }
1896
1897 // Heading structure scoring
1898 $total_headings = 0;
1899 $heading_levels = 0;
1900 foreach ($headings as $level => $level_headings) {
1901 $total_headings += count($level_headings);
1902 $heading_levels++;
1903 }
1904
1905 if ($total_headings >= 10) {
1906 $depth_score += 25;
1907 } elseif ($total_headings >= 5) {
1908 $depth_score += 15;
1909 } elseif ($total_headings >= 3) {
1910 $depth_score += 10;
1911 }
1912
1913 // Heading hierarchy scoring
1914 if ($heading_levels >= 3) {
1915 $depth_score += 20;
1916 } elseif ($heading_levels >= 2) {
1917 $depth_score += 15;
1918 }
1919
1920 // Content structure bonus
1921 if (isset($headings['h1']) && isset($headings['h2'])) {
1922 $depth_score += 15;
1923 }
1924
1925 // Determine depth level
1926 if ($depth_score >= 80) {
1927 $level = 'Comprehensive';
1928 } elseif ($depth_score >= 60) {
1929 $level = 'Detailed';
1930 } elseif ($depth_score >= 40) {
1931 $level = 'Moderate';
1932 } elseif ($depth_score >= 20) {
1933 $level = 'Basic';
1934 } else {
1935 $level = 'Shallow';
1936 }
1937
1938 return [
1939 'score' => min($depth_score, $max_score),
1940 'level' => $level,
1941 'word_count' => $word_count,
1942 'total_headings' => $total_headings,
1943 'heading_levels' => $heading_levels
1944 ];
1945 }
1946
1947 /**
1948 * Convert numeric score to letter grade
1949 *
1950 * @param int $score Numeric score
1951 * @return string Letter grade
1952 */
1953 private function get_grade_from_score(int $score): string {
1954 if ($score >= 90) { return 'A';
1955 }
1956 if ($score >= 80) { return 'B';
1957 }
1958 if ($score >= 70) { return 'C';
1959 }
1960 if ($score >= 60) { return 'D';
1961 }
1962 return 'F';
1963 }
1964
1965 /**
1966 * Log AI usage for analytics
1967 *
1968 * @param int $user_id User ID
1969 * @param string $action Action performed
1970 * @param int $tokens_used Tokens consumed
1971 * @param int|null $post_id Related post/brief ID
1972 * @param string|null $raw_response Raw AI response for debugging
1973 * @param string|null $actual_model Actual model used (from response)
1974 * @return int Usage record ID
1975 */
1976 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 {
1977 global $wpdb;
1978
1979 $table_name = $wpdb->prefix . 'thinkrank_ai_usage';
1980
1981 $metadata = [];
1982 if ($raw_response) {
1983 $metadata['raw_response'] = $raw_response;
1984 }
1985 if ($actual_model) {
1986 $metadata['actual_model'] = $actual_model;
1987 }
1988
1989 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- AI usage logging requires direct database access
1990 $wpdb->insert(
1991 $table_name,
1992 [
1993 'user_id' => $user_id,
1994 'action' => $action,
1995 'tokens_used' => $tokens_used,
1996 'provider' => $this->settings->get('ai_provider', 'openai'),
1997 'post_id' => $post_id,
1998 'metadata' => !empty($metadata) ? wp_json_encode($metadata) : null,
1999 'created_at' => current_time('mysql'),
2000 ],
2001 ['%d', '%s', '%d', '%s', '%d', '%s', '%s']
2002 );
2003
2004 return $wpdb->insert_id;
2005 }
2006
2007 /**
2008 * Get raw AI response for a brief from ai_usage table
2009 *
2010 * @param int $brief_id Brief ID
2011 * @return string Raw AI response or empty string if not found
2012 */
2013 private function get_raw_response_for_brief(int $brief_id): string {
2014 global $wpdb;
2015
2016 $table_name = $wpdb->prefix . 'thinkrank_ai_usage';
2017
2018 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- AI usage retrieval requires direct database access
2019 $result = $wpdb->get_var(
2020 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is validated with WordPress prefix
2021 $wpdb->prepare(
2022 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is validated with WordPress prefix
2023 "SELECT metadata FROM `{$table_name}` WHERE post_id = %d AND action = 'content_brief' ORDER BY created_at DESC LIMIT 1",
2024 $brief_id
2025 )
2026 );
2027
2028 if ($result) {
2029 $metadata = json_decode($result, true);
2030 return $metadata['raw_response'] ?? '';
2031 }
2032
2033 return '';
2034 }
2035
2036 /**
2037 * Get actual model used for a brief from ai_usage table
2038 *
2039 * @param int $brief_id Brief ID
2040 * @return string|null Actual model used or null if not found
2041 */
2042 private function get_actual_model_for_brief(int $brief_id): ?string {
2043 global $wpdb;
2044
2045 $table_name = $wpdb->prefix . 'thinkrank_ai_usage';
2046
2047 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- AI usage retrieval requires direct database access
2048 $result = $wpdb->get_var(
2049 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is validated with WordPress prefix
2050 $wpdb->prepare(
2051 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is validated with WordPress prefix
2052 "SELECT metadata FROM `{$table_name}` WHERE post_id = %d AND action = 'content_brief' ORDER BY created_at DESC LIMIT 1",
2053 $brief_id
2054 )
2055 );
2056
2057 if ($result) {
2058 $metadata = json_decode($result, true);
2059 return $metadata['actual_model'] ?? null;
2060 }
2061
2062 return null;
2063 }
2064
2065 /**
2066 * Get Prompt Builder instance
2067 *
2068 * @since 1.0.0
2069 *
2070 * @return \ThinkRank\AI\Prompt_Builder Prompt Builder instance
2071 */
2072 private function get_prompt_builder(): \ThinkRank\AI\Prompt_Builder {
2073 if (!class_exists('ThinkRank\\AI\\Prompt_Builder')) {
2074 require_once THINKRANK_PLUGIN_DIR . 'includes/ai/class-prompt-builder.php';
2075 }
2076 return new \ThinkRank\AI\Prompt_Builder();
2077 }
2078 }
2079