PluginProbe ʕ •ᴥ•ʔ
AI Engine – The Chatbot, AI Framework & MCP for WordPress / 2.8.4
AI Engine – The Chatbot, AI Framework & MCP for WordPress v2.8.4
3.5.7 3.5.6 3.5.5 3.5.4 3.5.3 3.5.2 3.5.1 3.5.0 3.4.9 3.4.8 3.4.7 0.2.1 1.6.91 0.2.2 1.6.92 0.2.3 1.6.93 0.2.4 1.6.94 0.2.5 1.6.95 0.2.6 1.6.96 0.2.7 1.6.97 0.2.8 1.6.98 0.2.9 1.6.99 0.3.0 1.7.0 0.3.1 1.7.1 0.3.2 1.7.2 0.3.3 1.7.3 0.3.4 1.7.4 0.3.5 1.7.5 0.3.6 1.7.6 0.4.0 1.7.7 0.4.1 1.7.8 0.4.2 1.7.9 0.4.3 1.8.0 0.4.4 1.8.1 0.4.5 1.8.2 0.4.6 1.8.3 0.4.7 1.8.4 0.4.8 1.8.5 0.4.9 1.8.6 0.5.0 1.8.7 0.5.1 1.8.8 0.5.2 1.8.9 0.5.3 1.9.0 0.5.4 1.9.1 0.5.5 1.9.2 0.5.6 1.9.3 0.5.7 1.9.4 0.5.8 1.9.5 0.5.9 1.9.6 0.6.0 1.9.7 0.6.1 1.9.8 0.6.2 1.9.81 0.6.3 1.9.82 0.6.4 1.9.83 0.6.5 1.9.84 0.6.6 1.9.85 0.6.7 1.9.86 0.6.8 1.9.87 0.6.9 1.9.88 0.7.0 1.9.89 0.7.1 1.9.90 0.7.2 1.9.91 0.7.3 1.9.92 0.7.4 1.9.93 0.7.5 1.9.94 0.7.6 1.9.95 0.7.7 1.9.96 0.7.8 1.9.97 0.7.9 1.9.98 0.8.0 1.9.99 0.8.1 2.0.0 0.8.2 2.0.1 0.8.3 2.0.2 0.8.4 2.0.3 0.8.5 2.0.4 0.8.6 2.0.5 0.8.7 2.0.6 0.8.8 2.0.7 0.8.9 2.0.8 0.9.0 2.0.9 0.9.2 2.1.0 0.9.3 2.1.1 0.9.4 2.1.2 0.9.5 2.1.3 0.9.6 2.1.4 0.9.7 2.1.5 0.9.8 2.1.6 0.9.81 2.1.7 0.9.82 2.1.8 0.9.83 2.1.9 0.9.84 2.2.0 0.9.85 2.2.1 0.9.86 2.2.2 0.9.87 2.2.3 0.9.88 2.2.4 0.9.89 2.2.5 0.9.9 2.2.51 0.9.91 2.2.52 0.9.92 2.2.53 0.9.93 2.2.54 0.9.94 2.2.56 0.9.95 2.2.57 0.9.96 2.2.6 0.9.97 2.2.60 0.9.98 2.2.61 0.9.99 2.2.62 1.0.0 2.2.63 1.0.01 2.2.70 1.0.1 2.2.80 1.0.2 2.2.81 1.0.3 2.2.90 1.0.4 2.2.91 1.0.5 2.2.92 1.0.6 2.2.93 1.0.7 2.2.94 1.0.8 2.2.95 1.0.9 2.3.0 1.1.0 2.3.1 1.1.1 2.3.2 1.1.2 2.3.3 1.1.3 2.3.4 1.1.4 2.3.5 1.1.5 2.3.6 1.1.6 2.3.7 1.1.7 2.3.8 1.1.8 2.3.9 1.1.9 2.4.0 1.2.0 2.4.1 1.2.1 2.4.2 1.2.2 2.4.3 1.2.21 2.4.4 1.2.3 2.4.5 1.2.30 2.4.6 1.3.0 2.4.7 1.3.1 2.4.8 1.3.2 2.4.9 1.3.3 2.5.0 1.3.31 2.5.1 1.3.32 2.5.2 1.3.33 2.5.3 1.3.34 2.5.4 1.3.35 2.5.5 1.3.36 2.5.6 1.3.37 2.5.7 1.3.38 2.5.8 1.3.39 2.5.9 1.3.40 2.6.0 1.3.41 2.6.1 1.3.42 2.6.2 1.3.43 2.6.3 1.3.44 2.6.5 1.3.45 2.6.6 1.3.46 2.6.7 1.3.47 2.6.8 1.3.48 2.6.9 1.3.49 2.7.0 1.3.50 2.7.1 1.3.51 2.7.2 1.3.52 2.7.3 1.3.53 2.7.4 1.3.54 2.7.5 1.3.56 2.7.6 1.3.57 2.7.7 1.3.58 2.7.8 1.3.59 2.7.9 1.3.60 2.8.0 1.3.61 2.8.1 1.3.62 2.8.2 1.3.63 2.8.3 1.3.64 2.8.4 1.3.65 2.8.5 1.3.66 2.8.6 1.3.67 2.8.7 1.3.68 2.8.8 1.3.69 2.8.9 1.3.70 2.9.0 1.3.71 2.9.1 1.3.72 2.9.2 1.3.73 2.9.3 1.3.74 2.9.4 1.3.75 2.9.5 1.3.76 2.9.6 1.3.77 2.9.7 1.3.78 2.9.8 1.3.79 2.9.9 1.3.80 3.0.0 1.3.81 3.0.1 1.3.82 3.0.2 1.3.83 3.0.3 1.3.84 3.0.4 1.3.85 3.0.5 1.3.86 3.0.6 1.3.87 3.0.7 1.3.88 3.0.8 1.3.89 3.0.9 1.3.90 3.1.0 1.3.91 3.1.1 1.3.92 3.1.2 1.3.93 3.1.3 1.3.94 3.1.4 1.3.95 3.1.5 1.3.96 3.1.6 1.3.97 3.1.7 1.3.98 3.1.8 1.3.99 3.1.9 1.4.0 3.2.0 1.4.1 3.2.1 1.4.2 3.2.2 1.4.3 3.2.3 1.4.4 3.2.4 1.4.5 3.2.5 1.4.6 3.2.6 1.4.7 3.2.7 1.4.8 3.2.8 1.4.9 3.2.9 1.5.0 3.3.0 1.5.1 3.3.1 1.5.2 3.3.2 1.5.3 3.3.3 1.5.4 3.3.4 1.5.5 3.3.5 1.5.6 3.3.6 1.5.7 3.3.7 1.5.8 3.3.8 1.5.9 3.3.9 1.6.0 3.4.0 1.6.1 3.4.1 1.6.2 3.4.2 1.6.3 3.4.3 1.6.5 3.4.4 1.6.51 3.4.5 1.6.52 3.4.6 1.6.53 1.6.54 1.6.55 1.6.56 1.6.57 1.6.58 1.6.59 1.6.60 1.6.61 1.6.62 1.6.63 1.6.64 1.6.65 1.6.66 1.6.67 1.6.68 trunk 1.6.69 0.0.1 1.6.70 0.0.2 1.6.71 0.0.3 1.6.72 0.0.4 1.6.73 0.0.5 1.6.74 0.0.6 1.6.75 0.0.7 1.6.76 0.0.8 1.6.77 0.0.9 1.6.78 0.1.0 1.6.79 0.1.1 1.6.81 0.1.2 1.6.82 0.1.3 1.6.83 0.1.4 1.6.84 0.1.5 1.6.85 0.1.6 1.6.86 0.1.7 1.6.87 0.1.8 1.6.88 0.1.9 1.6.89 0.2.0 1.6.90
ai-engine / classes / modules / search.php
ai-engine / classes / modules Last commit date
advisor.php 2 years ago chatbot.php 1 year ago discussions.php 1 year ago files.php 1 year ago gdpr.php 1 year ago search.php 1 year ago security.php 1 year ago tasks.php 1 year ago wand.php 1 year ago
search.php
923 lines
1 <?php
2
3 class Meow_MWAI_Modules_Search {
4 private $core = null;
5 private $namespace = 'mwai-ui/v1';
6
7 public function __construct( $core ) {
8 $this->core = $core;
9 add_action( 'rest_api_init', [ $this, 'rest_api_init' ] );
10 add_action( 'pre_get_posts', [ $this, 'pre_get_posts' ] );
11 add_filter( 'the_posts', [ $this, 'filter_embeddings_search_results' ], 10, 2 );
12 add_filter( 'get_search_query', [ $this, 'customize_search_display' ] );
13
14 // Initialize search frontend settings if they don't exist
15 $this->init_frontend_settings();
16 }
17
18 private function init_frontend_settings() {
19 // Ensure frontend search settings exist with defaults
20 if ( $this->core->get_option( 'search_frontend_method' ) === null ) {
21 $this->core->update_option( 'search_frontend_method', 'wordpress' );
22 }
23 if ( $this->core->get_option( 'search_frontend_env_id' ) === null ) {
24 $this->core->update_option( 'search_frontend_env_id', null );
25 }
26 if ( $this->core->get_option( 'search_website_context' ) === null ) {
27 $this->core->update_option( 'search_website_context', 'This is a website with useful information and content.' );
28 }
29 }
30
31 public function rest_api_init() {
32 register_rest_route( $this->namespace, '/search', [
33 'methods' => 'POST',
34 'callback' => [ $this, 'rest_search' ],
35 'permission_callback' => '__return_true'
36 ] );
37 }
38
39 public function pre_get_posts( $query ) {
40 if ( is_admin() || !$query->is_main_query() || !$query->is_search() ) {
41 return;
42 }
43 $search = $query->get( 's' );
44 if ( empty( $search ) ) {
45 return;
46 }
47
48 // Get the frontend search method setting
49 $frontend_method = $this->core->get_option( 'search_frontend_method', 'wordpress' );
50
51 // If WordPress method is selected, do nothing (use default WordPress search)
52 if ( $frontend_method === 'wordpress' ) {
53 return;
54 }
55
56 // For Keywords method, use the full progressive search like admin
57 if ( $frontend_method === 'keywords' ) {
58 // Store the original search for later use
59 $query->set( 'mwai_original_search', $search );
60 // Set a unique search term that won't match anything - we'll handle this in the_posts filter
61 $query->set( 's', 'MWAI_KEYWORDS_SEARCH_' . md5( $search ) );
62 return;
63 }
64
65 // For Embeddings method, we need to handle this differently
66 // Since we can't easily replace WordPress search with embeddings in pre_get_posts,
67 // we'll modify the query to return no results and handle embeddings in template_redirect
68 if ( $frontend_method === 'embeddings' ) {
69 // Store the original search for later use
70 $query->set( 'mwai_original_search', $search );
71 // Set a unique search term that won't match anything
72 $query->set( 's', 'MWAI_EMBEDDINGS_SEARCH_' . md5( $search ) );
73 }
74 }
75
76 public function filter_embeddings_search_results( $posts, $query ) {
77 // Only handle main search queries with our special markers
78 if ( !$query->is_main_query() || !$query->is_search() || is_admin() ) {
79 return $posts;
80 }
81
82 $search_term = $query->get( 's' );
83 $original_search = $query->get( 'mwai_original_search' );
84
85 // Check if this is our embeddings or keywords search
86 if ( empty( $original_search ) ||
87 ( strpos( $search_term, 'MWAI_EMBEDDINGS_SEARCH_' ) !== 0 &&
88 strpos( $search_term, 'MWAI_KEYWORDS_SEARCH_' ) !== 0 ) ) {
89 return $posts;
90 }
91
92 // Handle Keywords search
93 if ( strpos( $search_term, 'MWAI_KEYWORDS_SEARCH_' ) === 0 ) {
94 return $this->handle_frontend_keywords_search( $original_search, $query );
95 }
96
97 // Handle Embeddings search (existing logic below)
98 if ( strpos( $search_term, 'MWAI_EMBEDDINGS_SEARCH_' ) !== 0 ) {
99 return $posts;
100 }
101
102 // Get the frontend search method to double-check
103 $frontend_method = $this->core->get_option( 'search_frontend_method', 'wordpress' );
104 if ( $frontend_method !== 'embeddings' ) {
105 return $posts;
106 }
107
108 // Get the embeddings environment ID
109 $env_id = $this->core->get_option( 'search_frontend_env_id', null );
110 if ( empty( $env_id ) ) {
111 return $posts; // No environment selected, return empty
112 }
113
114 try {
115 // Perform embeddings search
116 $embedding_result = $this->search_with_embeddings( $original_search, $env_id );
117
118 if ( isset( $embedding_result['error'] ) || empty( $embedding_result['post_ids'] ) ) {
119 return []; // Return empty array if search failed or no results
120 }
121
122 // Get the post IDs from embeddings results
123 $post_ids = array_map( function( $result ) {
124 return $result['id'];
125 }, $embedding_result['post_ids'] );
126
127 if ( empty( $post_ids ) ) {
128 return [];
129 }
130
131 // Get the actual post objects
132 $embeddings_posts = get_posts( [
133 'post__in' => $post_ids,
134 'orderby' => 'post__in',
135 'posts_per_page' => count( $post_ids ),
136 'post_type' => 'post',
137 'post_status' => 'publish'
138 ] );
139
140 // Update the query's found_posts count
141 $query->found_posts = count( $embeddings_posts );
142 $query->max_num_pages = 1;
143
144 return $embeddings_posts;
145
146 } catch ( Exception $e ) {
147 error_log( 'AI Engine Search: Frontend embeddings search failed - ' . $e->getMessage() );
148 return [];
149 }
150 }
151
152 private function handle_frontend_keywords_search( $original_search, $query ) {
153 // Get website context for keywords search
154 $website_context = $this->core->get_option( 'search_website_context', '' );
155
156 try {
157 // Use the same search logic as the admin REST API
158 $search_queries = $this->generate_keyword_tiers( $original_search, $website_context );
159 $keyword_result = $this->search_with_keywords( $search_queries );
160
161 if ( !empty( $keyword_result['results'] ) ) {
162 // Extract post IDs from results
163 $post_ids = array_map( function( $result ) {
164 return $result['id'];
165 }, $keyword_result['results'] );
166
167 // Get the actual post objects
168 $posts = get_posts( [
169 'post__in' => $post_ids,
170 'orderby' => 'post__in',
171 'posts_per_page' => count( $post_ids ),
172 'post_type' => 'post',
173 'post_status' => 'publish'
174 ] );
175
176 // Update the query's found_posts count
177 $query->found_posts = count( $posts );
178 $query->max_num_pages = 1;
179
180 return $posts;
181 }
182
183 // If no results with keywords, fallback to original search
184 $fallback_posts = get_posts( [
185 's' => $original_search,
186 'posts_per_page' => 20,
187 'post_type' => 'post',
188 'post_status' => 'publish'
189 ] );
190
191 $query->found_posts = count( $fallback_posts );
192 $query->max_num_pages = 1;
193
194 return $fallback_posts;
195
196 } catch ( Exception $e ) {
197 error_log( 'AI Engine Search: Frontend keywords search failed - ' . $e->getMessage() );
198
199 // Fallback to original search
200 $fallback_posts = get_posts( [
201 's' => $original_search,
202 'posts_per_page' => 20,
203 'post_type' => 'post',
204 'post_status' => 'publish'
205 ] );
206
207 return $fallback_posts;
208 }
209 }
210
211 private function get_site_context() {
212 // Get all categories
213 $categories = get_categories( [ 'hide_empty' => false ] );
214 $category_names = array_map( function( $cat ) {
215 return $cat->name;
216 }, $categories );
217
218 // Get all tags
219 $tags = get_tags( [ 'hide_empty' => false ] );
220 $tag_names = array_map( function( $tag ) {
221 return $tag->name;
222 }, $tags );
223
224 return [
225 'categories' => $category_names,
226 'tags' => $tag_names
227 ];
228 }
229
230 private function generate_keyword_tiers( $text, $website_context = '' ) {
231 $context = $this->get_site_context();
232
233 $message = "Generate 40 progressive search queries for: \"$text\"\n\n";
234
235 if ( !empty( $website_context ) ) {
236 $message .= "Website Context: " . $website_context . "\n\n";
237 }
238
239 if ( !empty( $context['categories'] ) ) {
240 $message .= "Site Categories: " . implode( ', ', array_slice( $context['categories'], 0, 20 ) ) . "\n";
241 }
242 if ( !empty( $context['tags'] ) ) {
243 $message .= "Site Tags: " . implode( ', ', array_slice( $context['tags'], 0, 20 ) ) . "\n";
244 }
245
246 $message .= "\nCreate 40 search queries optimized for WordPress search:\n";
247 $message .= "- WordPress searches for EXACT WORDS in post content, not concepts\n";
248 $message .= "- Use SIMPLE, COMMON words that authors actually write in posts\n";
249 $message .= "- Avoid complex phrases, technical terms, or descriptive adjectives\n";
250 $message .= "- Focus on core nouns, verbs, and basic adjectives\n";
251 $message .= "- Consider the website context and categories\n";
252 $message .= "- HIGH SCORES (100-70): 3 keywords that are really good matches\n";
253 $message .= "- MEDIUM SCORES (69-31): 2 keywords for broader matches\n";
254 $message .= "- LOW SCORES (30 and below): 1 keyword for broadest possible matches\n";
255 $message .= "- Each line must be unique\n";
256 $message .= "- Format exactly as: SCORE: keyword1 keyword2 keyword3\n";
257 $message .= "- Do NOT add any other text, bullets, or formatting\n\n";
258
259 $message .= "Example for 'funny adventure game' on a gaming website:\n";
260 $message .= "100: funny adventure game\n";
261 $message .= "97: adventure game comedy\n";
262 $message .= "94: comedy adventure game\n";
263 $message .= "91: funny game adventure\n";
264 $message .= "88: adventure comedy game\n";
265 $message .= "85: game adventure funny\n";
266 $message .= "82: humor adventure game\n";
267 $message .= "79: adventure game humor\n";
268 $message .= "76: funny adventure quest\n";
269 $message .= "73: comedy game adventure\n";
270 $message .= "70: adventure game fun\n";
271 $message .= "67: adventure game\n";
272 $message .= "64: funny game\n";
273 $message .= "61: comedy game\n";
274 $message .= "58: adventure comedy\n";
275 $message .= "55: game humor\n";
276 $message .= "52: funny adventure\n";
277 $message .= "49: adventure quest\n";
278 $message .= "46: game comedy\n";
279 $message .= "43: adventure story\n";
280 $message .= "40: game fun\n";
281 $message .= "37: funny story\n";
282 $message .= "34: comedy quest\n";
283 $message .= "31: adventure play\n";
284 $message .= "30: adventure\n";
285 $message .= "29: game\n";
286 $message .= "28: funny\n";
287 $message .= "27: comedy\n";
288 $message .= "26: quest\n";
289 $message .= "25: humor\n";
290 $message .= "24: story\n";
291 $message .= "23: play\n";
292 $message .= "22: fun\n";
293 $message .= "21: character\n";
294 $message .= "20: world\n";
295 $message .= "19: level\n";
296 $message .= "18: puzzle\n";
297 $message .= "17: island\n";
298 $message .= "16: pirate\n";
299 $message .= "15: monkey\n\n";
300
301 $message .= "IMPORTANT: Generate EXACTLY 40 queries following this format. Start at 100 and decrease by 2-3 points each time.\n";
302 $message .= "Now generate 40 queries for \"$text\":\n";
303
304 $query = new Meow_MWAI_Query_Text( $message );
305 $query->set_max_tokens( 2000 ); // Use max_tokens instead of max_results
306
307 try {
308 $reply = $this->core->run_query( $query );
309 if ( !empty( $reply->result ) ) {
310 $parsed = $this->parse_search_queries( $reply->result );
311 if ( $parsed !== null ) {
312 return $parsed;
313 }
314 }
315 }
316 catch ( Exception $e ) {
317 error_log( 'AI Engine Search: Failed to generate search queries - ' . $e->getMessage() );
318 }
319
320 // Fallback
321 return $this->fallback_keyword_tiers( $text );
322 }
323
324 private function parse_search_queries( $ai_response ) {
325 $searches = [];
326
327
328 $lines = explode( "\n", $ai_response );
329 foreach ( $lines as $line ) {
330 $line = trim( $line );
331 if ( empty( $line ) ) continue;
332
333 // Parse lines like "100: keyword1 keyword2 keyword3"
334 // Also handle lines that might start with a dash or bullet
335 $line = preg_replace( '/^[-•*]\s*/', '', $line );
336
337 if ( preg_match( '/^(\d+)\s*:\s*(.+)$/', $line, $matches ) ) {
338 $score = intval( $matches[1] );
339 $keywords = trim( $matches[2] );
340
341 if ( $score >= 0 && $score <= 100 && !empty( $keywords ) ) {
342 $searches[] = [
343 'score' => $score,
344 'keywords' => $keywords
345 ];
346 }
347 }
348 }
349
350 // If we got good results, return them
351 if ( count( $searches ) >= 5 ) {
352 // Sort by score descending
353 usort( $searches, function( $a, $b ) {
354 return $b['score'] <=> $a['score'];
355 } );
356 return $searches;
357 }
358
359 // Otherwise use fallback
360 return null;
361 }
362
363 private function fallback_keyword_tiers( $text ) {
364 // Extract meaningful words
365 $words = str_word_count( strtolower( $text ), 1 );
366
367 // Remove stop words
368 $stop_words = [ 'i', 'me', 'my', 'we', 'our', 'you', 'your', 'he', 'she', 'it', 'they',
369 'want', 'need', 'like', 'love', 'to', 'a', 'an', 'the', 'with', 'is',
370 'for', 'of', 'and', 'or', 'but', 'in', 'on', 'at', 'which', 'that' ];
371
372 $meaningful = array_diff( $words, $stop_words );
373 $meaningful = array_values( $meaningful );
374
375 // Synonyms for common terms - focused on actual search intent
376 $synonyms = [
377 'game' => ['games', 'gaming', 'play', 'gameplay'],
378 'space' => ['galaxy', 'universe', 'cosmic', 'stellar', 'sci-fi'],
379 'funny' => ['humor', 'comedy', 'fun', 'humorous'],
380 'adventure' => ['quest', 'journey', 'exploration', 'story'],
381 'huge' => ['large', 'big', 'massive', 'vast'],
382 'world' => ['universe', 'realm', 'map', 'environment']
383 ];
384
385 // Create 40 search queries directly for better fallback
386 $search_queries = [];
387 $score = 100;
388
389 // If we have no meaningful words, use the original text
390 if ( empty( $meaningful ) ) {
391 $meaningful = $words;
392 if ( empty( $meaningful ) ) {
393 // Last resort: split the original text
394 $meaningful = explode( ' ', $text );
395 }
396 }
397
398 // First batch: exact words from query (100-85)
399 if ( count( $meaningful ) >= 1 ) {
400 // 4-5 keywords
401 for ( $i = 0; $i < 5 && $score >= 85; $i++ ) {
402 $keywords = [];
403 shuffle( $meaningful );
404 $num_keywords = min( 4 + rand(0, 1), count( $meaningful ) );
405 $keywords = array_slice( $meaningful, 0, max( 1, $num_keywords ) );
406 if ( count( $keywords ) >= 1 ) {
407 $search_queries[] = [
408 'score' => $score,
409 'keywords' => implode( ' ', $keywords )
410 ];
411 $score -= 3;
412 }
413 }
414 }
415
416 // Second batch: mix exact with synonyms (84-60)
417 $all_related = $meaningful;
418 foreach ( $meaningful as $word ) {
419 if ( isset( $synonyms[$word] ) ) {
420 $all_related = array_merge( $all_related, array_slice( $synonyms[$word], 0, 2 ) );
421 }
422 }
423 $all_related = array_unique( $all_related );
424
425 for ( $i = 0; $i < 15 && $score >= 60; $i++ ) {
426 shuffle( $all_related );
427 $num_keywords = 3 + rand( 0, 1 );
428 $keywords = array_slice( $all_related, 0, min( $num_keywords, count( $all_related ) ) );
429 if ( count( $keywords ) >= 2 ) {
430 $search_queries[] = [
431 'score' => $score,
432 'keywords' => implode( ' ', $keywords )
433 ];
434 $score -= 2;
435 }
436 }
437
438 // Third batch: fewer keywords (59-30)
439 for ( $i = 0; $i < 20 && $score >= 30; $i++ ) {
440 shuffle( $all_related );
441 $keywords = array_slice( $all_related, 0, 2 );
442 if ( count( $keywords ) >= 2 ) {
443 $search_queries[] = [
444 'score' => $score,
445 'keywords' => implode( ' ', $keywords )
446 ];
447 $score -= 2;
448 }
449 }
450
451 // If we couldn't generate any searches, create at least one with the original text
452 if ( empty( $search_queries ) ) {
453 $search_queries[] = [
454 'score' => 100,
455 'keywords' => $text
456 ];
457 }
458
459 // Return search queries in same format as AI would generate
460 return array_slice( $search_queries, 0, 40 );
461 }
462
463 private function create_search_combinations( $searches, $max_searches = 40 ) {
464 // If we have AI-generated searches, use them directly
465 if ( is_array( $searches ) && !empty( $searches ) && isset( $searches[0] ) && isset( $searches[0]['keywords'] ) ) {
466 $combinations = [];
467 foreach ( $searches as $search ) {
468 // Skip empty keywords
469 if ( !empty( trim( $search['keywords'] ) ) ) {
470 $combinations[] = [
471 'keywords' => $search['keywords'],
472 'score' => $search['score'],
473 'strategy' => 'ai_generated'
474 ];
475 }
476 if ( count( $combinations ) >= $max_searches ) {
477 break;
478 }
479 }
480 // If we have some combinations, return them
481 if ( !empty( $combinations ) ) {
482 return $combinations;
483 }
484 }
485
486 // Otherwise, this is the fallback format, generate combinations
487 $tiers = $searches;
488 $combinations = [];
489 $exact = $tiers['exact'] ?? [];
490 $contextual = $tiers['contextual'] ?? [];
491 $general = $tiers['general'] ?? [];
492
493 // Simple fallback algorithm
494 $search_count = 0;
495
496 // Mix of different combinations - start at 100
497 $strategies = [
498 [ 'exact' => 4, 'score' => 100 ],
499 [ 'exact' => 3, 'score' => 90 ],
500 [ 'exact' => 2, 'contextual' => 2, 'score' => 80 ],
501 [ 'exact' => 2, 'score' => 70 ],
502 [ 'exact' => 1, 'contextual' => 2, 'score' => 60 ],
503 [ 'contextual' => 2, 'score' => 50 ],
504 [ 'exact' => 1, 'general' => 1, 'score' => 40 ],
505 [ 'general' => 2, 'score' => 35 ]
506 ];
507
508 foreach ( $strategies as $strategy ) {
509 for ( $i = 0; $i < 5 && $search_count < $max_searches; $i++ ) {
510 $keywords = [];
511
512 if ( isset( $strategy['exact'] ) && count( $exact ) >= $strategy['exact'] ) {
513 shuffle( $exact );
514 $keywords = array_merge( $keywords, array_slice( $exact, 0, $strategy['exact'] ) );
515 }
516
517 if ( isset( $strategy['contextual'] ) && count( $contextual ) >= $strategy['contextual'] ) {
518 shuffle( $contextual );
519 $keywords = array_merge( $keywords, array_slice( $contextual, 0, $strategy['contextual'] ) );
520 }
521
522 if ( isset( $strategy['general'] ) && count( $general ) >= $strategy['general'] ) {
523 shuffle( $general );
524 $keywords = array_merge( $keywords, array_slice( $general, 0, $strategy['general'] ) );
525 }
526
527 if ( count( $keywords ) >= 2 ) {
528 $keywords_str = implode( ' ', $keywords );
529 if ( !empty( trim( $keywords_str ) ) ) {
530 $combinations[] = [
531 'keywords' => $keywords_str,
532 'score' => $strategy['score'] + rand( -5, 5 ),
533 'strategy' => 'fallback'
534 ];
535 $search_count++;
536 }
537 }
538 }
539 }
540
541 // If we have no combinations, create at least one from whatever we have
542 if ( empty( $combinations ) ) {
543 // Try to create a basic search from exact keywords
544 if ( !empty( $exact ) ) {
545 $combinations[] = [
546 'keywords' => implode( ' ', array_slice( $exact, 0, 3 ) ),
547 'score' => 50,
548 'strategy' => 'fallback_emergency'
549 ];
550 }
551 // Or from the original text
552 else if ( is_string( $searches ) && !empty( trim( $searches ) ) ) {
553 $combinations[] = [
554 'keywords' => $searches, // This will be the original text in worst case
555 'score' => 30,
556 'strategy' => 'fallback_original'
557 ];
558 }
559 }
560
561 return array_slice( $combinations, 0, $max_searches );
562 }
563
564 private function get_combinations( $array, $length ) {
565 if ( $length == 1 ) {
566 return array_map( function( $el ) { return [ $el ]; }, $array );
567 }
568
569 $combinations = [];
570 $array_length = count( $array );
571
572 for ( $i = 0; $i < $array_length - $length + 1; $i++ ) {
573 $head = array_slice( $array, $i, 1 );
574 $tail_combinations = $this->get_combinations( array_slice( $array, $i + 1 ), $length - 1 );
575 foreach ( $tail_combinations as $tail ) {
576 $combinations[] = array_merge( $head, $tail );
577 }
578 }
579
580 return $combinations;
581 }
582
583 private function search_with_keywords( $search_queries ) {
584 $all_results = [];
585 $searches_performed = 0;
586 $max_searches = 40;
587 $min_results_needed = 3;
588
589 // Create search combinations with scores
590 $search_combinations = $this->create_search_combinations( $search_queries, $max_searches );
591
592 $debug_searches = [];
593
594 foreach ( $search_combinations as $combination ) {
595 $searches_performed++;
596 $keywords = $combination['keywords'];
597 $score = $combination['score'];
598 $strategy = $combination['strategy'];
599
600 // Record what we're searching
601 $debug_searches[] = [
602 'attempt' => $searches_performed,
603 'keywords' => $keywords,
604 'score' => $score,
605 'strategy' => $strategy,
606 'found' => 0
607 ];
608
609 // Perform the search
610 $posts = get_posts( [
611 's' => $keywords,
612 'posts_per_page' => 10,
613 'post_type' => 'post',
614 'post_status' => 'publish',
615 'fields' => 'ids'
616 ] );
617
618 if ( !empty( $posts ) ) {
619 // Update found count
620 $debug_searches[count($debug_searches) - 1]['found'] = count( $posts );
621
622 // Add to results with score
623 foreach ( $posts as $post_id ) {
624 if ( !isset( $all_results[$post_id] ) ) {
625 $all_results[$post_id] = [
626 'id' => $post_id,
627 'best_score' => $score,
628 'found_with' => []
629 ];
630 } else {
631 // Keep the best (highest) score
632 if ( $score > $all_results[$post_id]['best_score'] ) {
633 $all_results[$post_id]['best_score'] = $score;
634 }
635 }
636 $all_results[$post_id]['found_with'][] = [
637 'keywords' => $keywords,
638 'score' => $score
639 ];
640 }
641
642 // Stop if we have enough unique results
643 if ( count( $all_results ) >= $min_results_needed ) {
644 break;
645 }
646 }
647
648 // Stop if we've done too many searches
649 if ( $searches_performed >= $max_searches ) {
650 break;
651 }
652 }
653
654 // Sort results by score (highest first)
655 uasort( $all_results, function( $a, $b ) {
656 return $b['best_score'] <=> $a['best_score'];
657 } );
658
659 // Get full post data for results
660 $final_results = [];
661 $post_ids = array_keys( $all_results );
662
663 if ( !empty( $post_ids ) ) {
664 // Get posts but maintain our score order
665 $posts_data = [];
666 $posts = get_posts( [
667 'post__in' => $post_ids,
668 'posts_per_page' => 20,
669 'post_type' => 'post',
670 'post_status' => 'publish'
671 ] );
672
673 // Create a map for easy access
674 foreach ( $posts as $post ) {
675 $posts_data[$post->ID] = $post;
676 }
677
678 // Build results in score order
679 foreach ( $post_ids as $post_id ) {
680 if ( isset( $posts_data[$post_id] ) ) {
681 $post = $posts_data[$post_id];
682 $result_data = $all_results[$post_id];
683
684 // Get the keywords that found this post with the best score
685 $best_keywords = '';
686 foreach ( $result_data['found_with'] as $found ) {
687 if ( $found['score'] == $result_data['best_score'] ) {
688 $best_keywords = $found['keywords'];
689 break;
690 }
691 }
692
693 $final_results[] = [
694 'id' => $post->ID,
695 'title' => get_the_title( $post ),
696 'excerpt' => wp_trim_words( $post->post_content, 30 ),
697 'score' => $result_data['best_score'] / 100, // Convert 0-100 to 0-1 for frontend consistency
698 'found_with' => $best_keywords
699 ];
700 }
701 }
702 }
703
704 return [
705 'results' => $final_results,
706 'debug' => [
707 'total_searches' => $searches_performed,
708 'keyword_tiers' => is_array( $search_queries ) && isset( $search_queries['exact'] ) ? $search_queries : null,
709 'searches' => $debug_searches
710 ]
711 ];
712 }
713
714 private function search_with_embeddings( $search_text, $env_id = null ) {
715 if ( !class_exists( 'MeowPro_MWAI_Embeddings' ) ) {
716 return [ 'error' => 'Embeddings module not available' ];
717 }
718
719 // Validate environment exists
720 if ( !$env_id ) {
721 return [ 'error' => 'No embeddings environment selected' ];
722 }
723
724 $env = $this->core->get_embeddings_env( $env_id );
725 if ( !$env ) {
726 return [ 'error' => 'Invalid embeddings environment selected. Please select a valid environment.' ];
727 }
728
729 try {
730 // Get the embeddings instance
731 $embeddings = new MeowPro_MWAI_Embeddings( $this->core );
732
733 // Use the query_vectors method which handles everything internally
734 // Parameters: offset, limit, filters, sort
735 $filters = [
736 'envId' => $env_id,
737 'search' => $search_text
738 ];
739 $result = $embeddings->query_vectors( 0, 20, $filters );
740
741 $vectors = isset( $result['rows'] ) ? $result['rows'] : [];
742
743 if ( empty( $vectors ) ) {
744 return [
745 'post_ids' => [],
746 'debug' => [
747 'total_vectors' => 0,
748 'message' => 'No matching vectors found'
749 ]
750 ];
751 }
752
753 // Extract post IDs from results
754 $post_ids = [];
755 $debug_info = [];
756
757 foreach ( $vectors as $vector ) {
758 $debug_info[] = [
759 'refId' => $vector['refId'] ?? 'unknown',
760 'score' => $vector['score'] ?? 0,
761 'type' => $vector['type'] ?? 'unknown',
762 'title' => $vector['title'] ?? 'unknown'
763 ];
764
765 // Check if this is a post embedding
766 if ( !empty( $vector['type'] ) && $vector['type'] === 'postId' && !empty( $vector['refId'] ) ) {
767 $score = isset( $vector['score'] ) ? (float)$vector['score'] : 0;
768 $post_ids[] = [
769 'id' => (int)$vector['refId'],
770 'score' => $score
771 ];
772 }
773 }
774
775 // Sort by score descending
776 usort( $post_ids, function( $a, $b ) {
777 return $b['score'] <=> $a['score'];
778 } );
779
780 return [
781 'post_ids' => $post_ids,
782 'debug' => [
783 'total_vectors' => count( $vectors ),
784 'filtered_posts' => count( $post_ids ),
785 'sample_vectors' => array_slice( $debug_info, 0, 5 )
786 ]
787 ];
788 }
789 catch ( Exception $e ) {
790 error_log( 'AI Engine Search: Embeddings search failed - ' . $e->getMessage() );
791 return [ 'error' => 'Embeddings search failed: ' . $e->getMessage() ];
792 }
793 }
794
795 public function rest_search( $request ) {
796 $params = $request->get_json_params();
797 $search = isset( $params['search'] ) ? sanitize_text_field( $params['search'] ) : '';
798 $method = isset( $params['method'] ) ? $params['method'] : 'wordpress';
799 $env_id = isset( $params['envId'] ) ? $params['envId'] : null;
800 $website_context = isset( $params['websiteContext'] ) ? sanitize_text_field( $params['websiteContext'] ) : '';
801
802 if ( empty( $search ) ) {
803 return new WP_REST_Response( [ 'success' => false, 'message' => 'Empty search' ], 400 );
804 }
805
806 $results = [];
807 $debug_info = [];
808
809 if ( $method === 'wordpress' ) {
810 // Use standard WordPress search
811 $posts = get_posts( [
812 's' => $search,
813 'posts_per_page' => 20,
814 'post_type' => 'post',
815 'post_status' => 'publish'
816 ] );
817
818 foreach ( $posts as $post ) {
819 $results[] = [
820 'id' => $post->ID,
821 'title' => get_the_title( $post ),
822 'excerpt' => wp_trim_words( $post->post_content, 30 )
823 ];
824 }
825
826 $debug_info = [
827 'method' => 'Standard WordPress search',
828 'query' => $search,
829 'found' => count( $posts )
830 ];
831 }
832 elseif ( $method === 'embeddings' ) {
833 // Search using embeddings
834 $embedding_result = $this->search_with_embeddings( $search, $env_id );
835
836 if ( isset( $embedding_result['error'] ) ) {
837 return new WP_REST_Response( [
838 'success' => false,
839 'message' => $embedding_result['error'],
840 'debug' => $embedding_result['debug'] ?? null
841 ], 200 );
842 }
843
844 $debug_info = $embedding_result['debug'] ?? [];
845
846 if ( !empty( $embedding_result['post_ids'] ) ) {
847 $post_ids = array_map( function( $result ) {
848 return $result['id'];
849 }, $embedding_result['post_ids'] );
850
851 $posts = get_posts( [
852 'post__in' => $post_ids,
853 'orderby' => 'post__in',
854 'posts_per_page' => count( $post_ids ),
855 'post_type' => 'post',
856 'post_status' => 'publish'
857 ] );
858
859 // Map posts with their scores
860 $score_map = [];
861 foreach ( $embedding_result['post_ids'] as $result ) {
862 $score_map[$result['id']] = $result['score'];
863 }
864
865 foreach ( $posts as $post ) {
866 $results[] = [
867 'id' => $post->ID,
868 'title' => get_the_title( $post ),
869 'excerpt' => wp_trim_words( $post->post_content, 30 ),
870 'score' => $score_map[$post->ID] ?? 0
871 ];
872 }
873 }
874 } else {
875 // Search using AI keywords with smart algorithm
876 $search_queries = $this->generate_keyword_tiers( $search, $website_context );
877 $keyword_result = $this->search_with_keywords( $search_queries );
878
879 $results = $keyword_result['results'];
880 $debug_info = $keyword_result['debug'];
881 }
882
883 $response = [
884 'success' => true,
885 'results' => $results,
886 'method' => $method,
887 'debug' => $debug_info
888 ];
889
890 return new WP_REST_Response( $response, 200 );
891 }
892
893 public function customize_search_display( $search_query ) {
894 // Only customize on frontend search pages
895 if ( is_admin() || !is_search() ) {
896 return $search_query;
897 }
898
899 // Get the frontend search method setting
900 $frontend_method = $this->core->get_option( 'search_frontend_method', 'wordpress' );
901
902 // If using standard WordPress search, no customization needed
903 if ( $frontend_method === 'wordpress' ) {
904 return $search_query;
905 }
906
907 // Check if this was an AI-powered search by looking for our special markers
908 global $wp_query;
909 $current_search = $wp_query->get( 's' );
910 $original_search = $wp_query->get( 'mwai_original_search' );
911
912 // Check if current search is one of our AI search markers
913 $is_keywords_search = strpos( $search_query, 'MWAI_KEYWORDS_SEARCH_' ) === 0;
914 $is_embeddings_search = strpos( $search_query, 'MWAI_EMBEDDINGS_SEARCH_' ) === 0;
915
916 // If we have an original search stored and this is an AI search, just return the original search
917 if ( !empty( $original_search ) && ( $is_keywords_search || $is_embeddings_search ) ) {
918 return $original_search;
919 }
920
921 return $search_query;
922 }
923 }