'Claude (Anthropic)', 'openai' => 'ChatGPT (OpenAI)', 'gemini' => 'Gemini (Google)' ); /** * Log file path for plugin-specific logging. */ private $log_file; /** * Available models for each provider. */ const MODELS = array( 'claude' => array( 'claude-haiku-4-5' => 'Claude 4.5 Haiku', 'claude-sonnet-4-5' => 'Claude Sonnet 4.5' ), 'openai' => array( 'gpt-5' => 'GPT-5', 'gpt-5-mini' => 'GPT-5 Mini', 'gpt-5-nano-2025-08-07' => 'GPT-5 Nano', 'gpt-5.2-2025-12-11' => 'GPT-5.2' ), 'gemini' => array( 'gemini-3-flash-preview' => 'Gemini 2.5 Flash Preview', 'gemini-3-pro-preview' => 'Gemini 3 Pro' ) ); /** * API endpoints for each provider. */ private $api_endpoints = array( 'claude' => 'https://api.anthropic.com/v1/messages', 'openai' => 'https://api.openai.com/v1/chat/completions', 'gemini' => 'https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent' ); /** * Plugin options. * * @var array */ private $options; /** * Constructor. */ public function __construct() { $this->options = get_option('ai_smart_excerpt_options', array()); $this->log_file = WP_CONTENT_DIR . '/ai-smart-excerpt-debug.log'; } /** * Custom logging function that works independently of WP_DEBUG. * * @param string $message Message to log. */ private function log($message) { // Always write to PHP error log error_log($message); // Also write to plugin-specific log file $timestamp = date('Y-m-d H:i:s'); $log_entry = "[{$timestamp}] {$message}\n"; file_put_contents($this->log_file, $log_entry, FILE_APPEND); } /** * Generate excerpt using the configured LLM provider. * * @param string $content Post content. * @param string $title Post title. * @param string $excerpt_type Type of excerpt (seo, social, summary, teaser). * @param int $max_length Maximum excerpt length. * @return string|false Generated excerpt or false on failure. */ public function generate_excerpt($content, $title, $excerpt_type = 'seo', $max_length = 300) { $provider = $this->options['llm_provider'] ?? 'claude'; $api_key = $this->options['api_key'] ?? ''; if (empty($api_key)) { return false; } if (empty($content)) { return false; } $prompt = $this->build_prompt($content, $title, $excerpt_type, $max_length); switch ($provider) { case 'claude': return $this->call_claude_api($prompt, $api_key); case 'openai': return $this->call_openai_api($prompt, $api_key); case 'gemini': return $this->call_gemini_api($prompt, $api_key); default: return false; } } /** * Build the prompt for AI generation. * * @param string $content Post content. * @param string $title Post title. * @param string $excerpt_type Type of excerpt. * @param int $max_length Maximum length. * @return string Generated prompt. */ private function build_prompt($content, $title, $excerpt_type, $max_length) { $type_instructions = array( 'seo' => __('Create an SEO-optimized excerpt that includes relevant keywords and is designed to improve search engine rankings. Focus on the main topic and key points that would help with discoverability.', 'ai-smart-excerpt'), 'social' => __('Create a social media optimized excerpt that is engaging, shareable, and likely to generate clicks and engagement. Make it compelling and attention-grabbing.', 'ai-smart-excerpt'), 'summary' => __('Create a clear, factual summary of the main points covered in the content. Focus on providing a comprehensive overview of the key information.', 'ai-smart-excerpt'), 'teaser' => __('Create an engaging teaser that creates curiosity and encourages readers to read the full content. Make it intriguing while providing just enough information to hook the reader.', 'ai-smart-excerpt') ); $instruction = $type_instructions[$excerpt_type] ?? $type_instructions['summary']; // Detect the language of the content $detected_language = $this->detect_content_language($content, $title); $prompt = sprintf( 'Please create a %s excerpt for the following article. Title: %s Content: %s Requirements: - %s - Maximum length: %d characters - Write the excerpt in the same language as the original content (%s) - Write in a natural, readable style - Focus on the most important and relevant information - Do not include quotes, markdown, or special formatting - Return only the excerpt text, nothing else Excerpt:', $excerpt_type, $title, $content, $instruction, $max_length, $detected_language ); return $prompt; } /** * Detect the language of the content based on common words and patterns. * * @param string $content Post content. * @param string $title Post title. * @return string Detected language name. */ private function detect_content_language($content, $title) { // Combine title and content for analysis $text = $title . ' ' . $content; // Remove HTML tags and normalize $text = strip_tags($text); $text = strtolower($text); $text = preg_replace('/[^\p{L}\s]/u', ' ', $text); $words = preg_split('/\s+/', $text, -1, PREG_SPLIT_NO_EMPTY); // Language detection based on common words $language_indicators = array( 'english' => array('the', 'and', 'for', 'are', 'but', 'not', 'you', 'all', 'can', 'had', 'her', 'was', 'one', 'our', 'out', 'day', 'get', 'has', 'him', 'his', 'how', 'its', 'may', 'new', 'now', 'old', 'see', 'two', 'way', 'who', 'boy', 'did', 'she', 'use', 'her', 'now', 'find', 'long', 'down', 'side', 'been', 'call', 'came', 'each', 'even', 'from', 'give', 'good', 'just', 'last', 'left', 'life', 'live', 'look', 'made', 'make', 'many', 'most', 'move', 'must', 'name', 'need', 'next', 'only', 'over', 'part', 'play', 'right', 'said', 'same', 'seem', 'show', 'such', 'take', 'than', 'that', 'them', 'well', 'were', 'what', 'when', 'where', 'which', 'while', 'with', 'work', 'would', 'write', 'year', 'your', 'after', 'again', 'being', 'every', 'great', 'might', 'never', 'other', 'shall', 'still', 'their', 'there', 'these', 'they', 'this', 'those', 'under', 'until', 'very', 'will', 'about'), 'italian' => array('che', 'per', 'una', 'con', 'del', 'della', 'nel', 'nella', 'come', 'più', 'anche', 'sono', 'dalla', 'dalle', 'degli', 'delle', 'questo', 'questa', 'questi', 'queste', 'tutto', 'tutti', 'sulla', 'sulle', 'altro', 'altri', 'altre', 'molto', 'molti', 'molte', 'dopo', 'dove', 'quando', 'mentre', 'prima', 'proprio', 'ancora', 'insieme', 'contro', 'durante', 'senza', 'presso', 'attraverso', 'sopra', 'sotto', 'dentro', 'fuori', 'oltre', 'verso', 'invece', 'infatti', 'quindi', 'perciò', 'tuttavia', 'comunque', 'inoltre', 'sempre', 'mai', 'oggi', 'ieri', 'domani', 'ora', 'adesso', 'subito', 'prima', 'poi', 'già', 'ancora', 'qui', 'qua', 'là', 'lì'), 'spanish' => array('que', 'para', 'una', 'con', 'del', 'las', 'los', 'esta', 'este', 'como', 'más', 'también', 'son', 'desde', 'hasta', 'todo', 'todos', 'todas', 'sobre', 'otro', 'otros', 'otras', 'muy', 'muchos', 'muchas', 'después', 'donde', 'cuando', 'mientras', 'antes', 'propio', 'aún', 'junto', 'contra', 'durante', 'sin', 'cerca', 'través', 'encima', 'debajo', 'dentro', 'fuera', 'además', 'hacia', 'sino', 'embargo', 'por tanto', 'así', 'siempre', 'nunca', 'hoy', 'ayer', 'mañana', 'ahora', 'ya', 'todavía', 'aquí', 'ahí', 'allí'), 'french' => array('que', 'pour', 'une', 'avec', 'dans', 'les', 'des', 'cette', 'comme', 'plus', 'aussi', 'sont', 'depuis', 'jusqu', 'tout', 'tous', 'toutes', 'sur', 'autre', 'autres', 'très', 'beaucoup', 'après', 'avant', 'pendant', 'sans', 'près', 'travers', 'dessus', 'dessous', 'dedans', 'dehors', 'outre', 'vers', 'mais', 'cependant', 'donc', 'ainsi', 'toujours', 'jamais', 'aujourd', 'hier', 'demain', 'maintenant', 'déjà', 'encore', 'ici', 'là'), 'german' => array('das', 'der', 'die', 'und', 'für', 'mit', 'den', 'des', 'dem', 'ein', 'eine', 'wie', 'mehr', 'auch', 'sind', 'von', 'bis', 'alle', 'über', 'andere', 'sehr', 'viele', 'nach', 'vor', 'während', 'ohne', 'unter', 'durch', 'gegen', 'aber', 'jedoch', 'also', 'immer', 'nie', 'heute', 'gestern', 'morgen', 'jetzt', 'schon', 'noch', 'hier', 'dort') ); $scores = array(); // Count matches for each language foreach ($language_indicators as $lang => $indicators) { $matches = 0; foreach ($words as $word) { if (in_array($word, $indicators)) { $matches++; } } $scores[$lang] = $matches; } // Find the language with the most matches $detected_lang = 'English'; // default $max_score = 0; foreach ($scores as $lang => $score) { if ($score > $max_score) { $max_score = $score; $detected_lang = ucfirst($lang); } } // If no clear winner or very few matches, fall back to a simple heuristic if ($max_score < 3) { // Look for characteristic language patterns if (preg_match('/\b(il|la|le|gli|delle|della|del|che|per|una|con|è|sono|hai|ha|abbiamo|avete|hanno)\b/', $text)) { $detected_lang = 'Italian'; } elseif (preg_match('/\b(el|la|los|las|de|del|que|para|con|es|son|tienes|tiene|tenemos|tenéis|tienen)\b/', $text)) { $detected_lang = 'Spanish'; } elseif (preg_match('/\b(le|la|les|de|du|des|que|pour|avec|est|sont|as|a|avons|avez|ont)\b/', $text)) { $detected_lang = 'French'; } elseif (preg_match('/\b(der|die|das|den|des|dem|und|für|mit|ist|sind|hast|hat|haben|habt)\b/', $text)) { $detected_lang = 'German'; } } return $detected_lang; } /** * Call Claude API. * * @param string $prompt The prompt. * @param string $api_key API key. * @return string|false Generated excerpt or false. */ private function call_claude_api($prompt, $api_key) { $model = $this->get_selected_model('claude'); $headers = array( 'Content-Type' => 'application/json', 'x-api-key' => $api_key, 'anthropic-version' => '2023-06-01' ); $body = array( 'model' => $model, 'max_tokens' => 300, 'messages' => array( array( 'role' => 'user', 'content' => $prompt ) ) ); // Log request details (without exposing full API key) $this->log('AI Smart Excerpt: Calling Claude API with model: ' . $model); $this->log('AI Smart Excerpt: API key starts with: ' . substr($api_key, 0, 10) . '...'); $this->log('AI Smart Excerpt: Endpoint: ' . $this->api_endpoints['claude']); $response = wp_remote_post($this->api_endpoints['claude'], array( 'headers' => $headers, 'body' => wp_json_encode($body), 'timeout' => 30 )); return $this->parse_claude_response($response); } /** * Call OpenAI API. * * @param string $prompt The prompt. * @param string $api_key API key. * @return string|false Generated excerpt or false. */ private function call_openai_api($prompt, $api_key) { $headers = array( 'Content-Type' => 'application/json', 'Authorization' => 'Bearer ' . $api_key ); $model = $this->get_selected_model('openai'); $body = array( 'model' => $model, 'messages' => array( array( 'role' => 'user', 'content' => $prompt ) ), 'temperature' => 0.7 ); // GPT-5 and GPT-4.1 models use max_completion_tokens, older models use max_tokens if (strpos($model, 'gpt-5') === 0 || strpos($model, 'GPT-5 Nano') === 0) { // GPT-5 reasoning models need significantly more tokens due to internal reasoning $body['max_completion_tokens'] = strpos($model, 'gpt-5') === 0 ? 2000 : 300; } else { $body['max_tokens'] = 300; } $response = wp_remote_post($this->api_endpoints['openai'], array( 'headers' => $headers, 'body' => wp_json_encode($body), 'timeout' => 30 )); $result = $this->parse_openai_response($response); // If GPT-5 failed due to reasoning token overflow, try GPT-5 Nano as fallback if (!$result && strpos($model, 'gpt-5') === 0) { $this->log('AI Smart Excerpt: GPT-5 failed, trying GPT-5 Nano fallback'); $body['model'] = 'gpt-5-nano-2025-08-07'; $body['max_completion_tokens'] = 300; $fallback_response = wp_remote_post($this->api_endpoints['openai'], array( 'headers' => $headers, 'body' => wp_json_encode($body), 'timeout' => 30 )); return $this->parse_openai_response($fallback_response); } return $result; } /** * Call Gemini API. * * @param string $prompt The prompt. * @param string $api_key API key. * @return string|false Generated excerpt or false. */ private function call_gemini_api($prompt, $api_key) { $model = $this->get_selected_model('gemini'); $url = str_replace('{model}', $model, $this->api_endpoints['gemini']) . '?key=' . $api_key; $headers = array( 'Content-Type' => 'application/json' ); $body = array( 'contents' => array( array( 'parts' => array( array('text' => $prompt) ) ) ), 'generationConfig' => array( 'maxOutputTokens' => 300, 'temperature' => 0.7 ) ); $response = wp_remote_post($url, array( 'headers' => $headers, 'body' => wp_json_encode($body), 'timeout' => 30 )); return $this->parse_gemini_response($response); } /** * Parse Claude API response. * * @param array|WP_Error $response API response. * @return string|false Parsed excerpt or false. */ private function parse_claude_response($response) { if (is_wp_error($response)) { $this->log('AI Smart Excerpt: WP Error in Claude response: ' . $response->get_error_message()); return false; } $status_code = wp_remote_retrieve_response_code($response); $body = wp_remote_retrieve_body($response); // Log response for debugging $this->log('AI Smart Excerpt: Claude API status code: ' . $status_code); $this->log('AI Smart Excerpt: Claude raw response: ' . $body); $data = json_decode($body, true); if (!$data) { $this->log('AI Smart Excerpt: Failed to decode Claude JSON response'); return false; } // Check for API errors if (isset($data['error'])) { $error_type = isset($data['error']['type']) ? $data['error']['type'] : 'unknown'; $error_message = isset($data['error']['message']) ? $data['error']['message'] : 'No error message provided'; $this->log('AI Smart Excerpt: Claude API error [' . $error_type . ']: ' . $error_message); // Log specific error types for easier debugging if ($error_type === 'authentication_error') { $this->log('AI Smart Excerpt: Invalid API key or authentication failed'); } elseif ($error_type === 'invalid_request_error') { $this->log('AI Smart Excerpt: Invalid request - check model name and parameters'); } elseif ($error_type === 'rate_limit_error') { $this->log('AI Smart Excerpt: Rate limit exceeded'); } elseif ($error_type === 'api_error') { $this->log('AI Smart Excerpt: Anthropic API is experiencing issues'); } return false; } if (!isset($data['content'][0]['text'])) { $this->log('AI Smart Excerpt: No content in Claude response structure'); $this->log('AI Smart Excerpt: Response structure: ' . print_r($data, true)); return false; } $content = trim($data['content'][0]['text']); if (empty($content)) { $this->log('AI Smart Excerpt: Claude returned empty content'); return false; } return $content; } /** * Parse OpenAI API response. * * @param array|WP_Error $response API response. * @return string|false Parsed excerpt or false. */ private function parse_openai_response($response) { if (is_wp_error($response)) { $this->log('AI Smart Excerpt: WP Error in OpenAI response: ' . $response->get_error_message()); return false; } $body = wp_remote_retrieve_body($response); $data = json_decode($body, true); // Log raw response for debugging $this->log('AI Smart Excerpt: OpenAI raw response: ' . $body); if (!$data) { $this->log('AI Smart Excerpt: Failed to decode JSON response'); return false; } if (isset($data['error'])) { $this->log('AI Smart Excerpt: OpenAI API error: ' . $data['error']['message']); return false; } if (!isset($data['choices'][0]['message']['content'])) { $this->log('AI Smart Excerpt: No content in OpenAI response structure'); return false; } $content = trim($data['choices'][0]['message']['content']); // Handle empty content from reasoning models (like GPT-5) if (empty($content) && isset($data['choices'][0]['finish_reason'])) { $finish_reason = $data['choices'][0]['finish_reason']; $reasoning_tokens = isset($data['usage']['completion_tokens_details']['reasoning_tokens']) ? $data['usage']['completion_tokens_details']['reasoning_tokens'] : 0; $this->log('AI Smart Excerpt: Empty content with finish_reason: ' . $finish_reason . ', reasoning_tokens: ' . $reasoning_tokens); if ($finish_reason === 'length' || $reasoning_tokens >= 1000) { $this->log('AI Smart Excerpt: GPT-5 used too many reasoning tokens, output empty'); return false; } } if (empty($content)) { $this->log('AI Smart Excerpt: OpenAI returned empty content'); return false; } return $content; } /** * Parse Gemini API response. * * @param array|WP_Error $response API response. * @return string|false Parsed excerpt or false. */ private function parse_gemini_response($response) { if (is_wp_error($response)) { return false; } $body = wp_remote_retrieve_body($response); $data = json_decode($body, true); if (!$data || !isset($data['candidates'][0]['content']['parts'][0]['text'])) { return false; } return trim($data['candidates'][0]['content']['parts'][0]['text']); } /** * Test API connection. * * @param string $provider Provider to test. * @param string $api_key API key to test. * @return array Test result with success status and message. */ public function test_api_connection($provider, $api_key) { $test_prompt = __('Write a brief test response to confirm the API connection is working.', 'ai-smart-excerpt'); // Temporarily set the provider and key for testing $original_provider = $this->options['llm_provider']; $original_key = $this->options['api_key']; $this->options['llm_provider'] = $provider; $this->options['api_key'] = $api_key; $this->log('AI Smart Excerpt: Testing API connection for provider: ' . $provider); $result = $this->generate_excerpt( __('This is a test article to verify API connectivity.', 'ai-smart-excerpt'), __('Test Article', 'ai-smart-excerpt'), 'summary', 100 ); // Restore original settings $this->options['llm_provider'] = $original_provider; $this->options['api_key'] = $original_key; if ($result) { $this->log('AI Smart Excerpt: API test successful for ' . $provider); return array( 'success' => true, 'message' => __('API connection successful!', 'ai-smart-excerpt'), 'response' => $result ); } else { // Get the last error from error log $selected_model = $this->get_selected_model($provider); $this->log('AI Smart Excerpt: API test failed for ' . $provider . ' with model ' . $selected_model); // Build detailed error message $error_details = array(); $error_details[] = sprintf(__('Provider: %s', 'ai-smart-excerpt'), $provider); $error_details[] = sprintf(__('Model: %s', 'ai-smart-excerpt'), $selected_model); $error_details[] = sprintf(__('API Key Format: %s', 'ai-smart-excerpt'), $this->validate_api_key_format($provider, $api_key) ? __('Valid', 'ai-smart-excerpt') : __('Invalid', 'ai-smart-excerpt')); // Point to plugin-specific log file $error_details[] = sprintf(__('Check error log at: %s', 'ai-smart-excerpt'), $this->log_file); return array( 'success' => false, 'message' => sprintf( __('API connection failed for model "%s". %s', 'ai-smart-excerpt'), $selected_model, implode(' | ', $error_details) ), 'model' => $selected_model, 'provider' => $provider, 'details' => $error_details ); } } /** * Get supported providers. * * @return array Supported providers. */ public static function get_providers() { return self::PROVIDERS; } /** * Validate API key format. * * @param string $provider Provider name. * @param string $api_key API key to validate. * @return bool True if format is valid. */ public function validate_api_key_format($provider, $api_key) { if (empty($api_key)) { return false; } switch ($provider) { case 'claude': return strpos($api_key, 'sk-ant-') === 0; case 'openai': return strpos($api_key, 'sk-') === 0; case 'gemini': return preg_match('/^[A-Za-z0-9_-]+$/', $api_key); default: return false; } } /** * Get the selected model for a provider. * * @param string $provider Provider name. * @return string Model identifier. */ private function get_selected_model($provider) { $model_option = $provider . '_model'; $selected_model = isset($this->options[$model_option]) ? $this->options[$model_option] : null; // Return selected model if valid, otherwise return default if ($selected_model && isset(self::MODELS[$provider][$selected_model])) { return $selected_model; } // Return first available model as default $models = self::MODELS[$provider]; return array_key_first($models); } /** * Get available models for a provider. * * @param string $provider Provider name. * @return array Available models. */ public static function get_models_for_provider($provider) { return isset(self::MODELS[$provider]) ? self::MODELS[$provider] : array(); } /** * Generate SEO-focused categories for WordPress taxonomy. * * @param string $content Post content. * @param string $title Post title. * @param array $existing_categories Existing site categories. * @return array|false Generated categories array or false on failure. */ public function generate_categories($content, $title, $existing_categories = array()) { $provider = $this->options['llm_provider'] ?? 'claude'; $api_key = $this->options['api_key'] ?? ''; if (empty($api_key) || empty($content)) { return false; } $existing_names = array_column($existing_categories, 'name'); $existing_list = !empty($existing_names) ? implode(', ', $existing_names) : 'None'; $prompt = sprintf( 'Analyze this article and suggest 2-3 broad category classifications that would help organize it on a website. Title: %s Content: %s Existing site categories: %s Requirements: - Suggest categories that are broad enough to contain multiple related articles - Consider the existing categories and suggest matches or logical additions - Return as comma-separated list - Maximum 3 categories - Each category should be 1-3 words - No explanations, quotes, or additional text Categories:', $title, $content, $existing_list ); $response = $this->make_api_call($prompt); if ($response) { return $this->parse_comma_separated_response($response); } return false; } /** * Generate specific tags for detailed content classification. * * @param string $content Post content. * @param string $title Post title. * @param int $max_tags Maximum number of tags. * @return array|false Generated tags array or false on failure. */ public function generate_tags($content, $title, $max_tags = 8) { $provider = $this->options['llm_provider'] ?? 'claude'; $api_key = $this->options['api_key'] ?? ''; if (empty($api_key) || empty($content)) { return false; } $prompt = sprintf( 'Generate specific, searchable tags for this article that users might search for or that would help with internal site organization. Title: %s Content: %s Requirements: - Focus on specific topics, techniques, tools, or concepts mentioned - Include both broad and specific terms - Think about what users would search for - Maximum %d tags - Each tag should be 1-4 words - Return as comma-separated list - No explanations, quotes, or additional text Tags:', $title, $content, $max_tags ); $response = $this->make_api_call($prompt); if ($response) { return $this->parse_comma_separated_response($response); } return false; } /** * Generate primary focus keyword for SEO plugins. * * @param string $content Post content. * @param string $title Post title. * @return string|false Generated focus keyword or false on failure. */ public function generate_focus_keyword($content, $title) { $provider = $this->options['llm_provider'] ?? 'claude'; $api_key = $this->options['api_key'] ?? ''; if (empty($api_key) || empty($content)) { return false; } $prompt = sprintf( 'Identify the single most important keyword phrase that this article should rank for in search engines. Title: %s Content: %s Requirements: - Should be 1-4 words that people actually search for - Should represent the main topic/benefit of the article - Should be specific enough to rank for but broad enough to get traffic - Return only the keyword phrase, nothing else - No quotes, explanations, or additional text Focus keyword:', $title, $content ); $response = $this->make_api_call($prompt); if ($response) { return trim($response); } return false; } /** * Make API call to the configured LLM provider. * * @param string $prompt The prompt to send. * @return string|false API response or false on failure. */ private function make_api_call($prompt) { $provider = $this->options['llm_provider'] ?? 'claude'; $api_key = $this->options['api_key'] ?? ''; switch ($provider) { case 'claude': return $this->call_claude_api($prompt, $api_key); case 'openai': return $this->call_openai_api($prompt, $api_key); case 'gemini': return $this->call_gemini_api($prompt, $api_key); default: return false; } } /** * Parse comma-separated response from AI provider. * * @param string $response Raw response from AI. * @return array|false Parsed array or false. */ private function parse_comma_separated_response($response) { if (empty($response)) { return false; } // Clean the response and split by comma $response = trim($response); $items = array_map('trim', explode(',', $response)); // Filter out empty items and limit length $items = array_filter($items, function($item) { return !empty($item) && strlen($item) <= 50; }); return array_values($items); } }