similarity-against-current-query (v1.26.0). * Reset at the start of each filter_search_results call so terms shared * across many posts in the same batch are scored exactly once. */ private $term_sim_cache = []; /** * Whether search logging is enabled (v1.31.0). */ private $logging_enabled; /** * Search to write on shutdown, or null. Collected during the search so * the request itself never waits on a database write (v1.31.0). * * @var array|null */ private $pending_log = null; /** * Details of the last embedding failure from get_embedding(), or null. * Shape: [ 'code' => string, 'message' => string, 'status' => int|null ]. * * @since 1.27.0 * @var array|null */ private $last_embedding_error = null; /** * Maximum posts to process per search (for performance on large sites). */ private $max_posts_to_process = 2000; /** * Batch size for processing posts (memory optimization). */ private $batch_size = 100; /** * Initialize the plugin. */ private function __construct() { $this->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(); // v1.26.0: per-term semantic embeddings. Default ON only on Woo stores. $default_term_enabled = class_exists( 'WooCommerce' ); $this->term_embeddings_enabled = (bool) get_option( 'ai_search_term_embeddings_enabled', $default_term_enabled ); $this->term_weight = (int) get_option( 'ai_search_term_weight', 20 ); $this->disabled_taxonomies = (array) get_option( 'ai_search_disabled_taxonomies', [] ); // v1.31.0: search logging (Insights tab). $this->logging_enabled = (bool) get_option( 'ai_search_logging_enabled', true ); $this->register_hooks(); // Initialize REST API new AI_Search_REST_API(); // v1.33.0: opt-in search box block. Registering it costs nothing // on sites that never place it - no scripts are enqueued unless // the block is on the page. add_action( 'init', [ $this, 'register_blocks' ] ); // Initialize admin interface if in admin area if ( is_admin() ) { $this->admin_manager = new AI_Search_Admin_Manager(); } } /** * Register the plugin's editor blocks. * * @since 1.33.0 */ public function register_blocks() { if ( ! function_exists( 'register_block_type' ) ) { return; } // The overlay renders the same result list as the box, so the box's // stylesheet is registered under its own handle and listed as a // second style on the button block. Duplicating the rules in both // files means fixing every dropdown tweak twice. wp_register_style( 'ai-search-box', AI_SEARCH_URL . 'blocks/search-box/style.css', [], self::VERSION ); register_block_type( AI_SEARCH_PATH . 'blocks/search-box' ); register_block_type( AI_SEARCH_PATH . 'blocks/search-button' ); } /** * 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 ); // v1.26.0: keep term embeddings in sync with taxonomy edits. add_action( 'created_term', [ $this, 'on_term_created' ], 10, 3 ); add_action( 'edited_term', [ $this, 'on_term_edited' ], 10, 3 ); add_action( 'delete_term', [ $this, 'on_term_deleted' ], 10, 4 ); // 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' ] ); // v1.31.0: search logging. The write happens on shutdown, after the // response has been sent, so a search never waits on the database. add_action( 'shutdown', [ $this, 'flush_search_log' ] ); add_action( 'plugins_loaded', [ $this, 'maybe_create_log_table' ] ); add_action( 'ai_search_purge_logs', [ $this, 'purge_old_logs' ] ); // 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. * @return true|WP_Error True when an embedding was stored; WP_Error * describing why not (since 1.27.0). Codes: * `revision`, `not_found`, `not_published`, * `skipped_by_filter`, `empty_content`, or any * provider code from get_last_embedding_error(). */ public function generate_embedding( $post_id ) { if ( wp_is_post_revision( $post_id ) ) { return new WP_Error( 'revision', __( 'Revisions are not embedded.', 'ai-search' ) ); } $post = get_post( $post_id ); if ( ! $post ) { return new WP_Error( 'not_found', __( 'Post not found.', 'ai-search' ) ); } if ( 'publish' !== $post->post_status ) { return new WP_Error( 'not_published', __( 'Only published posts are embedded (drafts, pending and private posts are skipped).', 'ai-search' ) ); } /** * Filter whether to generate an embedding for a given post. * * Return false to skip embedding generation — useful for excluding * drafts in certain categories, posts tagged "noindex", or posts * under a minimum word count. * * @since 1.25.0 * * @param bool $should_generate Whether to generate the embedding. Default true. * @param int $post_id Post ID. * @param WP_Post $post Post object. */ if ( ! apply_filters( 'ai_search_should_generate_embedding', true, $post_id, $post ) ) { return new WP_Error( 'skipped_by_filter', __( 'Skipped by the ai_search_should_generate_embedding filter.', 'ai-search' ) ); } // 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; /** * Filter the text that will be sent to the AI provider for embedding. * * Useful for injecting taxonomy names, SEO keywords, stripping * boilerplate, or normalizing terminology before the vector is * computed. * * @since 1.25.0 * * @param string $content The combined embedding content (title + body + custom fields). * @param int $post_id Post ID. * @param WP_Post $post Post object. */ $content = apply_filters( 'ai_search_embedding_content', $content, $post_id, $post ); $content = strtolower( $content ); if ( trim( $content ) === '' ) { return new WP_Error( 'empty_content', __( 'The post has no text to embed (empty title, content and custom fields).', 'ai-search' ) ); } $embedding = $this->get_embedding( $content ); if ( $embedding ) { update_post_meta( $post_id, '_ai_search_embedding', wp_json_encode( $embedding ) ); // v1.35.0: record which model produced this vector + when. // Inferred from the vector dimension (1536 = small, 3072 = large); // this is 100% reliable, no dependency on quota state. Used by the // post meta box display and by the "regenerate on current model" // bulk-generation option. $model = self::infer_embedding_model( $embedding ); if ( $model ) { update_post_meta( $post_id, '_ai_search_embedding_model', $model ); } else { delete_post_meta( $post_id, '_ai_search_embedding_model' ); } update_post_meta( $post_id, '_ai_search_embedding_generated_at', time() ); return true; } $error = $this->get_last_embedding_error(); return new WP_Error( $error['code'] ?? 'provider_error', $error['message'] ?? __( 'The AI provider did not return an embedding.', 'ai-search' ), [ 'status' => $error['status'] ?? null ] ); } /** * Infer which embedding model produced a vector by its dimension count. * * OpenAI v3 embeddings: 1536 dims = text-embedding-3-small, * 3072 dims = text-embedding-3-large. Any other length is treated as * unknown and returns null. Doing this from the vector length is 100% * reliable, whereas resolving from the plan/quota state would be stale * for a post that was embedded before an upgrade or during a network * blip. * * @since 1.35.0 * * @param array|mixed $embedding The stored embedding vector (array of floats). * @return string|null 'text-embedding-3-small' | 'text-embedding-3-large' | null. */ public static function infer_embedding_model( $embedding ) { $dims = is_array( $embedding ) ? count( $embedding ) : 0; if ( $dims === 1536 ) { return 'text-embedding-3-small'; } if ( $dims === 3072 ) { return 'text-embedding-3-large'; } return null; } /** * Details of the last get_embedding() failure. * * @since 1.27.0 * * @return array|null [ 'code', 'message', 'status' ] or null if the last call succeeded. */ public function get_last_embedding_error() { return $this->last_embedding_error; } /** * Record an embedding failure for callers to surface. * * @since 1.27.0 */ private function set_embedding_error( $code, $message, $status = null ) { $this->last_embedding_error = [ 'code' => (string) $code, 'message' => (string) $message, 'status' => $status !== null ? (int) $status : null, ]; } /** * Generate an embedding for a taxonomy term and save it as term meta. * * Term embeddings let the search compare the query against each term * individually (e.g. the "Sneakers" tag's own vector) instead of only * the post's bulk content embedding — meaning a query for "trainers" * still surfaces products tagged "Sneakers". * * @since 1.26.0 * * @param int $term_id Term ID. * @param string $taxonomy Taxonomy slug. * @return bool True on success, false if disabled / skipped / failed. */ public function generate_term_embedding( $term_id, $taxonomy ) { if ( ! $this->term_embeddings_enabled ) { return false; } if ( in_array( $taxonomy, $this->disabled_taxonomies, true ) ) { return false; } $term = get_term( $term_id, $taxonomy ); if ( ! $term || is_wp_error( $term ) ) { return false; } $content = $this->get_term_embedding_content( $term ); if ( '' === $content ) { return false; } $embedding = $this->get_embedding( $content ); if ( ! $embedding ) { return false; } update_term_meta( $term_id, '_ai_search_term_embedding', wp_json_encode( $embedding ) ); return true; } /** * Build the text that gets embedded for a taxonomy term. * * Includes the taxonomy label as a hint so cross-taxonomy collisions * (e.g. a "Red" category vs. a "Red" attribute) get disambiguated in * the vector space. * * @since 1.26.0 * * @param WP_Term $term Term object. * @return string Text fed to the embedding provider. */ private function get_term_embedding_content( $term ) { $taxonomy_obj = get_taxonomy( $term->taxonomy ); $tax_label = $taxonomy_obj ? $taxonomy_obj->labels->singular_name : $term->taxonomy; $content = $tax_label . ': ' . $term->name; $description = trim( wp_strip_all_tags( $term->description ) ); if ( '' !== $description ) { $content .= ' — ' . $description; } /** * Filter the text fed to the AI provider for a term embedding. * * Parallel to `ai_search_embedding_content` for posts. Useful for * injecting synonym hints, parent-term names, or stripping * boilerplate from term descriptions. * * @since 1.26.0 * * @param string $content The default content (": — "). * @param WP_Term $term The term being embedded. * @param string $taxonomy Taxonomy slug. */ $content = apply_filters( 'ai_search_term_embedding_content', $content, $term, $term->taxonomy ); return strtolower( trim( $content ) ); } /** * Hook callback: generate an embedding when a new term is created. * * @since 1.26.0 */ public function on_term_created( $term_id, $tt_id, $taxonomy ) { $this->generate_term_embedding( $term_id, $taxonomy ); } /** * Hook callback: regenerate the embedding when a term is renamed or * its description changes. * * @since 1.26.0 */ public function on_term_edited( $term_id, $tt_id, $taxonomy ) { $this->generate_term_embedding( $term_id, $taxonomy ); } /** * Hook callback: drop the stored embedding when a term is deleted. * * @since 1.26.0 */ public function on_term_deleted( $term, $tt_id, $taxonomy, $deleted_term ) { // $term here is the term_id (per `delete_term` action signature). delete_term_meta( (int) $term, '_ai_search_term_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', self::default_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 ) { $this->last_embedding_error = null; $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 ) ) { $this->set_embedding_error( 'no_token', __( 'The site is not connected to the AI Search Service (no token). Open the General tab and run the connection check.', 'ai-search' ) ); 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']; } $svc_error = method_exists( $this->service_client, 'get_last_error' ) ? $this->service_client->get_last_error() : null; $this->set_embedding_error( $svc_error['code'] ?? 'service_error', $svc_error['message'] ?? __( 'The AI Search Service did not return an embedding.', 'ai-search' ), $svc_error['status'] ?? null ); return false; // Service failed, don't fallback to OpenAI without user consent } if ( empty( $this->api_key ) ) { $this->set_embedding_error( 'no_api_key', __( 'No OpenAI API key configured. Add one on the General tab.', 'ai-search' ) ); return false; } $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 ) ) { $this->set_embedding_error( 'http_error', sprintf( /* translators: %s: transport error message (e.g. cURL error 28) */ __( 'Could not reach OpenAI: %s', 'ai-search' ), $response->get_error_message() ) ); return false; } $status = (int) wp_remote_retrieve_response_code( $response ); $body = json_decode( wp_remote_retrieve_body( $response ), true ); if ( ! isset( $body['data'][0]['embedding'] ) ) { $detail = isset( $body['error']['message'] ) && is_string( $body['error']['message'] ) ? $body['error']['message'] : ''; $code = 'provider_error'; if ( $status === 401 ) { $code = 'invalid_api_key'; } elseif ( $status === 429 ) { $code = 'quota_exceeded'; } $this->set_embedding_error( $code, $detail !== '' /* translators: 1: HTTP status, 2: error detail from OpenAI */ ? sprintf( __( 'OpenAI returned an error (HTTP %1$d: %2$s)', 'ai-search' ), $status, $detail ) /* translators: %d: HTTP status */ : sprintf( __( 'OpenAI returned an unexpected response (HTTP %d)', 'ai-search' ), $status ), $status ); 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 — or WordPress resolved it to the // literal 'any', which it does for every site-wide search — expand // to all public post types not flagged exclude_from_search (same // rule core applies to `post_type => any`). Before 1.27.0 the // string 'any' leaked into `post_type IN ('any')` and site-wide // searches silently skipped the AI ranking. if ( empty( $post_type ) || $post_type === 'any' || ( is_array( $post_type ) && in_array( 'any', $post_type, true ) ) ) { $post_type = get_post_types( [ 'public' => true, 'exclude_from_search' => false ], 'names' ); // Remove attachments from the list unset( $post_type['attachment'] ); $post_type = array_values( $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 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 ); // Read the searched terms from the query being filtered, not the // global one: secondary WP_Query instances with their own `s` // (blocks, widgets, AJAX handlers) carry the right terms here, // while get_search_query() answers for the main query - empty on // regular pages, or someone else's terms on a search page (v1.28.1). $raw_search = (string) $query->get( 's' ); if ( '' === $raw_search ) { $raw_search = (string) get_search_query( false ); } $search_query = sanitize_text_field( $raw_search ); $search_query = strtolower( $search_query ); // The raw (unescaped, unfiltered) query is what we compare against // SKUs and titles - entity escaping would break "Tom & Jerry", and // synonym expansion via the filter below must never touch a literal // SKU (v1.27.0). $exact_query = self::normalize_exact_query( $raw_search ); /** * Filter the search query before it is sent for embedding. * * Useful for synonym expansion ("tshirt" → "tshirt t-shirt tee"), * spell correction, locale-specific normalization, or stopword * removal. * * @since 1.25.0 * * @param string $search_query The sanitized, lowercased query. * @param WP_Query $query The current WP_Query object. */ $search_query = apply_filters( 'ai_search_query_before_embedding', $search_query, $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 ) ); } /** * Filter the similarity threshold for the current search. * * Useful for per-query or per-post-type overrides — e.g. a stricter * threshold on product searches where precision matters, or looser * on long-tail blog queries. * * @since 1.25.0 * * @param float $threshold The threshold (0.1 – 1.0). * @param string $search_query The (possibly filtered) search query. * @param string|array $post_type The post type(s) being searched. */ $threshold = apply_filters( 'ai_search_similarity_threshold', $threshold, $search_query, $post_type ); // Check cache first. Term-embedding settings are baked into the // key so toggling them invalidates the cache without explicit // cleanup (v1.26.0). $cache_key = 'ai_search_results_' . md5( $search_query . serialize( $post_type ) . $threshold . '|t:' . ( $this->term_embeddings_enabled ? '1' : '0' ) . '|w:' . $this->term_weight ); $cached_results = get_transient( $cache_key ); if ( $cached_results !== false ) { self::$similarity_scores = $cached_results['scores']; self::$exact_match_ids = isset( $cached_results['exact'] ) ? (array) $cached_results['exact'] : []; self::$lexical_match_ids = isset( $cached_results['lexical'] ) ? (array) $cached_results['lexical'] : []; return $this->sync_query_counts( $query, $cached_results['posts'] ); } // Tier 0: exact SKU / title matches. Always on, never below // threshold, always ahead of the AI ranking (v1.27.0). $exact_ids = $this->find_exact_matches( $exact_query, $post_type ); self::$exact_match_ids = $exact_ids; $exact_scores = array_fill_keys( $exact_ids, 1.0 ); // Tier 1 (v1.28.0): WordPress' own lexical results are never // discarded. A post that literally contains the searched words // outranks any semantic guess — the AI's job is to ADD results // below these, not to replace them. Ordered by keyword score // (title > content > taxonomies > custom fields), not post date. $lexical_scores = []; foreach ( $this->remove_hidden_from_search( (array) $posts ) as $lex_post ) { if ( isset( $exact_scores[ $lex_post->ID ] ) ) { continue; // Already pinned harder in Tier 0. } $lexical_scores[ $lex_post->ID ] = $this->calculate_keyword_score( $lex_post, $search_query ); } arsort( $lexical_scores ); self::$lexical_match_ids = array_keys( $lexical_scores ); $query_embedding = $this->get_embedding( $search_query ); if ( ! $query_embedding ) { // Provider unavailable — still honour exact matches ahead of // whatever WordPress found on its own. if ( ! empty( $exact_ids ) ) { self::$similarity_scores = $exact_scores; return $this->sync_query_counts( $query, $this->prepend_posts_by_id( $exact_ids, $posts, $post_type ) ); } return $this->sync_query_counts( $query, $this->remove_hidden_from_search( $posts ) ); } // Reset per-request term similarity cache for this query. $this->term_sim_cache = []; // Phase one: score posts that have their own content embedding. $similarities = $this->process_posts_in_batches( $query_embedding, $search_query, $post_type, $threshold ); // Phase two (v1.30.0): posts reachable only through a matching // taxonomy term. Union with phase one; term-only hits never lower // a score content matching already earned. $term_candidates = $this->find_posts_by_term_match( $query_embedding, $post_type ); if ( ! empty( $term_candidates ) ) { $term_candidates = array_filter( $term_candidates, function ( $score ) use ( $threshold ) { return $score >= $threshold; } ); if ( ! empty( $term_candidates ) ) { $similarities = self::merge_term_candidates( $similarities, $term_candidates ); } } if ( empty( $similarities ) && empty( $exact_ids ) && empty( $lexical_scores ) ) { // Nothing lexical, nothing semantic — try the fallback tiers. return $this->sync_query_counts( $query, $this->enhanced_fallback_search( $search_query, $post_type, $posts ) ); } arsort( $similarities ); // Keep the pre-merge semantic ids so logging can tell what the AI // tier actually contributed (v1.31.0). $semantic_ids = array_keys( $similarities ); // Strict tier order via array union (left-hand keys and order win): // Tier 0 exact (1.0) → Tier 1 lexical (keyword score) → Tier 2 // semantic extras. A post present in more than one tier appears // once, in its highest tier. $similarities = self::merge_tiers( $exact_scores, $lexical_scores, $similarities ); $ordered_ids = array_keys( $similarities ); // Store similarity scores for post_class filter self::$similarity_scores = $similarities; $posts = get_posts( $this->apply_search_visibility_args( [ 'post_type' => $post_type, 'post_status' => 'publish', 'post__in' => $ordered_ids, 'orderby' => 'post__in', 'posts_per_page' => -1, ] ) ); /** * Filter the final ordered search results before they are cached * and returned to WordPress. * * Useful for boosting in-stock products, demoting old posts, hiding * content the current user can't view, or injecting pinned results. * * Note: the filtered result is what gets cached for the 5-minute * transient window. If you need per-user logic, branch inside the * callback on `get_current_user_id()` so the cached value remains * neutral. * * @since 1.25.0 * * @param WP_Post[] $posts The ordered result posts. * @param string $search_query The (possibly filtered) search query. * @param array $similarities Map of post ID => similarity score, sorted desc. */ $posts = apply_filters( 'ai_search_results', $posts, $search_query, $similarities ); // Cache results for 5 minutes set_transient( $cache_key, [ 'posts' => $posts, 'scores' => $similarities, 'exact' => $exact_ids, 'lexical' => self::$lexical_match_ids, ], 5 * MINUTE_IN_SECONDS ); // v1.31.0: remember this search for the Insights tab, but only when // the AI tier surfaced something the other tiers had missed. The // write itself happens on shutdown. $lexical_ids = array_keys( $lexical_scores ); if ( $this->logging_enabled && ! empty( $search_query ) && self::should_log_search( $exact_ids, $lexical_ids, $semantic_ids ) ) { $result_ids = wp_list_pluck( $posts, 'ID' ); $this->pending_log = [ 'query' => mb_substr( $search_query, 0, 191 ), 'results_total' => count( $posts ), 'tier_exact' => count( $exact_ids ), 'tier_lexical' => count( $lexical_ids ), 'tier_ai' => count( array_diff( $semantic_ids, $exact_ids, $lexical_ids ) ), 'top_ids' => self::format_top_ids( $result_ids ), 'post_type' => is_array( $post_type ) ? ( count( $post_type ) === 1 ? reset( $post_type ) : 'any' ) : (string) $post_type, ]; } return $this->sync_query_counts( $query, $posts ); } /** * Normalize the raw search string for exact (lexical) matching. * * Strips tags/whitespace noise like the embedding path does, but keeps * case (comparison happens case-insensitively in SQL) and does NOT * entity-escape. * * @since 1.27.0 * * @param string $raw Raw search string (e.g. get_search_query( false )). * @return string Normalized query, possibly empty. */ public static function normalize_exact_query( $raw ) { $q = is_string( $raw ) ? $raw : ''; $q = wp_strip_all_tags( $q ); $q = preg_replace( '/\s+/u', ' ', $q ); return trim( (string) $q ); } /** * Whether a query is a plausible SKU prefix. * * Prefix matching only makes sense for a single token of reasonable * length — "abc" or "red dress" must not LIKE-match half the catalog. * * @since 1.27.0 * * @param string $query Normalized exact query. * @return bool */ public static function should_try_sku_prefix( $query ) { if ( ! is_string( $query ) ) { return false; } if ( strlen( $query ) < 4 ) { return false; } return preg_match( '/\s/u', $query ) !== 1; } /** * Turn raw `_sku` lookup rows into an ordered list of searchable post IDs. * * Rows come from find_exact_matches()' SQL and carry, per matching * post: ID, post_type, post_parent, sku, parent_type, parent_status. * Variations (`product_variation`) resolve to their published parent * product when the parent is one of the searched post types. * * Ordering: exact SKU equality first, then prefix matches by SKU * length (shortest = closest to the query). Duplicates removed. * * @since 1.27.0 * * @param array $rows DB rows (objects or arrays). * @param string[] $post_types Post types being searched. * @param string $query Normalized exact query. * @return int[] Ordered post IDs. */ public static function resolve_sku_rows( $rows, $post_types, $query ) { $post_types = array_map( 'strval', (array) $post_types ); $exact = []; $prefix = []; foreach ( (array) $rows as $row ) { $row = (object) $row; $id = 0; if ( in_array( (string) $row->post_type, $post_types, true ) ) { $id = (int) $row->ID; } elseif ( (int) $row->post_parent > 0 && isset( $row->parent_type, $row->parent_status ) && $row->parent_status === 'publish' && in_array( (string) $row->parent_type, $post_types, true ) ) { $id = (int) $row->post_parent; } if ( $id <= 0 ) { continue; } $sku = (string) ( $row->sku ?? '' ); if ( strcasecmp( $sku, $query ) === 0 ) { $exact[ $id ] = true; } else { // Keep the shortest SKU per post for ordering. $len = strlen( $sku ); if ( ! isset( $prefix[ $id ] ) || $len < $prefix[ $id ] ) { $prefix[ $id ] = $len; } } } asort( $prefix ); $ordered = array_keys( $exact ); foreach ( array_keys( $prefix ) as $id ) { if ( ! isset( $exact[ $id ] ) ) { $ordered[] = $id; } } return $ordered; } /** * Merge exact-match IDs ahead of an ordered ID list, deduped, capped. * * @since 1.27.0 * * @param int[] $exact_ids Exact-match IDs in priority order. * @param int[] $ordered_ids AI-ranked IDs. * @param int $limit Max exact IDs to pin (0 = no cap). * @return int[] */ public static function merge_exact_matches( $exact_ids, $ordered_ids, $limit = self::EXACT_MATCH_LIMIT ) { $exact_ids = array_values( array_unique( array_map( 'intval', (array) $exact_ids ) ) ); if ( $limit > 0 ) { $exact_ids = array_slice( $exact_ids, 0, $limit ); } $seen = array_fill_keys( $exact_ids, true ); $merged = $exact_ids; foreach ( (array) $ordered_ids as $id ) { $id = (int) $id; if ( ! isset( $seen[ $id ] ) ) { $seen[ $id ] = true; $merged[] = $id; } } return $merged; } /** * term_taxonomy_id of WooCommerce's `exclude-from-search` visibility * term, or 0 when WooCommerce is inactive / the term does not exist. * * Products marked "Hidden" or "Search results only: no" in the catalog * visibility UI carry this term. WooCommerce filters them from its own * product queries; since we replace the result set we must do the same * (v1.27.0). * * @since 1.27.0 * * @return int */ private function get_hidden_from_search_tt_id() { static $tt_id = null; if ( $tt_id !== null ) { return $tt_id; } $tt_id = 0; if ( function_exists( 'wc_get_product_visibility_term_ids' ) ) { $ids = wc_get_product_visibility_term_ids(); $tt_id = isset( $ids['exclude-from-search'] ) ? (int) $ids['exclude-from-search'] : 0; } elseif ( taxonomy_exists( 'product_visibility' ) ) { $term = get_term_by( 'slug', 'exclude-from-search', 'product_visibility' ); $tt_id = ( $term && ! is_wp_error( $term ) ) ? (int) $term->term_taxonomy_id : 0; } return $tt_id; } /** * SQL fragment excluding search-hidden products, for raw queries where * the posts table is aliased `p`. Empty string when not applicable. * * @since 1.27.0 * * @return string */ private function get_hidden_from_search_sql() { global $wpdb; $tt_id = $this->get_hidden_from_search_tt_id(); if ( $tt_id <= 0 ) { return ''; } return $wpdb->prepare( " AND p.ID NOT IN ( SELECT object_id FROM {$wpdb->term_relationships} WHERE term_taxonomy_id = %d )", $tt_id ); } /** * Add the search-visibility tax_query to WP_Query args when WooCommerce * products are (or may be) part of the query. * * @since 1.27.0 * * @param array $args WP_Query / get_posts args. * @return array */ private function apply_search_visibility_args( array $args ) { $tt_id = $this->get_hidden_from_search_tt_id(); if ( $tt_id <= 0 ) { return $args; } $types = isset( $args['post_type'] ) ? (array) $args['post_type'] : [ 'post' ]; if ( ! in_array( 'any', $types, true ) && ! in_array( 'product', $types, true ) ) { return $args; } $clause = [ 'taxonomy' => 'product_visibility', 'field' => 'term_taxonomy_id', 'terms' => [ $tt_id ], 'operator' => 'NOT IN', ]; if ( ! empty( $args['tax_query'] ) && is_array( $args['tax_query'] ) ) { $args['tax_query'] = [ 'relation' => 'AND', $args['tax_query'], $clause ]; } else { $args['tax_query'] = [ $clause ]; } return $args; } /** * Drop search-hidden products from a list of WP_Post objects. * * Used on result sets we did not query ourselves (WordPress' own * `posts_results` payload) — a plain site search does not go through * WooCommerce's product query, so hidden products can be in there. * * @since 1.27.0 * * @param WP_Post[] $posts * @return WP_Post[] */ private function remove_hidden_from_search( $posts ) { $tt_id = $this->get_hidden_from_search_tt_id(); if ( $tt_id <= 0 || empty( $posts ) ) { return $posts; } $out = []; foreach ( (array) $posts as $post ) { if ( isset( $post->post_type ) && $post->post_type === 'product' && has_term( 'exclude-from-search', 'product_visibility', $post ) ) { continue; } $out[] = $post; } return $out; } /** * Keep WP_Query's result counters in sync with a replaced result set. * * WordPress computes `found_posts` (and the "Showing all N results" * count themes print) from its own SQL before `posts_results` runs, so * once we swap the posts the number lies. The plugin returns the full * tiered set on one page, so the honest values are count() and one page * (v1.28.0). * * @since 1.28.0 * * @param WP_Query $query The query whose counters to fix. * @param WP_Post[] $posts The final result set. * @return WP_Post[] The same set, for `return` chaining. */ private function sync_query_counts( $query, $posts ) { $posts = is_array( $posts ) ? $posts : []; if ( $query instanceof WP_Query ) { $query->found_posts = count( $posts ); $query->max_num_pages = $posts ? 1 : 0; } return $posts; } /** * Merge the three result tiers into one ordered id => score map. * * Array union semantics: left-hand keys keep their position and score, * so a post in several tiers lands once, in its highest tier. Tier * order is positional (exact, lexical, semantic) — scores are per-tier * and deliberately NOT comparable across tiers. * * @since 1.28.0 * * @param array $exact id => 1.0, in pin order. * @param array $lexical id => keyword score, sorted desc. * @param array $semantic id => similarity score, sorted desc. * @return array Ordered id => score map. */ public static function merge_tiers( array $exact, array $lexical, array $semantic ) { return $exact + $lexical + $semantic; } /** * Whether a post came from WordPress' own lexical search (Tier 1) * in the current search. * * @since 1.28.0 * * @param int $post_id Post ID. * @return bool */ public static function is_lexical_match( $post_id ) { return in_array( (int) $post_id, self::$lexical_match_ids, true ); } /** * Find posts that match the query lexically and must be pinned first. * * Always-on behaviour (no setting): a shopper who types a SKU or the * exact product name gets that product first regardless of what the * embedding model thinks of a random alphanumeric string. * * Rules: * 1. `_sku` meta equals the query (variations resolve to parent). * 2. `_sku` meta starts with the query, when the query is a single * token of 4+ chars. * 3. Post title equals the query (case-insensitive). * * @since 1.27.0 * * @param string $query Normalized exact query. * @param string|array $post_type Post type(s) being searched. * @return int[] Ordered post IDs (SKU-exact, SKU-prefix, title-exact). */ private function find_exact_matches( $query, $post_type ) { global $wpdb; $query = (string) $query; if ( strlen( $query ) < 2 ) { return []; } $post_types = array_values( array_filter( array_map( 'strval', (array) $post_type ) ) ); if ( empty( $post_types ) ) { return []; } $type_placeholders = implode( ',', array_fill( 0, count( $post_types ), '%s' ) ); // --- SKU (exact + optional prefix) --- $sku_where = 'pm.meta_value = %s'; $sku_args = [ $query ]; if ( self::should_try_sku_prefix( $query ) ) { $sku_where .= ' OR pm.meta_value LIKE %s'; $sku_args[] = $wpdb->esc_like( $query ) . '%'; } $sku_sql = "SELECT p.ID, p.post_type, p.post_parent, pm.meta_value AS sku, par.post_type AS parent_type, par.post_status AS parent_status FROM {$wpdb->postmeta} pm INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id LEFT JOIN {$wpdb->posts} par ON par.ID = p.post_parent WHERE pm.meta_key = '_sku' AND pm.meta_value <> '' AND ( {$sku_where} ) AND p.post_status = 'publish'" . $this->get_hidden_from_search_sql() . " LIMIT 50"; $sku_rows = $wpdb->get_results( $wpdb->prepare( $sku_sql, $sku_args ) ); $ids = self::resolve_sku_rows( $sku_rows ?: [], $post_types, $query ); // --- Title (exact, case-insensitive) --- $title_sql = "SELECT p.ID FROM {$wpdb->posts} p WHERE p.post_status = 'publish' AND p.post_type IN ({$type_placeholders}) AND LOWER(p.post_title) = %s" . $this->get_hidden_from_search_sql() . " LIMIT " . (int) self::EXACT_MATCH_LIMIT; $lower = function_exists( 'mb_strtolower' ) ? mb_strtolower( $query, 'UTF-8' ) : strtolower( $query ); $title_args = array_merge( $post_types, [ $lower ] ); $title_ids = $wpdb->get_col( $wpdb->prepare( $title_sql, $title_args ) ); foreach ( (array) $title_ids as $tid ) { $ids[] = (int) $tid; } $ids = self::merge_exact_matches( $ids, [], self::EXACT_MATCH_LIMIT ); /** * Filter the post IDs pinned to the top of search results as exact * lexical matches, before AI ranking is applied. * * Add your own rules (EAN, supplier reference, brand codes) by * appending IDs, or drop rules by removing them. Order is kept. * * @since 1.27.0 * * @param int[] $ids Ordered post IDs (score 1.0). * @param string $query Normalized exact query. * @param string|array $post_type Post type(s) being searched. */ $ids = apply_filters( 'ai_search_exact_matches', $ids, $query, $post_type ); return array_values( array_unique( array_map( 'intval', (array) $ids ) ) ); } /** * Prepend posts (by ID, in order) to an existing WP_Post list, deduped. * * @since 1.27.0 * * @param int[] $ids IDs to prepend. * @param WP_Post[] $posts Existing posts. * @param string|array $post_type Post type(s) to fetch. * @return WP_Post[] */ private function prepend_posts_by_id( $ids, $posts, $post_type ) { if ( empty( $ids ) ) { return $posts; } $pinned = get_posts( $this->apply_search_visibility_args( [ 'post_type' => $post_type, 'post_status' => 'publish', 'post__in' => $ids, 'orderby' => 'post__in', 'posts_per_page' => count( $ids ), ] ) ); $seen = []; foreach ( $pinned as $p ) { $seen[ $p->ID ] = true; } foreach ( $this->remove_hidden_from_search( (array) $posts ) as $p ) { if ( empty( $seen[ $p->ID ] ) ) { $pinned[] = $p; } } return $pinned; } /** * Whether a post is pinned as an exact match in the current search. * * @since 1.27.0 * * @param int $post_id Post ID. * @return bool */ public static function is_exact_match( $post_id ) { return in_array( (int) $post_id, self::$exact_match_ids, true ); } /** * 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 ) . "'"; // Exclude WooCommerce products hidden from search (v1.27.0). $hidden_sql = $this->get_hidden_from_search_sql(); 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'" . $hidden_sql . " 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; } $final_score = $this->calculate_similarity( $query_embedding, $embedding ); // v1.26.0: additive boost from the post's best-matching taxonomy term. if ( $this->term_embeddings_enabled && $this->term_weight > 0 ) { $max_term_sim = $this->compute_max_term_similarity( $row->ID, $query_embedding ); $final_score = self::apply_term_boost( $final_score, $max_term_sim, $this->term_weight ); } if ( $final_score >= $threshold ) { $similarities[ $row->ID ] = $final_score; } $total_processed++; } $offset += $batch_size; // Free memory unset( $batch_posts ); } return $similarities; } /** * Apply the additive term-similarity boost to a post's base score. * * Pulled out as a static helper so the math can be unit-tested * without the search/DB harness around it. * * @since 1.26.0 * * @param float $base_score The post's score before the boost (0–1). * @param float $max_term_sim Max cosine similarity over the post's terms (0–1, or 0 if none). * @param int $term_weight Weight as a percent (0–50). * @return float Final score, clamped to 1.0. */ public static function apply_term_boost( $base_score, $max_term_sim, $term_weight ) { if ( $term_weight <= 0 || $max_term_sim <= 0 ) { return (float) $base_score; } $boosted = $base_score + ( $term_weight / 100 ) * $max_term_sim; return min( 1.0, $boosted ); } /** * Phase two: find posts through their taxonomy terms (v1.30.0). * * Phase one only scores posts that already have a content embedding, * so a product whose only real signal is its category ("Sneakers" for * a "trainers" search) can never clear the threshold - its content * score is near zero and the additive boost is capped by term weight. * * This method compares the query against every eligible term embedding * once, keeps the terms that genuinely match, then pulls the posts * attached to them straight from wp_term_relationships (indexed, cheap). * Posts found this way get a damped synthetic score. * * @since 1.30.0 * * @param array $query_embedding The search query embedding. * @param string|array $post_type Post type(s) being searched. * @return array Map of post ID => synthetic score. */ private function find_posts_by_term_match( $query_embedding, $post_type ) { global $wpdb; if ( empty( $query_embedding ) || ! $this->term_embeddings_enabled || $this->term_weight <= 0 ) { return []; } // Every term that has an embedding, with its taxonomy so the // admin's opt-out list can be honoured without extra queries. $rows = $wpdb->get_results( "SELECT tm.term_id, tt.term_taxonomy_id, tt.taxonomy, tm.meta_value AS embedding_json FROM {$wpdb->termmeta} tm INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_id = tm.term_id WHERE tm.meta_key = '_ai_search_term_embedding'" ); if ( empty( $rows ) ) { return []; } $disabled = (array) $this->disabled_taxonomies; $matched = []; foreach ( $rows as $row ) { if ( in_array( $row->taxonomy, $disabled, true ) ) { continue; } $term_id = (int) $row->term_id; if ( isset( $this->term_sim_cache[ $term_id ] ) ) { $sim = $this->term_sim_cache[ $term_id ]; } else { $decoded = json_decode( $row->embedding_json, true ); if ( ! empty( $decoded['embedding'] ) ) { $decoded = $decoded['embedding']; } if ( ! is_array( $decoded ) || empty( $decoded ) ) { $this->term_sim_cache[ $term_id ] = 0.0; continue; } $sim = $this->calculate_similarity( $query_embedding, $decoded ); $this->term_sim_cache[ $term_id ] = $sim; } /** * Filter the similarity a taxonomy term needs to pull in the * posts attached to it during phase-two retrieval. * * @since 1.30.0 * * @param float $threshold Minimum cosine similarity (0-1). * @param string $taxonomy The term's taxonomy. */ $term_threshold = (float) apply_filters( 'ai_search_term_match_threshold', self::TERM_MATCH_THRESHOLD, $row->taxonomy ); if ( $sim >= $term_threshold ) { $matched[ (int) $row->term_taxonomy_id ] = $sim; } } if ( empty( $matched ) ) { return []; } // Best-matching terms first, so the candidate cap keeps the most // relevant posts when a query matches many terms. arsort( $matched ); $matched = array_slice( $matched, 0, 25, true ); $post_types = array_values( array_filter( array_map( 'strval', (array) $post_type ) ) ); if ( empty( $post_types ) ) { return []; } $tt_ids = array_keys( $matched ); $tt_place = implode( ',', array_fill( 0, count( $tt_ids ), '%d' ) ); $type_place = implode( ',', array_fill( 0, count( $post_types ), '%s' ) ); $hidden_sql = $this->get_hidden_from_search_sql(); $sql = "SELECT tr.object_id, tr.term_taxonomy_id FROM {$wpdb->term_relationships} tr INNER JOIN {$wpdb->posts} p ON p.ID = tr.object_id WHERE tr.term_taxonomy_id IN ({$tt_place}) AND p.post_status = 'publish' AND p.post_type IN ({$type_place})" . $hidden_sql . " LIMIT " . (int) self::TERM_CANDIDATE_LIMIT; $relations = $wpdb->get_results( $wpdb->prepare( $sql, array_merge( $tt_ids, $post_types ) ) ); if ( empty( $relations ) ) { return []; } $scores = []; foreach ( $relations as $rel ) { $post_id = (int) $rel->object_id; $sim = isset( $matched[ (int) $rel->term_taxonomy_id ] ) ? $matched[ (int) $rel->term_taxonomy_id ] : 0.0; $score = self::term_only_score( $sim, $this->term_weight ); if ( ! isset( $scores[ $post_id ] ) || $scores[ $post_id ] < $score ) { $scores[ $post_id ] = $score; } } return $scores; } /** * Default WooCommerce product fields included in embeddings. * * The engine and the settings UI both read through this, so a store * that has never opened the WooCommerce tab still indexes sensible * fields - including the SKU, which powers exact-match search * (v1.32.0; previously the UI showed defaults the engine ignored). * * @since 1.32.0 * * @return string[] */ public static function default_woocommerce_fields() { return [ 'product_description', 'product_short_description', 'product_sku', 'product_categories', 'product_tags', ]; } /** * Name of the search log table. * * @since 1.31.0 * * @return string */ public static function log_table() { global $wpdb; return $wpdb->prefix . 'aisearch_logs'; } /** * Whether a search is worth logging: the AI tier must have surfaced at * least one post the exact and keyword tiers did not already have. * * Pure decision logic, extracted so it can be unit-tested without a DB. * * @since 1.31.0 * * @param int[] $exact_ids Tier 0 post IDs. * @param int[] $lexical_ids Tier 1 post IDs. * @param int[] $semantic_ids Tier 2 post IDs (before the tier merge). * @return bool */ public static function should_log_search( array $exact_ids, array $lexical_ids, array $semantic_ids ) { if ( empty( $semantic_ids ) ) { return false; } $added = array_diff( array_map( 'intval', $semantic_ids ), array_map( 'intval', $exact_ids ), array_map( 'intval', $lexical_ids ) ); return ! empty( $added ); } /** * Serialize the first N result IDs for storage. * * @since 1.31.0 * * @param int[] $ordered_ids Result IDs in display order. * @param int $limit How many to keep. * @return string Comma-separated IDs. */ public static function format_top_ids( array $ordered_ids, $limit = self::LOG_TOP_IDS ) { $ids = array_slice( array_map( 'intval', $ordered_ids ), 0, max( 0, (int) $limit ) ); $ids = array_filter( $ids, function ( $id ) { return $id > 0; } ); return implode( ',', $ids ); } /** * Parse a stored top_ids string back into post IDs. * * @since 1.31.0 * * @param string $stored Comma-separated IDs. * @return int[] */ public static function parse_top_ids( $stored ) { if ( ! is_string( $stored ) || '' === trim( $stored ) ) { return []; } $ids = array_map( 'intval', explode( ',', $stored ) ); return array_values( array_filter( $ids, function ( $id ) { return $id > 0; } ) ); } /** * Create the search log table when missing, and schedule the daily * retention cleanup. Runs on plugins_loaded so sites that update the * plugin (rather than activate it) also get the table. * * @since 1.31.0 */ public function maybe_create_log_table() { if ( get_option( 'ai_search_log_db_version' ) === self::LOG_DB_VERSION ) { if ( ! wp_next_scheduled( 'ai_search_purge_logs' ) ) { wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', 'ai_search_purge_logs' ); } return; } self::create_log_table(); update_option( 'ai_search_log_db_version', self::LOG_DB_VERSION, false ); if ( ! wp_next_scheduled( 'ai_search_purge_logs' ) ) { wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', 'ai_search_purge_logs' ); } } /** * Create (or update) the search log table via dbDelta. * * One row per logged search - never one per result - and deliberately * no IP, user id or any other personal data, so the log needs no * consent banner and carries no privacy risk. * * @since 1.31.0 */ public static function create_log_table() { global $wpdb; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; $table = self::log_table(); $collate = $wpdb->get_charset_collate(); // 191 chars keeps the index inside utf8mb4's key length limit. $sql = "CREATE TABLE {$table} ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, query varchar(191) NOT NULL DEFAULT '', results_total smallint(5) unsigned NOT NULL DEFAULT 0, tier_exact smallint(5) unsigned NOT NULL DEFAULT 0, tier_lexical smallint(5) unsigned NOT NULL DEFAULT 0, tier_ai smallint(5) unsigned NOT NULL DEFAULT 0, top_ids varchar(255) NOT NULL DEFAULT '', post_type varchar(20) NOT NULL DEFAULT '', created_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00', PRIMARY KEY (id), KEY query (query), KEY created_at (created_at) ) {$collate};"; dbDelta( $sql ); } /** * Write the pending search log row, if any. * * Hooked to `shutdown`: on FastCGI setups the response has already been * flushed to the visitor by this point, so this single INSERT is free * as far as the shopper is concerned. * * @since 1.31.0 */ public function flush_search_log() { if ( empty( $this->pending_log ) ) { return; } $row = $this->pending_log; $this->pending_log = null; global $wpdb; $wpdb->insert( self::log_table(), [ 'query' => $row['query'], 'results_total' => $row['results_total'], 'tier_exact' => $row['tier_exact'], 'tier_lexical' => $row['tier_lexical'], 'tier_ai' => $row['tier_ai'], 'top_ids' => $row['top_ids'], 'post_type' => $row['post_type'], 'created_at' => current_time( 'mysql' ), ], [ '%s', '%d', '%d', '%d', '%d', '%s', '%s', '%s' ] ); } /** * Delete log rows older than the configured retention window. * * @since 1.31.0 */ public function purge_old_logs() { global $wpdb; $days = (int) get_option( 'ai_search_log_retention_days', 90 ); $days = max( 1, min( 365, $days ) ); $table = self::log_table(); $wpdb->query( $wpdb->prepare( "DELETE FROM {$table} WHERE created_at < DATE_SUB( %s, INTERVAL %d DAY )", current_time( 'mysql' ), $days ) ); } /** * Score a synthetic result for a post found only through a matching * taxonomy term (phase two). * * A term-only match is real evidence but weaker than the post's own * content matching, so it is damped by the same term-weight the admin * already tuned: `score = term_sim * (weight / 100 + 0.5)`, clamped * below 1.0 so a term hit can never outrank an exact match. * * @since 1.30.0 * * @param float $term_sim Cosine similarity of the matching term (0-1). * @param int $term_weight Term weight as a percent (0-50). * @return float Synthetic score (0-0.99). */ public static function term_only_score( $term_sim, $term_weight ) { $term_sim = max( 0.0, min( 1.0, (float) $term_sim ) ); $term_weight = max( 0, min( 50, (int) $term_weight ) ); return min( 0.99, $term_sim * ( $term_weight / 100 + 0.5 ) ); } /** * Merge phase-two (term-seeded) scores into the phase-one map without * ever lowering a post that content matching already scored higher. * * @since 1.30.0 * * @param array $content_scores id => score from post embeddings. * @param array $term_scores id => synthetic score from term matches. * @return array Merged id => score map, sorted desc. */ public static function merge_term_candidates( array $content_scores, array $term_scores ) { foreach ( $term_scores as $id => $score ) { if ( ! isset( $content_scores[ $id ] ) || $content_scores[ $id ] < $score ) { $content_scores[ $id ] = $score; } } arsort( $content_scores ); return $content_scores; } /** * Compute the max cosine similarity between the query embedding and * any of a post's taxonomy term embeddings. * * The per-request `$term_sim_cache` map keys by `term_id` so a popular * term that appears on many products in the same batch (e.g. "Red") is * only scored against the query once per search. * * @since 1.26.0 * * @param int $post_id Post ID. * @param array $query_embedding The search query's embedding. * @return float Max term similarity, or 0.0 if no eligible terms have embeddings. */ private function compute_max_term_similarity( $post_id, $query_embedding ) { $taxonomies = get_object_taxonomies( get_post_type( $post_id ) ); if ( empty( $taxonomies ) ) { return 0.0; } // Honour the admin's opt-out list. if ( ! empty( $this->disabled_taxonomies ) ) { $taxonomies = array_diff( $taxonomies, $this->disabled_taxonomies ); if ( empty( $taxonomies ) ) { return 0.0; } } $term_ids = wp_get_object_terms( $post_id, $taxonomies, [ 'fields' => 'ids' ] ); if ( is_wp_error( $term_ids ) || empty( $term_ids ) ) { return 0.0; } $max = 0.0; foreach ( $term_ids as $term_id ) { if ( isset( $this->term_sim_cache[ $term_id ] ) ) { $sim = $this->term_sim_cache[ $term_id ]; } else { $raw = get_term_meta( $term_id, '_ai_search_term_embedding', true ); if ( empty( $raw ) ) { $this->term_sim_cache[ $term_id ] = 0.0; continue; } $decoded = json_decode( $raw, true ); if ( ! is_array( $decoded ) ) { $this->term_sim_cache[ $term_id ] = 0.0; continue; } $sim = $this->calculate_similarity( $query_embedding, $decoded ); $this->term_sim_cache[ $term_id ] = $sim; } if ( $sim > $max ) { $max = $sim; } } return $max; } /** * 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', self::default_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; } /** * 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 ) { // WordPress' own results may contain products hidden from search. $original_posts = $this->remove_hidden_from_search( $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']; } $final_score = $this->calculate_similarity( $query_embedding, $embedding ); 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']; } $final_score = $this->calculate_similarity( $query_embedding, $embedding ); 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']; } $final_score = $this->calculate_similarity( $query_embedding, $embedding ); 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( $this->apply_search_visibility_args( [ '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( $this->apply_search_visibility_args( $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; if ( self::is_exact_match( $post_id ) ) { $classes[] = 'ai-search-exact-match'; } elseif ( self::is_lexical_match( $post_id ) ) { $classes[] = 'ai-search-lexical-match'; } // 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; } // v1.37.0: badge now ONLY appears on genuine AI-tier results. // If the post was exact-matched (SKU/title), lexical-matched (WP // native keyword hit) or came from the fallback (no embedding), // no badge is shown — those results look like "normal" search // results because they *are* normal. The badge earns its place // only when the AI surfaced something the other tiers would have // missed. // // Visibility gate: `ai_search_badge_public` (default true in 1.37.0) // lets merchants who want unbranded search results turn it off. // Editors/admins always see it regardless — they need to be able // to spot AI results while tuning the plugin. Default flip from // false to true is intentional: opting in to transparency for the // 90%+ who never touch the setting. $badge_public = get_option( 'ai_search_badge_public', true ); if ( ! $badge_public && ! current_user_can( 'edit_posts' ) ) { return $title; } $has_score = isset( self::$similarity_scores[ $post_id ] ); $is_exact = self::is_exact_match( $post_id ); $is_lexical = self::is_lexical_match( $post_id ); if ( ! $has_score || $is_exact || $is_lexical ) { return $title; } $tooltip = __( 'This search result was generated by an AI system.', 'ai-search' ); // No `title` attribute — that triggers the browser's default OS // tooltip (yellow on macOS, grey on Windows) which we cannot style. // The visual tooltip is CSS-only via `data-tooltip` + `::after`. // `aria-label` keeps screen readers covered. $badge = sprintf( 'AI', esc_attr( $tooltip ), esc_attr( $tooltip ) ); return $title . ' ' . $badge; } /** * Add CSS styles for AI Search badge. */ public function add_ai_search_badge_styles() { if ( ! is_search() ) { return; } // Always emit the CSS on search pages. Badge visibility is decided // per post inside add_ai_search_badge() (only genuine AI results). // // Tooltip bg + text color are configurable in Appearance tab // (v1.37.0). We inline them into CSS custom properties so a // merchant change takes effect on the next page load — no // cache-busting needed. $tooltip_bg = get_option( 'ai_search_tooltip_bg', '#231a4f' ); $tooltip_color = get_option( 'ai_search_tooltip_color', '#ffffff' ); ?> 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.30; $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.30 ); // 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 ); } // v1.31.0: search log table + daily retention cleanup. AI_Search::create_log_table(); update_option( 'ai_search_log_db_version', AI_Search::LOG_DB_VERSION, false ); if ( ! wp_next_scheduled( 'ai_search_purge_logs' ) ) { wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', 'ai_search_purge_logs' ); } // Set activation redirect flag set_transient( 'ai_search_activation_redirect', true, 30 ); } // Clear the retention cron on deactivation; the table and its data stay. register_deactivation_hook( __FILE__, 'ai_search_deactivate_plugin' ); /** * Plugin deactivation handler. * * @since 1.31.0 */ function ai_search_deactivate_plugin() { $timestamp = wp_next_scheduled( 'ai_search_purge_logs' ); if ( $timestamp ) { wp_unschedule_event( $timestamp, 'ai_search_purge_logs' ); } } // 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( 'admin.php?page=ai-search&setup=wizard' ) ); exit; } // Initialize the plugin (skipped during unit tests — the test bootstrap // wires up its own stubs and instantiates the class without side effects). if ( ! defined( 'AI_SEARCH_SKIP_BOOTSTRAP' ) ) { AI_Search::get_instance(); }