/ wordpress / restai / includes / class-restai-provisioner.php
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  }