| @@ -4,112 +4,370 @@ | ||
| 4 | 4 | } |
| 5 | 5 | |
| 6 | 6 | class MxChat_Utils { |
| 7 | 7 | |
| 8 | - /** | |
| 9 | - * Submit or update content (and its embedding) in the database. | |
| 10 | - * | |
| 11 | - * If the source_url already exists, update that row. | |
| 12 | - * Otherwise, insert a new row. | |
| 13 | - * | |
| 14 | - * @param string $content The content to be embedded. | |
| 15 | - * @param string $source_url The source URL of the content. | |
| 16 | - * @param string $api_key The API key used for generating embeddings. | |
| 17 | - */ | |
| 18 | - public static function submit_content_to_db($content, $source_url, $api_key) { | |
| 19 | - global $wpdb; | |
| 20 | - $table_name = $wpdb->prefix . 'mxchat_system_prompt_content'; | |
| 8 | +/** | |
| 9 | + * Submit or update content (and its embedding) in the database. | |
| 10 | + * Stores in Pinecone if enabled, otherwise stores in WordPress DB. | |
| 11 | + * | |
| 12 | + * @param string $content The content to be embedded. | |
| 13 | + * @param string $source_url The source URL of the content. | |
| 14 | + * @param string $api_key The API key used for generating embeddings. | |
| 15 | + * @param string $vector_id Optional vector ID for Pinecone (if not provided, will use md5 of URL) | |
| 16 | + * @return bool|WP_Error True on success, WP_Error on failure | |
| 17 | + */ | |
| 18 | +public static function submit_content_to_db($content, $source_url, $api_key, $vector_id = null) { | |
| 19 | + global $wpdb; | |
| 20 | + $table_name = $wpdb->prefix . 'mxchat_system_prompt_content'; | |
| 21 | + | |
| 22 | + error_log('[MXCHAT-DB] Starting database submission for URL: ' . $source_url); | |
| 23 | + error_log('[MXCHAT-DB] Content length: ' . strlen($content) . ' bytes'); | |
| 24 | + | |
| 25 | + // Sanitize the source URL | |
| 26 | + $source_url = esc_url_raw($source_url); | |
| 27 | + | |
| 28 | + // Just ensure UTF-8 validity without aggressive escaping | |
| 29 | + $safe_content = wp_check_invalid_utf8($content); | |
| 30 | + // Remove only null bytes and other control characters, but preserve newlines (\n = \x0A) and carriage returns (\r = \x0D) | |
| 31 | + $safe_content = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $safe_content); | |
| 21 | 32 | |
| 22 | - // Sanitize the source URL | |
| 23 | - $source_url = esc_url_raw($source_url); | |
| 24 | 33 | |
| 25 | - // Generate the embedding using the API key | |
| 26 | - $embedding_vector = self::generate_embedding($content, $api_key); | |
| 34 | + // Generate the embedding using the API key | |
| 35 | + $embedding_vector = self::generate_embedding($content, $api_key); | |
| 36 | + | |
| 37 | + if (!is_array($embedding_vector)) { | |
| 38 | + error_log('[MXCHAT-DB] Error: Embedding generation failed'); | |
| 39 | + return new WP_Error('embedding_failed', 'Failed to generate embedding for content'); | |
| 40 | + } | |
| 41 | + | |
| 42 | + error_log('[MXCHAT-DB] Embedding generated successfully'); | |
| 43 | + | |
| 44 | + // Check if Pinecone is enabled and configured | |
| 45 | + if (self::is_pinecone_enabled()) { | |
| 46 | + error_log('[MXCHAT-DB] Pinecone is enabled - using Pinecone storage'); | |
| 47 | + // Store in Pinecone only | |
| 48 | + return self::store_in_pinecone_only($embedding_vector, $content, $source_url, $vector_id); | |
| 49 | + } else { | |
| 50 | + error_log('[MXCHAT-DB] Pinecone not enabled - using WordPress storage'); | |
| 51 | + // Store in WordPress database only | |
| 52 | + $embedding_vector_serialized = maybe_serialize($embedding_vector); | |
| 53 | + return self::store_in_wordpress_db($safe_content, $source_url, $embedding_vector_serialized, $table_name); | |
| 54 | + } | |
| 55 | +} | |
| 27 | 56 | |
| 28 | - if (is_array($embedding_vector)) { | |
| 29 | - $embedding_vector_serialized = maybe_serialize($embedding_vector); | |
| 57 | +/** | |
| 58 | + * Check if Pinecone is enabled and properly configured | |
| 59 | + */ | |
| 60 | +private static function is_pinecone_enabled() { | |
| 61 | + $pinecone_options = get_option('mxchat_pinecone_addon_options'); | |
| 62 | + | |
| 63 | + if (empty($pinecone_options)) { | |
| 64 | + return false; | |
| 65 | + } | |
| 66 | + | |
| 67 | + $enabled_check = !empty($pinecone_options['mxchat_use_pinecone']) && $pinecone_options['mxchat_use_pinecone'] !== '0'; | |
| 68 | + $api_key_check = !empty($pinecone_options['mxchat_pinecone_api_key']); | |
| 69 | + $host_check = !empty($pinecone_options['mxchat_pinecone_host']); | |
| 70 | + | |
| 71 | + return $enabled_check && $api_key_check && $host_check; | |
| 72 | +} | |
| 30 | 73 | |
| 31 | - // Check if a row already exists for this URL | |
| 32 | - $existing_id = $wpdb->get_var( | |
| 33 | - $wpdb->prepare( | |
| 34 | - "SELECT id FROM {$table_name} WHERE source_url = %s LIMIT 1", | |
| 35 | - $source_url | |
| 36 | - ) | |
| 37 | - ); | |
| 74 | +/** | |
| 75 | + * Store content in Pinecone only | |
| 76 | + */ | |
| 77 | +private static function store_in_pinecone_only($embedding_vector, $content, $source_url, $vector_id = null) { | |
| 78 | + error_log('[MXCHAT-PINECONE] ===== Using Pinecone-only storage ====='); | |
| 79 | + | |
| 80 | + $pinecone_options = get_option('mxchat_pinecone_addon_options'); | |
| 81 | + | |
| 82 | + $result = self::store_in_pinecone_main( | |
| 83 | + $embedding_vector, | |
| 84 | + $content, | |
| 85 | + $source_url, | |
| 86 | + $pinecone_options['mxchat_pinecone_api_key'], | |
| 87 | + $pinecone_options['mxchat_pinecone_environment'] ?? '', | |
| 88 | + $pinecone_options['mxchat_pinecone_index'] ?? '', | |
| 89 | + $vector_id | |
| 90 | + ); | |
| 91 | + | |
| 92 | + if (is_wp_error($result)) { | |
| 93 | + error_log('[MXCHAT-PINECONE] Pinecone storage failed: ' . $result->get_error_message()); | |
| 94 | + return $result; | |
| 95 | + } | |
| 96 | + | |
| 97 | + error_log('[MXCHAT-PINECONE] Pinecone storage completed successfully'); | |
| 98 | + return true; | |
| 99 | +} | |
| 38 | 100 | |
| 101 | +/** | |
| 102 | + * Store content in WordPress database with progressive fallback | |
| 103 | + */ | |
| 104 | +private static function store_in_wordpress_db($safe_content, $source_url, $embedding_vector_serialized, $table_name) { | |
| 105 | + global $wpdb; | |
| 106 | + | |
| 107 | + error_log('[MXCHAT-DB] ===== Using WordPress-only storage ====='); | |
| 108 | + | |
| 109 | + // Check if a row already exists for this URL | |
| 110 | + $existing_id = $wpdb->get_var( | |
| 111 | + $wpdb->prepare( | |
| 112 | + "SELECT id FROM {$table_name} WHERE source_url = %s LIMIT 1", | |
| 113 | + $source_url | |
| 114 | + ) | |
| 115 | + ); | |
| 116 | + | |
| 117 | + // Progressive fallback mechanism for problematic content | |
| 118 | + $attempt = 1; | |
| 119 | + $max_attempts = 3; | |
| 120 | + $current_content = $safe_content; | |
| 121 | + $result = false; | |
| 122 | + | |
| 123 | + while ($attempt <= $max_attempts && $result === false) { | |
| 124 | + try { | |
| 39 | 125 | if ($existing_id) { |
| 126 | + error_log('[MXCHAT-DB] Found existing entry (ID: ' . $existing_id . '). Updating... (Attempt ' . $attempt . ')'); | |
| 127 | + | |
| 40 | 128 | // Update the existing row |
| 41 | - $wpdb->update( | |
| 129 | + $result = $wpdb->update( | |
| 42 | 130 | $table_name, |
| 43 | 131 | array( |
| 44 | - 'article_content' => $content, | |
| 132 | + 'url' => $source_url, | |
| 133 | + 'article_content' => $current_content, | |
| 45 | 134 | 'embedding_vector' => $embedding_vector_serialized, |
| 46 | - 'timestamp' => current_time('mysql'), // Remove if your table doesn't have a timestamp column | |
| 135 | + 'source_url' => $source_url, | |
| 136 | + 'timestamp' => current_time('mysql'), | |
| 47 | 137 | ), |
| 48 | 138 | array('id' => $existing_id), |
| 49 | - array('%s','%s','%s'), | |
| 139 | + array('%s','%s','%s','%s','%s'), | |
| 50 | 140 | array('%d') |
| 51 | 141 | ); |
| 52 | 142 | } else { |
| 143 | + error_log('[MXCHAT-DB] No existing entry found. Inserting new row... (Attempt ' . $attempt . ')'); | |
| 144 | + error_log('[MXCHAT-DB] Content sample: ' . substr($current_content, 0, 1000)); | |
| 145 | + | |
| 53 | 146 | // Insert a new row |
| 54 | - $wpdb->insert( | |
| 147 | + $result = $wpdb->insert( | |
| 55 | 148 | $table_name, |
| 56 | 149 | array( |
| 57 | - 'article_content' => $content, | |
| 150 | + 'url' => $source_url, | |
| 151 | + 'article_content' => $current_content, | |
| 58 | 152 | 'embedding_vector' => $embedding_vector_serialized, |
| 59 | 153 | 'source_url' => $source_url, |
| 60 | - 'timestamp' => current_time('mysql'), // Remove if your table doesn't have a timestamp column | |
| 154 | + 'timestamp' => current_time('mysql'), | |
| 61 | 155 | ), |
| 62 | - array('%s','%s','%s','%s') | |
| 156 | + array('%s','%s','%s','%s','%s') | |
| 63 | 157 | ); |
| 64 | 158 | } |
| 65 | - } else { | |
| 66 | - // If embedding generation failed, you could log or handle it here | |
| 67 | - // error_log('Embedding generation failed for content from ' . $source_url); | |
| 159 | + | |
| 160 | + if ($result === false) { | |
| 161 | + error_log('[MXCHAT-DB] Database operation failed (Attempt ' . $attempt . '): ' . $wpdb->last_error); | |
| 162 | + | |
| 163 | + // Progressively apply more aggressive sanitization on failure | |
| 164 | + if ($attempt === 1) { | |
| 165 | + // First fallback: Use a more aggressive character filter and shorten | |
| 166 | + $current_content = preg_replace('/[^\p{L}\p{N}\s.,;:!?()-]/u', '', $current_content); | |
| 167 | + $current_content = substr($current_content, 0, 50000); | |
| 168 | + } else if ($attempt === 2) { | |
| 169 | + // Second fallback: Keep only alphanumeric and basic punctuation, shorten further | |
| 170 | + $current_content = preg_replace('/[^a-zA-Z0-9\s.,;:!?()-]/u', '', $current_content); | |
| 171 | + $current_content = substr($current_content, 0, 30000); | |
| 172 | + } | |
| 173 | + | |
| 174 | + $attempt++; | |
| 175 | + } | |
| 176 | + } catch (Exception $e) { | |
| 177 | + error_log('[MXCHAT-DB] Exception during database operation: ' . $e->getMessage()); | |
| 178 | + $attempt++; | |
| 68 | 179 | } |
| 69 | 180 | } |
| 181 | + | |
| 182 | + if ($result === false) { | |
| 183 | + error_log('[MXCHAT-DB] All database operation attempts failed'); | |
| 184 | + return new WP_Error('database_failed', 'Failed to store content in WordPress database after ' . $max_attempts . ' attempts'); | |
| 185 | + } | |
| 186 | + | |
| 187 | + error_log('[MXCHAT-DB] WordPress database operation completed successfully (Attempt ' . ($attempt - 1) . ')'); | |
| 188 | + return true; | |
| 189 | +} | |
| 70 | 190 | |
| 71 | - /** | |
| 72 | - * Generate an embedding for the given text using the specified API key. | |
| 73 | - * | |
| 74 | - * @param string $text The text to be embedded. | |
| 75 | - * @param string $api_key The API key used for generating embeddings. | |
| 76 | - * @return array|null The embedding vector or null on failure. | |
| 77 | - */ | |
| 78 | - private static function generate_embedding($text, $api_key) { | |
| 79 | - $endpoint = 'https://api.openai.com/v1/embeddings'; | |
| 191 | +/** | |
| 192 | + * Store content in Pinecone database | |
| 193 | + */ | |
| 194 | +private static function store_in_pinecone_main($embedding_vector, $content, $url, $api_key, $environment, $index_name, $vector_id = null) { | |
| 195 | + error_log('[MXCHAT-PINECONE-MAIN] ===== Starting Pinecone storage ====='); | |
| 196 | + | |
| 197 | + $vector_id = $vector_id ?: md5($url); | |
| 198 | + error_log('[MXCHAT-PINECONE-MAIN] Vector ID: ' . $vector_id); | |
| 199 | + | |
| 200 | + $options = get_option('mxchat_pinecone_addon_options'); | |
| 201 | + $host = $options['mxchat_pinecone_host'] ?? ''; | |
| 202 | + | |
| 203 | + error_log('[MXCHAT-PINECONE-MAIN] Host from options: ' . $host); | |
| 204 | + error_log('[MXCHAT-PINECONE-MAIN] API key length: ' . strlen($api_key)); | |
| 205 | + error_log('[MXCHAT-PINECONE-MAIN] Environment: ' . $environment); | |
| 206 | + error_log('[MXCHAT-PINECONE-MAIN] Index name: ' . $index_name); | |
| 80 | 207 | |
| 81 | - $body = wp_json_encode([ | |
| 82 | - 'input' => $text, | |
| 83 | - 'model' => 'text-embedding-ada-002' | |
| 84 | - ]); | |
| 208 | + if (empty($host)) { | |
| 209 | + error_log('[MXCHAT-PINECONE-MAIN] ERROR: Host is empty'); | |
| 210 | + return new WP_Error('pinecone_config', 'Pinecone host is not configured. Please set the host in your settings.'); | |
| 211 | + } | |
| 85 | 212 | |
| 86 | - $args = [ | |
| 87 | - 'body' => $body, | |
| 88 | - 'headers' => [ | |
| 89 | - 'Content-Type' => 'application/json', | |
| 90 | - 'Authorization' => 'Bearer ' . $api_key, | |
| 213 | + // Determine if this is a product URL | |
| 214 | + $is_product = (strpos($url, '/product/') !== false || strpos($url, '/shop/') !== false); | |
| 215 | + error_log('[MXCHAT-PINECONE-MAIN] Is product URL: ' . ($is_product ? 'YES' : 'NO')); | |
| 216 | + | |
| 217 | + $api_endpoint = "https://{$host}/vectors/upsert"; | |
| 218 | + error_log('[MXCHAT-PINECONE-MAIN] API endpoint: ' . $api_endpoint); | |
| 219 | + | |
| 220 | + $request_body = array( | |
| 221 | + 'vectors' => array( | |
| 222 | + array( | |
| 223 | + 'id' => $vector_id, | |
| 224 | + 'values' => $embedding_vector, | |
| 225 | + 'metadata' => array( | |
| 226 | + 'text' => $content, | |
| 227 | + 'source_url' => $url, | |
| 228 | + 'type' => $is_product ? 'product' : 'content', | |
| 229 | + 'last_updated' => time() | |
| 230 | + ) | |
| 231 | + ) | |
| 232 | + ) | |
| 233 | + ); | |
| 234 | + | |
| 235 | + error_log('[MXCHAT-PINECONE-MAIN] Request body prepared (embedding dimensions: ' . count($embedding_vector) . ')'); | |
| 236 | + | |
| 237 | + $response = wp_remote_post($api_endpoint, array( | |
| 238 | + 'headers' => array( | |
| 239 | + 'Api-Key' => $api_key, | |
| 240 | + 'accept' => 'application/json', | |
| 241 | + 'content-type' => 'application/json' | |
| 242 | + ), | |
| 243 | + 'body' => wp_json_encode($request_body), | |
| 244 | + 'timeout' => 30, | |
| 245 | + 'data_format' => 'body' | |
| 246 | + )); | |
| 247 | + | |
| 248 | + if (is_wp_error($response)) { | |
| 249 | + error_log('[MXCHAT-PINECONE-MAIN] WordPress request error: ' . $response->get_error_message()); | |
| 250 | + return new WP_Error('pinecone_request', $response->get_error_message()); | |
| 251 | + } | |
| 252 | + | |
| 253 | + $response_code = wp_remote_retrieve_response_code($response); | |
| 254 | + error_log('[MXCHAT-PINECONE-MAIN] Response code: ' . $response_code); | |
| 255 | + | |
| 256 | + if ($response_code !== 200) { | |
| 257 | + $body = wp_remote_retrieve_body($response); | |
| 258 | + error_log('[MXCHAT-PINECONE-MAIN] API error - Response body: ' . $body); | |
| 259 | + return new WP_Error('pinecone_api', sprintf( | |
| 260 | + 'Pinecone API error (HTTP %d): %s', | |
| 261 | + $response_code, | |
| 262 | + $body | |
| 263 | + )); | |
| 264 | + } | |
| 265 | + | |
| 266 | + $response_body = wp_remote_retrieve_body($response); | |
| 267 | + error_log('[MXCHAT-PINECONE-MAIN] Success response: ' . $response_body); | |
| 268 | + error_log('[MXCHAT-PINECONE-MAIN] Successfully stored in Pinecone'); | |
| 269 | + error_log('[MXCHAT-PINECONE-MAIN] ===== Pinecone storage complete ====='); | |
| 270 | + | |
| 271 | + return true; | |
| 272 | +} | |
| 273 | + | |
| 274 | +/** | |
| 275 | + * Generate an embedding for the given text using the specified API key. | |
| 276 | + * | |
| 277 | + * @param string $text The text to be embedded. | |
| 278 | + * @param string $api_key The API key used for generating embeddings. | |
| 279 | + * @return array|null The embedding vector or null on failure. | |
| 280 | + */ | |
| 281 | +private static function generate_embedding($text, $api_key) { | |
| 282 | + // Get options and selected model | |
| 283 | + $options = get_option('mxchat_options'); | |
| 284 | + $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002'; | |
| 285 | + | |
| 286 | + // Determine endpoint and API key based on model | |
| 287 | + if (strpos($selected_model, 'voyage') === 0) { | |
| 288 | + $endpoint = 'https://api.voyageai.com/v1/embeddings'; | |
| 289 | + $api_key = $options['voyage_api_key'] ?? ''; | |
| 290 | + } elseif (strpos($selected_model, 'gemini-embedding') === 0) { | |
| 291 | + $endpoint = 'https://generativelanguage.googleapis.com/v1beta/models/' . $selected_model . ':embedContent'; | |
| 292 | + $api_key = $options['gemini_api_key'] ?? ''; | |
| 293 | + } else { | |
| 294 | + $endpoint = 'https://api.openai.com/v1/embeddings'; | |
| 295 | + // Use the passed API key for OpenAI | |
| 296 | + } | |
| 297 | + | |
| 298 | + // Prepare request body based on provider | |
| 299 | + if (strpos($selected_model, 'gemini-embedding') === 0) { | |
| 300 | + // Gemini API format | |
| 301 | + $request_body = [ | |
| 302 | + 'model' => 'models/' . $selected_model, | |
| 303 | + 'content' => [ | |
| 304 | + 'parts' => [ | |
| 305 | + ['text' => $text] | |
| 306 | + ] | |
| 91 | 307 | ], |
| 92 | - 'timeout' => 60, | |
| 93 | - 'redirection' => 5, | |
| 94 | - 'blocking' => true, | |
| 95 | - 'httpversion' => '1.0', | |
| 96 | - 'sslverify' => true, | |
| 308 | + 'outputDimensionality' => 1536 | |
| 97 | 309 | ]; |
| 98 | - | |
| 99 | - $response = wp_remote_post($endpoint, $args); | |
| 100 | - | |
| 101 | - if (is_wp_error($response)) { | |
| 102 | - // error_log('Error generating embedding: ' . $response->get_error_message()); | |
| 310 | + | |
| 311 | + // Prepare headers for Gemini (API key as query parameter) | |
| 312 | + $endpoint .= '?key=' . $api_key; | |
| 313 | + $headers = [ | |
| 314 | + 'Content-Type' => 'application/json' | |
| 315 | + ]; | |
| 316 | + } else { | |
| 317 | + // OpenAI/Voyage API format | |
| 318 | + $request_body = [ | |
| 319 | + 'input' => $text, | |
| 320 | + 'model' => $selected_model | |
| 321 | + ]; | |
| 322 | + | |
| 323 | + // Add output_dimension for voyage-3-large | |
| 324 | + if ($selected_model === 'voyage-3-large') { | |
| 325 | + $request_body['output_dimension'] = 2048; | |
| 326 | + } | |
| 327 | + | |
| 328 | + // Prepare headers for OpenAI/Voyage | |
| 329 | + $headers = [ | |
| 330 | + 'Content-Type' => 'application/json', | |
| 331 | + 'Authorization' => 'Bearer ' . $api_key | |
| 332 | + ]; | |
| 333 | + } | |
| 334 | + | |
| 335 | + $args = [ | |
| 336 | + 'body' => wp_json_encode($request_body), | |
| 337 | + 'headers' => $headers, | |
| 338 | + 'timeout' => 60, | |
| 339 | + 'redirection' => 5, | |
| 340 | + 'blocking' => true, | |
| 341 | + 'httpversion' => '1.0', | |
| 342 | + 'sslverify' => true, | |
| 343 | + ]; | |
| 344 | + | |
| 345 | + $response = wp_remote_post($endpoint, $args); | |
| 346 | + | |
| 347 | + if (is_wp_error($response)) { | |
| 348 | + error_log('Error generating embedding: ' . $response->get_error_message()); | |
| 349 | + return null; | |
| 350 | + } | |
| 351 | + | |
| 352 | + $response_body = json_decode(wp_remote_retrieve_body($response), true); | |
| 353 | + | |
| 354 | + // Handle different response formats based on provider | |
| 355 | + if (strpos($selected_model, 'gemini-embedding') === 0) { | |
| 356 | + // Gemini API response format | |
| 357 | + if (isset($response_body['embedding']['values']) && is_array($response_body['embedding']['values'])) { | |
| 358 | + return $response_body['embedding']['values']; | |
| 359 | + } else { | |
| 360 | + error_log('Invalid response received from Gemini embedding API: ' . wp_json_encode($response_body)); | |
| 103 | 361 | return null; |
| 104 | 362 | } |
| 105 | - | |
| 106 | - $response_body = json_decode(wp_remote_retrieve_body($response), true); | |
| 107 | - | |
| 363 | + } else { | |
| 364 | + // OpenAI/Voyage API response format | |
| 108 | 365 | if (isset($response_body['data'][0]['embedding']) && is_array($response_body['data'][0]['embedding'])) { |
| 109 | 366 | return $response_body['data'][0]['embedding']; |
| 110 | 367 | } else { |
| 111 | - // error_log('Invalid response received from embedding API.'); | |
| 368 | + error_log('Invalid response received from embedding API: ' . wp_json_encode($response_body)); | |
| 112 | 369 | return null; |
| 113 | 370 | } |
| 114 | 371 | } |
| 115 | -} | |
| 372 | +} | |
| 373 | +} | |