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'] . '

'; } /** * Generate embedding for selected custom post type. * @return void */ public function generate_selected_embeddings() { if ( ! current_user_can( 'manage_options' ) || ! isset( $_POST['cpt'] ) ) { wp_die( __( 'Unauthorized access', 'ai-search' ) ); } $cpt = sanitize_text_field( $_POST['cpt'] ); $limit = isset( $_POST['limit'] ) ? intval( $_POST['limit'] ) : 50; $limit = min( max( $limit, 1 ), 1000 ); // Sanitize: min 1, max 1000 $processed_titles = []; $posts = get_posts([ 'numberposts' => $limit, 'post_type' => $cpt, 'post_status' => 'publish', 'meta_query' => [ [ 'key' => '_ai_search_embedding', 'compare' => 'NOT EXISTS' ] ] ]); foreach ( $posts as $post ) { $this->generate_embedding( $post->ID ); $processed_titles[] = $post->post_title; } set_transient( 'ai_search_processed_titles', $processed_titles, MINUTE_IN_SECONDS ); wp_redirect( admin_url( 'options-general.php?page=ai-search&embeddings_generated=true&tab=embeddings' ) ); exit; } /** * Clear all embedding cache (transients and post meta). * @return void */ public function clear_embeddings_cache() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( __( 'Unauthorized access', 'ai-search' ) ); } check_admin_referer( 'ai_search_clear_cache' ); $cache_type = isset( $_POST['cache_type'] ) ? sanitize_text_field( $_POST['cache_type'] ) : 'transients'; $cleared_count = 0; if ( $cache_type === 'transients' || $cache_type === 'all' ) { // Clear embedding transients $cleared_count += $this->clear_embedding_transients(); } if ( $cache_type === 'post_meta' || $cache_type === 'all' ) { // Clear post meta embeddings $cleared_count += $this->clear_post_meta_embeddings(); } $message = $cache_type === 'all' ? 'all_cache_cleared' : $cache_type . '_cleared'; wp_redirect( admin_url( 'options-general.php?page=ai-search&cache_cleared=' . $message . '&count=' . $cleared_count . '&tab=cache' ) ); exit; } /** * Validate the service token via admin action. * @return void */ public function validate_service_token() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( __( 'Unauthorized access', 'ai-search' ) ); } check_admin_referer( 'ai_search_validate_token' ); // Check if token exists first $service_token = get_option( 'ai_search_service_token', '' ); if ( empty( $service_token ) ) { wp_redirect( admin_url( 'options-general.php?page=ai-search&token_validation=no_token&tab=general' ) ); exit; } $validation_result = $this->service_client->validate_token(); if ( $validation_result === false ) { wp_redirect( admin_url( 'options-general.php?page=ai-search&token_validation=error&tab=general' ) ); } elseif ( isset( $validation_result['valid'] ) && $validation_result['valid'] === true ) { // Store validation data in transient for display set_transient( 'ai_search_validation_data', $validation_result, 300 ); // 5 minutes wp_redirect( admin_url( 'options-general.php?page=ai-search&token_validation=success&tab=general' ) ); } else { // Invalid token set_transient( 'ai_search_validation_error', $validation_result, 300 ); // 5 minutes wp_redirect( admin_url( 'options-general.php?page=ai-search&token_validation=invalid&tab=general' ) ); } exit; } /** * Clear embedding transients from WordPress cache. * @return int Number of transients cleared. */ private function clear_embedding_transients() { global $wpdb; // Clear ai_search_embedding_ transients $result = $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s", '_transient_ai_search_embedding_%', '_transient_timeout_ai_search_embedding_%' ) ); // Also clear ai_service_embedding_ transients $result += $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s", '_transient_ai_service_embedding_%', '_transient_timeout_ai_service_embedding_%' ) ); return $result; } /** * Clear post meta embeddings. * @return int Number of post meta entries cleared. */ private function clear_post_meta_embeddings() { global $wpdb; $result = $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->postmeta} WHERE meta_key = %s", '_ai_search_embedding' ) ); return $result; } /** * Display settings page. */ /** * Display settings page with tabs. */ public function settings_page() { $active_tab = isset( $_GET['tab'] ) ? sanitize_text_field( $_GET['tab'] ) : 'general'; echo '
'; echo '

AI Search Settings

'; echo ''; if ( $active_tab == 'general' ) { $this->general_settings_page(); } elseif ( $active_tab == 'embeddings' ) { $this->embeddings_settings_page(); } else { $this->cache_management_page(); } echo '
'; } /** * General settings tab. */ private function general_settings_page() { // Handle token validation messages if ( isset( $_GET['token_validation'] ) ) { $validation_status = sanitize_text_field( $_GET['token_validation'] ); if ( $validation_status === 'success' ) { $validation_data = get_transient( 'ai_search_validation_data' ); if ( $validation_data ) { echo '

Token Validation Successful!

'; if ( isset( $validation_data['client'] ) ) { $client = $validation_data['client']; echo ''; } echo '
'; delete_transient( 'ai_search_validation_data' ); } } elseif ( $validation_status === 'invalid' ) { $error_data = get_transient( 'ai_search_validation_error' ); echo '

Token Validation Failed!

'; if ( $error_data && isset( $error_data['details'] ) ) { echo '

Error: ' . esc_html( $error_data['details'] ) . '

