PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.1.4
MxChat – AI Chatbot & Content Generation for WordPress v3.1.4
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
← All changes | includes/class-mxchat-utils.php +27 -229 3.2.143.1.4 View file →
@@ -5,81 +5,8 @@
5 5
6 6 class MxChat_Utils {
7 7
8 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 9 * UPDATED: Submit or update content (and its embedding) in the database.
83 10 * Stores in Pinecone if enabled, otherwise stores in WordPress DB.
84 11 *
85 12 * @param string $content The content to be embedded.
@@ -250,15 +177,9 @@
250 177 }
251 178
252 179 // ===== FIXED: Generate unique identifier for manual content =====
253 180 $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;
181 + $is_manual_content = empty($source_url) || $source_url === '' || !filter_var($source_url, FILTER_VALIDATE_URL);
261 182
262 183 if ($is_manual_content) {
263 184 // Generate unique identifier for manual content to prevent overwrites
264 185 $source_url = 'mxchat://manual-content/' . time() . '-' . wp_generate_password(8, false);
@@ -382,14 +303,14 @@
382 303 // ===== UPDATED: Handle manual content with unique vector IDs =====
383 304 if ($vector_id) {
384 305 // Use provided vector ID
385 306 //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)
307 + } elseif (!empty($url) && filter_var($url, FILTER_VALIDATE_URL)) {
308 + // For valid URLs, use URL-based ID (existing behavior)
388 309 $vector_id = md5($url);
389 310 //error_log('[MXCHAT-PINECONE-MAIN] Generated vector ID from URL: ' . $vector_id);
390 311 } else {
391 - // For manual content (empty/no URL scheme), generate unique ID
312 + // For manual content (empty/invalid URL), generate unique ID
392 313 $vector_id = 'manual_' . time() . '_' . substr(md5($content . microtime(true)), 0, 8);
393 314 //error_log('[MXCHAT-PINECONE-MAIN] Generated unique vector ID for manual content: ' . $vector_id);
394 315 }
395 316 // ===== END UPDATE =====
@@ -425,9 +346,9 @@
425 346 // Fallback to old detection logic for backwards compatibility
426 347 $is_product = false;
427 348 $content_type = 'manual'; // Default for manual content
428 349
429 - if (!empty($url) && preg_match('#^https?://#i', $url)) {
350 + if (!empty($url) && filter_var($url, FILTER_VALIDATE_URL)) {
430 351 $is_product = (strpos($url, '/product/') !== false || strpos($url, '/shop/') !== false);
431 352 $content_type = $is_product ? 'product' : 'content';
432 353 }
433 354 }
@@ -444,9 +365,9 @@
444 365 'source_url' => $url, // Can be empty for manual content
445 366 'type' => $content_type, // Now supports: post, page, pdf, url, manual, product, etc.
446 367 'last_updated' => time(),
447 368 'created_at' => time(), // Add creation timestamp
448 - 'bot_id' => $bot_id, // Add bot identification
369 + 'bot_id' => $bot_id // Add bot identification
449 370 );
450 371
451 372 $vector_data = array(
452 373 'id' => $vector_id,
@@ -518,20 +439,11 @@
518 439 } else {
519 440 $bot_options = apply_filters('mxchat_get_bot_options', array(), $bot_id);
520 441 $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
521 442 }
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 -
443 +
532 444 $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
533 -
445 +
534 446 // Determine endpoint and API key based on model
535 447 if (strpos($selected_model, 'voyage') === 0) {
536 448 $endpoint = 'https://api.voyageai.com/v1/embeddings';
537 449 $api_key = $options['voyage_api_key'] ?? '';
@@ -603,9 +515,8 @@
603 515 // Handle different response formats based on provider
604 516 if (strpos($selected_model, 'gemini-embedding') === 0) {
605 517 // Gemini API response format
606 518 if (isset($response_body['embedding']['values']) && is_array($response_body['embedding']['values'])) {
607 - self::stamp_active_embedding_model($selected_model);
608 519 return $response_body['embedding']['values'];
609 520 } else {
610 521 //error_log('Invalid response received from Gemini embedding API for bot ' . $bot_id . ': ' . wp_json_encode($response_body));
611 522 return null;
@@ -612,9 +523,8 @@
612 523 }
613 524 } else {
614 525 // OpenAI/Voyage API response format
615 526 if (isset($response_body['data'][0]['embedding']) && is_array($response_body['data'][0]['embedding'])) {
616 - self::stamp_active_embedding_model($selected_model);
617 527 return $response_body['data'][0]['embedding'];
618 528 } else {
619 529 //error_log('Invalid response received from embedding API for bot ' . $bot_id . ': ' . wp_json_encode($response_body));
620 530 return null;
@@ -622,81 +532,8 @@
622 532 }
623 533 }
624 534
625 535 /**
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 536 * Submit content as multiple chunks
700 537 *
701 538 * Splits large content into chunks, generates embeddings for each,
702 539 * and stores them with chunk metadata for later reassembly.
@@ -743,36 +580,8 @@
743 580
744 581 foreach ($chunks as $index => $chunk_text) {
745 582 // Generate chunk metadata
746 583 $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 584 $chunk_vector_id = MxChat_Chunker::generate_chunk_vector_id($source_url, $index);
776 585
777 586 //error_log('[MXCHAT-CHUNK] Processing chunk ' . ($index + 1) . '/' . $total_chunks . ' (ID: ' . $chunk_vector_id . ')');
778 587
@@ -871,9 +680,9 @@
871 680 'total_chunks' => $chunk_metadata['total_chunks'],
872 681 'parent_url_hash' => $chunk_metadata['parent_url_hash'],
873 682 'last_updated' => time(),
874 683 'created_at' => time(),
875 - 'bot_id' => $bot_id,
684 + 'bot_id' => $bot_id
876 685 );
877 686
878 687 $vector_data = array(
879 688 'id' => $vector_id,
@@ -989,34 +798,31 @@
989 798
990 799 // Add the original single-vector ID (for non-chunked content)
991 800 $vectors_to_delete[] = $base_vector_id;
992 801
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(
802 + // Use Pinecone list API to find all chunk vectors with this prefix
803 + $list_url = "https://{$host}/vectors/list";
804 +
805 + $list_body = array(
996 806 'prefix' => $base_vector_id . '_chunk_',
997 - 'limit' => 100,
807 + 'limit' => 100
998 808 );
809 +
999 810 if (!empty($namespace)) {
1000 - $query_params['namespace'] = $namespace;
811 + $list_body['namespace'] = $namespace;
1001 812 }
1002 813
1003 - $list_url = "https://{$host}/vectors/list?" . http_build_query($query_params);
814 + $list_response = wp_remote_post($list_url, array(
815 + 'headers' => array(
816 + 'Api-Key' => $api_key,
817 + 'accept' => 'application/json',
818 + 'content-type' => 'application/json'
819 + ),
820 + 'body' => wp_json_encode($list_body),
821 + 'timeout' => 30
822 + ));
1004 823
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 -
824 + if (!is_wp_error($list_response)) {
1019 825 $list_data = json_decode(wp_remote_retrieve_body($list_response), true);
1020 826 if (!empty($list_data['vectors'])) {
1021 827 foreach ($list_data['vectors'] as $vector) {
1022 828 if (isset($vector['id'])) {
@@ -1023,17 +829,9 @@
1023 829 $vectors_to_delete[] = $vector['id'];
1024 830 }
1025 831 }
1026 832 }
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);
833 + }
1036 834
1037 835 if (empty($vectors_to_delete)) {
1038 836 //error_log('[MXCHAT-CHUNK-DELETE] No vectors found to delete');
1039 837 return true;