PluginProbe ʕ •ᴥ•ʔ
AI Engine – The Chatbot, AI Framework & MCP for WordPress / 3.5.0
AI Engine – The Chatbot, AI Framework & MCP for WordPress v3.5.0
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 3 months ago chatbot.php 2 months ago discussions.php 2 months ago editor-assistant.php 3 months ago files.php 3 months ago forms-manager.php 3 months ago gdpr.php 4 months ago search.php 3 months ago security.php 11 months ago tasks-examples.php 6 months ago tasks.php 1 month ago wand.php 3 months ago
search.php
930 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 }
147 catch ( Exception $e ) {
148 error_log( 'AI Engine Search: Frontend embeddings search failed - ' . $e->getMessage() );
149 return [];
150 }
151 }
152
153 private function handle_frontend_keywords_search( $original_search, $query ) {
154 // Get website context for keywords search
155 $website_context = $this->core->get_option( 'search_website_context', '' );
156
157 try {
158 // Use the same search logic as the admin REST API
159 $search_queries = $this->generate_keyword_tiers( $original_search, $website_context );
160 $keyword_result = $this->search_with_keywords( $search_queries );
161
162 if ( !empty( $keyword_result['results'] ) ) {
163 // Extract post IDs from results
164 $post_ids = array_map( function ( $result ) {
165 return $result['id'];
166 }, $keyword_result['results'] );
167
168 // Get the actual post objects
169 $posts = get_posts( [
170 'post__in' => $post_ids,
171 'orderby' => 'post__in',
172 'posts_per_page' => count( $post_ids ),
173 'post_type' => 'post',
174 'post_status' => 'publish'
175 ] );
176
177 // Update the query's found_posts count
178 $query->found_posts = count( $posts );
179 $query->max_num_pages = 1;
180
181 return $posts;
182 }
183
184 // If no results with keywords, fallback to original search
185 $fallback_posts = get_posts( [
186 's' => $original_search,
187 'posts_per_page' => 20,
188 'post_type' => 'post',
189 'post_status' => 'publish'
190 ] );
191
192 $query->found_posts = count( $fallback_posts );
193 $query->max_num_pages = 1;
194
195 return $fallback_posts;
196
197 }
198 catch ( Exception $e ) {
199 error_log( 'AI Engine Search: Frontend keywords search failed - ' . $e->getMessage() );
200
201 // Fallback to original search
202 $fallback_posts = get_posts( [
203 's' => $original_search,
204 'posts_per_page' => 20,
205 'post_type' => 'post',
206 'post_status' => 'publish'
207 ] );
208
209 return $fallback_posts;
210 }
211 }
212
213 private function get_site_context() {
214 // Get all categories
215 $categories = get_categories( [ 'hide_empty' => false ] );
216 $category_names = array_map( function ( $cat ) {
217 return $cat->name;
218 }, $categories );
219
220 // Get all tags
221 $tags = get_tags( [ 'hide_empty' => false ] );
222 $tag_names = array_map( function ( $tag ) {
223 return $tag->name;
224 }, $tags );
225
226 return [
227 'categories' => $category_names,
228 'tags' => $tag_names
229 ];
230 }
231
232 private function generate_keyword_tiers( $text, $website_context = '' ) {
233 $context = $this->get_site_context();
234
235 $message = "Generate 40 progressive search queries for: \"$text\"\n\n";
236
237 if ( !empty( $website_context ) ) {
238 $message .= 'Website Context: ' . $website_context . "\n\n";
239 }
240
241 if ( !empty( $context['categories'] ) ) {
242 $message .= 'Site Categories: ' . implode( ', ', array_slice( $context['categories'], 0, 20 ) ) . "\n";
243 }
244 if ( !empty( $context['tags'] ) ) {
245 $message .= 'Site Tags: ' . implode( ', ', array_slice( $context['tags'], 0, 20 ) ) . "\n";
246 }
247
248 $message .= "\nCreate 40 search queries optimized for WordPress search:\n";
249 $message .= "- WordPress searches for EXACT WORDS in post content, not concepts\n";
250 $message .= "- Use SIMPLE, COMMON words that authors actually write in posts\n";
251 $message .= "- Avoid complex phrases, technical terms, or descriptive adjectives\n";
252 $message .= "- Focus on core nouns, verbs, and basic adjectives\n";
253 $message .= "- Consider the website context and categories\n";
254 $message .= "- HIGH SCORES (100-70): 3 keywords that are really good matches\n";
255 $message .= "- MEDIUM SCORES (69-31): 2 keywords for broader matches\n";
256 $message .= "- LOW SCORES (30 and below): 1 keyword for broadest possible matches\n";
257 $message .= "- Each line must be unique\n";
258 $message .= "- Format exactly as: SCORE: keyword1 keyword2 keyword3\n";
259 $message .= "- Do NOT add any other text, bullets, or formatting\n\n";
260
261 $message .= "Example for 'funny adventure game' on a gaming website:\n";
262 $message .= "100: funny adventure game\n";
263 $message .= "97: adventure game comedy\n";
264 $message .= "94: comedy adventure game\n";
265 $message .= "91: funny game adventure\n";
266 $message .= "88: adventure comedy game\n";
267 $message .= "85: game adventure funny\n";
268 $message .= "82: humor adventure game\n";
269 $message .= "79: adventure game humor\n";
270 $message .= "76: funny adventure quest\n";
271 $message .= "73: comedy game adventure\n";
272 $message .= "70: adventure game fun\n";
273 $message .= "67: adventure game\n";
274 $message .= "64: funny game\n";
275 $message .= "61: comedy game\n";
276 $message .= "58: adventure comedy\n";
277 $message .= "55: game humor\n";
278 $message .= "52: funny adventure\n";
279 $message .= "49: adventure quest\n";
280 $message .= "46: game comedy\n";
281 $message .= "43: adventure story\n";
282 $message .= "40: game fun\n";
283 $message .= "37: funny story\n";
284 $message .= "34: comedy quest\n";
285 $message .= "31: adventure play\n";
286 $message .= "30: adventure\n";
287 $message .= "29: game\n";
288 $message .= "28: funny\n";
289 $message .= "27: comedy\n";
290 $message .= "26: quest\n";
291 $message .= "25: humor\n";
292 $message .= "24: story\n";
293 $message .= "23: play\n";
294 $message .= "22: fun\n";
295 $message .= "21: character\n";
296 $message .= "20: world\n";
297 $message .= "19: level\n";
298 $message .= "18: puzzle\n";
299 $message .= "17: island\n";
300 $message .= "16: pirate\n";
301 $message .= "15: monkey\n\n";
302
303 $message .= "IMPORTANT: Generate EXACTLY 40 queries following this format. Start at 100 and decrease by 2-3 points each time.\n";
304 $message .= "Now generate 40 queries for \"$text\":\n";
305
306 $query = new Meow_MWAI_Query_Text( $message );
307 $query->set_scope( 'search' );
308 $query->set_max_tokens( 2000 ); // Use max_tokens instead of max_results
309
310 try {
311 $reply = $this->core->run_query( $query );
312 if ( !empty( $reply->result ) ) {
313 $parsed = $this->parse_search_queries( $reply->result );
314 if ( $parsed !== null ) {
315 return $parsed;
316 }
317 }
318 }
319 catch ( Exception $e ) {
320 error_log( 'AI Engine Search: Failed to generate search queries - ' . $e->getMessage() );
321 }
322
323 // Fallback
324 return $this->fallback_keyword_tiers( $text );
325 }
326
327 private function parse_search_queries( $ai_response ) {
328 $searches = [];
329
330 $lines = explode( "\n", $ai_response );
331 foreach ( $lines as $line ) {
332 $line = trim( $line );
333 if ( empty( $line ) ) {
334 continue;
335 }
336
337 // Parse lines like "100: keyword1 keyword2 keyword3"
338 // Also handle lines that might start with a dash or bullet
339 $line = preg_replace( '/^[-•*]\s*/', '', $line );
340
341 if ( preg_match( '/^(\d+)\s*:\s*(.+)$/', $line, $matches ) ) {
342 $score = intval( $matches[1] );
343 $keywords = trim( $matches[2] );
344
345 if ( $score >= 0 && $score <= 100 && !empty( $keywords ) ) {
346 $searches[] = [
347 'score' => $score,
348 'keywords' => $keywords
349 ];
350 }
351 }
352 }
353
354 // If we got good results, return them
355 if ( count( $searches ) >= 5 ) {
356 // Sort by score descending
357 usort( $searches, function ( $a, $b ) {
358 return $b['score'] <=> $a['score'];
359 } );
360 return $searches;
361 }
362
363 // Otherwise use fallback
364 return null;
365 }
366
367 private function fallback_keyword_tiers( $text ) {
368 // Extract meaningful words
369 $words = str_word_count( strtolower( $text ), 1 );
370
371 // Remove stop words
372 $stop_words = [ 'i', 'me', 'my', 'we', 'our', 'you', 'your', 'he', 'she', 'it', 'they',
373 'want', 'need', 'like', 'love', 'to', 'a', 'an', 'the', 'with', 'is',
374 'for', 'of', 'and', 'or', 'but', 'in', 'on', 'at', 'which', 'that' ];
375
376 $meaningful = array_diff( $words, $stop_words );
377 $meaningful = array_values( $meaningful );
378
379 // Synonyms for common terms - focused on actual search intent
380 $synonyms = [
381 'game' => ['games', 'gaming', 'play', 'gameplay'],
382 'space' => ['galaxy', 'universe', 'cosmic', 'stellar', 'sci-fi'],
383 'funny' => ['humor', 'comedy', 'fun', 'humorous'],
384 'adventure' => ['quest', 'journey', 'exploration', 'story'],
385 'huge' => ['large', 'big', 'massive', 'vast'],
386 'world' => ['universe', 'realm', 'map', 'environment']
387 ];
388
389 // Create 40 search queries directly for better fallback
390 $search_queries = [];
391 $score = 100;
392
393 // If we have no meaningful words, use the original text
394 if ( empty( $meaningful ) ) {
395 $meaningful = $words;
396 if ( empty( $meaningful ) ) {
397 // Last resort: split the original text
398 $meaningful = explode( ' ', $text );
399 }
400 }
401
402 // First batch: exact words from query (100-85)
403 if ( count( $meaningful ) >= 1 ) {
404 // 4-5 keywords
405 for ( $i = 0; $i < 5 && $score >= 85; $i++ ) {
406 $keywords = [];
407 shuffle( $meaningful );
408 $num_keywords = min( 4 + rand( 0, 1 ), count( $meaningful ) );
409 $keywords = array_slice( $meaningful, 0, max( 1, $num_keywords ) );
410 if ( count( $keywords ) >= 1 ) {
411 $search_queries[] = [
412 'score' => $score,
413 'keywords' => implode( ' ', $keywords )
414 ];
415 $score -= 3;
416 }
417 }
418 }
419
420 // Second batch: mix exact with synonyms (84-60)
421 $all_related = $meaningful;
422 foreach ( $meaningful as $word ) {
423 if ( isset( $synonyms[$word] ) ) {
424 $all_related = array_merge( $all_related, array_slice( $synonyms[$word], 0, 2 ) );
425 }
426 }
427 $all_related = array_unique( $all_related );
428
429 for ( $i = 0; $i < 15 && $score >= 60; $i++ ) {
430 shuffle( $all_related );
431 $num_keywords = 3 + rand( 0, 1 );
432 $keywords = array_slice( $all_related, 0, min( $num_keywords, count( $all_related ) ) );
433 if ( count( $keywords ) >= 2 ) {
434 $search_queries[] = [
435 'score' => $score,
436 'keywords' => implode( ' ', $keywords )
437 ];
438 $score -= 2;
439 }
440 }
441
442 // Third batch: fewer keywords (59-30)
443 for ( $i = 0; $i < 20 && $score >= 30; $i++ ) {
444 shuffle( $all_related );
445 $keywords = array_slice( $all_related, 0, 2 );
446 if ( count( $keywords ) >= 2 ) {
447 $search_queries[] = [
448 'score' => $score,
449 'keywords' => implode( ' ', $keywords )
450 ];
451 $score -= 2;
452 }
453 }
454
455 // If we couldn't generate any searches, create at least one with the original text
456 if ( empty( $search_queries ) ) {
457 $search_queries[] = [
458 'score' => 100,
459 'keywords' => $text
460 ];
461 }
462
463 // Return search queries in same format as AI would generate
464 return array_slice( $search_queries, 0, 40 );
465 }
466
467 private function create_search_combinations( $searches, $max_searches = 40 ) {
468 // If we have AI-generated searches, use them directly
469 if ( is_array( $searches ) && !empty( $searches ) && isset( $searches[0] ) && isset( $searches[0]['keywords'] ) ) {
470 $combinations = [];
471 foreach ( $searches as $search ) {
472 // Skip empty keywords
473 if ( !empty( trim( $search['keywords'] ) ) ) {
474 $combinations[] = [
475 'keywords' => $search['keywords'],
476 'score' => $search['score'],
477 'strategy' => 'ai_generated'
478 ];
479 }
480 if ( count( $combinations ) >= $max_searches ) {
481 break;
482 }
483 }
484 // If we have some combinations, return them
485 if ( !empty( $combinations ) ) {
486 return $combinations;
487 }
488 }
489
490 // Otherwise, this is the fallback format, generate combinations
491 $tiers = $searches;
492 $combinations = [];
493 $exact = $tiers['exact'] ?? [];
494 $contextual = $tiers['contextual'] ?? [];
495 $general = $tiers['general'] ?? [];
496
497 // Simple fallback algorithm
498 $search_count = 0;
499
500 // Mix of different combinations - start at 100
501 $strategies = [
502 [ 'exact' => 4, 'score' => 100 ],
503 [ 'exact' => 3, 'score' => 90 ],
504 [ 'exact' => 2, 'contextual' => 2, 'score' => 80 ],
505 [ 'exact' => 2, 'score' => 70 ],
506 [ 'exact' => 1, 'contextual' => 2, 'score' => 60 ],
507 [ 'contextual' => 2, 'score' => 50 ],
508 [ 'exact' => 1, 'general' => 1, 'score' => 40 ],
509 [ 'general' => 2, 'score' => 35 ]
510 ];
511
512 foreach ( $strategies as $strategy ) {
513 for ( $i = 0; $i < 5 && $search_count < $max_searches; $i++ ) {
514 $keywords = [];
515
516 if ( isset( $strategy['exact'] ) && count( $exact ) >= $strategy['exact'] ) {
517 shuffle( $exact );
518 $keywords = array_merge( $keywords, array_slice( $exact, 0, $strategy['exact'] ) );
519 }
520
521 if ( isset( $strategy['contextual'] ) && count( $contextual ) >= $strategy['contextual'] ) {
522 shuffle( $contextual );
523 $keywords = array_merge( $keywords, array_slice( $contextual, 0, $strategy['contextual'] ) );
524 }
525
526 if ( isset( $strategy['general'] ) && count( $general ) >= $strategy['general'] ) {
527 shuffle( $general );
528 $keywords = array_merge( $keywords, array_slice( $general, 0, $strategy['general'] ) );
529 }
530
531 if ( count( $keywords ) >= 2 ) {
532 $keywords_str = implode( ' ', $keywords );
533 if ( !empty( trim( $keywords_str ) ) ) {
534 $combinations[] = [
535 'keywords' => $keywords_str,
536 'score' => $strategy['score'] + rand( -5, 5 ),
537 'strategy' => 'fallback'
538 ];
539 $search_count++;
540 }
541 }
542 }
543 }
544
545 // If we have no combinations, create at least one from whatever we have
546 if ( empty( $combinations ) ) {
547 // Try to create a basic search from exact keywords
548 if ( !empty( $exact ) ) {
549 $combinations[] = [
550 'keywords' => implode( ' ', array_slice( $exact, 0, 3 ) ),
551 'score' => 50,
552 'strategy' => 'fallback_emergency'
553 ];
554 }
555 // Or from the original text
556 else if ( is_string( $searches ) && !empty( trim( $searches ) ) ) {
557 $combinations[] = [
558 'keywords' => $searches, // This will be the original text in worst case
559 'score' => 30,
560 'strategy' => 'fallback_original'
561 ];
562 }
563 }
564
565 return array_slice( $combinations, 0, $max_searches );
566 }
567
568 private function get_combinations( $array, $length ) {
569 if ( $length == 1 ) {
570 return array_map( function ( $el ) { return [ $el ]; }, $array );
571 }
572
573 $combinations = [];
574 $array_length = count( $array );
575
576 for ( $i = 0; $i < $array_length - $length + 1; $i++ ) {
577 $head = array_slice( $array, $i, 1 );
578 $tail_combinations = $this->get_combinations( array_slice( $array, $i + 1 ), $length - 1 );
579 foreach ( $tail_combinations as $tail ) {
580 $combinations[] = array_merge( $head, $tail );
581 }
582 }
583
584 return $combinations;
585 }
586
587 private function search_with_keywords( $search_queries ) {
588 $all_results = [];
589 $searches_performed = 0;
590 $max_searches = 40;
591 $min_results_needed = 3;
592
593 // Create search combinations with scores
594 $search_combinations = $this->create_search_combinations( $search_queries, $max_searches );
595
596 $debug_searches = [];
597
598 foreach ( $search_combinations as $combination ) {
599 $searches_performed++;
600 $keywords = $combination['keywords'];
601 $score = $combination['score'];
602 $strategy = $combination['strategy'];
603
604 // Record what we're searching
605 $debug_searches[] = [
606 'attempt' => $searches_performed,
607 'keywords' => $keywords,
608 'score' => $score,
609 'strategy' => $strategy,
610 'found' => 0
611 ];
612
613 // Perform the search
614 $posts = get_posts( [
615 's' => $keywords,
616 'posts_per_page' => 10,
617 'post_type' => 'post',
618 'post_status' => 'publish',
619 'fields' => 'ids'
620 ] );
621
622 if ( !empty( $posts ) ) {
623 // Update found count
624 $debug_searches[count( $debug_searches ) - 1]['found'] = count( $posts );
625
626 // Add to results with score
627 foreach ( $posts as $post_id ) {
628 if ( !isset( $all_results[$post_id] ) ) {
629 $all_results[$post_id] = [
630 'id' => $post_id,
631 'best_score' => $score,
632 'found_with' => []
633 ];
634 }
635 else {
636 // Keep the best (highest) score
637 if ( $score > $all_results[$post_id]['best_score'] ) {
638 $all_results[$post_id]['best_score'] = $score;
639 }
640 }
641 $all_results[$post_id]['found_with'][] = [
642 'keywords' => $keywords,
643 'score' => $score
644 ];
645 }
646
647 // Stop if we have enough unique results
648 if ( count( $all_results ) >= $min_results_needed ) {
649 break;
650 }
651 }
652
653 // Stop if we've done too many searches
654 if ( $searches_performed >= $max_searches ) {
655 break;
656 }
657 }
658
659 // Sort results by score (highest first)
660 uasort( $all_results, function ( $a, $b ) {
661 return $b['best_score'] <=> $a['best_score'];
662 } );
663
664 // Get full post data for results
665 $final_results = [];
666 $post_ids = array_keys( $all_results );
667
668 if ( !empty( $post_ids ) ) {
669 // Get posts but maintain our score order
670 $posts_data = [];
671 $posts = get_posts( [
672 'post__in' => $post_ids,
673 'posts_per_page' => 20,
674 'post_type' => 'post',
675 'post_status' => 'publish'
676 ] );
677
678 // Create a map for easy access
679 foreach ( $posts as $post ) {
680 $posts_data[$post->ID] = $post;
681 }
682
683 // Build results in score order
684 foreach ( $post_ids as $post_id ) {
685 if ( isset( $posts_data[$post_id] ) ) {
686 $post = $posts_data[$post_id];
687 $result_data = $all_results[$post_id];
688
689 // Get the keywords that found this post with the best score
690 $best_keywords = '';
691 foreach ( $result_data['found_with'] as $found ) {
692 if ( $found['score'] == $result_data['best_score'] ) {
693 $best_keywords = $found['keywords'];
694 break;
695 }
696 }
697
698 $final_results[] = [
699 'id' => $post->ID,
700 'title' => get_the_title( $post ),
701 'excerpt' => wp_trim_words( $post->post_content, 30 ),
702 'score' => $result_data['best_score'] / 100, // Convert 0-100 to 0-1 for frontend consistency
703 'found_with' => $best_keywords
704 ];
705 }
706 }
707 }
708
709 return [
710 'results' => $final_results,
711 'debug' => [
712 'total_searches' => $searches_performed,
713 'keyword_tiers' => is_array( $search_queries ) && isset( $search_queries['exact'] ) ? $search_queries : null,
714 'searches' => $debug_searches
715 ]
716 ];
717 }
718
719 private function search_with_embeddings( $search_text, $env_id = null ) {
720 if ( !class_exists( 'MeowPro_MWAI_Embeddings' ) ) {
721 return [ 'error' => 'Embeddings module not available' ];
722 }
723
724 // Validate environment exists
725 if ( !$env_id ) {
726 return [ 'error' => 'No embeddings environment selected' ];
727 }
728
729 $env = $this->core->get_embeddings_env( $env_id );
730 if ( !$env ) {
731 return [ 'error' => 'Invalid embeddings environment selected. Please select a valid environment.' ];
732 }
733
734 try {
735 // Get the embeddings instance
736 $embeddings = new MeowPro_MWAI_Embeddings( $this->core );
737
738 // Use the query_vectors method which handles everything internally
739 // Parameters: offset, limit, filters, sort
740 $filters = [
741 'envId' => $env_id,
742 'search' => $search_text
743 ];
744 $result = $embeddings->query_vectors( 0, 20, $filters );
745
746 $vectors = isset( $result['rows'] ) ? $result['rows'] : [];
747
748 if ( empty( $vectors ) ) {
749 return [
750 'post_ids' => [],
751 'debug' => [
752 'total_vectors' => 0,
753 'message' => 'No matching vectors found'
754 ]
755 ];
756 }
757
758 // Extract post IDs from results
759 $post_ids = [];
760 $debug_info = [];
761
762 foreach ( $vectors as $vector ) {
763 $debug_info[] = [
764 'refId' => $vector['refId'] ?? 'unknown',
765 'score' => $vector['score'] ?? 0,
766 'type' => $vector['type'] ?? 'unknown',
767 'title' => $vector['title'] ?? 'unknown'
768 ];
769
770 // Check if this is a post embedding
771 if ( !empty( $vector['type'] ) && $vector['type'] === 'postId' && !empty( $vector['refId'] ) ) {
772 $score = isset( $vector['score'] ) ? (float) $vector['score'] : 0;
773 $post_ids[] = [
774 'id' => (int) $vector['refId'],
775 'score' => $score
776 ];
777 }
778 }
779
780 // Sort by score descending
781 usort( $post_ids, function ( $a, $b ) {
782 return $b['score'] <=> $a['score'];
783 } );
784
785 return [
786 'post_ids' => $post_ids,
787 'debug' => [
788 'total_vectors' => count( $vectors ),
789 'filtered_posts' => count( $post_ids ),
790 'sample_vectors' => array_slice( $debug_info, 0, 5 )
791 ]
792 ];
793 }
794 catch ( Exception $e ) {
795 error_log( 'AI Engine Search: Embeddings search failed - ' . $e->getMessage() );
796 return [ 'error' => 'Embeddings search failed: ' . $e->getMessage() ];
797 }
798 }
799
800 public function rest_search( $request ) {
801 $params = $request->get_json_params();
802 $search = isset( $params['search'] ) ? sanitize_text_field( $params['search'] ) : '';
803 $method = isset( $params['method'] ) ? $params['method'] : 'wordpress';
804 $env_id = isset( $params['envId'] ) ? $params['envId'] : null;
805 $website_context = isset( $params['websiteContext'] ) ? sanitize_text_field( $params['websiteContext'] ) : '';
806
807 if ( empty( $search ) ) {
808 return new WP_REST_Response( [ 'success' => false, 'message' => 'Empty search' ], 400 );
809 }
810
811 $results = [];
812 $debug_info = [];
813
814 if ( $method === 'wordpress' ) {
815 // Use standard WordPress search
816 $posts = get_posts( [
817 's' => $search,
818 'posts_per_page' => 20,
819 'post_type' => 'post',
820 'post_status' => 'publish'
821 ] );
822
823 foreach ( $posts as $post ) {
824 $results[] = [
825 'id' => $post->ID,
826 'title' => get_the_title( $post ),
827 'excerpt' => wp_trim_words( $post->post_content, 30 )
828 ];
829 }
830
831 $debug_info = [
832 'method' => 'Standard WordPress search',
833 'query' => $search,
834 'found' => count( $posts )
835 ];
836 }
837 elseif ( $method === 'embeddings' ) {
838 // Search using embeddings
839 $embedding_result = $this->search_with_embeddings( $search, $env_id );
840
841 if ( isset( $embedding_result['error'] ) ) {
842 return new WP_REST_Response( [
843 'success' => false,
844 'message' => $embedding_result['error'],
845 'debug' => $embedding_result['debug'] ?? null
846 ], 200 );
847 }
848
849 $debug_info = $embedding_result['debug'] ?? [];
850
851 if ( !empty( $embedding_result['post_ids'] ) ) {
852 $post_ids = array_map( function ( $result ) {
853 return $result['id'];
854 }, $embedding_result['post_ids'] );
855
856 $posts = get_posts( [
857 'post__in' => $post_ids,
858 'orderby' => 'post__in',
859 'posts_per_page' => count( $post_ids ),
860 'post_type' => 'post',
861 'post_status' => 'publish'
862 ] );
863
864 // Map posts with their scores
865 $score_map = [];
866 foreach ( $embedding_result['post_ids'] as $result ) {
867 $score_map[$result['id']] = $result['score'];
868 }
869
870 foreach ( $posts as $post ) {
871 $results[] = [
872 'id' => $post->ID,
873 'title' => get_the_title( $post ),
874 'excerpt' => wp_trim_words( $post->post_content, 30 ),
875 'score' => $score_map[$post->ID] ?? 0
876 ];
877 }
878 }
879 }
880 else {
881 // Search using AI keywords with smart algorithm
882 $search_queries = $this->generate_keyword_tiers( $search, $website_context );
883 $keyword_result = $this->search_with_keywords( $search_queries );
884
885 $results = $keyword_result['results'];
886 $debug_info = $keyword_result['debug'];
887 }
888
889 $response = [
890 'success' => true,
891 'results' => $results,
892 'method' => $method,
893 'debug' => $debug_info
894 ];
895
896 return new WP_REST_Response( $response, 200 );
897 }
898
899 public function customize_search_display( $search_query ) {
900 // Only customize on frontend search pages
901 if ( is_admin() || !is_search() ) {
902 return $search_query;
903 }
904
905 // Get the frontend search method setting
906 $frontend_method = $this->core->get_option( 'search_frontend_method', 'wordpress' );
907
908 // If using standard WordPress search, no customization needed
909 if ( $frontend_method === 'wordpress' ) {
910 return $search_query;
911 }
912
913 // Check if this was an AI-powered search by looking for our special markers
914 global $wp_query;
915 $current_search = $wp_query->get( 's' );
916 $original_search = $wp_query->get( 'mwai_original_search' );
917
918 // Check if current search is one of our AI search markers
919 $is_keywords_search = strpos( $search_query, 'MWAI_KEYWORDS_SEARCH_' ) === 0;
920 $is_embeddings_search = strpos( $search_query, 'MWAI_EMBEDDINGS_SEARCH_' ) === 0;
921
922 // If we have an original search stored and this is an AI search, just return the original search
923 if ( !empty( $original_search ) && ( $is_keywords_search || $is_embeddings_search ) ) {
924 return $original_search;
925 }
926
927 return $search_query;
928 }
929 }
930