/ wordpress / restai / includes / class-restai-knowledge-sync.php
class-restai-knowledge-sync.php
  1  <?php
  2  /**
  3   * Knowledge sync — pushes WordPress posts/pages into the Support Bot RAG
  4   * project so the bot is always current.
  5   *
  6   * Triggered on save_post (debounced) and via a daily cron sweep.
  7   *
  8   * @package RESTai
  9   */
 10  
 11  namespace RESTai;
 12  
 13  if ( ! defined( 'ABSPATH' ) ) {
 14  	exit;
 15  }
 16  
 17  class Knowledge_Sync {
 18  
 19  	/** @var Client */
 20  	private $client;
 21  
 22  	public function __construct( Client $client ) {
 23  		$this->client = $client;
 24  		add_action( 'save_post', array( $this, 'queue_sync_for_post' ), 20, 3 );
 25  		add_action( 'restai_knowledge_sync', array( $this, 'run_full_sync' ) );
 26  	}
 27  
 28  	/**
 29  	 * On save, push (or re-push) the single post into the Support Bot project.
 30  	 */
 31  	public function queue_sync_for_post( $post_id, $post, $update ) {
 32  		$settings = get_option( 'restai_settings', array() );
 33  		if ( empty( $settings['enable_knowledge'] ) ) {
 34  			return;
 35  		}
 36  		if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) {
 37  			return;
 38  		}
 39  		if ( ! in_array( $post->post_status, array( 'publish' ), true ) ) {
 40  			return;
 41  		}
 42  		if ( ! in_array( $post->post_type, array( 'post', 'page' ), true ) ) {
 43  			return;
 44  		}
 45  		$this->push_post( $post );
 46  	}
 47  
 48  	/**
 49  	 * Push a single post as a knowledge base entry. Uses RESTai's
 50  	 * /projects/{id}/embeddings/text endpoint (text ingestion).
 51  	 *
 52  	 * @return true|\WP_Error
 53  	 */
 54  	public function push_post( $post ) {
 55  		$plugin     = Plugin::instance();
 56  		$project_id = (int) $plugin->provisioner->project_for( 'support_bot' );
 57  		if ( $project_id <= 0 ) {
 58  			return new \WP_Error( 'restai_no_support_bot', __( 'Support Bot project is not mapped.', 'restai' ) );
 59  		}
 60  
 61  		$text = wp_strip_all_tags( $post->post_content );
 62  		if ( '' === trim( $text ) ) {
 63  			return true; // nothing to push, treat as a no-op success
 64  		}
 65  
 66  		// Source is the only metadata RESTai's text-ingest accepts; embed the
 67  		// permalink in it so the support bot can cite the URL back.
 68  		$source = (string) get_permalink( $post );
 69  		if ( '' === $source ) {
 70  			$source = 'wp:' . $post->ID;
 71  		}
 72  
 73  		$resp = $this->client->post(
 74  			'projects/' . $project_id . '/embeddings/ingest/text',
 75  			array(
 76  				'text'   => "Title: " . $post->post_title . "\n\n" . $text,
 77  				'source' => $source,
 78  			)
 79  		);
 80  		if ( is_wp_error( $resp ) ) {
 81  			error_log( '[RESTai] knowledge push failed for post ' . $post->ID . ': ' . $resp->get_error_message() );
 82  			return $resp;
 83  		}
 84  		return true;
 85  	}
 86  
 87  	/**
 88  	 * Cron sweep — re-push everything modified since the last sync.
 89  	 */
 90  	public function run_full_sync() {
 91  		$settings = get_option( 'restai_settings', array() );
 92  		if ( empty( $settings['enable_knowledge'] ) ) {
 93  			return;
 94  		}
 95  
 96  		$last  = get_option( 'restai_knowledge_last_sync', 0 );
 97  		$since = $last ? date( 'Y-m-d H:i:s', $last ) : '1970-01-01 00:00:00';
 98  
 99  		$posts = get_posts( array(
100  			'post_type'      => array( 'post', 'page' ),
101  			'post_status'    => 'publish',
102  			'posts_per_page' => 100,
103  			'date_query'     => array( array( 'after' => $since, 'column' => 'post_modified' ) ),
104  		) );
105  
106  		foreach ( $posts as $post ) {
107  			$this->push_post( $post );
108  		}
109  		update_option( 'restai_knowledge_last_sync', time() );
110  	}
111  
112  	/**
113  	 * Force-push every published post and page, ignoring the last-sync
114  	 * timestamp. Used by the "Push all to Support Bot" admin button.
115  	 *
116  	 * @return array { pushed: int, skipped: int, errors: int, error_messages: string[] }
117  	 */
118  	public function run_force_full_sync() {
119  		$result = array(
120  			'pushed'         => 0,
121  			'skipped'        => 0,
122  			'errors'         => 0,
123  			'error_messages' => array(),
124  		);
125  
126  		$plugin     = Plugin::instance();
127  		$project_id = (int) $plugin->provisioner->project_for( 'support_bot' );
128  		if ( $project_id <= 0 ) {
129  			$result['error_messages'][] = __( 'Support Bot project is not mapped — provision it first.', 'restai' );
130  			$result['errors']           = 1;
131  			return $result;
132  		}
133  
134  		$paged    = 1;
135  		$per_page = 50;
136  		while ( true ) {
137  			$posts = get_posts( array(
138  				'post_type'      => array( 'post', 'page' ),
139  				'post_status'    => 'publish',
140  				'posts_per_page' => $per_page,
141  				'paged'          => $paged,
142  				'orderby'        => 'ID',
143  				'order'          => 'ASC',
144  				'no_found_rows'  => false,
145  			) );
146  			if ( empty( $posts ) ) {
147  				break;
148  			}
149  			foreach ( $posts as $post ) {
150  				$pushed = $this->push_post( $post );
151  				if ( true === $pushed ) {
152  					$result['pushed']++;
153  				} elseif ( is_wp_error( $pushed ) ) {
154  					$result['errors']++;
155  					$msg = $pushed->get_error_message();
156  					if ( count( $result['error_messages'] ) < 5 ) {
157  						$result['error_messages'][] = '#' . $post->ID . ': ' . $msg;
158  					}
159  				} else {
160  					$result['skipped']++;
161  				}
162  			}
163  			if ( count( $posts ) < $per_page ) {
164  				break;
165  			}
166  			$paged++;
167  		}
168  
169  		update_option( 'restai_knowledge_last_sync', time() );
170  		return $result;
171  	}
172  }