get_quota_data(); if ( is_array( $quota ) && ! empty( $quota['plan']['key'] ) ) { $default = in_array( strtolower( (string) $quota['plan']['key'] ), [ 'standard', 'pro' ], true ); } } /** * Override Fast Search availability - useful for dev testing on * a Free-plan playground OR for beta-inviting a specific free * customer before a broader rollout. * * add_filter( 'ai_search_fast_search_available', '__return_true' ); * * @since 1.42.0 */ return (bool) apply_filters( 'ai_search_fast_search_available', $default ); } /** * True when the site is on a qualifying plan and Fast Search is active. * Defaults to on for qualifying plans so merchants get the better path * without having to flip a toggle; saving the option explicitly as * false lets them disable it when needed. */ public static function is_enabled() { if ( ! self::is_available_for_plan() ) return false; return (bool) get_option( self::OPTION_ENABLED, true ); } // --------------------------------------------------------------------- // Write path: sync posts to the cloud index // --------------------------------------------------------------------- /** * Publish or update just fired for this post. If Fast Search is on, * push the post's title / content / embedding to the service. * * Fires on priority 20 so it runs AFTER generate_embedding_on_publish * (priority 10), guaranteeing the embedding meta exists by the time * we read it. */ public static function on_publish( $new_status, $old_status, $post ) { if ( ! self::is_enabled() ) return; if ( 'publish' !== $new_status ) { // Going away from publish means the post should leave the // cloud index too - the local pipeline stops treating it, // so should the cloud. if ( 'publish' === $old_status && $post instanceof WP_Post ) { self::delete_remote( $post->ID ); } return; } if ( ! ( $post instanceof WP_Post ) ) return; if ( wp_is_post_revision( $post->ID ) || wp_is_post_autosave( $post->ID ) ) return; self::sync_one( $post->ID ); } /** * `delete_post` handler. Remove from cloud index silently. */ public static function on_delete( $post_id ) { if ( ! self::is_enabled() ) return; self::delete_remote( (int) $post_id ); } /** * Build the payload for a single post and POST it to the service. * Called from the publish hook and from the bulk backfill. */ public static function sync_one( $post_id ) { $payload = self::build_post_payload( $post_id ); if ( null === $payload ) return false; return self::upsert_batch( [ $payload ] ); } /** * Push a batch of post IDs to the cloud index in a single HTTP call. * Called from the sync AJAX handler right after a successful embedding * batch, so Standard+ sites keep the cloud index in lockstep with * `_ai_search_embedding` postmeta without the merchant pressing a * second button. Returns [pushed, missing] - pushed is how many posts * had buildable payloads AND the HTTP succeeded; missing is posts with * no embedding or that got filtered out by build_post_payload. Silent * no-op when the plan or toggle disables Fast Search, so callers never * need to gate on it themselves. * * @since 1.47.0 */ public static function sync_batch( array $post_ids ) { if ( ! self::is_enabled() || empty( $post_ids ) ) { return [ 'pushed' => 0, 'missing' => 0 ]; } $payloads = []; $missing = 0; foreach ( $post_ids as $pid ) { $p = self::build_post_payload( (int) $pid ); if ( null === $p ) { $missing++; continue; } $payloads[] = $p; } if ( empty( $payloads ) ) return [ 'pushed' => 0, 'missing' => $missing ]; $ok = self::upsert_batch( $payloads ); return [ 'pushed' => $ok ? count( $payloads ) : 0, 'missing' => $missing ]; } /** * Pull the post's title, content, embedding and taxonomies into the * shape the service expects. Returns null when the post cannot be * indexed (no embedding yet, private, etc.). */ private static function build_post_payload( $post_id ) { $post = get_post( $post_id ); if ( ! $post || 'publish' !== $post->post_status ) return null; $embedding_json = get_post_meta( $post_id, '_ai_search_embedding', true ); if ( empty( $embedding_json ) ) return null; $embedding = json_decode( $embedding_json, true ); // v1.35.0 wrapped embeddings in { embedding, model } - handle both shapes. if ( is_array( $embedding ) && isset( $embedding['embedding'] ) ) { $embedding = $embedding['embedding']; } if ( ! is_array( $embedding ) || empty( $embedding ) ) return null; $categories = wp_get_post_terms( $post_id, 'category', [ 'fields' => 'names' ] ); $tags = wp_get_post_terms( $post_id, 'post_tag', [ 'fields' => 'names' ] ); $sku = ''; if ( 'product' === $post->post_type ) { $sku = (string) get_post_meta( $post_id, '_sku', true ); } return [ 'post_id' => (int) $post_id, 'post_type' => $post->post_type, 'title' => wp_strip_all_tags( $post->post_title ), 'content' => wp_strip_all_tags( $post->post_content ), 'excerpt' => wp_strip_all_tags( $post->post_excerpt ), 'sku' => $sku ?: null, 'taxonomies' => [ 'categories' => is_wp_error( $categories ) ? [] : array_values( $categories ), 'tags' => is_wp_error( $tags ) ? [] : array_values( $tags ), ], 'embedding' => $embedding, 'language' => self::infer_language(), ]; } /** * POST { posts: [...] } to /wp-search/index/upsert. Fire-and-forget * with a short timeout so publish hooks never stall. */ private static function upsert_batch( $posts ) { if ( empty( $posts ) ) return true; $token = (string) get_option( 'ai_search_service_token', '' ); if ( '' === $token ) return false; $body = wp_json_encode( [ 'posts' => array_slice( $posts, 0, self::UPSERT_BATCH_CAP ) ] ); $response = wp_remote_post( self::SERVICE_BASE_URL . '/wp-search/index/upsert', [ 'timeout' => 8, 'blocking' => true, 'headers' => [ 'Content-Type' => 'application/json', 'Authorization' => 'Bearer ' . $token, // Origin lets the service check the cross-site guard. 'Origin' => (string) home_url(), ], 'body' => $body, ] ); if ( is_wp_error( $response ) ) { error_log( '[ai-search fast-search upsert] ' . $response->get_error_message() ); return false; } $code = (int) wp_remote_retrieve_response_code( $response ); if ( $code < 200 || $code >= 300 ) { error_log( '[ai-search fast-search upsert] HTTP ' . $code . ' body=' . substr( (string) wp_remote_retrieve_body( $response ), 0, 200 ) ); return false; } return true; } /** * DELETE /wp-search/index/:post_id. */ private static function delete_remote( $post_id ) { $token = (string) get_option( 'ai_search_service_token', '' ); if ( '' === $token ) return false; $response = wp_remote_request( self::SERVICE_BASE_URL . '/wp-search/index/' . (int) $post_id, [ 'method' => 'DELETE', 'timeout' => 5, 'blocking' => true, 'headers' => [ 'Authorization' => 'Bearer ' . $token, 'Origin' => (string) home_url(), ], ] ); if ( is_wp_error( $response ) ) { error_log( '[ai-search fast-search delete] ' . $response->get_error_message() ); return false; } return true; } // --------------------------------------------------------------------- // Read path: fetch candidates for a live search // --------------------------------------------------------------------- /** * Ask the service for ranked candidates. Returns: * * [ [ 'post_id' => int, 'lex_score' => float, 'ai_score' => float, 'tier' => 'lexical'|'semantic'|'both' ], ... ] * * or null on any error / non-200 - the caller must fall back to the * local pipeline in that case. NEVER throws. */ public static function fetch_candidates( $query, $post_types = null, $limit = self::SEARCH_CANDIDATE_LIMIT ) { $token = (string) get_option( 'ai_search_service_token', '' ); if ( '' === $token ) return null; $body = [ 'query' => (string) $query, 'limit' => (int) $limit, // tsvectors were indexed under this locale's config, so the // tsquery must use the same one to match stemmed lexemes. 'language' => self::infer_language(), ]; if ( is_array( $post_types ) && ! empty( $post_types ) ) { $body['post_types'] = array_values( array_filter( array_map( 'strval', $post_types ) ) ); } $response = wp_remote_post( self::SERVICE_BASE_URL . '/wp-search/search', [ 'timeout' => 5, 'blocking' => true, 'headers' => [ 'Content-Type' => 'application/json', 'Authorization' => 'Bearer ' . $token, 'Origin' => (string) home_url(), ], 'body' => wp_json_encode( $body ), ] ); if ( is_wp_error( $response ) ) return null; $code = (int) wp_remote_retrieve_response_code( $response ); if ( 200 !== $code ) return null; $decoded = json_decode( (string) wp_remote_retrieve_body( $response ), true ); if ( ! is_array( $decoded ) || ! isset( $decoded['candidates'] ) || ! is_array( $decoded['candidates'] ) ) { return null; } return $decoded['candidates']; } // --------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------- /** * AJAX: sync one batch of already-embedded posts to the cloud. * Called repeatedly from the Indexing tab's backfill button. * * Response shape mirrors ajax_generate_batch so the client-side JS * from the wizard/indexing view can be reused with minimal edits. */ public static function ajax_backfill_batch() { check_ajax_referer( 'ai_search_fast_backfill', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( [ 'message' => __( 'Unauthorized', 'ai-search' ) ], 403 ); } if ( ! self::is_available_for_plan() ) { wp_send_json_error( [ 'message' => __( 'Fast Search requires a Standard or Pro plan.', 'ai-search' ) ], 402 ); } $offset = isset( $_POST['offset'] ) ? max( 0, (int) $_POST['offset'] ) : 0; $limit = self::UPSERT_BATCH_CAP; // Only posts that already have a local embedding are candidates - // backfill uploads the vector alongside the content, so no vector // means nothing to send. $posts = get_posts( [ 'post_type' => [ 'post', 'page', 'product' ], 'post_status' => 'publish', 'posts_per_page' => $limit, 'offset' => $offset, 'orderby' => 'ID', 'order' => 'ASC', 'meta_query' => [ [ 'key' => '_ai_search_embedding', 'compare' => 'EXISTS' ] ], 'fields' => 'ids', ] ); if ( empty( $posts ) ) { wp_send_json_success( [ 'processed' => 0, 'offset' => $offset, 'done' => true, ] ); } $payloads = []; foreach ( $posts as $post_id ) { $p = self::build_post_payload( (int) $post_id ); if ( null !== $p ) $payloads[] = $p; } $ok = self::upsert_batch( $payloads ); if ( ! $ok ) { wp_send_json_error( [ 'message' => __( 'Batch upload failed - check the WP error log.', 'ai-search' ) ], 502 ); } wp_send_json_success( [ 'processed' => count( $posts ), 'offset' => $offset + count( $posts ), 'done' => count( $posts ) < $limit, ] ); } /** * Best-effort Postgres tsvector language config from get_locale(). * Supported names come from `SELECT cfgname FROM pg_ts_config` on the * production Postgres. We map common WordPress locales; anything * unknown falls back to 'simple' which the service also accepts. */ private static function infer_language() { $locale = (string) get_locale(); $two = substr( $locale, 0, 2 ); static $map = [ 'pt' => 'portuguese', 'es' => 'spanish', 'fr' => 'french', 'de' => 'german', 'it' => 'italian', 'nl' => 'dutch', 'en' => 'english', 'sv' => 'swedish', 'no' => 'norwegian', 'da' => 'danish', 'fi' => 'finnish', 'ru' => 'russian', 'tr' => 'turkish', ]; return $map[ $two ] ?? 'simple'; } }