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

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