api_key = get_option( 'ai_search_api_key', '' ); $this->similarity_threshold = get_option( 'ai_search_similarity_threshold', 0.5 ); $this->provider = get_option( 'ai_search_provider', $this->api_key ? 'openai' : 'ai_service' ); $this->service_token = get_option( 'ai_search_service_token', '' ); $this->service_client = new AI_Search_Service(); $this->register_hooks(); } /** * Get the singleton instance. * * @return AI_Search */ public static function get_instance() { if ( null === self::$instance ) { self::$instance = new self(); } return self::$instance; } /** * Register hooks. */ private function register_hooks() { add_action( 'save_post', [ $this, 'generate_embedding' ] ); add_filter( 'posts_results', [ $this, 'filter_search_results' ], 10, 2 ); add_action( 'admin_menu', [ $this, 'register_settings_menu' ] ); add_action( 'admin_post_ai_search_generate_embeddings', [ $this, 'generate_selected_embeddings' ] ); add_action( 'admin_post_ai_search_clear_cache', [ $this, 'clear_embeddings_cache' ] ); add_action( 'admin_post_ai_search_validate_token', [ $this, 'validate_service_token' ] ); add_action( 'wp_ajax_ai_search_validate_token', [ $this, 'validate_service_token' ] ); } /** * Generate embedding for a post and save it to post meta. * * @param int $post_id Post ID. */ public function generate_embedding( $post_id ) { if ( wp_is_post_revision( $post_id ) ) { return; } $post = get_post( $post_id ); if ( 'publish' !== $post->post_status ) { return; } // Sanitize post title and content $post_title = sanitize_text_field( $post->post_title ); $post_content = sanitize_textarea_field( $post->post_content ); // Combine context with content for embedding $content = $post_title . ' ' . $post_content; $embedding = $this->get_embedding( $content ); if ( $embedding ) { update_post_meta( $post_id, '_ai_search_embedding', wp_json_encode( $embedding ) ); } } /** * Get embedding from OpenAI. * * @param string $content Content to embed. * * @return array|false Embedding data or false on failure. */ private function get_embedding( $content ) { $content = sanitize_textarea_field( $content ); // Check for Cached Embedding $cached_embedding = get_transient( 'ai_search_embedding_' . md5( $content ) ); if ( $cached_embedding ) { return $cached_embedding; } if ( $this->provider === 'ai_service' ) { // Check if we have a service token first $service_token = get_option( 'ai_search_service_token', '' ); if ( empty( $service_token ) ) { return false; // No token available, service not registered } $embedding = $this->service_client->get_embedding( $content ); if ( $embedding && ! empty( $embedding['embedding']) ) { return $embedding['embedding']; } return false; // Service failed, don't fallback to OpenAI without user consent } $response = wp_remote_post( 'https://api.openai.com/v1/embeddings', [ 'headers' => [ 'Authorization' => 'Bearer ' . $this->api_key, 'Content-Type' => 'application/json', ], 'body' => wp_json_encode([ 'input' => $content, 'model' => 'text-embedding-ada-002', ]), ]); if ( is_wp_error( $response ) ) { return false; } $body = json_decode( wp_remote_retrieve_body( $response ), true ); if ( ! isset( $body['data'][0]['embedding'] ) ) { return false; } // Cache the embedding for 1 day set_transient( 'ai_search_embedding_' . md5( $content ), $body['data'][0]['embedding'], MONTH_IN_SECONDS ); return $body['data'][0]['embedding']; } /** * Customize the search query. * * @param string $sql Original SQL query. * @param WP_Query $query WP_Query object. * * @return string Modified SQL query. */ public function filter_search_results( $posts, $query ) { if ( ! $query->is_search() || is_admin() ) { return $posts; } $post_type = $query->get( 'post_type' ); $search_query = get_search_query(); $search_query = sanitize_text_field( $search_query ); $query_embedding = $this->get_embedding( $search_query ); if ( ! $query_embedding ) { return $posts; } $similarities = []; $all_posts = get_posts([ 'numberposts' => -1, 'post_type' => $post_type, 'post_status' => 'publish', 'meta_key' => '_ai_search_embedding', ]); foreach ( $all_posts as $post ) { $embedding_json = get_post_meta( $post->ID, '_ai_search_embedding', true ); if ( empty( $embedding_json ) ) { continue; } $embedding = json_decode( $embedding_json, true ); if ( ! empty( $embedding['embedding'] ) ) { $embedding = $embedding['embedding']; } $similarity = $this->calculate_similarity( $query_embedding, $embedding ); if ( $similarity >= $this->similarity_threshold ) { $similarities[ $post->ID ] = $similarity; } } if ( empty( $similarities ) ) { return $posts; } arsort( $similarities ); $ordered_ids = array_keys( $similarities ); $posts = get_posts([ 'post_type' => $post_type, 'post_status' => 'publish', 'post__in' => $ordered_ids, 'orderby' => 'post__in', 'posts_per_page' => -1, ]); return $posts; } /** * Calculate cosine similarity. * * @param array $a First vector. * @param array $b Second vector. * * @return float Cosine similarity. */ private function calculate_similarity( $a, $b ) { if ( ! is_array( $a ) || ! is_array( $b ) || count( $a ) !== count( $b ) ) { return 0; } $dot_product = array_sum( array_map( function ( $x, $y ) { return $x * $y; }, $a, $b ) ); $magnitude_a = sqrt( array_sum( array_map( function ( $x ) { return $x ** 2; }, $a ) ) ); $magnitude_b = sqrt( array_sum( array_map( function ( $x ) { return $x ** 2; }, $b ) ) ); if ( $magnitude_a === 0 || $magnitude_b === 0 ) { return 0; } return $dot_product / ( $magnitude_a * $magnitude_b ); } /** * Register settings menu in admin. */ public function register_settings_menu() { add_options_page( 'AI Search Settings', 'AI Search', 'manage_options', 'ai-search', [ $this, 'settings_page' ] ); } /** * Check if AI service registration is needed and attempt registration. * * @param string $provider The selected provider. * @return array Result array with 'success' boolean and 'message' string. */ private function handle_ai_service_registration( $provider ) { if ( $provider !== 'ai_service' ) { return [ 'success' => true, 'message' => 'Settings saved successfully!' ]; } if ( ! empty( get_option( 'ai_search_service_token', '' ) ) ) { return [ 'success' => true, 'message' => 'Settings saved successfully!' ]; } return $this->attempt_service_registration(); } /** * Attempt to register with AI service and handle the response. * * @return array Result array with 'success' boolean and 'message' string. */ private function attempt_service_registration() { $service_token = $this->service_client->register_origin(); if ( $service_token ) { update_option( 'ai_search_service_token', $service_token ); return [ 'success' => true, 'message' => 'Settings saved successfully! AI Search Service registered.' ]; } return [ 'success' => false, 'message' => 'AI Search Service is temporarily unavailable. Please try again later or use your own OpenAI API key instead.' ]; } /** * Display registration result message. * * @param array $result Result from registration attempt. */ private function display_registration_message( $result ) { $class = $result['success'] ? 'updated' : 'error'; echo '
' . $result['message'] . '
Token Validation Successful!
'; if ( isset( $validation_data['client'] ) ) { $client = $validation_data['client']; echo 'Token Validation Failed!
'; if ( $error_data && isset( $error_data['details'] ) ) { echo 'Error: ' . esc_html( $error_data['details'] ) . '
'; } echo 'Token Validation Error! Unable to connect to AI Search Service.
No Token Found! Please save your settings first to generate a service token.
Embeddings have been generated for selected posts!
Embeddings have been generated for the following posts:
Select a custom post type and click the button to generate embeddings for posts that do not yet have them.
'; echo ''; } /** * Cache management tab. */ private function cache_management_page() { // Display success messages if ( isset( $_GET['cache_cleared'] ) ) { $cleared_type = sanitize_text_field( $_GET['cache_cleared'] ); $count = isset( $_GET['count'] ) ? intval( $_GET['count'] ) : 0; $messages = [ 'transients_cleared' => 'Embedding cache (transients) cleared successfully!', 'post_meta_cleared' => 'Post meta embeddings cleared successfully!', 'all_cache_cleared' => 'All embedding cache and post meta cleared successfully!' ]; $message = isset( $messages[$cleared_type] ) ? $messages[$cleared_type] : 'Cache cleared successfully!'; echo '' . $message . ' (' . $count . ' entries removed)
Manage your AI Search embedding cache. Use these options to clear cached embeddings when needed.
'; // Get cache statistics $stats = $this->get_cache_statistics(); echo 'Cached Embeddings (Transients): ~' . $stats['transients'] . ' entries
'; echo 'Post Meta Embeddings: ' . $stats['post_meta'] . ' posts
'; echo 'Note: Transient count is approximate and includes both embedding cache and timeout entries.
'; echo 'Clears temporarily cached embeddings. These will be regenerated automatically when needed.
'; echo ''; echo 'Warning: This will remove stored embeddings from all posts. You will need to regenerate them manually.
'; echo ''; echo 'DANGER: This will remove ALL embedding cache and post meta. AI Search will stop working until you regenerate embeddings.
'; echo ''; echo '