logger = $logger; $this->extractor = $extractor; $this->ai = $ai; } public function init() { add_action( 'save_post', [ $this, 'on_save_post' ], 20, 2 ); // Background initial indexing (Lite). This is a lightweight cron-based batch job. add_action( 'ait_airce_run_initial_index', [ $this, 'run_initial_index_batch' ] ); } /** * Schedule initial background indexing if the index is empty. * * This is called from the renderer (when content is displayed) so that the plugin * works “out of the box” without forcing the user to click Tools → Rebuild Index. */ public function schedule_initial_index_if_needed( $reason = '' ) { // Don’t do anything if there is no API key yet. $key = trim( (string) AIT_AIRCE_Settings::get( 'openai_api_key' ) ); if ( '' === $key ) { return false; } // If we already have indexed items, do not schedule. if ( class_exists( 'AIT_AIRCE_DB' ) ) { $db = new AIT_AIRCE_DB(); if ( $db->count_indexed_items() > 0 ) { return false; } } // Avoid spamming cron schedules. if ( get_transient( 'ait_airce_initial_index_scheduled' ) ) { return true; } set_transient( 'ait_airce_initial_index_scheduled', 1, 5 * MINUTE_IN_SECONDS ); // Schedule near-immediate. if ( ! wp_next_scheduled( 'ait_airce_run_initial_index' ) ) { wp_schedule_single_event( time() + 10, 'ait_airce_run_initial_index' ); $this->logger->log( 'Scheduled initial index', [ 'reason' => (string) $reason ] ); } return true; } public function on_save_post( $post_id, $post ) { if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) { return; } if ( ! $post || $post->post_status !== 'publish' ) { return; } $enabled_types = AIT_AIRCE_Settings::get_enabled_post_types(); if ( empty( $enabled_types ) || ! in_array( $post->post_type, $enabled_types, true ) ) { return; } $this->index_post( $post_id ); } /** * Index a single post (hash-check + embedding). */ public function index_post( $post_id ) { global $wpdb; $t_index = esc_sql( AIT_AIRCE_DB::table( 'index_item' ) ); $t_embed = esc_sql( AIT_AIRCE_DB::table( 'embedding' ) ); $post = get_post( $post_id ); if ( ! $post ) { return false; } $text = $this->extractor->extract_for_embedding( $post_id ); if ( empty( $text ) ) { return false; } $hash = md5( $text ); $limit = intval( AIT_AIRCE_Settings::get( 'index_max_items' ) ); $limit = intval( apply_filters( 'ait_airce/index_max_items', $limit ) ); if ( $limit > 0 ) { // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from trusted source $count_active = intval( $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$t_index} WHERE status = %s", 'active' ) ) ); // If this post isn't already indexed and we'd exceed limit, stop. // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from trusted source $existing_id = $wpdb->get_var( $wpdb->prepare( "SELECT id FROM {$t_index} WHERE post_id = %d", $post_id ) ); if ( empty( $existing_id ) && $count_active >= $limit ) { $this->logger->log( 'Index limit reached; skipping', [ 'post_id' => $post_id, 'limit' => $limit ] ); return new WP_Error( 'ait_airce_index_limit', 'Index limit reached.' ); } } // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from trusted source $row = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$t_index} WHERE post_id = %d", $post_id ), ARRAY_A ); if ( $row && $row['content_hash'] === $hash ) { // No change; nothing to do. return true; } $embedding = $this->ai->embed( $text ); if ( is_wp_error( $embedding ) ) { $this->upsert_index_row( $post_id, $post->post_type, $hash, $post->post_modified_gmt, $embedding->get_error_message() ); return $embedding; } $norm = $this->vector_norm( $embedding ); $index_item_id = $this->upsert_index_row( $post_id, $post->post_type, $hash, $post->post_modified_gmt, null, count( $embedding ) ); if ( ! $index_item_id ) { return new WP_Error( 'ait_airce_db_error', 'Failed to write index row.' ); } $wpdb->replace( $t_embed, [ 'index_item_id' => $index_item_id, 'vector_json' => wp_json_encode( $embedding ), 'vector_norm' => $norm, 'created_at_gmt' => gmdate( 'Y-m-d H:i:s' ), ], [ '%d', '%s', '%f', '%s' ] ); $this->logger->log( 'Indexed post', [ 'post_id' => $post_id, 'dim' => count( $embedding ) ] ); // Invalidate cached recommendations for this post (regenerate later). do_action( 'ait_airce_post_indexed', $post_id ); return true; } protected function upsert_index_row( $post_id, $post_type, $hash, $modified_gmt, $error = null, $dim = 0 ) { global $wpdb; $t_index = esc_sql( AIT_AIRCE_DB::table( 'index_item' ) ); $model = AIT_AIRCE_Settings::get( 'embedding_model' ); $wpdb->replace( $t_index, [ 'post_id' => $post_id, 'post_type' => $post_type, 'status' => 'active', 'content_hash' => $hash, 'content_updated_gmt'=> $modified_gmt ? $modified_gmt : gmdate( 'Y-m-d H:i:s' ), 'indexed_at_gmt' => gmdate( 'Y-m-d H:i:s' ), 'model_key' => $model, 'embed_dim' => intval( $dim ), 'index_version' => 1, 'last_error' => $error, ], [ '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%d', '%d', '%s' ] ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from trusted source $id = $wpdb->get_var( $wpdb->prepare( "SELECT id FROM {$t_index} WHERE post_id = %d", $post_id ) ); return $id ? intval( $id ) : 0; } protected function vector_norm( $vec ) { $sum = 0.0; foreach ( $vec as $v ) { $sum += floatval( $v ) * floatval( $v ); } return sqrt( $sum ); } /** * Run a single batch of initial indexing in the background. * * Uses an option-based offset so it can resume across cron calls. */ public function run_initial_index_batch() { // Safety: avoid long-running requests. if ( defined( 'DOING_AJAX' ) && DOING_AJAX ) { return; } $enabled_types = AIT_AIRCE_Settings::get_enabled_post_types(); $enabled_types = array_values( array_filter( array_map( 'sanitize_key', $enabled_types ) ) ); if ( empty( $enabled_types ) ) { return; } $limit = intval( AIT_AIRCE_Settings::get( 'index_max_items' ) ); $limit = intval( apply_filters( 'ait_airce/index_max_items', $limit ) ); if ( $limit <= 0 ) { $limit = 100; } // If we already reached limit, stop. $db = new AIT_AIRCE_DB(); if ( $db->count_indexed_items() >= $limit ) { delete_option( 'ait_airce_initial_index_offset' ); $this->logger->log( 'Initial index complete (limit reached)', [ 'limit' => $limit ] ); return; } $offset = intval( get_option( 'ait_airce_initial_index_offset', 0 ) ); $batch_size = intval( apply_filters( 'ait_airce/initial_index_batch_size', 5 ) ); if ( $batch_size < 1 ) { $batch_size = 5; } $q = new WP_Query( [ 'post_type' => $enabled_types, 'post_status' => 'publish', 'posts_per_page' => $batch_size, 'offset' => $offset, 'orderby' => 'modified', 'order' => 'DESC', 'fields' => 'ids', 'no_found_rows' => false, ] ); if ( empty( $q->posts ) ) { delete_option( 'ait_airce_initial_index_offset' ); $this->logger->log( 'Initial index complete (no more posts)', [] ); return; } $processed = 0; foreach ( (array) $q->posts as $pid ) { if ( $db->count_indexed_items() >= $limit ) { break; } $res = $this->index_post( intval( $pid ) ); if ( is_wp_error( $res ) ) { $this->logger->log( 'Initial index error', [ 'post_id' => (int) $pid, 'error' => $res->get_error_message() ] ); } $processed++; } // Increment offset and schedule another batch. update_option( 'ait_airce_initial_index_offset', $offset + $batch_size ); $this->logger->log( 'Initial index batch', [ 'offset' => $offset, 'processed' => $processed ] ); // Schedule another batch soon. wp_schedule_single_event( time() + 30, 'ait_airce_run_initial_index' ); } }