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

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