api_key = get_option( 'ai_search_api_key', '' ); $this->similarity_threshold = get_option( 'ai_search_similarity_threshold', 0.65 ); $this->provider = get_option( 'ai_search_provider', 'ai_service' ); $this->service_token = get_option( 'ai_search_service_token', '' ); $this->service_client = new AI_Search_Service(); $this->hybrid_enabled = get_option( 'ai_search_hybrid_enabled', false ); $this->hybrid_balance = get_option( 'ai_search_hybrid_balance', 30 ); $this->register_hooks(); // Initialize REST API new AI_Search_REST_API(); // Initialize admin interface if in admin area if ( is_admin() ) { $this->admin_manager = new AI_Search_Admin_Manager(); } } /** * Get the singleton instance. * * @return AI_Search */ public static function get_instance() { if ( null === self::$instance ) { self::$instance = new self(); } return self::$instance; } /** * Get admin manager instance */ public function get_admin_manager() { return $this->admin_manager; } /** * Register hooks. */ private function register_hooks() { // Load text domain for translations add_action( 'init', [ $this, 'load_textdomain' ] ); // Core functionality hooks // Generate embedding automatically when post is published add_action( 'transition_post_status', [ $this, 'generate_embedding_on_publish' ], 10, 3 ); add_filter( 'posts_results', [ $this, 'filter_search_results' ], 10, 2 ); // Add similarity class to search results add_filter( 'post_class', [ $this, 'add_similarity_class' ], 10, 3 ); // Add AI Search badge to results add_filter( 'the_title', [ $this, 'add_ai_search_badge' ], 10, 2 ); add_action( 'wp_head', [ $this, 'add_ai_search_badge_styles' ] ); // Frontend threshold slider for admins add_action( 'wp_footer', [ $this, 'add_frontend_threshold_slider' ] ); // AJAX handlers add_action( 'wp_ajax_ai_search_clear_feedback', [ $this, 'clear_search_feedback' ] ); add_action( 'wp_ajax_nopriv_ai_search_clear_feedback', [ $this, 'clear_search_feedback' ] ); add_action( 'wp_ajax_ai_search_update_threshold', [ $this, 'ajax_update_threshold' ] ); // Admin hooks are now handled by AI_Search_Admin_Manager } /** * Load plugin text domain for translations. */ public function load_textdomain() { load_plugin_textdomain( 'ai-search', false, dirname( plugin_basename( __FILE__ ) ) . '/languages' ); } /** * Generate embedding when post transitions to publish status * * @param string $new_status New post status. * @param string $old_status Old post status. * @param WP_Post $post Post object. */ public function generate_embedding_on_publish( $new_status, $old_status, $post ) { // Only generate when transitioning TO publish status if ( 'publish' !== $new_status ) { return; } // Don't generate for revisions or auto-saves if ( wp_is_post_revision( $post->ID ) || wp_is_post_autosave( $post->ID ) ) { return; } // Generate the embedding $this->generate_embedding( $post->ID ); } /** * Generate embedding for a post and save it to post meta. * * @param int $post_id Post ID. */ public function generate_embedding( $post_id ) { if ( wp_is_post_revision( $post_id ) ) { return; } $post = get_post( $post_id ); if ( ! $post || 'publish' !== $post->post_status ) { return; } // Sanitize post title and content $post_title = sanitize_text_field( $post->post_title ); // Extract plain text from content (remove HTML, shortcodes, etc.) $post_content = wp_strip_all_tags( strip_shortcodes( $post->post_content ) ); // Remove extra whitespace and normalize $post_content = preg_replace( '/\s+/', ' ', trim( $post_content ) ); // Get custom fields for this post type $custom_fields_content = $this->get_custom_fields_content( $post_id, $post->post_type ); // Combine context with content for embedding and convert to lowercase for case-insensitive search $content = $post_title . ' ' . $post_content . ' ' . $custom_fields_content; $content = strtolower( $content ); $embedding = $this->get_embedding( $content ); if ( $embedding ) { update_post_meta( $post_id, '_ai_search_embedding', wp_json_encode( $embedding ) ); } } /** * Get custom fields content for a post. * * @param int $post_id Post ID. * @param string $post_type Post type. * * @return string Custom fields content. */ private function get_custom_fields_content( $post_id, $post_type ) { $content = ''; // Get selected custom fields for this post type $selected_fields = get_option( 'ai_search_custom_fields_' . $post_type, [] ); if ( empty( $selected_fields ) || ! is_array( $selected_fields ) ) { return $content; } // Get custom field values foreach ( $selected_fields as $field_key ) { $field_value = get_post_meta( $post_id, $field_key, true ); if ( ! empty( $field_value ) ) { // Get field label (support ACF if available) $field_label = $field_key; // Try to get ACF field label if ( function_exists( 'get_field_object' ) ) { $field_object = get_field_object( $field_key, $post_id ); if ( $field_object && isset( $field_object['label'] ) ) { $field_label = $field_object['label']; } } // Handle different field value types if ( is_array( $field_value ) ) { // Handle ACF relationship, post object, or other array fields $processed_values = []; foreach ( $field_value as $item ) { if ( is_object( $item ) && isset( $item->post_title ) ) { // ACF Post Object or Relationship field $processed_values[] = $item->post_title; } elseif ( is_string( $item ) ) { $processed_values[] = $item; } elseif ( is_array( $item ) && isset( $item['label'] ) ) { // ACF select/checkbox with labels $processed_values[] = $item['label']; } elseif ( is_array( $item ) && isset( $item['title'] ) ) { // ACF image/file field $processed_values[] = $item['title']; } } if ( ! empty( $processed_values ) ) { $field_value = implode( ' ', $processed_values ); } else { continue; // Skip if we couldn't extract meaningful data } } elseif ( is_object( $field_value ) ) { // Handle ACF User field or other object types if ( isset( $field_value->display_name ) ) { $field_value = $field_value->display_name; } elseif ( isset( $field_value->post_title ) ) { $field_value = $field_value->post_title; } else { continue; // Skip objects we can't process } } // Sanitize and add to content (without field label) if ( is_string( $field_value ) ) { // Strip HTML and shortcodes from field value $clean_value = wp_strip_all_tags( strip_shortcodes( $field_value ) ); $clean_value = preg_replace( '/\s+/', ' ', trim( $clean_value ) ); if ( ! empty( $clean_value ) ) { $content .= ' ' . $clean_value; } } } } // Handle WooCommerce products if applicable if ( $post_type === 'product' && class_exists( 'WooCommerce' ) ) { $woo_fields = get_option( 'ai_search_woocommerce_fields', [] ); if ( ! empty( $woo_fields ) && is_array( $woo_fields ) ) { $product = wc_get_product( $post_id ); if ( $product ) { foreach ( $woo_fields as $woo_field ) { switch ( $woo_field ) { case 'product_description': $desc = $product->get_description(); if ( ! empty( $desc ) ) { // Strip HTML and shortcodes from description $desc = wp_strip_all_tags( strip_shortcodes( $desc ) ); $desc = preg_replace( '/\s+/', ' ', trim( $desc ) ); if ( ! empty( $desc ) ) { $content .= ' ' . $desc; } } break; case 'product_short_description': $short_desc = $product->get_short_description(); if ( ! empty( $short_desc ) ) { // Strip HTML and shortcodes from short description $short_desc = wp_strip_all_tags( strip_shortcodes( $short_desc ) ); $short_desc = preg_replace( '/\s+/', ' ', trim( $short_desc ) ); if ( ! empty( $short_desc ) ) { $content .= ' ' . $short_desc; } } break; case 'product_sku': $sku = $product->get_sku(); if ( ! empty( $sku ) ) { $content .= ' ' . sanitize_text_field( $sku ); } break; case 'product_categories': $categories = wp_get_post_terms( $post_id, 'product_cat', [ 'fields' => 'names' ] ); if ( ! is_wp_error( $categories ) && ! empty( $categories ) ) { $content .= ' ' . implode( ' ', array_map( 'sanitize_text_field', $categories ) ); } break; case 'product_tags': $tags = wp_get_post_terms( $post_id, 'product_tag', [ 'fields' => 'names' ] ); if ( ! is_wp_error( $tags ) && ! empty( $tags ) ) { $content .= ' ' . implode( ' ', array_map( 'sanitize_text_field', $tags ) ); } break; } } // Handle product attributes $attributes = $product->get_attributes(); foreach ( $attributes as $attribute ) { $attribute_key = 'attribute_' . $attribute->get_name(); if ( in_array( $attribute_key, $woo_fields ) ) { $values = $attribute->get_options(); if ( ! empty( $values ) ) { $attribute_values = []; if ( is_array( $values ) ) { foreach ( $values as $value ) { $term = get_term( $value ); if ( $term && ! is_wp_error( $term ) ) { $attribute_values[] = sanitize_text_field( $term->name ); } } } else { $attribute_values[] = sanitize_text_field( $values ); } if ( ! empty( $attribute_values ) ) { $content .= ' ' . implode( ' ', $attribute_values ); } } } } } } } return trim( $content ); } /** * Get embedding from OpenAI. * * @param string $content Content to embed. * * @return array|false Embedding data or false on failure. */ private function get_embedding( $content ) { $content = sanitize_textarea_field( $content ); // Check for Cached Embedding $cached_embedding = get_transient( 'ai_search_embedding_' . md5( $content ) ); if ( $cached_embedding ) { return $cached_embedding; } if ( $this->provider === 'ai_service' ) { // Check if we have a service token first $service_token = get_option( 'ai_search_service_token', '' ); if ( empty( $service_token ) ) { return false; // No token available, service not registered } $embedding = $this->service_client->get_embedding( $content ); if ( $embedding && ! empty( $embedding['embedding'] ) ) { // Cache the embedding set_transient( 'ai_search_embedding_' . md5( $content ), $embedding['embedding'], MONTH_IN_SECONDS ); return $embedding['embedding']; } return false; // Service failed, don't fallback to OpenAI without user consent } $response = wp_remote_post( 'https://api.openai.com/v1/embeddings', [ 'headers' => [ 'Authorization' => 'Bearer ' . $this->api_key, 'Content-Type' => 'application/json', ], 'body' => wp_json_encode([ 'input' => $content, 'model' => get_option( 'ai_search_openai_model', 'text-embedding-3-small' ), ]), ]); if ( is_wp_error( $response ) ) { return false; } $body = json_decode( wp_remote_retrieve_body( $response ), true ); if ( ! isset( $body['data'][0]['embedding'] ) ) { return false; } // Cache the embedding for 1 day set_transient( 'ai_search_embedding_' . md5( $content ), $body['data'][0]['embedding'], MONTH_IN_SECONDS ); return $body['data'][0]['embedding']; } /** * Customize the search query. * * @param string $sql Original SQL query. * @param WP_Query $query WP_Query object. * * @return string Modified SQL query. */ public function filter_search_results( $posts, $query ) { if ( ! $query->is_search() || is_admin() ) { return $posts; } $post_type = $query->get( 'post_type' ); // If no post type is specified, search all public post types if ( empty( $post_type ) ) { $post_type = get_post_types( [ 'public' => true ], 'names' ); // Remove attachments from the list unset( $post_type['attachment'] ); } /** * Filter the post types that AI Search will search through. * * @since 1.9.2 * * @param string|array $post_type The post type(s) to search. Default is all public post types. * @param WP_Query $query The current WP_Query object. */ $post_type = apply_filters( 'ai_search_searchable_post_types', $post_type, $query ); $search_query = get_search_query(); $search_query = sanitize_text_field( $search_query ); $search_query = strtolower( $search_query ); $query_embedding = $this->get_embedding( $search_query ); if ( ! $query_embedding ) { return $posts; } // Allow admins to temporarily override threshold for testing $threshold = $this->similarity_threshold; if ( current_user_can( 'manage_options' ) && isset( $_GET['ai_threshold'] ) ) { $threshold = floatval( $_GET['ai_threshold'] ); $threshold = max( 0.1, min( 1.0, $threshold ) ); // Clamp between 0.5 and 1.0 } $similarities = []; $all_posts = get_posts([ 'numberposts' => -1, 'post_type' => $post_type, 'post_status' => 'publish', 'meta_key' => '_ai_search_embedding', ]); foreach ( $all_posts as $post ) { $embedding_json = get_post_meta( $post->ID, '_ai_search_embedding', true ); if ( empty( $embedding_json ) ) { continue; } $embedding = json_decode( $embedding_json, true ); if ( ! empty( $embedding['embedding'] ) ) { $embedding = $embedding['embedding']; } $semantic_score = $this->calculate_similarity( $query_embedding, $embedding ); // Calculate hybrid score if enabled if ( $this->hybrid_enabled ) { $keyword_score = $this->calculate_keyword_score( $post, $search_query ); $final_score = $this->calculate_hybrid_score( $semantic_score, $keyword_score ); } else { $final_score = $semantic_score; } if ( $final_score >= $threshold ) { $similarities[ $post->ID ] = $final_score; } } if ( empty( $similarities ) ) { // AI Search found no results, try enhanced fallback search return $this->enhanced_fallback_search( $search_query, $post_type, $posts ); } arsort( $similarities ); $ordered_ids = array_keys( $similarities ); // Store similarity scores for post_class filter self::$similarity_scores = $similarities; $posts = get_posts([ 'post_type' => $post_type, 'post_status' => 'publish', 'post__in' => $ordered_ids, 'orderby' => 'post__in', 'posts_per_page' => -1, ]); return $posts; } /** * Calculate cosine similarity. * * @param array $a First vector. * @param array $b Second vector. * * @return float Cosine similarity. */ private function calculate_similarity( $a, $b ) { if ( ! is_array( $a ) || ! is_array( $b ) || count( $a ) !== count( $b ) ) { return 0; } $dot_product = array_sum( array_map( function ( $x, $y ) { return $x * $y; }, $a, $b ) ); $magnitude_a = sqrt( array_sum( array_map( function ( $x ) { return $x ** 2; }, $a ) ) ); $magnitude_b = sqrt( array_sum( array_map( function ( $x ) { return $x ** 2; }, $b ) ) ); if ( $magnitude_a === 0 || $magnitude_b === 0 ) { return 0; } return $dot_product / ( $magnitude_a * $magnitude_b ); } /** * Calculate keyword match score for a post. * * @param WP_Post $post The post to score. * @param string $search_query The search query (already lowercase). * @return float Keyword match score (0-1). */ private function calculate_keyword_score( $post, $search_query ) { $score = 0.0; $search_words = array_filter( explode( ' ', $search_query ) ); if ( empty( $search_words ) ) { return 0.0; } $title = strtolower( $post->post_title ); $content = strtolower( wp_strip_all_tags( $post->post_content ) ); // Check each search word $title_matches = 0; $content_matches = 0; $custom_field_matches = 0; foreach ( $search_words as $word ) { if ( strlen( $word ) < 2 ) { continue; // Skip very short words } // Exact word match in title (highest priority) if ( preg_match( '/\b' . preg_quote( $word, '/' ) . '\b/i', $title ) ) { $title_matches++; } // Word match in content if ( preg_match( '/\b' . preg_quote( $word, '/' ) . '\b/i', $content ) ) { $content_matches++; } } $total_words = count( array_filter( $search_words, function( $w ) { return strlen( $w ) >= 2; } ) ); if ( $total_words === 0 ) { return 0.0; } // Calculate title score (0-1) $title_score = $title_matches / $total_words; // Calculate content score (0-1) $content_score = $content_matches / $total_words; // Check categories and tags $taxonomy_score = 0.0; $taxonomies = get_object_taxonomies( $post->post_type, 'names' ); foreach ( $taxonomies as $taxonomy ) { $terms = wp_get_post_terms( $post->ID, $taxonomy, [ 'fields' => 'names' ] ); if ( ! is_wp_error( $terms ) && ! empty( $terms ) ) { $terms_string = strtolower( implode( ' ', $terms ) ); foreach ( $search_words as $word ) { if ( strlen( $word ) >= 2 && strpos( $terms_string, $word ) !== false ) { $taxonomy_score = max( $taxonomy_score, 0.4 ); break 2; } } } } // Check custom fields configured in settings $custom_fields = get_option( 'ai_search_custom_fields_' . $post->post_type, [] ); $custom_field_content = ''; if ( ! empty( $custom_fields ) ) { foreach ( $custom_fields as $field_key ) { $field_value = get_post_meta( $post->ID, $field_key, true ); if ( ! empty( $field_value ) ) { if ( is_array( $field_value ) ) { $custom_field_content .= ' ' . implode( ' ', array_map( 'strval', $field_value ) ); } else { $custom_field_content .= ' ' . strval( $field_value ); } } } } // Check WooCommerce fields for products if ( $post->post_type === 'product' && class_exists( 'WooCommerce' ) ) { $woo_fields = get_option( 'ai_search_woocommerce_fields', [] ); $product = wc_get_product( $post->ID ); if ( $product ) { // SKU if ( in_array( 'sku', $woo_fields, true ) ) { $sku = $product->get_sku(); if ( ! empty( $sku ) ) { $custom_field_content .= ' ' . $sku; } } // Short description if ( in_array( 'short_description', $woo_fields, true ) ) { $short_desc = $product->get_short_description(); if ( ! empty( $short_desc ) ) { $custom_field_content .= ' ' . wp_strip_all_tags( $short_desc ); } } // Product attributes if ( in_array( 'attributes', $woo_fields, true ) ) { $attributes = $product->get_attributes(); if ( ! empty( $attributes ) ) { foreach ( $attributes as $attribute ) { if ( is_object( $attribute ) && method_exists( $attribute, 'get_options' ) ) { $options = $attribute->get_options(); if ( ! empty( $options ) ) { $custom_field_content .= ' ' . implode( ' ', $options ); } } } } } } } // Calculate custom field score $custom_field_score = 0.0; if ( ! empty( $custom_field_content ) ) { $custom_field_content = strtolower( $custom_field_content ); foreach ( $search_words as $word ) { if ( strlen( $word ) >= 2 && preg_match( '/\b' . preg_quote( $word, '/' ) . '\b/i', $custom_field_content ) ) { $custom_field_matches++; } } $custom_field_score = $custom_field_matches / $total_words; } // Weighted combination: title (1.0), content (0.5), taxonomy (0.4), custom fields (0.6) $score = ( $title_score * 1.0 ) + ( $content_score * 0.5 ) + $taxonomy_score + ( $custom_field_score * 0.6 ); // Normalize to 0-1 range (max possible is now 2.5: 1.0 + 0.5 + 0.4 + 0.6) $score = min( 1.0, $score / 2.5 ); return $score; } /** * Calculate hybrid score combining semantic and keyword scores. * * @param float $semantic_score Cosine similarity score (0-1). * @param float $keyword_score Keyword match score (0-1). * @return float Combined hybrid score (0-1). */ private function calculate_hybrid_score( $semantic_score, $keyword_score ) { if ( ! $this->hybrid_enabled ) { return $semantic_score; } // Balance: 0 = 100% semantic, 100 = 100% keyword $keyword_weight = $this->hybrid_balance / 100; $semantic_weight = 1 - $keyword_weight; return ( $semantic_score * $semantic_weight ) + ( $keyword_score * $keyword_weight ); } /** * Enhanced fallback search when AI search finds no results. * * @param string $search_query The original search query. * @param string $post_type The post type being searched. * @param array $original_posts Original WordPress search results. * @return array Enhanced search results or informative fallback. */ private function enhanced_fallback_search( $search_query, $post_type, $original_posts ) { // Get query embedding for similarity calculation $query_embedding = $this->get_embedding( $search_query ); // Get current threshold (with admin override support) $threshold = $this->similarity_threshold; if ( current_user_can( 'manage_options' ) && isset( $_GET['ai_threshold'] ) ) { $threshold = floatval( $_GET['ai_threshold'] ); $threshold = max( 0.1, min( 1.0, $threshold ) ); } // Strategy 1: If original WordPress search found results, use them if ( ! empty( $original_posts ) ) { $filtered_posts = []; // Calculate similarity scores for fallback results that have embeddings if ( $query_embedding ) { foreach ( $original_posts as $post ) { $embedding_json = get_post_meta( $post->ID, '_ai_search_embedding', true ); if ( ! empty( $embedding_json ) ) { $embedding = json_decode( $embedding_json, true ); if ( ! empty( $embedding['embedding'] ) ) { $embedding = $embedding['embedding']; } $semantic_score = $this->calculate_similarity( $query_embedding, $embedding ); // Calculate hybrid score if enabled if ( $this->hybrid_enabled ) { $keyword_score = $this->calculate_keyword_score( $post, $search_query ); $final_score = $this->calculate_hybrid_score( $semantic_score, $keyword_score ); } else { $final_score = $semantic_score; } self::$similarity_scores[ $post->ID ] = $final_score; // Only include posts that meet the threshold if ( $final_score >= $threshold ) { $filtered_posts[] = $post; } } else { // Post has no embedding, include it anyway (WordPress default behavior) $filtered_posts[] = $post; } } } else { $filtered_posts = $original_posts; } // If we have filtered results, return them if ( ! empty( $filtered_posts ) ) { // Add a transient to indicate fallback was used set_transient( 'ai_search_used_fallback', [ 'query' => $search_query, 'type' => 'wordpress_default', 'count' => count( $filtered_posts ) ], 300 ); // 5 minutes return $filtered_posts; } } // Strategy 2: Try broader WordPress search with partial matching $broader_results = $this->perform_broader_search( $search_query, $post_type ); if ( ! empty( $broader_results ) ) { $filtered_broader = []; // Calculate similarity scores for broader results that have embeddings if ( $query_embedding ) { foreach ( $broader_results as $post ) { $embedding_json = get_post_meta( $post->ID, '_ai_search_embedding', true ); if ( ! empty( $embedding_json ) ) { $embedding = json_decode( $embedding_json, true ); if ( ! empty( $embedding['embedding'] ) ) { $embedding = $embedding['embedding']; } $semantic_score = $this->calculate_similarity( $query_embedding, $embedding ); // Calculate hybrid score if enabled if ( $this->hybrid_enabled ) { $keyword_score = $this->calculate_keyword_score( $post, $search_query ); $final_score = $this->calculate_hybrid_score( $semantic_score, $keyword_score ); } else { $final_score = $semantic_score; } self::$similarity_scores[ $post->ID ] = $final_score; // Only include posts that meet the threshold if ( $final_score >= $threshold ) { $filtered_broader[] = $post; } } else { // Post has no embedding, include it anyway $filtered_broader[] = $post; } } } else { $filtered_broader = $broader_results; } // If we have filtered results, return them if ( ! empty( $filtered_broader ) ) { set_transient( 'ai_search_used_fallback', [ 'query' => $search_query, 'type' => 'broader_search', 'count' => count( $filtered_broader ) ], 300 ); return $filtered_broader; } } // Strategy 3: Try searching posts without embeddings (not yet processed) $unprocessed_results = $this->search_unprocessed_posts( $search_query, $post_type ); if ( ! empty( $unprocessed_results ) ) { $filtered_unprocessed = []; // Calculate similarity scores for unprocessed results that have embeddings if ( $query_embedding ) { foreach ( $unprocessed_results as $post ) { $embedding_json = get_post_meta( $post->ID, '_ai_search_embedding', true ); if ( ! empty( $embedding_json ) ) { $embedding = json_decode( $embedding_json, true ); if ( ! empty( $embedding['embedding'] ) ) { $embedding = $embedding['embedding']; } $semantic_score = $this->calculate_similarity( $query_embedding, $embedding ); // Calculate hybrid score if enabled if ( $this->hybrid_enabled ) { $keyword_score = $this->calculate_keyword_score( $post, $search_query ); $final_score = $this->calculate_hybrid_score( $semantic_score, $keyword_score ); } else { $final_score = $semantic_score; } self::$similarity_scores[ $post->ID ] = $final_score; // Only include posts that meet the threshold if ( $final_score >= $threshold ) { $filtered_unprocessed[] = $post; } } else { // Post has no embedding, include it anyway $filtered_unprocessed[] = $post; } } } else { $filtered_unprocessed = $unprocessed_results; } // If we have filtered results, return them if ( ! empty( $filtered_unprocessed ) ) { set_transient( 'ai_search_used_fallback', [ 'query' => $search_query, 'type' => 'unprocessed_posts', 'count' => count( $filtered_unprocessed ) ], 300 ); return $filtered_unprocessed; } } // Strategy 4: No results found anywhere, store info for user feedback set_transient( 'ai_search_no_results', [ 'query' => $search_query, 'suggestions' => $this->generate_search_suggestions( $search_query ), 'timestamp' => time() ], 300 ); // Return empty array - WordPress will show its "no results" page return []; } /** * Perform broader search with more flexible matching. * * @param string $search_query Search query. * @param string $post_type Post type to search. * @return array Found posts. */ private function perform_broader_search( $search_query, $post_type ) { // Split search query into individual words $search_words = explode( ' ', $search_query ); $search_words = array_filter( array_map( 'trim', $search_words ) ); if ( empty( $search_words ) ) { return []; } // Try searching for individual words (OR logic instead of AND) $args = [ 'post_type' => $post_type ?: 'any', 'post_status' => 'publish', 'posts_per_page' => 20, 's' => '', // We'll use meta_query for more flexible searching 'meta_query' => [ 'relation' => 'OR', ] ]; // Add word-based searches in title and content foreach ( $search_words as $word ) { if ( strlen( $word ) >= 3 ) { // Only search words with 3+ characters $word_results = get_posts([ 'post_type' => $post_type ?: 'any', 'post_status' => 'publish', 'posts_per_page' => 10, 's' => $word, ]); if ( ! empty( $word_results ) ) { return $word_results; } } } return []; } /** * Search posts that haven't been processed for AI embeddings yet. * * @param string $search_query Search query. * @param string $post_type Post type to search. * @return array Found posts. */ private function search_unprocessed_posts( $search_query, $post_type ) { $args = [ 'post_type' => $post_type ?: 'any', 'post_status' => 'publish', 'posts_per_page' => 15, 's' => $search_query, 'meta_query' => [ [ 'key' => '_ai_search_embedding', 'compare' => 'NOT EXISTS' ] ] ]; return get_posts( $args ); } /** * Generate search suggestions when no results are found. * * @param string $search_query Original search query. * @return array Suggested search terms. */ private function generate_search_suggestions( $search_query ) { $suggestions = []; // Get common terms from existing posts $recent_posts = get_posts([ 'posts_per_page' => 50, 'post_status' => 'publish' ]); $common_words = []; foreach ( $recent_posts as $post ) { $title_words = explode( ' ', strtolower( $post->post_title ) ); foreach ( $title_words as $word ) { $word = trim( $word, '.,!?;:"()[]' ); if ( strlen( $word ) >= 4 ) { $common_words[] = $word; } } } // Get most frequent words $word_counts = array_count_values( $common_words ); arsort( $word_counts ); $suggestions = array_slice( array_keys( $word_counts ), 0, 5 ); return $suggestions; } /** * Handle AJAX request to clear search feedback transients. */ public function clear_search_feedback() { // Verify nonce for security if ( ! wp_verify_nonce( $_POST['nonce'] ?? '', 'ai_search_clear_feedback' ) ) { wp_die( 'Security check failed' ); } // Clear the transients delete_transient( 'ai_search_used_fallback' ); delete_transient( 'ai_search_no_results' ); wp_die( 'success' ); } /** * Add similarity score as CSS class to search result posts. * * @param array $classes Existing post classes. * @param array $class Additional classes. * @param int $post_id Post ID. * @return array Modified classes. */ public function add_similarity_class( $classes, $class, $post_id ) { if ( ! is_search() || is_admin() ) { return $classes; } if ( isset( self::$similarity_scores[ $post_id ] ) ) { $score = self::$similarity_scores[ $post_id ]; // Convert to percentage (0-100) for CSS class, clamped to 0-100 range $score_percent = max( 0, min( 100, round( $score * 100 ) ) ); $classes[] = 'ai-search-result'; $classes[] = 'ai-search-similarity-' . $score_percent; // Add class based on threshold comparison if ( $score >= $this->similarity_threshold ) { $classes[] = 'ai-search-match'; } else { $classes[] = 'ai-search-below-threshold'; } } else { // Not an AI search result (fallback or no embedding) $classes[] = 'ai-search-fallback'; $classes[] = 'ai-search-similarity-0'; } return $classes; } /** * Add AI Search badge to post titles in search results. * * @param string $title The post title. * @param int $post_id The post ID. * @return string Modified title with badge. */ public function add_ai_search_badge( $title, $post_id = 0 ) { // Only on search pages, in the loop, on frontend if ( ! is_search() || is_admin() || ! in_the_loop() ) { return $title; } // Check visibility permission $badge_public = get_option( 'ai_search_badge_public', false ); if ( ! $badge_public && ! current_user_can( 'edit_posts' ) ) { return $title; } $icon_url = AI_SEARCH_URL . 'assets/icon.svg'; $is_ai_result = isset( self::$similarity_scores[ $post_id ] ); if ( $is_ai_result ) { // AI Search result with similarity score $score = self::$similarity_scores[ $post_id ]; // Clamp score percentage to 0-100 range $score_percent = max( 0, min( 100, round( $score * 100 ) ) ); // Show different tooltip based on visibility setting if ( $badge_public ) { $tooltip = __( 'This result was found using AI-powered semantic search.', 'ai-search' ); } else { $tooltip = __( 'This badge is only visible to editors and administrators.', 'ai-search' ); } $badge = sprintf( '%s%s %d%%', esc_attr( $tooltip ), esc_url( $icon_url ), esc_attr__( 'AI Search', 'ai-search' ), esc_html__( 'AI Search Result', 'ai-search' ), $score_percent ); } else { // Fallback result (no AI similarity score) if ( $badge_public ) { $tooltip = __( 'This result was found using fallback search (no AI embedding available).', 'ai-search' ); } else { $tooltip = __( 'Fallback result - This badge is only visible to editors and administrators.', 'ai-search' ); } $badge = sprintf( '%s%s', esc_attr( $tooltip ), esc_url( $icon_url ), esc_attr__( 'AI Search', 'ai-search' ), esc_html__( 'Fallback Result', 'ai-search' ) ); } return $badge . '
' . $title; } /** * Add CSS styles for AI Search badge. */ public function add_ai_search_badge_styles() { if ( ! is_search() ) { return; } // Check visibility permission $badge_public = get_option( 'ai_search_badge_public', false ); if ( ! $badge_public && ! current_user_can( 'edit_posts' ) ) { return; } ?> similarity_threshold; // Use override value if set if ( isset( $_GET['ai_threshold'] ) ) { $current_threshold = floatval( $_GET['ai_threshold'] ); $current_threshold = max( 0.1, min( 1.0, $current_threshold ) ); } $current_percent = round( $current_threshold * 100 ); $search_query = get_search_query(); ?>
AI Search Threshold
Current Value: %
10% - Very Broad 100% - Exact
Balanced Search
Good balance between precision and coverage.
'Unauthorized' ] ); } $threshold = isset( $_POST['threshold'] ) ? floatval( $_POST['threshold'] ) : 0.65; $threshold = max( 0.1, min( 1.0, $threshold ) ); update_option( 'ai_search_similarity_threshold', $threshold ); wp_send_json_success( [ 'threshold' => $threshold ] ); } } // Plugin activation hook register_activation_hook( __FILE__, 'ai_search_activate_plugin' ); /** * Plugin activation handler. */ function ai_search_activate_plugin() { // Set default settings add_option( 'ai_search_provider', 'ai_service' ); add_option( 'ai_search_similarity_threshold', 0.65 ); // Set flag to show setup wizard update_option( 'ai_search_show_setup_wizard', true ); // Register origin with AI Search Service immediately require_once plugin_dir_path( __FILE__ ) . 'includes/class-ai-search-service.php'; $service_client = new AI_Search_Service(); $service_token = $service_client->register_origin(); if ( $service_token ) { update_option( 'ai_search_service_token', $service_token ); } // Set activation redirect flag set_transient( 'ai_search_activation_redirect', true, 30 ); } // Handle activation redirect add_action( 'admin_init', 'ai_search_activation_redirect' ); /** * Redirect to setup wizard after plugin activation. */ function ai_search_activation_redirect() { // Check if we should redirect if ( ! get_transient( 'ai_search_activation_redirect' ) ) { return; } // Delete the transient so we don't redirect again delete_transient( 'ai_search_activation_redirect' ); // Don't redirect if activating multiple plugins or doing bulk action if ( isset( $_GET['activate-multi'] ) || ! current_user_can( 'manage_options' ) ) { return; } // Don't redirect if setup was already completed if ( get_option( 'ai_search_setup_completed', false ) ) { return; } // Redirect to setup wizard wp_safe_redirect( admin_url( 'options-general.php?page=ai-search&setup=wizard' ) ); exit; } // Initialize the plugin. AI_Search::get_instance();