PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.2.15
MxChat – AI Chatbot & Content Generation for WordPress v3.2.15
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.15, at includes/class-mxchat-utils.php

1,098 lines 45.2 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 * Extract the 11-character YouTube video ID from a URL, or '' if the URL is
53 * not a single-video YouTube link. Single source of truth for both the KB
54 * ingestion side and the chat render side — do not duplicate this parsing.
55 * Channel, playlist, and search URLs deliberately return '' (only a URL that
56 * identifies one video can be embedded).
57 */
58 public static function parse_youtube_id($url) {
59 if (!is_string($url) || $url === '' || !preg_match('#^https?://#i', $url)) {
60 return '';
61 }
62 $host = strtolower((string) wp_parse_url($url, PHP_URL_HOST));
63 $host = preg_replace('/^(www|m)\./', '', $host);
64 $path = (string) wp_parse_url($url, PHP_URL_PATH);
65 $id = '';
66 if ($host === 'youtu.be') {
67 $segments = explode('/', ltrim($path, '/'));
68 $id = $segments[0] ?? '';
69 } elseif (in_array($host, array('youtube.com', 'youtube-nocookie.com'), true)) {
70 if (preg_match('#^/(?:shorts|embed|live|v)/([A-Za-z0-9_-]+)#', $path, $m)) {
71 $id = $m[1];
72 } elseif ($path === '/watch') {
73 parse_str((string) wp_parse_url($url, PHP_URL_QUERY), $query_vars);
74 $id = isset($query_vars['v']) ? (string) $query_vars['v'] : '';
75 }
76 }
77 $id = preg_replace('/[^A-Za-z0-9_-]/', '', (string) $id);
78 return (strlen($id) === 11) ? $id : '';
79 }
80
81 /**
82 * UPDATED: Submit or update content (and its embedding) in the database.
83 * Stores in Pinecone if enabled, otherwise stores in WordPress DB.
84 *
85 * @param string $content The content to be embedded.
86 * @param string $source_url The source URL of the content.
87 * @param string $api_key The API key used for generating embeddings.
88 * @param string $vector_id Optional vector ID for Pinecone (if not provided, will use md5 of URL)
89 * @param string $bot_id The bot ID for multi-bot support
90 * @param string $content_type The type of content (post, page, pdf, url, manual, product, etc.)
91 * @return bool|WP_Error True on success, WP_Error on failure
92 */
93 public static function submit_content_to_db($content, $source_url, $api_key, $vector_id = null, $bot_id = 'default', $content_type = 'content') {
94 global $wpdb;
95 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
96
97 //error_log('[MXCHAT-DB] Starting database submission for URL: ' . $source_url . ' (Bot: ' . $bot_id . ', Type: ' . $content_type . ')');
98 //error_log('[MXCHAT-DB] Content length: ' . strlen($content) . ' bytes');
99
100 // Sanitize the source URL
101 $source_url = esc_url_raw($source_url);
102
103 // Sanitize content_type
104 $content_type = sanitize_key($content_type);
105 if (empty($content_type)) {
106 $content_type = 'content'; // Fallback for backwards compatibility
107 }
108
109 // Just ensure UTF-8 validity without aggressive escaping
110 $safe_content = wp_check_invalid_utf8($content);
111 // Remove only null bytes and other control characters, but preserve newlines (\n = \x0A) and carriage returns (\r = \x0D)
112 $safe_content = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $safe_content);
113
114 // Check if chunking should be applied
115 $chunker = MxChat_Chunker::from_settings();
116 if ($chunker->should_chunk($safe_content)) {
117 //error_log('[MXCHAT-DB] Content exceeds chunk threshold, using chunked submission');
118 return self::submit_chunked_content($safe_content, $source_url, $api_key, $bot_id, $content_type, $chunker);
119 }
120
121 // UPDATED: Generate the embedding using bot-specific configuration
122 $embedding_vector = self::generate_embedding($content, $api_key, $bot_id);
123
124 if (!is_array($embedding_vector)) {
125 //error_log('[MXCHAT-DB] Error: Embedding generation failed');
126 return new WP_Error('embedding_failed', 'Failed to generate embedding for content');
127 }
128
129 //error_log('[MXCHAT-DB] Embedding generated successfully');
130
131 // UPDATED: Check if Pinecone is enabled for this specific bot
132 if (self::is_pinecone_enabled_for_bot($bot_id)) {
133 //error_log('[MXCHAT-DB] Pinecone is enabled for bot ' . $bot_id . ' - using Pinecone storage');
134 // Store in Pinecone only
135 return self::store_in_pinecone_only($embedding_vector, $content, $source_url, $vector_id, $bot_id, $content_type);
136 } else {
137 //error_log('[MXCHAT-DB] Pinecone not enabled for bot ' . $bot_id . ' - using WordPress storage');
138 // Store in WordPress database only
139 $embedding_vector_serialized = maybe_serialize($embedding_vector);
140 return self::store_in_wordpress_db($safe_content, $source_url, $embedding_vector_serialized, $table_name, $content_type);
141 }
142 }
143
144 /**
145 * UPDATED: Check if Pinecone is enabled and properly configured for a specific bot
146 */
147 private static function is_pinecone_enabled_for_bot($bot_id = 'default') {
148 // For default bot or when multi-bot is not active, use original method
149 if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
150 return self::is_pinecone_enabled();
151 }
152
153 // Get bot-specific Pinecone configuration
154 $bot_pinecone_config = apply_filters('mxchat_get_bot_pinecone_config', array(), $bot_id);
155
156 if (empty($bot_pinecone_config)) {
157 // Fallback to default configuration
158 return self::is_pinecone_enabled();
159 }
160
161 $enabled_check = !empty($bot_pinecone_config['use_pinecone']) && $bot_pinecone_config['use_pinecone'];
162 $api_key_check = !empty($bot_pinecone_config['api_key']);
163 $host_check = !empty($bot_pinecone_config['host']);
164
165 return $enabled_check && $api_key_check && $host_check;
166 }
167
168 /**
169 * Check if Pinecone is enabled and properly configured (original method for default bot)
170 */
171 private static function is_pinecone_enabled() {
172 $pinecone_options = get_option('mxchat_pinecone_addon_options');
173
174 if (empty($pinecone_options)) {
175 return false;
176 }
177
178 $enabled_check = !empty($pinecone_options['mxchat_use_pinecone']) && $pinecone_options['mxchat_use_pinecone'] !== '0';
179 $api_key_check = !empty($pinecone_options['mxchat_pinecone_api_key']);
180 $host_check = !empty($pinecone_options['mxchat_pinecone_host']);
181
182 return $enabled_check && $api_key_check && $host_check;
183 }
184
185 /**
186 * UPDATED: Store content in Pinecone only with bot support
187 */
188 private static function store_in_pinecone_only($embedding_vector, $content, $source_url, $vector_id = null, $bot_id = 'default', $content_type = 'content') {
189 //error_log('[MXCHAT-PINECONE] ===== Using Pinecone-only storage for bot ' . $bot_id . ' =====');
190
191 // Get bot-specific Pinecone configuration
192 if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
193 $pinecone_options = get_option('mxchat_pinecone_addon_options');
194 $api_key = $pinecone_options['mxchat_pinecone_api_key'];
195 $environment = $pinecone_options['mxchat_pinecone_environment'] ?? '';
196 $index_name = $pinecone_options['mxchat_pinecone_index'] ?? '';
197 $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
198 } else {
199 $bot_pinecone_config = apply_filters('mxchat_get_bot_pinecone_config', array(), $bot_id);
200 if (empty($bot_pinecone_config)) {
201 // Fallback to default configuration
202 $pinecone_options = get_option('mxchat_pinecone_addon_options');
203 $api_key = $pinecone_options['mxchat_pinecone_api_key'];
204 $environment = $pinecone_options['mxchat_pinecone_environment'] ?? '';
205 $index_name = $pinecone_options['mxchat_pinecone_index'] ?? '';
206 $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
207 } else {
208 $api_key = $bot_pinecone_config['api_key'];
209 $environment = ''; // Not used in new Pinecone API
210 $index_name = ''; // Not used in new Pinecone API
211 $namespace = $bot_pinecone_config['namespace'] ?? '';
212 }
213 }
214
215 $result = self::store_in_pinecone_main(
216 $embedding_vector,
217 $content,
218 $source_url,
219 $api_key,
220 $environment,
221 $index_name,
222 $vector_id,
223 $bot_id,
224 $namespace,
225 $content_type
226 );
227
228 if (is_wp_error($result)) {
229 //error_log('[MXCHAT-PINECONE] Pinecone storage failed for bot ' . $bot_id . ': ' . $result->get_error_message());
230 return $result;
231 }
232
233 //error_log('[MXCHAT-PINECONE] Pinecone storage completed successfully for bot ' . $bot_id);
234 return true;
235 }
236
237 /**
238 * Store content in WordPress database with progressive fallback
239 * UPDATED 2.5.6: Now includes content_type parameter
240 */
241 private static function store_in_wordpress_db($safe_content, $source_url, $embedding_vector_serialized, $table_name, $content_type = 'content') {
242 global $wpdb;
243
244 //error_log('[MXCHAT-DB] ===== Using WordPress-only storage =====');
245
246 // Sanitize content_type
247 $content_type = sanitize_key($content_type);
248 if (empty($content_type)) {
249 $content_type = 'content'; // Fallback for backwards compatibility
250 }
251
252 // ===== FIXED: Generate unique identifier for manual content =====
253 $original_source_url = $source_url;
254 // Check if this is truly manual content (no URL at all) vs a real URL that filter_var rejects
255 // filter_var(FILTER_VALIDATE_URL) rejects valid URLs with encoded chars, non-ASCII, fragments, etc.
256 // Use a looser check: if it starts with http(s):// or has a scheme, it's a URL
257 $has_url_scheme = !empty($source_url) && preg_match('#^https?://#i', $source_url);
258 // Treat legacy mxchat.ai source URLs as manual — old bug assigned the site URL to manual entries
259 $is_legacy_mxchat_url = $has_url_scheme && strpos($source_url, 'mxchat.ai') !== false;
260 $is_manual_content = empty($source_url) || $source_url === '' || !$has_url_scheme || $is_legacy_mxchat_url;
261
262 if ($is_manual_content) {
263 // Generate unique identifier for manual content to prevent overwrites
264 $source_url = 'mxchat://manual-content/' . time() . '-' . wp_generate_password(8, false);
265 //error_log('[MXCHAT-DB] Generated unique ID for manual content: ' . $source_url);
266 }
267
268 // Only check for duplicates if we have a valid source URL (not manual content)
269 $existing_id = null;
270 if (!$is_manual_content) {
271 $existing_id = $wpdb->get_var(
272 $wpdb->prepare(
273 "SELECT id FROM {$table_name} WHERE source_url = %s LIMIT 1",
274 $source_url
275 )
276 );
277 //error_log('[MXCHAT-DB] Checked for existing URL, found ID: ' . ($existing_id ?: 'none'));
278 } else {
279 //error_log('[MXCHAT-DB] Manual content - will create new entry (no duplicate check)');
280 }
281 // ===== END FIX =====
282
283 // Progressive fallback mechanism for problematic content
284 $attempt = 1;
285 $max_attempts = 3;
286 $current_content = $safe_content;
287 $result = false;
288
289 while ($attempt <= $max_attempts && $result === false) {
290 try {
291 if ($existing_id) {
292 //error_log('[MXCHAT-DB] Found existing entry (ID: ' . $existing_id . '). Updating... (Attempt ' . $attempt . ')');
293
294 // Update the existing row - UPDATED 2.5.6: Added content_type
295 $result = $wpdb->update(
296 $table_name,
297 array(
298 'url' => $source_url,
299 'article_content' => $current_content,
300 'embedding_vector' => $embedding_vector_serialized,
301 'source_url' => $source_url,
302 'content_type' => $content_type,
303 'timestamp' => current_time('mysql'),
304 ),
305 array('id' => $existing_id),
306 array('%s','%s','%s','%s','%s','%s'),
307 array('%d')
308 );
309 } else {
310 //error_log('[MXCHAT-DB] No existing entry found. Inserting new row... (Attempt ' . $attempt . ')');
311 //error_log('[MXCHAT-DB] Content sample: ' . substr($current_content, 0, 1000));
312
313 // Insert a new row - UPDATED 2.5.6: Added content_type
314 $result = $wpdb->insert(
315 $table_name,
316 array(
317 'url' => $source_url, // Now unique for manual content
318 'article_content' => $current_content,
319 'embedding_vector' => $embedding_vector_serialized,
320 'source_url' => $source_url, // Now unique for manual content
321 'content_type' => $content_type,
322 'timestamp' => current_time('mysql'),
323 ),
324 array('%s','%s','%s','%s','%s','%s')
325 );
326 }
327
328 if ($result === false) {
329 //error_log('[MXCHAT-DB] Database operation failed (Attempt ' . $attempt . ')');
330 //error_log('[MXCHAT-DB] MySQL Error: ' . $wpdb->last_error);
331 //error_log('[MXCHAT-DB] MySQL Error Number: ' . $wpdb->last_errno);
332 //error_log('[MXCHAT-DB] Last Query: ' . substr($wpdb->last_query, 0, 500));
333 //error_log('[MXCHAT-DB] Content length: ' . strlen($current_content) . ' bytes');
334 //error_log('[MXCHAT-DB] Embedding vector length: ' . strlen($embedding_vector_serialized) . ' bytes');
335
336 // Progressively apply more aggressive sanitization on failure
337 if ($attempt === 1) {
338 // First fallback: Use a more aggressive character filter and shorten
339 $current_content = preg_replace('/[^\p{L}\p{N}\s.,;:!?()-]/u', '', $current_content);
340 $current_content = substr($current_content, 0, 50000);
341 } else if ($attempt === 2) {
342 // Second fallback: Keep only alphanumeric and basic punctuation, shorten further
343 $current_content = preg_replace('/[^a-zA-Z0-9\s.,;:!?()-]/u', '', $current_content);
344 $current_content = substr($current_content, 0, 30000);
345 }
346
347 $attempt++;
348 }
349 } catch (Exception $e) {
350 //error_log('[MXCHAT-DB] Exception during database operation: ' . $e->getMessage());
351 $attempt++;
352 }
353 }
354
355 if ($result === false) {
356 //error_log('[MXCHAT-DB] All database operation attempts failed');
357 //error_log('[MXCHAT-DB] Final MySQL Error: ' . $wpdb->last_error);
358
359 $detailed_error = sprintf(
360 'Failed to store content in WordPress database after %d attempts. MySQL Error: %s (Error #%d). Content size: %d bytes, Embedding size: %d bytes',
361 $max_attempts,
362 $wpdb->last_error,
363 $wpdb->last_errno,
364 strlen($current_content),
365 strlen($embedding_vector_serialized)
366 );
367
368 return new WP_Error('database_failed', $detailed_error);
369 }
370
371 //error_log('[MXCHAT-DB] WordPress database operation completed successfully (Attempt ' . ($attempt - 1) . ')');
372 return true;
373 }
374
375 /**
376 * UPDATED: Store content in Pinecone database with bot support
377 * UPDATED 2.5.6: Now accepts content_type parameter
378 */
379 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') {
380 //error_log('[MXCHAT-PINECONE-MAIN] ===== Starting Pinecone storage for bot ' . $bot_id . ' =====');
381
382 // ===== UPDATED: Handle manual content with unique vector IDs =====
383 if ($vector_id) {
384 // Use provided vector ID
385 //error_log('[MXCHAT-PINECONE-MAIN] Using provided vector ID: ' . $vector_id);
386 } elseif (!empty($url) && preg_match('#^https?://#i', $url)) {
387 // For URLs, use URL-based ID (existing behavior)
388 $vector_id = md5($url);
389 //error_log('[MXCHAT-PINECONE-MAIN] Generated vector ID from URL: ' . $vector_id);
390 } else {
391 // For manual content (empty/no URL scheme), generate unique ID
392 $vector_id = 'manual_' . time() . '_' . substr(md5($content . microtime(true)), 0, 8);
393 //error_log('[MXCHAT-PINECONE-MAIN] Generated unique vector ID for manual content: ' . $vector_id);
394 }
395 // ===== END UPDATE =====
396
397 // Get host from bot-specific config or fallback to default
398 if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
399 $options = get_option('mxchat_pinecone_addon_options');
400 $host = $options['mxchat_pinecone_host'] ?? '';
401 } else {
402 $bot_pinecone_config = apply_filters('mxchat_get_bot_pinecone_config', array(), $bot_id);
403 if (!empty($bot_pinecone_config)) {
404 $host = $bot_pinecone_config['host'] ?? '';
405 } else {
406 $options = get_option('mxchat_pinecone_addon_options');
407 $host = $options['mxchat_pinecone_host'] ?? '';
408 }
409 }
410
411 //error_log('[MXCHAT-PINECONE-MAIN] Host: ' . $host);
412 //error_log('[MXCHAT-PINECONE-MAIN] API key length: ' . strlen($api_key));
413 //error_log('[MXCHAT-PINECONE-MAIN] Bot ID: ' . $bot_id);
414 //error_log('[MXCHAT-PINECONE-MAIN] Namespace: ' . $namespace);
415
416 if (empty($host)) {
417 //error_log('[MXCHAT-PINECONE-MAIN] ERROR: Host is empty');
418 return new WP_Error('pinecone_config', 'Pinecone host is not configured. Please set the host in your bot settings.');
419 }
420
421 // ===== UPDATED 2.5.6: Use passed content_type or determine from URL if not provided =====
422 // Sanitize content_type
423 $content_type = sanitize_key($content_type);
424 if (empty($content_type)) {
425 // Fallback to old detection logic for backwards compatibility
426 $is_product = false;
427 $content_type = 'manual'; // Default for manual content
428
429 if (!empty($url) && preg_match('#^https?://#i', $url)) {
430 $is_product = (strpos($url, '/product/') !== false || strpos($url, '/shop/') !== false);
431 $content_type = $is_product ? 'product' : 'content';
432 }
433 }
434
435 //error_log('[MXCHAT-PINECONE-MAIN] Content type: ' . $content_type);
436 // ===== END UPDATE =====
437
438 $api_endpoint = "https://{$host}/vectors/upsert";
439 //error_log('[MXCHAT-PINECONE-MAIN] API endpoint: ' . $api_endpoint);
440
441 // UPDATED 2.5.6: Use provided content_type in metadata
442 $metadata = array(
443 'text' => $content,
444 'source_url' => $url, // Can be empty for manual content
445 'type' => $content_type, // Now supports: post, page, pdf, url, manual, product, etc.
446 'last_updated' => time(),
447 'created_at' => time(), // Add creation timestamp
448 'bot_id' => $bot_id, // Add bot identification
449 );
450
451 $vector_data = array(
452 'id' => $vector_id,
453 'values' => $embedding_vector,
454 'metadata' => $metadata
455 );
456
457 $request_body = array(
458 'vectors' => array($vector_data)
459 );
460
461 // Add namespace if specified for multi-bot separation
462 if (!empty($namespace)) {
463 $request_body['namespace'] = $namespace;
464 //error_log('[MXCHAT-PINECONE-MAIN] Using namespace: ' . $namespace);
465 }
466
467 //error_log('[MXCHAT-PINECONE-MAIN] Request body prepared (embedding dimensions: ' . count($embedding_vector) . ')');
468
469 $response = wp_remote_post($api_endpoint, array(
470 'headers' => array(
471 'Api-Key' => $api_key,
472 'accept' => 'application/json',
473 'content-type' => 'application/json'
474 ),
475 'body' => wp_json_encode($request_body),
476 'timeout' => 30,
477 'data_format' => 'body'
478 ));
479
480 if (is_wp_error($response)) {
481 //error_log('[MXCHAT-PINECONE-MAIN] WordPress request error: ' . $response->get_error_message());
482 return new WP_Error('pinecone_request', $response->get_error_message());
483 }
484
485 $response_code = wp_remote_retrieve_response_code($response);
486 //error_log('[MXCHAT-PINECONE-MAIN] Response code: ' . $response_code);
487
488 if ($response_code !== 200) {
489 $body = wp_remote_retrieve_body($response);
490 //error_log('[MXCHAT-PINECONE-MAIN] API error - Response body: ' . $body);
491 return new WP_Error('pinecone_api', sprintf(
492 'Pinecone API error (HTTP %d): %s',
493 $response_code,
494 $body
495 ));
496 }
497
498 $response_body = wp_remote_retrieve_body($response);
499 //error_log('[MXCHAT-PINECONE-MAIN] Success response: ' . $response_body);
500 //error_log('[MXCHAT-PINECONE-MAIN] Successfully stored in Pinecone for bot ' . $bot_id);
501 //error_log('[MXCHAT-PINECONE-MAIN] ===== Pinecone storage complete =====');
502
503 return true;
504 }
505
506 /**
507 * UPDATED: Generate an embedding for the given text using bot-specific configuration.
508 *
509 * @param string $text The text to be embedded.
510 * @param string $api_key The API key used for generating embeddings.
511 * @param string $bot_id The bot ID for multi-bot support
512 * @return array|null The embedding vector or null on failure.
513 */
514 private static function generate_embedding($text, $api_key, $bot_id = 'default') {
515 // Get bot-specific options
516 if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
517 $options = get_option('mxchat_options');
518 } else {
519 $bot_options = apply_filters('mxchat_get_bot_options', array(), $bot_id);
520 $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
521 }
522
523 // Opt-in: when the custom provider is selected for embeddings, route the KB
524 // INDEX side through the same custom endpoint the query side uses, so stored
525 // vectors and query vectors come from the same model. Default-off behavior
526 // below is untouched.
527 if (isset($options['custom_provider_for_embeddings']) && $options['custom_provider_for_embeddings'] === 'on') {
528 $custom = self::generate_embedding_custom($text, $options);
529 return is_array($custom) ? $custom : null;
530 }
531
532 $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
533
534 // Determine endpoint and API key based on model
535 if (strpos($selected_model, 'voyage') === 0) {
536 $endpoint = 'https://api.voyageai.com/v1/embeddings';
537 $api_key = $options['voyage_api_key'] ?? '';
538 } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
539 $endpoint = 'https://generativelanguage.googleapis.com/v1beta/models/' . $selected_model . ':embedContent';
540 $api_key = $options['gemini_api_key'] ?? '';
541 } else {
542 $endpoint = 'https://api.openai.com/v1/embeddings';
543 // Use the bot-specific API key or fallback to passed API key
544 $api_key = $options['api_key'] ?? $api_key;
545 }
546
547 // Prepare request body based on provider
548 if (strpos($selected_model, 'gemini-embedding') === 0) {
549 // Gemini API format
550 $request_body = [
551 'model' => 'models/' . $selected_model,
552 'content' => [
553 'parts' => [
554 ['text' => $text]
555 ]
556 ],
557 'outputDimensionality' => 1536
558 ];
559
560 // Prepare headers for Gemini (API key as query parameter)
561 $endpoint .= '?key=' . $api_key;
562 $headers = [
563 'Content-Type' => 'application/json'
564 ];
565 } else {
566 // OpenAI/Voyage API format
567 $request_body = [
568 'input' => $text,
569 'model' => $selected_model
570 ];
571
572 // Add output_dimension for voyage-3-large
573 if ($selected_model === 'voyage-3-large') {
574 $request_body['output_dimension'] = 2048;
575 }
576
577 // Prepare headers for OpenAI/Voyage
578 $headers = [
579 'Content-Type' => 'application/json',
580 'Authorization' => 'Bearer ' . $api_key
581 ];
582 }
583
584 $args = [
585 'body' => wp_json_encode($request_body),
586 'headers' => $headers,
587 'timeout' => 60,
588 'redirection' => 5,
589 'blocking' => true,
590 'httpversion' => '1.0',
591 'sslverify' => true,
592 ];
593
594 $response = wp_remote_post($endpoint, $args);
595
596 if (is_wp_error($response)) {
597 //error_log('Error generating embedding for bot ' . $bot_id . ': ' . $response->get_error_message());
598 return null;
599 }
600
601 $response_body = json_decode(wp_remote_retrieve_body($response), true);
602
603 // Handle different response formats based on provider
604 if (strpos($selected_model, 'gemini-embedding') === 0) {
605 // Gemini API response format
606 if (isset($response_body['embedding']['values']) && is_array($response_body['embedding']['values'])) {
607 self::stamp_active_embedding_model($selected_model);
608 return $response_body['embedding']['values'];
609 } else {
610 //error_log('Invalid response received from Gemini embedding API for bot ' . $bot_id . ': ' . wp_json_encode($response_body));
611 return null;
612 }
613 } else {
614 // OpenAI/Voyage API response format
615 if (isset($response_body['data'][0]['embedding']) && is_array($response_body['data'][0]['embedding'])) {
616 self::stamp_active_embedding_model($selected_model);
617 return $response_body['data'][0]['embedding'];
618 } else {
619 //error_log('Invalid response received from embedding API for bot ' . $bot_id . ': ' . wp_json_encode($response_body));
620 return null;
621 }
622 }
623 }
624
625 /**
626 * Generate an embedding via a Custom (OpenAI-compatible) provider's /embeddings route.
627 * Shared by every embedding entry point so the KNOWLEDGE-BASE INDEX side and the
628 * QUERY side route through the same model when the opt-in
629 * 'custom_provider_for_embeddings' setting is on. Mirrors the query-path logic in
630 * MxChat_Integrator::mxchat_generate_embedding_custom() but takes an explicit
631 * $options array so it is callable statically from utils + knowledge-manager.
632 *
633 * Returns a numeric array (the embedding vector) on success, or a human-readable
634 * error string on failure (so callers expecting a string error, like the
635 * knowledge-manager, can surface it directly; callers expecting array|null wrap it).
636 *
637 * @param string $text Text to embed.
638 * @param array $options The resolved mxchat options (must contain the custom_provider_* keys).
639 * @return array|string Embedding vector on success; error string on failure.
640 */
641 public static function generate_embedding_custom($text, $options) {
642 if (empty($text)) {
643 return 'No text provided for embedding generation';
644 }
645
646 $base_url = isset($options['custom_provider_base_url']) ? rtrim(trim((string) $options['custom_provider_base_url']), '/') : '';
647 if (empty($base_url)) {
648 return 'Custom provider Base URL is not configured.';
649 }
650
651 $api_key = isset($options['custom_provider_api_key']) ? trim((string) $options['custom_provider_api_key']) : '';
652 $auth_scheme = isset($options['custom_provider_auth_scheme']) ? $options['custom_provider_auth_scheme'] : 'bearer';
653 $api_version = isset($options['custom_provider_api_version']) ? trim((string) $options['custom_provider_api_version']) : '';
654
655 // Embedding model: prefer the dedicated custom_provider_embedding_model, fall back to the chat model.
656 $model = (isset($options['custom_provider_embedding_model']) && trim((string) $options['custom_provider_embedding_model']) !== '')
657 ? trim((string) $options['custom_provider_embedding_model'])
658 : ((isset($options['custom_provider_model']) && trim((string) $options['custom_provider_model']) !== '') ? trim((string) $options['custom_provider_model']) : 'default');
659
660 $embed_url = $base_url . '/embeddings';
661 if (!empty($api_version)) {
662 $embed_url .= (strpos($embed_url, '?') === false ? '?' : '&') . 'api-version=' . rawurlencode($api_version);
663 }
664
665 $headers = ['Content-Type' => 'application/json'];
666 if (!empty($api_key)) {
667 if ($auth_scheme === 'api-key') {
668 $headers['api-key'] = $api_key;
669 } else {
670 $headers['Authorization'] = 'Bearer ' . $api_key;
671 }
672 }
673
674 $response = wp_remote_post($embed_url, [
675 'headers' => $headers,
676 'body' => wp_json_encode(['input' => $text, 'model' => $model]),
677 'timeout' => 60,
678 ]);
679 if (is_wp_error($response)) {
680 return 'Connection error when generating embeddings (custom provider): ' . $response->get_error_message();
681 }
682
683 $status = wp_remote_retrieve_response_code($response);
684 $body = json_decode(wp_remote_retrieve_body($response), true);
685 if ($status !== 200) {
686 $msg = isset($body['error']['message']) ? $body['error']['message'] : 'HTTP ' . $status;
687 return 'Custom embedding endpoint error: ' . $msg;
688 }
689 if (isset($body['data'][0]['embedding']) && is_array($body['data'][0]['embedding'])) {
690 // Stamp the custom model identity so the active-embedding-model mismatch
691 // warning reflects the real (custom) model rather than the built-in setting.
692 self::stamp_active_embedding_model('custom:' . $model);
693 return $body['data'][0]['embedding'];
694 }
695 return 'Invalid embedding response from custom provider.';
696 }
697
698 /**
699 * Submit content as multiple chunks
700 *
701 * Splits large content into chunks, generates embeddings for each,
702 * and stores them with chunk metadata for later reassembly.
703 *
704 * @param string $content The content to chunk and store
705 * @param string $source_url The source URL
706 * @param string $api_key The API key for embeddings
707 * @param string $bot_id The bot ID
708 * @param string $content_type The content type
709 * @param MxChat_Chunker $chunker The chunker instance
710 * @return bool|WP_Error True on success, WP_Error on failure
711 */
712 private static function submit_chunked_content($content, $source_url, $api_key, $bot_id, $content_type, $chunker) {
713 global $wpdb;
714 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
715
716 //error_log('[MXCHAT-CHUNK-DEBUG] Starting chunked submission for: ' . $source_url);
717 //error_log('[MXCHAT-CHUNK-DEBUG] Content length: ' . strlen($content) . ' chars');
718
719 // First, delete any existing chunks for this URL (clean slate)
720 $delete_result = self::delete_chunks_for_url($source_url, $bot_id);
721 if (is_wp_error($delete_result)) {
722 //error_log('[MXCHAT-CHUNK-DEBUG] Warning: Failed to delete existing chunks: ' . $delete_result->get_error_message());
723 // Continue anyway - we'll overwrite with upsert
724 }
725
726 // Split content into chunks
727 $chunks = $chunker->chunk_text($content);
728 $total_chunks = count($chunks);
729
730 //error_log('[MXCHAT-CHUNK-DEBUG] Created ' . $total_chunks . ' chunks');
731 foreach ($chunks as $i => $chunk) {
732 //error_log('[MXCHAT-CHUNK-DEBUG] Chunk ' . $i . ' length: ' . strlen($chunk) . ' chars, preview: ' . substr($chunk, 0, 100));
733 }
734
735 //error_log('[MXCHAT-CHUNK] Split content into ' . $total_chunks . ' chunks');
736
737 if ($total_chunks === 0) {
738 return new WP_Error('chunking_failed', 'Content could not be split into chunks');
739 }
740
741 $errors = array();
742 $is_pinecone = self::is_pinecone_enabled_for_bot($bot_id);
743
744 foreach ($chunks as $index => $chunk_text) {
745 // Generate chunk metadata
746 $chunk_metadata = MxChat_Chunker::create_chunk_metadata($index, $total_chunks, $source_url);
747
748 // AI-Engine-style aliases so external consumers (Pinecone/Qdrant/Chroma) can rely on
749 // a stable shorthand ('source'/'part_index'/'part_total') without parsing our internal names.
750 $chunk_metadata['source'] = $source_url;
751 $chunk_metadata['part_index'] = (int) $index;
752 $chunk_metadata['part_total'] = (int) $total_chunks;
753
754 /**
755 * Filter the per-chunk metadata blob before it's written to the KB store.
756 *
757 * @param array $chunk_metadata Metadata array (source, part_index, part_total, chunk_index, total_chunks, source_url, parent_url_hash, document_type, ...).
758 * @param string $chunk_text The chunk text being stored.
759 * @param array $context ['bot_id' => string, 'content_type' => string, 'source_url' => string, 'part_index' => int, 'part_total' => int]
760 * @return array Updated metadata array.
761 */
762 $chunk_metadata = apply_filters(
763 'mxchat_embedding_chunk_metadata',
764 $chunk_metadata,
765 $chunk_text,
766 array(
767 'bot_id' => $bot_id,
768 'content_type' => $content_type,
769 'source_url' => $source_url,
770 'part_index' => (int) $index,
771 'part_total' => (int) $total_chunks,
772 )
773 );
774
775 $chunk_vector_id = MxChat_Chunker::generate_chunk_vector_id($source_url, $index);
776
777 //error_log('[MXCHAT-CHUNK] Processing chunk ' . ($index + 1) . '/' . $total_chunks . ' (ID: ' . $chunk_vector_id . ')');
778
779 // Generate embedding for this chunk
780 $embedding_vector = self::generate_embedding($chunk_text, $api_key, $bot_id);
781
782 if (!is_array($embedding_vector)) {
783 $errors[] = new WP_Error('embedding_failed', 'Failed to generate embedding for chunk ' . $index);
784 //error_log('[MXCHAT-CHUNK] Failed to generate embedding for chunk ' . $index);
785 continue;
786 }
787
788 if ($is_pinecone) {
789 // Store in Pinecone with chunk metadata
790 $result = self::store_chunk_in_pinecone(
791 $embedding_vector,
792 $chunk_text,
793 $source_url,
794 $chunk_vector_id,
795 $bot_id,
796 $content_type,
797 $chunk_metadata
798 );
799 } else {
800 // Store in WordPress DB with chunk metadata
801 $content_with_metadata = MxChat_Chunker::format_chunk_for_storage($chunk_text, $chunk_metadata);
802 $embedding_vector_serialized = maybe_serialize($embedding_vector);
803
804 $result = self::store_chunk_in_wordpress_db(
805 $content_with_metadata,
806 $source_url,
807 $embedding_vector_serialized,
808 $table_name,
809 $content_type,
810 $chunk_metadata
811 );
812 }
813
814 if (is_wp_error($result)) {
815 $errors[] = $result;
816 //error_log('[MXCHAT-CHUNK] Failed to store chunk ' . $index . ': ' . $result->get_error_message());
817 }
818 }
819
820 if (count($errors) === $total_chunks) {
821 return new WP_Error('chunking_failed', 'Failed to store any chunks');
822 }
823
824 if (!empty($errors)) {
825 //error_log('[MXCHAT-CHUNK] Completed with ' . count($errors) . ' errors out of ' . $total_chunks . ' chunks');
826 return new WP_Error('chunking_partial_failure',
827 sprintf('Failed to store %d of %d chunks', count($errors), $total_chunks));
828 }
829
830 //error_log('[MXCHAT-CHUNK] Successfully stored all ' . $total_chunks . ' chunks');
831 return true;
832 }
833
834 /**
835 * Store a single chunk in Pinecone with chunk-specific metadata
836 */
837 private static function store_chunk_in_pinecone($embedding_vector, $chunk_text, $source_url, $vector_id, $bot_id, $content_type, $chunk_metadata) {
838 // Get Pinecone configuration
839 if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
840 $pinecone_options = get_option('mxchat_pinecone_addon_options');
841 $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
842 $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
843 $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
844 } else {
845 $bot_pinecone_config = apply_filters('mxchat_get_bot_pinecone_config', array(), $bot_id);
846 if (empty($bot_pinecone_config)) {
847 $pinecone_options = get_option('mxchat_pinecone_addon_options');
848 $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
849 $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
850 $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
851 } else {
852 $api_key = $bot_pinecone_config['api_key'] ?? '';
853 $host = $bot_pinecone_config['host'] ?? '';
854 $namespace = $bot_pinecone_config['namespace'] ?? '';
855 }
856 }
857
858 if (empty($host) || empty($api_key)) {
859 return new WP_Error('pinecone_config', 'Pinecone is not properly configured');
860 }
861
862 $api_endpoint = "https://{$host}/vectors/upsert";
863
864 // Build metadata with chunk information
865 $metadata = array(
866 'text' => $chunk_text,
867 'source_url' => $source_url,
868 'type' => $content_type,
869 'is_chunked' => true,
870 'chunk_index' => $chunk_metadata['chunk_index'],
871 'total_chunks' => $chunk_metadata['total_chunks'],
872 'parent_url_hash' => $chunk_metadata['parent_url_hash'],
873 'last_updated' => time(),
874 'created_at' => time(),
875 'bot_id' => $bot_id,
876 );
877
878 $vector_data = array(
879 'id' => $vector_id,
880 'values' => $embedding_vector,
881 'metadata' => $metadata
882 );
883
884 $request_body = array(
885 'vectors' => array($vector_data)
886 );
887
888 if (!empty($namespace)) {
889 $request_body['namespace'] = $namespace;
890 }
891
892 $response = wp_remote_post($api_endpoint, array(
893 'headers' => array(
894 'Api-Key' => $api_key,
895 'accept' => 'application/json',
896 'content-type' => 'application/json'
897 ),
898 'body' => wp_json_encode($request_body),
899 'timeout' => 30
900 ));
901
902 if (is_wp_error($response)) {
903 return $response;
904 }
905
906 $response_code = wp_remote_retrieve_response_code($response);
907 if ($response_code !== 200) {
908 return new WP_Error('pinecone_api', 'Pinecone API error: HTTP ' . $response_code);
909 }
910
911 return true;
912 }
913
914 /**
915 * Store a single chunk in WordPress database
916 */
917 private static function store_chunk_in_wordpress_db($content_with_metadata, $source_url, $embedding_vector_serialized, $table_name, $content_type, $chunk_metadata) {
918 global $wpdb;
919
920 // For chunks, we always insert new rows (no duplicate checking)
921 // The URL includes chunk info in the metadata, but source_url stays the same for grouping
922 $result = $wpdb->insert(
923 $table_name,
924 array(
925 'url' => $source_url,
926 'article_content' => $content_with_metadata,
927 'embedding_vector' => $embedding_vector_serialized,
928 'source_url' => $source_url,
929 'content_type' => $content_type,
930 'timestamp' => current_time('mysql')
931 ),
932 array('%s', '%s', '%s', '%s', '%s', '%s')
933 );
934
935 if ($result === false) {
936 return new WP_Error('database_failed', 'Failed to insert chunk: ' . $wpdb->last_error);
937 }
938
939 return true;
940 }
941
942 /**
943 * Delete all chunks for a given URL
944 *
945 * @param string $source_url The source URL
946 * @param string $bot_id The bot ID
947 * @return bool|WP_Error True on success, WP_Error on failure
948 */
949 public static function delete_chunks_for_url($source_url, $bot_id = 'default') {
950 //error_log('[MXCHAT-CHUNK-DELETE] Deleting chunks for URL: ' . $source_url);
951
952 if (self::is_pinecone_enabled_for_bot($bot_id)) {
953 return self::delete_pinecone_chunks_by_url($source_url, $bot_id);
954 } else {
955 return self::delete_wordpress_chunks_by_url($source_url);
956 }
957 }
958
959 /**
960 * Delete all chunks for a URL from Pinecone
961 */
962 private static function delete_pinecone_chunks_by_url($source_url, $bot_id) {
963 // Get Pinecone configuration
964 if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
965 $pinecone_options = get_option('mxchat_pinecone_addon_options');
966 $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
967 $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
968 $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
969 } else {
970 $bot_pinecone_config = apply_filters('mxchat_get_bot_pinecone_config', array(), $bot_id);
971 if (empty($bot_pinecone_config)) {
972 $pinecone_options = get_option('mxchat_pinecone_addon_options');
973 $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
974 $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
975 $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
976 } else {
977 $api_key = $bot_pinecone_config['api_key'] ?? '';
978 $host = $bot_pinecone_config['host'] ?? '';
979 $namespace = $bot_pinecone_config['namespace'] ?? '';
980 }
981 }
982
983 if (empty($host) || empty($api_key)) {
984 return new WP_Error('pinecone_config', 'Pinecone is not properly configured');
985 }
986
987 $base_vector_id = md5($source_url);
988 $vectors_to_delete = array();
989
990 // Add the original single-vector ID (for non-chunked content)
991 $vectors_to_delete[] = $base_vector_id;
992
993 // Pinecone /vectors/list is a GET endpoint with query-string parameters; a POST here returns a
994 // non-200 silently and we end up only deleting the base vector, leaving chunks orphaned.
995 $query_params = array(
996 'prefix' => $base_vector_id . '_chunk_',
997 'limit' => 100,
998 );
999 if (!empty($namespace)) {
1000 $query_params['namespace'] = $namespace;
1001 }
1002
1003 $list_url = "https://{$host}/vectors/list?" . http_build_query($query_params);
1004
1005 // Paginate in case a URL has more than 100 chunks.
1006 do {
1007 $list_response = wp_remote_get($list_url, array(
1008 'headers' => array(
1009 'Api-Key' => $api_key,
1010 'accept' => 'application/json',
1011 ),
1012 'timeout' => 30,
1013 ));
1014
1015 if (is_wp_error($list_response) || wp_remote_retrieve_response_code($list_response) !== 200) {
1016 break;
1017 }
1018
1019 $list_data = json_decode(wp_remote_retrieve_body($list_response), true);
1020 if (!empty($list_data['vectors'])) {
1021 foreach ($list_data['vectors'] as $vector) {
1022 if (isset($vector['id'])) {
1023 $vectors_to_delete[] = $vector['id'];
1024 }
1025 }
1026 }
1027
1028 $next_token = $list_data['pagination']['next'] ?? '';
1029 if (empty($next_token)) {
1030 break;
1031 }
1032
1033 $query_params['paginationToken'] = $next_token;
1034 $list_url = "https://{$host}/vectors/list?" . http_build_query($query_params);
1035 } while (true);
1036
1037 if (empty($vectors_to_delete)) {
1038 //error_log('[MXCHAT-CHUNK-DELETE] No vectors found to delete');
1039 return true;
1040 }
1041
1042 //error_log('[MXCHAT-CHUNK-DELETE] Deleting ' . count($vectors_to_delete) . ' vectors from Pinecone');
1043
1044 // Delete vectors
1045 $delete_url = "https://{$host}/vectors/delete";
1046
1047 $delete_body = array(
1048 'ids' => $vectors_to_delete
1049 );
1050
1051 if (!empty($namespace)) {
1052 $delete_body['namespace'] = $namespace;
1053 }
1054
1055 $delete_response = wp_remote_post($delete_url, array(
1056 'headers' => array(
1057 'Api-Key' => $api_key,
1058 'accept' => 'application/json',
1059 'content-type' => 'application/json'
1060 ),
1061 'body' => wp_json_encode($delete_body),
1062 'timeout' => 30
1063 ));
1064
1065 if (is_wp_error($delete_response)) {
1066 return $delete_response;
1067 }
1068
1069 $response_code = wp_remote_retrieve_response_code($delete_response);
1070 if ($response_code !== 200) {
1071 return new WP_Error('pinecone_delete', 'Failed to delete vectors: HTTP ' . $response_code);
1072 }
1073
1074 return true;
1075 }
1076
1077 /**
1078 * Delete all chunks for a URL from WordPress database
1079 */
1080 private static function delete_wordpress_chunks_by_url($source_url) {
1081 global $wpdb;
1082 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
1083
1084 // Delete all rows with this source_url (handles both chunked and non-chunked)
1085 $result = $wpdb->delete(
1086 $table_name,
1087 array('source_url' => $source_url),
1088 array('%s')
1089 );
1090
1091 if ($result === false) {
1092 return new WP_Error('database_delete', 'Failed to delete chunks: ' . $wpdb->last_error);
1093 }
1094
1095 //error_log('[MXCHAT-CHUNK-DELETE] Deleted ' . $result . ' rows from WordPress DB');
1096 return true;
1097 }
1098 }