api_key = get_option( 'ai_search_api_key', '' ); $this->similarity_threshold = get_option( 'ai_search_similarity_threshold', 0.5 ); $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->register_hooks(); // 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 add_action( 'save_post', [ $this, 'generate_embedding' ] ); 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' ] ); // 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' ] ); // 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 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 ( 'publish' !== $post->post_status ) { return; } // Sanitize post title and content $post_title = sanitize_text_field( $post->post_title ); $post_content = sanitize_textarea_field( $post->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 $content = $post_title . ' ' . $post_content . ' ' . $custom_fields_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 with field label if ( is_string( $field_value ) ) { $content .= ' ' . sanitize_text_field( $field_label ) . ': ' . sanitize_text_field( $field_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 ) ) { $content .= ' product_description: ' . sanitize_textarea_field( $desc ); } break; case 'product_short_description': $short_desc = $product->get_short_description(); if ( ! empty( $short_desc ) ) { $content .= ' product_short_description: ' . sanitize_textarea_field( $short_desc ); } break; case 'product_sku': $sku = $product->get_sku(); if ( ! empty( $sku ) ) { $content .= ' product_sku: ' . 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 .= ' product_categories: ' . 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 .= ' product_tags: ' . 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_label = wc_attribute_label( $attribute->get_name() ); $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 .= ' ' . $attribute_label . ': ' . 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']) ) { 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' => 'text-embedding-ada-002', ]), ]); 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' ); /** * 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 the query's post_type. * @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 ); $query_embedding = $this->get_embedding( $search_query ); if ( ! $query_embedding ) { return $posts; } $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']; } $similarity = $this->calculate_similarity( $query_embedding, $embedding ); if ( $similarity >= $this->similarity_threshold ) { $similarities[ $post->ID ] = $similarity; } } 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 ); } /** * 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 ) { // Strategy 1: If original WordPress search found results, use them if ( ! empty( $original_posts ) ) { // Add a transient to indicate fallback was used set_transient( 'ai_search_used_fallback', [ 'query' => $search_query, 'type' => 'wordpress_default', 'count' => count( $original_posts ) ], 300 ); // 5 minutes return $original_posts; } // Strategy 2: Try broader WordPress search with partial matching $broader_results = $this->perform_broader_search( $search_query, $post_type ); if ( ! empty( $broader_results ) ) { set_transient( 'ai_search_used_fallback', [ 'query' => $search_query, 'type' => 'broader_search', 'count' => count( $broader_results ) ], 300 ); return $broader_results; } // Strategy 3: Try searching posts without embeddings (not yet processed) $unprocessed_results = $this->search_unprocessed_posts( $search_query, $post_type ); if ( ! empty( $unprocessed_results ) ) { set_transient( 'ai_search_used_fallback', [ 'query' => $search_query, 'type' => 'unprocessed_posts', 'count' => count( $unprocessed_results ) ], 300 ); return $unprocessed_results; } // 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 $score_percent = 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'; } 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; } // Only add badge for AI search results if ( ! isset( self::$similarity_scores[ $post_id ] ) ) { return $title; } $icon_url = AI_SEARCH_URL . 'assets/icon.svg'; // 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', esc_attr( $tooltip ), esc_url( $icon_url ), esc_attr__( 'AI Search', 'ai-search' ), esc_html__( 'AI Search 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; } ?> 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();