PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.1.8
MxChat – AI Chatbot & Content Generation for WordPress v3.1.8
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 +38 -505 3.2.173.1.8 View file →
@@ -5,153 +5,8 @@
5 5
6 6 class MxChat_Utils {
7 7
8 8 /**
9 - * Validate a client-supplied session id (plan-mxchat-20260731-d42bec).
10 - *
11 - * sanitize_text_field() — which every session_id read site used before this —
12 - * preserves '/' and '..'. Harmless where the value is only an option or
13 - * transient key suffix, but mxchat_send_delayed_transcript() interpolates it
14 - * into a filesystem path, so '../../../../path/x' wrote, emailed and deleted a
15 - * file outside the uploads dir.
16 - *
17 - * REJECTS rather than rewrites: a silently-stripped id would orphan the
18 - * conversation it belongs to, which is harder to diagnose than a clean refusal.
19 - * Returns '' for anything malformed, so call sites fall into the empty-session
20 - * error paths they already have.
21 - *
22 - * The generator only ever emits 'mxchat_chat_' + 32 hex chars
23 - * (class-mxchat-integrator.php, js/chat-script.js), so this is not restrictive
24 - * in practice. Length ceiling is deliberate — session ids are also used as
25 - * option-name suffixes, and WP option names cap at 191 chars.
26 - *
27 - * @param mixed $raw Raw request value.
28 - * @return string The id if well-formed, '' otherwise.
29 - */
30 -public static function sanitize_session_id($raw) {
31 - if (!is_scalar($raw)) {
32 - return '';
33 - }
34 - $val = trim((string) $raw);
35 - if ($val === '') {
36 - return '';
37 - }
38 - return preg_match('/\A[A-Za-z0-9_-]{1,128}\z/', $val) ? $val : '';
39 -}
40 -
41 -/**
42 - * Centralized embedding model registry. Single source of truth for dimensions
43 - * and provider, so model-switch protection logic doesn't drift across files.
44 - */
45 -public static function embedding_model_registry() {
46 - return array(
47 - 'text-embedding-ada-002' => array('dims' => 1536, 'provider' => 'openai', 'label' => 'Ada 2'),
48 - 'text-embedding-3-small' => array('dims' => 1536, 'provider' => 'openai', 'label' => 'TE3 Small'),
49 - 'text-embedding-3-large' => array('dims' => 3072, 'provider' => 'openai', 'label' => 'TE3 Large'),
50 - 'voyage-3-large' => array('dims' => 2048, 'provider' => 'voyage', 'label' => 'Voyage-3 Large'),
51 - 'gemini-embedding-001' => array('dims' => 1536, 'provider' => 'gemini', 'label' => 'Gemini Embedding'),
52 - );
53 -}
54 -
55 -public static function embedding_model_dimensions($model) {
56 - $registry = self::embedding_model_registry();
57 - return isset($registry[$model]) ? (int) $registry[$model]['dims'] : 0;
58 -}
59 -
60 -public static function embedding_model_label($model) {
61 - if (is_string($model) && strpos($model, 'custom:') === 0) {
62 - /* translators: %s: the embedding model name configured on the custom provider */
63 - return sprintf(__('%s (custom provider)', 'mxchat'), substr($model, 7));
64 - }
65 - $registry = self::embedding_model_registry();
66 - return isset($registry[$model]) ? $registry[$model]['label'] : $model;
67 -}
68 -
69 -/**
70 - * Returns the model that was last used to actually write embeddings into the
71 - * KB. Differs from the user-selected setting once a switch has happened but
72 - * no re-embed has occurred yet — that's the mismatch state we warn about.
73 - */
74 -public static function get_active_embedding_model() {
75 - return get_option('mxchat_active_embedding_model', '');
76 -}
77 -
78 -/**
79 - * Stamp the model that produced the most recent successful embedding. Called
80 - * from generate_embedding() right after the API responds with a valid vector.
81 - */
82 -public static function stamp_active_embedding_model($model) {
83 - if (!empty($model) && $model !== self::get_active_embedding_model()) {
84 - update_option('mxchat_active_embedding_model', $model, false);
85 - }
86 -}
87 -
88 -/**
89 - * The model name the custom-provider embedding path will send, mirroring the
90 - * fallback chain the request itself uses: dedicated custom embedding model,
91 - * else the custom chat model, else 'default'. Single source shared by
92 - * generate_embedding_custom() and the mismatch-warning "selected" side so the
93 - * two can never drift (plan ae02cb).
94 - */
95 -public static function resolve_custom_embedding_model($options) {
96 - if (isset($options['custom_provider_embedding_model']) && trim((string) $options['custom_provider_embedding_model']) !== '') {
97 - return trim((string) $options['custom_provider_embedding_model']);
98 - }
99 - if (isset($options['custom_provider_model']) && trim((string) $options['custom_provider_model']) !== '') {
100 - return trim((string) $options['custom_provider_model']);
101 - }
102 - return 'default';
103 -}
104 -
105 -/**
106 - * The EFFECTIVE selected embedding model — what the next embed will actually
107 - * use. With custom-provider embeddings on this is the custom identity in the
108 - * same 'custom:<model>' form stamp_active_embedding_model() records, not the
109 - * inert standard dropdown value. Mismatch-warning comparisons must read this,
110 - * never $options['embedding_model'] directly — the dropdown cannot be
111 - * deselected, so reading it raw flags every correctly-configured custom setup.
112 - */
113 -public static function get_selected_embedding_model($options = null) {
114 - if (!is_array($options)) {
115 - $options = get_option('mxchat_options', array());
116 - }
117 - if (isset($options['custom_provider_for_embeddings']) && $options['custom_provider_for_embeddings'] === 'on') {
118 - return 'custom:' . self::resolve_custom_embedding_model($options);
119 - }
120 - return $options['embedding_model'] ?? '';
121 -}
122 -
123 -/**
124 - * Extract the 11-character YouTube video ID from a URL, or '' if the URL is
125 - * not a single-video YouTube link. Single source of truth for both the KB
126 - * ingestion side and the chat render side — do not duplicate this parsing.
127 - * Channel, playlist, and search URLs deliberately return '' (only a URL that
128 - * identifies one video can be embedded).
129 - */
130 -public static function parse_youtube_id($url) {
131 - if (!is_string($url) || $url === '' || !preg_match('#^https?://#i', $url)) {
132 - return '';
133 - }
134 - $host = strtolower((string) wp_parse_url($url, PHP_URL_HOST));
135 - $host = preg_replace('/^(www|m)\./', '', $host);
136 - $path = (string) wp_parse_url($url, PHP_URL_PATH);
137 - $id = '';
138 - if ($host === 'youtu.be') {
139 - $segments = explode('/', ltrim($path, '/'));
140 - $id = $segments[0] ?? '';
141 - } elseif (in_array($host, array('youtube.com', 'youtube-nocookie.com'), true)) {
142 - if (preg_match('#^/(?:shorts|embed|live|v)/([A-Za-z0-9_-]+)#', $path, $m)) {
143 - $id = $m[1];
144 - } elseif ($path === '/watch') {
145 - parse_str((string) wp_parse_url($url, PHP_URL_QUERY), $query_vars);
146 - $id = isset($query_vars['v']) ? (string) $query_vars['v'] : '';
147 - }
148 - }
149 - $id = preg_replace('/[^A-Za-z0-9_-]/', '', (string) $id);
150 - return (strlen($id) === 11) ? $id : '';
151 -}
152 -
153 -/**
154 9 * UPDATED: Submit or update content (and its embedding) in the database.
155 10 * Stores in Pinecone if enabled, otherwise stores in WordPress DB.
156 11 *
157 12 * @param string $content The content to be embedded.
@@ -193,13 +48,10 @@
193 48 // UPDATED: Generate the embedding using bot-specific configuration
194 49 $embedding_vector = self::generate_embedding($content, $api_key, $bot_id);
195 50
196 51 if (!is_array($embedding_vector)) {
197 - // Surface the provider's real reason instead of a fixed string (4a7c0a).
198 - $reason = is_wp_error($embedding_vector)
199 - ? $embedding_vector->get_error_message()
200 - : 'Failed to generate embedding for content';
201 - return new WP_Error('embedding_failed', $reason);
52 + //error_log('[MXCHAT-DB] Error: Embedding generation failed');
53 + return new WP_Error('embedding_failed', 'Failed to generate embedding for content');
202 54 }
203 55
204 56 //error_log('[MXCHAT-DB] Embedding generated successfully');
205 57
@@ -519,9 +371,9 @@
519 371 'source_url' => $url, // Can be empty for manual content
520 372 'type' => $content_type, // Now supports: post, page, pdf, url, manual, product, etc.
521 373 'last_updated' => time(),
522 374 'created_at' => time(), // Add creation timestamp
523 - 'bot_id' => $bot_id, // Add bot identification
375 + 'bot_id' => $bot_id // Add bot identification
524 376 );
525 377
526 378 $vector_data = array(
527 379 'id' => $vector_id,
@@ -578,87 +430,8 @@
578 430 return true;
579 431 }
580 432
581 433 /**
582 - * Caller-side pre-flight for KB ingestion: can an embedding request be made
583 - * with these options, and which API key should travel downstream?
584 - *
585 - * Custom-provider-aware — generate_embedding() below routes to the custom
586 - * endpoint FIRST and ignores the passed cloud key entirely when
587 - * custom_provider_for_embeddings is on, so on that branch the only real
588 - * requirement is a Base URL. Ingestion callers that gated on a cloud API key
589 - * were killing keyless custom-embeddings sites (local Ollama / LM Studio
590 - * class) before the embed layer could route (plan cbd5fd).
591 - *
592 - * NOTE: reads $options['embedding_model'] raw on purpose — this mirrors
593 - * generate_embedding()'s own routing read, NOT the mismatch-banner's
594 - * "selected" chain (get_selected_embedding_model). The helper must predict
595 - * what the very next embed call will do, byte-for-byte.
596 - *
597 - * Decision only — callers keep their own error-surfacing shape (admin-notice
598 - * transient + redirect, wp_send_json_error, WP_Error, silent return).
599 - *
600 - * @param array|null $options Resolved options (bot-specific where the caller
601 - * has them); null loads the default bot's options.
602 - * @return array {
603 - * @type bool $ok Whether ingestion can proceed.
604 - * @type string $api_key Key to pass downstream ('' on the custom branch —
605 - * generate_embedding() ignores it there).
606 - * @type string $reason Human-readable blocker; '' when $ok.
607 - * @type string $provider Short provider label ('OpenAI', 'Voyage AI',
608 - * 'Google Gemini', 'Custom Provider').
609 - * }
610 - */
611 -public static function embedding_preflight($options = null) {
612 - if (!is_array($options)) {
613 - $options = get_option('mxchat_options');
614 - $options = is_array($options) ? $options : array();
615 - }
616 -
617 - // Custom branch mirrors generate_embedding()'s routing order (custom first).
618 - if (isset($options['custom_provider_for_embeddings']) && $options['custom_provider_for_embeddings'] === 'on') {
619 - $base_url = isset($options['custom_provider_base_url']) ? rtrim(trim((string) $options['custom_provider_base_url']), '/') : '';
620 - if ($base_url === '') {
621 - return array(
622 - 'ok' => false,
623 - 'api_key' => '',
624 - // Same string generate_embedding_custom() returns for this state.
625 - 'reason' => __('Custom provider Base URL is not configured.', 'mxchat'),
626 - 'provider' => 'Custom Provider',
627 - );
628 - }
629 - return array('ok' => true, 'api_key' => '', 'reason' => '', 'provider' => 'Custom Provider');
630 - }
631 -
632 - $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
633 - if (strpos($selected_model, 'voyage') === 0) {
634 - $api_key = $options['voyage_api_key'] ?? '';
635 - $provider = 'Voyage AI';
636 - } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
637 - $api_key = $options['gemini_api_key'] ?? '';
638 - $provider = 'Google Gemini';
639 - } else {
640 - $api_key = $options['api_key'] ?? '';
641 - $provider = 'OpenAI';
642 - }
643 -
644 - if (empty($api_key)) {
645 - return array(
646 - 'ok' => false,
647 - 'api_key' => '',
648 - 'reason' => sprintf(
649 - /* translators: %s: embedding provider name */
650 - __('%s API key is not configured. Please add your API key in the settings before submitting content.', 'mxchat'),
651 - $provider
652 - ),
653 - 'provider' => $provider,
654 - );
655 - }
656 -
657 - return array('ok' => true, 'api_key' => $api_key, 'reason' => '', 'provider' => $provider);
658 -}
659 -
660 -/**
661 434 * UPDATED: Generate an embedding for the given text using bot-specific configuration.
662 435 *
663 436 * @param string $text The text to be embedded.
664 437 * @param string $api_key The API key used for generating embeddings.
@@ -672,22 +445,11 @@
672 445 } else {
673 446 $bot_options = apply_filters('mxchat_get_bot_options', array(), $bot_id);
674 447 $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
675 448 }
676 -
677 - // Opt-in: when the custom provider is selected for embeddings, route the KB
678 - // INDEX side through the same custom endpoint the query side uses, so stored
679 - // vectors and query vectors come from the same model. Default-off behavior
680 - // below is untouched.
681 - if (isset($options['custom_provider_for_embeddings']) && $options['custom_provider_for_embeddings'] === 'on') {
682 - $custom = self::generate_embedding_custom($text, $options);
683 - // The custom path already returns a human-readable error string —
684 - // carry it instead of collapsing to null (plan 4a7c0a).
685 - return is_array($custom) ? $custom : new WP_Error('embedding_failed', (string) $custom);
686 - }
687 -
449 +
688 450 $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
689 -
451 +
690 452 // Determine endpoint and API key based on model
691 453 if (strpos($selected_model, 'voyage') === 0) {
692 454 $endpoint = 'https://api.voyageai.com/v1/embeddings';
693 455 $api_key = $options['voyage_api_key'] ?? '';
@@ -749,155 +511,35 @@
749 511
750 512 $response = wp_remote_post($endpoint, $args);
751 513
752 514 if (is_wp_error($response)) {
753 - $message = 'Embedding request failed (connection): ' . $response->get_error_message();
754 - if (class_exists('MxChat_Admin')) {
755 - MxChat_Admin::mxchat_log_debug('embedding_error', $message, array('model' => $selected_model, 'bot_id' => $bot_id));
756 - }
757 - return new WP_Error('embedding_failed', $message);
515 + //error_log('Error generating embedding for bot ' . $bot_id . ': ' . $response->get_error_message());
516 + return null;
758 517 }
759 -
518 +
760 519 $response_body = json_decode(wp_remote_retrieve_body($response), true);
761 -
520 +
762 521 // Handle different response formats based on provider
763 522 if (strpos($selected_model, 'gemini-embedding') === 0) {
764 523 // Gemini API response format
765 524 if (isset($response_body['embedding']['values']) && is_array($response_body['embedding']['values'])) {
766 - self::stamp_active_embedding_model($selected_model);
767 525 return $response_body['embedding']['values'];
768 526 } else {
769 - return self::embedding_failure_error($response, $selected_model, $api_key, $bot_id);
527 + //error_log('Invalid response received from Gemini embedding API for bot ' . $bot_id . ': ' . wp_json_encode($response_body));
528 + return null;
770 529 }
771 530 } else {
772 531 // OpenAI/Voyage API response format
773 532 if (isset($response_body['data'][0]['embedding']) && is_array($response_body['data'][0]['embedding'])) {
774 - self::stamp_active_embedding_model($selected_model);
775 533 return $response_body['data'][0]['embedding'];
776 534 } else {
777 - return self::embedding_failure_error($response, $selected_model, $api_key, $bot_id);
535 + //error_log('Invalid response received from embedding API for bot ' . $bot_id . ': ' . wp_json_encode($response_body));
536 + return null;
778 537 }
779 538 }
780 539 }
781 540
782 541 /**
783 - * Build a WP_Error carrying the embedding provider's REAL failure reason,
784 - * and record it in the Debug Mode log. Previously every failure path
785 - * returned bare null, so customers saw only "Failed to generate embedding
786 - * for content" / "Failed to store any chunks" with no cause (plan 4a7c0a).
787 - *
788 - * The API key never appears in provider response bodies (it travels in the
789 - * request headers), but the reason is scrubbed for it anyway before it can
790 - * reach a notice or the debug log.
791 - */
792 -private static function embedding_failure_error($response, $selected_model, $api_key, $bot_id) {
793 - $status = (int) wp_remote_retrieve_response_code($response);
794 - $raw = (string) wp_remote_retrieve_body($response);
795 - $decoded = json_decode($raw, true);
796 -
797 - // Provider error shapes: OpenAI + Gemini use {"error":{"message":…}};
798 - // Voyage uses {"detail":…}.
799 - $reason = '';
800 - if (is_array($decoded)) {
801 - if (isset($decoded['error']['message']) && is_string($decoded['error']['message'])) {
802 - $reason = $decoded['error']['message'];
803 - } elseif (isset($decoded['detail']) && is_string($decoded['detail'])) {
804 - $reason = $decoded['detail'];
805 - }
806 - }
807 - if ($reason === '') {
808 - $reason = ($raw !== '') ? substr($raw, 0, 200) : 'empty or malformed response';
809 - }
810 - if (is_string($api_key) && $api_key !== '') {
811 - $reason = str_replace($api_key, '[redacted]', $reason);
812 - }
813 - $reason = substr($reason, 0, 300);
814 - $message = sprintf('Embedding failed (%s, HTTP %d): %s', $selected_model, $status, $reason);
815 -
816 - if (class_exists('MxChat_Admin')) {
817 - MxChat_Admin::mxchat_log_debug('embedding_error', $message, array(
818 - 'model' => $selected_model,
819 - 'status' => $status,
820 - 'bot_id' => $bot_id,
821 - ));
822 - }
823 -
824 - return new WP_Error('embedding_failed', $message);
825 -}
826 -
827 -/**
828 - * Generate an embedding via a Custom (OpenAI-compatible) provider's /embeddings route.
829 - * Shared by every embedding entry point so the KNOWLEDGE-BASE INDEX side and the
830 - * QUERY side route through the same model when the opt-in
831 - * 'custom_provider_for_embeddings' setting is on. Mirrors the query-path logic in
832 - * MxChat_Integrator::mxchat_generate_embedding_custom() but takes an explicit
833 - * $options array so it is callable statically from utils + knowledge-manager.
834 - *
835 - * Returns a numeric array (the embedding vector) on success, or a human-readable
836 - * error string on failure (so callers expecting a string error, like the
837 - * knowledge-manager, can surface it directly; callers expecting array|null wrap it).
838 - *
839 - * @param string $text Text to embed.
840 - * @param array $options The resolved mxchat options (must contain the custom_provider_* keys).
841 - * @return array|string Embedding vector on success; error string on failure.
842 - */
843 -public static function generate_embedding_custom($text, $options) {
844 - if (empty($text)) {
845 - return 'No text provided for embedding generation';
846 - }
847 -
848 - $base_url = isset($options['custom_provider_base_url']) ? rtrim(trim((string) $options['custom_provider_base_url']), '/') : '';
849 - if (empty($base_url)) {
850 - return 'Custom provider Base URL is not configured.';
851 - }
852 -
853 - $api_key = isset($options['custom_provider_api_key']) ? trim((string) $options['custom_provider_api_key']) : '';
854 - $auth_scheme = isset($options['custom_provider_auth_scheme']) ? $options['custom_provider_auth_scheme'] : 'bearer';
855 - $api_version = isset($options['custom_provider_api_version']) ? trim((string) $options['custom_provider_api_version']) : '';
856 -
857 - // Embedding model: shared resolver (dedicated embedding model -> chat model
858 - // -> 'default') — the mismatch warning's "selected" side reads the same chain.
859 - $model = self::resolve_custom_embedding_model($options);
860 -
861 - $embed_url = $base_url . '/embeddings';
862 - if (!empty($api_version)) {
863 - $embed_url .= (strpos($embed_url, '?') === false ? '?' : '&') . 'api-version=' . rawurlencode($api_version);
864 - }
865 -
866 - $headers = ['Content-Type' => 'application/json'];
867 - if (!empty($api_key)) {
868 - if ($auth_scheme === 'api-key') {
869 - $headers['api-key'] = $api_key;
870 - } else {
871 - $headers['Authorization'] = 'Bearer ' . $api_key;
872 - }
873 - }
874 -
875 - $response = wp_remote_post($embed_url, [
876 - 'headers' => $headers,
877 - 'body' => wp_json_encode(['input' => $text, 'model' => $model]),
878 - 'timeout' => 60,
879 - ]);
880 - if (is_wp_error($response)) {
881 - return 'Connection error when generating embeddings (custom provider): ' . $response->get_error_message();
882 - }
883 -
884 - $status = wp_remote_retrieve_response_code($response);
885 - $body = json_decode(wp_remote_retrieve_body($response), true);
886 - if ($status !== 200) {
887 - $msg = isset($body['error']['message']) ? $body['error']['message'] : 'HTTP ' . $status;
888 - return 'Custom embedding endpoint error: ' . $msg;
889 - }
890 - if (isset($body['data'][0]['embedding']) && is_array($body['data'][0]['embedding'])) {
891 - // Stamp the custom model identity so the active-embedding-model mismatch
892 - // warning reflects the real (custom) model rather than the built-in setting.
893 - self::stamp_active_embedding_model('custom:' . $model);
894 - return $body['data'][0]['embedding'];
895 - }
896 - return 'Invalid embedding response from custom provider.';
897 -}
898 -
899 -/**
900 542 * Submit content as multiple chunks
901 543 *
902 544 * Splits large content into chunks, generates embeddings for each,
903 545 * and stores them with chunk metadata for later reassembly.
@@ -939,44 +581,13 @@
939 581 return new WP_Error('chunking_failed', 'Content could not be split into chunks');
940 582 }
941 583
942 584 $errors = array();
943 - $embed_failures = 0;
944 - $first_embed_reason = '';
945 - $first_store_reason = '';
946 585 $is_pinecone = self::is_pinecone_enabled_for_bot($bot_id);
947 586
948 587 foreach ($chunks as $index => $chunk_text) {
949 588 // Generate chunk metadata
950 589 $chunk_metadata = MxChat_Chunker::create_chunk_metadata($index, $total_chunks, $source_url);
951 -
952 - // AI-Engine-style aliases so external consumers (Pinecone/Qdrant/Chroma) can rely on
953 - // a stable shorthand ('source'/'part_index'/'part_total') without parsing our internal names.
954 - $chunk_metadata['source'] = $source_url;
955 - $chunk_metadata['part_index'] = (int) $index;
956 - $chunk_metadata['part_total'] = (int) $total_chunks;
957 -
958 - /**
959 - * Filter the per-chunk metadata blob before it's written to the KB store.
960 - *
961 - * @param array $chunk_metadata Metadata array (source, part_index, part_total, chunk_index, total_chunks, source_url, parent_url_hash, document_type, ...).
962 - * @param string $chunk_text The chunk text being stored.
963 - * @param array $context ['bot_id' => string, 'content_type' => string, 'source_url' => string, 'part_index' => int, 'part_total' => int]
964 - * @return array Updated metadata array.
965 - */
966 - $chunk_metadata = apply_filters(
967 - 'mxchat_embedding_chunk_metadata',
968 - $chunk_metadata,
969 - $chunk_text,
970 - array(
971 - 'bot_id' => $bot_id,
972 - 'content_type' => $content_type,
973 - 'source_url' => $source_url,
974 - 'part_index' => (int) $index,
975 - 'part_total' => (int) $total_chunks,
976 - )
977 - );
978 -
979 590 $chunk_vector_id = MxChat_Chunker::generate_chunk_vector_id($source_url, $index);
980 591
981 592 //error_log('[MXCHAT-CHUNK] Processing chunk ' . ($index + 1) . '/' . $total_chunks . ' (ID: ' . $chunk_vector_id . ')');
982 593
@@ -983,17 +594,10 @@
983 594 // Generate embedding for this chunk
984 595 $embedding_vector = self::generate_embedding($chunk_text, $api_key, $bot_id);
985 596
986 597 if (!is_array($embedding_vector)) {
987 - // Track embedding failures separately from storage failures, and
988 - // keep the first provider reason seen — the two failure classes
989 - // have opposite remedies (API key vs Pinecone/DB) (plan 4a7c0a).
990 - $embed_failures++;
991 - $reason = is_wp_error($embedding_vector) ? $embedding_vector->get_error_message() : '';
992 - if ($reason !== '' && $first_embed_reason === '') {
993 - $first_embed_reason = $reason;
994 - }
995 - $errors[] = new WP_Error('embedding_failed', 'Failed to generate embedding for chunk ' . $index . ($reason !== '' ? ' — ' . $reason : ''));
598 + $errors[] = new WP_Error('embedding_failed', 'Failed to generate embedding for chunk ' . $index);
599 + //error_log('[MXCHAT-CHUNK] Failed to generate embedding for chunk ' . $index);
996 600 continue;
997 601 }
998 602
999 603 if ($is_pinecone) {
@@ -1023,44 +627,20 @@
1023 627 }
1024 628
1025 629 if (is_wp_error($result)) {
1026 630 $errors[] = $result;
1027 - if ($first_store_reason === '') {
1028 - $first_store_reason = $result->get_error_message();
1029 - }
631 + //error_log('[MXCHAT-CHUNK] Failed to store chunk ' . $index . ': ' . $result->get_error_message());
1030 632 }
1031 633 }
1032 634
1033 635 if (count($errors) === $total_chunks) {
1034 - // Say WHICH stage failed — "failed to store" used to cover pure
1035 - // embedding failures too, sending customers to debug Pinecone when
1036 - // the problem was their embedding API key (plan 4a7c0a).
1037 - if ($embed_failures === $total_chunks) {
1038 - return new WP_Error('chunking_failed',
1039 - 'Failed to store any chunks — every chunk failed to embed'
1040 - . ($first_embed_reason !== '' ? ': ' . $first_embed_reason : '')
1041 - . ' Check the embedding provider API key and model under MxChat Settings.');
1042 - }
1043 - if ($embed_failures === 0) {
1044 - return new WP_Error('chunking_failed',
1045 - 'Failed to store any chunks — embeddings generated but storage failed'
1046 - . ($first_store_reason !== '' ? ': ' . $first_store_reason : '')
1047 - . ' Check the knowledge base storage (Pinecone index or database).');
1048 - }
1049 - return new WP_Error('chunking_failed', sprintf(
1050 - 'Failed to store any chunks — %d failed to embed%s and %d failed to store%s',
1051 - $embed_failures,
1052 - $first_embed_reason !== '' ? ' (' . $first_embed_reason . ')' : '',
1053 - $total_chunks - $embed_failures,
1054 - $first_store_reason !== '' ? ' (' . $first_store_reason . ')' : ''
1055 - ));
636 + return new WP_Error('chunking_failed', 'Failed to store any chunks');
1056 637 }
1057 638
1058 639 if (!empty($errors)) {
1059 - $detail = $first_embed_reason !== '' ? $first_embed_reason : $first_store_reason;
640 + //error_log('[MXCHAT-CHUNK] Completed with ' . count($errors) . ' errors out of ' . $total_chunks . ' chunks');
1060 641 return new WP_Error('chunking_partial_failure',
1061 - sprintf('Failed to store %d of %d chunks', count($errors), $total_chunks)
1062 - . ($detail !== '' ? ' — first error: ' . $detail : ''));
642 + sprintf('Failed to store %d of %d chunks', count($errors), $total_chunks));
1063 643 }
1064 644
1065 645 //error_log('[MXCHAT-CHUNK] Successfully stored all ' . $total_chunks . ' chunks');
1066 646 return true;
@@ -1106,9 +686,9 @@
1106 686 'total_chunks' => $chunk_metadata['total_chunks'],
1107 687 'parent_url_hash' => $chunk_metadata['parent_url_hash'],
1108 688 'last_updated' => time(),
1109 689 'created_at' => time(),
1110 - 'bot_id' => $bot_id,
690 + 'bot_id' => $bot_id
1111 691 );
1112 692
1113 693 $vector_data = array(
1114 694 'id' => $vector_id,
@@ -1224,34 +804,31 @@
1224 804
1225 805 // Add the original single-vector ID (for non-chunked content)
1226 806 $vectors_to_delete[] = $base_vector_id;
1227 807
1228 - // Pinecone /vectors/list is a GET endpoint with query-string parameters; a POST here returns a
1229 - // non-200 silently and we end up only deleting the base vector, leaving chunks orphaned.
1230 - $query_params = array(
808 + // Use Pinecone list API to find all chunk vectors with this prefix
809 + $list_url = "https://{$host}/vectors/list";
810 +
811 + $list_body = array(
1231 812 'prefix' => $base_vector_id . '_chunk_',
1232 - 'limit' => 100,
813 + 'limit' => 100
1233 814 );
815 +
1234 816 if (!empty($namespace)) {
1235 - $query_params['namespace'] = $namespace;
817 + $list_body['namespace'] = $namespace;
1236 818 }
1237 819
1238 - $list_url = "https://{$host}/vectors/list?" . http_build_query($query_params);
820 + $list_response = wp_remote_post($list_url, array(
821 + 'headers' => array(
822 + 'Api-Key' => $api_key,
823 + 'accept' => 'application/json',
824 + 'content-type' => 'application/json'
825 + ),
826 + 'body' => wp_json_encode($list_body),
827 + 'timeout' => 30
828 + ));
1239 829
1240 - // Paginate in case a URL has more than 100 chunks.
1241 - do {
1242 - $list_response = wp_remote_get($list_url, array(
1243 - 'headers' => array(
1244 - 'Api-Key' => $api_key,
1245 - 'accept' => 'application/json',
1246 - ),
1247 - 'timeout' => 30,
1248 - ));
1249 -
1250 - if (is_wp_error($list_response) || wp_remote_retrieve_response_code($list_response) !== 200) {
1251 - break;
1252 - }
1253 -
830 + if (!is_wp_error($list_response)) {
1254 831 $list_data = json_decode(wp_remote_retrieve_body($list_response), true);
1255 832 if (!empty($list_data['vectors'])) {
1256 833 foreach ($list_data['vectors'] as $vector) {
1257 834 if (isset($vector['id'])) {
@@ -1258,18 +835,10 @@
1258 835 $vectors_to_delete[] = $vector['id'];
1259 836 }
1260 837 }
1261 838 }
839 + }
1262 840
1263 - $next_token = $list_data['pagination']['next'] ?? '';
1264 - if (empty($next_token)) {
1265 - break;
1266 - }
1267 -
1268 - $query_params['paginationToken'] = $next_token;
1269 - $list_url = "https://{$host}/vectors/list?" . http_build_query($query_params);
1270 - } while (true);
1271 -
1272 841 if (empty($vectors_to_delete)) {
1273 842 //error_log('[MXCHAT-CHUNK-DELETE] No vectors found to delete');
1274 843 return true;
1275 844 }
@@ -1328,42 +897,6 @@
1328 897 }
1329 898
1330 899 //error_log('[MXCHAT-CHUNK-DELETE] Deleted ' . $result . ' rows from WordPress DB');
1331 900 return true;
1332 -}
1333 -
1334 -/**
1335 - * Hybrid keyword boost (plan-38ffa1): detect whether the WP-DB knowledge
1336 - * table can serve the keyword leg via a MySQL FULLTEXT index, creating the
1337 - * index if needed. Detection runs once and caches the answer in the
1338 - * mxchat_hybrid_keyword_capability option ('fulltext' | 'like'); pass
1339 - * $force to re-detect. LIKE is the graceful fallback for shared hosts
1340 - * whose ALTER fails — the feature works either way, FULLTEXT just ranks
1341 - * better and scales.
1342 - *
1343 - * @param bool $force Re-run detection even if a cached answer exists.
1344 - * @return string 'fulltext' or 'like'
1345 - */
1346 -public static function mxchat_hybrid_detect_capability($force = false) {
1347 - $cached = get_option('mxchat_hybrid_keyword_capability', '');
1348 - if (!$force && in_array($cached, array('fulltext', 'like'), true)) {
1349 - return $cached;
1350 - }
1351 -
1352 - global $wpdb;
1353 - $table = $wpdb->prefix . 'mxchat_system_prompt_content';
1354 -
1355 - $index_exists = $wpdb->get_var("SHOW INDEX FROM {$table} WHERE Key_name = 'mxchat_content_ft'");
1356 - if (!$index_exists) {
1357 - // Suppress the visible error on hosts where this is not permitted —
1358 - // failure is an expected, handled outcome (LIKE fallback).
1359 - $suppress = $wpdb->suppress_errors(true);
1360 - $wpdb->query("ALTER TABLE {$table} ADD FULLTEXT INDEX mxchat_content_ft (article_content)");
1361 - $wpdb->suppress_errors($suppress);
1362 - $index_exists = $wpdb->get_var("SHOW INDEX FROM {$table} WHERE Key_name = 'mxchat_content_ft'");
1363 - }
1364 -
1365 - $capability = $index_exists ? 'fulltext' : 'like';
1366 - update_option('mxchat_hybrid_keyword_capability', $capability);
1367 - return $capability;
1368 901 }
1369 902 }