| @@ -735,24 +735,33 @@ | ||
| 735 | 735 | * the first run and full is the whole joined run, both un-normalised. |
| 736 | 736 | */ |
| 737 | 737 | private static function code_runs($text) { |
| 738 | 738 | $runs = array(); |
| 739 | + $text = (string) $text; | |
| 739 | 740 | $pattern = '/(?<![A-Za-z0-9])([A-Za-z0-9]+(?:[.\-][A-Za-z0-9]+)*)((?:[ .\-]\d{2,6}(?![A-Za-z0-9])){1,3})?(?![A-Za-z0-9])/u'; |
| 740 | - if (!preg_match_all($pattern, (string) $text, $m, PREG_SET_ORDER)) { | |
| 741 | - return $runs; | |
| 742 | - } | |
| 743 | - foreach ($m as $match) { | |
| 744 | - $head = rtrim($match[1], '.'); | |
| 745 | - if (strlen($head) < self::CODE_MIN_LEN || !preg_match('/\d/', $head)) { | |
| 741 | + $offset = 0; | |
| 742 | + $length = strlen($text); | |
| 743 | + // 0d7bd4: walked one match at a time. Only a head that IS a code keeps | |
| 744 | + // its trailing digit groups; a plain word ("part 4471-AB-12") used to | |
| 745 | + // take "4471" as its tail and the digit-led number was never stored. | |
| 746 | + while ($offset < $length && preg_match($pattern, $text, $match, PREG_OFFSET_CAPTURE, $offset)) { | |
| 747 | + $head = rtrim($match[1][0], '.'); | |
| 748 | + $head_end = $match[1][1] + strlen($match[1][0]); | |
| 749 | + $is_code = strlen($head) >= self::CODE_MIN_LEN && preg_match('/\d/', $head); | |
| 750 | + if ($is_code) { | |
| 751 | + $has_letter = (bool) preg_match('/[A-Za-z]/', $head); | |
| 752 | + $separators = preg_match_all('/[.\-]/', $head); | |
| 753 | + if (!$has_letter && $separators < 2) { | |
| 754 | + $is_code = false; // 2024, 26.5, 10-20 | |
| 755 | + } | |
| 756 | + } | |
| 757 | + if (!$is_code) { | |
| 758 | + $offset = $head_end; | |
| 746 | 759 | continue; |
| 747 | 760 | } |
| 748 | - $has_letter = (bool) preg_match('/[A-Za-z]/', $head); | |
| 749 | - $separators = preg_match_all('/[.\-]/', $head); | |
| 750 | - if (!$has_letter && $separators < 2) { | |
| 751 | - continue; // 2024, 26.5, 10-20 | |
| 752 | - } | |
| 753 | - $tail = isset($match[2]) ? $match[2] : ''; | |
| 761 | + $tail = isset($match[2]) && $match[2][1] >= 0 ? $match[2][0] : ''; | |
| 754 | 762 | $runs[] = array($head, $head . $tail); |
| 763 | + $offset = $match[0][1] + strlen($match[0][0]); | |
| 755 | 764 | } |
| 756 | 765 | return $runs; |
| 757 | 766 | } |
| 758 | 767 | |
| @@ -780,9 +789,11 @@ | ||
| 780 | 789 | if (count($codes) >= self::CODES_MAX) { |
| 781 | 790 | break; |
| 782 | 791 | } |
| 783 | 792 | } |
| 784 | - return array_slice(array_keys($codes), 0, self::CODES_MAX); | |
| 793 | + // 0d7bd4: strval — PHP turns an all-digit array key into an int, and | |
| 794 | + // Pinecone refuses a list that mixes strings and numbers. | |
| 795 | + return array_slice(array_map('strval', array_keys($codes)), 0, self::CODES_MAX); | |
| 785 | 796 | } |
| 786 | 797 | |
| 787 | 798 | /** |
| 788 | 799 | * What a question's tokens expand to for the codes $in filter: the |
| @@ -809,9 +820,9 @@ | ||
| 809 | 820 | if (count($codes) >= 200) { |
| 810 | 821 | break; |
| 811 | 822 | } |
| 812 | 823 | } |
| 813 | - return array_slice(array_keys($codes), 0, 200); | |
| 824 | + return array_slice(array_map('strval', array_keys($codes)), 0, 200); | |
| 814 | 825 | } |
| 815 | 826 | |
| 816 | 827 | /** |
| 817 | 828 | * Order keyword-leg hits so an exact code match outranks a prefix-only |
| @@ -1089,29 +1100,55 @@ | ||
| 1089 | 1100 | } |
| 1090 | 1101 | |
| 1091 | 1102 | /** Dimension of the active embedding model (same table the KB screen uses). */ |
| 1092 | 1103 | public static function expected_dimension() { |
| 1104 | + $dimension = self::expected_dimension_checked(); | |
| 1105 | + return is_wp_error($dimension) ? 0 : (int) $dimension; | |
| 1106 | + } | |
| 1107 | + | |
| 1108 | + /** | |
| 1109 | + * 418afd: the dimension the site's embeddings really have, or why it is | |
| 1110 | + * not known. A Custom Provider embedding model is asked for its size | |
| 1111 | + * (MxChat_Utils::expected_embedding_dimension()); the standard dropdown is | |
| 1112 | + * disabled on such a site and used to size the index anyway. The filter | |
| 1113 | + * still has the last word, and can supply a number when the model could | |
| 1114 | + * not be asked. | |
| 1115 | + * | |
| 1116 | + * @param bool $force Ask a custom model again even after a recent failure (the card's buttons). | |
| 1117 | + * @return int|WP_Error | |
| 1118 | + */ | |
| 1119 | + public static function expected_dimension_checked($force = false) { | |
| 1093 | 1120 | $options = get_option('mxchat_options', array()); |
| 1094 | - $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002'; | |
| 1095 | - $model_dimensions = array( | |
| 1096 | - 'text-embedding-ada-002' => 1536, | |
| 1097 | - 'text-embedding-3-small' => 1536, | |
| 1098 | - 'text-embedding-3-large' => 3072, | |
| 1099 | - 'voyage-2' => 1024, | |
| 1100 | - 'voyage-large-2' => 1536, | |
| 1101 | - 'voyage-3-large' => 2048, | |
| 1102 | - 'gemini-embedding-001' => 1536, | |
| 1103 | - ); | |
| 1104 | - if (strpos($selected_model, 'voyage-3-large') === 0) { | |
| 1105 | - return intval($options['voyage_output_dimension'] ?? 2048); | |
| 1121 | + $options = is_array($options) ? $options : array(); | |
| 1122 | + $custom = isset($options['custom_provider_for_embeddings']) && $options['custom_provider_for_embeddings'] === 'on'; | |
| 1123 | + $selected_model = $custom | |
| 1124 | + ? MxChat_Utils::get_selected_embedding_model($options) | |
| 1125 | + : ($options['embedding_model'] ?? 'text-embedding-ada-002'); | |
| 1126 | + $dimension = MxChat_Utils::expected_embedding_dimension($options, $force); | |
| 1127 | + if (is_wp_error($dimension)) { | |
| 1128 | + $supplied = (int) apply_filters('mxchat_pinecone_expected_dimension', 0, $selected_model); | |
| 1129 | + return $supplied > 0 ? $supplied : $dimension; | |
| 1106 | 1130 | } |
| 1107 | - if (strpos($selected_model, 'gemini-embedding') === 0) { | |
| 1108 | - return intval($options['gemini_output_dimension'] ?? 1536); | |
| 1131 | + if (!$custom && (strpos($selected_model, 'voyage-3-large') === 0 || strpos($selected_model, 'gemini-embedding') === 0)) { | |
| 1132 | + return (int) $dimension; // adjustable-size models: the owner's own setting, never filtered (as before) | |
| 1109 | 1133 | } |
| 1110 | - $dimension = apply_filters('mxchat_pinecone_expected_dimension', $model_dimensions[$selected_model] ?? 1536, $selected_model); | |
| 1111 | - return (int) $dimension; | |
| 1134 | + return (int) apply_filters('mxchat_pinecone_expected_dimension', $dimension, $selected_model); | |
| 1112 | 1135 | } |
| 1113 | 1136 | |
| 1137 | + /** Plain-language refusal for the card when a custom model's size is unknown. (Plain __(): the card's JS text-escapes it.) */ | |
| 1138 | + public static function dimension_unknown_message(WP_Error $error, $action) { | |
| 1139 | + $reason = trim($error->get_error_message()); | |
| 1140 | + $tail = $action === 'create' | |
| 1141 | + ? __('The index was not created.', 'mxchat') | |
| 1142 | + : __('The index was not checked.', 'mxchat'); | |
| 1143 | + return sprintf( | |
| 1144 | + /* translators: 1: the error the embedding endpoint returned, 2: what did not happen */ | |
| 1145 | + __('Could not determine the dimension of your Custom Provider embedding model (%1$s). %2$s Check the Custom Provider embedding settings, then try again.', 'mxchat'), | |
| 1146 | + $reason !== '' ? $reason : __('no answer from the embedding endpoint', 'mxchat'), | |
| 1147 | + $tail | |
| 1148 | + ); | |
| 1149 | + } | |
| 1150 | + | |
| 1114 | 1151 | // ===================================================================== |
| 1115 | 1152 | // Options (raw writes — the registered sanitizer rebuilds the array and |
| 1116 | 1153 | // would flip mxchat_use_pinecone on any isset key; the autosave handler |
| 1117 | 1154 | // writes this option the same raw way for the same reason) |
| @@ -1497,11 +1534,13 @@ | ||
| 1497 | 1534 | } else { |
| 1498 | 1535 | $total = 0; |
| 1499 | 1536 | } |
| 1500 | 1537 | $per_namespace[self::ns_key($namespace)] = self::new_namespace_entry($namespace, $total, 'running'); |
| 1501 | - foreach ($ns_counts as $ns_name => $ns_count) { | |
| 1502 | - if ($ns_name !== $namespace && $ns_count > 0) { | |
| 1503 | - $other_namespaces[self::ns_key($ns_name)] = $ns_count; | |
| 1538 | + // 47c61f: in the copy order (default first, then by name). Pinecone's | |
| 1539 | + // stats object lists namespaces in a different order call to call. | |
| 1540 | + foreach (self::namespace_queue($ns_counts) as $ns_name) { | |
| 1541 | + if ((string) $ns_name !== (string) $namespace) { | |
| 1542 | + $other_namespaces[self::ns_key($ns_name)] = (int) $ns_counts[$ns_name]; | |
| 1504 | 1543 | } |
| 1505 | 1544 | } |
| 1506 | 1545 | } |
| 1507 | 1546 | $source_dimension = (int) ($stats['dimension'] ?? 0); |
| @@ -1755,8 +1794,48 @@ | ||
| 1755 | 1794 | implode(', ', $parts) |
| 1756 | 1795 | ); |
| 1757 | 1796 | } |
| 1758 | 1797 | |
| 1798 | + /** | |
| 1799 | + * 5872ef: bots that still keep the given Pinecone host as their own (the | |
| 1800 | + * Multi-Bot add-on stores a host per bot). Core knows nothing about the | |
| 1801 | + * bots table — the add-on answers the filter, and with it inactive the | |
| 1802 | + * list is empty. Delete old index refuses while this is not empty. | |
| 1803 | + * | |
| 1804 | + * @return array bot id → bot name | |
| 1805 | + */ | |
| 1806 | + public static function bots_on_host($host) { | |
| 1807 | + $host = strtolower(trim(str_replace(array('https://', 'http://'), '', (string) $host), '/')); | |
| 1808 | + if ($host === '') { | |
| 1809 | + return array(); | |
| 1810 | + } | |
| 1811 | + $bots = apply_filters('mxchat_pinecone_bots_on_host', array(), $host); | |
| 1812 | + if (!is_array($bots)) { | |
| 1813 | + return array(); | |
| 1814 | + } | |
| 1815 | + $named = array(); | |
| 1816 | + foreach ($bots as $bot_id => $name) { | |
| 1817 | + $name = is_scalar($name) ? trim((string) $name) : ''; | |
| 1818 | + $named[(string) $bot_id] = $name !== '' ? $name : (string) $bot_id; | |
| 1819 | + } | |
| 1820 | + return $named; | |
| 1821 | + } | |
| 1822 | + | |
| 1823 | + /** Plain-language refusal naming every bot still on the old index. (Plain __(): the card's JS text-escapes it.) */ | |
| 1824 | + public static function bots_on_host_message(array $bots) { | |
| 1825 | + return sprintf( | |
| 1826 | + /* translators: 1: number of bots, 2: comma-separated bot names */ | |
| 1827 | + _n( | |
| 1828 | + '%1$s bot still uses the old index: %2$s. Move it to the new index first (Multi-Bot, All Bots, Move these bots to the new index), or it will stop answering. The old index stays.', | |
| 1829 | + '%1$s bots still use the old index: %2$s. Move them to the new index first (Multi-Bot, All Bots, Move these bots to the new index), or they will stop answering. The old index stays.', | |
| 1830 | + count($bots), | |
| 1831 | + 'mxchat' | |
| 1832 | + ), | |
| 1833 | + number_format_i18n(count($bots)), | |
| 1834 | + implode(', ', $bots) | |
| 1835 | + ); | |
| 1836 | + } | |
| 1837 | + | |
| 1759 | 1838 | /** Record a step failure without ending the copy: status stays running, the cursor stays put. */ |
| 1760 | 1839 | private static function migration_fail(array $state, $message) { |
| 1761 | 1840 | $state['error'] = (string) $message; |
| 1762 | 1841 | $state['error_at'] = time(); |
| @@ -1842,9 +1921,13 @@ | ||
| 1842 | 1921 | } |
| 1843 | 1922 | if ($name === '') { |
| 1844 | 1923 | wp_send_json_error(array('message' => esc_html__('Enter an index name first — the new document index will be created under that name.', 'mxchat'))); |
| 1845 | 1924 | } |
| 1846 | - $dimension = self::expected_dimension(); | |
| 1925 | + $dimension = self::expected_dimension_checked(true); | |
| 1926 | + if (is_wp_error($dimension)) { | |
| 1927 | + // 418afd: never size an index from a guess | |
| 1928 | + wp_send_json_error(array('message' => self::dimension_unknown_message($dimension, 'create'), 'code' => 'dimension_unknown')); | |
| 1929 | + } | |
| 1847 | 1930 | $index = self::create_document_index($name, $dimension, $cloud, $region, $language, $api_key); |
| 1848 | 1931 | if (is_wp_error($index)) { |
| 1849 | 1932 | $data = $index->get_error_data(); |
| 1850 | 1933 | wp_send_json_error(array( |
| @@ -1907,9 +1990,13 @@ | ||
| 1907 | 1990 | $host = trim(str_replace(array('https://', 'http://'), '', $host), '/'); |
| 1908 | 1991 | if ($api_key === '' || $host === '') { |
| 1909 | 1992 | wp_send_json_error(array('message' => esc_html__('Enter the Pinecone API key and host first.', 'mxchat'))); |
| 1910 | 1993 | } |
| 1911 | - $check = self::check_document_index($host, $api_key, self::expected_dimension()); | |
| 1994 | + $dimension = self::expected_dimension_checked(true); | |
| 1995 | + if (is_wp_error($dimension)) { | |
| 1996 | + wp_send_json_error(array('ok' => false, 'message' => self::dimension_unknown_message($dimension, 'check'), 'code' => 'dimension_unknown')); | |
| 1997 | + } | |
| 1998 | + $check = self::check_document_index($host, $api_key, $dimension); | |
| 1912 | 1999 | if (!$check['ok']) { |
| 1913 | 2000 | wp_send_json_error($check); |
| 1914 | 2001 | } |
| 1915 | 2002 | $options = get_option(self::OPTION, array()); |
| @@ -1959,9 +2046,13 @@ | ||
| 1959 | 2046 | $target_host = self::posted_target_host($options); |
| 1960 | 2047 | if ($target_host === '') { |
| 1961 | 2048 | wp_send_json_error(array('message' => esc_html__('Enter the host of the document index to copy into (or create one with Create index for me).', 'mxchat'))); |
| 1962 | 2049 | } |
| 1963 | - $check = self::check_document_index($target_host, $api_key, self::expected_dimension()); | |
| 2050 | + $dimension = self::expected_dimension_checked(true); | |
| 2051 | + if (is_wp_error($dimension)) { | |
| 2052 | + wp_send_json_error(array('message' => self::dimension_unknown_message($dimension, 'check'), 'code' => 'dimension_unknown')); | |
| 2053 | + } | |
| 2054 | + $check = self::check_document_index($target_host, $api_key, $dimension); | |
| 1964 | 2055 | if (!$check['ok']) { |
| 1965 | 2056 | wp_send_json_error(array('message' => $check['message'])); |
| 1966 | 2057 | } |
| 1967 | 2058 | $target = array('api_key' => $api_key, 'host' => strtolower($check['host']), 'namespace' => $namespace, 'index_type' => 'document'); |
| @@ -2022,8 +2113,21 @@ | ||
| 2022 | 2113 | // finished copy whose count matches — not just the one copied last. |
| 2023 | 2114 | // Before this, a Multi-Bot or legacy-namespace site could delete |
| 2024 | 2115 | // records that were never copied. |
| 2025 | 2116 | $missing = self::uncopied_namespaces($state, $target['api_key']); |
| 2117 | + // 5872ef: a bot that still keeps the old index as its own host stops | |
| 2118 | + // answering the moment that index is deleted, however complete the | |
| 2119 | + // copy is. Refuse and name the bots; when namespaces are uncopied too, | |
| 2120 | + // say both in one answer. | |
| 2121 | + $bots = self::bots_on_host((string) ($state['source_host'] ?? '')); | |
| 2122 | + if (!empty($bots)) { | |
| 2123 | + $refusal = array('message' => self::bots_on_host_message($bots), 'bots' => $bots); | |
| 2124 | + if (is_array($missing) && !empty($missing)) { | |
| 2125 | + $refusal['message'] .= ' ' . self::uncopied_message($missing); | |
| 2126 | + $refusal['uncopied'] = $missing; | |
| 2127 | + } | |
| 2128 | + wp_send_json_error($refusal); | |
| 2129 | + } | |
| 2026 | 2130 | if (is_wp_error($missing)) { |
| 2027 | 2131 | wp_send_json_error(array('message' => sprintf(__('Could not read the old index before deleting it (%s). The old index stays.', 'mxchat'), $missing->get_error_message()))); |
| 2028 | 2132 | } |
| 2029 | 2133 | if (!empty($missing)) { |