api_key = get_option( 'ai_search_api_key', '' );
$this->similarity_threshold = get_option( 'ai_search_similarity_threshold', 0.30 );
$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 );
// 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 ) );
}
// Check cache first
$cache_key = 'ai_search_results_' . md5( $search_query . serialize( $post_type ) . $threshold );
$cached_results = get_transient( $cache_key );
if ( $cached_results !== false ) {
self::$similarity_scores = $cached_results['scores'];
return $cached_results['posts'];
}
$query_embedding = $this->get_embedding( $search_query );
if ( ! $query_embedding ) {
return $posts;
}
// Process posts in batches for better memory management
$similarities = $this->process_posts_in_batches( $query_embedding, $search_query, $post_type, $threshold );
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,
]);
// Cache results for 5 minutes
set_transient( $cache_key, [
'posts' => $posts,
'scores' => $similarities,
], 5 * MINUTE_IN_SECONDS );
return $posts;
}
/**
* Process posts in batches to avoid memory issues on large sites.
*
* @param array $query_embedding The search query embedding.
* @param string $search_query The search query string.
* @param string|array $post_type Post types to search.
* @param float $threshold Similarity threshold.
* @return array Associative array of post IDs => similarity scores.
*/
private function process_posts_in_batches( $query_embedding, $search_query, $post_type, $threshold ) {
global $wpdb;
$similarities = [];
$offset = 0;
$total_processed = 0;
$max_posts = $this->max_posts_to_process;
$batch_size = $this->batch_size;
// Get total count of posts with embeddings
$post_type_sql = is_array( $post_type )
? "'" . implode( "','", array_map( 'esc_sql', $post_type ) ) . "'"
: "'" . esc_sql( $post_type ) . "'";
while ( $total_processed < $max_posts ) {
// Fetch batch of post IDs with embeddings
$batch_posts = $wpdb->get_results( $wpdb->prepare(
"SELECT p.ID, pm.meta_value as embedding_json
FROM {$wpdb->posts} p
INNER JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id
WHERE p.post_type IN ({$post_type_sql})
AND p.post_status = 'publish'
AND pm.meta_key = '_ai_search_embedding'
ORDER BY p.post_date DESC
LIMIT %d OFFSET %d",
$batch_size,
$offset
) );
if ( empty( $batch_posts ) ) {
break; // No more posts to process
}
foreach ( $batch_posts as $row ) {
$embedding = json_decode( $row->embedding_json, true );
if ( ! empty( $embedding['embedding'] ) ) {
$embedding = $embedding['embedding'];
}
if ( empty( $embedding ) || ! is_array( $embedding ) ) {
continue;
}
$semantic_score = $this->calculate_similarity( $query_embedding, $embedding );
// Calculate hybrid score if enabled
if ( $this->hybrid_enabled ) {
$post = get_post( $row->ID );
if ( $post ) {
$keyword_score = $this->calculate_keyword_score( $post, $search_query );
$final_score = $this->calculate_hybrid_score( $semantic_score, $keyword_score );
} else {
$final_score = $semantic_score;
}
} else {
$final_score = $semantic_score;
}
if ( $final_score >= $threshold ) {
$similarities[ $row->ID ] = $final_score;
}
$total_processed++;
}
$offset += $batch_size;
// Free memory
unset( $batch_posts );
}
return $similarities;
}
/**
* 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 ) ) );
// Loose comparison: sqrt() returns float 0.0, which is not === int 0.
// Strict comparison here would let a zero-magnitude vector through to
// the division below, causing a DivisionByZeroError on PHP 8+.
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 %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',
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();
?>