* @author Maximum Software Inc * @license GPL-3.0-only * @link https://agenistrator.com/ * * SPDX-FileCopyrightText: 2025-2026 Alex Ruban * SPDX-FileCopyrightText: 2025-2026 Maximum Software Inc * SPDX-License-Identifier: GPL-3.0-only */ namespace Agenistrator; use MHz\MysqlVector\VectorTable; // Direct access prevention if ( ! defined( 'ABSPATH' ) ) { exit; } /** * Embeddings_Manager class. */ class Embeddings_Manager extends Manager { /** * Minimum MySQL version required for embeddings. * Required for: Virtual generated columns with indexes. */ const MIN_MYSQL_VERSION = '5.7.8'; /** * Minimum MariaDB version required for embeddings. * Required for: Virtual generated columns with indexes. */ const MIN_MARIADB_VERSION = '10.2.7'; /** * Vector table instance for optimized vector operations (lazy loaded) * @var VectorTable|null */ private $vector_table = null; /** * Constructor - initializes the manager * @since 1.0.0 */ protected function __construct() { // Call parent constructor with option suffix parent::__construct( 'embeddings_settings' ); } /** * Get default configuration * * Returns default embeddings settings values. * * @return array Default configuration * @since 1.0.0 */ protected function get_default_config() { return array( 'current_embedding_model' => '', 'current_embedding_dimension' => 0, 'last_sync_timestamp' => 0, ); } /** * Plugin activation tasks for embeddings * @since 1.0.0 */ public function activate_plugin() { // Call parent method to set default config parent::activate_plugin(); // Schedule sync if embeddings model is configured if ( LLM_Manager::get_instance()->is_embeddings_model_configured() ) { if ( $this->has_existing_content_embeddings() ) { // Reactivation: incremental sync + cleanup orphans $this->schedule_sync_content_embeddings( false, true, true ); } else { // Fresh start: full sync, no cleanup needed $this->schedule_sync_content_embeddings( true, false, false ); } } } /** * Plugin deactivation tasks for embeddings * * Preserve vector table and config on deactivation - only cancel any in-progress operations. * The table is only dropped on uninstall or when user explicitly disables embeddings. * @since 1.0.0 */ public function deactivate_plugin() { $this->cancel_embeddings_generation(); } /** * Uninstall plugin * @since 1.0.0 */ public function uninstall_plugin() { $this->disable_embeddings(); // Call parent method to delete config parent::uninstall_plugin(); } /** * Check if the database server supports required features for embeddings. * * Embeddings require MySQL 5.7.8+ or MariaDB 10.2.7+ for virtual generated * columns with indexes. Anything else is not supported due to mysql-vector * library limitations. * * @return bool True if database is compatible * @since 1.0.0 */ public function is_database_compatible() { $db_info = Main::get_instance()->get_db_type_and_version(); // Check database type and version - only MySQL and MariaDB support embeddings switch ( $db_info['type'] ) { case 'mysql': if ( version_compare( $db_info['version'], self::MIN_MYSQL_VERSION, '<' ) ) { return false; } break; case 'mariadb': if ( version_compare( $db_info['version'], self::MIN_MARIADB_VERSION, '<' ) ) { return false; } break; default: // Unknown database types are not supported return false; } // For MySQL/MariaDB with sufficient version, verify feature support via mysql-vector return $this->make_vector_table( 1 ) ->isFeatureSupported( 'VirtualGeneratedColumns' ); } /** * Check if content embeddings exist in the vector table. * * Used during activation to determine whether to run incremental sync or full sync. * * @return bool True if embeddings exist, false otherwise. */ private function has_existing_content_embeddings() { $stored_dimension = (int) $this->get_config( 'current_embedding_dimension', 0 ); if ( 0 === $stored_dimension ) { return false; } try { $count = $this->get_vector_table()->count( array( '$.collection' => 'content' ) ); return $count > 0; } catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch return false; } } /** * Initialize mysql-vector table with metadata indexes * * @param VectorTable $vector_table The vector table instance * @throws \Exception If database is incompatible or initialization fails */ private function create_database_tables( $vector_table ) { // Check database compatibility before attempting initialization if ( ! $this->is_database_compatible() ) { throw new \Exception( __( 'Your database does not support the features required for embeddings. MySQL 5.7.8+ or MariaDB 10.2.7+ is required.', 'agenistrator' ) // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception messages are not HTML output ); } try { // Clean possibly existing table that wasn't properly deinitialized previously $vector_table->deinitialize(); } catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch // Ignore drop errors } try { $vector_table->initialize( array( '$.collection' => 'VARCHAR(50)', '$.content_type' => 'VARCHAR(50)', '$.content_id' => 'BIGINT', ) ); } catch ( \Throwable $e ) { $error_message = String_Utils::replace_tags( __( 'Agenistrator: Vector table initialization failed: {error}', 'agenistrator' ), array( 'error' => $e->getMessage() ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Production error logging for vector table failures error_log( $error_message ); // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception messages are not HTML output throw new \Exception( String_Utils::replace_tags( __( 'Failed to initialize vector tables: {error}', 'agenistrator' ), array( 'error' => $e->getMessage() ) ) ); // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped } } /** * Disable embeddings by dropping the vector table and clearing config. * * Called when the user selects "None" for embeddings model, and also * during plugin deactivation and uninstall. * This is safe to call even if no vector table exists. * @since 1.0.0 */ public function disable_embeddings() { // Cancel any in-progress embeddings generation $this->cancel_embeddings_generation(); // Get stored dimension to create vector table instance $stored_dimension = (int) $this->get_config( 'current_embedding_dimension', 0 ); // Only try to drop if we have a stored dimension (table was initialized) if ( $stored_dimension > 0 ) { try { $vector_table = $this->make_vector_table( $stored_dimension ); $vector_table->deinitialize(); } catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch // Table might not exist, continue to clear config } } // Clear config state to indicate tables no longer exist $this->set_config( 'current_embedding_model', '' ); $this->set_config( 'current_embedding_dimension', 0 ); $this->save_config(); // Clear cached vector table instance $this->vector_table = null; } /** * Cancel any in-progress embeddings sync or cleanup. * * Cancels all running embeddings operations (sync and cleanup) and clears scheduled cron events. * Safe to call even if no operations are running. * @since 1.0.0 */ public function cancel_embeddings_generation() { // Clear all scheduled cron events for sync and cleanup wp_unschedule_hook( 'agenistrator_sync_content_embeddings_batch' ); wp_unschedule_hook( 'agenistrator_cleanup_orphaned_content_embeddings_batch' ); // Cancel all running embeddings sync operations $running_trackers = Progress_Tracker::get_running( 'embeddings' ); foreach ( $running_trackers as $tracker ) { $tracker->cancel(); } // Cancel all running embeddings cleanup operations $running_cleanup_trackers = Progress_Tracker::get_running( 'embeddings_cleanup' ); foreach ( $running_cleanup_trackers as $tracker ) { $tracker->cancel(); } } /** * Register all embeddings hooks * @since 1.0.0 */ public function register_hooks() { // Post hooks add_action( 'save_post', array( $this, 'save_post' ), 10, 2 ); add_action( 'deleted_post', array( $this, 'deleted_post' ) ); // Content embeddings sync batch action add_action( 'agenistrator_sync_content_embeddings_batch', array( $this, 'sync_content_embeddings_batch_action' ), 10, 6 ); // Orphan embeddings cleanup batch action add_action( 'agenistrator_cleanup_orphaned_content_embeddings_batch', array( $this, 'cleanup_orphaned_content_embeddings_batch_action' ), 10, 3 ); // Single post embeddings sync action add_action( 'agenistrator_sync_post_embeddings', array( $this, 'sync_post_embeddings_action' ), 10, 1 ); } /** * Schedule content embeddings sync via WP-Cron. * * Starts a batch processing operation that processes posts in chunks, * self-scheduling continuation batches until all posts are processed. * * @param bool $force_all If true, regenerate embeddings for all posts even if they already have embeddings. * @param bool $run_cleanup If true, schedule orphan cleanup after sync completes. * @param bool $since_last_sync If true, only process posts modified since last_sync_timestamp (incremental). * If false, walk through all posts to check for consistency. * @throws \Exception If no embeddings model is configured. * @since 1.0.0 */ public function schedule_sync_content_embeddings( $force_all = false, $run_cleanup = false, $since_last_sync = true ) { // Fail early if no embeddings model is configured if ( ! LLM_Manager::get_instance()->is_embeddings_model_configured() ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are not HTML output throw new \Exception( __( 'No embeddings model configured. Please select an embeddings model in settings.', 'agenistrator' ) ); } // Cancel any existing sync before starting a new one $this->cancel_embeddings_generation(); // Determine modified_since timestamp (snapshot at schedule time to avoid race conditions) $modified_since = null; if ( $since_last_sync ) { $modified_since = $this->get_config( 'last_sync_timestamp', null ); // If null or 0, this is first run - will process all posts if ( empty( $modified_since ) ) { $modified_since = null; } } // Get max post ID for progress tracking $max_post_id = $this->get_max_post_id(); if ( 0 === $max_post_id ) { // No posts to process if ( $run_cleanup ) { $this->schedule_cleanup_orphaned_content_embeddings(); } return; } // Start progress tracker $progress_tracker = Progress_Tracker::start( 'embeddings' ); // Schedule the first sync batch wp_schedule_single_event( time() + 2, 'agenistrator_sync_content_embeddings_batch', array( $progress_tracker->get_instance_id(), $modified_since, (bool) $force_all, (bool) $run_cleanup, $max_post_id, 0, // last_post_id cursor ) ); } /** * Cron action handler: process a batch of content embeddings sync. * * Processes posts in batches until time runs out or all posts are processed. * Self-schedules the next batch if more posts remain. * * @param string $instance_id Progress tracker instance ID. * @param int|null $modified_since Only sync posts modified after this Unix timestamp (null = all posts). * @param bool $force_all If true, regenerate all embeddings. * @param bool $run_cleanup If true, schedule orphan cleanup after sync completes. * @param int $max_post_id Maximum post ID for progress calculation (batch state). * @param int $last_post_id Last processed post ID cursor (batch state). * @since 1.0.0 */ public function sync_content_embeddings_batch_action( $instance_id, $modified_since, $force_all, $run_cleanup, $max_post_id, $last_post_id ) { $posts_per_batch = 10; $batch_time_limit = PHP_Utils::get_available_execution_time( 0.5, 15, 300 ); $start_time = time(); try { // Check if embeddings model is still configured if ( ! LLM_Manager::get_instance()->is_embeddings_model_configured() ) { return; } // Restore progress tracker $progress_tracker = Progress_Tracker::get_by_instance_id( $instance_id ); if ( null === $progress_tracker || $progress_tracker->is_cancelled() ) { return; } // Process posts until time runs out or no more posts while ( ( time() - $start_time ) < $batch_time_limit ) { if ( $progress_tracker->is_cancelled() ) { return; } $posts = $this->query_posts_after_id( $last_post_id, $posts_per_batch, $modified_since ); if ( empty( $posts ) ) { // Sync complete $total_posts = $this->count_posts_up_to_id( $max_post_id ); $progress_tracker->complete( $total_posts ); // Schedule cleanup after sync completes (if requested) if ( $run_cleanup ) { $this->schedule_cleanup_orphaned_content_embeddings(); } return; } // process_posts() updates last_sync_timestamp based on post_modified_gmt $this->process_posts( $posts, $force_all ); $last_post_id = end( $posts )->ID; // Update progress $processed_count = $this->count_posts_up_to_id( $last_post_id ); $total_posts = $this->count_posts_up_to_id( $max_post_id ); $progress_tracker->update( $processed_count, $total_posts ); } // Time limit reached - schedule next batch wp_schedule_single_event( time() + 2, // 2 second delay to avoid duplicate detection 'agenistrator_sync_content_embeddings_batch', array( $instance_id, $modified_since, $force_all, $run_cleanup, $max_post_id, $last_post_id ) ); } catch ( \Throwable $e ) { $error_message = String_Utils::replace_tags( __( 'Agenistrator: Content embeddings sync failed: {error}', 'agenistrator' ), array( 'error' => $e->getMessage() ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Production error logging for batch embeddings failures error_log( $error_message ); } } /** * Cron action handler: generate embeddings for a single post. * * @param int $post_id The post ID to generate embeddings for. * @since 1.0.0 */ public function sync_post_embeddings_action( $post_id ) { try { // Check if embeddings model is still configured (may have changed since scheduling) if ( ! LLM_Manager::get_instance()->is_embeddings_model_configured() ) { return; } $post = get_post( $post_id ); if ( ! $post ) { return; } // Only process posts of supported types if ( ! in_array( $post->post_type, array( 'post', 'page' ), true ) ) { return; } $this->process_posts( array( $post ), true ); } catch ( \Throwable $e ) { $error_message = String_Utils::replace_tags( __( 'Agenistrator: Scheduled post embeddings generation failed for post {post_id}: {error}', 'agenistrator' ), array( 'post_id' => $post_id, 'error' => $e->getMessage(), ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Production error logging for post embeddings failures error_log( $error_message ); } } /** * Schedule orphaned content embeddings cleanup via WP-Cron. * * Cleans up vectors for content that no longer exists. Uses batch processing * with pagination to handle large numbers of vectors. */ private function schedule_cleanup_orphaned_content_embeddings() { // Get total vector count for progress tracking try { $total_vectors = $this->get_vector_table()->count( array( '$.collection' => 'content' ) ); } catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch return; // Table doesn't exist or error } if ( 0 === $total_vectors ) { return; // Nothing to clean } // Start progress tracker (separate from sync) $progress_tracker = Progress_Tracker::start( 'embeddings_cleanup' ); // Schedule the first cleanup batch wp_schedule_single_event( time() + 2, 'agenistrator_cleanup_orphaned_content_embeddings_batch', array( $progress_tracker->get_instance_id(), $total_vectors, 0, // offset ) ); } /** * Cron action handler: process a batch of orphaned content embeddings cleanup. * * Uses mysql-vector's selectByMetadata() with pagination to iterate through vectors. * Caches content existence checks per batch for efficiency. * * @param string $instance_id Progress tracker instance ID. * @param int $total_vectors Total vectors for progress calculation. * @param int $offset Current offset in vector table. * @since 1.0.0 */ public function cleanup_orphaned_content_embeddings_batch_action( $instance_id, $total_vectors, $offset ) { $vectors_per_batch = 100; $cache_limit = 1000; // Per content_type $batch_time_limit = PHP_Utils::get_available_execution_time( 0.5, 15, 300 ); $start_time = time(); try { // Check if embeddings model is still configured if ( ! LLM_Manager::get_instance()->is_embeddings_model_configured() ) { return; } // Restore progress tracker $progress_tracker = Progress_Tracker::get_by_instance_id( $instance_id ); if ( null === $progress_tracker || $progress_tracker->is_cancelled() ) { return; } // Bounded FIFO caches indexed by content_type to avoid unbounded memory growth $existence_cache = array(); // content_type => [ content_id => bool ] $deleted_cache = array(); // content_type => [ content_id => true ] // Process vectors until time runs out or no more vectors while ( ( time() - $start_time ) < $batch_time_limit ) { if ( $progress_tracker->is_cancelled() ) { return; } // Fetch batch using library pagination $vectors = $this->get_vector_table()->selectByMetadata( array( '$.collection' => 'content' ), $vectors_per_batch, $offset, array( 'id' => 'ASC' ) ); if ( empty( $vectors ) ) { $progress_tracker->complete( $total_vectors ); return; } $deleted_in_batch = 0; foreach ( $vectors as $vector ) { $metadata = $vector['metadata']; if ( ! isset( $metadata['content_id'] ) ) { continue; } $content_id = (int) $metadata['content_id']; $content_type = $metadata['content_type'] ?? 'post'; // Skip if already deleted earlier (within cache window) if ( isset( $deleted_cache[ $content_type ][ $content_id ] ) ) { ++$deleted_in_batch; continue; } // Check existence (bounded cache with FIFO eviction per content_type) if ( ! isset( $existence_cache[ $content_type ][ $content_id ] ) ) { if ( ! isset( $existence_cache[ $content_type ] ) ) { $existence_cache[ $content_type ] = array(); } if ( count( $existence_cache[ $content_type ] ) >= $cache_limit ) { array_shift( $existence_cache[ $content_type ] ); } $existence_cache[ $content_type ][ $content_id ] = $this->content_exists( $content_type, $content_id ); } if ( ! $existence_cache[ $content_type ][ $content_id ] ) { // Delete immediately - removes vectors for this content_type + content_id $this->delete_content_embeddings( $content_type, $content_id ); // Add to deletion cache (bounded with FIFO eviction per content_type) if ( ! isset( $deleted_cache[ $content_type ] ) ) { $deleted_cache[ $content_type ] = array(); } if ( count( $deleted_cache[ $content_type ] ) >= $cache_limit ) { array_shift( $deleted_cache[ $content_type ] ); } $deleted_cache[ $content_type ][ $content_id ] = true; ++$deleted_in_batch; } } // Adjust offset: subtract deleted vectors to avoid skipping unprocessed vectors $offset += count( $vectors ) - $deleted_in_batch; $progress_tracker->update( $offset, $total_vectors ); } // Time limit reached - schedule next batch wp_schedule_single_event( time() + 2, // 2 second delay to avoid duplicate detection 'agenistrator_cleanup_orphaned_content_embeddings_batch', array( $instance_id, $total_vectors, $offset ) ); } catch ( \Throwable $e ) { $error_message = String_Utils::replace_tags( __( 'Agenistrator: Orphan cleanup failed: {error}', 'agenistrator' ), array( 'error' => $e->getMessage() ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Production error logging for orphan cleanup failures error_log( $error_message ); } } /** * Check if content exists for a given content type and ID. * * @param string $content_type The type of content (e.g., 'post'). * @param int $content_id The content ID. * @return bool True if content exists, false otherwise. */ private function content_exists( $content_type, $content_id ) { switch ( $content_type ) { case 'post': return null !== get_post( $content_id ); // Future content types: 'media', etc. default: // Unknown content type - treat as orphan return false; } } /** * Create or replace the VectorTable instance for the given dimension * * @param int $dimension Vector dimension * @return VectorTable */ private function make_vector_table( $dimension ) { global $wpdb; $table_base_name = $wpdb->prefix . 'agenistrator_embeddings'; return new VectorTable( $wpdb->dbh, $table_base_name, $dimension ); } /** * Get the vector table instance, initializing it lazily if needed. * * @return VectorTable The vector table instance * @throws \Exception If vector table initialization fails or no embeddings model configured */ private function get_vector_table() { $llm_manager = LLM_Manager::get_instance(); // Fail if embeddings model is not configured if ( ! $llm_manager->is_embeddings_model_configured() ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are not HTML output throw new \Exception( __( 'No embeddings model configured', 'agenistrator' ) ); } // Determine current model $current_model = $llm_manager->get_embeddings_model_id(); // Load stored state $stored_model = $this->get_config( 'current_embedding_model', '' ); $stored_dimension = (int) $this->get_config( 'current_embedding_dimension', 0 ); // Determine current dimension (only fetch dimension if model changed or not yet stored) // We are storing the dimension to 'cache' it to avoid repeated expensive get_embedding_model() calls // This works fine because normally the dimension does not change for a given model if ( $stored_model !== $current_model || 0 === $stored_dimension ) { // Model changed or set for the first time, we need to get dimension $current_dimension = $llm_manager->get_embedding_model()->get_dimension(); } else { // Use previously stored dimension from config $current_dimension = $stored_dimension; } // Ensure instance exists if ( null === $this->vector_table ) { $this->vector_table = $this->make_vector_table( $current_dimension ); } // Detect state changes $new_vector_table = '' === $stored_model || 0 === $stored_dimension; $model_changed = ( $stored_model !== $current_model ); // Handle table initialization or changes if ( $new_vector_table || $model_changed ) { try { // Model changed or first time - recreate vector table $this->create_database_tables( $this->vector_table ); // drops vector table if it already exists // Persist new state $this->set_config( 'current_embedding_model', $current_model ); $this->set_config( 'current_embedding_dimension', $current_dimension ); $this->save_config(); } catch ( \Throwable $e ) { $error_message = String_Utils::replace_tags( __( 'Embedding model switch failed: {error}', 'agenistrator' ), array( 'error' => $e->getMessage() ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Production error logging for model switch failures error_log( $error_message ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are not HTML output throw new \Exception( $error_message ); } } return $this->vector_table; } /** * Generate embedding for a single post. * * @param int $post_id The post ID. * @param WP_Post $post The post object. * @param boolean $update Whether this is an existing post being updated or not. * @since 1.0.0 */ public function save_post( $post_id, $post = null ) { // If this is an autosave, don't do anything // WordPress creates a separate revision with a post status of auto-draft // The original post is not modified, so, we should ignore the action if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) { return; } // Only generate embeddings for posts and pages $post_type = $post ? $post->post_type : get_post_type( $post_id ); if ( ! in_array( $post_type, array( 'post', 'page' ), true ) ) { return; } // Skip if embeddings model is not configured if ( ! LLM_Manager::get_instance()->is_embeddings_model_configured() ) { return; } // TODO: Determine what to do if embeddings regeneration is in progress, there may be a race condition caused by trying to generate embeddings twice for the same post at the same time. // Schedule embedding sync via WP-Cron to avoid blocking post saves. // Clear any existing scheduled event for this post first, so that rapid saves // result in only the final content being processed. $args = array( $post_id ); wp_clear_scheduled_hook( 'agenistrator_sync_post_embeddings', $args ); wp_schedule_single_event( time() + 2, 'agenistrator_sync_post_embeddings', $args ); } /** * Delete post embeddings when post is deleted. * * @param int $post_id The post ID. * @since 1.0.0 */ public function deleted_post( $post_id ) { try { $this->delete_content_embeddings( 'post', $post_id ); } catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch // Silently fail - embedding deletion should not block post deletion } } /** * Delete all embeddings for a specific content item. * * @param string $content_type The content type (e.g., 'post'). * @param int $content_id The content ID. */ private function delete_content_embeddings( $content_type, $content_id ) { $this->get_vector_table()->deleteByMetadata( array( '$.content_id' => (int) $content_id, '$.content_type' => (string) $content_type, '$.collection' => 'content', ) ); } /** * Get the maximum post ID for supported post types. * * Used for progress tracking in batch processing. * * @return int Maximum post ID, or 0 if no posts exist. */ private function get_max_post_id() { global $wpdb; $post_types = "'post', 'page'"; $excluded_statuses = "'inherit', 'auto-draft', 'trash'"; $query = "SELECT MAX(ID) FROM {$wpdb->posts} WHERE post_type IN ({$post_types}) AND post_status NOT IN ({$excluded_statuses})"; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Query uses only hardcoded string literals and $wpdb->posts table name; caching not applicable. $max_id = $wpdb->get_var( $query ); return (int) $max_id; } /** * Count posts with ID up to and including the given value. * * Used for accurate progress tracking - counts actual posts rather than using * post IDs which may have gaps due to deleted posts. * * @param int $max_id Maximum post ID to count up to (inclusive). * @return int Number of posts with ID <= $max_id. */ private function count_posts_up_to_id( $max_id ) { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Progress tracking query; caching not applicable. return (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->posts} WHERE ID <= %d AND post_type IN ('post', 'page') AND post_status NOT IN ('inherit', 'auto-draft', 'trash')", $max_id ) ); } /** * Query posts with ID greater than the given value. * * @param int $last_post_id The last processed post ID (cursor). * @param int $limit Maximum number of posts to return. * @param int|null $modified_since Only return posts modified after this Unix timestamp (null = all posts). * @return \WP_Post[] Array of post objects. */ private function query_posts_after_id( $last_post_id, $limit, $modified_since = null ) { $args = array( 'post_type' => array( 'post', 'page' ), 'post_status' => 'any', 'posts_per_page' => $limit, 'orderby' => 'ID', 'order' => 'ASC', '_agenistrator_id_gt' => (int) $last_post_id, '_agenistrator_mod_since' => $modified_since, ); $filter_callback = function ( $where, $query ) { global $wpdb; // ID > cursor $id = $query->get( '_agenistrator_id_gt' ); if ( is_int( $id ) && $id >= 0 ) { $where .= $wpdb->prepare( " AND {$wpdb->posts}.ID > %d", $id ); } // post_modified_gmt >= timestamp (use GMT column for timezone safety) $mod_since = $query->get( '_agenistrator_mod_since' ); if ( ! empty( $mod_since ) && is_int( $mod_since ) ) { $where .= $wpdb->prepare( " AND {$wpdb->posts}.post_modified_gmt >= %s", gmdate( 'Y-m-d H:i:s', $mod_since ) ); } return $where; }; add_filter( 'posts_where', $filter_callback, 10, 2 ); $query = new \WP_Query( $args ); remove_filter( 'posts_where', $filter_callback, 10 ); return $query->posts; } /** * Process multiple posts for embedding generation using chunk-based batching. * * This method extracts chunks from posts and processes them in batches to * leverage batch processing capabilities of provider APIs efficiently. * * Each chunk's metadata includes: * - collection, content_type, content_id: Standard identifiers * - chunk_source: 'title', 'excerpt', or 'content' * - chunk_position: Position in markdown content (null for title/excerpt) * - chunk_length: Length of chunk in characters (null for title/excerpt) * - content_md5: MD5 hash of original post_content for change detection * * @param array $posts Array of WP_Post objects to process * @param bool $force_update Whether to force update existing embeddings */ private function process_posts( $posts, $force_update = false ) { if ( empty( $posts ) ) { return; } $batch_size = 100; // Batch size for embedding generation $chunk_batch = array(); // Current batch of items (max $batch_size) $max_post_modified_gmt = 0; // Track max post_modified_gmt for timestamp update foreach ( $posts as $post ) { try { // Track max post_modified_gmt for timestamp update (true Unix timestamp) $post_modified_gmt_ts = get_post_timestamp( $post, 'modified' ); $max_post_modified_gmt = max( $max_post_modified_gmt, false !== $post_modified_gmt_ts ? $post_modified_gmt_ts : 0 ); // Build base metadata for this post $base_metadata = array( 'collection' => 'content', 'content_type' => 'post', 'content_id' => $post->ID, ); if ( ! $force_update && $this->embedding_exists( $base_metadata ) ) { continue; } // Delete existing embeddings for this post $this->delete_content_embeddings( 'post', $post->ID ); // Skip embedding generation for statuses that should not have embeddings. $excluded_statuses = array( 'inherit', 'auto-draft', 'trash' ); if ( in_array( $post->post_status, $excluded_statuses, true ) ) { continue; } // Extract chunks from this post $result = Chunk_Extractor::get_instance()->extract_chunks( $post ); $chunks = $result['chunks']; $content_md5 = $result['content_md5']; // Build items with metadata for each chunk foreach ( $chunks as $chunk_data ) { $chunk_metadata = array_merge( $base_metadata, array( 'chunk_source' => $chunk_data['source'], 'chunk_position' => $chunk_data['position'], 'chunk_length' => $chunk_data['length'], 'content_md5' => $content_md5, ) ); $chunk_batch[] = array( 'chunk' => $chunk_data['content'], 'metadata' => $chunk_metadata, ); // Process batch when it reaches the configured batch size if ( count( $chunk_batch ) >= $batch_size ) { $this->store_chunk_batch( $chunk_batch ); $chunk_batch = array(); } } } catch ( \Throwable $e ) { $error_message = String_Utils::replace_tags( __( 'Agenistrator: Failed to process post {post_id} for embeddings: {error}', 'agenistrator' ), array( 'post_id' => $post->ID, 'error' => $e->getMessage(), ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Production error logging for embedding generation failures error_log( $error_message ); } } // Process remaining chunks in final batch if ( ! empty( $chunk_batch ) ) { $this->store_chunk_batch( $chunk_batch ); } // Update last_sync_timestamp (only advance, never regress) if ( $max_post_modified_gmt > 0 ) { $current_ts = (int) $this->get_config( 'last_sync_timestamp', 0 ); if ( $max_post_modified_gmt > $current_ts ) { $this->set_config( 'last_sync_timestamp', $max_post_modified_gmt ); $this->save_config(); } } } /** * Store a batch of chunks by generating embeddings and storing them. * * @param array $items Array of items, each with 'chunk' and 'metadata' keys */ private function store_chunk_batch( $items ) { if ( empty( $items ) ) { return; } try { // Extract chunks for embedding generation $chunks = array_column( $items, 'chunk' ); // Generate embeddings for all chunks in batch $embedding_model = LLM_Manager::get_instance()->get_embedding_model(); $embeddings = $embedding_model->generate_embeddings( $chunks ); // Prepare batch insert items with metadata $vector_items = array(); foreach ( $embeddings as $index => $embedding ) { if ( isset( $items[ $index ] ) && is_array( $embedding ) ) { $vector_items[] = array( 'vector' => $embedding, 'metadata' => $items[ $index ]['metadata'], ); } } if ( ! empty( $vector_items ) ) { $this->get_vector_table()->batchInsert( $vector_items ); } } catch ( \Throwable $e ) { $error_message = String_Utils::replace_tags( __( 'Agenistrator: Chunk batch storage failed: {error}', 'agenistrator' ), array( 'error' => $e->getMessage() ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Production error logging for chunk batch failures error_log( $error_message ); } } /** * Check if an embedding exists for the given metadata. * * @param array $metadata The metadata to check (e.g., ['collection' => 'content', 'content_id' => 123, 'content_type' => 'post']) * @return bool True if embedding exists, false otherwise */ private function embedding_exists( $metadata ) { // Build metadata filter from provided metadata $filter = array(); foreach ( $metadata as $key => $value ) { $filter[ '$.' . $key ] = $value; } $vectors = $this->get_vector_table()->selectByMetadata( $filter, 1 ); return ! empty( $vectors ); } /** * Check if semantic search is operational. * * Verifies that embeddings model is configured AND vector tables have been * initialized (stored model/dimension exist in config). * * @return bool True if semantic search can be performed * @since 1.0.0 */ public function is_search_operational() { if ( ! LLM_Manager::get_instance()->is_embeddings_model_configured() ) { return false; } $stored_model = $this->get_config( 'current_embedding_model', '' ); $stored_dimension = (int) $this->get_config( 'current_embedding_dimension', 0 ); return '' !== $stored_model && $stored_dimension > 0; } /** * Get the count of stored embeddings. * * @param string|null $collection Optional collection to filter by * @return int The number of stored embeddings. * @since 1.0.0 */ public function get_embeddings_count( $collection = null ) { // Fast-path: return 0 if database doesn't support embeddings if ( ! $this->is_database_compatible() ) { return 0; } try { $conditions = null !== $collection ? array( '$.collection' => $collection ) : null; return $this->get_vector_table()->count( $conditions ); } catch ( \Throwable $e ) { // Return 0 if no embeddings model configured or any other error return 0; } } /** * Search for similar content using semantic embeddings. * * @param string $query The search query string. * @param int $limit Maximum number of results. * @return array Array of raw results with content_type and content_id. * @throws \Exception If embeddings not configured or search fails. * @since 1.0.0 */ public function search_similar_content( $query, $limit = 5 ) { // Get the embedding model from the LLM Manager $embedding_model = LLM_Manager::get_instance()->get_embedding_model(); $query_embeddings = $embedding_model->generate_embeddings( array( $query ) ); // Validate the embedding result if ( ! is_array( $query_embeddings ) || empty( $query_embeddings ) || ! is_array( $query_embeddings[0] ) ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are not HTML output throw new \Exception( __( 'Query embedding generation failed', 'agenistrator' ) ); } return $this->find_similar_content( $query_embeddings[0], $limit ); } /** * Find similar embeddings using mysql-vector optimized search with similarity threshold filtering. * * @param array $query_embedding The query embedding. * @param string $collection The collection to search in. * @param int $limit The maximum number of results to return. * @return array Array of metadata from similar vectors. */ private function find_similar_embeddings( $query_embedding, $collection, $limit = 10 ) { // Minimum cosine similarity threshold $min_similarity = 0.3; $metadata_filter = array( '$.collection' => $collection ); $vector_results = $this->get_vector_table()->search( $query_embedding, $metadata_filter, $limit, null, $min_similarity ); if ( empty( $vector_results ) ) { return array(); } $results = array(); foreach ( $vector_results as $vector_result ) { if ( isset( $vector_result['metadata'] ) ) { $results[] = $vector_result['metadata']; } } return $results; } /** * Find similar content from the 'content' collection with deduplication. * * Groups results by content_id while preserving all matching chunk metadata. * * @param array $query_embedding The query embedding. * @param int $limit The maximum number of unique content items to return. * @return array Array of content results grouped by content_id, each with 'chunks' array. */ private function find_similar_content( $query_embedding, $limit = 10 ) { // Account for multiple vectors per content item (title, excerpt, sentences) // Each content item can potentially be mapped to multiple vectors, so we need to search more // vectors than the requested limit to find roughly the requested number of unique content items $vectors_per_content_item = 10; // Assume ~10 vectors per content item on average $vector_search_limit = $limit * $vectors_per_content_item; // Get raw results from the low-level method $vector_results = $this->find_similar_embeddings( $query_embedding, 'content', $vector_search_limit ); if ( empty( $vector_results ) ) { return array(); } // Group results by content_id, collecting all matching chunks $results = array(); foreach ( $vector_results as $metadata ) { if ( ! isset( $metadata['content_type'], $metadata['content_id'] ) ) { continue; } $content_key = $metadata['content_type'] . '_' . $metadata['content_id']; // Initialize new content item if not seen if ( ! isset( $results[ $content_key ] ) ) { // Stop if we've reached the limit of unique content items if ( count( $results ) >= $limit ) { break; } $results[ $content_key ] = array( 'content_id' => $metadata['content_id'], 'content_type' => $metadata['content_type'], 'chunks' => array(), ); } // Add chunk metadata $results[ $content_key ]['chunks'][] = array( 'source' => $metadata['chunk_source'] ?? null, 'position' => $metadata['chunk_position'] ?? null, 'length' => $metadata['chunk_length'] ?? null, 'content_md5' => $metadata['content_md5'] ?? null, ); } return array_values( $results ); } /** * Reconstruct a chunk from a post based on stored metadata. * * @param \WP_Post $post The post object * @param array $chunk_meta Chunk metadata with 'source', 'position', 'length', 'content_md5' * @return string|null The chunk content, or null if content has changed * @since 1.0.0 */ public function get_chunk( $post, $chunk_meta ) { return Chunk_Extractor::get_instance()->reconstruct_chunk( $post, $chunk_meta['source'] ?? '', $chunk_meta['position'] ?? null, $chunk_meta['length'] ?? null, $chunk_meta['content_md5'] ?? null ); } /** * Delete all embeddings (optionally filtered by collection) * * @param string|null $collection Optional collection to filter by. * @throws \Exception If no embeddings model configured or deletion fails * @since 1.0.0 */ public function delete_all_embeddings( $collection = null ) { // get_vector_table() will throw if no embeddings model configured if ( null === $collection ) { // Delete all embeddings across all collections $this->get_vector_table()->truncate(); } else { // Delete embeddings for specific collection $this->get_vector_table()->deleteByMetadata( array( '$.collection' => $collection, ) ); } } }