| @@ -48,8 +48,34 @@ | ||
| 48 | 48 | */ |
| 49 | 49 | private const OPENAI_REQUEST_TIMEOUT = 300; |
| 50 | 50 | |
| 51 | 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 | + /** | |
| 52 | 78 | * Settings instance |
| 53 | 79 | * |
| 54 | 80 | * @var Settings |
| 55 | 81 | */ |
| @@ -57,9 +83,11 @@ | ||
| 57 | 83 | |
| 58 | 84 | /** |
| 59 | 85 | * AI client instance |
| 60 | 86 | * |
| 61 | - * @var OpenAI_Client|Claude_Client | |
| 87 | + * Null when the generator was built for storage-only work. | |
| 88 | + * | |
| 89 | + * @var OpenAI_Client|Claude_Client|null | |
| 62 | 90 | */ |
| 63 | 91 | private $ai_client; |
| 64 | 92 | |
| 65 | 93 | /** |
| @@ -66,18 +94,31 @@ | ||
| 66 | 94 | * Constructor |
| 67 | 95 | * |
| 68 | 96 | * @param Settings|null $settings Settings instance |
| 69 | 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. | |
| 70 | 101 | */ |
| 71 | - public function __construct(?Settings $settings = null, $ai_client = null) { | |
| 102 | + public function __construct(?Settings $settings = null, $ai_client = null, bool $require_ai_client = true) { | |
| 72 | 103 | $this->settings = $settings ?? Settings::instance(); |
| 73 | 104 | |
| 74 | 105 | if ($ai_client) { |
| 75 | 106 | $this->ai_client = $ai_client; |
| 76 | - } else { | |
| 77 | - // Fallback to creating own client for backward compatibility | |
| 78 | - $this->init_ai_client(); | |
| 107 | + | |
| 108 | + return; | |
| 79 | 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(); | |
| 80 | 121 | } |
| 81 | 122 | |
| 82 | 123 | /** |
| 83 | 124 | * Initialize AI client based on available API keys |
| @@ -82,11 +123,13 @@ | ||
| 82 | 123 | /** |
| 83 | 124 | * Initialize AI client based on available API keys |
| 84 | 125 | * |
| 85 | 126 | * @return void |
| 127 | + * | |
| 128 | + * @throws \Exception On failure. | |
| 86 | 129 | */ |
| 87 | 130 | private function init_ai_client(): void { |
| 88 | - $provider = $this->settings->get('ai_provider', 'openai'); | |
| 131 | + $provider = $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE); | |
| 89 | 132 | |
| 90 | 133 | if ($provider === 'openai') { |
| 91 | 134 | $api_key = $this->settings->get('openai_api_key'); |
| 92 | 135 | if ($api_key) { |
| @@ -110,11 +153,31 @@ | ||
| 110 | 153 | if ($api_key) { |
| 111 | 154 | $model = $this->settings->get('openrouter_model', Settings::DEFAULT_OPENROUTER_MODEL); |
| 112 | 155 | $this->ai_client = new OpenRouter_Client($api_key, $model, self::AI_REQUEST_TIMEOUT); |
| 113 | 156 | } |
| 157 | + } elseif ($provider === 'openai_compatible') { | |
| 158 | + // Same client as OpenAI, different host — and the key is optional, | |
| 159 | + // so the URL and model id are what gate it (#721). The user's own | |
| 160 | + // timeout applies: a local model writing a brief on CPU is slow, | |
| 161 | + // and the setting exists for exactly that. | |
| 162 | + $base_url = (string) $this->settings->get('openai_compatible_base_url', ''); | |
| 163 | + $model = trim((string) $this->settings->get('openai_compatible_model', '')); | |
| 164 | + if ('' !== $base_url && '' !== $model) { | |
| 165 | + $this->ai_client = new OpenAI_Client( | |
| 166 | + (string) $this->settings->get('openai_compatible_api_key', ''), | |
| 167 | + $model, | |
| 168 | + (int) $this->settings->get('openai_compatible_timeout', Settings::DEFAULT_OPENAI_COMPATIBLE_TIMEOUT), | |
| 169 | + $base_url | |
| 170 | + ); | |
| 171 | + $this->ai_client->set_json_mode((bool) $this->settings->get('openai_compatible_json_mode', false)); | |
| 172 | + } | |
| 114 | 173 | } |
| 115 | 174 | |
| 116 | 175 | if (!$this->ai_client) { |
| 176 | + if ('openai_compatible' === $provider) { | |
| 177 | + throw new \Exception('Please set the base URL and model id for your OpenAI-compatible endpoint in ThinkRank settings.'); | |
| 178 | + } | |
| 179 | + | |
| 117 | 180 | throw new \Exception('Please configure your AI provider API key in ThinkRank settings.'); |
| 118 | 181 | } |
| 119 | 182 | } |
| 120 | 183 | |
| @@ -129,9 +192,15 @@ | ||
| 129 | 192 | return $this->ai_client->get_model(); |
| 130 | 193 | } |
| 131 | 194 | |
| 132 | 195 | // Fallback to settings |
| 133 | - $provider = $this->settings->get('ai_provider', 'openai'); | |
| 196 | + $provider = $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE); | |
| 197 | + if (Settings::AI_PROVIDER_NONE === $provider) { | |
| 198 | + // No provider chosen, so there is no model to name. Reporting the | |
| 199 | + // OpenAI default here would attribute output to a provider the site | |
| 200 | + // never selected (#572). | |
| 201 | + return ''; | |
| 202 | + } | |
| 134 | 203 | if ($provider === 'claude') { |
| 135 | 204 | return $this->settings->get('claude_model', Settings::DEFAULT_CLAUDE_MODEL); |
| 136 | 205 | } elseif ($provider === 'gemini') { |
| 137 | 206 | return $this->settings->get('gemini_model', Settings::DEFAULT_GEMINI_MODEL); |
| @@ -136,8 +205,10 @@ | ||
| 136 | 205 | } elseif ($provider === 'gemini') { |
| 137 | 206 | return $this->settings->get('gemini_model', Settings::DEFAULT_GEMINI_MODEL); |
| 138 | 207 | } elseif ($provider === 'openrouter') { |
| 139 | 208 | return $this->settings->get('openrouter_model', Settings::DEFAULT_OPENROUTER_MODEL); |
| 209 | + } elseif ($provider === 'openai_compatible') { | |
| 210 | + return (string) $this->settings->get('openai_compatible_model', ''); | |
| 140 | 211 | } else { |
| 141 | 212 | return $this->settings->get('openai_model', Settings::DEFAULT_OPENAI_MODEL); |
| 142 | 213 | } |
| 143 | 214 | } |
| @@ -142,14 +213,51 @@ | ||
| 142 | 213 | } |
| 143 | 214 | } |
| 144 | 215 | |
| 145 | 216 | /** |
| 217 | + * Resolve the reasoning-effort level for a content-brief request. | |
| 218 | + * | |
| 219 | + * Without an explicit level, GPT-5 models run at their default (maximum) | |
| 220 | + * reasoning effort against a ~95% completion budget — the slowest and | |
| 221 | + * costliest configuration, where billed reasoning tokens (drawn from the | |
| 222 | + * same budget) are spent before any visible output (issue #286). | |
| 223 | + * | |
| 224 | + * A brief is a structured planning task, so 'low' is a provisional middle | |
| 225 | + * ground between 'minimal' and the model's default. | |
| 226 | + * The level is filterable so a site can trade latency for more reasoning; | |
| 227 | + * returning '' opts out entirely and lets the model use its default effort. | |
| 228 | + * Only the GPT-5 family consumes this — o1/o3, gpt-4o and the non-OpenAI | |
| 229 | + * clients ignore an unrecognised option key. | |
| 230 | + * | |
| 231 | + * @param string $model The resolved model ID (passed to the filter). | |
| 232 | + * @param array $params The brief generation parameters (passed to the filter). | |
| 233 | + * @return string One of 'minimal' | 'low' | 'medium' | 'high', or '' to opt out. | |
| 234 | + */ | |
| 235 | + private function resolve_reasoning_effort(string $model, array $params): string { | |
| 236 | + /** | |
| 237 | + * Filter the reasoning-effort level used for content-brief generation. | |
| 238 | + * | |
| 239 | + * @param string $effort The default level ('low'). Return '' to opt out. | |
| 240 | + * @param string $model The resolved model ID for this request. | |
| 241 | + * @param array $params The brief generation parameters. | |
| 242 | + */ | |
| 243 | + $effort = (string) apply_filters('thinkrank_content_brief_reasoning_effort', 'low', $model, $params); | |
| 244 | + | |
| 245 | + // Only values OpenAI accepts may reach the request body ('' opts out). | |
| 246 | + // An unrecognised filter return (e.g. 'turbo') would otherwise be sent | |
| 247 | + // verbatim and fail the whole brief with a 400, so degrade to the | |
| 248 | + // documented default instead. | |
| 249 | + $allowed = ['', 'minimal', 'low', 'medium', 'high']; | |
| 250 | + return in_array($effort, $allowed, true) ? $effort : 'low'; | |
| 251 | + } | |
| 252 | + | |
| 253 | + /** | |
| 146 | 254 | * Get current AI provider |
| 147 | 255 | * |
| 148 | 256 | * @return string Current provider name |
| 149 | 257 | */ |
| 150 | 258 | private function get_current_provider(): string { |
| 151 | - return $this->settings->get('ai_provider', 'openai'); | |
| 259 | + return $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE); | |
| 152 | 260 | } |
| 153 | 261 | |
| 154 | 262 | /** |
| 155 | 263 | * Extract token usage from AI response |
| @@ -159,9 +267,9 @@ | ||
| 159 | 267 | */ |
| 160 | 268 | private function extract_token_usage(array $ai_response): int { |
| 161 | 269 | $provider = $this->get_current_provider(); |
| 162 | 270 | |
| 163 | - if ($provider === 'openai' || $provider === 'openrouter') { | |
| 271 | + if ($provider === 'openai' || $provider === 'openrouter' || $provider === 'openai_compatible') { | |
| 164 | 272 | // OpenAI-compatible format: response['usage']['total_tokens'] |
| 165 | 273 | return (int) ($ai_response['usage']['total_tokens'] ?? 0); |
| 166 | 274 | } elseif ($provider === 'claude') { |
| 167 | 275 | // Claude format: response['usage']['input_tokens'] + response['usage']['output_tokens'] |
| @@ -241,29 +349,55 @@ | ||
| 241 | 349 | $language |
| 242 | 350 | ); |
| 243 | 351 | |
| 244 | 352 | try { |
| 245 | - // Get recommended token limit for content briefs (model-specific) | |
| 246 | - $max_tokens = method_exists($this->ai_client, 'get_recommended_tokens') | |
| 247 | - ? $this->ai_client->get_recommended_tokens('content_brief') | |
| 248 | - : 4000; // Fallback for non-OpenAI clients | |
| 353 | + // Get the model-aware budget for a comprehensive brief, then scale | |
| 354 | + // it to the requested content length so Short/Medium/Long actually | |
| 355 | + // request different budgets (issue #287). Every client (OpenAI, | |
| 356 | + // Claude, Gemini, OpenRouter) implements get_recommended_tokens(), | |
| 357 | + // so there is no model-blind fallback. | |
| 358 | + $base_tokens = (int) $this->ai_client->get_recommended_tokens('content_brief'); | |
| 359 | + $max_tokens = $this->scale_tokens_for_length($base_tokens, $content_length); | |
| 249 | 360 | |
| 361 | + // Bound hidden reasoning on the GPT-5 family (issue #286). See | |
| 362 | + // resolve_reasoning_effort(). Only the GPT-5 family reads this; | |
| 363 | + // o1/o3, gpt-4o and the non-OpenAI clients ignore the option, and | |
| 364 | + // an empty string opts out (model default effort). | |
| 365 | + $reasoning_effort = $this->resolve_reasoning_effort($this->get_current_model(), $params); | |
| 366 | + | |
| 367 | + $completion_options = [ | |
| 368 | + // For GPT‑5 family the client translates max_tokens to | |
| 369 | + // max_completion_tokens internally. Temperature is intentionally | |
| 370 | + // omitted: every client defaults it to 0.7, and reasoning models | |
| 371 | + // reject it outright, so passing it here was misleading no-op. | |
| 372 | + 'max_tokens' => $max_tokens, | |
| 373 | + // The brief is one JSON object. Only a compatible endpoint | |
| 374 | + // with JSON mode on reads this; every other client ignores it. | |
| 375 | + 'json_object' => true, | |
| 376 | + ]; | |
| 377 | + if ('' !== $reasoning_effort) { | |
| 378 | + $completion_options['reasoning_effort'] = $reasoning_effort; | |
| 379 | + } | |
| 380 | + | |
| 250 | 381 | // Generate brief using AI |
| 251 | - $ai_response = $this->ai_client->generate_completion($prompt, [ | |
| 252 | - // For GPT‑5 family the client will translate to max_completion_tokens internally | |
| 253 | - 'max_tokens' => $max_tokens, | |
| 254 | - 'temperature' => 0.7, | |
| 255 | - ]); | |
| 382 | + $ai_response = $this->ai_client->generate_completion($prompt, $completion_options); | |
| 256 | 383 | |
| 384 | + // Detect a provider-side non-answer (refusal, policy block, or | |
| 385 | + // truncation) BEFORE attempting text extraction. Otherwise a | |
| 386 | + // refusal — which OpenAI returns as HTTP 200 with content=null — | |
| 387 | + // slips past every isset() branch and gets serialized into the | |
| 388 | + // brief body instead of being reported to the user. | |
| 389 | + $this->guard_against_non_answer($ai_response, $max_tokens); | |
| 390 | + | |
| 257 | 391 | // Extract text content from AI response |
| 258 | 392 | $ai_text = ''; |
| 259 | 393 | |
| 260 | 394 | // Handle OpenAI response format |
| 261 | 395 | if (isset($ai_response['choices'][0]['message']['content'])) { |
| 262 | - $contentField = $ai_response['choices'][0]['message']['content']; | |
| 263 | - if (is_string($contentField)) { | |
| 264 | - $ai_text = $contentField; | |
| 265 | - } elseif (is_array($contentField)) { | |
| 396 | + $content_field = $ai_response['choices'][0]['message']['content']; | |
| 397 | + if (is_string($content_field)) { | |
| 398 | + $ai_text = $content_field; | |
| 399 | + } elseif (is_array($content_field)) { | |
| 266 | 400 | // Concatenate text parts from array-based content (Chat Completions multimodal) |
| 267 | 401 | $parts = array_map(function($part) { |
| 268 | 402 | if (is_array($part)) { |
| 269 | 403 | return $part['text'] ?? ''; |
| @@ -268,9 +402,9 @@ | ||
| 268 | 402 | if (is_array($part)) { |
| 269 | 403 | return $part['text'] ?? ''; |
| 270 | 404 | } |
| 271 | 405 | return is_string($part) ? $part : ''; |
| 272 | - }, $contentField); | |
| 406 | + }, $content_field); | |
| 273 | 407 | $ai_text = trim(implode("\n", array_filter($parts))); |
| 274 | 408 | } |
| 275 | 409 | } |
| 276 | 410 | // Handle Claude response format |
| @@ -279,15 +413,8 @@ | ||
| 279 | 413 | } |
| 280 | 414 | // Handle Gemini response format |
| 281 | 415 | elseif (isset($ai_response['candidates'][0]['content']['parts'][0]['text'])) { |
| 282 | 416 | $ai_text = $ai_response['candidates'][0]['content']['parts'][0]['text']; |
| 283 | - | |
| 284 | - // A reply cut off at the token limit is incomplete JSON, so it | |
| 285 | - // can never be parsed. Fail with the real reason instead of | |
| 286 | - // saving a brief titled "Unable to parse AI response". | |
| 287 | - if (($ai_response['candidates'][0]['finishReason'] ?? '') === 'MAX_TOKENS') { | |
| 288 | - throw new \Exception('The AI stopped at its output token limit before finishing the brief. Try a shorter content length or fewer competitor URLs.'); | |
| 289 | - } | |
| 290 | 417 | } |
| 291 | 418 | // Handle direct content field |
| 292 | 419 | elseif (isset($ai_response['content']) && is_string($ai_response['content'])) { |
| 293 | 420 | $ai_text = $ai_response['content']; |
| @@ -295,12 +422,19 @@ | ||
| 295 | 422 | // Handle direct string response |
| 296 | 423 | elseif (is_string($ai_response)) { |
| 297 | 424 | $ai_text = $ai_response; |
| 298 | 425 | } |
| 299 | - // If we still don't have text, log the response structure for debugging | |
| 426 | + // No known provider shape matched and guard_against_non_answer() | |
| 427 | + // found nothing it recognised. Never serialize the raw envelope | |
| 428 | + // into the brief body — that turns a clear failure into a saved, | |
| 429 | + // meaningless brief. Log the shape for diagnostics and fail. | |
| 300 | 430 | else { |
| 301 | - // As a last resort, stringify the response for visibility (prevents empty content error) | |
| 302 | - $ai_text = is_array($ai_response) ? wp_json_encode($ai_response) : (string) $ai_response; | |
| 431 | + if (defined('WP_DEBUG') && WP_DEBUG) { | |
| 432 | + $shape = is_array($ai_response) ? implode(', ', array_keys($ai_response)) : gettype($ai_response); | |
| 433 | + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Debug logging only when WP_DEBUG is enabled. | |
| 434 | + error_log('[ThinkRank] Content brief: unrecognised AI response shape. Top-level keys: ' . $shape); | |
| 435 | + } | |
| 436 | + throw new \Exception('The AI returned a response in an unexpected format. Please try again.'); | |
| 303 | 437 | } |
| 304 | 438 | |
| 305 | 439 | // Ensure we have actual text content |
| 306 | 440 | if (empty(trim($ai_text))) { |
| @@ -341,8 +475,18 @@ | ||
| 341 | 475 | |
| 342 | 476 | } catch (\Exception $e) { |
| 343 | 477 | // Provide more specific error messages |
| 344 | 478 | $error_message = $e->getMessage(); |
| 479 | + | |
| 480 | + // Messages we authored for the user (refusals, policy blocks, | |
| 481 | + // token-limit truncation, unexpected shape) all start with "The AI " | |
| 482 | + // and are already actionable. Pass them through verbatim instead of | |
| 483 | + // flattening them via the substring matching below — e.g. so a | |
| 484 | + // refusal is not rewritten into generic "empty content" advice. | |
| 485 | + if (strpos($error_message, 'The AI ') === 0) { | |
| 486 | + throw new \Exception(esc_html($error_message)); | |
| 487 | + } | |
| 488 | + | |
| 345 | 489 | if (strpos($error_message, 'API key') !== false) { |
| 346 | 490 | throw new \Exception('API key configuration error. Please check your AI provider settings.'); |
| 347 | 491 | } elseif (strpos($error_message, 'Invalid AI response format') !== false) { |
| 348 | 492 | throw new \Exception('AI service returned an unexpected response format. Please try again.'); |
| @@ -354,8 +498,120 @@ | ||
| 354 | 498 | } |
| 355 | 499 | } |
| 356 | 500 | |
| 357 | 501 | /** |
| 502 | + * Scale the model-aware brief budget to the requested content length. | |
| 503 | + * | |
| 504 | + * get_recommended_tokens('content_brief') returns the budget for a full, | |
| 505 | + * comprehensive (Long) brief, already capped at the model's completion | |
| 506 | + * ceiling. Shorter tiers request proportionally less so that choosing Short | |
| 507 | + * is genuinely faster and cheaper (issue #287), while every tier stays at or | |
| 508 | + * below the base and at or above MIN_BRIEF_TOKENS so it cannot truncate. | |
| 509 | + * | |
| 510 | + * @param int $base_tokens Model-aware budget for a comprehensive brief. | |
| 511 | + * @param string $content_length One of 'short' | 'medium' | 'long'. | |
| 512 | + * @return int Scaled max_tokens, clamped to [floor, base_tokens]. | |
| 513 | + */ | |
| 514 | + private function scale_tokens_for_length(int $base_tokens, string $content_length): int { | |
| 515 | + // Unknown/missing length falls back to the medium tier — never to 0 or | |
| 516 | + // to the raw ceiling. | |
| 517 | + $multiplier = self::LENGTH_TOKEN_MULTIPLIERS[$content_length] | |
| 518 | + ?? self::LENGTH_TOKEN_MULTIPLIERS['medium']; | |
| 519 | + | |
| 520 | + $scaled = (int) round($base_tokens * $multiplier); | |
| 521 | + | |
| 522 | + // The floor can never exceed the base itself, so a model with a tiny | |
| 523 | + // ceiling still yields a sane, in-range value. | |
| 524 | + $floor = (int) min($base_tokens, self::MIN_BRIEF_TOKENS); | |
| 525 | + | |
| 526 | + return max($floor, min($scaled, $base_tokens)); | |
| 527 | + } | |
| 528 | + | |
| 529 | + /** | |
| 530 | + * Detect a provider-side non-answer and fail with the real reason. | |
| 531 | + * | |
| 532 | + * A refusal, content-policy block, or token-limit truncation is not a | |
| 533 | + * usable brief. Each provider signals these differently, and none of the | |
| 534 | + * signals set the content field the extraction chain looks for — so if we | |
| 535 | + * don't catch them here they fall through to the "unexpected format" path | |
| 536 | + * (or, historically, were serialized into the brief body). All messages | |
| 537 | + * start with "The AI " so the outer catch passes them through unchanged. | |
| 538 | + * | |
| 539 | + * @param mixed $ai_response Raw response from the AI client. | |
| 540 | + * @param int $requested_tokens The max_tokens this request asked for; 0 when unknown. | |
| 541 | + * @throws \Exception If the response is a refusal, policy block, or truncation. | |
| 542 | + */ | |
| 543 | + private function guard_against_non_answer($ai_response, int $requested_tokens = 0): void { | |
| 544 | + if (!is_array($ai_response)) { | |
| 545 | + return; | |
| 546 | + } | |
| 547 | + | |
| 548 | + // --- OpenAI (Chat Completions) --- | |
| 549 | + // A structured refusal is HTTP 200 with message.content=null and the | |
| 550 | + // stated reason carried in message.refusal. finish_reason distinguishes | |
| 551 | + // a policy block from a truncated completion. | |
| 552 | + if (isset($ai_response['choices'][0]['message'])) { | |
| 553 | + $message = $ai_response['choices'][0]['message']; | |
| 554 | + $finish = (string) ($ai_response['choices'][0]['finish_reason'] ?? ''); | |
| 555 | + | |
| 556 | + if (!empty($message['refusal'])) { | |
| 557 | + throw new \Exception(esc_html(sprintf( | |
| 558 | + 'The AI declined to generate this brief: %s', | |
| 559 | + (string) $message['refusal'] | |
| 560 | + ))); | |
| 561 | + } | |
| 562 | + if ('content_filter' === $finish) { | |
| 563 | + throw new \Exception('The AI blocked this request under its content policy. Try a different topic or less sensitive keywords.'); | |
| 564 | + } | |
| 565 | + // A self-hosted server can stop short of max_tokens because the | |
| 566 | + // prompt and the answer together filled its context window | |
| 567 | + // (Ollama loads models at 4096 by default). A bigger output budget | |
| 568 | + // cannot fix that, so say what can. | |
| 569 | + $completion_tokens = (int) ($ai_response['usage']['completion_tokens'] ?? 0); | |
| 570 | + if ('length' === $finish && $requested_tokens > 0 && $completion_tokens > 0 && $completion_tokens < $requested_tokens) { | |
| 571 | + throw new \Exception(esc_html(sprintf( | |
| 572 | + 'The AI stopped after %1$d tokens, short of the %2$d allowed, because the server ran out of context window before finishing the brief. Raise the context length on your AI server (for Ollama, set OLLAMA_CONTEXT_LENGTH to 16384 or more) and try again.', | |
| 573 | + $completion_tokens, | |
| 574 | + $requested_tokens | |
| 575 | + ))); | |
| 576 | + } | |
| 577 | + if ('length' === $finish) { | |
| 578 | + 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.'); | |
| 579 | + } | |
| 580 | + } | |
| 581 | + | |
| 582 | + // --- Claude (Messages) --- | |
| 583 | + if (isset($ai_response['stop_reason'])) { | |
| 584 | + $stop_reason = (string) $ai_response['stop_reason']; | |
| 585 | + if ('refusal' === $stop_reason) { | |
| 586 | + throw new \Exception('The AI declined to generate this brief for this topic. Try a different topic or less sensitive keywords.'); | |
| 587 | + } | |
| 588 | + if ('max_tokens' === $stop_reason) { | |
| 589 | + 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.'); | |
| 590 | + } | |
| 591 | + } | |
| 592 | + | |
| 593 | + // --- Gemini --- | |
| 594 | + // A prompt rejected outright returns no candidate at all, only | |
| 595 | + // promptFeedback.blockReason; a candidate can also finish on SAFETY or | |
| 596 | + // PROHIBITED_CONTENT, or be truncated at MAX_TOKENS. | |
| 597 | + $block_reason = (string) ($ai_response['promptFeedback']['blockReason'] ?? ''); | |
| 598 | + if ('' !== $block_reason) { | |
| 599 | + throw new \Exception(esc_html(sprintf( | |
| 600 | + 'The AI blocked this request under its content policy (%s). Try a different topic or less sensitive keywords.', | |
| 601 | + $block_reason | |
| 602 | + ))); | |
| 603 | + } | |
| 604 | + $gemini_finish = (string) ($ai_response['candidates'][0]['finishReason'] ?? ''); | |
| 605 | + if (in_array($gemini_finish, ['SAFETY', 'PROHIBITED_CONTENT'], true)) { | |
| 606 | + throw new \Exception('The AI blocked this request under its content policy. Try a different topic or less sensitive keywords.'); | |
| 607 | + } | |
| 608 | + if ('MAX_TOKENS' === $gemini_finish) { | |
| 609 | + 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.'); | |
| 610 | + } | |
| 611 | + } | |
| 612 | + | |
| 613 | + /** | |
| 358 | 614 | * Validate brief generation parameters |
| 359 | 615 | * |
| 360 | 616 | * @param array $params Parameters to validate |
| 361 | 617 | * @throws \Exception If validation fails |
| @@ -365,19 +621,19 @@ | ||
| 365 | 621 | throw new \Exception('Target keywords are required and must be an array.'); |
| 366 | 622 | } |
| 367 | 623 | |
| 368 | 624 | $valid_content_types = ['blog_post', 'product_page', 'landing_page', 'tutorial']; |
| 369 | - if (!empty($params['content_type']) && !in_array($params['content_type'], $valid_content_types)) { | |
| 625 | + if (!empty($params['content_type']) && !in_array($params['content_type'], $valid_content_types, true)) { | |
| 370 | 626 | throw new \Exception('Invalid content type specified.'); |
| 371 | 627 | } |
| 372 | 628 | |
| 373 | 629 | $valid_lengths = ['short', 'medium', 'long']; |
| 374 | - if (!empty($params['content_length']) && !in_array($params['content_length'], $valid_lengths)) { | |
| 630 | + if (!empty($params['content_length']) && !in_array($params['content_length'], $valid_lengths, true)) { | |
| 375 | 631 | throw new \Exception('Invalid content length specified.'); |
| 376 | 632 | } |
| 377 | 633 | |
| 378 | 634 | $valid_tones = ['professional', 'casual', 'technical', 'friendly']; |
| 379 | - if (!empty($params['tone']) && !in_array($params['tone'], $valid_tones)) { | |
| 635 | + if (!empty($params['tone']) && !in_array($params['tone'], $valid_tones, true)) { | |
| 380 | 636 | throw new \Exception('Invalid tone specified.'); |
| 381 | 637 | } |
| 382 | 638 | } |
| 383 | 639 | |
| @@ -445,9 +701,9 @@ | ||
| 445 | 701 | 'title' => $json_data['title_suggestions'] ?? [], |
| 446 | 702 | 'meta_description' => $json_data['meta_descriptions'][0] ?? '', |
| 447 | 703 | 'meta_descriptions' => $json_data['meta_descriptions'] ?? [], |
| 448 | 704 | 'url_slugs' => $json_data['url_slugs'] ?? [], |
| 449 | - 'outline' => $json_data['outline'] ?? [], | |
| 705 | + 'outline' => self::strip_outline_level_labels($json_data['outline'] ?? []), | |
| 450 | 706 | 'seo_recommendations' => [ |
| 451 | 707 | 'title_suggestions' => $json_data['title_suggestions'] ?? [], |
| 452 | 708 | 'meta_description' => $json_data['meta_descriptions'][0] ?? '', |
| 453 | 709 | 'meta_descriptions' => $json_data['meta_descriptions'] ?? [], |
| @@ -473,9 +729,9 @@ | ||
| 473 | 729 | ], |
| 474 | 730 | 'competitor_gaps' => $json_data['competitor_analysis']['content_gaps'] ?? [], |
| 475 | 731 | 'call_to_actions' => $json_data['call_to_actions'] ?? [], |
| 476 | 732 | 'writing_guidelines' => $json_data['writing_guidelines'] ?? [], |
| 477 | - 'content_body' => $json_data['content_body'] ?? '', | |
| 733 | + 'content_body' => self::strip_heading_level_labels((string) ($json_data['content_body'] ?? '')), | |
| 478 | 734 | 'estimated_word_count' => $this->get_word_count_estimate($original_params['content_length'] ?? 'medium'), |
| 479 | 735 | 'raw_response' => '', // Will be retrieved from ai_usage table |
| 480 | 736 | 'generation_params' => $original_params, |
| 481 | 737 | 'parsing_status' => 'success', |
| @@ -483,8 +739,118 @@ | ||
| 483 | 739 | ]; |
| 484 | 740 | } |
| 485 | 741 | |
| 486 | 742 | /** |
| 743 | + * Remove a leading level label from a heading string. | |
| 744 | + * | |
| 745 | + * The prompt's own JSON example labelled outline headings with their level | |
| 746 | + * (`"heading": "H1: Main Title"` next to a separate `"level": 1`), so the | |
| 747 | + * model often carried the convention into the drafted article and Pro's | |
| 748 | + * "Insert into post" wrote `<h2>H2: Real Heading</h2>` into published | |
| 749 | + * content. The prompt no longer does that, but a prompt change never fully | |
| 750 | + * binds a model — so the label is stripped here too (#410). | |
| 751 | + * | |
| 752 | + * Covers the label forms a model actually emits: `H2:`, `h3:`, `H2 -`, | |
| 753 | + * `H4.`, `H2)` and the en/em dash variants, optionally wrapped in markdown | |
| 754 | + * emphasis (`**H2:**`). The delimiter is anchored directly after the digit | |
| 755 | + * so `H10:` — a plausible heading in a numbered list — is left alone, and | |
| 756 | + * only a leading label is matched so body copy that mentions a level | |
| 757 | + * survives. Trailing emphasis is consumed only when the same marker opened | |
| 758 | + * the label, so `H2: *emphasised start*` keeps its asterisks. | |
| 759 | + * | |
| 760 | + * @since 2.0.1 | |
| 761 | + * | |
| 762 | + * @param string $heading Heading text. | |
| 763 | + * @return string Heading without its level prefix. | |
| 764 | + */ | |
| 765 | + public static function strip_level_label(string $heading): string { | |
| 766 | + // En dash and em dash as raw UTF-8 bytes, so the pattern needs no /u | |
| 767 | + // modifier and cannot blank a heading that is not valid UTF-8. | |
| 768 | + $delimiter = '(?:[:.)\-]|\xe2\x80\x93|\xe2\x80\x94)'; | |
| 769 | + $emphasis = '(\*{1,3}|_{1,3})'; | |
| 770 | + | |
| 771 | + $pattern = '/^\s*(?:' | |
| 772 | + . $emphasis . '\s*[Hh][1-6]\s*' . $delimiter . '\s*\1' | |
| 773 | + . '|[Hh][1-6]\s*' . $delimiter | |
| 774 | + . ')\s*/'; | |
| 775 | + | |
| 776 | + return (string) preg_replace($pattern, '', $heading); | |
| 777 | + } | |
| 778 | + | |
| 779 | + /** | |
| 780 | + * Strip a level label from a heading's inner HTML. | |
| 781 | + * | |
| 782 | + * A model drafting publish-ready HTML often wraps the heading text in an | |
| 783 | + * inline tag (`<h2><strong>H2: Real Heading</strong></h2>`). That pushes a | |
| 784 | + * `<` in front of the label, so the leading run of inline opening tags is | |
| 785 | + * set aside and re-attached around the cleaned text. | |
| 786 | + * | |
| 787 | + * @since 2.0.1 | |
| 788 | + * | |
| 789 | + * @param string $inner Heading inner HTML. | |
| 790 | + * @return string Inner HTML without the level prefix. | |
| 791 | + */ | |
| 792 | + private static function strip_inner_level_label(string $inner): string { | |
| 793 | + $prefix = ''; | |
| 794 | + | |
| 795 | + if (preg_match('/^(\s*(?:<(?:strong|em|b|i|span|mark|code|u)\b[^>]*>\s*)+)(.*)$/is', $inner, $parts)) { | |
| 796 | + $prefix = $parts[1]; | |
| 797 | + $inner = $parts[2]; | |
| 798 | + } | |
| 799 | + | |
| 800 | + return $prefix . self::strip_level_label($inner); | |
| 801 | + } | |
| 802 | + | |
| 803 | + /** | |
| 804 | + * Strip level labels from every heading in an outline. | |
| 805 | + * | |
| 806 | + * @since 2.0.1 | |
| 807 | + * | |
| 808 | + * @param mixed $outline Outline as returned by the model. | |
| 809 | + * @return array Outline with clean headings. | |
| 810 | + */ | |
| 811 | + public static function strip_outline_level_labels($outline): array { | |
| 812 | + if (!is_array($outline)) { | |
| 813 | + return []; | |
| 814 | + } | |
| 815 | + | |
| 816 | + foreach ($outline as $index => $section) { | |
| 817 | + if (is_array($section) && isset($section['heading']) && is_string($section['heading'])) { | |
| 818 | + $outline[$index]['heading'] = self::strip_level_label($section['heading']); | |
| 819 | + } elseif (is_string($section)) { | |
| 820 | + $outline[$index] = self::strip_level_label($section); | |
| 821 | + } | |
| 822 | + } | |
| 823 | + | |
| 824 | + return $outline; | |
| 825 | + } | |
| 826 | + | |
| 827 | + /** | |
| 828 | + * Strip level labels from the heading text inside drafted HTML. | |
| 829 | + * | |
| 830 | + * This is the path that reaches published post content, so it is the one | |
| 831 | + * that matters most. Only the text directly inside an <h1>-<h6> is touched. | |
| 832 | + * | |
| 833 | + * @since 2.0.1 | |
| 834 | + * | |
| 835 | + * @param string $html Drafted article body. | |
| 836 | + * @return string Body with clean headings. | |
| 837 | + */ | |
| 838 | + public static function strip_heading_level_labels(string $html): string { | |
| 839 | + if ('' === $html || false === stripos($html, '<h')) { | |
| 840 | + return $html; | |
| 841 | + } | |
| 842 | + | |
| 843 | + return (string) preg_replace_callback( | |
| 844 | + '/(<h([1-6])\b[^>]*>)(.*?)(<\/h\2>)/is', | |
| 845 | + static function (array $parts): string { | |
| 846 | + return $parts[1] . self::strip_inner_level_label($parts[3]) . $parts[4]; | |
| 847 | + }, | |
| 848 | + $html | |
| 849 | + ); | |
| 850 | + } | |
| 851 | + | |
| 852 | + /** | |
| 487 | 853 | * Create error response when JSON parsing fails |
| 488 | 854 | * |
| 489 | 855 | * @param string $ai_response Raw AI response |
| 490 | 856 | * @param array $original_params Original generation parameters |
| @@ -647,63 +1013,17 @@ | ||
| 647 | 1013 | * resolves only to public IP addresses. |
| 648 | 1014 | * |
| 649 | 1015 | * Prevents SSRF — a low-privilege user could otherwise point competitor |
| 650 | 1016 | * scraping at loopback, link-local (e.g. 169.254.169.254 cloud metadata), |
| 651 | - * private or reserved addresses to probe internal services. | |
| 1017 | + * CGNAT, private or reserved addresses to probe internal services. The | |
| 1018 | + * block list lives in {@see \ThinkRank\Core\Url_Safety} so this and the | |
| 1019 | + * schema importer can never drift apart. | |
| 652 | 1020 | * |
| 653 | 1021 | * @param string $url URL to validate. |
| 654 | 1022 | * @return bool True when safe to fetch. |
| 655 | 1023 | */ |
| 656 | 1024 | private function is_safe_public_url(string $url): bool { |
| 657 | - $parts = wp_parse_url($url); | |
| 658 | - if (empty($parts['scheme']) || empty($parts['host'])) { | |
| 659 | - return false; | |
| 660 | - } | |
| 661 | - | |
| 662 | - // Only ever fetch over http/https. | |
| 663 | - if (!in_array(strtolower($parts['scheme']), ['http', 'https'], true)) { | |
| 664 | - return false; | |
| 665 | - } | |
| 666 | - | |
| 667 | - $host = $parts['host']; | |
| 668 | - | |
| 669 | - // Resolve the host to the IP(s) it points at (literal IPs pass through). | |
| 670 | - $ips = []; | |
| 671 | - if (filter_var($host, FILTER_VALIDATE_IP)) { | |
| 672 | - $ips[] = $host; | |
| 673 | - } else { | |
| 674 | - $records = @dns_get_record($host, DNS_A + DNS_AAAA); | |
| 675 | - if (is_array($records)) { | |
| 676 | - foreach ($records as $record) { | |
| 677 | - if (!empty($record['ip'])) { | |
| 678 | - $ips[] = $record['ip']; | |
| 679 | - } elseif (!empty($record['ipv6'])) { | |
| 680 | - $ips[] = $record['ipv6']; | |
| 681 | - } | |
| 682 | - } | |
| 683 | - } | |
| 684 | - // Fallback for hosts dns_get_record can't resolve. | |
| 685 | - if (empty($ips)) { | |
| 686 | - $resolved = gethostbyname($host); | |
| 687 | - if ($resolved !== $host) { | |
| 688 | - $ips[] = $resolved; | |
| 689 | - } | |
| 690 | - } | |
| 691 | - } | |
| 692 | - | |
| 693 | - // Unresolvable host → don't fetch. | |
| 694 | - if (empty($ips)) { | |
| 695 | - return false; | |
| 696 | - } | |
| 697 | - | |
| 698 | - // Reject if any resolved address is private, reserved, loopback or link-local. | |
| 699 | - foreach ($ips as $ip) { | |
| 700 | - if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) { | |
| 701 | - return false; | |
| 702 | - } | |
| 703 | - } | |
| 704 | - | |
| 705 | - return true; | |
| 1025 | + return \ThinkRank\Core\Url_Safety::is_safe_public_url($url); | |
| 706 | 1026 | } |
| 707 | 1027 | |
| 708 | 1028 | /** |
| 709 | 1029 | * Scrape content from a competitor URL |
| @@ -711,12 +1031,13 @@ | ||
| 711 | 1031 | * @param string $url The URL to scrape |
| 712 | 1032 | * @return array|null Content data or null if failed |
| 713 | 1033 | */ |
| 714 | 1034 | private function scrape_competitor_content(string $url): ?array { |
| 715 | - // wp_safe_remote_get() (reject_unsafe_urls) re-validates the URL and, unlike | |
| 716 | - // wp_remote_get(), rejects redirects to internal/private hosts — closing the | |
| 717 | - // redirect-based SSRF bypass on top of the pre-flight is_safe_public_url() check. | |
| 718 | - $response = wp_safe_remote_get($url, [ | |
| 1035 | + // Url_Safety::safe_remote_get() follows redirects manually and re-checks | |
| 1036 | + // the resolved host on every hop, so a target that redirects to (or | |
| 1037 | + // rebinds onto) an internal address after the pre-flight check is | |
| 1038 | + // refused rather than fetched. | |
| 1039 | + $response = \ThinkRank\Core\Url_Safety::safe_remote_get($url, [ | |
| 719 | 1040 | 'timeout' => 8, // Reduced from 15 to 8 seconds |
| 720 | 1041 | 'user-agent' => 'Mozilla/5.0 (compatible; ThinkRank SEO Bot)', |
| 721 | 1042 | 'headers' => [ |
| 722 | 1043 | 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', |
| @@ -992,8 +1313,20 @@ | ||
| 992 | 1313 | if (false === $result) { |
| 993 | 1314 | throw new \Exception('Failed to save content brief to database.'); |
| 994 | 1315 | } |
| 995 | 1316 | |
| 1317 | + /** | |
| 1318 | + * Fires after a content brief is persisted. | |
| 1319 | + * | |
| 1320 | + * Analytics listens to drop its cached overview so the brief counts | |
| 1321 | + * on the Usages page are not stale for a TTL. | |
| 1322 | + * | |
| 1323 | + * @since 2.2.1 | |
| 1324 | + * | |
| 1325 | + * @param int $brief_id Row id of the stored brief. | |
| 1326 | + */ | |
| 1327 | + do_action('thinkrank_content_brief_created', (int) $wpdb->insert_id); | |
| 1328 | + | |
| 996 | 1329 | return $wpdb->insert_id; |
| 997 | 1330 | } |
| 998 | 1331 | |
| 999 | 1332 | /** |
| @@ -1023,11 +1356,14 @@ | ||
| 1023 | 1356 | if (isset($brief_data['visual_content']['image_recommendations']) && is_array($brief_data['visual_content']['image_recommendations'])) { |
| 1024 | 1357 | $brief_data['visual_content']['image_recommendations'] = array_map(function($rec) { |
| 1025 | 1358 | if (is_array($rec)) { |
| 1026 | 1359 | $text = ''; |
| 1027 | - if (isset($rec['type'])) $text .= $rec['type'] . ': '; | |
| 1028 | - if (isset($rec['description'])) $text .= $rec['description']; | |
| 1029 | - if (isset($rec['alt_text'])) $text .= ' (Alt: ' . $rec['alt_text'] . ')'; | |
| 1360 | + if (isset($rec['type'])) { $text .= $rec['type'] . ': '; | |
| 1361 | + } | |
| 1362 | + if (isset($rec['description'])) { $text .= $rec['description']; | |
| 1363 | + } | |
| 1364 | + if (isset($rec['alt_text'])) { $text .= ' (Alt: ' . $rec['alt_text'] . ')'; | |
| 1365 | + } | |
| 1030 | 1366 | return $text ?: 'Image recommendation'; |
| 1031 | 1367 | } |
| 1032 | 1368 | return is_string($rec) ? $rec : 'Image recommendation'; |
| 1033 | 1369 | }, $brief_data['visual_content']['image_recommendations']); |
| @@ -1032,8 +1368,54 @@ | ||
| 1032 | 1368 | return is_string($rec) ? $rec : 'Image recommendation'; |
| 1033 | 1369 | }, $brief_data['visual_content']['image_recommendations']); |
| 1034 | 1370 | } |
| 1035 | 1371 | |
| 1372 | + return $this->sanitize_brief_output($brief_data); | |
| 1373 | + } | |
| 1374 | + | |
| 1375 | + /** | |
| 1376 | + * Strip untrusted markup out of brief fields before they leave the server. | |
| 1377 | + * | |
| 1378 | + * Brief content crosses a trust boundary: it is assembled by an external AI | |
| 1379 | + * provider from prompts that can include text fetched from competitor URLs. | |
| 1380 | + * It was previously copied out of the decoded JSON verbatim and rendered in | |
| 1381 | + * the admin SPA through dangerouslySetInnerHTML, so a malicious or | |
| 1382 | + * prompt-injected response could execute script in the admin origin (#365). | |
| 1383 | + * | |
| 1384 | + * Runs on the read path as well as generation, so briefs stored before this | |
| 1385 | + * fix are sanitized when they are loaded. | |
| 1386 | + * | |
| 1387 | + * @since 1.32.0 | |
| 1388 | + * | |
| 1389 | + * @param array $brief_data Brief data to sanitize. | |
| 1390 | + * @return array Sanitized brief data. | |
| 1391 | + */ | |
| 1392 | + private function sanitize_brief_output(array $brief_data): array { | |
| 1393 | + foreach ($brief_data as $key => $value) { | |
| 1394 | + // The raw provider response is debug output shown as plain text, and | |
| 1395 | + // the generation params are our own values — leave both intact. | |
| 1396 | + if ('raw_response' === $key || 'generation_params' === $key) { | |
| 1397 | + continue; | |
| 1398 | + } | |
| 1399 | + | |
| 1400 | + if ('content_body' === $key && is_string($value)) { | |
| 1401 | + // Deliberately HTML: it is the drafted article and is rendered as | |
| 1402 | + // markup. wp_kses_post() keeps normal post formatting while | |
| 1403 | + // dropping script/style/iframe, event-handler attributes and | |
| 1404 | + // javascript: URLs. | |
| 1405 | + $brief_data[$key] = wp_kses_post($value); | |
| 1406 | + continue; | |
| 1407 | + } | |
| 1408 | + | |
| 1409 | + if (is_array($value)) { | |
| 1410 | + $brief_data[$key] = $this->sanitize_brief_output($value); | |
| 1411 | + } elseif (is_string($value)) { | |
| 1412 | + // Every other field is plain text (headings, keywords, guidance). | |
| 1413 | + // Markdown emphasis markers are preserved; HTML tags are not. | |
| 1414 | + $brief_data[$key] = wp_strip_all_tags($value); | |
| 1415 | + } | |
| 1416 | + } | |
| 1417 | + | |
| 1036 | 1418 | return $brief_data; |
| 1037 | 1419 | } |
| 1038 | 1420 | |
| 1039 | 1421 | /** |
| @@ -1118,13 +1500,17 @@ | ||
| 1118 | 1500 | private function hydrate_brief_row(array $brief): array { |
| 1119 | 1501 | $brief['target_keywords'] = json_decode($brief['target_keywords'], true); |
| 1120 | 1502 | $brief['brief_data'] = json_decode($brief['brief_data'], true); |
| 1121 | 1503 | |
| 1504 | + // Cast: the row comes from $wpdb, which returns every column as a | |
| 1505 | + // string, and both helpers declare an int parameter. | |
| 1506 | + $brief_id = (int) $brief['id']; | |
| 1507 | + | |
| 1122 | 1508 | // Retrieve raw response from ai_usage table |
| 1123 | - $brief['brief_data']['raw_response'] = $this->get_raw_response_for_brief($brief['id']); | |
| 1509 | + $brief['brief_data']['raw_response'] = $this->get_raw_response_for_brief($brief_id); | |
| 1124 | 1510 | |
| 1125 | 1511 | // Update model with actual model used (if available in ai_usage table) |
| 1126 | - $actual_model = $this->get_actual_model_for_brief($brief['id']); | |
| 1512 | + $actual_model = $this->get_actual_model_for_brief($brief_id); | |
| 1127 | 1513 | if ($actual_model && isset($brief['brief_data']['generation_meta'])) { |
| 1128 | 1514 | $brief['brief_data']['generation_meta']['model'] = $actual_model; |
| 1129 | 1515 | } |
| 1130 | 1516 | |
| @@ -1171,9 +1557,10 @@ | ||
| 1171 | 1557 | } |
| 1172 | 1558 | |
| 1173 | 1559 | // Count sentences (approximate) |
| 1174 | 1560 | $sentences = preg_split('/[.!?]+/', $text); |
| 1175 | - $sentence_count = count(array_filter($sentences, function($s) { return trim($s) !== ''; })); | |
| 1561 | + $sentence_count = count(array_filter($sentences, function($s) { return trim($s) !== ''; | |
| 1562 | +})); | |
| 1176 | 1563 | |
| 1177 | 1564 | // Count words |
| 1178 | 1565 | $word_count = str_word_count($text); |
| 1179 | 1566 | |
| @@ -1250,9 +1637,9 @@ | ||
| 1250 | 1637 | $vowels = 'aeiouy'; |
| 1251 | 1638 | $syllable_count = 0; |
| 1252 | 1639 | $previous_was_vowel = false; |
| 1253 | 1640 | |
| 1254 | - for ($i = 0; $i < strlen($word); $i++) { | |
| 1641 | + for ($i = 0, $len = strlen($word); $i < $len; $i++) { | |
| 1255 | 1642 | $is_vowel = strpos($vowels, $word[$i]) !== false; |
| 1256 | 1643 | if ($is_vowel && !$previous_was_vowel) { |
| 1257 | 1644 | $syllable_count++; |
| 1258 | 1645 | } |
| @@ -1523,9 +1910,10 @@ | ||
| 1523 | 1910 | } |
| 1524 | 1911 | |
| 1525 | 1912 | // Readability check |
| 1526 | 1913 | $sentences = preg_split('/[.!?]+/', $meta_desc); |
| 1527 | - $sentence_count = count(array_filter($sentences, function($s) { return trim($s) !== ''; })); | |
| 1914 | + $sentence_count = count(array_filter($sentences, function($s) { return trim($s) !== ''; | |
| 1915 | +})); | |
| 1528 | 1916 | if ($sentence_count >= 1 && $sentence_count <= 3) { |
| 1529 | 1917 | $score += 20; |
| 1530 | 1918 | $feedback[] = 'Good sentence structure'; |
| 1531 | 1919 | } else { |
| @@ -1618,12 +2006,16 @@ | ||
| 1618 | 2006 | * @param int $score Numeric score |
| 1619 | 2007 | * @return string Letter grade |
| 1620 | 2008 | */ |
| 1621 | 2009 | private function get_grade_from_score(int $score): string { |
| 1622 | - if ($score >= 90) return 'A'; | |
| 1623 | - if ($score >= 80) return 'B'; | |
| 1624 | - if ($score >= 70) return 'C'; | |
| 1625 | - if ($score >= 60) return 'D'; | |
| 2010 | + if ($score >= 90) { return 'A'; | |
| 2011 | + } | |
| 2012 | + if ($score >= 80) { return 'B'; | |
| 2013 | + } | |
| 2014 | + if ($score >= 70) { return 'C'; | |
| 2015 | + } | |
| 2016 | + if ($score >= 60) { return 'D'; | |
| 2017 | + } | |
| 1626 | 2018 | return 'F'; |
| 1627 | 2019 | } |
| 1628 | 2020 | |
| 1629 | 2021 | /** |
| @@ -1656,9 +2048,9 @@ | ||
| 1656 | 2048 | [ |
| 1657 | 2049 | 'user_id' => $user_id, |
| 1658 | 2050 | 'action' => $action, |
| 1659 | 2051 | 'tokens_used' => $tokens_used, |
| 1660 | - 'provider' => $this->settings->get('ai_provider', 'openai'), | |
| 2052 | + 'provider' => $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE), | |
| 1661 | 2053 | 'post_id' => $post_id, |
| 1662 | 2054 | 'metadata' => !empty($metadata) ? wp_json_encode($metadata) : null, |
| 1663 | 2055 | 'created_at' => current_time('mysql'), |
| 1664 | 2056 | ], |
| @@ -1663,8 +2055,17 @@ | ||
| 1663 | 2055 | 'created_at' => current_time('mysql'), |
| 1664 | 2056 | ], |
| 1665 | 2057 | ['%d', '%s', '%d', '%s', '%d', '%s', '%s'] |
| 1666 | 2058 | ); |
| 2059 | + | |
| 2060 | + /** | |
| 2061 | + * Fires after an AI usage row is recorded. | |
| 2062 | + * | |
| 2063 | + * @since 2.2.1 | |
| 2064 | + * | |
| 2065 | + * @param int $user_id User the usage was recorded against. | |
| 2066 | + */ | |
| 2067 | + do_action('thinkrank_ai_usage_logged', $user_id); | |
| 1667 | 2068 | |
| 1668 | 2069 | return $wpdb->insert_id; |
| 1669 | 2070 | } |
| 1670 | 2071 | |