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 +38 -388 3.2.163.2.0 View file →
@@ -5,114 +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 - $registry = self::embedding_model_registry();
62 - return isset($registry[$model]) ? $registry[$model]['label'] : $model;
63 -}
64 -
65 -/**
66 - * Returns the model that was last used to actually write embeddings into the
67 - * KB. Differs from the user-selected setting once a switch has happened but
68 - * no re-embed has occurred yet — that's the mismatch state we warn about.
69 - */
70 -public static function get_active_embedding_model() {
71 - return get_option('mxchat_active_embedding_model', '');
72 -}
73 -
74 -/**
75 - * Stamp the model that produced the most recent successful embedding. Called
76 - * from generate_embedding() right after the API responds with a valid vector.
77 - */
78 -public static function stamp_active_embedding_model($model) {
79 - if (!empty($model) && $model !== self::get_active_embedding_model()) {
80 - update_option('mxchat_active_embedding_model', $model, false);
81 - }
82 -}
83 -
84 -/**
85 - * Extract the 11-character YouTube video ID from a URL, or '' if the URL is
86 - * not a single-video YouTube link. Single source of truth for both the KB
87 - * ingestion side and the chat render side — do not duplicate this parsing.
88 - * Channel, playlist, and search URLs deliberately return '' (only a URL that
89 - * identifies one video can be embedded).
90 - */
91 -public static function parse_youtube_id($url) {
92 - if (!is_string($url) || $url === '' || !preg_match('#^https?://#i', $url)) {
93 - return '';
94 - }
95 - $host = strtolower((string) wp_parse_url($url, PHP_URL_HOST));
96 - $host = preg_replace('/^(www|m)\./', '', $host);
97 - $path = (string) wp_parse_url($url, PHP_URL_PATH);
98 - $id = '';
99 - if ($host === 'youtu.be') {
100 - $segments = explode('/', ltrim($path, '/'));
101 - $id = $segments[0] ?? '';
102 - } elseif (in_array($host, array('youtube.com', 'youtube-nocookie.com'), true)) {
103 - if (preg_match('#^/(?:shorts|embed|live|v)/([A-Za-z0-9_-]+)#', $path, $m)) {
104 - $id = $m[1];
105 - } elseif ($path === '/watch') {
106 - parse_str((string) wp_parse_url($url, PHP_URL_QUERY), $query_vars);
107 - $id = isset($query_vars['v']) ? (string) $query_vars['v'] : '';
108 - }
109 - }
110 - $id = preg_replace('/[^A-Za-z0-9_-]/', '', (string) $id);
111 - return (strlen($id) === 11) ? $id : '';
112 -}
113 -
114 -/**
115 9 * UPDATED: Submit or update content (and its embedding) in the database.
116 10 * Stores in Pinecone if enabled, otherwise stores in WordPress DB.
117 11 *
118 12 * @param string $content The content to be embedded.
@@ -154,13 +48,10 @@
154 48 // UPDATED: Generate the embedding using bot-specific configuration
155 49 $embedding_vector = self::generate_embedding($content, $api_key, $bot_id);
156 50
157 51 if (!is_array($embedding_vector)) {
158 - // Surface the provider's real reason instead of a fixed string (4a7c0a).
159 - $reason = is_wp_error($embedding_vector)
160 - ? $embedding_vector->get_error_message()
161 - : 'Failed to generate embedding for content';
162 - 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');
163 54 }
164 55
165 56 //error_log('[MXCHAT-DB] Embedding generated successfully');
166 57
@@ -480,9 +371,9 @@
480 371 'source_url' => $url, // Can be empty for manual content
481 372 'type' => $content_type, // Now supports: post, page, pdf, url, manual, product, etc.
482 373 'last_updated' => time(),
483 374 'created_at' => time(), // Add creation timestamp
484 - 'bot_id' => $bot_id, // Add bot identification
375 + 'bot_id' => $bot_id // Add bot identification
485 376 );
486 377
487 378 $vector_data = array(
488 379 'id' => $vector_id,
@@ -554,22 +445,11 @@
554 445 } else {
555 446 $bot_options = apply_filters('mxchat_get_bot_options', array(), $bot_id);
556 447 $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
557 448 }
558 -
559 - // Opt-in: when the custom provider is selected for embeddings, route the KB
560 - // INDEX side through the same custom endpoint the query side uses, so stored
561 - // vectors and query vectors come from the same model. Default-off behavior
562 - // below is untouched.
563 - if (isset($options['custom_provider_for_embeddings']) && $options['custom_provider_for_embeddings'] === 'on') {
564 - $custom = self::generate_embedding_custom($text, $options);
565 - // The custom path already returns a human-readable error string —
566 - // carry it instead of collapsing to null (plan 4a7c0a).
567 - return is_array($custom) ? $custom : new WP_Error('embedding_failed', (string) $custom);
568 - }
569 -
449 +
570 450 $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
571 -
451 +
572 452 // Determine endpoint and API key based on model
573 453 if (strpos($selected_model, 'voyage') === 0) {
574 454 $endpoint = 'https://api.voyageai.com/v1/embeddings';
575 455 $api_key = $options['voyage_api_key'] ?? '';
@@ -631,156 +511,35 @@
631 511
632 512 $response = wp_remote_post($endpoint, $args);
633 513
634 514 if (is_wp_error($response)) {
635 - $message = 'Embedding request failed (connection): ' . $response->get_error_message();
636 - if (class_exists('MxChat_Admin')) {
637 - MxChat_Admin::mxchat_log_debug('embedding_error', $message, array('model' => $selected_model, 'bot_id' => $bot_id));
638 - }
639 - return new WP_Error('embedding_failed', $message);
515 + //error_log('Error generating embedding for bot ' . $bot_id . ': ' . $response->get_error_message());
516 + return null;
640 517 }
641 -
518 +
642 519 $response_body = json_decode(wp_remote_retrieve_body($response), true);
643 -
520 +
644 521 // Handle different response formats based on provider
645 522 if (strpos($selected_model, 'gemini-embedding') === 0) {
646 523 // Gemini API response format
647 524 if (isset($response_body['embedding']['values']) && is_array($response_body['embedding']['values'])) {
648 - self::stamp_active_embedding_model($selected_model);
649 525 return $response_body['embedding']['values'];
650 526 } else {
651 - 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;
652 529 }
653 530 } else {
654 531 // OpenAI/Voyage API response format
655 532 if (isset($response_body['data'][0]['embedding']) && is_array($response_body['data'][0]['embedding'])) {
656 - self::stamp_active_embedding_model($selected_model);
657 533 return $response_body['data'][0]['embedding'];
658 534 } else {
659 - 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;
660 537 }
661 538 }
662 539 }
663 540
664 541 /**
665 - * Build a WP_Error carrying the embedding provider's REAL failure reason,
666 - * and record it in the Debug Mode log. Previously every failure path
667 - * returned bare null, so customers saw only "Failed to generate embedding
668 - * for content" / "Failed to store any chunks" with no cause (plan 4a7c0a).
669 - *
670 - * The API key never appears in provider response bodies (it travels in the
671 - * request headers), but the reason is scrubbed for it anyway before it can
672 - * reach a notice or the debug log.
673 - */
674 -private static function embedding_failure_error($response, $selected_model, $api_key, $bot_id) {
675 - $status = (int) wp_remote_retrieve_response_code($response);
676 - $raw = (string) wp_remote_retrieve_body($response);
677 - $decoded = json_decode($raw, true);
678 -
679 - // Provider error shapes: OpenAI + Gemini use {"error":{"message":…}};
680 - // Voyage uses {"detail":…}.
681 - $reason = '';
682 - if (is_array($decoded)) {
683 - if (isset($decoded['error']['message']) && is_string($decoded['error']['message'])) {
684 - $reason = $decoded['error']['message'];
685 - } elseif (isset($decoded['detail']) && is_string($decoded['detail'])) {
686 - $reason = $decoded['detail'];
687 - }
688 - }
689 - if ($reason === '') {
690 - $reason = ($raw !== '') ? substr($raw, 0, 200) : 'empty or malformed response';
691 - }
692 - if (is_string($api_key) && $api_key !== '') {
693 - $reason = str_replace($api_key, '[redacted]', $reason);
694 - }
695 - $reason = substr($reason, 0, 300);
696 - $message = sprintf('Embedding failed (%s, HTTP %d): %s', $selected_model, $status, $reason);
697 -
698 - if (class_exists('MxChat_Admin')) {
699 - MxChat_Admin::mxchat_log_debug('embedding_error', $message, array(
700 - 'model' => $selected_model,
701 - 'status' => $status,
702 - 'bot_id' => $bot_id,
703 - ));
704 - }
705 -
706 - return new WP_Error('embedding_failed', $message);
707 -}
708 -
709 -/**
710 - * Generate an embedding via a Custom (OpenAI-compatible) provider's /embeddings route.
711 - * Shared by every embedding entry point so the KNOWLEDGE-BASE INDEX side and the
712 - * QUERY side route through the same model when the opt-in
713 - * 'custom_provider_for_embeddings' setting is on. Mirrors the query-path logic in
714 - * MxChat_Integrator::mxchat_generate_embedding_custom() but takes an explicit
715 - * $options array so it is callable statically from utils + knowledge-manager.
716 - *
717 - * Returns a numeric array (the embedding vector) on success, or a human-readable
718 - * error string on failure (so callers expecting a string error, like the
719 - * knowledge-manager, can surface it directly; callers expecting array|null wrap it).
720 - *
721 - * @param string $text Text to embed.
722 - * @param array $options The resolved mxchat options (must contain the custom_provider_* keys).
723 - * @return array|string Embedding vector on success; error string on failure.
724 - */
725 -public static function generate_embedding_custom($text, $options) {
726 - if (empty($text)) {
727 - return 'No text provided for embedding generation';
728 - }
729 -
730 - $base_url = isset($options['custom_provider_base_url']) ? rtrim(trim((string) $options['custom_provider_base_url']), '/') : '';
731 - if (empty($base_url)) {
732 - return 'Custom provider Base URL is not configured.';
733 - }
734 -
735 - $api_key = isset($options['custom_provider_api_key']) ? trim((string) $options['custom_provider_api_key']) : '';
736 - $auth_scheme = isset($options['custom_provider_auth_scheme']) ? $options['custom_provider_auth_scheme'] : 'bearer';
737 - $api_version = isset($options['custom_provider_api_version']) ? trim((string) $options['custom_provider_api_version']) : '';
738 -
739 - // Embedding model: prefer the dedicated custom_provider_embedding_model, fall back to the chat model.
740 - $model = (isset($options['custom_provider_embedding_model']) && trim((string) $options['custom_provider_embedding_model']) !== '')
741 - ? trim((string) $options['custom_provider_embedding_model'])
742 - : ((isset($options['custom_provider_model']) && trim((string) $options['custom_provider_model']) !== '') ? trim((string) $options['custom_provider_model']) : 'default');
743 -
744 - $embed_url = $base_url . '/embeddings';
745 - if (!empty($api_version)) {
746 - $embed_url .= (strpos($embed_url, '?') === false ? '?' : '&') . 'api-version=' . rawurlencode($api_version);
747 - }
748 -
749 - $headers = ['Content-Type' => 'application/json'];
750 - if (!empty($api_key)) {
751 - if ($auth_scheme === 'api-key') {
752 - $headers['api-key'] = $api_key;
753 - } else {
754 - $headers['Authorization'] = 'Bearer ' . $api_key;
755 - }
756 - }
757 -
758 - $response = wp_remote_post($embed_url, [
759 - 'headers' => $headers,
760 - 'body' => wp_json_encode(['input' => $text, 'model' => $model]),
761 - 'timeout' => 60,
762 - ]);
763 - if (is_wp_error($response)) {
764 - return 'Connection error when generating embeddings (custom provider): ' . $response->get_error_message();
765 - }
766 -
767 - $status = wp_remote_retrieve_response_code($response);
768 - $body = json_decode(wp_remote_retrieve_body($response), true);
769 - if ($status !== 200) {
770 - $msg = isset($body['error']['message']) ? $body['error']['message'] : 'HTTP ' . $status;
771 - return 'Custom embedding endpoint error: ' . $msg;
772 - }
773 - if (isset($body['data'][0]['embedding']) && is_array($body['data'][0]['embedding'])) {
774 - // Stamp the custom model identity so the active-embedding-model mismatch
775 - // warning reflects the real (custom) model rather than the built-in setting.
776 - self::stamp_active_embedding_model('custom:' . $model);
777 - return $body['data'][0]['embedding'];
778 - }
779 - return 'Invalid embedding response from custom provider.';
780 -}
781 -
782 -/**
783 542 * Submit content as multiple chunks
784 543 *
785 544 * Splits large content into chunks, generates embeddings for each,
786 545 * and stores them with chunk metadata for later reassembly.
@@ -822,44 +581,13 @@
822 581 return new WP_Error('chunking_failed', 'Content could not be split into chunks');
823 582 }
824 583
825 584 $errors = array();
826 - $embed_failures = 0;
827 - $first_embed_reason = '';
828 - $first_store_reason = '';
829 585 $is_pinecone = self::is_pinecone_enabled_for_bot($bot_id);
830 586
831 587 foreach ($chunks as $index => $chunk_text) {
832 588 // Generate chunk metadata
833 589 $chunk_metadata = MxChat_Chunker::create_chunk_metadata($index, $total_chunks, $source_url);
834 -
835 - // AI-Engine-style aliases so external consumers (Pinecone/Qdrant/Chroma) can rely on
836 - // a stable shorthand ('source'/'part_index'/'part_total') without parsing our internal names.
837 - $chunk_metadata['source'] = $source_url;
838 - $chunk_metadata['part_index'] = (int) $index;
839 - $chunk_metadata['part_total'] = (int) $total_chunks;
840 -
841 - /**
842 - * Filter the per-chunk metadata blob before it's written to the KB store.
843 - *
844 - * @param array $chunk_metadata Metadata array (source, part_index, part_total, chunk_index, total_chunks, source_url, parent_url_hash, document_type, ...).
845 - * @param string $chunk_text The chunk text being stored.
846 - * @param array $context ['bot_id' => string, 'content_type' => string, 'source_url' => string, 'part_index' => int, 'part_total' => int]
847 - * @return array Updated metadata array.
848 - */
849 - $chunk_metadata = apply_filters(
850 - 'mxchat_embedding_chunk_metadata',
851 - $chunk_metadata,
852 - $chunk_text,
853 - array(
854 - 'bot_id' => $bot_id,
855 - 'content_type' => $content_type,
856 - 'source_url' => $source_url,
857 - 'part_index' => (int) $index,
858 - 'part_total' => (int) $total_chunks,
859 - )
860 - );
861 -
862 590 $chunk_vector_id = MxChat_Chunker::generate_chunk_vector_id($source_url, $index);
863 591
864 592 //error_log('[MXCHAT-CHUNK] Processing chunk ' . ($index + 1) . '/' . $total_chunks . ' (ID: ' . $chunk_vector_id . ')');
865 593
@@ -866,17 +594,10 @@
866 594 // Generate embedding for this chunk
867 595 $embedding_vector = self::generate_embedding($chunk_text, $api_key, $bot_id);
868 596
869 597 if (!is_array($embedding_vector)) {
870 - // Track embedding failures separately from storage failures, and
871 - // keep the first provider reason seen — the two failure classes
872 - // have opposite remedies (API key vs Pinecone/DB) (plan 4a7c0a).
873 - $embed_failures++;
874 - $reason = is_wp_error($embedding_vector) ? $embedding_vector->get_error_message() : '';
875 - if ($reason !== '' && $first_embed_reason === '') {
876 - $first_embed_reason = $reason;
877 - }
878 - $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);
879 600 continue;
880 601 }
881 602
882 603 if ($is_pinecone) {
@@ -906,44 +627,20 @@
906 627 }
907 628
908 629 if (is_wp_error($result)) {
909 630 $errors[] = $result;
910 - if ($first_store_reason === '') {
911 - $first_store_reason = $result->get_error_message();
912 - }
631 + //error_log('[MXCHAT-CHUNK] Failed to store chunk ' . $index . ': ' . $result->get_error_message());
913 632 }
914 633 }
915 634
916 635 if (count($errors) === $total_chunks) {
917 - // Say WHICH stage failed — "failed to store" used to cover pure
918 - // embedding failures too, sending customers to debug Pinecone when
919 - // the problem was their embedding API key (plan 4a7c0a).
920 - if ($embed_failures === $total_chunks) {
921 - return new WP_Error('chunking_failed',
922 - 'Failed to store any chunks — every chunk failed to embed'
923 - . ($first_embed_reason !== '' ? ': ' . $first_embed_reason : '')
924 - . ' Check the embedding provider API key and model under MxChat Settings.');
925 - }
926 - if ($embed_failures === 0) {
927 - return new WP_Error('chunking_failed',
928 - 'Failed to store any chunks — embeddings generated but storage failed'
929 - . ($first_store_reason !== '' ? ': ' . $first_store_reason : '')
930 - . ' Check the knowledge base storage (Pinecone index or database).');
931 - }
932 - return new WP_Error('chunking_failed', sprintf(
933 - 'Failed to store any chunks — %d failed to embed%s and %d failed to store%s',
934 - $embed_failures,
935 - $first_embed_reason !== '' ? ' (' . $first_embed_reason . ')' : '',
936 - $total_chunks - $embed_failures,
937 - $first_store_reason !== '' ? ' (' . $first_store_reason . ')' : ''
938 - ));
636 + return new WP_Error('chunking_failed', 'Failed to store any chunks');
939 637 }
940 638
941 639 if (!empty($errors)) {
942 - $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');
943 641 return new WP_Error('chunking_partial_failure',
944 - sprintf('Failed to store %d of %d chunks', count($errors), $total_chunks)
945 - . ($detail !== '' ? ' — first error: ' . $detail : ''));
642 + sprintf('Failed to store %d of %d chunks', count($errors), $total_chunks));
946 643 }
947 644
948 645 //error_log('[MXCHAT-CHUNK] Successfully stored all ' . $total_chunks . ' chunks');
949 646 return true;
@@ -989,9 +686,9 @@
989 686 'total_chunks' => $chunk_metadata['total_chunks'],
990 687 'parent_url_hash' => $chunk_metadata['parent_url_hash'],
991 688 'last_updated' => time(),
992 689 'created_at' => time(),
993 - 'bot_id' => $bot_id,
690 + 'bot_id' => $bot_id
994 691 );
995 692
996 693 $vector_data = array(
997 694 'id' => $vector_id,
@@ -1107,34 +804,31 @@
1107 804
1108 805 // Add the original single-vector ID (for non-chunked content)
1109 806 $vectors_to_delete[] = $base_vector_id;
1110 807
1111 - // Pinecone /vectors/list is a GET endpoint with query-string parameters; a POST here returns a
1112 - // non-200 silently and we end up only deleting the base vector, leaving chunks orphaned.
1113 - $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(
1114 812 'prefix' => $base_vector_id . '_chunk_',
1115 - 'limit' => 100,
813 + 'limit' => 100
1116 814 );
815 +
1117 816 if (!empty($namespace)) {
1118 - $query_params['namespace'] = $namespace;
817 + $list_body['namespace'] = $namespace;
1119 818 }
1120 819
1121 - $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 + ));
1122 829
1123 - // Paginate in case a URL has more than 100 chunks.
1124 - do {
1125 - $list_response = wp_remote_get($list_url, array(
1126 - 'headers' => array(
1127 - 'Api-Key' => $api_key,
1128 - 'accept' => 'application/json',
1129 - ),
1130 - 'timeout' => 30,
1131 - ));
1132 -
1133 - if (is_wp_error($list_response) || wp_remote_retrieve_response_code($list_response) !== 200) {
1134 - break;
1135 - }
1136 -
830 + if (!is_wp_error($list_response)) {
1137 831 $list_data = json_decode(wp_remote_retrieve_body($list_response), true);
1138 832 if (!empty($list_data['vectors'])) {
1139 833 foreach ($list_data['vectors'] as $vector) {
1140 834 if (isset($vector['id'])) {
@@ -1141,18 +835,10 @@
1141 835 $vectors_to_delete[] = $vector['id'];
1142 836 }
1143 837 }
1144 838 }
839 + }
1145 840
1146 - $next_token = $list_data['pagination']['next'] ?? '';
1147 - if (empty($next_token)) {
1148 - break;
1149 - }
1150 -
1151 - $query_params['paginationToken'] = $next_token;
1152 - $list_url = "https://{$host}/vectors/list?" . http_build_query($query_params);
1153 - } while (true);
1154 -
1155 841 if (empty($vectors_to_delete)) {
1156 842 //error_log('[MXCHAT-CHUNK-DELETE] No vectors found to delete');
1157 843 return true;
1158 844 }
@@ -1211,42 +897,6 @@
1211 897 }
1212 898
1213 899 //error_log('[MXCHAT-CHUNK-DELETE] Deleted ' . $result . ' rows from WordPress DB');
1214 900 return true;
1215 -}
1216 -
1217 -/**
1218 - * Hybrid keyword boost (plan-38ffa1): detect whether the WP-DB knowledge
1219 - * table can serve the keyword leg via a MySQL FULLTEXT index, creating the
1220 - * index if needed. Detection runs once and caches the answer in the
1221 - * mxchat_hybrid_keyword_capability option ('fulltext' | 'like'); pass
1222 - * $force to re-detect. LIKE is the graceful fallback for shared hosts
1223 - * whose ALTER fails — the feature works either way, FULLTEXT just ranks
1224 - * better and scales.
1225 - *
1226 - * @param bool $force Re-run detection even if a cached answer exists.
1227 - * @return string 'fulltext' or 'like'
1228 - */
1229 -public static function mxchat_hybrid_detect_capability($force = false) {
1230 - $cached = get_option('mxchat_hybrid_keyword_capability', '');
1231 - if (!$force && in_array($cached, array('fulltext', 'like'), true)) {
1232 - return $cached;
1233 - }
1234 -
1235 - global $wpdb;
1236 - $table = $wpdb->prefix . 'mxchat_system_prompt_content';
1237 -
1238 - $index_exists = $wpdb->get_var("SHOW INDEX FROM {$table} WHERE Key_name = 'mxchat_content_ft'");
1239 - if (!$index_exists) {
1240 - // Suppress the visible error on hosts where this is not permitted —
1241 - // failure is an expected, handled outcome (LIKE fallback).
1242 - $suppress = $wpdb->suppress_errors(true);
1243 - $wpdb->query("ALTER TABLE {$table} ADD FULLTEXT INDEX mxchat_content_ft (article_content)");
1244 - $wpdb->suppress_errors($suppress);
1245 - $index_exists = $wpdb->get_var("SHOW INDEX FROM {$table} WHERE Key_name = 'mxchat_content_ft'");
1246 - }
1247 -
1248 - $capability = $index_exists ? 'fulltext' : 'like';
1249 - update_option('mxchat_hybrid_keyword_capability', $capability);
1250 - return $capability;
1251 901 }
1252 902 }