'GET', 'callback' => [ $this, 'get_settings' ], 'permission_callback' => '__return_true', ] ); // Live suggestions for the search box block (v1.33.0). register_rest_route( self::NAMESPACE, '/suggest', [ 'methods' => 'GET', 'callback' => [ $this, 'get_suggestions' ], 'permission_callback' => '__return_true', 'args' => [ 'q' => [ 'required' => true, 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', ], 'mode' => [ 'type' => 'string', 'default' => 'keyword', 'enum' => [ 'keyword', 'full' ], ], 'post_type' => [ 'type' => 'string', 'default' => '', ], 'limit' => [ 'type' => 'integer', 'default' => 6, ], ], ] ); } /** * Minimum query length before we do any work. * * @since 1.33.0 */ const SUGGEST_MIN_CHARS = 3; /** * Suggestion requests allowed per IP per minute. * * @since 1.33.0 */ const SUGGEST_RATE_LIMIT = 30; /** * Build the transient key for a suggestion response. * * Pure helper so the caching contract can be unit-tested: the same * query in the same mode and post type must reuse one cache entry, * and different ones must never collide. * * @since 1.33.0 * * @param string $query Search query. * @param string $mode 'keyword' or 'full'. * @param string $post_type Post type filter, or ''. * @param int $limit Result cap. * @return string */ public static function suggest_cache_key( $query, $mode, $post_type, $limit ) { return 'ai_search_sug_' . md5( strtolower( trim( (string) $query ) ) . '|' . $mode . '|' . $post_type . '|' . (int) $limit ); } /** * Whether a query is worth running at all. * * @since 1.33.0 * * @param string $query Raw query. * @return bool */ public static function is_suggestable( $query ) { $query = trim( (string) $query ); return mb_strlen( $query ) >= self::SUGGEST_MIN_CHARS; } /** * Live search suggestions for the search box block. * * Two modes, so typing stays free and only a deliberate pause costs * an embedding: * * - `keyword`: WordPress' own matching plus the plugin's exact SKU and * title tier. The semantic filter is detached for the call, so no * embedding is requested at all. * - `full`: the complete tiered search (exact, keyword, AI), which * costs exactly one embedding. * * Results are shaped identically in both modes so the client can * replace one with the other without re-rendering the shell. * * @since 1.33.0 * * @param WP_REST_Request $request Request. * @return WP_REST_Response */ public function get_suggestions( WP_REST_Request $request ) { $query = (string) $request->get_param( 'q' ); $mode = (string) $request->get_param( 'mode' ); $post_type = (string) $request->get_param( 'post_type' ); $limit = max( 1, min( 12, (int) $request->get_param( 'limit' ) ) ); if ( ! self::is_suggestable( $query ) ) { return rest_ensure_response( [ 'query' => $query, 'mode' => $mode, 'results' => [] ] ); } if ( ! $this->check_suggest_rate_limit() ) { return new WP_REST_Response( [ 'query' => $query, 'mode' => $mode, 'results' => [], 'throttled' => true ], 429 ); } $cache_key = self::suggest_cache_key( $query, $mode, $post_type, $limit ); $cached = get_transient( $cache_key ); if ( false !== $cached ) { return rest_ensure_response( $cached ); } $args = [ 's' => $query, 'posts_per_page' => $limit, 'post_status' => 'publish', 'ignore_sticky_posts' => true, 'no_found_rows' => true, ]; if ( '' !== $post_type && post_type_exists( $post_type ) ) { $args['post_type'] = $post_type; } $ai = class_exists( 'AI_Search' ) ? AI_Search::get_instance() : null; // Keyword mode: detach the semantic filter so this costs nothing. if ( 'keyword' === $mode && $ai ) { remove_filter( 'posts_results', [ $ai, 'filter_search_results' ], 10 ); } // The search filter reads the terms from the query it is given, so // a secondary WP_Query gets the full tier treatment (since 1.28.1). $search = new WP_Query( $args ); if ( 'keyword' === $mode && $ai ) { add_filter( 'posts_results', [ $ai, 'filter_search_results' ], 10, 2 ); } $posts = self::cap_results( $search->posts, $limit ); $response = [ 'query' => $query, 'mode' => $mode, 'results' => array_map( function ( $post ) use ( $mode ) { return $this->shape_suggestion( $post, $mode ); }, $posts ), ]; set_transient( $cache_key, $response, 5 * MINUTE_IN_SECONDS ); return rest_ensure_response( $response ); } /** * Cap the result set the tier system returned. * * `filter_search_results` replaces the whole result set in * `posts_results` so that `found_posts` can be corrected, which means * the query's own `posts_per_page` no longer limits what comes back. A * dropdown asking for six rows would otherwise receive every match in * the catalogue. * * @since 1.33.1 * * @param array $posts Results from the query. * @param int $limit Maximum to return. * @return array */ public static function cap_results( $posts, $limit ) { if ( ! is_array( $posts ) ) { return []; } $limit = (int) $limit; if ( $limit < 1 ) { return []; } return array_slice( $posts, 0, $limit ); } /** * Shape one post for the suggestions dropdown. * * @since 1.33.0 * * @param WP_Post $post Result post. * @return array */ private function shape_suggestion( $post, $mode = 'full' ) { // In keyword mode no semantic pass ran, so nothing here is an AI // result - labelling it as one would be a lie in the dropdown. $tier = 'keyword' === $mode ? 'keyword' : 'ai'; if ( class_exists( 'AI_Search' ) ) { if ( AI_Search::is_exact_match( $post->ID ) ) { $tier = 'exact'; } elseif ( 'full' === $mode && method_exists( 'AI_Search', 'is_lexical_match' ) && AI_Search::is_lexical_match( $post->ID ) ) { $tier = 'keyword'; } } // Plain formatted price. get_price_html() carries "Original price // was..." screen-reader markup that reads as noise once stripped. $price = ''; if ( 'product' === $post->post_type && function_exists( 'wc_get_product' ) ) { $product = wc_get_product( $post->ID ); if ( $product ) { $amount = $product->get_price(); if ( '' !== $amount && null !== $amount && function_exists( 'wc_price' ) ) { $price = html_entity_decode( wp_strip_all_tags( wc_price( $amount ) ), ENT_QUOTES, 'UTF-8' ); } } } $thumb = get_the_post_thumbnail_url( $post->ID, 'thumbnail' ); return [ 'id' => (int) $post->ID, // wptexturize turns quotes and dashes into entities. The dropdown // inserts titles as text, so an undecoded title would render as // a literal `Aurora 34″ Ultrawide`. 'title' => html_entity_decode( wp_strip_all_tags( get_the_title( $post ) ), ENT_QUOTES, 'UTF-8' ), 'url' => get_permalink( $post ), 'image' => $thumb ? $thumb : '', 'price' => $price, 'tier' => $tier, ]; } /** * Simple per-IP rate limit so a public endpoint cannot be hammered. * * @since 1.33.0 * * @return bool True when the request may proceed. */ private function check_suggest_rate_limit() { $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? (string) $_SERVER['REMOTE_ADDR'] : ''; $key = 'ai_search_sug_rl_' . md5( $ip ); $hits = (int) get_transient( $key ); if ( $hits >= self::SUGGEST_RATE_LIMIT ) { return false; } set_transient( $key, $hits + 1, MINUTE_IN_SECONDS ); return true; } /** * Get all plugin settings. * * @return WP_REST_Response */ public function get_settings() { global $wpdb; $provider = get_option( 'ai_search_provider', 'ai_service' ); // Count posts with embeddings $embedding_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->postmeta} WHERE meta_key = '_ai_search_embedding'" ); // Get public post types $post_types = get_post_types( [ 'public' => true ], 'names' ); unset( $post_types['attachment'] ); // Get custom fields per post type $custom_fields = []; foreach ( $post_types as $post_type ) { $fields = get_option( 'ai_search_custom_fields_' . $post_type, [] ); if ( ! empty( $fields ) ) { $custom_fields[ $post_type ] = $fields; } } $settings = [ 'plugin_version' => AI_SEARCH_VERSION, 'wordpress_version' => get_bloginfo( 'version' ), 'php_version' => phpversion(), 'woocommerce_active' => class_exists( 'WooCommerce' ), 'provider' => $provider, 'has_api_key' => ! empty( get_option( 'ai_search_api_key', '' ) ), 'has_service_token' => ! empty( get_option( 'ai_search_service_token', '' ) ), 'openai_model' => get_option( 'ai_search_openai_model', 'text-embedding-3-small' ), 'similarity_threshold' => (float) get_option( 'ai_search_similarity_threshold', 0.30 ), 'badge_public' => (bool) get_option( 'ai_search_badge_public', true ), 'setup_completed' => (bool) get_option( 'ai_search_setup_completed', false ), 'woocommerce_fields' => get_option( 'ai_search_woocommerce_fields', [] ), 'custom_fields' => $custom_fields, 'total_embeddings' => $embedding_count, 'post_types' => array_values( $post_types ), ]; return rest_ensure_response( $settings ); } }