PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.2.0
MxChat – AI Chatbot & Content Generation for WordPress v3.2.0
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 +40 -582 3.2.183.2.0 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,104 +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 - * Public QUERY-side entry point (plan 876edb). The chat pipeline's
662 - * MxChat_Integrator::mxchat_generate_embedding() adapter routes through here
663 - * so the query and index sides share ONE provider-routing implementation —
664 - * the same endpoints, request bodies, and stamping semantics. The Integrator
665 - * keeps its own error vocabulary by translating the WP_Error this returns
666 - * (see the structured error data on every failure path below).
667 - *
668 - * @param string $text The text to be embedded.
669 - * @param string $api_key Caller-resolved API key (per-bot on the query side).
670 - * @param string $bot_id The bot ID for multi-bot support.
671 - * @return array|WP_Error The embedding vector, or WP_Error carrying the reason.
672 - */
673 -public static function generate_query_embedding($text, $api_key, $bot_id = 'default') {
674 - return self::generate_embedding($text, $api_key, $bot_id);
675 -}
676 -
677 -/**
678 434 * UPDATED: Generate an embedding for the given text using bot-specific configuration.
679 435 *
680 436 * @param string $text The text to be embedded.
681 437 * @param string $api_key The API key used for generating embeddings.
@@ -689,24 +445,11 @@
689 445 } else {
690 446 $bot_options = apply_filters('mxchat_get_bot_options', array(), $bot_id);
691 447 $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
692 448 }
693 -
694 - // Opt-in: when the custom provider is selected for embeddings, route the KB
695 - // INDEX side through the same custom endpoint the query side uses, so stored
696 - // vectors and query vectors come from the same model. Default-off behavior
697 - // below is untouched.
698 - if (isset($options['custom_provider_for_embeddings']) && $options['custom_provider_for_embeddings'] === 'on') {
699 - $custom = self::generate_embedding_custom($text, $options);
700 - // The custom path already returns a human-readable error string —
701 - // carry it instead of collapsing to null (plan 4a7c0a). The 'custom'
702 - // branch marker lets the Integrator adapter map the string back onto
703 - // its own error codes (876edb).
704 - return is_array($custom) ? $custom : new WP_Error('embedding_failed', (string) $custom, array('branch' => 'custom'));
705 - }
706 -
449 +
707 450 $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
708 -
451 +
709 452 // Determine endpoint and API key based on model
710 453 if (strpos($selected_model, 'voyage') === 0) {
711 454 $endpoint = 'https://api.voyageai.com/v1/embeddings';
712 455 $api_key = $options['voyage_api_key'] ?? '';
@@ -714,13 +457,10 @@
714 457 $endpoint = 'https://generativelanguage.googleapis.com/v1beta/models/' . $selected_model . ':embedContent';
715 458 $api_key = $options['gemini_api_key'] ?? '';
716 459 } else {
717 460 $endpoint = 'https://api.openai.com/v1/embeddings';
718 - // Prefer the caller-resolved key when one was passed — the query side
719 - // resolves per-bot keys at its call sites (integrator adapter, 876edb).
720 - // Index callers pass the preflight key, which equals this options read,
721 - // so nothing changes for them.
722 - $api_key = !empty($api_key) ? $api_key : ($options['api_key'] ?? '');
461 + // Use the bot-specific API key or fallback to passed API key
462 + $api_key = $options['api_key'] ?? $api_key;
723 463 }
724 464
725 465 // Prepare request body based on provider
726 466 if (strpos($selected_model, 'gemini-embedding') === 0) {
@@ -771,208 +511,35 @@
771 511
772 512 $response = wp_remote_post($endpoint, $args);
773 513
774 514 if (is_wp_error($response)) {
775 - $message = 'Embedding request failed (connection): ' . $response->get_error_message();
776 - if (class_exists('MxChat_Admin')) {
777 - MxChat_Admin::mxchat_log_debug('embedding_error', $message, array('model' => $selected_model, 'bot_id' => $bot_id));
778 - }
779 - return new WP_Error('embedding_failed', $message, array(
780 - 'branch' => 'cloud',
781 - 'kind' => 'connection',
782 - 'reason' => $response->get_error_message(),
783 - 'model' => $selected_model,
784 - ));
515 + //error_log('Error generating embedding for bot ' . $bot_id . ': ' . $response->get_error_message());
516 + return null;
785 517 }
786 -
518 +
787 519 $response_body = json_decode(wp_remote_retrieve_body($response), true);
788 -
520 +
789 521 // Handle different response formats based on provider
790 522 if (strpos($selected_model, 'gemini-embedding') === 0) {
791 523 // Gemini API response format
792 524 if (isset($response_body['embedding']['values']) && is_array($response_body['embedding']['values'])) {
793 - self::stamp_active_embedding_model($selected_model);
794 525 return $response_body['embedding']['values'];
795 526 } else {
796 - 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;
797 529 }
798 530 } else {
799 531 // OpenAI/Voyage API response format
800 532 if (isset($response_body['data'][0]['embedding']) && is_array($response_body['data'][0]['embedding'])) {
801 - self::stamp_active_embedding_model($selected_model);
802 533 return $response_body['data'][0]['embedding'];
803 534 } else {
804 - 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;
805 537 }
806 538 }
807 539 }
808 540
809 541 /**
810 - * Build a WP_Error carrying the embedding provider's REAL failure reason,
811 - * and record it in the Debug Mode log. Previously every failure path
812 - * returned bare null, so customers saw only "Failed to generate embedding
813 - * for content" / "Failed to store any chunks" with no cause (plan 4a7c0a).
814 - *
815 - * The API key never appears in provider response bodies (it travels in the
816 - * request headers), but the reason is scrubbed for it anyway before it can
817 - * reach a notice or the debug log.
818 - */
819 -private static function embedding_failure_error($response, $selected_model, $api_key, $bot_id) {
820 - $status = (int) wp_remote_retrieve_response_code($response);
821 - $raw = (string) wp_remote_retrieve_body($response);
822 - $decoded = json_decode($raw, true);
823 -
824 - // Provider error shapes: OpenAI + Gemini use {"error":{"message":…}};
825 - // Voyage uses {"detail":…}.
826 - $reason = '';
827 - if (is_array($decoded)) {
828 - if (isset($decoded['error']['message']) && is_string($decoded['error']['message'])) {
829 - $reason = $decoded['error']['message'];
830 - } elseif (isset($decoded['detail']) && is_string($decoded['detail'])) {
831 - $reason = $decoded['detail'];
832 - }
833 - }
834 - if ($reason === '') {
835 - $reason = ($raw !== '') ? substr($raw, 0, 200) : 'empty or malformed response';
836 - }
837 - if (is_string($api_key) && $api_key !== '') {
838 - $reason = str_replace($api_key, '[redacted]', $reason);
839 - }
840 - $reason = substr($reason, 0, 300);
841 - $message = sprintf('Embedding failed (%s, HTTP %d): %s', $selected_model, $status, $reason);
842 -
843 - if (class_exists('MxChat_Admin')) {
844 - MxChat_Admin::mxchat_log_debug('embedding_error', $message, array(
845 - 'model' => $selected_model,
846 - 'status' => $status,
847 - 'bot_id' => $bot_id,
848 - ));
849 - }
850 -
851 - // Structured data so the Integrator's query-side adapter can rebuild its
852 - // typed error contract (auth/rate-limit/quota/invalid-response) without a
853 - // second transport implementation (876edb). Additive — message unchanged.
854 - return new WP_Error('embedding_failed', $message, array(
855 - 'branch' => 'cloud',
856 - 'status' => $status,
857 - 'error_type' => (is_array($decoded) && isset($decoded['error']['type']) && is_string($decoded['error']['type'])) ? $decoded['error']['type'] : '',
858 - 'reason' => $reason,
859 - 'model' => $selected_model,
860 - ));
861 -}
862 -
863 -/**
864 - * Generate an embedding via a Custom (OpenAI-compatible) provider's /embeddings route.
865 - * Shared by every embedding entry point so the KNOWLEDGE-BASE INDEX side and the
866 - * QUERY side route through the same model when the opt-in
867 - * 'custom_provider_for_embeddings' setting is on. Mirrors the query-path logic in
868 - * MxChat_Integrator::mxchat_generate_embedding_custom() but takes an explicit
869 - * $options array so it is callable statically from utils + knowledge-manager.
870 - *
871 - * Returns a numeric array (the embedding vector) on success, or a human-readable
872 - * error string on failure (so callers expecting a string error, like the
873 - * knowledge-manager, can surface it directly; callers expecting array|null wrap it).
874 - *
875 - * @param string $text Text to embed.
876 - * @param array $options The resolved mxchat options (must contain the custom_provider_* keys).
877 - * @return array|string Embedding vector on success; error string on failure.
878 - */
879 -public static function generate_embedding_custom($text, $options) {
880 - if (empty($text)) {
881 - return 'No text provided for embedding generation';
882 - }
883 -
884 - $base_url = isset($options['custom_provider_base_url']) ? rtrim(trim((string) $options['custom_provider_base_url']), '/') : '';
885 - if (empty($base_url)) {
886 - return 'Custom provider Base URL is not configured.';
887 - }
888 -
889 - $api_key = isset($options['custom_provider_api_key']) ? trim((string) $options['custom_provider_api_key']) : '';
890 - $auth_scheme = isset($options['custom_provider_auth_scheme']) ? $options['custom_provider_auth_scheme'] : 'bearer';
891 - $api_version = isset($options['custom_provider_api_version']) ? trim((string) $options['custom_provider_api_version']) : '';
892 -
893 - // Embedding model: shared resolver (dedicated embedding model -> chat model
894 - // -> 'default') — the mismatch warning's "selected" side reads the same chain.
895 - $model = self::resolve_custom_embedding_model($options);
896 -
897 - $embed_url = $base_url . '/embeddings';
898 - if (!empty($api_version)) {
899 - $embed_url .= (strpos($embed_url, '?') === false ? '?' : '&') . 'api-version=' . rawurlencode($api_version);
900 - }
901 -
902 - $headers = ['Content-Type' => 'application/json'];
903 - if (!empty($api_key)) {
904 - if ($auth_scheme === 'api-key') {
905 - $headers['api-key'] = $api_key;
906 - } else {
907 - $headers['Authorization'] = 'Bearer ' . $api_key;
908 - }
909 - }
910 -
911 - $response = wp_remote_post($embed_url, [
912 - 'headers' => $headers,
913 - 'body' => wp_json_encode(['input' => $text, 'model' => $model]),
914 - 'timeout' => 60,
915 - ]);
916 - if (is_wp_error($response)) {
917 - return self::log_custom_embedding_failure(
918 - 'Connection error when generating embeddings (custom provider): ' . $response->get_error_message(),
919 - $model,
920 - $api_key
921 - );
922 - }
923 -
924 - $status = wp_remote_retrieve_response_code($response);
925 - $body = json_decode(wp_remote_retrieve_body($response), true);
926 - if ($status !== 200) {
927 - $msg = isset($body['error']['message']) ? $body['error']['message'] : 'HTTP ' . $status;
928 - return self::log_custom_embedding_failure(
929 - 'Custom embedding endpoint error: ' . $msg,
930 - $model,
931 - $api_key,
932 - (int) $status
933 - );
934 - }
935 - if (isset($body['data'][0]['embedding']) && is_array($body['data'][0]['embedding'])) {
936 - // Stamp the custom model identity so the active-embedding-model mismatch
937 - // warning reflects the real (custom) model rather than the built-in setting.
938 - self::stamp_active_embedding_model('custom:' . $model);
939 - return $body['data'][0]['embedding'];
940 - }
941 - return self::log_custom_embedding_failure('Invalid embedding response from custom provider.', $model, $api_key);
942 -}
943 -
944 -/**
945 - * Record a custom-provider embedding failure in the Debug Mode log, then
946 - * return the message unchanged so callers keep their string-error contract.
947 - * The cloud branch has logged its failures since 4a7c0a; the custom branch
948 - * never did, so chat-side failures on Custom-provider installs were
949 - * invisible to Debug Mode despite the 3.2.18 readme saying otherwise
950 - * (plan 71e4b6). Same scrub-then-log shape as embedding_failure_error().
951 - *
952 - * @param string $message Human-readable failure (the caller's return value).
953 - * @param string $model Resolved custom embedding model.
954 - * @param string $api_key Scrubbed out of the logged message if it ever appears.
955 - * @param int $status HTTP status when one was received, 0 otherwise.
956 - * @return string The (scrubbed) message.
957 - */
958 -private static function log_custom_embedding_failure($message, $model, $api_key, $status = 0) {
959 - if (is_string($api_key) && $api_key !== '') {
960 - $message = str_replace($api_key, '[redacted]', $message);
961 - }
962 -
963 - if (class_exists('MxChat_Admin')) {
964 - $context = array('model' => 'custom:' . $model);
965 - if ($status > 0) {
966 - $context['status'] = $status;
967 - }
968 - MxChat_Admin::mxchat_log_debug('embedding_error', $message, $context);
969 - }
970 -
971 - return $message;
972 -}
973 -
974 -/**
975 542 * Submit content as multiple chunks
976 543 *
977 544 * Splits large content into chunks, generates embeddings for each,
978 545 * and stores them with chunk metadata for later reassembly.
@@ -1014,44 +581,13 @@
1014 581 return new WP_Error('chunking_failed', 'Content could not be split into chunks');
1015 582 }
1016 583
1017 584 $errors = array();
1018 - $embed_failures = 0;
1019 - $first_embed_reason = '';
1020 - $first_store_reason = '';
1021 585 $is_pinecone = self::is_pinecone_enabled_for_bot($bot_id);
1022 586
1023 587 foreach ($chunks as $index => $chunk_text) {
1024 588 // Generate chunk metadata
1025 589 $chunk_metadata = MxChat_Chunker::create_chunk_metadata($index, $total_chunks, $source_url);
1026 -
1027 - // AI-Engine-style aliases so external consumers (Pinecone/Qdrant/Chroma) can rely on
1028 - // a stable shorthand ('source'/'part_index'/'part_total') without parsing our internal names.
1029 - $chunk_metadata['source'] = $source_url;
1030 - $chunk_metadata['part_index'] = (int) $index;
1031 - $chunk_metadata['part_total'] = (int) $total_chunks;
1032 -
1033 - /**
1034 - * Filter the per-chunk metadata blob before it's written to the KB store.
1035 - *
1036 - * @param array $chunk_metadata Metadata array (source, part_index, part_total, chunk_index, total_chunks, source_url, parent_url_hash, document_type, ...).
1037 - * @param string $chunk_text The chunk text being stored.
1038 - * @param array $context ['bot_id' => string, 'content_type' => string, 'source_url' => string, 'part_index' => int, 'part_total' => int]
1039 - * @return array Updated metadata array.
1040 - */
1041 - $chunk_metadata = apply_filters(
1042 - 'mxchat_embedding_chunk_metadata',
1043 - $chunk_metadata,
1044 - $chunk_text,
1045 - array(
1046 - 'bot_id' => $bot_id,
1047 - 'content_type' => $content_type,
1048 - 'source_url' => $source_url,
1049 - 'part_index' => (int) $index,
1050 - 'part_total' => (int) $total_chunks,
1051 - )
1052 - );
1053 -
1054 590 $chunk_vector_id = MxChat_Chunker::generate_chunk_vector_id($source_url, $index);
1055 591
1056 592 //error_log('[MXCHAT-CHUNK] Processing chunk ' . ($index + 1) . '/' . $total_chunks . ' (ID: ' . $chunk_vector_id . ')');
1057 593
@@ -1058,17 +594,10 @@
1058 594 // Generate embedding for this chunk
1059 595 $embedding_vector = self::generate_embedding($chunk_text, $api_key, $bot_id);
1060 596
1061 597 if (!is_array($embedding_vector)) {
1062 - // Track embedding failures separately from storage failures, and
1063 - // keep the first provider reason seen — the two failure classes
1064 - // have opposite remedies (API key vs Pinecone/DB) (plan 4a7c0a).
1065 - $embed_failures++;
1066 - $reason = is_wp_error($embedding_vector) ? $embedding_vector->get_error_message() : '';
1067 - if ($reason !== '' && $first_embed_reason === '') {
1068 - $first_embed_reason = $reason;
1069 - }
1070 - $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);
1071 600 continue;
1072 601 }
1073 602
1074 603 if ($is_pinecone) {
@@ -1098,44 +627,20 @@
1098 627 }
1099 628
1100 629 if (is_wp_error($result)) {
1101 630 $errors[] = $result;
1102 - if ($first_store_reason === '') {
1103 - $first_store_reason = $result->get_error_message();
1104 - }
631 + //error_log('[MXCHAT-CHUNK] Failed to store chunk ' . $index . ': ' . $result->get_error_message());
1105 632 }
1106 633 }
1107 634
1108 635 if (count($errors) === $total_chunks) {
1109 - // Say WHICH stage failed — "failed to store" used to cover pure
1110 - // embedding failures too, sending customers to debug Pinecone when
1111 - // the problem was their embedding API key (plan 4a7c0a).
1112 - if ($embed_failures === $total_chunks) {
1113 - return new WP_Error('chunking_failed',
1114 - 'Failed to store any chunks — every chunk failed to embed'
1115 - . ($first_embed_reason !== '' ? ': ' . $first_embed_reason : '')
1116 - . ' Check the embedding provider API key and model under MxChat Settings.');
1117 - }
1118 - if ($embed_failures === 0) {
1119 - return new WP_Error('chunking_failed',
1120 - 'Failed to store any chunks — embeddings generated but storage failed'
1121 - . ($first_store_reason !== '' ? ': ' . $first_store_reason : '')
1122 - . ' Check the knowledge base storage (Pinecone index or database).');
1123 - }
1124 - return new WP_Error('chunking_failed', sprintf(
1125 - 'Failed to store any chunks — %d failed to embed%s and %d failed to store%s',
1126 - $embed_failures,
1127 - $first_embed_reason !== '' ? ' (' . $first_embed_reason . ')' : '',
1128 - $total_chunks - $embed_failures,
1129 - $first_store_reason !== '' ? ' (' . $first_store_reason . ')' : ''
1130 - ));
636 + return new WP_Error('chunking_failed', 'Failed to store any chunks');
1131 637 }
1132 638
1133 639 if (!empty($errors)) {
1134 - $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');
1135 641 return new WP_Error('chunking_partial_failure',
1136 - sprintf('Failed to store %d of %d chunks', count($errors), $total_chunks)
1137 - . ($detail !== '' ? ' — first error: ' . $detail : ''));
642 + sprintf('Failed to store %d of %d chunks', count($errors), $total_chunks));
1138 643 }
1139 644
1140 645 //error_log('[MXCHAT-CHUNK] Successfully stored all ' . $total_chunks . ' chunks');
1141 646 return true;
@@ -1181,9 +686,9 @@
1181 686 'total_chunks' => $chunk_metadata['total_chunks'],
1182 687 'parent_url_hash' => $chunk_metadata['parent_url_hash'],
1183 688 'last_updated' => time(),
1184 689 'created_at' => time(),
1185 - 'bot_id' => $bot_id,
690 + 'bot_id' => $bot_id
1186 691 );
1187 692
1188 693 $vector_data = array(
1189 694 'id' => $vector_id,
@@ -1299,34 +804,31 @@
1299 804
1300 805 // Add the original single-vector ID (for non-chunked content)
1301 806 $vectors_to_delete[] = $base_vector_id;
1302 807
1303 - // Pinecone /vectors/list is a GET endpoint with query-string parameters; a POST here returns a
1304 - // non-200 silently and we end up only deleting the base vector, leaving chunks orphaned.
1305 - $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(
1306 812 'prefix' => $base_vector_id . '_chunk_',
1307 - 'limit' => 100,
813 + 'limit' => 100
1308 814 );
815 +
1309 816 if (!empty($namespace)) {
1310 - $query_params['namespace'] = $namespace;
817 + $list_body['namespace'] = $namespace;
1311 818 }
1312 819
1313 - $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 + ));
1314 829
1315 - // Paginate in case a URL has more than 100 chunks.
1316 - do {
1317 - $list_response = wp_remote_get($list_url, array(
1318 - 'headers' => array(
1319 - 'Api-Key' => $api_key,
1320 - 'accept' => 'application/json',
1321 - ),
1322 - 'timeout' => 30,
1323 - ));
1324 -
1325 - if (is_wp_error($list_response) || wp_remote_retrieve_response_code($list_response) !== 200) {
1326 - break;
1327 - }
1328 -
830 + if (!is_wp_error($list_response)) {
1329 831 $list_data = json_decode(wp_remote_retrieve_body($list_response), true);
1330 832 if (!empty($list_data['vectors'])) {
1331 833 foreach ($list_data['vectors'] as $vector) {
1332 834 if (isset($vector['id'])) {
@@ -1333,18 +835,10 @@
1333 835 $vectors_to_delete[] = $vector['id'];
1334 836 }
1335 837 }
1336 838 }
839 + }
1337 840
1338 - $next_token = $list_data['pagination']['next'] ?? '';
1339 - if (empty($next_token)) {
1340 - break;
1341 - }
1342 -
1343 - $query_params['paginationToken'] = $next_token;
1344 - $list_url = "https://{$host}/vectors/list?" . http_build_query($query_params);
1345 - } while (true);
1346 -
1347 841 if (empty($vectors_to_delete)) {
1348 842 //error_log('[MXCHAT-CHUNK-DELETE] No vectors found to delete');
1349 843 return true;
1350 844 }
@@ -1403,42 +897,6 @@
1403 897 }
1404 898
1405 899 //error_log('[MXCHAT-CHUNK-DELETE] Deleted ' . $result . ' rows from WordPress DB');
1406 900 return true;
1407 -}
1408 -
1409 -/**
1410 - * Hybrid keyword boost (plan-38ffa1): detect whether the WP-DB knowledge
1411 - * table can serve the keyword leg via a MySQL FULLTEXT index, creating the
1412 - * index if needed. Detection runs once and caches the answer in the
1413 - * mxchat_hybrid_keyword_capability option ('fulltext' | 'like'); pass
1414 - * $force to re-detect. LIKE is the graceful fallback for shared hosts
1415 - * whose ALTER fails — the feature works either way, FULLTEXT just ranks
1416 - * better and scales.
1417 - *
1418 - * @param bool $force Re-run detection even if a cached answer exists.
1419 - * @return string 'fulltext' or 'like'
1420 - */
1421 -public static function mxchat_hybrid_detect_capability($force = false) {
1422 - $cached = get_option('mxchat_hybrid_keyword_capability', '');
1423 - if (!$force && in_array($cached, array('fulltext', 'like'), true)) {
1424 - return $cached;
1425 - }
1426 -
1427 - global $wpdb;
1428 - $table = $wpdb->prefix . 'mxchat_system_prompt_content';
1429 -
1430 - $index_exists = $wpdb->get_var("SHOW INDEX FROM {$table} WHERE Key_name = 'mxchat_content_ft'");
1431 - if (!$index_exists) {
1432 - // Suppress the visible error on hosts where this is not permitted —
1433 - // failure is an expected, handled outcome (LIKE fallback).
1434 - $suppress = $wpdb->suppress_errors(true);
1435 - $wpdb->query("ALTER TABLE {$table} ADD FULLTEXT INDEX mxchat_content_ft (article_content)");
1436 - $wpdb->suppress_errors($suppress);
1437 - $index_exists = $wpdb->get_var("SHOW INDEX FROM {$table} WHERE Key_name = 'mxchat_content_ft'");
1438 - }
1439 -
1440 - $capability = $index_exists ? 'fulltext' : 'like';
1441 - update_option('mxchat_hybrid_keyword_capability', $capability);
1442 - return $capability;
1443 901 }
1444 902 }