PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.2.3
MxChat – AI Chatbot & Content Generation for WordPress v3.2.3
3.2.21 3.2.20 3.2.19 3.2.18 3.2.17 3.2.16 3.2.15 3.2.14 3.2.12 3.2.13 3.2.11 3.2.10 3.2.9 3.2.8 3.2.7 3.2.6 3.2.5 3.2.4 3.2.3 3.2.2 3.2.1 2.0.3 2.0.4 2.0.5 2.0.6 All 152 releases
mxchat-basic / includes / class-mxchat-utils.php

class-mxchat-utils.php in MxChat – AI Chatbot & Content Generation for WordPress 3.2.3, at includes/class-mxchat-utils.php

965 lines 38.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 if (!defined('ABSPATH')) {
3 exit; // Exit if accessed directly
4 }
5
6 class MxChat_Utils {
7
8 /**
9 * Centralized embedding model registry. Single source of truth for dimensions
10 * and provider, so model-switch protection logic doesn't drift across files.
11 */
12 public static function embedding_model_registry() {
13 return array(
14 'text-embedding-ada-002' => array('dims' => 1536, 'provider' => 'openai', 'label' => 'Ada 2'),
15 'text-embedding-3-small' => array('dims' => 1536, 'provider' => 'openai', 'label' => 'TE3 Small'),
16 'text-embedding-3-large' => array('dims' => 3072, 'provider' => 'openai', 'label' => 'TE3 Large'),
17 'voyage-3-large' => array('dims' => 2048, 'provider' => 'voyage', 'label' => 'Voyage-3 Large'),
18 'gemini-embedding-001' => array('dims' => 1536, 'provider' => 'gemini', 'label' => 'Gemini Embedding'),
19 );
20 }
21
22 public static function embedding_model_dimensions($model) {
23 $registry = self::embedding_model_registry();
24 return isset($registry[$model]) ? (int) $registry[$model]['dims'] : 0;
25 }
26
27 public static function embedding_model_label($model) {
28 $registry = self::embedding_model_registry();
29 return isset($registry[$model]) ? $registry[$model]['label'] : $model;
30 }
31
32 /**
33 * Returns the model that was last used to actually write embeddings into the
34 * KB. Differs from the user-selected setting once a switch has happened but
35 * no re-embed has occurred yet — that's the mismatch state we warn about.
36 */
37 public static function get_active_embedding_model() {
38 return get_option('mxchat_active_embedding_model', '');
39 }
40
41 /**
42 * Stamp the model that produced the most recent successful embedding. Called
43 * from generate_embedding() right after the API responds with a valid vector.
44 */
45 public static function stamp_active_embedding_model($model) {
46 if (!empty($model) && $model !== self::get_active_embedding_model()) {
47 update_option('mxchat_active_embedding_model', $model, false);
48 }
49 }
50
51 /**
52 * UPDATED: Submit or update content (and its embedding) in the database.
53 * Stores in Pinecone if enabled, otherwise stores in WordPress DB.
54 *
55 * @param string $content The content to be embedded.
56 * @param string $source_url The source URL of the content.
57 * @param string $api_key The API key used for generating embeddings.
58 * @param string $vector_id Optional vector ID for Pinecone (if not provided, will use md5 of URL)
59 * @param string $bot_id The bot ID for multi-bot support
60 * @param string $content_type The type of content (post, page, pdf, url, manual, product, etc.)
61 * @return bool|WP_Error True on success, WP_Error on failure
62 */
63 public static function submit_content_to_db($content, $source_url, $api_key, $vector_id = null, $bot_id = 'default', $content_type = 'content') {
64 global $wpdb;
65 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
66
67 //error_log('[MXCHAT-DB] Starting database submission for URL: ' . $source_url . ' (Bot: ' . $bot_id . ', Type: ' . $content_type . ')');
68 //error_log('[MXCHAT-DB] Content length: ' . strlen($content) . ' bytes');
69
70 // Sanitize the source URL
71 $source_url = esc_url_raw($source_url);
72
73 // Sanitize content_type
74 $content_type = sanitize_key($content_type);
75 if (empty($content_type)) {
76 $content_type = 'content'; // Fallback for backwards compatibility
77 }
78
79 // Just ensure UTF-8 validity without aggressive escaping
80 $safe_content = wp_check_invalid_utf8($content);
81 // Remove only null bytes and other control characters, but preserve newlines (\n = \x0A) and carriage returns (\r = \x0D)
82 $safe_content = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $safe_content);
83
84 // Check if chunking should be applied
85 $chunker = MxChat_Chunker::from_settings();
86 if ($chunker->should_chunk($safe_content)) {
87 //error_log('[MXCHAT-DB] Content exceeds chunk threshold, using chunked submission');
88 return self::submit_chunked_content($safe_content, $source_url, $api_key, $bot_id, $content_type, $chunker);
89 }
90
91 // UPDATED: Generate the embedding using bot-specific configuration
92 $embedding_vector = self::generate_embedding($content, $api_key, $bot_id);
93
94 if (!is_array($embedding_vector)) {
95 //error_log('[MXCHAT-DB] Error: Embedding generation failed');
96 return new WP_Error('embedding_failed', 'Failed to generate embedding for content');
97 }
98
99 //error_log('[MXCHAT-DB] Embedding generated successfully');
100
101 // UPDATED: Check if Pinecone is enabled for this specific bot
102 if (self::is_pinecone_enabled_for_bot($bot_id)) {
103 //error_log('[MXCHAT-DB] Pinecone is enabled for bot ' . $bot_id . ' - using Pinecone storage');
104 // Store in Pinecone only
105 return self::store_in_pinecone_only($embedding_vector, $content, $source_url, $vector_id, $bot_id, $content_type);
106 } else {
107 //error_log('[MXCHAT-DB] Pinecone not enabled for bot ' . $bot_id . ' - using WordPress storage');
108 // Store in WordPress database only
109 $embedding_vector_serialized = maybe_serialize($embedding_vector);
110 return self::store_in_wordpress_db($safe_content, $source_url, $embedding_vector_serialized, $table_name, $content_type);
111 }
112 }
113
114 /**
115 * UPDATED: Check if Pinecone is enabled and properly configured for a specific bot
116 */
117 private static function is_pinecone_enabled_for_bot($bot_id = 'default') {
118 // For default bot or when multi-bot is not active, use original method
119 if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
120 return self::is_pinecone_enabled();
121 }
122
123 // Get bot-specific Pinecone configuration
124 $bot_pinecone_config = apply_filters('mxchat_get_bot_pinecone_config', array(), $bot_id);
125
126 if (empty($bot_pinecone_config)) {
127 // Fallback to default configuration
128 return self::is_pinecone_enabled();
129 }
130
131 $enabled_check = !empty($bot_pinecone_config['use_pinecone']) && $bot_pinecone_config['use_pinecone'];
132 $api_key_check = !empty($bot_pinecone_config['api_key']);
133 $host_check = !empty($bot_pinecone_config['host']);
134
135 return $enabled_check && $api_key_check && $host_check;
136 }
137
138 /**
139 * Check if Pinecone is enabled and properly configured (original method for default bot)
140 */
141 private static function is_pinecone_enabled() {
142 $pinecone_options = get_option('mxchat_pinecone_addon_options');
143
144 if (empty($pinecone_options)) {
145 return false;
146 }
147
148 $enabled_check = !empty($pinecone_options['mxchat_use_pinecone']) && $pinecone_options['mxchat_use_pinecone'] !== '0';
149 $api_key_check = !empty($pinecone_options['mxchat_pinecone_api_key']);
150 $host_check = !empty($pinecone_options['mxchat_pinecone_host']);
151
152 return $enabled_check && $api_key_check && $host_check;
153 }
154
155 /**
156 * UPDATED: Store content in Pinecone only with bot support
157 */
158 private static function store_in_pinecone_only($embedding_vector, $content, $source_url, $vector_id = null, $bot_id = 'default', $content_type = 'content') {
159 //error_log('[MXCHAT-PINECONE] ===== Using Pinecone-only storage for bot ' . $bot_id . ' =====');
160
161 // Get bot-specific Pinecone configuration
162 if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
163 $pinecone_options = get_option('mxchat_pinecone_addon_options');
164 $api_key = $pinecone_options['mxchat_pinecone_api_key'];
165 $environment = $pinecone_options['mxchat_pinecone_environment'] ?? '';
166 $index_name = $pinecone_options['mxchat_pinecone_index'] ?? '';
167 $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
168 } else {
169 $bot_pinecone_config = apply_filters('mxchat_get_bot_pinecone_config', array(), $bot_id);
170 if (empty($bot_pinecone_config)) {
171 // Fallback to default configuration
172 $pinecone_options = get_option('mxchat_pinecone_addon_options');
173 $api_key = $pinecone_options['mxchat_pinecone_api_key'];
174 $environment = $pinecone_options['mxchat_pinecone_environment'] ?? '';
175 $index_name = $pinecone_options['mxchat_pinecone_index'] ?? '';
176 $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
177 } else {
178 $api_key = $bot_pinecone_config['api_key'];
179 $environment = ''; // Not used in new Pinecone API
180 $index_name = ''; // Not used in new Pinecone API
181 $namespace = $bot_pinecone_config['namespace'] ?? '';
182 }
183 }
184
185 $result = self::store_in_pinecone_main(
186 $embedding_vector,
187 $content,
188 $source_url,
189 $api_key,
190 $environment,
191 $index_name,
192 $vector_id,
193 $bot_id,
194 $namespace,
195 $content_type
196 );
197
198 if (is_wp_error($result)) {
199 //error_log('[MXCHAT-PINECONE] Pinecone storage failed for bot ' . $bot_id . ': ' . $result->get_error_message());
200 return $result;
201 }
202
203 //error_log('[MXCHAT-PINECONE] Pinecone storage completed successfully for bot ' . $bot_id);
204 return true;
205 }
206
207 /**
208 * Store content in WordPress database with progressive fallback
209 * UPDATED 2.5.6: Now includes content_type parameter
210 */
211 private static function store_in_wordpress_db($safe_content, $source_url, $embedding_vector_serialized, $table_name, $content_type = 'content') {
212 global $wpdb;
213
214 //error_log('[MXCHAT-DB] ===== Using WordPress-only storage =====');
215
216 // Sanitize content_type
217 $content_type = sanitize_key($content_type);
218 if (empty($content_type)) {
219 $content_type = 'content'; // Fallback for backwards compatibility
220 }
221
222 // ===== FIXED: Generate unique identifier for manual content =====
223 $original_source_url = $source_url;
224 // Check if this is truly manual content (no URL at all) vs a real URL that filter_var rejects
225 // filter_var(FILTER_VALIDATE_URL) rejects valid URLs with encoded chars, non-ASCII, fragments, etc.
226 // Use a looser check: if it starts with http(s):// or has a scheme, it's a URL
227 $has_url_scheme = !empty($source_url) && preg_match('#^https?://#i', $source_url);
228 // Treat legacy mxchat.ai source URLs as manual — old bug assigned the site URL to manual entries
229 $is_legacy_mxchat_url = $has_url_scheme && strpos($source_url, 'mxchat.ai') !== false;
230 $is_manual_content = empty($source_url) || $source_url === '' || !$has_url_scheme || $is_legacy_mxchat_url;
231
232 if ($is_manual_content) {
233 // Generate unique identifier for manual content to prevent overwrites
234 $source_url = 'mxchat://manual-content/' . time() . '-' . wp_generate_password(8, false);
235 //error_log('[MXCHAT-DB] Generated unique ID for manual content: ' . $source_url);
236 }
237
238 // Only check for duplicates if we have a valid source URL (not manual content)
239 $existing_id = null;
240 if (!$is_manual_content) {
241 $existing_id = $wpdb->get_var(
242 $wpdb->prepare(
243 "SELECT id FROM {$table_name} WHERE source_url = %s LIMIT 1",
244 $source_url
245 )
246 );
247 //error_log('[MXCHAT-DB] Checked for existing URL, found ID: ' . ($existing_id ?: 'none'));
248 } else {
249 //error_log('[MXCHAT-DB] Manual content - will create new entry (no duplicate check)');
250 }
251 // ===== END FIX =====
252
253 // Progressive fallback mechanism for problematic content
254 $attempt = 1;
255 $max_attempts = 3;
256 $current_content = $safe_content;
257 $result = false;
258
259 $active_model = self::get_active_embedding_model();
260
261 while ($attempt <= $max_attempts && $result === false) {
262 try {
263 if ($existing_id) {
264 //error_log('[MXCHAT-DB] Found existing entry (ID: ' . $existing_id . '). Updating... (Attempt ' . $attempt . ')');
265
266 // Update the existing row - UPDATED 2.5.6: Added content_type
267 $result = $wpdb->update(
268 $table_name,
269 array(
270 'url' => $source_url,
271 'article_content' => $current_content,
272 'embedding_vector' => $embedding_vector_serialized,
273 'source_url' => $source_url,
274 'content_type' => $content_type,
275 'embedding_model' => $active_model,
276 'timestamp' => current_time('mysql'),
277 ),
278 array('id' => $existing_id),
279 array('%s','%s','%s','%s','%s','%s','%s'),
280 array('%d')
281 );
282 } else {
283 //error_log('[MXCHAT-DB] No existing entry found. Inserting new row... (Attempt ' . $attempt . ')');
284 //error_log('[MXCHAT-DB] Content sample: ' . substr($current_content, 0, 1000));
285
286 // Insert a new row - UPDATED 2.5.6: Added content_type
287 $result = $wpdb->insert(
288 $table_name,
289 array(
290 'url' => $source_url, // Now unique for manual content
291 'article_content' => $current_content,
292 'embedding_vector' => $embedding_vector_serialized,
293 'source_url' => $source_url, // Now unique for manual content
294 'content_type' => $content_type,
295 'embedding_model' => $active_model,
296 'timestamp' => current_time('mysql'),
297 ),
298 array('%s','%s','%s','%s','%s','%s','%s')
299 );
300 }
301
302 if ($result === false) {
303 //error_log('[MXCHAT-DB] Database operation failed (Attempt ' . $attempt . ')');
304 //error_log('[MXCHAT-DB] MySQL Error: ' . $wpdb->last_error);
305 //error_log('[MXCHAT-DB] MySQL Error Number: ' . $wpdb->last_errno);
306 //error_log('[MXCHAT-DB] Last Query: ' . substr($wpdb->last_query, 0, 500));
307 //error_log('[MXCHAT-DB] Content length: ' . strlen($current_content) . ' bytes');
308 //error_log('[MXCHAT-DB] Embedding vector length: ' . strlen($embedding_vector_serialized) . ' bytes');
309
310 // Progressively apply more aggressive sanitization on failure
311 if ($attempt === 1) {
312 // First fallback: Use a more aggressive character filter and shorten
313 $current_content = preg_replace('/[^\p{L}\p{N}\s.,;:!?()-]/u', '', $current_content);
314 $current_content = substr($current_content, 0, 50000);
315 } else if ($attempt === 2) {
316 // Second fallback: Keep only alphanumeric and basic punctuation, shorten further
317 $current_content = preg_replace('/[^a-zA-Z0-9\s.,;:!?()-]/u', '', $current_content);
318 $current_content = substr($current_content, 0, 30000);
319 }
320
321 $attempt++;
322 }
323 } catch (Exception $e) {
324 //error_log('[MXCHAT-DB] Exception during database operation: ' . $e->getMessage());
325 $attempt++;
326 }
327 }
328
329 if ($result === false) {
330 //error_log('[MXCHAT-DB] All database operation attempts failed');
331 //error_log('[MXCHAT-DB] Final MySQL Error: ' . $wpdb->last_error);
332
333 $detailed_error = sprintf(
334 'Failed to store content in WordPress database after %d attempts. MySQL Error: %s (Error #%d). Content size: %d bytes, Embedding size: %d bytes',
335 $max_attempts,
336 $wpdb->last_error,
337 $wpdb->last_errno,
338 strlen($current_content),
339 strlen($embedding_vector_serialized)
340 );
341
342 return new WP_Error('database_failed', $detailed_error);
343 }
344
345 //error_log('[MXCHAT-DB] WordPress database operation completed successfully (Attempt ' . ($attempt - 1) . ')');
346 return true;
347 }
348
349 /**
350 * UPDATED: Store content in Pinecone database with bot support
351 * UPDATED 2.5.6: Now accepts content_type parameter
352 */
353 private static function store_in_pinecone_main($embedding_vector, $content, $url, $api_key, $environment, $index_name, $vector_id = null, $bot_id = 'default', $namespace = '', $content_type = 'content') {
354 //error_log('[MXCHAT-PINECONE-MAIN] ===== Starting Pinecone storage for bot ' . $bot_id . ' =====');
355
356 // ===== UPDATED: Handle manual content with unique vector IDs =====
357 if ($vector_id) {
358 // Use provided vector ID
359 //error_log('[MXCHAT-PINECONE-MAIN] Using provided vector ID: ' . $vector_id);
360 } elseif (!empty($url) && preg_match('#^https?://#i', $url)) {
361 // For URLs, use URL-based ID (existing behavior)
362 $vector_id = md5($url);
363 //error_log('[MXCHAT-PINECONE-MAIN] Generated vector ID from URL: ' . $vector_id);
364 } else {
365 // For manual content (empty/no URL scheme), generate unique ID
366 $vector_id = 'manual_' . time() . '_' . substr(md5($content . microtime(true)), 0, 8);
367 //error_log('[MXCHAT-PINECONE-MAIN] Generated unique vector ID for manual content: ' . $vector_id);
368 }
369 // ===== END UPDATE =====
370
371 // Get host from bot-specific config or fallback to default
372 if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
373 $options = get_option('mxchat_pinecone_addon_options');
374 $host = $options['mxchat_pinecone_host'] ?? '';
375 } else {
376 $bot_pinecone_config = apply_filters('mxchat_get_bot_pinecone_config', array(), $bot_id);
377 if (!empty($bot_pinecone_config)) {
378 $host = $bot_pinecone_config['host'] ?? '';
379 } else {
380 $options = get_option('mxchat_pinecone_addon_options');
381 $host = $options['mxchat_pinecone_host'] ?? '';
382 }
383 }
384
385 //error_log('[MXCHAT-PINECONE-MAIN] Host: ' . $host);
386 //error_log('[MXCHAT-PINECONE-MAIN] API key length: ' . strlen($api_key));
387 //error_log('[MXCHAT-PINECONE-MAIN] Bot ID: ' . $bot_id);
388 //error_log('[MXCHAT-PINECONE-MAIN] Namespace: ' . $namespace);
389
390 if (empty($host)) {
391 //error_log('[MXCHAT-PINECONE-MAIN] ERROR: Host is empty');
392 return new WP_Error('pinecone_config', 'Pinecone host is not configured. Please set the host in your bot settings.');
393 }
394
395 // ===== UPDATED 2.5.6: Use passed content_type or determine from URL if not provided =====
396 // Sanitize content_type
397 $content_type = sanitize_key($content_type);
398 if (empty($content_type)) {
399 // Fallback to old detection logic for backwards compatibility
400 $is_product = false;
401 $content_type = 'manual'; // Default for manual content
402
403 if (!empty($url) && preg_match('#^https?://#i', $url)) {
404 $is_product = (strpos($url, '/product/') !== false || strpos($url, '/shop/') !== false);
405 $content_type = $is_product ? 'product' : 'content';
406 }
407 }
408
409 //error_log('[MXCHAT-PINECONE-MAIN] Content type: ' . $content_type);
410 // ===== END UPDATE =====
411
412 $api_endpoint = "https://{$host}/vectors/upsert";
413 //error_log('[MXCHAT-PINECONE-MAIN] API endpoint: ' . $api_endpoint);
414
415 // UPDATED 2.5.6: Use provided content_type in metadata
416 $metadata = array(
417 'text' => $content,
418 'source_url' => $url, // Can be empty for manual content
419 'type' => $content_type, // Now supports: post, page, pdf, url, manual, product, etc.
420 'last_updated' => time(),
421 'created_at' => time(), // Add creation timestamp
422 'bot_id' => $bot_id, // Add bot identification
423 'embedding_model' => self::get_active_embedding_model() // 3.2.3: track which model produced this vector
424 );
425
426 $vector_data = array(
427 'id' => $vector_id,
428 'values' => $embedding_vector,
429 'metadata' => $metadata
430 );
431
432 $request_body = array(
433 'vectors' => array($vector_data)
434 );
435
436 // Add namespace if specified for multi-bot separation
437 if (!empty($namespace)) {
438 $request_body['namespace'] = $namespace;
439 //error_log('[MXCHAT-PINECONE-MAIN] Using namespace: ' . $namespace);
440 }
441
442 //error_log('[MXCHAT-PINECONE-MAIN] Request body prepared (embedding dimensions: ' . count($embedding_vector) . ')');
443
444 $response = wp_remote_post($api_endpoint, array(
445 'headers' => array(
446 'Api-Key' => $api_key,
447 'accept' => 'application/json',
448 'content-type' => 'application/json'
449 ),
450 'body' => wp_json_encode($request_body),
451 'timeout' => 30,
452 'data_format' => 'body'
453 ));
454
455 if (is_wp_error($response)) {
456 //error_log('[MXCHAT-PINECONE-MAIN] WordPress request error: ' . $response->get_error_message());
457 return new WP_Error('pinecone_request', $response->get_error_message());
458 }
459
460 $response_code = wp_remote_retrieve_response_code($response);
461 //error_log('[MXCHAT-PINECONE-MAIN] Response code: ' . $response_code);
462
463 if ($response_code !== 200) {
464 $body = wp_remote_retrieve_body($response);
465 //error_log('[MXCHAT-PINECONE-MAIN] API error - Response body: ' . $body);
466 return new WP_Error('pinecone_api', sprintf(
467 'Pinecone API error (HTTP %d): %s',
468 $response_code,
469 $body
470 ));
471 }
472
473 $response_body = wp_remote_retrieve_body($response);
474 //error_log('[MXCHAT-PINECONE-MAIN] Success response: ' . $response_body);
475 //error_log('[MXCHAT-PINECONE-MAIN] Successfully stored in Pinecone for bot ' . $bot_id);
476 //error_log('[MXCHAT-PINECONE-MAIN] ===== Pinecone storage complete =====');
477
478 return true;
479 }
480
481 /**
482 * UPDATED: Generate an embedding for the given text using bot-specific configuration.
483 *
484 * @param string $text The text to be embedded.
485 * @param string $api_key The API key used for generating embeddings.
486 * @param string $bot_id The bot ID for multi-bot support
487 * @return array|null The embedding vector or null on failure.
488 */
489 private static function generate_embedding($text, $api_key, $bot_id = 'default') {
490 // Get bot-specific options
491 if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
492 $options = get_option('mxchat_options');
493 } else {
494 $bot_options = apply_filters('mxchat_get_bot_options', array(), $bot_id);
495 $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
496 }
497
498 $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
499
500 // Determine endpoint and API key based on model
501 if (strpos($selected_model, 'voyage') === 0) {
502 $endpoint = 'https://api.voyageai.com/v1/embeddings';
503 $api_key = $options['voyage_api_key'] ?? '';
504 } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
505 $endpoint = 'https://generativelanguage.googleapis.com/v1beta/models/' . $selected_model . ':embedContent';
506 $api_key = $options['gemini_api_key'] ?? '';
507 } else {
508 $endpoint = 'https://api.openai.com/v1/embeddings';
509 // Use the bot-specific API key or fallback to passed API key
510 $api_key = $options['api_key'] ?? $api_key;
511 }
512
513 // Prepare request body based on provider
514 if (strpos($selected_model, 'gemini-embedding') === 0) {
515 // Gemini API format
516 $request_body = [
517 'model' => 'models/' . $selected_model,
518 'content' => [
519 'parts' => [
520 ['text' => $text]
521 ]
522 ],
523 'outputDimensionality' => 1536
524 ];
525
526 // Prepare headers for Gemini (API key as query parameter)
527 $endpoint .= '?key=' . $api_key;
528 $headers = [
529 'Content-Type' => 'application/json'
530 ];
531 } else {
532 // OpenAI/Voyage API format
533 $request_body = [
534 'input' => $text,
535 'model' => $selected_model
536 ];
537
538 // Add output_dimension for voyage-3-large
539 if ($selected_model === 'voyage-3-large') {
540 $request_body['output_dimension'] = 2048;
541 }
542
543 // Prepare headers for OpenAI/Voyage
544 $headers = [
545 'Content-Type' => 'application/json',
546 'Authorization' => 'Bearer ' . $api_key
547 ];
548 }
549
550 $args = [
551 'body' => wp_json_encode($request_body),
552 'headers' => $headers,
553 'timeout' => 60,
554 'redirection' => 5,
555 'blocking' => true,
556 'httpversion' => '1.0',
557 'sslverify' => true,
558 ];
559
560 $response = wp_remote_post($endpoint, $args);
561
562 if (is_wp_error($response)) {
563 //error_log('Error generating embedding for bot ' . $bot_id . ': ' . $response->get_error_message());
564 return null;
565 }
566
567 $response_body = json_decode(wp_remote_retrieve_body($response), true);
568
569 // Handle different response formats based on provider
570 if (strpos($selected_model, 'gemini-embedding') === 0) {
571 // Gemini API response format
572 if (isset($response_body['embedding']['values']) && is_array($response_body['embedding']['values'])) {
573 self::stamp_active_embedding_model($selected_model);
574 return $response_body['embedding']['values'];
575 } else {
576 //error_log('Invalid response received from Gemini embedding API for bot ' . $bot_id . ': ' . wp_json_encode($response_body));
577 return null;
578 }
579 } else {
580 // OpenAI/Voyage API response format
581 if (isset($response_body['data'][0]['embedding']) && is_array($response_body['data'][0]['embedding'])) {
582 self::stamp_active_embedding_model($selected_model);
583 return $response_body['data'][0]['embedding'];
584 } else {
585 //error_log('Invalid response received from embedding API for bot ' . $bot_id . ': ' . wp_json_encode($response_body));
586 return null;
587 }
588 }
589 }
590
591 /**
592 * Submit content as multiple chunks
593 *
594 * Splits large content into chunks, generates embeddings for each,
595 * and stores them with chunk metadata for later reassembly.
596 *
597 * @param string $content The content to chunk and store
598 * @param string $source_url The source URL
599 * @param string $api_key The API key for embeddings
600 * @param string $bot_id The bot ID
601 * @param string $content_type The content type
602 * @param MxChat_Chunker $chunker The chunker instance
603 * @return bool|WP_Error True on success, WP_Error on failure
604 */
605 private static function submit_chunked_content($content, $source_url, $api_key, $bot_id, $content_type, $chunker) {
606 global $wpdb;
607 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
608
609 //error_log('[MXCHAT-CHUNK-DEBUG] Starting chunked submission for: ' . $source_url);
610 //error_log('[MXCHAT-CHUNK-DEBUG] Content length: ' . strlen($content) . ' chars');
611
612 // First, delete any existing chunks for this URL (clean slate)
613 $delete_result = self::delete_chunks_for_url($source_url, $bot_id);
614 if (is_wp_error($delete_result)) {
615 //error_log('[MXCHAT-CHUNK-DEBUG] Warning: Failed to delete existing chunks: ' . $delete_result->get_error_message());
616 // Continue anyway - we'll overwrite with upsert
617 }
618
619 // Split content into chunks
620 $chunks = $chunker->chunk_text($content);
621 $total_chunks = count($chunks);
622
623 //error_log('[MXCHAT-CHUNK-DEBUG] Created ' . $total_chunks . ' chunks');
624 foreach ($chunks as $i => $chunk) {
625 //error_log('[MXCHAT-CHUNK-DEBUG] Chunk ' . $i . ' length: ' . strlen($chunk) . ' chars, preview: ' . substr($chunk, 0, 100));
626 }
627
628 //error_log('[MXCHAT-CHUNK] Split content into ' . $total_chunks . ' chunks');
629
630 if ($total_chunks === 0) {
631 return new WP_Error('chunking_failed', 'Content could not be split into chunks');
632 }
633
634 $errors = array();
635 $is_pinecone = self::is_pinecone_enabled_for_bot($bot_id);
636
637 foreach ($chunks as $index => $chunk_text) {
638 // Generate chunk metadata
639 $chunk_metadata = MxChat_Chunker::create_chunk_metadata($index, $total_chunks, $source_url);
640 $chunk_vector_id = MxChat_Chunker::generate_chunk_vector_id($source_url, $index);
641
642 //error_log('[MXCHAT-CHUNK] Processing chunk ' . ($index + 1) . '/' . $total_chunks . ' (ID: ' . $chunk_vector_id . ')');
643
644 // Generate embedding for this chunk
645 $embedding_vector = self::generate_embedding($chunk_text, $api_key, $bot_id);
646
647 if (!is_array($embedding_vector)) {
648 $errors[] = new WP_Error('embedding_failed', 'Failed to generate embedding for chunk ' . $index);
649 //error_log('[MXCHAT-CHUNK] Failed to generate embedding for chunk ' . $index);
650 continue;
651 }
652
653 if ($is_pinecone) {
654 // Store in Pinecone with chunk metadata
655 $result = self::store_chunk_in_pinecone(
656 $embedding_vector,
657 $chunk_text,
658 $source_url,
659 $chunk_vector_id,
660 $bot_id,
661 $content_type,
662 $chunk_metadata
663 );
664 } else {
665 // Store in WordPress DB with chunk metadata
666 $content_with_metadata = MxChat_Chunker::format_chunk_for_storage($chunk_text, $chunk_metadata);
667 $embedding_vector_serialized = maybe_serialize($embedding_vector);
668
669 $result = self::store_chunk_in_wordpress_db(
670 $content_with_metadata,
671 $source_url,
672 $embedding_vector_serialized,
673 $table_name,
674 $content_type,
675 $chunk_metadata
676 );
677 }
678
679 if (is_wp_error($result)) {
680 $errors[] = $result;
681 //error_log('[MXCHAT-CHUNK] Failed to store chunk ' . $index . ': ' . $result->get_error_message());
682 }
683 }
684
685 if (count($errors) === $total_chunks) {
686 return new WP_Error('chunking_failed', 'Failed to store any chunks');
687 }
688
689 if (!empty($errors)) {
690 //error_log('[MXCHAT-CHUNK] Completed with ' . count($errors) . ' errors out of ' . $total_chunks . ' chunks');
691 return new WP_Error('chunking_partial_failure',
692 sprintf('Failed to store %d of %d chunks', count($errors), $total_chunks));
693 }
694
695 //error_log('[MXCHAT-CHUNK] Successfully stored all ' . $total_chunks . ' chunks');
696 return true;
697 }
698
699 /**
700 * Store a single chunk in Pinecone with chunk-specific metadata
701 */
702 private static function store_chunk_in_pinecone($embedding_vector, $chunk_text, $source_url, $vector_id, $bot_id, $content_type, $chunk_metadata) {
703 // Get Pinecone configuration
704 if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
705 $pinecone_options = get_option('mxchat_pinecone_addon_options');
706 $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
707 $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
708 $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
709 } else {
710 $bot_pinecone_config = apply_filters('mxchat_get_bot_pinecone_config', array(), $bot_id);
711 if (empty($bot_pinecone_config)) {
712 $pinecone_options = get_option('mxchat_pinecone_addon_options');
713 $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
714 $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
715 $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
716 } else {
717 $api_key = $bot_pinecone_config['api_key'] ?? '';
718 $host = $bot_pinecone_config['host'] ?? '';
719 $namespace = $bot_pinecone_config['namespace'] ?? '';
720 }
721 }
722
723 if (empty($host) || empty($api_key)) {
724 return new WP_Error('pinecone_config', 'Pinecone is not properly configured');
725 }
726
727 $api_endpoint = "https://{$host}/vectors/upsert";
728
729 // Build metadata with chunk information
730 $metadata = array(
731 'text' => $chunk_text,
732 'source_url' => $source_url,
733 'type' => $content_type,
734 'is_chunked' => true,
735 'chunk_index' => $chunk_metadata['chunk_index'],
736 'total_chunks' => $chunk_metadata['total_chunks'],
737 'parent_url_hash' => $chunk_metadata['parent_url_hash'],
738 'last_updated' => time(),
739 'created_at' => time(),
740 'bot_id' => $bot_id,
741 'embedding_model' => self::get_active_embedding_model()
742 );
743
744 $vector_data = array(
745 'id' => $vector_id,
746 'values' => $embedding_vector,
747 'metadata' => $metadata
748 );
749
750 $request_body = array(
751 'vectors' => array($vector_data)
752 );
753
754 if (!empty($namespace)) {
755 $request_body['namespace'] = $namespace;
756 }
757
758 $response = wp_remote_post($api_endpoint, array(
759 'headers' => array(
760 'Api-Key' => $api_key,
761 'accept' => 'application/json',
762 'content-type' => 'application/json'
763 ),
764 'body' => wp_json_encode($request_body),
765 'timeout' => 30
766 ));
767
768 if (is_wp_error($response)) {
769 return $response;
770 }
771
772 $response_code = wp_remote_retrieve_response_code($response);
773 if ($response_code !== 200) {
774 return new WP_Error('pinecone_api', 'Pinecone API error: HTTP ' . $response_code);
775 }
776
777 return true;
778 }
779
780 /**
781 * Store a single chunk in WordPress database
782 */
783 private static function store_chunk_in_wordpress_db($content_with_metadata, $source_url, $embedding_vector_serialized, $table_name, $content_type, $chunk_metadata) {
784 global $wpdb;
785
786 // For chunks, we always insert new rows (no duplicate checking)
787 // The URL includes chunk info in the metadata, but source_url stays the same for grouping
788 $result = $wpdb->insert(
789 $table_name,
790 array(
791 'url' => $source_url,
792 'article_content' => $content_with_metadata,
793 'embedding_vector' => $embedding_vector_serialized,
794 'source_url' => $source_url,
795 'content_type' => $content_type,
796 'embedding_model' => self::get_active_embedding_model(),
797 'timestamp' => current_time('mysql')
798 ),
799 array('%s', '%s', '%s', '%s', '%s', '%s', '%s')
800 );
801
802 if ($result === false) {
803 return new WP_Error('database_failed', 'Failed to insert chunk: ' . $wpdb->last_error);
804 }
805
806 return true;
807 }
808
809 /**
810 * Delete all chunks for a given URL
811 *
812 * @param string $source_url The source URL
813 * @param string $bot_id The bot ID
814 * @return bool|WP_Error True on success, WP_Error on failure
815 */
816 public static function delete_chunks_for_url($source_url, $bot_id = 'default') {
817 //error_log('[MXCHAT-CHUNK-DELETE] Deleting chunks for URL: ' . $source_url);
818
819 if (self::is_pinecone_enabled_for_bot($bot_id)) {
820 return self::delete_pinecone_chunks_by_url($source_url, $bot_id);
821 } else {
822 return self::delete_wordpress_chunks_by_url($source_url);
823 }
824 }
825
826 /**
827 * Delete all chunks for a URL from Pinecone
828 */
829 private static function delete_pinecone_chunks_by_url($source_url, $bot_id) {
830 // Get Pinecone configuration
831 if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
832 $pinecone_options = get_option('mxchat_pinecone_addon_options');
833 $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
834 $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
835 $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
836 } else {
837 $bot_pinecone_config = apply_filters('mxchat_get_bot_pinecone_config', array(), $bot_id);
838 if (empty($bot_pinecone_config)) {
839 $pinecone_options = get_option('mxchat_pinecone_addon_options');
840 $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
841 $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
842 $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
843 } else {
844 $api_key = $bot_pinecone_config['api_key'] ?? '';
845 $host = $bot_pinecone_config['host'] ?? '';
846 $namespace = $bot_pinecone_config['namespace'] ?? '';
847 }
848 }
849
850 if (empty($host) || empty($api_key)) {
851 return new WP_Error('pinecone_config', 'Pinecone is not properly configured');
852 }
853
854 $base_vector_id = md5($source_url);
855 $vectors_to_delete = array();
856
857 // Add the original single-vector ID (for non-chunked content)
858 $vectors_to_delete[] = $base_vector_id;
859
860 // Pinecone /vectors/list is a GET endpoint with query-string parameters; a POST here returns a
861 // non-200 silently and we end up only deleting the base vector, leaving chunks orphaned.
862 $query_params = array(
863 'prefix' => $base_vector_id . '_chunk_',
864 'limit' => 100,
865 );
866 if (!empty($namespace)) {
867 $query_params['namespace'] = $namespace;
868 }
869
870 $list_url = "https://{$host}/vectors/list?" . http_build_query($query_params);
871
872 // Paginate in case a URL has more than 100 chunks.
873 do {
874 $list_response = wp_remote_get($list_url, array(
875 'headers' => array(
876 'Api-Key' => $api_key,
877 'accept' => 'application/json',
878 ),
879 'timeout' => 30,
880 ));
881
882 if (is_wp_error($list_response) || wp_remote_retrieve_response_code($list_response) !== 200) {
883 break;
884 }
885
886 $list_data = json_decode(wp_remote_retrieve_body($list_response), true);
887 if (!empty($list_data['vectors'])) {
888 foreach ($list_data['vectors'] as $vector) {
889 if (isset($vector['id'])) {
890 $vectors_to_delete[] = $vector['id'];
891 }
892 }
893 }
894
895 $next_token = $list_data['pagination']['next'] ?? '';
896 if (empty($next_token)) {
897 break;
898 }
899
900 $query_params['paginationToken'] = $next_token;
901 $list_url = "https://{$host}/vectors/list?" . http_build_query($query_params);
902 } while (true);
903
904 if (empty($vectors_to_delete)) {
905 //error_log('[MXCHAT-CHUNK-DELETE] No vectors found to delete');
906 return true;
907 }
908
909 //error_log('[MXCHAT-CHUNK-DELETE] Deleting ' . count($vectors_to_delete) . ' vectors from Pinecone');
910
911 // Delete vectors
912 $delete_url = "https://{$host}/vectors/delete";
913
914 $delete_body = array(
915 'ids' => $vectors_to_delete
916 );
917
918 if (!empty($namespace)) {
919 $delete_body['namespace'] = $namespace;
920 }
921
922 $delete_response = wp_remote_post($delete_url, array(
923 'headers' => array(
924 'Api-Key' => $api_key,
925 'accept' => 'application/json',
926 'content-type' => 'application/json'
927 ),
928 'body' => wp_json_encode($delete_body),
929 'timeout' => 30
930 ));
931
932 if (is_wp_error($delete_response)) {
933 return $delete_response;
934 }
935
936 $response_code = wp_remote_retrieve_response_code($delete_response);
937 if ($response_code !== 200) {
938 return new WP_Error('pinecone_delete', 'Failed to delete vectors: HTTP ' . $response_code);
939 }
940
941 return true;
942 }
943
944 /**
945 * Delete all chunks for a URL from WordPress database
946 */
947 private static function delete_wordpress_chunks_by_url($source_url) {
948 global $wpdb;
949 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
950
951 // Delete all rows with this source_url (handles both chunked and non-chunked)
952 $result = $wpdb->delete(
953 $table_name,
954 array('source_url' => $source_url),
955 array('%s')
956 );
957
958 if ($result === false) {
959 return new WP_Error('database_delete', 'Failed to delete chunks: ' . $wpdb->last_error);
960 }
961
962 //error_log('[MXCHAT-CHUNK-DELETE] Deleted ' . $result . ' rows from WordPress DB');
963 return true;
964 }
965 }