'; } echo '
'; delete_transient( 'ai_search_validation_error' ); } elseif ( $validation_status === 'error' ) { echo '

Token Validation Error! Unable to connect to AI Search Service.

'; } elseif ( $validation_status === 'no_token' ) { echo '

No Token Found! Please save your settings first to generate a service token.

'; } } if ( isset( $_POST['provider'], $_POST['api_key'], $_POST['similarity_threshold'] ) ) { check_admin_referer( 'ai_search_save_settings' ); $new_provider = sanitize_text_field( $_POST['provider'] ); $current_provider = get_option( 'ai_search_provider', '' ); // Save the basic settings update_option( 'ai_search_provider', $new_provider ); update_option( 'ai_search_api_key', sanitize_text_field( wp_unslash( $_POST['api_key'] ) ) ); update_option( 'ai_search_similarity_threshold', floatval( $_POST['similarity_threshold'] ) ); // Save service token if provided if ( isset( $_POST['service_token'] ) ) { update_option( 'ai_search_service_token', sanitize_text_field( wp_unslash( $_POST['service_token'] ) ) ); } // Handle AI service registration $registration_result = $this->handle_ai_service_registration( $new_provider ); if ( ! $registration_result['success'] ) { // Registration failed - revert provider setting update_option( 'ai_search_provider', $current_provider ); } // Display the result message $this->display_registration_message( $registration_result ); } $provider = get_option( 'ai_search_provider', $this->api_key ? 'openai' : 'ai_service' ); $api_key = get_option( 'ai_search_api_key', '' ); $service_token = get_option( 'ai_search_service_token', '' ); $similarity_threshold = get_option( 'ai_search_similarity_threshold', 0.5 ); echo '
'; wp_nonce_field( 'ai_search_save_settings' ); echo '
'; echo ''; echo '

Choose between using our external service (no API key needed) or managing embeddings yourself with an OpenAI key. No personal data is stored or used for other purposes.

'; echo '

'; echo '
'; echo '
'; echo ''; echo '
'; echo '
'; echo '
'; echo ''; echo '

This token is automatically generated when you select the AI Service. You can manually update it if needed.

'; // Add validation button if token exists if ( ! empty( $service_token ) ) { echo '
'; echo ''; echo ''; echo '

Click to validate your token and check usage statistics.

'; echo '
'; } echo '
'; echo '

'; echo '
'; echo ''; echo '' . number_format( $similarity_threshold, 3 ) . ''; echo '

Defines how similar a post must be to appear in search results. Higher values (closer to 1.0) mean stricter, more relevant results. Values below 0.5 often return too many irrelevant matches. Default: 0.500.

'; echo '

'; echo ''; echo '
'; echo ''; } /** * Embeddings settings tab. */ private function embeddings_settings_page() { if ( isset( $_GET['embeddings_generated'] ) ) { echo '

Embeddings have been generated for selected posts!

'; $processed_titles = get_transient( 'ai_search_processed_titles' ); if ( $processed_titles && is_array( $processed_titles ) ) { echo '

Embeddings have been generated for the following posts:

'; delete_transient( 'ai_search_processed_titles' ); } } $post_types = get_post_types( [ 'public' => true ], 'objects' ); echo '

Generate Embeddings for Selected CPT

'; echo '

Select a custom post type and click the button to generate embeddings for posts that do not yet have them.

'; echo '
'; echo '
'; echo '

'; echo '
'; echo ''; echo '

Note: Selecting higher values (e.g., 400 or more) may cause timeouts or crash the server depending on your hosting limits. Use with caution.



'; echo ''; 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)

'; } echo '

Cache Management

'; echo '

Manage your AI Search embedding cache. Use these options to clear cached embeddings when needed.

'; // Get cache statistics $stats = $this->get_cache_statistics(); echo '
'; echo '

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 '
'; echo '
'; // Clear transients only echo '
'; echo '

Clear Embedding Cache (Transients)

'; echo '

Clears temporarily cached embeddings. These will be regenerated automatically when needed.

'; echo '
'; wp_nonce_field( 'ai_search_clear_cache' ); echo ''; echo ''; echo '
'; echo '
'; // Clear post meta embeddings echo '
'; echo '

Clear Post Embeddings

'; echo '

Warning: This will remove stored embeddings from all posts. You will need to regenerate them manually.

'; echo '
'; wp_nonce_field( 'ai_search_clear_cache' ); echo ''; echo ''; echo '
'; echo '
'; // Clear everything echo '
'; echo '

Clear All Embedding Data

'; echo '

DANGER: This will remove ALL embedding cache and post meta. AI Search will stop working until you regenerate embeddings.

'; echo '
'; wp_nonce_field( 'ai_search_clear_cache' ); echo ''; echo ''; echo '
'; echo '
'; echo '
'; } /** * Get cache statistics. * @return array Cache statistics. */ private function get_cache_statistics() { global $wpdb; // Count embedding-related transients (approximate) $transients = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s", '_transient_ai_search_embedding_%', '_transient_ai_service_embedding_%' ) ); // Count posts with embeddings $post_meta = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->postmeta} WHERE meta_key = %s", '_ai_search_embedding' ) ); return [ 'transients' => intval( $transients ), 'post_meta' => intval( $post_meta ) ]; } } // Initialize the plugin. AI_Search::get_instance();