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 }