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',
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();