class-restai-provisioner.php
1 <?php 2 /** 3 * Auto-provision starter RESTai projects on first connect. 4 * 5 * Each task the plugin performs maps to one RESTai project so the user can 6 * tune model/prompt/budget per task. The provisioner creates a sensible 7 * default project for each task; the settings page lets the admin re-map any 8 * task to a different existing project later. 9 * 10 * @package RESTai 11 */ 12 13 namespace RESTai; 14 15 if ( ! defined( 'ABSPATH' ) ) { 16 exit; 17 } 18 19 class Provisioner { 20 21 /** @var Client */ 22 private $client; 23 24 public function __construct( Client $client ) { 25 $this->client = $client; 26 } 27 28 /** 29 * Definitions of the starter projects, keyed by task slug. 30 * 31 * @return array 32 */ 33 public static function definitions() { 34 return array( 35 'content_writer' => array( 36 'name' => 'wp-content-writer', 37 'type' => 'agent', 38 'system' => 'You are a senior WordPress content writer. Given a brief, write engaging, well-structured blog content in HTML using <h2>, <h3>, <p>, <ul>, <strong>. Never include <html>, <head> or <body> tags. Match the requested tone and length.', 39 'label' => __( 'Content Writer', 'restai' ), 40 ), 41 'seo_assistant' => array( 42 'name' => 'wp-seo-assistant', 43 'type' => 'agent', 44 'system' => 'You are an SEO expert. Given a post title and body, return a JSON object with keys: meta_title (max 60 chars), meta_description (max 155 chars), focus_keyphrase (1-3 words), suggested_tags (array of 3-7 tags), readability_notes (brief). Output ONLY the JSON, no preamble.', 45 'label' => __( 'SEO Assistant', 'restai' ), 46 ), 47 'translator' => array( 48 'name' => 'wp-translator', 49 'type' => 'agent', 50 'system' => 'You are a professional translator. Translate the input HTML into the requested language. Preserve all HTML tags, attributes and structure. Return only the translated HTML, no preamble or explanation.', 51 'label' => __( 'Translator', 'restai' ), 52 ), 53 'comment_moderator' => array( 54 'name' => 'wp-comment-moderator', 55 'type' => 'agent', 56 'system' => 'You are a comment moderator. Given a comment, return a JSON object: { "spam": true|false, "toxic": true|false, "sentiment": "positive|neutral|negative", "suggested_reply": "..." }. Output ONLY the JSON.', 57 'label' => __( 'Comment Moderator', 'restai' ), 58 ), 59 'support_bot' => array( 60 'name' => 'wp-support-bot', 61 'type' => 'rag', 62 'system' => 'You are a helpful support assistant for this website. Answer using only the provided context. If the answer is not in the context, say you do not have that information and suggest contacting the team.', 63 'label' => __( 'Support Bot (RAG)', 'restai' ), 64 ), 65 'product_writer' => array( 66 'name' => 'wp-product-writer', 67 'type' => 'agent', 68 'system' => 'You are an e-commerce copywriter. Given product attributes, generate a compelling product description (2-3 short paragraphs) and a 5-bullet feature list. Persuasive, specific, no fluff.', 69 'label' => __( 'Product Writer (WooCommerce)', 'restai' ), 70 ), 71 'email_personalizer' => array( 72 'name' => 'wp-email-personalizer', 73 'type' => 'agent', 74 'system' => 'You rewrite transactional emails to feel personal and on-brand. Keep all dynamic placeholders intact (anything inside curly braces). Match the original intent and length.', 75 'label' => __( 'Email Personalizer', 'restai' ), 76 ), 77 'image_alt' => array( 78 'name' => 'wp-image-alt', 79 'type' => 'agent', 80 'system' => 'You write concise, descriptive alt text for images. Return only the alt text, no quotes or preamble. Max 125 characters.', 81 'label' => __( 'Image Alt Writer', 'restai' ), 82 ), 83 ); 84 } 85 86 /** 87 * Map of task slug => RESTai project ID (int). 88 */ 89 public function get_project_map() { 90 $map = get_option( 'restai_project_map', array() ); 91 return is_array( $map ) ? $map : array(); 92 } 93 94 /** 95 * Save the project map. 96 * 97 * @param array $map 98 */ 99 public function save_project_map( $map ) { 100 update_option( 'restai_project_map', $map ); 101 } 102 103 /** 104 * Return the project name (or id) currently mapped to a given task slug. 105 * 106 * @param string $task 107 * @return string|null 108 */ 109 /** 110 * @param string $task one of the keys in self::definitions(). 111 * @return int the RESTai project id mapped to that task, or 0 when none. 112 */ 113 public function project_for( $task ) { 114 $map = $this->get_project_map(); 115 return isset( $map[ $task ] ) ? (int) $map[ $task ] : 0; 116 } 117 118 /** 119 * Provision any missing starter projects on the connected RESTai instance. 120 * 121 * Idempotent: if a project with the conventional name already exists, we 122 * just record it in the map. Tasks the user has already mapped manually 123 * are left alone. 124 * 125 * @return array { created: string[], existing: string[], errors: array } 126 */ 127 public function provision_all() { 128 $results = array( 129 'created' => array(), 130 'existing' => array(), 131 'errors' => array(), 132 'warnings' => array(), 133 ); 134 135 $settings = get_option( 'restai_settings', array() ); 136 $team_id = isset( $settings['team_id'] ) ? (int) $settings['team_id'] : 0; 137 if ( $team_id <= 0 ) { 138 $results['errors']['no_team'] = __( 'Select a team in Settings → RESTai before provisioning.', 'restai' ); 139 return $results; 140 } 141 142 $existing = $this->client->get( 'projects' ); 143 $by_name = array(); 144 if ( ! is_wp_error( $existing ) && isset( $existing['projects'] ) && is_array( $existing['projects'] ) ) { 145 foreach ( $existing['projects'] as $p ) { 146 if ( isset( $p['name'] ) ) { 147 $by_name[ $p['name'] ] = $p; 148 } 149 } 150 } 151 152 $map = $this->get_project_map(); 153 154 foreach ( self::definitions() as $task => $def ) { 155 // User already chose a project for this task — leave it alone. 156 if ( ! empty( $map[ $task ] ) ) { 157 $results['existing'][ $task ] = $map[ $task ]; 158 continue; 159 } 160 161 $name = $def['name']; 162 163 // Project with the conventional name exists — adopt it and re-sync prompt. 164 if ( isset( $by_name[ $name ] ) && isset( $by_name[ $name ]['id'] ) ) { 165 $existing_id = (int) $by_name[ $name ]['id']; 166 $map[ $task ] = $existing_id; 167 $results['existing'][ $task ] = $existing_id; 168 $this->sync_system_prompt( $existing_id, $def['system'], $results, $task ); 169 continue; 170 } 171 172 // Need to create — pick the first LLM the user has access to. 173 $llm = $this->pick_default_llm(); 174 if ( null === $llm ) { 175 $results['errors'][ $task ] = __( 'No LLM available — add one in RESTai first.', 'restai' ); 176 continue; 177 } 178 179 $payload = array( 180 'name' => $name, 181 'human_name' => $def['label'], 182 'type' => $def['type'], 183 'llm' => $llm, 184 'team_id' => $team_id, 185 ); 186 187 // RAG projects need an embeddings model + vectorstore. 188 if ( 'rag' === $def['type'] ) { 189 $emb = $this->pick_default_embeddings(); 190 if ( null === $emb ) { 191 $results['errors'][ $task ] = __( 'No embeddings model available — skipping RAG project.', 'restai' ); 192 continue; 193 } 194 $payload['embeddings'] = $emb; 195 $payload['vectorstore'] = 'chroma'; 196 } 197 198 $resp = $this->client->post( 'projects', $payload ); 199 if ( is_wp_error( $resp ) ) { 200 $results['errors'][ $task ] = $resp->get_error_message(); 201 continue; 202 } 203 204 // POST /projects returns { "project": <id> }. 205 $new_id = isset( $resp['project'] ) ? (int) $resp['project'] : 0; 206 if ( $new_id <= 0 ) { 207 $results['errors'][ $task ] = __( 'Project created but no id returned.', 'restai' ); 208 continue; 209 } 210 211 $map[ $task ] = $new_id; 212 $results['created'][ $task ] = $new_id; 213 214 // Set the system prompt via PATCH (not accepted at creation time). 215 $this->sync_system_prompt( $new_id, $def['system'], $results, $task ); 216 } 217 218 $this->save_project_map( $map ); 219 update_option( 'restai_install_signature', md5( wp_json_encode( $map ) . RESTAI_VERSION ) ); 220 221 return $results; 222 } 223 224 /** 225 * Update a project's system prompt. Used for both freshly created and 226 * adopted projects so the canonical prompt in definitions() stays the 227 * source of truth. 228 */ 229 private function sync_system_prompt( $project_id, $system, &$results, $task ) { 230 $resp = $this->client->patch( 231 'projects/' . (int) $project_id, 232 array( 'system' => $system ) 233 ); 234 if ( is_wp_error( $resp ) ) { 235 $results['warnings'][ $task ] = sprintf( 236 /* translators: %s error message */ 237 __( 'Project ready but prompt not synced: %s', 'restai' ), 238 $resp->get_error_message() 239 ); 240 } 241 } 242 243 /** 244 * Pick a default LLM name. Prefers a fast/cheap model when available. 245 */ 246 private function pick_default_llm() { 247 $llms = $this->client->get( 'llms' ); 248 if ( is_wp_error( $llms ) || ! is_array( $llms ) ) { 249 return null; 250 } 251 $names = array(); 252 foreach ( $llms as $llm ) { 253 if ( isset( $llm['name'] ) ) { 254 $names[] = $llm['name']; 255 } 256 } 257 if ( empty( $names ) ) { 258 return null; 259 } 260 // Prefer common cheap models if present. 261 $preferred = array( 'gpt-4o-mini', 'gpt-4o', 'claude-3-5-haiku', 'gemini-2.0-flash' ); 262 foreach ( $preferred as $p ) { 263 foreach ( $names as $n ) { 264 if ( stripos( $n, $p ) !== false ) { 265 return $n; 266 } 267 } 268 } 269 return $names[0]; 270 } 271 272 /** 273 * Pick a default embeddings model name. 274 */ 275 private function pick_default_embeddings() { 276 $embs = $this->client->get( 'embeddings' ); 277 if ( is_wp_error( $embs ) || ! is_array( $embs ) ) { 278 return null; 279 } 280 $names = array(); 281 foreach ( $embs as $e ) { 282 if ( isset( $e['name'] ) ) { 283 $names[] = $e['name']; 284 } 285 } 286 return empty( $names ) ? null : $names[0]; 287 } 288 }